diff --git a/src/dials_rest/__init__.py b/src/dials_rest/__init__.py index e69de29..0e7cb1c 100644 --- a/src/dials_rest/__init__.py +++ b/src/dials_rest/__init__.py @@ -0,0 +1,7 @@ +from importlib.metadata import PackageNotFoundError, version + +try: + __version__ = version("dials_rest") +except PackageNotFoundError: + # package is not installed + __version__ = None diff --git a/src/dials_rest/main.py b/src/dials_rest/main.py index e77fd62..73da584 100644 --- a/src/dials_rest/main.py +++ b/src/dials_rest/main.py @@ -1,14 +1,64 @@ +""" +A RESTful API to a (limited) subset of DIALS functionality. + +Authorization is currently handled through simple [JSON Web +Tokens](https://jwt.io/). + +## Usage Example: Python/Requests + +Given an authentication token you can use this API from python as: + +```python +import pathlib +import requests + +AUTHENTICATION_TOKEN = pathlib.Path("/path/to/token").read_text().strip() +auth_header = {"Authorization": f"Bearer {AUTHENTICATION_TOKEN}"} +base_url = "https://example-dials-rest.com" + +# e.g. creating data collection +response = requests.post( + f"{base_url}/export_bitmaps", + headers=auth_header, + json={ + # ... body schema, see API below ... + }, +) +response.raise_for_status() # Check if the call was successful +``` +""" + import logging from fastapi import FastAPI +from fastapi.responses import RedirectResponse +from . import __version__ from .routers import find_spots, image from .settings import Settings logging.basicConfig(level=logging.INFO) + +tags_metadata = [ + { + "name": "images", + "description": "Generate bitmaps of diffraction images", + }, + { + "name": "spotfinding", + "description": "Run spotfinding on a diffraction image and report summary statistics", + }, +] + + settings = Settings() -app = FastAPI() +app = FastAPI( + title="DIALS REST API", + description=__doc__, + openapi_tags=tags_metadata, + version=__version__, +) app.include_router(find_spots.router) app.include_router(image.router) @@ -23,6 +73,6 @@ instrumentator.expose(app) -@app.get("/") -async def root(): - return {"message": "Welcome to the DIALS REST API!"} +@app.get("/", include_in_schema=False) +def get_root(): + return RedirectResponse("/docs") diff --git a/src/dials_rest/routers/find_spots.py b/src/dials_rest/routers/find_spots.py index 454b33b..26c5e05 100644 --- a/src/dials_rest/routers/find_spots.py +++ b/src/dials_rest/routers/find_spots.py @@ -2,7 +2,7 @@ import time from enum import Enum from pathlib import Path -from typing import Optional +from typing import Annotated import pydantic from cctbx import uctbx @@ -11,7 +11,7 @@ from dials.command_line.find_spots import phil_scope as find_spots_phil_scope from dials.util import phil from dxtbx.model.experiment_list import ExperimentListFactory -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Body, Depends from ..auth import JWTBearer @@ -20,7 +20,7 @@ router = APIRouter( prefix="/find_spots", - tags=["find_spots"], + tags=["spotfinding"], dependencies=[Depends(JWTBearer())], responses={404: {"description": "Not found"}}, ) @@ -32,11 +32,11 @@ class ThresholdAlgorithm(Enum): class PerImageAnalysisParameters(pydantic.BaseModel): filename: Path - d_min: Optional[pydantic.PositiveFloat] = None - d_max: Optional[pydantic.PositiveFloat] = 40 + d_min: pydantic.PositiveFloat | None = None + d_max: pydantic.PositiveFloat | None = 40 threshold_algorithm: ThresholdAlgorithm = ThresholdAlgorithm.DISPERSION disable_parallax_correction: bool = True - scan_range: Optional[tuple[int, int]] = None + scan_range: tuple[int, int] | None = None filter_ice: bool = True ice_rings_width: pydantic.NonNegativeFloat = 0.004 @@ -54,15 +54,58 @@ class PerImageAnalysisResults(pydantic.BaseModel): n_spots_no_ice: pydantic.NonNegativeInt n_spots_total: pydantic.NonNegativeInt total_intensity: pydantic.NonNegativeFloat - d_min_distl_method_1: Optional[float] = None - d_min_distl_method_2: Optional[float] = None - estimated_d_min: Optional[float] = None - noisiness_method_1: Optional[float] = None - noisiness_method_2: Optional[float] = None + d_min_distl_method_1: float | None = None + d_min_distl_method_2: float | None = None + estimated_d_min: float | None = None + noisiness_method_1: float | None = None + noisiness_method_2: float | None = None + + class Config: + schema_extra = { + "example": { + "n_spots_4A": 36, + "n_spots_no_ice": 44, + "n_spots_total": 49, + "total_intensity": 56848.0, + "d_min_distl_method_1": 4.234420130210043, + "d_min_distl_method_2": 4.053322019536269, + "estimated_d_min": 3.517157644985513, + "noisiness_method_1": 0.15019762845849802, + "noisiness_method_2": 0.46842105263157896, + } + } + + +find_spots_examples = { + "Single image example": { + "description": "Perform spotfinding on a single image with a high resolution cutoff of 3.5 Å", + "value": { + "filename": "/path/to/image_00001.cbf", + "d_min": 3.5, + }, + }, + "Image template example": { + "description": "Perform spotfinding on the second image matching the given filename template", + "value": { + "filename": "/path/to/image_#####.cbf", + "scan_range": [1, 1], + }, + }, + "Multi-image format example": { + "description": "Perform spotfinding on the fifth image of a NeXus file, filtering out spots at ice ring resolutions", + "value": { + "filename": "/path/to/master.h5", + "scan_range": [4, 4], + "filter_ice": True, + }, + }, +} @router.post("/") -async def find_spots(params: PerImageAnalysisParameters) -> PerImageAnalysisResults: +async def find_spots( + params: Annotated[PerImageAnalysisParameters, Body(examples=find_spots_examples)] +) -> PerImageAnalysisResults: if "#" in params.filename.stem: experiments = ExperimentListFactory.from_templates([params.filename]) else: diff --git a/src/dials_rest/routers/image.py b/src/dials_rest/routers/image.py index 2d407de..442703c 100644 --- a/src/dials_rest/routers/image.py +++ b/src/dials_rest/routers/image.py @@ -2,11 +2,12 @@ import time from enum import Enum from pathlib import Path +from typing import Annotated import pydantic from dials.command_line import export_bitmaps from dxtbx.model.experiment_list import ExperimentListFactory -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Body, Depends from fastapi.responses import FileResponse from ..auth import JWTBearer @@ -15,7 +16,7 @@ router = APIRouter( prefix="/export_bitmap", - tags=["image"], + tags=["images"], dependencies=[Depends(JWTBearer())], responses={404: {"description": "Not found"}}, ) @@ -62,8 +63,51 @@ class ExportBitmapParams(pydantic.BaseModel): resolution_rings: ResolutionRingsParams = ResolutionRingsParams() -@router.post("/") -async def image_as_bitmap(params: ExportBitmapParams): +image_as_bitmap_examples = { + "Single image example": { + "description": "Convert a cbf image to a png with binning of pixel to reduce overall image size", + "value": { + "filename": "/path/to/image_00001.cbf", + "binning": 4, + }, + }, + "Image template example": { + "description": "Generate a png for the second image matching the given filename template", + "value": { + "filename": "/path/to/image_#####.cbf", + "image_index": 2, + "binning": 4, + }, + }, + "Multi-image format example": { + "description": "Generate a png for the fifth image of a NeXus file, modifying the default colour scheme", + "value": { + "filename": "/path/to/master.h5", + "image_index": 5, + "binning": 4, + "colour_scheme": "inverse_greyscale", + }, + }, + "Resolution rings": { + "description": "Generate a png with resolution ring overlays", + "value": { + "filename": "/path/to/image_00001.cbf", + "binning": 2, + "resolution_rings": {"show": True, "number": 10}, + }, + }, +} + + +@router.post( + "/", + status_code=200, + response_class=FileResponse, + responses={200: {"description": "Asynchronously streams the file as the response"}}, +) +async def image_as_bitmap( + params: Annotated[ExportBitmapParams, Body(examples=image_as_bitmap_examples)] +) -> FileResponse: if "#" in params.filename.stem: # A filename template e.g. image_#####.cbf expts = ExperimentListFactory.from_templates([params.filename])