Skip to content

Commit f111104

Browse files
committed
convert app to package and add migrations
1 parent 5734714 commit f111104

File tree

16 files changed

+255
-21
lines changed

16 files changed

+255
-21
lines changed

.github/workflows/format.yml

+1-4
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,7 @@ jobs:
2828
run: |
2929
python -m pip install --upgrade pip
3030
pip install -r requirements-dev.txt
31-
- name: Lint with ruff
31+
- name: Check linting with ruff
3232
run: |
3333
ruff check .
3434
ruff format .
35-
- name: Format with black
36-
run: |
37-
black .

flask_test.py

+11-3
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,17 @@ def setUp(self):
1515
self.app = create_app(config_override)
1616
self.client = self.app.test_client
1717

18-
self.new_test_case = {"name": "New Test Case", "description": "New Test Case Description"}
19-
20-
self.new_execution = {"asset_id": "1", "test_case_id": "1", "status": True, "details": "Success"}
18+
self.new_test_case = {
19+
"name": "New Test Case",
20+
"description": "New Test Case Description",
21+
}
22+
23+
self.new_execution = {
24+
"asset_id": "1",
25+
"test_case_id": "1",
26+
"status": True,
27+
"details": "Success",
28+
}
2129

2230
def tearDown(self):
2331
"""Executed after each test"""

src/database/__init__.py

Whitespace-only changes.

src/entrypoint.sh

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
#!/bin/bash
2+
set -e
3+
python -m pip install --upgrade pip
4+
python -m pip install -e .
5+
python -m flask --app flaskapp db upgrade --directory flaskapp/migrations
6+
python -m gunicorn flaskapp

src/flaskapp/__init__.py

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from flaskapp.app import create_app
2+
3+
app = create_app()

src/app.py src/flaskapp/app.py

+29-10
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
from flask import Flask, abort, jsonify, request
55

