Lilly is fast service-oriented and layered Python 3.6+ web framework built on top of FastAPI It is enforces a certain way of creating FastApi applications that is much easier to reason about. Since it is based on FastAPI, it is modern, fast (high performance), and works well with Python type hints.
Lilly signifies peaceful beauty. Lilly is thus an opinionated framework that ensures clean beautiful code structure that scales well for large projects and large teams.
- It just adds more opinionated structure to the already beautiful FastAPI.
- It ensures that when someone is building a web application basing on Lilly, they don't need to think about the structure.
- The developer should just know that it is a service-oriented architecture with each service having a layered architecture that ensures layers don't know what the other layer is doing.
On top of the key features of FastAPI which include:
- Fast. It is based on FastApi
- Intuitive: Great editor support. Completion everywhere. Less time debugging.
- Easy: Designed to be easy to use and learn. Less time reading docs.
- Short: Minimize code duplication. Multiple features from each parameter declaration. Fewer bugs.
- Robust: Get production-ready code. With automatic interactive documentation.
- Standards-based: Based on (and fully compatible with) the open standards for APIs: OpenAPI (previously known as Swagger) and JSON Schema.
It also:
- Enforces a separation of concerns between service to service
- Enforces a separation of concerns within the service between presentation, business, persistence, and data_source layers
- Ensure you have Python 3.7 or +3.7 installed
- Create a new folder for your application
mkdir lilly_sample && cd lilly_sample- Create the virtual environment and activate it
python3 -m venv env
source env/bin/activate- Install lilly
pip install lilly- Create your first application based off the framework
python -m lilly create-appThis will create the following folder structure with some fully functional sample code
.
├── main.py
├── settings.py
└── services
├── __init__.py
└── hello
├── __init__.py
├── actions.py
├── datasources.py
├── dtos.py
├── repositories.py
└── routes.py- Install uvicorn and run the app
pip install uvicorn
uvicorn main:app --reload-
View the OpenAPI docs at http://127.0.0.1:8000/docs
-
For you to add another service in the services folder, run the command:
python -m lilly create-service <service-name>e.g.
python -m lilly create-service blog- For more information about the commands, just run the
helpcommands
python -m lilly --help
python -m lilly create-app --help
python -m lilly create-service --help- Clone the repository
git clone git@github.com:sopherapps/lilly.git && cd lilly- Create a test postgres database if you have not yet
sudo -su postgres
createdb <test_db_name>
exit- Copy the
.example.envfile to.env
cp .example.env .env- Update the
TEST_DATABASE_URLto the URL of your test postgres database in the.envfile - Create virtual environment for Python 3.7 and above and activate it
python3 -m venv env
source env/bin/activate- Install requirements
pip install -r requirements.txt- Run the test command
python -m unittestLilly can be used easily in your app.
To create a new app, we use the command:
python -m lilly create-app <app-name>To add another service in the service folder, we use the command:
python -m lilly create-service <service-name>These two commands create a starting point with a sample fully-functional web app whose docs can be found at http://127.0.0.1:8000/docs when the app is run locally with the command.
uvicorn main:app --reloadThe two create commands typically create a service folder with the follwoing structure
└── <service-name>
├── __init__.py
├── actions.py
├── datasources.py
├── dtos.py
├── repositories.py
└── routes.pyThe Actions can be found in the actions.py module. Customize them accordingly following the guidance of the already
existing code.
The DataSources can be found in the datasources.py module. Customize them accordingly following the guidance of the
already existing code.
The Repositorys can be found in the respositories.py module. Customize them accordingly following the guidance of
the already existing code.
The RouteSets can be found in the routes.py module. Customize them accordingly following the guidance of the already
existing code.
The DataModel DTOs can be found in the dtos.py module. Customize them accordingly following the guidance of the
already existing code.
To create a new data source, one needs to subclass the DataSource class and override the connect(self) method.
from typing import ContextManager
from lilly.datasources import DataSource
class SampleConnectionContextManager:
def __init__(self, connection):
self.connection = connection
def __enter__(self):
return self.connection
def __exit__(self, exc_type, exc_val, exc_tb):
self.connection.close()
class SampleDataSource(DataSource):
def connect(self) -> ContextManager:
# do some stuff and return a context manager for a connection
passTo make life easier for the developer, we have created a few DataSources that can be used or overridden. They include:
This connects to any relational database e.g. MySQL, PostgreSQL, Sqlite etc. using SQLAlchemy It can be used in a repository as in this example:
from typing import Any
from sqlalchemy.orm import declarative_base
from sqlalchemy import Column, Integer, String
from lilly.repositories import Repository
from lilly.datasources import SQLAlchemyDataSource, DataSource
from lilly.conf import settings
Base = declarative_base()
class UserModel(Base):
"""The database model for users"""
__tablename__ = "users"
id = Column(Integer, primary_key=True)
name = Column(String)
email = Column(String)
class UsersRepository(Repository):
"""Repository for saving and retrieving users"""
_users_db = SQLAlchemyDataSource(db_uri=settings.DATABASE_URL, declarative_meta=Base)
# -- other important methods need to be overridden also. I have excluded them for brevity.
@property
def _datasource(self) -> DataSource:
return self._users_dbTo create a new repository, one needs to subclass the Repository class and override all the following methods:
_get_one(self, datasource_connection: Any, record_id: Any, **kwargs) -> Anymethod to get one record of idrecord_id_get_many(self, datasource_connection: Any, skip: int, limit: int, filters: Dict[Any, Any], **kwargs) -> List[Any]method to get many records that fulfil thefilters_create_one(self, datasource_connection: Any, record: BaseModel, **kwargs) -> Anymethod to create one record_create_many(self, datasource_connection: Any, record: List[BaseModel], **kwargs) -> List[Any]method to create many records_update_one(self, datasource_connection: Any, record_id: Any, new_record: BaseModel, **kwargs) -> Anymethod to update one record of idrecord_id_update_many(self, datasource_connection: Any, new_record: BaseModel, filters: Dict[Any, Any], **kwargs) -> Anymethod to update many records that fulfil thefilters_remove_one(self, datasource_connection: Any, record_id: Any, **kwargs) -> Anymethod to remove one record of idrecord_id_remove_many(self, datasource_connection: Any, filters: Dict[Any, Any], **kwargs) -> Anymethod to remove many records that fulfil thefilters_datasource(self) -> DataSourcean @property-decorated method to return the DataSource whoseconnect()method is to be called in any of the other methods to get its instance._to_output_dto(self, record: Any) -> BaseModelmethod which converts any record from the data source raw to DTO for the public methods
A good example is how we implemented the SQLAlchemyRepository. Feel free to look at it.
To make life easier for the developer, we have created a few off-the-shelf Repository subclasses with most of those methods implemented.
They just need to be inherited and a few abstract methods filled with one-liners (or slightly more than one-liners if you wish).
These include:
This connects to any relational database e.g. MySQL, PostgreSQL, Sqlite etc. using SQLAlchemy
via the SQLAlchemyDataSource data source class.
Here is a sample of its usage:
from typing import Type
from pydantic import BaseModel
from sqlalchemy import Column, Integer, String
from sqlalchemy.orm import DeclarativeMeta, declarative_base
from lilly.repositories import SQLAlchemyRepository
from lilly.datasources import SQLAlchemyDataSource
from lilly.conf import settings
from .dtos import NameRecordDTO # a subclass of pydantic.BaseModel that is a Data Transfer Object for Name types
Base = declarative_base()
class Name(Base):
__tablename__ = "names"
id = Column(Integer, primary_key=True)
title = Column(String, nullable=False)
class NamesRepository(SQLAlchemyRepository):
"""Repository for saving and retrieving random names"""
_names_db = SQLAlchemyDataSource(declarative_meta=Base, db_uri=settings.DATABASE_URL)
@property
def _model_cls(self) -> Type[DeclarativeMeta]:
return Name
@property
def _dto_cls(self) -> Type[BaseModel]:
return NameRecordDTO
@property
def _datasource(self) -> SQLAlchemyDataSource:
return self._names_db
# The NamesRepository can then be instantiated in the `Actions` subclassesTo create a new action, one needs to subclass the Action class and override the run() method.
For instance:
import random
import string
from pydantic import BaseModel
from lilly.actions import Action
from .repositories import NamesRepository # A Repository for names
from .dto import NameCreationRequestDTO # The Data Transfer Object to used when creating a name
class GenerateRandomName(Action):
"""
Generates a random string and persists it in the data source
"""
_vowels = "aeiou"
_consonants = "".join(set(string.ascii_lowercase) - set("aeiou"))
_name_repository = NamesRepository()
def __init__(self, length: int = 7):
self._length = length
def run(self) -> BaseModel:
"""Actual method that is run"""
name = self._generate_random_word()
return self._name_repository.create_one(NameCreationRequestDTO(title=name))
def _generate_random_word(self):
"""Generates a random word"""
word = ""
for i in range(self._length):
if i % 2 == 0:
word += random.choice(self._consonants)
else:
word += random.choice(self._vowels)
return word
# The GenerateRandomName action is then used in a route as self._do(GenerateRandomName, length=9)To make life easier for the developer, we have developed a few Actions that can be inherited and used easily. They include:
This is a CRUD action that creates a single item in the repository. Here is a sample of how it is used.
from lilly.actions import CreateOneAction
from lilly.repositories import Repository
# inherit the CreateOneAction and implement its _repository @property method
class CreateOneName(CreateOneAction):
"""Create a single Name record in the repository"""
@property
def _repository(self) -> Repository:
return # your repository
# then use it in your routes like self._do(CreateOneName, data_dto)This is a CRUD action that creates multiple items in the repository at one go. Here is a sample of how it is used.
from lilly.actions import CreateManyAction
from lilly.repositories import Repository
# inherit the CreateManyAction and implement its _repository @property method
class CreateManyNames(CreateManyAction):
@property
def _repository(self) -> Repository:
return # your repository
# then use it in your routes like self._do(CreateManyNames, data_dtos)This is a CRUD action that reads a single item from the repository. Here is a sample of how it is used.
from lilly.actions import ReadOneAction
from lilly.repositories import Repository
# inherit the ReadOneAction and implement its _repository @property method
class ReadOneName(ReadOneAction):
@property
def _repository(self) -> Repository:
return # your repository
# then use it in your routes like self._do(ReadOneName, record_id)This is a CRUD action that reads multiple items in the repository at one go basing on a number of filters and pagination controls. Here is a sample of how it is used.
from lilly.actions import ReadManyAction
from lilly.repositories import Repository
# inherit the ReadManyAction and implement its _repository @property method
class ReadManyNames(ReadManyAction):
@property
def _repository(self) -> Repository:
return # your repository
# then use it in your routes like self._do(ReadManyNames, "id > 8 AND title LIKE "%doe", skip=1, limit=10, address="Kampala")
# To read all names that:
# - have an id greater than 8
# - and title ending with 'doe'
# - as well having the address for that name equal to "Kampala"
# - but skipping the first item in that collection
# - and returning not more than ten recordsThis is a CRUD action that updates a single item in the repository. Here is a sample of how it is used.
from lilly.actions import UpdateOneAction
from lilly.repositories import Repository
# inherit the UpdateOneAction and implement its _repository @property method
class UpdateOneName(UpdateOneAction):
@property
def _repository(self) -> Repository:
return # your repository
# then use it in your routes like self._do(UpdateOneName, record_id, new_data_dto)This is a CRUD action that updates multiple items in the repository at one go basing on a number of filters supplied. Here is a sample of how it is used.
from lilly.actions import UpdateManyAction
from lilly.repositories import Repository
# inherit the UpdateManyAction and implement its _repository @property method
class UpdateManyNames(UpdateManyAction):
@property
def _repository(self) -> Repository:
return # your repository
# then use it in your routes like self._do(UpdateManyNames, new_data_dto, "id > 8 AND title LIKE "%doe", address="Kampala")
# To update all names to resemble new_data_dto for all names that:
# - have an id greater than 8
# - and title ending with 'doe'
# - as well having the address for that name equal to "Kampala"This is a CRUD action that deletes a single item in the repository. Here is a sample of how it is used.
from lilly.actions import DeleteOneAction
from lilly.repositories import Repository
# inherit the DeleteOneAction and implement its _repository @property method
class DeleteOneName(DeleteOneAction):
@property
def _repository(self) -> Repository:
return # your repository
# then use it in your routes like self._do(DeleteOneName, record_id)This is a CRUD action that deletes multiple items in the repository at one go basing on a number of filters supplied. Here is a sample of how it is used.
from lilly.actions import DeleteManyAction
from lilly.repositories import Repository
# inherit the DeleteManyAction and implement its _repository @property method
class DeleteManyNames(DeleteManyAction):
@property
def _repository(self) -> Repository:
return # your repository
# then use it in your routes like self._do(DeleteManyNames, "id > 8 AND title LIKE "%doe", address="Kampala")
# To delete all names that:
# - have an id greater than 8
# - and title ending with 'doe'
# - as well having the address for that name equal to "Kampala"To create a new route set, one needs to subclass the RouteSet class and decorate it with routeset and decorate each
method that is to be an endpoint with appropriate the method (HTTP or websocket) decorators like get,
post etc.
For instance:
from lilly.routing import routeset, RouteSet, get, post
from .dto import MessageDTO # the Data Transfer Object to pass data around the app
@routeset
class NormalRouteSet(RouteSet):
"""
A basic Class based route that can have any method as an endpoint and can have common variables in the init
attached to self
"""
def __init__(self):
self.name = "Lilly"
@get("/", response_model=MessageDTO)
def home(self):
"""Home"""
return {"message": f"Welcome to {self.name}"}
@get("/login", response_model=MessageDTO)
def login(self):
"""Login"""
return {"message": f"{self.name} invites you to login"}To make the life of the developer easier, there are some RouteSet subclasses that one can inherit from and easily have
a set of endpoints that fulfill a particular purpose.
They include:
For CRUD (Create-Read-Update-Delete) actions, a RouteSet can be created be by subclassing CRUDRouteSet
and overriding the get_settings() class method on it to return the appropriate CRUDRouteSetSettings for the given
route set.
For example:
from lilly.routing import routeset, CRUDRouteSet, CRUDRouteSetSettings, get, post
from .dto import (
NameRecordDTO,
NameCreationRequestDTO,
MessageDTO,
RandomNameCreationRequestDTO,
) # The Data Transfer Objects to be used as responses or requests
from .actions import (
CreateOneName,
CreateManyNames,
ReadOneName,
ReadManyNames,
UpdateOneName,
UpdateManyNames,
DeleteOneName,
DeleteManyNames,
GenerateRandomName,
) # The Actions for CRUD
@routeset
class HelloWorld(CRUDRouteSet):
"""
Class Based Route set that handles CRUD functionality out of the box
"""
@classmethod
def get_settings(cls) -> CRUDRouteSetSettings:
# When an action is not defined, the dependant routes will not be shown
return CRUDRouteSetSettings(
id_type=int,
base_path="/names",
base_path_for_multiple_items="/admin/names",
response_model=NameRecordDTO,
creation_request_model=NameCreationRequestDTO,
create_one_action=CreateOneName,
create_many_action=CreateManyNames,
read_one_action=ReadOneName,
read_many_action=ReadManyNames,
update_one_action=UpdateOneName,
update_many_action=UpdateManyNames,
delete_one_action=DeleteOneName,
delete_many_action=DeleteManyNames,
string_searchable_fields=["title"],
)
# You can add even more routes on the CRUD routeset
@get("/hello/{name}", response_model=MessageDTO)
def say_hello(self, name: str):
return {"message": f"Hi {name}"}
@post("/random-names/", response_model=NameRecordDTO)
def create_random_name(self, request: RandomNameCreationRequestDTO):
return self._do(GenerateRandomName, length=request.length)The following features are required.
- All services are put in the
servicesfolder whose import path is passed as a parameter to theLillyinstance during initialization. (Default: folder calledserviceson root of project) - All settings are put as constants in the
settingspython module whose import path is passed toLillyinstance at initialization. (Default:settings.pyon the root of project)
- All services must have the following modules or packages:
routes(if a package is used, allRouteSetsubclasses must be imported into theroutes.__init__module)actionsrepositoriesdatasourcesdtos
- Just like FastAPI Class-based views (CBV)
routes, Lilly routes (which are technically methods of the Service subclass) should have the
post,get,put,patch...decorators. The format is exactly as it is in FastAPI. In addition, dependencies can be shared across multiple endpoints of the same service thanks toFastApi CBV. RouteSetis the base class of all Routes. It should have the following methods overridden:_do(self, actionCls: Type[Action], *args, **kwargs)which internally initializes the actionCls and callsrun()on it
Actionsubclasses should have an overriddenrun(self) -> Anymethod- The
run(self)method should be able to access any repositories by directly importing any it needs
- The
Repositorysubclasses should have public:get_one(self, record_id: Any, **kwargs) -> Anymethod to get one record of idrecord_idget_many(self, skip: int, limit: int, filters: Dict[Any, Any], **kwargs) -> List[Any]method to get many records that fulfil thefilterscreate_one(self, record: BaseModel, **kwargs) -> Anymethod to create one recordcreate_many(self, records: List[BaseModel], **kwargs) -> List[Any]method to create many recordsupdate_one(self, record_id: Any, new_record: Any, **kwargs) -> Anymethod to update one record of idrecord_idupdate_many(self, new_record: BaseModel, filters: Dict[Any, Any], **kwargs) -> Anymethod to update many records that fulfil thefiltersremove_one(self, record_id: Any, **kwargs) -> Anymethod to remove one record of idrecord_idremove_many(self, filters: Dict[Any, Any], **kwargs) -> Anymethod to remove many records that fulfil thefilters
Repositorysubclasses should also have the following methods overridden:_get_one(self, datasource_connection: Any, record_id: Any, **kwargs) -> Anymethod to get one record of idrecord_id_get_many(self, datasource_connection: Any, skip: int, limit: int, filters: Dict[Any, Any], **kwargs) -> List[Any]method to get many records that fulfil thefilters_create_one(self, datasource_connection: Any, record: BaseModel, **kwargs) -> Anymethod to create one record_create_many(self, datasource_connection: Any, record: List[BaseModel], **kwargs) -> List[Any]method to create many records_update_one(self, datasource_connection: Any, record_id: Any, new_record: BaseModel, **kwargs) -> Anymethod to update one record of idrecord_id_update_many(self, datasource_connection: Any, new_record: BaseModel, filters: Dict[Any, Any], **kwargs) -> Anymethod to update many records that fulfil thefilters_remove_one(self, datasource_connection: Any, record_id: Any, **kwargs) -> Anymethod to remove one record of idrecord_id_remove_many(self, datasource_connection: Any, filters: Dict[Any, Any], **kwargs) -> Anymethod to remove many records that fulfil thefilters_datasource(self) -> DataSourcean @property-decorated method to return the DataSource whoseconnect()method is to be called in any of the other methods to get its instance._to_output_dto(self, record: Any) -> BaseModelmethod which converts any record from the data source raw to DTO for the public methods
DataSourcesubclasses should have an overriddenconnect(self)methoddtos(Data Transfer Object classes) are subclasses of thepydantic.BaseModelwhich are to be used to move data across the layers- Any setting added to the gazetted settings file can be accessed via
lilly.conf.settings.<setting_name>e.g.lilly.conf.settings.APP_SETTING
- The
Lillyinstance should be run the same way as FastAPI instances are run e.g.
uvicorn main:app # for app defined in the main.py module- The application is an instance of the
Lillyclass which is a subclass of theFastAPIclass. - To create a
Lillyinstance, we need to pass in the following parameters:- services_path (an import path as string, default is "services")
- settings_path (an import path as string, default is "settings")
- During
Lillyinitialization, all routes are automatically imported usingimportlib.import_moduleby concatenating the<services_path>.<service_name>.routese.g.services.hello.routes. - In order to make route definition solely dependent on folder structure, we change
@app.getdecorators to@get app.get,app.postetc. should throwNotImplementedErrorerrors- The whole app has one instance of the
router: APIRouter. It is defined in theroutingmodule. - In that same
routingmodule,router.get,router.post,router.delete,router.put,router.patch,router.head,router.optionsare all aliased by their post-periodsuffixese.g.get,postetc. - When initializing in init of Lilly, we fetch the routes in all services then call
self.include_router(router). app.mountshould throw anNotImplementedErrorerror because it complicates the app structure if used to mount other applications, considering the fact that all routes share onerouterinstance.- In order to have a protected method
_do()to call an action within the routers, we use class-based views from fastapi-utils CBV. - All these class based views will be subclasses of
RouteSetwhich has an overridable protected method_do(self, action_cls: Action, *args, **kwargs)to make a call to any action - All these class based views will have a decorator
@routesetwhich is an alias of@cbv(router)whererouteris the router common to all routes - All the routes in the app have one router so their endpoints need to be different and explicit since no mounting will be allowed
- The
connect()method of theDataSourceclass should return aContextManagerwrapped around the connection itself so as to allow for any clean up tasks to be done in the__exit__()method of that ContextManager after each connection is ready to be dropped. The__enter__method of the ContextManager needs to return the actual connection object.
- Set up the abstract methods structure
- Set up the CLI to generate an app
- Set up the CLI to generate a service
- Make repository public
- Package it and publish it
- Add some out-of-the-box base data sources e.g.
- SqlAlchemy
- Redis
- Memcached
- RESTAPI
- GraphQL
- RabbitMQ
- ActiveMQ
- Websockets
- Kafka
- Mongodb
- Couchbase
- DiskCache
- Add some out-of-the-box base repositories e.g.
- SqlAlchemyRepository (RDBM e.g. PostgreSQL, MySQL etc.)
- SQLAlchemyRepository hangs when postgres is used (try running tests)
- RedisRepository
- MemcachedRepository
- RESTAPIRepository
- GraphQLRepository
- RabbitMQRepository
- ActiveMQRepository
- WebsocketsRepository
- KafkaRepository
- MongodbRepository
- CouchbaseRepository
- DiskCacheRepository
- Add some out-of-the-box base actions e.g.
- CreateOneAction
- CreateManyAction
- UpdateOneAction
- UpdateManyAction
- ReadOneAction
- ReadManyAction
- DeleteOneAction
- DeleteManyAction
- Add some out-of-the-box base route sets
- CRUDRouteSet
- WebsocketRouteSet
- GraphQLRoute
- Add example code in examples folder
- Todolist (CRUDRouteSet, SqlAlchemyRepo)
- RandomQuotes (WebsocketRouteSet, MongodbRepo) (quotes got from the Bible)
- Clock (WebsocketRouteSet, WebsocketsRepo)
- Set up automatic documentation
- Set up CI via Github actions
- Set up CD via Github actions
- Write about it in hashnode or Medium or both
For the changes across versions, look at the CHANGELOG.md
Copyright (c) 2022 Martin Ahindura Licensed under the MIT License