From ac2f9bdb79f7b538bbf177ba2da09832de4cb351 Mon Sep 17 00:00:00 2001 From: Ruslan Khyurri Date: Wed, 20 Jul 2022 17:47:12 +0300 Subject: [PATCH] Add resource for basic and acid PKA calculations --- api/http/.pre-commit-config.yaml | 17 +- api/http/Dockerfile | 2 +- api/http/indigo_service/indigo_http.py | 118 ++++++------- api/http/indigo_service/jsonapi.py | 63 ++++++- api/http/indigo_service/service.py | 19 +- api/http/mypy.ini | 1 + api/http/pylintrc | 5 +- api/http/requirements_dev.txt | 1 + api/http/tests/test_indigo_http.py | 233 +++++-------------------- 9 files changed, 186 insertions(+), 273 deletions(-) diff --git a/api/http/.pre-commit-config.yaml b/api/http/.pre-commit-config.yaml index 1fa3a112e2..157aab826b 100644 --- a/api/http/.pre-commit-config.yaml +++ b/api/http/.pre-commit-config.yaml @@ -22,15 +22,20 @@ repos: args: [ "-sn", - "--rcfile=api/http/pylintrc", - "indigo_service", - "tests" + "--rcfile=api/http/pylintrc" ] language: system types: [ python ] - -- repo: https://github.com/pre-commit/mirrors-mypy - rev: "v0.910" +- repo: local hooks: - id: mypy + name: mypy + language: system + entry: mypy + args: + [ + "--config-file", + "api/http/mypy.ini" + ] + types: [ python ] diff --git a/api/http/Dockerfile b/api/http/Dockerfile index 4b8985d078..305f93eb61 100644 --- a/api/http/Dockerfile +++ b/api/http/Dockerfile @@ -16,7 +16,7 @@ # limitations under the License. # For test purposes -FROM python:3.9-slim-buster as indigo_service_dev +FROM python:3.10-slim-buster as indigo_service_dev RUN mkdir -p /opt/indigo WORKDIR /opt/indigo diff --git a/api/http/indigo_service/indigo_http.py b/api/http/indigo_service/indigo_http.py index 5cfb8c8ce0..deee0a96b1 100644 --- a/api/http/indigo_service/indigo_http.py +++ b/api/http/indigo_service/indigo_http.py @@ -60,6 +60,7 @@ def compounds( jsonapi.ValidationRequest, jsonapi.CompoundConvertRequest, jsonapi.RenderRequest, + jsonapi.PKARequest, ], ) -> List[Tuple[str, jsonapi.CompoundFormat]]: return service.extract_pairs(request.data.attributes.compound) @@ -81,7 +82,7 @@ def targets( @app.get(f"{BASE_URL_INDIGO}/version", response_model=jsonapi.VersionResponse) -def indigo_version() -> jsonapi.VersionResponse: +async def indigo_version() -> jsonapi.VersionResponse: return jsonapi.make_version_response(indigo().version()) @@ -90,7 +91,7 @@ def indigo_version() -> jsonapi.VersionResponse: response_model=jsonapi.SimilaritiesResponse, response_model_exclude_unset=True, ) -def similarities( +async def similarities( request: jsonapi.SimilaritiesRequest, ) -> jsonapi.SimilaritiesResponse: @@ -122,7 +123,7 @@ def similarities( response_model=jsonapi.MatchResponse, # type: ignore response_model_exclude_unset=True, ) -def exact_match(request: jsonapi.MatchRequest) -> jsonapi.MatchResponse: +async def exact_match(request: jsonapi.MatchRequest) -> jsonapi.MatchResponse: compound, *_ = service.extract_compounds(source(request)) target_pairs = targets(request) target_compounds = service.extract_compounds(target_pairs) @@ -149,7 +150,7 @@ def exact_match(request: jsonapi.MatchRequest) -> jsonapi.MatchResponse: response_model=jsonapi.CompoundResponse, response_model_exclude_unset=True, ) -def convert( +async def convert( request: jsonapi.CompoundConvertRequest, ) -> jsonapi.CompoundResponse: compound, *_ = service.extract_compounds( @@ -165,7 +166,9 @@ def convert( response_model=jsonapi.ValidationResponse, response_model_exclude_unset=True, ) -def validate(request: jsonapi.ValidationRequest) -> jsonapi.ValidationResponse: +async def validate( + request: jsonapi.ValidationRequest +) -> jsonapi.ValidationResponse: compound, *_ = service.extract_compounds(compounds(request)) validations = request.data.attributes.validations results = {} @@ -179,7 +182,7 @@ def validate(request: jsonapi.ValidationRequest) -> jsonapi.ValidationResponse: response_model=jsonapi.DescriptorResponse, response_model_exclude_unset=True, ) -def descriptors( +async def descriptors( request: jsonapi.DescriptorRequest, ) -> jsonapi.DescriptorResponse: compound, *_ = service.extract_compounds(compounds(request)) @@ -190,12 +193,54 @@ def descriptors( return jsonapi.make_descriptor_response(results) +@app.post(f"{BASE_URL_INDIGO}/pka", response_model=jsonapi.PKAResponse) +async def pka(request: jsonapi.PKARequest) -> jsonapi.PKAResponse: + compound, *_ = service.extract_compounds(compounds(request)) + if request.data.attributes.pka_model == jsonapi.PKAModel.ADVANCED: + indigo().setOption("pKa-model", jsonapi.PKAModel.ADVANCED.value) + pka_model_level = request.data.attributes.pka_model_level + pka_model_min_level = request.data.attributes.pka_model_min_level + pka_values = jsonapi.AtomToValueContainer() + pka_type = request.data.attributes.pka_type + pka_model_build = request.data.attributes.pka_model_build + + if pka_model_build is not None: + service.build_pka_model( + pka_model_build.sdf, + pka_model_build.max_level, + pka_model_build.threshold, + ) + + for atom in compound.iterateAtoms(): + if pka_type == jsonapi.PKAType.BASIC: + pka_values.mappings.append( + jsonapi.AtomToValueMapping( + index=atom.index(), + symbol=atom.symbol(), + value=compound.getBasicPkaValue( + atom, pka_model_level, pka_model_min_level + ), + ) + ) + elif pka_type == jsonapi.PKAType.ACID: + pka_values.mappings.append( + jsonapi.AtomToValueMapping( + index=atom.index(), + symbol=atom.symbol(), + value=compound.getAcidPkaValue( + atom, pka_model_level, pka_model_min_level + ), + ) + ) + return jsonapi.make_pka_response(pka_values) + + @app.post( f"{BASE_URL_INDIGO}/commonBits", response_model=jsonapi.CommonBitsResponse, response_model_exclude_unset=True, ) -def common_bits( +async def common_bits( request: jsonapi.CommonBitsRequest, ) -> jsonapi.CommonBitsResponse: compound, *_ = service.extract_compounds(source(request)) @@ -209,9 +254,7 @@ def common_bits( @app.post(f"{BASE_URL_INDIGO}/render", response_model=jsonapi.RenderResponse) -def render( - request: jsonapi.RenderRequest, -) -> jsonapi.RenderResponse: +async def render(request: jsonapi.RenderRequest,) -> jsonapi.RenderResponse: compound, *_ = service.extract_compounds(compounds(request)) output_format = request.data.attributes.outputFormat indigo_renderer = IndigoRenderer(indigo()) @@ -245,58 +288,3 @@ def run_debug() -> None: if __name__ == "__main__": run_debug() - - -# TODO: /indigo/render with alternative responses types -# @app.post(f"{BASE_URL_INDIGO}/render") -# def render( -# request: jsonapi.RenderRequest, -# ) -> Union[Response, FileResponse]: -# compound, *_ = service.extract_compounds(compounds(request)) -# output_format = request.data.attributes.outputFormat -# indigo_renderer = IndigoRenderer(indigo()) -# indigo().setOption( -# "render-output-format", jsonapi.rendering_formats.get(output_format) -# ) -# options = request.data.attributes.options -# if options: -# for option, value in options.items(): -# if option == "render-output-format": -# raise HTTPException( -# status_code=400, detail="Choose only one output format" -# ) -# indigo().setOption(option, value) -# if output_format == "image/png": -# result = indigo_renderer.renderToBuffer(compound).tobytes() -# response = Response( -# result, -# headers={"Content-Type": "image/png"} -# ) -# elif output_format == "image/png;base64": -# result = indigo_renderer.renderToBuffer(compound).tobytes() -# decoded_image = base64.b64encode(result).decode("utf-8") -# image_base64 = f"data:image/png;base64,{decoded_image}" -# response = Response( -# image_base64, -# headers={"Content-Type": "image/png"} -# ) -# elif output_format == "image/svg+xml": -# result = indigo_renderer.renderToString(compound) -# response = Response( -# result, -# headers={"Content-Type": "image/svg+xml"} -# ) -# elif output_format == "application/pdf": -# result = indigo_renderer.renderToBuffer(compound).tobytes() -# response = Response( -# result, headers={ -# "Content-Type": "application/pdf", -# "Content-Disposition": "attachment; filename=mol.pdf" -# } -# ) -# else: -# raise HTTPException( -# status_code=400, -# detail=f"Incorrect output format {output_format}" -# ) -# return response diff --git a/api/http/indigo_service/jsonapi.py b/api/http/indigo_service/jsonapi.py index 2a78df4f56..b6601794b5 100644 --- a/api/http/indigo_service/jsonapi.py +++ b/api/http/indigo_service/jsonapi.py @@ -515,8 +515,6 @@ class Descriptors(str, Enum): MONOISOTOPIC_MASS = "monoisotopicMass" MOST_ABUNDANT_MASS = "mostAbundantMass" NAME = "name" - GET_ACID_PKA_VALUE = "acidPkaValue" - GET_BASIC_PKA_VALUE = "basicPkaValue" GROSS_FORMULA = "grossFormula" @@ -563,8 +561,6 @@ class DescriptorResultModel(BaseModel): monoisotopicMass: Optional[str] mostAbundantMass: Optional[str] name: Optional[str] - getAcidPkaValue: Optional[str] - getBasicPkaValue: Optional[str] grossFormula: Optional[str] @@ -585,6 +581,62 @@ def make_descriptor_response( ] +# PKA models + + +class PKAType(str, Enum): + ACID = "acid" + BASIC = "basic" + + +class PKAModel(str, Enum): + SIMPLE = "simple" + ADVANCED = "advanced" + + +class PKAModelBuild(BaseModel): + sdf: str + max_level: int + threshold: float + + +class PKARequestModel(BaseModel): + compound: CompoundObject + pka_model_build: Optional[PKAModelBuild] + pka_model: PKAModel + pka_type: PKAType = PKAType.ACID + pka_model_level: int = 0 + pka_model_min_level: int = 0 + + +class PKARequestModelType(BaseModel): + __root__ = "pka" + + +class AtomToValueMapping(BaseModel): + index: int + symbol: str + value: float + + +class AtomToValueContainer(BaseModel): + mappings: list[AtomToValueMapping] = [] + + +class PKAResultModelType(BaseModel): + __root__ = "pkaResult" + + +PKARequest = Request[PKARequestModelType, PKARequestModel] +PKAResponse = Response[PKAResultModelType, AtomToValueContainer] + + +def make_pka_response(mappings: AtomToValueContainer) -> PKAResponse: + return PKAResponse( + **{"data": {"type": "pkaResult", "attributes": mappings}} + ) + + # Render @@ -618,8 +670,7 @@ class RenderResultModel(BaseModel): def make_render_response( - raw_image: bytes, - output_format: str, + raw_image: bytes, output_format: str ) -> RenderResponse: if output_format == "image/svg+xml": str_image = raw_image.decode("utf-8") diff --git a/api/http/indigo_service/service.py b/api/http/indigo_service/service.py index bac29cf029..882ff24b75 100644 --- a/api/http/indigo_service/service.py +++ b/api/http/indigo_service/service.py @@ -16,14 +16,19 @@ # limitations under the License. # +import logging +import tempfile +from pathlib import Path from typing import List, Optional, Tuple, Union -from indigo import IndigoObject +from indigo import IndigoException, IndigoObject from indigo.inchi import IndigoInchi from indigo_service import jsonapi from indigo_service.indigo_tools import indigo +logger = logging.getLogger(__name__) + def extract_compounds( pairs: List[Tuple[str, jsonapi.CompoundFormat]], @@ -142,3 +147,15 @@ def get_descriptor( compound: IndigoObject, descriptor: jsonapi.Descriptors ) -> str: return str(getattr(compound, descriptor.value)()) + + +def build_pka_model(sdf: str, max_level: int, threshold: float) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + model_file = Path(tmp_dir) / "model.sdf" + with open(model_file, mode="w", encoding="utf-8") as sdf_file: + sdf_file.write(sdf) + try: + indigo().buildPkaModel(max_level, threshold, str(model_file)) + except IndigoException as err: + logger.exception(err) + raise IndigoException("Unable to build pka model") from err diff --git a/api/http/mypy.ini b/api/http/mypy.ini index 072fef58ff..7f90aadf6b 100644 --- a/api/http/mypy.ini +++ b/api/http/mypy.ini @@ -2,6 +2,7 @@ python_version = 3.9 strict = True plugins = pydantic.mypy +exclude = dist [mypy-indigo.*] ignore_missing_imports = True diff --git a/api/http/pylintrc b/api/http/pylintrc index 195d11e495..564e731888 100644 --- a/api/http/pylintrc +++ b/api/http/pylintrc @@ -1,6 +1,9 @@ [FORMAT] max-line-length = 79 +[REPORTS] +output-format=parseable + [MESSAGE CONTROL] disable = missing-module-docstring, missing-class-docstring, @@ -9,4 +12,4 @@ disable = missing-module-docstring, wrong-import-order, too-few-public-methods, no-name-in-module, - fixme + fixmem diff --git a/api/http/requirements_dev.txt b/api/http/requirements_dev.txt index c5101f66cf..4e5bc4d930 100644 --- a/api/http/requirements_dev.txt +++ b/api/http/requirements_dev.txt @@ -19,3 +19,4 @@ types-requests==2.26.3 urllib3==1.26.7 isort==5.9.3 pylint==2.11.1 +mypy==0.910 diff --git a/api/http/tests/test_indigo_http.py b/api/http/tests/test_indigo_http.py index 8f7c13caeb..a1fb731080 100644 --- a/api/http/tests/test_indigo_http.py +++ b/api/http/tests/test_indigo_http.py @@ -4,7 +4,8 @@ import os import pathlib import xml.etree.ElementTree as elTree -from typing import Any, BinaryIO, Dict, List, Optional +from pathlib import Path +from typing import Any, BinaryIO, Dict, List, Optional, Union import PyPDF2 import pytest @@ -181,6 +182,43 @@ def test_base_descriptors() -> None: assert response.status_code == 200 +def get_model() -> dict[str, Union[str, int, float]]: + path = Path(os.getcwd()).parent / Path( + "tests/integration/tests/calc/molecules/PkaModel.sdf" + ) + with open(path, mode="r", encoding="utf-8") as sdf_file: + return {"sdf": sdf_file.read(), "max_level": 10, "threshold": 0.5} + + +def test_pka_with_model() -> None: + """ + This test covers only response structure + We need to add more structures and different pka_types + to this test in future + """ + response = client.post( + "/indigo/pka", + json={ + "data": { + "type": "pka", + "attributes": { + "compound": { + "structure": "c1(Cl)c(Cl)cc(cc1Cl)O", + "format": "auto", + }, + "pka_model_build": get_model(), + "pka_model": "simple", + "pka_type": "acid", + }, + } + }, + ).json() + assert response["data"]["type"] == "pkaResult" + for atom in response["data"]["attributes"]["mappings"]: + if atom["symbol"] == "O": + assert f"{atom['value']:.3}" == "2.17" + + # Render @@ -261,9 +299,7 @@ def test_render_correct_png_base64_options() -> None: response = client.post( "/indigo/render", json=render_request( - structure="C", - output_format="image/png", - options=correct_options, + structure="C", output_format="image/png", options=correct_options ), ) png_image = Image.open(decode_image(response, "image/png")) @@ -342,192 +378,3 @@ def test_render_incorrect_options() -> None: assert error_msg == ( 'option manager: Cannot recognize "whatever" as a color value' ) - - -# TODO: /indigo/render with alternative responses types -# def render_request( -# structure: str, output_format: str, options: Dict = None -# ) -> Dict: -# return { -# "data": { -# "type": "render", -# "attributes": { -# "compound": {"structure": structure, "format": "auto"}, -# "outputFormat": output_format, -# "options": options, -# }, -# } -# } -# -# -# correct_options = { -# "render-coloring": 1, -# "render-bond-line-width": 1.5, -# "render-background-color": "255, 179, 179", -# "render-comment": "COMMENT", -# "render-comment-alignment": "center", -# "render-image-height": 400, -# "render-image-width": 500 -# } -# -# incorrect_options = { -# "render-atom-ids-visible": 11, -# "render-highlight-color": "doesn't matter", -# "render-bond-length": "true", -# "render-catalysts-placement": "set something", -# } -# -# -# def test_render_png() -> None: -# response = client.post( -# "/indigo/render", -# json=render_request( -# structure="C", output_format="image/png" -# ) -# ) -# image = Image.open(io.BytesIO(response.content)) -# assert response.status_code == 200 -# assert response.headers["Content-Type"] == "image/png" -# assert image.format == "PNG" -# -# -# def test_render_svg() -> None: -# response = client.post( -# "/indigo/render", -# json=render_request( -# structure="C", output_format="image/svg+xml" -# ) -# ) -# image = elTree.fromstring(response.content) -# assert response.status_code == 200 -# assert response.headers["Content-Type"] == "image/svg+xml" -# assert image.tag == "{http://www.w3.org/2000/svg}svg" -# -# -# def test_render_png_base64() -> None: -# response = client.post( -# "/indigo/render", -# json=render_request( -# structure="C", output_format="image/png;base64" -# ) -# ) -# base64_image = response.content.replace(b"data:image/png;base64,", b"") -# decoded_image = base64.b64decode(base64_image) -# png_image = Image.open(io.BytesIO(decoded_image)) -# assert response.status_code == 200 -# assert response.headers["Content-Type"] == "image/png" -# assert png_image.format == "PNG" -# -# -# def test_render_pdf() -> None: -# response = client.post( -# "/indigo/render", -# json=render_request( -# structure="C", output_format="application/pdf" -# ) -# ) -# read_file = PyPDF2.PdfFileReader(io.BytesIO(response.content)) -# pages_number = read_file.numPages -# assert response.status_code == 200 -# assert response.headers["Content-Type"] == "application/pdf" -# assert ( -# response.headers["Content-Disposition"] -# == "attachment; filename=mol.pdf" -# ) -# assert pages_number == 1 -# -# -# def test_render_correct_png_options() -> None: -# response = client.post( -# "/indigo/render", -# json=render_request( -# structure="C", -# output_format="image/png", -# options=correct_options -# ) -# ) -# image = Image.open(io.BytesIO(response.content)) -# assert response.status_code == 200 -# assert image.size == (500, 400) -# -# -# def test_render_correct_svg_options() -> None: -# response = client.post( -# "/indigo/render", -# json=render_request( -# structure="C", -# output_format="image/svg+xml", -# options=correct_options, -# ) -# ) -# image = elTree.fromstring(response.content) -# width = image.attrib.get("width") -# height = image.attrib.get("height") -# assert response.status_code == 200 -# assert width == "500" -# assert height == "400" -# -# -# def test_render_correct_png_base64_options() -> None: -# response = client.post( -# "/indigo/render", -# json=render_request( -# structure="C", -# output_format="image/png;base64", -# options=correct_options, -# ) -# ) -# base64_image = response.content.replace(b"data:image/png;base64,", b"") -# decoded_image = base64.b64decode(base64_image) -# png_image = Image.open(io.BytesIO(decoded_image)) -# assert response.status_code == 200 -# assert png_image.size == (500, 400) -# -# -# def test_render_correct_pdf_options() -> None: -# response = client.post( -# "/indigo/render", -# json=render_request( -# structure="C", -# output_format="application/pdf", -# options=correct_options, -# ) -# ) -# read_file = PyPDF2.PdfFileReader(io.BytesIO(response.content)) -# page_height = read_file.getPage(0).mediaBox.getHeight() -# page_width = read_file.getPage(0).mediaBox.getWidth() -# assert response.status_code == 200 -# assert page_height == 400 -# assert page_width == 500 -# -# -# def test_render_incorrect_format() -> None: -# response = client.post( -# "/indigo/render", -# json=render_request(structure="C", output_format="bla") -# ) -# assert response.status_code == 400 -# -# -# def test_render_two_output_formats() -> None: -# response = client.post( -# "/indigo/render", -# json=render_request( -# structure="C", -# output_format="image/png", -# options={"render-output-format": "image/svg+xml"} -# ) -# ) -# assert response.status_code == 400 -# -# -# def test_render_incorrect_options() -> None: -# response = client.post( -# "/indigo/render", -# json=render_request( -# structure="C", -# output_format="image/svg+xml", -# options=incorrect_options, -# ) -# ) -# assert response.status_code == 400