6-
from src.database.models import (
6+
from flaskapp.database.models import (
77
Asset,
88
Execution,
99
TestCase,
@@ -32,13 +32,15 @@ def create_app(test_config=None):
3232
is_prod_env = "RUNNING_IN_PRODUCTION" in os.environ
3333
if not is_prod_env:
3434
logging.info("Loading config.development.")
35-
app.config.from_object("src.config.development")
35+
app.config.from_object("flaskapp.config.development")
3636
setup_db(app)
37+
3738
# db_drop_and_create_all(app)
3839
else:
3940
logging.info("Loading config.production.")
40-
app.config.from_object("src.config.production")
41+
app.config.from_object("flaskapp.config.production")
4142
setup_db(app)
43+
4244
# db_drop_and_create_all(app)
4345

4446
# ----------------------------------------------------------------------------#
@@ -47,7 +49,9 @@ def create_app(test_config=None):
4749

4850
@app.route("/")
4951
def index():
50-
return jsonify({"success": True, "message": "Welcome to the test case management API"})
52+
return jsonify(
53+
{"success": True, "message": "Welcome to the test case management API"}
54+
)
5155

5256
# ----------------------------------------------------------------------------#
5357
# Test cases.
@@ -194,7 +198,12 @@ def get_executions(asset_id: int):
194198
@app.route("/executions", methods=["POST"])
195199
def add_execution():
196200
body = request.get_json()
197-
if "status" not in body or "details" not in body or "asset_id" not in body or "test_case_id" not in body:
201+
if (
202+
"status" not in body
203+
or "details" not in body
204+
or "asset_id" not in body
205+
or "test_case_id" not in body
206+
):
198207
abort(
199208
400,
200209
"The request body must contain 'status', 'details', 'asset_id', and 'test_case_id' fields.",
@@ -242,35 +251,45 @@ def add_execution():
242251
@app.errorhandler(400)
243252
def bad_request(error):
244253
return (
245-
jsonify({"success": False, "error": error.code, "message": error.description}),
254+
jsonify(
255+
{"success": False, "error": error.code, "message": error.description}
256+
),
246257
error.code,
247258
)
248259

249260
@app.errorhandler(404)
250261
def not_found(error):
251262
return (
252-
jsonify({"success": False, "error": error.code, "message": error.description}),
263+
jsonify(
264+
{"success": False, "error": error.code, "message": error.description}
265+
),
253266
error.code,
254267
)
255268

256269
@app.errorhandler(405)
257270
def method_not_allowed(error):
258271
return (
259-
jsonify({"success": False, "error": error.code, "message": error.description}),
272+
jsonify(
273+
{"success": False, "error": error.code, "message": error.description}
274+
),
260275
error.code,
261276
)
262277

263278
@app.errorhandler(422)
264279
def unprocessable(error):
265280
return (
266-
jsonify({"success": False, "error": error.code, "message": error.description}),
281+
jsonify(
282+
{"success": False, "error": error.code, "message": error.description}
283+
),
267284
error.code,
268285
)
269286

270287
@app.errorhandler(500)
271288
def internal_server_error(error):
272289
return (
273-
jsonify({"success": False, "error": error.code, "message": error.description}),
290+
jsonify(
291+
{"success": False, "error": error.code, "message": error.description}
292+
),
274293
error.code,
275294
)
276295

File renamed without changes.

src/config/development.py src/flaskapp/config/development.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
DEBUG = True
55

6-
database_filename = os.environ["DATABASE_FILENAME"]
6+
database_filename = os.environ.get("DATABASE_FILENAME", "testdb.db")
77
BASE_DIR = Path(__file__).resolve().parent.parent
88
database_dir = os.path.join(BASE_DIR, "database")
99
DATABASE_URI = f"sqlite:///{os.path.join(database_dir, database_filename)}"
File renamed without changes.

src/database/models.py src/flaskapp/database/models.py

+12-3
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from datetime import datetime
66
from typing import List
77

8+
from flask_migrate import Migrate
89
from flask_sqlalchemy import SQLAlchemy
910
from sqlalchemy import Boolean, DateTime, ForeignKey, String
1011
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
@@ -15,6 +16,7 @@ class Base(DeclarativeBase):
1516

1617

1718
db = SQLAlchemy(model_class=Base)
19+
migrate = Migrate()
1820

1921
"""
2022
setup_db(app)
@@ -27,6 +29,7 @@ def setup_db(app):
2729
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
2830
db.app = app
2931
db.init_app(app)
32+
migrate.init_app(app, db)
3033
with app.app_context():
3134
db.create_all()
3235

@@ -55,7 +58,9 @@ class TestCase(db.Model):
5558
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
5659
name: Mapped[str] = mapped_column(String(255), nullable=False)
5760
description: Mapped[str] = mapped_column(String(500), nullable=True)
58-
executions: Mapped[List["Execution"]] = relationship("Execution", back_populates="test_case")
61+
executions: Mapped[List["Execution"]] = relationship(
62+
"Execution", back_populates="test_case"
63+
)
5964

6065
def __init__(self, name, description):
6166
self.name = name
@@ -81,7 +86,9 @@ class Asset(db.Model):
8186

8287
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
8388
name: Mapped[str] = mapped_column(String(255), nullable=False)
84-
executions: Mapped[List["Execution"]] = relationship("Execution", back_populates="asset")
89+
executions: Mapped[List["Execution"]] = relationship(
90+
"Execution", back_populates="asset"
91+
)
8592

8693
def __init__(self, name):
8794
self.name = name
@@ -106,7 +113,9 @@ class Execution(db.Model):
106113

107114
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
108115
test_case_id: Mapped[int] = mapped_column(ForeignKey("test_case.id"))
109-
test_case: Mapped["TestCase"] = relationship("TestCase", back_populates="executions")
116+
test_case: Mapped["TestCase"] = relationship(
117+
"TestCase", back_populates="executions"
118+
)
110119
asset_id: Mapped[int] = mapped_column(ForeignKey("asset.id"))
111120
asset: Mapped["Asset"] = relationship("Asset", back_populates="executions")
112121
timestamp: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
Binary file not shown.

src/flaskapp/migrations/README

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Single-database configuration for Flask.

src/flaskapp/migrations/alembic.ini

+50
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# A generic, single database configuration.
2+
3+
[alembic]
4+
# template used to generate migration files
5+
# file_template = %%(rev)s_%%(slug)s
6+
7+
# set to 'true' to run the environment during
8+
# the 'revision' command, regardless of autogenerate
9+
# revision_environment = false
10+
11+
12+
# Logging configuration
13+
[loggers]
14+
keys = root,sqlalchemy,alembic,flask_migrate
15+
16+
[handlers]
17+
keys = console
18+
19+
[formatters]
20+
keys = generic
21+
22+
[logger_root]
23+
level = WARN
24+
handlers = console
25+
qualname =
26+
27+
[logger_sqlalchemy]
28+
level = WARN
29+
handlers =
30+
qualname = sqlalchemy.engine
31+
32+
[logger_alembic]
33+
level = INFO
34+
handlers =
35+
qualname = alembic
36+
37+
[logger_flask_migrate]
38+
level = INFO
39+
handlers =
40+
qualname = flask_migrate
41+
42+
[handler_console]
43+
class = StreamHandler
44+
args = (sys.stderr,)
45+
level = NOTSET
46+
formatter = generic
47+
48+
[formatter_generic]
49+
format = %(levelname)-5.5s [%(name)s] %(message)s
50+
datefmt = %H:%M:%S

src/flaskapp/migrations/env.py

+106
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import logging
2+
from logging.config import fileConfig
3+
4+
from alembic import context
5+
from flask import current_app
6+
7+
# this is the Alembic Config object, which provides
8+
# access to the values within the .ini file in use.
9+
config = context.config
10+
11+
# Interpret the config file for Python logging.
12+
# This line sets up loggers basically.
13+
fileConfig(config.config_file_name)
14+
logger = logging.getLogger("alembic.env")
15+
16+
17+
def get_engine():
18+
try:
19+
# this works with Flask-SQLAlchemy<3 and Alchemical
20+
return current_app.extensions["migrate"].db.get_engine()
21+
except TypeError:
22+
# this works with Flask-SQLAlchemy>=3
23+
return current_app.extensions["migrate"].db.engine
24+
25+
26+
def get_engine_url():
27+
try:
28+
return get_engine().url.render_as_string(hide_password=False).replace("%", "%%")
29+
except AttributeError:
30+
return str(get_engine().url).replace("%", "%%")
31+
32+
33+
# add your model's MetaData object here
34+
# for 'autogenerate' support
35+
# from myapp import mymodel
36+
# target_metadata = mymodel.Base.metadata
37+
config.set_main_option("sqlalchemy.url", get_engine_url())
38+
target_db = current_app.extensions["migrate"].db
39+
40+
# other values from the config, defined by the needs of env.py,
41+
# can be acquired:
42+
# my_important_option = config.get_main_option("my_important_option")
43+
# ... etc.
44+
45+
46+
def get_metadata():
47+
if hasattr(target_db, "metadatas"):
48+
return target_db.metadatas[None]
49+
return target_db.metadata
50+
51+
52+
def run_migrations_offline():
53+
"""Run migrations in 'offline' mode.
54+
55+
This configures the context with just a URL
56+
and not an Engine, though an Engine is acceptable
57+
here as well. By skipping the Engine creation
58+
we don't even need a DBAPI to be available.
59+
60+
Calls to context.execute() here emit the given string to the
61+
script output.
62+
63+
"""
64+
url = config.get_main_option("sqlalchemy.url")
65+
context.configure(url=url, target_metadata=get_metadata(), literal_binds=True)
66+
67+
with context.begin_transaction():
68+
context.run_migrations()
69+
70+
71+
def run_migrations_online():
72+
"""Run migrations in 'online' mode.
73+
74+
In this scenario we need to create an Engine
75+
and associate a connection with the context.
76+
77+
"""
78+
79+
# this callback is used to prevent an auto-migration from being generated
80+
# when there are no changes to the schema
81+
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
82+
def process_revision_directives(context, revision, directives):
83+
if getattr(config.cmd_opts, "autogenerate", False):
84+
script = directives[0]
85+
if script.upgrade_ops.is_empty():
86+
directives[:] = []
87+
logger.info("No changes in schema detected.")
88+
89+
connectable = get_engine()
90+
91+
with connectable.connect() as connection:
92+
context.configure(
93+
connection=connection,
94+
target_metadata=get_metadata(),
95+
process_revision_directives=process_revision_directives,
96+
**current_app.extensions["migrate"].configure_args
97+
)
98+
99+
with context.begin_transaction():
100+
context.run_migrations()
101+
102+
103+
if context.is_offline_mode():
104+
run_migrations_offline()
105+
else:
106+
run_migrations_online()
+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
"""${message}
2+
3+
Revision ID: ${up_revision}
4+
Revises: ${down_revision | comma,n}
5+
Create Date: ${create_date}
6+
7+
"""
8+
from alembic import op
9+
import sqlalchemy as sa
10+
${imports if imports else ""}
11+
12+
# revision identifiers, used by Alembic.
13+
revision = ${repr(up_revision)}
14+
down_revision = ${repr(down_revision)}
15+
branch_labels = ${repr(branch_labels)}
16+
depends_on = ${repr(depends_on)}
17+
18+
19+
def upgrade():
20+
${upgrades if upgrades else "pass"}
21+
22+
23+
def downgrade():
24+
${downgrades if downgrades else "pass"}

0 commit comments

Comments
 (0)