Metro is a lightweight, opinionated, batteries-included Python web framework built on top of FastAPI and MongoEngine.
It is means to provide helpful, lightweight abstractions to enable standard ways of implementing common patters to
prevent the SaaSification of the developer stack.
The goal is to enhance not inhibit.
- Built on top of FastAPI and MongoEngine ODM
- CLI tool for project management and code generation
- Built-in database management (MongoDB)
- Support for both local and Docker-based development
- Environment-specific configurations
- Automatic API documentation
Install Metro using pip:
pip install metroapi
Create a new Metro project using the new
command:
metro new my_project
cd my_project
This will create a new directory my_project
with the default project structure:
my_project/
├── app/
│ ├── controllers/
│ ├── models/
│ └── __init__.py
├── config/
│ ├── development.py
│ ├── production.py
│ └── __init__.py
├── main.py
├── Dockerfile
└── docker-compose.yml
Start the development server using the run
command:
metro run
This will start the development server on http://localhost:8000.
You can also run the service using Docker:
metro run --docker
Metro includes a scaffold generator to quickly create models, controllers, and route definitions for a new resource.
To generate a scaffold for a Post
resource with title
and body
fields:
metro generate scaffold Post title:str body:str
This will generate:
app/models/post.py
with aPost
model classapp/controllers/posts_controller.py
with CRUD route handlers- Update
app/controllers/__init__.py
to import the new controller - Update
app/models/__init__.py
to import the new model
You can also generate models and controllers individually.
To generate a Comment
model with post_id
, author
, and content
fields:
metro generate model Comment post_id:str author:str content:str
To generate a controller for Auth
routes:
metro generate controller Auth
You can also pass in the routes to generate as arguments:
metro generate controller Auth post:login post:register
str
, int
, float
, bool
, datetime
, date
, dict
, list
.
ref
, file
, list:ref
, list:file
, hashed_str
.
You can define relationships between models using the following syntax:
- One-to-Many Relationship: Use the
ref:
prefix followed by the related model name.
metro generate model Post author:ref:User
This will generate a Post
model with an author
field referencing the User
model.
- Many-to-Many Relationship: Use
list:
andref:
together.
metro generate model Student courses:list:ref:Course
This will generate a Student
model with a courses
field that is a list of references to Course
models.
_
, ^
are used to define a field as optional or unique.
Append _
to the field name to mark it as optional.
metro generate model User email_:str
This will generate a User
model with an optional email
field.
Append ^
to the field name to specify it as unique.
metro generate model User username^:str
This will generate a User
model with a unique username
field.
hashed_str
is a special field type that automatically hashes the value before storing it in the database.
metro generate model User name:str password_hashed:str
This will generate a User
model with a password
field stored as a hashed value.
file
and list:file
are special field types for handling file uploads. They automatically upload
files to the specified storage backend (local filesystem, AWS S3, etc.) and store the file path and file metadata in the database.
file
: Generates a singleFileField
on the model.list:file
: Generates aFileListField
, allowing multiple files.
Example usage:
metro generate model User avatar:file
metro generate model Post attachments:list:file
This will generate the following model classes:
class User(BaseModel):
avatar = FileField()
class Post(BaseModel):
attachments = FileListField()
Uploading files to s3 then becomes as easy as:
# Set an individual file field
@put('/users/{id}/update-avatar')
async def update_avatar(
self,
id: str,
avatar: UploadFile = File(None),
):
user = User.find_by_id(id=id)
if avatar:
# This stages the file for upload
user.avatar = avatar
# This actually uploads the file and stores the metadata in the database
user.save()
return user.to_dict()
# Work with a list of files
@post('/posts/{id}/upload-attachments')
async def upload_attachments(
self,
id: str,
attachments: List[UploadFile] = File(None),
):
post = Post.find_by_id(id=id)
if attachments:
# This stages the new files for upload
post.attachments.extend(attachments)
# This actually uploads the files and adds appends to the attachments list in the db with the new metadata
post.save()
return post.to_dict()
The default configuration is set to use the local filesystem and store files in the uploads
directory. You can change the storage backend and location in the config/development.py
or config/production.py
file.
Default configuration:
FILE_STORAGE_BACKEND = "filesystem"
FILE_SYSTEM_STORAGE_LOCATION = "./uploads"
FILE_SYSTEM_BASE_URL = "/uploads/"
Custom configuration in config/development.py
or config/production.py
:
FILE_STORAGE_BACKEND = 'filesystem'
FILE_SYSTEM_STORAGE_LOCATION = './uploads_dev'
FILE_SYSTEM_BASE_URL = '/uploads_dev/'
Or to use AWS S3:
FILE_STORAGE_BACKEND = 's3'
S3_BUCKET_NAME = "my-bucket"
AWS_ACCESS_KEY_ID = "..."
AWS_SECRET_ACCESS_KEY = "..."
AWS_REGION_NAME = "us-east-1"
Lifecycle hooks like before_request
and after_request
can be defined directly in a controller or inherited from a parent controller. Hooks are useful for tasks such as authentication, logging, or cleanup.
admin_controller.py
from metro.controllers import Controller, before_request, after_request
from metro.exceptions import UnauthorizedError
from metro import Request
class AdminController(Controller):
@before_request
async def check_admin(self, request: Request):
is_admin = False # Replace with actual logic
print("Checking admin status... (this will be run before each request)")
if not is_admin:
raise UnauthorizedError(detail="Unauthorized access.")
@after_request
async def cleanup_stuff(self, request: Request):
print("Cleaning up... (this will be run after each request)")
admin_user_controller.py
from app.controllers.admin_controller import AdminController
class AdminUserController(AdminController):
@get('/admin-user/all-users')
async def all_users(self, request):
return {"users": []}
- Hooks like
check_admin
andafter_request
can be defined directly in a controller or inherited from a parent. - In
AdminUserController
, hooks are inherited fromAdminController
and run before and after each request handler.
- If a
before_request
hook raises an exception (e.g.,UnauthorizedError
), the request handler is skipped, but theafter_request
hook still runs.
Metro includes a built-in rate limiter that can be applied to specific routes or controllers.
To apply rate limiting to a controller endpoint, use the @throttle
decorator:
from metro.rate_limiting import throttle
class UserController(Controller):
@get('/users/{id}')
@throttle(per_second=1, per_minute=10)
async def get_user(self, request: Request, id: str):
return {"id": id}
To apply rate limiting to a specific route, pass the Throttler
class as a dependency:
from metro.rate_limiting import Throttler
@app.get("/users/{id}", dependencies=[Depends(Throttler(per_second=1, per_minute=10))]
async def get_user(request: Request, id: str):
return {"id": id}
Parameters:
name
: Namespace for the rate limiter.limits
: Compound rate limit definition. Can be a RateLimits() object or a function that returns a RateLimits() object.per_second
: Number of requests allowed per second.per_minute
: Number of requests allowed per minute.per_hour
: Number of requests allowed per hour.per_day
: Number of requests allowed per day.per_week
: Number of requests allowed per week.per_month
: Number of requests allowed per month.backend
: Rate limiting backend (e.g.,InMemoryRateLimiterBackend
,RedisRateLimiterBackend
). Defaults to InMemoryRateLimiterBackend.callback
: Callback function to execute when the rate limit is exceeded. (request, limited, limit_info) => .... Defaults to raising aTooManyRequestsError
if limit is exceeded.key
: Custom key function to generate a unique key for rate limiting. (request) => str. Defaults to request IP.cost
: Custom cost function to calculate the cost of a request. (request) => int. Defaults to 1.
Easily send emails using the built-in EmailSender
class, which supports multiple email providers like Mailgun and AWS SES.
# 1. Configure the provider:
# - For Mailgun
mailgun_provider = MailgunProvider(
domain=os.getenv("MAILGUN_DOMAIN"), api_key=os.getenv("MAILGUN_API_KEY")
)
mailgun_sender = EmailSender(provider=mailgun_provider)
# - For AWS SES (coming soon)
ses_provider = AWSESProvider(region_name="us-west-2")
ses_sender = EmailSender(provider=ses_provider)
# 2. Send the email:
mailgun_sender.send_email(
source="sender@example.com",
recipients=["recipient@example.com"],
subject="Test Email",
body="This is a test email sent using Mailgun.",
)
Easily send SMS messages using the built-in SMSSender
class, which supports multiple SMS providers like Twilio and Vonage.
- Add the provider credentials to the environment variables or config file:
# For Twilio
TWILIO_ACCOUNT_SID=ACXXXXXXXXXXXXXXXX
TWILIO_AUTH_TOKEN=your_auth_token
TWILIO_PHONE_NUMBER=+1234567890
# For Vonage
VONAEG_API_KEY=your_api_key
VONAGE_API_SECRET=your_api_secret
VONAGE_PHONE_NUMBER=+1234567890
- Send an SMS message:
sms_sender = SMSSender() # provider will be automatically detected based on environment variables but can also be specified explicitly
sms_sender.send_sms(
source="+1234567890",
recipients=["+1234567891"],
message="This is a test SMS message!",
)
Metro provides commands to manage your MongoDB database.
To start a local MongoDB instance for development:
metro db up
To stop the local MongoDB instance:
metro db down
You can also specify the environment and run MongoDB in a Docker container:
metro db up --env production --docker
Environment-specific configuration files are located in the config
directory:
config/development.py
config/production.py
Here you can set your DATABASE_URL
, API keys, and other settings that vary between environments.
Metro includes a built-in admin panel. You can view this at /admin
You can disable this or change the admin route in the config/development.py
or config/production.py
file:
ENABLE_ADMIN_PANEL = False
ADMIN_PANEL_ROUTE_PREFIX = "/admin-panel"
- API Documentation: http://localhost:8000/docs
- CLI help:
metro --help
For guides, tutorials, and detailed API references, check out the Metro documentation site.
Metro is open-source software licensed under the MIT license.