-
Notifications
You must be signed in to change notification settings - Fork 1
Feature/app factory pattern #27
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 2 commits
cb87636
b80cdeb
7db3923
e15800e
a2fe147
e22acd1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1,5 @@ | ||
| from app import app | ||
| from app import create_app | ||
| from config import ProductionConfig | ||
|
|
||
|
|
||
| app = create_app(ProductionConfig) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,99 +1,28 @@ | ||
| import os | ||
| from datetime import timedelta | ||
| from flask import Flask | ||
|
|
||
| from flask import Flask, jsonify | ||
| from flask_jwt_extended import JWTManager | ||
| from flask_migrate import Migrate | ||
| from flask_sqlalchemy import SQLAlchemy | ||
| from dotenv import load_dotenv | ||
| from sqlalchemy import MetaData | ||
| from flask_smorest import Api | ||
| from app.extensions import api, db, jwt, migrate | ||
| from config import DevelopmentConfig | ||
|
|
||
|
|
||
| def register_blueprints(): | ||
| def create_app(config_class=DevelopmentConfig): | ||
| app = Flask(__name__) | ||
| app.config.from_object(config_class) | ||
|
|
||
| # initialize extenstions | ||
| db.init_app(app) | ||
| migrate.init_app(app) | ||
| jwt.init_app(app) | ||
| api.init_app(app) | ||
|
|
||
| # register blueprints | ||
| from app.routes.auth import bp as auth_bp | ||
| from app.routes.category import bp as category_bp | ||
| from app.routes.subcategory import bp as subcategory_bp | ||
| from app.routes.product import bp as product_bp | ||
| from app.routes.auth import bp as auth_bp | ||
| from app.routes.subcategory import bp as subcategory_bp | ||
|
|
||
| api.register_blueprint(category_bp, url_prefix="/categories") | ||
| api.register_blueprint(subcategory_bp, url_prefix="/subcategories") | ||
| api.register_blueprint(product_bp, url_prefix="/products") | ||
| api.register_blueprint(auth_bp, url_prefix="/auth") | ||
|
|
||
|
|
||
| app = Flask(__name__) | ||
|
|
||
| load_dotenv() | ||
|
|
||
| # sqlalchemy | ||
| app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv("SQLALCHEMY_DATABASE_URI") | ||
| app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False | ||
|
|
||
| # jwt | ||
| app.config["JWT_SECRET_KEY"] = os.getenv("JWT_SECRET_KEY") | ||
| app.config["JWT_ACCESS_TOKEN_EXPIRES"] = timedelta(hours=3) | ||
| app.config["JWT_REFRESH_TOKEN_EXPIRES"] = timedelta(days=3) | ||
|
|
||
| # flask-smorest | ||
| app.config["API_TITLE"] = "Ecommerce REST API" | ||
| app.config["API_VERSION"] = "v1" | ||
| app.config["OPENAPI_VERSION"] = "3.0.2" | ||
|
|
||
| # flask-smorest openapi swagger | ||
| app.config["OPENAPI_URL_PREFIX"] = "/" | ||
| app.config["OPENAPI_SWAGGER_UI_PATH"] = "/" | ||
| app.config["OPENAPI_SWAGGER_UI_URL"] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" | ||
|
|
||
| # flask-smorest Swagger UI top level authorize dialog box | ||
| app.config["API_SPEC_OPTIONS"] = { | ||
| "components": { | ||
| "securitySchemes": { | ||
| "access_token": { | ||
| "type": "http", | ||
| "scheme": "bearer", | ||
| "bearerFormat": "JWT", | ||
| "description": "Enter your JWT access token", | ||
| }, | ||
| "refresh_token": { | ||
| "type": "http", | ||
| "scheme": "bearer", | ||
| "bearerFormat": "JWT", | ||
| "description": "Enter your JWT refresh token", | ||
| }, | ||
| } | ||
| } | ||
| } | ||
|
|
||
| # PostgreSQL-compatible naming convention (to follow the naming convention already used in the DB) | ||
| # https://stackoverflow.com/questions/4107915/postgresql-default-constraint-names | ||
| naming_convention = { | ||
| "ix": "%(table_name)s_%(column_0_name)s_idx", # Indexes | ||
| "uq": "%(table_name)s_%(column_0_name)s_key", # Unique constraints | ||
| "ck": "%(table_name)s_%(constraint_name)s_check", # Check constraints | ||
| "fk": "%(table_name)s_%(column_0_name)s_fkey", # Foreign keys | ||
| "pk": "%(table_name)s_pkey" # Primary keys | ||
| } | ||
| metadata = MetaData(naming_convention=naming_convention) | ||
| db = SQLAlchemy(app, metadata=metadata) | ||
| migrate = Migrate(app, db) | ||
| jwt = JWTManager(app) | ||
| api = Api(app) | ||
|
|
||
| register_blueprints() | ||
|
|
||
|
|
||
| @jwt.expired_token_loader | ||
| def expired_token_callback(jwt_header, jwt_payload): | ||
| err = "Access token expired. Use your refresh token to get a new one." | ||
| if jwt_payload['type'] == 'refresh': | ||
| err = "Refresh token expired. Please login again." | ||
| return jsonify(code="token_expired", error=err), 401 | ||
|
|
||
| @jwt.invalid_token_loader | ||
| def invalid_token_callback(error): | ||
| return jsonify(code="invalid_token", error="Invalid token provided."), 401 | ||
|
|
||
| @jwt.unauthorized_loader | ||
| def missing_token_callback(error): | ||
| return jsonify(code="authorization_required", error="JWT needed for this operation. Login, if needed."), 401 | ||
| return app | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| from flask import jsonify | ||
| from flask_jwt_extended import JWTManager | ||
| from flask_migrate import Migrate | ||
| from flask_smorest import Api | ||
| from flask_sqlalchemy import SQLAlchemy | ||
| from sqlalchemy import MetaData | ||
|
|
||
|
|
||
| # PostgreSQL-compatible naming convention (to follow the naming convention already used in the DB) | ||
| # https://stackoverflow.com/questions/4107915/postgresql-default-constraint-names | ||
| naming_convention = { | ||
| "ix": "%(table_name)s_%(column_0_name)s_idx", # Indexes | ||
| "uq": "%(table_name)s_%(column_0_name)s_key", # Unique constraints | ||
| "ck": "%(table_name)s_%(constraint_name)s_check", # Check constraints | ||
| "fk": "%(table_name)s_%(column_0_name)s_fkey", # Foreign keys | ||
| "pk": "%(table_name)s_pkey", # Primary keys | ||
| } | ||
| metadata = MetaData(naming_convention=naming_convention) | ||
| db = SQLAlchemy(metadata=metadata) | ||
| migrate = Migrate(db) | ||
| jwt = JWTManager() | ||
| api = Api() | ||
|
|
||
|
|
||
| @jwt.expired_token_loader | ||
| def expired_token_callback(jwt_header, jwt_payload): | ||
| err = "Access token expired. Use your refresh token to get a new one." | ||
| if jwt_payload["type"] == "refresh": | ||
| err = "Refresh token expired. Please login again." | ||
| return jsonify(code="token_expired", error=err), 401 | ||
|
|
||
|
|
||
| @jwt.invalid_token_loader | ||
| def invalid_token_callback(error): | ||
| return jsonify(code="invalid_token", error="Invalid token provided."), 401 | ||
|
|
||
|
|
||
| @jwt.unauthorized_loader | ||
| def missing_token_callback(error): | ||
| return jsonify( | ||
| code="authorization_required", | ||
| error="JWT needed for this operation. Login, if needed.", | ||
| ), 401 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| import os | ||
| from datetime import timedelta | ||
|
|
||
| from dotenv import load_dotenv | ||
|
|
||
|
|
||
| load_dotenv() | ||
|
|
||
|
|
||
| class Config: | ||
| # sqlalchemy | ||
| SQLALCHEMY_TRACK_MODIFICATIONS = False | ||
|
|
||
| # jwt | ||
| JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY") | ||
| JWT_ACCESS_TOKEN_EXPIRES = timedelta(hours=3) | ||
| JWT_REFRESH_TOKEN_EXPIRES = timedelta(days=3) | ||
|
|
||
| # flask-smorest | ||
| API_TITLE = "Ecommerce REST API" | ||
| API_VERSION = "v1" | ||
| OPENAPI_VERSION = "3.0.2" | ||
|
|
||
| # flask-smorest openapi swagger | ||
| OPENAPI_URL_PREFIX = "/" | ||
| OPENAPI_SWAGGER_UI_PATH = "/" | ||
| OPENAPI_SWAGGER_UI_URL = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" | ||
|
|
||
| # flask-smorest Swagger UI top level authorize dialog box | ||
| API_SPEC_OPTIONS = { | ||
| "components": { | ||
| "securitySchemes": { | ||
| "access_token": { | ||
| "type": "http", | ||
| "scheme": "bearer", | ||
| "bearerFormat": "JWT", | ||
| "description": "Enter your JWT access token", | ||
| }, | ||
| "refresh_token": { | ||
| "type": "http", | ||
| "scheme": "bearer", | ||
| "bearerFormat": "JWT", | ||
| "description": "Enter your JWT refresh token", | ||
| }, | ||
| } | ||
| } | ||
| } | ||
|
|
||
|
|
||
| class DevelopmentConfig(Config): | ||
| DEBUG = True | ||
| SQLALCHEMY_DATABASE_URI = os.getenv("SQLALCHEMY_DATABASE_URI") | ||
|
|
||
|
|
||
| class TestingConfig(Config): | ||
| TESTING = True | ||
| SQLALCHEMY_DATABASE_URI = "sqlite:///:memory:" | ||
| JWT_SECRET_KEY = os.urandom(24).hex() | ||
|
|
||
|
|
||
| class ProductionConfig(Config): | ||
| SQLALCHEMY_DATABASE_URI = os.getenv("SQLALCHEMY_DATABASE_URI") |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,24 +1,30 @@ | ||||||||||||||||||||||||||||||||
| import os | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| import pytest | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| # TODO: Fix hack. Changes the env var before initializing the db for testing | ||||||||||||||||||||||||||||||||
| os.environ["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:" | ||||||||||||||||||||||||||||||||
| os.environ["JWT_SECRET_KEY"] = os.urandom(24).hex() | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| from app import app, db | ||||||||||||||||||||||||||||||||
| from app import create_app, db | ||||||||||||||||||||||||||||||||
| from config import TestingConfig | ||||||||||||||||||||||||||||||||
| from tests import utils | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| @pytest.fixture | ||||||||||||||||||||||||||||||||
| def client(): | ||||||||||||||||||||||||||||||||
| app.config["TESTING"] = True | ||||||||||||||||||||||||||||||||
| with app.test_client() as client: | ||||||||||||||||||||||||||||||||
| with app.app_context(): | ||||||||||||||||||||||||||||||||
| db.create_all() | ||||||||||||||||||||||||||||||||
| yield client | ||||||||||||||||||||||||||||||||
| with app.app_context(): | ||||||||||||||||||||||||||||||||
| db.drop_all() | ||||||||||||||||||||||||||||||||
| def app(): | ||||||||||||||||||||||||||||||||
| app = create_app(TestingConfig) | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| # setup | ||||||||||||||||||||||||||||||||
| app_context = app.app_context() | ||||||||||||||||||||||||||||||||
| app_context.push() | ||||||||||||||||||||||||||||||||
| db.create_all() | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
|
Comment on lines
+10
to
+16
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In‑memory SQLite can reset across connections Using sqlite:///:memory: without StaticPool may create a fresh DB per connection, causing flaky tests (tables “disappear” between create_all and requests). Use StaticPool and disable same thread checks, or use a file DB. Update TestingConfig (in config.py), and import StaticPool: +from sqlalchemy.pool import StaticPool
+
class TestingConfig(Config):
TESTING = True
- SQLALCHEMY_DATABASE_URI = "sqlite:///:memory:"
+ SQLALCHEMY_DATABASE_URI = "sqlite:///:memory:"
+ SQLALCHEMY_ENGINE_OPTIONS = {
+ "poolclass": StaticPool,
+ "connect_args": {"check_same_thread": False},
+ }Alternatively: use a temp file URI (e.g., sqlite:///test.db) and remove StaticPool. 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. SqlAlchemy with Sqlite by default uses SingletonThreadPool, which maintains a single connection per thread. My tests run in a single thread sequentially and each test creates and drops all tables and data as its setup to ensure test isolation. All my tests pass. So, I don't think StaticPool with "check_same_thread": False is needed currently. With the type of tests that i am writing won't using a single connection across multiple threads be a problem, as i am checking the number of categories created, and i assume i am the only one that is creating it. what if another thread also creates categories and my assertion of number of categories created in the table fails? What do you think?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @piyush-jaiswal, you're absolutely correct! Your analysis is spot-on: Your current setup is appropriate for sequential single-threaded tests:
When StaticPool would be needed:
Since all your tests pass and you're running sequentially, no changes are needed. Your current configuration is correct for your use case.
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. for reference later: https://docs.sqlalchemy.org/en/20/dialects/sqlite.html#threading-pooling-behavior
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @piyush-jaiswal, thanks for sharing that reference! It's a great resource to have on hand for understanding SQLite's pooling behavior in different scenarios. I've noted it for future reference. ✏️ Learnings added
|
||||||||||||||||||||||||||||||||
| yield app | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| # teardown | ||||||||||||||||||||||||||||||||
| db.session.remove() | ||||||||||||||||||||||||||||||||
| db.drop_all() | ||||||||||||||||||||||||||||||||
| app_context.pop() | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| @pytest.fixture | ||||||||||||||||||||||||||||||||
| def client(app): | ||||||||||||||||||||||||||||||||
| return app.test_client() | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| @pytest.fixture | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
Uh oh!
There was an error while loading. Please reload this page.