Guide
The simplest application
Let's create our first FPS application. Enter the following code in a file called simple.py
:
from fps import Module
class Main(Module):
def __init__(self, name, **kwargs):
super().__init__(name)
self.config = kwargs
async def start(self):
print(self.config["greeting"])
async def stop(self):
print(self.config["farewell"])
And enter in the terminal:
fps simple:Main --set greeting="Hello, World!" --set farewell="See you later!"
This should print Hello, World!
and hang forever, which means that the application is running. To exit, press Ctrl-C. This should now print See you later!
and return to the terminal prompt.
What happened?
- By entering
fps simple:Main
, we told FPS to run the module calledMain
in thesimple.py
file. - Options
--set greeting="Hello, World!"
and--set farewell="See you later!"
told FPS to pass parameter keysgreeting
andfarewell
toMain.__init__
's keyword arguments, with values"Hello, World!"
and"See you later!"
, respectively. - In its startup phase (
start
method),Main
prints thegreeting
parameter value. - After starting, the application runs until it is stopped. Pressing Ctrl-C stops the application, calling its teardown phase.
- In its teardown phase (
stop
method),Main
prints thefarewell
parameter value.
Sharing objects between modules
Now let's see how we can share objects between modules. Enter the following code in a file called share.py
:
from anyio import Event, sleep
from fps import Module
class Main(Module):
def __init__(self, name):
super().__init__(name)
self.add_module(Publisher, "publisher")
self.add_module(Consumer, "consumer")
class Publisher(Module):
async def start(self):
self.shared = Event() # the object to share
self.put(self.shared, Event) # publish the shared object as type Event
print("Published:", self.shared.is_set())
await self.shared.wait() # wait for the shared object to be updated
self.exit_app() # force the application to exit
async def stop(self):
print("Got:", self.shared.is_set())
class Consumer(Module):
def __init__(self, name, wait=0):
super().__init__(name)
self.wait = float(wait)
async def start(self):
shared = await self.get(Event) # request an object of type Event
print("Acquired:", shared.is_set())
await sleep(self.wait) # wait before updating the shared object
shared.set() # update the shared object
print("Updated:", shared.is_set())
And enter in the terminal:
fps share:Main
You should see in the terminal:
Published: False
Acquired: False
Updated: True
Got: True
Sharing objects between modules is based on types: a module (Consumer
) requests an object of a given type (Event
) with await self.get
, and it eventually acquires it when another module (Publisher
) publishes an object of this type with self.put
. It is the same object that they are sharing, so if Consumer
changes the object, Publisher
sees it immediatly.
The Consumer
's default value for parameter wait
is 0, which means that the shared object will be updated right away. If we set it to 0.5 seconds:
fps share:Main --set consumer.wait=0.5
You should see that the application hangs for half a second after the shared object is acquired. This illustrate that we can configure any nested module in the application, just by providing the path to its parameter in the CLI. If we provide a wrong parameter name, we get a nice error:
fps share:Main --set consumer.wrong_parameter=0.5
RuntimeError: Cannot instantiate module 'root_module.consumer': Consumer.__init__() got an unexpected keyword argument 'wrong_parameter'
A pluggable web server
FPS comes with a FastAPIModule
that publishes a FastAPI
application. This FastAPI
object can be shared with other modules, which can add routes to it. As part of its startup phase, FastAPIModule
serves the FastAPI
application with a web server. Enter the following code in a file called server.py
:
from fastapi import FastAPI
from fps import Module
from fps.web.fastapi import FastAPIModule
from pydantic import BaseModel
class Main(Module):
def __init__(self, name):
super().__init__(name)
self.add_module(FastAPIModule, "fastapi")
self.add_module(Router, "router")
class Router(Module):
def __init__(self, name, **kwargs):
super().__init__(name)
self.config = Config(**kwargs)
async def prepare(self):
app = await self.get(FastAPI)
@app.get("/")
def read_root():
return {self.config.key: self.config.value}
class Config(BaseModel):
key: str = "count"
value: int = 3
And enter in the terminal:
fps server:Main
Now if you open a browser at http://127.0.0.1:8000
, you should see:
{"count":3}
Note that Router
has a prepare
method. It is similar to the start
method, be it is executed just before. Typically, this is used by modules like FastAPIModule
which must give a chance to every other module to register their routes on the FastAPI
application, before running the server in start
, because routes cannot be added once the server has started.
See how Router
uses a Pydantic model Config
to validate its configuration. With this, running the application with a wrong type will not work:
fps main:Main --set router.value=foo
# RuntimeError: Cannot instantiate module 'root_module.router': 1 validation error for Config
# value
# Input should be a valid integer, unable to parse string as an integer [type=int_parsing, input_value='foo', input_type=str]
# For further information visit https://errors.pydantic.dev/2.10/v/int_parsing
Jupyverse uses FastAPIModule
in order to compose a Jupyter server from swappable pluggins.
A declarative application
It is possible to configure an application entirely as a Python dictionary or a JSON file. Let's rewrite the previous example in router.py
, and just keep the code for the Router
module:
from fastapi import FastAPI
from fps import Module
from pydantic import BaseModel
class Router(Module):
def __init__(self, name, **kwargs):
super().__init__(name)
self.config = Config(**kwargs)
async def prepare(self):
app = await self.get(FastAPI)
@app.get("/")
def read_root():
return {self.config.key: self.config.value}
class Config(BaseModel):
key: str = "count"
value: int = 3
Now we can write a config.json
file like so:
{
"main": {
"type": "fps_module",
"modules": {
"fastapi": {
"type": "fps.web.fastapi:FastAPIModule"
},
"router": {
"type": "router:Router",
"config": {
"value": 7
}
}
}
}
}
And launch our application with:
fps --config config.json
Note that the type
field in config.json
can be a path to a module, like fps.web.fastapi:FastAPIModule
or router:Router
, or a module name registered in the fps.modules
entry-point group, like fps_module
which is a base FPS Module
.
A note on concurrency
The following Module
methods are run as background tasks:
prepare
start
stop
FPS will consider each of them to have completed if they run to completion, or if they call self.done()
. Let's consider the following example:
from anyio import sleep
from fps import Module
class MyModule(Module):
async def start(self):
await sleep(float("inf"))
FPS will notice that this module never completes the startup phase, because its start
method hangs indefinitely. By default, this will time out after one second. The solution is to launch a background task and then explicitly call self.done()
, like so:
from anyio import create_task_group, sleep
from fps import Module
class MyModule(Module):
async def start(self):
async with create_task_group() as tg:
tg.start_soon(sleep, float("inf"))
self.done()