diff --git a/.gitignore b/.gitignore index b7faf40..7172413 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ +# postman +.postman +postman + # Byte-compiled / optimized / DLL files __pycache__/ *.py[codz] @@ -182,9 +186,9 @@ cython_debug/ .abstra/ # Visual Studio Code -# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore -# and can be added to the global gitignore or merged into this file. However, if you prefer, +# and can be added to the global gitignore or merged into this file. However, if you prefer, # you could uncomment the following to ignore the entire vscode folder # .vscode/ diff --git a/docker-compose.yaml b/docker-compose.yaml index 44e08c6..670b3f4 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -6,4 +6,11 @@ services: PORT: 1654 ports: - 1654:1654 - tty: true + depends_on: + - redis + restart: unless-stopped + redis: + image: "redis:alpine" + ports: + - "6379:6379" + restart: unless-stopped diff --git a/dockerfile b/dockerfile index 5e73b92..a3ef7f5 100644 --- a/dockerfile +++ b/dockerfile @@ -1,12 +1,12 @@ FROM ubuntu:22.04 -# Install openbabel dependencies +# Install openbabel and python RUN apt-get update \ - && apt-get install --yes --quiet --no-install-recommends \ - openbabel \ - python3 \ - python3-pip \ - && apt-get clean && rm -rf /var/lib/apt/lists/* + && apt-get install --yes --quiet --no-install-recommends \ + openbabel \ + python3 \ + python3-pip \ + && apt-get clean && rm -rf /var/lib/apt/lists/* WORKDIR /var/local @@ -17,4 +17,4 @@ RUN /usr/bin/python3 -m pip install --no-cache-dir -r requirements.txt COPY src . -CMD fastapi run app.py --port ${PORT} \ No newline at end of file +CMD uvicorn app:app --host 0.0.0.0 --port ${PORT} --log-level warning diff --git a/requirements.txt b/requirements.txt index 653b571..27b1657 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ openbabel-wheel==3.1.1.22 fastapi[all]==0.121.3 fastapi-cache2==0.2.2 -redis==7.1.0 \ No newline at end of file +redis==7.1.0 +pyscf==2.11.0 diff --git a/src/app.py b/src/app.py index 13c861e..cc8e6db 100644 --- a/src/app.py +++ b/src/app.py @@ -2,39 +2,103 @@ from collections.abc import AsyncIterator from contextlib import asynccontextmanager from fastapi import FastAPI -from starlette.requests import Request -from starlette.responses import Response +from fastapi.exceptions import HTTPException +from fastapi.middleware.cors import CORSMiddleware +# from fastapi.requests import Request +from fastapi.responses import Response from fastapi_cache import FastAPICache from fastapi_cache.backends.redis import RedisBackend from fastapi_cache.decorator import cache - -from redis import asyncio as aioredis +from logging_config import logger from openbabel import pybel +from pyscf import gto # pyright: ignore +from redis import asyncio as aioredis +from request_response_models import BasicError, ConvertRequest, MolFileModel @asynccontextmanager async def lifespan(_: FastAPI) -> AsyncIterator[None]: - redis = aioredis.from_url("redis://localhost") + redis = aioredis.from_url("redis://redis:6379") FastAPICache.init(RedisBackend(redis), prefix="fastapi-cache") + pybel.ob.obErrorLog.SetOutputLevel(0) # NOTE removes logging yield app = FastAPI(lifespan=lifespan) +origins = [ + "http://localhost", + "http://localhost:8001", +] + +app.add_middleware( + CORSMiddleware, + allow_origins=origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +@app.post( + "/convert", + responses={ + 200: {"description": "Coversion Successful", "model": MolFileModel}, + 400: {"description": "Item created", "model": BasicError}, + }, +) +def convert_molecule(req: ConvertRequest): + try: + # Rad the string and format from request + mol = pybel.readstring(req.format, req.text) + + mol.addh() + + if req.convert_3d: + mol.make3D() + if req.optimize_geometry: + mol.localopt() + + logger.info(f"Converting from format {req.format}") + + # To compute the Number of electrons and orbitals + atoms = [(atom.atomicnum, atom.coords) for atom in mol.atoms] + + mol_pyscf = gto.Mole() + mol_pyscf.atom = atoms + mol_pyscf.basis = "sto-3g" # simple basis + mol_pyscf.charge = mol.charge # default net charge + mol_pyscf.spin = mol.spin - 1 # multiplicity - 1 + mol_pyscf.build() + + # Export the resulting molecule + mol.OBMol.SetTitle( + f"Charge={mol.charge} Multiplicity={mol.spin} Electrons={mol_pyscf.nelectron} Orbitals={mol_pyscf.nao_nr()}" + ) + + mol2string = mol.write("xyz") + return Response({"molfile": mol2string}, 200) + except Exception as e: + raise HTTPException(status_code=400, detail={"error": str(e)}) + + @app.get("/informats") +@cache() async def get_informats(): return pybel.informats -@app.get("/") -@cache(expire=60) -async def index(): - return dict(hello="world") - - - - - - +@app.get( + "/health", + responses={ + 200: { + "description": "Is the service running?", + "content": {"application/json": {"example": {"status": "healthy"}}}, + }, + }, +) +@cache() +async def health_check(): + return {"status": "healthy"} diff --git a/src/logging_config.py b/src/logging_config.py new file mode 100644 index 0000000..5e8839c --- /dev/null +++ b/src/logging_config.py @@ -0,0 +1,38 @@ +import logging +import logging.config +import os + +ROOT_LEVEL = os.environ.get("PROD", "INFO") + +LOGGING_CONFIG = { + "version": 1, + "disable_existing_loggers": True, + "formatters": { + "standard": {"format": "%(asctime)s [%(levelname)s] %(name)s: %(message)s"}, + }, + "handlers": { + "default": { + "formatter": "standard", + "class": "logging.StreamHandler", + "stream": "ext://sys.stdout", # Default is stderr + }, + }, + "loggers": { + "": { # root logger + "level": ROOT_LEVEL, # "INFO", + "handlers": ["default"], + "propagate": False, + }, + "uvicorn.error": { + "level": "WARNING", + "handlers": ["default"], + }, + "uvicorn.access": { + "level": "DEBUG", + "handlers": ["default"], + }, + }, +} + +logging.config.dictConfig(LOGGING_CONFIG) +logger = logging.getLogger(__name__) diff --git a/src/request_response_models.py b/src/request_response_models.py new file mode 100644 index 0000000..d7618b7 --- /dev/null +++ b/src/request_response_models.py @@ -0,0 +1,16 @@ +from pydantic import BaseModel + + +class ConvertRequest(BaseModel): + text: str + format: str # e.g. "mol", "smi", "sdf", "inchi", etc. + convert_3d: bool = False + optimize_geometry: bool = False + + +class MolFileModel(BaseModel): + molfile: str + + +class BasicError(BaseModel): + error: str