diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..1c63810a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,44 @@ +# ========================================================= +# .dockerignore +# ========================================================= + +# Environment and secrets +.env +.env.* +*.env + +# Logs +*.log +*.log.* + +# Git +.git/ +.gitignore + +# IDE and OS +.vscode/ +.idea/ +.DS_Store +Thumbs.db +._* +*~ +\#*# +\#* + + +# Temporary files +*.tmp +*.temp +*.bak +*.backup + +# Virtual environment +.venv +venv + +#Python cache files +__pycache__ +*.pyc + +dist +build diff --git a/.github/workflows/black.yml b/.github/workflows/black.yml deleted file mode 100644 index 93ebb225..00000000 --- a/.github/workflows/black.yml +++ /dev/null @@ -1,12 +0,0 @@ -name: Lint - -on: [push, pull_request] - -jobs: - lint: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: psf/black@stable - with: - src: "./hippo" diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml new file mode 100644 index 00000000..4b553de8 --- /dev/null +++ b/.github/workflows/lint.yaml @@ -0,0 +1,25 @@ +--- +name: Lint + +on: + push: + branches: + - '*' + tags-ignore: + - '*' + pull_request: + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Install uv + uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + - name: Install dependencies + run: uv sync --frozen + - name: Repeat pre-commit + run: uv run pre-commit run --all-files diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml deleted file mode 100644 index efb488c2..00000000 --- a/.github/workflows/pytest.yml +++ /dev/null @@ -1,42 +0,0 @@ -# This workflow will install Python dependencies, run tests and lint with a single version of Python -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python - -name: Python application - -on: - push: - branches: [ "main" ] - pull_request: - branches: [ "main" ] - -permissions: - contents: read - -jobs: - build: - - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v3 - - name: Set up Python 3.10 - uses: actions/setup-python@v3 - with: - python-version: "3.10" - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install rdkit==2023.9.1 molparse>=0.0.13 tqdm jupyterlab pycule chardet pandas - conda install -c conda-forge -y chemicalite - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - # - name: Lint with flake8 - # run: | - # # stop the build if there are Python syntax errors or undefined names - # flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - # # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - # flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - - name: Test with pytest - run: | - #pytest - cd tests - python test_A71EV2A.py diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml deleted file mode 100644 index bdaab28a..00000000 --- a/.github/workflows/python-publish.yml +++ /dev/null @@ -1,39 +0,0 @@ -# This workflow will upload a Python Package using Twine when a release is created -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries - -# This workflow uses actions that are not certified by GitHub. -# They are provided by a third-party and are governed by -# separate terms of service, privacy policy, and support -# documentation. - -name: Upload Python Package - -on: - release: - types: [published] - -permissions: - contents: read - -jobs: - deploy: - - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v3 - - name: Set up Python - uses: actions/setup-python@v3 - with: - python-version: '3.x' - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install build - - name: Build package - run: python -m build - - name: Publish package - uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 - with: - user: __token__ - password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 00000000..7eb60e85 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,58 @@ +--- +# The standard xchem Python package release process. +# Run on 'Release' and published to PyPI as a 'trusted' publisher. +# +# See https://docs.pypi.org/trusted-publishers/creating-a-project-through-oidc/ +# See https://packaging.python.org/en/latest/guides/publishing-package-distribution-releases-using-github-actions-ci-cd-workflows/ +name: Release + +on: + release: + types: + - published + +jobs: + build: + name: Build distribution + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.x' + - name: Install build package + run: | + python3 -m pip install --upgrade pip + python3 -m pip install uv==0.10.6 --user + - name: Build + run: | + uv version ${{ github.ref_name }} + uv build + - name: Store the distribution + uses: actions/upload-artifact@v5 + with: + name: python-package-distribution + path: dist/ + + publish: + name: Publish to PyPI + needs: + - build + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/xchem-hippo + permissions: + id-token: write + steps: + - name: Download distribution + uses: actions/download-artifact@v6 + with: + name: python-package-distribution + path: dist/ + - name: Publish + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 00000000..6f096c4b --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,36 @@ +--- +name: Test + +on: + push: + branches: + - '*' + tags-ignore: + - '*' + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + # Test is broken - fix and remove this condition + if: false + steps: + - uses: actions/checkout@v6 + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.10" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install rdkit==2023.9.1 molparse>=0.0.13 tqdm jupyterlab pycule chardet pandas + conda install -c conda-forge -y chemicalite + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + - name: Test + run: | + #pytest + cd tests + python test_A71EV2A.py diff --git a/.gitignore b/.gitignore index f27a22d8..819d59d5 100644 --- a/.gitignore +++ b/.gitignore @@ -138,6 +138,10 @@ celerybeat.pid .venv env/ venv/ + +# Local docker-compose overrides (per-developer, not shared) +docker-compose.override.yaml +docker-compose.override.yml ENV/ env.bak/ venv.bak/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e35b4559..4666b1b8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,11 +1,44 @@ +--- + repos: - # Using this mirror lets us use mypyc-compiled black, which is about 2x faster - - repo: https://github.com/psf/black-pre-commit-mirror - rev: 25.1.0 + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 hooks: - - id: black - # It is recommended to specify the latest version of Python - # supported by your project here, or alternatively use - # pre-commit's default_language_version, see - # https://pre-commit.com/#top_level-default_language_version - language_version: python3 + - id: check-yaml + - id: check-case-conflict + - id: check-executables-have-shebangs + - id: check-shebang-scripts-are-executable + - id: detect-private-key + - id: end-of-file-fixer + - id: trailing-whitespace + args: + - --markdown-linebreak-ext=md + + + # project tools using uv local environment + - repo: local + hooks: + + - id: ruff + name: ruff lint + entry: uv run ruff check --fix + language: system + types: [python] + + - id: ruff-format + name: ruff format + entry: uv run ruff format + language: system + types: [python] + + # - id: mypy + # name: mypy + # entry: uv run mypy + # language: system + # pass_filenames: false + + # - id: ty-check + # name: ty check + # entry: uv run ty check + # language: system + # pass_filenames: false diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..ed65ce10 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,50 @@ +FROM quay.io/jupyter/minimal-notebook:2025-04-14 +LABEL authors="Max Winokan" + + +# Ported from Max's Dockerfile to support local development + +# combines conda and pip environments. There's a conflict somewhere +# which I haven't resolved (TODO) between something in conda and pip +# envs, so cannot use a single env + +# this is what I'm stuck on. Project depends on numpy v1.23 (why?) and +# I can't go over 3.12 +ARG PYTHON_VERSION=3.12 + + +WORKDIR "/home/code/HIPPO" +# WORKDIR "/home/code/HIPPO/data" +# WORKDIR "/home/code/HIPPO/hippo" + +USER 0 + + +# install package dependencies into different virtual env +COPY uv.lock pyproject.toml ./ +RUN pip install --upgrade pip && python -m pip install uv + +# install all dependencies into active environment without updating lockfile +# RUN uv sync --frozen --active +RUN uv venv /home/code/HIPPO/.venv +RUN uv sync --frozen --python /home/code/HIPPO/.venv/bin/python + +# now add venv python to path so conda python can find it +ENV PATH="/home/code/HIPPO/.venv/bin:$PATH" +ENV PYTHONPATH="/home/code/HIPPO/.venv/lib/python${PYTHON_VERSION}/site-packages:$PYTHONPATH" + + +# patch rich +RUN python -c "import mrich; mrich.patch_rich_jupyter_margins()" + + +# notebooks +RUN chown ${NB_USER} "/home/code" && sudo apt update && sudo apt install screen -y + +# force numpy update. since there's now a new venv, make sure conda is +# used (othewise numpy will be invisible) +RUN uv pip install --upgrade numpy==2.4.4 +USER ${NB_USER} + + +# WORKDIR "/home/code/HIPPO" diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..f0038ab8 --- /dev/null +++ b/Makefile @@ -0,0 +1,44 @@ +PYTHON = uv run python + +SRC = hippo +TESTS = tests + +.PHONY: help install lint format typecheck check test ci build clean + +help: + @echo "Available commands:" + @echo " make install Install dependencies" + @echo " make lint Run ruff lint" + @echo " make format Run ruff format" + @echo " make typecheck Run mypy" + @echo " make check Run all checks" + @echo " make test Run tests" + @echo " make ci Simulate CI run" + @echo " make build Build package" + @echo " make clean Remove build artifacts" + +install: + uv sync --frozen + +lint: + uv run pre-commit run ruff --all-files + +format: + uv run pre-commit run ruff-format --all-files + +typecheck: + uv run pre-commit run mypy --all-files + +check: + uv run pre-commit run --all-files + +test: + uv run pytest + +ci: check test + +build: + uv run python -m build + +clean: + rm -rf build dist *.egg-info .pytest_cache .mypy_cache .ruff_cache diff --git a/README-test.md b/README-test.md new file mode 100644 index 00000000..5a2454b1 --- /dev/null +++ b/README-test.md @@ -0,0 +1,347 @@ +## Testing new HIPPO + +Hippo testing during active development phase: mount the code in git branch directly to container and have it handle notebook server. + + +## Environment variables +Not strictly necessary but it's convenient to have database connection parameters in `.env` file, in project root directory. Should contain minimally: + +``` +DB_NAME=designdb +DB_USER=postgres +DB_PASSWORD=s_URzt7CWfWZ.AXD7RcF +DB_HOST=database + +TA_AUTH_SERVICE=https://ta-authenticator.xchem.diamond.ac.uk/ +TA_AUTH_QUERY_KEY= +``` + +These are for local connection, values may be different for kubernetes deployment + + +## Running services + +NB! depending on your environment, you may have to prefix your docker commands with `sudo` + +### Build the app container +In project root directory: + +```bash +docker build --no-cache . -t hippo_backend:latest +``` + +`--no-cache` may not be necessary, but I find that without that it sometimes does not upgrade numpy. + + +### If using local database, build the designdb postgres container +In `/images/xchem-designdb` directory: + +```bash +docker build -t xchem_designdb:latest . +``` +This may take some time + + +### Launch the service(s) + +`docker-compose.yaml` contains instructions for docker to run the services. + +```bash +docker compose up +``` + +If not using local database, just the app container: + +```bash +docker compose up backend +``` + +After running the `up` command, at the very end you should see notebook server addres: + +``` + +hippo_backend | Or copy and paste one of these URLs: +hippo_backend | http://localhost:8888/lab?token=90de0d2f297079ee393cdc202c06064406c5cb3a8032e8d8 +hippo_backend | http://127.0.0.1:8888/lab?token=90de0d2f297079ee393cdc202c06064406c5cb3a8032e8d8 +hippo_backend | [I 2026-04-23 09:49:32.420 ServerApp] Skipped non-installed server(s): basedpyright, bash-language-server, dockerfile-language-server-nodejs, javascript-typescript-langserver, jedi-language-server, julia-language-server, pyrefly, pyright, python-language-server, python-lsp-server, r-languageserver, sql-language-server, texlab, typescript-language-server, unified-language-server, vscode-css-languageserver-bin, vscode-html-languageserver-bin, vscode-json-languageserver-bin, yaml-language-server + +``` +Copy one of the addresses to a broser tab and you're in jupyter lab environment. + + +### Cleaning up when done + +```bash +docker compose down +``` + +When using local database and it's necessary to wipe db contents: + +```bash +docker compose down -v +``` + + +## Test commands you originally shared with me + +I tried to keep the commands as they were before but there are some changes, and considering the upcoming permissions ands scope issues, there will be more. + +### Imports + +Original imports + +``` +import hippo +``` + +New imports + +``` +from hippo import HIPPO +``` +(+ whatever else you might need) + + + +### Setup animal +Original setup + +``` +target_name = "Flavi_NS5_RdRp" +animal = hippo.HIPPO(target_name, f"{target_name}.sqlite", update_legacy=True) +``` + +New animal setup: + +``` +target_name = "Flavi_NS5_RdRp" +target_access_string = "lb18145-1" +username = '' + +animal = hippo.HIPPO( + target_name=target_name, + target_access_string=target_access_string, + username=username, +) + + +``` + +If you didn't set up an `.env` file, you need to give connection parameters here (using the appropriate values of course): + +``` +db = { + 'DB_NAME': designdb, + 'DB_USER': postgres, + 'DB_PASSWORD': s_URzt7CWfWZ.AXD7RcF, + 'DB_HOST': database, + 'POSTGRES_PORT': '5432', +} +animal = hippo.HIPPO( + target_name=target_name, + target_access_string=target_access_string, + username=username, + db=db, +) +``` + +Unlike before, `HIPPO` is now a bootstrap function that sets up database. Since we're using it as a library not a standalone app, it needs to be called by user. That means, many if not most library objects cannot be imported before the animal is initialised. For example `RouteSet` (used below), can be imported only now: + +``` +from designdb.sets.route import RouteSet +``` + + + +Most important change here (even though it's not visible) - the `target_name` argument, which used to be just a project name, now actually is a target name. If there's no target by that name, it will be created, otherwise it will be fetched from the db. `animal` will remain associated with this target during it's lifecycle, you cannot work with other targets once initialised. + + +`update_legacy` flag doesn't work at the moment and going forward, probably won't be necessary at all. + +It's still possible to work with local sqlite databases. Let me know if you're interested in this, I may have to do some tweaks to make it more convenient. + + +### Registering methods + +The following methods seem necessary for the test workflow +``` +animal.register_enumeration_method(name="fragmenstein", version="1.0.0", description="XChem's implementation of Fragmenstein merges") + + +animal.register_pose_method(name="fragmenstein", version="1.0.0", description="XChem's implementation of Fragmenstein placement") +animal.register_pose_method(name="xray", version="1.0.0", description="Appropriate description") +animal.register_pose_method(name="gnina_repose", version="1.0.0", description="Appropriate description") + + +animal.register_scoring_method(name="gnina_cnn_vs", version="1.3.2", description="GNINA CNN VS score") +animal.register_scoring_method(name="fragmenstein_energy", version="1.0.0", description="appropriate description") +animal.register_scoring_method(name="fragmenstein_distance", version="1.0.0", description="appropriate description") +animal.register_scoring_method(name="moc_combo_multiref", version="1.3.2", description="Symmetric similarity score (equal weighting given to references and queries) with given reference/inspiration compounds set as references, and placed designs as the query") +animal.register_scoring_method(name="moc_combo_multiref", version="0.1.0", description="Symmetric similarity score (equal weighting given to references and queries) with given reference/inspiration compounds set as references, and placed designs as the query") +``` + + +### Add fragalysis hits +Original command: + +``` +animal.add_hits( + target_name=target_name, + metadata_csv=f"{target_name}/metadata.csv", + aligned_directory= f"{target_name}/aligned_files", + load_pose_mols=True, +) +``` + +New command: + +``` +animal.add_hits( + metadata_csv=f"{target_name}/metadata.csv", + aligned_directory= f"{target_name}/aligned_files", +) +``` + +Since the animal already knows about the target, there' no need to specify it. +`load_pose_mols` is gone because pose registration is done quite differently now. + + +## Create input for Fragmenstein and Knitwork +No changes here: + +``` +fragment_hits = animal.poses(tag="hits") +fragment_hits + +fragment_hits.write_sdf("fragment_hits.sdf") +fragment_hits.to_knitwork("knitwork_input.csv", aligned_files_dir="aligned_files") +``` + +### Load BulkDock SDF +Original code: + +``` +SDFs = [ + "openbind_flavi_ns5_rdrp_c1_fragmenstein_split2000_batch002_820954.sdf", + "openbind_flavi_ns5_rdrp_c1_fragmenstein_split2000_batch000_820952.sdf", + "openbind_flavi_ns5_rdrp_c1_fragmenstein_split2000_batch001_820953.sdf" +] + +for sd in SDFs: + full_path = os.path.join("bulkdock", sd) + + key = sd.strip(".sdf") + + animal.load_sdf( + target= target_name, + path=full_path, + inspiration_col="inspiration_ids", + reference_col="reference_id", + compound_tags = [key], + pose_tags = ["fragmenstein_placed", key], + name_col = "ID", + ) +``` + +New code, `target_name` is gone: + +``` +SDFs = [ + "openbind_flavi_ns5_rdrp_c1_fragmenstein_split2000_batch002_820954.sdf", + "openbind_flavi_ns5_rdrp_c1_fragmenstein_split2000_batch000_820952.sdf", + "openbind_flavi_ns5_rdrp_c1_fragmenstein_split2000_batch001_820953.sdf" +] + +for sd in SDFs: + full_path = os.path.join("bulkdock", sd) + + key = sd.strip(".sdf") + + animal.load_sdf( + path=full_path, + inspiration_col="inspiration_ids", + reference_col="reference_id", + compound_tags = [key], + pose_tags = ["fragmenstein_placed", key], + name_col = "ID", + ) +``` + +### Generate Fragalysis RHS input +No changes + +``` +poses = animal.poses.get_by_tag("fragmenstein_placed") + +poses.to_fragalysis("flavi_ns5_rdrp_bulkdock_poses.sdf", method="fragmenstein", submitter_name = "Lauren Reid", submitter_email= "lauren.reid@medchemica.com", submitter_institution="MedChemica", copy_reference_pdbs=True) +``` + + +### Load GNINA poses and scores +Drops `target_name`, otherwise no changes + +``` +animal.load_sdf( + path="gnina/output_sdfs/z0625b_ligands_minimized.sdf", + pose_tags = ["gnina_minimised"], +) +``` + + +### Create Syndirella inputs +No changes + +``` +poses.to_syndirella("syndirella_input.csv") +``` + +### Load Syndirella retrosynthesis routes +No changes + +``` +animal.add_syndirella_routes( + "syndirella/retro/justretroquery_manifold_ZWWZBAUAVBEARZ-UHFFFAOYSA-N-scaffold-A.pkl.gz", + CAR_only=False, + check_chemistry=False, +) +``` + + +### Load Syndirella elaborations +Old code: + +``` +comp = animal.compounds.get_by_smiles("CN(C[C@H]1CCNC1)c1ccc2ccccc2n1") + +print(comp.id) +print(comp.smiles) + +routes = hippo.RouteSet.from_product_ids(animal.db, [comp.id]) + +assert len(routes) == 1, "Wrong number of routes" + +route = routes.pop() + +animal.add_syndirella_elabs("syndirella/elabs/ZWWZBAUAVBEARZ-UHFFFAOYSA-N_a7d7696daae73aca44078d04fc8c3093_structured_output.pkl.gz", scaffold_route=route) +``` + +New code: + +``` +comp = animal.compounds.get_by_smiles("CN(C[C@H]1CCNC1)c1ccc2ccccc2n1") + +print(comp.id) +print(comp.smiles) + +routes = RouteSet.from_product_ids(animal.db, [comp.id]) + +assert len(routes) == 1, "Wrong number of routes" + +route = routes.pop() + +animal.add_syndirella_elabs("syndirella/elabs/ZWWZBAUAVBEARZ-UHFFFAOYSA-N_a7d7696daae73aca44078d04fc8c3093_structured_output.pkl.gz", scaffold_route=route) +``` + +RouteSet must be imported and called directly, not through animal. This is something that probably needs to change, but I'm not sure which way. I'll know better once work on target and TAS scope begins. + +NB! the last command did not run successfully for me, I didn't have original SDF files on disk, so it couldn't find the reference files, and even if I disabled that, there was nan reference in the data frame. If the files were indeed correct, I'll have to revisit that. diff --git a/README.md b/README.md index ecd62d64..6c94740d 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,26 @@ - + -HIPPO -===== +# XChem HIPPO -> 🦛 Hit Interaction Profiling for Progression Optimisation +> HIPPO: 🦛 Hit Interaction Profiling for Progression Optimisation HIPPO is in active development and feedback is appreciated. Please see the [documentation](https://hippo-docs.winokan.com) to get started +![GitHub Tag](https://img.shields.io/github/v/tag/xchem/hippo?include_prereleases&label=PyPI&link=https%3A%2F%2Fpypi.org%2Fproject%2Fxchem-hippo%2F) +![Release](https://img.shields.io/github/actions/workflow/status/xchem/HIPPO/release.yaml?label=publish&link=https%3A%2F%2Fgithub.com%2Fxchem%2FHIPPO%2Factions%2Fworkflows%2Frelease.yaml) +![Lint](https://img.shields.io/github/actions/workflow/status/xchem/HIPPO/lint.yaml?label=lint&link=https%3A%2F%2Fgithub.com%2Fxchem%2FHIPPO%2Factions%2Fworkflows%lint.yaml) +![Test](https://img.shields.io/github/actions/workflow/status/xchem/HIPPO/test.yaml?label=test&link=https%3A%2F%2Fgithub.com%2Fxchem%2FHIPPO%2Factions%2Fworkflows%test.yaml) -![GitHub Tag](https://img.shields.io/github/v/tag/mwinokan/hippo?include_prereleases&label=PyPI&link=https%3A%2F%2Fpypi.org%2Fproject%2Fhippo-db%2F) -![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/mwinokan/HIPPO/python-publish.yml?label=publish&link=https%3A%2F%2Fgithub.com%2Fmwinokan%2FHIPPO%2Factions%2Fworkflows%2Fpython-publish.yml) -![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/mwinokan/HIPPO/black.yml?label=lint&link=https%3A%2F%2Fgithub.com%2Fmwinokan%2FHIPPO%2Factions%2Fworkflows%2Fblack.yml) -[![Documentation Status](https://readthedocs.org/projects/hippo-db/badge/?version=latest)](https://hippo-docs.winokan.com/en/latest/?badge=latest) -![GitHub last commit](https://img.shields.io/github/last-commit/mwinokan/hippo) -![GitHub Issues or Pull Requests](https://img.shields.io/github/issues/mwinokan/hippo) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) ## Installation -HIPPO is pip-installable, but use of a `conda` environment is recommended for the rdkit and chemicalite dependencies: +HIPPO is pip-installable, but use of a `conda` environment is recommended for the +rdkit and chemicalite dependencies: -``` +```bash pip install --upgrade hippo-db conda install -c conda-forge chemicalite=2024.05.1 ``` @@ -31,7 +29,7 @@ For more information see the [installation guide](https://hippo-docs.winokan.com You can verify the installation: -``` +```bash python -m hippo verify ``` @@ -40,15 +38,15 @@ Or by running the full suite of tests (see Developer information) ## More Information
- + Repository structure ### Branches -- [HIPPO/main](https://github.com/mwinokan/HIPPO/tree/main): latest stable version -- [HIPPO/dev](https://github.com/mwinokan/HIPPO/tree/dev): @mwinokan's development branch -- [HIPPO/postgres](https://github.com/mwinokan/HIPPO/tree/dev): @mwinokan's PostgreSQL development branch -- [HIPPO/django_lean](https://github.com/mwinokan/HIPPO/tree/django_lean): An experimental branch implementing HIPPO as a Django web-app +- [HIPPO/main](https://github.com/xchem/HIPPO/tree/main): latest stable version +- [HIPPO/dev](https://github.com/xchem/HIPPO/tree/dev): Dvelopment branch +- [HIPPO/postgres](https://github.com/xchem/HIPPO/tree/dev): PostgreSQL development branch +- [HIPPO/django_lean](https://github.com/xchem/HIPPO/tree/django_lean): An experimental branch implementing HIPPO as a Django web-app
@@ -61,7 +59,7 @@ Or by running the full suite of tests (see Developer information) To develop on HIPPO please fork this repository and then install locally: -``` +```bash git clone https://github.com/YOUR_USER/HIPPO cd HIPPO pip install -e . @@ -69,29 +67,43 @@ pip install -e . ### Releases -HIPPO is automatically released to [PyPI](https://pypi.org/project/hippo-db/) as `hippo-db` via Github [releases](https://github.com/mwinokan/HIPPO/releases) off the `main` branch using the [python-publish](https://github.com/mwinokan/HIPPO/actions/workflows/python-publish.yml) workflow. +HIPPO is automatically released to [PyPI](https://pypi.org/project/hippo-db/) as +`xchem-hippo` via a Github Action off the using the +[release](https://github.com/xchem/HIPPO/actions/workflows/release.yaml) workflow. + +When you want to make an official release go to the [Releases](https://github.com/xchem/HIPPO/releases) page +and then click the **Draft a new release** button. Remember to familiarise yourself +with the xchem release process on the trunk-based-development Wiki +[Creating releases](https://github.com/xchem/trunk-based-development/wiki/Creating-releases) +page. ### Code style -HIPPO is linted using [black](https://pypi.org/project/black/) and commits are automatically linted using the [black](https://github.com/mwinokan/HIPPO/actions/workflows/black.yml) workflow. The use of [pre-commit](https://pre-commit.com/) is encouraged for local development to automatically run the linting at git commit time: +HIPPO is linted using [black](https://pypi.org/project/black/) and commits are +automatically linted using the +[lint](https://github.com/xchem/HIPPO/actions/workflows/lint.yaml) workflow. +The use of [pre-commit](https://pre-commit.com/) is encouraged for local development +to automatically run the linting at git commit time: -``` +```bash pip install pre-commit pre-commit install ``` ### Documentation -Documentation is automatically built off the [HIPPO/main](https://github.com/mwinokan/HIPPO/tree/main) branch using readthedocs. For local building using sphinx: +Documentation is automatically built off the +[HIPPO/main](https://github.com/xchem/HIPPO/tree/main) branch using readthedocs. +For local building using sphinx: -``` +```bash cd docs make html ``` To check API reference coverage use [docstr-coverage](https://pypi.org/project/docstr-coverage/) -``` +```bash pip install docstr-coverage docstr-coverage hippo ``` @@ -100,7 +112,7 @@ docstr-coverage hippo Some tests are provided in the tests directory, which can be run with pytest: -``` +```bash cd tests pytest ``` @@ -108,3 +120,81 @@ pytest N.B. the numbered tests, e.g. `test_00_cleanup.py` need to run in sequential order to set up the database. Other tests can run in arbitrary order thereafter. The tests will fail if https://fragalysis.diamond.ac.uk can not provide the protein target's data, as specified in tests/config.py. + +
+ + Postgres specific instructions + +### Local Postgres development (Mac) + +Install via homebrew + +```bash +brew install postgresql@18 +``` + +Initialise database + +```bash +/opt/homebrew/opt/postgresql@18/bin/initdb -D /opt/homebrew/var/postgresql@18 -U postgres -W +``` + +Run in foreground + +```bash +/opt/homebrew/opt/postgresql@18/bin/postgres -D /opt/homebrew/var/postgresql@18 -p 5432 +``` + +Install psycopg + +```bash +pip install psycopg[binary] +``` + +See `images/postgres` for a container including the [RDKit cartridge](https://rdkit.org/docs/Cartridge.html) + +### Connecting to a remote deployment + +Check port availability: + +```bash +nc -zv IP_ADDRESS 5432 +``` + +Success will look something like this: + +``` +Ncat: Version 7.92 ( https://nmap.org/ncat ) +Ncat: Connected to IP_ADDRESS:5432. +Ncat: 0 bytes sent, 0 bytes received in 0.01 seconds. +``` + +To ssh tunnel to a host which has the correct exposed port and forward the correct port: + +```bash +ssh -L 5432:IP_ADDRESS:5432 USER@GATEWAY_HOST +``` + +To test your connection (from your local machine) + +``` +pg_isready -h localhost -p 5432 +``` + +To list available databases with `psql` + +```bash +psql -h localhost -U USER -p 5432 -l +``` + +To connect to a specific database with `psql` + +```bash +psql -h localhost -U USER -p 5432 -n DATABASE +``` + +### Running tests + +To run the unit tests, uncomment and configure `tests/config.py` to the desired postgres deployment. N.B. currently not all tests will succeed. + +
diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 00000000..4c59c975 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,80 @@ +--- +# Dev environment for local HIPPO development. +# Needs two containers, backend with HIPPO and dependencies installed, and database + +# database container is built from images/xchem-designdb/Dockerfile: +# sudo docker build --no-cache -t xchem_designdb:latest . + +# Backend from ./Dockerfile +# sudo docker build --no-cache . -t hippo_backend:latest + +# Then bring the containers up with: +# sudo docker-compose up + +# To clear the system run +# sudo docker compose down + +# To also clear the datbase volume +# sudo docker compose down -v + + +services: + + database: + image: xchem_designdb:latest + container_name: xchem_designdb + restart: unless-stopped + ports: + - "${POSTGRES_HOST_PORT:-5433}:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + env_file: + - .env + environment: + POSTGRES_DB: ${DB_NAME:-designdb} + POSTGRES_USER: ${DB_USER} + POSTGRES_PASSWORD: ${DB_PASSWORD} + POSTGRES_INITDB_ARGS: ${POSTGRES_INITDB_ARGS:---auth-host=scram-sha-256 --auth-local=trust} + PGDATA: ${PGDATA:-/var/lib/postgresql/data} + POSTGRES_HOST_AUTH_METHOD: ${POSTGRES_HOST_AUTH_METHOD:-scram-sha-256} + shm_size: 8g + healthcheck: + test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\" -h localhost"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 90s + networks: + - app_network + extra_hosts: + - "host.docker.internal:host-gateway" + + + backend: + image: hippo_backend:latest + container_name: hippo_backend + build: + context: . + dockerfile: Dockerfile + env_file: + - .env + ports: + - "8888:8888" + volumes: + - ./hippo:/home/code/HIPPO/hippo + - ./data:/home/code/HIPPO/data + networks: + - app_network + # depends_on: + # database: + # condition: service_healthy + + +networks: + app_network: + name: xchem_designdb_network + driver: bridge + + +volumes: + postgres_data: diff --git a/docs/requirements.txt b/docs/requirements.txt index dcb6a32a..a5358494 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -9,4 +9,3 @@ pandas tqdm pycule sphinxcontrib-prettyspecialmethods - diff --git a/docs/source/add_data.rst b/docs/source/add_data.rst index 9b7f1d5a..6f5d980e 100644 --- a/docs/source/add_data.rst +++ b/docs/source/add_data.rst @@ -140,4 +140,3 @@ To add a reaction: The above will register an amidation reaction combining compounds **a** and **b** into **c**. See also the API reference :meth:`.HIPPO.register_reaction`. - diff --git a/docs/source/animal.rst b/docs/source/animal.rst index 276c805d..f0d50950 100644 --- a/docs/source/animal.rst +++ b/docs/source/animal.rst @@ -4,4 +4,3 @@ HIPPO "animal" object .. autoclass:: hippo.animal.HIPPO :members: - \ No newline at end of file diff --git a/docs/source/api_reference.rst b/docs/source/api_reference.rst index a5671ea1..38e1909c 100644 --- a/docs/source/api_reference.rst +++ b/docs/source/api_reference.rst @@ -17,6 +17,5 @@ API Reference sampling metadata plotting - fragalysis db web diff --git a/docs/source/compounds.rst b/docs/source/compounds.rst index 12bebb1c..e8912bc7 100644 --- a/docs/source/compounds.rst +++ b/docs/source/compounds.rst @@ -31,4 +31,3 @@ IngredientSet: Set of Ingredients .. autoclass:: hippo.cset.IngredientSet :members: - diff --git a/docs/source/conf.py b/docs/source/conf.py index a5a44d07..a07e8e20 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -6,56 +6,56 @@ import os import sys -sys.path.insert(0, os.path.abspath("../../")) +sys.path.insert(0, os.path.abspath('../../')) # -- Project information ----------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information -project = "HIPPO" -copyright = "2024, Max Winokan" -author = "Max Winokan" +project = 'HIPPO' +copyright = '2024, Max Winokan' +author = 'Max Winokan' # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration extensions = [ - "sphinx.ext.autodoc", - "sphinx.ext.doctest", - "sphinx.ext.extlinks", - "sphinx.ext.mathjax", - "sphinx.ext.viewcode", - "sphinx.ext.napoleon", - "sphinx.ext.intersphinx", - "sphinxcontrib.prettyspecialmethods", + 'sphinx.ext.autodoc', + 'sphinx.ext.doctest', + 'sphinx.ext.extlinks', + 'sphinx.ext.mathjax', + 'sphinx.ext.viewcode', + 'sphinx.ext.napoleon', + 'sphinx.ext.intersphinx', + 'sphinxcontrib.prettyspecialmethods', ] -templates_path = ["_templates"] +templates_path = ['_templates'] exclude_patterns = [] # -- Options for HTML output ------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output -html_theme = "sphinx_rtd_theme" -html_static_path = ["_static"] +html_theme = 'sphinx_rtd_theme' +html_static_path = ['_static'] -html_logo = "../../logos/hippo_logo_tightcrop.png" -html_favicon = "../../logos/hippo_assets-02.gif" +html_logo = '../../logos/hippo_logo_tightcrop.png' +html_favicon = '../../logos/hippo_assets-02.gif' html_theme_options = { - "navigation_depth": -1, - "logo_only": True, - "prev_next_buttons_location": "both", + 'navigation_depth': -1, + 'logo_only': True, + 'prev_next_buttons_location': 'both', } -html_css_files = ["css/custom.css"] +html_css_files = ['css/custom.css'] -source_suffix = ".rst" -master_doc = "index" +source_suffix = '.rst' +master_doc = 'index' # project = 'ASE' # copyright = f'{datetime.date.today().year}, ASE-developers' # templates_path = ['templates'] -exclude_patterns = ["build"] +exclude_patterns = ['build'] # default_role = 'math' # pygments_style = 'sphinx' -autoclass_content = "both" -modindex_common_prefix = ["hippo."] +autoclass_content = 'both' +modindex_common_prefix = ['hippo.'] diff --git a/docs/source/db.rst b/docs/source/db.rst index 791e0fa7..9bf05000 100644 --- a/docs/source/db.rst +++ b/docs/source/db.rst @@ -1,7 +1,7 @@ Database ========= -HIPPO stores data in a SQLite Database file that structures information across multiple cross-referenced tables. For general use it is not necessary to understand the database schema in detail. +HIPPO stores data in a database file that structures information across multiple cross-referenced tables. For general use it is not necessary to understand the database schema in detail. .. image:: ../images/db_architecture-01.png :width: 900 @@ -9,3 +9,6 @@ HIPPO stores data in a SQLite Database file that structures information across m .. autoclass:: hippo.db.Database :members: + +.. autoclass:: hippo.postgres.PostgresDatabase + :members: diff --git a/docs/source/definitions.rst b/docs/source/definitions.rst index 3a2d4dcb..1e65b4a1 100644 --- a/docs/source/definitions.rst +++ b/docs/source/definitions.rst @@ -18,7 +18,7 @@ Compound A :class:`.Compound` represents a ligand/small molecule with stereochemistry removed and no atomic coordinates. I.e. it represents the flattened chemical structure. It's default name is always an InChiKey. :class:`.Compound` objects can have an :attr:`.Compound.alias` which is a custom name which will supercede the InChiKey when representing the compound. :class:`.Compound` objects also have a shorthand prefixed with ``C``, for example: ``C1`` which refers to the compound with database id 7273. -:: +:: c1 = animal.register_compound(smiles="OCc1ccc2c(c1)CCO2") print(c1) @@ -36,7 +36,7 @@ Scaffolds / Elaborations Scaffold / superstructure relationships can also be encoded for :class:`.Compound` objects. Namely, the :attr:`.Compound.scaffolds` property can be used to access other :class:`.Compound` objects that have been labelled as scaffolds/substructures, and :attr:`.Compound.elabs` is used to access the inverse relationship. -:: +:: c2 = animal.register_compound(smiles="OCc1ccc2c(c1F)CCO2") c2.add_scaffold(scaffold=c1) @@ -57,7 +57,7 @@ Pose A :class:`.Pose` is a particular conformer of a :class:`.Compound` within a protein environment. A pose will have its own (stereochemical) smiles string, and must have a path to a coordinate file. This file can either be a ``.mol`` molecule file or a ``.pdb`` file of the protein-ligand complex. -:: +:: p1 = c1.poses[0] print(p1) @@ -134,7 +134,7 @@ The :class:`.Interaction` class can be used to store protein-ligand interactions .. seealso:: :doc:`interactions` API reference page - + Units ===== diff --git a/docs/source/fff.rst b/docs/source/fff.rst deleted file mode 100644 index 01abd85c..00000000 --- a/docs/source/fff.rst +++ /dev/null @@ -1,54 +0,0 @@ - -==================================================== -Running a Fast-Forward Fragments campaign with HIPPO -==================================================== - -Premise... - -Resources... - -Target setup -============ - -Fragment Merging -================ - -Place Merges -============ - -Curate scaffolds -================ - -Review Chemistry -================ - -Run Syndirella -============== - -Load Elaborations -================= - -Quote reactants -=============== - -Generate routes -=============== - -Create RandomRecipeGenerator -============================ - -Generate recipes -================ - -Profile interactions -==================== - -Score recipes -============= - -Optimise recipes -================ - -Web output -========== - diff --git a/docs/source/fragalysis.rst b/docs/source/fragalysis.rst deleted file mode 100644 index 3494978d..00000000 --- a/docs/source/fragalysis.rst +++ /dev/null @@ -1,11 +0,0 @@ -Fragalysis -========== - -To use these functions import them like this - -:: - - from hippo.fragalysis import download_target - -.. automodule:: hippo.fragalysis - :members: diff --git a/docs/source/getting_started.rst b/docs/source/getting_started.rst index 46f2c936..11c6f2af 100644 --- a/docs/source/getting_started.rst +++ b/docs/source/getting_started.rst @@ -63,12 +63,12 @@ You can select a subset of compounds using slices, tuples, or lists: Additionally you can get compounds by their tag: :: - + hits = animal.compounds(tag='hits') .. See also the :doc:`tools for structure-based searching` -Equivalent methods exist for animal.poses (returns a :class:`PoseTable`), animal.reactions (returns a :class:`.ReactionTable`), animal.interactions (returns a :class:`.InteractionTable`), and animal.tags returns a :class:`TagTable`). See also the :doc:`api_reference` pages. +Equivalent methods exist for animal.poses (returns a :class:`PoseTable`), animal.reactions (returns a :class:`.ReactionTable`), animal.interactions (returns a :class:`.InteractionTable`), and animal.tags returns a :class:`TagTable`). See also the :doc:`api_reference` pages. Inspecting a compound and its poses ----------------------------------- @@ -85,7 +85,7 @@ Once you have a compound you can access database properties using its properties c.mol # rdkit.Chem.Mol c.tags # assigned tags c.metadata # metadata dictionary - + c.draw() # draw the molecule (and its scaffold) You can access a compounds poses, which have similar functionality @@ -102,7 +102,7 @@ You can access a compounds poses, which have similar functionality p.mol # rdkit.Chem.Mol p.tags # assigned tags p.metadata # metadata dictionary - + c.draw() # draw the molecule pose (3d) See also the API reference for :doc:`compounds ` and :doc:`poses `. @@ -173,4 +173,3 @@ This will create an HTML file you can open in your browser: This method of writing to an HTML file works for all the above figures. See also :func:`.plotting.plot_pose_interactions`. - diff --git a/docs/source/index.rst b/docs/source/index.rst index 8a147a67..5591b414 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -7,19 +7,19 @@ HIPPO Documentation =================== -*Hit Interaction Profiling for Procurement Optimisation* (HIPPO) is a chemical database and python toolkit to expedite fragment-based drug discovery. +*Hit Interaction Profiling for Progression Optimisation* (HIPPO) is a chemical database and python toolkit to expedite fragment-based drug discovery. Installation ============ -On Mac OS and Linux it is recommended to install from PyPI using Conda/Miniconda. +On Mac OS and Linux it is recommended to install from PyPI using Conda/Miniconda. Chemicalite is not supported on Windows, but there is a workaround described in the :doc:`windows`. The `hippo` python module can be obtained from PyPI: :: - + $ pip install --upgrade hippo-db You will also need `chemicalite` which is an extension to SQLite for cheminformatics: @@ -28,7 +28,7 @@ You will also need `chemicalite` which is an extension to SQLite for cheminforma $ conda install -c conda-forge chemicalite=2024.05.1 -N.B. Compatibility between rdkit and chemicalite versions is quite strict, and database files created with a certain version pair may not be interoperable with others. +N.B. Compatibility between rdkit and chemicalite versions is quite strict, and database files created with a certain version pair may not be interoperable with others. See also :ref:`installation-snippets`. @@ -37,31 +37,31 @@ Getting started HIPPO uses an sqlite database with several inter-connected tables and Python-class representations thereof, the core concepts are explained in :doc:`definitions`. Once familiar you can try :doc:`getting_started`. +.. note:: + + HIPPO is built primarily as a Python API to be used in interactive :doc:`notebooks` such as in JupyterLab, but where higher performance is needed several tasks are accessible via a :doc:`cli` which can be used in SLURM jobs. .. toctree:: :maxdepth: 1 :caption: Documentation Pages - Home - Definitions, units, and data types Getting started Adding data - Interfacing with Syndirella - - Running an FFF campaign - - Preparing files for Fragalysis upload + Interfacing with Syndirella and merging algorithms + + Example Notebooks Windows installation - Random recipe generation + Command-Line Interface API Reference + Core concepts ============= @@ -70,7 +70,7 @@ HIPPO uses an sqlite database with several inter-connected tables (see :doc:`db` Compound -------- -A :class:`.Compound` represents a ligand/small molecule with stereochemistry removed and no atomic coordinates. I.e. it represents the chemical structure. It's name is always an InChiKey. If a compound is an elaboration it can have a :meth:`.Compound.scaffold` property which is another :class:`.Compound`. :class:`.Compound` objects are target-agnostic and can be linked to any number of catalogue entries (:class:`.Quote`) or synthetic pathways (:class:`.Reaction`). +A :class:`.Compound` represents a ligand/small molecule with stereochemistry removed and no atomic coordinates. I.e. it represents the chemical structure. It's name is always an InChiKey. If a compound is an elaboration it can have a :meth:`.Compound.scaffold` property which is another :class:`.Compound`. :class:`.Compound` objects are target-agnostic and can be linked to any number of catalogue entries (:class:`.Quote`) or synthetic pathways (:class:`.Reaction`). Pose ---- @@ -92,7 +92,7 @@ Installation Snippets If the above fails in your existing software environments, try this: :: - + mamba create --name py312 python=3.12 mamba activate py312 pip install hippo-db syndirella typer neo4j black gemmi @@ -105,7 +105,7 @@ Additionally, this Dockerfile can be used to create a container with a Jupyter N FROM quay.io/jupyter/minimal-notebook:2025-04-14 LABEL authors="Max Winokan" - + # Upgrade pip and install JupyterLab RUN pip install --upgrade pip && pip install hippo-db syndirella typer neo4j black gemmi @@ -144,4 +144,4 @@ Additionally, this Dockerfile can be used to create a container with a Jupyter N .. Structure-based searching .. Inserting synthetic pathways -.. Quoting with Pycule \ No newline at end of file +.. Quoting with Pycule diff --git a/docs/source/poses.rst b/docs/source/poses.rst index 85d9e463..c523d0a7 100644 --- a/docs/source/poses.rst +++ b/docs/source/poses.rst @@ -8,4 +8,4 @@ Poses :members: .. autoclass:: hippo.pset.PoseSet - :members: \ No newline at end of file + :members: diff --git a/docs/source/quoting.rst b/docs/source/quoting.rst index 4a2e42e5..22eaeaf2 100644 --- a/docs/source/quoting.rst +++ b/docs/source/quoting.rst @@ -7,4 +7,3 @@ Quoting .. autoclass:: hippo.price.Price :members: - \ No newline at end of file diff --git a/docs/source/recipes.rst b/docs/source/recipes.rst index 2734a6dd..aaaaa06c 100644 --- a/docs/source/recipes.rst +++ b/docs/source/recipes.rst @@ -15,4 +15,3 @@ Recipes & Routes .. autoclass:: hippo.rgen.RandomSelectionGenerator :members: - diff --git a/docs/source/rgen.rst b/docs/source/rgen.rst deleted file mode 100644 index 83da58c8..00000000 --- a/docs/source/rgen.rst +++ /dev/null @@ -1,26 +0,0 @@ - -======================== -Random Recipe Generation -======================== - -1. Start with a recipe - -:: - - recipe = hippo.Recipe.from_json() - -2. Create the generator object - -:: - - gen = hippo.RandomRecipeGenerator() - -hippo.RandomRecipeGenerator.get_route_pool() pseudocode: - -:: - - route_ids = SELECT route_id FROM route WHERE route_product in RandomRecipeGenerator.product_pool - routes = [db.get_route(id=route_id) from route_id, in route_ids] - return RouteSet(db, routes) - -3. \ No newline at end of file diff --git a/docs/source/sampling.rst b/docs/source/sampling.rst index 5d191807..5ac113a0 100644 --- a/docs/source/sampling.rst +++ b/docs/source/sampling.rst @@ -23,4 +23,3 @@ Scoring recipes .. autoclass:: hippo.scoring.CustomAttribute :members: - diff --git a/docs/source/syndirella.rst b/docs/source/syndirella.rst index 703c8bc4..666ca878 100644 --- a/docs/source/syndirella.rst +++ b/docs/source/syndirella.rst @@ -97,7 +97,7 @@ Syndirella has been developed to produce a HIPPO-friendly output in the syntax ` mrich.error(file) mrich.error(e) continue - + animal.db.close() The above script can be submitted to the DLS / IRIS cluster as follows: diff --git a/docs/source/windows.rst b/docs/source/windows.rst index e942043b..308908a9 100644 --- a/docs/source/windows.rst +++ b/docs/source/windows.rst @@ -8,7 +8,7 @@ Since chemicalite is not available on the Windows platform, a workaround is need Setting up Docker ================= -Docker is the industry standard way to create and share application *containers*, which are standalone Linux emulations with customisable software environments. +Docker is the industry standard way to create and share application *containers*, which are standalone Linux emulations with customisable software environments. 1. Create a Docker_ account 2. Install `Docker Desktop`_ diff --git a/hippo/__init__.py b/hippo/__init__.py index 5b8b43d0..9035006f 100644 --- a/hippo/__init__.py +++ b/hippo/__init__.py @@ -1,32 +1,18 @@ -""" +from .bootstrap import load_hippo as HIPPO -Hit Interaction Profiling for Progression Optimisation +__all__ = ['HIPPO', 'IngredientSet'] -HIPPO is a Python toolkit for structure- and fragment-based computational drug discovery, -storing large datasets in a database and facilitating rational decision making. -HIPPO was originally developed by Max Winokan while in XChem at Diamond Light Source. -See https://hippo-docs.winokan.com and https://github.com/mwinokan/HIPPO +def __getattr__(name): + """Lazily expose select designdb classes at the package top level. -""" + Imported on first access (after ``load_hippo()``/``HIPPO()`` has configured + Django) rather than at ``import hippo`` time, which runs before Django is + configured. NB: transitional -- exposing internal classes like this is + pending the client-exposure design (see RecipeManager discussion). + """ + if name == 'IngredientSet': + from designdb.sets.ingredient import IngredientSet -__version__ = "0.3.38" - -from .animal import HIPPO -from .compound import Compound, Ingredient -from .cset import CompoundSet, CompoundTable, IngredientSet -from .db import Database -from .feature import Feature -from .metadata import MetaData -from .pose import Pose -from .price import Price -from .pset import PoseTable, PoseSet -from .quote import Quote -from .reaction import Reaction -from .recipe import Recipe, Route, RouteSet -from .rgen import RandomRecipeGenerator -from .rset import ReactionTable, ReactionSet -from .tags import TagTable, TagSet -from .target import Target -from .scoring import Scorer, CustomAttribute -from .web import ProjectPage + return IngredientSet + raise AttributeError(f'module {__name__!r} has no attribute {name!r}') diff --git a/hippo/__main__.py b/hippo/__main__.py deleted file mode 100644 index 0ecc2980..00000000 --- a/hippo/__main__.py +++ /dev/null @@ -1,213 +0,0 @@ -"""Define CLI commands for HIPPO""" - -import mrich -from typer import Typer -from pathlib import Path - -app = Typer() - - -def setup_animal( - database: str, - backup: bool = True, - update_legacy: bool = False, -) -> "HIPPO": - """Setup the :class:`.HIPPO` object and optionally perform a database backup""" - - from .animal import HIPPO - - animal = HIPPO("CLI", database, update_legacy=update_legacy) - if backup: - animal.db.backup() - return animal - - -@app.command() -def backup(database: str): - """Backup database file""" - mrich.h1("hippo.backup") - - mrich.h3("Params") - mrich.var("database", database) - - from hippo.db import backup - - backup(database) - mrich.success("Successfully backed up") - - -@app.command() -def update_legacy(database: str, backup: bool = True): - """Update legacy database format""" - - mrich.h1("hippo.update_legacy") - - mrich.h3("Params") - mrich.var("database", database) - - if backup: - from hippo.db import backup - - backup(database) - - animal = setup_animal(database, backup=False, update_legacy=True) - mrich.success("Successfully updated database format") - - -@app.command() -def calculate_scaffolds( - database: str, - backup: bool = True, -): - """Calculate scaffold/superstructure relationships for all compounds""" - - mrich.h1("hippo.calculate_scaffolds") - - mrich.h3("Params") - mrich.var("database", database) - mrich.var("backup", backup) - - mrich.h3("Animal") - animal = setup_animal(database=database, backup=backup) - - mrich.h3("State Before") - mrich.var("scaffolds", animal.scaffolds) - mrich.var("elabs", animal.elabs) - - mrich.h3("Calculation") - animal.db.calculate_all_scaffolds() - - mrich.h3("State After") - mrich.var("scaffolds", animal.scaffolds) - mrich.var("elabs", animal.elabs) - - mrich.success("Completed") - - -@app.command() -def calculate_interactions( - database: str, - prolif: bool = False, - backup: bool = True, - force: bool = False, -) -> None: - """Calculate interactions for all poses""" - - mrich.h1("hippo.calculate_interactions") - - mrich.h3("Params") - mrich.var("database", database) - mrich.var("backup", backup) - - mrich.h3("Animal") - animal = setup_animal(database=database, backup=backup) - - mrich.h3("State Before") - mrich.var("#poses", animal.num_poses) - mrich.var("#fingerprinted", animal.poses.num_fingerprinted) - - mrich.h3("Calculation") - - n = len(animal.poses) - for i, pose in mrich.track(enumerate(animal.poses), total=n): - - mrich.set_progress_prefix(f"{i}/{n}") - - try: - if prolif: - pose.calculate_prolif_interactions(force=force) - else: - pose.calculate_interactions(force=force) - - except Exception as e: - mrich.error(e) - mrich.error("Could not fingerprint pose") - continue - - mrich.h3("State After") - mrich.var("#fingerprinted", animal.poses.num_fingerprinted) - - mrich.success("Completed") - - -@app.command() -def verify() -> None: - """Verify installation""" - - import os - - file_path = "_test.sqlite" - - try: - animal = setup_animal(file_path, backup=False) - c = animal.register_compound(smiles="COc1ccc2sc(N)nc2c1") - c.mol - mrich.success("HIPPO/rdkit/chemicalite installations are compatible") - except Exception as e: - mrich.error(e) - - if os.path.exists(file_path): - os.remove(file_path) - - -@app.command() -def tag_summary( - database: str, -) -> None: - """Print a table of statistics for all tags in the database""" - - mrich.h1("hippo.tag_summary") - - mrich.h3("Params") - mrich.var("database", database) - - animal = setup_animal(database=database, backup=False) - animal.tags.summary() - - -@app.command() -def add_hits( - database: str, - target_name: str, - aligned_directory: str, - metadata_csv: str = None, - tags: list[str] = None, - skip: list[str] = None, - debug: bool = False, - load_pose_mols: bool = False, - backup: bool = True, -): - """Load hits from Fragalysis / XCA data package""" - - mrich.h1("hippo.tag_summary") - - mrich.h3("Params") - mrich.var("database", database) - mrich.var("target_name", target_name) - mrich.var("metadata_csv", metadata_csv) - mrich.var("aligned_directory", aligned_directory) - mrich.var("tags", tags) - mrich.var("skip", skip) - mrich.var("debug", debug) - mrich.var("load_pose_mols", load_pose_mols) - - animal = setup_animal(database=database, backup=False) - - animal.add_hits( - target_name=target_name, - metadata_csv=metadata_csv, - aligned_directory=aligned_directory, - tags=tags, - skip=skip, - debug=debug, - load_pose_mols=load_pose_mols, - ) - - -def main() -> None: - """CLI entry point""" - app() - - -if __name__ == "__main__": - main() diff --git a/hippo/animal.py b/hippo/animal.py deleted file mode 100644 index 4ce15f86..00000000 --- a/hippo/animal.py +++ /dev/null @@ -1,3134 +0,0 @@ -"""Main animal class for HIPPO""" - -import mcol -import mrich -from mrich import print - -import numpy as np -import pandas as pd -from pathlib import Path -from rdkit.Chem import Mol - -from .pose import Pose -from .db import Database -from .tags import TagTable -from .target import Target -from .compound import Compound -from .reaction import Reaction -from .iset import InteractionTable -from .pset import PoseTable, PoseSet -from .rset import ReactionTable, ReactionSet -from .cset import CompoundTable, IngredientSet, CompoundSet -from .tools import inchikey_from_smiles, sanitise_smiles, SanitisationError - - -class HIPPO: - """The :class:`.HIPPO` `animal` class. Instantiating a :class:`.HIPPO` object will create or link a :class:`.HIPPO` :class:`.Database`. - - :: - - from hippo import HIPPO - animal = HIPPO(project_name, db_path) - - .. attention:: - - In addition to this API reference please see the tutorial pages :doc:`getting_started` and :doc:`insert_elaborations`. - - :param project_name: give this :class:`.HIPPO` a name - :param db_path: path where the :class:`.Database` will be stored - :param copy_from: optionally initialise this animal by copying the :class:`.Database` at this given path, defaults to None - :returns: :class:`.HIPPO` object - """ - - def __init__( - self, - name: str, - db_path: str | Path, - copy_from: str | Path | None = None, - overwrite_existing: bool = False, - update_legacy: bool = False, - ) -> None: - """HIPPO initialisation""" - - mrich.bold("Creating HIPPO animal") - - self._name = name - - mrich.var("name", name, color="arg") - - if not isinstance(db_path, Path): - db_path = Path(db_path) - - mrich.var("db_path", db_path, color="file") - - self._db_path = db_path - - if copy_from: - self._db = Database.copy_from( - source=copy_from, - destination=self.db_path, - animal=self, - update_legacy=update_legacy, - overwrite_existing=overwrite_existing, - ) - else: - self._db = Database(self.db_path, animal=self, update_legacy=update_legacy) - - self._compounds = CompoundTable(self.db) - self._poses = PoseTable(self.db) - self._tags = TagTable(self.db) - self._reactions = ReactionTable(self.db) - - ### in memory subsets - self._reactants = None - self._products = None - self._intermediates = None - self._scaffolds = None - self._elabs = None - - mrich.success("Initialised animal", f"[var_name]{self}") - - ### PROPERTIES - - @property - def name(self) -> str: - """Returns the project name - - :returns: project name - """ - return self._name - - @property - def db_path(self) -> str: - """Returns the database path""" - return self._db_path - - @property - def db(self) -> Database: - """Returns the Database object""" - return self._db - - @property - def compounds(self) -> CompoundTable: - """Access compounds in the Database""" - return self._compounds - - @property - def poses(self) -> PoseTable: - """Access Poses in the Database""" - return self._poses - - @property - def reactions(self) -> ReactionTable: - """Access Reactions in the Database""" - return self._reactions - - @property - def tags(self) -> TagTable: - """Access Tags in the Database""" - return self._tags - - @property - def interactions(self) -> InteractionTable: - """Access Interactions in the Database""" - # return self._interactions - from .iset import InteractionTable - - return InteractionTable(self.db) - - @property - def num_compounds(self) -> int: - """Total number of Compounds in the Database""" - return len(self.compounds) - - @property - def num_poses(self) -> int: - """Total number of Poses in the Database""" - return len(self.poses) - - @property - def num_reactions(self) -> int: - """Total number of Reactions in the Database""" - return len(self.reactions) - - @property - def num_tags(self) -> int: - """Number of unique Tags in the Database""" - return len(self.tags.unique) - - @property - def targets(self) -> list[Target]: - """Access Targets in the Database""" - target_ids = self.db.select(table="target", query="target_id", multiple=True) - return [self.db.get_target(id=q) for q, in target_ids] - - @property - def reactants(self) -> CompoundSet: - """Returns all compounds that are reactants for at least one :class:`.Reaction` (and not products of others)""" - if ( - self._reactants is None - or self._reactants["total_changes"] != self.db.total_changes - ): - self._reactants = dict( - set=self.compounds.reactants, total_changes=self.db.total_changes - ) - return self._reactants["set"] - - @property - def products(self) -> CompoundSet: - """Returns all compounds that are products of at least one :class:`.Reaction` (and not reactants of others)""" - if ( - self._products is None - or self._products["total_changes"] != self.db.total_changes - ): - self._products = dict( - set=self.compounds.products, total_changes=self.db.total_changes - ) - return self._products["set"] - - @property - def intermediates(self) -> CompoundSet: - """Returns all compounds that are products and reactants of :class:`.Reaction`""" - if ( - self._intermediates is None - or self._intermediates["total_changes"] != self.db.total_changes - ): - self._intermediates = dict( - set=self.compounds.intermediates, total_changes=self.db.total_changes - ) - return self._intermediates["set"] - - @property - def num_reactants(self) -> int: - """Returns the number of reactants (see :meth:`reactants`)""" - return len(self.reactants) - - @property - def num_intermediates(self) -> int: - """Returns the number of intermediates (see :meth:`intermediates`)""" - return len(self.intermediates) - - @property - def num_products(self) -> int: - """Returns the number of products (see :meth:`products`)""" - return len(self.products) - - @property - def elabs(self) -> CompoundSet: - """Returns compounds that are an based on another""" - if self._elabs is None or self._elabs["total_changes"] != self.db.total_changes: - self._elabs = dict( - set=self.compounds.elabs, total_changes=self.db.total_changes - ) - return self._elabs["set"] - - @property - def scaffolds(self) -> CompoundSet: - """Returns compounds that are the basis for one or more elaborations""" - if ( - self._scaffolds is None - or self._scaffolds["total_changes"] != self.db.total_changes - ): - self._scaffolds = dict( - set=self.compounds.scaffolds, total_changes=self.db.total_changes - ) - return self._scaffolds["set"] - - @property - def num_elabs(self) -> int: - """Number of compounds that are an elaboration of an existing scaffold""" - return len(self.elabs) - - @property - def num_scaffolds(self) -> int: - """Number of compounds that are the basis for elaborations""" - return len(self.scaffolds) - - ### BULK INSERTION - - def add_hits( - self, - target_name: str, - metadata_csv: str | Path, - aligned_directory: str | Path, - tags: list | None = None, - skip: list | None = None, - debug: bool = False, - load_pose_mols: bool = False, - ) -> pd.DataFrame: - """Load in crystallographic hits from a Fragalysis download or XChemAlign alignment. - - For a Fragalysis download `aligned_directory` and `metadata_csv` should point to the `aligned_files` and `metadata.csv` at the root of the extracted download. - For an XChemAlign dataset the `aligned_directory` should point to the `aligned_files`. - - :param target_name: Name of this protein :class:`.Target` - :param metadata_csv: Path to the metadata.csv from the Fragalysis download - :param aligned_directory: Path to the aligned_files directory from the Fragalysis download - :param skip: optional list of observation names to skip - :param debug: bool: (Default value = False) - :returns: a DataFrame of metadata - - """ - - import re - from enum import Enum - import molparse as mp - from rdkit.Chem import PandasTools - from .tools import remove_other_ligands - - ### Process arguments - - assert aligned_directory, "aligned_directory must be provided" - - skip = skip or [] - tags = tags or ["hits"] - - if not isinstance(aligned_directory, Path): - aligned_directory = Path(aligned_directory) - - mrich.var("aligned_directory", aligned_directory) - - ### Register Target - - target = self.register_target(name=target_name) - - ### Determine data format - - class DataFormat(Enum): - """DataFormat enum""" - - Fragalysis_v2 = 1 - XChemAlign_v2 = 2 - XChemAlign_v3 = 3 - - def __str__(self) -> str: - """name""" - return self.name - - subdirs = list(aligned_directory.glob("*[0-9][0-9][0-9][0-9]*")) - - SUBDIR_PATTERN_FRAGALYSIS = re.compile(r"^.*\d{4}[a-z]$") - SUBDIR_PATTERN_XCA = re.compile(r"^.*-.\d{4}$") - - fragalysis_subdirs_present = any( - SUBDIR_PATTERN_FRAGALYSIS.match(subdir.name) for subdir in subdirs - ) - xca_subdirs_present = any( - SUBDIR_PATTERN_XCA.match(subdir.name) for subdir in subdirs - ) - assert ( - fragalysis_subdirs_present ^ xca_subdirs_present - ), "Unexpected mixed data format" - - if fragalysis_subdirs_present: - data_format = DataFormat.Fragalysis_v2 - else: - - if any(list(subdir.glob("*_artefacts.pdb")) for subdir in subdirs): - data_format = DataFormat.XChemAlign_v3 - else: - data_format = DataFormat.XChemAlign_v2 - - mrich.var("data_format", data_format) - - ### Counters - - count_directories_tried = 0 - count_compound_registered = 0 - count_poses_registered = 0 - - ### Read metadata - - if data_format is DataFormat.Fragalysis_v2: - - assert metadata_csv, "metadata.csv required" - - meta_df = pd.read_csv(metadata_csv) - curated_tag_cols = [ - c - for c in meta_df.columns - if c - not in [ - "Code", - "Long code", - "Compound code", - "Smiles", - "Downloaded", - "Main status", - "GOOD count", - "MEDIOCRE count", - "BAD count", - "RefinementResolution", - ] - + GENERATED_TAG_COLS - ] - - mrich.var("curated_tag_cols", curated_tag_cols) - - ### Parse subdirectories - - match data_format: - case DataFormat.Fragalysis_v2: - - from .fragalysis import parse_observation_longcode - - sdf_pattern = re.compile(r"^.*\d{4}[a-z].sdf$") - - observations = {} - - for path in list( - sorted(aligned_directory.glob(f"*[0-9][0-9][0-9][0-9][a-z]")) - ): - - name = path.name - - if name in skip: - continue - - d = dict( - name=name, - path=path, - ) - - ### SDFs - - sdfs = [] - - for sdf_path in path.glob("*.sdf"): - - sdf_name = sdf_path.name - - # fragalysis SDF - if sdf_pattern.match(sdf_name): - sdfs.append(sdf_path) - - if not sdfs: - mrich.error(name, "has no compatible SDFs", path) - continue - - elif len(sdfs) > 1: - mrich.warning(name, "has multiple compatible SDFs", sdfs) - - d["sdf"] = sdfs[0] - - ### PDBs - - pdbs = [ - p - for p in path.glob("*.pdb") - if "_ligand" not in p.name - and "_apo" not in p.name - and "_hippo" not in p.name - ] - - if not len(pdbs) == 1: - mrich.error(name, "has invalid PDBs", pdbs) - continue - - d["pdb"] = pdbs[0] - - observations[name] = d - - if debug: - print(d) - - case _: - - from .xca import parse_observation_longcode - - observations = {} - - match data_format: - case DataFormat.XChemAlign_v2: - sdf_pattern = re.compile( - r"^.*-.\d{4}_._\d*_\d_.*-.\d{4}\+.\+\d*\+\d_ligand\.sdf$" - ) - case DataFormat.XChemAlign_v3: - sdf_pattern = re.compile( - r"^.*-.\d{4}_._\d*_._\d_.*-.\d{4}\+.\+\d*\+.\+\d_ligand\.sdf$" - ) - - for path in list( - sorted(aligned_directory.glob(f"*[0-9][0-9][0-9][0-9]")) - ): - - name = path.name - - if name in skip: - continue - - ### Group by SDF - - sdfs = [] - - for sdf_path in sorted(path.glob("*.sdf")): - - sdf_name = sdf_path.name - - if sdf_pattern.match(sdf_name): - sdfs.append(sdf_path) - - if not sdfs: - mrich.error(name, "has no compatible SDFs", path) - continue - - for i, sdf in enumerate(sdfs): - - subname = name + chr(ord("a") + i) - - d = dict( - name=subname, - path=path, - sdf=sdf, - ) - - pdb = path / sdf.name.replace("_ligand.sdf", ".pdb") - - if not pdb.exists(): - mrich.error(name, "is missing PDB", pdb) - continue - - d["pdb"] = pdb - - observations[name] = d - - mrich.var("#valid observations", len(observations)) - - n_poses = self.num_poses - - for observation_dict in mrich.track( - observations.values(), prefix="Adding hits..." - ): - - path = observation_dict["path"] - name = observation_dict["name"] - sdf = observation_dict["sdf"] - pdb = observation_dict["pdb"] - - if debug: - mrich.debug("Processing", path) - - count_directories_tried += 1 - - # load the SDF - df = PandasTools.LoadSDF( - str(sdf), molColName="ROMol", idName="ID", strictParsing=True - ) - - # extract fields - longcode = df.ID[0] - mol = df.ROMol[0] - - match data_format: - case DataFormat.Fragalysis_v2: - obs_dict = parse_observation_longcode(longcode) - case DataFormat.XChemAlign_v2: - obs_dict = parse_observation_longcode(longcode) - - if debug: - mrich.debug(name, longcode) - - # parse the PDB file - if debug: - mrich.reading(pdb) - sys = mp.parse(pdb, verbosity=0) - - # create the single ligand bound pdb - lig_residues = sys.residues["LIG"] - if len(lig_residues) > 1 or any( - r.contains_alternative_sites for r in lig_residues - ): - sys = remove_other_ligands( - sys, obs_dict["residue_number"], obs_dict["chain"] - ) - sys.prune_alternative_sites("A", verbosity=0) - pose_path = str(pdb.resolve()).replace(".pdb", "_hippo.pdb") - mp.write(pose_path, sys, shift_name=True, verbosity=debug) - else: - pose_path = str(pdb.resolve()) - - # smiles - smiles = mp.rdkit.mol_to_smiles(mol) - smiles = sanitise_smiles(smiles, verbosity=debug) - - # create the molecule / pose - compound_id = self.db.insert_compound( - smiles=smiles, - tags=tags, - warn_duplicate=debug, - commit=False, - ) - - if not compound_id: - - inchikey = inchikey_from_smiles(smiles) - compound = self.compounds[inchikey] - - if not compound: - mrich.error( - "Compound exists in database but could not be found by inchikey" - ) - mrich.var("smiles", smiles) - mrich.var("inchikey", inchikey) - mrich.var("observation_shortname", name) - raise Exception - - else: - count_compound_registered += 1 - compound = self.compounds[compound_id] - - # metadata - - match data_format: - case DataFormat.Fragalysis_v2: - - meta_row = meta_df[meta_df["Code"] == name] - if not len(meta_row): - assert longcode - meta_row = meta_df[meta_df["Long code"] == longcode] - - assert len(meta_row) - - metadata = {"fragalysis_longcode": meta_row["Long code"].values[0]} - - for tag in GENERATED_TAG_COLS: - if tag in meta_row.columns: - metadata[tag] = meta_row[tag].values[0] - - pose_tags = set(tags) - - for tag in curated_tag_cols: - if meta_row[tag].values[0]: - pose_tags.add(tag) - - case DataFormat.XChemAlign_v2: - metadata = {"xca_longcode": longcode} - pose_tags = set(tags) - - pose = self.register_pose( - compound=compound, - alias=name, - target=target.id, - path=pose_path, - tags=pose_tags, - metadata=metadata, - duplicate_alias="skip", - ) - - if load_pose_mols: - try: - pose.mol - except Exception as e: - mrich.error("Could not load molecule", pose) - mrich.error(e) - - mrich.var("#directories parsed", count_directories_tried) - mrich.var("#compounds registered", count_compound_registered) - mrich.var("#poses registered", self.num_poses - n_poses) - - def load_sdf( - self, - *, - target: str, - path: str | Path, - reference: int | Pose | None = None, - inspirations: list[int] | PoseSet | None = None, - compound_tags: None | list[str] = None, - pose_tags: None | list[str] = None, - mol_col: str = "ROMol", - name_col: str | None = "ID", - inspiration_col: str | None = "ref_mols", - reference_col: str = "ref_pdb", - energy_score_col: str = "energy_score", - distance_score_col: str = "distance_score", - inspiration_map: None | dict = None, - convert_floats: bool = True, - skip_equal_dict: dict | None = None, - skip_not_equal_dict: dict | None = None, - ) -> None: - """Add posed virtual hits from an SDF into the database. - - :param target: Name of the protein :class:`.Target` - :param path: Path to the SDF - :param reference: Optional single reference :class:`.Pose` to use as the protein conformation for all poses, defaults to ``None`` - :param reference_col: Column that contains reference :class:`.Pose` aliases or ID's - :param compound_tags: List of string Tags to assign to all created compounds, defaults to ``None`` - :param pose_tags: List of string Tags to assign to all created poses, defaults to ``None`` - :param mol_col: Name of the column containing the ``rdkit.ROMol`` ligands, defaults to ``"ROMol"`` - :param name_col: Name of the column containing the ligand name/alias, defaults to ``"ID"`` - :param inspirations: Optional single set of inspirations :class:`.PoseSet` object or list of IDs to assign as inspirations to all inserted poses, defaults to ``None`` - :param inspiration_col: Name of the column containing the list of inspiration :class:`.Pose` names or ID's, defaults to ``"ref_mols"`` - :param inspiration_map: Optional dictionary or callable mapping between inspiration strings found in ``inspiration_col`` and :class:`.Pose` ids - :param energy_score_col: Name of the column containing the list of energy scores ``"energy_score"`` - :param distance_score_col: Name of the column containing the list of distance scores, defaults to ``"distance_score"`` - :param convert_floats: Try to convert all values to ``float``, defaults to ``True`` - :param skip_equal_dict: Skip rows where ``any(row[key] == value for key, value in skip_equal_dict.items())``, defaults to ``None`` - :param skip_not_equal_dict: Skip rows where ``any(row[key] != value for key, value in skip_not_equal_dict.items())``, defaults to ``None`` - - All non-name columns are added to the Pose metadata. - N.B. separate .mol files are not created. The molecule binary will only be stored in the .sqlite file and fake paths are added to the database. - """ - - if not isinstance(path, Path): - path = Path(path) - - skip_equal_dict = skip_equal_dict or {} - skip_not_equal_dict = skip_not_equal_dict or {} - - mrich.debug(f"{path=}") - - compound_tags = compound_tags or [] - pose_tags = pose_tags or [] - - from rdkit.Chem import PandasTools, MolToMolFile, MolFromMolFile - from molparse.rdkit import mol_to_smiles, mol_to_pdb_block - from numpy import isnan - from pandas import read_pickle - from tempfile import NamedTemporaryFile - - if path.name.endswith(".sdf"): - df = PandasTools.LoadSDF(str(path.resolve())) - else: - df = read_pickle(path) - - df_columns = list(df.columns) - - target = self.register_target(target) - - assert mol_col in df_columns, f"{mol_col=} not in {df_columns}" - - if name_col: - assert name_col in df_columns, f"{name_col=} not in {df_columns}" - - if inspiration_col and not inspirations: - assert ( - inspiration_col in df_columns - ), f"{inspiration_col=} not in {df_columns}" - - if not reference and reference_col: - assert reference_col in df_columns, f"{reference_col=} not in {df_columns}" - - output_directory = str(path.name).removesuffix(".sdf") - output_directory = Path(output_directory) - if not output_directory.exists: - mrich.writing(f"Creating output directory {output_directory}") - os.system(f"mkdir -p {output_directory}") - - n_poses = self.num_poses - n_comps = self.num_compounds - - ### FILTER DATAFRAME - - mrich.var("SDF entries (pre-filter)", len(df)) - - df = df[df["ID"] != "ver_1.2"] - - for k, v in skip_equal_dict.items(): - df = df[df[k] == v] - - for k, v in skip_not_equal_dict.items(): - df = df[df[k] != v] - - mrich.var("SDF entries (post-filter)", len(df)) - - ### COMPOUND REGISTRATION - - if "smiles" not in df.columns: - df["smiles"] = df[mol_col].apply(mol_to_smiles) - smiles = list(set(df["smiles"].values)) - mrich.debug("#smiles", len(smiles)) - mrich.debug("Registering compounds...") - pairs = self.register_compounds(smiles=smiles, sanitisation_verbosity=False) - smiles_lookup = {s1: i for s1, (i, s2) in zip(smiles, pairs)} - inchi_lookup = self.db.get_compound_inchikey_id_dict( - inchikeys=smiles_lookup.values() - ) - - df["inchikey"] = df["smiles"].apply(lambda x: smiles_lookup.get(x)) - df["compound_id"] = df["inchikey"].apply(lambda x: inchi_lookup.get(x)) - df["compound_id"] = df["compound_id"].fillna(0).astype(int) - - if n := len(df[df["compound_id"].isna()]): - mrich.error(n, "invalid compound rows") - - cset = self.compounds[set(i for i in df["compound_id"].values if i)] - for tag in compound_tags: - cset.add_tag(tag) - - ### POSE REGISTRATION - - if not inspiration_map: - inspiration_map = self.db.get_pose_alias_id_dict() - - # dicts: (alias, compound, target, path, metadata, inspirations, tags, reference,) - data = [] - - for i, row in mrich.track(df.iterrows(), prefix="Reading SDF rows..."): - - if name_col: - name = row[name_col].strip() or f"pose_{i}" - alias = name - else: - name = f"pose_{i}" - alias = None - - mol = row[mol_col] - inchikey = row["inchikey"] - smiles = row["smiles"] - compound_id = row["compound_id"] - if not compound_id: - mrich.error("Skipping invalid compound", i) - continue - pose_path = (output_directory / f"{name}.fake.mol").resolve() - energy_score = float(row[energy_score_col]) - distance_score = float(row[distance_score_col]) - - # inspirations - - inspiration_list = [] - - if isinstance(inspirations, PoseSet): - inspiration_list = list(inspirations.ids) - - elif inspirations or inspiration_col: - - if inspirations: - insp_str = inspirations - else: - insp_str = row[inspiration_col] - - if isinstance(insp_str, str): - insp_str = insp_str.removeprefix("[") - insp_str = insp_str.removesuffix("]") - insp_str = insp_str.replace("'", "") - generator = insp_str.split(",") - - elif isinstance(insp_str, float): - generator = [] - - else: - generator = insp_str - - for insp in generator: - insp = insp.strip() - - try: - pose_id = int(insp) - inspirations.append(pose_id) - - except ValueError: - if ( - isinstance(inspiration_map, dict) - and insp in inspiration_map - ): - pose_id = inspiration_map[insp] - if pose_id: - inspiration_list.append(pose_id) - elif hasattr(inspiration_map, "__call__"): - pose_id = inspiration_map(insp) - if pose_id: - inspiration_list.append(pose_id) - else: - mrich.error( - f"Could not find inspiration pose with alias={insp}" - ) - continue - - if not reference: - ref_str = row.get(reference_col) - if ref_str: - try: - reference = int(ref_str) - except ValueError: - reference = inspiration_map[ref_str] - else: - reference = None - - elif isinstance(reference, Pose): - reference = reference.id - - # metadata - metadata = {} - skip = { - "smiles", - "inchikey", - "compound_id", - inspiration_col, - name_col, - mol_col, - energy_score_col, - distance_score_col, - "target_id", - "reference_id", - "path", - "exports", - } - - for col in df_columns: - value = row[col] - - if col in skip: - continue - - if isinstance(value, float) and isnan(value): - continue - - if convert_floats: - try: - value = float(value) - except TypeError: - pass - except ValueError: - pass - - if not (isinstance(value, str) or isinstance(value, float)): - if i == 0: - mrich.warning(f"Skipping metadata from column={col}.") - continue - - metadata[col] = value - - data.append( - dict( - alias=alias, - compound_id=compound_id, - target_id=target.id, - path=pose_path, - metadata=metadata, - inspiration_ids=inspiration_list, - reference_id=reference, - mol=mol, - inchikey=inchikey, - smiles=smiles, - energy_score=energy_score, - distance_score=distance_score, - ) - ) - - ### ACTUALLY DO THE BULK INSERTION - - mrich.debug("Registering poses...") - ids = self.db.register_poses(data) - pset = self.poses[ids] - mrich.debug("Adding tags...") - for tag in pose_tags: - pset.add_tag(tag) - - if n := self.num_compounds - n_comps: - f = mrich.success - else: - f = mrich.warning - - f(f"{n} new compounds from {path}") - - if n := self.num_poses - n_poses: - f = mrich.success - else: - f = mrich.warning - - f(f"{n} new poses from {path}") - - def add_syndirella_scaffolds( - self, - output_directory: str | Path, - *, - pattern: str = "*-*-?-scaffold-check/scaffold-*", - tags: None | list[str] = None, - target: int | str = 1, - debug: bool = False, - ) -> None: - """ - Load Poses from Syndirella "scaffold-check" outputs - - :param df_path: Path to the pickled DataFrame or SDF. - :param tags: list of tags to assign to compounds and poses, defaults to ``None`` - :param target: :class:`.Target` ID or name - :param pattern: UNIX pattern by which to search for subdirectories - :param debug: Increase verbosity of output, defaults to ``False`` - :returns: None - """ - - import json - - output_directory = Path(output_directory) - - n_poses = self.num_poses - - mrich.warning("Not setting inspirations and references") - - for subdir in mrich.track( - list(output_directory.glob(pattern)), prefix="Loading scaffolds..." - ): - - inchikey = subdir.parent.name.replace("-scaffold-check", "") - - compound = self.compounds[inchikey] - - if debug: - mrich.var("subdir", subdir) - mrich.var("inchikey", inchikey) - mrich.var("compound", compound) - - name = subdir.name - - mol_file = subdir / f"{name}.minimised.mol" - if not mol_file.exists(): - continue - - json_file = subdir / f"{name}.minimised.json" - if not json_file.exists(): - continue - - metadata = json.load(open(json_file, "rt")) - - if debug: - mrich.print(metadata) - - energy_score = ( - metadata["Energy"]["bound"]["total_score"] - - metadata["Energy"]["unbound"]["total_score"] - ) - distance_score = metadata["mRMSD"] - - tags = tags or ["Syndirella scaffold"] - - self.register_pose( - path=mol_file, - compound=compound, - target=target, - tags=tags, - return_pose=False, - ) - - n_poses = self.num_poses - n_poses - - if n_poses: - mrich.success(f"Added {n_poses} scaffold Poses") - else: - mrich.warning(f"Added {n_poses} scaffold Poses") - - def add_syndirella_elabs( - self, - df_path: str | Path, - max_energy_score: float | None = 0.0, - max_distance_score: float | None = 2.0, - require_intra_geometry_pass: bool = True, - reject_flags: list[str] | None = None, - register_reactions: bool = True, - dry_run: bool = False, - scaffold_route: "Route | None" = None, - scaffold_compound: "Compound | None" = None, - pose_tags: list[str] | None = None, - product_tags: list[str] | None = None, - ) -> "pd.DataFrame": - """ - Load Syndirella elaboration compounds and poses from a pickled DataFrame - - :param df_path: Path to the pickled DataFrame - :param max_energy_score: Filter out poses with `∆∆G` above this value - :param max_distance_score: Filter out poses with `comRMSD` above this value - :param require_intra_geometry_pass: Filter out poses with falsy `intra_geometry_pass` values - :param reject_flags: Filter out rows flagged with strings from this list (default = ["one_of_multiple_products", "selectivity_issue_contains_reaction_atoms_of_both_reactants"]) - :param scaffold_route: Supply a known single-step route to the scaffold product to use if scaffold placements are missing - :param scaffold_compound: Supply a :class:`.Compound` for the scaffold product to use if scaffold placements are missing - :param dry_run: Don't insert new records into the database (for debugging/testing) - :param pose_tags: Add these tags to all inserted poses, defaults to ["syndirella_product", "syndirella_placed"] - :param product_tags: Add these tags to all inserted product compounds, defaults to ["syndirella_product"] - :returns: annotated DataFrame - """ - - reject_flags = reject_flags or [ - "one_of_multiple_products", - "selectivity_issue_contains_reaction_atoms_of_both_reactants", - ] - - pose_tags = pose_tags or ["syndirella_product", "syndirella_placed"] - product_tags = product_tags or ["syndirella_product"] - - from .syndirella import reactions_from_row - - df_path = Path(df_path) - mrich.h3(df_path.name) - - mrich.reading(df_path) - df = pd.read_pickle(df_path) - - # work out number of reaction steps - num_steps = max( - [int(s.split("_")[0]) for s in df.columns if "_product_smiles" in s] - ) - mrich.var("num_steps", num_steps) - - # add is_scaffold row - df["is_scaffold"] = df[f"{num_steps}_product_name"].str.contains("scaffold") - - ###### PREP ###### - - # flags - - present_flags = set() - for step in range(num_steps): - step += 1 - - for flags in set(df[df[f"{step}_flag"].notna()][f"{step}_flag"].to_list()): - for flag in flags: - present_flags.add(flag) - - if present_flags: - mrich.warning("Flags in DataFrame:", present_flags) - - for flag in reject_flags: - if flag in present_flags: - for step in range(num_steps): - step += 1 - matches = df[f"{step}_flag"].apply( - lambda x: flag in x if x is not None else False - ) - mrich.print( - "Filtering out", - len(df[matches]), - "rows from step", - step, - "due to", - flag, - ) - df = df[~matches] - - # poses - - n_null_mol = len(df[df["path_to_mol"].isna()]) - if n_null_mol: - df = df[df["path_to_mol"].notna()] - mrich.var("#rows skipped due to null path_to_mol", n_null_mol) - - if not len(df): - mrich.warning("No valid rows") - return None - - # inspirations - inspiration_sets = set(tuple(sorted(i)) for i in df["regarded"]) - if len(inspiration_sets) != 1: - mrich.error("Varying inspirations not supported") - return df - - (inspiration_set,) = inspiration_sets - inspirations = self.poses[inspiration_set] - assert len(inspirations) == len(inspiration_set) - - # reference - template_paths = set(df["template"].to_list()) - assert len(template_paths) == 1, "Multiple references not supported" - (template_path,) = template_paths - template_path = Path(template_path) - mrich.var("template_path", template_path) - base_name = template_path.name.removesuffix(".pdb").removesuffix("_apo-desolv") - reference = self.poses[base_name] - assert reference, "Could not determine reference structure" - mrich.var("reference", reference) - - target = reference.target - - # subset of rows - scaffold_df = df[df["is_scaffold"]] - elab_df = df[~df["is_scaffold"]] - mrich.var("#scaffold entries", len(scaffold_df)) - mrich.var("#elab entries", len(elab_df)) - - if not len(scaffold_df) and not scaffold_route and not scaffold_compound: - mrich.error("No valid scaffold rows") - return None - - elif scaffold_route: - - ### SUPPLEMENT THE SCAFFOLD ROWS FROM KNOWN ROUTE - - assert scaffold_route.num_reactions == 1 - - product = scaffold_route.products[0].compound - reaction = scaffold_route.reactions[0] - - assert len(reaction.reactants) == 2 - - scaffold_dict = { - "scaffold_smiles": product.smiles, - "1_reaction": reaction.type, - "1_r1_smiles": reaction.reactants[0].smiles, - "1_r2_smiles": reaction.reactants[1].smiles, - "1_product_smiles": product.smiles, - "1_product_name": "scaffold", - "1_single_reactant_elab": False, - "1_num_atom_diff": 0, - "is_scaffold": True, - } - - scaffold_df = pd.DataFrame([scaffold_dict]) - - df = pd.concat([scaffold_df, df]) - - scaffold_df = df[df["is_scaffold"]] - elab_df = df[~df["is_scaffold"]] - - elif scaffold_compound: - - ### SUPPLEMENT PARTIAL SCAFFOLD ROWS FROM KNOWN PRODUCT - - scaffold_dict = { - "scaffold_smiles": scaffold_compound.smiles, - "is_scaffold": True, - } - - scaffold_df = pd.DataFrame([scaffold_dict]) - - df = pd.concat([scaffold_df, df]) - - scaffold_df = df[df["is_scaffold"]] - elab_df = df[~df["is_scaffold"]] - - if dry_run: - mrich.error("Not registering records (dry_run)") - return df - - ###### ELABS ###### - - # bulk register compounds - - smiles_cols = [ - c for c in df.columns if c.endswith("_smiles") and c != "scaffold_smiles" - ] - - for smiles_col in smiles_cols: - - inchikey_col = smiles_col.replace("_smiles", "_inchikey") - compound_id_col = smiles_col.replace("_smiles", "_compound_id") - - unique_smiles = df[smiles_col].dropna().unique() - - mrich.debug( - f"Registering {len(unique_smiles)} compounds from column: {smiles_col}" - ) - - values = self.register_compounds( - smiles=unique_smiles, - radical=False, - sanitisation_verbosity=False, - ) - - orig_smiles_to_inchikey = { - orig_smiles: inchikey - for orig_smiles, (inchikey, new_smiles) in zip(unique_smiles, values) - } - - df[inchikey_col] = df[smiles_col].apply( - lambda x: orig_smiles_to_inchikey.get(x) - ) - - # get associated IDs - compound_inchikey_id_dict = self.db.get_compound_inchikey_id_dict( - list(orig_smiles_to_inchikey.values()) - ) - df[compound_id_col] = df[inchikey_col].apply( - lambda x: compound_inchikey_id_dict.get(x) - ) - - # bulk register reactions - - if register_reactions: - for step in range(num_steps): - - step += 1 - - mrich.debug(f"Registering reactions for step {step}") - - reaction_dicts = [] - - for reaction_name, r1_id, r2_id, product_id in df[ - [ - f"{step}_reaction", - f"{step}_r1_compound_id", - f"{step}_r2_compound_id", - f"{step}_product_compound_id", - ] - ].values: - - # skip invalid rows - if pd.isna(r1_id) or pd.isna(product_id): - mrich.warning("Can't insert reactions for missing scaffold") - continue - - # reactant IDs - - reactant_ids = set() - reactant_ids.add(int(r1_id)) - - if not pd.isna(r2_id): - reactant_ids.add(int(r2_id)) - - product_id = int(product_id) - - # registration data - - reaction_dicts.append( - dict( - reaction_name=reaction_name, - reactant_ids=reactant_ids, - product_id=int(product_id), - ) - ) - - reaction_ids = self.register_reactions( - types=[d["reaction_name"] for d in reaction_dicts], - product_ids=[d["product_id"] for d in reaction_dicts], - reactant_id_lists=[d["reactant_ids"] for d in reaction_dicts], - ) - - scaffold_df = df[df["is_scaffold"]] - elab_df = df[~df["is_scaffold"]] - - # tag product compounds: - - product_ids = list(df[f"{num_steps}_product_compound_id"].dropna().unique()) - products = self.compounds[product_ids] - for tag in product_tags: - products.add_tag(tag) - - # bulk register scaffold relationships - - for step in range(num_steps): - - step += 1 - - for role in ["r1", "r2", "product"]: - - key = f"{step}_{role}_compound_id" - - mrich.debug(f"Registering scaffold relatonships for {key}") - - if step == num_steps and role == "product" and scaffold_compound: - - scaffold_id = scaffold_compound.id - - else: - - scaffold_ids = list(scaffold_df[key].dropna().unique()) - - if not scaffold_ids: - mrich.warning( - "Can't insert scaffold relationships due to missing", - key, - "for all scaffold rows", - ) - continue - - if len(scaffold_ids) > 1: - mrich.error("Multiple scaffold row values in", key) - return scaffold_df - - scaffold_id = scaffold_ids[0] - - superstructure_ids = [ - i for i in elab_df[key].unique() if i != scaffold_id - ] - - sql = """ - INSERT OR IGNORE INTO scaffold(scaffold_base, scaffold_superstructure) - VALUES(?1, ?2) - """ - - self.db.executemany(sql, [(scaffold_id, i) for i in superstructure_ids]) - self.db.commit() - - # filter poses - - ok = df - - try: - if require_intra_geometry_pass: - mrich.var( - "#poses !intra_geometry_pass", - len(df[df["intra_geometry_pass"] == False]), - ) - ok = ok[ok["intra_geometry_pass"] == True] - - if max_energy_score is not None: - mrich.var( - f"#poses ∆∆G > {max_energy_score}", - len(df[df["∆∆G"] > max_energy_score]), - ) - ok = ok[ok["∆∆G"] <= max_energy_score] - - if max_distance_score is not None: - mrich.var( - f"#poses comRMSD > {max_distance_score}", - len(df[df["comRMSD"] > max_energy_score]), - ) - ok = ok[ok["comRMSD"] <= max_distance_score] - - except Exception as e: - mrich.error("Problem filtering dataframe") - mrich.error(e) - return df - - mrich.var("#acceptable poses", len(ok)) - - if not len(ok): - mrich.warning("No valid poses") - return None - - # bulk register poses - - payload = [] - - for i, row in ok.iterrows(): - - path = Path(row.path_to_mol).resolve() - - if not path.exists(): - mrich.warning("Skipping pose w/ non-exising file:", path) - continue - - pose_tuple = ( - int(reference.id), - str(path), - int(row[f"{num_steps}_product_compound_id"]), - int(target.id), - float(row["∆∆G"]), - float(row["comRMSD"]), - ) - - payload.append(pose_tuple) - - if not payload: - mrich.warning("No valid poses") - return None - - mrich.debug(f"Registering {len(payload)} poses...") - - sql = """ - INSERT OR IGNORE INTO pose( - pose_reference, - pose_path, - pose_compound, - pose_target, - pose_energy_score, - pose_distance_score - ) - VALUES(?1, ?2, ?3, ?4, ?5, ?6) - """ - - n_before = self.num_poses - self.db.executemany(sql, payload) - self.db.commit() - diff = self.num_poses - n_before - - if diff: - mrich.success("Registered", diff, "new poses") - else: - mrich.warning("Registered", diff, "new poses") - - # query relevant poses (also previously registered) - paths = [t[1] for t in payload] - str_ids = str(tuple(paths)).replace(",)", ")") - records = self.db.select_where( - table="pose", query="pose_id", key=f"pose_path IN {str_ids}", multiple=True - ) - pose_ids = [i for i, in records] - - # bulk register inspirations - - payload = set() - for pose_id in pose_ids: - for inspiration in inspirations.ids: - payload.add((inspiration, pose_id)) - - sql = """ - INSERT OR IGNORE INTO inspiration(inspiration_original, inspiration_derivative) - VALUES(?1, ?2) - """ - - self.db.executemany(sql, list(payload)) - self.db.commit() - - # if pose_tags: - poses = self.poses[pose_ids] - for tag in pose_tags: - poses.add_tag(tag) - - return df - - def add_syndirella_routes( - self, - pickle_path: str | Path, - CAR_only: bool = True, - pick_first: bool = True, - check_chemistry: bool = True, - register_routes: bool = True, - ) -> pd.DataFrame: - """Add routes found from syndirella --just_retro query""" - - from .recipe import Recipe - from .cset import IngredientSet - from .rset import ReactionSet - from .chem import InvalidChemistryError, UnsupportedChemistryError - - df = pd.read_pickle(pickle_path) - - for i, row in mrich.track(df.iterrows(), total=len(df)): - - mrich.set_progress_field("i", i) - mrich.set_progress_field("n", len(df)) - - d = row.to_dict() - - comp = self.compounds(smiles=d["smiles"]) - - n_routes = 0 - for key in d: - if not key.startswith("route"): - continue - - if not key.endswith("_names"): - continue - - v = d[key] - - if isinstance(v, float) and pd.isna(v): - break - - n_routes += 1 - - if not n_routes: - # mrich.warning(comp, "#routes =", n_routes) - continue - - routes = [] - for j in range(n_routes): - - route_str = f"route{j}" - - route = d[route_str] - - if CAR_only and not d[route_str + "_CAR"]: - continue - - reactions = ReactionSet(self.db) - reactants = IngredientSet(self.db) - intermediates = IngredientSet(self.db) - products = IngredientSet(self.db) - - try: - for k, reaction in enumerate(route): - - reaction_type = reaction["name"] - - product = self.compounds(smiles=reaction["productSmiles"]) - - mrich.print(i, j, k, reaction_type, product) - - rs = [] - for reactant_s in reaction["reactantSmiles"]: - reactant = self.register_compound(smiles=reactant_s) - rs.append(reactant.id) - - # register the reaction - reaction = self.register_reaction( - type=reaction_type, - product=product, - reactants=rs, - check_chemistry=check_chemistry, - ) - - for r_id in rs: - if r_id in reactants: - intermediates.add(compound_id=r_id, amount=1) - else: - reactants.add(compound_id=r_id, amount=1) - - reactions.add(reaction) - except InvalidChemistryError: - continue - except UnsupportedChemistryError: - mrich.warning("Skipping unsupported chemistry:", reaction_type) - continue - except Exception as e: - mrich.error("Uncaught error with row", i, "route", j, "reaction", k) - continue - - products.add(product.as_ingredient(amount=1)) - - recipe = Recipe( - db=self.db, - reactions=reactions, - reactants=reactants, - intermediates=intermediates, - products=products, - ) - - if register_routes: - route_id = self.register_route(recipe=recipe) - mrich.success("registered route", route_id) - - if pick_first: - break - - return df - - def add_enamine_quote( - self, - path: str | Path, - *, - orig_name_col: str = "Customer Code", - # orig_name_col: str = 'Diamond ID (Molecule Name)', - price_col: str | None = None, - fixed_amount: float | None = None, - fixed_lead_time: float | None = False, - fixed_purity: float | None = False, - entry_col: str = "Catalog ID", - catalogue_col: str = "Collection", - smiles_col: str = "SMILES", - amount_col: str = "Amount, mg", - purity_col: str = "Purity, %", - lead_time_col: str | None = "Lead time", - stop_after: None | int = None, - orig_name_is_hippo_id: bool = False, - allow_no_catalogue_col: bool = False, - delete_unavailable: bool = True, - overwrite_existing_quotes: bool = False, - supplier_name: str = "Enamine", - currency: str = None, - dry_run: bool = False, - ): - """ - Load an Enamine quote provided as an excel file - - :param path: Path to the excel file - :param orig_name_col: Column name of the original alias, defaults to 'Customer Code' - :param entry_col: Column name of the catalogue ID/entry, defaults to 'Catalog ID' - :param price_col: Column name of the price, defaults to 'Price, EUR' or 'Price, USD' if present - :param catalogue_col: Column name of the price, defaults to 'Price, EUR' or 'Price, USD' if present - :param fixed_amount: Optionally use a fixed amount for all quotes (in mg) - :param fixed_lead_time: Optionally use a fixed lead time for all quotes (in days) - :param stop_after: Stop after given number of rows, defaults to ``None`` - :param orig_name_is_hippo_id: Set to ``True`` if ``orig_name_col`` is the original HIPPO :class:``hippo.compound.Compound`` ID, defaults to ``False`` - :param delete_unavailable: Delete existing Enamine database quotes for compounds that are unavailable in the quote being loaded - :param overwrite_existing_quotes: Delete existing Enamine database quotes for compounds that are available in the quote being loaded - :param dry_run: Stop before any database modification, return first quote data to be inserted - :param currency: Specify currency if non-standard price column - :returns: An :class:`.IngredientSet` of the quoted molecules - """ - - df = pd.read_excel(path) - - def unexpected_column(key: str, value: str | float) -> str: - """Generate assertion message""" - return ( - f"Unexpected Excel format ({key}='{value}') \n\nfirst row:\n{df.loc[0]}" - ) - - if smiles_col not in df.columns: - smiles_col = smiles_col.lower() - - assert smiles_col in df.columns, unexpected_column("smiles_col", smiles_col) - - if orig_name_col is not None: - assert orig_name_col in df.columns, unexpected_column( - "orig_name_col", orig_name_col - ) - else: - orig_name_is_hippo_id = False - - assert entry_col in df.columns, unexpected_column("entry_col", entry_col) - - if fixed_purity is False: - assert purity_col in df.columns, unexpected_column("purity_col", purity_col) - - if fixed_amount is None: - assert amount_col in df.columns, unexpected_column("amount_col", amount_col) - - if fixed_lead_time is False and lead_time_col is not None: - assert lead_time_col in df.columns, unexpected_column( - "lead_time_col", lead_time_col - ) - - if not allow_no_catalogue_col: - assert catalogue_col in df.columns, unexpected_column( - "catalogue_col", catalogue_col - ) - elif catalogue_col not in df.columns: - catalogue_col = None - - assert ( - "Price, EUR" in df.columns - or "Price, USD" in df.columns - or price_col in df.columns - ), unexpected_column("Price", "") - - if price_col is None: - price_cols = [c for c in df.columns if c.startswith("Price")] - assert len(price_cols) == 1 - price_col = price_cols[0] - - currency = currency or price_col.split(", ")[-1] - - ingredients = IngredientSet(self.db) - - if len(df) > 100: - generator = mrich.track( - df.iterrows(), prefix="Loading quotes...", total=len(df) - ) - else: - generator = df.iterrows() - - for i, row in generator: - smiles = row[smiles_col] - - if not isinstance(smiles, str): - break - - compound = self.register_compound(smiles=smiles) - - if orig_name_is_hippo_id: - try: - expected_id = int(row[orig_name_col]) - - if expected_id != compound.id: - mrich.error("Compound registration mismatch:") - mrich.var("expected_id", expected_id) - mrich.var("new_id", compound.id) - mrich.var("original_smiles", self.compounds[expected_id].smiles) - mrich.var("new_smiles", smiles) - - except ValueError: - pass - - if catalogue_col and (catalogue := row[catalogue_col]) in [ - "No starting material", - "Out of stock", - "Unavailable", - ]: - - if not dry_run and delete_unavailable: - - mrich.warning(f"Deleting '{supplier_name}' quotes for", compound) - - self.db.delete_where( - table="quote", - key=f"quote_supplier = '{supplier_name}' AND quote_compound = {compound.id}", - ) - - continue - - if (price := row[price_col]) == 0.0: - continue - - if fixed_amount is None: - amount = row[amount_col] - else: - amount = fixed_amount - - if fixed_purity is False: - purity = row[purity_col] / 100 - else: - purity = fixed_purity - - if fixed_lead_time is False: - if not isinstance(row[lead_time_col], str): - continue - if "week" in row[lead_time_col]: - lead_time = int(row[lead_time_col].split()[0].split("-")[-1]) * 5 - else: - raise NotImplementedError - else: - lead_time = fixed_lead_time - - quote_data = dict( - compound=compound, - supplier=supplier_name, - catalogue=catalogue if catalogue_col else None, - entry=row[entry_col], - amount=amount, - purity=purity, - lead_time=lead_time, - price=price, - currency=currency, - smiles=smiles, - ) - - if dry_run: - mrich.warning("Dry-run, stopping before any database modifications") - return quote_data - - if overwrite_existing_quotes: - self.db.delete_where( - table="quote", - key=f"quote_supplier = '{supplier_name}' AND quote_compound = {compound.id}", - ) - - q_id = self.db.insert_quote(**quote_data) - - ingredients.add( - compound_id=compound.id, - amount=amount, - quoted_amount=amount, - quote_id=q_id, - supplier="Enamine", - max_lead_time=None, - ) - - if stop_after and stop_after == i: - break - - return ingredients - - def add_mcule_quote( - self, - path: str | Path, - ): - """ - Load an MCule quote provided as an excel file - - :param path: Path to the excel file - :returns: An :class:`.IngredientSet` of the quoted molecules - """ - - ### get lead time from suppliers sheet - - sheet_name: str = "List of suppliers" - df = pd.read_excel(path, sheet_name=sheet_name) - - supplier_col = "Supplier" - lead_time_col = "Delivery time (working days)" - - assert supplier_col in df.columns, "Unexpected Excel format (supplier_col)" - assert lead_time_col in df.columns, "Unexpected Excel format (lead_time_col)" - - lead_time_lookup = { - row[supplier_col]: row[lead_time_col] for i, row in df.iterrows() - } - - ### parse individual compound quotes - - sheet_name: str = "List of products" - df = pd.read_excel(path, sheet_name=sheet_name) - - # return df - - smiles_col = "Quoted product SMILES" - entry_col = "Query Mcule ID" - purity_col = "Guaranteed purity (%)" - amount_col = "Quoted Amount (mg)" - catalogue_col = "Supplier" - lead_time_col = "Lead time" - price_col = "Product price (USD)" - currency = "USD" - - assert smiles_col in df.columns, "Unexpected Excel format (smiles_col)" - assert entry_col in df.columns, "Unexpected Excel format (entry_col)" - assert purity_col in df.columns, "Unexpected Excel format (purity_col)" - assert amount_col in df.columns, "Unexpected Excel format (amount_col)" - assert catalogue_col in df.columns, "Unexpected Excel format (catalogue_col)" - assert price_col in df.columns, "Unexpected Excel format (price_col)" - - ingredients = IngredientSet(self.db) - - for i, row in mrich.track(df.iterrows(), prefix="Loading quotes..."): - smiles = row[smiles_col] - - if not isinstance(smiles, str): - break - - compound = self.register_compound(smiles=smiles) - - # if (catalogue := row[catalogue_col]) == 'No starting material': - # continue - - catalogue = row[catalogue_col] - lead_time = lead_time_lookup[catalogue] - - # if (price := row[price_col]) == 0.0: - # continue - - # if not isinstance(row[lead_time_col], str): - # continue - - # if 'week' in row[lead_time_col]: - # lead_time = int(row[lead_time_col].split()[0].split('-')[-1])*5 - # else: - # raise NotImplementedError - - quote_data = dict( - compound=compound, - supplier="MCule", - catalogue=catalogue, - entry=row[entry_col], - amount=row[amount_col], - purity=row[purity_col] / 100, - lead_time=lead_time, - price=row[price_col], - currency=currency, - smiles=smiles, - ) - - q_id = self.db.insert_quote(**quote_data, commit=False) - - ingredients.add( - compound_id=compound.id, - amount=row[amount_col], - quote_id=q_id, - supplier="MCule", - max_lead_time=None, - ) - - self.db.commit() - - return ingredients - - def add_soakdb_compounds( - self, - path: "str | Path", - smiles_col: str = "CompoundSMILES", - alias_col: str = "CompoundCode", - update_aliases: bool = True, - soak_count_to_metadata: bool = True, - sanitisation_verbosity: bool = False, - stop_after: int | None = None, - ) -> "CompoundSet": - """Registers compounds with aliases and metadata from a SoakDB file - - :param path: Path to SoakDB CSV or SQLite file - :returns: :class:`.CompoundSet` of registered/matched compounds - """ - - from json import dumps - - path = Path(path) - - match ext := path.name.split(".")[-1]: - case "csv": - df = pd.read_csv(path) - case "sqlite": - raise NotImplementedError - case _: - print(ext) - raise ValueError( - f"Could not determine file type from extension, use '.csv' or '.sqlite' {path}" - ) - - smiles_alias_tuples = [] - - for i, row in df.iterrows(): - - smiles = row[smiles_col] - alias = row[alias_col] - - if pd.isna(smiles): - continue - - if pd.isna(alias): - continue - - smiles_alias_tuples.append((smiles, alias)) - - if stop_after and i > stop_after: - break - - smiles_alias_tuples = set(smiles_alias_tuples) - - old_smiles = [s for s, a in smiles_alias_tuples] - - mrich.debug("Registering compounds...") - inchikey_new_smiles_tuples = self.register_compounds( - smiles=old_smiles, sanitisation_verbosity=sanitisation_verbosity - ) - - inchikey_old_smiles_lookup = { - inchikey: old_s - for old_s, (inchikey, new_s) in zip(old_smiles, inchikey_new_smiles_tuples) - } - alias_lookup = {s: a for s, a in smiles_alias_tuples} - alias_dicts = [ - dict(compound_inchikey=inchikey, compound_alias=alias_lookup[old_s]) - for old_s, (inchikey, new_s) in zip(old_smiles, inchikey_new_smiles_tuples) - ] - - if update_aliases: - - sql = """ - UPDATE OR IGNORE compound - SET compound_alias = :compound_alias - WHERE compound_inchikey = :compound_inchikey; - """ - - mrich.debug("Updating aliases...") - self.db.executemany(sql, alias_dicts) - - inchikeys = [d["compound_inchikey"] for d in alias_dicts] - - inchikey_id_lookup = self.db.get_compound_inchikey_id_dict(inchikeys) - - cset = self.compounds[ - [inchikey_id_lookup[d["compound_inchikey"]] for d in alias_dicts] - ] - - cset.add_tag("soaks") - - metadata_lookup = self.db.get_id_metadata_dict(table="compound", ids=cset.ids) - - if soak_count_to_metadata: - - mrich.debug("Getting soak counts...") - for inchikey in inchikeys: - old_s = inchikey_old_smiles_lookup[inchikey] - c_id = inchikey_id_lookup[inchikey] - metadata_lookup[c_id]["SoakDB count"] = len(df[df[smiles_col] == old_s]) - - sql = """ - UPDATE compound - SET compound_metadata = ?2 - WHERE compound_id = ?1; - """ - - mrich.debug("Updating metadata...") - self.db.executemany( - sql, [(i, dumps(m)) for i, m in metadata_lookup.items()] - ) - - self.db.commit() - - return cset - - ### REGISTRATION - - def register_compound( - self, - *, - smiles: str, - scaffolds: list[Compound] | list[int] | None = None, - tags: None | list = None, - metadata: None | dict = None, - return_compound: bool = True, - commit: bool = True, - alias: str | None = None, - return_duplicate: bool = False, - register_scaffold_if_duplicate: bool = True, - radical: str = "warning", - debug: bool = False, - ) -> Compound: - """Use a smiles string to add a compound to the database. If it already exists return the compound - - :param smiles: The SMILES string of the compound - :param bases: A list of :class:`.Compound` objects or IDs that this compound is based on, defaults to ``None`` - :param tags: A list of tags to assign to this compound, defaults to ``None`` - :param metadata: A dictionary of metadata to assign to this compound, defaults to ``None`` - :param return_compound: return the :class:`.Compound` object instead of the integer ID, defaults to ``True`` - :param commit: Commit the changes to the :class:`.Database`, defaults to ``True`` - :param alias: The string alias of this compound, defaults to ``None`` - :param return_duplicate: If ``True`` returns a boolean indicating if this compound previously existed, defaults to ``False`` - :param register_scaffold_if_duplicate: If this compound exists in the :class:`.Database` modify it's ``base`` property, defaults to ``True`` - :param radical: Define the behaviour for dealing with radical atoms in the SMILES. See :class:`.sanitise_smiles`. Defaults to ``'warning'`` - :param debug: Increase verbosity of output, defaults to ``False`` - :returns: The registered/existing :class:`.Compound` object or its ID (depending on ``return_compound``), and optionally a boolean to indicate duplication see ``return_duplicate`` - """ - - assert smiles - assert isinstance(smiles, str), f"Non-string {smiles=}" - - try: - smiles = sanitise_smiles( - smiles, sanitisation_failed="error", radical=radical, verbosity=debug - ) - except SanitisationError as e: - mrich.error(f"Could not sanitise {smiles=}") - mrich.error(str(e)) - return None - except AssertionError: - mrich.error(f"Could not sanitise {smiles=}") - return None - - if scaffolds: - scaffolds = [b.id if isinstance(b, Compound) else b for b in scaffolds] - - inchikey = inchikey_from_smiles(smiles) - - if debug: - mrich.var("inchikey", inchikey) - - compound_id = self.db.insert_compound( - smiles=smiles, - inchikey=inchikey, - tags=tags, - metadata=metadata, - warn_duplicate=False, - commit=False, - alias=alias, - ) - - duplicate = not bool(compound_id) - - def _return( - compound: "Compound", - duplicate: bool, - return_compound: bool, - return_duplicate: bool, - ): - """Run on exit""" - if commit: - self.db.commit() - if not return_compound and not isinstance(compound, int): - compound = compound.id - if return_duplicate: - return compound, duplicate - else: - return compound - - def check_smiles(compound_id: int, smiles: str) -> None: - """Check smiles""" - assert compound_id - db_smiles = self.db.select_where( - table="compound", query="compound_smiles", key="id", value=compound_id - ) - (db_smiles,) = db_smiles - if db_smiles != smiles: - mrich.warning( - f"SMILES changed during compound registration: {smiles} --> {db_smiles}" - ) - - def insert_scaffolds( - scaffolds: "list[Compound] | list[int]", compound_id: int - ) -> None: - """Insert scaffolds""" - scaffolds = [b for b in scaffolds if b is not None] or [] - for scaffold in scaffolds: - self.db.insert_scaffold( - scaffold=scaffold, - superstructure=compound_id, - warn_duplicate=False, - commit=False, - ) - - if return_compound or metadata or alias or tags: - if not compound_id: - compound = self.compounds[inchikey] - - check_smiles(compound.id, smiles) - - else: - compound = self.compounds[compound_id] - - if metadata: - compound.metadata.update(metadata) - - if alias: - compound.alias = alias - - if tags: - for tag in tags: - compound.tags.add(tag, commit=False) - - if scaffolds and not (not register_scaffold_if_duplicate and duplicate): - insert_scaffolds(scaffolds, compound.id) - - return _return(compound, duplicate, return_compound, return_duplicate) - - else: - if not compound_id: - - assert inchikey - - compound_id = self.db.get_compound_id(inchikey=inchikey) - - check_smiles(compound_id, smiles) - - if scaffolds and not (not register_scaffold_if_duplicate and duplicate): - insert_scaffolds(scaffolds, compound_id) - - return _return(compound_id, duplicate, return_compound, return_duplicate) - - def register_compounds( - self, - *, - smiles: list[str], - radical: str = "warning", - sanitisation_verbosity: bool = True, - debug: bool = False, - ) -> list[tuple[str, str]]: - """Insert many compounds at once - - :param smiles: list of smiles strings - :returns: list of sanitised inchikey and smiles string pairs - - """ - - if debug: - mrich.var("#smiles", len(smiles)) - - n_before = self.num_compounds - - values = self.db.register_compounds( - smiles=smiles, - radical=radical, - sanitisation_verbosity=sanitisation_verbosity, - debug=debug, - ) - - diff = self.num_compounds - n_before - - if diff: - mrich.success(f"Inserted {diff} new compounds") - else: - mrich.warning(f"Inserted {diff} new compounds") - - return values - - def register_reaction( - self, - *, - type: str, - product: Compound | int, - reactants: list[Compound | int], - commit: bool = True, - product_yield: float = 1.0, - check_chemistry: bool = False, - ) -> Reaction: - """Add a :class:`.Reaction` to the :class:`.Database`. If it already exists return the existing one - - :param type: string indicating the type of reaction - :param product: The :class:`.Compound` object or ID of the product - :param reactants: A list of :class:`.Compound` objects or IDs of the reactants - :param commit: Commit the changes to the :class:`.Database`, defaults to ``True`` - :param product_yield: The fraction of product yielded from this reaction ``0 < product_yield <= 1.0``, defaults to ``1.0`` - :param check_chemistry: check the reaction chemistry, defaults to ``True`` - :returns: The registered :class:`.Reaction` - """ - - ### CHECK REACTION VALIDITY - - if check_chemistry: - from .chem import ( - check_chemistry, - InvalidChemistryError, - UnsupportedChemistryError, - ) - - if not isinstance(product, Compound): - product = self.db.get_compound(id=product) - - if not isinstance(reactants, CompoundSet): - reactants = CompoundSet(self.db, reactants) - - valid = check_chemistry(type, reactants, product) - - if not valid: - raise InvalidChemistryError(f"{type=}, {reactants.ids=}, {product.id=}") - - ### CHECK FOR DUPLICATES - - if isinstance(product, Compound): - product = product.id - - reactant_ids = set(v.id if isinstance(v, Compound) else v for v in reactants) - - pairs = self.db.execute( - f"""SELECT reactant_reaction, reactant_compound - FROM reactant INNER JOIN reaction - ON reactant.reactant_reaction = reaction.reaction_id - WHERE reaction_type="{type}" - AND reaction_product = {product}""" - ).fetchall() - - if pairs: - - reax_dict = {} - for reaction_id, reactant_id in pairs: - if reaction_id not in reax_dict: - reax_dict[reaction_id] = set() - reax_dict[reaction_id].add(reactant_id) - - for reaction_id, reactants in reax_dict.items(): - if reactants == reactant_ids: - return self.reactions[reaction_id] - - ### INSERT A NEW REACTION - - assert ( - product_yield > 0 and product_yield <= 1.0 - ), f"{product_yield=} out of range (0,1)" - - reaction_id = self.db.insert_reaction( - type=type, product=product, commit=commit, product_yield=product_yield - ) - - ### INSERT REACTANTS - - for reactant in reactant_ids: - self.db.insert_reactant( - compound=reactant, reaction=reaction_id, commit=commit - ) - - return self.reactions[reaction_id] - - def register_reactions( - self, - *, - types: list[str], - product_ids: list[list[int]], - reactant_id_lists: list[list[int]], - ): - """Insert many reactions at once - - :param types: list of reaction type strings - :param reactant_id_lists: list of reactant compound id lists - :param product_ids: list of product compound ids - :returns: list of reaction ids - """ - - assert len(types) == len(reactant_id_lists) == len(product_ids) - - # assert not any(not isinstance(t, str) for t in types) - # assert not any(not isinstance(t, int) for t in product_ids) - # assert not any(not any(not isinstance(i, int) for i in t) for t in reactant_id_lists) - - types = [str(t) for t in types] - product_ids = [int(i) for i in product_ids] - reactant_id_lists = [set(int(i) for i in r) for r in reactant_id_lists] - - n_before = self.num_reactions - - # get possible duplicates - existing = self.db.get_reaction_map_from_products(product_ids) - - non_duplicates = {} - - existing_count = 0 - - for reaction_type, product_id, reactant_ids in zip( - types, product_ids, reactant_id_lists - ): - - key = (reaction_type, product_id) - - reactant_ids = set(reactant_ids) - - possible_matches = {k: v for k, v in existing.items() if k == key} - - assert len(possible_matches) < 2 - - if possible_matches: - possible_matches = list(possible_matches.values())[0] - - if any(reactant_ids == v for v in possible_matches.values()): - existing_count += 1 - continue - - non_duplicates[key] = reactant_ids - - if existing_count: - mrich.warning("Skipped", existing_count, "existing reactions") - - if not non_duplicates: - mrich.warning("All reactions are duplicates") - return None - - # insert reaction records - sql = """ - INSERT INTO reaction(reaction_type, reaction_product, reaction_product_yield) - VALUES(?1, ?2, 1) - RETURNING reaction_id - """ - - payload = list(non_duplicates.keys()) - - records = self.db.executemany(sql, payload) - reaction_ids = [r_id for r_id, in records] - self.db.commit() - - # insert reactant records - sql = """ - INSERT OR IGNORE INTO reactant(reactant_amount, reactant_reaction, reactant_compound) - VALUES(1.0, ?1, ?2) - """ - - payload = [] - for reaction_id, ((reaction_type, product_id), reactant_ids) in zip( - reaction_ids, non_duplicates.items() - ): - for reactant_id in reactant_ids: - payload.append((reaction_id, reactant_id)) - - self.db.executemany(sql, payload) - self.db.commit() - - diff = self.num_reactions - n_before - - # delete orphaned reactions - - sql = """ - SELECT reaction_id FROM reaction - LEFT JOIN reactant ON reaction_id = reactant_reaction - WHERE reactant_compound IS NULL - """ - - records = self.db.execute(sql).fetchall() - orphaned_str_ids = str(tuple(r for r, in records)).replace(",)", ")") - self.db.execute(f"DELETE FROM reaction WHERE reaction_id IN {orphaned_str_ids}") - - if diff: - mrich.success(f"Inserted {diff} new reactions") - else: - mrich.warning(f"Inserted {diff} new reactions") - - return reaction_ids - - def register_target( - self, - name: str, - warn_duplicate: bool = True, - ) -> Target: - """ - Register a new protein :class:`` to the Database - - :param param1: this is a first param - :param param2: this is a second param - :returns: this is a description of what is returned - :raises keyError: raises an exception - """ - - target_id = self.db.insert_target(name=name, warn_duplicate=warn_duplicate) - - if not target_id: - target_id = self.db.get_target_id(name=name) - - return self.db.get_target(id=target_id) - - def register_pose( - self, - *, - compound: Compound | int, - target: str, - path: str, - inchikey: str | None = None, - alias: str | None = None, - reference: int | None = None, - tags: None | list = None, - metadata: None | dict = None, - inspirations: None | list[int | Pose] = None, - return_pose: bool = True, - energy_score: float | None = None, - distance_score: float | None = None, - commit: bool = True, - overwrite_metadata: bool = True, - warn_duplicate: bool = True, - check_RMSD: bool = False, - RMSD_tolerance: float = 1.0, - split_PDB: bool = False, - duplicate_alias: str = "modify", - resolve_path: bool = True, - load_mol: bool = False, - ) -> Pose: - """Add a :class:`.Pose` to the :class:`.Database`. If it already exists return the pose - - :param compound: The :class:`.Compound` object or ID that this :class:`.Pose` is a conformer of - :param target: The :class:`.Target` name or ID - :param path: Path to the :class:`.Pose`'s conformer file (.pdb or .mol) - :param alias: The string alias of this :class:`.Pose`, defaults to ``None`` - :param reference: Reference :class:`.Pose` to use as the protein conformation for all poses, defaults to ``None`` - :param tags: A list of tags to assign to this compound, defaults to ``None`` - :param metadata: A dictionary of metadata to assign to this compound, defaults to ``None`` - :param inspirations: a list of inspiration :class:`.Pose` objects or ID's, defaults to ``None`` - :param energy_score: assign an energy score to this :class:`.Pose`, defaults to ``None`` - :param distance_score: assign a distance score to this :class:`.Pose`, defaults to ``None`` - :param commit: Commit the changes to the :class:`.Database`, defaults to ``True`` - :param overwrite_metadata: If a duplicate is found, overwrite its metadata, defaults to ``True`` - :param warn_duplicate: Warn if a duplicate :class:`.Pose` exists, defaults to ``True`` - :param check_RMSD: Check the RMSD against existing :class:`.Pose`, defaults to ``False`` - :param RMSD_tolerance: Tolerance for ``check_RMSD`` in Angstrom, defaults to ``1.0`` - :param split_PDB: Register a :class:`.Pose` for every ligand residue in the PDB, defaults to ``False`` - :param duplicate_alias: In the case of a duplicate, define the behaviour for the ``alias`` property, defaults to ``'modify'`` which appends ``_copy`` to the alias. Set to ``error`` to raise an Exception. - :param resolve_path: Resolve to an absoltue path, default = True. - :param load_mol: Parse the input file and load the ligand rdkit.Chem.Mol - :returns: The registered/existing :class:`.Pose` object or its ID (depending on ``return_pose``) - """ - - assert duplicate_alias in ["error", "modify", "skip"] - - from molparse import parse - - if split_PDB: - - sys = parse(path, verbosity=False, alternative_site_warnings=False) - - lig_residues = [] - - for res in sys.ligand_residues: - lig_residues += res.split_by_site() - - if len(lig_residues) > 1: - - assert not energy_score - assert not distance_score - - mrich.warning(f"Splitting ligands in PDB: {path}") - - results = [] - for i, res in enumerate(lig_residues): - file = str(path).replace(".pdb", f"_hippo_{i}.pdb") - - split_sys = sys.protein_system - - for atom in res.atoms: - split_sys.add_atom(atom) - - mrich.writing(file) - split_sys.write(file, verbosity=False) - - result = self.register_pose( - compound=compound, - target=target, - path=file, - inchikey=inchikey, - alias=alias, - reference=reference, - tags=tags, - metadata=metadata, - inspirations=inspirations, - return_pose=return_pose, - commit=commit, - overwrite_metadata=overwrite_metadata, - warn_duplicate=warn_duplicate, - check_RMSD=check_RMSD, - RMSD_tolerance=RMSD_tolerance, - split_PDB=False, - load_mol=load_mol, - ) - - results.append(result) - - return results - - if isinstance(compound, int): - compound_id = compound - else: - compound_id = compound.id - - if check_RMSD: - - # check if the compound has existing poses - other_pose_ids = self.db.select_id_where( - table="pose", - key="compound", - value=compound_id, - none="quiet", - multiple=True, - ) - - if other_pose_ids: - other_poses = PoseSet(self.db, [i for i, in other_pose_ids]) - - from molparse.rdkit import draw_mols, draw_flat - from rdkit.Chem import MolFromMolFile - from numpy.linalg import norm - from numpy import array - - mol = MolFromMolFile(str(path.resolve())) - - c1 = mol.GetConformer() - atoms1 = [a for a in mol.GetAtoms()] - symbols1 = [a.GetSymbol() for a in atoms1] - positions1 = [c1.GetAtomPosition(i) for i, _ in enumerate(atoms1)] - - for pose in other_poses: - c2 = pose.mol.GetConformer() - atoms2 = [a for a in pose.mol.GetAtoms()] - symbols2 = [a.GetSymbol() for a in atoms2] - positions2 = [c2.GetAtomPosition(i) for i, _ in enumerate(atoms2)] - - for s1, p1 in zip(symbols1, positions1): - for s2, p2 in zip(symbols2, positions2): - if s2 != s1: - continue - if norm(array(p2 - p1)) <= RMSD_tolerance: - # this atom (1) is within tolerance - break - else: - # this atom (1) is outside of tolerance - break - else: - # all atoms within tolerance --> too similar - mrich.warning(f"Found similar {pose=}") - if return_pose: - return pose - else: - return pose.id - - pose_data = dict( - compound=compound, - inchikey=inchikey, - alias=alias, - target=target, - path=path, - tags=tags, - metadata=metadata, - reference=reference, - warn_duplicate=warn_duplicate, - commit=commit, - energy_score=energy_score, - distance_score=distance_score, - ) - - pose_id = self.db.insert_pose(**pose_data, resolve_path=resolve_path) - - # if no pose_id then there must be a duplicate - if not pose_id: - - # constraint failed - if isinstance(path, Path): - path = path.resolve() - - # try getting by path - result = self.db.select_where( - table="pose", query="pose_id", key="path", value=str(path), none="quiet" - ) - - # try getting by alias - if not result: - result = self.db.select_where( - table="pose", query="pose_id", key="alias", value=alias - ) - - if result and duplicate_alias == "error": - raise Exception("could not register pose with existing alias") - - elif result and duplicate_alias == "modify": - - new_alias = alias + "_copy" - - mrich.warning(f"Modifying alias={alias} --> {new_alias}") - - pose_data["alias"] = new_alias - pose_id = self.db.insert_pose(**pose_data) - - elif result and duplicate_alias == "skip": - (pose_id,) = result - - else: - (pose_id,) = result - else: - (pose_id,) = result - - assert pose_id, (result, pose_id) - - if not pose_id: - mrich.var("compound", compound) - mrich.var("inchikey", inchikey) - mrich.var("alias", alias) - mrich.var("target", target) - mrich.var("path", path) - mrich.var("reference", reference) - mrich.var("tags", tags) - mrich.debug(f"{metadata=}") - mrich.debug(f"{inspirations=}") - - raise Exception - - if return_pose or (metadata and not overwrite_metadata) or load_mol: - pose = self.poses[pose_id] - - if metadata: - pose.metadata.update(metadata) - - if load_mol: - pose.mol - - else: - pose = pose_id - - if overwrite_metadata: - self.db.insert_metadata( - table="pose", id=pose_id, payload=metadata, commit=commit - ) - - inspirations = inspirations or [] - for inspiration in inspirations: - self.db.insert_inspiration( - original=inspiration, - derivative=pose, - warn_duplicate=False, - commit=commit, - ) - - return pose - - def register_route( - self, - *, - recipe: "Recipe", - commit: bool = True, - ) -> int: - """ - Insert a single-product :class:`.Recipe` into the :class:`.Database`. - - :param recipe: The :class:`.Recipe` object to be registered - :param commit: Commit the changes to the :class:`.Database`, defaults to ``True`` - :returns: The :class:`.Route` ID - """ - - assert recipe.num_products == 1 - - # register the route - route_id = self.db.insert_route(product_id=recipe.product.id, commit=False) - - assert route_id - - # reactions - for ref in recipe.reactions.ids: - self.db.insert_component( - component_type=1, ref=ref, route=route_id, commit=False - ) - - # reactants - for ref, amount in recipe.reactants.id_amount_pairs: - self.db.insert_component( - component_type=2, ref=ref, route=route_id, amount=amount, commit=False - ) - - # intermediates - for ref, amount in recipe.intermediates.id_amount_pairs: - self.db.insert_component( - component_type=3, ref=ref, route=route_id, amount=amount, commit=False - ) - - if commit: - self.db.commit() - - return route_id - - ### QUOTING - - def quote_compounds( - self, - ref_animal: "HIPPO", - compounds: CompoundSet | None = None, - *, - debug: bool = False, - ) -> "CompoundSet,CompoundSet": - """Transfer quotes from another reference :class:`.HIPPO` animal object (e.g. the one from https://github.com/mwinokan/EnamineCatalogs) - - :param ref_animal: The reference :class:`.HIPPO` animal to fetch quotes from - :param compounds: A :class:`.CompoundSet` containing the compounds to be quoted - """ - - if compounds is not None: - inchikeys = compounds.inchikeys - - else: - inchikeys = self.compounds.inchikeys - - sql = f""" - SELECT quote_id, quote_smiles, quote_amount, quote_supplier, quote_catalogue, quote_entry, quote_lead_time, quote_price, quote_currency, quote_purity, quote_date, quote_compound FROM quote - INNER JOIN compound ON quote_compound = compound_id - WHERE compound_inchikey IN {tuple(inchikeys)} - """ - - with mrich.loading("Querying reference database..."): - records = ref_animal.db.execute(sql).fetchall() - - quoted_compound_ids = set() - quote_count = self.db.count("quote") - - for record in mrich.track( - records, total=len(records), prefix="Inserting quotes" - ): - - ( - quote_id, - quote_smiles, - quote_amount, - quote_supplier, - quote_catalogue, - quote_entry, - quote_lead_time, - quote_price, - quote_currency, - quote_purity, - quote_date, - quote_compound, - ) = record - - try: - compound = self.compounds(smiles=quote_smiles) - except Exception as e: - mrich.error(e) - continue - - if debug: - mrich.debug("Inserting quote for", compound) - - try: - self.db.insert_quote( - compound=compound, - supplier=quote_supplier, - catalogue=quote_catalogue, - entry=quote_entry, - amount=quote_amount, - price=quote_price, - currency=quote_currency, - purity=quote_purity, - lead_time=quote_lead_time, - smiles=quote_smiles, - date=quote_date, - commit=False, - ) - except Exception as e: - mrich.error(e) - continue - - quoted_compound_ids.add(compound.id) - - self.db.commit() - - quoted_compounds = self.compounds[quoted_compound_ids] - - if compounds: - unquoted_compounds = compounds - quoted_compounds - else: - unquoted_compounds = self.compounds[:] - quoted_compounds - - mrich.var("#new quotes", self.db.count("quote") - quote_count) - mrich.var("#quoted_compounds", len(quoted_compounds)) - mrich.var("#unquoted_compounds", len(unquoted_compounds)) - - return quoted_compounds, unquoted_compounds - - def quote_reactants( - self, - ref_animal: "HIPPO", - *, - unquoted_only: bool = False, - supplier: str = "any", - debug: bool = False, - ) -> None: - """Get batch quotes for all reactants in the database - - :param ref_animal: The reference :class:`.HIPPO` animal to fetch quotes from (e.g. the one from https://github.com/mwinokan/EnamineCatalogs) - :param unquoted_only: Only request quotes for unquoted compouds, defaults to ``False`` - """ - - if unquoted_only: - compounds = self.reactants.get_unquoted(supplier=supplier) - else: - compounds = self.reactants - - mrich.var("#compounds", len(compounds)) - - self.quote_compounds(ref_animal=ref_animal, compounds=compounds, debug=debug) - - def quote_intermediates( - self, - ref_animal: "HIPPO", - ) -> None: - """Get batch quotes for all reactants in the database - - :param ref_animal: The reference :class:`.HIPPO` animal to fetch quotes from (e.g. the one from https://github.com/mwinokan/EnamineCatalogs) - :param unquoted_only: Only request quotes for unquoted compouds, defaults to ``False`` - """ - - self.quote_compounds(quoter=quoter, compounds=self.intermediates) - - ### PLOTTING - - def plot_tag_statistics(self, *args, **kwargs) -> "plotly.graph_objects.Figure": - """Plot an overview of the number of compounds and poses for each tag, see :func:`hippo.plotting.plot_tag_statistics`""" - - if not self.num_tags: - mrich.error("No tagged compounds or poses") - return - from .plotting import plot_tag_statistics - - return plot_tag_statistics(self, *args, **kwargs) - - def plot_compound_property(self, prop, **kwargs) -> "plotly.graph_objects.Figure": - """Plot an arbitrary compound property across the whole dataset, see :func:`hippo.plotting.plot_compound_property`""" - from .plotting import plot_compound_property - - return plot_compound_property(self, prop, **kwargs) - - def plot_pose_property(self, prop, **kwargs) -> "plotly.graph_objects.Figure": - """Plot an arbitrary pose property across the whole dataset, see :func:`hippo.plotting.plot_pose_property`""" - from .plotting import plot_pose_property - - return plot_pose_property(self, prop, **kwargs) - - def plot_interaction_punchcard( - self, poses=None, subtitle=None, opacity=1.0, **kwargs - ) -> "plotly.graph_objects.Figure": - """Plot an interaction punchcard for a set of poses, see :func:`hippo.plotting.plot_interaction_punchcard`""" - from .plotting import plot_interaction_punchcard - - return plot_interaction_punchcard( - self, poses=poses, subtitle=subtitle, opacity=opacity, **kwargs - ) - - def plot_interaction_punchcard_by_tags( - self, tags: dict[str, str] | list[str], **kwargs - ) -> "plotly.graph_objects.Figure": - """Plot an interaction punchcard for a set of poses associated to given tags, see :func:`hippo.plotting.plot_interaction_punchcard_by_tags`""" - from .plotting import plot_interaction_punchcard_by_tags - - return plot_interaction_punchcard_by_tags(self, tags=tags, **kwargs) - - def plot_residue_interactions( - self, residue_number: int, poses: str | None = None, **kwargs - ) -> "plotly.graph_objects.Figure": - """Plot an interaction punchcard for a set of poses, see :func:`hippo.plotting.plot_residue_interactions`""" - from .plotting import plot_residue_interactions - - return plot_residue_interactions( - self, poses=poses, residue_number=residue_number, **kwargs - ) - - def plot_compound_availability( - self, compounds=None, **kwargs - ) -> "plotly.graph_objects.Figure": - """Plot a bar chart of compound availability by supplier/catalogue, see :func:`hippo.plotting.plot_compound_availability`""" - from .plotting import plot_compound_availability - - return plot_compound_availability(self, compounds=compounds, **kwargs) - - def plot_compound_availability_venn( - self, compounds, **kwargs - ) -> "plotly.graph_objects.Figure": - """Plot a venn diagram of compound availability by supplier/catalogue, see :func:`hippo.plotting.plot_compound_availability`""" - from .plotting import plot_compound_availability_venn - - return plot_compound_availability_venn(self, compounds=compounds, **kwargs) - - def plot_compound_price( - self, - min_amount, - compounds=None, - plot_lead_time=False, - style="histogram", - **kwargs, - ) -> "plotly.graph_objects.Figure": - """Plot a bar chart of minimum compound price for a given minimum amount, see :func:`hippo.plotting.plot_compound_price`""" - from .plotting import plot_compound_price - - return plot_compound_price( - self, min_amount=min_amount, compounds=compounds, style=style, **kwargs - ) - - def plot_reaction_funnel(self, **kwargs) -> "plotly.graph_objects.Figure": - """Plot a funnel chart of the reactants, intermediates, and products across the whole dataset, see :func:`hippo.plotting.plot_reaction_funnel`""" - from .plotting import plot_reaction_funnel - - return plot_reaction_funnel(self, **kwargs) - - def plot_pose_interactions( - self, pose: "Pose", **kwargs - ) -> "plotly.graph_objects.Figure": - """3d figure showing the interactions between a :class:`.Pose` and the protein. see :func:`hippo.plotting.plot_pose_interactions`""" - from .plotting import plot_pose_interactions - - return plot_pose_interactions(self, pose=pose, **kwargs) - - def get_scaffold_network( - self, - compounds: "CompoundSet | None" = None, - scaffolds: "CompoundSet | None" = None, - notebook: bool = True, - depth: int = 5, - scaffold_tag: str | None = None, - exclude_tag: str | None = None, - physics: bool = True, - arrows: bool = True, - ) -> "pyvis.network.Network": - """Use PyVis to display a network of molecules connected by scaffold relationships in the database""" - from .pyvis import get_scaffold_network - - return get_scaffold_network( - self, - compounds=compounds, - scaffolds=scaffolds, - notebook=notebook, - depth=depth, - scaffold_tag=scaffold_tag, - exclude_tag=exclude_tag, - physics=physics, - arrows=arrows, - ) - - ### COMPOUND DESIGN - - # def fragmenstein_merge(self, - # reference: Pose, - # hits: PoseSet, - # combination_size: int = 2, - # timeout=300, - # n_cores: int = 1, - # scratch_dir: str = "fragmenstein_scratch", - # require_outome: str = "acceptable", - # return_df: bool = False, - # bulkdock_csv: str = "", - # ) -> PoseSet: - - # mrich.var("reference", reference) - # mrich.var("combination_size", combination_size) - # mrich.var("require_outome", require_outome) - # mrich.var("n_cores", n_cores) - # mrich.var("timeout", timeout) - # mrich.var("scratch_dir", scratch_dir) - # mrich.var("protein_path", reference.apo_path) - # mrich.var("bulkdock_csv", bulkdock_csv) - - # from .fstein import setup_wictor_laboratory, pure_merge - - # lab = setup_wictor_laboratory( - # scratch_dir=scratch_dir, - # protein_path=reference.apo_path, - # ) - - # df = pure_merge( - # lab, - # hits.mols, - # n_cores=n_cores, - # timeout=timeout, - # combination_size=combination_size, - # ) - - # if not len(filtered): - # mrich.error(f"No merges") - # return None - - # if require_outome: - # filtered = df[df["outcome"] == require_outome] - - # if not len(filtered): - # mrich.error(f"No merges with 'outcome' == {require_outome}") - # if return_df: - # return None, df - # return None - - # df = filtered - - # # register the poses - # n = len(self.compounds) - # compound_ids = [] - # # inspirations = [] - # for i,row in df.iterrows(): - # smiles = row.smiles - # compound = self.register_compound(smiles=smiles, tags=["Fragmenstein pure merge"]) - # compound_ids.append(compound.id) - # # hit_mols = row.hit_names - # # print(row) - # # raise NotImplementedError - - # compounds = self.compounds[compound_ids] - - # d = len(self.compounds) - n - - # if d: - # mrich.success(f"Found and registered {d} new merges") - # else: - # mrich.success(f"Found and registered {d} new merges") - - # if return_df: - # return compounds, df - - # return compounds - - ### OTHER - - def summary(self) -> None: - """Print a text summary of this HIPPO""" - mrich.header(self) - mrich.var("db_path", self.db_path) - mrich.var("#compounds", self.num_compounds) - mrich.var("#poses", self.num_poses) - mrich.var("#reactions", self.num_reactions) - mrich.var("#tags", self.num_tags) - mrich.var("tags", self.tags.unique) - # mrich.var('#products', len(self.products)) - - def get_by_shorthand(self, key) -> "Compound | Pose | Reaction": - """Get a :class:`.Compound`, :class:`.Pose`, or :class:`.Reaction` by its ID - - :param key: shortname of the object, e.g. C100 for :class:`.Compound` with id=100 - :returns: :class:`.Compound`, :class:`.Pose`, or :class:`.Reaction` object - """ - - assert isinstance(key, str), f"'HIPPO' object has no attribute '{key}'" - assert len(key) > 1, f"'HIPPO' object has no attribute '{key}'" - - prefix = key[0] - index = key[1:] - - if prefix not in "CPRTFIS": - raise AttributeError(f"'HIPPO' object has no attribute '{key}'") - - try: - index = int(index) - except ValueError: - mrich.error(f"Cannot convert {index} to integer") - return None - - match key[0]: - case "C": - return self.compounds[index] - case "P": - return self.poses[index] - case "R": - return self.reactions[index] - case "T": - return self.db.get_target(id=index) - case "F": - return self.db.get_feature(id=index) - case "I": - return self.db.get_interaction(id=index) - case "S": - return self.db.get_subsite(id=index) - - mrich.error(f"Unsupported {prefix=}") - return None - - ### DUNDERS - - def __str__(self) -> str: - """Unformatted string representation of this HIPPO""" - return f'HIPPO("{self.name}")' - - def __repr__(self) -> str: - """Returns a command line representation of this HIPPO""" - return f"{mcol.bold}{mcol.underline}{self}{mcol.clear}" - - def __rich__(self) -> str: - """Representation for mrich""" - return f"[bold underline]{self}" - - def __getitem__(self, key: str): - """Get a :class:`.Compound`, :class:`.Pose`, or :class:`.Reaction` by its ID. See :meth:`.HIPPO.get_by_shorthand`""" - return self.get_by_shorthand(key) - - def __getattr__(self, key: str): - """Get a :class:`.Compound`, :class:`.Pose`, or :class:`.Reaction` by its ID. See :meth:`.HIPPO.get_by_shorthand`""" - return self.get_by_shorthand(key) - - -GENERATED_TAG_COLS = [ - "ConformerSites alias", - "CanonSites alias", - "CrystalformSites alias", - "Quatassemblies alias", - "Crystalforms alias", - "ConformerSites upload name", - "CanonSites upload name", - "CrystalformSites upload name", - "Quatassemblies upload name", - "Crystalforms upload name", - "ConformerSites short tag", - "CanonSites short tag", - "CrystalformSites short tag", - "Quatassemblies short tag", - "Crystalforms short tag", - "Centroid res", - "Experiment code", - "Pose", -] diff --git a/hippo/apsw.py b/hippo/apsw.py deleted file mode 100644 index 0917c22a..00000000 --- a/hippo/apsw.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Functions for interfacing with the apsw library""" - -import apsw - - -def executemany(path: "Path", sql: str, payload: list[tuple]): - """Bulk execution with apsw""" - connection = apsw.Connection(str(path.resolve())) - result = list(connection.executemany(sql, payload)) - connection.close() - return result diff --git a/hippo/bootstrap.py b/hippo/bootstrap.py new file mode 100644 index 00000000..e8aa107c --- /dev/null +++ b/hippo/bootstrap.py @@ -0,0 +1,143 @@ +import os +import sys +from pathlib import Path + +import django +import mrich +from django.conf import settings + +from .ta_auth_connector import get_auth_target_access + +# fix path +ROOT = Path(__file__).resolve().parent +sys.path.insert(0, str(ROOT)) + + +def configure_django(db_config, manage_models: bool): + + if settings.configured: + return + + if manage_models: + # sqlite3 db, create and manage models + database = { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': db_config, + } + else: + # postgres, existing installation, don't touch + + database = { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': db_config['DB_NAME'], + 'USER': db_config['DB_USER'], + 'PASSWORD': db_config['DB_PASSWORD'], + 'HOST': db_config['DB_HOST'], + 'PORT': db_config['POSTGRES_PORT'], + 'OPTIONS': { + # sets the schema + 'options': '-c search_path=rdkit,designdb' + }, + } + + # fix for SynchronousOnlyOperation when working in notebooks. safe + # to use for now + os.environ.setdefault('DJANGO_ALLOW_ASYNC_UNSAFE', 'true') + + settings.configure( + INSTALLED_APPS=[ + 'designdb.apps.DesigndbConfig', + ], + DATABASES={'default': database}, + SECRET_KEY='runtime', + DEFAULT_AUTO_FIELD='django.db.models.BigAutoField', + TIME_ZONE='UTC', + USE_TZ=True, + MIGRATION_MODULES={'designdb': None}, + MANAGE_MODELS=manage_models, + ) + + django.setup() + + +def load_hippo( + *, + target_name: str, + target_access_string: str, + username: str, + db: str | Path | dict | None = None, + stack: str = 'production', + auth_token: str | None = None, + # copy_from: str | Path | None = None, + # overwrite_existing: bool = False, + # update_legacy: bool = False, +): + """Initialisation function for HIPPO object. + + User should not call HIPPO directly because the db needs to be initialised. + """ + + mrich.bold('Creating HIPPO animal') + mrich.var('target_name', target_name, color='arg') + + tas_list = get_auth_target_access(username) + + if target_access_string not in tas_list: + mrich.error(f'User {username} does not have access to {target_access_string}') + return + + if db is None: + # populate from env + + db = { + 'DB_NAME': os.environ.get('DB_NAME', ''), + 'DB_USER': os.environ.get('DB_USER', ''), + 'DB_PASSWORD': os.environ.get('DB_PASSWORD', ''), + 'DB_HOST': os.environ.get('DB_HOST', ''), + 'POSTGRES_PORT': os.environ.get('POSTGRES_PORT', '5432'), + } + + if isinstance(db, str): + # sqlite db + + db_path = Path(db) + + mrich.var('db_path', db_path, color='file') + + # if copy_from: + # self._db = Database.copy_from( + # source=copy_from, + # destination=db_path, + # animal=self, + # update_legacy=update_legacy, + # overwrite_existing=overwrite_existing, + # ) + # else: + # self._db = Database(db_path, animal=self, update_legacy=update_legacy) + + configure_django(db_path, manage_models=True) + + from django.apps import apps + from django.db import connection + + with connection.schema_editor() as schema_editor: + for model in apps.get_models(): + if model._meta.managed: + schema_editor.create_model(model) + + else: + # postgres db + # pass + + # self._db = PostgresDatabase(animal=self, **db) + configure_django(db, manage_models=False) + + # import .testmodule + from designdb.animal import HIPPO + + animal = HIPPO( + target_name, target_access_string, stack=stack, auth_token=auth_token + ) + + mrich.success('Initialised animal', f'{target_name}') + return animal diff --git a/hippo/chem.py b/hippo/chem.py deleted file mode 100644 index b60ae35c..00000000 --- a/hippo/chem.py +++ /dev/null @@ -1,401 +0,0 @@ -"""functions for validating chemistry""" - -import mrich - -""" - -Checks -====== - -- Num heavy atoms difference -- Formula checks -- Num rings difference - -""" - -SUPPORTED_CHEMISTRY = { - "Amidation": { - "heavy_atoms_diff": 1, - "rings_diff": 0, - "atomtype": { - "removed": {"O": 1, "H": 2}, - }, - }, - "Ester_amidation": { - "heavy_atoms_diff": ">=3", - "rings_diff": 0, - "atomtype": { - "removed": {"O": ">=1", "*": "*"}, - }, - }, - "Williamson_ether_synthesis": { - "heavy_atoms_diff": 1, - "rings_diff": 0, - "atomtype": { - "removed": {"Ha": 1, "H": 1}, # any halogen - }, - }, - "N-Boc_deprotection": { - "heavy_atoms_diff": 7, - "rings_diff": 0, - "atomtype": { - "removed": {"O": 2, "C": 5, "H": 8}, - }, - }, - "TBS_alcohol_deprotection": { - "heavy_atoms_diff": 7, - "rings_diff": 0, - "atomtype": { - "removed": {"C": 6, "Si": 1, "H": 14}, - }, - }, - "Sp3-sp2_Suzuki_coupling": { - # "heavy_atoms_diff": 10, - "heavy_atoms_diff": ">=4", - "rings_diff": ">=0", - "atomtype": { - # "removed": {"C": 6, "O": 2, "B": 1, "Ha": 1, "H": 12}, # any halogen - "removed": {"C": ">=0", "O": 2, "B": 1, "Ha": 1, "H": ">=2"}, # any halogen - }, - }, - "Sp2-sp2_Suzuki_coupling": { - "heavy_atoms_diff": ">=4", - "rings_diff": ">=0", - "atomtype": { - "removed": {"C": ">=0", "O": 2, "B": 1, "Ha": 1, "H": ">=2"}, # any halogen - }, - }, - "Buchwald-Hartwig_amidation_with_amide-like_nucleophile": { - "heavy_atoms_diff": 1, - "rings_diff": 0, - "atomtype": { - "removed": {"Ha": 1, "H": 1}, - }, - }, - "Buchwald-Hartwig_amination": { - "heavy_atoms_diff": 1, - "rings_diff": 0, - "atomtype": { - "removed": {"Ha": 1, "H": 1}, - }, - }, - "Nucleophilic_substitution_with_amine": { - "heavy_atoms_diff": 1, - "rings_diff": 0, - "atomtype": { - "removed": {"Ha": 1, "H": 1}, - }, - }, - "N-nucleophilic_aromatic_substitution": { - "heavy_atoms_diff": 1, - "rings_diff": 0, - "atomtype": { - "removed": {"Ha": 1, "H": 1}, # any halogen - }, - }, - "Reductive_amination": { - "heavy_atoms_diff": 1, - "rings_diff": 0, - "atomtype": { - "removed": {"O": 1}, # any halogen - }, - }, - "Mitsunobu_reaction_with_amine_alcohol_and_thioalcohol": { - "heavy_atoms_diff": 1, - "rings_diff": 0, - "atomtype": { - "removed": {"O": 1, "H": ">=1"}, - }, - }, - "Steglich_esterification": { - "heavy_atoms_diff": 1, - "rings_diff": 0, - "atomtype": { - "removed": {"O": 1, "H": 2}, - }, - }, - "Benzyl_alcohol_deprotection": { - "heavy_atoms_diff": 7, - "rings_diff": 1, - "atomtype": { - "removed": {"C": 7, "H": 6}, - }, - }, - "N-Bn_deprotection": { - "heavy_atoms_diff": 7, - "rings_diff": 1, - }, - "Formation_of_urea_from_two_amines": { - "heavy_atoms_diff": -2, - "rings_diff": 0, - }, - "Amide_Schotten-Baumann_with_amine": { - "heavy_atoms_diff": 1, - "rings_diff": 0, - }, - "Nucleophilic_substitution": { - "heavy_atoms_diff": 1, - "rings_diff": 0, - }, -} - - -def check_reaction_types(types: list[str]) -> None: - """ - Prints a warning if any of the reaction type strings in ``types`` are not in ``SUPPORTED_CHEMISTRY`` - - :param types: A list of reaction type strings to check - """ - - for reaction_type in types: - if reaction_type not in SUPPORTED_CHEMISTRY: - mrich.error(f"Can't check chemistry of unsupported {reaction_type=}") - - -def check_chemistry( - reaction_type: str, - reactants: "CompoundSet", - product: "Compound", - debug: bool = False, -) -> bool: - """Check chemistry of given reaction""" - - if reaction_type not in SUPPORTED_CHEMISTRY: - - mrich.var("reactants", reactants.ids) - mrich.var("product", product) - - raise UnsupportedChemistryError(f"Unsupported {reaction_type=}") - - assert reactants - assert product - - CHEMISTRY = SUPPORTED_CHEMISTRY[reaction_type] - - if "heavy_atoms_diff" in CHEMISTRY: - check = check_count_diff( - "heavy_atoms", reaction_type, reactants, product, debug=debug - ) - if not check: - return False - - if "rings_diff" in CHEMISTRY: - check = check_count_diff( - "rings", reaction_type, reactants, product, debug=debug - ) - if not check: - return False - - if "atomtype" in CHEMISTRY: - check = check_atomtype_diff(reaction_type, reactants, product, debug=debug) - if not check: - return False - - if debug: - mrich.success(f"{reaction_type}: All OK") - - return True - - -def check_count_diff( - check_type: str, - reaction_type: str, - reactants: "CompoundSet", - product: "Compound", - debug: bool = False, -): - """Check integer difference""" - - # get target value - diff = SUPPORTED_CHEMISTRY[reaction_type][f"{check_type}_diff"] - - # get attribute name - attr = f"num_{check_type}" - - # get values - reac_count = getattr(reactants, attr) - prod_count = getattr(product, attr) - if debug: - mrich.var(f"#{check_type} reactants", reac_count) - if debug: - mrich.var(f"#{check_type} product", prod_count) - - # check against target value - if isinstance(diff, str): - - assert diff.startswith(">="), diff - - diff = int(diff[2:]) - - if reac_count - prod_count < diff: - if debug: - mrich.error( - f"{reaction_type}: #{check_type} {(reac_count - prod_count)=} FAIL" - ) - return False - - elif debug: - mrich.success(f"{reaction_type}: #{check_type} OK") - - else: - - if reac_count - diff != prod_count: - if debug: - mrich.error(f"{reaction_type}: #{check_type} FAIL") - return False - - elif debug: - mrich.success(f"{reaction_type}: #{check_type} OK") - - return True - - -def check_atomtype_diff( - reaction_type: str, - reactants: "CompoundSet", - product: "Compound", - debug: bool = False, -) -> bool: - """check atomtypes""" - - check_type = "atomtype" - - # get values - reac = reactants.atomtype_dict - prod = product.atomtype_dict - - if debug: - mrich.var("reactants.atomtype_dict", str(reac)) - mrich.var("product.atomtype_dict", str(prod)) - - if "removed" in SUPPORTED_CHEMISTRY[reaction_type]["atomtype"]: - removal = check_specific_atomtype_diff( - reaction_type, prod, reac, removal=True, debug=debug - ) - - if not removal: - return False - - if "added" in SUPPORTED_CHEMISTRY[reaction_type]["atomtype"]: - addition = check_specific_atomtype_diff( - reaction_type, prod, reac, removal=False, debug=debug - ) - - if not addition: - return False - - if debug: - mrich.success(f"{reaction_type}: atomtypes OK") - - return True - - -def check_specific_atomtype_diff( - reaction_type: str, - prod: "Compound", - reac: "Compound", - removal: bool = False, - debug: bool = False, -) -> bool: - """check specific atomtype difference""" - - if removal: - add_str = "removed" - else: - add_str = "added" - - add_dict = SUPPORTED_CHEMISTRY[reaction_type]["atomtype"][add_str] - - if not add_dict: - return True - - if debug: - mrich.var(add_str, str(add_dict)) - - for symbol, count in add_dict.items(): - - if symbol == "Ha": - p_count = halogen_count(prod) - r_count = halogen_count(reac) - - elif symbol == "*": - assert count == "*", (symbol, count) - if debug: - mrich.debug("Allowing wildcard atomtype differences") - continue - - else: - p_count = prod[symbol] if symbol in prod else 0 - r_count = reac[symbol] if symbol in reac else 0 - - if isinstance(count, str): - - assert count.startswith(">="), (symbol, count) - - count = int(count[2:]) - - if removal and r_count - p_count < count: - if debug: - mrich.error( - f"{symbol}: {r_count=} - {p_count=} >= {r_count - p_count}" - ) - mrich.error( - f"{reaction_type}: atomtype removal {symbol} × {count} FAIL" - ) - return False - - elif not removal and p_count - r_count < count: - if debug: - mrich.error( - f"{symbol}: {p_count=} - {r_count=} >= {p_count - r_count}" - ) - mrich.error( - f"{reaction_type}: atomtype addition {symbol} × {count} FAIL" - ) - return False - - else: - - if removal and r_count - p_count != count: - if debug: - mrich.error( - f"{symbol}: {r_count=} - {p_count=} = {r_count - p_count}" - ) - mrich.error( - f"{reaction_type}: atomtype removal {symbol} × {count} FAIL" - ) - return False - - elif not removal and p_count - r_count != count: - if debug: - mrich.error( - f"{symbol}: {p_count=} - {r_count=} = {p_count - r_count}" - ) - mrich.error( - f"{reaction_type}: atomtype addition {symbol} × {count} FAIL" - ) - return False - - return True - - -def halogen_count(atomtype_dict: dict[str, int]) -> int: - """Count halogens""" - count = 0 - symbols = ["F", "Cl", "Br", "I"] - for symbol in symbols: - if symbol in atomtype_dict: - count += atomtype_dict[symbol] - return count - - -class InvalidChemistryError(Exception): - """Chemistry is not valid""" - - ... - - -class UnsupportedChemistryError(Exception): - """Chemistry is not supported""" - - ... diff --git a/hippo/compound.py b/hippo/compound.py deleted file mode 100644 index 5476b1a4..00000000 --- a/hippo/compound.py +++ /dev/null @@ -1,1306 +0,0 @@ -"""Classes for working with compounds""" - -import mcol -import mrich - -from rdkit import Chem - -from .pose import Pose -from .tags import TagSet -from .quote import Quote -from .target import Target - - -class Compound: - """A :class:`.Compound` represents a ligand/small molecule with stereochemistry removed and no atomic coordinates. I.e. it represents the chemical structure. It's name is always an InChiKey. If a compound is an elaboration it can have a :meth:`.Compound.scaffolds` property which is another :class:`.Compound`. :class:`.Compound` objects are target-agnostic and can be linked to any number of catalogue entries (:class:`.Quote`) or synthetic pathways (:class:`.Reaction`). - - .. attention:: - - :class:`.Compound` objects should not be created directly. Instead use :meth:`.HIPPO.register_compound` or :meth:`.HIPPO.compounds`. See :doc:`getting_started` and :doc:`insert_elaborations`. - - """ - - _table = "compound" - - def __init__( - self, - animal: "HIPPO", - db: "Database", - id: int, - inchikey: str, - alias: str, - smiles: str, - mol: Chem.Mol | bytes | None = None, - metadata: dict | None = None, - ): - """Compound initialisation""" - - # from compound table - self._id = id - self._inchikey = inchikey - self._alias = alias - self._smiles = smiles - self._animal = animal - self._scaffolds = None - self._elabs = None - self._alias = alias - self._tags = None - self._metadata = metadata - - # computed properties - self._num_heavy_atoms = None - self._num_rings = None - self._formula = None - self._molecular_weight = None - self._total_changes = db.total_changes - - if isinstance(mol, bytes): - mol = Chem.Mol(mol) - - self._mol = mol - - self._db = db - - ### FACTORIES - - ### PROPERTIES - - @property - def id(self) -> int: - """Returns the compound's database ID""" - return self._id - - @property - def inchikey(self) -> str: - """Returns the compound's InChiKey""" - return self._inchikey - - @property - def name(self) -> str: - """Returns the compound's InChiKey""" - if self.alias: - return self.alias - return self.inchikey - - @property - def smiles(self) -> str: - """Returns the compound's (flattened) smiles""" - return self._smiles - - @property - def alias(self) -> str: - """Returns the compound's alias""" - return self._alias - - @alias.setter - def alias(self, alias: str) -> None: - """Set the compound's alias""" - self.set_alias(alias) - - @property - def mol(self) -> Chem.Mol: - """Returns the compound's RDKit Molecule""" - if self._mol is None: - (mol,) = self.db.select_where( - query="mol_to_binary_mol(compound_mol)", - table="compound", - key="id", - value=self.id, - multiple=False, - ) - self._mol = Chem.Mol(mol) - return self._mol - - @property - def num_heavy_atoms(self) -> int: - """Get the number of heavy atoms""" - if self._num_heavy_atoms is None: - self._num_heavy_atoms = self.db.get_compound_computed_property( - "num_heavy_atoms", self.id - ) - return self._num_heavy_atoms - - @property - def molecular_weight(self) -> float: - """Get the molecular weight""" - if self._molecular_weight is None: - self._molecular_weight = self.db.get_compound_computed_property( - "molecular_weight", self.id - ) - return self._molecular_weight - - @property - def num_rings(self) -> int: - """Get the number of rings""" - if self._num_rings is None: - self._num_rings = self.db.get_compound_computed_property( - "num_rings", self.id - ) - return self._num_rings - - @property - def formula(self) -> str: - """Get the chemical formula""" - if self._formula is None: - self._formula = self.db.get_compound_computed_property("formula", self.id) - return self._formula - - @property - def atomtype_dict(self) -> dict[str, int]: - """Get a dictionary with atomtypes as keys and corresponding quantities/counts as values.""" - from molparse.atomtypes import formula_to_atomtype_dict - - return formula_to_atomtype_dict(self.formula) - - @property - def num_atoms_added(self) -> int | list[int] | None: - """Calculate the number of atoms added relative to the scaffold compound""" - match self.num_scaffolds: - case 0: - mrich.error(f"{self} has no scaffold") - return None - case 1: - b_id = self.scaffolds.ids[0] - n_e = self.num_heavy_atoms - n_b = self.db.get_compound_computed_property("num_heavy_atoms", b_id) - return n_e - n_b - case _: - mrich.warning(f"{self} has multiple scaffolds") - n_e = self.num_heavy_atoms - return [ - n_e - - self.db.get_compound_computed_property("num_heavy_atoms", b_id) - for b_id in self.scaffolds.ids - ] - - @property - def metadata(self) -> "MetaData": - """Returns the compound's metadata dict""" - if self._metadata is None: - self._metadata = self.db.get_metadata(table="compound", id=self.id) - return self._metadata - - @property - def db(self) -> "Database": - """Returns a pointer to the parent database""" - return self._db - - @property - def tags(self) -> TagSet: - """Returns the compound's tags""" - if not self._tags: - self._tags = self.get_tags() - return self._tags - - @property - def poses(self) -> "PoseSet": - """Returns the compound's poses""" - return self.get_poses() - - @property - def best_placed_pose(self) -> Pose: - """Returns the compound's pose with the lowest distance score""" - return self.poses.best_placed_pose - - @property - def num_poses(self) -> int: - """Returns the number of associated poses""" - return self.db.count_where(table="pose", key="compound", value=self.id) - - @property - def num_reactions(self) -> int: - """Returns the number of associated reactions (product)""" - return self.db.count_where(table="reaction", key="product", value=self.id) - - @property - def num_reactant(self) -> int: - """Returns the number of associated reactions (reactant)""" - return self.db.count_where(table="reactant", key="compound", value=self.id) - - @property - def scaffolds(self) -> "CompoundSet | None": - """Returns the scaffold compound for this elaboration""" - if self._scaffolds is None or self._db_changed: - ids = self.get_scaffold_ids() - if not ids: - self._scaffolds = None - else: - from .cset import CompoundSet - - self._scaffolds = CompoundSet( - self.db, ids, name=f"scaffold scaffolds of {self}" - ) - self._total_changes = self.db.total_changes - return self._scaffolds - - @property - def num_scaffolds(self) -> int: - """Get the number of scaffold compounds for this elaboration""" - if scaffolds := self.scaffolds: - return len(scaffolds) - else: - return 0 - - @property - def elabs(self): - """Returns the scaffold compound for this elaboration""" - if self._elabs is None or self._db_changed: - ids = self.get_superstructure_ids() - if not ids: - self._elabs = None - else: - from .cset import CompoundSet - - self._elabs = CompoundSet(self.db, ids, name=f"elaborations of {self}") - self._total_changes = self.db.total_changes - return self._elabs - - @property - def reactions(self) -> "ReactionSet": - """Returns the reactions resulting in this compound""" - return self.get_reactions(none=False) - - @property - def reaction(self) -> "Reaction": - """Returns the reaction resulting in this compound (will return first if multiple, with a warning)""" - reactions = self.reactions - match len(reactions): - case 0: - mrich.warning(f"{self} has no reactions") - return None - case 1: - mrich.warning(f"{self} has multiple reactions, returning first") - case _: - pass - - return reactions[0] - - @property - def dict(self) -> dict: - """Returns a dictionary of this compound. See :meth:`.Compound.get_dict`""" - return self.get_dict() - - @property - def is_scaffold(self) -> bool: - """Is this Compound the basis for any elaborations?""" - return bool( - self.db.select_where( - query="1", - table="scaffold", - key="base", - value=self.id, - multiple=False, - none="quiet", - ) - ) - - @property - def is_elab(self) -> bool: - """Is this Compound the based on any other compound?""" - return bool( - self.db.select_where( - query="1", - table="scaffold", - key="superstructure", - value=self.id, - multiple=False, - none="quiet", - ) - ) - - @property - def is_product(self) -> bool: - """Is this Compound a product of at least one reaction""" - return bool(self.get_reactions(none=False)) - - @property - def table(self): - """Returns the name of the :class:`.Database` table""" - return self._table - - @property - def _db_changed(self) -> bool: - """Has the database changed?""" - if self._total_changes != self.db.total_changes: - self._total_changes = self.db.total_changes - return True - return False - - ### METHODS - - def add_stock( - self, - amount: float, - *, - purity: float | None = None, - entry: str | None = None, - location: str | None = None, - return_quote: bool = True, - ) -> int | Quote: - """Register a certain quantity of compound stock in the Database. - - :param amount: Amount in ``mg`` - :param purity: Purity fraction ``0 < purity <= 1``, defaults to ``None`` - :param location: String describing where this stock is located, defaults to ``None`` - :param return_quote: If ``True`` a :class:`.Quote` object is returned instead of its ID, defaults to ``True`` - :returns: The inserted :class:`.Quote` object or ID (see ``return_quote``) - """ - - assert amount - - # search for existing in stock quotes - existing = self.get_quotes(supplier="Stock", df=False) - - # supersede old in stock records - if existing: - delete = set() - not_deleted = 0 - for quote in existing: - - if any( - [ - quote.entry != entry, - quote.purity != purity, - quote.catalogue != location, - ] - ): - not_deleted += 1 - continue - - delete.add(quote.id) - - delete_str = str(tuple(delete)).replace(",)", ")") - - self.db.delete_where(table="quote", key=f"quote_id IN {delete_str}") - - if delete: - mrich.warning(f"Removed {len(delete)} existing In-Stock Quotes") - - if not_deleted: - mrich.warning( - f"Did not remove {not_deleted} existing In-Stock Quotes with differing entry/purity/location" - ) - - # insert the new quote - quote_id = self.db.insert_quote( - compound=self.id, - price=0, - lead_time=0, - currency=None, - supplier="Stock", - catalogue=location, - entry=entry, - amount=amount, - purity=purity, - ) - - if return_quote: - return self.db.get_quote(id=quote_id) - else: - return quote_id - - def get_tags(self) -> "TagSet": - """Get the tags assigned to this compound""" - tags = self.db.select_where( - query="tag_name", - table="tag", - key="compound", - value=self.id, - multiple=True, - none="quiet", - ) - return TagSet(self, {t[0] for t in tags}, commit=False) - - def add_tag( - self, - tag: str, - ) -> None: - """Add this tag to every member of the set""" - assert isinstance(tag, str) - self.db.insert_tag(name=tag, compound=self.id, commit=True) - - def get_quotes( - self, - min_amount: float | None = None, - supplier: str | None = None, - max_lead_time: float | None = None, - none: str = "quiet", - pick_cheapest: bool = False, - df: bool = False, - ) -> list["Quote"]: - """Get all quotes associated to this compound - - :param min_amount: Only return quotes with amounts greater than this, defaults to ``None`` - :param supplier: Only return quotes with the given supplier, defaults to ``None`` - :param max_lead_time: Only return quotes with lead times less than this (in days), defaults to ``None`` - :param none: Define the behaviour when no quotes are found. Choose `error` to raise print an error. - :param pick_cheapest: If ``True`` only the cheapest :class:`.Quote` is returned, defaults to ``False`` - :param df: Returns a ``DataFrame`` of the quoting data, defaults to ``False`` - :returns: List of :class:`.Quote` objects, ``DataFrame``, or single :class:`.Quote`. See ``pick_cheapest`` and ``df`` parameters - - """ - - if not supplier: - quote_ids = self.db.select_where( - query="quote_id", - table="quote", - key="compound", - value=self.id, - multiple=True, - none=none, - ) - elif isinstance(supplier, str): - quote_ids = self.db.select_where( - query="quote_id", - table="quote", - key=f'quote_compound = {self.id} AND quote_supplier = "{supplier}"', - multiple=True, - none=none, - ) - else: - quote_ids = self.db.select_where( - query="quote_id", - table="quote", - key=f'quote_compound = {self.id} AND quote_supplier IN {str(tuple(supplier)).replace(",)",")")}', - multiple=True, - none=none, - ) - - if quote_ids: - quotes = [self.db.get_quote(id=q[0]) for q in quote_ids] - else: - return None - - if max_lead_time: - quotes = [q for q in quotes if q.lead_time <= max_lead_time] - - if min_amount: - suitable_quotes = [q for q in quotes if q.amount >= min_amount] - - if not suitable_quotes: - mrich.debug(f"No quote available with amount >= {min_amount} mg") - quotes = [Quote.combination(min_amount, quotes)] - - else: - quotes = suitable_quotes - - if pick_cheapest: - return sorted(quotes, key=lambda x: x.price)[0] - - if df: - from pandas import DataFrame - - return DataFrame([q.dict for q in quotes]).drop(columns="compound") - - return quotes - - def get_reactions( - self, - as_reactant: bool = False, - permitted_reactions: "ReactionSet" = None, - none: str = "error", - ) -> "ReactionSet": - """Get the associated :class:`.Reaction` objects. By default this function returns all reaction resulting in this :class:`.Compound` as a product, unless ``as_reactant`` is set to ``True``. - - :param as_reactant: Search for :class:`.Reaction` objects using this :class:`.Compound` as a reactant instead of a product, defaults to ``False`` - :param permitted_reactions: Provide a :class:`.ReactionSet` by which to filter the results - :param none: Define the behaviour when no quotes are found. Choose `error` to raise print an error, defaults to ``'error'`` - """ - - from .rset import ReactionSet - - if as_reactant: - reaction_ids = self.db.select_where( - query="reactant_reaction", - table="reactant", - key="compound", - value=self.id, - multiple=True, - none=none, - ) - else: - reaction_ids = self.db.select_where( - query="reaction_id", - table="reaction", - key="product", - value=self.id, - multiple=True, - none=none, - ) - - reaction_ids = [q for q, in reaction_ids] - - if permitted_reactions: - reaction_ids = [i for i in reaction_ids if i in permitted_reactions] - - rset = ReactionSet(self.db, reaction_ids) - - if not permitted_reactions: - rset._name = f"reactions resulting in {str(self)}" - - return rset - - def get_poses(self) -> "PoseSet": - """Get the associated :class:`.Pose` objects.""" - - pose_ids = self.db.select_where( - query="pose_id", - table="pose", - key="compound", - value=self.id, - multiple=True, - none=False, - ) - - from .pset import PoseSet - - return PoseSet(self.db, [q[0] for q in pose_ids], name=f"{self}'s poses") - - def get_dict( - self, - *, - mol: bool = True, - alias: bool = True, - inchikey: bool = True, - metadata: bool = True, - poses: bool = True, - count_by_target: bool = False, - num_reactant: bool = True, - num_reactions: bool = True, - scaffolds: bool = True, - elabs: bool = True, - tags: bool = True, - ) -> "dict": - """Returns a dictionary representing this :class:`.Compound` - - :param mol: Include a ``rdkit.Chem.Mol object``, defaults to ``True`` - :param metadata: Include metadata, defaults to ``True`` - :param poses: Include dictionaries of associated :class:`.Pose` objects, defaults to ``True`` - :param count_by_target: Include counts by protein :class:`.Target`, defaults to ``False``. Only applicable when ``count_by_target = True``. - :param num_reactant: include num_reactant column - :param num_reactions: include num_reactions column - :param scaffolds: include scaffolds column - :param elabs: include elabs column - :param tags: include tags column - :returns: A dictionary - """ - - serialisable_fields = [ - "id", - "smiles", - ] - - if alias: - serialisable_fields.append("alias") - if inchikey: - serialisable_fields.append("inchikey") - if num_reactant: - serialisable_fields.append("num_reactant") - if num_reactions: - serialisable_fields.append("num_reactions") - - data = {} - for key in serialisable_fields: - data[key] = getattr(self, key) - - if mol: - try: - data["mol"] = self.mol - except InvalidMolError: - data["mol"] = None - - if scaffolds: - if self.scaffolds: - data["scaffolds"] = self.scaffolds.ids - else: - data["scaffolds"] = None - - if elabs: - if self.elabs: - data["elabs"] = self.elabs.ids - else: - data["elabs"] = None - - if tags: - data["tags"] = self.tags - - if poses: - - poses = self.poses - - if poses: - - data["poses"] = poses.ids - data["targets"] = poses.target_names - - if count_by_target: - target_ids = poses.target_ids - - for target in self._animal.targets: - t_poses = poses(target=target.id) or [] - data[f"#poses {target.name}"] = len(t_poses) - - if metadata and (metadict := self.metadata): - for key in metadict: - data[key] = metadict[key] - - return data - - def get_recipes( - self, - *, - amount: float = 1, - debug: bool = False, - pick_cheapest: bool = False, - quoted_only: bool = False, - supplier: None | str = None, - **kwargs, - ): - """Get :class:`.Recipe` objects that result in this compound. See :meth:`.Recipe.from_compounds`""" - - from .recipe import Recipe - from .cset import CompoundSet - - return Recipe.from_compounds( - CompoundSet(self.db, [self.id]), - amount=amount, - debug=debug, - pick_cheapest=pick_cheapest, - quoted_only=quoted_only, - supplier=supplier, - **kwargs, - ) - - def get_scaffold_ids(self) -> list[int]: - """Get a list of :class:`.Compound` ID's that this object is a superstructure of""" - ids = self.db.select_where( - table="scaffold", - query="scaffold_base", - key="superstructure", - value=self.id, - none="quiet", - multiple=True, - ) - if not ids: - return None - return [i for i, in ids] - - def get_superstructure_ids(self) -> list[int]: - """Get a list of :class:`.Compound` ID's that this object is a substructure of""" - ids = self.db.select_where( - table="scaffold", - query="scaffold_superstructure", - key="base", - value=self.id, - none="quiet", - multiple=True, - ) - if not ids: - return None - return [i for i, in ids] - - def add_scaffold(self, scaffold: "Compound | int", commit: bool = True) -> None: - """ - Add a scaffold :class:`.Compound` this molecule is derived from. - - :param scaffold: The scaffold :class:`.Compound` or its ID. - :param commit: Commit the changes to the :class:`.Database`, defaults to ``True`` - """ - - if not isinstance(scaffold, int): - assert scaffold._table == "compound" - scaffold = scaffold.id - self.db.insert_scaffold(scaffold=scaffold, superstructure=self.id) - - def set_alias(self, alias: str, commit=True) -> None: - """ - Set this :class:`.Compound`'s alias. - - :param alias: The alias - :param commit: Commit the changes to the :class:`.Database`, defaults to ``True`` - """ - - assert isinstance(alias, str) - self._alias = alias - self.db.update( - table="compound", - id=self.id, - key="compound_alias", - value=alias, - commit=commit, - ) - - def as_ingredient( - self, - amount: float, - max_lead_time: float = None, - supplier: str = None, - get_quote: bool = True, - quote_none: str = "quiet", - ) -> "Ingredient": - """Convert this compound into an :class:`.Ingredient` object with an associated amount (in ``mg``) and :class:`.Quote` if available. - - :param amount: Amount in ``mg`` - :param supplier: Only search for quotes with the given supplier, defaults to ``None`` - :param max_lead_time: Only search for quotes with lead times less than this (in days), defaults to ``None`` - """ - - if get_quote: - quote = self.get_quotes( - pick_cheapest=True, - min_amount=amount, - max_lead_time=max_lead_time, - supplier=supplier, - none=quote_none, - ) - - if not quote: - quote = None - - else: - quote = None - - return Ingredient( - db=self.db, - compound=self.id, - amount=amount, - quote=quote, - supplier=supplier, - max_lead_time=max_lead_time, - ) - - def draw(self, scaffolds: bool = True, align_substructure: bool = False) -> None: - """Display this compound (and its scaffold if it has one) - - .. attention:: - - This method is only intended for use within a Jupyter Notebook. - - :param align_substructure: Align the two drawing by their common substructure, defaults to ``False`` - """ - - if scaffolds and (scaffolds := self.scaffolds): - - from molparse.rdkit import draw_mcs - - data = {} - for scaffold in scaffolds: - data[scaffold.smiles] = f"{scaffold} (scaffold)" - data[self.smiles] = str(self) - - if len(data) > 1: - - drawing = draw_mcs( - data, - align_substructure=align_substructure, - show_mcs=False, - highlight=False, - ) - display(drawing) - - else: - mrich.error( - f"Problem drawing {scaffold.id=} vs {self.id=}, self referential?" - ) - display(self.mol) - - else: - display(self.mol) - - def draw_elabs(self): - """Draw elaborations""" - - from molparse.rdkit import draw_highlighted_mol - from rdkit.Chem import rdRGroupDecomposition, MolFromSmarts - - elabs = self.elabs - - display(self) - display(elabs) - - if not elabs: - mrich.error(self, "has no elaborations") - return self.draw() - - # set RGD params - params = rdRGroupDecomposition.RGroupDecompositionParameters() - params.removeAllHydrogenRGroups = False - params.removeAllHydrogenRGroupsAndLabels = True - params.removeHydrogensPostMatch = True - - # do the RGD - rgd = rdRGroupDecomposition.RGroupDecomposition( - MolFromSmarts(self.smiles), params - ) - for mol in elabs.mols: - rgd.Add(mol) - rgd.Process() - - # Get the R-group decomposition results - rgroup_table = rgd.GetRGroupsAsColumns() - - # get the core and its attachment points - core = rgroup_table["Core"][0] - attachment_points = set() - for rgroup in rgroup_table["Core"]: - for atom in rgroup.GetAtoms(): - if atom.GetAtomicNum() == 0: # Dummy atom (R-group attachment point) - attachment_points.add(atom.GetIdx()) - - # display the annotated core - drawing = draw_highlighted_mol( - core, [(i, (0.5, 1, 0.5)) for i in attachment_points] - ) - display(drawing) - - def classify( - self, - draw: bool = True, - ) -> list[tuple[str, int]]: - """ - Find RDKit Fragments within the compound molecule and draw them - - :param draw: Draw the annotated molecule, defaults to ``True`` - :returns: A list of tuples containing a descriptor (``str``) and count (``int``) pair - """ - # from molparse.rdkit import classify_mol - from molparse.rdkit.classify import classify_mol - - return classify_mol(self.mol, draw=draw) - - def murcko_scaffold( - self, - generic: bool = False, - ): - """Get the rdkit MurckoScaffold for this compound""" - - from rdkit.Chem.Scaffolds import MurckoScaffold - - scaffold = MurckoScaffold.GetScaffoldForMol(self.mol) - - if generic: - scaffold = MurckoScaffold.MakeScaffoldGeneric(scaffold) - - return scaffold - - def summary( - self, metadata: bool = True, draw: bool = True, tags: bool = True - ) -> None: - """ - Print a summary of this compound - - :param metadata: Include metadata, defaults to ``True`` - :param draw: Include a 2D molecule drawing, defaults to ``True`` - """ - - mrich.header(self) - - mrich.var("inchikey", self.inchikey) - mrich.var("alias", self.alias) - mrich.var("smiles", self.smiles) - mrich.var("scaffolds", self.scaffolds) - mrich.var("elabs", self.elabs) - - mrich.var("is_scaffold", self.is_scaffold) - mrich.var("is_elab", self.is_elab) - mrich.var("num_heavy_atoms", self.num_heavy_atoms) - mrich.var("num_rings", self.num_rings) - mrich.var("formula", self.formula) - - mrich.var("#reactions (product)", self.num_reactions) - mrich.var("#reactions (reactant)", self.num_reactant) - - if tags: - mrich.var("tags", self.tags) - - poses = self.poses - mrich.var("#poses", len(poses)) - if poses: - mrich.var("targets", poses.targets) - - if metadata: - mrich.var("metadata", str(self.metadata)) - - if draw: - self.draw() - - def place( - self, - *, - reference: Pose, - inspirations: list[Pose] | None = None, - max_ddG: float = 0.0, - max_RMSD: float = 2.0, - output_dir: str = "wictor_place", - tags: list[str] = None, - metadata: dict = None, - overwrite: bool = False, - ) -> Pose: - """ - Generate a new pose for this compound using Fragmenstein. - - :param reference: Choose the :class:`.Pose` to use as the reference protein conformation - :param inspirations: Choose the (virtual) hits to to define the ligand reference, defaults to the ``reference``'s inspirations - :param max_ddG: Maximum ``ddG`` value permitted for a valid ligand conformation, defaults to ``0.0`` - :param max_RMSD: Maximum ``RMSD`` value permitted for a valid ligand conformation, defaults to ``2.0`` - :param output_dir: Output directory for Fragmenstein files, defaults to ``wictor_place`` - :param tags: Tags to assign to the created pose, defaults to ``[]`` - :param metadata: A dictionary of metadata to assign to this compound, defaults to ``{}`` - :param overwrite: Delete old poses, defaults to ``False`` - """ - - from fragmenstein import Monster, Wictor - from pathlib import Path - - tags = tags or [] - metadata = metadata or {} - - # get required data - smiles = self.smiles - - inspirations = inspirations or reference.inspirations - target = reference.target.name - - inspiration_mols = [c.mol for c in inspirations] - protein_pdb_block = reference.protein_system.pdb_block_with_alt_sites - - # create the victor - victor = Wictor(hits=inspiration_mols, pdb_block=protein_pdb_block) - victor.work_path = output_dir - victor.enable_stdout(logging.CRITICAL) - - # do the placement - victor.place(smiles, long_name=self.name) - - # metadata - metadata["ddG"] = ( - victor.energy_score["bound"]["total_score"] - - victor.energy_score["unbound"]["total_score"] - ) - metadata["RMSD"] = victor.mrmsd.mrmsd - - if metadata["ddG"] > max_ddG: - return None - - if metadata["RMSD"] > max_RMSD: - return None - - # register the pose - pose = self._animal.register_pose( - compound=self, - target=target, - path=Path(victor.work_path) / self.name / f"{self.name}.minimised.mol", - inspirations=inspirations, - reference=reference, - tags=tags, - metadata=metadata, - ) - - if overwrite: - ids = [p.id for p in self.poses if p.id != pose.id] - for i in ids: - self.db.delete_where(table="pose", key="id", value=i) - mrich.success(f"Successfully posed {self} (and deleted old poses)") - else: - mrich.success(f"Successfully posed {self}") - - return pose - - def get_inspirations(self, debug: bool = True, none: str = "warning") -> "PoseSet": - """Since inspirations map :class:`.Pose` objects to each other rather than :class:`.Compound` objects, this only works if there are poses registerd for this compound or it's elaborations/superstructures. - - :returns: a :class:`.PoseSet` object - """ - - from .pset import PoseSet - - sql = """ - SELECT pose_id, inspiration_original FROM compound - INNER JOIN scaffold ON compound_id = scaffold_base - INNER JOIN pose ON compound_id = pose_compound - INNER JOIN inspiration ON pose_id = inspiration_derivative - WHERE compound_id = :compound_id - """ - - with mrich.spinner(f"Querying inspirations for {self}"): - records = self.db.execute(sql, dict(compound_id=self.id)).fetchall() - - if not records and none in ("warning", "warn"): - mrich.warning("Could not determine inspirations for", self) - return None - - derivatives = PoseSet(self.db, set(a for a, b in records)) - inspirations = PoseSet(self.db, set(b for a, b in records)) - - if debug: - mrich.debug(f"Inspirations derived from {derivatives.ids}") - - inspirations._name = f"Inspirations for {self}" - - return inspirations - - ### DUNDERS - - def __str__(self) -> str: - """Unformatted string representation""" - return f"C{self.id}" - - def __repr__(self) -> str: - """ANSI Formatted string representation""" - return f'{mcol.bold}{mcol.underline}{self} "{self.name}"{mcol.unbold}{mcol.ununderline}' - - def __rich__(self) -> str: - """Representation for mrich""" - return f'[bold underline]{self} "{self.name}"' - - def __eq__(self, other) -> bool: - """Compare compounds""" - assert isinstance(other, Compound) - return self.id == other.id - - -class Ingredient: - """An ingredient is a :class:`.Compound` with a fixed quanitity and an attached quote. - - .. image:: ../images/ingredient.png - :width: 450 - :alt: Ingredient schema - - .. attention:: - - :class:`.Ingredient` objects should not be created directly. Instead use :meth:`.Compound.as_ingredient`. - """ - - _table = "ingredient" - - def __init__( - self, - db: "Database", - compound: "Compound | int", - amount: float, - quote: "Quote | None", - max_lead_time: float | None = None, - supplier: str | None = None, - ) -> "Ingredient": - """Ingredient initialisation""" - - assert compound - - self._db = db - - # don't store inherited compound in memory until needed - self._compound = None - - if isinstance(compound, Compound): - self._compound_id = compound.id - self._compound = None - else: - self._compound_id = compound - - if isinstance(quote, Quote): - - if id := quote.id: - self._quote_id = quote.id - self._quote = None - - else: - self._quote_id = None - self._quote = quote - - elif quote is None: - self._quote_id = None - self._quote = None - - else: - self._quote_id = int(quote) - self._quote = None - - self._amount = amount - self._max_lead_time = max_lead_time - self._supplier = supplier - self._total_changes = db.total_changes - - ### PROPERTIES - - @property - def db(self) -> "Database": - """Returns the parent :class:`.Database`""" - return self._db - - @property - def amount(self) -> float: - """Returns the amount (in ``mg``)""" - return self._amount - - @property - def id(self) -> int: - """Returns the ID of the associated :class:`.Compound`""" - return self._compound_id - - @property - def compound_id(self) -> int: - """Returns the ID of the associated :class:`.Compound`""" - return self._compound_id - - @property - def quote_id(self) -> int: - """Returns the ID of the associated :class:`.Quote`""" - return self._quote_id - - @property - def max_lead_time(self) -> float: - """Returns the max_lead_time (in days) from the original quote query""" - return self._max_lead_time - - @property - def supplier(self) -> str: - """Returns the supplier from the original quote query""" - return self._supplier - - @amount.setter - def amount(self, a) -> None: - """Set the amount and fetch updated :class:`.Quote`s""" - - quote_id = self.get_cheapest_quote_id( - min_amount=a, - max_lead_time=self._max_lead_time, - supplier=self._supplier, - none="quiet", - ) - - self._quote_id = quote_id - - self._amount = a - - @property - def compound(self) -> Compound: - """Returns the associated :class:`.Compound`""" - - if not self._compound: - self._compound = self.db.get_compound(id=self.compound_id) - return self._compound - - @property - def quote(self) -> Quote: - """Returns the associated :class:`.Quote`""" - - if self._quote is None: - - if q_id := self.quote_id: - self._quote = self.db.get_quote(id=self.quote_id) - - else: - q = self.compound.get_quotes( - pick_cheapest=True, - min_amount=self.amount, - max_lead_time=self.max_lead_time, - supplier=self.supplier, - none="quiet", - ) - - if not q: - return None - - self._quote = q - self._quote_id = q.id - - return self._quote - - @property - def compound_price_amount_str(self) -> str: - """String representation including :class:`.Compound`, :class:`.Price`, and amount.""" - return f"{self} ({self.amount})" - - @property - def smiles(self) -> str: - """Returns the SMILES of the associated :class:`.Compound`""" - return self.compound.smiles - - @property - def price(self) -> "Price | None": - """Returns the :class:`.Price` of the associated :class:`.Quote`""" - if self.quote: - return self.quote.price - else: - return None - - @property - def lead_time(self) -> float | None: - """Returns the lead time (in days) of the associated :class:`.Quote`""" - if self.quote: - return self.quote.lead_time - else: - return None - - @property - def _db_changed(self) -> bool: - """Has the database changed?""" - if self._total_changes != self.db.total_changes: - self._total_changes = self.db.total_changes - return True - return False - - ### METHODS - - def get_cheapest_quote_id( - self, - min_amount: float | None = None, - supplier: str | None = None, - max_lead_time: float | None = None, - none: str = "quiet", - ) -> int | None: - """ - Query quotes associated to this ingredient, and return the cheapest - - :param min_amount: Only return quotes with amounts greater than this, defaults to ``None`` - :param supplier: Only return quotes with the given supplier, defaults to ``None`` - :param max_lead_time: Only return quotes with lead times less than this (in days), defaults to ``None`` - :param none: Define the behaviour when no quotes are found. Choose `error` to raise print an error. - """ - - supplier_str = f' AND quote_supplier IS "{supplier}"' if supplier else "" - lead_time_str = ( - f" AND quote_lead_time <= {max_lead_time}" if max_lead_time else "" - ) - key_str = f"quote_compound IS {self.compound_id} AND quote_amount >= {min_amount}{supplier_str}{lead_time_str} ORDER BY quote_price" - - result = self.db.select_where( - query="quote_id", table="quote", key=key_str, multiple=False, none=none - ) - - if result: - (quote_id,) = result - return quote_id - - else: - return None - - def get_quotes(self, **kwargs) -> list["Quote"]: - """Wrapper for :meth:`.Compound.get_quotes()`""" - return self.compound.get_quotes(**kwargs) - - ### DUNDERS - - def __str__(self) -> str: - """Plain string representation""" - return f"{self.amount:.2f}mg of C{self._compound_id}" - - def __repr__(self) -> str: - """ANSI Formatted string representation""" - return f"{mcol.bold}{mcol.underline}{str(self)}{mcol.unbold}{mcol.ununderline}" - - def __rich__(self) -> str: - """Representation for mrich""" - return f"[bold underline]{str(self)}" - - def __eq__(self, other) -> bool: - """Equality operator""" - - if self.compound_id != other.compound_id: - return False - - return self.amount == other.amount - - def __getattr__(self, key: str): - """For missing attributes try getting from associated :class:`.Compound`""" - return getattr(self.compound, key) diff --git a/hippo/cset.py b/hippo/cset.py deleted file mode 100644 index af3cecee..00000000 --- a/hippo/cset.py +++ /dev/null @@ -1,2999 +0,0 @@ -"""Classes for working with sets of compounds""" - -import mcol -import mrich - -import os -from typing import Callable -from numpy import int64, nan, isnan, mean, std - -from .db import Database -from .recipe import Recipe -from .compound import Compound, Ingredient - - -class CompoundTable: - """Class representing all :class:`.Compound` objects in the 'compound' table of the :class:`.Database`. - - .. attention:: - - :class:`.CompoundTable` objects should not be created directly. Instead use the :meth:`.HIPPO.compounds` property. See :doc:`getting_started` and :doc:`insert_elaborations`. - - Use as an iterable - ================== - - Iterate through :class:`.Compound` objects in the table: - - :: - - for compound in animal.compounds: - ... - - - Selecting compounds in the table - ================================ - - The :class:`.CompoundTable` can be indexed with :class:`.Compound` IDs, names, aliases, or list/sets/tuples/slices thereof: - - :: - - ctable = animal.compounds - - # indexing individual compounds - comp = ctable[13] # using the ID - comp = ctable["BSYNRYMUTXBXSQ-UHFFFAOYSA-N"] # using the InChIKey - comp = ctable["aspirin"] # using the alias - - # getting a subset of compounds - cset = ctable[13,15,18] # using IDs (tuple) - cset = ctable[[13,15,18]] # using IDs (list) - cset = ctable[set(13,15,18)] # using IDs (set) - cset = ctable[13:18] # using a slice - - Tags and scaffold compounds can also be used to filter: - - :: - - cset = animal.compounds(tag='hits') # select compounds tagged with 'hits' - cset = animal.compounds(scaffold=comp) # select elaborations of comp - - """ - - _table = "compound" - _name = "all compounds" - - def __init__( - self, - db: Database, - ) -> None: - """CompoundTable initialisation""" - - self._db = db - - ### PROPERTIES - - @property - def db(self) -> Database: - """Returns the associated :class:`.Database`""" - return self._db - - @property - def table(self) -> str: - """Returns the name of the :class:`.Database` table""" - return self._table - - @property - def names(self) -> list[str]: - """Returns the names of child compounds""" - result = self.db.select(table=self.table, query="compound_name", multiple=True) - return [q for q, in result] - - @property - def name(self) -> None | str: - """Optional name of this compound set""" - return self._name - - @property - def ids(self) -> list[int]: - """Returns the IDs of child compounds""" - result = self.db.select(table=self.table, query="compound_id", multiple=True) - return [q for q, in result] - - @property - def str_ids(self) -> str: - """Return an SQL formatted tuple string of the :class:`.Compound` IDs""" - return str(tuple(self.ids)).replace(",)", ")") - - @property - def inchikeys(self) -> list[str]: - """Returns the inchikeys of all compounds""" - result = self.db.select( - query="compound_inchikey", - table="compound", - multiple=True, - ) - return [q for q, in result] - - @property - def tags(self) -> set[str]: - """Returns the set of unique tags present in this compound set""" - values = self.db.select_where( - table="tag", - query="DISTINCT tag_name", - key="tag_compound IS NOT NULL", - multiple=True, - ) - return set(v for v, in values) - - @property - def reactants(self) -> "CompoundSet": - """Returns a :class:`.CompoundSet` of all compounds that are used as a reactants""" - # ids = self.db.select(table='reactant', query='DISTINCT reactant_compound', multiple=True) - ids = self.db.execute( - "SELECT reactant_compound FROM reactant LEFT JOIN reaction ON reactant.reactant_compound = reaction.reaction_product WHERE reaction.reaction_product IS NULL" - ).fetchall() - ids = [q for q, in ids] - from .cset import CompoundSet - - cset = CompoundSet(self.db, ids) - cset._name = "all reactants" - return cset - - @property - def products(self) -> "CompoundSet": - """Returns a :class:`.CompoundSet` of all compounds that are a product of a reaction but not a reactant""" - ids = self.db.execute( - "SELECT reaction_product FROM reaction LEFT JOIN reactant ON reaction.reaction_product = reactant.reactant_compound WHERE reactant.reactant_compound IS NULL" - ).fetchall() - ids = [q for q, in ids] - from .cset import CompoundSet - - cset = CompoundSet(self.db, ids) - cset._name = "all products" - return cset - - @property - def intermediates(self) -> "CompoundSet": - """Returns a :class:`.CompoundSet` of all compounds that are products and reactants""" - ids = self.db.execute( - "SELECT DISTINCT reaction_product FROM reaction INNER JOIN reactant ON reaction.reaction_product = reactant.reactant_compound" - ).fetchall() - ids = [q for q, in ids] - from .cset import CompoundSet - - cset = CompoundSet(self.db, ids) - cset._name = "all intermediates" - return cset - - @property - def num_reactants(self) -> int: - """Returns the number of reactants (see :meth:`CompoundTable.reactants`)""" - return len(self.reactants) - - @property - def num_intermediates(self) -> int: - """Returns the number of intermediates (see :meth:`CompoundTable.intermediates`)""" - return len(self.intermediates) - - @property - def num_products(self) -> int: - """Returns the number of products (see :meth:`CompoundTable.products`)""" - return len(self.products) - - @property - def elabs(self) -> "CompoundSet": - """Returns a :class:`.CompoundSet` of all compounds that are a an elaboration of an existing scaffold""" - ids = self.db.select_where( - query="scaffold_superstructure", - table="scaffold", - key="scaffold_superstructure IS NOT NULL", - multiple=True, - none="quiet", - ) - - if not ids: - return None - - ids = [q for q, in ids] - from .cset import CompoundSet - - cset = CompoundSet(self.db, ids) - cset._name = "all elaborations" - return cset - - @property - def scaffolds(self) -> "CompoundSet": - """Returns a :class:`.CompoundSet` of all compounds that are the basis for a set of elaborations""" - ids = self.db.select_where( - query="DISTINCT scaffold_base", - table="scaffold", - key="scaffold_base IS NOT NULL", - multiple=True, - none="quiet", - ) - ids = [q for q, in ids] - from .cset import CompoundSet - - cset = CompoundSet(self.db, ids) - cset._name = "all scaffolds" - return cset - - @property - def num_elabs(self) -> int: - """Returns the number of compounds that are a an elaboration of an existing scaffold""" - return len(self.elabs) - - @property - def num_scaffolds(self) -> int: - """Returns the number of compounds that are the basis for a set of elaborations""" - return len(self.scaffolds) - - ### METHODS - - def get_by_tag( - self, - tag: str, - inverse: bool = False, - ) -> "CompoundSet": - """Get all child compounds with a certain tag - - :param tag: tag to filter by - - """ - - if not inverse: - - values = self.db.select_where( - query="tag_compound", table="tag", key="name", value=tag, multiple=True - ) - - else: - - values = self.db.select_where( - query="tag_compound", table="tag", key="name", value=tag, multiple=True - ) - - if not values: - return self - - ids = [v for v, in values if v] - - values = self.db.select_where( - query="compound_id", - table="compound", - key=f"compound_id NOT IN {str(tuple(ids))}", - multiple=True, - ) - - if not values: - return None - - ids = [v for v, in values if v] - cset = self[ids] - if inverse: - cset._name = f"compounds not tagged {tag}" - else: - cset._name = f"compounds tagged {tag}" - return cset - - def get_by_metadata( - self, - key: str, - value: str | None = None, - ): - """Get all child compounds by their metadata. If no value is passed, then simply containing the key in the metadata dictionary is sufficient - - :param key: metadata key - :param value: metadata value (Default value = None) - - """ - results = self.db.select( - query="compound_id, compound_metadata", table="compound", multiple=True - ) - if value is None: - ids = [i for i, d in results if d and f'"{key}":' in d] - name = f"compounds with {key} in metadata" - else: - if isinstance(value, str): - value = f'"{value}"' - ids = [i for i, d in results if d and f'"{key}": {value}' in d] - name = f"compounds with metadata[{key}] == {value}" - - cset = self[ids] - cset._name = name - return cset - - def get_by_metadata_substring_match( - self, - substring: str, - ) -> "CompoundSet": - """Get :class:`.CompoundSet` of poses with metadata JSON containing substring""" - - assert substring - assert isinstance(substring, str) - - compound_ids = self.db.select_where( - table="compound", - query="compound_id", - key=f"""compound_metadata LIKE '%{substring}%'""", - multiple=True, - ) - - if not compound_ids: - mrich.error(f"No compounds with metadata substring: {substring}") - return None - - compound_ids = [i for i, in compound_ids] - - name = f"compounds with '{substring}' in metadata" - - cset = self[compound_ids] - cset._name = name - - return cset - - def get_by_scaffold( - self, - scaffold: Compound | int, - ) -> "CompoundSet": - """Get all compounds that elaborate the given scaffold compound - - :param scaffold: :class:`.Compound` object or ID to search by - - """ - - if not isinstance(scaffold, int): - assert scaffold._table == "compound" - scaffold = scaffold.id - - values = self.db.select_where( - query="scaffold_superstructure", - table="scaffold", - key="base", - value=scaffold, - multiple=True, - ) - ids = [v for v, in values if v] - cset = self[ids] - cset._name = f"elaborations of C{scaffold}" - return cset - - def get_by_smiles(self, smiles: str, **kwargs) -> "Compound | None": - """Get a member compound by its smiles""" - - from .tools import inchikey_from_smiles, sanitise_smiles, SanitisationError - - assert isinstance(smiles, str), f"Non-string {smiles=}" - try: - smiles = sanitise_smiles(smiles, sanitisation_failed="error") - except SanitisationError as e: - mrich.error(f"Could not sanitise {smiles=}") - mrich.error(str(e)) - return None - except AssertionError: - mrich.error(f"Could not sanitise {smiles=}") - return None - return c - inchikey = inchikey_from_smiles(smiles) - return self.db.get_compound(inchikey=inchikey, **kwargs) - - def summary(self) -> None: - """Print a summary of this compound set""" - mrich.header("CompoundTable()") - mrich.var("#compounds", len(self)) - # mrich.var('#poses', self.num_poses) - mrich.var("tags", self.tags) - mrich.var("#scaffolds", self.num_scaffolds) - mrich.var("#elabs", self.num_elabs) - mrich.var("#reactants", self.num_reactants) - mrich.var("#intermediates", self.num_intermediates) - mrich.var("#products", self.num_products) - - def draw(self) -> None: - """2D grid of drawings of molecules in this set - - .. attention:: - - This method instantiates a :class:`.CompoundSet` containing all compounds, it is recommended to instead select a subset for display. This method is only intended for use within a Jupyter Notebook. - - """ - return self[self.ids].draw() - - def interactive(self) -> None: - """Interactive widget to navigate compounds in the table - - .. attention:: - - This method instantiates a :class:`.CompoundSet` containing all compounds, it is recommended to instead select a subset for display. This method is only intended for use within a Jupyter Notebook. - - """ - self[self.ids].interactive() - - def plot_tsnee(self, **kwargs) -> "go.Figure": - """Plot a tanimoto similarity plot of these compounds. See :func:`hippo.plotting.plot_compound_tsnee`""" - return self[:].plot_tsnee(**kwargs) - - def write_smiles_csv(self, file: str) -> None: - """Write a CSV of the smiles contained in this set to a file - - :param file: path of the CSV file - - """ - from pandas import DataFrame - - records = self.db.execute( - """SELECT compound_id, compound_smiles FROM compound ORDER BY compound_id""" - ).fetchall() - - data = [dict(id=id, smiles=smiles) for id, smiles in records] - - df = DataFrame(data) - mrich.writing(file) - df.to_csv(file, index=False) - - ### DUNDERS - - def __call__( - self, - *, - tag: str = None, - scaffold: int | Compound = None, - smiles: str | None = None, - ids: list | set | None = None, - sort: bool = True, - **kwargs, - ) -> "CompoundSet | Compound | None": - """Filter compounds by a given tag, scaffold, or it's SMILES string. See :meth:`.CompoundTable.get_by_tag` and :meth:`.CompoundTable.get_by_scaffold` - - :param tag: optional tag to filter by - :param scaffold: optional :class:`.Compound` ID or object to filter by - :param smiles: optional SMILES string to filter by - :param ids: optional set of :class:`.Compound` ID's - :param sort: sort :class:`.Compound` ID's - :returns: :class:`.CompoundSet` if searching by tag or scaffold, else :class:`.Compound` object - - """ - - if tag: - return self.get_by_tag(tag, **kwargs) - elif scaffold: - return self.get_by_scaffold(scaffold, **kwargs) - elif smiles: - return self.get_by_smiles(smiles, **kwargs) - elif ids: - return CompoundSet(self.db, indices=list(ids), sort=sort) - else: - mrich.error("Must provide one of tag, scaffold, or smiles arguments") - return None - - def __getitem__( - self, - key: int | str | tuple | list | set | slice, - ) -> Compound: - """Get a member :class:`.Pose` object or subset :class:`.PoseSet` thereof. - - :param key: Can be an integer ID, negative integer index, alias or inchikey string, list/set/tuple of IDs, or slice of IDs - - """ - - from numpy import ndarray - from pandas import Index, Series - - match key: - - # case int(): - case key if isinstance(key, int) or isinstance(key, int64): - - if key == 0: - return self.__getitem__(key=1) - - if key < 0: - key = len(self) + 1 + key - return self.__getitem__(key=key) - - else: - return self.db.get_compound(id=key) - - case str(): - comp = self.db.get_compound(inchikey=key, none="quiet") - if not comp: - comp = self.db.get_compound(alias=key) - return comp - - case key if ( - isinstance(key, list) - or isinstance(key, tuple) - or isinstance(key, set) - or isinstance(key, ndarray) - or isinstance(key, Index) - or isinstance(key, Series) - ): - - if isinstance(key, Index): - assert key.nlevels == 1 - - if isinstance(key, ndarray): - assert len(key.shape) == 1 - - indices = [] - for i in key: - if isinstance(i, int) or isinstance(i, int64): - index = i - elif isinstance(i, float): - index = int(i) - elif isinstance(i, str): - index = self.db.get_compound_id(inchikey=i) - else: - raise NotImplementedError - - assert index - indices.append(index) - - return CompoundSet(self.db, indices) - - case slice(): - ids = self.db.slice_ids( - table=self.table, start=key.start, stop=key.stop, step=key.step - ) - return self[ids] - - case _: - mrich.error( - f"Unsupported type for CompoundTable.__getitem__(): {key=} {type(key)}" - ) - - return None - - def __str__(self) -> str: - """Unformatted string representation""" - - if self.name: - s = f"{self.name}: " - else: - s = "" - - s += "{" f"C × {len(self)}" "}" - - return s - - def __repr__(self) -> str: - """ANSI ormatted string representation""" - return f"{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}" - - def __rich__(self) -> str: - """Representation for mrich""" - return f"[bold underline]{self}" - - def __len__(self) -> int: - """Total number of compounds""" - return self.db.count(self.table) - - def __iter__(self): - """Iterate through all compounds""" - return iter(self[i + 1] for i in range(len(self))) - - -class CompoundSet: - """Object representing a subset of the 'compound' table in the :class:`.Database`. - - .. attention:: - - :class:`.CompoundSet` objects should not be created directly. Instead use the :meth:`.HIPPO.compounds` property. See :doc:`getting_started` and :doc:`insert_elaborations`. - - Use as an iterable - ================== - - Iterate through :class:`.Compound` objects in the set: - - :: - - cset = animal.compounds[:100] - - for compound in cset: - ... - - Check membership - ================ - - To determine if a :class:`.Compound` is present in the set: - - :: - - is_member = compound in cset - - Selecting compounds in the set - ============================== - - The :class:`.CompoundSet` can be indexed like standard Python lists by their indices - - :: - - cset = animal.compounds[1:100] - - # indexing individual compounds - comp = cset[0] # get the first compound - comp = cset[1] # get the second compound - comp = cset[-1] # get the last compound - - # getting a subset of compounds using a slice - cset2 = cset[13:18] # using a slice - - Tags and scaffold compounds can also be used to filter: - - :: - - cset = animal.compounds(tag='hits') # select compounds tagged with 'hits' - cset = animal.compounds(scaffold=comp) # select elaborations of comp - - """ - - _table = "compound" - - def __init__( - self, - db: Database, - indices: list = None, - sort: bool = True, - name: str | None = None, - ) -> None: - """CompoundSet initialisation""" - - self._db = db - - indices = indices or [] - - if not isinstance(indices, list): - indices = list(indices) - - indices = [int(i) for i in indices] - - if sort: - self._indices = sorted(list(set(indices))) - else: - self._indices = list(indices) - - self._name = name - self._total_changes = db.total_changes - - ### PROPERTIES - - @property - def db(self) -> "Database": - """Associated :class:`.Database` object""" - return self._db - - @property - def table(self) -> str: - """Get the name of the database table""" - return self._table - - @property - def indices(self) -> list[int]: - """Returns the ids of compounds in this set""" - return self._indices - - @property - def ids(self) -> list[int]: - """Returns the ids of compounds in this set""" - return self.indices - - @property - def name(self) -> str | None: - """Returns the name of set""" - return self._name - - @property - def names(self) -> list[str]: - """Returns the aliases of compounds in this set""" - result = self.db.select_where( - query="compound_alias", - table="compound", - key=f"compound_id in {self.str_ids}", - multiple=True, - ) - return [q for q, in result] - - @property - def smiles(self) -> list[str]: - """Returns the smiles of child compounds""" - result = self.db.select_where( - query="compound_smiles", - table="compound", - key=f"compound_id in {self.str_ids}", - multiple=True, - ) - return [q for q, in result] - - @property - def mols(self) -> "list[Chem.Mol]": - """Returns the molecules of child compounds""" - from rdkit.Chem import Mol - - result = self.db.select_where( - query="mol_to_binary_mol(compound_mol)", - table="compound", - key=f"compound_id in {self.str_ids}", - multiple=True, - ) - return [Mol(q) for q, in result] - - @property - def inchikeys(self) -> list[str]: - """Returns the inchikeys of compounds in this set""" - result = self.db.select_where( - query="compound_inchikey", - table="compound", - key=f"compound_id in {self.str_ids}", - multiple=True, - ) - return [q for q, in result] - - @property - def tags(self) -> set[str]: - """Returns the set of unique tags present in this compound set""" - values = self.db.select_where( - table="tag", - query="DISTINCT tag_name", - key=f"tag_compound in {self.str_ids}", - multiple=True, - ) - if not values: - return set() - return set(v for v, in values) - - @property - def num_poses(self) -> int: - """Count the poses associated to this set of compounds""" - from .pset import PoseSet - - return self.db.count_where(table="pose", key=f"pose_compound in {self.str_ids}") - - @property - def poses(self) -> "PoseSet": - """Get the poses associated to this set of compounds""" - from .pset import PoseSet - - ids = self.db.select_where( - query="pose_id", - table="pose", - key=f"pose_compound in {self.str_ids}", - multiple=True, - none="warning", - ) - - if not ids: - return PoseSet(self.db, {}) - - ids = [v for v, in ids] - return PoseSet(self.db, ids) - - @property - def best_placed_poses(self) -> "PoseSet": - """Get the best placed pose for each compound in this set""" - from .pset import PoseSet - - query = self.db.select_where( - table="pose", - query="pose_id, MIN(pose_distance_score)", - key=f"pose_compound in {self.str_ids} GROUP BY pose_compound", - multiple=True, - ) - ids = [i for i, s in query] - return PoseSet(self.db, ids) - - @property - def str_ids(self) -> str: - """Return an SQL formatted tuple string of the :class:`.Compound` IDs""" - return str(tuple(self.ids)).replace(",)", ")") - - @property - def num_heavy_atoms(self) -> int: - """Get the total number of heavy atoms""" - return sum([c.num_heavy_atoms for c in self]) - - @property - def num_rings(self): - """Get the total number of molecular rings""" - return sum([c.num_rings for c in self]) - - @property - def formula(self) -> str: - """Get the combined chemical formula for all compounds""" - from molparse.atomtypes import atomtype_dict_to_formula - - return atomtype_dict_to_formula(self.atomtype_dict) - - @property - def atomtype_dict(self) -> dict[str, int]: - """Get a dictionary with atomtypes as keys and corresponding quantities/counts as values""" - from molparse.atomtypes import formula_to_atomtype_dict, combine_atomtype_dicts - - atomtype_dicts = [c.atomtype_dict for c in self] - return combine_atomtype_dicts(atomtype_dicts) - - @property - def num_atoms_added(self) -> list[int]: - """Calculate the number of atoms added w.r.t the scaffold - - :returns: list of number of atoms added values - - """ - - query = self.db.execute( - f""" - WITH nums AS ( - SELECT A.compound_id AS comp_id, - mol_num_hvyatms(A.compound_mol) - mol_num_hvyatms(B.compound_mol) AS diff - FROM compound A, compound B - WHERE A.compound_base = B.compound_id - AND A.compound_id IN {self.str_ids} - ) - - SELECT compound_id, diff FROM compound - LEFT JOIN nums - ON comp_id = compound_id - WHERE compound_id IN {self.str_ids} - """ - ).fetchall() - - lookup = {k: v for k, v in query} - - return [lookup[i] for i in self.indices] - - @property - def avg_num_atoms_added(self) -> float: - """Calculate the average number of atoms added w.r.t the scaffold - - :returns: average number of atoms added values for compounds which have a scaffold - - """ - - (avg,) = self.db.execute( - f""" - WITH nums AS ( - SELECT A.compound_id AS comp_id, - mol_num_hvyatms(A.compound_mol) - mol_num_hvyatms(B.compound_mol) AS diff - FROM compound A, compound B - WHERE A.compound_base = B.compound_id - AND A.compound_id IN {self.str_ids} - ) - - SELECT AVG(diff) FROM compound - INNER JOIN nums - ON comp_id = compound_id - WHERE compound_id IN {self.str_ids} - """ - ).fetchone() - - return avg - - @property - def risk_diversity(self) -> float: - """Calculate the average spread of risk (#atoms added) for each scaffold in this set - - :returns: average of the standard deviations of number of atoms added for each scaffold - - """ - - return self.get_risk_diversity() - - @property - def elaboration_balance(self) -> float: - """Measure of how evenly elaborations are distributed across scaffolds in this set""" - - sql = f""" - SELECT COUNT(1) FROM scaffold - WHERE scaffold_superstructure IN {self.str_ids} - GROUP BY scaffold_base - """ - - counts = self.db.execute(sql).fetchall() - - counts = [c for c, in counts] # + [0 for _ in range(len(self)-len(counts))] - - from hirsch import hirsch - - return hirsch(counts) - - # return -std(counts) - - @property - def num_scaffolds_elaborated(self) -> int: - """Count the number of scaffold compounds that have at least one elaboration in this set - - :returns: number of scaffold compounds - - """ - - (count,) = self.db.execute( - f""" - SELECT COUNT(DISTINCT scaffold_base) FROM scaffold - WHERE scaffold_superstructure IN {self.str_ids} - """ - ).fetchone() - - return count - - @property - def scaffolds(self) -> "CompoundSet": - """Get the scaffold compounds that have at least one elaboration in this set - - :returns: :class:`.CompoundSet` - - """ - return CompoundSet(self.db, self.scaffold_ids) - - @property - def scaffold_ids(self) -> list[int]: - """Return a list of :class:`.Compound` ID's for scaffolds of this set""" - scaffold_ids = self.db.execute( - f""" - SELECT DISTINCT scaffold_base FROM scaffold - WHERE scaffold_superstructure IN {self.str_ids} - """ - ).fetchall() - return [i for i, in scaffold_ids] - - @property - def num_scaffolds(self) -> int: - """Return a count of scaffolds of this set""" - (count,) = self.db.execute( - f""" - SELECT COUNT(DISTINCT scaffold_base) FROM scaffold - WHERE scaffold_superstructure IN {self.str_ids} - """ - ).fetchone() - return count - - @property - def elabs(self) -> "CompoundSet": - """Returns a :class:`.CompoundSet` of all compounds that are a an elaboration of an existing scaffold""" - - ids = self.db.select_where( - query="scaffold_superstructure", - table="scaffold", - key=f"scaffold_superstructure IS NOT NULL and scaffold_base IN {self.str_ids}", - multiple=True, - none="quiet", - ) - - if not ids: - return None - - ids = [q for q, in ids] - from .cset import CompoundSet - - return CompoundSet(self.db, ids) - - @property - def num_elabs(self) -> int: - """Return a count of elaborations of this set""" - (count,) = self.db.execute( - f""" - SELECT COUNT(DISTINCT scaffold_superstructure) FROM scaffold - WHERE scaffold_base IN {self.str_ids} - """ - ).fetchone() - return count - - @property - def elab_df(self) -> "pd.DataFrame": - """Get a DataFrame summarising the elaborations in this CompoundSet""" - from pandas import DataFrame - - cluster_dict = self.db.get_compound_cluster_dict(max_scaffolds=1) - - data = [] - for scaffold, elabs in cluster_dict.items(): - scaffold = self.db.get_compound(id=scaffold[0]) - elabs = CompoundSet(self.db, indices=elabs) - data.append( - dict( - scaffold_id=scaffold.id, - scaffold_compound=scaffold, - elabs=elabs, - num_elabs=len(elabs), - ) - ) - - return DataFrame(data) - - @property - def id_num_poses_dict(self) -> dict[int, int]: - """Get a dictionary mapping compound ids to the number of poses""" - - sql = f""" - SELECT pose_compound, COUNT(1) FROM pose - WHERE pose_compound IN {self.str_ids} - GROUP BY pose_compound - """ - - records = self.db.execute(sql) - - assert records - - lookup = {k: v for k, v in records} - - for id in self.ids: - if id not in lookup: - lookup[id] = 0 - - return lookup - - @property - def _db_changed(self) -> bool: - """Has the database changed?""" - if self._total_changes != self.db.total_changes: - self._total_changes = self.db.total_changes - return True - return False - - @property - def reaction_ids(self) -> list[int]: - """Returns a list of :class:`.Reaction` IDs that result in members of this set""" - records = self.db.select_where( - table="reaction", - query="reaction_id", - key=f"reaction_product IN {self.str_ids}", - multiple=True, - ) - if not records: - return None - return [r for r, in records] - - ### FILTERING - - def get_by_tag( - self, - tag: str, - inverse: bool = False, - ) -> "CompoundSet": - """Get all child compounds with a certain tag""" - - values = self.db.select_where( - query="tag_compound", - table="tag", - key=f'tag_name = "{tag}" AND tag_compound IN {self.str_ids}', - multiple=True, - ) - - if inverse: - matches = set(v for v, in values) - ids = [i for i in self.ids if i not in matches] - else: - ids = [v for v, in values] - - return CompoundSet(self.db, ids) - - def get_by_metadata(self, key: str, value: str | None = None) -> "CompoundSet": - """Get all child compounds with by their metadata. If no value is passed, then simply containing the key in the metadata dictionary is sufficient - - :param key: metadata key - :param value: metadata value (Default value = None) - """ - - results = self.db.select_where( - query="compound_id, compound_metadata", - table="compound", - key=f"compound_id IN {self.str_ids}", - multiple=True, - ) - if value is None: - ids = [i for i, d in results if d and f'"{key}":' in d] - else: - if isinstance(value, str): - value = f'"{value}"' - ids = [i for i, d in results if d and f'"{key}": {value}' in d] - return CompoundSet(self.db, ids) - - def get_by_metadata_substring_match( - self, - substring: str, - ) -> "CompoundSet": - """Get :class:`.CompoundSet` of poses with metadata JSON containing substring""" - - assert substring - assert isinstance(substring, str) - - compound_ids = self.db.select_where( - table="compound", - query="compound_id", - key=f"""compound_metadata LIKE '%{substring}%' AND compound_id IN {self.str_ids}""", - multiple=True, - ) - - if not compound_ids: - mrich.error(f"No compounds with metadata substring: {substring}") - return None - - compound_ids = [i for i, in compound_ids] - - name = f"compounds with '{substring}' in metadata" - - cset = CompoundSet(self.db, compound_ids) - cset._name = name - - return cset - - def get_by_scaffold( - self, - scaffold: Compound | int, - none: str = "error", - ) -> "CompoundSet": - """Get all compounds that elaborate the given scaffold compound - - :param scaffold: :class:`.Compound` object or ID to search by - - """ - - if not isinstance(scaffold, int): - assert scaffold._table == "compound" - scaffold = scaffold.id - - values = self.db.select_where( - query="scaffold_superstructure", - table="scaffold", - key=f"scaffold_base = {scaffold} AND scaffold_superstructure IN {self.str_ids}", - multiple=True, - none=none, - ) - ids = [v for v, in values if v] - - if not ids: - return None - return CompoundSet(self.db, ids) - - def get_all_possible_reactants( - self, - debug: bool = False, - ) -> "CompoundSet": - """Recursively searches for all the reactants that could possible be needed to synthesise these compounds. - - :param debug: Increased verbosity for debugging (Default value = False) - - """ - all_reactants, all_reactions = self.db.get_unsolved_reaction_tree( - product_ids=self.ids, debug=debug - ) - return all_reactants - - def get_all_possible_reactions( - self, - debug: bool = False, - ) -> "ReactionSet": - """Recursively searches for all the reactants that could possible be needed to synthesise these compounds. - - :param debug: Increased verbosity for debugging (Default value = False) - - """ - all_reactants, all_reactions = self.db.get_unsolved_reaction_tree( - product_ids=self.ids, debug=debug - ) - return all_reactions - - def get_risk_diversity(self, debug: bool = False) -> float: - """Calculate the average spread of risk (#atoms added) for each scaffold in this set - - :returns: average of the standard deviations of number of atoms added for each scaffold - - """ - - variances = self.db.execute( - f""" - WITH nums AS ( - SELECT scaffold_base AS base, scaffold_superstructure AS elab, - mol_num_hvyatms(c2.compound_mol) - mol_num_hvyatms(c1.compound_mol) AS diff - FROM scaffold - INNER JOIN compound AS c1 ON scaffold_base = c1.compound_id - INNER JOIN compound AS c2 ON scaffold_superstructure = c2.compound_id - WHERE scaffold_superstructure IN {self.str_ids} - ), - - means AS ( - SELECT base, AVG(diff) AS mean FROM nums - GROUP BY base - ) - - SELECT AVG((nums.diff - mean)*(nums.diff - mean)) var FROM nums - LEFT JOIN means - ON nums.base = means.base - GROUP BY nums.base - """ - ).fetchall() - - if not variances: - return None - - variances = [v for v, in variances] - - if debug: - mrich.debug(f"{variances=}") - - return mean(variances) - - def count_by_tag( - self, - tag: str, - ) -> "CompoundSet": - """Count all child compounds with a certain tag - - :param tag: tag to filter by - - """ - (count,) = self.db.select_where( - query="COUNT(tag_compound)", - table="tag", - key=f'tag_name = "{tag}" AND tag_compound IN {self.str_ids}', - multiple=False, - ) - return count - - ### CONSOLE / NOTEBOOK OUTPUT - - def draw(self) -> None: - """Draw a grid of all contained molecules. - - .. attention:: - - This method is only intended for use within a Jupyter Notebook. - - """ - - from molparse.rdkit import draw_grid - - data = [(str(c), c.mol) for c in self] - - mols = [d[1] for d in data] - labels = [d[0] for d in data] - - display(draw_grid(mols, labels=labels)) - - def grid(self) -> None: - """Draw a grid of all contained molecules. - - .. attention:: - - This method is only intended for use within a Jupyter Notebook. - - """ - - self.draw() - - def summary(self, return_df: bool = False) -> None: - """Print a summary of this compound set""" - - mrich.header(self) - - from pandas import DataFrame - - sql = f""" - SELECT tag_name, - COUNT(DISTINCT tag_compound) - FROM tag - WHERE tag_compound IN {self.str_ids} - GROUP BY tag_name - ORDER BY tag_name - """ - - cursor = self.db.execute(sql) - - data = [dict(tag=a, num_compounds=b) for a, b in cursor.fetchall()] - - df = DataFrame(data) - df = df.set_index("tag") - - # poses - - sql = f""" - SELECT tag_name, - COUNT(DISTINCT tag_pose) - FROM tag - INNER JOIN pose - ON pose_id = tag_pose - WHERE pose_compound IN {self.str_ids} - GROUP BY tag_name - ORDER BY tag_name - """ - - cursor = self.db.execute(sql) - - for tag, count in cursor.fetchall(): - df.loc[tag, "num_poses"] = count - - # compounds with poses - - sql = f""" - SELECT tag_name, COUNT(DISTINCT pose_compound) FROM tag - INNER JOIN pose - ON tag_pose = pose_id - WHERE pose_compound IN {self.str_ids} - GROUP BY tag_name - ORDER BY tag_name - """ - - cursor = self.db.execute(sql) - - for tag, count in cursor.fetchall(): - df.loc[tag, "num_posed_compounds"] = count - - df.loc["TOTAL", "num_compounds"] = len(self) - df.loc["TOTAL", "num_poses"] = self.num_poses - df.loc["TOTAL", "num_posed_compounds"] = len(self.poses.compounds) - - df = df.fillna(0) - df = df.astype(int) - - if return_df: - return df - else: - mrich.print(df) - - def interactive( - self, - function: Callable | None = None, - ) -> None: - """Creates a ipywidget to interactively navigate this PoseSet.""" - - from ipywidgets import ( - interactive, - BoundedIntText, - Checkbox, - interactive_output, - HBox, - GridBox, - Layout, - VBox, - ) - from IPython.display import display - from pprint import pprint - - if function: - - def widget(i): - """interactive function widget""" - compound = self[i] - display(compound) - function(compound) - - return interactive( - widget, - i=BoundedIntText( - value=0, - min=0, - max=len(self) - 1, - step=1, - description=f"Comp (/{len(self)}):", - disabled=False, - ), - ) - - else: - - a = BoundedIntText( - value=0, - min=0, - max=len(self) - 1, - step=1, - description=f"Comp (/{len(self)}):", - disabled=False, - ) - - b = Checkbox(description="Name", value=True) - c = Checkbox(description="Summary", value=False) - d = Checkbox(description="2D", value=True) - e = Checkbox(description="Poses", value=False) - f = Checkbox(description="Reactions", value=False) - g = Checkbox(description="Tags", value=False) - h = Checkbox(description="Quotes", value=False) - i = Checkbox(description="Metadata", value=False) - j = Checkbox(description="Classify", value=False) - - ui1 = GridBox( - [b, c, d], layout=Layout(grid_template_columns="repeat(3, 100px)") - ) - ui2 = GridBox( - [e, f, g], layout=Layout(grid_template_columns="repeat(3, 100px)") - ) - ui3 = GridBox( - [h, i, j], layout=Layout(grid_template_columns="repeat(3, 100px)") - ) - ui = VBox([a, ui1, ui2, ui3]) - - def widget( - i, - name: bool = True, - summary: bool = True, - draw: bool = True, - poses: bool = True, - reactions: bool = True, - tags: bool = True, - quotes: bool = True, - metadata: bool = True, - classify: bool = True, - ): - """interactive default widget""" - """ - - :param i: param name: (Default value = True) - :param summary: Default value = True) - :param draw: Default value = True) - :param poses: Default value = True) - :param reactions: Default value = True) - :param metadata: Default value = True) - :param name: (Default value = True) - - """ - comp = self[i] - - if name and not summary: - print(repr(comp)) - - if summary: - comp.summary(metadata=False, draw=False, tags=False) - - if draw: - comp.draw() - - if poses and (pset := comp.poses): - for p in pset: - mrich.print(p) - pset.draw() - - if reactions and (reactions := comp.reactions): - for r in reactions: - mrich.print(r) - r.draw() - - if tags: - mrich.title("Tags") - mrich.print(comp.tags) - - if quotes: - mrich.title("Quotes") - display(comp.get_quotes(df=True)) - - if metadata: - mrich.title("Metadata:") - mrich.print(comp.metadata) - - if classify: - mrich.title("Classification:") - comp.classify() - - out = interactive_output( - widget, - { - "i": a, - "name": b, - "summary": c, - "draw": d, - "poses": e, - "reactions": f, - "tags": g, - "quotes": h, - "metadata": i, - "classify": j, - }, - ) - - display(ui, out) - - def tag_summary(self) -> "pd.DataFrame": - """Print a summary table of tags with compound counts""" - - from pandas import DataFrame - - sql = f""" - SELECT tag_name, - COUNT(DISTINCT tag_compound) - FROM tag - WHERE tag_compound IN {self.str_ids} - GROUP BY tag_name - ORDER BY tag_name; - """ - - cursor = self.db.execute(sql) - - data = [dict(tag=a, num_compounds=b) for a, b in cursor.fetchall()] - - df = DataFrame(data) - df = df.set_index("tag") - - df = df.astype(int) - - mrich.print(df) - - return df - - ### OTHER METHODS - - def add(self, compound: Compound | int) -> None: - """Add a compound to this set - - :param compound: compound to be added - - """ - - if isinstance(compound, Compound): - compound = compound.id - - if compound not in self.ids: - from bisect import insort - - insort(self.ids, compound) - - def get_recipes( - self, - amount: float = 1, - debug: bool = False, - pick_cheapest: bool = False, - permitted_reactions: "ReactionSet | None" = None, - quoted_only: bool = False, - supplier: None | str = None, - **kwargs, - ): - """Generate the :class:`.Recipe` to make these compounds. - - See :meth:`.Recipe.from_compounds` - """ - from .recipe import Recipe - - return Recipe.from_compounds( - self, - amount=amount, - debug=debug, - pick_cheapest=pick_cheapest, - permitted_reactions=permitted_reactions, - quoted_only=quoted_only, - supplier=supplier, - **kwargs, - ) - - def get_routes( - self, - permitted_reactions: "None | ReactionSet" = None, - return_ids: bool = False, - debug: bool = True, - ) -> "RouteSet": - """Get a RoutSet to products in this set. - - :param permitted_reactions: optionally restrict reactions to those in this :class:`.ReactionSet` - - """ - - if "route" not in self.db.table_names: - mrich.error("route table not in Database") - raise NotImplementedError - - if permitted_reactions is not None: - - sql = f""" - SELECT route_id, route_product, component_ref FROM route - INNER JOIN component ON route_id = component_route - WHERE route_product IN {self.str_ids} - AND component_type = 1 - """ - - permitted_reactions = set(permitted_reactions.ids) - - if debug: - mrich.debug("Querying database for routes") - records = self.db.execute(sql).fetchall() - - if debug: - mrich.debug("Assembling route dictionary") - - routes = {} - for route_id, route_product, reaction_id in records: - if route_id not in routes: - routes[route_id] = dict(product=route_product, reactions=set()) - assert routes[route_id]["product"] == route_product - routes[route_id]["reactions"].add(reaction_id) - - if debug: - mrich.debug("Checking availability") - - available_routes = set() - for route_id, route_dict in routes.items(): - product = route_dict["product"] - assert product in self - reactions = route_dict["reactions"] - if all(r in permitted_reactions for r in reactions): - available_routes.add(route_id) - - if return_ids: - return list(available_routes) - - routes = [ - self.db.get_route(id=route_id) - for route_id in mrich.track(available_routes, prefix="Getting routes") - ] - - else: - - sql = f""" - SELECT route_id FROM route - WHERE route_product IN {self.str_ids} - """ - - if debug: - mrich.debug("Querying database for routes") - records = self.db.execute(sql).fetchall() - - if return_ids: - return [i for i, in records] - - routes = [ - self.db.get_route(id=route_id) - for route_id, in mrich.track(records, prefix="Getting routes") - ] - - from .recipe import RouteSet - - return RouteSet(self.db, routes) - - def copy(self) -> "CompoundSet": - """Returns a copy of this set""" - return CompoundSet(self.db, self.ids) - - def shuffled(self) -> "CompoundSet": - """Returns a randomised copy of this set""" - copy = self.copy() - copy.shuffle() - return copy - - def pop(self) -> Compound: - """Pop the last compound in this set""" - c_id = self.pop_id() - return self.db.get_compound(id=c_id) - - def pop_id(self) -> int: - """Pop the last compound id in this set""" - return self._indices.pop() - - def shuffle(self) -> None: - """Randomises the order of compounds in this set""" - from random import shuffle - - shuffle(self._indices) - - def get_df( - self, - smiles: bool = True, - inchikey: bool = False, - alias: bool = True, - mol: bool = False, - metadata: bool = False, - expand_metadata: bool = True, - poses: bool = False, - num_reactant: bool = False, - num_reactions: bool = False, - num_poses: bool = False, - tags: bool = False, - scaffolds: bool = False, - elabs: bool = False, - routes: bool = False, - debug: bool = False, - **kwargs, - ) -> "DataFrame": - """Get a DataFrame representation of this set - - :param smiles: include SMILES column (Default value = True) - :param inchikey: include InChIKey column (Default value = False) - :param alias: include alias column (Default value = True) - :param mol: include ``rdkit.Chem.Mol`` in output (Default value = False) - :param metadata: include metadata in output (Default value = False) - :param expand_metadata: create separate column for each metadata key (Default value = True) - :param poses: include poses in output (Default value = False) - :param num_reactant: include num_poses column - :param num_reactant: include num_reactant column (number of reactions where compound is a reactant) - :param num_reactions: include num_reactions column (number of reactions where compound is a product) - :param tags: include tags column - :param scaffolds: include scaffolds column - :param elabs: include elabs column - - """ - - from json import loads - from rdkit.Chem import Mol - from pandas import DataFrame - - data = [] - - query = ["compound_id"] - - if smiles: - query.append("compound_smiles") - - if inchikey: - query.append("compound_inchikey") - - if alias: - query.append("compound_alias") - - if mol: - query.append("mol_to_binary_mol(compound_mol)") - - if metadata: - query.append("compound_metadata") - - query = ", ".join(query) - - sql = f""" - SELECT {query} - FROM compound - WHERE compound_id IN {self.str_ids} - """ - - if debug: - mrich.debug("querying...") - records = self.db.execute(sql).fetchall() - - if debug: - generator = mrich.track(records) - else: - generator = records - - for row in generator: - - row = list(row) - - d = dict(id=row.pop(0)) - - if smiles: - d["smiles"] = row.pop(0) - - if inchikey: - d["inchikey"] = row.pop(0) - - if alias: - d["alias"] = row.pop(0) - - if mol: - d["mol"] = Mol(row.pop(0)) - - if metadata and (meta_str := row.pop(0)): - - meta_dict = loads(meta_str) - - if expand_metadata: - for k, v in meta_dict.items(): - d[k] = v - - else: - d["metadata"] = meta_dict - - data.append(d) - - df = DataFrame(data) - - if poses or num_poses: - if debug: - mrich.debug("adding pose column") - - lookup = self.db.get_compound_id_pose_ids_dict(self) - if poses: - df["poses"] = df["id"].apply(lambda x: lookup.get(x, {})) - if num_poses: - df["num_poses"] = df["id"].apply(lambda x: len(lookup.get(x, {}))) - - if num_reactant or num_reactions: - if debug: - mrich.debug("adding reaction columns") - tuples = self.db.get_reactant_product_tuples(self.ids, deduplicated=False) - - if num_reactant: - lookup = {} - for r, p in tuples: - lookup.setdefault(r, 0) - lookup[r] += 1 - df["num_reactant"] = df["id"].apply(lambda x: lookup.get(x, 0)) - - if num_reactions: - lookup = {} - for r, p in tuples: - lookup.setdefault(p, 0) - lookup[p] += 1 - df["num_reactions"] = df["id"].apply(lambda x: lookup.get(x, 0)) - - if scaffolds or elabs: - if debug: - mrich.debug("adding scaffold columns") - tuples = self.db.get_scaffold_tuples(self.ids) - - if scaffolds: - lookup = {} - for b, e in tuples: - lookup.setdefault(e, set()) - lookup[e].add(b) - df["scaffolds"] = df["id"].apply(lambda x: lookup.get(x, set())) - - if elabs: - lookup = {} - for b, e in tuples: - lookup.setdefault(b, set()) - lookup[b].add(e) - df["elabs"] = df["id"].apply(lambda x: lookup.get(x, set())) - - if tags: - if debug: - mrich.debug("adding tag column") - lookup = self.db.get_compound_tag_dict() - df["tags"] = df["id"].apply(lambda x: lookup.get(x, {})) - - if routes: - if debug: - mrich.debug("adding route column") - lookup = self.db.get_product_id_routes_dict() - df["routes"] = df["id"].apply(lambda x: lookup.get(x, {})) - - df = df.set_index("id") - - return df - - def get_quoted( - self, - *, - supplier: str = "any", - ) -> "CompoundSet": - """Get all member compounds that have a quote from given supplier - - :param supplier: supplier name (Default value = 'any') - - """ - - if supplier == "any": - key = f"quote_compound IN {self.str_ids}" - else: - key = f'quote_compound IN {self.str_ids} AND quote_supplier = "{supplier}"' - - ids = self.db.select_where( - table="quote", - query="DISTINCT quote_compound", - key=key, - multiple=True, - ) - - ids = [i for i, in ids] - return CompoundSet(self.db, ids) - - def get_unquoted( - self, - *, - supplier: str = "any", - ) -> "CompoundSet": - """Get all member compounds that do not have a quote from given supplier - - :param supplier: supplier name (Default value = 'any') - - """ - - quoted = self.get_quoted(supplier=supplier) - return self - quoted - - def get_dict(self) -> dict: - """Get a dictionary object with all serialisable data needed to reconstruct this set""" - return dict(db=str(self.db.path.resolve()), indices=self.indices) - - def write_smiles_csv( - self, file: str, tags: bool = True, split_tags: bool = True - ) -> None: - """Write a CSV of the smiles contained in this set to a file - - :param file: path of the CSV file - :param tags: include tags in output - :param split_tags: split tags into separate columns - - """ - from pandas import DataFrame - - if tags: - records = self.db.select_where( - table="tag", - query="tag_compound, tag_name", - key=f"tag_compound IN {self.str_ids}", - multiple=True, - none="quiet", - ) - TAGS = {} - if records: - for compound_id, tag_name in records: - if compound_id not in TAGS: - TAGS[compound_id] = set() - TAGS[compound_id].add(tag_name) - - records = self.db.select_where( - table=self.table, - query="compound_id, compound_smiles", - key=f"compound_id IN {self.str_ids}", - multiple=True, - ) - - data = [dict(id=id, smiles=smiles) for id, smiles in records] - - if tags: - for d in data: - - tagset = TAGS.get(d["id"], set()) - - if split_tags: - for tag in tagset: - d[tag] = True - else: - d["tags"] = tagset - - df = DataFrame(data) - mrich.writing(file) - df.to_csv(file, index=False) - - def write_postera_csv( - self, - file, - *, - supplier: str = "Enamine", - prefix: str = "fragment", - ) -> None: - """Write a CSV formatted for upload to Postera's Manifold - - :param file: path of the CSV file - :param supplier: supplier to use for quotes, (Default value = 'Enamine') - :param prefix: prefix to metadata columns, (Default value = 'fragment') - - """ - - from datetime import date as dt - from pandas import DataFrame - - if prefix: - prefix = f"{prefix}_" - - data = [] - - for c in mrich.track(self, prefix="Creating DataFrame"): - - # get props - smiles = c.smiles - tags = c.tags - metadata = c.metadata - poses = c.poses - scaffold = c.scaffold - - # method - assert len(tags) == 1, c - method = tags[0] - - # date - date = dt.today() - - # author - assert "author" in metadata, c - author = metadata["author"] - - match len(poses): - case 1: - pose = poses[0] - case 0: - mrich.warning(f"{c} has no poses") - assert scaffold - pose = scaffold.poses[0] - case _: - mrich.warning(f"{c} has multiple poses") - pose = poses[0] - - # extract inspirations - inspirations = pose.inspirations - inspiration_names = ",".join(inspirations.names) - inspiration_smiles = ".".join(inspirations.smiles) - - # quote info - quotes = c.get_quotes(supplier=supplier) - assert len(quotes) == 1, c - quote = quotes[0] - catalog_id = quote.entry - catalog_price = quote.price - catalog_lead_time = quote.lead_time - - # hippo string - hippo_str = f"compound={c.id}, pose={pose.id}" - - # create row - data.append( - { - "SMILES": smiles, - f"{prefix}HIPPO_IDs": hippo_str, - f"{prefix}method": method, - f"{prefix}export_date": date, - f"{prefix}author": author, - f"{prefix}inspiration_names": inspiration_names, - f"{prefix}inspiration_SMILES": inspiration_smiles, - f"{prefix}supplier": supplier, - f"{prefix}supplier_catalogue": quote.catalogue, - f"{prefix}supplier_ID": catalog_id, - f"{prefix}supplier_price": catalog_price, - f"{prefix}supplier_lead_time": catalog_lead_time, - } - ) - - df = DataFrame(data) - - mrich.writing(file) - df.to_csv(file, index=False) - - return df - - def write_CAR_csv( - self, - file: "str | Path", - amount: float = 1, # in mg - return_df: bool = False, - # pick_cheapest: bool = False, - quoted_only: bool = False, - get_ingredient_quotes: bool = True, - **kwargs, - ) -> "DataFrame | None": - """List of reactions for CAR - - Columns: - - * target-name - * no-steps - * concentration = None - * amount-required - * batch-tag - - per reaction - - * reactant-1-1 - * reactant-2-1 - * reaction-product-smiles-1 - * reaction-name-1 - * reaction-recipe-1 - * reaction-groupby-column-1 - - :param file: output file - :param amount: amount of each product in `mg` - :param quoted_only: only choose reactants that have quotes - :param supplier: only choose reactants that have quotes from this supplier - :param kwargs: passed to :meth:`.Recipe.from_reaction` - :param return_df: return a `DataFrame` (Default value = False) - - """ - - from pathlib import Path - from pandas import DataFrame - from .recipe import Recipe - - file = str(Path(file).resolve()) - - rows = [] - - for r_id in mrich.track(self.reaction_ids, prefix="Solving compound recipes"): - - reaction = self.db.get_reaction(id=r_id) - - recipes = Recipe.from_reaction( - reaction, - amount=amount, - pick_cheapest=False, - quoted_only=quoted_only, - get_ingredient_quotes=get_ingredient_quotes, - **kwargs, - ) - - for sub_recipe in recipes: - - product = sub_recipe.product - - row = { - "target-names": str(product.compound), - "no-steps": 0, - "concentration-required-mM": None, - "amount-required-uL": None, - "batch-tag": None, - } - - for i, reaction in enumerate(sub_recipe.reactions): - - i = i + 1 - - row["no-steps"] += 1 - - match len(reaction.reactants): - case 1: - row[f"reactant-1-{i}"] = reaction.reactants[0].smiles - row[f"reactant-2-{i}"] = None - case 2: - row[f"reactant-1-{i}"] = reaction.reactants[0].smiles - row[f"reactant-2-{i}"] = reaction.reactants[1].smiles - case _: - raise NotImplementedError( - f"Unsupported number of reactants for {reaction=}: {len(reaction.reactants)}" - ) - - row[f"reaction-product-smiles-{i}"] = reaction.product.smiles - row[f"reaction-name-{i}"] = reaction.type - row[f"reaction-recipe-{i}"] = None - row[f"reaction-groupby-column-{i}"] = None - # row[f'reaction-id-{i}'] = int(reaction.id) - - rows.append(row) - - df = DataFrame(rows) - - df = df.convert_dtypes() - - for n_steps in set(df["no-steps"]): - subset = df[df["no-steps"] == n_steps] - this_file = file.replace(".csv", f"_{n_steps}steps.csv") - mrich.writing(this_file) - subset.to_csv(this_file, index=False) - - mrich.writing(file) - df.to_csv(file, index=False) - - if return_df: - return df - - def add_tag( - self, - tag: str, - ) -> None: - """Add this tag to every member of the set""" - - assert isinstance(tag, str) - - for i in self.indices: - self.db.insert_tag(name=tag, compound=i, commit=False) - - mrich.print(f'Tagged {self} w/ "{tag}"') - - self.db.commit() - - def plot_tsnee(self, **kwargs) -> "go.Figure": - """Plot a tanimoto similarity plot of these compounds""" - from .plotting import plot_compound_tsnee - - return plot_compound_tsnee(self, **kwargs) - - def as_ingredientset( - self, - amount: float | list[float] = 1, - supplier: str | list | None = None, - ) -> "IngredientSet": - """Get an :class:`.IngredientSet` for these compounds""" - return IngredientSet.from_compounds( - compounds=self, amount=amount, supplier=supplier - ) - - ### DUNDERS - - def __len__(self) -> int: - """The number of compounds in this set""" - return len(self.indices) - - def __iter__(self): - """Iterate through compounds in this set""" - return iter(self.db.get_compound(id=i) for i in self.indices) - - def __getitem__( - self, - key: int | slice, - ) -> "Compound | CompoundSet": - """Get compounds or subsets thereof from this set - - :param key: integer index or slice of indices - - """ - match key: - case int(): - index = self.indices[key] - return self.db.get_compound(id=index) - - case slice(): - indices = self.indices[key] - return CompoundSet(self.db, indices) - - case _: - raise NotImplementedError - - def __sub__( - self, - other: "Compound | CompoundSet | IngredientSet", - ) -> "CompoundSet": - """Subtract a :class:`.Compound` object or ID from this set, or subtract multiple at once when ``other`` is a :class:`.CompoundSet` or :class:`.IngredientSet`""" - - match other: - - case Compound(): - ids = set(self.ids) - set([other.id]) - return CompoundSet(self.db, ids) - - case CompoundSet(): - ids = set(self.ids) - set(other.ids) - return CompoundSet(self.db, ids) - - case IngredientSet(): - mrich.warning( - "Subtracting IngredientSet from CompoundSet. Ignoring quote/amount data" - ) - ids = set(self.ids) - set([int(i) for i in other.compound_ids]) - return CompoundSet(self.db, ids) - - case _: - raise NotImplementedError - - def __add__( - self, - other: "Compound | CompoundSet | IngredientSet | int", - ) -> "CompoundSet": - """Add a :class:`.Compound` object or ID to this set, or add multiple at once when ``other`` is a :class:`.CompoundSet` or :class:`.IngredientSet`""" - - match other: - - case Compound(): - ids = set(self.ids) - ids.add(other.id) - return CompoundSet(self.db, ids) - - case int(): - ids = set(self.ids) - ids.add(other) - return CompoundSet(self.db, ids) - - case CompoundSet(): - ids = set(self.ids) | set(other.ids) - return CompoundSet(self.db, ids) - - case IngredientSet(): - ids = set(self.ids) | set(other.compound_ids) - return CompoundSet(self.db, ids) - - case _: - raise NotImplementedError - - def __and__(self, other: "CompoundSet"): - """AND set operation, returns only compounds in both sets""" - - match other: - - case CompoundSet(): - ids = set(self.ids) & set(other.ids) - return CompoundSet(self.db, ids) - - case _: - raise NotImplementedError - - def __or__(self, other: "CompoundSet"): - """OR set operation, returns union of both sets""" - - match other: - - case CompoundSet(): - ids = set(self.ids) | set(other.ids) - return CompoundSet(self.db, ids) - - case _: - raise NotImplementedError - - def __xor__(self, other: "CompoundSet"): - """Exclusive OR set operation, returns all compounds in either set but not both""" - - match other: - - case CompoundSet(): - ids = set(self.ids) ^ set(other.ids) - return CompoundSet(self.db, ids) - - case _: - raise NotImplementedError - - def __str__(self) -> str: - """Unformatted string representation""" - - if self.name: - s = f"{self.name}: " - else: - s = "" - - s += "{" f"C × {len(self)}" "}" - - return s - - def __repr__(self) -> str: - """ANSI ormatted string representation""" - return f"{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}" - - def __rich__(self) -> str: - """Representation for mrich""" - return f"[bold underline]{self}" - - def __contains__(self, other: Compound | Ingredient | int): - """Check if compound or ingredient is a member of this set""" - match other: - case Compound(): - id = other.id - case Ingredient(): - id = other.compound_id - case int(): - id = other - - return id in set(self.ids) - - -class IngredientSet: - """An :class:`.Ingredient` is a :class:`.Compound` with a fixed quanitity and an attached quote, the :class:`.IngredientSet` is a object representing multiple ingredients. - - .. attention:: - - :class:`.IngredientSet` objects should not be created directly. Instead they are returned by several methods when working with :doc:`quoting` and :doc:`rgen`. - - Selecting ingredients in the set - ================================ - - The :class:`.IngredientSet` can be indexed like a Python list: - - :: - - ingredient = ingredient_set[0] # first ingredient - - To get the ingredient for a specific :class:`.Compound` ID: - - :: - - ingredient = ingredient_set(compound_id=13) - - """ - - _columns = [ - "compound_id", - "amount", - "quote_id", - "supplier", - "max_lead_time", - "quoted_amount", - ] - - def __init__( - self, - db: "Database", - ingredients: "None | list[Ingredient]" = None, - supplier: str | list | None = None, - debug: bool = False, - ) -> None: - """IngredientSet initialisation""" - - from pandas import DataFrame - - ingredients = ingredients or [] - - self._db = db - - self._data = DataFrame(columns=self._columns, dtype=object) - - if debug: - mrich.debug(self._data) - - self._supplier = supplier - - for ingredient in ingredients: - self.add(ingredient) - - for col in self._columns: - assert col in self._data.columns, f"{col} not in df.columns" - - if debug: - mrich.debug(self._data) - - @classmethod - def from_ingredient_df( - cls, - db: "Database", - df: "DataFrame", - supplier: str | list | None = None, - ) -> "IngredientSet": - """Create an :class:`.IngredientSet` from a DataFrame - - :param db: HIPPO Database - :param df: DataFrame of Ingredients - :param supplier: supplier to use for all quoting, (Default value = None) - - """ - # from numpy import nan - self = cls.__new__(cls) - - for col in cls._columns: - if col not in df.columns: - raise Exception(f"{col} not in df.columns") - df[col] = None - - self._db = db - self._data = df.copy() - self._supplier = supplier - - return self - - @classmethod - def from_json( - cls, - db: "Database", - path: None | str, - supplier: str | list | None = None, - data: None | dict = None, - ) -> "IngredientSet": - """Create an :class:`.IngredientSet` from JSON data or a JSON file - - :param db: HIPPO Database - :param path: path to JSON data (can be ``None`` if ``data`` provided) - :param supplier: supplier to use for all quoting, (Default value = ``None``) - :param data: optional JSON data to parse, (Default value = ``None``) - - """ - - if not data: - import json - - data = json.load(open(path, "rt")) - - from pandas import DataFrame - - df = DataFrame(columns=cls._columns, dtype=object) - - for col in cls._columns: - df[col] = data[col] - - return cls.from_ingredient_df(db=db, df=df, supplier=supplier) - - @classmethod - def from_ingredient_dicts( - cls, - db: "Database", - dicts: list[dict], - supplier: str | list | None = None, - ) -> "IngredientSet": - """Create an :class:`.IngredientSet` from :class:`.Ingredient` dictionaries - - :param db: HIPPO Database - :param dicts: List of individual ingredient dictionaries - :param supplier: supplier to use for all quoting, (Default value = ``None``) - - """ - from pandas import DataFrame - - df = DataFrame(dicts, dtype=object) - return cls.from_ingredient_df(db=db, df=df, supplier=supplier) - - @classmethod - def from_compounds( - cls, - *, - compounds: "CompoundSet | None" = None, - ids: list[int] | None = None, - db: "Database | None" = None, - amount: float | list[float] = 1, - supplier: str | list | None = None, - ) -> "IngredientSet": - """Create an :class:`.IngredientSet` from a :class:`.CompoundSet` or IDs - - :param compounds: :class:`.CompoundSet` to use, if ``None`` must provide ``ids`` and ``db`` (Default value = None) - :param ids: Compound IDs (Default value = None) - :param db: HIPPO Database (Default value = None) - :param amount: Amount(s) in ``mg`` (Default value = 1) - :param supplier: supplier to use for all quoting, (Default value = ``None``) - - """ - - from pandas import DataFrame - - if not ids: - ids = compounds.ids - - if not db: - db = compounds.db - - df = DataFrame( - dict( - compound_id=ids, - amount=amount, - quote_id=None, - supplier=supplier, - max_lead_time=None, - quoted_amount=None, - ), - dtype=object, - ) - - return cls.from_ingredient_df(db, df) - - ### PROPERTIES - - @property - def df(self) -> "DataFrame": - """Access the raw DataFrame""" - return self._data - - @property - def db(self) -> "Database": - """Linked HIPPO Database""" - return self._db - - @property - def price_df(self) -> "DataFrame": - """DataFrame including prices""" - df = self.df.copy() - tuples = [(i.price, i.lead_time, i.quote.supplier) for i in self] - df["price"] = [t[0] for t in tuples] - df["lead_time"] = [t[1] for t in tuples] - df["quote_supplier"] = [t[2] for t in tuples] - return df - - @property - def price(self) -> "Price": - """Total price of these ingredients""" - return self.get_price() - - @property - def supplier(self) -> str | list[str]: - """Supplier(s)""" - return self._supplier - - @supplier.setter - def supplier(self, s): - - if isinstance(s, list) or isinstance(s, tuple): - for x in s: - assert isinstance(x, str) - else: - assert isinstance(s, str) - - self._supplier = s - self.df["supplier"] = [s] * len(self) - - @property - def smiles(self) -> list[str]: - """SMILES for all ingredients""" - compound_ids = list(self.df["compound_id"]) - result = self.db.select_where( - query="compound_smiles", - table="compound", - key=f"compound_id in {tuple(compound_ids)}", - multiple=True, - ) - return [q for q, in result] - - @property - def inchikeys(self) -> list[str]: - """InChI-keys for all ingredients""" - compound_ids = list(self.df["compound_id"]) - result = self.db.select_where( - query="compound_inchikey", - table="compound", - key=f"compound_id in {tuple(compound_ids)}", - multiple=True, - ) - return [q for q, in result] - - @property - def compound_ids(self) -> list[int]: - """Compound IDs for all ingredients""" - return list(self.df["compound_id"].values) - - @property - def ids(self) -> list[int]: - """Compound IDs for all ingredients""" - return self.compound_ids - - @property - def id_amount_pairs(self) -> list[tuple]: - """Get a list of compound ID and amount pairs""" - return [ - (id, amount) for id, amount in self.df[["compound_id", "amount"]].values - ] - - @property - def str_compound_ids(self) -> str: - """Return an SQL formatted tuple string of the :class:`.Compound` IDs""" - return str(tuple(self.df["compound_id"].values)).replace(",)", ")") - - @property - def compounds(self) -> "CompoundSet": - """:class:`.CompoundSet` of all compounds in this set""" - return CompoundSet(self.db, self.compound_ids) - - @property - def quote_ids(self) -> list[int]: - """Get a list of quote ID's""" - from pandas import isna - - return [q for q in self.df["quote_id"].values if not isna(q) and q is not None] - - ### METHODS - - def get_price( - self, supplier: str | list[str] = None, none: str = "error", debug: bool = False - ) -> "Price": - """Calculate the price with a given supplier - - :param supplier: supplier to use for all quoting, (Default value = ``None``) - - """ - - from .price import Price - - pairs = {i: q for i, q in enumerate(self.df["quote_id"])} - - quote_ids = [q for q in pairs.values() if q is not None and not isnan(q)] - - if debug: - mrich.debug("quote_ids", quote_ids) - - if quote_ids: - - quote_id_str = str(tuple(quote_ids)).replace(",)", ")") - - if supplier: - result = self.db.select_where( - query="quote_price, quote_currency", - table="quote", - key=f'quote_id in {quote_id_str} AND quote_supplier = "{supplier}"', - multiple=True, - none=none, - ) - else: - result = self.db.select_where( - query="quote_price, quote_currency", - table="quote", - key=f"quote_id in {quote_id_str}", - multiple=True, - none=none, - ) - - prices = [Price(a, b) for a, b in result] - quoted = sum(prices, Price.null()) - - else: - - quoted = Price.null() - - if debug: - mrich.debug("quoted", quoted) - - unquoted = [i for i, q in pairs.items() if q is None or isnan(q)] - - unquoted_price = Price.null() - - for i in unquoted: - - ingredient = self[i] - - if debug: - mrich.debug("unquoted", i, ingredient) - - p = ingredient.price - - unquoted_price += p - - if debug: - mrich.debug(unquoted_price) - - quote = ingredient.quote - - if not quote: - mrich.warning("NULL Quote:", ingredient) - continue - - self.df.loc[i, "quote_id"] = quote.id - - assert quote.amount - - self.df.loc[i, "quoted_amount"] = quote.amount - - if debug: - mrich.debug("quoted", quoted) - mrich.debug("unquoted_price", unquoted_price) - mrich.error("end of IngredientSet.get_price()") - - return quoted + unquoted_price - - def interactive(self, **kwargs) -> None: - """Wrapper for :meth:`.CompoundSet.interactive`""" - self.compounds.interactive(**kwargs) - - def add( - self, - ingredient: "Ingredient | None" = None, - *, - compound_id: int | None = None, - amount: float | None = None, - quote_id: int | None = None, - supplier: str | list[str] | None = None, - max_lead_time: float | None = None, - quoted_amount: float | None = None, - debug: bool = False, - ) -> None: - """Add an :class:`.Ingredient` to this set - - :param ingredient: :class:`.Ingredient` to be added, if ``None`` must specify other parameters, (Default value = None) - :param compound_id: :class:`.Compound` ID (Default value = None) - :param amount: amount in ``mg`` (Default value = None) - :param quote_id: :class:`.Quote` ID (Default value = None) - :param supplier: supplier name string or list (Default value = None) - :param max_lead_time: maximum lead-time for quoting (in days) (Default value = None) - :param quoted_amount: amount of associated :class:`.Quote` (Default value = None) - :param debug: increase verbosity for debugging (Default value = False) - - """ - - from pandas import DataFrame, concat - - if ingredient: - assert ingredient._table == "ingredient" - compound_id = ingredient.compound_id - amount = ingredient.amount - - if (q := ingredient.quote) and not ingredient.quote_id: - mrich.warning(f"Losing quote! {ingredient.quote=}") - - supplier = ingredient.supplier - max_lead_time = ingredient.max_lead_time - - if q is None: - quote_id = None - quoted_amount = None - else: - quote_id = q.id - quoted_amount = q.amount - - else: - assert compound_id - assert amount - - if quote_id: - # if not quoted_amount: - # mrich.warning(f'Requoting C{compound_id}...') - - assert quoted_amount - - supplier = self.supplier - - if self._data.empty: - addition = DataFrame( - [ - dict( - compound_id=compound_id, - amount=amount, - quote_id=quote_id, - supplier=supplier, - max_lead_time=max_lead_time, - quoted_amount=quoted_amount, - ) - ], - dtype=object, - ) - self._data = addition - - else: - - if compound_id in self._data["compound_id"].values: - index = self._data.index[ - self._data["compound_id"] == compound_id - ].tolist()[0] - self._data.loc[index, "amount"] += amount - - # discard if the quote is no longer valid - if (a := self.df.loc[index, "quoted_amount"]) and a < self.df.loc[ - index, "amount" - ]: - self._data.loc[index, "quote_id"] = None - self._data.loc[index, "quoted_amount"] = None - - if debug and supplier: - mrich.debug("Adding to existing ingredient") - mrich.debug(f'{self._data.loc[index, "supplier"]=}') - mrich.debug(f"{supplier=}") - - else: - # from numpy import nan - addition = DataFrame( - [ - dict( - compound_id=compound_id, - amount=amount, - quote_id=quote_id, - supplier=supplier, - max_lead_time=max_lead_time, - quoted_amount=quoted_amount, - ) - ], - dtype=object, - ) - - self._data = concat( - [self._data, addition], ignore_index=True, join="inner" - ) - - if debug: - mrich.out(addition) - - def _get_ingredient( - self, - series, - ) -> "Ingredient": - """Get ingredient from one of the DataFrame rows""" - - q_id = series["quote_id"] - - if isinstance(q_id, float) and isnan(q_id): - q_id = None - - return Ingredient( - db=self._db, - compound=series["compound_id"], - amount=series["amount"], - quote=q_id, - supplier=series["supplier"], - max_lead_time=series["max_lead_time"], - ) - - def copy(self) -> "IngredientSet": - """Return a copy of this :class:`.IngredientSet`""" - return IngredientSet.from_ingredient_df( - self.db, self.df, supplier=self.supplier - ) - - def draw(self) -> None: - """Wrapper for :meth:`.CompoundSet.draw`""" - self.compounds.draw() - - def set_amounts( - self, - amount: float | list[float], - ) -> None: - """Set the amount(s) for all ingredients in this set, and update quotes - - :param amount: amount in ``mg`` - - """ - - self.df["amount"] = amount - - # if amounts are modified the quotes should be cleared - self.df["quote_id"] = None - - assert all(self.df["supplier"].isna()) and all(self.df["max_lead_time"].isna()) - - # update quotes - pairs = self.db.execute( - f""" - WITH matching_quotes AS ( - SELECT quote_id, quote_compound, MIN(quote_price) FROM quote - WHERE quote_compound IN {self.str_compound_ids} - AND quote_amount >= {amount} - GROUP BY quote_compound - ) - SELECT compound_id, quote_id FROM compound - LEFT JOIN matching_quotes ON quote_compound = compound_id - WHERE compound_id IN {self.str_compound_ids} - """ - ).fetchall() - - for compound_id, quote_id in pairs: - match = self.df.index[self.df["compound_id"] == compound_id][0] - self.df.loc[match, "quote_id"] = quote_id - - def get_dict(self, data_orient: str = "list") -> dict: - """Get serialisable dictionary - - :param data_orient: passed to ``pandas.DataFrame.to_dict`` (Default value = 'list') - - """ - return dict( - db=str(self.db), - supplier=self.supplier, - data=self.df.to_dict(orient=data_orient), - ) - - def pop(self) -> Ingredient: - """Pop the last compound in this set""" - item = self[self.df.index[-1]] - self.df.drop(self.df.index[-1], inplace=True) - return item - - def shuffle(self) -> None: - """Randomises the order of compounds in this set""" - self._data = self.df.sample(frac=1).reset_index(drop=True) - - ### DUNDERS - - def __len__(self): - """The number of ingredients in this set""" - return len(self._data) - - def __str__(self) -> str: - """Unformatted string representation""" - return "{" f"Ingredient × {len(self)}" "}" - - def __repr__(self) -> str: - """ANSI ormatted string representation""" - return f"{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}" - - def __rich__(self) -> str: - """Representation for mrich""" - return f"[bold underline]{self}" - - def __add__(self, other): - """Add another :class:`.IngredientSet` this set""" - - for i, row in other._data.iterrows(): - self.add( - compound_id=row.compound_id, - amount=row.amount, - quote_id=row.quote_id, - supplier=row.supplier, - max_lead_time=row.max_lead_time, - quoted_amount=row.quoted_amount, - ) - - return self - - def __getitem__(self, key: int) -> "Ingredient": - """Get a member by it's index""" - match key: - case int(): - series = self.df.loc[key] - return self._get_ingredient(series) - - case _: - raise NotImplementedError - - def __iter__(self): - """Iterate through the ingredients""" - return iter(self._get_ingredient(s) for i, s in self.df.iterrows()) - - def __call__( - self, - *, - compound_id: int | None = None, - tag: str | None = None, - ) -> "IngredientSet | Ingredient | CompoundSet": - """Get members based on a compound_id or tag""" - - if compound_id: - - # get the ingredient with the matching compound ID - matches = self.df[self.df["compound_id"] == compound_id] - - if len(matches) == 0: - return None - - elif len(matches) != 1: - - mrich.warning(f"Multiple ingredients in set with {compound_id=}") - # print(matches) - - return IngredientSet( - self.db, [self._get_ingredient(s) for i, s in matches.iterrows()] - ) - - return self._get_ingredient(matches.iloc[0]) - - # elif tag: - # return self.compounds(tag=tag) - - else: - raise NotImplementedError - - def __getattr__(self, key: str): - """For missing attributes try getting from associated :class:`.CompoundSet`""" - return getattr(self.compounds, key) - - def __contains__(self, other: Compound | Ingredient | int): - """Check if compound or ingredient is a member of this set""" - match other: - case Compound(): - id = other.id - case Ingredient(): - id = other.compound_id - case int(): - id = other - - return id in set(self.compound_ids) diff --git a/hippo/db.py b/hippo/db.py deleted file mode 100644 index 3680c582..00000000 --- a/hippo/db.py +++ /dev/null @@ -1,4692 +0,0 @@ -"""Sqlite database wrapper class""" - -import mcol -import mrich - -import json -import time -import sqlite3 -from pathlib import Path -from pprint import pprint -from sqlite3 import Error - -from .pose import Pose -from .quote import Quote -from .target import Target -from .feature import Feature -from .compound import Compound -from .reaction import Reaction -from .metadata import MetaData -from .recipe import Recipe, Route -from .tools import inchikey_from_smiles, sanitise_smiles, SanitisationError - - -CHEMICALITE_COMPOUND_PROPERTY_MAP = { - "num_heavy_atoms": "mol_num_hvyatms", - "formula": "mol_formula", - "num_rings": "mol_num_rings", - "molecular_weight": "mol_amw", -} - - -class Database: - """Wrapper to connect to the HIPPO sqlite database. - - .. attention:: - - :class:`.Database` objects should not be created directly. Instead use the methods in :class:`.HIPPO` to interact with data in the database. See :doc:`getting_started` and :doc:`insert_elaborations`. - - """ - - def __init__( - self, - path: Path, - animal: "HIPPO", - update_legacy: bool = False, - auto_compute_bfps: bool = True, - ) -> None: - """Database initialisation""" - - assert isinstance(path, Path) - - mrich.debug("hippo.Database.__init__()") - - self._path = path - self._connection = None - self._cursor = None - self._animal = animal - self._auto_compute_bfps = auto_compute_bfps - - mrich.debug(f"Database.path = {self.path}") - - try: - path = path.resolve(strict=True) - - except FileNotFoundError: - # create a blank database - self.connect() - self.create_blank_db() - - else: - # connect to existing database - self.connect() - - if "interaction" not in self.table_names: - if not update_legacy: - mrich.error("This is a legacy format database (hippo-db < 0.3.23)") - mrich.error("Existing fingerprints will not be compatible") - mrich.error("Re-initialise HIPPO object with update_legacy=True to fix") - raise LegacyDatabaseError("hippo-db < 0.3.23") - else: - mrich.warning("This is a legacy format database (hippo-db < 0.3.23)") - mrich.warning("Clearing legacy fingerprints...") - self.create_table_interaction() - self.delete_interactions() - - if "subsite" not in self.table_names or "subsite_tag" not in self.table_names: - mrich.warning("This is a legacy format database (hippo-db < 0.3.24)") - self.create_table_subsite() - self.create_table_subsite_tag() - - if "scaffold" not in self.table_names: - if not update_legacy: - mrich.error("This is a legacy format database (hippo-db < 0.3.25)") - mrich.error("Existing base-elab relationships will not be compatible") - mrich.error("Re-initialise HIPPO object with update_legacy=True to fix") - raise LegacyDatabaseError("hippo-db < 0.3.25") - else: - mrich.warning("This is a legacy format database (hippo-db < 0.3.25)") - mrich.warning("Migrating compound_base values to scaffold table...") - self.create_table_scaffold() - self.migrate_legacy_scaffolds() - - if "route" not in self.table_names: - self.create_table_route() - self.create_table_component() - - elif "component_amount" not in self.column_names("component"): - if not update_legacy: - mrich.error("This is a legacy format database (hippo-db < 0.3.29)") - mrich.error("Re-initialise HIPPO object with update_legacy=True to fix") - raise LegacyDatabaseError("hippo-db < 0.3.29") - else: - mrich.warning("This is a legacy format database (hippo-db < 0.3.29)") - mrich.warning("Updating legacy routes...") - - self.update_legacy_routes() - - if "reaction_metadata" not in self.column_names("reaction"): - if not update_legacy: - mrich.error("This is a legacy format database (hippo-db < 0.3.32)") - mrich.error("Re-initialise HIPPO object with update_legacy=True to fix") - raise LegacyDatabaseError("hippo-db < 0.3.32") - else: - mrich.warning("This is a legacy format database (hippo-db < 0.3.32)") - mrich.warning("Updating legacy reaction table...") - - self.update_legacy_reaction_metadata() - - if "pose_inspiration_score" not in self.column_names("pose"): - if not update_legacy: - mrich.error("This is a legacy format database (hippo-db < 0.3.36)") - mrich.error("Re-initialise HIPPO object with update_legacy=True to fix") - raise LegacyDatabaseError("hippo-db < 0.3.36") - else: - mrich.warning("This is a legacy format database (hippo-db < 0.3.36)") - mrich.warning("Updating legacy pose table...") - - self.update_legacy_pose_inspiration_score() - - @classmethod - def copy_from( - cls, - source: Path, - destination: Path, - animal: "HIPPO", - update_legacy: bool = False, - overwrite_existing: bool = False, - pages: int = 10000, - ) -> None: - """Create a :class:`.Database` from an existing one""" - - source = Path(source) - - assert source.exists() - - if destination.exists(): - if overwrite_existing: - mrich.warning(f"Overwriting {destination}") - else: - mrich.error(f"Will not overwrite {destination}") - mrich.error(f"Set overwrite_existing=True to override") - raise Exception("Set overwrite_existing=True to override") - - mrich.print(f"Copying {source} --> {destination}") - - def progress(status, remaining, total): - """print progress""" - mrich.debug(f"Copied {total-remaining} of {total} pages...") - - src = sqlite3.connect(source) - dst = sqlite3.connect(destination) - with dst: - src.backup(dst, pages=pages, progress=progress) - dst.close() - src.close() - - self = cls.__new__(cls) - - self.__init__(path=destination, animal=animal, update_legacy=update_legacy) - - return self - - ### PROPERTIES - - @property - def path(self) -> Path: - """Returns the path to the database file""" - return self._path - - @property - def connection(self) -> "sqlite3.connection": - """Returns a ``sqlite3.connection`` to the database""" - if not self._connection: - self.connect() - return self._connection - - @property - def cursor(self) -> "sqlite3.cursor": - """Returns a ``sqlite3.cursor``""" - if not self._cursor: - self._cursor = self.connection.cursor() - return self._cursor - - @property - def total_changes(self) -> int: - """Return the total number of database rows that have been modified, inserted, or deleted since the database connection was opened.""" - return self.connection.total_changes - - @property - def table_names(self) -> list[str]: - """List of all the table names in the database""" - results = self.execute( - "SELECT name FROM sqlite_master WHERE type='table';" - ).fetchall() - return [n for n, in results] - - @property - def auto_compute_bfps(self) -> bool: - """Automatically compute compound binary fingerprints on insertion""" - return self._auto_compute_bfps - - @auto_compute_bfps.setter - def auto_compute_bfps(self, b: bool): - """Automatically compute compound binary fingerprints on insertion""" - self._auto_compute_bfps = b - - ### PUBLIC METHODS / API CALLS - - def close(self) -> None: - """Close the connection""" - mrich.debug("hippo.Database.close()") - if self.connection: - self.connection.close() - mrich.success(f"Closed connection to {self.path}") - - def backup( - self, - destination: Path | str | None = None, - pages: int = 10_000, - ) -> None: - """Create a backup of the database""" - return backup(self.path, destination, pages=pages) - - ### GENERAL SQL - - def connect(self) -> None: - """Connect to the database""" - mrich.debug("hippo.Database.connect()") - - conn = None - - try: - conn = sqlite3.connect(self.path) - - mrich.debug(f"{sqlite3.version=}") - - conn.enable_load_extension(True) - conn.load_extension("chemicalite") - conn.enable_load_extension(False) - - mrich.success("Database connected @", f"[file]{self.path}") - - except sqlite3.OperationalError as e: - - if "cannot open shared object file" in str(e): - mrich.error("chemicalite package not installed correctly") - else: - mrich.error(e) - raise - - except Error as e: - mrich.error(e) - raise - - self._connection = conn - self._cursor = conn.cursor() - - def execute( - self, sql, payload=None, *, retry: float | None = 1, debug: bool = False - ): - """Execute arbitrary SQL with retry if database is locked.""" - if debug: - mrich.debug(sql) - - while True: - try: - if payload: - return self.cursor.execute(sql, payload) - else: - return self.cursor.execute(sql) - except sqlite3.OperationalError as e: - if "database is locked" in str(e) and retry: - with mrich.clock( - f"SQLite Database is locked, waiting {retry} second(s)..." - ): - time.sleep(retry) - mrich.print("[debug]SQLite Database was locked, retrying...") - continue # retry without recursion - elif "syntax error" in str(e): - mrich.error(sql) - mrich.error(payload) - raise - else: - raise - except Exception as e: - raise - - def executemany(self, sql, payload, *, retry: float | None = 1) -> None: - """Execute arbitrary SQL - - :param sql: SQL query - :param retry: If truthy, keep trying to executemany every `retry` seconds if the Database is locked - :param payload: Payload for insertion, etc. (Default value = None) - - """ - - if "RETURNING" in sql: - from .apsw import executemany - - return executemany(self.path, sql, payload) - - try: - return self.cursor.executemany(sql, payload) - except sqlite3.OperationalError as e: - if "database is locked" in str(e) and retry: - with mrich.clock( - f"SQLite Database is locked, waiting {retry} second(s)..." - ): - time.sleep(retry) - mrich.print("[debug]SQLite Database was locked, retrying...") - return self.executemany(sql=sql, payload=payload, retry=retry) - else: - raise - except Exception as e: - mrich.print(sql) - mrich.print(payload[0]) - raise - - def commit(self, *, retry: float | None = 1) -> None: - """Commit changes to the database - - :param retry: If truthy, keep trying to execute every `retry` seconds if the Database is locked - """ - try: - self.connection.commit() - except sqlite3.OperationalError as e: - if "database is locked" in str(e) and retry: - with mrich.clock( - f"SQLite Database is locked, waiting {retry} second(s)..." - ): - time.sleep(retry) - mrich.print("[debug]SQLite Database was locked, retrying...") - return self.commit() - else: - raise - - ### CREATE TABLES - - def create_blank_db(self) -> None: - """Create a blank database""" - - with mrich.loading("Creating blank database..."): - self.create_table_compound() - self.create_table_inspiration() - self.create_table_reaction() - self.create_table_reactant() - self.create_table_pose() - self.create_table_tag() - self.create_table_quote() - self.create_table_target() - self.create_table_pattern_bfp() - self.create_table_feature() - self.create_table_route() - self.create_table_component() - self.create_table_interaction() - self.create_table_subsite() - self.create_table_subsite_tag() - self.create_table_scaffold() - self.commit() - - def create_table_compound(self) -> None: - """Create the compound table""" - mrich.debug("HIPPO.Database.create_table_compound()") - - sql = """CREATE TABLE compound( - compound_id INTEGER PRIMARY KEY, - compound_inchikey TEXT, - compound_alias TEXT, - compound_smiles TEXT, - compound_base INTEGER, - compound_mol MOL, - compound_pattern_bfp bits(2048), - compound_morgan_bfp bits(2048), - compound_metadata TEXT, - FOREIGN KEY (compound_base) REFERENCES compound(compound_id), - CONSTRAINT UC_compound_inchikey UNIQUE (compound_inchikey) - CONSTRAINT UC_compound_alias UNIQUE (compound_alias) - CONSTRAINT UC_compound_smiles UNIQUE (compound_smiles) - ); - """ - self.execute(sql) - - def create_table_inspiration(self) -> None: - """Create the inspiration table""" - mrich.debug("HIPPO.Database.create_table_inspiration()") - - sql = """CREATE TABLE inspiration( - inspiration_original INTEGER, - inspiration_derivative INTEGER, - FOREIGN KEY (inspiration_original) REFERENCES pose(pose_id), - FOREIGN KEY (inspiration_derivative) REFERENCES pose(pose_id), - CONSTRAINT UC_inspiration UNIQUE (inspiration_original, inspiration_derivative) - ); - """ - - self.execute(sql) - - def create_table_scaffold(self) -> None: - """Create the scaffold table""" - mrich.debug("HIPPO.Database.create_table_scaffold()") - - sql = """CREATE TABLE scaffold( - scaffold_base INTEGER, - scaffold_superstructure INTEGER, - FOREIGN KEY (scaffold_base) REFERENCES pose(pose_id), - FOREIGN KEY (scaffold_superstructure) REFERENCES pose(pose_id), - CONSTRAINT UC_scaffold UNIQUE (scaffold_base, scaffold_superstructure) - ); - """ - - self.execute(sql) - - def create_table_reaction(self) -> None: - """Create the reaction table""" - mrich.debug("HIPPO.Database.create_table_reaction()") - - sql = """CREATE TABLE reaction( - reaction_id INTEGER PRIMARY KEY, - reaction_type TEXT, - reaction_product INTEGER, - reaction_product_yield REAL, - reaction_metadata TEXT, - FOREIGN KEY (reaction_product) REFERENCES compound(compound_id) - ); - """ - - self.execute(sql) - - def create_table_reactant(self) -> None: - """Create the reactant table""" - mrich.debug("HIPPO.Database.create_table_reactant()") - - sql = """CREATE TABLE reactant( - reactant_amount REAL, - reactant_reaction INTEGER, - reactant_compound INTEGER, - FOREIGN KEY (reactant_reaction) REFERENCES reaction(reaction_id), - FOREIGN KEY (reactant_compound) REFERENCES compound(compound_id), - CONSTRAINT UC_reactant UNIQUE (reactant_reaction, reactant_compound) - ); - """ - - self.execute(sql) - - def create_table_pose(self) -> None: - """Create the pose table""" - mrich.debug("HIPPO.Database.create_table_pose()") - - sql = """CREATE TABLE pose( - pose_id INTEGER PRIMARY KEY, - pose_inchikey TEXT, - pose_alias TEXT, - pose_smiles TEXT, - pose_reference INTEGER, - pose_path TEXT, - pose_compound INTEGER, - pose_target INTEGER, - pose_mol BLOB, - pose_fingerprint BLOB, - pose_energy_score REAL, - pose_distance_score REAL, - pose_inspiration_score REAL, - pose_metadata TEXT, - FOREIGN KEY (pose_compound) REFERENCES compound(compound_id), - CONSTRAINT UC_pose_alias UNIQUE (pose_alias) - CONSTRAINT UC_pose_path UNIQUE (pose_path) - ); - """ - - ### snippet to convert python metadata dictionary with JSON - # json.dumps(variables).encode('utf-8') - # json.loads(s.decode('utf-8')) - - self.execute(sql) - - def create_table_tag(self) -> None: - """Create the tag table""" - mrich.debug("HIPPO.Database.create_table_tag()") - - sql = """CREATE TABLE tag( - tag_name TEXT, - tag_compound INTEGER, - tag_pose INTEGER, - FOREIGN KEY (tag_compound) REFERENCES compound(compound_id), - FOREIGN KEY (tag_pose) REFERENCES pose(pose_id), - CONSTRAINT UC_tag_compound UNIQUE (tag_name, tag_compound) - CONSTRAINT UC_tag_pose UNIQUE (tag_name, tag_pose) - ); - """ - - self.execute(sql) - - def create_table_quote(self) -> None: - """Create the quote table""" - mrich.debug("HIPPO.Database.create_table_quote()") - - sql = """CREATE TABLE quote( - quote_id INTEGER PRIMARY KEY, - quote_smiles TEXT, - quote_amount REAL, - quote_supplier TEXT, - quote_catalogue TEXT, - quote_entry TEXT, - quote_lead_time INTEGER, - quote_price REAL, - quote_currency TEXT, - quote_purity REAL, - quote_date TEXT, - quote_compound INTEGER, - FOREIGN KEY (quote_compound) REFERENCES compound(compound_id), - CONSTRAINT UC_quote UNIQUE (quote_amount, quote_supplier, quote_catalogue, quote_entry) - ); - """ - - self.execute(sql) - - def create_table_target(self) -> None: - """Create the target table""" - mrich.debug("HIPPO.Database.create_table_target()") - sql = """CREATE TABLE target( - target_id INTEGER PRIMARY KEY, - target_name TEXT, - target_metadata TEXT, - CONSTRAINT UC_target UNIQUE (target_name) - ); - """ - - self.execute(sql) - - def create_table_feature(self) -> None: - """Create the feature table""" - mrich.debug("HIPPO.Database.create_table_feature()") - sql = """CREATE TABLE feature( - feature_id INTEGER PRIMARY KEY, - feature_family TEXT, - feature_target INTEGER, - feature_chain_name TEXT, - feature_residue_name TEXT, - feature_residue_number INTEGER, - feature_atom_names TEXT, - CONSTRAINT UC_feature UNIQUE (feature_family, feature_target, feature_chain_name, feature_residue_number, feature_residue_name, feature_atom_names) - ); - """ - - self.execute(sql) - - def create_table_route(self) -> None: - """Create the route table""" - mrich.debug("HIPPO.Database.create_table_route()") - sql = """CREATE TABLE route( - route_id INTEGER PRIMARY KEY, - route_product INTEGER - ); - """ - - self.execute(sql) - - def create_table_component(self) -> None: - """Create the component table""" - mrich.debug("HIPPO.Database.create_table_component()") - sql = """CREATE TABLE component( - component_id INTEGER PRIMARY KEY, - component_route INTEGER, - component_type INTEGER, - component_ref INTEGER, - component_amount REAL, - CONSTRAINT UC_component UNIQUE (component_route, component_ref, component_type) - ); - """ - - self.execute(sql) - - def create_table_pattern_bfp(self) -> None: - """Create the pattern_bfp table""" - mrich.debug("HIPPO.Database.create_table_pattern_bfp()") - - sql = "CREATE VIRTUAL TABLE compound_pattern_bfp USING rdtree(compound_id, fp bits(2048))" - - self.execute(sql) - - def create_table_interaction( - self, table: str = "interaction", debug: bool = True - ) -> None: - """Create an interaction table""" - - if debug: - mrich.debug(f"HIPPO.Database.create_table_interaction({table=})") - sql = f"""CREATE TABLE {table}( - interaction_id INTEGER PRIMARY KEY, - interaction_feature INTEGER NOT NULL, - interaction_pose INTEGER NOT NULL, - interaction_type TEXT NOT NULL, - interaction_family TEXT NOT NULL, - interaction_atom_ids TEXT NOT NULL, - interaction_prot_coord TEXT NOT NULL, - interaction_lig_coord TEXT NOT NULL, - interaction_distance REAL NOT NULL, - interaction_angle REAL, - interaction_energy REAL, - CONSTRAINT UC_interaction UNIQUE (interaction_feature, interaction_pose, interaction_type, interaction_family, interaction_atom_ids) - ); - """ - - self.execute(sql) - - def create_table_subsite(self) -> None: - """Create the subsite table""" - - mrich.debug("HIPPO.Database.create_table_subsite()") - sql = """CREATE TABLE subsite( - subsite_id INTEGER PRIMARY KEY, - subsite_target INTEGER NOT NULL, - subsite_name TEXT NOT NULL, - subsite_metadata TEXT, - CONSTRAINT UC_subsite UNIQUE (subsite_target, subsite_name) - ); - """ - - self.execute(sql) - - def create_table_subsite_tag(self) -> None: - """Create the subsite_tag table""" - - mrich.debug("HIPPO.Database.create_table_subsite_tag()") - sql = """CREATE TABLE subsite_tag( - subsite_tag_id INTEGER PRIMARY KEY, - subsite_tag_ref INTEGER NOT NULL, - subsite_tag_pose INTEGER NOT NULL, - subsite_tag_metadata TEXT, - CONSTRAINT UC_subsite_tag UNIQUE (subsite_tag_ref, subsite_tag_pose) - ); - """ - - self.execute(sql) - - ### INSERTION - - def insert_compound( - self, - *, - smiles: str, - alias: str | None = None, - tags: None | list[str] = None, - warn_duplicate: bool = True, - commit: bool = True, - metadata: None | dict = None, - inchikey: str = None, - ) -> int: - """Insert an entry into the compound table - - :param smiles: SMILES string - :param alias: optional alias for the compound (Default value = None) - :param tags: list of string tags, (Default value = None) - :param warn_duplicate: print a warning if the compound already exists (Default value = True) - :param commit: commit the changes to the database (Default value = True) - :param metadata: dictionary of metadata (Default value = None) - :param inchikey: provide an InChI-key, otherwise it's calculated from the SMILES, (Default value = None) - :returns: compound ID - - """ - - # generate the inchikey name - inchikey = inchikey or inchikey_from_smiles(smiles) - - sql = """ - INSERT INTO compound(compound_inchikey, compound_smiles, compound_mol, compound_pattern_bfp, compound_morgan_bfp, compound_alias) - VALUES(?1, ?2, mol_from_smiles(?2), mol_pattern_bfp(mol_from_smiles(?2), 2048), mol_morgan_bfp(mol_from_smiles(?2), 2, 2048), ?3) - """ - - try: - self.execute(sql, (inchikey, smiles, alias)) - - except sqlite3.IntegrityError as e: - if "UNIQUE constraint failed: compound.compound_inchikey" in str(e): - if warn_duplicate: - mrich.warning( - f'Skipping compound with existing inchikey "{inchikey}"' - ) - elif "UNIQUE constraint failed: compound.compound_smiles" in str(e): - if warn_duplicate: - mrich.warning(f'Skipping compound with existing smiles "{smiles}"') - elif "UNIQUE constraint failed: compound.compound_pattern_bfp" in str(e): - if warn_duplicate: - mrich.warning( - f'Skipping compound with existing pattern binary fingerprint "{smiles}"' - ) - elif "UNIQUE constraint failed: compound.compound_morgan_bfp" in str(e): - if warn_duplicate: - mrich.warning( - f'Skipping compound with existing morgan binary fingerprint "{smiles}"' - ) - else: - mrich.error(e) - - return None - - except Exception as e: - mrich.error(e) - - compound_id = self.cursor.lastrowid - if commit: - self.commit() - - ### register the tags - - if tags: - for tag in tags: - self.insert_tag(name=tag, compound=compound_id, commit=commit) - - ### register the binary fingerprints - - if self.auto_compute_bfps: - result = self.insert_compound_pattern_bfp(compound_id, commit=commit) - - if not result: - mrich.error("Could not insert compound pattern bfp") - - ### insert metadata - - if metadata: - self.insert_metadata( - table="compound", id=compound_id, payload=metadata, commit=commit - ) - - return compound_id - - def insert_compound_pattern_bfp(self, compound_id: int, commit: bool = True) -> int: - """Insert a compound_pattern_bfp - - :param compound_id: ID of the associated compound - :param commit: commit the changes to the database (Default value = True) - :returns: binary fingerprint ID - - """ - - sql = """ - INSERT INTO compound_pattern_bfp(compound_id, fp) - VALUES(?1, ?2) - """ - - (bfp,) = self.select_where( - "compound_pattern_bfp", "compound", "id", compound_id - ) - - try: - self.execute(sql, (compound_id, bfp)) - - except Exception as e: - mrich.error(e) - - bfp_id = self.cursor.lastrowid - if commit: - self.commit() - - return bfp_id - - def insert_pose( - self, - *, - compound: Compound | int, - target: Target | int | str, - path: str, - inchikey: str | None = None, - alias: str | None = None, - reference: int | Pose | None = None, - tags: None | list = None, - energy_score: float | None = None, - distance_score: float | None = None, - metadata: None | dict = None, - commit: bool = True, - warn_duplicate: bool = True, - resolve_path: bool = True, - ) -> int: - """Insert an entry into the pose table - - :param compound: associated :class:`.Compound` object or ID - :param target: protein :class:`.Target` name or ID - :param path: path to the molecular structure (.pdb/.mol) - :param inchikey: provide an InChI-key if available, (Default value = None) - :param alias: optional alias for the compound (Default value = None) - :param reference: reference :class:`.Pose` object or ID to use for the protein conformation (Default value = None) - :param tags: list of string tags, (Default value = None) - :param energy_score: optional score of the ligand's binding energy (Default value = None) - :param distance_score: optional score of the ligand's binding position (Default value = None) - :param metadata: dictionary of metadata (Default value = None) - :param commit: commit the changes to the database (Default value = True) - :param warn_duplicate: print a warning if the pose already exists (Default value = True) - :param resolve_path: try resolving the path (Default value = True) - :returns: the pose ID - - """ - - if isinstance(compound, Compound): - compound = compound.id - - if isinstance(reference, Pose): - reference = reference.id - - if isinstance(target, Target): - target = target.id - - if isinstance(target, str): - target = self.get_target_id(name=target) - if not target: - raise ValueError(f"No such {target=}") - target_name = self.get_target_name(id=target) - - if resolve_path: - try: - path = Path(path) - path = path.resolve(strict=True) - path = str(path) - - except FileNotFoundError as e: - mrich.error(f"Path cannot be resolved: {mcol.file}{path}") - raise - - sql = """ - INSERT INTO pose(pose_inchikey, pose_alias, pose_smiles, pose_compound, pose_target, pose_path, pose_reference, pose_energy_score, pose_distance_score) - VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) - """ - - try: - self.execute( - sql, - ( - inchikey, - alias, - None, - compound, - target, - path, - reference, - energy_score, - distance_score, - ), - ) - - except sqlite3.IntegrityError as e: - if "UNIQUE constraint failed: pose.pose_path" in str(e): - if warn_duplicate: - mrich.warning(f'Could not insert pose with duplicate path "{path}"') - elif "UNIQUE constraint failed: pose.pose_alias" in str(e): - if warn_duplicate: - mrich.warning( - f'Could not insert pose with duplicate alias "{alias}"' - ) - else: - mrich.error(e) - return None - - except Exception as e: - mrich.error(e) - raise - - pose_id = self.cursor.lastrowid - if commit: - self.commit() - - if tags: - for tag in tags: - self.insert_tag(name=tag, pose=pose_id, commit=commit) - - if metadata: - self.insert_metadata( - table="pose", id=pose_id, payload=metadata, commit=commit - ) - - return pose_id - - def insert_tag( - self, - *, - name: str, - compound: int = None, - pose: int = None, - commit: bool = True, - ) -> int: - """Insert an entry into the tag table. - - .. attention:: - Exactly one of compound or pose arguments must have a value - - :param name: name of the tag - :param compound: associated :class:`.Compound` ID - :param pose: associated :class:`.Pose` ID - :param commit: commit the changes to the database (Default value = True) - :returns: the tag ID - - """ - - assert bool(compound) ^ bool( - pose - ), "Exactly one of compound or pose arguments must have a value" - - sql = """ - INSERT INTO tag(tag_name, tag_compound, tag_pose) - VALUES(?1, ?2, ?3) - """ - - try: - self.execute(sql, (name, compound, pose)) - - except sqlite3.IntegrityError as e: - return None - - except Exception as e: - mrich.error(e) - - tag_id = self.cursor.lastrowid - if commit: - self.commit() - return tag_id - - def insert_inspiration( - self, - *, - original: Pose | int, - derivative: Pose | int, - warn_duplicate: bool = True, - commit: bool = True, - ) -> int: - """Insert an entry into the inspiration table - - :param original: :class:`.Pose` object or ID of the original hit - :param derivative: :class:`.Pose` object or ID of the derivative hit - :param warn_duplicate: print a warning if the pose already exists (Default value = True) - :param commit: commit the changes to the database (Default value = True) - :returns: the inspiration ID - - """ - - if isinstance(original, Pose): - original = original.id - if isinstance(derivative, Pose): - derivative = derivative.id - - assert isinstance( - original, int - ), "Must pass an integer ID or Pose object (original)" - assert isinstance( - derivative, int - ), "Must pass an integer ID or Pose object (derivative)" - - sql = """ - INSERT INTO inspiration(inspiration_original, inspiration_derivative) - VALUES(?1, ?2) - """ - - try: - self.execute(sql, (original, derivative)) - - except sqlite3.IntegrityError as e: - if warn_duplicate: - mrich.warning( - f"Skipping existing inspiration: {original=} {derivative=}" - ) - return None - - except Exception as e: - mrich.error(e) - - inspiration_id = self.cursor.lastrowid - - if commit: - self.commit() - return inspiration_id - - def insert_scaffold( - self, - *, - scaffold: Compound | int, - superstructure: Compound | int, - warn_duplicate: bool = True, - commit: bool = True, - ) -> int: - """Insert an entry into the scaffold table - - :param scaffold: :class:`.Compound` object or ID of the scaffold hit - :param superstructure: :class:`.Compound` object or ID of the superstructure hit - :param warn_duplicate: print a warning if the pose already exists (Default value = True) - :param commit: commit the changes to the database (Default value = True) - :returns: the scaffold row ID - - """ - - if isinstance(scaffold, Compound): - scaffold = scaffold.id - if isinstance(superstructure, Compound): - superstructure = superstructure.id - - assert isinstance( - scaffold, int - ), f"Must pass an integer ID or Compound object (scaffold) {scaffold=} {type(scaffold)}" - assert isinstance( - superstructure, int - ), f"Must pass an integer ID or Compound object (superstructure) {superstructure=} {type(superstructure)}" - - if scaffold == superstructure: - # mrich.warning(f"Skipped self-referential scaffold assignment (C{scaffold})") - return None - - sql = """ - INSERT INTO scaffold(scaffold_base, scaffold_superstructure) - VALUES(?1, ?2) - """ - - try: - self.execute(sql, (scaffold, superstructure)) - - except sqlite3.IntegrityError as e: - if warn_duplicate: - mrich.warning( - f"Skipping existing scaffold: {scaffold=} {superstructure=}" - ) - return None - - except Exception as e: - mrich.error(e) - - scaffold_id = self.cursor.lastrowid - - if commit: - self.commit() - return scaffold_id - - def insert_reaction( - self, - *, - type: str, - product: Compound | int, - product_yield: float = 1.0, - commit: bool = True, - ) -> int: - """Insert an entry into the reaction table - - :param type: string to indicate the reaction type - :param product: :class:`.Compound` object or ID of the reaction product - :param product_yield: yield fraction of the reaction product (Default value = 1.0) - :param commit: commit the changes to the database (Default value = True) - :returns: the reaction ID - - """ - - if isinstance(product, Compound): - product = product.id - - # assert isinstance(product, Compound), f'incompatible {product=}' - assert isinstance(type, str), f"incompatible {type=}" - - sql = """ - INSERT INTO reaction(reaction_type, reaction_product, reaction_product_yield) - VALUES(?1, ?2, ?3) - """ - - try: - self.execute(sql, (type, product, product_yield)) - - except Exception as e: - mrich.error(e) - - reaction_id = self.cursor.lastrowid - if commit: - self.commit() - return reaction_id - - def insert_reactant( - self, - *, - compound: Compound | int, - reaction: Reaction | int, - amount: float = 1.0, - commit: bool = True, - ) -> int: - """Insert an entry into the reactant table - - :param compound: :class:`.Compound` object or ID of the reactant - :param reaction: :class:`.Reaction` object or ID of the reaction - :param amount: amount (in ``mg``) needed for each unit of product (Default value = 1.0) - :param commit: commit the changes to the database (Default value = True) - :returns: the reactant ID - - """ - - if isinstance(reaction, int): - reaction = self.get_reaction(id=reaction) - - if isinstance(compound, int): - compound = self.get_compound(id=compound) - - assert isinstance(compound, Compound), f"incompatible {compound=}" - assert isinstance(reaction, Reaction), f"incompatible {reaction=}" - - sql = """ - INSERT INTO reactant(reactant_amount, reactant_reaction, reactant_compound) - VALUES(?1, ?2, ?3) - """ - - try: - self.execute(sql, (amount, reaction.id, compound.id)) - - except sqlite3.IntegrityError as e: - mrich.warning(f"Skipping existing reactant: {reaction=} {compound=}") - - except Exception as e: - mrich.error(e) - - reactant_id = self.cursor.lastrowid - - if commit: - self.commit() - - return reactant_id - - def insert_quote( - self, - *, - compound: Compound | int, - supplier: str, - catalogue: str | None = None, - entry: str | None = None, - amount: float, - price: float, - currency: str | None = None, - purity: float | None = None, - lead_time: float, - smiles: str | None = None, - date: str | None = None, - commit: bool = True, - ) -> int | None: - """Insert an entry into the quote table - - :param compound: associated :class:`.Compound` object or ID - :param supplier: name of the supplier - :param catalogue: optional catalogue name - :param entry: name of the catalogue entry - :param amount: amount in `mg` - :param price: price of the compound - :param currency: currency string ``['GBP', 'EUR', 'USD', None]`` - :param purity: compound purity fraction - :param lead_time: lead time in days - :param smiles: quoted SMILES string (Default value = None) - :param commit: commit the changes to the database (Default value = True) - :returns: the quote ID - - """ - - if not isinstance(compound, int): - assert isinstance(compound, Compound), f"incompatible {compound=}" - compound = compound.id - - assert currency in ["GBP", "EUR", "USD", None], f"incompatible {currency=}" - - assert supplier in [ - "MCule", - "Enamine", - "Stock", - "Molport", - ], f"incompatible {supplier=}" - - smiles = smiles or "" - - payload = [ - smiles, - amount, - supplier, - catalogue, - entry, - lead_time, - price, - currency, - purity, - compound, - ] - - if date: - date_str = "?11" - payload.append(date) - else: - date_str = "date()" - - sql = f""" - INSERT or REPLACE INTO quote( - quote_smiles, - quote_amount, - quote_supplier, - quote_catalogue, - quote_entry, - quote_lead_time, - quote_price, - quote_currency, - quote_purity, - quote_compound, - quote_date - ) - VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, {date_str}); - """ - - try: - self.execute( - sql, - tuple(payload), - ) - - except sqlite3.InterfaceError as e: - mrich.error(e) - mrich.debug(payload) - raise - - except Exception as e: - mrich.error(e) - return None - - quote_id = self.cursor.lastrowid - if commit: - self.commit() - return quote_id - - def insert_target( - self, - *, - name: str, - warn_duplicate: bool = True, - ) -> int: - """Insert an entry into the target table - - :param name: name of the protein target - :returns: the target ID - - """ - - sql = """ - INSERT INTO target(target_name) - VALUES(?1) - """ - - try: - self.execute(sql, (name,)) - - except sqlite3.IntegrityError as e: - if warn_duplicate: - mrich.warning(f"Skipping existing target with {name=}") - return None - - except Exception as e: - mrich.error(e) - - target_id = self.cursor.lastrowid - self.commit() - return target_id - - def insert_feature( - self, - *, - family: str, - target: int, - chain_name: str, - residue_name: str, - residue_number: int, - atom_names: list[str], - warn_duplicate: bool = False, - commit: bool = True, - ) -> int: - """Insert an entry into the feature table - - :param family: feature type string - :param target: associated :class:`.Target` ID - :param chain_name: single character name of the chain - :param residue_name: 3-4 character string name of the residue - :param residue_number: integer residue number - :param atom_names: list of atom names - :param commit: commit the changes to the database (Default value = True) - :returns: feature ID - - """ - - assert len(chain_name) == 1 - assert len(residue_name) <= 4 - for a in atom_names: - assert len(a) <= 4 - - if isinstance(target, str): - target = self.get_target_id(name=target) - assert isinstance(target, int) - - from .prolif import FEATURE_FAMILIES - - if family: - assert family in FEATURE_FAMILIES, f"Unsupported {family=}" - else: - family = "Unknown" - - sql = """ - INSERT INTO feature(feature_family, feature_target, feature_chain_name, feature_residue_name, feature_residue_number, feature_atom_names) - VALUES(?1, ?2, ?3, ?4, ?5, ?6) - """ - - atom_names = " ".join(sorted(atom_names)) - - try: - self.execute( - sql, - (family, target, chain_name, residue_name, residue_number, atom_names), - ) - - except sqlite3.IntegrityError as e: - - if warn_duplicate: - mrich.warning(str(e)) - mrich.var("family", family) - mrich.var("target", target) - mrich.var("chain_name", chain_name) - mrich.var("residue_name", residue_name) - mrich.var("residue_number", residue_number) - mrich.var("atom_names", atom_names) - - return None - - except Exception as e: - mrich.error(e) - - feature_id = self.cursor.lastrowid - if commit: - self.commit() - return feature_id - - def insert_metadata( - self, - *, - table: str, - id: int, - payload: dict, - commit: bool = True, - ) -> None: - """Insert metadata into an an existing entry in the compound or pose tables - - :param table: table for insertions ``['pose', 'compound', 'subsite', 'subsite_tag']`` - :param id: associated entry ID - :param payload: metadata dictionary - :param commit: commit the changes to the database (Default value = True) - - """ - - payload = json.dumps(payload) - - self.update( - table=table, id=id, key=f"{table}_metadata", value=payload, commit=commit - ) - - def insert_route( - self, - *, - product_id: int, - commit: bool = True, - ) -> int: - """Insert an entry into the route table - - :param product_id: :class:`.Compound` ID of the product - :param commit: commit the changes to the database (Default value = True) - :returns: route ID - - """ - - sql = """ - INSERT INTO route(route_product) - VALUES(?1) - """ - - product_id = int(product_id) - - try: - self.execute(sql, (product_id,)) - - except Exception as e: - mrich.error(e) - - route_id = self.cursor.lastrowid - - if commit: - self.commit() - - return route_id - - def insert_component( - self, - *, - route: int, - ref: int, - component_type: int, - amount: float = 1.0, - commit: bool = True, - ) -> int: - """ - ================ ========== ============== - component_type table ref - ================ ========== ============== - 1 reaction reaction - 2 compound reactant - 3 compound intermediate - ================ ========== ============== - - :param route: associated :class:`.Route` ID - :param ref: ID of the :class:`.Reaction` or :class:`.Compound` - :param component_type: integer specifying the type of the component - :param commit: commit the changes to the database (Default value = True) - :returns: the component ID - - """ - - sql = """ - INSERT INTO component(component_route, component_type, component_ref, component_amount) - VALUES(:component_route, :component_type, :component_ref, :component_amount) - """ - - route = int(route) - ref = int(ref) - component_type = int(component_type) - - if component_type == 1: - component_amount = None - else: - component_amount = float(amount) - assert component_amount > 0 - - try: - self.execute( - sql, - dict( - component_route=route, - component_type=component_type, - component_ref=ref, - component_amount=component_amount, - ), - ) - - except sqlite3.IntegrityError as e: - - if "UNIQUE constraint failed: component" in str(e): - mrich.warning( - f"Did not add existing component={ref} (type={component_type}) to {route=}" - ) - return None - else: - raise - - component_id = self.cursor.lastrowid - - if commit: - self.commit() - - return component_id - - def insert_interaction( - self, - *, - feature: Feature | int, - pose: Pose | int, - type: str, - family: str, - atom_ids: list[int], - prot_coord: list[float], - lig_coord: list[float], - distance: float, - angle: float | None = None, - energy: float | None = None, - warn_duplicate: bool = True, - commit: bool = True, - table: str = "interaction", - ) -> int: - """Insert an entry into the interaction table - - :param feature: associated :class:`.Feature` object or ID - :param pose: associated :class:`.Pose` object or ID - :param type: interaction type - :param family: ligand feature type - :param atom_ids: atom indices of ligand feature - :param prot_coord: ``[x,y,z]`` coordinate of protein feature - :param lig_coord: ``[x,y,z]`` coordinate of ligand feature - :param distance: interaction distance ``Angstrom`` - :param angle: optional interaction angle ``degrees`` - :param energy: energy score ``kcal/mol``, defaults to ``None`` - :param warn_duplicate: print a warning if the pose already exists (Default value = True) - :param commit: commit the changes to the database (Default value = True) - :param table: the name of the table to insert into (Default value = 'interaction') - :returns: the interaction ID - """ - - # validation - - if isinstance(feature, Feature): - feature = feature.id - - if isinstance(pose, Pose): - pose = pose.id - - from .prolif import FEATURE_FAMILIES, INTERACTION_TYPES - - if family: - assert family in FEATURE_FAMILIES, f"Unsupported {family=}" - else: - family = "Unknown" - - # assert type in INTERACTION_TYPES.values(), f"Unsupported {type=}" - - assert isinstance(atom_ids, list), f"Unsupported {atom_ids=}" - assert not any( - [not isinstance(i, int) for i in atom_ids] - ), f"Unsupported {atom_ids=}" - atom_ids = json.dumps(atom_ids) - - prot_coord = list(prot_coord) if prot_coord is not None else [] - assert len(prot_coord) == 3 or not prot_coord, f"Unsupported {prot_coord=}" - assert not any( - [not isinstance(i, float) for i in prot_coord] - ), f"Unsupported {prot_coord=}" - prot_coord = json.dumps(prot_coord) - - lig_coord = list(lig_coord) if lig_coord is not None else [] - assert len(lig_coord) == 3 or not lig_coord, f"Unsupported {lig_coord=}" - assert not any( - [not isinstance(i, float) for i in lig_coord] - ), f"Unsupported {lig_coord=}" - lig_coord = json.dumps(lig_coord) - - try: - distance = float(distance) - except ValueError: - raise ValueError(f"Unsupported {distance=}") - - try: - if angle is not None: - angle = float(angle) - except ValueError: - raise ValueError(f"Unsupported {angle=}") - - if energy is not None: - try: - energy = float(energy) - except ValueError: - raise ValueError(f"Unsupported {energy=}") - - # insertion - - sql = f""" - INSERT INTO {table}( - interaction_feature, - interaction_pose, - interaction_type, - interaction_family, - interaction_atom_ids, - interaction_prot_coord, - interaction_lig_coord, - interaction_distance, - interaction_angle, - interaction_energy - ) - VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) - """ - - try: - self.execute( - sql, - ( - feature, - pose, - type, - family, - atom_ids, - prot_coord, - lig_coord, - distance, - angle, - energy, - ), - ) - - except sqlite3.IntegrityError as e: - mrich.error(e) - if warn_duplicate: - # mrich.warning(f"Skipping existing interaction: {(feature, pose, family, atom_ids, prot_coord, lig_coord, distance, energy)}") - mrich.warning( - f"Skipping existing interaction: {feature=} {pose=} {family=} {atom_ids=}" - ) - return None - - except Exception as e: - mrich.error(e) - - interaction_id = self.cursor.lastrowid - - if commit: - self.commit() - - return interaction_id - - def insert_subsite(self, target: int, name: str, commit: bool = True) -> int: - """Insert an entry into the subsite table - - :param target: protein :class:`.Target` ID - :param name: name of the protein subsite/subsite - :returns: the subsite ID - - """ - - assert isinstance(target, int) - assert isinstance(name, str) - - sql = """ - INSERT INTO subsite(subsite_target, subsite_name) - VALUES(?1, ?2) - """ - - try: - self.execute(sql, (target, name)) - - except sqlite3.IntegrityError as e: - mrich.warning(f"Skipping existing subsite for {target=} with {name=}") - return None - - except Exception as e: - mrich.error(e) - - subsite_id = self.cursor.lastrowid - if commit: - self.commit() - - return subsite_id - - def insert_subsite_tag( - self, - *, - pose_id: int, - name: str | None, - target: int | None = None, - subsite_id: int | None = None, - commit: bool = True, - ) -> int: - """Insert an entry into the subsite_tag table - - :param pose_id: :class:`.Pose` ID - :param name: name of the protein subsite/pocket - :param target: protein :class:`.Target` ID, defaults to querying pose table - :param target: protein Subsite ID, defaults to querying Subsite table - :returns: the Subsite ID - - """ - - if name is not None: - assert isinstance(name, str) - - assert isinstance(pose_id, int) - - if not target: - (target,) = self.select_where( - table="pose", key="id", value=pose_id, query="pose_target" - ) - - assert isinstance(target, int) - - if not subsite_id: - subsite_id = self.get_subsite_id(name=name, none="quiet") - - if not subsite_id: - subsite_id = self.insert_subsite(name=name, target=target) - - assert isinstance(subsite_id, int) - - sql = """ - INSERT INTO subsite_tag(subsite_tag_ref, subsite_tag_pose) - VALUES(?1, ?2) - """ - - try: - self.execute(sql, (subsite_id, pose_id)) - - except sqlite3.IntegrityError as e: - mrich.warning( - f"Skipping existing subsite_tag for {subsite_id=} with {pose_id=}" - ) - return None - - except Exception as e: - mrich.error(e) - - subsite_tag_id = self.cursor.lastrowid - if commit: - self.commit() - - return subsite_tag_id - - ### SELECTION - - def select( - self, - query: str, - table: str, - multiple: bool = False, - ) -> tuple | list[tuple]: - """Wrapper for the SQL SELECT query, in the following syntax: - - :: - - 'SELECT {query} FROM {table}' - - :param query: the columns to return - :param table: the table from which to select - :param multiple: fetch all results (Default value = False) - :returns: the result of the query - - """ - - sql = f"SELECT {query} FROM {table}" - - try: - self.execute(sql) - except sqlite3.OperationalError as e: - mrich.var("sql", sql) - raise - - if multiple: - result = self.cursor.fetchall() - else: - result = self.cursor.fetchone() - - return result - - def select_where( - self, - query: str, - table: str, - key: str, - value: str | None = None, - multiple: bool = False, - none: str | None = "error", - sort: str = None, - ) -> tuple | list[tuple]: - """Select entries where ``key == value`` - - Examples - ======== - - Find compound alias with matching ID: - - :: - - animal.db.select_where( - query='compound_alias', - table='compound', - key='id', - value='123', - ) - - # the above evaluates to: - 'SELECT compound_id FROM compound WHERE compound_id = 123' - - Find compound aliases with ID below 10 and order alphabetically: - - :: - - animal.db.select_where( - query='compound_alias', - table='compound', - key='compound_id < 10', - multiple=True, - sort='compound_alias', - ) - - # the above evaluates to: - 'SELECT compound_id FROM compound WHERE compound_id < 10 ORDER BY compound_alias' - - Parameters - ========== - - :param query: the columns to return - :param table: the table from which to select - :param key: column name to match to value, if no ``value`` is provided the key argument should contain the a SQL string to select entries - :param value: the value to match (Default value = None) - :param multiple: fetch all results (Default value = False) - :param none: define the behaviour for no matches, any value other than ``'error'`` will silently return empty data (Default value = 'error') - :param sort: optionally sort the output (Default value = None) - :returns: the result of the query - - """ - - if isinstance(value, str): - if "'" in value: - value = f'"{value}"' - else: - value = f"'{value}'" - - if value is not None: - where_str = f"{table}_{key}={value}" - else: - where_str = key - - if sort: - sql = f"SELECT {query} FROM {table} WHERE {where_str} ORDER BY {sort}" - else: - sql = f"SELECT {query} FROM {table} WHERE {where_str}" - - try: - self.execute(sql) - except sqlite3.OperationalError as e: - mrich.var("sql", sql) - raise - - if multiple: - result = self.cursor.fetchall() - else: - result = self.cursor.fetchone() - - if not result and none == "error": - mrich.error(f"No entry in {table} with {where_str}") - return None - elif not result and none == "exception": - raise ValueError(f"No entry in {table} with {where_str}") - - # if not result: - # raise ValueError(f"No entry in {table} with {where_str}") - - return result - - def select_id_where( - self, - table: str, - key: str, - value: str | None = None, - multiple: bool = False, - none: str | None = "error", - ) -> tuple | list[tuple]: - """Select ID's where ``key==value``. Similar to :meth:`.select_where` except the query argument is always ``{table}_id``. - - :param table: the table from which to select - :param key: column name to match to value, if no ``value`` is provided this - :param value: the value to match (Default value = None) - :param multiple: fetch all results (Default value = False) - :param none: define the behaviour for no matches, any value other than ``'error'`` will silently return empty data (Default value = 'error') - :returns: the result of the query - - """ - return self.select_where( - query=f"{table}_id", - table=table, - key=key, - value=value, - multiple=multiple, - none=none, - ) - - def select_all_where( - self, - table: str, - key: str, - value: str | None = None, - multiple: bool = False, - none: str | None = "error", - ) -> tuple | list[tuple]: - """Select entries where ``key==value``. Similar to :meth:`.select_where` except the query argument is always ``*``. - - :param table: the table from which to select - :param key: column name to match to value, if no ``value`` is provided this - :param value: the value to match (Default value = None) - :param multiple: fetch all results (Default value = False) - :param none: define the behaviour for no matches, any value other than ``'error'`` will silently return empty data (Default value = 'error') - :returns: the result of the query - - """ - return self.select_where( - query="*", table=table, key=key, value=value, multiple=multiple, none=none - ) - - ### DELETION - - def delete_where( - self, - table: str, - key: str, - value: str | None = None, - commit: bool = True, - ) -> None: - """Delete entries where ``key==value`` - - :param table: the table from which to delete - :param key: column name to match to value, if no ``value`` is provided this - :param value: the value to match (Default value = None) - :param commit: commit the changes (Default value = True) - - """ - - if value is not None: - - if isinstance(value, str): - value = f"'{value}'" - - sql = f"DELETE FROM {table} WHERE {table}_{key}={value}" - - else: - - sql = f"DELETE FROM {table} WHERE {key}" - - try: - result = self.execute(sql) - - except sqlite3.OperationalError as e: - mrich.var("sql", sql) - raise - - if commit: - self.commit() - - def delete_tag( - self, - tag: str, - ) -> None: - """Delete all tag entries with the matching name - - :param tag: tag name to match - - """ - self.delete_where(table="tag", key="name", value=tag) - - def delete_interactions(self) -> None: - """Delete all calculated interactions and set pose_fingerprint appropriately""" - - self.delete_where(table="interaction", key="interaction_id > 0") - self.update_all(table="pose", key="pose_fingerprint", value=0) - - def delete_features(self) -> None: - """Delete all protein features""" - - self.delete_where(table="feature", key="feature_id > 0") - - def delete_reactions(self) -> None: - """Delete all reaction data""" - - tables = ["reaction", "reactant", "route", "component"] - - for table in tables: - self.execute(f"DELETE FROM {table};") - self.commit() - - def delete_subsites(self) -> None: - """Delete all protein subsites""" - - self.delete_where(table="subsite", key="subsite_id > 0") - self.delete_where(table="subsite_tag", key="subsite_tag_id > 0") - - ### UPDATE - - def update( - self, - *, - table: str, - id: int, - key: str, - value, - commit: bool = True, - ) -> int: - """Update a field in a database entry with given ID - - :param table: the table which to update - :param id: the ID of the entry to update - :param key: column name to update - :param value: the value to insert - :param commit: commit the changes to the database (Default value = True) - :returns: the ID of the modified entry - - """ - - sql = f""" - UPDATE {table} - SET {key} = ? - WHERE {table}_id = {id}; - """ - - try: - self.execute(sql, (value,)) - except sqlite3.OperationalError as e: - mrich.var("sql", sql) - raise - - id = self.cursor.lastrowid - if commit: - self.commit() - return id - - def update_all( - self, - *, - table: str, - key: str, - value, - commit: bool = True, - ) -> None: - """Update all fields in a table column at once - - :param table: the table which to update - :param key: column name to update - :param value: the value to insert - :param commit: commit the changes to the database (Default value = True) - - """ - - sql = f""" - UPDATE {table} - SET {key} = ? - """ - - try: - self.execute(sql, (value,)) - except sqlite3.OperationalError as e: - mrich.var("sql", sql) - raise - - if commit: - self.commit() - - ### COPYING / MIGRATION - - def copy_temp_interactions(self) -> int: - """Copy the records from the 'temp_interaction' table to the 'interaction' table - - :returns: ID of the last inserted :class:`.Interaction` - """ - - cursor = self.execute( - """ - INSERT OR IGNORE INTO interaction( - interaction_feature, - interaction_pose, - interaction_type, - interaction_family, - interaction_atom_ids, - interaction_prot_coord, - interaction_lig_coord, - interaction_distance, - interaction_angle, - interaction_energy - ) - SELECT interaction_feature, - interaction_pose, - interaction_type, - interaction_family, - interaction_atom_ids, - interaction_prot_coord, - interaction_lig_coord, - interaction_distance, - interaction_angle, - interaction_energy - FROM temp_interaction - """ - ) - - return cursor.lastrowid - - def copy_interactions_to_temp(self, pose_id: int) -> int: - """Copy the records from the 'temp_interaction' table to the 'interaction' table - - :returns: ID of the last inserted :class:`.Interaction` - """ - - cursor = self.execute( - f""" - INSERT OR IGNORE INTO temp_interaction( - interaction_feature, - interaction_pose, - interaction_type, - interaction_family, - interaction_atom_ids, - interaction_prot_coord, - interaction_lig_coord, - interaction_distance, - interaction_angle, - interaction_energy - ) - SELECT interaction_feature, - interaction_pose, - interaction_type, - interaction_family, - interaction_atom_ids, - interaction_prot_coord, - interaction_lig_coord, - interaction_distance, - interaction_angle, - interaction_energy - FROM interaction - WHERE interaction_pose = {pose_id} - """ - ) - - return cursor.lastrowid - - def migrate_legacy_scaffolds(self) -> int: - """Migrate legacy compound_scaffold records from the 'compound' table to the 'scaffold' table - - :returns: ID of the last inserted scaffold record - """ - - mrich.debug("HIPPO.Database.migrate_legacy_scaffolds()") - - cursor = self.execute( - """ - INSERT INTO scaffold(scaffold_base, scaffold_superstructure) - SELECT compound_base, compound_id FROM compound - WHERE compound_base IS NOT NULL - """ - ) - - self.commit() - - return cursor.lastrowid - - def update_legacy_routes(self) -> None: - """Update legacy component entries""" - - # add column - - sql = """ - ALTER TABLE component - ADD component_amount REAL; - """ - - self.execute(sql) - - # set values - - sql = """ - UPDATE component - SET component_amount = :component_amount - WHERE component_type = :component_type; - """ - - self.execute(sql, dict(component_amount=None, component_type=1)) - self.execute(sql, dict(component_amount=1.0, component_type=2)) - self.execute(sql, dict(component_amount=1.0, component_type=3)) - - def update_legacy_reaction_metadata(self) -> None: - """Add reaction_metadata column""" - - sql = """ - ALTER TABLE reaction - ADD reaction_metadata TEXT; - """ - - self.execute(sql) - - def update_legacy_pose_inspiration_score(self) -> None: - """Add pose_inspiration_score column""" - - sql = """ - ALTER TABLE pose - ADD pose_inspiration_score REAL; - """ - - self.execute(sql) - - def update_compound_pattern_bfp_table(self): - """Update the compound pattern BFP table""" - self.execute( - """ - INSERT INTO compound_pattern_bfp - SELECT c.compound_id, c.compound_pattern_bfp FROM compound AS c - LEFT JOIN compound_pattern_bfp as fp - ON c.compound_id = fp.compound_id - WHERE fp.compound_id IS NULL - """ - ) - - ### BULK CLEANUP - - def prune_duplicate_routes(self) -> None: - """Remove duplicate routes from the database""" - - from collections import Counter - - sql = """ - SELECT route_id, route_product, component_ref, component_type FROM route - INNER JOIN component ON route_id = component_route - """ - - records = self.execute(sql).fetchall() - - routes = {} - - for route_id, route_product, component_ref, component_type in records: - if route_id not in routes: - routes[route_id] = (route_product, set()) - - key = (component_ref, component_type) - routes[route_id][1].add(key) - - routes = {key: (value[0], tuple(value[1])) for key, value in routes.items()} - - flat_routes = [value for key, value in routes.items()] - - mrich.var("#routes", len(flat_routes)) - - counter = Counter(flat_routes) - duplicates = {item: count for item, count in counter.items() if count > 1} - - mrich.var("products with duplicate routes", len(duplicates)) - - if not duplicates: - mrich.success("No duplicate routes found") - return None - - delete = set() - for dupe in duplicates: - matched_ids = [k for k, v in routes.items() if v == dupe] - mrich.print( - "compound", dupe[0], "has", len(matched_ids), "duplicate routes" - ) - for route_id in matched_ids[1:]: - delete.add(route_id) - - str_ids = str(tuple(delete)).replace(",)", ")") - self.delete_where(table="component", key=f"component_route IN {str_ids}") - self.delete_where(table="route", key=f"route_id IN {str_ids}") - - mrich.success("Deleted", len(delete), "duplicate routes") - - return delete - - def reinitialise_molecules(self): - """In the case where the Mol binaries in a database are throwing unpickling errors, run this to reinitialise them all from their smiles.""" - - mrich.var("#compounds", self.count("compound")) - - sql = """ - UPDATE your_table_name - SET compound_mol = mol_from_smiles(compound_smiles); - """ - - with mrich.loading("Reinitialising compounds..."): - self.execute(sql) - - mrich.success("compound_mol records updated") - - def fix_incorrect_pose_compound_assignments(self): - """Fix pose_compound values that reference incorrect chemical structures""" - - lookup = self.get_compound_id_smiles_dict() - lookup = {v: k for k, v in lookup.items()} - - count = self.count_where(table="pose", key="mol", value="NOT null") - - sql = """ - SELECT pose_id, pose_compound, mol_to_smiles(mol_from_binary_mol(pose_mol)) - FROM pose - WHERE pose_mol IS NOT null - """ - - c = self.execute(sql) - - fix = set() - fix_count = 0 - for pose_id, pose_compound, smiles in mrich.track(c, total=count): - try: - flat_smiles = sanitise_smiles(smiles) - except Exception as e: - mrich.error("Could not sanitise", pose_id, smiles) - - comp_id = lookup.get(flat_smiles) - - if not comp_id: - mrich.error("No matching compound", pose_id, smiles) - continue - - if comp_id != pose_compound: - fix.add((pose_id, comp_id)) - fix_count += 1 - mrich.set_progress_field("#fix", fix_count) - - mrich.var("#fix", len(fix)) - - sql = """ - UPDATE pose - SET pose_compound = ?2 - WHERE pose_id = ?1 - """ - - self.executemany(sql, list(fix)) - self.commit() - - ### BULK REGISTRATION - - def register_compounds( - self, - *, - smiles: list[str], - radical: str = "warning", - sanitisation_verbosity: bool = True, - sanitise: bool = True, - debug: bool = False, - ) -> list[tuple[str, str]]: - """Bulk register compounds""" - - values = [] - - if len(smiles) > 1000: - generator = mrich.track(smiles, prefix="Sanitising...") - else: - generator = smiles - - for s in generator: - - if sanitise: - try: - new_smiles = sanitise_smiles( - s, - sanitisation_failed="error", - radical=radical, - verbosity=sanitisation_verbosity, - ) - except SanitisationError as e: - mrich.error(f"Could not sanitise {s=}") - mrich.error(str(e)) - continue - except AssertionError: - mrich.error(f"Could not sanitise {s=}") - continue - else: - new_smiles = s - - inchikey = inchikey_from_smiles(new_smiles) - values.append((inchikey, new_smiles)) - - if self.auto_compute_bfps: - - sql = """ - INSERT OR IGNORE INTO compound(compound_inchikey, compound_smiles, compound_mol, compound_pattern_bfp, compound_morgan_bfp) - VALUES(?1, ?2, mol_from_smiles(?2), mol_pattern_bfp(mol_from_smiles(?2), 2048), mol_morgan_bfp(mol_from_smiles(?2), 2, 2048)) - """ - - else: - - sql = """ - INSERT OR IGNORE INTO compound(compound_inchikey, compound_smiles, compound_mol) - VALUES(?1, ?2, mol_from_smiles(?2)) - """ - - if debug: - mrich.debug("Inserting...") - - self.executemany(sql, values) - - if self.auto_compute_bfps: - self.update_compound_pattern_bfp_table() - - self.commit() - - return values - - def register_poses(self, dicts: list[dict]) -> set[int]: - """Insert or ignore a bunch of poses, also returns a set of Pose IDs - - :param dicts: a list of dictionaries describing the poses to be inserted. See the expected format below: - - dicts = [ - dict( - alias=..., # string can be None - reference_id=..., # reference pose id - inchikey=..., # pre-computed inchikey - smiles=..., # SMILEs - path=..., # path to mol-file on disk, used for uniqueness check, can be a fake path - compound_id=..., # Compound database ID - target_id=..., # Target database ID - mol=..., # rdkit.Chem.Mol - energy_score=..., # float, can be None - distance_score=..., # float, can be None - metadata=..., # dictionary, can be empty - ) - ] - - """ - - from json import dumps - - ### POSES - - sql = """ - INSERT OR IGNORE INTO pose( - pose_inchikey, - pose_smiles, - pose_alias, - pose_reference, - pose_path, - pose_compound, - pose_target, - pose_mol, - pose_energy_score, - pose_distance_score, - pose_metadata - ) - VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11) - """ - - values = [] - for i, d in enumerate(dicts): - - alias = d["alias"] - reference_id = d.get("reference_id") - if reference_id: - reference_id = int(reference_id) - - if not alias: - alias = None - - try: - values.append( - ( - str(d["inchikey"]), - str(d["smiles"]), - alias, - reference_id, - str(d["path"]), - int(d["compound_id"]), - int(d["target_id"]), - d["mol"].ToBinary(), - float(d["energy_score"]), - float(d["distance_score"]), - dumps(d["metadata"]), - ) - ) - except KeyError as e: - mrich.error("Skipping", i, str(e)) - - self.executemany(sql, values) - self.commit() - - ### INSPIRATIONS - - lookup = self.get_pose_path_id_dict() - - values = [] - pose_ids = set() - - for i, d in enumerate(dicts): - if "inspiration_ids" not in d: - continue - derivative_id = lookup.get(str(d["path"])) - if not derivative_id: - mrich.error("Could not get derivative by path:", str(d["path"])) - continue - pose_ids.add(derivative_id) - for inspiration_id in d["inspiration_ids"]: - values.append((inspiration_id, derivative_id)) - - sql = """ - INSERT OR IGNORE INTO inspiration(inspiration_original, inspiration_derivative) - VALUES(?1, ?2) - """ - - self.executemany(sql, values) - self.commit() - - return pose_ids - - def calculate_all_scaffolds(self) -> None: - """Determine and insert records for all substructure/superstructure relationships in the Compound table""" - - n_before = self.count("scaffold") - - mrich.var("#compounds", self.count("compound")) - mrich.var("#scaffold defs", n_before) - - sql = """ - SELECT compound_id, compound_mol, compound_pattern_bfp - FROM compound - """ - - with mrich.loading("Fetching compounds..."): - records = self.execute(sql).fetchall() - - self.commit() - - sql = """ - INSERT OR IGNORE INTO scaffold - SELECT ?1, c.compound_id - FROM compound AS c, compound_pattern_bfp AS fp - WHERE c.compound_id = fp.compound_id - AND c.compound_id <> ?1 - AND mol_is_substruct(c.compound_mol, ?2) - AND fp.compound_id MATCH rdtree_subset(?3) - """ - - with mrich.loading("Calculating scaffolds..."): - t1 = time.time() - self.executemany(sql, records) - mrich.print("Took", f"{time.time() - t1:.1f}", "seconds") - - self.commit() - - diff = self.count("scaffold") - n_before - - if diff: - mrich.success( - "Found", diff, "new substructure-superstructure relationships" - ) - else: - mrich.warning( - "Found", diff, "new substructure-superstructure relationships" - ) - - def calculate_all_murcko_scaffolds( - self, generic: bool = True - ) -> "dict | (dict, dict)": - """Determine Murcko and optionally generic Murcko scaffolds for all Compounds in the Database and add relevant records. - - :param generic: Calculate generic (single bonds and all carbon) scaffolds as well - """ - - n_before = self.count("scaffold") - - mrich.var("#compounds", self.count("compound")) - mrich.var("#scaffold defs", n_before) - - from rdkit.Chem import MolFromSmiles, MolToSmiles - from rdkit.Chem.Scaffolds.MurckoScaffold import ( - MurckoScaffoldSmiles, - MakeScaffoldGeneric, - ) - - compound_records = self.select( - query="compound_id, compound_smiles", table="compound", multiple=True - ) - - ### CALCULATE SCAFFOLDS - - murcko_data = {} - generic_data = {} - generic_to_murcko = {} - for c_id, smiles in mrich.track(compound_records): - - # murcko - - try: - murcko_smiles = sanitise_smiles(MurckoScaffoldSmiles(smiles)) - except KeyboardInterrupt: - raise - except: - mrich.error("can't make murcko:", smiles) - continue - - if murcko_smiles not in murcko_data: - murcko_data[murcko_smiles] = set() - - murcko_data[murcko_smiles].add(c_id) - - # generic - - if generic: - - try: - generic_smiles = sanitise_smiles( - MolToSmiles(MakeScaffoldGeneric(MolFromSmiles(murcko_smiles))) - ) - except KeyboardInterrupt: - raise - except: - mrich.error("can't make generic:", murcko_smiles) - continue - - if generic_smiles not in generic_data: - generic_data[generic_smiles] = set() - - if generic_smiles not in generic_to_murcko: - generic_to_murcko[generic_smiles] = set() - - generic_data[generic_smiles].add(c_id) - generic_to_murcko[generic_smiles].add(murcko_smiles) - - mrich.var("#murcko scaffolds", len(murcko_data)) - mrich.var("#generic murcko scaffolds", len(generic_data)) - - ### REGISTER MURCKOS - - murcko_values = self.register_compounds( - smiles=murcko_data.keys(), sanitisation_verbosity=False, sanitise=False - ) - murcko_s2i = {s: i for i, s in murcko_values} - - ### REGISTER GENERICS - - if generic: - generic_values = self.register_compounds( - smiles=generic_data.keys(), sanitisation_verbosity=False, sanitise=False - ) - generic_s2i = {s: i for i, s in generic_values} - - ### TAG MURCKOS - - murcko_ids = self.select_id_where( - table="compound", - key=f"compound_inchikey IN {tuple(murcko_s2i.values())}", - multiple=True, - ) - - self.executemany( - """INSERT OR IGNORE INTO tag (tag_name, tag_compound) VALUES (?,?)""", - [("MurckoScaffold", i) for i, in murcko_ids], - ) - - ### TAG GENERICS - - if generic: - - generic_ids = self.select_id_where( - table="compound", - key=f"compound_inchikey IN {tuple(generic_s2i.values())}", - multiple=True, - ) - - self.executemany( - """INSERT OR IGNORE INTO tag (tag_name, tag_compound) VALUES (?,?)""", - [("GenericMurckoScaffold", i) for i, in generic_ids], - ) - - ### ADD MURCKO SCAFFOLD RELATIONS - - pairs = [] - - murcko_inchikey_lookup = self.get_compound_inchikey_id_dict(murcko_s2i.values()) - for murcko_smiles, c_ids in murcko_data.items(): - murcko_id = murcko_inchikey_lookup[murcko_s2i[murcko_smiles]] - for c_id in c_ids: - pairs.append((murcko_id, c_id)) - - pairs = [(a, b) for a, b in pairs if a != b] - - mrich.var("#murcko scaffold relations", len(pairs)) - - self.executemany( - """INSERT OR IGNORE INTO scaffold (scaffold_base, scaffold_superstructure) VALUES (?,?)""", - pairs, - ) - - ### ADD GENERIC SCAFFOLD RELATIONS - - if generic: - - pairs = [] - - generic_inchikey_lookup = self.get_compound_inchikey_id_dict( - generic_s2i.values() - ) - for generic_smiles, c_ids in generic_data.items(): - generic_id = generic_inchikey_lookup[generic_s2i[generic_smiles]] - for c_id in c_ids: - pairs.append((generic_id, c_id)) - - for generic_smiles, murcko_smiles_list in generic_to_murcko.items(): - generic_id = generic_inchikey_lookup[generic_s2i[generic_smiles]] - for murcko_smiles in murcko_smiles_list: - murcko_id = murcko_inchikey_lookup[murcko_s2i[murcko_smiles]] - pairs.append((generic_id, murcko_id)) - - pairs = [(a, b) for a, b in pairs if a != b] - - mrich.var("#generic murcko scaffold relations", len(pairs)) - - self.executemany( - """INSERT OR IGNORE INTO scaffold (scaffold_base, scaffold_superstructure) VALUES (?,?)""", - pairs, - ) - - self.commit() - - if generic: - return murcko_data, generic_data - else: - return murcko_data - - def set_derivative_subsites(self, commit: bool = True) -> None: - """Propagate all subsite assignments from inspirations to their derivatives""" - - sql = """ - INSERT OR IGNORE INTO subsite_tag(subsite_tag_ref, subsite_tag_pose) - SELECT subsite_tag_ref, inspiration_derivative FROM subsite_tag - INNER JOIN inspiration - ON subsite_tag_pose = inspiration_original - """ - - self.execute(sql) - - if commit: - self.commit() - - ### GETTERS - - def get_compound( - self, - *, - id: int | None = None, - inchikey: str | None = None, - alias: str | None = None, - smiles: str | None = None, - none: str = "error", - **kwargs, - ) -> Compound: - """Get a :class:`.Compound` using one of the following fields: ['id', 'inchikey', 'alias', 'smiles'] - - :param id: the ID to search for (Default value = None) - :param inchikey: the InChi-Key to search for (Default value = None) - :param alias: the alias to search for (Default value = None) - :param smiles: the smiles to search for (Default value = None) - :returns: the :class:`.Compound` object - - """ - - if id is None: - id = self.get_compound_id( - inchikey=inchikey, smiles=smiles, alias=alias, none=none, **kwargs - ) - - if not id: - if none == "error": - mrich.error(f"Invalid {id=}") - return None - - query = "compound_id, compound_inchikey, compound_alias, compound_smiles" - entry = self.select_where( - query=query, table="compound", key="id", value=id, none=none, **kwargs - ) - compound = Compound(self._animal, self, *entry, metadata=None, mol=None) - return compound - - def get_compound_id( - self, - *, - inchikey: str | None = None, - alias: str | None = None, - smiles: str | None = None, - **kwargs, - ) -> int: - """Get a compound's ID using one of the following fields: ['inchikey', 'alias', 'smiles'] - - :param inchikey: the InChi-Key to search for (Default value = None) - :param alias: the alias to search for (Default value = None) - :param smiles: the smiles to search for (Default value = None) - :returns: the :class:`.Compound` ID - - """ - - if inchikey: - entry = self.select_id_where( - table="compound", key="inchikey", value=inchikey, **kwargs - ) - - elif alias: - entry = self.select_id_where( - table="compound", key="alias", value=alias, **kwargs - ) - - elif smiles: - entry = self.select_id_where( - table="compound", key="smiles", value=smiles, **kwargs - ) - - else: - raise NotImplementedError - - if entry: - return entry[0] - - return None - - def get_compound_computed_property( - self, - prop: str, - compound_id: int, - ) -> int | str: - """Use chemicalite to calculate a property from the stored binary molecule - - :param prop: the property to calculate [num_heavy_atoms, formula, num_rings] - :param compound_id: the compound ID to query - :returns: the value of the computed property - - """ - - function = CHEMICALITE_COMPOUND_PROPERTY_MAP[prop] - (val,) = self.select_where( - query=f"{function}(compound_mol)", - table="compound", - key="id", - value=compound_id, - multiple=False, - ) - return val - - def get_pose( - self, - *, - id: int | None = None, - inchikey: str = None, - alias: str = None, - ) -> Pose: - """Get a pose using one of the following fields: ['id', 'inchikey', 'alias'] - - :param id: the ID to search for (Default value = None) - :param inchikey: the InChi-Key to search for (Default value = None) - :param alias: the alias to search for (Default value = None) - :returns: the :class:`.Pose` object - - """ - - if id is None: - id = self.get_pose_id(inchikey=inchikey, alias=alias) - - if isinstance(id, list): - from .pset import PoseSet - - return PoseSet(self, id) - - if not id: - mrich.error(f"Invalid {id=}") - return None - - fields = [ - "pose_id", - "pose_inchikey", - "pose_alias", - "pose_smiles", - "pose_reference", - "pose_path", - "pose_compound", - "pose_target", - "pose_mol", - "pose_fingerprint", - "pose_energy_score", - "pose_distance_score", - "pose_inspiration_score", - ] - - query = ", ".join(fields) - - entry = self.select_where(query=query, table="pose", key="id", value=id) - pose = Pose(self, *entry) - return pose - - def get_pose_id( - self, - *, - inchikey: str | None = None, - alias: str | None = None, - ) -> int: - """Get a pose's ID using one of the following fields: ['inchikey', 'alias', 'smiles'] - - :param table: the table from which to get the entry (Default value = 'pose') - :param inchikey: the InChi-Key to search for (Default value = None) - :param alias: the alias to search for (Default value = None) - :returns: the :class:`.Pose` ID - - """ - - if inchikey: - # inchikey might not be unique - entries = self.select_id_where( - table="pose", key="inchikey", value=inchikey, multiple=True - ) - if len(entries) != 1: - mrich.warning(f"Multiple poses with {inchikey=}") - return [i for i, in entries] - else: - entry = entries[0] - - elif alias: - entry = self.select_id_where(table="pose", key="alias", value=alias) - - else: - raise NotImplementedError - - if entry: - return entry[0] - - return None - - def get_reaction( - self, - *, - id: int | None = None, - none: str | None = None, - ) -> Reaction: - """Get a reaction using its ID - - :param id: the ID to search for (Default value = None) - :param none: define the behaviour for no matches, any value other than ``'error'`` will silently return empty data (Default value = 'error') - :returns: the :class:`.Reaction` object - - """ - - if not id: - mrich.error(f"Invalid {id=}") - return None - - query = "reaction_id, reaction_type, reaction_product, reaction_product_yield" - entry = self.select_where( - query=query, table="reaction", key="id", value=id, none=none - ) - - if not entry: - return None - - reaction = Reaction(self, *entry) - return reaction - - def get_quote( - self, - *, - id: int | None = None, - none: str | None = None, - ) -> Quote: - """Get a quote using its ID - - :param id: the ID to search for (Default value = None) - :param none: define the behaviour for no matches, any value other than ``'error'`` will silently return empty data (Default value = 'error') - :returns: the :class:`.Quote` object - - """ - - query = "quote_compound, quote_supplier, quote_catalogue, quote_entry, quote_amount, quote_price, quote_currency, quote_lead_time, quote_purity, quote_date, quote_smiles, quote_id " - entry = self.select_where( - query=query, table="quote", key="id", value=id, none=none - ) - - return Quote( - db=self, - id=entry[11], - compound=entry[0], - supplier=entry[1], - catalogue=entry[2], - entry=entry[3], - amount=entry[4], - price=entry[5], - currency=entry[6], - lead_time=entry[7], - purity=entry[8], - date=entry[9], - smiles=entry[10], - ) - - def get_quote_df(self, ids: list[int]) -> "pd.DataFrame": - """Get a pandas DataFrame representing quotes with given IDs""" - - from pandas import DataFrame - - str_ids = str(tuple(ids)).replace(",)", ")") - - query = "quote_compound, quote_supplier, quote_catalogue, quote_entry, quote_amount, quote_price, quote_currency, quote_lead_time, quote_purity, quote_date, quote_smiles, quote_id " - records = self.select_where( - query=query, - table="quote", - key=f"quote_id IN {str_ids}", - multiple=True, - ) - - data = [] - - for entry in records: - data.append( - dict( - id=entry[11], - compound=entry[0], - supplier=entry[1], - catalogue=entry[2], - entry=entry[3], - amount=entry[4], - price=entry[5], - currency=entry[6], - lead_time=entry[7], - purity=entry[8], - date=entry[9], - smiles=entry[10], - ) - ) - - return DataFrame(data) - - def get_metadata( - self, - *, - table: str, - id: int, - ) -> dict: - """Get metadata dictionary from a specific table and ID - - :param table: the table from which to get the entry - :param id: the ID to search for (Default value = None) - :returns: a dictionary of metadata - - """ - - (payload,) = self.select_where( - query=f"{table}_metadata", table=table, key=f"id", value=id - ) - - if payload: - payload = json.loads(payload) - - else: - payload = dict() - - metadata = MetaData(payload) - - metadata._db = self - metadata._id = id - metadata._table = table - - return metadata - - def get_target( - self, - *, - id: int, - ) -> Target: - """Get target with specific ID - - :param id: the ID of the target to retrieve - :returns: :class:`.Target` object - - """ - - return Target(db=self, id=id, name=self.get_target_name(id=id)) - - def get_target_name( - self, - *, - id: int, - ) -> str: - """Get the name of a target with given ID - - :param id: the ID of the target to retrieve - :returns: target name - - """ - - table = "target" - (payload,) = self.select_where( - query=f"{table}_name", table=table, key="id", value=id - ) - return payload - - def get_target_id( - self, - *, - name: str, - ) -> int | None: - """Get target ID with a given name - - :param name: the protein target name - :returns: the :class:`.Target` ID - - """ - - table = "target" - entry = self.select_id_where(table=table, key="name", value=name) - - if entry: - return entry[0] - - return None - - def get_feature( - self, - *, - id: int, - ) -> Feature: - """Get a protein interaction :class:`.Feature` with a given ID - - :param id: the protein interaction :class:`.Feature` ID to be retrieved - :returns: :class:`.Feature` object - - """ - - entry = self.select_all_where(table="feature", key="id", value=id) - return Feature(*entry) - - def get_route( - self, - *, - id: int, - debug: bool = False, - ) -> Route: - """Fetch a :class:`.Route` object stored in the :class:`.Database`. - - :param id: the ID of the :class:`.Route` to be retrieved - :param debug: increase verbosity for debugging, defaults to False - :returns: :class:`.Route` object - - """ - - from .cset import CompoundSet, IngredientSet - from .rset import ReactionSet - - (product_id,) = self.select_where( - table="route", query="route_product", key="id", value=id - ) - - if debug: - mrich.var("product_id", product_id) - - triples = self.select_where( - table="component", - query="component_ref, component_type, component_amount", - key=f"component_route IS {id} ORDER BY component_id", - multiple=True, - ) - - reaction_ids = [] - reactant_ids = [] - reactant_amounts = [] - intermediate_ids = [] - intermediate_amounts = [] - - for ref, c_type, amount in triples: - match c_type: - case 1: - reaction_ids.append(ref) - case 2: - reactant_ids.append(ref) - reactant_amounts.append(amount) - case 3: - intermediate_ids.append(ref) - intermediate_amounts.append(amount) - case _: - raise ValueError(f"Unknown component type {c_type}") - - if debug: - mrich.var("pairs", pairs) - - products = CompoundSet(self, [product_id]) - reactants = CompoundSet(self, reactant_ids) - intermediates = CompoundSet(self, intermediate_ids) - - products = IngredientSet.from_compounds(compounds=products, amount=1) - reactants = IngredientSet.from_compounds( - compounds=reactants, amount=reactant_amounts - ) - intermediates = IngredientSet.from_compounds( - compounds=intermediates, amount=intermediate_amounts - ) - - reactions = ReactionSet(self, reaction_ids) - - recipe = Route( - self, - route_id=id, - product=products, - reactants=reactants, - intermediates=intermediates, - reactions=reactions, - ) - - if debug: - mrich.var("recipe", recipe) - - return recipe - - def get_route_products(self) -> "CompoundSet | None": - """Get a :class:`.CompoundSet` of all route products""" - from .cset import CompoundSet - - records = self.execute("SELECT DISTINCT route_product FROM route").fetchall() - if not records: - return None - return CompoundSet(self, [i for i, in records]) - - def get_route_id_product_dict(self) -> dict[int, int]: - """Get a dictionary mapping route ID's to their product :class:`.Compound`""" - records = self.execute("SELECT route_id, route_product FROM route").fetchall() - return {route_id: route_product for route_id, route_product in records} - - def get_product_id_routes_dict(self) -> dict[int, set[int]]: - """Get a dictionary mapping product :class:`.Compound` to their route IDs""" - records = self.execute("SELECT route_id, route_product FROM route").fetchall() - - lookup = {} - - for route_id, route_product in records: - - if route_product not in lookup: - lookup[route_product] = set() - - lookup[route_product].add(route_id) - - return lookup - - def get_compound_id_pose_ids_dict(self, cset: "CompoundSet") -> dict[int, set]: - """Get a dictionary mapping :class:`.Compound` ID's to their associated :class:`.Pose` ID's""" - records = self.execute( - f"SELECT pose_compound, pose_id FROM pose WHERE pose_compound IN {cset.str_ids}" - ).fetchall() - - d = {} - - for comp_id, pose_id in records: - d[comp_id] = d.get(comp_id, set()) - d[comp_id].add(pose_id) - return d - - def get_compound_id_suppliers_dict( - self, cset: "CompoundSet" - ) -> dict[int, set[str]]: - """Get a dictionary mapping :class:`.Compound` ID's to suppliers which stock it""" - records = self.execute( - f"SELECT quote_compound, quote_supplier FROM quote WHERE quote_compound IN {cset.str_ids}" - ).fetchall() - - d = {} - - for comp_id, quote_supplier in records: - d[comp_id] = d.get(comp_id, set()) - d[comp_id].add(quote_supplier) - - return d - - def get_compound_id_smiles_dict( - self, - cset: "CompoundSet | None" = None, - ) -> dict[int, set[str]]: - """Get a dictionary mapping :class:`.Compound` ID's to suppliers which stock it""" - - if cset: - sql = f"SELECT compound_id, compound_smiles FROM compound WHERE compound_id IN {cset.str_ids}" - - else: - sql = "SELECT compound_id, compound_smiles FROM compound" - - c = self.execute(sql) - - d = {} - for comp_id, comp_smiles in c: - d[comp_id] = comp_smiles - - return d - - def get_compound_inchikey_id_dict(self, inchikeys: list[str]) -> dict[str, int]: - """Get a dictionary mapping :class:`.Compound` inchikeys to their ID's""" - - inchikey_str = str(tuple(inchikeys)).replace(",)", ")") - - records = self.select_where( - table="compound", - multiple=True, - query="compound_inchikey, compound_id", - key=f"compound_inchikey IN {inchikey_str}", - ) - - return { - compound_inchikey: compound_id for compound_inchikey, compound_id in records - } - - def get_compound_id_inchikey_dict( - self, cset: "CompoundSet | None" = None - ) -> dict[int, str]: - """Get a dictionary mapping :class:`.Compound` IDs to their inchikeys""" - - if cset: - - records = self.select_where( - table="compound", - multiple=True, - query="compound_id, compound_inchikey", - key=f"compound_id IN {cset.str_ids}", - ) - - else: - - records = self.select( - table="compound", - multiple=True, - query="compound_id, compound_inchikey", - ) - - return { - compound_id: compound_inchikey for compound_id, compound_inchikey in records - } - - def get_id_metadata_dict(self, *, table: str, ids: list[int]) -> dict[int, dict]: - """Get a dictionary mapping IDs to metadata dictionaries""" - from json import loads - - str_ids = str(tuple(ids)).replace(",)", ")") - records = self.select_where( - query=f"{table}_id, {table}_metadata", - table=table, - key=f"{table}_id IN {str_ids}", - multiple=True, - ) - return {i: (loads(m) if m is not None else {}) for i, m in records} - - def get_compound_cluster_dict( - self, - cset: "CompoundSet | None" = None, - *, - fractions: bool = False, - max_scaffolds: int | None = None, - fraction_reference: "CompoundSet | None" = None, - ) -> dict[tuple, set]: - """Create a dictionary grouping compounds by their scaffold/base cluster. - - :param cset: :class:`.CompoundSet` subset to query, defaults to all compounds - :param fractions: Calculate fractional populations for each cluster - :param max_scaffolds: Define the maximum number of compounds to use as cluster keys - :param fraction_reference: Use cset to build the cluster map and use fraction_reference to determine the fractional populations - :returns: A dictionary mapping a tuple of scaffold :class:`.Compound` IDs to a set of superstructure :class:`.Compound` ID's. - """ - - if fractions: - assert cset is not None - - if (cset is not None and not fractions) or (fraction_reference is not None): - sql = f""" - SELECT scaffold_superstructure, scaffold_base FROM scaffold - WHERE scaffold_superstructure IN {cset.str_ids} - """ - else: - sql = f""" - SELECT scaffold_superstructure, scaffold_base FROM scaffold - """ - - records = self.execute(sql).fetchall() - - lookup = {} - for superstructure, scaffold in records: - group = lookup.setdefault(superstructure, set()) - group.add(scaffold) - - clustered = {} - for superstructure, scaffolds in lookup.items(): - if max_scaffolds and len(scaffolds) > max_scaffolds: - continue - cluster = tuple(scaffolds) - group = clustered.setdefault(cluster, set()) - group.add(superstructure) - - if fractions: - if fraction_reference is not None: - cset = fraction_reference - - fractions = {} - for cluster, members in clustered.items(): - size = len(members) - present = sum(1 for c in members if c in cset) - fractions[cluster] = present / size - return fractions - - return clustered - - def get_compound_scaffold_dict(self) -> dict[int, set[int]]: - """Get a dictionary mapping scaffold_base compound ID's to a set of their superstructure IDs""" - - records = self.select( - table="scaffold", - query="scaffold_base, scaffold_superstructure", - multiple=True, - ) - - data = {} - for scaffold, elab in records: - if scaffold not in data: - data[base] = set() - data[base].add(elab) - - return data - - def get_compound_tag_dict( - self, - cset: "CompoundSet | None" = None, - ) -> dict[int, set[str]]: - """Get a dictionary mapping compound ID's to their tags""" - - if cset: - raise NotImplementedError - - records = self.select( - query="tag_name, tag_compound", table="tag", multiple=True - ) - - data = {} - for tag_name, compound_id in records: - if compound_id not in data: - data[compound_id] = set() - data[compound_id].add(tag_name) - - # null IDS - comp_ids = self.select(table="compound", query="compound_id", multiple=True) - comp_ids = set(q for q, in comp_ids) - - null_ids = comp_ids - set(data.keys()) - - for c_id in null_ids: - data[c_id] = set() - - return data - - def get_pose_tag_dict( - self, - pset: "PoseSet | None" = None, - ) -> dict[int, set[str]]: - """Get a dictionary mapping pose ID's to their tags""" - - if pset: - records = self.select_where( - query="tag_name, tag_pose", - table="tag", - key=f"tag_pose IN {pset.str_ids}", - multiple=True, - ) - - else: - records = self.select( - query="tag_name, tag_pose", table="tag", multiple=True - ) - - data = {} - for tag_name, pose_id in records: - if pose_id not in data: - data[pose_id] = set() - data[pose_id].add(tag_name) - - # null IDS - - if pset: - null_ids = set(pset.ids) - set(data.keys()) - - else: - pose_ids = self.select(table="pose", query="pose_id", multiple=True) - pose_ids = set(q for q, in pose_ids) - - null_ids = pose_ids - set(data.keys()) - - for c_id in null_ids: - data[c_id] = set() - - return data - - def get_pose_subsite_names_dict(self) -> dict[int, set[str]]: - """Get a dictionary mapping pose ID's to their subsite names""" - - lookup = { - i: n - for i, n in self.select( - query="subsite_id, subsite_name", table="subsite", multiple=True - ) - } - - records = self.select( - query="subsite_tag_ref, subsite_tag_pose", - table="subsite_tag", - multiple=True, - ) - - data = {} - for subsite_id, pose_id in records: - data.setdefault(pose_id, set()) - data[pose_id].add(lookup[subsite_id]) - - # null IDS - pose_ids = self.select(table="pose", query="pose_id", multiple=True) - pose_ids = set(q for q, in pose_ids) - - null_ids = pose_ids - set(data.keys()) - - for c_id in null_ids: - data[c_id] = set() - - return data - - def get_pose_id_interaction_ids_dict(self, pset: "PoseSet") -> dict[int, set]: - """Get a dictionary mapping :class:`.Pose` ID's to their associated :class:`.Interaction` ID's""" - records = self.execute( - f"SELECT interaction_pose, interaction_id FROM interaction WHERE interaction_pose IN {pset.str_ids}" - ).fetchall() - - d = {} - - for pose_id, interaction_id in records: - d[pose_id] = d.get(pose_id, set()) - d[pose_id].add(interaction_id) - return d - - def get_pose_alias_id_dict(self, pset: "PoseSet | None" = None) -> dict[str, int]: - """Get a dictionary mapping :class:`.Pose` aliases to ID's""" - - if pset: - records = self.execute( - f""" - SELECT pose_id, pose_alias FROM pose - WHERE pose_alias IS NOT NULL - AND pose_id IN {pset.str_ids}""" - ).fetchall() - - else: - records = self.execute( - """SELECT pose_id, pose_alias FROM pose - WHERE pose_alias IS NOT NULL""" - ).fetchall() - - d = {} - for pose_id, pose_alias in records: - d[pose_alias] = pose_id - - return d - - def get_pose_alias_path_dict(self, pset: "PoseSet | None" = None) -> dict[str, str]: - """Get a dictionary mapping :class:`.Pose` aliases to paths""" - - if pset: - records = self.execute( - f""" - SELECT pose_alias, pose_path FROM pose - WHERE pose_id IN {pset.str_ids}""" - ).fetchall() - - else: - records = self.execute( - """SELECT pose_alias, pose_path FROM pose """ - ).fetchall() - - d = {} - for pose_alias, pose_path in records: - d[pose_alias] = pose_path - - return d - - def get_pose_id_alias_dict(self, pset: "PoseSet | None" = None) -> dict[str, int]: - """Get a dictionary mapping :class:`.Pose` aliases to ID's""" - - if pset: - records = self.execute( - f""" - SELECT pose_id, pose_alias FROM pose - WHERE pose_alias IS NOT NULL - AND pose_id IN {pset.str_ids}""" - ).fetchall() - - else: - records = self.execute( - """SELECT pose_id, pose_alias FROM pose - WHERE pose_alias IS NOT NULL""" - ).fetchall() - - d = {} - for pose_id, pose_alias in records: - d[pose_id] = pose_alias - - return d - - def get_pose_path_id_dict(self, pset: "PoseSet | None" = None) -> dict[str, int]: - """Get a dictionary mapping :class:`.Pose` aliases to ID's""" - - if pset: - records = self.execute( - f""" - SELECT pose_id, pose_path FROM pose - WHERE pose_path IS NOT NULL - AND pose_id IN {pset.str_ids}""" - ).fetchall() - - else: - records = self.execute( - """SELECT pose_id, pose_path FROM pose - WHERE pose_path IS NOT NULL""" - ).fetchall() - - d = {} - for pose_id, pose_path in records: - d[pose_path] = pose_id - - return d - - def get_pose_id_obj_dict(self, pset: "PoseSet") -> "dict[id, Pose]": - """Get a dictionary mapping :class:`.Pose` ID's to their objects""" - - query = "pose_id, pose_inchikey, pose_alias, pose_smiles, pose_reference, pose_path, pose_compound, pose_target, pose_mol, pose_fingerprint, pose_energy_score, pose_distance_score" - records = self.select_where( - query=query, table="pose", key=f"pose_id IN {pset.str_ids}", multiple=True - ) - - d = {} - for entry in records: - ( - pose_id, - pose_inchikey, - pose_alias, - pose_smiles, - pose_reference, - pose_path, - pose_compound, - pose_target, - pose_mol, - pose_fingerprint, - pose_energy_score, - pose_distance_score, - ) = entry - d[pose_id] = Pose( - self, - pose_id, - pose_inchikey, - pose_alias, - pose_smiles, - pose_reference, - pose_path, - pose_compound, - pose_target, - pose_mol, - pose_fingerprint, - pose_energy_score, - pose_distance_score, - ) - return d - - def get_pose_id_interaction_tuples_dict(self, pset: "PoseSet") -> dict[int, set]: - """Get a dictionary mapping :class:`.Pose` ID's to lists of `(interaction_type, feature_id)` tuples describing their interactions""" - - sql = f""" - SELECT DISTINCT interaction_pose, feature_id, interaction_type FROM interaction - INNER JOIN feature ON interaction_feature = feature_id - WHERE interaction_pose IN {pset.str_ids} - """ - - records = self.execute(sql).fetchall() - - ISETS = {} - for pose_id, feature_id, interaction_type in records: - values = ISETS.get(pose_id, set()) - values.add((interaction_type, feature_id)) - ISETS[pose_id] = values - - return ISETS - - def get_compound_id_inspiration_ids_dict(self) -> dict[int, set]: - """Get a dictionary mapping :class:`.Compound` ID's to a set of :class:`Pose` ID's for the inspirations for the whole database""" - - sql = """ - SELECT compound_id, pose_id, inspiration_original FROM compound - INNER JOIN pose ON compound_id = pose_compound - INNER JOIN inspiration ON pose_id = inspiration_derivative - """ - - with mrich.spinner("Database.get_pose_id_interaction_ids_dict()"): - records = self.execute(sql).fetchall() - - d = {} - - for compound_id, pose_id, inspiration_id in records: - d[compound_id] = d.get(compound_id, set()) - d[compound_id].add(inspiration_id) - - return d - - def get_pose_id_inspiration_ids_dict( - self, - pset: "PoseSet" = None, - ) -> dict[int, set]: - """Get a dictionary mapping :class:`.Pose` ID's to a set of :class:`Pose` ID's for the inspirations for the whole database""" - - if pset: - sql = f""" - SELECT pose_id, inspiration_original FROM pose - INNER JOIN inspiration ON pose_id = inspiration_derivative - WHERE pose_id IN {pset.str_ids} - """ - - else: - - sql = """ - SELECT pose_id, inspiration_original FROM pose - INNER JOIN inspiration ON pose_id = inspiration_derivative - """ - - with mrich.spinner("Database.get_pose_id_interaction_ids_dict()"): - records = self.execute(sql).fetchall() - - d = {} - - for pose_id, inspiration_id in records: - d[pose_id] = d.get(pose_id, set()) - d[pose_id].add(inspiration_id) - - return d - - def get_inspiration_tuples(self) -> list[int, int]: - """Get a dictionary mapping :class:`.Pose` ID's to a set of :class:`Pose` ID's for the inspirations for the whole database""" - sql = """SELECT inspiration_original, inspiration_derivative FROM inspiration""" - return self.execute(sql).fetchall() - - def get_compound_id_obj_dict(self, cset: "CompoundSet") -> "dict[id, Compound]": - """Get a dictionary mapping :class:`.Compound` ID's to their objects""" - - query = "compound_id, compound_inchikey, compound_alias, compound_smiles" - records = self.select_where( - query=query, - table="compound", - key=f"compound_id IN {cset.str_ids}", - multiple=True, - ) - - d = {} - for entry in records: - compound_id, compound_inchikey, compound_alias, compound_smiles = entry - d[compound_id] = Compound( - self._animal, - self, - compound_id, - compound_inchikey, - compound_alias, - compound_smiles, - metadata=None, - mol=None, - ) - return d - - def get_interaction(self, *, id: int, table: str = "interaction") -> "Interaction": - """Fetch the :class:`.Interaction` object with given ID - - :param id: the ID of the Interaction to retrieve - :returns: :class:`.Interaction` object - - """ - - from .interaction import Interaction - - result = self.select_all_where(table=table, key=f"interaction_id = {id}") - - ( - id, - feature_id, - pose_id, - type, - family, - atom_ids, - prot_coord, - lig_coord, - distance, - angle, - energy, - ) = result - - return Interaction( - db=self, - id=id, - feature_id=feature_id, - pose_id=pose_id, - type=type, - family=family, - atom_ids=atom_ids, - prot_coord=prot_coord, - lig_coord=lig_coord, - distance=distance, - angle=angle, - energy=energy, - table=table, - ) - - def get_reaction_map_from_products( - self, product_ids: list[int] - ) -> dict[tuple[str, int], set[int]]: - """Get a dictionary mapping (reaction_type, product_id) tuples to sets of reactant_ids""" - - str_ids = str(tuple(product_ids)).replace(",)", ")") - - records = self.execute( - f""" - SELECT reaction_type, reaction_product, reaction_id, reactant_compound - FROM reaction INNER JOIN reactant - ON reaction_id = reactant_reaction - WHERE reaction_product IN {str_ids} - """ - ).fetchall() - - mapping = {} - for reaction_type, reaction_product, reaction_id, reactant_compound in records: - - key = (reaction_type, reaction_product) - - if key not in mapping: - mapping[key] = {} - - if reaction_id not in mapping[key]: - mapping[key][reaction_id] = set() - - mapping[key][reaction_id].add(reactant_compound) - - return mapping - - def get_possible_reaction_ids( - self, - *, - compound_ids: list[int], - ) -> list[int]: - """Given a set of reactant :class:`.Compound` ID's, compute which :class:`.Reaction` objects are possible (all reactants present). - - :param compound_ids: the list of reactant :class:`.Compound` IDs - :returns: a list of :class:`.Reaction` ID's that are possible with the given reactants - - """ - - compound_ids_str = str(tuple(compound_ids)).replace(",)", ")") - - result = self.execute( - f""" - WITH possible_reactants AS - ( - SELECT reactant_reaction, CASE WHEN reactant_compound IN {compound_ids_str} THEN reactant_compound END AS [possible_reactant] FROM reactant - ) - - , possible_reactions AS ( - SELECT reactant_reaction, COUNT(CASE WHEN possible_reactant IS NULL THEN 1 END) AS [count_null] FROM possible_reactants - GROUP BY reactant_reaction - ) - - SELECT reactant_reaction FROM possible_reactions - WHERE count_null = 0 - """ - ).fetchall() - - return [q for q, in result] - - def get_unsolved_reaction_tree( - self, - *, - product_ids: list[int], - debug: bool = False, - ) -> "(CompoundSet, ReactionSet)": - """Given a set of product :class:`.Compound` IDs, recursively solve for all the reactants (:class:`.CompoundSet`) and reactions (:class:`.ReactionSet`) that could be involved in their synthesis. N.B. This evaluates all synthesis branches. - - :param product_ids: list of product :class:`.Compound` IDs - :param debug: increase verbosity for debugging, defaults to False - :returns: a tuple of ``(reactants, reactions)`` - - """ - - from .cset import CompoundSet - from .rset import ReactionSet - - all_reactants = set() - all_reactions = set() - - # print(product_ids) - - # for product_id in product_ids: - # all_reactants.add(product_id) - - for i in range(300): - - if debug: - mrich.var("recursive depth", i + 1) - - if debug: - mrich.var("#products", len(product_ids)) - - product_ids_str = str(tuple(product_ids)).replace(",)", ")") - - reaction_ids = self.select_where( - table="reaction", - query="DISTINCT reaction_id", - key=f"reaction_product in {product_ids_str}", - multiple=True, - none="quiet", - ) - - reaction_ids = [q for q, in reaction_ids] - - if not reaction_ids: - break - - for reaction_id in reaction_ids: - all_reactions.add(reaction_id) - - if debug: - mrich.var("#reactions", len(reaction_ids)) - - reaction_ids_str = str(tuple(reaction_ids)).replace(",)", ")") - - reactant_ids = self.select_where( - table="reactant", - query="DISTINCT reactant_compound", - key=f"reactant_reaction in {reaction_ids_str}", - multiple=True, - ) - - if debug: - mrich.var("#reactants", len(reactant_ids)) - - reactant_ids = [q for q, in reactant_ids] - - if not reactant_ids: - break - - for reactant_id in reactant_ids: - all_reactants.add(reactant_id) - - product_ids = reactant_ids - - # all intermediates - ids = self.execute( - "SELECT DISTINCT reaction_product FROM reaction INNER JOIN reactant ON reaction.reaction_product = reactant.reactant_compound" - ).fetchall() - ids = [q for q, in ids] - intermediates = CompoundSet(self, ids) - - # remove intermediates - cset = CompoundSet(self, all_reactants) - all_reactants = cset - intermediates - - # reactions - all_reactions = ReactionSet(self, all_reactions) - - if debug: - mrich.var("#all_reactants", len(all_reactants)) - mrich.var("#all_reactions", len(all_reactions)) - - return all_reactants, all_reactions - - def get_reaction_price_estimate( - self, - *, - reaction: Reaction, - ) -> float: - """Estimate the price of a :class:`.Reaction` - - :param reaction: :class:`.Reaction` object - :returns: price estimate - - """ - - # get reactants for a given reaction - - mrich.warning("Price estimate does not account for branching!") - reactants, _ = self.get_unsolved_reaction_tree( - product_ids=reaction.reactant_ids - ) - - # how to make sure that there are no branching reactions?! - - # sum lowest unit price for each reactant - - (price,) = self.execute( - f""" - WITH unit_prices AS - ( - SELECT quote_compound, MIN(quote_price/quote_amount) AS unit_price FROM quote - WHERE quote_compound IN {reactants.str_ids} - GROUP BY quote_compound - ) - SELECT SUM(unit_price) FROM unit_prices - """ - ).fetchone() - - return price - - def get_possible_reaction_product_ids( - self, - *, - reaction_ids: list[int], - ) -> list[int]: - """Given a set of :class:`.Reaction` IDs return the :class:`.Compound` IDs of their synthesis products - - :param reaction_ids: :class:`.Reaction` IDs - :returns: list of :class:`.Compound` IDs - - """ - reaction_ids_str = str(tuple(reaction_ids)).replace(",)", ")") - return [ - q - for q, in self.select_where( - query="DISTINCT reaction_product", - table="reaction", - key=f"reaction_id IN {reaction_ids_str}", - multiple=True, - ) - ] - - def get_subsite(self, *, id) -> "Subsite": - """Get protein subsite with a given ID - - :param ID: the subsite ID - :returns: :class:`.Subsite` object - - """ - - from .subsite import Subsite - - results = self.select_where( - table="subsite", - key="id", - value=id, - multiple=False, - query="subsite_name, subsite_target", - ) - - if not results: - mrich.error(f"No subsite with {id=}") - return None - - name, target = results - - subsite = Subsite(db=self, id=id, name=name, target_id=target) - - return subsite - - def get_subsite_tag(self, *, id) -> "SubsiteTag": - """Get subsite_tag with a given ID - - :param ID: the subsite_tag ID - :returns: :class:`.SubsiteTag` object - - """ - - from .subsite import SubsiteTag - - subsite_id, pose_id = self.select_where( - table="subsite_tag", - key="id", - value=id, - multiple=False, - query="subsite_tag_ref, subsite_tag_pose", - ) - - subsite_tag = SubsiteTag(db=self, id=id, subsite_id=subsite_id, pose_id=pose_id) - - return subsite_tag - - def get_subsite_id(self, *, name: str, **kwargs) -> int | None: - """Get protein Subsite ID with a given name - - :param name: the protein Subsite name - :returns: the Subsite ID - - """ - - table = "Subsite" - entry = self.select_id_where(table=table, key="name", value=name, **kwargs) - - if entry: - return entry[0] - - return None - - def get_subsite_name(self, *, id: str, **kwargs) -> int | None: - """Get protein :class:`.Subsite` name with a given ID - - :param name: the protein :class:`.Subsite` ID - :returns: the :class:`.Subsite` ID - - """ - - table = "subsite" - entry = self.select_where( - query="subsite_name", table=table, key="id", value=id, **kwargs - ) - - if entry: - return entry[0] - - return None - - def get_scaffold_similarity_dict( - self, scaffolds: "CompoundSet | None" = None - ) -> list[dict]: - """Get a dictionary mapping scaffold :class:`.Compound` IDs to their superstructure's IDs""" - - sql = """ - SELECT scaffold_base as a, scaffold_superstructure as b, bfp_tanimoto(c.fp, d.fp) AS t - FROM scaffold - INNER JOIN compound_pattern_bfp AS c ON a = c.compound_id - INNER JOIN compound_pattern_bfp AS d ON b = d.compound_id - """ - - if scaffolds: - - sql += f" WHERE a IN {scaffolds.str_ids}" - - records = self.execute(sql).fetchall() - - data = [] - for a, b, s in mrich.track(records): - data.append(dict(scaffold_id=a, superstructure_id=b, similarity=s)) - - return data - - def get_reactant_product_tuples( - self, compound_ids: list | None = None, deduplicated: bool = True - ) -> set[tuple[int, int]]: - """Get tuples of (reactant, product) :class:`.Compound` IDs""" - - sql = """ - SELECT reactant_compound, reaction_product FROM reactant - INNER JOIN reaction ON reactant_reaction = reaction_id - """ - - if compound_ids: - str_ids = str(tuple(compound_ids)).replace(",)", ")") - sql += ( - f"WHERE reactant_compound IN {str_ids} OR reaction_product IN {str_ids}" - ) - - records = self.execute(sql) - if deduplicated: - return set((a, b) for a, b in records) - else: - return [(a, b) for a, b in records] - - def get_scaffold_tuples( - self, compound_ids: list | None = None - ) -> set[tuple[int, int]]: - """Get tuples of (reactant, product) :class:`.Compound` IDs""" - - sql = """ - SELECT scaffold_base, scaffold_superstructure FROM scaffold - """ - - if compound_ids: - str_ids = str(tuple(compound_ids)).replace(",)", ")") - sql += f"WHERE scaffold_base IN {str_ids} OR scaffold_superstructure IN {str_ids}" - - records = self.execute(sql) - return set((a, b) for a, b in records) - - ### COMPOUND QUERY - - def query_substructure( - self, - query: str, - *, - fast: bool = True, - none: str = "error", - smarts: bool = False, - ) -> "CompoundSet": - """Search for compounds by substructure - - :param query: SMILES string of the substructure - :param fast: Use pattern binary fingerprint table to improve performance (Default value = True) - :param none: define the behaviour for no matches, any value other than ``'error'`` will silently return empty data (Default value = 'error') - :returns: :class:`.CompoundSet` object - - """ - - if smarts: - func = "mol_from_smarts" - else: - func = "mol_from_smiles" - - # smiles - if isinstance(query, str): - - if fast: - sql = f""" - SELECT compound.compound_id, compound.compound_inchikey - FROM compound, compound_pattern_bfp AS bfp - WHERE compound.compound_id = bfp.compound_id - AND mol_is_substruct(compound.compound_mol, {func}(?)) - """ - - else: - sql = f""" - SELECT compound_id, compound_inchikey FROM compound - WHERE mol_is_substruct(compound_mol, {func}(?)) - """ - - else: - - raise NotImplementedError - - try: - self.execute(sql, (query,)) - except sqlite3.OperationalError as e: - mrich.var("sql", sql) - raise - - result = self.cursor.fetchall() - - if not result and none == "error": - mrich.error(f"No compounds with substructure {query}") - return None - elif not result: - return None - - from .cset import CompoundSet - - if not smarts: - smiles = query - try: - smiles = sanitise_smiles(smiles, sanitisation_failed="error") - except SanitisationError as e: - mrich.error(f"Could not sanitise {smiles=}") - mrich.error(str(e)) - return None - except AssertionError: - mrich.error(f"Could not sanitise {smiles=}") - return None - return c - inchikey = inchikey_from_smiles(smiles) - - ids = [i for i, key in result if key != inchikey] - return CompoundSet(self, ids) - - else: - return CompoundSet(self, [i for i, _ in result]) - - def query_most_similar( - self, - query: str, - subset: "CompoundSet", - return_similarity: bool = False, - none="error", - ) -> "Compound | (Compound, float)": - """Search for the most similar compound by tanimoto similarity of binary pattern fingerprints using the chemicalite function `mol_pattern_bfp` - - :param query: SMILES string - :param return_similarity: return a list of similarity values together with the :class:`.CompoundSet` (Default value = False) - :param none: define the behaviour for no matches, any value other than ``'error'`` will silently return empty data (Default value = 'error') - :param subset: optional subset of compounds to search - :returns: :class:`.Compound` and optionally a similarity values - """ - - from .compound import Compound - - sql = f""" - WITH subset AS ( - SELECT compound_id, fp - FROM compound - JOIN compound_pattern_bfp USING (compound_id) - WHERE compound_id IN {subset.str_ids} - ) - - SELECT compound_id, bfp_tanimoto(mol_pattern_bfp(mol_from_smiles(?1), 2048), fp) AS similarity - FROM subset - ORDER BY similarity DESC - LIMIT 1 - """ - - try: - self.execute(sql, (query,)) - except sqlite3.OperationalError as e: - mrich.var("sql", sql) - raise - - compound_id, similarity = self.cursor.fetchone() - - if return_similarity: - return self.get_compound(id=compound_id), similarity - - return self.get_compound(id=compound_id) - - def query_similarity( - self, - query: str, - threshold: float, - return_similarity: bool = False, - subset: "CompoundSet" = None, - none="error", - ) -> "CompoundSet | (CompoundSet, list[float])": - """Search compounds by tanimoto similarity of binary pattern fingerprints using the chemicalite function `mol_pattern_bfp` - - :param query: SMILES string - :param threshold: similarity threshold to exceed - :param return_similarity: return a list of similarity values together with the :class:`.CompoundSet` (Default value = False) - :param none: define the behaviour for no matches, any value other than ``'error'`` will silently return empty data (Default value = 'error') - :param subset: optional subset of compounds to search - :returns: :class:`.CompoundSet` and optionally a list of similarity values - - """ - - from .cset import CompoundSet - - # smiles - if subset: - - if return_similarity: - sql = f""" - SELECT compound_id, - bfp_tanimoto(mol_pattern_bfp(mol_from_smiles(?1), 2048), - mol_pattern_bfp(compound.compound_mol, 2048)) as t - FROM compound - JOIN compound_pattern_bfp AS mfp - USING(compound_id) - WHERE mfp.compound_id match rdtree_tanimoto(mol_pattern_bfp(mol_from_smiles(?1), 2048), ?2) - AND compound_id IN {subset.str_ids} - ORDER BY t DESC - """ - else: - sql = f""" - SELECT compound_id - FROM compound_pattern_bfp AS bfp - WHERE bfp.compound_id match rdtree_tanimoto(mol_pattern_bfp(mol_from_smiles(?1), 2048), ?2) - AND compound_id IN {subset.str_ids} - """ - - elif isinstance(query, str): - - if return_similarity: - sql = f""" - SELECT compound_id, - bfp_tanimoto(mol_pattern_bfp(mol_from_smiles(?1), 2048), - mol_pattern_bfp(compound.compound_mol, 2048)) as t - FROM compound - JOIN compound_pattern_bfp AS mfp - USING(compound_id) - WHERE mfp.compound_id match rdtree_tanimoto(mol_pattern_bfp(mol_from_smiles(?1), 2048), ?2) - ORDER BY t DESC - """ - else: - sql = f""" - SELECT compound_id - FROM compound_pattern_bfp AS bfp - WHERE bfp.compound_id match rdtree_tanimoto(mol_pattern_bfp(mol_from_smiles(?1), 2048), ?2) - """ - else: - raise NotImplementedError - - try: - self.execute(sql, (query, threshold)) - except sqlite3.OperationalError as e: - mrich.var("sql", sql) - raise - - result = self.cursor.fetchall() - - if not result and none == "error": - mrich.error(f"No compounds with similarity >= {threshold} to {query}") - return None - - if return_similarity: - ids, similarities = zip(*result) - cset = CompoundSet(self, ids) - return cset, similarities - - ids = [r for r, in result] - cset = CompoundSet(self, ids) - - return cset - - def query_exact( - self, - query: str, - threshold: float = 0.989, - ) -> "CompoundSet": - """Search for exact match compounds (default similarity > 0.989) - - :param query: SMILES string - :param threshold: similarity threshold to exceed - - """ - - return self.query_similarity(query, 0.989, return_similarity=False) - - ### LOOKUPS / MAPPING - - def create_metadata_id_map(self, *, table: str, key: str) -> dict[str, int]: - """Create a mapping between metadata[key] values to their respective parent record ID's - - :returns: dictionary mapping metadata[key] values to integer ID's - - """ - - pairs = self.execute( - f"""SELECT {table}_id, {table}_metadata FROM {table} WHERE {table}_metadata LIKE '%"{key}": "%'""" - ).fetchall() - from json import loads - - return dict( - sorted( - {loads(metadata)[key]: pose_id for pose_id, metadata in pairs}.items() - ) - ) - - ### COUNTING - - def count( - self, - table: str, - ) -> int: - """Count all entries in a table - - :param table: table to count entries from - - """ - - sql = f"""SELECT COUNT(1) FROM {table}; """ - self.execute(sql) - return self.cursor.fetchone()[0] - - def count_where( - self, - table: str, - key: str, - value=None, - ): - """Count all entries in a table where ``key==value`` - - :param table: table to count entries from - :param key: the key to match as ``{table}_{key} = {value}`` or the SQL string if ``value == None`` - :param value: the value to match (Default value = None) - - """ - if value is not None: - where_str = f"{table}_{key} is {value}" - else: - where_str = key - - sql = f"""SELECT COUNT(1) FROM {table} WHERE {where_str};""" - self.execute(sql) - return self.cursor.fetchone()[0] - - ### ID SELECTION - - def min_id( - self, - table: str, - ) -> int: - """Get the minimal entry ID from a given table - - :param table: the database table to query - :returns: the smallest entry ID - - """ - - (id,) = self.select(table=table, query=f"MIN({table}_id)") - return id - - def max_id( - self, - table: str, - ) -> int: - """Get the maximal entry ID from a given table - - :param table: the database table to query - :returns: the largest entry ID - - """ - (id,) = self.select(table=table, query=f"MAX({table}_id)") - return id - - def slice_ids( - self, - *, - table: str, - start: int | None, - stop: int | None, - step: int = 1, - name: bool = False, - ) -> list[int]: - """Retrieve ID's matching a slice - - :param table: the database table to query - :param start: return IDs equal to or larger than this value - :param stop: return IDs smaller than this value - :param step: return IDs in increments of this value (Default value = 1) - :returns: matching IDs - - """ - - min_id = self.min_id(table) - max_id = self.max_id(table) - - start = start or min_id - stop = stop or max_id + 1 - step = step or 1 - - if not (start >= 0 and start <= max_id): - raise IndexError( - f"Slice {start=} outside of DB {table}_id range ({min_id}, {max_id})" - ) - - if not (stop >= 0 and stop <= max_id + 1): - raise IndexError( - f"Slice {stop=} outside of DB {table}_id range ({min_id}, {max_id})" - ) - - if step != 1: - raise NotImplementedError(f"Slice {step=} not supported") - - ids = self.select_where( - table=table, - query=f"{table}_id", - key=f"{table}_id >= {start} AND {table}_id < {stop}", - multiple=True, - ) - ids = [q for q, in ids] - - if name: - return ids, f"{table}s[{start}:{stop}]" - else: - return ids - - ### PRUNING - - def prune_reactions( - self, - reactions: "ReactionSet", - ) -> list[Reaction]: - """Remove duplicate reactions - - :param reactions: :class:`.ReactionSet` - :returns: list of pruned :class:`.Reaction` objects - - """ - - pruned = [] - del_list = [] - - for i, reaction in enumerate(reactions): - - matches = [r for r in pruned if r == reaction] - - if not matches: - pruned.append(reaction) - - else: - del_list.append(reaction) - - for reaction in del_list: - mrich.warning(f"Deleted duplicate {reaction=}") - self.delete_where("reaction", "id", reaction.id) - - return pruned - - def remove_metadata_list_item( - self, - *, - table: str, - key: str, - value, - remove_empty: bool = True, - ) -> None: - """Remove a specific item from list-like values associated with a given key from all metadata entries in a given table - - :param table: the database table to query - :param key: the :class:`.Metadata` key to match - :param value: the value to remove from the list - :param remove_empty: remove the key from the metadata if the list is empty (Default value = True) - - """ - - # get id's with specific metadata key and value - value_str = json.dumps(value) - result = self.select_where( - query=f"{table}_id, {table}_metadata", - table=table, - key=f"{table}_metadata LIKE '%\"export\": [%{value_str}%]%'", - multiple=True, - none="quiet", - ) - - # loop over all matches - for id, metadata_str in result: - - # read the metadata - metadata = json.loads(metadata_str) - - # modify the metadata - index = metadata[key].index(value) - metadata[key].pop(index) - - if remove_empty and not metadata[key]: - del metadata[key] - - # update the database - metadata_str = json.dumps(metadata) - self.update( - table=table, - id=id, - key=f"{table}_metadata", - value=metadata_str, - commit=False, - ) - - # only runs if non-zero matches - else: - # commit the changes - self.commit() - - ### TABLE INFO - - def print_table( - self, - table: str, - ) -> None: - """Print a table's entries - - :param table: the table to print - - """ - - # mrich.print(self.cursor.fetchall()) - - from rich.table import Table - - tab = Table() - - for col in self.column_names(table): - tab.add_column(col.removeprefix(table).removeprefix("_")) - - self.execute(f"SELECT * FROM {table}") - for record in self.cursor.fetchall(): - record = [str(v) for v in record] - tab.add_row(*record) - - mrich.print(tab) - - def table_info( - self, - table: str, - ) -> list[tuple]: - """Print a table's schema - - :param table: the table to print - - """ - - self.execute(f"PRAGMA table_info({table})") - return self.cursor.fetchall() - - def column_names(self, table: str) -> list[str]: - """Get the column names of the given table""" - table_info = self.table_info(table) - return [i[1] for i in table_info] - - ### DUNDERS - - def __str__(self): - """Unformatted string representation""" - return f"Database @ {self.path.resolve()}" - - def __repr__(self): - """ANSI Formatted string representation""" - return f"{mcol.bold}{mcol.underline}{self}{mcol.clear}" - - def __rich__(self) -> str: - """Representation for mrich""" - return f"[bold underline]{self}" - - -class LegacyDatabaseError(Exception): - """This database is in a legacy format""" - - ... - - -def backup( - source: Path | str, - destination: Path | str | None = None, - pages: int = 10_000, -) -> None: - """Create a backup of the database""" - - from .tools import dt_hash - - source = Path(source) - - if not destination: - destination = str(source.resolve()).replace(".sqlite", f"_{dt_hash()}.sqlite") - - destination = Path(destination) - - with mrich.spinner(f"Backing up {source}..."): - - mrich.writing(destination) - - def progress(status, remaining, total): - """print progress""" - mrich.debug(f"Copied {total-remaining} of {total} pages...") - - src = sqlite3.connect(source) - dst = sqlite3.connect(destination) - with dst: - src.backup(dst, pages=pages, progress=progress) - - dst.close() diff --git a/hippo/designdb/__init__.py b/hippo/designdb/__init__.py new file mode 100644 index 00000000..967b94b7 --- /dev/null +++ b/hippo/designdb/__init__.py @@ -0,0 +1,3 @@ +import logging + +logging.getLogger(__name__).addHandler(logging.NullHandler()) diff --git a/hippo/designdb/admin.py b/hippo/designdb/admin.py new file mode 100644 index 00000000..846f6b40 --- /dev/null +++ b/hippo/designdb/admin.py @@ -0,0 +1 @@ +# Register your models here. diff --git a/hippo/designdb/animal.py b/hippo/designdb/animal.py new file mode 100644 index 00000000..7f2ad87e --- /dev/null +++ b/hippo/designdb/animal.py @@ -0,0 +1,889 @@ +"""Main animal class for HIPPO""" + +import logging +import re +from datetime import datetime +from enum import Enum +from pathlib import Path + +import mrich +import pandas as pd +from django.db import transaction + +from .client import ( + GeneratorManager, + IngredientManager, + RecipeManager, + RouteManager, + ScorerManager, +) +from .models import ( + CompoundModel, + EnumerationMethodModel, + PoseMethodModel, + PoseModel, + Project, + RouteModel, + ScoringMethodModel, + TargetModel, +) +from .services.download import DownloadService +from .services.ingestion import IngestionBatchResult, IngestionService +from .services.method import MethodService +from .services.quote import QuoteService +from .services.reaction import ReactionService +from .services.route import RouteService +from .services.subsite import SubsiteService +from .sets.compound import CompoundSet +from .sets.pose import PoseSet +from .settings import DEFAULT_POSE_METHODS +from .utils import make_warn_once_per_key + +logger = logging.getLogger(__name__) + +# Root directory under which Fragalysis downloads are extracted, laid out as +# data/downloads//. +DOWNLOADS_DIR = Path('data') / 'downloads' + +# Fragalysis download flags requested when fetching the full hit data for a +# target (see HIPPO._ensure_hit_data). Everything else stays False. +HIT_DATA_FLAGS = ( + 'apo_file', + 'bound_file', + 'apo_solv_file', + 'apo_desolv_file', + 'ligand_pdb', + 'ligand_sdf', + 'ligand_smiles', + 'sdf_info', + 'smiles_info', + 'metadata_info', +) + + +class HIPPO: + """Entry-point class of the xchem-hippo package. + + Update: this is atm not being called directly by the user. + """ + + def __init__( + self, + target_name: str, + target_access_string: str, + *, + stack: str = 'production', + auth_token: str | None = None, + ) -> None: + + # TODO: with working db, hippo shouldn't be creating projects + project, _ = Project.objects.get_or_create( + project_name=target_access_string, + ) + + # TODO: user- or project based targets + self._target, _ = TargetModel.objects.get_or_create( + target_name=target_name, + project=project, + ) + + # Fragalysis stack / auth used for any downloads triggered by this + # instance (see _ensure_hit_data / _ensure_apo_desolv_files). + self._stack = stack + self._auth_token = auth_token + + # Download state (see _ensure_hit_data / _ensure_apo_desolv_files). The + # full hit data persists on disk and is reused across sessions; the + # apo_desolv subset is re-downloaded once per instance to stay fresh. + self._hit_data_path: Path | None = None + self._apo_desolv_path: Path | None = None + self._apo_desolv_downloaded_at: datetime | None = None + + # Download policy: a first run downloads nothing here (the first add_hits + # fetches the full data). On re-instantiation (a persisted download already + # on disk) only the apo_desolv proteins are refreshed, so a new session has + # current PDBs. + target_dir = DOWNLOADS_DIR / project.project_name / target_name + if (target_dir / 'metadata.csv').is_file() and ( + target_dir / 'aligned_files' + ).is_dir(): + try: + self._ensure_apo_desolv_files( + auth_token=self._auth_token, stack=self._stack + ) + except ValueError as e: + # no poses in the DB yet -> can't tell which structures to fetch + mrich.warning(f'Skipping apo_desolv refresh on init: {e}') + + # TODO: the way this worked previously was it gave the HIPPO + # instance full access to the pose table. When working with + # multi-project central postgres db, this is almost certainly + # not what I want. How is it that I'm going to keep this + # updated? What does it mean upadte? Access to all objects + # along this target? + + # self._compounds = CompoundTable(self.db) + # self._poses = PoseSet(PoseModel.objects.all()) # <- NB! for testing + # self._tags = TagTable(self.db) + # self._reactions = ReactionTable(self.db) + + # ### in memory subsets + # self._reactants = None + # self._products = None + # self._intermediates = None + # self._scaffolds = None + # self._elabs = None + + # @property + # def name(self) -> str: + # """Returns the project name + + # :returns: project name + # """ + # return self._name + + @property + def target(self) -> TargetModel: + """Returns the target instance""" + return self._target + + # actually expected to return all poses. filtering in PoseTable + # class i.e. get_by_target. + + # Looks like I need to implement this. PoseService with some + # manager- and instance mthods as helpers? + + # Actually it's more compplex than this: in the original code + # there's PoseTable, and then there's PoseSet for a selection + @property + def poses(self): + """Return pose instances for this target""" + # return PoseModel.objects.filter(target=self._target) + return PoseSet(PoseModel.objects.filter(target=self._target)) + + @property + def compounds(self) -> CompoundSet: + """Return all compounds in the database""" + return CompoundSet(CompoundModel.compound_filter.all()) + + @property + def reactants(self) -> CompoundSet: + """Compounds that are reactants of a reaction and not a product of any + (leaf reactants / purchasable building blocks).""" + return CompoundSet(list(ReactionService.reactant_compound_ids())) + + @property + def recipes(self) -> RecipeManager: + """Client-side accessor for building recipes (see :class:`.RecipeManager`).""" + return RecipeManager(self) + + @property + def ingredients(self) -> IngredientManager: + """Client-side accessor for building IngredientSets (see + :class:`.IngredientManager`).""" + return IngredientManager(self) + + @property + def routes(self) -> RouteManager: + """Client-side accessor for building RouteSets (see + :class:`.RouteManager`).""" + return RouteManager(self) + + @property + def generators(self) -> GeneratorManager: + """Client-side accessor for the random recipe/selection generators (see + :class:`.GeneratorManager`).""" + return GeneratorManager(self) + + @property + def scorers(self) -> ScorerManager: + """Client-side accessor for recipe scoring (see :class:`.ScorerManager`).""" + return ScorerManager(self) + + @property + def num_poses(self) -> int: + """Total number of Poses in the Database""" + return self.poses.count() + + def quote_compounds( + self, compounds: 'CompoundSet | None' = None + ) -> tuple[CompoundSet, CompoundSet]: + """Report which compounds have catalogue quotes. + + :param compounds: optional :class:`.CompoundSet` to restrict to; defaults + to all compounds in the database + :returns: ``(quoted, unquoted)`` :class:`.CompoundSet` objects + """ + if compounds is None: + compounds = self.compounds + elif not isinstance(compounds, CompoundSet): + raise TypeError( + f'compounds must be a CompoundSet or None, got {type(compounds)}' + ) + + quoted_ids, unquoted_ids = QuoteService.partition_quoted(compounds.ids) + + mrich.var('#quoted compounds', len(quoted_ids)) + mrich.var('#unquoted compounds', len(unquoted_ids)) + + return CompoundSet(list(quoted_ids)), CompoundSet(list(unquoted_ids)) + + def quote_reactants(self) -> tuple[CompoundSet, CompoundSet]: + """Report which reactant compounds have catalogue quotes. + + Convenience wrapper around :meth:`.quote_compounds` restricted to the + animal's reactants (see :attr:`.reactants`). + + :returns: ``(quoted, unquoted)`` :class:`.CompoundSet` objects + """ + return self.quote_compounds(self.reactants) + + def plot_interaction_punchcard( + self, poses: 'PoseSet | None' = None, *, subtitle=None, opacity=1.0, **kwargs + ): + """Plot an interaction punch-card for a :class:`.PoseSet` (default: all of + this target's poses). See :func:`.plotting.plot_interaction_punchcard`.""" + from .plotting import plot_interaction_punchcard + + return plot_interaction_punchcard( + self.poses if poses is None else poses, + title_prefix=self._target.target_name, + subtitle=subtitle, + opacity=opacity, + **kwargs, + ) + + def _ensure_hit_data( + self, auth_token: str | None = None, stack: str = 'production' + ) -> Path: + """Ensure the target's full Fragalysis hit data is downloaded locally. + + Downloads all observations via :class:`.DownloadService` to + ``data/downloads//`` and returns that path. The data + persists: an existing download (detected by ``metadata.csv``) is reused. + + :param auth_token: Fragalysis ``sessionid`` (else ``FRAGALYSIS_AUTH_TOKEN``) + :param stack: Fragalysis stack to download from (default ``'production'``) + :returns: path to the extracted download directory + """ + + target_name = self._target.target_name + project_name = self._target.project.project_name + + destination = DOWNLOADS_DIR / project_name + target_dir = destination / target_name + + # reuse an existing full download (metadata.csv distinguishes it from an + # apo_desolv-only download, which has no metadata.csv) + if (target_dir / 'metadata.csv').is_file() and ( + target_dir / 'aligned_files' + ).is_dir(): + mrich.print('Using existing hit data download', target_dir) + self._hit_data_path = target_dir + return target_dir + + path = DownloadService.download_target( + target_name=target_name, + target_access_string=project_name, + proteins='', # all observations (no poses exist yet to filter by) + stack=stack, + auth_token=auth_token, + destination=destination, + **{flag: True for flag in HIT_DATA_FLAGS}, + ) + + self._hit_data_path = path + return path + + def _ensure_apo_desolv_files( + self, auth_token: str | None = None, stack: str = 'production' + ) -> Path: + """Ensure the target's apo-desolvated protein PDBs are downloaded locally. + + Downloads the ``apo_desolv`` structures for this target's poses (by + ``pose_alias``) via :class:`.DownloadService`, once per instance. Reuses the + full hit data if it was already downloaded this instance. + + :param auth_token: Fragalysis ``sessionid`` (else ``FRAGALYSIS_AUTH_TOKEN``) + :param stack: Fragalysis stack to download from (default ``'production'``) + :returns: path to the extracted download directory + """ + + # already resolved during this session? + if self._apo_desolv_path is not None and self._apo_desolv_path.exists(): + return self._apo_desolv_path + + # the full hit data downloaded this instance already includes fresh + # apo_desolv files, so reuse it instead of re-downloading the subset + if self._hit_data_path is not None and self._hit_data_path.exists(): + self._apo_desolv_path = self._hit_data_path + return self._apo_desolv_path + + target_name = self._target.target_name + project_name = self._target.project.project_name + + # downloads are laid out as data/downloads//; + # DownloadService extracts into destination/, so we pass + # data/downloads/ as the destination + destination = DOWNLOADS_DIR / project_name + + # observation shortcodes to request, from the database + proteins = list( + PoseModel.objects.filter(target=self._target) + .exclude(pose_alias__isnull=True) + .exclude(pose_alias='') + .values_list('pose_alias', flat=True) + .distinct() + ) + if not proteins: + raise ValueError( + f'No pose aliases found for target {target_name!r}; ' + 'cannot determine which structures to download' + ) + + path = DownloadService.download_target( + target_name=target_name, + target_access_string=project_name, + proteins=','.join(proteins), + stack=stack, + auth_token=auth_token, + destination=destination, + ) + + self._apo_desolv_path = path + self._apo_desolv_downloaded_at = datetime.now() + return path + + def add_hits( + self, + *, + metadata_csv: str | Path | None = None, + aligned_directory: str | Path | None = None, + auth_token: str | None = None, + stack: str | None = None, + tags: list | None = None, + pose_methods: list[str] | None = None, + skip: list | None = None, + check_rmsd: bool = False, + rmsd_threshold: float = 1.0, + # debug: bool = False, + # load_pose_mols: bool = False, + ) -> pd.DataFrame: + """Crystallographic hits from a Fragalysis download or XChemAlign alignment. + + Provide both `metadata_csv` and `aligned_directory` to load existing + local data (for a Fragalysis download these point to the `metadata.csv` + and `aligned_files` at the root of the extracted download; for an + XChemAlign dataset `aligned_directory` points to the `aligned_files`). + Omit both to download this target's data from the Fragalysis stack first + (see :meth:`._ensure_hit_data`). + + :param metadata_csv: Path to the metadata.csv (omit to download) + :param aligned_directory: Path to the aligned_files directory + (omit to download) + :param auth_token: optional Fragalysis ``sessionid`` for the download + (otherwise ``FRAGALYSIS_AUTH_TOKEN`` is used) + :param stack: Fragalysis stack to download from (default ``'production'``) + :param skip: optional list of observation names to skip + :returns: a DataFrame of metadata + + """ + + # fall back to the stack/auth configured at instantiation + if stack is None: + stack = self._stack + if auth_token is None: + auth_token = self._auth_token + + if metadata_csv is None and aligned_directory is None: + hit_dir = self._ensure_hit_data(auth_token=auth_token, stack=stack) + aligned_directory = hit_dir / 'aligned_files' + metadata_csv = hit_dir / 'metadata.csv' + elif metadata_csv is None or aligned_directory is None: + raise ValueError( + 'Provide both metadata_csv and aligned_directory to use existing ' + 'data, or neither to download from the stack.' + ) + + skip = skip or [] + tags = tags or [] + pose_methods = pose_methods or DEFAULT_POSE_METHODS + + if not isinstance(aligned_directory, Path): + aligned_directory = Path(aligned_directory) + + mrich.var('aligned_directory', aligned_directory) + + ### Validate inputs early with clear messages. A wrong/mismatched target + # name usually yields an aligned_directory (often derived from the target + # name) that doesn't exist or has no recognizable observation + # subdirectories; without these checks that surfaces later as a confusing + # "Unexpected mixed data format" assertion. We rely only on the aligned + # data structure here -- not on the directory name, and not on the + # presence of metadata (which is optional, e.g. for XChemAlign data). + target_name = self.target.target_name + if not aligned_directory.is_dir(): + raise NotADirectoryError( + f'aligned_directory not found: {aligned_directory}. Check the path ' + f'matches the data for target {target_name!r}.' + ) + + ### Determine data format + + # TODO: as it appears that users are currently only loading + # fragalysis data, XCA format is not supported. Leaving the + # format checks here to print a message for user + + class DataFormat(Enum): + """DataFormat enum""" + + Fragalysis_v2 = 1 + XChemAlign_v2 = 2 + XChemAlign_v3 = 3 + + def __str__(self) -> str: + """name""" + return self.name + + subdirs = [p for p in aligned_directory.glob('*') if p.is_dir()] + if not subdirs: + raise ValueError( + f'No observation subdirectories found in {aligned_directory}. Is the ' + 'path correct and the download extracted? A wrong target name (here ' + f'{target_name!r}) often points add_hits at an empty/missing directory.' + ) + + SUBDIR_PATTERN_FRAGALYSIS = re.compile(r'^.*\d{4}[a-z]$') + SUBDIR_PATTERN_XCA = re.compile(r'^.*-.\d{4}$') + + fragalysis_subdirs_present = any( + SUBDIR_PATTERN_FRAGALYSIS.match(subdir.name) for subdir in subdirs + ) + xca_subdirs_present = any( + SUBDIR_PATTERN_XCA.match(subdir.name) for subdir in subdirs + ) + + # distinguish the two failure modes the old XOR assertion conflated + if fragalysis_subdirs_present and xca_subdirs_present: + raise ValueError( + 'Mixed Fragalysis and XChemAlign observation directories in ' + f'{aligned_directory}; expected a single consistent format.' + ) + if not (fragalysis_subdirs_present or xca_subdirs_present): + examples = ', '.join(p.name for p in subdirs[:3]) + raise ValueError( + 'Could not recognise any Fragalysis or XChemAlign observation ' + f'directories in {aligned_directory} (e.g. {examples}). Check that ' + f'the data matches target {target_name!r}.' + ) + + if fragalysis_subdirs_present: + data_format = DataFormat.Fragalysis_v2 + else: + if any(list(subdir.glob('*_artefacts.pdb')) for subdir in subdirs): + data_format = DataFormat.XChemAlign_v3 + else: + data_format = DataFormat.XChemAlign_v2 + + mrich.error( + 'Loading XChemAlign data currently not supported.' + + ' Contact developers to enable this feature' + ) + + mrich.var('data_format', data_format) + + pose_method_objs = [] + for name in pose_methods: + obj = PoseMethodModel.objects.filter(pose_method_name=name).first() + if obj is None: + raise ValueError( + f"Pose method '{name}' not found. " + 'Call register_pose_method() first.' + ) + pose_method_objs.append(obj) + + try: + with transaction.atomic(): + result: IngestionBatchResult = IngestionService.ingest_filesystem( + root_path=aligned_directory, + target=self.target, + skip_records=skip, + compound_tag_list=tags, + metadata_file=metadata_csv, + pose_methods=pose_method_objs, + check_rmsd=check_rmsd, + rmsd_threshold=rmsd_threshold, + ) + except Exception as exc: + logger.error(exc, exc_info=True) + # TODO: handle gracefully + raise Exception from exc + + # looking at the code, it seems to be the same, there are no + # skips between observations and dirs_parsed declaratiosn + mrich.var('#valid observations', result.attempts) + + # n_poses = self.num_poses + # n_poses = PoseModel.objects.count() + + mrich.var('#directories parsed', result.attempts) + mrich.var('#compounds registered', result.compounds_created) + mrich.var('#poses registered', result.poses_created) + + def load_sdf( + self, + *, + path: str | Path, + reference: int | PoseModel | None = None, + inspirations: list[int] | PoseSet | None = None, + compound_tags: None | list[str] = None, + pose_tags: None | list[str] = None, + enumeration_method: tuple[str, str] | None = None, + pose_method: tuple[str, str] | None = None, + score_cols: list[str] | None = None, + scoring_methods: list[tuple[str, str]] | None = None, + mol_col: str = 'ROMol', + name_col: str = 'ID', + inspiration_col: str = 'ref_mols', + reference_col: str = 'ref_pdb', + inspiration_map: None | dict = None, + convert_floats: bool = True, + skip_equal_dict: dict | None = None, + skip_not_equal_dict: dict | None = None, + check_rmsd: bool = False, + rmsd_threshold: float = 1.0, + ) -> None: + """Add posed virtual hits from an SDF into the database. + + :param target: Name of the protein :class:`.TargetModel` + :param path: Path to the SDF + :param reference: Optional single reference :class:`.PoseModel` to use as + the protein conformation for all poses, defaults to ``None`` + :param reference_col: Column that contains reference :class:`.PoseModel` aliases + or ID's + :param compound_tags: List of string Tags to assign to all created compounds, + defaults to ``None`` + :param pose_tags: List of string Tags to assign to all created poses, + defaults to ``None`` + :param mol_col: Name of the column containing the ``rdkit.ROMol`` ligands, + defaults to ``"ROMol"`` + :param name_col: Name of the column containing the ligand name/alias, + defaults to ``"ID"`` + :param inspirations: Optional single set of inspirations :class:`.PoseSet` + object or list of IDs to assign as inspirations to all inserted poses, + defaults to ``None`` + :param inspiration_col: Name of the column containing the list of inspiration + :class:`.PoseModel` names or ID's, defaults to ``"ref_mols"`` + :param inspiration_map: Optional dictionary or callable mapping between + inspiration strings found in ``inspiration_col`` and :class:`.PoseModel` ids + :param energy_score_col: Name of the column containing the list of energy + scores ``"energy_score"`` + :param distance_score_col: Name of the column containing the list of distance + scores, defaults to ``"distance_score"`` + :param convert_floats: Try to convert all values to ``float``, + defaults to ``True`` + :param skip_equal_dict: Skip rows where + ``any(row[key] == value for key, value in skip_equal_dict.items())``, + defaults to ``None`` + :param skip_not_equal_dict: Skip rows where + ``any(row[key] != value for key, value in skip_not_equal_dict.items())``, + defaults to ``None`` + + All non-name columns are added to the PoseModel metadata. + N.B. separate .mol files are not created. The molecule binary will only be + stored in the .sqlite file and fake paths are added to the database. + """ + # TODO: original code reads sdf into data frame. I don't see + # much point for this in this function. get rid of it at some + # point + + if not isinstance(path, Path): + path = Path(path) + + if name_col is None: + raise ValueError( + 'name_col cannot be None. Provide the SDF column name that ' + 'contains pose identifiers.' + ) + + skip_equal_dict = skip_equal_dict or {} + skip_not_equal_dict = skip_not_equal_dict or {} + + mrich.debug(f'{path=}') + + compound_tags = compound_tags or [] + pose_tags = pose_tags or [] + + if isinstance(inspirations, PoseSet): + inspiration_list = list(inspirations.ids) + elif isinstance(inspirations, list): + # TODO: potentially check types + inspiration_list = inspirations + else: + inspiration_list = [] + + if reference and isinstance(reference, PoseModel): + reference_id = reference.id + else: + reference_id = None + + if inspiration_map is None: + inspiration_map = {} + + enumeration_method_obj = None + if enumeration_method is not None: + name, version = enumeration_method + try: + enumeration_method_obj = EnumerationMethodModel.objects.get( + enum_name=name, enum_version=version + ) + except EnumerationMethodModel.DoesNotExist: + raise ValueError( + f"Enumeration method '{name}' v{version} not found. " + 'Call register_enumeration_method() first.' + ) from None + + pose_method_obj = None + if pose_method is not None: + name, version = pose_method + try: + pose_method_obj = PoseMethodModel.objects.get( + pose_method_name=name, pose_method_version=version + ) + except PoseMethodModel.DoesNotExist: + raise ValueError( + f"Pose method '{name}' v{version} not found. " + 'Call register_pose_method() first.' + ) from None + + score_method_map = {} + if score_cols and scoring_methods: + if len(score_cols) != len(scoring_methods): + raise ValueError( + 'score_cols and scoring_methods must be the same length' + ) + for col, (method_name, method_version) in zip( + score_cols, scoring_methods, strict=False + ): + try: + obj = ScoringMethodModel.objects.get( + method_name=method_name, method_version=method_version + ) + except ScoringMethodModel.DoesNotExist: + raise ValueError( + f"Scoring method '{method_name}' v{method_version} not found. " + 'Call register_scoring_method() first.' + ) from None + score_method_map[col] = obj + + warn = make_warn_once_per_key() + + try: + with transaction.atomic(): + result: IngestionBatchResult = IngestionService.ingest_sdf( + file_path=path, + target=self.target, + compound_tag_list=compound_tags, + pose_tag_list=pose_tags, + enumeration_method_obj=enumeration_method_obj, + pose_method_obj=pose_method_obj, + score_method_map=score_method_map, + mol_col=mol_col, + name_col=name_col, + inspiration_col=inspiration_col, + inspirations=inspiration_list, + reference_col=reference_col, + reference=reference_id, + skip_equal=skip_equal_dict, + skip_not_equal=skip_not_equal_dict, + convert_floats=convert_floats, + field_warning=warn, + inspiration_map=inspiration_map, + check_rmsd=check_rmsd, + rmsd_threshold=rmsd_threshold, + ) + except Exception as exc: + logger.error(exc, exc_info=True) + # TODO: handle gracefully + raise Exception from exc + + # It's not clear what the original code was trying to do. I'm + # going to issue warning when number of compounds and poses + # was less than the number of compounds in sdf (not all were + # successfully parsed) but that may not have been the original + # intention + if result.attempts == result.compounds_created: + f = mrich.success + else: + f = mrich.warning + + f(f'{result.compounds_created} new compounds from {path}') + + if result.attempts == result.poses_created: + f = mrich.success + else: + f = mrich.warning + + f(f'{result.poses_created} new poses from {path}') + + def add_syndirella_routes( + self, + pickle_path: str | Path, + CAR_only: bool = True, + pick_first: bool = True, + check_chemistry: bool = True, + register_routes: bool = True, + ) -> pd.DataFrame: + """Add routes found from syndirella --just_retro query""" + + try: + with transaction.atomic(): + result: IngestionBatchResult = ( + IngestionService.ingest_syndirella_routes( + pickle_path=pickle_path, + CAR_only=CAR_only, + pick_first=pick_first, + do_check_chemistry=check_chemistry, + register_routes=register_routes, + ) + ) + except Exception as exc: + logger.error(exc, exc_info=True) + # TODO: handle gracefully + raise Exception from exc + + return result + + def add_enamine_real_routes( + self, + csv_path: str | Path, + check_chemistry: bool = True, + register_routes: bool = True, + ) -> pd.DataFrame: + """Add synthesis routes from an Enamine REAL CSV export""" + + try: + with transaction.atomic(): + result = IngestionService.ingest_enamine_real_routes( + csv_path=csv_path, + do_check_chemistry=check_chemistry, + register_routes=register_routes, + ) + except Exception as exc: + logger.error(exc, exc_info=True) + raise Exception from exc + + return result + + def prune_duplicate_routes(self) -> int: + """Remove duplicate routes from the database""" + return RouteService.prune_duplicate_routes() + + def add_syndirella_elabs( + self, + df_path: str | Path, + max_energy_score: float | None = 0.0, + max_distance_score: float | None = 2.0, + require_intra_geometry_pass: bool = True, + reject_flags: list[str] | None = None, + register_reactions: bool = True, + dry_run: bool = False, + scaffold_route: 'RouteModel | None' = None, + scaffold_compound: 'CompoundModel | None' = None, + pose_tags: list[str] | None = None, + product_tags: list[str] | None = None, + ) -> pd.DataFrame: + """ + Load Syndirella elaboration compounds and poses from a pickled DataFrame + + :param df_path: Path to the pickled DataFrame + :param max_energy_score: Filter out poses with `∆∆G` above this value + :param max_distance_score: Filter out poses with `comRMSD` above this value + :param require_intra_geometry_pass: Filter out poses with falsy + `intra_geometry_pass` values + :param reject_flags: Filter out rows flagged with strings from this list + (default = ["one_of_multiple_products", + "selectivity_issue_contains_reaction_atoms_of_both_reactants"]) + :param scaffold_route: Supply a known single-step route to the scaffold product + to use if scaffold placements are missing + :param scaffold_compound: Supply a :class:`.CompoundModel` for the scaffold + product to use if scaffold placements are missing + :param dry_run: Don't insert new records into the database + (for debugging/testing) + :param pose_tags: Add these tags to all inserted poses, defaults to + ["syndirella_product", "syndirella_placed"] + :param product_tags: Add these tags to all inserted product compounds, + defaults to ["syndirella_product"] + :returns: annotated DataFrame + """ + + reject_flags = reject_flags or [ + 'one_of_multiple_products', + 'selectivity_issue_contains_reaction_atoms_of_both_reactants', + ] + + pose_tags = pose_tags or ['syndirella_product', 'syndirella_placed'] + product_tags = product_tags or ['syndirella_product'] + + df_path = Path(df_path) + mrich.h3(df_path.name) + mrich.reading(df_path) + df = pd.read_pickle(df_path) + + # testing + # df = pd.read_csv(df_path.replace('.pkl.gz', '.csv')) + + try: + with transaction.atomic(): + result: pd.DataFrame = IngestionService.ingest_syndirella_elabs( + df=df, + # TODO: check if target eists + target=self.target, + reject_flags=reject_flags, + pose_tag_list=pose_tags, + product_tag_list=pose_tags, + max_energy_score=max_energy_score, + max_distance_score=max_distance_score, + require_intra_geometry_pass=require_intra_geometry_pass, + register_reactions=register_reactions, + scaffold_route=scaffold_route, + scaffold_compound=scaffold_compound, + ) + return result + except Exception as exc: + logger.error(exc, exc_info=True) + # TODO: handle gracefully + raise Exception from exc + + def set_derivative_subsites(self) -> None: + """Propagate subsite assignments from inspiration poses to their derivatives.""" + SubsiteService.set_derivative_subsites() + + def register_enumeration_method( + self, name: str, version: str, description: str = '' + ): + """Register an enumeration method, or retrieve it if already registered.""" + return MethodService.register_enumeration_method(name, version, description) + + def register_pose_method(self, name: str, version: str, description: str = ''): + """Register a pose method, or retrieve it if already registered.""" + return MethodService.register_pose_method(name, version, description) + + def register_scoring_method(self, name: str, version: str, description: str = ''): + """Register a scoring method, or retrieve it if already registered.""" + return MethodService.register_scoring_method(name, version, description) + + @property + def enumeration_methods(self): + """All registered enumeration methods.""" + return MethodService.get_enumeration_methods() + + @property + def pose_methods(self): + """All registered pose methods.""" + return MethodService.get_pose_methods() + + @property + def scoring_methods(self): + """All registered scoring methods.""" + return MethodService.get_scoring_methods() diff --git a/hippo/designdb/apps.py b/hippo/designdb/apps.py new file mode 100644 index 00000000..f5477dd0 --- /dev/null +++ b/hippo/designdb/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class DesigndbConfig(AppConfig): + name = 'designdb' diff --git a/hippo/designdb/client.py b/hippo/designdb/client.py new file mode 100644 index 00000000..7b2cf739 --- /dev/null +++ b/hippo/designdb/client.py @@ -0,0 +1,214 @@ +"""Client-side accessors (managers) for the HIPPO user-facing API. + +Each manager is bound to a :class:`.HIPPO` instance and groups a family of +construction entry points (recipes, ingredients, routes, scorers, generators), +validating input and delegating to the backend services/sets. Together they form +the client surface of the eventual client/backend split (see CLAUDE.md). + +Access them via the corresponding :class:`.HIPPO` properties: ``animal.recipes``, +``animal.ingredients``, ``animal.routes``, ``animal.scorers``, ``animal.generators``. +""" + +from typing import TYPE_CHECKING + +from .recipe import Recipe +from .services.generation import ( + RandomRecipeGenerator, + RandomRecipeSelectionGenerator, + RandomSelectionGenerator, +) +from .services.recipe import RecipeService +from .services.recipe_score import Scorer +from .sets.compound import CompoundSet +from .sets.ingredient import IngredientSet +from .sets.reaction import ReactionSet +from .sets.route import RouteSet + +if TYPE_CHECKING: + from .animal import HIPPO + + +class RecipeManager: + """Build recipes from compounds/reactions/reactants (via ``animal.recipes``).""" + + def __init__(self, animal: 'HIPPO') -> None: + self._animal = animal + + def from_compounds(self, compounds: CompoundSet, **kwargs): + """Build recipe(s) to synthesise a :class:`.CompoundSet`. + + See :meth:`.RecipeService.from_compounds` for keyword arguments. + """ + if not isinstance(compounds, CompoundSet): + raise TypeError(f'compounds must be a CompoundSet, got {type(compounds)}') + return RecipeService.from_compounds(compounds, **kwargs) + + def from_reactions(self, reactions: ReactionSet, **kwargs): + """Build recipe(s) from a :class:`.ReactionSet`. + + See :meth:`.RecipeService.from_reactions` for keyword arguments. + """ + if not isinstance(reactions, ReactionSet): + raise TypeError(f'reactions must be a ReactionSet, got {type(reactions)}') + return RecipeService.from_reactions(reactions, **kwargs) + + def from_reactants(self, reactants: 'CompoundSet | IngredientSet', **kwargs): + """Build the maximal recipe reachable from a set of reactants. + + See :meth:`.RecipeService.from_reactants` for keyword arguments. + """ + if not isinstance(reactants, (CompoundSet, IngredientSet)): + raise TypeError( + 'reactants must be a CompoundSet or IngredientSet, ' + f'got {type(reactants)}' + ) + return RecipeService.from_reactants(reactants, **kwargs) + + def from_json(self, path, **kwargs) -> 'Recipe': + """Load a serialised :class:`.Recipe` from a JSON file. + + See :meth:`.Recipe.from_json` for keyword arguments (``data``, + ``clear_quotes``, ``debug``). + """ + return Recipe.from_json(path, **kwargs) + + +class IngredientManager: + """Build :class:`.IngredientSet`\\ s (via ``animal.ingredients``).""" + + def __init__(self, animal: 'HIPPO') -> None: + self._animal = animal + + def from_compounds(self, compounds: 'CompoundSet | None' = None, **kwargs): + """Build an :class:`.IngredientSet` from a :class:`.CompoundSet` (or IDs). + + See :meth:`.IngredientSet.from_compounds` for keyword arguments. + """ + if compounds is not None and not isinstance(compounds, CompoundSet): + raise TypeError(f'compounds must be a CompoundSet, got {type(compounds)}') + return IngredientSet.from_compounds(compounds=compounds, **kwargs) + + +class RouteManager: + """Build :class:`.RouteSet`\\ s (via ``animal.routes``).""" + + def __init__(self, animal: 'HIPPO') -> None: + self._animal = animal + + def from_product_ids(self, ids: 'CompoundSet | list[int]', *, progress=True): + """Build a :class:`.RouteSet` of stored routes to the given products. + + :param ids: product :class:`.CompoundModel` IDs (or a :class:`.CompoundSet`) + :param progress: show a progress bar while building + """ + if isinstance(ids, CompoundSet): + ids = ids.ids + return RouteSet.from_product_ids(ids, progress=progress) + + +class ScorerManager: + """Build recipe :class:`.Scorer`\\ s (via ``animal.scorers``).""" + + def __init__(self, animal: 'HIPPO') -> None: + self._animal = animal + + def default(self, directory, **kwargs) -> Scorer: + """Create a :class:`.Scorer` with the default attributes. + + See :meth:`.Scorer.default` for keyword arguments (``skip``, + ``load_cache``, ``allowed_poses``, ``out_key``, ...). + """ + return Scorer.default(directory, **kwargs) + + def create(self, directory, **kwargs) -> Scorer: + """Create a :class:`.Scorer` with explicit attributes. + + See :class:`.Scorer` for keyword arguments. + """ + return Scorer(directory, **kwargs) + + +class GeneratorManager: + """Build random recipe/selection generators (via ``animal.generators``).""" + + def __init__(self, animal: 'HIPPO') -> None: + self._animal = animal + + @staticmethod + def _check(route_pool, compounds) -> None: + if route_pool is not None and not isinstance(route_pool, RouteSet): + raise TypeError(f'route_pool must be a RouteSet, got {type(route_pool)}') + if compounds is not None and not isinstance(compounds, CompoundSet): + raise TypeError(f'compounds must be a CompoundSet, got {type(compounds)}') + + def random_recipe( + self, + *, + out_key: str, + route_pool=None, + suppliers=None, + max_lead_time=None, + start_with=None, + ) -> RandomRecipeGenerator: + """A generator that samples synthetic :class:`.Route`\\ s. + + :param out_key: base path/key for the generator's output files + """ + self._check(route_pool, None) + return RandomRecipeGenerator( + out_key=out_key, + route_pool=route_pool, + suppliers=suppliers, + max_lead_time=max_lead_time, + start_with=start_with, + ) + + def random_selection( + self, + *, + out_key: str, + compounds=None, + suppliers=None, + amount: float = 1.0, + max_lead_time=None, + start_with=None, + ) -> RandomSelectionGenerator: + """A generator that samples (catalogue) compound selections. + + :param out_key: base path/key for the generator's output files + """ + self._check(None, compounds) + return RandomSelectionGenerator( + out_key=out_key, + compounds=compounds, + suppliers=suppliers, + amount=amount, + max_lead_time=max_lead_time, + start_with=start_with, + ) + + def random_recipe_selection( + self, + *, + out_key: str, + route_pool=None, + compounds=None, + suppliers=None, + amount: float = 1.0, + max_lead_time=None, + start_with=None, + ) -> RandomRecipeSelectionGenerator: + """A generator combining routes and compound selections. + + :param out_key: base path/key for the generator's output files + """ + self._check(route_pool, compounds) + return RandomRecipeSelectionGenerator( + out_key=out_key, + route_pool=route_pool, + compounds=compounds, + suppliers=suppliers, + amount=amount, + max_lead_time=max_lead_time, + start_with=start_with, + ) diff --git a/hippo/designdb/components/__init__.py b/hippo/designdb/components/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hippo/designdb/components/compound.py b/hippo/designdb/components/compound.py new file mode 100644 index 00000000..a8f1155a --- /dev/null +++ b/hippo/designdb/components/compound.py @@ -0,0 +1,1192 @@ +"""Compound and Ingredient components.""" + +import logging +from pathlib import Path +from typing import TYPE_CHECKING + +import mcol +import mrich +import pandas as pd +from designdb.models import ( + CataloguePriceCompoundJunctionModel, + CataloguePriceModel, + CompoundModel, + CompoundTagJunctionModel, + CompoundTagModel, + InspirationModel, + PoseModel, + ReactantModel, + ReactionModel, + ScaffoldModel, +) +from django.db.models import Exists, OuterRef +from IPython.display import display +from molparse.atomtypes import formula_to_atomtype_dict +from molparse.rdkit import draw_highlighted_mol, draw_mcs +from molparse.rdkit.classify import classify_mol +from rdkit import Chem +from rdkit.Chem import Descriptors, MolFromSmarts, rdRGroupDecomposition +from rdkit.Chem.rdMolDescriptors import CalcMolFormula, CalcNumRings +from rdkit.Chem.Scaffolds import MurckoScaffold + +from .price import Price +from .quote import Quote + +if TYPE_CHECKING: + from designdb.animal import HIPPO + from designdb.sets.compound import CompoundSet + from designdb.sets.pose import PoseSet + from designdb.sets.reaction import ReactionSet + + +class Compound: + """A :class:`.Compound` represents a ligand/small molecule with stereochemistry + removed and no atomic coordinates. I.e. it represents the chemical structure. + It's name is always an InChiKey. If a compound is an elaboration it can have a + :meth:`.Compound.scaffolds` property which is another :class:`.Compound`. + :class:`.Compound` objects are target-agnostic and can be linked to any number of + catalogue entries (:class:`.Quote`) or synthetic pathways (:class:`.Reaction`). + + .. attention:: + + :class:`.Compound` objects should not be created directly. Instead use + :meth:`.HIPPO.register_compound` or :meth:`.HIPPO.compounds`. See + :doc:`getting_started` and :doc:`insert_elaborations`. + + """ + + _table = 'compound' + + def __init__(self, instance: CompoundModel): + """Compound initialisation""" + + self._instance = instance + + # caches + self._scaffolds = None + self._elabs = None + self._tags = None + self._mol = None + self._num_heavy_atoms = None + self._num_rings = None + self._formula = None + self._molecular_weight = None + + ### FACTORIES + + @classmethod + def from_id(cls, id: int) -> 'Compound': + """Create a :class:`.Compound` from its database ID""" + return cls(CompoundModel.objects.get(pk=id)) + + ### PROPERTIES + + @property + def id(self) -> int: + """Returns the compound's database ID""" + return self._instance.pk + + @property + def inchikey(self) -> str: + """Returns the compound's InChiKey""" + return self._instance.compound_inchikey + + @property + def name(self) -> str: + """Returns the compound's alias, or InChiKey if no alias is set""" + if self.alias: + return self.alias + return self.inchikey + + @property + def smiles(self) -> str: + """Returns the compound's (flattened) SMILES""" + return self._instance.compound_smiles + + @property + def alias(self) -> str: + """Returns the compound's alias""" + return self._instance.compound_alias + + @alias.setter + def alias(self, alias: str) -> None: + """Set the compound's alias""" + self.set_alias(alias) + + @property + def mol(self) -> Chem.Mol | None: + """Returns the compound's RDKit Molecule""" + if self._mol is None: + mol_text = self._instance.compound_mol + if mol_text: + self._mol = Chem.MolFromMolBlock(mol_text) + return self._mol + + @property + def num_heavy_atoms(self) -> int | None: + """Get the number of heavy atoms""" + if self._num_heavy_atoms is None and self.mol is not None: + self._num_heavy_atoms = self.mol.GetNumHeavyAtoms() + return self._num_heavy_atoms + + @property + def molecular_weight(self) -> float | None: + """Get the molecular weight""" + if self._molecular_weight is None and self.mol is not None: + self._molecular_weight = Descriptors.ExactMolWt(self.mol) + return self._molecular_weight + + @property + def num_rings(self) -> int | None: + """Get the number of rings""" + if self._num_rings is None and self.mol is not None: + self._num_rings = CalcNumRings(self.mol) + return self._num_rings + + @property + def formula(self) -> str | None: + """Get the chemical formula""" + if self._formula is None and self.mol is not None: + self._formula = CalcMolFormula(self.mol) + return self._formula + + @property + def atomtype_dict(self) -> dict[str, int]: + """Get a dictionary with atomtypes as keys and corresponding + quantities/counts as values.""" + + return formula_to_atomtype_dict(self.formula) + + @property + def num_atoms_added(self) -> int | list[int] | None: + """Calculate the number of atoms added relative to the scaffold compound""" + match self.num_scaffolds: + case 0: + mrich.error(f'{self} has no scaffold') + return None + case 1: + scaffold = Compound(next(iter(self.scaffolds._queryset))) + return self.num_heavy_atoms - scaffold.num_heavy_atoms + case _: + mrich.warning(f'{self} has multiple scaffolds') + n_e = self.num_heavy_atoms + return [ + n_e - Compound(c).num_heavy_atoms for c in self.scaffolds._queryset + ] + + @property + def metadata(self) -> dict | None: + """Returns the compound's metadata dict""" + return self._instance.compound_metadata + + @property + def tags(self) -> list[str]: + """Returns the compound's tags""" + if self._tags is None: + self._tags = self.get_tags() + return self._tags + + @property + def poses(self) -> 'PoseSet': + """Returns the compound's poses""" + return self.get_poses() + + @property + def best_placed_pose(self) -> 'PoseModel': + """Returns the compound's pose with the lowest distance score""" + return self.poses.best_placed_pose + + @property + def num_poses(self) -> int: + """Returns the number of associated poses""" + return PoseModel.objects.filter(compound=self._instance).count() + + @property + def num_reactions(self) -> int: + """Returns the number of associated reactions (product)""" + return ReactionModel.objects.filter(product_compound=self._instance).count() + + @property + def num_reactant(self) -> int: + """Returns the number of associated reactions (reactant)""" + return ReactantModel.objects.filter(compound=self._instance).count() + + @property + def scaffolds(self) -> 'CompoundSet | None': + """Returns the scaffold compounds for this elaboration""" + if self._scaffolds is None: + ids = self.get_scaffold_ids() + if not ids: + return None + from designdb.sets.compound import CompoundSet + + self._scaffolds = CompoundSet(ids, name=f'scaffolds of {self}') + return self._scaffolds + + @property + def num_scaffolds(self) -> int: + """Get the number of scaffold compounds for this elaboration""" + if scaffolds := self.scaffolds: + return len(scaffolds) + return 0 + + @property + def elabs(self) -> 'CompoundSet | None': + """Returns the elaborations of this scaffold compound""" + if self._elabs is None: + ids = self.get_superstructure_ids() + if not ids: + return None + from designdb.sets.compound import CompoundSet + + self._elabs = CompoundSet(ids, name=f'elaborations of {self}') + return self._elabs + + @property + def reactions(self) -> 'ReactionSet': + """Returns the reactions resulting in this compound""" + return self.get_reactions() + + @property + def reaction(self) -> 'ReactionModel | None': + """Returns the reaction resulting in this compound (warns if multiple)""" + reactions = self.reactions + match len(reactions): + case 0: + mrich.warning(f'{self} has no reactions') + return None + case 1: + pass + case _: + mrich.warning(f'{self} has multiple reactions, returning first') + return reactions[0] + + @property + def dict(self) -> dict: + """Returns a dictionary of this compound. See :meth:`.Compound.get_dict`""" + return self.get_dict() + + @property + def is_scaffold(self) -> bool: + """Is this Compound the basis for any elaborations?""" + return ScaffoldModel.objects.filter(base_compound=self._instance).exists() + + @property + def is_elab(self) -> bool: + """Is this Compound based on any other compound?""" + return ScaffoldModel.objects.filter( + superstructure_compound=self._instance + ).exists() + + @property + def is_product(self) -> bool: + """Is this Compound a product of at least one reaction?""" + return ReactionModel.objects.filter(product_compound=self._instance).exists() + + @property + def table(self) -> str: + """Returns the name of the database table""" + return self._table + + ### METHODS + + def add_stock( + self, + amount: float, + *, + purity: float | None = None, + entry: str | None = None, + location: str | None = None, + return_quote: bool = True, + ) -> int | CataloguePriceModel: + """Register a certain quantity of compound stock in the Database. + + :param amount: Amount in ``mg`` + :param purity: Purity fraction ``0 < purity <= 1``, defaults to ``None`` + :param entry: Catalogue entry identifier, defaults to ``None`` + :param location: String describing where this stock is located, defaults to + ``None`` + :param return_quote: If ``True`` a :class:`.CataloguePriceModel` object is + returned instead of its ID, defaults to ``True`` + :returns: The inserted :class:`.CataloguePriceModel` object or ID + """ + + assert amount + + # Find existing in-stock entries for this compound + existing_qs = CataloguePriceModel.objects.annotate( + has_compound=Exists( + CataloguePriceCompoundJunctionModel.objects.filter( + compound=self._instance, + catalogue_price=OuterRef('pk'), + ) + ) + ).filter(has_compound=True, supplier='Stock') + + # Delete entries matching this entry/purity/location + to_delete = existing_qs.filter( + supplier_id=entry or '', + purity=purity, + vendor=location or '', + ) + deleted_count = to_delete.count() + if deleted_count: + CataloguePriceCompoundJunctionModel.objects.filter( + compound=self._instance, + catalogue_price__in=to_delete, + ).delete() + mrich.warning(f'Removed {deleted_count} existing In-Stock entries') + + not_deleted = existing_qs.exclude( + supplier_id=entry or '', + purity=purity, + vendor=location or '', + ).count() + if not_deleted: + mrich.warning( + f'Did not remove {not_deleted} existing In-Stock entries with' + f' differing entry/purity/location' + ) + + # Create new price entry and link to compound + quote = CataloguePriceModel.objects.create( + supplier='Stock', + supplier_id=entry or '', + vendor=location or '', + amount=amount, + price=0, + currency=None, + lead_time=0, + purity=purity, + catalogue_compound=None, + ) + CataloguePriceCompoundJunctionModel.objects.create( + compound=self._instance, + catalogue_price=quote, + ) + + if return_quote: + return quote + return quote.pk + + def get_tags(self) -> list[str]: + """Get the tags assigned to this compound""" + return list(self._instance.tags.values_list('compound_tag_name', flat=True)) + + def add_tag(self, tag: str) -> None: + """Add a tag to this compound""" + + assert isinstance(tag, str) + tag_obj, _ = CompoundTagModel.objects.get_or_create(compound_tag_name=tag) + CompoundTagJunctionModel.objects.get_or_create( + compound=self._instance, compound_tag=tag_obj + ) + self._tags = None # invalidate cache + + def get_quotes( + self, + min_amount: float | None = None, + supplier: str | None = None, + max_lead_time: float | None = None, + none: str = 'quiet', + pick_cheapest: bool = False, + df: bool = False, + ): + """Get all quotes associated to this compound. + See :meth:`.Ingredient.get_quotes`""" + return Ingredient.get_quotes( + compound=self._instance, + min_amount=min_amount, + supplier=supplier, + max_lead_time=max_lead_time, + none=none, + pick_cheapest=pick_cheapest, + df=df, + ) + + def get_reactions( + self, + as_reactant: bool = False, + permitted_reactions: 'ReactionSet' = None, + none: str = 'error', + ) -> 'ReactionSet': + """Get the associated :class:`.ReactionModel` objects. + + :param as_reactant: Search for reactions using this compound as a reactant, + defaults to ``False`` + :param permitted_reactions: Filter results to this :class:`.ReactionSet` + :param none: Unused, kept for API compatibility + """ + + from designdb.sets.reaction import ReactionSet + + if as_reactant: + reaction_ids = list( + ReactantModel.objects.filter(compound=self._instance).values_list( + 'reaction_id', flat=True + ) + ) + else: + reaction_ids = list( + ReactionModel.objects.filter( + product_compound=self._instance + ).values_list('pk', flat=True) + ) + + if permitted_reactions: + permitted_ids = set(permitted_reactions.ids) + reaction_ids = [i for i in reaction_ids if i in permitted_ids] + + rset = ReactionSet(reaction_ids) + if not as_reactant and not permitted_reactions: + rset._name = f'reactions resulting in {str(self)}' + + return rset + + def get_poses(self) -> 'PoseSet': + """Get the associated :class:`.PoseModel` objects.""" + from designdb.sets.pose import PoseSet + + qs = PoseModel.objects.filter(compound=self._instance) + return PoseSet(qs, name=f"{self}'s poses") + + def get_dict( + self, + *, + mol: bool = True, + alias: bool = True, + inchikey: bool = True, + metadata: bool = True, + poses: bool = True, + num_reactant: bool = True, + num_reactions: bool = True, + scaffolds: bool = True, + elabs: bool = True, + tags: bool = True, + ) -> dict: + """Returns a dictionary representing this :class:`.Compound` + + :param mol: Include a ``rdkit.Chem.Mol object``, defaults to ``True`` + :param metadata: Include metadata, defaults to ``True`` + :param poses: Include IDs of associated :class:`.PoseModel` objects, + defaults to ``True`` + :param num_reactant: include num_reactant column + :param num_reactions: include num_reactions column + :param scaffolds: include scaffolds column + :param elabs: include elabs column + :param tags: include tags column + :returns: A dictionary + """ + + data: dict = {'id': self.id, 'smiles': self.smiles} + + if alias: + data['alias'] = self.alias + if inchikey: + data['inchikey'] = self.inchikey + if num_reactant: + data['num_reactant'] = self.num_reactant + if num_reactions: + data['num_reactions'] = self.num_reactions + + if mol: + data['mol'] = self.mol + + if scaffolds: + data['scaffolds'] = self.scaffolds.ids if self.scaffolds else None + + if elabs: + data['elabs'] = self.elabs.ids if self.elabs else None + + if tags: + data['tags'] = self.tags + + if poses: + pose_set = self.poses + if pose_set: + data['poses'] = pose_set.ids + data['targets'] = pose_set.target_names + + if metadata and (metadict := self.metadata): + for key, value in metadict.items(): + data[key] = value + + return data + + def get_recipes( + self, + *, + amount: float = 1, + debug: bool = False, + pick_cheapest: bool = False, + quoted_only: bool = False, + supplier: None | str = None, + **kwargs, + ): + """Get :class:`.Recipe` objects that result in this compound. + See :meth:`.Recipe.from_compounds`""" + from designdb.recipe import Recipe + from designdb.sets.compound import CompoundSet + + return Recipe.from_compounds( + CompoundSet([self._instance.pk]), + amount=amount, + debug=debug, + pick_cheapest=pick_cheapest, + quoted_only=quoted_only, + supplier=supplier, + **kwargs, + ) + + def get_scaffold_ids(self) -> list[int] | None: + """Get a list of :class:`.Compound` IDs that this object is a superstructure + of""" + ids = list( + ScaffoldModel.objects.filter( + superstructure_compound=self._instance + ).values_list('base_compound_id', flat=True) + ) + return ids or None + + def get_superstructure_ids(self) -> list[int] | None: + """Get a list of :class:`.Compound` IDs that this object is a substructure of""" + ids = list( + ScaffoldModel.objects.filter(base_compound=self._instance).values_list( + 'superstructure_compound_id', flat=True + ) + ) + return ids or None + + def add_scaffold( + self, scaffold: 'Compound | CompoundModel | int', commit: bool = True + ) -> None: + """Add a scaffold :class:`.Compound` this molecule is derived from. + + :param scaffold: The scaffold :class:`.Compound`, :class:`.CompoundModel`, + or its ID. + :param commit: Unused, kept for API compatibility + """ + + if isinstance(scaffold, int): + scaffold_model = CompoundModel.objects.get(pk=scaffold) + elif isinstance(scaffold, CompoundModel): + scaffold_model = scaffold + else: + assert scaffold._table == 'compound' + scaffold_model = scaffold._model + + ScaffoldModel.objects.get_or_create( + base_compound=scaffold_model, + superstructure_compound=self._instance, + ) + self._scaffolds = None # invalidate cache + + def set_alias(self, alias: str, commit: bool = True) -> None: + """Set this :class:`.Compound`'s alias. + + :param alias: The alias + :param commit: Unused, kept for API compatibility + """ + + assert isinstance(alias, str) + self._instance.compound_alias = alias + self._instance.save(update_fields=['compound_alias', 'updated_on']) + + def as_ingredient( + self, + amount: float, + max_lead_time: float = None, + supplier: str = None, + get_quote: bool = True, + quote_none: str = 'quiet', + ) -> 'Ingredient': + """Convert this compound into an :class:`.Ingredient` with an associated + amount and quote. + + :param amount: Amount in ``mg`` + :param supplier: Only search for quotes with the given supplier, defaults to + ``None`` + :param max_lead_time: Only search for quotes with lead times less than this + (in days), defaults to ``None`` + """ + + return Ingredient.from_compound( + compound=self._instance, + amount=amount, + max_lead_time=max_lead_time, + supplier=supplier, + get_quote=get_quote, + quote_none=quote_none, + ) + + def draw(self, scaffolds: bool = True, align_substructure: bool = False) -> None: + """Display this compound (and its scaffold if it has one) + + .. attention:: + + This method is only intended for use within a Jupyter Notebook. + + :param align_substructure: Align the two drawings by their common + substructure, defaults to ``False`` + """ + + if scaffolds and (scaffolds := self.scaffolds): + data = {} + for scaffold in scaffolds: + data[scaffold.compound_smiles] = f'C{scaffold.pk} (scaffold)' + data[self.smiles] = str(self) + + if len(data) > 1: + drawing = draw_mcs( + data, + align_substructure=align_substructure, + show_mcs=False, + highlight=False, + ) + display(drawing) + else: + mrich.error( + f'Problem drawing scaffold vs {self.id=}, self referential?' + ) + display(self.mol) + else: + display(self.mol) + + def draw_elabs(self) -> None: + """Draw elaborations""" + + elabs = self.elabs + + display(self) + display(elabs) + + if not elabs: + mrich.error(self, 'has no elaborations') + return self.draw() + + params = rdRGroupDecomposition.RGroupDecompositionParameters() + params.removeAllHydrogenRGroups = False + params.removeAllHydrogenRGroupsAndLabels = True + params.removeHydrogensPostMatch = True + + rgd = rdRGroupDecomposition.RGroupDecomposition( + MolFromSmarts(self.smiles), params + ) + for c in elabs._queryset: + mol_text = c.compound_mol + if mol_text: + mol = Chem.MolFromMolBlock(mol_text) + if mol: + rgd.Add(mol) + rgd.Process() + + rgroup_table = rgd.GetRGroupsAsColumns() + core = rgroup_table['Core'][0] + attachment_points = set() + for rgroup in rgroup_table['Core']: + for atom in rgroup.GetAtoms(): + if atom.GetAtomicNum() == 0: + attachment_points.add(atom.GetIdx()) + + drawing = draw_highlighted_mol( + core, [(i, (0.5, 1, 0.5)) for i in attachment_points] + ) + display(drawing) + + def classify(self, draw: bool = True) -> list[tuple[str, int]]: + """Find RDKit Fragments within the compound molecule and draw them + + :param draw: Draw the annotated molecule, defaults to ``True`` + :returns: A list of tuples containing a descriptor (``str``) and count + (``int``) pair + """ + + return classify_mol(self.mol, draw=draw) + + def murcko_scaffold(self, generic: bool = False) -> Chem.Mol: + """Get the rdkit MurckoScaffold for this compound""" + + scaffold = MurckoScaffold.GetScaffoldForMol(self.mol) + if generic: + scaffold = MurckoScaffold.MakeScaffoldGeneric(scaffold) + return scaffold + + def summary( + self, metadata: bool = True, draw: bool = True, tags: bool = True + ) -> None: + """Print a summary of this compound + + :param metadata: Include metadata, defaults to ``True`` + :param draw: Include a 2D molecule drawing, defaults to ``True`` + """ + + mrich.header(self) + mrich.var('inchikey', self.inchikey) + mrich.var('alias', self.alias) + mrich.var('smiles', self.smiles) + mrich.var('scaffolds', self.scaffolds) + mrich.var('elabs', self.elabs) + mrich.var('is_scaffold', self.is_scaffold) + mrich.var('is_elab', self.is_elab) + mrich.var('num_heavy_atoms', self.num_heavy_atoms) + mrich.var('num_rings', self.num_rings) + mrich.var('formula', self.formula) + mrich.var('#reactions (product)', self.num_reactions) + mrich.var('#reactions (reactant)', self.num_reactant) + + if tags: + mrich.var('tags', self.tags) + + poses = self.poses + mrich.var('#poses', len(poses)) + if poses: + mrich.var('targets', poses.targets) + + if metadata: + mrich.var('metadata', str(self.metadata)) + + if draw: + self.draw() + + def place( + self, + *, + animal: 'HIPPO', + reference: 'PoseModel', + inspirations: list['PoseModel'] | None = None, + max_ddG: float = 0.0, + max_RMSD: float = 2.0, + output_dir: str = 'wictor_place', + tags: list[str] = None, + metadata: dict = None, + overwrite: bool = False, + ) -> 'PoseModel | None': + """Generate a new pose for this compound using Fragmenstein. + + :param animal: The :class:`.HIPPO` instance used to register the pose + :param reference: Choose the :class:`.PoseModel` to use as the reference + protein conformation + :param inspirations: Choose the (virtual) hits to define the ligand reference, + defaults to the ``reference``'s inspirations + :param max_ddG: Maximum ``ddG`` value permitted, defaults to ``0.0`` + :param max_RMSD: Maximum ``RMSD`` value permitted, defaults to ``2.0`` + :param output_dir: Output directory for Fragmenstein files, defaults to + ``wictor_place`` + :param tags: Tags to assign to the created pose, defaults to ``[]`` + :param metadata: A dictionary of metadata to assign to this compound, + defaults to ``{}`` + :param overwrite: Delete old poses, defaults to ``False`` + """ + + from fragmenstein import Wictor + + tags = tags or [] + metadata = metadata or {} + + inspirations = inspirations or reference.inspirations.all() + target = reference.target.target_name + + inspiration_mols = [] + for insp in inspirations: + mol_text = insp.compound.compound_mol + if mol_text: + mol = Chem.MolFromMolBlock(mol_text) + if mol: + inspiration_mols.append(mol) + + protein_pdb_block = reference.protein_system.pdb_block_with_alt_sites + + victor = Wictor(hits=inspiration_mols, pdb_block=protein_pdb_block) + victor.work_path = output_dir + victor.enable_stdout(logging.CRITICAL) + victor.place(self.smiles, long_name=self.name) + + metadata['ddG'] = ( + victor.energy_score['bound']['total_score'] + - victor.energy_score['unbound']['total_score'] + ) + metadata['RMSD'] = victor.mrmsd.mrmsd + + if metadata['ddG'] > max_ddG: + return None + if metadata['RMSD'] > max_RMSD: + return None + + pose = animal.register_pose( + compound=self, + target=target, + path=Path(victor.work_path) / self.name / f'{self.name}.minimised.mol', + inspirations=inspirations, + reference=reference, + tags=tags, + metadata=metadata, + ) + + if overwrite: + PoseModel.objects.filter(compound=self._instance).exclude( + pk=pose.pk + ).delete() + mrich.success(f'Successfully posed {self} (and deleted old poses)') + else: + mrich.success(f'Successfully posed {self}') + + return pose + + def get_inspirations( + self, debug: bool = True, none: str = 'warning' + ) -> 'PoseSet | None': + """Get the fragment inspirations for this compound's poses. + + Since inspirations map :class:`.PoseModel` objects to each other, this requires + poses to be registered for this compound. + + :returns: a :class:`.PoseSet` object + """ + + from designdb.sets.pose import PoseSet + + poses_qs = PoseModel.objects.filter(compound=self._instance) + insp_qs = InspirationModel.objects.filter(derivative_pose__in=poses_qs) + + if not insp_qs.exists() and none in ('warning', 'warn'): + mrich.warning('Could not determine inspirations for', self) + return None + + derivative_ids = set(insp_qs.values_list('derivative_pose_id', flat=True)) + original_ids = set(insp_qs.values_list('original_pose_id', flat=True)) + + if debug: + mrich.debug(f'Inspirations derived from {derivative_ids}') + + inspirations = PoseSet( + PoseModel.objects.filter(pk__in=original_ids), + name=f'Inspirations for {self}', + ) + + return inspirations + + ### DUNDERS + + def __str__(self) -> str: + """Unformatted string representation""" + return f'C{self.id}' + + def __repr__(self) -> str: + """ANSI Formatted string representation""" + return ( + f'{mcol.bold}{mcol.underline}{self} "{self.name}"' + f'{mcol.unbold}{mcol.ununderline}' + ) + + def __rich__(self) -> str: + """Representation for mrich""" + return f'[bold underline]{self} "{self.name}"' + + def __eq__(self, other) -> bool: + """Compare compounds""" + assert isinstance(other, Compound) + return self.id == other.id + + +class Ingredient: + """An ingredient is a :class:`.Compound` with a fixed quanitity and an attached + quote. + + .. image:: ../images/ingredient.png + :width: 450 + :alt: Ingredient schema + + .. attention:: + + :class:`.Ingredient` objects should not be created directly. Instead use + :meth:`.Compound.as_ingredient`. + """ + + _table = 'ingredient' + + def __init__( + self, + compound: CompoundModel, # or CatalogueCompoundModel? + amount: float, + quote: 'Quote | CataloguePriceModel | int | None' = None, + max_lead_time: float | None = None, + supplier: str | None = None, + ): + """Ingredient initialisation + + ``quote`` may be a :class:`.Quote`, a :class:`.CataloguePriceModel`, a quote + ID (``int``), or ``None``. When only an ID (or nothing) is stored, the quote + is resolved lazily on first access via :attr:`.quote` -- which re-quotes the + catalogue (estimating a price if no pack is large enough). + """ + + self._compound = compound + self._amount = amount + self._max_lead_time = max_lead_time + self._supplier = supplier + + # `_quote` holds a resolved Quote component (or None); `_quote_id` holds a + # persisted quote ID awaiting lazy resolution. Estimated quotes have no ID + # and so are always carried as a resolved `_quote` object. + self._quote = None + self._quote_id = None + + if isinstance(quote, Quote): + self._quote = quote + self._quote_id = quote.id + elif isinstance(quote, CataloguePriceModel): + self._quote = Quote(quote) + self._quote_id = quote.pk + elif quote is not None: + self._quote_id = int(quote) + + def __str__(self) -> str: + """Plain string representation""" + return f'{self.amount:.2f}mg of C{self._compound.id}' + + def __repr__(self) -> str: + """ANSI Formatted string representation""" + return f'{mcol.bold}{mcol.underline}{str(self)}{mcol.unbold}{mcol.ununderline}' + + def __rich__(self) -> str: + """Representation for mrich""" + return f'[bold underline]{str(self)}' + + def __eq__(self, other) -> bool: + """Equality operator""" + + if self.compound != other.compound: + return False + + return self.amount == other.amount + + def __getattr__(self, key: str): + """For missing attributes try getting from associated :class:`.Compound`""" + return getattr(self.compound, key) + + @classmethod + def from_compound( + cls, + compound: CompoundModel, + amount: float, + max_lead_time: float = None, + supplier: str = None, + get_quote: bool = True, + quote_none: str = 'quiet', + ) -> 'Ingredient': + """Convert this compound into an :class:`.Ingredient` object with an + associated amount (in ``mg``) and :class:`.Quote` if available. + + :param amount: Amount in ``mg`` + :param supplier: Only search for quotes with the given supplier, defaults to + ``None`` + :param max_lead_time: Only search for quotes with lead times less than this + (in days), defaults to ``None`` + """ + + if get_quote: + # quote = self.get_quotes( + # pick_cheapest=True, + # min_amount=amount, + # max_lead_time=max_lead_time, + # supplier=supplier, + # none=quote_none, + # ) + + # if not quote: + # quote = None + + quote = cls.get_quotes( + compound=compound, + pick_cheapest=True, + min_amount=amount, + max_lead_time=max_lead_time, + supplier=supplier, + none=quote_none, + ) + + else: + quote = None + + return Ingredient( + compound=compound, + amount=amount, + quote=quote, + supplier=supplier, + max_lead_time=max_lead_time, + ) + + @classmethod + def get_quotes( + cls, + compound: CompoundModel, + min_amount: float | None = None, + supplier: str | None = None, + max_lead_time: float | None = None, + none: str = 'quiet', + pick_cheapest: bool = False, + df: bool = False, + ): + """Get all quotes associated to this compound + + :param min_amount: Only return quotes with amounts greater than this, + defaults to ``None`` + :param supplier: Only return quotes with the given supplier, defaults to + ``None`` + :param max_lead_time: Only return quotes with lead times less than this + (in days), defaults to ``None`` + :param none: Define the behaviour when no quotes are found. Choose `error` + to raise print an error. + :param pick_cheapest: If ``True`` only the cheapest :class:`.Quote` is + returned, defaults to ``False`` + :param df: Returns a ``DataFrame`` of the quoting data, defaults to ``False`` + :returns: List of :class:`.Quote` objects, ``DataFrame``, or single + :class:`.Quote`. See ``pick_cheapest`` and ``df`` parameters + + """ + + qs = CataloguePriceModel.objects.annotate( + has_compound=Exists( + CataloguePriceCompoundJunctionModel.objects.filter( + compound=compound, + catalogue_price=OuterRef('pk'), + ), + ), + ).filter( + has_compound=True, + ) + + if supplier: + if isinstance(supplier, str): + qs = qs.filter(supplier=supplier) + else: + qs = qs.filter(supplier__in=supplier) + + if not qs.exists(): + return None + + if max_lead_time: + qs = qs.filter(lead_time__lte=max_lead_time) + + estimate = None + + if min_amount: + suitable = qs.filter(amount__gte=min_amount) + + if suitable.exists(): + qs = suitable + else: + # no single pack is large enough -> estimate by scaling the biggest + # available pack's unit price (mirrors legacy Quote.combination) + mrich.debug( + f'No quote available for C{compound.pk} with amount >=' + f' {min_amount} mg. Estimating price...' + ) + estimate = Quote.estimate(min_amount, [Quote(m) for m in qs]) + + if pick_cheapest: + if estimate is not None: + return estimate + cheapest = qs.order_by('price').first() + return Quote(cheapest) if cheapest is not None else None + + if df: + return pd.DataFrame(qs.values()).drop(columns='compound') + + return qs + + ### PROPERTIES + + @property + def amount(self) -> float: + """Returns the amount (in ``mg``)""" + return self._amount + + @property + def id(self) -> int: + """Returns the ID of the associated :class:`.Compound`""" + return self._compound.id + + @property + def compound_id(self) -> int: + """Returns the ID of the associated :class:`.Compound`""" + return self._compound.id + + @property + def quote(self) -> 'Quote | None': + """The associated :class:`.Quote`, resolved lazily. + + If a persisted quote ID is stored it is fetched; otherwise the catalogue is + re-quoted for this ingredient's amount (estimating a price if no single pack + is large enough). Returns ``None`` if no quote can be found or estimated. + """ + if self._quote is None: + if self._quote_id: + self._quote = Quote(CataloguePriceModel.objects.get(pk=self._quote_id)) + else: + self._quote = Ingredient.get_quotes( + compound=self._compound, + min_amount=self._amount, + supplier=self._supplier, + max_lead_time=self._max_lead_time, + pick_cheapest=True, + none='quiet', + ) + if self._quote is not None: + self._quote_id = self._quote.id + return self._quote + + @property + def quote_id(self) -> int | None: + """ID of the associated quote, or ``None`` (estimated/unquoted).""" + if self._quote_id is not None: + return self._quote_id + quote = self.quote + return quote.id if quote is not None else None + + @property + def price(self) -> Price: + """Returns the price from the associated quote, or a null Price if + unavailable.""" + quote = self.quote + if quote is None: + return Price.null() + return quote.as_price + + @property + def max_lead_time(self) -> float: + """Returns the max_lead_time (in days) from the original quote query""" + return self._max_lead_time + + @property + def supplier(self) -> str: + """Returns the supplier from the original quote query""" + return self._supplier + + @amount.setter + def amount(self, a) -> None: + """Set the amount, invalidating the cached quote so it is re-quoted (and + re-estimated if needed) for the new amount on next access.""" + + self._amount = a + self._quote = None + self._quote_id = None + + @property + def compound(self) -> CompoundModel: + """Returns the associated :class:`.Compound`""" + + # if not self._compound: + # self._compound = self.db.get_compound(id=self.compound_id) + return self._compound + + @property + def compound_price_amount_str(self) -> str: + """String representation including :class:`.Compound`, :class:`.Price`, + and amount.""" + return f'{self} ({self.amount})' + + @property + def smiles(self) -> str: + """Returns the SMILES of the associated :class:`.Compound`""" + return self.compound.smiles diff --git a/hippo/designdb/components/pose.py b/hippo/designdb/components/pose.py new file mode 100644 index 00000000..4fdc7280 --- /dev/null +++ b/hippo/designdb/components/pose.py @@ -0,0 +1,143 @@ +"""Pose component wrapping a :class:`.PoseModel`. + +A :class:`.Pose` is a particular conformer of a :class:`.Compound` in a protein +environment. This component wraps the ORM :class:`.PoseModel`, exposing the ligand +molecule, the protein structure, and interaction-fingerprinting entry points. + +Missing attributes are delegated to the wrapped :class:`.PoseModel`, so model +fields/relations (``pose_alias``, ``tags``, ``inspirations``, ``save`` …) remain +accessible. +""" + +import mcol +import molparse as mp +from designdb.models import PoseModel + + +class Pose: + """A conformer of a :class:`.Compound` within a protein environment.""" + + def __init__(self, instance: 'PoseModel'): + """Pose initialisation""" + self._instance = instance + self._protein_system = None + + def __getattr__(self, key: str): + """Delegate unknown attributes to the wrapped :class:`.PoseModel`.""" + # guard internal attributes to avoid recursion before _instance is set + if key.startswith('_'): + raise AttributeError(key) + return getattr(self._instance, key) + + ### PROPERTIES + + @property + def instance(self) -> 'PoseModel': + """The wrapped :class:`.PoseModel`""" + return self._instance + + @property + def id(self) -> int: + """The pose's database ID""" + return self._instance.id + + @property + def pk(self) -> int: + """The pose's primary key""" + return self._instance.pk + + @property + def mol(self): + """The pose's ligand ``rdkit.Chem.Mol`` (stored in the DB)""" + return self._instance.pose_mol + + @property + def protein_link(self) -> str | None: + """Path/link to the pose's protein structure (PDB)""" + return self._instance.protein_link + + @property + def reference_id(self) -> int | None: + """ID of the pose's protein reference pose, if any""" + return self._instance.pose_reference + + @property + def reference(self) -> 'Pose | None': + """The pose's protein reference (another :class:`.Pose`), if any""" + ref_id = self._instance.pose_reference + if ref_id is None: + return None + return Pose(PoseModel.objects.get(pk=ref_id)) + + @property + def protein_system(self) -> 'mp.System | None': + """The pose's protein ``molparse.System`` (parsed from the PDB)""" + if self._protein_system is None: + link = self.protein_link + if link and str(link).endswith('.pdb'): + self._protein_system = mp.parse(link, verbosity=False).protein_system + return self._protein_system + + @protein_system.setter + def protein_system(self, system) -> None: + """Set the pose's protein ``molparse.System``""" + self._protein_system = system + + @property + def features(self) -> list: + """The pose ligand's ``molparse`` features""" + return mp.rdkit.features_from_mol(self.mol) + + @property + def has_fingerprint(self) -> bool: + """Whether this pose has had its interactions fingerprinted""" + return bool(self._instance.pose_fingerprint) + + ### METHODS + + def set_has_fingerprint(self, fp: bool, commit: bool = True) -> None: + """Record whether this pose has been fingerprinted. + + :param fp: fingerprint state + :param commit: persist to the database (Default value = True) + """ + assert isinstance(fp, bool) + self._instance.pose_fingerprint = int(fp) + if commit: + self._instance.save(update_fields=['pose_fingerprint']) + + def calculate_interactions(self, **kwargs) -> None: + """Enumerate valid interactions between this pose's ligand and protein. + + Delegates to :meth:`.InteractionService.calculate`. See that method for + keyword arguments. + """ + from designdb.services.interaction import InteractionService + + return InteractionService.calculate(self, **kwargs) + + ### DUNDERS + + def __str__(self) -> str: + """Plain string representation""" + return f'P{self.id}' + + def __repr__(self) -> str: + """ANSI formatted string representation""" + return f'{mcol.bold}{mcol.underline}{str(self)}{mcol.unbold}{mcol.ununderline}' + + def __rich__(self) -> str: + """Representation for mrich""" + return f'[bold underline]{str(self)}' + + def __eq__(self, other) -> bool: + """Equality by pose ID""" + if isinstance(other, Pose): + return self.id == other.id + if isinstance(other, PoseModel): + return self.id == other.id + return NotImplemented + + def __hash__(self) -> int: + """Hash by pose ID""" + return hash(('Pose', self.id)) diff --git a/hippo/price.py b/hippo/designdb/components/price.py similarity index 73% rename from hippo/price.py rename to hippo/designdb/components/price.py index fb0601ba..fc9b2373 100644 --- a/hippo/price.py +++ b/hippo/designdb/components/price.py @@ -1,12 +1,11 @@ """Class for working with prices""" import mcol -import mrich CURRENCIES = { - "USD": "$", - "EUR": "€", - "GBP": "£", + 'USD': '$', + 'EUR': '€', + 'GBP': '£', } @@ -27,7 +26,7 @@ def __init__(self, amount: float | None, currency: str | None): """Price initialisation""" if currency not in CURRENCIES: - assert currency is None, f"Unrecognised {currency=}" + assert currency is None, f'Unrecognised {currency=}' assert not amount, f"Null Price can't have {amount=}" amount = None @@ -40,7 +39,7 @@ def __init__(self, amount: float | None, currency: str | None): ### FACTORIES @classmethod - def null(cls) -> "Price": + def null(cls) -> 'Price': """Zero in any currency""" self = cls.__new__(cls) self.__init__(None, None) @@ -50,7 +49,7 @@ def null(cls) -> "Price": def from_dict( cls, d: dict, - ) -> "Price": + ) -> 'Price': """Create a :class:`.Price` object from a dictionary: :: @@ -61,7 +60,7 @@ def from_dict( """ self = cls.__new__(cls) - self.__init__(d["amount"], d["currency"]) + self.__init__(d['amount'], d['currency']) return self ### PROPERTIES @@ -98,28 +97,36 @@ def get_dict(self) -> dict: """ return dict(amount=self.amount, currency=self.currency) - def copy(self) -> "Price": + def copy(self) -> 'Price': """Return a copy of this :class:`.Price`""" return Price(amount=self.amount, currency=self.currency) ### DUNDERS + def __bool__(self) -> bool: + """A null Price is falsy; a real (priced) Price is truthy. + + Recipe selection relies on ``if recipe.get_price()`` to drop unpriced + solutions, so this must reflect :attr:`.Price.is_null`. + """ + return not self.is_null + def __str__(self) -> str: """Unformatted string representation""" if self.currency is None: - return "Null Price" + return 'Null Price' - return f"{self.symbol}{self.amount:.2f} {self.currency}" + return f'{self.symbol}{self.amount:.2f} {self.currency}' def __repr__(self) -> str: """ANSI Formatted string representation""" - return f"{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}" + return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' def __rich__(self) -> str: """Rich Formatted string representation""" - return f"[bold underline]{self}" + return f'[bold underline]{self}' - def __add__(self, other: "Price") -> "Price": + def __add__(self, other: 'Price') -> 'Price': """Add two :class:`.Price` objects :param other: :class:`.Price` object @@ -138,11 +145,11 @@ def __add__(self, other: "Price") -> "Price": if self.currency != other.currency: raise NotImplementedError( - f"Adding two different currencies: {self.currency} != {other.currency}" + f'Adding two different currencies: {self.currency} != {other.currency}' ) return Price(self.amount + other.amount, self.currency) - def __truediv__(self, other: "Price | float | int") -> "Price | float": + def __truediv__(self, other: 'Price | float | int') -> 'Price | float': """Divide this :class:`.Price` by another object :param other: :class:`.Price` or float or int @@ -160,9 +167,9 @@ def __truediv__(self, other: "Price | float | int") -> "Price | float": assert not other.is_null return self.amount / other.amount - raise TypeError(f"Division not supported between Price and {type(other)}") + raise TypeError(f'Division not supported between Price and {type(other)}') - def __mul__(self, other: "Price | float | int") -> "Price | float": + def __mul__(self, other: 'Price | float | int') -> 'Price | float': """Multiply this :class:`.Price` by another object :param other: :class:`.Price` or float or int @@ -175,9 +182,9 @@ def __mul__(self, other: "Price | float | int") -> "Price | float": return self return Price(amount=self.amount * other, currency=self.currency) - raise TypeError(f"Multiplication not supported between Price and {type(other)}") + raise TypeError(f'Multiplication not supported between Price and {type(other)}') - def __eq__(self, other: "Price") -> bool: + def __eq__(self, other: 'Price') -> bool: """Compare two :class:`.Price` objects""" if isinstance(other, int) or isinstance(other, float): @@ -194,12 +201,12 @@ def __eq__(self, other: "Price") -> bool: if not self.is_null and other.is_null: return False - assert ( - self.currency == other.currency - ), f"Comparing different currencies: {self.currency} != {other.currency}" + assert self.currency == other.currency, ( + f'Comparing different currencies: {self.currency} != {other.currency}' + ) return self.amount == other.amount - def __lt__(self, other: "Price") -> bool: + def __lt__(self, other: 'Price') -> bool: """Compare two :class:`.Price` objects""" if isinstance(other, int) or isinstance(other, float): @@ -216,12 +223,12 @@ def __lt__(self, other: "Price") -> bool: if not self.is_null and other.is_null: return False - assert ( - self.currency == other.currency - ), f"Comparing different currencies: {self.currency} != {other.currency}" + assert self.currency == other.currency, ( + f'Comparing different currencies: {self.currency} != {other.currency}' + ) return self.amount < other.amount - def __gt__(self, other: "Price") -> bool: + def __gt__(self, other: 'Price') -> bool: """Compare two :class:`.Price` objects""" if isinstance(other, int) or isinstance(other, float): @@ -238,13 +245,13 @@ def __gt__(self, other: "Price") -> bool: if not self.is_null and other.is_null: return True - assert ( - self.currency == other.currency - ), f"Comparing different currencies: {self.currency} != {other.currency}" + assert self.currency == other.currency, ( + f'Comparing different currencies: {self.currency} != {other.currency}' + ) return self.amount > other.amount def __hash__(self) -> int: """Allow for Prices to be hashed for comparison""" if self.is_null: - return hash("NULL") - return hash(f"{self.currency} {self.amount}") + return hash('NULL') + return hash(f'{self.currency} {self.amount}') diff --git a/hippo/designdb/components/quote.py b/hippo/designdb/components/quote.py new file mode 100644 index 00000000..52283247 --- /dev/null +++ b/hippo/designdb/components/quote.py @@ -0,0 +1,125 @@ +"""Component wrapping a catalogue price (quote). + +A :class:`.Quote` wraps a :class:`.CataloguePriceModel` row. It can also be an +*estimated* quote (see :meth:`.Quote.estimate`), not backed by a saved row, for +when no single catalogue pack covers the required amount. +""" + +import mcol +from designdb.models import CataloguePriceModel + +from .price import Price + + +class Quote: + """A catalogue price for a :class:`.CompoundModel`, with a fixed amount. + + Wraps a :class:`.CataloguePriceModel`. Estimated quotes (built by + :meth:`.estimate`) wrap an *unsaved* model instance and therefore have + ``id is None`` and :attr:`.is_estimate` ``True``. + """ + + def __init__(self, instance: CataloguePriceModel): + """Quote initialisation""" + self._instance = instance + + ### FACTORIES + + @classmethod + def estimate(cls, required_amount: float, quotes: 'list[Quote]') -> 'Quote | None': + """Estimate a quote for ``required_amount`` when no single pack is big enough. + + Scales the biggest available pack's unit price to the required amount; the + returned quote wraps an *unsaved* :class:`.CataloguePriceModel` + (``id is None``). + + :param required_amount: amount in ``mg`` + :param quotes: available :class:`.Quote` packs to scale from + :returns: the estimated :class:`.Quote`, or ``None`` if there's nothing usable + to scale from + """ + + usable = [q for q in quotes if q.amount and q.price is not None] + + if not usable: + return None + + biggest_pack = max(usable, key=lambda q: q.amount) + + unit_price = biggest_pack.price / biggest_pack.amount + estimated_price = unit_price * required_amount + + instance = CataloguePriceModel( + catalogue_compound_id=biggest_pack._instance.catalogue_compound_id, + vendor=biggest_pack._instance.vendor, + supplier=biggest_pack.supplier, + amount=required_amount, + price=estimated_price, + currency=biggest_pack.currency, + purity=biggest_pack._instance.purity, + lead_time=biggest_pack.lead_time, + ) + + return cls(instance) + + ### PROPERTIES + + @property + def id(self) -> int | None: + """Database ID of the wrapped quote, or ``None`` for an estimate""" + return self._instance.pk + + @property + def is_estimate(self) -> bool: + """``True`` if this quote is an estimate not backed by a saved row""" + return self._instance.pk is None + + @property + def price(self) -> float | None: + """Price amount (currency-less float), see :attr:`.as_price`""" + return self._instance.price + + @property + def currency(self) -> str | None: + """Currency of the price""" + return self._instance.currency + + @property + def as_price(self) -> 'Price': + """The price as a :class:`.Price` object""" + return Price(self._instance.price, self._instance.currency) + + @property + def amount(self) -> float | None: + """Quoted amount in ``mg``""" + return self._instance.amount + + @property + def supplier(self) -> str | None: + """Supplier of the quote""" + return self._instance.supplier + + @property + def vendor(self) -> str | None: + """Vendor of the quote""" + return self._instance.vendor + + @property + def lead_time(self) -> int | None: + """Lead time of the quote (in days)""" + return self._instance.lead_time + + ### DUNDERS + + def __str__(self) -> str: + """Plain string representation""" + tag = 'estimate' if self.is_estimate else f'Q{self.id}' + return f'{tag}: {self.as_price} for {self.amount}mg' + + def __repr__(self) -> str: + """ANSI formatted string representation""" + return f'{mcol.bold}{mcol.underline}{str(self)}{mcol.unbold}{mcol.ununderline}' + + def __rich__(self) -> str: + """Representation for mrich""" + return f'[bold underline]{str(self)}' diff --git a/hippo/designdb/components/reaction.py b/hippo/designdb/components/reaction.py new file mode 100644 index 00000000..32aa54dd --- /dev/null +++ b/hippo/designdb/components/reaction.py @@ -0,0 +1,191 @@ +"""Reaction component.""" + +from typing import TYPE_CHECKING + +import mcol +import mrich +from designdb.models import ( + CataloguePriceCompoundJunctionModel, + CompoundModel, + ReactionModel, +) + +from .compound import Compound + +if TYPE_CHECKING: + from designdb.recipe import Recipe + from designdb.sets.reaction import ReactionSet + +DEFAULT_REACTANT_AMOUNT = 1.0 +DEFAULT_PRODUCT_YIELD = 1.0 + + +class Reaction: + """A :class:`.Reaction` wraps a :class:`.ReactionModel` and represents a + synthetic step from reactant :class:`.Compound` objects to a product.""" + + def __init__(self, instance: ReactionModel) -> None: + self._instance = instance + + ### PROPERTIES + + @property + def id(self) -> int: + """Returns the :class:`.Reaction` ID""" + return self._instance.pk + + @property + def type(self) -> str: + """Returns the reaction type string""" + return self._instance.reaction_type + + @property + def product_yield(self) -> float: + """Returns the reaction product yield (fraction)""" + return self._instance.reaction_product_yield + + @property + def product(self) -> 'Compound': + """Returns the product as a :class:`.Compound` component""" + return Compound(self._instance.product_compound) + + @property + def reactants(self) -> 'list[Compound]': + """Returns the reactant :class:`.Compound` components""" + return [Compound(r.compound) for r in self._instance.reactants.all()] + + @property + def reactant_ids(self) -> list[int]: + """Returns the reactant :class:`.CompoundModel` PKs""" + return list(self._instance.reactants.values_list('compound_id', flat=True)) + + @property + def product_smiles(self) -> str: + """Returns the product compound SMILES""" + return self._instance.product_compound.compound_smiles + + @property + def reaction_str(self) -> str: + """Returns a human-readable reaction string""" + s = ' + '.join(str(r) for r in self.reactants) + return f'{s} -> {self.product}' + + @property + def plain_repr(self) -> str: + """Unformatted long string representation""" + return f'{self}: {self.reaction_str} via {self.type}' + + ### METHODS + + def get_reactant_amount_pairs(self, compound_object: bool = True) -> list[tuple]: + """Returns pairs of (compound, amount) for each reactant. + + :param compound_object: return :class:`.Compound` objects instead of IDs + """ + pairs = [ + (cid, amount if amount is not None else DEFAULT_REACTANT_AMOUNT) + for cid, amount in self._instance.reactants.values_list( + 'compound_id', 'reactant_amount' + ) + ] + if not pairs: + return [] + if compound_object: + return [ + (Compound(CompoundModel.objects.get(pk=cid)), amount) + for cid, amount in pairs + ] + return pairs + + def check_reactant_availability( + self, + supplier: str | None = None, + debug: bool = False, + ) -> bool: + """Check that every reactant either has a catalogue price or can be synthesised. + + :param supplier: restrict price check to this supplier + :param debug: increase verbosity + """ + for reactant_model in self._instance.reactants.all(): + compound = reactant_model.compound + + if debug: + mrich.var('reactant', compound.pk) + + if supplier: + has_quote = CataloguePriceCompoundJunctionModel.objects.filter( + compound=compound, + catalogue_price__supplier=supplier, + ).exists() + else: + has_quote = CataloguePriceCompoundJunctionModel.objects.filter( + compound=compound, + ).exists() + + has_reaction = ReactionModel.objects.filter( + product_compound=compound + ).exists() + + if debug: + mrich.debug(f'{has_quote=}, {has_reaction=}') + + if not has_quote and not has_reaction: + if debug: + mrich.warning(f'No quote or reaction for reactant pk={compound.pk}') + return False + + return True + + def get_recipes( + self, + amount: float = 1, + debug: bool = False, + pick_cheapest: bool = False, + permitted_reactions: 'ReactionSet | None' = None, + supplier: str | None = None, + ) -> 'Recipe | list[Recipe]': + """Get a :class:`.Recipe` for this reaction. + + :param amount: amount in mg + """ + # convenience bridge to the service layer (local import keeps the + # component -> service dependency out of the module import graph) + from designdb.services.recipe import RecipeService + + return RecipeService.from_reaction( + self._instance, + amount=amount, + debug=debug, + pick_cheapest=pick_cheapest, + permitted_reactions=permitted_reactions, + supplier=supplier, + ) + + ### DUNDERS + + def __str__(self) -> str: + return f'R{self.id}' + + def __repr__(self) -> str: + return ( + f'{mcol.bold}{mcol.underline}{self.plain_repr}' + f'{mcol.unbold}{mcol.ununderline}' + ) + + def __rich__(self) -> str: + return f'[bold underline]{self.plain_repr}' + + def __eq__(self, other: 'int | Reaction | ReactionModel') -> bool: + match other: + case int(): + return self.id == other + case Reaction(): + return self._instance == other._instance + case ReactionModel(): + return self._instance == other + case _: + raise NotImplementedError + + def __hash__(self) -> int: + return self.id diff --git a/hippo/designdb/interactions.py b/hippo/designdb/interactions.py new file mode 100644 index 00000000..45f00cd8 --- /dev/null +++ b/hippo/designdb/interactions.py @@ -0,0 +1,42 @@ +"""Constants for protein-ligand interaction detection. + +Feature families and the complementary-feature / interaction-type maps are +re-exported from ``molparse``; the distance/angle cutoffs are defined here. +""" + +from molparse.rdkit.features import ( + COMPLEMENTARY_FEATURES, + FEATURE_FAMILIES, + INTERACTION_TYPES, +) + +# maximum centroid-centroid distance (Angstrom) for each interaction type +INTERACTION_CUTOFF = { + 'Hydrophobic': 4.5, + 'Hydrogen Bond': 3.5, + 'Electrostatic': 4.5, + 'π-stacking': 6.0, + 'π-cation': 4.5, + # https://pubs.acs.org/doi/full/10.1021/acs.cgd.5b01058 + 'Sulfur-Sulfur': 4.0, +} + +# π-stacking geometry cutoffs (Angstrom) +PI_STACK_MIN_CUTOFF = 3.8 +PI_STACK_F2F_CUTOFF = 4.5 +PI_STACK_E2F_CUTOFF = 6.0 + +# warn (rather than silently skip) when a residue mismatch (mutation) occurs +# within this distance (Angstrom) of any ligand feature +MUTATION_WARNING_DIST = 15 + +__all__ = [ + 'FEATURE_FAMILIES', + 'COMPLEMENTARY_FEATURES', + 'INTERACTION_TYPES', + 'INTERACTION_CUTOFF', + 'PI_STACK_MIN_CUTOFF', + 'PI_STACK_F2F_CUTOFF', + 'PI_STACK_E2F_CUTOFF', + 'MUTATION_WARNING_DIST', +] diff --git a/hippo/designdb/managers.py b/hippo/designdb/managers.py new file mode 100644 index 00000000..97fa8909 --- /dev/null +++ b/hippo/designdb/managers.py @@ -0,0 +1,40 @@ +from django.apps import apps +from django.db.models import Manager, QuerySet +from rdkit import Chem + +from .utils import registration_hash_tautomer_insensitive, superparent + + +class CompoundQueryset(QuerySet): + def filter_qs(self): + CompoundModel = apps.get_model('designdb', 'CompoundModel') + qs = CompoundModel.objects.all() + return qs + + def get_by_smiles(self, smiles): + mol = Chem.MolFromSmiles(smiles, sanitize=True) + try: + sp = superparent(mol) + except Exception as e: + raise ValueError(f'SuperParent failed: {e}') from e + + h = registration_hash_tautomer_insensitive(sp) + + return self.filter_qs().get(compound_hash=h) + + +class CompoundManager(Manager): + def get_queryset(self): + return CompoundQueryset(self.model, using=self._db) + + # probably not needed here? + def get_by_smiles(self, smiles): + mol = Chem.MolFromSmiles(smiles, sanitize=True) + try: + sp = superparent(mol) + except Exception as e: + raise ValueError(f'SuperParent failed: {e}') from e + + h = registration_hash_tautomer_insensitive(sp) + + return self.get_queryset().get(compound_hash=h) diff --git a/hippo/designdb/models.py b/hippo/designdb/models.py new file mode 100644 index 00000000..b9fdc041 --- /dev/null +++ b/hippo/designdb/models.py @@ -0,0 +1,1033 @@ +from pathlib import Path + +import mrich + +# from django.db.models import indexes +from django.conf import settings +from django.db import models +from django.db.models import Q +from django.utils import timezone +from rdkit import Chem + +from .managers import CompoundManager + +_MANAGE_MODELS = settings.MANAGE_MODELS + + +# Custom field type for text fields that store json. Once switchint to +# postgres, replace +class JSONTextField(models.TextField): + def from_db_value(self, value, expression, connection): + import json + + return json.loads(value) if value else {} + + def get_prep_value(self, value): + import json + + if isinstance(value, dict): + return json.dumps(value) + return value + + +class RDKitMolField(models.TextField): + """ + Stores RDKit molecules as MolBlock text (SDF format) in DB, + but returns RDKit Mol objects in Python. + """ + + description = 'RDKit molecule stored as MolBlock text' + + # ------------------------- + # DB → Python (read path) + # ------------------------- + def from_db_value(self, value, expression, connection): + if not value: + return None + return Chem.MolFromMolBlock(value) + + # ------------------------- + # Python → DB (write path) + # ------------------------- + def get_prep_value(self, value): + if value is None: + return None + + # Already serialized + if isinstance(value, str): + return value + + # RDKit Mol → MolBlock + if isinstance(value, Chem.Mol): + return Chem.MolToMolBlock(value) + + raise TypeError( + 'RDKitMolField only accepts RDKit Mol or MolBlock string, ' + f'got {type(value)}' + ) + + def deconstruct(self): + name, path, args, kwargs = super().deconstruct() + return name, path, args, kwargs + + +if settings.MANAGE_MODELS: + # sqlite3: the RDKit cartridge field types are unavailable, fall back to the + # plain-text RDKitMolField shim defined above. + MolField = RDKitMolField +else: + from django_rdkit.models import MolField + + +class BaseModel(models.Model): + created_on = models.DateTimeField(null=True, blank=True, default=timezone.now) + updated_on = models.DateTimeField(null=True, blank=True, default=timezone.now) + + class Meta: + abstract = True + managed = _MANAGE_MODELS + app_label = 'designdb' + default_related_name = '%(class)ss' + + +class Project(BaseModel): + project_name = models.TextField(null=False, unique=True) + open_to_public = models.BooleanField(default=False) + + class Meta(BaseModel.Meta): + db_table = 'projects' + constraints = [ + models.UniqueConstraint( + fields=[ + 'project_name', + ], + name='uc_project', + ), + ] + + def __str__(self) -> str: + return f'{self.project_name}' + + +class TargetModel(BaseModel): + id = models.BigAutoField(primary_key=True) + external_target_id = models.BigIntegerField(null=True, blank=True) + target_name = models.TextField() + target_metadata = models.TextField(null=True, blank=True) + project = models.ForeignKey( + Project, + on_delete=models.RESTRICT, + db_column='project_id', + ) + + class Meta(BaseModel.Meta): + db_table = 'targets' + constraints = [ + models.UniqueConstraint( + fields=[ + 'target_name', + ], + name='uc_target', + ), + ] + indexes = [ + models.Index(fields=['target_name'], name='idx_target_name'), + models.Index(fields=['created_on'], name='idx_target_created'), + ] + + +class CompoundModel(BaseModel): + id = models.BigAutoField(primary_key=True) + compound_inchikey = models.TextField(null=True, blank=True) + compound_alias = models.TextField(null=True, blank=True) + compound_smiles = models.TextField(null=True, blank=True) + compound_hash = models.TextField(null=False, blank=True, default='a') + + base_compound = models.ForeignKey( + 'self', + null=True, + blank=True, + on_delete=models.SET_NULL, + db_column='base_compound_id', + related_name='+', # add if needed + ) + + # compound_mol = models.TextField(null=True, blank=True) + # compound_pattern_bfp = models.TextField(null=True, blank=True) + # compound_morgan_bfp = models.TextField(null=True, blank=True) + # compound_mol = MolField(null=True) + compound_mol = models.TextField(null=True, blank=True) + # compound_pattern_bfp = BfpField(null=True) + # compound_morgan_bfp = BfpField(null=True) + compound_pattern_bfp = models.BinaryField(max_length=2048, null=True) + compound_morgan_bfp = models.BinaryField(max_length=2048, null=True) + + compound_metadata = models.TextField(null=True, blank=True) + note = models.TextField(null=True, blank=True) + rdkit_version = models.TextField(null=True, blank=True) + inchi_version = models.TextField(null=True, blank=True) + + tags = models.ManyToManyField( + 'CompoundTagModel', + through='CompoundTagJunctionModel', + related_name='compounds', + ) + + enumeration_methods = models.ManyToManyField( + 'EnumerationMethodModel', + through='CompoundEnumerationMethodJunctionModel', + related_name='compounds', + ) + + # unlike others, this wasn't clearly defined as m2m. may not want + # to keep it + scaffolds = models.ManyToManyField( + 'self', + through='ScaffoldModel', + ) + + objects = models.Manager() + compound_filter = CompoundManager() + + class Meta(BaseModel.Meta): + db_table = 'compounds' + constraints = [ + # I believe there were supposed to be changes to these + # models.UniqueConstraint( + # fields=[ + # 'compound_alias', + # ], + # name='uc_compound_alias', + # ), + models.UniqueConstraint( + fields=[ + 'compound_inchikey', + ], + name='uc_compound_inchikey', + ), + # tautomers mess this up + # models.UniqueConstraint( + # fields=[ + # 'compound_smiles', + # ], + # name='uc_compound_smiles', + # ), + ] + indexes = [ + # models.Index(fields=['base_compound'], name='idx_base_compound_id'), + models.Index(fields=['compound_inchikey'], name='idx_compound_inchikey'), + # models.Index(fields=['compound_smiles'], name='idx_compound_smiles'), + models.Index(fields=['created_on'], name='idx_compound_created'), + ] + + +class SubsiteModel(BaseModel): + id = models.BigAutoField(primary_key=True) + target = models.ForeignKey( + TargetModel, + on_delete=models.RESTRICT, + db_column='target_id', + ) + + subsite_name = models.TextField() + subsite_metadata = models.TextField(null=True, blank=True) + + class Meta(BaseModel.Meta): + db_table = 'subsites' + constraints = [ + models.UniqueConstraint( + fields=[ + 'target', + 'subsite_name', + ], + name='uc_subsite', + ), + ] + indexes = [ + models.Index(fields=['target'], name='idx_subsite_target_id'), + models.Index(fields=['created_on'], name='idx_subsite_created'), + ] + + +class PoseModel(BaseModel): + id = models.BigAutoField(primary_key=True) + + pose_inchikey = models.TextField(null=True, blank=True) + pose_alias = models.TextField(null=True, blank=True) + pose_smiles = models.TextField(null=True, blank=True) + + pose_reference = models.IntegerField(null=True, blank=True) + protein_link = models.TextField(null=True, blank=True) + + compound = models.ForeignKey( + CompoundModel, + on_delete=models.RESTRICT, + db_column='compound_id', + ) + + target = models.ForeignKey( + TargetModel, + on_delete=models.RESTRICT, + db_column='target_id', + ) + + # pose_mol = models.TextField(null=True, blank=True) + pose_mol = MolField(null=True) + # this is integer in the db.. pretty sure this cannot be the case? + pose_fingerprint = models.IntegerField(null=True, blank=True) + + # dicts dumped into that field, change to JSON? + # pose_metadata = models.TextField(null=True, blank=True) + pose_metadata = JSONTextField(null=True, blank=True) + # pose_metadata = models.JSONField(null=True, blank=True) + note = models.TextField(null=True, blank=True) + + rdkit_version = models.TextField(null=True, blank=True) + inchi_version = models.TextField(null=True, blank=True) + + methods = models.ManyToManyField( + 'PoseMethodModel', + through='PoseMethodJunctionModel', + related_name='poses', + ) + tags = models.ManyToManyField( + 'PoseTagModel', + through='PoseTagJunctionModel', + related_name='poses', + ) + # unlike others, this wasn't clearly defined as m2m. may not want + # to keep it + # through_fields: the calling pose is the derivative, the added pose is the original + inspirations = models.ManyToManyField( + 'self', + through='InspirationModel', + through_fields=('derivative_pose', 'original_pose'), + symmetrical=False, + ) + + subsites = models.ManyToManyField( + SubsiteModel, + through='SubsiteTagModel', + ) + + class Meta(BaseModel.Meta): + db_table = 'poses' + # There are no constraints here, but they need to be unique, + # verified in code (rdkit.align_pose coords from + # file). Investigate adding coords to db and doing the search + # there + + # although.. would alias-target combo work? + indexes = [ + models.Index(fields=['compound'], name='idx_pose_compound_id'), + models.Index(fields=['target'], name='idx_pose_target_id'), + models.Index(fields=['protein_link'], name='idx_protein_link'), + models.Index(fields=['created_on'], name='idx_pose_created'), + ] + + @property + def mol_path(self) -> Path | None: + """Get Path to molecule file""" + path = Path(self.protein_link) + if path.name.endswith('.pdb'): + mol_path = path.parent / path.name.replace('_hippo.pdb', '.pdb').replace( + '.pdb', '_ligand.mol' + ) + if not mol_path.exists(): + mol_path = path.parent / path.name.replace( + '_hippo.pdb', '.pdb' + ).replace('.pdb', '_ligand.sdf') + if not mol_path.exists(): + mrich.error('Could not find ligand mol/sdf:', mol_path) + return None + return mol_path + elif path.name.endswith('.mol'): + return path + else: + raise NotImplementedError + + @property + def apo_path(self) -> Path | None: + """Get path to the apo (de-liganded, desolvated) protein file""" + path = Path(self.protein_link) + if path.name.endswith('.pdb'): + stem = path.name.replace('_hippo.pdb', '.pdb') + # current Fragalysis protein-file naming + apo_path = path.parent / stem.replace('.pdb', '_delig-desolv.pdb') + if apo_path.exists(): + return apo_path + # DEPRECATED(apo-naming): pre-'delig' Fragalysis naming, remove once + # all data uses 'delig' + legacy_path = path.parent / stem.replace('.pdb', '_apo-desolv.pdb') + if legacy_path.exists(): + return legacy_path + return None + else: + raise NotImplementedError + + +class SubsiteTagModel(BaseModel): + id = models.BigAutoField(primary_key=True) + pose = models.ForeignKey( + PoseModel, + on_delete=models.RESTRICT, + db_column='pose_id', + ) + subsite = models.ForeignKey( + SubsiteModel, + on_delete=models.RESTRICT, + db_column='subsite_id', + ) + + subsite_tag_metadata = models.TextField(null=True, blank=True) + + class Meta(BaseModel.Meta): + db_table = 'subsite_tags' + constraints = [ + models.UniqueConstraint( + fields=[ + 'subsite', + 'pose', + ], + name='uc_subsite_tag', + ), + ] + indexes = [ + models.Index(fields=['subsite'], name='idx_subsite_tag_subsite_id'), + models.Index(fields=['pose'], name='idx_subsite_tag_pose_id'), + models.Index(fields=['created_on'], name='idx_subsite_tag_created'), + ] + + +class PoseMethodModel(BaseModel): + id = models.BigAutoField(primary_key=True) + pose_method_name = models.TextField(null=True, blank=True) + pose_method_description = models.TextField(null=True, blank=True) + pose_method_version = models.TextField(null=True, blank=True) + pose_method_organization = models.TextField(null=True, blank=True) + pose_method_link = models.TextField(null=True, blank=True) + pose_method_note = models.TextField(null=True, blank=True) + + class Meta(BaseModel.Meta): + db_table = 'pose_methods' + constraints = [ + models.UniqueConstraint( + fields=[ + 'pose_method_name', + 'pose_method_version', + ], + name='uc_pose_method', + nulls_distinct=False, + ) + ] + indexes = [ + models.Index(fields=['pose_method_name'], name='idx_pose_method_name'), + models.Index(fields=['created_on'], name='idx_pose_method_created'), + ] + + +class PoseMethodJunctionModel(BaseModel): + pk = models.CompositePrimaryKey('pose_id', 'pose_method_id') + pose = models.ForeignKey( + 'PoseModel', + on_delete=models.CASCADE, + db_column='pose_id', + ) + + pose_method = models.ForeignKey( + 'PoseMethodModel', + on_delete=models.CASCADE, + db_column='pose_method_id', + ) + + class Meta(BaseModel.Meta): + db_table = 'has_pose_methods' + indexes = [ + models.Index( + fields=['pose_method'], name='idx_has_pose_methods_pose_method_id' + ), + models.Index( + fields=['created_on'], name='idx_idx_has_pose_methods_created' + ), + ] + + +class PoseTagModel(BaseModel): + id = models.BigAutoField(primary_key=True) + pose_tag_name = models.TextField() + pose_tag_description = models.TextField(null=True, blank=True) + pose_tag_note = models.TextField(null=True, blank=True) + + class Meta(BaseModel.Meta): + db_table = 'pose_tags' + constraints = [ + models.UniqueConstraint( + fields=[ + 'pose_tag_name', + ], + name='uc_pose_tag', + ) + ] + indexes = [ + models.Index(fields=['created_on'], name='idx_pose_tag_created'), + ] + + +class PoseTagJunctionModel(BaseModel): + pk = models.CompositePrimaryKey('pose_id', 'pose_tag_id') + pose = models.ForeignKey( + PoseModel, + on_delete=models.CASCADE, + db_column='pose_id', + ) + pose_tag = models.ForeignKey( + PoseTagModel, + on_delete=models.CASCADE, + db_column='pose_tag_id', + ) + + class Meta(BaseModel.Meta): + db_table = 'has_pose_tags' + indexes = [ + models.Index(fields=['pose_tag'], name='idx_has_pose_tag_pose_tag_id'), + models.Index(fields=['created_on'], name='idx_has_pose_tag_created'), + ] + + +# this was missing.. is this a m2m table as well? really looks like it +class InspirationModel(BaseModel): + id = models.BigAutoField(primary_key=True) + # original behaviour described in schema was SET_NULL but I don't + # see how that makes sense. if either original or derivative is + # deleted, you'll have orphaned entries + original_pose = models.ForeignKey( + PoseModel, + # on_delete=models.SET_NULL, + on_delete=models.CASCADE, + db_column='original_pose_id', + related_name='+', + ) + derivative_pose = models.ForeignKey( + PoseModel, + # on_delete=models.SET_NULL, + on_delete=models.CASCADE, + db_column='derivative_pose_id', + related_name='+', + ) + + class Meta(BaseModel.Meta): + db_table = 'inspirations' + constraints = [ + models.UniqueConstraint( + fields=[ + 'original_pose', + 'derivative_pose', + ], + name='uc_inspiration', + ) + ] + indexes = [ + models.Index( + fields=['original_pose'], name='idx_inspiration_original_pose_id' + ), + models.Index( + fields=['derivative_pose'], name='idx_inspiration_derivative_pose_id' + ), + models.Index(fields=['created_on'], name='idx_inspiration_created'), + ] + + +class FeatureModel(BaseModel): + id = models.BigAutoField(primary_key=True) + feature_family = models.TextField(null=True, blank=True) + target = models.ForeignKey( + TargetModel, + on_delete=models.RESTRICT, + db_column='target_id', + ) + + feature_chain_name = models.TextField(null=True, blank=True) + feature_residue_name = models.TextField(null=True, blank=True) + feature_residue_number = models.IntegerField(null=True, blank=True) + feature_atom_name = models.TextField(null=True, blank=True) + + class Meta(BaseModel.Meta): + db_table = 'features' + constraints = [ + models.UniqueConstraint( + fields=[ + 'feature_family', + 'target', + 'feature_chain_name', + 'feature_residue_name', + 'feature_residue_number', + 'feature_atom_name', + ], + name='uc_feature', + ) + ] + indexes = [ + models.Index(fields=['target'], name='idx_feature_target_id'), + models.Index(fields=['created_on'], name='idx_feature_created'), + ] + + +class InteractionModel(BaseModel): + id = models.BigAutoField(primary_key=True) + feature = models.ForeignKey( + FeatureModel, + on_delete=models.RESTRICT, + db_column='feature_id', + ) + pose = models.ForeignKey( + PoseModel, + on_delete=models.RESTRICT, + db_column='pose_id', + ) + + interaction_type = models.TextField() + interaction_family = models.TextField() + interaction_atom_id = models.TextField() + + # could these be vectors? + interaction_prot_coord = models.TextField() + interaction_lig_coord = models.TextField() + + interaction_distance = models.FloatField() + interaction_angle = models.FloatField(null=True, blank=True) + interaction_energy = models.FloatField(null=True, blank=True) + + class Meta(BaseModel.Meta): + db_table = 'interactions' + constraints = [ + models.UniqueConstraint( + fields=[ + 'feature', + 'pose', + 'interaction_type', + 'interaction_family', + 'interaction_atom_id', + ], + name='uc_interaction', + ) + ] + indexes = [ + models.Index(fields=['feature_id'], name='idx_interaction_feature_id'), + models.Index(fields=['pose'], name='idx_interaction_pose_id'), + models.Index(fields=['created_on'], name='idx_interaction_created'), + ] + + +class CompoundTagModel(BaseModel): + id = models.BigAutoField(primary_key=True) + compound_tag_name = models.TextField() + compound_tag_description = models.TextField(null=True, blank=True) + compound_tag_note = models.TextField(null=True, blank=True) + + class Meta(BaseModel.Meta): + db_table = 'compound_tags' + constraints = [ + models.UniqueConstraint( + fields=[ + 'compound_tag_name', + ], + name='uc_compound_tag_name', + ) + ] + indexes = [ + models.Index(fields=['created_on'], name='idx_compound_tag_created'), + ] + + +class CompoundTagJunctionModel(BaseModel): + pk = models.CompositePrimaryKey('compound_id', 'compound_tag_id') + compound = models.ForeignKey( + CompoundModel, + on_delete=models.CASCADE, + db_column='compound_id', + ) + compound_tag = models.ForeignKey( + CompoundTagModel, + on_delete=models.CASCADE, + db_column='compound_tag_id', + ) + + class Meta(BaseModel.Meta): + db_table = 'has_compound_tags' + indexes = [ + models.Index( + fields=['compound_tag'], name='idx_has_compound_tag_compound_tag_id' + ), + models.Index(fields=['created_on'], name='idx_has_compound_tag_created'), + ] + + +class EnumerationMethodModel(BaseModel): + id = models.BigAutoField(primary_key=True) + enum_name = models.TextField(null=True, blank=True) + enum_description = models.TextField(null=True, blank=True) + enum_version = models.TextField(null=True, blank=True) + enum_organization = models.TextField(null=True, blank=True) + enum_link = models.TextField(null=True, blank=True) + enum_note = models.TextField(null=True, blank=True) + + class Meta(BaseModel.Meta): + db_table = 'enumeration_methods' + constraints = [ + models.UniqueConstraint( + fields=[ + 'enum_name', + 'enum_version', + ], + name='uc_enumeration_method', + nulls_distinct=False, + ) + ] + indexes = [ + models.Index(fields=['enum_name'], name='idx_enumeration_method_name'), + models.Index(fields=['created_on'], name='idx_enumeration_method_created'), + ] + + +class CompoundEnumerationMethodJunctionModel(BaseModel): + pk = models.CompositePrimaryKey('compound_id', 'enumeration_method_id') + compound = models.ForeignKey( + CompoundModel, + on_delete=models.CASCADE, + db_column='compound_id', + ) + enumeration_method = models.ForeignKey( + EnumerationMethodModel, + on_delete=models.CASCADE, + db_column='enumeration_method_id', + ) + + class Meta(BaseModel.Meta): + db_table = 'has_enumeration_methods' + indexes = [ + models.Index( + fields=['enumeration_method'], + name='idx_has_enumeration_methods_enumeration_method_id', + ), + models.Index( + fields=['created_on'], name='idx_has_enumeration_methods_created' + ), + ] + + +class ScoringMethodModel(BaseModel): + id = models.BigAutoField(primary_key=True) + method_name = models.TextField(null=True, blank=True) + method_description = models.TextField(null=True, blank=True) + method_version = models.TextField(null=True, blank=True) + method_organization = models.TextField(null=True, blank=True) + method_link = models.TextField(null=True, blank=True) + note = models.TextField(null=True, blank=True) + + class Meta(BaseModel.Meta): + db_table = 'scoring_methods' + constraints = [ + models.UniqueConstraint( + fields=[ + 'method_name', + 'method_version', + ], + name='uc_scoring_method', + nulls_distinct=False, + ) + ] + indexes = [ + models.Index(fields=['method_name'], name='idx_scoring_method_name'), + models.Index(fields=['created_on'], name='idx_scoring_method_created'), + ] + + +class ScoreValueModel(BaseModel): + pk = models.CompositePrimaryKey('pose_id', 'compound_id', 'scoring_method_id') + pose = models.ForeignKey( + PoseModel, + on_delete=models.RESTRICT, + db_column='pose_id', + related_name='scores', + ) + + compound = models.ForeignKey( + CompoundModel, + on_delete=models.RESTRICT, + db_column='compound_id', + related_name='scores', + ) + + scoring_method = models.ForeignKey( + ScoringMethodModel, + on_delete=models.RESTRICT, + db_column='scoring_method_id', + related_name='scores', + ) + + score = models.JSONField() + + class Meta(BaseModel.Meta): + db_table = 'score_values' + indexes = [ + models.Index(fields=['pose'], name='idx_score_values_pose_id'), + models.Index(fields=['compound'], name='idx_score_values_compound_id'), + models.Index( + fields=['scoring_method'], name='idx_score_values_scoring_method_id' + ), + models.Index(fields=['created_on'], name='idx_score_values_created'), + ] + + +class ReactionModel(BaseModel): + id = models.BigAutoField(primary_key=True) + reaction_type = models.TextField(null=True, blank=True) + product_compound = models.ForeignKey( + CompoundModel, + on_delete=models.RESTRICT, + db_column='product_compound_id', + ) + + reaction_product_yield = models.FloatField(null=True, blank=True) + reaction_metadata = models.TextField(null=True, blank=True) + + class Meta(BaseModel.Meta): + db_table = 'reactions' + indexes = [ + models.Index( + fields=['product_compound'], name='idx_reaction_product_compound_id' + ), + models.Index(fields=['created_on'], name='idx_reaction_created'), + ] + + +class ReactantModel(BaseModel): + id = models.BigAutoField(primary_key=True) + reactant_amount = models.FloatField(null=True, blank=True) + reaction = models.ForeignKey( + ReactionModel, + on_delete=models.CASCADE, + db_column='reaction_id', + related_name='reactants', + ) + compound = models.ForeignKey( + CompoundModel, + on_delete=models.RESTRICT, + db_column='compound_id', + ) + + class Meta(BaseModel.Meta): + db_table = 'reactants' + constraints = [ + models.UniqueConstraint( + fields=[ + 'reaction', + 'compound', + ], + name='uc_reactant', + ) + ] + indexes = [ + models.Index(fields=['reaction'], name='idx_reactant_reaction_id'), + models.Index(fields=['compound'], name='idx_reactant_compound_id'), + models.Index(fields=['created_on'], name='idx_reactant_created'), + ] + + +class CatalogueCompoundModel(BaseModel): + id = models.BigAutoField(primary_key=True) + catalogue_smiles = models.TextField(null=False, blank=True) + catalogue_inchikey = models.TextField(null=False, blank=True) + catalogue_hash = models.TextField(null=False, blank=True) + rdkit_version = models.TextField(null=True, blank=True) + inchi_version = models.TextField(null=True, blank=True) + + class Meta(BaseModel.Meta): + db_table = 'catalogue_compounds' + constraints = [ + models.UniqueConstraint( + fields=[ + 'catalogue_smiles', + ], + name='uq_catalogue_compounds_smiles', + ), + models.CheckConstraint( + condition=Q(catalogue_hash__isnull=False) & Q(catalogue_hash__gt=''), + name='ck_catalogue_compounds_hash_nonempty', + ), + ] + + +class CataloguePriceModel(BaseModel): + id = models.BigAutoField(primary_key=True) + catalogue_compound = models.ForeignKey( + CatalogueCompoundModel, + null=True, + on_delete=models.CASCADE, + db_column='catalogue_id', + ) + vendor = models.TextField(null=False, blank=True) + supplier = models.TextField(null=True, blank=True) + supplier_id = models.TextField(null=False, blank=True) + amount = models.FloatField(null=True, blank=True) + price = models.FloatField(null=True, blank=True) + currency = models.TextField(null=True, blank=True) + purity = models.FloatField(null=True, blank=True) + lead_time = models.IntegerField(null=True, blank=True) + + compounds = models.ManyToManyField( + CompoundModel, + through='CataloguePriceCompoundJunctionModel', + related_name='prices', + ) + + class Meta(BaseModel.Meta): + db_table = 'catalogue_prices' + constraints = [ + models.UniqueConstraint( + fields=[ + 'catalogue_compound', + 'vendor', + 'supplier', + 'supplier_id', + 'amount', + ], + name='uc_catalogue_price', + ) + ] + + +class CataloguePriceCompoundJunctionModel(BaseModel): + ipk = models.CompositePrimaryKey('compound_id', 'catalogue_price_id') + catalogue_price = models.ForeignKey( + CataloguePriceModel, + on_delete=models.CASCADE, + db_column='catalogue_price_id', + ) + compound = models.ForeignKey( + CompoundModel, + on_delete=models.CASCADE, + db_column='compound_id', + ) + + match_hash = models.TextField(null=False, blank=True) + + # Not needed, remove + # catalogue_inchikey = models.TextField(null=False, blank=True) + # supplier = models.TextField(null=True, blank=True) + # amount = models.FloatField(null=True, blank=True) + # price = models.FloatField(null=True, blank=True) + # lead_time = models.IntegerField(null=True, blank=True) + + class Meta(BaseModel.Meta): + db_table = 'compound_catalogue_map' + constraints = [ + models.CheckConstraint( + condition=Q(match_hash__isnull=False) & Q(match_hash__gt=''), + name='ck_compound_catalogue_map_match_hash_nonempty', + ) + ] + + +class ScaffoldModel(BaseModel): + id = models.BigAutoField(primary_key=True) + # same comment as with inspiratons. original schema says SET_NULL + # but doesn't seem right + base_compound = models.ForeignKey( + CompoundModel, + # on_delete=models.SET_NULL, + on_delete=models.CASCADE, + db_column='base_compound_id', + related_name='scaffold_bases', + ) + superstructure_compound = models.ForeignKey( + CompoundModel, + # on_delete=models.SET_NULL, + on_delete=models.CASCADE, + db_column='superstructure_compound_id', + related_name='scaffold_superstructures', + ) + + class Meta(BaseModel.Meta): + db_table = 'scaffolds' + constraints = [ + models.UniqueConstraint( + fields=[ + 'base_compound', + 'superstructure_compound', + ], + name='uc_scaffold', + ) + ] + indexes = [ + models.Index( + fields=['base_compound'], name='idx_scaffold_base_compound_id' + ), + models.Index( + fields=['superstructure_compound'], + name='idx_scaffold_superstructure_compound_id', + ), + models.Index(fields=['created_on'], name='idx_scaffold_created'), + ] + + +class RouteModel(BaseModel): + id = models.BigAutoField(primary_key=True) + product_compound = models.ForeignKey( + CompoundModel, + on_delete=models.RESTRICT, + db_column='product_compound_id', + ) + + class Meta(BaseModel.Meta): + db_table = 'routes' + indexes = [ + models.Index( + fields=['product_compound'], name='idx_route_product_compound_id' + ), + models.Index(fields=['created_on'], name='idx_route_created'), + ] + + +class ComponentModel(BaseModel): + id = models.BigAutoField(primary_key=True) + route = models.ForeignKey( + RouteModel, + on_delete=models.RESTRICT, + db_column='route_id', + ) + component_type = models.IntegerField(null=True, blank=True) + component_ref = models.IntegerField(null=True, blank=True) + component_amount = models.FloatField(null=True, blank=True) + + class Meta(BaseModel.Meta): + db_table = 'components' + constraints = [ + models.UniqueConstraint( + fields=[ + 'route', + 'component_ref', + 'component_type', + ], + name='uc_component', + ) + ] + indexes = [ + models.Index(fields=['route'], name='idx_component_route_id'), + models.Index(fields=['created_on'], name='idx_component_created'), + ] + + +# what follows is audit tables, indexes, materialised views (none), +# views, functions and triggers. I'm not sure I need them here, will create if do. + + +# these functions available in db +# CREATE OR REPLACE FUNCTION designdb.mol_from_smiles(smiles TEXT) RETURNS rdkit.mol +# LANGUAGE SQL AS $$ SELECT rdkit.mol_from_smiles(smiles::cstring); $$; + +# CREATE OR REPLACE FUNCTION designdb.mol_to_smiles(m rdkit.mol) RETURNS text +# LANGUAGE SQL AS $$ SELECT rdkit.mol_to_smiles(m); $$; + +# CREATE OR REPLACE FUNCTION designdb.mol_to_inchikey(m rdkit.mol) RETURNS text +# LANGUAGE SQL AS $$ SELECT rdkit.mol_inchikey(m); $$; diff --git a/hippo/designdb/plotting.py b/hippo/designdb/plotting.py new file mode 100644 index 00000000..e4402fb9 --- /dev/null +++ b/hippo/designdb/plotting.py @@ -0,0 +1,115 @@ +"""Plotting helpers (plotly figures) operating on sets.""" + +import mrich + +HIPPO_HEAD_URL = ( + 'https://raw.githubusercontent.com/mwinokan/HIPPO/main/logos/hippo_assets-02.png' +) + + +def add_punchcard_logo(fig): + """Add the HIPPO logo to a punch-card figure.""" + fig.add_layout_image( + dict( + source=HIPPO_HEAD_URL, + xref='paper', + yref='paper', + x=1, + y=1, + sizex=0.25, + sizey=0.25, + xanchor='right', + yanchor='top', + ) + ) + return fig + + +def plot_interaction_punchcard( + poses, + *, + title_prefix: str | None = None, + subtitle: str | None = None, + opacity: float = 1.0, + group: str = 'pose_name', + ignore_chains: bool = False, +): + """Interaction punch-card (residue vs. feature family) for a :class:`.PoseSet`. + + :param poses: the :class:`.PoseSet` to plot + :param title_prefix: bold prefix for the title (e.g. the target name) + :param subtitle: optional subtitle + :param opacity: marker opacity + :param group: column to colour points by (default: per-pose) + :param ignore_chains: drop the chain from the residue axis label + """ + import plotly.express as px + import plotly.graph_objects as go + + iset = poses.interactions + mrich.var('#poses', len(poses)) + mrich.var('#interactions', len(iset)) + + plot_data = iset.df + if plot_data.empty: + mrich.warning('No interactions to plot') + return None + + name_lookup = poses.id_name_dict + plot_data['pose_name'] = [name_lookup.get(i) for i in plot_data['pose_id'].values] + + if ignore_chains: + x = 'res_name_number' + plot_data[x] = plot_data[['residue_name', 'residue_number']].agg( + lambda r: ' '.join(str(i) for i in r), axis=1 + ) + + def sort_key(v): + return v[1] + else: + x = 'chain_res_name_number_str' + plot_data[x] = plot_data[['chain_name', 'residue_name', 'residue_number']].agg( + lambda r: ' '.join(str(i) for i in r), axis=1 + ) + + def sort_key(v): + return (v[2], v[1]) + + title = 'Interaction Punch-Card' + if title_prefix: + title = f'{title_prefix}: {title}' + if subtitle: + title += f'
{subtitle}' + + fig = px.scatter( + plot_data, + x=x, + y='type', + marginal_x='histogram', + marginal_y='histogram', + hover_data=plot_data.columns, + color=group, + title=title, + ) + + fig.update_layout(title=title, title_automargin=False, title_yref='container') + fig.update_layout(xaxis_title='Residue', yaxis_title='Feature Family') + + # order the residue axis by (residue_number, chain) + categoryarray = plot_data[[x, 'residue_number', 'chain_name']].agg(tuple, axis=1) + categoryarray = [v[0] for v in sorted(categoryarray.values, key=sort_key)] + fig.update_xaxes(categoryorder='array', categoryarray=categoryarray) + fig.update_yaxes(categoryorder='category descending') + + for trace in fig.data: + if isinstance(trace, go.Histogram): + trace.opacity = 1 + trace.xbins.size = 1 + else: + trace['marker']['size'] = 10 + trace['marker']['opacity'] = opacity + + fig.update_layout(barmode='stack') + fig.update_layout(scattermode='group', scattergap=0.75) + + return add_punchcard_logo(fig) diff --git a/hippo/designdb/recipe.py b/hippo/designdb/recipe.py new file mode 100644 index 00000000..850b039c --- /dev/null +++ b/hippo/designdb/recipe.py @@ -0,0 +1,1212 @@ +"""Classes for working with Recipes (reaction networks). + +A :class:`.Recipe` is a lean *aggregate*: it holds the products, reactants, +intermediates, reactions and (no-chem) compounds that make up a synthetic recipe, +and exposes price/serialisation/presentation on top of them. + +Construction and DB-traversal *orchestration* lives in :class:`.RecipeService`. +The ``from_*`` and export methods on :class:`.Recipe` are **deprecated shims** that +delegate to it (see the ``DEPRECATED`` banner below) via a local import. +""" + +from typing import TYPE_CHECKING + +import mcol +import mrich +from designdb.components.compound import Ingredient +from designdb.components.reaction import Reaction +from designdb.models import ComponentModel, CompoundModel, ReactionModel, RouteModel +from designdb.sets.compound import CompoundSet +from designdb.sets.ingredient import IngredientSet +from designdb.sets.reaction import ReactionSet + +if TYPE_CHECKING: + from pathlib import Path + + import pandas + from designdb.components.price import Price + from designdb.sets.interaction import InteractionSet + from designdb.sets.pose import PoseSet + from designdb.sets.route import RouteSet + from plotly import graph_objects + + +class Recipe: + """A Recipe stores data corresponding to a specific synthetic recipe involving + several products, reactants, intermediates, and reactions.""" + + def __init__( + self, + *, + products: 'IngredientSet | None' = None, + reactants: 'IngredientSet | None' = None, + intermediates: 'IngredientSet | None' = None, + reactions: 'ReactionSet | None' = None, + compounds: 'IngredientSet | None' = None, + ) -> None: + """Recipe initialisation""" + + if products is None: + products = IngredientSet() + if reactants is None: + reactants = IngredientSet() + if intermediates is None: + intermediates = IngredientSet() + if compounds is None: + compounds = IngredientSet() + if reactions is None: + reactions = ReactionSet() + + # check typing + assert isinstance(products, IngredientSet) + assert isinstance(reactants, IngredientSet) + assert isinstance(intermediates, IngredientSet) + assert isinstance(compounds, IngredientSet) + assert isinstance(reactions, ReactionSet) + + self._products = products + self._reactants = reactants + self._intermediates = intermediates + self._reactions = reactions + self._compounds = compounds + self._hash = None + + self._score = None + + # caches + self._product_compounds = None + self._poses = None + self._interactions = None + self._combined_compounds = None + + ### DEPRECATED — construction shims (relocated to RecipeService) + # These delegate to designdb.services.recipe.RecipeService and exist only to + # keep legacy `Recipe.from_*(...)` call sites working during the migration. + + @classmethod + def from_reaction(cls, *args, **kwargs): + """DEPRECATED: use :meth:`.RecipeService.from_reaction`.""" + from designdb.services.recipe import RecipeService + + return RecipeService.from_reaction(*args, **kwargs) + + @classmethod + def from_reactions(cls, *args, **kwargs): + """DEPRECATED: use :meth:`.RecipeService.from_reactions`.""" + from designdb.services.recipe import RecipeService + + return RecipeService.from_reactions(*args, **kwargs) + + @classmethod + def from_compounds(cls, *args, **kwargs): + """DEPRECATED: use :meth:`.RecipeService.from_compounds`.""" + from designdb.services.recipe import RecipeService + + return RecipeService.from_compounds(*args, **kwargs) + + @classmethod + def from_reactants(cls, *args, **kwargs): + """DEPRECATED: use :meth:`.RecipeService.from_reactants`.""" + from designdb.services.recipe import RecipeService + + return RecipeService.from_reactants(*args, **kwargs) + + ### FACTORIES + + @classmethod + def from_json( + cls, + path: 'str | Path | None' = None, + *, + data: dict | None = None, + clear_quotes: bool = False, + debug: bool = False, + ) -> 'Recipe': + """Load a serialised recipe from a JSON file (see :meth:`.Recipe.get_dict`). + + :param path: path to JSON (ignored if ``data`` is provided) + :param data: pre-loaded serialised data (Default value = None) + :param clear_quotes: ignore stored reactant/compound quotes + :param debug: increase verbosity + """ + + import json + + if not data: + if debug: + mrich.reading(path) + data = json.load(open(path)) + + if debug and 'timestamp' in data: + mrich.print(f'Recipe was generated at: {data["timestamp"]}') + + # IngredientSets are stored column-oriented (df.to_dict(orient='list')) + products = IngredientSet.from_json(path=None, data=data['products']) + intermediates = IngredientSet.from_json(path=None, data=data['intermediates']) + reactants = IngredientSet.from_json( + path=None, data=data['reactants'], supplier=data.get('reactant_supplier') + ) + + if 'compounds' in data: + compounds = IngredientSet.from_json( + path=None, + data=data['compounds'], + supplier=data.get('compound_supplier'), + ) + else: + compounds = IngredientSet() + + if clear_quotes: + for iset in (reactants, compounds): + iset.df['quote_id'] = None + iset.df['quoted_amount'] = None + + reactions = ReactionSet(data['reaction_ids'], sort=False) + + if debug: + mrich.var('reactants', reactants) + mrich.var('intermediates', intermediates) + mrich.var('products', products) + mrich.var('reactions', reactions) + mrich.var('compounds', compounds) + + return cls( + products=products, + reactants=reactants, + intermediates=intermediates, + reactions=reactions, + compounds=compounds, + ) + + ### PROPERTIES + + @property + def products(self) -> 'IngredientSet': + """Product :class:`.IngredientSet`""" + return self._products + + @products.setter + def products(self, a: 'IngredientSet'): + """Set the products""" + self._products = a + self.__flag_modification() + + @property + def compounds(self) -> 'IngredientSet': + """No-chem (directly purchased) :class:`.IngredientSet`""" + return self._compounds + + @compounds.setter + def compounds(self, a: 'IngredientSet'): + """Set the compounds""" + self._compounds = a + self.__flag_modification() + + @property + def reactants(self) -> 'IngredientSet': + """Reactant :class:`.IngredientSet`""" + return self._reactants + + @reactants.setter + def reactants(self, a: 'IngredientSet'): + """Set the reactants""" + self._reactants = a + self.__flag_modification() + + @property + def intermediates(self) -> 'IngredientSet': + """Intermediate :class:`.IngredientSet`""" + return self._intermediates + + @intermediates.setter + def intermediates(self, a: 'IngredientSet'): + """Set the intermediates""" + self._intermediates = a + + @property + def reactions(self) -> 'ReactionSet': + """:class:`.ReactionSet` for this recipe""" + return self._reactions + + @reactions.setter + def reactions(self, a: 'ReactionSet'): + """Set the reactions""" + self._reactions = a + self.__flag_modification() + + @property + def product(self) -> 'Ingredient': + """Return the single product (if there's only one)""" + assert len(self.products) == 1 + return self.products[0] + + @property + def product_compounds(self) -> 'CompoundSet': + """Product compounds""" + if self._product_compounds is None: + self._product_compounds = self.products.compounds + self._product_compounds._name = f'products of {self}' + return self._product_compounds + + @property + def combined_compound_ids(self) -> set[int]: + """Combined :class:`.CompoundModel` IDs from :meth:`.Recipe.product_compounds` + and :meth:`.Recipe.compounds`""" + return set(self.product_compounds.ids) | set(self.compounds.ids) + + @property + def combined_compounds(self) -> 'CompoundSet': + """Combined product and no-chem compounds""" + if self._combined_compounds is None: + self._combined_compounds = CompoundSet(list(self.combined_compound_ids)) + self._combined_compounds._name = f'combined compounds of {self}' + return self._combined_compounds + + @property + def poses(self) -> 'PoseSet': + """Poses of the combined compounds""" + if self._poses is None: + self._poses = self.combined_compounds.poses + self._poses._name = f'poses of {self}' + return self._poses + + @property + def interactions(self) -> 'InteractionSet': + """Product pose interactions""" + if self._interactions is None: + self._interactions = self.poses.interactions + return self._interactions + + @property + def price(self) -> 'Price': + """Total price of the reactants and no-chem compounds""" + return self.reactants.get_price() + self.compounds.get_price() + + @property + def num_products(self) -> int: + """Number of products""" + return len(self.products) + + @property + def num_compounds(self) -> int: + """Number of combined compounds""" + return len(self.combined_compound_ids) + + @property + def num_reactions(self) -> int: + """Number of reactions""" + return len(self.reactions) + + @property + def num_reaction_types(self) -> int: + """Number of distinct reaction types""" + return self.reactions.num_types + + @property + def num_reactants(self) -> int: + """Number of reactants""" + return len(self.reactants) + + @property + def num_intermediates(self) -> int: + """Number of intermediates""" + return len(self.intermediates) + + @property + def hash(self) -> str: + """Unique hash string (set when loaded from a RecipeSet)""" + return self._hash + + @property + def score(self): + """Recipe score""" + return self._score + + @property + def type(self) -> str: + """Recipe type (EMPTY/MIXED/CHEM/NOCHEM)""" + + if self.empty: + return 'EMPTY' + + chem = bool(self.reactions) + nochem = bool(self.compounds) + + if chem and nochem: + return 'MIXED' + if chem and not nochem: + return 'CHEM' + if nochem and not chem: + return 'NOCHEM' + + @property + def empty(self) -> bool: + """Is this Recipe empty?""" + return not any( + ( + self.reactants, + self.products, + self.intermediates, + self.reactions, + self.compounds, + ) + ) + + ### METHODS + + def get_price(self, supplier: str | None = None) -> 'Price': + """Get the reactants price. See :meth:`.IngredientSet.get_price` + + :param supplier: restrict quotes to this supplier + """ + return self.reactants.get_price(supplier=supplier) + + def get_ingredient(self, id) -> 'Ingredient': + """Get an ingredient by its compound ID + + :param id: compound ID + """ + matches = [r for r in self.reactants if r.id == id] + if not matches: + matches = [r for r in self.intermediates if r.id == id] + if not matches: + matches = [r for r in self.products if r.id == id] + + assert len(matches) == 1 + return matches[0] + + def add_ingredient(self, ingredient: 'Ingredient', amount: float = 1): + """Add an :class:`.Ingredient` for direct purchase (no associated reactions)""" + self.compounds.add(ingredient) + + def add_to_all_reactants(self, amount: float = 20) -> None: + """Increment all reactants by this amount + + :param amount: amount in ``mg`` (Default value = 20) + """ + self.reactants.df['amount'] += amount + + def copy(self) -> 'Recipe': + """Copy this recipe""" + return Recipe( + products=self.products.copy(), + reactants=self.reactants.copy(), + intermediates=self.intermediates.copy(), + reactions=self.reactions.copy(), + compounds=self.compounds.copy(), + ) + + def check_integrity(self, debug: bool = False) -> bool: + """Verify the internal integrity of this recipe.""" + + if debug: + mrich.debug('Checking integrity:', self) + mrich.debug('Checking for duplicate compounds') + + for label, iset in ( + ('Reactant', self.reactants), + ('Intermediate', self.intermediates), + ('Product', self.products), + ): + if len(iset.compound_ids) != len(set(iset.compound_ids)): + mrich.error(f"{label} compound ID's are not unique") + return False + + if debug: + mrich.debug('Checking for missing references') + + # all references should exist in the database + if ReactionModel.objects.filter(pk__in=self.reactions.ids).count() < len( + self.reactions + ): + mrich.error('Not all Reactions in Database') + return False + + checks = ( + ('product', self.product_compounds.ids, len(self.products)), + ('reactant', self.reactants.compounds.ids, len(self.reactants)), + ('intermediate', self.intermediates.compounds.ids, len(self.intermediates)), + ) + for label, ids, expected in checks: + if CompoundModel.objects.filter(pk__in=list(ids)).count() < expected: + mrich.error(f'Not all {label} Compounds in Database') + return False + + reaction_intermediates = self.reactions.intermediates + reaction_products = self.reactions.products + reaction_reactants = self.reactions.reactants + + if debug: + mrich.debug('Checking for missing reactions') + + for product in self.products: + if product not in reaction_products: + mrich.error(f'Product: {product} does not have associated reaction') + return False + + for intermediate in self.intermediates: + if intermediate not in reaction_intermediates: + mrich.error( + f'Intermediate: {intermediate} is not in ' + f'self.reactions.intermediates' + ) + return False + + for reactant in self.reactants: + if reactant not in reaction_reactants: + mrich.error(f'Reactant: {reactant} is not in self.reactions.reactants') + return False + + if debug: + mrich.debug('Checking reactant quantities') + + for reaction in (Reaction(r) for r in self.reactions): + product_ingredient = self.products(compound_id=reaction.product.id) + if product_ingredient is None: + product_ingredient = self.intermediates(compound_id=reaction.product.id) + + if debug and reaction.product_yield < 1.0: + mrich.debug(f'{reaction}.product_yield={reaction.product_yield}') + + for reactant in reaction.reactants: + reactant_ingredient = self.intermediates(compound_id=reactant.id) + if reactant_ingredient is None: + reactant_ingredient = self.reactants(compound_id=reactant.id) + + required_amount = product_ingredient.amount / reaction.product_yield + + if reactant_ingredient.amount < required_amount: + mrich.error( + f'Not enough of {reactant_ingredient.compound}: ' + f'{reactant_ingredient.amount} < {required_amount}' + ) + return False + + if debug: + mrich.success(self, 'OK') + + return True + + ### SERIALISATION + + def get_dict( + self, + *, + price: bool = True, + reactant_supplier: bool = True, + compound_supplier: bool = True, + timestamp: bool = True, + compound_ids_only: bool = False, + products: bool = True, + serialise_price: bool = False, + ) -> dict: + """Serialise this recipe to a dictionary. + + :param price: include the price + :param reactant_supplier: include the reactant supplier + :param compound_supplier: include the compound supplier + :param timestamp: add a timestamp + :param compound_ids_only: store IDs only (instead of full ingredient dataframes) + :param products: include products + :param serialise_price: serialise the :class:`.Price` object + """ + + from datetime import datetime + + data = {} + + if timestamp: + data['timestamp'] = str(datetime.now()) + + try: + if price and serialise_price: + data['price'] = self.price.get_dict() + elif price: + data['price'] = self.price + except AssertionError as e: + mrich.warning(f'Could not get price: {e}') + data['price'] = None + + if reactant_supplier: + data['reactant_supplier'] = self.reactants.supplier + if compound_supplier: + data['compound_supplier'] = self.compounds.supplier + + if compound_ids_only: + data['reactant_ids'] = self.reactants.compound_ids + data['intermediate_ids'] = self.intermediates.compound_ids + if products: + data['products_ids'] = self.products.compound_ids + data['compound_ids'] = self.compounds.compound_ids + else: + data['reactants'] = self.reactants.df.to_dict(orient='list') + data['intermediates'] = self.intermediates.df.to_dict(orient='list') + if products: + data['products'] = self.products.df.to_dict(orient='list') + data['compounds'] = self.compounds.df.to_dict(orient='list') + + data['reaction_ids'] = self.reactions.ids + + return data + + def write_json( + self, + file: 'str | Path', + *, + extra: dict | None = None, + indent: str = '\t', + **kwargs, + ) -> None: + """Serialise this recipe and write it to disk. + + :param file: write to this path + :param extra: extra data to serialise + :param indent: indentation whitespace (Default value = '\\t') + """ + import json + from pathlib import Path + + file = Path(file).resolve() + assert file.parent.exists(), f'Directory does not exist: {file.parent}' + + data = self.get_dict(serialise_price=True, **kwargs) + if extra: + data.update(extra) + + mrich.writing(file) + json.dump(data, open(file, 'w'), indent=indent) + + ### PRESENTATION + + def summary(self, price: bool = True) -> None: + """Print a summary of this recipe + + :param price: print the price (Default value = True) + """ + + mrich.h1(str(self)) + + if price: + price = self.price + if price: + mrich.var('\nprice', price.amount, price.currency) + + if self.products: + mrich.h3(f'{len(self.products)} products') + if len(self.products) < 100: + for product in self.products: + mrich.var(str(product.compound), f'{product.amount:.2f}', 'mg') + + if self.intermediates: + mrich.h3(f'{len(self.intermediates)} intermediates') + if len(self.intermediates) < 100: + for intermediate in self.intermediates: + mrich.var( + str(intermediate.compound), f'{intermediate.amount:.2f}', 'mg' + ) + + if self.reactants: + mrich.h3(f'{len(self.reactants)} reactants') + if len(self.reactants) < 100: + for reactant in self.reactants: + mrich.var(str(reactant.compound), f'{reactant.amount:.2f}', 'mg') + + if self.reactions: + mrich.h3(f'{len(self.reactions)} reactions') + if len(self.reactions) < 100: + for reaction in (Reaction(r) for r in self.reactions): + mrich.var(str(reaction), reaction.reaction_str, reaction.type) + + if self.compounds: + mrich.h3(f'{len(self.compounds)} compounds') + if len(self.compounds) < 100: + for compound in self.compounds: + mrich.var(str(compound.compound), f'{compound.amount:.2f}', 'mg') + + def draw(self, color_mapper=None, node_size=300, graph_only=False): + """Draw a graph of the reaction network + + :param color_mapper: (Default value = None) + :param node_size: (Default value = 300) + :param graph_only: (Default value = False) + """ + + import networkx as nx + + color_mapper = color_mapper or {} + colors = {} + sizes = {} + + graph = nx.DiGraph() + + for reaction in (Reaction(r) for r in self.reactions): + for reactant in reaction.reactants: + key = str(reactant) + ingredient = self.get_ingredient(id=reactant.id) + graph.add_node( + key, + id=reactant.id, + smiles=reactant.smiles, + amount=ingredient.amount, + price=str(ingredient.price), + lead_time=ingredient.lead_time, + ) + if not graph_only: + sizes[key] = ingredient.amount + colors[key] = color_mapper.get(key, (0.7, 0.7, 0.7)) + + for product in self.products: + key = str(product.compound) + ingredient = self.get_ingredient(id=product.id) + graph.add_node( + key, + id=product.id, + smiles=product.smiles, + amount=ingredient.amount, + price=str(ingredient.price), + lead_time=ingredient.lead_time, + ) + if not graph_only: + sizes[key] = product.amount + colors[key] = color_mapper.get(key, (0.7, 0.7, 0.7)) + + for reaction in (Reaction(r) for r in self.reactions): + for reactant in reaction.reactants: + graph.add_edge( + str(reactant), + str(reaction.product), + id=reaction.id, + type=reaction.type, + product_yield=reaction.product_yield, + ) + + if graph_only: + return graph + + s_min = min(sizes.values()) + sizes = [s / s_min * node_size for s in sizes.values()] + pos = nx.spring_layout(graph) + return nx.draw( + graph, + pos=pos, + with_labels=True, + font_weight='bold', + node_color=list(colors.values()), + node_size=sizes, + ) + + def sankey(self, title: str | None = None) -> 'graph_objects.Figure': + """Draw a plotly Sankey diagram + + :param title: (Default value = None) + """ + + graph = self.draw(graph_only=True) + + import plotly.graph_objects as go + + nodes = {} + for edge in graph.edges: + for c in edge: + if c not in nodes: + nodes[c] = len(nodes) + + source = [nodes[a] for a, b in graph.edges] + target = [nodes[b] for a, b in graph.edges] + value = [1 for _ in graph.edges] + labels = list(nodes.keys()) + + hoverkeys = None + customdata = [] + for key in nodes.keys(): + n = graph.nodes[key] + if not hoverkeys: + hoverkeys = list(n.keys()) + if not n: + mrich.error(f'problem w/ node {key=}') + customdata.append((int(key[1:]), None)) + else: + customdata.append( + tuple(v if v is not None else 'N/A' for v in n.values()) + ) + + hoverkeys_edges = None + customdata_edges = [] + for s, t in graph.edges.keys(): + edge = graph.edges[s, t] + if not hoverkeys_edges: + hoverkeys_edges = list(edge.keys()) + customdata_edges.append( + tuple(v if v is not None else 'N/A' for v in edge.values()) + ) + + hoverlines = [f'{key}=%{{customdata[{i}]}}' for i, key in enumerate(hoverkeys)] + hovertemplate = 'Compound ' + '
'.join(hoverlines) + '' + + hoverlines_edges = [ + f'{key}=%{{customdata[{i}]}}' for i, key in enumerate(hoverkeys_edges) + ] + hovertemplate_edges = ( + 'Reaction ' + '
'.join(hoverlines_edges) + '' + ) + + fig = go.Figure( + data=[ + go.Sankey( + node=dict( + label=labels, + customdata=customdata, + hovertemplate=hovertemplate, + ), + link=dict( + customdata=customdata_edges, + hovertemplate=hovertemplate_edges, + source=source, + target=target, + value=value, + ), + ) + ] + ) + + if not title: + try: + title = f'Recipe
price={self.price}' + except AssertionError: + title = 'Recipe' + + fig.update_layout(title=title) + return fig + + ### DEPRECATED — traversal/export shims (relocated to RecipeService) + + def get_routes(self, return_ids: bool = False) -> 'RouteSet': + """DEPRECATED: use :meth:`.RecipeService.get_routes`.""" + from designdb.services.recipe import RecipeService + + return RecipeService.get_routes(self, return_ids=return_ids) + + def register_missing_routes( + self, missing_only: bool = True, supplier: str = 'Enamine' + ) -> None: + """DEPRECATED: use :meth:`.RecipeService.register_missing_routes`.""" + from designdb.services.recipe import RecipeService + + return RecipeService.register_missing_routes( + self, missing_only=missing_only, supplier=supplier + ) + + def write_CAR_csv(self, file: 'str | Path', return_df: bool = False): + """DEPRECATED: use :meth:`.RecipeService.write_CAR_csv`.""" + from designdb.services.recipe import RecipeService + + return RecipeService.write_CAR_csv(self, file, return_df=return_df) + + def write_reactant_csv( + self, file: 'str | Path', reaction_type_counts: bool = True, return_df=False + ): + """DEPRECATED: use :meth:`.RecipeService.write_reactant_csv`.""" + from designdb.services.recipe import RecipeService + + return RecipeService.write_reactant_csv( + self, file, reaction_type_counts=reaction_type_counts, return_df=return_df + ) + + def write_product_csv(self, file: 'str | Path', return_df: bool = False): + """DEPRECATED: use :meth:`.RecipeService.write_product_csv`.""" + from designdb.services.recipe import RecipeService + + return RecipeService.write_product_csv(self, file, return_df=return_df) + + def to_syndirella(self, out_key: 'str | Path', poses: 'PoseSet', *, separate=False): + """DEPRECATED: use :meth:`.RecipeService.to_syndirella`.""" + from designdb.services.recipe import RecipeService + + return RecipeService.to_syndirella(self, out_key, poses, separate=separate) + + ### INTERNALS + + def __flag_modification(self) -> None: + """Invalidate cached derived data after a mutation""" + self._interactions = None + self._score = None + self._product_compounds = None + self._poses = None + self._combined_compounds = None + + ### DUNDERS + + def __str__(self) -> str: + """Unformatted string representation""" + s = f'(score={self.score:.3f})' if self.score else '' + if self.hash: + return f'Recipe_{self.hash}{s}' + return f'Recipe{s}' + + def __longstr(self) -> str: + """Long unformatted string representation""" + + if self.empty: + return 'Empty Recipe()' + + if self.reactions: + if self.intermediates: + s = ( + f'{self.reactants} --> {self.intermediates} --> ' + f'{self.products} via {self.reactions}' + ) + else: + s = f'{self.reactants} --> {self.products} via {self.reactions}' + + if self.score: + s += f', score={self.score:.3f}' + if self.hash: + return f'Recipe_{self.hash}({s})' + return f'Recipe({s})' + + if self.hash: + return f'Recipe_{self.hash}({self.compounds})' + return f'Recipe(#compounds={self.num_compounds} [no-chem])' + + def __repr__(self) -> str: + """ANSI Formatted string representation""" + return ( + f'{mcol.bold}{mcol.underline}{self.__longstr()}' + f'{mcol.unbold}{mcol.ununderline}' + ) + + def __rich__(self) -> str: + """Rich Formatted string representation""" + return f'[bold underline]{self.__longstr()}' + + def __add__(self, other: 'Recipe') -> 'Recipe': + """Add another :class:`.Recipe` to this one""" + result = self.copy() + result.reactants += other.reactants + result.intermediates += other.intermediates + result.reactions += other.reactions + result.products += other.products + result.compounds += other.compounds + return result + + +# name conflict with RouteModel. Trying to get rid of this entirely +class Route(Recipe): + """A recipe with a single product, that is stored in the database""" + + def __init__( + self, + *, + route_id: int, + product: 'IngredientSet', + reactants: 'IngredientSet', + intermediates: 'IngredientSet', + reactions: 'ReactionSet', + ) -> None: + """Route initialisation""" + + # check typing + assert isinstance(product, IngredientSet) + assert isinstance(reactants, IngredientSet) + assert isinstance(intermediates, IngredientSet) + assert isinstance(reactions, ReactionSet) + + assert len(product) == 1 + assert isinstance(route_id, int) + assert route_id + + self._id = route_id + self._products = product + self._product_id = product.ids[0] + self._reactants = reactants + self._intermediates = intermediates + self._reactions = reactions + self._compounds = IngredientSet() + self._hash = None + self._score = None + self._product_compounds = None + self._poses = None + self._interactions = None + self._combined_compounds = None + + ### FACTORIES + + @classmethod + def from_json(cls, path: 'str | Path | None' = None, data: dict = None) -> 'Route': + """Load a serialised route from a JSON file + + :param path: path to JSON + :param data: serialised data (Default value = None) + """ + + import json + + if data is None: + data = json.load(open(path)) + + self = cls.__new__(cls) + + self._id = data['id'] + self._product_id = data['product_id'] + self._products = IngredientSet.from_compounds(ids=[self._product_id]) + self._reactants = IngredientSet.from_json( + path=None, + data=data['reactants']['data'], + supplier=data['reactants']['supplier'], + ) + self._intermediates = IngredientSet.from_json( + path=None, + data=data['intermediates']['data'], + supplier=data['intermediates']['supplier'], + ) + self._reactions = ReactionSet(data['reactions']['indices']) + self._compounds = IngredientSet() + self._hash = None + self._score = None + self._product_compounds = None + self._poses = None + self._interactions = None + self._combined_compounds = None + + return self + + @classmethod + def get_route( + cls, *, id: int, get_quote: bool = True, debug: bool = False + ) -> 'Route': + """Fetch a :class:`.RouteModel` stored in the database and wrap it. + + :param id: the ID of the :class:`.RouteModel` to retrieve + :param get_quote: fetch catalogue quotes for the reactants so the route is + priced, defaults to ``True`` + :param debug: increase verbosity for debugging + """ + + route = RouteModel.objects.get(pk=id) + + if debug: + mrich.var('product_id', route.product_compound) + + qs = ComponentModel.objects.filter(route=route).order_by('id') + + reaction_ids = [] + reactant_ids = [] + reactant_amounts = [] + intermediate_ids = [] + intermediate_amounts = [] + + for k in qs: + ref = k.component_ref + c_type = k.component_type + amount = k.component_amount + match c_type: + case 1: + reaction_ids.append(ref) + case 2: + reactant_ids.append(ref) + reactant_amounts.append(amount) + case 3: + intermediate_ids.append(ref) + intermediate_amounts.append(amount) + case _: + raise ValueError(f'Unknown component type {c_type}') + + if debug: + mrich.var('components', qs) + + def _ingredients(ids, amounts, quote=False): + """Build an IngredientSet, returning an empty one for no ids. + + When ``quote`` is set, each ingredient fetches its cheapest catalogue + quote (via the ``compound_catalogue_map`` junction) so the route can be + priced; otherwise ingredients are left unquoted. + """ + iset = IngredientSet() + for cid, amount in zip(ids, amounts, strict=False): + iset.add( + Ingredient.from_compound( + compound=CompoundModel.objects.get(pk=cid), + amount=amount, + get_quote=quote, + ) + ) + return iset + + # products are made, not purchased, so they are never quoted + products = IngredientSet.from_compounds( + ids=[route.product_compound_id], amount=1 + ) + # reactants are the building blocks that get bought -> fetch quotes + reactants = _ingredients(reactant_ids, reactant_amounts, quote=get_quote) + intermediates = _ingredients(intermediate_ids, intermediate_amounts) + + reactions = ReactionSet(reaction_ids) + + recipe = Route( + route_id=id, + product=products, + reactants=reactants, + intermediates=intermediates, + reactions=reactions, + ) + + if debug: + mrich.var('recipe', recipe) + + return recipe + + ### PROPERTIES + + @property + def product(self) -> 'Ingredient': + """Product ingredient""" + return self._products[0] + + @property + def product_compound(self) -> 'CompoundModel': + """Product compound""" + return self.product.compound + + @property + def id(self) -> int: + """Route ID""" + return self._id + + @property + def price(self) -> 'Price': + """Get the price of the reactants""" + return self.reactants.price + + ### METHODS + + def get_dict(self) -> dict: + """Serialisable dictionary""" + return { + 'id': self.id, + 'product_id': self.product.id, + 'reactants': self.reactants.get_dict(), + 'intermediates': self.intermediates.get_dict(), + 'reactions': self.reactions.get_dict, + } + + ### DUNDERS + + def __str__(self) -> str: + """Unformatted string representation""" + return f'Route #{self.id}: {self.product_compound}' + + def __repr__(self) -> str: + """ANSI Formatted string representation""" + return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' + + def __rich__(self) -> str: + """Rich Formatted string representation""" + return f'[bold underline]{self}' + + +class RecipeSet: + """A set of :class:`.Recipe` objects stored on disk as JSON.""" + + def __init__( + self, + directory: 'str | Path', + pattern: str = '*.json', + ): + """Load all recipes matching ``pattern`` in ``directory``.""" + + from json import JSONDecodeError + from pathlib import Path + + self._json_directory = Path(directory) + self._json_pattern = pattern + + self._json_paths = {} + for path in self._json_directory.glob(self._json_pattern): + key = path.name.removeprefix('Recipe_').removesuffix('.json') + self._json_paths[key] = path.resolve() + + mrich.reading(f'{directory}/{pattern}') + + self._recipes = {} + for key, path in mrich.track( + self._json_paths.items(), prefix='Loading recipes' + ): + try: + recipe = Recipe.from_json(path=path, debug=False) + except JSONDecodeError: + mrich.error(f'Bad JSON in {path}') + continue + recipe._hash = key + self._recipes[key] = recipe + + mrich.success('Loaded', len(self), 'Recipes') + + ### METHODS + + def get_values( + self, key: str, progress: bool = False, serialise_price: bool = False + ): + """Get the value of attribute ``key`` for each member recipe.""" + values = [] + recipes = self._recipes.values() + if progress: + recipes = mrich.track(recipes, prefix=f'Calculating {self} values...') + for recipe in recipes: + value = getattr(recipe, key) + if serialise_price and key == 'price': + value = value.amount + values.append(value) + return values + + def get_df(self, **kwargs) -> 'pandas.DataFrame': + """Get a dataframe of recipe dictionaries. See :meth:`.Recipe.get_dict`.""" + from pandas import DataFrame + + data = [recipe.get_dict(timestamp=False, **kwargs) for recipe in self] + return DataFrame(data) + + def items(self) -> 'list[tuple[str, Recipe]]': + """Data dictionary items""" + return self._recipes.items() + + def keys(self) -> list[str]: + """Data dictionary keys (recipe hashes)""" + return self._recipes.keys() + + ### DUNDERS + + def __len__(self) -> int: + """Number of recipes in this set""" + return len(self._recipes) + + def __getitem__(self, key: int | str) -> Recipe: + """Get a :class:`.Recipe` by index or hash""" + match key: + case int(): + return list(self._recipes.values())[key] + case str(): + return self._recipes[key] + case _: + mrich.error(f'Unsupported RecipeSet key: {key=} {type(key)}') + return None + + def __iter__(self): + """Iterate over member recipes""" + return iter(self._recipes.values()) + + def __contains__(self, key: str) -> bool: + """Is this hash present in the set?""" + assert isinstance(key, str) + return key in self._recipes + + def __str__(self) -> str: + """Unformatted string representation""" + return f'{{Recipe × {len(self)}}}' + + def __repr__(self) -> str: + """ANSI Formatted string representation""" + return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' + + def __rich__(self) -> str: + """Rich Formatted string representation""" + return f'[bold underline]{self}' diff --git a/hippo/designdb/services/__init__.py b/hippo/designdb/services/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hippo/designdb/services/compound.py b/hippo/designdb/services/compound.py new file mode 100644 index 00000000..b206bd1e --- /dev/null +++ b/hippo/designdb/services/compound.py @@ -0,0 +1,160 @@ +import logging +import re + +import mrich +import rdkit +from designdb.models import CompoundModel, CompoundTagModel +from designdb.utils import ( + registration_hash_tautomer_insensitive, + sanitise_smiles, + superparent, +) + +# from mypackage.services.compound import CompoundService +from rdkit import Chem + +# from rdkit.Chem import inchi + + +# from .validation.compound import ValidationError, validate_compound_data + +SDF_XCAv2_PATTERN = re.compile( + r'^.*-.\d{4}_._\d*_\d_.*-.\d{4}\+.\+\d*\+\d_ligand\.sdf$' +) +SDF_XCAV3_PATTERN = re.compile( + r'^.*-.\d{4}_._\d*_._\d_.*-.\d{4}\+.\+\d*\+.\+\d_ligand\.sdf$' +) + + +SDF_FRAGALYSIS_PATTERN = re.compile(r'^.*\d{4}[a-z].sdf$') +PDBID_PATTERN = re.compile(r'^[A-Za-z0-9]{4}-[a-z].sdf$') + + +logger = logging.getLogger(__name__) + + +class CompoundBatchResult: + def __init__(self): + self.created = [] + self.errors = [] + + +class CompoundService: + @classmethod + def create( + cls, + *, + # mol: Chem.rdchem.Mol, + smiles: str, + # inchikey: str, + ) -> tuple[CompoundModel, bool]: + + # designdb expects smils as input, so this is the entrypoint + # for insertion + mol = Chem.MolFromSmiles(smiles, sanitize=True) + try: + sp = superparent(mol) + except Exception as e: + raise ValueError(f'SuperParent failed: {e}') from e + + h = registration_hash_tautomer_insensitive(sp) + + compound, created = CompoundModel.objects.get_or_create( + compound_hash=h, + defaults={ + # 'compound_mol': mol, + # 'compound_inchikey': inchikey, + 'compound_smiles': smiles, + 'rdkit_version': rdkit.__version__, + 'inchi_version': Chem.inchi.GetInchiVersion(), + }, + ) + if not created and logger.level == logging.DEBUG: + mrich.warning(f'Skipping compound {h}, duplicate of {compound.pk}') + + # there's a following block in the original code + # I don't understand what it is trying to achieve + # smiles and inchikey are both inserted, so compound existing + # but not reachable by inchikey should not happen. maybe this + # covers compounds loaded through different pathway? + + # compound_id = self.db.insert_compound( + # smiles=smiles, + # tags=tags, + # warn_duplicate=debug, + # commit=False, + # ) + + # if not compound_id: + # inchikey = inchikey_from_smiles(smiles) + # compound = self.compounds[inchikey] + + # if not compound: + # mrich.error( + # 'CompoundModel exists in database but could not be found ' + # 'by inchikey' + # ) + # mrich.var('smiles', smiles) + # mrich.var('inchikey', inchikey) + # mrich.var('observation_shortname', name) + # raise Exception + + # else: + # count_compound_registered += 1 + # compound = self.compounds[compound_id] + + return compound, created + + # @classmethod + # def create_from_smiles( + # cls, + # smiles: str, + # ) -> tuple[CompoundModel, bool]: + # mol = Chem.MolFromSmiles(smiles, sanitize=True) + # compound, created = cls.create(mol=mol) + # return compound, created + + @classmethod + def create_from_smiles_list( + cls, + smiles_list: list[str], + ) -> list[tuple[str, str]]: + result = [] + for smiles in smiles_list: + sane_smiles = sanitise_smiles( + smiles, verbosity=logger.level == logging.DEBUG + ) + # compound, _ = cls.create_from_smiles(sane_smiles) + compound, _ = cls.create(smiles=sane_smiles) + result.append((compound.compound_inchikey, compound.compound_smiles)) + + return result + + @classmethod + def get_by_smiles(cls, smiles: str) -> CompoundModel | None: + mol = Chem.MolFromSmiles(smiles, sanitize=True) + try: + sp = superparent(mol) + except Exception as e: + raise ValueError(f'SuperParent failed: {e}') from e + + h = registration_hash_tautomer_insensitive(sp) + + return CompoundModel.objects.get(compound_hash=h) + + +class CompoundTagService: + @staticmethod + def tags_from_list(tag_list: list[str]): + assert tag_list is not None, '"None" passed as tag_list' + + CompoundTagModel.objects.bulk_create( + [ + CompoundTagModel(compound_tag_name=k.strip()) + for k in tag_list + if k.strip() + ], + ignore_conflicts=True, + ) + tags = CompoundTagModel.objects.filter(compound_tag_name__in=tag_list) + return tags diff --git a/hippo/designdb/services/download.py b/hippo/designdb/services/download.py new file mode 100644 index 00000000..384325fd --- /dev/null +++ b/hippo/designdb/services/download.py @@ -0,0 +1,403 @@ +"""Service layer for downloading target data from Fragalysis. + +Wraps the Fragalysis ``/api/download_structures/`` endpoint, which builds a +(g)zipped archive on demand and returns it in two steps: + +1. ``POST`` the download specification -> the response JSON contains a + ``file_url`` once the archive is ready (this is *not* async on the server, so + the POST can block while the archive is assembled). +2. ``GET`` that ``file_url`` -> stream the archive to disk. + +The endpoint's serializer exposes many boolean flags. For hippo we only need the +apo-desolvated protein structures, so :meth:`.DownloadService.download_target` +defaults ``apo_desolv_file=True`` and every other flag to ``False``. Individual +flags can still be overridden per call via keyword arguments. + +Adapted and hardened from the ``downloader.py`` prototype. +""" + +import tarfile +import time +import zipfile +from pathlib import Path +from urllib.parse import urljoin + +import mrich +import requests +from designdb.utils_frag import STACK_URLS +from requests.exceptions import JSONDecodeError + +LOGIN_URL = '/accounts/login/' +DOWNLOAD_URL = '/api/download_structures/' +LANDING_PAGE_URL = '/viewer/react/landing/' + +# Keep reasonably current; some endpoints reject obviously-bot user agents. +USER_AGENT = ( + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) ' + 'Chrome/120.0.0.0 Safari/537.36' +) + +# Every BooleanField on DownloadStructuresSerializer except ``use_zip`` (which is +# handled as a dedicated parameter). This tuple is the single source of truth for +# "apo_desolv default, everything else False" and should track the serializer. +BOOLEAN_FLAGS = ( + 'all_aligned_structures', + 'apo_file', + 'bound_file', + 'apo_solv_file', + 'apo_desolv_file', + 'ligand_pdb', + 'ligand_sdf', + 'ligand_smiles', + 'sdf_info', + 'smiles_info', + 'pdb_info', + 'cif_info', + 'mtz_info', + 'diff_file', + 'event_file', + 'sigmaa_file', + 'map_info', + 'single_sdf_file', + 'metadata_info', + 'trans_matrix_info', + 'compound_sets', + 'soakdb_files', + 'yaml_files', + 'extra_files', + 'pymol_scripts', + 'readme', + 'static_link', +) + +# The flag enabled by default by :meth:`.DownloadService.download_target`. +DEFAULT_FLAG = 'apo_desolv_file' + +# (connect timeout, read timeout) in seconds. +DEFAULT_TIMEOUT = (30, 1800) + +# Short timeout for the best-effort CSRF-priming GET. It hits a normal page just +# to obtain the csrftoken cookie, so it must fail fast (e.g. on a backend-only +# deployment that doesn't serve the frontend landing page) rather than block on +# the long download read timeout. +CSRF_TIMEOUT = (10, 10) + +# Task-status polling. The POST only *triggers* archive creation and returns a +# task status URL; we poll it until the task reaches a terminal state and a +# file_url becomes available. +FAILURE_STATUSES = ('FAILED', 'CANCELED', 'FATAL') +DEFAULT_POLL_INTERVAL = 2 +DEFAULT_POLL_TIMEOUT = 1800 + + +class DownloadService: + """Download target data archives from Fragalysis.""" + + @staticmethod + def _build_payload( + *, + target_name: str, + target_access_string: str = '', + proteins: str = '', + use_zip: bool = False, + **flag_overrides: bool, + ) -> dict: + """Build the request payload. + + Every boolean flag defaults to ``False`` except :data:`.DEFAULT_FLAG` + (``apo_desolv_file``). Pass any flag in :data:`.BOOLEAN_FLAGS` as a keyword + argument to override it. + + :param target_name: Fragalysis target name + :param target_access_string: target access string (proposal) + :param proteins: comma-separated observation shortcodes (empty = all) + :param use_zip: request a ``.zip`` instead of a ``.tar.gz`` archive + :param flag_overrides: per-call overrides for any flag in + :data:`.BOOLEAN_FLAGS` + """ + + unknown = set(flag_overrides) - set(BOOLEAN_FLAGS) + if unknown: + raise TypeError( + f'Unknown download flag(s): {sorted(unknown)}. ' + f'Valid flags: {", ".join(BOOLEAN_FLAGS)}' + ) + + payload = {flag: False for flag in BOOLEAN_FLAGS} + payload[DEFAULT_FLAG] = True + payload.update(flag_overrides) + + payload.update( + target_name=target_name, + target_access_string=target_access_string or '', + proteins=proteins or '', + # NB: must be '' (not False) on the initial request, see prototype + file_url='', + use_zip=bool(use_zip), + ) + + return payload + + @staticmethod + def download_target( + *, + target_name: str, + target_access_string: str = '', + proteins: str = '', + stack: str = 'production', + url: str | None = None, + auth_token: str | None = None, + destination: 'str | Path | None' = None, + extract: bool = True, + use_zip: bool = False, + timeout: 'tuple[int, int] | int | None' = DEFAULT_TIMEOUT, + poll_interval: float = DEFAULT_POLL_INTERVAL, + poll_timeout: float = DEFAULT_POLL_TIMEOUT, + **flag_overrides: bool, + ) -> Path: + """Download (and optionally extract) a target archive from Fragalysis. + + :param target_name: Fragalysis target name + :param target_access_string: target access string (proposal) + :param proteins: comma-separated observation shortcodes (empty = all) + :param stack: key into :data:`.STACK_URLS` (``'production'``/``'staging'``) + :param url: explicit base URL, overrides ``stack`` if given + :param auth_token: Fragalysis ``sessionid`` cookie; falls back to the + ``FRAGALYSIS_AUTH_TOKEN`` environment variable + :param destination: directory to download into (default: current dir) + :param extract: extract the archive and return the extracted directory + :param use_zip: request a ``.zip`` instead of a ``.tar.gz`` archive + :param timeout: requests timeout (connect, read) in seconds + :param poll_interval: seconds between task-status polls + :param poll_timeout: max seconds to wait for the archive task to finish + :param flag_overrides: per-call overrides for any flag in + :data:`.BOOLEAN_FLAGS` + :returns: path to the extracted directory (``extract=True``) or the + downloaded archive (``extract=False``) + """ + + base_url = url or STACK_URLS.get(stack) + if not base_url: + raise ValueError( + f'Unknown stack {stack!r}; choose from {sorted(STACK_URLS)} ' + 'or pass an explicit url' + ) + + destination = Path(destination) if destination else Path.cwd() + destination.mkdir(parents=True, exist_ok=True) + + payload = DownloadService._build_payload( + target_name=target_name, + target_access_string=target_access_string, + proteins=proteins, + use_zip=use_zip, + **flag_overrides, + ) + + download_api_url = urljoin(base_url, DOWNLOAD_URL) + landing_page_url = urljoin(base_url, LANDING_PAGE_URL) + + mrich.var('target_name', target_name) + mrich.var('target_access_string', target_access_string) + mrich.var('stack url', base_url) + + with requests.Session() as session: + session.headers.update( + { + 'User-Agent': USER_AGENT, + 'Referer': landing_page_url, + 'Referrer-policy': 'same-origin', + } + ) + + # Best-effort: prime the csrftoken cookie by hitting a normal page. + # Use a short timeout and tolerate failure so a backend-only stack + # that doesn't serve the landing page fails fast instead of hanging + # on the long download read timeout. If no token is obtained we still + # proceed; a CSRF-enforcing server would then return a clear error. + try: + session.get(landing_page_url, timeout=CSRF_TIMEOUT) + except requests.RequestException as exc: + mrich.warning( + f'Could not reach {landing_page_url} to obtain a CSRF token ' + f'({exc}); proceeding without it.' + ) + csrftoken = session.cookies.get('csrftoken', None) + if csrftoken: + session.headers.update({'X-CSRFToken': csrftoken}) + + if auth_token: + session.cookies.update({'sessionid': auth_token}) + + # Step 1: trigger archive creation. This returns a task status URL + # (the archive is built asynchronously on the server). + mrich.print('Requesting download from Fragalysis...') + start_response = session.post( + download_api_url, data=payload, timeout=timeout + ) + start_response.raise_for_status() + start_json = start_response.json() + + # Step 2: resolve the file_url. Newer servers return a task status + # URL to poll; older/cached responses may return file_url directly. + file_url = start_json.get('file_url') + if not file_url: + task_status_url = start_json.get('task_status_url') + if not task_status_url: + raise RuntimeError( + 'Fragalysis returned neither file_url nor task_status_url: ' + f'{start_json}' + ) + task_status_url = urljoin(base_url, task_status_url) + file_url = DownloadService._poll_task( + session, + task_status_url, + timeout=timeout, + poll_interval=poll_interval, + poll_timeout=poll_timeout, + ) + + # Step 3: stream the archive to disk + archive_path = destination / Path(file_url).name + mrich.writing(archive_path) + with session.get( + download_api_url, + params={'file_url': file_url}, + stream=True, + timeout=timeout, + ) as r: + r.raise_for_status() + with open(archive_path, 'wb') as f: + for chunk in r.iter_content(chunk_size=8192): + f.write(chunk) + + mrich.success('Downloaded', archive_path) + + if not extract: + return archive_path + + return DownloadService._extract(archive_path, destination) + + @staticmethod + def _poll_task( + session: requests.Session, + task_status_url: str, + *, + timeout: 'tuple[int, int] | int | None' = DEFAULT_TIMEOUT, + poll_interval: float = DEFAULT_POLL_INTERVAL, + poll_timeout: float = DEFAULT_POLL_TIMEOUT, + ) -> str: + """Poll a Fragalysis task-status URL until the archive is ready. + + :param session: the authenticated :class:`requests.Session` + :param task_status_url: absolute task-status URL returned by the POST + :param timeout: per-request timeout + :param poll_interval: seconds between polls + :param poll_timeout: max seconds to wait before giving up + :returns: the ``file_url`` of the assembled archive + """ + + mrich.print('Waiting for Fragalysis to build the archive...') + + waited = 0.0 + seen_messages = 0 + + while True: + resp = session.get(task_status_url, timeout=timeout) + try: + data = resp.json() + except JSONDecodeError: + # task is too early in its lifecycle to have a JSON body yet + data = {} + + if error := data.get('error'): + raise RuntimeError(f'Fragalysis download task failed: {error}') + + status = data.get('status') + if status in FAILURE_STATUSES: + raise RuntimeError(f'Fragalysis download task {status}: {data}') + + finished = ( + status == 'SUCCESS' + or data.get('finished') is True + or data.get('ready') is True + ) + + if finished: + # On success the download endpoint delivers the archive path in + # `messages` (a string), not a dedicated `file_url` key. + file_url = DownloadService._extract_file_url(data) + if not file_url: + raise RuntimeError( + f'Download task finished but returned no file_url: {data}' + ) + return file_url + + # not finished yet: surface any new progress messages + messages = data.get('messages') + if messages is not None: + messages = messages if isinstance(messages, list) else [messages] + for m in messages[seen_messages:]: + mrich.print(m) + seen_messages = len(messages) + + if waited >= poll_timeout: + raise TimeoutError( + f'Download task did not finish within {poll_timeout}s ' + f'({task_status_url})' + ) + + time.sleep(poll_interval) + waited += poll_interval + + @staticmethod + def _extract_file_url(data: dict) -> 'str | None': + """Extract the archive path from a completed task-status response. + + The download endpoint returns the path in ``file_url`` on some servers and + in ``messages`` (a string, occasionally a list) on others. + """ + file_url = data.get('file_url') + if file_url: + return file_url + + messages = data.get('messages') + if isinstance(messages, str): + return messages + if isinstance(messages, list) and messages: + return messages[-1] + return None + + @staticmethod + def _extract(archive_path: Path, destination: Path) -> Path: + """Extract a ``.zip`` or ``.tar(.gz)`` archive into a sibling directory. + + :param archive_path: path to the downloaded archive + :param destination: directory to extract into + :returns: path to the extracted directory + """ + + extract_dir = destination / _archive_stem(archive_path.name) + extract_dir.mkdir(parents=True, exist_ok=True) + + mrich.print('Extracting', archive_path.name, '->', extract_dir) + + if zipfile.is_zipfile(archive_path): + with zipfile.ZipFile(archive_path) as zf: + zf.extractall(extract_dir) + elif tarfile.is_tarfile(archive_path): + with tarfile.open(archive_path) as tf: + tf.extractall(extract_dir) + else: + raise ValueError(f'Unrecognised archive format: {archive_path}') + + mrich.success('Extracted to', extract_dir) + return extract_dir + + +def _archive_stem(name: str) -> str: + """Strip a ``.zip`` / ``.tar`` / ``.tar.gz`` / ``.tgz`` suffix from a filename.""" + for suffix in ('.tar.gz', '.tar.bz2', '.tgz', '.tar', '.zip', '.gz'): + if name.endswith(suffix): + return name[: -len(suffix)] + return Path(name).stem diff --git a/hippo/designdb/services/generation.py b/hippo/designdb/services/generation.py new file mode 100644 index 00000000..16d94ce1 --- /dev/null +++ b/hippo/designdb/services/generation.py @@ -0,0 +1,459 @@ +"""Random recipe / selection generators (user entry point: ``animal.generators``). + +``generate()`` returns a :class:`.Recipe` and writes it to +``{recipe_dir}/Recipe_.json``; generator state is dumped to +``{out_key}_.json`` on construction. ``out_key`` is required (it names the +output files). +""" + +import json +from pathlib import Path + +import mrich +from designdb.components.price import Price +from designdb.recipe import Recipe, Route +from designdb.sets.ingredient import IngredientSet +from designdb.sets.route import RouteSet +from designdb.utils import dt_hash + +# sentinel for "no limit" on a given count +_UNLIMITED = 10**9 + + +def _generate_recipe( + starting_recipe: 'Recipe', + pool: list, + *, + budget: 'Price', + suppliers: 'list | None', + max_products: int, + max_reactions: int, + max_compounds: int, + max_iter: int | None, + shuffle: bool, + debug: bool, +) -> 'tuple[Recipe, dict]': + """Randomly add routes/compounds from ``pool`` to a recipe within ``budget``. + + ``pool`` items are :class:`.Route` objects (added via ``recipe += route``) or + :class:`.Ingredient` objects (added to ``recipe.compounds``). Candidates whose + product/compound is already present are skipped; an addition that exceeds the + budget is reverted. Stops on budget/limit/pool-depletion or ``max_iter``. + + :returns: ``(recipe, stats)`` where ``stats`` has ``stop_reason``, + ``iterations`` and the resolved ``max_iter``. + """ + + from random import shuffle as shuffle_func + + recipe = starting_recipe.copy() + + if suppliers is not None: + recipe.reactants._supplier = suppliers + recipe.compounds._supplier = suppliers + + pool = list(pool) + if not pool: + raise ValueError('Route/compound pool is empty!') + + if max_iter is None: + max_iter = max_products + max_reactions + max_iter = min(max_iter, len(pool)) + + if shuffle: + mrich.debug('Shuffling pool') + shuffle_func(pool) + + old_recipe = recipe.copy() + stop_reason = 'max iterations reached' + iterations = 0 + + for i in mrich.track(range(max_iter), prefix='Generating recipe...'): + iterations = i + candidate = pool.pop() + + candidate_compound = ( + candidate.product if isinstance(candidate, Route) else candidate + ) + + if ( + candidate_compound in recipe.products + or candidate_compound in recipe.compounds + ): + continue + + if isinstance(candidate, Route): + recipe += candidate + else: + recipe.compounds.add(candidate) + + new_price = recipe.price + + if not pool: + stop_reason = 'Route/compound pool depleted' + break + + if new_price > budget: + recipe = old_recipe.copy() + continue + + if len(recipe.reactions) > max_reactions: + stop_reason = 'Max #reactions exceeded' + break + + if len(recipe.products) > max_products: + stop_reason = 'Max #products exceeded' + break + + if len(recipe.compounds) > max_compounds: + stop_reason = 'Max #compounds exceeded' + break + + old_recipe = recipe.copy() + + mrich.success(f'{stop_reason}!') + mrich.success(f'Completed after {iterations} iterations!') + + stats = {'stop_reason': stop_reason, 'iterations': iterations, 'max_iter': max_iter} + return recipe, stats + + +class _GeneratorBase: + """Shared state, I/O setup and representation for the random generators.""" + + _suppliers: 'list | None' = None + _max_lead_time: 'float | None' = None + _starting_recipe: 'Recipe | None' = None + _out_key: str | None = None + _data_path: 'Path | None' = None + _recipe_dir: 'Path | None' = None + + def _setup_io( + self, + out_key: str | None, + data_suffix: str, + dir_suffix: str, + skip_directory_creation: bool, + ) -> None: + """Resolve output paths from ``out_key`` and create directories. + + :param out_key: base path/key for output files (required -- there is no + database path to derive it from) + :param data_suffix: suffix for the state JSON, e.g. ``'_rsgen.json'`` + :param dir_suffix: suffix for the recipe directory, e.g. + ``'_recipes_and_selections'`` + :param skip_directory_creation: don't create a recipe directory (used for + the inner generators of a composed generator) + """ + if not out_key: + raise ValueError( + 'out_key is required (used for output filenames; there is no ' + 'database path to derive it from)' + ) + + self._out_key = out_key + + parent_dir = Path(out_key).parent + if not parent_dir.exists(): + parent_dir.mkdir(parents=True) + + self._data_path = Path(f'{out_key}{data_suffix}') + if self._data_path.exists(): + mrich.warning(f'Will overwrite existing data file: {self._data_path}') + + if skip_directory_creation: + self._recipe_dir = None + else: + path = Path(f'{out_key}{dir_suffix}') + if not path.exists(): + mrich.writing(f'{path}/') + path.mkdir() + self._recipe_dir = path + + def _dump_data(self, data: dict) -> None: + """Write the generator state dict to ``data_path``.""" + if self._recipe_dir is not None: + data['recipe_dir'] = str(self._recipe_dir.resolve()) + else: + data['recipe_dir'] = None + data['suppliers'] = self._suppliers + data['starting_recipe'] = self._starting_recipe.get_dict(serialise_price=True) + mrich.writing(self._data_path) + json.dump(data, open(self._data_path, 'w'), indent=4) + + def _write_recipe( + self, recipe: 'Recipe', budget: 'Price', stats: dict, params: dict + ): + """Write a generated recipe to ``{recipe_dir}/Recipe_.json``.""" + out_file = self._recipe_dir / f'Recipe_{dt_hash()}.json' + metadict = { + 'gen_data_path': str(self._data_path.resolve()), + 'gen_recipe_dir': str(self._recipe_dir.resolve()), + 'gen_max_lead_time': self._max_lead_time, + 'gen_suppliers': self._suppliers, + 'gen_budget': budget.amount, + 'gen_currency': budget.currency, + 'gen_stop_reason': stats['stop_reason'], + 'gen_iterations': stats['iterations'], + 'gen_max_iter': stats['max_iter'], + 'gen_recipe_path': str(out_file.resolve()), + **params, + } + recipe.write_json(out_file, extra=metadict) + return out_file + + @property + def suppliers(self) -> 'list | None': + """Restrict quoting to these suppliers""" + return self._suppliers + + @property + def max_lead_time(self) -> 'float | None': + """Maximum lead-time constraint""" + return self._max_lead_time + + @property + def starting_recipe(self) -> 'Recipe': + """The recipe every generation starts from (a copy)""" + return self._starting_recipe + + @property + def out_key(self) -> str | None: + """Base key for output filenames""" + return self._out_key + + @property + def recipe_dir(self) -> 'Path | None': + """Directory generated recipe JSONs are written to""" + return self._recipe_dir + + def __call__(self, *args, **kwargs) -> 'Recipe': + """Generate a recipe (alias for :meth:`generate`)""" + return self.generate(*args, **kwargs) + + def __repr__(self) -> str: + return f'{type(self).__name__}(out_key={self._out_key!r})' + + +class RandomRecipeGenerator(_GeneratorBase): + """Generate random recipes by sampling synthetic :class:`.Route`\\ s.""" + + def __init__( + self, + *, + out_key: str, + suppliers: 'list | None' = None, + route_pool: 'RouteSet | None' = None, + max_lead_time: 'float | None' = None, + start_with: 'Recipe | None' = None, + skip_directory_creation: bool = False, + ) -> None: + mrich.debug('RandomRecipeGenerator.__init__()') + self._suppliers = suppliers + self._max_lead_time = max_lead_time + self._starting_recipe = start_with or Recipe() + self._route_pool = route_pool if route_pool is not None else RouteSet() + + self._setup_io(out_key, '_rgen.json', '_recipes', skip_directory_creation) + self._dump_data({'route_pool': self._route_pool.get_dict()}) + + @property + def route_pool(self) -> 'RouteSet': + """Pool of routes to sample from""" + return self._route_pool + + def generate( + self, + budget: float = 10000, + currency: str = 'EUR', + *, + max_products: int = 1000, + max_reactions: int = 1000, + max_iter: int | None = None, + shuffle: bool = True, + balance_clusters: bool = False, + permitted_clusters=None, + debug: bool = False, + ) -> 'Recipe': + """Generate a random recipe of routes within ``budget`` + (also written to disk).""" + if balance_clusters: + raise NotImplementedError( + 'balance_clusters requires route clustering, which is not implemented' + ) + budget = Price(budget, currency) + recipe, stats = _generate_recipe( + self._starting_recipe, + list(self._route_pool), + budget=budget, + suppliers=self._suppliers, + max_products=max_products, + max_reactions=max_reactions, + max_compounds=_UNLIMITED, + max_iter=max_iter, + shuffle=shuffle, + debug=debug, + ) + self._write_recipe( + recipe, + budget, + stats, + {'gen_max_products': max_products, 'gen_max_reactions': max_reactions}, + ) + return recipe + + +class RandomSelectionGenerator(_GeneratorBase): + """Generate random selections of (catalogue) compounds.""" + + def __init__( + self, + *, + out_key: str, + suppliers: 'list | None' = None, + compounds=None, + amount: float = 1.0, + max_lead_time: 'float | None' = None, + start_with: 'Recipe | None' = None, + skip_directory_creation: bool = False, + ) -> None: + mrich.debug('RandomSelectionGenerator.__init__()') + self._suppliers = suppliers + self._max_lead_time = max_lead_time + self._amount = amount + self._starting_recipe = start_with or Recipe() + if compounds is None: + self._compound_pool = IngredientSet() + else: + self._compound_pool = IngredientSet.from_compounds( + compounds=compounds, amount=amount + ) + + self._setup_io(out_key, '_sgen.json', '_selections', skip_directory_creation) + self._dump_data( + {'amount': amount, 'compound_pool': self._compound_pool.get_dict()} + ) + + @property + def compound_pool(self) -> 'IngredientSet': + """Pool of compound ingredients to sample from""" + return self._compound_pool + + def generate( + self, + budget: float = 10000, + currency: str = 'EUR', + *, + max_compounds: int = 1000, + max_iter: int | None = None, + shuffle: bool = True, + debug: bool = False, + ) -> 'Recipe': + """Generate a random compound selection within ``budget`` + (also written to disk).""" + if max_iter is None: + max_iter = max_compounds * 3 + budget = Price(budget, currency) + recipe, stats = _generate_recipe( + self._starting_recipe, + list(self._compound_pool), + budget=budget, + suppliers=self._suppliers, + max_products=_UNLIMITED, + max_reactions=_UNLIMITED, + max_compounds=max_compounds, + max_iter=max_iter, + shuffle=shuffle, + debug=debug, + ) + self._write_recipe(recipe, budget, stats, {'gen_max_compounds': max_compounds}) + return recipe + + +class RandomRecipeSelectionGenerator(_GeneratorBase): + """Generate random recipes combining synthetic routes and compound selections.""" + + def __init__( + self, + *, + out_key: str, + suppliers: 'list | None' = None, + route_pool: 'RouteSet | None' = None, + compounds=None, + amount: float = 1.0, + max_lead_time: 'float | None' = None, + start_with: 'Recipe | None' = None, + ) -> None: + mrich.debug('RandomRecipeSelectionGenerator.__init__()') + self._suppliers = suppliers + self._max_lead_time = max_lead_time + self._starting_recipe = start_with or Recipe() + + self._setup_io( + out_key, + '_rsgen.json', + '_recipes_and_selections', + skip_directory_creation=False, + ) + + # inner generators build the pools and dump their own state files; they do + # not create recipe directories (only this generator writes recipes) + self._rgen = RandomRecipeGenerator( + out_key=out_key, + suppliers=suppliers, + route_pool=route_pool, + max_lead_time=max_lead_time, + skip_directory_creation=True, + ) + self._sgen = RandomSelectionGenerator( + out_key=out_key, + suppliers=suppliers, + compounds=compounds, + amount=amount, + max_lead_time=max_lead_time, + skip_directory_creation=True, + ) + + # combined pool: compound ingredients followed by routes + self._pool = list(self._sgen.compound_pool) + list(self._rgen.route_pool) + + self._dump_data({}) + + @property + def compound_and_route_pool(self) -> list: + """Combined pool of compound ingredients and routes""" + return self._pool + + def generate( + self, + budget: float = 10000, + currency: str = 'EUR', + *, + max_products: int = 1000, + max_reactions: int = 1000, + max_iter: int | None = None, + shuffle: bool = True, + debug: bool = False, + ) -> 'Recipe': + """Generate a random recipe of routes + compound selections + (also written to disk).""" + budget = Price(budget, currency) + recipe, stats = _generate_recipe( + self._starting_recipe, + list(self._pool), + budget=budget, + suppliers=self._suppliers, + max_products=max_products, + max_reactions=max_reactions, + max_compounds=_UNLIMITED, + max_iter=max_iter, + shuffle=shuffle, + debug=debug, + ) + self._write_recipe( + recipe, + budget, + stats, + {'gen_max_products': max_products, 'gen_max_reactions': max_reactions}, + ) + return recipe diff --git a/hippo/designdb/services/ingestion.py b/hippo/designdb/services/ingestion.py new file mode 100644 index 00000000..111e5aa4 --- /dev/null +++ b/hippo/designdb/services/ingestion.py @@ -0,0 +1,1220 @@ +import logging +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import molparse as mp +import mrich +import pandas as pd +from designdb.components.compound import Ingredient +from designdb.models import ( + CompoundModel, + EnumerationMethodModel, + PoseMethodModel, + PoseModel, + ReactantModel, + ReactionModel, + ScaffoldModel, + ScoringMethodModel, + TargetModel, +) +from designdb.recipe import Recipe, Route +from designdb.services.compound import CompoundService, CompoundTagService +from designdb.services.pose import PoseService, PoseTagService +from designdb.services.pose_score import ScoreService +from designdb.services.reaction import ReactionService +from designdb.services.route import RouteService +from designdb.sets.compound import CompoundSet +from designdb.sets.ingredient import IngredientSet +from designdb.sets.reaction import ReactionSet +from designdb.utils import ( + SanitisationError, + inchikey_from_smiles, + remove_other_ligands, + sanitise_smiles, +) +from designdb.utils_chem import ( + InvalidChemistryError, + UnsupportedChemistryError, + check_chemistry, +) +from designdb.utils_frag import ( + UnsupportedFragalysisLongcodeError, + parse_observation_longcode, +) +from django.db import connection +from numpy import isnan +from pandas import read_pickle + +# from mypackage.services.compound import CompoundService +from rdkit import Chem + +# from rdkit.Chem import inchi +from rdkit.Chem import PandasTools + +# from .validation.compound import ValidationError, validate_compound_data + +SDF_XCAv2_PATTERN = re.compile( + r'^[^.]*-.\d{4}_._\d*_\d_.*-.\d{4}\+.\+\d*\+\d_ligand\.sdf$' +) +SDF_XCAV3_PATTERN = re.compile( + r'^[^.]*-.\d{4}_._\d*_._\d_.*-.\d{4}\+.\+\d*\+.\+\d_ligand\.sdf$' +) + + +SDF_FRAGALYSIS_PATTERN = re.compile(r'^[^.].*\d{4}[a-z].sdf$') +PDBID_PATTERN = re.compile(r'^[A-Za-z0-9]{4}-[a-z].sdf$') + + +logger = logging.getLogger(__name__) + + +@dataclass +class FSRecord: + name: str + path: Path + sdf: Path + pdb: Path + + +def parse_sdf_pandas(sdf_path: Path) -> tuple[str, Chem.rdchem.Mol]: + df = PandasTools.LoadSDF( + str(sdf_path), molColName='ROMol', idName='ID', strictParsing=True + ) + # extract fields + longcode = df.ID[0] + mol = df.ROMol[0] + + return longcode, mol + + +def parse_pdb_mp(pdb_path: Path, residue: int, chain: str) -> str: + logger.debug('Reading %s', pdb_path) + pdb = mp.parse(pdb_path, verbosity=0) + + # create the single ligand bound pdb + lig_residues = pdb.residues['LIG'] + if len(lig_residues) > 1 or any(r.contains_alternative_sites for r in lig_residues): + pdb = remove_other_ligands(pdb, residue, chain) + pdb.prune_alternative_sites('A', verbosity=0) + pose_path = str(pdb_path.resolve()).replace('.pdb', '_hippo.pdb') + # side effect: writes pdb into file + mp.write( + pose_path, pdb, shift_name=True, verbosity=logger.level == logging.DEBUG + ) + else: + pose_path = str(pdb_path.resolve()) + + return pose_path + + +def iter_fs_fragalysis(root_path, skip_records): + assert skip_records is not None, '"None" passed instead as skip_records' + + for dset_path in list(sorted(root_path.glob('[!.]*'))): + if dset_path.name in skip_records: + continue + + sdfs = [] + for sdf_path in dset_path.glob('[!.]*.sdf'): + sdf_name = sdf_path.name + + if ( + '_ligand' in sdf_name + ): # Quick fix, _ligand.sdf are exactly the same as .sdf + # in aligned_directory. + continue + + # fragalysis SDF + if SDF_FRAGALYSIS_PATTERN.match(sdf_name): + sdfs.append(sdf_path) + # fragalysis SDF from PDB id + elif PDBID_PATTERN.match(sdf_name): + sdfs.append(sdf_path) + else: + mrich.warning( + sdf_name, + "doesn't not follow neither Fragalysis nor PDB ID patterns", + ) + sdfs.append(sdf_path) + + if not sdfs: + mrich.error(dset_path.name, 'has no compatible SDFs', dset_path) + continue + + pdbs = [ + p + for p in dset_path.glob('[!.]*.pdb') + if '_ligand' not in p.name + and '_delig' not in p.name # current Fragalysis protein-file naming + and '_hippo' not in p.name + # DEPRECATED(apo-naming): pre-'delig' Fragalysis naming, remove once + # all data uses 'delig' + and '_apo' not in p.name + ] + + if not len(pdbs) == 1: + mrich.error(dset_path.name, 'has invalid PDBs', pdbs) + continue + + record = FSRecord(name=dset_path.name, path=dset_path, sdf=sdfs[0], pdb=pdbs[0]) + + logger.debug('fs_frag record: %s', record) + + yield record + + +# unfinished, seems XCA data is not loaded now +def iter_fs_xca(root_path, skip): + for dset_path in sorted(root_path.glob('*[0-9][0-9][0-9][0-9]')): + if dset_path.name in skip: + continue + + sdfs = [] + + for sdf_path in sorted(dset_path.glob('[!.]*.sdf')): + sdf_name = sdf_path.name + + # TODO: switch between patterns?? + if SDF_XCAv2_PATTERN.match(sdf_name): + sdfs.append(sdf_path) + + if not sdfs: + mrich.error(dset_path.name, 'has no compatible SDFs', dset_path) + continue + + for i, sdf in enumerate(sdfs): + subname = dset_path.name + chr(ord('a') + i) + + pdb = dset_path / sdf.name.replace('_ligand.sdf', '.pdb') + + if not pdb.exists(): + mrich.error(dset_path.name, 'is missing PDB', pdb) + continue + + record = FSRecord(name=subname, path=dset_path, sdf=sdf, pdb=pdb) + + logger.debug('fs_frag record: %s', record) + + yield record + + +def read_df(path: Path): + if path.name.endswith('.sdf'): + df = PandasTools.LoadSDF(str(path.resolve())) + else: + df = read_pickle(path) + + return df + + +def validate_df( + df, + mol_col, + name_col, + inspiration_col, + inspirations, + reference_col, + reference, +): + + # TODO: these are part of input validation and should be removed. or + # at least rewritten + assert mol_col in df.columns, f'{mol_col=} not in {df.columns}' + + if name_col: + assert name_col in df.columns, f'{name_col=} not in {df.columns}' + + if inspiration_col and not inspirations: + assert inspiration_col in df.columns, f'{inspiration_col=} not in {df.columns}' + + if not reference and reference_col: + assert reference_col in df.columns, f'{reference_col=} not in {df.columns}' + + +def preprocess_df( + df, + *, + skip_equal, + skip_not_equal, + name_col: str, +) -> list[dict[str, Any]]: + + mrich.var('SDF entries (pre-filter)', len(df)) + + df = df[df['ID'] != 'ver_1.2'] + + for k, v in skip_equal.items(): + df = df[df[k] == v] + + for k, v in skip_not_equal.items(): + df = df[df[k] != v] + + mrich.var('SDF entries (post-filter)', len(df)) + + df[name_col] = df[name_col].str.strip() + + records = df.to_dict(orient='records') + + return records + + +def metadata_from_record( + record: dict[str, str], + ignore_fields: list[str | None], + convert_floats: bool, + field_warning=None, +) -> dict[str, str | float]: + + result = {} + skip = { + 'smiles', + 'inchikey', + 'compound_id', + 'target_id', + 'reference_id', + 'path', + 'exports', + } + + skip = skip.union(set([k for k in ignore_fields if k])) + + for key, value in record.items(): + if key in skip: + continue + + if isinstance(value, float) and isnan(value): + continue + + if convert_floats: + try: + value = float(value) + except TypeError: + pass + except ValueError: + pass + + if not (isinstance(value, str) or isinstance(value, float)): + if field_warning: + field_warning(mrich.warning(f'Skipping metadata from column={key}.')) + continue + + result[key] = value + + return result + + +@dataclass +class IngestionBatchResult: + attempts: int = 0 + compounds_created: int = 0 + poses_created: int = 0 + + +class IngestionService: + @classmethod + def ingest_filesystem( + cls, + *, + root_path: Path, + target: TargetModel, + skip_records: list[str], + compound_tag_list: list[str], + metadata_file: Path | str, + pose_methods: list[PoseMethodModel] | None = None, + check_rmsd: bool = False, + rmsd_threshold: float = 1.0, + ) -> IngestionBatchResult: + + # this is now strictly for loading frag data. cannot switch inner funcs easily + result = IngestionBatchResult() + compound_tags = CompoundTagService.tags_from_list(compound_tag_list) + pose_tagger = PoseTagService(metadata_file, other_tags=compound_tag_list) + + # if needs xca paths, need to pass or select function + for fs_record in iter_fs_fragalysis(root_path, skip_records): + longcode, mol = parse_sdf_pandas(fs_record.sdf) + logger.debug(fs_record.name, longcode) + result.attempts += 1 + + # TODO: this is the original procedure how it was + # calculated in hippo. I'm not touching it now, but this + # could use a rewrite, it converts smiles back to mol and + # then to inchikey + smiles = mp.rdkit.mol_to_smiles(mol) + # sane_smiles = sanitise_smiles( + # smiles, verbosity=logger.level == logging.DEBUG + # ) + inchikey = inchikey_from_smiles(smiles) + # sane_inchikey = inchikey_from_smiles(sane_smiles) + + # NB! different func if XCA data + try: + longcode_rec = parse_observation_longcode(longcode) + except UnsupportedFragalysisLongcodeError as exc: + # unhandled in original code. do what? + raise UnsupportedFragalysisLongcodeError from exc + + pose_path = parse_pdb_mp( + fs_record.pdb, longcode_rec.residue_number, longcode_rec.chain + ) + + compound, compound_created = CompoundService.create( + # mol=mol, + # smiles=sane_smiles, + smiles=smiles, + # inchikey=sane_inchikey, + ) + compound.tags.add(*compound_tags) + if compound_created: + result.compounds_created += 1 + + # pose_tags = PoseTagService.tags_from_list(pose_tag_set) + pose_tags, metadata = pose_tagger.tags_and_meta( + code=fs_record.name, + longcode=longcode, + ) + + metadata = {'fragalysis_longcode': longcode} + + pose, pose_created = PoseService.create( + compound=compound, + target=target, + mol=mol, + alias=fs_record.name, + path=pose_path, + metadata=metadata, + inchikey=inchikey, + smiles=smiles, + # the first method is the uniqueness/producing method; create() + # associates it. add_hits may request additional method tags, + # which are associated below. + pose_method=pose_methods[0] if pose_methods else None, + check_rmsd=check_rmsd, + rmsd_threshold=rmsd_threshold, + ) + if pose_created: + result.poses_created += 1 + + pose.tags.add(*pose_tags) + + if pose_methods and len(pose_methods) > 1: + pose.methods.add(*pose_methods[1:]) + + # it seems fragalysis data is not expected to contain + # scores + + return result + + @classmethod + def ingest_sdf( + cls, + *, + file_path: Path, + target, + compound_tag_list: list[str], + pose_tag_list: list[str], + enumeration_method_obj: 'EnumerationMethodModel | None' = None, + pose_method_obj: PoseMethodModel | None = None, + score_method_map: dict[str, 'ScoringMethodModel'] | None = None, + mol_col: str, + name_col: str, + inspiration_col: str | None = None, + inspirations: list[int], + inspiration_map: dict[str, PoseModel], + reference: int | None, + reference_col: str, + skip_equal, + skip_not_equal, + convert_floats: bool = True, + field_warning=None, + check_rmsd: bool = False, + rmsd_threshold: float = 1.0, + ) -> IngestionBatchResult: + result = IngestionBatchResult() + + output_directory = Path(str(file_path.name).removesuffix('.sdf')) + output_directory.mkdir(parents=True, exist_ok=True) + + df = read_df(file_path) + validate_df( + df, + mol_col, + name_col, + inspiration_col, + inspirations, + reference_col, + reference, + ) + + compound_tags = CompoundTagService.tags_from_list(compound_tag_list) + pose_tags = PoseTagService.tags_from_list(pose_tag_list) + + # I need to know here one of two things: + # - which scores to create + # - which fields in sdf to ignore + # I mean, probs shouldn't cats smiles, etc as scores + + # it's probably the latter, isn't it? then I don't actually + # need to init scores at all, especially with central + # deisgndb, the scoring method likely exists + + # scorer = ScoreService(['energy_score', 'distance_score']) + scorer = ScoreService() + + records = preprocess_df( + df, + skip_equal=skip_equal, + skip_not_equal=skip_not_equal, + name_col=name_col, + ) + + # temp(?) hack: disable a trigger that runs on every score + # insertion and later enable it + cursor = connection.cursor() + cursor.execute('SELECT designdb.begin_score_values_load();') + + for r in records: + result.attempts += 1 + + # TODO: this is the original procedure how it was + # calculated in hippo. I'm not touching it now, but this + # could use a rewrite, it converts smiles back to mol and + # then to inchikey + smiles = r.get('smiles', None) + if not smiles: + smiles = mp.rdkit.mol_to_smiles(r[mol_col]) + try: + sanitise_smiles( + smiles, + sanitisation_failed='error', + radical='warning', + verbosity=logger.level == logging.DEBUG, + ) + except SanitisationError as e: + mrich.error(f'Could not sanitise {smiles=}') + mrich.error(str(e)) + continue + except AssertionError: + mrich.error(f'Could not sanitise {smiles=}') + continue + + inchikey = inchikey_from_smiles(smiles) + # sane_inchikey = inchikey_from_smiles(sane_smiles) + + compound, compound_created = CompoundService.create( + smiles=smiles, + # mol=r[mol_col], + # smiles=sane_smiles, + # inchikey=sane_inchikey, + ) + compound.tags.add(*compound_tags) + if enumeration_method_obj is not None: + compound.enumeration_methods.add(enumeration_method_obj) + if compound_created: + result.compounds_created += 1 + + pose_inspirations = PoseService.get_inspirations( + inspirations, + inspiration_map.get(r[name_col], []), + r.get(inspiration_col, []) if inspiration_col else None, + target=target, + ) + + if not reference and reference_col: + reference = PoseService.get_reference(r[reference_col], target) + + metadata = metadata_from_record( + r, + ignore_fields=[inspiration_col, name_col, mol_col], + convert_floats=convert_floats, + field_warning=field_warning, + ) + + pose_path = (output_directory / f'{r[name_col]}.fake.mol').resolve() + pose, pose_created = PoseService.create( + compound=compound, + target=target, + mol=r[mol_col], + alias=r[name_col], + path=pose_path, + metadata=metadata, + inchikey=inchikey, + smiles=smiles, + reference=reference, + pose_method=pose_method_obj, + check_rmsd=check_rmsd, + rmsd_threshold=rmsd_threshold, + ) + if pose_created: + result.poses_created += 1 + + pose.tags.add(*pose_tags) + pose.inspirations.add(*PoseModel.objects.filter(pk__in=pose_inspirations)) + + if score_method_map: + scorer.add_scores_from_record( + pose=pose, record=r, score_method_map=score_method_map + ) + else: + scorer.add_scores_from_record(pose=pose, record=r) + + # re-enable trigger and populate matview + cursor.execute('SELECT designdb.begin_score_values_load();') + + return result + + # how is that without target?? + @classmethod + def ingest_syndirella_routes( + cls, + pickle_path: str | Path, + CAR_only: bool = True, + pick_first: bool = True, + do_check_chemistry: bool = True, + register_routes: bool = True, + ): + # this is pretty much a copy from the original method now + df = read_pickle(pickle_path) + + for i, row in mrich.track(df.iterrows(), total=len(df)): + mrich.set_progress_field('i', i) + mrich.set_progress_field('n', len(df)) + + d = row.to_dict() + + # comp = self.compounds(smiles=d['smiles']) + + n_routes = 0 + for key in d: + if not key.startswith('route'): + continue + + if not key.endswith('_names'): + continue + + v = d[key] + + if isinstance(v, float) and pd.isna(v): + break + + n_routes += 1 + + if not n_routes: + # mrich.warning(comp, "#routes =", n_routes) + continue + + # routes = [] + for j in range(n_routes): + route_str = f'route{j}' + + route = d[route_str] + + if CAR_only and not d[route_str + '_CAR']: + continue + + reactions = ReactionSet() + reactants = IngredientSet() + intermediates = IngredientSet() + products = IngredientSet() + + # new models include ReactionModel, ReactantModel and + # ComponentModel. Should use these instead? + + try: + for k, reaction_struct in enumerate(route): + reaction_type = reaction_struct['name'] + + # product = self.compounds(smiles=reaction['productSmiles']) + # no error handling on sanitaiton, catchall at the end + # from original code + + smiles = reaction_struct['productSmiles'] + # sane_smiles = sanitise_smiles( + # smiles, + # sanitisation_failed='error', + # ) + + # sane_inchikey = inchikey_from_smiles(sane_smiles) + product = CompoundService.get_by_smiles(smiles=smiles) + + mrich.print(i, j, k, reaction_type, product) + + reaction, _ = ReactionModel.objects.get_or_create( + reaction_type=reaction_type, + product_compound=product, + ) + + rs = [] + print('reactant smiles', reaction_struct['reactantSmiles']) + for reactant_s in reaction_struct['reactantSmiles']: + reactant_comp, _ = CompoundService.create(smiles=reactant_s) + reactant, _ = ReactantModel.objects.get_or_create( + compound=reactant_comp, + reaction=reaction, + ) + rs.append(reactant_comp.pk) + + if do_check_chemistry and not check_chemistry( + reaction_type, CompoundSet(rs), product + ): + raise InvalidChemistryError( + f'{type=}, {rs=}, {product.id=}', + ) + + for r_id in rs: + if r_id in reactants: + intermediates.add(compound_id=r_id, amount=1) + else: + reactants.add(compound_id=r_id, amount=1) + + reactions.add(reaction) + + except InvalidChemistryError: + continue + except UnsupportedChemistryError: + mrich.warning('Skipping unsupported chemistry:', reaction_type) + continue + # except Exception: + # mrich.error( + # 'Uncaught error with row', i, 'route', j, 'reaction', k + # ) + # continue + + products.add(Ingredient.from_compound(product, amount=1)) + + recipe = Recipe( + reactions=reactions, + reactants=reactants, + intermediates=intermediates, + products=products, + ) + + if register_routes: + route, _ = RouteService.create_from_recipe( + recipe=recipe, + ) + mrich.success('registered route', route.pk) + + if pick_first: + break + + return df + + @classmethod + def ingest_enamine_real_routes( + cls, + csv_path: str | Path, + do_check_chemistry: bool = True, + register_routes: bool = True, + ): + df = pd.read_csv(csv_path) + steps = len([col for col in df.columns if 'product_step' in col]) + + for i, row in mrich.track(df.iterrows(), total=len(df)): + mrich.set_progress_field('i', i) + mrich.set_progress_field('n', len(df)) + + d = row.to_dict() + + reactions = ReactionSet() + reactants = IngredientSet() + intermediates = IngredientSet() + products = IngredientSet() + + product = None + try: + for step_id in range(1, steps + 1): + r1_smiles = d.get(f'reactant_step{step_id}') + if not r1_smiles or ( + isinstance(r1_smiles, float) and isnan(r1_smiles) + ): + continue + + reaction_type = d[f'reaction_name_step{step_id}'] + product = CompoundService.get_by_smiles(smiles=d['smiles']) + + mrich.print(i, step_id, reaction_type, product) + + reactant_smiles = [r1_smiles] + r2_smiles = d.get(f'reactant2_step{step_id}') + if r2_smiles and not ( + isinstance(r2_smiles, float) and isnan(r2_smiles) + ): + reactant_smiles.append(r2_smiles) + + reaction, _ = ReactionModel.objects.get_or_create( + reaction_type=reaction_type, + product_compound=product, + ) + + rs = [] + for smiles in reactant_smiles: + reactant_comp, _ = CompoundService.create(smiles=smiles) + reactant, _ = ReactantModel.objects.get_or_create( + compound=reactant_comp, + reaction=reaction, + ) + rs.append(reactant_comp.pk) + + if do_check_chemistry and not check_chemistry( + reaction_type, CompoundSet(rs), product + ): + raise InvalidChemistryError( + f'{reaction_type=}, {rs=}, {product.id=}', + ) + + for r_id in rs: + if r_id in reactants: + intermediates.add(compound_id=r_id, amount=1) + else: + reactants.add(compound_id=r_id, amount=1) + + reactions.add(reaction) + + except InvalidChemistryError: + continue + except UnsupportedChemistryError: + mrich.warning('Skipping unsupported chemistry:', reaction_type) + continue + except Exception: + mrich.error('Uncaught error with row', i) + raise + + if product is None: + continue + + products.add(Ingredient.from_compound(product, amount=1)) + + recipe = Recipe( + reactions=reactions, + reactants=reactants, + intermediates=intermediates, + products=products, + ) + + if register_routes: + route, _ = RouteService.create_from_recipe(recipe=recipe) + mrich.success('registered route', route.pk) + + return df + + @classmethod + def ingest_syndirella_elabs( + cls, + *, + df: pd.DataFrame, + target: TargetModel, + reject_flags: list[str], + pose_tag_list: list[str], + product_tag_list: list[str], + max_energy_score: float, + max_distance_score: float, + require_intra_geometry_pass: bool, + register_reactions: bool, + scaffold_route: Route | None = None, + scaffold_compound: CompoundModel | None = None, + ) -> pd.DataFrame: + + # work out number of reaction steps + num_steps = max( + [int(s.split('_')[0]) for s in df.columns if '_product_smiles' in s] + ) + mrich.var('num_steps', num_steps) + + # add is_scaffold row + df['is_scaffold'] = df[f'{num_steps}_product_name'].str.contains('scaffold') + + ###### PREP ###### + + # flags + + present_flags = set() + for step in range(num_steps): + step += 1 + + for flags in set(df[df[f'{step}_flag'].notna()][f'{step}_flag'].to_list()): + for flag in flags: + present_flags.add(flag) + + if present_flags: + mrich.warning('Flags in DataFrame:', present_flags) + + for flag in reject_flags: + if flag in present_flags: + for step in range(num_steps): + step += 1 + matches = df[f'{step}_flag'].apply( + lambda x, flag=flag: flag in x if x is not None else False + ) + mrich.print( + 'Filtering out', + len(df[matches]), + 'rows from step', + step, + 'due to', + flag, + ) + df = df[~matches] + + # poses + + n_null_mol = len(df[df['path_to_mol'].isna()]) + if n_null_mol: + df = df[df['path_to_mol'].notna()] + mrich.var('#rows skipped due to null path_to_mol', n_null_mol) + + if not len(df): + mrich.warning('No valid rows') + return None + + # inspirations + inspiration_sets = set(tuple(sorted(i)) for i in df['regarded']) + # smth like {('z0637a', 'z1040a')} + + if len(inspiration_sets) != 1: + mrich.error('Varying inspirations not supported') + return df + + (inspiration_set,) = inspiration_sets + + inspirations = PoseModel.objects.filter( + pose_alias__in=inspiration_set, + target=target, + ) + + if inspirations.count() != len(inspiration_set): + print('target', target) + print('inspiration_set', inspiration_set) + print('inspiration comparison', inspirations.count(), len(inspiration_set)) + assert inspirations.count() == len(inspiration_set) + + # reference + template_paths = set(df['template'].to_list()) + assert len(template_paths) == 1, 'Multiple references not supported' + (template_path,) = template_paths + template_path = Path(template_path) + mrich.var('template_path', template_path) + base_name = template_path.name.removesuffix('.pdb').removesuffix( + '_delig-desolv' + ) + # DEPRECATED(apo-naming): pre-'delig' Fragalysis naming, remove once all + # data uses 'delig' + base_name = base_name.removesuffix('_apo-desolv') + # reference = self.poses[base_name] + + # TODO: error handling + reference = PoseModel.objects.get( + pose_alias=base_name, + target=target, + ) + + assert reference, 'Could not determine reference structure' + mrich.var('reference', reference) + + # that's nice but I need it before that + # target = reference.target + + # subset of rows + scaffold_df = df[df['is_scaffold']] + elab_df = df[~df['is_scaffold']] + mrich.var('#scaffold entries', len(scaffold_df)) + mrich.var('#elab entries', len(elab_df)) + + if not len(scaffold_df) and not scaffold_route and not scaffold_compound: + mrich.error('No valid scaffold rows') + return None + + elif scaffold_route: + ### SUPPLEMENT THE SCAFFOLD ROWS FROM KNOWN ROUTE + + assert scaffold_route.num_reactions == 1 + + product = scaffold_route.products[0].compound + reaction = scaffold_route.reactions[0] + + assert reaction.reactants.count() == 2 + + scaffold_dict = { + 'scaffold_smiles': product.compound_smiles, + '1_reaction': reaction.reaction_type, + # this is so hacky + '1_r1_smiles': reaction.reactants.first().compound.compound_smiles, + '1_r2_smiles': reaction.reactants.last().compound.compound_smiles, + '1_product_smiles': product.compound_smiles, + '1_product_name': 'scaffold', + '1_single_reactant_elab': False, + '1_num_atom_diff': 0, + 'is_scaffold': True, + } + + scaffold_df = pd.DataFrame([scaffold_dict]) + + df = pd.concat([scaffold_df, df]) + + scaffold_df = df[df['is_scaffold']] + elab_df = df[~df['is_scaffold']] + + elif scaffold_compound: + ### SUPPLEMENT PARTIAL SCAFFOLD ROWS FROM KNOWN PRODUCT + + scaffold_dict = { + 'scaffold_smiles': scaffold_compound.smiles, + 'is_scaffold': True, + } + + scaffold_df = pd.DataFrame([scaffold_dict]) + + df = pd.concat([scaffold_df, df]) + + scaffold_df = df[df['is_scaffold']] + elab_df = df[~df['is_scaffold']] + + # if dry_run: + # mrich.error('Not registering records (dry_run)') + # return df + + ###### ELABS ###### + + # bulk register compounds + + smiles_cols = [ + c for c in df.columns if c.endswith('_smiles') and c != 'scaffold_smiles' + ] + + for smiles_col in smiles_cols: + inchikey_col = smiles_col.replace('_smiles', '_inchikey') + compound_id_col = smiles_col.replace('_smiles', '_compound_id') + + unique_smiles = df[smiles_col].dropna().unique() + + mrich.debug( + f'Registering {len(unique_smiles)} compounds from column: {smiles_col}' + ) + + # radical? + values = CompoundService.create_from_smiles_list(unique_smiles) + + orig_smiles_to_inchikey = { + orig_smiles: inchikey + for orig_smiles, (inchikey, new_smiles) in zip( + unique_smiles, values, strict=False + ) + } + + df[inchikey_col] = df[smiles_col].apply( + lambda x, m=orig_smiles_to_inchikey: m.get(x) + ) + + # get associated IDs + compound_inchikey_id_dict = { + k.compound_inchikey: k.pk + for k in CompoundModel.objects.filter(compound_smiles__in=unique_smiles) + } + df[compound_id_col] = df[inchikey_col].apply( + lambda x, m=compound_inchikey_id_dict: m.get(x) + ) + + # bulk register reactions + + if register_reactions: + for step in range(num_steps): + step += 1 + + mrich.debug(f'Registering reactions for step {step}') + + reaction_dicts = [] + + for reaction_name, r1_id, r2_id, product_id in df[ + [ + f'{step}_reaction', + f'{step}_r1_compound_id', + f'{step}_r2_compound_id', + f'{step}_product_compound_id', + ] + ].values: + # skip invalid rows + if pd.isna(r1_id) or pd.isna(product_id): + mrich.warning("Can't insert reactions for missing scaffold") + continue + + # reactant IDs + + reactant_ids = set() + reactant_ids.add(int(r1_id)) + + if not pd.isna(r2_id): + reactant_ids.add(int(r2_id)) + + product_id = int(product_id) + + # registration data + + reaction_dicts.append( + dict( + reaction_name=reaction_name, + reactant_ids=reactant_ids, + product_id=int(product_id), + ) + ) + + # why is this outside of loop? + _ = ReactionService.create_from_lists( + reaction_types=[d['reaction_name'] for d in reaction_dicts], + product_ids=[d['product_id'] for d in reaction_dicts], + reactant_id_lists=[d['reactant_ids'] for d in reaction_dicts], + ) + + scaffold_df = df[df['is_scaffold']] + elab_df = df[~df['is_scaffold']] + + # tag product compounds: + + product_ids = list(df[f'{num_steps}_product_compound_id'].dropna().unique()) + products = CompoundModel.objects.filter(pk__in=product_ids) + product_tags = CompoundTagService.tags_from_list(product_tag_list) + for compound in products: + compound.tags.add(*product_tags) + + # bulk register scaffold relationships + + for step in range(num_steps): + step += 1 + + for role in ['r1', 'r2', 'product']: + key = f'{step}_{role}_compound_id' + + mrich.debug(f'Registering scaffold relatonships for {key}') + + if step == num_steps and role == 'product' and scaffold_compound: + scaffold_id = scaffold_compound.id + + else: + scaffold_ids = list(scaffold_df[key].dropna().unique()) + + if not scaffold_ids: + mrich.warning( + "Can't insert scaffold relationships due to missing", + key, + 'for all scaffold rows', + ) + continue + + if len(scaffold_ids) > 1: + mrich.error('Multiple scaffold row values in', key) + return scaffold_df + + scaffold_id = scaffold_ids[0] + + # original code didn't do dropna? how? filter in later step? + superstructure_ids = [ + i for i in elab_df[key].dropna().unique() if i != scaffold_id + ] + + # comp service? + for superstructure_id in superstructure_ids: + base = CompoundModel.objects.get(pk=scaffold_id) + superstructure = CompoundModel.objects.get( + pk=int(superstructure_id) + ) + ScaffoldModel.objects.get_or_create( + base_compound=base, + superstructure_compound=superstructure, + ) + + # filter poses + + ok = df + + try: + if require_intra_geometry_pass: + mrich.var( + '#poses !intra_geometry_pass', + len(df[df['intra_geometry_pass'] == False]), # noqa: E712 + ) + ok = ok[ok['intra_geometry_pass'] == True] # noqa: E712 + + if max_energy_score is not None: + mrich.var( + f'#poses ∆∆G > {max_energy_score}', + len(df[df['∆∆G'] > max_energy_score]), + ) + ok = ok[ok['∆∆G'] <= max_energy_score] + + if max_distance_score is not None: + mrich.var( + f'#poses comRMSD > {max_distance_score}', + len(df[df['comRMSD'] > max_energy_score]), + ) + ok = ok[ok['comRMSD'] <= max_distance_score] + + except Exception as e: + mrich.error('Problem filtering dataframe') + mrich.error(e) + return df + + mrich.var('#acceptable poses', len(ok)) + + if not len(ok): + mrich.warning('No valid poses') + return None + + # bulk register poses + + pose_ids = [] + scorer = ScoreService() + for _, row in ok.iterrows(): + path = Path(row.path_to_mol).resolve() + print('comp id in row', row[f'{num_steps}_product_compound_id']) + + # closed for testing + if not path.exists(): + mrich.warning('Skipping pose w/ non-exising file:', path) + continue + + if pd.isna(row[f'{num_steps}_product_compound_id']): + continue + + pose, created = PoseService.create_from_record( + compound_id=int(row[f'{num_steps}_product_compound_id']), + target_id=int(target.id), + reference=int(reference.id), + path=str(path), + ) + if created: + scores = { + 'energy_score': float(row['∆∆G']), + 'distance_score': float(row['comRMSD']), + } + pose_ids.append(pose.id) + scorer.add_scores_from_record(pose=pose, record=scores) + + if not pose_ids: + mrich.warning('No valid poses') + return None + + poses = PoseModel.objects.filter(pk__in=pose_ids) + mrich.success('Registered', poses.count(), 'new poses') + + # query relevant poses (also previously registered) + paths = poses.values_list('path', flat=True) + + # what the hell is this?? + records = PoseModel.objects.filter( + path__in=paths, + ) + for pose in records: + # pose.inspirations.add(*PoseModel.objects.filter(pk__in=inspiration.ids)) + pose.inspirations.add(*inspirations.queryset) + + # if pose_tags: + pose_tags = PoseTagService.tags_from_list(pose_tag_list) + for pose in poses: + pose.tags.add(*pose_tags) + + return df + + +# def create_compound(...): +# assert connection.in_atomic_block diff --git a/hippo/designdb/services/interaction.py b/hippo/designdb/services/interaction.py new file mode 100644 index 00000000..d940a5a7 --- /dev/null +++ b/hippo/designdb/services/interaction.py @@ -0,0 +1,345 @@ +"""Protein-ligand interaction fingerprinting. + +Detects interactions for a :class:`.Pose`: extracts protein features (populating +:class:`.FeatureModel`), runs the geometric detector, resolves duplicate +interactions, and populates :class:`.InteractionModel`. Protein features come from +the pose's own ``protein_system``. Entry point: :meth:`.Pose.calculate_interactions`. +""" + +import json + +import mrich +import numpy as np +from designdb.interactions import ( + COMPLEMENTARY_FEATURES, + INTERACTION_CUTOFF, + INTERACTION_TYPES, + PI_STACK_F2F_CUTOFF, + PI_STACK_MIN_CUTOFF, +) +from designdb.models import FeatureModel, InteractionModel + + +def _norm(coords) -> np.ndarray: + """Principal axis (first eigenvector of the covariance) of a set of points.""" + coords = np.array(coords).T + cov = np.cov(coords) + eig = np.linalg.eig(cov) + return eig[1][:, 0] + + +def _unit_vector(vector) -> np.ndarray: + """Unit vector in the direction of ``vector``.""" + return vector / np.linalg.norm(vector) + + +def _angle_between(v1, v2) -> float: + """Angle (degrees, folded to 0-90) between two vectors.""" + v1_u = _unit_vector(v1) + v2_u = _unit_vector(v2) + a = 180 * np.arccos(np.clip(np.dot(v1_u, v2_u), -1.0, 1.0)) / np.pi + if a > 90: + a = 180 - a + return a + + +class InteractionService: + """Construction and persistence of pose-protein interactions.""" + + @staticmethod + def calculate( + pose, + *, + resolve: bool = True, + distance_padding: float = 0.0, + angle_padding: float = 0.0, + force: bool = False, + commit: bool = True, + debug: bool = False, + ) -> None: + """Enumerate valid interactions between a pose's ligand and protein. + + :param pose: the :class:`.Pose` to fingerprint + :param resolve: cull duplicate / less-significant interactions + :param distance_padding: padding (Angstrom) added to all distance cutoffs + :param angle_padding: padding (degrees) added to all angle cutoffs + :param force: recalculate even if the pose is already fingerprinted + :param commit: persist the results to the database + :param debug: increase verbosity + """ + + if pose.has_fingerprint and not force: + if debug: + mrich.warning(f'{pose} is already fingerprinted') + return + + protein_system = pose.protein_system + if protein_system is None and pose.reference is not None: + protein_system = pose.reference.protein_system + + if protein_system is None: + raise NotImplementedError( + f'No protein system for {pose} ' + f'(protein_link={pose.protein_link!r}, reference={pose.reference_id})' + ) + + mol = pose.mol + if mol is None: + mrich.error(f'Could not read molecule for {pose}') + return + + candidates = InteractionService._detect( + pose=pose, + protein_system=protein_system, + mol=mol, + distance_padding=distance_padding, + angle_padding=angle_padding, + debug=debug, + ) + + if resolve: + candidates = InteractionService._resolve(candidates, debug=debug) + + if not commit: + return + + # replace any existing interactions for this pose + InteractionModel.objects.filter(pose_id=pose.id).delete() + + InteractionModel.objects.bulk_create( + [ + InteractionModel( + feature_id=c['feature_id'], + pose_id=pose.id, + interaction_type=c['type'], + interaction_family=c['family'], + interaction_atom_id=json.dumps(c['atom_ids']), + interaction_prot_coord=json.dumps(c['prot_coord']), + interaction_lig_coord=json.dumps(c['lig_coord']), + interaction_distance=c['distance'], + interaction_angle=c['angle'], + interaction_energy=None, + ) + for c in candidates + ] + ) + + pose.set_has_fingerprint(True, commit=commit) + + if debug: + mrich.success(f'{pose}: {len(candidates)} interactions') + + @staticmethod + def _protein_feature_id(target, prot_feature) -> int: + """Get-or-create the :class:`.FeatureModel` for a molparse protein feature.""" + atom_name = ' '.join(a.name for a in prot_feature.atoms) + feature, _ = FeatureModel.objects.get_or_create( + feature_family=prot_feature.family, + target=target, + feature_chain_name=prot_feature.res_chain, + feature_residue_name=prot_feature.res_name, + feature_residue_number=prot_feature.res_number, + feature_atom_name=atom_name, + ) + return feature.id + + @staticmethod + def _detect( + pose, protein_system, mol, distance_padding, angle_padding, debug + ) -> list[dict]: + """Run the geometric detector, returning candidate interaction dicts.""" + + target = pose.target + + # organise ligand features by family + comp_features_by_family: dict[str, list] = {} + for f in pose.features: + comp_features_by_family.setdefault(f.family, []).append(f) + + candidates: list[dict] = [] + + for prot_feature in protein_system.get_protein_features(): + prot_family = prot_feature.family + + if prot_family not in COMPLEMENTARY_FEATURES: + continue + + prot_coords = [a.np_pos for a in prot_feature.atoms] + if not prot_coords: + continue + prot_coord = np.sum(prot_coords, axis=0) / len(prot_coords) + + feature_id = InteractionService._protein_feature_id(target, prot_feature) + + for complementary_family in COMPLEMENTARY_FEATURES[prot_family]: + interaction_type = INTERACTION_TYPES[ + (prot_family, complementary_family) + ] + + for lig_feature in comp_features_by_family.get( + complementary_family, [] + ): + lig_pos = np.asarray(lig_feature.position) + distance = float(np.linalg.norm(lig_pos - prot_coord)) + angle = None + + if ( + distance + > INTERACTION_CUTOFF[interaction_type] + distance_padding + ): + continue + + lig_coords = None + if interaction_type.startswith('π'): + conf = mol.GetConformer() + lig_coords = [ + np.array(conf.GetAtomPosition(i - 1)) + for i in lig_feature.atom_numbers + ] + + if interaction_type == 'π-stacking': + # require at least one atom within the min cutoff + min_distance = min( + float(np.linalg.norm(lig_coord - p_coord)) + for lig_coord in lig_coords + for p_coord in prot_coords + ) + if min_distance > PI_STACK_MIN_CUTOFF + distance_padding: + continue + + lig_norm = _norm([list(p) for p in lig_coords]) + prot_norm = _norm(prot_coords) + angle = _angle_between(lig_norm, prot_norm) + + # face-to-face has a stricter distance cutoff + if ( + angle < 40 - angle_padding + and distance > PI_STACK_F2F_CUTOFF + distance_padding + ): + continue + + elif interaction_type == 'π-cation': + if prot_family == 'Aromatic': + aromatic_norm = _norm(prot_coords) + cation_vec = lig_pos - prot_coord + else: + aromatic_norm = _norm([list(p) for p in lig_coords]) + cation_vec = prot_coord - lig_pos + + angle = _angle_between(aromatic_norm, cation_vec) + if angle > 30 + angle_padding: + continue + + candidates.append( + { + 'feature_id': feature_id, + 'feature_family': prot_family, + 'feature_atom_name': ' '.join( + a.name for a in prot_feature.atoms + ), + 'type': interaction_type, + 'family': lig_feature.family, + 'atom_ids': [int(i) for i in lig_feature.atom_numbers], + 'prot_coord': [float(x) for x in prot_coord], + 'lig_coord': [float(x) for x in lig_pos], + 'distance': distance, + 'angle': None if angle is None else float(angle), + } + ) + + if debug: + mrich.debug(f'{pose}: {len(candidates)} candidate interactions') + + return candidates + + @staticmethod + def _resolve(candidates: list[dict], debug: bool = False) -> list[dict]: + """Cull duplicate / less-significant interactions. + + Keeps, per interaction type: the closest interaction per ligand-atom group + (Hydrogen Bond, π-cation, Electrostatic), the closest per protein feature + (π-stacking), all Sulfur-Sulfur, and -- for Hydrophobic -- de-duplicates + lumped vs. single hydrophobes then keeps the closest per protein feature. + """ + + for i, c in enumerate(candidates): + c['_idx'] = i + + keep: set[int] = set() + + def keep_min_per(predicate, key) -> None: + """Keep the min-distance candidate within each ``key`` group.""" + best: dict = {} + for c in candidates: + if not predicate(c): + continue + k = key(c) + if k not in best or c['distance'] < best[k]['distance']: + best[k] = c + keep.update(c['_idx'] for c in best.values()) + + keep_min_per( + lambda c: c['type'] == 'Hydrogen Bond', lambda c: tuple(c['atom_ids']) + ) + keep_min_per(lambda c: c['type'] == 'π-stacking', lambda c: c['feature_id']) + keep_min_per(lambda c: c['type'] == 'π-cation', lambda c: tuple(c['atom_ids'])) + keep_min_per( + lambda c: c['type'] == 'Electrostatic', lambda c: tuple(c['atom_ids']) + ) + + # Sulfur-Sulfur: keep all + keep.update(c['_idx'] for c in candidates if c['type'] == 'Sulfur-Sulfur') + + # Hydrophobic: de-duplicate lumped vs. single hydrophobes + hydrophobic = [c for c in candidates if c['type'] == 'Hydrophobic'] + + covered: dict = {} + lumped_lumped: dict = {} + for c in hydrophobic: + families = (c['feature_family'], c['family']) + names = c['feature_atom_name'].split() + if families == ('LumpedHydrophobe', 'Hydrophobe'): + for name in names: + covered.setdefault((name, c['atom_ids'][0]), []).append(c['_idx']) + elif families == ('Hydrophobe', 'LumpedHydrophobe'): + for atom_id in c['atom_ids']: + covered.setdefault((c['feature_atom_name'], atom_id), []).append( + c['_idx'] + ) + elif families == ('LumpedHydrophobe', 'LumpedHydrophobe'): + for name in names: + for atom_id in c['atom_ids']: + covered.setdefault((name, atom_id), []).append(c['_idx']) + lumped_lumped[c['feature_atom_name']] = tuple(c['atom_ids']) + + keep_hydrophobic = {c['_idx'] for c in hydrophobic} + for c in hydrophobic: + families = (c['feature_family'], c['family']) + if families == ('Hydrophobe', 'Hydrophobe'): + if (c['feature_atom_name'], c['atom_ids'][0]) in covered: + keep_hydrophobic.discard(c['_idx']) + elif families == ('LumpedHydrophobe', 'Hydrophobe'): + value = lumped_lumped.get(c['feature_atom_name']) + if value is not None and c['atom_ids'][0] in value: + keep_hydrophobic.discard(c['_idx']) + + # of the surviving hydrophobes, keep the closest per protein feature + by_idx = {c['_idx']: c for c in candidates} + best_per_feature: dict = {} + for idx in keep_hydrophobic: + c = by_idx[idx] + k = c['feature_id'] + if k not in best_per_feature or ( + c['distance'] < best_per_feature[k]['distance'] + ): + best_per_feature[k] = c + keep.update(c['_idx'] for c in best_per_feature.values()) + + resolved = [c for c in candidates if c['_idx'] in keep] + for c in candidates: + c.pop('_idx', None) + + if debug: + mrich.debug(f'resolved {len(candidates)} -> {len(resolved)} interactions') + + return resolved diff --git a/hippo/designdb/services/method.py b/hippo/designdb/services/method.py new file mode 100644 index 00000000..9b1c3796 --- /dev/null +++ b/hippo/designdb/services/method.py @@ -0,0 +1,48 @@ +import logging + +from designdb.models import EnumerationMethodModel, PoseMethodModel, ScoringMethodModel + +logger = logging.getLogger(__name__) + + +class MethodService: + @classmethod + def register_enumeration_method( + cls, name: str, version: str, description: str = '' + ): + obj, created = EnumerationMethodModel.objects.get_or_create( + enum_name=name, + enum_version=version, + defaults={'enum_description': description}, + ) + return obj, created + + @classmethod + def register_pose_method(cls, name: str, version: str, description: str = ''): + obj, created = PoseMethodModel.objects.get_or_create( + pose_method_name=name, + pose_method_version=version, + defaults={'pose_method_description': description}, + ) + return obj, created + + @classmethod + def register_scoring_method(cls, name: str, version: str, description: str = ''): + obj, created = ScoringMethodModel.objects.get_or_create( + method_name=name, + method_version=version, + defaults={'method_description': description}, + ) + return obj, created + + @classmethod + def get_enumeration_methods(cls): + return EnumerationMethodModel.objects.all() + + @classmethod + def get_pose_methods(cls): + return PoseMethodModel.objects.all() + + @classmethod + def get_scoring_methods(cls): + return ScoringMethodModel.objects.all() diff --git a/hippo/designdb/services/pose.py b/hippo/designdb/services/pose.py new file mode 100644 index 00000000..5c4e808e --- /dev/null +++ b/hippo/designdb/services/pose.py @@ -0,0 +1,263 @@ +import json +import logging +import re +from collections.abc import Iterable +from pathlib import Path + +import mrich +import pandas as pd +import rdkit + +# from rdkit.Chem import inchi +from designdb.models import ( + CompoundModel, + PoseMethodModel, + PoseModel, + PoseTagModel, + TargetModel, +) +from designdb.utils import normalize_string_list +from designdb.utils_chem import get_rmsd +from designdb.utils_frag import GENERATED_TAG_COLS, META_IGNORE_COLS +from django.db.models import Q + +# from mypackage.services.compound import CompoundService +from rdkit import Chem + +# from .validation.compound import ValidationError, validate_compound_data + +SDF_XCAv2_PATTERN = re.compile( + r'^.*-.\d{4}_._\d*_\d_.*-.\d{4}\+.\+\d*\+\d_ligand\.sdf$' +) +SDF_XCAV3_PATTERN = re.compile( + r'^.*-.\d{4}_._\d*_._\d_.*-.\d{4}\+.\+\d*\+.\+\d_ligand\.sdf$' +) + + +SDF_FRAGALYSIS_PATTERN = re.compile(r'^.*\d{4}[a-z].sdf$') +PDBID_PATTERN = re.compile(r'^[A-Za-z0-9]{4}-[a-z].sdf$') + + +logger = logging.getLogger(__name__) + + +class PoseService: + @classmethod + def create( + cls, + *, + compound: CompoundModel, + target: TargetModel, + mol: Chem.rdchem.Mol, + alias: str, + path: str, + metadata: dict[str, str], + inchikey: str, + smiles: str, + reference: int | None = None, + pose_method: 'PoseMethodModel | None' = None, + check_rmsd: bool = False, + rmsd_threshold: float = 1.0, + ): + qs = PoseModel.objects.filter( + target=target, + compound=compound, + pose_alias=alias, + ) + if pose_method: + qs = qs.filter(methods=pose_method) + + # (target, compound, pose_alias, method) should identify one pose + try: + existing = qs.get() + except PoseModel.DoesNotExist: + existing = None + else: + # overwrite metadata on the matching pose + existing.pose_metadata = json.dumps(metadata) + existing.save() + return existing, False + + if check_rmsd and ( + duplicate := cls.find_rmsd_duplicate( + mol, compound, target, rmsd_threshold, pose_method=pose_method + ) + ): + return duplicate, False + + pose = PoseModel( + compound=compound, + target=target, + pose_alias=alias, + protein_link=path, + pose_inchikey=inchikey, # SQLITE_RELIC + pose_smiles=smiles, # SQLITE_RELIC + pose_metadata=json.dumps(metadata), + pose_mol=mol, + # pose_mol=Chem.MolToMolBlock(mol), + rdkit_version=rdkit.__version__, + inchi_version=Chem.inchi.GetInchiVersion(), + pose_reference=reference, + ) + pose.save() + + # associate the method here so subsequent loads dedup correctly + if pose_method is not None: + pose.methods.add(pose_method) + + return pose, True + + @classmethod + def find_rmsd_duplicate( + cls, + mol: 'Chem.rdchem.Mol', + compound: 'CompoundModel', + target: 'TargetModel', + rmsd_threshold: float, + pose_method: 'PoseMethodModel | None' = None, + ) -> 'PoseModel | None': + # only compare against poses produced by the same method + candidates = PoseModel.objects.filter(compound=compound, target=target) + if pose_method is not None: + candidates = candidates.filter(methods=pose_method) + for existing in candidates: + try: + rmsd = get_rmsd(mol, existing.pose_mol) + if rmsd < rmsd_threshold: + logger.warning( + 'Pose RMSD %.3f Å below threshold %.3f Å, ' + 'skipping duplicate (alias=%s)', + rmsd, + rmsd_threshold, + existing.pose_alias, + ) + return existing + except Exception: + logger.warning('RMSD calculation failed for pose pk=%s', existing.pk) + return None + + @classmethod + def create_from_record( + cls, + *, + compound_id: int, + target_id: int, + path: str, + reference: int | None = None, + ): + target = TargetModel.objects.get(pk=target_id) + compound = CompoundModel.objects.get(pk=compound_id) + pose, created = PoseModel.objects.get_or_create( + compound=compound, + target=target, + protein_link=path, + reference=reference, + ) + return pose, created + + # this is parsing input, maybe in ingestion? + @staticmethod + def get_inspirations(*args, target: TargetModel | None = None): + parsed = [] + for el in args: + if isinstance(el, str): + parsed.extend(normalize_string_list(el)) + elif isinstance(el, Iterable) and not isinstance(el, dict): + parsed.extend(el) + else: + logger.warning( + 'Unsupported inspiration collection received: %s', + type(el), + ) + + # inputs can be pk or name + pks = [] + aliases = [] + + for val in parsed: + try: + pks.append(int(val)) + except ValueError: + # assume string alias + aliases.append(val) + + qs = PoseModel.objects.filter( + Q(pk__in=pks) | Q(pose_alias__in=aliases, target=target) + ) + + return qs + + @staticmethod + def get_reference(reference, target) -> int: + try: + reference = int(reference) + # should I check if exist here as well? + except ValueError: + try: + reference = PoseModel.objects.get( + pose_alias=reference, + target=target, + ).pk + except PoseModel.DoesNotExist as exp: + logger.error('PoseModel %s does not exist', reference) + raise PoseModel.DoesNotExist from exp + + return reference + + +class PoseTagService: + def __init__(self, metadata_file: Path | str, other_tags: list[str] | None = None): + self._df = pd.read_csv(metadata_file) + self._curated_tag_cols = [ + c + for c in self._df.columns + if c not in META_IGNORE_COLS + GENERATED_TAG_COLS + ] + # any other tags to be added + if other_tags: + self._other_tags = [k.strip() for k in other_tags if k.strip()] + else: + self._other_tags = [] + + mrich.var('curated_tag_cols', self._curated_tag_cols) + + @staticmethod + def tags_from_list(tag_list: list[str]): + assert tag_list is not None, '"None" passed as tag_list' + + PoseTagModel.objects.bulk_create( + [PoseTagModel(pose_tag_name=k.strip()) for k in tag_list if k.strip()], + ignore_conflicts=True, + ) + tags = PoseTagModel.objects.filter(pose_tag_name__in=tag_list) + return tags + + # might be a good idea to break meta and tags apart + def tags_and_meta( + self, + *, + code: str, + longcode: str, + ) -> tuple[list[PoseTagModel], dict[str, str]]: + meta_row = self._df[self._df['Code'] == code] + if not len(meta_row): + meta_row = self._df[self._df['Long code'] == longcode] + + # TODO: another unhandled exception, apprently not having + # meta_row is an option + + metadata = {'fragalysis_longcode': meta_row['Long code'].values[0]} + + for tag in GENERATED_TAG_COLS: + if tag in meta_row.columns: + metadata[tag] = meta_row[tag].values[0] + + pose_tag_set = set(self._other_tags) + + for tag in self._curated_tag_cols: + if meta_row[tag].values[0]: + pose_tag_set.add(tag) + + tags = PoseTagService.tags_from_list(pose_tag_set) + + return tags, metadata diff --git a/hippo/designdb/services/pose_score.py b/hippo/designdb/services/pose_score.py new file mode 100644 index 00000000..a91a3bf4 --- /dev/null +++ b/hippo/designdb/services/pose_score.py @@ -0,0 +1,79 @@ +import logging +import re + +# from mypackage.services.compound import CompoundService +# from rdkit.Chem import inchi +from designdb.models import PoseModel, ScoreValueModel, ScoringMethodModel + +# from .validation.compound import ValidationError, validate_compound_data + +SDF_XCAv2_PATTERN = re.compile( + r'^.*-.\d{4}_._\d*_\d_.*-.\d{4}\+.\+\d*\+\d_ligand\.sdf$' +) +SDF_XCAV3_PATTERN = re.compile( + r'^.*-.\d{4}_._\d*_._\d_.*-.\d{4}\+.\+\d*\+.\+\d_ligand\.sdf$' +) + + +SDF_FRAGALYSIS_PATTERN = re.compile(r'^.*\d{4}[a-z].sdf$') +PDBID_PATTERN = re.compile(r'^[A-Za-z0-9]{4}-[a-z].sdf$') + + +logger = logging.getLogger(__name__) + + +class ScoreService: + def __init__(self, scoring_method_list: list[str] | None = None): + self._scoring_method_list = scoring_method_list + # self._score_map = {} + self._scoring_method_cache = {} + + # unused, but I imagine this could take various arguments, + # like include or exclude list + + # if self._scoring_method_list: + # for m in self._scoring_method_list: + # sm, _ = ScoringMethodModel.objects.get_or_create( + # method_name=m, + # ) + # self._score_map[sm.method_name] = sm + + def add_scores_from_record( + self, + *, + pose: PoseModel, + record: dict[str, str | float], + score_method_map: dict[str, ScoringMethodModel] | None = None, + ): + if score_method_map: + scores = {col: record[col] for col in score_method_map if col in record} + else: + # FIXME: this because don't know how to select + scores = {k: v for k, v in record.items() if k.lower().find('score') >= 0} + + for col_or_method_name, score_value in scores.items(): + if score_method_map: + method = score_method_map[col_or_method_name] + else: + try: + method = self.scoring_methods[col_or_method_name] + except KeyError: + method, _ = ScoringMethodModel.objects.get_or_create( + method_name=col_or_method_name, + ) + + score = ScoreValueModel( + pose=pose, + compound=pose.compound, + scoring_method=method, + score={'score': score_value}, + ) + score.save() + + # def bulk_scores(poses: list[pose], record: dict[str, str | float]): + # # potentially lots of scores, can do bulk insertion all at once + # pass + + @property + def scoring_methods(self) -> dict[str, ScoringMethodModel]: + return self._scoring_method_cache diff --git a/hippo/designdb/services/quote.py b/hippo/designdb/services/quote.py new file mode 100644 index 00000000..3041cd51 --- /dev/null +++ b/hippo/designdb/services/quote.py @@ -0,0 +1,47 @@ +"""Service layer for catalogue-price quoting. + +In the modern DesignDB, catalogue prices live in the same database as the design +compounds (``catalogue_compounds`` / ``catalogue_prices``) and are linked to +compounds automatically by database triggers that match the registration hash +(``compounds.compound_hash == catalogue_compounds.catalogue_hash``), populating +the ``compound_catalogue_map`` junction. + +Quoting is therefore a read against that junction: a compound is "quoted" if it +has at least one linked catalogue price. There is no longer any cross-database +transfer (the legacy ``HIPPO.quote_compounds(ref_animal)`` behaviour). +""" + +from designdb.models import CataloguePriceCompoundJunctionModel + + +class QuoteService: + """Report catalogue-price quoting for compounds using the current database.""" + + @staticmethod + def quoted_compound_ids(compound_ids: 'list[int] | set[int]') -> set[int]: + """Return the subset of ``compound_ids`` that have at least one linked + catalogue price. + + :param compound_ids: compound IDs to check + :returns: the IDs that are quoted + """ + return set( + CataloguePriceCompoundJunctionModel.objects.filter( + compound_id__in=list(compound_ids) + ) + .values_list('compound_id', flat=True) + .distinct() + ) + + @classmethod + def partition_quoted( + cls, compound_ids: 'list[int] | set[int]' + ) -> tuple[set[int], set[int]]: + """Split ``compound_ids`` into ``(quoted, unquoted)`` ID sets. + + :param compound_ids: compound IDs to partition + :returns: ``(quoted_ids, unquoted_ids)`` + """ + ids = set(compound_ids) + quoted = cls.quoted_compound_ids(ids) + return quoted, ids - quoted diff --git a/hippo/designdb/services/reaction.py b/hippo/designdb/services/reaction.py new file mode 100644 index 00000000..4ef76aa2 --- /dev/null +++ b/hippo/designdb/services/reaction.py @@ -0,0 +1,127 @@ +import logging + +import mrich + +# from mypackage.services.compound import CompoundService +# from rdkit.Chem import inchi +from designdb.models import CompoundModel, ReactantModel, ReactionModel + +logger = logging.getLogger(__name__) + + +class ReactionService: + @staticmethod + def reactant_compound_ids() -> set[int]: + """Return compound IDs that are reactants of at least one reaction and not + a product of any (i.e. leaf reactants / purchasable building blocks). + + Mirrors the legacy ``CompoundSet.reactants`` (reactant compounds minus + compounds that are a reaction product). + """ + reactant_ids = set( + ReactantModel.objects.values_list('compound_id', flat=True).distinct() + ) + product_ids = set( + ReactionModel.objects.values_list( + 'product_compound_id', flat=True + ).distinct() + ) + return reactant_ids - product_ids + + @classmethod + def create_from_lists( + cls, + *, + reaction_types: list[str], + product_ids: list[int], + reactant_id_lists: list[set[int]], + ) -> list[int]: + # insert reaction + + # insert reactant + + reaction_ids = [] + non_duplicates = {} + + # not entirely sure how the original query was meant to work + qs = ReactantModel.objects.filter(compound__pk__in=product_ids) + existing = {} + for r in qs: + reaction_type = r.reaction.reaction_type + reaction_product = r.reaction.product_compound.pk + reaction_id = r.reaction.pk + reactant_compound = r.compound.pk + + key = (reaction_type, reaction_product) + + if key not in existing: + existing[key] = {} + + if reaction_id not in existing[key]: + existing[key][reaction_id] = set() + + existing[key][reaction_id].add(reactant_compound) + + existing_count = 0 + + # why is strict false?? + for reaction_type, product_id, reactant_ids in zip( + reaction_types, product_ids, reactant_id_lists, strict=False + ): + key = (reaction_type, product_id) + + possible_matches = {k: v for k, v in existing.items() if k == key} + + assert len(possible_matches) < 2 + + if possible_matches: + possible_matches = list(possible_matches.values())[0] + + if any(reactant_ids == v for v in possible_matches.values()): + existing_count += 1 + continue + + non_duplicates[key] = reactant_ids + + if existing_count: + mrich.warning('Skipped', existing_count, 'existing reactions') + + if not non_duplicates: + mrich.warning('All reactions are duplicates') + return None + + for reaction_type, product_id in non_duplicates.keys(): + compound = CompoundModel.objects.get(pk=product_id) + # if I understand the original procedure correctly, it + # should have already weeded out the duplicates + reaction, _ = ReactionModel.objects.get_or_create( + reaction_type=reaction_type, + product_compound=compound, + reaction_product_yield=1.0, + ) + reaction_ids.append(reaction.pk) + + payload = [] + for reaction_id, ((_reaction_type, _product_id), reactant_ids) in zip( + reaction_ids, non_duplicates.items(), strict=False + ): + for reactant_id in reactant_ids: + payload.append((reaction_id, reactant_id)) + + for reaction_id, reactant_id in payload: + reaction = ReactionModel.objects.get(pk=reaction_id) + compound = CompoundModel.objects.get(pk=reactant_id) + reaction, _ = ReactantModel.objects.get_or_create( + reaction=reaction, + compound=compound, + reactant_amount=1.0, + ) + + # delete orphaned reactions, srsly?? + ReactionModel.objects.filter( + pk__in=ReactantModel.objects.filter( + compound__isnull=True, + ).values('reaction'), + ).delete() + + return reaction_ids diff --git a/hippo/designdb/services/recipe.py b/hippo/designdb/services/recipe.py new file mode 100644 index 00000000..168486d7 --- /dev/null +++ b/hippo/designdb/services/recipe.py @@ -0,0 +1,803 @@ +"""Recipe construction and DB traversal. + +Builds :class:`.Recipe` objects from reactions/compounds/reactants. The +:class:`.Recipe` aggregate itself is lean; its ``from_*`` classmethods are +deprecated shims that delegate here. +""" + +from itertools import product +from typing import TYPE_CHECKING + +import mrich +from designdb.components.compound import Compound +from designdb.components.reaction import DEFAULT_PRODUCT_YIELD, Reaction +from designdb.models import ( + CompoundModel, + InspirationModel, + PoseModel, + ReactionModel, + RouteModel, +) +from designdb.sets.compound import CompoundSet +from designdb.sets.ingredient import IngredientSet +from designdb.sets.pose import PoseSet +from designdb.sets.reaction import ReactionSet + +if TYPE_CHECKING: + from pathlib import Path + + from designdb.recipe import Recipe + from designdb.sets.route import RouteSet + from pandas import DataFrame + + +class RecipeService: + """Construction and traversal logic for :class:`.Recipe` objects.""" + + ### FACTORIES + + @staticmethod + def from_reaction( + reaction, + amount=1, + *, + debug: bool = False, + pick_cheapest: bool = True, + permitted_reactions: 'ReactionSet | None' = None, + quoted_only: bool = False, + supplier: None | str = None, + unavailable_reaction: str = 'error', + reaction_checking_cache: dict[int, bool] | None = None, + reaction_reactant_cache: dict[int, bool] | None = None, + inner: bool = False, + get_ingredient_quotes: bool = True, + ) -> 'Recipe | list[Recipe] | None': + """Create a :class:`.Recipe` from a :class:`.ReactionModel` and its upstream + dependencies. + + :param reaction: :class:`.ReactionModel` to create the recipe from + :param amount: amount in ``mg`` (Default value = 1) + :param debug: increase verbosity (Default value = False) + :param pick_cheapest: return only the cheapest solution (Default value = True) + :param permitted_reactions: only consider reactions in this set + :param quoted_only: only allow reactants with quotes (Default value = False) + :param supplier: restrict quotes to this supplier (Default value = None) + :param unavailable_reaction: behaviour when a reaction has unavailable + reactants (Default value = 'error') + :param inner: indicates a recursive call (Default value = False) + :param get_ingredient_quotes: get quotes for product ingredients + """ + + from designdb.recipe import Recipe + + assert isinstance(reaction, ReactionModel) + reaction_c = Reaction(reaction) + + if debug: + mrich.debug( + f'RecipeService.from_reaction(R{reaction.id}, ' + f'{amount=}, {pick_cheapest=})' + ) + mrich.debug(f'{reaction_c.product.id=}') + mrich.debug(f'{reaction_c.reactant_ids=}') + + if permitted_reactions: + assert reaction in permitted_reactions + + recipe = Recipe( + products=IngredientSet( + [ + reaction_c.product.as_ingredient( + amount=amount, get_quote=get_ingredient_quotes + ) + ], + ), + reactants=IngredientSet([], supplier=supplier), + intermediates=IngredientSet([]), + reactions=ReactionSet([reaction.id], sort=False), + ) + + recipes = [recipe] + + if quoted_only or supplier: + if debug: + mrich.debug(f'Checking reactant_availability: {reaction=}') + if reaction_checking_cache and reaction.id in reaction_checking_cache: + ok = reaction_checking_cache[reaction.id] + else: + ok = reaction_c.check_reactant_availability(supplier=supplier) + if reaction_checking_cache is not None: + reaction_checking_cache[reaction.id] = ok + if not ok: + if unavailable_reaction == 'error': + mrich.error(f'Reactants not available for {reaction=}') + return None if pick_cheapest else [] + + def get_reactant_amount_pairs( + reaction_model: 'ReactionModel', + ) -> list[tuple[int, float]]: + """Get pairs of reactant ID and float amounts""" + if reaction_reactant_cache and reaction_model.id in reaction_reactant_cache: + return reaction_reactant_cache[reaction_model.id] + pairs = Reaction(reaction_model).get_reactant_amount_pairs( + compound_object=False + ) + if reaction_reactant_cache is not None: + reaction_reactant_cache[reaction_model.id] = pairs + return pairs + + if debug: + mrich.debug(f'get_reactant_amount_pairs({reaction.id})') + pairs = get_reactant_amount_pairs(reaction) + + for reactant_id, reactant_amount in pairs: + reactant = Compound(CompoundModel.objects.get(pk=reactant_id)) + + if debug: + mrich.debug(f'{reactant.id=}, {reactant_amount=}') + + # scale amount + reactant_amount *= amount + reactant_amount /= reaction_c.product_yield or DEFAULT_PRODUCT_YIELD + + inner_reactions = reactant.get_reactions( + none='quiet', permitted_reactions=permitted_reactions + ) + + if len(inner_reactions): + if debug: + if len(inner_reactions) == 1: + mrich.debug('Reactant has ONE inner reaction') + else: + mrich.warning(f'{reactant=} has MULTIPLE inner reactions') + + inner_recipes = [] + for inner_reaction in inner_reactions: + reaction_recipes = RecipeService.from_reaction( + reaction=inner_reaction, + amount=reactant_amount, + debug=debug, + pick_cheapest=False, + quoted_only=quoted_only, + supplier=supplier, + unavailable_reaction=unavailable_reaction, + reaction_checking_cache=reaction_checking_cache, + reaction_reactant_cache=reaction_reactant_cache, + inner=True, + ) + inner_recipes += reaction_recipes + + new_recipes = [] + for recipe in recipes: + for inner_recipe in inner_recipes: + combined_recipe = recipe.copy() + + combined_recipe.reactants += inner_recipe.reactants + combined_recipe.intermediates += inner_recipe.intermediates + combined_recipe.reactions += inner_recipe.reactions + combined_recipe.intermediates.add( + reactant.as_ingredient(reactant_amount, supplier=supplier) + ) + + new_recipes.append(combined_recipe) + + recipes = new_recipes + + else: + ingredient = reactant.as_ingredient(reactant_amount, supplier=supplier) + for recipe in recipes: + recipe.reactants.add(ingredient) + + # reverse ReactionSet's (outermost call only) + if not inner: + for recipe in recipes: + recipe.reactions.reverse() + + if pick_cheapest: + if debug: + mrich.debug('Picking cheapest') + priced = [r for r in recipes if r.get_price(supplier=supplier)] + if not priced: + mrich.error("0 recipes with prices, can't choose cheapest") + return recipes + sorted_recipes = sorted( + priced, key=lambda r: r.get_price(supplier=supplier) + ) + if debug: + for recipe in recipes: + mrich.debug(f'{recipe}, {recipe.price}') + return sorted_recipes[0] + + return recipes + + @staticmethod + def from_reactions( + reactions: 'ReactionSet', + amount: float = 1, + pick_cheapest: bool = True, + permitted_reactions: 'ReactionSet | None' = None, + final_products_only: bool = True, + return_products: bool = False, + supplier: str | None = None, + use_routes: bool = False, + debug: bool = False, + **kwargs, + ) -> 'Recipe | list[Recipe] | CompoundSet': + """Create a :class:`.Recipe` from a :class:`.ReactionSet` and its upstream + dependencies. + + :param reactions: reactions to create the recipe from + :param amount: amount in ``mg`` (Default value = 1) + :param pick_cheapest: choose the cheapest solution (Default value = True) + :param permitted_reactions: only consider reactions in this set + :param final_products_only: don't make routes to intermediates + (Default value = True) + :param return_products: return the :class:`.CompoundSet` of products instead + """ + + assert isinstance(reactions, ReactionSet) + + if debug: + mrich.debug('RecipeService.from_reactions()') + mrich.var('reactions', reactions) + mrich.var('amount', amount) + mrich.var('final_products_only', final_products_only) + mrich.var('permitted_reactions', permitted_reactions) + + # all products synthesisable from these reactions + products = reactions.products + + if debug: + mrich.var('products', products) + + if final_products_only: + # keep only compounds that are never used as a reactant (i.e. leaves) + from designdb.models import ReactantModel + + products = CompoundSet( + CompoundModel.objects.filter(pk__in=list(products.ids)).exclude( + pk__in=ReactantModel.objects.values('compound'), + ) + ) + if debug: + mrich.var('final products', products) + + if return_products: + return products + + return RecipeService.from_compounds( + compounds=products, + amount=amount, + permitted_reactions=reactions, + pick_cheapest=pick_cheapest, + supplier=supplier, + use_routes=use_routes, + debug=debug, + **kwargs, + ) + + @staticmethod + def from_compounds( + compounds: 'CompoundSet', + amount: float = 1, + debug: bool = False, + pick_cheapest: bool = True, + permitted_reactions: 'ReactionSet | None' = None, + quoted_only: bool = False, + supplier: None | str = None, + solve_combinations: bool = True, + pick_first: bool = False, + warn_multiple_solutions: bool = True, + pick_cheapest_inner_routes: bool = False, + unavailable_reaction: str = 'error', + reaction_checking_cache: dict[int, bool] | None = None, + reaction_reactant_cache: dict[int, bool] | None = None, + use_routes: bool = False, + **kwargs, + ): + """Create recipe(s) to synthesise the products in a :class:`.CompoundSet`. + + :param compounds: set of compounds to find routes for + :param solve_combinations: combinatorially combine the individual solutions + (Default value = True) + :param pick_first: return the first solution without comparison + :param warn_multiple_solutions: warn if a compound has multiple routes + :param pick_cheapest_inner_routes: for each compound choose the cheapest route + :param use_routes: use stored :class:`.RouteModel` rows instead of solving + reactions on the fly + """ + + from designdb.recipe import Route + + assert isinstance(compounds, CompoundSet) + + n_comps = len(compounds) + assert n_comps + + if not hasattr(amount, '__iter__'): + amount = [amount] * n_comps + + if use_routes and supplier: + raise NotImplementedError( + 'use_routes combined with a supplier filter is not supported' + ) + + options = [] + ok = 0 + mrich.var('#compounds', n_comps) + + for comp, a in mrich.track( + zip(compounds, amount, strict=False), + prefix='Solving individual compound recipes...', + total=n_comps, + ): + comp_options = [] + + if use_routes: + route_ids = list( + RouteModel.objects.filter(product_compound__id=comp.id).values_list( + 'id', flat=True + ) + ) + if not route_ids: + mrich.error('No routes to', comp) + continue + comp_options = [Route.get_route(id=route_id) for route_id in route_ids] + + else: + for reaction in Compound(comp).reactions: + if permitted_reactions and reaction not in permitted_reactions: + continue + + sol = RecipeService.from_reaction( + reaction=reaction, + amount=a, + pick_cheapest=pick_cheapest_inner_routes, + debug=debug, + permitted_reactions=permitted_reactions, + quoted_only=quoted_only, + supplier=supplier, + unavailable_reaction=unavailable_reaction, + reaction_checking_cache=reaction_checking_cache, + reaction_reactant_cache=reaction_reactant_cache, + **kwargs, + ) + + if pick_cheapest_inner_routes: + if sol: + comp_options.append(sol) + else: + assert isinstance(sol, list) + comp_options += sol + + if not comp_options: + mrich.error( + f'No solutions for compound={comp} ' + f'({Compound(comp).reactions.ids=})' + ) + continue + + if pick_cheapest and len(comp_options) > 1: + if warn_multiple_solutions: + mrich.warning( + 'Multiple solutions for', comp, '(', len(comp_options), ')' + ) + if debug: + mrich.debug('Picking cheapest...') + priced = [r for r in comp_options if r.price] + comp_options = sorted(priced, key=lambda r: r.price)[:1] + + if warn_multiple_solutions and len(comp_options) > 1: + mrich.warning(f'Multiple solutions for compound={comp}') + if debug: + mrich.debug(f'{comp_options=}') + else: + if n_comps <= 200: + mrich.success(f'Found solution for compound={comp}') + ok += 1 + mrich.set_progress_field('ok', ok) + mrich.set_progress_field('n', n_comps) + + options.append(comp_options) + + assert all(options) + + mrich.print('Solving recipe combinations...') + combinations = list(product(*options)) + + if not solve_combinations: + return combinations + + solutions = [] + + if n_comps > 1: + generator = mrich.track( + combinations, prefix='Combining recipes...', total=len(combinations) + ) + else: + generator = combinations + + ok = 0 + for combo in generator: + if debug: + mrich.debug(f'Combination of {len(combo)} recipes') + + if not combo: + continue + + solution = combo[0] + for i, recipe in enumerate(combo[1:]): + if debug: + mrich.debug(i + 1) + solution += recipe + + solutions.append(solution) + ok += 1 + mrich.set_progress_field('ok', ok) + mrich.set_progress_field('n', len(combinations)) + + if not solutions: + mrich.error('No solutions') + return None + + if pick_first: + return solutions[0] + + if pick_cheapest: + mrich.debug('Calculating prices...') + priced = [r for r in solutions if r.price] + mrich.print('Picking cheapest from', len(priced), 'options') + if not priced: + mrich.error("0 recipes with prices, can't choose cheapest") + # fall back to the first (unpriced) solution so the return type + # stays a single Recipe, consistent with pick_first / the priced path + return solutions[0] + return sorted(priced, key=lambda r: r.price)[0] + + return solutions + + @staticmethod + def from_reactants( + reactants: 'CompoundSet | IngredientSet', + amount: float = 1, + debug: bool = False, + return_products: bool = False, + supplier: str | None = None, + pick_cheapest: bool = False, + use_routes: bool = False, + **kwargs, + ) -> 'list[Recipe] | Recipe | CompoundSet': + """Find the maximal recipe reachable from a given set of reactants. + + :param reactants: :class:`.CompoundSet` or :class:`.IngredientSet` of + reactants (ingredient amounts are ignored) + :param amount: amount of each product needed (Default value = 1) + :param return_products: return the products instead of the recipe + """ + + if isinstance(reactants, IngredientSet): + reactant_ids = reactants.compound_ids + else: + reactant_ids = reactants.ids + + all_reactants = set(reactant_ids) + possible_reactions: set[int] = set() + + # recursively expand the set of reachable reactions/products + for _ in range(300): + reaction_ids = RecipeService._possible_reaction_ids(all_reactants) + + if not reaction_ids: + break + + if debug: + mrich.debug(f'Adding {len(reaction_ids)} reactions') + + possible_reactions |= set(reaction_ids) + + product_ids = list( + ReactionModel.objects.filter(pk__in=reaction_ids).values_list( + 'product_compound_id', flat=True + ) + ) + + n_prev = len(all_reactants) + all_reactants |= set(product_ids) + + if n_prev == len(all_reactants): + break + else: + raise NotImplementedError('Maximum recursion depth exceeded') + + if debug: + mrich.var('all possible reactions', possible_reactions) + + rset = ReactionSet(list(possible_reactions), sort=False) + + return RecipeService.from_reactions( + rset, + amount=amount, + permitted_reactions=rset, + debug=debug, + return_products=return_products, + supplier=supplier, + use_routes=use_routes, + pick_cheapest=pick_cheapest, + **kwargs, + ) + + ### TRAVERSAL + + @staticmethod + def get_routes(recipe: 'Recipe', return_ids: bool = False) -> 'RouteSet': + """Get stored routes to the products of ``recipe`` restricted to its + reactions.""" + return recipe.products.compounds.get_routes( + permitted_reactions=recipe.reactions, return_ids=return_ids + ) + + ### EXPORTERS + + @staticmethod + def write_CAR_csv( + recipe: 'Recipe', file: 'str | Path', return_df: bool = False + ) -> 'DataFrame | None': + """Write CSV(s) for use with CAR. + + Requires a populated ``route`` table (see :meth:`.RecipeService.get_routes`). + One row per route; reactions are flattened into ``reactant-N-i`` / + ``reaction-product-smiles-i`` / ``reaction-name-i`` columns. + + :param recipe: the :class:`.Recipe` to export + :param file: output path (per-step files are also written alongside) + :param return_df: return the assembled DataFrame + """ + + from pathlib import Path + + from pandas import DataFrame + + file = str(Path(file).resolve()) + rows = [] + + for sub_recipe in RecipeService.get_routes(recipe): + product = sub_recipe.product + + row = { + 'target-names': str(product.compound), + 'no-steps': 0, + 'concentration-required-mM': None, + 'amount-required-uL': None, + 'batch-tag': None, + } + + for i, reaction_model in enumerate(sub_recipe.reactions): + i = i + 1 + reaction = Reaction(reaction_model) + row['no-steps'] += 1 + + reactants = reaction.reactants + match len(reactants): + case 1: + row[f'reactant-1-{i}'] = reactants[0].smiles + row[f'reactant-2-{i}'] = None + case 2: + row[f'reactant-1-{i}'] = reactants[0].smiles + row[f'reactant-2-{i}'] = reactants[1].smiles + case _: + for j, reactant in enumerate(reactants): + row[f'reactant-{j + 1}-{i}'] = reactant.smiles + + row[f'reaction-product-smiles-{i}'] = reaction.product_smiles + row[f'reaction-name-{i}'] = reaction.type + row[f'reaction-recipe-{i}'] = None + row[f'reaction-groupby-column-{i}'] = None + + rows.append(row) + + df = DataFrame(rows) + + if len(df[df.duplicated()]): + mrich.warning('Removing duplicates from CAR DataFrame') + df = df.drop_duplicates() + + df = df.convert_dtypes() + + for n_steps in set(df['no-steps']): + subset = df[df['no-steps'] == n_steps] + this_file = file.replace('.csv', f'_{n_steps}steps.csv') + mrich.writing(this_file) + subset.to_csv(this_file, index=False) + + mrich.writing(file) + df.to_csv(file, index=False) + + if return_df: + return df + return None + + @staticmethod + def write_reactant_csv(recipe: 'Recipe', file, reaction_type_counts=True, **kwargs): + """Detailed reactant-purchasing CSV. Not implemented.""" + raise NotImplementedError('write_reactant_csv is not implemented') + + @staticmethod + def write_product_csv( + recipe: 'Recipe', file, return_df: bool = False + ) -> 'DataFrame | None': + """Detailed CSV output including product information for selection/synthesis. + + One row per product compound: identifiers, required amount, associated poses, + tags, upstream route/reaction/reactant dependencies, scaffold series, and + inspiration pose names. + + :param recipe: the :class:`.Recipe` whose products to report + :param file: output CSV path + :param return_df: also return the assembled ``DataFrame`` + """ + + from pandas import DataFrame + + routes = RecipeService.get_routes(recipe) + + product_ids = list(recipe.products.compound_ids) + + # compound_id -> set of associated pose IDs + pose_map: dict[int, set] = {} + for comp_id, pose_id in PoseModel.objects.filter( + compound_id__in=product_ids + ).values_list('compound_id', 'id'): + pose_map.setdefault(comp_id, set()).add(pose_id) + + # compound_id -> set of inspiration (original) pose IDs, scoped to the + # product compounds and their scaffolds (the inspiration fallback needs both) + scaffold_ids = set() + for prod in recipe.products: + # Ingredient.__getattr__ delegates to the CompoundModel (ORM), so wrap + # in the Compound component to reach component-level properties + if scaffolds := Compound(prod.compound).scaffolds: + scaffold_ids.update(scaffolds.ids) + needed_ids = set(product_ids) | scaffold_ids + + inspiration_map: dict[int, set] = {} + for comp_id, original_pose_id in InspirationModel.objects.filter( + derivative_pose__compound_id__in=needed_ids + ).values_list('derivative_pose__compound_id', 'original_pose_id'): + inspiration_map.setdefault(comp_id, set()).add(original_pose_id) + + data = [] + + for prod in mrich.track( + recipe.products, prefix='Constructing product DataFrame' + ): + # wrap in the Compound component for component-level properties + # (Ingredient.__getattr__ delegates to the CompoundModel ORM instead) + comp = Compound(prod.compound) + + d = dict( + hippo_id=prod.compound_id, + smiles=comp.smiles, + inchikey=comp.inchikey, + required_amount_mg=prod.amount, + ) + + upstream_routes = [] + upstream_reaction_ids = [] + + for route in routes: + if route.product_compound.id == prod.compound_id: + upstream_routes.append(route) + upstream_reaction_ids += route.reactions.ids + + if not upstream_routes: + mrich.error('No upstream routes for', prod) + continue + + if not upstream_reaction_ids: + mrich.error('No upstream reactions for', prod) + continue + + upstream_reactions = ReactionSet(list(set(upstream_reaction_ids))) + + # scaffold series: the product's scaffolds, or itself if it is one + if scaffolds := comp.scaffolds: + scaffold_series, is_scaffold = scaffolds.ids, False + else: + scaffold_series, is_scaffold = [prod.compound_id], True + + poses = pose_map.get(prod.compound_id, set()) + + d['num_poses'] = len(poses) + d['poses'] = poses + d['tags'] = comp.tags + d['num_routes'] = len(upstream_routes) + d['num_reaction_steps'] = {len(r.reactions) for r in upstream_routes} + d['reaction_dependencies'] = upstream_reactions.ids + d['reactant_dependencies'] = set( + sum((route.reactants.ids for route in upstream_routes), []) + ) + d['route_ids'] = [route.id for route in upstream_routes] + d['chemistry_types'] = ', '.join(t for t in upstream_reactions.types if t) + d['is_scaffold'] = is_scaffold + d['scaffold_series'] = scaffold_series + + # inspiration pose IDs, with fallback to the scaffold / metadata + inspirations = inspiration_map.get(prod.compound_id, None) + + if not inspirations and not is_scaffold: + scaffold = Compound(comp.scaffolds[0]) + inspirations = inspiration_map.get(scaffold.id, None) + + scaffold_meta = scaffold.metadata or {} + if not inspirations and 'inspiration_pose_ids' in scaffold_meta: + inspirations = scaffold_meta['inspiration_pose_ids'] + + product_meta = comp.metadata or {} + if ( + not inspirations + and is_scaffold + and 'inspiration_pose_ids' in product_meta + ): + inspirations = product_meta['inspiration_pose_ids'] + + if inspirations: + inspiration_poses = PoseSet( + PoseModel.objects.filter(pk__in=list(inspirations)) + ) + d['inspirations'] = ', '.join(inspiration_poses.names) + else: + d['inspirations'] = '' + + data.append(d) + + df = DataFrame(data) + mrich.writing(file) + df.to_csv(file, index=False) + + if return_df: + return df + return None + + @staticmethod + def to_syndirella(recipe: 'Recipe', out_key, poses, *, separate: bool = False): + """Generate Syndirella elaboration inputs from this recipe. Not implemented.""" + raise NotImplementedError('RecipeService.to_syndirella is not implemented') + + @staticmethod + def register_missing_routes( + recipe: 'Recipe', missing_only: bool = True, supplier: str = 'Enamine' + ) -> None: + """Calculate and register missing routes to the products of ``recipe``. + Not implemented.""" + raise NotImplementedError('register_missing_routes is not implemented') + + ### HELPERS + + @staticmethod + def _possible_reaction_ids(compound_ids: set[int]) -> list[int]: + """Return reaction IDs whose every reactant is in ``compound_ids``.""" + from designdb.models import ReactantModel + + compound_ids = set(compound_ids) + + # reactions that use at least one of these compounds as a reactant + candidate_ids = ( + ReactantModel.objects.filter(compound_id__in=compound_ids) + .values_list('reaction_id', flat=True) + .distinct() + ) + + # of those, keep reactions whose reactants are all available + rows = ReactantModel.objects.filter( + reaction_id__in=list(candidate_ids) + ).values_list('reaction_id', 'compound_id') + + reaction_reactants: dict[int, set[int]] = {} + for reaction_id, compound_id in rows: + reaction_reactants.setdefault(reaction_id, set()).add(compound_id) + + return [ + reaction_id + for reaction_id, reactants in reaction_reactants.items() + if reactants <= compound_ids + ] diff --git a/hippo/designdb/services/recipe_score.py b/hippo/designdb/services/recipe_score.py new file mode 100644 index 00000000..941062f5 --- /dev/null +++ b/hippo/designdb/services/recipe_score.py @@ -0,0 +1,618 @@ +"""Recipe scoring (user entry point: ``animal.scorers``). + +A :class:`.Scorer` loads recipes from a directory of ``Recipe_*.json`` files and +evaluates them against weighted :class:`.Attribute` / :class:`.CustomAttribute` +objects: each attribute value is converted to a percentile (0-1) and combined by +weight. The score cache is written to ``{out_key}.json``. +""" + +from pathlib import Path + +import mrich +import numpy as np +import pandas as pd +from designdb.models import InteractionModel, PoseModel, ScaffoldModel +from designdb.recipe import Recipe, RecipeSet +from designdb.sets.compound import CompoundSet +from designdb.sets.interaction import InteractionSet +from designdb.sets.pose import PoseSet +from scipy.interpolate import interp1d + +# columns of the internal score-cache DataFrame (besides the attribute columns) +DATA_COLUMNS = [ + 'score', + 'price', + 'compound_ids', + 'pose_ids', + 'interaction_ids', + 'pose_metadata', +] + + +class Attribute: + """A scoring attribute evaluated over the recipes of a :class:`.Scorer`. + + The raw value (``recipe.``) is converted to a percentile in ``[0, 1]`` + across all recipes, optionally inverted, and scaled by ``weight``. + """ + + _type = 'Attribute' + + def __init__( + self, + scorer: 'Scorer', + key: str, + *, + inverse: bool = False, + weight: float = 1.0, + bins: int = 100, + ) -> None: + self._scorer = scorer + self._key = key + self._inverse = inverse + self._weight = weight + self._bins = bins + self._percentile_interpolator = None + + ### PROPERTIES + + @property + def scorer(self) -> 'Scorer': + return self._scorer + + @property + def key(self) -> str: + return self._key + + @property + def inverse(self) -> bool: + return self._inverse + + @property + def bins(self) -> int: + return self._bins + + @property + def values(self) -> list[float]: + """Attribute values across all recipes (computed/cached on access).""" + col = self.scorer._data[self.key] + null = col.isnull() + if null.sum(): + for key in col[null].index.values: + self.get_value(self.scorer.recipes[key], force=True) + self.scorer._dump_json() + return list(self.scorer._data[self.key].to_dict().values()) + + @property + def mean(self) -> float: + return float(np.mean(self.values)) + + @property + def std(self) -> float: + return float(np.std(self.values)) + + @property + def max(self) -> float: + return max(self.values) + + @property + def min(self) -> float: + return min(self.values) + + @property + def weight(self) -> float: + return self._weight + + @weight.setter + def weight(self, w) -> None: + self.scorer._flag_weight_modification() + self._weight = abs(w) + self._inverse = self._inverse or (w < 0) + + @property + def percentile_interpolator(self): + """Interpolator mapping a value to its cumulative percentile.""" + if self._percentile_interpolator is None: + count, bins_count = np.histogram(self.values, bins=self.bins) + pdf = count / sum(count) + cdf = np.cumsum(pdf) + self._percentile_interpolator = interp1d( + bins_count[1:], cdf, kind='linear', fill_value='extrapolate' + ) + return self._percentile_interpolator + + ### METHODS + + def get_value(self, recipe: 'Recipe', force: bool = False) -> float: + """Get (and cache) this attribute's raw value for ``recipe``.""" + if not force: + cached = self.scorer._data[self.key][recipe.hash] + if cached is not None: + return cached + value = getattr(recipe, self.key) + self.scorer._data.at[recipe.hash, self.key] = value + return value + + def unweighted(self, recipe: 'Recipe') -> float: + """Percentile score (0-1) for ``recipe``.""" + value = self.get_value(recipe) + score = float(self.percentile_interpolator(value)) + if self.inverse: + score = 1 - score + return score + + def __call__(self, recipe: 'Recipe') -> float: + """Weighted score for ``recipe``.""" + if not self.weight: + return 0.0 + return self.weight * self.unweighted(recipe) + + def __str__(self) -> str: + return ( + f'{self._type}("{self.key}", weight={self.weight:.2f}, ' + f'inverse={self.inverse})' + ) + + def __repr__(self) -> str: + import mcol + + return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' + + def __rich__(self) -> str: + return f'[bold underline]{self}' + + +class CustomAttribute(Attribute): + """A scoring attribute whose value is computed by a user-supplied function.""" + + _type = 'CustomAttribute' + + def __init__(self, scorer: 'Scorer', key: str, function) -> None: + self._function = function + super().__init__(scorer=scorer, key=key) + + def get_value(self, recipe: 'Recipe', force: bool = False) -> float: + if not force: + cached = self.scorer._data[self.key][recipe.hash] + if cached is not None: + return cached + value = self._function(recipe) + self.scorer._data.at[recipe.hash, self.key] = value + return value + + +class Scorer: + """Score a set of recipes against weighted attributes.""" + + def __init__( + self, + directory: 'str | Path', + *, + pattern: str = '*.json', + attributes: list[str] | None = None, + populate: bool = True, + load_cache: bool = True, + allowed_poses: 'PoseSet | list[int] | None' = None, + out_key: str = 'scorer', + ) -> None: + """Scorer initialisation""" + + self._out_key = out_key + + if allowed_poses is None: + self._allowed_pose_ids = None + elif isinstance(allowed_poses, PoseSet): + self._allowed_pose_ids = set(allowed_poses.ids) + else: + self._allowed_pose_ids = set(allowed_poses) + + self._recipes = RecipeSet(directory, pattern=pattern) + + self._attributes = {} + for key in attributes or []: + self._attributes[key] = Attribute(self, key) + + self._data = pd.DataFrame( + index=self._recipes.keys(), + columns=DATA_COLUMNS + self.attribute_keys, + ) + self._data = self._data.replace({np.nan: None}) + + if populate: + if load_cache and self.json_path.exists(): + self._load_json() + else: + self._populate_query_cache() + self._populate_recipe_child_sets() + + self.weights = 1.0 + + ### FACTORIES + + @classmethod + def default( + cls, + directory: 'str | Path', + *, + pattern: str = '*.json', + skip: list[str] | None = None, + load_cache: bool = True, + subsites: bool = True, + allowed_poses: 'PoseSet | list[int] | None' = None, + out_key: str = 'scorer', + ) -> 'Scorer': + """Create a :class:`.Scorer` with the default attributes (minus ``skip``).""" + + self = cls.__new__(cls) + + standard = [k for k, v in DEFAULT_ATTRIBUTES.items() if v['type'] == 'standard'] + + self.__init__( + directory=directory, + pattern=pattern, + attributes=standard, + populate=False, + allowed_poses=allowed_poses, + out_key=out_key, + ) + + skip = list(skip or []) + + # drop metrics whose data isn't present (ORM checks, no db) + if not InteractionModel.objects.exists(): + mrich.warning('No interactions in DB, skipping related metrics') + skip += ['interaction_count', 'interaction_balance'] + if not PoseModel.objects.exists(): + mrich.warning('No poses in DB, skipping related metrics') + skip += [ + 'num_inspirations', + 'num_inspiration_sets', + 'avg_energy_score', + 'avg_distance_score', + ] + if not ScaffoldModel.objects.exists(): + mrich.warning('No scaffold entries in DB, skipping related metrics') + skip += ['num_scaffolds', 'num_scaffolds_elaborated', 'elaboration_balance'] + + for key, attribute in [ + (k, v) for k, v in DEFAULT_ATTRIBUTES.items() if v['type'] == 'custom' + ]: + if key in skip: + continue + if not subsites and 'subsite' in key: + continue + self.add_custom_attribute( + key, attribute['function'], weight_reset_warning=False + ) + + if load_cache and self.json_path.exists(): + self._load_json() + else: + self._populate_query_cache() + self._populate_recipe_child_sets() + + # normalise weights from the DEFAULT_ATTRIBUTES config + wsum = sum(abs(d['weight']) for d in DEFAULT_ATTRIBUTES.values()) + for attribute in self.attributes: + attribute.weight = DEFAULT_ATTRIBUTES[attribute.key]['weight'] / wsum + + return self + + ### PROPERTIES + + @property + def num_recipes(self) -> int: + return len(self._recipes) + + @property + def attributes(self) -> list: + return list(self._attributes.values()) + + @property + def attribute_keys(self) -> list[str]: + return list(self._attributes.keys()) + + @property + def recipes(self) -> 'RecipeSet': + return self._recipes + + @property + def num_attributes(self) -> int: + return len(self._attributes) + + @property + def weights(self) -> list[float]: + return [a.weight for a in self.attributes] + + @weights.setter + def weights(self, ws) -> None: + self._flag_weight_modification() + if isinstance(ws, (int, float)): + ws = [ws] * self.num_attributes + ws = list(ws) + wsum = sum(abs(w) for w in ws) or 1.0 + for a, w in zip(self.attributes, ws, strict=False): + a.weight = w / wsum + + @property + def json_path(self) -> 'Path': + """Path the score cache is written to (derived from ``out_key``).""" + return Path(f'{self._out_key}.json') + + @property + def score_dict(self) -> dict: + """Scores keyed by recipe hash (computed/cached on access).""" + col = self._data['score'] + null = col.isnull() + if null.sum(): + mrich.debug('Calculating scores...') + for key in col[null].index.values: + self._data.at[key, 'score'] = self.score(self.recipes[key]) + self._dump_json() + return self._data['score'].to_dict() + + @property + def scores(self) -> list[float]: + return list(self.score_dict.values()) + + @property + def best(self) -> 'Recipe': + """Highest-scoring recipe.""" + return self.top(1) + + ### METHODS + + def add_custom_attribute( + self, key: str, function, weight_reset_warning: bool = True + ) -> 'CustomAttribute': + """Add a custom scoring attribute computed by ``function(recipe)``.""" + ca = CustomAttribute(self, key, function) + if key not in self._attributes: + self._attributes[key] = ca + if weight_reset_warning: + mrich.warning('Attribute weights have been reset') + self.weights = 1.0 + self._data[key] = None + else: + mrich.warning(f'Existing attribute with {key=}') + return self._attributes[key] + + def score(self, recipe: 'Recipe', *, debug: bool = False) -> float: + """Total weighted score for ``recipe``.""" + score = sum(attribute(recipe) for attribute in self.attributes) + recipe._score = score + return score + + def get_sorted_df(self) -> 'pd.DataFrame': + """Score cache sorted by descending score.""" + _ = self.scores + return self._data.sort_values(by='score', ascending=False) + + def top_keys(self, n: int) -> list[str]: + return list(self.get_sorted_df().index[:n]) + + def top(self, n: int) -> 'Recipe | list[Recipe]': + """Top-``n`` scoring recipes (a single recipe when ``n == 1``).""" + keys = self.top_keys(n) + recipes = [self.recipes[key] for key in keys] + return recipes[0] if n == 1 else recipes + + def plot(self, keys: list[str], budget: float | None = None): + """Scatter plot of two attributes, coloured by score.""" + import plotly.express as px + + if len(keys) != 2: + mrich.error('Only two keys supported') + return None + + _ = self.scores + + df = self._data.drop( + columns=['compound_ids', 'pose_ids', 'interaction_ids', 'pose_metadata'] + ) + df['score'] = pd.to_numeric(df['score']) + + for key in keys: + if key not in df.columns: + raise KeyError(f'no attribute/column named "{key}"') + + if budget: + df = df[df['price'] < budget] + + df['hash'] = df.index.values + return px.scatter( + df, x=keys[0], y=keys[1], color='score', hover_data=list(df.columns) + ) + + ### INTERNALS + + def _flag_weight_modification(self) -> None: + """Reset cached scores (weights changed).""" + if hasattr(self, '_data'): + self._data['score'] = None + + def _populate_query_cache(self) -> None: + """Pre-fetch per-recipe compound/pose/interaction IDs + pose metadata (ORM).""" + + df = self._data + + # prices + combined compound IDs + mrich.debug('Populating _data["compound_ids"]...') + for recipe in self.recipes: + df.at[recipe.hash, 'price'] = recipe.price.amount + df.at[recipe.hash, 'compound_ids'] = recipe.combined_compound_ids + + # compound -> pose IDs + all_compound_ids = set().union(*(set(ids) for ids in df['compound_ids'] if ids)) + mrich.debug(f'Getting poses for {len(all_compound_ids)} compounds') + compound_pose_map: dict[int, set] = {} + for c_id, p_id in PoseModel.objects.filter( + compound_id__in=all_compound_ids + ).values_list('compound_id', 'id'): + compound_pose_map.setdefault(c_id, set()).add(p_id) + + mrich.debug('Populating _data["pose_ids"]...') + for key in df.index.values: + pose_ids = set() + for c_id in df['compound_ids'][key]: + ids = compound_pose_map.get(c_id, set()) + if self._allowed_pose_ids is not None: + ids = {i for i in ids if i in self._allowed_pose_ids} + pose_ids |= ids + df.at[key, 'pose_ids'] = pose_ids + + # pose -> interaction IDs + all_pose_ids = set().union(*(set(ids) for ids in df['pose_ids'] if ids)) + mrich.debug(f'Getting interactions for {len(all_pose_ids)} poses') + pose_interaction_map: dict[int, set] = {} + if all_pose_ids: + for p_id, i_id in InteractionModel.objects.filter( + pose_id__in=all_pose_ids + ).values_list('pose_id', 'id'): + pose_interaction_map.setdefault(p_id, set()).add(i_id) + + mrich.debug('Populating _data["interaction_ids"]...') + for key in df.index.values: + interaction_ids = set() + for p_id in df['pose_ids'][key]: + interaction_ids |= pose_interaction_map.get(p_id, set()) + df.at[key, 'interaction_ids'] = interaction_ids + + # pose -> metadata + mrich.debug(f'Getting metadata for {len(all_pose_ids)} poses') + metadata_map: dict[int, dict] = {} + if all_pose_ids: + for p_id, meta in PoseModel.objects.filter(pk__in=all_pose_ids).values_list( + 'id', 'pose_metadata' + ): + metadata_map[p_id] = meta or {} + + mrich.debug('Populating _data["pose_metadata"]...') + for key in df.index.values: + df.at[key, 'pose_metadata'] = { + p_id: metadata_map.get(p_id, {}) for p_id in df['pose_ids'][key] + } + + def _populate_recipe_child_sets(self) -> None: + """Inject the pre-fetched child sets onto each recipe's caches.""" + mrich.debug('Populating recipe caches') + for key, recipe in self.recipes.items(): + row = self._data.loc[key] + + if recipe._combined_compounds is None: + recipe._combined_compounds = CompoundSet(list(row['compound_ids'])) + if recipe._poses is None: + recipe._poses = PoseSet( + PoseModel.objects.filter(pk__in=list(row['pose_ids'])) + ) + if recipe._interactions is None: + recipe._interactions = InteractionSet(list(row['interaction_ids'])) + if recipe._poses._metadata_dict is None: + recipe._poses._metadata_dict = row['pose_metadata'] + + def _dump_json(self) -> None: + path = self.json_path + if path.parent and not path.parent.exists(): + path.parent.mkdir(parents=True) + mrich.writing(path) + self._data.to_json(path) + + def _load_json(self) -> None: + path = self.json_path + mrich.reading(path) + cached = pd.read_json(path, orient='columns') + + if set(cached.columns) != set(self._data.columns): + raise ValueError("Cached score JSON columns don't match expectation") + if set(self._data.index.values) - set(cached.index.values): + raise ValueError('Cached score JSON is missing recipes') + + self._data = cached.replace({np.nan: None}) + + ### DUNDERS + + def __str__(self) -> str: + return f'Scorer(#recipes={self.num_recipes})' + + def __repr__(self) -> str: + import mcol + + return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' + + def __rich__(self) -> str: + return f'[bold underline]{self}' + + +DEFAULT_ATTRIBUTES = { + 'num_scaffolds': dict( + type='custom', + weight=1.0, + function=lambda r: r.combined_compounds.count_by_tag(tag='Syndirella scaffold'), + description='Number of Syndirella scaffold compounds. Higher is better.', + ), + 'num_compounds': dict( + type='standard', + weight=1.0, + description='Number of product compounds. Higher is better.', + ), + 'num_scaffolds_elaborated': dict( + type='custom', + weight=1.0, + function=lambda r: r.combined_compounds.num_scaffolds_elaborated, + description='Number of scaffolds with >=1 elaboration. Higher is better.', + ), + 'elaboration_balance': dict( + type='custom', + weight=1.0, + function=lambda r: r.combined_compounds.elaboration_balance, + description='Evenness of scaffold elaboration (h-index). Higher is better.', + ), + 'num_inspirations': dict( + type='custom', + weight=1.0, + function=lambda r: r.poses.num_inspirations, + description='Number of unique fragment inspirations. Higher is better.', + ), + 'num_inspiration_sets': dict( + type='custom', + weight=1.0, + function=lambda r: r.poses.num_inspiration_sets, + description='Number of unique fragment combinations. Higher is better.', + ), + 'interaction_count': dict( + type='custom', + weight=1.0, + function=lambda r: r.interactions.num_features, + description='Number of protein features interacted with. Higher is better.', + ), + 'interaction_balance': dict( + type='custom', + weight=1.0, + function=lambda r: r.interactions.per_feature_count_hirsch, + description='Evenness of interactions across features. Higher is better.', + ), + 'num_subsites': dict( + type='custom', + weight=1.0, + function=lambda r: r.poses.num_subsites, + description='Number of subsites occupied. Higher is better.', + ), + 'subsite_balance': dict( + type='custom', + weight=1.0, + function=lambda r: r.poses.subsite_balance, + description='Evenness of subsite occupancy. Higher is better.', + ), + 'avg_distance_score': dict( + type='custom', + weight=1.0, + function=lambda r: r.poses.avg_distance_score, + description='Average pose distance score.', + ), + 'avg_energy_score': dict( + type='custom', + weight=1.0, + function=lambda r: r.poses.avg_energy_score, + description='Average pose energy score.', + ), +} diff --git a/hippo/designdb/services/route.py b/hippo/designdb/services/route.py new file mode 100644 index 00000000..71844860 --- /dev/null +++ b/hippo/designdb/services/route.py @@ -0,0 +1,121 @@ +# from mypackage.services.compound import CompoundService + +# from rdkit.Chem import inchi +import logging +from collections import Counter + +import mrich +from designdb.models import ComponentModel, RouteModel +from designdb.recipe import Recipe + +logger = logging.getLogger(__name__) + + +class RouteService: + @classmethod + def create_from_recipe( + cls, + *, + recipe: Recipe, + ) -> tuple[RouteModel, bool]: + + route, created = RouteModel.objects.get_or_create( + product_compound=recipe.product.compound + ) + + # are you joking?? reactants and intermediates are all of the + # sudden components + + # component_type encoding (see Route.get_route): 1=reaction, 2=reactant, + # 3=intermediate + components = [] + + # reactions + components.extend( + [ + ComponentModel(route=route, component_type=1, component_ref=ref.pk) + for ref in recipe.reactions + ], + ) + + # reactants + components.extend( + [ + ComponentModel( + route=route, + component_type=2, + component_ref=ref, + component_amount=amount, + ) + for ref, amount in recipe.reactants.id_amount_pairs + ], + ) + + # intermediates + components.extend( + [ + ComponentModel( + route=route, + component_type=3, + component_ref=ref, + component_amount=amount, + ) + for ref, amount in recipe.intermediates.id_amount_pairs + ], + ) + + ComponentModel.objects.bulk_create(components, ignore_conflicts=True) + + return route, created + + @classmethod + def prune_duplicate_routes(cls) -> int: + """Delete duplicate routes, keeping the lowest-id copy of each. + + A duplicate is any pair of routes with the same product compound and + identical sets of (component_ref, component_type) pairs. + + Returns the number of routes deleted. + """ + rows = ComponentModel.objects.values_list( + 'route_id', 'route__product_compound_id', 'component_ref', 'component_type' + ) + + route_fingerprints: dict[int, tuple] = {} + for route_id, product_id, comp_ref, comp_type in rows: + if route_id not in route_fingerprints: + route_fingerprints[route_id] = (product_id, set()) + route_fingerprints[route_id][1].add((comp_ref, comp_type)) + + # freeze the sets so they're hashable + frozen = { + rid: (fp[0], frozenset(fp[1])) for rid, fp in route_fingerprints.items() + } + + mrich.var('#routes', len(frozen)) + + counter = Counter(frozen.values()) + duplicates = {fp: count for fp, count in counter.items() if count > 1} + mrich.var('products with duplicate routes', len(duplicates)) + + if not duplicates: + mrich.success('No duplicate routes found') + return 0 + + to_delete: set[int] = set() + for fp in duplicates: + matched = sorted(rid for rid, v in frozen.items() if v == fp) + mrich.print('compound', fp[0], 'has', len(matched), 'duplicate routes') + to_delete.update(matched[1:]) + + ComponentModel.objects.filter(route_id__in=to_delete).delete() + deleted, _ = RouteModel.objects.filter(pk__in=to_delete).delete() + mrich.success('Deleted', len(to_delete), 'duplicate routes') + return len(to_delete) + + # @property + # def id_amount_pairs(self) -> list[tuple]: + # """Get a list of compound ID and amount pairs""" + # return [ + # (id, amount) for id, amount in self.df[['compound_id', 'amount']].values + # ] diff --git a/hippo/designdb/services/subsite.py b/hippo/designdb/services/subsite.py new file mode 100644 index 00000000..c9365cac --- /dev/null +++ b/hippo/designdb/services/subsite.py @@ -0,0 +1,56 @@ +import logging +from collections import defaultdict + +from designdb.models import InspirationModel, SubsiteModel, SubsiteTagModel + +logger = logging.getLogger(__name__) + + +class SubsiteService: + @classmethod + def set_derivative_subsites(cls) -> None: + """Propagate subsite assignments from inspiration poses to their derivatives.""" + subsite_tags = SubsiteTagModel.objects.filter( + pose__in=InspirationModel.objects.values('original_pose') + ).select_related('subsite') + + inspirations = InspirationModel.objects.filter( + original_pose__in=subsite_tags.values('pose') + ).select_related('derivative_pose') + + original_to_derivatives = defaultdict(list) + for insp in inspirations: + original_to_derivatives[insp.original_pose_id].append(insp.derivative_pose) + + new_tags = [ + SubsiteTagModel(pose=derivative, subsite=st.subsite) + for st in subsite_tags + for derivative in original_to_derivatives[st.pose_id] + ] + + SubsiteTagModel.objects.bulk_create(new_tags, ignore_conflicts=True) + + @classmethod + def set_subsites_from_metadata_field( + cls, + poses, + field: str = 'CanonSites alias', + ) -> None: + """Create and assign subsite entries from a pose metadata field.""" + assignments = [] + + for pose in poses.select_related('target'): + metadata = pose.pose_metadata or {} + name = metadata.get(field) + if not name: + logger.warning( + 'Field "%s" not in metadata for pose pk=%s', field, pose.pk + ) + continue + subsite, _ = SubsiteModel.objects.get_or_create( + target=pose.target, + subsite_name=name, + ) + assignments.append(SubsiteTagModel(pose=pose, subsite=subsite)) + + SubsiteTagModel.objects.bulk_create(assignments, ignore_conflicts=True) diff --git a/hippo/designdb/sets/__init__.py b/hippo/designdb/sets/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hippo/designdb/sets/compound.py b/hippo/designdb/sets/compound.py new file mode 100644 index 00000000..b7becd62 --- /dev/null +++ b/hippo/designdb/sets/compound.py @@ -0,0 +1,1754 @@ +import json +from collections.abc import Callable +from pathlib import Path +from statistics import mean, pvariance +from typing import TYPE_CHECKING + +import mcol +import mrich +import pandas as pd +from designdb.components.compound import Compound, Ingredient +from designdb.components.reaction import Reaction +from designdb.models import ( + CompoundModel, + CompoundTagJunctionModel, + CompoundTagModel, + PoseModel, + PoseTagJunctionModel, + ReactantModel, + ReactionModel, + RouteModel, + ScaffoldModel, +) +from django.db.models import Count, Exists, OuterRef, Q +from pandas import DataFrame +from rdkit import Chem + +# from rdkit.Chem import inchi +from ..utils import registration_hash_tautomer_insensitive, superparent + +if TYPE_CHECKING: + import plotly.graph_objects as go + from designdb.sets.ingredient import IngredientSet + from designdb.sets.pose import PoseSet + from designdb.sets.reaction import ReactionSet + from designdb.sets.route import RouteSet + + +class CompoundSet: + """Object representing a subset of the 'compound' table in the :class:`.Database`. + + .. attention:: + + :class:`.CompoundSet` objects should not be created directly. Instead use + the :meth:`.HIPPO.compounds` property. See :doc:`getting_started` and + :doc:`insert_elaborations`. + + Use as an iterable + ================== + + Iterate through :class:`.CompoundModel` objects in the set: + + :: + + cset = animal.compounds[:100] + + for compound in cset: + ... + + Check membership + ================ + + To determine if a :class:`.CompoundModel` is present in the set: + + :: + + is_member = compound in cset + + Selecting compounds in the set + ============================== + + The :class:`.CompoundSet` can be indexed like standard Python lists by their indices + + :: + + cset = animal.compounds[1:100] + + # indexing individual compounds + comp = cset[0] # get the first compound + comp = cset[1] # get the second compound + comp = cset[-1] # get the last compound + + # getting a subset of compounds using a slice + cset2 = cset[13:18] # using a slice + + Tags and scaffold compounds can also be used to filter: + + :: + + cset = animal.compounds(tag='hits') # select compounds tagged with 'hits' + cset = animal.compounds(scaffold=comp) # select elaborations of comp + + """ + + def __init__( + self, + queryset=None, + *, + sort: bool = True, + name: str | None = None, + ) -> None: + """CompoundSet initialisation""" + + if queryset: + if isinstance(queryset, list): + self._queryset = CompoundModel.objects.filter(pk__in=queryset) + else: + self._queryset = queryset + else: + self._queryset = CompoundModel.objects.none() + + if sort: + self._queryset = self._queryset.order_by('pk') + + self._name = name + + ### DUNDERS + + def __len__(self) -> int: + """The number of compounds in this set""" + return self._queryset.count() + + def __iter__(self): + """Iterate through compounds in this set""" + return iter(self._queryset) + + def __getitem__( + self, + key: int | slice, + ) -> 'CompoundModel | CompoundSet': + """Get compounds or subsets thereof from this set + + :param key: integer index or slice of indices + + """ + match key: + case int(): + index = self.indices[key] + try: + return CompoundModel.objects.get(id=index) + except CompoundModel.DoesNotExist as exc: + raise CompoundModel.DoesNotExist from exc + + case slice(): + return CompoundSet(CompoundModel.objects.filter(pk__in=key)) + + case _: + raise NotImplementedError + + def __sub__( + self, + other: 'CompoundModel | CompoundSet | IngredientSet', + ) -> 'CompoundSet': + """Subtract a :class:`.CompoundModel` object or ID from this set, or subtract + multiple at once when ``other`` is a :class:`.CompoundSet` or + :class:`.IngredientSet`""" + + match other: + case CompoundSet(): + return CompoundSet( + CompoundModel.objects.filter( + Q(pk__in=self._queryset) & ~Q(pk__in=other.queryset) + ), + sort=False, + ) + case int(): + return CompoundSet( + CompoundModel.objects.filter( + Q(pk__in=self._queryset) & ~Q(pk=other.pk) + ), + sort=False, + ) + + def __add__( + self, + other: 'CompoundModel | CompoundSet | IngredientSet | int', + ) -> 'CompoundSet': + """Add a :class:`.CompoundModel` object or ID to this set, or add multiple at + once when ``other`` is a :class:`.CompoundSet` or :class:`.IngredientSet`""" + + # local import to avoid the IngredientSet <-> CompoundSet cycle + from designdb.sets.ingredient import IngredientSet + + match other: + case CompoundModel(): + return CompoundSet( + CompoundModel.objects.filter( + Q(pk__in=self._queryset) | Q(pk__in=other._queryset) + ), + sort=False, + ) + + case int(): + return CompoundSet( + CompoundModel.objects.filter( + Q(pk__in=self._queryset) | Q(pk__in=other._queryset) + ), + sort=False, + ) + + case CompoundSet(): + return CompoundSet( + CompoundModel.objects.filter( + Q(pk__in=self._queryset) | Q(pk__in=other._queryset) + ), + sort=False, + ) + + case IngredientSet(): + return CompoundSet( + CompoundModel.objects.filter( + Q(pk__in=self._queryset) | Q(pk__in=other._queryset) + ), + sort=False, + ) + + case _: + raise NotImplementedError + + def __and__(self, other: 'CompoundSet'): + """AND set operation, returns only compounds in both sets""" + + match other: + case CompoundSet(): + return CompoundSet( + CompoundModel.objects.filter( + Q(pk__in=self._queryset) & Q(pk__in=other.queryset) + ), + sort=False, + ) + + case _: + raise NotImplementedError + + def __or__(self, other: 'CompoundSet'): + """OR set operation, returns union of both sets""" + + match other: + case CompoundSet(): + return CompoundSet( + CompoundModel.objects.filter( + Q(pk__in=self._queryset) | Q(pk__in=other.queryset) + ), + sort=False, + ) + + case _: + raise NotImplementedError + + def __xor__(self, other: 'CompoundSet'): + """Exclusive OR set operation, returns all compounds in either set but + not both""" + + match other: + case CompoundSet(): + return CompoundSet( + CompoundModel.objects.filter( + Q(Q(pk__in=self._queryset) | Q(pk__in=other.queryset)) + & ~Q(Q(pk__in=self._queryset) & Q(pk__in=other.queryset)) + ), + sort=False, + ) + + case _: + raise NotImplementedError + + def __str__(self) -> str: + """Unformatted string representation""" + + if self.name: + s = f'{self.name}: ' + else: + s = '' + + s += f'{{C × {len(self)}}}' + + return s + + def __repr__(self) -> str: + """ANSI ormatted string representation""" + return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' + + def __rich__(self) -> str: + """Representation for mrich""" + return f'[bold underline]{self}' + + def __contains__(self, other: 'CompoundModel | int | Ingredient'): + """Check if a compound or ingredient is a member of this set""" + match other: + case CompoundModel(): + pk = other.pk + case int(): + pk = other + case _: + # Ingredient (or anything exposing a compound id) + pk = getattr(other, 'compound_id', None) + if pk is None: + pk = getattr(other, 'id', None) + + if pk is None: + return False + + return self._queryset.filter(pk=pk).exists() + + ### FILTERING + + def get_by_tag( + self, + tag: str, + inverse: bool = False, + ) -> 'CompoundSet': + """Get all child compounds with a certain tag""" + + self._queryset = self._queryset.annotate( + has_tag=Exists( + CompoundTagJunctionModel.objects.filter( + compound=OuterRef('pk'), + compound_tag__compound_tag_name=tag, + ), + ), + ) + if inverse: + return CompoundSet(self._queryset.filter(has_tag=False)) + else: + return CompoundSet(self._queryset.filter(has_tag=True)) + + def get_by_metadata(self, key: str, value: str | None = None) -> 'CompoundSet': + """Get all child compounds with by their metadata. If no value is passed, then + simply containing the key in the metadata dictionary is sufficient + + :param key: metadata key + :param value: metadata value (Default value = None) + """ + + q = Q(compound_metadata__has_key=key) + if value: + q = Q(compound_metadata__key=value) + + qs = CompoundModel.objects.filter(q) + + return CompoundSet(qs) + + def get_by_scaffold( + self, + scaffold: CompoundModel | int, + none: str = 'error', + ) -> 'CompoundSet': + """Get all compounds that elaborate the given scaffold compound + + :param scaffold: :class:`.CompoundModel` object or ID to search by + + """ + + scaffold_id = scaffold if isinstance(scaffold, int) else scaffold.id + + ids = list( + ScaffoldModel.objects.filter( + base_compound_id=scaffold_id, + superstructure_compound_id__in=self.ids, + ) + .values_list('superstructure_compound_id', flat=True) + .distinct() + ) + return CompoundSet(ids) + + def get_by_smiles(self, smiles: str) -> CompoundModel: + """Get a compound in this set by SMILES, using tautomer-insensitive matching. + + :param smiles: SMILES string to search for + :raises ValueError: if SMILES standardisation fails + :raises CompoundModel.DoesNotExist: if no match found in this set + """ + mol = Chem.MolFromSmiles(smiles, sanitize=True) + try: + sp = superparent(mol) + except Exception as e: + raise ValueError(f'SuperParent failed: {e}') from e + + h = registration_hash_tautomer_insensitive(sp) + return self._queryset.get(compound_hash=h) + + def get_all_possible_reactants( + self, + debug: bool = False, + ) -> 'CompoundSet': + """Recursively searches for all the reactants that could possible be needed to + synthesise these compounds. + + :param debug: Increased verbosity for debugging (Default value = False) + + """ + + qs = CompoundModel.objects.filter( + pk__in=ReactantModel.objects.filter( + reaction__in=self._queryset, + ), + ) + + seen = set(qs.values_list('id', flat=True)) + frontier = set(seen) + + while frontier: + new = ( + set( + CompoundModel.objects.filter( + pk__in=ReactantModel.objects.filter( + reaction__in=self._queryset, + ), + ).values_list('pk', flat=True) + ) + - seen + ) + + seen |= new + frontier = new + + return CompoundSet(CompoundModel.objects.filter(pk__in=seen)) + + def get_all_possible_reactions( + self, + debug: bool = False, + ) -> 'ReactionSet': + """Recursively searches for all the reactants that could possible be needed to + synthesise these compounds. + + :param debug: Increased verbosity for debugging (Default value = False) + + """ + qs = CompoundModel.objects.filter( + pk__in=ReactantModel.objects.filter( + reaction__in=self._queryset, + ), + ) + + seen = set(qs.values_list('id', flat=True)) + frontier = set(seen) + + while frontier: + new = ( + set( + CompoundModel.objects.filter( + pk__in=ReactantModel.objects.filter( + reaction__in=self._queryset, + ), + ).values_list('pk', flat=True) + ) + - seen + ) + + seen |= new + frontier = new + + return ReactionModel.objects.filter(product__compound__in=seen) + + def get_risk_diversity(self, debug: bool = False) -> float: + """Calculate the average spread of risk (#atoms added) for each scaffold in + this set + + :returns: average of the standard deviations of number of atoms added for each + scaffold + + """ + + # heavy-atom count per compound, cached across scaffold edges + nha_cache: dict[int, int | None] = {} + + def nha(compound_id: int) -> int | None: + if compound_id not in nha_cache: + nha_cache[compound_id] = Compound( + CompoundModel.objects.get(pk=compound_id) + ).num_heavy_atoms + return nha_cache[compound_id] + + # group the #atoms-added of each elaboration by its scaffold base + diffs_by_base: dict[int, list[int]] = {} + for base_id, elab_id in ScaffoldModel.objects.filter( + superstructure_compound_id__in=self.ids + ).values_list('base_compound_id', 'superstructure_compound_id'): + base_nha, elab_nha = nha(base_id), nha(elab_id) + if base_nha is None or elab_nha is None: + continue + diffs_by_base.setdefault(base_id, []).append(elab_nha - base_nha) + + if not diffs_by_base: + return None + + # population variance of #atoms-added within each scaffold (0 for singletons) + variances = [pvariance(diffs) for diffs in diffs_by_base.values()] + + if debug: + mrich.debug(f'{variances=}') + + return mean(variances) + + def count_by_tag( + self, + tag: str, + ) -> 'CompoundSet': + """Count all child compounds with a certain tag + + :param tag: tag to filter by + + """ + return self._queryset.annotate( + has_tag=Exists( + CompoundTagModel.objects.filter( + compound=OuterRef('pk'), + compound_tag__compound_tag_name=tag, + ), + ), + ).count() + + ### CONSOLE / NOTEBOOK OUTPUT + + def draw(self) -> None: + """Draw a grid of all contained molecules. + + .. attention:: + + This method is only intended for use within a Jupyter Notebook. + + """ + + from IPython.display import display + from molparse.rdkit import draw_grid + + compounds = [Compound(c) for c in self._queryset] + mols = [c.mol for c in compounds] + labels = [str(c) for c in compounds] + + display(draw_grid(mols, labels=labels)) + + def grid(self) -> None: + """Draw a grid of all contained molecules. + + .. attention:: + + This method is only intended for use within a Jupyter Notebook. + + """ + + self.draw() + + def summary(self, return_df: bool = False) -> None: + """Print a summary of this compound set""" + + mrich.header(self) + + ids = list(self.ids) + + # compound-tag counts: distinct compounds per compound tag + comp_rows = [ + dict(tag=name, num_compounds=n) + for name, n in CompoundTagJunctionModel.objects.filter(compound_id__in=ids) + .values_list('compound_tag__compound_tag_name') + .annotate(n=Count('compound', distinct=True)) + .order_by('compound_tag__compound_tag_name') + ] + df = DataFrame(comp_rows) + if len(df): + df = df.set_index('tag') + else: + df = DataFrame(columns=['num_compounds']) + df.index.name = 'tag' + + # pose-tag counts over the poses of these compounds + for name, num_poses, num_posed in ( + PoseTagJunctionModel.objects.filter(pose__compound_id__in=ids) + .values_list('pose_tag__pose_tag_name') + .annotate( + num_poses=Count('pose', distinct=True), + num_posed=Count('pose__compound', distinct=True), + ) + .order_by('pose_tag__pose_tag_name') + ): + df.loc[name, 'num_poses'] = num_poses + df.loc[name, 'num_posed_compounds'] = num_posed + + df.loc['TOTAL', 'num_compounds'] = len(self) + df.loc['TOTAL', 'num_poses'] = self.num_poses + df.loc['TOTAL', 'num_posed_compounds'] = ( + PoseModel.objects.filter(compound_id__in=ids) + .values('compound_id') + .distinct() + .count() + ) + + df = df.fillna(0) + df = df.astype(int) + + if return_df: + return df + else: + mrich.print(df) + + def interactive( + self, + function: Callable | None = None, + ) -> None: + """Creates a ipywidget to interactively navigate this PoseSet.""" + + from IPython.display import display + from ipywidgets import ( + BoundedIntText, + Checkbox, + GridBox, + Layout, + VBox, + interactive, + interactive_output, + ) + + if function: + + def widget(i): + """interactive function widget""" + compound = self[i] + display(compound) + function(compound) + + return interactive( + widget, + i=BoundedIntText( + value=0, + min=0, + max=len(self) - 1, + step=1, + description=f'Comp (/{len(self)}):', + disabled=False, + ), + ) + + else: + a = BoundedIntText( + value=0, + min=0, + max=len(self) - 1, + step=1, + description=f'Comp (/{len(self)}):', + disabled=False, + ) + + b = Checkbox(description='Name', value=True) + c = Checkbox(description='Summary', value=False) + d = Checkbox(description='2D', value=True) + e = Checkbox(description='Poses', value=False) + f = Checkbox(description='Reactions', value=False) + g = Checkbox(description='Tags', value=False) + h = Checkbox(description='Quotes', value=False) + i = Checkbox(description='Metadata', value=False) + j = Checkbox(description='Classify', value=False) + + ui1 = GridBox( + [b, c, d], layout=Layout(grid_template_columns='repeat(3, 100px)') + ) + ui2 = GridBox( + [e, f, g], layout=Layout(grid_template_columns='repeat(3, 100px)') + ) + ui3 = GridBox( + [h, i, j], layout=Layout(grid_template_columns='repeat(3, 100px)') + ) + ui = VBox([a, ui1, ui2, ui3]) + + def widget( + i, + name: bool = True, + summary: bool = True, + draw: bool = True, + poses: bool = True, + reactions: bool = True, + tags: bool = True, + quotes: bool = True, + metadata: bool = True, + classify: bool = True, + ): + """interactive default widget""" + """ + + :param i: param name: (Default value = True) + :param summary: Default value = True) + :param draw: Default value = True) + :param poses: Default value = True) + :param reactions: Default value = True) + :param metadata: Default value = True) + :param name: (Default value = True) + + """ + comp = self[i] + + if name and not summary: + print(repr(comp)) + + if summary: + comp.summary(metadata=False, draw=False, tags=False) + + if draw: + comp.draw() + + if poses and (pset := comp.poses): + for p in pset: + mrich.print(p) + pset.draw() + + if reactions and (reactions := comp.reactions): + for r in reactions: + mrich.print(r) + r.draw() + + if tags: + mrich.title('Tags') + mrich.print(comp.tags) + + if quotes: + mrich.title('Quotes') + display(comp.get_quotes(df=True)) + + if metadata: + mrich.title('Metadata:') + mrich.print(comp.metadata) + + if classify: + mrich.title('Classification:') + comp.classify() + + out = interactive_output( + widget, + { + 'i': a, + 'name': b, + 'summary': c, + 'draw': d, + 'poses': e, + 'reactions': f, + 'tags': g, + 'quotes': h, + 'metadata': i, + 'classify': j, + }, + ) + + display(ui, out) + + def tag_summary(self) -> 'pd.DataFrame': + """Print a summary table of tags with compound counts""" + + data = [ + dict(tag=name, num_compounds=n) + for name, n in CompoundTagJunctionModel.objects.filter( + compound_id__in=list(self.ids) + ) + .values_list('compound_tag__compound_tag_name') + .annotate(n=Count('compound', distinct=True)) + .order_by('compound_tag__compound_tag_name') + ] + + df = DataFrame(data) + if len(df): + df = df.set_index('tag').astype(int) + + mrich.print(df) + + return df + + ### OTHER METHODS + + def get_recipes( + self, + amount: float = 1, + debug: bool = False, + pick_cheapest: bool = False, + permitted_reactions: 'ReactionSet | None' = None, + quoted_only: bool = False, + supplier: None | str = None, + **kwargs, + ): + """Generate the :class:`.Recipe` to make these compounds. + + See :meth:`.RecipeService.from_compounds` + """ + + # convenience bridge to the service layer + from designdb.services.recipe import RecipeService + + return RecipeService.from_compounds( + self, + amount=amount, + debug=debug, + pick_cheapest=pick_cheapest, + permitted_reactions=permitted_reactions, + quoted_only=quoted_only, + supplier=supplier, + **kwargs, + ) + + def get_routes( + self, + permitted_reactions: 'None | ReactionSet' = None, + return_ids: bool = False, + debug: bool = True, + ) -> 'RouteSet': + """Get a RoutSet to products in this set. + + :param permitted_reactions: optionally restrict reactions to those in this + :class:`.ReactionSet` + + """ + + from designdb.models import ComponentModel, RouteModel + + from .route import RouteSet + + base_qs = RouteModel.objects.filter(product_compound_id__in=list(self.ids)) + + if permitted_reactions is not None: + permitted = set(permitted_reactions.ids) + + if debug: + mrich.debug('Querying database for routes') + + # reaction components (component_type == 1) grouped per route + rows = ComponentModel.objects.filter( + route__in=base_qs, + component_type=1, + ).values_list('route_id', 'component_ref') + + route_reactions: dict[int, set[int]] = {} + for route_id, reaction_id in rows: + route_reactions.setdefault(route_id, set()).add(reaction_id) + + if debug: + mrich.debug('Checking availability') + + available_routes = [ + route_id + for route_id, reactions in route_reactions.items() + if reactions <= permitted + ] + + if return_ids: + return available_routes + + return RouteSet.from_ids(available_routes) + + route_ids = list(base_qs.values_list('id', flat=True)) + + if return_ids: + return route_ids + + return RouteSet.from_ids(route_ids) + + def copy(self) -> 'CompoundSet': + """Returns a copy of this set""" + return CompoundSet(self.ids) + + def get_df( + self, + smiles: bool = True, + inchikey: bool = False, + alias: bool = True, + mol: bool = False, + metadata: bool = False, + expand_metadata: bool = True, + poses: bool = False, + num_reactant: bool = False, + num_reactions: bool = False, + num_poses: bool = False, + tags: bool = False, + scaffolds: bool = False, + elabs: bool = False, + routes: bool = False, + debug: bool = False, + **kwargs, + ) -> 'DataFrame': + """Get a DataFrame representation of this set + + :param smiles: include SMILES column (Default value = True) + :param inchikey: include InChIKey column (Default value = False) + :param alias: include alias column (Default value = True) + :param mol: include ``rdkit.Chem.Mol`` in output (Default value = False) + :param metadata: include metadata in output (Default value = False) + :param expand_metadata: create separate column for each metadata key + (Default value = True) + :param poses: include poses in output (Default value = False) + :param num_reactant: include num_poses column + :param num_reactant: include num_reactant column (number of reactions where + compound is a reactant) + :param num_reactions: include num_reactions column (number of reactions where + compound is a product) + :param tags: include tags column + :param scaffolds: include scaffolds column + :param elabs: include elabs column + + """ + + if mol: + raise NotImplementedError( + 'get_df(mol=True) requires the RDKit cartridge (mol_to_binary_mol)' + ) + + ids = list(self.ids) + + # base columns straight off the CompoundModel rows + fields = ['id'] + if smiles: + fields.append('compound_smiles') + if inchikey: + fields.append('compound_inchikey') + if alias: + fields.append('compound_alias') + if metadata: + fields.append('compound_metadata') + + if debug: + mrich.debug('querying...') + + rows = { + r['id']: r for r in CompoundModel.objects.filter(pk__in=ids).values(*fields) + } + + data = [] + for cid in ids: + row = rows.get(cid) + if row is None: + continue + + d = dict(id=cid) + + if smiles: + d['smiles'] = row['compound_smiles'] + + if inchikey: + d['inchikey'] = row['compound_inchikey'] + + if alias: + d['alias'] = row['compound_alias'] + + # compound_metadata is JSON stored in a TextField + if metadata and (meta_str := row['compound_metadata']): + meta_dict = json.loads(meta_str) + + if expand_metadata: + for k, v in meta_dict.items(): + d[k] = v + else: + d['metadata'] = meta_dict + + data.append(d) + + df = DataFrame(data) + + if not data: + return df + + if poses or num_poses: + if debug: + mrich.debug('adding pose column') + + lookup: dict[int, set] = {} + for cid, pid in PoseModel.objects.filter(compound_id__in=ids).values_list( + 'compound_id', 'id' + ): + lookup.setdefault(cid, set()).add(pid) + + if poses: + df['poses'] = df['id'].apply(lambda x: lookup.get(x, set())) + if num_poses: + df['num_poses'] = df['id'].apply(lambda x: len(lookup.get(x, set()))) + + if num_reactant: + if debug: + mrich.debug('adding num_reactant column') + counts: dict[int, int] = {} + for cid in ReactantModel.objects.filter(compound_id__in=ids).values_list( + 'compound_id', flat=True + ): + counts[cid] = counts.get(cid, 0) + 1 + df['num_reactant'] = df['id'].apply(lambda x: counts.get(x, 0)) + + if num_reactions: + if debug: + mrich.debug('adding num_reactions column') + counts = {} + for cid in ReactionModel.objects.filter( + product_compound_id__in=ids + ).values_list('product_compound_id', flat=True): + counts[cid] = counts.get(cid, 0) + 1 + df['num_reactions'] = df['id'].apply(lambda x: counts.get(x, 0)) + + if scaffolds: + if debug: + mrich.debug('adding scaffolds column') + lookup = {} + for sup_id, base_id in ScaffoldModel.objects.filter( + superstructure_compound_id__in=ids + ).values_list('superstructure_compound_id', 'base_compound_id'): + lookup.setdefault(sup_id, set()).add(base_id) + df['scaffolds'] = df['id'].apply(lambda x: lookup.get(x, set())) + + if elabs: + if debug: + mrich.debug('adding elabs column') + lookup = {} + for base_id, sup_id in ScaffoldModel.objects.filter( + base_compound_id__in=ids + ).values_list('base_compound_id', 'superstructure_compound_id'): + lookup.setdefault(base_id, set()).add(sup_id) + df['elabs'] = df['id'].apply(lambda x: lookup.get(x, set())) + + if tags: + if debug: + mrich.debug('adding tag column') + lookup = {} + for cid, name in CompoundTagJunctionModel.objects.filter( + compound_id__in=ids + ).values_list('compound_id', 'compound_tag__compound_tag_name'): + lookup.setdefault(cid, set()).add(name) + df['tags'] = df['id'].apply(lambda x: lookup.get(x, set())) + + if routes: + if debug: + mrich.debug('adding route column') + lookup = {} + for pid, rid in RouteModel.objects.filter( + product_compound_id__in=ids + ).values_list('product_compound_id', 'id'): + lookup.setdefault(pid, set()).add(rid) + df['routes'] = df['id'].apply(lambda x: lookup.get(x, set())) + + df = df.set_index('id') + + return df + + def get_quoted( + self, + *, + supplier: str = 'any', + ) -> 'CompoundSet': + """Get all member compounds that have a catalogue quote. + + Quotes live in ``catalogue_prices`` and are linked to compounds via the + ``compound_catalogue_map`` junction (see :class:`.QuoteService`). + + :param supplier: restrict to this supplier, or ``'any'`` (default) + """ + + from designdb.models import CataloguePriceCompoundJunctionModel + + qs = CataloguePriceCompoundJunctionModel.objects.filter( + compound_id__in=list(self.ids) + ) + if supplier != 'any': + qs = qs.filter(catalogue_price__supplier=supplier) + + quoted = set(qs.values_list('compound_id', flat=True).distinct()) + return CompoundSet([i for i in self.ids if i in quoted]) + + def get_unquoted( + self, + *, + supplier: str = 'any', + ) -> 'CompoundSet': + """Get all member compounds that do not have a quote from given supplier + + :param supplier: supplier name (Default value = 'any') + + """ + + quoted = self.get_quoted(supplier=supplier) + return self - quoted + + def get_dict(self) -> dict: + """Get a dictionary object with all serialisable data needed to reconstruct + this set""" + return dict(indices=list(self.indices)) + + def write_smiles_csv( + self, file: str, tags: bool = True, split_tags: bool = True + ) -> None: + """Write a CSV of the smiles contained in this set to a file + + :param file: path of the CSV file + :param tags: include tags in output + :param split_tags: split tags into separate columns + + """ + df = self.get_df(smiles=True, alias=False, tags=tags).reset_index() + + # explode the per-compound tag sets into a boolean column per tag + if tags and split_tags and 'tags' in df.columns: + all_tags: set[str] = set().union(*df['tags']) if len(df) else set() + for tag in sorted(all_tags): + df[tag] = df['tags'].apply(lambda s, t=tag: t in s) + df = df.drop(columns=['tags']) + + mrich.writing(file) + df.to_csv(file, index=False) + + def write_postera_csv( + self, + file, + *, + supplier: str = 'Enamine', + prefix: str = 'fragment', + ) -> None: + """Write a CSV formatted for upload to Postera's Manifold + + :param file: path of the CSV file + :param supplier: supplier to use for quotes, (Default value = 'Enamine') + :param prefix: prefix to metadata columns, (Default value = 'fragment') + + """ + + from datetime import date as dt + + from pandas import DataFrame + + if prefix: + prefix = f'{prefix}_' + + data = [] + + for c in mrich.track(self, prefix='Creating DataFrame'): + # get props + smiles = c.smiles + tags = c.tags + metadata = c.metadata + poses = c.poses + scaffold = c.scaffold + + # method + assert len(tags) == 1, c + method = tags[0] + + # date + date = dt.today() + + # author + assert 'author' in metadata, c + author = metadata['author'] + + match len(poses): + case 1: + pose = poses[0] + case 0: + mrich.warning(f'{c} has no poses') + assert scaffold + pose = scaffold.poses[0] + case _: + mrich.warning(f'{c} has multiple poses') + pose = poses[0] + + # extract inspirations + inspirations = pose.inspirations + inspiration_names = ','.join(inspirations.names) + inspiration_smiles = '.'.join(inspirations.smiles) + + # quote info + quotes = c.get_quotes(supplier=supplier) + assert len(quotes) == 1, c + quote = quotes[0] + catalog_id = quote.entry + catalog_price = quote.price + catalog_lead_time = quote.lead_time + + # hippo string + hippo_str = f'compound={c.id}, pose={pose.id}' + + # create row + data.append( + { + 'SMILES': smiles, + f'{prefix}HIPPO_IDs': hippo_str, + f'{prefix}method': method, + f'{prefix}export_date': date, + f'{prefix}author': author, + f'{prefix}inspiration_names': inspiration_names, + f'{prefix}inspiration_SMILES': inspiration_smiles, + f'{prefix}supplier': supplier, + f'{prefix}supplier_catalogue': quote.catalogue, + f'{prefix}supplier_ID': catalog_id, + f'{prefix}supplier_price': catalog_price, + f'{prefix}supplier_lead_time': catalog_lead_time, + } + ) + + df = DataFrame(data) + + mrich.writing(file) + df.to_csv(file, index=False) + + return df + + def write_CAR_csv( + self, + file: 'str | Path', + amount: float = 1, # in mg + return_df: bool = False, + # pick_cheapest: bool = False, + quoted_only: bool = False, + get_ingredient_quotes: bool = True, + **kwargs, + ) -> 'DataFrame | None': + """List of reactions for CAR + + Columns: + + * target-name + * no-steps + * concentration = None + * amount-required + * batch-tag + + per reaction + + * reactant-1-1 + * reactant-2-1 + * reaction-product-smiles-1 + * reaction-name-1 + * reaction-recipe-1 + * reaction-groupby-column-1 + + :param file: output file + :param amount: amount of each product in `mg` + :param quoted_only: only choose reactants that have quotes + :param supplier: only choose reactants that have quotes from this supplier + :param kwargs: passed to :meth:`.Recipe.from_reaction` + :param return_df: return a `DataFrame` (Default value = False) + + """ + + # avoiding circular imports + from designdb.recipe import Recipe + + file = str(Path(file).resolve()) + + rows = [] + + for r_id in mrich.track(self.reaction_ids, prefix='Solving compound recipes'): + reaction = ReactionModel.objects.get(pk=r_id) + + recipes = Recipe.from_reaction( + reaction, + amount=amount, + pick_cheapest=False, + quoted_only=quoted_only, + get_ingredient_quotes=get_ingredient_quotes, + **kwargs, + ) + + for sub_recipe in recipes: + product = sub_recipe.product + + row = { + 'target-names': str(product.compound), + 'no-steps': 0, + 'concentration-required-mM': None, + 'amount-required-uL': None, + 'batch-tag': None, + } + + for i, reaction_model in enumerate(sub_recipe.reactions): + reaction = Reaction(reaction_model) + i = i + 1 + + row['no-steps'] += 1 + + match len(reaction.reactants): + case 1: + row[f'reactant-1-{i}'] = reaction.reactants[0].smiles + row[f'reactant-2-{i}'] = None + case 2: + row[f'reactant-1-{i}'] = reaction.reactants[0].smiles + row[f'reactant-2-{i}'] = reaction.reactants[1].smiles + case _: + raise NotImplementedError( + f'Unsupported number of reactants for' + f' {reaction=}: {len(reaction.reactants)}' + ) + + row[f'reaction-product-smiles-{i}'] = reaction.product.smiles + row[f'reaction-name-{i}'] = reaction.type + row[f'reaction-recipe-{i}'] = None + row[f'reaction-groupby-column-{i}'] = None + # row[f'reaction-id-{i}'] = int(reaction.id) + + rows.append(row) + + df = DataFrame(rows) + + df = df.convert_dtypes() + + for n_steps in set(df['no-steps']): + subset = df[df['no-steps'] == n_steps] + this_file = file.replace('.csv', f'_{n_steps}steps.csv') + mrich.writing(this_file) + subset.to_csv(this_file, index=False) + + mrich.writing(file) + df.to_csv(file, index=False) + + if return_df: + return df + + def add_tag( + self, + tag: str, + ) -> None: + """Add this tag to every member of the set""" + + assert isinstance(tag, str) + + compound_tag, _ = CompoundTagModel.objects.get_or_create(compound_tag_name=tag) + + CompoundTagJunctionModel.objects.bulk_create( + [ + CompoundTagJunctionModel(compound=compound, compound_tag=compound_tag) + for compound in self._queryset + ], + ignore_conflicts=True, + ) + + mrich.print(f'Tagged {self} w/ "{tag}"') + + def plot_tsnee(self, **kwargs) -> 'go.Figure': + """Plot a tanimoto similarity plot of these compounds""" + from .plotting import plot_compound_tsnee + + return plot_compound_tsnee(self, **kwargs) + + def as_ingredientset( + self, + amount: float | list[float] = 1, + supplier: str | list | None = None, + ) -> 'IngredientSet': + """Get an :class:`.IngredientSet` for these compounds""" + from designdb.sets.ingredient import IngredientSet + + return IngredientSet.from_compounds( + compounds=self, amount=amount, supplier=supplier + ) + + def split_by_scaffolds(self) -> 'dict[CompoundSet, CompoundSet]': + """Split this set into subsets clustered by scaffold compound + + Maps a single-compound :class:`.CompoundSet` for each scaffold base to a + :class:`.CompoundSet` of its elaborations within this set. + """ + clusters: dict[int, set] = {} + for base_id, sup_id in ScaffoldModel.objects.filter( + superstructure_compound_id__in=self.ids + ).values_list('base_compound_id', 'superstructure_compound_id'): + clusters.setdefault(base_id, set()).add(sup_id) + + return { + CompoundSet([base_id]): CompoundSet(list(elab_ids)) + for base_id, elab_ids in clusters.items() + } + + def despaghettify( + self, + register_missing_routes: bool = True, + supplier='Enamine', + ) -> 'CompoundSet': + """Reduce this set to only compounds that elaborate a single reactant at a time. + Requires routes to be present in the database.""" + + if register_missing_routes: + mrich.debug('registering_missing_routes...') + route_lookup = self.register_missing_routes( + missing_only=True, supplier=supplier + ) + + mrich.debug('clustering by scaffold...') + clustered = self.split_by_scaffolds() + + n = len(clustered) + mrich.var('#clusters', n) + + mrich.debug('getting route lookup...') + route_lookup = self.db.get_product_id_routes_dict() + + mrich.debug('getting reactant lookup...') + reactant_lookup = self.db.get_route_id_reactant_ids_dict() + + keep = set() + for i, (cluster, elabs) in enumerate(clustered.items()): + for scaffold in cluster: + mrich.debug( + f'{i}/{n}', + 'scaffold:', + scaffold.id, + '#elabs:', + len(elabs), + '#kept:', + len(keep), + ) + + route_ids = route_lookup.get(scaffold.id) + + if not route_ids: + mrich.error(f'scaffold {scaffold} has no routes') + continue + + elif len(route_ids) > 1: + mrich.warning(f'scaffold {scaffold} has multiple routes') + + for route_id in route_ids: + scaffold_reactants = reactant_lookup[route_id] + + for elab in elabs: + route_ids = route_lookup.get(elab.id, set()) + + if len(route_ids) != 1: + mrich.error(f'elab {elab.id} has {route_ids=}') + continue + + reactants = reactant_lookup[list(route_ids)[0]] + + common = scaffold_reactants & reactants + + if len(common) == len(scaffold_reactants) - 1: + keep.add(elab.id) + + return CompoundSet(self.db, keep) + + def register_missing_routes( + self, missing_only: bool = True, supplier: str = 'Enamine' + ) -> None: + """Calculate missing routes to compounds in this set""" + + if missing_only: + records = self.db.select_where( + table='route', + key=f'route_product IN {self.str_ids}', + query='route_product', + multiple=True, + ) + existing = set(i for (i,) in records) + missing = set(self.ids) - existing + return CompoundSet(self.db, missing).register_missing_routes( + missing_only=False, supplier=supplier + ) + + mrich.var('#compounds', len(self)) + + for c in mrich.track(self, total=len(self)): + try: + reactions = c.reactions + except Exception as e: + mrich.error(f"Error getting {c}'s reactions", e) + continue + + for reaction in reactions: + try: + recipes = reaction.get_recipes(supplier=supplier) + except Exception as e: + mrich.error(f"Error getting {reaction}'s ({c}) recipes", e) + continue + + for recipe in recipes: + route = self.db.register_route(recipe=recipe) + + mrich.print(f'registered {route=}') + + self.db.prune_duplicate_routes() + + ### PROPERTIES + + @property + def queryset(self): + """Associated :class:`.Database` object""" + return self._queryset + + @property + def indices(self) -> list[int]: + """Returns the ids of compounds in this set""" + return self._queryset.values_list('id', flat=True) + + @property + def ids(self) -> list[int]: + """Returns the ids of compounds in this set""" + return self.indices + + @property + def name(self) -> str | None: + """Returns the name of set""" + return self._name + + @property + def names(self) -> list[str]: + """Returns the aliases of compounds in this set""" + return list(self._queryset.values_list('compound_alias', flat=True)) + + @property + def smiles(self) -> list[str]: + """Returns the smiles of child compounds""" + return list(self._queryset.values_list('compound_smiles', flat=True)) + + @property + def mols(self) -> 'list[Chem.Mol]': + """Returns the RDKit molecules of child compounds + + Uses each compound's stored MolBlock (``compound_mol``) where available, + falling back to parsing ``compound_smiles``. + """ + mols = [] + for molblock, smiles in self._queryset.values_list( + 'compound_mol', 'compound_smiles' + ): + if molblock: + mols.append(Chem.MolFromMolBlock(molblock)) + elif smiles: + mols.append(Chem.MolFromSmiles(smiles)) + else: + mols.append(None) + return mols + + @property + def inchikeys(self) -> list[str]: + """Returns the inchikeys of compounds in this set""" + return list(self._queryset.values_list('compound_inchikey', flat=True)) + + @property + def tags(self) -> set[str]: + """Returns the set of unique tags present in this compound set""" + return set( + CompoundTagJunctionModel.objects.filter(compound_id__in=self.ids) + .values_list('compound_tag__compound_tag_name', flat=True) + .distinct() + ) + + @property + def num_poses(self) -> int: + """Count the poses associated to this set of compounds""" + return PoseModel.objects.filter(compound_id__in=self.ids).count() + + @property + def poses(self) -> 'PoseSet': + """Get the poses associated to this set of compounds""" + from .pose import PoseSet + + return PoseSet(PoseModel.objects.filter(compound_id__in=self.ids)) + + def best_placed_poses( + self, + scoring_method: str, + version: str | None = None, + inverse: bool = False, + ) -> 'PoseSet': + """Get the best-scoring pose for each compound in this set. + + Delegates to :meth:`.PoseSet.get_best_scoring_poses_per_compound`. + + :param scoring_method: ``ScoringMethodModel.method_name`` to rank by + :param version: ``ScoringMethodModel.method_version`` — required when + multiple versions of the same method exist + :param inverse: if ``True``, higher score is better (default: lower is better) + """ + return self.poses.get_best_scoring_poses_per_compound( + scoring_method, version=version, inverse=inverse + ) + + @property + def str_ids(self) -> str: + """Return an SQL formatted tuple string of the :class:`.CompoundModel` IDs""" + return str(tuple(self.ids)).replace(',)', ')') + + @property + def num_heavy_atoms(self) -> int: + """Get the total number of heavy atoms""" + return sum( + n for c in self._queryset if (n := Compound(c).num_heavy_atoms) is not None + ) + + @property + def num_rings(self): + """Get the total number of molecular rings""" + return sum( + n for c in self._queryset if (n := Compound(c).num_rings) is not None + ) + + @property + def formula(self) -> str: + """Get the combined chemical formula for all compounds""" + from molparse.atomtypes import atomtype_dict_to_formula + + return atomtype_dict_to_formula(self.atomtype_dict) + + @property + def atomtype_dict(self) -> dict[str, int]: + """Get a dictionary with atomtypes as keys and corresponding + quantities/counts as values""" + from molparse.atomtypes import combine_atomtype_dicts + + atomtype_dicts = [Compound(c).atomtype_dict for c in self._queryset] + return combine_atomtype_dicts(atomtype_dicts) + + @property + def num_atoms_added(self) -> list: + """Calculate the number of heavy atoms added w.r.t the scaffold(s) + + Delegates to :attr:`.Compound.num_atoms_added` per member, so each entry + is an ``int`` (single scaffold), a list of ints (multiple scaffolds), or + ``None`` (no scaffold), aligned with this set's compounds. + """ + return [Compound(c).num_atoms_added for c in self._queryset] + + @property + def avg_num_atoms_added(self) -> float: + """Calculate the average number of heavy atoms added w.r.t the scaffold + + :returns: mean number of atoms added across members which have a scaffold + (``0.0`` if none do) + """ + values: list[int] = [] + for v in self.num_atoms_added: + if v is None: + continue + if isinstance(v, list): + values.extend(v) + else: + values.append(v) + return mean(values) if values else 0.0 + + @property + def risk_diversity(self) -> float: + """Calculate the average spread of risk (#atoms added) for each scaffold in + this set + + :returns: average of the standard deviations of number of atoms added for each + scaffold + + """ + + return self.get_risk_diversity() + + @property + def elaboration_balance(self) -> float: + """Measure of how evenly elaborations are distributed across scaffolds in + this set""" + + # number of elaborations (in this set) per scaffold base compound + counts_by_base: dict[int, int] = {} + for base_id in ScaffoldModel.objects.filter( + superstructure_compound_id__in=self.ids + ).values_list('base_compound_id', flat=True): + counts_by_base[base_id] = counts_by_base.get(base_id, 0) + 1 + + # optional dependency, isolated so module import never depends on it + from hirsch import hirsch + + return hirsch(list(counts_by_base.values())) + + @property + def num_scaffolds_elaborated(self) -> int: + """Count the number of scaffold compounds that have at least one elaboration in + this set + + :returns: number of scaffold compounds + + """ + return len(self.scaffold_ids) + + @property + def scaffolds(self) -> 'CompoundSet': + """Get the scaffold compounds that have at least one elaboration in this set + + :returns: :class:`.CompoundSet` + + """ + return CompoundSet(self.scaffold_ids) + + @property + def scaffold_ids(self) -> list[int]: + """Return a list of :class:`.CompoundModel` ID's for scaffolds of this set""" + return list( + ScaffoldModel.objects.filter(superstructure_compound_id__in=self.ids) + .values_list('base_compound_id', flat=True) + .distinct() + ) + + @property + def num_scaffolds(self) -> int: + """Return a count of scaffolds of this set""" + return len(self.scaffold_ids) + + @property + def elabs(self) -> 'CompoundSet': + """Returns a :class:`.CompoundSet` of all compounds that are an elaboration + of a scaffold in this set""" + + ids = list( + ScaffoldModel.objects.filter( + base_compound_id__in=self.ids, + superstructure_compound_id__isnull=False, + ) + .values_list('superstructure_compound_id', flat=True) + .distinct() + ) + return CompoundSet(ids) + + @property + def num_elabs(self) -> int: + """Return a count of elaborations of this set""" + return len(self.elabs) + + @property + def elab_df(self) -> 'pd.DataFrame': + """Get a DataFrame summarising the elaborations in this CompoundSet. + + Groups this set's compounds by the scaffold(s) they elaborate; a compound + with multiple scaffolds is listed under each. + """ + # base scaffold compound -> elaboration compound ids within this set + clusters: dict[int, set] = {} + for base_id, sup_id in ScaffoldModel.objects.filter( + superstructure_compound_id__in=self.ids + ).values_list('base_compound_id', 'superstructure_compound_id'): + clusters.setdefault(base_id, set()).add(sup_id) + + data = [] + for base_id, elab_ids in clusters.items(): + elabs = CompoundSet(list(elab_ids)) + data.append( + dict( + scaffold_id=base_id, + scaffold_compound=Compound(CompoundModel.objects.get(pk=base_id)), + elabs=elabs, + num_elabs=len(elabs), + ) + ) + + return DataFrame(data) + + @property + def id_num_poses_dict(self) -> dict[int, int]: + """Get a dictionary mapping compound ids to the number of poses""" + + counts = dict( + PoseModel.objects.filter(compound_id__in=self.ids) + .values_list('compound_id') + .annotate(n=Count('id')) + ) + + return {cid: counts.get(cid, 0) for cid in self.ids} + + @property + def reaction_ids(self) -> list[int]: + """Returns a list of :class:`.ReactionModel` IDs that result in members of + this set""" + return list( + ReactionModel.objects.filter(product_compound_id__in=self.ids) + .values_list('id', flat=True) + .distinct() + ) diff --git a/hippo/designdb/sets/ingredient.py b/hippo/designdb/sets/ingredient.py new file mode 100644 index 00000000..91d4bb94 --- /dev/null +++ b/hippo/designdb/sets/ingredient.py @@ -0,0 +1,659 @@ +"""IngredientSet: a set of :class:`.Ingredient`\\ s (compounds with amounts/quotes). + +Builds on :class:`.CompoundSet` (the dependency runs ``ingredient -> compound``; +``CompoundSet``'s few uses of ``IngredientSet`` are deferred local imports). +""" + +import json + +import mcol +import mrich +from designdb.components.compound import Ingredient +from designdb.components.price import Price +from designdb.models import CataloguePriceModel, CompoundModel +from designdb.sets.compound import CompoundSet +from pandas import DataFrame, concat, isna + + +class IngredientSet: + """An :class:`.Ingredient` is a :class:`.CompoundModel` with a fixed quanitity and + an attached quote, the :class:`.IngredientSet` is a object representing multiple + ingredients. + + .. attention:: + + :class:`.IngredientSet` objects should not be created directly. Instead they + are returned by several methods when working with :doc:`quoting` and + :doc:`rgen`. + + Selecting ingredients in the set + ================================ + + The :class:`.IngredientSet` can be indexed like a Python list: + + :: + + ingredient = ingredient_set[0] # first ingredient + + To get the ingredient for a specific :class:`.CompoundModel` ID: + + :: + + ingredient = ingredient_set(compound_id=13) + + """ + + _columns = [ + 'compound_id', + 'amount', + 'quote_id', + 'supplier', + 'max_lead_time', + 'quoted_amount', + ] + + def __init__( + self, + ingredients: 'None | list[Ingredient]' = None, + supplier: str | list | None = None, + debug: bool = False, + ) -> None: + """IngredientSet initialisation""" + + ingredients = ingredients or [] + + self._data = DataFrame(columns=self._columns, dtype=object) + + if debug: + mrich.debug(self._data) + + self._supplier = supplier + + for ingredient in ingredients: + self.add(ingredient) + + for col in self._columns: + assert col in self._data.columns, f'{col} not in df.columns' + + if debug: + mrich.debug(self._data) + + ### DUNDERS + + def __len__(self): + """The number of ingredients in this set""" + return len(self._data) + + def __str__(self) -> str: + """Unformatted string representation""" + return f'{{Ingredient × {len(self)}}}' + + def __repr__(self) -> str: + """ANSI ormatted string representation""" + return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' + + def __rich__(self) -> str: + """Representation for mrich""" + return f'[bold underline]{self}' + + def __add__(self, other): + """Add another :class:`.IngredientSet` this set""" + + for _, row in other._data.iterrows(): + self.add( + compound_id=row.compound_id, + amount=row.amount, + quote_id=row.quote_id, + supplier=row.supplier, + max_lead_time=row.max_lead_time, + quoted_amount=row.quoted_amount, + ) + + return self + + def __getitem__(self, key: int) -> 'Ingredient': + """Get a member by it's index""" + match key: + case int(): + series = self.df.loc[key] + return self._get_ingredient(series) + + case _: + raise NotImplementedError + + def __iter__(self): + """Iterate through the ingredients""" + return iter(self._get_ingredient(s) for i, s in self.df.iterrows()) + + def __call__( + self, + *, + compound_id: int | None = None, + tag: str | None = None, + ) -> 'IngredientSet | Ingredient | CompoundSet': + """Get members based on a compound_id or tag""" + + if compound_id: + # get the ingredient with the matching compound ID + matches = self.df[self.df['compound_id'] == compound_id] + + if len(matches) == 0: + return None + + elif len(matches) != 1: + mrich.warning(f'Multiple ingredients in set with {compound_id=}') + # print(matches) + + return IngredientSet( + self.db, [self._get_ingredient(s) for i, s in matches.iterrows()] + ) + + return self._get_ingredient(matches.iloc[0]) + + # elif tag: + # return self.compounds(tag=tag) + + else: + raise NotImplementedError + + def __getattr__(self, key: str): + """For missing attributes try getting from associated :class:`.CompoundSet`""" + return getattr(self.compounds, key) + + def __contains__(self, other: CompoundModel | Ingredient | int): + """Check if compound or ingredient is a member of this set""" + match other: + case CompoundModel(): + id = other.id + case Ingredient(): + id = other.compound_id + case int(): + id = other + + return id in set(self.compound_ids) + + @classmethod + def from_ingredient_df( + cls, + df: 'DataFrame', + supplier: str | list | None = None, + ) -> 'IngredientSet': + """Create an :class:`.IngredientSet` from a DataFrame + + :param db: HIPPO Database + :param df: DataFrame of Ingredients + :param supplier: supplier to use for all quoting, (Default value = None) + + """ + # from numpy import nan + self = cls.__new__(cls) + + for col in cls._columns: + if col not in df.columns: + raise Exception(f'{col} not in df.columns') + df[col] = None + + self._data = df.copy() + self._supplier = supplier + + return self + + @classmethod + def from_json( + cls, + path: None | str, + supplier: str | list | None = None, + data: None | dict = None, + ) -> 'IngredientSet': + """Create an :class:`.IngredientSet` from JSON data or a JSON file + + :param db: HIPPO Database + :param path: path to JSON data (can be ``None`` if ``data`` provided) + :param supplier: supplier to use for all quoting, (Default value = ``None``) + :param data: optional JSON data to parse, (Default value = ``None``) + + """ + + if not data: + data = json.load(open(path)) + + df = DataFrame(columns=cls._columns, dtype=object) + + for col in cls._columns: + df[col] = data[col] + + return cls.from_ingredient_df(df=df, supplier=supplier) + + @classmethod + def from_ingredient_dicts( + cls, + dicts: list[dict], + supplier: str | list | None = None, + ) -> 'IngredientSet': + """Create an :class:`.IngredientSet` from :class:`.Ingredient` dictionaries + + :param db: HIPPO Database + :param dicts: List of individual ingredient dictionaries + :param supplier: supplier to use for all quoting, (Default value = ``None``) + + """ + + df = DataFrame(dicts, dtype=object) + return cls.from_ingredient_df(df=df, supplier=supplier) + + @classmethod + def from_compounds( + cls, + *, + compounds: 'CompoundSet | None' = None, + ids: list[int] | None = None, + amount: float | list[float] = 1, + supplier: str | list | None = None, + ) -> 'IngredientSet': + """Create an :class:`.IngredientSet` from a :class:`.CompoundSet` or IDs + + :param compounds: :class:`.CompoundSet` to use, if ``None`` must provide + ``ids`` and ``db`` (Default value = None) + :param ids: CompoundModel IDs (Default value = None) + :param db: HIPPO Database (Default value = None) + :param amount: Amount(s) in ``mg`` (Default value = 1) + :param supplier: supplier to use for all quoting, (Default value = ``None``) + + """ + + if not ids: + ids = compounds.ids + + df = DataFrame( + dict( + compound_id=ids, + amount=amount, + quote_id=None, + supplier=supplier, + max_lead_time=None, + quoted_amount=None, + ), + dtype=object, + ) + + return cls.from_ingredient_df(df) + + ### METHODS + + def get_price( + self, supplier: str | list[str] = None, none: str = 'error', debug: bool = False + ) -> 'Price': + """Calculate the price with a given supplier + + :param supplier: supplier to use for all quoting, (Default value = ``None``) + + """ + + pairs = {i: q for i, q in enumerate(self.df['quote_id'])} + + # coerce to int: df values may be stored as float/object (pandas) + quote_ids = [int(q) for q in pairs.values() if q is not None and not isna(q)] + + if debug: + mrich.debug('quote_ids', quote_ids) + + if quote_ids: + qs = CataloguePriceModel.objects.filter(pk__in=set(quote_ids)) + + if supplier: + qs = qs.filter(supplier=supplier) + + if qs.exists(): + # map pk -> Price, then sum over quote_ids so that ingredients + # sharing the same catalogue row are counted with multiplicity + # (filter(pk__in=...) collapses duplicates to one row each) + price_by_id = { + k.pk: Price(amount=k.price, currency=k.currency) for k in qs + } + quoted = Price.null() + for q in quote_ids: + price = price_by_id.get(q) + if price is not None: + quoted += price + else: + quoted = Price.null() + self.df['quote_id'] = None + pairs = {i: q for i, q in enumerate(self.df['quote_id'])} + + else: + quoted = Price.null() + + if debug: + mrich.debug('quoted', quoted) + + unquoted = [i for i, q in pairs.items() if q is None or isna(q)] + + unquoted_price = Price.null() + + for i in unquoted: + ingredient = self[i] + + if debug: + mrich.debug('unquoted', i, ingredient) + + p = ingredient.price + + unquoted_price += p + + if debug: + mrich.debug(unquoted_price) + + quote = ingredient.quote + + if not quote: + mrich.warning('NULL Quote:', ingredient) + continue + + self.df.loc[i, 'quote_id'] = quote.id + + assert quote.amount + + self.df.loc[i, 'quoted_amount'] = quote.amount + + if debug: + mrich.debug('quoted', quoted) + mrich.debug('unquoted_price', unquoted_price) + mrich.error('end of IngredientSet.get_price()') + + return quoted + unquoted_price + + def interactive(self, **kwargs) -> None: + """Wrapper for :meth:`.CompoundSet.interactive`""" + self.compounds.interactive(**kwargs) + + def add( + self, + ingredient: 'Ingredient | None' = None, + *, + compound_id: int | None = None, + amount: float | None = None, + quote_id: int | None = None, + supplier: str | list[str] | None = None, + max_lead_time: float | None = None, + quoted_amount: float | None = None, + debug: bool = False, + ) -> None: + """Add an :class:`.Ingredient` to this set + + :param ingredient: :class:`.Ingredient` to be added, if ``None`` must specify + other parameters, (Default value = None) + :param compound_id: :class:`.CompoundModel` ID (Default value = None) + :param amount: amount in ``mg`` (Default value = None) + :param quote_id: :class:`.Quote` ID (Default value = None) + :param supplier: supplier name string or list (Default value = None) + :param max_lead_time: maximum lead-time for quoting (in days) + (Default value = None) + :param quoted_amount: amount of associated :class:`.Quote` + (Default value = None) + :param debug: increase verbosity for debugging (Default value = False) + + """ + + if ingredient: + compound_id = ingredient.compound.pk + amount = ingredient.amount + + q = ingredient.quote + + supplier = ingredient.supplier + max_lead_time = ingredient.max_lead_time + + if q is None: + quote_id = None + quoted_amount = None + else: + quote_id = q.id + quoted_amount = q.amount + + else: + assert compound_id + assert amount + + if quote_id: + # if not quoted_amount: + # mrich.warning(f'Requoting C{compound_id}...') + + assert quoted_amount + + supplier = self.supplier + + if self._data.empty: + addition = DataFrame( + [ + dict( + compound_id=compound_id, + amount=amount, + quote_id=quote_id, + supplier=supplier, + max_lead_time=max_lead_time, + quoted_amount=quoted_amount, + ) + ], + dtype=object, + ) + self._data = addition + + else: + if compound_id in self._data['compound_id'].values: + index = self._data.index[ + self._data['compound_id'] == compound_id + ].tolist()[0] + self._data.loc[index, 'amount'] += amount + + # discard if the quote is no longer valid + if (a := self.df.loc[index, 'quoted_amount']) and a < self.df.loc[ + index, 'amount' + ]: + self._data.loc[index, 'quote_id'] = None + self._data.loc[index, 'quoted_amount'] = None + + if debug and supplier: + mrich.debug('Adding to existing ingredient') + mrich.debug(f'{self._data.loc[index, "supplier"]=}') + mrich.debug(f'{supplier=}') + + else: + # from numpy import nan + addition = DataFrame( + [ + dict( + compound_id=compound_id, + amount=amount, + quote_id=quote_id, + supplier=supplier, + max_lead_time=max_lead_time, + quoted_amount=quoted_amount, + ) + ], + dtype=object, + ) + + self._data = concat( + [self._data, addition], ignore_index=True, join='inner' + ) + + if debug: + mrich.out(addition) + + def _get_ingredient( + self, + series, + ) -> 'Ingredient': + """Get ingredient from one of the DataFrame rows""" + + q_id = series['quote_id'] + + if isinstance(q_id, float) and isna(q_id): + q_id = None + + return Ingredient( + compound=CompoundModel.objects.get(pk=series['compound_id']), + amount=series['amount'], + quote=q_id, + supplier=series['supplier'], + max_lead_time=series['max_lead_time'], + ) + + def copy(self) -> 'IngredientSet': + """Return a copy of this :class:`.IngredientSet`""" + return IngredientSet.from_ingredient_df(self.df, supplier=self.supplier) + + def draw(self) -> None: + """Wrapper for :meth:`.CompoundSet.draw`""" + self.compounds.draw() + + def set_amounts( + self, + amount: float | list[float], + ) -> None: + """Set the amount(s) for all ingredients in this set, and update quotes + + :param amount: amount in ``mg`` + + """ + + self.df['amount'] = amount + + # if amounts are modified the quotes should be cleared + self.df['quote_id'] = None + + assert all(self.df['supplier'].isna()) and all(self.df['max_lead_time'].isna()) + + # # update quotes + # pairs = self.db.execute( + # f""" + # WITH matching_quotes AS ( + # SELECT quote_id, quote_compound, MIN(quote_price) + # FROM {self.db.SQL_SCHEMA_PREFIX}quote + # WHERE quote_compound IN {self.str_compound_ids} + # AND quote_amount >= {amount} + # GROUP BY quote_compound + # ) + # SELECT compound_id, quote_id FROM {self.db.SQL_SCHEMA_PREFIX}compound + # LEFT JOIN matching_quotes ON quote_compound = compound_id + # WHERE compound_id IN {self.str_compound_ids} + # """ + # ).fetchall() + + qs = CataloguePriceModel.objects.filter( + compound__pk__in=self.compound_ids, + quote_amount__gte=amount, + ) + + for k in qs: + match = self.df.index[self.df['compound_id'] == k.compound.pk][0] + self.df.loc[match, 'quote_id'] = k.quote.pk + + def get_dict(self, data_orient: str = 'list') -> dict: + """Get serialisable dictionary + + :param data_orient: passed to ``pandas.DataFrame.to_dict`` + (Default value = 'list') + + """ + return dict( + supplier=self.supplier, + data=self.df.to_dict(orient=data_orient), + ) + + def pop(self) -> Ingredient: + """Pop the last compound in this set""" + item = self[self.df.index[-1]] + self.df.drop(self.df.index[-1], inplace=True) + return item + + def shuffle(self) -> None: + """Randomises the order of compounds in this set""" + self._data = self.df.sample(frac=1).reset_index(drop=True) + + ### PROPERTIES + + @property + def df(self) -> 'DataFrame': + """Access the raw DataFrame""" + return self._data + + @property + def price_df(self) -> 'DataFrame': + """DataFrame including prices""" + df = self.df.copy() + tuples = [(i.price, i.lead_time, i.quote.supplier) for i in self] + df['price'] = [t[0] for t in tuples] + df['lead_time'] = [t[1] for t in tuples] + df['quote_supplier'] = [t[2] for t in tuples] + return df + + @property + def price(self) -> 'Price': + """Total price of these ingredients""" + return self.get_price() + + @property + def supplier(self) -> str | list[str]: + """Supplier(s)""" + return self._supplier + + @supplier.setter + def supplier(self, s): + if isinstance(s, list) or isinstance(s, tuple): + for x in s: + assert isinstance(x, str) + else: + assert isinstance(s, str) + + self._supplier = s + self.df['supplier'] = [s] * len(self) + + @property + def smiles(self) -> list[str]: + """SMILES for all ingredients""" + compound_ids = list(self.df['compound_id']) + return CompoundModel.objects.filter( + pk__in=compound_ids, + ).values_list('compound_smiles', flat=True) + + @property + def inchikeys(self) -> list[str]: + """InChI-keys for all ingredients""" + compound_ids = list(self.df['compound_id']) + return CompoundModel.objects.filter( + pk__in=compound_ids, + ).values_list('compound_inchikeys', flat=True) + + @property + def compound_ids(self) -> list[int]: + """CompoundModel IDs for all ingredients""" + return list(self.df['compound_id'].values) + + @property + def ids(self) -> list[int]: + """CompoundModel IDs for all ingredients""" + return self.compound_ids + + @property + def id_amount_pairs(self) -> list[tuple]: + """Get a list of compound ID and amount pairs""" + return [ + (id, amount) for id, amount in self.df[['compound_id', 'amount']].values + ] + + @property + def str_compound_ids(self) -> str: + """Return an SQL formatted tuple string of the :class:`.CompoundModel` IDs""" + return str(tuple(self.df['compound_id'].values)).replace(',)', ')') + + @property + def compounds(self) -> 'CompoundSet': + """:class:`.CompoundSet` of all compounds in this set""" + return CompoundSet(self.compound_ids) + + @property + def quote_ids(self) -> list[int]: + """Get a list of quote ID's""" + + return [q for q in self.df['quote_id'].values if not isna(q) and q is not None] diff --git a/hippo/designdb/sets/interaction.py b/hippo/designdb/sets/interaction.py new file mode 100644 index 00000000..c15f322f --- /dev/null +++ b/hippo/designdb/sets/interaction.py @@ -0,0 +1,300 @@ +"""Sets of protein-ligand interactions (ORM-backed). + +An :class:`.InteractionSet` wraps a set of :class:`.InteractionModel` rows (via a +Django queryset). Construct it through :meth:`.PoseModel.interactions` / +:meth:`.PoseSet.interactions` or the factories here. + +Interaction *detection* and duplicate *resolution* live in +:class:`.InteractionService` (``services/interaction.py``); this module is just the +read/aggregate surface over already-stored interactions. +""" + +from typing import TYPE_CHECKING + +import mcol +import mrich +from designdb.models import InteractionModel +from django.db.models import Count + +if TYPE_CHECKING: + import pandas + from designdb.models import PoseModel, TargetModel + from designdb.sets.pose import PoseSet + +# `df` columns: ORM field (model / joined feature) -> output column name +_DF_COLUMNS = { + 'id': 'id', + 'feature_id': 'feature_id', + 'pose_id': 'pose_id', + 'feature__target_id': 'target_id', + 'interaction_type': 'type', + 'feature__feature_family': 'prot_family', + 'interaction_family': 'lig_family', + 'feature__feature_residue_name': 'residue_name', + 'feature__feature_residue_number': 'residue_number', + 'feature__feature_chain_name': 'chain_name', + 'interaction_distance': 'distance', + 'interaction_angle': 'angle', + 'interaction_energy': 'energy', + 'interaction_prot_coord': 'prot_coord', + 'interaction_lig_coord': 'lig_coord', + 'feature__feature_atom_name': 'prot_atoms', + 'interaction_atom_id': 'lig_atoms', +} + + +class InteractionSet: + """A set of :class:`.InteractionModel` rows. + + .. attention:: + + Not constructed directly -- use :meth:`.PoseModel.interactions` / + :meth:`.PoseSet.interactions`, or the factory classmethods here. + """ + + def __init__(self, indices: list | None = None) -> None: + """InteractionSet initialisation from a list of + :class:`.InteractionModel` IDs""" + indices = indices or [] + if not isinstance(indices, list): + indices = list(indices) + self._indices = sorted({int(i) for i in indices}) + self._df = None + self._qs = InteractionModel.objects.filter(pk__in=self._indices) + + ### FACTORIES + + @classmethod + def from_pose(cls, pose: 'PoseModel | PoseSet') -> 'InteractionSet': + """Construct from one or more poses. + + :param pose: a :class:`.PoseSet` (has ``.ids``) or a single + :class:`.Pose`/:class:`.PoseModel` (has ``.id``) + """ + if hasattr(pose, 'ids'): + qs = InteractionModel.objects.filter(pose_id__in=list(pose.ids)) + else: + qs = InteractionModel.objects.filter(pose_id=pose.id) + return cls(list(qs.values_list('id', flat=True))) + + @classmethod + def all(cls) -> 'InteractionSet': + """Construct an :class:`.InteractionSet` for every interaction in the table.""" + return cls(list(InteractionModel.objects.values_list('pk', flat=True))) + + @classmethod + def from_residue( + cls, + residue_number: int, + chain: str | None = None, + target: 'TargetModel | int' = 1, + ) -> 'InteractionSet': + """Interactions formed with a given protein residue (and optionally chain). + + :param residue_number: the residue number + :param chain: the chain name, or ``None`` for any chain + :param target: the :class:`.TargetModel` or its ID (defaults to ``1``) + """ + from designdb.models import TargetModel + + if isinstance(target, TargetModel): + target = target.id + + qs = InteractionModel.objects.filter( + feature__target_id=target, + feature__feature_residue_number=residue_number, + ) + if chain: + qs = qs.filter(feature__feature_chain_name=chain) + + return cls(list(qs.values_list('id', flat=True))) + + ### PROPERTIES + + @property + def queryset(self): + """The underlying :class:`.InteractionModel` queryset""" + return self._qs + + @property + def indices(self) -> list[int]: + """:class:`.InteractionModel` IDs in this set""" + return self._indices + + @property + def ids(self) -> list[int]: + """:class:`.InteractionModel` IDs in this set""" + return self._indices + + @property + def types(self) -> list[str]: + """Distinct interaction types in this set""" + return list(self._qs.values_list('interaction_type', flat=True).distinct()) + + @property + def feature_ids(self) -> list[int]: + """Distinct :class:`.FeatureModel` IDs interacted with""" + return list(self._qs.values_list('feature_id', flat=True).distinct()) + + @property + def df(self) -> 'pandas.DataFrame': + """DataFrame of the interactions, one row each.""" + if self._df is None: + from pandas import DataFrame + + rows = list(self._qs.values(*_DF_COLUMNS)) + df = DataFrame(rows) + if not df.empty: + df = df.rename(columns=_DF_COLUMNS) + self._df = df + return self._df + + @property + def _feature_counts(self) -> dict[int, int]: + """Map of :class:`.FeatureModel` ID -> number of interactions with it""" + return { + row['feature']: row['n'] + for row in self._qs.values('feature').annotate(n=Count('id')) + } + + @property + def classic_fingerprint(self) -> dict: + """Classic HIPPO fingerprint: :class:`.FeatureModel` ID -> interaction count.""" + return self.get_classic_fingerprint() + + @property + def residue_number_chain_pairs(self) -> list[tuple]: + """Distinct ``(residue_number, chain_name)`` pairs""" + return list( + self._qs.values_list( + 'feature__feature_residue_number', 'feature__feature_chain_name' + ).distinct() + ) + + @property + def type_residue_number_chain_triples(self) -> list[tuple]: + """Distinct ``(interaction_type, residue_number, chain_name)`` triples""" + return list( + self._qs.values_list( + 'interaction_type', + 'feature__feature_residue_number', + 'feature__feature_chain_name', + ).distinct() + ) + + @property + def avg_num_residues_per_pose(self) -> float: + """Mean number of distinct ``(residue, chain)`` contacts per pose""" + from collections import defaultdict + + from numpy import mean + + d: dict[int, set] = defaultdict(set) + for pose_id, res_num, chain in self._qs.values_list( + 'pose_id', + 'feature__feature_residue_number', + 'feature__feature_chain_name', + ): + d[pose_id].add((res_num, chain)) + return mean([len(v) for v in d.values()]) if d else 0 + + @property + def avg_num_interactions_per_pose(self) -> float: + """Mean number of interactions per pose""" + from collections import defaultdict + + from numpy import mean + + d: dict[int, int] = defaultdict(int) + for (pose_id,) in self._qs.values_list('pose_id'): + d[pose_id] += 1 + return mean(list(d.values())) if d else 0 + + @property + def avg_num_interaction_type_residue_pairs_per_pose(self) -> float: + """Mean number of distinct ``(residue, type, chain)`` contacts per pose""" + from collections import defaultdict + + from numpy import mean + + d: dict[int, set] = defaultdict(set) + for pose_id, itype, res_num, chain in self._qs.values_list( + 'pose_id', + 'interaction_type', + 'feature__feature_residue_number', + 'feature__feature_chain_name', + ): + d[pose_id].add((res_num, itype, chain)) + return mean([len(v) for v in d.values()]) if d else 0 + + @property + def num_features(self) -> int: + """Number of distinct protein :class:`.FeatureModel`\\ s interacted with""" + return self._qs.values('feature').distinct().count() + + @property + def avg_num_interactions_per_feature(self) -> float: + """Mean number of interactions formed with each protein feature""" + from numpy import mean + + counts = list(self._feature_counts.values()) + return mean(counts) if counts else 0 + + @property + def per_feature_count_hirsch(self) -> float: + """h-index-like measure of how evenly features are interacted with""" + from hirsch import hirsch + + counts = list(self._feature_counts.values()) + return hirsch(counts) if counts else 0 + + ### METHODS + + def get_classic_fingerprint(self) -> dict: + """Classic HIPPO fingerprint: :class:`.FeatureModel` ID -> interaction count.""" + return dict(self._feature_counts) + + def summary(self, families: bool = False) -> None: + """Print a summary of this :class:`.InteractionSet`""" + mrich.header(self) + for i in self._qs.select_related('feature'): + feature = i.feature + s = ( + f'{i.interaction_type} ' + f'{feature.feature_residue_name}{feature.feature_residue_number}' + ) + if families: + s += f' {feature.feature_family} ~ {i.interaction_family}' + mrich.var(s, f'{i.interaction_distance:.1f}', 'Å') + + ### DUNDERS + + def __len__(self) -> int: + """The number of interactions in this set""" + return len(self._indices) + + def __str__(self) -> str: + """Unformatted command-line representation""" + return f'{{I × {len(self)}}}' + + def __repr__(self) -> str: + """ANSI formatted command-line representation""" + return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' + + def __rich__(self) -> str: + """Rich formatted command-line representation""" + return f'[bold underline]{self}' + + def __iter__(self): + """Iterate through the :class:`.InteractionModel` rows in this set""" + return iter(self._qs) + + def __getitem__(self, key) -> 'InteractionModel | InteractionSet': + """Index by position (int) or slice""" + match key: + case int(): + return InteractionModel.objects.get(pk=self._indices[key]) + case slice(): + return InteractionSet(self._indices[key]) + case _: + raise NotImplementedError diff --git a/hippo/designdb/sets/pose.py b/hippo/designdb/sets/pose.py new file mode 100644 index 00000000..e1e21542 --- /dev/null +++ b/hippo/designdb/sets/pose.py @@ -0,0 +1,2353 @@ +import inspect +import json +import logging +import re +import shutil +from collections.abc import Callable +from itertools import combinations +from os.path import relpath +from pathlib import Path +from pprint import pprint +from zipfile import ZipFile + +import community as louvain +import mcol +import molparse as mp +import mrich +import networkx as nx +import pandas as pd +from designdb.models import ( + CompoundModel, + InspirationModel, + InteractionModel, + PoseModel, + PoseTagJunctionModel, + PoseTagModel, + ScoreValueModel, + SubsiteModel, + SubsiteTagModel, + TargetModel, +) +from designdb.sets.interaction import InteractionSet +from designdb.settings import DEFAULT_POSE_METHODS +from designdb.utils import ScoreSubquery, normalize_string_list +from designdb.utils_frag import generate_header +from django.conf import settings +from django.db import IntegrityError, transaction +from django.db.models import ( + Count, + Exists, + FloatField, + OuterRef, + Q, + QuerySet, + Subquery, +) +from django.db.models.fields.json import KeyTextTransform +from django.db.models.functions import Cast +from IPython.display import display +from ipywidgets import ( + BoundedIntText, + Checkbox, + GridBox, + Layout, + VBox, + interactive, + interactive_output, +) +from molparse.rdkit import draw_grid, draw_mols +from pandas import DataFrame + +# from mypackage.services.compound import CompoundService +from rdkit import Chem + +# from rdkit.Chem import inchi +from rdkit.Chem import PandasTools, SDWriter + +if settings.MANAGE_MODELS: + from designdb.utils import JsonGroupArray as ArrayAgg +else: + from django.contrib.postgres.aggregates import ArrayAgg + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import pandas + from designdb.components.pose import Pose + from designdb.sets.compound import CompoundSet + + +# from .validation.compound import ValidationError, validate_compound_data + +SDF_XCAv2_PATTERN = re.compile( + r'^.*-.\d{4}_._\d*_\d_.*-.\d{4}\+.\+\d*\+\d_ligand\.sdf$' +) +SDF_XCAV3_PATTERN = re.compile( + r'^.*-.\d{4}_._\d*_._\d_.*-.\d{4}\+.\+\d*\+.\+\d_ligand\.sdf$' +) + + +SDF_FRAGALYSIS_PATTERN = re.compile(r'^.*\d{4}[a-z].sdf$') +PDBID_PATTERN = re.compile(r'^[A-Za-z0-9]{4}-[a-z].sdf$') + + +logger = logging.getLogger(__name__) + + +class PoseSet: + """Object representing a subset of the 'pose' table in the :class:`.Database`. + + .. attention:: + + :class:`.PoseSet` objects should not be created directly. Instead use the + :meth:`.HIPPO.poses` property. See :doc:`getting_started` and + :doc:`insert_elaborations`. + + Use as an iterable + ================== + + Iterate through :class:`.PoseModel` objects in the set: + + :: + + pset = animal.poses[:100] + + for pose in pset: + ... + + Check membership + ================ + + To determine if a :class:`.PoseModel` is present in the set: + + :: + + is_member = pose in cset + + Selecting compounds in the set + ============================== + + The :class:`.PoseSet` can be indexed like standard Python lists by their indices + + :: + + pset = animal.poses[1:100] + + # indexing individual compounds + pose = pset[0] # get the first pose + pose = pset[1] # get the second pose + pose = pset[-1] # get the last pose + + # getting a subset of compounds using a slice + pset2 = pset[13:18] # using a slice + + """ + + def __init__( + self, + queryset=None, + *, + sort: bool = True, + name: str | None = None, + ) -> None: + """PoseSet initialisation""" + + # let's have a queryset no matter what. + if queryset: + self._queryset = queryset + else: + self._queryset = PoseModel.objects.none() + + self._name = name + if sort: + self._queryset = self._queryset.order_by('pk') + + self._interactions = None + self._metadata_dict = None + + ### DUNDERS + + def __str__(self): + """Unformatted string representation""" + if self.name: + s = f'{self._name}: ' + else: + s = '' + + s += f'{{P × {len(self)}}}' + + return s + + def __repr__(self) -> str: + """ANSI Formatted string representation""" + return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' + + def __rich__(self) -> str: + """Rich Formatted string representation""" + return f'[bold underline]{self}' + + def __len__(self) -> int: + """The number of poses in this set""" + return self._queryset.count() + + def __iter__(self): + """Iterate through poses in this set as :class:`.Pose` components""" + from designdb.components.pose import Pose + + return (Pose(p) for p in self._queryset) + + def __getitem__( + self, + key: int | slice, + ) -> 'Pose | PoseSet': + """Get poses or subsets thereof from this set + + :param key: integer index or slice of indices + + """ + from designdb.components.pose import Pose + + match key: + case int(): + try: + pose = PoseModel.objects.get(pk=key) + except PoseModel.DoesNotExist as exc: + mrich.error(f'list index out of range: {key=} for {self}') + raise PoseModel.DoesNotExist from exc + + return Pose(pose) + + case slice(): + return PoseSet(PoseModel.objects.filter(pk__in=key)) + + case _: + raise NotImplementedError + + def __add__( + self, + other: 'PoseSet', + ) -> 'PoseSet': + """Add a :class:`.PoseSet` to this set""" + if isinstance(other, PoseSet): + return PoseSet( + PoseModel.objects.filter( + Q(pk__in=self._queryset) | Q(pk__in=other.queryset) + ), + sort=False, + ) + elif isinstance(other, PoseModel): + return PoseSet( + PoseModel.objects.filter(Q(pk__in=self._queryset) | Q(pk=other.pk)), + sort=False, + ) + else: + raise NotImplementedError + + def __sub__( + self, + other: 'PoseSet', + ) -> 'PoseSet': + """Substract a :class:`.PoseSet` from this set""" + match other: + case PoseSet(): + return PoseSet( + PoseModel.objects.filter( + Q(pk__in=self._queryset) & ~Q(pk__in=other.queryset) + ), + sort=False, + ) + case int(): + return PoseSet( + PoseModel.objects.filter( + Q(pk__in=self._queryset) & ~Q(pk=other.pk) + ), + sort=False, + ) + + def __and__(self, other: 'PoseSet'): + """AND set operation, returns only poses in both sets""" + + match other: + case PoseSet(): + return PoseSet( + PoseModel.objects.filter( + Q(pk__in=self._queryset) & Q(pk__in=other.queryset) + ), + sort=False, + ) + + case _: + raise NotImplementedError + + def __or__(self, other: 'PoseSet'): + """OR set operation, returns union of both sets""" + + match other: + case PoseSet(): + return PoseSet( + PoseModel.objects.filter( + Q(pk__in=self._queryset) | Q(pk__in=other.queryset) + ), + sort=False, + ) + + case _: + raise NotImplementedError + + def __xor__(self, other: 'PoseSet'): + """Exclusive OR set operation, returns all poses in either set but not both""" + + match other: + case PoseSet(): + return PoseSet( + PoseModel.objects.filter( + Q(Q(pk__in=self._queryset) | Q(pk__in=other.queryset)) + & ~Q(Q(pk__in=self._queryset) & Q(pk__in=other.queryset)) + ), + sort=False, + ) + + case _: + raise NotImplementedError + + def __call__( + self, + *, + tag: str = None, + pose_method: str = None, + target: int = None, + subsite: int = None, + ) -> 'PoseSet': + """Filter poses by a given tag, pose method name, SubsiteModel ID, or + target ID. See + :meth:`.PoseSet.get_by_tag`, :meth:`.PoseSet.get_by_method`, + :meth:`.PoseSet.get_by_target`, and :meth:`.PoseSet.get_by_subsite`""" + + if tag: + return self.get_by_tag(tag) + elif pose_method: + return self.get_by_method(pose_method) + elif target: + return self.get_by_target(target=TargetModel.objects.get(pk=target)) + elif subsite: + return self.get_by_subsite(subsite=SubsiteModel.objects.get(pk=subsite)) + else: + raise NotImplementedError + + @classmethod + def get_by_references(cls, poseset: 'PoseSet') -> 'PoseSet': + return PoseSet( + PoseModel.objects.filter(pk__in=poseset._queryset.values('pose_reference')) + ) + + # there's a method get_by_inspiration + @classmethod + def get_by_inspirations(cls, poseset: 'PoseSet') -> 'PoseSet': + return PoseSet( + PoseModel.objects.filter( + pk__in=InspirationModel.objects.filter( + derivative_pose__in=poseset._queryset, + ).values( + 'original_pose', + ), + ), + ) + + ### FILTERING + + def get_by_tag( + self, + tag: str, + inverse: bool = False, + ) -> 'PoseSet': + """Get all child poses with a certain tag + + :param tag: tag to filter by + :param inverse: return all poses *not* tagged with ``tag`` + (Default value = False) + + """ + self._queryset = self._queryset.annotate( + has_tag=Exists( + PoseTagJunctionModel.objects.filter( + pose=OuterRef('pk'), + pose_tag__pose_tag_name=tag, + ), + ), + ) + if inverse: + return PoseSet(self._queryset.filter(has_tag=False)) + else: + return PoseSet(self._queryset.filter(has_tag=True)) + + def get_by_method(self, method: str) -> 'PoseSet': + """Get all poses associated with a given pose method name.""" + return PoseSet( + self._queryset.filter( + methods__pose_method_name=method, + ) + ) + + def get_by_metadata( + self, key: str, value: str | None = None, debug: bool = False + ) -> 'PoseSet': + """Get all child poses with by their metadata. If no value is passed, then + simply containing the key in the metadata dictionary is sufficient + + :param key: metadata key to search for + :param value: metadata value, if ``None`` return poses with the metadata key + regardless of value (Default value = None) + + """ + if value is None: + # metadata stored as string + return PoseSet( + self._queryset.filter(pose_metadata__contains=f'"{key}"'), + ) + + else: + if isinstance(value, str): + value = f'"{value}"' + + return PoseSet( + self._queryset.filter(pose_metadata__contains=f'"{key}: {value}"'), + ) + + def get_by_inspiration(self, inspiration: PoseModel, inverse: bool = False): + """Get all child poses with with this inspiration. + + :param inspiration: inspiration :class:`.PoseModel` ID or object + :param inverse: invert the selection (Default value = False) + + """ + # not entirely sure which way the filtering should go + qs = ( + InspirationModel.objects.filter( + derivative_pose=inspiration, + ).values('original_pose'), + ) + + if inverse: + return PoseSet(self._queryset.exclude(pk__in=qs)) + else: + return PoseSet(self._queryset.filter(pk__in=qs)) + + def get_df( + self, + smiles: bool = True, + inchikey: bool = True, + alias: bool = True, + name: bool = True, + compound_id: bool = False, + target_id: bool = False, + reference_id: bool = False, + reference_alias: bool = False, + path: bool = False, + mol: bool = False, + energy_score: bool = False, + distance_score: bool = False, + inspiration_score: bool = False, + metadata: bool = False, + expand_metadata: bool = True, + debug: bool = True, + inspiration_ids: bool = False, + inspiration_aliases: bool = False, + derivative_ids: bool = False, + tags: bool = False, + expand_tags: bool = False, + subsites: bool = False, + scoring_methods: list[tuple[str, str]] | None = None, + pose_method: bool = False, + # skip_no_mol=True, reference: str = "name", mol: bool = False, **kwargs + ) -> 'pandas.DataFrame': + """Get a DataFrame of the poses in this set. + + :param smiles: include SMILES column (Default value = True) + :param inchikey: include InChIKey column (Default value = True) + :param alias: include alias column (Default value = True) + :param name: include name column (Default value = True) + :param compound_id: include :class:`.CompoundModel` ID column + (Default value = False) + :param reference_id: include reference :class:`.PoseModel` ID column + (Default value = False) + :param target_id: include reference :class:`.TargetModel` ID column + (Default value = False) + :param path: include path column (Default value = False) + :param mol: include ``rdkit.Chem.Mol`` in output (Default value = False) + :param energy_score: include energy_score column (Default value = False) + :param distance_score: include distance_score column (Default value = False) + :param inspiration_score: include inspiration_score column + (Default value = False) + :param metadata: include metadata in output (Default value = False) + :param expand_metadata: create separate column for each metadata key + (Default value = True) + :param inspiration_ids: include inspiration :class:`.PoseModel` ID column + :param inspiration_aliases: include inspiration :class:`.PoseModel` alias column + :param derivative_ids: include derivative :class:`.PoseModel` ID column + :param tags: include tags column + :param subsites: include subsites column + """ + + sig = inspect.signature(self.get_df) + flags = { + name: locals()[name] + for name in sig.parameters + if name + not in ( + 'self', + 'debug', + 'expand_tags', + 'expand_metadata', + 'scoring_methods', + ) + } + # need id in output + flags['id'] = True + + # alias and name both point to same thing. prefer 'name' + if flags.get('name', False): + flags['alias'] = True + + # this is still not working right and I don't understand. What + # was the original code doing here? simply adding both fields, + # name and alias? + + # dict :: func arg: (col title, qs field lookup, queryset annotation) + # this is going to get out of hand with multiple scoring methods + fields = { + 'id': ('id', 'id', None), + 'smiles': ('smiles', 'pose_smiles', None), + 'inchikey': ('inchikey', 'pose_inchikey', None), + # 'alias': ('alias', 'pose_alias', None), + 'name': ('name', 'pose_alias', None), + 'compound_id': ('compound_id', 'compound__id', None), + 'target_id': ('target_id', 'target__id', None), + 'reference_id': ('reference_id', 'pose_reference', None), + 'reference_alias': ( + 'reference_alias', + 'reference_alias', + Subquery( + PoseModel.objects.filter( + pk=OuterRef('pose_reference'), + ).values('pose_alias')[0:1] + ), + ), + 'path': ('protein_link', 'protein_link', None), + 'mol': ('mol', 'pose_mol', None), + 'energy_score': ( + 'energy_score', + 'energy_score', + ScoreSubquery('energy_score'), + ), + 'distance_score': ( + 'distance_score', + 'distance_score', + ScoreSubquery('distance_score'), + ), + 'inspiration_score': ( + 'inspiration_score', + 'inspiration_score', + ScoreSubquery('inspiration_score'), + ), + 'metadata': ('metadata', 'pose_metadata', None), + 'inspiration_ids': ( + 'inspiration_ids', + 'inspiration_ids', + ArrayAgg('inspirations__id'), + # JsonGroupArray('inspirations__id'), + ), + 'inspiration_aliases': ( + 'inspiration_aliases', + 'inspiration_aliases', + ArrayAgg('inspirations__pose_alias'), + # JsonGroupArray('inspirations__pose_alias'), + ), + 'derivative_ids': ( + 'derivative_ids', + 'derivative_ids', + ArrayAgg('inspirations__id'), + # JsonGroupArray('inspirations__id'), + ), + 'tags': ( + 'tags', + 'tag_names', + ArrayAgg('tags__pose_tag_name'), + # JsonGroupArray('tags__pose_tag_name'), + ), + 'subsites': ( + 'subsites', + 'subsites_names', + ArrayAgg( + 'subsites__subsite_name', + filter=Q(subsites__isnull=False), + ), + # JsonGroupArray('subsites__subsite_name', + # filter=Q(subsites__isnull=False),), + ), + 'pose_method': ( + 'pose_method', + 'pose_method_names', + ArrayAgg('methods__pose_method_name', distinct=True), + ), + } + + annotations = { + v[1]: v[2] for k, v in fields.items() if flags.get(k, False) and v[2] + } + values = [v[1] for k, v in fields.items() if flags.get(k, False)] + columns = {v[1]: v[0] for k, v in fields.items() if flags.get(k, False)} + + for method_name, version in scoring_methods or []: + score_sq = Subquery( + ScoreValueModel.objects.filter( + pose=OuterRef('pk'), + compound=OuterRef('compound'), + scoring_method__method_name=method_name, + scoring_method__method_version=version, + ) + .annotate( + score_val=Cast( + KeyTextTransform('score', 'score'), output_field=FloatField() + ) + ) + .values('score_val')[:1], + output_field=FloatField(), + ) + annotations[method_name] = score_sq + values.append(method_name) + columns[method_name] = method_name + + qs = self._queryset.annotate(**annotations).values(*values) + + df = pd.DataFrame(qs) + df = df.rename(columns=columns) + df = df.set_index('id') + + if alias: + df['alias'] = df.name + + if metadata and expand_metadata: + # TODO: code specific to my current situation. have to + # parse string to json (does postgres handle this + # automatically?) + # expanded = pd.json_normalize( + # df["metadata"].apply(lambda x: json.loads(x) if x else {}), + # ) + expanded = pd.json_normalize(df['metadata']) + # dropping columns is due to confusion with scores. can't be + # permanent solution, for now, drop the common ones + expanded = expanded.drop( + columns=set(expanded.columns).intersection(set(df.columns)), + ) + + df = df.drop(columns=['metadata']).join(expanded) + + if tags and expand_tags: + # surprisingly manual compared to expand_metadata, but + # kept running into problems + df['tags'] = df['tags'].apply(normalize_string_list) + # get all unique tags + all_tags = sorted(set(tag for tags in df['tags'] for tag in tags)) + + # build boolean columns + for tag in all_tags: + df[tag] = df['tags'].apply(lambda tags, tag=tag: tag in tags) + + df = df.drop(columns=['tags']) + + # custom aggreagte field is giving me string, parse to list + for col in [ + 'inspiration_aliases', + ]: + if col in df.columns: + df[col] = df[col].apply(normalize_string_list) + + return df + + def get_by_reference( + self, + ref_id: int, + ) -> 'PoseSet | None': + """Get poses with a certain reference id + + :param ref_id: reference :class:`.PoseModel` ID + + """ + qs = self._queryset.filter(pose_reference=ref_id) + if not qs.exists(): + # odd, but keeping now + return None + + return PoseSet(qs) + + def get_by_compound( + self, + *, + compound: 'int | CompoundModel | CompoundSet', + ) -> 'PoseSet | None': + """Select a subset of this :class:`.PoseSet` by the associated + :class:`.CompoundModel`. + + :param compound: :class:`.CompoundModel` object or ID + :returns: a :class:`.PoseSet` of the selection + + """ + if isinstance(compound, int): + return PoseSet(self._queryset.filter(compound__id=compound)) + elif isinstance(compound, CompoundModel): + return PoseSet(self._queryset.filter(compound=compound)) + else: + # possible crash point: assuming CompoundSet but not + # testing type, still trying to fiugre out circular + # imports + return PoseSet(self._queryset.filter(compound__in=compound.queryset)) + + def get_by_target( + self, + *, + target: TargetModel, + ) -> 'PoseSet | None': + """Select a subset of this :class:`.PoseSet` by the associated + :class:`.TargetModel`. + + :param id: :class:`.TargetModel` ID + :returns: a :class:`.PoseSet` of the selection + + """ + # where would you need this method?? do you ever create sets + # of poses from different targets? + return PoseSet(self._queryset.filter(target=target)) + + def get_by_subsite( + self, + *, + subsite: SubsiteModel, + ) -> 'PoseSet | None': + """Select a subset of this :class:`.PoseSet` by the associated + :class:`.SubsiteModel`. + + :param id: :class:`.SubsiteModel` ID + :returns: a :class:`.PoseSet` of the selection + + """ + qs = self._queryset.filter( + id__in=SubsiteTagModel.objects.filter( + subsite=subsite, + ).values('pose'), + ) + + if self.name: + name = f'{self.name} & subsite={subsite.pk}' + else: + name = None + + return PoseSet(qs, name=name) + + def set_subsites_from_metadata_field(self, field: str = 'CanonSites alias') -> None: + """Create and assign subsite entries from a pose metadata field.""" + # local import: keeps this upward set -> service call off the module graph + from designdb.services.subsite import SubsiteService + + SubsiteService.set_subsites_from_metadata_field(self._queryset, field) + + def get_best_scoring_poses_per_compound( + self, + scoring_method: str, + version: str | None = None, + inverse: bool = False, + ) -> 'PoseSet': + """Return one pose per compound with the best score for the given + scoring method. + + :param scoring_method: ``ScoringMethodModel.method_name`` to rank by + :param version: ``ScoringMethodModel.method_version`` — required when multiple + versions of the same method exist + :param inverse: if ``True``, higher score is better (default: lower is better) + """ + score_num = Cast(KeyTextTransform('score', 'score'), output_field=FloatField()) + + filters = {'scoring_method__method_name': scoring_method} + if version is not None: + filters['scoring_method__method_version'] = version + + rows = ( + ScoreValueModel.objects.filter(pose__in=self._queryset, **filters) + .annotate(score_num=score_num) + .values_list('compound_id', 'pose_id', 'score_num') + ) + + # keep exactly one pose per compound (the best score; ties broken by + # first-seen), rather than every pose tied for the best + best: dict[int, tuple[int, float]] = {} + for compound_id, pose_id, score in rows: + if score is None: + continue + current = best.get(compound_id) + if current is None or ( + score > current[1] if inverse else score < current[1] + ): + best[compound_id] = (pose_id, score) + + best_pose_ids = [pose_id for pose_id, _ in best.values()] + return PoseSet(PoseModel.objects.filter(pk__in=best_pose_ids)) + + # def filter( + # self, + # function=None, + # *, + # key: str = None, + # value: str = None, + # operator='=', + # inverse: bool = False, + # ): + # """Filter this :class:`.PoseSet` by selecting members where + # ``function(pose)`` is truthy or pass a key, value, and optional operator + # to search by database values + + # :param function: callable object + # :param key: database field for 'pose' table ('pose_' prefix not needed) + # :param value: value to compare to + # :param operator: comparison operator (default = "=") + # :param inverse: invert the selection (Default value = False) + + # """ + + # if function: + # ids = set() + # for pose in self: + # value = function(pose) + # # mrich.debug(f'{pose=} {value=}') + # if value and not inverse: + # ids.add(pose.id) + # elif not value and inverse: + # ids.add(pose.id) + + # return PoseSet(self.db, ids) + + # sql = f""" + # SELECT pose_id FROM {self.db.SQL_SCHEMA_PREFIX}pose + # WHERE pose_id IN {self.str_ids} + # AND pose_{key} {operator} {value} + # """ + + # cursor = self.db.execute(sql) + + # ids = [i for (i,) in cursor] + + # return PoseSet(self.db, ids) + + def add_tag( + self, + tag: str, + ) -> None: + """Add this tag to every member of the set""" + + assert isinstance(tag, str) + + pose_tag = PoseTagModel(pose_tag_name=tag) + pose_tag.save() + + PoseTagJunctionModel.objects.bulk_create( + [ + PoseTagJunctionModel(pose=pose, pose_tag=pose_tag) + for pose in self._queryset + ], + ignore_conflicts=True, + ) + + mrich.print(f'Tagged {self} w/ "{tag}"') + + # refetch in case was evaluated + self._queryset = PoseModel.objects.filter(pk__in=self._queryset.values('pk')) + + # NB! I'm now realizing this is potentially a huge + # problem. with every evaluation and refretch some attributes + # may be lost. how can this be kept clean? + + def append_to_metadata( + self, + key, + value, + ) -> None: + """Append ``value`` to the list at ``key`` in every member's metadata dict. + + :param key: the metadata key to match (created as a new list if absent) + :param value: the value to append to the list + """ + for pose in self._queryset: + metadata = pose.pose_metadata or {} + existing = metadata.get(key) + if existing is None: + metadata[key] = [value] + elif isinstance(existing, list): + existing.append(value) + else: + mrich.error(f'Could not append to metadata {key=}: not a list') + continue + pose.pose_metadata = metadata + pose.save() + self._queryset = PoseModel.objects.filter(pk__in=self._queryset.values('pk')) + + # TODO: implement scores + # def calculate_inspiration_scores( + # self, + # alpha: float = 0.95, + # beta: float = 0.05, + # score_type: str = 'combo', + # ) -> 'pd.DataFrame': + # """Set inspiration_score values using MoCASSIn.calculate_mocassin_tversky + + # :param alpha: Tversky alpha parameter + # :param beta: Tversky beta parameter + # :param score_type: Score type to add to database, choose from + # "combo", "shape", "colour" + # :returns: Pandas DataFrame with molecules and scores + # """ + + # from mocassin.mocassin import calculate_mocassin_tversky + + # df = self.get_df( + # alias=False, + # smiles=False, + # inchikey=False, + # inspiration_ids=True, + # mol=True, + # ) + + # inspirations = {p.id: p for p in self.inspirations} + + # df['inspiration_mols'] = df['inspiration_ids'].apply( + # lambda x: [inspirations[i].mol for i in x] + # ) + + # n = len(df) + + # for j, (i, row) in mrich.track( + # enumerate(df.iterrows()), prefix='MoCASSIn', total=n + # ): + # mrich.set_progress_field('j', j) + # mrich.set_progress_field('n', n) + + # try: + # combo, shape, colour = calculate_mocassin_tversky( + # row['inspiration_mols'], + # row['mol'], + # alpha=0.95, + # beta=0.05, + # ) + # df.loc[i, f'mocassin_combo({alpha},{beta})'] = combo + # df.loc[i, f'mocassin_shape({alpha},{beta})'] = shape + # df.loc[i, f'mocassin_colour({alpha},{beta})'] = colour + # except Exception as e: + # mrich.error(e) + + # tuples = df[f'mocassin_{score_type}({alpha},{beta})'].items() + + # sql = f"""UPDATE {self.db.SQL_SCHEMA_PREFIX}pose + # SET pose_inspiration_score = {self.db.SQL_STRING_PLACEHOLDER} + # WHERE pose_id = {self.db.SQL_STRING_PLACEHOLDER}""" + + # mrich.debug('Updating pose_inspiration_score values') + # self.db.executemany(sql, [(b, a) for a, b in tuples]) + # self.db.commit() + + # return df + + ### SPLITTING + + def split_by_reference(self) -> 'dict[int,PoseSet]': + """Split this :class:`.PoseSet` into subsets grouped by reference ID + + :returns: a dictionary with reference :class:`.PoseModel` IDs as keys and + :class:`.PoseSet` subsets as values + + """ + sets = {} + for ref_id in self.reference_ids: + sets[ref_id] = self.get_by_reference(ref_id) + return sets + + def split_by_inspirations( + self, + single_set: bool = False, + ) -> 'dict[PoseSet,PoseSet] | PoseSet': + """Split this :class:`.PoseSet` into subsets grouped by inspirations + + :param single_set: Return a single :class:`.PoseSet` with members sorted by + inspirations (Default value = False) + :returns: a dictionary with tuples of inspiration :class:`.PoseSet` as keys and + :class:`.PoseSet` derivative subsets as values + + """ + + sets = {} + + for pose in self._queryset: + insp_ids = list(pose.inspirations.distinct().values_list('pk', flat=True)) + key = tuple(insp_ids) + sets.setdefault(key, set()) + sets[key].add(pose.pk) + + mrich.var('#unique inspiration combinations', len(sets)) + + if single_set: + return PoseSet( + PoseModel.objects.filter( + pk__in=[id for s in sets.values() for id in s.ids], + sort=False, + ) + ) + + self._queryset = PoseModel.objects.filter(pk__in=self._queryset.values('pk')) + + return { + PoseSet(PoseModel.objects.filter(pk__in=insp_ids)): PoseSet( + PoseModel.objects.filter(pk__in=pose_ids) + ) + for insp_ids, pose_ids in sets.items() + } + + ### EXPORTING + + def write_sdf( + self, + out_path: str, + name_col: str = 'alias', + inspiration_ids: bool = False, + inspiration_aliases: bool = False, + **kwargs, + ) -> None: + """Write an SDF + + :param out_path: filepath of the output + :param name_col: pose property to use as the name column, can be + ``["name", "alias", "inchikey", "id"]`` (Default value = 'name') + :param inspiration_ids: include inspiration :class:`.PoseModel` ID column + :param inspiration_aliases: include inspiration :class:`.PoseModel` alias column + :param fragalysis_inspirations: create inspirations column "ref_mols" + """ + + df = self.get_df( + mol=True, + inspiration_ids=inspiration_ids, + inspiration_aliases=inspiration_aliases, + name=name_col == 'name', + **kwargs, + ) + + if name_col not in ['name', 'alias', 'inchikey', 'id']: + # try getting name from metadata + records = self._queryset.values('id', 'pose_metadata') + + longcode_lookup = {} + for i, d in records: + if d: + metadata = json.loads(d) + else: + metadata = {} + + longcode_lookup[i] = metadata.get(name_col, None) + + values = [] + for _, row in df.iterrows(): + values.append(longcode_lookup[row['id']]) + + df[name_col] = values + + df = df.rename(columns={name_col: '_Name', 'mol': 'ROMol'}) + + mrich.writing(out_path) + + PandasTools.WriteSDF(df, out_path, 'ROMol', '_Name', list(df.columns)) + + # keep record of export + value = str(Path(out_path).resolve()) + # self.db.remove_metadata_list_item(table='pose', key='exports', value=value) + self.append_to_metadata(key='exports', value=value) + + def to_fragalysis( + self, + out_path: str, + *, + method: str, + ref_url: str = 'https://hippo.winokan.com', + submitter_name: str, + submitter_email: str, + submitter_institution: str, + metadata: bool = True, + sort_by: str | None = None, + sort_reverse: bool = False, + generate_pdbs: bool = False, + copy_reference_pdbs: bool = False, + # ingredients: IngredientSet = None, + skip_no_reference: bool = True, + skip_no_inspirations: bool = True, + skip_metadata: list[str] | None = None, + tags: bool = True, + subsites: bool = True, + extra_cols: dict[str, list] = None, + inspiration_score: bool = True, + # name_col: str = "name", + **kwargs, + ): + """Prepare an SDF for upload to the RHS of Fragalysis. + + :param out_path: the file path to write to + :param method: method used to generate the compounds + :param ref_url: reference URL for the method + :param submitter_name: name of the person submitting the compounds + :param submitter_email: email of the person submitting the compounds + :param submitter_institution: institution name of the person submitting the + compounds + :param metadata: include metadata in the output? (Default value = True) + :param skipmetadata: exclude metadata keys from output + :param sort_by: if set will sort the SDF by this column/field + (Default value = None) + :param sort_reverse: reverse the sorting (Default value = False) + :param generate_pdbs: generate accompanying protein-ligand complex PDBs + (Default value = False) + :param ingredients: get procurement and amount information from this + :class:`.IngredientSet` (Default value = None) + :param tags: include a column for tags in the output (Default value = True) + :param subsites: include a column for subsites in the output + (Default value = True) + :param extra_cols: extra_cols should be a dictionary with a key for each column + name, and list values where the first element is the field description, and + all subsequent elements are values for each pose. + + """ + + assert out_path.endswith('.sdf') + + _name_col = '_Name' + mol_col = 'ROMol' + mol_col = 'mol' + + # make sure references are defined: + logger.debug('entering') + + mrich.debug(len(self), 'poses in set') + poses = None + + if skip_no_reference: + values = self._queryset.filter(pose_reference__isnull=False) + + if not values.exists(): + mrich.debug('no references, quitting') + logger.warning('no references, quitting') + return + + poses = PoseSet(values) + + mrich.debug(len(poses), 'remaining after skipping null reference') + + if skip_no_inspirations: + if not poses: + poses = self + + values = InspirationModel.objects.filter( + derivative_pose__in=self._queryset, + ).values( + 'derivative_pose', + ) + + if values.exists(): + poses = PoseSet(PoseModel.objects.filter(pk__in=values)) + mrich.debug(len(poses), 'remaining after skipping null inspirations') + else: + logger.warning( + 'no inspirations found; per-pose fallback will set ' + 'inspiration to self' + ) + + if not poses: + # huh? + poses = PoseSet(self._queryset) + + mrich.var('#poses', len(poses)) + + # TODO: this should not go through the df + pose_df = poses.get_df( + mol=True, + inspiration_ids=True, + # duplicate_name="original ID", + name=True, + compound_id=True, + reference_id=True, + metadata=metadata, + tags=tags, + subsites=subsites, + energy_score=True, + distance_score=True, + inspiration_score=inspiration_score, + # sanitise_null_metadata_values=True, + expand_tags=False, + # sanitise_tag_list_separator=";", + # sanitise_metadata_list_separator=";", + # skip_metadata=skip_metadata, + # **kwargs, + ) + + pose_df = pose_df.reset_index() + + # fix inspirations and reference column (comma separated aliases). + # reference poses may lie outside this set, so look up exactly the + # referenced pose IDs rather than scanning the whole table. + ref_ids = {r for r in pose_df['reference_id'].tolist() if r is not None} + lookup = dict( + PoseModel.objects.filter(pk__in=ref_ids).values_list('pk', 'pose_alias') + ) + + # for i, row in pose_df.iterrows(): + # strs = [] + # for i in normalize_string_list(row['inspiration_ids']): + # # this is what it did in original code + # alias = self._queryset.get(pk=i).pose_alias + # if not alias: + # continue + # strs.append(alias) + # inspiration_strs.append(','.join(strs)) + + # comma separate subsites + if subsites: + + def fix_subsites(subsite_list: list[str]): + """Fix subsites""" + if not subsite_list: + logger.warning('no subsite list') + return 'None' + return ','.join(subsite_list) + + pose_df['subsites'] = pose_df['subsites'].apply(fix_subsites) + + if tags: + pose_df['tags'] = pose_df['tags'].apply( + lambda x: ','.join(v for v in x if v is not None) + ) + + # pose_df['ref_mols'] = inspiration_strs + pose_df['ref_mols'] = 'inspiration_strs' + pose_df['ref_pdb'] = pose_df['reference_id'].apply(lambda x: lookup.get(x)) + + # add compound identifier column (inchikey?) + + drops = ['inspiration_ids', 'reference_id'] + + # if ingredients: + # drops.pop(drops.index("compound")) + + if skip_no_reference: + prev = len(pose_df) + pose_df = pose_df[pose_df['reference_id'].notna()] + if len(pose_df) < prev: + mrich.warning(f'Skipping {prev - len(pose_df)} Poses with no reference') + + pose_df = pose_df.drop(columns=drops, errors='ignore') + + pose_df[_name_col] = pose_df['name'] + + pose_df.rename( + inplace=True, + columns={ + 'id': 'HIPPO PoseModel ID', + 'compound_id': 'HIPPO CompoundModel ID', + 'mol': mol_col, + # "smiles": "original SMILES", + # "compound_id": "compound inchikey", + }, + ) + + extras = { + 'HIPPO PoseModel ID': 'HIPPO PoseModel ID', + 'HIPPO CompoundModel ID': 'HIPPO CompoundModel ID', + 'smiles': 'smiles', + 'ref_pdb': 'protein reference', + 'ref_mols': 'fragment inspirations', + 'alias': 'alias', + # "compound inchikey": "compound inchikey", + 'distance_score': 'distance_score', + 'energy_score': 'energy_score', + 'inspiration_score': 'inspiration_score', + } + + if subsites: + extras['subsites'] = 'subsites' + + if tags: + extras['tags'] = 'tags' + + if extra_cols: + for key, value in extra_cols.items(): + extras[key] = value[0] + + # if ingredients: + + # q_entries = [] + # q_prices = [] + # q_lead_times = [] + # q_amounts = [] + + # currency = None + + # for i, row in pose_df.iterrows(): + + # compound_id = self.db.get_compound_id( + # inchikey=row["compound inchikey"]) + + # ingredient = ingredients(compound_id=compound_id) + + # if isinstance(ingredient, IngredientSet): + # ingredient = sorted( + # [i for i in ingredient], key=lambda x: x.quote.price + # )[0] + + # quote = ingredient.quote + # if not currency: + # currency = quote.currency + # else: + # assert quote.currency == currency + + # q_entries.append(quote.entry_str) + # q_prices.append(quote.price) + # q_lead_times.append(quote.lead_time) + # q_amounts.append(quote.amount) + + # pose_df["Supplier Catalogue Entry"] = q_entries + # # pose_df['Supplier:Catalogue:Entry'] = q_entries + # pose_df[f"Price ({currency})"] = q_prices + # pose_df["Lead time (working days)"] = q_lead_times + # pose_df["Amount (mg)"] = q_amounts + + # extras["Supplier Catalogue Entry"] = "Supplier Catalogue Entry string" + # extras[f"Price ({currency})"] = "Quoted price" + # extras["Lead time (working days)"] = "Quoted lead-time" + # extras["Amount (mg)"] = "Quoted amount" + + out_path = Path(out_path).resolve() + mrich.var('out_path', out_path) + + if generate_pdbs: + # output subdirectory + out_key = Path(out_path).name.removesuffix('.sdf') + pdb_dir = Path(out_path).parent / Path(out_key) + pdb_dir.mkdir(exist_ok=True) + zip_path = Path(out_path).parent / f'{out_key}_pdbs.zip' + + # create the zip archive + with ZipFile(str(zip_path.resolve()), 'w') as z: + # loop over poses + for (i, row), pose in zip(pose_df.iterrows(), poses, strict=False): + # filenames + pdb_name = f'{out_key}_{row._Name}.pdb' + pdb_path = pdb_dir / pdb_name + pose_df.loc[i, 'ref_pdb'] = pdb_name + + # generate the PL-complex + sys = pose.complex_system + + # write the PDB + mrich.writing(pdb_path) + sys.write(pdb_path, verbosity=0) + z.write(pdb_path) + + mrich.writing(f'{out_key}_pdbs.zip') + + if copy_reference_pdbs: + # output subdirectory + out_key = Path(out_path).name.removesuffix('.sdf') + pdb_dir = Path(out_path).parent / Path(out_key) + pdb_dir.mkdir(exist_ok=True) + zip_path = Path(out_path).parent / f'{out_key}_refs.zip' + + # ref_pdb holds reference pose aliases that may lie outside this set, + # so look them up directly (alias -> protein PDB path) + ref_aliases = {a for a in pose_df['ref_pdb'].tolist() if a is not None} + lookup = dict( + PoseModel.objects.filter(pose_alias__in=ref_aliases).values_list( + 'pose_alias', 'protein_link' + ) + ) + + zips = set() + for ref_alias in pose_df['ref_pdb'].values: + source = lookup.get(ref_alias) + if not source: + mrich.warning( + f'No protein file for reference {ref_alias!r}; skipping' + ) + continue + source_path = Path(source) + + stem = source_path.name.replace('_hippo.pdb', '.pdb') + # current Fragalysis protein-file naming + apo_path = source_path.parent / stem.replace( + '.pdb', '_delig-desolv.pdb' + ) + # DEPRECATED(apo-naming): fall back to pre-'delig' naming if present, + # remove once all data uses 'delig' + legacy_path = source_path.parent / stem.replace( + '.pdb', '_apo-desolv.pdb' + ) + if not apo_path.exists() and legacy_path.exists(): + apo_path = legacy_path + + if not apo_path.exists(): + sys = mp.parse(source_path).protein_system + sys.write(apo_path, verbosity=0) + + target_path = pdb_dir / f'{ref_alias}.pdb' + + if not target_path.exists(): + mrich.writing(target_path) + shutil.copy(apo_path, target_path) + + zips.add(target_path) + + # create the zip archive + with ZipFile(str(zip_path.resolve()), 'w') as z: + for path in zips: + z.write(path, arcname=path.name) + + mrich.writing(f'{out_key}_refs.zip') + + # create the header molecule + + header = generate_header( + # self[0], # <- what does that do?? + self._queryset.first(), + method=method, + ref_url=ref_url, + submitter_name=submitter_name, + submitter_email=submitter_email, + submitter_institution=submitter_institution, + extras=extras, + metadata=metadata, + ) + + # # empty properties + # pose_df["generation_date"] = [None] * len(pose_df) + # pose_df["submitter_name"] = [None] * len(pose_df) + # pose_df["method"] = [None] * len(pose_df) + # pose_df["submitter_email"] = [None] * len(pose_df) + # pose_df["ref_url"] = [None] * len(pose_df) + + if extra_cols: + for key, value in extra_cols.items(): + if len(value) != len(pose_df) + 1: + mrich.error( + f'extra_col "{key}" does not have the correct number of values' + ) + raise ValueError( + f'extra_col "{key}" does not have the correct number of values' + ) + pose_df[key] = value[1:] + + if sort_by: + pose_df = pose_df.sort_values(by=sort_by, ascending=not sort_reverse) + + mrich.writing(out_path) + + with open(out_path, 'w') as sdfh: + with SDWriter(sdfh) as w: + w.write(header) + PandasTools.WriteSDF( + pose_df, sdfh, mol_col, _name_col, set(pose_df.columns) + ) + + # keep record of export + value = str(Path(out_path).resolve()) + + # FIXME + # self.db.remove_metadata_list_item(table='pose', key='exports', value=value) + + self.append_to_metadata(key='exports', value=value) + + return pose_df + + def to_pymol(self, prefix: str | None = None) -> None: + """Group the poses by reference protein and inspirations and output relevant + PDBs and SDFs. + + :param prefix: prefix to give all output subdirectories (Default value = None) + + """ + + commands = [] + + prefix = prefix or '' + if prefix: + prefix = f'{prefix}_' + + from pathlib import Path + + for ref_id, poses in self.split_by_reference().items(): + ref_pose = PoseModel.objects.get(id=ref_id) + ref_name = ref_pose.pose_alias or ref_id + + # create the subdirectory + ref_dir = Path(f'{prefix}ref_{ref_name}') + mrich.writing(ref_dir) + ref_dir.mkdir(parents=True, exist_ok=True) + + # write the reference protein + ref_pdb = ref_dir / f'ref_{ref_name}.pdb' + ref_pose.protein_system.write(ref_pdb, verbosity=0) + + # color the reference: + commands.append(f'load {ref_pdb.resolve()}') + commands.append('hide') + commands.append('show lines') + commands.append('show surface') + commands.append('util.cbaw') + commands.append('set surface_color, white') + commands.append('set transparency, 0.4') + + for j, (inspirations, poses) in enumerate( # noqa: B020 + poses.split_by_inspirations().items() + ): + # split_by_inspirations() keys are already inspiration PoseSets + insp_names = '-'.join(inspirations.names) + + # create the subdirectory + insp_dir = ref_dir / insp_names + insp_dir.mkdir(parents=True, exist_ok=True) + + # write the inspirations + insp_sdf = insp_dir / f'{insp_names}_frags.sdf' + inspirations.write_sdf(insp_sdf) + + commands.append(f'load {insp_sdf.resolve()}') + commands.append( + f'set all_states, on, {insp_sdf.name.removesuffix(".sdf")}' + ) + commands.append(f'util.rainbow "{insp_sdf.name.removesuffix(".sdf")}"') + + # write the poses + pose_sdf = insp_dir / f'{insp_names}_derivatives.sdf' + poses.write_sdf(pose_sdf) + + commands.append(f'load {pose_sdf.resolve()}') + commands.append(f'util.cbaw "{pose_sdf.name.removesuffix(".sdf")}"') + + if j > 0: + commands.append(f'disable "{insp_sdf.name.removesuffix(".sdf")}"') + commands.append(f'disable "{pose_sdf.name.removesuffix(".sdf")}"') + + return '; '.join(commands) + + def to_knitwork( + self, out_path: str, path_root: str = '.', aligned_files_dir: str | None = None + ) -> None: + """Knitwork takes a CSV input with: + + - observation shortcode + - smiles + - path_to_ligand_mol + - path_to_pdb + + :param out_path: path to output CSV + :param path_root: paths in CSV will be relative to here + + """ + + out_path = Path(out_path).resolve() + path_root = Path(path_root).resolve() + mrich.var('out_path', out_path) + mrich.var('path_root', path_root) + mrich.var('aligned_files_dir', aligned_files_dir) + + assert out_path.name.endswith('.csv') + + with open(out_path, 'w') as f: + mrich.writing(out_path) + + for pose in self._queryset: + assert pose.pose_alias + assert pose.methods.filter( + pose_method_name__in=DEFAULT_POSE_METHODS + ).exists() + + if aligned_files_dir: + mol = str(pose.mol_path) + pdb = str(pose.apo_path) + + assert 'aligned_files' in mol + assert 'aligned_files' in pdb + + mol = mol.split('aligned_files/')[-1] + pdb = pdb.split('aligned_files/')[-1] + + aligned_files_dir = Path(aligned_files_dir) + + mol = relpath(aligned_files_dir / mol, path_root) + pdb = relpath(aligned_files_dir / pdb, path_root) + + else: + mol = relpath(pose.mol_path, path_root) + pdb = relpath(pose.apo_path, path_root) + + data = [pose.pose_alias, pose.compound.compound_smiles, mol, pdb] + + f.write(','.join(data)) + f.write('\n') + + def to_syndirella( + self, out_key: 'str | Path', separate: bool = False + ) -> 'DataFrame': + """Create syndirella inputs""" + + out_key = Path('.') / out_key + + out_dir = out_key.parent + out_key = out_key.name + + mrich.var('out_key', out_key) + mrich.var('#poses', len(self)) + + out_dir.mkdir(parents=True, exist_ok=True) + + ### Prepare Syndirella CSV data + + df = self.get_df( + inchikey=False, alias=False, reference_alias=True, inspiration_aliases=True + ) + df = df.rename(columns={'reference_alias': 'template'}) + + # compound_set + + if separate: + df['compound_set'] = df.apply( + lambda row: f'{out_key}_{row["name"]}', axis=1 + ) + + else: + df['compound_set'] = out_key + + # template + + null_template = df['template'].isnull() + if null_template.any(): + mrich.warning( + len(null_template), 'poses have no reference. Setting to self' + ) + mrich.print(df.loc[null_template, 'name'].values) + df['template'] = df['template'].fillna(df['name']) + + # inspirations + + null_inspirations = df['inspiration_aliases'].apply(lambda x: not x) + + if null_inspirations.any(): + mrich.warning( + len(null_inspirations), 'poses have no inspirations. Setting to self' + ) + mrich.print(df.loc[null_inspirations, 'name'].values) + df.loc[null_inspirations, 'inspiration_aliases'] = df.loc[ + null_inspirations + ].apply(lambda row: set([row['name']]), axis=1) + + for i, row in df.iterrows(): + for j, inspiration in enumerate(row['inspiration_aliases']): + df.loc[i, f'hit{j + 1}'] = inspiration + + # this from original code. looking at the data type I have, + # this cannot possibly work. did I get something wrong filling + # the df? + # all_inspirations = set.union(*list(df['inspiration_aliases'].values)) + all_inspirations = set().union(*df['inspiration_aliases']) + + df = df.drop(columns=['name', 'inspiration_aliases']) + + ### Copy Templates + + template_dir = out_dir / 'templates' + mrich.writing(template_dir) + template_dir.mkdir(parents=True, exist_ok=True) + + templates = df['template'].unique() + + # records = self._queryset.filter(pose_alias__in=templates) + records = PoseModel.objects.filter( + target__in=self.targets, + pose_alias__in=templates, + ) + + templates = PoseSet(records) + + for ref in templates: + template = template_dir / ref.apo_path.name + if not template.exists(): + mrich.writing(template) + shutil.copy(ref.apo_path, template) + + ### Inspirations + # isn't this overwriting the one few lines above?? + records = PoseModel.objects.filter( + target__in=self.targets, pose_alias__in=all_inspirations + ) + + all_inspirations = PoseSet(records) + + ### Write CSV + + if separate: + for _, row in df.iterrows(): + csv_name = out_dir / f'{row["compound_set"]}_syndirella_input.csv' + mrich.writing(csv_name) + row.to_frame().T.to_csv(csv_name, index=False) + + else: + csv_name = out_dir / f'{out_key}_syndirella_input.csv' + mrich.writing(csv_name) + df.to_csv(csv_name, index=False) + + ### Write Inspirations + + sdf_name = out_dir / f'{out_key}_syndirella_inspiration_hits.sdf' + all_inspirations.write_sdf( + sdf_name, + tags=False, + metadata=False, + name_col='name', + ) + + return df + + ### OUTPUT + + def interactive( + self, + print_name: str = True, + method: str | None = None, + function: Callable | None = None, + **kwargs, + ): + """Interactive widget to navigate compounds in the table + + :param print_name: print the :class:`.PoseModel` name (Default value = True) + :param method: pass the name of a :class:`.PoseModel` method to interactively + display. Keyword arguments to interactive() will be passed through + (Default value = None) + :param function: pass a callable which will be called as `function(pose)` + + """ + + if method: + + def widget(i): + """Method widget""" + pose = self[i] + if print_name: + print(repr(pose)) + value = getattr(pose, method)(**kwargs) + if value: + display(value) + + return interactive( + widget, + i=BoundedIntText( + value=0, + min=0, + max=len(self) - 1, + step=1, + description='PoseModel:', + disabled=False, + ), + ) + + elif function: + + def widget(i): + """Function widget""" + pose = self[i] + if print_name: + display(pose) + function(pose) + + return interactive( + widget, + i=BoundedIntText( + value=0, + min=0, + max=len(self) - 1, + step=1, + description='PoseModel:', + disabled=False, + ), + ) + + else: + a = BoundedIntText( + value=0, + min=0, + max=len(self) - 1, + step=1, + description=f'PoseModel (/{len(self)}):', + disabled=False, + ) + + b = Checkbox(description='Name', value=True) + c = Checkbox(description='Summary', value=False) + h = Checkbox(description='Tags', value=False) + i = Checkbox(description='Subsites', value=False) + d = Checkbox(description='2D (Comp.)', value=False) + e = Checkbox(description='2D (PoseModel)', value=False) + f = Checkbox(description='3D', value=True) + g = Checkbox(description='Metadata', value=False) + + ui1 = GridBox( + [b, c, d, h], + layout=Layout(grid_template_columns='repeat(4, 100px)'), + ) + ui2 = GridBox( + [e, f, g, i], + layout=Layout(grid_template_columns='repeat(4, 100px)'), + ) + ui = VBox([a, ui1, ui2]) + + def widget( + i, + name: bool = True, + summary: bool = True, + grid: bool = True, + draw2d: bool = True, + draw: bool = True, + tags: bool = True, + subsites: bool = True, + metadata: bool = True, + ): + """Default widget""" + pose = self._queryset.get(pk=i) + if name: + print(repr(pose)) + + if summary: + pose.summary(metadata=False, tags=False, subsites=False) + if tags: + print(pose.tags) + if subsites: + print(pose.subsites) + if grid: + pose.grid() + if draw2d: + pose.draw2d() + if draw: + pose.draw() + if metadata: + mrich.title('Metadata:') + pprint(pose.metadata) + + out = interactive_output( + widget, + { + 'i': a, + 'name': b, + 'summary': c, + 'grid': d, + 'draw2d': e, + 'draw': f, + 'metadata': g, + 'tags': h, + 'subsites': i, + }, + ) + + display(ui, out) + + def summary(self) -> None: + """Print a summary of this pose set""" + mrich.header('PoseSet()') + mrich.var('#poses', len(self)) + mrich.var('#compounds', self.num_compounds) + mrich.var('tags', self.tags) + + def draw(self) -> None: + """Render this pose set with Py3Dmol""" + + mols = [p.mol for p in self] + + drawing = draw_mols(mols) + display(drawing) + + def grid(self) -> None: + """Draw a grid of all contained molecules""" + + data = [(p.name, p.compound.mol) for p in self] + + mols = [d[1] for d in data] + labels = [d[0] for d in data] + + drawing = draw_grid(mols, labels=labels) + display(drawing) + + def subsite_summary(self) -> 'pd.DataFrame': + """Print a table counting poses by subsite""" + + rows = ( + SubsiteTagModel.objects.filter(pose__in=self._queryset) + .values('subsite_id', 'subsite__subsite_name') + .annotate(num_poses=Count('pose', distinct=True)) + ) + + df = DataFrame( + [ + dict( + id=r['subsite_id'], + subsite=r['subsite__subsite_name'], + num_poses=r['num_poses'], + ) + for r in rows + ] + ) + + if len(df): + df = df.set_index('id').sort_values(by='num_poses', ascending=False) + + mrich.print(df) + + return df + + def get_interaction_overlaps(self, return_pairs: bool = False) -> int: + """Count the number of member pose pairs which share at least one but not all + interactions""" + + records = InteractionModel.objects.filter( + pose__in=self._queryset, + ).values( + 'pose', + 'feature', + 'interaction_type', + ) + + ISETS = {} + for r in records: + pose_id = r['pose'] + feature_id = r['feature'] + interaction_type = r['interaction_type'] + values = ISETS.get(pose_id, set()) + values.add((interaction_type, feature_id)) + ISETS[pose_id] = values + + ids = [i for i in self.ids if i in ISETS] + + count = 0 + + pairs = set() + + for pose_j, pose_k in combinations(ids, 2): + iset_j = ISETS[pose_j] + iset_k = ISETS[pose_k] + + intersection = iset_j & iset_k + diff1 = iset_j - iset_k + diff2 = iset_k - iset_j + + if intersection and diff1 and diff2: + count += 1 + pairs.add((pose_j, pose_k)) + + if return_pairs: + return [PoseSet(PoseModel.objects.filter(pk__in=[a, b])) for a, b in pairs] + + return count + + def get_interaction_clusters(self) -> 'dict[int, PoseSet]': + """Cluster poses based on shared interactions.""" + + # get interaction records + records = InteractionModel.objects.filter( + pose__in=self._queryset, + ).values( + 'pose', + 'feature__feature_residue_name', + 'feature__feature_residue_number', + 'interaction_type', + ) + + ISETS = {} + for r in records: + pose_id = r['pose'] + feature_residue_name = r['feature__feature_residue_name'] + feature_residue_number = r['feature__feature_residue_number'] + interaction_type = r['interaction_type'] + values = ISETS.get(pose_id, set()) + values.add((interaction_type, feature_residue_name, feature_residue_number)) + ISETS[pose_id] = values + + pairs = combinations(ISETS.keys(), 2) + + # construct overlap dictionary + + OVERLAPS = {} + for id1, id2 in pairs: + iset1 = ISETS[id1] + iset2 = ISETS[id2] + OVERLAPS[(id1, id2)] = len(iset1 & iset2) + + # make the graph + G = nx.Graph() + + for (id1, id2), count in OVERLAPS.items(): + G.add_edge(id1, id2, weight=count) + + # partition the graph + + partition = louvain.best_partition(G, weight='weight') + + # find the clusters + + clusters = {} + for node, cluster_id in partition.items(): + clusters.setdefault(cluster_id, set()).add(node) + + # create the PoseSets + + psets = { + i: PoseSet(PoseModel.objects.filter(pk__in=ids), name=f'Cluster {i}') + for i, ids in enumerate(clusters.values()) + } + + all_ids = set(sum((pset.ids for pset in psets.values()), [])) + + # calculate modal interactions + + for cluster in psets.values(): + mrich.var(cluster.name, len(cluster), unit='poses') + + df = cluster.interactions.df + + unique_counts = df.groupby(['type', 'residue_name', 'residue_number'])[ + 'pose_id' + ].nunique() + + max_count = unique_counts.max() + max_pairs = unique_counts[unique_counts == max_count] + + for ( + interaction_type, + residue_name, + residue_number, + ) in max_pairs.index.values: + mrich.print(interaction_type, 'w/', residue_name, residue_number) + + # unclustered + unclustered = set(i for i in self.ids if i not in all_ids) + psets[None] = PoseSet( + PoseModel.objects.filter(pk__in=unclustered), name='Unclustered' + ) + + return psets + + ### PROPERTIES + + @property + def queryset(self) -> QuerySet[PoseModel]: + """Returns the ids of poses in this set""" + return self._queryset + + @property + def indices(self) -> list[int]: + """Returns the ids of poses in this set""" + return self.queryset.values_list('id', flat=True) + + @property + def ids(self) -> list[int]: + """Returns the ids of poses in this set""" + return self.indices + + @property + def name(self) -> str | None: + """Returns the name of set""" + return self._name + + @property + def names(self) -> list[str]: + """Returns the aliases of poses in this set""" + return self._queryset.values_list('pose_alias', flat=True) + + @property + def aliases(self) -> list[str]: + """Returns the aliases of child poses""" + return self._queryset.values_list('pose_alias', flat=True) + + @property + def inchikeys(self) -> list[str]: + """Returns the inchikeys of child poses""" + return self._queryset.values_list('pose_inchikey', flat=True) + + @property + def id_name_dict(self) -> dict: + """Return a dictionary mapping pose ID's to their name""" + return dict(self._queryset.values_list('pk', 'pose_alias')) + + @property + def smiles(self) -> list[str]: + """Returns the smiles of poses in this set""" + return self._queryset.values_list('pose_smiles', flat=True) + + @property + def tags(self) -> set[str]: + """Returns the set of unique tags present in this pose set""" + return self._queryset.values_list('tags__pose_tag_name', flat=True).distinct() + + @property + def num_fingerprinted(self) -> int: + """Count the number of fingerprinted poses""" + # that's one field suspect not in use + return self._queryset.filter(pose_fingerprint=1).count() + + @property + def compounds(self) -> 'CompoundSet': + """Get the compounds associated to this set of poses""" + # local import to avoid the PoseSet <-> CompoundSet cycle + from designdb.sets.compound import CompoundSet + + ids = list(self._queryset.values_list('compound_id', flat=True).distinct()) + return CompoundSet(ids) + + @property + def mols(self) -> list[Chem.rdchem.Mol]: + """Get the rdkit Molecules contained in this set""" + return self._queryset.values_list('pose_mol', flat=True) + + @property + def num_compounds(self) -> int: + """Count the compounds associated to this set of poses""" + return self._queryset.values('compound').distinct().count() + + @property + def df(self) -> pd.DataFrame: + """Get a DataFrame of the poses in this set""" + return self.get_df(mol=True) + + @property + def references(self) -> 'PoseSet': + """Return a :class:`.PoseSet` of the all the distinct references in this + :class:`.PoseSet`""" + # TODO: call through proper factory method + return self.get_by_references(self) + + @property + def reference_ids(self) -> set[int]: + """Return a set of :class:`.PoseModel` ID's of the all the distinct references + in this :class:`.PoseSet`""" + return self.get_by_references(self).values_list('pk', flat=True) + + @property + def inspiration_sets(self) -> set[tuple[int, ...]]: + """Return the unique sets of inspiration :class:`.PoseModel` IDs""" + + # group original (inspiration) pose IDs by derivative pose ID -- use the + # FK ids (sortable ints, no extra queries) rather than the model objects + data: dict[int, set[int]] = {} + for deriv_id, orig_id in InspirationModel.objects.filter( + derivative_pose__in=self._queryset + ).values_list('derivative_pose_id', 'original_pose_id'): + data.setdefault(deriv_id, set()).add(orig_id) + + return {tuple(sorted(v)) for v in data.values()} + + @property + def num_inspiration_sets(self) -> int: + """Return the number of unique sets of inspirations""" + return len(self.inspiration_sets) + + @property + def num_inspirations(self) -> int: + """Return the number of unique inspirations for poses in this set""" + # fmt: off + return InspirationModel.objects.filter( + derivative_pose__in=self._queryset, + ).values( + 'original_pose', + ).distinct().count() + # fmt: on + + @property + def inspirations(self) -> int: + """Return the number of unique inspirations for poses in this set""" + return self.get_by_inspirations(self._queryset) + + # @property + # def str_ids(self) -> str: + # """Return an SQL formatted tuple string of the :class:`.PoseModel` IDs""" + # return str(tuple(self.ids)).replace(',)', ')') + + @property + def targets(self) -> QuerySet[TargetModel]: + """Returns the :class:`.TargetModel` objects of poses in this set""" + return TargetModel.objects.filter(pk__in=self._queryset.values('target')) + + @property + def target_names(self) -> list[str]: + """Returns the :class:`.TargetModel` objects of poses in this set""" + return self.targets.values_list('target_name', flat=True) + + @property + def target_ids(self) -> list[int]: + """Returns the :class:`.TargetModel` objects ID's of poses in this set""" + return self.targets.values_list('id', flat=True) + + @property + def best_placed_pose(self) -> PoseModel: + """Returns the pose with the best distance_score in this subset""" + return self._queryset.get(pk=self.best_placed_pose_id) + + @property + def best_placed_pose_id(self) -> int: + """Get the id of the pose with the best distance_score in this subset""" + + # if len(self) == 1: + # return self.ids[0] + + # query = 'pose_id, MIN(pose_distance_score)' + # query = self.db.select_where( + # table='pose', query=query, + # key=f'pose_id in {self.str_ids}', multiple=False + # ) + # return query[0] + + # TODO: scoring not implemented yet + return self.queryset.first().pk + + @property + def interactions(self) -> 'InteractionSet': + """Get a :class:`.InteractionSet` for this :class:`.PoseModel`""" + if self._interactions is None: + self._interactions = InteractionSet.from_pose(self) + return self._interactions + + @property + def pose_id_metadata_dict(self) -> dict[int, dict]: + """Get a dictionary mapping pose_ids to metadata dicts""" + if self._metadata_dict is None: + metadata = {} + for p in self._queryset: + metadata[p.pk] = p.pose_metadata + self._metadata_dict = metadata + return self._metadata_dict + + @property + def fraction_fingerprinted(self) -> float: + """Return the fraction of fingerprinted poses in this set""" + return self.num_fingerprinted / len(self) + + @property + def num_subsites(self) -> int: + """Count the number of subsites that poses in this set come into contact with""" + return ( + SubsiteModel.objects.filter(posemodels__in=self._queryset) + .distinct() + .count() + ) + + @property + def subsite_balance(self) -> float: + """Measure of how evenly subsite counts are distributed across poses in this + set""" + # TODO: subsites not implemented yet + # from numpy import std + + # sql = f""" + # SELECT COUNT(DISTINCT subsite_tag_ref) + # FROM {self.db.SQL_SCHEMA_PREFIX}subsite_tag + # WHERE subsite_tag_pose IN {self.str_ids} + # GROUP BY subsite_tag_pose + # """ + + # counts = self.db.execute(sql).fetchall() + + # counts = [c for (c,) in counts] + [0 for _ in range(len(self) - len(counts))] + + # return -std(counts) + return 4 + + @property + def subsite_ids(self) -> set[int]: + """Return a list of subsite id's of member poses""" + return SubsiteModel.objects.filter( + pk__in=SubsiteTagModel.objects.filter( + pose__in=self._queryset, + ).values('subsite'), + ).values_list('pk', flat=True) + + @property + def avg_energy_score(self) -> float: + """Average energy score of poses in this set""" + # TODO: scores not implemented + # from numpy import mean + + # sql = f""" + # SELECT pose_energy_score + # FROM {self.db.SQL_SCHEMA_PREFIX}pose + # WHERE pose_id IN {self.str_ids} + # """ + + # scores = self.db.execute(sql).fetchall() + # return mean([s for (s,) in scores if s is not None]) + return 4 + + @property + def avg_distance_score(self) -> float: + """Average distance score of poses in this set""" + # TODO: scores not implemented yet + # from numpy import mean + + # sql = f""" + # SELECT pose_distance_score + # FROM {self.db.SQL_SCHEMA_PREFIX}pose + # WHERE pose_id IN {self.str_ids} + # """ + + # scores = self.db.execute(sql).fetchall() + + # return mean([s for (s,) in scores if s is not None]) + return 4 + + @property + def derivatives(self) -> 'PoseSet': + """Get the :class:`.PoseSet` of derivatives""" + return PoseSet( + PoseModel.objects.filter( + pk__in=InspirationModel.objects.filter( + original_pose__in=self._queryset, + ).values( + 'derivative_pose', + ), + ), + ) + + @property + def reference(self): + """Bulk set the references for poses in this set""" + raise NotImplementedError( + 'This attribute only allows setting, ``PoseSet.reference = ...``' + ) + + @reference.setter + def reference(self, r) -> None: + """Bulk set the references for poses in this set""" + self._queryset.update(pose_reference=r) + + ### PRIVATE + + def _delete(self, *, force: bool = False) -> None: + """Delete poses in this set""" + + if not force: + mrich.warning('Deleting Poses is risky! Set force=True to continue') + return + + try: + with transaction.atomic(): + InspirationModel.objects.filter( + original_pose__in=self._queryset + ).delete() + InspirationModel.objects.filter( + derivative_pose__in=self._queryset + ).delete() + SubsiteTagModel.objects.filter(pose__in=self._queryset).delete() + InteractionModel.objects.filter(pose__in=self._queryset).delete() + self._queryset.delete() + except IntegrityError as exc: + mrich.error(exc) diff --git a/hippo/designdb/sets/reaction.py b/hippo/designdb/sets/reaction.py new file mode 100644 index 00000000..9e54b7d3 --- /dev/null +++ b/hippo/designdb/sets/reaction.py @@ -0,0 +1,383 @@ +"""Classes for working with sets of :class:`.ReactionModel` objects""" + +import mcol +import mrich +import pandas as pd +from designdb.models import CompoundModel, ReactantModel, ReactionModel +from designdb.sets.compound import CompoundSet +from django.db.models import Q +from IPython.display import display +from ipywidgets import ( + BoundedIntText, + Checkbox, + GridBox, + Layout, + VBox, + interactive_output, +) + + +class ReactionSet: + """Object representing a subset of the 'reaction' table in the :class:`.Database`. + + .. attention:: + + :class:`.ReactionSet` objects should not be created directly. Instead use + the :meth:`.HIPPO.reactions` property. See :doc:`getting_started` and + :doc:`insert_elaborations`. + + Use as an iterable + ================== + + Iterate through :class:`.ReactionModel` objects in the set: + + :: + + rset = animal.reactions[:100] + + for reaction in rset: + ... + + Check membership + ================ + + To determine if a :class:`.ReactionModel` is present in the set: + + :: + + is_member = reaction in cset + + Selecting compounds in the set + ============================== + + The :class:`.ReactionSet` can be indexed like standard Python lists by their indices + + :: + + rset = animal.reactions[1:100] + + # indexing individual compounds + reaction = rset[0] # get the first reaction + reaction = rset[1] # get the second reaction + reaction = rset[-1] # get the last reaction + + # getting a subset of compounds using a slice + rset2 = rset[13:18] # using a slice + + """ + + def __init__( + self, + queryset=None, + *, + sort: bool = True, + name: str | None = None, + ) -> None: + """ReactionSet initialisation""" + + if queryset: + if isinstance(queryset, list): + self._queryset = ReactionModel.objects.filter(pk__in=queryset) + else: + self._queryset = queryset + else: + self._queryset = ReactionModel.objects.none() + + self._name = name + if sort: + self._queryset = self._queryset.order_by('pk') + + def __str__(self) -> str: + """Unformatted string representation""" + + if self.name: + s = f'{self.name}: ' + else: + s = '' + + s += f'{{R × {len(self)}}}' + + return s + + def __repr__(self) -> str: + """ANSI Formatted string representation""" + return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' + + def __rich__(self) -> str: + """Rich Formatted string representation""" + return f'[bold underline]{self}' + + def __len__(self) -> int: + """Number of member :class:`.ReactionModel` objects""" + return self._queryset.count() + + def __iter__(self): + """Iterate through member :class:`.ReactionModel` objects""" + return iter(self._queryset) + + def __getitem__(self, key) -> 'ReactionModel | ReactionSet': + """Get member :class:`.ReactionModel` object by single, slice or + list/set/tuple of ID""" + + match key: + case int(): + try: + # reaction = ReactionModel.objects.get(pk=key) + reaction = self._queryset[key] + except ReactionModel.DoesNotExist as exc: + mrich.error(f'list index out of range: {key=} for {self}') + raise ReactionModel.DoesNotExist from exc + + return reaction + + case slice(): + return ReactionSet(ReactionModel.objects.filter(pk__in=key)) + + case _: + mrich.error( + f'Unsupported type for ReactionSet.__getitem__():' + f' {key=} {type(key)}' + ) + + return None + + def __add__(self, other: 'ReactionSet') -> 'ReactionSet': + """Add a :class:`.ReactionSet` to this one""" + if other: + return ReactionSet( + ReactionModel.objects.filter( + Q(pk__in=self._queryset) | Q(pk__in=other.queryset) + ), + sort=False, + ) + + def __sub__( + self, + other: 'ReactionSet', + ) -> 'ReactionSet': + """Substract a :class:`.ReactionSet` from this set""" + match other: + case ReactionSet(): + return ReactionSet( + ReactionModel.objects.filter( + Q(pk__in=self._queryset) & ~Q(pk__in=other.queryset) + ), + sort=False, + ) + + ### METHODS + + def add(self, r: ReactionModel) -> None: + """Add a :class:`.ReactionModel` to this set + + :param r: :class:`.ReactionModel` to be added + + """ + assert isinstance(r, ReactionModel) + self._queryset = ReactionModel.objects.filter( + pk__in=list(self._queryset.values_list('pk', flat=True)) + [r.pk], + ) + + def interactive(self): + """Creates a ipywidget to interactively navigate this PoseSet.""" + + a = BoundedIntText( + value=0, + min=0, + max=len(self) - 1, + step=1, + description=f'Rs (/{len(self)}):', + disabled=False, + ) + + b = Checkbox(description='Name', value=True) + c = Checkbox(description='Summary', value=False) + d = Checkbox(description='Draw', value=True) + e = Checkbox(description='Check chemistry', value=False) + f = Checkbox(description='ReactantModel Quotes', value=False) + + ui1 = GridBox( + [b, c, d], layout=Layout(grid_template_columns='repeat(5, 100px)') + ) + ui2 = GridBox([e, f], layout=Layout(grid_template_columns='repeat(2, 150px)')) + ui = VBox([a, ui1, ui2]) + + def widget( + i, name=True, summary=True, draw=True, check_chemistry=True, reactants=False + ): + """ + + :param i: + :param name: (Default value = True) + :param summary: (Default value = True) + :param draw: (Default value = True) + :param check_chemistry: (Default value = True) + :param reactants: (Default value = False) + + """ + reaction = self[i] + if name: + print(repr(reaction)) + if summary: + reaction.summary(draw=False) + if draw: + reaction.draw() + if check_chemistry: + reaction.check_chemistry(debug=True) + if reactants: + for comp in reaction.reactants: + # if summary: + # comp.summary(draw=False) + # elif name: + print(repr(comp)) + + quotes = comp.get_quotes(df=True) + display(quotes) + + # break + + # if draw: + # comp.draw() + + out = interactive_output( + widget, + { + 'i': a, + 'name': b, + 'summary': c, + 'draw': d, + 'check_chemistry': e, + 'reactants': f, + }, + ) + + display(ui, out) + + def get_df(self, smiles=True, mols=True, **kwargs) -> pd.DataFrame: + """Construct a pandas.DataFrame of this ReactionSet + + :param smiles: Include smiles column (Default value = True) + :param mols: Include `rdkit.Chem.Mol` column (Default value = True) + :param kwargs: keyword arguments are passed on to + :meth:`.ReactionModel.get_dict:` + + """ + + mrich.debug('Using slower ReactionModel.dict rather than direct SQL query...') + + data = [] + for r in mrich.track(self, prefix='ReactionSet --> DataFrame'): + data.append(r.get_dict(smiles=smiles, mols=mols, **kwargs)) + + return pd.DataFrame(data) + + def copy(self) -> 'ReactionSet': + """Return a copy of this set""" + return ReactionSet(self._queryset.all(), sort=False, name=self.name) + + def reverse(self) -> None: + """Reverse the ordering of this set in-place""" + self._queryset = self._queryset.reverse() + + def get_recipes(self, amounts: float | list[float] = 1.0, **kwargs): + """Get the :class:`.Recipe` object(s) from this set of recipes + + :param amounts: float or list/generator of product amounts in mg, + (Default value = 1.0) + :param kwargs: keyword arguments are passed on to + :meth:`.RecipeService.from_reactions` + + """ + # convenience bridge to the service layer + from designdb.services.recipe import RecipeService + + return RecipeService.from_reactions(reactions=self, amount=amounts, **kwargs) + + def summary(self) -> None: + """Print a summary of the Reactions""" + + mrich.header(self) + for reaction in self: + print(repr(reaction)) + + ### PROPERTIES + + @property + def name(self) -> str | None: + """Returns the name of set""" + return self._name + + @property + def queryset(self): + """Returns the underlying Django queryset""" + return self._queryset + + @property + def indices(self) -> list[int]: + """Returns the ids of reactions in this set""" + return list(self._queryset.values_list('pk', flat=True)) + + @property + def ids(self) -> list[int]: + """Returns the ids of reactions in this set""" + return list(self.indices) + + @property + def types(self) -> list[str]: + """Returns the unique reaction types in this set""" + return list(self._queryset.values_list('reaction_type', flat=True).distinct()) + + @property + def num_types(self) -> int: + """Returns the number of reaction types in this set""" + return self._queryset.values('reaction_type').distinct().count() + + @property + def products(self) -> CompoundSet: + """Get all product compounds that can be synthesised with these reactions + (no intermediates)""" + + qs = CompoundModel.objects.filter( + pk__in=self._queryset.values('product_compound'), + ).exclude( + pk__in=self.intermediates.queryset.values('pk'), + ) + cset = CompoundSet(qs) + if self.name: + cset._name = f'products of {self}' + return cset + + @property + def intermediates(self) -> CompoundSet: + """Get all intermediate compounds that can be synthesised with these + reactions""" + + # NB! not 100% sure about this queryset + qs = CompoundModel.objects.filter( + Q( + pk__in=ReactantModel.objects.values('compound'), + ) + & Q(pk__in=self._queryset.values('product_compound')), + ) + cset = CompoundSet(qs) + + if self.name: + cset._name = f'intermediates of {self}' + return cset + + @property + def reactants(self) -> 'CompoundSet': + """Get all reactant compounds that are used by these reactions""" + + qs = ReactantModel.objects.filter( + reaction__in=self._queryset, + ).values('compound') + cset = CompoundSet(qs) + if self.name: + cset._name = f'reactants of {self}' + return cset + + @property + def get_dict(self) -> dict[str]: + """Serializable dictionary""" + return dict(indices=self.indices) diff --git a/hippo/designdb/sets/route.py b/hippo/designdb/sets/route.py new file mode 100644 index 00000000..8ff1cb6a --- /dev/null +++ b/hippo/designdb/sets/route.py @@ -0,0 +1,401 @@ +import json +from typing import TYPE_CHECKING + +import mcol +import mrich +from designdb.models import ComponentModel, RouteModel +from designdb.sets.compound import CompoundSet + +if TYPE_CHECKING: + from pathlib import Path + + from designdb.recipe import Route + + +class RouteSet: + """A set of Route objects""" + + def __init__(self, routes: 'list[Route]') -> None: + """RouteSet initialisation""" + + data = {} + for route in routes: + # assert isinstance(route, Route) + data[route.id] = route + + self._data = data + self._cluster_map = None + self._permitted_clusters = None + self._current_cluster = None + + ### FACTORIES + + @classmethod + def from_ids(cls, ids: list | set, progress: bool = True): + """Generate a routeset from a set of :class:`.RouteModel` IDs + + :param db: database to link + :param ids: :class:`.RouteModel` database IDs + :param progress: show progress bar + """ + + # this gets stuck + # if progress: + # ids = mrich.track(ids, prefix='Getting routes') + + # avoiding circular reference + # avoiding name conflict + from designdb.recipe import Route + + routes = [Route.get_route(id=r) for r in ids] + + # self = cls.__new__(cls) + return RouteSet(routes) + + @classmethod + def from_product_ids(cls, ids: list | set, progress: bool = True): + """Generate a routeset from a set of product :class:`.CompoundModel` IDs + + :param db: database to link + :param ids: :class:`.CompoundModel` database IDs + """ + + # str_ids = str(tuple(ids)).replace(',)', ')') + + # records = db.select_where( + # table='route', + # query='route_id', + # key=f'route_product IN {str_ids}', + # multiple=True, + # ) + records = RouteModel.objects.filter( + product_compound__pk__in=ids, + ) + + # route_ids = [i for (i,) in records] + + return cls.from_ids(records.values_list('id', flat=True), progress=progress) + + @classmethod + def from_json(cls, path: 'str | Path', data: dict = None) -> 'RouteSet': + """Load a serialised routeset from a JSON file + + :param db: database to link + :param path: path to JSON + :param data: serialised data (Default value = None) + + """ + + from designdb.recipe import Route + + self = cls.__new__(cls) + + if data is None: + data = json.load(open(path)) + + new_data = {} + for d in mrich.track(data['routes'].values(), prefix='Loading Routes...'): + route_id = d['id'] + new_data[route_id] = Route.from_json(path=None, data=d) + + self._data = new_data + self._cluster_map = None + self._permitted_clusters = None + self._current_cluster = None + + return self + + ### PROPERTIES + + @property + def data(self) -> 'dict[int, RouteModel]': + """Get internal data dictionary""" + return self._data + + @property + def db(self): + """Deprecated: the modern ORM RouteSet has no associated ``db`` handle. + + Raises to surface any remaining legacy ``self.db`` callers explicitly. + """ + raise NotImplementedError( + 'RouteSet no longer holds a `db` handle; use the Django ORM directly' + ) + + @property + def routes(self) -> 'list[Route]': + """Get route objects""" + return self.data.values() + + @property + def product_ids(self) -> list[int]: + """Get the :class:`.CompoundModel` ID's of the products""" + return RouteModel.objects.values_list( + 'product_compound__id', flat=True + ).distinct() + + @property + def reactant_ids(self) -> list[int]: + """Get the :class:`.CompoundModel` ID's of the reactants""" + + return ComponentModel.objects.filter( + route__in=self.ids, + component_type=2, + ).values_list('component_ref', flat=True) + + @property + def products(self) -> 'CompoundSet': + """Return a :class:`.CompoundSet` of all the route products""" + return CompoundSet(self.product_ids) + + @property + def reactants(self) -> 'CompoundSet': + """Return a :class:`.CompoundSet` of all the route reactants""" + return CompoundSet(self.reactant_ids) + + @property + def str_ids(self) -> str: + """Return an SQL formatted tuple string of the :class:`.RouteModel` ID's""" + return str(tuple(self.ids)).replace(',)', ')') + + @property + def ids(self) -> list[int]: + """Return the :class:`.RouteModel` IDs""" + return self.data.keys() + + @property + def cluster_map(self) -> dict[tuple, set]: + """Create a dictionary grouping routes by their scaffold/base cluster. + + :returns: A dictionary mapping a tuple of scaffold :class:`.CompoundModel` IDs + to a set of :class:`.RouteModel` ID's to their superstructures. + """ + + # NOTE: scaffold-cluster grouping depends on the not-yet-ported + # `get_compound_cluster_dict` machinery. This property is only consumed by + # `balanced_pop`, which in turn is only used by the (out-of-scope) rgen + # RandomRecipeSelectionGenerator. Port alongside rgen. + raise NotImplementedError( + 'RouteSet.cluster_map requires the unported compound-clustering helper ' + '(get_compound_cluster_dict); port it together with the rgen subsystem' + ) + + ### METHODS + + def copy(self) -> 'RouteSet': + """Copy this RouteSet""" + return RouteSet(list(self.data.values())) + + def get_dict(self): + """Get serialisable dictionary""" + + data = dict(routes={}) + + # populate with routes + for route_id, route in self.data.items(): + data['routes'][route_id] = route.get_dict() + + return data + + def prune_unavailable(self, suppliers: list[str]): + """Remove routes that don't have all reactants available from given suppliers. + + Keeps only routes where every reactant component (``component_type == 2``) + has at least one catalogue price from one of the given ``suppliers``. + """ + + from designdb.models import CataloguePriceCompoundJunctionModel + + # compound IDs quotable from the permitted suppliers + quotable = set( + CataloguePriceCompoundJunctionModel.objects.filter( + catalogue_price__supplier__in=list(suppliers), + ).values_list('compound_id', flat=True) + ) + + # reactant components grouped by route + reactant_rows = ComponentModel.objects.filter( + route_id__in=list(self.ids), + component_type=2, + ).values_list('route_id', 'component_ref') + + route_reactants: dict[int, set[int]] = {} + for route_id, ref in reactant_rows: + route_reactants.setdefault(route_id, set()).add(ref) + + kept = [ + route_id + for route_id, reactants in route_reactants.items() + if reactants <= quotable + ] + + mrich.var('#routes before pruning', len(self)) + mrich.var('#routes after pruning', len(kept)) + + return RouteSet.from_ids(kept) + + def pop_id(self) -> int: + """Pop the last route from the set and return it's id""" + route_id, route = self.data.popitem() + return route_id + + def pop(self) -> 'RouteModel': + """Pop the last route from the set and return it's object""" + route_id, route = self.data.popitem() + return route + + def balanced_pop( + self, permitted_clusters: set[tuple] | None = None, debug: bool = False + ) -> 'RouteModel': + """Pop a route from this set, while maintaining the balance of scaffold + clusters populations""" + + if not self._data: + mrich.print('RouteSet depleted') + return None + + if not self.cluster_map: + # mrich.warning("RouteSet.cluster_map depleted but _data isn't...") + return self.pop() + + # store the permitted clusters (or all clusters) list as property + + if self._permitted_clusters is None: + if permitted_clusters: + permitted_clusters = set( + (cluster,) if isinstance(cluster, int) else cluster + for cluster in permitted_clusters + ) + + self._permitted_clusters = [] + for cluster in permitted_clusters: + if cluster not in self.cluster_map: + mrich.warning( + cluster, 'in permitted_clusters but not cluster_map' + ) + else: + self._permitted_clusters.append(cluster) + + else: + self._permitted_clusters = list(self.cluster_map.keys()) + + if self._current_cluster is None: + self._current_cluster = self._permitted_clusters[0] + + ### pop a RouteModel + + if debug: + mrich.debug(f'Would pop RouteModel from {self._current_cluster=}') + + cluster = self._current_cluster + + # pop the last route id from the given cluster + + try: + route_id = self.cluster_map[cluster].pop() + except IndexError: + mrich.print(self._permitted_clusters) + mrich.print(self.cluster_map) + raise + except AttributeError: + mrich.print(cluster) + mrich.print(self.cluster_map) + raise + except KeyError: + mrich.print('cluster', cluster) + mrich.print('self._permitted_clusters', self._permitted_clusters) + mrich.print('self.cluster_map.keys()', self.cluster_map.keys()) + raise + + # clean up empty clusters + + if debug: + mrich.debug('Popped route', route_id) + + # get the RouteModel object + + if route_id in self._data: + route = self._data[route_id] + del self._data[route_id] + else: + # if debug: + mrich.debug('RouteModel not present') + return self.balanced_pop() + + ### increment cluster + + # def increment_cluster(cluster): + n = len(self._permitted_clusters) + if n > 1: + for i, cluster in enumerate(self._permitted_clusters): + if cluster == self._current_cluster: + if i == n - 1: + self._current_cluster = self._permitted_clusters[0] + else: + self._current_cluster = self._permitted_clusters[i + 1] + break + else: + raise IndexError('This should never be reached...') + + # increment_cluster() + + if not self.cluster_map[cluster]: + del self.cluster_map[cluster] + if not self.cluster_map: + mrich.debug('RouteSet.cluster_map depleted') + self._permitted_clusters = [ + c for c in self._permitted_clusters if c != cluster + ] + # if debug: + mrich.debug('Depleted cluster', cluster) + + if not self._permitted_clusters: + mrich.debug('Depleted all permitted clusters', cluster) + mrich.debug('Removing cluster restriction', cluster) + self._permitted_clusters = list(self.cluster_map.keys()) + self._current_cluster = None + + if debug: + mrich.debug('#Routes in set', len(self._data)) + + return route + + def shuffle(self): + """Randomly shuffle the routes in this set""" + import random + + items = list(self.data.items()) + random.shuffle(items) + self._data = dict(items) + + ### shuffle the cluster map as well + + for cluster, routes in self.cluster_map.items(): + random.shuffle(routes) + self.cluster_map[cluster] = routes + + ### DUNDERS + + def __len__(self) -> int: + """Number of routes in this set""" + return len(self.data) + + def __str__(self) -> str: + """Unformatted string representation""" + return f'{{RouteModel × {len(self)}}}' + + def __repr__(self) -> str: + """ANSI Formatted string representation""" + return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' + + def __rich__(self) -> str: + """Rich Formatted string representation""" + return f'[bold underline]{self}' + + def __iter__(self): + """Iterate over routes in this set""" + return iter(self.data.values()) + + def __getitem__(self, key): + """Get a specific route in this set""" + return list(self.data.values())[key] diff --git a/hippo/designdb/settings.py b/hippo/designdb/settings.py new file mode 100644 index 00000000..0fb2130e --- /dev/null +++ b/hippo/designdb/settings.py @@ -0,0 +1 @@ +DEFAULT_POSE_METHODS: list[str] = ['xray'] diff --git a/hippo/designdb/tests.py b/hippo/designdb/tests.py new file mode 100644 index 00000000..a39b155a --- /dev/null +++ b/hippo/designdb/tests.py @@ -0,0 +1 @@ +# Create your tests here. diff --git a/hippo/designdb/utils.py b/hippo/designdb/utils.py new file mode 100644 index 00000000..78afec54 --- /dev/null +++ b/hippo/designdb/utils.py @@ -0,0 +1,366 @@ +"""Generic tools for use in the HIPPO package""" + +import ast +import json +import re +from datetime import datetime +from string import ascii_uppercase +from typing import TYPE_CHECKING + +import mcol +import molparse as mp +import mrich +import numpy as np +from django.db.models import Aggregate, OuterRef, Subquery +from molparse.rdkit import mol_from_smiles +from rdkit import Chem +from rdkit.Chem import AddHs, MolFromSmiles, MolToSmiles, RegistrationHash, RemoveHs +from rdkit.Chem.inchi import MolToInchiKey +from rdkit.Chem.MolStandardize import rdMolStandardize + +if TYPE_CHECKING: + from designdb.models import PoseModel + + +def strip_sql(sql) -> str: + """Reduce unecessary whitespace in SQL""" + return re.sub(r'\s+', ' ', sql).strip() + + +def df_row_to_dict(df_row) -> dict: + """Convert a dataframe row to a dictionary + + :param df_row: pandas dataframe row / series + """ + + assert len(df_row) == 1, f'{len(df_row)=}' + + data = {} + + for col in df_row.columns: + if col == 'Unnamed: 0': + continue + + value = df_row[col].values[0] + + if not isinstance(value, str) and np.isnan(value): + value = None + + data[col] = value + + return data + + +def remove_other_ligands(sys: mp.System, residue_number: int, chain: str) -> mp.System: + """Remove ligands other than the specified one""" + + ligand_residues = [r.number for r in sys['rLIG'] if r.number != residue_number] + + # if ligand_residues: + for c in sys.chains: + if c.name != chain: + c.remove_residues(names=['LIG'], verbosity=0) + elif ligand_residues: + c.remove_residues(numbers=ligand_residues, verbosity=0) + + # print([r.name_number_str for r in sys['rLIG']]) + + assert len([r.name_number_str for r in sys['rLIG']]) == 1, ( + f'{sys.name} {[r.name_number_str for r in sys["rLIG"]]}' + ) + + return sys + + +def inchikey_from_smiles(smiles: str) -> str: + """InChI-Key from smiles string""" + mol = mol_from_smiles(smiles) + return MolToInchiKey(mol) + + +def flat_inchikey(smiles: str) -> str: + """Stereochemistry-flattened InChI-Key from smiles string""" + smiles = sanitise_smiles(smiles) + return inchikey_from_smiles(smiles) + + +def remove_isotopes_from_smiles(smiles: str) -> str: + """Remove isotopes from smiles string""" + + mol = MolFromSmiles(smiles) + + atom_data = [(atom, atom.GetIsotope()) for atom in mol.GetAtoms()] + + for atom, isotope in atom_data: + if isotope: + atom.SetIsotope(0) + + return MolToSmiles(mol) + + +def smiles_has_isotope(smiles: str, regex=True) -> bool: + """Does provided smiles string contain isotopes?""" + if regex: + return re.search(r'([\[][0-9]+[A-Z]+\])', smiles) + else: + mol = MolFromSmiles(smiles) + return any(atom.GetIsotope() for atom in mol.GetAtoms()) + + +REPLACE = { + '[STB]': '[S]', +} + + +def sanitise_smiles( + s: str, + verbosity: bool = False, + sanitisation_failed: str = 'error', + radical: str = 'error', +) -> str: + """Sanitise smiles by: + + - Taking largest fragment + - Flattening stereochemistry + - Removing isotopes + - RDKit round-trip + - Treating radicals + + :param s: input smiles string + :param verbosity: print smiles changes (Default value = False) + :param sanitisation_failed: behvaiour when sanitisation fails, + choose from ["error", "warning", "quiet"] (Default value = 'error') + :param radical: behvaiour when radicals occur, choose from + ["error", "warning", "remove"] (Default value = 'error') + :returns: SMILES string + """ + + assert isinstance(s, str), f'non-string smiles={s}' + + orig_smiles = s + + # if multiple molecules take the largest + if '.' in s: + s = sorted(s.split('.'), key=lambda x: len(x))[-1] + + # flatten the smiles + # stereo_smiles = s + smiles = s.replace('@', '') + smiles = smiles.replace('/', '') + smiles = smiles.replace('\\', '') + + # remove isotopic stuff + if smiles_has_isotope(smiles): + mrich.warning(f'Isotope(s) in SMILES: {smiles}') + smiles = remove_isotopes_from_smiles(smiles) + + # replace specific sequences + for key in REPLACE: + if key in smiles: + smiles = smiles.replace(key, REPLACE[key]) + + # canonicalise + mol = MolFromSmiles(smiles) + if mol: + smiles = MolToSmiles(mol, True) + elif sanitisation_failed == 'error': + raise SanitisationError + elif sanitisation_failed == 'warning': + mrich.warning(f'sanitisation failed for {smiles=}') + + # check radicals + reconstruct = False + for atom in mol.GetAtoms(): + if not atom.GetNumRadicalElectrons(): + continue + + if radical == 'warning': + mrich.warning(f'Radical atom in {smiles=}') + elif radical == 'error': + raise SanitisationError(f'Radical atom in {smiles=}') + elif radical == 'remove': + mrich.warning('Removed radical atom') + atom.SetNumRadicalElectrons(0) + smiles = MolToSmiles(mol, True) + reconstruct = True + # atom.SetFormalCharge(0) + else: + raise NotImplementedError(f'Unknown option {radical=}') + + if reconstruct: + mol = AddHs(mol) + mol = RemoveHs(mol, implicitOnly=True) + smiles = MolToSmiles(mol, True) + mrich.warning(f'New {smiles=}') + + if verbosity: + if smiles != orig_smiles: + annotated_smiles_str = orig_smiles.replace( + '.', f'{mcol.error}{mcol.underline}.{mcol.clear}{mcol.warning}' + ) + annotated_smiles_str = annotated_smiles_str.replace( + '@', f'{mcol.error}{mcol.underline}@{mcol.clear}{mcol.warning}' + ) + + mrich.warning(f'SMILES was changed: {annotated_smiles_str} --> {smiles}') + + return smiles + + +def sanitise_mol(m: Chem.rdchem.Mol) -> Chem.rdchem.Mol: + """Sanitise by RDKit round-trip""" + from rdkit.Chem import MolFromMolBlock, MolToMolBlock + + return MolFromMolBlock(MolToMolBlock(m)) + + +def pose_gap(a: 'PoseModel', b: 'PoseModel') -> float: + """Calculate minimum distance between two :class:`.PoseModel` objects""" + + from molparse.rdkit import mol_to_AtomGroup + from numpy.linalg import norm + + # avoiding circular imports + + min_dist = None + + a = mol_to_AtomGroup(a.mol) + b = mol_to_AtomGroup(b.mol) + + for atom1 in a.atoms: + for atom2 in b.atoms: + dist = norm(atom1.np_pos - atom2.np_pos) + if min_dist is None or dist < min_dist: + min_dist = dist + + return min_dist + + +ALPHANUMERIC_CHARS = '0123456789' + ascii_uppercase + + +def number_to_base(n: int, b: int) -> int: + """Convert an integer `n` into base `b` representation""" + if n == 0: + return [0] + digits = [] + while n: + digits.append(int(n % b)) + n //= b + return digits[::-1] + + +def dt_hash() -> str: + """Create 7 alphanumeric-character hash based on current timestamp""" + dt = datetime.now() + x = int( + dt.month * 36000 * 24 * 365.25 + + dt.day * 36000 * 24 + + dt.hour * 36000 + + dt.minute * 600 + + dt.second * 10 + + dt.microsecond / 10000 + ) + timehash = ''.join([ALPHANUMERIC_CHARS[v] for v in number_to_base(x, 36)]) + return f'{timehash:>07}' + + +class SanitisationError(Exception): + """Something went wrong in Molecule/SMILES sanitisation""" + + ... + + +def make_warn_once_per_key(): + """Warn once per field type in sdf file. + + When attribute is defined but broken in all molecules, no need to + complain every time. + + Instatiate at the beginning of the loading process and pass where + needed. + + """ + warned = set() + + def warn(key, msg): + if key not in warned: + print(f'WARNING: {msg}') + warned.add(key) + + return warn + + +# TODO: move +class ScoreSubquery(Subquery): + def __init__(self, scoring_method): + # avoiding circular imports + from .models import ScoreValueModel + + query = ScoreValueModel.objects.filter( + pose=OuterRef('pk'), + compound=OuterRef('compound'), + scoring_method__method_name=scoring_method, + ).values('score')[:1] + super().__init__(query) + + +# Don't understand the distinct here. Shouldn't have to use it. +# Workaround for missing ArrayAgg in sqlite, can get rid of when +# moving to postgres +class JsonGroupArray(Aggregate): + function = 'json_group_array' + # template = "%(function)s(%(expressions)s)" + template = '%(function)s(DISTINCT %(expressions)s)' + + +def normalize_string_list(x): + """Convert string representation of list to proper list""" + if not x: + return [] + if isinstance(x, list): + # return list(set(x)) + return x + if isinstance(x, str): + # try JSON first + try: + parsed = json.loads(x) + if isinstance(parsed, list): + # return list(set(parsed)) + return parsed + except Exception: + pass + + # fallback for python-style strings + try: + parsed = ast.literal_eval(x) + if isinstance(parsed, list): + # return list(set(parsed)) + return parsed + except Exception: + pass + + # ultimate fallback, comma-separated string + try: + splits = x.split(',') + if isinstance(splits, list): + return splits + except Exception: + pass + return [] + + +def superparent(mol: Chem.Mol) -> Chem.Mol: + return rdMolStandardize.SuperParent(mol) + + +def registration_hash_tautomer_insensitive(mol: Chem.Mol) -> str: + layers = RegistrationHash.GetMolLayers( + mol, + escape='', + enable_tautomer_hash_v2=True, + ) + return RegistrationHash.GetMolHash( + layers, + RegistrationHash.HashScheme.TAUTOMER_INSENSITIVE_LAYERS, + ) diff --git a/hippo/designdb/utils_chem.py b/hippo/designdb/utils_chem.py new file mode 100644 index 00000000..c8e1f434 --- /dev/null +++ b/hippo/designdb/utils_chem.py @@ -0,0 +1,414 @@ +"""functions for validating chemistry""" + +import logging +from typing import TYPE_CHECKING + +import mrich +from designdb.models import CompoundModel +from rdkit import Chem +from rdkit.Chem import rdMolAlign + +if TYPE_CHECKING: + from designdb.sets.compound import CompoundSet + +""" + +Checks +====== + +- Num heavy atoms difference +- Formula checks +- Num rings difference + +""" + +logger = logging.getLogger(__name__) + +SUPPORTED_CHEMISTRY = { + 'Amidation': { + 'heavy_atoms_diff': 1, + 'rings_diff': 0, + 'atomtype': { + 'removed': {'O': 1, 'H': 2}, + }, + }, + 'Ester_amidation': { + 'heavy_atoms_diff': '>=3', + 'rings_diff': 0, + 'atomtype': { + 'removed': {'O': '>=1', '*': '*'}, + }, + }, + 'Williamson_ether_synthesis': { + 'heavy_atoms_diff': 1, + 'rings_diff': 0, + 'atomtype': { + 'removed': {'Ha': 1, 'H': 1}, # any halogen + }, + }, + 'N-Boc_deprotection': { + 'heavy_atoms_diff': 7, + 'rings_diff': 0, + 'atomtype': { + 'removed': {'O': 2, 'C': 5, 'H': 8}, + }, + }, + 'TBS_alcohol_deprotection': { + 'heavy_atoms_diff': 7, + 'rings_diff': 0, + 'atomtype': { + 'removed': {'C': 6, 'Si': 1, 'H': 14}, + }, + }, + 'Sp3-sp2_Suzuki_coupling': { + # "heavy_atoms_diff": 10, + 'heavy_atoms_diff': '>=4', + 'rings_diff': '>=0', + 'atomtype': { + # "removed": {"C": 6, "O": 2, "B": 1, "Ha": 1, "H": 12}, # any halogen + 'removed': {'C': '>=0', 'O': 2, 'B': 1, 'Ha': 1, 'H': '>=2'}, # any halogen + }, + }, + 'Sp2-sp2_Suzuki_coupling': { + 'heavy_atoms_diff': '>=4', + 'rings_diff': '>=0', + 'atomtype': { + 'removed': {'C': '>=0', 'O': 2, 'B': 1, 'Ha': 1, 'H': '>=2'}, # any halogen + }, + }, + 'Buchwald-Hartwig_amidation_with_amide-like_nucleophile': { + 'heavy_atoms_diff': 1, + 'rings_diff': 0, + 'atomtype': { + 'removed': {'Ha': 1, 'H': 1}, + }, + }, + 'Buchwald-Hartwig_amination': { + 'heavy_atoms_diff': 1, + 'rings_diff': 0, + 'atomtype': { + 'removed': {'Ha': 1, 'H': 1}, + }, + }, + 'Nucleophilic_substitution_with_amine': { + 'heavy_atoms_diff': 1, + 'rings_diff': 0, + 'atomtype': { + 'removed': {'Ha': 1, 'H': 1}, + }, + }, + 'N-nucleophilic_aromatic_substitution': { + 'heavy_atoms_diff': 1, + 'rings_diff': 0, + 'atomtype': { + 'removed': {'Ha': 1, 'H': 1}, # any halogen + }, + }, + 'Reductive_amination': { + 'heavy_atoms_diff': 1, + 'rings_diff': 0, + 'atomtype': { + 'removed': {'O': 1}, # any halogen + }, + }, + 'Mitsunobu_reaction_with_amine_alcohol_and_thioalcohol': { + 'heavy_atoms_diff': 1, + 'rings_diff': 0, + 'atomtype': { + 'removed': {'O': 1, 'H': '>=1'}, + }, + }, + 'Steglich_esterification': { + 'heavy_atoms_diff': 1, + 'rings_diff': 0, + 'atomtype': { + 'removed': {'O': 1, 'H': 2}, + }, + }, + 'Benzyl_alcohol_deprotection': { + 'heavy_atoms_diff': 7, + 'rings_diff': 1, + 'atomtype': { + 'removed': {'C': 7, 'H': 6}, + }, + }, + 'N-Bn_deprotection': { + 'heavy_atoms_diff': 7, + 'rings_diff': 1, + }, + 'Formation_of_urea_from_two_amines': { + 'heavy_atoms_diff': -2, + 'rings_diff': 0, + }, + 'Amide_Schotten-Baumann_with_amine': { + 'heavy_atoms_diff': 1, + 'rings_diff': 0, + }, + 'Nucleophilic_substitution': { + 'heavy_atoms_diff': 1, + 'rings_diff': 0, + }, +} + + +def get_rmsd(mol1: Chem.rdchem.Mol, mol2: Chem.rdchem.Mol) -> float: + """Return the RMSD between two molecules. + + NB! after discussion in #2094, *do not* align structures. They + are already expected to be in the correct coordinate system. + """ + return rdMolAlign.CalcRMS(mol1, mol2) + + +def check_reaction_types(types: list[str]) -> None: + """ + Prints a warning if any of the reaction type strings in ``types`` are not in + ``SUPPORTED_CHEMISTRY`` + + :param types: A list of reaction type strings to check + """ + + for reaction_type in types: + if reaction_type not in SUPPORTED_CHEMISTRY: + mrich.error(f"Can't check chemistry of unsupported {reaction_type=}") + + +def check_chemistry( + reaction_type: str, + reactants: 'CompoundSet', + product: CompoundModel, + debug: bool = False, +) -> bool: + """Check chemistry of given reaction""" + + if reaction_type not in SUPPORTED_CHEMISTRY: + mrich.var('reactants', reactants.ids) + mrich.var('product', product) + + raise UnsupportedChemistryError(f'Unsupported {reaction_type=}') + + assert reactants + assert product + + CHEMISTRY = SUPPORTED_CHEMISTRY[reaction_type] + + if 'heavy_atoms_diff' in CHEMISTRY: + check = check_count_diff( + 'heavy_atoms', reaction_type, reactants, product, debug=debug + ) + if not check: + return False + + if 'rings_diff' in CHEMISTRY: + check = check_count_diff( + 'rings', reaction_type, reactants, product, debug=debug + ) + if not check: + return False + + if 'atomtype' in CHEMISTRY: + check = check_atomtype_diff(reaction_type, reactants, product, debug=debug) + if not check: + return False + + if debug: + mrich.success(f'{reaction_type}: All OK') + + return True + + +def check_count_diff( + check_type: str, + reaction_type: str, + reactants: 'CompoundSet', + product: 'CompoundModel', + debug: bool = False, +): + """Check integer difference""" + + # get target value + diff = SUPPORTED_CHEMISTRY[reaction_type][f'{check_type}_diff'] + + # get attribute name + attr = f'num_{check_type}' + + # get values + reac_count = getattr(reactants, attr) + prod_count = getattr(product, attr) + if debug: + mrich.var(f'#{check_type} reactants', reac_count) + if debug: + mrich.var(f'#{check_type} product', prod_count) + + # check against target value + if isinstance(diff, str): + assert diff.startswith('>='), diff + + diff = int(diff[2:]) + + if reac_count - prod_count < diff: + if debug: + mrich.error( + f'{reaction_type}: #{check_type} {(reac_count - prod_count)=} FAIL' + ) + return False + + elif debug: + mrich.success(f'{reaction_type}: #{check_type} OK') + + else: + if reac_count - diff != prod_count: + if debug: + mrich.error(f'{reaction_type}: #{check_type} FAIL') + return False + + elif debug: + mrich.success(f'{reaction_type}: #{check_type} OK') + + return True + + +def check_atomtype_diff( + reaction_type: str, + reactants: 'CompoundSet', + product: 'CompoundModel', + debug: bool = False, +) -> bool: + """check atomtypes""" + + # get values + reac = reactants.atomtype_dict + prod = product.atomtype_dict + + if debug: + mrich.var('reactants.atomtype_dict', str(reac)) + mrich.var('product.atomtype_dict', str(prod)) + + if 'removed' in SUPPORTED_CHEMISTRY[reaction_type]['atomtype']: + removal = check_specific_atomtype_diff( + reaction_type, prod, reac, removal=True, debug=debug + ) + + if not removal: + return False + + if 'added' in SUPPORTED_CHEMISTRY[reaction_type]['atomtype']: + addition = check_specific_atomtype_diff( + reaction_type, prod, reac, removal=False, debug=debug + ) + + if not addition: + return False + + if debug: + mrich.success(f'{reaction_type}: atomtypes OK') + + return True + + +def check_specific_atomtype_diff( + reaction_type: str, + prod: 'CompoundModel', + reac: 'CompoundModel', + removal: bool = False, + debug: bool = False, +) -> bool: + """check specific atomtype difference""" + + if removal: + add_str = 'removed' + else: + add_str = 'added' + + add_dict = SUPPORTED_CHEMISTRY[reaction_type]['atomtype'][add_str] + + if not add_dict: + return True + + if debug: + mrich.var(add_str, str(add_dict)) + + for symbol, count in add_dict.items(): + if symbol == 'Ha': + p_count = halogen_count(prod) + r_count = halogen_count(reac) + + elif symbol == '*': + assert count == '*', (symbol, count) + if debug: + mrich.debug('Allowing wildcard atomtype differences') + continue + + else: + p_count = prod[symbol] if symbol in prod else 0 + r_count = reac[symbol] if symbol in reac else 0 + + if isinstance(count, str): + assert count.startswith('>='), (symbol, count) + + count = int(count[2:]) + + if removal and r_count - p_count < count: + if debug: + mrich.error( + f'{symbol}: {r_count=} - {p_count=} >= {r_count - p_count}' + ) + mrich.error( + f'{reaction_type}: atomtype removal {symbol} × {count} FAIL' + ) + return False + + elif not removal and p_count - r_count < count: + if debug: + mrich.error( + f'{symbol}: {p_count=} - {r_count=} >= {p_count - r_count}' + ) + mrich.error( + f'{reaction_type}: atomtype addition {symbol} × {count} FAIL' + ) + return False + + else: + if removal and r_count - p_count != count: + if debug: + mrich.error( + f'{symbol}: {r_count=} - {p_count=} = {r_count - p_count}' + ) + mrich.error( + f'{reaction_type}: atomtype removal {symbol} × {count} FAIL' + ) + return False + + elif not removal and p_count - r_count != count: + if debug: + mrich.error( + f'{symbol}: {p_count=} - {r_count=} = {p_count - r_count}' + ) + mrich.error( + f'{reaction_type}: atomtype addition {symbol} × {count} FAIL' + ) + return False + + return True + + +def halogen_count(atomtype_dict: dict[str, int]) -> int: + """Count halogens""" + count = 0 + symbols = ['F', 'Cl', 'Br', 'I'] + for symbol in symbols: + if symbol in atomtype_dict: + count += atomtype_dict[symbol] + return count + + +class InvalidChemistryError(Exception): + """Chemistry is not valid""" + + ... + + +class UnsupportedChemistryError(Exception): + """Chemistry is not supported""" + + ... diff --git a/hippo/designdb/utils_frag.py b/hippo/designdb/utils_frag.py new file mode 100644 index 00000000..584e05e1 --- /dev/null +++ b/hippo/designdb/utils_frag.py @@ -0,0 +1,244 @@ +"""Functions for interfacing with Fragalysis data""" + +import os +import re +from dataclasses import dataclass, fields + +import mrich +from rdkit import Chem + +GENERATED_TAG_COLS = [ + 'ConformerSites alias', + 'CanonSites alias', + 'CrystalformSites alias', + 'Quatassemblies alias', + 'Crystalforms alias', + 'ConformerSites upload name', + 'CanonSites upload name', + 'CrystalformSites upload name', + 'Quatassemblies upload name', + 'Crystalforms upload name', + 'ConformerSites short tag', + 'CanonSites short tag', + 'CrystalformSites short tag', + 'Quatassemblies short tag', + 'Crystalforms short tag', + 'Centroid res', + 'Experiment code', + 'PoseModel', +] + + +META_IGNORE_COLS = [ + 'Code', + 'Long code', + 'CompoundModel code', + 'Smiles', + 'Downloaded', + 'Main status', + 'GOOD count', + 'MEDIOCRE count', + 'BAD count', + 'RefinementResolution', +] + + +def generate_header( + pose, + method, + ref_url, + submitter_name, + submitter_email, + submitter_institution, + generation_date: str | None = None, + extras=None, + metadata: bool = True, +) -> Chem.rdchem.Mol: + """Generate a header molecule for Fragalysis RHS upload""" + + extras = extras or {} + + from datetime import date + + from molparse.rdkit import mol_from_smiles + from rdkit.Chem.AllChem import EmbedMolecule + + header = mol_from_smiles(pose.compound.compound_smiles) + + header.SetProp('_Name', 'ver_1.2') + EmbedMolecule(header) + + generation_date = str(generation_date or date.today()) + + header.SetProp('ref_url', ref_url) + header.SetProp('submitter_name', submitter_name) + header.SetProp('submitter_email', submitter_email) + header.SetProp('submitter_institution', submitter_institution) + header.SetProp('generation_date', generation_date) + header.SetProp('method', method) + + if metadata: + for k, _ in pose.pose_metadata.items(): + header.SetProp(k, str(k)) + + for k, v in extras.items(): + header.SetProp(k, str(v)) + + return header + + +@dataclass +class LongcodeRecord: + crystal: str # full crystal token, e.g. A71EV2A-x0152 + protein_name: str # e.g. A71EV2A + chain: str + residue_number: int + version: int + altloc: str | None = None + + +# Current Fragalysis longcode format. The code combines two sites +# (e.g. A71EV2A-x0152_A_147_0_1_A71EV2A-x0526+A+147+0+1__LIG); we parse only the +# first (underscore-separated) site and ignore the second (plus-separated) one. +# First-site groups: crystal token, then chain_resnum_altloc_version. +_LONGCODE_RE = re.compile( + r'(.*)_' # crystal token, e.g. A71EV2A-x0152 + r'([A-Za-z]+_[0-9]+_[A-Za-z0-9]+_[0-9]+)_' # chain_resnum_altloc_version + r'.*\+[A-Za-z]+\+[0-9]+\+[A-Za-z0-9]+\+[0-9]+' # second site (ignored) + r'_.LIG' +) + +# DEPRECATED(longcode-altloc): pre-altloc Fragalysis longcode format +# (e.g. D68EV3CPROB-x0455_A_209_1_7gp9+A+201+1__LIG, no altloc group). Remove this +# regex and its branch in parse_observation_longcode once all data uses the +# current format above. +_LONGCODE_RE_LEGACY = re.compile( + r'(.*)_([A-Za-z]_[0-9]*_[0-9])_(.*)\+([A-Za-z]\+[0-9]*\+[0-9])_.LIG' +) + +# Split a crystal token (e.g. A71EV2A-x0152) into protein name and crystal id. +_CRYSTAL_RE = re.compile(r'(.*)-(\w[0-9]{4})') + + +def parse_observation_longcode(longcode: str) -> LongcodeRecord: + """Parse a Fragalysis observation longcode (first site only). + + Extracts: + + - Crystal token (crystal), e.g. ``A71EV2A-x0152`` + - Protein name (protein_name), e.g. ``A71EV2A`` + - Chain letter (chain) + - Residue number (residue_number) + - Altloc (altloc; ``None`` for the older format) + - Version number (version) + """ + + altloc = None + + match = _LONGCODE_RE.search(longcode) + if match: + cryst_str = match.group(1) + chain, residue_number, altloc, version = match.group(2).split('_') + else: + # DEPRECATED(longcode-altloc): pre-altloc format had no altloc group; + # remove this branch (and _LONGCODE_RE_LEGACY) once all data uses the + # current format. + match = _LONGCODE_RE_LEGACY.search(longcode) + if not match: + raise UnsupportedFragalysisLongcodeError(longcode) + cryst_str = match.group(1) + chain, residue_number, version = match.group(2).split('_') + # end DEPRECATED(longcode-altloc) + + residue_number = int(residue_number) + version = int(version) + + if m := _CRYSTAL_RE.search(cryst_str): + crystal = m.group(0) # full token, e.g. A71EV2A-x0152 + protein_name = m.group(1) # e.g. A71EV2A + else: + crystal = cryst_str + protein_name = '' + + return LongcodeRecord( + crystal=crystal, + protein_name=protein_name, + chain=chain, + residue_number=residue_number, + version=version, + altloc=altloc, + ) + + +def find_observation_longcode_matches( + query: str, codes: list[str], debug: bool = False, allow_version_none: bool = False +) -> list[str]: + """find_observation_longcode_matches""" + + dq = parse_observation_longcode(query) + + if debug: + mrich.var('allow_version_none', allow_version_none) + mrich.var('dq', str(dq)) + + matches = [] + + for code in codes: + if code == query: + if debug: + mrich.debug('exact match') + matches.append(code) + continue + + dc = parse_observation_longcode(code) + + for key in fields(dq): + if ( + allow_version_none + and key.name == 'version' + and (getattr(dc, key.name) is None or getattr(dq, key.name) is None) + ): + continue + + if getattr(dc, key.name) != getattr(dq, key.name): + break + else: + if debug: + mrich.debug(f'{query} matches {code}') + matches.append(code) + + if debug: + mrich.var('#matches', len(matches)) + + if len(matches) < 1 and not allow_version_none: + return find_observation_longcode_matches(query, codes, allow_version_none=True) + + return matches + + +STACK_URLS = { + 'production': 'https://fragalysis.diamond.ac.uk', + 'staging': 'https://fragalysis.xchem.diamond.ac.uk', + # testing + 'localhost': 'http://localhost:8080', +} + +# Developers can add or override stacks via the environment without editing this +# shared file. Each ``HIPPO_STACK_URL_`` variable becomes the ```` +# stack (lower-cased), e.g. set in your (gitignored) .env: +# HIPPO_STACK_URL_DOCKERHOST=http://host.docker.internal:8080 +# then use stack='dockerhost'. +_STACK_URL_ENV_PREFIX = 'HIPPO_STACK_URL_' +STACK_URLS.update( + { + key[len(_STACK_URL_ENV_PREFIX) :].lower(): value + for key, value in os.environ.items() + if key.startswith(_STACK_URL_ENV_PREFIX) and value + } +) + + +class UnsupportedFragalysisLongcodeError(NotImplementedError): + """Provided Fragalysis observation long code syntax is not supported""" + + ... diff --git a/hippo/xca.py b/hippo/designdb/utils_xca.py similarity index 85% rename from hippo/xca.py rename to hippo/designdb/utils_xca.py index 76dd4b1e..32646585 100644 --- a/hippo/xca.py +++ b/hippo/designdb/utils_xca.py @@ -1,15 +1,12 @@ """Functions for interfacing with XChemAlign data""" -import mrich -from mrich import print - import re def parse_observation_longcode(longcode: str) -> dict[str]: """Parse an XChemAlign longcode and try to extract the following information: - - Target name (target) + - TargetModel name (target) - Crystal/dataset code (crystal) - Chain letter (chain) - Residue number (residue_number) @@ -19,7 +16,7 @@ def parse_observation_longcode(longcode: str) -> dict[str]: """ match = re.search( - r"^(.*)-(.\d{4})_(.)_(\d*)_(\d)_.*-.\d{4}\+.\+\d*\+\d_.LIG$", longcode + r'^(.*)-(.\d{4})_(.)_(\d*)_(\d)_.*-.\d{4}\+.\+\d*\+\d_.LIG$', longcode ) if not match: diff --git a/hippo/designdb/views.py b/hippo/designdb/views.py new file mode 100644 index 00000000..60f00ef0 --- /dev/null +++ b/hippo/designdb/views.py @@ -0,0 +1 @@ +# Create your views here. diff --git a/hippo/feature.py b/hippo/feature.py deleted file mode 100644 index afe0cf5d..00000000 --- a/hippo/feature.py +++ /dev/null @@ -1,89 +0,0 @@ -"""Classes to work with pharmacophoric features""" - -import mcol -import mrich - -from dataclasses import dataclass - -from .target import Target - - -@dataclass -class Feature: - """Pharmocophoric feature in a protein - - attributes: - id: Database ID - family: Feature family - chain_name: Protein chain name/letter - residue_name: Protein residue name - residue_number: Protein residue number - atom_names: Protein atom names (whitespace-delimited) - - """ - - id: int - family: str - target: Target - chain_name: str - residue_name: str - residue_number: int - atom_names: str - - def __str__(self) -> str: - """Unformatted string representation""" - return f"F{self.id}" - - def __repr__(self) -> str: - """ANSI Formatted string representation""" - return f"{mcol.bold}{mcol.underline}{self.family} {self.chain_name} {self.residue_name} {self.residue_number} [{self.atom_names}]{mcol.unbold}{mcol.ununderline}" - - def __rich__(self) -> str: - """Representation for mrich""" - return f"[bold underline]{self.family} {self.chain_name} {self.residue_name} {self.residue_number} [{self.atom_names}]" - - @property - def chain_res_name_number_str(self) -> str: - """Return a string representation of the feature""" - return f"{self.chain_name} {self.residue_name} {self.residue_number}" - - @property - def res_name_number_str(self) -> str: - """Return a string representation of the feature""" - return f"{self.residue_name} {self.residue_number}" - - @property - def res_number_name_tuple(self) -> str: - """Return a tuple representation of the feature""" - return (self.residue_number, self.residue_name) - - @property - def res_name_number_family_str(self) -> str: - """Return a string representation of the feature""" - return f"{self.residue_name} {self.residue_number} {self.family}" - - @property - def backbone(self) -> bool: - """Are any of the atoms referenced by this feature on the backbone?""" - from molparse.amino import BB_NAMES - - for atom_name in self.atom_names.split(","): - if atom_name in BB_NAMES: - return True - - return False - - @property - def sidechain(self) -> bool: - """Are any of the atoms referenced by this feature on the sidechain?""" - from molparse.amino import BB_NAMES - - for atom_name in self.atom_names.split(","): - - if atom_name.startswith("H"): - continue - - if atom_name not in BB_NAMES: - return True - - return False diff --git a/hippo/fragalysis.py b/hippo/fragalysis.py deleted file mode 100644 index f3df0062..00000000 --- a/hippo/fragalysis.py +++ /dev/null @@ -1,155 +0,0 @@ -"""Functions for interfacing with Fragalysis data""" - -import mrich - - -def generate_header( - pose, - method, - ref_url, - submitter_name, - submitter_email, - submitter_institution, - generation_date: str | None = None, - extras=None, - metadata: bool = True, -) -> "Chem.Mol": - """Generate a header molecule for Fragalysis RHS upload""" - - extras = extras or {} - - from rdkit.Chem.AllChem import EmbedMolecule - from molparse.rdkit import mol_from_smiles - from datetime import date - - header = mol_from_smiles(pose.compound.smiles) - - header.SetProp("_Name", "ver_1.2") - EmbedMolecule(header) - - generation_date = str(generation_date or date.today()) - - header.SetProp("ref_url", ref_url) - header.SetProp("submitter_name", submitter_name) - header.SetProp("submitter_email", submitter_email) - header.SetProp("submitter_institution", submitter_institution) - header.SetProp("generation_date", generation_date) - header.SetProp("method", method) - - if metadata: - for k, v in pose.metadata.items(): - header.SetProp(k, str(k)) - - for k, v in extras.items(): - header.SetProp(k, str(v)) - - return header - - -def parse_observation_longcode(longcode: str) -> dict[str]: - """Parse a Fragalysis longcode and try to extract the following information: - - - Target name (target) - - Crystal/dataset code (crystal) - - Chain letter (chain) - - Residue number (residue_number) - - Version number (version) - - :returns: dictionary of the above keys in parentheses - """ - - import re - - match = re.search( - r"(.*)_([A-z]_[0-9]*_[0-9])_(.*)\+([A-z]\+[0-9]*\+[0-9])_.LIG", longcode - ) - - if not match: - raise UnsupportedFragalysisLongcodeError(longcode) - - cryst_str, lig_str, _, _ = match.groups() - - chain, residue_number, version = lig_str.split("_") - - residue_number = int(residue_number) - version = int(version) - - if match := re.search(r"(.*)-(\w[0-9]{4})", cryst_str): - - target_name = match.group(0) - crystal = match.group(1) - - else: - - target_name = None - crystal = cryst_str - - return dict( - target=target_name, - crystal=crystal, - chain=chain, - residue_number=residue_number, - version=version, - ) - - -def find_observation_longcode_matches( - query: str, codes: list[str], debug: bool = False, allow_version_none: bool = False -) -> list[str]: - """find_observation_longcode_matches""" - - dq = parse_observation_longcode(query) - - keys = dq.keys() - - if debug: - mrich.var("allow_version_none", allow_version_none) - mrich.var("dq", str(dq)) - - matches = [] - - for code in codes: - - if code == query: - if debug: - mrich.debug("exact match") - matches.append(code) - continue - - dc = parse_observation_longcode(code) - - for key in keys: - - if ( - allow_version_none - and key == "version" - and (dc[key] is None or dq[key] is None) - ): - continue - - if dc[key] != dq[key]: - break - else: - if debug: - mrich.debug(f"{query} matches {code}") - matches.append(code) - - if debug: - mrich.var("#matches", len(matches)) - - if len(matches) < 1 and not allow_version_none: - return find_observation_longcode_matches(query, codes, allow_version_none=True) - - return matches - - -STACK_URLS = { - "production": "https://fragalysis.diamond.ac.uk", - "staging": "https://fragalysis.xchem.diamond.ac.uk", -} - - -class UnsupportedFragalysisLongcodeError(NotImplementedError): - """Provided Fragalysis observation long code syntax is not supported""" - - ... diff --git a/hippo/interaction.py b/hippo/interaction.py deleted file mode 100644 index b681d393..00000000 --- a/hippo/interaction.py +++ /dev/null @@ -1,191 +0,0 @@ -"""Classes for working with interactions""" - -import mcol -import mrich - - -class Interaction: - """A :class:`.Interaction` represents an interaction between an rdkit Feature on a :class:`.Pose` and a :class:`.Feature` on the protein :class:`.Target`. - - .. attention:: - - :class:`.Interaction` objects should not be created directly. Instead use :meth:`.Pose.interactions`, or :meth:`.PoseSet.interactions` methods. - - """ - - def __init__( - self, - db: "Database", - id: int, - feature_id: int, - pose_id: int, - type: str, - family: str, - atom_ids: str, - prot_coord: str, - lig_coord: str, - distance: float, - angle: float, - energy: float | None, - table: str = "interaction", - ) -> None: - """Interaction initialisation""" - - import json - - # from interaction table - self._id = id - self._feature_id = feature_id - self._pose_id = pose_id - self._type = type - self._family = family - self._atom_ids = json.loads(atom_ids) - self._prot_coord = json.loads(prot_coord) - self._lig_coord = json.loads(lig_coord) - self._distance = distance - self._angle = angle - self._energy = energy - - # placeholders - self._pose = None - self._feature = None - self._table = table - - self._db = db - - ### PROPERTIES - - @property - def id(self) -> int: - """Returns the interaction's database ID""" - return self._id - - @property - def table(self) -> str: - """Returns the name of the :class:`.Database` table""" - return self._table - - @property - def db(self) -> "Database": - """Returns a pointer to the parent database""" - return self._db - - @property - def family(self) -> str: - """The Feature family""" - return self._family - - @property - def pose_id(self) -> int: - """Returns the associated :class:`.Pose`'s database ID""" - return self._pose_id - - @property - def pose(self) -> "Pose": - """Returns the associated :class:`.Pose`'s object""" - if not self._pose: - self._pose = self.db.get_pose(id=self.pose_id) - return self._pose - - @property - def feature_id(self) -> int: - """Returns the associated :class:`.Feature`'s database ID""" - return self._feature_id - - @property - def feature(self) -> "Feature": - """Returns the associated :class:`.Feature`'s object""" - if not self._feature: - self._feature = self.db.get_feature(id=self.feature_id) - return self._feature - - @property - def residue_name(self) -> str: - """Returns the associated :class:`.Feature`'s residue name""" - return self.feature.residue_name - - @property - def residue_number(self) -> int: - """Returns the associated :class:`.Feature`'s residue number""" - return self.feature.residue_number - - @property - def atom_ids(self) -> list[int]: - """Returns the indices of atoms making up the ligand feature""" - return self._atom_ids - - @property - def prot_coord(self) -> list[float]: - """Returns the cartesian position of the protein :class:`.Feature`""" - return self._prot_coord - - @property - def lig_coord(self) -> list[float]: - """Returns the cartesian position of the ligand feature""" - return self._lig_coord - - @property - def distance(self) -> float: - """Returns the euclidian distance of the interaction""" - return self._distance - - @property - def angle(self) -> float | None: - """Returns the interaction angle (only defined for π-stacking and π-cation interactions)""" - return self._angle - - @property - def energy(self) -> float | None: - """Returns the interaction energy, if defined""" - return self._energy - - @property - def family_str(self) -> str: - """String of the two feature families""" - return f"{repr(self.feature)} ~ {self.family}" - - @property - def type(self) -> str: - """Interaction type string""" - from molparse.rdkit.features import INTERACTION_TYPES - - return INTERACTION_TYPES[(self.feature.family, self.family)] - - @property - def description(self) -> str: - """One line description of this interaction""" - s = f"{self.type} [{self.feature.chain_res_name_number_str}] {self.distance:.1f} Å" - if self.angle: - s += f", {self.angle:.1f} degrees" - return s - - ### METHODS - - def summary(self) -> None: - """Print a summary of this interaction's properties""" - - mrich.header(f"Interaction {self.id}") - - mrich.var("feature", self.feature) - mrich.var("pose", self.pose) - mrich.var("family", self.family) - mrich.var("atom_ids", self.atom_ids) - mrich.var("prot_coord", self.prot_coord) - mrich.var("lig_coord", self.lig_coord) - mrich.var("distance", self.distance) - mrich.var("angle", self.angle) - mrich.var("energy", self.energy) - - ### DUNDERS - - def __str__(self) -> str: - """Plain string representation""" - return f"I{self.id}" - - def __repr__(self) -> str: - """ANSI Formatted string representation""" - return f"{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}" - - def __rich__(self) -> str: - """Rich formatted string representation""" - return f"[bold underline]{self}" diff --git a/hippo/iset.py b/hippo/iset.py deleted file mode 100644 index 71b991df..00000000 --- a/hippo/iset.py +++ /dev/null @@ -1,784 +0,0 @@ -"""Classes for working with sets of interactions""" - -import mcol -import mrich - - -class InteractionTable: - """Class representing all :class:`.Interaction` objects in the 'interaction' table of the :class:`.Database`. - - .. attention:: - - :class:`.InteractionTable` objects should not be created directly. Instead use the :meth:`.HIPPO.interactions` property. - - """ - - def __init__(self, db: "Database", table: str = "interaction") -> None: - """InteractionTable initialisation""" - - self._db = db - self._df = None - self._table = table - - ### PROPERTIES - - @property - def db(self) -> "Database": - """Returns the associated :class:`.Database`""" - return self._db - - @property - def table(self) -> str: - """Returns the name of the :class:`.Database` table""" - return self._table - - @property - def df(self) -> "pandas.DataFrame": - """DataFrame representation of the interactions - - :returns: a ``pandas.Dataframe`` of the interactions - - """ - - if self._df is None: - records = self.db.select_all_where( - table="interaction", key=f"interaction_id > 0", multiple=True - ) - df = df_from_interaction_records(self.db, records) - self._df = df - - return self._df - - ### DUNDERS - - def __len__(self) -> int: - """The total number of interactions""" - return self.db.count(self.table) - - def __str__(self) -> str: - """Unformatted command-line representation""" - return "{" f"I × {len(self)}" "}" - - def __repr__(self) -> str: - """ANSI formatted command-line representation""" - return f"{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}" - - def __rich__(self) -> str: - """Rich formatted command-line representation""" - return f"[bold underline]{self}" - - -class InteractionSet: - """Class representing a subset of the :class:`.Interaction` objects in the 'interaction' table of the :class:`.Database`. - - .. attention:: - - :class:`.InteractionSet` objects should not be created directly. Instead use :meth:`.Pose.interactions`, or :meth:`.PoseSet.interactions` methods. - - """ - - def __init__( - self, - db: "Database", - indices: list = None, - table: str = "interaction", - ) -> None: - """InteractionSet initialisation""" - - self._db = db - self._table = table - - indices = indices or [] - - if not isinstance(indices, list): - indices = list(indices) - - indices = [int(i) for i in indices] - - self._indices = sorted(list(set(indices))) - self._df = None - - ### FACTORIES - - @classmethod - def from_pose( - cls, pose: "Pose | PoseSet", table: str = "interaction" - ) -> "InteractionSet": - """Construct a :class:`.InteractionSet` from one or more poses. - - :param pose: a :class:`.Pose` or :class:`.PoseSet` object - :returns: an :class:`.InteractionSet` - - """ - - self = cls.__new__(cls) - - ### get the ID's - - from .pset import PoseSet - - if isinstance(pose, PoseSet): - - # check if all poses have fingerprints - (has_invalid_fps,) = pose.db.select_where( - query="COUNT(1)", - table="pose", - key=f"pose_id IN {pose.str_ids} AND pose_fingerprint = 0", - ) - - if has_invalid_fps: - mrich.warning(f"{has_invalid_fps} Poses have not been fingerprinted") - - sql = f""" - SELECT interaction_id FROM {table} - WHERE interaction_pose IN {pose.str_ids} - """ - - else: - - sql = f""" - SELECT interaction_id FROM {table} - WHERE interaction_pose = {pose.id} - """ - - ids = pose.db.execute(sql).fetchall() - - ids = [i for i, in ids] - - self.__init__(pose.db, ids, table=table) - - return self - - @classmethod - def all( - cls, - db: "Database", - table: str = "interaction", - ) -> "InteractionSet": - """Construct a :class:`.InteractionSet` for all interactions in the table. - - :returns: an :class:`.InteractionSet` - - """ - - sql = f"SELECT interaction_id FROM {table}" - - ids = db.execute(sql).fetchall() - - ids = [i for i, in ids] - - self = cls.__new__(cls) - self.__init__(db, ids, table=table) - - return self - - @classmethod - def from_residue( - cls, - db: "Database", - residue_number: int, - chain: None | str = None, - target: "Target | int" = 1, - ) -> "InteractionSet": - """Get the set of interactions for a given residue number (and chain) - - :param db: HIPPO :class:`.Database` - :param residue_number: the residue number - :param chain: the chain name / letter, defaults to any chain - :param target: the protein :class:`.Target` object or ID, defaults to first target in database - :returns: a :class:`.InteractionSet` object - """ - - from .target import Target - - self = cls.__new__(cls) - - if isinstance(target, Target): - target = target.id - - sql = f""" - SELECT interaction_id FROM interaction - INNER JOIN feature - ON interaction_feature = feature_id - WHERE feature_target = {target} - AND feature_residue_number = {residue_number} - """ - - if chain: - sql += f' AND feature_chain_name = "{chain}"' - - ids = db.execute(sql).fetchall() - - ids = [i for i, in ids] - - self.__init__(db, ids) - - return self - - ### PROPERTIES - - @property - def indices(self) -> list[int]: - """Returns the ids of interactions in this set""" - return self._indices - - @property - def ids(self) -> list[int]: - """Returns the ids of interactions in this set""" - return self._indices - - @property - def types(self) -> list[str]: - """Returns the ids of interactions in this set""" - records = self.db.select_where( - query="interaction_type", - table="interaction", - key=f"interaction_id IN {self.str_ids}", - multiple=True, - ) - return [r for r, in records] - - @property - def db(self) -> "Database": - """The associated HIPPO :class:`.Database`""" - return self._db - - @property - def table(self) -> str: - """Get the name of the database table""" - return self._table - - @property - def str_ids(self) -> str: - """Return an SQL formatted tuple string of the :class:`.Interaction` IDs""" - return str(tuple(self.ids)).replace(",)", ")") - - @property - def classic_fingerprint(self) -> dict: - """Classic HIPPO fingerprint dictionary, mapping protein :class:`.Feature` ID's to the number of corresponding ligand features (from any :class:`.Pose`)""" - return self.get_classic_fingerprint() - - @property - def df(self) -> "pandas.DataFrame": - """DataFrame representation of the interactions - - :returns: a ``pandas.Dataframe`` of the interactions - - """ - - if self._df is None: - records = self.db.select_all_where( - table=self.table, - key=f"interaction_id IN {self.str_ids}", - multiple=True, - ) - df = df_from_interaction_records(self.db, records) - self._df = df - - return self._df - - @property - def residue_number_chain_pairs(self) -> list[tuple]: - """Get a list of ``(residue_number, chain_name)`` tuples""" - - sql = f""" - SELECT DISTINCT feature_residue_number, feature_chain_name FROM {self.table} - INNER JOIN feature - ON feature_id = interaction_feature - WHERE interaction_id IN {self.str_ids} - """ - - return self.db.execute(sql).fetchall() - - @property - def avg_num_residues_per_pose(self) -> list[tuple]: - """Get a list of ``(residue_number, chain_name)`` tuples""" - - sql = f""" - SELECT DISTINCT interaction_pose, feature_residue_number, feature_chain_name FROM {self.table} - INNER JOIN feature - ON feature_id = interaction_feature - WHERE interaction_id IN {self.str_ids} - """ - - records = self.db.execute(sql).fetchall() - - from collections import defaultdict - from numpy import mean - - d = defaultdict(set) - - for pose_id, res_num, chain_name in records: - d[pose_id].add((res_num, chain_name)) - - return mean(list(len(v) for v in d.values())) - - @property - def avg_num_interactions_per_pose(self) -> list[tuple]: - """Get a list of ``(residue_number, chain_name)`` tuples""" - - sql = f""" - SELECT interaction_pose FROM {self.table} - WHERE interaction_id IN {self.str_ids} - """ - - records = self.db.execute(sql).fetchall() - - from collections import defaultdict - from numpy import mean - - d = defaultdict(int) - - for (pose_id,) in records: - d[pose_id] += 1 - - return mean(list(d.values())) - - @property - def avg_num_interaction_type_residue_pairs_per_pose(self) -> list[tuple]: - """Get a list of ``(residue_number, chain_name)`` tuples""" - - sql = f""" - SELECT DISTINCT interaction_pose, interaction_type, feature_residue_number, feature_chain_name FROM {self.table} - INNER JOIN feature - ON feature_id = interaction_feature - WHERE interaction_id IN {self.str_ids} - """ - - records = self.db.execute(sql).fetchall() - - from collections import defaultdict - from numpy import mean - - d = defaultdict(set) - - for pose_id, type, res_num, chain_name in records: - d[pose_id].add((res_num, type, chain_name)) - - return mean(list(len(v) for v in d.values())) - - @property - def type_residue_number_chain_triples(self) -> list[tuple]: - """Get a list of ``(interaction_type, residue_number, chain_name)`` tuples""" - - sql = f""" - SELECT DISTINCT interaction_type, feature_residue_number, feature_chain_name FROM {self.table} - INNER JOIN feature - ON feature_id = interaction_feature - WHERE interaction_id IN {self.str_ids} - """ - - return self.db.execute(sql).fetchall() - - @property - def num_features(self) -> int: - """Count the funmber of protein :class:`.Feature`s with which interactions are formed""" - - (count,) = self.db.execute( - f""" - SELECT COUNT(DISTINCT interaction_feature) FROM {self.table} - WHERE interaction_id IN {self.str_ids} - """ - ).fetchone() - - return count - - @property - def avg_num_interactions_per_feature(self) -> float: - """Average number of interactions formed with each protein :class:`.Feature`""" - - (count,) = self.db.execute( - f""" - WITH counts AS - ( - SELECT interaction_feature, COUNT(1) AS count FROM {self.table} - WHERE interaction_id IN {self.str_ids} - GROUP BY interaction_feature - ) - - SELECT AVG(count) FROM counts - """ - ).fetchone() - - return count - - @property - def per_feature_count_hirsch(self) -> float: - """A measure for how evenly protein :class:`.Feature`s are being interacted with""" - - counts = self.db.execute( - f""" - SELECT interaction_feature, COUNT(1) AS count FROM interaction - WHERE interaction_id IN {self.str_ids} - GROUP BY interaction_feature - """ - ).fetchall() - - counts = [count for f_id, count in counts] - - from numpy import std - - # return -std(counts) - - from hirsch import hirsch - - if not counts: - return 0 - - return hirsch(counts) - - ### METHODS - - def summary( - self, - families: bool = False, - ) -> None: - """Print a summary of this :class:`.InteractionSet`""" - - mrich.header(self) - - for interaction in self: - # print(interaction) - - # mrich.var(f'{interaction.family_str}', f'{interaction.distance:.1f}') - s = f"{interaction.description}" - - if families: - s += f" {interaction.feature.family} ~ {interaction.family}" - - mrich.var(s, f"{interaction.distance:.1f}", "Å") - - def get_classic_fingerprint(self) -> dict: - """Classic HIPPO fingerprint dictionary, mapping protein :class:`.Feature` ID's to the number of corresponding ligand features (from any :class:`.Pose`)""" - - pairs = self.db.execute( - f""" - SELECT interaction_feature, COUNT(1) FROM {self.table} - WHERE interaction_id IN {self.str_ids} - GROUP BY interaction_feature - """ - ).fetchall() - - return {f: c for f, c in pairs} - - def resolve( - self, - debug: bool = False, - commit: bool = True, - # table: str = 'interaction', - ) -> "InteractionSet": - """Resolve into predicted key interactions. In place modification. - - :param debug: Increased verbosity for debugging (Default value = False) - :param commit: commit the changes (Default value = True) - :returns: a filtered :class:`.InteractionSet` - """ - - keep_list = [] - - table = self.table - - ### H-Bonds (closest) - - sql = f""" - SELECT interaction_id, MIN(interaction_distance) - FROM {table} - WHERE interaction_id IN {self.str_ids} - AND interaction_type = "Hydrogen Bond" - GROUP BY interaction_atom_ids - """ - - records = self.db.execute(sql).fetchall() - ids = [a for a, b in records] - keep_list += ids - - ### pi-stacking (closest) - - sql = f""" - SELECT interaction_id, MIN(interaction_distance) - FROM {table} - INNER JOIN feature - ON feature_id = interaction_feature - WHERE interaction_id IN {self.str_ids} - AND interaction_type = "π-stacking" - GROUP BY feature_atom_names - """ - # GROUP BY interaction_atom_ids - - records = self.db.execute(sql).fetchall() - ids = [a for a, b in records] - keep_list += ids - - ### pi-cation (closest) - - sql = f""" - SELECT interaction_id, MIN(interaction_distance) - FROM {table} - WHERE interaction_id IN {self.str_ids} - AND interaction_type = "π-cation" - GROUP BY interaction_atom_ids - """ - # GROUP BY interaction_atom_ids - - records = self.db.execute(sql).fetchall() - ids = [a for a, b in records] - keep_list += ids - - ### electrostatic (closest) - - sql = f""" - SELECT interaction_id, MIN(interaction_distance) - FROM {table} - WHERE interaction_id IN {self.str_ids} - AND interaction_type = "Electrostatic" - GROUP BY interaction_atom_ids - """ - # GROUP BY interaction_atom_ids - - records = self.db.execute(sql).fetchall() - ids = [a for a, b in records] - keep_list += ids - - ### sulfur-sulfur (all) - - sql = f""" - SELECT interaction_id - FROM {table} - WHERE interaction_id IN {self.str_ids} - AND interaction_type = "Sulfur-Sulfur" - """ - - records = self.db.execute(sql).fetchall() - ids = [a for a, in records] - keep_list += ids - - ### hydrophobic - - sql = f""" - SELECT interaction_id, interaction_distance - FROM {table} - WHERE interaction_id IN {self.str_ids} - AND interaction_type = "Hydrophobic" - """ - - records = self.db.execute(sql).fetchall() - ids = [a for a, b in records] - subset = InteractionSet(self.db, ids, table=table) - - # aggregate lumped - - hydrophobic_interactions_in_lumped = {} - lumped_hydrophobic_in_lumped_lumped = {} - - for interaction in subset: - families = (interaction.feature.family, interaction.family) - - if families == ("LumpedHydrophobe", "Hydrophobe"): - for name in interaction.feature.atom_names.split(): - key = (name, interaction.atom_ids[0]) - if key not in hydrophobic_interactions_in_lumped: - hydrophobic_interactions_in_lumped[key] = [] - hydrophobic_interactions_in_lumped[key].append(interaction.id) - - elif families == ("Hydrophobe", "LumpedHydrophobe"): - for atom_id in interaction.atom_ids: - key = (interaction.feature.atom_names, atom_id) - if key not in hydrophobic_interactions_in_lumped: - hydrophobic_interactions_in_lumped[key] = [] - hydrophobic_interactions_in_lumped[key].append(interaction.id) - - elif families == ("LumpedHydrophobe", "LumpedHydrophobe"): - for name in interaction.feature.atom_names.split(): - for atom_id in interaction.atom_ids: - key = (name, atom_id) - if key not in hydrophobic_interactions_in_lumped: - hydrophobic_interactions_in_lumped[key] = [] - hydrophobic_interactions_in_lumped[key].append(interaction.id) - - key = interaction.feature.atom_names - lumped_hydrophobic_in_lumped_lumped[key] = tuple(interaction.atom_ids) - - keep_hydrophobic_ids = set(subset.ids) - rev_hydrophobic_in_lumped_lumped = { - v: k for k, v in lumped_hydrophobic_in_lumped_lumped.items() - } - - # modify keep list by those covered in lumped - - for interaction in subset: - families = (interaction.feature.family, interaction.family) - - if families == ("Hydrophobe", "Hydrophobe"): - key = (interaction.feature.atom_names, interaction.atom_ids[0]) - - if key in hydrophobic_interactions_in_lumped: - keep_hydrophobic_ids -= set([interaction.id]) - - elif families == ("LumpedHydrophobe", "Hydrophobe"): - - key = interaction.feature.atom_names - - if key in lumped_hydrophobic_in_lumped_lumped: - atom_id = interaction.atom_ids[0] - value = lumped_hydrophobic_in_lumped_lumped[key] - if atom_id in value: - keep_hydrophobic_ids -= set([interaction.id]) - - elif families == ("Hydrophobe", "LumpedHydrophobe"): - - key = tuple(interaction.atom_ids) - - if key in rev_hydrophobic_in_lumped_lumped: - - atom_name = interaction.feature.atom_names - value = rev_hydrophobic_in_lumped_lumped[key] - - if atom_name in value: - keep_hydrophobic_ids -= set([interaction.id]) - - keep_list += list(keep_hydrophobic_ids) - - ### cull non-keepers - - cull_list = set(self.ids) - set(keep_list) - cull_iset = InteractionSet(self.db, cull_list) - self.db.delete_where( - table=table, - key=f"interaction_id IN {cull_iset.str_ids}", - commit=commit, - ) - self._indices = sorted(list(set(keep_list))) - - ### revisit hydrophobes - - # for a given protein feature, choose the closest interaction - - cull_list = [] - - hydrophobic_keeper_iset = InteractionSet(self.db, keep_hydrophobic_ids) - - sql = f""" - SELECT interaction_id, MIN(interaction_distance) - FROM {table} - WHERE interaction_id IN {hydrophobic_keeper_iset.str_ids} - GROUP BY interaction_feature - """ - - records = self.db.execute(sql).fetchall() - ids = [a for a, b in records] - - cull_list = set(hydrophobic_keeper_iset.ids) - set(ids) - cull_iset = InteractionSet(self.db, cull_list) - self.db.delete_where( - table=table, - key=f"interaction_id IN {cull_iset.str_ids}", - commit=commit, - ) - self._indices = sorted(list(set(keep_list) - cull_list)) - - ### Summary - - if debug: - self.summary() - - ### DUNDERS - - def __len__(self) -> int: - """The number of interactions in this set""" - return len(self.indices) - - def __str__(self) -> str: - """Unformatted command-line representation""" - return "{" f"I × {len(self)}" "}" - - def __repr__(self) -> str: - """ANSI formatted command-line representation""" - return f"{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}" - - def __rich__(self) -> str: - """Rich formatted command-line representation""" - return f"[bold underline]{self}" - - def __iter__(self): - """Iterate through interactions in this set""" - return iter( - self.db.get_interaction(id=i, table=self.table) for i in self.indices - ) - - def __getitem__(self, key) -> "Interaction | InteractionSet": - """Get interaction or subsets thereof from this set""" - match key: - case int(): - index = self.indices[key] - return self.db.get_interaction(id=index, table=self.table) - - case slice(): - indices = self.indices[key] - return InteractionSet(self.db, indices, table=self.table) - - case _: - raise NotImplementedError - - -def df_from_interaction_records( - db: "Database", - records: list[tuple], -) -> "pandas.DataFrame": - """Construct a dataframe from the 'interaction' table records""" - - import json - from pandas import DataFrame - - data = [] - for record in records: - - ( - id, - feature_id, - pose_id, - type, - family, - atom_ids, - prot_coord, - lig_coord, - distance, - angle, - energy, - ) = record - - feature = db.get_feature(id=feature_id) - - d = dict(id=id) - - d["feature_id"] = feature_id - d["pose_id"] = pose_id - d["target_id"] = feature.target - - # d['type'] = INTERACTION_TYPES[(feature.family, family)] - d["type"] = type - - d["prot_family"] = feature.family - d["lig_family"] = family - - d["residue_name"] = feature.residue_name - d["residue_number"] = feature.residue_number - d["chain_name"] = feature.chain_name - - d["distance"] = distance - d["angle"] = angle - d["energy"] = energy - - d["prot_coord"] = json.loads(prot_coord) - d["lig_coord"] = json.loads(lig_coord) - - d["prot_atoms"] = feature.atom_names - d["lig_atoms"] = atom_ids - - d["backbone"] = feature.backbone - d["sidechain"] = feature.sidechain - - data.append(d) - - df = DataFrame.from_records(data=data) - - return df diff --git a/hippo/manage.py b/hippo/manage.py new file mode 100755 index 00000000..8333a568 --- /dev/null +++ b/hippo/manage.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" + +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'xchem_hippo.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + 'available on your PYTHONPATH environment variable? Did you ' + 'forget to activate a virtual environment?' + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/hippo/metadata.py b/hippo/metadata.py deleted file mode 100644 index 962ea0c6..00000000 --- a/hippo/metadata.py +++ /dev/null @@ -1,112 +0,0 @@ -"""Class for working with database stored JSON metadata""" - -from typing import Mapping -from collections import UserDict - - -class MetaData(UserDict): - """Metadata dictionary linked to a compound or pose in a HIPPO :class:`.Database` - - .. attention:: - - :class:`.Metadata` objects should not be created directly. Instead use the methods :meth:`.Compound.metadata` and :meth:`.Pose.metadata` - - """ - - def __init__( - self, - __dict: Mapping[str, str] | None, - ) -> None: - """Metadata initialisation""" - - super().__init__() - if __dict: - for key, value in __dict.items(): - super().__setitem__(key, value) - - self._db = None - self._table: str = None - self._id: str = None - - ### PROPERTIES - - @property - def table(self) -> str: - """name of the associated :class:`.Database` table""" - return self._table - - @property - def id(self) -> int: - """entry ID in the associated :class:`.Database` table""" - return self._id - - @property - def db(self) -> "Database": - """associated :class:`.Database`""" - return self._db - - ### METHODS - - def _update_db( - self, - commit: bool = True, - ) -> None: - """Update the associated :class:`.Database` entry - - :param commit: commit the changes (Default value = True) - - """ - self._db.insert_metadata( - table=self._table, id=self._id, payload=self.data, commit=commit - ) - - def update( - self, - data: dict, - commit: bool = True, - ) -> None: - """Wrapper for dict.update() - - :param data: data with which to update the metadata - :param commit: commit the changes (Default value = True) - - """ - self.data.update(data) - self._update_db(commit=commit) - - def append( - self, - key: str, - value, - commit: bool = True, - ) -> None: - """Create or append to a list-like value with given key - - :param key: metadata dictionary key to be modified - :param value: value to be appended to list-like ``metadata[key]`` - :param commit: commit the changes (Default value = True) - - """ - if key not in self: - self.data[key] = [] - if value not in self.data[key]: - self.data[key].append(value) - self._update_db(commit=commit) - - ### DUNDERS - - def __setitem__( - self, - key: str, - item, - ) -> None: - """Set the value associated to a specific dictionary key""" - - self.data.__setitem__(key, item) - self._update_db() - - def __delitem__(self, key: str) -> None: - """Set a dictionary key""" - - self.data.__delitem__(key) - self._update_db() diff --git a/hippo/pca.py b/hippo/pca.py deleted file mode 100644 index 7b6e92f5..00000000 --- a/hippo/pca.py +++ /dev/null @@ -1,92 +0,0 @@ -"""Classes and functions for generating molecular principal component analyses""" - -# https://github.com/rdkit/rdkit-tutorials/blob/master/notebooks/005_Chemical_space_analysis_and_visualization.ipynb - -import numpy as np -from rdkit import DataStructs -from rdkit.Chem import AllChem, rdFingerprintGenerator - - -class FP: - """ - Molecular fingerprint class, useful to pack features in pandas df - - Parameters - ---------- - fp : np.array - Features stored in numpy array - names : list, np.array - Names of the features - """ - - def __init__(self, fp: "np.array", names: list[str]) -> None: - """FP initialisation""" - self.fp = fp - self.names = names - - def __str__(self) -> str: - """string representation""" - return "%d bit FP" % len(self.fp) - - def __len__(self) -> int: - """length""" - return len(self.fp) - - -def get_cfps( - mol: "rdkit.Chem.Mol", - radius: int = 2, - nBits: int = 1024, - useFeatures: bool = False, - counts: bool = False, - dtype=np.float32, -): - """Calculates circular (Morgan) fingerprint. - http://rdkit.org/docs/GettingStartedInPython.html#morgan-fingerprints-circular-fingerprints - - Parameters - ---------- - mol : rdkit.Chem.rdchem.Mol - radius : float - Fingerprint radius, default 2 - nBits : int - Length of hashed fingerprint (without descriptors), default 1024 - useFeatures : bool - To get feature fingerprints (FCFP) instead of normal ones (ECFP), defaults to False - counts : bool - If set to true it returns for each bit number of appearances of each substructure (counts). Defaults to false (fingerprint is binary) - dtype : np.dtype - Numpy data type for the array. Defaults to np.float32 because it is the default dtype for scikit-learn - - Returns - ------- - ML.FP - Fingerprint (feature) object - """ - arr = np.zeros((1,), dtype) - - if counts is True: - info = {} - fp = AllChem.GetHashedMorganFingerprint( - mol, radius, nBits, useFeatures=useFeatures - ) - DataStructs.ConvertToNumpyArray(fp, arr) - else: - - # https://greglandrum.github.io/rdkit-blog/posts/2023-01-18-fingerprint-generator-tutorial.html#additional-information-explaining-bits - fmgen = rdFingerprintGenerator.GetMorganGenerator( - radius=radius, - fpSize=nBits, - atomInvariantsGenerator=rdFingerprintGenerator.GetMorganFeatureAtomInvGen(), - ) - - assert not useFeatures - - DataStructs.ConvertToNumpyArray( - fmgen.GetFingerprint(mol), - # AllChem.GetMorganFingerprintAsBitVect( - # mol, radius, nBits=nBits, useFeatures=useFeatures - # ), - arr, - ) - return FP(arr, range(nBits)) diff --git a/hippo/plotting.py b/hippo/plotting.py deleted file mode 100644 index 2f8139c2..00000000 --- a/hippo/plotting.py +++ /dev/null @@ -1,2047 +0,0 @@ -"""Functions to generate standard HIPPO plots""" - -import mrich -import molparse as mp - -import functools -import pandas as pd -import plotly.express as px -import plotly.graph_objects as go - - -""" - - ALL GRAPHS DEFINED HERE SHOULD: - - * Have a HIPPO logo - * Include the target name in the title - -""" - - -# hippo_graph decorator -def hippo_graph(func): - """HIPPO graph decorator""" - - @functools.wraps(func) - def wrapper(animal, *args, logo="top right", **kwargs): - """ - - :param animal: - :param *args: - :param logo: (Default value = 'top right') - :param **kwargs: - - """ - - wrapper_kwargs = {} - wrapper_keys = ["show", "html", "pdf", "png"] - for key in wrapper_keys: - wrapper_kwargs[key] = kwargs.pop(key, None) - - fig = func(animal, *args, **kwargs) - - if not isinstance(fig, go.Figure): - return fig - - if wrapper_kwargs["show"]: - fig.show() - - if wrapper_kwargs["html"]: - file = wrapper_kwargs["html"] - if not file.endswith(".html"): - file = f"{file}.html" - mp.write(file, fig) - - if wrapper_kwargs["pdf"]: - file = wrapper_kwargs["pdf"] - if not file.endswith(".pdf"): - file = f"{file}.pdf" - mp.write(file, fig) - - if wrapper_kwargs["png"]: - file = wrapper_kwargs["png"] - if not file.endswith(".png"): - file = f"{file}.png" - mp.write(file, fig) - - if not fig.layout.images and logo: - add_hippo_logo(fig, position=logo) - - return fig - - return wrapper - - -@hippo_graph -def plot_tag_statistics( - animal, - color="type", - subtitle=None, - log_y=False, - show_compounds=True, - show_poses=True, - compounds=None, - poses=None, - title: str | None = None, - skip: list[str] | None = None, -): - """ - - :param animal: - :param color: (Default value = 'type') - :param subtitle: (Default value = None) - :param log_y: (Default value = False) - :param compounds: (Default value = True) - :param poses: (Default value = True) - - """ - - compounds = compounds or animal.compounds - poses = poses or animal.poses - skip = skip or [] - - plot_data = [] - - for tag in animal.tags.unique: - - if tag in skip: - continue - - if show_compounds: - num_compounds = len(compounds.get_by_tag(tag=tag)) - if num_compounds: - data = dict(tag=tag, number=num_compounds, type="compounds") - plot_data.append(data) - - if show_poses: - num_poses = len(poses.get_by_tag(tag=tag)) - if num_poses: - data = dict(tag=tag, number=num_poses, type="poses") - plot_data.append(data) - - from pandas import DataFrame - - df = DataFrame(plot_data) - - df.sort_values(by="tag", inplace=True) - - fig = px.bar(df, x="tag", y="number", color=color, log_y=log_y) - - if not title: - title = "Tag Statistics" - - if subtitle: - title = f"{animal.name}: {title}
{subtitle}" - else: - title = f"{animal.name}: {title}" - - fig.update_layout( - title=title, title_automargin=False, title_yref="container", barmode="group" - ) - - fig.update_layout(xaxis_title="Tag", yaxis_title="#") - - return fig - - -@hippo_graph -def plot_interaction_histogram( - animal, - poses, - feature_metadata, - subtitle=None, -): - """ - - :param animal: - :param poses: - :param feature_metadata: - :param subtitle: (Default value = None) - - """ - - raise NotImplementedError - - df = animal._fingerprint_df(poses) - - plot_data = [] - - for key in df.columns: - count = int(df[key].sum()) - - if not count: - continue - - data = dict(str=key, count=count) - - data["family"] = feature_metadata[key]["family"] - data["res_name"] = feature_metadata[key]["res_name"] - data["res_number"] = feature_metadata[key]["res_number"] - data["res_chain"] = feature_metadata[key]["res_chain"] - data["atom_numbers"] = feature_metadata[key]["atom_numbers"] - - data["res_name_number_chain_str"] = ( - f"{feature_metadata[key]['res_name']} {feature_metadata[key]['res_number']} {feature_metadata[key]['res_chain']}" - ) - - plot_data.append(data) - - plot_df = pd.DataFrame(plot_data) - plot_df.sort_values(["res_chain", "res_number", "family"], inplace=True) - plot_df - - fig = px.bar( - plot_df, - x="res_name_number_chain_str", - y="count", - color="family", - hover_data=plot_df.columns, - ) - - title = "Leveraged protein features" - - if subtitle: - title = f"{animal.name}: {title}
{subtitle}" - else: - title = f"{animal.name}: {title}" - - fig.update_layout(title=title, title_automargin=False, title_yref="container") - - fig.update_layout(xaxis_title="Residue") - fig.update_layout(yaxis_title="#Interactions") - - return fig - - -@hippo_graph -def plot_interaction_punchcard( - animal, - poses=None, - subtitle=None, - opacity=1.0, - group: str = "pose_name", - ignore_chains=False, -): - """ - - :param animal: - :param poses: (Default value = None) - :param subtitle: (Default value = None) - :param opacity: (Default value = 1.0) - :param group: (Default value = 'pose_name') - :param ignore_chains: (Default value = False) - - """ - - import plotly - from .pset import PoseTable - - poses = poses or animal.poses - - if isinstance(poses, PoseTable): - iset = animal.interactions - else: - iset = poses.interactions - - mrich.var("#poses", len(poses)) - mrich.var("#interactions", len(iset)) - - plot_data = iset.df - - name_lookup = poses.id_name_dict - - names = [] - for pose_id in plot_data["pose_id"].values: - names.append(name_lookup[pose_id]) - plot_data["pose_name"] = names - - if ignore_chains: - x = "res_name_number" - plot_data[x] = plot_data[["residue_name", "residue_number"]].agg( - lambda x: " ".join([str(i) for i in x]), axis=1 - ) - plot_data = plot_data.sort_values([group, x, "residue_number"]) - sort_key = lambda x: x[1] - else: - x = "chain_res_name_number_str" - plot_data[x] = plot_data[["chain_name", "residue_name", "residue_number"]].agg( - lambda x: " ".join([str(i) for i in x]), axis=1 - ) - sort_key = lambda x: (x[2], x[1]) - - title = "Interaction Punch-Card" - - if subtitle: - title = f"{animal.name}: {title}
{subtitle}" - else: - title = f"{animal.name}: {title}" - - fig = px.scatter( - plot_data, - x=x, - y="type", - marginal_x="histogram", - marginal_y="histogram", - hover_data=plot_data.columns, - color=group, - title=title, - ) - - fig.update_layout(title=title, title_automargin=False, title_yref="container") - - fig.update_layout(xaxis_title="Residue", yaxis_title="Feature Family") - - # x-axis sorting - categoryarray = plot_data[[x, "residue_number", "chain_name"]].agg(tuple, axis=1) - categoryarray = sorted([v for v in categoryarray.values], key=sort_key) - categoryarray = [v[0] for v in categoryarray] - - # sort axes - fig.update_xaxes(categoryorder="array", categoryarray=categoryarray) - fig.update_yaxes(categoryorder="category descending") - - for trace in fig.data: - if type(trace) == plotly.graph_objs._histogram.Histogram: - trace.opacity = 1 - trace.xbins.size = 1 - else: - trace["marker"]["size"] = 10 - trace["marker"]["opacity"] = opacity - - fig.update_layout(barmode="stack") - fig.update_layout(scattermode="group", scattergap=0.75) - - return add_punchcard_logo(fig) - - -# @hippo_graph -def plot_interaction_punchcard_by_tags( - animal, - tags: dict[str, str] | list[str], - permitted_residues: dict[str, list[int]] | None = None, - yaxis_title: str = "Tag", - subtitle=None, - opacity=0.7, - group="type", - marginal_histogram_x: bool = True, - # marginal_histogram_y: bool = False, - sizeref=0.08, - counts: bool = True, - ignore_chains=True, - backbone_only: bool = False, - sidechain_only: bool = False, - return_plot_data: bool = False, -): - """ - - :param animal: - :param poses: (Default value = None) - :param subtitle: (Default value = None) - :param opacity: (Default value = 1.0) - :param group: (Default value = 'pose_name') - :param ignore_chains: (Default value = False) - :param permitted_residues: dictionary mapping with keys matching `tags` mapping to list of IDs for residues to include for each tag - - """ - - import plotly - import numpy as np - - if isinstance(tags, list): - tags = {v: v for v in tags} - - permitted_residues = permitted_residues or {} - - dfs = [] - for group_name, tag in tags.items(): - poses = animal.poses(tag=tag) - mrich.debug(group_name, poses) - - name_lookup = poses.id_name_dict - - with mrich.loading("Getting interactions dataframe"): - df = poses.interactions.df - - df["tag"] = tag - df["group_name"] = group_name - df["pose_name"] = [name_lookup[pose_id] for pose_id in df["pose_id"].values] - - if group_name in permitted_residues: - subset = df[df["residue_number"].isin(permitted_residues[group_name])] - diff = len(df) - len(subset) - if diff: - mrich.warning( - "Skipping", - diff, - "markers due unpermitted residue numbers for: ", - group_name, - ) - df = subset - - dfs.append(df) - - mrich.debug("Concatenating dataframes") - plot_data = pd.concat(dfs, ignore_index=True) - - ### add permitted residues - - if permitted_residues and ignore_chains: - - permitted_df = [] - for group_name, ids in permitted_residues.items(): - - unique_combinations = plot_data[plot_data["group_name"] == group_name][ - ["residue_name", "residue_number"] - ].drop_duplicates() - - for resid in ids: - - try: - resname = unique_combinations[ - unique_combinations["residue_number"] == resid - ]["residue_name"].values[0] - except IndexError: - continue - - # if plot_data[['residue_name', 'residue_number']] - - permitted_df.append( - dict(res_name_number=f"{resname} {resid}", group_name=group_name) - ) - - permitted_df = pd.DataFrame(permitted_df) - - mrich.debug("Building plot_data") - if backbone_only: - plot_data = plot_data[plot_data["backbone"] == True] - if sidechain_only: - plot_data = plot_data[plot_data["sidechain"] == True] - - if ignore_chains: - x = "res_name_number" - plot_data[x] = plot_data[["residue_name", "residue_number"]].agg( - lambda x: " ".join([str(i) for i in x]), axis=1 - ) - plot_data = plot_data.sort_values([group, x, "residue_number"]) - sort_key = lambda x: x[1] - else: - x = "chain_res_name_number_str" - plot_data[x] = plot_data[["chain_name", "residue_name", "residue_number"]].agg( - lambda x: " ".join([str(i) for i in x]), axis=1 - ) - sort_key = lambda x: (x[2], x[1]) - - if counts: - mrich.debug("Summing by residue") - orig_data = plot_data.copy() - - if ignore_chains: - plot_data = ( - plot_data.groupby(["group_name", "type", x, "residue_number"]) - .size() - .reset_index(name="count") - ) - else: - plot_data = ( - plot_data.groupby( - ["group_name", "type", x, "residue_number", "chain_name"] - ) - .size() - .reset_index(name="count") - ) - - plot_data["size"] = np.sqrt(plot_data["count"]) - - # add a size reference - - type_str = "Size" - sizes = [1, 50, 100, 250] - - dicts = [] - for group_name, size in zip(tags.keys(), sizes): - dicts.append( - dict( - group_name=group_name, - type="type_str", - res_name_number="", - residue_number=999, - count=size, - size=np.sqrt(size), - text=size, - ) - ) - - plot_data = pd.concat([plot_data, pd.DataFrame(dicts)]) - - mrich.debug("Making scatter plot") - fig = px.scatter( - plot_data, - x=x, - y="group_name", - hover_data=plot_data.columns, - color=group, - size="size" if counts else None, - text="text", - # color_discrete_sequence=px.colors.qualitative.Dark2 - ) - - fig.update_traces(textposition="middle right") - - if return_plot_data: - data_snapshot1 = plot_data.copy() - - # fig.update_layout(title=title, title_automargin=False, title_yref="container") - - fig.update_layout(xaxis_title="Residue", yaxis_title=yaxis_title) - - # x-axis sorting - if ignore_chains: - categoryarray = plot_data[[x, "residue_number"]].agg(tuple, axis=1) - else: - categoryarray = plot_data[[x, "residue_number", "chain_name"]].agg( - tuple, axis=1 - ) - categoryarray = sorted([v for v in categoryarray.values], key=sort_key) - categoryarray = [v[0] for v in categoryarray] - - # sort axes - fig.update_xaxes(categoryorder="array", categoryarray=categoryarray) - - for trace in fig.data: - if type(trace) == plotly.graph_objs._histogram.Histogram: - trace.opacity = 1 - trace.xbins.size = 1 - else: - trace["marker"]["opacity"] = opacity - - if marginal_histogram_x: - - from plotly.subplots import make_subplots - - subplot_fig = make_subplots( - rows=2, - cols=1, - specs=[[{"type": "histogram"}], [{"type": "scatter"}]], - shared_xaxes=True, - shared_yaxes=False, - vertical_spacing=0.02, - horizontal_spacing=0.02, - ) - - # add in scatter traces - for trace in fig.data: - trace.yaxis = "y2" - trace.showlegend = False - trace.marker.sizeref = sizeref - trace.marker.line.width = 0 - subplot_fig.add_trace(trace) - - # aggregate data for histogram plot - plot_data = ( - orig_data.groupby(["type", x, "residue_number"]) - .size() - .reset_index(name="count") - ) - - # generate histogram - fig2 = px.histogram(plot_data, x=x, y="count", color="type") - - # add in histogram traces - for trace in fig2.data: - trace.yaxis = "y1" - subplot_fig.add_trace(trace) - - # if permitted_residues and ignore_chains: - # trace = go.Scatter( - # name="Subsite Residues", - # x=permitted_df[x], - # y=permitted_df["group_name"], - # mode="markers", - # marker_symbol="square-open", - # marker_size=25, - # marker_color="grey") - - # trace.yaxis="y2" - # subplot_fig.add_trace(trace) - - # clean up the axes - subplot_fig.update_layout( - xaxis=dict(anchor="y2", visible=True, showticklabels=True, side="bottom"), - # xaxis2=dict(visible=True, showticklabels=True, side="bottom"), - yaxis2=dict( - anchor="x", - overlaying="x", - side="top", - categoryorder="category descending", - ), # Secondary y-axis for x marginal - ) - - # x-axis sorting - if ignore_chains: - categoryarray = plot_data[[x, "residue_number"]].agg(tuple, axis=1) - else: - raise NotImplementedError - categoryarray = sorted([v for v in categoryarray.values], key=sort_key) - categoryarray = [v[0] for v in categoryarray] - subplot_fig.update_xaxes(categoryorder="array", categoryarray=categoryarray) - - # y-axis sorting - categoryarray = list(reversed(tags.keys())) - subplot_fig.update_yaxes(categoryorder="array", categoryarray=categoryarray) - - # stack histogram bars on top of each other - subplot_fig.update_layout(barmode="stack") - - subplot_fig.update_layout( - margin=dict(l=20, r=20, t=20, b=20), - ) - - if return_plot_data: - return subplot_fig, [orig_data, plot_data, data_snapshot1] - - return subplot_fig - - # fig.update_layout(scattermode="group", scattergap=0.75) - - if return_plot_data: - return fig, plot_data - - # return add_punchcard_logo(fig) - return fig - - -@hippo_graph -def plot_residue_interactions( - animal, - residue_number, - poses: str | None = None, - subtitle: str | None = None, - chain: str | None = None, - target: int = 1, -): - """ - - :param animal: - :param poses: - :param residue_number: - :param subtitle: (Default value = None) - :param chain: (Default value = None) - - """ - - if not poses: - poses = animal.poses - - mrich.var("#poses", len(poses)) - - from .iset import InteractionSet - - iset = InteractionSet.from_residue( - animal.db, residue_number=residue_number, chain=chain - ) - - # return iset - - mrich.var("#interactions", len(iset)) - - plot_data = iset.df - - # name_lookup = {i:n for i,n in zip(poses.ids,poses.names)} - name_lookup = poses.id_name_dict - - # print(name_lookup) - - names = [] - for pose_id in plot_data["pose_id"].values: - names.append(name_lookup[int(pose_id)]) - plot_data["pose_name"] = names - - fig = px.histogram(plot_data, x="pose_name", color="type") - - # return plot_data[plot_data['pose_name'] == ' x1762b'] - - fig.update_xaxes(categoryorder="total descending") - - # set customdata from x-axis labels - for trace in fig.data: - trace["customdata"] = trace["x"] - - if not subtitle: - subtitle = f"#Poses={len(poses)}" - - residue_name = animal.db.get_feature( - id=plot_data["feature_id"].values[0] - ).residue_name - - title = f"Interactions w/ {residue_name} {residue_number}" - - if chain: - title += f" {chain}" - - title = f"{animal.name}: {title}
{subtitle}" - - fig.update_layout(title=title, title_automargin=False, title_yref="container") - - fig.update_xaxes(title="Pose") - fig.update_yaxes(title="#Interactions") - - return fig - - -@hippo_graph -# def plot_building_blocks(animal, subtitle=None, cset='elabs', color='name_is_smiles'): -def plot_reactant_amounts( - animal, subtitle=None, color="has_price_picker", named_only=False, most_common=None -): - """ - - :param animal: - :param subtitle: (Default value = None) - :param color: (Default value = 'has_price_picker') - :param named_only: (Default value = False) - :param most_common: (Default value = None) - - """ - - # cset = animal.compound_sets[cset] - - # bbs = cset.get_building_blocks() - - bbs = animal.building_blocks - - mrich.debug("making plot_data") - plot_data = [] - for bb in bbs: - d = bb.dict - # if most_common and d['amount'] is not None: - - if not named_only or not d["name_is_smiles"]: - plot_data.append(d) - - if most_common: - mrich.debug("sorting") - plot_data = sorted(plot_data, key=lambda x: x["amount"], reverse=True)[ - :most_used_number - ] - - fig = px.bar( - plot_data, x="name", y="amount", color=color, hover_data=plot_data[0].keys() - ) - # fig = px.bar(plot_data, x='smiles', y='amount', color=color) - - title = "Building Blocks" - - if not subtitle: - # subtitle = f'"{cset.name}": #BBs={len(bbs)}' - subtitle = f"#BBs={len(bbs)}" - - title = f"{animal.name}: {title}
{subtitle}" - - fig.update_layout(title=title, title_automargin=False, title_yref="container") - - fig.update_layout(xaxis_title="Reactant", yaxis_title="#Reactions") - - return fig - - -@hippo_graph -# def plot_building_blocks(animal, subtitle=None, cset='elabs', color='name_is_smiles'): -def plot_reactant_price(animal, subtitle=None, amount=20): - """ - - :param animal: - :param subtitle: (Default value = None) - :param amount: (Default value = 20) - - """ - - # cset = animal.compound_sets[cset] - - # bbs = cset.get_building_blocks() - - bbs = animal.building_blocks - - plot_data = [] - for bb in bbs: - d = bb.dict - if not d["has_price_picker"]: - continue - - d[f"price_{amount}mg"] = bb.get_price(amount) - d[f"min_amount"] = bb.price_picker.min_amount - - plot_data.append(d) - - # fig = px.bar(plot_data, x='name', y=f'price_{amount}mg', color='lead_time', log_y=True, hover_data=plot_data[0].keys()) - fig = px.histogram( - plot_data, - x=f"price_{amount}mg", - color="lead_time", - hover_data=plot_data[0].keys(), - ) - # fig = px.bar(plot_data, x='smiles', y='amount', color=color) - - title = "Reactant Pricing" - - if not subtitle: - # subtitle = f'"{cset.name}": #BBs={len(bbs)}' - subtitle = f"#BBs={len(bbs)}" - - title = f"{animal.name}: {title}
{subtitle}" - - fig.update_layout(title=title, title_automargin=False, title_yref="container") - - fig.update_layout( - yaxis_title="Number of reactants", xaxis_title=f"Price for {amount}mg [$USD]" - ) - - return fig - - -@hippo_graph -# def plot_building_blocks(animal, subtitle=None, cset='elabs', color='name_is_smiles'): -def plot_reactants_2d(animal, subtitle=None, amount=20): - """ - - :param animal: - :param subtitle: (Default value = None) - :param amount: (Default value = 20) - - """ - - # cset = animal.compound_sets[cset] - - # bbs = cset.get_building_blocks() - - bbs = animal.building_blocks - - plot_data = [] - for bb in bbs: - d = bb.dict - if not d["has_price_picker"]: - continue - - d[f"price_{amount}mg"] = bb.get_price(amount) - d[f"min_amount"] = bb.price_picker.min_amount - - plot_data.append(d) - - fig = px.scatter( - plot_data, - y="amount", - x=f"price_{amount}mg", - color="name", - hover_data=plot_data[0].keys(), - ) - # fig = px.bar(plot_data, x='smiles', y='amount', color=color) - - title = "Building Blocks" - - if not subtitle: - # subtitle = f'"{cset.name}": #BBs={len(bbs)}' - subtitle = f"#BBs={len(bbs)}" - - title = f"{animal.name}: {title}
{subtitle}" - - fig.update_layout(title=title, title_automargin=False, title_yref="container") - - fig.update_layout(scattermode="group", scattergap=0.75) - - fig.update_layout( - yaxis_title="Quantity [mg]", xaxis_title=f"Price for {amount}mg [$USD]" - ) - - return fig - - -@hippo_graph -# def plot_building_blocks(animal, subtitle=None, cset='elabs', color='name_is_smiles'): -def plot_building_blocks(animal, subtitle=None, color="name_is_smiles"): - """ - - :param animal: - :param subtitle: (Default value = None) - :param color: (Default value = 'name_is_smiles') - - """ - - # cset = animal.compound_sets[cset] - - # bbs = cset.get_building_blocks() - - bbs = animal.building_blocks - - plot_data = [] - for bb in bbs: - plot_data.append(bb.dict) - - fig = px.scatter(plot_data, x="name", y="max", color="amount") - # fig = px.bar(plot_data, x='smiles', y='amount', color=color) - - title = "Building Blocks" - - if not subtitle: - # subtitle = f'"{cset.name}": #BBs={len(bbs)}' - subtitle = f"#BBs={len(bbs)}" - - title = f"{animal.name}: {title}
{subtitle}" - - fig.update_layout(title=title, title_automargin=False, title_yref="container") - - fig.update_layout(xaxis_title="Reactant", yaxis_title="Quantity") - - return fig - - -@hippo_graph -def plot_synthetic_routes(animal, subtitle=None, cset="elabs", color="num_reactants"): - """ - - :param animal: - :param subtitle: (Default value = None) - :param cset: (Default value = 'elabs') - :param color: (Default value = 'num_reactants') - - """ - - cset = animal.compound_sets[cset] - - plot_data = [] - for reax in cset.reactions: - plot_data.append(reax.dict) - - # fig = px.bar(plot_data, x='name', y='amount', color=color) - fig = px.histogram(plot_data, x="type", color=color) - - title = "Synthetic Routes" - - if not subtitle: - subtitle = f'"{cset.name}": #compounds={len(cset)}' - - title = f"{animal.name}: {title}
{subtitle}" - - fig.update_layout(title=title, title_automargin=False, title_yref="container") - - fig.update_layout(xaxis_title="Compound", yaxis_title="#Routes") - - return fig - - -@hippo_graph -def plot_numbers(animal, subtitle=None): - """ - - y-axis: numbers - - x-categories - * hits - * hit poses - * scaffolds - * scaffold poses - * elabs - * elab poses - * BBs (total) - * BBs (in enamine) - - :param animal: - :param subtitle: (Default value = None) - - """ - - # cset = animal.compound_sets[cset] - - plot_data = [ - dict(category="Experimental Hits", number=len(animal.hits), type="compound"), - dict(category="Experimental Hits", number=len(animal.hits.poses), type="poses"), - dict(category="Base compounds", number=len(animal.scaffolds), type="compound"), - dict( - category="Base compounds", number=len(animal.scaffolds.poses), type="poses" - ), - dict( - category="Syndirella Elaborations", - number=len(animal.elabs), - type="compound", - ), - dict( - category="Syndirella Elaborations", - number=len(animal.elabs.poses), - type="poses", - ), - dict( - category="Unique Reactants", - number=len(animal.building_blocks), - type="compound", - ), - ] - - fig = px.bar(plot_data, x="category", y="number", log_y=True, color="type") - - title = "Compounds & Poses" - - title = f"{animal.name}: {title}
" - - fig.update_layout( - title=title, title_automargin=False, title_yref="container", barmode="group" - ) - - fig.update_layout(xaxis_title=None, yaxis_title="Log(Quantity)") - - return fig - - -@hippo_graph -def plot_compound_property( - animal, - prop, - compounds=None, - style="bar", - null=None, - hover_data=None, - custom_data=None, -): - """Get an arbitrary property from all the compounds in animal.compounds - - If one property, plot a 1D histogram - If 2D plot a bar/scatter - - :param animal: - :param prop: - :param compounds: (Default value = None) - :param style: (Default value = 'bar') - :param null: (Default value = None) - - """ - - if not isinstance(prop, list): - prop = [prop] - - if hover_data is None: - hover_data = [] - - plot_data = [] - - if not compounds: - compounds = animal.compounds - - if len(compounds) > 1000: - compounds = mrich.track(compounds, prefix="Generating plot data") - - for comp in compounds: - - data = comp.dict - - for p in prop: - - # has attr - - if p not in data: - - # get attr - if hasattr(comp, p): - v = getattr(comp, p) - - elif p in (m := comp.metadata): - v = m[p] - - else: - v = null - - data[p] = v - - plot_data.append(data) - - if len(prop) == 1: - - title = f"Compound {prop[0]}" - - fig = px.histogram(plot_data, x=prop[0]) - - fig.update_layout(xaxis_title=prop[0], yaxis_title="Quantity") - - elif len(prop) == 2: - - hover_data = prop + ["smiles"] + hover_data - - title = f"Compound {prop[0]} vs {prop[1]}" - - func = eval(f"px.{style}") - fig = func( - plot_data, - x=prop[0], - y=prop[1], - hover_data=hover_data, - custom_data=custom_data, - ) - - fig.update_layout(xaxis_title=prop[0], yaxis_title=prop[1]) - - else: - mrich.error("Unsupported") - - title = f"{animal.name}: {title}
" - - fig.update_layout( - title=title, title_automargin=False, title_yref="container", barmode="group" - ) - - return fig - - -@hippo_graph -def plot_pose_property( - animal, - prop, - poses=None, - style="scatter", - title=None, - null=None, - color=None, - log_y=False, - subtitle=None, - data_only=False, - custom_data=None, - **kwargs, -): - """Get an arbitrary property from all the poses in animal.poses - - If one property, plot a 1D histogram - If 2D plot a scatter plot - - :param animal: - :param prop: - :param poses: (Default value = None) - :param style: (Default value = 'scatter') - :param title: (Default value = None) - :param null: (Default value = None) - :param color: (Default value = None) - :param log_y: (Default value = False) - :param subtitle: (Default value = None) - :param data_only: (Default value = False) - :param **kwargs: - - """ - - # fetch these directly from the database - if prop in ["energy_score", "distance_score"]: - - p = prop - - if not poses: - # great all poses! - poses = animal.poses - n_poses = len(poses) - mrich.out(f"Querying database for {n_poses} poses...") - field = f"pose_{p}" - title = title or f"{p} of all poses" - query = animal.db.select_where( - table="pose", query=field, key=f"{field} is not NULL", multiple=True - ) - - else: - - # subset of poses - assert poses.table == "pose", f"{poses=} is not a set of Pose objects" - n_poses = len(poses) - mrich.out(f"Querying database for {n_poses} poses...") - field = f"pose_{p}" - title = title or f"{p} of pose subset" - query = animal.db.select_where( - table="pose", - query=field, - key=f"{field} is not NULL and pose_id in {poses.str_ids}", - multiple=True, - ) - - plot_data = [{p: v} for v, in query] - - if p == "energy_score": - subtitle = ( - subtitle - or f"#poses={n_poses}, energy_score < 0 = {len([None for d in plot_data if d[p] < 0])/n_poses:.1%}" - ) - elif p == "distance_score": - subtitle = ( - subtitle - or f"#poses={n_poses}, distance_score < 2 = {len([None for d in plot_data if d[p] < 2])/n_poses:.1%}" - ) - else: - subtitle = subtitle or f"#poses={n_poses}" - - prop = [prop] - - elif prop == ["energy_score", "distance_score"] or prop == [ - "distance_score", - "energy_score", - ]: - - query = f"pose_id, pose_distance_score, pose_energy_score" - - # hardcoded errorbars - distance_score_err = 0.03 - energy_score_err = 6 - - if color: - query += f", {color}" - - if not poses: - # great all poses! - poses = animal.poses - n_poses = len(poses) - mrich.out(f"Querying database for {n_poses} poses...") - title = f"distance & energy scores of all poses" - query = animal.db.select(table="pose", query=query, multiple=True) - - else: - - # subset of poses - n_poses = len(poses) - mrich.out(f"Querying database for {n_poses} poses...") - title = f"distance & energy scores of pose subset" - query = animal.db.select_where( - table="pose", - query=query, - key=f"pose_id in {poses.str_ids}", - multiple=True, - ) - - plot_data = [] - for q in query: - d = { - "id": q[0], - "distance_score": q[1], - "energy_score": q[2], - "distance_score_err": distance_score_err, - "energy_score_err": energy_score_err, - } - if color: - d[color] = q[-1] - if color == "pose_compound": - d[color] = f"C{d[color]}" - - plot_data.append(d) - - kwargs["error_x"] = "energy_score_err" - kwargs["error_y"] = "distance_score_err" - - subtitle = subtitle or f"#poses={n_poses}" - - # elif prop == ['num_atoms_added', 'energy_score'] or prop == ['num_atoms_added', 'energy_score']: - # mrich.error('Use animal.plot_pose_risk_vs_placement') - # raise NotImplementedError - - # query = f'pose_id, , pose_distance_score, pose_energy_score' - - # if not poses: - # # great all poses! - # poses = animal.poses - # n_poses = len(poses) - # mrich.out(f'Querying database for {n_poses} poses...') - # title = f'distance & energy scores of all poses' - # query = animal.db.select(table='pose', query=query, multiple=True) - - # else: - - # # subset of poses - # n_poses = len(poses) - # mrich.out(f'Querying database for {n_poses} poses...') - # title = f'distance & energy scores of pose subset' - # query = animal.db.select_where(table='pose', query=query, key=f'pose_id in {poses.str_ids}', multiple=True) - - # plot_data = [{'id':id, 'distance_score':v1, 'energy_score':v2 } for id,v1,v2 in query] - - # subtitle = f'#poses={n_poses}' - - else: - - if not poses: - poses = animal.poses - - if prop == "tags": - - plot_data = [] - - for tag in poses.tags: - - num_poses = len(poses.get_by_tag(tag=tag)) - data = dict(tag=tag, number=num_poses) - plot_data.append(data) - - fig = px.bar(plot_data, x="tag", y="number", color=color, log_y=log_y) - - title = "Tag Statistics" - - fig.update_layout( - title=title, - title_automargin=False, - title_yref="container", - barmode="group", - ) - - fig.update_layout(xaxis_title="Tag", yaxis_title="#") - - return fig - - if not isinstance(prop, list): - prop = [prop] - - plot_data = [] - if len(poses) > 1000: - poses = mrich.track(poses, prefix="Generating plot data") - - for pose in poses: - if len(prop) > 1: - data = dict(id=pose.id) - else: - data = {} - - for p in prop: - if p not in data: - # get attr - if hasattr(pose, p): - v = getattr(pose, p) - - elif p in (m := pose.metadata): - v = m[p] - - else: - v = null - - data[p] = v - - if color: - if color not in data: - # get attr - if hasattr(pose, color): - v = getattr(pose, color) - - elif color in (m := pose.metadata): - v = m[color] - - else: - v = null - - data[color] = v - - plot_data.append(data) - - if data_only: - return plot_data - - hover_data = ["id"] # , 'alias', 'inchikey'] #, 'tags', 'inspirations'] - - if len(prop) == 1: - - title = title or f"Pose {prop[0]}" - - fig = px.histogram(plot_data, x=prop[0], hover_data=None, color=color, **kwargs) - - fig.update_layout(xaxis_title=prop[0], yaxis_title="Quantity") - - elif len(prop) == 2: - - if style == "histogram": - - x = [d[prop[0]] for d in plot_data] - y = [d[prop[1]] for d in plot_data] - - fig = go.Figure(go.Histogram2d(x=x, y=y, **kwargs)) - - else: - - # if style == "bar": - # style = "scatter" - - func = eval(f"px.{style}") - fig = func( - plot_data, - x=prop[0], - y=prop[1], - color=color, - hover_data=hover_data, - custom_data=custom_data, - **kwargs, - ) - - title = title or f"Pose {prop[0]} vs {prop[1]}" - fig.update_layout(xaxis_title=prop[0], yaxis_title=prop[1]) - - else: - mrich.error("Unsupported") - - title = title or f"{animal.name}: {title}
" - - if subtitle: - title = f"{title}
{subtitle}" - - fig.update_layout( - title=title, title_automargin=False, title_yref="container", barmode="group" - ) - - return fig - - -@hippo_graph -def plot_compound_availability(animal, compounds=None, title=None, subtitle=None): - """ - - :param animal: - :param compounds: (Default value = None) - :param title: (Default value = None) - :param subtitle: (Default value = None) - - """ - - from .cset import CompoundTable, CompoundSet - - compounds = compounds or animal.compounds - - match compounds: - case CompoundTable(): - pairs = animal.db.select( - table="quote", - query="DISTINCT quote_supplier, quote_catalogue", - multiple=True, - ) - - plot_data = [] - for supplier, catalogue in pairs: - - if catalogue is None: - catalogue = "None" - cat_str = "NULL" - else: - cat_str = f'"{catalogue}"' - - (count,) = animal.db.select_where( - table="quote", - query="COUNT(DISTINCT quote_compound)", - key=f'quote_supplier IS "{supplier}" AND quote_catalogue IS {cat_str}', - ) - - plot_data.append( - dict(supplier=supplier, catalogue=catalogue, count=count) - ) - - case CompoundSet(): - - pairs = animal.db.select( - table="quote", - query="DISTINCT quote_supplier, quote_catalogue", - multiple=True, - ) - - plot_data = [] - for supplier, catalogue in pairs: - - if catalogue is None: - catalogue = "None" - cat_str = "NULL" - else: - cat_str = f'"{catalogue}"' - - (count,) = animal.db.select_where( - table="quote", - query="COUNT(DISTINCT quote_compound)", - key=f'quote_supplier IS "{supplier}" AND quote_catalogue IS {cat_str} AND quote_compound IN {compounds.str_ids}', - ) - - if not count: - continue - - plot_data.append( - dict(supplier=supplier, catalogue=catalogue, count=count) - ) - - case _: - raise NotImplementedError - - fig = px.bar(plot_data, x="catalogue", y="count", color="supplier") - - title = "Compound availability" - - title = title or f"{animal.name}: Compound availability
" - - if subtitle: - title = f"{title}
{subtitle}" - - fig.update_layout( - title=title - ) # ,title_automargin=False, title_yref='container', barmode='group') - - return fig - - -# @hippo_graph -def plot_compound_availability_venn(animal, compounds): - """ - - :param animal: - :param compounds: (Default value = None) - :param title: (Default value = None) - :param subtitle: (Default value = None) - - """ - - from venn import venn - from .cset import CompoundTable, CompoundSet - - pairs = animal.db.select( - table="quote", - query="DISTINCT quote_supplier, quote_catalogue", - multiple=True, - ) - - plot_data = {} - - for supplier, catalogue in pairs: - - if catalogue is None: - catalogue = "None" - cat_str = "NULL" - else: - cat_str = f'"{catalogue}"' - - if (supplier, catalogue) not in plot_data: - plot_data[(supplier, catalogue)] = set() - - records = animal.db.select_where( - table="quote", - query="quote_compound", - key=f'quote_supplier IS "{supplier}" AND quote_catalogue IS {cat_str} AND quote_compound IN {compounds.str_ids}', - multiple=True, - none="quiet", - ) - - if not records: - continue - - for (i,) in records: - plot_data[(supplier, catalogue)].add(i) - - plot_data = {k: v for k, v in plot_data.items() if v} - - # return plot_data - - return venn(plot_data) - - -@hippo_graph -def plot_compound_price( - animal, - compounds=None, - min_amount=1, - subtitle=None, - title=None, - style="histogram", - **kwargs, -): - """ - - :param animal: - :param compounds: (Default value = None) - :param min_amount: (Default value = 1) - :param subtitle: (Default value = None) - :param title: (Default value = None) - :param style: (Default value = 'histogram') - :param **kwargs: - - """ - - from .cset import CompoundTable, CompoundSet - import numpy as np - - compounds = compounds or animal.compounds - - match compounds: - case CompoundTable(): - - if style == "scatter": - - sql = """ - SELECT quote_compound, quote_amount, MIN(quote_price), quote_lead_time, compound_smiles, COUNT(DISTINCT reactant_reaction) - FROM quote - INNER JOIN compound ON quote.quote_compound = compound.compound_id - INNER JOIN reactant ON quote.quote_compound = reactant.reactant_compound - WHERE quote_amount >= {min_amount} - GROUP BY quote_compound - """.format( - min_amount=min_amount - ) - - results = animal.db.execute(sql).fetchall() - - n_compounds = len(results) - - plot_data = [] - for ( - compound_id, - amount, - price, - lead_time, - smiles, - num_reactions, - ) in results: - plot_data.append( - dict( - compound_id=compound_id, - min_price=price, - quoted_amount=amount, - lead_time=lead_time, - smiles=smiles, - num_reactions=num_reactions, - log_price_per_reaction=np.log(price / num_reactions), - price_per_reaction=price / num_reactions, - ) - ) - - else: - data = animal.db.select_where( - table="quote", - query="quote_amount, MIN(quote_price)", - key=f"quote_amount >= {min_amount} GROUP BY quote_compound", - multiple=True, - ) - - n_compounds = len(data) - - plot_data = [] - for amount, price in data: - plot_data.append(dict(min_price=price, quoted_amount=amount)) - - case CompoundSet(): - - if style == "scatter": - - sql = """ - SELECT quote_compound, quote_amount, MIN(quote_price), quote_lead_time, compound_smiles, COUNT(DISTINCT reactant_reaction) - FROM quote - INNER JOIN compound ON quote.quote_compound = compound.compound_id - INNER JOIN reactant ON quote.quote_compound = reactant.reactant_compound - WHERE quote_amount >= {min_amount} - AND quote_compound IN {str_ids} - GROUP BY quote_compound - """.format( - min_amount=min_amount, str_ids=compounds.str_ids - ) - - results = animal.db.execute(sql).fetchall() - - n_compounds = len(results) - - plot_data = [] - for ( - compound_id, - amount, - price, - lead_time, - smiles, - num_reactions, - ) in results: - plot_data.append( - dict( - compound_id=compound_id, - min_price=price, - quoted_amount=amount, - lead_time=lead_time, - smiles=smiles, - num_reactions=num_reactions, - log_price_per_reaction=np.log(price / num_reactions), - price_per_reaction=price / num_reactions, - ) - ) - - else: - data = animal.db.select_where( - table="quote", - query="quote_amount, MIN(quote_price)", - key=f"quote_amount >= {min_amount} AND quote_compound IN {compounds.str_ids} GROUP BY quote_compound", - multiple=True, - ) - - n_compounds = len(data) - - plot_data = [] - for amount, price in data: - plot_data.append(dict(min_price=price, quoted_amount=amount)) - - case _: - raise NotImplementedError("CompoundSet not yet supported") - - plot_data = sorted(plot_data, key=lambda x: x["quoted_amount"]) - - match style: - - case "histogram": - fig = px.histogram( - plot_data, color="quoted_amount", x="min_price", **kwargs - ) - - case "violin": - fig = px.violin(plot_data, color="quoted_amount", x="min_price", **kwargs) - - case "scatter": - fig = px.scatter( - plot_data, - color="log_price_per_reaction", - x="min_price", - y="lead_time", - hover_data=plot_data[0].keys(), - **kwargs, - ) - - case _: - raise NotImplementedError(f"{style=}") - - subtitle = subtitle or f"#compounds={n_compounds}, {min_amount=} mg" - - title = title or f"{animal.name}: Compound price
" - - if subtitle: - title = f"{title}
{subtitle}" - - fig.update_layout( - title=title - ) # ,title_automargin=False, title_yref='container', barmode='group') - - return fig - - -@hippo_graph -def plot_reaction_funnel(animal, title=None, subtitle=None): - """ - - :param animal: - :param title: (Default value = None) - :param subtitle: (Default value = None) - - """ - - compounds = animal.compounds - - data = dict( - number=[ - compounds.num_reactants, - compounds.num_intermediates, - compounds.num_products, - ], - category=["Reactants", "Intermediates", "Products"], - ) - - fig = px.funnel(data, x="category", y="number") - - title = title or f"{animal.name}: Reaction statistics" - - if subtitle: - title = f"{title}
{subtitle}" - - fig.update_layout(title=title, title_automargin=False, title_yref="container") - - return fig - - -HIPPO_LOGO_URL = "https://raw.githubusercontent.com/mwinokan/HIPPO/main/logos/hippo_logo_tightcrop.png" -HIPPO_HEAD_URL = ( - "https://raw.githubusercontent.com/mwinokan/HIPPO/main/logos/hippo_assets-02.png" -) - - -def plot_pose_interactions( - animal: "HIPPO", pose: "Pose" -) -> "plotly.graph_objects.Figure": - """3d figure showing the interactions between a :class:`.Pose` and the protein. In a Jupyter notebook this figure may be unusable, instead write it as a HTML file and open it in your browser: - - :: - - import molparse as mp - fig = animal.plot_pose_interactions(pose) - mp.write(f'{pose}_interactions.html', fig) - - :param pose: the :class:`.Pose` whose interactions are to be rendered - - """ - - import molparse as mp - - # get interactions - iset = pose.interactions - - # get the protein - protein = pose.protein_system - - # get interacting residues - pairs = iset.residue_number_chain_pairs - - # get residues - residues = [] - for resnum, chain in pairs: - residues.append(protein.get_chain(chain).residues[f"n{resnum}"]) - - # get ligand - lig_group = mp.rdkit.mol_to_AtomGroup(pose.mol) - - # create the combined plotting group - plot_group = mp.AtomGroup.from_any(str(pose), residues + [lig_group]) - - # interaction labels and vectors - labels = [] - extras = [] - for interaction in iset: - extras.append([interaction.prot_coord, interaction.lig_coord]) - labels.append(interaction.description) - - # create the figure - fig = plot_group.plot3d(show=False, extra=extras, extra_labels=labels) - - return fig - - -@hippo_graph -def plot_compound_tsnee( - animal: "HIPPO | None" = None, - compounds: "CompoundSet | None" = None, - df: "pd.DataFrame | None" = None, - title: str | None = None, - subtitle: str | None = None, - legend: bool = False, - symbol: str = "type", - sort_by: str = "type", - color: str = "cluster", - cluster_by: str = "scaffolds", - **kwargs, -) -> "plotly.graph_objects.Figure": - """Plot a compound tanimoto similarity plot with principal components determined by pattern binary fingerprint similarity. - - :param compounds: compounds to plot - :param df: optional pre-computed DataFrame - :param title: plot title - :param subtitle: plot subtitle - :param legend: show the plot legend - :param symbol: property used to determine symbol - :param sort_by: property used to sort dataframe - :param color: property used to determine marker color - :returns: `plotly.graph_objects.Figure` - - """ - - from .pca import get_cfps - from sklearn.decomposition import PCA - import numpy as np - - if compounds: - mrich.var("#compounds", len(compounds)) - - if df is None: - with mrich.loading("Getting Compound DataFrame"): - df = compounds.get_df(mol=True, scaffolds=True, inchikey=True, alias=True) - df = df.reset_index() - - df["scaffolds"] = df["scaffolds"].map( - lambda x: x if not isinstance(x, float) else None - ) - - else: - - # check dataframe columns - if "mol" not in df.columns: - mrich.error("'mol' column not in dataframe") - return None - - if cluster_by not in df.columns: - mrich.error(f"{cluster_by=} column not in dataframe") - return None - - with mrich.loading("Getting Compound fingerprints"): - df["FP"] = df["mol"].map(get_cfps) - - def get_cluster(row): - """Get cluster""" - - scaffolds = row[cluster_by] - - if not scaffolds: - return row["id"] - - if scaffolds is None: - mrich.error(row) - return - - if len(scaffolds) == 1: - return list(scaffolds)[0] - - return tuple(scaffolds) - - def get_type(row): - """Get type""" - - if row[cluster_by] is None: - return "scaffold" - - return "elaboration" - - with mrich.loading("Adding columns"): - df[cluster_by] = df[cluster_by].apply(tuple) - df["cluster"] = df.apply(get_cluster, axis=1) - df["type"] = df.apply(get_type, axis=1) - - if sort_by: - df = df.sort_values(by=sort_by) - - X = np.array([x.fp for x in df["FP"]]) - - with mrich.loading("Computing PCA"): - pca = PCA(n_components=2, random_state=0) - pca_fit = pca.fit_transform(X) - - df["PC1"] = pca_fit.T[0] - df["PC2"] = pca_fit.T[1] - - hover_data = [ - "id", - "smiles", - "alias", - "inchikey", - "PC1", - "PC2", - cluster_by, - "cluster", - "type", - ] - - df["scaffolds"] = df[cluster_by].astype(str) - df["cluster"] = df["cluster"].astype(str) - - with mrich.loading("Creating figure"): - fig = px.scatter( - df, - x="PC1", - y="PC2", - hover_data=hover_data, - color=color, - symbol=symbol, - # custom_data="alias", - **kwargs, - ) - - subtitle = subtitle or f"#compounds={len(df)}" - - title = title or f"{compounds} PCA
" - - if subtitle: - title = f"{title}
{subtitle}" - - fig.update_layout(title=title) - - if not legend: - fig.update_layout(showlegend=False) - - return fig - - -def add_hippo_logo(fig, in_plot=True, position="top right"): - """ - - :param fig: - :param in_plot: (Default value = True) - :param position: (Default value = 'top right') - - """ - - assert fig.layout.title.text, "Figure must have a title to add the HIPPO logo" - - if in_plot: - - sizex = 0.3 - sizey = 0.3 - - if "top" in position: - yanchor = "top" - y = 0.95 - elif "bottom" in position: - yanchor = "bottom" - y = 0.05 - else: - yanchor = "middle" - y = 0.50 - - if "left" in position: - xanchor = "left" - x = 0.05 - elif "right" in position: - xanchor = "right" - x = 0.95 - else: - xanchor = "center" - x = 0.50 - - fig.add_layout_image( - dict( - source=HIPPO_LOGO_URL, - xref="paper", - yref="paper", - # layer='below', - x=x, - y=y, - sizex=sizex, - sizey=sizey, - xanchor=xanchor, - yanchor=yanchor, - ) - ) - - return fig - - has_legend = fig.layout.legend.title.text is not None - fig.layout.margin.t = None - - if has_legend: - - fig.add_layout_image( - dict( - source="", - xref="paper", - yref="paper", - x=1, - y=1.05, - sizex=0.4, - sizey=0.4, - xanchor="left", - yanchor="bottom", - ) - ) - - else: - - fig.add_layout_image( - dict( - source=HIPPO_LOGO_URL, - xref="paper", - yref="paper", - x=1, - y=1.05, - sizex=0.3, - sizey=0.3, - xanchor="right", - yanchor="bottom", - ) - ) - - return fig - - -def add_punchcard_logo(fig): - """Add the HIPPO logo to a punchcard figure""" - - fig.add_layout_image( - dict( - source=HIPPO_HEAD_URL, - xref="paper", - yref="paper", - x=1, - y=1, - sizex=0.25, - sizey=0.25, - xanchor="right", - yanchor="top", - ) - ) - - return fig diff --git a/hippo/pose.py b/hippo/pose.py deleted file mode 100644 index 402fd8d3..00000000 --- a/hippo/pose.py +++ /dev/null @@ -1,1711 +0,0 @@ -"""Classes for working with poses""" - -import mcol -import mrich -from mrich import print - - -import pickle -import numpy as np -from rdkit import Chem -from pathlib import Path - -from .tags import TagSet - -import molparse as mp -from molparse.rdkit.features import ( - FEATURE_FAMILIES, - COMPLEMENTARY_FEATURES, - INTERACTION_TYPES, -) - -INTERACTION_CUTOFF = { - "Hydrophobic": 4.5, - "Hydrogen Bond": 3.5, - "Electrostatic": 4.5, - "π-stacking": 6.0, - "π-cation": 4.5, - "Sulfur-Sulfur": 4.0, # https://pubs.acs.org/doi/full/10.1021/acs.cgd.5b01058 -} - -PI_STACK_MIN_CUTOFF = 3.8 -PI_STACK_F2F_CUTOFF = 4.5 -PI_STACK_E2F_CUTOFF = 6.0 -MUTATION_WARNING_DIST = 15 - - -class Pose: - """A :class:`.Pose` is a particular conformer of a :class:`.Compound` within a protein environment. A pose will have its own (stereochemical) smiles string, and must have a path to a coordinate file. Poses can have *inspirations* that can be used to trace fragment-derived scaffolds in merges and expansions. - - .. attention:: - - :class:`.Pose` objects should not be created directly. Instead use :meth:`.HIPPO.register_pose` or :meth:`.HIPPO.poses` - - """ - - _table = "pose" - - def __init__( - self, - db: "Database", - id: int, - inchikey: str | None, - alias: str | None, - smiles: str, - reference: int, # another pose - path: str, - compound: int, - target: int, - mol: Chem.Mol | bytes | None, - fingerprint: int, - energy_score: float | None = None, - distance_score: float | None = None, - inspiration_score: float | None = None, - metadata: dict | None = None, - ): - """Pose initialisation""" - - self._db = db - self._id = id - self._inchikey = inchikey - self._alias = alias - self._smiles = smiles - self._compound_id = compound - self._target = target - self._path = path - self._protein_system = None - self._energy_score = energy_score - self._distance_score = distance_score - self._inspiration_score = inspiration_score - - self._scaffold_ids = None - self._num_heavy_atoms = None - - self._has_fingerprint = False - - if fingerprint is None: - self._has_fingerprint = False - elif not isinstance(fingerprint, int): - mrich.warning("Legacy fingerprint data format") - self.has_fingerprint = False - else: - self.has_fingerprint = bool(fingerprint) - - # print(f'{self}{metadata=}') - self._metadata = metadata - self._tags = None - self._reference = reference - self._reference_id = reference - self._interactions = None - - if isinstance(mol, bytes): - self._mol = Chem.Mol(mol) - else: - self._mol = mol - - self._total_changes = db.total_changes - self._num_atoms_added_wrt_inspirations = None - - ### FACTORIES - - ### PROPERTIES - - @property - def db(self) -> "Database": - """Returns a pointer to the parent database""" - return self._db - - @property - def id(self) -> int: - """Returns the pose's database ID""" - return self._id - - @property - def inchikey(self) -> str: - """Returns the pose's inchikey""" - if not self._inchikey: - self.smiles - return self._inchikey - - @property - def alias(self) -> str: - """Returns the pose's alias""" - return self._alias - - @property - def name(self) -> str: - """Returns the pose's name""" - if n := self.alias: - return n - else: - return self.inchikey - - @alias.setter - def alias(self, n) -> None: - """Set the pose's alias""" - assert isinstance(n, str) - self._alias = n - self.db.update(table="pose", id=self.id, key="pose_alias", value=n) - - @inchikey.setter - def inchikey(self, n) -> None: - """Set the pose's inchikey""" - assert isinstance(n, str) - self._inchikey = n - self.db.update(table="pose", id=self.id, key="pose_inchikey", value=n) - - @property - def smiles(self) -> str: - """Returns the pose's smiles""" - if not self._smiles: - from molparse.rdkit import mol_to_smiles - from rdkit.Chem.inchi import MolToInchiKey - - try: - mol = self.mol - self._smiles = mol_to_smiles(mol) - self.inchikey = MolToInchiKey(mol) - self.db.update( - table="pose", id=self.id, key="pose_smiles", value=self._smiles - ) - except InvalidMolError: - mrich.warning(f"Taking smiles from {self.compound}") - self._smiles = self.compound.smiles - return self._smiles - - @property - def target(self) -> "Target": - """Returns the pose's associated target""" - if isinstance(self._target, int): - self._target = self.db.get_target(id=self._target) - return self._target - - @property - def compound_id(self) -> int: - """Returns the pose's associated compound ID""" - return self._compound_id - - @property - def compound(self) -> "Compound": - """Returns the pose's associated compound""" - return self.get_compound() - - @property - def path(self) -> str: - """Returns the pose's path""" - return self._path - - @property - def reference(self) -> "Pose": - """Returns the pose's protein reference (another pose)""" - if isinstance(self._reference, int): - self._reference = self.db.get_pose(id=self._reference) - return self._reference - - @property - def reference_id(self) -> int: - """Returns the pose's protein reference ID""" - return self._reference_id - - @reference.setter - def reference(self, p): - """Set the pose's reference""" - if not isinstance(p, int): - assert p._table == "pose" - p = p.id - self._reference = p - self._reference_id = p - self.db.update(table="pose", id=self.id, key="pose_reference", value=p) - - @property - def mol(self) -> "rdkit.Chem.Mol": - """Returns a pose's rdkit.Chem.Mol""" - if not self._mol and self.path: - - if self.path.endswith(".pdb"): - - mrich.reading(self.path) - - # mrich.reading(self.path) - sys = mp.parse(self.path, verbosity=False) - - self.protein_system = sys.protein_system - - sdf_path = list(Path(self.path).parent.glob("*_ligand.sdf")) - - if len(sdf_path) == 1: - - supplier = Chem.SDMolSupplier(sdf_path[0]) - mols = [mol for mol in supplier if mol is not None] - - if len(mols) > 1: - mrich.warning(f"Multiple molecules in SDF {self}") - - self.mol = mols[0] - return self._mol - - # look for ligand mol from Fragalysis - mol_path = list( - Path(self.path).parent.glob("*_ligand.mol") - ) # str(Path(self.path).name).replace('.pdb','_ligand.mol') - - if len(mol_path) == 1: - mol_path = mol_path[0] - from rdkit.Chem import MolFromMolFile - - self._mol_path = mol_path.resolve() - - mol = MolFromMolFile(str(self._mol_path)) - - elif len(mol_path) == 0: - - lig_residues = sys["rLIG"] - - if not lig_residues: - lig_residues = [r for r in sys.residues if r.type == "LIG"] - - if len(lig_residues) > 1: - mrich.warning(f"Multiple ligands in PDB {self}") - - lig_res = lig_residues[0] - - if not (mol := lig_res.rdkit_mol): - mrich.error( - f"[{self}] Error computing RDKit Mol from PDB={self.path}" - ) - - print(lig_res.pdb_block) - - lig_res.plot3d() - - raise InvalidMolError - - # clean up bond orders - from rdkit.Chem.AllChem import ( - MolFromSmiles, - AssignBondOrdersFromTemplate, - ) - - template = MolFromSmiles(self.compound.smiles) - try: - mol = AssignBondOrdersFromTemplate(template, mol) - except Exception as e: - mrich.error( - f"Exception occured during AssignBondOrdersFromTemplate for {self}.mol" - ) - print(f"template_smiles={self.compound.smiles}") - print(f"pdbblock={print(lig_res.pdb_block)}") - mrich.error(e) - mol = lig_res.rdkit_mol - - else: - - path = Path(self.path) - parent_dir = path.parent - mol_path = parent_dir / path.name.replace( - "_hippo.pdb", ".pdb" - ).replace(".pdb", "_ligand.mol") - - if not mol_path.exists(): - mrich.warning( - f"There are multiple *_ligand.mol files in {Path(self.path).parent}" - ) - raise FileNotFoundError(mol_path) - - from rdkit.Chem import MolFromMolFile - - mol = MolFromMolFile(str(mol_path)) - - self.mol = mol - - elif self.path.endswith(".mol"): - - mrich.reading(self.path) - - # mrich.reading(self.path) - mol = mp.parse(self.path, verbosity=False) - - if not mol: - mrich.error( - f"[{self}] Error computing RDKit Mol from .mol={self.path}" - ) - - raise InvalidMolError - - self.mol = mol - - else: - - raise NotImplementedError - - if not mol: - mrich.error(f"Could not parse {self}.path={self.path}") - - return self._mol - - @mol.setter - def mol(self, m): - """Set the pose's rdkit.Chem.Mol""" - assert m - from .tools import sanitise_mol - - self._mol = sanitise_mol(m) - self.db.update(table="pose", id=self.id, key="pose_mol", value=m.ToBinary()) - - @property - def protonated_mol(self) -> "rdkit.Chem.Mol": - """Guess hydrogen positions""" - from rdkit.Chem import AllChem - - mol = self.mol - protonated_mol = Chem.AddHs(mol) - try: - protonated_mol = AllChem.ConstrainedEmbed(protonated_mol, mol) - except Exception as e: - mrich.error("Error while embedding protonated molecule") - mrich.error(e) - return mol - return protonated_mol - - @property - def protein_system(self) -> "molparse.System": - """Returns the pose's protein molparse.System""" - if self._protein_system is None and self.path.endswith(".pdb"): - # mrich.debug(f'getting pose protein system {self}') - self.protein_system = mp.parse(self.path, verbosity=False).protein_system - return self._protein_system - - @protein_system.setter - def protein_system(self, a): - """Sets the pose's protein molparse.System""" - self._protein_system = a - - @property - def complex_system(self) -> "molparse.System": - """Get molparse.System representation of the protein-ligand complex""" - - if self.has_complex_pdb_path: - return mp.parse(self.path, verbosity=False) - - elif self.reference: - - # construct from .mol and reference - - system = self.reference.protein_system.copy() - - system.name = ( - f"{self.target.name}_{self.reference.name}_{self.compound.name}" - ) - - from molparse.rdkit import mol_to_AtomGroup - - ligand = mol_to_AtomGroup(self.mol) - - for atom in ligand.atoms: - system.add_atom(atom) - - return system - - else: - - raise NotImplementedError - - @property - def has_complex_pdb_path(self) -> bool: - """Does this pose have a PDB file?""" - return self.path.endswith(".pdb") - - @property - def metadata(self) -> "MetaData": - """Returns the pose's metadata""" - if self._metadata is None: - self._metadata = self.db.get_metadata(table="pose", id=self.id) - return self._metadata - - @property - def has_fingerprint(self) -> bool: - """Does the pose have a fingerprint?""" - return self._has_fingerprint - - @has_fingerprint.setter - def has_fingerprint(self, fp): - self.set_has_fingerprint(fp) - - @property - def tags(self) -> "TagSet": - """Returns the pose's tags""" - if not self._tags: - self._tags = self.get_tags() - return self._tags - - @property - def inspirations(self) -> "PoseSet": - """Returns the pose's inspirations""" - return self.get_inspirations() - - @property - def derivatives(self) -> "PoseSet": - """Returns the pose's derivatives""" - return self.get_derivatives() - - @property - def features(self) -> "list[molparse.rdkit.Feature]": - """Returns the pose's features""" - return mp.rdkit.features_from_mol(self.mol) - - @property - def dict(self) -> dict: - """Serialised dictionary representing the pose""" - return self.get_dict() - - @property - def table(self) -> str: - """Get the name of the database table""" - return self._table - - @property - def num_heavy_atoms(self) -> int: - """Number of heavy atoms""" - if not self._num_heavy_atoms: - self._num_heavy_atoms = self.db.get_compound_computed_property( - "num_heavy_atoms", self.compound_id - ) - return self._num_heavy_atoms - - @property - def num_atoms_added(self) -> int: - """Calculate the number of atoms added relative to the scaffold or inspirations""" - if self.num_scaffolds == 1: - return self.num_atoms_added_wrt_scaffolds - else: - return self.num_atoms_added_wrt_inspirations - - @property - def num_atoms_added_wrt_scaffolds(self) -> int | list[int] | None: - """Calculate the number of atoms added relative to the scaffold""" - return self.compound.num_atoms_added - - @property - def num_atoms_added_wrt_inspirations(self) -> int | None: - """Calculate the number of atoms added relative to its inspirations""" - - if self._num_atoms_added_wrt_inspirations is None or self._db_changed: - - sql = f""" - WITH inspirations AS ( - SELECT SUM(mol_num_hvyatms(compound_mol)) AS sum, inspiration_derivative FROM inspiration - INNER JOIN pose ON inspiration_original = pose_id - INNER JOIN compound ON pose_compound = compound_id - WHERE inspiration_derivative = {self.id} - ) - SELECT mol_num_hvyatms(compound_mol) - sum FROM inspirations - INNER JOIN pose ON inspiration_derivative = pose_id - INNER JOIN compound ON compound_id = pose_compound - """ - - result = self.db.execute(sql).fetchone() - - if not result: - return None - - (self._num_atoms_added_wrt_inspirations,) = result - - return self._num_atoms_added_wrt_inspirations - - @property - def num_scaffolds(self) -> int: - """Get the number of scaffold scaffolds""" - return len(self.scaffold_ids) - - @property - def scaffold_ids(self) -> list[int] | None: - """Get the scaffold :class:`.Compound` IDs""" - if self._scaffold_ids is None: - records = self.db.select_where( - table="scaffold", - query="scaffold_base", - key="superstructure", - value=self.compound_id, - multiple=True, - none="quiet", - ) - records = [i for i, in records] - self._scaffold_ids = records - return self._scaffold_ids - - @property - def energy_score(self) -> float | None: - """Energy score of the Pose (kcal/mol)""" - return self._energy_score - - @property - def distance_score(self) -> float | None: - """Distance score of the Pose (w.r.t. its inspirations), in Angstroms""" - return self._distance_score - - @property - def inspiration_score(self) -> float | None: - """inspiration score of the Pose in range 0.00-1.00""" - return self._inspiration_score - - @property - def interactions(self) -> "InteractionSet": - """Get a :class:`.InteractionSet` for this :class:`.Pose`""" - if not self._interactions: - from .iset import InteractionSet - - self._interactions = InteractionSet.from_pose(self) - return self._interactions - - @property - def classic_fingerprint(self) -> dict: - """Classic HIPPO fingerprint dictionary, mapping protein :class:`.Feature` ID's to the number of corresponding ligand features (from any :class:`.Pose`)""" - return self.interactions.classic_fingerprint - - @property - def subsites(self) -> "list[SubsiteTag]": - """Get member :class:`.SubsiteTag`""" - - from .subsite import SubsiteTag - - records = self.db.select_where( - table="subsite_tag", - key="pose", - value=self.id, - multiple=True, - query="subsite_tag_id, subsite_tag_ref", - none="quiet", - ) - - if not records: - return None - - subsite_tags = [] - for record in records: - id, ref = record - subsite_tag = SubsiteTag(db=self.db, id=id, subsite_id=ref, pose_id=self.id) - subsite_tags.append(subsite_tag) - - return subsite_tags - - @property - def _db_changed(self) -> bool: - """Has the database changed?""" - if self._total_changes != self.db.total_changes: - self._total_changes = self.db.total_changes - return True - return False - - @property - def mol_path(self) -> "Path": - """Get Path to molecule file""" - path = Path(self.path) - if path.name.endswith(".pdb"): - mol_path = path.parent / path.name.replace("_hippo.pdb", ".pdb").replace( - ".pdb", "_ligand.mol" - ) - if not mol_path.exists(): - mol_path = path.parent / path.name.replace( - "_hippo.pdb", ".pdb" - ).replace(".pdb", "_ligand.sdf") - if not mol_path.exists(): - mrich.error("Could not find ligand mol/sdf:", mol_path) - return None - return mol_path - elif path.name.endswith(".mol"): - return path - else: - raise NotImplementedError - - @property - def apo_path(self) -> "Path": - """Get path to apo protein file""" - path = Path(self.path) - if path.name.endswith(".pdb"): - apo_path = path.parent / path.name.replace("_hippo.pdb", ".pdb").replace( - ".pdb", "_apo-desolv.pdb" - ) - if not apo_path.exists(): - return None - return apo_path - else: - raise NotImplementedError - - ### METHODS - - def score_inspiration( - self, - debug: bool = False, - draw: bool = False, - return_all: bool = False, - ) -> float: - """Score how well this Pose recapitulates the pharmacophoric features of its inspirations. - - :param debug: Increased verbosity for debugging (Default value = False) - :param draw: Render each inspiration pose with it's features, the derivative with the combined features of the inspirations, and the derivative with it's features. (Default value = False) - - """ - - # from molparse.rdkit import SuCOS_score - from mucos import MuCOS_score - - multi_sucos = MuCOS_score( - self.inspirations.mols, - self.mol, - print_scores=debug, - draw=draw, - return_all=return_all, - ) - - if debug: - mrich.var("energy_score", self.energy_score) - mrich.var("distance_score", self.distance_score) - - for inspiration in self.inspirations: - mrich.var( - f"{inspiration} SuCOS", - MuCOS_score(inspiration.mol, self.mol, print_scores=debug), - ) - - mrich.var(f"multi SuCOS", multi_sucos) - - return multi_sucos - - def get_compound(self) -> "Compound": - """Get the :class:`.Compound` that this pose is a conformer of""" - return self.db.get_compound(id=self._compound_id) - - def get_tags(self) -> "TagSet": - """Get this Pose's tags""" - tags = self.db.select_where( - query="tag_name", - table="tag", - key="pose", - value=self.id, - multiple=True, - none="quiet", - ) - return TagSet(self, {t[0] for t in tags}) - - def get_inspiration_ids(self) -> list[int]: - """Get the :class:`.Pose` IDs of this pose's inspirations""" - inspirations = self.db.select_where( - query="inspiration_original", - table="inspiration", - key="derivative", - value=self.id, - multiple=True, - none="quiet", - ) - if not inspirations: - return None - return set([v for v, in inspirations]) - - def get_derivative_ids(self) -> list[int]: - """Get the :class:`.Pose` IDs of this pose's derivatives""" - derivatives = self.db.select_where( - query="inspiration_derivative", - table="inspiration", - key="original", - value=self.id, - multiple=True, - none="quiet", - ) - if not derivatives: - return None - return set([v for v, in derivatives]) - - def get_inspirations(self) -> "PoseSet": - """Get a :class:`.PoseSet` of this pose's inspirations""" - if not (inspirations := self.get_inspiration_ids()): - return None - - from .pset import PoseSet - - return PoseSet(self.db, indices=inspirations) - - def get_derivatives(self) -> "PoseSet": - """Get a :class:`.PoseSet` of this pose's derivatives""" - if not (derivatives := self.get_derivative_ids()): - return None - - from .pset import PoseSet - - return PoseSet(self.db, indices=derivatives) - - def get_dict( - self, - mol: bool = False, - inspirations: bool | str = True, - subsites: bool | str = True, - reference: bool | str = True, - metadata: bool = True, - duplicate_name: str | bool = False, - sanitise_null_metadata_values: bool = False, - skip_metadata: list[str] | None = None, - sanitise_tag_list_separator: str | None = None, - sanitise_metadata_list_separator: str | None = ";", - tags: bool = True, - ) -> dict: - """Returns a dictionary representing this Pose. Arguments: - - :param mol: Include a ``rdkit.Chem.Mol`` in the output? (Default value = False) - :param inspirations: Include inspirations? ``[True, False, 'names']`` Specify ``names`` to format as a comma-separated string (Default value = True) - :param subsites: Include subsites? ``[True, False, 'names']`` Specify ``names`` to format as a comma-separated string (Default value = True) - :param reference: Include reference? ``[True, False, 'name']`` Specify ``name`` to include the :class:`.Pose` name rather than it's ID (Default value = True) - :param metadata: Include metadata? (Default value = True) - :param duplicate_name: Specify the name of a new column duplicating the pose name column (Default value = False) - :param tags: bool: Include tags? (Default value = True) - - """ - - skip_metadata = skip_metadata or [] - - serialisable_fields = [ - "id", - "inchikey", - "alias", - "name", - "smiles", - "path", - "distance_score", - "energy_score", - "inspiration_score", - ] - - data = {} - for key in serialisable_fields: - data[key] = getattr(self, key) - - if duplicate_name: - assert isinstance(duplicate_name, str) - data[duplicate_name] = data["name"] - - if mol: - try: - data["mol"] = self.mol - except InvalidMolError: - data["mol"] = None - - data["compound"] = self.compound.name - data["compound_id"] = self.compound.id - data["target"] = self.target.name - - if tags: - data["tags"] = self.tags - if sanitise_tag_list_separator: - data["tags"] = sanitise_tag_list_separator.join(data["tags"]) - - if inspirations == "names": - if not self.inspirations: - data["inspirations"] = None - else: - data["inspirations"] = ",".join([p.name for p in self.inspirations]) - elif inspirations: - data["inspirations"] = self.inspirations - - if subsites == "names": - if not (sites := self.subsites): - data["subsites"] = None - else: - data["subsites"] = ",".join([p.name for p in sites]) - elif subsites: - data["subsites"] = self.subsites - - if reference == "name": - if not self.reference: - data["reference"] = "" - else: - data["reference"] = self.reference.name - elif reference: - data["reference"] = self.reference - - if metadata and (metadict := self.metadata): - for key in metadict: - - value = metadict[key] - - if key in skip_metadata: - continue - - if ( - sanitise_null_metadata_values - and isinstance(value, str) - and not value - ): - value = None - - elif sanitise_metadata_list_separator and isinstance(value, list): - - new_values = [] - - for v in value: - if ( - sanitise_null_metadata_values - and isinstance(v, str) - and not v - ): - v = None - - else: - v = str(v) - - new_values.append(v) - - value = sanitise_metadata_list_separator.join(new_values) - - data[key] = value - - return data - - def add_subsite(self, name: str, commit: bool = True) -> "SubsiteTag": - """Tag this pose with a protein subsite - - :param name: the name of the subsite - :param commit: commit the insertion to the database - :returns: :class:`.SubsiteTag` - - """ - - id = self.db.insert_subsite_tag(pose_id=self.id, name=name, commit=commit) - - if not id: - return None - - return self.db.get_subsite_tag(id=id) - - def calculate_interactions( - self, - resolve: bool = True, - distance_padding: float = 0.0, - angle_padding: float = 0.0, - force: bool = False, - debug: bool = False, - commit: bool = True, - mutation_warnings: bool = True, - delete_temp_table: bool = True, - ) -> None: - """Enumerate all valid interactions between this ligand and the protein - - :param resolve: Cull duplicate / less-significant interactions - :param distance_padding: Apply a padding in Angstrom to all distance cutoffs - :param angle_padding: Apply a padding in degrees to all angle cutoffs - :param force: Force a recalculation even if the pose has already been fingerprinted - :param debug: Increase verbosity for debugging - :param commit: commit the changes to the database (Default value = True) - :param mutation_warnings: warn when there has been a mutation in the protein (Default value = True) - :param delete_temp_table: delete the temporary interaction table created during interaction resolution (Default value = True) - - """ - - if not self.has_fingerprint or force: - - def norm(coords): - """Vector norm""" - import numpy as np - - coords = np.array(coords).T - cov = np.cov(coords) - eig = np.linalg.eig(cov) - vec = eig[1][:, 0] - return vec - - def unit_vector(vector): - """Returns the unit vector of the vector.""" - return vector / np.linalg.norm(vector) - - def angle_between(v1, v2): - """Returns the angle in radians between vectors 'v1' and 'v2':: - - >>> angle_between((1, 0, 0), (0, 1, 0)) - 1.5707963267948966 - >>> angle_between((1, 0, 0), (1, 0, 0)) - 0.0 - >>> angle_between((1, 0, 0), (-1, 0, 0)) - 3.141592653589793 - """ - v1_u = unit_vector(v1) - v2_u = unit_vector(v2) - a = 180 * np.arccos(np.clip(np.dot(v1_u, v2_u), -1.0, 1.0)) / np.pi - - if a > 90: - a = 180 - a - return a - - # calculate interactions... - - ### clear old interactions - - self.db.delete_where( - table="interaction", key="pose", value=self.id, commit=commit - ) - self.set_has_fingerprint(False, commit=commit) - self._interactions = None - - ### create temporary table - - if "temp_interaction" in self.db.table_names: - self.db.execute("DROP TABLE temp_interaction") - - self.db.create_table_interaction(table="temp_interaction", debug=False) - - ### load the ligand structure - - if self.path.endswith(".pdb"): - from molparse import parse - - protein_system = self.protein_system - if not self.protein_system: - protein_system = parse(self.path, verbosity=False).protein_system - - elif self.path.endswith(".mol") and self.reference: - protein_system = self.reference.protein_system - - else: - mrich.debug("Unsupported: Pose.calculate_interactions()") - raise NotImplementedError(f"{self}, {self.reference=}, {self.path=}") - - assert protein_system - - if not self.mol: - mrich.error(f"Could not read molecule for {self}") - return - - ### get features - - comp_features = self.features - protein_features = self.target.calculate_features( - protein_system, reference_id=self.reference_id - ) - - if debug: - print("ligand features", comp_features) - - ### organise ligand features by family - comp_features_by_family = {} - for family in FEATURE_FAMILIES: - comp_features_by_family[family] = [ - f for f in comp_features if f.family == family - ] - - ### protein chain names - chains = protein_system.chain_names - - mutation_warnings = set() - mutation_count = 0 - - # loop over protein features - for prot_feature in protein_features: - - # skip chains that aren't present - if prot_feature.chain_name not in chains: - continue - - prot_family = prot_feature.family - - prot_residue = protein_system.get_chain( - prot_feature.chain_name - ).residues[f"n{prot_feature.residue_number}"] - - if not prot_residue: - continue - - if prot_residue.name != prot_feature.residue_name: - com = prot_residue.centre_of_mass() - if any( - np.linalg.norm(com - cf.position) < MUTATION_WARNING_DIST - for cf in comp_features - ): - mutation_warnings.add( - f"{prot_residue.name} {prot_residue.number} -> {prot_feature.residue_name} {prot_feature.residue_number}" - ) - mutation_count += 1 - continue - - ### calculate protein coordinate - prot_atoms = [] - for atom_name in prot_feature.atom_names.split(" "): - atom = prot_residue.get_atom(atom_name, verbosity=0) - if atom: - prot_atoms.append(atom) - - prot_coords = [a.np_pos for a in prot_atoms if a is not None] - - if not prot_coords: - # mrich.warning("Skipping feature with no atoms") - continue - - prot_coord = np.array(np.sum(prot_coords, axis=0) / len(prot_atoms)) - - if prot_family not in COMPLEMENTARY_FEATURES: - continue - - complementary_families = COMPLEMENTARY_FEATURES[prot_family] - - # print(prot_family, complementary_families) - - for complementary_family in complementary_families: - - interaction_type = INTERACTION_TYPES[ - (prot_family, complementary_family) - ] - - complementary_comp_features = comp_features_by_family[ - complementary_family - ] - - for lig_feature in complementary_comp_features: - - distance = np.linalg.norm(lig_feature - prot_coord) - angle = None - - # check distance cutoff - if ( - distance - > INTERACTION_CUTOFF[interaction_type] + distance_padding - ): - continue - - # special rules for aromatics - if interaction_type.startswith("π"): - lig_coords = [ - self.mol.GetConformer().GetAtomPosition(i - 1) - for i in lig_feature.atom_numbers - ] - - # special rules for pi-stacking - if interaction_type == "π-stacking": - - # calculate minimum distance - min_distance = None - for lig_coord in lig_coords: - for p_coord in prot_coords: - d = np.linalg.norm(lig_coord - p_coord) - if not min_distance or d < min_distance: - min_distance = d - - # skip interaction if no atom is within PI_STACK_MIN_CUTOFF - if min_distance > PI_STACK_MIN_CUTOFF + distance_padding: - if debug: - print( - f"skipping {prot_feature} due to pi-stack min_distance" - ) - print( - prot_feature.residue_name, - prot_feature.residue_number, - prot_feature.chain_name, - prot_feature.family, - lig_feature, - distance, - ) - continue - - ### angles - lig_norm = norm([list(p) for p in lig_coords]) - prot_norm = norm(prot_coords) - angle = angle_between(lig_norm, prot_norm) - - # Face to Face has more stringent restraints - if ( - angle < 40 - angle_padding - and distance > PI_STACK_F2F_CUTOFF + distance_padding - ): - if debug: - print(prot_feature.res_name_number_family_str) - print(angle, distance) - continue - - # special rules for pi-cation - elif interaction_type == "π-cation": - - # construct vectors - if prot_family == "Aromatic": - aromatic_norm = norm(prot_coords) - cation_vec = lig_feature.position - prot_coord - else: - aromatic_norm = norm([list(p) for p in lig_coords]) - cation_vec = prot_coord - lig_feature.position - - # calculate angle - angle = angle_between(aromatic_norm, cation_vec) - - # skip if angle too large - if angle > 30 + angle_padding: - if debug: - print(prot_feature.res_name_number_family_str) - print(angle, distance) - continue - - if debug: - print("Prot:", prot_feature, "Lig:", lig_feature) - - # insert into the Database - self.db.insert_interaction( - feature=prot_feature.id, - pose=self.id, - type=interaction_type, - family=lig_feature.family, - atom_ids=lig_feature.atom_numbers, - prot_coord=prot_coord, - lig_coord=lig_feature.position, - distance=distance, - angle=angle, - energy=None, - commit=commit, - table="temp_interaction", - ) - - if mutation_warnings: - mrich.warning( - f"Skipped {mutation_count} protein features because the residue was mutated:" - ) - for mutation in mutation_warnings: - mrich.warning(mutation) - - if resolve: - from .iset import InteractionSet - - interactions = InteractionSet.from_pose(self, table="temp_interaction") - interactions.resolve(debug=debug) - # self.interactions.resolve(debug=debug, table='temp_interaction') - - ### transfer interactions from temporary table - self.db.copy_temp_interactions() - self.set_has_fingerprint(True, commit=commit) - - ### delete temporary table - - if delete_temp_table: - self.db.execute("DROP TABLE temp_interaction") - - elif debug: - mrich.warning(f"{self} is already fingerprinted, no new calculation") - - def calculate_prolif_interactions( - self, - return_all: bool = False, - max_retry: int = 5, - use_mda: bool = False, - force: bool = False, - clear_existing: bool = True, - debug: bool = False, - resolve: bool = True, - ) -> "prolif.Fingerprint": - """Use ProLIF to populate the interactions table""" - - if not self.has_fingerprint or force: - - ### clear old interactions - - if clear_existing: - self.db.delete_where( - table="interaction", key="pose", value=self.id, commit=False - ) - self.set_has_fingerprint(False, commit=False) - - ### create temporary table - - table = "temp_interaction" - - if "temp_interaction" in self.db.table_names: - mrich.warning("Deleting existing temp_interaction table") - self.db.execute("DROP TABLE temp_interaction") - - self.db.create_table_interaction(table="temp_interaction", debug=False) - - if not clear_existing: - self.db.copy_interactions_to_temp(pose_id=self.id) - - # clear cached InteractionSet - self._interactions = None - - import prolif as plf - from tempfile import NamedTemporaryFile - from .prolif import parse_prolif_interactions - from MDAnalysis import Universe - import logging - - mdanalysis_logger = logging.getLogger("MDAnalysis") - mdanalysis_logger.setLevel(logging.WARNING) - - ## prepare inputs - - # decide if MDA is needed - unprotonated_sys = self.protein_system - residue_names = set(r.name for r in unprotonated_sys.residues) - nonstandard = ["HID", "HIE", "HSE", "HSD", "HSP"] - if any(r in residue_names for r in nonstandard): - mrich.debug("Using MDA") - use_mda = True - - # protonated protein - for i in range(max_retry): - try: - protonated_sys, protein_file = unprotonated_sys.add_hydrogens( - return_file=True - ) - - if use_mda: - with mrich.loading("Creating MDAnalysis.Universe"): - universe = Universe(protein_file.name) - protein_mol = plf.Molecule.from_mda(universe) - else: - with mrich.loading("Creating protein rdkit.Chem.Mol"): - rdkit_prot = Chem.MolFromPDBFile( - protein_file.name, removeHs=False - ) - protein_mol = plf.Molecule(rdkit_prot) - - break - - except Exception as e: - mrich.warning( - f"Could not create satisfactory protein molecule, attempts = {i+1}/{max_retry}" - ) - mrich.warning(e) - use_mda = True - continue - else: - mrich.error( - f"Tried {max_retry} times to create protein molecule and failed" - ) - return None - - # ligand - ligand_file = NamedTemporaryFile(mode="w+t", suffix=".sdf") - writer = Chem.SDWriter(ligand_file.name) - writer.write(self.protonated_mol) - writer.close() - ligand_iterable = plf.sdf_supplier(ligand_file.name) - - ## run prolif - - fp = plf.Fingerprint(count=True) - fp.run_from_iterable(ligand_iterable, protein_mol, progress=False, n_jobs=1) - - ## parse outputs and insert Feature and Interaction records - parse_prolif_interactions( - self, fp, protonated_sys, debug=debug, table=table - ) - - if resolve: - from .iset import InteractionSet - - interactions = InteractionSet.from_pose(self, table="temp_interaction") - interactions.resolve(debug=debug) - - self.db.copy_temp_interactions() - self.set_has_fingerprint(True, commit=True) - - ### delete temporary table - - self.db.execute("DROP TABLE temp_interaction") - - ## close files - protein_file.close() - ligand_file.close() - - if return_all: - return fp, ligand_iterable, protein_mol - - def calculate_classic_fingerprint( - self, - debug: bool = False, - ) -> dict: - """Calculate the pose's interaction fingerprint""" - - if self.path.endswith(".pdb"): - - import molparse as mp - - protein_system = self.protein_system - if not self.protein_system: - # mrich.reading(self.path) - protein_system = mp.parse(self.path, verbosity=False).protein_system - - elif self.path.endswith(".mol") and self.reference: - - # mrich.debug('fingerprint from .mol and reference pose') - protein_system = self.reference.protein_system - - else: - - mrich.debug("Unsupported: Pose.calculate_fingerprint()") - raise NotImplementedError(f"{self.reference=}, {self.path=}") - - assert protein_system - - if not self.mol: - return - - comp_features = self.features - - comp_features_by_family = {} - for family in FEATURE_FAMILIES: - comp_features_by_family[family] = [ - f for f in comp_features if f.family == family - ] - - # protein_features = self.target.features - # if not protein_features: - protein_features = self.target.calculate_features(protein_system) - - fingerprint = {} - - chains = protein_system.chain_names - - for prot_feature in protein_features: - - if prot_feature.chain_name not in chains: - continue - - prot_family = prot_feature.family - - prot_residue = protein_system.get_chain(prot_feature.chain_name).residues[ - f"n{prot_feature.residue_number}" - ] - - if not prot_residue: - continue - - # if prot_feature.residue_number == 77: - # mrich.debug(repr(prot_feature)) - - if prot_residue.name != prot_feature.residue_name: - mrich.warning(f"Feature {repr(prot_feature)}") - continue - - prot_atoms = [ - prot_residue.get_atom(a) for a in prot_feature.atom_names.split(" ") - ] - - prot_coords = [a.np_pos for a in prot_atoms if a is not None] - - prot_coord = np.array(np.sum(prot_coords, axis=0) / len(prot_atoms)) - - complementary_family = COMPLEMENTARY_FEATURES[prot_family] - - complementary_comp_features = comp_features_by_family[complementary_family] - - cutoff = FEATURE_PAIR_CUTOFFS[f"{prot_family} {complementary_family}"] - - valid_features = [ - f - for f in complementary_comp_features - if np.linalg.norm(f - prot_coord) <= cutoff - ] - - if valid_features: - if debug: - mrich.debug( - f"PROT: {prot_feature.residue_name} {prot_feature.residue_number} {prot_feature.atom_names}, LIG: #{len(valid_features)} {[f for f in valid_features]}" - ) - fingerprint[prot_feature.id] = len(valid_features) - - return fingerprint - - # self.fingerprint = fingerprint - - def draw( - self, - inspirations: bool = True, - protein: bool = False, - **kwargs, - ) -> None: - """Render this pose (and its inspirations) - - :param inspirations: Render the inspirations? (Default value = True) - :param protein: Render the protein? This wraps :meth:`.Pose.render` (Default value = False) - - """ - - if protein: - self.render(**kwargs) - - from molparse.rdkit import draw_mols - - mols = [self.mol] - if inspirations and self.inspirations: - mols += [i.mol for i in self.inspirations] - - draw_mols(mols) - - def draw2d( - self, - ) -> None: - """Draw a 2D drawing of this pose""" - from rdkit.Chem import MolFromSmiles - - mol = MolFromSmiles(self.smiles) - display(mol) - - def render( - self, - protein="cartoon", - ligand="stick", - protein_color="spectrum", - interactions: bool = True, - file: str | None = None, - ) -> None: - """Render this pose with the protein using py3Dmol - - :param protein: protein representation, default = 'cartoon' - :param ligand: ligand representation, default = 'stick' - :param protein_color: color of protein representation, default = 'spectrum' - - """ - - from molparse.py3d import render - - sys = self.complex_system - - def make_view(width="640px", height="480px"): - """Create py3Dmol view""" - - view = render( - sys, - protein=protein, - ligand=ligand, - protein_color=protein_color, - width=width, - height=height, - ) - - if interactions: - COLORS = { - "Hydrophobic": "green", - "Hydrogen Bond": "blue", - "π-stacking": "purple", - "π-cation": "pink", - "Electrostatic": "red", - "Sulfur-Sulfur": "yellow", - } - - iset = self.interactions - - if not iset: - return view - - df = iset.df - - residues = set() - - for i, row in df.iterrows(): - - prot_coord = row["prot_coord"] - lig_coord = row["lig_coord"] - type = row["type"] - color = COLORS.get(type, "black") - - view.addCylinder( - { - "start": { - "x": prot_coord[0], - "y": prot_coord[1], - "z": prot_coord[2], - }, - "end": { - "x": lig_coord[0], - "y": lig_coord[1], - "z": lig_coord[2], - }, - # 'radius': radius, - "color": color, - } - ) - - residues.add((row["residue_name"], row["residue_number"])) - - for res_name, res_num in residues: - res = sys.residues[f"{res_name} n{res_num}"] - view.addModel(res.pdb_block, "pdb") - view.setStyle({"model": -1}, {ligand: {}}) - - return view - - if file: - view = make_view(width="100%", height="100%") - html = view._make_html() - - mrich.writing(file) - with open(file, "w") as f: - f.write(html) - - view = make_view() - return view._repr_html_() - - def grid(self) -> None: - """Draw a grid of this pose with its inspirations""" - from molparse.rdkit import draw_grid - from IPython.display import display - - mols = [self.compound.mol] - labels = [self.plain_repr()] - if self.inspirations: - mols += [i.compound.mol for i in self.inspirations] - labels += [i.plain_repr() for i in self.inspirations] - - display(draw_grid(mols, labels=labels)) - - def summary( - self, metadata: bool = True, tags: bool = True, subsites: bool = True - ) -> None: - """Print a summary of this pose - - :param metadata: include metadata (Default value = True) - - """ - if self.alias: - mrich.header(f"{str(self)}: {self.alias}") - else: - mrich.header(f"{str(self)}: {self.inchikey}") - mrich.var("inchikey", self.inchikey) - mrich.var("alias", self.alias) - mrich.var("smiles", self.smiles) - mrich.var("compound", self.compound) - mrich.var("path", self.path) - mrich.var("target", self.target) - mrich.var("reference", self.reference) - if tags: - mrich.var("tags", self.tags) - if subsites: - mrich.var("subsites", self.subsites) - mrich.var("num_heavy_atoms", self.num_heavy_atoms) - mrich.var("distance_score", self.distance_score) - mrich.var("energy_score", self.energy_score) - mrich.var("inspiration_score", self.inspiration_score) - if inspirations := self.inspirations: - mrich.var("inspirations", self.inspirations.names) - mrich.var("num_atoms_added", self.num_atoms_added) - if metadata: - mrich.var("metadata", str(self.metadata)) - - def showcase(self) -> None: - """Print and render this pose as if you were using :meth:`.PoseSet.interactive`""" - - self.summary(metadata=False) - self.grid() - self.draw() - from pprint import pprint - - mrich.title("Metadata:") - pprint(self.metadata) - - def plain_repr(self) -> str: - """Unformatted detailed string representation""" - if self.name: - return f'{self.compound}->{self}: "{self.name}"' - else: - return f"{self.compound}->{self}" - - def plot3d( - self, - features: bool = False, - **kwargs, - ) -> "plotly.graph_objects.Figure": - """Use Molparse/Plotly to create a 3d figure of this pose - - :param features: include the features in the figure - :returns: a plotly Figure object - - """ - - mol = self.mol - - import molparse as mp - - group = mp.rdkit.mol_to_AtomGroup(mol) - - if features: - features = self.features - - return mp.go.plot3d(atoms=group.atoms, features=features, **kwargs) - - def set_has_fingerprint(self, fp: bool, commit: bool = True) -> None: - """Update the database to reflect this pose's has_fingerprint property""" - assert isinstance(fp, bool) - self._has_fingerprint = fp - self.db.update( - table="pose", - id=self.id, - key=f"pose_fingerprint", - value=int(fp), - commit=commit, - ) - - def posebusters(self, debug: bool = False) -> bool: - """Run a posebusters ligand check on this pose's molecule""" - - # use syndirella implementation - from syndirella.slipper import intra_geometry, flatness - - geometries: Dict = intra_geometry.check_geometry(self.mol, threshold_clash=0.4) - flat_results: Dict = flatness.check_flatness(self.mol) - - if not geometries["results"]["bond_lengths_within_bounds"]: - if debug: - mrich.debug(self, "did not pass bond length checks.") - return False - if not geometries["results"]["bond_angles_within_bounds"]: - if debug: - mrich.debug(self, "did not pass bond angle checks.") - return False - if not geometries["results"]["no_internal_clash"]: - if debug: - mrich.debug(self, "did not pass internal clash checks.") - return False - if not flat_results["results"]["flatness_passes"]: - if debug: - mrich.debug(self, "did not pass flatness checks.") - return False - return True - - def to_syndirella(self, out_key: "str | Path") -> "DataFrame": - """Create syndirella inputs. See :meth:`.PoseSet.to_syndirella`""" - from .pset import PoseSet - - return PoseSet(self.db, [self.id]).to_syndirella( - out_key=out_key, separate=False - ) - - ### DUNDERS - - def __str__(self) -> str: - """Unformatted string representation""" - return f"P{self.id}" - - def __repr__(self) -> str: - """ANSI Formatted string representation""" - return f"{mcol.bold}{mcol.underline}{self.plain_repr()}{mcol.unbold}{mcol.ununderline}" - - def __rich__(self) -> str: - """Formatted string representation""" - return f"[bold underline]{self.plain_repr()}" - - def __eq__(self, other: "Pose") -> bool: - """Compare this pose with another instance""" - - if isinstance(other, int): - return self.id == other - - return self.id == other.id - - def __add__( - self, - other: "Pose | PoseSet", - ) -> "PoseSet": - """Add a :class:`.PoseSet` to this pose""" - from .pset import PoseSet - - if isinstance(other, PoseSet): - return PoseSet(self.db, [self.id] + other.ids, sort=False) - elif isinstance(other, Pose): - return PoseSet(self.db, [self.id, other.id], sort=False) - else: - raise NotImplementedError - - -class InvalidMolError(Exception): - """Exception to be thrown when the molecule could not be parsed""" - - ... diff --git a/hippo/prolif.py b/hippo/prolif.py deleted file mode 100644 index 1cef1309..00000000 --- a/hippo/prolif.py +++ /dev/null @@ -1,225 +0,0 @@ -"""Functions for ProLIF interaction profiling""" - -import mrich -from mrich import print - -import molparse as mp - -from molparse.rdkit.features import FEATURE_FAMILIES, INTERACTION_TYPES - -INTERACTION_TYPES = list(INTERACTION_TYPES.values()) + ["VdWContact"] -FEATURE_FAMILIES = list(FEATURE_FAMILIES) + ["VdWSphere"] - - -def guess_feature_families( - interaction_type: str, prot_atom_names: list[str], lig_atom_ids: list[int] -) -> tuple[str, str, str]: - """Guess feature families from atom names - - :param interaction_type: interaction type - - """ - - match interaction_type: - case "VdWContact": - lig_feature_family = "VdWSphere" - prot_feature_family = "VdWSphere" - - case "Hydrophobic": - lig_feature_family = ( - "LumpedHydrophobe" if len(lig_atom_ids) > 1 else "Hydrophobe" - ) - prot_feature_family = ( - "LumpedHydrophobe" if len(prot_atom_names) > 1 else "Hydrophobe" - ) - - case "Anionic": - lig_feature_family = "NegIonizable" - prot_feature_family = "PosIonizable" - interaction_type = "Electrostatic" - - case "Cationic": - lig_feature_family = "PosIonizable" - prot_feature_family = "NegIonizable" - interaction_type = "Electrostatic" - - case "CationPi": - lig_feature_family = "PosIonizable" - prot_feature_family = "Aromatic" - interaction_type = "π-cation" - - case "PiCation": - lig_feature_family = "Aromatic" - prot_feature_family = "PosIonizable" - interaction_type = "π-cation" - - case "PiStacking": - lig_feature_family = "Aromatic" - prot_feature_family = "Aromatic" - interaction_type = "π-stacking" - - case "PiStacking": - lig_feature_family = "Aromatic" - prot_feature_family = "Aromatic" - interaction_type = "π-stacking" - - case "EdgeToFace": - lig_feature_family = "Aromatic" - prot_feature_family = "Aromatic" - interaction_type = "π-stacking (EdgeToFace)" - - case "FaceToFace": - lig_feature_family = "Aromatic" - prot_feature_family = "Aromatic" - interaction_type = "π-stacking (FaceToFace)" - - case "HBAcceptor": - lig_feature_family = "Acceptor" - prot_feature_family = "Donor" - interaction_type = "Hydrogen Bond" - - case "HBDonor": - lig_feature_family = "Donor" - prot_feature_family = "Acceptor" - interaction_type = "Hydrogen Bond" - - case "XBAcceptor": - lig_feature_family = "Acceptor" - prot_feature_family = "Donor" - interaction_type = "Halogen Bond" - - case "XBDonor": - lig_feature_family = "Donor" - prot_feature_family = "Acceptor" - interaction_type = "Halogen Bond" - - case "MetalAcceptor": - lig_feature_family = None - prot_feature_family = "Metal" - interaction_type = "Metal complexation" - - case "MetalDonor": - lig_feature_family = "Metal" - prot_feature_family = None - interaction_type = "Metal complexation" - - case _: - prot_feature_family = None - lig_feature_family = None - - mrich.error("Unsupported interaction_type", interaction_type) - - assert interaction_type in INTERACTION_TYPES - assert prot_feature_family in FEATURE_FAMILIES or prot_feature_family is None - assert lig_feature_family in FEATURE_FAMILIES or lig_feature_family is None - - return prot_feature_family, lig_feature_family, interaction_type - - -def parse_prolif_interactions( - pose: "Pose", - fp: "plf.Fingerprint", - protonated_sys: "molparse.System", - table: str = "temp_interaction", - debug: bool = False, -) -> None: - """Parse ProLIF output into HIPPO database table""" - - target_id = pose.target.id - - for key, value in fp.ifp[0].items(): - - res_number = key[1].number - res_name = key[1].name - chain_name = key[1].chain - - chain = protonated_sys.get_chain(chain_name) - residue = chain.residues.get_matches(number=res_number) - - prot_mol = residue.rdkit_mol - prot_group = mp.AtomGroup.from_pdb_block(mp.rdkit.mol_to_pdb_block(prot_mol)) - - if not residue.name == res_name: - mrich.debug(protonated_sys.name + ".pdb") - raise AssertionError( - f"Residue name mismatch: [sys]={residue.name} [prolif]={res_name}" - ) - - for interaction_type, interaction_dicts in value.items(): - - for interaction_dict in interaction_dicts: - - angle = interaction_dict.get("angle") - distance = interaction_dict.get("distance") - - lig_atom_ids = list(interaction_dict["indices"]["ligand"]) - - # insert a dummy protein feature - prot_interaction_atoms = [ - prot_group.atoms[i] for i in interaction_dict["indices"]["protein"] - ] - prot_atom_names = [a.name for a in prot_interaction_atoms] - - # rename stuff - prot_family, lig_family, interaction_type = guess_feature_families( - interaction_type, prot_atom_names, lig_atom_ids - ) - - feature_id = pose.db.insert_feature( - family=prot_family, - target=target_id, - chain_name=chain_name, - residue_name=res_name, - residue_number=residue.number, - atom_names=prot_atom_names, - commit=False, - ) - - if not feature_id: - - sql = f""" - feature_target = {target_id} - AND feature_family = '{prot_family}' - AND feature_chain_name = '{chain_name}' - AND feature_residue_name = '{res_name}' - AND feature_residue_number = {residue.number} - AND feature_atom_names = '{" ".join(sorted(prot_atom_names))}' - """ - - try: - (feature_id,) = pose.db.select_id_where( - table="feature", key=sql - ) - except: - feature_id = pose.db.insert_feature( - family=prot_family, - target=target_id, - chain_name=chain_name, - residue_name=res_name, - residue_number=residue.number, - atom_names=prot_atom_names, - warn_duplicate=True, - commit=False, - ) - raise - - # insert into the Database - interaction_id = pose.db.insert_interaction( - feature=feature_id, - pose=pose.id, - type=interaction_type, - family=lig_family, - atom_ids=[i + 1 for i in lig_atom_ids], - prot_coord=None, - lig_coord=None, - distance=distance, - angle=angle, - energy=None, - commit=False, - table=table, - ) - - if debug: - mrich.debug( - f"Residue: {res_name} {res_number} {chain_name}. Interaction: {interaction_type}. Ligand: {lig_family} ({lig_atom_ids}). Protein: {prot_family} ({prot_atom_names})" - ) diff --git a/hippo/pset.py b/hippo/pset.py deleted file mode 100644 index 0a20162a..00000000 --- a/hippo/pset.py +++ /dev/null @@ -1,3008 +0,0 @@ -"""Classes to work with sets of Poses""" - -import mcol -import mrich - -import os -from typing import Callable - -from .pose import Pose -from .db import Database -from .cset import IngredientSet - - -class PoseTable: - """Class representing all :class:`.Pose` objects in the 'pose' table of the :class:`.Database`. - - .. attention:: - - :class:`.PoseTable` objects should not be created directly. Instead use the :meth:`.HIPPO.poses` property. See :doc:`getting_started` and :doc:`insert_elaborations`. - - Use as an iterable - ================== - - Iterate through :class:`.Pose` objects in the table: - - :: - - for pose in animal.poses: - ... - - - Selecting poses in the table - ============================ - - The :class:`.PoseTable` can be indexed with :class:`.Pose` IDs, names, aliases, or list/sets/tuples/slices thereof: - - :: - - ptable = animal.poses - - # indexing individual compounds - pose = ptable[13] # using the ID - pose = ptable["BSYNRYMUTXBXSQ-UHFFFAOYSA-N"] # using the InChIKey - pose = ptable["Ax0310a"] # using the alias - - # getting a subset of compounds - pset = ptable[13,15,18] # using IDs (tuple) - pset = ptable[[13,15,18]] # using IDs (list) - pset = ptable[set(13,15,18)] # using IDs (set) - pset = ptable[13:18] # using a slice - - Tags and target IDs can also be used to filter: - - :: - - pset = animal.poses(tag='hits') # select compounds tagged with 'hits' - pset = animal.poses(target=1) # select poses from the first target - - """ - - _table = "pose" - _name = "all poses" - - def __init__( - self, - db: Database, - ) -> None: - """PoseTable initialisation""" - - self._db = db - self._interactions = None - - ### PROPERTIES - - @property - def db(self) -> Database: - """Returns the associated :class:`.Database`""" - return self._db - - @property - def table(self) -> str: - """Returns the name of the :class:`.Database` table""" - return self._table - - @property - def name(self) -> str | None: - """Returns the name of set""" - return self._name - - @property - def names(self) -> list[str]: - """Returns the aliases of child poses""" - return [p.name for p in self] - - @property - def aliases(self) -> list[str]: - """Returns the aliases of child poses""" - result = self.db.select(table=self.table, query="pose_alias", multiple=True) - return [q for q, in result] - - @property - def inchikeys(self) -> list[str]: - """Returns the inchikeys of child poses""" - result = self.db.select(table=self.table, query="pose_inchikey", multiple=True) - return [q for q, in result] - - @property - def ids(self) -> list[int]: - """Returns the IDs of child poses""" - result = self.db.select(table=self.table, query="pose_id", multiple=True) - return [q for q, in result] - - @property - def tags(self) -> set[str]: - """Returns the set of unique tags present in this pose set""" - values = self.db.select_where( - table="tag", - query="DISTINCT tag_name", - key="tag_pose IS NOT NULL", - multiple=True, - ) - return set(v for v, in values) - - @property - def num_fingerprinted(self) -> int: - """Count the number of fingerprinted poses""" - return self.db.count_where( - table="pose", - key="fingerprint", - value=1, - ) - - @property - def id_name_dict(self) -> dict[int, str]: - """Return a dictionary mapping pose ID's to their name""" - - records = self.db.select( - table=self.table, query="pose_id, pose_inchikey, pose_alias", multiple=True - ) - - lookup = {} - for i, inchikey, alias in records: - if alias: - lookup[i] = alias - else: - lookup[i] = inchikey - - return lookup - - @property - def interactions(self) -> "InteractionSet": - """Get a :class:`.InteractionSet`""" - if self._interactions is None: - from .iset import InteractionSet - - self._interactions = InteractionSet.all(self.db) - - return self._interactions - - ### METHODS - - def get_by_tag( - self, - tag: str, - inverse: bool = False, - ) -> "PoseSet": - """Get all child poses with a certain tag - - :param tag: tag to search for - :param inverse: invert the selection - :returns: a :class:`.PoseSet` of the subset - - """ - - if not inverse: - - values = self.db.select_where( - query="tag_pose", table="tag", key="name", value=tag, multiple=True - ) - - else: - - values = self.db.select_where( - query="tag_pose", table="tag", key="name", value=tag, multiple=True - ) - - if not values: - return self - - ids = [v for v, in values if v] - - values = self.db.select_where( - query="pose_id", - table="pose", - key=f"pose_id NOT IN {self.str_ids}", - multiple=True, - ) - - if not values: - return None - - ids = [v for v, in values if v] - - pset = self[ids] - - if inverse: - pset._name = f'poses not tagged "{tag}"' - else: - pset._name = f'poses tagged "{tag}"' - return pset - - def get_by_target( - self, - *, - id: int, - ) -> "PoseSet": - """Get all child poses with a certain :class:`.Target` ID: - - :param id: :class:`.Target` ID - :returns: a :class:`.PoseSet` of the subset - - """ - assert isinstance(id, int) - values = self.db.select_where( - query="pose_id", table="pose", key="target", value=id, multiple=True - ) - ids = [v for v, in values if v] - - target = self.db.get_target(id=id) - - pset = self[ids] - pset._name = f'poses for "{target}"' - return pset - - def get_by_smiles(self, smiles: str) -> "Pose | PoseSet | None": - """Get a member pose by it's smiles""" - - from .tools import inchikey_from_smiles, sanitise_smiles, SanitisationError - - try: - flat_smiles = sanitise_smiles(smiles, sanitisation_failed="error") - except SanitisationError as e: - mrich.error(f"Could not sanitise {smiles=}") - mrich.error(str(e)) - return None - except AssertionError: - mrich.error(f"Could not sanitise {smiles=}") - return None - return c - - # get the compound - - flat_inchikey = inchikey_from_smiles(flat_smiles) - - comp_id = self.db.select_id_where( - table="compound", key="inchikey", value=flat_inchikey - ) - - if not comp_id: - return None - - (comp_id,) = comp_id - - # get the poses - - pose_ids = self.db.select_id_where( - table="pose", key="compound", value=comp_id, multiple=True - ) - - if not pose_ids: - return None - - pose_ids = [i for i, in pose_ids] - pset = self[pose_ids] - - # identify the pose - - inchikey = inchikey_from_smiles(smiles) - - matches = set() - for pose in pset: - if pose.inchikey == inchikey: - matches.add(pose.id) - matches = list(matches) - - if not matches: - mrich.error(f"Did not find pose matching stereochemistry (C{comp_id})") - return None - - if len(matches) == 1: - return self[matches[0]] - - return self[matches] - - def get_by_subsite( - self, - *, - id: int, - ) -> "PoseSet": - """Get all child poses with a certain :class:`.Subsite` ID: - - :param id: :class:`.Subsite` ID - :returns: a :class:`.PoseSet` of the subset - - """ - assert isinstance(id, int) - values = self.db.select_where( - query="subsite_tag_pose", - table="subsite_tag", - key="ref", - value=id, - multiple=True, - ) - ids = [v for v, in values if v] - - subsite = self.db.get_subsite_name(id=id) - - pset = self[ids] - pset._name = f'poses in "{subsite}"' - return pset - - def get_by_metadata( - self, - key: str, - value: str | None = None, - ) -> "PoseSet": - """Get all child poses by their metadata. If no value is passed, then simply containing the key in the metadata dictionary is sufficient - - :param key: metadata key to match - :param value: metadata value to match, if ``None`` any pose with the key present will be returned (Default value = None) - :returns: a :class:`.PoseSet` of the subset - - """ - results = self.db.select( - query="pose_id, pose_metadata", table="pose", multiple=True - ) - if value is None: - ids = [i for i, d in results if d and f'"{key}":' in d] - name = f"poses with {key} in metadata" - else: - if isinstance(value, str): - value = f'"{value}"' - ids = [i for i, d in results if d and f'"{key}": {value}' in d] - name = f"poses with metadata[{key}] == {value}" - - pset = self[ids] - pset._name = name - return pset - - def get_by_metadata_substring_match( - self, - substring: str, - ) -> "PoseSet": - """Get :class:`.PoseSet` of poses with metadata JSON containing substring""" - - assert substring - assert isinstance(substring, str) - - pose_ids = self.db.select_where( - table="pose", - query="pose_id", - key=f"""pose_metadata LIKE '%{substring}%'""", - multiple=True, - ) - - if not pose_ids: - mrich.error("No poses with export ") - return None - - pose_ids = [i for i, in pose_ids] - - name = f"poses with '{substring}' in metadata" - - pset = self[pose_ids] - pset._name = name - - return pset - - def draw( - self, - max_draw: int = 100, - ) -> None: - """Render the poses - - :param max_draw: show a warning if trying to draw more than this number of poses (Default value = 100) - - """ - if len(self) <= max_draw: - self[:].draw() - else: - mrich.warning( - f"Too many poses: {len(self)} > {max_draw=}. Increase max_draw or use animal.poses[:].draw()" - ) - - def summary(self) -> None: - """Print a summary of this pose set""" - mrich.header("PoseTable()") - mrich.var("#poses", len(self)) - mrich.var("tags", self.tags) - - def interactive(self) -> None: - """Interactive widget to navigate poses in the table - - .. attention:: - - This method instantiates a :class:`.PoseSet` containing all poses, it is recommended to instead select a subset for display. This method is only intended for use within a Jupyter Notebook. - - """ - - self[self.ids].interactive() - - ### DUNDERS - - def __call__( - self, - *, - tag: str | None = None, - target: int | None = None, - subsite: int | None = None, - smiles: str | None = None, - ) -> "PoseSet": - """Filter poses by a given tag, subsite ID, or target ID. See :meth:`.PoseTable.get_by_tag`, :meth:`.PoseTable.get_by_target`, amd :meth:`.PoseTable.get_by_subsite`""" - - if tag: - return self.get_by_tag(tag) - elif target: - return self.get_by_target(id=target) - elif subsite: - return self.get_by_subsite(id=subsite) - elif smiles: - return self.get_by_smiles(smiles=smiles) - else: - raise NotImplementedError - - def __getitem__( - self, - key: int | str | tuple | list | set | slice, - ) -> Pose: - """Get a member :class:`.Pose` object or subset :class:`.PoseSet` thereof. - - :param key: Can be an integer ID, negative integer index, alias or inchikey string, list/set/tuple of IDs, or slice of IDs - - """ - - from pandas import Series - - match key: - - case int(): - if key == 0: - return self.__getitem__(key=1) - - if key < 0: - key = len(self) + 1 + key - return self.__getitem__(key=key) - - else: - return self.db.get_pose(id=key) - - case str(): - pose = self.db.get_pose(alias=key) - if not pose: - pose = self.db.get_pose(inchikey=key) - return pose - - case key if ( - isinstance(key, list) - or isinstance(key, tuple) - or isinstance(key, set) - or isinstance(key, Series) - ): - - indices = [] - for i in key: - if isinstance(i, int): - index = i - elif isinstance(i, str): - index = self.db.get_pose_id(alias=i) - if not index: - index = self.db.get_pose_id(inchikey=i) - else: - raise NotImplementedError - - assert index - indices.append(index) - - return PoseSet(self.db, indices) - - case slice(): - ids, name = self.db.slice_ids( - table=self.table, - start=key.start, - stop=key.stop, - step=key.step, - name=True, - ) - pset = self[ids] - pset._name = name - return pset - - case _: - mrich.error( - f"Unsupported type for PoseTable.__getitem__(): {type(key)}" - ) - - return None - - def __str__(self): - """Unformatted string representation""" - if self.name: - s = f"{self.name}: " - else: - s = "" - - s += "{" f"P × {len(self)}" "}" - - return s - - def __repr__(self) -> str: - """ANSI Formatted string representation""" - return f"{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}" - - def __rich__(self) -> str: - """Rich Formatted string representation""" - return f"[bold underline]{self}" - - def __len__(self) -> int: - """Total number of compounds""" - return self.db.count(self.table) - - def __iter__(self): - """Iterate through all compounds""" - return iter(self[i + 1] for i in range(len(self))) - - -class PoseSet: - """Object representing a subset of the 'pose' table in the :class:`.Database`. - - .. attention:: - - :class:`.PoseSet` objects should not be created directly. Instead use the :meth:`.HIPPO.poses` property. See :doc:`getting_started` and :doc:`insert_elaborations`. - - Use as an iterable - ================== - - Iterate through :class:`.Pose` objects in the set: - - :: - - pset = animal.poses[:100] - - for pose in pset: - ... - - Check membership - ================ - - To determine if a :class:`.Pose` is present in the set: - - :: - - is_member = pose in cset - - Selecting compounds in the set - ============================== - - The :class:`.PoseSet` can be indexed like standard Python lists by their indices - - :: - - pset = animal.poses[1:100] - - # indexing individual compounds - pose = pset[0] # get the first pose - pose = pset[1] # get the second pose - pose = pset[-1] # get the last pose - - # getting a subset of compounds using a slice - pset2 = pset[13:18] # using a slice - - """ - - _table = "pose" - - def __init__( - self, - db: Database, - indices: list = None, - *, - sort: bool = True, - name: str | None = None, - ) -> None: - """PoseSet initialisation""" - - self._db = db - - indices = indices or [] - - if not isinstance(indices, list): - indices = list(indices) - - assert all(isinstance(i, int) for i in indices) - - if sort: - self._indices = sorted(list(set(indices))) - else: - - # remove duplicates but keep order - self._indices = dict() - for i in indices: - if i not in self._indices: - self._indices[i] = i - self._indices = list(self._indices.keys()) - - self._interactions = None - self._metadata_dict = None - - self._name = name - - ### PROPERTIES - - @property - def db(self) -> "Database": - """Returns the associated :class:`.Database`""" - return self._db - - @property - def table(self) -> str: - """Returns the name of the :class:`.Database` table""" - return self._table - - @property - def indices(self) -> list[int]: - """Returns the ids of poses in this set""" - return self._indices - - @property - def ids(self) -> list[int]: - """Returns the ids of poses in this set""" - return self._indices - - @property - def name(self) -> str | None: - """Returns the name of set""" - return self._name - - @property - def names(self) -> list[str]: - """Returns the aliases of poses in this set""" - return [p.name for p in self] - - @property - def aliases(self) -> list[str]: - """Returns the aliases of child poses""" - return [ - self.db.select_where( - table=self.table, query="pose_alias", key="id", value=i, multiple=False - )[0] - for i in self.indices - ] - - @property - def inchikeys(self) -> list[str]: - """Returns the inchikeys of child poses""" - return [ - self.db.select_where( - table=self.table, - query="pose_inchikey", - key="id", - value=i, - multiple=False, - )[0] - for i in self.indices - ] - - @property - def id_name_dict(self) -> dict: - """Return a dictionary mapping pose ID's to their name""" - - records = self.db.select_where( - table=self.table, - query="pose_id, pose_inchikey, pose_alias", - key=f"pose_id IN {self.str_ids}", - multiple=True, - ) - - lookup = {} - for i, inchikey, alias in records: - if alias: - lookup[i] = alias - else: - lookup[i] = inchikey - - return lookup - - @property - def smiles(self) -> list[str]: - """Returns the smiles of poses in this set""" - pairs = self.db.select_where( - table=self.table, - query="pose_id, pose_smiles", - key=f"pose_id IN {self.str_ids}", - multiple=True, - ) - - results = [] - for pose_id, smiles in pairs: - if smiles is None: - pose = self.db.get_pose(id=pose_id) - smiles = pose.smiles - - results.append(smiles) - - return results - - @property - def tags(self) -> set[str]: - """Returns the set of unique tags present in this pose set""" - values = self.db.select_where( - table="tag", - query="DISTINCT tag_name", - key=f"tag_pose in {self.str_ids}", - multiple=True, - ) - return set(v for v, in values) - - @property - def compounds(self) -> "CompoundSet": - """Get the compounds associated to this set of poses""" - from .cset import CompoundSet - - ids = self.db.select_where( - table="pose", - query="DISTINCT pose_compound", - key=f"pose_id in {self.str_ids}", - multiple=True, - ) - ids = [v for v, in ids] - return CompoundSet(self.db, ids) - - @property - def mols(self) -> "list[rdkit.Chem.mol]": - """Get the rdkit Molecules contained in this set""" - return [p.mol for p in self] - - @property - def num_compounds(self) -> int: - """Count the compounds associated to this set of poses""" - return len(self.compounds) - - @property - def df(self) -> "pandas.DataFrame": - """Get a DataFrame of the poses in this set""" - return self.get_df(mol=True) - - @property - def references(self) -> "PoseSet": - """Return a :class:`.PoseSet` of the all the distinct references in this :class:`.PoseSet`""" - return PoseSet(self.db, self.reference_ids) - - @property - def reference_ids(self) -> set[int]: - """Return a set of :class:`.Pose` ID's of the all the distinct references in this :class:`.PoseSet`""" - values = self.db.select_where( - table="pose", - query="DISTINCT pose_reference", - key=f"pose_reference IS NOT NULL and pose_id in {self.str_ids}", - value=None, - multiple=True, - ) - return set(v for v, in values) - - @property - def inspiration_sets(self) -> list[set[int]]: - """Return a list of unique sets of inspiration :class:`.Pose` IDs""" - - sql = f""" - SELECT inspiration_derivative, inspiration_original FROM inspiration - WHERE inspiration_derivative IN {self.str_ids} - """ - - pairs = self.db.execute(sql).fetchall() - - data = {} - for derivative, original in pairs: - if derivative not in data: - data[derivative] = set() - data[derivative].add(original) - - data = {k: tuple(sorted(list(v))) for k, v in data.items()} - - unique = set(data.values()) - - return unique - - @property - def num_inspiration_sets(self) -> int: - """Return the number of unique sets of inspirations""" - return len(self.inspiration_sets) - - @property - def num_inspirations(self) -> int: - """Return the number of unique inspirations for poses in this set""" - (count,) = self.db.select_where( - table="inspiration", - query="COUNT(DISTINCT inspiration_original)", - key=f"inspiration_derivative IN {self.str_ids}", - ) - - return count - - @property - def inspirations(self) -> int: - """Return the number of unique inspirations for poses in this set""" - records = self.db.select_where( - table="inspiration", - query="DISTINCT inspiration_original", - key=f"inspiration_derivative IN {self.str_ids}", - multiple=True, - ) - - if not records: - return None - - return PoseSet(self.db, [i for i, in records]) - - @property - def str_ids(self) -> str: - """Return an SQL formatted tuple string of the :class:`.Pose` IDs""" - return str(tuple(self.ids)).replace(",)", ")") - - @property - def targets(self) -> "list[Target]": - """Returns the :class:`.Target` objects of poses in this set""" - return [self.db.get_target(id=q) for q in self.target_ids] - - @property - def target_names(self) -> list[str]: - """Returns the :class:`.Target` objects of poses in this set""" - return [self.db.get_target_name(id=q) for q in self.target_ids] - - @property - def target_ids(self) -> list[int]: - """Returns the :class:`.Target` objects ID's of poses in this set""" - result = self.db.select_where( - table=self.table, - query="DISTINCT pose_target", - key=f"pose_id in {self.str_ids}", - multiple=True, - ) - return [q for q, in result] - - @property - def best_placed_pose(self) -> Pose: - """Returns the pose with the best distance_score in this subset""" - return self.db.get_pose(id=self.best_placed_pose_id) - - @property - def best_placed_pose_id(self) -> int: - """Get the id of the pose with the best distance_score in this subset""" - query = f"pose_id, MIN(pose_distance_score)" - query = self.db.select_where( - table="pose", query=query, key=f"pose_id in {self.str_ids}", multiple=False - ) - return query[0] - - @property - def interactions(self) -> "InteractionSet": - """Get a :class:`.InteractionSet` for this :class:`.Pose`""" - if self._interactions is None: - from .iset import InteractionSet - - self._interactions = InteractionSet.from_pose(self) - return self._interactions - - @property - def pose_id_metadata_dict(self) -> dict[int, dict]: - """Get a dictionary mapping pose_ids to metadata dicts""" - if self._metadata_dict is None: - metadata_lookup = self.db.get_id_metadata_dict(table="pose", ids=self.ids) - metadata = {} - for pose_id in self.ids: - metadata[pose_id] = metadata_lookup[pose_id] - self._metadata_dict = metadata - return self._metadata_dict - - def get_interaction_overlaps(self, return_pairs: bool = False) -> int: - """Count the number of member pose pairs which share at least one but not all interactions""" - - from itertools import combinations - - sql = f""" - SELECT DISTINCT interaction_pose, feature_id, interaction_type FROM interaction - INNER JOIN feature ON interaction_feature = feature_id - WHERE interaction_pose IN {self.str_ids} - """ - - # mrich.print(sql) - - records = self.db.execute(sql).fetchall() - - ISETS = {} - for pose_id, feature_id, interaction_type in records: - values = ISETS.get(pose_id, set()) - values.add((interaction_type, feature_id)) - ISETS[pose_id] = values - - ids = [i for i in self.ids if i in ISETS] - - count = 0 - - pairs = set() - - for pose_j, pose_k in combinations(ids, 2): - - iset_j = ISETS[pose_j] - iset_k = ISETS[pose_k] - - intersection = iset_j & iset_k - diff1 = iset_j - iset_k - diff2 = iset_k - iset_j - - if intersection and diff1 and diff2: - count += 1 - pairs.add((pose_j, pose_k)) - - if return_pairs: - return [PoseSet(self.db, [a, b]) for a, b in pairs] - - return count - - def get_interaction_clusters(self) -> "dict[int, PoseSet]": - """Cluster poses based on shared interactions.""" - - import networkx as nx - import community as louvain - from itertools import combinations - - # get interaction records - - sql = f""" - SELECT DISTINCT interaction_pose, feature_residue_name, feature_residue_number, interaction_type FROM interaction - INNER JOIN feature ON interaction_feature = feature_id - WHERE interaction_pose IN {self.str_ids} - """ - - records = self.db.execute(sql).fetchall() - - ISETS = {} - for ( - pose_id, - feature_residue_name, - feature_residue_number, - interaction_type, - ) in records: - values = ISETS.get(pose_id, set()) - values.add((interaction_type, feature_residue_name, feature_residue_number)) - ISETS[pose_id] = values - - pairs = combinations(ISETS.keys(), 2) - - # construct overlap dictionary - - OVERLAPS = {} - for id1, id2 in pairs: - iset1 = ISETS[id1] - iset2 = ISETS[id2] - OVERLAPS[(id1, id2)] = len(iset1 & iset2) - - # make the graph - G = nx.Graph() - - for (id1, id2), count in OVERLAPS.items(): - G.add_edge(id1, id2, weight=count) - - # partition the graph - - partition = louvain.best_partition(G, weight="weight") - - # find the clusters - - clusters = {} - for node, cluster_id in partition.items(): - clusters.setdefault(cluster_id, set()).add(node) - - # create the PoseSets - - psets = { - i: PoseSet(self.db, ids, name=f"Cluster {i}") - for i, ids in enumerate(clusters.values()) - } - - all_ids = set(sum((pset.ids for pset in psets.values()), [])) - - # calculate modal interactions - - for i, cluster in psets.items(): - - mrich.var(cluster.name, len(cluster), unit="poses") - - df = cluster.interactions.df - - unique_counts = df.groupby(["type", "residue_name", "residue_number"])[ - "pose_id" - ].nunique() - - max_count = unique_counts.max() - max_pairs = unique_counts[unique_counts == max_count] - - for ( - interaction_type, - residue_name, - residue_number, - ) in max_pairs.index.values: - mrich.print(interaction_type, "w/", residue_name, residue_number) - - # unclustered - unclustered = set((i for i in self.ids if i not in all_ids)) - psets[None] = PoseSet(self.db, unclustered, name="Unclustered") - - return psets - - @property - def num_fingerprinted(self) -> int: - """Count the number of fingerprinted poses in this set""" - return self.db.count_where( - table="pose", key=f"pose_id IN {self.str_ids} AND pose_fingerprint = 1" - ) - - @property - def fraction_fingerprinted(self) -> float: - """Return the fraction of fingerprinted poses in this set""" - return self.num_fingerprinted / len(self) - - @property - def num_subsites(self) -> int: - """Count the number of subsites that poses in this set come into contact with""" - (count,) = self.db.select_where( - query="COUNT(DISTINCT subsite_tag_ref)", - table="subsite_tag", - key=f"subsite_tag_pose IN {self.str_ids}", - none="quiet", - ) - if count is None: - count = 0 - return count - - @property - def subsite_balance(self) -> float: - """Measure of how evenly subsite counts are distributed across poses in this set""" - - from numpy import std - - sql = f""" - SELECT COUNT(DISTINCT subsite_tag_ref) FROM subsite_tag - WHERE subsite_tag_pose IN {self.str_ids} - GROUP BY subsite_tag_pose - """ - - counts = self.db.execute(sql).fetchall() - - counts = [c for c, in counts] + [0 for _ in range(len(self) - len(counts))] - - return -std(counts) - - @property - def subsite_ids(self) -> set[int]: - """Return a list of subsite id's of member poses""" - - sql = f""" - SELECT DISTINCT subsite_tag_ref FROM subsite_tag - WHERE subsite_tag_pose IN {self.str_ids} - """ - - subsite_ids = self.db.execute(sql).fetchall() - - if not subsite_ids: - return set() - - subsite_ids = set([i for i, in subsite_ids]) - - return subsite_ids - - @property - def avg_energy_score(self) -> float: - """Average energy score of poses in this set""" - - from numpy import mean - - sql = f""" - SELECT pose_energy_score FROM pose - WHERE pose_id IN {self.str_ids} - """ - - scores = self.db.execute(sql).fetchall() - return mean([s for s, in scores if s is not None]) - - @property - def avg_distance_score(self) -> float: - """Average distance score of poses in this set""" - - from numpy import mean - - sql = f""" - SELECT pose_distance_score FROM pose - WHERE pose_id IN {self.str_ids} - """ - - scores = self.db.execute(sql).fetchall() - - return mean([s for s, in scores if s is not None]) - - @property - def derivatives(self) -> "PoseSet": - """Get the :class:`.PoseSet` of derivatives""" - - ids = self.db.select_where( - table="inspiration", - query="inspiration_derivative", - key=f"inspiration_original IN {self.str_ids}", - multiple=True, - none="quiet", - ) - if not ids: - return None - ids = [i for i, in ids] - pset = PoseSet(self.db, ids, name=f"derivatives of {self}") - return pset - - ### FILTERING - - def get_by_tag( - self, - tag: str, - inverse: bool = False, - ) -> "PoseSet": - """Get all child poses with a certain tag - - :param tag: tag to filter by - :param inverse: return all poses *not* tagged with ``tag`` (Default value = False) - - """ - values = self.db.select_where( - query="tag_pose", table="tag", key="name", value=tag, multiple=True - ) - if inverse: - matches = [v for v, in values if v] - ids = [i for i in self.ids if i not in matches] - else: - ids = [v for v, in values if v and v in self.ids] - return PoseSet(self.db, ids) - - def get_by_metadata( - self, key: str, value: str | None = None, debug: bool = False - ) -> "PoseSet": - """Get all child poses with by their metadata. If no value is passed, then simply containing the key in the metadata dictionary is sufficient - - :param key: metadata key to search for - :param value: metadata value, if ``None`` return poses with the metadata key regardless of value (Default value = None) - - """ - results = self.db.select_where( - query="pose_id, pose_metadata", - key=f"pose_id IN {self.str_ids}", - table="pose", - multiple=True, - ) - - if value is None: - ids = [i for i, d in results if d and f'"{key}":' in d] - - else: - if isinstance(value, str): - value = f'"{value}"' - - ids = [] - - for i, d in results: - if not d: - continue - - if debug: - mrich.print(i, d, f'"{key}": {value}' in d) - - if f'"{key}": {value}' in d: - ids.append(i) - else: - continue - - if debug: - break - - return PoseSet(self.db, ids) - - def get_by_inspiration(self, inspiration: int | Pose, inverse: bool = False): - """Get all child poses with with this inspiration. - - :param inspiration: inspiration :class:`.Pose` ID or object - :param inverse: invert the selection (Default value = False) - - """ - - ids = set() - - for pose in self: - if not inverse: - for pose_inspiration in pose.inspirations: - if pose_inspiration == inspiration: - ids.add(pose.id) - break - - elif inverse: - for pose_inspiration in pose.inspirations: - if pose_inspiration == inspiration: - break - else: - ids.add(pose.id) - - return PoseSet(self.db, ids) - - def get_df( - self, - smiles: bool = True, - inchikey: bool = True, - alias: bool = True, - name: bool = True, - compound_id: bool = False, - target_id: bool = False, - reference_id: bool = False, - reference_alias: bool = False, - path: bool = False, - mol: bool = False, - energy_score: bool = False, - distance_score: bool = False, - inspiration_score: bool = False, - metadata: bool = False, - expand_metadata: bool = True, - debug: bool = True, - inspiration_ids: bool = False, - inspiration_aliases: bool = False, - derivative_ids: bool = False, - tags: bool = False, - subsites: bool = False, - # skip_no_mol=True, reference: str = "name", mol: bool = False, **kwargs - ) -> "pandas.DataFrame": - """Get a DataFrame of the poses in this set. - - :param smiles: include SMILES column (Default value = True) - :param inchikey: include InChIKey column (Default value = True) - :param alias: include alias column (Default value = True) - :param name: include name column (Default value = True) - :param compound_id: include :class:`.Compound` ID column (Default value = False) - :param reference_id: include reference :class:`.Pose` ID column (Default value = False) - :param target_id: include reference :class:`.Target` ID column (Default value = False) - :param path: include path column (Default value = False) - :param mol: include ``rdkit.Chem.Mol`` in output (Default value = False) - :param energy_score: include energy_score column (Default value = False) - :param distance_score: include distance_score column (Default value = False) - :param inspiration_score: include inspiration_score column (Default value = False) - :param metadata: include metadata in output (Default value = False) - :param expand_metadata: create separate column for each metadata key (Default value = True) - :param inspiration_ids: include inspiration :class:`.Pose` ID column - :param inspiration_aliases: include inspiration :class:`.Pose` alias column - :param derivative_ids: include derivative :class:`.Pose` ID column - :param tags: include tags column - :param subsites: include subsites column - """ - - from json import loads - from rdkit.Chem import Mol - from pandas import DataFrame - - get_alias = alias - - if name: - alias = True - - query = ["pose_id"] - - if smiles: - query.append("pose_smiles") - - if inchikey: - query.append("pose_inchikey") - - if alias: - query.append("pose_alias") - - if reference_id or reference_alias: - query.append("pose_reference") - - if path: - query.append("pose_path") - - if compound_id: - query.append("pose_compound") - - if target_id: - query.append("pose_target") - - if mol: - query.append("pose_mol") - - if energy_score: - query.append("pose_energy_score") - - if distance_score: - query.append("pose_distance_score") - - if inspiration_score: - query.append("pose_inspiration_score") - - if metadata: - query.append("pose_metadata") - - query = ", ".join(query) - - sql = f""" - SELECT {query} - FROM pose - WHERE pose_id IN {self.str_ids} - """ - - if debug: - # print(sql) - mrich.debug("querying...") - records = self.db.execute(sql).fetchall() - - if debug: - generator = mrich.track(records) - else: - generator = records - - data = [] - for row in generator: - - row = list(row) - - d = dict(id=row.pop(0)) - - if smiles: - d["smiles"] = row.pop(0) - - if inchikey: - d["inchikey"] = row.pop(0) - - if alias: - d["alias"] = row.pop(0) - - if reference_id or reference_alias: - d["reference_id"] = row.pop(0) - - if path: - d["path"] = row.pop(0) - - if compound_id: - d["compound_id"] = row.pop(0) - - if target_id: - d["target_id"] = row.pop(0) - - if mol: - mol_bytes = row.pop(0) - if mol_bytes: - d["mol"] = Mol(mol_bytes) - - if energy_score: - d["energy_score"] = row.pop(0) - - if distance_score: - d["distance_score"] = row.pop(0) - - if inspiration_score: - d["inspiration_score"] = row.pop(0) - - if metadata and (meta_str := row.pop(0)): - - meta_dict = loads(meta_str) or {} - - if expand_metadata: - for k, v in meta_dict.items(): - d[k] = v - - else: - d["metadata"] = meta_dict - - data.append(d) - - df = DataFrame(data) - - if inspiration_ids or derivative_ids or inspiration_aliases: - if debug: - mrich.debug("adding inspiration column(s)") - - tuples = self.db.get_inspiration_tuples() - - if inspiration_ids or inspiration_aliases: - lookup = {} - for inspiration, derivative in tuples: - lookup.setdefault(derivative, set()) - lookup[derivative].add(inspiration) - df["inspiration_ids"] = df["id"].apply(lambda x: lookup.get(x, set())) - - if derivative_ids: - lookup = {} - for inspiration, derivative in tuples: - lookup.setdefault(inspiration, set()) - lookup[inspiration].add(derivative) - df["derivative_ids"] = df["id"].apply(lambda x: lookup.get(x, set())) - - if inspiration_aliases: - inspirations = PoseSet( - self.db, set.union(*list(df["inspiration_ids"].values)) - ) - lookup = self.db.get_pose_id_alias_dict(pset=inspirations) - df["inspiration_aliases"] = df["inspiration_ids"].apply( - lambda x: {lookup[i] for i in x} - ) - if not inspiration_ids: - df = df.drop(columns=["inspiration_ids"]) - - if reference_alias: - references = PoseSet( - self.db, - set([int(x) for x in df["reference_id"].values if x is not None]), - ) - - if references: - lookup = self.db.get_pose_id_alias_dict(pset=references) - df["reference_alias"] = df["reference_id"].apply(lambda x: lookup[x]) - else: - df["reference_alias"] = None - - if not reference_id: - df = df.drop(columns=["reference_id"]) - - if tags: - if debug: - mrich.debug("adding tag column") - lookup = self.db.get_pose_tag_dict() - df["tags"] = df["id"].apply(lambda x: lookup.get(x, {})) - - if subsites: - if debug: - mrich.debug("adding subsite column") - lookup = self.db.get_pose_subsite_names_dict() - df["subsites"] = df["id"].apply(lambda x: lookup.get(x, set())) - - if name: - df["name"] = df.apply(lambda row: row["alias"] or f'P{row["id"]}', axis=1) - if not get_alias: - df = df.drop(columns=["alias"]) - - df = df.set_index("id") - - ### Fill missing smiles entries - - smiles_missing = smiles and "smiles" in df.columns and df["smiles"].isna().any() - inchikey_missing = ( - inchikey and "inchikey" in df.columns and df["inchikey"].isna().any() - ) - - if smiles_missing or inchikey_missing: - - mrich.error("None in smiles/inchikey column") - - empty = df[df["smiles"].isna()] - empty_poses = PoseSet(self.db, set(empty.index)) - - for pose in mrich.track( - empty_poses, prefix=f"generating smiles/inchikeys ({len(empty)} poses)" - ): - pose.smiles - - records = self.db.select_where( - table="pose", - query="pose_id, pose_smiles, pose_inchikey", - key=f"pose_id IN {empty_poses.str_ids}", - multiple=True, - ) - - for pose_id, pose_smiles, pose_inchikey in records: - df.loc[pose_id, "smiles"] = pose_smiles - df.loc[pose_id, "inchikey"] = pose_inchikey - - assert not df["smiles"].isna().any() - assert not df["inchikey"].isna().any() - - ### Fill missing molecule entries - - if mol and df["mol"].isna().any(): - empty = df[df["mol"].isna()] - - mrich.warning(len(empty), "rows have empty 'mol'") - empty_poses = PoseSet(self.db, set(empty.index)) - - for pose in mrich.track(empty_poses, prefix="generating Mols"): - pose.mol - - records = self.db.select_where( - table="pose", - query="pose_id, pose_mol", - key=f"pose_id IN {empty_poses.str_ids}", - multiple=True, - ) - - for pose_id, pose_mol in records: - df.loc[pose_id, "mol"] = Mol(pose_mol) - - assert not len(df[df["mol"].isna()]) - - return df - - def get_by_reference( - self, - ref_id: int, - ) -> "PoseSet | None": - """Get poses with a certain reference id - - :param ref_id: reference :class:`.Pose` ID - - """ - values = self.db.select_where( - table="pose", - query="pose_id", - key=f"pose_reference={ref_id} AND pose_id in {self.str_ids}", - multiple=True, - ) - if not values: - return None - return PoseSet(self.db, [v for v, in values]) - - def get_by_compound( - self, - *, - compound: "int | Compound | CompoundSet", - ) -> "PoseSet | None": - """Select a subset of this :class:`.PoseSet` by the associated :class:`.Compound`. - - :param compound: :class:`.Compound` object or ID - :returns: a :class:`.PoseSet` of the selection - - """ - from .compound import Compound - from .cset import CompoundSet - - if isinstance(compound, CompoundSet): - values = self.db.select_where( - query="pose_id", - table="pose", - key=f"pose_compound IN {compound.str_ids} AND pose_id in {self.str_ids}", - multiple=True, - none="quiet", - ) - - else: - - if isinstance(compound, Compound): - compound = compound.id - - values = self.db.select_where( - query="pose_id", - table="pose", - key=f"pose_compound={compound} AND pose_id in {self.str_ids}", - multiple=True, - none="quiet", - ) - - if not values: - return None - ids = [v for v, in values if v] - return PoseSet(self.db, [v for v, in values]) - - def get_by_target( - self, - *, - id: int, - ) -> "PoseSet | None": - """Select a subset of this :class:`.PoseSet` by the associated :class:`.Target`. - - :param id: :class:`.Target` ID - :returns: a :class:`.PoseSet` of the selection - - """ - assert isinstance(id, int) - values = self.db.select_where( - query="pose_id", - table="pose", - key=f"pose_target is {id} AND pose_id in {self.str_ids}", - multiple=True, - none="quiet", - ) - ids = [v for v, in values if v] - if not ids: - return None - return PoseSet(self.db, ids) - - def get_by_subsite( - self, - *, - id: int, - ) -> "PoseSet | None": - """Select a subset of this :class:`.PoseSet` by the associated :class:`.Subsite`. - - :param id: :class:`.Subsite` ID - :returns: a :class:`.PoseSet` of the selection - - """ - assert isinstance(id, int) - values = self.db.select_where( - query="subsite_tag_pose", - table="subsite_tag", - key=f"subsite_tag_ref is {id} AND subsite_tag_pose in {self.str_ids}", - multiple=True, - none="quiet", - ) - ids = [v for v, in values if v] - if not ids: - return None - - if self.name: - name = f"{self.name} & subsite={id}" - else: - name = None - - return PoseSet(self.db, ids, name=name) - - def get_best_placed_poses_per_compound(self): - """Choose the best placed pose (best distance_score) grouped by compound""" - - sql = f""" - SELECT pose_id, MIN(pose_distance_score) FROM pose - WHERE pose_id IN {self.str_ids} - GROUP BY pose_compound - """ - - cursor = self.db.execute(sql) - - ids = [i for i, _ in cursor] - - return PoseSet(self.db, ids) - - def filter( - self, - function=None, - *, - key: str = None, - value: str = None, - operator="=", - inverse: bool = False, - ): - """Filter this :class:`.PoseSet` by selecting members where ``function(pose)`` is truthy or pass a key, value, and optional operator to search by database values - - :param function: callable object - :param key: database field for 'pose' table ('pose_' prefix not needed) - :param value: value to compare to - :param operator: comparison operator (default = "=") - :param inverse: invert the selection (Default value = False) - - """ - - if function: - - ids = set() - for pose in self: - value = function(pose) - # mrich.debug(f'{pose=} {value=}') - if value and not inverse: - ids.add(pose.id) - elif not value and inverse: - ids.add(pose.id) - - return PoseSet(self.db, ids) - - sql = f""" - SELECT pose_id FROM pose - WHERE pose_id IN {self.str_ids} - AND pose_{key} {operator} {value} - """ - - cursor = self.db.execute(sql) - - ids = [i for i, in cursor] - - return PoseSet(self.db, ids) - - ### BULK SETTING - - @property - def reference(self): - """Bulk set the references for poses in this set""" - raise NotImplementedError( - "This attribute only allows setting, ``PoseSet.reference = ...``" - ) - - @reference.setter - def reference(self, r) -> None: - """Bulk set the references for poses in this set""" - if not isinstance(r, int): - assert r._table == "pose" - r = r.id - - for i in self.indices: - self.db.update( - table="pose", id=i, key="pose_reference", value=r, commit=False - ) - - self.db.commit() - - def add_tag( - self, - tag: str, - ) -> None: - """Add this tag to every member of the set""" - - assert isinstance(tag, str) - - for i in self.indices: - self.db.insert_tag(name=tag, pose=i, commit=False) - - mrich.print(f'Tagged {self} w/ "{tag}"') - - self.db.commit() - - def append_to_metadata( - self, - key, - value, - ) -> None: - """Append a specific item to list-like values associated with a given key for all member's metadata dictionaries - - :param key: the :class:`.Metadata` key to match - :param value: the value to append to the list - - """ - for id in self.indices: - metadata = self.db.get_metadata(table="pose", id=id) - try: - metadata.append(key, value) - except AttributeError: - mrich.error(f"Could not append to metadata {key=}. Not a list?") - - def set_subsites_from_metadata_field(self, field="CanonSites alias") -> None: - """Create and assign subsite entries from a metadata field - - :param field: the metadata field to use - - """ - - from json import loads - - records = self.db.select_where( - table="pose", - query="pose_id, pose_target, pose_metadata", - key=f"pose_id IN {self.str_ids}", - multiple=True, - ) - - subsites = set() - subsite_tags = set() - - for pose_id, pose_target, metadata in records: - - metadata = loads(metadata) - - key = metadata.get(field) - - if not key: - mrich.warning(field, "not in metadata pose_id=", pose_id) - continue - - subsites.add((pose_target, key)) - subsite_tags.add((key, pose_id)) - - sql = """ - INSERT OR IGNORE INTO subsite(subsite_target, subsite_name) - VALUES(?1, ?2) - RETURNING subsite_id - """ - - records = self.db.executemany(sql, sorted(list(subsites))) - subsite_ids = [i for i, in records] - subsite_lookup = {name: i for (t, name), i in zip(subsites, subsite_ids)} - - # supplement existing subsites - subsite_lookup.update( - { - n: i - for i, n in self.db.select( - table="subsite", query="subsite_id, subsite_name", multiple=True - ) - } - ) - - sql = """ - INSERT OR IGNORE INTO subsite_tag(subsite_tag_ref, subsite_tag_pose) - VALUES(?1, ?2) - """ - - subsite_tags = [ - (subsite_lookup[subsite], pose_id) for subsite, pose_id in subsite_tags - ] - - self.db.executemany(sql, subsite_tags) - - self.db.commit() - - def calculate_inspiration_scores( - self, - alpha: float = 0.95, - beta: float = 0.05, - score_type: str = "combo", - ) -> "pd.DataFrame": - """Set inspiration_score values using MoCASSIn.calculate_mocassin_tversky - - :param alpha: Tversky alpha parameter - :param beta: Tversky beta parameter - :param score_type: Score type to add to database, choose from "combo", "shape", "colour" - :returns: Pandas DataFrame with molecules and scores - """ - - from mocassin.mocassin import calculate_mocassin_tversky - - df = self.get_df( - alias=False, - smiles=False, - inchikey=False, - inspiration_ids=True, - mol=True, - ) - - inspirations = {p.id: p for p in self.inspirations} - - df["inspiration_mols"] = df["inspiration_ids"].apply( - lambda x: [inspirations[i].mol for i in x] - ) - - n = len(df) - - for j, (i, row) in mrich.track( - enumerate(df.iterrows()), prefix="MoCASSIn", total=n - ): - - mrich.set_progress_field("j", j) - mrich.set_progress_field("n", n) - - try: - combo, shape, colour = calculate_mocassin_tversky( - row["inspiration_mols"], - row["mol"], - alpha=0.95, - beta=0.05, - ) - df.loc[i, f"mocassin_combo({alpha},{beta})"] = combo - df.loc[i, f"mocassin_shape({alpha},{beta})"] = shape - df.loc[i, f"mocassin_colour({alpha},{beta})"] = colour - except Exception as e: - mrich.error(e) - - tuples = df[f"mocassin_{score_type}({alpha},{beta})"].items() - - sql = """UPDATE pose SET pose_inspiration_score = ?2 WHERE pose_id = ?1""" - - mrich.debug("Updating pose_inspiration_score values") - self.db.executemany(sql, tuples) - self.db.commit() - - return df - - ### SPLITTING - - def split_by_reference(self) -> "dict[int,PoseSet]": - """Split this :class:`.PoseSet` into subsets grouped by reference ID - - :returns: a dictionary with reference :class:`.Pose` IDs as keys and :class:`.PoseSet` subsets as values - - """ - sets = {} - for ref_id in self.reference_ids: - sets[ref_id] = self.get_by_reference(ref_id) - return sets - - def split_by_inspirations( - self, - single_set: bool = False, - ) -> "dict[PoseSet,PoseSet] | PoseSet": - """Split this :class:`.PoseSet` into subsets grouped by inspirations - - :param single_set: Return a single :class:`.PoseSet` with members sorted by inspirations (Default value = False) - :returns: a dictionary with tuples of inspiration :class:`.PoseSet` as keys and :class:`.PoseSet` derivative subsets as values - - """ - - sets = {} - - lookup = self.db.get_pose_id_inspiration_ids_dict(pset=self) - - for pose_id, insp_ids in lookup.items(): - key = tuple(insp_ids) - sets.setdefault(key, set()) - sets[key].add(pose_id) - - mrich.var("#unique inspiration combinations", len(sets)) - - if single_set: - return PoseSet(self.db, sum([s.ids for s in sets.values()], []), sort=False) - - return { - PoseSet(self.db, insp_ids): PoseSet(self.db, pose_ids) - for insp_ids, pose_ids in sets.items() - } - - ### EXPORTING - - def write_sdf( - self, - out_path: str, - name_col: str = "alias", - inspiration_ids: bool = False, - inspiration_aliases: bool = False, - **kwargs, - ) -> None: - """Write an SDF - - :param out_path: filepath of the output - :param name_col: pose property to use as the name column, can be ``["name", "alias", "inchikey", "id"]`` (Default value = 'name') - :param inspiration_ids: include inspiration :class:`.Pose` ID column - :param inspiration_aliases: include inspiration :class:`.Pose` alias column - :param fragalysis_inspirations: create inspirations column "ref_mols" - """ - - from pathlib import Path - import json - - df = self.get_df( - mol=True, - inspiration_ids=inspiration_ids, - inspiration_aliases=inspiration_aliases, - name=name_col == "name", - **kwargs, - ) - - if name_col not in ["name", "alias", "inchikey", "id"]: - # try getting name from metadata - records = self.db.select_where( - table="pose", - query="pose_id, pose_metadata", - key=f"pose_id IN {self.str_ids}", - multiple=True, - ) - - longcode_lookup = {} - for i, d in records: - if d: - metadata = json.loads(d) - else: - metadata = {} - - longcode_lookup[i] = metadata.get(name_col, None) - - values = [] - for i, row in df.iterrows(): - values.append(longcode_lookup[row["id"]]) - - df[name_col] = values - - df.rename(inplace=True, columns={name_col: "_Name", "mol": "ROMol"}) - - mrich.writing(out_path) - - from rdkit.Chem import PandasTools - - PandasTools.WriteSDF(df, out_path, "ROMol", "_Name", list(df.columns)) - - # keep record of export - value = str(Path(out_path).resolve()) - self.db.remove_metadata_list_item(table="pose", key="exports", value=value) - self.append_to_metadata(key="exports", value=value) - - def to_fragalysis( - self, - out_path: str, - *, - method: str, - ref_url: str = "https://hippo.winokan.com", - submitter_name: str, - submitter_email: str, - submitter_institution: str, - metadata: bool = True, - sort_by: str | None = None, - sort_reverse: bool = False, - generate_pdbs: bool = False, - copy_reference_pdbs: bool = False, - # ingredients: IngredientSet = None, - skip_no_reference: bool = True, - skip_no_inspirations: bool = True, - skip_metadata: list[str] | None = None, - tags: bool = True, - subsites: bool = True, - extra_cols: dict[str, list] = None, - # name_col: str = "name", - **kwargs, - ): - """Prepare an SDF for upload to the RHS of Fragalysis. - - :param out_path: the file path to write to - :param method: method used to generate the compounds - :param ref_url: reference URL for the method - :param submitter_name: name of the person submitting the compounds - :param submitter_email: email of the person submitting the compounds - :param submitter_institution: institution name of the person submitting the compounds - :param metadata: include metadata in the output? (Default value = True) - :param skipmetadata: exclude metadata keys from output - :param sort_by: if set will sort the SDF by this column/field (Default value = None) - :param sort_reverse: reverse the sorting (Default value = False) - :param generate_pdbs: generate accompanying protein-ligand complex PDBs (Default value = False) - :param ingredients: get procurement and amount information from this :class:`.IngredientSet` (Default value = None) - :param tags: include a column for tags in the output (Default value = True) - :param subsites: include a column for subsites in the output (Default value = True) - :param extra_cols: extra_cols should be a dictionary with a key for each column name, and list values where the first element is the field description, and all subsequent elements are values for each pose. - - """ - - from .fragalysis import generate_header - from pathlib import Path - from rdkit.Chem import SDWriter, PandasTools - - assert out_path.endswith(".sdf") - - _name_col = "_Name" - mol_col = "ROMol" - - # make sure references are defined: - - mrich.debug(len(self), "poses in set") - poses = None - - if skip_no_reference: - - values = self.db.select_where( - table="pose", - query="DISTINCT pose_id", - key=f"pose_reference IS NOT NULL and pose_id in {self.str_ids}", - multiple=True, - none="error", - ) - - if not values: - return - - poses = PoseSet(self.db, [i for i, in values]) - - mrich.debug(len(poses), "remaining after skipping null reference") - - if skip_no_inspirations: - - if not poses: - poses = self - - values = self.db.select_where( - table="inspiration", - query="DISTINCT inspiration_derivative", - key=f"inspiration_derivative IN {poses.str_ids}", - multiple=True, - none="error", - ) - - if not values: - return - - poses = PoseSet(self.db, [i for i, in values]) - - mrich.debug(len(poses), "remaining after skipping null inspirations") - - if not poses: - poses = PoseSet(self.db, self.ids) - - mrich.var("#poses", len(poses)) - - # get the dataframe of poses - - pose_df = poses.get_df( - mol=True, - inspiration_ids=True, - # duplicate_name="original ID", - name=True, - compound_id=True, - reference_id=True, - metadata=metadata, - tags=tags, - subsites=subsites, - energy_score=True, - distance_score=True, - inspiration_score=True, - # sanitise_null_metadata_values=True, - # sanitise_tag_list_separator=";", - # sanitise_metadata_list_separator=";", - # skip_metadata=skip_metadata, - # **kwargs, - ) - - pose_df = pose_df.reset_index() - - # fix inspirations and reference column (comma separated aliases) - - lookup = self.db.get_pose_id_alias_dict() - - inspiration_strs = [] - for i, row in pose_df.iterrows(): - strs = [] - for i in row["inspiration_ids"]: - alias = lookup.get(i) - if not alias: - continue - strs.append(alias) - inspiration_strs.append(",".join(strs)) - - # comma separate subsites - if subsites: - - def fix_subsites(subsite_list: list[str]): - """Fix subsites""" - if not subsite_list: - return "None" - return ",".join(subsite_list) - - pose_df["subsites"] = pose_df["subsites"].apply(fix_subsites) - - if tags: - pose_df["tags"] = pose_df["tags"].apply(lambda x: ",".join(x)) - - pose_df["ref_mols"] = inspiration_strs - pose_df["ref_pdb"] = pose_df["reference_id"].apply(lambda x: lookup[x]) - - # add compound identifier column (inchikey?) - - drops = ["inspiration_ids", "reference_id"] - - # if ingredients: - # drops.pop(drops.index("compound")) - - if skip_no_reference: - prev = len(pose_df) - pose_df = pose_df[pose_df["reference_id"].notna()] - if len(pose_df) < prev: - mrich.warning(f"Skipping {prev - len(pose_df)} Poses with no reference") - - pose_df = pose_df.drop(columns=drops, errors="ignore") - - pose_df[_name_col] = pose_df["name"] - - pose_df.rename( - inplace=True, - columns={ - "id": "HIPPO Pose ID", - "compound_id": "HIPPO Compound ID", - "mol": mol_col, - # "smiles": "original SMILES", - # "compound_id": "compound inchikey", - }, - ) - - extras = { - "HIPPO Pose ID": "HIPPO Pose ID", - "HIPPO Compound ID": "HIPPO Compound ID", - "smiles": "smiles", - "ref_pdb": "protein reference", - "ref_mols": "fragment inspirations", - "alias": "alias", - # "compound inchikey": "compound inchikey", - "distance_score": "distance_score", - "energy_score": "energy_score", - "inspiration_score": "inspiration_score", - } - - if subsites: - extras["subsites"] = "subsites" - - if tags: - extras["tags"] = "tags" - - if extra_cols: - for key, value in extra_cols.items(): - extras[key] = value[0] - - # if ingredients: - - # q_entries = [] - # q_prices = [] - # q_lead_times = [] - # q_amounts = [] - - # currency = None - - # for i, row in pose_df.iterrows(): - - # compound_id = self.db.get_compound_id(inchikey=row["compound inchikey"]) - - # ingredient = ingredients(compound_id=compound_id) - - # if isinstance(ingredient, IngredientSet): - # ingredient = sorted( - # [i for i in ingredient], key=lambda x: x.quote.price - # )[0] - - # quote = ingredient.quote - # if not currency: - # currency = quote.currency - # else: - # assert quote.currency == currency - - # q_entries.append(quote.entry_str) - # q_prices.append(quote.price) - # q_lead_times.append(quote.lead_time) - # q_amounts.append(quote.amount) - - # pose_df["Supplier Catalogue Entry"] = q_entries - # # pose_df['Supplier:Catalogue:Entry'] = q_entries - # pose_df[f"Price ({currency})"] = q_prices - # pose_df["Lead time (working days)"] = q_lead_times - # pose_df["Amount (mg)"] = q_amounts - - # extras["Supplier Catalogue Entry"] = "Supplier Catalogue Entry string" - # extras[f"Price ({currency})"] = "Quoted price" - # extras["Lead time (working days)"] = "Quoted lead-time" - # extras["Amount (mg)"] = "Quoted amount" - - out_path = Path(out_path).resolve() - mrich.var("out_path", out_path) - - if generate_pdbs: - - from zipfile import ZipFile - - # output subdirectory - out_key = Path(out_path).name.removesuffix(".sdf") - pdb_dir = Path(out_path).parent / Path(out_key) - pdb_dir.mkdir(exist_ok=True) - zip_path = Path(out_path).parent / f"{out_key}_pdbs.zip" - - # create the zip archive - with ZipFile(str(zip_path.resolve()), "w") as z: - - # loop over poses - for (i, row), pose in zip(pose_df.iterrows(), poses): - - # filenames - pdb_name = f"{out_key}_{row._Name}.pdb" - pdb_path = pdb_dir / pdb_name - pose_df.loc[i, "ref_pdb"] = pdb_name - - # generate the PL-complex - sys = pose.complex_system - - # write the PDB - mrich.writing(pdb_path) - sys.write(pdb_path, verbosity=0) - z.write(pdb_path) - - mrich.writing(f"{out_key}_pdbs.zip") - - if copy_reference_pdbs: - - from zipfile import ZipFile - import shutil - - # output subdirectory - out_key = Path(out_path).name.removesuffix(".sdf") - pdb_dir = Path(out_path).parent / Path(out_key) - pdb_dir.mkdir(exist_ok=True) - zip_path = Path(out_path).parent / f"{out_key}_refs.zip" - - references = self.references - lookup = self.db.get_pose_alias_path_dict(references) - - zips = set() - for ref_alias in pose_df["ref_pdb"].values: - source_path = Path(lookup[ref_alias]) - - apo_path = source_path.parent / source_path.name.replace( - "_hippo.pdb", ".pdb" - ).replace(".pdb", "_apo-desolv.pdb") - - if not apo_path.exists(): - sys = mp.parse(source_path).protein_system - sys.write(apo_path, verbosity=0) - - target_path = pdb_dir / f"{ref_alias}.pdb" - - if not target_path.exists(): - mrich.writing(target_path) - shutil.copy(apo_path, target_path) - - zips.add(target_path) - - # create the zip archive - with ZipFile(str(zip_path.resolve()), "w") as z: - for path in zips: - z.write(path, arcname=path.name) - - mrich.writing(f"{out_key}_refs.zip") - - # create the header molecule - - df_cols = set(pose_df.columns) - - header = generate_header( - self[0], - method=method, - ref_url=ref_url, - submitter_name=submitter_name, - submitter_email=submitter_email, - submitter_institution=submitter_institution, - extras=extras, - metadata=metadata, - ) - - header_cols = set(header.GetPropNames()) - - # # empty properties - # pose_df["generation_date"] = [None] * len(pose_df) - # pose_df["submitter_name"] = [None] * len(pose_df) - # pose_df["method"] = [None] * len(pose_df) - # pose_df["submitter_email"] = [None] * len(pose_df) - # pose_df["ref_url"] = [None] * len(pose_df) - - if extra_cols: - for key, value in extra_cols.items(): - if len(value) != len(pose_df) + 1: - mrich.error( - f'extra_col "{key}" does not have the correct number of values' - ) - raise ValueError( - f'extra_col "{key}" does not have the correct number of values' - ) - pose_df[key] = value[1:] - - if sort_by: - pose_df = pose_df.sort_values(by=sort_by, ascending=not sort_reverse) - - fields = [] - - mrich.writing(out_path) - - with open(out_path, "w") as sdfh: - with SDWriter(sdfh) as w: - w.write(header) - PandasTools.WriteSDF( - pose_df, sdfh, mol_col, _name_col, set(pose_df.columns) - ) - - # keep record of export - value = str(Path(out_path).resolve()) - self.db.remove_metadata_list_item(table="pose", key="exports", value=value) - self.append_to_metadata(key="exports", value=value) - - return pose_df - - def to_pymol(self, prefix: str | None = None) -> None: - """Group the poses by reference protein and inspirations and output relevant PDBs and SDFs. - - :param prefix: prefix to give all output subdirectories (Default value = None) - - """ - - commands = [] - - prefix = prefix or "" - if prefix: - prefix = f"{prefix}_" - - from pathlib import Path - - for i, (ref_id, poses) in enumerate(self.split_by_reference().items()): - - ref_pose = self.db.get_pose(id=ref_id) - ref_name = ref_pose.name or ref_id - - # create the subdirectory - ref_dir = Path(f"{prefix}ref_{ref_name}") - mrich.writing(ref_dir) - ref_dir.mkdir(parents=True, exist_ok=True) - - # write the reference protein - ref_pdb = ref_dir / f"ref_{ref_name}.pdb" - ref_pose.protein_system.write(ref_pdb, verbosity=0) - - # color the reference: - commands.append(f"load {ref_pdb.resolve()}") - commands.append("hide") - commands.append("show lines") - commands.append("show surface") - commands.append("util.cbaw") - commands.append("set surface_color, white") - commands.append("set transparency, 0.4") - - for j, (insp_ids, poses) in enumerate( - poses.split_by_inspirations().items() - ): - - inspirations = PoseSet(self.db, insp_ids) - insp_names = "-".join(inspirations.names) - - # create the subdirectory - insp_dir = ref_dir / insp_names - insp_dir.mkdir(parents=True, exist_ok=True) - - # write the inspirations - insp_sdf = insp_dir / f"{insp_names}_frags.sdf" - inspirations.write_sdf(insp_sdf) - - commands.append(f"load {insp_sdf.resolve()}") - commands.append( - f"set all_states, on, {insp_sdf.name.removesuffix('.sdf')}" - ) - commands.append( - f"util.rainbow \"{insp_sdf.name.removesuffix('.sdf')}\"" - ) - - # write the poses - pose_sdf = insp_dir / f"{insp_names}_derivatives.sdf" - poses.write_sdf(pose_sdf) - - commands.append(f"load {pose_sdf.resolve()}") - commands.append(f'util.cbaw "{pose_sdf.name.removesuffix(".sdf")}"') - - if j > 0: - commands.append(f"disable \"{insp_sdf.name.removesuffix('.sdf')}\"") - commands.append(f'disable "{pose_sdf.name.removesuffix(".sdf")}"') - - return "; ".join(commands) - - def to_knitwork( - self, out_path: str, path_root: str = ".", aligned_files_dir: str | None = None - ) -> None: - """Knitwork takes a CSV input with: - - - observation shortcode - - smiles - - path_to_ligand_mol - - path_to_pdb - - :param out_path: path to output CSV - :param path_root: paths in CSV will be relative to here - - """ - - from os.path import relpath - from pathlib import Path - - out_path = Path(out_path).resolve() - path_root = Path(path_root).resolve() - mrich.var("out_path", out_path) - mrich.var("path_root", path_root) - mrich.var("aligned_files_dir", aligned_files_dir) - - assert out_path.name.endswith(".csv") - - with open(out_path, "wt") as f: - - mrich.writing(out_path) - - for pose in self: - - assert pose.alias - assert "hits" in pose.tags - - if aligned_files_dir: - - mol = str(pose.mol_path) - pdb = str(pose.apo_path) - - assert "aligned_files" in mol - assert "aligned_files" in pdb - - mol = mol.split("aligned_files/")[-1] - pdb = pdb.split("aligned_files/")[-1] - - aligned_files_dir = Path(aligned_files_dir) - - mol = relpath(aligned_files_dir / mol, path_root) - pdb = relpath(aligned_files_dir / pdb, path_root) - - else: - mol = relpath(pose.mol_path, path_root) - pdb = relpath(pose.apo_path, path_root) - - data = [pose.alias, pose.compound.smiles, mol, pdb] - - f.write(",".join(data)) - f.write("\n") - - def to_syndirella( - self, out_key: "str | Path", separate: bool = False - ) -> "DataFrame": - """Create syndirella inputs""" - - from pathlib import Path - - out_key = Path(".") / out_key - - out_dir = out_key.parent - out_key = out_key.name - - mrich.var("out_key", out_key) - mrich.var("#poses", len(self)) - - out_dir.mkdir(parents=True, exist_ok=True) - - import shutil - from pandas import DataFrame - - ### Prepare Syndirella CSV data - - df = self.get_df( - inchikey=False, alias=False, reference_alias=True, inspiration_aliases=True - ) - df = df.rename(columns={"reference_alias": "template"}) - - # compound_set - - if separate: - df["compound_set"] = df.apply( - lambda row: f"{out_key}_{row['name']}", axis=1 - ) - - else: - df["compound_set"] = out_key - - # template - - null_template = df["template"].isnull() - if null_template.any(): - mrich.warning( - len(null_template), "poses have no reference. Setting to self" - ) - mrich.print(df.loc[null_template, "name"].values) - df["template"] = df["template"].fillna(df["name"]) - - # inspirations - - null_inspirations = df["inspiration_aliases"].apply(lambda x: not x) - - if null_inspirations.any(): - mrich.warning( - len(null_inspirations), "poses have no inspirations. Setting to self" - ) - mrich.print(df.loc[null_inspirations, "name"].values) - df.loc[null_inspirations, "inspiration_aliases"] = df.loc[ - null_inspirations - ].apply(lambda row: set([row["name"]]), axis=1) - - for i, row in df.iterrows(): - for j, inspiration in enumerate(row["inspiration_aliases"]): - df.loc[i, f"hit{j+1}"] = inspiration - - all_inspirations = set.union(*list(df["inspiration_aliases"].values)) - - df = df.drop(columns=["name", "inspiration_aliases"]) - - ### Copy Templates - - template_dir = out_dir / "templates" - mrich.writing(template_dir) - template_dir.mkdir(parents=True, exist_ok=True) - - templates = df["template"].unique() - - records = self.db.select_id_where( - table="pose", - key=f"pose_alias IN {str(tuple(templates)).replace(',)', ')')}", - multiple=True, - ) - - templates = PoseSet(self.db, [i for i, in records]) - - for ref in templates: - template = template_dir / ref.apo_path.name - if not template.exists(): - mrich.writing(template) - shutil.copy(ref.apo_path, template) - - ### Inspirations - - records = self.db.select_id_where( - table="pose", - key=f"pose_alias IN {str(tuple(all_inspirations)).replace(',)', ')')}", - multiple=True, - ) - - all_inspirations = PoseSet(self.db, [i for i, in records]) - - ### Write CSV - - if separate: - for i, row in df.iterrows(): - csv_name = out_dir / f"{row['compound_set']}_syndirella_input.csv" - mrich.writing(csv_name) - row.to_frame().T.to_csv(csv_name, index=False) - - else: - csv_name = out_dir / f"{out_key}_syndirella_input.csv" - mrich.writing(csv_name) - df.to_csv(csv_name, index=False) - - ### Write Inspirations - - sdf_name = out_dir / f"{out_key}_syndirella_inspiration_hits.sdf" - all_inspirations.write_sdf( - sdf_name, - tags=False, - metadata=False, - name_col="name", - ) - - return df - - ### OUTPUT - - def interactive( - self, - print_name: str = True, - method: str | None = None, - function: Callable | None = None, - **kwargs, - ): - """Interactive widget to navigate compounds in the table - - :param print_name: print the :class:`.Pose` name (Default value = True) - :param method: pass the name of a :class:`.Pose` method to interactively display. Keyword arguments to interactive() will be passed through (Default value = None) - :param function: pass a callable which will be called as `function(pose)` - - """ - - from ipywidgets import ( - interactive, - BoundedIntText, - Checkbox, - interactive_output, - HBox, - GridBox, - Layout, - VBox, - ) - from IPython.display import display - from pprint import pprint - - if method: - - def widget(i): - """Method widget""" - pose = self[i] - if print_name: - print(repr(pose)) - value = getattr(pose, method)(**kwargs) - if value: - display(value) - - return interactive( - widget, - i=BoundedIntText( - value=0, - min=0, - max=len(self) - 1, - step=1, - description="Pose:", - disabled=False, - ), - ) - - elif function: - - def widget(i): - """Function widget""" - pose = self[i] - if print_name: - display(pose) - function(pose) - - return interactive( - widget, - i=BoundedIntText( - value=0, - min=0, - max=len(self) - 1, - step=1, - description="Pose:", - disabled=False, - ), - ) - - else: - - a = BoundedIntText( - value=0, - min=0, - max=len(self) - 1, - step=1, - description=f"Pose (/{len(self)}):", - disabled=False, - ) - - b = Checkbox(description="Name", value=True) - c = Checkbox(description="Summary", value=False) - h = Checkbox(description="Tags", value=False) - i = Checkbox(description="Subsites", value=False) - d = Checkbox(description="2D (Comp.)", value=False) - e = Checkbox(description="2D (Pose)", value=False) - f = Checkbox(description="3D", value=True) - g = Checkbox(description="Metadata", value=False) - - ui1 = GridBox( - [b, c, d, h], - layout=Layout(grid_template_columns="repeat(4, 100px)"), - ) - ui2 = GridBox( - [e, f, g, i], - layout=Layout(grid_template_columns="repeat(4, 100px)"), - ) - ui = VBox([a, ui1, ui2]) - - def widget( - i, - name: bool = True, - summary: bool = True, - grid: bool = True, - draw2d: bool = True, - draw: bool = True, - tags: bool = True, - subsites: bool = True, - metadata: bool = True, - ): - """Default widget""" - pose = self[i] - if name: - print(repr(pose)) - - if summary: - pose.summary(metadata=False, tags=False, subsites=False) - if tags: - print(pose.tags) - if subsites: - print(pose.subsites) - if grid: - pose.grid() - if draw2d: - pose.draw2d() - if draw: - pose.draw() - if metadata: - mrich.title("Metadata:") - pprint(pose.metadata) - - out = interactive_output( - widget, - { - "i": a, - "name": b, - "summary": c, - "grid": d, - "draw2d": e, - "draw": f, - "metadata": g, - "tags": h, - "subsites": i, - }, - ) - - display(ui, out) - - def summary(self) -> None: - """Print a summary of this pose set""" - mrich.header("PoseSet()") - mrich.var("#poses", len(self)) - mrich.var("#compounds", self.num_compounds) - mrich.var("tags", self.tags) - - def draw(self) -> None: - """Render this pose set with Py3Dmol""" - - from molparse.rdkit import draw_mols - - mols = [p.mol for p in self] - - drawing = draw_mols(mols) - # display(drawing) - - def grid(self) -> None: - """Draw a grid of all contained molecules""" - from molparse.rdkit import draw_grid - - data = [(p.name, p.compound.mol) for p in self] - - mols = [d[1] for d in data] - labels = [d[0] for d in data] - - drawing = draw_grid(mols, labels=labels) - display(drawing) - - def subsite_summary(self) -> "pd.DataFrame": - """Print a table counting poses by subsite""" - - from pandas import DataFrame - - sql = f""" - SELECT subsite_id, subsite_name, COUNT(DISTINCT subsite_tag_pose) FROM subsite - INNER JOIN subsite_tag - ON subsite_id = subsite_tag_ref - WHERE subsite_tag_pose IN {self.str_ids} - GROUP BY subsite_name - """ - - cursor = self.db.execute(sql) - - df = DataFrame( - [dict(id=i, subsite=name, num_poses=count) for i, name, count in cursor] - ) - - df = df.set_index("id") - - df = df.sort_values(by="num_poses", ascending=False) - - mrich.print(df) - - return df - - ### PRIVATE - - def _delete(self, *, force: bool = False) -> None: - """Delete poses in this set""" - - if not force: - mrich.warning("Deleting Poses is risky! Set force=True to continue") - return - - str_ids = self.str_ids - - # delete the poses in this set - self.db.delete_where( - table=self.table, key=f"pose_id IN {str_ids}", commit=False - ) - - # check for other references to this pose - self.db.delete_where(table="tag", key=f"tag_pose IN {str_ids}", commit=False) - self.db.delete_where( - table="inspiration", - key=f"inspiration_original IN {str_ids}", - commit=False, - ) - self.db.delete_where( - table="inspiration", - key=f"inspiration_derivative IN {str_ids}", - commit=False, - ) - self.db.delete_where( - table="subsite_tag", - key=f"subsite_tag_pose IN {str_ids}", - commit=False, - ) - self.db.delete_where( - table="interaction", - key=f"interaction_pose IN {str_ids}", - commit=False, - ) - - self.db.execute( - f""" - UPDATE pose - SET pose_reference = NULL - WHERE pose_id IN {str_ids} - """ - ) - - self.db.commit() - - ### DUNDERS - - def __str__(self): - """Unformatted string representation""" - if self.name: - s = f"{self.name}: " - else: - s = "" - - s += "{" f"P × {len(self)}" "}" - - return s - - def __repr__(self) -> str: - """ANSI Formatted string representation""" - return f"{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}" - - def __rich__(self) -> str: - """Rich Formatted string representation""" - return f"[bold underline]{self}" - - def __len__(self) -> int: - """The number of poses in this set""" - return len(self.indices) - - def __iter__(self): - """Iterate through poses in this set""" - return iter(self.db.get_pose(id=i) for i in self.indices) - - def __getitem__( - self, - key: int | slice, - ) -> "Pose | PoseSet": - """Get poses or subsets thereof from this set - - :param key: integer index or slice of indices - - """ - match key: - - case int(): - try: - index = self.indices[key] - except IndexError: - mrich.error(f"list index out of range: {key=} for {self}") - raise - return self.db.get_pose(id=index) - - case slice(): - ids = self.indices[key] - return PoseSet(self.db, ids) - - case _: - raise NotImplementedError - - def __add__( - self, - other: "PoseSet", - ) -> "PoseSet": - """Add a :class:`.PoseSet` to this set""" - if isinstance(other, PoseSet): - return PoseSet(self.db, self.ids + other.ids, sort=False) - elif isinstance(other, Pose): - return PoseSet(self.db, self.ids + [other.id], sort=False) - else: - raise NotImplementedError - - def __sub__( - self, - other: "PoseSet", - ) -> "PoseSet": - """Substract a :class:`.PoseSet` from this set""" - match other: - case PoseSet(): - ids = set(self.ids) - set(other.ids) - return PoseSet(self.db, ids, sort=False) - case int(): - # assert other in set(self.ids) - return PoseSet(self.db, [i for i in self.ids if i != other], sort=False) - - def __and__(self, other: "PoseSet"): - """AND set operation, returns only poses in both sets""" - - match other: - - case PoseSet(): - ids = set(self.ids) & set(other.ids) - return PoseSet(self.db, ids) - - case _: - raise NotImplementedError - - def __or__(self, other: "PoseSet"): - """OR set operation, returns union of both sets""" - - match other: - - case PoseSet(): - ids = set(self.ids) | set(other.ids) - return PoseSet(self.db, ids) - - case _: - raise NotImplementedError - - def __xor__(self, other: "PoseSet"): - """Exclusive OR set operation, returns all poses in either set but not both""" - - match other: - - case PoseSet(): - ids = set(self.ids) ^ set(other.ids) - return PoseSet(self.db, ids) - - case _: - raise NotImplementedError - - def __call__( - self, - *, - tag: str = None, - target: int = None, - subsite: int = None, - ) -> "PoseSet": - """Filter poses by a given tag, Subsite ID, or target ID. See :meth:`.PoseSet.get_by_tag`, :meth:`.PoseSet.get_by_target`, amd :meth:`.PoseSet.get_by_subsite`""" - - if tag: - return self.get_by_tag(tag) - elif target: - return self.get_by_target(id=target) - elif subsite: - return self.get_by_subsite(id=subsite) - else: - raise NotImplementedError diff --git a/hippo/pyvis.py b/hippo/pyvis.py deleted file mode 100644 index b2eb2ff6..00000000 --- a/hippo/pyvis.py +++ /dev/null @@ -1,169 +0,0 @@ -"""Functions to visualise networks with pyvis""" - -import mrich - - -def get_scaffold_network( - animal, - compounds="CompoundSet | None", - scaffolds="CompoundSet | None", - # filename: "str | Path" = "network.html", - notebook: bool = True, - depth: int = 5, - scaffold_tag: str | None = None, - exclude_tag: str | None = None, - physics: bool = True, - arrows: bool = True, -) -> "pyvis.network.Network": - """Use PyVis to display a network of molecules connected by scaffold relationships in the database""" - - from pyvis.network import Network - from molparse.rdkit import smiles_to_pngstr - - net = Network(notebook=notebook, cdn_resources="in_line") - - nodes = set() - edges = set() - - arrows = "to" if arrows else None - - def add_node(compound: "Compound") -> None: - """Add node to network""" - - if compound.id in nodes: - return - - pngstr = smiles_to_pngstr(compound.smiles) - - net.add_node( - compound.id, - label=compound.alias or str(compound), - title=str(compound), - shape="circularImage", - image=f"data:image/png;base64,{pngstr}", - physics=physics, - ) - - nodes.add(compound.id) - - def add_edge(scaffold: "Compound", compound: "Compound") -> None: - """Add edge to network""" - - key = (scaffold.id, compound.id) - - if key in edges: - return - - net.add_edge( - scaffold.id, - compound.id, - arrows=arrows, - # label="PDE5", - # title="Protein target", - # color="purple" - ) - - edges.add(key) - - def get_scaffold_records( - scaffolds: "None | CompoundSet" = None, compounds: "None | CompoundSet" = None - ): - """Get scaffold records""" - - if scaffolds: - return animal.db.select_all_where( - table="scaffold", - key=f"scaffold_base IN {scaffolds.str_ids}", - multiple=True, - none="quiet", - ) - elif compounds: - return animal.db.select_all_where( - table="scaffold", - key=f"scaffold_superstructure IN {compounds.str_ids}", - multiple=True, - none="quiet", - ) - raise ValueError - - if compounds and not scaffolds: - - mrich.var("recursion depth", depth) - mrich.var("#compounds", len(compounds)) - - records = [] - n = 0 - while n < depth: - - if not compounds: - break - - results = get_scaffold_records(compounds=compounds) - - if results: - records.extend(results) - compounds = animal.compounds[[a for a, b in results]] - n += 1 - else: - break - - if n == depth: - mrich.warning( - "Reached recursion depth. More scaffolds may be in the database" - ) - - elif scaffolds and not compounds: - - mrich.var("recursion depth", depth) - mrich.var("#scaffolds", len(scaffolds)) - - records = [] - n = 0 - while n < depth: - - if not scaffolds: - break - - results = get_scaffold_records(scaffolds=scaffolds) - - if results: - records.extend(results) - scaffolds = animal.compounds[[b for a, b in results]] - n += 1 - else: - break - - if n == depth: - mrich.warning( - "Reached recursion depth. More superstructures may be in the database" - ) - - else: - raise ValueError - - mrich.var("#edges", len(records)) - - if records: - for scaffold_id, compound_id in mrich.track( - records, prefix="Adding nodes and edges" - ): - - scaffold = animal.db.get_compound(id=scaffold_id) - - scaffold_tags = scaffold.tags - - if scaffold_tag and scaffold_tag not in scaffold_tags: - continue - - compound = animal.db.get_compound(id=compound_id) - - if exclude_tag and ( - exclude_tag in scaffold_tags or exclude_tag in compound.tags - ): - continue - - add_node(scaffold) - add_node(compound) - add_edge(scaffold, compound) - - return net diff --git a/hippo/quote.py b/hippo/quote.py deleted file mode 100644 index b71ba2c5..00000000 --- a/hippo/quote.py +++ /dev/null @@ -1,229 +0,0 @@ -"""Classes to work with quote data""" - -import mcol -import mrich - -from .price import Price - - -class Quote: - """Supplier quote for a specific quantity of a :class:`.Compound`. - - .. attention:: - - :class:`.Quote` objects should not be created directly. Instead use :meth:`.Compound.get_quotes`. - - """ - - _db = None - - def __init__( - self, - db: "Database", - id: int, - compound: int, - smiles: str, - supplier: str, - catalogue: str, - entry: str, - amount: float, - price: float, - currency: str, - purity: float, - lead_time: int, - date: str | None = None, - type: str | None = None, - ) -> None: - """Quote initialisation""" - - price = Price(price, currency) - - self._db = db - self._id = id - self._compound = compound - self._smiles = smiles - self._supplier = supplier - self._catalogue = catalogue - self._entry = entry - self._amount = amount - self._price = price - self._purity = purity - self._lead_time = lead_time - self._date = date - self._type = type - - from datetime import datetime - - quote_age = (datetime.today() - datetime.strptime(self.date, "%Y-%m-%d")).days - # if quote_age > 30: - # mrich.warning(f'Quote is {quote_age} days old') - # mrich.warning(self) - - ### FACTORIES - - @classmethod - def combination( - cls, - required_amount: float, - quotes: list["Quote"], - ) -> "Quote": - """Combine a list of quotes into one :class:`.Quote` object. - - * Start with biggest pack - * Estimate by scaling linearly with unit price - - :param required_amount: amount in mg - :param quotes: list of quotes to be combined - - """ - - biggest_pack = sorted(quotes, key=lambda x: x.amount)[-1] - unit_price = biggest_pack.price / biggest_pack.amount - estimated_price = unit_price * required_amount - - self = cls.__new__(cls) - self.__init__( - db=biggest_pack.db, - id=None, - compound=biggest_pack.compound, - smiles=biggest_pack.smiles, - supplier=biggest_pack.supplier, - catalogue=biggest_pack.catalogue, - entry=biggest_pack.entry, - amount=required_amount, - price=estimated_price.amount, - currency=biggest_pack.currency, - purity=biggest_pack.purity, - lead_time=biggest_pack.lead_time, - date=biggest_pack.date, - type=f"estimate from quote={biggest_pack.id}", - ) - - return self - - ### PROPERTIES - - @property - def entry_str(self) -> str: - """Unformatted string including the supplier, catalogue (if available), and entry name of the quote""" - if self.catalogue: - return f"{self.supplier}:{self.catalogue}:{self.entry}" - else: - return f"{self.supplier}:{self.entry}" - - @property - def db(self) -> "Database": - """Returns a pointer to the parent database""" - return self._db - - @property - def id(self) -> int: - """Returns the quote's database ID""" - return self._id - - @property - def compound(self) -> int: - """Returns the associated :class:`.Compound`""" - return self._compound - - @property - def smiles(self) -> str: - """Returns the catalogue SMILES string""" - return self._smiles - - @property - def supplier(self) -> str: - """Name of the supplier""" - return self._supplier - - @property - def catalogue(self) -> str | None: - """Name of the catalogue""" - return self._catalogue - - @property - def entry(self) -> str: - """Name/ID of the catalogue entry""" - return self._entry - - @property - def amount(self) -> float: - """Amount in mg""" - return self._amount - - @property - def price(self) -> "Price": - """Price""" - return self._price - - @property - def currency(self) -> str: - """Currency of the associated :class:`.Price` object""" - return self.price.currency - - @property - def purity(self) -> float: - """Purity fraction""" - return self._purity - - @property - def lead_time(self) -> float: - """Lead time in days""" - return self._lead_time - - @property - def date(self) -> str: - """Date the quote was registered to the database""" - return self._date - - @property - def type(self) -> str: - """Description of this quote""" - return self._type - - @property - def dict(self) -> dict: - """Dictionary representation of this quote""" - return dict( - id=self.id, - compound=self.compound, - smiles=self.smiles, - supplier=self.supplier, - catalogue=self.catalogue, - entry=self.entry, - amount=self.amount, - price=self.price, - purity=self.purity, - lead_time=self.lead_time, - date=self.date, - type=self.type, - ) - - @property - def currency_symbol(self) -> str: - """Currency symbol of the associated :class:`.Price`""" - return self.price.symbol - - ### DUNDERS - - def __str__(self): - """Unformatted string representation""" - if self.purity: - purity = f" @ {self.purity:.0%}" - else: - purity = "" - - if self.supplier == "Stock": - return f"C{self.compound} In Stock: {self.amount:}mg{purity}" - elif self.type: - return f"C{self.compound} {self.entry_str} {self.amount:}mg{purity} = {self.price:} ({self.lead_time} days) {self.smiles} [{self.type}]" - else: - return f"C{self.compound} {self.entry_str} {self.amount:}mg{purity} = {self.price:} ({self.lead_time} days) {self.smiles}" - - def __repr__(self) -> str: - """ANSI Formatted string representation""" - return f"{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}" - - def __rich__(self) -> str: - """Rich Formatted string representation""" - return f"[bold underline]{self}" diff --git a/hippo/reaction.py b/hippo/reaction.py deleted file mode 100644 index 04bc1eb3..00000000 --- a/hippo/reaction.py +++ /dev/null @@ -1,429 +0,0 @@ -"""Classes to work with Reaction objects""" - -import mcol -import mrich - -from .recipe import Recipe -from .compound import Compound - - -class Reaction: - """ - A :class:`.Reaction` is a simplified representation of a synthetic pathway to create a product :class:`.Compound`. Reactants (also :class:`.Compound` objects) as well as a reaction type are required. - - .. attention:: - - :class:`.Reaction` objects should not be created directly. Instead use :meth:`.HIPPO.register_reaction` or :meth:`.HIPPO.reactions` - - """ - - _table = "reaction" - - def __init__( - self, - db: "Database", - id: int, - type: str, - product: int, - product_yield: float, - ) -> None: - """Reaction initialisation""" - - self._db = db - self._id = id - self._type = type - self._product_id = product - self._product = None - self._product_yield = product_yield - self._metadata = None - - ### PROPERTIES - - @property - def id(self) -> int: - """Returns the :class:`.Reaction` ID""" - return self._id - - @property - def type(self) -> str: - """Returns the :class:`.Reaction` tyoe""" - return self._type - - @property - def product(self) -> "Compound": - """Returns the reaction's product :class:`.Compound`""" - if self._product is None: - self._product = self.db.get_compound(id=self.product_id) - return self._product - - @property - def product_yield(self) -> float: - """Returns the reaction's product yield (fraction)""" - return self._product_yield - - @property - def db(self) -> "Database": - """Returns a pointer to the parent database""" - return self._db - - @property - def reactants(self) -> "CompoundSet": - """Returns a :class:`.CompoundSet` of the reactants""" - from .cset import CompoundSet - - return CompoundSet(self.db, indices=self.reactant_ids) - - @property - def reaction_str(self) -> str: - """Returns a string representing the reaction""" - s = " + ".join([str(r) for r in self.reactants]) - s = f"{s} -> {str(self.product)}" - return s - - @property - def reactant_ids(self) -> set[int]: - """Returns a set of reactant ID's""" - return set(v for v in self.get_reactant_ids()) - - @property - def reactant_str_ids(self) -> str: - """Return an SQL formatted tuple string of the reactant :class:`.Compound` IDs""" - return str(tuple(self.reactant_ids)).replace(",)", ")") - - @property - def product_id(self) -> int: - """Returns the product :class:`.Compound` ID""" - return self._product_id - - @property - def product_smiles(self) -> str: - """Product :class:`.Compound` SMILES string""" - return self.product.smiles - - @property - def reactant_smiles(self) -> list[str]: - """List of reactant :class:`.Compound` SMILES strings""" - return [r.smiles for r in self.reactants] - - @property - def product_mol(self): - """Product :class:`.Compound` ``rdkit.Chem.Mol`` object""" - return self.product.mol - - @property - def reactant_mols(self): - """List of reactant :class:`.Compound` ``rdkit.Chem.Mol`` object""" - return [r.mol for r in self.reactants] - - @property - def price_estimate(self) -> float: - """Estimate the price of this :class:`.Reaction`""" - return self.db.get_reaction_price_estimate(reaction=self) - - @property - def plain_repr(self) -> str: - """Unformatted long string representation""" - return f"{self}: {self.reaction_str} via {self.type}" - - @property - def metadata(self) -> "MetaData": - """Returns the compound's metadata dict""" - if self._metadata is None: - self._metadata = self.db.get_metadata(table="reaction", id=self.id) - return self._metadata - - ### METHODS - - def get_reactant_amount_pairs(self, compound_object: bool = True) -> list[tuple]: - """Returns pairs of reactants and their amounts - - :param compound_object: return :class:`.Compound` object instead of ID, (Default value = True) - :returns: list of tuples containing :class:`.Compound` ID/object and amount in mg - """ - - compound_ids = self.db.select_where( - query="reactant_compound, reactant_amount", - table="reactant", - key="reaction", - value=self.id, - multiple=True, - ) - - if compound_ids: - if compound_object: - return [ - # (self.db.get_compound(id=id), amount/self.product_yield) for id, amount in compound_ids - (self.db.get_compound(id=id), amount) - for id, amount in compound_ids - ] - else: - return compound_ids - else: - return [] - - def get_reactant_ids(self) -> list[int]: - """Returns list of reactants :class:`.Compound` IDs - - :returns: list of :class:`.Compound` IDs - """ - - compound_ids = self.db.select_where( - query="reactant_compound", - table="reactant", - key="reaction", - value=self.id, - multiple=True, - ) - - if compound_ids: - return [id for id, in compound_ids] - else: - return [] - - def get_recipes( - self, - amount: float = 1, # in mg - debug: bool = False, - pick_cheapest: bool = False, - permitted_reactions: "None | ReactionSet" = None, - supplier: str | None = None, - ) -> "Recipe | list[Recipe]": - """Get a :class:`.Recipe` describing how to make the product - - :param amount: Amount in ``mg``, defaults to ``1`` - :param debug: Increase verbosity, (Default value = False) - :param pick_cheapest: pick the cheapest :class:`.Recipe`, (Default value = False) - :param permitted_reactions: Limit the reactions to consider to members of this set, (Default value = None) - :param supplier: Limit to reactants from this supplier (Default value = None) - :returns: :class:`.Recipe` object or list thereof - - """ - - from .recipe import Recipe - - return Recipe.from_reaction( - self, - amount=amount, - debug=debug, - pick_cheapest=pick_cheapest, - permitted_reactions=permitted_reactions, - supplier=supplier, - ) - - def summary( - self, - draw: bool = True, - ) -> None: - """Print a summary of this reaction's information - - :param draw: draw the reaction compounds (Default value = True) - - """ - - print(f"id={self.id}") - print(f"type={self.type}") - print(f"product={self.product}") - print(f"product_yield={self.product_yield}") - - reactants = self.get_reactant_amount_pairs() - print(f"reactants={reactants}") - - print(f"price_estimate={self.price_estimate}") - - if draw: - self.draw() - - def draw(self) -> None: - """Draw the molecules involved in this reaction""" - - from molparse.rdkit import draw_grid - - reactants = self.reactants - - product = self.product - - mols = [r.mol for r in reactants] - mols.append(product.mol) - - labels = [f"+ {r}" if i > 0 else f"{r}" for i, r in enumerate(reactants)] - labels.append(f"-> {product}") - - drawing = draw_grid(mols, labels=labels, highlightAtomLists=None) - display(drawing) - - def check_chemistry( - self, - debug: bool = False, - ) -> bool: - """Sanity check the chemistry of this reaction - - :param debug: increase verbosity (Default value = False) - """ - from .chem import check_chemistry - - return check_chemistry(self.type, self.reactants, self.product, debug=debug) - - def check_reactant_availability( - self, - supplier: None | str = None, - debug: bool = False, - ) -> bool: - """Check the availability of reactant compounds - - :param supplier: Limit to quotes from this supplier (Default value = None) - :param debug: increase verbosity (Default value = False) - - """ - - if debug: - mrich.var("reaction", self.id) - mrich.var("reactants", self.reactant_ids) - mrich.var("supplier", supplier) - - if supplier is None: - - triples = self.db.execute( - f""" - SELECT reactant_compound, SUM(quote_id), SUM(reaction_id) FROM reactant - LEFT JOIN quote ON quote_compound = reactant_compound - LEFT JOIN reaction ON reaction_product = reactant_compound - WHERE reactant_reaction = {self.id} - GROUP BY reactant_compound - """ - ).fetchall() - - else: - - triples = self.db.execute( - f""" - WITH filtered_quotes AS - ( - SELECT * FROM quote - WHERE quote_supplier = "{supplier}" - ) - SELECT reactant_compound, SUM(quote_id), SUM(reaction_id) FROM reactant - LEFT JOIN filtered_quotes ON quote_compound = reactant_compound - LEFT JOIN reaction ON reaction_product = reactant_compound - WHERE reactant_reaction = {self.id} - GROUP BY reactant_compound - """ - ).fetchall() - - for reactant_compound, has_quote, has_reaction in triples: - - if debug: - mrich.debug( - f"{reactant_compound=}, {bool(has_quote)=}, {bool(has_reaction)=}" - ) - - if has_quote: - if debug: - mrich.debug(f"reactant={reactant_compound} has quote") - continue - - if has_reaction: - if debug: - mrich.debug(f"reactant={reactant_compound} has reaction") - continue - - if debug: - mrich.warning(f"No quote or reaction for reactant={reactant_compound}") - - return False - - return True - - def get_dict( - self, - smiles: bool = True, - mols: bool = True, - ) -> dict[str]: - """Returns a dictionary representing this :class:`.Reaction` - - :param smiles: include smiles string (Default value = True) - :param mols: include ``rdkit.Chem.Mol`` (Default value = True) - - """ - - serialisable_fields = ["id", "type", "product_id", "reactant_ids"] - - data = {} - for key in serialisable_fields: - data[key] = getattr(self, key) - - if smiles: - data["product_smiles"] = self.product_smiles - data["reactant_smiles"] = self.reactant_smiles - - if mols: - data["product_mol"] = self.product_mol - data["reactant_mols"] = self.reactant_mols - - return data - - def _delete(self) -> None: - """Delete this reaction and any related reactants, routes, and components""" - - route_ids = self.db.select_where( - query="component_route", - table="component", - key=f"component_ref = {self.id} AND component_type = 1", - multiple=True, - ) - - route_ids = [r for r, in route_ids] - route_str_ids = str(tuple(route_ids)).replace(",)", ")") - - self.db.delete_where( - table="component", key=f"component_route IN {route_str_ids}" - ) - - self.db.delete_where(table="route", key=f"route_id IN {route_str_ids}") - - self.db.delete_where(table="reactant", key="reaction", value=self.id) - - self.db.delete_where(table="reaction", key="id", value=self.id) - - ### DUNDERS - - def __str__(self) -> str: - """Unformatted string representation""" - return f"R{self.id}" - - def __repr__(self) -> str: - """ANSI Formatted string representation""" - return f"{mcol.bold}{mcol.underline}{self.plain_repr}{mcol.unbold}{mcol.ununderline}" - - def __rich__(self) -> str: - """Rich Formatted string representation""" - return f"[bold underline]{self.plain_repr}" - - def __eq__( - self, - other: "int | Reaction", - ) -> bool: - """compare this reaction to a :class:`.Reaction` object or ID""" - - match other: - case int(): - return self.id == other - - case Reaction(): - - if self.type != other.type: - return False - - if self.product != other.product: - return False - - if self.reactant_ids != other.reactant_ids: - return False - - return True - - case _: - raise NotImplementedError - - def __hash__(self) -> int: - """Integer hash from ID""" - return self.id diff --git a/hippo/recipe.py b/hippo/recipe.py deleted file mode 100644 index 9a0ff986..00000000 --- a/hippo/recipe.py +++ /dev/null @@ -1,3019 +0,0 @@ -"""Classes for working with Recipes (reaction networks)""" - -import mcol -import mrich - -from dataclasses import dataclass, field - -from .compound import Ingredient - - -class Recipe: - """A Recipe stores data corresponding to a specific synthetic recipe involving several products, reactants, intermediates, and reactions.""" - - _db = None - - def __init__( - self, - db: "Database", - *, - products: "IngredientSet | None" = None, - reactants: "IngredientSet | None" = None, - intermediates: "IngredientSet | None" = None, - reactions: "ReactionSet | None" = None, - compounds: "IngredientSet | None" = None, - ) -> None: - """Recipe initialisation""" - - from .cset import IngredientSet - from .rset import ReactionSet - - if products is None: - products = IngredientSet(db) - - if reactants is None: - reactants = IngredientSet(db) - - if intermediates is None: - intermediates = IngredientSet(db) - - if compounds is None: - compounds = IngredientSet(db) - - if reactions is None: - reactions = ReactionSet(db) - - # check typing - assert isinstance(products, IngredientSet) - assert isinstance(reactants, IngredientSet) - assert isinstance(intermediates, IngredientSet) - assert isinstance(compounds, IngredientSet) - assert isinstance(reactions, ReactionSet) - - self._products = products - self._reactants = reactants - self._intermediates = intermediates - self._reactions = reactions - self._compounds = compounds - self._db = db - self._hash = None - - self._score = None - - # caches - self._product_compounds = None - self._poses = None - self._interactions = None - self._combined_compounds = None - - ### FACTORIES - - @classmethod - def from_reaction( - cls, - reaction, - amount=1, - *, - debug: bool = False, - pick_cheapest: bool = True, - permitted_reactions: "ReactionSet | None" = None, - quoted_only: bool = False, - supplier: None | str = None, - unavailable_reaction: str = "error", - reaction_checking_cache: dict[int, bool] = None, - reaction_reactant_cache: dict[int, bool] = None, - inner: bool = False, - get_ingredient_quotes: bool = True, - ) -> "Recipe | list[Recipe]": - """Create a :class:`.Recipe` from a :class:`.Reaction` and its upstream dependencies - - :param reaction: reaction to create recipe from - :param amount: amount in ``mg`` (Default value = 1) - :param debug: bool: increase verbosity for debugging (Default value = False) - :param pick_cheapest: bool: choose the cheapest solution (Default value = True) - :param permitted_reactions: once consider reactions in this set (Default value = None) - :param quoted_only: bool: only allow reactants with quotes (Default value = False) - :param supplier: None | str: optionally restrict quotes to only this supplier (Default value = None) - :param unavailable_reaction: define the behaviour for when a reaction has unavailable reactants (Default value = 'error') - :param inner: used to indicate that this is a recursive call (Default value = False) - :param get_ingredient_quotes: get quotes for ingredients in this recipe - - """ - - from .reaction import Reaction - - assert isinstance(reaction, Reaction) - - from .cset import IngredientSet - from .rset import ReactionSet - - if debug: - mrich.debug( - f"Recipe.from_reaction(R{reaction.id}, {amount=}, {pick_cheapest=})" - ) - mrich.debug(f"{reaction.product.id=}") - mrich.debug(f"{reaction.reactants.ids=}") - - if permitted_reactions: - assert reaction in permitted_reactions - # raise NotImplementedError - - db = reaction.db - - recipe = cls.__new__(cls) - recipe.__init__( - db, - products=IngredientSet( - db, - [ - reaction.product.as_ingredient( - amount=amount, get_quote=get_ingredient_quotes - ) - ], - ), - reactants=IngredientSet(db, [], supplier=supplier), - intermediates=IngredientSet(db, []), - reactions=ReactionSet(db, [reaction.id], sort=False), - ) - - recipes = [recipe] - - if quoted_only or supplier: - if debug: - mrich.debug(f"Checking reactant_availability: {reaction=}") - if reaction_checking_cache and reaction.id in reaction_checking_cache: - ok = reaction_checking_cache[reaction.id] - print("reaction_checking_cache used") - else: - ok = reaction.check_reactant_availability(supplier=supplier) - # print('cache not used') - if reaction_checking_cache is not None: - reaction_checking_cache[reaction.id] = ok - if not ok: - if unavailable_reaction == "error": - mrich.error(f"Reactants not available for {reaction=}") - if pick_cheapest: - return None - else: - return [] - - def get_reactant_amount_pairs(reaction: "Reaction") -> list[tuple[int, float]]: - """Get pairs of reactant ID and float amounts""" - if reaction_reactant_cache and reaction.id in reaction_reactant_cache: - print("reaction_reactant_cache used") - return reaction_reactant_cache[reaction.id] - else: - pairs = reaction.get_reactant_amount_pairs(compound_object=False) - if reaction_reactant_cache is not None: - reaction_reactant_cache[reaction.id] = pairs - return pairs - - if debug: - mrich.debug(f"get_reactant_amount_pairs({reaction.id})") - pairs = get_reactant_amount_pairs(reaction) - - for reactant, reactant_amount in pairs: - - reactant = db.get_compound(id=reactant) - - if debug: - mrich.debug(f"{reactant.id=}, {reactant_amount=}") - - # scale amount - reactant_amount *= amount - reactant_amount /= reaction.product_yield - - inner_reactions = reactant.get_reactions( - none="quiet", permitted_reactions=permitted_reactions - ) - - if inner_reactions: - - if debug: - if len(inner_reactions) == 1: - mrich.debug(f"Reactant has ONE inner reaction") - else: - mrich.warning(f"{reactant=} has MULTIPLE inner reactions") - - new_recipes = [] - - inner_recipes = [] - for reaction in inner_reactions: - reaction_recipes = Recipe.from_reaction( - reaction=reaction, - amount=reactant_amount, - debug=debug, - pick_cheapest=False, - quoted_only=quoted_only, - supplier=supplier, - unavailable_reaction=unavailable_reaction, - reaction_checking_cache=reaction_checking_cache, - reaction_reactant_cache=reaction_reactant_cache, - inner=True, - ) - inner_recipes += reaction_recipes - - for recipe in recipes: - - for inner_recipe in inner_recipes: - - combined_recipe = recipe.copy() - - combined_recipe.reactants += inner_recipe.reactants - combined_recipe.intermediates += inner_recipe.intermediates - combined_recipe.reactions += inner_recipe.reactions - combined_recipe.intermediates.add( - reactant.as_ingredient(reactant_amount, supplier=supplier) - ) - - new_recipes.append(combined_recipe) - - recipes = new_recipes - - else: - - ingredient = reactant.as_ingredient(reactant_amount, supplier=supplier) - for recipe in recipes: - recipe.reactants.add(ingredient) - - # reverse ReactionSet's - if not inner: - for recipe in recipes: - recipe.reactions.reverse() - - if pick_cheapest: - if debug: - mrich.debug("Picking cheapest") - priced = [r for r in recipes if r.get_price(supplier=supplier)] - # priced = [r for r in recipes if r.price] - if not priced: - mrich.error("0 recipes with prices, can't choose cheapest") - return recipes - sorted_recipes = sorted( - priced, key=lambda r: r.get_price(supplier=supplier) - ) - - if debug: - for recipe in recipes: - mrich.debug(f"{recipe}, {recipe.price}") - - return sorted_recipes[0] - # return sorted(priced, key=lambda r: r.price)[0] - - return recipes - - @classmethod - def from_reactions( - cls, - reactions: "ReactionSet", - amount: float = 1, - pick_cheapest: bool = True, - permitted_reactions: "ReactionSet | None" = None, - final_products_only: bool = True, - return_products: bool = False, - supplier: str | None = None, - use_routes: bool = False, - debug: bool = False, - **kwargs, - ) -> "Recipe | list[Recipe] | CompoundSet": - """Create a :class:`.Recipe` from a :class:`.ReactionSet` and its upstream dependencies - - :param reactions: reactions to create recipe from - :param amount: amount in ``mg`` (Default value = 1) - :param debug: bool: increase verbosity for debugging (Default value = False) - :param pick_cheapest: bool: choose the cheapest solution (Default value = True) - :param permitted_reactions: once consider reactions in this set (Default value = None) - :param final_products_only: don't get routes to intermediates (Default value = True) - :param return_products: return the :class:`.CompoundSet` of products instead (Default value = False) - - """ - - from .rset import ReactionSet - from .cset import IngredientSet, CompoundSet - - assert isinstance(reactions, ReactionSet) - - db = reactions.db - - if debug: - mrich.debug("Recipe.from_reactions()") - mrich.var("reactions", reactions) - mrich.var("amount", amount) - mrich.var("final_products_only", final_products_only) - mrich.var("permitted_reactions", permitted_reactions) - - # get all the products - products = reactions.products - - if debug: - mrich.var("products", products) - - # return products - - if final_products_only: - - if debug: - mrich.var("products.str_ids", products.str_ids) - - # raise NotImplementedError - ids = reactions.db.execute( - f""" - SELECT DISTINCT compound_id FROM compound - LEFT JOIN reactant ON compound_id = reactant_compound - WHERE reactant_compound IS NULL - AND compound_id IN {products.str_ids} - """ - ).fetchall() - - ids = [i for i, in ids] - - products = CompoundSet(db, ids) - if debug: - mrich.var("final products", products) - - # return ids - - if return_products: - return products - - recipe = Recipe.from_compounds( - compounds=products, - amount=amount, - permitted_reactions=reactions, - pick_cheapest=pick_cheapest, - supplier=supplier, - use_routes=use_routes, - **kwargs, - ) - - return recipe - - @classmethod - def from_compounds( - cls, - compounds: "CompoundSet", - amount: float = 1, - debug: bool = False, - pick_cheapest: bool = True, - permitted_reactions=None, - quoted_only: bool = False, - supplier: None | str = None, - solve_combinations: bool = True, - pick_first: bool = False, - warn_multiple_solutions: bool = True, - pick_cheapest_inner_routes: bool = False, - unavailable_reaction: str = "error", - reaction_checking_cache: dict[int, bool] | None = None, - reaction_reactant_cache: dict[int, bool] | None = None, - use_routes: bool = False, - **kwargs, - ): - """Create recipe(s) to synthesis products in the :class:`.CompoundSet` - - :param compounds: set of compounds to find routes for - :param solve_combinations: bool: combinatorially combine all individual routes (Default value = True) - :param pick_first: return the first solution without comparison (Default value = False) - :param warn_multiple_solutions: warn if a compound has multiple routes (Default value = True) - :param pick_cheapest_inner_routes: for each compound choose the cheapest route (Default value = False) - :param reaction: reaction to create recipe from - :param amount: amount in ``mg`` (Default value = 1) - :param debug: bool: increase verbosity for debugging (Default value = False) - :param pick_cheapest: bool: choose the cheapest solution (Default value = True) - :param permitted_reactions: once consider reactions in this set (Default value = None) - :param quoted_only: bool: only allow reactants with quotes (Default value = False) - :param supplier: None | str: optionally restrict quotes to only this supplier (Default value = None) - :param unavailable_reaction: define the behaviour for when a reaction has unavailable reactants (Default value = 'error') - - """ - - from .cset import CompoundSet - - assert isinstance(compounds, CompoundSet) - - db = compounds.db - - n_comps = len(compounds) - - assert n_comps - - if not hasattr(amount, "__iter__"): - amount = [amount] * n_comps - - if use_routes: - route_lookup = db.get_product_id_routes_dict() - - if supplier: - raise NotImplementedError - # supplier_lookup = db.get_compound_id_suppliers_dict() - - options = [] - - ok = 0 - mrich.var("#compounds", n_comps) - - for comp, a in mrich.track( - zip(compounds, amount), - prefix="Solving individual compound recipes...", - total=n_comps, - ): - comp_options = [] - - if use_routes: - - if comp.id not in route_lookup: - mrich.error("No routes to", comp) - continue - - comp_options = [] - for route_id in route_lookup[comp.id]: - route = db.get_route(id=route_id) - comp_options.append(route) - - else: - - for reaction in comp.reactions: - - if permitted_reactions and reaction not in permitted_reactions: - continue - - sol = Recipe.from_reaction( - reaction=reaction, - amount=a, - pick_cheapest=pick_cheapest_inner_routes, - debug=debug, - permitted_reactions=permitted_reactions, - quoted_only=quoted_only, - supplier=supplier, - unavailable_reaction=unavailable_reaction, - reaction_checking_cache=reaction_checking_cache, - reaction_reactant_cache=reaction_reactant_cache, - **kwargs, - ) - - if pick_cheapest_inner_routes: - if sol: - comp_options.append(sol) - else: - assert isinstance(sol, list) - comp_options += sol - - if not comp_options: - mrich.error( - f"No solutions for compound={comp} ({comp.reactions.ids=})" - ) - continue - - if pick_cheapest and len(comp_options) > 1: - if warn_multiple_solutions: - mrich.warning( - f"Multiple solutions for", comp, "(", len(comp_options), ")" - ) - if debug: - mrich.debug("Picking cheapest...") - priced = [r for r in comp_options if r.price] - comp_options = sorted(priced, key=lambda r: r.price)[:1] - - if warn_multiple_solutions and len(comp_options) > 1: - mrich.warning(f"Multiple solutions for compound={comp}") - if debug: - mrich.debug(f"{comp_options=}") - else: - if n_comps <= 200: - mrich.success(f"Found solution for compound={comp}") - ok += 1 - mrich.set_progress_field("ok", ok) - mrich.set_progress_field("n", n_comps) - - options.append(comp_options) - - assert all(options) - - from itertools import product - - mrich.print("Solving recipe combinations...") - combinations = list(product(*options)) - - if not solve_combinations: - return combinations - - solutions = [] - - if n_comps > 1: - generator = mrich.track( - combinations, prefix="Combining recipes...", total=len(combinations) - ) - else: - generator = combinations - - ok = 0 - for combo in generator: - - if debug: - mrich.debug(f"Combination of {len(combo)} recipes") - - if not combo: - continue - - solution = combo[0] - - for i, recipe in enumerate(combo[1:]): - if debug: - mrich.debug(i + 1) - solution += recipe - - solutions.append(solution) - ok += 1 - mrich.set_progress_field("ok", ok) - mrich.set_progress_field("n", len(combinations)) - - if not solutions: - mrich.error("No solutions") - return None - - if pick_first: - return solutions[0] - - if pick_cheapest: - mrich.debug("Calculating prices...") - priced = [r for r in solutions if r.price] - mrich.print("Picking cheapest from", len(priced), "options") - if not priced: - mrich.error("0 recipes with prices, can't choose cheapest") - return solutions - return sorted(priced, key=lambda r: r.price)[0] - - return solutions - - @classmethod - def from_reactants( - cls, - reactants: "CompoundSet | IngredientSet", - amount: float = 1, - debug: bool = False, - return_products: bool = False, - supplier: str | None = None, - pick_cheapest: bool = False, - use_routes: bool = False, - **kwargs, - ) -> "list[Recipe] | Recipe | CompoundSet": - """Find the maximal recipe from a given set of reactants - - :param reactants: :class:`.CompoundSet` or :class:`.IngredientSet` for the reactants. Ingredient amounts are ignored - :param amount: amount of each product needed (Default value = 1) - :param debug: increase verbosity (Default value = False) - :param return_products: return products instead of recipe (Default value = False) - :param kwargs: passed to :meth:`.Recipe.from_reactions` - - """ - - from .cset import IngredientSet - - if isinstance(reactants, IngredientSet): - reactant_ids = reactants.compound_ids - else: - reactant_ids = reactants.ids - - db = reactants.db - - all_reactants = set(reactant_ids) - - possible_reactions = [] - - # recursively search for possible reactions - for i in range(300): - - if debug: - mrich.debug(i) - - # reaction_ids = db.get_possible_reaction_ids(compound_ids=compound_ids) - reaction_ids = db.get_possible_reaction_ids(compound_ids=all_reactants) - - if not reaction_ids: - break - - if debug: - mrich.debug(f"Adding {len(reaction_ids)} reactions") - - possible_reactions += reaction_ids - - if debug: - mrich.var("reaction_ids", reaction_ids) - - product_ids = db.get_possible_reaction_product_ids( - reaction_ids=reaction_ids - ) - - if debug: - mrich.var("product_ids", product_ids) - - n_prev = len(all_reactants) - - all_reactants |= set(product_ids) - - if n_prev == len(all_reactants): - break - - else: - raise NotImplementedError("Maximum recursion depth exceeded") - - possible_reactions = list(set(possible_reactions)) - - if debug: - mrich.var("all possible reactions", possible_reactions) - - from .rset import ReactionSet - - rset = ReactionSet(db, possible_reactions, sort=False) - - recipe = cls.from_reactions( - rset, - amount=amount, - permitted_reactions=rset, - debug=debug, - return_products=return_products, - supplier=supplier, - use_routes=use_routes, - **kwargs, - ) - - return recipe - - @classmethod - def from_json( - cls, - db: "Database", - path: "str | Path", - debug: bool = True, - allow_db_mismatch: bool = False, - clear_quotes: bool = False, - data: dict = None, - db_mismatch_warning: bool = True, - ): - """Load a serialised recipe from a JSON file - - :param db: database to link - :param path: path to JSON - :param debug: increase verbosity (Default value = True) - :param allow_db_mismatch: allow a database mismatch (Default value = False) - :param clear_quotes: ignore reactant quotes (Default value = False) - :param data: serialised data (Default value = None) - - """ - - # imports - import json - from .cset import IngredientSet - from .rset import ReactionSet - - # load JSON - if not data: - if debug: - mrich.reading(path) - data = json.load(open(path, "rt")) - - # check metadata - if str(db.path.resolve()) != data["database"]: - if db_mismatch_warning: - mrich.var("session", str(db.path.resolve())) - mrich.var("in file", data["database"]) - if allow_db_mismatch: - if db_mismatch_warning: - mrich.warning("Database path mismatch") - else: - mrich.error( - "Database path mismatch, set allow_db_mismatch=True to ignore" - ) - return None - - if debug: - mrich.print(f'Recipe was generated at: {data["timestamp"]}') - price = data["price"] - - # IngredientSets - products = IngredientSet.from_ingredient_dicts(db, data["products"]) - intermediates = IngredientSet.from_ingredient_dicts(db, data["intermediates"]) - reactants = IngredientSet.from_ingredient_dicts( - db, data["reactants"], supplier=data["reactant_supplier"] - ) - - if "compounds" in data: - compounds = IngredientSet.from_ingredient_dicts( - db, data["compounds"], supplier=data["compound_supplier"] - ) - else: - compounds = IngredientSet(db) - - if clear_quotes: - reactants.df["quote_id"] = None - reactants.df["quoted_amount"] = None - compounds.df["quote_id"] = None - compounds.df["quoted_amount"] = None - - # ReactionSet - reactions = ReactionSet(db, data["reaction_ids"], sort=False) - - if debug: - mrich.var("reactants", reactants) - mrich.var("intermediates", intermediates) - mrich.var("products", products) - mrich.var("reactions", reactions) - mrich.var("compounds", compounds) - - # Create the object - self = cls.__new__(cls) - self.__init__( - db, - products=products, - reactants=reactants, - intermediates=intermediates, - reactions=reactions, - compounds=compounds, - ) - - return self - - ### PROPERTIES - - @property - def db(self) -> "Database": - """Associated :class:`.Database:""" - return self._db - - @property - def products(self) -> "IngredientSet": - """Product :class:`.IngredientSet`""" - return self._products - - @property - def compounds(self) -> "IngredientSet": - """Product :class:`.IngredientSet`""" - return self._compounds - - @compounds.setter - def compounds(self, a: "IngredientSet"): - """Set the compounds""" - self._compounds = a - self.__flag_modification() - - @property - def poses(self) -> "PoseSet": - """Product poses""" - if self._poses is None: - self._poses = self.combined_compounds.poses - self._poses._name = f"poses of {self}" - return self._poses - - @property - def product_compounds(self) -> "CompoundSet": - """Product compounds""" - if self._product_compounds is None: - self._product_compounds = self.products.compounds - self._product_compounds._name = f"products of {self}" - return self._product_compounds - - @property - def combined_compound_ids(self) -> set[int]: - """Combined :class:`.Compound` IDs from :meth:`.Recipe.product_compounds` and :meth:`.Recipe.compounds`""" - return set(self.product_compounds.ids) | set(self.compounds.ids) - - @property - def combined_compounds(self) -> "CompoundSet": - """Combined product and no-chem compounds""" - if self._combined_compounds is None: - from .cset import CompoundSet - - self._combined_compounds = CompoundSet(self.db, self.combined_compound_ids) - self._combined_compounds._name = f"combined compounds of {self}" - return self._combined_compounds - - @property - def interactions(self) -> "InteractionSet": - """Product pose interactions""" - if self._interactions is None: - self._interactions = self.poses.interactions - return self._interactions - - @property - def product(self) -> "Ingredient": - """Return single product (if there's only one)""" - assert len(self.products) == 1 - return self.products[0] - - @products.setter - def products(self, a: "IngredientSet"): - """Set the products""" - self._products = a - self.__flag_modification() - - @property - def reactants(self): - """Reactant :class:`.IngredientSet`""" - return self._reactants - - @reactants.setter - def reactants(self, a: "IngredientSet"): - """Set the reactants""" - self._reactants = a - self.__flag_modification() - - @property - def intermediates(self) -> "IngredientSet": - """Intermediates :class:`.IngredientSet`""" - return self._intermediates - - @intermediates.setter - def intermediates(self, a: "IngredientSet"): - """Set the intermediates""" - self._intermediates = a - self.__flag_modification() - - @property - def reactions(self) -> "ReactionSet": - """Intermediates :class:`.IngredientSet`""" - return self._reactions - - @reactions.setter - def reactions(self, a: "ReactionSet"): - """Set the reactions""" - self._reactions = a - self.__flag_modification() - - @property - def price(self) -> "Price": - """Get the price of the reactants""" - return self.reactants.get_price() + self.compounds.get_price() - - @property - def num_products(self) -> int: - """Return the number of products""" - return len(self.products) - - @property - def num_compounds(self) -> int: - """Return the number of compounds""" - return len(self.combined_compound_ids) - - @property - def num_reactions(self): - """Return the number of reactions""" - return len(self.reactions) - - @property - def num_reaction_types(self): - """Return the number of reactions""" - return self.reactions.num_types - - @property - def num_reactants(self): - """Return the number of reactants""" - return len(self.reactants) - - @property - def num_intermediates(self): - """Return the number of intermediates""" - return len(self.intermediates) - - @property - def hash(self) -> str: - """Return the unique hash string""" - return self._hash - - @property - def score(self): - """Return the Recipe score""" - return self._score - - @property - def type(self) -> str: - """Get Recipe type (EMPTY/MIXED/CHEM/NOCHEM)""" - - if self.empty: - return "EMPTY" - - chem = bool(self.reactions) - nochem = bool(self.compounds) - - if chem and nochem: - return "MIXED" - - if chem and not nochem: - return "CHEM" - - if nochem and not chem: - return "NOCHEM" - - @property - def empty(self) -> bool: - """Is this Recipe empty?""" - - if self.reactants: - return False - - if self.products: - return False - - if self.intermediates: - return False - - if self.reactions: - return False - - if self.compounds: - return False - - return True - - ### METHODS - - def get_price(self, supplier: str | None = None) -> "Price": - """get the reactants price. See :meth:`.IngredientSet.get_price` - - :param supplier: restrict quotes to this supplier - - """ - return self.reactants.get_price(supplier=supplier) - - def draw(self, color_mapper=None, node_size=300, graph_only=False): - """draw graph of the reaction network - - :param color_mapper: (Default value = None) - :param node_size: (Default value = 300) - :param graph_only: (Default value = False) - - """ - - import networkx as nx - - color_mapper = color_mapper or {} - colors = {} - sizes = {} - - graph = nx.DiGraph() - - for reaction in self.reactions: - for reactant in reaction.reactants: - key = str(reactant) - ingredient = self.get_ingredient(id=reactant.id) - - graph.add_node( - key, - id=reactant.id, - smiles=reactant.smiles, - amount=ingredient.amount, - price=str(ingredient.price), - lead_time=ingredient.lead_time, - ) - - if not graph_only: - sizes[key] = self.get_ingredient(id=reactant.id).amount - if key in color_mapper: - colors[key] = color_mapper[key] - else: - colors[key] = (0.7, 0.7, 0.7) - - for product in self.products: - key = str(product.compound) - ingredient = self.get_ingredient(id=product.id) - - graph.add_node( - key, - id=product.id, - smiles=product.smiles, - amount=ingredient.amount, - price=str(ingredient.price), - lead_time=ingredient.lead_time, - ) - - if not graph_only: - sizes[key] = product.amount - if key in color_mapper: - colors[key] = color_mapper[key] - else: - colors[key] = (0.7, 0.7, 0.7) - - for reaction in self.reactions: - for reactant in reaction.reactants: - graph.add_edge( - str(reactant), - str(reaction.product), - id=reaction.id, - type=reaction.type, - product_yield=reaction.product_yield, - ) - - # rescale sizes - if not graph_only: - s_min = min(sizes.values()) - sizes = [s / s_min * node_size for s in sizes.values()] - - if graph_only: - return graph - else: - import matplotlib as plt - - # return nx.draw(graph, pos, with_labels=True, font_weight='bold') - # pos = nx.spring_layout(graph, iterations=200, k=30) - pos = nx.spring_layout(graph) - return nx.draw( - graph, - pos=pos, - with_labels=True, - font_weight="bold", - node_color=list(colors.values()), - node_size=sizes, - ) - - def sankey(self, title: str | None = None) -> "graph_objects.Figure": - """draw a plotly Sankey diagram - - :param title: (Default value = None) - - """ - - graph = self.draw(graph_only=True) - - import plotly.graph_objects as go - - nodes = {} - - for edge in graph.edges: - - c = edge[0] - if c not in nodes: - nodes[c] = len(nodes) - - c = edge[1] - if c not in nodes: - nodes[c] = len(nodes) - - source = [nodes[a] for a, b in graph.edges] - target = [nodes[b] for a, b in graph.edges] - value = [1 for l in graph.edges] - - labels = list(nodes.keys()) - - hoverkeys = None - - customdata = [] - for key in nodes.keys(): - n = graph.nodes[key] - - if not hoverkeys: - hoverkeys = list(n.keys()) - - if not n: - mrich.error(f"problem w/ node {key=}") - compound_id = int(key[1:]) - customdata.append((compound_id, None)) - - else: - d = tuple(v if v is not None else "N/A" for v in n.values()) - customdata.append(d) - - hoverkeys_edges = None - - customdata_edges = [] - - for s, t in graph.edges.keys(): - edge = graph.edges[s, t] - - if not hoverkeys_edges: - hoverkeys_edges = list(edge.keys()) - - if not n: - mrich.error(f"problem w/ edge {s=} {t=}") - customdata_edges.append((None, None, None)) - - else: - d = tuple(v if v is not None else "N/A" for v in edge.values()) - customdata_edges.append(d) - - hoverlines = [] - for i, key in enumerate(hoverkeys): - hoverlines.append(f"{key}=%" "{" f"customdata[{i}]" "}") - hovertemplate = "Compound " + "
".join(hoverlines) + "" - - hoverlines_edges = [] - for i, key in enumerate(hoverkeys_edges): - hoverlines_edges.append(f"{key}=%" "{" f"customdata[{i}]" "}") - hovertemplate_edges = ( - "Reaction " + "
".join(hoverlines_edges) + "" - ) - - fig = go.Figure( - data=[ - go.Sankey( - node=dict( - # pad = 15, - # thickness = 20, - # line = dict(color = "black", width = 0.5), - label=labels, - # color = "blue" - customdata=customdata, - # customdata = ["Long name A1", "Long name A2", "Long name B1", "Long name B2", - # "Long name C1", "Long name C2"], - # hovertemplate='Compound %{label}

smiles=%{customdata}', - hovertemplate=hovertemplate, - ), - link=dict( - customdata=customdata_edges, - hovertemplate=hovertemplate_edges, - source=source, - target=target, - value=value, - ), - ) - ] - ) - - if not title: - try: - title = f"Recipe
price={self.price}" - except AssertionError: - title = f"Recipe" - - fig.update_layout(title=title) - - return fig - - def summary(self, price: bool = True) -> None: - """Print a summary of this recipe - - :param price: print the price (Default value = True) - - """ - - import mcol - - mrich.h1(str(self)) - - if price: - price = self.price - if price: - mrich.var("\nprice", price.amount, price.currency) - # mrich.var('lead-time', self.lead_time, 'working days)) - - if self.products: - mrich.h3(f"{len(self.products)} products") - - if len(self.products) < 100: - for product in self.products: - mrich.var(str(product.compound), f"{product.amount:.2f}", "mg") - - if self.intermediates: - mrich.h3(f"{len(self.intermediates)} intermediates") - - if len(self.intermediates) < 100: - for intermediate in self.intermediates: - mrich.var( - str(intermediate.compound), - f"{intermediate.amount:.2f}", - "mg", - ) - - if self.reactants: - mrich.h3(f"{len(self.reactants)} reactants") - - if len(self.reactants) < 100: - for reactant in self.reactants: - mrich.var(str(reactant.compound), f"{reactant.amount:.2f}", "mg") - - if self.reactions: - mrich.h3(f"{len(self.reactions)} reactions") - - if len(self.reactions) < 100: - for reaction in self.reactions: - mrich.var(str(reaction), reaction.reaction_str, reaction.type) - - if hasattr(self, "_compounds") and self.compounds: - - mrich.h3(f"{len(self.compounds)} compounds") - - if len(self.compounds) < 100: - for compound in self.compounds: - mrich.var(str(compound.compound), f"{compound.amount:.2f}", "mg") - - def get_ingredient(self, id) -> "Ingredient": - """Get an ingredient by its compound ID - - :param id: compound ID - - """ - matches = [r for r in self.reactants if r.id == id] - if not matches: - matches = [r for r in self.intermediates if r.id == id] - if not matches: - matches = [r for r in self.products if r.id == id] - - assert len(matches) == 1 - return matches[0] - - def add_to_all_reactants(self, amount: float = 20) -> None: - """Increment all reactants by this amount - - :param amount: amount in ``mg`` (Default value = 20) - - """ - self.reactants.df["amount"] += amount - - def write_json( - self, - file: "str | Path", - *, - extra: dict | None = None, - indent: str = "\t", - **kwargs, - ) -> None: - """Serialise this recipe object and write it to disk - - :param file: write to this path - :param extra: extra data to serialise - :param indent: indentation whitespace (Default value = '\t') - - """ - import json - from pathlib import Path - - file = Path(file).resolve() - - assert file.parent.exists(), f"Directory does not exist: {file.parent}" - - data = self.get_dict(serialise_price=True, **kwargs) - - if extra: - data.update(extra) - - mrich.writing(file) - json.dump(data, open(file, "wt"), indent=indent) - - def get_dict( - self, - *, - price: bool = True, - reactant_supplier: bool = True, - compound_supplier: bool = True, - database: bool = True, - timestamp: bool = True, - compound_ids_only: bool = False, - products: bool = True, - serialise_price: bool = False, - ): - """Serialise this recipe object - - Store - ===== - - - Path to database - - Timestamp - - Reactants (& their quotes, amounts) - - Intermediates (& their quotes) - - Products (& their poses/scores/fingerprints) - - Reactions - - Total Price - - Lead time - - :param price: include the price (Default value = True) - :param reactant_supplier: include the supplier (Default value = True) - :param database: include the database (Default value = True) - :param timestamp: add a timestamp (Default value = True) - :param compound_ids_only: ID's only (instead of full :attr:`.IngredientSet.df`) (Default value = False) - :param products: include products (Default value = True) - :param serialise_price: serialise :class:`.Price` object (Default value = False) - - """ - - import json - from datetime import datetime - - data = {} - - # Database - if database: - data["database"] = str(self.db.path.resolve()) - if timestamp: - data["timestamp"] = str(datetime.now()) - - # Recipe properties - try: - if price and serialise_price: - data["price"] = self.price.get_dict() - elif price: - data["price"] = self.price - except AssertionError as e: - mrich.warning(f"Could not get price: {e}") - data["price"] = None - - if reactant_supplier: - data["reactant_supplier"] = self.reactants.supplier - - if compound_supplier: - data["compound_supplier"] = self.compounds.supplier - - # IngredientSets - if compound_ids_only: - data["reactant_ids"] = self.reactants.compound_ids - data["intermediate_ids"] = self.intermediates.compound_ids - if products: - data["products_ids"] = self.products.compound_ids - data["compound_ids"] = self.compounds.compound_ids - - else: - data["reactants"] = self.reactants.df.to_dict(orient="list") - data["intermediates"] = self.intermediates.df.to_dict(orient="list") - if products: - data["products"] = self.products.df.to_dict(orient="list") - data["compounds"] = self.compounds.df.to_dict(orient="list") - - # ReactionSet - data["reaction_ids"] = self.reactions.ids - - return data - - def get_routes(self, return_ids: bool = False) -> "RouteSet": - """Get routes""" - return self.products.get_routes( - permitted_reactions=self.reactions, return_ids=return_ids - ) - - def write_CAR_csv( - self, file: "str | Path", return_df: bool = False - ) -> "DataFrame | None": - """Prepares CSVs for use with CAR. - - .. attention:: - - This method requires a populated `route` table. For a workaround use :meth:`.CompoundSet.write_CAR_csv` instead - - Columns: - - * target-name - * no-steps - * concentration = None - * amount-required - * batch-tag - - per reaction - - * reactant-1-1 - * reactant-2-1 - * reaction-product-smiles-1 - * reaction-name-1 - * reaction-recipe-1 - * reaction-groupby-column-1 - - :param file: file to write to - :param return_df: return the dataframe (Default value = False) - - """ - - from .cset import CompoundSet - from pandas import DataFrame - from pathlib import Path - - # solve each product's reaction - - file = str(Path(file).resolve()) - - rows = [] - - routes = self.get_routes() - - for sub_recipe in routes: - - product = sub_recipe.product - - row = { - "target-names": str(product.compound), - "no-steps": 0, - "concentration-required-mM": None, - "amount-required-uL": None, - "batch-tag": None, - } - - for i, reaction in enumerate(sub_recipe.reactions): - - i = i + 1 - - row["no-steps"] += 1 - - match len(reaction.reactants): - case 1: - row[f"reactant-1-{i}"] = reaction.reactants[0].smiles - row[f"reactant-2-{i}"] = None - case 2: - row[f"reactant-1-{i}"] = reaction.reactants[0].smiles - row[f"reactant-2-{i}"] = reaction.reactants[1].smiles - case _: - # mrich.warning(f"More than two reactants for {reaction=}") - for j, r in enumerate(reaction.reactants): - row[f"reactant-{j+1}-{i}"] = reaction.reactants[j].smiles - - row[f"reaction-product-smiles-{i}"] = reaction.product.smiles - row[f"reaction-name-{i}"] = reaction.type - row[f"reaction-recipe-{i}"] = None - row[f"reaction-groupby-column-{i}"] = None - # row[f'reaction-id-{i}'] = int(reaction.id) - - rows.append(row) - - df = DataFrame(rows) - - if len(df[df.duplicated()]): - mrich.warning("Removing duplicates from CAR DataFrame") - df = df.drop_duplicates() - - df = df.convert_dtypes() - - for n_steps in set(df["no-steps"]): - subset = df[df["no-steps"] == n_steps] - this_file = file.replace(".csv", f"_{n_steps}steps.csv") - mrich.writing(this_file) - subset.to_csv(this_file, index=False) - - mrich.writing(file) - df.to_csv(file, index=False) - - return df - - def write_reactant_csv( - self, file: "str | Path", return_df: bool = False - ) -> "DataFrame | None": - """Detailed CSV output including reactant information for purchasing and information on the downstream synthetic use - - Reactant - ======== - - - ID - - SMILES - - Inchikey - - Quote - ===== - - - Supplier - - Catalogue - - Entry - - Lead-time - - Quoted amount - - Quote currency - - Quote price - - Quote purity - - Downstream - ========== - - - num_reaction_dependencies - - num_product_dependencies - - reaction_dependencies - - product_dependencies - - """ - # - remove_with - - from pandas import DataFrame - - # from rich import print - from .cset import CompoundSet - from .rset import ReactionSet - - data = [] - - ### Get lookup data - - route_ids = self.get_routes(return_ids=True) - - sql = f""" - SELECT component_ref, route_product FROM component - INNER JOIN route ON route_id = component_route - WHERE component_type = 2 - AND component_ref IN {self.reactants.compounds.str_ids} - AND component_route IN {str(tuple(route_ids)).replace(',)',')')} - """ - product_lookup = {} - for reactant_id, product_id in self.db.execute(sql): - product_lookup.setdefault(reactant_id, set()) - product_lookup[reactant_id].add(product_id) - - sql = f""" - WITH reactants AS ( - SELECT component_ref AS reactant_id, component_route AS route_id FROM component - WHERE component_type = 2 - AND component_ref IN {self.reactants.compounds.str_ids} - ), - - reactions AS ( - SELECT component_ref AS reaction_id, component_route AS route_id, reaction_type FROM component - INNER JOIN reaction ON component_ref = reaction_id - WHERE component_type = 1 - AND component_ref IN {self.reactions.str_ids} - ) - - SELECT reactants.reactant_id, reactions.reaction_id, reactions.reaction_type FROM reactants - INNER JOIN reactions ON reactants.route_id = reactions.route_id - """ - reaction_lookup = {} - for reactant_id, reaction_id, reaction_type in self.db.execute(sql): - reaction_lookup.setdefault(reactant_id, dict(ids=set(), types=set())) - reaction_lookup[reactant_id]["ids"].add(reaction_id) - reaction_lookup[reactant_id]["types"].add(reaction_type) - - smiles_lookup = self.db.get_compound_id_smiles_dict(self.reactants.compounds) - - inchikey_lookup = self.db.get_compound_id_inchikey_dict( - self.reactants.compounds - ) - - ### Reactant Dataframe - - df = self.reactants.df - - df["smiles"] = df["compound_id"].apply(lambda x: smiles_lookup[x]) - df["inchikey"] = df["compound_id"].apply(lambda x: inchikey_lookup[x]) - df = df.drop(columns=["supplier", "max_lead_time", "quoted_amount"]) - - ### Quote DataFrame - - qdf = self.db.get_quote_df(self.reactants.quote_ids) - - qdf = qdf.rename( - columns={ - "id": "quote_id", - "smiles": "quoted_smiles", - "purity": "quoted_purity", - "date": "quote_date", - "lead_time": "quote_lead_time_days", - "price": "quote_price", - "currency": "quote_currency", - "catalogue": "quote_catalogue", - "supplier": "quote_supplier", - "entry": "quote_entry", - "amount": "quoted_amount_mg", - } - ) - qdf = qdf.drop(columns=["compound"]) - - ### Downstream info - - df["downstream_product_ids"] = df["compound_id"].apply( - lambda x: product_lookup.get(x, set()) - ) - - df["downstream_reaction_ids"] = df["compound_id"].apply( - lambda x: reaction_lookup[x]["ids"] - ) - df["downstream_reaction_types"] = df["compound_id"].apply( - lambda x: reaction_lookup[x]["types"] - ) - - df["num_downstream_reactions"] = df["downstream_reaction_ids"].apply(len) - df["num_downstream_reaction_types"] = df["downstream_reaction_types"].apply(len) - df["num_downstream_products"] = df["downstream_product_ids"].apply(len) - - ### Join and reformat - - df = df.merge(qdf, on="quote_id", how="left") - - df = df.rename( - columns={ - "amount": "required_amount_mg", - } - ) - - cols = [ - "compound_id", - "smiles", - "inchikey", - "required_amount_mg", - "quoted_amount_mg", - "quote_id", - "quote_supplier", - "quote_catalogue", - "quote_entry", - "quote_price", - "quote_currency", - "quote_lead_time_days", - "quoted_purity", - "quoted_smiles", - "quote_date", - "num_downstream_reaction_types", - "num_downstream_reactions", - "num_downstream_products", - "downstream_reaction_types", - "downstream_reaction_ids", - "downstream_product_ids", - ] - - df = df[[c for c in cols if c in df.columns]] - - ### N.B. scaffold series no longer output - - mrich.writing(file) - df.to_csv(file, index=False) - - if return_df: - return df - - return None - - def write_product_csv( - self, file: "str | Path", return_df: bool = False - ) -> "pd.DataFrame | None": - """Detailed CSV output including product information for selection and synthesis""" - - from pandas import DataFrame - - # from rich import print - from .pset import PoseSet - from .cset import CompoundSet - from .rset import ReactionSet - - data = [] - - routes = self.get_routes() - - pose_map = self.db.get_compound_id_pose_ids_dict(self.products.compounds) - - inspiration_map = self.db.get_compound_id_inspiration_ids_dict() - - for product in mrich.track( - self.products, prefix="Constructing product DataFrame" - ): - - d = dict( - hippo_id=product.compound_id, - smiles=product.smiles, - inchikey=product.inchikey, - required_amount_mg=product.amount, - ) - - upstream_routes = [] - upstream_reactions = [] - - for route in routes: - if product in route.products: - upstream_routes.append(route) - - for reaction in route.reactions: - upstream_reactions.append(reaction) - - upstream_reactions = ReactionSet( - self.db, set(reaction.id for reaction in upstream_reactions) - ) - - if not upstream_routes: - mrich.error("No upstream routes for", product) - continue - - if not upstream_reactions: - mrich.error("No upstream reactions for", product) - continue - - def get_scaffold_series() -> tuple[list[int], bool]: - """Get scaffold series value""" - - if scaffolds := product.scaffolds: - return scaffolds.ids, False - - else: - return [product.id], True - - poses = pose_map.get(product.id, set()) - - d["num_poses"] = len(poses) - d["poses"] = poses - d["tags"] = product.tags - d["num_routes"] = len(upstream_routes) - d["num_reaction_steps"] = set( - len(route.reactions) for route in upstream_routes - ) - d["reaction_dependencies"] = upstream_reactions.ids - d["reactant_dependencies"] = set( - sum([route.reactants.ids for route in upstream_routes], []) - ) - d["route_ids"] = [route.id for route in upstream_routes] - d["chemistry_types"] = ", ".join(upstream_reactions.types) - series, is_scaffold = get_scaffold_series() - d["is_scaffold"] = is_scaffold - d["scaffold_series"] = series - - inspirations = inspiration_map.get(product.id, None) - - if not inspirations and not is_scaffold: - scaffold = product.scaffolds[0] - inspirations = inspiration_map.get(scaffold.id, None) - - if not inspirations and "inspiration_pose_ids" in scaffold.metadata: - inspirations = scaffold.metadata["inspiration_pose_ids"] - - if ( - not inspirations - and is_scaffold - and "inspiration_pose_ids" in product.metadata - ): - inspirations = product.metadata["inspiration_pose_ids"] - - if inspirations: - inspirations = PoseSet(self.db, inspirations) - d["inspirations"] = ", ".join(n for n in inspirations.names) - else: - d["inspirations"] = "" - - data.append(d) - - df = DataFrame(data) - mrich.writing(file) - df.to_csv(file, index=False) - - if return_df: - return df - - return None - - def write_chemistry_csv( - self, file: "str | Path", return_df: bool = True - ) -> "pd.DataFrame | None": - """Detailed CSV output synthetis information for chemistry types in this set""" - - from pandas import DataFrame - - from rich import print - from .cset import CompoundSet - from .rset import ReactionSet - - data = [] - - # get compounds - - scaffolds = CompoundSet(self.db) - - for product in self.products: - - if scaffolds := product.scaffolds: - scaffolds += scaffolds - else: - scaffolds.add(product.compound) - - routes = self.get_routes() - - route_types = {} - - for compound in scaffolds: - - elabs = ( - self.products.compounds.get_by_scaffold(scaffold=compound, none="quiet") - or [] - ) - - d = dict( - scaffold_id=compound.id, - product_id=compound.id, - smiles=compound.smiles, - inchikey=compound.inchikey, - num_elaborations=len(elabs), - is_scaffold=True, - ) - - upstream_routes = [] - for route in routes: - if compound in route.products: - upstream_routes.append(route) - - if not upstream_routes: - mrich.warning(f"No routes to scaffold={compound}") - continue - - d["num_routes"] = len(upstream_routes) - - for j, route in enumerate(upstream_routes): - d[f"route_{j+1}_num_steps"] = len(route.reactions) - - group = route_types.setdefault(compound.id, set()) - group.add(tuple([r.type for r in route.reactions])) - - for k, reaction in enumerate(route.reactions): - key = f"route_{j+1}_reaction_{k+1}" - - product = reaction.product - - d[f"{key}_type"] = reaction.type - d[f"{key}_product_smiles"] = product.smiles - d[f"{key}_product_id"] = product.id - d[f"{key}_product_yield"] = reaction.product_yield - - for i, reactant in enumerate(reaction.reactants): - d[f"{key}_reactant_{i+1}_smiles"] = reactant.smiles - d[f"{key}_reactant_{i+1}_id"] = reactant.id - - data.append(d) - - missing_scaffolds = {} - - for compound in self.products.compounds: - - if compound in scaffolds: - continue - - upstream_routes = [] - for route in routes: - if compound in route.products: - upstream_routes.append(route) - - scaffolds = compound.scaffolds - - for scaffold in scaffolds: - - if scaffold.id not in route_types: - group = missing_scaffolds.setdefault(scaffold.id, []) - group.append(compound.id) - continue - - else: - for route in upstream_routes: - chem_types = tuple([r.type for r in route.reactions]) - - if chem_types not in route_types[base.id]: - mrich.success(scaffold) - mrich.success(chem_types) - raise ValueError( - "Scaffold has route not present in dataframe" - ) - - for scaffold_id, elab_ids in missing_scaffolds.items(): - - compound = self.db.get_compound(id=sorted(elab_ids)[0]) - - d = dict( - scaffold_id=scaffold_id, - product_id=compound.id, - smiles=compound.smiles, - inchikey=compound.inchikey, - num_elaborations=len(elab_ids), - is_scaffold=False, - ) - - upstream_routes = [] - for route in routes: - if compound in route.products: - upstream_routes.append(route) - - if not upstream_routes: - mrich.error(f"No routes to elab {compound}") - raise ValueError(f"No routes to elab {compound}") - - d["num_routes"] = len(upstream_routes) - - for j, route in enumerate(upstream_routes): - d[f"route_{j+1}_num_steps"] = len(route.reactions) - - group = route_types.setdefault(compound.id, set()) - group.add(tuple([r.type for r in route.reactions])) - - for k, reaction in enumerate(route.reactions): - key = f"route_{j+1}_reaction_{k+1}" - - product = reaction.product - - d[f"{key}_type"] = reaction.type - d[f"{key}_product_smiles"] = product.smiles - d[f"{key}_product_id"] = product.id - d[f"{key}_product_yield"] = reaction.product_yield - - for i, reactant in enumerate(reaction.reactants): - d[f"{key}_reactant_{i+1}_smiles"] = reactant.smiles - d[f"{key}_reactant_{i+1}_id"] = reactant.id - - data.append(d) - - df = DataFrame(data) - mrich.writing(file) - df.to_csv(file, index=False) - - if return_df: - return df - - return None - - def to_syndirella( - self, - out_key: "str | Path", - poses: "PoseSet", - *, - separate: bool = False, - ) -> "DataFrame": - """Generate inputs for running syndirella elaboration""" - - import shutil - from pathlib import Path - - out_key = Path(".") / out_key - out_dir = out_key.parent - out_key = out_key.name - - mrich.var("out_key", out_key) - mrich.var("out_dir", out_dir) - - if not out_dir.exists(): - mrich.writing(out_dir) - out_dir.mkdir(parents=True, exist_ok=True) - - template_dir = out_dir / "templates" - if not template_dir.exists(): - mrich.writing(template_dir) - template_dir.mkdir(parents=True, exist_ok=True) - - """ - - Need to create dataframe with columns: - - compound_id - - pose_id - - smiles - - reaction_name_step1 - - reactant_step1 - - reactant2_step1 - - product_step1 - ... - - hit1 - - hit2 - ... - - template - - compound_set - - """ - - pose_compounds = poses.compounds - assert set(self.products.compound_ids) == set( - pose_compounds.ids - ), "supplied poses have different compounds to Recipe products" - assert len(poses) == len( - self.products - ), "some duplicate compounds in supplied poses" - - df = poses.get_df( - inchikey=False, - alias=False, - name=False, - compound_id=True, - reference_id=True, - inspiration_aliases=True, - ) - - df = df.reset_index() - df = df.rename(columns={"id": "pose_id"}) - df["compound_set"] = df["compound_id"].apply(lambda x: f"C{x}") - df = df.set_index(["compound_id", "pose_id"]) - - ## CHECKS - - no_refs = df[df["reference_id"].isna()] - - if len(no_refs): - mrich.error(len(no_refs), "poses without reference!") - ids = set(no_refs.index.get_level_values("pose_id")) - mrich.print(ids) - from .pset import PoseSet - - no_insps = bool([1 for i in df["inspiration_aliases"].values if not len(i)]) - - if no_insps: - mrich.error(len(no_insps), "poses without inspirations!") - return None - - ## TEMPLATES - - references = poses.references - ref_lookup = self.db.get_pose_id_alias_dict(references) - df["template"] = df["reference_id"].apply(lambda x: ref_lookup[x]) - - for ref_pose in references: - assert ref_pose.apo_path, f"Reference {ref_pose} has no apo_path" - - template = template_dir / ref_pose.apo_path.name - - if not template.exists(): - mrich.writing(template) - shutil.copy(ref_pose.apo_path, template) - - ## INSPIRATIONS - - for i, row in df.iterrows(): - for j, alias in enumerate(row["inspiration_aliases"]): - df.loc[i, f"hit{j+1}"] = alias - - inspirations = poses.inspirations - - sdf_name = out_dir / f"{out_key}_syndirella_inspiration_hits.sdf" - - inspirations.write_sdf( - sdf_name, - tags=False, - metadata=False, - name_col="name", - ) - - ## ADD ROUTE INFO - - routes = self.get_routes() - - for sub_recipe in mrich.track(routes, prefix="Adding chemistry info..."): - - product = sub_recipe.product - - product_id = product.compound_id - - matches = df.xs(product_id, level="compound_id") - - if len(matches) > 1: - mrich.warning("Multiple rows for compound", product_id) - - for i, row in matches.iterrows(): - - key = (product_id, i) - - for j, reaction in enumerate(sub_recipe.reactions): - - j = j + 1 - - match len(reaction.reactants): - case 1: - df.loc[key, f"reactant_step{j}"] = reaction.reactants[ - 0 - ].smiles - df.loc[key, f"reactant2_step{j}"] = None - case 2: - df.loc[key, f"reactant_step{j}"] = reaction.reactants[ - 0 - ].smiles - df.loc[key, f"reactant2_step{j}"] = reaction.reactants[ - 1 - ].smiles - case 3: - df.loc[key, f"reactant_step{j}"] = reaction.reactants[ - 0 - ].smiles - df.loc[key, f"reactant2_step{j}"] = reaction.reactants[ - 1 - ].smiles - df.loc[key, f"reactant3_step{j}"] = reaction.reactants[ - 2 - ].smiles - case _: - raise NotImplementedError("Too many reactants") - - df.loc[key, f"product_step{j}"] = reaction.product.smiles - df.loc[key, f"reaction_name_step{j}"] = reaction.type - - break - - ## REMOVE UNECESSARY COLS - - df = df.drop(columns=["reference_id", "inspiration_aliases"]) - - ## REORDER COLUMNS - - cols = [ - "smiles", - "reaction_name_step1", - "reactant_step1", - "reactant2_step1", - "reactant3_step1", - "product_step11", - "hit1", - "hit2", - "hit3", - "hit4", - "hit5", - "hit6", - "hit7", - "hit8", - "hit9", - "template", - "compound_set", - ] - - if not any([c not in cols for c in df.columns]): - df = df[[c for c in cols if c in df.columns]] - - if not separate: - out_path = out_dir / f"{out_key}_syndirella_input.csv" - mrich.writing(out_path) - df.to_csv(out_path) - return df - - for idx, row in df.iterrows(): - out_path = out_dir / f"{out_key}_{row['compound_set']}_syndirella_input.csv" - mrich.writing(out_path) - single_df = row.to_frame().T - single_df = single_df.dropna(axis=1, how="all") - single_df.to_csv(out_path, index=False) - - return df - - def copy(self) -> "Recipe": - """Copy this recipe""" - - if hasattr(self, "compounds"): - compounds = self.compounds.copy() - else: - compounds = None - - return Recipe( - self.db, - products=self.products.copy(), - reactants=self.reactants.copy(), - intermediates=self.intermediates.copy(), - reactions=self.reactions.copy(), - compounds=compounds, - # supplier=self.supplier - ) - - def __flag_modification(self) -> None: - """Flag this recipe as modified""" - self._product_interactions = None - self._score = None - self._product_compounds = None - self._product_poses = None - - def check_integrity(self, debug: bool = False) -> bool: - """Verify integrity of this recipe""" - - # no duplicate ingredients - - if debug: - mrich.debug("Checking integrity:", self) - mrich.debug("Checking for duplicate compounds") - - if len(self.reactants.compound_ids) != len(set(self.reactants.compound_ids)): - mrich.error("Reactant compound ID's are not unique") - return False - if len(self.intermediates.compound_ids) != len( - set(self.intermediates.compound_ids) - ): - mrich.error("Intermediate compound ID's are not unique") - return False - if len(self.products.compound_ids) != len(set(self.products.compound_ids)): - mrich.error("Product compound ID's are not unique") - return False - - # all references should exist - - if debug: - mrich.debug("Checking for missing references") - - if self.db.count_where( - table="reaction", key=f"reaction_id IN {self.reactions.str_ids}" - ) < len(self.reactions): - mrich.error("Not all Reactions in Database") - return False - - if self.db.count_where( - table="compound", key=f"compound_id IN {self.product_compounds.str_ids}" - ) < len(self.products): - mrich.error("Not all product Compounds in Database") - return False - - if self.db.count_where( - table="compound", key=f"compound_id IN {self.reactants.compounds.str_ids}" - ) < len(self.reactants): - mrich.error("Not all reactant Compounds in Database") - return False - - if self.db.count_where( - table="compound", - key=f"compound_id IN {self.intermediates.compounds.str_ids}", - ) < len(self.intermediates): - mrich.error("Not all intermediate Compounds in Database") - return False - - reaction_intermediates = self.reactions.intermediates - reaction_products = self.reactions.products - reaction_reactants = self.reactions.reactants - - if debug: - mrich.debug("Checking for missing reactions") - - # all products should have a reaction - for product in self.products: - if product not in reaction_products: - mrich.error(f"Product: {product} does not have associated reaction") - return False - - # intermediates - for intermediate in self.intermediates: - if intermediate not in reaction_intermediates: - mrich.error( - f"Intermediate: {intermediate} is not in self.reactions.intermediates" - ) - return False - - # reactants - for reactant in self.reactants: - if reactant not in reaction_reactants: - mrich.error(f"Reactant: {reactant} is not in self.reactions.reactants") - return False - - # all reactions should have enough reactant - - if debug: - mrich.debug("Checking reactant quantities") - - for reaction in self.reactions: - - product_ingredient = self.products(compound_id=reaction.product_id) - - if product_ingredient is None: - product_ingredient = self.intermediates(compound_id=reaction.product_id) - - if debug and reaction.product_yield < 1.0: - mrich.debug(f"{reaction}.product_yield={reaction.product_yield}") - - for reactant in reaction.reactants: - - reactant_ingredient = self.intermediates(compound_id=reactant.id) - - if reactant_ingredient is None: - reactant_ingredient = self.reactants(compound_id=reactant.id) - - required_amount = product_ingredient.amount / reaction.product_yield - - if reactant_ingredient.amount < required_amount: - mrich.error( - f"Not enough of {reactant_ingredient.compound}: {reactant_ingredient.amount} < {required_amount}" - ) - return False - - if debug: - mrich.success(self, "OK") - - return True - - def add_ingredient(self, ingredient: "Ingredient", amount: float = 1): - """Add an :class:`.Ingredient` object for direct purchase (no associated reactions)""" - self.compounds.add(ingredient) - - ### DUNDERS - - def __str__(self) -> str: - """Unformatted string representation""" - - if self.score: - s = f"(score={self.score:.3f})" - else: - s = "" - - if self.hash: - return f"Recipe_{self.hash}{s}" - - return f"Recipe{s}" - - def __longstr(self) -> str: - """Unformatted string representation""" - - if self.empty: - return f"Empty Recipe()" - - if self.reactions: - - if self.intermediates: - s = f"{self.reactants} --> {self.intermediates} --> {self.products} via {self.reactions}" - else: - s = f"{self.reactants} --> {self.products} via {self.reactions}" - - if self.score: - s += f", score={self.score:.3f}" - - if self.hash: - return f"Recipe_{self.hash}({s})" - - return f"Recipe({s})" - - else: - - s = f"{self.compounds}" - - if self.hash: - return f"Recipe_{self.hash}({s})" - - return f"Recipe(#compounds={self.num_compounds} [no-chem])" - - def __repr__(self) -> str: - """ANSI Formatted string representation""" - return f"{mcol.bold}{mcol.underline}{self.__longstr()}{mcol.unbold}{mcol.ununderline}" - - def __rich__(self) -> str: - """Rich Formatted string representation""" - return f"[bold underline]{self.__longstr()}" - - def __add__(self, other: "Recipe"): - """Add another :class:`.Recipe` to this one""" - result = self.copy() - result.reactants += other.reactants - result.intermediates += other.intermediates - result.reactions += other.reactions - result.products += other.products - if hasattr(other, "compounds"): - result.compounds += other.compounds - return result - - -class Route(Recipe): - """A recipe with a single product, that is stored in the database""" - - def __init__( - self, - db, - *, - route_id: int, - product: "IngredientSet", - reactants: "IngredientSet", - intermediates: "IngredientSet", - reactions: "ReactionSet", - ) -> None: - """Route initialisation""" - - from .cset import IngredientSet - from .rset import ReactionSet - - # check typing - assert isinstance(product, IngredientSet) - assert isinstance(reactants, IngredientSet) - assert isinstance(intermediates, IngredientSet) - assert isinstance(reactions, ReactionSet) - - assert len(product) == 1 - assert isinstance(route_id, int) - assert route_id - - self._id = route_id - self._products = product - self._product_id = product.ids[0] - self._reactants = reactants - self._intermediates = intermediates - self._reactions = reactions - self._db = db - - ### FACTORIES - - @classmethod - def from_json( - cls, db: "Database", path: "str | Path", data: dict = None - ) -> "Route": - """Load a serialised route from a JSON file - - :param db: database to link - :param path: path to JSON - :param data: serialised data (Default value = None) - - """ - - import json - from .cset import IngredientSet - from .rset import ReactionSet - - if data is None: - data = json.load(open(path, "rt")) - - self = cls.__new__(cls) - - self._db = db - self._id = data["id"] - - self._product_id = data["product_id"] - self._products = IngredientSet.from_compounds( - compounds=None, ids=[self._product_id], db=db - ) # IngredientSet - - self._reactants = IngredientSet.from_json( - db=db, - path=None, - data=data["reactants"]["data"], - supplier=data["reactants"]["supplier"], - ) - self._intermediates = IngredientSet.from_json( - db=db, - path=None, - data=data["intermediates"]["data"], - supplier=data["intermediates"]["supplier"], - ) - self._reactions = ReactionSet( - db=db, indices=data["reactions"]["indices"] - ) # ReactionSet - - return self - - ### PROPERTIES - - @property - def product(self) -> "Ingredient": - """Product ingredient""" - return self._products[0] - - @property - def product_compound(self) -> "Compound": - """Product compound""" - return self.product.compound - - @property - def id(self) -> int: - """Route ID""" - return self._id - - @property - def price(self) -> "Price": - """Get the price of the reactants""" - return self.reactants.price - - ### METHODS - - def get_dict(self) -> dict: - """Serialisable dictionary""" - data = {} - - data["id"] = self.id - data["product_id"] = self.product.id - data["reactants"] = self.reactants.get_dict() - data["intermediates"] = self.intermediates.get_dict() - data["reactions"] = self.reactions.get_dict() - - return data - - ### DUNDERS - - def __str__(self) -> str: - """Unformatted string representation""" - return f"Route #{self.id}: {self.product_compound}" - - def __repr__(self) -> str: - """ANSI Formatted string representation""" - return f"{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}" - - def __rich__(self) -> str: - """Rich Formatted string representation""" - return f"[bold underline]{self}" - - -class RouteSet: - """A set of Route objects""" - - def __init__(self, db: "Database", routes: "list[Route]") -> None: - """RouteSet initialisation""" - - data = {} - for route in routes: - # assert isinstance(route, Route) - data[route.id] = route - - self._data = data - self._db = db - self._cluster_map = None - self._permitted_clusters = None - self._current_cluster = None - - ### FACTORIES - - @classmethod - def from_ids(cls, db: "Database", ids: list | set): - """Generate a routeset from a set of :class:`.Route` IDs - - :param db: database to link - :param ids: :class:`.Route` database IDs - """ - - routes = [ - db.get_route(id=route_id) - for route_id in mrich.track(ids, prefix="Getting routes") - ] - - self = cls.__new__(cls) - return RouteSet(db, routes) - - @classmethod - def from_product_ids(cls, db: "Database", ids: list | set): - """Generate a routeset from a set of product :class:`.Compound` IDs - - :param db: database to link - :param ids: :class:`.Compound` database IDs - """ - - str_ids = str(tuple(ids)).replace(",)", ")") - - records = db.select_where( - table="route", - query="route_id", - key=f"route_product IN {str_ids}", - multiple=True, - ) - - route_ids = [i for i, in records] - - return cls.from_ids(db, route_ids) - - @classmethod - def from_json( - cls, db: "Database", path: "str | Path", data: dict = None - ) -> "RouteSet": - """Load a serialised routeset from a JSON file - - :param db: database to link - :param path: path to JSON - :param data: serialised data (Default value = None) - - """ - - self = cls.__new__(cls) - - if data is None: - import json - - data = json.load(open(path, "rt")) - - new_data = {} - for d in mrich.track(data["routes"].values(), prefix="Loading Routes..."): - route_id = d["id"] - new_data[route_id] = Route.from_json(db=db, path=None, data=d) - - self._data = new_data - self._db = db - self._cluster_map = None - self._permitted_clusters = None - self._current_cluster = None - - return self - - ### PROPERTIES - - @property - def data(self) -> "dict[int, Route]": - """Get internal data dictionary""" - return self._data - - @property - def db(self): - """Get associated database""" - return self._db - - @property - def routes(self) -> "list[Route]": - """Get route objects""" - return self.data.values() - - @property - def product_ids(self) -> list[int]: - """Get the :class:`.Compound` ID's of the products""" - ids = self.db.select_where( - table="route", - query="route_product", - key=f"route_id IN {self.str_ids}", - multiple=True, - ) - return [i for i, in ids] - - @property - def products(self) -> "CompoundSet": - """Return a :class:`.CompoundSet` of all the route products""" - from .cset import CompoundSet - - return CompoundSet(self.db, self.product_ids) - - @property - def str_ids(self) -> str: - """Return an SQL formatted tuple string of the :class:`.Route` ID's""" - return str(tuple(self.ids)).replace(",)", ")") - - @property - def ids(self) -> list[int]: - """Return the :class:`.Route` IDs""" - return self.data.keys() - - @property - def cluster_map(self) -> dict[tuple, set]: - """Create a dictionary grouping routes by their scaffold/base cluster. - - :returns: A dictionary mapping a tuple of scaffold :class:`.Compound` IDs to a set of :class:`.Route` ID's to their superstructures. - """ - - if self._cluster_map is None: - - # get route mapping - pairs = self.db.select_where( - query="route_product, route_id", - key=f"route_id IN {self.str_ids}", - table="route", - multiple=True, - ) - - route_map = {route_product: route_id for route_product, route_id in pairs} - - # group compounds by cluster - compound_clusters = self.db.get_compound_cluster_dict(cset=self.products) - - # create the map - self._cluster_map = {} - for cluster, compounds in compound_clusters.items(): - self._cluster_map[cluster] = [] - for compound in compounds: - route_id = route_map.get(compound, None) - if not route_id: - continue - self._cluster_map[cluster].append(route_id) - - if not self._cluster_map[cluster]: - del self._cluster_map[cluster] - - return self._cluster_map - - ### METHODS - - def copy(self) -> "RouteSet": - """Copy this RouteSet""" - return RouteSet(self.db, self.data.values()) - - def set_db_pointers(self, db: "Database") -> None: - """ - - :param db: - - """ - self._db = db - for route in self.data.values(): - route._db = db - - # def clear_db_pointers(self): - # """ """ - # self._db = None - # for route in self.data.values(): - # route._db = None - - def get_dict(self): - """Get serialisable dictionary""" - - data = dict(db=str(self.db), routes={}) - - # populate with routes - for route_id, route in self.data.items(): - data["routes"][route_id] = route.get_dict() - - return data - - def prune_unavailable(self, suppliers: list[str]): - """Remove routes that don't have all reactants available from given suppliers""" - - suppliers_str = str(tuple(suppliers)).replace(",)", ")") - - sql = f""" - WITH possible_reactants AS ( - SELECT quote_compound, COUNT( - CASE - WHEN quote_supplier IN {suppliers_str} THEN 1 - END) AS [count_valid] - FROM quote - GROUP BY quote_compound - ), - - route_reactants AS ( - SELECT route_id, route_product, - COUNT( - CASE - WHEN count_valid = 0 THEN 1 - WHEN count_valid IS NULL THEN 1 - END) - AS [count_unavailable] FROM route - INNER JOIN component ON component_route = route_id - LEFT JOIN possible_reactants ON quote_compound = component_ref - WHERE component_type = 2 - GROUP BY route_id - ) - - SELECT route_id FROM route_reactants - WHERE count_unavailable = 0 - AND route_id IN {self.str_ids} - """ - - route_ids = self.db.execute(sql).fetchall() - - route_ids = [i for i, in route_ids] - - mrich.var("#routes before pruning", len(self)) - mrich.var("#routes after pruning", len(route_ids)) - - return RouteSet.from_ids(self.db, route_ids) - - def pop_id(self) -> int: - """Pop the last route from the set and return it's id""" - route_id, route = self.data.popitem() - return route_id - - def pop(self) -> "Route": - """Pop the last route from the set and return it's object""" - route_id, route = self.data.popitem() - return route - - def balanced_pop( - self, permitted_clusters: set[tuple] | None = None, debug: bool = False - ) -> "Route": - """Pop a route from this set, while maintaining the balance of scaffold clusters populations""" - - if not self._data: - mrich.print("RouteSet depleted") - return None - - if not self.cluster_map: - # mrich.warning("RouteSet.cluster_map depleted but _data isn't...") - return self.pop() - - # store the permitted clusters (or all clusters) list as property - - if self._permitted_clusters is None: - if permitted_clusters: - permitted_clusters = set( - (cluster,) if isinstance(cluster, int) else cluster - for cluster in permitted_clusters - ) - - self._permitted_clusters = [] - for cluster in permitted_clusters: - if cluster not in self.cluster_map: - mrich.warning( - cluster, "in permitted_clusters but not cluster_map" - ) - else: - self._permitted_clusters.append(cluster) - - else: - self._permitted_clusters = list(self.cluster_map.keys()) - - if self._current_cluster is None: - self._current_cluster = self._permitted_clusters[0] - - ### pop a Route - - if debug: - mrich.debug(f"Would pop Route from {self._current_cluster=}") - - cluster = self._current_cluster - - # pop the last route id from the given cluster - - try: - route_id = self.cluster_map[cluster].pop() - except IndexError: - mrich.print(self._permitted_clusters) - mrich.print(self.cluster_map) - raise - except AttributeError: - mrich.print(cluster) - mrich.print(self.cluster_map) - raise - except KeyError: - mrich.print("cluster", cluster) - mrich.print("self._permitted_clusters", self._permitted_clusters) - mrich.print("self.cluster_map.keys()", self.cluster_map.keys()) - raise - - # clean up empty clusters - - if debug: - mrich.debug("Popped route", route_id) - - # get the Route object - - if route_id in self._data: - route = self._data[route_id] - del self._data[route_id] - else: - # if debug: - mrich.debug("Route not present") - return self.balanced_pop() - - ### increment cluster - - # def increment_cluster(cluster): - n = len(self._permitted_clusters) - if n > 1: - for i, cluster in enumerate(self._permitted_clusters): - if cluster == self._current_cluster: - if i == n - 1: - self._current_cluster = self._permitted_clusters[0] - else: - self._current_cluster = self._permitted_clusters[i + 1] - break - else: - raise IndexError("This should never be reached...") - - # increment_cluster() - - if not self.cluster_map[cluster]: - del self.cluster_map[cluster] - if not self.cluster_map: - mrich.debug("RouteSet.cluster_map depleted") - self._permitted_clusters = [ - c for c in self._permitted_clusters if c != cluster - ] - # if debug: - mrich.debug("Depleted cluster", cluster) - - if not self._permitted_clusters: - mrich.debug("Depleted all permitted clusters", cluster) - mrich.debug("Removing cluster restriction", cluster) - self._permitted_clusters = list(self.cluster_map.keys()) - self._current_cluster = None - - if debug: - mrich.debug("#Routes in set", len(self._data)) - - return route - - def shuffle(self): - """Randomly shuffle the routes in this set""" - import random - - items = list(self.data.items()) - random.shuffle(items) - self._data = dict(items) - - ### shuffle the cluster map as well - - for cluster, routes in self.cluster_map.items(): - random.shuffle(routes) - self.cluster_map[cluster] = routes - - ### DUNDERS - - def __len__(self) -> int: - """Number of routes in this set""" - return len(self.data) - - def __str__(self) -> str: - """Unformatted string representation""" - return "{" f"Route × {len(self)}" "}" - - def __repr__(self) -> str: - """ANSI Formatted string representation""" - return f"{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}" - - def __rich__(self) -> str: - """Rich Formatted string representation""" - return f"[bold underline]{self}" - - def __iter__(self): - """Iterate over routes in this set""" - return iter(self.data.values()) - - -class RecipeSet: - """A set of recipes stored on disk""" - - def __init__( - self, db: "Database", directory: "str | Path", pattern: str = "*.json" - ): - """RecipeSet initialisation""" - - from pathlib import Path - from json import JSONDecodeError - - self._db = db - self._json_directory = Path(directory) - self._json_pattern = pattern - - self._json_paths = {} - for path in self._json_directory.glob(self._json_pattern): - self._json_paths[ - path.name.removeprefix("Recipe_").removesuffix(".json") - ] = path.resolve() - - mrich.reading(f"{directory}/{pattern}") - - self._recipes = {} - for key, path in mrich.track( - self._json_paths.items(), prefix="Loading recipes" - ): - try: - recipe = Recipe.from_json( - db=self.db, - path=path, - allow_db_mismatch=True, - debug=False, - db_mismatch_warning=False, - ) - except JSONDecodeError: - mrich.error(f"Bad JSON in {path}") - continue - recipe._hash = key - self._recipes[key] = recipe - - mrich.success("Loaded", len(self), "Recipes") - - ### FACTORIES - - ### PROPERTIES - - @property - def db(self) -> "Database": - """Associated database""" - return self._db - - ### METHODS - - def get_values( - self, - key: str, - progress: bool = False, - serialise_price: bool = False, - ): - """Get values of member recipes associated with attribute ``key`` - - :param key: attribute to query/calculate - :param progress: show a progress bar - :param serialise_price: serialise price objects - - """ - - values = [] - recipes = self._recipes.values() - - if progress: - recipes = mrich.track(recipes, prefix=f"Calculating {self} values...") - - for recipe in recipes: - value = getattr(recipe, key) - if serialise_price and key == "price": - value = value.amount - values.append(value) - - return values - - def get_df(self, **kwargs) -> "pandas.DataFrame": - """Get dataframe of recipe dictionaries. See :meth:`.Recipe.get_dict`""" - - data = [] - - for recipe in self: - - d = recipe.get_dict( - # reactant_supplier=False, - database=False, - timestamp=False, - **kwargs, - # timestamp=False, - ) - - data.append(d) - - from pandas import DataFrame - - return DataFrame(data) - - def items(self) -> "list[tuple[str, Recipe]]": - """Get data dictionary items""" - return self._recipes.items() - - def keys(self) -> list[str]: - """Get data dictionary keys (recipe hashes)""" - return self._recipes.keys() - - ### DUNDERS - - def __len__(self) -> int: - """Number of recipes in this set""" - return len(self._recipes) - - def __getitem__( - self, - key: int | str, - ) -> Recipe: - """Get a :class:`.Recipe` in this set by it's index or key/hash""" - - match key: - - case int(): - return list(self._recipes.values())[key] - - case str(): - return self._recipes[key] - - case _: - mrich.error( - f"Unsupported type for RecipeSet.__getitem__(): {key=} {type(key)}" - ) - - return None - - def __iter__(self): - """Iterate over recipes""" - return iter(self._recipes.values()) - - def __contains__(self, key: str): - """Is this hash contained in the set""" - assert isinstance(key, str) - return key in self._recipes - - def __str__(self) -> str: - """Unformatted string representation""" - return "{" f"Recipe × {len(self)}" "}" - - def __repr__(self) -> str: - """ANSI Formatted string representation""" - return f"{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}" - - def __rich__(self) -> str: - """Rich Formatted string representation""" - return f"[bold underline]{self}" diff --git a/hippo/rgen.py b/hippo/rgen.py deleted file mode 100644 index d0d700ac..00000000 --- a/hippo/rgen.py +++ /dev/null @@ -1,806 +0,0 @@ -"""Classes for generating random recipes/selections""" - -import mrich - -import json -from pathlib import Path - -from .tools import dt_hash -from .recipe import Recipe -from .cset import CompoundSet, IngredientSet - - -class RRGMixin: - """Mixin class for shared properties""" - - @property - def db(self) -> "Database": - """Get the linked HIPPO Database object""" - return self._db - - @property - def db_path(self) -> str: - """Get the path of the linked Database""" - return self._db_path - - @property - def starting_recipe(self): - """Get the starting recipe used in all generations""" - return self._starting_recipe - - @property - def suppliers_str(self) -> str: - """SQL formatted tuple of suppliers""" - return str(tuple(self.suppliers)).replace(",)", ")") - - @property - def suppliers(self) -> list[str]: - """List of suppliers""" - return self._suppliers - - @property - def max_lead_time(self) -> float: - """Maximum lead-time constraint""" - return self._max_lead_time - - @property - def data_path(self): - """File path for the JSON data export""" - return self._data_path - - @property - def recipe_dir(self): - """File path for the JSON recipe export""" - return self._recipe_dir - - def __repr__(self) -> str: - """ANSI Formatted string representation""" - import mcol - - return f"{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}" - - def __call__(self, *args, **kwargs) -> "Recipe": - """Generate Recipe""" - return self.generate(*args, **kwargs) - - def __rich__(self) -> str: - """Rich Formatted string representation""" - return f"[bold underline]{self}" - - -class RandomRecipeGenerator(RRGMixin): - """Class to create randomly sampled Recipe from a HIPPO Database""" - - def __init__( - self, - db, - *, - max_lead_time=None, - suppliers: list | None = None, - start_with: Recipe | CompoundSet | IngredientSet | None = None, - route_pool: "RouteSet | None" = None, - out_key: str | None = None, - ): - """RandomRecipeGenerator initialisation""" - - mrich.debug("RandomRecipeGenerator.__init__()") - - if not start_with: - start_with = Recipe(db) - - # Static parameters - self._db_path = db.path - self._max_lead_time = max_lead_time - self._suppliers = suppliers - self._starting_recipe = start_with - - mrich.var("database", self.db_path) - mrich.var("max_lead_time", self.max_lead_time) - mrich.var("suppliers", self.suppliers) - - # Database set up - self._db = db - - if not out_key: - out_key = str(self.db_path.name).removesuffix(".sqlite") - mrich.var("out_key", out_key) - - parent_dir = Path(out_key).parent - if not parent_dir.exists(): - parent_dir.mkdir(parents=True) - - # JSON I/O set up - self._data_path = Path(f"{out_key}_rgen.json") - if self.data_path.exists(): - mrich.warning(f"Will overwrite existing rgen data file: {self.data_path}") - - # Recipe I/O set up - path = Path(f"{out_key}_recipes") - if not path.exists(): - mrich.writing(f"{path}/") - path.mkdir() - self._recipe_dir = path - - # Route pool - if route_pool: - route_pool = route_pool.prune_unavailable(suppliers=suppliers) - self._route_pool = route_pool - else: - mrich.debug("Solving route pool...") - self._route_pool = self.get_route_pool() - - assert len(self._route_pool), "Route pool is empty!" - - # dump data - self.dump_data() - - ### FACTORIES - - @classmethod - def from_json(cls, db: "Database", path: "Path | str"): - """Construct the RandomRecipeGenerator from a JSON file""" - - data = json.load(open(path, "rt")) - - self = cls.__new__(cls) - - self._db_path = Path(data["db_path"]) - self._recipe_dir = Path(data["recipe_dir"]) - self._max_lead_time = data["max_lead_time"] - self._suppliers = data["suppliers"] - - self._starting_recipe = Recipe.from_json( - db=db, - path=None, - data=data["starting_recipe"], - allow_db_mismatch=True, - ) - - mrich.var("database", self.db_path) - mrich.var("max_lead_time", self.max_lead_time) - mrich.var("suppliers", self.suppliers) - - self._db = db - - # JSON I/O set up - self._data_path = Path(path) - - # Route pool - from .recipe import RouteSet - - self._route_pool = RouteSet.from_json(path=None, data=data["route_pool"], db=db) - - return self - - ### PROPERTIES - - @property - def route_pool(self): - """Get the RouteSet of all product reaction routes considered by this generator""" - return self._route_pool - - ### POOL METHODS - - def get_route_pool(self, mini_test=False): - """Construct the pool of routes that will be randomly sampled from - - :param mini_test: (Default value = False) - - """ - - """ - Explainer for SQL query: - - - get table of quoted compounds with a count of the valid suppliers - - join routes, components, and the new table together and grouped by route count the unavailable reactants - - return route ids where no reactants are unavailable - - """ - - if "route" not in self.db.table_names: - mrich.error("route table not in Database") - raise NotImplementedError - - assert self.suppliers_str - if self.max_lead_time: - raise NotImplementedError - - ### EXCLUDE PRODUCTS OF ROUTES IN STARTING RECIPE!!! - - sql = f""" - WITH possible_reactants AS ( - SELECT quote_compound, COUNT(CASE WHEN quote_supplier IN {self.suppliers_str} THEN 1 END) AS [count_valid] - FROM quote - GROUP BY quote_compound - ), - - route_reactants AS ( - SELECT route_id, route_product, - COUNT( - CASE - WHEN count_valid = 0 THEN 1 - WHEN count_valid IS NULL THEN 1 - END) - AS [count_unavailable] FROM route - INNER JOIN component ON component_route = route_id - LEFT JOIN possible_reactants ON quote_compound = component_ref - WHERE component_type = 2 - GROUP BY route_id - ) - - SELECT route_id FROM route_reactants - WHERE count_unavailable = 0 - """ - - route_ids = self.db.execute(sql).fetchall() - - route_ids = [i for i, in route_ids] - - if mini_test: - route_ids = route_ids[:100] - - from .recipe import RouteSet - - return RouteSet.from_ids(self.db, route_ids) - - ### FILE I/O METHODS - - def dump_data(self): - """Dump data to JSON""" - - data = {} - - data["db_path"] = str(self.db_path.resolve()) - data["recipe_dir"] = str(self.recipe_dir.resolve()) - data["max_lead_time"] = self.max_lead_time - data["suppliers"] = self.suppliers - data["starting_recipe"] = self.starting_recipe.get_dict(serialise_price=True) - data["route_pool"] = self.route_pool.get_dict() - - mrich.writing(self.data_path) - json.dump(data, open(self.data_path, "wt"), indent=4) - - def generate( - self, - budget: float = 10000, - currency: str = "EUR", - max_products: int = 1000, - max_reactions: int = 1000, - debug: bool = False, - max_iter: int | None = None, - shuffle: bool = True, - balance_clusters: bool = False, - permitted_clusters: None | set = None, - ): - """Generate random recipe - - :param budget: maximum budget (Default value = 10000) - :param currency: currency (Default value = 'EUR') - :param max_products: maximum number of products (Default value = 1000) - :param max_reactions: maximum number of reactions (Default value = 1000) - :param debug: increase verbosity for debugging (Default value = True) - :param max_iter: maximum number of iterations (Default value = None) - :param shuffle: randomly shuffle recipe pool (Default value = True) - :param balance_clusters: balance selection across scaffold clusters (Default value = False) - :param permitted_clusters: restrict selection to provided set of clusters (Default value = False) - """ - - # construct filename - - out_file = self.recipe_dir / f"Recipe_{dt_hash()}.json" - - from .price import Price - - if not max_iter: - max_iter = max_products + max_reactions - - max_iter = min(max_iter, len(self.route_pool)) - - budget = Price(budget, currency) - - recipe = self.starting_recipe.copy() - - recipe.reactants._supplier = self.suppliers - - # get the RouteSet - pool = self.route_pool.copy() - - assert len(pool), "Route pool is empty!" - - if shuffle: - mrich.debug("Shuffling Route pool") - pool.shuffle() - - old_recipe = recipe.copy() - - mrich.var("route pool", len(pool)) - mrich.var("max_iter", max_iter) - - for i in mrich.track(range(max_iter), prefix="Generating Recipe..."): - - if debug: - mrich.title(f"Iteration {i}") - - price = recipe.price - mrich.set_progress_field("price", str(price)) - mrich.set_progress_field("#products", len(recipe.products)) - - if debug: - mrich.var("price", price) - - # pop a route - if balance_clusters: - candidate_route = pool.balanced_pop( - permitted_clusters=permitted_clusters - ) - else: - candidate_route = pool.pop() - - if debug: - mrich.var("candidate_route", candidate_route) - if debug: - mrich.var("candidate_route.reactants", candidate_route.reactants.ids) - - if candidate_route.product in recipe.products: - continue - - # add the route to the recipe - if debug: - mrich.var("#recipe.reactants", len(recipe.reactants)) - recipe += candidate_route - if debug: - mrich.var("#recipe.reactants", len(recipe.reactants)) - - # calculate the new price - try: - new_price = recipe.price - except AssertionError: - mrich.error( - f"Something went wrong while calculating the price after adding {candidate_route=} to recipe" - ) - raise - - if debug: - mrich.var("new price", new_price) - - # Break if product pool depleted - if not len(pool): - stop_reason = "Product pool depleted" - mrich.success(stop_reason) - break - - # check breaking conditions - if new_price > budget: - recipe = old_recipe.copy() - continue - - if len(recipe.reactions) > max_reactions: - stop_reason = "Max #reactions exceeded" - mrich.success(stop_reason) - break - - if len(recipe.products) > max_products: - stop_reason = "Max #products exceeded" - mrich.success(stop_reason) - break - - # accept change - old_recipe = recipe.copy() - - else: - stop_reason = "Max #iterations reached" - mrich.warning(stop_reason) - - ### recalculate the products to see if any extra can be had for free? - - mrich.success(f"Completed after {i} iterations") - - metadict = { - "rgen_data_path": str(self.data_path.resolve()), - "rgen_db_path": str(self.db_path.resolve()), - "rgen_recipe_dir": str(self.recipe_dir.resolve()), - "rgen_max_lead_time": self.max_lead_time, - "rgen_suppliers": self.suppliers, - "gen_budget": budget.amount, - "gen_currency": budget.currency, - "gen_max_products": max_products, - "gen_max_reactions": max_reactions, - "gen_max_iter": max_iter, - "gen_shuffle": shuffle, - "gen_iterations": i, - "gen_stop_reason": stop_reason, - "gen_recipe_path": str(out_file.resolve()), - } - - # write the Recipe JSON - recipe.write_json(out_file, extra=metadict) - - return recipe - - ### DUNDERS - - def __str__(self) -> str: - """Unformatted string representation""" - return f"RandomRecipeGenerator(recipe_dir={self.recipe_dir})" - - -class RandomSelectionGenerator(RRGMixin): - """Class to create randomly sampled (no-chemistry) Recipe from a HIPPO Database""" - - def __init__( - self, - db, - *, - # max_lead_time=None, - suppliers: list | None = None, - amount: float = 1.0, # in mg - start_with: Recipe | CompoundSet | IngredientSet = None, - compounds: CompoundSet | None = None, - quoted_only: bool = True, - ): - """RandomSelectionGenerator initialisation""" - - mrich.debug("RandomRecipeGenerator.__init__()") - - # Static parameters - self._db_path = db.path - self._suppliers = suppliers - self._amount = amount - self._quoted_only = quoted_only - self._db = db - - mrich.var("database", self.db_path) - mrich.var("suppliers", self.suppliers) - mrich.var("amount per compound", self.amount, unit="mg") - mrich.var("quoted_only", self.quoted_only) - - self.get_starting_recipe(start_with) - mrich.var("starting recipe", self.starting_recipe) - - # JSON I/O set up - self._data_path = Path(str(self.db_path.name).replace(".sqlite", "_sgen.json")) - if self.data_path.exists(): - mrich.warning(f"Will overwrite existing rgen data file: {self.data_path}") - - # Recipe I/O set up - path = Path(str(self.db_path.name).replace(".sqlite", "_selections")) - mrich.writing(f"{path}/") - path.mkdir(exist_ok=True) - self._recipe_dir = path - - with mrich.spinner("Getting compound pool"): - self.get_compound_pool(compounds) - mrich.var("compound pool", self.compound_pool) - - # dump data - self.dump_data() - - ### FACTORIES - - @classmethod - def from_json( - cls, db: "Database", path: "Path | str" - ) -> "RandomSelectionGenerator": - """Construct the RandomRecipeGenerator from a JSON file""" - - data = json.load(open(path, "rt")) - - self = cls.__new__(cls) - - self._db_path = Path(data["db_path"]) - self._recipe_dir = Path(data["recipe_dir"]) - # self._max_lead_time = data["max_lead_time"] - self._suppliers = data["suppliers"] - self._amount = data["amount"] - - self._starting_recipe = Recipe.from_json( - db=db, - path=None, - data=data["starting_recipe"], - allow_db_mismatch=True, - ) - - mrich.var("database", self.db_path) - mrich.var("suppliers", self.suppliers) - mrich.var("amount", self.amount) - mrich.var("starting_recipe", self.starting_recipe) - - self._db = db - - # JSON I/O set up - self._data_path = Path(path) - - # Route pool - self._compound_pool = IngredientSet.from_json( - path=None, data=data["compound_pool"]["data"], db=db - ) - mrich.var("compound_pool", self.compound_pool) - - return self - - ### PROPERTIES - - @property - def amount(self) -> float: - """Amount to quote each compound for""" - return self._amount - - @property - def quoted_only(self) -> bool: - """Only consider compounds with quotes""" - return self._quoted_only - - @property - def compound_pool(self) -> "CompoundTable | CompoundSet": - """The pool of compounds that will be chosen from""" - return self._compound_pool - - ### METHODS - - def get_starting_recipe( - self, start_with: "Recipe | CompoundSet | IngredientSet" - ) -> Recipe: - """Process start_with into Recipe object""" - - if isinstance(start_with, Recipe): - if start_with.type != "NOCHEM": - raise NotImplementedError("Only NOCHEM recipes are supported") - self._starting_recipe = start_with - return self._starting_recipe - - from .compound import Compound - - self._starting_recipe = Recipe(self.db) - - if start_with is not None: - for item in start_with: - if isinstance(item, Compound): - item = item.as_ingredient(amount=self._amount) - - self._starting_recipe.compounds.add(item) - - return self._starting_recipe - - def get_compound_pool( - self, compounds: CompoundSet | None - ) -> "CompoundTable | CompoundSet": - """Get pool of compounds to select from""" - - if self.suppliers: - raise NotImplementedError - - if compounds is None: - - # all compounds - if not self.quoted_only: - ids = self.db.select( - table="compound", query="compound_id", multiple=True - ) - self._compound_pool = IngredientSet.from_compounds( - db=self.db, ids=[i for i, in ids], amount=self.amount - ) - return self._compound_pool - - # get all compounds that have a quote - - sql = f""" - SELECT quote_id, quote_compound, quote_amount, quote_supplier, MIN(quote_price) - FROM quote - WHERE quote_amount >= {self.amount} - GROUP BY quote_compound - """ - - records = self.db.execute(sql).fetchall() - - ingredients = [ - dict( - quote_id=i, - compound_id=c, - amount=self.amount, - quoted_amount=a, - supplier=None, - max_lead_time=None, - ) - for i, c, a, s, p in records - ] - - self._compound_pool = IngredientSet.from_ingredient_dicts( - self.db, ingredients - ) - - else: - - # ignore quoting - if not self.quoted_only: - self._compound_pool = IngredientSet.from_compounds( - db=self.db, ids=compounds.ids, amount=self.amount - ) - return self._compound_pool - - # get all compounds that have a quote - - sql = f""" - SELECT quote_id, quote_compound, quote_amount, quote_supplier, MIN(quote_price) - FROM quote - WHERE quote_amount >= {self.amount} - AND quote_compound IN {compounds.str_ids} - GROUP BY quote_compound - """ - - records = self.db.execute(sql).fetchall() - - ingredients = [ - dict( - quote_id=i, - compound_id=c, - amount=self.amount, - quoted_amount=a, - supplier=None, - max_lead_time=None, - ) - for i, c, a, s, p in records - ] - - self._compound_pool = IngredientSet.from_ingredient_dicts( - self.db, ingredients - ) - - def dump_data(self): - """Dump data to JSON""" - - data = {} - - data["db_path"] = str(self.db_path.resolve()) - data["recipe_dir"] = str(self.recipe_dir.resolve()) - # data["max_lead_time"] = self.max_lead_time - data["amount"] = self.amount - data["suppliers"] = self.suppliers - data["starting_recipe"] = self.starting_recipe.get_dict(serialise_price=True) - data["compound_pool"] = self.compound_pool.get_dict() - - mrich.writing(self.data_path) - json.dump(data, open(self.data_path, "wt"), indent=4) - - def generate( - self, - budget: float = 10000, - currency: str = "EUR", - max_iter: int | None = None, - max_compounds: int = 1000, - debug: bool = False, - shuffle: bool = True, - ): - """Generate random selection - - :param budget: maximum budget - :param currency: currency - :param max_iter: maximum number of iterations - :param max_compounds: maximum number of compounds - :param debug: Increase verbosity for debugging - :param shuffle: Randomise order of compound pool - """ - - # construct filename - - out_file = self.recipe_dir / f"Recipe_{dt_hash()}.json" - - from .price import Price - - budget = Price(budget, currency) - - recipe = self.starting_recipe.copy() - - recipe.compounds._supplier = self.suppliers - - # get the RouteSet - pool = self.compound_pool.copy() - - assert len(pool), "Route pool is empty!" - - if shuffle: - mrich.debug("Shuffling Route pool") - pool.shuffle() - - old_recipe = recipe.copy() - - if not max_iter: - max_iter = max_compounds * 3 - - mrich.var("compound pool", pool) - mrich.var("max_compounds", max_compounds) - mrich.var("max_iter", max_iter) - - for i in mrich.track(range(max_iter), prefix="Generating Recipe..."): - - if debug: - mrich.title(f"Iteration {i}") - - price = recipe.price - mrich.set_progress_field("price", str(price)) - mrich.set_progress_field("#compounds", len(recipe.compounds)) - - if debug: - mrich.var("price", price) - - # # pop a route - # if balance_clusters: - # candidate_route = pool.balanced_pop( - # permitted_clusters=permitted_clusters - # ) - # else: - candidate = pool.pop() - - if debug: - mrich.var("candidate", candidate) - - if candidate in recipe.compounds: - continue - - # add the route to the recipe - recipe.compounds.add(candidate) - - # calculate the new price - try: - new_price = recipe.price - except AssertionError: - mrich.error( - f"Something went wrong while calculating the price after adding {candidate_route=} to recipe" - ) - raise - - if debug: - mrich.var("#compounds", recipe.num_compounds) - mrich.var("new price", new_price) - - # Break if product pool depleted - if not len(pool): - stop_reason = "Compound pool depleted" - mrich.success(stop_reason) - break - - # check breaking conditions - if new_price > budget: - recipe = old_recipe.copy() - continue - - if len(recipe.compounds) > max_compounds: - stop_reason = "Max #compounds exceeded" - mrich.success(stop_reason) - break - - # accept change - old_recipe = recipe.copy() - - else: - stop_reason = "Max #iterations reached" - mrich.warning(stop_reason) - - ### recalculate the products to see if any extra can be had for free? - - mrich.success(f"Completed after {i} iterations") - - metadict = { - "rgen_data_path": str(self.data_path.resolve()), - "rgen_db_path": str(self.db_path.resolve()), - "rgen_recipe_dir": str(self.recipe_dir.resolve()), - "rgen_suppliers": self.suppliers, - "rgen_amount": self.amount, - "gen_budget": budget.amount, - "gen_currency": budget.currency, - "gen_max_compounds": max_compounds, - "gen_shuffle": shuffle, - "gen_iterations": i, - "gen_stop_reason": stop_reason, - "gen_recipe_path": str(out_file.resolve()), - } - - # write the Recipe JSON - recipe.write_json(out_file, extra=metadict) - - return recipe - - ### DUNDERS - - def __str__(self) -> str: - """Unformatted string representation""" - return f"RandomSelectionGenerator(recipe_dir={self.recipe_dir})" diff --git a/hippo/rset.py b/hippo/rset.py deleted file mode 100644 index a2f8cb20..00000000 --- a/hippo/rset.py +++ /dev/null @@ -1,728 +0,0 @@ -"""Classes for working with sets of :class:`.Reaction` objects""" - -import mcol -import mrich - -import os -from numpy import int64 - -from .db import Database -from .reaction import Reaction - - -class ReactionTable: - """Class representing all :class:`.Reaction` objects in the 'reaction' table of the :class:`.Database`. - - .. attention:: - - :class:`.ReactionTable` objects should not be created directly. Instead use the :meth:`.HIPPO.reactions` property. See :doc:`getting_started`. - - Use as an iterable - ================== - - Iterate through :class:`.Reaction` objects in the table: - - :: - - for reaction in animal.reactions: - ... - - - Selecting reactions in the table - ================================ - - The :class:`.ReactionTable` can be indexed with :class:`.Reaction` ID, or list/sets/tuples/slices thereof: - - :: - - rtable = animal.reactions - - # indexing individual compounds - reaction = rtable[13] # using the ID - - # getting a subset of compounds - rset = rtable[13,15,18] # using IDs (tuple) - rset = rtable[[13,15,18]] # using IDs (list) - rset = rtable[set(13,15,18)] # using IDs (set) - rset = rtable[13:18] # using a slice - - """ - - _name = "all reactions" - - def __init__( - self, - db: Database, - table: str = "reaction", - ) -> None: - """ReactionTable initialisation""" - - self._db = db - self._table = table - - ### PROPERTIES - - @property - def db(self) -> "Database": - """Returns the associated :class:`.Database`""" - return self._db - - @property - def table(self) -> str: - """Returns the name of the :class:`.Database` table""" - return self._table - - @property - def name(self) -> str | None: - """Returns the name of set""" - return self._name - - @property - def types(self) -> list[str]: - """Returns a list of the unique reaction types present in the table""" - result = self.db.select( - table=self.table, query="DISTINCT reaction_type", multiple=True - ) - return [q for q, in result] - - @property - def ids(self) -> list[int]: - """Returns the IDs of child reactions""" - result = self.db.select(table=self.table, query="reaction_id", multiple=True) - return [q for q, in result] - - ### METHODS - - def interactive(self) -> None: - """Interactive widget to navigate reactions in the table - - .. attention:: - - This method instantiates a :class:`.ReactionSet` containing all poses, it is recommended to instead select a subset for display. This method is only intended for use within a Jupyter Notebook. - - """ - return self[self.ids].interactive() - - def get_by_type(self, reaction_type: str) -> "ReactionSet": - """Get all child reactions of the given type - - :param reaction_type: reaction type to filter by - - """ - result = self.db.select_where( - table=self.table, - query="reaction_id", - key="type", - value=reaction_type, - multiple=True, - ) - rset = self[[q for q, in result]] - rset._name = f"all {reaction_type} reactions" - return rset - - def get_df(self, *, smiles: bool = True, mols: bool = True) -> "pandas.DataFrame": - """Construct a pandas.DataFrame of all reactions in the database - - :param smiles: Include smiles column (Default value = True) - :param mols: Include `rdkit.Chem.Mol` column (Default value = True) - - """ - - from rdkit.Chem import Mol - from pandas import DataFrame - - ### SQL QUERY - - data = {} - - if not smiles and not mols: - - sql = "SELECT reaction_id, reaction_type, reaction_product, reactant_compound FROM reaction INNER JOIN reactant ON reaction.reaction_id = reactant.reactant_reaction" - - triples = self.db.execute(sql).fetchall() - - for reaction_id, product_id, reactant_id in triples: - if reaction_id not in data: - data[reaction_id] = dict(product_id=product_id, reactant_ids=[]) - else: - assert data[reaction_id]["product_id"] == product_id - - data[reaction_id]["reactant_ids"].append(reactant_id) - - else: - - sql = """ - SELECT {query} - FROM reaction - - INNER JOIN reactant - ON reaction.reaction_id = reactant.reactant_reaction - - INNER JOIN compound c_r - ON c_r.compound_id = reactant.reactant_compound - - INNER JOIN compound c_p - ON c_p.compound_id = reaction.reaction_product - """ - - if not mols: - sql = sql.format( - query="reaction_id, reaction_type, reaction_product, reactant_compound, c_p.compound_smiles, c_r.compound_smiles" - ) - - else: - sql = sql.format( - query="reaction_id, reaction_type, reaction_product, reactant_compound, c_p.compound_smiles, c_r.compound_smiles, mol_to_binary_mol(c_p.compound_mol), mol_to_binary_mol(c_r.compound_mol)" - ) - - results = self.db.execute(sql).fetchall() - - for result in results: - - ( - reaction_id, - reaction_type, - product_id, - reactant_id, - product_smiles, - reactant_smiles, - ) = result[:6] - - if mols: - product_mol, reactant_mol = result[6:] - - if reaction_id not in data: - data[reaction_id] = dict( - reaction_id=reaction_id, - reaction_type=reaction_type, - product_id=product_id, - reactant_ids=set(), - product_smiles=product_smiles, - reactant_smiles=set(), - ) - if mols: - data[reaction_id]["product_mol"] = Mol(product_mol) - data[reaction_id]["reactant_mols"] = set() - else: - assert data[reaction_id]["product_id"] == product_id - - data[reaction_id]["reactant_ids"].add(reactant_id) - data[reaction_id]["reactant_smiles"].add(reactant_smiles) - if mols: - data[reaction_id]["reactant_mols"].add(Mol(reactant_mol)) - - data = data.values() - return DataFrame(data) - - def set_product_yields( - self, *, type: str, product_yield: float, commit: bool = True - ) -> None: - """Set the product_yield for all member :class:`.Reaction` entries with given type - - :param type: the :class:`.Reaction` type to filter by - :param product_yield: the :class:`.Reaction` product_yield to assign - - """ - - assert isinstance(product_yield, float) - assert product_yield > 0 - assert product_yield <= 1.0 - - sql = f""" - UPDATE reaction - SET reaction_product_yield = :reaction_product_yield - WHERE reaction_type = :reaction_type; - """ - - self.db.execute( - sql, - dict( - reaction_product_yield=product_yield, - reaction_type=type, - ), - ) - - if commit: - self.db.commit() - - ### DUNDERS - - def __getitem__(self, key) -> "Reaction | ReactionSet | None": - """Get a member :class:`.Reaction` object or subset :class:`.ReactionSet` thereof. - - :param key: Can be an integer ID, negative integer index, list/set/tuple of IDs, or slice of IDs - - """ - - match key: - - case int(): - - if key == 0: - return self.__getitem__(key=1) - - if key < 0: - key = len(self) + 1 + key - return self.__getitem__(key=key) - - else: - return self.db.get_reaction(id=key) - - case key if ( - isinstance(key, list) or isinstance(key, tuple) or isinstance(key, set) - ): - return ReactionSet(self.db, key) - - case slice(): - ids = self.db.slice_ids( - table=self.table, start=key.start, stop=key.stop, step=key.step - ) - return self[ids] - - case _: - mrich.error( - f"Unsupported type for ReactionTable.__getitem__(): {key=} {type(key)}" - ) - - return None - - def __str__(self) -> str: - """Unformatted string representation""" - - if self.name: - s = f"{self.name}: " - else: - s = "" - - s += "{" f"R × {len(self)}" "}" - - return s - - def __repr__(self) -> str: - """ANSI Formatted string representation""" - return f"{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}" - - def __rich__(self) -> str: - """Rich Formatted string representation""" - return f"[bold underline]{self}" - - def __len__(self) -> int: - """Number of reactions in this set""" - return self.db.count(self.table) - - def __iter__(self): - """Iterate through poses in this set""" - return iter(self[i + 1] for i in range(len(self))) - - def __call__( - self, - *, - type: str = None, - ) -> "ReactionSet": - """Filter reactions by a given type - - :param type: reaction type to filter by - :returns: :class:`.ReactionSet` - - """ - - if type: - return self.get_by_type(type) - else: - mrich.error("Must provide type argument") - return None - - -class ReactionSet: - """Object representing a subset of the 'reaction' table in the :class:`.Database`. - - .. attention:: - - :class:`.ReactionSet` objects should not be created directly. Instead use the :meth:`.HIPPO.reactions` property. See :doc:`getting_started` and :doc:`insert_elaborations`. - - Use as an iterable - ================== - - Iterate through :class:`.Reaction` objects in the set: - - :: - - rset = animal.reactions[:100] - - for reaction in rset: - ... - - Check membership - ================ - - To determine if a :class:`.Reaction` is present in the set: - - :: - - is_member = reaction in cset - - Selecting compounds in the set - ============================== - - The :class:`.ReactionSet` can be indexed like standard Python lists by their indices - - :: - - rset = animal.reactions[1:100] - - # indexing individual compounds - reaction = rset[0] # get the first reaction - reaction = rset[1] # get the second reaction - reaction = rset[-1] # get the last reaction - - # getting a subset of compounds using a slice - rset2 = rset[13:18] # using a slice - - """ - - _table = "reaction" - - def __init__( - self, - db: Database, - indices: list = None, - *, - sort: bool = True, - name: str | None = None, - ) -> None: - """ReactionSet initialisation""" - - self._db = db - indices = indices or [] - - if not isinstance(indices, list): - indices = list(indices) - - assert all(isinstance(i, int) or isinstance(i, int64) for i in indices) - - if sort: - self._indices = sorted(list(set(indices))) - else: - self._indices = list(set(indices)) - - self._name = name - - ### PROPERTIES - - @property - def db(self) -> Database: - """Returns the associated :class:`.Database`""" - return self._db - - @property - def table(self) -> str: - """Returns the name of the :class:`.Database` table""" - return self._table - - @property - def name(self) -> str | None: - """Returns the name of set""" - return self._name - - @property - def indices(self) -> list[int]: - """Returns the ids of reactions in this set""" - return self._indices - - @property - def ids(self) -> list[int]: - """Returns the ids of reactions in this set""" - return self._indices - - @property - def types(self) -> list[str]: - """Returns the types of reactions in this set""" - records = self.db.select_where( - table="reaction", - key=f"reaction_id IN {self.str_ids}", - query="DISTINCT reaction_type", - multiple=True, - ) - return [t for t, in records] - - @property - def num_types(self) -> int: - """Returns the number of reaction types in this set""" - (count,) = self.db.select_where( - table="reaction", - key=f"reaction_id IN {self.str_ids}", - query="COUNT(DISTINCT reaction_type)", - ) - return count - - @property - def str_ids(self) -> str: - """Return an SQL formatted tuple string of the :class:`.Compound` IDs""" - return str(tuple(self.ids)).replace(",)", ")") - - @property - def products(self) -> "CompoundSet": - """Get all product compounds that can be synthesised with these reactions (no intermediates)""" - from .cset import CompoundSet - - intermediates = self.intermediates - product_ids = self.db.execute( - f""" - SELECT compound_id FROM compound - INNER JOIN reaction ON compound_id = reaction_product - WHERE reaction_id IN {self.str_ids} - AND compound_id NOT IN {intermediates.str_ids} - """ - ).fetchall() - cset = CompoundSet(self.db, [i for i, in product_ids]) - if self.name: - cset._name = f"products of {self}" - return cset - - @property - def intermediates(self) -> "CompoundSet": - """Get all intermediate compounds that can be synthesised with these reactions""" - from .cset import CompoundSet - - sql = f""" - SELECT DISTINCT compound_id FROM compound - INNER JOIN reaction ON compound_id = reaction_product - INNER JOIN reactant ON compound_id = reactant_compound - WHERE reactant_reaction IN {self.str_ids} - """ - intermediate_ids = self.db.execute(sql).fetchall() - cset = CompoundSet(self.db, [i for i, in intermediate_ids]) - if self.name: - cset._name = f"intermediates of {self}" - return cset - - @property - def reactants(self) -> "CompoundSet": - """Get all reactant compounds that are used by these reactions""" - from .cset import CompoundSet - - sql = f""" - SELECT DISTINCT reactant_compound FROM reactant - WHERE reactant_reaction IN {self.str_ids} - """ - reactant_ids = self.db.execute(sql).fetchall() - cset = CompoundSet(self.db, [i for i, in reactant_ids]) - if self.name: - cset._name = f"reactants of {self}" - return cset - - ### METHODS - - def add(self, r: Reaction) -> None: - """Add a :class:`.Reaction` to this set - - :param r: :class:`.Reaction` to be added - - """ - assert isinstance(r, Reaction) - if (id := r.id) not in self._indices: - self._indices.append(id) - - def interactive(self): - """Creates a ipywidget to interactively navigate this PoseSet.""" - - from ipywidgets import ( - interactive, - BoundedIntText, - Checkbox, - interactive_output, - HBox, - GridBox, - Layout, - VBox, - ) - from IPython.display import display - from pprint import pprint - - a = BoundedIntText( - value=0, - min=0, - max=len(self) - 1, - step=1, - description=f"Rs (/{len(self)}):", - disabled=False, - ) - - b = Checkbox(description="Name", value=True) - c = Checkbox(description="Summary", value=False) - d = Checkbox(description="Draw", value=True) - e = Checkbox(description="Check chemistry", value=False) - f = Checkbox(description="Reactant Quotes", value=False) - - ui1 = GridBox( - [b, c, d], layout=Layout(grid_template_columns="repeat(5, 100px)") - ) - ui2 = GridBox([e, f], layout=Layout(grid_template_columns="repeat(2, 150px)")) - ui = VBox([a, ui1, ui2]) - - def widget( - i, name=True, summary=True, draw=True, check_chemistry=True, reactants=False - ): - """ - - :param i: - :param name: (Default value = True) - :param summary: (Default value = True) - :param draw: (Default value = True) - :param check_chemistry: (Default value = True) - :param reactants: (Default value = False) - - """ - reaction = self[i] - if name: - print(repr(reaction)) - if summary: - reaction.summary(draw=False) - if draw: - reaction.draw() - if check_chemistry: - reaction.check_chemistry(debug=True) - if reactants: - for comp in reaction.reactants: - # if summary: - # comp.summary(draw=False) - # elif name: - print(repr(comp)) - - quotes = comp.get_quotes(df=True) - display(quotes) - - # break - - # if draw: - # comp.draw() - - out = interactive_output( - widget, - { - "i": a, - "name": b, - "summary": c, - "draw": d, - "check_chemistry": e, - "reactants": f, - }, - ) - - display(ui, out) - - def get_df(self, smiles=True, mols=True, **kwargs) -> "pandas.DataFrame": - """Construct a pandas.DataFrame of this ReactionSet - - :param smiles: Include smiles column (Default value = True) - :param mols: Include `rdkit.Chem.Mol` column (Default value = True) - :param kwargs: keyword arguments are passed on to :meth:`.Reaction.get_dict: - - """ - - from pandas import DataFrame - from rdkit.Chem import Mol - - mrich.debug("Using slower Reaction.dict rather than direct SQL query...") - - data = [] - for r in mrich.track(self, prefix="ReactionSet --> DataFrame"): - data.append(r.get_dict(smiles=smiles, mols=mols, **kwargs)) - - return DataFrame(data) - - def copy(self) -> "ReactionSet": - """Return a copy of this set""" - return ReactionSet(self.db, self.ids, sort=False, name=self.name) - - def get_recipes( - self, amounts: float | list[float] = 1.0, **kwargs - ) -> "Recipe | list[Recipe]": - """Get the :class:`.Recipe` object(s) from this set of recipes - - :param amounts: float or list/generator of product amounts in mg, (Default value = 1.0) - :param kwargs: keyword arguments are passed on to :meth:`.Recipe.from_reactions: - - """ - from .recipe import Recipe - - return Recipe.from_reactions(db=self.db, reactions=self, amounts=1, **kwargs) - - def reverse(self) -> None: - """In-place reversal of indices""" - self._indices = list(reversed(self._indices)) - - def get_dict(self) -> dict[str]: - """Serializable dictionary""" - return dict(db=str(self.db), indices=self.indices) - - def summary(self) -> None: - """Print a summary of the Reactions""" - - mrich.header(self) - for reaction in self: - print(repr(reaction)) - - ### DUNDERS - - def __str__(self) -> str: - """Unformatted string representation""" - - if self.name: - s = f"{self.name}: " - else: - s = "" - - s += "{" f"R × {len(self)}" "}" - - return s - - def __repr__(self) -> str: - """ANSI Formatted string representation""" - return f"{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}" - - def __rich__(self) -> str: - """Rich Formatted string representation""" - return f"[bold underline]{self}" - - def __len__(self) -> int: - """Number of member :class:`.Reaction` objects""" - return len(self.indices) - - def __iter__(self): - """Iterate through member :class:`.Reaction` objects""" - return iter(self.db.get_reaction(id=i) for i in self.indices) - - def __getitem__(self, key) -> "Reaction | ReactionSet": - """Get member :class:`.Reaction` object by single, slice or list/set/tuple of ID""" - - match key: - case int(): - try: - index = self.indices[key] - except IndexError: - mrich.error(f"list index out of range: {key=} for {self}") - raise - return self.db.get_reaction(id=index) - case slice(): - ids = self.ids[key] - return ReactionSet(self.db, ids) - case key if ( - isinstance(key, list) or isinstance(key, tuple) or isinstance(key, set) - ): - ids = self.ids[key] - return ReactionSet(self.db, ids) - case _: - mrich.error( - f"Unsupported type for ReactionSet.__getitem__(): {key=} {type(key)}" - ) - - return None - - def __add__(self, other: "ReactionSet") -> "ReactionSet": - """Add a :class:`.ReactionSet` to this one""" - if other: - for reaction in other: - self.add(reaction) - self._name = None - return self diff --git a/hippo/scoring.py b/hippo/scoring.py deleted file mode 100644 index b34eda59..00000000 --- a/hippo/scoring.py +++ /dev/null @@ -1,1081 +0,0 @@ -"""Classes for scoring Recipes""" - -import mrich - -import numpy as np -import pandas as pd -from scipy.interpolate import interp1d - -DATA_COLUMNS = [ - "score", - "price", - "compound_ids", - "pose_ids", - "interaction_ids", - "pose_metadata", -] - - -class Scorer: - """Create a scorer object to score sets of recipes - - :param db: :class:`.Database` - :param directory: path to directory containing recipe JSONs - :param pattern: glob pattern for :class:`.Recipe` JSON, default: "*.json" - :param attributes: attributes of :class:`.Recipe` objects to use for scoring - :param populate: Pre-populate query caches and child objects in memory (don't disable unless you have a good reason) - :param load_cache: Load cache from existing JSON - :param allowed_pose_ids: Restrict interaction and subsite calculations to these :class:`.Pose` IDs - """ - - def __init__( - self, - db: "Database", - directory: "Path | str", - pattern: str = "*.json", - attributes: list[str] = None, - populate: bool = True, - load_cache: bool = True, - allowed_poses: "PoseSet | list[int] | None" = None, - out_key: str = "scorer", - ) -> None: - """Scorer initialisation""" - - from .pset import PoseSet - from .recipe import RecipeSet - - self._db = db - self._out_key = out_key - - if allowed_poses is None: - self._allowed_pose_ids = None - elif isinstance(allowed_poses, PoseSet): - self._allowed_pose_ids = set(allowed_poses.ids) - else: - self._allowed_pose_ids = set(allowed_poses) - - attributes = attributes or [] - - recipes = RecipeSet(db, directory, pattern=pattern) - - self._recipes = recipes - - self._attributes = {} - - for key in attributes: - attribute = Attribute(self, key) - self._attributes[key] = attribute - - self._data = pd.DataFrame( - index=recipes.keys(), - columns=DATA_COLUMNS + self.attribute_keys, - ) - - self._data.replace({np.nan: None}, inplace=True), - - if populate: - if load_cache and self.json_path.exists(): - self._load_json() - else: - self._populate_query_cache() - - self._populate_recipe_child_sets() - - self.weights = 1.0 - - ### FACTORIES - - @classmethod - def default( - cls, - db: "Database", - directory: "Path | str", - pattern: str = "*.json", - skip: list[str] | None = None, - load_cache: bool = True, - subsites: bool = True, - allowed_poses: "PoseSet | list[int] | None" = None, - out_key: str = "scorer", - ) -> "Scorer": - """Create a Scorer instance with Default attributes""" - - from .recipe import RecipeSet - - self = cls.__new__(cls) - - attributes = [ - k for k, v in DEFAULT_ATTRIBUTES.items() if v["type"] == "standard" - ] - - self.__init__( - db=db, - directory=directory, - pattern=pattern, - attributes=attributes, - populate=False, - allowed_poses=allowed_poses, - out_key=out_key, - ) - - skip = skip or [] - - if not db.count("interaction"): - mrich.warning("No interactions in DB, skipping related metrics") - skip.append("interaction_count") - skip.append("interaction_balance") - - if not db.count("pose"): - mrich.warning("No poses in DB, skipping related metrics") - skip.append("num_inspirations") - skip.append("num_inspiration_sets") - skip.append("avg_energy_score") - skip.append("avg_distance_score") - - if not db.count("scaffold"): - mrich.warning("No scaffold entries in DB, skipping related metrics") - skip.append("num_scaffolds") - skip.append("num_scaffolds_elaborated") - skip.append("elaboration_balance") - - # custom attributes - for key, attribute in [ - (k, v) for k, v in DEFAULT_ATTRIBUTES.items() if v["type"] == "custom" - ]: - - if skip and key in skip: - continue - - if not subsites and "subsite" in key: - continue - - self.add_custom_attribute( - key, attribute["function"], weight_reset_warning=False - ) - - if load_cache and self.json_path.exists(): - self._load_json() - else: - self._populate_query_cache() - - self._populate_recipe_child_sets() - - # weights - wsum = sum(abs(d["weight"]) for d in DEFAULT_ATTRIBUTES.values()) - for attribute in self.attributes: - d = DEFAULT_ATTRIBUTES[attribute.key] - attribute.weight = d["weight"] / wsum - - return self - - ### PROPERTIES - - @property - def num_recipes(self) -> int: - """Number of recipes being evaluated""" - return len(self._recipes) - - @property - def attributes(self) -> "list[Attribute | CustomAttribute]": - """Return list of :class:`.Attribute` / :class:`.CustomAttribute` objects""" - return list(self._attributes.values()) - - @property - def attribute_keys(self) -> list[str]: - """Return list of :class:`.Attribute` / :class:`.CustomAttribute` names/keys""" - return list(self._attributes.keys()) - - @property - def recipes(self) -> "RecipeSet": - """Return :class:`.RecipeSet` of recipes being scored""" - return self._recipes - - @property - def num_attributes(self) -> int: - """Count of attributes""" - return len(self.attributes) - - @property - def weights(self) -> list[float]: - """List of attribute weights""" - return [a.weight for a in self.attributes] - - @weights.setter - def weights(self, ws) -> None: - """Setter for weights list""" - - self._flag_weight_modification() - - if isinstance(ws, float) or isinstance(ws, int): - ws = [ws] * self.num_attributes - - ws = [w for w in ws] - wsum = sum([abs(w) for w in ws]) - - for a, w in zip(self.attributes, ws): - a.weight = w / wsum - - @property - def score_dict(self) -> dict[str, float]: - """Dictionary of scores keyed by :meth:`.Recipe.hash`""" - - col = self._data["score"] - - null = col.isnull() - - if null.sum(): - mrich.debug("Calculating scores...") - for key in col[null].index.values: - recipe = self.recipes[key] - score = self.score(recipe) - self._data.at[key, "score"] = score - - self._dump_json() - - return col.to_dict() - - @property - def scores(self) -> list[float]: - """List of :class:`.Recipe` scores""" - return list(self.score_dict.values()) - - @property - def best(self) -> "Recipe": - """Return highest scoring :class:`.Recipe`""" - return self.top(1) - - @property - def db(self) -> "Database": - """:class:`.Database`""" - return self._db - - @property - def json_path(self) -> "Path": - """Path where cache will be written""" - from pathlib import Path - - return Path(self.db.path.name.replace(".sqlite", f"_{self._out_key}.json")) - - @property - def poses(self) -> "PoseSet": - """Return all associated poses as :class:`.PoseSet`""" - from .pset import PoseSet - - ids = set().union(*self._data["pose_ids"]) - return PoseSet(self.db, ids) - - ### METHODS - - def add_custom_attribute( - self, - key: str, - function: "Callable", - weight_reset_warning: bool = True, - ) -> "CustomAttribute": - """Add a custom scoring attribute - - :param key: name/key for the attribute - :param function: function call to get the attribute alue, will be passed :class:`.Recipe` object - :param weight_reset_warning: write a warning to indicate weights have been reset - """ - - ca = CustomAttribute(self, key, function) - - if key not in self._attributes: - - # self._flag_weight_modification() - - self._attributes[key] = ca - - if weight_reset_warning: - mrich.warning("Attribute weights have been reset") - self.weights = 1.0 - - self._data[key] = None - - else: - mrich.warning("Existing attribute with {key=}") - - return self._attributes[key] - - def add_recipes(self, json_paths: "list", debug: bool = False) -> None: - """Add more serialised :class:`.Recipe` objects to be scored - - :param json_paths: list of JSON paths - :param debug: increase verbosity for debugging - """ - - from pathlib import Path - from .recipe import Recipe - - for json_path in json_paths: - - path = Path(json_path) - - key = path.name.removeprefix("Recipe_").removesuffix(".json") - - if key in self.recipes: - mrich.warning(f"Skipping duplicate {path}") - continue - - recipe = Recipe.from_json(self._db, path, allow_db_mismatch=True) - - recipe._hash = key - - if debug: - mrich.debug(recipe) - - if debug: - mrich.debug("Updating Scorer.recipes._json_paths") - self.recipes._json_paths[key] = path.resolve() - - if debug: - mrich.debug("Updating Scorer.recipes._recipes") - self.recipes._recipes[key] = recipe - - self._data.loc[key] = None - - self._data.replace({np.nan: None}, inplace=True), - self._populate_query_cache() - self._populate_recipe_child_sets() - self._flag_weight_modification() - - def score( - self, - recipe: "Recipe", - *, - debug: bool = False, - ) -> float: - """Score a :class:`.Recipe` object - - :param recipe: :class:`.Recipe` to be scored - :param debug: increase verbosity for debugging - :returns: float score from 0 to 1 - """ - - score = 0.0 - - for attribute in self.attributes: - recipe_score = attribute(recipe) - score += recipe_score - - if debug: - print_data = [] - for attribute in self.attributes: - print_data.append( - dict( - key=attribute.key, - weight=f"{attribute.weight:.2f}", - value=f"{attribute.get_value(recipe):.2f}", - unweighted=f"{attribute.unweighted(recipe):.2%}", - weighted=f"{attribute(recipe):.2%}", - ) - ) - - df = pd.DataFrame(print_data).set_index("key") - mrich.print(df) - mrich.var("score", score) - - recipe._score = score - - return score - - def compare(self, recipes: "list[Recipe] | list[str]") -> None: - """Compare attribute values and scores for recipes - - :param recipes: list of :class:`.Recipe` objects or hashes - """ - - recipes = [ - self.recipes[recipe] if isinstance(recipe, str) else recipe - for recipe in recipes - ] - - print_data = [] - - for attribute in self.attributes: - d = {"attribute (weight)": f"{attribute.key} ({attribute.weight:.2%})"} - for recipe in recipes: - # d = dict(hash=recipe.hash) - d[recipe.hash] = ( - f"{attribute.get_value(recipe):.2f} ({attribute.unweighted(recipe):.2%})" - ) - print_data.append(d) - - df = pd.DataFrame(print_data).set_index("attribute (weight)") - mrich.print(df) - - def get_sorted_df(self) -> "pd.DataFrame": - """Get DataFrame sorted by descending score""" - - # compute scores - self.scores - - return self._data.sort_values(by="score", ascending=False) - - def plot( - self, - keys: list[str], - budget: float | None = None, - ) -> "plotly.graph_objects.Figure": - """Plot any two attributes as a scatter plot - - :param keys: list two attribute keys to plot - :param budget: limit :class:`.Recipe` objects to below this budget value - :returns: plotly Figure object containing a scatter trace - """ - - import plotly.express as px - - if len(keys) != 2: - mrich.error("Only two keys supported") - return None - - # calculate scores - self.scores - - df = self._data.drop( - columns=[ - "compound_ids", - "pose_ids", - "interaction_ids", - ] - ) - - df["score"] = pd.to_numeric(df["score"]) - - if isinstance(keys, str): - assert keys in df.columns - return px.histogram(df, x=keys) - - if not all(key in df.columns for key in keys): - for key in keys: - if key not in df.columns: - raise KeyError(f'no attribute/column named "{key}"') - - if budget: - df = df[df["price"] < budget] - - df["hash"] = df.index.values - - hover_data = [ - "hash", - ] - - hover_data += [c for c in df.columns] - - return px.scatter( - df, x=keys[0], y=keys[1], color="score", hover_data=hover_data - ) - - def top_keys(self, n: int, budget: float | None = None) -> list[str]: - """Return keys of top `n` scoring :class:`.Recipe` - - :param n: number of keys to return - :param budget: limit :class:`.Recipe` objects to below this budget value - :returns: list of :class:`.Recipe` hashes - """ - keys = self.get_sorted_df(budget=budget).index[:n] - return list(keys) - - def top(self, n: int, budget: float | None = None) -> "list[Recipe]": - """Return top `n` scoring :class:`.Recipe` - - :param n: number of :class:`.Recipe` objects to return - :param budget: limit :class:`.Recipe` objects to below this budget value - :returns: list of :class:`.Recipe` objects - """ - keys = self.top_keys(n=n, budget=budget) - if n == 1: - return [self.recipes[key] for key in keys][0] - else: - return [self.recipes[key] for key in keys] - - ### INTERNALS - - def _flag_weight_modification(self): - """Reset scores due to weight modification""" - self._data["score"] = None - - def summary(self) -> None: - """Print some summary statistics of the scorer's attributes""" - - mrich.header(self) - for attribute in self.attributes: - mrich.print( - attribute, - f"min={attribute.min:.3g}, mean={attribute.mean:.3g}, std={attribute.std:.3g}, max={attribute.max:.3g}", - ) - - def __check_integrity(self) -> bool: - """Check integrity of data""" - - n_recipes = len(self.recipes) - - for attribute in self.attributes: - assert len(attribute._value_dict) == n_recipes - - assert len(self._scores) == n_recipes - - assert len(self._data) == n_recipes - assert len(self._data.columns) == len(attributes) + len(DATA_COLUMNS) - - return True - - def _populate_query_cache(self) -> None: - """Update internal data with pre-fetched related database IDs""" - - from .cset import CompoundSet - from .pset import PoseSet - - df = self._data - - ### Recipe prices - - for recipe in self.recipes: - self._data.at[recipe.hash, "price"] = recipe.price.amount - - ### Compound IDs - - col = "compound_ids" - null = df[col].isnull() - - # populate missing product compound ids - if null.sum(): - mrich.debug(f'Populating _data["{col}"]...') - assert len(df[null]) == null.sum() - for key in df[null].index.values: - recipe = self.recipes[key] - df.at[key, col] = recipe.combined_compound_ids - - ### Pose IDs - - col = "pose_ids" - null = df[col].isnull() - - # populate missing product pose ids - if null.sum(): - - compound_ids = set() - for ids in df[null]["compound_ids"]: - for id in ids: - compound_ids.add(id) - - cset = CompoundSet(self.db, compound_ids, sort=False) - - mrich.debug(f"Getting poses for {len(cset)} compounds") - pose_map = self.db.get_compound_id_pose_ids_dict(cset) - - mrich.debug(f'Populating _data["{col}"]...') - for key in df[null].index.values: - assert len(df[null]) == null.sum() - recipe = self.recipes[key] - comp_ids = df["compound_ids"][key] - - all_pose_ids = set() - - for comp_id in comp_ids: - pose_ids = pose_map.get(comp_id, set()) - - if self._allowed_pose_ids: - pose_ids = set( - i for i in pose_ids if i in self._allowed_pose_ids - ) - - all_pose_ids |= pose_ids - - df.at[key, col] = all_pose_ids - - ### Interaction IDs - - col = "interaction_ids" - null = df[col].isnull() - - # populate missing product interaction ids - if null.sum(): - - pose_ids = set() - for ids in df[null]["pose_ids"]: - for id in ids: - pose_ids.add(id) - - pset = PoseSet(self.db, pose_ids, sort=False) - - mrich.debug(f"Getting interactions for {len(pset)} poses") - interaction_map = self.db.get_pose_id_interaction_ids_dict(pset) - - mrich.debug(f'Populating _data["{col}"]...') - for key in df[null].index.values: - assert len(df[null]) == null.sum() - recipe = self.recipes[key] - pose_ids = df["pose_ids"][key] - - all_interaction_ids = set() - - for pose_id in pose_ids: - interaction_ids = interaction_map.get(pose_id, set()) - all_interaction_ids |= interaction_ids - - df.at[key, col] = all_interaction_ids - - ### Metadata Dictionaries - - col = "pose_metadata" - null = df[col].isnull() - - # populate missing product interaction ids - if null.sum(): - - pose_ids = set() - for ids in df[null]["pose_ids"]: - for id in ids: - pose_ids.add(id) - - # pset = PoseSet(self.db, pose_ids, sort=False) - - mrich.debug(f"Getting metadata for {len(pose_ids)} poses") - metadata_lookup = self.db.get_id_metadata_dict(table="pose", ids=pose_ids) - - mrich.debug(f'Populating _data["{col}"]...') - for key in df[null].index.values: - assert len(df[null]) == null.sum() - recipe = self.recipes[key] - pose_ids = df["pose_ids"][key] - - row = df.loc[key] - - metadata = {} - for pose_id in pose_ids: - metadata[pose_id] = metadata_lookup[pose_id] - - df.at[key, col] = metadata - - def _populate_recipe_child_sets(self) -> None: - """Populate internal cache of recipe child compound/pose/interaction sets""" - - from .cset import CompoundSet - from .pset import PoseSet - from .iset import InteractionSet - - mrich.debug("Populating recipe caches") - for key, recipe in self.recipes.items(): - - row = self._data.loc[key] - - if recipe._combined_compounds is None: - ids = row["compound_ids"] - cache = CompoundSet(self.db, ids) - cache._name = f"Recipe_{key} products" - recipe._combined_compounds = cache - - if recipe._poses is None: - ids = row["pose_ids"] - cache = PoseSet(self.db, ids) - cache._name = f"Recipe_{key} poses" - recipe._poses = cache - - if recipe._interactions is None: - ids = row["interaction_ids"] - cache = InteractionSet(self.db, ids) - cache._name = f"Recipe_{key} product interactions" - recipe._interactions = cache - - if recipe._poses._metadata_dict is None: - cache = row["pose_metadata"] - recipe._poses._metadata_dict = cache - - def _dump_json(self) -> None: - """Write JSON cache to file""" - path = self.json_path - mrich.writing(path) - self._data.to_json(path) - - def _load_json(self): - """Load JSON cache from file""" - path = self.json_path - - mrich.reading(path) - cached = pd.read_json(path, orient="columns") - - if (cached_columns := set(cached.columns)) != ( - self_columns := set(self._data.columns) - ): - - for col in cached_columns - self_columns: - mrich.error(f"JSON has unexpected {col}") - - for col in self_columns - cached_columns: - mrich.error(f"JSON is missing {col}") - - display(cached.head()) - display(self._data.head()) - - raise ValueError("JSON columns don't match expectation") - - cached_keys = set(cached.index.values) - self_keys = set(self._data.index.values) - - if difference := cached_keys - self_keys: - mrich.warning("JSON has extra Recipes:") - mrich.warning(difference) - - if difference := self_keys - cached_keys: - mrich.error("JSON is missing Recipes:") - mrich.error(difference) - raise ValueError("JSON is missing Recipes") - - cached.replace({np.nan: None}, inplace=True), - - self._data = cached - - ### DUNDERS - - def __str__(self) -> str: - """Unformatted string representation""" - return f"Scorer(#recipes={self.num_recipes})" - - def __repr__(self) -> str: - """ANSI Formatted string representation""" - import mcol - - return f"{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}" - - def __rich__(self) -> str: - """Rich Formatted string representation""" - return f"[bold underline]{self}" - - -class Attribute: - """Scoring Attribute to be used with a :class:`.Scorer` object - - :param scorer: associated :class:`.Scorer` - :param key: key/name for the attribute - :param inverse: if true, lower values score higher - :param weight: adjust scores by this weight - :param bins: number of scoring bins - """ - - _type = "Attribute" - - ### DUNDERS - - def __init__( - self, - scorer: "Scorer", - key: str, - *, - inverse: bool = False, - weight: float = 1.0, - bins: int = 100, - ) -> None: - """Attribute initialisation""" - - self._scorer = scorer - - self._key = key - self._inverse = inverse - self._weight = weight - - self._value_dict = {} - - self._bins = bins - - self._percentile_interpolator = None - - ### PROPERTIES - - @property - def scorer(self) -> "Scorer": - """Get associated :class:`.Scorer`""" - return self._scorer - - @property - def key(self) -> str: - """Get name/key""" - return self._key - - @property - def inverse(self) -> bool: - """Is this attribute inverted, lower values will score higher if true""" - return self._inverse - - @property - def bins(self) -> int: - """Number of bins""" - return self._bins - - @property - def value_dict(self) -> dict[str, float]: - """Dictionary of attribute values keyed by :class:`.Recipe` hash""" - df = self.scorer._data[self.key] - - null = df.isnull() - - if null.sum(): - with mrich.loading(f"Constructing value dictionary for {self}"): - for key in df[null].index.values: - recipe = self.scorer.recipes[key] - self.get_value(recipe, force=True) - self.scorer._dump_json() - - return df.to_dict() - - @property - def values(self) -> list[float]: - """Return list of values""" - return list(self.value_dict.values()) - - @property - def mean(self) -> float: - """Return mean of value""" - return np.mean(self.values) - - @property - def std(self) -> float: - """Return standard deviation of values""" - return np.std(self.values) - - @property - def max(self) -> float: - """Return maximum of values""" - return max(self.values) - - @property - def min(self) -> float: - """Return minimum of values""" - return min(self.values) - - @property - def weight(self) -> float: - """Return weight""" - return self._weight - - @weight.setter - def weight(self, w): - """Set attribute weight""" - self.scorer._flag_weight_modification() - self._weight = abs(w) - self._reverse = w < 0 - - @property - def percentile_interpolator(self): - """Interpolator function""" - if self._percentile_interpolator is None: - - count, bins_count = np.histogram(self.values, bins=self.bins) - - pdf = count / sum(count) - cdf = np.cumsum(pdf) - self._percentile_interpolator = interp1d( - bins_count[1:], cdf, kind="linear", fill_value="extrapolate" - ) - - return self._percentile_interpolator - - ### METHODS - - def get_value( - self, - recipe: "Recipe", - serialise_price: bool = True, - force: bool = False, - ) -> float: - """Get value for a :class:`.Recipe` - - :param serialise_price: serialise :class:`.Price` objects to their amount - :param force: force calculation? (don't use cache) - """ - - if not force: - cached = self.scorer._data[self.key][recipe.hash] - - if force or cached is None: - value = getattr(recipe, self.key) - if serialise_price and self.key == "price": - value = value.amount - self.scorer._data.at[recipe.hash, self.key] = value - else: - return cached - - return value - - def histogram(self) -> "plotly.graph_objects.Figure": - """Plot histogram of attribute values""" - - import plotly.graph_objects as go - - fig = go.Figure(go.Histogram(x=self.values)) - fig.update_layout(xaxis_title=self.key, yaxis_title="count") - - return fig - - def unweighted( - self, - recipe: "Recipe", - ) -> float: - """Return unweighted percentile score for a given :class:`.Recipe`""" - - value = self.get_value(recipe) - - score = float(self.percentile_interpolator(value)) - - if self.inverse: - score = 1 - score - - return score - - ### DUNDERS - - def __call__( - self, - recipe: "Recipe", - ) -> float: - """return the weighted score of a given :class:`.Recipe`""" - - if not self.weight: - return 0.0 - - value = self.unweighted(recipe) - - return self.weight * value - - def __str__(self) -> str: - """Unformatted string representation""" - if self.weight is None: - return f'{self._type}("{self.key}", inverse={self.inverse})' - else: - return f'{self._type}("{self.key}", weight={self.weight:.2f}, inverse={self.inverse})' - - def __repr__(self) -> str: - """ANSI Formatted string representation""" - import mcol - - return f"{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}" - - def __rich__(self) -> str: - """Rich Formatted string representation""" - return f"[bold underline]{self}" - - -class CustomAttribute(Attribute): - """Scoring attribute with a custom function""" - - _type = "CustomAttribute" - - def __init__(self, scorer: "Scorer", key: str, function: "Callable") -> None: - """CustomAttribute initialisation""" - self._function = function - super(CustomAttribute, self).__init__(scorer=scorer, key=key) - - ### METHODS - - def get_value( - self, - recipe: "Recipe", - serialise_price: bool = True, - force: bool = False, - ) -> float: - """Compute custom attribute value for provided :class:`.Recipe` - - :param serialise_price: serialise :class:`.Price` objects to their amount - :param force: force calculation? (don't use cache) - """ - - if not force: - cached = self.scorer._data[self.key][recipe.hash] - - if force or cached is None: - - value = self._function(recipe) - - if serialise_price and self.key == "price": - value = value.amount - self.scorer._data.at[recipe.hash, self.key] = value - else: - return cached - - return value - - -DEFAULT_ATTRIBUTES = { - "num_scaffolds": dict( - type="custom", - weight=1.0, - function=lambda r: r.combined_compounds.count_by_tag(tag="Syndirella scaffold"), - description="The number of Syndirella scaffold compounds in this selection. Higher is better.", - ), - "num_compounds": dict( - type="standard", - weight=1.0, - description="The number of product compounds in this selection. Higher is better.", - ), - "num_scaffolds_elaborated": dict( - type="custom", - weight=1.0, - function=lambda r: r.combined_compounds.num_scaffolds_elaborated, - description="The number of Syndirella scaffold compounds that have at least one elaboration in this selection. Higher is better.", - ), - "elaboration_balance": dict( - type="custom", - weight=1.0, - function=lambda r: r.combined_compounds.elaboration_balance, - description="A measure for how evenly scaffold compounds have been elaborated using an h-index. Higher is better.", - ), ### REALLY UNPERFORMANT? - "num_inspirations": dict( - type="custom", - weight=1.0, - function=lambda r: r.poses.num_inspirations, - description="The number of unique fragment compounds that inspired poses for product compounds in this selection. Higher is better.", - ), - "num_inspiration_sets": dict( - type="custom", - weight=1.0, - function=lambda r: r.poses.num_inspiration_sets, - description="The number of unique fragment combinations that inspired poses for product compounds in this selection. Higher is better.", - ), - # "risk_diversity": dict( - # type="custom", - # weight=0.0, - # function=lambda r: r.combined_compounds.risk_diversity, - # description="A measure of how evenly spread the risk of elaborations are for each scaffold compound. Risk in this case refers to the number of atoms added. Higher is better", - # ), # REMOVED BECAUSE IT DOES NOT NECESSARILY IMPROVE AS PRODUCTS ARE ADDED - "interaction_count": dict( - type="custom", - weight=1.0, - function=lambda r: r.interactions.num_features, - description="The number of protein features that are being interecated with in this selection. Higher is better.", - ), - "interaction_balance": dict( - type="custom", - weight=0.0, - function=lambda r: r.interactions.per_feature_count_hirsch, - description="A measure for how evenly protein features are being interacted with in this selection using an h-index. Higher is better", - ), - "num_subsites": dict( - type="custom", - weight=1.0, - function=lambda r: r.poses.num_subsites, - description="Count the number of subsites that poses in this set come into contact with. Higher is better.", - ), - "subsite_balance": dict( - type="custom", - weight=0.0, - function=lambda r: r.poses.subsite_balance, - description="Count the number of subsites that poses in this set come into contact with", - ), - "avg_distance_score": dict( - type="custom", - weight=-0.0, - function=lambda r: r.poses.avg_distance_score, - description="Average distance score (e.g. RMSD to fragment inspirations) for poses in this set. Lower is better.", - ), - "avg_energy_score": dict( - type="custom", - weight=-0.0, - function=lambda r: r.poses.avg_energy_score, - description="Average energy score (e.g. binding ddG) for poses in this set. Lower is better.", - ), - # "reaction_risk": dict(type='custom', weight=1.0, function=None), - # "pockets?": dict(type='custom', weight=1.0, function=None), - # "chemical_diversity": dict(type='custom', weight=1.0, function=None), - # "DMS/sequence_variability": dict(type='custom', weight=1.0, function=None), -} diff --git a/hippo/subsite.py b/hippo/subsite.py deleted file mode 100644 index 8911b059..00000000 --- a/hippo/subsite.py +++ /dev/null @@ -1,179 +0,0 @@ -"""Classes for working with protein subsites""" - -import mcol - - -class Subsite: - """ - Class representing a subsite/subsite on a protein :class:`.Target` - - .. attention:: - - :class:`.Subsite` objects should not be created directly. Instead use :meth:`.Target.subsites`. - - """ - - _table = "subsite" - - def __init__(self, db: "Database", id: int, target_id: int, name: str) -> None: - """Subsite initialisation""" - - self._db = db - self._id = id - self._target_id = target_id - self._name = name - self._metadata = None - self._target = None - - ### PROPERTIES - - @property - def db(self) -> "Database": - """Returns a pointer to the parent database""" - return self._db - - @property - def id(self) -> int: - """Returns the SubsiteTag's database ID""" - return self._id - - @property - def table(self): - """Returns the name of the :class:`.Database` table""" - return self._table - - @property - def target(self) -> "Target": - """Returns the associated protein :class:`.Target`""" - if self._target is None: - self._target = self.db.get_target(id=self.target_id) - return self._target - - @property - def target_id(self) -> int: - """Returns the associated protein :class:`.Target` ID""" - return self._target_id - - @property - def name(self) -> str: - """The subsite name""" - return self._name - - @property - def metadata(self) -> "MetaData": - """Returns the SubsiteTag's metadata""" - if self._metadata is None: - self._metadata = self.db.get_metadata(table="subsite", id=self.id) - return self._metadata - - @property - def poses(self) -> "PoseSet | None": - """Return all poses in this subsite""" - from .pset import PoseSet - - indices = self.db.select_where( - table="subsite_tag", - query="subsite_tag_pose", - multiple=True, - key="ref", - value=self.id, - ) - indices = [i for i, in indices] - if not indices: - return None - return PoseSet(self.db, indices, name=f"poses in {self}") - - ### METHODS - - ### DUNDERS - - def __str__(self): - """Unformatted string representation""" - return f"S{self.id}: {self.target.name}->{self.name}" - - def __repr__(self) -> str: - """ANSI Formatted string representation""" - return f"{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}" - - def __rich__(self) -> str: - """Rich Formatted string representation""" - return f"[bold underline]{self}" - - -class SubsiteTag: - """ - Class representing a tag assigning a :class:`.Subsite` to a :class:`.Pose` - - .. attention:: - - :class:`.SubsiteTag` objects should not be created directly. Instead use :meth:`.Pose.subsites`. - - """ - - _table = "subsite_tag" - - def __init__(self, db: "Database", id: int, subsite_id: int, pose_id: int): - """SubsiteTag initialisation""" - - self._db = db - self._id = id - self._subsite_id = subsite_id - self._pose_id = pose_id - self._metadata = None - - self._name = db.get_subsite_name(id=subsite_id) - - ### FACTORIES - - ### PROPERTIES - - @property - def db(self) -> "Database": - """Returns a pointer to the parent database""" - return self._db - - @property - def id(self) -> int: - """Returns the SubsiteTag's database ID""" - return self._id - - @property - def table(self): - """Returns the name of the :class:`.Database` table""" - return self._table - - @property - def pose_id(self) -> int: - """Returns the associated :class:`.Pose`'s database ID""" - return self._pose_id - - @property - def subsite_id(self): - """Returns the associated :class:`.Subsite`'s database ID""" - return self._subsite_id - - @property - def name(self): - """Returns the associated :class:`.Subsite`'s name""" - return self._name - - @property - def metadata(self) -> "MetaData": - """Returns the SubsiteTag's metadata""" - if self._metadata is None: - self._metadata = self.db.get_metadata(table="subsite_tag", id=self.id) - return self._metadata - - ### DUNDERS - - def __str__(self): - """Unformatted string representation""" - return self.name - - def __repr__(self) -> str: - """ANSI Formatted string representation""" - return f'{mcol.bold}{mcol.underline}"{self}"{mcol.unbold}{mcol.ununderline}' - - def __rich__(self) -> str: - """Rich Formatted string representation""" - return f'[bold underline]"{self}"' diff --git a/hippo/syndirella.py b/hippo/syndirella.py deleted file mode 100644 index c1637b38..00000000 --- a/hippo/syndirella.py +++ /dev/null @@ -1,43 +0,0 @@ -"""Functions for interfacing with syndirella data""" - - -def reactions_from_row( - *, animal: "HIPPO", row: "pandas.Series", num_steps: int -) -> "ReactionSet": - """Get :class:`.ReactionSet` from a syndirella *to-hippo* DataFrame row""" - - reaction_ids = set() - - for step in range(num_steps): - - step += 1 - - # get relevant fields - reaction_name = row[f"{step}_reaction"] - - product = None - reactants = [] - - if reactant1_smiles := row[f"{step}_r1_smiles"]: - reactants.append(animal.register_compound(smiles=reactant1_smiles)) - - if reactant2_smiles := row[f"{step}_r2_smiles"]: - reactants.append(animal.register_compound(smiles=reactant2_smiles)) - - if product_smiles := row[f"{step}_product_smiles"]: - product = animal.register_compound(smiles=product_smiles) - - assert product - assert reactants - - reaction = animal.register_reaction( - type=reaction_name, - product=product, - reactants=reactants, - ) - - assert reaction - - reaction_ids.add(reaction.id) - - return animal.reactions[reaction_ids] diff --git a/hippo/ta_auth_connector.py b/hippo/ta_auth_connector.py new file mode 100644 index 00000000..f369f37b --- /dev/null +++ b/hippo/ta_auth_connector.py @@ -0,0 +1,168 @@ +"""A module that provides simplified request access to the TA Authenticator service. +Provides the following functions, that access the authenticator Pod: - + +- get_auth_version() +- get_auth_ping() +- get_auth_target_access(username) +""" + +# downloaded from https://github.com/xchem/fragalysis-target-access-authenticator-python-client +# incompatible versions because this is stuck on 3.12 and ta-auth requires 3.13 + +import logging +import os +from dataclasses import dataclass +from urllib.parse import quote + +import requests + +# Service location (e.g. "http://auth.ta-authenticator.svc") and request query key +_TA_AUTH_SERVICE: str = os.environ.get('TA_AUTH_SERVICE', '') +_TA_AUTH_QUERY_KEY: str = os.environ.get('TA_AUTH_QUERY_KEY', '') + +_URL_TIMEOUT: int = 3 +_QUERY_HEADERS: dict[str, str] = {'X-TAAQueryKey': _TA_AUTH_QUERY_KEY} + +logger: logging.Logger = logging.getLogger(__name__) + + +@dataclass +class TasAuthVersionGetResponse: + """The TA authenticator version response (including the base URL).""" + + version: str + kind: str + name: str + location: str = _TA_AUTH_SERVICE + + +@dataclass +class TasAuthPingGetResponse: + """The TA authenticator ping response.""" + + ping: str + + +def get_auth_version() -> TasAuthVersionGetResponse: + """Returns the version reported by the TA authentication service.""" + if not _TA_AUTH_SERVICE: + return TasAuthVersionGetResponse( + version='', kind='AUTH_SERVICE_NOT_DEFINED', name='' + ) + if not _TA_AUTH_QUERY_KEY: + return TasAuthVersionGetResponse( + version='', kind='SERVICE_QUERY_KEY_NOT_DEFINED', name='' + ) + + url: str = f'{_TA_AUTH_SERVICE}/version/' + resp: requests.Response | None = None + try: + resp = requests.get(url, timeout=_URL_TIMEOUT) + except requests.exceptions.RequestException as r_ex: # pylint: disable=broad-except + logger.error('TA:GET:%s RequestException (%s)', url, r_ex) + except Exception as ex: # pylint: disable=broad-exception-caught + logger.error('TA:GET:%s Exception (%s)', url, ex) + + if resp is None: + logger.warning('TA:GET:%s (no response)', url) + return TasAuthVersionGetResponse( + version='', kind='ERROR_INTERNAL', name='Null response' + ) + elif resp.status_code not in (200,): + logger.warning('TA:GET:%s [%s] (status not 200)', url, resp.status_code) + return TasAuthVersionGetResponse( + version='', kind='ERROR_INTERNAL', name='(status not 200)' + ) + elif 'application/json' not in resp.headers.get('Content-Type', ''): + logger.warning('TA:GET:%s (empty response)', url) + return TasAuthVersionGetResponse( + version='', kind='ERROR_INTERNAL', name='(empty response)' + ) + elif 'version' not in resp.json(): + logger.warning('TA:GET:%s (no version property)', url) + return TasAuthVersionGetResponse( + version='', kind='ERROR_INTERNAL', name='(no version property)' + ) + + logger.info('TA:GET:%s [%s]', url, resp.json()) + + return TasAuthVersionGetResponse( + version=resp.json()['version'], + kind=resp.json()['kind'], + name=resp.json()['name'], + ) + + +def get_auth_ping() -> TasAuthPingGetResponse: + """Returns the ping reported by the TA authentication service.""" + if not _TA_AUTH_SERVICE: + return TasAuthPingGetResponse(ping='AUTH_SERVICE_NOT_DEFINED') + + url: str = f'{_TA_AUTH_SERVICE}/ping/' + resp: requests.Response | None = None + try: + resp = requests.get(url, timeout=_URL_TIMEOUT) + except requests.exceptions.RequestException as r_ex: # pylint: disable=broad-except + logger.error('TA:GET:%s RequestException (%s)', url, r_ex) + except Exception as ex: # pylint: disable=broad-exception-caught + logger.error('TA:GET:%s Exception (%s)', url, ex) + + if resp is None: + logger.warning('TA:GET:%s (no response)', url) + return TasAuthPingGetResponse('PING response was null') + elif resp.status_code not in (200,): + logger.warning('TA:GET:%s [%s] (status not 200)', url, resp.status_code) + return TasAuthPingGetResponse('PING response status not 200') + elif 'application/json' not in resp.headers.get('Content-Type', ''): + logger.warning('TA:GET:%s (empty response)', url) + return TasAuthPingGetResponse('PING response was empty') + elif 'ping' not in resp.json(): + logger.warning('TA:GET:%s (no ping property)', url) + return TasAuthPingGetResponse('PING response has no ping property') + + ping: str = resp.json()['ping'] + logger.info('TA:GET:%s [%s]', url, ping) + + return TasAuthPingGetResponse(ping=ping) + + +def get_auth_target_access(username: str) -> set[str]: + """Returns the set of target access strings a user is entitled to + as reported by the TA authentication service.""" + assert username + + empty_target_access: set[str] = set() + + if not _TA_AUTH_QUERY_KEY: + logger.debug('Skipping query - query key is not set (TA_AUTH_QUERY_KEY)') + return empty_target_access + + url: str = f'{_TA_AUTH_SERVICE}/target-access/{quote(username)}' + resp: requests.Response | None = None + try: + resp = requests.get(url, headers=_QUERY_HEADERS, timeout=_URL_TIMEOUT) + except requests.exceptions.RequestException as ex: # pylint: disable=broad-except + logger.error('TA:GET:%s RequestException (%s)', url, ex) + except Exception as ex: # pylint: disable=broad-exception-caught + logger.error('TA:GET:%s Exception (%s)', url, ex) + + if resp is None: + logger.warning('TA:GET:%s (no response)', url) + return empty_target_access + if resp.status_code not in (200,): + logger.warning('TA:GET:%s [%s] (status not 200)', url, resp.status_code) + return empty_target_access + elif 'application/json' not in resp.headers.get('Content-Type', ''): + logger.warning('TA:GET:%s (empty response)', url) + return empty_target_access + elif 'count' not in resp.json(): + logger.warning('TA:GET:%s (no count)', url) + return empty_target_access + elif 'target_access' not in resp.json(): + logger.warning('TA:GET:%s (no target_access)', url) + return empty_target_access + + target_access: set[str] = set(resp.json()['target_access']) + logger.info('TA:GET:%s (got %d for %s)', url, len(target_access), username) + + return target_access diff --git a/hippo/tags.py b/hippo/tags.py deleted file mode 100644 index 3dca85e1..00000000 --- a/hippo/tags.py +++ /dev/null @@ -1,333 +0,0 @@ -"""Classes for managing compound/pose tags""" - -import mcol -import mrich - -from collections.abc import MutableSet - - -class TagTable: - """Object representing the 'tag' table in the :class:`.Database`. - - .. attention:: - - :class:`.TagTable` objects should not be created directly. Instead use the :meth:`.HIPPO.tags` property. - - """ - - _table = "tag" - - def __init__( - self, - db: "Database", - ) -> None: - """TagTable initialisation""" - - self._db = db - - ### PROPERTIES - - @property - def db(self) -> "Database": - """Returns a pointer to the parent database""" - return self._db - - @property - def table(self) -> str: - """Returns the name of the :class:`.Database` table""" - return self._table - - @property - def unique(self) -> set[str]: - """Returns a set of unique tag names contained in the table""" - values = self.db.select( - table=self.table, query="DISTINCT tag_name", multiple=True - ) - return list(sorted(set(v for v, in values))) - - ### METHODS - - def summary(self, return_df: bool = False) -> "pd.DataFrame": - """Print a summary table of tags with compound and pose counts""" - - from pandas import DataFrame - - sql = """ - SELECT tag_name, - COUNT(DISTINCT tag_compound), - COUNT(DISTINCT tag_pose) - FROM tag - GROUP BY tag_name - ORDER BY tag_name; - """ - - cursor = self.db.execute(sql) - - data = [ - dict(tag=a, num_compounds=b, num_poses=c) for a, b, c in cursor.fetchall() - ] - - df = DataFrame(data) - df = df.set_index("tag") - - # compounds with poses - - sql = """ - SELECT tag_name, COUNT(DISTINCT pose_compound) FROM tag - INNER JOIN pose - ON tag_pose = pose_id - GROUP BY tag_name - ORDER BY tag_name; - """ - - cursor = self.db.execute(sql) - - for tag, count in cursor.fetchall(): - df.loc[tag, "num_posed_compounds"] = count - - df = df.fillna(0) - df = df.astype(int) - - if return_df: - return df - else: - mrich.print(df) - - def rename(self, old: str, new: str) -> None: - """Rename all instances of a tag across the database""" - - sql = """ - UPDATE OR IGNORE tag - SET tag_name = ?2 - WHERE tag_name = ?1; - """ - - self.db.execute(sql, (str(old), str(new))) - - self.delete(old) - - self.db.commit() - - def delete(self, tag: str) -> None: - """Delete all assignments for the given tag""" - self.db.delete_where(table="tag", key="name", value=tag) - - ### DUNDERS - - def __str__(self) -> str: - """Unformatted representation of this object""" - return f"Tags {self.unique}" - - def __repr__(self) -> str: - """ANSI Formatted string representation""" - return f"{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}" - - def __rich__(self) -> str: - """Rich Formatted string representation""" - return f"[bold underline]{self}" - - -class TagSet(MutableSet): - """Object representing a subset of the 'tag' table in the :class:`.Database` belonging to a certain :class:`.Compound` or :class:`.Pose`. - - .. attention:: - - :class:`.TagSet` objects should not be created directly. Instead use the :meth:`.Compound.tags` or :meth:`.Pose.tags` property. - - """ - - def __init__( - self, - parent: "Compound | Pose", - tags: list | tuple | None = None, - immutable: bool = False, - commit: bool = True, - ): - """TagSet initialisation""" - - self._elements = [] - self._immutable = immutable - self._parent = parent - - tags = tags or [] - - for tag in tags: - if tag not in self._elements: - self._elements.append(tag) - - ### PROPERTIES - - @property - def tags(self) -> list: - """Returns the elements in this set""" - return self._elements - - @property - def immutable(self) -> bool: - """Is this set is immutable?""" - return self._immutable - - @immutable.setter - def immutable( - self, - b: bool, - ) -> None: - self._immutable = b - - @property - def parent(self): - """Returns this set of tags parent :class:`.Compound` or :class:`.Pose`.""" - return self._parent - - @property - def db(self) -> "Database": - """Returns a pointer to the parent database""" - return self.parent.db - - ### DATABASE - - def _remove_tag_from_db( - self, - tag: str, - ) -> None: - """Delete a specific tag assignment for the parent :class:`.Compound`/:class:`.Pose` - - :param tag: tag to delete - - """ - sql = f'DELETE FROM tag WHERE tag_name="{tag}" AND tag_{self.parent.table} = {self.parent.id}' - self.db.execute(sql) - - def _clear_tags_from_db( - self, - tag: str, - ) -> None: - """Delete all tag assignments for the parent :class:`.Compound`/:class:`.Pose` - - :param tag: tag to delete - - """ - sql = f"DELETE FROM tag WHERE tag_{self.parent.table} = {self.parent.id}" - self.db.execute(sql) - - def _add_tag_to_db( - self, - tag: str, - commit: bool = True, - ) -> None: - """Assign a given tag to the parent - - :param tag: tag to add - :param commit: commit the changes? (Default value = True) - - """ - payload = {"name": tag, self.parent.table: self.parent.id} - self.db.insert_tag(**payload, commit=commit) - - ### METHODS - - def pop(self) -> str: - """Pop the last element""" - assert not self.immutable - return self._elements.pop() - - def discard( - self, - tag: str, - ) -> None: - """Discard an element - - :param tag: tag to discard - - """ - - self.discard(tag) - - def clear(self): - """Clear all tags""" - self._elements = [] - self._clear_tags_from_db(self) - - def remove(self, tag: str) -> None: - """Remove an element - - :param tag: tag to remove - :raises ValueError: if tag is not in set - - """ - - assert not self.immutable - if tag in self: - i = self._elements.index(tag) - del self._elements[i] - self._remove_tag_from_db(tag) - else: - raise ValueError(f"{tag} not in {self}") - - def add( - self, - tag: str, - commit: bool = True, - ) -> None: - """Add a tag to the set - - :param tag: tag to add - :param commit: commit the change? (Default value = True) - - """ - - assert not self.immutable - if tag not in self._elements: - self._elements.append(tag) - self._add_tag_to_db(tag, commit=commit) - - def glob(self, pattern: str) -> list[str]: - """Construct a list from tags in the set names that match a given UNIX-style pattern. - - :param pattern: unix style pattern with shell-style wildcards - :returns: list of tags - - """ - - import fnmatch - - return fnmatch.filter(self.tags, pattern) - - ### DUNDERS - - def __contains__(self, tag: str) -> bool: - """Is this tag in the set?""" - return tag in self.tags - - def __str__(self) -> str: - """Unformatted representation of this object""" - return str(self._elements) - - def __repr__(self) -> str: - """ANSI Formatted string representation""" - return f"{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}" - - def __rich__(self) -> str: - """Rich Formatted string representation""" - return f"[bold underline]{self}" - - def __len__(self) -> int: - """Number of tags in this set""" - return len(self._elements) - - def __iter__(self): - """Iterate through this set""" - return iter(self._elements) - - def __add__(self, other): - """ - .. attention:: - - Adding sets together is not supported - - """ - raise NotImplementedError - - def __getitem__(self, key: int): - """Get a specific element in the set by index""" - return self._elements[key] diff --git a/hippo/target.py b/hippo/target.py deleted file mode 100644 index b832fba8..00000000 --- a/hippo/target.py +++ /dev/null @@ -1,151 +0,0 @@ -"""Classes for protein targets""" - -import mcol -import mrich - - -class Target: - """Object representing a protein target - - .. attention:: - - :class:`.Target` objects should not be created directly. Instead use :meth:`.HIPPO.register_target` or :meth:`.Pose.target`. - - """ - - _feature_cache = {} - - def __init__( - self, - db: "Database", - id: int, - name: str, - ) -> None: - """Target initialisation""" - - self._db = db - self._id = id - self._name = name - - ### PROPERTIES - - @property - def db(self) -> "Database": - """Returns a pointer to the parent database""" - return self._db - - @property - def id(self) -> int: - """Returns the target's ID""" - return self._id - - @property - def name(self) -> str: - """Returns the target's name""" - return self._name - - @property - def feature_ids(self) -> list[int]: - """Returns the target's feature ID's""" - feature_ids = self.db.select_where( - query="feature_id", - table="feature", - key="target", - value=self.id, - none=False, - multiple=True, - sort="feature_chain_name, feature_residue_number", - ) - - if feature_ids: - feature_ids = [v for v, in feature_ids] - - return feature_ids - - @property - def features(self) -> list["Feature"]: - """Returns the target's features""" - if feature_ids := self.feature_ids: - return [self.db.get_feature(id=i) for i in feature_ids] - return None - - @property - def subsites(self) -> "list[Subsite]": - """List of :class:`.Subsite` objects on this target""" - - from .subsite import Subsite - - records = self.db.select_where( - table="subsite", - key="target", - value=self.id, - multiple=True, - query="subsite_id, subsite_name", - ) - - if not records: - return [] - - subsites = [] - for record in records: - id, name = record - subsite = Subsite(db=self.db, id=id, name=name, target_id=self.id) - subsites.append(subsite) - - return subsites - - ### METHODS - - def calculate_features( - self, - protein: "mp.System", - reference_id: int | None = None, - force: bool = False, - ) -> list["Feature"]: - """Calculate features from a protein system - - :param protein: `molparse.System` object, likely from :meth:`.Pose.protein_system` - :returns: a list of :class:`.Feature` objects - - """ - - if not force and reference_id and reference_id in self._feature_cache: - return self._feature_cache[reference_id] - - else: - - features = protein.get_protein_features() - - for f in features: - self.db.insert_feature( - family=f.family, - target=self.id, - atom_names=[a.name for a in f.atoms], - residue_name=f.res_name, - residue_number=f.res_number, - chain_name=f.res_chain, - commit=False, - ) - - self.db.commit() - - features = self.features - - if reference_id: - self._feature_cache[reference_id] = features - - return features - - ### DUNDERS - - def __str__(self) -> str: - """Unformatted string representation""" - return f'T{self.id} "{self.name}"' - - def __repr__(self) -> str: - """ANSI Formatted string representation""" - return f"{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}" - - def __rich__(self) -> str: - """Rich Formatted string representation""" - return f"[bold underline]{self}" diff --git a/hippo/test.db b/hippo/test.db deleted file mode 100644 index 378426eb..00000000 Binary files a/hippo/test.db and /dev/null differ diff --git a/hippo/tools.py b/hippo/tools.py deleted file mode 100644 index 65a72a5c..00000000 --- a/hippo/tools.py +++ /dev/null @@ -1,257 +0,0 @@ -"""Generic tools for use in the HIPPO package""" - -import re -import numpy as np -from molparse.rdkit import mol_from_smiles -from rdkit.Chem.inchi import MolToInchiKey -from rdkit.Chem import MolFromSmiles, MolToSmiles, AddHs, RemoveHs -import mcol -from datetime import datetime -from string import ascii_uppercase - -import mrich - - -def df_row_to_dict(df_row) -> dict: - """Convert a dataframe row to a dictionary - - :param df_row: pandas dataframe row / series - """ - - assert len(df_row) == 1, f"{len(df_row)=}" - - data = {} - - for col in df_row.columns: - - if col == "Unnamed: 0": - continue - - value = df_row[col].values[0] - - if not isinstance(value, str) and np.isnan(value): - value = None - - data[col] = value - - return data - - -def remove_other_ligands( - sys: "molparse.System", residue_number: int, chain: str -) -> "molparse.System": - """Remove ligands other than the specified one""" - - ligand_residues = [r.number for r in sys["rLIG"] if r.number != residue_number] - - # if ligand_residues: - for c in sys.chains: - if c.name != chain: - c.remove_residues(names=["LIG"], verbosity=0) - elif ligand_residues: - c.remove_residues(numbers=ligand_residues, verbosity=0) - - # print([r.name_number_str for r in sys['rLIG']]) - - assert ( - len([r.name_number_str for r in sys["rLIG"]]) == 1 - ), f"{sys.name} {[r.name_number_str for r in sys['rLIG']]}" - - return sys - - -def inchikey_from_smiles(smiles: str) -> str: - """InChI-Key from smiles string""" - mol = mol_from_smiles(smiles) - return MolToInchiKey(mol) - - -def flat_inchikey(smiles: str) -> str: - """Stereochemistry-flattened InChI-Key from smiles string""" - smiles = sanitise_smiles(smiles) - return inchikey_from_smiles(smiles) - - -def remove_isotopes_from_smiles(smiles: str) -> str: - """Remove isotopes from smiles string""" - - mol = MolFromSmiles(smiles) - - atom_data = [(atom, atom.GetIsotope()) for atom in mol.GetAtoms()] - - for atom, isotope in atom_data: - if isotope: - atom.SetIsotope(0) - - return MolToSmiles(mol) - - -def smiles_has_isotope(smiles: str, regex=True) -> bool: - """Does provided smiles string contain isotopes?""" - if regex: - return re.search(r"([\[][0-9]+[A-Z]+\])", smiles) - else: - mol = MolFromSmiles(smiles) - return any(atom.GetIsotope() for atom in mol.GetAtoms()) - - -REPLACE = { - "[STB]": "[S]", -} - - -def sanitise_smiles( - s: str, - verbosity: bool = False, - sanitisation_failed: str = "error", - radical: str = "error", -) -> str: - """Sanitise smiles by: - - - Taking largest fragment - - Flattening stereochemistry - - Removing isotopes - - RDKit round-trip - - Treating radicals - - :param s: input smiles string - :param verbosity: print smiles changes (Default value = False) - :param sanitisation_failed: behvaiour when sanitisation fails, choose from ["error", "warning", "quiet"] (Default value = 'error') - :param radical: behvaiour when radicals occur, choose from ["error", "warning", "remove"] (Default value = 'error') - :returns: SMILES string - """ - - assert isinstance(s, str), f"non-string smiles={s}" - - orig_smiles = s - - # if multiple molecules take the largest - if "." in s: - s = sorted(s.split("."), key=lambda x: len(x))[-1] - - # flatten the smiles - stereo_smiles = s - smiles = s.replace("@", "") - smiles = smiles.replace("/", "") - smiles = smiles.replace("\\", "") - - # remove isotopic stuff - if smiles_has_isotope(smiles): - mrich.warning(f"Isotope(s) in SMILES: {smiles}") - smiles = remove_isotopes_from_smiles(smiles) - - # replace specific sequences - for key in REPLACE: - if key in smiles: - smiles = smiles.replace(key, REPLACE[key]) - - # canonicalise - mol = MolFromSmiles(smiles) - if mol: - smiles = MolToSmiles(mol, True) - elif sanitisation_failed == "error": - raise SanitisationError - elif sanitisation_failed == "warning": - mrich.warning(f"sanitisation failed for {smiles=}") - - # check radicals - reconstruct = False - for atom in mol.GetAtoms(): - if not atom.GetNumRadicalElectrons(): - continue - - if radical == "warning": - mrich.warning(f"Radical atom in {smiles=}") - elif radical == "error": - raise SanitisationError(f"Radical atom in {smiles=}") - elif radical == "remove": - mrich.warning(f"Removed radical atom") - atom.SetNumRadicalElectrons(0) - smiles = MolToSmiles(mol, True) - reconstruct = True - # atom.SetFormalCharge(0) - else: - raise NotImplementedError(f"Unknown option {radical=}") - - if reconstruct: - mol = AddHs(mol) - mol = RemoveHs(mol, implicitOnly=True) - smiles = MolToSmiles(mol, True) - mrich.warning(f"New {smiles=}") - - if verbosity: - - if smiles != orig_smiles: - - annotated_smiles_str = orig_smiles.replace( - ".", f"{mcol.error}{mcol.underline}.{mcol.clear}{mcol.warning}" - ) - annotated_smiles_str = annotated_smiles_str.replace( - "@", f"{mcol.error}{mcol.underline}@{mcol.clear}{mcol.warning}" - ) - - mrich.warning(f"SMILES was changed: {annotated_smiles_str} --> {smiles}") - - return smiles - - -def sanitise_mol(m: "rdkit.Chem.Mol") -> "rdkit.Chem.Mol": - """Sanitise by RDKit round-trip""" - from rdkit.Chem import MolToMolBlock, MolFromMolBlock - - return MolFromMolBlock(MolToMolBlock(m)) - - -def pose_gap(a: "Pose", b: "Pose") -> float: - """Calculate minimum distance between two :class:`.Pose` objects""" - - from numpy.linalg import norm - from molparse.rdkit import mol_to_AtomGroup - - min_dist = None - - a = mol_to_AtomGroup(a.mol) - b = mol_to_AtomGroup(b.mol) - - for atom1 in a.atoms: - for atom2 in b.atoms: - dist = norm(atom1.np_pos - atom2.np_pos) - if min_dist is None or dist < min_dist: - min_dist = dist - - return min_dist - - -ALPHANUMERIC_CHARS = "0123456789" + ascii_uppercase - - -def number_to_base(n: int, b: int) -> int: - """Convert an integer `n` into base `b` representation""" - if n == 0: - return [0] - digits = [] - while n: - digits.append(int(n % b)) - n //= b - return digits[::-1] - - -def dt_hash() -> str: - """Create 7 alphanumeric-character hash based on current timestamp""" - dt = datetime.now() - x = int( - dt.month * 36000 * 24 * 365.25 - + dt.day * 36000 * 24 - + dt.hour * 36000 - + dt.minute * 600 - + dt.second * 10 - + dt.microsecond / 10000 - ) - timehash = "".join([ALPHANUMERIC_CHARS[v] for v in number_to_base(x, 36)]) - return f"{timehash:>07}" - - -class SanitisationError(Exception): - """Something went wrong in Molecule/SMILES sanitisation""" - - ... diff --git a/hippo/web.py b/hippo/web.py deleted file mode 100644 index 30582ba0..00000000 --- a/hippo/web.py +++ /dev/null @@ -1,1116 +0,0 @@ -"""Classes for web (static HTML) output""" - -from pathlib import Path -import shutil - -import logging - -logging.getLogger("PIL").setLevel(logging.WARNING) - -import mrich - - -class ProjectPage: - """ - Recipe proposal web page - - """ - - def __init__( - self, - output_dir: str | Path, - *, - animal: "HIPPO", - scaffolds: "CompoundSet | None" = None, - suppliers: list[str] | None = None, - starting_recipe: "Recipe | None" = None, - rgen: "RandomRecipeGenerator | None" = None, - scorer: "Scorer | None" = None, - proposals: "list[Recipe] | None" = None, - title: str | None = None, - scaffold_tag: str = "Syndirella scaffold", - extra_recipe_dir: str | Path = None, - skip_existing: bool = True, - ) -> None: - """ProjectPage initialisation""" - - # setup directories - self._output_dir = Path(output_dir) - self.make_directories() - - # project objects - self._animal = animal - self._scaffolds = scaffolds - self._suppliers = suppliers - self._starting_recipe = starting_recipe - self._rgen = rgen - self._scorer = scorer - self._proposals = proposals - self._extra_recipe_dir = extra_recipe_dir - - self._all_scaffolds = self.animal.compounds(tag=scaffold_tag) - - mrich.debug(f"{len(self.all_scaffolds)=}") - - self._all_scaffold_poses = None - self._all_elabs = None - self._all_elab_poses = None - self._scaffold_poses = None - - self._title = title or animal.name - - self._skip_existing = skip_existing - - self.setup_page() - self.write_html() - - ### FACTORIES - - ### PROPERTIES - - @property - def animal(self) -> "HIPPO": - """associated :class:`.HIPPO` object""" - return self._animal - - @property - def db(self) -> "Database": - """associated :class:`.Database` object""" - return self.animal.db - - @property - def doc(self) -> "yattag.Doc": - """yattag.Doc""" - return self._doc - - @property - def tag(self) -> "yattag.tag": - """yattag.tag""" - return self._tag - - @property - def text(self) -> "yattag.text": - """yattag.text""" - return self._text - - @property - def line(self) -> "yattag.line": - """yattag.line""" - return self._line - - @property - def title(self) -> str: - """Page title""" - return self._title - - @property - def output_dir(self) -> "Path": - """Output directory""" - return self._output_dir - - @property - def resource_dir(self) -> "Path": - """Output directory""" - return self.output_dir / "web_resources" - - @property - def mol_image_dir(self) -> "Path": - """Output directory""" - return self.resource_dir / "mol_images" - - @property - def pose_sdf_dir(self) -> "Path": - """Output directory""" - return self.resource_dir / "pose_sdfs" - - @property - def index_path(self) -> "Path": - """index.html Path""" - return self.output_dir / "index.html" - - @property - def proposals(self) -> "list[Recipe]": - """List of proposal :class:`.Recipe` objects""" - return self._proposals - - @property - def scaffolds(self) -> "CompoundSet": - """Scaffold :class:`.CompoundSet`""" - return self._scaffolds - - @property - def suppliers(self) -> list[str]: - """Compound suppliers""" - return self._suppliers - - @property - def starting_recipe(self) -> "Recipe": - """Starting :class:`.Recipe`""" - return self._starting_recipe - - @property - def rgen(self) -> "RandomRecipeGenerator": - """:class:`.RandomRecipeGenerator`""" - return self._rgen - - @property - def scorer(self) -> "Scorer": - """:class:`.Scorer`""" - return self._scorer - - @property - def all_scaffolds(self) -> "CompoundSet": - """All scaffold compounds""" - return self._all_scaffolds - - @property - def all_elabs(self) -> "CompoundSet": - """All elaborations""" - if self._all_elabs is None: - self._all_elabs = self.all_scaffolds.elabs - return self._all_elabs - - @property - def all_elab_poses(self) -> "PoseSet": - """All elaboration poses""" - if self._all_elab_poses is None: - self._all_elab_poses = self.all_elabs.poses - return self._all_elab_poses - - @property - def scaffold_poses(self) -> "PoseSet": - """Scaffold poses""" - if self._scaffold_poses is None: - self._scaffold_poses = self.scaffolds.poses - return self._scaffold_poses - - @property - def all_scaffold_poses(self) -> "PoseSet": - """All scaffold poses""" - if self._all_scaffold_poses is None: - self._all_scaffold_poses = self.all_scaffolds.poses - return self._all_scaffold_poses - - @property - def proposal(self) -> "Recipe": - """Return single :class:`.Recipe` proposal""" - if len(self.proposals) != 1: - mrich.warning(f"{len(self.proposals)=}") - return self._proposals[0] - - @property - def extra_recipe_dir(self) -> "Path": - """Optional extra recipe directory""" - return self._extra_recipe_dir - - @property - def skip_existing(self) -> bool: - """Skip creation of existing files""" - return self._skip_existing - - ### METHODS - - def write_html(self) -> None: - """Write the index.html file""" - - from yattag import indent - - path = self.index_path - - with open(path, "wt") as f: - mrich.writing(path) - f.writelines(indent(self.doc.getvalue())) - - ### INTERNAL HTML STUFF - - def make_directories(self) -> None: - """Create output directories""" - - mrich.writing(self.output_dir) - self.output_dir.mkdir(parents=True, exist_ok=True) - - mrich.writing(self.resource_dir) - (self.resource_dir).mkdir(parents=True, exist_ok=True) - - mrich.writing(self.mol_image_dir) - (self.mol_image_dir).mkdir(parents=True, exist_ok=True) - - mrich.writing(self.pose_sdf_dir) - (self.pose_sdf_dir).mkdir(parents=True, exist_ok=True) - - def setup_page(self) -> None: - """Create the yattag page content""" - - # yattag setup - from yattag import Doc - - doc, tag, text, line = Doc().ttl() - - self._doc = doc - self._tag = tag - self._text = text - self._line = line - - self.doc.asis("") - - with self.tag("html"): - - self.header() - - with self.tag("body", klass="w3-content", style="max-width:none"): - - with self.tag("div", klass="w3-bar w3-teal"): - with self.tag("div", klass="w3-bar-item"): - src = "https://github.com/mwinokan/HIPPO/raw/main/logos/hippo_assets-02.png?raw=true" - self.doc.stag( - "img", src=src, style="max-height:75px" - ) # , klass="w3-image") - - with self.tag("div", klass="w3-bar-item"): - with self.tag("h1"): - self.text(self.title) - - with self.tag("div", klass="w3-container w3-dark-gray w3-padding"): - self.section(self.sec_targets) - self.section(self.sec_hits) - - # placeholders - if self.scaffolds: - self.section(self.sec_scaffolds) - if self.scaffolds: - self.section(self.sec_elaborations) - # if self.quoting: self.section(self.sec_quoting) - # if self.product_pool: self.section(self.sec_product_pool) - # if self.route_pool: self.section(self.sec_route_pool) - if self.rgen: - self.section(self.sec_rgen) - if self.scorer: - self.section(self.sec_scorer) - if self.proposals: - self.section(self.sec_proposals) - - with self.tag("div", klass="w3-container w3-teal w3-padding"): - with self.tag("div", klass="w3-center"): - src = "https://github.com/mwinokan/HIPPO/raw/main/logos/hippo_logo_tightcrop.png?raw=true" - self.doc.stag("img", src=src, style="max-height:150px") - - def header(self) -> None: - """Create the page header""" - - with self.tag("head"): - - with self.tag("title"): - self.text(self.title) - - self.doc.stag("meta", charset="UTF-8") - self.doc.stag( - "meta", name="viewport", content="width=device-width, initial-scale=1" - ) - self.doc.stag( - "link", - rel="stylesheet", - href="https://www.w3schools.com/w3css/4/w3.css", - ) - self.doc.stag( - "link", - rel="stylesheet", - href="https://fonts.googleapis.com/css?family=Oswald", - ) - self.doc.stag( - "link", - rel="stylesheet", - href="https://fonts.googleapis.com/css?family=Open Sans", - ) - self.doc.stag( - "link", - rel="stylesheet", - href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css", - ) - - with self.tag("script", src="https://cdn.plot.ly/plotly-latest.min.js"): - ... - - with self.tag("script", src="https://3Dmol.org/build/3Dmol-min.js"): - ... - - with self.tag("script", src="https://3Dmol.org/build/3Dmol.ui-min.js"): - ... - - self.style() - - def style(self) -> None: - """Create the page style""" - - # change to a .css file and use doc.stag("link", rel="stylesheet", href="style.css") - - with self.tag("style"): - self.doc.asis( - """h1,h2,h3,h4,h5,h6 {font-family: "Oswald"}body {font-family: "Open Sans"}""" - ) - - def section_header(self, title: str, tag: str = "h2") -> None: - """section header""" - with self.tag(tag): - self.text(str(title)) - - def accordion(self) -> None: - """sub-content accordion""" - # https://www.w3schools.com/w3css/w3css_accordions.asp - raise NotImplementedError - - def sidebar(self) -> None: - """https://www.w3schools.com/w3css/w3css_sidebar.asp""" - raise NotImplementedError - - def var(self, key, value, tag=None, separator=": ") -> None: - """sub-content accordion""" - text = f"{key}{separator}{value}" - - if not tag: - with self.tag("b"): - self.text(key) - self.text(separator) - self.text(str(value)) - else: - with self.tag(tag): - self.var(key, value, separator=separator) - - def section(self, function) -> None: - """create section div""" - with self.tag("div", klass="w3-panel w3-border w3-white"): - function() - - def plotly_graph(self, figure, filename, write: bool = True): - """Generic plotly graph component""" - - # from plotly.offline import plot - from hippo_plot import write_html - - f"""
- -
""" - - path = self.resource_dir / filename - rel_path = Path(self.resource_dir.name) / filename - - if write: - write_html(path, figure) - - # embed the graph - with self.tag("div"): - with self.tag( - "iframe", - src=str(rel_path), - width="100%", - height="500", - style="border:none", - ): - ... - - def table(self, data, style: str = "w3-table-all w3-responsive", **kwargs): - """Embed some data as a table""" - from pandas import DataFrame - - df = DataFrame(data) - html = df.to_html(**kwargs, classes=style, index=False, escape=False) - self.doc.asis(html) - self.doc.asis("
") - - # def mol_grid_svg(self, cset, **kwargs): - - # mols = [c.mol for c in cset] - - # from rdkit.Chem.Draw import MolsToGridImage - - # return MolsToGridImage(mols, - # molsPerRow=3, - # subImgSize=(200, - # 200), - # legends=None, - # # highlightAtomLists=None, - # # highlightBondLists=None, - # useSVG=True, - # returnPNG=False, - # **kwargs) - - def save_compound_image(self, compound): - """Create 2D compound drawing""" - from rdkit.Chem.Draw import MolToImage - - image = MolToImage(compound.mol) - path = self.mol_image_dir / f"C{compound.id}.png" - mrich.writing(path) - image.save(path) - - def save_pose_sdf(self, pose): - """Export pose SDF""" - path = self.pose_sdf_dir / f"P{pose.id}.sdf" - self.animal.poses([pose.id]).write_sdf(path, inspirations=False) - - def save_pset_sdf(self, name, pset): - """Save poseset as SDF""" - path = self.pose_sdf_dir / f"{name}.sdf" - pset.write_sdf(path, inspirations=False) - - def compound_image(self, compound, max_height="250px"): - """Compound image stag""" - self.save_compound_image(compound) - - src = str( - Path(self.resource_dir.name) - / Path(self.mol_image_dir.name) - / f"C{compound.id}.png" - ) - - self.doc.stag("img", src=src, style=f"max-height:{max_height}") - - # def pose_3d_view(self, pose): - # """Compound image stag""" - - # self.save_pose_sdf(compound) - - # src = str( - # Path(self.resource_dir.name) - # / Path(self.mol_image_dir.name) - # / f"C{compound.id}.png" - # ) - - # self.doc.stag("img", src=src, style=f"max-height:{max_height}") - - def compound_grid(self, compounds, style="w3-center", pose_modal: bool = False): - """Compound grid component""" - - id_num_poses_dict = compounds.id_num_poses_dict - - inspiration_map = self.db.get_compound_id_inspiration_ids_dict() - - with self.tag("div", klass="w3-row"): - for compound in compounds: - - with self.tag( - "div", - klass=f"w3-col s12 m6 l4 {style} w3-hover-border-black", - style="border:8px solid white", - ): - with self.tag("p"): - with self.tag("b"): - self.text(f"{compound}") - - self.compound_image(compound) - - with self.tag("p", klass="w3-small w3-monospace"): - self.text(f"{compound.inchikey}") - self.doc.asis("
") - self.text(f"{compound.smiles}") - self.doc.asis("
") - - inspirations = inspiration_map.get(compound.id, None) - - if ( - not inspirations - and "inspiration_pose_ids" in compound.metadata - ): - inspirations = compound.metadata["inspiration_pose_ids"] - - if inspirations: - inspirations = self.animal.poses[inspirations] - self.text(f"inspirations: {inspirations.names}") - else: - self.text(f"inspirations: ?") - - num_poses = id_num_poses_dict[compound.id] - self.button(f"{num_poses} poses", disable=num_poses == 0) - - if pose_modal: - poses = compound.poses - - modal_name = f"modal_c{compound.id}_poses" - - # MOLECULE MODAL - self.modal_button( - f"view {len(poses)} poses", - modal_name, - disable=len(poses) == 0, - ) - - if poses: - - self.save_pset_sdf(modal_name, poses) - - def modal_content(): - """modal content""" - with self.tag("p"): - self.text("TEXT TEXT TEXT") - - self.modal(modal_name, modal_content) - - def modal_button( - self, text, modal_name, disable: bool = False, style: str = "w3-teal" - ): - """Modal opening button""" - onclick = f"document.getElementById('{modal_name}').style.display='block'" - self.button(text, style=style, onclick=onclick, disable=disable) - - def button( - self, text: str, onclick: str = "", style="w3-teal", disable: bool = False - ): - """Generic button component""" - - assert text - - klass = f"w3-btn {style}" - - if disable: - klass += " w3-disabled" - onclick = "" - - with self.tag("button", klass=klass, onclick=onclick): - self.text(text) - - def modal(self, modal_name, content_function): - """Generic modal""" - with self.tag("div", id=modal_name, klass="w3-modal"): - with self.tag("div", klass="w3-modal-content"): - with self.tag("div", klass="w3-container"): - with self.tag( - "span", - onclick=f"document.getElementById('{modal_name}').style.display='none'", - klass="w3-button w3-display-topright", - ): - self.doc.asis("×") - content_function() - - def recipe_subsection( - self, recipe, title, sankey: bool = False, title_style="h3", show_title=True - ): - """recipe subsection""" - - if show_title: - self.section_header(title, title_style) - - recipe_name = title.lower().replace(" ", "_") - - if sankey: - fig = recipe.sankey() - self.plotly_graph(fig, f"{recipe_name}.html") - - # self.section_header("Products", "h4") - - df = recipe.products.compounds.get_df() - - def modal_content(): - """modal content""" - self.table(df, style="w3-table-all w3-small") - - modal_name = f"{recipe_name}_products" - self.modal_button("products", modal_name) - self.modal(modal_name, modal_content) - - if intermediates := recipe.intermediates: - # self.section_header("Intermediates", "h4") - df = intermediates.compounds.get_df() - - def modal_content(): - """modal content""" - self.table(df, style="w3-table-all w3-small") - - modal_name = f"{recipe_name}_intermediates" - self.modal_button("intermediates", modal_name) - self.modal(modal_name, modal_content) - - # self.section_header("Reactants", "h4") - - df = recipe.reactants.df - - def modal_content(): - """modal content""" - self.table(df, style="w3-table-all w3-small") - - modal_name = f"{recipe_name}_reactants" - self.modal_button("reactants", modal_name) - self.modal(modal_name, modal_content) - - # self.section_header("Reactions", "h4") - - df = recipe.reactions.get_df(mols=False) - - def modal_content(): - """modal content""" - self.table(df, style="w3-table-all w3-small") - - modal_name = f"{recipe_name}_reactions" - self.modal_button("reactions", modal_name) - self.modal(modal_name, modal_content) - - def scorer_attribute(self, attribute, histogram: bool = True): - """scorer attribute component""" - - from .scoring import DEFAULT_ATTRIBUTES - - key = attribute.key - - self.section_header(f'{attribute._type}: "{key}"') - - with self.tag("ul"): - self.var("weight", f"{attribute.weight:.2f}", tag="li") - self.var("inverse", f"{attribute.inverse}", tag="li") - self.var("min", f"{attribute.min:.2f}", tag="li") - self.var("max", f"{attribute.max:.2f}", tag="li") - self.var("mean", f"{attribute.mean:.2f}", tag="li") - self.var("std", f"{attribute.std:.2f}", tag="li") - - if key in DEFAULT_ATTRIBUTES: - description = DEFAULT_ATTRIBUTES[key]["description"] - if attribute.inverse: - description += "(Lower is better)" - else: - description += "(Lower is better)" - - self.var("Description", description, tag="li") - - if histogram: - fig = attribute.histogram(progress=True) - self.plotly_graph(fig, f"attribute_{key}_hist.html") - - ### SECTION CONTENT - - def sec_targets(self) -> None: - """Section on targets""" - - title = "Protein Target" - targets = self.animal.targets - - if len(targets) > 1: - title += "s" - - self.section_header(title) - - for target in targets: - self.section_header(target.name, "h3") - - self.var("name", target.name) - - subsites = target.subsites - - if subsites: - - self.section_header("Subsites", "h4") - with self.tag("ul"): - for subsite in subsites: - self.var(f"Site {subsite.id}", subsite.name, tag="li") - - # try: - fig = self.funnel() - self.plotly_graph(fig, "project_funnel.html") - # except Exception as e: - # mrich.error(e) - - def sec_hits(self) -> None: - """Section on experimental hits""" - - title = "Experimental hits" - hit_compounds = self.animal.compounds(tag="hits") - hit_poses = self.animal.poses(tag="hits") - - self.section_header(title) - - with self.tag("ul"): - self.var("#compounds", len(hit_compounds), tag="li") - self.var("#observations", len(hit_poses), tag="li") - - from .animal import GENERATED_TAG_COLS - - # tag statistics - fig = self.animal.plot_tag_statistics( - show_compounds=False, - poses=hit_poses, - logo=None, - title="Tags", - skip=["Pose", "hits"] + GENERATED_TAG_COLS, - ) - - self.plotly_graph(fig, "hit_tags.html") - - # files - self.section_header("Downloads", "h3") - path = self.resource_dir / "hit_poses.sdf" - rel_path = Path(self.resource_dir.name) / "hit_poses.sdf" - hit_poses.write_sdf(path, inspirations=False) - table_data = [ - dict( - Name="hit_poses.sdf", - Description="SDF of the experimental hits", - Download=f'SDF', - ) - ] - self.table(table_data) - - def sec_scaffolds(self) -> None: - """Section on scaffolds""" - - title = "Scaffolds" - self.section_header(title) - - self.section_header("All scaffolds", "h3") - - with self.tag("ul"): - self.var("#compounds", len(self.all_scaffolds), tag="li") - - # route dict? - df = self.all_scaffolds.get_df(mol=False, num_poses=True) # , routes=True) - - def modal_content(): - """modal content""" - self.table(df, style="w3-table-all w3-small") - - modal_name = "all_scaffolds_modal" - self.modal_button(f"all scaffolds table", modal_name) - self.modal(modal_name, modal_content) - - # quoting? - - self.section_header("Selected scaffolds", "h3") - - self.compound_grid(self.scaffolds, pose_modal=False) - - # files - self.section_header("Downloads", "h3") - - table_data = [] - - path = self.resource_dir / "all_scaffold_smiles.csv" - rel_path = Path(self.resource_dir.name) / path.name - self.scaffolds.write_smiles_csv(path) - table_data.append( - dict( - Name="All scaffolds", - Description="CSV of scaffold SMILES", - Download=f'CSV', - ) - ) - - path = self.resource_dir / "selected_scaffold_smiles.csv" - rel_path = Path(self.resource_dir.name) / path.name - self.scaffolds.write_smiles_csv(path) - table_data.append( - dict( - Name="Selected scaffolds", - Description="CSV of scaffold SMILES", - Download=f'CSV', - ) - ) - - self.table(table_data) - - def sec_elaborations(self) -> None: - """Section on elaborations""" - - elabs = self.scaffolds.elabs - - if not elabs: - mrich.warning("No elaborations") - return None - - self._elaborations = elabs - - title = "Elaborations" - self.section_header(title) - - fig = self.animal.plot_reaction_funnel( - title="Syndirella elaboration space", logo=False - ) - self.plotly_graph(fig, "reaction_funnel.html") - - def sec_quoting(self) -> None: - """Section on quoting""" - - title = "Quoting" - self.section_header(title) - - def sec_product_pool(self) -> None: - """Section on product_pool""" - - title = "product_pool" - self.section_header(title) - - def sec_route_pool(self) -> None: - """Section on route_pool""" - - title = "route_pool" - self.section_header(title) - - def sec_rgen(self) -> None: - """Section on rgen""" - - title = "Random Recipe Generation" - self.section_header(title) - - rgen = self.rgen - - with self.tag("ul"): - self.var("suppliers", rgen.suppliers, tag="li") - self.var("max_lead_time", rgen.max_lead_time, tag="li") - self.var("route_pool", len(rgen.route_pool), tag="li") - - self.recipe_subsection(rgen.starting_recipe, "Starting Recipe", sankey=False) - - def sec_scorer(self) -> None: - """Section on scorer""" - - title = "Recipe Selection" - self.section_header(title) - - scorer = self.scorer - - with self.tag("ul"): - self.var("#recipes", len(scorer.recipes), tag="li") - self.var("#attributes", len(scorer.attributes), tag="li") - - fig = scorer.plot(["price", "score"]) - self.plotly_graph(fig, "scorer_scatter.html") - - for attribute in scorer.attributes: - self.scorer_attribute(attribute) - - def sec_proposals(self) -> None: - """Section on proposals""" - - title = "Proposal Recipes" - self.section_header(title) - - table_data = [] - - for proposal in self.proposals: - - d = {} - - d["hash"] = str(proposal.hash) - d["price"] = str(proposal.price) - d["price/compound"] = str(proposal.price / proposal.num_products) - - for attribute in self.scorer.attributes: - d[f"{attribute.key} w={attribute.weight}"] = ( - f"{attribute.get_value(proposal):.1f} ({attribute.unweighted(proposal):.0%})" - ) - - table_data.append(d) - - self.table(table_data) - - for proposal in self.proposals: - - filename = f"proposal_{proposal.hash}.html" - path = self.resource_dir / filename - - self.section_header(str(proposal), "h3") - - from .plotting import plot_compound_tsnee - - if path.exists() and self.skip_existing: - - self.plotly_graph(None, filename, write=False) - - else: - - fig = plot_compound_tsnee( - proposal.products.compounds, - logo=False, - legend=False, - title="Product Clustering", - ) - - self.plotly_graph(fig, filename) - - self.recipe_subsection( - proposal, f"Recipe {proposal.hash}", sankey=False, show_title=False - ) - - # files - self.section_header("Downloads", "h3") - - table_data = [] - - for proposal in self.proposals: - - # JSON - filename = f"Recipe_{proposal.hash}.json" - original = self.rgen.recipe_dir / filename - if not original.exists() and self.extra_recipe_dir: - original = Path(self.extra_recipe_dir) / filename - shutil.copyfile(original, self.resource_dir / filename) - - path = self.resource_dir / filename - rel_path = Path(self.resource_dir.name) / filename - - table_data.append( - dict( - Name=str(proposal), - Description="Recipe JSON", - Download=f'JSON', - ) - ) - - # SDF - filename = f"Recipe_{proposal.hash}_poses.sdf" - - try: - original = self.rgen.recipe_dir / filename - if not original.exists() and self.extra_recipe_dir: - original = Path(self.extra_recipe_dir) / filename - shutil.copyfile(original, self.resource_dir / filename) - - path = self.resource_dir / filename - rel_path = Path(self.resource_dir.name) / filename - - table_data.append( - dict( - Name=str(proposal), - Description="Recipe product poses (Fragalysis compatible)", - Download=f'SDF', - ) - ) - except FileNotFoundError: - # hit_poses.write_sdf(path, inspirations=False) - mrich.error(f"Could not find pose SDF: {original}") - - # CAR CSVs - - filename = f"Recipe_{proposal.hash}_CAR" - path = self.resource_dir / f"{filename}.csv" - proposal.write_CAR_csv(path) - - for file in Path(self.resource_dir).glob(f"{filename}*.csv"): - - rel_path = Path(self.resource_dir.name) / file.name - - table_data.append( - dict( - Name=str(proposal), - Description=f"CAR input file [{file.name}]", - Download=f'CSV', - ) - ) - - # Reactant CSV - - filename = f"Recipe_{proposal.hash}_reactants" - path = self.resource_dir / f"{filename}.csv" - - if not path.exists() or not self.skip_existing: - proposal.write_reactant_csv(path) - - rel_path = Path(self.resource_dir.name) / path.name - - table_data.append( - dict( - Name=str(proposal), - Description=f"Reactant data file", - Download=f'CSV', - ) - ) - - # Product CSV - - filename = f"Recipe_{proposal.hash}_products" - path = self.resource_dir / f"{filename}.csv" - - if not path.exists() or not self.skip_existing: - proposal.write_product_csv(path) - - rel_path = Path(self.resource_dir.name) / path.name - - table_data.append( - dict( - Name=str(proposal), - Description=f"Product data file", - Download=f'CSV', - ) - ) - - # Scaffold/chemistry CSV - - filename = f"Recipe_{proposal.hash}_chemistry" - path = self.resource_dir / f"{filename}.csv" - - if not path.exists() or not self.skip_existing: - proposal.write_chemistry_csv(path) - - rel_path = Path(self.resource_dir.name) / path.name - - table_data.append( - dict( - Name=str(proposal), - Description=f"Chemistry review file", - Download=f'CSV', - ) - ) - - self.table(table_data) - - ### GRAPHS - - def funnel( - self, - log_y: bool = True, - scaffolds: "CompoundSet | None" = None, - num_inspirations: int | None = None, - num_inspiration_sets: int | None = None, - ) -> "plotly.graph_objects.Figure": - """Funnel plot""" - - if scaffolds is None: - scaffolds = self.all_scaffolds - scaffold_poses = self.all_scaffold_poses - elabs = self.all_elabs - else: - scaffold_poses = scaffolds.poses - elabs = scaffolds.elabs - - import plotly.express as px - from numpy import log as np_log - from pandas import DataFrame - - data = dict( - number=[ - num_inspirations or scaffold_poses.num_inspirations, - num_inspiration_sets or scaffold_poses.num_inspiration_sets, - len(scaffolds), - len(elabs), - len(self.rgen.route_pool), - len(self.proposal.products), - ], - category=[ - "Fragments", - "Fragment Sets", - "Scaffolds", - "Elaborations", - "Accessible Products", - "Selected Products", - ], - ) - - df = DataFrame(data) - - if log_y: - y = "log_y" - df["log_y"] = df.apply(lambda x: np_log(x["number"]), axis=1) - else: - y = "number" - - fig = px.funnel(df, x="category", y=y, text="number", log_y=False) - - fig.data[0].texttemplate = "%{text}" - fig.update_layout( - xaxis={"side": "top"}, - ) - - fig.layout.xaxis.title.text = "" - - # title = title or f"{animal.name}: Reaction statistics" - - # if subtitle: - # title = f"{title}
{subtitle}" - - # fig.update_layout(title=title, title_automargin=False, title_yref="container") - - return fig diff --git a/hippo/xchem_hippo/__init__.py b/hippo/xchem_hippo/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hippo/xchem_hippo/asgi.py b/hippo/xchem_hippo/asgi.py new file mode 100644 index 00000000..a393821a --- /dev/null +++ b/hippo/xchem_hippo/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for xchem_hippo project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/6.0/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'xchem_hippo.settings') + +application = get_asgi_application() diff --git a/hippo/xchem_hippo/urls.py b/hippo/xchem_hippo/urls.py new file mode 100644 index 00000000..dcf76c71 --- /dev/null +++ b/hippo/xchem_hippo/urls.py @@ -0,0 +1,23 @@ +""" +URL configuration for xchem_hippo project. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/6.0/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" + +from django.contrib import admin +from django.urls import path + +urlpatterns = [ + path('admin/', admin.site.urls), +] diff --git a/hippo/xchem_hippo/wsgi.py b/hippo/xchem_hippo/wsgi.py new file mode 100644 index 00000000..bc49854e --- /dev/null +++ b/hippo/xchem_hippo/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for xchem_hippo project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/6.0/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'xchem_hippo.settings') + +application = get_wsgi_application() diff --git a/images/postgres/Dockerfile b/images/postgres/Dockerfile new file mode 100644 index 00000000..35ebc42f --- /dev/null +++ b/images/postgres/Dockerfile @@ -0,0 +1,122 @@ +# Single Dockerfile: builder stage builds RDKit; final stage is Debian Trixie runtime +ARG PG_MAJOR=18 +ARG RDKIT_VERSION=Release_2025_09_1 + +######################################## +# Stage: builder (build RDKit) +######################################## +FROM debian:trixie AS builder + +ARG PG_MAJOR +ARG RDKIT_VERSION +ENV DEBIAN_FRONTEND=noninteractive +ENV CMAKE_PREFIX_PATH=/usr/local + +# Install build deps and PGDG repo so postgresql-18 is available during builder +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates wget gnupg2 lsb-release dirmngr && \ + CODENAME="$(lsb_release -cs)"; \ + wget -qO /usr/share/keyrings/pgdg.gpg https://www.postgresql.org/media/keys/ACCC4CF8.asc; \ + echo "deb [signed-by=/usr/share/keyrings/pgdg.gpg] http://apt.postgresql.org/pub/repos/apt ${CODENAME}-pgdg main" \ + > /etc/apt/sources.list.d/pgdg.list && \ + apt-get update && \ + apt-get install -y --no-install-recommends \ + build-essential cmake git ca-certificates curl wget python3 python3-pip \ + postgresql-${PG_MAJOR} postgresql-server-dev-${PG_MAJOR} \ + libeigen3-dev libcairo2-dev libfreetype6-dev libpng-dev libjpeg-dev \ + zlib1g-dev pkg-config libboost-all-dev && \ + rm -rf /var/lib/apt/lists/* + +# Fetch RDKit and build +WORKDIR /opt +RUN git clone --depth 1 --branch "${RDKIT_VERSION}" https://github.com/rdkit/rdkit.git /opt/rdkit + +WORKDIR /opt/rdkit_build +RUN set -eux; \ + PG_INC="$(/usr/lib/postgresql/${PG_MAJOR}/bin/pg_config --includedir)"; \ + PG_INC_SRV="$(/usr/lib/postgresql/${PG_MAJOR}/bin/pg_config --includedir-server)"; \ + PG_LIB="$(/usr/lib/postgresql/${PG_MAJOR}/bin/pg_config --libdir)"; \ + cmake -S /opt/rdkit -B /opt/rdkit_build \ + -DCMAKE_BUILD_TYPE=Release \ + -DRDK_BUILD_PGSQL=ON \ + -DRDK_BUILD_PYTHON_WRAPPERS=OFF \ + -DRDK_BUILD_INCHI_SUPPORT=ON \ + -DRDK_BUILD_AVALON_SUPPORT=ON \ + -DRDK_BUILD_CAIRO_SUPPORT=ON \ + -DRDK_PGSQL_STATIC=ON \ + -DPostgreSQL_CONFIG=/usr/lib/postgresql/${PG_MAJOR}/bin/pg_config \ + -DPG_CONFIG=/usr/lib/postgresql/${PG_MAJOR}/bin/pg_config \ + -DPostgreSQL_INCLUDE_DIR="${PG_INC}" \ + -DPostgreSQL_TYPE_INCLUDE_DIR="${PG_INC_SRV}" \ + -DPostgreSQL_LIBRARY_DIR="${PG_LIB}"; \ + cmake --build /opt/rdkit_build -j"$(nproc)"; \ + cmake --install /opt/rdkit_build --strip + +# Preserve artifacts in canonical places for copying into final +RUN mkdir -p /usr/lib/postgresql/${PG_MAJOR}/lib /usr/share/postgresql/${PG_MAJOR}/extension /usr/local/lib && \ + cp -a /usr/lib/postgresql/${PG_MAJOR}/lib/rdkit.so /usr/lib/postgresql/${PG_MAJOR}/lib/ 2>/dev/null || true && \ + cp -a /usr/share/postgresql/${PG_MAJOR}/extension/rdkit* /usr/share/postgresql/${PG_MAJOR}/extension/ 2>/dev/null || true && \ + cp -a /usr/lib/x86_64-linux-gnu/libboost_*.so* /usr/local/lib/ 2>/dev/null || true + +######################################## +# Stage: final (runtime on Debian Trixie) +######################################## +FROM debian:trixie AS final + +ARG PG_MAJOR +ENV DEBIAN_FRONTEND=noninteractive +ENV PATH="/usr/lib/postgresql/${PG_MAJOR}/bin:${PATH}" +ENV PGDATA=/var/lib/postgresql/data + +# Add PGDG repo in final stage so postgresql-18 packages are available +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates wget gnupg2 lsb-release dirmngr && \ + CODENAME="$(lsb_release -cs)"; \ + wget -qO /usr/share/keyrings/pgdg.gpg https://www.postgresql.org/media/keys/ACCC4CF8.asc; \ + echo "deb [signed-by=/usr/share/keyrings/pgdg.gpg] http://apt.postgresql.org/pub/repos/apt ${CODENAME}-pgdg main" \ + > /etc/apt/sources.list.d/pgdg.list && \ + apt-get update && \ + apt-get install -y --no-install-recommends \ + ca-certificates wget gnupg2 lsb-release dirmngr gettext procps \ + libcairo2 libfreetype6 libpng16-16 libjpeg62-turbo zlib1g gosu \ + postgresql-${PG_MAJOR} postgresql-server-dev-${PG_MAJOR} && \ + rm -rf /var/lib/apt/lists/* + +# Copy artifacts from the local builder stage (NOT from a deleted external image) +COPY --from=builder /usr/lib/postgresql/${PG_MAJOR}/lib/rdkit.so /usr/lib/postgresql/${PG_MAJOR}/lib/ +COPY --from=builder /usr/share/postgresql/${PG_MAJOR}/extension/rdkit* /usr/share/postgresql/${PG_MAJOR}/extension/ +COPY --from=builder /usr/local/lib/ /usr/local/lib/ + +# change UID/GID and fix ownership (optional) +RUN set -eux; \ + OLD_UID="$(id -u postgres)"; OLD_GID="$(id -g postgres)"; \ + usermod -u 999 postgres || true; groupmod -g 999 postgres || true; \ + find / -path /proc -prune -o -user "${OLD_UID}" -exec chown -h 999 {} + 2>/dev/null || true; \ + find / -path /proc -prune -o -group "${OLD_GID}" -exec chgrp -h 999 {} + 2>/dev/null || true + +# Make common PG tools available in /usr/bin +RUN ln -sf /usr/lib/postgresql/${PG_MAJOR}/bin/initdb /usr/bin/initdb || true && \ + ln -sf /usr/lib/postgresql/${PG_MAJOR}/bin/pg_isready /usr/bin/pg_isready || true && \ + ln -sf /usr/lib/postgresql/${PG_MAJOR}/bin/pg_ctl /usr/bin/pg_ctl || true && \ + ln -sf /usr/lib/postgresql/${PG_MAJOR}/bin/psql /usr/bin/psql || true + +# Vendor entrypoint (place official script at files/docker-entrypoint.sh) +# COPY files/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh +COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh +RUN chmod +x /usr/local/bin/docker-entrypoint.sh + +# Prepare dirs and permissions +RUN mkdir -p /docker-entrypoint-initdb.d /var/lib/postgresql/data && chown -R postgres:postgres /var/lib/postgresql /docker-entrypoint-initdb.d /usr/share/postgresql/${PG_MAJOR}/extension + +RUN ldconfig + +EXPOSE 5432 +# adding this in compose file instead +# VOLUME ["/var/lib/postgresql/data"] + +# entrypoint script needs root user to init db, then drops to postgres +# USER postgres +WORKDIR /var/lib/postgresql + +ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] +CMD ["postgres", "-c", "listen_addresses=*", "-D", "/var/lib/postgresql/data"] diff --git a/images/postgres/docker-entrypoint.sh b/images/postgres/docker-entrypoint.sh new file mode 100755 index 00000000..b49bcc4c --- /dev/null +++ b/images/postgres/docker-entrypoint.sh @@ -0,0 +1,382 @@ +#!/usr/bin/env bash +set -Eeo pipefail +# TODO swap to -Eeuo pipefail above (after handling all potentially-unset variables) + +# usage: file_env VAR [DEFAULT] +# ie: file_env 'XYZ_DB_PASSWORD' 'example' +# (will allow for "$XYZ_DB_PASSWORD_FILE" to fill in the value of +# "$XYZ_DB_PASSWORD" from a file, especially for Docker's secrets feature) +file_env() { + local var="$1" + local fileVar="${var}_FILE" + local def="${2:-}" + if [ "${!var:-}" ] && [ "${!fileVar:-}" ]; then + printf >&2 'error: both %s and %s are set (but are exclusive)\n' "$var" "$fileVar" + exit 1 + fi + local val="$def" + if [ "${!var:-}" ]; then + val="${!var}" + elif [ "${!fileVar:-}" ]; then + val="$(< "${!fileVar}")" + fi + export "$var"="$val" + unset "$fileVar" +} + +# check to see if this file is being run or sourced from another script +_is_sourced() { + # https://unix.stackexchange.com/a/215279 + [ "${#FUNCNAME[@]}" -ge 2 ] \ + && [ "${FUNCNAME[0]}" = '_is_sourced' ] \ + && [ "${FUNCNAME[1]}" = 'source' ] +} + +# used to create initial postgres directories and if run as root, ensure ownership to the "postgres" user +docker_create_db_directories() { + local user; user="$(id -u)" + + mkdir -p "$PGDATA" + # ignore failure since there are cases where we can't chmod (and PostgreSQL might fail later anyhow - it's picky about permissions of this directory) + chmod 00700 "$PGDATA" || : + + # ignore failure since it will be fine when using the image provided directory; see also https://github.com/docker-library/postgres/pull/289 + mkdir -p /var/run/postgresql || : + chmod 03775 /var/run/postgresql || : + + # Create the transaction log directory before initdb is run so the directory is owned by the correct user + if [ -n "${POSTGRES_INITDB_WALDIR:-}" ]; then + mkdir -p "$POSTGRES_INITDB_WALDIR" + if [ "$user" = '0' ]; then + find "$POSTGRES_INITDB_WALDIR" \! -user postgres -exec chown postgres '{}' + + fi + chmod 700 "$POSTGRES_INITDB_WALDIR" + fi + + # allow the container to be started with `--user` + if [ "$user" = '0' ]; then + find "$PGDATA" \! -user postgres -exec chown postgres '{}' + + find /var/run/postgresql \! -user postgres -exec chown postgres '{}' + + fi +} + +# initialize empty PGDATA directory with new database via 'initdb' +# arguments to `initdb` can be passed via POSTGRES_INITDB_ARGS or as arguments to this function +# `initdb` automatically creates the "postgres", "template0", and "template1" dbnames +# this is also where the database user is created, specified by `POSTGRES_USER` env +docker_init_database_dir() { + # "initdb" is particular about the current user existing in "/etc/passwd", so we use "nss_wrapper" to fake that if necessary + # see https://github.com/docker-library/postgres/pull/253, https://github.com/docker-library/postgres/issues/359, https://cwrap.org/nss_wrapper.html + local uid; uid="$(id -u)" + if ! getent passwd "$uid" &> /dev/null; then + # see if we can find a suitable "libnss_wrapper.so" (https://salsa.debian.org/sssd-team/nss-wrapper/-/commit/b9925a653a54e24d09d9b498a2d913729f7abb15) + local wrapper + for wrapper in {/usr,}/lib{/*,}/libnss_wrapper.so; do + if [ -s "$wrapper" ]; then + NSS_WRAPPER_PASSWD="$(mktemp)" + NSS_WRAPPER_GROUP="$(mktemp)" + export LD_PRELOAD="$wrapper" NSS_WRAPPER_PASSWD NSS_WRAPPER_GROUP + local gid; gid="$(id -g)" + printf 'postgres:x:%s:%s:PostgreSQL:%s:/bin/false\n' "$uid" "$gid" "$PGDATA" > "$NSS_WRAPPER_PASSWD" + printf 'postgres:x:%s:\n' "$gid" > "$NSS_WRAPPER_GROUP" + break + fi + done + fi + + if [ -n "${POSTGRES_INITDB_WALDIR:-}" ]; then + set -- --waldir "$POSTGRES_INITDB_WALDIR" "$@" + fi + + # --pwfile refuses to handle a properly-empty file (hence the "\n"): https://github.com/docker-library/postgres/issues/1025 + eval 'initdb --username="$POSTGRES_USER" --pwfile=<(printf "%s\n" "$POSTGRES_PASSWORD") '"$POSTGRES_INITDB_ARGS"' "$@"' + + # unset/cleanup "nss_wrapper" bits + if [[ "${LD_PRELOAD:-}" == */libnss_wrapper.so ]]; then + rm -f "$NSS_WRAPPER_PASSWD" "$NSS_WRAPPER_GROUP" + unset LD_PRELOAD NSS_WRAPPER_PASSWD NSS_WRAPPER_GROUP + fi +} + +# print large warning if POSTGRES_PASSWORD is long +# error if both POSTGRES_PASSWORD is empty and POSTGRES_HOST_AUTH_METHOD is not 'trust' +# print large warning if POSTGRES_HOST_AUTH_METHOD is set to 'trust' +# assumes database is not set up, ie: [ -z "$DATABASE_ALREADY_EXISTS" ] +docker_verify_minimum_env() { + if [ -z "$POSTGRES_PASSWORD" ] && [ 'trust' != "$POSTGRES_HOST_AUTH_METHOD" ]; then + # The - option suppresses leading tabs but *not* spaces. :) + cat >&2 <<-'EOE' + Error: Database is uninitialized and superuser password is not specified. + You must specify POSTGRES_PASSWORD to a non-empty value for the + superuser. For example, "-e POSTGRES_PASSWORD=password" on "docker run". + + You may also use "POSTGRES_HOST_AUTH_METHOD=trust" to allow all + connections without a password. This is *not* recommended. + + See PostgreSQL documentation about "trust": + https://www.postgresql.org/docs/current/auth-trust.html + EOE + exit 1 + fi + if [ 'trust' = "$POSTGRES_HOST_AUTH_METHOD" ]; then + cat >&2 <<-'EOWARN' + ******************************************************************************** + WARNING: POSTGRES_HOST_AUTH_METHOD has been set to "trust". This will allow + anyone with access to the Postgres port to access your database without + a password, even if POSTGRES_PASSWORD is set. See PostgreSQL + documentation about "trust": + https://www.postgresql.org/docs/current/auth-trust.html + In Docker's default configuration, this is effectively any other + container on the same system. + + It is not recommended to use POSTGRES_HOST_AUTH_METHOD=trust. Replace + it with "-e POSTGRES_PASSWORD=password" instead to set a password in + "docker run". + ******************************************************************************** + EOWARN + fi +} +# similar to the above, but errors if there are any "old" databases detected (usually due to upgrades without pg_upgrade) +docker_error_old_databases() { + if [ -n "${OLD_DATABASES[0]:-}" ]; then + cat >&2 <<-EOE + Error: in 18+, these Docker images are configured to store database data in a + format which is compatible with "pg_ctlcluster" (specifically, using + major-version-specific directory names). This better reflects how + PostgreSQL itself works, and how upgrades are to be performed. + + See also https://github.com/docker-library/postgres/pull/1259 + + Counter to that, there appears to be PostgreSQL data in: + ${OLD_DATABASES[*]} + + This is usually the result of upgrading the Docker image without + upgrading the underlying database using "pg_upgrade" (which requires both + versions). + + The suggested container configuration for 18+ is to place a single mount + at /var/lib/postgresql which will then place PostgreSQL data in a + subdirectory, allowing usage of "pg_upgrade --link" without mount point + boundary issues. + + See https://github.com/docker-library/postgres/issues/37 for a (long) + discussion around this process, and suggestions for how to do so. + EOE + exit 1 + fi +} + +# usage: docker_process_init_files [file [file [...]]] +# ie: docker_process_init_files /always-initdb.d/* +# process initializer files, based on file extensions and permissions +docker_process_init_files() { + # psql here for backwards compatibility "${psql[@]}" + psql=( docker_process_sql ) + + printf '\n' + local f + for f; do + case "$f" in + *.sh) + # https://github.com/docker-library/postgres/issues/450#issuecomment-393167936 + # https://github.com/docker-library/postgres/pull/452 + if [ -x "$f" ]; then + printf '%s: running %s\n' "$0" "$f" + "$f" + else + printf '%s: sourcing %s\n' "$0" "$f" + . "$f" + fi + ;; + *.sql) printf '%s: running %s\n' "$0" "$f"; docker_process_sql -f "$f"; printf '\n' ;; + *.sql.gz) printf '%s: running %s\n' "$0" "$f"; gunzip -c "$f" | docker_process_sql; printf '\n' ;; + *.sql.xz) printf '%s: running %s\n' "$0" "$f"; xzcat "$f" | docker_process_sql; printf '\n' ;; + *.sql.zst) printf '%s: running %s\n' "$0" "$f"; zstd -dc "$f" | docker_process_sql; printf '\n' ;; + *) printf '%s: ignoring %s\n' "$0" "$f" ;; + esac + printf '\n' + done +} + +# Execute sql script, passed via stdin (or -f flag of pqsl) +# usage: docker_process_sql [psql-cli-args] +# ie: docker_process_sql --dbname=mydb <<<'INSERT ...' +# ie: docker_process_sql -f my-file.sql +# ie: docker_process_sql > "$PGDATA/pg_hba.conf" +} + +# start socket-only postgresql server for setting up or running scripts +# all arguments will be passed along as arguments to `postgres` (via pg_ctl) +docker_temp_server_start() { + if [ "$1" = 'postgres' ]; then + shift + fi + + # internal start of server in order to allow setup using psql client + # does not listen on external TCP/IP and waits until start finishes + set -- "$@" -c listen_addresses='' -p "${PGPORT:-5432}" + + # unset NOTIFY_SOCKET so the temporary server doesn't prematurely notify + # any process supervisor. + NOTIFY_SOCKET= \ + PGUSER="${PGUSER:-$POSTGRES_USER}" \ + pg_ctl -D "$PGDATA" \ + -o "$(printf '%q ' "$@")" \ + -w start +} + +# stop postgresql server after done setting up user and running scripts +docker_temp_server_stop() { + PGUSER="${PGUSER:-postgres}" \ + pg_ctl -D "$PGDATA" -m fast -w stop +} + +# check arguments for an option that would cause postgres to stop +# return true if there is one +_pg_want_help() { + local arg + for arg; do + case "$arg" in + # postgres --help | grep 'then exit' + # leaving out -C on purpose since it always fails and is unhelpful: + # postgres: could not access the server configuration file "/var/lib/postgresql/data/postgresql.conf": No such file or directory + -'?'|--help|--describe-config|-V|--version) + return 0 + ;; + esac + done + return 1 +} + +_main() { + # if first arg looks like a flag, assume we want to run postgres server + if [ "${1:0:1}" = '-' ]; then + set -- postgres "$@" + fi + + if [ "$1" = 'postgres' ] && ! _pg_want_help "$@"; then + docker_setup_env + # setup data directories and permissions (when run as root) + # why is this here?? I'm creating them in dockerfile + docker_create_db_directories + if [ "$(id -u)" = '0' ]; then + # then restart script as postgres user + exec gosu postgres "$BASH_SOURCE" "$@" + fi + + # only run initialization on an empty data directory + if [ -z "$DATABASE_ALREADY_EXISTS" ]; then + docker_verify_minimum_env + docker_error_old_databases + + # check dir permissions to reduce likelihood of half-initialized database + ls /docker-entrypoint-initdb.d/ > /dev/null + + docker_init_database_dir + pg_setup_hba_conf "$@" + + # PGPASSWORD is required for psql when authentication is required for 'local' connections via pg_hba.conf and is otherwise harmless + # e.g. when '--auth=md5' or '--auth-local=md5' is used in POSTGRES_INITDB_ARGS + export PGPASSWORD="${PGPASSWORD:-$POSTGRES_PASSWORD}" + docker_temp_server_start "$@" + + docker_setup_db + docker_process_init_files /docker-entrypoint-initdb.d/* + + docker_temp_server_stop + unset PGPASSWORD + + cat <<-'EOM' + + PostgreSQL init process complete; ready for start up. + + EOM + else + cat <<-'EOM' + + PostgreSQL Database directory appears to contain a database; Skipping initialization + + EOM + fi + fi + exec "$@" +} + +if ! _is_sourced; then + _main "$@" +fi diff --git a/images/xchem-designdb/.dockerignore b/images/xchem-designdb/.dockerignore new file mode 100644 index 00000000..e96f3c5a --- /dev/null +++ b/images/xchem-designdb/.dockerignore @@ -0,0 +1,29 @@ +# ========================================================= +# .dockerignore +# ========================================================= + +# Environment and secrets +.env +.env.* +*.env + +# Logs +*.log +*.log.* + +# Git +.git/ +.gitignore + +# IDE and OS +.vscode/ +.idea/ +.DS_Store +Thumbs.db +._* + +# Temporary files +*.tmp +*.temp +*.bak +*.backup diff --git a/images/xchem-designdb/.gitignore b/images/xchem-designdb/.gitignore new file mode 100644 index 00000000..d1e8b901 --- /dev/null +++ b/images/xchem-designdb/.gitignore @@ -0,0 +1,42 @@ +# ========================================================= +# Database Service .gitignore +# ========================================================= + +# Environment variables +.env +.env.local +.env.production +.env.development + +# Docker volumes and data +docker-data/ +postgres-data/ +data/ +*.db +*.sqlite + +# Logs +*.log +logs/ + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db +desktop.ini + +# IDE files +.vscode/ +.idea/ +*.swp +*.swo + +# Temporary files +*.tmp +*.temp +*.bak +*.backup diff --git a/images/xchem-designdb/01_schema.sql b/images/xchem-designdb/01_schema.sql new file mode 100644 index 00000000..0e3ce0d7 --- /dev/null +++ b/images/xchem-designdb/01_schema.sql @@ -0,0 +1,1488 @@ +-- ========================================================= +-- designdb Database Schema +-- ========================================================= + +-- ========================================================= +DROP SCHEMA IF EXISTS designdb CASCADE; +-- ========================================================= + +-- ========================================================= +-- PREREQUISITES & EXTENSIONS +-- ========================================================= + +CREATE SCHEMA IF NOT EXISTS designdb; +CREATE SCHEMA IF NOT EXISTS rdkit; + +CREATE EXTENSION IF NOT EXISTS rdkit WITH SCHEMA rdkit; +CREATE EXTENSION IF NOT EXISTS pgcrypto WITH SCHEMA designdb; + +SET search_path TO designdb, rdkit, public; + +REVOKE CREATE ON SCHEMA public FROM PUBLIC; + +-- ========================================================= +-- TABLES (ordered by FK dependencies) +-- ========================================================= + +CREATE TABLE IF NOT EXISTS designdb.targets ( + id BIGSERIAL PRIMARY KEY, --Internal ID inserted when registering target via Fragalysis + external_target_id BIGINT, -- ID of this target in the external database (Scarab link) + target_name TEXT NOT NULL, --Insert from HIPPO codebase. Must be a link to Scarab protein production target + target_metadata TEXT, -- Not populated by code + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_target UNIQUE (target_name) +); + +CREATE TABLE IF NOT EXISTS designdb.compounds ( + id BIGSERIAL PRIMARY KEY, + compound_inchikey TEXT, -- Populated by RDKit cartridge trigger from compound_smiles (do not insert by code). Maybe insert by the codebase or jupyter. Do we need to write this by code or can be calculated by the cartridge? + compound_alias TEXT, -- Maybe insert by the codebase. + compound_smiles TEXT, -- Inserted by the codebase. Trigger populates compound_mol and compound_inchikey. 2D flat SMILES. Is this 2d flat smiles without any stereochemistry? LR - Yes 2D. Looks like designdb function sanitise_smiles does remove stereochemistry - will this be a problem when a user wants to register a design with defined stereochemistry? + compound_hash TEXT NOT NULL, -- Canonical identity hash from application pipeline (e.g. RDKit RegistrationHash after SuperParent); pair with rdkit_version for reproducibility + base_compound_id BIGINT REFERENCES designdb.compounds (id) ON DELETE SET NULL, -- Not populated by code + -- compound_mol rdkit.mol, -- Replaced by TEXT CTAB below: JDBC showed SMILES text; mol_to_ctab gives a molfile string that Scarab will easily convert to structure. + compound_mol TEXT, -- V2000 CTAB (mol block) from rdkit.mol_to_ctab(mol_from_smiles(...)) + compound_pattern_bfp bit(2048), -- Postgres RDkit cartridge can calc this, Chemicalite does, not sure if insert by codebase. Currently seems broken + compound_morgan_bfp bit(2048), -- Postgres cartridge can't calc this. Must be inserted by codebase, but currently its broken + compound_metadata TEXT, -- currently Null + note TEXT, -- New column + rdkit_version TEXT, -- RDKit version string used when computing compound_hash (application-set; cartridge may also populate) + inchi_version TEXT NOT NULL, -- InChI software version (rdkit.Chem.inchi.GetInchiVersion) + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_compound_alias UNIQUE (compound_alias), + CONSTRAINT uc_compound_inchikey UNIQUE (compound_inchikey), + CONSTRAINT uc_compound_smiles UNIQUE (compound_smiles) +); + +CREATE TABLE IF NOT EXISTS designdb.subsites ( + id BIGSERIAL PRIMARY KEY, + target_id BIGINT NOT NULL REFERENCES designdb.targets (id) ON DELETE RESTRICT, -- Insert by codebase/notebook + subsite_name TEXT NOT NULL, -- Insert by codebase/notebook + subsite_metadata TEXT, -- Not populated by code + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_subsite UNIQUE (target_id, subsite_name) +); + +CREATE TABLE IF NOT EXISTS designdb.poses ( + id BIGSERIAL PRIMARY KEY, + pose_inchikey TEXT, -- Populated by RDKit cartridge trigger from pose_mol (do not insert by code). Originally, nserted by codebase when registering poses? Might be done from pose.mol? + pose_alias TEXT, + pose_smiles TEXT, -- Populated by RDKit cartridge trigger from pose_mol (do not insert by code). LR - necessary because will contain defined stereochemistry - should these be canonicalised? Is it done by codebase from pose.mol? Could be done by RDkit cartridge. + pose_reference INTEGER, + pose_path TEXT, + compound_id BIGINT NOT NULL REFERENCES designdb.compounds (id) ON DELETE RESTRICT, + target_id BIGINT NOT NULL REFERENCES designdb.targets (id) ON DELETE RESTRICT, + pose_mol rdkit.mol, -- Insert by codebase. Trigger populates pose_inchikey and pose_smiles via cartridge. + pose_fingerprint INTEGER, --Not sure if it null or actually calcualated somewhere. + --pose_energy_score REAL, -- LR - redundant; use designdb.score_values + --pose_distance_score REAL, -- LR - redundant; use designdb.score_values + --pose_inspiration_score REAL, -- LR - redundant; use designdb.score_values + pose_metadata TEXT, + note TEXT, -- New column + rdkit_version TEXT, --Can be done by RDkit cartridge + inchi_version TEXT, -- Must be done by codebase + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now() + -- CONSTRAINT uc_pose_alias UNIQUE (pose_alias), -- Removed + -- CONSTRAINT uc_pose_path UNIQUE (pose_path) -- Removed +); + +CREATE TABLE IF NOT EXISTS designdb.subsite_tags ( + id BIGSERIAL PRIMARY KEY, + subsite_id BIGINT NOT NULL REFERENCES designdb.subsites (id) ON DELETE RESTRICT, -- Insert by codebase + pose_id BIGINT NOT NULL REFERENCES designdb.poses (id) ON DELETE RESTRICT, -- Insert by codebase + subsite_tag_metadata TEXT, -- Not populated by code + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_subsite_tag UNIQUE (subsite_id, pose_id) +); + +-- New table +CREATE TABLE IF NOT EXISTS designdb.pose_methods ( + id BIGSERIAL PRIMARY KEY, + pose_method_name TEXT, + pose_method_description TEXT, + pose_method_version TEXT, + pose_method_organization TEXT, + pose_method_link TEXT, + pose_method_note TEXT, + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_pose_method UNIQUE NULLS NOT DISTINCT (pose_method_name, pose_method_version) +); + +-- New table +CREATE TABLE IF NOT EXISTS designdb.has_pose_methods ( + pose_id BIGINT NOT NULL REFERENCES designdb.poses (id) ON DELETE CASCADE, + pose_method_id BIGINT NOT NULL REFERENCES designdb.pose_methods (id) ON DELETE CASCADE, + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + PRIMARY KEY (pose_id, pose_method_id) +); + +-- New table +CREATE TABLE IF NOT EXISTS designdb.pose_tags ( + id BIGSERIAL PRIMARY KEY, + pose_tag_name TEXT NOT NULL, + pose_tag_description TEXT, + pose_tag_note TEXT, + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_pose_tag_name UNIQUE (pose_tag_name) +); + +-- New table +CREATE TABLE IF NOT EXISTS designdb.has_pose_tags ( + pose_id BIGINT NOT NULL REFERENCES designdb.poses (id) ON DELETE CASCADE, + pose_tag_id BIGINT NOT NULL REFERENCES designdb.pose_tags (id) ON DELETE CASCADE, + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + PRIMARY KEY (pose_id, pose_tag_id) +); + +CREATE TABLE IF NOT EXISTS designdb.inspirations ( + id BIGSERIAL PRIMARY KEY, + original_pose_id BIGINT REFERENCES designdb.poses (id) ON DELETE SET NULL, -- Insert by codebase + derivative_pose_id BIGINT REFERENCES designdb.poses (id) ON DELETE SET NULL, -- Insert by codebase + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_inspiration UNIQUE (original_pose_id, derivative_pose_id) +); + +CREATE TABLE IF NOT EXISTS designdb.features ( + id BIGSERIAL PRIMARY KEY, + feature_family TEXT, -- Insert by codebase + target_id BIGINT NOT NULL REFERENCES designdb.targets (id) ON DELETE RESTRICT, -- Insert by codebase + feature_chain_name TEXT, -- Insert by codebase + feature_residue_name TEXT, -- Insert by codebase + feature_residue_number INTEGER, -- Insert by codebase + feature_atom_name TEXT, -- Insert by codebase + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_feature UNIQUE (feature_family, target_id, feature_chain_name, feature_residue_number, feature_residue_name, feature_atom_name) +); + +CREATE TABLE IF NOT EXISTS designdb.interactions ( + id BIGSERIAL PRIMARY KEY, + feature_id BIGINT NOT NULL REFERENCES designdb.features (id) ON DELETE RESTRICT, -- Insert by codebase + pose_id BIGINT NOT NULL REFERENCES designdb.poses (id) ON DELETE RESTRICT, -- Insert by codebase + interaction_type TEXT NOT NULL, -- Insert by codebase + interaction_family TEXT NOT NULL, -- Insert by codebase + interaction_atom_id TEXT NOT NULL, -- Insert by codebase + interaction_prot_coord TEXT NOT NULL, -- Insert by codebase. Not populated by ProLIF, needs reviewing + interaction_lig_coord TEXT NOT NULL, -- Insert by codebase. Not populated by ProLIF, needs reviewing + interaction_distance REAL NOT NULL, -- Insert by codebase + interaction_angle REAL, -- Insert by codebase + interaction_energy REAL, -- Insert by codebase. Not populated by ProLIF, needs reviewing + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_interaction UNIQUE (feature_id, pose_id, interaction_type, interaction_family, interaction_atom_id) +); + +-- New table +CREATE TABLE IF NOT EXISTS designdb.compound_tags ( + id BIGSERIAL PRIMARY KEY, + compound_tag_name TEXT NOT NULL, + compound_tag_description TEXT, + compound_tag_note TEXT, + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_compound_tag_name UNIQUE (compound_tag_name) +); + +-- New table +CREATE TABLE IF NOT EXISTS designdb.has_compound_tags ( + compound_id BIGINT NOT NULL REFERENCES designdb.compounds (id) ON DELETE CASCADE, + compound_tag_id BIGINT NOT NULL REFERENCES designdb.compound_tags (id) ON DELETE CASCADE, + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + PRIMARY KEY (compound_id, compound_tag_id) +); + +-- New table +CREATE TABLE IF NOT EXISTS designdb.enumeration_methods ( + id BIGSERIAL PRIMARY KEY, + enum_name TEXT, + enum_description TEXT, + enum_version TEXT, + enum_organization TEXT, + enum_link TEXT, + enum_note TEXT, + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_enumeration_method UNIQUE NULLS NOT DISTINCT (enum_name, enum_version) +); + +-- New table +CREATE TABLE IF NOT EXISTS designdb.has_enumeration_methods ( + compound_id BIGINT NOT NULL REFERENCES designdb.compounds (id) ON DELETE CASCADE, + enumeration_method_id BIGINT NOT NULL REFERENCES designdb.enumeration_methods (id) ON DELETE CASCADE, + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + PRIMARY KEY (compound_id, enumeration_method_id) +); + +-- New table +CREATE TABLE IF NOT EXISTS designdb.scoring_methods ( + id BIGSERIAL PRIMARY KEY, + method_name TEXT, + method_description TEXT, + method_version TEXT, + method_organization TEXT, + method_link TEXT, + note TEXT, + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_scoring_method UNIQUE NULLS NOT DISTINCT (method_name, method_version) +); + +-- One row per (pose_id, compound_id, scoring_method_id). score JSONB stores {"score": value} (numeric or text). +CREATE TABLE IF NOT EXISTS designdb.score_values ( + pose_id BIGINT NOT NULL REFERENCES designdb.poses (id) ON DELETE RESTRICT, + compound_id BIGINT NOT NULL REFERENCES designdb.compounds (id) ON DELETE RESTRICT, + scoring_method_id BIGINT NOT NULL REFERENCES designdb.scoring_methods (id) ON DELETE RESTRICT, + score JSONB NOT NULL, + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT pk_score_values PRIMARY KEY (pose_id, compound_id, scoring_method_id) +); + +CREATE TABLE IF NOT EXISTS designdb.reactions ( + id BIGSERIAL PRIMARY KEY, + reaction_type TEXT, -- Insert by codebase/notebook, Synderilla + product_compound_id BIGINT NOT NULL REFERENCES designdb.compounds (id) ON DELETE RESTRICT, -- Insert by codebase/notebook, Synderilla + reaction_product_yield REAL, -- Insert by codebase/notebook, Synderilla + reaction_metadata TEXT, -- Not populated by code + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS designdb.reactants ( + id BIGSERIAL PRIMARY KEY, + reactant_amount REAL, -- Insert by codebase/notebook, Synderilla + reaction_id BIGINT NOT NULL REFERENCES designdb.reactions (id) ON DELETE CASCADE, -- Insert by codebase/notebook, Synderilla + compound_id BIGINT NOT NULL REFERENCES designdb.compounds (id) ON DELETE RESTRICT, -- Insert by codebase/notebook, Synderilla + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_reactant UNIQUE (reaction_id, compound_id) +); + +-- Registration identity: one row per distinct catalogue_smiles (unique). catalogue_inchikey is NOT unique — +-- different SMILES strings can map to the same Standard InChIKey after cartridge normalization (trigger from SMILES). +-- catalogue_hash: application / loader pipeline (same algorithm as compounds.compound_hash); NOT unique — multiple +-- rows may share a hash when SuperParent/registration hash collapses stereoisomers differently than stored SMILES and that's the correct behaviour +CREATE TABLE IF NOT EXISTS designdb.catalogue_compounds ( + id BIGSERIAL PRIMARY KEY, + catalogue_smiles TEXT NOT NULL, + catalogue_inchikey TEXT NOT NULL, -- Populated by RDKit cartridge trigger from catalogue_smiles + catalogue_hash TEXT NOT NULL, -- Set by Enamine parsing script on insert/update; links via designdb.compound_catalogue_map + rdkit_version TEXT NOT NULL, + inchi_version TEXT NOT NULL, + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uq_catalogue_compounds_smiles UNIQUE (catalogue_smiles), + CONSTRAINT ck_catalogue_compounds_hash_nonempty CHECK (length(trim(catalogue_hash)) > 0) +); + +-- Former quotes rows split out. supplier = old quote_catalogue; supplier_id = old quote_entry; vendor = old quote_supplier. +CREATE TABLE IF NOT EXISTS designdb.catalogue_prices ( + id BIGSERIAL PRIMARY KEY, + catalogue_id BIGINT NOT NULL REFERENCES designdb.catalogue_compounds (id) ON DELETE CASCADE, + vendor TEXT NOT NULL, + supplier TEXT, + supplier_id TEXT NOT NULL, + amount REAL NOT NULL, + price REAL, + currency TEXT, + purity REAL, + lead_time INTEGER, + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_catalogue_price UNIQUE (catalogue_id, vendor, supplier, supplier_id, amount) +); + +-- Many-to-many: compounds - catalogue price lines matched on shared identity hash (compound_hash = catalogue_hash). +CREATE TABLE IF NOT EXISTS designdb.compound_catalogue_map ( + compound_id BIGINT NOT NULL REFERENCES designdb.compounds (id) ON DELETE CASCADE, + catalogue_price_id BIGINT NOT NULL REFERENCES designdb.catalogue_prices (id) ON DELETE CASCADE, + match_hash TEXT NOT NULL, + -- Will be deleted automatically from db from here: + -- HIPPO temporary columns, remove when HIPPO can handle catalogue_prices and catalogue_compounds: + -- catalogue_inchikey TEXT, --Needs to be removed when HIPPO codebase ready to handle prices properly. + -- supplier TEXT, --Needs to be removed when HIPPO codebase ready to handle prices properly. + -- amount REAL, --Needs to be removed when HIPPO codebase ready to handle prices properly. + -- price REAL, --Needs to be removed when HIPPO codebase ready to handle prices properly. + -- lead_time INTEGER, --Needs to be removed when HIPPO codebase ready to handle prices properly. + -- Will be deleted automatically until here + created_on TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_on TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (compound_id, catalogue_price_id), + CONSTRAINT ck_compound_catalogue_map_match_hash_nonempty CHECK (length(trim(match_hash)) > 0) +); + +CREATE TABLE IF NOT EXISTS designdb.scaffolds ( + id BIGSERIAL PRIMARY KEY, + base_compound_id BIGINT REFERENCES designdb.compounds (id) ON DELETE SET NULL, -- Insert by codebase + superstructure_compound_id BIGINT REFERENCES designdb.compounds (id) ON DELETE SET NULL, -- Insert by codebase + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_scaffold UNIQUE (base_compound_id, superstructure_compound_id) +); + +CREATE TABLE IF NOT EXISTS designdb.routes ( + id BIGSERIAL PRIMARY KEY, + product_compound_id BIGINT NOT NULL REFERENCES designdb.compounds (id) ON DELETE RESTRICT, -- Insert by codebase/notebook, Synderilla + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS designdb.components ( + id BIGSERIAL PRIMARY KEY, + route_id BIGINT NOT NULL REFERENCES designdb.routes (id) ON DELETE RESTRICT, -- Insert by codebase/notebook, Synderilla + component_type INTEGER, -- Insert by codebase/notebook, Synderilla-- + component_ref INTEGER, -- Insert by codebase/notebook, Synderilla + component_amount REAL, -- Insert by codebase/notebook, Synderilla + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_component UNIQUE (route_id, component_ref, component_type) +); + +-- Replaced table with individual tag tables +-- CREATE TABLE IF NOT EXISTS designdb.tags ( +-- id BIGSERIAL PRIMARY KEY, +-- tag_name TEXT, -- Insert by codebase +-- tag_description TEXT, -- New column +-- note TEXT, -- New column +-- -- compound_id BIGINT REFERENCES designdb.compounds (id) ON DELETE SET NULL, -- Insert by codebase, need to be removed, change in code needed +-- -- pose_id BIGINT REFERENCES designdb.poses (id) ON DELETE SET NULL, -- Insert by codebase, need to be removed, change in code needed +-- created_on TIMESTAMPTZ DEFAULT now(), +-- updated_on TIMESTAMPTZ DEFAULT now() +-- -- CONSTRAINT uc_tag_compound UNIQUE (tag_name, compound_id), +-- -- CONSTRAINT uc_tag_pose UNIQUE (tag_name, pose_id) +-- ); + + +-- ========================================================= +-- AUDIT TABLES +-- ========================================================= + +-- Event audit for catalogue_compounds (chemistry / identity rows) +CREATE TABLE IF NOT EXISTS designdb.catalogue_compounds_event_audit ( + audit_pk BIGSERIAL PRIMARY KEY, + id BIGINT NOT NULL, + operation CHAR(1) NOT NULL CHECK (operation IN ('I','U','D')), + old_values JSONB, + new_values JSONB, + changed_by TEXT, + changed_at TIMESTAMPTZ DEFAULT now() +); + +-- Event audit for catalogue_prices (vendor / pricing lines) +CREATE TABLE IF NOT EXISTS designdb.catalogue_prices_event_audit ( + audit_pk BIGSERIAL PRIMARY KEY, + id BIGINT NOT NULL, + operation CHAR(1) NOT NULL CHECK (operation IN ('I','U','D')), + old_values JSONB, + new_values JSONB, + changed_by TEXT, + changed_at TIMESTAMPTZ DEFAULT now() +); + +-- Event audit for pose_tags +CREATE TABLE IF NOT EXISTS designdb.pose_tags_event_audit ( + audit_pk BIGSERIAL PRIMARY KEY, + id BIGINT NOT NULL, + operation CHAR(1) NOT NULL CHECK (operation IN ('I','U','D')), + old_values JSONB, + new_values JSONB, + changed_by TEXT, + changed_at TIMESTAMPTZ DEFAULT now() +); + +-- Event audit for compound_tags +CREATE TABLE IF NOT EXISTS designdb.compound_tags_event_audit ( + audit_pk BIGSERIAL PRIMARY KEY, + id BIGINT NOT NULL, + operation CHAR(1) NOT NULL CHECK (operation IN ('I','U','D')), + old_values JSONB, + new_values JSONB, + changed_by TEXT, + changed_at TIMESTAMPTZ DEFAULT now() +); + +-- Event audit for pose_methods +CREATE TABLE IF NOT EXISTS designdb.pose_methods_event_audit ( + audit_pk BIGSERIAL PRIMARY KEY, + id BIGINT NOT NULL, + operation CHAR(1) NOT NULL CHECK (operation IN ('I','U','D')), + old_values JSONB, + new_values JSONB, + changed_by TEXT, + changed_at TIMESTAMPTZ DEFAULT now() +); + +-- Event audit for enumeration_methods +CREATE TABLE IF NOT EXISTS designdb.enumeration_methods_event_audit ( + audit_pk BIGSERIAL PRIMARY KEY, + id BIGINT NOT NULL, + operation CHAR(1) NOT NULL CHECK (operation IN ('I','U','D')), + old_values JSONB, + new_values JSONB, + changed_by TEXT, + changed_at TIMESTAMPTZ DEFAULT now() +); + +-- Event audit for scoring_methods +CREATE TABLE IF NOT EXISTS designdb.scoring_methods_event_audit ( + audit_pk BIGSERIAL PRIMARY KEY, + id BIGINT NOT NULL, + operation CHAR(1) NOT NULL CHECK (operation IN ('I','U','D')), + old_values JSONB, + new_values JSONB, + changed_by TEXT, + changed_at TIMESTAMPTZ DEFAULT now() +); + +-- ========================================================= +-- INDEXES +-- ========================================================= + +CREATE INDEX IF NOT EXISTS idx_target_name ON designdb.targets(target_name); +CREATE INDEX IF NOT EXISTS idx_target_created ON designdb.targets(created_on); + +CREATE INDEX IF NOT EXISTS idx_scoring_method_name ON designdb.scoring_methods(method_name); +CREATE INDEX IF NOT EXISTS idx_scoring_method_created ON designdb.scoring_methods(created_on); + +CREATE INDEX IF NOT EXISTS idx_enumeration_method_name ON designdb.enumeration_methods(enum_name); +CREATE INDEX IF NOT EXISTS idx_enumeration_method_created ON designdb.enumeration_methods(created_on); + +CREATE INDEX IF NOT EXISTS idx_pose_method_name ON designdb.pose_methods(pose_method_name); +CREATE INDEX IF NOT EXISTS idx_pose_method_created ON designdb.pose_methods(created_on); + +CREATE INDEX IF NOT EXISTS idx_compound_base_compound_id ON designdb.compounds(base_compound_id); +CREATE INDEX IF NOT EXISTS idx_compound_inchikey ON designdb.compounds(compound_inchikey); +CREATE INDEX IF NOT EXISTS idx_compound_smiles ON designdb.compounds(compound_smiles); +CREATE INDEX IF NOT EXISTS idx_compound_compound_hash ON designdb.compounds(compound_hash); +CREATE INDEX IF NOT EXISTS idx_compound_created ON designdb.compounds(created_on); + +CREATE INDEX IF NOT EXISTS idx_feature_target_id ON designdb.features(target_id); +CREATE INDEX IF NOT EXISTS idx_feature_created ON designdb.features(created_on); + +CREATE INDEX IF NOT EXISTS idx_route_product_compound_id ON designdb.routes(product_compound_id); +CREATE INDEX IF NOT EXISTS idx_route_created ON designdb.routes(created_on); + +CREATE INDEX IF NOT EXISTS idx_reaction_product_compound_id ON designdb.reactions(product_compound_id); +CREATE INDEX IF NOT EXISTS idx_reaction_created ON designdb.reactions(created_on); + +CREATE INDEX IF NOT EXISTS idx_pose_compound_id ON designdb.poses(compound_id); +CREATE INDEX IF NOT EXISTS idx_pose_target_id ON designdb.poses(target_id); +CREATE INDEX IF NOT EXISTS idx_pose_path ON designdb.poses(pose_path); +CREATE INDEX IF NOT EXISTS idx_pose_created ON designdb.poses(created_on); + +CREATE INDEX IF NOT EXISTS idx_score_values_pose_id ON designdb.score_values(pose_id); +CREATE INDEX IF NOT EXISTS idx_score_values_compound_id ON designdb.score_values(compound_id); +CREATE INDEX IF NOT EXISTS idx_score_values_scoring_method_id ON designdb.score_values(scoring_method_id); +CREATE INDEX IF NOT EXISTS idx_score_values_created ON designdb.score_values(created_on); + +CREATE INDEX IF NOT EXISTS idx_subsite_target_id ON designdb.subsites(target_id); +CREATE INDEX IF NOT EXISTS idx_subsite_created ON designdb.subsites(created_on); + +CREATE INDEX IF NOT EXISTS idx_component_route_id ON designdb.components(route_id); +CREATE INDEX IF NOT EXISTS idx_component_created ON designdb.components(created_on); + +CREATE INDEX IF NOT EXISTS idx_inspiration_original_pose_id ON designdb.inspirations(original_pose_id); +CREATE INDEX IF NOT EXISTS idx_inspiration_derivative_pose_id ON designdb.inspirations(derivative_pose_id); +CREATE INDEX IF NOT EXISTS idx_inspiration_created ON designdb.inspirations(created_on); + +CREATE INDEX IF NOT EXISTS idx_interaction_feature_id ON designdb.interactions(feature_id); +CREATE INDEX IF NOT EXISTS idx_interaction_pose_id ON designdb.interactions(pose_id); +CREATE INDEX IF NOT EXISTS idx_interaction_created ON designdb.interactions(created_on); + +-- UNIQUE(catalogue_smiles) supplies btree on catalogue_smiles; btree on catalogue_inchikey (non-unique) and catalogue_hash for lookups +CREATE INDEX IF NOT EXISTS idx_catalogue_compounds_created ON designdb.catalogue_compounds(created_on); +CREATE INDEX IF NOT EXISTS idx_catalogue_compounds_inchikey ON designdb.catalogue_compounds(catalogue_inchikey); +CREATE INDEX IF NOT EXISTS idx_catalogue_compounds_hash ON designdb.catalogue_compounds(catalogue_hash); + +CREATE INDEX IF NOT EXISTS idx_catalogue_prices_catalogue_id ON designdb.catalogue_prices(catalogue_id); +CREATE INDEX IF NOT EXISTS idx_catalogue_prices_created ON designdb.catalogue_prices(created_on); + +CREATE INDEX IF NOT EXISTS idx_compound_catalogue_map_match_hash ON designdb.compound_catalogue_map(match_hash); +CREATE INDEX IF NOT EXISTS idx_compound_catalogue_map_catalogue_price_id ON designdb.compound_catalogue_map(catalogue_price_id); +CREATE INDEX IF NOT EXISTS idx_compound_catalogue_map_created ON designdb.compound_catalogue_map(created_on); + +-- ========================================================= +-- AUDIT INDEXES +-- ========================================================= + +CREATE INDEX IF NOT EXISTS idx_catalogue_compounds_event_audit_id ON designdb.catalogue_compounds_event_audit(id); +CREATE INDEX IF NOT EXISTS idx_catalogue_compounds_event_audit_operation ON designdb.catalogue_compounds_event_audit(operation); +CREATE INDEX IF NOT EXISTS idx_catalogue_compounds_event_audit_changed_at ON designdb.catalogue_compounds_event_audit(changed_at); +CREATE INDEX IF NOT EXISTS idx_catalogue_compounds_event_audit_changed_by ON designdb.catalogue_compounds_event_audit(changed_by); +CREATE INDEX IF NOT EXISTS idx_catalogue_compounds_event_audit_old_gin ON designdb.catalogue_compounds_event_audit USING GIN (old_values); +CREATE INDEX IF NOT EXISTS idx_catalogue_compounds_event_audit_new_gin ON designdb.catalogue_compounds_event_audit USING GIN (new_values); +CREATE INDEX IF NOT EXISTS idx_catalogue_compounds_event_audit_id_changed ON designdb.catalogue_compounds_event_audit(id, changed_at DESC); + +CREATE INDEX IF NOT EXISTS idx_catalogue_prices_event_audit_id ON designdb.catalogue_prices_event_audit(id); +CREATE INDEX IF NOT EXISTS idx_catalogue_prices_event_audit_operation ON designdb.catalogue_prices_event_audit(operation); +CREATE INDEX IF NOT EXISTS idx_catalogue_prices_event_audit_changed_at ON designdb.catalogue_prices_event_audit(changed_at); +CREATE INDEX IF NOT EXISTS idx_catalogue_prices_event_audit_changed_by ON designdb.catalogue_prices_event_audit(changed_by); +CREATE INDEX IF NOT EXISTS idx_catalogue_prices_event_audit_old_gin ON designdb.catalogue_prices_event_audit USING GIN (old_values); +CREATE INDEX IF NOT EXISTS idx_catalogue_prices_event_audit_new_gin ON designdb.catalogue_prices_event_audit USING GIN (new_values); +CREATE INDEX IF NOT EXISTS idx_catalogue_prices_event_audit_id_changed ON designdb.catalogue_prices_event_audit(id, changed_at DESC); + +CREATE INDEX IF NOT EXISTS idx_pose_tags_event_audit_id ON designdb.pose_tags_event_audit(id); +CREATE INDEX IF NOT EXISTS idx_pose_tags_event_audit_operation ON designdb.pose_tags_event_audit(operation); +CREATE INDEX IF NOT EXISTS idx_pose_tags_event_audit_changed_at ON designdb.pose_tags_event_audit(changed_at); +CREATE INDEX IF NOT EXISTS idx_pose_tags_event_audit_old_gin ON designdb.pose_tags_event_audit USING GIN (old_values); +CREATE INDEX IF NOT EXISTS idx_pose_tags_event_audit_new_gin ON designdb.pose_tags_event_audit USING GIN (new_values); +CREATE INDEX IF NOT EXISTS idx_pose_tags_event_audit_id_changed ON designdb.pose_tags_event_audit(id, changed_at DESC); + +CREATE INDEX IF NOT EXISTS idx_compound_tags_event_audit_id ON designdb.compound_tags_event_audit(id); +CREATE INDEX IF NOT EXISTS idx_compound_tags_event_audit_operation ON designdb.compound_tags_event_audit(operation); +CREATE INDEX IF NOT EXISTS idx_compound_tags_event_audit_changed_at ON designdb.compound_tags_event_audit(changed_at); +CREATE INDEX IF NOT EXISTS idx_compound_tags_event_audit_old_gin ON designdb.compound_tags_event_audit USING GIN (old_values); +CREATE INDEX IF NOT EXISTS idx_compound_tags_event_audit_new_gin ON designdb.compound_tags_event_audit USING GIN (new_values); +CREATE INDEX IF NOT EXISTS idx_compound_tags_event_audit_id_changed ON designdb.compound_tags_event_audit(id, changed_at DESC); + +CREATE INDEX IF NOT EXISTS idx_pose_methods_event_audit_id ON designdb.pose_methods_event_audit(id); +CREATE INDEX IF NOT EXISTS idx_pose_methods_event_audit_operation ON designdb.pose_methods_event_audit(operation); +CREATE INDEX IF NOT EXISTS idx_pose_methods_event_audit_changed_at ON designdb.pose_methods_event_audit(changed_at); +CREATE INDEX IF NOT EXISTS idx_pose_methods_event_audit_old_gin ON designdb.pose_methods_event_audit USING GIN (old_values); +CREATE INDEX IF NOT EXISTS idx_pose_methods_event_audit_new_gin ON designdb.pose_methods_event_audit USING GIN (new_values); +CREATE INDEX IF NOT EXISTS idx_pose_methods_event_audit_id_changed ON designdb.pose_methods_event_audit(id, changed_at DESC); + +CREATE INDEX IF NOT EXISTS idx_enumeration_methods_event_audit_id ON designdb.enumeration_methods_event_audit(id); +CREATE INDEX IF NOT EXISTS idx_enumeration_methods_event_audit_operation ON designdb.enumeration_methods_event_audit(operation); +CREATE INDEX IF NOT EXISTS idx_enumeration_methods_event_audit_changed_at ON designdb.enumeration_methods_event_audit(changed_at); +CREATE INDEX IF NOT EXISTS idx_enumeration_methods_event_audit_old_gin ON designdb.enumeration_methods_event_audit USING GIN (old_values); +CREATE INDEX IF NOT EXISTS idx_enumeration_methods_event_audit_new_gin ON designdb.enumeration_methods_event_audit USING GIN (new_values); +CREATE INDEX IF NOT EXISTS idx_enumeration_methods_event_audit_id_changed ON designdb.enumeration_methods_event_audit(id, changed_at DESC); + +CREATE INDEX IF NOT EXISTS idx_scoring_methods_event_audit_id ON designdb.scoring_methods_event_audit(id); +CREATE INDEX IF NOT EXISTS idx_scoring_methods_event_audit_operation ON designdb.scoring_methods_event_audit(operation); +CREATE INDEX IF NOT EXISTS idx_scoring_methods_event_audit_changed_at ON designdb.scoring_methods_event_audit(changed_at); +CREATE INDEX IF NOT EXISTS idx_scoring_methods_event_audit_old_gin ON designdb.scoring_methods_event_audit USING GIN (old_values); +CREATE INDEX IF NOT EXISTS idx_scoring_methods_event_audit_new_gin ON designdb.scoring_methods_event_audit USING GIN (new_values); +CREATE INDEX IF NOT EXISTS idx_scoring_methods_event_audit_id_changed ON designdb.scoring_methods_event_audit(id, changed_at DESC); + +CREATE INDEX IF NOT EXISTS idx_reactant_reaction_id ON designdb.reactants(reaction_id); +CREATE INDEX IF NOT EXISTS idx_reactant_compound_id ON designdb.reactants(compound_id); +CREATE INDEX IF NOT EXISTS idx_reactant_created ON designdb.reactants(created_on); + +CREATE INDEX IF NOT EXISTS idx_scaffold_base_compound_id ON designdb.scaffolds(base_compound_id); +CREATE INDEX IF NOT EXISTS idx_scaffold_superstructure_compound_id ON designdb.scaffolds(superstructure_compound_id); +CREATE INDEX IF NOT EXISTS idx_scaffold_created ON designdb.scaffolds(created_on); + +CREATE INDEX IF NOT EXISTS idx_subsite_tag_subsite_id ON designdb.subsite_tags(subsite_id); +CREATE INDEX IF NOT EXISTS idx_subsite_tag_pose_id ON designdb.subsite_tags(pose_id); +CREATE INDEX IF NOT EXISTS idx_subsite_tag_created ON designdb.subsite_tags(created_on); + +-- Removed due to replaced tables +-- CREATE INDEX IF NOT EXISTS idx_tag_compound ON designdb.tags(tag_compound); +-- CREATE INDEX IF NOT EXISTS idx_tag_pose ON designdb.tags(tag_pose); +-- CREATE INDEX IF NOT EXISTS idx_tag_created ON designdb.tags(created_on); + +CREATE INDEX IF NOT EXISTS idx_pose_tag_created ON designdb.pose_tags(created_on); +CREATE INDEX IF NOT EXISTS idx_compound_tag_created ON designdb.compound_tags(created_on); + +CREATE INDEX IF NOT EXISTS idx_has_pose_methods_pose_method_id ON designdb.has_pose_methods(pose_method_id); +CREATE INDEX IF NOT EXISTS idx_has_pose_methods_created ON designdb.has_pose_methods(created_on); + +CREATE INDEX IF NOT EXISTS idx_has_pose_tag_pose_tag_id ON designdb.has_pose_tags(pose_tag_id); +CREATE INDEX IF NOT EXISTS idx_has_pose_tag_created ON designdb.has_pose_tags(created_on); + +CREATE INDEX IF NOT EXISTS idx_has_enumeration_methods_enumeration_method_id ON designdb.has_enumeration_methods(enumeration_method_id); +CREATE INDEX IF NOT EXISTS idx_has_enumeration_methods_created ON designdb.has_enumeration_methods(created_on); + +CREATE INDEX IF NOT EXISTS idx_has_compound_tag_compound_tag_id ON designdb.has_compound_tags(compound_tag_id); +CREATE INDEX IF NOT EXISTS idx_has_compound_tag_created ON designdb.has_compound_tags(created_on); + +-- ========================================================= +-- MATERIALIZED VIEWS +-- ========================================================= +-- designdb.scores_per_pose_pivoted_mv: pose_id, compound_id + one column per (method_name, method_version). +-- Pivoted from score_values joined with scoring_methods. Dynamically re-generated when new method added. + +-- ========================================================= +-- VIEWS +-- ========================================================= + +-- Price-line UPDATEs from catalogue_prices_event_audit (JSON keys match catalogue_prices column names) +CREATE OR REPLACE VIEW designdb.catalogue_prices_price_changes_v AS +SELECT + a.id AS catalogue_price_id, + (NULLIF(COALESCE(a.new_values->>'catalogue_id', a.old_values->>'catalogue_id'), ''))::BIGINT AS catalogue_id, + COALESCE(a.new_values->>'vendor', a.old_values->>'vendor') AS vendor, + COALESCE(a.new_values->>'supplier', a.old_values->>'supplier') AS supplier, + COALESCE(a.new_values->>'supplier_id', a.old_values->>'supplier_id') AS supplier_id, + (NULLIF(COALESCE(a.new_values->>'amount', a.old_values->>'amount'), ''))::DOUBLE PRECISION AS amount, + (NULLIF(a.old_values->>'price', ''))::DOUBLE PRECISION AS price_old, + (NULLIF(a.new_values->>'price', ''))::DOUBLE PRECISION AS price_new, + COALESCE(a.new_values->>'currency', a.old_values->>'currency') AS currency, + (NULLIF(COALESCE(a.new_values->>'purity', a.old_values->>'purity'), ''))::DOUBLE PRECISION AS purity, + (NULLIF(COALESCE(a.new_values->>'lead_time', a.old_values->>'lead_time'), ''))::INTEGER AS lead_time, + a.changed_at +FROM designdb.catalogue_prices_event_audit a +WHERE a.operation = 'U'; + +-- Pose tags: UPDATE events with old/new name, description, note. +CREATE OR REPLACE VIEW designdb.pose_tags_changes_v AS +SELECT + a.id AS pose_tag_id, + a.old_values->>'pose_tag_name' AS pose_tag_name_old, + a.new_values->>'pose_tag_name' AS pose_tag_name_new, + a.old_values->>'pose_tag_description' AS pose_tag_description_old, + a.new_values->>'pose_tag_description' AS pose_tag_description_new, + a.old_values->>'pose_tag_note' AS pose_tag_note_old, + a.new_values->>'pose_tag_note' AS pose_tag_note_new, + a.changed_by, + a.changed_at +FROM designdb.pose_tags_event_audit a +WHERE a.operation = 'U'; + +-- Compound tags: UPDATE events with old/new name, description, note. +CREATE OR REPLACE VIEW designdb.compound_tags_changes_v AS +SELECT + a.id AS compound_tag_id, + a.old_values->>'compound_tag_name' AS compound_tag_name_old, + a.new_values->>'compound_tag_name' AS compound_tag_name_new, + a.old_values->>'compound_tag_description' AS compound_tag_description_old, + a.new_values->>'compound_tag_description' AS compound_tag_description_new, + a.old_values->>'compound_tag_note' AS compound_tag_note_old, + a.new_values->>'compound_tag_note' AS compound_tag_note_new, + a.changed_by, + a.changed_at +FROM designdb.compound_tags_event_audit a +WHERE a.operation = 'U'; + +-- Pose methods: UPDATE events with old/new name, description, version, etc. +CREATE OR REPLACE VIEW designdb.pose_methods_changes_v AS +SELECT + a.id AS pose_method_id, + a.old_values->>'pose_method_name' AS pose_method_name_old, + a.new_values->>'pose_method_name' AS pose_method_name_new, + a.old_values->>'pose_method_description' AS pose_method_description_old, + a.new_values->>'pose_method_description' AS pose_method_description_new, + a.old_values->>'pose_method_version' AS pose_method_version_old, + a.new_values->>'pose_method_version' AS pose_method_version_new, + a.changed_by, + a.changed_at +FROM designdb.pose_methods_event_audit a +WHERE a.operation = 'U'; + +-- Enumeration methods: UPDATE events with old/new name, description, version, etc. +CREATE OR REPLACE VIEW designdb.enumeration_methods_changes_v AS +SELECT + a.id AS enumeration_method_id, + a.old_values->>'enum_name' AS enum_name_old, + a.new_values->>'enum_name' AS enum_name_new, + a.old_values->>'enum_description' AS enum_description_old, + a.new_values->>'enum_description' AS enum_description_new, + a.old_values->>'enum_version' AS enum_version_old, + a.new_values->>'enum_version' AS enum_version_new, + a.changed_by, + a.changed_at +FROM designdb.enumeration_methods_event_audit a +WHERE a.operation = 'U'; + +-- Scoring methods: UPDATE events with old/new name, description, version, etc. +CREATE OR REPLACE VIEW designdb.scoring_methods_changes_v AS +SELECT + a.id AS scoring_method_id, + a.old_values->>'method_name' AS method_name_old, + a.new_values->>'method_name' AS method_name_new, + a.old_values->>'method_description' AS method_description_old, + a.new_values->>'method_description' AS method_description_new, + a.old_values->>'method_version' AS method_version_old, + a.new_values->>'method_version' AS method_version_new, + a.changed_by, + a.changed_at +FROM designdb.scoring_methods_event_audit a +WHERE a.operation = 'U'; + +-- ========================================================= +-- FUNCTIONS +-- ========================================================= + +-- ========================================================= +-- RDKIT CARTRIDGE – COMPOUND WRAPPERS +-- ========================================================= +-- Schema-qualified wrappers for RDKit mol_from_smiles, mol_to_smiles, mol_inchikey, mol_to_ctab (compound, pose, catalogue_compounds triggers). + +CREATE OR REPLACE FUNCTION designdb.mol_from_smiles(smiles TEXT) RETURNS rdkit.mol + LANGUAGE SQL AS $$ SELECT rdkit.mol_from_smiles(smiles::cstring); $$; + +CREATE OR REPLACE FUNCTION designdb.mol_to_smiles(m rdkit.mol) RETURNS text + LANGUAGE SQL AS $$ SELECT rdkit.mol_to_smiles(m); $$; + +CREATE OR REPLACE FUNCTION designdb.mol_to_inchikey(m rdkit.mol) RETURNS text + LANGUAGE SQL AS $$ SELECT rdkit.mol_inchikey(m); $$; + +CREATE OR REPLACE FUNCTION designdb.mol_to_ctab(m rdkit.mol) RETURNS text + LANGUAGE SQL AS $$ SELECT rdkit.mol_to_ctab(m); $$; + +-- ========================================================= +-- RDKIT CARTRIDGE – COMPOUND TRIGGER +-- ========================================================= +-- Input: compound_smiles (inserted by application). Populates compound_mol (CTAB) and compound_inchikey. + +CREATE OR REPLACE FUNCTION designdb.populate_compound_cartridge_from_smiles() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +DECLARE + v_mol rdkit.mol; +BEGIN + IF NEW.compound_smiles IS NOT NULL THEN + BEGIN + v_mol := designdb.mol_from_smiles(NEW.compound_smiles); + IF v_mol IS NOT NULL THEN + -- NEW.compound_mol := v_mol; -- store rdkit.mol (was default text form ~ SMILES over JDBC) + NEW.compound_mol := designdb.mol_to_ctab(v_mol); + NEW.compound_inchikey := designdb.mol_to_inchikey(v_mol); + END IF; + EXCEPTION WHEN OTHERS THEN + NULL; + END; + END IF; + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_populate_compound_cartridge_from_smiles ON designdb.compounds; +CREATE TRIGGER trg_populate_compound_cartridge_from_smiles + BEFORE INSERT OR UPDATE OF compound_smiles ON designdb.compounds + FOR EACH ROW + EXECUTE FUNCTION designdb.populate_compound_cartridge_from_smiles(); + +-- ========================================================= +-- RDKIT CARTRIDGE – CATALOGUE_COMPOUNDS TRIGGER +-- ========================================================= +-- Input: catalogue_smiles (inserted by application). Populates catalogue_inchikey (NOT NULL column). + +CREATE OR REPLACE FUNCTION designdb.populate_catalogue_cartridge_from_smiles() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +DECLARE + v_mol rdkit.mol; + v_ik TEXT; +BEGIN + IF NEW.catalogue_smiles IS NULL OR btrim(NEW.catalogue_smiles) = '' THEN + RAISE EXCEPTION 'designdb.catalogue_compounds: catalogue_smiles is required'; + END IF; + v_mol := designdb.mol_from_smiles(NEW.catalogue_smiles); + IF v_mol IS NULL THEN + RAISE EXCEPTION 'designdb.catalogue_compounds: mol_from_smiles returned NULL for catalogue_smiles'; + END IF; + v_ik := designdb.mol_to_inchikey(v_mol); + IF v_ik IS NULL OR btrim(v_ik) = '' THEN + RAISE EXCEPTION 'designdb.catalogue_compounds: mol_to_inchikey returned empty for catalogue_smiles'; + END IF; + NEW.catalogue_inchikey := v_ik; + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_populate_catalogue_cartridge_from_smiles ON designdb.catalogue_compounds; +CREATE TRIGGER trg_populate_catalogue_cartridge_from_smiles + BEFORE INSERT OR UPDATE OF catalogue_smiles ON designdb.catalogue_compounds + FOR EACH ROW + EXECUTE FUNCTION designdb.populate_catalogue_cartridge_from_smiles(); + +-- ========================================================= +-- RDKIT CARTRIDGE – POSE TRIGGER +-- ========================================================= +-- Input: pose_mol (inserted by application). Populates pose_inchikey and pose_smiles. + +CREATE OR REPLACE FUNCTION designdb.populate_pose_cartridge_from_mol() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF NEW.pose_mol IS NOT NULL THEN + BEGIN + NEW.pose_smiles := designdb.mol_to_smiles(NEW.pose_mol); + NEW.pose_inchikey := designdb.mol_to_inchikey(NEW.pose_mol); + EXCEPTION WHEN OTHERS THEN + NULL; + END; + END IF; + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_populate_pose_cartridge_from_mol ON designdb.poses; +CREATE TRIGGER trg_populate_pose_cartridge_from_mol + BEFORE INSERT OR UPDATE OF pose_mol ON designdb.poses + FOR EACH ROW + EXECUTE FUNCTION designdb.populate_pose_cartridge_from_mol(); + +-- ========================================================= +-- SCORE_VALUES – ENFORCE compound_id MATCHES pose's compound_id +-- ========================================================= +CREATE OR REPLACE FUNCTION designdb.check_score_values_compound_matches_pose() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +DECLARE + v_pose_compound_id BIGINT; +BEGIN + SELECT compound_id INTO v_pose_compound_id + FROM designdb.poses + WHERE id = NEW.pose_id; + IF v_pose_compound_id IS NULL THEN + RAISE EXCEPTION 'pose_id % does not exist', NEW.pose_id; + END IF; + IF NEW.compound_id IS DISTINCT FROM v_pose_compound_id THEN + RAISE EXCEPTION 'score_values.compound_id (%) must match poses.compound_id (%) for pose_id %', + NEW.compound_id, v_pose_compound_id, NEW.pose_id; + END IF; + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_check_score_values_compound_matches_pose ON designdb.score_values; +CREATE TRIGGER trg_check_score_values_compound_matches_pose + BEFORE INSERT OR UPDATE OF pose_id, compound_id ON designdb.score_values + FOR EACH ROW + EXECUTE FUNCTION designdb.check_score_values_compound_matches_pose(); + +-- ========================================================= +-- (Re)creates materialized view designdb.scores_per_pose_pivoted_mv with columns from scoring_method. +-- Columns: pose_id, compound_id, then one JSONB column per (method_name, method_version). +-- Column names use suffix _m{scoring_method_id} to avoid collisions (e.g. vina_1_0_m1). +-- Value in column: if score is numeric then JSONB number, if text then JSONB string. +CREATE OR REPLACE FUNCTION designdb.create_scores_per_pose_pivoted_mv() +RETURNS void +LANGUAGE plpgsql +AS $$ +DECLARE + select_qry text; + col text; + method_rec record; + score_txt text; + value_expr text; + numeric_pat text := '^\-?[0-9]*\.?[0-9]+([eE][\-+]?[0-9]+)?$'; +BEGIN + select_qry := 'SELECT sv.pose_id, sv.compound_id'; + FOR method_rec IN + SELECT m.id, m.method_name, m.method_version + FROM designdb.scoring_methods m + ORDER BY m.id + LOOP + col := regexp_replace( + trim(method_rec.method_name) || '_' || coalesce( + replace(replace(trim(coalesce(method_rec.method_version, '')), ' ', '_'), '.', '_'), + '' + ), + '[^a-zA-Z0-9_]', '_', 'g' + ) || '_m' || method_rec.id; + IF col <> '' AND col <> '_' THEN + col := quote_ident(col); + score_txt := '(sv.score->>' || quote_literal('score') || ')'; + value_expr := '(CASE WHEN ' || score_txt || ' IS NOT NULL AND ' || score_txt || ' ~ ' || quote_literal(numeric_pat) + || ' THEN to_jsonb((' || score_txt || ')::numeric) ELSE to_jsonb(' || score_txt || ') END)'; + select_qry := select_qry || ', MAX(CASE WHEN sv.scoring_method_id = ' || method_rec.id + || ' THEN ' || value_expr || ' END) AS ' || col; + END IF; + END LOOP; + select_qry := select_qry || ' FROM designdb.score_values sv GROUP BY sv.pose_id, sv.compound_id'; + EXECUTE 'DROP MATERIALIZED VIEW IF EXISTS designdb.scores_per_pose_pivoted_mv CASCADE'; + EXECUTE 'CREATE MATERIALIZED VIEW designdb.scores_per_pose_pivoted_mv AS ' || select_qry; + EXECUTE 'CREATE UNIQUE INDEX ON designdb.scores_per_pose_pivoted_mv (pose_id, compound_id)'; +END; +$$; + +CREATE OR REPLACE FUNCTION designdb.trg_recreate_scores_pivoted_mv() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + PERFORM designdb.create_scores_per_pose_pivoted_mv(); + RETURN NULL; +END; +$$; + +CREATE OR REPLACE FUNCTION designdb.refresh_scores_per_pose_pivoted_mv() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + REFRESH MATERIALIZED VIEW CONCURRENTLY designdb.scores_per_pose_pivoted_mv; + RETURN NULL; +END; +$$; + +CREATE OR REPLACE FUNCTION designdb.update_updated_on() +RETURNS trigger AS $$ +BEGIN + NEW.updated_on = now(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +/* +-- Will be deleted automatically from db from here: +-- === HIPPO TEMPORARY: denormalized map columns; remove when HIPPO ready === + +CREATE OR REPLACE FUNCTION designdb.trg_compound_catalogue_map_from_compound_temp() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF TG_OP = 'UPDATE' THEN + IF OLD.compound_hash IS NOT DISTINCT FROM NEW.compound_hash THEN + RETURN NEW; + END IF; + DELETE FROM designdb.compound_catalogue_map WHERE compound_id = NEW.id; + END IF; + + INSERT INTO designdb.compound_catalogue_map ( + compound_id, catalogue_price_id, match_hash, + catalogue_inchikey, supplier, amount, price, lead_time + ) + SELECT NEW.id, cp.id, NEW.compound_hash, + cat.catalogue_inchikey, cp.supplier, cp.amount, cp.price, cp.lead_time + FROM designdb.catalogue_prices cp + JOIN designdb.catalogue_compounds cat ON cat.id = cp.catalogue_id + WHERE cat.catalogue_hash = NEW.compound_hash + ON CONFLICT (compound_id, catalogue_price_id) DO NOTHING; + + RETURN NEW; +END; +$$; + +CREATE OR REPLACE FUNCTION designdb.trg_compound_catalogue_map_from_catalogue_temp() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF TG_OP = 'UPDATE' THEN + IF OLD.catalogue_hash IS NOT DISTINCT FROM NEW.catalogue_hash THEN + RETURN NEW; + END IF; + DELETE FROM designdb.compound_catalogue_map + WHERE catalogue_price_id IN ( + SELECT id FROM designdb.catalogue_prices WHERE catalogue_id = NEW.id + ); + END IF; + + INSERT INTO designdb.compound_catalogue_map ( + compound_id, catalogue_price_id, match_hash, + catalogue_inchikey, supplier, amount, price, lead_time + ) + SELECT c.id, cp.id, c.compound_hash, + NEW.catalogue_inchikey, cp.supplier, cp.amount, cp.price, cp.lead_time + FROM designdb.compounds c + CROSS JOIN designdb.catalogue_prices cp + WHERE cp.catalogue_id = NEW.id AND c.compound_hash = NEW.catalogue_hash + ON CONFLICT (compound_id, catalogue_price_id) DO NOTHING; + + RETURN NEW; +END; +$$; + +CREATE OR REPLACE FUNCTION designdb.trg_compound_catalogue_map_from_catalogue_price_temp() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF TG_OP = 'UPDATE' AND OLD.catalogue_id IS NOT DISTINCT FROM NEW.catalogue_id THEN + RETURN NEW; + END IF; + IF TG_OP = 'UPDATE' THEN + DELETE FROM designdb.compound_catalogue_map WHERE catalogue_price_id = NEW.id; + END IF; + + INSERT INTO designdb.compound_catalogue_map ( + compound_id, catalogue_price_id, match_hash, + catalogue_inchikey, supplier, amount, price, lead_time + ) + SELECT c.id, NEW.id, c.compound_hash, + cat.catalogue_inchikey, NEW.supplier, NEW.amount, NEW.price, NEW.lead_time + FROM designdb.compounds c + JOIN designdb.catalogue_compounds cat ON cat.id = NEW.catalogue_id + WHERE c.compound_hash = cat.catalogue_hash + ON CONFLICT (compound_id, catalogue_price_id) DO NOTHING; + + RETURN NEW; +END; +$$; + +CREATE OR REPLACE FUNCTION designdb.trg_compound_catalogue_map_refresh_denorm_from_catalogue_price_temp() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + UPDATE designdb.compound_catalogue_map m + SET supplier = NEW.supplier, + amount = NEW.amount, + price = NEW.price, + lead_time = NEW.lead_time, + updated_on = now() + WHERE m.catalogue_price_id = NEW.id; + RETURN NEW; +END; +$$; + +CREATE OR REPLACE FUNCTION designdb.trg_compound_catalogue_map_refresh_denorm_from_catalogue_compound_temp() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF TG_OP = 'UPDATE' THEN + IF OLD.catalogue_inchikey IS NOT DISTINCT FROM NEW.catalogue_inchikey THEN + RETURN NEW; + END IF; + END IF; + UPDATE designdb.compound_catalogue_map m + SET catalogue_inchikey = NEW.catalogue_inchikey, + updated_on = now() + WHERE m.catalogue_price_id IN ( + SELECT id FROM designdb.catalogue_prices WHERE catalogue_id = NEW.id + ); + RETURN NEW; +END; +$$; +-- Will be deleted automatically until here +*/ + +-- ========================================================= +-- AUDIT FUNCTIONS +-- ========================================================= + +-- Event audit trigger: records INSERT/UPDATE/DELETE to an audit table with JSONB old/new values. +-- TG_ARGV[0]=audit_table, [1]=pk_column, [2]=excluded_columns (comma-sep), [3]=binary_columns (comma-sep, hashed as sha256:hex). +CREATE OR REPLACE FUNCTION designdb.event_audit_trigger() +RETURNS trigger AS +$$ +DECLARE + v_audit_table TEXT := TG_ARGV[0]; + v_pk_col TEXT := TG_ARGV[1]; + v_excluded TEXT := COALESCE(TG_ARGV[2], ''); + v_bincols TEXT := COALESCE(TG_ARGV[3], ''); + v_excluded_arr TEXT[]; + v_bincols_arr TEXT[]; + v_changed_by TEXT := COALESCE(current_setting('app.current_user', true), current_user); + v_old_json JSONB; + v_new_json JSONB; + v_record_pk TEXT; + v_bin_hash TEXT; + v_col TEXT; +BEGIN + IF v_excluded = '' THEN + v_excluded_arr := ARRAY[]::text[]; + ELSE + v_excluded_arr := ARRAY(SELECT trim(x) FROM regexp_split_to_table(v_excluded, ',') AS x); + END IF; + + IF v_bincols = '' THEN + v_bincols_arr := ARRAY[]::text[]; + ELSE + v_bincols_arr := ARRAY(SELECT trim(x) FROM regexp_split_to_table(v_bincols, ',') AS x); + END IF; + + IF TG_OP = 'INSERT' THEN + v_old_json := NULL; + v_new_json := to_jsonb(NEW); + ELSIF TG_OP = 'DELETE' THEN + v_old_json := to_jsonb(OLD); + v_new_json := NULL; + ELSE + IF to_jsonb(OLD) IS NOT DISTINCT FROM to_jsonb(NEW) THEN + RETURN NEW; + END IF; + v_old_json := to_jsonb(OLD); + v_new_json := to_jsonb(NEW); + END IF; + + FOREACH v_col IN ARRAY v_excluded_arr LOOP + IF v_old_json IS NOT NULL THEN v_old_json := v_old_json - v_col; END IF; + IF v_new_json IS NOT NULL THEN v_new_json := v_new_json - v_col; END IF; + END LOOP; + + FOREACH v_col IN ARRAY v_bincols_arr LOOP + IF v_old_json IS NOT NULL AND v_old_json ? v_col THEN + BEGIN + EXECUTE format('SELECT CASE WHEN ($1).%I IS NULL THEN NULL ELSE encode(digest(($1).%I::bytea, ''sha256''), ''hex'') END', v_col, v_col) + USING OLD INTO v_bin_hash; + IF v_bin_hash IS NOT NULL THEN + v_old_json := jsonb_set(v_old_json, ARRAY[v_col], to_jsonb('sha256:' || v_bin_hash)); + ELSE + v_old_json := v_old_json - v_col; + END IF; + EXCEPTION WHEN others THEN + v_old_json := v_old_json - v_col; + END; + END IF; + IF v_new_json IS NOT NULL AND v_new_json ? v_col THEN + BEGIN + EXECUTE format('SELECT CASE WHEN ($1).%I IS NULL THEN NULL ELSE encode(digest(($1).%I::bytea, ''sha256''), ''hex'') END', v_col, v_col) + USING NEW INTO v_bin_hash; + IF v_bin_hash IS NOT NULL THEN + v_new_json := jsonb_set(v_new_json, ARRAY[v_col], to_jsonb('sha256:' || v_bin_hash)); + ELSE + v_new_json := v_new_json - v_col; + END IF; + EXCEPTION WHEN others THEN + v_new_json := v_new_json - v_col; + END; + END IF; + END LOOP; + + IF TG_OP = 'DELETE' THEN + EXECUTE format('SELECT ($1).%I::text', v_pk_col) USING OLD INTO v_record_pk; + ELSE + EXECUTE format('SELECT ($1).%I::text', v_pk_col) USING NEW INTO v_record_pk; + END IF; + + EXECUTE format('INSERT INTO %s (%s, operation, old_values, new_values, changed_by, changed_at) VALUES ($1,$2,$3,$4,$5,NOW())', v_audit_table, v_pk_col) + USING v_record_pk::BIGINT, TG_OP::CHAR, v_old_json, v_new_json, v_changed_by; + + RETURN COALESCE(NEW, OLD); +END; +$$ LANGUAGE plpgsql VOLATILE; + +-- ========================================================= +-- TRIGGERS (updated_on) +-- ========================================================= + +DROP TRIGGER IF EXISTS trg_target_updated_on ON designdb.targets; +CREATE TRIGGER trg_target_updated_on BEFORE UPDATE ON designdb.targets FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_scoring_method_updated_on ON designdb.scoring_methods; +CREATE TRIGGER trg_scoring_method_updated_on BEFORE UPDATE ON designdb.scoring_methods FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_scoring_method_recreate_pivoted_mv ON designdb.scoring_methods; +CREATE TRIGGER trg_scoring_method_recreate_pivoted_mv + AFTER INSERT OR UPDATE OR DELETE ON designdb.scoring_methods + FOR EACH STATEMENT EXECUTE FUNCTION designdb.trg_recreate_scores_pivoted_mv(); + +DROP TRIGGER IF EXISTS trg_enumeration_method_updated_on ON designdb.enumeration_methods; +CREATE TRIGGER trg_enumeration_method_updated_on BEFORE UPDATE ON designdb.enumeration_methods FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_pose_method_updated_on ON designdb.pose_methods; +CREATE TRIGGER trg_pose_method_updated_on BEFORE UPDATE ON designdb.pose_methods FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_compound_updated_on ON designdb.compounds; +CREATE TRIGGER trg_compound_updated_on BEFORE UPDATE ON designdb.compounds FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +/* +-- Will be deleted automatically from db from here: +-- Production trigger names reserved (see /* */ block at end of file); active temp triggers: +DROP TRIGGER IF EXISTS trg_compound_catalogue_map_sync_compound ON designdb.compounds; +DROP TRIGGER IF EXISTS trg_compound_catalogue_map_sync_compound_temp ON designdb.compounds; +CREATE TRIGGER trg_compound_catalogue_map_sync_compound_temp + AFTER INSERT OR UPDATE OF compound_hash ON designdb.compounds + FOR EACH ROW + EXECUTE FUNCTION designdb.trg_compound_catalogue_map_from_compound_temp(); +-- Will be deleted automatically until here +*/ + +DROP TRIGGER IF EXISTS trg_feature_updated_on ON designdb.features; +CREATE TRIGGER trg_feature_updated_on BEFORE UPDATE ON designdb.features FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_route_updated_on ON designdb.routes; +CREATE TRIGGER trg_route_updated_on BEFORE UPDATE ON designdb.routes FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_reaction_updated_on ON designdb.reactions; +CREATE TRIGGER trg_reaction_updated_on BEFORE UPDATE ON designdb.reactions FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_pose_updated_on ON designdb.poses; +CREATE TRIGGER trg_pose_updated_on BEFORE UPDATE ON designdb.poses FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_score_values_updated_on ON designdb.score_values; +CREATE TRIGGER trg_score_values_updated_on BEFORE UPDATE ON designdb.score_values FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_score_values_refresh_pivoted_mv ON designdb.score_values; +CREATE TRIGGER trg_score_values_refresh_pivoted_mv + AFTER INSERT OR UPDATE OR DELETE ON designdb.score_values + FOR EACH STATEMENT EXECUTE FUNCTION designdb.refresh_scores_per_pose_pivoted_mv(); + +DROP TRIGGER IF EXISTS trg_subsite_updated_on ON designdb.subsites; +CREATE TRIGGER trg_subsite_updated_on BEFORE UPDATE ON designdb.subsites FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_component_updated_on ON designdb.components; +CREATE TRIGGER trg_component_updated_on BEFORE UPDATE ON designdb.components FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_inspiration_updated_on ON designdb.inspirations; +CREATE TRIGGER trg_inspiration_updated_on BEFORE UPDATE ON designdb.inspirations FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_interaction_updated_on ON designdb.interactions; +CREATE TRIGGER trg_interaction_updated_on BEFORE UPDATE ON designdb.interactions FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_catalogue_updated_on ON designdb.catalogue_compounds; +CREATE TRIGGER trg_catalogue_updated_on BEFORE UPDATE ON designdb.catalogue_compounds FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_catalogue_price_updated_on ON designdb.catalogue_prices; +CREATE TRIGGER trg_catalogue_price_updated_on BEFORE UPDATE ON designdb.catalogue_prices FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +/* +-- Will be deleted automatically from db from here: +DROP TRIGGER IF EXISTS trg_compound_catalogue_map_sync_catalogue ON designdb.catalogue_compounds; +DROP TRIGGER IF EXISTS trg_compound_catalogue_map_sync_catalogue_temp ON designdb.catalogue_compounds; +CREATE TRIGGER trg_compound_catalogue_map_sync_catalogue_temp + AFTER INSERT OR UPDATE OF catalogue_hash ON designdb.catalogue_compounds + FOR EACH ROW + EXECUTE FUNCTION designdb.trg_compound_catalogue_map_from_catalogue_temp(); + +DROP TRIGGER IF EXISTS trg_compound_catalogue_map_sync_catalogue_price ON designdb.catalogue_prices; +DROP TRIGGER IF EXISTS trg_compound_catalogue_map_sync_catalogue_price_temp ON designdb.catalogue_prices; +CREATE TRIGGER trg_compound_catalogue_map_sync_catalogue_price_temp + AFTER INSERT OR UPDATE OF catalogue_id ON designdb.catalogue_prices + FOR EACH ROW + EXECUTE FUNCTION designdb.trg_compound_catalogue_map_from_catalogue_price_temp(); + +DROP TRIGGER IF EXISTS trg_compound_catalogue_map_refresh_denorm_from_catalogue_price ON designdb.catalogue_prices; +DROP TRIGGER IF EXISTS trg_compound_catalogue_map_refresh_denorm_from_catalogue_price_temp ON designdb.catalogue_prices; +CREATE TRIGGER trg_compound_catalogue_map_refresh_denorm_from_catalogue_price_temp + AFTER UPDATE OF supplier, amount, price, lead_time ON designdb.catalogue_prices + FOR EACH ROW + EXECUTE FUNCTION designdb.trg_compound_catalogue_map_refresh_denorm_from_catalogue_price_temp(); + +DROP TRIGGER IF EXISTS trg_compound_catalogue_map_refresh_denorm_from_catalogue_compound ON designdb.catalogue_compounds; +DROP TRIGGER IF EXISTS trg_compound_catalogue_map_refresh_denorm_from_catalogue_compound_temp ON designdb.catalogue_compounds; +CREATE TRIGGER trg_compound_catalogue_map_refresh_denorm_from_catalogue_compound_temp + AFTER UPDATE OF catalogue_inchikey ON designdb.catalogue_compounds + FOR EACH ROW + EXECUTE FUNCTION designdb.trg_compound_catalogue_map_refresh_denorm_from_catalogue_compound_temp(); +-- Will be deleted automatically until here +*/ + +DROP TRIGGER IF EXISTS trg_compound_catalogue_map_updated_on ON designdb.compound_catalogue_map; +CREATE TRIGGER trg_compound_catalogue_map_updated_on BEFORE UPDATE ON designdb.compound_catalogue_map FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +-- ========================================================= +-- AUDIT TRIGGERS +-- ========================================================= + +DROP TRIGGER IF EXISTS trg_catalogue_compounds_event_audit ON designdb.catalogue_compounds; +CREATE TRIGGER trg_catalogue_compounds_event_audit + AFTER INSERT OR UPDATE OR DELETE ON designdb.catalogue_compounds + FOR EACH ROW + EXECUTE FUNCTION designdb.event_audit_trigger( + 'designdb.catalogue_compounds_event_audit', + 'id', + 'created_on,updated_on', + '' + ); + +DROP TRIGGER IF EXISTS trg_catalogue_prices_event_audit ON designdb.catalogue_prices; +CREATE TRIGGER trg_catalogue_prices_event_audit + AFTER INSERT OR UPDATE OR DELETE ON designdb.catalogue_prices + FOR EACH ROW + EXECUTE FUNCTION designdb.event_audit_trigger( + 'designdb.catalogue_prices_event_audit', + 'id', + 'created_on,updated_on', + '' + ); + +DROP TRIGGER IF EXISTS trg_pose_tags_event_audit ON designdb.pose_tags; +CREATE TRIGGER trg_pose_tags_event_audit + AFTER INSERT OR UPDATE OR DELETE ON designdb.pose_tags + FOR EACH ROW + EXECUTE FUNCTION designdb.event_audit_trigger( + 'designdb.pose_tags_event_audit', + 'id', + 'created_on,updated_on', + '' + ); + +DROP TRIGGER IF EXISTS trg_compound_tags_event_audit ON designdb.compound_tags; +CREATE TRIGGER trg_compound_tags_event_audit + AFTER INSERT OR UPDATE OR DELETE ON designdb.compound_tags + FOR EACH ROW + EXECUTE FUNCTION designdb.event_audit_trigger( + 'designdb.compound_tags_event_audit', + 'id', + 'created_on,updated_on', + '' + ); + +DROP TRIGGER IF EXISTS trg_pose_methods_event_audit ON designdb.pose_methods; +CREATE TRIGGER trg_pose_methods_event_audit + AFTER INSERT OR UPDATE OR DELETE ON designdb.pose_methods + FOR EACH ROW + EXECUTE FUNCTION designdb.event_audit_trigger( + 'designdb.pose_methods_event_audit', + 'id', + 'created_on,updated_on', + '' + ); + +DROP TRIGGER IF EXISTS trg_enumeration_methods_event_audit ON designdb.enumeration_methods; +CREATE TRIGGER trg_enumeration_methods_event_audit + AFTER INSERT OR UPDATE OR DELETE ON designdb.enumeration_methods + FOR EACH ROW + EXECUTE FUNCTION designdb.event_audit_trigger( + 'designdb.enumeration_methods_event_audit', + 'id', + 'created_on,updated_on', + '' + ); + +DROP TRIGGER IF EXISTS trg_scoring_methods_event_audit ON designdb.scoring_methods; +CREATE TRIGGER trg_scoring_methods_event_audit + AFTER INSERT OR UPDATE OR DELETE ON designdb.scoring_methods + FOR EACH ROW + EXECUTE FUNCTION designdb.event_audit_trigger( + 'designdb.scoring_methods_event_audit', + 'id', + 'created_on,updated_on', + '' + ); + +DROP TRIGGER IF EXISTS trg_reactant_updated_on ON designdb.reactants; +CREATE TRIGGER trg_reactant_updated_on BEFORE UPDATE ON designdb.reactants FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_scaffold_updated_on ON designdb.scaffolds; +CREATE TRIGGER trg_scaffold_updated_on BEFORE UPDATE ON designdb.scaffolds FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_subsite_tag_updated_on ON designdb.subsite_tags; +CREATE TRIGGER trg_subsite_tag_updated_on BEFORE UPDATE ON designdb.subsite_tags FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +-- Removed due to replaced tables +-- DROP TRIGGER IF EXISTS trg_tag_updated_on ON designdb.tags; +-- CREATE TRIGGER trg_tag_updated_on BEFORE UPDATE ON designdb.tags FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_pose_tag_updated_on ON designdb.pose_tags; +CREATE TRIGGER trg_pose_tag_updated_on BEFORE UPDATE ON designdb.pose_tags FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_compound_tag_updated_on ON designdb.compound_tags; +CREATE TRIGGER trg_compound_tag_updated_on BEFORE UPDATE ON designdb.compound_tags FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_has_pose_tag_updated_on ON designdb.has_pose_tags; +CREATE TRIGGER trg_has_pose_tag_updated_on BEFORE UPDATE ON designdb.has_pose_tags FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_has_pose_methods_updated_on ON designdb.has_pose_methods; +CREATE TRIGGER trg_has_pose_methods_updated_on BEFORE UPDATE ON designdb.has_pose_methods FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_has_compound_tag_updated_on ON designdb.has_compound_tags; +CREATE TRIGGER trg_has_compound_tag_updated_on BEFORE UPDATE ON designdb.has_compound_tags FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_has_enumeration_methods_updated_on ON designdb.has_enumeration_methods; +CREATE TRIGGER trg_has_enumeration_methods_updated_on BEFORE UPDATE ON designdb.has_enumeration_methods FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +-- Pivoted materialized view once at schema load, this will be mapped in Scarab to do the filtering based on any type of scores/methods +SELECT designdb.create_scores_per_pose_pivoted_mv(); + +-- ============================================================================= +-- HIPPO → production revert (manual step on live DB): strip leading "-- " from the +-- DROP/ALTER lines below and run in order; then remove the "/*" and "*/" around the +-- production block so it can be manually executed. +-- +-- DROP TRIGGER IF EXISTS trg_compound_catalogue_map_refresh_denorm_from_catalogue_compound_temp ON designdb.catalogue_compounds; +-- DROP TRIGGER IF EXISTS trg_compound_catalogue_map_sync_catalogue_temp ON designdb.catalogue_compounds; +-- DROP TRIGGER IF EXISTS trg_compound_catalogue_map_sync_compound_temp ON designdb.compounds; +-- DROP TRIGGER IF EXISTS trg_compound_catalogue_map_refresh_denorm_from_catalogue_price_temp ON designdb.catalogue_prices; +-- DROP TRIGGER IF EXISTS trg_compound_catalogue_map_sync_catalogue_price_temp ON designdb.catalogue_prices; +-- +-- DROP FUNCTION IF EXISTS designdb.trg_compound_catalogue_map_refresh_denorm_from_catalogue_compound_temp(); +-- DROP FUNCTION IF EXISTS designdb.trg_compound_catalogue_map_from_catalogue_temp(); +-- DROP FUNCTION IF EXISTS designdb.trg_compound_catalogue_map_refresh_denorm_from_catalogue_price_temp(); +-- DROP FUNCTION IF EXISTS designdb.trg_compound_catalogue_map_from_catalogue_price_temp(); +-- DROP FUNCTION IF EXISTS designdb.trg_compound_catalogue_map_from_compound_temp(); +-- +-- ALTER TABLE designdb.compound_catalogue_map DROP COLUMN IF EXISTS catalogue_inchikey; +-- ALTER TABLE designdb.compound_catalogue_map DROP COLUMN IF EXISTS supplier; +-- ALTER TABLE designdb.compound_catalogue_map DROP COLUMN IF EXISTS amount; +-- ALTER TABLE designdb.compound_catalogue_map DROP COLUMN IF EXISTS price; +-- ALTER TABLE designdb.compound_catalogue_map DROP COLUMN IF EXISTS lead_time; +-- ============================================================================= + +-- === PRODUCTION: compound_catalogue_map (INSERT only compound_id, catalogue_price_id, match_hash) === + +CREATE OR REPLACE FUNCTION designdb.trg_compound_catalogue_map_from_compound() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF TG_OP = 'UPDATE' THEN + IF OLD.compound_hash IS NOT DISTINCT FROM NEW.compound_hash THEN + RETURN NEW; + END IF; + DELETE FROM designdb.compound_catalogue_map WHERE compound_id = NEW.id; + END IF; + + INSERT INTO designdb.compound_catalogue_map (compound_id, catalogue_price_id, match_hash) + SELECT NEW.id, cp.id, NEW.compound_hash + FROM designdb.catalogue_prices cp + JOIN designdb.catalogue_compounds cat ON cat.id = cp.catalogue_id + WHERE cat.catalogue_hash = NEW.compound_hash + ON CONFLICT (compound_id, catalogue_price_id) DO NOTHING; + + RETURN NEW; +END; +$$; + +CREATE OR REPLACE FUNCTION designdb.trg_compound_catalogue_map_from_catalogue() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF TG_OP = 'UPDATE' THEN + IF OLD.catalogue_hash IS NOT DISTINCT FROM NEW.catalogue_hash THEN + RETURN NEW; + END IF; + DELETE FROM designdb.compound_catalogue_map + WHERE catalogue_price_id IN ( + SELECT id FROM designdb.catalogue_prices WHERE catalogue_id = NEW.id + ); + END IF; + + INSERT INTO designdb.compound_catalogue_map (compound_id, catalogue_price_id, match_hash) + SELECT c.id, cp.id, c.compound_hash + FROM designdb.compounds c + CROSS JOIN designdb.catalogue_prices cp + WHERE cp.catalogue_id = NEW.id AND c.compound_hash = NEW.catalogue_hash + ON CONFLICT (compound_id, catalogue_price_id) DO NOTHING; + + RETURN NEW; +END; +$$; + +CREATE OR REPLACE FUNCTION designdb.trg_compound_catalogue_map_from_catalogue_price() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF TG_OP = 'UPDATE' AND OLD.catalogue_id IS NOT DISTINCT FROM NEW.catalogue_id THEN + RETURN NEW; + END IF; + IF TG_OP = 'UPDATE' THEN + DELETE FROM designdb.compound_catalogue_map WHERE catalogue_price_id = NEW.id; + END IF; + + INSERT INTO designdb.compound_catalogue_map (compound_id, catalogue_price_id, match_hash) + SELECT c.id, NEW.id, c.compound_hash + FROM designdb.compounds c + JOIN designdb.catalogue_compounds cat ON cat.id = NEW.catalogue_id + WHERE c.compound_hash = cat.catalogue_hash + ON CONFLICT (compound_id, catalogue_price_id) DO NOTHING; + + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_compound_catalogue_map_sync_compound ON designdb.compounds; +CREATE TRIGGER trg_compound_catalogue_map_sync_compound + AFTER INSERT OR UPDATE OF compound_hash ON designdb.compounds + FOR EACH ROW + EXECUTE FUNCTION designdb.trg_compound_catalogue_map_from_compound(); + +DROP TRIGGER IF EXISTS trg_compound_catalogue_map_sync_catalogue ON designdb.catalogue_compounds; +CREATE TRIGGER trg_compound_catalogue_map_sync_catalogue + AFTER INSERT OR UPDATE OF catalogue_hash ON designdb.catalogue_compounds + FOR EACH ROW + EXECUTE FUNCTION designdb.trg_compound_catalogue_map_from_catalogue(); + +DROP TRIGGER IF EXISTS trg_compound_catalogue_map_sync_catalogue_price ON designdb.catalogue_prices; +CREATE TRIGGER trg_compound_catalogue_map_sync_catalogue_price + AFTER INSERT OR UPDATE OF catalogue_id ON designdb.catalogue_prices + FOR EACH ROW + EXECUTE FUNCTION designdb.trg_compound_catalogue_map_from_catalogue_price(); diff --git a/images/xchem-designdb/Dockerfile b/images/xchem-designdb/Dockerfile new file mode 100644 index 00000000..1b724372 --- /dev/null +++ b/images/xchem-designdb/Dockerfile @@ -0,0 +1,127 @@ +# Dockerfile — RDKit + Postgres cartridge build (Bookworm / Postgres 18) +# CVE mitigation: pinned base 18.3-bookworm; gosu rebuilt with Go >= 1.24.8 to fix golang stdlib CVEs; apt-get upgrade in runtime. + +ARG RDKIT_VERSION=Release_2025_09_5 +ARG BOOST_VERSION=1.90.0 +ARG BOOST_VER_US=1_90_0 +ARG PG_MAJOR=18 +ARG PG_BASE=postgres:18.3-bookworm +ARG GOLANG_VERSION=1.24.13-alpine +ARG GOSU_VERSION=1.19 + +# ------------------------------------------------------------------- +# Stage 1: builder image (uses upstream postgres dev headers) +# ------------------------------------------------------------------- +FROM ${PG_BASE} AS builder + +ARG RDKIT_VERSION +ARG BOOST_VERSION +ARG BOOST_VER_US +ARG PG_MAJOR + +ENV DEBIAN_FRONTEND=noninteractive +ENV CMAKE_PREFIX_PATH=/usr/local +ENV PATH="/usr/lib/postgresql/${PG_MAJOR}/bin:${PATH}" + +# Install build deps (no python3-pip; RDKit Python wrappers are OFF) +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential cmake git ca-certificates curl python3 \ + postgresql-server-dev-${PG_MAJOR} \ + libeigen3-dev libcairo2-dev libfreetype6-dev libpng-dev libjpeg-dev \ + zlib1g-dev pkg-config && \ + rm -rf /var/lib/apt/lists/* + +# Build Boost from source (ensures ABI compatibility) +WORKDIR /opt +RUN set -eux; \ + BOOST_URL="https://archives.boost.io/release/${BOOST_VERSION}/source/boost_${BOOST_VER_US}.tar.gz"; \ + echo "Downloading Boost from: ${BOOST_URL}"; \ + curl -fSL --retry 5 --retry-connrefused -o boost.tar.gz "${BOOST_URL}"; \ + tar -xzf boost.tar.gz; \ + cd "boost_${BOOST_VER_US}"; \ + ./bootstrap.sh --prefix=/usr/local; \ + ./b2 -j"$(nproc)" install + +# pg_config in PATH and as PG_CONFIG (RDKit CMake may probe both) +ENV PG_CONFIG="/usr/lib/postgresql/${PG_MAJOR}/bin/pg_config" +RUN ln -sf "$PG_CONFIG" /usr/bin/pg_config && \ + mkdir -p /usr/lib/bin && ln -sf "$PG_CONFIG" /usr/lib/bin/pg_config + +# Fetch RDKit source +RUN git clone --depth 1 --branch "${RDKIT_VERSION}" https://github.com/rdkit/rdkit.git /opt/rdkit + +# Configure and build RDKit with PostgreSQL cartridge +WORKDIR /opt/rdkit_build +RUN set -eux; \ + PG_INC="$(pg_config --includedir)"; \ + PG_INC_SRV="$(pg_config --includedir-server)"; \ + PG_LIB="$(pg_config --libdir)"; \ + cmake -S /opt/rdkit -B /opt/rdkit_build \ + -DCMAKE_BUILD_TYPE=Release \ + -DRDK_BUILD_PYTHON_WRAPPERS=OFF \ + -DRDK_BUILD_PGSQL=ON \ + -DRDK_BUILD_INCHI_SUPPORT=ON \ + -DRDK_BUILD_AVALON_SUPPORT=ON \ + -DRDK_BUILD_CAIRO_SUPPORT=ON \ + -DRDK_PGSQL_STATIC=ON \ + -DPostgreSQL_CONFIG=pg_config \ + -DPG_CONFIG=/usr/lib/postgresql/${PG_MAJOR}/bin/pg_config \ + -DPostgreSQL_INCLUDE_DIR="${PG_INC}" \ + -DPostgreSQL_TYPE_INCLUDE_DIR="${PG_INC_SRV}" \ + -DPostgreSQL_LIBRARY_DIR="${PG_LIB}" && \ + cmake --build /opt/rdkit_build -j"$(nproc)" && \ + cmake --install /opt/rdkit_build --strip + +# ------------------------------------------------------------------- +# Stage 2: gosu builder (fixes golang stdlib CVEs in postgres base image) +# ------------------------------------------------------------------- +# The official postgres image includes gosu (used by docker-entrypoint.sh for +# privilege switching) built with an older Go stdlib, causing ~60 CVEs including +# CVE-2025-68121 (Critical). Upstream fix: https://github.com/docker-library/postgres/pull/1323 +# We rebuild gosu with Go (stdlib >= 1.24.8 fixes the CVEs) and replace the binary. +# REMOVAL: Remove this stage when the official postgres image ships gosu built +# with Go >= 1.24.8 (check https://github.com/docker-library/postgres/pull/1323). +FROM golang:${GOLANG_VERSION} AS gosu-builder +ARG GOSU_VERSION=1.19 +RUN apk add --no-cache git ca-certificates && \ + git clone --depth 1 --branch "${GOSU_VERSION}" https://github.com/tianon/gosu.git /gosu && \ + cd /gosu && go build -o gosu . + +# ------------------------------------------------------------------- +# Stage 3: final runtime image +# ------------------------------------------------------------------- +FROM ${PG_BASE} + +ARG PG_MAJOR + +ENV DEBIAN_FRONTEND=noninteractive + +# Replace gosu with CVE-free build (see gosu-builder stage above) +COPY --from=gosu-builder /gosu/gosu /usr/local/bin/gosu +RUN gosu --version + +# Runtime libraries required by rdkit and cairo/freetype. Latest Debian patches to fix CVE +RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends \ + libcairo2 libfreetype6 libpng16-16 libjpeg62-turbo zlib1g && \ + rm -rf /var/lib/apt/lists/* + +# Copy RDKit cartridge artifacts from builder +COPY --from=builder /usr/lib/postgresql/${PG_MAJOR}/lib/rdkit.so /usr/lib/postgresql/${PG_MAJOR}/lib/ +COPY --from=builder /usr/share/postgresql/${PG_MAJOR}/extension/rdkit* /usr/share/postgresql/${PG_MAJOR}/extension/ + +# Copy Boost runtime libs from builder +COPY --from=builder /usr/local/lib/libboost_*.so* /usr/local/lib/ + +# Update linker cache so Postgres can load rdkit.so +RUN ldconfig + +# Copy init-db scripts (run on first start) +COPY init-db/ /docker-entrypoint-initdb.d/ + +# Healthcheck (uses POSTGRES_USER from env when set, e.g. by compose) +HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=5 \ + CMD pg_isready -U "$${POSTGRES_USER:-postgres}" || exit 1 + +EXPOSE 5432 + +# The base postgres image will handle the entrypoint diff --git a/images/xchem-designdb/docker-compose.yml b/images/xchem-designdb/docker-compose.yml new file mode 100644 index 00000000..ffd5ad1b --- /dev/null +++ b/images/xchem-designdb/docker-compose.yml @@ -0,0 +1,42 @@ +# Postgres + RDKit cartridge (designdb). Config via .env. +# Rebuild with: docker compose build --pull to refresh base image and reduce CVEs. + +services: + database: + build: + context: . + dockerfile: Dockerfile + args: + RDKIT_VERSION: ${RDKIT_VERSION:-Release_2025_09_5} + BOOST_VERSION: ${BOOST_VERSION:-1.90.0} + BOOST_VER_US: ${BOOST_VER_US:-1_90_0} + PG_MAJOR: ${PG_MAJOR:-18} + PG_BASE: ${PG_BASE:-postgres:18.3-bookworm} + image: xchem_designdb:latest + container_name: xchem_designdb + restart: unless-stopped + ports: + - "${POSTGRES_PORT:-5432}:5432" + volumes: + - ${POSTGRES_DATA_PATH}:/var/lib/postgresql/data + environment: + POSTGRES_DB: ${DB_NAME:-designdb} + POSTGRES_USER: ${DB_USER} + POSTGRES_PASSWORD: ${DB_PASSWORD} + POSTGRES_INITDB_ARGS: ${POSTGRES_INITDB_ARGS:---auth-host=scram-sha-256 --auth-local=trust} + PGDATA: ${PGDATA:-/var/lib/postgresql/data} + POSTGRES_HOST_AUTH_METHOD: ${POSTGRES_HOST_AUTH_METHOD:-scram-sha-256} + shm_size: 8g + healthcheck: + test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\" -h localhost"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 90s + networks: + - app_network + +networks: + app_network: + name: xchem_designdb_network + driver: bridge diff --git a/images/xchem-designdb/env.template b/images/xchem-designdb/env.template new file mode 100644 index 00000000..791d64e8 --- /dev/null +++ b/images/xchem-designdb/env.template @@ -0,0 +1,22 @@ +# ========================================================= +# DATABASE CONFIGURATION +# ========================================================= +DB_NAME=CHANGE_ME +DB_USER=CHANGE_ME +DB_PASSWORD=CHANGE_ME + +# PostgreSQL +POSTGRES_PORT=5432 +POSTGRES_INITDB_ARGS=--auth-host=scram-sha-256 --auth-local=trust +PGDATA=/var/lib/postgresql/data +POSTGRES_HOST_AUTH_METHOD=scram-sha-256 + +# Volume path for persistent data +POSTGRES_DATA_PATH=CHANGE_ME + +# Build versions (RDKit / Postgres versions) +RDKIT_VERSION=Release_2025_09_5 +BOOST_VERSION=1.90.0 +BOOST_VER_US=1_90_0 +PG_MAJOR=18 +PG_BASE=postgres:18.2-bookworm diff --git a/images/xchem-designdb/init-db/01_schema.sql b/images/xchem-designdb/init-db/01_schema.sql new file mode 100644 index 00000000..7bff0ea4 --- /dev/null +++ b/images/xchem-designdb/init-db/01_schema.sql @@ -0,0 +1,1324 @@ +-- ========================================================= +-- designdb Database Schema +-- ========================================================= + +-- ========================================================= +DROP SCHEMA IF EXISTS designdb CASCADE; +-- ========================================================= + +-- ========================================================= +-- PREREQUISITES & EXTENSIONS +-- ========================================================= + +CREATE SCHEMA IF NOT EXISTS designdb; +CREATE SCHEMA IF NOT EXISTS rdkit; + +CREATE EXTENSION IF NOT EXISTS rdkit WITH SCHEMA rdkit; +CREATE EXTENSION IF NOT EXISTS pgcrypto WITH SCHEMA designdb; + +SET search_path TO designdb, rdkit, public; + +REVOKE CREATE ON SCHEMA public FROM PUBLIC; + +-- ========================================================= +-- TABLES (ordered by FK dependencies) +-- ========================================================= + +CREATE TABLE IF NOT EXISTS designdb.projects ( + id BIGSERIAL PRIMARY KEY, + project_name TEXT NOT NULL, + open_to_public BOOLEAN NOT NULL DEFAULT FALSE, + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_project UNIQUE (project_name) +); + +CREATE TABLE IF NOT EXISTS designdb.targets ( + id BIGSERIAL PRIMARY KEY, --Internal ID inserted when registering target via Fragalysis + external_target_id BIGINT, -- ID of this target in the external database (Scarab link) + target_name TEXT NOT NULL, --Insert from HIPPO codebase. Must be a link to Scarab protein production target + target_metadata TEXT, -- Not populated by code + project_id BIGINT NOT NULL REFERENCES designdb.projects (id) ON DELETE RESTRICT, + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_target UNIQUE (target_name) +); + +CREATE TABLE IF NOT EXISTS designdb.compounds ( + id BIGSERIAL PRIMARY KEY, + compound_inchikey TEXT, -- Populated by RDKit cartridge trigger from compound_smiles (do not insert by code). Maybe insert by the codebase or jupyter. Do we need to write this by code or can be calculated by the cartridge? + compound_alias TEXT, -- Maybe insert by the codebase. + compound_smiles TEXT, -- Inserted by the codebase. Trigger populates compound_mol and compound_inchikey. 2D flat SMILES. Is this 2d flat smiles without any stereochemistry? LR - Yes 2D. Looks like designdb function sanitise_smiles does remove stereochemistry - will this be a problem when a user wants to register a design with defined stereochemistry? + compound_hash TEXT NOT NULL, -- Canonical identity hash from application pipeline (e.g. RDKit RegistrationHash after SuperParent); pair with rdkit_version for reproducibility + base_compound_id BIGINT REFERENCES designdb.compounds (id) ON DELETE SET NULL, -- Not populated by code + -- compound_mol rdkit.mol, -- Replaced by TEXT CTAB below: JDBC showed SMILES text; mol_to_ctab gives a molfile string that Scarab will easily convert to structure. + compound_mol TEXT, -- V2000 CTAB (mol block) from rdkit.mol_to_ctab(mol_from_smiles(...)) + compound_pattern_bfp bit(2048), -- Postgres RDkit cartridge can calc this, Chemicalite does, not sure if insert by codebase. Currently seems broken + compound_morgan_bfp bit(2048), -- Postgres cartridge can't calc this. Must be inserted by codebase, but currently its broken + compound_metadata TEXT, -- currently Null + note TEXT, -- New column + rdkit_version TEXT, -- RDKit version string used when computing compound_hash (application-set; cartridge may also populate) + inchi_version TEXT NOT NULL, -- InChI software version (rdkit.Chem.inchi.GetInchiVersion) + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_compound_alias UNIQUE (compound_alias), + CONSTRAINT uc_compound_inchikey UNIQUE (compound_inchikey), + CONSTRAINT uc_compound_smiles UNIQUE (compound_smiles) +); + +CREATE TABLE IF NOT EXISTS designdb.subsites ( + id BIGSERIAL PRIMARY KEY, + target_id BIGINT NOT NULL REFERENCES designdb.targets (id) ON DELETE RESTRICT, -- Insert by codebase/notebook + subsite_name TEXT NOT NULL, -- Insert by codebase/notebook + subsite_metadata TEXT, -- Not populated by code + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_subsite UNIQUE (target_id, subsite_name) +); + +CREATE TABLE IF NOT EXISTS designdb.poses ( + id BIGSERIAL PRIMARY KEY, + pose_inchikey TEXT, -- Populated by RDKit cartridge trigger from pose_mol (do not insert by code). Originally, nserted by codebase when registering poses? Might be done from pose.mol? + pose_alias TEXT, + pose_smiles TEXT, -- Populated by RDKit cartridge trigger from pose_mol (do not insert by code). LR - necessary because will contain defined stereochemistry - should these be canonicalised? Is it done by codebase from pose.mol? Could be done by RDkit cartridge. + pose_reference INTEGER, + protein_link TEXT, + compound_id BIGINT NOT NULL REFERENCES designdb.compounds (id) ON DELETE RESTRICT, + target_id BIGINT NOT NULL REFERENCES designdb.targets (id) ON DELETE RESTRICT, + pose_mol rdkit.mol, -- Insert by codebase. Trigger populates pose_inchikey and pose_smiles via cartridge. + pose_fingerprint INTEGER, --Not sure if it null or actually calcualated somewhere. + --pose_energy_score REAL, -- LR - redundant; use designdb.score_values + --pose_distance_score REAL, -- LR - redundant; use designdb.score_values + --pose_inspiration_score REAL, -- LR - redundant; use designdb.score_values + pose_metadata TEXT, + note TEXT, -- New column + rdkit_version TEXT, --Can be done by RDkit cartridge + inchi_version TEXT, -- Must be done by codebase + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now() + -- CONSTRAINT uc_pose_alias UNIQUE (pose_alias), -- Removed + -- CONSTRAINT uc_protein_link UNIQUE (protein_link) -- Removed +); + +CREATE TABLE IF NOT EXISTS designdb.subsite_tags ( + id BIGSERIAL PRIMARY KEY, + subsite_id BIGINT NOT NULL REFERENCES designdb.subsites (id) ON DELETE RESTRICT, -- Insert by codebase + pose_id BIGINT NOT NULL REFERENCES designdb.poses (id) ON DELETE RESTRICT, -- Insert by codebase + subsite_tag_metadata TEXT, -- Not populated by code + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_subsite_tag UNIQUE (subsite_id, pose_id) +); + +-- New table +CREATE TABLE IF NOT EXISTS designdb.pose_methods ( + id BIGSERIAL PRIMARY KEY, + pose_method_name TEXT, + pose_method_description TEXT, + pose_method_version TEXT, + pose_method_organization TEXT, + pose_method_link TEXT, + pose_method_note TEXT, + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_pose_method UNIQUE NULLS NOT DISTINCT (pose_method_name, pose_method_version) +); + +-- New table +CREATE TABLE IF NOT EXISTS designdb.has_pose_methods ( + pose_id BIGINT NOT NULL REFERENCES designdb.poses (id) ON DELETE CASCADE, + pose_method_id BIGINT NOT NULL REFERENCES designdb.pose_methods (id) ON DELETE CASCADE, + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + PRIMARY KEY (pose_id, pose_method_id) +); + +-- New table +CREATE TABLE IF NOT EXISTS designdb.pose_tags ( + id BIGSERIAL PRIMARY KEY, + pose_tag_name TEXT NOT NULL, + pose_tag_description TEXT, + pose_tag_note TEXT, + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_pose_tag_name UNIQUE (pose_tag_name) +); + +-- New table +CREATE TABLE IF NOT EXISTS designdb.has_pose_tags ( + pose_id BIGINT NOT NULL REFERENCES designdb.poses (id) ON DELETE CASCADE, + pose_tag_id BIGINT NOT NULL REFERENCES designdb.pose_tags (id) ON DELETE CASCADE, + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + PRIMARY KEY (pose_id, pose_tag_id) +); + +CREATE TABLE IF NOT EXISTS designdb.inspirations ( + id BIGSERIAL PRIMARY KEY, + original_pose_id BIGINT REFERENCES designdb.poses (id) ON DELETE SET NULL, -- Insert by codebase + derivative_pose_id BIGINT REFERENCES designdb.poses (id) ON DELETE SET NULL, -- Insert by codebase + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_inspiration UNIQUE (original_pose_id, derivative_pose_id) +); + +CREATE TABLE IF NOT EXISTS designdb.features ( + id BIGSERIAL PRIMARY KEY, + feature_family TEXT, -- Insert by codebase + target_id BIGINT NOT NULL REFERENCES designdb.targets (id) ON DELETE RESTRICT, -- Insert by codebase + feature_chain_name TEXT, -- Insert by codebase + feature_residue_name TEXT, -- Insert by codebase + feature_residue_number INTEGER, -- Insert by codebase + feature_atom_name TEXT, -- Insert by codebase + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_feature UNIQUE (feature_family, target_id, feature_chain_name, feature_residue_number, feature_residue_name, feature_atom_name) +); + +CREATE TABLE IF NOT EXISTS designdb.interactions ( + id BIGSERIAL PRIMARY KEY, + feature_id BIGINT NOT NULL REFERENCES designdb.features (id) ON DELETE RESTRICT, -- Insert by codebase + pose_id BIGINT NOT NULL REFERENCES designdb.poses (id) ON DELETE RESTRICT, -- Insert by codebase + interaction_type TEXT NOT NULL, -- Insert by codebase + interaction_family TEXT NOT NULL, -- Insert by codebase + interaction_atom_id TEXT NOT NULL, -- Insert by codebase + interaction_prot_coord TEXT NOT NULL, -- Insert by codebase. Not populated by ProLIF, needs reviewing + interaction_lig_coord TEXT NOT NULL, -- Insert by codebase. Not populated by ProLIF, needs reviewing + interaction_distance REAL NOT NULL, -- Insert by codebase + interaction_angle REAL, -- Insert by codebase + interaction_energy REAL, -- Insert by codebase. Not populated by ProLIF, needs reviewing + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_interaction UNIQUE (feature_id, pose_id, interaction_type, interaction_family, interaction_atom_id) +); + +-- New table +CREATE TABLE IF NOT EXISTS designdb.compound_tags ( + id BIGSERIAL PRIMARY KEY, + compound_tag_name TEXT NOT NULL, + compound_tag_description TEXT, + compound_tag_note TEXT, + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_compound_tag_name UNIQUE (compound_tag_name) +); + +-- New table +CREATE TABLE IF NOT EXISTS designdb.has_compound_tags ( + compound_id BIGINT NOT NULL REFERENCES designdb.compounds (id) ON DELETE CASCADE, + compound_tag_id BIGINT NOT NULL REFERENCES designdb.compound_tags (id) ON DELETE CASCADE, + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + PRIMARY KEY (compound_id, compound_tag_id) +); + +-- New table +CREATE TABLE IF NOT EXISTS designdb.enumeration_methods ( + id BIGSERIAL PRIMARY KEY, + enum_name TEXT, + enum_description TEXT, + enum_version TEXT, + enum_organization TEXT, + enum_link TEXT, + enum_note TEXT, + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_enumeration_method UNIQUE NULLS NOT DISTINCT (enum_name, enum_version) +); + +-- New table +CREATE TABLE IF NOT EXISTS designdb.has_enumeration_methods ( + compound_id BIGINT NOT NULL REFERENCES designdb.compounds (id) ON DELETE CASCADE, + enumeration_method_id BIGINT NOT NULL REFERENCES designdb.enumeration_methods (id) ON DELETE CASCADE, + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + PRIMARY KEY (compound_id, enumeration_method_id) +); + +-- New table +CREATE TABLE IF NOT EXISTS designdb.scoring_methods ( + id BIGSERIAL PRIMARY KEY, + method_name TEXT, + method_description TEXT, + method_version TEXT, + method_organization TEXT, + method_link TEXT, + note TEXT, + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_scoring_method UNIQUE NULLS NOT DISTINCT (method_name, method_version) +); + +-- One row per (pose_id, compound_id, scoring_method_id). score JSONB stores {"score": value} (numeric or text). +CREATE TABLE IF NOT EXISTS designdb.score_values ( + pose_id BIGINT NOT NULL REFERENCES designdb.poses (id) ON DELETE RESTRICT, + compound_id BIGINT NOT NULL REFERENCES designdb.compounds (id) ON DELETE RESTRICT, + scoring_method_id BIGINT NOT NULL REFERENCES designdb.scoring_methods (id) ON DELETE RESTRICT, + score JSONB NOT NULL, + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT pk_score_values PRIMARY KEY (pose_id, compound_id, scoring_method_id) +); + +CREATE TABLE IF NOT EXISTS designdb.reactions ( + id BIGSERIAL PRIMARY KEY, + reaction_type TEXT, -- Insert by codebase/notebook, Synderilla + product_compound_id BIGINT NOT NULL REFERENCES designdb.compounds (id) ON DELETE RESTRICT, -- Insert by codebase/notebook, Synderilla + reaction_product_yield REAL, -- Insert by codebase/notebook, Synderilla + reaction_metadata TEXT, -- Not populated by code + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS designdb.reactants ( + id BIGSERIAL PRIMARY KEY, + reactant_amount REAL, -- Insert by codebase/notebook, Synderilla + reaction_id BIGINT NOT NULL REFERENCES designdb.reactions (id) ON DELETE CASCADE, -- Insert by codebase/notebook, Synderilla + compound_id BIGINT NOT NULL REFERENCES designdb.compounds (id) ON DELETE RESTRICT, -- Insert by codebase/notebook, Synderilla + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_reactant UNIQUE (reaction_id, compound_id) +); + +-- Registration identity: one row per distinct catalogue_smiles (unique). catalogue_inchikey is NOT unique — +-- different SMILES strings can map to the same Standard InChIKey after cartridge normalization (trigger from SMILES). +-- catalogue_hash: application / loader pipeline (same algorithm as compounds.compound_hash); NOT unique — multiple +-- rows may share a hash when SuperParent/registration hash collapses stereoisomers differently than stored SMILES and that's the correct behaviour +CREATE TABLE IF NOT EXISTS designdb.catalogue_compounds ( + id BIGSERIAL PRIMARY KEY, + catalogue_smiles TEXT NOT NULL, + catalogue_inchikey TEXT NOT NULL, -- Populated by RDKit cartridge trigger from catalogue_smiles + catalogue_hash TEXT NOT NULL, -- Set by Enamine parsing script on insert/update; links via designdb.compound_catalogue_map + rdkit_version TEXT NOT NULL, + inchi_version TEXT NOT NULL, + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uq_catalogue_compounds_smiles UNIQUE (catalogue_smiles), + CONSTRAINT ck_catalogue_compounds_hash_nonempty CHECK (length(trim(catalogue_hash)) > 0) +); + +-- Former quotes rows split out. supplier = old quote_catalogue; supplier_id = old quote_entry; vendor = old quote_supplier. +CREATE TABLE IF NOT EXISTS designdb.catalogue_prices ( + id BIGSERIAL PRIMARY KEY, + catalogue_id BIGINT NOT NULL REFERENCES designdb.catalogue_compounds (id) ON DELETE CASCADE, + vendor TEXT NOT NULL, + supplier TEXT, + supplier_id TEXT NOT NULL, + amount REAL NOT NULL, + price REAL, + currency TEXT, + purity REAL, + lead_time INTEGER, + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_catalogue_price UNIQUE (catalogue_id, vendor, supplier, supplier_id, amount) +); + +-- Many-to-many: compounds - catalogue price lines matched on shared identity hash (compound_hash = catalogue_hash). +CREATE TABLE IF NOT EXISTS designdb.compound_catalogue_map ( + compound_id BIGINT NOT NULL REFERENCES designdb.compounds (id) ON DELETE CASCADE, + catalogue_price_id BIGINT NOT NULL REFERENCES designdb.catalogue_prices (id) ON DELETE CASCADE, + match_hash TEXT NOT NULL, + created_on TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_on TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (compound_id, catalogue_price_id), + CONSTRAINT ck_compound_catalogue_map_match_hash_nonempty CHECK (length(trim(match_hash)) > 0) +); + +CREATE TABLE IF NOT EXISTS designdb.scaffolds ( + id BIGSERIAL PRIMARY KEY, + base_compound_id BIGINT REFERENCES designdb.compounds (id) ON DELETE SET NULL, -- Insert by codebase + superstructure_compound_id BIGINT REFERENCES designdb.compounds (id) ON DELETE SET NULL, -- Insert by codebase + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_scaffold UNIQUE (base_compound_id, superstructure_compound_id) +); + +CREATE TABLE IF NOT EXISTS designdb.routes ( + id BIGSERIAL PRIMARY KEY, + product_compound_id BIGINT NOT NULL REFERENCES designdb.compounds (id) ON DELETE RESTRICT, -- Insert by codebase/notebook, Synderilla + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS designdb.components ( + id BIGSERIAL PRIMARY KEY, + route_id BIGINT NOT NULL REFERENCES designdb.routes (id) ON DELETE RESTRICT, -- Insert by codebase/notebook, Synderilla + component_type INTEGER, -- Insert by codebase/notebook, Synderilla-- + component_ref INTEGER, -- Insert by codebase/notebook, Synderilla + component_amount REAL, -- Insert by codebase/notebook, Synderilla + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_component UNIQUE (route_id, component_ref, component_type) +); + +-- Replaced table with individual tag tables +-- CREATE TABLE IF NOT EXISTS designdb.tags ( +-- id BIGSERIAL PRIMARY KEY, +-- tag_name TEXT, -- Insert by codebase +-- tag_description TEXT, -- New column +-- note TEXT, -- New column +-- -- compound_id BIGINT REFERENCES designdb.compounds (id) ON DELETE SET NULL, -- Insert by codebase, need to be removed, change in code needed +-- -- pose_id BIGINT REFERENCES designdb.poses (id) ON DELETE SET NULL, -- Insert by codebase, need to be removed, change in code needed +-- created_on TIMESTAMPTZ DEFAULT now(), +-- updated_on TIMESTAMPTZ DEFAULT now() +-- -- CONSTRAINT uc_tag_compound UNIQUE (tag_name, compound_id), +-- -- CONSTRAINT uc_tag_pose UNIQUE (tag_name, pose_id) +-- ); + + +-- ========================================================= +-- AUDIT TABLES +-- ========================================================= + +-- Event audit for catalogue_compounds (chemistry / identity rows) +CREATE TABLE IF NOT EXISTS designdb.catalogue_compounds_event_audit ( + audit_pk BIGSERIAL PRIMARY KEY, + id BIGINT NOT NULL, + operation CHAR(1) NOT NULL CHECK (operation IN ('I','U','D')), + old_values JSONB, + new_values JSONB, + changed_by TEXT, + changed_at TIMESTAMPTZ DEFAULT now() +); + +-- Event audit for catalogue_prices (vendor / pricing lines) +CREATE TABLE IF NOT EXISTS designdb.catalogue_prices_event_audit ( + audit_pk BIGSERIAL PRIMARY KEY, + id BIGINT NOT NULL, + operation CHAR(1) NOT NULL CHECK (operation IN ('I','U','D')), + old_values JSONB, + new_values JSONB, + changed_by TEXT, + changed_at TIMESTAMPTZ DEFAULT now() +); + +-- Event audit for pose_tags +CREATE TABLE IF NOT EXISTS designdb.pose_tags_event_audit ( + audit_pk BIGSERIAL PRIMARY KEY, + id BIGINT NOT NULL, + operation CHAR(1) NOT NULL CHECK (operation IN ('I','U','D')), + old_values JSONB, + new_values JSONB, + changed_by TEXT, + changed_at TIMESTAMPTZ DEFAULT now() +); + +-- Event audit for compound_tags +CREATE TABLE IF NOT EXISTS designdb.compound_tags_event_audit ( + audit_pk BIGSERIAL PRIMARY KEY, + id BIGINT NOT NULL, + operation CHAR(1) NOT NULL CHECK (operation IN ('I','U','D')), + old_values JSONB, + new_values JSONB, + changed_by TEXT, + changed_at TIMESTAMPTZ DEFAULT now() +); + +-- Event audit for pose_methods +CREATE TABLE IF NOT EXISTS designdb.pose_methods_event_audit ( + audit_pk BIGSERIAL PRIMARY KEY, + id BIGINT NOT NULL, + operation CHAR(1) NOT NULL CHECK (operation IN ('I','U','D')), + old_values JSONB, + new_values JSONB, + changed_by TEXT, + changed_at TIMESTAMPTZ DEFAULT now() +); + +-- Event audit for enumeration_methods +CREATE TABLE IF NOT EXISTS designdb.enumeration_methods_event_audit ( + audit_pk BIGSERIAL PRIMARY KEY, + id BIGINT NOT NULL, + operation CHAR(1) NOT NULL CHECK (operation IN ('I','U','D')), + old_values JSONB, + new_values JSONB, + changed_by TEXT, + changed_at TIMESTAMPTZ DEFAULT now() +); + +-- Event audit for scoring_methods +CREATE TABLE IF NOT EXISTS designdb.scoring_methods_event_audit ( + audit_pk BIGSERIAL PRIMARY KEY, + id BIGINT NOT NULL, + operation CHAR(1) NOT NULL CHECK (operation IN ('I','U','D')), + old_values JSONB, + new_values JSONB, + changed_by TEXT, + changed_at TIMESTAMPTZ DEFAULT now() +); + +-- ========================================================= +-- INDEXES +-- ========================================================= + +CREATE INDEX IF NOT EXISTS idx_target_name ON designdb.targets(target_name); +CREATE INDEX IF NOT EXISTS idx_target_created ON designdb.targets(created_on); + +CREATE INDEX IF NOT EXISTS idx_scoring_method_name ON designdb.scoring_methods(method_name); +CREATE INDEX IF NOT EXISTS idx_scoring_method_created ON designdb.scoring_methods(created_on); + +CREATE INDEX IF NOT EXISTS idx_enumeration_method_name ON designdb.enumeration_methods(enum_name); +CREATE INDEX IF NOT EXISTS idx_enumeration_method_created ON designdb.enumeration_methods(created_on); + +CREATE INDEX IF NOT EXISTS idx_pose_method_name ON designdb.pose_methods(pose_method_name); +CREATE INDEX IF NOT EXISTS idx_pose_method_created ON designdb.pose_methods(created_on); + +CREATE INDEX IF NOT EXISTS idx_compound_base_compound_id ON designdb.compounds(base_compound_id); +CREATE INDEX IF NOT EXISTS idx_compound_inchikey ON designdb.compounds(compound_inchikey); +CREATE INDEX IF NOT EXISTS idx_compound_smiles ON designdb.compounds(compound_smiles); +CREATE INDEX IF NOT EXISTS idx_compound_compound_hash ON designdb.compounds(compound_hash); +CREATE INDEX IF NOT EXISTS idx_compound_created ON designdb.compounds(created_on); + +CREATE INDEX IF NOT EXISTS idx_feature_target_id ON designdb.features(target_id); +CREATE INDEX IF NOT EXISTS idx_feature_created ON designdb.features(created_on); + +CREATE INDEX IF NOT EXISTS idx_route_product_compound_id ON designdb.routes(product_compound_id); +CREATE INDEX IF NOT EXISTS idx_route_created ON designdb.routes(created_on); + +CREATE INDEX IF NOT EXISTS idx_reaction_product_compound_id ON designdb.reactions(product_compound_id); +CREATE INDEX IF NOT EXISTS idx_reaction_created ON designdb.reactions(created_on); + +CREATE INDEX IF NOT EXISTS idx_pose_compound_id ON designdb.poses(compound_id); +CREATE INDEX IF NOT EXISTS idx_pose_target_id ON designdb.poses(target_id); +CREATE INDEX IF NOT EXISTS idx_protein_link ON designdb.poses(protein_link); +CREATE INDEX IF NOT EXISTS idx_pose_created ON designdb.poses(created_on); + +CREATE INDEX IF NOT EXISTS idx_score_values_pose_id ON designdb.score_values(pose_id); +CREATE INDEX IF NOT EXISTS idx_score_values_compound_id ON designdb.score_values(compound_id); +CREATE INDEX IF NOT EXISTS idx_score_values_scoring_method_id ON designdb.score_values(scoring_method_id); +CREATE INDEX IF NOT EXISTS idx_score_values_created ON designdb.score_values(created_on); + +CREATE INDEX IF NOT EXISTS idx_subsite_target_id ON designdb.subsites(target_id); +CREATE INDEX IF NOT EXISTS idx_subsite_created ON designdb.subsites(created_on); + +CREATE INDEX IF NOT EXISTS idx_component_route_id ON designdb.components(route_id); +CREATE INDEX IF NOT EXISTS idx_component_created ON designdb.components(created_on); + +CREATE INDEX IF NOT EXISTS idx_inspiration_original_pose_id ON designdb.inspirations(original_pose_id); +CREATE INDEX IF NOT EXISTS idx_inspiration_derivative_pose_id ON designdb.inspirations(derivative_pose_id); +CREATE INDEX IF NOT EXISTS idx_inspiration_created ON designdb.inspirations(created_on); + +CREATE INDEX IF NOT EXISTS idx_interaction_feature_id ON designdb.interactions(feature_id); +CREATE INDEX IF NOT EXISTS idx_interaction_pose_id ON designdb.interactions(pose_id); +CREATE INDEX IF NOT EXISTS idx_interaction_created ON designdb.interactions(created_on); + +-- UNIQUE(catalogue_smiles) supplies btree on catalogue_smiles; btree on catalogue_inchikey (non-unique) and catalogue_hash for lookups +CREATE INDEX IF NOT EXISTS idx_catalogue_compounds_created ON designdb.catalogue_compounds(created_on); +CREATE INDEX IF NOT EXISTS idx_catalogue_compounds_inchikey ON designdb.catalogue_compounds(catalogue_inchikey); +CREATE INDEX IF NOT EXISTS idx_catalogue_compounds_hash ON designdb.catalogue_compounds(catalogue_hash); + +CREATE INDEX IF NOT EXISTS idx_catalogue_prices_catalogue_id ON designdb.catalogue_prices(catalogue_id); +CREATE INDEX IF NOT EXISTS idx_catalogue_prices_created ON designdb.catalogue_prices(created_on); + +CREATE INDEX IF NOT EXISTS idx_compound_catalogue_map_match_hash ON designdb.compound_catalogue_map(match_hash); +CREATE INDEX IF NOT EXISTS idx_compound_catalogue_map_catalogue_price_id ON designdb.compound_catalogue_map(catalogue_price_id); +CREATE INDEX IF NOT EXISTS idx_compound_catalogue_map_created ON designdb.compound_catalogue_map(created_on); + +-- ========================================================= +-- AUDIT INDEXES +-- ========================================================= + +CREATE INDEX IF NOT EXISTS idx_catalogue_compounds_event_audit_id ON designdb.catalogue_compounds_event_audit(id); +CREATE INDEX IF NOT EXISTS idx_catalogue_compounds_event_audit_operation ON designdb.catalogue_compounds_event_audit(operation); +CREATE INDEX IF NOT EXISTS idx_catalogue_compounds_event_audit_changed_at ON designdb.catalogue_compounds_event_audit(changed_at); +CREATE INDEX IF NOT EXISTS idx_catalogue_compounds_event_audit_changed_by ON designdb.catalogue_compounds_event_audit(changed_by); +CREATE INDEX IF NOT EXISTS idx_catalogue_compounds_event_audit_old_gin ON designdb.catalogue_compounds_event_audit USING GIN (old_values); +CREATE INDEX IF NOT EXISTS idx_catalogue_compounds_event_audit_new_gin ON designdb.catalogue_compounds_event_audit USING GIN (new_values); +CREATE INDEX IF NOT EXISTS idx_catalogue_compounds_event_audit_id_changed ON designdb.catalogue_compounds_event_audit(id, changed_at DESC); + +CREATE INDEX IF NOT EXISTS idx_catalogue_prices_event_audit_id ON designdb.catalogue_prices_event_audit(id); +CREATE INDEX IF NOT EXISTS idx_catalogue_prices_event_audit_operation ON designdb.catalogue_prices_event_audit(operation); +CREATE INDEX IF NOT EXISTS idx_catalogue_prices_event_audit_changed_at ON designdb.catalogue_prices_event_audit(changed_at); +CREATE INDEX IF NOT EXISTS idx_catalogue_prices_event_audit_changed_by ON designdb.catalogue_prices_event_audit(changed_by); +CREATE INDEX IF NOT EXISTS idx_catalogue_prices_event_audit_old_gin ON designdb.catalogue_prices_event_audit USING GIN (old_values); +CREATE INDEX IF NOT EXISTS idx_catalogue_prices_event_audit_new_gin ON designdb.catalogue_prices_event_audit USING GIN (new_values); +CREATE INDEX IF NOT EXISTS idx_catalogue_prices_event_audit_id_changed ON designdb.catalogue_prices_event_audit(id, changed_at DESC); + +CREATE INDEX IF NOT EXISTS idx_pose_tags_event_audit_id ON designdb.pose_tags_event_audit(id); +CREATE INDEX IF NOT EXISTS idx_pose_tags_event_audit_operation ON designdb.pose_tags_event_audit(operation); +CREATE INDEX IF NOT EXISTS idx_pose_tags_event_audit_changed_at ON designdb.pose_tags_event_audit(changed_at); +CREATE INDEX IF NOT EXISTS idx_pose_tags_event_audit_old_gin ON designdb.pose_tags_event_audit USING GIN (old_values); +CREATE INDEX IF NOT EXISTS idx_pose_tags_event_audit_new_gin ON designdb.pose_tags_event_audit USING GIN (new_values); +CREATE INDEX IF NOT EXISTS idx_pose_tags_event_audit_id_changed ON designdb.pose_tags_event_audit(id, changed_at DESC); + +CREATE INDEX IF NOT EXISTS idx_compound_tags_event_audit_id ON designdb.compound_tags_event_audit(id); +CREATE INDEX IF NOT EXISTS idx_compound_tags_event_audit_operation ON designdb.compound_tags_event_audit(operation); +CREATE INDEX IF NOT EXISTS idx_compound_tags_event_audit_changed_at ON designdb.compound_tags_event_audit(changed_at); +CREATE INDEX IF NOT EXISTS idx_compound_tags_event_audit_old_gin ON designdb.compound_tags_event_audit USING GIN (old_values); +CREATE INDEX IF NOT EXISTS idx_compound_tags_event_audit_new_gin ON designdb.compound_tags_event_audit USING GIN (new_values); +CREATE INDEX IF NOT EXISTS idx_compound_tags_event_audit_id_changed ON designdb.compound_tags_event_audit(id, changed_at DESC); + +CREATE INDEX IF NOT EXISTS idx_pose_methods_event_audit_id ON designdb.pose_methods_event_audit(id); +CREATE INDEX IF NOT EXISTS idx_pose_methods_event_audit_operation ON designdb.pose_methods_event_audit(operation); +CREATE INDEX IF NOT EXISTS idx_pose_methods_event_audit_changed_at ON designdb.pose_methods_event_audit(changed_at); +CREATE INDEX IF NOT EXISTS idx_pose_methods_event_audit_old_gin ON designdb.pose_methods_event_audit USING GIN (old_values); +CREATE INDEX IF NOT EXISTS idx_pose_methods_event_audit_new_gin ON designdb.pose_methods_event_audit USING GIN (new_values); +CREATE INDEX IF NOT EXISTS idx_pose_methods_event_audit_id_changed ON designdb.pose_methods_event_audit(id, changed_at DESC); + +CREATE INDEX IF NOT EXISTS idx_enumeration_methods_event_audit_id ON designdb.enumeration_methods_event_audit(id); +CREATE INDEX IF NOT EXISTS idx_enumeration_methods_event_audit_operation ON designdb.enumeration_methods_event_audit(operation); +CREATE INDEX IF NOT EXISTS idx_enumeration_methods_event_audit_changed_at ON designdb.enumeration_methods_event_audit(changed_at); +CREATE INDEX IF NOT EXISTS idx_enumeration_methods_event_audit_old_gin ON designdb.enumeration_methods_event_audit USING GIN (old_values); +CREATE INDEX IF NOT EXISTS idx_enumeration_methods_event_audit_new_gin ON designdb.enumeration_methods_event_audit USING GIN (new_values); +CREATE INDEX IF NOT EXISTS idx_enumeration_methods_event_audit_id_changed ON designdb.enumeration_methods_event_audit(id, changed_at DESC); + +CREATE INDEX IF NOT EXISTS idx_scoring_methods_event_audit_id ON designdb.scoring_methods_event_audit(id); +CREATE INDEX IF NOT EXISTS idx_scoring_methods_event_audit_operation ON designdb.scoring_methods_event_audit(operation); +CREATE INDEX IF NOT EXISTS idx_scoring_methods_event_audit_changed_at ON designdb.scoring_methods_event_audit(changed_at); +CREATE INDEX IF NOT EXISTS idx_scoring_methods_event_audit_old_gin ON designdb.scoring_methods_event_audit USING GIN (old_values); +CREATE INDEX IF NOT EXISTS idx_scoring_methods_event_audit_new_gin ON designdb.scoring_methods_event_audit USING GIN (new_values); +CREATE INDEX IF NOT EXISTS idx_scoring_methods_event_audit_id_changed ON designdb.scoring_methods_event_audit(id, changed_at DESC); + +CREATE INDEX IF NOT EXISTS idx_reactant_reaction_id ON designdb.reactants(reaction_id); +CREATE INDEX IF NOT EXISTS idx_reactant_compound_id ON designdb.reactants(compound_id); +CREATE INDEX IF NOT EXISTS idx_reactant_created ON designdb.reactants(created_on); + +CREATE INDEX IF NOT EXISTS idx_scaffold_base_compound_id ON designdb.scaffolds(base_compound_id); +CREATE INDEX IF NOT EXISTS idx_scaffold_superstructure_compound_id ON designdb.scaffolds(superstructure_compound_id); +CREATE INDEX IF NOT EXISTS idx_scaffold_created ON designdb.scaffolds(created_on); + +CREATE INDEX IF NOT EXISTS idx_subsite_tag_subsite_id ON designdb.subsite_tags(subsite_id); +CREATE INDEX IF NOT EXISTS idx_subsite_tag_pose_id ON designdb.subsite_tags(pose_id); +CREATE INDEX IF NOT EXISTS idx_subsite_tag_created ON designdb.subsite_tags(created_on); + +-- Removed due to replaced tables +-- CREATE INDEX IF NOT EXISTS idx_tag_compound ON designdb.tags(tag_compound); +-- CREATE INDEX IF NOT EXISTS idx_tag_pose ON designdb.tags(tag_pose); +-- CREATE INDEX IF NOT EXISTS idx_tag_created ON designdb.tags(created_on); + +CREATE INDEX IF NOT EXISTS idx_pose_tag_created ON designdb.pose_tags(created_on); +CREATE INDEX IF NOT EXISTS idx_compound_tag_created ON designdb.compound_tags(created_on); + +CREATE INDEX IF NOT EXISTS idx_has_pose_methods_pose_method_id ON designdb.has_pose_methods(pose_method_id); +CREATE INDEX IF NOT EXISTS idx_has_pose_methods_created ON designdb.has_pose_methods(created_on); + +CREATE INDEX IF NOT EXISTS idx_has_pose_tag_pose_tag_id ON designdb.has_pose_tags(pose_tag_id); +CREATE INDEX IF NOT EXISTS idx_has_pose_tag_created ON designdb.has_pose_tags(created_on); + +CREATE INDEX IF NOT EXISTS idx_has_enumeration_methods_enumeration_method_id ON designdb.has_enumeration_methods(enumeration_method_id); +CREATE INDEX IF NOT EXISTS idx_has_enumeration_methods_created ON designdb.has_enumeration_methods(created_on); + +CREATE INDEX IF NOT EXISTS idx_has_compound_tag_compound_tag_id ON designdb.has_compound_tags(compound_tag_id); +CREATE INDEX IF NOT EXISTS idx_has_compound_tag_created ON designdb.has_compound_tags(created_on); + +-- ========================================================= +-- MATERIALIZED VIEWS +-- ========================================================= +-- designdb.scores_per_pose_pivoted_mv: pose_id, compound_id + one column per (method_name, method_version). +-- Pivoted from score_values joined with scoring_methods. Dynamically re-generated when new method added. + +-- ========================================================= +-- VIEWS +-- ========================================================= + +-- Price-line UPDATEs from catalogue_prices_event_audit (JSON keys match catalogue_prices column names) +CREATE OR REPLACE VIEW designdb.catalogue_prices_price_changes_v AS +SELECT + a.id AS catalogue_price_id, + (NULLIF(COALESCE(a.new_values->>'catalogue_id', a.old_values->>'catalogue_id'), ''))::BIGINT AS catalogue_id, + COALESCE(a.new_values->>'vendor', a.old_values->>'vendor') AS vendor, + COALESCE(a.new_values->>'supplier', a.old_values->>'supplier') AS supplier, + COALESCE(a.new_values->>'supplier_id', a.old_values->>'supplier_id') AS supplier_id, + (NULLIF(COALESCE(a.new_values->>'amount', a.old_values->>'amount'), ''))::DOUBLE PRECISION AS amount, + (NULLIF(a.old_values->>'price', ''))::DOUBLE PRECISION AS price_old, + (NULLIF(a.new_values->>'price', ''))::DOUBLE PRECISION AS price_new, + COALESCE(a.new_values->>'currency', a.old_values->>'currency') AS currency, + (NULLIF(COALESCE(a.new_values->>'purity', a.old_values->>'purity'), ''))::DOUBLE PRECISION AS purity, + (NULLIF(COALESCE(a.new_values->>'lead_time', a.old_values->>'lead_time'), ''))::INTEGER AS lead_time, + a.changed_at +FROM designdb.catalogue_prices_event_audit a +WHERE a.operation = 'U'; + +-- Pose tags: UPDATE events with old/new name, description, note. +CREATE OR REPLACE VIEW designdb.pose_tags_changes_v AS +SELECT + a.id AS pose_tag_id, + a.old_values->>'pose_tag_name' AS pose_tag_name_old, + a.new_values->>'pose_tag_name' AS pose_tag_name_new, + a.old_values->>'pose_tag_description' AS pose_tag_description_old, + a.new_values->>'pose_tag_description' AS pose_tag_description_new, + a.old_values->>'pose_tag_note' AS pose_tag_note_old, + a.new_values->>'pose_tag_note' AS pose_tag_note_new, + a.changed_by, + a.changed_at +FROM designdb.pose_tags_event_audit a +WHERE a.operation = 'U'; + +-- Compound tags: UPDATE events with old/new name, description, note. +CREATE OR REPLACE VIEW designdb.compound_tags_changes_v AS +SELECT + a.id AS compound_tag_id, + a.old_values->>'compound_tag_name' AS compound_tag_name_old, + a.new_values->>'compound_tag_name' AS compound_tag_name_new, + a.old_values->>'compound_tag_description' AS compound_tag_description_old, + a.new_values->>'compound_tag_description' AS compound_tag_description_new, + a.old_values->>'compound_tag_note' AS compound_tag_note_old, + a.new_values->>'compound_tag_note' AS compound_tag_note_new, + a.changed_by, + a.changed_at +FROM designdb.compound_tags_event_audit a +WHERE a.operation = 'U'; + +-- Pose methods: UPDATE events with old/new name, description, version, etc. +CREATE OR REPLACE VIEW designdb.pose_methods_changes_v AS +SELECT + a.id AS pose_method_id, + a.old_values->>'pose_method_name' AS pose_method_name_old, + a.new_values->>'pose_method_name' AS pose_method_name_new, + a.old_values->>'pose_method_description' AS pose_method_description_old, + a.new_values->>'pose_method_description' AS pose_method_description_new, + a.old_values->>'pose_method_version' AS pose_method_version_old, + a.new_values->>'pose_method_version' AS pose_method_version_new, + a.changed_by, + a.changed_at +FROM designdb.pose_methods_event_audit a +WHERE a.operation = 'U'; + +-- Enumeration methods: UPDATE events with old/new name, description, version, etc. +CREATE OR REPLACE VIEW designdb.enumeration_methods_changes_v AS +SELECT + a.id AS enumeration_method_id, + a.old_values->>'enum_name' AS enum_name_old, + a.new_values->>'enum_name' AS enum_name_new, + a.old_values->>'enum_description' AS enum_description_old, + a.new_values->>'enum_description' AS enum_description_new, + a.old_values->>'enum_version' AS enum_version_old, + a.new_values->>'enum_version' AS enum_version_new, + a.changed_by, + a.changed_at +FROM designdb.enumeration_methods_event_audit a +WHERE a.operation = 'U'; + +-- Scoring methods: UPDATE events with old/new name, description, version, etc. +CREATE OR REPLACE VIEW designdb.scoring_methods_changes_v AS +SELECT + a.id AS scoring_method_id, + a.old_values->>'method_name' AS method_name_old, + a.new_values->>'method_name' AS method_name_new, + a.old_values->>'method_description' AS method_description_old, + a.new_values->>'method_description' AS method_description_new, + a.old_values->>'method_version' AS method_version_old, + a.new_values->>'method_version' AS method_version_new, + a.changed_by, + a.changed_at +FROM designdb.scoring_methods_event_audit a +WHERE a.operation = 'U'; + +-- ========================================================= +-- FUNCTIONS +-- ========================================================= + +-- ========================================================= +-- RDKIT CARTRIDGE – COMPOUND WRAPPERS +-- ========================================================= +-- Schema-qualified wrappers for RDKit mol_from_smiles, mol_to_smiles, mol_inchikey, mol_to_ctab (compound, pose, catalogue_compounds triggers). + +CREATE OR REPLACE FUNCTION designdb.mol_from_smiles(smiles TEXT) RETURNS rdkit.mol + LANGUAGE SQL AS $$ SELECT rdkit.mol_from_smiles(smiles::cstring); $$; + +CREATE OR REPLACE FUNCTION designdb.mol_to_smiles(m rdkit.mol) RETURNS text + LANGUAGE SQL AS $$ SELECT rdkit.mol_to_smiles(m); $$; + +CREATE OR REPLACE FUNCTION designdb.mol_to_inchikey(m rdkit.mol) RETURNS text + LANGUAGE SQL AS $$ SELECT rdkit.mol_inchikey(m); $$; + +CREATE OR REPLACE FUNCTION designdb.mol_to_ctab(m rdkit.mol) RETURNS text + LANGUAGE SQL AS $$ SELECT rdkit.mol_to_ctab(m); $$; + +-- ========================================================= +-- RDKIT CARTRIDGE – COMPOUND TRIGGER +-- ========================================================= +-- Input: compound_smiles (inserted by application). Populates compound_mol (CTAB) and compound_inchikey. + +CREATE OR REPLACE FUNCTION designdb.populate_compound_cartridge_from_smiles() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +DECLARE + v_mol rdkit.mol; +BEGIN + IF NEW.compound_smiles IS NOT NULL THEN + BEGIN + v_mol := designdb.mol_from_smiles(NEW.compound_smiles); + IF v_mol IS NOT NULL THEN + -- NEW.compound_mol := v_mol; -- store rdkit.mol (was default text form ~ SMILES over JDBC) + NEW.compound_mol := designdb.mol_to_ctab(v_mol); + NEW.compound_inchikey := designdb.mol_to_inchikey(v_mol); + END IF; + EXCEPTION WHEN OTHERS THEN + NULL; + END; + END IF; + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_populate_compound_cartridge_from_smiles ON designdb.compounds; +CREATE TRIGGER trg_populate_compound_cartridge_from_smiles + BEFORE INSERT OR UPDATE OF compound_smiles ON designdb.compounds + FOR EACH ROW + EXECUTE FUNCTION designdb.populate_compound_cartridge_from_smiles(); + +-- ========================================================= +-- RDKIT CARTRIDGE – CATALOGUE_COMPOUNDS TRIGGER +-- ========================================================= +-- Input: catalogue_smiles (inserted by application). Populates catalogue_inchikey (NOT NULL column). + +CREATE OR REPLACE FUNCTION designdb.populate_catalogue_cartridge_from_smiles() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +DECLARE + v_mol rdkit.mol; + v_ik TEXT; +BEGIN + IF NEW.catalogue_smiles IS NULL OR btrim(NEW.catalogue_smiles) = '' THEN + RAISE EXCEPTION 'designdb.catalogue_compounds: catalogue_smiles is required'; + END IF; + v_mol := designdb.mol_from_smiles(NEW.catalogue_smiles); + IF v_mol IS NULL THEN + RAISE EXCEPTION 'designdb.catalogue_compounds: mol_from_smiles returned NULL for catalogue_smiles'; + END IF; + v_ik := designdb.mol_to_inchikey(v_mol); + IF v_ik IS NULL OR btrim(v_ik) = '' THEN + RAISE EXCEPTION 'designdb.catalogue_compounds: mol_to_inchikey returned empty for catalogue_smiles'; + END IF; + NEW.catalogue_inchikey := v_ik; + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_populate_catalogue_cartridge_from_smiles ON designdb.catalogue_compounds; +CREATE TRIGGER trg_populate_catalogue_cartridge_from_smiles + BEFORE INSERT OR UPDATE OF catalogue_smiles ON designdb.catalogue_compounds + FOR EACH ROW + EXECUTE FUNCTION designdb.populate_catalogue_cartridge_from_smiles(); + +-- ========================================================= +-- RDKIT CARTRIDGE – POSE TRIGGER +-- ========================================================= +-- Input: pose_mol (inserted by application). Populates pose_inchikey and pose_smiles. + +CREATE OR REPLACE FUNCTION designdb.populate_pose_cartridge_from_mol() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF NEW.pose_mol IS NOT NULL THEN + BEGIN + NEW.pose_smiles := designdb.mol_to_smiles(NEW.pose_mol); + NEW.pose_inchikey := designdb.mol_to_inchikey(NEW.pose_mol); + EXCEPTION WHEN OTHERS THEN + NULL; + END; + END IF; + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_populate_pose_cartridge_from_mol ON designdb.poses; +CREATE TRIGGER trg_populate_pose_cartridge_from_mol + BEFORE INSERT OR UPDATE OF pose_mol ON designdb.poses + FOR EACH ROW + EXECUTE FUNCTION designdb.populate_pose_cartridge_from_mol(); + +-- ========================================================= +-- SCORE_VALUES – ENFORCE compound_id MATCHES pose's compound_id +-- ========================================================= +CREATE OR REPLACE FUNCTION designdb.check_score_values_compound_matches_pose() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +DECLARE + v_pose_compound_id BIGINT; +BEGIN + SELECT compound_id INTO v_pose_compound_id + FROM designdb.poses + WHERE id = NEW.pose_id; + IF v_pose_compound_id IS NULL THEN + RAISE EXCEPTION 'pose_id % does not exist', NEW.pose_id; + END IF; + IF NEW.compound_id IS DISTINCT FROM v_pose_compound_id THEN + RAISE EXCEPTION 'score_values.compound_id (%) must match poses.compound_id (%) for pose_id %', + NEW.compound_id, v_pose_compound_id, NEW.pose_id; + END IF; + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_check_score_values_compound_matches_pose ON designdb.score_values; +CREATE TRIGGER trg_check_score_values_compound_matches_pose + BEFORE INSERT OR UPDATE OF pose_id, compound_id ON designdb.score_values + FOR EACH ROW + EXECUTE FUNCTION designdb.check_score_values_compound_matches_pose(); + +-- ========================================================= +-- (Re)creates materialized view designdb.scores_per_pose_pivoted_mv with columns from scoring_method. +-- Columns: pose_id, compound_id, then one JSONB column per (method_name, method_version). +-- Column names use suffix _m{scoring_method_id} to avoid collisions (e.g. vina_1_0_m1). +-- Value in column: if score is numeric then JSONB number, if text then JSONB string. +CREATE OR REPLACE FUNCTION designdb.create_scores_per_pose_pivoted_mv() +RETURNS void +LANGUAGE plpgsql +AS $$ +DECLARE + select_qry text; + col text; + method_rec record; + score_txt text; + value_expr text; + numeric_pat text := '^\-?[0-9]*\.?[0-9]+([eE][\-+]?[0-9]+)?$'; +BEGIN + select_qry := 'SELECT sv.pose_id, sv.compound_id'; + FOR method_rec IN + SELECT m.id, m.method_name, m.method_version + FROM designdb.scoring_methods m + ORDER BY m.id + LOOP + col := regexp_replace( + trim(method_rec.method_name) || '_' || coalesce( + replace(replace(trim(coalesce(method_rec.method_version, '')), ' ', '_'), '.', '_'), + '' + ), + '[^a-zA-Z0-9_]', '_', 'g' + ) || '_m' || method_rec.id; + IF col <> '' AND col <> '_' THEN + col := quote_ident(col); + score_txt := '(sv.score->>' || quote_literal('score') || ')'; + value_expr := '(CASE WHEN ' || score_txt || ' IS NOT NULL AND ' || score_txt || ' ~ ' || quote_literal(numeric_pat) + || ' THEN to_jsonb((' || score_txt || ')::numeric) ELSE to_jsonb(' || score_txt || ') END)'; + select_qry := select_qry || ', (array_agg(' || value_expr + || ') FILTER (WHERE sv.scoring_method_id = ' || method_rec.id || '))[1] AS ' || col; + END IF; + END LOOP; + select_qry := select_qry || ' FROM designdb.score_values sv GROUP BY sv.pose_id, sv.compound_id'; + EXECUTE 'DROP MATERIALIZED VIEW IF EXISTS designdb.scores_per_pose_pivoted_mv CASCADE'; + EXECUTE 'CREATE MATERIALIZED VIEW designdb.scores_per_pose_pivoted_mv AS ' || select_qry; + EXECUTE 'CREATE UNIQUE INDEX ON designdb.scores_per_pose_pivoted_mv (pose_id, compound_id)'; +END; +$$; + +CREATE OR REPLACE FUNCTION designdb.trg_recreate_scores_pivoted_mv() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + PERFORM designdb.create_scores_per_pose_pivoted_mv(); + RETURN NULL; +END; +$$; + +CREATE OR REPLACE FUNCTION designdb.refresh_scores_per_pose_pivoted_mv() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + REFRESH MATERIALIZED VIEW CONCURRENTLY designdb.scores_per_pose_pivoted_mv; + RETURN NULL; +END; +$$; + +CREATE OR REPLACE FUNCTION designdb.update_updated_on() +RETURNS trigger AS $$ +BEGIN + NEW.updated_on = now(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- ========================================================= +-- Populate compound_catalogue_map when compounds and/or catalogue prices exist for the same registration hash. +CREATE OR REPLACE FUNCTION designdb.trg_compound_catalogue_map_from_compound() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF TG_OP = 'UPDATE' THEN + IF OLD.compound_hash IS NOT DISTINCT FROM NEW.compound_hash THEN + RETURN NEW; + END IF; + DELETE FROM designdb.compound_catalogue_map WHERE compound_id = NEW.id; + END IF; + + INSERT INTO designdb.compound_catalogue_map (compound_id, catalogue_price_id, match_hash) + SELECT NEW.id, cp.id, NEW.compound_hash + FROM designdb.catalogue_prices cp + JOIN designdb.catalogue_compounds cat ON cat.id = cp.catalogue_id + WHERE cat.catalogue_hash = NEW.compound_hash + ON CONFLICT (compound_id, catalogue_price_id) DO NOTHING; + + RETURN NEW; +END; +$$; + +-- When catalogue_hash changes on catalogue_compounds: refresh map rows for all price lines under that compound row. +CREATE OR REPLACE FUNCTION designdb.trg_compound_catalogue_map_from_catalogue() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF TG_OP = 'UPDATE' THEN + IF OLD.catalogue_hash IS NOT DISTINCT FROM NEW.catalogue_hash THEN + RETURN NEW; + END IF; + DELETE FROM designdb.compound_catalogue_map + WHERE catalogue_price_id IN ( + SELECT id FROM designdb.catalogue_prices WHERE catalogue_id = NEW.id + ); + END IF; + + INSERT INTO designdb.compound_catalogue_map (compound_id, catalogue_price_id, match_hash) + SELECT c.id, cp.id, c.compound_hash + FROM designdb.compounds c + CROSS JOIN designdb.catalogue_prices cp + WHERE cp.catalogue_id = NEW.id AND c.compound_hash = NEW.catalogue_hash + ON CONFLICT (compound_id, catalogue_price_id) DO NOTHING; + + RETURN NEW; +END; +$$; + +-- When a catalogue_price row is inserted or its catalogue_id changes: link compounds by parent hash. +CREATE OR REPLACE FUNCTION designdb.trg_compound_catalogue_map_from_catalogue_price() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF TG_OP = 'UPDATE' AND OLD.catalogue_id IS NOT DISTINCT FROM NEW.catalogue_id THEN + RETURN NEW; + END IF; + IF TG_OP = 'UPDATE' THEN + DELETE FROM designdb.compound_catalogue_map WHERE catalogue_price_id = NEW.id; + END IF; + + INSERT INTO designdb.compound_catalogue_map (compound_id, catalogue_price_id, match_hash) + SELECT c.id, NEW.id, c.compound_hash + FROM designdb.compounds c + JOIN designdb.catalogue_compounds cat ON cat.id = NEW.catalogue_id + WHERE c.compound_hash = cat.catalogue_hash + ON CONFLICT (compound_id, catalogue_price_id) DO NOTHING; + + RETURN NEW; +END; +$$; + + +-- ========================================================= +-- AUDIT FUNCTIONS +-- ========================================================= + +-- Event audit trigger: records INSERT/UPDATE/DELETE to an audit table with JSONB old/new values. +-- TG_ARGV[0]=audit_table, [1]=pk_column, [2]=excluded_columns (comma-sep), [3]=binary_columns (comma-sep, hashed as sha256:hex). +CREATE OR REPLACE FUNCTION designdb.event_audit_trigger() +RETURNS trigger AS +$$ +DECLARE + v_audit_table TEXT := TG_ARGV[0]; + v_pk_col TEXT := TG_ARGV[1]; + v_excluded TEXT := COALESCE(TG_ARGV[2], ''); + v_bincols TEXT := COALESCE(TG_ARGV[3], ''); + v_excluded_arr TEXT[]; + v_bincols_arr TEXT[]; + v_changed_by TEXT := COALESCE(current_setting('app.current_user', true), current_user); + v_old_json JSONB; + v_new_json JSONB; + v_record_pk TEXT; + v_bin_hash TEXT; + v_col TEXT; +BEGIN + IF v_excluded = '' THEN + v_excluded_arr := ARRAY[]::text[]; + ELSE + v_excluded_arr := ARRAY(SELECT trim(x) FROM regexp_split_to_table(v_excluded, ',') AS x); + END IF; + + IF v_bincols = '' THEN + v_bincols_arr := ARRAY[]::text[]; + ELSE + v_bincols_arr := ARRAY(SELECT trim(x) FROM regexp_split_to_table(v_bincols, ',') AS x); + END IF; + + IF TG_OP = 'INSERT' THEN + v_old_json := NULL; + v_new_json := to_jsonb(NEW); + ELSIF TG_OP = 'DELETE' THEN + v_old_json := to_jsonb(OLD); + v_new_json := NULL; + ELSE + IF to_jsonb(OLD) IS NOT DISTINCT FROM to_jsonb(NEW) THEN + RETURN NEW; + END IF; + v_old_json := to_jsonb(OLD); + v_new_json := to_jsonb(NEW); + END IF; + + FOREACH v_col IN ARRAY v_excluded_arr LOOP + IF v_old_json IS NOT NULL THEN v_old_json := v_old_json - v_col; END IF; + IF v_new_json IS NOT NULL THEN v_new_json := v_new_json - v_col; END IF; + END LOOP; + + FOREACH v_col IN ARRAY v_bincols_arr LOOP + IF v_old_json IS NOT NULL AND v_old_json ? v_col THEN + BEGIN + EXECUTE format('SELECT CASE WHEN ($1).%I IS NULL THEN NULL ELSE encode(digest(($1).%I::bytea, ''sha256''), ''hex'') END', v_col, v_col) + USING OLD INTO v_bin_hash; + IF v_bin_hash IS NOT NULL THEN + v_old_json := jsonb_set(v_old_json, ARRAY[v_col], to_jsonb('sha256:' || v_bin_hash)); + ELSE + v_old_json := v_old_json - v_col; + END IF; + EXCEPTION WHEN others THEN + v_old_json := v_old_json - v_col; + END; + END IF; + IF v_new_json IS NOT NULL AND v_new_json ? v_col THEN + BEGIN + EXECUTE format('SELECT CASE WHEN ($1).%I IS NULL THEN NULL ELSE encode(digest(($1).%I::bytea, ''sha256''), ''hex'') END', v_col, v_col) + USING NEW INTO v_bin_hash; + IF v_bin_hash IS NOT NULL THEN + v_new_json := jsonb_set(v_new_json, ARRAY[v_col], to_jsonb('sha256:' || v_bin_hash)); + ELSE + v_new_json := v_new_json - v_col; + END IF; + EXCEPTION WHEN others THEN + v_new_json := v_new_json - v_col; + END; + END IF; + END LOOP; + + IF TG_OP = 'DELETE' THEN + EXECUTE format('SELECT ($1).%I::text', v_pk_col) USING OLD INTO v_record_pk; + ELSE + EXECUTE format('SELECT ($1).%I::text', v_pk_col) USING NEW INTO v_record_pk; + END IF; + + EXECUTE format('INSERT INTO %s (%s, operation, old_values, new_values, changed_by, changed_at) VALUES ($1,$2,$3,$4,$5,NOW())', v_audit_table, v_pk_col) + USING v_record_pk::BIGINT, TG_OP::CHAR, v_old_json, v_new_json, v_changed_by; + + RETURN COALESCE(NEW, OLD); +END; +$$ LANGUAGE plpgsql VOLATILE; + +-- ========================================================= +-- TRIGGERS (updated_on) +-- ========================================================= + +DROP TRIGGER IF EXISTS trg_target_updated_on ON designdb.targets; +CREATE TRIGGER trg_target_updated_on BEFORE UPDATE ON designdb.targets FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_scoring_method_updated_on ON designdb.scoring_methods; +CREATE TRIGGER trg_scoring_method_updated_on BEFORE UPDATE ON designdb.scoring_methods FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_scoring_method_recreate_pivoted_mv ON designdb.scoring_methods; +CREATE TRIGGER trg_scoring_method_recreate_pivoted_mv + AFTER INSERT OR UPDATE OR DELETE ON designdb.scoring_methods + FOR EACH STATEMENT EXECUTE FUNCTION designdb.trg_recreate_scores_pivoted_mv(); + +DROP TRIGGER IF EXISTS trg_enumeration_method_updated_on ON designdb.enumeration_methods; +CREATE TRIGGER trg_enumeration_method_updated_on BEFORE UPDATE ON designdb.enumeration_methods FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_pose_method_updated_on ON designdb.pose_methods; +CREATE TRIGGER trg_pose_method_updated_on BEFORE UPDATE ON designdb.pose_methods FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_compound_updated_on ON designdb.compounds; +CREATE TRIGGER trg_compound_updated_on BEFORE UPDATE ON designdb.compounds FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_compound_catalogue_map_sync_compound ON designdb.compounds; +CREATE TRIGGER trg_compound_catalogue_map_sync_compound + AFTER INSERT OR UPDATE OF compound_hash ON designdb.compounds + FOR EACH ROW + EXECUTE FUNCTION designdb.trg_compound_catalogue_map_from_compound(); + +DROP TRIGGER IF EXISTS trg_feature_updated_on ON designdb.features; +CREATE TRIGGER trg_feature_updated_on BEFORE UPDATE ON designdb.features FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_route_updated_on ON designdb.routes; +CREATE TRIGGER trg_route_updated_on BEFORE UPDATE ON designdb.routes FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_reaction_updated_on ON designdb.reactions; +CREATE TRIGGER trg_reaction_updated_on BEFORE UPDATE ON designdb.reactions FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_pose_updated_on ON designdb.poses; +CREATE TRIGGER trg_pose_updated_on BEFORE UPDATE ON designdb.poses FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_score_values_updated_on ON designdb.score_values; +CREATE TRIGGER trg_score_values_updated_on BEFORE UPDATE ON designdb.score_values FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_score_values_refresh_pivoted_mv ON designdb.score_values; +CREATE TRIGGER trg_score_values_refresh_pivoted_mv + AFTER INSERT OR UPDATE OR DELETE ON designdb.score_values + FOR EACH STATEMENT EXECUTE FUNCTION designdb.refresh_scores_per_pose_pivoted_mv(); + +DROP TRIGGER IF EXISTS trg_subsite_updated_on ON designdb.subsites; +CREATE TRIGGER trg_subsite_updated_on BEFORE UPDATE ON designdb.subsites FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_component_updated_on ON designdb.components; +CREATE TRIGGER trg_component_updated_on BEFORE UPDATE ON designdb.components FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_inspiration_updated_on ON designdb.inspirations; +CREATE TRIGGER trg_inspiration_updated_on BEFORE UPDATE ON designdb.inspirations FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_interaction_updated_on ON designdb.interactions; +CREATE TRIGGER trg_interaction_updated_on BEFORE UPDATE ON designdb.interactions FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_catalogue_updated_on ON designdb.catalogue_compounds; +CREATE TRIGGER trg_catalogue_updated_on BEFORE UPDATE ON designdb.catalogue_compounds FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_catalogue_price_updated_on ON designdb.catalogue_prices; +CREATE TRIGGER trg_catalogue_price_updated_on BEFORE UPDATE ON designdb.catalogue_prices FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_compound_catalogue_map_sync_catalogue ON designdb.catalogue_compounds; +CREATE TRIGGER trg_compound_catalogue_map_sync_catalogue + AFTER INSERT OR UPDATE OF catalogue_hash ON designdb.catalogue_compounds + FOR EACH ROW + EXECUTE FUNCTION designdb.trg_compound_catalogue_map_from_catalogue(); + +DROP TRIGGER IF EXISTS trg_compound_catalogue_map_sync_catalogue_price ON designdb.catalogue_prices; +CREATE TRIGGER trg_compound_catalogue_map_sync_catalogue_price + AFTER INSERT OR UPDATE OF catalogue_id ON designdb.catalogue_prices + FOR EACH ROW + EXECUTE FUNCTION designdb.trg_compound_catalogue_map_from_catalogue_price(); + +DROP TRIGGER IF EXISTS trg_compound_catalogue_map_updated_on ON designdb.compound_catalogue_map; +CREATE TRIGGER trg_compound_catalogue_map_updated_on BEFORE UPDATE ON designdb.compound_catalogue_map FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +-- ========================================================= +-- AUDIT TRIGGERS +-- ========================================================= + +DROP TRIGGER IF EXISTS trg_catalogue_compounds_event_audit ON designdb.catalogue_compounds; +CREATE TRIGGER trg_catalogue_compounds_event_audit + AFTER INSERT OR UPDATE OR DELETE ON designdb.catalogue_compounds + FOR EACH ROW + EXECUTE FUNCTION designdb.event_audit_trigger( + 'designdb.catalogue_compounds_event_audit', + 'id', + 'created_on,updated_on', + '' + ); + +DROP TRIGGER IF EXISTS trg_catalogue_prices_event_audit ON designdb.catalogue_prices; +CREATE TRIGGER trg_catalogue_prices_event_audit + AFTER INSERT OR UPDATE OR DELETE ON designdb.catalogue_prices + FOR EACH ROW + EXECUTE FUNCTION designdb.event_audit_trigger( + 'designdb.catalogue_prices_event_audit', + 'id', + 'created_on,updated_on', + '' + ); + +DROP TRIGGER IF EXISTS trg_pose_tags_event_audit ON designdb.pose_tags; +CREATE TRIGGER trg_pose_tags_event_audit + AFTER INSERT OR UPDATE OR DELETE ON designdb.pose_tags + FOR EACH ROW + EXECUTE FUNCTION designdb.event_audit_trigger( + 'designdb.pose_tags_event_audit', + 'id', + 'created_on,updated_on', + '' + ); + +DROP TRIGGER IF EXISTS trg_compound_tags_event_audit ON designdb.compound_tags; +CREATE TRIGGER trg_compound_tags_event_audit + AFTER INSERT OR UPDATE OR DELETE ON designdb.compound_tags + FOR EACH ROW + EXECUTE FUNCTION designdb.event_audit_trigger( + 'designdb.compound_tags_event_audit', + 'id', + 'created_on,updated_on', + '' + ); + +DROP TRIGGER IF EXISTS trg_pose_methods_event_audit ON designdb.pose_methods; +CREATE TRIGGER trg_pose_methods_event_audit + AFTER INSERT OR UPDATE OR DELETE ON designdb.pose_methods + FOR EACH ROW + EXECUTE FUNCTION designdb.event_audit_trigger( + 'designdb.pose_methods_event_audit', + 'id', + 'created_on,updated_on', + '' + ); + +DROP TRIGGER IF EXISTS trg_enumeration_methods_event_audit ON designdb.enumeration_methods; +CREATE TRIGGER trg_enumeration_methods_event_audit + AFTER INSERT OR UPDATE OR DELETE ON designdb.enumeration_methods + FOR EACH ROW + EXECUTE FUNCTION designdb.event_audit_trigger( + 'designdb.enumeration_methods_event_audit', + 'id', + 'created_on,updated_on', + '' + ); + +DROP TRIGGER IF EXISTS trg_scoring_methods_event_audit ON designdb.scoring_methods; +CREATE TRIGGER trg_scoring_methods_event_audit + AFTER INSERT OR UPDATE OR DELETE ON designdb.scoring_methods + FOR EACH ROW + EXECUTE FUNCTION designdb.event_audit_trigger( + 'designdb.scoring_methods_event_audit', + 'id', + 'created_on,updated_on', + '' + ); + +DROP TRIGGER IF EXISTS trg_reactant_updated_on ON designdb.reactants; +CREATE TRIGGER trg_reactant_updated_on BEFORE UPDATE ON designdb.reactants FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_scaffold_updated_on ON designdb.scaffolds; +CREATE TRIGGER trg_scaffold_updated_on BEFORE UPDATE ON designdb.scaffolds FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_subsite_tag_updated_on ON designdb.subsite_tags; +CREATE TRIGGER trg_subsite_tag_updated_on BEFORE UPDATE ON designdb.subsite_tags FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +-- Removed due to replaced tables +-- DROP TRIGGER IF EXISTS trg_tag_updated_on ON designdb.tags; +-- CREATE TRIGGER trg_tag_updated_on BEFORE UPDATE ON designdb.tags FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_pose_tag_updated_on ON designdb.pose_tags; +CREATE TRIGGER trg_pose_tag_updated_on BEFORE UPDATE ON designdb.pose_tags FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_compound_tag_updated_on ON designdb.compound_tags; +CREATE TRIGGER trg_compound_tag_updated_on BEFORE UPDATE ON designdb.compound_tags FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_has_pose_tag_updated_on ON designdb.has_pose_tags; +CREATE TRIGGER trg_has_pose_tag_updated_on BEFORE UPDATE ON designdb.has_pose_tags FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_has_pose_methods_updated_on ON designdb.has_pose_methods; +CREATE TRIGGER trg_has_pose_methods_updated_on BEFORE UPDATE ON designdb.has_pose_methods FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_has_compound_tag_updated_on ON designdb.has_compound_tags; +CREATE TRIGGER trg_has_compound_tag_updated_on BEFORE UPDATE ON designdb.has_compound_tags FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_has_enumeration_methods_updated_on ON designdb.has_enumeration_methods; +CREATE TRIGGER trg_has_enumeration_methods_updated_on BEFORE UPDATE ON designdb.has_enumeration_methods FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +-- Pivoted materialized view once at schema load, this will be mapped in Scarab to do the filtering based on any type of scores/methods +SELECT designdb.create_scores_per_pose_pivoted_mv(); + + +-- functions to disable and enable triggers in pose table (Lucas' later addition) +CREATE OR REPLACE FUNCTION designdb.begin_score_values_load() +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = designdb, pg_temp +AS $$ +BEGIN + EXECUTE 'ALTER TABLE designdb.score_values DISABLE TRIGGER trg_score_values_refresh_pivoted_mv'; +END; +$$; + +CREATE OR REPLACE FUNCTION designdb.end_score_values_load() +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = designdb, pg_temp +AS $$ +BEGIN + EXECUTE 'ALTER TABLE designdb.score_values ENABLE TRIGGER trg_score_values_refresh_pivoted_mv'; +  EXECUTE 'REFRESH MATERIALIZED VIEW designdb.scores_per_pose_pivoted_mv'; +END; +$$; diff --git a/pyproject.toml b/pyproject.toml index cadfc095..587439ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,15 +1,17 @@ -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" +# [build-system] +# requires = ["hatchling"] +# build-backend = "hatchling.build" [project] -name = "hippo-db" -version = "0.3.38" +name = "xchem-hippo" +version = "1.0.0" authors = [ { name = "Max Winokan", email = "max@winokan.com" }, + { name = "Kalev Takkis", email = "ktakkis@informaticsmatters.com" }, ] description = "Hit Interaction Profiling and Procurement Optimisation" readme = "README.md" -requires-python = ">=3.10" +# python version limit dictated by pandas +requires-python = ">=3.10,<3.14" requires = [] classifiers = [ "Programming Language :: Python :: 3", @@ -17,27 +19,114 @@ classifiers = [ "Operating System :: OS Independent", ] dependencies = [ - "rdkit >= 2024.9.6", - "molparse >= 0.0.41", - "mpytools >= 0.0.21", - "jupyterlab", - "chardet", - "pandas", - "yattag", - "hirsch", - "hippo_plot", - "scikit-learn", - "openpyxl", - "ipywidgets", - "networkx", - "openmm", - "apsw", - "python-louvain", -] -[project.urls] -"Homepage" = "https://hippo.winokan.com" -"Bug Tracker" = "https://github.com/mwinokan/HIPPO/issues" -[tool.hatch.build] -include = [ - "hippo/*.py", + "rdkit==2025.9.5", + "molparse>=0.0.41", + "mpytools>=0.0.21", + "jupyterlab>=4.5", + "chardet>=7", + "pandas>=2.3", + "yattag>=1.16", # html widget rendering, possibly to be removed + "hirsch>=0.1", # used? + "hippo_plot>=0.0.1", + "scikit-learn>=1.7", + "openpyxl>=3.1", + "ipywidgets>=8.1", + "networkx>=3.4", + "openmm>=8.4", + "apsw>=3.51", + "python-louvain>=0.16", + "psycopg[binary]>=3.3", + "django>=5.2.12", + "syndirella>=5.0.7a0", + "typer>=0.24.1", # used? + "neo4j>=6.1.0", + "gemmi>=0.7.5", + "mrich>=1.0", + "requests>=2.32", + "pdbfixer>=1.12.0", + # Max's hippo depends on this. cannot install with uv, comes from conda + # "chemicalite==2024.5.1", + # options: + # - this is probably present in diamond environments, + # - can use conda install in container + # - but.. probs don't even need it, it's only used for expressions and I got that covered + "django-rdkit", + "numpy>=1.26.4", + # "numpy>=2", # need it but not working +] + +[dependency-groups] +dev = [ + "pytest>=9.0.2,<10", + "ruff>=0.15.2", + "ty>=0.0.19", + "mypy>=1.19", + "commitizen>=4.13.5,<5", + "pre-commit>=4.5.1", +] + +[tool.commitizen] +name = "cz_customize" +version_provider = "scm" +version_scheme = "semver2" +annotated_tag = true +update_changelog_on_bump = true +changelog_merge_prerelease = true +prerelease_offset = 1 +pre_bump_hooks = ["./add-issue-links-to-changelog.sh"] + +[tool.commitizen.customize] +bump_pattern = '^(feat|fix|ci|build|perf|refactor|chore|remove|style|test)' +bump_map = {feat = "MINOR", fix = "PATCH", ci = "PATCH", build = "PATCH", perf = "PATCH", refactor = "PATCH", chore = "PATCH", remove = "PATCH", style = "PATCH", test = "PATCH" } +schema_pattern = "(build|bump|chore|ci|dev|docs|feat|fix|perf|refactor|remove|style|test):(\\s.*)" +commit_parser = "^(?Pbuild|bump|chore|ci|dev|docs|feat|fix|perf|refactor|remove|style|test):\\s(?P.*)?" +# By excluding 'bump', 'ci', 'dev', and 'docs' from +# 'change_type_map', 'change_type_order' and 'changelog_pattern' +# we omit the corresponding commit comments from the CHANGELOG. +# The order of types in the CHANGELOG is dictated by the type_order +change_type_map = {"feat" = "New Features", "fix" = "Bug Fixes", "perf" = "Performance Improvements", "refactor" = "Refactoring", "chore" = "General Improvements", "remove" = "Removed", "style" = "Stylistic Changes", "test" = "Testing", "build" = "Build"} +change_type_order = ["BREAKING CHANGE", "New Features", "Bug Fixes", "Performance Improvements", "Refactoring", "General Improvements", "Removed", "Stylistic Changes", "Testing", "Build"] +changelog_pattern = "^(build|chore|feat|fix|perf|refactor|remove|style|test)" + +[tool.ruff] +line-length = 88 +target-version = "py313" +exclude = [ + "tests", + "migrations", + # "hippo", + "hippo_legacy", +] +force-exclude = true + +[tool.ruff.lint] +select = [ + "E", # pycodestyle + "F", # pyflakes + "I", # import sort + "UP", # pyupgrade + "B", # bugbear +] + +[tool.ruff.lint.pycodestyle] +ignore-overlong-task-comments = true + +[tool.ruff.format] +quote-style = "single" + +[tool.ty.src] +exclude = [ + "migrations", + "tests", + # "hippo", + "hippo_legacy", +] + +[tool.uv.sources] +django-rdkit = { git = "https://github.com/rdkit/django-rdkit" } + +[tool.uv] +required-environments = [ + "sys_platform == 'linux' and platform_machine == 'aarch64'", + "sys_platform == 'linux' and platform_machine == 'x86_64'", ] diff --git a/tests/config.py b/tests/config.py index 6ab467e5..52a4b122 100644 --- a/tests/config.py +++ b/tests/config.py @@ -1,11 +1,21 @@ ## CONFIGURE TESTING DATA -TARGET = "SARS2_Nprot" -PROPOSAL = "lb32627-93" -STACK = "production" -DB = "db_test.sqlite" -TARGET = "SARS2_Nprot" +TARGET = 'SARS2_Nprot' +PROPOSAL = 'lb32627-93' +STACK = 'production' -## DISABLE TESTS +## CONFIGURE CLEANUP + +CLEANUP_FILES = [ + f'{TARGET}.tar.gz', +] + +CLEANUP_DIRS = [ + TARGET, +] + +## CONFIGURE DATABASE + +## CONFIGURE TESTS CLEANUP = True DOWNLOAD = True @@ -14,13 +24,24 @@ SCAFFOLDS = True SUBSITES = True -## CONFIGURE CLEANUP +### SQLITE -CLEANUP_FILES = [ - DB, - f"{TARGET}.tar.gz", -] +DB = 'db_test.sqlite' -CLEANUP_DIRS = [ - TARGET, -] +CLEANUP_FILES.append(DB) + +### POSTGRES + +# SCAFFOLDS = False + +# local testing + +# from os import environ + +# DB = dict( +# username=environ["HIPPO_POSTGRES_USERNAME"], +# password=environ["HIPPO_POSTGRES_PASSWORD"], +# host="localhost", +# port=5555, +# dbname="postgres", +# ) diff --git a/tests/test_00_cleanup.py b/tests/test_00_cleanup.py index 648a1943..8a37697a 100644 --- a/tests/test_00_cleanup.py +++ b/tests/test_00_cleanup.py @@ -22,5 +22,5 @@ def test_cleanup(): pass -if __name__ == "__main__": +if __name__ == '__main__': test_cleanup() diff --git a/tests/test_01_fragalysis_download.py b/tests/test_01_fragalysis_download.py index a4882176..6d9fbb40 100644 --- a/tests/test_01_fragalysis_download.py +++ b/tests/test_01_fragalysis_download.py @@ -6,15 +6,14 @@ def test_fragalysis_download(): if not DOWNLOAD: return - from pathlib import Path from fragalysis.requests import download_target path = download_target(name=TARGET, tas=PROPOSAL) assert path.exists() - assert (path / "metadata.csv").exists() - assert (path / "aligned_files").exists() + assert (path / 'metadata.csv').exists() + assert (path / 'aligned_files').exists() -if __name__ == "__main__": +if __name__ == '__main__': test_fragalysis_download() diff --git a/tests/test_02_setup_animal.py b/tests/test_02_setup_animal.py index 26752ee9..942953d0 100644 --- a/tests/test_02_setup_animal.py +++ b/tests/test_02_setup_animal.py @@ -7,15 +7,17 @@ def test_setup_animal(): return from pathlib import Path + import hippo - animal = hippo.HIPPO("test", DB) + animal = hippo.HIPPO('test', DB) animal.summary() - assert Path(DB).exists() + if isinstance(DB, str): + assert Path(DB).exists() animal.db.close() -if __name__ == "__main__": +if __name__ == '__main__': test_setup_animal() diff --git a/tests/test_03_add_hits.py b/tests/test_03_add_hits.py index 526ccc2a..7cc1ab0f 100644 --- a/tests/test_03_add_hits.py +++ b/tests/test_03_add_hits.py @@ -10,16 +10,16 @@ def test_add_hits(): import hippo - animal = hippo.HIPPO("test", DB) + animal = hippo.HIPPO('test', DB) animal.add_hits( target_name=TARGET, - aligned_directory=Path(TARGET) / "aligned_files", - metadata_csv=Path(TARGET) / "metadata.csv", + aligned_directory=Path(TARGET) / 'aligned_files', + metadata_csv=Path(TARGET) / 'metadata.csv', ) animal.db.close() -if __name__ == "__main__": +if __name__ == '__main__': test_add_hits() diff --git a/tests/test_04_interactions.py b/tests/test_04_interactions.py index eb8d2bb7..407825b8 100644 --- a/tests/test_04_interactions.py +++ b/tests/test_04_interactions.py @@ -5,7 +5,7 @@ def test_calculate_interactions(): import hippo - animal = hippo.HIPPO("test", DB) + animal = hippo.HIPPO('test', DB) for pose in animal.poses: pose.calculate_interactions() @@ -13,5 +13,5 @@ def test_calculate_interactions(): animal.db.close() -if __name__ == "__main__": +if __name__ == '__main__': test_calculate_interactions() diff --git a/tests/test_05_scaffolds.py b/tests/test_05_scaffolds.py index 5d829196..03baf48e 100644 --- a/tests/test_05_scaffolds.py +++ b/tests/test_05_scaffolds.py @@ -1,21 +1,22 @@ -from config import * import hippo +from config import * + def test_calculate_all_scaffolds(): if SCAFFOLDS: - animal = hippo.HIPPO("test", DB) + animal = hippo.HIPPO('test', DB) animal.db.calculate_all_scaffolds() animal.db.close() def test_calculate_all_murcko_scaffolds(): if SCAFFOLDS: - animal = hippo.HIPPO("test", DB) + animal = hippo.HIPPO('test', DB) animal.db.calculate_all_murcko_scaffolds() animal.db.close() -if __name__ == "__main__": +if __name__ == '__main__': test_calculate_all_scaffolds() test_calculate_all_murcko_scaffolds() diff --git a/tests/test_06_subsites.py b/tests/test_06_subsites.py index 2e93dbff..97ee0048 100644 --- a/tests/test_06_subsites.py +++ b/tests/test_06_subsites.py @@ -8,13 +8,13 @@ def test_set_subsites(): import hippo - animal = hippo.HIPPO("test", DB) + animal = hippo.HIPPO('test', DB) - hits = animal.poses(tag="hits") + hits = animal.poses(tag='hits') hits.set_subsites_from_metadata_field() animal.db.close() -if __name__ == "__main__": +if __name__ == '__main__': test_set_subsites() diff --git a/tests/test_compound.py b/tests/test_compound.py index 0512d647..fe1194a9 100644 --- a/tests/test_compound.py +++ b/tests/test_compound.py @@ -1,39 +1,39 @@ from config import * NOT_NULL_PROPERTIES = [ - "id", - "inchikey", - "name", - "smiles", - "mol", - "num_heavy_atoms", - "molecular_weight", - "num_rings", - "formula", - "atomtype_dict", - "metadata", - "db", - "tags", - "poses", - "best_placed_pose", - "num_poses", - "num_reactions", - "num_reactant", - "num_scaffolds", - "dict", - "is_scaffold", - "is_elab", - "is_product", - "table", + 'id', + 'inchikey', + 'name', + 'smiles', + 'mol', + 'num_heavy_atoms', + 'molecular_weight', + 'num_rings', + 'formula', + 'atomtype_dict', + 'metadata', + 'db', + 'tags', + 'poses', + 'best_placed_pose', + 'num_poses', + 'num_reactions', + 'num_reactant', + 'num_scaffolds', + 'dict', + 'is_scaffold', + 'is_elab', + 'is_product', + 'table', ] PROPERTIES = [ - "alias", - "elabs", - "reaction", - "reactions", - "scaffolds", - "num_atoms_added", + 'alias', + 'elabs', + 'reaction', + 'reactions', + 'scaffolds', + 'num_atoms_added', ] @@ -41,13 +41,13 @@ def test_properties(): import hippo - animal = hippo.HIPPO("test", DB) + animal = hippo.HIPPO('test', DB) compound = animal.C1 for prop in NOT_NULL_PROPERTIES: value = getattr(compound, prop) print(prop, value) - assert value is not None, f"{prop} is None" + assert value is not None, f'{prop} is None' for prop in PROPERTIES: value = getattr(compound, prop) @@ -56,5 +56,5 @@ def test_properties(): animal.db.close() -if __name__ == "__main__": +if __name__ == '__main__': test_properties() diff --git a/tests/test_feature.py b/tests/test_feature.py index 895913a5..afdeb250 100644 --- a/tests/test_feature.py +++ b/tests/test_feature.py @@ -1,14 +1,13 @@ from config import * -from common import animal NOT_NULL_PROPERTIES = [ - "id", - "family", - "target", - "chain_name", - "residue_name", - "residue_number", - "atom_names", + 'id', + 'family', + 'target', + 'chain_name', + 'residue_name', + 'residue_number', + 'atom_names', ] PROPERTIES = [] @@ -18,13 +17,16 @@ def test_properties(): import hippo - animal = hippo.HIPPO("test", DB) + animal = hippo.HIPPO('test', DB) + + animal.db.print_table('feature') + feature = animal.F1 for prop in NOT_NULL_PROPERTIES: value = getattr(feature, prop) print(prop, value) - assert value is not None, f"{prop} is None" + assert value is not None, f'{prop} is None' for prop in PROPERTIES: value = getattr(feature, prop) @@ -33,5 +35,5 @@ def test_properties(): animal.db.close() -if __name__ == "__main__": +if __name__ == '__main__': test_properties() diff --git a/tests/test_interaction.py b/tests/test_interaction.py index 7f812a08..1afc923e 100644 --- a/tests/test_interaction.py +++ b/tests/test_interaction.py @@ -1,29 +1,28 @@ from config import * -from common import animal NOT_NULL_PROPERTIES = [ - "id", - "table", - "db", - "family", - "pose_id", - "pose", - "feature_id", - "feature", - "residue_name", - "residue_number", - "atom_ids", - "prot_coord", - "lig_coord", - "distance", - "family_str", - "type", - "description", + 'id', + 'table', + 'db', + 'family', + 'pose_id', + 'pose', + 'feature_id', + 'feature', + 'residue_name', + 'residue_number', + 'atom_ids', + 'prot_coord', + 'lig_coord', + 'distance', + 'family_str', + 'type', + 'description', ] PROPERTIES = [ - "angle", - "energy", + 'angle', + 'energy', ] @@ -31,13 +30,13 @@ def test_properties(): import hippo - animal = hippo.HIPPO("test", DB) + animal = hippo.HIPPO('test', DB) interaction = animal.I1 for prop in NOT_NULL_PROPERTIES: value = getattr(interaction, prop) print(prop, value) - assert value is not None, f"{prop} is None" + assert value is not None, f'{prop} is None' for prop in PROPERTIES: value = getattr(interaction, prop) @@ -46,5 +45,5 @@ def test_properties(): animal.db.close() -if __name__ == "__main__": +if __name__ == '__main__': test_properties() diff --git a/tests/test_pose.py b/tests/test_pose.py index 51861c54..850b4673 100644 --- a/tests/test_pose.py +++ b/tests/test_pose.py @@ -1,49 +1,48 @@ from config import * -from common import animal NOT_NULL_PROPERTIES = [ - "db", - "id", - "inchikey", - "alias", - "name", - "smiles", - "target", - "compound_id", - "compound", - "path", - "mol", - "protonated_mol", - "protein_system", - "complex_system", - "has_complex_pdb_path", - "metadata", - "has_fingerprint", - "tags", - "features", - "dict", - "table", - "num_heavy_atoms", - "num_scaffolds", - "scaffold_ids", - "interactions", - "classic_fingerprint", - "mol_path", - "apo_path", + 'db', + 'id', + 'inchikey', + 'alias', + 'name', + 'smiles', + 'target', + 'compound_id', + 'compound', + 'path', + 'mol', + 'protonated_mol', + 'protein_system', + 'complex_system', + 'has_complex_pdb_path', + 'metadata', + 'has_fingerprint', + 'tags', + 'features', + 'dict', + 'table', + 'num_heavy_atoms', + 'num_scaffolds', + 'scaffold_ids', + 'interactions', + 'classic_fingerprint', + 'mol_path', + 'apo_path', ] PROPERTIES = [ - "reference", - "reference_id", - "inspirations", - "derivatives", - "num_atoms_added", - "num_atoms_added_wrt_scaffolds", - "num_atoms_added_wrt_inspirations", - "energy_score", - "distance_score", - "inspiration_score", - "subsites", + 'reference', + 'reference_id', + 'inspirations', + 'derivatives', + 'num_atoms_added', + 'num_atoms_added_wrt_scaffolds', + 'num_atoms_added_wrt_inspirations', + 'energy_score', + 'distance_score', + 'inspiration_score', + 'subsites', ] @@ -51,13 +50,13 @@ def test_properties(): import hippo - animal = hippo.HIPPO("test", DB) + animal = hippo.HIPPO('test', DB) pose = animal.P1 for prop in NOT_NULL_PROPERTIES: value = getattr(pose, prop) print(prop, value) - assert value is not None, f"{prop} is None" + assert value is not None, f'{prop} is None' for prop in PROPERTIES: value = getattr(pose, prop) @@ -65,5 +64,5 @@ def test_properties(): animal.db.close() -if __name__ == "__main__": +if __name__ == '__main__': test_properties() diff --git a/tests/test_subsite.py b/tests/test_subsite.py index c4fed794..e05a6bbc 100644 --- a/tests/test_subsite.py +++ b/tests/test_subsite.py @@ -1,15 +1,14 @@ from config import * -from common import animal NOT_NULL_PROPERTIES = [ - "db", - "id", - "table", - "target", - "target_id", - "name", - "metadata", - "poses", + 'db', + 'id', + 'table', + 'target', + 'target_id', + 'name', + 'metadata', + 'poses', ] PROPERTIES = [] @@ -19,13 +18,13 @@ def test_properties(): import hippo - animal = hippo.HIPPO("test", DB) + animal = hippo.HIPPO('test', DB) subsite = animal.S1 for prop in NOT_NULL_PROPERTIES: value = getattr(subsite, prop) print(prop, value) - assert value is not None, f"{prop} is None" + assert value is not None, f'{prop} is None' for prop in PROPERTIES: value = getattr(subsite, prop) @@ -34,5 +33,5 @@ def test_properties(): animal.db.close() -if __name__ == "__main__": +if __name__ == '__main__': test_properties() diff --git a/tests/test_tags.py b/tests/test_tags.py new file mode 100644 index 00000000..4195d3ea --- /dev/null +++ b/tests/test_tags.py @@ -0,0 +1,40 @@ +from config import * + +NOT_NULL_PROPERTIES = [ + 'unique', +] + +PROPERTIES = [] + + +def test_properties(): + + import hippo + + animal = hippo.HIPPO('test', DB) + tag_table = animal.tags + + for prop in NOT_NULL_PROPERTIES: + value = getattr(tag_table, prop) + print(prop, value) + assert value is not None, f'{prop} is None' + + for prop in PROPERTIES: + value = getattr(tag_table, prop) + print(prop, value) + + animal.db.close() + + +def test_summary(): + + import hippo + + animal = hippo.HIPPO('test', DB) + tag_table = animal.tags + tag_table.summary() + + +if __name__ == '__main__': + test_properties() + test_summary() diff --git a/tests/test_target.py b/tests/test_target.py index 5d60a39d..fef8d0b6 100644 --- a/tests/test_target.py +++ b/tests/test_target.py @@ -1,12 +1,11 @@ from config import * -from common import animal NOT_NULL_PROPERTIES = [ - "id", - "name", - "feature_ids", - "features", - "subsites", + 'id', + 'name', + 'feature_ids', + 'features', + 'subsites', ] PROPERTIES = [] @@ -16,13 +15,13 @@ def test_properties(): import hippo - animal = hippo.HIPPO("test", DB) + animal = hippo.HIPPO('test', DB) target = animal.T1 for prop in NOT_NULL_PROPERTIES: value = getattr(target, prop) print(prop, value) - assert value is not None, f"{prop} is None" + assert value is not None, f'{prop} is None' for prop in PROPERTIES: value = getattr(target, prop) @@ -31,5 +30,5 @@ def test_properties(): animal.db.close() -if __name__ == "__main__": +if __name__ == '__main__': test_properties() diff --git a/uv.lock b/uv.lock new file mode 100644 index 00000000..2c48a962 --- /dev/null +++ b/uv.lock @@ -0,0 +1,4390 @@ +version = 1 +revision = 3 +requires-python = ">=3.10, <3.14" +resolution-markers = [ + "python_full_version >= '3.13' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version >= '3.13' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version >= '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.11'", +] +required-markers = [ + "platform_machine == 'aarch64' and sys_platform == 'linux'", + "platform_machine == 'x86_64' and sys_platform == 'linux'", +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, +] + +[[package]] +name = "appnope" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/5d/752690df9ef5b76e169e68d6a129fa6d08a7100ca7f754c89495db3c6019/appnope-0.1.4.tar.gz", hash = "sha256:1de3860566df9caf38f01f86f65e0e13e379af54f9e4bee1e66b48f2efffd1ee", size = 4170, upload-time = "2024-02-06T09:43:11.258Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/29/5ecc3a15d5a33e31b26c11426c45c501e439cb865d0bff96315d86443b78/appnope-0.1.4-py2.py3-none-any.whl", hash = "sha256:502575ee11cd7a28c0205f379b525beefebab9d161b7c964670864014ed7213c", size = 4321, upload-time = "2024-02-06T09:43:09.663Z" }, +] + +[[package]] +name = "apsw" +version = "3.51.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0c/87/ae61c54529fb44610962599ab3b909627928992e59c28775dff76bf04125/apsw-3.51.3.0.tar.gz", hash = "sha256:821966a66ed5fd539e863a8f60d9a53497e0a47ffdabde4ec7714fae9ed00261", size = 1231663, upload-time = "2026-03-14T16:05:10.236Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/fa/3e0d2fabfd5e0529e039da183f5ef8cbad2fe1980b8da6c88cc785bde801/apsw-3.51.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ea214a63f811f769c1d54fd03a4be0a261d5b1bebf70820785bd0717b404d84c", size = 3729236, upload-time = "2026-03-14T16:02:32.895Z" }, + { url = "https://files.pythonhosted.org/packages/fe/09/607b745b189157a8b8e3f314d13f18a6091b1eb07476b41bf03a3378185c/apsw-3.51.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:01ff1d8553813d122326bade5e6402f11cb27afcb4db889f969584432e58e77a", size = 3496106, upload-time = "2026-03-14T16:02:34.93Z" }, + { url = "https://files.pythonhosted.org/packages/51/29/d7a2851c1df4671eccd589cc5eed592102d626a86568bffff98fecff55a0/apsw-3.51.3.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:ea5058ed15d058d39b1309741365134d16de4ad617d718deb462da727cc2430a", size = 4229040, upload-time = "2026-03-14T16:02:36.616Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9d/75da82eb79cbc2e4b15231e432134d502e69fade6a46652256589aa1bcdd/apsw-3.51.3.0-cp310-cp310-manylinux_2_28_i686.whl", hash = "sha256:f6c740c5b241cc66c5e99d280aa5f8696977a9941c8d42efcfe1b092fa08e03a", size = 4665332, upload-time = "2026-03-14T16:02:38.519Z" }, + { url = "https://files.pythonhosted.org/packages/91/06/588756292684968ba56d5b9ed859fa6432c693a77f35dc9d0ee8bd690a41/apsw-3.51.3.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:6bba622b3b524ab7de8a353b4081d382bca0ef3253d22064a6a6753caeaea651", size = 4336266, upload-time = "2026-03-14T16:02:40.201Z" }, + { url = "https://files.pythonhosted.org/packages/b1/05/5bcbabb0b244b51f32eb0ba0d439e463f1276c0c8185c82947eefe5ed02f/apsw-3.51.3.0-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:289777a11591a1882a21f49993d7a0ae2588055b6edbfbb5e1121d2fdd39d28d", size = 3775489, upload-time = "2026-03-14T16:02:42.079Z" }, + { url = "https://files.pythonhosted.org/packages/2c/03/6fa781378518b39f1b81d9eea4b5068a4e57d3ca888f94b509f8f62613b1/apsw-3.51.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74f6ceb71e651971af9b93c82a26969743fc69af0ed2a8b992b99148e5e4d554", size = 4185240, upload-time = "2026-03-14T16:02:43.806Z" }, + { url = "https://files.pythonhosted.org/packages/1c/7a/0dbc17170989513d1802b693c75b83f41e7ffd9f636bcbd015242c5b856a/apsw-3.51.3.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:9ab30dcf3f1c9bfe2f13e0fdbde895b90a2a22ad486343389c5084f857b4b83a", size = 3805687, upload-time = "2026-03-14T16:02:45.567Z" }, + { url = "https://files.pythonhosted.org/packages/af/f4/d628c29fb5a3741efdd7d7116ce3f7ef055f2048631149cb85a582638f4a/apsw-3.51.3.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e60c39abe3cb997e6cf1b7d875108b39583e575e1e247609a768849d50a39efb", size = 4632999, upload-time = "2026-03-14T16:02:47.332Z" }, + { url = "https://files.pythonhosted.org/packages/c7/d9/a86383c9cf97163795b80a4e375fcec0093d056a9e44387a7d2f5ff76d80/apsw-3.51.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:08c05a62e84b96ab742032fb912753b3c11d313ae111c439f323ba3daac019db", size = 4297505, upload-time = "2026-03-14T16:02:48.859Z" }, + { url = "https://files.pythonhosted.org/packages/65/ec/c7b3432dd5889787d574a9196040e7be31720d00f3e7cd6bb2931093126f/apsw-3.51.3.0-cp310-cp310-win32.whl", hash = "sha256:c30a8062434b4edff6ff0ecd36328e4eb0c7c28b837ca3bd683a85f8030fb79b", size = 3192449, upload-time = "2026-03-14T16:02:50.518Z" }, + { url = "https://files.pythonhosted.org/packages/85/cd/b2887b58b8c23f1a009eb6cd03fd6b6f58a456695b8f47ba4101bd625a03/apsw-3.51.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:2db6b9327fd5ab6a7d9e7691b03bef8ba57d201f0d65714b34c788a2d18595b1", size = 3629320, upload-time = "2026-03-14T16:02:52.087Z" }, + { url = "https://files.pythonhosted.org/packages/a7/32/06946cbf029b0736fb532c7ac80bea8bfa3a7735f81c4526c24911a0052d/apsw-3.51.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:bc835e3f123ebed299159381fb764276e59ceb4030cc495187a6aff52ad6e141", size = 3183785, upload-time = "2026-03-14T16:02:53.91Z" }, + { url = "https://files.pythonhosted.org/packages/86/e5/16824fc8909d769391dbedf08294a0d09094cbdfdae4feaa8f3f1290c57a/apsw-3.51.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:78e0956c0c53c1fc46d73e0000a9a42c07942f728d162fe0b512dd5273f264d0", size = 3735649, upload-time = "2026-03-14T16:02:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/97/dc/eaa0fb33c4e5c80dc827ac2b8b145cab059b52f5f234fc4a6f2b181aaaca/apsw-3.51.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:72a1df1df8f792a8218b248590b29551e09a2abf0192b84cb18c45878cd73310", size = 3503115, upload-time = "2026-03-14T16:02:57.63Z" }, + { url = "https://files.pythonhosted.org/packages/c4/d2/5e54a26cee3de37963b9e9cd6440059fcdcb93cd4e30f60c7e83c8689c81/apsw-3.51.3.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:9cd5832f1b97bcbe5b5da902425af99308962c1eab49348c680647c4288842e6", size = 4166314, upload-time = "2026-03-14T16:02:59.187Z" }, + { url = "https://files.pythonhosted.org/packages/2f/08/92522d09c27354ea740bf76512f541094db5e0f70cf3d00ad87c63147785/apsw-3.51.3.0-cp311-cp311-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a233841ef6beb0f4571d55a34910df636fd409140ed38d790d4428b052281d69", size = 3777700, upload-time = "2026-03-14T16:03:01.142Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a6/be8e777c5eaf4190902db1af7e65be1efa3152f27ec2c65f45dcfa56f2f3/apsw-3.51.3.0-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:aad69b1e1a08c5359cb6c72f1ec752dde34187f8e9c71d71d0000a266480e1f4", size = 4621390, upload-time = "2026-03-14T16:03:03.266Z" }, + { url = "https://files.pythonhosted.org/packages/a0/77/67188edac94ea077635f30d4a0c2247d15cdf1829bea1489b6c532e54137/apsw-3.51.3.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:5c9e7a16dcf3e474d6a696ce17b566a2d6964219b4d887c49c8d67562669b9bd", size = 4293456, upload-time = "2026-03-14T16:03:05.056Z" }, + { url = "https://files.pythonhosted.org/packages/8d/07/12b57f4f27d13b219994a8093fa1987da24868e788cbd7ed406f1297366d/apsw-3.51.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bae631aa0c32a64a5e1989c0fcbbbd74e458b83bb242de47b9a28f5230f83256", size = 4189204, upload-time = "2026-03-14T16:03:07.078Z" }, + { url = "https://files.pythonhosted.org/packages/98/ef/dadbe0bb4f34cf5b1a305d2eb74cdc47f1f8888f6ad849606c403dbc18fd/apsw-3.51.3.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:6c51e7788ad88a30229e738d3669ab1930258e72255b397308fc3c5da16dd8ce", size = 3812470, upload-time = "2026-03-14T16:03:08.664Z" }, + { url = "https://files.pythonhosted.org/packages/93/d0/31a11edbb119d2d4e82e29c393d9b3c65e92864c8ffa363c50022748998e/apsw-3.51.3.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4945ab142ef98da69720d7f9c5e7a9a86ea0fa19637320c9a3a35729298d6cd3", size = 4642149, upload-time = "2026-03-14T16:03:10.289Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ce/1fc9a70ccbf14f881dc25ea4c858fca5faa88383c4dae093227fe170c282/apsw-3.51.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:20fd05b9302bc6e8d9fbe3b9e6034015fb31779c1dd50c463cd879610a6114b6", size = 4301621, upload-time = "2026-03-14T16:03:12.263Z" }, + { url = "https://files.pythonhosted.org/packages/ca/5b/b557c9456692064dc0ee98dc3da790ad5c4f6966e2f090af7424a5c79169/apsw-3.51.3.0-cp311-cp311-win32.whl", hash = "sha256:193e348a8a71a179c4fffb414ef61f71f3009c82ab4aed711bc1169be3e68044", size = 3187115, upload-time = "2026-03-14T16:03:13.873Z" }, + { url = "https://files.pythonhosted.org/packages/79/a6/7cdb75e60cfcc4ba15a037f984b18daa79fc3882649cdf07d9ace60ecc8d/apsw-3.51.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:63e043b56549c159ca30a6474779e0b482fc88bd02b9bc17f5050e69ff62f44c", size = 3627629, upload-time = "2026-03-14T16:03:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/49/06/e08843bb4bb404ccb8cae4592aca9ae23d048c15ea7c28c975acb1b23dd4/apsw-3.51.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:c9f2d544036c675c7332db32eb845f01261abcfcc74f27278610a5031bbb95ce", size = 3183400, upload-time = "2026-03-14T16:03:16.905Z" }, + { url = "https://files.pythonhosted.org/packages/cd/6a/4544afa5e29c1bb48f9d13ea49f897537167b43990346af2237094acbbe3/apsw-3.51.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:69b45f0496d62ea673ff75a45c6610366b606baeb0ab9a24c5796b1fa7248f79", size = 3733554, upload-time = "2026-03-14T16:03:18.727Z" }, + { url = "https://files.pythonhosted.org/packages/ce/30/0e29cfdbb882b94a2d09ab58d351162b8f47efe5c75b20b17786603caa71/apsw-3.51.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:14cf60bb9e354d7272e21bc175653247e3ef3f4e5f60dd0cbd01150d6f9e51a5", size = 3502556, upload-time = "2026-03-14T16:03:20.499Z" }, + { url = "https://files.pythonhosted.org/packages/fb/77/c2e14fb98306ec4cdfed6543b95b285c4d5042086859dc0b2af438691eed/apsw-3.51.3.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:a9feb28cbd061a5e9e045b47cbe7a77f0a80bbfa19b2ab2071af95e43e998eaa", size = 4163452, upload-time = "2026-03-14T16:03:22.116Z" }, + { url = "https://files.pythonhosted.org/packages/a6/ad/fcbda02af13c154f929422015f69b2d0a5c6a2c7b20a693772d02eb05e58/apsw-3.51.3.0-cp312-cp312-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e42c589b0aaa8520ab43590d24b92d6ba78b73ec394f81c4d28f3fee38188816", size = 3772011, upload-time = "2026-03-14T16:03:24.063Z" }, + { url = "https://files.pythonhosted.org/packages/a2/c7/13956b1cfcdd45db66b3738b75373367e5ac27cd62e04556a7bfa697de7e/apsw-3.51.3.0-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:f11e49aead6a0165060dd117e96e27fbf17e371eaf30bce39417e7b3bb2531e1", size = 4610512, upload-time = "2026-03-14T16:03:25.992Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e5/5e42bff00f375ca8702c4566d2248d6d00f23c95a2f7c5ca600941603b37/apsw-3.51.3.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:97233fa1ee8811530f09989acb0ad99ab6b2ca8cae14baf8defe07897d39f6d1", size = 4283567, upload-time = "2026-03-14T16:03:27.903Z" }, + { url = "https://files.pythonhosted.org/packages/15/e6/d0c028420f0195e12a7fcede7037fa24e2cd9685b7c36534690a5d2ca9cc/apsw-3.51.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4965e0f31d1c14ad0c2ba1fcf56e086f21386a4198820654a538cb4315f976ec", size = 4185086, upload-time = "2026-03-14T16:03:29.537Z" }, + { url = "https://files.pythonhosted.org/packages/d5/89/845c8ba00053b02d878136863bf5a20af3b7060f12189fd3f4ce3897ce7e/apsw-3.51.3.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:47ce2bbfaa3f4ca28d54ff1e45234906df2b2a9686367c01cf91605c2feac230", size = 3806413, upload-time = "2026-03-14T16:03:31.471Z" }, + { url = "https://files.pythonhosted.org/packages/f9/5f/57cc46978b9673e09945bb3039b4bf2628666dc89afcffa7893c8c60bdb6/apsw-3.51.3.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9c341cf5b650c3fd648066d8b09a8da8af8ebeb94491b1d7c09637bc7423315f", size = 4629478, upload-time = "2026-03-14T16:03:33.412Z" }, + { url = "https://files.pythonhosted.org/packages/5f/b6/c8f53804bd90407b907a0ca5b7a669232e654d9ec41b1e102bfe2358264e/apsw-3.51.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5b3578746400b748def42500de5e2666e07819fef025e3ca02e82366712560d1", size = 4298045, upload-time = "2026-03-14T16:03:35.132Z" }, + { url = "https://files.pythonhosted.org/packages/91/ba/43336dd507ffb6d30209b89c257f3e0f453e7aef442ffd6c0facb4249f8f/apsw-3.51.3.0-cp312-cp312-win32.whl", hash = "sha256:74b361fc27bd9c233576adad24875b757ffdfa3f5f5453d98a9e021a3ab73806", size = 3186682, upload-time = "2026-03-14T16:03:36.644Z" }, + { url = "https://files.pythonhosted.org/packages/ce/7a/82e15c8da7c0de5df9fa47655ffd286ee8eb4a11eedbe6889b47f7604aea/apsw-3.51.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:ec869f9ffb7dff49aef68f3200a5fab9d55c956561b6e1a2f3e5ca5b499b4d22", size = 3625919, upload-time = "2026-03-14T16:03:38.567Z" }, + { url = "https://files.pythonhosted.org/packages/82/11/7f9d919fefcb9e2ac633e1feb28b72ae466b6ec7e2128acd197d13ce90fe/apsw-3.51.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:a6426bdcb9cd60a68b6534abffedce11418b28cbeb6b89cc23f50611bdb1d7eb", size = 3183248, upload-time = "2026-03-14T16:03:40.24Z" }, + { url = "https://files.pythonhosted.org/packages/17/06/e652801e3ff3e3f065c3f07c54794024ded2750d66e6f21b8f0098d4e28f/apsw-3.51.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4fd2c96c98f223e9732b894662ff2b91636c8335f089293ab1b3fbe233f31bf2", size = 3731439, upload-time = "2026-03-14T16:03:41.883Z" }, + { url = "https://files.pythonhosted.org/packages/16/2a/85d753b1b7c430283f4fd831672165eec74bc91ab5b9cea7f3fef652249e/apsw-3.51.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:376c3ec4da6058fd110f2339e58cbe9a2bfded08d60795fbbc8fcc225e5c3d50", size = 3499870, upload-time = "2026-03-14T16:03:43.872Z" }, + { url = "https://files.pythonhosted.org/packages/90/5a/81a65f260fc6e8f0f9d07929fe6b3f59ddc5bf1a0e2b87f18c49a8bbe25f/apsw-3.51.3.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:8dcb52104f26b41dbf82eba793987611f42c88d3ce0b2e4049f7bab6703fd28c", size = 4159338, upload-time = "2026-03-14T16:03:45.451Z" }, + { url = "https://files.pythonhosted.org/packages/30/1c/aab87cc58220d1d4e83cb1f6eb7efdfc150fb165dab9c211c1967fc71489/apsw-3.51.3.0-cp313-cp313-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a4b1f66f0753cae3163fc2b57d7c77dc16bebcd279384072df65d17053480a77", size = 3774233, upload-time = "2026-03-14T16:03:47.356Z" }, + { url = "https://files.pythonhosted.org/packages/75/ad/0cb2efccd548f951220b5d74d7baad6c5d3af9879cd4a9fe24d6ef374d55/apsw-3.51.3.0-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:1457f9a356351e9a6a34afe7dba7e1232c72279e97e04351936f0c20d20c3606", size = 4603017, upload-time = "2026-03-14T16:03:48.915Z" }, + { url = "https://files.pythonhosted.org/packages/56/00/188d4edf72178706df6556d01c6357af068e45bbcbf11fa1902fac9987e8/apsw-3.51.3.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:5f0c116efd1a5db0e94885313cc321a8f6e0c3dc7dcb21823d19bdc0c689cc0d", size = 4288777, upload-time = "2026-03-14T16:03:50.807Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b7/1b985fd892c1fa95cc3d9e5f128e560ab35b64bfb663337c2b499323b4f6/apsw-3.51.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6ca90f943c19fa4cc15459b82eafd581355186a2ba4c2c904aba8549a75eec9f", size = 4178855, upload-time = "2026-03-14T16:03:52.402Z" }, + { url = "https://files.pythonhosted.org/packages/86/d6/5ff3467d87febd970b469980044a58e172c608af37d0c5bb19e193547d09/apsw-3.51.3.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:3cecf686e00c511e6565b024d0b8d9b9689ea5c92dad4ab889abe8fcc2a59e6c", size = 3808644, upload-time = "2026-03-14T16:03:54.292Z" }, + { url = "https://files.pythonhosted.org/packages/94/b9/5c2513f1cdb0c5ba9944be4e3f0f0ea216010102404835cbc30195889b9d/apsw-3.51.3.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d56d51b03970c78900dcb2003a5db1df8be9690b0abdfccdf5c04b5af984586f", size = 4623847, upload-time = "2026-03-14T16:03:56.016Z" }, + { url = "https://files.pythonhosted.org/packages/e7/1b/3a545ef60d58c3feaa41a0d18f3444fda1c82a7bb4694ae0a5006da2de23/apsw-3.51.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e6db970976c5645d255d9944a8d9d7e38d30835121354bb11ebf5687b2f376df", size = 4297109, upload-time = "2026-03-14T16:03:57.735Z" }, + { url = "https://files.pythonhosted.org/packages/99/2b/3035b1a44498ff63dda458606fd42547f9e6b5a733f81ee75c50a27f4307/apsw-3.51.3.0-cp313-cp313-win32.whl", hash = "sha256:d10159228ea3bb342b84b5be9546e26100e9f28f79a1f1b990e0c23adc566fd1", size = 3185665, upload-time = "2026-03-14T16:04:00.436Z" }, + { url = "https://files.pythonhosted.org/packages/bb/06/3942fcb970af53d6ca9040b4bee690bd9f0afd9a42c040044ee4a2f877b1/apsw-3.51.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:b3b2bf0ffe99d19affdc80f9c3208f697155bc2add1c562eb79d79d3867b0042", size = 3624831, upload-time = "2026-03-14T16:04:02.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/6a/e29659da47ce9f4e618f8397ea6c00b94054dfcda324d87e44a1257289c9/apsw-3.51.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:8f3c9a1d92cfd12925e4d43379a51fbac6203e36d1d316a49cb09b9460496cc4", size = 3182787, upload-time = "2026-03-14T16:04:03.988Z" }, +] + +[[package]] +name = "argcomplete" +version = "3.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/38/61/0b9ae6399dd4a58d8c1b1dc5a27d6f2808023d0b5dd3104bb99f45a33ff6/argcomplete-3.6.3.tar.gz", hash = "sha256:62e8ed4fd6a45864acc8235409461b72c9a28ee785a2011cc5eb78318786c89c", size = 73754, upload-time = "2025-10-20T03:33:34.741Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/f5/9373290775639cb67a2fce7f629a1c240dce9f12fe927bc32b2736e16dfc/argcomplete-3.6.3-py3-none-any.whl", hash = "sha256:f5007b3a600ccac5d25bbce33089211dfd49eab4a7718da3f10e3082525a92ce", size = 43846, upload-time = "2025-10-20T03:33:33.021Z" }, +] + +[[package]] +name = "argon2-cffi" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argon2-cffi-bindings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706, upload-time = "2025-06-03T06:55:32.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657, upload-time = "2025-06-03T06:55:30.804Z" }, +] + +[[package]] +name = "argon2-cffi-bindings" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/2d/db8af0df73c1cf454f71b2bbe5e356b8c1f8041c979f505b3d3186e520a9/argon2_cffi_bindings-25.1.0.tar.gz", hash = "sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d", size = 1783441, upload-time = "2025-07-30T10:02:05.147Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/57/96b8b9f93166147826da5f90376e784a10582dd39a393c99bb62cfcf52f0/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500", size = 54121, upload-time = "2025-07-30T10:01:50.815Z" }, + { url = "https://files.pythonhosted.org/packages/0a/08/a9bebdb2e0e602dde230bdde8021b29f71f7841bd54801bcfd514acb5dcf/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44", size = 29177, upload-time = "2025-07-30T10:01:51.681Z" }, + { url = "https://files.pythonhosted.org/packages/b6/02/d297943bcacf05e4f2a94ab6f462831dc20158614e5d067c35d4e63b9acb/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0", size = 31090, upload-time = "2025-07-30T10:01:53.184Z" }, + { url = "https://files.pythonhosted.org/packages/c1/93/44365f3d75053e53893ec6d733e4a5e3147502663554b4d864587c7828a7/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6", size = 81246, upload-time = "2025-07-30T10:01:54.145Z" }, + { url = "https://files.pythonhosted.org/packages/09/52/94108adfdd6e2ddf58be64f959a0b9c7d4ef2fa71086c38356d22dc501ea/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a", size = 87126, upload-time = "2025-07-30T10:01:55.074Z" }, + { url = "https://files.pythonhosted.org/packages/72/70/7a2993a12b0ffa2a9271259b79cc616e2389ed1a4d93842fac5a1f923ffd/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d", size = 80343, upload-time = "2025-07-30T10:01:56.007Z" }, + { url = "https://files.pythonhosted.org/packages/78/9a/4e5157d893ffc712b74dbd868c7f62365618266982b64accab26bab01edc/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99", size = 86777, upload-time = "2025-07-30T10:01:56.943Z" }, + { url = "https://files.pythonhosted.org/packages/74/cd/15777dfde1c29d96de7f18edf4cc94c385646852e7c7b0320aa91ccca583/argon2_cffi_bindings-25.1.0-cp39-abi3-win32.whl", hash = "sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2", size = 27180, upload-time = "2025-07-30T10:01:57.759Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c6/a759ece8f1829d1f162261226fbfd2c6832b3ff7657384045286d2afa384/argon2_cffi_bindings-25.1.0-cp39-abi3-win_amd64.whl", hash = "sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98", size = 31715, upload-time = "2025-07-30T10:01:58.56Z" }, + { url = "https://files.pythonhosted.org/packages/42/b9/f8d6fa329ab25128b7e98fd83a3cb34d9db5b059a9847eddb840a0af45dd/argon2_cffi_bindings-25.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94", size = 27149, upload-time = "2025-07-30T10:01:59.329Z" }, + { url = "https://files.pythonhosted.org/packages/11/2d/ba4e4ca8d149f8dcc0d952ac0967089e1d759c7e5fcf0865a317eb680fbb/argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:6dca33a9859abf613e22733131fc9194091c1fa7cb3e131c143056b4856aa47e", size = 24549, upload-time = "2025-07-30T10:02:00.101Z" }, + { url = "https://files.pythonhosted.org/packages/5c/82/9b2386cc75ac0bd3210e12a44bfc7fd1632065ed8b80d573036eecb10442/argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:21378b40e1b8d1655dd5310c84a40fc19a9aa5e6366e835ceb8576bf0fea716d", size = 25539, upload-time = "2025-07-30T10:02:00.929Z" }, + { url = "https://files.pythonhosted.org/packages/31/db/740de99a37aa727623730c90d92c22c9e12585b3c98c54b7960f7810289f/argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d588dec224e2a83edbdc785a5e6f3c6cd736f46bfd4b441bbb5aa1f5085e584", size = 28467, upload-time = "2025-07-30T10:02:02.08Z" }, + { url = "https://files.pythonhosted.org/packages/71/7a/47c4509ea18d755f44e2b92b7178914f0c113946d11e16e626df8eaa2b0b/argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5acb4e41090d53f17ca1110c3427f0a130f944b896fc8c83973219c97f57b690", size = 27355, upload-time = "2025-07-30T10:02:02.867Z" }, + { url = "https://files.pythonhosted.org/packages/ee/82/82745642d3c46e7cea25e1885b014b033f4693346ce46b7f47483cf5d448/argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:da0c79c23a63723aa5d782250fbf51b768abca630285262fb5144ba5ae01e520", size = 29187, upload-time = "2025-07-30T10:02:03.674Z" }, +] + +[[package]] +name = "arrow" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/33/032cdc44182491aa708d06a68b62434140d8c50820a087fac7af37703357/arrow-1.4.0.tar.gz", hash = "sha256:ed0cc050e98001b8779e84d461b0098c4ac597e88704a655582b21d116e526d7", size = 152931, upload-time = "2025-10-18T17:46:46.761Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/c9/d7977eaacb9df673210491da99e6a247e93df98c715fc43fd136ce1d3d33/arrow-1.4.0-py3-none-any.whl", hash = "sha256:749f0769958ebdc79c173ff0b0670d59051a535fa26e8eba02953dc19eb43205", size = 68797, upload-time = "2025-10-18T17:46:45.663Z" }, +] + +[[package]] +name = "ase" +version = "3.27.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "matplotlib" }, + { name = "numpy" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/d38b39abd24110deb13bee0dc14404eca1f2113c01bc9bbf075dc3e1c2dd/ase-3.27.0.tar.gz", hash = "sha256:92ada752d6866a61d2d27e0e6a4fd5b8cd86f59ca79a58f1d2fe29d7099153dc", size = 2363050, upload-time = "2025-12-28T15:41:22.419Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/9b/9b55b4d4855743de61ba91566d03b2560285ed8fc0387b9cf914795d4abf/ase-3.27.0-py3-none-any.whl", hash = "sha256:058c48ea504fe7fbbe7c932f778415243ef2df45b1ab869866f24efcc17f0538", size = 2885170, upload-time = "2025-12-28T15:41:20.257Z" }, +] + +[[package]] +name = "asgiref" +version = "3.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/40/f03da1264ae8f7cfdbf9146542e5e7e8100a4c66ab48e791df9a03d3f6c0/asgiref-3.11.1.tar.gz", hash = "sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce", size = 38550, upload-time = "2026-02-03T13:30:14.33Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/0a/a72d10ed65068e115044937873362e6e32fab1b7dce0046aeb224682c989/asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133", size = 24345, upload-time = "2026-02-03T13:30:13.039Z" }, +] + +[[package]] +name = "asttokens" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/a5/8e3f9b6771b0b408517c82d97aed8f2036509bc247d46114925e32fe33f0/asttokens-3.0.1.tar.gz", hash = "sha256:71a4ee5de0bde6a31d64f6b13f2293ac190344478f081c3d1bccfcf5eacb0cb7", size = 62308, upload-time = "2025-11-15T16:43:48.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/39/e7eaf1799466a4aef85b6a4fe7bd175ad2b1c6345066aa33f1f58d4b18d0/asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a", size = 27047, upload-time = "2025-11-15T16:43:16.109Z" }, +] + +[[package]] +name = "async-lru" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/8a/ca724066c32a53fa75f59e0f21aa822fdaa8a0dffa112d223634e3caabf9/async_lru-2.2.0.tar.gz", hash = "sha256:80abae2a237dbc6c60861d621619af39f0d920aea306de34cb992c879e01370c", size = 14654, upload-time = "2026-02-20T19:11:43.848Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/5c/af990f019b8dd11c5492a6371fe74a5b0276357370030b67254a87329944/async_lru-2.2.0-py3-none-any.whl", hash = "sha256:e2c1cf731eba202b59c5feedaef14ffd9d02ad0037fcda64938699f2c380eafe", size = 7890, upload-time = "2026-02-20T19:11:42.273Z" }, +] + +[[package]] +name = "attrs" +version = "25.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, +] + +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + +[[package]] +name = "beautifulsoup4" +version = "4.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, +] + +[[package]] +name = "biopython" +version = "1.86" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/61/c59a849bd457c8a1b408ae828dbcc15e674962b5a29705e869e15b32bf25/biopython-1.86.tar.gz", hash = "sha256:93a50b586a4d2cec68ab2f99d03ef583c5761d8fba5535cb8e81da781d0d92ff", size = 19835323, upload-time = "2025-10-28T21:18:31.041Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/9f/5c95732ad98a6d40f4be58978be801cc87b50c71d79a7aee46c4a085114d/biopython-1.86-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:02aef2e31cc92544f574ff837cabaaaf53733f3a6b5a433f781c59e5424a7576", size = 2691935, upload-time = "2025-10-28T21:27:12.558Z" }, + { url = "https://files.pythonhosted.org/packages/ca/15/9902cbc901073ba2de397f5c9c84e72147e02aaca1755fa650d26bb715a2/biopython-1.86-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e1b12819a78242b529f54e5d2d00ad90023710a5846ca0f2011ac989fd17d4b", size = 2669420, upload-time = "2025-10-28T21:26:40.048Z" }, + { url = "https://files.pythonhosted.org/packages/9d/5d/9cb775106361f8ef7ab459b89ff6d725e81dae7abd382309d3bbf82ced6d/biopython-1.86-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e62504faac6e62fe26e40d6905a69519d8b7b5b0506a426d641b218fde788b5", size = 3183554, upload-time = "2025-10-29T00:35:24.074Z" }, + { url = "https://files.pythonhosted.org/packages/08/47/87c8db55746099b38baf6c597688807bad2e59074cec37169168fe98e69e/biopython-1.86-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8d4530060aadc6af060a9a049da91a582738837e187fcea80486c71eca74ae59", size = 3205260, upload-time = "2025-10-29T00:35:33.89Z" }, + { url = "https://files.pythonhosted.org/packages/88/6c/7822f6b7521073ccc80eb18736b664efe1d59edeb6a3f72c362f542ec352/biopython-1.86-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:186e2065c0d1a6c2afc85b9c21a2911a931949668bd73b4c03f429a31b3589f8", size = 3154783, upload-time = "2025-10-28T23:52:52.157Z" }, + { url = "https://files.pythonhosted.org/packages/98/7a/6759f99b481432969a4979f03b4c4966716d4cffd2154749ae5af0d8904a/biopython-1.86-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6915a09859159598b9421e7240561692e7bb4084e5340c4dbb2435c5c38805a2", size = 3173920, upload-time = "2025-10-28T23:52:57.611Z" }, + { url = "https://files.pythonhosted.org/packages/17/c2/fe2a223b3fdd718099055679b68f9f15b2006d83a5a1c1593246d5e5fe81/biopython-1.86-cp310-cp310-win32.whl", hash = "sha256:be3d83152fe3232e2d197896a506902b84ad60d40b3f1d1fc934914d138c6dc1", size = 2697949, upload-time = "2025-10-28T21:32:07.305Z" }, + { url = "https://files.pythonhosted.org/packages/1f/11/44fb3975df1070966ffc213f52e9fff63fb48b43bc3d403584ca3f4f9a42/biopython-1.86-cp310-cp310-win_amd64.whl", hash = "sha256:6fbbfe19e12170754adb9632155b7e3be0d4c247f0a2e09d3917bec859282de1", size = 2734235, upload-time = "2025-10-28T21:32:03.014Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f5/37d6bb3a1245ec5f5f1c66d5cd790b06cdb54a75b36849893405c17f3612/biopython-1.86-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ba88b0754ad53c93eba11d910364cfc773686933c89a886522309ba903151e50", size = 2691944, upload-time = "2025-10-28T21:27:24.053Z" }, + { url = "https://files.pythonhosted.org/packages/14/12/44d71f333b7302b30788df80705f2207c47b54c17d0935a378dfc709507d/biopython-1.86-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6cceb32b9036bbdc59962e31bd1605ece24edc226c0d50f99839948b5b5c9dda", size = 2669434, upload-time = "2025-10-28T21:26:49.145Z" }, + { url = "https://files.pythonhosted.org/packages/a5/1b/731060090ed29b5ac2484865255f1f363a50afb7275717ceb2c6f20d3ea4/biopython-1.86-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0f040ff85bd7d0ee06574bc6d032bc666802f2fe781b0c316b936237eb3d17e", size = 3196718, upload-time = "2025-10-29T00:35:47.806Z" }, + { url = "https://files.pythonhosted.org/packages/1c/8d/8409535c341061b9c78faf151e73b484b456b3c3bdf59b27cf3984f16fbc/biopython-1.86-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6ac858fd71f1093380d8b0a16acf060e7c228ad65f9ecacdb9f5760cfb9f59b1", size = 3218383, upload-time = "2025-10-29T00:35:53.523Z" }, + { url = "https://files.pythonhosted.org/packages/f4/bc/5e93a11f70732122679747a728509d03a6a066b178cc1d7ca30ed2f1ebee/biopython-1.86-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da4bcf5a48ee647624e2d0bedac7fb1c24ef0facd514519cca074593b8a6a40e", size = 3168368, upload-time = "2025-10-28T23:53:16.425Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c6/e187940571a3a24d20f407f1d7514ab1fe0dc9fa49e01790c4bd56ced0bc/biopython-1.86-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1d4dd9090caaf364a08ab54cd561f37c5f4ea5bcc8f0189d332dcd36d6df5767", size = 3186451, upload-time = "2025-10-28T23:53:22.463Z" }, + { url = "https://files.pythonhosted.org/packages/2a/88/1e8ffb0db6a03888768613d682a79043e9975067b9095e644a6872905c88/biopython-1.86-cp311-cp311-win32.whl", hash = "sha256:90591f4554c09d311193e7774b5143442c67e178a5b7d929aaa2a054048b22a7", size = 2697756, upload-time = "2025-10-28T21:32:23.017Z" }, + { url = "https://files.pythonhosted.org/packages/f0/b2/e34e45d6cb46c96486a2ed5f07874b6c9493dec68b9d6262ae05f4fe909b/biopython-1.86-cp311-cp311-win_amd64.whl", hash = "sha256:0a95321ca929c04c934e62252c9e2cc5c4fd13ce575798d98af2d79512334b9b", size = 2733781, upload-time = "2025-10-28T21:32:18.409Z" }, + { url = "https://files.pythonhosted.org/packages/98/e2/199b8ccbd4b9bf234157db0668177b5b7784d62f29d9096fd0d3a70e3b86/biopython-1.86-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f8d372aae21d79b11613751c6ae23c88db0e94d25b7567b1f67aa0304fb61667", size = 2693171, upload-time = "2025-10-29T00:26:59.028Z" }, + { url = "https://files.pythonhosted.org/packages/d8/2f/1a7da2a55212b3d0a03866d22213f91273fee3722b5364575419fbe574a5/biopython-1.86-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:baf19d9237aaaa387a68f8f055f978af5c80338d7e037ab028e8d768928f1250", size = 2692543, upload-time = "2025-10-28T21:27:31.855Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e9/4057d4c2aa22ca25c180ecbed2ce9e7d65bf787999778bc63b41df0d03b5/biopython-1.86-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:04f9abdf6cbf0087850de5f8148da0d420c4cb87905bf4de3145ad24a8d55dcd", size = 2669975, upload-time = "2025-10-28T21:26:54.181Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b2/3e6862720d7c51f0fbe7d6d25be72a95486779d9d98122283b4e8032fb40/biopython-1.86-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:187c3c24dd2255e7328f3e0523ab5d6350b73ff562517de0c1922385617101d2", size = 3209367, upload-time = "2025-10-29T00:36:06.522Z" }, + { url = "https://files.pythonhosted.org/packages/d7/cb/61877367bf08670573d62513b239dc65cf2b7488dc74322cc6051da2e55e/biopython-1.86-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1859830b8262785c6b59dfe0c82cddb643974f63b9d2779bb9f3e2c47c0a95da", size = 3235466, upload-time = "2025-10-29T00:36:11.516Z" }, + { url = "https://files.pythonhosted.org/packages/84/1a/3182a77776b76f3f5c64825ee1acf9355f665bed72ee9e8ff49e48f25d98/biopython-1.86-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dfd906c47b6fb38e3abb9f52e0c06822e6e82a043d38c2000773692c29db1ed8", size = 3178776, upload-time = "2025-10-28T23:53:41.487Z" }, + { url = "https://files.pythonhosted.org/packages/1a/22/828b08fac8dbc8c1dbc1ad03815137cebc9c78303ec7d21b568544028119/biopython-1.86-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a6ab2c60742f1c8494cfbbe3b7a8b45f0400c8f2b36b686b895d5e4d625f04e", size = 3197586, upload-time = "2025-10-28T23:53:47.136Z" }, + { url = "https://files.pythonhosted.org/packages/36/7a/122aea7653fa93d7eb72978928e80759082efffa70afe0c25a17e18521da/biopython-1.86-cp312-cp312-win32.whl", hash = "sha256:192c61bc3d782c171b7d50bb7d8189d84790d6e3c4b24fd41d1d7ffc7d303efe", size = 2698043, upload-time = "2025-10-28T21:32:39.452Z" }, + { url = "https://files.pythonhosted.org/packages/a9/13/00db03b01e54070d5b0ec9c71eef86e61afa733d9af76e5b9b09f5dc9165/biopython-1.86-cp312-cp312-win_amd64.whl", hash = "sha256:35a6b9c5dcdfb5c2631a313a007f3f41a7d72573ba2b68c962e10ea92096ff3b", size = 2733610, upload-time = "2025-10-28T21:32:34.99Z" }, + { url = "https://files.pythonhosted.org/packages/fd/6e/84d6c66ab93095aa7adb998a8eef045328470eafd36b9237c4db213e587c/biopython-1.86-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fb3a11a98e49428720dca227e2a5bdd57c973ee7c4df3cf6734c0aa13fd134c7", size = 2693185, upload-time = "2025-10-28T21:27:39.709Z" }, + { url = "https://files.pythonhosted.org/packages/12/75/60386f2640f13765b1651f2f26d8b4f893c46ee663df3ca76eda966d4f6a/biopython-1.86-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e161f3d3b6e65fbfd1ce22a01c3e9fa9da789adde4972fd0cc2370795ea5357b", size = 2669980, upload-time = "2025-10-28T21:26:58.839Z" }, + { url = "https://files.pythonhosted.org/packages/dd/de/a39adb98a0552a257219503c236ef17f007598af55326c0d143db52e5a92/biopython-1.86-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5aa8c9e92ee6fe59dfe0d2c2daf9a9eec6b812c78328caad038f79163c500218", size = 3209657, upload-time = "2025-10-29T00:36:28.842Z" }, + { url = "https://files.pythonhosted.org/packages/0b/c7/b2e7aca3de8981f4ecb6ab1e0334c3c4a512e5e9898b57b3d8734b086da7/biopython-1.86-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:593ec6a2a4fedec08ddcee1a8a0e0b0ed56835b2714904b352ec4a93d5b9d973", size = 3235774, upload-time = "2025-10-29T00:36:34.07Z" }, + { url = "https://files.pythonhosted.org/packages/52/ed/e6647b0b9cf2bb67347612e8e443b84378c44768a8d8439276e4ba881178/biopython-1.86-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd2f9ebf9b14d67ca92f48779c4f0ba404c35dba3e8b9d6c34d1a3591c3b746d", size = 3178415, upload-time = "2025-10-28T23:54:05.475Z" }, + { url = "https://files.pythonhosted.org/packages/ff/37/f6a14b835842c66a52f212136a99416265f5ce76813d668ceac1cb306357/biopython-1.86-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:137fe9aafd93baa5127d17534b473f6646f92a883f52b34f7c306b800ac50038", size = 3197201, upload-time = "2025-10-28T23:54:10.462Z" }, + { url = "https://files.pythonhosted.org/packages/f2/73/0eac930016c509763c174a0e25e92e6d7a711f6f5de1f7001e54fd5c49f7/biopython-1.86-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e784dc8382430c9893aa084ca18fe8a8815b5811f1c324492ef3f4b54e664fff", size = 3145106, upload-time = "2025-10-28T23:54:15.235Z" }, + { url = "https://files.pythonhosted.org/packages/00/aa/26e836274d03402e8011b04a1714d4ac2f704add303a493e54d2d5646973/biopython-1.86-cp313-cp313-win32.whl", hash = "sha256:5329a777ba90ea624447173046e77c4df2862acc46eea4e94fe2211fe041750f", size = 2698051, upload-time = "2025-10-28T21:32:55.225Z" }, + { url = "https://files.pythonhosted.org/packages/ae/27/fa1f8fa57f2ac8fdc41d14ab36001b8ba0fce5eac01585227b99a4da0e9d/biopython-1.86-cp313-cp313-win_amd64.whl", hash = "sha256:f6f2f1dc75423b15d8a22b8eceae32785736612b6740688526401b8c2d821270", size = 2733649, upload-time = "2025-10-28T21:32:51.052Z" }, +] + +[[package]] +name = "bleach" +version = "6.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/18/3c8523962314be6bf4c8989c79ad9531c825210dd13a8669f6b84336e8bd/bleach-6.3.0.tar.gz", hash = "sha256:6f3b91b1c0a02bb9a78b5a454c92506aa0fdf197e1d5e114d2e00c6f64306d22", size = 203533, upload-time = "2025-10-27T17:57:39.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/3a/577b549de0cc09d95f11087ee63c739bba856cd3952697eec4c4bb91350a/bleach-6.3.0-py3-none-any.whl", hash = "sha256:fe10ec77c93ddf3d13a73b035abaac7a9f5e436513864ccdad516693213c65d6", size = 164437, upload-time = "2025-10-27T17:57:37.538Z" }, +] + +[package.optional-dependencies] +css = [ + { name = "tinycss2" }, +] + +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, +] + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" }, + { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" }, + { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, + { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, + { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, + { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, + { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, + { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, + { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, + { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, + { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, +] + +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + +[[package]] +name = "chardet" +version = "7.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/80/4684035f1a2a3096506bc377276a815ccf0be3c3316eab35d589e82d9f3c/chardet-7.0.1.tar.gz", hash = "sha256:6fce895c12c5495bb598e59ae3cd89306969b4464ec7b6dd609b9c86e3397fe3", size = 490240, upload-time = "2026-03-04T21:25:26.97Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/97/07c01ad079ede646f241fe34de7686f2385e0deae4feb36ca2041a9ed059/chardet-7.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8a8d87853c7f191029933307094a8896b087c2c436703281cb289a22aa4ae8bd", size = 542016, upload-time = "2026-03-04T21:24:41.685Z" }, + { url = "https://files.pythonhosted.org/packages/a7/a2/5f9afb10c47852de7bd2399e25dd72fe3884b16b79a195c230e9e4affd4f/chardet-7.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fb14755377d8de845c69378bbaedc0e35109c21a43824450524fd9c3178792d5", size = 535149, upload-time = "2026-03-04T21:24:43.585Z" }, + { url = "https://files.pythonhosted.org/packages/c0/e4/47a9306a1c5757e86309f558d0e206d71842efb7b5109ab8e5991a63e926/chardet-7.0.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4af34cf0652a9da44720540c97f11e30781a77900c89547b311984a7272b33f7", size = 554683, upload-time = "2026-03-04T21:24:45.376Z" }, + { url = "https://files.pythonhosted.org/packages/23/b3/7494df94d362bc5602fdb7bd3df20afc9d6005c6e781030c1415c40e812f/chardet-7.0.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54e448fab0c11b27bb908ea0218e2094578c583d05faa5f65b91fa6ccfa45570", size = 557300, upload-time = "2026-03-04T21:24:47.126Z" }, + { url = "https://files.pythonhosted.org/packages/5e/bb/388f15997240ea245087e66a258ed301247f84cd34328dd8f73a6bba9184/chardet-7.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:69708a504a43464b60ea16d031250b58206969c9bbd6851266e2f39afef53168", size = 524154, upload-time = "2026-03-04T21:24:49.243Z" }, + { url = "https://files.pythonhosted.org/packages/00/fb/a90b4510aa9080966c65321db2084bcfa184518ee1ed15570d351649ecb2/chardet-7.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c3f59dc3e148b54813ec5c7b4b2e025d37f5dc221ee28a06d1a62f169cfaedf5", size = 540100, upload-time = "2026-03-04T21:24:50.883Z" }, + { url = "https://files.pythonhosted.org/packages/24/fa/3ad0b454a55376b7971fe64c2f225dfe56a491d8d8728fbfba63f8ff416d/chardet-7.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3355a3c8453d673e7c1664fdd24a0c6ef39964c3d41befc4849250f7eb1de3b5", size = 533202, upload-time = "2026-03-04T21:24:52.253Z" }, + { url = "https://files.pythonhosted.org/packages/ad/53/a57a8a6be34379e55c8bdbf2b988c145d3b7675577bd152e73bff7c4ba3c/chardet-7.0.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5333f9967863ea7d8642df0e00cf4d33e8ed7e99fe7b6464b40ba969a2808544", size = 552994, upload-time = "2026-03-04T21:24:53.923Z" }, + { url = "https://files.pythonhosted.org/packages/e4/9f/3d4ba1650e3eb3e7431a054e3bf1b5eaea25b84c72afabf5ef6fc33305d1/chardet-7.0.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:265cb3b5dafc0411c0949800a0692f07e986fb663b6ae1ecfba32ad193a55a03", size = 555605, upload-time = "2026-03-04T21:24:55.647Z" }, + { url = "https://files.pythonhosted.org/packages/73/64/9c5c450ba18359a8e8ab2943e6c3a0b100bd394799bc73a844e3c5cd9c7c/chardet-7.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:26186f0ea03c4c1f9be20c088b127c71b0e9d487676930fab77625ddec2a4ef2", size = 524098, upload-time = "2026-03-04T21:24:57.3Z" }, + { url = "https://files.pythonhosted.org/packages/f6/88/4c6fe7dcd5d36a2cfd7030084fbd79264083f329faaf96038c23888a8e05/chardet-7.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f661edbfa77b8683a503043ddc9b9fe9036cf28af13064200e11fa1844ded79c", size = 541828, upload-time = "2026-03-04T21:24:58.726Z" }, + { url = "https://files.pythonhosted.org/packages/f9/fb/3b92a2433eadef83ae131fa720a17857cfbf7687c5f188bfb2f9eee2d3dd/chardet-7.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:169951fa88d449e72e0c6194cec1c5e405fd36a6cfbe74c7dab5494cc35f1700", size = 533571, upload-time = "2026-03-04T21:25:00.703Z" }, + { url = "https://files.pythonhosted.org/packages/d9/75/37bee6900183ea08a3a0ae04b9f018f9e64c6b10716e1f7b423db0c4356c/chardet-7.0.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd6db7505556ae8f9e2a3bf6d689c2b86aa6b459cf39552645d2c4d3fdbf489c", size = 554182, upload-time = "2026-03-04T21:25:02.168Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ed/2fe5ea435ae480bd3a76be1415920ce52b3ff6e188d8eab6a635d6a2a1d1/chardet-7.0.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f907962b18df78d5ca87a7484e4034354408d2c97cec6f53634b0ea0424c594", size = 557933, upload-time = "2026-03-04T21:25:03.694Z" }, + { url = "https://files.pythonhosted.org/packages/07/ba/7ca89301e492ac4184ba7f4736565d954ba3125acf6bf02c66a38a802bda/chardet-7.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:302798e1e62008ca34a216dd04ecc5e240993b2090628e2a35d4c0754313ea9a", size = 524256, upload-time = "2026-03-04T21:25:05.581Z" }, + { url = "https://files.pythonhosted.org/packages/56/26/1a22b9a19b4ca167ca462eaf91d0fc31285874d80b0381c55fdc5bc5f066/chardet-7.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:67fe3f453416ed9343057dcf06583b36aae6d8bdb013370b3ff46bc37b7e30ac", size = 541652, upload-time = "2026-03-04T21:25:07.041Z" }, + { url = "https://files.pythonhosted.org/packages/24/fe/2f2425f3b0801e897653723ee827bc87e5a0feacf826ab268a9216680615/chardet-7.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:63bc210ce73f8a1b87430b949f84d086cb326d67eb259305862e7c8861b73374", size = 533333, upload-time = "2026-03-04T21:25:08.886Z" }, + { url = "https://files.pythonhosted.org/packages/b2/8c/6b5f4b49c471b396bdbddad55b569e05d686ea65d91795dae6c774b285f0/chardet-7.0.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:11f51985946b49739968b6dc2fa70e7d8f490bb15574377c5ee114f33d19ef7e", size = 553815, upload-time = "2026-03-04T21:25:10.861Z" }, + { url = "https://files.pythonhosted.org/packages/b9/45/860a82d618e5c3930faef0a0fe205b752323e5d10ce0c18fe5016fd4f8d2/chardet-7.0.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8714f0013c208452a98e23595d99cef53c5364565454425f431446eb586e2591", size = 557506, upload-time = "2026-03-04T21:25:14.081Z" }, + { url = "https://files.pythonhosted.org/packages/ed/44/7acb8f84fc7b5ad3c977ac31865b308881da1c0a6ca58be35554d2473dd7/chardet-7.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:c12abc65830068ad05bd257fb953aaaf63a551446688e03e145522086be5738c", size = 524145, upload-time = "2026-03-04T21:25:15.696Z" }, + { url = "https://files.pythonhosted.org/packages/a3/1f/c1a089db6333b1283409cad3714b8935e7e56722c9c60f9299726a1e57c2/chardet-7.0.1-py3-none-any.whl", hash = "sha256:e51e1ff2c51b2d622d97c9737bd5ee9d9b9038f05b7dd8f9ea10b9e2d9674c24", size = 408292, upload-time = "2026-03-04T21:25:25.214Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/35/02daf95b9cd686320bb622eb148792655c9412dbb9b67abb5694e5910a24/charset_normalizer-3.4.5.tar.gz", hash = "sha256:95adae7b6c42a6c5b5b559b1a99149f090a57128155daeea91732c8d970d8644", size = 134804, upload-time = "2026-03-06T06:03:19.46Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/21/a2b1505639008ba2e6ef03733a81fc6cfd6a07ea6139a2b76421230b8dad/charset_normalizer-3.4.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4167a621a9a1a986c73777dbc15d4b5eac8ac5c10393374109a343d4013ec765", size = 283319, upload-time = "2026-03-06T06:00:26.433Z" }, + { url = "https://files.pythonhosted.org/packages/70/67/df234c29b68f4e1e095885c9db1cb4b69b8aba49cf94fac041db4aaf1267/charset_normalizer-3.4.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f64c6bf8f32f9133b668c7f7a7cbdbc453412bc95ecdbd157f3b1e377a92990", size = 189974, upload-time = "2026-03-06T06:00:28.222Z" }, + { url = "https://files.pythonhosted.org/packages/df/7f/fc66af802961c6be42e2c7b69c58f95cbd1f39b0e81b3365d8efe2a02a04/charset_normalizer-3.4.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:568e3c34b58422075a1b49575a6abc616d9751b4d61b23f712e12ebb78fe47b2", size = 207866, upload-time = "2026-03-06T06:00:29.769Z" }, + { url = "https://files.pythonhosted.org/packages/c9/23/404eb36fac4e95b833c50e305bba9a241086d427bb2167a42eac7c4f7da4/charset_normalizer-3.4.5-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:036c079aa08a6a592b82487f97c60b439428320ed1b2ea0b3912e99d30c77765", size = 203239, upload-time = "2026-03-06T06:00:31.086Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2f/8a1d989bfadd120c90114ab33e0d2a0cbde05278c1fc15e83e62d570f50a/charset_normalizer-3.4.5-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:340810d34ef83af92148e96e3e44cb2d3f910d2bf95e5618a5c467d9f102231d", size = 196529, upload-time = "2026-03-06T06:00:32.608Z" }, + { url = "https://files.pythonhosted.org/packages/a5/0c/c75f85ff7ca1f051958bb518cd43922d86f576c03947a050fbedfdfb4f15/charset_normalizer-3.4.5-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:cd2d0f0ec9aa977a27731a3209ebbcacebebaf41f902bd453a928bfd281cf7f8", size = 184152, upload-time = "2026-03-06T06:00:33.93Z" }, + { url = "https://files.pythonhosted.org/packages/f9/20/4ed37f6199af5dde94d4aeaf577f3813a5ec6635834cda1d957013a09c76/charset_normalizer-3.4.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0b362bcd27819f9c07cbf23db4e0e8cd4b44c5ecd900c2ff907b2b92274a7412", size = 195226, upload-time = "2026-03-06T06:00:35.469Z" }, + { url = "https://files.pythonhosted.org/packages/28/31/7ba1102178cba7c34dcc050f43d427172f389729e356038f0726253dd914/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:77be992288f720306ab4108fe5c74797de327f3248368dfc7e1a916d6ed9e5a2", size = 192933, upload-time = "2026-03-06T06:00:36.83Z" }, + { url = "https://files.pythonhosted.org/packages/4b/23/f86443ab3921e6a60b33b93f4a1161222231f6c69bc24fb18f3bee7b8518/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:8b78d8a609a4b82c273257ee9d631ded7fac0d875bdcdccc109f3ee8328cfcb1", size = 185647, upload-time = "2026-03-06T06:00:38.367Z" }, + { url = "https://files.pythonhosted.org/packages/82/44/08b8be891760f1f5a6d23ce11d6d50c92981603e6eb740b4f72eea9424e2/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ba20bdf69bd127f66d0174d6f2a93e69045e0b4036dc1ca78e091bcc765830c4", size = 209533, upload-time = "2026-03-06T06:00:41.931Z" }, + { url = "https://files.pythonhosted.org/packages/3b/5f/df114f23406199f8af711ddccfbf409ffbc5b7cdc18fa19644997ff0c9bb/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:76a9d0de4d0eab387822e7b35d8f89367dd237c72e82ab42b9f7bf5e15ada00f", size = 195901, upload-time = "2026-03-06T06:00:43.978Z" }, + { url = "https://files.pythonhosted.org/packages/07/83/71ef34a76fe8aa05ff8f840244bda2d61e043c2ef6f30d200450b9f6a1be/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8fff79bf5978c693c9b1a4d71e4a94fddfb5fe744eb062a318e15f4a2f63a550", size = 204950, upload-time = "2026-03-06T06:00:45.202Z" }, + { url = "https://files.pythonhosted.org/packages/58/40/0253be623995365137d7dc68e45245036207ab2227251e69a3d93ce43183/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c7e84e0c0005e3bdc1a9211cd4e62c78ba80bc37b2365ef4410cd2007a9047f2", size = 198546, upload-time = "2026-03-06T06:00:46.481Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5c/5f3cb5b259a130895ef5ae16b38eaf141430fa3f7af50cd06c5d67e4f7b2/charset_normalizer-3.4.5-cp310-cp310-win32.whl", hash = "sha256:58ad8270cfa5d4bef1bc85bd387217e14ff154d6630e976c6f56f9a040757475", size = 132516, upload-time = "2026-03-06T06:00:47.924Z" }, + { url = "https://files.pythonhosted.org/packages/a5/c3/84fb174e7770f2df2e1a2115090771bfbc2227fb39a765c6d00568d1aab4/charset_normalizer-3.4.5-cp310-cp310-win_amd64.whl", hash = "sha256:02a9d1b01c1e12c27883b0c9349e0bcd9ae92e727ff1a277207e1a262b1cbf05", size = 142906, upload-time = "2026-03-06T06:00:49.389Z" }, + { url = "https://files.pythonhosted.org/packages/d7/b2/6f852f8b969f2cbd0d4092d2e60139ab1af95af9bb651337cae89ec0f684/charset_normalizer-3.4.5-cp310-cp310-win_arm64.whl", hash = "sha256:039215608ac7b358c4da0191d10fc76868567fbf276d54c14721bdedeb6de064", size = 133258, upload-time = "2026-03-06T06:00:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/8f/9e/bcec3b22c64ecec47d39bf5167c2613efd41898c019dccd4183f6aa5d6a7/charset_normalizer-3.4.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:610f72c0ee565dfb8ae1241b666119582fdbfe7c0975c175be719f940e110694", size = 279531, upload-time = "2026-03-06T06:00:52.252Z" }, + { url = "https://files.pythonhosted.org/packages/58/12/81fd25f7e7078ab5d1eedbb0fac44be4904ae3370a3bf4533c8f2d159acd/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60d68e820af339df4ae8358c7a2e7596badeb61e544438e489035f9fbf3246a5", size = 188006, upload-time = "2026-03-06T06:00:53.8Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6e/f2d30e8c27c1b0736a6520311982cf5286cfc7f6cac77d7bc1325e3a23f2/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b473fc8dca1c3ad8559985794815f06ca3fc71942c969129070f2c3cdf7281", size = 205085, upload-time = "2026-03-06T06:00:55.311Z" }, + { url = "https://files.pythonhosted.org/packages/d0/90/d12cefcb53b5931e2cf792a33718d7126efb116a320eaa0742c7059a95e4/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d4eb8ac7469b2a5d64b5b8c04f84d8bf3ad340f4514b98523805cbf46e3b3923", size = 200545, upload-time = "2026-03-06T06:00:56.532Z" }, + { url = "https://files.pythonhosted.org/packages/03/f4/44d3b830a20e89ff82a3134912d9a1cf6084d64f3b95dcad40f74449a654/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bcb3227c3d9aaf73eaaab1db7ccd80a8995c509ee9941e2aae060ca6e4e5d81", size = 193863, upload-time = "2026-03-06T06:00:57.823Z" }, + { url = "https://files.pythonhosted.org/packages/25/4b/f212119c18a6320a9d4a730d1b4057875cdeabf21b3614f76549042ef8a8/charset_normalizer-3.4.5-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:75ee9c1cce2911581a70a3c0919d8bccf5b1cbc9b0e5171400ec736b4b569497", size = 181827, upload-time = "2026-03-06T06:00:59.323Z" }, + { url = "https://files.pythonhosted.org/packages/74/00/b26158e48b425a202a92965f8069e8a63d9af1481dfa206825d7f74d2a3c/charset_normalizer-3.4.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d1401945cb77787dbd3af2446ff2d75912327c4c3a1526ab7955ecf8600687c", size = 191085, upload-time = "2026-03-06T06:01:00.546Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/1c1737bf6fd40335fe53d28fe49afd99ee4143cc57a845e99635ce0b9b6d/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a45e504f5e1be0bd385935a8e1507c442349ca36f511a47057a71c9d1d6ea9e", size = 190688, upload-time = "2026-03-06T06:01:02.479Z" }, + { url = "https://files.pythonhosted.org/packages/5a/3d/abb5c22dc2ef493cd56522f811246a63c5427c08f3e3e50ab663de27fcf4/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e09f671a54ce70b79a1fc1dc6da3072b7ef7251fadb894ed92d9aa8218465a5f", size = 183077, upload-time = "2026-03-06T06:01:04.231Z" }, + { url = "https://files.pythonhosted.org/packages/44/33/5298ad4d419a58e25b3508e87f2758d1442ff00c2471f8e0403dab8edad5/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d01de5e768328646e6a3fa9e562706f8f6641708c115c62588aef2b941a4f88e", size = 206706, upload-time = "2026-03-06T06:01:05.773Z" }, + { url = "https://files.pythonhosted.org/packages/7b/17/51e7895ac0f87c3b91d276a449ef09f5532a7529818f59646d7a55089432/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:131716d6786ad5e3dc542f5cc6f397ba3339dc0fb87f87ac30e550e8987756af", size = 191665, upload-time = "2026-03-06T06:01:07.473Z" }, + { url = "https://files.pythonhosted.org/packages/90/8f/cce9adf1883e98906dbae380d769b4852bb0fa0004bc7d7a2243418d3ea8/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a374cc0b88aa710e8865dc1bd6edb3743c59f27830f0293ab101e4cf3ce9f85", size = 201950, upload-time = "2026-03-06T06:01:08.973Z" }, + { url = "https://files.pythonhosted.org/packages/08/ca/bce99cd5c397a52919e2769d126723f27a4c037130374c051c00470bcd38/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d31f0d1671e1534e395f9eb84a68e0fb670e1edb1fe819a9d7f564ae3bc4e53f", size = 195830, upload-time = "2026-03-06T06:01:10.155Z" }, + { url = "https://files.pythonhosted.org/packages/87/4f/2e3d023a06911f1281f97b8f036edc9872167036ca6f55cc874a0be6c12c/charset_normalizer-3.4.5-cp311-cp311-win32.whl", hash = "sha256:cace89841c0599d736d3d74a27bc5821288bb47c5441923277afc6059d7fbcb4", size = 132029, upload-time = "2026-03-06T06:01:11.706Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1f/a853b73d386521fd44b7f67ded6b17b7b2367067d9106a5c4b44f9a34274/charset_normalizer-3.4.5-cp311-cp311-win_amd64.whl", hash = "sha256:f8102ae93c0bc863b1d41ea0f4499c20a83229f52ed870850892df555187154a", size = 142404, upload-time = "2026-03-06T06:01:12.865Z" }, + { url = "https://files.pythonhosted.org/packages/b4/10/dba36f76b71c38e9d391abe0fd8a5b818790e053c431adecfc98c35cd2a9/charset_normalizer-3.4.5-cp311-cp311-win_arm64.whl", hash = "sha256:ed98364e1c262cf5f9363c3eca8c2df37024f52a8fa1180a3610014f26eac51c", size = 132796, upload-time = "2026-03-06T06:01:14.106Z" }, + { url = "https://files.pythonhosted.org/packages/9c/b6/9ee9c1a608916ca5feae81a344dffbaa53b26b90be58cc2159e3332d44ec/charset_normalizer-3.4.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ed97c282ee4f994ef814042423a529df9497e3c666dca19be1d4cd1129dc7ade", size = 280976, upload-time = "2026-03-06T06:01:15.276Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d8/a54f7c0b96f1df3563e9190f04daf981e365a9b397eedfdfb5dbef7e5c6c/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0294916d6ccf2d069727d65973c3a1ca477d68708db25fd758dd28b0827cff54", size = 189356, upload-time = "2026-03-06T06:01:16.511Z" }, + { url = "https://files.pythonhosted.org/packages/42/69/2bf7f76ce1446759a5787cb87d38f6a61eb47dbbdf035cfebf6347292a65/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dc57a0baa3eeedd99fafaef7511b5a6ef4581494e8168ee086031744e2679467", size = 206369, upload-time = "2026-03-06T06:01:17.853Z" }, + { url = "https://files.pythonhosted.org/packages/10/9c/949d1a46dab56b959d9a87272482195f1840b515a3380e39986989a893ae/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ed1a9a204f317ef879b32f9af507d47e49cd5e7f8e8d5d96358c98373314fc60", size = 203285, upload-time = "2026-03-06T06:01:19.473Z" }, + { url = "https://files.pythonhosted.org/packages/67/5c/ae30362a88b4da237d71ea214a8c7eb915db3eec941adda511729ac25fa2/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ad83b8f9379176c841f8865884f3514d905bcd2a9a3b210eaa446e7d2223e4d", size = 196274, upload-time = "2026-03-06T06:01:20.728Z" }, + { url = "https://files.pythonhosted.org/packages/b2/07/c9f2cb0e46cb6d64fdcc4f95953747b843bb2181bda678dc4e699b8f0f9a/charset_normalizer-3.4.5-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:a118e2e0b5ae6b0120d5efa5f866e58f2bb826067a646431da4d6a2bdae7950e", size = 184715, upload-time = "2026-03-06T06:01:22.194Z" }, + { url = "https://files.pythonhosted.org/packages/36/64/6b0ca95c44fddf692cd06d642b28f63009d0ce325fad6e9b2b4d0ef86a52/charset_normalizer-3.4.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:754f96058e61a5e22e91483f823e07df16416ce76afa4ebf306f8e1d1296d43f", size = 193426, upload-time = "2026-03-06T06:01:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/50/bc/a730690d726403743795ca3f5bb2baf67838c5fea78236098f324b965e40/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0c300cefd9b0970381a46394902cd18eaf2aa00163f999590ace991989dcd0fc", size = 191780, upload-time = "2026-03-06T06:01:25.053Z" }, + { url = "https://files.pythonhosted.org/packages/97/4f/6c0bc9af68222b22951552d73df4532b5be6447cee32d58e7e8c74ecbb7b/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c108f8619e504140569ee7de3f97d234f0fbae338a7f9f360455071ef9855a95", size = 185805, upload-time = "2026-03-06T06:01:26.294Z" }, + { url = "https://files.pythonhosted.org/packages/dd/b9/a523fb9b0ee90814b503452b2600e4cbc118cd68714d57041564886e7325/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:d1028de43596a315e2720a9849ee79007ab742c06ad8b45a50db8cdb7ed4a82a", size = 208342, upload-time = "2026-03-06T06:01:27.55Z" }, + { url = "https://files.pythonhosted.org/packages/4d/61/c59e761dee4464050713e50e27b58266cc8e209e518c0b378c1580c959ba/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:19092dde50335accf365cce21998a1c6dd8eafd42c7b226eb54b2747cdce2fac", size = 193661, upload-time = "2026-03-06T06:01:29.051Z" }, + { url = "https://files.pythonhosted.org/packages/1c/43/729fa30aad69783f755c5ad8649da17ee095311ca42024742701e202dc59/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4354e401eb6dab9aed3c7b4030514328a6c748d05e1c3e19175008ca7de84fb1", size = 204819, upload-time = "2026-03-06T06:01:30.298Z" }, + { url = "https://files.pythonhosted.org/packages/87/33/d9b442ce5a91b96fc0840455a9e49a611bbadae6122778d0a6a79683dd31/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a68766a3c58fde7f9aaa22b3786276f62ab2f594efb02d0a1421b6282e852e98", size = 198080, upload-time = "2026-03-06T06:01:31.478Z" }, + { url = "https://files.pythonhosted.org/packages/56/5a/b8b5a23134978ee9885cee2d6995f4c27cc41f9baded0a9685eabc5338f0/charset_normalizer-3.4.5-cp312-cp312-win32.whl", hash = "sha256:1827734a5b308b65ac54e86a618de66f935a4f63a8a462ff1e19a6788d6c2262", size = 132630, upload-time = "2026-03-06T06:01:33.056Z" }, + { url = "https://files.pythonhosted.org/packages/70/53/e44a4c07e8904500aec95865dc3f6464dc3586a039ef0df606eb3ac38e35/charset_normalizer-3.4.5-cp312-cp312-win_amd64.whl", hash = "sha256:728c6a963dfab66ef865f49286e45239384249672cd598576765acc2a640a636", size = 142856, upload-time = "2026-03-06T06:01:34.489Z" }, + { url = "https://files.pythonhosted.org/packages/ea/aa/c5628f7cad591b1cf45790b7a61483c3e36cf41349c98af7813c483fd6e8/charset_normalizer-3.4.5-cp312-cp312-win_arm64.whl", hash = "sha256:75dfd1afe0b1647449e852f4fb428195a7ed0588947218f7ba929f6538487f02", size = 132982, upload-time = "2026-03-06T06:01:35.641Z" }, + { url = "https://files.pythonhosted.org/packages/f5/48/9f34ec4bb24aa3fdba1890c1bddb97c8a4be1bd84ef5c42ac2352563ad05/charset_normalizer-3.4.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac59c15e3f1465f722607800c68713f9fbc2f672b9eb649fe831da4019ae9b23", size = 280788, upload-time = "2026-03-06T06:01:37.126Z" }, + { url = "https://files.pythonhosted.org/packages/0e/09/6003e7ffeb90cc0560da893e3208396a44c210c5ee42efff539639def59b/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:165c7b21d19365464e8f70e5ce5e12524c58b48c78c1f5a57524603c1ab003f8", size = 188890, upload-time = "2026-03-06T06:01:38.73Z" }, + { url = "https://files.pythonhosted.org/packages/42/1e/02706edf19e390680daa694d17e2b8eab4b5f7ac285e2a51168b4b22ee6b/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:28269983f25a4da0425743d0d257a2d6921ea7d9b83599d4039486ec5b9f911d", size = 206136, upload-time = "2026-03-06T06:01:40.016Z" }, + { url = "https://files.pythonhosted.org/packages/c7/87/942c3def1b37baf3cf786bad01249190f3ca3d5e63a84f831e704977de1f/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d27ce22ec453564770d29d03a9506d449efbb9fa13c00842262b2f6801c48cce", size = 202551, upload-time = "2026-03-06T06:01:41.522Z" }, + { url = "https://files.pythonhosted.org/packages/94/0a/af49691938dfe175d71b8a929bd7e4ace2809c0c5134e28bc535660d5262/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0625665e4ebdddb553ab185de5db7054393af8879fb0c87bd5690d14379d6819", size = 195572, upload-time = "2026-03-06T06:01:43.208Z" }, + { url = "https://files.pythonhosted.org/packages/20/ea/dfb1792a8050a8e694cfbde1570ff97ff74e48afd874152d38163d1df9ae/charset_normalizer-3.4.5-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:c23eb3263356d94858655b3e63f85ac5d50970c6e8febcdde7830209139cc37d", size = 184438, upload-time = "2026-03-06T06:01:44.755Z" }, + { url = "https://files.pythonhosted.org/packages/72/12/c281e2067466e3ddd0595bfaea58a6946765ace5c72dfa3edc2f5f118026/charset_normalizer-3.4.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e6302ca4ae283deb0af68d2fbf467474b8b6aedcd3dab4db187e07f94c109763", size = 193035, upload-time = "2026-03-06T06:01:46.051Z" }, + { url = "https://files.pythonhosted.org/packages/ba/4f/3792c056e7708e10464bad0438a44708886fb8f92e3c3d29ec5e2d964d42/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e51ae7d81c825761d941962450f50d041db028b7278e7b08930b4541b3e45cb9", size = 191340, upload-time = "2026-03-06T06:01:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/e7/86/80ddba897127b5c7a9bccc481b0cd36c8fefa485d113262f0fe4332f0bf4/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:597d10dec876923e5c59e48dbd366e852eacb2b806029491d307daea6b917d7c", size = 185464, upload-time = "2026-03-06T06:01:48.764Z" }, + { url = "https://files.pythonhosted.org/packages/4d/00/b5eff85ba198faacab83e0e4b6f0648155f072278e3b392a82478f8b988b/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5cffde4032a197bd3b42fd0b9509ec60fb70918d6970e4cc773f20fc9180ca67", size = 208014, upload-time = "2026-03-06T06:01:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/c8/11/d36f70be01597fd30850dde8a1269ebc8efadd23ba5785808454f2389bde/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2da4eedcb6338e2321e831a0165759c0c620e37f8cd044a263ff67493be8ffb3", size = 193297, upload-time = "2026-03-06T06:01:51.933Z" }, + { url = "https://files.pythonhosted.org/packages/1a/1d/259eb0a53d4910536c7c2abb9cb25f4153548efb42800c6a9456764649c0/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:65a126fb4b070d05340a84fc709dd9e7c75d9b063b610ece8a60197a291d0adf", size = 204321, upload-time = "2026-03-06T06:01:53.887Z" }, + { url = "https://files.pythonhosted.org/packages/84/31/faa6c5b9d3688715e1ed1bb9d124c384fe2fc1633a409e503ffe1c6398c1/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7a80a9242963416bd81f99349d5f3fce1843c303bd404f204918b6d75a75fd6", size = 197509, upload-time = "2026-03-06T06:01:56.439Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a5/c7d9dd1503ffc08950b3260f5d39ec2366dd08254f0900ecbcf3a6197c7c/charset_normalizer-3.4.5-cp313-cp313-win32.whl", hash = "sha256:f1d725b754e967e648046f00c4facc42d414840f5ccc670c5670f59f83693e4f", size = 132284, upload-time = "2026-03-06T06:01:57.812Z" }, + { url = "https://files.pythonhosted.org/packages/b9/0f/57072b253af40c8aa6636e6de7d75985624c1eb392815b2f934199340a89/charset_normalizer-3.4.5-cp313-cp313-win_amd64.whl", hash = "sha256:e37bd100d2c5d3ba35db9c7c5ba5a9228cbcffe5c4778dc824b164e5257813d7", size = 142630, upload-time = "2026-03-06T06:01:59.062Z" }, + { url = "https://files.pythonhosted.org/packages/31/41/1c4b7cc9f13bd9d369ce3bc993e13d374ce25fa38a2663644283ecf422c1/charset_normalizer-3.4.5-cp313-cp313-win_arm64.whl", hash = "sha256:93b3b2cc5cf1b8743660ce77a4f45f3f6d1172068207c1defc779a36eea6bb36", size = 133254, upload-time = "2026-03-06T06:02:00.281Z" }, + { url = "https://files.pythonhosted.org/packages/c5/60/3a621758945513adfd4db86827a5bafcc615f913dbd0b4c2ed64a65731be/charset_normalizer-3.4.5-py3-none-any.whl", hash = "sha256:9db5e3fcdcee89a78c04dffb3fe33c79f77bd741a624946db2591c81b2fc85b0", size = 55455, upload-time = "2026-03-06T06:03:17.827Z" }, +] + +[[package]] +name = "choreographer" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "logistro" }, + { name = "simplejson" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/47/64a035c6f764450ea9f902cbeba14c8c70316c2641125510066d8f912bfa/choreographer-1.2.1.tar.gz", hash = "sha256:022afd72b1e9b0bcb950420b134e70055a294c791b6f36cfb47d89745b701b5f", size = 43399, upload-time = "2025-11-09T23:04:44.749Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/9f/d73dfb85d7a5b1a56a99adc50f2074029468168c970ff5daeade4ad819e4/choreographer-1.2.1-py3-none-any.whl", hash = "sha256:9af5385effa3c204dbc337abf7ac74fd8908ced326a15645dc31dde75718c77e", size = 49338, upload-time = "2025-11-09T23:04:43.154Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "comm" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/13/7d740c5849255756bc17888787313b61fd38a0a8304fc4f073dfc46122aa/comm-0.2.3.tar.gz", hash = "sha256:2dc8048c10962d55d7ad693be1e7045d891b7ce8d999c97963a5e3e99c055971", size = 6319, upload-time = "2025-07-25T14:02:04.452Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl", hash = "sha256:c615d91d75f7f04f095b30d1c1711babd43bdc6419c1be9886a85f2f4e489417", size = 7294, upload-time = "2025-07-25T14:02:02.896Z" }, +] + +[[package]] +name = "commitizen" +version = "4.13.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argcomplete" }, + { name = "charset-normalizer" }, + { name = "colorama" }, + { name = "decli" }, + { name = "deprecated" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "prompt-toolkit" }, + { name = "pyyaml" }, + { name = "questionary" }, + { name = "termcolor" }, + { name = "tomlkit" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/44/10f95e8178ab5a584298726a4a94ceb83a7f77e00741fec4680df05fedd5/commitizen-4.13.9.tar.gz", hash = "sha256:2b4567ed50555e10920e5bd804a6a4e2c42ec70bb74f14a83f2680fe9eaf9727", size = 64145, upload-time = "2026-02-25T02:40:05.326Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/22/9b14ee0f17f0aad219a2fb37a293a57b8324d9d195c6ef6807bcd0bf2055/commitizen-4.13.9-py3-none-any.whl", hash = "sha256:d2af3d6a83cacec9d5200e17768942c5de6266f93d932c955986c60c4285f2db", size = 85373, upload-time = "2026-02-25T02:40:03.83Z" }, +] + +[[package]] +name = "contourpy" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "numpy", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/54/eb9bfc647b19f2009dd5c7f5ec51c4e6ca831725f1aea7a993034f483147/contourpy-1.3.2.tar.gz", hash = "sha256:b6945942715a034c671b7fc54f9588126b0b8bf23db2696e3ca8328f3ff0ab54", size = 13466130, upload-time = "2025-04-15T17:47:53.79Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/a3/da4153ec8fe25d263aa48c1a4cbde7f49b59af86f0b6f7862788c60da737/contourpy-1.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ba38e3f9f330af820c4b27ceb4b9c7feee5fe0493ea53a8720f4792667465934", size = 268551, upload-time = "2025-04-15T17:34:46.581Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6c/330de89ae1087eb622bfca0177d32a7ece50c3ef07b28002de4757d9d875/contourpy-1.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dc41ba0714aa2968d1f8674ec97504a8f7e334f48eeacebcaa6256213acb0989", size = 253399, upload-time = "2025-04-15T17:34:51.427Z" }, + { url = "https://files.pythonhosted.org/packages/c1/bd/20c6726b1b7f81a8bee5271bed5c165f0a8e1f572578a9d27e2ccb763cb2/contourpy-1.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9be002b31c558d1ddf1b9b415b162c603405414bacd6932d031c5b5a8b757f0d", size = 312061, upload-time = "2025-04-15T17:34:55.961Z" }, + { url = "https://files.pythonhosted.org/packages/22/fc/a9665c88f8a2473f823cf1ec601de9e5375050f1958cbb356cdf06ef1ab6/contourpy-1.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8d2e74acbcba3bfdb6d9d8384cdc4f9260cae86ed9beee8bd5f54fee49a430b9", size = 351956, upload-time = "2025-04-15T17:35:00.992Z" }, + { url = "https://files.pythonhosted.org/packages/25/eb/9f0a0238f305ad8fb7ef42481020d6e20cf15e46be99a1fcf939546a177e/contourpy-1.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e259bced5549ac64410162adc973c5e2fb77f04df4a439d00b478e57a0e65512", size = 320872, upload-time = "2025-04-15T17:35:06.177Z" }, + { url = "https://files.pythonhosted.org/packages/32/5c/1ee32d1c7956923202f00cf8d2a14a62ed7517bdc0ee1e55301227fc273c/contourpy-1.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad687a04bc802cbe8b9c399c07162a3c35e227e2daccf1668eb1f278cb698631", size = 325027, upload-time = "2025-04-15T17:35:11.244Z" }, + { url = "https://files.pythonhosted.org/packages/83/bf/9baed89785ba743ef329c2b07fd0611d12bfecbedbdd3eeecf929d8d3b52/contourpy-1.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cdd22595308f53ef2f891040ab2b93d79192513ffccbd7fe19be7aa773a5e09f", size = 1306641, upload-time = "2025-04-15T17:35:26.701Z" }, + { url = "https://files.pythonhosted.org/packages/d4/cc/74e5e83d1e35de2d28bd97033426b450bc4fd96e092a1f7a63dc7369b55d/contourpy-1.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b4f54d6a2defe9f257327b0f243612dd051cc43825587520b1bf74a31e2f6ef2", size = 1374075, upload-time = "2025-04-15T17:35:43.204Z" }, + { url = "https://files.pythonhosted.org/packages/0c/42/17f3b798fd5e033b46a16f8d9fcb39f1aba051307f5ebf441bad1ecf78f8/contourpy-1.3.2-cp310-cp310-win32.whl", hash = "sha256:f939a054192ddc596e031e50bb13b657ce318cf13d264f095ce9db7dc6ae81c0", size = 177534, upload-time = "2025-04-15T17:35:46.554Z" }, + { url = "https://files.pythonhosted.org/packages/54/ec/5162b8582f2c994721018d0c9ece9dc6ff769d298a8ac6b6a652c307e7df/contourpy-1.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:c440093bbc8fc21c637c03bafcbef95ccd963bc6e0514ad887932c18ca2a759a", size = 221188, upload-time = "2025-04-15T17:35:50.064Z" }, + { url = "https://files.pythonhosted.org/packages/b3/b9/ede788a0b56fc5b071639d06c33cb893f68b1178938f3425debebe2dab78/contourpy-1.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6a37a2fb93d4df3fc4c0e363ea4d16f83195fc09c891bc8ce072b9d084853445", size = 269636, upload-time = "2025-04-15T17:35:54.473Z" }, + { url = "https://files.pythonhosted.org/packages/e6/75/3469f011d64b8bbfa04f709bfc23e1dd71be54d05b1b083be9f5b22750d1/contourpy-1.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b7cd50c38f500bbcc9b6a46643a40e0913673f869315d8e70de0438817cb7773", size = 254636, upload-time = "2025-04-15T17:35:58.283Z" }, + { url = "https://files.pythonhosted.org/packages/8d/2f/95adb8dae08ce0ebca4fd8e7ad653159565d9739128b2d5977806656fcd2/contourpy-1.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6658ccc7251a4433eebd89ed2672c2ed96fba367fd25ca9512aa92a4b46c4f1", size = 313053, upload-time = "2025-04-15T17:36:03.235Z" }, + { url = "https://files.pythonhosted.org/packages/c3/a6/8ccf97a50f31adfa36917707fe39c9a0cbc24b3bbb58185577f119736cc9/contourpy-1.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:70771a461aaeb335df14deb6c97439973d253ae70660ca085eec25241137ef43", size = 352985, upload-time = "2025-04-15T17:36:08.275Z" }, + { url = "https://files.pythonhosted.org/packages/1d/b6/7925ab9b77386143f39d9c3243fdd101621b4532eb126743201160ffa7e6/contourpy-1.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65a887a6e8c4cd0897507d814b14c54a8c2e2aa4ac9f7686292f9769fcf9a6ab", size = 323750, upload-time = "2025-04-15T17:36:13.29Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f3/20c5d1ef4f4748e52d60771b8560cf00b69d5c6368b5c2e9311bcfa2a08b/contourpy-1.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3859783aefa2b8355697f16642695a5b9792e7a46ab86da1118a4a23a51a33d7", size = 326246, upload-time = "2025-04-15T17:36:18.329Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e5/9dae809e7e0b2d9d70c52b3d24cba134dd3dad979eb3e5e71f5df22ed1f5/contourpy-1.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eab0f6db315fa4d70f1d8ab514e527f0366ec021ff853d7ed6a2d33605cf4b83", size = 1308728, upload-time = "2025-04-15T17:36:33.878Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/0058ba34aeea35c0b442ae61a4f4d4ca84d6df8f91309bc2d43bb8dd248f/contourpy-1.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d91a3ccc7fea94ca0acab82ceb77f396d50a1f67412efe4c526f5d20264e6ecd", size = 1375762, upload-time = "2025-04-15T17:36:51.295Z" }, + { url = "https://files.pythonhosted.org/packages/09/33/7174bdfc8b7767ef2c08ed81244762d93d5c579336fc0b51ca57b33d1b80/contourpy-1.3.2-cp311-cp311-win32.whl", hash = "sha256:1c48188778d4d2f3d48e4643fb15d8608b1d01e4b4d6b0548d9b336c28fc9b6f", size = 178196, upload-time = "2025-04-15T17:36:55.002Z" }, + { url = "https://files.pythonhosted.org/packages/5e/fe/4029038b4e1c4485cef18e480b0e2cd2d755448bb071eb9977caac80b77b/contourpy-1.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:5ebac872ba09cb8f2131c46b8739a7ff71de28a24c869bcad554477eb089a878", size = 222017, upload-time = "2025-04-15T17:36:58.576Z" }, + { url = "https://files.pythonhosted.org/packages/34/f7/44785876384eff370c251d58fd65f6ad7f39adce4a093c934d4a67a7c6b6/contourpy-1.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4caf2bcd2969402bf77edc4cb6034c7dd7c0803213b3523f111eb7460a51b8d2", size = 271580, upload-time = "2025-04-15T17:37:03.105Z" }, + { url = "https://files.pythonhosted.org/packages/93/3b/0004767622a9826ea3d95f0e9d98cd8729015768075d61f9fea8eeca42a8/contourpy-1.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:82199cb78276249796419fe36b7386bd8d2cc3f28b3bc19fe2454fe2e26c4c15", size = 255530, upload-time = "2025-04-15T17:37:07.026Z" }, + { url = "https://files.pythonhosted.org/packages/e7/bb/7bd49e1f4fa805772d9fd130e0d375554ebc771ed7172f48dfcd4ca61549/contourpy-1.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:106fab697af11456fcba3e352ad50effe493a90f893fca6c2ca5c033820cea92", size = 307688, upload-time = "2025-04-15T17:37:11.481Z" }, + { url = "https://files.pythonhosted.org/packages/fc/97/e1d5dbbfa170725ef78357a9a0edc996b09ae4af170927ba8ce977e60a5f/contourpy-1.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d14f12932a8d620e307f715857107b1d1845cc44fdb5da2bc8e850f5ceba9f87", size = 347331, upload-time = "2025-04-15T17:37:18.212Z" }, + { url = "https://files.pythonhosted.org/packages/6f/66/e69e6e904f5ecf6901be3dd16e7e54d41b6ec6ae3405a535286d4418ffb4/contourpy-1.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:532fd26e715560721bb0d5fc7610fce279b3699b018600ab999d1be895b09415", size = 318963, upload-time = "2025-04-15T17:37:22.76Z" }, + { url = "https://files.pythonhosted.org/packages/a8/32/b8a1c8965e4f72482ff2d1ac2cd670ce0b542f203c8e1d34e7c3e6925da7/contourpy-1.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f26b383144cf2d2c29f01a1e8170f50dacf0eac02d64139dcd709a8ac4eb3cfe", size = 323681, upload-time = "2025-04-15T17:37:33.001Z" }, + { url = "https://files.pythonhosted.org/packages/30/c6/12a7e6811d08757c7162a541ca4c5c6a34c0f4e98ef2b338791093518e40/contourpy-1.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c49f73e61f1f774650a55d221803b101d966ca0c5a2d6d5e4320ec3997489441", size = 1308674, upload-time = "2025-04-15T17:37:48.64Z" }, + { url = "https://files.pythonhosted.org/packages/2a/8a/bebe5a3f68b484d3a2b8ffaf84704b3e343ef1addea528132ef148e22b3b/contourpy-1.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3d80b2c0300583228ac98d0a927a1ba6a2ba6b8a742463c564f1d419ee5b211e", size = 1380480, upload-time = "2025-04-15T17:38:06.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/db/fcd325f19b5978fb509a7d55e06d99f5f856294c1991097534360b307cf1/contourpy-1.3.2-cp312-cp312-win32.whl", hash = "sha256:90df94c89a91b7362e1142cbee7568f86514412ab8a2c0d0fca72d7e91b62912", size = 178489, upload-time = "2025-04-15T17:38:10.338Z" }, + { url = "https://files.pythonhosted.org/packages/01/c8/fadd0b92ffa7b5eb5949bf340a63a4a496a6930a6c37a7ba0f12acb076d6/contourpy-1.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:8c942a01d9163e2e5cfb05cb66110121b8d07ad438a17f9e766317bcb62abf73", size = 223042, upload-time = "2025-04-15T17:38:14.239Z" }, + { url = "https://files.pythonhosted.org/packages/2e/61/5673f7e364b31e4e7ef6f61a4b5121c5f170f941895912f773d95270f3a2/contourpy-1.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:de39db2604ae755316cb5967728f4bea92685884b1e767b7c24e983ef5f771cb", size = 271630, upload-time = "2025-04-15T17:38:19.142Z" }, + { url = "https://files.pythonhosted.org/packages/ff/66/a40badddd1223822c95798c55292844b7e871e50f6bfd9f158cb25e0bd39/contourpy-1.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3f9e896f447c5c8618f1edb2bafa9a4030f22a575ec418ad70611450720b5b08", size = 255670, upload-time = "2025-04-15T17:38:23.688Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c7/cf9fdee8200805c9bc3b148f49cb9482a4e3ea2719e772602a425c9b09f8/contourpy-1.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71e2bd4a1c4188f5c2b8d274da78faab884b59df20df63c34f74aa1813c4427c", size = 306694, upload-time = "2025-04-15T17:38:28.238Z" }, + { url = "https://files.pythonhosted.org/packages/dd/e7/ccb9bec80e1ba121efbffad7f38021021cda5be87532ec16fd96533bb2e0/contourpy-1.3.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de425af81b6cea33101ae95ece1f696af39446db9682a0b56daaa48cfc29f38f", size = 345986, upload-time = "2025-04-15T17:38:33.502Z" }, + { url = "https://files.pythonhosted.org/packages/dc/49/ca13bb2da90391fa4219fdb23b078d6065ada886658ac7818e5441448b78/contourpy-1.3.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:977e98a0e0480d3fe292246417239d2d45435904afd6d7332d8455981c408b85", size = 318060, upload-time = "2025-04-15T17:38:38.672Z" }, + { url = "https://files.pythonhosted.org/packages/c8/65/5245ce8c548a8422236c13ffcdcdada6a2a812c361e9e0c70548bb40b661/contourpy-1.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:434f0adf84911c924519d2b08fc10491dd282b20bdd3fa8f60fd816ea0b48841", size = 322747, upload-time = "2025-04-15T17:38:43.712Z" }, + { url = "https://files.pythonhosted.org/packages/72/30/669b8eb48e0a01c660ead3752a25b44fdb2e5ebc13a55782f639170772f9/contourpy-1.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c66c4906cdbc50e9cba65978823e6e00b45682eb09adbb78c9775b74eb222422", size = 1308895, upload-time = "2025-04-15T17:39:00.224Z" }, + { url = "https://files.pythonhosted.org/packages/05/5a/b569f4250decee6e8d54498be7bdf29021a4c256e77fe8138c8319ef8eb3/contourpy-1.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8b7fc0cd78ba2f4695fd0a6ad81a19e7e3ab825c31b577f384aa9d7817dc3bef", size = 1379098, upload-time = "2025-04-15T17:43:29.649Z" }, + { url = "https://files.pythonhosted.org/packages/19/ba/b227c3886d120e60e41b28740ac3617b2f2b971b9f601c835661194579f1/contourpy-1.3.2-cp313-cp313-win32.whl", hash = "sha256:15ce6ab60957ca74cff444fe66d9045c1fd3e92c8936894ebd1f3eef2fff075f", size = 178535, upload-time = "2025-04-15T17:44:44.532Z" }, + { url = "https://files.pythonhosted.org/packages/12/6e/2fed56cd47ca739b43e892707ae9a13790a486a3173be063681ca67d2262/contourpy-1.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:e1578f7eafce927b168752ed7e22646dad6cd9bca673c60bff55889fa236ebf9", size = 223096, upload-time = "2025-04-15T17:44:48.194Z" }, + { url = "https://files.pythonhosted.org/packages/54/4c/e76fe2a03014a7c767d79ea35c86a747e9325537a8b7627e0e5b3ba266b4/contourpy-1.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0475b1f6604896bc7c53bb070e355e9321e1bc0d381735421a2d2068ec56531f", size = 285090, upload-time = "2025-04-15T17:43:34.084Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e2/5aba47debd55d668e00baf9651b721e7733975dc9fc27264a62b0dd26eb8/contourpy-1.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c85bb486e9be652314bb5b9e2e3b0d1b2e643d5eec4992c0fbe8ac71775da739", size = 268643, upload-time = "2025-04-15T17:43:38.626Z" }, + { url = "https://files.pythonhosted.org/packages/a1/37/cd45f1f051fe6230f751cc5cdd2728bb3a203f5619510ef11e732109593c/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:745b57db7758f3ffc05a10254edd3182a2a83402a89c00957a8e8a22f5582823", size = 310443, upload-time = "2025-04-15T17:43:44.522Z" }, + { url = "https://files.pythonhosted.org/packages/8b/a2/36ea6140c306c9ff6dd38e3bcec80b3b018474ef4d17eb68ceecd26675f4/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:970e9173dbd7eba9b4e01aab19215a48ee5dd3f43cef736eebde064a171f89a5", size = 349865, upload-time = "2025-04-15T17:43:49.545Z" }, + { url = "https://files.pythonhosted.org/packages/95/b7/2fc76bc539693180488f7b6cc518da7acbbb9e3b931fd9280504128bf956/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6c4639a9c22230276b7bffb6a850dfc8258a2521305e1faefe804d006b2e532", size = 321162, upload-time = "2025-04-15T17:43:54.203Z" }, + { url = "https://files.pythonhosted.org/packages/f4/10/76d4f778458b0aa83f96e59d65ece72a060bacb20cfbee46cf6cd5ceba41/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc829960f34ba36aad4302e78eabf3ef16a3a100863f0d4eeddf30e8a485a03b", size = 327355, upload-time = "2025-04-15T17:44:01.025Z" }, + { url = "https://files.pythonhosted.org/packages/43/a3/10cf483ea683f9f8ab096c24bad3cce20e0d1dd9a4baa0e2093c1c962d9d/contourpy-1.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d32530b534e986374fc19eaa77fcb87e8a99e5431499949b828312bdcd20ac52", size = 1307935, upload-time = "2025-04-15T17:44:17.322Z" }, + { url = "https://files.pythonhosted.org/packages/78/73/69dd9a024444489e22d86108e7b913f3528f56cfc312b5c5727a44188471/contourpy-1.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e298e7e70cf4eb179cc1077be1c725b5fd131ebc81181bf0c03525c8abc297fd", size = 1372168, upload-time = "2025-04-15T17:44:33.43Z" }, + { url = "https://files.pythonhosted.org/packages/0f/1b/96d586ccf1b1a9d2004dd519b25fbf104a11589abfd05484ff12199cca21/contourpy-1.3.2-cp313-cp313t-win32.whl", hash = "sha256:d0e589ae0d55204991450bb5c23f571c64fe43adaa53f93fc902a84c96f52fe1", size = 189550, upload-time = "2025-04-15T17:44:37.092Z" }, + { url = "https://files.pythonhosted.org/packages/b0/e6/6000d0094e8a5e32ad62591c8609e269febb6e4db83a1c75ff8868b42731/contourpy-1.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:78e9253c3de756b3f6a5174d024c4835acd59eb3f8e2ca13e775dbffe1558f69", size = 238214, upload-time = "2025-04-15T17:44:40.827Z" }, + { url = "https://files.pythonhosted.org/packages/33/05/b26e3c6ecc05f349ee0013f0bb850a761016d89cec528a98193a48c34033/contourpy-1.3.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:fd93cc7f3139b6dd7aab2f26a90dde0aa9fc264dbf70f6740d498a70b860b82c", size = 265681, upload-time = "2025-04-15T17:44:59.314Z" }, + { url = "https://files.pythonhosted.org/packages/2b/25/ac07d6ad12affa7d1ffed11b77417d0a6308170f44ff20fa1d5aa6333f03/contourpy-1.3.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:107ba8a6a7eec58bb475329e6d3b95deba9440667c4d62b9b6063942b61d7f16", size = 315101, upload-time = "2025-04-15T17:45:04.165Z" }, + { url = "https://files.pythonhosted.org/packages/8f/4d/5bb3192bbe9d3f27e3061a6a8e7733c9120e203cb8515767d30973f71030/contourpy-1.3.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ded1706ed0c1049224531b81128efbd5084598f18d8a2d9efae833edbd2b40ad", size = 220599, upload-time = "2025-04-15T17:45:08.456Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c0/91f1215d0d9f9f343e4773ba6c9b89e8c0cc7a64a6263f21139da639d848/contourpy-1.3.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5f5964cdad279256c084b69c3f412b7801e15356b16efa9d78aa974041903da0", size = 266807, upload-time = "2025-04-15T17:45:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/d4/79/6be7e90c955c0487e7712660d6cead01fa17bff98e0ea275737cc2bc8e71/contourpy-1.3.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49b65a95d642d4efa8f64ba12558fcb83407e58a2dfba9d796d77b63ccfcaff5", size = 318729, upload-time = "2025-04-15T17:45:20.166Z" }, + { url = "https://files.pythonhosted.org/packages/87/68/7f46fb537958e87427d98a4074bcde4b67a70b04900cfc5ce29bc2f556c1/contourpy-1.3.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8c5acb8dddb0752bf252e01a3035b21443158910ac16a3b0d20e7fed7d534ce5", size = 221791, upload-time = "2025-04-15T17:45:24.794Z" }, +] + +[[package]] +name = "contourpy" +version = "1.3.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.13' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version >= '3.13' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version >= '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "numpy", marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/2e/c4390a31919d8a78b90e8ecf87cd4b4c4f05a5b48d05ec17db8e5404c6f4/contourpy-1.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:709a48ef9a690e1343202916450bc48b9e51c049b089c7f79a267b46cffcdaa1", size = 288773, upload-time = "2025-07-26T12:01:02.277Z" }, + { url = "https://files.pythonhosted.org/packages/0d/44/c4b0b6095fef4dc9c420e041799591e3b63e9619e3044f7f4f6c21c0ab24/contourpy-1.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:23416f38bfd74d5d28ab8429cc4d63fa67d5068bd711a85edb1c3fb0c3e2f381", size = 270149, upload-time = "2025-07-26T12:01:04.072Z" }, + { url = "https://files.pythonhosted.org/packages/30/2e/dd4ced42fefac8470661d7cb7e264808425e6c5d56d175291e93890cce09/contourpy-1.3.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:929ddf8c4c7f348e4c0a5a3a714b5c8542ffaa8c22954862a46ca1813b667ee7", size = 329222, upload-time = "2025-07-26T12:01:05.688Z" }, + { url = "https://files.pythonhosted.org/packages/f2/74/cc6ec2548e3d276c71389ea4802a774b7aa3558223b7bade3f25787fafc2/contourpy-1.3.3-cp311-cp311-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e999574eddae35f1312c2b4b717b7885d4edd6cb46700e04f7f02db454e67c1", size = 377234, upload-time = "2025-07-26T12:01:07.054Z" }, + { url = "https://files.pythonhosted.org/packages/03/b3/64ef723029f917410f75c09da54254c5f9ea90ef89b143ccadb09df14c15/contourpy-1.3.3-cp311-cp311-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf67e0e3f482cb69779dd3061b534eb35ac9b17f163d851e2a547d56dba0a3a", size = 380555, upload-time = "2025-07-26T12:01:08.801Z" }, + { url = "https://files.pythonhosted.org/packages/5f/4b/6157f24ca425b89fe2eb7e7be642375711ab671135be21e6faa100f7448c/contourpy-1.3.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51e79c1f7470158e838808d4a996fa9bac72c498e93d8ebe5119bc1e6becb0db", size = 355238, upload-time = "2025-07-26T12:01:10.319Z" }, + { url = "https://files.pythonhosted.org/packages/98/56/f914f0dd678480708a04cfd2206e7c382533249bc5001eb9f58aa693e200/contourpy-1.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:598c3aaece21c503615fd59c92a3598b428b2f01bfb4b8ca9c4edeecc2438620", size = 1326218, upload-time = "2025-07-26T12:01:12.659Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d7/4a972334a0c971acd5172389671113ae82aa7527073980c38d5868ff1161/contourpy-1.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:322ab1c99b008dad206d406bb61d014cf0174df491ae9d9d0fac6a6fda4f977f", size = 1392867, upload-time = "2025-07-26T12:01:15.533Z" }, + { url = "https://files.pythonhosted.org/packages/75/3e/f2cc6cd56dc8cff46b1a56232eabc6feea52720083ea71ab15523daab796/contourpy-1.3.3-cp311-cp311-win32.whl", hash = "sha256:fd907ae12cd483cd83e414b12941c632a969171bf90fc937d0c9f268a31cafff", size = 183677, upload-time = "2025-07-26T12:01:17.088Z" }, + { url = "https://files.pythonhosted.org/packages/98/4b/9bd370b004b5c9d8045c6c33cf65bae018b27aca550a3f657cdc99acdbd8/contourpy-1.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:3519428f6be58431c56581f1694ba8e50626f2dd550af225f82fb5f5814d2a42", size = 225234, upload-time = "2025-07-26T12:01:18.256Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b6/71771e02c2e004450c12b1120a5f488cad2e4d5b590b1af8bad060360fe4/contourpy-1.3.3-cp311-cp311-win_arm64.whl", hash = "sha256:15ff10bfada4bf92ec8b31c62bf7c1834c244019b4a33095a68000d7075df470", size = 193123, upload-time = "2025-07-26T12:01:19.848Z" }, + { url = "https://files.pythonhosted.org/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb", size = 293419, upload-time = "2025-07-26T12:01:21.16Z" }, + { url = "https://files.pythonhosted.org/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6", size = 273979, upload-time = "2025-07-26T12:01:22.448Z" }, + { url = "https://files.pythonhosted.org/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7", size = 332653, upload-time = "2025-07-26T12:01:24.155Z" }, + { url = "https://files.pythonhosted.org/packages/63/12/897aeebfb475b7748ea67b61e045accdfcf0d971f8a588b67108ed7f5512/contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8", size = 379536, upload-time = "2025-07-26T12:01:25.91Z" }, + { url = "https://files.pythonhosted.org/packages/43/8a/a8c584b82deb248930ce069e71576fc09bd7174bbd35183b7943fb1064fd/contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea", size = 384397, upload-time = "2025-07-26T12:01:27.152Z" }, + { url = "https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1", size = 362601, upload-time = "2025-07-26T12:01:28.808Z" }, + { url = "https://files.pythonhosted.org/packages/05/0a/a3fe3be3ee2dceb3e615ebb4df97ae6f3828aa915d3e10549ce016302bd1/contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7", size = 1331288, upload-time = "2025-07-26T12:01:31.198Z" }, + { url = "https://files.pythonhosted.org/packages/33/1d/acad9bd4e97f13f3e2b18a3977fe1b4a37ecf3d38d815333980c6c72e963/contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411", size = 1403386, upload-time = "2025-07-26T12:01:33.947Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8f/5847f44a7fddf859704217a99a23a4f6417b10e5ab1256a179264561540e/contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69", size = 185018, upload-time = "2025-07-26T12:01:35.64Z" }, + { url = "https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b", size = 226567, upload-time = "2025-07-26T12:01:36.804Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e2/f05240d2c39a1ed228d8328a78b6f44cd695f7ef47beb3e684cf93604f86/contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc", size = 193655, upload-time = "2025-07-26T12:01:37.999Z" }, + { url = "https://files.pythonhosted.org/packages/68/35/0167aad910bbdb9599272bd96d01a9ec6852f36b9455cf2ca67bd4cc2d23/contourpy-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:177fb367556747a686509d6fef71d221a4b198a3905fe824430e5ea0fda54eb5", size = 293257, upload-time = "2025-07-26T12:01:39.367Z" }, + { url = "https://files.pythonhosted.org/packages/96/e4/7adcd9c8362745b2210728f209bfbcf7d91ba868a2c5f40d8b58f54c509b/contourpy-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d002b6f00d73d69333dac9d0b8d5e84d9724ff9ef044fd63c5986e62b7c9e1b1", size = 274034, upload-time = "2025-07-26T12:01:40.645Z" }, + { url = "https://files.pythonhosted.org/packages/73/23/90e31ceeed1de63058a02cb04b12f2de4b40e3bef5e082a7c18d9c8ae281/contourpy-1.3.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:348ac1f5d4f1d66d3322420f01d42e43122f43616e0f194fc1c9f5d830c5b286", size = 334672, upload-time = "2025-07-26T12:01:41.942Z" }, + { url = "https://files.pythonhosted.org/packages/ed/93/b43d8acbe67392e659e1d984700e79eb67e2acb2bd7f62012b583a7f1b55/contourpy-1.3.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655456777ff65c2c548b7c454af9c6f33f16c8884f11083244b5819cc214f1b5", size = 381234, upload-time = "2025-07-26T12:01:43.499Z" }, + { url = "https://files.pythonhosted.org/packages/46/3b/bec82a3ea06f66711520f75a40c8fc0b113b2a75edb36aa633eb11c4f50f/contourpy-1.3.3-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:644a6853d15b2512d67881586bd03f462c7ab755db95f16f14d7e238f2852c67", size = 385169, upload-time = "2025-07-26T12:01:45.219Z" }, + { url = "https://files.pythonhosted.org/packages/4b/32/e0f13a1c5b0f8572d0ec6ae2f6c677b7991fafd95da523159c19eff0696a/contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9", size = 362859, upload-time = "2025-07-26T12:01:46.519Z" }, + { url = "https://files.pythonhosted.org/packages/33/71/e2a7945b7de4e58af42d708a219f3b2f4cff7386e6b6ab0a0fa0033c49a9/contourpy-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a15459b0f4615b00bbd1e91f1b9e19b7e63aea7483d03d804186f278c0af2659", size = 1332062, upload-time = "2025-07-26T12:01:48.964Z" }, + { url = "https://files.pythonhosted.org/packages/12/fc/4e87ac754220ccc0e807284f88e943d6d43b43843614f0a8afa469801db0/contourpy-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca0fdcd73925568ca027e0b17ab07aad764be4706d0a925b89227e447d9737b7", size = 1403932, upload-time = "2025-07-26T12:01:51.979Z" }, + { url = "https://files.pythonhosted.org/packages/a6/2e/adc197a37443f934594112222ac1aa7dc9a98faf9c3842884df9a9d8751d/contourpy-1.3.3-cp313-cp313-win32.whl", hash = "sha256:b20c7c9a3bf701366556e1b1984ed2d0cedf999903c51311417cf5f591d8c78d", size = 185024, upload-time = "2025-07-26T12:01:53.245Z" }, + { url = "https://files.pythonhosted.org/packages/18/0b/0098c214843213759692cc638fce7de5c289200a830e5035d1791d7a2338/contourpy-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:1cadd8b8969f060ba45ed7c1b714fe69185812ab43bd6b86a9123fe8f99c3263", size = 226578, upload-time = "2025-07-26T12:01:54.422Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9a/2f6024a0c5995243cd63afdeb3651c984f0d2bc727fd98066d40e141ad73/contourpy-1.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:fd914713266421b7536de2bfa8181aa8c699432b6763a0ea64195ebe28bff6a9", size = 193524, upload-time = "2025-07-26T12:01:55.73Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b3/f8a1a86bd3298513f500e5b1f5fd92b69896449f6cab6a146a5d52715479/contourpy-1.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:88df9880d507169449d434c293467418b9f6cbe82edd19284aa0409e7fdb933d", size = 306730, upload-time = "2025-07-26T12:01:57.051Z" }, + { url = "https://files.pythonhosted.org/packages/3f/11/4780db94ae62fc0c2053909b65dc3246bd7cecfc4f8a20d957ad43aa4ad8/contourpy-1.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d06bb1f751ba5d417047db62bca3c8fde202b8c11fb50742ab3ab962c81e8216", size = 287897, upload-time = "2025-07-26T12:01:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/ae/15/e59f5f3ffdd6f3d4daa3e47114c53daabcb18574a26c21f03dc9e4e42ff0/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4e6b05a45525357e382909a4c1600444e2a45b4795163d3b22669285591c1ae", size = 326751, upload-time = "2025-07-26T12:02:00.343Z" }, + { url = "https://files.pythonhosted.org/packages/0f/81/03b45cfad088e4770b1dcf72ea78d3802d04200009fb364d18a493857210/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab3074b48c4e2cf1a960e6bbeb7f04566bf36b1861d5c9d4d8ac04b82e38ba20", size = 375486, upload-time = "2025-07-26T12:02:02.128Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ba/49923366492ffbdd4486e970d421b289a670ae8cf539c1ea9a09822b371a/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c3d53c796f8647d6deb1abe867daeb66dcc8a97e8455efa729516b997b8ed99", size = 388106, upload-time = "2025-07-26T12:02:03.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/52/5b00ea89525f8f143651f9f03a0df371d3cbd2fccd21ca9b768c7a6500c2/contourpy-1.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50ed930df7289ff2a8d7afeb9603f8289e5704755c7e5c3bbd929c90c817164b", size = 352548, upload-time = "2025-07-26T12:02:05.165Z" }, + { url = "https://files.pythonhosted.org/packages/32/1d/a209ec1a3a3452d490f6b14dd92e72280c99ae3d1e73da74f8277d4ee08f/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4feffb6537d64b84877da813a5c30f1422ea5739566abf0bd18065ac040e120a", size = 1322297, upload-time = "2025-07-26T12:02:07.379Z" }, + { url = "https://files.pythonhosted.org/packages/bc/9e/46f0e8ebdd884ca0e8877e46a3f4e633f6c9c8c4f3f6e72be3fe075994aa/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2b7e9480ffe2b0cd2e787e4df64270e3a0440d9db8dc823312e2c940c167df7e", size = 1391023, upload-time = "2025-07-26T12:02:10.171Z" }, + { url = "https://files.pythonhosted.org/packages/b9/70/f308384a3ae9cd2209e0849f33c913f658d3326900d0ff5d378d6a1422d2/contourpy-1.3.3-cp313-cp313t-win32.whl", hash = "sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3", size = 196157, upload-time = "2025-07-26T12:02:11.488Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dd/880f890a6663b84d9e34a6f88cded89d78f0091e0045a284427cb6b18521/contourpy-1.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8", size = 240570, upload-time = "2025-07-26T12:02:12.754Z" }, + { url = "https://files.pythonhosted.org/packages/80/99/2adc7d8ffead633234817ef8e9a87115c8a11927a94478f6bb3d3f4d4f7d/contourpy-1.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301", size = 199713, upload-time = "2025-07-26T12:02:14.4Z" }, + { url = "https://files.pythonhosted.org/packages/a5/29/8dcfe16f0107943fa92388c23f6e05cff0ba58058c4c95b00280d4c75a14/contourpy-1.3.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:cd5dfcaeb10f7b7f9dc8941717c6c2ade08f587be2226222c12b25f0483ed497", size = 278809, upload-time = "2025-07-26T12:02:52.74Z" }, + { url = "https://files.pythonhosted.org/packages/85/a9/8b37ef4f7dafeb335daee3c8254645ef5725be4d9c6aa70b50ec46ef2f7e/contourpy-1.3.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:0c1fc238306b35f246d61a1d416a627348b5cf0648648a031e14bb8705fcdfe8", size = 261593, upload-time = "2025-07-26T12:02:54.037Z" }, + { url = "https://files.pythonhosted.org/packages/0a/59/ebfb8c677c75605cc27f7122c90313fd2f375ff3c8d19a1694bda74aaa63/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70f9aad7de812d6541d29d2bbf8feb22ff7e1c299523db288004e3157ff4674e", size = 302202, upload-time = "2025-07-26T12:02:55.947Z" }, + { url = "https://files.pythonhosted.org/packages/3c/37/21972a15834d90bfbfb009b9d004779bd5a07a0ec0234e5ba8f64d5736f4/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ed3657edf08512fc3fe81b510e35c2012fbd3081d2e26160f27ca28affec989", size = 329207, upload-time = "2025-07-26T12:02:57.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/58/bd257695f39d05594ca4ad60df5bcb7e32247f9951fd09a9b8edb82d1daa/contourpy-1.3.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3d1a3799d62d45c18bafd41c5fa05120b96a28079f2393af559b843d1a966a77", size = 225315, upload-time = "2025-07-26T12:02:58.801Z" }, +] + +[[package]] +name = "cycler" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, +] + +[[package]] +name = "dash" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flask" }, + { name = "importlib-metadata" }, + { name = "nest-asyncio" }, + { name = "plotly" }, + { name = "requests" }, + { name = "retrying" }, + { name = "setuptools" }, + { name = "typing-extensions" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/dd/3aed9bfd81dfd8f44b3a5db0583080ac9470d5e92ee134982bd5c69e286e/dash-4.0.0.tar.gz", hash = "sha256:c5f2bca497af288f552aea3ae208f6a0cca472559003dac84ac21187a1c3a142", size = 6943263, upload-time = "2026-02-03T19:42:27.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/8c/dd63d210b28a7589f4bc1e84880525368147425c717d12834ab562f52d14/dash-4.0.0-py3-none-any.whl", hash = "sha256:e36b4b4eae9e1fa4136bf4f1450ed14ef76063bc5da0b10f8ab07bd57a7cb1ab", size = 7247521, upload-time = "2026-02-03T19:42:25.01Z" }, +] + +[[package]] +name = "dash-dangerously-set-inner-html" +version = "0.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/56/b1/5102060b9b6836409db84265f4f934475c2707cce87e75f3f8a04493e0dc/dash_dangerously_set_inner_html-0.0.2.tar.gz", hash = "sha256:d7fe990755851fc4d2e22c8f10b7aea055cabf380bbceefba589779b269fea64", size = 4956, upload-time = "2018-12-19T21:12:47.258Z" } + +[[package]] +name = "debugpy" +version = "1.8.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/b7/cd8080344452e4874aae67c40d8940e2b4d47b01601a8fd9f44786c757c7/debugpy-1.8.20.tar.gz", hash = "sha256:55bc8701714969f1ab89a6d5f2f3d40c36f91b2cbe2f65d98bf8196f6a6a2c33", size = 1645207, upload-time = "2026-01-29T23:03:28.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/be/8bd693a0b9d53d48c8978fa5d889e06f3b5b03e45fd1ea1e78267b4887cb/debugpy-1.8.20-cp310-cp310-macosx_15_0_x86_64.whl", hash = "sha256:157e96ffb7f80b3ad36d808646198c90acb46fdcfd8bb1999838f0b6f2b59c64", size = 2099192, upload-time = "2026-01-29T23:03:29.707Z" }, + { url = "https://files.pythonhosted.org/packages/77/1b/85326d07432086a06361d493d2743edd0c4fc2ef62162be7f8618441ac37/debugpy-1.8.20-cp310-cp310-manylinux_2_34_x86_64.whl", hash = "sha256:c1178ae571aff42e61801a38b007af504ec8e05fde1c5c12e5a7efef21009642", size = 3088568, upload-time = "2026-01-29T23:03:31.467Z" }, + { url = "https://files.pythonhosted.org/packages/e8/60/3e08462ee3eccd10998853eb35947c416e446bfe2bc37dbb886b9044586c/debugpy-1.8.20-cp310-cp310-win32.whl", hash = "sha256:c29dd9d656c0fbd77906a6e6a82ae4881514aa3294b94c903ff99303e789b4a2", size = 5284399, upload-time = "2026-01-29T23:03:33.678Z" }, + { url = "https://files.pythonhosted.org/packages/72/43/09d49106e770fe558ced5e80df2e3c2ebee10e576eda155dcc5670473663/debugpy-1.8.20-cp310-cp310-win_amd64.whl", hash = "sha256:3ca85463f63b5dd0aa7aaa933d97cbc47c174896dcae8431695872969f981893", size = 5316388, upload-time = "2026-01-29T23:03:35.095Z" }, + { url = "https://files.pythonhosted.org/packages/51/56/c3baf5cbe4dd77427fd9aef99fcdade259ad128feeb8a786c246adb838e5/debugpy-1.8.20-cp311-cp311-macosx_15_0_universal2.whl", hash = "sha256:eada6042ad88fa1571b74bd5402ee8b86eded7a8f7b827849761700aff171f1b", size = 2208318, upload-time = "2026-01-29T23:03:36.481Z" }, + { url = "https://files.pythonhosted.org/packages/9a/7d/4fa79a57a8e69fe0d9763e98d1110320f9ecd7f1f362572e3aafd7417c9d/debugpy-1.8.20-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:7de0b7dfeedc504421032afba845ae2a7bcc32ddfb07dae2c3ca5442f821c344", size = 3171493, upload-time = "2026-01-29T23:03:37.775Z" }, + { url = "https://files.pythonhosted.org/packages/7d/f2/1e8f8affe51e12a26f3a8a8a4277d6e60aa89d0a66512f63b1e799d424a4/debugpy-1.8.20-cp311-cp311-win32.whl", hash = "sha256:773e839380cf459caf73cc533ea45ec2737a5cc184cf1b3b796cd4fd98504fec", size = 5209240, upload-time = "2026-01-29T23:03:39.109Z" }, + { url = "https://files.pythonhosted.org/packages/d5/92/1cb532e88560cbee973396254b21bece8c5d7c2ece958a67afa08c9f10dc/debugpy-1.8.20-cp311-cp311-win_amd64.whl", hash = "sha256:1f7650546e0eded1902d0f6af28f787fa1f1dbdbc97ddabaf1cd963a405930cb", size = 5233481, upload-time = "2026-01-29T23:03:40.659Z" }, + { url = "https://files.pythonhosted.org/packages/14/57/7f34f4736bfb6e00f2e4c96351b07805d83c9a7b33d28580ae01374430f7/debugpy-1.8.20-cp312-cp312-macosx_15_0_universal2.whl", hash = "sha256:4ae3135e2089905a916909ef31922b2d733d756f66d87345b3e5e52b7a55f13d", size = 2550686, upload-time = "2026-01-29T23:03:42.023Z" }, + { url = "https://files.pythonhosted.org/packages/ab/78/b193a3975ca34458f6f0e24aaf5c3e3da72f5401f6054c0dfd004b41726f/debugpy-1.8.20-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:88f47850a4284b88bd2bfee1f26132147d5d504e4e86c22485dfa44b97e19b4b", size = 4310588, upload-time = "2026-01-29T23:03:43.314Z" }, + { url = "https://files.pythonhosted.org/packages/c1/55/f14deb95eaf4f30f07ef4b90a8590fc05d9e04df85ee379712f6fb6736d7/debugpy-1.8.20-cp312-cp312-win32.whl", hash = "sha256:4057ac68f892064e5f98209ab582abfee3b543fb55d2e87610ddc133a954d390", size = 5331372, upload-time = "2026-01-29T23:03:45.526Z" }, + { url = "https://files.pythonhosted.org/packages/a1/39/2bef246368bd42f9bd7cba99844542b74b84dacbdbea0833e610f384fee8/debugpy-1.8.20-cp312-cp312-win_amd64.whl", hash = "sha256:a1a8f851e7cf171330679ef6997e9c579ef6dd33c9098458bd9986a0f4ca52e3", size = 5372835, upload-time = "2026-01-29T23:03:47.245Z" }, + { url = "https://files.pythonhosted.org/packages/15/e2/fc500524cc6f104a9d049abc85a0a8b3f0d14c0a39b9c140511c61e5b40b/debugpy-1.8.20-cp313-cp313-macosx_15_0_universal2.whl", hash = "sha256:5dff4bb27027821fdfcc9e8f87309a28988231165147c31730128b1c983e282a", size = 2539560, upload-time = "2026-01-29T23:03:48.738Z" }, + { url = "https://files.pythonhosted.org/packages/90/83/fb33dcea789ed6018f8da20c5a9bc9d82adc65c0c990faed43f7c955da46/debugpy-1.8.20-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:84562982dd7cf5ebebfdea667ca20a064e096099997b175fe204e86817f64eaf", size = 4293272, upload-time = "2026-01-29T23:03:50.169Z" }, + { url = "https://files.pythonhosted.org/packages/a6/25/b1e4a01bfb824d79a6af24b99ef291e24189080c93576dfd9b1a2815cd0f/debugpy-1.8.20-cp313-cp313-win32.whl", hash = "sha256:da11dea6447b2cadbf8ce2bec59ecea87cc18d2c574980f643f2d2dfe4862393", size = 5331208, upload-time = "2026-01-29T23:03:51.547Z" }, + { url = "https://files.pythonhosted.org/packages/13/f7/a0b368ce54ffff9e9028c098bd2d28cfc5b54f9f6c186929083d4c60ba58/debugpy-1.8.20-cp313-cp313-win_amd64.whl", hash = "sha256:eb506e45943cab2efb7c6eafdd65b842f3ae779f020c82221f55aca9de135ed7", size = 5372930, upload-time = "2026-01-29T23:03:53.585Z" }, + { url = "https://files.pythonhosted.org/packages/e0/c3/7f67dea8ccf8fdcb9c99033bbe3e90b9e7395415843accb81428c441be2d/debugpy-1.8.20-py2.py3-none-any.whl", hash = "sha256:5be9bed9ae3be00665a06acaa48f8329d2b9632f15fd09f6a9a8c8d9907e54d7", size = 5337658, upload-time = "2026-01-29T23:04:17.404Z" }, +] + +[[package]] +name = "decli" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0c/59/d4ffff1dee2c8f6f2dd8f87010962e60f7b7847504d765c91ede5a466730/decli-0.6.3.tar.gz", hash = "sha256:87f9d39361adf7f16b9ca6e3b614badf7519da13092f2db3c80ca223c53c7656", size = 7564, upload-time = "2025-06-01T15:23:41.25Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/fa/ec878c28bc7f65b77e7e17af3522c9948a9711b9fa7fc4c5e3140a7e3578/decli-0.6.3-py3-none-any.whl", hash = "sha256:5152347c7bb8e3114ad65db719e5709b28d7f7f45bdb709f70167925e55640f3", size = 7989, upload-time = "2025-06-01T15:23:40.228Z" }, +] + +[[package]] +name = "decorator" +version = "5.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711, upload-time = "2025-02-24T04:41:34.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" }, +] + +[[package]] +name = "defusedxml" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, +] + +[[package]] +name = "deprecated" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, +] + +[[package]] +name = "dill" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/e1/56027a71e31b02ddc53c7d65b01e68edf64dea2932122fe7746a516f75d5/dill-0.4.1.tar.gz", hash = "sha256:423092df4182177d4d8ba8290c8a5b640c66ab35ec7da59ccfa00f6fa3eea5fa", size = 187315, upload-time = "2026-01-19T02:36:56.85Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl", hash = "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d", size = 120019, upload-time = "2026-01-19T02:36:55.663Z" }, +] + +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + +[[package]] +name = "django" +version = "5.2.13" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.11'", +] +dependencies = [ + { name = "asgiref", marker = "python_full_version < '3.12'" }, + { name = "sqlparse", marker = "python_full_version < '3.12'" }, + { name = "tzdata", marker = "python_full_version < '3.12' and sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/c5/c69e338eb2959f641045802e5ea87ca4bf5ac90c5fd08953ca10742fad51/django-5.2.13.tar.gz", hash = "sha256:a31589db5188d074c63f0945c3888fad104627dfcc236fb2b97f71f89da33bc4", size = 10890368, upload-time = "2026-04-07T14:02:15.072Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/b1/51ab36b2eefcf8cdb9338c7188668a157e29e30306bfc98a379704c9e10d/django-5.2.13-py3-none-any.whl", hash = "sha256:5788fce61da23788a8ce6f02583765ab060d396720924789f97fa42119d37f7a", size = 8310982, upload-time = "2026-04-07T14:02:08.883Z" }, +] + +[[package]] +name = "django" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.13' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version >= '3.13' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version >= '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "asgiref", marker = "python_full_version >= '3.12'" }, + { name = "sqlparse", marker = "python_full_version >= '3.12'" }, + { name = "tzdata", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/80/e1/894115c6bd70e2c8b66b0c40a3c367d83a5a48c034a4d904d31b62f7c53a/django-6.0.3.tar.gz", hash = "sha256:90be765ee756af8a6cbd6693e56452404b5ad15294f4d5e40c0a55a0f4870fe1", size = 10872701, upload-time = "2026-03-03T13:55:15.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/b1/23f2556967c45e34d3d3cf032eb1bd3ef925ee458667fb99052a0b3ea3a6/django-6.0.3-py3-none-any.whl", hash = "sha256:2e5974441491ddb34c3f13d5e7a9f97b07ba03bf70234c0a9c68b79bbb235bc3", size = 8358527, upload-time = "2026-03-03T13:55:10.552Z" }, +] + +[[package]] +name = "django-rdkit" +version = "0.4.0" +source = { git = "https://github.com/rdkit/django-rdkit#6dcf8f677ced66f1a9c51d086d746c01753aa23f" } + +[[package]] +name = "emoji" +version = "2.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/78/0d2db9382c92a163d7095fc08efff7800880f830a152cfced40161e7638d/emoji-2.15.0.tar.gz", hash = "sha256:eae4ab7d86456a70a00a985125a03263a5eac54cd55e51d7e184b1ed3b6757e4", size = 615483, upload-time = "2025-09-21T12:13:02.755Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/5e/4b5aaaabddfacfe36ba7768817bd1f71a7a810a43705e531f3ae4c690767/emoji-2.15.0-py3-none-any.whl", hash = "sha256:205296793d66a89d88af4688fa57fd6496732eb48917a87175a023c8138995eb", size = 608433, upload-time = "2025-09-21T12:13:01.197Z" }, +] + +[[package]] +name = "et-xmlfile" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "executing" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, +] + +[[package]] +name = "fastjsonschema" +version = "2.21.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/b5/23b216d9d985a956623b6bd12d4086b60f0059b27799f23016af04a74ea1/fastjsonschema-2.21.2.tar.gz", hash = "sha256:b1eb43748041c880796cd077f1a07c3d94e93ae84bba5ed36800a33554ae05de", size = 374130, upload-time = "2025-08-14T18:49:36.666Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/a8/20d0723294217e47de6d9e2e40fd4a9d2f7c4b6ef974babd482a59743694/fastjsonschema-2.21.2-py3-none-any.whl", hash = "sha256:1c797122d0a86c5cace2e54bf4e819c36223b552017172f32c5c024a6b77e463", size = 24024, upload-time = "2025-08-14T18:49:34.776Z" }, +] + +[[package]] +name = "filelock" +version = "3.25.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/8b/4c32ecde6bea6486a2a5d05340e695174351ff6b06cf651a74c005f9df00/filelock-3.25.1.tar.gz", hash = "sha256:b9a2e977f794ef94d77cdf7d27129ac648a61f585bff3ca24630c1629f701aa9", size = 40319, upload-time = "2026-03-09T19:38:47.309Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/b8/2f664b56a3b4b32d28d3d106c71783073f712ba43ff6d34b9ea0ce36dc7b/filelock-3.25.1-py3-none-any.whl", hash = "sha256:18972df45473c4aa2c7921b609ee9ca4925910cc3a0fb226c96b92fc224ef7bf", size = 26720, upload-time = "2026-03-09T19:38:45.718Z" }, +] + +[[package]] +name = "flask" +version = "3.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" }, +] + +[[package]] +name = "fonttools" +version = "4.62.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/96/686339e0fda8142b7ebed39af53f4a5694602a729662f42a6209e3be91d0/fonttools-4.62.0.tar.gz", hash = "sha256:0dc477c12b8076b4eb9af2e440421b0433ffa9e1dcb39e0640a6c94665ed1098", size = 3579521, upload-time = "2026-03-09T16:50:06.217Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/e0/9db48ec7f6b95bae7b20667ded54f18dba8e759ef66232c8683822ae26fc/fonttools-4.62.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:62b6a3d0028e458e9b59501cf7124a84cd69681c433570e4861aff4fb54a236c", size = 2873527, upload-time = "2026-03-09T16:48:12.416Z" }, + { url = "https://files.pythonhosted.org/packages/dd/45/86eccfdc922cb9fafc63189a9793fa9f6dd60e68a07be42e454ef2c0deae/fonttools-4.62.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:966557078b55e697f65300b18025c54e872d7908d1899b7314d7c16e64868cb2", size = 2417427, upload-time = "2026-03-09T16:48:15.122Z" }, + { url = "https://files.pythonhosted.org/packages/d3/98/f547a1fceeae81a9a5c6461bde2badac8bf50bda7122a8012b32b1e65396/fonttools-4.62.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cf34861145b516cddd19b07ae6f4a61ea1c6326031b960ec9ddce8ee815e888", size = 4934993, upload-time = "2026-03-09T16:48:18.186Z" }, + { url = "https://files.pythonhosted.org/packages/5c/57/a23a051fcff998fdfabdd33c6721b5bad499da08b586d3676993410071f0/fonttools-4.62.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e2ff573de2775508c8a366351fb901c4ced5dc6cf2d87dd15c973bedcdd5216", size = 4892154, upload-time = "2026-03-09T16:48:20.736Z" }, + { url = "https://files.pythonhosted.org/packages/e2/62/e27644b433dc6db1d47bc6028a27d772eec5cc8338e24a9a1fce5d7120aa/fonttools-4.62.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:55b189a1b3033860a38e4e5bd0626c5aa25c7ce9caee7bc784a8caec7a675401", size = 4911635, upload-time = "2026-03-09T16:48:23.174Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e2/1bf141911a5616bacfe9cf237c80ccd69d0d92482c38c0f7f6a55d063ad9/fonttools-4.62.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:825f98cd14907c74a4d0a3f7db8570886ffce9c6369fed1385020febf919abf6", size = 5031492, upload-time = "2026-03-09T16:48:25.095Z" }, + { url = "https://files.pythonhosted.org/packages/2f/59/790c292f4347ecfa77d9c7e0d1d91e04ab227f6e4a337ed4fe37ca388048/fonttools-4.62.0-cp310-cp310-win32.whl", hash = "sha256:c858030560f92a054444c6e46745227bfd3bb4e55383c80d79462cd47289e4b5", size = 1507656, upload-time = "2026-03-09T16:48:26.973Z" }, + { url = "https://files.pythonhosted.org/packages/e9/ee/08c0b7f8bac6e44638de6fe9a3e710a623932f60eccd58912c4d4743516d/fonttools-4.62.0-cp310-cp310-win_amd64.whl", hash = "sha256:9bf75eb69330e34ad2a096fac67887102c8537991eb6cac1507fc835bbb70e0a", size = 1556540, upload-time = "2026-03-09T16:48:30.359Z" }, + { url = "https://files.pythonhosted.org/packages/e4/33/63d79ca41020dd460b51f1e0f58ad1ff0a36b7bcbdf8f3971d52836581e9/fonttools-4.62.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:196cafef9aeec5258425bd31a4e9a414b2ee0d1557bca184d7923d3d3bcd90f9", size = 2870816, upload-time = "2026-03-09T16:48:32.39Z" }, + { url = "https://files.pythonhosted.org/packages/c0/7a/9aeec114bc9fc00d757a41f092f7107863d372e684a5b5724c043654477c/fonttools-4.62.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:153afc3012ff8761b1733e8fbe5d98623409774c44ffd88fbcb780e240c11d13", size = 2416127, upload-time = "2026-03-09T16:48:34.627Z" }, + { url = "https://files.pythonhosted.org/packages/5a/71/12cfd8ae0478b7158ffa8850786781f67e73c00fd897ef9d053415c5f88b/fonttools-4.62.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13b663fb197334de84db790353d59da2a7288fd14e9be329f5debc63ec0500a5", size = 5100678, upload-time = "2026-03-09T16:48:36.454Z" }, + { url = "https://files.pythonhosted.org/packages/8a/d7/8e4845993ee233c2023d11babe9b3dae7d30333da1d792eeccebcb77baab/fonttools-4.62.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:591220d5333264b1df0d3285adbdfe2af4f6a45bbf9ca2b485f97c9f577c49ff", size = 5070859, upload-time = "2026-03-09T16:48:38.786Z" }, + { url = "https://files.pythonhosted.org/packages/ae/a0/287ae04cd883a52e7bb1d92dfc4997dcffb54173761c751106845fa9e316/fonttools-4.62.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:579f35c121528a50c96bf6fcb6a393e81e7f896d4326bf40e379f1c971603db9", size = 5076689, upload-time = "2026-03-09T16:48:41.886Z" }, + { url = "https://files.pythonhosted.org/packages/6d/4e/a2377ad26c36fcd3e671a1c316ea5ed83107de1588e2d897a98349363bc7/fonttools-4.62.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:44956b003151d5a289eba6c71fe590d63509267c37e26de1766ba15d9c589582", size = 5202053, upload-time = "2026-03-09T16:48:43.867Z" }, + { url = "https://files.pythonhosted.org/packages/44/2e/ad0472e69b02f83dc88983a9910d122178461606404be5b4838af6d1744a/fonttools-4.62.0-cp311-cp311-win32.whl", hash = "sha256:42c7848fa8836ab92c23b1617c407a905642521ff2d7897fe2bf8381530172f1", size = 2292852, upload-time = "2026-03-09T16:48:46.962Z" }, + { url = "https://files.pythonhosted.org/packages/77/ce/f5a4c42c117f8113ce04048053c128d17426751a508f26398110c993a074/fonttools-4.62.0-cp311-cp311-win_amd64.whl", hash = "sha256:4da779e8f342a32856075ddb193b2a024ad900bc04ecb744014c32409ae871ed", size = 2344367, upload-time = "2026-03-09T16:48:48.818Z" }, + { url = "https://files.pythonhosted.org/packages/ab/9d/7ad1ffc080619f67d0b1e0fa6a0578f0be077404f13fd8e448d1616a94a3/fonttools-4.62.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:22bde4dc12a9e09b5ced77f3b5053d96cf10c4976c6ac0dee293418ef289d221", size = 2870004, upload-time = "2026-03-09T16:48:50.837Z" }, + { url = "https://files.pythonhosted.org/packages/4d/8b/ba59069a490f61b737e064c3129453dbd28ee38e81d56af0d04d7e6b4de4/fonttools-4.62.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7199c73b326bad892f1cb53ffdd002128bfd58a89b8f662204fbf1daf8d62e85", size = 2414662, upload-time = "2026-03-09T16:48:53.295Z" }, + { url = "https://files.pythonhosted.org/packages/8c/8c/c52a4310de58deeac7e9ea800892aec09b00bb3eb0c53265b31ec02be115/fonttools-4.62.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d732938633681d6e2324e601b79e93f7f72395ec8681f9cdae5a8c08bc167e72", size = 5032975, upload-time = "2026-03-09T16:48:55.718Z" }, + { url = "https://files.pythonhosted.org/packages/0b/a1/d16318232964d786907b9b3613b8409f74cf0be2da400854509d3a864e43/fonttools-4.62.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:31a804c16d76038cc4e3826e07678efb0a02dc4f15396ea8e07088adbfb2578e", size = 4988544, upload-time = "2026-03-09T16:48:57.715Z" }, + { url = "https://files.pythonhosted.org/packages/b2/8d/7e745ca3e65852adc5e52a83dc213fe1b07d61cb5b394970fcd4b1199d1e/fonttools-4.62.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:090e74ac86e68c20150e665ef8e7e0c20cb9f8b395302c9419fa2e4d332c3b51", size = 4971296, upload-time = "2026-03-09T16:48:59.678Z" }, + { url = "https://files.pythonhosted.org/packages/e6/d4/b717a4874175146029ca1517e85474b1af80c9d9a306fc3161e71485eea5/fonttools-4.62.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8f086120e8be9e99ca1288aa5ce519833f93fe0ec6ebad2380c1dee18781f0b5", size = 5122503, upload-time = "2026-03-09T16:49:02.464Z" }, + { url = "https://files.pythonhosted.org/packages/cb/4b/92cfcba4bf8373f51c49c5ae4b512ead6fbda7d61a0e8c35a369d0db40a0/fonttools-4.62.0-cp312-cp312-win32.whl", hash = "sha256:37a73e5e38fd05c637daede6ffed5f3496096be7df6e4a3198d32af038f87527", size = 2281060, upload-time = "2026-03-09T16:49:04.385Z" }, + { url = "https://files.pythonhosted.org/packages/cd/06/cc96468781a4dc8ae2f14f16f32b32f69bde18cb9384aad27ccc7adf76f7/fonttools-4.62.0-cp312-cp312-win_amd64.whl", hash = "sha256:658ab837c878c4d2a652fcbb319547ea41693890e6434cf619e66f79387af3b8", size = 2331193, upload-time = "2026-03-09T16:49:06.598Z" }, + { url = "https://files.pythonhosted.org/packages/82/c7/985c1670aa6d82ef270f04cde11394c168f2002700353bd2bde405e59b8f/fonttools-4.62.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:274c8b8a87e439faf565d3bcd3f9f9e31bca7740755776a4a90a4bfeaa722efa", size = 2864929, upload-time = "2026-03-09T16:49:09.331Z" }, + { url = "https://files.pythonhosted.org/packages/c1/dc/c409c8ceec0d3119e9ab0b7b1a2e3c76d1f4d66e4a9db5c59e6b7652e7df/fonttools-4.62.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93e27131a5a0ae82aaadcffe309b1bae195f6711689722af026862bede05c07c", size = 2412586, upload-time = "2026-03-09T16:49:11.378Z" }, + { url = "https://files.pythonhosted.org/packages/5f/ac/8e300dbf7b4d135287c261ffd92ede02d9f48f0d2db14665fbc8b059588a/fonttools-4.62.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83c6524c5b93bad9c2939d88e619fedc62e913c19e673f25d5ab74e7a5d074e5", size = 5013708, upload-time = "2026-03-09T16:49:14.063Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bc/60d93477b653eeb1ddf5f9ec34be689b79234d82dbdded269ac0252715b8/fonttools-4.62.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:106aec9226f9498fc5345125ff7200842c01eda273ae038f5049b0916907acee", size = 4964355, upload-time = "2026-03-09T16:49:16.515Z" }, + { url = "https://files.pythonhosted.org/packages/cb/eb/6dc62bcc3c3598c28a3ecb77e69018869c3e109bd83031d4973c059d318b/fonttools-4.62.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:15d86b96c79013320f13bc1b15f94789edb376c0a2d22fb6088f33637e8dfcbc", size = 4953472, upload-time = "2026-03-09T16:49:18.494Z" }, + { url = "https://files.pythonhosted.org/packages/82/b3/3af7592d9b254b7b7fec018135f8776bfa0d1ad335476c2791b1334dc5e4/fonttools-4.62.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f16c07e5250d5d71d0f990a59460bc5620c3cc456121f2cfb5b60475699905f", size = 5094701, upload-time = "2026-03-09T16:49:21.67Z" }, + { url = "https://files.pythonhosted.org/packages/31/3d/976645583ab567d3ee75ff87b33aa1330fa2baeeeae5fc46210b4274dd45/fonttools-4.62.0-cp313-cp313-win32.whl", hash = "sha256:d31558890f3fa00d4f937d12708f90c7c142c803c23eaeb395a71f987a77ebe3", size = 2279710, upload-time = "2026-03-09T16:49:23.812Z" }, + { url = "https://files.pythonhosted.org/packages/f5/7a/e25245a30457595740041dba9d0ea8ec1b2517f2f1a6a741f15eba1a4edc/fonttools-4.62.0-cp313-cp313-win_amd64.whl", hash = "sha256:6826a5aa53fb6def8a66bf423939745f415546c4e92478a7c531b8b6282b6c3b", size = 2330291, upload-time = "2026-03-09T16:49:26.237Z" }, + { url = "https://files.pythonhosted.org/packages/9c/57/c2487c281dde03abb2dec244fd67059b8d118bd30a653cbf69e94084cb23/fonttools-4.62.0-py3-none-any.whl", hash = "sha256:75064f19a10c50c74b336aa5ebe7b1f89fd0fb5255807bfd4b0c6317098f4af3", size = 1152427, upload-time = "2026-03-09T16:50:04.074Z" }, +] + +[[package]] +name = "fqdn" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/3e/a80a8c077fd798951169626cde3e239adeba7dab75deb3555716415bd9b0/fqdn-1.5.1.tar.gz", hash = "sha256:105ed3677e767fb5ca086a0c1f4bb66ebc3c100be518f0e0d755d9eae164d89f", size = 6015, upload-time = "2021-03-11T07:16:29.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/58/8acf1b3e91c58313ce5cb67df61001fc9dcd21be4fadb76c1a2d540e09ed/fqdn-1.5.1-py3-none-any.whl", hash = "sha256:3a179af3761e4df6eb2e026ff9e1a3033d3587bf980a0b1b2e1e5d08d7358014", size = 9121, upload-time = "2021-03-11T07:16:28.351Z" }, +] + +[[package]] +name = "fragmenstein" +version = "1.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "matplotlib" }, + { name = "molecular-rectifier" }, + { name = "numpy" }, + { name = "pandarallel" }, + { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "pandas", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pandera" }, + { name = "pebble" }, + { name = "pyyaml" }, + { name = "rdkit" }, + { name = "rdkit-to-params" }, + { name = "requests" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "singledispatchmethod" }, + { name = "smallworld-api" }, + { name = "sqlitedict" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6f/04/607e375df3e1c97be61410d66ef318ad1c44eb384c0a7d517f168f307f1a/fragmenstein-1.1.2.tar.gz", hash = "sha256:e8e64e16e374f90c81162d20a6fbb2bd7d32338aa7f2346e42fb1844a8d50885", size = 611303, upload-time = "2026-02-23T22:29:54.742Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/04/058a9a6e8fdfa31d7533b11750b641334b968182d14f1b06dfb29932d9df/fragmenstein-1.1.2-py3-none-any.whl", hash = "sha256:0a7af54fce80806cea256324b6704705e8d8d0a27050083ceabfafa4b39daf23", size = 826074, upload-time = "2026-02-23T22:29:52.326Z" }, +] + +[[package]] +name = "gemmi" +version = "0.7.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/60/38/a79d9e672b837ceefa7da1921c33b10479362603c4c6370bc004d69558d6/gemmi-0.7.5.tar.gz", hash = "sha256:3328f26c8a8a0ef6a7fc8bb28e167818e324e4239dd4197d6b6066ae2b6315fe", size = 1523171, upload-time = "2026-03-02T08:27:31.157Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/4a/85b0a6eb5b885f9304eb33e6998a3c3ae5b5f0387eeb22272c3b7d1824b4/gemmi-0.7.5-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:dfa43fa2f02aada6427ebe4cba0665a5884a8a97fcf41f799bd3ea5fae2af241", size = 2834338, upload-time = "2026-03-02T08:31:00.878Z" }, + { url = "https://files.pythonhosted.org/packages/a1/c8/79d9ae367b4a101ace15466c5d5454f8e4ae92c286ebc827ac6aef2a4e48/gemmi-0.7.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b1b0a66dc42b898cf81c3951ebb7f9c4d96a12f80a7b166032d4b47bce01e9cd", size = 2716758, upload-time = "2026-03-02T08:31:05.283Z" }, + { url = "https://files.pythonhosted.org/packages/fd/fd/2388f03cb11558ec7b459f6ee4ab415a65fbdbeccff40115252f06ba86fb/gemmi-0.7.5-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:336074d483f5a7945eff61db18ed1a10475104367b99936dc52eec66551d3cdf", size = 2637256, upload-time = "2026-03-02T08:31:08.663Z" }, + { url = "https://files.pythonhosted.org/packages/f9/84/b77a22012e8198327648b5502cb204392b9da945abfe0e1b73d7d955c7b9/gemmi-0.7.5-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:37d7cfd00a6d717baaba72bb6c1cd392d4f2a01913261e17bbbc6c394a0f031d", size = 2991344, upload-time = "2026-03-02T08:31:11.434Z" }, + { url = "https://files.pythonhosted.org/packages/52/93/f8b6d7e0533cbc015690e9f730ceaa7004c33dc9adfdd7166ed7d242060d/gemmi-0.7.5-cp310-cp310-win_amd64.whl", hash = "sha256:b682ce2f67e46fc609dcd780d12bbec8d7769a3816506f24d7c2a761ccdb73c9", size = 2268178, upload-time = "2026-03-02T08:31:13.781Z" }, + { url = "https://files.pythonhosted.org/packages/7f/79/b13830a65bf9fc85474a984604f094cc18817dc93a784f4c567a2dc05169/gemmi-0.7.5-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:e134fd33f34bf9f2ffacd9e0207aeac6329dde818f62340e7390217a25ee8e2d", size = 2834430, upload-time = "2026-03-02T08:31:16.14Z" }, + { url = "https://files.pythonhosted.org/packages/42/15/26cac702cdf6281ddeb185d5912ce14e555e277c6e4caeb1d36966e43822/gemmi-0.7.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4db34eaa3d3fc102afea7a156330862cbeb82f557444c079403d4412e326c527", size = 2716532, upload-time = "2026-03-02T08:31:18.321Z" }, + { url = "https://files.pythonhosted.org/packages/2a/42/a60fd259b99785f04fe5c7fdbda1ec9c10aa9641d4efe9186c019a1689d9/gemmi-0.7.5-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fcd6b82ce6b33049aa43d2aaed167090a77eaa1370f51f5422a683edfe2eec97", size = 2638468, upload-time = "2026-03-02T08:31:20.26Z" }, + { url = "https://files.pythonhosted.org/packages/48/eb/46e443fc70b4aabe6e775521ff476aefb051db9acabb16a5cb51f04e3e2b/gemmi-0.7.5-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:895c63c7bcf30cffba97cf12c89dc3905f4645f838c17009b4534459a6c53a1e", size = 2991765, upload-time = "2026-03-02T08:31:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/32/7e/27b2313a644b42e02ed875ebeff73a1e88d7f564f15c1bf88c9557bbda0b/gemmi-0.7.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7f9524061282ceb114d5316af333667fae850896141ddbadfd2d275d9d6ac5ad", size = 3161503, upload-time = "2026-03-02T08:31:24.757Z" }, + { url = "https://files.pythonhosted.org/packages/8f/05/ee808eb8ece89c612d1bf6dd071ee870e129a69331383b95e482bbd4b692/gemmi-0.7.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:264726ed818aee8907dd8e6007f4a14c28fbf1f1b8ded3761e793e5a5f3284c6", size = 3514467, upload-time = "2026-03-02T08:31:26.812Z" }, + { url = "https://files.pythonhosted.org/packages/b9/5e/62402bf021183bc6122cb01b8f1be17cac67545713fb30f888f59357a782/gemmi-0.7.5-cp311-cp311-win_amd64.whl", hash = "sha256:06cb44f4e3657b7e3a2b23cd40b67a8e7b5d00bfb92ea94cb4060bd47ba50df6", size = 2268231, upload-time = "2026-03-02T08:31:29.469Z" }, + { url = "https://files.pythonhosted.org/packages/0b/72/7e33f0c1871d648088e3dd67ce47366ed942d625300f6d966730da92a7d7/gemmi-0.7.5-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:2da5d5c1d31fc8c3bffe7530c697c97f8389edff57e8b12898218c588c4f0dac", size = 2845071, upload-time = "2026-03-02T08:31:31.413Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3e/a6497e1c2c9bc6ed2b79e0f2d31a4ce509fd2a9eed4e4f7ac63eda8113cb/gemmi-0.7.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5682920985109c6a08616ae9aae080f8b46a9714534dc864b535e3e6d203d5b8", size = 2720607, upload-time = "2026-03-02T08:31:33.778Z" }, + { url = "https://files.pythonhosted.org/packages/df/31/704e6c7ffc251d1dc1a19a8cd2e30881a83978c6df8668ba052523fa1720/gemmi-0.7.5-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:255ca0b0a7f6fb0bf4322f2d69c5f94edf6e95fb801bc1d120ca8dd93b646065", size = 2624986, upload-time = "2026-03-02T08:31:35.902Z" }, + { url = "https://files.pythonhosted.org/packages/e8/88/5a431cd1ea7587408a66947384b39beb2ab2bcc1c87b7c4082f05036719f/gemmi-0.7.5-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:217bb9ac9da7c90704026dacfc0a0652a38f4df1e318225d8f35c75f1f8c7ebf", size = 2982717, upload-time = "2026-03-02T08:31:37.781Z" }, + { url = "https://files.pythonhosted.org/packages/36/e0/ca646b4e22b3d6129ce56a087a9031f7a7843d47425f0adc38a7ab789b24/gemmi-0.7.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:74e1b5177b626aadb819fd8168f5d6064c04a2a1e45c87f357a96d30ddafc749", size = 3146031, upload-time = "2026-03-02T08:31:39.744Z" }, + { url = "https://files.pythonhosted.org/packages/d5/cc/47e6039859393175a9b38f9a72732c018a3052d838fecf1ff635d8b84d95/gemmi-0.7.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6d30fa7ae889149c22dbb58899e77117e6548edc6e8ccfae3b4b2a259464d2ee", size = 3505196, upload-time = "2026-03-02T08:31:41.651Z" }, + { url = "https://files.pythonhosted.org/packages/eb/f2/53be7a4ba5816e13c39be0f728facac4bcb39cf4903ceeec54b006511c8f/gemmi-0.7.5-cp312-cp312-win_amd64.whl", hash = "sha256:a1fdb6f72006495b5119e3a8bb5c3185efa708b785bd4a5ce4397ef7abb3fec7", size = 2270488, upload-time = "2026-03-02T08:31:43.898Z" }, + { url = "https://files.pythonhosted.org/packages/c4/80/fd758344a72ca7b5e1c5bbdc1d263f3b215d3897941b5f450380445ca0a9/gemmi-0.7.5-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:ef9b6ada1c00c6ba7c7a5b9e938cc3b45d83e775c23d12bf63b6882d5f3cdd6b", size = 2844981, upload-time = "2026-03-02T08:31:45.741Z" }, + { url = "https://files.pythonhosted.org/packages/c1/9c/1236dd7d22ed48527286b613c84e3376ea731b65e6734b6e6a0b4d03744c/gemmi-0.7.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c7d8b08c33fe6ba375223306149092440c69cbfbd55c3d3e3436e5fb315a225d", size = 2720773, upload-time = "2026-03-02T08:31:47.494Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/ccf890f054f2fc12ff3a43a604a7a1e9f99706f057394e5c7d51c67cf6ed/gemmi-0.7.5-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f2bd55985d7cf4403985118f677a187a3f0bb96fd314fb4582e66c2ab4a752ec", size = 2625116, upload-time = "2026-03-02T08:31:49.848Z" }, + { url = "https://files.pythonhosted.org/packages/a3/8c/db8e79c4c744ebae1dcf25f7dbcc5d7df912cdbcdf7221e761479e8bd04b/gemmi-0.7.5-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:750b4d9751aaf1460ac4f0f45308ddced25f47bcf7a30355eb3b1f779f03952a", size = 2982474, upload-time = "2026-03-02T08:31:52.09Z" }, + { url = "https://files.pythonhosted.org/packages/05/eb/24c0071ad231b22dac9acf7e7e544e0b6466307a01d716c8a06363fa70a4/gemmi-0.7.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:789f0e05e8ad020c69011351c54cc1a9555f6aaf2ac18e00e5624eb5255c309d", size = 3146075, upload-time = "2026-03-02T08:31:54.163Z" }, + { url = "https://files.pythonhosted.org/packages/08/06/da6fe6eb09e7f3e439e8c5b85908bf9539dfb7afe19bb4853f0c1fd98e4c/gemmi-0.7.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f34643c917c9ae0c26cded3044ad4634987469797188782b882cd2812c7769b1", size = 3505371, upload-time = "2026-03-02T08:31:56.056Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ab/7d7463cda94f8b68b969ea97aaad679655a0e436efd6a643e528a8de114e/gemmi-0.7.5-cp313-cp313-win_amd64.whl", hash = "sha256:ad1f72ffa24adbfaf259e11471f6f071a668667f6ca846051f3bfea024fd337d", size = 2270352, upload-time = "2026-03-02T08:31:58.538Z" }, +] + +[[package]] +name = "glob2" +version = "0.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/a5/bbbc3b74a94fbdbd7915e7ad030f16539bfdc1362f7e9003b594f0537950/glob2-0.7.tar.gz", hash = "sha256:85c3dbd07c8aa26d63d7aacee34fa86e9a91a3873bc30bf62ec46e531f92ab8c", size = 10697, upload-time = "2019-06-10T23:33:48.308Z" } + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "haggis" +version = "0.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/c8/832f5820c4967182ab341b8b868465b550a94bd9a16267025b24f938040f/haggis-0.14.1.tar.gz", hash = "sha256:16f1f9352107e398bbd87475f267ff7f8d7648f407014770b35862e8d14ff3c1", size = 178957, upload-time = "2024-10-17T21:49:33.051Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/11/4d86aca86b85493ef50c029f9aa155cb8013234b8eee124ff9d7877877dd/haggis-0.14.1-py3-none-any.whl", hash = "sha256:7962ce71bb0b40c3d81342f8083e256895c122c96cf166b7d120317a107f290a", size = 221221, upload-time = "2024-10-17T21:49:31.186Z" }, +] + +[[package]] +name = "hippo-plot" +version = "0.0.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dash" }, + { name = "dash-dangerously-set-inner-html" }, + { name = "mpytools" }, + { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "pandas", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "plotly" }, + { name = "rdkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/64/98/ef0347cd015a813ba35c7e6007967c2b01bb7b86e03a5be469f8512d66b8/hippo_plot-0.0.7.tar.gz", hash = "sha256:ce13eed683772fd009c54384e4f3214978d98785292e65fc1f58078e2b22ef5d", size = 4731, upload-time = "2024-05-03T13:09:57.786Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/da/ab6152212987c563c74d369bd1cf03b945ac10f51bd9e6147943c5396097/hippo_plot-0.0.7-py3-none-any.whl", hash = "sha256:1c1ca1726a0da9d5977d64fd0c8ed7b68fdcef90d14a43324d230625ca6863a8", size = 4761, upload-time = "2024-05-03T13:09:56.822Z" }, +] + +[[package]] +name = "hirsch" +version = "0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/c2/d4897bd5aa06a2170d085be3aa25ad7ff5c299b61c0ea3af4e1fc248d382/hirsch-0.1.tar.gz", hash = "sha256:e82d9c56580a846a4d7d8e16d21b77a63537dc7c34e76c0d83054d93bd967578", size = 15892, upload-time = "2024-10-31T15:26:53.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/13/7af74c270c485607fcf3d2049544cf6d1e79966f641e6bf375b1fdbc8668/hirsch-0.1-py3-none-any.whl", hash = "sha256:4890897ffc7178ba5aab66dcf74ee77ab11c786b5ec4dc45081d0faa23a235b8", size = 15106, upload-time = "2024-10-31T15:26:51.683Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "identify" +version = "2.6.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/84/376a3b96e5a8d33a7aa2c5b3b31a4b3c364117184bf0b17418055f6ace66/identify-2.6.17.tar.gz", hash = "sha256:f816b0b596b204c9fdf076ded172322f2723cf958d02f9c3587504834c8ff04d", size = 99579, upload-time = "2026-03-01T20:04:12.702Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/66/71c1227dff78aaeb942fed29dd5651f2aec166cc7c9aeea3e8b26a539b7d/identify-2.6.17-py2.py3-none-any.whl", hash = "sha256:be5f8412d5ed4b20f2bd41a65f920990bdccaa6a4a18a08f1eefdcd0bdd885f0", size = 99382, upload-time = "2026-03-01T20:04:11.439Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "8.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "ipykernel" +version = "7.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "appnope", marker = "sys_platform == 'darwin'" }, + { name = "comm" }, + { name = "debugpy" }, + { name = "ipython", version = "8.39.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "ipython", version = "9.10.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "ipython", version = "9.11.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "matplotlib-inline" }, + { name = "nest-asyncio" }, + { name = "packaging" }, + { name = "psutil" }, + { name = "pyzmq" }, + { name = "tornado" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/8d/b68b728e2d06b9e0051019640a40a9eb7a88fcd82c2e1b5ce70bef5ff044/ipykernel-7.2.0.tar.gz", hash = "sha256:18ed160b6dee2cbb16e5f3575858bc19d8f1fe6046a9a680c708494ce31d909e", size = 176046, upload-time = "2026-02-06T16:43:27.403Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/b9/e73d5d9f405cba7706c539aa8b311b49d4c2f3d698d9c12f815231169c71/ipykernel-7.2.0-py3-none-any.whl", hash = "sha256:3bbd4420d2b3cc105cbdf3756bfc04500b1e52f090a90716851f3916c62e1661", size = 118788, upload-time = "2026-02-06T16:43:25.149Z" }, +] + +[[package]] +name = "ipython" +version = "8.39.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" }, + { name = "decorator", marker = "python_full_version < '3.11'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "jedi", marker = "python_full_version < '3.11'" }, + { name = "matplotlib-inline", marker = "python_full_version < '3.11'" }, + { name = "pexpect", marker = "python_full_version < '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit", marker = "python_full_version < '3.11'" }, + { name = "pygments", marker = "python_full_version < '3.11'" }, + { name = "stack-data", marker = "python_full_version < '3.11'" }, + { name = "traitlets", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/18/f8598d287006885e7136451fdea0755af4ebcbfe342836f24deefaed1164/ipython-8.39.0.tar.gz", hash = "sha256:4110ae96012c379b8b6db898a07e186c40a2a1ef5d57a7fa83166047d9da7624", size = 5513971, upload-time = "2026-03-27T10:02:13.94Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/56/4cc7fc9e9e3f38fd324f24f8afe0ad8bb5fa41283f37f1aaf9de0612c968/ipython-8.39.0-py3-none-any.whl", hash = "sha256:bb3c51c4fa8148ab1dea07a79584d1c854e234ea44aa1283bcb37bc75054651f", size = 831849, upload-time = "2026-03-27T10:02:07.846Z" }, +] + +[[package]] +name = "ipython" +version = "9.10.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version == '3.11.*' and sys_platform == 'win32'" }, + { name = "decorator", marker = "python_full_version == '3.11.*'" }, + { name = "ipython-pygments-lexers", marker = "python_full_version == '3.11.*'" }, + { name = "jedi", marker = "python_full_version == '3.11.*'" }, + { name = "matplotlib-inline", marker = "python_full_version == '3.11.*'" }, + { name = "pexpect", marker = "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit", marker = "python_full_version == '3.11.*'" }, + { name = "pygments", marker = "python_full_version == '3.11.*'" }, + { name = "stack-data", marker = "python_full_version == '3.11.*'" }, + { name = "traitlets", marker = "python_full_version == '3.11.*'" }, + { name = "typing-extensions", marker = "python_full_version == '3.11.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c5/25/daae0e764047b0a2480c7bbb25d48f4f509b5818636562eeac145d06dfee/ipython-9.10.1.tar.gz", hash = "sha256:e170e9b2a44312484415bdb750492699bf329233b03f2557a9692cce6466ada4", size = 4426663, upload-time = "2026-03-27T09:53:26.244Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/09/ba70f8d662d5671687da55ad2cc0064cf795b15e1eea70907532202e7c97/ipython-9.10.1-py3-none-any.whl", hash = "sha256:82d18ae9fb9164ded080c71ef92a182ee35ee7db2395f67616034bebb020a232", size = 622827, upload-time = "2026-03-27T09:53:24.566Z" }, +] + +[[package]] +name = "ipython" +version = "9.11.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.13' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version >= '3.13' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version >= '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, + { name = "decorator", marker = "python_full_version >= '3.12'" }, + { name = "ipython-pygments-lexers", marker = "python_full_version >= '3.12'" }, + { name = "jedi", marker = "python_full_version >= '3.12'" }, + { name = "matplotlib-inline", marker = "python_full_version >= '3.12'" }, + { name = "pexpect", marker = "python_full_version >= '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit", marker = "python_full_version >= '3.12'" }, + { name = "pygments", marker = "python_full_version >= '3.12'" }, + { name = "stack-data", marker = "python_full_version >= '3.12'" }, + { name = "traitlets", marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/28/a4698eda5a8928a45d6b693578b135b753e14fa1c2b36ee9441e69a45576/ipython-9.11.0.tar.gz", hash = "sha256:2a94bc4406b22ecc7e4cb95b98450f3ea493a76bec8896cda11b78d7752a6667", size = 4427354, upload-time = "2026-03-05T08:57:30.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/90/45c72becc57158facc6a6404f663b77bbcea2519ca57f760e2879ae1315d/ipython-9.11.0-py3-none-any.whl", hash = "sha256:6922d5bcf944c6e525a76a0a304451b60a2b6f875e86656d8bc2dfda5d710e19", size = 624222, upload-time = "2026-03-05T08:57:28.94Z" }, +] + +[[package]] +name = "ipython-pygments-lexers" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pygments", marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" }, +] + +[[package]] +name = "ipywidgets" +version = "8.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "comm" }, + { name = "ipython", version = "8.39.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "ipython", version = "9.10.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "ipython", version = "9.11.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "jupyterlab-widgets" }, + { name = "traitlets" }, + { name = "widgetsnbextension" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4c/ae/c5ce1edc1afe042eadb445e95b0671b03cee61895264357956e61c0d2ac0/ipywidgets-8.1.8.tar.gz", hash = "sha256:61f969306b95f85fba6b6986b7fe45d73124d1d9e3023a8068710d47a22ea668", size = 116739, upload-time = "2025-11-01T21:18:12.393Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/6d/0d9848617b9f753b87f214f1c682592f7ca42de085f564352f10f0843026/ipywidgets-8.1.8-py3-none-any.whl", hash = "sha256:ecaca67aed704a338f88f67b1181b58f821ab5dc89c1f0f5ef99db43c1c2921e", size = 139808, upload-time = "2025-11-01T21:18:10.956Z" }, +] + +[[package]] +name = "isoduration" +version = "20.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "arrow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7c/1a/3c8edc664e06e6bd06cce40c6b22da5f1429aa4224d0c590f3be21c91ead/isoduration-20.11.0.tar.gz", hash = "sha256:ac2f9015137935279eac671f94f89eb00584f940f5dc49462a0c4ee692ba1bd9", size = 11649, upload-time = "2020-11-01T11:00:00.312Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/55/e5326141505c5d5e34c5e0935d2908a74e4561eca44108fbfb9c13d2911a/isoduration-20.11.0-py3-none-any.whl", hash = "sha256:b2904c2a4228c3d44f409c8ae8e2370eb21a26f7ac2ec5446df141dde3452042", size = 11321, upload-time = "2020-11-01T10:59:58.02Z" }, +] + +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + +[[package]] +name = "jedi" +version = "0.19.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "parso" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/3a/79a912fbd4d8dd6fbb02bf69afd3bb72cf0c729bb3063c6f4498603db17a/jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0", size = 1231287, upload-time = "2024-11-11T01:41:42.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278, upload-time = "2024-11-11T01:41:40.175Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "joblib" +version = "1.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, +] + +[[package]] +name = "json5" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/77/e8/a3f261a66e4663f22700bc8a17c08cb83e91fbf086726e7a228398968981/json5-0.13.0.tar.gz", hash = "sha256:b1edf8d487721c0bf64d83c28e91280781f6e21f4a797d3261c7c828d4c165bf", size = 52441, upload-time = "2026-01-01T19:42:14.99Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/9e/038522f50ceb7e74f1f991bf1b699f24b0c2bbe7c390dd36ad69f4582258/json5-0.13.0-py3-none-any.whl", hash = "sha256:9a08e1dd65f6a4d4c6fa82d216cf2477349ec2346a38fd70cc11d2557499fbcc", size = 36163, upload-time = "2026-01-01T19:42:13.962Z" }, +] + +[[package]] +name = "jsonpointer" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/0a/eebeb1fa92507ea94016a2a790b93c2ae41a7e18778f85471dc54475ed25/jsonpointer-3.0.0.tar.gz", hash = "sha256:2b2d729f2091522d61c3b31f82e11870f60b68f43fbc705cb76bf4b832af59ef", size = 9114, upload-time = "2024-06-10T19:24:42.462Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/92/5e77f98553e9e75130c78900d000368476aed74276eb8ae8796f65f00918/jsonpointer-3.0.0-py2.py3-none-any.whl", hash = "sha256:13e088adc14fca8b6aa8177c044e12701e6ad4b28ff10e65f2267a90109c9942", size = 7595, upload-time = "2024-06-10T19:24:40.698Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[package.optional-dependencies] +format-nongpl = [ + { name = "fqdn" }, + { name = "idna" }, + { name = "isoduration" }, + { name = "jsonpointer" }, + { name = "rfc3339-validator" }, + { name = "rfc3986-validator" }, + { name = "rfc3987-syntax" }, + { name = "uri-template" }, + { name = "webcolors" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "jupyter" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ipykernel" }, + { name = "ipywidgets" }, + { name = "jupyter-console" }, + { name = "jupyterlab" }, + { name = "nbconvert" }, + { name = "notebook" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/f3/af28ea964ab8bc1e472dba2e82627d36d470c51f5cd38c37502eeffaa25e/jupyter-1.1.1.tar.gz", hash = "sha256:d55467bceabdea49d7e3624af7e33d59c37fff53ed3a350e1ac957bed731de7a", size = 5714959, upload-time = "2024-08-30T07:15:48.299Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/64/285f20a31679bf547b75602702f7800e74dbabae36ef324f716c02804753/jupyter-1.1.1-py2.py3-none-any.whl", hash = "sha256:7a59533c22af65439b24bbe60373a4e95af8f16ac65a6c00820ad378e3f7cc83", size = 2657, upload-time = "2024-08-30T07:15:47.045Z" }, +] + +[[package]] +name = "jupyter-client" +version = "8.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-core" }, + { name = "python-dateutil" }, + { name = "pyzmq" }, + { name = "tornado" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/e4/ba649102a3bc3fbca54e7239fb924fd434c766f855693d86de0b1f2bec81/jupyter_client-8.8.0.tar.gz", hash = "sha256:d556811419a4f2d96c869af34e854e3f059b7cc2d6d01a9cd9c85c267691be3e", size = 348020, upload-time = "2026-01-08T13:55:47.938Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/0b/ceb7694d864abc0a047649aec263878acb9f792e1fec3e676f22dc9015e3/jupyter_client-8.8.0-py3-none-any.whl", hash = "sha256:f93a5b99c5e23a507b773d3a1136bd6e16c67883ccdbd9a829b0bbdb98cd7d7a", size = 107371, upload-time = "2026-01-08T13:55:45.562Z" }, +] + +[[package]] +name = "jupyter-console" +version = "6.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ipykernel" }, + { name = "ipython", version = "8.39.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "ipython", version = "9.10.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "ipython", version = "9.11.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "prompt-toolkit" }, + { name = "pygments" }, + { name = "pyzmq" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bd/2d/e2fd31e2fc41c14e2bcb6c976ab732597e907523f6b2420305f9fc7fdbdb/jupyter_console-6.6.3.tar.gz", hash = "sha256:566a4bf31c87adbfadf22cdf846e3069b59a71ed5da71d6ba4d8aaad14a53539", size = 34363, upload-time = "2023-03-06T14:13:31.02Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/77/71d78d58f15c22db16328a476426f7ac4a60d3a5a7ba3b9627ee2f7903d4/jupyter_console-6.6.3-py3-none-any.whl", hash = "sha256:309d33409fcc92ffdad25f0bcdf9a4a9daa61b6f341177570fdac03de5352485", size = 24510, upload-time = "2023-03-06T14:13:28.229Z" }, +] + +[[package]] +name = "jupyter-core" +version = "5.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "platformdirs" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/49/9d1284d0dc65e2c757b74c6687b6d319b02f822ad039e5c512df9194d9dd/jupyter_core-5.9.1.tar.gz", hash = "sha256:4d09aaff303b9566c3ce657f580bd089ff5c91f5f89cf7d8846c3cdf465b5508", size = 89814, upload-time = "2025-10-16T19:19:18.444Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/e7/80988e32bf6f73919a113473a604f5a8f09094de312b9d52b79c2df7612b/jupyter_core-5.9.1-py3-none-any.whl", hash = "sha256:ebf87fdc6073d142e114c72c9e29a9d7ca03fad818c5d300ce2adc1fb0743407", size = 29032, upload-time = "2025-10-16T19:19:16.783Z" }, +] + +[[package]] +name = "jupyter-events" +version = "0.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonschema", extra = ["format-nongpl"] }, + { name = "packaging" }, + { name = "python-json-logger" }, + { name = "pyyaml" }, + { name = "referencing" }, + { name = "rfc3339-validator" }, + { name = "rfc3986-validator" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/c3/306d090461e4cf3cd91eceaff84bede12a8e52cd821c2d20c9a4fd728385/jupyter_events-0.12.0.tar.gz", hash = "sha256:fc3fce98865f6784c9cd0a56a20644fc6098f21c8c33834a8d9fe383c17e554b", size = 62196, upload-time = "2025-02-03T17:23:41.485Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/48/577993f1f99c552f18a0428731a755e06171f9902fa118c379eb7c04ea22/jupyter_events-0.12.0-py3-none-any.whl", hash = "sha256:6464b2fa5ad10451c3d35fabc75eab39556ae1e2853ad0c0cc31b656731a97fb", size = 19430, upload-time = "2025-02-03T17:23:38.643Z" }, +] + +[[package]] +name = "jupyter-lsp" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-server" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/5a/9066c9f8e94ee517133cd98dba393459a16cd48bba71a82f16a65415206c/jupyter_lsp-2.3.0.tar.gz", hash = "sha256:458aa59339dc868fb784d73364f17dbce8836e906cd75fd471a325cba02e0245", size = 54823, upload-time = "2025-08-27T17:47:34.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/60/1f6cee0c46263de1173894f0fafcb3475ded276c472c14d25e0280c18d6d/jupyter_lsp-2.3.0-py3-none-any.whl", hash = "sha256:e914a3cb2addf48b1c7710914771aaf1819d46b2e5a79b0f917b5478ec93f34f", size = 76687, upload-time = "2025-08-27T17:47:33.15Z" }, +] + +[[package]] +name = "jupyter-server" +version = "2.17.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "argon2-cffi" }, + { name = "jinja2" }, + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "jupyter-events" }, + { name = "jupyter-server-terminals" }, + { name = "nbconvert" }, + { name = "nbformat" }, + { name = "overrides", marker = "python_full_version < '3.12'" }, + { name = "packaging" }, + { name = "prometheus-client" }, + { name = "pywinpty", marker = "os_name == 'nt'" }, + { name = "pyzmq" }, + { name = "send2trash" }, + { name = "terminado" }, + { name = "tornado" }, + { name = "traitlets" }, + { name = "websocket-client" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/ac/e040ec363d7b6b1f11304cc9f209dac4517ece5d5e01821366b924a64a50/jupyter_server-2.17.0.tar.gz", hash = "sha256:c38ea898566964c888b4772ae1ed58eca84592e88251d2cfc4d171f81f7e99d5", size = 731949, upload-time = "2025-08-21T14:42:54.042Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/80/a24767e6ca280f5a49525d987bf3e4d7552bf67c8be07e8ccf20271f8568/jupyter_server-2.17.0-py3-none-any.whl", hash = "sha256:e8cb9c7db4251f51ed307e329b81b72ccf2056ff82d50524debde1ee1870e13f", size = 388221, upload-time = "2025-08-21T14:42:52.034Z" }, +] + +[[package]] +name = "jupyter-server-terminals" +version = "0.5.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pywinpty", marker = "os_name == 'nt'" }, + { name = "terminado" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/a7/bcd0a9b0cbba88986fe944aaaf91bfda603e5a50bda8ed15123f381a3b2f/jupyter_server_terminals-0.5.4.tar.gz", hash = "sha256:bbda128ed41d0be9020349f9f1f2a4ab9952a73ed5f5ac9f1419794761fb87f5", size = 31770, upload-time = "2026-01-14T16:53:20.213Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/2d/6674563f71c6320841fc300911a55143925112a72a883e2ca71fba4c618d/jupyter_server_terminals-0.5.4-py3-none-any.whl", hash = "sha256:55be353fc74a80bc7f3b20e6be50a55a61cd525626f578dcb66a5708e2007d14", size = 13704, upload-time = "2026-01-14T16:53:18.738Z" }, +] + +[[package]] +name = "jupyterlab" +version = "4.5.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "async-lru" }, + { name = "httpx" }, + { name = "ipykernel" }, + { name = "jinja2" }, + { name = "jupyter-core" }, + { name = "jupyter-lsp" }, + { name = "jupyter-server" }, + { name = "jupyterlab-server" }, + { name = "notebook-shim" }, + { name = "packaging" }, + { name = "setuptools" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "tornado" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6e/2d/953a5612a34a3c799a62566a548e711d103f631672fd49650e0f2de80870/jupyterlab-4.5.5.tar.gz", hash = "sha256:eac620698c59eb810e1729909be418d9373d18137cac66637141abba613b3fda", size = 23968441, upload-time = "2026-02-23T18:57:34.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/52/372d3494766d690dfdd286871bf5f7fb9a6c61f7566ccaa7153a163dd1df/jupyterlab-4.5.5-py3-none-any.whl", hash = "sha256:a35694a40a8e7f2e82f387472af24e61b22adcce87b5a8ab97a5d9c486202a6d", size = 12446824, upload-time = "2026-02-23T18:57:30.398Z" }, +] + +[[package]] +name = "jupyterlab-pygments" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/90/51/9187be60d989df97f5f0aba133fa54e7300f17616e065d1ada7d7646b6d6/jupyterlab_pygments-0.3.0.tar.gz", hash = "sha256:721aca4d9029252b11cfa9d185e5b5af4d54772bb8072f9b7036f4170054d35d", size = 512900, upload-time = "2023-11-23T09:26:37.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/dd/ead9d8ea85bf202d90cc513b533f9c363121c7792674f78e0d8a854b63b4/jupyterlab_pygments-0.3.0-py3-none-any.whl", hash = "sha256:841a89020971da1d8693f1a99997aefc5dc424bb1b251fd6322462a1b8842780", size = 15884, upload-time = "2023-11-23T09:26:34.325Z" }, +] + +[[package]] +name = "jupyterlab-server" +version = "2.28.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "babel" }, + { name = "jinja2" }, + { name = "json5" }, + { name = "jsonschema" }, + { name = "jupyter-server" }, + { name = "packaging" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d6/2c/90153f189e421e93c4bb4f9e3f59802a1f01abd2ac5cf40b152d7f735232/jupyterlab_server-2.28.0.tar.gz", hash = "sha256:35baa81898b15f93573e2deca50d11ac0ae407ebb688299d3a5213265033712c", size = 76996, upload-time = "2025-10-22T13:59:18.37Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/07/a000fe835f76b7e1143242ab1122e6362ef1c03f23f83a045c38859c2ae0/jupyterlab_server-2.28.0-py3-none-any.whl", hash = "sha256:e4355b148fdcf34d312bbbc80f22467d6d20460e8b8736bf235577dd18506968", size = 59830, upload-time = "2025-10-22T13:59:16.767Z" }, +] + +[[package]] +name = "jupyterlab-widgets" +version = "3.0.16" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/2d/ef58fed122b268c69c0aa099da20bc67657cdfb2e222688d5731bd5b971d/jupyterlab_widgets-3.0.16.tar.gz", hash = "sha256:423da05071d55cf27a9e602216d35a3a65a3e41cdf9c5d3b643b814ce38c19e0", size = 897423, upload-time = "2025-11-01T21:11:29.724Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/b5/36c712098e6191d1b4e349304ef73a8d06aed77e56ceaac8c0a306c7bda1/jupyterlab_widgets-3.0.16-py3-none-any.whl", hash = "sha256:45fa36d9c6422cf2559198e4db481aa243c7a32d9926b500781c830c80f7ecf8", size = 914926, upload-time = "2025-11-01T21:11:28.008Z" }, +] + +[[package]] +name = "kaleido" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "choreographer" }, + { name = "logistro" }, + { name = "orjson" }, + { name = "packaging" }, + { name = "pytest-timeout" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/ad/76eec859b71eda803a88ea50ed3f270281254656bb23d19eb0a39aa706a0/kaleido-1.2.0.tar.gz", hash = "sha256:fa621a14423e8effa2895a2526be00af0cf21655be1b74b7e382c171d12e71ef", size = 64160, upload-time = "2025-11-04T21:24:23.833Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/97/f6de8d4af54d6401d6581a686cce3e3e2371a79ba459a449104e026c08bc/kaleido-1.2.0-py3-none-any.whl", hash = "sha256:c27ed82b51df6b923d0e656feac221343a0dbcd2fb9bc7e6b1db97f61e9a1513", size = 68997, upload-time = "2025-11-04T21:24:21.704Z" }, +] + +[[package]] +name = "kiwisolver" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/67/9c61eccb13f0bdca9307614e782fec49ffdde0f7a2314935d489fa93cd9c/kiwisolver-1.5.0.tar.gz", hash = "sha256:d4193f3d9dc3f6f79aaed0e5637f45d98850ebf01f7ca20e69457f3e8946b66a", size = 103482, upload-time = "2026-03-09T13:15:53.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/f8/06549565caa026e540b7e7bab5c5a90eb7ca986015f4c48dace243cd24d9/kiwisolver-1.5.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:32cc0a5365239a6ea0c6ed461e8838d053b57e397443c0ca894dcc8e388d4374", size = 122802, upload-time = "2026-03-09T13:12:37.515Z" }, + { url = "https://files.pythonhosted.org/packages/84/eb/8476a0818850c563ff343ea7c9c05dcdcbd689a38e01aa31657df01f91fa/kiwisolver-1.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cc0b66c1eec9021353a4b4483afb12dfd50e3669ffbb9152d6842eb34c7e29fd", size = 66216, upload-time = "2026-03-09T13:12:38.812Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/f9c8a6b4c21aed4198566e45923512986d6cef530e7263b3a5f823546561/kiwisolver-1.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:86e0287879f75621ae85197b0877ed2f8b7aa57b511c7331dce2eb6f4de7d476", size = 63917, upload-time = "2026-03-09T13:12:40.053Z" }, + { url = "https://files.pythonhosted.org/packages/f1/0e/ba4ae25d03722f64de8b2c13e80d82ab537a06b30fc7065183c6439357e3/kiwisolver-1.5.0-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:62f59da443c4f4849f73a51a193b1d9d258dcad0c41bc4d1b8fb2bcc04bfeb22", size = 1628776, upload-time = "2026-03-09T13:12:41.976Z" }, + { url = "https://files.pythonhosted.org/packages/8a/e4/3f43a011bc8a0860d1c96f84d32fa87439d3feedf66e672fef03bf5e8bac/kiwisolver-1.5.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9190426b7aa26c5229501fa297b8d0653cfd3f5a36f7990c264e157cbf886b3b", size = 1228164, upload-time = "2026-03-09T13:12:44.002Z" }, + { url = "https://files.pythonhosted.org/packages/4b/34/3a901559a1e0c218404f9a61a93be82d45cb8f44453ba43088644980f033/kiwisolver-1.5.0-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c8277104ded0a51e699c8c3aff63ce2c56d4ed5519a5f73e0fd7057f959a2b9e", size = 1246656, upload-time = "2026-03-09T13:12:45.557Z" }, + { url = "https://files.pythonhosted.org/packages/87/9e/f78c466ea20527822b95ad38f141f2de1dcd7f23fb8716b002b0d91bbe59/kiwisolver-1.5.0-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8f9baf6f0a6e7571c45c8863010b45e837c3ee1c2c77fcd6ef423be91b21fedb", size = 1295562, upload-time = "2026-03-09T13:12:47.562Z" }, + { url = "https://files.pythonhosted.org/packages/0a/66/fd0e4a612e3a286c24e6d6f3a5428d11258ed1909bc530ba3b59807fd980/kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cff8e5383db4989311f99e814feeb90c4723eb4edca425b9d5d9c3fefcdd9537", size = 2178473, upload-time = "2026-03-09T13:12:50.254Z" }, + { url = "https://files.pythonhosted.org/packages/dc/8e/6cac929e0049539e5ee25c1ee937556f379ba5204840d03008363ced662d/kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ebae99ed6764f2b5771c522477b311be313e8841d2e0376db2b10922daebbba4", size = 2274035, upload-time = "2026-03-09T13:12:51.785Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d3/9d0c18f1b52ea8074b792452cf17f1f5a56bd0302a85191f405cfbf9da16/kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:d5cd5189fc2b6a538b75ae45433140c4823463918f7b1617c31e68b085c0022c", size = 2443217, upload-time = "2026-03-09T13:12:53.329Z" }, + { url = "https://files.pythonhosted.org/packages/45/2a/6e19368803a038b2a90857bf4ee9e3c7b667216d045866bf22d3439fd75e/kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f42c23db5d1521218a3276bb08666dcb662896a0be7347cba864eca45ff64ede", size = 2249196, upload-time = "2026-03-09T13:12:55.057Z" }, + { url = "https://files.pythonhosted.org/packages/75/2b/3f641dfcbe72e222175d626bacf2f72c3b34312afec949dd1c50afa400f5/kiwisolver-1.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:94eff26096eb5395136634622515b234ecb6c9979824c1f5004c6e3c3c85ccd2", size = 73389, upload-time = "2026-03-09T13:12:56.496Z" }, + { url = "https://files.pythonhosted.org/packages/da/88/299b137b9e0025d8982e03d2d52c123b0a2b159e84b0ef1501ef446339cf/kiwisolver-1.5.0-cp310-cp310-win_arm64.whl", hash = "sha256:dd952e03bfbb096cfe2dd35cd9e00f269969b67536cb4370994afc20ff2d0875", size = 64782, upload-time = "2026-03-09T13:12:57.609Z" }, + { url = "https://files.pythonhosted.org/packages/12/dd/a495a9c104be1c476f0386e714252caf2b7eca883915422a64c50b88c6f5/kiwisolver-1.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9eed0f7edbb274413b6ee781cca50541c8c0facd3d6fd289779e494340a2b85c", size = 122798, upload-time = "2026-03-09T13:12:58.963Z" }, + { url = "https://files.pythonhosted.org/packages/11/60/37b4047a2af0cf5ef6d8b4b26e91829ae6fc6a2d1f74524bcb0e7cd28a32/kiwisolver-1.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c4923e404d6bcd91b6779c009542e5647fef32e4a5d75e115e3bbac6f2335eb", size = 66216, upload-time = "2026-03-09T13:13:00.155Z" }, + { url = "https://files.pythonhosted.org/packages/0a/aa/510dc933d87767584abfe03efa445889996c70c2990f6f87c3ebaa0a18c5/kiwisolver-1.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0df54df7e686afa55e6f21fb86195224a6d9beb71d637e8d7920c95cf0f89aac", size = 63911, upload-time = "2026-03-09T13:13:01.671Z" }, + { url = "https://files.pythonhosted.org/packages/80/46/bddc13df6c2a40741e0cc7865bb1c9ed4796b6760bd04ce5fae3928ef917/kiwisolver-1.5.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2517e24d7315eb51c10664cdb865195df38ab74456c677df67bb47f12d088a27", size = 1438209, upload-time = "2026-03-09T13:13:03.385Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d6/76621246f5165e5372f02f5e6f3f48ea336a8f9e96e43997d45b240ed8cd/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff710414307fefa903e0d9bdf300972f892c23477829f49504e59834f4195398", size = 1248888, upload-time = "2026-03-09T13:13:05.231Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c1/31559ec6fb39a5b48035ce29bb63ade628f321785f38c384dee3e2c08bc1/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6176c1811d9d5a04fa391c490cc44f451e240697a16977f11c6f722efb9041db", size = 1266304, upload-time = "2026-03-09T13:13:06.743Z" }, + { url = "https://files.pythonhosted.org/packages/5e/ef/1cb8276f2d29cc6a41e0a042f27946ca347d3a4a75acf85d0a16aa6dcc82/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50847dca5d197fcbd389c805aa1a1cf32f25d2e7273dc47ab181a517666b68cc", size = 1319650, upload-time = "2026-03-09T13:13:08.607Z" }, + { url = "https://files.pythonhosted.org/packages/4c/e4/5ba3cecd7ce6236ae4a80f67e5d5531287337d0e1f076ca87a5abe4cd5d0/kiwisolver-1.5.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:01808c6d15f4c3e8559595d6d1fe6411c68e4a3822b4b9972b44473b24f4e679", size = 970949, upload-time = "2026-03-09T13:13:10.299Z" }, + { url = "https://files.pythonhosted.org/packages/5a/69/dc61f7ae9a2f071f26004ced87f078235b5507ab6e5acd78f40365655034/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f1f9f4121ec58628c96baa3de1a55a4e3a333c5102c8e94b64e23bf7b2083309", size = 2199125, upload-time = "2026-03-09T13:13:11.841Z" }, + { url = "https://files.pythonhosted.org/packages/e5/7b/abbe0f1b5afa85f8d084b73e90e5f801c0939eba16ac2e49af7c61a6c28d/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b7d335370ae48a780c6e6a6bbfa97342f563744c39c35562f3f367665f5c1de2", size = 2293783, upload-time = "2026-03-09T13:13:14.399Z" }, + { url = "https://files.pythonhosted.org/packages/8a/80/5908ae149d96d81580d604c7f8aefd0e98f4fd728cf172f477e9f2a81744/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:800ee55980c18545af444d93fdd60c56b580db5cc54867d8cbf8a1dc0829938c", size = 1960726, upload-time = "2026-03-09T13:13:16.047Z" }, + { url = "https://files.pythonhosted.org/packages/84/08/a78cb776f8c085b7143142ce479859cfec086bd09ee638a317040b6ef420/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:c438f6ca858697c9ab67eb28246c92508af972e114cac34e57a6d4ba17a3ac08", size = 2464738, upload-time = "2026-03-09T13:13:17.897Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e1/65584da5356ed6cb12c63791a10b208860ac40a83de165cb6a6751a686e3/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8c63c91f95173f9c2a67c7c526b2cea976828a0e7fced9cdcead2802dc10f8a4", size = 2270718, upload-time = "2026-03-09T13:13:19.421Z" }, + { url = "https://files.pythonhosted.org/packages/be/6c/28f17390b62b8f2f520e2915095b3c94d88681ecf0041e75389d9667f202/kiwisolver-1.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:beb7f344487cdcb9e1efe4b7a29681b74d34c08f0043a327a74da852a6749e7b", size = 73480, upload-time = "2026-03-09T13:13:20.818Z" }, + { url = "https://files.pythonhosted.org/packages/d8/0e/2ee5debc4f77a625778fec5501ff3e8036fe361b7ee28ae402a485bb9694/kiwisolver-1.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:ad4ae4ffd1ee9cd11357b4c66b612da9888f4f4daf2f36995eda64bd45370cac", size = 64930, upload-time = "2026-03-09T13:13:21.997Z" }, + { url = "https://files.pythonhosted.org/packages/4d/b2/818b74ebea34dabe6d0c51cb1c572e046730e64844da6ed646d5298c40ce/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4e9750bc21b886308024f8a54ccb9a2cc38ac9fa813bf4348434e3d54f337ff9", size = 123158, upload-time = "2026-03-09T13:13:23.127Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d9/405320f8077e8e1c5c4bd6adc45e1e6edf6d727b6da7f2e2533cf58bff71/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:72ec46b7eba5b395e0a7b63025490d3214c11013f4aacb4f5e8d6c3041829588", size = 66388, upload-time = "2026-03-09T13:13:24.765Z" }, + { url = "https://files.pythonhosted.org/packages/99/9f/795fedf35634f746151ca8839d05681ceb6287fbed6cc1c9bf235f7887c2/kiwisolver-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ed3a984b31da7481b103f68776f7128a89ef26ed40f4dc41a2223cda7fb24819", size = 64068, upload-time = "2026-03-09T13:13:25.878Z" }, + { url = "https://files.pythonhosted.org/packages/c4/13/680c54afe3e65767bed7ec1a15571e1a2f1257128733851ade24abcefbcc/kiwisolver-1.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb5136fb5352d3f422df33f0c879a1b0c204004324150cc3b5e3c4f310c9049f", size = 1477934, upload-time = "2026-03-09T13:13:27.166Z" }, + { url = "https://files.pythonhosted.org/packages/c8/2f/cebfcdb60fd6a9b0f6b47a9337198bcbad6fbe15e68189b7011fd914911f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2af221f268f5af85e776a73d62b0845fc8baf8ef0abfae79d29c77d0e776aaf", size = 1278537, upload-time = "2026-03-09T13:13:28.707Z" }, + { url = "https://files.pythonhosted.org/packages/f2/0d/9b782923aada3fafb1d6b84e13121954515c669b18af0c26e7d21f579855/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b0f172dc8ffaccb8522d7c5d899de00133f2f1ca7b0a49b7da98e901de87bf2d", size = 1296685, upload-time = "2026-03-09T13:13:30.528Z" }, + { url = "https://files.pythonhosted.org/packages/27/70/83241b6634b04fe44e892688d5208332bde130f38e610c0418f9ede47ded/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ab8ba9152203feec73758dad83af9a0bbe05001eb4639e547207c40cfb52083", size = 1346024, upload-time = "2026-03-09T13:13:32.818Z" }, + { url = "https://files.pythonhosted.org/packages/e4/db/30ed226fb271ae1a6431fc0fe0edffb2efe23cadb01e798caeb9f2ceae8f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:cdee07c4d7f6d72008d3f73b9bf027f4e11550224c7c50d8df1ae4a37c1402a6", size = 987241, upload-time = "2026-03-09T13:13:34.435Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bd/c314595208e4c9587652d50959ead9e461995389664e490f4dce7ff0f782/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7c60d3c9b06fb23bd9c6139281ccbdc384297579ae037f08ae90c69f6845c0b1", size = 2227742, upload-time = "2026-03-09T13:13:36.4Z" }, + { url = "https://files.pythonhosted.org/packages/c1/43/0499cec932d935229b5543d073c2b87c9c22846aab48881e9d8d6e742a2d/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e315e5ec90d88e140f57696ff85b484ff68bb311e36f2c414aa4286293e6dee0", size = 2323966, upload-time = "2026-03-09T13:13:38.204Z" }, + { url = "https://files.pythonhosted.org/packages/3d/6f/79b0d760907965acfd9d61826a3d41f8f093c538f55cd2633d3f0db269f6/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1465387ac63576c3e125e5337a6892b9e99e0627d52317f3ca79e6930d889d15", size = 1977417, upload-time = "2026-03-09T13:13:39.966Z" }, + { url = "https://files.pythonhosted.org/packages/ab/31/01d0537c41cb75a551a438c3c7a80d0c60d60b81f694dac83dd436aec0d0/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:530a3fd64c87cffa844d4b6b9768774763d9caa299e9b75d8eca6a4423b31314", size = 2491238, upload-time = "2026-03-09T13:13:41.698Z" }, + { url = "https://files.pythonhosted.org/packages/e4/34/8aefdd0be9cfd00a44509251ba864f5caf2991e36772e61c408007e7f417/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d9daea4ea6b9be74fe2f01f7fbade8d6ffab263e781274cffca0dba9be9eec9", size = 2294947, upload-time = "2026-03-09T13:13:43.343Z" }, + { url = "https://files.pythonhosted.org/packages/ad/cf/0348374369ca588f8fe9c338fae49fa4e16eeb10ffb3d012f23a54578a9e/kiwisolver-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:f18c2d9782259a6dc132fdc7a63c168cbc74b35284b6d75c673958982a378384", size = 73569, upload-time = "2026-03-09T13:13:45.792Z" }, + { url = "https://files.pythonhosted.org/packages/28/26/192b26196e2316e2bd29deef67e37cdf9870d9af8e085e521afff0fed526/kiwisolver-1.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7c7553b13f69c1b29a5bde08ddc6d9d0c8bfb84f9ed01c30db25944aeb852a7", size = 64997, upload-time = "2026-03-09T13:13:46.878Z" }, + { url = "https://files.pythonhosted.org/packages/9d/69/024d6711d5ba575aa65d5538042e99964104e97fa153a9f10bc369182bc2/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:fd40bb9cd0891c4c3cb1ddf83f8bbfa15731a248fdc8162669405451e2724b09", size = 123166, upload-time = "2026-03-09T13:13:48.032Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/adbb40df306f587054a348831220812b9b1d787aff714cfbc8556e38fccd/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c0e1403fd7c26d77c1f03e096dc58a5c726503fa0db0456678b8668f76f521e3", size = 66395, upload-time = "2026-03-09T13:13:49.365Z" }, + { url = "https://files.pythonhosted.org/packages/a8/3a/d0a972b34e1c63e2409413104216cd1caa02c5a37cb668d1687d466c1c45/kiwisolver-1.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dda366d548e89a90d88a86c692377d18d8bd64b39c1fb2b92cb31370e2896bbd", size = 64065, upload-time = "2026-03-09T13:13:50.562Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0a/7b98e1e119878a27ba8618ca1e18b14f992ff1eda40f47bccccf4de44121/kiwisolver-1.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:332b4f0145c30b5f5ad9374881133e5aa64320428a57c2c2b61e9d891a51c2f3", size = 1477903, upload-time = "2026-03-09T13:13:52.084Z" }, + { url = "https://files.pythonhosted.org/packages/18/d8/55638d89ffd27799d5cc3d8aa28e12f4ce7a64d67b285114dbedc8ea4136/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c50b89ffd3e1a911c69a1dd3de7173c0cd10b130f56222e57898683841e4f96", size = 1278751, upload-time = "2026-03-09T13:13:54.673Z" }, + { url = "https://files.pythonhosted.org/packages/b8/97/b4c8d0d18421ecceba20ad8701358453b88e32414e6f6950b5a4bad54e65/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4db576bb8c3ef9365f8b40fe0f671644de6736ae2c27a2c62d7d8a1b4329f099", size = 1296793, upload-time = "2026-03-09T13:13:56.287Z" }, + { url = "https://files.pythonhosted.org/packages/c4/10/f862f94b6389d8957448ec9df59450b81bec4abb318805375c401a1e6892/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0b85aad90cea8ac6797a53b5d5f2e967334fa4d1149f031c4537569972596cb8", size = 1346041, upload-time = "2026-03-09T13:13:58.269Z" }, + { url = "https://files.pythonhosted.org/packages/a3/6a/f1650af35821eaf09de398ec0bc2aefc8f211f0cda50204c9f1673741ba9/kiwisolver-1.5.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:d36ca54cb4c6c4686f7cbb7b817f66f5911c12ddb519450bbe86707155028f87", size = 987292, upload-time = "2026-03-09T13:13:59.871Z" }, + { url = "https://files.pythonhosted.org/packages/de/19/d7fb82984b9238115fe629c915007be608ebd23dc8629703d917dbfaffd4/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:38f4a703656f493b0ad185211ccfca7f0386120f022066b018eb5296d8613e23", size = 2227865, upload-time = "2026-03-09T13:14:01.401Z" }, + { url = "https://files.pythonhosted.org/packages/7f/b9/46b7f386589fd222dac9e9de9c956ce5bcefe2ee73b4e79891381dda8654/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3ac2360e93cb41be81121755c6462cff3beaa9967188c866e5fce5cf13170859", size = 2324369, upload-time = "2026-03-09T13:14:02.972Z" }, + { url = "https://files.pythonhosted.org/packages/92/8b/95e237cf3d9c642960153c769ddcbe278f182c8affb20cecc1cc983e7cc5/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c95cab08d1965db3d84a121f1c7ce7479bdd4072c9b3dafd8fecce48a2e6b902", size = 1977989, upload-time = "2026-03-09T13:14:04.503Z" }, + { url = "https://files.pythonhosted.org/packages/1b/95/980c9df53501892784997820136c01f62bc1865e31b82b9560f980c0e649/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fc20894c3d21194d8041a28b65622d5b86db786da6e3cfe73f0c762951a61167", size = 2491645, upload-time = "2026-03-09T13:14:06.106Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/900647fd0840abebe1561792c6b31e6a7c0e278fc3973d30572a965ca14c/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7a32f72973f0f950c1920475d5c5ea3d971b81b6f0ec53b8d0a956cc965f22e0", size = 2295237, upload-time = "2026-03-09T13:14:08.891Z" }, + { url = "https://files.pythonhosted.org/packages/be/8a/be60e3bbcf513cc5a50f4a3e88e1dcecebb79c1ad607a7222877becaa101/kiwisolver-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bf3acf1419fa93064a4c2189ac0b58e3be7872bf6ee6177b0d4c63dc4cea276", size = 73573, upload-time = "2026-03-09T13:14:12.327Z" }, + { url = "https://files.pythonhosted.org/packages/4d/d2/64be2e429eb4fca7f7e1c52a91b12663aeaf25de3895e5cca0f47ef2a8d0/kiwisolver-1.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:fa8eb9ecdb7efb0b226acec134e0d709e87a909fa4971a54c0c4f6e88635484c", size = 64998, upload-time = "2026-03-09T13:14:13.469Z" }, + { url = "https://files.pythonhosted.org/packages/b0/69/ce68dd0c85755ae2de490bf015b62f2cea5f6b14ff00a463f9d0774449ff/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db485b3847d182b908b483b2ed133c66d88d49cacf98fd278fadafe11b4478d1", size = 125700, upload-time = "2026-03-09T13:14:14.636Z" }, + { url = "https://files.pythonhosted.org/packages/74/aa/937aac021cf9d4349990d47eb319309a51355ed1dbdc9c077cdc9224cb11/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:be12f931839a3bdfe28b584db0e640a65a8bcbc24560ae3fdb025a449b3d754e", size = 67537, upload-time = "2026-03-09T13:14:15.808Z" }, + { url = "https://files.pythonhosted.org/packages/ee/20/3a87fbece2c40ad0f6f0aefa93542559159c5f99831d596050e8afae7a9f/kiwisolver-1.5.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:16b85d37c2cbb3253226d26e64663f755d88a03439a9c47df6246b35defbdfb7", size = 65514, upload-time = "2026-03-09T13:14:18.035Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7f/f943879cda9007c45e1f7dba216d705c3a18d6b35830e488b6c6a4e7cdf0/kiwisolver-1.5.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4432b835675f0ea7414aab3d37d119f7226d24869b7a829caeab49ebda407b0c", size = 1584848, upload-time = "2026-03-09T13:14:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/37/f8/4d4f85cc1870c127c88d950913370dd76138482161cd07eabbc450deff01/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b0feb50971481a2cc44d94e88bdb02cdd497618252ae226b8eb1201b957e368", size = 1391542, upload-time = "2026-03-09T13:14:21.54Z" }, + { url = "https://files.pythonhosted.org/packages/04/0b/65dd2916c84d252b244bd405303220f729e7c17c9d7d33dca6feeff9ffc4/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56fa888f10d0f367155e76ce849fa1166fc9730d13bd2d65a2aa13b6f5424489", size = 1404447, upload-time = "2026-03-09T13:14:23.205Z" }, + { url = "https://files.pythonhosted.org/packages/39/5c/2606a373247babce9b1d056c03a04b65f3cf5290a8eac5d7bdead0a17e21/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:940dda65d5e764406b9fb92761cbf462e4e63f712ab60ed98f70552e496f3bf1", size = 1455918, upload-time = "2026-03-09T13:14:24.74Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d1/c6078b5756670658e9192a2ef11e939c92918833d2745f85cd14a6004bdf/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:89fc958c702ee9a745e4700378f5d23fddbc46ff89e8fdbf5395c24d5c1452a3", size = 1072856, upload-time = "2026-03-09T13:14:26.597Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c8/7def6ddf16eb2b3741d8b172bdaa9af882b03c78e9b0772975408801fa63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9027d773c4ff81487181a925945743413f6069634d0b122d0b37684ccf4f1e18", size = 2333580, upload-time = "2026-03-09T13:14:28.237Z" }, + { url = "https://files.pythonhosted.org/packages/9e/87/2ac1fce0eb1e616fcd3c35caa23e665e9b1948bb984f4764790924594128/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5b233ea3e165e43e35dba1d2b8ecc21cf070b45b65ae17dd2747d2713d942021", size = 2423018, upload-time = "2026-03-09T13:14:30.018Z" }, + { url = "https://files.pythonhosted.org/packages/67/13/c6700ccc6cc218716bfcda4935e4b2997039869b4ad8a94f364c5a3b8e63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ce9bf03dad3b46408c08649c6fbd6ca28a9fce0eb32fdfffa6775a13103b5310", size = 2062804, upload-time = "2026-03-09T13:14:32.888Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bd/877056304626943ff0f1f44c08f584300c199b887cb3176cd7e34f1515f1/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:fc4d3f1fb9ca0ae9f97b095963bc6326f1dbfd3779d6679a1e016b9baaa153d3", size = 2597482, upload-time = "2026-03-09T13:14:34.971Z" }, + { url = "https://files.pythonhosted.org/packages/75/19/c60626c47bf0f8ac5dcf72c6c98e266d714f2fbbfd50cf6dab5ede3aaa50/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f443b4825c50a51ee68585522ab4a1d1257fac65896f282b4c6763337ac9f5d2", size = 2394328, upload-time = "2026-03-09T13:14:36.816Z" }, + { url = "https://files.pythonhosted.org/packages/47/84/6a6d5e5bb8273756c27b7d810d47f7ef2f1f9b9fd23c9ee9a3f8c75c9cef/kiwisolver-1.5.0-cp313-cp313t-win_arm64.whl", hash = "sha256:893ff3a711d1b515ba9da14ee090519bad4610ed1962fbe298a434e8c5f8db53", size = 68410, upload-time = "2026-03-09T13:14:38.695Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fa/2910df836372d8761bb6eff7d8bdcb1613b5c2e03f260efe7abe34d388a7/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:5ae8e62c147495b01a0f4765c878e9bfdf843412446a247e28df59936e99e797", size = 130262, upload-time = "2026-03-09T13:15:35.629Z" }, + { url = "https://files.pythonhosted.org/packages/0f/41/c5f71f9f00aabcc71fee8b7475e3f64747282580c2fe748961ba29b18385/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f6764a4ccab3078db14a632420930f6186058750df066b8ea2a7106df91d3203", size = 138036, upload-time = "2026-03-09T13:15:36.894Z" }, + { url = "https://files.pythonhosted.org/packages/fa/06/7399a607f434119c6e1fdc8ec89a8d51ccccadf3341dee4ead6bd14caaf5/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c31c13da98624f957b0fb1b5bae5383b2333c2c3f6793d9825dd5ce79b525cb7", size = 194295, upload-time = "2026-03-09T13:15:38.22Z" }, + { url = "https://files.pythonhosted.org/packages/b5/91/53255615acd2a1eaca307ede3c90eb550bae9c94581f8c00081b6b1c8f44/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:1f1489f769582498610e015a8ef2d36f28f505ab3096d0e16b4858a9ec214f57", size = 75987, upload-time = "2026-03-09T13:15:39.65Z" }, + { url = "https://files.pythonhosted.org/packages/17/6f/6fd4f690a40c2582fa34b97d2678f718acf3706b91d270c65ecb455d0a06/kiwisolver-1.5.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:295d9ffe712caa9f8a3081de8d32fc60191b4b51c76f02f951fd8407253528f4", size = 59606, upload-time = "2026-03-09T13:15:40.81Z" }, + { url = "https://files.pythonhosted.org/packages/82/a0/2355d5e3b338f13ce63f361abb181e3b6ea5fffdb73f739b3e80efa76159/kiwisolver-1.5.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:51e8c4084897de9f05898c2c2a39af6318044ae969d46ff7a34ed3f96274adca", size = 57537, upload-time = "2026-03-09T13:15:42.071Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b9/1d50e610ecadebe205b71d6728fd224ce0e0ca6aba7b9cbe1da049203ac5/kiwisolver-1.5.0-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b83af57bdddef03c01a9138034c6ff03181a3028d9a1003b301eb1a55e161a3f", size = 79888, upload-time = "2026-03-09T13:15:43.317Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ee/b85ffcd75afed0357d74f0e6fc02a4507da441165de1ca4760b9f496390d/kiwisolver-1.5.0-pp310-pypy310_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf4679a3d71012a7c2bf360e5cd878fbd5e4fcac0896b56393dec239d81529ed", size = 77584, upload-time = "2026-03-09T13:15:44.605Z" }, + { url = "https://files.pythonhosted.org/packages/6b/dd/644d0dde6010a8583b4cd66dd41c5f83f5325464d15c4f490b3340ab73b4/kiwisolver-1.5.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:41024ed50e44ab1a60d3fe0a9d15a4ccc9f5f2b1d814ff283c8d01134d5b81bc", size = 73390, upload-time = "2026-03-09T13:15:45.832Z" }, + { url = "https://files.pythonhosted.org/packages/e9/eb/5fcbbbf9a0e2c3a35effb88831a483345326bbc3a030a3b5b69aee647f84/kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ec4c85dc4b687c7f7f15f553ff26a98bfe8c58f5f7f0ac8905f0ba4c7be60232", size = 59532, upload-time = "2026-03-09T13:15:47.047Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9b/e17104555bb4db148fd52327feea1e96be4b88e8e008b029002c281a21ab/kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:12e91c215a96e39f57989c8912ae761286ac5a9584d04030ceb3368a357f017a", size = 57420, upload-time = "2026-03-09T13:15:48.199Z" }, + { url = "https://files.pythonhosted.org/packages/48/44/2b5b95b7aa39fb2d8d9d956e0f3d5d45aef2ae1d942d4c3ffac2f9cfed1a/kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be4a51a55833dc29ab5d7503e7bcb3b3af3402d266018137127450005cdfe737", size = 79892, upload-time = "2026-03-09T13:15:49.694Z" }, + { url = "https://files.pythonhosted.org/packages/52/7d/7157f9bba6b455cfb4632ed411e199fc8b8977642c2b12082e1bd9e6d173/kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:daae526907e262de627d8f70058a0f64acc9e2641c164c99c8f594b34a799a16", size = 77603, upload-time = "2026-03-09T13:15:50.945Z" }, + { url = "https://files.pythonhosted.org/packages/0a/dd/8050c947d435c8d4bc94e3252f4d8bb8a76cfb424f043a8680be637a57f1/kiwisolver-1.5.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:59cd8683f575d96df5bb48f6add94afc055012c29e28124fcae2b63661b9efb1", size = 73558, upload-time = "2026-03-09T13:15:52.112Z" }, +] + +[[package]] +name = "lark" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/34/28fff3ab31ccff1fd4f6c7c7b0ceb2b6968d8ea4950663eadcb5720591a0/lark-1.3.1.tar.gz", hash = "sha256:b426a7a6d6d53189d318f2b6236ab5d6429eaf09259f1ca33eb716eed10d2905", size = 382732, upload-time = "2025-10-27T18:25:56.653Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3d/14ce75ef66813643812f3093ab17e46d3a206942ce7376d31ec2d36229e7/lark-1.3.1-py3-none-any.whl", hash = "sha256:c629b661023a014c37da873b4ff58a817398d12635d3bbb2c5a03be7fe5d1e12", size = 113151, upload-time = "2025-10-27T18:25:54.882Z" }, +] + +[[package]] +name = "legacy-cgi" +version = "2.6.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f4/9c/91c7d2c5ebbdf0a1a510bfa0ddeaa2fbb5b78677df5ac0a0aa51cf7125b0/legacy_cgi-2.6.4.tar.gz", hash = "sha256:abb9dfc7835772f7c9317977c63253fd22a7484b5c9bbcdca60a29dcce97c577", size = 24603, upload-time = "2025-10-27T05:20:05.395Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/7e/e7394eeb49a41cc514b3eb49020223666cbf40d86f5721c2f07871e6d84a/legacy_cgi-2.6.4-py3-none-any.whl", hash = "sha256:7e235ce58bf1e25d1fc9b2d299015e4e2cd37305eccafec1e6bac3fc04b878cd", size = 20035, upload-time = "2025-10-27T05:20:04.289Z" }, +] + +[[package]] +name = "librt" +version = "0.8.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/56/9c/b4b0c54d84da4a94b37bd44151e46d5e583c9534c7e02250b961b1b6d8a8/librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73", size = 177471, upload-time = "2026-02-17T16:13:06.101Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/5f/63f5fa395c7a8a93558c0904ba8f1c8d1b997ca6a3de61bc7659970d66bf/librt-0.8.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:81fd938344fecb9373ba1b155968c8a329491d2ce38e7ddb76f30ffb938f12dc", size = 65697, upload-time = "2026-02-17T16:11:06.903Z" }, + { url = "https://files.pythonhosted.org/packages/ff/e0/0472cf37267b5920eff2f292ccfaede1886288ce35b7f3203d8de00abfe6/librt-0.8.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5db05697c82b3a2ec53f6e72b2ed373132b0c2e05135f0696784e97d7f5d48e7", size = 68376, upload-time = "2026-02-17T16:11:08.395Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8bd1359fdcd27ab897cd5963294fa4a7c83b20a8564678e4fd12157e56a5/librt-0.8.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d56bc4011975f7460bea7b33e1ff425d2f1adf419935ff6707273c77f8a4ada6", size = 197084, upload-time = "2026-02-17T16:11:09.774Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fe/163e33fdd091d0c2b102f8a60cc0a61fd730ad44e32617cd161e7cd67a01/librt-0.8.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdc0f588ff4b663ea96c26d2a230c525c6fc62b28314edaaaca8ed5af931ad0", size = 207337, upload-time = "2026-02-17T16:11:11.311Z" }, + { url = "https://files.pythonhosted.org/packages/01/99/f85130582f05dcf0c8902f3d629270231d2f4afdfc567f8305a952ac7f14/librt-0.8.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:97c2b54ff6717a7a563b72627990bec60d8029df17df423f0ed37d56a17a176b", size = 219980, upload-time = "2026-02-17T16:11:12.499Z" }, + { url = "https://files.pythonhosted.org/packages/6f/54/cb5e4d03659e043a26c74e08206412ac9a3742f0477d96f9761a55313b5f/librt-0.8.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f1125e6bbf2f1657d9a2f3ccc4a2c9b0c8b176965bb565dd4d86be67eddb4b6", size = 212921, upload-time = "2026-02-17T16:11:14.484Z" }, + { url = "https://files.pythonhosted.org/packages/b1/81/a3a01e4240579c30f3487f6fed01eb4bc8ef0616da5b4ebac27ca19775f3/librt-0.8.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8f4bb453f408137d7581be309b2fbc6868a80e7ef60c88e689078ee3a296ae71", size = 221381, upload-time = "2026-02-17T16:11:17.459Z" }, + { url = "https://files.pythonhosted.org/packages/08/b0/fc2d54b4b1c6fb81e77288ff31ff25a2c1e62eaef4424a984f228839717b/librt-0.8.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c336d61d2fe74a3195edc1646d53ff1cddd3a9600b09fa6ab75e5514ba4862a7", size = 216714, upload-time = "2026-02-17T16:11:19.197Z" }, + { url = "https://files.pythonhosted.org/packages/96/96/85daa73ffbd87e1fb287d7af6553ada66bf25a2a6b0de4764344a05469f6/librt-0.8.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:eb5656019db7c4deacf0c1a55a898c5bb8f989be904597fcb5232a2f4828fa05", size = 214777, upload-time = "2026-02-17T16:11:20.443Z" }, + { url = "https://files.pythonhosted.org/packages/12/9c/c3aa7a2360383f4bf4f04d98195f2739a579128720c603f4807f006a4225/librt-0.8.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c25d9e338d5bed46c1632f851babf3d13c78f49a225462017cf5e11e845c5891", size = 237398, upload-time = "2026-02-17T16:11:22.083Z" }, + { url = "https://files.pythonhosted.org/packages/61/19/d350ea89e5274665185dabc4bbb9c3536c3411f862881d316c8b8e00eb66/librt-0.8.1-cp310-cp310-win32.whl", hash = "sha256:aaab0e307e344cb28d800957ef3ec16605146ef0e59e059a60a176d19543d1b7", size = 54285, upload-time = "2026-02-17T16:11:23.27Z" }, + { url = "https://files.pythonhosted.org/packages/4f/d6/45d587d3d41c112e9543a0093d883eb57a24a03e41561c127818aa2a6bcc/librt-0.8.1-cp310-cp310-win_amd64.whl", hash = "sha256:56e04c14b696300d47b3bc5f1d10a00e86ae978886d0cee14e5714fafb5df5d2", size = 61352, upload-time = "2026-02-17T16:11:24.207Z" }, + { url = "https://files.pythonhosted.org/packages/1d/01/0e748af5e4fee180cf7cd12bd12b0513ad23b045dccb2a83191bde82d168/librt-0.8.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:681dc2451d6d846794a828c16c22dc452d924e9f700a485b7ecb887a30aad1fd", size = 65315, upload-time = "2026-02-17T16:11:25.152Z" }, + { url = "https://files.pythonhosted.org/packages/9d/4d/7184806efda571887c798d573ca4134c80ac8642dcdd32f12c31b939c595/librt-0.8.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3b4350b13cc0e6f5bec8fa7caf29a8fb8cdc051a3bae45cfbfd7ce64f009965", size = 68021, upload-time = "2026-02-17T16:11:26.129Z" }, + { url = "https://files.pythonhosted.org/packages/ae/88/c3c52d2a5d5101f28d3dc89298444626e7874aa904eed498464c2af17627/librt-0.8.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ac1e7817fd0ed3d14fd7c5df91daed84c48e4c2a11ee99c0547f9f62fdae13da", size = 194500, upload-time = "2026-02-17T16:11:27.177Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5d/6fb0a25b6a8906e85b2c3b87bee1d6ed31510be7605b06772f9374ca5cb3/librt-0.8.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:747328be0c5b7075cde86a0e09d7a9196029800ba75a1689332348e998fb85c0", size = 205622, upload-time = "2026-02-17T16:11:28.242Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a6/8006ae81227105476a45691f5831499e4d936b1c049b0c1feb17c11b02d1/librt-0.8.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0af2bd2bc204fa27f3d6711d0f360e6b8c684a035206257a81673ab924aa11e", size = 218304, upload-time = "2026-02-17T16:11:29.344Z" }, + { url = "https://files.pythonhosted.org/packages/ee/19/60e07886ad16670aae57ef44dada41912c90906a6fe9f2b9abac21374748/librt-0.8.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d480de377f5b687b6b1bc0c0407426da556e2a757633cc7e4d2e1a057aa688f3", size = 211493, upload-time = "2026-02-17T16:11:30.445Z" }, + { url = "https://files.pythonhosted.org/packages/9c/cf/f666c89d0e861d05600438213feeb818c7514d3315bae3648b1fc145d2b6/librt-0.8.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d0ee06b5b5291f609ddb37b9750985b27bc567791bc87c76a569b3feed8481ac", size = 219129, upload-time = "2026-02-17T16:11:32.021Z" }, + { url = "https://files.pythonhosted.org/packages/8f/ef/f1bea01e40b4a879364c031476c82a0dc69ce068daad67ab96302fed2d45/librt-0.8.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9e2c6f77b9ad48ce5603b83b7da9ee3e36b3ab425353f695cba13200c5d96596", size = 213113, upload-time = "2026-02-17T16:11:33.192Z" }, + { url = "https://files.pythonhosted.org/packages/9b/80/cdab544370cc6bc1b72ea369525f547a59e6938ef6863a11ab3cd24759af/librt-0.8.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:439352ba9373f11cb8e1933da194dcc6206daf779ff8df0ed69c5e39113e6a99", size = 212269, upload-time = "2026-02-17T16:11:34.373Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9c/48d6ed8dac595654f15eceab2035131c136d1ae9a1e3548e777bb6dbb95d/librt-0.8.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:82210adabbc331dbb65d7868b105185464ef13f56f7f76688565ad79f648b0fe", size = 234673, upload-time = "2026-02-17T16:11:36.063Z" }, + { url = "https://files.pythonhosted.org/packages/16/01/35b68b1db517f27a01be4467593292eb5315def8900afad29fabf56304ba/librt-0.8.1-cp311-cp311-win32.whl", hash = "sha256:52c224e14614b750c0a6d97368e16804a98c684657c7518752c356834fff83bb", size = 54597, upload-time = "2026-02-17T16:11:37.544Z" }, + { url = "https://files.pythonhosted.org/packages/71/02/796fe8f02822235966693f257bf2c79f40e11337337a657a8cfebba5febc/librt-0.8.1-cp311-cp311-win_amd64.whl", hash = "sha256:c00e5c884f528c9932d278d5c9cbbea38a6b81eb62c02e06ae53751a83a4d52b", size = 61733, upload-time = "2026-02-17T16:11:38.691Z" }, + { url = "https://files.pythonhosted.org/packages/28/ad/232e13d61f879a42a4e7117d65e4984bb28371a34bb6fb9ca54ec2c8f54e/librt-0.8.1-cp311-cp311-win_arm64.whl", hash = "sha256:f7cdf7f26c2286ffb02e46d7bac56c94655540b26347673bea15fa52a6af17e9", size = 52273, upload-time = "2026-02-17T16:11:40.308Z" }, + { url = "https://files.pythonhosted.org/packages/95/21/d39b0a87ac52fc98f621fb6f8060efb017a767ebbbac2f99fbcbc9ddc0d7/librt-0.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a28f2612ab566b17f3698b0da021ff9960610301607c9a5e8eaca62f5e1c350a", size = 66516, upload-time = "2026-02-17T16:11:41.604Z" }, + { url = "https://files.pythonhosted.org/packages/69/f1/46375e71441c43e8ae335905e069f1c54febee63a146278bcee8782c84fd/librt-0.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:60a78b694c9aee2a0f1aaeaa7d101cf713e92e8423a941d2897f4fa37908dab9", size = 68634, upload-time = "2026-02-17T16:11:43.268Z" }, + { url = "https://files.pythonhosted.org/packages/0a/33/c510de7f93bf1fa19e13423a606d8189a02624a800710f6e6a0a0f0784b3/librt-0.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:758509ea3f1eba2a57558e7e98f4659d0ea7670bff49673b0dde18a3c7e6c0eb", size = 198941, upload-time = "2026-02-17T16:11:44.28Z" }, + { url = "https://files.pythonhosted.org/packages/dd/36/e725903416409a533d92398e88ce665476f275081d0d7d42f9c4951999e5/librt-0.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:039b9f2c506bd0ab0f8725aa5ba339c6f0cd19d3b514b50d134789809c24285d", size = 209991, upload-time = "2026-02-17T16:11:45.462Z" }, + { url = "https://files.pythonhosted.org/packages/30/7a/8d908a152e1875c9f8eac96c97a480df425e657cdb47854b9efaa4998889/librt-0.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bb54f1205a3a6ab41a6fd71dfcdcbd278670d3a90ca502a30d9da583105b6f7", size = 224476, upload-time = "2026-02-17T16:11:46.542Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b8/a22c34f2c485b8903a06f3fe3315341fe6876ef3599792344669db98fcff/librt-0.8.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:05bd41cdee35b0c59c259f870f6da532a2c5ca57db95b5f23689fcb5c9e42440", size = 217518, upload-time = "2026-02-17T16:11:47.746Z" }, + { url = "https://files.pythonhosted.org/packages/79/6f/5c6fea00357e4f82ba44f81dbfb027921f1ab10e320d4a64e1c408d035d9/librt-0.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adfab487facf03f0d0857b8710cf82d0704a309d8ffc33b03d9302b4c64e91a9", size = 225116, upload-time = "2026-02-17T16:11:49.298Z" }, + { url = "https://files.pythonhosted.org/packages/f2/a0/95ced4e7b1267fe1e2720a111685bcddf0e781f7e9e0ce59d751c44dcfe5/librt-0.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:153188fe98a72f206042be10a2c6026139852805215ed9539186312d50a8e972", size = 217751, upload-time = "2026-02-17T16:11:50.49Z" }, + { url = "https://files.pythonhosted.org/packages/93/c2/0517281cb4d4101c27ab59472924e67f55e375bc46bedae94ac6dc6e1902/librt-0.8.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dd3c41254ee98604b08bd5b3af5bf0a89740d4ee0711de95b65166bf44091921", size = 218378, upload-time = "2026-02-17T16:11:51.783Z" }, + { url = "https://files.pythonhosted.org/packages/43/e8/37b3ac108e8976888e559a7b227d0ceac03c384cfd3e7a1c2ee248dbae79/librt-0.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e0d138c7ae532908cbb342162b2611dbd4d90c941cd25ab82084aaf71d2c0bd0", size = 241199, upload-time = "2026-02-17T16:11:53.561Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5b/35812d041c53967fedf551a39399271bbe4257e681236a2cf1a69c8e7fa1/librt-0.8.1-cp312-cp312-win32.whl", hash = "sha256:43353b943613c5d9c49a25aaffdba46f888ec354e71e3529a00cca3f04d66a7a", size = 54917, upload-time = "2026-02-17T16:11:54.758Z" }, + { url = "https://files.pythonhosted.org/packages/de/d1/fa5d5331b862b9775aaf2a100f5ef86854e5d4407f71bddf102f4421e034/librt-0.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:ff8baf1f8d3f4b6b7257fcb75a501f2a5499d0dda57645baa09d4d0d34b19444", size = 62017, upload-time = "2026-02-17T16:11:55.748Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7c/c614252f9acda59b01a66e2ddfd243ed1c7e1deab0293332dfbccf862808/librt-0.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f2ae3725904f7377e11cc37722d5d401e8b3d5851fb9273d7f4fe04f6b3d37d", size = 52441, upload-time = "2026-02-17T16:11:56.801Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3c/f614c8e4eaac7cbf2bbdf9528790b21d89e277ee20d57dc6e559c626105f/librt-0.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e6bad1cd94f6764e1e21950542f818a09316645337fd5ab9a7acc45d99a8f35", size = 66529, upload-time = "2026-02-17T16:11:57.809Z" }, + { url = "https://files.pythonhosted.org/packages/ab/96/5836544a45100ae411eda07d29e3d99448e5258b6e9c8059deb92945f5c2/librt-0.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf450f498c30af55551ba4f66b9123b7185362ec8b625a773b3d39aa1a717583", size = 68669, upload-time = "2026-02-17T16:11:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/06/53/f0b992b57af6d5531bf4677d75c44f095f2366a1741fb695ee462ae04b05/librt-0.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eca45e982fa074090057132e30585a7e8674e9e885d402eae85633e9f449ce6c", size = 199279, upload-time = "2026-02-17T16:11:59.862Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ad/4848cc16e268d14280d8168aee4f31cea92bbd2b79ce33d3e166f2b4e4fc/librt-0.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c3811485fccfda840861905b8c70bba5ec094e02825598bb9d4ca3936857a04", size = 210288, upload-time = "2026-02-17T16:12:00.954Z" }, + { url = "https://files.pythonhosted.org/packages/52/05/27fdc2e95de26273d83b96742d8d3b7345f2ea2bdbd2405cc504644f2096/librt-0.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4af413908f77294605e28cfd98063f54b2c790561383971d2f52d113d9c363", size = 224809, upload-time = "2026-02-17T16:12:02.108Z" }, + { url = "https://files.pythonhosted.org/packages/7a/d0/78200a45ba3240cb042bc597d6f2accba9193a2c57d0356268cbbe2d0925/librt-0.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5212a5bd7fae98dae95710032902edcd2ec4dc994e883294f75c857b83f9aba0", size = 218075, upload-time = "2026-02-17T16:12:03.631Z" }, + { url = "https://files.pythonhosted.org/packages/af/72/a210839fa74c90474897124c064ffca07f8d4b347b6574d309686aae7ca6/librt-0.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e692aa2d1d604e6ca12d35e51fdc36f4cda6345e28e36374579f7ef3611b3012", size = 225486, upload-time = "2026-02-17T16:12:04.725Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c1/a03cc63722339ddbf087485f253493e2b013039f5b707e8e6016141130fa/librt-0.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4be2a5c926b9770c9e08e717f05737a269b9d0ebc5d2f0060f0fe3fe9ce47acb", size = 218219, upload-time = "2026-02-17T16:12:05.828Z" }, + { url = "https://files.pythonhosted.org/packages/58/f5/fff6108af0acf941c6f274a946aea0e484bd10cd2dc37610287ce49388c5/librt-0.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fd1a720332ea335ceb544cf0a03f81df92abd4bb887679fd1e460976b0e6214b", size = 218750, upload-time = "2026-02-17T16:12:07.09Z" }, + { url = "https://files.pythonhosted.org/packages/71/67/5a387bfef30ec1e4b4f30562c8586566faf87e47d696768c19feb49e3646/librt-0.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2af9e01e0ef80d95ae3c720be101227edae5f2fe7e3dc63d8857fadfc5a1d", size = 241624, upload-time = "2026-02-17T16:12:08.43Z" }, + { url = "https://files.pythonhosted.org/packages/d4/be/24f8502db11d405232ac1162eb98069ca49c3306c1d75c6ccc61d9af8789/librt-0.8.1-cp313-cp313-win32.whl", hash = "sha256:086a32dbb71336627e78cc1d6ee305a68d038ef7d4c39aaff41ae8c9aa46e91a", size = 54969, upload-time = "2026-02-17T16:12:09.633Z" }, + { url = "https://files.pythonhosted.org/packages/5c/73/c9fdf6cb2a529c1a092ce769a12d88c8cca991194dfe641b6af12fa964d2/librt-0.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:e11769a1dbda4da7b00a76cfffa67aa47cfa66921d2724539eee4b9ede780b79", size = 62000, upload-time = "2026-02-17T16:12:10.632Z" }, + { url = "https://files.pythonhosted.org/packages/d3/97/68f80ca3ac4924f250cdfa6e20142a803e5e50fca96ef5148c52ee8c10ea/librt-0.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:924817ab3141aca17893386ee13261f1d100d1ef410d70afe4389f2359fea4f0", size = 52495, upload-time = "2026-02-17T16:12:11.633Z" }, +] + +[[package]] +name = "logistro" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/08/90/bfd7a6fab22bdfafe48ed3c4831713cb77b4779d18ade5e248d5dbc0ca22/logistro-2.0.1.tar.gz", hash = "sha256:8446affc82bab2577eb02bfcbcae196ae03129287557287b6a070f70c1985047", size = 8398, upload-time = "2025-11-01T02:41:18.81Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/6aa79ba3570bddd1bf7e951c6123f806751e58e8cce736bad77b2cf348d7/logistro-2.0.1-py3-none-any.whl", hash = "sha256:06ffa127b9fb4ac8b1972ae6b2a9d7fde57598bf5939cd708f43ec5bba2d31eb", size = 8555, upload-time = "2025-11-01T02:41:17.587Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, +] + +[[package]] +name = "matplotlib" +version = "3.10.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "contourpy", version = "1.3.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "contourpy", version = "1.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "cycler" }, + { name = "fonttools" }, + { name = "kiwisolver" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyparsing" }, + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/76/d3c6e3a13fe484ebe7718d14e269c9569c4eb0020a968a327acb3b9a8fe6/matplotlib-3.10.8.tar.gz", hash = "sha256:2299372c19d56bcd35cf05a2738308758d32b9eaed2371898d8f5bd33f084aa3", size = 34806269, upload-time = "2025-12-10T22:56:51.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/be/a30bd917018ad220c400169fba298f2bb7003c8ccbc0c3e24ae2aacad1e8/matplotlib-3.10.8-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:00270d217d6b20d14b584c521f810d60c5c78406dc289859776550df837dcda7", size = 8239828, upload-time = "2025-12-10T22:55:02.313Z" }, + { url = "https://files.pythonhosted.org/packages/58/27/ca01e043c4841078e82cf6e80a6993dfecd315c3d79f5f3153afbb8e1ec6/matplotlib-3.10.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:37b3c1cc42aa184b3f738cfa18c1c1d72fd496d85467a6cf7b807936d39aa656", size = 8128050, upload-time = "2025-12-10T22:55:04.997Z" }, + { url = "https://files.pythonhosted.org/packages/cb/aa/7ab67f2b729ae6a91bcf9dcac0affb95fb8c56f7fd2b2af894ae0b0cf6fa/matplotlib-3.10.8-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ee40c27c795bda6a5292e9cff9890189d32f7e3a0bf04e0e3c9430c4a00c37df", size = 8700452, upload-time = "2025-12-10T22:55:07.47Z" }, + { url = "https://files.pythonhosted.org/packages/73/ae/2d5817b0acee3c49b7e7ccfbf5b273f284957cc8e270adf36375db353190/matplotlib-3.10.8-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a48f2b74020919552ea25d222d5cc6af9ca3f4eb43a93e14d068457f545c2a17", size = 9534928, upload-time = "2025-12-10T22:55:10.566Z" }, + { url = "https://files.pythonhosted.org/packages/c9/5b/8e66653e9f7c39cb2e5cab25fce4810daffa2bff02cbf5f3077cea9e942c/matplotlib-3.10.8-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f254d118d14a7f99d616271d6c3c27922c092dac11112670b157798b89bf4933", size = 9586377, upload-time = "2025-12-10T22:55:12.362Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e2/fd0bbadf837f81edb0d208ba8f8cb552874c3b16e27cb91a31977d90875d/matplotlib-3.10.8-cp310-cp310-win_amd64.whl", hash = "sha256:f9b587c9c7274c1613a30afabf65a272114cd6cdbe67b3406f818c79d7ab2e2a", size = 8128127, upload-time = "2025-12-10T22:55:14.436Z" }, + { url = "https://files.pythonhosted.org/packages/f8/86/de7e3a1cdcfc941483af70609edc06b83e7c8a0e0dc9ac325200a3f4d220/matplotlib-3.10.8-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6be43b667360fef5c754dda5d25a32e6307a03c204f3c0fc5468b78fa87b4160", size = 8251215, upload-time = "2025-12-10T22:55:16.175Z" }, + { url = "https://files.pythonhosted.org/packages/fd/14/baad3222f424b19ce6ad243c71de1ad9ec6b2e4eb1e458a48fdc6d120401/matplotlib-3.10.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2b336e2d91a3d7006864e0990c83b216fcdca64b5a6484912902cef87313d78", size = 8139625, upload-time = "2025-12-10T22:55:17.712Z" }, + { url = "https://files.pythonhosted.org/packages/8f/a0/7024215e95d456de5883e6732e708d8187d9753a21d32f8ddb3befc0c445/matplotlib-3.10.8-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efb30e3baaea72ce5928e32bab719ab4770099079d66726a62b11b1ef7273be4", size = 8712614, upload-time = "2025-12-10T22:55:20.8Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f4/b8347351da9a5b3f41e26cf547252d861f685c6867d179a7c9d60ad50189/matplotlib-3.10.8-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d56a1efd5bfd61486c8bc968fa18734464556f0fb8e51690f4ac25d85cbbbbc2", size = 9540997, upload-time = "2025-12-10T22:55:23.258Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c0/c7b914e297efe0bc36917bf216b2acb91044b91e930e878ae12981e461e5/matplotlib-3.10.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:238b7ce5717600615c895050239ec955d91f321c209dd110db988500558e70d6", size = 9596825, upload-time = "2025-12-10T22:55:25.217Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d3/a4bbc01c237ab710a1f22b4da72f4ff6d77eb4c7735ea9811a94ae239067/matplotlib-3.10.8-cp311-cp311-win_amd64.whl", hash = "sha256:18821ace09c763ec93aef5eeff087ee493a24051936d7b9ebcad9662f66501f9", size = 8135090, upload-time = "2025-12-10T22:55:27.162Z" }, + { url = "https://files.pythonhosted.org/packages/89/dd/a0b6588f102beab33ca6f5218b31725216577b2a24172f327eaf6417d5c9/matplotlib-3.10.8-cp311-cp311-win_arm64.whl", hash = "sha256:bab485bcf8b1c7d2060b4fcb6fc368a9e6f4cd754c9c2fea281f4be21df394a2", size = 8012377, upload-time = "2025-12-10T22:55:29.185Z" }, + { url = "https://files.pythonhosted.org/packages/9e/67/f997cdcbb514012eb0d10cd2b4b332667997fb5ebe26b8d41d04962fa0e6/matplotlib-3.10.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:64fcc24778ca0404ce0cb7b6b77ae1f4c7231cdd60e6778f999ee05cbd581b9a", size = 8260453, upload-time = "2025-12-10T22:55:30.709Z" }, + { url = "https://files.pythonhosted.org/packages/7e/65/07d5f5c7f7c994f12c768708bd2e17a4f01a2b0f44a1c9eccad872433e2e/matplotlib-3.10.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b9a5ca4ac220a0cdd1ba6bcba3608547117d30468fefce49bb26f55c1a3d5c58", size = 8148321, upload-time = "2025-12-10T22:55:33.265Z" }, + { url = "https://files.pythonhosted.org/packages/3e/f3/c5195b1ae57ef85339fd7285dfb603b22c8b4e79114bae5f4f0fcf688677/matplotlib-3.10.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3ab4aabc72de4ff77b3ec33a6d78a68227bf1123465887f9905ba79184a1cc04", size = 8716944, upload-time = "2025-12-10T22:55:34.922Z" }, + { url = "https://files.pythonhosted.org/packages/00/f9/7638f5cc82ec8a7aa005de48622eecc3ed7c9854b96ba15bd76b7fd27574/matplotlib-3.10.8-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24d50994d8c5816ddc35411e50a86ab05f575e2530c02752e02538122613371f", size = 9550099, upload-time = "2025-12-10T22:55:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/57/61/78cd5920d35b29fd2a0fe894de8adf672ff52939d2e9b43cb83cd5ce1bc7/matplotlib-3.10.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:99eefd13c0dc3b3c1b4d561c1169e65fe47aab7b8158754d7c084088e2329466", size = 9613040, upload-time = "2025-12-10T22:55:38.715Z" }, + { url = "https://files.pythonhosted.org/packages/30/4e/c10f171b6e2f44d9e3a2b96efa38b1677439d79c99357600a62cc1e9594e/matplotlib-3.10.8-cp312-cp312-win_amd64.whl", hash = "sha256:dd80ecb295460a5d9d260df63c43f4afbdd832d725a531f008dad1664f458adf", size = 8142717, upload-time = "2025-12-10T22:55:41.103Z" }, + { url = "https://files.pythonhosted.org/packages/f1/76/934db220026b5fef85f45d51a738b91dea7d70207581063cd9bd8fafcf74/matplotlib-3.10.8-cp312-cp312-win_arm64.whl", hash = "sha256:3c624e43ed56313651bc18a47f838b60d7b8032ed348911c54906b130b20071b", size = 8012751, upload-time = "2025-12-10T22:55:42.684Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b9/15fd5541ef4f5b9a17eefd379356cf12175fe577424e7b1d80676516031a/matplotlib-3.10.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3f2e409836d7f5ac2f1c013110a4d50b9f7edc26328c108915f9075d7d7a91b6", size = 8261076, upload-time = "2025-12-10T22:55:44.648Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a0/2ba3473c1b66b9c74dc7107c67e9008cb1782edbe896d4c899d39ae9cf78/matplotlib-3.10.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56271f3dac49a88d7fca5060f004d9d22b865f743a12a23b1e937a0be4818ee1", size = 8148794, upload-time = "2025-12-10T22:55:46.252Z" }, + { url = "https://files.pythonhosted.org/packages/75/97/a471f1c3eb1fd6f6c24a31a5858f443891d5127e63a7788678d14e249aea/matplotlib-3.10.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a0a7f52498f72f13d4a25ea70f35f4cb60642b466cbb0a9be951b5bc3f45a486", size = 8718474, upload-time = "2025-12-10T22:55:47.864Z" }, + { url = "https://files.pythonhosted.org/packages/01/be/cd478f4b66f48256f42927d0acbcd63a26a893136456cd079c0cc24fbabf/matplotlib-3.10.8-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:646d95230efb9ca614a7a594d4fcacde0ac61d25e37dd51710b36477594963ce", size = 9549637, upload-time = "2025-12-10T22:55:50.048Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7c/8dc289776eae5109e268c4fb92baf870678dc048a25d4ac903683b86d5bf/matplotlib-3.10.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f89c151aab2e2e23cb3fe0acad1e8b82841fd265379c4cecd0f3fcb34c15e0f6", size = 9613678, upload-time = "2025-12-10T22:55:52.21Z" }, + { url = "https://files.pythonhosted.org/packages/64/40/37612487cc8a437d4dd261b32ca21fe2d79510fe74af74e1f42becb1bdb8/matplotlib-3.10.8-cp313-cp313-win_amd64.whl", hash = "sha256:e8ea3e2d4066083e264e75c829078f9e149fa119d27e19acd503de65e0b13149", size = 8142686, upload-time = "2025-12-10T22:55:54.253Z" }, + { url = "https://files.pythonhosted.org/packages/66/52/8d8a8730e968185514680c2a6625943f70269509c3dcfc0dcf7d75928cb8/matplotlib-3.10.8-cp313-cp313-win_arm64.whl", hash = "sha256:c108a1d6fa78a50646029cb6d49808ff0fc1330fda87fa6f6250c6b5369b6645", size = 8012917, upload-time = "2025-12-10T22:55:56.268Z" }, + { url = "https://files.pythonhosted.org/packages/b5/27/51fe26e1062f298af5ef66343d8ef460e090a27fea73036c76c35821df04/matplotlib-3.10.8-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ad3d9833a64cf48cc4300f2b406c3d0f4f4724a91c0bd5640678a6ba7c102077", size = 8305679, upload-time = "2025-12-10T22:55:57.856Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1e/4de865bc591ac8e3062e835f42dd7fe7a93168d519557837f0e37513f629/matplotlib-3.10.8-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:eb3823f11823deade26ce3b9f40dcb4a213da7a670013929f31d5f5ed1055b22", size = 8198336, upload-time = "2025-12-10T22:55:59.371Z" }, + { url = "https://files.pythonhosted.org/packages/c6/cb/2f7b6e75fb4dce87ef91f60cac4f6e34f4c145ab036a22318ec837971300/matplotlib-3.10.8-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d9050fee89a89ed57b4fb2c1bfac9a3d0c57a0d55aed95949eedbc42070fea39", size = 8731653, upload-time = "2025-12-10T22:56:01.032Z" }, + { url = "https://files.pythonhosted.org/packages/46/b3/bd9c57d6ba670a37ab31fb87ec3e8691b947134b201f881665b28cc039ff/matplotlib-3.10.8-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b44d07310e404ba95f8c25aa5536f154c0a8ec473303535949e52eb71d0a1565", size = 9561356, upload-time = "2025-12-10T22:56:02.95Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3d/8b94a481456dfc9dfe6e39e93b5ab376e50998cddfd23f4ae3b431708f16/matplotlib-3.10.8-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0a33deb84c15ede243aead39f77e990469fff93ad1521163305095b77b72ce4a", size = 9614000, upload-time = "2025-12-10T22:56:05.411Z" }, + { url = "https://files.pythonhosted.org/packages/bd/cd/bc06149fe5585ba800b189a6a654a75f1f127e8aab02fd2be10df7fa500c/matplotlib-3.10.8-cp313-cp313t-win_amd64.whl", hash = "sha256:3a48a78d2786784cc2413e57397981fb45c79e968d99656706018d6e62e57958", size = 8220043, upload-time = "2025-12-10T22:56:07.551Z" }, + { url = "https://files.pythonhosted.org/packages/e3/de/b22cf255abec916562cc04eef457c13e58a1990048de0c0c3604d082355e/matplotlib-3.10.8-cp313-cp313t-win_arm64.whl", hash = "sha256:15d30132718972c2c074cd14638c7f4592bd98719e2308bccea40e0538bc0cb5", size = 8062075, upload-time = "2025-12-10T22:56:09.178Z" }, + { url = "https://files.pythonhosted.org/packages/f5/43/31d59500bb950b0d188e149a2e552040528c13d6e3d6e84d0cccac593dcd/matplotlib-3.10.8-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:f97aeb209c3d2511443f8797e3e5a569aebb040d4f8bc79aa3ee78a8fb9e3dd8", size = 8237252, upload-time = "2025-12-10T22:56:39.529Z" }, + { url = "https://files.pythonhosted.org/packages/0c/2c/615c09984f3c5f907f51c886538ad785cf72e0e11a3225de2c0f9442aecc/matplotlib-3.10.8-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:fb061f596dad3a0f52b60dc6a5dec4a0c300dec41e058a7efe09256188d170b7", size = 8124693, upload-time = "2025-12-10T22:56:41.758Z" }, + { url = "https://files.pythonhosted.org/packages/91/e1/2757277a1c56041e1fc104b51a0f7b9a4afc8eb737865d63cababe30bc61/matplotlib-3.10.8-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:12d90df9183093fcd479f4172ac26b322b1248b15729cb57f42f71f24c7e37a3", size = 8702205, upload-time = "2025-12-10T22:56:43.415Z" }, + { url = "https://files.pythonhosted.org/packages/04/30/3afaa31c757f34b7725ab9d2ba8b48b5e89c2019c003e7d0ead143aabc5a/matplotlib-3.10.8-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:6da7c2ce169267d0d066adcf63758f0604aa6c3eebf67458930f9d9b79ad1db1", size = 8249198, upload-time = "2025-12-10T22:56:45.584Z" }, + { url = "https://files.pythonhosted.org/packages/48/2f/6334aec331f57485a642a7c8be03cb286f29111ae71c46c38b363230063c/matplotlib-3.10.8-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9153c3292705be9f9c64498a8872118540c3f4123d1a1c840172edf262c8be4a", size = 8136817, upload-time = "2025-12-10T22:56:47.339Z" }, + { url = "https://files.pythonhosted.org/packages/73/e4/6d6f14b2a759c622f191b2d67e9075a3f56aaccb3be4bb9bb6890030d0a0/matplotlib-3.10.8-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1ae029229a57cd1e8fe542485f27e7ca7b23aa9e8944ddb4985d0bc444f1eca2", size = 8713867, upload-time = "2025-12-10T22:56:48.954Z" }, +] + +[[package]] +name = "matplotlib-inline" +version = "0.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/74/97e72a36efd4ae2bccb3463284300f8953f199b5ffbc04cbbb0ec78f74b1/matplotlib_inline-0.2.1.tar.gz", hash = "sha256:e1ee949c340d771fc39e241ea75683deb94762c8fa5f2927ec57c83c4dffa9fe", size = 8110, upload-time = "2025-10-23T09:00:22.126Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/33/ee4519fa02ed11a94aef9559552f3b17bb863f2ecfe1a35dc7f548cde231/matplotlib_inline-0.2.1-py3-none-any.whl", hash = "sha256:d56ce5156ba6085e00a9d54fead6ed29a9c47e215cd1bba2e976ef39f5710a76", size = 9516, upload-time = "2025-10-23T09:00:20.675Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mistune" +version = "3.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/55/d01f0c4b45ade6536c51170b9043db8b2ec6ddf4a35c7ea3f5f559ac935b/mistune-3.2.0.tar.gz", hash = "sha256:708487c8a8cdd99c9d90eb3ed4c3ed961246ff78ac82f03418f5183ab70e398a", size = 95467, upload-time = "2025-12-23T11:36:34.994Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/f7/4a5e785ec9fbd65146a27b6b70b6cdc161a66f2024e4b04ac06a67f5578b/mistune-3.2.0-py3-none-any.whl", hash = "sha256:febdc629a3c78616b94393c6580551e0e34cc289987ec6c35ed3f4be42d0eee1", size = 53598, upload-time = "2025-12-23T11:36:33.211Z" }, +] + +[[package]] +name = "molecular-rectifier" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "rdkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/44/99/00a2d3d4646d7a470944bb3b356d47f8355b6556c2def6d086a01a440447/molecular_rectifier-1.0.1.tar.gz", hash = "sha256:76ac7cdbee8ade1e7acb89b373ba55ccc9b8b07bb8e1fadc5239f59ef77d941f", size = 17015, upload-time = "2024-04-05T10:22:19.168Z" } + +[[package]] +name = "molparse" +version = "0.0.41" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ase" }, + { name = "ipython", version = "8.39.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "ipython", version = "9.10.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "ipython", version = "9.11.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "kaleido" }, + { name = "matplotlib" }, + { name = "mpytools" }, + { name = "numpy" }, + { name = "plotly" }, + { name = "py3dmol" }, + { name = "rdkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/81/41df14bc6cf8e93aeba1a69a85f102c12d99cf0bea1ed1e0934a2377449a/molparse-0.0.41.tar.gz", hash = "sha256:c0c15e243a7cb29f010ae6bdf37f1d870bf0bb7dc97fcae1d223b74513c38440", size = 145086, upload-time = "2025-11-06T16:06:19.045Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/09/318ff29d72034c7a11068f7d86ec825e0774219a65537e9ccf4d6c92031e/molparse-0.0.41-py3-none-any.whl", hash = "sha256:bb128cdc8124570fa174c6124a9d1de81a9e7b9e46db3d7083720a8beec3a96d", size = 176391, upload-time = "2025-11-06T16:06:16.937Z" }, +] + +[[package]] +name = "mpytools" +version = "0.0.28" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cycler" }, + { name = "emoji" }, + { name = "haggis" }, + { name = "matplotlib" }, + { name = "numpy" }, + { name = "plotly" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/d9/dfdd9bcac8e28807a0b68c18dffb7e44bd37244d531c41adeffe78319ebf/mpytools-0.0.28.tar.gz", hash = "sha256:88669ee9847d6db57327a0cb9df9d1f7e05688a3239a2255ff122986c7658fcf", size = 24153, upload-time = "2025-08-05T10:44:09.089Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/c0/07db92c4cd7d1453b5080435afc691c3338ab3aed2c6f3ae267de5737fb9/mpytools-0.0.28-py3-none-any.whl", hash = "sha256:59bbda5678ebce73ddefa0dbdd86ecc9198fcbd50c1c005b854fb2d962197d6d", size = 33465, upload-time = "2025-08-05T10:44:08.121Z" }, +] + +[[package]] +name = "mrich" +version = "1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/79/afc99575469cf5729b707887ba75b85865d9feb89924b633725c4227c9a5/mrich-1.0.tar.gz", hash = "sha256:3b448227708e5df291c07dd04898e58fdac084770f6ab4e9caf8de97689fbe37", size = 6752, upload-time = "2026-01-27T14:51:39.221Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/30/85fb178959f80ef5745b7cf9549a1b18f87e844dff53944e5bc240cb2bbe/mrich-1.0-py3-none-any.whl", hash = "sha256:1f2b09837a239236dcf50d5982b2ddf84b9941605ab9137d0702d3191dbae00c", size = 5966, upload-time = "2026-01-27T14:51:37.526Z" }, +] + +[[package]] +name = "mypy" +version = "1.19.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/63/e499890d8e39b1ff2df4c0c6ce5d371b6844ee22b8250687a99fd2f657a8/mypy-1.19.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5f05aa3d375b385734388e844bc01733bd33c644ab48e9684faa54e5389775ec", size = 13101333, upload-time = "2025-12-15T05:03:03.28Z" }, + { url = "https://files.pythonhosted.org/packages/72/4b/095626fc136fba96effc4fd4a82b41d688ab92124f8c4f7564bffe5cf1b0/mypy-1.19.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:022ea7279374af1a5d78dfcab853fe6a536eebfda4b59deab53cd21f6cd9f00b", size = 12164102, upload-time = "2025-12-15T05:02:33.611Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/952928dd081bf88a83a5ccd49aaecfcd18fd0d2710c7ff07b8fb6f7032b9/mypy-1.19.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee4c11e460685c3e0c64a4c5de82ae143622410950d6be863303a1c4ba0e36d6", size = 12765799, upload-time = "2025-12-15T05:03:28.44Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0d/93c2e4a287f74ef11a66fb6d49c7a9f05e47b0a4399040e6719b57f500d2/mypy-1.19.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de759aafbae8763283b2ee5869c7255391fbc4de3ff171f8f030b5ec48381b74", size = 13522149, upload-time = "2025-12-15T05:02:36.011Z" }, + { url = "https://files.pythonhosted.org/packages/7b/0e/33a294b56aaad2b338d203e3a1d8b453637ac36cb278b45005e0901cf148/mypy-1.19.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ab43590f9cd5108f41aacf9fca31841142c786827a74ab7cc8a2eacb634e09a1", size = 13810105, upload-time = "2025-12-15T05:02:40.327Z" }, + { url = "https://files.pythonhosted.org/packages/0e/fd/3e82603a0cb66b67c5e7abababce6bf1a929ddf67bf445e652684af5c5a0/mypy-1.19.1-cp310-cp310-win_amd64.whl", hash = "sha256:2899753e2f61e571b3971747e302d5f420c3fd09650e1951e99f823bc3089dac", size = 10057200, upload-time = "2025-12-15T05:02:51.012Z" }, + { url = "https://files.pythonhosted.org/packages/ef/47/6b3ebabd5474d9cdc170d1342fbf9dddc1b0ec13ec90bf9004ee6f391c31/mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288", size = 13028539, upload-time = "2025-12-15T05:03:44.129Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a6/ac7c7a88a3c9c54334f53a941b765e6ec6c4ebd65d3fe8cdcfbe0d0fd7db/mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab", size = 12083163, upload-time = "2025-12-15T05:03:37.679Z" }, + { url = "https://files.pythonhosted.org/packages/67/af/3afa9cf880aa4a2c803798ac24f1d11ef72a0c8079689fac5cfd815e2830/mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6", size = 12687629, upload-time = "2025-12-15T05:02:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/2d/46/20f8a7114a56484ab268b0ab372461cb3a8f7deed31ea96b83a4e4cfcfca/mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331", size = 13436933, upload-time = "2025-12-15T05:03:15.606Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f8/33b291ea85050a21f15da910002460f1f445f8007adb29230f0adea279cb/mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925", size = 13661754, upload-time = "2025-12-15T05:02:26.731Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a3/47cbd4e85bec4335a9cd80cf67dbc02be21b5d4c9c23ad6b95d6c5196bac/mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042", size = 10055772, upload-time = "2025-12-15T05:03:26.179Z" }, + { url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" }, + { url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" }, + { url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z" }, + { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" }, + { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" }, + { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" }, + { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" }, + { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "narwhals" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/b4/02a8add181b8d2cd5da3b667cd102ae536e8c9572ab1a130816d70a89edb/narwhals-2.18.0.tar.gz", hash = "sha256:1de5cee338bc17c338c6278df2c38c0dd4290499fcf70d75e0a51d5f22a6e960", size = 620222, upload-time = "2026-03-10T15:51:27.14Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/75/0b4a10da17a44cf13567d08a9c7632a285297e46253263f1ae119129d10a/narwhals-2.18.0-py3-none-any.whl", hash = "sha256:68378155ee706ac9c5b25868ef62ecddd62947b6df7801a0a156bc0a615d2d0d", size = 444865, upload-time = "2026-03-10T15:51:24.085Z" }, +] + +[[package]] +name = "nbclient" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "nbformat" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/56/91/1c1d5a4b9a9ebba2b4e32b8c852c2975c872aec1fe42ab5e516b2cecd193/nbclient-0.10.4.tar.gz", hash = "sha256:1e54091b16e6da39e297b0ece3e10f6f29f4ac4e8ee515d29f8a7099bd6553c9", size = 62554, upload-time = "2025-12-23T07:45:46.369Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/a0/5b0c2f11142ed1dddec842457d3f65eaf71a0080894eb6f018755b319c3a/nbclient-0.10.4-py3-none-any.whl", hash = "sha256:9162df5a7373d70d606527300a95a975a47c137776cd942e52d9c7e29ff83440", size = 25465, upload-time = "2025-12-23T07:45:44.51Z" }, +] + +[[package]] +name = "nbconvert" +version = "7.17.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "bleach", extra = ["css"] }, + { name = "defusedxml" }, + { name = "jinja2" }, + { name = "jupyter-core" }, + { name = "jupyterlab-pygments" }, + { name = "markupsafe" }, + { name = "mistune" }, + { name = "nbclient" }, + { name = "nbformat" }, + { name = "packaging" }, + { name = "pandocfilters" }, + { name = "pygments" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/47/81f886b699450d0569f7bc551df2b1673d18df7ff25cc0c21ca36ed8a5ff/nbconvert-7.17.0.tar.gz", hash = "sha256:1b2696f1b5be12309f6c7d707c24af604b87dfaf6d950794c7b07acab96dda78", size = 862855, upload-time = "2026-01-29T16:37:48.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/4b/8d5f796a792f8a25f6925a96032f098789f448571eb92011df1ae59e8ea8/nbconvert-7.17.0-py3-none-any.whl", hash = "sha256:4f99a63b337b9a23504347afdab24a11faa7d86b405e5c8f9881cd313336d518", size = 261510, upload-time = "2026-01-29T16:37:46.322Z" }, +] + +[[package]] +name = "nbformat" +version = "5.10.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fastjsonschema" }, + { name = "jsonschema" }, + { name = "jupyter-core" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/fd/91545e604bc3dad7dca9ed03284086039b294c6b3d75c0d2fa45f9e9caf3/nbformat-5.10.4.tar.gz", hash = "sha256:322168b14f937a5d11362988ecac2a4952d3d8e3a2cbeb2319584631226d5b3a", size = 142749, upload-time = "2024-04-04T11:20:37.371Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/82/0340caa499416c78e5d8f5f05947ae4bc3cba53c9f038ab6e9ed964e22f1/nbformat-5.10.4-py3-none-any.whl", hash = "sha256:3b48d6c8fbca4b299bf3982ea7db1af21580e4fec269ad087b9e81588891200b", size = 78454, upload-time = "2024-04-04T11:20:34.895Z" }, +] + +[[package]] +name = "neo4j" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytz" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1b/01/d6ce65e4647f6cb2b9cca3b813978f7329b54b4e36660aaec1ddf0ccce7a/neo4j-6.1.0.tar.gz", hash = "sha256:b5dde8c0d8481e7b6ae3733569d990dd3e5befdc5d452f531ad1884ed3500b84", size = 239629, upload-time = "2026-01-12T11:27:34.777Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/5c/ee71e2dd955045425ef44283f40ba1da67673cf06404916ca2950ac0cd39/neo4j-6.1.0-py3-none-any.whl", hash = "sha256:3bd93941f3a3559af197031157220af9fd71f4f93a311db687bd69ffa417b67d", size = 325326, upload-time = "2026-01-12T11:27:33.196Z" }, +] + +[[package]] +name = "nest-asyncio" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/f8/51569ac65d696c8ecbee95938f89d4abf00f47d58d48f6fbabfe8f0baefe/nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe", size = 7418, upload-time = "2024-01-21T14:25:19.227Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" }, +] + +[[package]] +name = "networkx" +version = "3.4.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/fd/1d/06475e1cd5264c0b870ea2cc6fdb3e37177c1e565c43f56ff17a10e3937f/networkx-3.4.2.tar.gz", hash = "sha256:307c3669428c5362aab27c8a1260aa8f47c4e91d3891f48be0141738d8d053e1", size = 2151368, upload-time = "2024-10-21T12:39:38.695Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/54/dd730b32ea14ea797530a4479b2ed46a6fb250f682a9cfb997e968bf0261/networkx-3.4.2-py3-none-any.whl", hash = "sha256:df5d4365b724cf81b8c6a7312509d0c22386097011ad1abe274afd5e9d3bbc5f", size = 1723263, upload-time = "2024-10-21T12:39:36.247Z" }, +] + +[[package]] +name = "networkx" +version = "3.6.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.13' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version >= '3.13' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version >= '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "notebook" +version = "7.5.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-server" }, + { name = "jupyterlab" }, + { name = "jupyterlab-server" }, + { name = "notebook-shim" }, + { name = "tornado" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/08/9d446fbb49f95de316ea6d7f25d0a4bc95117dd574e35f405895ac706f29/notebook-7.5.4.tar.gz", hash = "sha256:b928b2ba22cb63aa83df2e0e76fe3697950a0c1c4a41b84ebccf1972b1bb5771", size = 14167892, upload-time = "2026-02-24T14:13:56.116Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/01/05e5387b53e0f549212d5eff58845886f3827617b5c9409c966ddc07cb6d/notebook-7.5.4-py3-none-any.whl", hash = "sha256:860e31782b3d3a25ca0819ff039f5cf77845d1bf30c78ef9528b88b25e0a9850", size = 14578014, upload-time = "2026-02-24T14:13:52.274Z" }, +] + +[[package]] +name = "notebook-shim" +version = "0.2.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-server" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/54/d2/92fa3243712b9a3e8bafaf60aac366da1cada3639ca767ff4b5b3654ec28/notebook_shim-0.2.4.tar.gz", hash = "sha256:b4b2cfa1b65d98307ca24361f5b30fe785b53c3fd07b7a47e89acb5e6ac638cb", size = 13167, upload-time = "2024-02-14T23:35:18.353Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/33/bd5b9137445ea4b680023eb0469b2bb969d61303dedb2aac6560ff3d14a1/notebook_shim-0.2.4-py3-none-any.whl", hash = "sha256:411a5be4e9dc882a074ccbcae671eda64cceb068767e9a3419096986560e1cef", size = 13307, upload-time = "2024-02-14T23:35:16.286Z" }, +] + +[[package]] +name = "numpy" +version = "1.26.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/6e/09db70a523a96d25e115e71cc56a6f9031e7b8cd166c1ac8438307c14058/numpy-1.26.4.tar.gz", hash = "sha256:2a02aba9ed12e4ac4eb3ea9421c420301a0c6460d9830d74a9df87efa4912010", size = 15786129, upload-time = "2024-02-06T00:26:44.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/94/ace0fdea5241a27d13543ee117cbc65868e82213fb31a8eb7fe9ff23f313/numpy-1.26.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9ff0f4f29c51e2803569d7a51c2304de5554655a60c5d776e35b4a41413830d0", size = 20631468, upload-time = "2024-02-05T23:48:01.194Z" }, + { url = "https://files.pythonhosted.org/packages/20/f7/b24208eba89f9d1b58c1668bc6c8c4fd472b20c45573cb767f59d49fb0f6/numpy-1.26.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e4ee3380d6de9c9ec04745830fd9e2eccb3e6cf790d39d7b98ffd19b0dd754a", size = 13966411, upload-time = "2024-02-05T23:48:29.038Z" }, + { url = "https://files.pythonhosted.org/packages/fc/a5/4beee6488160798683eed5bdb7eead455892c3b4e1f78d79d8d3f3b084ac/numpy-1.26.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d209d8969599b27ad20994c8e41936ee0964e6da07478d6c35016bc386b66ad4", size = 14219016, upload-time = "2024-02-05T23:48:54.098Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d7/ecf66c1cd12dc28b4040b15ab4d17b773b87fa9d29ca16125de01adb36cd/numpy-1.26.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ffa75af20b44f8dba823498024771d5ac50620e6915abac414251bd971b4529f", size = 18240889, upload-time = "2024-02-05T23:49:25.361Z" }, + { url = "https://files.pythonhosted.org/packages/24/03/6f229fe3187546435c4f6f89f6d26c129d4f5bed40552899fcf1f0bf9e50/numpy-1.26.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:62b8e4b1e28009ef2846b4c7852046736bab361f7aeadeb6a5b89ebec3c7055a", size = 13876746, upload-time = "2024-02-05T23:49:51.983Z" }, + { url = "https://files.pythonhosted.org/packages/39/fe/39ada9b094f01f5a35486577c848fe274e374bbf8d8f472e1423a0bbd26d/numpy-1.26.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a4abb4f9001ad2858e7ac189089c42178fcce737e4169dc61321660f1a96c7d2", size = 18078620, upload-time = "2024-02-05T23:50:22.515Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ef/6ad11d51197aad206a9ad2286dc1aac6a378059e06e8cf22cd08ed4f20dc/numpy-1.26.4-cp310-cp310-win32.whl", hash = "sha256:bfe25acf8b437eb2a8b2d49d443800a5f18508cd811fea3181723922a8a82b07", size = 5972659, upload-time = "2024-02-05T23:50:35.834Z" }, + { url = "https://files.pythonhosted.org/packages/19/77/538f202862b9183f54108557bfda67e17603fc560c384559e769321c9d92/numpy-1.26.4-cp310-cp310-win_amd64.whl", hash = "sha256:b97fe8060236edf3662adfc2c633f56a08ae30560c56310562cb4f95500022d5", size = 15808905, upload-time = "2024-02-05T23:51:03.701Z" }, + { url = "https://files.pythonhosted.org/packages/11/57/baae43d14fe163fa0e4c47f307b6b2511ab8d7d30177c491960504252053/numpy-1.26.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4c66707fabe114439db9068ee468c26bbdf909cac0fb58686a42a24de1760c71", size = 20630554, upload-time = "2024-02-05T23:51:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/1a/2e/151484f49fd03944c4a3ad9c418ed193cfd02724e138ac8a9505d056c582/numpy-1.26.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:edd8b5fe47dab091176d21bb6de568acdd906d1887a4584a15a9a96a1dca06ef", size = 13997127, upload-time = "2024-02-05T23:52:15.314Z" }, + { url = "https://files.pythonhosted.org/packages/79/ae/7e5b85136806f9dadf4878bf73cf223fe5c2636818ba3ab1c585d0403164/numpy-1.26.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7ab55401287bfec946ced39700c053796e7cc0e3acbef09993a9ad2adba6ca6e", size = 14222994, upload-time = "2024-02-05T23:52:47.569Z" }, + { url = "https://files.pythonhosted.org/packages/3a/d0/edc009c27b406c4f9cbc79274d6e46d634d139075492ad055e3d68445925/numpy-1.26.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:666dbfb6ec68962c033a450943ded891bed2d54e6755e35e5835d63f4f6931d5", size = 18252005, upload-time = "2024-02-05T23:53:15.637Z" }, + { url = "https://files.pythonhosted.org/packages/09/bf/2b1aaf8f525f2923ff6cfcf134ae5e750e279ac65ebf386c75a0cf6da06a/numpy-1.26.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:96ff0b2ad353d8f990b63294c8986f1ec3cb19d749234014f4e7eb0112ceba5a", size = 13885297, upload-time = "2024-02-05T23:53:42.16Z" }, + { url = "https://files.pythonhosted.org/packages/df/a0/4e0f14d847cfc2a633a1c8621d00724f3206cfeddeb66d35698c4e2cf3d2/numpy-1.26.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:60dedbb91afcbfdc9bc0b1f3f402804070deed7392c23eb7a7f07fa857868e8a", size = 18093567, upload-time = "2024-02-05T23:54:11.696Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b7/a734c733286e10a7f1a8ad1ae8c90f2d33bf604a96548e0a4a3a6739b468/numpy-1.26.4-cp311-cp311-win32.whl", hash = "sha256:1af303d6b2210eb850fcf03064d364652b7120803a0b872f5211f5234b399f20", size = 5968812, upload-time = "2024-02-05T23:54:26.453Z" }, + { url = "https://files.pythonhosted.org/packages/3f/6b/5610004206cf7f8e7ad91c5a85a8c71b2f2f8051a0c0c4d5916b76d6cbb2/numpy-1.26.4-cp311-cp311-win_amd64.whl", hash = "sha256:cd25bcecc4974d09257ffcd1f098ee778f7834c3ad767fe5db785be9a4aa9cb2", size = 15811913, upload-time = "2024-02-05T23:54:53.933Z" }, + { url = "https://files.pythonhosted.org/packages/95/12/8f2020a8e8b8383ac0177dc9570aad031a3beb12e38847f7129bacd96228/numpy-1.26.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b3ce300f3644fb06443ee2222c2201dd3a89ea6040541412b8fa189341847218", size = 20335901, upload-time = "2024-02-05T23:55:32.801Z" }, + { url = "https://files.pythonhosted.org/packages/75/5b/ca6c8bd14007e5ca171c7c03102d17b4f4e0ceb53957e8c44343a9546dcc/numpy-1.26.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:03a8c78d01d9781b28a6989f6fa1bb2c4f2d51201cf99d3dd875df6fbd96b23b", size = 13685868, upload-time = "2024-02-05T23:55:56.28Z" }, + { url = "https://files.pythonhosted.org/packages/79/f8/97f10e6755e2a7d027ca783f63044d5b1bc1ae7acb12afe6a9b4286eac17/numpy-1.26.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9fad7dcb1aac3c7f0584a5a8133e3a43eeb2fe127f47e3632d43d677c66c102b", size = 13925109, upload-time = "2024-02-05T23:56:20.368Z" }, + { url = "https://files.pythonhosted.org/packages/0f/50/de23fde84e45f5c4fda2488c759b69990fd4512387a8632860f3ac9cd225/numpy-1.26.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:675d61ffbfa78604709862923189bad94014bef562cc35cf61d3a07bba02a7ed", size = 17950613, upload-time = "2024-02-05T23:56:56.054Z" }, + { url = "https://files.pythonhosted.org/packages/4c/0c/9c603826b6465e82591e05ca230dfc13376da512b25ccd0894709b054ed0/numpy-1.26.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab47dbe5cc8210f55aa58e4805fe224dac469cde56b9f731a4c098b91917159a", size = 13572172, upload-time = "2024-02-05T23:57:21.56Z" }, + { url = "https://files.pythonhosted.org/packages/76/8c/2ba3902e1a0fc1c74962ea9bb33a534bb05984ad7ff9515bf8d07527cadd/numpy-1.26.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1dda2e7b4ec9dd512f84935c5f126c8bd8b9f2fc001e9f54af255e8c5f16b0e0", size = 17786643, upload-time = "2024-02-05T23:57:56.585Z" }, + { url = "https://files.pythonhosted.org/packages/28/4a/46d9e65106879492374999e76eb85f87b15328e06bd1550668f79f7b18c6/numpy-1.26.4-cp312-cp312-win32.whl", hash = "sha256:50193e430acfc1346175fcbdaa28ffec49947a06918b7b92130744e81e640110", size = 5677803, upload-time = "2024-02-05T23:58:08.963Z" }, + { url = "https://files.pythonhosted.org/packages/16/2e/86f24451c2d530c88daf997cb8d6ac622c1d40d19f5a031ed68a4b73a374/numpy-1.26.4-cp312-cp312-win_amd64.whl", hash = "sha256:08beddf13648eb95f8d867350f6a018a4be2e5ad54c8d8caed89ebca558b2818", size = 15517754, upload-time = "2024-02-05T23:58:36.364Z" }, +] + +[[package]] +name = "openmm" +version = "8.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/9e/5b22b7e35ce3ecb91506f55ea33165ff253027404cc2ee08baebaeb94c19/openmm-8.5.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:c4403cdfc42fbcec9e7cb3f6fa12c1b9d3f951152903f0a62876c475c62e618d", size = 13338163, upload-time = "2026-04-29T02:46:56.995Z" }, + { url = "https://files.pythonhosted.org/packages/49/3c/d4aa304f6aae9f33366d12e686f28395d912045710d24cb7bed5e09a9dae/openmm-8.5.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1630a4eff9209bac6b713336c64550416fab400cae59fd9953619128aa3408c1", size = 12841997, upload-time = "2026-04-10T21:14:51.653Z" }, + { url = "https://files.pythonhosted.org/packages/94/ae/85b82886e3953a603ecf54f37e7ba9902560067b92ef96724837338c1d6a/openmm-8.5.1-cp310-cp310-manylinux_2_34_aarch64.whl", hash = "sha256:c393489bf1a926be7e019eb5e145d0fe5c94b5934f8e996d711cfa0cdfeb2777", size = 13987439, upload-time = "2026-04-10T21:14:57.461Z" }, + { url = "https://files.pythonhosted.org/packages/a8/1b/02bad5dec5375f2557f80e2a9bb4d9f6483f65f78651a540d8fcd8677f76/openmm-8.5.1-cp310-cp310-manylinux_2_34_x86_64.whl", hash = "sha256:0e41eb743f9c541d6e4c947a45d88477acaf0487ade42fb4e994a0a0cc103e3a", size = 14381656, upload-time = "2026-04-10T21:15:02.046Z" }, + { url = "https://files.pythonhosted.org/packages/09/cd/b0d9581b3db9f9fa0a49a90bc6a5b5defd62fb83919f454216296f6c0d3b/openmm-8.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:d1c7e8adc3bfeed7f66eae5163720fc2d48bd813ed4fd218565a5aa26894f657", size = 13206368, upload-time = "2026-04-10T21:15:06.633Z" }, + { url = "https://files.pythonhosted.org/packages/23/65/595fd0088cfe8f4fa7d3ec22a5a5898480ca7fe4863bbecbd18f43248bc4/openmm-8.5.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:d0cb5e4f632b0f96f3768ce1b30af1da38444b79b04f320ab8a8dde8c6b91afe", size = 13337563, upload-time = "2026-04-10T21:15:11.004Z" }, + { url = "https://files.pythonhosted.org/packages/f3/2a/32da6ff84d96f703257ebcb5915d2efc6545a19738112a918a77cc7a6894/openmm-8.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4deed68b0a275b307384d07364100652e32eaf404cda61f73d936bedad8f95d9", size = 12841135, upload-time = "2026-04-10T21:15:15.683Z" }, + { url = "https://files.pythonhosted.org/packages/72/a4/fd6b180c710ec074d8e16cc3aa1b4b5fc96d7d59ad24fdaee86775096750/openmm-8.5.1-cp311-cp311-manylinux_2_34_aarch64.whl", hash = "sha256:3a1d8618ee89b1a044699099529372a70603fdb3ef5c7a834a7b39700f313204", size = 13986926, upload-time = "2026-04-10T21:15:20.528Z" }, + { url = "https://files.pythonhosted.org/packages/c0/72/07ee4e3042fc469d3cc156f02f9bebd49bea4320a048b8a762902d7999bd/openmm-8.5.1-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:61c3ce28d7bc1e309ac5a7fe182af8099f0185ed8c6c6a845741c9c9345a214c", size = 14381116, upload-time = "2026-04-10T21:15:25.275Z" }, + { url = "https://files.pythonhosted.org/packages/e0/96/ff5f633d17d02f25233ed9c4627b640f93e4f49eeab6256214c2ea242ad8/openmm-8.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:057cb320e5c37dc16c675c896c0af2ea0fcec1611f73c6a4086ff39c3bf3e08a", size = 13206469, upload-time = "2026-04-10T21:15:29.723Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a8/1f6fc3e36e444621d026be9a145c0f6400c1de11c4a0426d1a60d37d0e91/openmm-8.5.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:2fc0f52337cf12706bdb9c157dc4f02079dc95cdf270e2057a21de1f00317652", size = 13336987, upload-time = "2026-04-10T21:15:34.091Z" }, + { url = "https://files.pythonhosted.org/packages/64/27/afd4f608560e439ff9f425afe9e2b3b7fb9e9eeb0cdf1b8533db49eb1a40/openmm-8.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ac0db7b6b488f29925d7e3135cc5f11b6021e4b1f3588b3a7518fb74336e79a2", size = 12836895, upload-time = "2026-04-10T21:15:38.295Z" }, + { url = "https://files.pythonhosted.org/packages/55/29/aa385017888e58de81037451deca484ca5f69e8dcf6806fad1c3e113eb07/openmm-8.5.1-cp312-cp312-manylinux_2_34_aarch64.whl", hash = "sha256:ed5853a2745c35b4ac7c1aabf356ac47c7c10baee5e093167f463c4b6d27f2a6", size = 13981598, upload-time = "2026-04-10T21:15:42.939Z" }, + { url = "https://files.pythonhosted.org/packages/58/dc/958ea1a61d53379a6ba49209cbfeffe1c5239dea4d63306f82f86a441cd2/openmm-8.5.1-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:8349602a76107eb130d07573be44d7ed439f286d254687114e2bfe43e463b048", size = 14386765, upload-time = "2026-04-10T21:15:47.876Z" }, + { url = "https://files.pythonhosted.org/packages/03/5e/c4a926ad73b9d791abf5cb37720b73cec55766f2d8f4c602dd5d3d536c2a/openmm-8.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:56e447955539037aedf62f169c35a4f7c463720a36fff5d07e6b00b4addeb7b2", size = 13207985, upload-time = "2026-04-10T21:15:52.187Z" }, + { url = "https://files.pythonhosted.org/packages/cf/2b/a20b958d14c459e89fcea50015e4e2d5f22e1b0aec67b85d5c79b3a16f48/openmm-8.5.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:15d0fe8f0849202046aa4776fcfe1d909a48765661cd4bb2ffb2adb16b750d42", size = 13337010, upload-time = "2026-04-10T21:15:56.445Z" }, + { url = "https://files.pythonhosted.org/packages/78/75/e59a9db6b3a4f08e4368e6fca745dd71b2aa6e4013f1ae72bb6f60ccedca/openmm-8.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aea6d7ff26930e63f3be9a415c64a2cad6c8db11c1b6ab58d566bd119b538bc9", size = 12835917, upload-time = "2026-04-10T21:16:01.221Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c8/ac33f3cbef9ca913c64dc62834d2c0ea0f1ee1549a72f258ddfda59a8938/openmm-8.5.1-cp313-cp313-manylinux_2_34_aarch64.whl", hash = "sha256:85fd99de00060fb96ca22edaa411121c7ad251a242ce75b021e083cce97d459e", size = 13883007, upload-time = "2026-04-10T21:16:05.802Z" }, + { url = "https://files.pythonhosted.org/packages/ba/f9/f5d81f33fa5160317a1cf4abeaf0334fbf8ab4375c54dac789e7b6cbec43/openmm-8.5.1-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:d278fc670bb33b0887c9562e11c1573e3f7f8a17bbddf217fe87e1f946d6175e", size = 14383980, upload-time = "2026-04-29T02:46:43.325Z" }, + { url = "https://files.pythonhosted.org/packages/ff/50/7cea3b822874edd80d26dd8d335da45e4003678db361f966fda6e77580d5/openmm-8.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:fd6318d6eaaf2475a227f6358757bf45df5d24f874ce14fbfbb13f2616784a5a", size = 13206502, upload-time = "2026-04-10T21:16:10.319Z" }, +] + +[[package]] +name = "openpyxl" +version = "3.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "et-xmlfile" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" }, +] + +[[package]] +name = "orjson" +version = "3.11.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/45/b268004f745ede84e5798b48ee12b05129d19235d0e15267aa57dcdb400b/orjson-3.11.7.tar.gz", hash = "sha256:9b1a67243945819ce55d24a30b59d6a168e86220452d2c96f4d1f093e71c0c49", size = 6144992, upload-time = "2026-02-02T15:38:49.29Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/1a/a373746fa6d0e116dd9e54371a7b54622c44d12296d5d0f3ad5e3ff33490/orjson-3.11.7-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:a02c833f38f36546ba65a452127633afce4cf0dd7296b753d3bb54e55e5c0174", size = 229140, upload-time = "2026-02-02T15:37:06.082Z" }, + { url = "https://files.pythonhosted.org/packages/52/a2/fa129e749d500f9b183e8a3446a193818a25f60261e9ce143ad61e975208/orjson-3.11.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b63c6e6738d7c3470ad01601e23376aa511e50e1f3931395b9f9c722406d1a67", size = 128670, upload-time = "2026-02-02T15:37:08.002Z" }, + { url = "https://files.pythonhosted.org/packages/08/93/1e82011cd1e0bd051ef9d35bed1aa7fb4ea1f0a055dc2c841b46b43a9ebd/orjson-3.11.7-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:043d3006b7d32c7e233b8cfb1f01c651013ea079e08dcef7189a29abd8befe11", size = 123832, upload-time = "2026-02-02T15:37:09.191Z" }, + { url = "https://files.pythonhosted.org/packages/fe/d8/a26b431ef962c7d55736674dddade876822f3e33223c1f47a36879350d04/orjson-3.11.7-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57036b27ac8a25d81112eb0cc9835cd4833c5b16e1467816adc0015f59e870dc", size = 129171, upload-time = "2026-02-02T15:37:11.112Z" }, + { url = "https://files.pythonhosted.org/packages/a7/19/f47819b84a580f490da260c3ee9ade214cf4cf78ac9ce8c1c758f80fdfc9/orjson-3.11.7-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:733ae23ada68b804b222c44affed76b39e30806d38660bf1eb200520d259cc16", size = 141967, upload-time = "2026-02-02T15:37:12.282Z" }, + { url = "https://files.pythonhosted.org/packages/5b/cd/37ece39a0777ba077fdcdbe4cccae3be8ed00290c14bf8afdc548befc260/orjson-3.11.7-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5fdfad2093bdd08245f2e204d977facd5f871c88c4a71230d5bcbd0e43bf6222", size = 130991, upload-time = "2026-02-02T15:37:13.465Z" }, + { url = "https://files.pythonhosted.org/packages/8f/ed/f2b5d66aa9b6b5c02ff5f120efc7b38c7c4962b21e6be0f00fd99a5c348e/orjson-3.11.7-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cededd6738e1c153530793998e31c05086582b08315db48ab66649768f326baa", size = 133674, upload-time = "2026-02-02T15:37:14.694Z" }, + { url = "https://files.pythonhosted.org/packages/c4/6e/baa83e68d1aa09fa8c3e5b2c087d01d0a0bd45256de719ed7bc22c07052d/orjson-3.11.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:14f440c7268c8f8633d1b3d443a434bd70cb15686117ea6beff8fdc8f5917a1e", size = 138722, upload-time = "2026-02-02T15:37:16.501Z" }, + { url = "https://files.pythonhosted.org/packages/0c/47/7f8ef4963b772cd56999b535e553f7eb5cd27e9dd6c049baee6f18bfa05d/orjson-3.11.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:3a2479753bbb95b0ebcf7969f562cdb9668e6d12416a35b0dda79febf89cdea2", size = 409056, upload-time = "2026-02-02T15:37:17.895Z" }, + { url = "https://files.pythonhosted.org/packages/38/eb/2df104dd2244b3618f25325a656f85cc3277f74bbd91224752410a78f3c7/orjson-3.11.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:71924496986275a737f38e3f22b4e0878882b3f7a310d2ff4dc96e812789120c", size = 144196, upload-time = "2026-02-02T15:37:19.349Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2a/ee41de0aa3a6686598661eae2b4ebdff1340c65bfb17fcff8b87138aab21/orjson-3.11.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b4a9eefdc70bf8bf9857f0290f973dec534ac84c35cd6a7f4083be43e7170a8f", size = 134979, upload-time = "2026-02-02T15:37:20.906Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fa/92fc5d3d402b87a8b28277a9ed35386218a6a5287c7fe5ee9b9f02c53fb2/orjson-3.11.7-cp310-cp310-win32.whl", hash = "sha256:ae9e0b37a834cef7ce8f99de6498f8fad4a2c0bf6bfc3d02abd8ed56aa15b2de", size = 127968, upload-time = "2026-02-02T15:37:23.178Z" }, + { url = "https://files.pythonhosted.org/packages/07/29/a576bf36d73d60df06904d3844a9df08e25d59eba64363aaf8ec2f9bff41/orjson-3.11.7-cp310-cp310-win_amd64.whl", hash = "sha256:d772afdb22555f0c58cfc741bdae44180122b3616faa1ecadb595cd526e4c993", size = 125128, upload-time = "2026-02-02T15:37:24.329Z" }, + { url = "https://files.pythonhosted.org/packages/37/02/da6cb01fc6087048d7f61522c327edf4250f1683a58a839fdcc435746dd5/orjson-3.11.7-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9487abc2c2086e7c8eb9a211d2ce8855bae0e92586279d0d27b341d5ad76c85c", size = 228664, upload-time = "2026-02-02T15:37:25.542Z" }, + { url = "https://files.pythonhosted.org/packages/c1/c2/5885e7a5881dba9a9af51bc564e8967225a642b3e03d089289a35054e749/orjson-3.11.7-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:79cacb0b52f6004caf92405a7e1f11e6e2de8bdf9019e4f76b44ba045125cd6b", size = 125344, upload-time = "2026-02-02T15:37:26.92Z" }, + { url = "https://files.pythonhosted.org/packages/a4/1d/4e7688de0a92d1caf600dfd5fb70b4c5bfff51dfa61ac555072ef2d0d32a/orjson-3.11.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c2e85fe4698b6a56d5e2ebf7ae87544d668eb6bde1ad1226c13f44663f20ec9e", size = 128404, upload-time = "2026-02-02T15:37:28.108Z" }, + { url = "https://files.pythonhosted.org/packages/2f/b2/ec04b74ae03a125db7bd69cffd014b227b7f341e3261bf75b5eb88a1aa92/orjson-3.11.7-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b8d14b71c0b12963fe8a62aac87119f1afdf4cb88a400f61ca5ae581449efcb5", size = 123677, upload-time = "2026-02-02T15:37:30.287Z" }, + { url = "https://files.pythonhosted.org/packages/4c/69/f95bdf960605f08f827f6e3291fe243d8aa9c5c9ff017a8d7232209184c3/orjson-3.11.7-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:91c81ef070c8f3220054115e1ef468b1c9ce8497b4e526cb9f68ab4dc0a7ac62", size = 128950, upload-time = "2026-02-02T15:37:31.595Z" }, + { url = "https://files.pythonhosted.org/packages/a4/1b/de59c57bae1d148ef298852abd31909ac3089cff370dfd4cd84cc99cbc42/orjson-3.11.7-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:411ebaf34d735e25e358a6d9e7978954a9c9d58cfb47bc6683cdc3964cd2f910", size = 141756, upload-time = "2026-02-02T15:37:32.985Z" }, + { url = "https://files.pythonhosted.org/packages/ee/9e/9decc59f4499f695f65c650f6cfa6cd4c37a3fbe8fa235a0a3614cb54386/orjson-3.11.7-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a16bcd08ab0bcdfc7e8801d9c4a9cc17e58418e4d48ddc6ded4e9e4b1a94062b", size = 130812, upload-time = "2026-02-02T15:37:34.204Z" }, + { url = "https://files.pythonhosted.org/packages/28/e6/59f932bcabd1eac44e334fe8e3281a92eacfcb450586e1f4bde0423728d8/orjson-3.11.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c0b51672e466fd7e56230ffbae7f1639e18d0ce023351fb75da21b71bc2c960", size = 133444, upload-time = "2026-02-02T15:37:35.446Z" }, + { url = "https://files.pythonhosted.org/packages/f1/36/b0f05c0eaa7ca30bc965e37e6a2956b0d67adb87a9872942d3568da846ae/orjson-3.11.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:136dcd6a2e796dfd9ffca9fc027d778567b0b7c9968d092842d3c323cef88aa8", size = 138609, upload-time = "2026-02-02T15:37:36.657Z" }, + { url = "https://files.pythonhosted.org/packages/b8/03/58ec7d302b8d86944c60c7b4b82975d5161fcce4c9bc8c6cb1d6741b6115/orjson-3.11.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:7ba61079379b0ae29e117db13bda5f28d939766e410d321ec1624afc6a0b0504", size = 408918, upload-time = "2026-02-02T15:37:38.076Z" }, + { url = "https://files.pythonhosted.org/packages/06/3a/868d65ef9a8b99be723bd510de491349618abd9f62c826cf206d962db295/orjson-3.11.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0527a4510c300e3b406591b0ba69b5dc50031895b0a93743526a3fc45f59d26e", size = 143998, upload-time = "2026-02-02T15:37:39.706Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c7/1e18e1c83afe3349f4f6dc9e14910f0ae5f82eac756d1412ea4018938535/orjson-3.11.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a709e881723c9b18acddcfb8ba357322491ad553e277cf467e1e7e20e2d90561", size = 134802, upload-time = "2026-02-02T15:37:41.002Z" }, + { url = "https://files.pythonhosted.org/packages/d4/0b/ccb7ee1a65b37e8eeb8b267dc953561d72370e85185e459616d4345bab34/orjson-3.11.7-cp311-cp311-win32.whl", hash = "sha256:c43b8b5bab288b6b90dac410cca7e986a4fa747a2e8f94615aea407da706980d", size = 127828, upload-time = "2026-02-02T15:37:42.241Z" }, + { url = "https://files.pythonhosted.org/packages/af/9e/55c776dffda3f381e0f07d010a4f5f3902bf48eaba1bb7684d301acd4924/orjson-3.11.7-cp311-cp311-win_amd64.whl", hash = "sha256:6543001328aa857187f905308a028935864aefe9968af3848401b6fe80dbb471", size = 124941, upload-time = "2026-02-02T15:37:43.444Z" }, + { url = "https://files.pythonhosted.org/packages/aa/8e/424a620fa7d263b880162505fb107ef5e0afaa765b5b06a88312ac291560/orjson-3.11.7-cp311-cp311-win_arm64.whl", hash = "sha256:1ee5cc7160a821dfe14f130bc8e63e7611051f964b463d9e2a3a573204446a4d", size = 126245, upload-time = "2026-02-02T15:37:45.18Z" }, + { url = "https://files.pythonhosted.org/packages/80/bf/76f4f1665f6983385938f0e2a5d7efa12a58171b8456c252f3bae8a4cf75/orjson-3.11.7-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:bd03ea7606833655048dab1a00734a2875e3e86c276e1d772b2a02556f0d895f", size = 228545, upload-time = "2026-02-02T15:37:46.376Z" }, + { url = "https://files.pythonhosted.org/packages/79/53/6c72c002cb13b5a978a068add59b25a8bdf2800ac1c9c8ecdb26d6d97064/orjson-3.11.7-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:89e440ebc74ce8ab5c7bc4ce6757b4a6b1041becb127df818f6997b5c71aa60b", size = 125224, upload-time = "2026-02-02T15:37:47.697Z" }, + { url = "https://files.pythonhosted.org/packages/2c/83/10e48852865e5dd151bdfe652c06f7da484578ed02c5fca938e3632cb0b8/orjson-3.11.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ede977b5fe5ac91b1dffc0a517ca4542d2ec8a6a4ff7b2652d94f640796342a", size = 128154, upload-time = "2026-02-02T15:37:48.954Z" }, + { url = "https://files.pythonhosted.org/packages/6e/52/a66e22a2b9abaa374b4a081d410edab6d1e30024707b87eab7c734afe28d/orjson-3.11.7-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b7b1dae39230a393df353827c855a5f176271c23434cfd2db74e0e424e693e10", size = 123548, upload-time = "2026-02-02T15:37:50.187Z" }, + { url = "https://files.pythonhosted.org/packages/de/38/605d371417021359f4910c496f764c48ceb8997605f8c25bf1dfe58c0ebe/orjson-3.11.7-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed46f17096e28fb28d2975834836a639af7278aa87c84f68ab08fbe5b8bd75fa", size = 129000, upload-time = "2026-02-02T15:37:51.426Z" }, + { url = "https://files.pythonhosted.org/packages/44/98/af32e842b0ffd2335c89714d48ca4e3917b42f5d6ee5537832e069a4b3ac/orjson-3.11.7-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3726be79e36e526e3d9c1aceaadbfb4a04ee80a72ab47b3f3c17fefb9812e7b8", size = 141686, upload-time = "2026-02-02T15:37:52.607Z" }, + { url = "https://files.pythonhosted.org/packages/96/0b/fc793858dfa54be6feee940c1463370ece34b3c39c1ca0aa3845f5ba9892/orjson-3.11.7-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0724e265bc548af1dedebd9cb3d24b4e1c1e685a343be43e87ba922a5c5fff2f", size = 130812, upload-time = "2026-02-02T15:37:53.944Z" }, + { url = "https://files.pythonhosted.org/packages/dc/91/98a52415059db3f374757d0b7f0f16e3b5cd5976c90d1c2b56acaea039e6/orjson-3.11.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7745312efa9e11c17fbd3cb3097262d079da26930ae9ae7ba28fb738367cbad", size = 133440, upload-time = "2026-02-02T15:37:55.615Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b6/cb540117bda61791f46381f8c26c8f93e802892830a6055748d3bb1925ab/orjson-3.11.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f904c24bdeabd4298f7a977ef14ca2a022ca921ed670b92ecd16ab6f3d01f867", size = 138386, upload-time = "2026-02-02T15:37:56.814Z" }, + { url = "https://files.pythonhosted.org/packages/63/1a/50a3201c334a7f17c231eee5f841342190723794e3b06293f26e7cf87d31/orjson-3.11.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b9fc4d0f81f394689e0814617aadc4f2ea0e8025f38c226cbf22d3b5ddbf025d", size = 408853, upload-time = "2026-02-02T15:37:58.291Z" }, + { url = "https://files.pythonhosted.org/packages/87/cd/8de1c67d0be44fdc22701e5989c0d015a2adf391498ad42c4dc589cd3013/orjson-3.11.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:849e38203e5be40b776ed2718e587faf204d184fc9a008ae441f9442320c0cab", size = 144130, upload-time = "2026-02-02T15:38:00.163Z" }, + { url = "https://files.pythonhosted.org/packages/0f/fe/d605d700c35dd55f51710d159fc54516a280923cd1b7e47508982fbb387d/orjson-3.11.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4682d1db3bcebd2b64757e0ddf9e87ae5f00d29d16c5cdf3a62f561d08cc3dd2", size = 134818, upload-time = "2026-02-02T15:38:01.507Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e4/15ecc67edb3ddb3e2f46ae04475f2d294e8b60c1825fbe28a428b93b3fbd/orjson-3.11.7-cp312-cp312-win32.whl", hash = "sha256:f4f7c956b5215d949a1f65334cf9d7612dde38f20a95f2315deef167def91a6f", size = 127923, upload-time = "2026-02-02T15:38:02.75Z" }, + { url = "https://files.pythonhosted.org/packages/34/70/2e0855361f76198a3965273048c8e50a9695d88cd75811a5b46444895845/orjson-3.11.7-cp312-cp312-win_amd64.whl", hash = "sha256:bf742e149121dc5648ba0a08ea0871e87b660467ef168a3a5e53bc1fbd64bb74", size = 125007, upload-time = "2026-02-02T15:38:04.032Z" }, + { url = "https://files.pythonhosted.org/packages/68/40/c2051bd19fc467610fed469dc29e43ac65891571138f476834ca192bc290/orjson-3.11.7-cp312-cp312-win_arm64.whl", hash = "sha256:26c3b9132f783b7d7903bf1efb095fed8d4a3a85ec0d334ee8beff3d7a4749d5", size = 126089, upload-time = "2026-02-02T15:38:05.297Z" }, + { url = "https://files.pythonhosted.org/packages/89/25/6e0e52cac5aab51d7b6dcd257e855e1dec1c2060f6b28566c509b4665f62/orjson-3.11.7-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:1d98b30cc1313d52d4af17d9c3d307b08389752ec5f2e5febdfada70b0f8c733", size = 228390, upload-time = "2026-02-02T15:38:06.8Z" }, + { url = "https://files.pythonhosted.org/packages/a5/29/a77f48d2fc8a05bbc529e5ff481fb43d914f9e383ea2469d4f3d51df3d00/orjson-3.11.7-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:d897e81f8d0cbd2abb82226d1860ad2e1ab3ff16d7b08c96ca00df9d45409ef4", size = 125189, upload-time = "2026-02-02T15:38:08.181Z" }, + { url = "https://files.pythonhosted.org/packages/89/25/0a16e0729a0e6a1504f9d1a13cdd365f030068aab64cec6958396b9969d7/orjson-3.11.7-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:814be4b49b228cfc0b3c565acf642dd7d13538f966e3ccde61f4f55be3e20785", size = 128106, upload-time = "2026-02-02T15:38:09.41Z" }, + { url = "https://files.pythonhosted.org/packages/66/da/a2e505469d60666a05ab373f1a6322eb671cb2ba3a0ccfc7d4bc97196787/orjson-3.11.7-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d06e5c5fed5caedd2e540d62e5b1c25e8c82431b9e577c33537e5fa4aa909539", size = 123363, upload-time = "2026-02-02T15:38:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/23/bf/ed73f88396ea35c71b38961734ea4a4746f7ca0768bf28fd551d37e48dd0/orjson-3.11.7-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:31c80ce534ac4ea3739c5ee751270646cbc46e45aea7576a38ffec040b4029a1", size = 129007, upload-time = "2026-02-02T15:38:12.138Z" }, + { url = "https://files.pythonhosted.org/packages/73/3c/b05d80716f0225fc9008fbf8ab22841dcc268a626aa550561743714ce3bf/orjson-3.11.7-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f50979824bde13d32b4320eedd513431c921102796d86be3eee0b58e58a3ecd1", size = 141667, upload-time = "2026-02-02T15:38:13.398Z" }, + { url = "https://files.pythonhosted.org/packages/61/e8/0be9b0addd9bf86abfc938e97441dcd0375d494594b1c8ad10fe57479617/orjson-3.11.7-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e54f3808e2b6b945078c41aa8d9b5834b28c50843846e97807e5adb75fa9705", size = 130832, upload-time = "2026-02-02T15:38:14.698Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ec/c68e3b9021a31d9ec15a94931db1410136af862955854ed5dd7e7e4f5bff/orjson-3.11.7-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a12b80df61aab7b98b490fe9e4879925ba666fccdfcd175252ce4d9035865ace", size = 133373, upload-time = "2026-02-02T15:38:16.109Z" }, + { url = "https://files.pythonhosted.org/packages/d2/45/f3466739aaafa570cc8e77c6dbb853c48bf56e3b43738020e2661e08b0ac/orjson-3.11.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:996b65230271f1a97026fd0e6a753f51fbc0c335d2ad0c6201f711b0da32693b", size = 138307, upload-time = "2026-02-02T15:38:17.453Z" }, + { url = "https://files.pythonhosted.org/packages/e1/84/9f7f02288da1ffb31405c1be07657afd1eecbcb4b64ee2817b6fe0f785fa/orjson-3.11.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ab49d4b2a6a1d415ddb9f37a21e02e0d5dbfe10b7870b21bf779fc21e9156157", size = 408695, upload-time = "2026-02-02T15:38:18.831Z" }, + { url = "https://files.pythonhosted.org/packages/18/07/9dd2f0c0104f1a0295ffbe912bc8d63307a539b900dd9e2c48ef7810d971/orjson-3.11.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:390a1dce0c055ddf8adb6aa94a73b45a4a7d7177b5c584b8d1c1947f2ba60fb3", size = 144099, upload-time = "2026-02-02T15:38:20.28Z" }, + { url = "https://files.pythonhosted.org/packages/a5/66/857a8e4a3292e1f7b1b202883bcdeb43a91566cf59a93f97c53b44bd6801/orjson-3.11.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1eb80451a9c351a71dfaf5b7ccc13ad065405217726b59fdbeadbcc544f9d223", size = 134806, upload-time = "2026-02-02T15:38:22.186Z" }, + { url = "https://files.pythonhosted.org/packages/0a/5b/6ebcf3defc1aab3a338ca777214966851e92efb1f30dc7fc8285216e6d1b/orjson-3.11.7-cp313-cp313-win32.whl", hash = "sha256:7477aa6a6ec6139c5cb1cc7b214643592169a5494d200397c7fc95d740d5fcf3", size = 127914, upload-time = "2026-02-02T15:38:23.511Z" }, + { url = "https://files.pythonhosted.org/packages/00/04/c6f72daca5092e3117840a1b1e88dfc809cc1470cf0734890d0366b684a1/orjson-3.11.7-cp313-cp313-win_amd64.whl", hash = "sha256:b9f95dcdea9d4f805daa9ddf02617a89e484c6985fa03055459f90e87d7a0757", size = 124986, upload-time = "2026-02-02T15:38:24.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/ba/077a0f6f1085d6b806937246860fafbd5b17f3919c70ee3f3d8d9c713f38/orjson-3.11.7-cp313-cp313-win_arm64.whl", hash = "sha256:800988273a014a0541483dc81021247d7eacb0c845a9d1a34a422bc718f41539", size = 126045, upload-time = "2026-02-02T15:38:26.216Z" }, +] + +[[package]] +name = "overrides" +version = "7.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/86/b585f53236dec60aba864e050778b25045f857e17f6e5ea0ae95fe80edd2/overrides-7.7.0.tar.gz", hash = "sha256:55158fa3d93b98cc75299b1e67078ad9003ca27945c76162c1c0766d6f91820a", size = 22812, upload-time = "2024-01-27T21:01:33.423Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/ab/fc8290c6a4c722e5514d80f62b2dc4c4df1a68a41d1364e625c35990fcf3/overrides-7.7.0-py3-none-any.whl", hash = "sha256:c7ed9d062f78b8e4c1a7b70bd8796b35ead4d9f510227ef9c5dc7626c60d7e49", size = 17832, upload-time = "2024-01-27T21:01:31.393Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pandarallel" +version = "1.6.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dill" }, + { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "pandas", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "psutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6e/c5/787365399cc7262e20d1d9f42ba202018c2191e6cd5b1a2a10f9161dae35/pandarallel-1.6.5.tar.gz", hash = "sha256:1c2df98ff6441e8ae13ff428ceebaa7ec42d731f7f972c41ce4fdef1d3adf640", size = 14201, upload-time = "2023-05-02T20:43:15.214Z" } + +[[package]] +name = "pandas" +version = "2.3.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "numpy", marker = "python_full_version < '3.11'" }, + { name = "python-dateutil", marker = "python_full_version < '3.11'" }, + { name = "pytz", marker = "python_full_version < '3.11'" }, + { name = "tzdata", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/f7/f425a00df4fcc22b292c6895c6831c0c8ae1d9fac1e024d16f98a9ce8749/pandas-2.3.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:376c6446ae31770764215a6c937f72d917f214b43560603cd60da6408f183b6c", size = 11555763, upload-time = "2025-09-29T23:16:53.287Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/66d99628ff8ce7857aca52fed8f0066ce209f96be2fede6cef9f84e8d04f/pandas-2.3.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e19d192383eab2f4ceb30b412b22ea30690c9e618f78870357ae1d682912015a", size = 10801217, upload-time = "2025-09-29T23:17:04.522Z" }, + { url = "https://files.pythonhosted.org/packages/1d/03/3fc4a529a7710f890a239cc496fc6d50ad4a0995657dccc1d64695adb9f4/pandas-2.3.3-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5caf26f64126b6c7aec964f74266f435afef1c1b13da3b0636c7518a1fa3e2b1", size = 12148791, upload-time = "2025-09-29T23:17:18.444Z" }, + { url = "https://files.pythonhosted.org/packages/40/a8/4dac1f8f8235e5d25b9955d02ff6f29396191d4e665d71122c3722ca83c5/pandas-2.3.3-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd7478f1463441ae4ca7308a70e90b33470fa593429f9d4c578dd00d1fa78838", size = 12769373, upload-time = "2025-09-29T23:17:35.846Z" }, + { url = "https://files.pythonhosted.org/packages/df/91/82cc5169b6b25440a7fc0ef3a694582418d875c8e3ebf796a6d6470aa578/pandas-2.3.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4793891684806ae50d1288c9bae9330293ab4e083ccd1c5e383c34549c6e4250", size = 13200444, upload-time = "2025-09-29T23:17:49.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/ae/89b3283800ab58f7af2952704078555fa60c807fff764395bb57ea0b0dbd/pandas-2.3.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:28083c648d9a99a5dd035ec125d42439c6c1c525098c58af0fc38dd1a7a1b3d4", size = 13858459, upload-time = "2025-09-29T23:18:03.722Z" }, + { url = "https://files.pythonhosted.org/packages/85/72/530900610650f54a35a19476eca5104f38555afccda1aa11a92ee14cb21d/pandas-2.3.3-cp310-cp310-win_amd64.whl", hash = "sha256:503cf027cf9940d2ceaa1a93cfb5f8c8c7e6e90720a2850378f0b3f3b1e06826", size = 11346086, upload-time = "2025-09-29T23:18:18.505Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fa/7ac648108144a095b4fb6aa3de1954689f7af60a14cf25583f4960ecb878/pandas-2.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:602b8615ebcc4a0c1751e71840428ddebeb142ec02c786e8ad6b1ce3c8dec523", size = 11578790, upload-time = "2025-09-29T23:18:30.065Z" }, + { url = "https://files.pythonhosted.org/packages/9b/35/74442388c6cf008882d4d4bdfc4109be87e9b8b7ccd097ad1e7f006e2e95/pandas-2.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8fe25fc7b623b0ef6b5009149627e34d2a4657e880948ec3c840e9402e5c1b45", size = 10833831, upload-time = "2025-09-29T23:38:56.071Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e4/de154cbfeee13383ad58d23017da99390b91d73f8c11856f2095e813201b/pandas-2.3.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b468d3dad6ff947df92dcb32ede5b7bd41a9b3cceef0a30ed925f6d01fb8fa66", size = 12199267, upload-time = "2025-09-29T23:18:41.627Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c9/63f8d545568d9ab91476b1818b4741f521646cbdd151c6efebf40d6de6f7/pandas-2.3.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b98560e98cb334799c0b07ca7967ac361a47326e9b4e5a7dfb5ab2b1c9d35a1b", size = 12789281, upload-time = "2025-09-29T23:18:56.834Z" }, + { url = "https://files.pythonhosted.org/packages/f2/00/a5ac8c7a0e67fd1a6059e40aa08fa1c52cc00709077d2300e210c3ce0322/pandas-2.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37b5848ba49824e5c30bedb9c830ab9b7751fd049bc7914533e01c65f79791", size = 13240453, upload-time = "2025-09-29T23:19:09.247Z" }, + { url = "https://files.pythonhosted.org/packages/27/4d/5c23a5bc7bd209231618dd9e606ce076272c9bc4f12023a70e03a86b4067/pandas-2.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:db4301b2d1f926ae677a751eb2bd0e8c5f5319c9cb3f88b0becbbb0b07b34151", size = 13890361, upload-time = "2025-09-29T23:19:25.342Z" }, + { url = "https://files.pythonhosted.org/packages/8e/59/712db1d7040520de7a4965df15b774348980e6df45c129b8c64d0dbe74ef/pandas-2.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:f086f6fe114e19d92014a1966f43a3e62285109afe874f067f5abbdcbb10e59c", size = 11348702, upload-time = "2025-09-29T23:19:38.296Z" }, + { url = "https://files.pythonhosted.org/packages/9c/fb/231d89e8637c808b997d172b18e9d4a4bc7bf31296196c260526055d1ea0/pandas-2.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d21f6d74eb1725c2efaa71a2bfc661a0689579b58e9c0ca58a739ff0b002b53", size = 11597846, upload-time = "2025-09-29T23:19:48.856Z" }, + { url = "https://files.pythonhosted.org/packages/5c/bd/bf8064d9cfa214294356c2d6702b716d3cf3bb24be59287a6a21e24cae6b/pandas-2.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3fd2f887589c7aa868e02632612ba39acb0b8948faf5cc58f0850e165bd46f35", size = 10729618, upload-time = "2025-09-29T23:39:08.659Z" }, + { url = "https://files.pythonhosted.org/packages/57/56/cf2dbe1a3f5271370669475ead12ce77c61726ffd19a35546e31aa8edf4e/pandas-2.3.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecaf1e12bdc03c86ad4a7ea848d66c685cb6851d807a26aa245ca3d2017a1908", size = 11737212, upload-time = "2025-09-29T23:19:59.765Z" }, + { url = "https://files.pythonhosted.org/packages/e5/63/cd7d615331b328e287d8233ba9fdf191a9c2d11b6af0c7a59cfcec23de68/pandas-2.3.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b3d11d2fda7eb164ef27ffc14b4fcab16a80e1ce67e9f57e19ec0afaf715ba89", size = 12362693, upload-time = "2025-09-29T23:20:14.098Z" }, + { url = "https://files.pythonhosted.org/packages/a6/de/8b1895b107277d52f2b42d3a6806e69cfef0d5cf1d0ba343470b9d8e0a04/pandas-2.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a68e15f780eddf2b07d242e17a04aa187a7ee12b40b930bfdd78070556550e98", size = 12771002, upload-time = "2025-09-29T23:20:26.76Z" }, + { url = "https://files.pythonhosted.org/packages/87/21/84072af3187a677c5893b170ba2c8fbe450a6ff911234916da889b698220/pandas-2.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:371a4ab48e950033bcf52b6527eccb564f52dc826c02afd9a1bc0ab731bba084", size = 13450971, upload-time = "2025-09-29T23:20:41.344Z" }, + { url = "https://files.pythonhosted.org/packages/86/41/585a168330ff063014880a80d744219dbf1dd7a1c706e75ab3425a987384/pandas-2.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:a16dcec078a01eeef8ee61bf64074b4e524a2a3f4b3be9326420cabe59c4778b", size = 10992722, upload-time = "2025-09-29T23:20:54.139Z" }, + { url = "https://files.pythonhosted.org/packages/cd/4b/18b035ee18f97c1040d94debd8f2e737000ad70ccc8f5513f4eefad75f4b/pandas-2.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:56851a737e3470de7fa88e6131f41281ed440d29a9268dcbf0002da5ac366713", size = 11544671, upload-time = "2025-09-29T23:21:05.024Z" }, + { url = "https://files.pythonhosted.org/packages/31/94/72fac03573102779920099bcac1c3b05975c2cb5f01eac609faf34bed1ca/pandas-2.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdcd9d1167f4885211e401b3036c0c8d9e274eee67ea8d0758a256d60704cfe8", size = 10680807, upload-time = "2025-09-29T23:21:15.979Z" }, + { url = "https://files.pythonhosted.org/packages/16/87/9472cf4a487d848476865321de18cc8c920b8cab98453ab79dbbc98db63a/pandas-2.3.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e32e7cc9af0f1cc15548288a51a3b681cc2a219faa838e995f7dc53dbab1062d", size = 11709872, upload-time = "2025-09-29T23:21:27.165Z" }, + { url = "https://files.pythonhosted.org/packages/15/07/284f757f63f8a8d69ed4472bfd85122bd086e637bf4ed09de572d575a693/pandas-2.3.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318d77e0e42a628c04dc56bcef4b40de67918f7041c2b061af1da41dcff670ac", size = 12306371, upload-time = "2025-09-29T23:21:40.532Z" }, + { url = "https://files.pythonhosted.org/packages/33/81/a3afc88fca4aa925804a27d2676d22dcd2031c2ebe08aabd0ae55b9ff282/pandas-2.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4e0a175408804d566144e170d0476b15d78458795bb18f1304fb94160cabf40c", size = 12765333, upload-time = "2025-09-29T23:21:55.77Z" }, + { url = "https://files.pythonhosted.org/packages/8d/0f/b4d4ae743a83742f1153464cf1a8ecfafc3ac59722a0b5c8602310cb7158/pandas-2.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2d9ab0fc11822b5eece72ec9587e172f63cff87c00b062f6e37448ced4493", size = 13418120, upload-time = "2025-09-29T23:22:10.109Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c7/e54682c96a895d0c808453269e0b5928a07a127a15704fedb643e9b0a4c8/pandas-2.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:f8bfc0e12dc78f777f323f55c58649591b2cd0c43534e8355c51d3fede5f4dee", size = 10993991, upload-time = "2025-09-29T23:25:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/f9/ca/3f8d4f49740799189e1395812f3bf23b5e8fc7c190827d55a610da72ce55/pandas-2.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:75ea25f9529fdec2d2e93a42c523962261e567d250b0013b16210e1d40d7c2e5", size = 12048227, upload-time = "2025-09-29T23:22:24.343Z" }, + { url = "https://files.pythonhosted.org/packages/0e/5a/f43efec3e8c0cc92c4663ccad372dbdff72b60bdb56b2749f04aa1d07d7e/pandas-2.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74ecdf1d301e812db96a465a525952f4dde225fdb6d8e5a521d47e1f42041e21", size = 11411056, upload-time = "2025-09-29T23:22:37.762Z" }, + { url = "https://files.pythonhosted.org/packages/46/b1/85331edfc591208c9d1a63a06baa67b21d332e63b7a591a5ba42a10bb507/pandas-2.3.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6435cb949cb34ec11cc9860246ccb2fdc9ecd742c12d3304989017d53f039a78", size = 11645189, upload-time = "2025-09-29T23:22:51.688Z" }, + { url = "https://files.pythonhosted.org/packages/44/23/78d645adc35d94d1ac4f2a3c4112ab6f5b8999f4898b8cdf01252f8df4a9/pandas-2.3.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:900f47d8f20860de523a1ac881c4c36d65efcb2eb850e6948140fa781736e110", size = 12121912, upload-time = "2025-09-29T23:23:05.042Z" }, + { url = "https://files.pythonhosted.org/packages/53/da/d10013df5e6aaef6b425aa0c32e1fc1f3e431e4bcabd420517dceadce354/pandas-2.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a45c765238e2ed7d7c608fc5bc4a6f88b642f2f01e70c0c23d2224dd21829d86", size = 12712160, upload-time = "2025-09-29T23:23:28.57Z" }, + { url = "https://files.pythonhosted.org/packages/bd/17/e756653095a083d8a37cbd816cb87148debcfcd920129b25f99dd8d04271/pandas-2.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c4fc4c21971a1a9f4bdb4c73978c7f7256caa3e62b323f70d6cb80db583350bc", size = 13199233, upload-time = "2025-09-29T23:24:24.876Z" }, +] + +[[package]] +name = "pandas" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.13' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version >= '3.13' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version >= '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "numpy", marker = "python_full_version >= '3.11'" }, + { name = "python-dateutil", marker = "python_full_version >= '3.11'" }, + { name = "tzdata", marker = "(python_full_version >= '3.11' and sys_platform == 'emscripten') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/0c/b28ed414f080ee0ad153f848586d61d1878f91689950f037f976ce15f6c8/pandas-3.0.1.tar.gz", hash = "sha256:4186a699674af418f655dbd420ed87f50d56b4cd6603784279d9eef6627823c8", size = 4641901, upload-time = "2026-02-17T22:20:16.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/07/c7087e003ceee9b9a82539b40414ec557aa795b584a1a346e89180853d79/pandas-3.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:de09668c1bf3b925c07e5762291602f0d789eca1b3a781f99c1c78f6cac0e7ea", size = 10323380, upload-time = "2026-02-17T22:18:16.133Z" }, + { url = "https://files.pythonhosted.org/packages/c1/27/90683c7122febeefe84a56f2cde86a9f05f68d53885cebcc473298dfc33e/pandas-3.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:24ba315ba3d6e5806063ac6eb717504e499ce30bd8c236d8693a5fd3f084c796", size = 9923455, upload-time = "2026-02-17T22:18:19.13Z" }, + { url = "https://files.pythonhosted.org/packages/0e/f1/ed17d927f9950643bc7631aa4c99ff0cc83a37864470bc419345b656a41f/pandas-3.0.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:406ce835c55bac912f2a0dcfaf27c06d73c6b04a5dde45f1fd3169ce31337389", size = 10753464, upload-time = "2026-02-17T22:18:21.134Z" }, + { url = "https://files.pythonhosted.org/packages/2e/7c/870c7e7daec2a6c7ff2ac9e33b23317230d4e4e954b35112759ea4a924a7/pandas-3.0.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:830994d7e1f31dd7e790045235605ab61cff6c94defc774547e8b7fdfbff3dc7", size = 11255234, upload-time = "2026-02-17T22:18:24.175Z" }, + { url = "https://files.pythonhosted.org/packages/5c/39/3653fe59af68606282b989c23d1a543ceba6e8099cbcc5f1d506a7bae2aa/pandas-3.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a64ce8b0f2de1d2efd2ae40b0abe7f8ae6b29fbfb3812098ed5a6f8e235ad9bf", size = 11767299, upload-time = "2026-02-17T22:18:26.824Z" }, + { url = "https://files.pythonhosted.org/packages/9b/31/1daf3c0c94a849c7a8dab8a69697b36d313b229918002ba3e409265c7888/pandas-3.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9832c2c69da24b602c32e0c7b1b508a03949c18ba08d4d9f1c1033426685b447", size = 12333292, upload-time = "2026-02-17T22:18:28.996Z" }, + { url = "https://files.pythonhosted.org/packages/1f/67/af63f83cd6ca603a00fe8530c10a60f0879265b8be00b5930e8e78c5b30b/pandas-3.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:84f0904a69e7365f79a0c77d3cdfccbfb05bf87847e3a51a41e1426b0edb9c79", size = 9892176, upload-time = "2026-02-17T22:18:31.79Z" }, + { url = "https://files.pythonhosted.org/packages/79/ab/9c776b14ac4b7b4140788eca18468ea39894bc7340a408f1d1e379856a6b/pandas-3.0.1-cp311-cp311-win_arm64.whl", hash = "sha256:4a68773d5a778afb31d12e34f7dd4612ab90de8c6fb1d8ffe5d4a03b955082a1", size = 9151328, upload-time = "2026-02-17T22:18:35.721Z" }, + { url = "https://files.pythonhosted.org/packages/37/51/b467209c08dae2c624873d7491ea47d2b47336e5403309d433ea79c38571/pandas-3.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:476f84f8c20c9f5bc47252b66b4bb25e1a9fc2fa98cead96744d8116cb85771d", size = 10344357, upload-time = "2026-02-17T22:18:38.262Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f1/e2567ffc8951ab371db2e40b2fe068e36b81d8cf3260f06ae508700e5504/pandas-3.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0ab749dfba921edf641d4036c4c21c0b3ea70fea478165cb98a998fb2a261955", size = 9884543, upload-time = "2026-02-17T22:18:41.476Z" }, + { url = "https://files.pythonhosted.org/packages/d7/39/327802e0b6d693182403c144edacbc27eb82907b57062f23ef5a4c4a5ea7/pandas-3.0.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8e36891080b87823aff3640c78649b91b8ff6eea3c0d70aeabd72ea43ab069b", size = 10396030, upload-time = "2026-02-17T22:18:43.822Z" }, + { url = "https://files.pythonhosted.org/packages/3d/fe/89d77e424365280b79d99b3e1e7d606f5165af2f2ecfaf0c6d24c799d607/pandas-3.0.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:532527a701281b9dd371e2f582ed9094f4c12dd9ffb82c0c54ee28d8ac9520c4", size = 10876435, upload-time = "2026-02-17T22:18:45.954Z" }, + { url = "https://files.pythonhosted.org/packages/b5/a6/2a75320849dd154a793f69c951db759aedb8d1dd3939eeacda9bdcfa1629/pandas-3.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:356e5c055ed9b0da1580d465657bc7d00635af4fd47f30afb23025352ba764d1", size = 11405133, upload-time = "2026-02-17T22:18:48.533Z" }, + { url = "https://files.pythonhosted.org/packages/58/53/1d68fafb2e02d7881df66aa53be4cd748d25cbe311f3b3c85c93ea5d30ca/pandas-3.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9d810036895f9ad6345b8f2a338dd6998a74e8483847403582cab67745bff821", size = 11932065, upload-time = "2026-02-17T22:18:50.837Z" }, + { url = "https://files.pythonhosted.org/packages/75/08/67cc404b3a966b6df27b38370ddd96b3b023030b572283d035181854aac5/pandas-3.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:536232a5fe26dd989bd633e7a0c450705fdc86a207fec7254a55e9a22950fe43", size = 9741627, upload-time = "2026-02-17T22:18:53.905Z" }, + { url = "https://files.pythonhosted.org/packages/86/4f/caf9952948fb00d23795f09b893d11f1cacb384e666854d87249530f7cbe/pandas-3.0.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f463ebfd8de7f326d38037c7363c6dacb857c5881ab8961fb387804d6daf2f7", size = 9052483, upload-time = "2026-02-17T22:18:57.31Z" }, + { url = "https://files.pythonhosted.org/packages/0b/48/aad6ec4f8d007534c091e9a7172b3ec1b1ee6d99a9cbb936b5eab6c6cf58/pandas-3.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5272627187b5d9c20e55d27caf5f2cd23e286aba25cadf73c8590e432e2b7262", size = 10317509, upload-time = "2026-02-17T22:18:59.498Z" }, + { url = "https://files.pythonhosted.org/packages/a8/14/5990826f779f79148ae9d3a2c39593dc04d61d5d90541e71b5749f35af95/pandas-3.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:661e0f665932af88c7877f31da0dc743fe9c8f2524bdffe23d24fdcb67ef9d56", size = 9860561, upload-time = "2026-02-17T22:19:02.265Z" }, + { url = "https://files.pythonhosted.org/packages/fa/80/f01ff54664b6d70fed71475543d108a9b7c888e923ad210795bef04ffb7d/pandas-3.0.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:75e6e292ff898679e47a2199172593d9f6107fd2dd3617c22c2946e97d5df46e", size = 10365506, upload-time = "2026-02-17T22:19:05.017Z" }, + { url = "https://files.pythonhosted.org/packages/f2/85/ab6d04733a7d6ff32bfc8382bf1b07078228f5d6ebec5266b91bfc5c4ff7/pandas-3.0.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1ff8cf1d2896e34343197685f432450ec99a85ba8d90cce2030c5eee2ef98791", size = 10873196, upload-time = "2026-02-17T22:19:07.204Z" }, + { url = "https://files.pythonhosted.org/packages/48/a9/9301c83d0b47c23ac5deab91c6b39fd98d5b5db4d93b25df8d381451828f/pandas-3.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:eca8b4510f6763f3d37359c2105df03a7a221a508f30e396a51d0713d462e68a", size = 11370859, upload-time = "2026-02-17T22:19:09.436Z" }, + { url = "https://files.pythonhosted.org/packages/59/fe/0c1fc5bd2d29c7db2ab372330063ad555fb83e08422829c785f5ec2176ca/pandas-3.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:06aff2ad6f0b94a17822cf8b83bbb563b090ed82ff4fe7712db2ce57cd50d9b8", size = 11924584, upload-time = "2026-02-17T22:19:11.562Z" }, + { url = "https://files.pythonhosted.org/packages/d6/7d/216a1588b65a7aa5f4535570418a599d943c85afb1d95b0876fc00aa1468/pandas-3.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:9fea306c783e28884c29057a1d9baa11a349bbf99538ec1da44c8476563d1b25", size = 9742769, upload-time = "2026-02-17T22:19:13.926Z" }, + { url = "https://files.pythonhosted.org/packages/c4/cb/810a22a6af9a4e97c8ab1c946b47f3489c5bca5adc483ce0ffc84c9cc768/pandas-3.0.1-cp313-cp313-win_arm64.whl", hash = "sha256:a8d37a43c52917427e897cb2e429f67a449327394396a81034a4449b99afda59", size = 9043855, upload-time = "2026-02-17T22:19:16.09Z" }, + { url = "https://files.pythonhosted.org/packages/92/fa/423c89086cca1f039cf1253c3ff5b90f157b5b3757314aa635f6bf3e30aa/pandas-3.0.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d54855f04f8246ed7b6fc96b05d4871591143c46c0b6f4af874764ed0d2d6f06", size = 10752673, upload-time = "2026-02-17T22:19:18.304Z" }, + { url = "https://files.pythonhosted.org/packages/22/23/b5a08ec1f40020397f0faba72f1e2c11f7596a6169c7b3e800abff0e433f/pandas-3.0.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e1b677accee34a09e0dc2ce5624e4a58a1870ffe56fc021e9caf7f23cd7668f", size = 10404967, upload-time = "2026-02-17T22:19:20.726Z" }, + { url = "https://files.pythonhosted.org/packages/5c/81/94841f1bb4afdc2b52a99daa895ac2c61600bb72e26525ecc9543d453ebc/pandas-3.0.1-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a9cabbdcd03f1b6cd254d6dda8ae09b0252524be1592594c00b7895916cb1324", size = 10320575, upload-time = "2026-02-17T22:19:24.919Z" }, + { url = "https://files.pythonhosted.org/packages/0a/8b/2ae37d66a5342a83adadfd0cb0b4bf9c3c7925424dd5f40d15d6cfaa35ee/pandas-3.0.1-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ae2ab1f166668b41e770650101e7090824fd34d17915dd9cd479f5c5e0065e9", size = 10710921, upload-time = "2026-02-17T22:19:27.181Z" }, + { url = "https://files.pythonhosted.org/packages/a2/61/772b2e2757855e232b7ccf7cb8079a5711becb3a97f291c953def15a833f/pandas-3.0.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6bf0603c2e30e2cafac32807b06435f28741135cb8697eae8b28c7d492fc7d76", size = 11334191, upload-time = "2026-02-17T22:19:29.411Z" }, + { url = "https://files.pythonhosted.org/packages/1b/08/b16c6df3ef555d8495d1d265a7963b65be166785d28f06a350913a4fac78/pandas-3.0.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6c426422973973cae1f4a23e51d4ae85974f44871b24844e4f7de752dd877098", size = 11782256, upload-time = "2026-02-17T22:19:32.34Z" }, + { url = "https://files.pythonhosted.org/packages/55/80/178af0594890dee17e239fca96d3d8670ba0f5ff59b7d0439850924a9c09/pandas-3.0.1-cp313-cp313t-win_amd64.whl", hash = "sha256:b03f91ae8c10a85c1613102c7bef5229b5379f343030a3ccefeca8a33414cf35", size = 10485047, upload-time = "2026-02-17T22:19:34.605Z" }, +] + +[[package]] +name = "pandera" +version = "0.29.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "pydantic" }, + { name = "typeguard" }, + { name = "typing-extensions" }, + { name = "typing-inspect" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/80/ee/8e0d40dad2c0947b933fc9c0959b2c17cc3419ccdf50df683216f37a3f96/pandera-0.29.0.tar.gz", hash = "sha256:06bc4fc1e4ff02534dd44482a9bc704fb2e58fe3fbb11be906aa714f7f5ec801", size = 575324, upload-time = "2026-01-29T02:49:36.891Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/7b/03299e4ccc5e3cfb0f9e234207ac43ef08b3ba6c4c2882c890e550ceadba/pandera-0.29.0-py3-none-any.whl", hash = "sha256:b3b25d6c00d7c100fbab96aff0e81e52d3dae543a880d24135cca705fa97c516", size = 295876, upload-time = "2026-01-29T02:49:34.812Z" }, +] + +[[package]] +name = "pandocfilters" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/70/6f/3dd4940bbe001c06a65f88e36bad298bc7a0de5036115639926b0c5c0458/pandocfilters-1.5.1.tar.gz", hash = "sha256:002b4a555ee4ebc03f8b66307e287fa492e4a77b4ea14d3f934328297bb4939e", size = 8454, upload-time = "2024-01-18T20:08:13.726Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/af/4fbc8cab944db5d21b7e2a5b8e9211a03a79852b1157e2c102fcc61ac440/pandocfilters-1.5.1-py2.py3-none-any.whl", hash = "sha256:93be382804a9cdb0a7267585f157e5d1731bbe5545a85b268d6f5fe6232de2bc", size = 8663, upload-time = "2024-01-18T20:08:11.28Z" }, +] + +[[package]] +name = "parso" +version = "0.8.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/76/a1e769043c0c0c9fe391b702539d594731a4362334cdf4dc25d0c09761e7/parso-0.8.6.tar.gz", hash = "sha256:2b9a0332696df97d454fa67b81618fd69c35a7b90327cbe6ba5c92d2c68a7bfd", size = 401621, upload-time = "2026-02-09T15:45:24.425Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/61/fae042894f4296ec49e3f193aff5d7c18440da9e48102c3315e1bc4519a7/parso-0.8.6-py2.py3-none-any.whl", hash = "sha256:2c549f800b70a5c4952197248825584cb00f033b29c692671d3bf08bf380baff", size = 106894, upload-time = "2026-02-09T15:45:21.391Z" }, +] + +[[package]] +name = "pathspec" +version = "1.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, +] + +[[package]] +name = "pdbfixer" +version = "1.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "legacy-cgi", marker = "python_full_version >= '3.13'" }, + { name = "numpy" }, + { name = "openmm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/bd/394c3185e4b9fbacf803651a374e99e597d3411d316784f8ea6d8fc17af5/pdbfixer-1.12.0.tar.gz", hash = "sha256:82e54074b33eb00270e9a439c8633fcf82cd3ab7a36561b01425a260c0fc4143", size = 665893, upload-time = "2026-02-06T19:11:22.323Z" } + +[[package]] +name = "pebble" +version = "5.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/3b/7debef984e227a70798963cf2e5ea90882f62bca659b33cbd421a453abd1/pebble-5.2.0.tar.gz", hash = "sha256:8e0a5f6a1cfdd0ac1bfc4a789e20d2b4b895de976e547d23b7de23b71ef39b34", size = 39811, upload-time = "2026-01-25T12:05:11.422Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/de/1cce5274efcb921484998864820f2ba41679ea472daef748a7bc03fc0bb7/pebble-5.2.0-py3-none-any.whl", hash = "sha256:6237a792a78524648857ec6d2dae069c91a45bdef18daf957078a56e2dd8e0a8", size = 34881, upload-time = "2026-01-25T12:05:09.714Z" }, +] + +[[package]] +name = "pexpect" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ptyprocess", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, +] + +[[package]] +name = "pillow" +version = "12.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/30/5bd3d794762481f8c8ae9c80e7b76ecea73b916959eb587521358ef0b2f9/pillow-12.1.1-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1f1625b72740fdda5d77b4def688eb8fd6490975d06b909fd19f13f391e077e0", size = 5304099, upload-time = "2026-02-11T04:20:06.13Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c1/aab9e8f3eeb4490180e357955e15c2ef74b31f64790ff356c06fb6cf6d84/pillow-12.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:178aa072084bd88ec759052feca8e56cbb14a60b39322b99a049e58090479713", size = 4657880, upload-time = "2026-02-11T04:20:09.291Z" }, + { url = "https://files.pythonhosted.org/packages/f1/0a/9879e30d56815ad529d3985aeff5af4964202425c27261a6ada10f7cbf53/pillow-12.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b66e95d05ba806247aaa1561f080abc7975daf715c30780ff92a20e4ec546e1b", size = 6222587, upload-time = "2026-02-11T04:20:10.82Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5f/a1b72ff7139e4f89014e8d451442c74a774d5c43cd938fb0a9f878576b37/pillow-12.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:89c7e895002bbe49cdc5426150377cbbc04767d7547ed145473f496dfa40408b", size = 8027678, upload-time = "2026-02-11T04:20:12.455Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c2/c7cb187dac79a3d22c3ebeae727abee01e077c8c7d930791dc592f335153/pillow-12.1.1-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a5cbdcddad0af3da87cb16b60d23648bc3b51967eb07223e9fed77a82b457c4", size = 6335777, upload-time = "2026-02-11T04:20:14.441Z" }, + { url = "https://files.pythonhosted.org/packages/0c/7b/f9b09a7804ec7336effb96c26d37c29d27225783dc1501b7d62dcef6ae25/pillow-12.1.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f51079765661884a486727f0729d29054242f74b46186026582b4e4769918e4", size = 7027140, upload-time = "2026-02-11T04:20:16.387Z" }, + { url = "https://files.pythonhosted.org/packages/98/b2/2fa3c391550bd421b10849d1a2144c44abcd966daadd2f7c12e19ea988c4/pillow-12.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:99c1506ea77c11531d75e3a412832a13a71c7ebc8192ab9e4b2e355555920e3e", size = 6449855, upload-time = "2026-02-11T04:20:18.554Z" }, + { url = "https://files.pythonhosted.org/packages/96/ff/9caf4b5b950c669263c39e96c78c0d74a342c71c4f43fd031bb5cb7ceac9/pillow-12.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:36341d06738a9f66c8287cf8b876d24b18db9bd8740fa0672c74e259ad408cff", size = 7151329, upload-time = "2026-02-11T04:20:20.646Z" }, + { url = "https://files.pythonhosted.org/packages/7b/f8/4b24841f582704da675ca535935bccb32b00a6da1226820845fac4a71136/pillow-12.1.1-cp310-cp310-win32.whl", hash = "sha256:6c52f062424c523d6c4db85518774cc3d50f5539dd6eed32b8f6229b26f24d40", size = 6325574, upload-time = "2026-02-11T04:20:22.43Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f9/9f6b01c0881d7036063aa6612ef04c0e2cad96be21325a1e92d0203f8e91/pillow-12.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:c6008de247150668a705a6338156efb92334113421ceecf7438a12c9a12dab23", size = 7032347, upload-time = "2026-02-11T04:20:23.932Z" }, + { url = "https://files.pythonhosted.org/packages/79/13/c7922edded3dcdaf10c59297540b72785620abc0538872c819915746757d/pillow-12.1.1-cp310-cp310-win_arm64.whl", hash = "sha256:1a9b0ee305220b392e1124a764ee4265bd063e54a751a6b62eff69992f457fa9", size = 2453457, upload-time = "2026-02-11T04:20:25.392Z" }, + { url = "https://files.pythonhosted.org/packages/2b/46/5da1ec4a5171ee7bf1a0efa064aba70ba3d6e0788ce3f5acd1375d23c8c0/pillow-12.1.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:e879bb6cd5c73848ef3b2b48b8af9ff08c5b71ecda8048b7dd22d8a33f60be32", size = 5304084, upload-time = "2026-02-11T04:20:27.501Z" }, + { url = "https://files.pythonhosted.org/packages/78/93/a29e9bc02d1cf557a834da780ceccd54e02421627200696fcf805ebdc3fb/pillow-12.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:365b10bb9417dd4498c0e3b128018c4a624dc11c7b97d8cc54effe3b096f4c38", size = 4657866, upload-time = "2026-02-11T04:20:29.827Z" }, + { url = "https://files.pythonhosted.org/packages/13/84/583a4558d492a179d31e4aae32eadce94b9acf49c0337c4ce0b70e0a01f2/pillow-12.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d4ce8e329c93845720cd2014659ca67eac35f6433fd3050393d85f3ecef0dad5", size = 6232148, upload-time = "2026-02-11T04:20:31.329Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e2/53c43334bbbb2d3b938978532fbda8e62bb6e0b23a26ce8592f36bcc4987/pillow-12.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc354a04072b765eccf2204f588a7a532c9511e8b9c7f900e1b64e3e33487090", size = 8038007, upload-time = "2026-02-11T04:20:34.225Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a6/3d0e79c8a9d58150dd98e199d7c1c56861027f3829a3a60b3c2784190180/pillow-12.1.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e7976bf1910a8116b523b9f9f58bf410f3e8aa330cd9a2bb2953f9266ab49af", size = 6345418, upload-time = "2026-02-11T04:20:35.858Z" }, + { url = "https://files.pythonhosted.org/packages/a2/c8/46dfeac5825e600579157eea177be43e2f7ff4a99da9d0d0a49533509ac5/pillow-12.1.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:597bd9c8419bc7c6af5604e55847789b69123bbe25d65cc6ad3012b4f3c98d8b", size = 7034590, upload-time = "2026-02-11T04:20:37.91Z" }, + { url = "https://files.pythonhosted.org/packages/af/bf/e6f65d3db8a8bbfeaf9e13cc0417813f6319863a73de934f14b2229ada18/pillow-12.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c1fc0f2ca5f96a3c8407e41cca26a16e46b21060fe6d5b099d2cb01412222f5", size = 6458655, upload-time = "2026-02-11T04:20:39.496Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c2/66091f3f34a25894ca129362e510b956ef26f8fb67a0e6417bc5744e56f1/pillow-12.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:578510d88c6229d735855e1f278aa305270438d36a05031dfaae5067cc8eb04d", size = 7159286, upload-time = "2026-02-11T04:20:41.139Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5a/24bc8eb526a22f957d0cec6243146744966d40857e3d8deb68f7902ca6c1/pillow-12.1.1-cp311-cp311-win32.whl", hash = "sha256:7311c0a0dcadb89b36b7025dfd8326ecfa36964e29913074d47382706e516a7c", size = 6328663, upload-time = "2026-02-11T04:20:43.184Z" }, + { url = "https://files.pythonhosted.org/packages/31/03/bef822e4f2d8f9d7448c133d0a18185d3cce3e70472774fffefe8b0ed562/pillow-12.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:fbfa2a7c10cc2623f412753cddf391c7f971c52ca40a3f65dc5039b2939e8563", size = 7031448, upload-time = "2026-02-11T04:20:44.696Z" }, + { url = "https://files.pythonhosted.org/packages/49/70/f76296f53610bd17b2e7d31728b8b7825e3ac3b5b3688b51f52eab7c0818/pillow-12.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:b81b5e3511211631b3f672a595e3221252c90af017e399056d0faabb9538aa80", size = 2453651, upload-time = "2026-02-11T04:20:46.243Z" }, + { url = "https://files.pythonhosted.org/packages/07/d3/8df65da0d4df36b094351dce696f2989bec731d4f10e743b1c5f4da4d3bf/pillow-12.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052", size = 5262803, upload-time = "2026-02-11T04:20:47.653Z" }, + { url = "https://files.pythonhosted.org/packages/d6/71/5026395b290ff404b836e636f51d7297e6c83beceaa87c592718747e670f/pillow-12.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984", size = 4657601, upload-time = "2026-02-11T04:20:49.328Z" }, + { url = "https://files.pythonhosted.org/packages/b1/2e/1001613d941c67442f745aff0f7cc66dd8df9a9c084eb497e6a543ee6f7e/pillow-12.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79", size = 6234995, upload-time = "2026-02-11T04:20:51.032Z" }, + { url = "https://files.pythonhosted.org/packages/07/26/246ab11455b2549b9233dbd44d358d033a2f780fa9007b61a913c5b2d24e/pillow-12.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293", size = 8045012, upload-time = "2026-02-11T04:20:52.882Z" }, + { url = "https://files.pythonhosted.org/packages/b2/8b/07587069c27be7535ac1fe33874e32de118fbd34e2a73b7f83436a88368c/pillow-12.1.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397", size = 6349638, upload-time = "2026-02-11T04:20:54.444Z" }, + { url = "https://files.pythonhosted.org/packages/ff/79/6df7b2ee763d619cda2fb4fea498e5f79d984dae304d45a8999b80d6cf5c/pillow-12.1.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0", size = 7041540, upload-time = "2026-02-11T04:20:55.97Z" }, + { url = "https://files.pythonhosted.org/packages/2c/5e/2ba19e7e7236d7529f4d873bdaf317a318896bac289abebd4bb00ef247f0/pillow-12.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3", size = 6462613, upload-time = "2026-02-11T04:20:57.542Z" }, + { url = "https://files.pythonhosted.org/packages/03/03/31216ec124bb5c3dacd74ce8efff4cc7f52643653bad4825f8f08c697743/pillow-12.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35", size = 7166745, upload-time = "2026-02-11T04:20:59.196Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e7/7c4552d80052337eb28653b617eafdef39adfb137c49dd7e831b8dc13bc5/pillow-12.1.1-cp312-cp312-win32.whl", hash = "sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a", size = 6328823, upload-time = "2026-02-11T04:21:01.385Z" }, + { url = "https://files.pythonhosted.org/packages/3d/17/688626d192d7261bbbf98846fc98995726bddc2c945344b65bec3a29d731/pillow-12.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6", size = 7033367, upload-time = "2026-02-11T04:21:03.536Z" }, + { url = "https://files.pythonhosted.org/packages/ed/fe/a0ef1f73f939b0eca03ee2c108d0043a87468664770612602c63266a43c4/pillow-12.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523", size = 2453811, upload-time = "2026-02-11T04:21:05.116Z" }, + { url = "https://files.pythonhosted.org/packages/d5/11/6db24d4bd7685583caeae54b7009584e38da3c3d4488ed4cd25b439de486/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e", size = 4062689, upload-time = "2026-02-11T04:21:06.804Z" }, + { url = "https://files.pythonhosted.org/packages/33/c0/ce6d3b1fe190f0021203e0d9b5b99e57843e345f15f9ef22fcd43842fd21/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9", size = 4138535, upload-time = "2026-02-11T04:21:08.452Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c6/d5eb6a4fb32a3f9c21a8c7613ec706534ea1cf9f4b3663e99f0d83f6fca8/pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6", size = 3601364, upload-time = "2026-02-11T04:21:10.194Z" }, + { url = "https://files.pythonhosted.org/packages/14/a1/16c4b823838ba4c9c52c0e6bbda903a3fe5a1bdbf1b8eb4fff7156f3e318/pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60", size = 5262561, upload-time = "2026-02-11T04:21:11.742Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ad/ad9dc98ff24f485008aa5cdedaf1a219876f6f6c42a4626c08bc4e80b120/pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2", size = 4657460, upload-time = "2026-02-11T04:21:13.786Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/f1a4ea9a895b5732152789326202a82464d5254759fbacae4deea3069334/pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850", size = 6232698, upload-time = "2026-02-11T04:21:15.949Z" }, + { url = "https://files.pythonhosted.org/packages/95/f4/86f51b8745070daf21fd2e5b1fe0eb35d4db9ca26e6d58366562fb56a743/pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289", size = 8041706, upload-time = "2026-02-11T04:21:17.723Z" }, + { url = "https://files.pythonhosted.org/packages/29/9b/d6ecd956bb1266dd1045e995cce9b8d77759e740953a1c9aad9502a0461e/pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e", size = 6346621, upload-time = "2026-02-11T04:21:19.547Z" }, + { url = "https://files.pythonhosted.org/packages/71/24/538bff45bde96535d7d998c6fed1a751c75ac7c53c37c90dc2601b243893/pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717", size = 7038069, upload-time = "2026-02-11T04:21:21.378Z" }, + { url = "https://files.pythonhosted.org/packages/94/0e/58cb1a6bc48f746bc4cb3adb8cabff73e2742c92b3bf7a220b7cf69b9177/pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a", size = 6460040, upload-time = "2026-02-11T04:21:23.148Z" }, + { url = "https://files.pythonhosted.org/packages/6c/57/9045cb3ff11eeb6c1adce3b2d60d7d299d7b273a2e6c8381a524abfdc474/pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029", size = 7164523, upload-time = "2026-02-11T04:21:25.01Z" }, + { url = "https://files.pythonhosted.org/packages/73/f2/9be9cb99f2175f0d4dbadd6616ce1bf068ee54a28277ea1bf1fbf729c250/pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b", size = 6332552, upload-time = "2026-02-11T04:21:27.238Z" }, + { url = "https://files.pythonhosted.org/packages/3f/eb/b0834ad8b583d7d9d42b80becff092082a1c3c156bb582590fcc973f1c7c/pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1", size = 7040108, upload-time = "2026-02-11T04:21:29.462Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/fc09634e2aabdd0feabaff4a32f4a7d97789223e7c2042fd805ea4b4d2c2/pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a", size = 2453712, upload-time = "2026-02-11T04:21:31.072Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/b9d62794fc8a0dd14c1943df68347badbd5511103e0d04c035ffe5cf2255/pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da", size = 5264880, upload-time = "2026-02-11T04:21:32.865Z" }, + { url = "https://files.pythonhosted.org/packages/26/9d/e03d857d1347fa5ed9247e123fcd2a97b6220e15e9cb73ca0a8d91702c6e/pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc", size = 4660616, upload-time = "2026-02-11T04:21:34.97Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ec/8a6d22afd02570d30954e043f09c32772bfe143ba9285e2fdb11284952cd/pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c", size = 6269008, upload-time = "2026-02-11T04:21:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/3d/1d/6d875422c9f28a4a361f495a5f68d9de4a66941dc2c619103ca335fa6446/pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8", size = 8073226, upload-time = "2026-02-11T04:21:38.585Z" }, + { url = "https://files.pythonhosted.org/packages/a1/cd/134b0b6ee5eda6dc09e25e24b40fdafe11a520bc725c1d0bbaa5e00bf95b/pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20", size = 6380136, upload-time = "2026-02-11T04:21:40.562Z" }, + { url = "https://files.pythonhosted.org/packages/7a/a9/7628f013f18f001c1b98d8fffe3452f306a70dc6aba7d931019e0492f45e/pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13", size = 7067129, upload-time = "2026-02-11T04:21:42.521Z" }, + { url = "https://files.pythonhosted.org/packages/1e/f8/66ab30a2193b277785601e82ee2d49f68ea575d9637e5e234faaa98efa4c/pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf", size = 6491807, upload-time = "2026-02-11T04:21:44.22Z" }, + { url = "https://files.pythonhosted.org/packages/da/0b/a877a6627dc8318fdb84e357c5e1a758c0941ab1ddffdafd231983788579/pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524", size = 7190954, upload-time = "2026-02-11T04:21:46.114Z" }, + { url = "https://files.pythonhosted.org/packages/83/43/6f732ff85743cf746b1361b91665d9f5155e1483817f693f8d57ea93147f/pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986", size = 6336441, upload-time = "2026-02-11T04:21:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/3b/44/e865ef3986611bb75bfabdf94a590016ea327833f434558801122979cd0e/pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c", size = 7045383, upload-time = "2026-02-11T04:21:50.015Z" }, + { url = "https://files.pythonhosted.org/packages/a8/c6/f4fb24268d0c6908b9f04143697ea18b0379490cb74ba9e8d41b898bd005/pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3", size = 2456104, upload-time = "2026-02-11T04:21:51.633Z" }, + { url = "https://files.pythonhosted.org/packages/56/11/5d43209aa4cb58e0cc80127956ff1796a68b928e6324bbf06ef4db34367b/pillow-12.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:600fd103672b925fe62ed08e0d874ea34d692474df6f4bf7ebe148b30f89f39f", size = 5228606, upload-time = "2026-02-11T04:22:52.106Z" }, + { url = "https://files.pythonhosted.org/packages/5f/d5/3b005b4e4fda6698b371fa6c21b097d4707585d7db99e98d9b0b87ac612a/pillow-12.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:665e1b916b043cef294bc54d47bf02d87e13f769bc4bc5fa225a24b3a6c5aca9", size = 4622321, upload-time = "2026-02-11T04:22:53.827Z" }, + { url = "https://files.pythonhosted.org/packages/df/36/ed3ea2d594356fd8037e5a01f6156c74bc8d92dbb0fa60746cc96cabb6e8/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:495c302af3aad1ca67420ddd5c7bd480c8867ad173528767d906428057a11f0e", size = 5247579, upload-time = "2026-02-11T04:22:56.094Z" }, + { url = "https://files.pythonhosted.org/packages/54/9a/9cc3e029683cf6d20ae5085da0dafc63148e3252c2f13328e553aaa13cfb/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8fd420ef0c52c88b5a035a0886f367748c72147b2b8f384c9d12656678dfdfa9", size = 6989094, upload-time = "2026-02-11T04:22:58.288Z" }, + { url = "https://files.pythonhosted.org/packages/00/98/fc53ab36da80b88df0967896b6c4b4cd948a0dc5aa40a754266aa3ae48b3/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f975aa7ef9684ce7e2c18a3aa8f8e2106ce1e46b94ab713d156b2898811651d3", size = 5313850, upload-time = "2026-02-11T04:23:00.554Z" }, + { url = "https://files.pythonhosted.org/packages/30/02/00fa585abfd9fe9d73e5f6e554dc36cc2b842898cbfc46d70353dae227f8/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8089c852a56c2966cf18835db62d9b34fef7ba74c726ad943928d494fa7f4735", size = 5963343, upload-time = "2026-02-11T04:23:02.934Z" }, + { url = "https://files.pythonhosted.org/packages/f2/26/c56ce33ca856e358d27fda9676c055395abddb82c35ac0f593877ed4562e/pillow-12.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cb9bb857b2d057c6dfc72ac5f3b44836924ba15721882ef103cecb40d002d80e", size = 7029880, upload-time = "2026-02-11T04:23:04.783Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.9.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/56/8d4c30c8a1d07013911a8fdbd8f89440ef9f08d07a1b50ab8ca8be5a20f9/platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", size = 28737, upload-time = "2026-03-05T18:34:13.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" }, +] + +[[package]] +name = "plotly" +version = "6.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "narwhals" }, + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/24/fb/41efe84970cfddefd4ccf025e2cbfafe780004555f583e93dba3dac2cdef/plotly-6.6.0.tar.gz", hash = "sha256:b897f15f3b02028d69f755f236be890ba950d0a42d7dfc619b44e2d8cea8748c", size = 7027956, upload-time = "2026-03-02T21:10:25.321Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/d2/c6e44dba74f17c6216ce1b56044a9b93a929f1c2d5bdaff892512b260f5e/plotly-6.6.0-py3-none-any.whl", hash = "sha256:8d6daf0f87412e0c0bfe72e809d615217ab57cc715899a1e5145135a7800d1d0", size = 9910315, upload-time = "2026-03-02T21:10:18.131Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pre-commit" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232, upload-time = "2025-12-16T21:14:33.552Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" }, +] + +[[package]] +name = "prometheus-client" +version = "0.24.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/58/a794d23feb6b00fc0c72787d7e87d872a6730dd9ed7c7b3e954637d8f280/prometheus_client-0.24.1.tar.gz", hash = "sha256:7e0ced7fbbd40f7b84962d5d2ab6f17ef88a72504dcf7c0b40737b43b2a461f9", size = 85616, upload-time = "2026-01-14T15:26:26.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/c3/24a2f845e3917201628ecaba4f18bab4d18a337834c1df2a159ee9d22a42/prometheus_client-0.24.1-py3-none-any.whl", hash = "sha256:150db128af71a5c2482b36e588fc8a6b95e498750da4b17065947c16070f4055", size = 64057, upload-time = "2026-01-14T15:26:24.42Z" }, +] + +[[package]] +name = "prompt-toolkit" +version = "3.0.51" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/6e/9d084c929dfe9e3bfe0c6a47e31f78a25c54627d64a66e884a8bf5474f1c/prompt_toolkit-3.0.51.tar.gz", hash = "sha256:931a162e3b27fc90c86f1b48bb1fb2c528c2761475e57c9c06de13311c7b54ed", size = 428940, upload-time = "2025-04-15T09:18:47.731Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/4f/5249960887b1fbe561d9ff265496d170b55a735b76724f10ef19f9e40716/prompt_toolkit-3.0.51-py3-none-any.whl", hash = "sha256:52742911fde84e2d423e2f9a4cf1de7d7ac4e51958f648d9540e0fb8db077b07", size = 387810, upload-time = "2025-04-15T09:18:44.753Z" }, +] + +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" }, + { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" }, + { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" }, + { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + +[[package]] +name = "psycopg" +version = "3.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d3/b6/379d0a960f8f435ec78720462fd94c4863e7a31237cf81bf76d0af5883bf/psycopg-3.3.3.tar.gz", hash = "sha256:5e9a47458b3c1583326513b2556a2a9473a1001a56c9efe9e587245b43148dd9", size = 165624, upload-time = "2026-02-18T16:52:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/5b/181e2e3becb7672b502f0ed7f16ed7352aca7c109cfb94cf3878a9186db9/psycopg-3.3.3-py3-none-any.whl", hash = "sha256:f96525a72bcfade6584ab17e89de415ff360748c766f0106959144dcbb38c698", size = 212768, upload-time = "2026-02-18T16:46:27.365Z" }, +] + +[package.optional-dependencies] +binary = [ + { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, +] + +[[package]] +name = "psycopg-binary" +version = "3.3.3" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/d8/a763308a41e2ecfb6256ba0877d340c2f2b124c8b2746401863d96fa2c7a/psycopg_binary-3.3.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b3385b58b2fe408a13d084c14b8dcf468cd36cbbe774408250facc128f9fa75c", size = 4609758, upload-time = "2026-02-18T16:46:33.132Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a9/f8a683e85400c1208685e7c895abc049dc13aa0b6ea989e6adf0a3681fe0/psycopg_binary-3.3.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1bef235a50a80f6aba05147002bc354559657cb6386dbd04d8e1c97d1d7cbe84", size = 4676740, upload-time = "2026-02-18T16:46:42.904Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7d/03512c4aaac8a58fc3b1221f38293aa517a1950d10ef8646c72c49addc7d/psycopg_binary-3.3.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:97c839717bf8c8df3f6d983a20949c4fb22e2a34ee172e3e427ede363feda27b", size = 5496335, upload-time = "2026-02-18T16:46:51.517Z" }, + { url = "https://files.pythonhosted.org/packages/8a/bc/23319b4b1c2c0b810d225e1b6f16efbb16150074fc0ea96bfcabdf59ee09/psycopg_binary-3.3.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:48e500cf1c0984dacf1f28ea482c3cdbb4c2288d51c336c04bc64198ab21fc51", size = 5172032, upload-time = "2026-02-18T16:47:00.878Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c8/6d61dc0a56654c558a37b2d9b2094e470aa12621305cc7935fd769122e32/psycopg_binary-3.3.3-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb36a08859b9432d94ea6b26ec41a2f98f83f14868c91321d0c1e11f672eeae7", size = 6763107, upload-time = "2026-02-18T16:47:11.784Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b5/e2a3c90aa1059f5b5f593379caad7be3cc3c2ce1ddfc7730e39854e174fe/psycopg_binary-3.3.3-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0dde92cfde09293fb63b3f547919ba7d73bd2654573c03502b3263dd0218e44e", size = 5006494, upload-time = "2026-02-18T16:47:17.062Z" }, + { url = "https://files.pythonhosted.org/packages/5d/3e/bf126e0a1f864e191b7f3eeea667ee2ce13d582b036255fb8b12946d1f7a/psycopg_binary-3.3.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:78c9ce98caaf82ac8484d269791c1b403d7598633e0e4e2fa1097baae244e2f1", size = 4533850, upload-time = "2026-02-18T16:47:21.673Z" }, + { url = "https://files.pythonhosted.org/packages/f4/d8/bb5e8d395deb945629aa0c65d12ab90ec3bfcbdf56be89e2a84d001864c9/psycopg_binary-3.3.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d593612758d0041cb13cb0003f7f8d3fabb7ad9319e651e78afae49b1cf5860e", size = 4223316, upload-time = "2026-02-18T16:47:25.82Z" }, + { url = "https://files.pythonhosted.org/packages/c2/70/33eef61b0f0fd41ebf93b9699f44067313a45016827f67b3c8cc41f0a7ab/psycopg_binary-3.3.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:f24e8e17035200a465c178e9ea945527ad0738118694184c450f1192a452ff25", size = 3954515, upload-time = "2026-02-18T16:47:30.434Z" }, + { url = "https://files.pythonhosted.org/packages/ea/db/27c2b3b9698e713e83e11e8540daa27516f9e90390ec21a41091cb15fcaf/psycopg_binary-3.3.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e7b607f0e14f2a4cf7e78a05ebd13df6144acfba87cb90842e70d3f125d9f53f", size = 4260274, upload-time = "2026-02-18T16:47:36.128Z" }, + { url = "https://files.pythonhosted.org/packages/a1/3b/71e5d603059bf5474215f573a3e2d357a4e95672b26e04d41674400d4862/psycopg_binary-3.3.3-cp310-cp310-win_amd64.whl", hash = "sha256:b27d3a23c79fa59557d2cc63a7e8bb4c7e022c018558eda36f9d7c4e6b99a6e0", size = 3557375, upload-time = "2026-02-18T16:47:42.799Z" }, + { url = "https://files.pythonhosted.org/packages/be/c0/b389119dd754483d316805260f3e73cdcad97925839107cc7a296f6132b1/psycopg_binary-3.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a89bb9ee11177b2995d87186b1d9fa892d8ea725e85eab28c6525e4cc14ee048", size = 4609740, upload-time = "2026-02-18T16:47:51.093Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9976eef20f61840285174d360da4c820a311ab39d6b82fa09fbb545be825/psycopg_binary-3.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9f7d0cf072c6fbac3795b08c98ef9ea013f11db609659dcfc6b1f6cc31f9e181", size = 4676837, upload-time = "2026-02-18T16:47:55.523Z" }, + { url = "https://files.pythonhosted.org/packages/9f/f2/d28ba2f7404fd7f68d41e8a11df86313bd646258244cb12a8dd83b868a97/psycopg_binary-3.3.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:90eecd93073922f085967f3ed3a98ba8c325cbbc8c1a204e300282abd2369e13", size = 5497070, upload-time = "2026-02-18T16:47:59.929Z" }, + { url = "https://files.pythonhosted.org/packages/de/2f/6c5c54b815edeb30a281cfcea96dc93b3bb6be939aea022f00cab7aa1420/psycopg_binary-3.3.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dac7ee2f88b4d7bb12837989ca354c38d400eeb21bce3b73dac02622f0a3c8d6", size = 5172410, upload-time = "2026-02-18T16:48:05.665Z" }, + { url = "https://files.pythonhosted.org/packages/51/75/8206c7008b57de03c1ada46bd3110cc3743f3fd9ed52031c4601401d766d/psycopg_binary-3.3.3-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b62cf8784eb6d35beaee1056d54caf94ec6ecf2b7552395e305518ab61eb8fd2", size = 6763408, upload-time = "2026-02-18T16:48:13.541Z" }, + { url = "https://files.pythonhosted.org/packages/d4/5a/ea1641a1e6c8c8b3454b0fcb43c3045133a8b703e6e824fae134088e63bd/psycopg_binary-3.3.3-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a39f34c9b18e8f6794cca17bfbcd64572ca2482318db644268049f8c738f35a6", size = 5006255, upload-time = "2026-02-18T16:48:22.176Z" }, + { url = "https://files.pythonhosted.org/packages/aa/fb/538df099bf55ae1637d52d7ccb6b9620b535a40f4c733897ac2b7bb9e14c/psycopg_binary-3.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:883d68d48ca9ff3cb3d10c5fdebea02c79b48eecacdddbf7cce6e7cdbdc216b8", size = 4532694, upload-time = "2026-02-18T16:48:27.338Z" }, + { url = "https://files.pythonhosted.org/packages/a1/d1/00780c0e187ea3c13dfc53bd7060654b2232cd30df562aac91a5f1c545ac/psycopg_binary-3.3.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:cab7bc3d288d37a80aa8c0820033250c95e40b1c2b5c57cf59827b19c2a8b69d", size = 4222833, upload-time = "2026-02-18T16:48:31.221Z" }, + { url = "https://files.pythonhosted.org/packages/7a/34/a07f1ff713c51d64dc9f19f2c32be80299a2055d5d109d5853662b922cb4/psycopg_binary-3.3.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:56c767007ca959ca32f796b42379fc7e1ae2ed085d29f20b05b3fc394f3715cc", size = 3952818, upload-time = "2026-02-18T16:48:35.869Z" }, + { url = "https://files.pythonhosted.org/packages/d3/67/d33f268a7759b4445f3c9b5a181039b01af8c8263c865c1be7a6444d4749/psycopg_binary-3.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:da2f331a01af232259a21573a01338530c6016dcfad74626c01330535bcd8628", size = 4258061, upload-time = "2026-02-18T16:48:41.365Z" }, + { url = "https://files.pythonhosted.org/packages/b4/3b/0d8d2c5e8e29ccc07d28c8af38445d9d9abcd238d590186cac82ee71fc84/psycopg_binary-3.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:19f93235ece6dbfc4036b5e4f6d8b13f0b8f2b3eeb8b0bd2936d406991bcdd40", size = 3558915, upload-time = "2026-02-18T16:48:46.679Z" }, + { url = "https://files.pythonhosted.org/packages/90/15/021be5c0cbc5b7c1ab46e91cc3434eb42569f79a0592e67b8d25e66d844d/psycopg_binary-3.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6698dbab5bcef8fdb570fc9d35fd9ac52041771bfcfe6fd0fc5f5c4e36f1e99d", size = 4591170, upload-time = "2026-02-18T16:48:55.594Z" }, + { url = "https://files.pythonhosted.org/packages/f1/54/a60211c346c9a2f8c6b272b5f2bbe21f6e11800ce7f61e99ba75cf8b63e1/psycopg_binary-3.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:329ff393441e75f10b673ae99ab45276887993d49e65f141da20d915c05aafd8", size = 4670009, upload-time = "2026-02-18T16:49:03.608Z" }, + { url = "https://files.pythonhosted.org/packages/c1/53/ac7c18671347c553362aadbf65f92786eef9540676ca24114cc02f5be405/psycopg_binary-3.3.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:eb072949b8ebf4082ae24289a2b0fd724da9adc8f22743409d6fd718ddb379df", size = 5469735, upload-time = "2026-02-18T16:49:10.128Z" }, + { url = "https://files.pythonhosted.org/packages/7f/c3/4f4e040902b82a344eff1c736cde2f2720f127fe939c7e7565706f96dd44/psycopg_binary-3.3.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:263a24f39f26e19ed7fc982d7859a36f17841b05bebad3eb47bb9cd2dd785351", size = 5152919, upload-time = "2026-02-18T16:49:16.335Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e7/d929679c6a5c212bcf738806c7c89f5b3d0919f2e1685a0e08d6ff877945/psycopg_binary-3.3.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5152d50798c2fa5bd9b68ec68eb68a1b71b95126c1d70adaa1a08cd5eefdc23d", size = 6738785, upload-time = "2026-02-18T16:49:22.687Z" }, + { url = "https://files.pythonhosted.org/packages/69/b0/09703aeb69a9443d232d7b5318d58742e8ca51ff79f90ffe6b88f1db45e7/psycopg_binary-3.3.3-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d6a1e56dd267848edb824dbeb08cf5bac649e02ee0b03ba883ba3f4f0bd54f2", size = 4979008, upload-time = "2026-02-18T16:49:27.313Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a6/e662558b793c6e13a7473b970fee327d635270e41eded3090ef14045a6a5/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73eaaf4bb04709f545606c1db2f65f4000e8a04cdbf3e00d165a23004692093e", size = 4508255, upload-time = "2026-02-18T16:49:31.575Z" }, + { url = "https://files.pythonhosted.org/packages/5f/7f/0f8b2e1d5e0093921b6f324a948a5c740c1447fbb45e97acaf50241d0f39/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:162e5675efb4704192411eaf8e00d07f7960b679cd3306e7efb120bb8d9456cc", size = 4189166, upload-time = "2026-02-18T16:49:35.801Z" }, + { url = "https://files.pythonhosted.org/packages/92/ec/ce2e91c33bc8d10b00c87e2f6b0fb570641a6a60042d6a9ae35658a3a797/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:fab6b5e37715885c69f5d091f6ff229be71e235f272ebaa35158d5a46fd548a0", size = 3924544, upload-time = "2026-02-18T16:49:41.129Z" }, + { url = "https://files.pythonhosted.org/packages/c5/2f/7718141485f73a924205af60041c392938852aa447a94c8cbd222ff389a1/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a4aab31bd6d1057f287c96c0effca3a25584eb9cc702f282ecb96ded7814e830", size = 4235297, upload-time = "2026-02-18T16:49:46.726Z" }, + { url = "https://files.pythonhosted.org/packages/57/f9/1add717e2643a003bbde31b1b220172e64fbc0cb09f06429820c9173f7fc/psycopg_binary-3.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:59aa31fe11a0e1d1bcc2ce37ed35fe2ac84cd65bb9036d049b1a1c39064d0f14", size = 3547659, upload-time = "2026-02-18T16:49:52.999Z" }, + { url = "https://files.pythonhosted.org/packages/03/0a/cac9fdf1df16a269ba0e5f0f06cac61f826c94cadb39df028cdfe19d3a33/psycopg_binary-3.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:05f32239aec25c5fb15f7948cffdc2dc0dac098e48b80a140e4ba32b572a2e7d", size = 4590414, upload-time = "2026-02-18T16:50:01.441Z" }, + { url = "https://files.pythonhosted.org/packages/9c/c0/d8f8508fbf440edbc0099b1abff33003cd80c9e66eb3a1e78834e3fb4fb9/psycopg_binary-3.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c84f9d214f2d1de2fafebc17fa68ac3f6561a59e291553dfc45ad299f4898c1", size = 4669021, upload-time = "2026-02-18T16:50:08.803Z" }, + { url = "https://files.pythonhosted.org/packages/04/05/097016b77e343b4568feddf12c72171fc513acef9a4214d21b9478569068/psycopg_binary-3.3.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e77957d2ba17cada11be09a5066d93026cdb61ada7c8893101d7fe1c6e1f3925", size = 5467453, upload-time = "2026-02-18T16:50:14.985Z" }, + { url = "https://files.pythonhosted.org/packages/91/23/73244e5feb55b5ca109cede6e97f32ef45189f0fdac4c80d75c99862729d/psycopg_binary-3.3.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:42961609ac07c232a427da7c87a468d3c82fee6762c220f38e37cfdacb2b178d", size = 5151135, upload-time = "2026-02-18T16:50:24.82Z" }, + { url = "https://files.pythonhosted.org/packages/11/49/5309473b9803b207682095201d8708bbc7842ddf3f192488a69204e36455/psycopg_binary-3.3.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae07a3114313dd91fce686cab2f4c44af094398519af0e0f854bc707e1aeedf1", size = 6737315, upload-time = "2026-02-18T16:50:35.106Z" }, + { url = "https://files.pythonhosted.org/packages/d4/5d/03abe74ef34d460b33c4d9662bf6ec1dd38888324323c1a1752133c10377/psycopg_binary-3.3.3-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d257c58d7b36a621dcce1d01476ad8b60f12d80eb1406aee4cf796f88b2ae482", size = 4979783, upload-time = "2026-02-18T16:50:42.067Z" }, + { url = "https://files.pythonhosted.org/packages/f0/6c/3fbf8e604e15f2f3752900434046c00c90bb8764305a1b81112bff30ba24/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:07c7211f9327d522c9c47560cae00a4ecf6687f4e02d779d035dd3177b41cb12", size = 4509023, upload-time = "2026-02-18T16:50:50.116Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6b/1a06b43b7c7af756c80b67eac8bfaa51d77e68635a8a8d246e4f0bb7604a/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:8e7e9eca9b363dbedeceeadd8be97149d2499081f3c52d141d7cd1f395a91f83", size = 4185874, upload-time = "2026-02-18T16:50:55.97Z" }, + { url = "https://files.pythonhosted.org/packages/2b/d3/bf49e3dcaadba510170c8d111e5e69e5ae3f981c1554c5bb71c75ce354bb/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:cb85b1d5702877c16f28d7b92ba030c1f49ebcc9b87d03d8c10bf45a2f1c7508", size = 3925668, upload-time = "2026-02-18T16:51:03.299Z" }, + { url = "https://files.pythonhosted.org/packages/f8/92/0aac830ed6a944fe334404e1687a074e4215630725753f0e3e9a9a595b62/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4d4606c84d04b80f9138d72f1e28c6c02dc5ae0c7b8f3f8aaf89c681ce1cd1b1", size = 4234973, upload-time = "2026-02-18T16:51:09.097Z" }, + { url = "https://files.pythonhosted.org/packages/2e/96/102244653ee5a143ece5afe33f00f52fe64e389dfce8dbc87580c6d70d3d/psycopg_binary-3.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:74eae563166ebf74e8d950ff359be037b85723d99ca83f57d9b244a871d6c13b", size = 3551342, upload-time = "2026-02-18T16:51:13.892Z" }, +] + +[[package]] +name = "ptyprocess" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762, upload-time = "2020-12-28T15:15:30.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" }, +] + +[[package]] +name = "pure-eval" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752, upload-time = "2024-07-21T12:58:21.801Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" }, +] + +[[package]] +name = "py3dmol" +version = "2.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/39/7a/d7be826cf33a917f4ff78fd07237368dfdf21e09ab463b9de9a33c61ec0c/py3dmol-2.5.4.tar.gz", hash = "sha256:6142e1605f51e0fd8ec000db5ab045a90a08bc6147176735631a20be301dd19d", size = 7851, upload-time = "2026-01-22T13:33:49.8Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/7d/cea3531f77df694ac7f169378250d85f19f69b09a5f4fa45f650837ae7cc/py3dmol-2.5.4-py2.py3-none-any.whl", hash = "sha256:32806726b5310524a2b5bfee320737f7feef635cafc945c991062806daa9e43a", size = 7154, upload-time = "2026-01-22T13:33:48.659Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298, upload-time = "2025-11-04T13:39:04.116Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475, upload-time = "2025-11-04T13:39:06.055Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815, upload-time = "2025-11-04T13:39:10.41Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567, upload-time = "2025-11-04T13:39:12.244Z" }, + { url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442, upload-time = "2025-11-04T13:39:13.962Z" }, + { url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956, upload-time = "2025-11-04T13:39:15.889Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253, upload-time = "2025-11-04T13:39:17.403Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050, upload-time = "2025-11-04T13:39:19.351Z" }, + { url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178, upload-time = "2025-11-04T13:39:21Z" }, + { url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833, upload-time = "2025-11-04T13:39:22.606Z" }, + { url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156, upload-time = "2025-11-04T13:39:25.843Z" }, + { url = "https://files.pythonhosted.org/packages/7e/70/cad3acd89fde2010807354d978725ae111ddf6d0ea46d1ea1775b5c1bd0c/pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", size = 1989378, upload-time = "2025-11-04T13:39:27.92Z" }, + { url = "https://files.pythonhosted.org/packages/76/92/d338652464c6c367e5608e4488201702cd1cbb0f33f7b6a85a60fe5f3720/pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", size = 2013622, upload-time = "2025-11-04T13:39:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351, upload-time = "2025-11-04T13:43:02.058Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363, upload-time = "2025-11-04T13:43:05.159Z" }, + { url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615, upload-time = "2025-11-04T13:43:08.116Z" }, + { url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369, upload-time = "2025-11-04T13:43:12.49Z" }, + { url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218, upload-time = "2025-11-04T13:43:15.431Z" }, + { url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951, upload-time = "2025-11-04T13:43:18.062Z" }, + { url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428, upload-time = "2025-11-04T13:43:20.679Z" }, + { url = "https://files.pythonhosted.org/packages/2f/02/8559b1f26ee0d502c74f9cca5c0d2fd97e967e083e006bbbb4e97f3a043a/pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", size = 2147009, upload-time = "2025-11-04T13:43:23.286Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + +[[package]] +name = "pyrosetta-installer" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/e5/f65f56754c5ce31fc63cc8d29cc6b148f1730488f80e3dade1d10fd2ae17/pyrosetta_installer-0.1.2.tar.gz", hash = "sha256:cc442000a9470f9fbacad286b5e4620739321f39e3e7bb5f66a76a5e97c3359c", size = 3597, upload-time = "2024-10-22T20:12:53.752Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/5e/186bbc2fc9b66acbee19d21f30df611dca3bac7e76eb5da51aa664ea1c43/pyrosetta_installer-0.1.2-py3-none-any.whl", hash = "sha256:5fc2addb4083f1fc29b3e2b816df93610367a00b70d94030026447d6786c0305", size = 3906, upload-time = "2024-10-22T20:12:52.317Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-timeout" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973, upload-time = "2025-05-05T19:44:34.99Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382, upload-time = "2025-05-05T19:44:33.502Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-discovery" +version = "1.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d7/7e/9f3b0dd3a074a6c3e1e79f35e465b1f2ee4b262d619de00cfce523cc9b24/python_discovery-1.1.3.tar.gz", hash = "sha256:7acca36e818cd88e9b2ba03e045ad7e93e1713e29c6bbfba5d90202310b7baa5", size = 56945, upload-time = "2026-03-10T15:08:15.038Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/80/73211fc5bfbfc562369b4aa61dc1e4bf07dc7b34df7b317e4539316b809c/python_discovery-1.1.3-py3-none-any.whl", hash = "sha256:90e795f0121bc84572e737c9aa9966311b9fde44ffb88a5953b3ec9b31c6945e", size = 31485, upload-time = "2026-03-10T15:08:13.06Z" }, +] + +[[package]] +name = "python-json-logger" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/29/bf/eca6a3d43db1dae7070f70e160ab20b807627ba953663ba07928cdd3dc58/python_json_logger-4.0.0.tar.gz", hash = "sha256:f58e68eb46e1faed27e0f574a55a0455eecd7b8a5b88b85a784519ba3cff047f", size = 17683, upload-time = "2025-10-06T04:15:18.984Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/e5/fecf13f06e5e5f67e8837d777d1bc43fac0ed2b77a676804df5c34744727/python_json_logger-4.0.0-py3-none-any.whl", hash = "sha256:af09c9daf6a813aa4cc7180395f50f2a9e5fa056034c9953aec92e381c5ba1e2", size = 15548, upload-time = "2025-10-06T04:15:17.553Z" }, +] + +[[package]] +name = "python-louvain" +version = "0.16" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "networkx", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7c/0d/8787b021d52eb8764c0bb18ab95f720cf554902044c6a5cb1865daf45763/python-louvain-0.16.tar.gz", hash = "sha256:b7ba2df5002fd28d3ee789a49532baad11fe648e4f2117cf0798e7520a1da56b", size = 204641, upload-time = "2022-01-29T15:53:03.532Z" } + +[[package]] +name = "pytz" +version = "2026.1.post1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/56/db/b8721d71d945e6a8ac63c0fc900b2067181dbb50805958d4d4661cf7d277/pytz-2026.1.post1.tar.gz", hash = "sha256:3378dde6a0c3d26719182142c56e60c7f9af7e968076f31aae569d72a0358ee1", size = 321088, upload-time = "2026-03-03T07:47:50.683Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/99/781fe0c827be2742bcc775efefccb3b048a3a9c6ce9aec0cbf4a101677e5/pytz-2026.1.post1-py2.py3-none-any.whl", hash = "sha256:f2fd16142fda348286a75e1a524be810bb05d444e5a081f37f7affc635035f7a", size = 510489, upload-time = "2026-03-03T07:47:49.167Z" }, +] + +[[package]] +name = "pywinpty" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/54/37c7370ba91f579235049dc26cd2c5e657d2a943e01820844ffc81f32176/pywinpty-3.0.3.tar.gz", hash = "sha256:523441dc34d231fb361b4b00f8c99d3f16de02f5005fd544a0183112bcc22412", size = 31309, upload-time = "2026-02-04T21:51:09.524Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/28/a652709bd76ca7533cd1c443b03add9f5051fdf71bc6bdb8801dddd4e7a3/pywinpty-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:ff05f12d775b142b11c6fe085129bdd759b61cf7d41da6c745e78e3a1ef5bf40", size = 2114320, upload-time = "2026-02-04T21:53:50.972Z" }, + { url = "https://files.pythonhosted.org/packages/b2/13/a0181cc5c2d5635d3dbc3802b97bc8e3ad4fa7502ccef576651a5e08e54c/pywinpty-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:340ccacb4d74278a631923794ccd758471cfc8eeeeee4610b280420a17ad1e82", size = 235670, upload-time = "2026-02-04T21:50:20.324Z" }, + { url = "https://files.pythonhosted.org/packages/79/c3/3e75075c7f71735f22b66fab0481f2c98e3a4d58cba55cb50ba29114bcf6/pywinpty-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:dff25a9a6435f527d7c65608a7e62783fc12076e7d44487a4911ee91be5a8ac8", size = 2114430, upload-time = "2026-02-04T21:54:19.485Z" }, + { url = "https://files.pythonhosted.org/packages/8d/1e/8a54166a8c5e4f5cb516514bdf4090be4d51a71e8d9f6d98c0aa00fe45d4/pywinpty-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:fbc1e230e5b193eef4431cba3f39996a288f9958f9c9f092c8a961d930ee8f68", size = 236191, upload-time = "2026-02-04T21:50:36.239Z" }, + { url = "https://files.pythonhosted.org/packages/7c/d4/aeb5e1784d2c5bff6e189138a9ca91a090117459cea0c30378e1f2db3d54/pywinpty-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:c9081df0e49ffa86d15db4a6ba61530630e48707f987df42c9d3313537e81fc0", size = 2113098, upload-time = "2026-02-04T21:54:37.711Z" }, + { url = "https://files.pythonhosted.org/packages/b9/53/7278223c493ccfe4883239cf06c823c56460a8010e0fc778eef67858dc14/pywinpty-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:15e79d870e18b678fb8a5a6105fd38496b55697c66e6fc0378236026bc4d59e9", size = 234901, upload-time = "2026-02-04T21:53:31.35Z" }, + { url = "https://files.pythonhosted.org/packages/e5/cb/58d6ed3fd429c96a90ef01ac9a617af10a6d41469219c25e7dc162abbb71/pywinpty-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9c91dbb026050c77bdcef964e63a4f10f01a639113c4d3658332614544c467ab", size = 2112686, upload-time = "2026-02-04T21:52:03.035Z" }, + { url = "https://files.pythonhosted.org/packages/fd/50/724ed5c38c504d4e58a88a072776a1e880d970789deaeb2b9f7bd9a5141a/pywinpty-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:fe1f7911805127c94cf51f89ab14096c6f91ffdcacf993d2da6082b2142a2523", size = 234591, upload-time = "2026-02-04T21:52:29.821Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ad/90a110538696b12b39fd8758a06d70ded899308198ad2305ac68e361126e/pywinpty-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:3f07a6cf1c1d470d284e614733c3d0f726d2c85e78508ea10a403140c3c0c18a", size = 2112360, upload-time = "2026-02-04T21:55:33.397Z" }, + { url = "https://files.pythonhosted.org/packages/44/0f/7ffa221757a220402bc79fda44044c3f2cc57338d878ab7d622add6f4581/pywinpty-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:15c7c0b6f8e9d87aabbaff76468dabf6e6121332c40fc1d83548d02a9d6a3759", size = 233107, upload-time = "2026-02-04T21:51:45.455Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, +] + +[[package]] +name = "pyzmq" +version = "27.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "implementation_name == 'pypy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/0b/3c9baedbdf613ecaa7aa07027780b8867f57b6293b6ee50de316c9f3222b/pyzmq-27.1.0.tar.gz", hash = "sha256:ac0765e3d44455adb6ddbf4417dcce460fc40a05978c08efdf2948072f6db540", size = 281750, upload-time = "2025-09-08T23:10:18.157Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/b9/52aa9ec2867528b54f1e60846728d8b4d84726630874fee3a91e66c7df81/pyzmq-27.1.0-cp310-cp310-macosx_10_15_universal2.whl", hash = "sha256:508e23ec9bc44c0005c4946ea013d9317ae00ac67778bd47519fdf5a0e930ff4", size = 1329850, upload-time = "2025-09-08T23:07:26.274Z" }, + { url = "https://files.pythonhosted.org/packages/99/64/5653e7b7425b169f994835a2b2abf9486264401fdef18df91ddae47ce2cc/pyzmq-27.1.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:507b6f430bdcf0ee48c0d30e734ea89ce5567fd7b8a0f0044a369c176aa44556", size = 906380, upload-time = "2025-09-08T23:07:29.78Z" }, + { url = "https://files.pythonhosted.org/packages/73/78/7d713284dbe022f6440e391bd1f3c48d9185673878034cfb3939cdf333b2/pyzmq-27.1.0-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf7b38f9fd7b81cb6d9391b2946382c8237fd814075c6aa9c3b746d53076023b", size = 666421, upload-time = "2025-09-08T23:07:31.263Z" }, + { url = "https://files.pythonhosted.org/packages/30/76/8f099f9d6482450428b17c4d6b241281af7ce6a9de8149ca8c1c649f6792/pyzmq-27.1.0-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03ff0b279b40d687691a6217c12242ee71f0fba28bf8626ff50e3ef0f4410e1e", size = 854149, upload-time = "2025-09-08T23:07:33.17Z" }, + { url = "https://files.pythonhosted.org/packages/59/f0/37fbfff06c68016019043897e4c969ceab18bde46cd2aca89821fcf4fb2e/pyzmq-27.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:677e744fee605753eac48198b15a2124016c009a11056f93807000ab11ce6526", size = 1655070, upload-time = "2025-09-08T23:07:35.205Z" }, + { url = "https://files.pythonhosted.org/packages/47/14/7254be73f7a8edc3587609554fcaa7bfd30649bf89cd260e4487ca70fdaa/pyzmq-27.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:dd2fec2b13137416a1c5648b7009499bcc8fea78154cd888855fa32514f3dad1", size = 2033441, upload-time = "2025-09-08T23:07:37.432Z" }, + { url = "https://files.pythonhosted.org/packages/22/dc/49f2be26c6f86f347e796a4d99b19167fc94503f0af3fd010ad262158822/pyzmq-27.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:08e90bb4b57603b84eab1d0ca05b3bbb10f60c1839dc471fc1c9e1507bef3386", size = 1891529, upload-time = "2025-09-08T23:07:39.047Z" }, + { url = "https://files.pythonhosted.org/packages/a3/3e/154fb963ae25be70c0064ce97776c937ecc7d8b0259f22858154a9999769/pyzmq-27.1.0-cp310-cp310-win32.whl", hash = "sha256:a5b42d7a0658b515319148875fcb782bbf118dd41c671b62dae33666c2213bda", size = 567276, upload-time = "2025-09-08T23:07:40.695Z" }, + { url = "https://files.pythonhosted.org/packages/62/b2/f4ab56c8c595abcb26b2be5fd9fa9e6899c1e5ad54964e93ae8bb35482be/pyzmq-27.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:c0bb87227430ee3aefcc0ade2088100e528d5d3298a0a715a64f3d04c60ba02f", size = 632208, upload-time = "2025-09-08T23:07:42.298Z" }, + { url = "https://files.pythonhosted.org/packages/3b/e3/be2cc7ab8332bdac0522fdb64c17b1b6241a795bee02e0196636ec5beb79/pyzmq-27.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:9a916f76c2ab8d045b19f2286851a38e9ac94ea91faf65bd64735924522a8b32", size = 559766, upload-time = "2025-09-08T23:07:43.869Z" }, + { url = "https://files.pythonhosted.org/packages/06/5d/305323ba86b284e6fcb0d842d6adaa2999035f70f8c38a9b6d21ad28c3d4/pyzmq-27.1.0-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:226b091818d461a3bef763805e75685e478ac17e9008f49fce2d3e52b3d58b86", size = 1333328, upload-time = "2025-09-08T23:07:45.946Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a0/fc7e78a23748ad5443ac3275943457e8452da67fda347e05260261108cbc/pyzmq-27.1.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0790a0161c281ca9723f804871b4027f2e8b5a528d357c8952d08cd1a9c15581", size = 908803, upload-time = "2025-09-08T23:07:47.551Z" }, + { url = "https://files.pythonhosted.org/packages/7e/22/37d15eb05f3bdfa4abea6f6d96eb3bb58585fbd3e4e0ded4e743bc650c97/pyzmq-27.1.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c895a6f35476b0c3a54e3eb6ccf41bf3018de937016e6e18748317f25d4e925f", size = 668836, upload-time = "2025-09-08T23:07:49.436Z" }, + { url = "https://files.pythonhosted.org/packages/b1/c4/2a6fe5111a01005fc7af3878259ce17684fabb8852815eda6225620f3c59/pyzmq-27.1.0-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bbf8d3630bf96550b3be8e1fc0fea5cbdc8d5466c1192887bd94869da17a63e", size = 857038, upload-time = "2025-09-08T23:07:51.234Z" }, + { url = "https://files.pythonhosted.org/packages/cb/eb/bfdcb41d0db9cd233d6fb22dc131583774135505ada800ebf14dfb0a7c40/pyzmq-27.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:15c8bd0fe0dabf808e2d7a681398c4e5ded70a551ab47482067a572c054c8e2e", size = 1657531, upload-time = "2025-09-08T23:07:52.795Z" }, + { url = "https://files.pythonhosted.org/packages/ab/21/e3180ca269ed4a0de5c34417dfe71a8ae80421198be83ee619a8a485b0c7/pyzmq-27.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:bafcb3dd171b4ae9f19ee6380dfc71ce0390fefaf26b504c0e5f628d7c8c54f2", size = 2034786, upload-time = "2025-09-08T23:07:55.047Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b1/5e21d0b517434b7f33588ff76c177c5a167858cc38ef740608898cd329f2/pyzmq-27.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e829529fcaa09937189178115c49c504e69289abd39967cd8a4c215761373394", size = 1894220, upload-time = "2025-09-08T23:07:57.172Z" }, + { url = "https://files.pythonhosted.org/packages/03/f2/44913a6ff6941905efc24a1acf3d3cb6146b636c546c7406c38c49c403d4/pyzmq-27.1.0-cp311-cp311-win32.whl", hash = "sha256:6df079c47d5902af6db298ec92151db82ecb557af663098b92f2508c398bb54f", size = 567155, upload-time = "2025-09-08T23:07:59.05Z" }, + { url = "https://files.pythonhosted.org/packages/23/6d/d8d92a0eb270a925c9b4dd039c0b4dc10abc2fcbc48331788824ef113935/pyzmq-27.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:190cbf120fbc0fc4957b56866830def56628934a9d112aec0e2507aa6a032b97", size = 633428, upload-time = "2025-09-08T23:08:00.663Z" }, + { url = "https://files.pythonhosted.org/packages/ae/14/01afebc96c5abbbd713ecfc7469cfb1bc801c819a74ed5c9fad9a48801cb/pyzmq-27.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:eca6b47df11a132d1745eb3b5b5e557a7dae2c303277aa0e69c6ba91b8736e07", size = 559497, upload-time = "2025-09-08T23:08:02.15Z" }, + { url = "https://files.pythonhosted.org/packages/92/e7/038aab64a946d535901103da16b953c8c9cc9c961dadcbf3609ed6428d23/pyzmq-27.1.0-cp312-abi3-macosx_10_15_universal2.whl", hash = "sha256:452631b640340c928fa343801b0d07eb0c3789a5ffa843f6e1a9cee0ba4eb4fc", size = 1306279, upload-time = "2025-09-08T23:08:03.807Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5e/c3c49fdd0f535ef45eefcc16934648e9e59dace4a37ee88fc53f6cd8e641/pyzmq-27.1.0-cp312-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1c179799b118e554b66da67d88ed66cd37a169f1f23b5d9f0a231b4e8d44a113", size = 895645, upload-time = "2025-09-08T23:08:05.301Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/b0b2504cb4e903a74dcf1ebae157f9e20ebb6ea76095f6cfffea28c42ecd/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3837439b7f99e60312f0c926a6ad437b067356dc2bc2ec96eb395fd0fe804233", size = 652574, upload-time = "2025-09-08T23:08:06.828Z" }, + { url = "https://files.pythonhosted.org/packages/f8/9b/c108cdb55560eaf253f0cbdb61b29971e9fb34d9c3499b0e96e4e60ed8a5/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43ad9a73e3da1fab5b0e7e13402f0b2fb934ae1c876c51d0afff0e7c052eca31", size = 840995, upload-time = "2025-09-08T23:08:08.396Z" }, + { url = "https://files.pythonhosted.org/packages/c2/bb/b79798ca177b9eb0825b4c9998c6af8cd2a7f15a6a1a4272c1d1a21d382f/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0de3028d69d4cdc475bfe47a6128eb38d8bc0e8f4d69646adfbcd840facbac28", size = 1642070, upload-time = "2025-09-08T23:08:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/9c/80/2df2e7977c4ede24c79ae39dcef3899bfc5f34d1ca7a5b24f182c9b7a9ca/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:cf44a7763aea9298c0aa7dbf859f87ed7012de8bda0f3977b6fb1d96745df856", size = 2021121, upload-time = "2025-09-08T23:08:11.907Z" }, + { url = "https://files.pythonhosted.org/packages/46/bd/2d45ad24f5f5ae7e8d01525eb76786fa7557136555cac7d929880519e33a/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f30f395a9e6fbca195400ce833c731e7b64c3919aa481af4d88c3759e0cb7496", size = 1878550, upload-time = "2025-09-08T23:08:13.513Z" }, + { url = "https://files.pythonhosted.org/packages/e6/2f/104c0a3c778d7c2ab8190e9db4f62f0b6957b53c9d87db77c284b69f33ea/pyzmq-27.1.0-cp312-abi3-win32.whl", hash = "sha256:250e5436a4ba13885494412b3da5d518cd0d3a278a1ae640e113c073a5f88edd", size = 559184, upload-time = "2025-09-08T23:08:15.163Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7f/a21b20d577e4100c6a41795842028235998a643b1ad406a6d4163ea8f53e/pyzmq-27.1.0-cp312-abi3-win_amd64.whl", hash = "sha256:9ce490cf1d2ca2ad84733aa1d69ce6855372cb5ce9223802450c9b2a7cba0ccf", size = 619480, upload-time = "2025-09-08T23:08:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/78/c2/c012beae5f76b72f007a9e91ee9401cb88c51d0f83c6257a03e785c81cc2/pyzmq-27.1.0-cp312-abi3-win_arm64.whl", hash = "sha256:75a2f36223f0d535a0c919e23615fc85a1e23b71f40c7eb43d7b1dedb4d8f15f", size = 552993, upload-time = "2025-09-08T23:08:18.926Z" }, + { url = "https://files.pythonhosted.org/packages/60/cb/84a13459c51da6cec1b7b1dc1a47e6db6da50b77ad7fd9c145842750a011/pyzmq-27.1.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:93ad4b0855a664229559e45c8d23797ceac03183c7b6f5b4428152a6b06684a5", size = 1122436, upload-time = "2025-09-08T23:08:20.801Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b6/94414759a69a26c3dd674570a81813c46a078767d931a6c70ad29fc585cb/pyzmq-27.1.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:fbb4f2400bfda24f12f009cba62ad5734148569ff4949b1b6ec3b519444342e6", size = 1156301, upload-time = "2025-09-08T23:08:22.47Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ad/15906493fd40c316377fd8a8f6b1f93104f97a752667763c9b9c1b71d42d/pyzmq-27.1.0-cp313-cp313t-macosx_10_15_universal2.whl", hash = "sha256:e343d067f7b151cfe4eb3bb796a7752c9d369eed007b91231e817071d2c2fec7", size = 1341197, upload-time = "2025-09-08T23:08:24.286Z" }, + { url = "https://files.pythonhosted.org/packages/14/1d/d343f3ce13db53a54cb8946594e567410b2125394dafcc0268d8dda027e0/pyzmq-27.1.0-cp313-cp313t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:08363b2011dec81c354d694bdecaef4770e0ae96b9afea70b3f47b973655cc05", size = 897275, upload-time = "2025-09-08T23:08:26.063Z" }, + { url = "https://files.pythonhosted.org/packages/69/2d/d83dd6d7ca929a2fc67d2c3005415cdf322af7751d773524809f9e585129/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d54530c8c8b5b8ddb3318f481297441af102517602b569146185fa10b63f4fa9", size = 660469, upload-time = "2025-09-08T23:08:27.623Z" }, + { url = "https://files.pythonhosted.org/packages/3e/cd/9822a7af117f4bc0f1952dbe9ef8358eb50a24928efd5edf54210b850259/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f3afa12c392f0a44a2414056d730eebc33ec0926aae92b5ad5cf26ebb6cc128", size = 847961, upload-time = "2025-09-08T23:08:29.672Z" }, + { url = "https://files.pythonhosted.org/packages/9a/12/f003e824a19ed73be15542f172fd0ec4ad0b60cf37436652c93b9df7c585/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c65047adafe573ff023b3187bb93faa583151627bc9c51fc4fb2c561ed689d39", size = 1650282, upload-time = "2025-09-08T23:08:31.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4a/e82d788ed58e9a23995cee70dbc20c9aded3d13a92d30d57ec2291f1e8a3/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:90e6e9441c946a8b0a667356f7078d96411391a3b8f80980315455574177ec97", size = 2024468, upload-time = "2025-09-08T23:08:33.543Z" }, + { url = "https://files.pythonhosted.org/packages/d9/94/2da0a60841f757481e402b34bf4c8bf57fa54a5466b965de791b1e6f747d/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:add071b2d25f84e8189aaf0882d39a285b42fa3853016ebab234a5e78c7a43db", size = 1885394, upload-time = "2025-09-08T23:08:35.51Z" }, + { url = "https://files.pythonhosted.org/packages/4f/6f/55c10e2e49ad52d080dc24e37adb215e5b0d64990b57598abc2e3f01725b/pyzmq-27.1.0-cp313-cp313t-win32.whl", hash = "sha256:7ccc0700cfdf7bd487bea8d850ec38f204478681ea02a582a8da8171b7f90a1c", size = 574964, upload-time = "2025-09-08T23:08:37.178Z" }, + { url = "https://files.pythonhosted.org/packages/87/4d/2534970ba63dd7c522d8ca80fb92777f362c0f321900667c615e2067cb29/pyzmq-27.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:8085a9fba668216b9b4323be338ee5437a235fe275b9d1610e422ccc279733e2", size = 641029, upload-time = "2025-09-08T23:08:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/f6/fa/f8aea7a28b0641f31d40dea42d7ef003fded31e184ef47db696bc74cd610/pyzmq-27.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6bb54ca21bcfe361e445256c15eedf083f153811c37be87e0514934d6913061e", size = 561541, upload-time = "2025-09-08T23:08:42.668Z" }, + { url = "https://files.pythonhosted.org/packages/f3/81/a65e71c1552f74dec9dff91d95bafb6e0d33338a8dfefbc88aa562a20c92/pyzmq-27.1.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c17e03cbc9312bee223864f1a2b13a99522e0dc9f7c5df0177cd45210ac286e6", size = 836266, upload-time = "2025-09-08T23:09:40.048Z" }, + { url = "https://files.pythonhosted.org/packages/58/ed/0202ca350f4f2b69faa95c6d931e3c05c3a397c184cacb84cb4f8f42f287/pyzmq-27.1.0-pp310-pypy310_pp73-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f328d01128373cb6763823b2b4e7f73bdf767834268c565151eacb3b7a392f90", size = 800206, upload-time = "2025-09-08T23:09:41.902Z" }, + { url = "https://files.pythonhosted.org/packages/47/42/1ff831fa87fe8f0a840ddb399054ca0009605d820e2b44ea43114f5459f4/pyzmq-27.1.0-pp310-pypy310_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c1790386614232e1b3a40a958454bdd42c6d1811837b15ddbb052a032a43f62", size = 567747, upload-time = "2025-09-08T23:09:43.741Z" }, + { url = "https://files.pythonhosted.org/packages/d1/db/5c4d6807434751e3f21231bee98109aa57b9b9b55e058e450d0aef59b70f/pyzmq-27.1.0-pp310-pypy310_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:448f9cb54eb0cee4732b46584f2710c8bc178b0e5371d9e4fc8125201e413a74", size = 747371, upload-time = "2025-09-08T23:09:45.575Z" }, + { url = "https://files.pythonhosted.org/packages/26/af/78ce193dbf03567eb8c0dc30e3df2b9e56f12a670bf7eb20f9fb532c7e8a/pyzmq-27.1.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:05b12f2d32112bf8c95ef2e74ec4f1d4beb01f8b5e703b38537f8849f92cb9ba", size = 544862, upload-time = "2025-09-08T23:09:47.448Z" }, + { url = "https://files.pythonhosted.org/packages/4c/c6/c4dcdecdbaa70969ee1fdced6d7b8f60cfabe64d25361f27ac4665a70620/pyzmq-27.1.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:18770c8d3563715387139060d37859c02ce40718d1faf299abddcdcc6a649066", size = 836265, upload-time = "2025-09-08T23:09:49.376Z" }, + { url = "https://files.pythonhosted.org/packages/3e/79/f38c92eeaeb03a2ccc2ba9866f0439593bb08c5e3b714ac1d553e5c96e25/pyzmq-27.1.0-pp311-pypy311_pp73-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:ac25465d42f92e990f8d8b0546b01c391ad431c3bf447683fdc40565941d0604", size = 800208, upload-time = "2025-09-08T23:09:51.073Z" }, + { url = "https://files.pythonhosted.org/packages/49/0e/3f0d0d335c6b3abb9b7b723776d0b21fa7f3a6c819a0db6097059aada160/pyzmq-27.1.0-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53b40f8ae006f2734ee7608d59ed661419f087521edbfc2149c3932e9c14808c", size = 567747, upload-time = "2025-09-08T23:09:52.698Z" }, + { url = "https://files.pythonhosted.org/packages/a1/cf/f2b3784d536250ffd4be70e049f3b60981235d70c6e8ce7e3ef21e1adb25/pyzmq-27.1.0-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f605d884e7c8be8fe1aa94e0a783bf3f591b84c24e4bc4f3e7564c82ac25e271", size = 747371, upload-time = "2025-09-08T23:09:54.563Z" }, + { url = "https://files.pythonhosted.org/packages/01/1b/5dbe84eefc86f48473947e2f41711aded97eecef1231f4558f1f02713c12/pyzmq-27.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c9f7f6e13dff2e44a6afeaf2cf54cee5929ad64afaf4d40b50f93c58fc687355", size = 544862, upload-time = "2025-09-08T23:09:56.509Z" }, +] + +[[package]] +name = "questionary" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "prompt-toolkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/45/eafb0bba0f9988f6a2520f9ca2df2c82ddfa8d67c95d6625452e97b204a5/questionary-2.1.1.tar.gz", hash = "sha256:3d7e980292bb0107abaa79c68dd3eee3c561b83a0f89ae482860b181c8bd412d", size = 25845, upload-time = "2025-08-28T19:00:20.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59", size = 36753, upload-time = "2025-08-28T19:00:19.56Z" }, +] + +[[package]] +name = "rdkit" +version = "2025.9.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "pillow" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/f0/4637604614dc11abe1a57c4491033e57e96a09400f6b60b64dccd055bc78/rdkit-2025.9.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:129ab527eb4a12d7cc82707a78b5558ba33074894ee86138ad382d486bf3cd72", size = 29515255, upload-time = "2026-02-16T08:48:18.509Z" }, + { url = "https://files.pythonhosted.org/packages/a7/82/fec87c89c816d66bd6dfa1765dc479f8383135ce9b55664eb52351f72004/rdkit-2025.9.5-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:6ec55e650967cc41f90207f2fa240458e8a689739be97045ebd1ac0cf4bf2985", size = 35242460, upload-time = "2026-02-16T08:48:23.124Z" }, + { url = "https://files.pythonhosted.org/packages/21/67/aeb84b2da4da12c7ba56cc29e3302259dee1630ff580bee6d04d68669f81/rdkit-2025.9.5-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:b1dd28003a601a65ef5a89013b4a48589edc6c4e94bd98a2752116c6bed6d138", size = 36729523, upload-time = "2026-02-16T08:48:26.893Z" }, + { url = "https://files.pythonhosted.org/packages/70/64/f6ab4d69f5797c30b50aa0959e0eb257aeedc73fdb29acad432b434bc95f/rdkit-2025.9.5-cp310-cp310-win_amd64.whl", hash = "sha256:c3281d5ba71781409f63f3e08d501a69fe977fcb19742cb2a3905646307aff11", size = 24281514, upload-time = "2026-02-16T08:48:30.177Z" }, + { url = "https://files.pythonhosted.org/packages/47/e5/aafa2eea493035edc6995b6b99ad5776ca735613344c3468386b72ad4e58/rdkit-2025.9.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37e33ac1437ae60f8e97901ad0246a69b5ea0c68ac74605cbea85c3913721840", size = 29515614, upload-time = "2026-02-16T08:48:34.565Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c5/79a67fd3fce6e8d087a120c74f49cd0a68ceb441819f09e948d6ab38f579/rdkit-2025.9.5-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:7cea7687aaf73ae6950e730203d7e0eab769e56aa24289aace64ba7ae5b457d1", size = 35237379, upload-time = "2026-02-16T08:48:38.18Z" }, + { url = "https://files.pythonhosted.org/packages/f3/2c/f949c5b8fbc760906bc6555beefbebd505894d50e38fbaa159e5c8301bbc/rdkit-2025.9.5-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:7318bc4fb8a1755999aeed43222cd4c95e64795555b84db15ae0cbb7686a254c", size = 36728364, upload-time = "2026-02-16T08:48:42.689Z" }, + { url = "https://files.pythonhosted.org/packages/73/cf/e2c0d68f2586f1a24e256d6132f73db4b30af3a548a3ffd6a24142414fb9/rdkit-2025.9.5-cp311-cp311-win_amd64.whl", hash = "sha256:14413636c51c46a238ca6d8b652b13d6824f10e4951b6d995cbc7f2e763170b9", size = 24281655, upload-time = "2026-02-16T08:48:46.029Z" }, + { url = "https://files.pythonhosted.org/packages/2b/6c/55aa888c54cf3aa8c8fb525642c51769e37fd2bcdd924cd35c3cb3678fc1/rdkit-2025.9.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:64f313a8e2993bd7de8c1d72dc268e25c27bcaab31d4217a58528fbcc0ca5195", size = 29560510, upload-time = "2026-02-16T08:48:49.538Z" }, + { url = "https://files.pythonhosted.org/packages/21/6f/d6420b5343c9b113b41235f3497e4d9fbff9ac5ee7df3be5c6f09f25723e/rdkit-2025.9.5-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:24ab792c9eae49bc0cffe619bcac4ccc91ce61eb09ea68be5a74f29d7671681d", size = 35118879, upload-time = "2026-02-16T08:48:53.36Z" }, + { url = "https://files.pythonhosted.org/packages/6e/86/5609ddde91431190739919e6c41821b4fcd2a3fcc05dcc4094a666b34796/rdkit-2025.9.5-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:07705d64bf33b832eff1ea8819c0ffe38e079b31ebb194a73e5ae47554910c8b", size = 36663662, upload-time = "2026-02-16T08:48:57.757Z" }, + { url = "https://files.pythonhosted.org/packages/03/21/1da115389d2eae82364ac5eb4c95c7c57841f2906239257b17af3b8d48f4/rdkit-2025.9.5-cp312-cp312-win_amd64.whl", hash = "sha256:f99b65d3b52d76532ceecb73befae0180e147120972d4054fcd3c66fa71f3a95", size = 24300316, upload-time = "2026-02-16T08:49:01.634Z" }, + { url = "https://files.pythonhosted.org/packages/e3/08/692d9f44c4dfa1068bfc0d9240dba5f96205b33054367c19c1e4d2448290/rdkit-2025.9.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e31e1924e4d0ce19e5474037875294937bc5835b2f95d2c2c3025d6cd4ec4dfa", size = 29559487, upload-time = "2026-02-16T08:49:05.175Z" }, + { url = "https://files.pythonhosted.org/packages/9a/f3/baffbc78adad2324662d9c72bc132af40506eec2b612f7c07756b970426b/rdkit-2025.9.5-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:158d04129bfdeb2513c49cea208721bc5d6efb6611ba578006f81d3a9807381b", size = 35117644, upload-time = "2026-02-16T08:49:09.343Z" }, + { url = "https://files.pythonhosted.org/packages/5e/c2/41b6344807a63fb8367be2f3a503d13d22d6d905c2254091e1c2004fd3bc/rdkit-2025.9.5-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6fd443a12fc14a8258629d08916d39080a470b839d5a91c2275cc48fe93712f2", size = 36662343, upload-time = "2026-02-16T08:49:13.216Z" }, + { url = "https://files.pythonhosted.org/packages/9a/1e/dad9db005fc843a1ac718e4d09ec2d744e5849b6b453b196a065515b3edd/rdkit-2025.9.5-cp313-cp313-win_amd64.whl", hash = "sha256:957050e2c5b9cf623dc2a82f10847f7f6cebd15ef2edac40cd10120117c9927c", size = 24299076, upload-time = "2026-02-16T08:49:16.746Z" }, +] + +[[package]] +name = "rdkit-to-params" +version = "1.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "rdkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/65/70/cc9d755059d428478d2580e346ba83318ba1cd74deff66018790d6108ea6/rdkit_to_params-1.3.4.tar.gz", hash = "sha256:5cc0f773e4ee612586efafc6a1e129a0851b8b688baebe8d33863eeca7e18950", size = 71065, upload-time = "2026-02-28T14:57:04.387Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/19/a95c16edd252f9ced111a713f5c3b00977626398de7fdb6fad7ad2f8e0d9/rdkit_to_params-1.3.4-py3-none-any.whl", hash = "sha256:ceb7b030b175ff05f44f57a7cb5d480ff747563b73c1e2cd1a8d46789c217c12", size = 59800, upload-time = "2026-02-28T14:57:03.026Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "retrying" +version = "1.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c8/5a/b17e1e257d3e6f2e7758930e1256832c9ddd576f8631781e6a072914befa/retrying-1.4.2.tar.gz", hash = "sha256:d102e75d53d8d30b88562d45361d6c6c934da06fab31bd81c0420acb97a8ba39", size = 11411, upload-time = "2025-08-03T03:35:25.189Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/f3/6cd296376653270ac1b423bb30bd70942d9916b6978c6f40472d6ac038e7/retrying-1.4.2-py3-none-any.whl", hash = "sha256:bbc004aeb542a74f3569aeddf42a2516efefcdaff90df0eb38fbfbf19f179f59", size = 10859, upload-time = "2025-08-03T03:35:23.829Z" }, +] + +[[package]] +name = "rfc3339-validator" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/ea/a9387748e2d111c3c2b275ba970b735e04e15cdb1eb30693b6b5708c4dbd/rfc3339_validator-0.1.4.tar.gz", hash = "sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b", size = 5513, upload-time = "2021-05-12T16:37:54.178Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/44/4e421b96b67b2daff264473f7465db72fbdf36a07e05494f50300cc7b0c6/rfc3339_validator-0.1.4-py2.py3-none-any.whl", hash = "sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa", size = 3490, upload-time = "2021-05-12T16:37:52.536Z" }, +] + +[[package]] +name = "rfc3986-validator" +version = "0.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/88/f270de456dd7d11dcc808abfa291ecdd3f45ff44e3b549ffa01b126464d0/rfc3986_validator-0.1.1.tar.gz", hash = "sha256:3d44bde7921b3b9ec3ae4e3adca370438eccebc676456449b145d533b240d055", size = 6760, upload-time = "2019-10-28T16:00:19.144Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/51/17023c0f8f1869d8806b979a2bffa3f861f26a3f1a66b094288323fba52f/rfc3986_validator-0.1.1-py2.py3-none-any.whl", hash = "sha256:2f235c432ef459970b4306369336b9d5dbdda31b510ca1e327636e01f528bfa9", size = 4242, upload-time = "2019-10-28T16:00:13.976Z" }, +] + +[[package]] +name = "rfc3987-syntax" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lark" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2c/06/37c1a5557acf449e8e406a830a05bf885ac47d33270aec454ef78675008d/rfc3987_syntax-1.1.0.tar.gz", hash = "sha256:717a62cbf33cffdd16dfa3a497d81ce48a660ea691b1ddd7be710c22f00b4a0d", size = 14239, upload-time = "2025-07-18T01:05:05.015Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/71/44ce230e1b7fadd372515a97e32a83011f906ddded8d03e3c6aafbdedbb7/rfc3987_syntax-1.1.0-py3-none-any.whl", hash = "sha256:6c3d97604e4c5ce9f714898e05401a0445a641cfa276432b0a648c80856f6a3f", size = 8046, upload-time = "2025-07-18T01:05:03.843Z" }, +] + +[[package]] +name = "rich" +version = "14.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, +] + +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/0c/0c411a0ec64ccb6d104dcabe0e713e05e153a9a2c3c2bd2b32ce412166fe/rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288", size = 370490, upload-time = "2025-11-30T20:21:33.256Z" }, + { url = "https://files.pythonhosted.org/packages/19/6a/4ba3d0fb7297ebae71171822554abe48d7cab29c28b8f9f2c04b79988c05/rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00", size = 359751, upload-time = "2025-11-30T20:21:34.591Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7c/e4933565ef7f7a0818985d87c15d9d273f1a649afa6a52ea35ad011195ea/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6", size = 389696, upload-time = "2025-11-30T20:21:36.122Z" }, + { url = "https://files.pythonhosted.org/packages/5e/01/6271a2511ad0815f00f7ed4390cf2567bec1d4b1da39e2c27a41e6e3b4de/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7", size = 403136, upload-time = "2025-11-30T20:21:37.728Z" }, + { url = "https://files.pythonhosted.org/packages/55/64/c857eb7cd7541e9b4eee9d49c196e833128a55b89a9850a9c9ac33ccf897/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324", size = 524699, upload-time = "2025-11-30T20:21:38.92Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ed/94816543404078af9ab26159c44f9e98e20fe47e2126d5d32c9d9948d10a/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df", size = 412022, upload-time = "2025-11-30T20:21:40.407Z" }, + { url = "https://files.pythonhosted.org/packages/61/b5/707f6cf0066a6412aacc11d17920ea2e19e5b2f04081c64526eb35b5c6e7/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3", size = 390522, upload-time = "2025-11-30T20:21:42.17Z" }, + { url = "https://files.pythonhosted.org/packages/13/4e/57a85fda37a229ff4226f8cbcf09f2a455d1ed20e802ce5b2b4a7f5ed053/rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221", size = 404579, upload-time = "2025-11-30T20:21:43.769Z" }, + { url = "https://files.pythonhosted.org/packages/f9/da/c9339293513ec680a721e0e16bf2bac3db6e5d7e922488de471308349bba/rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7", size = 421305, upload-time = "2025-11-30T20:21:44.994Z" }, + { url = "https://files.pythonhosted.org/packages/f9/be/522cb84751114f4ad9d822ff5a1aa3c98006341895d5f084779b99596e5c/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff", size = 572503, upload-time = "2025-11-30T20:21:46.91Z" }, + { url = "https://files.pythonhosted.org/packages/a2/9b/de879f7e7ceddc973ea6e4629e9b380213a6938a249e94b0cdbcc325bb66/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7", size = 598322, upload-time = "2025-11-30T20:21:48.709Z" }, + { url = "https://files.pythonhosted.org/packages/48/ac/f01fc22efec3f37d8a914fc1b2fb9bcafd56a299edbe96406f3053edea5a/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139", size = 560792, upload-time = "2025-11-30T20:21:50.024Z" }, + { url = "https://files.pythonhosted.org/packages/e2/da/4e2b19d0f131f35b6146425f846563d0ce036763e38913d917187307a671/rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464", size = 221901, upload-time = "2025-11-30T20:21:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/96/cb/156d7a5cf4f78a7cc571465d8aec7a3c447c94f6749c5123f08438bcf7bc/rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169", size = 235823, upload-time = "2025-11-30T20:21:52.505Z" }, + { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157, upload-time = "2025-11-30T20:21:53.789Z" }, + { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676, upload-time = "2025-11-30T20:21:55.475Z" }, + { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938, upload-time = "2025-11-30T20:21:57.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932, upload-time = "2025-11-30T20:21:58.47Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830, upload-time = "2025-11-30T20:21:59.699Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033, upload-time = "2025-11-30T20:22:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828, upload-time = "2025-11-30T20:22:02.723Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683, upload-time = "2025-11-30T20:22:04.367Z" }, + { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583, upload-time = "2025-11-30T20:22:05.814Z" }, + { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496, upload-time = "2025-11-30T20:22:07.713Z" }, + { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669, upload-time = "2025-11-30T20:22:09.312Z" }, + { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011, upload-time = "2025-11-30T20:22:11.309Z" }, + { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406, upload-time = "2025-11-30T20:22:13.101Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024, upload-time = "2025-11-30T20:22:14.853Z" }, + { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069, upload-time = "2025-11-30T20:22:16.577Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, + { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, + { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, + { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292, upload-time = "2025-11-30T20:24:16.537Z" }, + { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128, upload-time = "2025-11-30T20:24:18.086Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542, upload-time = "2025-11-30T20:24:20.092Z" }, + { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004, upload-time = "2025-11-30T20:24:22.231Z" }, + { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063, upload-time = "2025-11-30T20:24:24.302Z" }, + { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099, upload-time = "2025-11-30T20:24:25.916Z" }, + { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177, upload-time = "2025-11-30T20:24:27.834Z" }, + { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015, upload-time = "2025-11-30T20:24:29.457Z" }, + { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736, upload-time = "2025-11-30T20:24:31.22Z" }, + { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981, upload-time = "2025-11-30T20:24:32.934Z" }, + { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782, upload-time = "2025-11-30T20:24:35.169Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/77/9b/840e0039e65fcf12758adf684d2289024d6140cde9268cc59887dc55189c/ruff-0.15.5.tar.gz", hash = "sha256:7c3601d3b6d76dce18c5c824fc8d06f4eef33d6df0c21ec7799510cde0f159a2", size = 4574214, upload-time = "2026-03-05T20:06:34.946Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/20/5369c3ce21588c708bcbe517a8fbe1a8dfdb5dfd5137e14790b1da71612c/ruff-0.15.5-py3-none-linux_armv6l.whl", hash = "sha256:4ae44c42281f42e3b06b988e442d344a5b9b72450ff3c892e30d11b29a96a57c", size = 10478185, upload-time = "2026-03-05T20:06:29.093Z" }, + { url = "https://files.pythonhosted.org/packages/44/ed/e81dd668547da281e5dce710cf0bc60193f8d3d43833e8241d006720e42b/ruff-0.15.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6edd3792d408ebcf61adabc01822da687579a1a023f297618ac27a5b51ef0080", size = 10859201, upload-time = "2026-03-05T20:06:32.632Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8f/533075f00aaf19b07c5cd6aa6e5d89424b06b3b3f4583bfa9c640a079059/ruff-0.15.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:89f463f7c8205a9f8dea9d658d59eff49db05f88f89cc3047fb1a02d9f344010", size = 10184752, upload-time = "2026-03-05T20:06:40.312Z" }, + { url = "https://files.pythonhosted.org/packages/66/0e/ba49e2c3fa0395b3152bad634c7432f7edfc509c133b8f4529053ff024fb/ruff-0.15.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba786a8295c6574c1116704cf0b9e6563de3432ac888d8f83685654fe528fd65", size = 10534857, upload-time = "2026-03-05T20:06:19.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/71/39234440f27a226475a0659561adb0d784b4d247dfe7f43ffc12dd02e288/ruff-0.15.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd4b801e57955fe9f02b31d20375ab3a5c4415f2e5105b79fb94cf2642c91440", size = 10309120, upload-time = "2026-03-05T20:06:00.435Z" }, + { url = "https://files.pythonhosted.org/packages/f5/87/4140aa86a93df032156982b726f4952aaec4a883bb98cb6ef73c347da253/ruff-0.15.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:391f7c73388f3d8c11b794dbbc2959a5b5afe66642c142a6effa90b45f6f5204", size = 11047428, upload-time = "2026-03-05T20:05:51.867Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f7/4953e7e3287676f78fbe85e3a0ca414c5ca81237b7575bdadc00229ac240/ruff-0.15.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8dc18f30302e379fe1e998548b0f5e9f4dff907f52f73ad6da419ea9c19d66c8", size = 11914251, upload-time = "2026-03-05T20:06:22.887Z" }, + { url = "https://files.pythonhosted.org/packages/77/46/0f7c865c10cf896ccf5a939c3e84e1cfaeed608ff5249584799a74d33835/ruff-0.15.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc6e7f90087e2d27f98dc34ed1b3ab7c8f0d273cc5431415454e22c0bd2a681", size = 11333801, upload-time = "2026-03-05T20:05:57.168Z" }, + { url = "https://files.pythonhosted.org/packages/d3/01/a10fe54b653061585e655f5286c2662ebddb68831ed3eaebfb0eb08c0a16/ruff-0.15.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1cb7169f53c1ddb06e71a9aebd7e98fc0fea936b39afb36d8e86d36ecc2636a", size = 11206821, upload-time = "2026-03-05T20:06:03.441Z" }, + { url = "https://files.pythonhosted.org/packages/7a/0d/2132ceaf20c5e8699aa83da2706ecb5c5dcdf78b453f77edca7fb70f8a93/ruff-0.15.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9b037924500a31ee17389b5c8c4d88874cc6ea8e42f12e9c61a3d754ff72f1ca", size = 11133326, upload-time = "2026-03-05T20:06:25.655Z" }, + { url = "https://files.pythonhosted.org/packages/72/cb/2e5259a7eb2a0f87c08c0fe5bf5825a1e4b90883a52685524596bfc93072/ruff-0.15.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:65bb414e5b4eadd95a8c1e4804f6772bbe8995889f203a01f77ddf2d790929dd", size = 10510820, upload-time = "2026-03-05T20:06:37.79Z" }, + { url = "https://files.pythonhosted.org/packages/ff/20/b67ce78f9e6c59ffbdb5b4503d0090e749b5f2d31b599b554698a80d861c/ruff-0.15.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d20aa469ae3b57033519c559e9bc9cd9e782842e39be05b50e852c7c981fa01d", size = 10302395, upload-time = "2026-03-05T20:05:54.504Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e5/719f1acccd31b720d477751558ed74e9c88134adcc377e5e886af89d3072/ruff-0.15.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:15388dd28c9161cdb8eda68993533acc870aa4e646a0a277aa166de9ad5a8752", size = 10754069, upload-time = "2026-03-05T20:06:06.422Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9c/d1db14469e32d98f3ca27079dbd30b7b44dbb5317d06ab36718dee3baf03/ruff-0.15.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b30da330cbd03bed0c21420b6b953158f60c74c54c5f4c1dabbdf3a57bf355d2", size = 11304315, upload-time = "2026-03-05T20:06:10.867Z" }, + { url = "https://files.pythonhosted.org/packages/28/3a/950367aee7c69027f4f422059227b290ed780366b6aecee5de5039d50fa8/ruff-0.15.5-py3-none-win32.whl", hash = "sha256:732e5ee1f98ba5b3679029989a06ca39a950cced52143a0ea82a2102cb592b74", size = 10551676, upload-time = "2026-03-05T20:06:13.705Z" }, + { url = "https://files.pythonhosted.org/packages/b8/00/bf077a505b4e649bdd3c47ff8ec967735ce2544c8e4a43aba42ee9bf935d/ruff-0.15.5-py3-none-win_amd64.whl", hash = "sha256:821d41c5fa9e19117616c35eaa3f4b75046ec76c65e7ae20a333e9a8696bc7fe", size = 11678972, upload-time = "2026-03-05T20:06:45.379Z" }, + { url = "https://files.pythonhosted.org/packages/fe/4e/cd76eca6db6115604b7626668e891c9dd03330384082e33662fb0f113614/ruff-0.15.5-py3-none-win_arm64.whl", hash = "sha256:b498d1c60d2fe5c10c45ec3f698901065772730b411f164ae270bb6bfcc4740b", size = 10965572, upload-time = "2026-03-05T20:06:16.984Z" }, +] + +[[package]] +name = "scikit-learn" +version = "1.7.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "joblib", marker = "python_full_version < '3.11'" }, + { name = "numpy", marker = "python_full_version < '3.11'" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "threadpoolctl", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/c2/a7855e41c9d285dfe86dc50b250978105dce513d6e459ea66a6aeb0e1e0c/scikit_learn-1.7.2.tar.gz", hash = "sha256:20e9e49ecd130598f1ca38a1d85090e1a600147b9c02fa6f15d69cb53d968fda", size = 7193136, upload-time = "2025-09-09T08:21:29.075Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/3e/daed796fd69cce768b8788401cc464ea90b306fb196ae1ffed0b98182859/scikit_learn-1.7.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6b33579c10a3081d076ab403df4a4190da4f4432d443521674637677dc91e61f", size = 9336221, upload-time = "2025-09-09T08:20:19.328Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ce/af9d99533b24c55ff4e18d9b7b4d9919bbc6cd8f22fe7a7be01519a347d5/scikit_learn-1.7.2-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:36749fb62b3d961b1ce4fedf08fa57a1986cd409eff2d783bca5d4b9b5fce51c", size = 8653834, upload-time = "2025-09-09T08:20:22.073Z" }, + { url = "https://files.pythonhosted.org/packages/58/0e/8c2a03d518fb6bd0b6b0d4b114c63d5f1db01ff0f9925d8eb10960d01c01/scikit_learn-1.7.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7a58814265dfc52b3295b1900cfb5701589d30a8bb026c7540f1e9d3499d5ec8", size = 9660938, upload-time = "2025-09-09T08:20:24.327Z" }, + { url = "https://files.pythonhosted.org/packages/2b/75/4311605069b5d220e7cf5adabb38535bd96f0079313cdbb04b291479b22a/scikit_learn-1.7.2-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a847fea807e278f821a0406ca01e387f97653e284ecbd9750e3ee7c90347f18", size = 9477818, upload-time = "2025-09-09T08:20:26.845Z" }, + { url = "https://files.pythonhosted.org/packages/7f/9b/87961813c34adbca21a6b3f6b2bea344c43b30217a6d24cc437c6147f3e8/scikit_learn-1.7.2-cp310-cp310-win_amd64.whl", hash = "sha256:ca250e6836d10e6f402436d6463d6c0e4d8e0234cfb6a9a47835bd392b852ce5", size = 8886969, upload-time = "2025-09-09T08:20:29.329Z" }, + { url = "https://files.pythonhosted.org/packages/43/83/564e141eef908a5863a54da8ca342a137f45a0bfb71d1d79704c9894c9d1/scikit_learn-1.7.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c7509693451651cd7361d30ce4e86a1347493554f172b1c72a39300fa2aea79e", size = 9331967, upload-time = "2025-09-09T08:20:32.421Z" }, + { url = "https://files.pythonhosted.org/packages/18/d6/ba863a4171ac9d7314c4d3fc251f015704a2caeee41ced89f321c049ed83/scikit_learn-1.7.2-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:0486c8f827c2e7b64837c731c8feff72c0bd2b998067a8a9cbc10643c31f0fe1", size = 8648645, upload-time = "2025-09-09T08:20:34.436Z" }, + { url = "https://files.pythonhosted.org/packages/ef/0e/97dbca66347b8cf0ea8b529e6bb9367e337ba2e8be0ef5c1a545232abfde/scikit_learn-1.7.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:89877e19a80c7b11a2891a27c21c4894fb18e2c2e077815bcade10d34287b20d", size = 9715424, upload-time = "2025-09-09T08:20:36.776Z" }, + { url = "https://files.pythonhosted.org/packages/f7/32/1f3b22e3207e1d2c883a7e09abb956362e7d1bd2f14458c7de258a26ac15/scikit_learn-1.7.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8da8bf89d4d79aaec192d2bda62f9b56ae4e5b4ef93b6a56b5de4977e375c1f1", size = 9509234, upload-time = "2025-09-09T08:20:38.957Z" }, + { url = "https://files.pythonhosted.org/packages/9f/71/34ddbd21f1da67c7a768146968b4d0220ee6831e4bcbad3e03dd3eae88b6/scikit_learn-1.7.2-cp311-cp311-win_amd64.whl", hash = "sha256:9b7ed8d58725030568523e937c43e56bc01cadb478fc43c042a9aca1dacb3ba1", size = 8894244, upload-time = "2025-09-09T08:20:41.166Z" }, + { url = "https://files.pythonhosted.org/packages/a7/aa/3996e2196075689afb9fce0410ebdb4a09099d7964d061d7213700204409/scikit_learn-1.7.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8d91a97fa2b706943822398ab943cde71858a50245e31bc71dba62aab1d60a96", size = 9259818, upload-time = "2025-09-09T08:20:43.19Z" }, + { url = "https://files.pythonhosted.org/packages/43/5d/779320063e88af9c4a7c2cf463ff11c21ac9c8bd730c4a294b0000b666c9/scikit_learn-1.7.2-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:acbc0f5fd2edd3432a22c69bed78e837c70cf896cd7993d71d51ba6708507476", size = 8636997, upload-time = "2025-09-09T08:20:45.468Z" }, + { url = "https://files.pythonhosted.org/packages/5c/d0/0c577d9325b05594fdd33aa970bf53fb673f051a45496842caee13cfd7fe/scikit_learn-1.7.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e5bf3d930aee75a65478df91ac1225ff89cd28e9ac7bd1196853a9229b6adb0b", size = 9478381, upload-time = "2025-09-09T08:20:47.982Z" }, + { url = "https://files.pythonhosted.org/packages/82/70/8bf44b933837ba8494ca0fc9a9ab60f1c13b062ad0197f60a56e2fc4c43e/scikit_learn-1.7.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4d6e9deed1a47aca9fe2f267ab8e8fe82ee20b4526b2c0cd9e135cea10feb44", size = 9300296, upload-time = "2025-09-09T08:20:50.366Z" }, + { url = "https://files.pythonhosted.org/packages/c6/99/ed35197a158f1fdc2fe7c3680e9c70d0128f662e1fee4ed495f4b5e13db0/scikit_learn-1.7.2-cp312-cp312-win_amd64.whl", hash = "sha256:6088aa475f0785e01bcf8529f55280a3d7d298679f50c0bb70a2364a82d0b290", size = 8731256, upload-time = "2025-09-09T08:20:52.627Z" }, + { url = "https://files.pythonhosted.org/packages/ae/93/a3038cb0293037fd335f77f31fe053b89c72f17b1c8908c576c29d953e84/scikit_learn-1.7.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0b7dacaa05e5d76759fb071558a8b5130f4845166d88654a0f9bdf3eb57851b7", size = 9212382, upload-time = "2025-09-09T08:20:54.731Z" }, + { url = "https://files.pythonhosted.org/packages/40/dd/9a88879b0c1104259136146e4742026b52df8540c39fec21a6383f8292c7/scikit_learn-1.7.2-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:abebbd61ad9e1deed54cca45caea8ad5f79e1b93173dece40bb8e0c658dbe6fe", size = 8592042, upload-time = "2025-09-09T08:20:57.313Z" }, + { url = "https://files.pythonhosted.org/packages/46/af/c5e286471b7d10871b811b72ae794ac5fe2989c0a2df07f0ec723030f5f5/scikit_learn-1.7.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:502c18e39849c0ea1a5d681af1dbcf15f6cce601aebb657aabbfe84133c1907f", size = 9434180, upload-time = "2025-09-09T08:20:59.671Z" }, + { url = "https://files.pythonhosted.org/packages/f1/fd/df59faa53312d585023b2da27e866524ffb8faf87a68516c23896c718320/scikit_learn-1.7.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a4c328a71785382fe3fe676a9ecf2c86189249beff90bf85e22bdb7efaf9ae0", size = 9283660, upload-time = "2025-09-09T08:21:01.71Z" }, + { url = "https://files.pythonhosted.org/packages/a7/c7/03000262759d7b6f38c836ff9d512f438a70d8a8ddae68ee80de72dcfb63/scikit_learn-1.7.2-cp313-cp313-win_amd64.whl", hash = "sha256:63a9afd6f7b229aad94618c01c252ce9e6fa97918c5ca19c9a17a087d819440c", size = 8702057, upload-time = "2025-09-09T08:21:04.234Z" }, + { url = "https://files.pythonhosted.org/packages/55/87/ef5eb1f267084532c8e4aef98a28b6ffe7425acbfd64b5e2f2e066bc29b3/scikit_learn-1.7.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:9acb6c5e867447b4e1390930e3944a005e2cb115922e693c08a323421a6966e8", size = 9558731, upload-time = "2025-09-09T08:21:06.381Z" }, + { url = "https://files.pythonhosted.org/packages/93/f8/6c1e3fc14b10118068d7938878a9f3f4e6d7b74a8ddb1e5bed65159ccda8/scikit_learn-1.7.2-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:2a41e2a0ef45063e654152ec9d8bcfc39f7afce35b08902bfe290c2498a67a6a", size = 9038852, upload-time = "2025-09-09T08:21:08.628Z" }, + { url = "https://files.pythonhosted.org/packages/83/87/066cafc896ee540c34becf95d30375fe5cbe93c3b75a0ee9aa852cd60021/scikit_learn-1.7.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98335fb98509b73385b3ab2bd0639b1f610541d3988ee675c670371d6a87aa7c", size = 9527094, upload-time = "2025-09-09T08:21:11.486Z" }, + { url = "https://files.pythonhosted.org/packages/9c/2b/4903e1ccafa1f6453b1ab78413938c8800633988c838aa0be386cbb33072/scikit_learn-1.7.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:191e5550980d45449126e23ed1d5e9e24b2c68329ee1f691a3987476e115e09c", size = 9367436, upload-time = "2025-09-09T08:21:13.602Z" }, + { url = "https://files.pythonhosted.org/packages/b5/aa/8444be3cfb10451617ff9d177b3c190288f4563e6c50ff02728be67ad094/scikit_learn-1.7.2-cp313-cp313t-win_amd64.whl", hash = "sha256:57dc4deb1d3762c75d685507fbd0bc17160144b2f2ba4ccea5dc285ab0d0e973", size = 9275749, upload-time = "2025-09-09T08:21:15.96Z" }, +] + +[[package]] +name = "scikit-learn" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.13' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version >= '3.13' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version >= '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "joblib", marker = "python_full_version >= '3.11'" }, + { name = "numpy", marker = "python_full_version >= '3.11'" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "threadpoolctl", marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585, upload-time = "2025-12-10T07:08:53.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/92/53ea2181da8ac6bf27170191028aee7251f8f841f8d3edbfdcaf2008fde9/scikit_learn-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:146b4d36f800c013d267b29168813f7a03a43ecd2895d04861f1240b564421da", size = 8595835, upload-time = "2025-12-10T07:07:39.385Z" }, + { url = "https://files.pythonhosted.org/packages/01/18/d154dc1638803adf987910cdd07097d9c526663a55666a97c124d09fb96a/scikit_learn-1.8.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:f984ca4b14914e6b4094c5d52a32ea16b49832c03bd17a110f004db3c223e8e1", size = 8080381, upload-time = "2025-12-10T07:07:41.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/44/226142fcb7b7101e64fdee5f49dbe6288d4c7af8abf593237b70fca080a4/scikit_learn-1.8.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e30adb87f0cc81c7690a84f7932dd66be5bac57cfe16b91cb9151683a4a2d3b", size = 8799632, upload-time = "2025-12-10T07:07:43.899Z" }, + { url = "https://files.pythonhosted.org/packages/36/4d/4a67f30778a45d542bbea5db2dbfa1e9e100bf9ba64aefe34215ba9f11f6/scikit_learn-1.8.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ada8121bcb4dac28d930febc791a69f7cb1673c8495e5eee274190b73a4559c1", size = 9103788, upload-time = "2025-12-10T07:07:45.982Z" }, + { url = "https://files.pythonhosted.org/packages/89/3c/45c352094cfa60050bcbb967b1faf246b22e93cb459f2f907b600f2ceda5/scikit_learn-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:c57b1b610bd1f40ba43970e11ce62821c2e6569e4d74023db19c6b26f246cb3b", size = 8081706, upload-time = "2025-12-10T07:07:48.111Z" }, + { url = "https://files.pythonhosted.org/packages/3d/46/5416595bb395757f754feb20c3d776553a386b661658fb21b7c814e89efe/scikit_learn-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:2838551e011a64e3053ad7618dda9310175f7515f1742fa2d756f7c874c05961", size = 7688451, upload-time = "2025-12-10T07:07:49.873Z" }, + { url = "https://files.pythonhosted.org/packages/90/74/e6a7cc4b820e95cc38cf36cd74d5aa2b42e8ffc2d21fe5a9a9c45c1c7630/scikit_learn-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5fb63362b5a7ddab88e52b6dbb47dac3fd7dafeee740dc6c8d8a446ddedade8e", size = 8548242, upload-time = "2025-12-10T07:07:51.568Z" }, + { url = "https://files.pythonhosted.org/packages/49/d8/9be608c6024d021041c7f0b3928d4749a706f4e2c3832bbede4fb4f58c95/scikit_learn-1.8.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:5025ce924beccb28298246e589c691fe1b8c1c96507e6d27d12c5fadd85bfd76", size = 8079075, upload-time = "2025-12-10T07:07:53.697Z" }, + { url = "https://files.pythonhosted.org/packages/dd/47/f187b4636ff80cc63f21cd40b7b2d177134acaa10f6bb73746130ee8c2e5/scikit_learn-1.8.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4496bb2cf7a43ce1a2d7524a79e40bc5da45cf598dbf9545b7e8316ccba47bb4", size = 8660492, upload-time = "2025-12-10T07:07:55.574Z" }, + { url = "https://files.pythonhosted.org/packages/97/74/b7a304feb2b49df9fafa9382d4d09061a96ee9a9449a7cbea7988dda0828/scikit_learn-1.8.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0bcfe4d0d14aec44921545fd2af2338c7471de9cb701f1da4c9d85906ab847a", size = 8931904, upload-time = "2025-12-10T07:07:57.666Z" }, + { url = "https://files.pythonhosted.org/packages/9f/c4/0ab22726a04ede56f689476b760f98f8f46607caecff993017ac1b64aa5d/scikit_learn-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:35c007dedb2ffe38fe3ee7d201ebac4a2deccd2408e8621d53067733e3c74809", size = 8019359, upload-time = "2025-12-10T07:07:59.838Z" }, + { url = "https://files.pythonhosted.org/packages/24/90/344a67811cfd561d7335c1b96ca21455e7e472d281c3c279c4d3f2300236/scikit_learn-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:8c497fff237d7b4e07e9ef1a640887fa4fb765647f86fbe00f969ff6280ce2bb", size = 7641898, upload-time = "2025-12-10T07:08:01.36Z" }, + { url = "https://files.pythonhosted.org/packages/03/aa/e22e0768512ce9255eba34775be2e85c2048da73da1193e841707f8f039c/scikit_learn-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0d6ae97234d5d7079dc0040990a6f7aeb97cb7fa7e8945f1999a429b23569e0a", size = 8513770, upload-time = "2025-12-10T07:08:03.251Z" }, + { url = "https://files.pythonhosted.org/packages/58/37/31b83b2594105f61a381fc74ca19e8780ee923be2d496fcd8d2e1147bd99/scikit_learn-1.8.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:edec98c5e7c128328124a029bceb09eda2d526997780fef8d65e9a69eead963e", size = 8044458, upload-time = "2025-12-10T07:08:05.336Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5a/3f1caed8765f33eabb723596666da4ebbf43d11e96550fb18bdec42b467b/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74b66d8689d52ed04c271e1329f0c61635bcaf5b926db9b12d58914cdc01fe57", size = 8610341, upload-time = "2025-12-10T07:08:07.732Z" }, + { url = "https://files.pythonhosted.org/packages/38/cf/06896db3f71c75902a8e9943b444a56e727418f6b4b4a90c98c934f51ed4/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8fdf95767f989b0cfedb85f7ed8ca215d4be728031f56ff5a519ee1e3276dc2e", size = 8900022, upload-time = "2025-12-10T07:08:09.862Z" }, + { url = "https://files.pythonhosted.org/packages/1c/f9/9b7563caf3ec8873e17a31401858efab6b39a882daf6c1bfa88879c0aa11/scikit_learn-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:2de443b9373b3b615aec1bb57f9baa6bb3a9bd093f1269ba95c17d870422b271", size = 7989409, upload-time = "2025-12-10T07:08:12.028Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/1f4001503650e72c4f6009ac0c4413cb17d2d601cef6f71c0453da2732fc/scikit_learn-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:eddde82a035681427cbedded4e6eff5e57fa59216c2e3e90b10b19ab1d0a65c3", size = 7619760, upload-time = "2025-12-10T07:08:13.688Z" }, + { url = "https://files.pythonhosted.org/packages/d2/7d/a630359fc9dcc95496588c8d8e3245cc8fd81980251079bc09c70d41d951/scikit_learn-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7cc267b6108f0a1499a734167282c00c4ebf61328566b55ef262d48e9849c735", size = 8826045, upload-time = "2025-12-10T07:08:15.215Z" }, + { url = "https://files.pythonhosted.org/packages/cc/56/a0c86f6930cfcd1c7054a2bc417e26960bb88d32444fe7f71d5c2cfae891/scikit_learn-1.8.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:fe1c011a640a9f0791146011dfd3c7d9669785f9fed2b2a5f9e207536cf5c2fd", size = 8420324, upload-time = "2025-12-10T07:08:17.561Z" }, + { url = "https://files.pythonhosted.org/packages/46/1e/05962ea1cebc1cf3876667ecb14c283ef755bf409993c5946ade3b77e303/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72358cce49465d140cc4e7792015bb1f0296a9742d5622c67e31399b75468b9e", size = 8680651, upload-time = "2025-12-10T07:08:19.952Z" }, + { url = "https://files.pythonhosted.org/packages/fe/56/a85473cd75f200c9759e3a5f0bcab2d116c92a8a02ee08ccd73b870f8bb4/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80832434a6cc114f5219211eec13dcbc16c2bac0e31ef64c6d346cde3cf054cb", size = 8925045, upload-time = "2025-12-10T07:08:22.11Z" }, + { url = "https://files.pythonhosted.org/packages/cc/b7/64d8cfa896c64435ae57f4917a548d7ac7a44762ff9802f75a79b77cb633/scikit_learn-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ee787491dbfe082d9c3013f01f5991658b0f38aa8177e4cd4bf434c58f551702", size = 8507994, upload-time = "2025-12-10T07:08:23.943Z" }, + { url = "https://files.pythonhosted.org/packages/5e/37/e192ea709551799379958b4c4771ec507347027bb7c942662c7fbeba31cb/scikit_learn-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf97c10a3f5a7543f9b88cbf488d33d175e9146115a451ae34568597ba33dcde", size = 7869518, upload-time = "2025-12-10T07:08:25.71Z" }, +] + +[[package]] +name = "scipy" +version = "1.15.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "numpy", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/37/6964b830433e654ec7485e45a00fc9a27cf868d622838f6b6d9c5ec0d532/scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf", size = 59419214, upload-time = "2025-05-08T16:13:05.955Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/2f/4966032c5f8cc7e6a60f1b2e0ad686293b9474b65246b0c642e3ef3badd0/scipy-1.15.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:a345928c86d535060c9c2b25e71e87c39ab2f22fc96e9636bd74d1dbf9de448c", size = 38702770, upload-time = "2025-05-08T16:04:20.849Z" }, + { url = "https://files.pythonhosted.org/packages/a0/6e/0c3bf90fae0e910c274db43304ebe25a6b391327f3f10b5dcc638c090795/scipy-1.15.3-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:ad3432cb0f9ed87477a8d97f03b763fd1d57709f1bbde3c9369b1dff5503b253", size = 30094511, upload-time = "2025-05-08T16:04:27.103Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b1/4deb37252311c1acff7f101f6453f0440794f51b6eacb1aad4459a134081/scipy-1.15.3-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:aef683a9ae6eb00728a542b796f52a5477b78252edede72b8327a886ab63293f", size = 22368151, upload-time = "2025-05-08T16:04:31.731Z" }, + { url = "https://files.pythonhosted.org/packages/38/7d/f457626e3cd3c29b3a49ca115a304cebb8cc6f31b04678f03b216899d3c6/scipy-1.15.3-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:1c832e1bd78dea67d5c16f786681b28dd695a8cb1fb90af2e27580d3d0967e92", size = 25121732, upload-time = "2025-05-08T16:04:36.596Z" }, + { url = "https://files.pythonhosted.org/packages/db/0a/92b1de4a7adc7a15dcf5bddc6e191f6f29ee663b30511ce20467ef9b82e4/scipy-1.15.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:263961f658ce2165bbd7b99fa5135195c3a12d9bef045345016b8b50c315cb82", size = 35547617, upload-time = "2025-05-08T16:04:43.546Z" }, + { url = "https://files.pythonhosted.org/packages/8e/6d/41991e503e51fc1134502694c5fa7a1671501a17ffa12716a4a9151af3df/scipy-1.15.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e2abc762b0811e09a0d3258abee2d98e0c703eee49464ce0069590846f31d40", size = 37662964, upload-time = "2025-05-08T16:04:49.431Z" }, + { url = "https://files.pythonhosted.org/packages/25/e1/3df8f83cb15f3500478c889be8fb18700813b95e9e087328230b98d547ff/scipy-1.15.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ed7284b21a7a0c8f1b6e5977ac05396c0d008b89e05498c8b7e8f4a1423bba0e", size = 37238749, upload-time = "2025-05-08T16:04:55.215Z" }, + { url = "https://files.pythonhosted.org/packages/93/3e/b3257cf446f2a3533ed7809757039016b74cd6f38271de91682aa844cfc5/scipy-1.15.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5380741e53df2c566f4d234b100a484b420af85deb39ea35a1cc1be84ff53a5c", size = 40022383, upload-time = "2025-05-08T16:05:01.914Z" }, + { url = "https://files.pythonhosted.org/packages/d1/84/55bc4881973d3f79b479a5a2e2df61c8c9a04fcb986a213ac9c02cfb659b/scipy-1.15.3-cp310-cp310-win_amd64.whl", hash = "sha256:9d61e97b186a57350f6d6fd72640f9e99d5a4a2b8fbf4b9ee9a841eab327dc13", size = 41259201, upload-time = "2025-05-08T16:05:08.166Z" }, + { url = "https://files.pythonhosted.org/packages/96/ab/5cc9f80f28f6a7dff646c5756e559823614a42b1939d86dd0ed550470210/scipy-1.15.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:993439ce220d25e3696d1b23b233dd010169b62f6456488567e830654ee37a6b", size = 38714255, upload-time = "2025-05-08T16:05:14.596Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4a/66ba30abe5ad1a3ad15bfb0b59d22174012e8056ff448cb1644deccbfed2/scipy-1.15.3-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:34716e281f181a02341ddeaad584205bd2fd3c242063bd3423d61ac259ca7eba", size = 30111035, upload-time = "2025-05-08T16:05:20.152Z" }, + { url = "https://files.pythonhosted.org/packages/4b/fa/a7e5b95afd80d24313307f03624acc65801846fa75599034f8ceb9e2cbf6/scipy-1.15.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3b0334816afb8b91dab859281b1b9786934392aa3d527cd847e41bb6f45bee65", size = 22384499, upload-time = "2025-05-08T16:05:24.494Z" }, + { url = "https://files.pythonhosted.org/packages/17/99/f3aaddccf3588bb4aea70ba35328c204cadd89517a1612ecfda5b2dd9d7a/scipy-1.15.3-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:6db907c7368e3092e24919b5e31c76998b0ce1684d51a90943cb0ed1b4ffd6c1", size = 25152602, upload-time = "2025-05-08T16:05:29.313Z" }, + { url = "https://files.pythonhosted.org/packages/56/c5/1032cdb565f146109212153339f9cb8b993701e9fe56b1c97699eee12586/scipy-1.15.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:721d6b4ef5dc82ca8968c25b111e307083d7ca9091bc38163fb89243e85e3889", size = 35503415, upload-time = "2025-05-08T16:05:34.699Z" }, + { url = "https://files.pythonhosted.org/packages/bd/37/89f19c8c05505d0601ed5650156e50eb881ae3918786c8fd7262b4ee66d3/scipy-1.15.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39cb9c62e471b1bb3750066ecc3a3f3052b37751c7c3dfd0fd7e48900ed52982", size = 37652622, upload-time = "2025-05-08T16:05:40.762Z" }, + { url = "https://files.pythonhosted.org/packages/7e/31/be59513aa9695519b18e1851bb9e487de66f2d31f835201f1b42f5d4d475/scipy-1.15.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:795c46999bae845966368a3c013e0e00947932d68e235702b5c3f6ea799aa8c9", size = 37244796, upload-time = "2025-05-08T16:05:48.119Z" }, + { url = "https://files.pythonhosted.org/packages/10/c0/4f5f3eeccc235632aab79b27a74a9130c6c35df358129f7ac8b29f562ac7/scipy-1.15.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:18aaacb735ab38b38db42cb01f6b92a2d0d4b6aabefeb07f02849e47f8fb3594", size = 40047684, upload-time = "2025-05-08T16:05:54.22Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a7/0ddaf514ce8a8714f6ed243a2b391b41dbb65251affe21ee3077ec45ea9a/scipy-1.15.3-cp311-cp311-win_amd64.whl", hash = "sha256:ae48a786a28412d744c62fd7816a4118ef97e5be0bee968ce8f0a2fba7acf3bb", size = 41246504, upload-time = "2025-05-08T16:06:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/37/4b/683aa044c4162e10ed7a7ea30527f2cbd92e6999c10a8ed8edb253836e9c/scipy-1.15.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6ac6310fdbfb7aa6612408bd2f07295bcbd3fda00d2d702178434751fe48e019", size = 38766735, upload-time = "2025-05-08T16:06:06.471Z" }, + { url = "https://files.pythonhosted.org/packages/7b/7e/f30be3d03de07f25dc0ec926d1681fed5c732d759ac8f51079708c79e680/scipy-1.15.3-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:185cd3d6d05ca4b44a8f1595af87f9c372bb6acf9c808e99aa3e9aa03bd98cf6", size = 30173284, upload-time = "2025-05-08T16:06:11.686Z" }, + { url = "https://files.pythonhosted.org/packages/07/9c/0ddb0d0abdabe0d181c1793db51f02cd59e4901da6f9f7848e1f96759f0d/scipy-1.15.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:05dc6abcd105e1a29f95eada46d4a3f251743cfd7d3ae8ddb4088047f24ea477", size = 22446958, upload-time = "2025-05-08T16:06:15.97Z" }, + { url = "https://files.pythonhosted.org/packages/af/43/0bce905a965f36c58ff80d8bea33f1f9351b05fad4beaad4eae34699b7a1/scipy-1.15.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:06efcba926324df1696931a57a176c80848ccd67ce6ad020c810736bfd58eb1c", size = 25242454, upload-time = "2025-05-08T16:06:20.394Z" }, + { url = "https://files.pythonhosted.org/packages/56/30/a6f08f84ee5b7b28b4c597aca4cbe545535c39fe911845a96414700b64ba/scipy-1.15.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c05045d8b9bfd807ee1b9f38761993297b10b245f012b11b13b91ba8945f7e45", size = 35210199, upload-time = "2025-05-08T16:06:26.159Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1f/03f52c282437a168ee2c7c14a1a0d0781a9a4a8962d84ac05c06b4c5b555/scipy-1.15.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:271e3713e645149ea5ea3e97b57fdab61ce61333f97cfae392c28ba786f9bb49", size = 37309455, upload-time = "2025-05-08T16:06:32.778Z" }, + { url = "https://files.pythonhosted.org/packages/89/b1/fbb53137f42c4bf630b1ffdfc2151a62d1d1b903b249f030d2b1c0280af8/scipy-1.15.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6cfd56fc1a8e53f6e89ba3a7a7251f7396412d655bca2aa5611c8ec9a6784a1e", size = 36885140, upload-time = "2025-05-08T16:06:39.249Z" }, + { url = "https://files.pythonhosted.org/packages/2e/2e/025e39e339f5090df1ff266d021892694dbb7e63568edcfe43f892fa381d/scipy-1.15.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0ff17c0bb1cb32952c09217d8d1eed9b53d1463e5f1dd6052c7857f83127d539", size = 39710549, upload-time = "2025-05-08T16:06:45.729Z" }, + { url = "https://files.pythonhosted.org/packages/e6/eb/3bf6ea8ab7f1503dca3a10df2e4b9c3f6b3316df07f6c0ded94b281c7101/scipy-1.15.3-cp312-cp312-win_amd64.whl", hash = "sha256:52092bc0472cfd17df49ff17e70624345efece4e1a12b23783a1ac59a1b728ed", size = 40966184, upload-time = "2025-05-08T16:06:52.623Z" }, + { url = "https://files.pythonhosted.org/packages/73/18/ec27848c9baae6e0d6573eda6e01a602e5649ee72c27c3a8aad673ebecfd/scipy-1.15.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2c620736bcc334782e24d173c0fdbb7590a0a436d2fdf39310a8902505008759", size = 38728256, upload-time = "2025-05-08T16:06:58.696Z" }, + { url = "https://files.pythonhosted.org/packages/74/cd/1aef2184948728b4b6e21267d53b3339762c285a46a274ebb7863c9e4742/scipy-1.15.3-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:7e11270a000969409d37ed399585ee530b9ef6aa99d50c019de4cb01e8e54e62", size = 30109540, upload-time = "2025-05-08T16:07:04.209Z" }, + { url = "https://files.pythonhosted.org/packages/5b/d8/59e452c0a255ec352bd0a833537a3bc1bfb679944c4938ab375b0a6b3a3e/scipy-1.15.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:8c9ed3ba2c8a2ce098163a9bdb26f891746d02136995df25227a20e71c396ebb", size = 22383115, upload-time = "2025-05-08T16:07:08.998Z" }, + { url = "https://files.pythonhosted.org/packages/08/f5/456f56bbbfccf696263b47095291040655e3cbaf05d063bdc7c7517f32ac/scipy-1.15.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:0bdd905264c0c9cfa74a4772cdb2070171790381a5c4d312c973382fc6eaf730", size = 25163884, upload-time = "2025-05-08T16:07:14.091Z" }, + { url = "https://files.pythonhosted.org/packages/a2/66/a9618b6a435a0f0c0b8a6d0a2efb32d4ec5a85f023c2b79d39512040355b/scipy-1.15.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79167bba085c31f38603e11a267d862957cbb3ce018d8b38f79ac043bc92d825", size = 35174018, upload-time = "2025-05-08T16:07:19.427Z" }, + { url = "https://files.pythonhosted.org/packages/b5/09/c5b6734a50ad4882432b6bb7c02baf757f5b2f256041da5df242e2d7e6b6/scipy-1.15.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9deabd6d547aee2c9a81dee6cc96c6d7e9a9b1953f74850c179f91fdc729cb7", size = 37269716, upload-time = "2025-05-08T16:07:25.712Z" }, + { url = "https://files.pythonhosted.org/packages/77/0a/eac00ff741f23bcabd352731ed9b8995a0a60ef57f5fd788d611d43d69a1/scipy-1.15.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dde4fc32993071ac0c7dd2d82569e544f0bdaff66269cb475e0f369adad13f11", size = 36872342, upload-time = "2025-05-08T16:07:31.468Z" }, + { url = "https://files.pythonhosted.org/packages/fe/54/4379be86dd74b6ad81551689107360d9a3e18f24d20767a2d5b9253a3f0a/scipy-1.15.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f77f853d584e72e874d87357ad70f44b437331507d1c311457bed8ed2b956126", size = 39670869, upload-time = "2025-05-08T16:07:38.002Z" }, + { url = "https://files.pythonhosted.org/packages/87/2e/892ad2862ba54f084ffe8cc4a22667eaf9c2bcec6d2bff1d15713c6c0703/scipy-1.15.3-cp313-cp313-win_amd64.whl", hash = "sha256:b90ab29d0c37ec9bf55424c064312930ca5f4bde15ee8619ee44e69319aab163", size = 40988851, upload-time = "2025-05-08T16:08:33.671Z" }, + { url = "https://files.pythonhosted.org/packages/1b/e9/7a879c137f7e55b30d75d90ce3eb468197646bc7b443ac036ae3fe109055/scipy-1.15.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:3ac07623267feb3ae308487c260ac684b32ea35fd81e12845039952f558047b8", size = 38863011, upload-time = "2025-05-08T16:07:44.039Z" }, + { url = "https://files.pythonhosted.org/packages/51/d1/226a806bbd69f62ce5ef5f3ffadc35286e9fbc802f606a07eb83bf2359de/scipy-1.15.3-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:6487aa99c2a3d509a5227d9a5e889ff05830a06b2ce08ec30df6d79db5fcd5c5", size = 30266407, upload-time = "2025-05-08T16:07:49.891Z" }, + { url = "https://files.pythonhosted.org/packages/e5/9b/f32d1d6093ab9eeabbd839b0f7619c62e46cc4b7b6dbf05b6e615bbd4400/scipy-1.15.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:50f9e62461c95d933d5c5ef4a1f2ebf9a2b4e83b0db374cb3f1de104d935922e", size = 22540030, upload-time = "2025-05-08T16:07:54.121Z" }, + { url = "https://files.pythonhosted.org/packages/e7/29/c278f699b095c1a884f29fda126340fcc201461ee8bfea5c8bdb1c7c958b/scipy-1.15.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:14ed70039d182f411ffc74789a16df3835e05dc469b898233a245cdfd7f162cb", size = 25218709, upload-time = "2025-05-08T16:07:58.506Z" }, + { url = "https://files.pythonhosted.org/packages/24/18/9e5374b617aba742a990581373cd6b68a2945d65cc588482749ef2e64467/scipy-1.15.3-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a769105537aa07a69468a0eefcd121be52006db61cdd8cac8a0e68980bbb723", size = 34809045, upload-time = "2025-05-08T16:08:03.929Z" }, + { url = "https://files.pythonhosted.org/packages/e1/fe/9c4361e7ba2927074360856db6135ef4904d505e9b3afbbcb073c4008328/scipy-1.15.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9db984639887e3dffb3928d118145ffe40eff2fa40cb241a306ec57c219ebbbb", size = 36703062, upload-time = "2025-05-08T16:08:09.558Z" }, + { url = "https://files.pythonhosted.org/packages/b7/8e/038ccfe29d272b30086b25a4960f757f97122cb2ec42e62b460d02fe98e9/scipy-1.15.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:40e54d5c7e7ebf1aa596c374c49fa3135f04648a0caabcb66c52884b943f02b4", size = 36393132, upload-time = "2025-05-08T16:08:15.34Z" }, + { url = "https://files.pythonhosted.org/packages/10/7e/5c12285452970be5bdbe8352c619250b97ebf7917d7a9a9e96b8a8140f17/scipy-1.15.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5e721fed53187e71d0ccf382b6bf977644c533e506c4d33c3fb24de89f5c3ed5", size = 38979503, upload-time = "2025-05-08T16:08:21.513Z" }, + { url = "https://files.pythonhosted.org/packages/81/06/0a5e5349474e1cbc5757975b21bd4fad0e72ebf138c5592f191646154e06/scipy-1.15.3-cp313-cp313t-win_amd64.whl", hash = "sha256:76ad1fb5f8752eabf0fa02e4cc0336b4e8f021e2d5f061ed37d6d264db35e3ca", size = 40308097, upload-time = "2025-05-08T16:08:27.627Z" }, +] + +[[package]] +name = "scipy" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.13' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version >= '3.13' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version >= '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "numpy", marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/75/b4ce781849931fef6fd529afa6b63711d5a733065722d0c3e2724af9e40a/scipy-1.17.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:1f95b894f13729334fb990162e911c9e5dc1ab390c58aa6cbecb389c5b5e28ec", size = 31613675, upload-time = "2026-02-23T00:16:00.13Z" }, + { url = "https://files.pythonhosted.org/packages/f7/58/bccc2861b305abdd1b8663d6130c0b3d7cc22e8d86663edbc8401bfd40d4/scipy-1.17.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:e18f12c6b0bc5a592ed23d3f7b891f68fd7f8241d69b7883769eb5d5dfb52696", size = 28162057, upload-time = "2026-02-23T00:16:09.456Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ee/18146b7757ed4976276b9c9819108adbc73c5aad636e5353e20746b73069/scipy-1.17.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a3472cfbca0a54177d0faa68f697d8ba4c80bbdc19908c3465556d9f7efce9ee", size = 20334032, upload-time = "2026-02-23T00:16:17.358Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e6/cef1cf3557f0c54954198554a10016b6a03b2ec9e22a4e1df734936bd99c/scipy-1.17.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:766e0dc5a616d026a3a1cffa379af959671729083882f50307e18175797b3dfd", size = 22709533, upload-time = "2026-02-23T00:16:25.791Z" }, + { url = "https://files.pythonhosted.org/packages/4d/60/8804678875fc59362b0fb759ab3ecce1f09c10a735680318ac30da8cd76b/scipy-1.17.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:744b2bf3640d907b79f3fd7874efe432d1cf171ee721243e350f55234b4cec4c", size = 33062057, upload-time = "2026-02-23T00:16:36.931Z" }, + { url = "https://files.pythonhosted.org/packages/09/7d/af933f0f6e0767995b4e2d705a0665e454d1c19402aa7e895de3951ebb04/scipy-1.17.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43af8d1f3bea642559019edfe64e9b11192a8978efbd1539d7bc2aaa23d92de4", size = 35349300, upload-time = "2026-02-23T00:16:49.108Z" }, + { url = "https://files.pythonhosted.org/packages/b4/3d/7ccbbdcbb54c8fdc20d3b6930137c782a163fa626f0aef920349873421ba/scipy-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd96a1898c0a47be4520327e01f874acfd61fb48a9420f8aa9f6483412ffa444", size = 35127333, upload-time = "2026-02-23T00:17:01.293Z" }, + { url = "https://files.pythonhosted.org/packages/e8/19/f926cb11c42b15ba08e3a71e376d816ac08614f769b4f47e06c3580c836a/scipy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4eb6c25dd62ee8d5edf68a8e1c171dd71c292fdae95d8aeb3dd7d7de4c364082", size = 37741314, upload-time = "2026-02-23T00:17:12.576Z" }, + { url = "https://files.pythonhosted.org/packages/95/da/0d1df507cf574b3f224ccc3d45244c9a1d732c81dcb26b1e8a766ae271a8/scipy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:d30e57c72013c2a4fe441c2fcb8e77b14e152ad48b5464858e07e2ad9fbfceff", size = 36607512, upload-time = "2026-02-23T00:17:23.424Z" }, + { url = "https://files.pythonhosted.org/packages/68/7f/bdd79ceaad24b671543ffe0ef61ed8e659440eb683b66f033454dcee90eb/scipy-1.17.1-cp311-cp311-win_arm64.whl", hash = "sha256:9ecb4efb1cd6e8c4afea0daa91a87fbddbce1b99d2895d151596716c0b2e859d", size = 24599248, upload-time = "2026-02-23T00:17:34.561Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" }, + { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" }, + { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" }, + { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" }, + { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" }, + { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" }, + { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" }, + { url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" }, + { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" }, + { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" }, + { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" }, + { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" }, + { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" }, + { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" }, +] + +[[package]] +name = "seaborn" +version = "0.13.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "matplotlib" }, + { name = "numpy" }, + { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "pandas", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/59/a451d7420a77ab0b98f7affa3a1d78a313d2f7281a57afb1a34bae8ab412/seaborn-0.13.2.tar.gz", hash = "sha256:93e60a40988f4d65e9f4885df477e2fdaff6b73a9ded434c1ab356dd57eefff7", size = 1457696, upload-time = "2024-01-25T13:21:52.551Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/11/00d3c3dfc25ad54e731d91449895a79e4bf2384dc3ac01809010ba88f6d5/seaborn-0.13.2-py3-none-any.whl", hash = "sha256:636f8336facf092165e27924f223d3c62ca560b1f2bb5dff7ab7fad265361987", size = 294914, upload-time = "2024-01-25T13:21:49.598Z" }, +] + +[[package]] +name = "send2trash" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c5/f0/184b4b5f8d00f2a92cf96eec8967a3d550b52cf94362dad1100df9e48d57/send2trash-2.1.0.tar.gz", hash = "sha256:1c72b39f09457db3c05ce1d19158c2cbef4c32b8bedd02c155e49282b7ea7459", size = 17255, upload-time = "2026-01-14T06:27:36.056Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/78/504fdd027da3b84ff1aecd9f6957e65f35134534ccc6da8628eb71e76d3f/send2trash-2.1.0-py3-none-any.whl", hash = "sha256:0da2f112e6d6bb22de6aa6daa7e144831a4febf2a87261451c4ad849fe9a873c", size = 17610, upload-time = "2026-01-14T06:27:35.218Z" }, +] + +[[package]] +name = "setuptools" +version = "82.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "shortuuid" +version = "1.0.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/e2/bcf761f3bff95856203f9559baf3741c416071dd200c0fc19fad7f078f86/shortuuid-1.0.13.tar.gz", hash = "sha256:3bb9cf07f606260584b1df46399c0b87dd84773e7b25912b7e391e30797c5e72", size = 9662, upload-time = "2024-03-11T20:11:06.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/44/21d6bf170bf40b41396480d8d49ad640bca3f2b02139cd52aa1e272830a5/shortuuid-1.0.13-py3-none-any.whl", hash = "sha256:a482a497300b49b4953e15108a7913244e1bb0d41f9d332f5e9925dba33a3c5a", size = 10529, upload-time = "2024-03-11T20:11:04.807Z" }, +] + +[[package]] +name = "simplejson" +version = "3.20.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/f4/a1ac5ed32f7ed9a088d62a59d410d4c204b3b3815722e2ccfb491fa8251b/simplejson-3.20.2.tar.gz", hash = "sha256:5fe7a6ce14d1c300d80d08695b7f7e633de6cd72c80644021874d985b3393649", size = 85784, upload-time = "2025-09-26T16:29:36.64Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/09/2bf3761de89ea2d91bdce6cf107dcd858892d0adc22c995684878826cc6b/simplejson-3.20.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:6d7286dc11af60a2f76eafb0c2acde2d997e87890e37e24590bb513bec9f1bc5", size = 94039, upload-time = "2025-09-26T16:27:29.283Z" }, + { url = "https://files.pythonhosted.org/packages/0f/33/c3277db8931f0ae9e54b9292668863365672d90fb0f632f4cf9829cb7d68/simplejson-3.20.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c01379b4861c3b0aa40cba8d44f2b448f5743999aa68aaa5d3ef7049d4a28a2d", size = 75894, upload-time = "2025-09-26T16:27:30.378Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ea/ae47b04d03c7c8a7b7b1a8b39a6e27c3bd424e52f4988d70aca6293ff5e5/simplejson-3.20.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a16b029ca25645b3bc44e84a4f941efa51bf93c180b31bd704ce6349d1fc77c1", size = 76116, upload-time = "2025-09-26T16:27:31.42Z" }, + { url = "https://files.pythonhosted.org/packages/4b/42/6c9af551e5a8d0f171d6dce3d9d1260068927f7b80f1f09834e07887c8c4/simplejson-3.20.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e22a5fb7b1437ffb057e02e1936a3bfb19084ae9d221ec5e9f4cf85f69946b6", size = 138827, upload-time = "2025-09-26T16:27:32.486Z" }, + { url = "https://files.pythonhosted.org/packages/2b/22/5e268bbcbe9f75577491e406ec0a5536f5b2fa91a3b52031fea51cd83e1d/simplejson-3.20.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d8b6ff02fc7b8555c906c24735908854819b0d0dc85883d453e23ca4c0445d01", size = 146772, upload-time = "2025-09-26T16:27:34.036Z" }, + { url = "https://files.pythonhosted.org/packages/71/b4/800f14728e2ad666f420dfdb57697ca128aeae7f991b35759c09356b829a/simplejson-3.20.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2bfc1c396ad972ba4431130b42307b2321dba14d988580c1ac421ec6a6b7cee3", size = 134497, upload-time = "2025-09-26T16:27:35.211Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b9/c54eef4226c6ac8e9a389bbe5b21fef116768f97a2dc1a683c716ffe66ef/simplejson-3.20.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a97249ee1aee005d891b5a211faf58092a309f3d9d440bc269043b08f662eda", size = 138172, upload-time = "2025-09-26T16:27:36.44Z" }, + { url = "https://files.pythonhosted.org/packages/09/36/4e282f5211b34620f1b2e4b51d9ddaab5af82219b9b7b78360a33f7e5387/simplejson-3.20.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f1036be00b5edaddbddbb89c0f80ed229714a941cfd21e51386dc69c237201c2", size = 140272, upload-time = "2025-09-26T16:27:37.605Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/94ad2cf32f477c449e1f63c863d8a513e2408d651c4e58fe4b6a7434e168/simplejson-3.20.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5d6f5bacb8cdee64946b45f2680afa3f54cd38e62471ceda89f777693aeca4e4", size = 140468, upload-time = "2025-09-26T16:27:39.015Z" }, + { url = "https://files.pythonhosted.org/packages/e5/46/827731e4163be3f987cb8ee90f5d444161db8f540b5e735355faa098d9bc/simplejson-3.20.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8db6841fb796ec5af632f677abf21c6425a1ebea0d9ac3ef1a340b8dc69f52b8", size = 148700, upload-time = "2025-09-26T16:27:40.171Z" }, + { url = "https://files.pythonhosted.org/packages/c7/28/c32121064b1ec2fb7b5d872d9a1abda62df064d35e0160eddfa907118343/simplejson-3.20.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c0a341f7cc2aae82ee2b31f8a827fd2e51d09626f8b3accc441a6907c88aedb7", size = 141323, upload-time = "2025-09-26T16:27:41.324Z" }, + { url = "https://files.pythonhosted.org/packages/46/b6/c897c54326fe86dd12d101981171a49361949f4728294f418c3b86a1af77/simplejson-3.20.2-cp310-cp310-win32.whl", hash = "sha256:27f9c01a6bc581d32ab026f515226864576da05ef322d7fc141cd8a15a95ce53", size = 74377, upload-time = "2025-09-26T16:27:42.533Z" }, + { url = "https://files.pythonhosted.org/packages/ad/87/a6e03d4d80cca99c1fee4e960f3440e2f21be9470e537970f960ca5547f1/simplejson-3.20.2-cp310-cp310-win_amd64.whl", hash = "sha256:c0a63ec98a4547ff366871bf832a7367ee43d047bcec0b07b66c794e2137b476", size = 76081, upload-time = "2025-09-26T16:27:43.945Z" }, + { url = "https://files.pythonhosted.org/packages/b9/3e/96898c6c66d9dca3f9bd14d7487bf783b4acc77471b42f979babbb68d4ca/simplejson-3.20.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:06190b33cd7849efc413a5738d3da00b90e4a5382fd3d584c841ac20fb828c6f", size = 92633, upload-time = "2025-09-26T16:27:45.028Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a2/cd2e10b880368305d89dd540685b8bdcc136df2b3c76b5ddd72596254539/simplejson-3.20.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4ad4eac7d858947a30d2c404e61f16b84d16be79eb6fb316341885bdde864fa8", size = 75309, upload-time = "2025-09-26T16:27:46.142Z" }, + { url = "https://files.pythonhosted.org/packages/5d/02/290f7282eaa6ebe945d35c47e6534348af97472446951dce0d144e013f4c/simplejson-3.20.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b392e11c6165d4a0fde41754a0e13e1d88a5ad782b245a973dd4b2bdb4e5076a", size = 75308, upload-time = "2025-09-26T16:27:47.542Z" }, + { url = "https://files.pythonhosted.org/packages/43/91/43695f17b69e70c4b0b03247aa47fb3989d338a70c4b726bbdc2da184160/simplejson-3.20.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:51eccc4e353eed3c50e0ea2326173acdc05e58f0c110405920b989d481287e51", size = 143733, upload-time = "2025-09-26T16:27:48.673Z" }, + { url = "https://files.pythonhosted.org/packages/9b/4b/fdcaf444ac1c3cbf1c52bf00320c499e1cf05d373a58a3731ae627ba5e2d/simplejson-3.20.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:306e83d7c331ad833d2d43c76a67f476c4b80c4a13334f6e34bb110e6105b3bd", size = 153397, upload-time = "2025-09-26T16:27:49.89Z" }, + { url = "https://files.pythonhosted.org/packages/c4/83/21550f81a50cd03599f048a2d588ffb7f4c4d8064ae091511e8e5848eeaa/simplejson-3.20.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f820a6ac2ef0bc338ae4963f4f82ccebdb0824fe9caf6d660670c578abe01013", size = 141654, upload-time = "2025-09-26T16:27:51.168Z" }, + { url = "https://files.pythonhosted.org/packages/cf/54/d76c0e72ad02450a3e723b65b04f49001d0e73218ef6a220b158a64639cb/simplejson-3.20.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21e7a066528a5451433eb3418184f05682ea0493d14e9aae690499b7e1eb6b81", size = 144913, upload-time = "2025-09-26T16:27:52.331Z" }, + { url = "https://files.pythonhosted.org/packages/3f/49/976f59b42a6956d4aeb075ada16ad64448a985704bc69cd427a2245ce835/simplejson-3.20.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:438680ddde57ea87161a4824e8de04387b328ad51cfdf1eaf723623a3014b7aa", size = 144568, upload-time = "2025-09-26T16:27:53.41Z" }, + { url = "https://files.pythonhosted.org/packages/60/c7/30bae30424ace8cd791ca660fed454ed9479233810fe25c3f3eab3d9dc7b/simplejson-3.20.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:cac78470ae68b8d8c41b6fca97f5bf8e024ca80d5878c7724e024540f5cdaadb", size = 146239, upload-time = "2025-09-26T16:27:54.502Z" }, + { url = "https://files.pythonhosted.org/packages/79/3e/7f3b7b97351c53746e7b996fcd106986cda1954ab556fd665314756618d2/simplejson-3.20.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7524e19c2da5ef281860a3d74668050c6986be15c9dd99966034ba47c68828c2", size = 154497, upload-time = "2025-09-26T16:27:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/1d/48/7241daa91d0bf19126589f6a8dcbe8287f4ed3d734e76fd4a092708947be/simplejson-3.20.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0e9b6d845a603b2eef3394eb5e21edb8626cd9ae9a8361d14e267eb969dbe413", size = 148069, upload-time = "2025-09-26T16:27:57.039Z" }, + { url = "https://files.pythonhosted.org/packages/e6/f4/ef18d2962fe53e7be5123d3784e623859eec7ed97060c9c8536c69d34836/simplejson-3.20.2-cp311-cp311-win32.whl", hash = "sha256:47d8927e5ac927fdd34c99cc617938abb3624b06ff86e8e219740a86507eb961", size = 74158, upload-time = "2025-09-26T16:27:58.265Z" }, + { url = "https://files.pythonhosted.org/packages/35/fd/3d1158ecdc573fdad81bf3cc78df04522bf3959758bba6597ba4c956c74d/simplejson-3.20.2-cp311-cp311-win_amd64.whl", hash = "sha256:ba4edf3be8e97e4713d06c3d302cba1ff5c49d16e9d24c209884ac1b8455520c", size = 75911, upload-time = "2025-09-26T16:27:59.292Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9e/1a91e7614db0416885eab4136d49b7303de20528860ffdd798ce04d054db/simplejson-3.20.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:4376d5acae0d1e91e78baeba4ee3cf22fbf6509d81539d01b94e0951d28ec2b6", size = 93523, upload-time = "2025-09-26T16:28:00.356Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2b/d2413f5218fc25608739e3d63fe321dfa85c5f097aa6648dbe72513a5f12/simplejson-3.20.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f8fe6de652fcddae6dec8f281cc1e77e4e8f3575249e1800090aab48f73b4259", size = 75844, upload-time = "2025-09-26T16:28:01.756Z" }, + { url = "https://files.pythonhosted.org/packages/ad/f1/efd09efcc1e26629e120fef59be059ce7841cc6e1f949a4db94f1ae8a918/simplejson-3.20.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:25ca2663d99328d51e5a138f22018e54c9162438d831e26cfc3458688616eca8", size = 75655, upload-time = "2025-09-26T16:28:03.037Z" }, + { url = "https://files.pythonhosted.org/packages/97/ec/5c6db08e42f380f005d03944be1af1a6bd501cc641175429a1cbe7fb23b9/simplejson-3.20.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:12a6b2816b6cab6c3fd273d43b1948bc9acf708272074c8858f579c394f4cbc9", size = 150335, upload-time = "2025-09-26T16:28:05.027Z" }, + { url = "https://files.pythonhosted.org/packages/81/f5/808a907485876a9242ec67054da7cbebefe0ee1522ef1c0be3bfc90f96f6/simplejson-3.20.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac20dc3fcdfc7b8415bfc3d7d51beccd8695c3f4acb7f74e3a3b538e76672868", size = 158519, upload-time = "2025-09-26T16:28:06.5Z" }, + { url = "https://files.pythonhosted.org/packages/66/af/b8a158246834645ea890c36136584b0cc1c0e4b83a73b11ebd9c2a12877c/simplejson-3.20.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:db0804d04564e70862ef807f3e1ace2cc212ef0e22deb1b3d6f80c45e5882c6b", size = 148571, upload-time = "2025-09-26T16:28:07.715Z" }, + { url = "https://files.pythonhosted.org/packages/20/05/ed9b2571bbf38f1a2425391f18e3ac11cb1e91482c22d644a1640dea9da7/simplejson-3.20.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:979ce23ea663895ae39106946ef3d78527822d918a136dbc77b9e2b7f006237e", size = 152367, upload-time = "2025-09-26T16:28:08.921Z" }, + { url = "https://files.pythonhosted.org/packages/81/2c/bad68b05dd43e93f77994b920505634d31ed239418eb6a88997d06599983/simplejson-3.20.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a2ba921b047bb029805726800819675249ef25d2f65fd0edb90639c5b1c3033c", size = 150205, upload-time = "2025-09-26T16:28:10.086Z" }, + { url = "https://files.pythonhosted.org/packages/69/46/90c7fc878061adafcf298ce60cecdee17a027486e9dce507e87396d68255/simplejson-3.20.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:12d3d4dc33770069b780cc8f5abef909fe4a3f071f18f55f6d896a370fd0f970", size = 151823, upload-time = "2025-09-26T16:28:11.329Z" }, + { url = "https://files.pythonhosted.org/packages/ab/27/b85b03349f825ae0f5d4f780cdde0bbccd4f06c3d8433f6a3882df887481/simplejson-3.20.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:aff032a59a201b3683a34be1169e71ddda683d9c3b43b261599c12055349251e", size = 158997, upload-time = "2025-09-26T16:28:12.917Z" }, + { url = "https://files.pythonhosted.org/packages/71/ad/d7f3c331fb930638420ac6d236db68e9f4c28dab9c03164c3cd0e7967e15/simplejson-3.20.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:30e590e133b06773f0dc9c3f82e567463df40598b660b5adf53eb1c488202544", size = 154367, upload-time = "2025-09-26T16:28:14.393Z" }, + { url = "https://files.pythonhosted.org/packages/f0/46/5c67324addd40fa2966f6e886cacbbe0407c03a500db94fb8bb40333fcdf/simplejson-3.20.2-cp312-cp312-win32.whl", hash = "sha256:8d7be7c99939cc58e7c5bcf6bb52a842a58e6c65e1e9cdd2a94b697b24cddb54", size = 74285, upload-time = "2025-09-26T16:28:15.931Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c9/5cc2189f4acd3a6e30ffa9775bf09b354302dbebab713ca914d7134d0f29/simplejson-3.20.2-cp312-cp312-win_amd64.whl", hash = "sha256:2c0b4a67e75b945489052af6590e7dca0ed473ead5d0f3aad61fa584afe814ab", size = 75969, upload-time = "2025-09-26T16:28:17.017Z" }, + { url = "https://files.pythonhosted.org/packages/5e/9e/f326d43f6bf47f4e7704a4426c36e044c6bedfd24e072fb8e27589a373a5/simplejson-3.20.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:90d311ba8fcd733a3677e0be21804827226a57144130ba01c3c6a325e887dd86", size = 93530, upload-time = "2025-09-26T16:28:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/35/28/5a4b8f3483fbfb68f3f460bc002cef3a5735ef30950e7c4adce9c8da15c7/simplejson-3.20.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:feed6806f614bdf7f5cb6d0123cb0c1c5f40407ef103aa935cffaa694e2e0c74", size = 75846, upload-time = "2025-09-26T16:28:19.12Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4d/30dfef83b9ac48afae1cf1ab19c2867e27b8d22b5d9f8ca7ce5a0a157d8c/simplejson-3.20.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6b1d8d7c3e1a205c49e1aee6ba907dcb8ccea83651e6c3e2cb2062f1e52b0726", size = 75661, upload-time = "2025-09-26T16:28:20.219Z" }, + { url = "https://files.pythonhosted.org/packages/09/1d/171009bd35c7099d72ef6afd4bb13527bab469965c968a17d69a203d62a6/simplejson-3.20.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:552f55745044a24c3cb7ec67e54234be56d5d6d0e054f2e4cf4fb3e297429be5", size = 150579, upload-time = "2025-09-26T16:28:21.337Z" }, + { url = "https://files.pythonhosted.org/packages/61/ae/229bbcf90a702adc6bfa476e9f0a37e21d8c58e1059043038797cbe75b8c/simplejson-3.20.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c2da97ac65165d66b0570c9e545786f0ac7b5de5854d3711a16cacbcaa8c472d", size = 158797, upload-time = "2025-09-26T16:28:22.53Z" }, + { url = "https://files.pythonhosted.org/packages/90/c5/fefc0ac6b86b9108e302e0af1cf57518f46da0baedd60a12170791d56959/simplejson-3.20.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f59a12966daa356bf68927fca5a67bebac0033cd18b96de9c2d426cd11756cd0", size = 148851, upload-time = "2025-09-26T16:28:23.733Z" }, + { url = "https://files.pythonhosted.org/packages/43/f1/b392952200f3393bb06fbc4dd975fc63a6843261705839355560b7264eb2/simplejson-3.20.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133ae2098a8e162c71da97cdab1f383afdd91373b7ff5fe65169b04167da976b", size = 152598, upload-time = "2025-09-26T16:28:24.962Z" }, + { url = "https://files.pythonhosted.org/packages/f4/b4/d6b7279e52a3e9c0fa8c032ce6164e593e8d9cf390698ee981ed0864291b/simplejson-3.20.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7977640af7b7d5e6a852d26622057d428706a550f7f5083e7c4dd010a84d941f", size = 150498, upload-time = "2025-09-26T16:28:26.114Z" }, + { url = "https://files.pythonhosted.org/packages/62/22/ec2490dd859224326d10c2fac1353e8ad5c84121be4837a6dd6638ba4345/simplejson-3.20.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b530ad6d55e71fa9e93e1109cf8182f427a6355848a4ffa09f69cc44e1512522", size = 152129, upload-time = "2025-09-26T16:28:27.552Z" }, + { url = "https://files.pythonhosted.org/packages/33/ce/b60214d013e93dd9e5a705dcb2b88b6c72bada442a97f79828332217f3eb/simplejson-3.20.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:bd96a7d981bf64f0e42345584768da4435c05b24fd3c364663f5fbc8fabf82e3", size = 159359, upload-time = "2025-09-26T16:28:28.667Z" }, + { url = "https://files.pythonhosted.org/packages/99/21/603709455827cdf5b9d83abe726343f542491ca8dc6a2528eb08de0cf034/simplejson-3.20.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f28ee755fadb426ba2e464d6fcf25d3f152a05eb6b38e0b4f790352f5540c769", size = 154717, upload-time = "2025-09-26T16:28:30.288Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f9/dc7f7a4bac16cf7eb55a4df03ad93190e11826d2a8950052949d3dfc11e2/simplejson-3.20.2-cp313-cp313-win32.whl", hash = "sha256:472785b52e48e3eed9b78b95e26a256f59bb1ee38339be3075dad799e2e1e661", size = 74289, upload-time = "2025-09-26T16:28:31.809Z" }, + { url = "https://files.pythonhosted.org/packages/87/10/d42ad61230436735c68af1120622b28a782877146a83d714da7b6a2a1c4e/simplejson-3.20.2-cp313-cp313-win_amd64.whl", hash = "sha256:a1a85013eb33e4820286139540accbe2c98d2da894b2dcefd280209db508e608", size = 75972, upload-time = "2025-09-26T16:28:32.883Z" }, + { url = "https://files.pythonhosted.org/packages/05/5b/83e1ff87eb60ca706972f7e02e15c0b33396e7bdbd080069a5d1b53cf0d8/simplejson-3.20.2-py3-none-any.whl", hash = "sha256:3b6bb7fb96efd673eac2e4235200bfffdc2353ad12c54117e1e4e2fc485ac017", size = 57309, upload-time = "2025-09-26T16:29:35.312Z" }, +] + +[[package]] +name = "singledispatchmethod" +version = "1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/e0/20ecc21453d7f052ee04d0d9b3305798e0f6afc619885c9aaee4ba86b25c/singledispatchmethod-1.0.tar.gz", hash = "sha256:183a7fbeab53b9c9d182f8b8f9c2d7e109a7d40afaa30261d81dd8de68cd73bf", size = 6064, upload-time = "2019-08-12T07:28:17.216Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/ae/cb1b0901b332754245ce6ce900beef6e6c6a97b42e1e4eb78ab66379a6ba/singledispatchmethod-1.0-py2.py3-none-any.whl", hash = "sha256:ed4a794e701cbe415f0df32b71dbdb96d3a415701795bcfb5ee694f8c12418db", size = 4674, upload-time = "2021-09-30T13:17:20.27Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "smallworld-api" +version = "1.1.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ipython", version = "8.39.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "ipython", version = "9.10.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "ipython", version = "9.11.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "pandas", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/b9/2fbbdf844956da26dd8592a206cb74694b66959a77e39c37d8375192cb02/smallworld-api-1.1.4.tar.gz", hash = "sha256:2d01d5b6d1441ded4daf6631592c3bfcc7bf7c0307ccd2417d1e9cf00ee8ae9b", size = 19548, upload-time = "2024-01-17T11:16:26.602Z" } + +[[package]] +name = "soupsieve" +version = "2.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" }, +] + +[[package]] +name = "sqlitedict" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/12/9a/7620d1e9dcb02839ed6d4b14064e609cdd7a8ae1e47289aa0456796dd9ca/sqlitedict-2.1.0.tar.gz", hash = "sha256:03d9cfb96d602996f1d4c2db2856f1224b96a9c431bdd16e78032a72940f9e8c", size = 21846, upload-time = "2022-12-03T13:39:13.102Z" } + +[[package]] +name = "sqlparse" +version = "0.5.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815, upload-time = "2025-12-19T07:17:45.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" }, +] + +[[package]] +name = "stack-data" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asttokens" }, + { name = "executing" }, + { name = "pure-eval" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707, upload-time = "2023-09-30T13:58:05.479Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" }, +] + +[[package]] +name = "syndirella" +version = "5.0.7a0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "biopython" }, + { name = "fragmenstein" }, + { name = "glob2" }, + { name = "jupyter" }, + { name = "matplotlib" }, + { name = "numpy" }, + { name = "openpyxl" }, + { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "pandas", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pillow" }, + { name = "pyrosetta-installer" }, + { name = "pyyaml" }, + { name = "rdkit" }, + { name = "requests" }, + { name = "scikit-learn", version = "1.7.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "scikit-learn", version = "1.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "seaborn" }, + { name = "shortuuid" }, + { name = "xlrd" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e0/b7/bd425f95b94fed78b80ce527dfacd96a34ef990b863156e777443d5aeb90/syndirella-5.0.7a0.tar.gz", hash = "sha256:ddd6938dada2290128abbaf346af54333d5d72c6aaac173bf0e6f46ca750c0cb", size = 13117837, upload-time = "2026-02-27T15:49:26.68Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/c0/b175dad3f227aa6b7134fc307879790d0c828df50746f87c59b4447617b6/syndirella-5.0.7a0-py3-none-any.whl", hash = "sha256:8033f9b12808f50df0a1d47445f3c5f41609ca0db634fe8799dfdd8ef37aa9bc", size = 9577633, upload-time = "2026-02-27T15:49:24.526Z" }, +] + +[[package]] +name = "termcolor" +version = "3.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/79/cf31d7a93a8fdc6aa0fbb665be84426a8c5a557d9240b6239e9e11e35fc5/termcolor-3.3.0.tar.gz", hash = "sha256:348871ca648ec6a9a983a13ab626c0acce02f515b9e1983332b17af7979521c5", size = 14434, upload-time = "2025-12-29T12:55:21.882Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/d1/8bb87d21e9aeb323cc03034f5eaf2c8f69841e40e4853c2627edf8111ed3/termcolor-3.3.0-py3-none-any.whl", hash = "sha256:cf642efadaf0a8ebbbf4bc7a31cec2f9b5f21a9f726f4ccbb08192c9c26f43a5", size = 7734, upload-time = "2025-12-29T12:55:20.718Z" }, +] + +[[package]] +name = "terminado" +version = "0.18.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ptyprocess", marker = "os_name != 'nt'" }, + { name = "pywinpty", marker = "os_name == 'nt'" }, + { name = "tornado" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/11/965c6fd8e5cc254f1fe142d547387da17a8ebfd75a3455f637c663fb38a0/terminado-0.18.1.tar.gz", hash = "sha256:de09f2c4b85de4765f7714688fff57d3e75bad1f909b589fde880460c753fd2e", size = 32701, upload-time = "2024-03-12T14:34:39.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/9e/2064975477fdc887e47ad42157e214526dcad8f317a948dee17e1659a62f/terminado-0.18.1-py3-none-any.whl", hash = "sha256:a4468e1b37bb318f8a86514f65814e1afc977cf29b3992a4500d9dd305dcceb0", size = 14154, upload-time = "2024-03-12T14:34:36.569Z" }, +] + +[[package]] +name = "threadpoolctl" +version = "3.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274, upload-time = "2025-03-13T13:49:23.031Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" }, +] + +[[package]] +name = "tinycss2" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/fd/7a5ee21fd08ff70d3d33a5781c255cbe779659bd03278feb98b19ee550f4/tinycss2-1.4.0.tar.gz", hash = "sha256:10c0972f6fc0fbee87c3edb76549357415e94548c1ae10ebccdea16fb404a9b7", size = 87085, upload-time = "2024-10-24T14:58:29.895Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/34/ebdc18bae6aa14fbee1a08b63c015c72b64868ff7dae68808ab500c492e2/tinycss2-1.4.0-py3-none-any.whl", hash = "sha256:3a49cf47b7675da0b15d0c6e1df8df4ebd96e9394bb905a5775adb0d884c5289", size = 26610, upload-time = "2024-10-24T14:58:28.029Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "tomlkit" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/af/14b24e41977adb296d6bd1fb59402cf7d60ce364f90c890bd2ec65c43b5a/tomlkit-0.14.0.tar.gz", hash = "sha256:cf00efca415dbd57575befb1f6634c4f42d2d87dbba376128adb42c121b87064", size = 187167, upload-time = "2026-01-13T01:14:53.304Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/11/87d6d29fb5d237229d67973a6c9e06e048f01cf4994dee194ab0ea841814/tomlkit-0.14.0-py3-none-any.whl", hash = "sha256:592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680", size = 39310, upload-time = "2026-01-13T01:14:51.965Z" }, +] + +[[package]] +name = "tornado" +version = "6.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/37/1d/0a336abf618272d53f62ebe274f712e213f5a03c0b2339575430b8362ef2/tornado-6.5.4.tar.gz", hash = "sha256:a22fa9047405d03260b483980635f0b041989d8bcc9a313f8fe18b411d84b1d7", size = 513632, upload-time = "2025-12-15T19:21:03.836Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/a9/e94a9d5224107d7ce3cc1fab8d5dc97f5ea351ccc6322ee4fb661da94e35/tornado-6.5.4-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:d6241c1a16b1c9e4cc28148b1cda97dd1c6cb4fb7068ac1bedc610768dff0ba9", size = 443909, upload-time = "2025-12-15T19:20:48.382Z" }, + { url = "https://files.pythonhosted.org/packages/db/7e/f7b8d8c4453f305a51f80dbb49014257bb7d28ccb4bbb8dd328ea995ecad/tornado-6.5.4-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2d50f63dda1d2cac3ae1fa23d254e16b5e38153758470e9956cbc3d813d40843", size = 442163, upload-time = "2025-12-15T19:20:49.791Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b5/206f82d51e1bfa940ba366a8d2f83904b15942c45a78dd978b599870ab44/tornado-6.5.4-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1cf66105dc6acb5af613c054955b8137e34a03698aa53272dbda4afe252be17", size = 445746, upload-time = "2025-12-15T19:20:51.491Z" }, + { url = "https://files.pythonhosted.org/packages/8e/9d/1a3338e0bd30ada6ad4356c13a0a6c35fbc859063fa7eddb309183364ac1/tornado-6.5.4-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:50ff0a58b0dc97939d29da29cd624da010e7f804746621c78d14b80238669335", size = 445083, upload-time = "2025-12-15T19:20:52.778Z" }, + { url = "https://files.pythonhosted.org/packages/50/d4/e51d52047e7eb9a582da59f32125d17c0482d065afd5d3bc435ff2120dc5/tornado-6.5.4-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5fb5e04efa54cf0baabdd10061eb4148e0be137166146fff835745f59ab9f7f", size = 445315, upload-time = "2025-12-15T19:20:53.996Z" }, + { url = "https://files.pythonhosted.org/packages/27/07/2273972f69ca63dbc139694a3fc4684edec3ea3f9efabf77ed32483b875c/tornado-6.5.4-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9c86b1643b33a4cd415f8d0fe53045f913bf07b4a3ef646b735a6a86047dda84", size = 446003, upload-time = "2025-12-15T19:20:56.101Z" }, + { url = "https://files.pythonhosted.org/packages/d1/83/41c52e47502bf7260044413b6770d1a48dda2f0246f95ee1384a3cd9c44a/tornado-6.5.4-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:6eb82872335a53dd063a4f10917b3efd28270b56a33db69009606a0312660a6f", size = 445412, upload-time = "2025-12-15T19:20:57.398Z" }, + { url = "https://files.pythonhosted.org/packages/10/c7/bc96917f06cbee182d44735d4ecde9c432e25b84f4c2086143013e7b9e52/tornado-6.5.4-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6076d5dda368c9328ff41ab5d9dd3608e695e8225d1cd0fd1e006f05da3635a8", size = 445392, upload-time = "2025-12-15T19:20:58.692Z" }, + { url = "https://files.pythonhosted.org/packages/0c/1a/d7592328d037d36f2d2462f4bc1fbb383eec9278bc786c1b111cbbd44cfa/tornado-6.5.4-cp39-abi3-win32.whl", hash = "sha256:1768110f2411d5cd281bac0a090f707223ce77fd110424361092859e089b38d1", size = 446481, upload-time = "2025-12-15T19:21:00.008Z" }, + { url = "https://files.pythonhosted.org/packages/d6/6d/c69be695a0a64fd37a97db12355a035a6d90f79067a3cf936ec2b1dc38cd/tornado-6.5.4-cp39-abi3-win_amd64.whl", hash = "sha256:fa07d31e0cd85c60713f2b995da613588aa03e1303d75705dca6af8babc18ddc", size = 446886, upload-time = "2025-12-15T19:21:01.287Z" }, + { url = "https://files.pythonhosted.org/packages/50/49/8dc3fd90902f70084bd2cd059d576ddb4f8bb44c2c7c0e33a11422acb17e/tornado-6.5.4-cp39-abi3-win_arm64.whl", hash = "sha256:053e6e16701eb6cbe641f308f4c1a9541f91b6261991160391bfc342e8a551a1", size = 445910, upload-time = "2025-12-15T19:21:02.571Z" }, +] + +[[package]] +name = "traitlets" +version = "5.14.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/79/72064e6a701c2183016abbbfedaba506d81e30e232a68c9f0d6f6fcd1574/traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7", size = 161621, upload-time = "2024-04-19T11:11:49.746Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f", size = 85359, upload-time = "2024-04-19T11:11:46.763Z" }, +] + +[[package]] +name = "ty" +version = "0.0.21" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/20/2ba8fd9493c89c41dfe9dbb73bc70a28b28028463bc0d2897ba8be36230a/ty-0.0.21.tar.gz", hash = "sha256:a4c2ba5d67d64df8fcdefd8b280ac1149d24a73dbda82fa953a0dff9d21400ed", size = 5297967, upload-time = "2026-03-06T01:57:13.809Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/70/edf38bb37517531681d1c37f5df64744e5ad02673c02eb48447eae4bea08/ty-0.0.21-py3-none-linux_armv6l.whl", hash = "sha256:7bdf2f572378de78e1f388d24691c89db51b7caf07cf90f2bfcc1d6b18b70a76", size = 10299222, upload-time = "2026-03-06T01:57:16.64Z" }, + { url = "https://files.pythonhosted.org/packages/72/62/0047b0bd19afeefbc7286f20a5f78a2aa39f92b4d89853f0d7185ab89edc/ty-0.0.21-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7e9613994610431ab8625025bd2880dbcb77c5c9fabdd21134cda12d840a529d", size = 10130513, upload-time = "2026-03-06T01:57:29.93Z" }, + { url = "https://files.pythonhosted.org/packages/a2/20/0b93a9e91aaed23155780258cdfdb4726ef68b6985378ac069bc427291a0/ty-0.0.21-py3-none-macosx_11_0_arm64.whl", hash = "sha256:56d3b198b64dd0a19b2b66e257deaed2ecea568e722ae5352f3c6fb62027f89d", size = 9605425, upload-time = "2026-03-06T01:57:27.115Z" }, + { url = "https://files.pythonhosted.org/packages/ea/fd/9945e2fa2996a1287b1e1d7ce050e97e1f420233b271e770934bfa0880a0/ty-0.0.21-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d23d2c34f7a77d974bb08f0860ef700addc8a683d81a0319f71c08f87506cfd0", size = 10108298, upload-time = "2026-03-06T01:57:35.429Z" }, + { url = "https://files.pythonhosted.org/packages/52/e7/4ec52fcb15f3200826c9f048472c062549a05b0d1ef0b51f32d527b513c4/ty-0.0.21-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56b01fd2519637a4ca88344f61c96225f540c98ff18bca321d4eaa7bb0f7aa2f", size = 10121556, upload-time = "2026-03-06T01:57:03.242Z" }, + { url = "https://files.pythonhosted.org/packages/ee/c0/ad457be2a8abea0f25549598bd098554540ced66229488daa0d558dad3c8/ty-0.0.21-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e9de7e11c63c6afc40f3e9ba716374add171aee7fabc70b5146a510705c6d41b", size = 10603264, upload-time = "2026-03-06T01:56:52.134Z" }, + { url = "https://files.pythonhosted.org/packages/f8/5b/2ecc7a2175243a4bcb72f5298ae41feabbb93b764bb0dc45722f3752c2c2/ty-0.0.21-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:62f7f5b235c4f7876db305c36997aea07b7af29b1a068f373d0e2547e25f32ff", size = 11196428, upload-time = "2026-03-06T01:57:32.94Z" }, + { url = "https://files.pythonhosted.org/packages/37/f5/aff507d6a901f328ef96a298032b0c11aaaf950a146ed7dd3b5bf2cd3acf/ty-0.0.21-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ee8399f7c453a425291e6688efe430cfae7ab0ac4ffd50eba9f872bf878b54f6", size = 10866355, upload-time = "2026-03-06T01:56:57.831Z" }, + { url = "https://files.pythonhosted.org/packages/be/30/822bbcb92d55b65989aa7ed06d9585f28ade9c9447369194ed4b0fb3b5b9/ty-0.0.21-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:210e7568c9f886c4d01308d751949ee714ad7ad9d7d928d2ba90d329dd880367", size = 10738177, upload-time = "2026-03-06T01:57:11.256Z" }, + { url = "https://files.pythonhosted.org/packages/57/cc/46e7991b6469e93ac2c7e533a028983e402485580150ac864c56352a3a82/ty-0.0.21-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:53508e345b11569f78b21ba8e2b4e61df38a9754947fb3cd9f2ef574367338fb", size = 10079158, upload-time = "2026-03-06T01:57:00.516Z" }, + { url = "https://files.pythonhosted.org/packages/15/c2/0bbdadfbd008240f8f1a87dc877433cb3884436097926107ccf06e618199/ty-0.0.21-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:553e43571f4a35604c36cfd07d8b61a5eb7a714e3c67f8c4ff2cf674fefbaef9", size = 10150535, upload-time = "2026-03-06T01:57:08.815Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b5/2dbdb7b57b5362200ef0a39738ebd31331726328336def0143ac097ee59d/ty-0.0.21-py3-none-musllinux_1_2_i686.whl", hash = "sha256:666f6822e3b9200abfa7e95eb0ddd576460adb8d66b550c0ad2c70abc84a2048", size = 10319803, upload-time = "2026-03-06T01:57:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/72/84/70e52c0b7abc7c2086f9876ef454a73b161d3125315536d8d7e911c94ca4/ty-0.0.21-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a0854d008347ce4a5fb351af132f660a390ab2a1163444d075251d43e6f74b9b", size = 10826239, upload-time = "2026-03-06T01:57:21.727Z" }, + { url = "https://files.pythonhosted.org/packages/a1/8a/1f72480fd013bbc6cd1929002abbbcde9a0b08ead6a15154de9d7f7fa37e/ty-0.0.21-py3-none-win32.whl", hash = "sha256:bef3ab4c7b966bcc276a8ac6c11b63ba222d21355b48d471ea782c4104eee4e0", size = 9693196, upload-time = "2026-03-06T01:57:24.126Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f8/1104808b875c26c640e536945753a78562d606bef4e241d9dbf3d92477f6/ty-0.0.21-py3-none-win_amd64.whl", hash = "sha256:a709d576e5bea84b745d43058d8b9cd4f27f74a0b24acb4b0cbb7d3d41e0d050", size = 10668660, upload-time = "2026-03-06T01:56:55.06Z" }, + { url = "https://files.pythonhosted.org/packages/1b/b8/25e0adc404bbf986977657b25318991f93097b49f8aea640d93c0b0db68e/ty-0.0.21-py3-none-win_arm64.whl", hash = "sha256:f72047996598ac20553fb7e21ba5741e3c82dee4e9eadf10d954551a5fe09391", size = 10104161, upload-time = "2026-03-06T01:57:06.072Z" }, +] + +[[package]] +name = "typeguard" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2b/e8/66e25efcc18542d58706ce4e50415710593721aae26e794ab1dec34fb66f/typeguard-4.5.1.tar.gz", hash = "sha256:f6f8ecbbc819c9bc749983cc67c02391e16a9b43b8b27f15dc70ed7c4a007274", size = 80121, upload-time = "2026-02-19T16:09:03.392Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/88/b55b3117287a8540b76dbdd87733808d4d01c8067a3b339408c250bb3600/typeguard-4.5.1-py3-none-any.whl", hash = "sha256:44d2bf329d49a244110a090b55f5f91aa82d9a9834ebfd30bcc73651e4a8cc40", size = 36745, upload-time = "2026-02-19T16:09:01.6Z" }, +] + +[[package]] +name = "typer" +version = "0.24.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/24/cb09efec5cc954f7f9b930bf8279447d24618bb6758d4f6adf2574c41780/typer-0.24.1.tar.gz", hash = "sha256:e39b4732d65fbdcde189ae76cf7cd48aeae72919dea1fdfc16593be016256b45", size = 118613, upload-time = "2026-02-21T16:54:40.609Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl", hash = "sha256:112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e", size = 56085, upload-time = "2026-02-21T16:54:41.616Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspect" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/74/1789779d91f1961fa9438e9a8710cdae6bd138c80d7303996933d117264a/typing_inspect-0.9.0.tar.gz", hash = "sha256:b23fc42ff6f6ef6954e4852c1fb512cdd18dbea03134f91f856a95ccc9461f78", size = 13825, upload-time = "2023-05-24T20:25:47.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/f3/107a22063bf27bdccf2024833d3445f4eea42b2e598abfbd46f6a63b6cb0/typing_inspect-0.9.0-py3-none-any.whl", hash = "sha256:9ee6fc59062311ef8547596ab6b955e1b8aa46242d854bfc78f4f6b0eff35f9f", size = 8827, upload-time = "2023-05-24T20:25:45.287Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "tzdata" +version = "2025.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, +] + +[[package]] +name = "uri-template" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/31/c7/0336f2bd0bcbada6ccef7aaa25e443c118a704f828a0620c6fa0207c1b64/uri-template-1.3.0.tar.gz", hash = "sha256:0e00f8eb65e18c7de20d595a14336e9f337ead580c70934141624b6d1ffdacc7", size = 21678, upload-time = "2023-06-21T01:49:05.374Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/00/3fca040d7cf8a32776d3d81a00c8ee7457e00f80c649f1e4a863c8321ae9/uri_template-1.3.0-py3-none-any.whl", hash = "sha256:a44a133ea12d44a0c0f06d7d42a52d71282e77e2f937d8abd5655b8d56fc1363", size = 11140, upload-time = "2023-06-21T01:49:03.467Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "virtualenv" +version = "21.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "python-discovery" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/92/58199fe10049f9703c2666e809c4f686c54ef0a68b0f6afccf518c0b1eb9/virtualenv-21.2.0.tar.gz", hash = "sha256:1720dc3a62ef5b443092e3f499228599045d7fea4c79199770499df8becf9098", size = 5840618, upload-time = "2026-03-09T17:24:38.013Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/59/7d02447a55b2e55755011a647479041bc92a82e143f96a8195cb33bd0a1c/virtualenv-21.2.0-py3-none-any.whl", hash = "sha256:1bd755b504931164a5a496d217c014d098426cddc79363ad66ac78125f9d908f", size = 5825084, upload-time = "2026-03-09T17:24:35.378Z" }, +] + +[[package]] +name = "wcwidth" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/a2/8e3becb46433538a38726c948d3399905a4c7cabd0df578ede5dc51f0ec2/wcwidth-0.6.0.tar.gz", hash = "sha256:cdc4e4262d6ef9a1a57e018384cbeb1208d8abbc64176027e2c2455c81313159", size = 159684, upload-time = "2026-02-06T19:19:40.919Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189, upload-time = "2026-02-06T19:19:39.646Z" }, +] + +[[package]] +name = "webcolors" +version = "25.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/7a/eb316761ec35664ea5174709a68bbd3389de60d4a1ebab8808bfc264ed67/webcolors-25.10.0.tar.gz", hash = "sha256:62abae86504f66d0f6364c2a8520de4a0c47b80c03fc3a5f1815fedbef7c19bf", size = 53491, upload-time = "2025-10-31T07:51:03.977Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/cc/e097523dd85c9cf5d354f78310927f1656c422bd7b2613b2db3e3f9a0f2c/webcolors-25.10.0-py3-none-any.whl", hash = "sha256:032c727334856fc0b968f63daa252a1ac93d33db2f5267756623c210e57a4f1d", size = 14905, upload-time = "2025-10-31T07:51:01.778Z" }, +] + +[[package]] +name = "webencodings" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/02/ae6ceac1baeda530866a85075641cec12989bd8d31af6d5ab4a3e8c92f47/webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923", size = 9721, upload-time = "2017-04-05T20:21:34.189Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774, upload-time = "2017-04-05T20:21:32.581Z" }, +] + +[[package]] +name = "websocket-client" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, +] + +[[package]] +name = "werkzeug" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/f1/ee81806690a87dab5f5653c1f146c92bc066d7f4cebc603ef88eb9e13957/werkzeug-3.1.6.tar.gz", hash = "sha256:210c6bede5a420a913956b4791a7f4d6843a43b6fcee4dfa08a65e93007d0d25", size = 864736, upload-time = "2026-02-19T15:17:18.884Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/ec/d58832f89ede95652fd01f4f24236af7d32b70cab2196dfcc2d2fd13c5c2/werkzeug-3.1.6-py3-none-any.whl", hash = "sha256:7ddf3357bb9564e407607f988f683d72038551200c704012bb9a4c523d42f131", size = 225166, upload-time = "2026-02-19T15:17:17.475Z" }, +] + +[[package]] +name = "widgetsnbextension" +version = "4.0.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/f4/c67440c7fb409a71b7404b7aefcd7569a9c0d6bd071299bf4198ae7a5d95/widgetsnbextension-4.0.15.tar.gz", hash = "sha256:de8610639996f1567952d763a5a41af8af37f2575a41f9852a38f947eb82a3b9", size = 1097402, upload-time = "2025-11-01T21:15:55.178Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/0e/fa3b193432cfc60c93b42f3be03365f5f909d2b3ea410295cf36df739e31/widgetsnbextension-4.0.15-py3-none-any.whl", hash = "sha256:8156704e4346a571d9ce73b84bee86a29906c9abfd7223b7228a28899ccf3366", size = 2196503, upload-time = "2025-11-01T21:15:53.565Z" }, +] + +[[package]] +name = "wrapt" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/64/925f213fdcbb9baeb1530449ac71a4d57fc361c053d06bf78d0c5c7cd80c/wrapt-2.1.2.tar.gz", hash = "sha256:3996a67eecc2c68fd47b4e3c564405a5777367adfd9b8abb58387b63ee83b21e", size = 81678, upload-time = "2026-03-06T02:53:25.134Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/d2/387594fb592d027366645f3d7cc9b4d7ca7be93845fbaba6d835a912ef3c/wrapt-2.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4b7a86d99a14f76facb269dc148590c01aaf47584071809a70da30555228158c", size = 60669, upload-time = "2026-03-06T02:52:40.671Z" }, + { url = "https://files.pythonhosted.org/packages/c9/18/3f373935bc5509e7ac444c8026a56762e50c1183e7061797437ca96c12ce/wrapt-2.1.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a819e39017f95bf7aede768f75915635aa8f671f2993c036991b8d3bfe8dbb6f", size = 61603, upload-time = "2026-03-06T02:54:21.032Z" }, + { url = "https://files.pythonhosted.org/packages/c2/7a/32758ca2853b07a887a4574b74e28843919103194bb47001a304e24af62f/wrapt-2.1.2-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5681123e60aed0e64c7d44f72bbf8b4ce45f79d81467e2c4c728629f5baf06eb", size = 113632, upload-time = "2026-03-06T02:53:54.121Z" }, + { url = "https://files.pythonhosted.org/packages/1d/d5/eeaa38f670d462e97d978b3b0d9ce06d5b91e54bebac6fbed867809216e7/wrapt-2.1.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b8b28e97a44d21836259739ae76284e180b18abbb4dcfdff07a415cf1016c3e", size = 115644, upload-time = "2026-03-06T02:54:53.33Z" }, + { url = "https://files.pythonhosted.org/packages/e3/09/2a41506cb17affb0bdf9d5e2129c8c19e192b388c4c01d05e1b14db23c00/wrapt-2.1.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cef91c95a50596fcdc31397eb6955476f82ae8a3f5a8eabdc13611b60ee380ba", size = 112016, upload-time = "2026-03-06T02:54:43.274Z" }, + { url = "https://files.pythonhosted.org/packages/64/15/0e6c3f5e87caadc43db279724ee36979246d5194fa32fed489c73643ba59/wrapt-2.1.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dad63212b168de8569b1c512f4eac4b57f2c6934b30df32d6ee9534a79f1493f", size = 114823, upload-time = "2026-03-06T02:54:29.392Z" }, + { url = "https://files.pythonhosted.org/packages/56/b2/0ad17c8248f4e57bedf44938c26ec3ee194715f812d2dbbd9d7ff4be6c06/wrapt-2.1.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d307aa6888d5efab2c1cde09843d48c843990be13069003184b67d426d145394", size = 111244, upload-time = "2026-03-06T02:54:02.149Z" }, + { url = "https://files.pythonhosted.org/packages/ff/04/bcdba98c26f2c6522c7c09a726d5d9229120163493620205b2f76bd13c01/wrapt-2.1.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c87cf3f0c85e27b3ac7d9ad95da166bf8739ca215a8b171e8404a2d739897a45", size = 113307, upload-time = "2026-03-06T02:54:12.428Z" }, + { url = "https://files.pythonhosted.org/packages/0e/1b/5e2883c6bc14143924e465a6fc5a92d09eeabe35310842a481fb0581f832/wrapt-2.1.2-cp310-cp310-win32.whl", hash = "sha256:d1c5fea4f9fe3762e2b905fdd67df51e4be7a73b7674957af2d2ade71a5c075d", size = 57986, upload-time = "2026-03-06T02:54:26.823Z" }, + { url = "https://files.pythonhosted.org/packages/42/5a/4efc997bccadd3af5749c250b49412793bc41e13a83a486b2b54a33e240c/wrapt-2.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:d8f7740e1af13dff2684e4d56fe604a7e04d6c94e737a60568d8d4238b9a0c71", size = 60336, upload-time = "2026-03-06T02:54:18Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f5/a2bb833e20181b937e87c242645ed5d5aa9c373006b0467bfe1a35c727d0/wrapt-2.1.2-cp310-cp310-win_arm64.whl", hash = "sha256:1c6cc827c00dc839350155f316f1f8b4b0c370f52b6a19e782e2bda89600c7dc", size = 58757, upload-time = "2026-03-06T02:53:51.545Z" }, + { url = "https://files.pythonhosted.org/packages/c7/81/60c4471fce95afa5922ca09b88a25f03c93343f759aae0f31fb4412a85c7/wrapt-2.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:96159a0ee2b0277d44201c3b5be479a9979cf154e8c82fa5df49586a8e7679bb", size = 60666, upload-time = "2026-03-06T02:52:58.934Z" }, + { url = "https://files.pythonhosted.org/packages/6b/be/80e80e39e7cb90b006a0eaf11c73ac3a62bbfb3068469aec15cc0bc795de/wrapt-2.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:98ba61833a77b747901e9012072f038795de7fc77849f1faa965464f3f87ff2d", size = 61601, upload-time = "2026-03-06T02:53:00.487Z" }, + { url = "https://files.pythonhosted.org/packages/b0/be/d7c88cd9293c859fc74b232abdc65a229bb953997995d6912fc85af18323/wrapt-2.1.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:767c0dbbe76cae2a60dd2b235ac0c87c9cccf4898aef8062e57bead46b5f6894", size = 114057, upload-time = "2026-03-06T02:52:44.08Z" }, + { url = "https://files.pythonhosted.org/packages/ea/25/36c04602831a4d685d45a93b3abea61eca7fe35dab6c842d6f5d570ef94a/wrapt-2.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c691a6bc752c0cc4711cc0c00896fcd0f116abc253609ef64ef930032821842", size = 116099, upload-time = "2026-03-06T02:54:56.74Z" }, + { url = "https://files.pythonhosted.org/packages/5c/4e/98a6eb417ef551dc277bec1253d5246b25003cf36fdf3913b65cb7657a56/wrapt-2.1.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f3b7d73012ea75aee5844de58c88f44cf62d0d62711e39da5a82824a7c4626a8", size = 112457, upload-time = "2026-03-06T02:53:52.842Z" }, + { url = "https://files.pythonhosted.org/packages/cb/a6/a6f7186a5297cad8ec53fd7578533b28f795fdf5372368c74bd7e6e9841c/wrapt-2.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:577dff354e7acd9d411eaf4bfe76b724c89c89c8fc9b7e127ee28c5f7bcb25b6", size = 115351, upload-time = "2026-03-06T02:53:32.684Z" }, + { url = "https://files.pythonhosted.org/packages/97/6f/06e66189e721dbebd5cf20e138acc4d1150288ce118462f2fcbff92d38db/wrapt-2.1.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3d7b6fd105f8b24e5bd23ccf41cb1d1099796524bcc6f7fbb8fe576c44befbc9", size = 111748, upload-time = "2026-03-06T02:53:08.455Z" }, + { url = "https://files.pythonhosted.org/packages/ef/43/4808b86f499a51370fbdbdfa6cb91e9b9169e762716456471b619fca7a70/wrapt-2.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:866abdbf4612e0b34764922ef8b1c5668867610a718d3053d59e24a5e5fcfc15", size = 113783, upload-time = "2026-03-06T02:53:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/91/2c/a3f28b8fa7ac2cefa01cfcaca3471f9b0460608d012b693998cd61ef43df/wrapt-2.1.2-cp311-cp311-win32.whl", hash = "sha256:5a0a0a3a882393095573344075189eb2d566e0fd205a2b6414e9997b1b800a8b", size = 57977, upload-time = "2026-03-06T02:53:27.844Z" }, + { url = "https://files.pythonhosted.org/packages/3f/c3/2b1c7bd07a27b1db885a2fab469b707bdd35bddf30a113b4917a7e2139d2/wrapt-2.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:64a07a71d2730ba56f11d1a4b91f7817dc79bc134c11516b75d1921a7c6fcda1", size = 60336, upload-time = "2026-03-06T02:54:28.104Z" }, + { url = "https://files.pythonhosted.org/packages/ec/5c/76ece7b401b088daa6503d6264dd80f9a727df3e6042802de9a223084ea2/wrapt-2.1.2-cp311-cp311-win_arm64.whl", hash = "sha256:b89f095fe98bc12107f82a9f7d570dc83a0870291aeb6b1d7a7d35575f55d98a", size = 58756, upload-time = "2026-03-06T02:53:16.319Z" }, + { url = "https://files.pythonhosted.org/packages/4c/b6/1db817582c49c7fcbb7df6809d0f515af29d7c2fbf57eb44c36e98fb1492/wrapt-2.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ff2aad9c4cda28a8f0653fc2d487596458c2a3f475e56ba02909e950a9efa6a9", size = 61255, upload-time = "2026-03-06T02:52:45.663Z" }, + { url = "https://files.pythonhosted.org/packages/a2/16/9b02a6b99c09227c93cd4b73acc3678114154ec38da53043c0ddc1fba0dc/wrapt-2.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6433ea84e1cfacf32021d2a4ee909554ade7fd392caa6f7c13f1f4bf7b8e8748", size = 61848, upload-time = "2026-03-06T02:53:48.728Z" }, + { url = "https://files.pythonhosted.org/packages/af/aa/ead46a88f9ec3a432a4832dfedb84092fc35af2d0ba40cd04aea3889f247/wrapt-2.1.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c20b757c268d30d6215916a5fa8461048d023865d888e437fab451139cad6c8e", size = 121433, upload-time = "2026-03-06T02:54:40.328Z" }, + { url = "https://files.pythonhosted.org/packages/3a/9f/742c7c7cdf58b59085a1ee4b6c37b013f66ac33673a7ef4aaed5e992bc33/wrapt-2.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79847b83eb38e70d93dc392c7c5b587efe65b3e7afcc167aa8abd5d60e8761c8", size = 123013, upload-time = "2026-03-06T02:53:26.58Z" }, + { url = "https://files.pythonhosted.org/packages/e8/44/2c3dd45d53236b7ed7c646fcf212251dc19e48e599debd3926b52310fafb/wrapt-2.1.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f8fba1bae256186a83d1875b2b1f4e2d1242e8fac0f58ec0d7e41b26967b965c", size = 117326, upload-time = "2026-03-06T02:53:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/74/e2/b17d66abc26bd96f89dec0ecd0ef03da4a1286e6ff793839ec431b9fae57/wrapt-2.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e3d3b35eedcf5f7d022291ecd7533321c4775f7b9cd0050a31a68499ba45757c", size = 121444, upload-time = "2026-03-06T02:54:09.5Z" }, + { url = "https://files.pythonhosted.org/packages/3c/62/e2977843fdf9f03daf1586a0ff49060b1b2fc7ff85a7ea82b6217c1ae36e/wrapt-2.1.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:6f2c5390460de57fa9582bc8a1b7a6c86e1a41dfad74c5225fc07044c15cc8d1", size = 116237, upload-time = "2026-03-06T02:54:03.884Z" }, + { url = "https://files.pythonhosted.org/packages/88/dd/27fc67914e68d740bce512f11734aec08696e6b17641fef8867c00c949fc/wrapt-2.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7dfa9f2cf65d027b951d05c662cc99ee3bd01f6e4691ed39848a7a5fffc902b2", size = 120563, upload-time = "2026-03-06T02:53:20.412Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9f/b750b3692ed2ef4705cb305bd68858e73010492b80e43d2a4faa5573cbe7/wrapt-2.1.2-cp312-cp312-win32.whl", hash = "sha256:eba8155747eb2cae4a0b913d9ebd12a1db4d860fc4c829d7578c7b989bd3f2f0", size = 58198, upload-time = "2026-03-06T02:53:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/8e/b2/feecfe29f28483d888d76a48f03c4c4d8afea944dbee2b0cd3380f9df032/wrapt-2.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1c51c738d7d9faa0b3601708e7e2eda9bf779e1b601dce6c77411f2a1b324a63", size = 60441, upload-time = "2026-03-06T02:52:47.138Z" }, + { url = "https://files.pythonhosted.org/packages/44/e1/e328f605d6e208547ea9fd120804fcdec68536ac748987a68c47c606eea8/wrapt-2.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:c8e46ae8e4032792eb2f677dbd0d557170a8e5524d22acc55199f43efedd39bf", size = 58836, upload-time = "2026-03-06T02:53:22.053Z" }, + { url = "https://files.pythonhosted.org/packages/4c/7a/d936840735c828b38d26a854e85d5338894cda544cb7a85a9d5b8b9c4df7/wrapt-2.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:787fd6f4d67befa6fe2abdffcbd3de2d82dfc6fb8a6d850407c53332709d030b", size = 61259, upload-time = "2026-03-06T02:53:41.922Z" }, + { url = "https://files.pythonhosted.org/packages/5e/88/9a9b9a90ac8ca11c2fdb6a286cb3a1fc7dd774c00ed70929a6434f6bc634/wrapt-2.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4bdf26e03e6d0da3f0e9422fd36bcebf7bc0eeb55fdf9c727a09abc6b9fe472e", size = 61851, upload-time = "2026-03-06T02:52:48.672Z" }, + { url = "https://files.pythonhosted.org/packages/03/a9/5b7d6a16fd6533fed2756900fc8fc923f678179aea62ada6d65c92718c00/wrapt-2.1.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bbac24d879aa22998e87f6b3f481a5216311e7d53c7db87f189a7a0266dafffb", size = 121446, upload-time = "2026-03-06T02:54:14.013Z" }, + { url = "https://files.pythonhosted.org/packages/45/bb/34c443690c847835cfe9f892be78c533d4f32366ad2888972c094a897e39/wrapt-2.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16997dfb9d67addc2e3f41b62a104341e80cac52f91110dece393923c0ebd5ca", size = 123056, upload-time = "2026-03-06T02:54:10.829Z" }, + { url = "https://files.pythonhosted.org/packages/93/b9/ff205f391cb708f67f41ea148545f2b53ff543a7ac293b30d178af4d2271/wrapt-2.1.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:162e4e2ba7542da9027821cb6e7c5e068d64f9a10b5f15512ea28e954893a267", size = 117359, upload-time = "2026-03-06T02:53:03.623Z" }, + { url = "https://files.pythonhosted.org/packages/1f/3d/1ea04d7747825119c3c9a5e0874a40b33594ada92e5649347c457d982805/wrapt-2.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f29c827a8d9936ac320746747a016c4bc66ef639f5cd0d32df24f5eacbf9c69f", size = 121479, upload-time = "2026-03-06T02:53:45.844Z" }, + { url = "https://files.pythonhosted.org/packages/78/cc/ee3a011920c7a023b25e8df26f306b2484a531ab84ca5c96260a73de76c0/wrapt-2.1.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:a9dd9813825f7ecb018c17fd147a01845eb330254dff86d3b5816f20f4d6aaf8", size = 116271, upload-time = "2026-03-06T02:54:46.356Z" }, + { url = "https://files.pythonhosted.org/packages/98/fd/e5ff7ded41b76d802cf1191288473e850d24ba2e39a6ec540f21ae3b57cb/wrapt-2.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6f8dbdd3719e534860d6a78526aafc220e0241f981367018c2875178cf83a413", size = 120573, upload-time = "2026-03-06T02:52:50.163Z" }, + { url = "https://files.pythonhosted.org/packages/47/c5/242cae3b5b080cd09bacef0591691ba1879739050cc7c801ff35c8886b66/wrapt-2.1.2-cp313-cp313-win32.whl", hash = "sha256:5c35b5d82b16a3bc6e0a04349b606a0582bc29f573786aebe98e0c159bc48db6", size = 58205, upload-time = "2026-03-06T02:53:47.494Z" }, + { url = "https://files.pythonhosted.org/packages/12/69/c358c61e7a50f290958809b3c61ebe8b3838ea3e070d7aac9814f95a0528/wrapt-2.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:f8bc1c264d8d1cf5b3560a87bbdd31131573eb25f9f9447bb6252b8d4c44a3a1", size = 60452, upload-time = "2026-03-06T02:53:30.038Z" }, + { url = "https://files.pythonhosted.org/packages/8e/66/c8a6fcfe321295fd8c0ab1bd685b5a01462a9b3aa2f597254462fc2bc975/wrapt-2.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:3beb22f674550d5634642c645aba4c72a2c66fb185ae1aebe1e955fae5a13baf", size = 58842, upload-time = "2026-03-06T02:52:52.114Z" }, + { url = "https://files.pythonhosted.org/packages/da/55/9c7052c349106e0b3f17ae8db4b23a691a963c334de7f9dbd60f8f74a831/wrapt-2.1.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0fc04bc8664a8bc4c8e00b37b5355cffca2535209fba1abb09ae2b7c76ddf82b", size = 63075, upload-time = "2026-03-06T02:53:19.108Z" }, + { url = "https://files.pythonhosted.org/packages/09/a8/ce7b4006f7218248dd71b7b2b732d0710845a0e49213b18faef64811ffef/wrapt-2.1.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a9b9d50c9af998875a1482a038eb05755dfd6fe303a313f6a940bb53a83c3f18", size = 63719, upload-time = "2026-03-06T02:54:33.452Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e5/2ca472e80b9e2b7a17f106bb8f9df1db11e62101652ce210f66935c6af67/wrapt-2.1.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2d3ff4f0024dd224290c0eabf0240f1bfc1f26363431505fb1b0283d3b08f11d", size = 152643, upload-time = "2026-03-06T02:52:42.721Z" }, + { url = "https://files.pythonhosted.org/packages/36/42/30f0f2cefca9d9cbf6835f544d825064570203c3e70aa873d8ae12e23791/wrapt-2.1.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3278c471f4468ad544a691b31bb856374fbdefb7fee1a152153e64019379f015", size = 158805, upload-time = "2026-03-06T02:54:25.441Z" }, + { url = "https://files.pythonhosted.org/packages/bb/67/d08672f801f604889dcf58f1a0b424fe3808860ede9e03affc1876b295af/wrapt-2.1.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8914c754d3134a3032601c6984db1c576e6abaf3fc68094bb8ab1379d75ff92", size = 145990, upload-time = "2026-03-06T02:53:57.456Z" }, + { url = "https://files.pythonhosted.org/packages/68/a7/fd371b02e73babec1de6ade596e8cd9691051058cfdadbfd62a5898f3295/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ff95d4264e55839be37bafe1536db2ab2de19da6b65f9244f01f332b5286cfbf", size = 155670, upload-time = "2026-03-06T02:54:55.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/2d/9fe0095dfdb621009f40117dcebf41d7396c2c22dca6eac779f4c007b86c/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:76405518ca4e1b76fbb1b9f686cff93aebae03920cc55ceeec48ff9f719c5f67", size = 144357, upload-time = "2026-03-06T02:54:24.092Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b6/ec7b4a254abbe4cde9fa15c5d2cca4518f6b07d0f1b77d4ee9655e30280e/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c0be8b5a74c5824e9359b53e7e58bef71a729bacc82e16587db1c4ebc91f7c5a", size = 150269, upload-time = "2026-03-06T02:53:31.268Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6b/2fabe8ebf148f4ee3c782aae86a795cc68ffe7d432ef550f234025ce0cfa/wrapt-2.1.2-cp313-cp313t-win32.whl", hash = "sha256:f01277d9a5fc1862f26f7626da9cf443bebc0abd2f303f41c5e995b15887dabd", size = 59894, upload-time = "2026-03-06T02:54:15.391Z" }, + { url = "https://files.pythonhosted.org/packages/ca/fb/9ba66fc2dedc936de5f8073c0217b5d4484e966d87723415cc8262c5d9c2/wrapt-2.1.2-cp313-cp313t-win_amd64.whl", hash = "sha256:84ce8f1c2104d2f6daa912b1b5b039f331febfeee74f8042ad4e04992bd95c8f", size = 63197, upload-time = "2026-03-06T02:54:41.943Z" }, + { url = "https://files.pythonhosted.org/packages/c0/1c/012d7423c95d0e337117723eb8ecf73c622ce15a97847e84cf3f8f26cd7e/wrapt-2.1.2-cp313-cp313t-win_arm64.whl", hash = "sha256:a93cd767e37faeddbe07d8fc4212d5cba660af59bdb0f6372c93faaa13e6e679", size = 60363, upload-time = "2026-03-06T02:54:48.093Z" }, + { url = "https://files.pythonhosted.org/packages/1a/c7/8528ac2dfa2c1e6708f647df7ae144ead13f0a31146f43c7264b4942bf12/wrapt-2.1.2-py3-none-any.whl", hash = "sha256:b8fd6fa2b2c4e7621808f8c62e8317f4aae56e59721ad933bac5239d913cf0e8", size = 43993, upload-time = "2026-03-06T02:53:12.905Z" }, +] + +[[package]] +name = "xchem-hippo" +version = "1.0.0" +source = { virtual = "." } +dependencies = [ + { name = "apsw" }, + { name = "chardet" }, + { name = "django", version = "5.2.13", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "django", version = "6.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "django-rdkit" }, + { name = "gemmi" }, + { name = "hippo-plot" }, + { name = "hirsch" }, + { name = "ipywidgets" }, + { name = "jupyterlab" }, + { name = "molparse" }, + { name = "mpytools" }, + { name = "mrich" }, + { name = "neo4j" }, + { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "networkx", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "numpy" }, + { name = "openmm" }, + { name = "openpyxl" }, + { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "pandas", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pdbfixer" }, + { name = "psycopg", extra = ["binary"] }, + { name = "python-louvain" }, + { name = "rdkit" }, + { name = "requests" }, + { name = "scikit-learn", version = "1.7.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "scikit-learn", version = "1.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "syndirella" }, + { name = "typer" }, + { name = "yattag" }, +] + +[package.dev-dependencies] +dev = [ + { name = "commitizen" }, + { name = "mypy" }, + { name = "pre-commit" }, + { name = "pytest" }, + { name = "ruff" }, + { name = "ty" }, +] + +[package.metadata] +requires-dist = [ + { name = "apsw", specifier = ">=3.51" }, + { name = "chardet", specifier = ">=7" }, + { name = "django", specifier = ">=5.2.12" }, + { name = "django-rdkit", git = "https://github.com/rdkit/django-rdkit" }, + { name = "gemmi", specifier = ">=0.7.5" }, + { name = "hippo-plot", specifier = ">=0.0.1" }, + { name = "hirsch", specifier = ">=0.1" }, + { name = "ipywidgets", specifier = ">=8.1" }, + { name = "jupyterlab", specifier = ">=4.5" }, + { name = "molparse", specifier = ">=0.0.41" }, + { name = "mpytools", specifier = ">=0.0.21" }, + { name = "mrich", specifier = ">=1.0" }, + { name = "neo4j", specifier = ">=6.1.0" }, + { name = "networkx", specifier = ">=3.4" }, + { name = "numpy", specifier = ">=1.26.4" }, + { name = "openmm", specifier = ">=8.4" }, + { name = "openpyxl", specifier = ">=3.1" }, + { name = "pandas", specifier = ">=2.3" }, + { name = "pdbfixer", specifier = ">=1.12.0" }, + { name = "psycopg", extras = ["binary"], specifier = ">=3.3" }, + { name = "python-louvain", specifier = ">=0.16" }, + { name = "rdkit", specifier = "==2025.9.5" }, + { name = "requests", specifier = ">=2.32" }, + { name = "scikit-learn", specifier = ">=1.7" }, + { name = "syndirella", specifier = ">=5.0.7a0" }, + { name = "typer", specifier = ">=0.24.1" }, + { name = "yattag", specifier = ">=1.16" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "commitizen", specifier = ">=4.13.5,<5" }, + { name = "mypy", specifier = ">=1.19" }, + { name = "pre-commit", specifier = ">=4.5.1" }, + { name = "pytest", specifier = ">=9.0.2,<10" }, + { name = "ruff", specifier = ">=0.15.2" }, + { name = "ty", specifier = ">=0.0.19" }, +] + +[[package]] +name = "xlrd" +version = "2.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/07/5a/377161c2d3538d1990d7af382c79f3b2372e880b65de21b01b1a2b78691e/xlrd-2.0.2.tar.gz", hash = "sha256:08b5e25de58f21ce71dc7db3b3b8106c1fa776f3024c54e45b45b374e89234c9", size = 100167, upload-time = "2025-06-14T08:46:39.039Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/62/c8d562e7766786ba6587d09c5a8ba9f718ed3fa8af7f4553e8f91c36f302/xlrd-2.0.2-py2.py3-none-any.whl", hash = "sha256:ea762c3d29f4cca48d82df517b6d89fbce4db3107f9d78713e48cd321d5c9aa9", size = 96555, upload-time = "2025-06-14T08:46:37.766Z" }, +] + +[[package]] +name = "yattag" +version = "1.16.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/1a/d3b2a2b8f843f5e7138471c4a5c9172ef62bb41239aa4371784b7448110c/yattag-1.16.1.tar.gz", hash = "sha256:baa8f254e7ea5d3e0618281ad2ff5610e0e5360b3608e695c29bfb3b29d051f4", size = 29069, upload-time = "2024-11-02T22:38:30.443Z" } + +[[package]] +name = "zipp" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, +]