Versions covered: Python 3.10+, Poetry, FastAPI 0.103+, SQLAlchemy 2.0+
Table of Contents
- 2.1 Introduction and prerequisites
- 2.2 Presentation of the GloboTicket project
- 2.3 First steps — get the source code
- 2.4 Start server
- 2.5 Open project in PyCharm
- 2.6 Open project in VS Code
- 2.7 The pre-commit framework
- 3.1 Module overview
- 3.2 Configure database
- 3.3 Create data model for categories
- 3.4 Create data model for events
- 3.5 Create relationship between models
- 3.6 Make the database configurable with Dotenv
- 3.7 Commit to version control
- 4.1 Create an API endpoint
- 4.2 DB session injection
- 4.3 Take argument from URL
- 4.4 FastAPI documentation
- 4.5 Define response pattern
- 4.6 An endpoint that returns a list of events
- 4.7 Make API testable
- 4.8 Unit tests with mocking
- 4.9 Integration testing with TestClient
- 5.1 Introduction to frontmatter files
- 5.2 Finding files with pathlib
- 5.3 Parsing files with Regex and pyyaml
- 5.4 Add file contents to data model
- 5.5 Fix unit tests
1. Course Overview
Welcome to this course: Building a REST API with Python 3. The instructor is Reindert-Jan Ekker, a Python developer and trainer at his own company called Code Sensei.
In this course you will learn how to build a complete Python project from scratch. You will use the excellent and very popular FastAPI framework to build a REST API that serves data from a database, using SQLAlchemy for the data model.
The general approach is to follow best practices to create a project that resembles real-world software development as closely as possible.
Main topics covered
- Creating a REST API with FastAPI
- Data modeling with SQLAlchemy
- Complete unit testing with pytest
- Working with frontmatter files using regular expressions and YAML
At the end of the course, you will know how to work with a complete and realistic Python project.
2. Project start
2.1 Introduction and prerequisites
This course was created with the displayed versions of Python, Poetry, FastAPI and SQLAlchemy. The information in this course applies to these specific versions.
Target audience
This course is intended for intermediate level Python developers. Here is an overview of the subjects you must master as prerequisites:
Python language and standard library:
- Basics of Object Oriented Programming (OOP), including the use of inheritance and properties
pathlibfor working with text files- Regular expressions to match text patterns
Code quality:
- Flake8 to check code style
- mypy for type annotations (type hints)
- Poetry for project setup and dependency management
Tests:
- pytest for unit testing
External libraries:
- SQLAlchemy for database access
- requests to test the REST API in unit tests
- FastAPI to build the API
- Pydantic for data validation
- Uvicorn as HTTP server
2.2 Presentation of the GloboTicket project
The client is GloboTicket — a company that sells tickets to events like concerts.
Current problem
All of their systems currently use a legacy system where data is stored in two places:
- Database: contains only part of the data — product codes, dates, prices
- Frontmatter files (represented by a YAML file icon): contain other information such as the event name, artist, description, etc.
To obtain all the information for a product, you must consult the database and find the corresponding frontmatter file, then combine the two.
Currently the website and other systems (like billing) connect to these two different data sources to bring everything together.
Desired solution
The client wants us to build a REST API which:
- Connects to both data sources
- Presents a unified interface
The client plans to create a new website, mobile application, and other systems in the future. It wants to be independent of data storage technology. All systems will only talk to the REST API.
This will allow them to change the way data is stored (e.g. migrate everything to one database or use cloud storage) without having to change the front-end systems. Only the API will need to be updated.
2.3 Getting started — get the source code
The source code for the project is in a GitHub repository. To work in parallel with the course (which is strongly recommended for better learning), you must collect this project:
- Clone the repository with Git
- Or download the zip file
To manage dependencies, we use Poetry. If you don’t have it yet, follow the link to the installation instructions.
Project initialization
The project was created with the command:
poetry new globoticket
Poetry created:
- A package called
globoticket(client name) - A
testsfolder (empty, containing only a__init__.pyfile) - In the
globoticketpackage: a__init__.pyfile and anapi.pyfile added manually - The
pyproject.tomlfile - An empty
README.mdfile
There are also data files provided at startup (the events.db database, product files, and static HTML files).
To start at the correct version of the project:
git clone <url-du-repo>
git checkout project-start
2.4 Start the server
runserver.py
The runserver.py script imports Uvicorn (an HTTP server — a library that can serve websites) and tells it to run app from globoticket.api:
"""
runserver.py
---
Run this script to start the globoticket app.
"""
import uvicorn
def main():
uvicorn.run(
"globoticket.api:app",
host="0.0.0.0",
port=8000,
reload=True,
)
if __name__ == "__main__":
main()
Important parameters:
host="0.0.0.0": listen for local connections (the four zeros mean “listen for local connections”)port=8000: the TCP port to listen onreload=True: automatically reloads the application when you modify the Python code
globoticket/api.py (initial release)
"""
api.py
---
The REST Api for the Globoticket events database.
"""
from fastapi import FastAPI
from starlette.staticfiles import StaticFiles
from pathlib import Path
app = FastAPI()
PROJECT_ROOT = Path(__file__).parent.parent
app.mount("/", StaticFiles(directory=PROJECT_ROOT / "static", html=True))
FastAPI is imported and an app object is created. It is the application object which represents the REST API. This is what is referenced in the Uvicorn server startup (globoticket.api then the app object).
app.mount("/", StaticFiles(...)): The server must directly serve certain files from the file system. These files are served as is — we call them static files (as opposed to dynamic content generated by code). The PROJECT_ROOT/static directory contains HTML files representing what the final product might look like when there is a website using our REST API.
To start the server:
python runserver.py
# ou, si pas dans l'environnement virtuel actif :
poetry run python runserver.py
2.5 Open project in PyCharm
When we open the project in PyCharm, we should see the message “No Python interpreter configured” with the option to configure a Poetry environment from pyproject.toml. Or, if the environment has already been created with Poetry on the command line, you can activate it directly.
Configuration steps in PyCharm
- Click at the bottom right on “No interpreter”
- Select “Add New Interpreter” → “Add Local Interpreter”
- In the window, choose “Poetry Environment” (if not visible: update PyCharm)
- Specify the Poetry installation path
To find the path to Poetry:
# Si installé avec pipx :
pipx list
# => indique le répertoire d'installation (ex. ~/.local/bin)
# Si installé avec pip :
pip show poetry
If the Poetry environment has already been created (with poetry install): select “Existing environment” — PyCharm will automatically detect it.
Once configured, you can launch the project from the integrated terminal:
# Dans le terminal PyCharm (environnement actif automatiquement)
python runserver.py
2.6 Open project in VS Code
In Visual Studio Code, when opening the project, the Python interpreter may not be correctly selected.
Configuration steps in VS Code
- Click at the bottom right on the name of the interpreter (ex. “No interpreter”)
- A list of available environments appears, including Poetry environments
- Select the GloboTicket Poetry environment
If VS Code does not detect it automatically:
# Dans le dossier du projet, demander à Poetry le chemin de l'environnement :
poetry env info
# => affiche le chemin de l'exécutable du venv
Then in VS Code: click on the interpreter → “Enter interpreter path” → paste the path.
2.7 The pre-commit framework
One last step to complete the project configuration: install the pre-commit framework.
# Avec poetry run (fonctionne même en dehors de l'environnement actif) :
poetry run pre-commit install
# Ou directement si dans l'environnement actif :
pre-commit install
This command creates a Git hook — a piece of code that runs before any commit.
Configuration in .pre-commit-config.yaml
# See https://pre-commit.com for more information
repos:
- repo: https://github.com/psf/black
rev: 23.3.0
hooks:
- id: black
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.2.0
hooks:
- id: mypy
- repo: https://github.com/PyCQA/flake8
rev: 6.0.0
hooks:
- id: flake8
args: ["--max-line-length=88", "--extend-ignore=E203"]
- repo: https://github.com/PyCQA/isort
rev: 5.12.0
hooks:
- id: isort
args: [ "--profile=black" ]
During a commit, pre-commit will automatically execute:
- black: formats files
- mypy: checks type annotations
- Flake8: checks the code style (code smells)
- isort: sorts import instructions
Example of operation
If we insert bad code into api.py — a poorly formatted function with incorrect spaces and a bad type annotation — and try to commit:
- The first time, pre-commit takes a little while because it installs black, mypy, etc.
- black indicates that it has reformatted the file
- mypy reports the problem with the type annotation (eg: return type
strbut the function returnsNone) - isort sorted the imports
Commit is rejected due to mypy error. You need to fix the problem, then re-commit.
Note: Files modified by black and isort are in the “modified not staged” state. You must add them with
git addbefore re-committing.
3. Creating the data model
3.1 Module Overview
In this module, we will write code to read and write to the customer provided database, using SQLAlchemy.
We will also make the URL of the database configurable to be able to run the project with different databases (production, development, etc.). For these configuration parameters, we will use the python-dotenv library — the de facto standard for this type of configuration.
The customer shared the SQL code used to create the existing tables in the database:
- Table
category: an ID and a name - Table
event: an ID, a product code, a date, a price, and a foreign key tocategory
3.2 Configure the database
SQL schema of the database (database.ddl)
create table category
(
id INTEGER not null
primary key,
name VARCHAR not null
unique
);
create table event
(
id INTEGER not null
primary key,
product_code VARCHAR not null
unique,
date DATE not null,
price NUMERIC not null,
category_id INTEGER not null
references category
);
The events.db database currently contains 3 events and 3 categories.
database.py (initial version with hardcoded URL)
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
SQLALCHEMY_DATABASE_URL = "sqlite:///events.db"
engine = create_engine(
SQLALCHEMY_DATABASE_URL,
connect_args={"check_same_thread": False},
echo=True,
)
SessionLocal = sessionmaker(bind=engine)
Explanation:
SQLALCHEMY_DATABASE_URL: String telling SQLAlchemy how to connect. For SQLite, it’s simply a file name. For Postgres, it would look likepostgres://server-nameengine: represents the database connectionconnect_args={"check_same_thread": False}: specific to SQLite, allows multi-threaded accessecho=True: SQLAlchemy will display all executed SQL queriesSessionLocal: factory to create database sessions
3.3 Create the data model for categories
We create a new models.py file. The data model for category is:
id: primary key of type intname: string with uniqueness constraint
import decimal
from datetime import date
from sqlalchemy import ForeignKey
from sqlalchemy.orm import (
DeclarativeBase,
Mapped,
declarative_base,
mapped_column,
relationship,
)
Base: DeclarativeBase = declarative_base()
class DBCategory(Base):
__tablename__ = "category"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(unique=True)
events: Mapped[list["DBEvent"]] = relationship(back_populates="category")
Naming convention: We prefix DB model classes with
DB(e.g.DBCategory) to distinguish them from other classes of the same name that might appear later (like Pydantic schemas).
To test that it works (temporary test code):
# Dans models.py, ajouter temporairement :
session = SessionLocal()
results = session.execute(select(DBCategory)).scalars()
print("\n".join(category.name for category in results))
Execution:
python globoticket/models.py
# ou :
poetry run python globoticket/models.py
SQLAlchemy executes an SQL query and the category names appear in the output.
3.4 Create the data model for events
The model for the event table:
class DBEvent(Base):
__tablename__ = "event"
id: Mapped[int] = mapped_column(primary_key=True)
product_code: Mapped[str] = mapped_column(unique=True)
date: Mapped[date]
price: Mapped[decimal.Decimal]
category_id: Mapped[int] = mapped_column(ForeignKey("category.id"))
category: Mapped["DBCategory"] = relationship(back_populates="events")
Important points:
date: typedateimported fromdatetimeprice: typedecimal.Decimal— good practice for monetary data
Why
Decimalfor the price? If we represent money as a floating point number (float), we can get rounding errors during calculations. With integers (int), we cannot represent cents. The best practice for monetary data in a database is to use an infinite precision type. In Python, it isDecimal. In SQLite, this is aNUMERICfield.
category_id: foreign key tocategory.idcategory: additional attribute mapped as a SQLAlchemy relationship. This allows access to theDBCategoryobject directly from aDBEvent
3.5 Create a relationship between models
Lazy loading vs joined
Looking at the output of SQLAlchemy during a query that accesses the event categories, we see 4 queries to print 3 events:
- A query to select all events
- A separate query for each category
This is because SQLAlchemy relationships are lazy by default: they only load when needed. When selecting events, SQLAlchemy only loads the event table. Only when event.category is accessed does SQLAlchemy execute a query to load the category.
To force immediate loading (joined loading):
category: Mapped["DBCategory"] = relationship(back_populates="events", lazy="joined")
With lazy="joined", only one query with a JOIN is executed. The disadvantage: the JOIN is always performed, even if we don’t need the categories. After reflection, we return to lazy loading by default (by removing this argument).
The two-way relationship with back_populates
With relationship(back_populates="events") in DBEvent and relationship(back_populates="category") in DBCategory, the relationship is bidirectional:
- From an event, you can access its category:
event.category - From a category, you can access all its events:
category.events
These are two sides of the same relationship. The foreign key in the DBEvent class points to a single category, but a category can have multiple events.
3.6 Make the database configurable with Dotenv
In database.py, the database URL is hardcoded, which is not a good practice. On a production server or on another developer’s machine, the file might be elsewhere, or one might want to use a different type of database.
Installing python-dotenv
poetry add python-dotenv
Note: The package was already required by other packages like Uvicorn, so it was already installed. The command only adds the main dependency in
pyproject.toml.
database.py (final version with dotenv)
import os
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
SQLALCHEMY_DATABASE_URL = os.getenv("SQLALCHEMY_DATABASE_URL")
engine = create_engine(
SQLALCHEMY_DATABASE_URL,
connect_args={"check_same_thread": False},
echo=True,
)
SessionLocal = sessionmaker(bind=engine)
os.getenv("SQLALCHEMY_DATABASE_URL") reads an environment variable — a variable that comes not from the Python code, but from the program’s execution environment (the shell).
In the terminal (to test):
export SQLALCHEMY_DATABASE_URL="sqlite:///events.db"
python globoticket/database.py
But it is more convenient to store these values in an .env file.
.env file
# Usually you don't add this file to git
SQLALCHEMY_DATABASE_URL="sqlite:///events.db"
At the top of database.py, we call load_dotenv():
from dotenv import load_dotenv
load_dotenv()
load_dotenv() reads the .env file and loads environment variables from this file. So os.getenv("SQLALCHEMY_DATABASE_URL") will read the value from the .env file.
Best practice: We generally not commit the
.envfile to Git because it contains the local configuration, which differs from one developer to another or from one environment to another. For this course, we still commit it for simplicity.
3.7 Commit to version control
Thinking about code quality
Docstrings: The names DBEvent and DBCategory are sufficient in themselves. The field definitions are very readable. Adding a docstring saying “this class represents an event” would be unnecessary. Same goes for models.py — the module name is descriptive enough.
Unit tests: The functions written don’t really contain any custom logic — it’s mostly SQLAlchemy configuration. Writing tests that verify that we can execute SQL queries would simply be testing SQLAlchemy itself, not our own code. There is therefore no need to write unit tests at this stage.
Resolving mypy error
During commit, mypy complains about the line:
Base = declarative_base()
mypy cannot determine the type. The correction is to add a type annotation:
from sqlalchemy.orm import DeclarativeBase, declarative_base
Base: DeclarativeBase = declarative_base()
Resolving Flake8 Warnings
Flake8 reports several unused imports in files. This is the part that requires manual cleanup work: removing unused imports in each file.
In PyCharm (Mac): Ctrl+Alt+O to optimize imports.
4. Make content accessible via an API
4.1 Create an API endpoint
General structure of a FastAPI endpoint
@app.get("/hello")
def hello() -> str:
return "hello"
The hello function returns a string "hello" and is decorated with @app.get. Decoration is what turns an ordinary function into an API endpoint (or, as FastAPI calls it, an operation). An operation always needs an assigned URL — here /hello.
Behavior: When a client requests the /hello URL, FastAPI calls this function. It is the function’s responsibility to determine what the server should return in response. Here it simply returns the string "hello".
This can be checked in the browser by accessing http://localhost:8000/hello.
4.2 DB session injection
The get_session function
from sqlalchemy.orm import Session
def get_session() -> Session:
session = SessionLocal()
try:
yield session
finally:
session.close()
This is a generator function (it uses yield). The session is created, then yield to provide it to the calling code. When the call is completed (whether there is an error or not), the finally block closes the session.
Dependency Injection with Annotated and Depends
from typing import Annotated
from fastapi import Depends, FastAPI, HTTPException
@app.get("/event/{id}", response_model=Event)
def get_event(id: int, db: Annotated[Session, Depends(get_session)]) -> DBEvent:
"""Retrieve a single event by id. Returns status 404 if event is not found."""
event = get_dbevent(id, db)
if event is None:
raise HTTPException(status_code=404, detail=f"No product with id {id}")
return event
Annotated is a Python feature not often used outside of FastAPI. Available since Python 3.9 in the typing module, it allows you to annotate a function argument with more than just its type.
The call to Annotated takes two arguments:
Session— the classic type annotation:dbis an object of classSessionDepends(get_session)— FastAPI specific annotation
Depends(get_session) tells FastAPI to create the db object by calling the get_session function. This is called dependency injection: FastAPI takes care of calling get_session and passing the resulting session to the get_event function.
4.3 Take an argument from the URL
@app.get("/event/{id}", response_model=Event)
def get_event(id: int, db: Annotated[Session, Depends(get_session)]) -> DBEvent:
In the decorator, {id} in the URL is a path parameter. It specifies part of the URL that will be used for the function’s id argument.
Example: if we access /event/1, the 1 is used as the id parameter. The event with id=1 is retrieved from the database and returned to the client.
Advantages of type annotation for parameters:
- Automatic validation: if you enter something that is not an integer, FastAPI returns a validation error (eg:
"id is not a valid integer") - Automatic documentation: FastAPI uses type annotation to document parameters
Error case — 404 Not Found:
if event is None:
raise HTTPException(status_code=404, detail=f"No product with id {id}")
When the event does not exist, we throw an HTTPException with the code 404.
4.4 FastAPI documentation
FastAPI automatically generates documentation for API endpoints. It can be accessed at the URL /docs.
What we find there:
- The list of all endpoints (e.g.:
get_event, name taken from the name of the function) - The operation type (GET)
- The endpoint URL
- Parameters (e.g.:
id, typeinteger) - A “Try it out” button to directly test the endpoint as a test client
Adding a docstring improves the documentation:
def get_event(id: int, db: ...) -> DBEvent:
"""Retrieve a single event by id. Returns status 404 if event is not found."""
When refreshing the docs, this docstring appears in the documentation.
FastAPI generates comprehensive and user-friendly documentation for your API, which is one of the reasons for its great popularity.
Current limitation: There is no actual documentation of the returned JSON structure. To remedy this, we must formally define what the endpoint returns — this is the role of schemas.
4.5 Define a response pattern
schemas.py — The Event class (Pydantic)
import datetime
import decimal
from pydantic import BaseModel
class Event(BaseModel):
id: int
date: datetime.date
price: decimal.Decimal
Class Event inherits from BaseModel (imported from Pydantic). Such a class is similar to a standard Python dataclass — it’s a quick way to define a class with fields.
What you get for free with BaseModel:
- A
__init__method that validates its inputs - Type validation at instantiation
Demonstration in the Python console:
from globoticket.schemas import Event
from datetime import date
# Cela échoue car "2024-not-a-date" n'est pas une date valide :
e = Event(id=1, date="2024-not-a-date", price=10.50)
# => Pydantic lève une ValidationError
# Version correcte :
e = Event(id=1, date=date.today(), price=10.50)
# => fonctionne
Difference between DBEvent (SQLAlchemy) and Event (Pydantic)
| Class | Library | Role |
|--------|------------||------|
| DBEvent | SQLAlchemy | Defines the table structure in the database |
| Event | Pydantic | Defines the structure of JSON data returned by the API |
Event is used to define what will be sent by the API. Such a formal definition of a data structure is called a schema — hence the name schemas.py.
Use schema in endpoint
from globoticket.schemas import Event
@app.get("/event/{id}", response_model=Event)
def get_event(id: int, db: ...) -> DBEvent:
The response_model=Event parameter in the decorator tells FastAPI to use this pattern for the response. In the docs the JSON structure of the response is now documented. The diagrams also validate the answer.
4.6 An endpoint that returns a list of events
Exercise then solution
@app.get("/event/", response_model=list[Event])
def get_all_events(db: Annotated[Session, Depends(get_session)]) -> list[DBEvent]:
return get_all_dbevents(db)
The get_all_events function has the same db argument as get_event. The return type is slightly different: a list of DBEvent objects. The response_model says that we return a list of Event schemas.
Endpoint order — important point: FastAPI always processes the first matching endpoint. If we put
get_all_eventsbeforeget_event, all requests starting with/eventwould be handled byget_all_events, makingget_eventinaccessible. You must therefore putget_eventfirst. All/event/{something}requests will be handled byget_event. Only/event/requests with nothing after them will be processed byget_all_events.
4.7 Make the API testable
Extracting to crud.py
To make the code more testable, we extract the data access logic into a separate crud.py file (CRUD = Create, Read, Update, Delete):
from sqlalchemy import select
from sqlalchemy.orm import Session
from globoticket.models import DBEvent
def get_dbevent(id: int, db: Session) -> DBEvent | None:
return db.get(DBEvent, id)
def get_all_dbevents(db: Session) -> list[DBEvent]:
return db.execute(select(DBEvent)).scalars()
Advantage: Instead of mocking the call to SQLAlchemy, we can now mock the get_dbevent function, which is simpler.
We update api.py to use CRUD functions:
from globoticket.crud import get_all_dbevents, get_dbevent
@app.get("/event/{id}", response_model=Event)
def get_event(id: int, db: Annotated[Session, Depends(get_session)]) -> DBEvent:
"""Retrieve a single event by id. Returns status 404 if event is not found."""
event = get_dbevent(id, db)
if event is None:
raise HTTPException(status_code=404, detail=f"No product with id {id}")
return event
@app.get("/event/", response_model=list[Event])
def get_all_events(db: Annotated[Session, Depends(get_session)]) -> list[DBEvent]:
return get_all_dbevents(db)
4.8 Unit tests with mocking
test_get_event.py (version with mocks)
from unittest.mock import patch
import pytest
from fastapi import HTTPException
from globoticket.api import get_event
from globoticket.models import DBEvent
@patch("globoticket.api.get_dbevent", return_value=DBEvent(product_code="Test"))
def test_get_event(mock_get_dbevent):
"""Return the event found in the database."""
assert get_event(id=25, db="Fake db") is mock_get_dbevent.return_value
mock_get_dbevent.assert_called_with(25, "Fake db")
@patch("globoticket.api.get_dbevent", return_value=None)
def test_get_event_404(_):
"""Raise HTTPException when no event is found."""
with pytest.raises(HTTPException):
get_event(id=0, db=None)
Explanation of the decorator @patch
@patch("globoticket.api.get_dbevent") replaces the get_dbevent identifier in the globoticket.api module with a mock object. We use globoticket.api.get_dbevent (and not globoticket.crud.get_dbevent) because we replace the name as it exists in the API module (which imports the function), not where it is defined.
The mock is passed as an argument to the test function (mock_get_dbevent). We can:
- Set a return value:
return_value=DBEvent(product_code="Test") - Check how it was called:
mock_get_dbevent.assert_called_with(25, "Fake db")
The test verifies:
test_get_event: whatget_dbeventreturns must also be whatget_eventreturnstest_get_event_404: whenget_dbeventreturnsNone,get_eventshould throw anHTTPException
Advantage of mocking: We do not use the real database. We only test the logic of get_event in isolation.
4.9 Integration testing with TestClient
CustomerTest Concept
FastAPI includes its own TestClient, allowing you to write integration tests that simulate real HTTP calls. It comes from Starlette (which is included with FastAPI). Behind the scenes it uses HTTPX (an alternative to requests) but exposes the same interface.
from starlette.testclient import TestClient
from globoticket.api import app
client = TestClient(app)
response = client.get("/event/1")
This is not a unit test but an integration test. The function under test runs in the context of FastAPI and makes real queries to the (test) database.
Benefits of Integration Testing:
- Test how everything works together
- Detect Pydantic validation errors
- Detect errors in model classes or CRUD module
conftest.py — Configuring the test database
"""
conftest.py
---
Fixtures for pytest.
"""
from datetime import date
from pathlib import Path
from unittest.mock import patch
import pytest
import sqlalchemy
from sqlalchemy.orm import Session, sessionmaker
from starlette.testclient import TestClient
from globoticket.api import app, get_session
from globoticket.models import Base, DBCategory, DBEvent
# Créer une base de données en mémoire pour les tests
test_db = sqlalchemy.create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
echo=True,
)
test_sessionmaker = sessionmaker(bind=test_db)
def setup_test_db():
"""Create tables and test data in test db."""
Base.metadata.create_all(bind=test_db)
session = Session(test_db)
cat = DBCategory(name="t")
ev = DBEvent(product_code="123456", price=5.50, date=date(2024, 1, 1), category=cat)
session.add(cat)
session.add(ev)
session.commit()
def get_test_session():
"""Create a db session for a single test.
After the test: close the session and drop all tables."""
setup_test_db()
session = test_sessionmaker()
try:
yield session
finally:
session.close()
Base.metadata.drop_all(bind=test_db)
@pytest.fixture()
def client():
"""Create a TestClient that uses the test database."""
# See: https://fastapi.tiangolo.com/advanced/testing-database/
app.dependency_overrides[get_session] = get_test_session
yield TestClient(app)
del app.dependency_overrides[get_session]
Key points:
- We create an SQLite database in memory (
"sqlite+pysqlite:///:memory:") — it disappears at the end of the tests app.dependency_overrides[get_session] = get_test_session: replace dependencyget_sessionwith the test version. FastAPI will call ourget_test_sessioninstead ofget_sessionto create the DB session- After the tests, we remove the replacement:
del app.dependency_overrides[get_session] - Tables are created and dropped for each test (
create_allanddrop_all)
test_get_event.py (integration tests)
def test_client_get_event_1(client):
"""Call get_event using the client, retrieving event 1"""
response = client.get("/event/1")
assert response.status_code == 200
assert response.json() == {
"date": "2024-01-01",
"id": 1,
"price": "5.5000000000",
}
def test_client_get_event_404(client):
"""Call get_event using the client, with a non-existing id"""
response = client.get("/event/100")
assert response.status_code == 404
5. Reading contents from file system
5.1 Introduction to frontmatter files
In the previous module, we implemented endpoints to retrieve event data from the database. But some data is still missing: artist, image, name, etc., because these properties are not stored in the database but on the file system.
Structure of the product_info folder
The additional data is located in the product_info folder of the project. It contains subfolders for different music genres. Each subfolder can contain frontmatter files.
Example frontmatter file (product_info/rock/23534643.yml):
---
name: "Album Presentation: Batteries Included"
artist: Guido and the Snakes
published: t
image: guido.png
---
Guido van Rossum and his band of Pythonistas run through all the wonderful lines they have written
for their new Batteries Included Album.
Structure of a frontmatter file
A frontmatter file is composed of two parts separated by a line with three hyphens (---):
- First part (YAML): event metadata — name, artist, image file name, publishing status, etc. It’s a YAML format — a dictionary of key-value pairs (like a Python dict)
- Second part (text): textual content (description) after the separation line
5.2 Searching for files with pathlib
find_frontmatter_file function
Objective: Given a product code, recursively search the product_info folder for a file whose name matches this product code and ends with .yml.
We practice Test-Driven Development (TDD): we start with the tests that fail, then we implement the function.
Unit tests (test_find_frontmatter_files.py):
from pathlib import Path
import pytest
from globoticket.frontmatter import find_frontmatter_file
FM_PATH = Path(__file__).parent / "product_info"
def test_find_file_toplevel():
"""Locate a yaml file at toplevel directory"""
assert find_frontmatter_file("111222", FM_PATH) == FM_PATH / "111222.yml"
def test_find_file_subdirs():
"""Locate a yaml file in subdirectory"""
assert find_frontmatter_file("123456", FM_PATH) == FM_PATH / "hiphop" / "123456.yml"
assert find_frontmatter_file("654321", FM_PATH) == FM_PATH / "reggae" / "654321.yml"
def test_find_nosuchfile():
"""Raise correct exception when file does not exist"""
with pytest.raises(FileNotFoundError):
find_frontmatter_file("000000", FM_PATH)
Implementation of find_frontmatter_file:
def find_frontmatter_file(product_code: str, frontmatter_dir: Path) -> Path:
"""Find a file named `product_code`.yml in frontmatter_dir
and its subdirectories.
Raises FileNotFoundError if no file is found for this product_code."""
matches = list(frontmatter_dir.glob(f"**/{product_code}.yml"))
if not matches:
raise FileNotFoundError(
f"Cannot find yaml file for product {product_code} in {frontmatter_dir}"
)
return matches[0]
Explanation:
frontmatter_dir.glob(f"**/{product_code}.yml"): uses pathlib’s glob**: match any subfolder (recursively)/{product_code}.yml: searches for a file with this name and extension- If no file found →
FileNotFoundError - Returns the first result found
5.3 Parsing files with Regex and pyyaml
Format analysis
The frontmatter file has the following structure:
---
<données YAML>
---
<contenu texte>
We will use a regular expression to divide the file into parts, then PyYAML to parse the YAML part.
Custom exception type
class InvalidFrontmatterError(Exception):
pass
We create a custom exception type by inheriting from Exception and leaving the body empty (pass).
The regular expression FRONTMATTER_REGEX
FRONTMATTER_REGEX = re.compile(
r"---\n(?P<yaml>.*?)---\n(?P<content>.*)",
re.DOTALL,
)
Explanation of named groups:
(?P<yaml>.*?): group namedyaml— captures YAML data (the?makes the match non-greedy)(?P<content>.*): group namedcontent— captures content textre.DOTALL: the.also corresponds to line breaks
parse_frontmatter function
def parse_frontmatter(frontmatter: str) -> dict[str, Any]:
"""Return the contents of frontmatter as a dict.
Raises an InvalidFrontmatterError if anything is wrong."""
match = re.match(FRONTMATTER_REGEX, frontmatter)
if not match:
raise InvalidFrontmatterError("Invalid file structure")
try:
data = yaml.load(match.group("yaml"), Loader=yaml.FullLoader)
data["content"] = match.group("content") or ""
except (yaml.YAMLError, TypeError) as e:
raise InvalidFrontmatterError("Invalid YAML (should be a dict)") from e
return data
Two things that can go wrong:
- The YAML is invalid →
yaml.loadraisesyaml.YAMLError - The YAML is valid but is not a dict (YAML allows other types) →
TypeErrorbecause we are trying to assigndata["content"]to a non-dict
Unit tests for parse_frontmatter
import pytest
from globoticket.frontmatter import InvalidFrontmatterError, parse_frontmatter
def test_parse_frontmatter():
"""Parse a frontmatter file."""
fm = parse_frontmatter(
"""---
name: Live at Folsom Prison
artist: Johnny Cash
published: false
---
Sold out"""
)
assert fm == {
"content": "Sold out",
"name": "Live at Folsom Prison",
"artist": "Johnny Cash",
"published": False,
}
def test_parse_empty():
"""An empty file causes an error. So do files with only dashes in them."""
for s in ["", "----", "---\n---"]:
with pytest.raises(InvalidFrontmatterError):
parse_frontmatter(s)
def test_parse_content_only():
"""A file without YAML raises an error"""
with pytest.raises(InvalidFrontmatterError):
parse_frontmatter(
"""---
---
Test content"""
)
def test_parse_yaml_only():
"""Parse a file with YAML but empty content."""
fm = parse_frontmatter(
"""---
name: Live at Folsom Prison
artist: Johnny Cash
published: false
---
"""
)
assert fm == {
"content": "",
"name": "Live at Folsom Prison",
"artist": "Johnny Cash",
"published": False,
}
def test_parse_yaml_invalid():
"""Invalid YAML is not allowed."""
with pytest.raises(InvalidFrontmatterError):
parse_frontmatter(
"""---
- name: test
- artist: testartist
- published: true
---
Test content!"""
)
5.4 Add file contents to data model
Constant FRONTMATTER_DIRECTORY
FRONTMATTER_DIRECTORY = Path(__file__).parent.parent / "product_info"
get_frontmatter function
def get_frontmatter(product_code: str) -> dict[str, Any]:
"""Read the frontmatter for event with given product_code,
and return the properties as a dict."""
frontmatter = find_frontmatter_file(product_code, FRONTMATTER_DIRECTORY).read_text()
return parse_frontmatter(frontmatter)
This function combines find_frontmatter_file and parse_frontmatter: it finds the file, reads its contents, and parses it.
Trying with __init__ (does not work with SQLAlchemy)
We could think of adding a __init__ method to DBEvent:
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
frontmatter = get_frontmatter(self.product_code)
for k, v in frontmatter.items():
if not hasattr(self, k):
setattr(self, k, v)
But it doesn’t work: SQLAlchemy does not call __init__ when it reads a record from the database. It uses its own mechanisms to reconstruct objects.
Solution with @reconstructor
from sqlalchemy.orm import reconstructor
class DBEvent(Base):
__tablename__ = "event"
id: Mapped[int] = mapped_column(primary_key=True)
product_code: Mapped[str] = mapped_column(unique=True)
date: Mapped[date]
price: Mapped[decimal.Decimal]
category_id: Mapped[int] = mapped_column(ForeignKey("category.id"))
category: Mapped["DBCategory"] = relationship(back_populates="events")
@reconstructor
def _get_frontmatter(self):
frontmatter = get_frontmatter(self.product_code)
for k, v in frontmatter.items():
if not hasattr(self, k):
setattr(self, k, v)
The @reconstructor decorator tells SQLAlchemy to execute this method when it rebuilds an object from the database. So the frontmatter attributes are dynamically added to the DBEvent object via setattr.
Logic:
- For each key-value in the frontmatter dictionary, if the attribute does not yet exist on the object (e.g.
artist), we add it withsetattr - If the attribute already exists (e.g.
pricewhich comes from the DB), we do not overwrite it
Pydantic schema update
Now that the DBEvent object has additional fields, we update schemas.py:
import datetime
import decimal
from pydantic import BaseModel
class Event(BaseModel):
id: int
date: datetime.date
price: decimal.Decimal
artist: str
name: str
content: str
image: str
5.5 Fix unit tests
Problem
Integration tests fail because the constant FRONTMATTER_DIRECTORY in frontmatter.py points to the real product_info folder of the project — not the test folder.
First approach (by test) — not recommended
@patch(
"globoticket.frontmatter.FRONTMATTER_DIRECTORY",
new=Path(__file__).parent / "product_info",
)
def test_client_get_event_1(client):
...
Although this works, if you have many similar tests, you have to copy and paste this decorator everywhere. It’s not clean.
Best approach — autouse fixture in conftest.py
@pytest.fixture(autouse=True, scope="session")
def test_frontmatter():
"""Override the location of frontmatter files."""
with patch(
"globoticket.frontmatter.FRONTMATTER_DIRECTORY",
new=Path(__file__).parent / "product_info",
):
yield
autouse=True: this fixture runs automatically without the tests needing to explicitly request it.
scope="session": Runs once for the entire test session.
The with patch(...): block with yield encapsulates all tests in the context of the patch — all tests will run with FRONTMATTER_DIRECTORY pointing to the tests folder.
full final conftest.py (Module 5)
"""
conftest.py
---
Fixtures for pytest.
"""
from datetime import date
from pathlib import Path
from unittest.mock import patch
import pytest
import sqlalchemy
from sqlalchemy.orm import Session, sessionmaker
from starlette.testclient import TestClient
from globoticket.api import app, get_session
from globoticket.models import Base, DBCategory, DBEvent
test_db = sqlalchemy.create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
echo=True,
)
test_sessionmaker = sessionmaker(bind=test_db)
def setup_test_db():
"""Create tables and test data in test db."""
Base.metadata.create_all(bind=test_db)
session = Session(test_db)
cat = DBCategory(name="t")
ev = DBEvent(product_code="123456", price=5.50, date=date(2024, 1, 1), category=cat)
session.add(cat)
session.add(ev)
session.commit()
def get_test_session():
"""Create a db session for a single test.
After the test: close the session and drop all tables."""
setup_test_db()
session = test_sessionmaker()
try:
yield session
finally:
session.close()
Base.metadata.drop_all(bind=test_db)
@pytest.fixture()
def client():
"""Create a TestClient that uses the test database."""
app.dependency_overrides[get_session] = get_test_session
yield TestClient(app)
del app.dependency_overrides[get_session]
@pytest.fixture(autouse=True, scope="session")
def test_frontmatter():
"""Override the location of frontmatter files."""
with patch(
"globoticket.frontmatter.FRONTMATTER_DIRECTORY",
new=Path(__file__).parent / "product_info",
):
yield
Final tests with frontmatter (test_get_event.py — Module 5)
def test_client_get_event_1(client):
"""Call get_event using the client, retrieving event 1"""
response = client.get("/event/1")
assert response.status_code == 200
assert response.json() == {
"date": "2024-01-01",
"id": 1,
"price": "5.5000000000",
"artist": "Bob Marley",
"content": "Love the life you live. Live the life you love.",
"image": "whalers.jpg",
"name": "Live at the Rainbow",
}
def test_client_get_event_404(client):
"""Call get_event using the client, with a non-existing id"""
response = client.get("/event/100")
assert response.status_code == 404
6. Complete project structure
Final tree
globoticket/ # Package principal
├── __init__.py
├── api.py # Endpoints FastAPI
├── crud.py # Fonctions d'accès aux données
├── database.py # Configuration SQLAlchemy
├── frontmatter.py # Lecture des fichiers frontmatter
├── models.py # Modèles SQLAlchemy (DBEvent, DBCategory)
└── schemas.py # Schémas Pydantic (Event)
tests/ # Tests pytest
├── __init__.py
├── conftest.py # Fixtures partagées (DB de test, client)
├── product_info/ # Données de test (fichiers .yml)
│ ├── 111222.yml
│ ├── hiphop/
│ │ └── 123456.yml
│ └── reggae/
│ └── 654321.yml
├── test_find_frontmatter_files.py
├── test_get_event.py
└── test_parse_frontmatter.py
product_info/ # Vraies données (fichiers frontmatter)
├── classical/
├── jazz/
└── rock/
└── 23534643.yml
static/ # Fichiers HTML statiques
runserver.py # Script de démarrage
pyproject.toml # Configuration Poetry
.env # Variables d'environnement (non committé)
.pre-commit-config.yaml # Configuration pre-commit
database.ddl # Schéma SQL de référence
events.db # Base de données SQLite
final pyproject.toml
[tool.poetry]
name = "globoticket"
version = "0.1.0"
description = "Demo project for the Pluralsight course 'Building a REST API with Python 3'"
authors = ["Reindert-Jan Ekker <info@codesensei.nl>"]
readme = "README.md"
[tool.poetry.dependencies]
python = "^3.10"
fastapi = {extras = ["all"], version = "^0.103.1"}
sqlalchemy = "^2.0.20"
python-dotenv = "^1.0.0"
pyyaml = "^6.0.1"
[tool.poetry.group.dev.dependencies]
pytest = "^7.4.1"
pre-commit = "^3.4.0"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
Final globoticket/api.py file
"""
api.py
---
The REST Api for the Globoticket events database.
"""
from pathlib import Path
from typing import Annotated
from fastapi import Depends, FastAPI, HTTPException
from sqlalchemy.orm import Session
from starlette.staticfiles import StaticFiles
from globoticket.crud import get_all_dbevents, get_dbevent
from globoticket.database import SessionLocal
from globoticket.models import DBEvent
from globoticket.schemas import Event
app = FastAPI()
PROJECT_ROOT = Path(__file__).parent.parent
def get_session() -> Session:
session = SessionLocal()
try:
yield session
finally:
session.close()
@app.get("/event/{id}", response_model=Event)
def get_event(id: int, db: Annotated[Session, Depends(get_session)]) -> DBEvent:
"""Retrieve a single event by id. Returns status 404 if event is not found."""
event = get_dbevent(id, db)
if event is None:
raise HTTPException(status_code=404, detail=f"No product with id {id}")
return event
@app.get("/event/", response_model=list[Event])
def get_all_events(db: Annotated[Session, Depends(get_session)]) -> list[DBEvent]:
return get_all_dbevents(db)
# This should come AFTER all other endpoints
app.mount("/", StaticFiles(directory=PROJECT_ROOT / "static", html=True))
Final globoticket/frontmatter.py file
import re
from pathlib import Path
from typing import Any
import yaml
FRONTMATTER_DIRECTORY = Path(__file__).parent.parent / "product_info"
def get_frontmatter(product_code: str) -> dict[str, Any]:
"""Read the frontmatter for event with given product_code,
and return the properties as a dict."""
frontmatter = find_frontmatter_file(product_code, FRONTMATTER_DIRECTORY).read_text()
return parse_frontmatter(frontmatter)
def find_frontmatter_file(product_code: str, frontmatter_dir: Path) -> Path:
"""Find a file named `product_code`.yml in frontmatter_dir
and its subdirectories.
Raises FileNotFoundError if no file is found for this product_code."""
matches = list(frontmatter_dir.glob(f"**/{product_code}.yml"))
if not matches:
raise FileNotFoundError(
f"Cannot find yaml file for product {product_code} in {frontmatter_dir}"
)
return matches[0]
class InvalidFrontmatterError(Exception):
pass
FRONTMATTER_REGEX = re.compile(
r"---\n(?P<yaml>.*?)---\n(?P<content>.*)",
re.DOTALL,
)
def parse_frontmatter(frontmatter: str) -> dict[str, Any]:
"""Return the contents of frontmatter as a dict.
Raises an InvalidFrontmatterError if anything is wrong."""
match = re.match(FRONTMATTER_REGEX, frontmatter)
if not match:
raise InvalidFrontmatterError("Invalid file structure")
try:
data = yaml.load(match.group("yaml"), Loader=yaml.FullLoader)
data["content"] = match.group("content") or ""
except (yaml.YAMLError, TypeError) as e:
raise InvalidFrontmatterError("Invalid YAML (should be a dict)") from e
return data
Final globoticket/models.py file
import decimal
from datetime import date
from sqlalchemy import ForeignKey
from sqlalchemy.orm import (
DeclarativeBase,
Mapped,
declarative_base,
mapped_column,
reconstructor,
relationship,
)
from globoticket.frontmatter import get_frontmatter
Base: DeclarativeBase = declarative_base()
class DBCategory(Base):
__tablename__ = "category"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(unique=True)
events: Mapped[list["DBEvent"]] = relationship(back_populates="category")
class DBEvent(Base):
__tablename__ = "event"
id: Mapped[int] = mapped_column(primary_key=True)
product_code: Mapped[str] = mapped_column(unique=True)
date: Mapped[date]
price: Mapped[decimal.Decimal]
category_id: Mapped[int] = mapped_column(ForeignKey("category.id"))
category: Mapped["DBCategory"] = relationship(back_populates="events")
@reconstructor
def _get_frontmatter(self):
frontmatter = get_frontmatter(self.product_code)
for k, v in frontmatter.items():
if not hasattr(self, k):
setattr(self, k, v)
Search Terms
rest · api · python · java · backend · architecture · full-stack · web · globoticket · tests · data · database · endpoint · function · model · version · configuration · conftest.py · frontmatter · pydantic · schema · testgetevent.py · unit · api.py