Skip to content

Commit

Permalink
Use FastAPI to provide rest endpoints (#135)
Browse files Browse the repository at this point in the history
* simple fastapi setup

* squashing commits; rebased onto master and modified some vscode settings. WIP.

* made application compatible with config changes

* moved starting the app into blueapi/service/main instead of cli. Added deprecation message for worker.

* responded to comments

* added some cli tests. Intend to add more...

* added mock as dependency to pass tests

* added more tests for cli and modified existing rest tests

* changed run to serve instead

* made teardown handler do nothing if handler not set

* removed redundant comment

* removed redundant string concatenation in test

* moved assert statement

* made minor changes in response to comments; using functools wraps and .json() instead of literal_eval in tests

* fixed minor issues; param ingestion for cli.py::run_plan and made fixtures for tests

* moved common parts of tests to conftest.py, made session scope fixtures

* fixed linting

* made minor changes in response to comments; changed dependencies

* modified pyproject toml to include all fastapi dependencies

* removed redundant fake_cli file which I just used for testing. Changed error message for deprecated worker command

* fixing tests

* added extra test for the handler

* made suggested changes

* Update src/blueapi/service/main.py

Co-authored-by: Callum Forrester <29771545+callumforrester@users.noreply.github.com>

* modified docs

---------

Co-authored-by: Callum Forrester <29771545+callumforrester@users.noreply.github.com>
  • Loading branch information
rosesyrett and callumforrester authored May 11, 2023
1 parent db1f3b7 commit 76a4e02
Show file tree
Hide file tree
Showing 17 changed files with 535 additions and 146 deletions.
2 changes: 1 addition & 1 deletion .devcontainer/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,4 @@ ENV PATH=/venv/bin:$PATH

# change this entrypoint if it is not the same as the repo
ENTRYPOINT ["blueapi"]
CMD ["worker"]
CMD ["serve"]
18 changes: 15 additions & 3 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,18 @@
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Debug Rest Service",
"type": "python",
"request": "launch",
"module": "uvicorn",
"args": [
"src.blueapi.rest.main:app",
"--reload"
],
"jinja": true,
"justMyCode": true
},
{
"name": "Debug Unit Test",
"type": "python",
Expand All @@ -22,13 +34,13 @@
},
},
{
"name": "Worker Service",
"name": "Run Service",
"type": "python",
"request": "launch",
"justMyCode": false,
"module": "blueapi.cli",
"args": [
"worker"
"serve"
]
},
{
Expand Down Expand Up @@ -68,4 +80,4 @@
]
}
]
}
}
4 changes: 2 additions & 2 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,5 @@
"source.organizeImports": true
},
"esbonio.server.enabled": true,
"esbonio.sphinx.confDir": ""
}
"esbonio.sphinx.confDir": "",
}
2 changes: 1 addition & 1 deletion docs/developer/tutorials/dev-run.rst
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ Start the worker from the command line or vscode:

.. code:: shell
blueapi worker
blueapi serve
.. tab-item:: VSCode

Expand Down
6 changes: 6 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ dependencies = [
"scanspec",
"PyYAML",
"click",
"fastapi[all]",
"uvicorn",
"requests",
]
dynamic = ["version"]
license.file = "LICENSE"
Expand All @@ -44,6 +47,9 @@ dev = [
"tox-direct",
"types-mock",
"types-PyYAML",
"types-requests",
"types-urllib3",
"mock",
]

[project.scripts]
Expand Down
1 change: 1 addition & 0 deletions src/blueapi/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@
__version__ = version("blueapi")
del version


__all__ = ["__version__"]
93 changes: 57 additions & 36 deletions src/blueapi/cli/cli.py
Original file line number Diff line number Diff line change
@@ -1,27 +1,32 @@
import json
import logging
from functools import wraps
from pathlib import Path
from pprint import pprint
from typing import Optional

import click
import requests
from requests.exceptions import ConnectionError

from blueapi import __version__
from blueapi.config import ApplicationConfig, ConfigLoader
from blueapi.messaging import StompMessagingTemplate

from .amq import AmqClient
from .updates import CliEventRenderer
from blueapi.service.main import start


@click.group(invoke_without_command=True)
@click.version_option(version=__version__, prog_name="blueapi")
@click.option("-c", "--config", type=Path, help="Path to configuration YAML file")
@click.pass_context
def main(ctx, config: Optional[Path]) -> None:
def main(ctx: click.Context, config: Optional[Path]) -> None:
# if no command is supplied, run with the options passed

config_loader = ConfigLoader(ApplicationConfig)
if config is not None:
config_loader.use_values_from_yaml(config)
if config.exists():
config_loader.use_values_from_yaml(config)
else:
raise FileNotFoundError(f"Cannot find file: {config}")

ctx.ensure_object(dict)
ctx.obj["config"] = config_loader.load()
Expand All @@ -30,58 +35,74 @@ def main(ctx, config: Optional[Path]) -> None:
print("Please invoke subcommand!")


@main.command(name="worker")
@main.command(name="serve")
@click.pass_obj
def start_worker(obj: dict) -> None:
from blueapi.service import start
def start_application(obj: dict):
start(obj["config"])

config: ApplicationConfig = obj["config"]
start(config)

@main.command(name="worker", deprecated=True)
@click.pass_obj
def deprecated_start_application(obj: dict):
print("Please use serve command instead.\n")
start(obj["config"])


@main.group()
@click.pass_context
def controller(ctx) -> None:
def controller(ctx: click.Context) -> None:
if ctx.invoked_subcommand is None:
print("Please invoke subcommand!")
return

ctx.ensure_object(dict)
config: ApplicationConfig = ctx.obj["config"]
logging.basicConfig(level=config.logging.level)
client = AmqClient(StompMessagingTemplate.autoconfigured(config.stomp))
ctx.obj["client"] = client
client.app.connect()


def check_connection(func):
@wraps(func)
def wrapper(*args, **kwargs):
try:
func(*args, **kwargs)
except ConnectionError:
print("Failed to establish connection to FastAPI server.")

return wrapper


@controller.command(name="plans")
@click.pass_context
def get_plans(ctx) -> None:
client: AmqClient = ctx.obj["client"]
plans = client.get_plans()
print("PLANS")
for plan in plans.plans:
print("\t" + plan.name)
@check_connection
@click.pass_obj
def get_plans(obj: dict) -> None:
config: ApplicationConfig = obj["config"]

resp = requests.get(f"http://{config.api.host}:{config.api.port}/plans")
print(f"Response returned with {resp.status_code}: ")
pprint(resp.json())


@controller.command(name="devices")
@click.pass_context
def get_devices(ctx) -> None:
client: AmqClient = ctx.obj["client"]
print(client.get_devices().devices)
@check_connection
@click.pass_obj
def get_devices(obj: dict) -> None:
config: ApplicationConfig = obj["config"]

resp = requests.get(f"http://{config.api.host}:{config.api.port}/devices")
print(f"Response returned with {resp.status_code}: ")
pprint(resp.json())


@controller.command(name="run")
@click.argument("name", type=str)
@click.option("-p", "--parameters", type=str, help="Parameters as valid JSON")
@click.pass_context
def run_plan(ctx, name: str, parameters: str) -> None:
client: AmqClient = ctx.obj["client"]
renderer = CliEventRenderer()
client.run_plan(
name,
json.loads(parameters),
renderer.on_worker_event,
renderer.on_progress_event,
timeout=120.0,
@check_connection
@click.pass_obj
def run_plan(obj: dict, name: str, parameters: str) -> None:
config: ApplicationConfig = obj["config"]

resp = requests.put(
f"http://{config.api.host}:{config.api.port}/task/{name}",
json={"name": name, "params": json.loads(parameters)},
)
print(f"Response returned with {resp.status_code}: ")
7 changes: 7 additions & 0 deletions src/blueapi/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ class LoggingConfig(BlueapiBaseModel):
level: LogLevel = "INFO"


class RestConfig(BlueapiBaseModel):
host: str = "localhost"
port: int = 8000


class ApplicationConfig(BlueapiBaseModel):
"""
Config for the worker application as a whole. Root of
Expand All @@ -44,13 +49,15 @@ class ApplicationConfig(BlueapiBaseModel):
stomp: StompConfig = Field(default_factory=StompConfig)
env: EnvironmentConfig = Field(default_factory=EnvironmentConfig)
logging: LoggingConfig = Field(default_factory=LoggingConfig)
api: RestConfig = Field(default_factory=RestConfig)

def __eq__(self, other: object) -> bool:
if isinstance(other, ApplicationConfig):
return (
(self.stomp == other.stomp)
& (self.env == other.env)
& (self.logging == other.logging)
& (self.api == other.api)
)
return False

Expand Down
3 changes: 1 addition & 2 deletions src/blueapi/service/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
from .app import start
from .model import DeviceModel, PlanModel

__all__ = ["start", "PlanModel", "DeviceModel"]
__all__ = ["PlanModel", "DeviceModel"]
99 changes: 0 additions & 99 deletions src/blueapi/service/app.py

This file was deleted.

Loading

0 comments on commit 76a4e02

Please sign in to comment.