Skip to content

Commit da87e4a

Browse files
authoredDec 5, 2023
Update for new flask/flask-sqlalchemy (#33)
* Update for new flask/flask-sqlalchemy * Update CI * Bump version
1 parent 147a572 commit da87e4a

12 files changed

+79
-101
lines changed
 

‎.github/workflows/test.yml

+8-11
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,9 @@ jobs:
1010
runs-on: ${{ matrix.os }}
1111
services:
1212
postgres:
13-
image: postgres:11.6
13+
image: postgres:14.9
1414
env:
15-
POSTGRES_DB: chrononaut_test
16-
POSTGRES_USER: postgres
17-
POSTGRES_PASSWORD: ""
15+
POSTGRES_PASSWORD: "password"
1816
ports:
1917
- 5432:5432
2018
# needed because the postgres container does not provide a healthcheck
@@ -23,19 +21,16 @@ jobs:
2321
strategy:
2422
matrix:
2523
os: [ubuntu-latest]
26-
python-version: ['3.7', '3.8', '3.9', '3.10']
24+
python-version: ['3.8', '3.9', '3.10', '3.11', '3.12']
2725

2826
steps:
29-
- uses: actions/checkout@v2
27+
- uses: actions/checkout@v3
3028
- name: Set up Python
31-
uses: actions/setup-python@v1
29+
uses: actions/setup-python@v4
3230
with:
3331
python-version: ${{ matrix.python-version }}
3432
- name: Display Python version
3533
run: python -c "import sys; print(sys.version)"
36-
- name: Install virtualenv (Python2/3 compat)
37-
run: |
38-
pip install --progress-bar=off virtualenv
3934
- name: Cache virtualenv
4035
uses: actions/cache@v1
4136
id: cache-pip
@@ -45,12 +40,14 @@ jobs:
4540
- name: Install dependencies in a venv
4641
if: steps.cache-pip.outputs.cache-hit != 'true'
4742
run: |
48-
virtualenv venv
43+
python -m venv venv
4944
. venv/bin/activate
5045
pip install -q -U pip
5146
pip install --progress-bar=off .
5247
pip install --progress-bar=off -r requirements.txt
5348
- name: Run all tests
49+
env:
50+
SQLALCHEMY_DATABASE_URI: postgresql://postgres:password@localhost:5432/chrononaut_test
5451
run: |
5552
. venv/bin/activate
5653
py.test tests/

‎chrononaut/__init__.py

+10-9
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@
77
:license: MIT, see LICENSE for more details.
88
"""
99

10-
import sqlalchemy
1110
from sqlalchemy import event
1211
from flask import g
13-
from flask_sqlalchemy import SignallingSession, SQLAlchemy
12+
from flask_sqlalchemy import SQLAlchemy
13+
from flask_sqlalchemy.session import Session
1414

1515

1616
# Chrononaut imports
@@ -39,7 +39,7 @@ def before_flush(session, flush_context, instances):
3939
"""A listener that handles state changes for objects with Chrononaut mixins."""
4040
for obj in session.new:
4141
if hasattr(obj, "__chrononaut_record_change_info__"):
42-
append_recorded_changes(obj, session)
42+
append_recorded_changes(obj)
4343
if hasattr(obj, "__chrononaut_primary_key_nonunique__"):
4444
increment_version_on_insert(obj)
4545

@@ -48,7 +48,7 @@ def before_flush(session, flush_context, instances):
4848
# Objects cannot be updated in the `after_flush` step hence bumping the version here
4949
obj.version = obj.version + 1 if obj.version is not None else 1
5050
if hasattr(obj, "__chrononaut_record_change_info__"):
51-
append_recorded_changes(obj, session)
51+
append_recorded_changes(obj)
5252

5353
for obj in session.deleted:
5454
if hasattr(obj, "__chrononaut_version__") and not hasattr(
@@ -71,8 +71,8 @@ def after_flush(session, flush_context):
7171
create_version(obj, session)
7272

7373

74-
class VersionedSignallingSession(SignallingSession):
75-
"""A subclass of Flask-SQLAlchemy's SignallingSession that supports
74+
class VersionedSession(Session):
75+
"""A subclass of Flask-SQLAlchemy's Session that supports
7676
versioned and change info session information.
7777
"""
7878

@@ -85,7 +85,7 @@ def delete(self, instance):
8585
super().delete(instance)
8686

8787

88-
versioned_session(VersionedSignallingSession)
88+
versioned_session(VersionedSession)
8989

9090

9191
class VersionedSQLAlchemy(SQLAlchemy):
@@ -121,8 +121,9 @@ def __init__(self, *args, **kwargs):
121121
super(VersionedSQLAlchemy, self).__init__(*args, **kwargs)
122122
self.metadata._activity_cls = activity_factory(self.Model)
123123

124-
def create_session(self, options):
125-
return sqlalchemy.orm.sessionmaker(class_=VersionedSignallingSession, db=self, **options)
124+
def _make_session_factory(self, options):
125+
options["class_"] = VersionedSession
126+
return super(VersionedSQLAlchemy, self)._make_session_factory(options)
126127

127128

128129
__all__ = [

‎chrononaut/change_info.py

+7-14
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@
22
"""
33
from datetime import datetime
44

5-
from flask import current_app, g, request
6-
from flask.globals import _app_ctx_stack, _request_ctx_stack
5+
from flask import current_app, g, request, has_request_context, has_app_context
76
from sqlalchemy import Column, DateTime
87
from sqlalchemy.dialects import postgresql
98

@@ -12,13 +11,6 @@
1211
from chrononaut.flask_versioning import UTC
1312

1413

15-
def _in_flask_context():
16-
if _app_ctx_stack.top is None or _request_ctx_stack.top is None:
17-
return False
18-
else:
19-
return True
20-
21-
2214
class ChangeInfoMixin(object):
2315
"""A mixin that the :class:`Versioned` mixin inherits from and includes change info tracking
2416
features.
@@ -45,13 +37,14 @@ def _fetch_current_user_id(cls):
4537
4638
:return: A unique user ID string or ``None`` if not available.
4739
"""
48-
if not _in_flask_context():
40+
if not has_app_context():
4941
return None
42+
5043
try:
5144
from flask_login import current_user
5245

5346
return current_user.email if current_user.is_authenticated else None
54-
except ImportError:
47+
except (ImportError, AttributeError):
5548
return None
5649

5750
@classmethod
@@ -60,7 +53,7 @@ def _fetch_remote_addr(cls):
6053
6154
:return: An IP address string or ``None`` if not available.
6255
"""
63-
if not _in_flask_context():
56+
if not has_request_context():
6457
return None
6558
return request.remote_addr
6659

@@ -92,8 +85,8 @@ class RecordChanges(ChangeInfoMixin):
9285
changed = Column("changed", DateTime(timezone=True), default=lambda: datetime.now(UTC))
9386

9487

95-
def append_recorded_changes(obj, session):
96-
if session.app.config.get(
88+
def append_recorded_changes(obj):
89+
if current_app.config.get(
9790
"CHRONONAUT_REQUIRE_EXTRA_CHANGE_INFO", False
9891
) is True and not hasattr(g, "__version_extra_change_info__"):
9992
msg = (

‎chrononaut/context_managers.py

+4-3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from contextlib import contextmanager
22

3-
from flask import g, _app_ctx_stack
3+
from flask import g, has_app_context
4+
45
from chrononaut.exceptions import ChrononautException
56

67

@@ -27,7 +28,7 @@ def extra_change_info(**kwargs):
2728
"change_rationale": "User request"
2829
}
2930
"""
30-
if _app_ctx_stack.top is None:
31+
if not has_app_context():
3132
raise ChrononautException("Can only use `extra_change_info` in a Flask app context.")
3233
g.__version_extra_change_info__ = kwargs
3334
yield
@@ -71,7 +72,7 @@ def append_change_info(obj, **kwargs):
7172
block for additional fields to be appended. Changes take the same form as with
7273
:func:`extra_change_info`.
7374
"""
74-
if _app_ctx_stack.top is None:
75+
if not has_app_context():
7576
raise ChrononautException("Can only use `append_change_info` in a Flask app context.")
7677

7778
if not hasattr(obj, "__versioned__"):

‎chrononaut/flask_versioning.py

+3-4
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
"""Flask versioning extension. Requires g and _app_ctx_stack in looking for extra recorded changes.
22
"""
3-
from flask import g
4-
from flask.globals import _app_ctx_stack
3+
from flask import g, current_app, has_app_context
54
from datetime import datetime
65
from dateutil.tz import tzutc
76
import six
@@ -94,7 +93,7 @@ def _get_dirty_attributes(obj, state=None, check_relationships=False):
9493
def fetch_change_info(obj):
9594
"""Returns a user and extra info context for a change."""
9695
user_info = obj._capture_user_info()
97-
if _app_ctx_stack.top is None:
96+
if not has_app_context():
9897
return user_info, {}
9998

10099
extra_change_info = obj._get_custom_change_info()
@@ -169,7 +168,7 @@ def create_version(obj, session, created=False, deleted=False):
169168

170169
snapshot = model_to_chrononaut_snapshot(obj, state)
171170

172-
if session.app.config.get(
171+
if current_app.config.get(
173172
"CHRONONAUT_REQUIRE_EXTRA_CHANGE_INFO", False
174173
) is True and not hasattr(g, "__version_extra_change_info__"):
175174
msg = (

‎chrononaut/unsafe.py

+3-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from contextlib import contextmanager
22

3-
from flask import g, _app_ctx_stack
3+
from flask import g, has_app_context
4+
45
from chrononaut.exceptions import ChrononautException
56

67

@@ -25,7 +26,7 @@ def suppress_versioning(allow_deleting_history=False):
2526
2627
Do not nest this context manager. If possible, avoid using at all.
2728
"""
28-
if _app_ctx_stack.top is None:
29+
if not has_app_context():
2930
raise ChrononautException("Can only use `suppress_versioning` in a Flask app context.")
3031
g.__suppress_versioning__ = True
3132
if allow_deleting_history:

‎requirements.txt

+10-9
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Requirements
2-
Flask==2.1.0
3-
Flask-SQLAlchemy==2.5.1
4-
SQLAlchemy==1.4.25
2+
Flask>=2.3.2
3+
Flask-SQLAlchemy>=3.0.5
4+
SQLAlchemy==1.4.50
55
psycopg2-binary>=2.8.1
66
alembic>=1.5.1
77
six>=1.15.0
@@ -10,12 +10,13 @@ python-dateutil
1010
# Testing & documentation requirements (note these must be installable across all Tox environment)
1111
pytest>=3.0.7
1212
pytest-cov==2.4.0
13-
Flask-Security-Fork==2.0.1
14-
Flask-WTF==1.0.1
15-
Werkzeug==2.0.0
13+
Flask-Security-Too==5.3.2
14+
Flask-WTF==1.2.1
15+
Werkzeug==2.3.6
1616
sphinx==1.5.5
17-
sphinx-autobuild==0.6.0
18-
Flask-Sphinx-Themes==1.0.1
19-
bcrypt==3.1.4
17+
Flask-Sphinx-Themes==1.0.2
2018
email_validator>=1.1.0
2119
enum34>=1.1.10
20+
flake8
21+
black==23.7.0
22+
sqlalchemy-utils

‎setup.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from setuptools import setup, find_packages
1010
import sys
1111

12-
__version__ = "0.4.2"
12+
__version__ = "0.5.0"
1313

1414

1515
if sys.version_info[0] < 3:

‎tests/conftest.py

+29-33
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
1+
import uuid
2+
3+
from sqlalchemy_utils import database_exists, drop_database, create_database
4+
15
from chrononaut.flask_versioning import UTC
26
from datetime import datetime
37
import os
48
from enum import Enum
59

610
import flask
7-
from flask import g, _request_ctx_stack
11+
from flask import g
812
import flask_security
9-
import flask_sqlalchemy
1013
import sqlalchemy
1114
import random
1215
import string
@@ -36,19 +39,17 @@ def app(request):
3639

3740

3841
@pytest.fixture(scope="session")
39-
def unversioned_db(app, request):
40-
"""An unversioned db fixture."""
41-
db = flask_sqlalchemy.SQLAlchemy(app)
42-
yield db
43-
44-
45-
@pytest.fixture(scope="session")
46-
def db(app, request):
42+
def db(app):
4743
"""A versioned db fixture."""
4844
db = chrononaut.VersionedSQLAlchemy(app)
4945
models = generate_test_models(db)
5046
for model in models:
5147
setattr(db, model.__name__, model)
48+
if database_exists(db.engine.url):
49+
db.engine.dispose()
50+
drop_database(db.engine.url)
51+
52+
create_database(db.engine.url)
5253
db.create_all()
5354
try:
5455
yield db
@@ -170,6 +171,9 @@ def validate_title(self, k, v):
170171
db.Column("role_id", db.Integer(), db.ForeignKey("role.id")),
171172
)
172173

174+
def gen_fs_uniquifier() -> str:
175+
return str(uuid.uuid4())
176+
173177
class Role(db.Model, flask_security.RoleMixin, chrononaut.Versioned):
174178
id = db.Column(db.Integer, primary_key=True)
175179
name = db.Column(db.String(80), unique=True)
@@ -187,6 +191,9 @@ class User(db.Model, flask_security.UserMixin, chrononaut.Versioned):
187191
roles = db.relationship(
188192
"Role", secondary=roles_users, backref=db.backref("users", lazy="dynamic")
189193
)
194+
fs_uniquifier = db.Column(
195+
db.String(255), unique=True, nullable=False, default=gen_fs_uniquifier
196+
)
190197

191198
class ChangeLog(db.Model, chrononaut.RecordChanges, chrononaut.Versioned):
192199
id = db.Column(db.Integer, primary_key=True)
@@ -202,7 +209,7 @@ def session(db):
202209
transaction = connection.begin()
203210

204211
options = dict(bind=connection, binds={})
205-
session = db.create_scoped_session(options=options)
212+
session = db._make_scoped_session(options=options)
206213
session.begin_nested()
207214

208215
# session is actually a scoped_session
@@ -242,16 +249,6 @@ def app_client(security_app):
242249
yield security_app.test_client(use_cookies=True)
243250

244251

245-
@pytest.fixture(scope="function")
246-
def user(db, session):
247-
user = db.User(email="test@example.com", password="password", active=True)
248-
role = db.Role(name="Admin")
249-
session.add(user)
250-
session.add(role)
251-
session.commit()
252-
yield user
253-
254-
255252
@pytest.fixture(scope="function")
256253
def anonymous_user(app_client):
257254
with app_client:
@@ -261,19 +258,18 @@ def anonymous_user(app_client):
261258

262259

263260
@pytest.fixture(scope="function")
264-
def logged_in_user(security_app, user):
261+
def logged_in_user(security_app, db, session):
265262
with security_app.test_request_context(environ_base={"REMOTE_ADDR": "10.0.0.1"}):
266-
original = security_app.login_manager._load_user
267-
if hasattr(g, "_login_user"):
268-
delattr(g, "_login_user")
269-
270-
def _load_user_from_request():
271-
_request_ctx_stack.top.user = user
272-
g._login_user = user
273-
return user
274-
275-
security_app.login_manager._load_user = _load_user_from_request
263+
user = db.User(email="test@example.com", password="password", active=True)
264+
role = db.Role(name="Admin")
265+
session.add(user)
266+
session.add(role)
267+
session.commit()
268+
269+
security_app.login_manager._update_request_context_with_user(user)
270+
g._login_user = user
276271
try:
277272
yield user
278273
finally:
279-
security_app.login_manager._load_user = original
274+
delattr(g, "_login_user")
275+
security_app.login_manager._update_request_context_with_user(None)

‎tests/test_basic.py

+1-12
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,17 @@
11
"""Test basic FlaskSQLAlchemy integration points
22
"""
3-
import flask_sqlalchemy
43
import sqlalchemy
54

65
import chrononaut
76

87

9-
def test_unversioned_db_fixture(unversioned_db):
10-
"""Test unversioned SQLAlchemy object."""
11-
assert unversioned_db.__class__ == flask_sqlalchemy.SQLAlchemy
12-
assert unversioned_db.session.__class__ == sqlalchemy.orm.scoping.scoped_session
13-
assert (
14-
unversioned_db.session.session_factory().__class__.__name__
15-
== flask_sqlalchemy.SignallingSession.__name__ # noqa
16-
)
17-
18-
198
def test_db_fixture(db):
209
"""Test fixtures."""
2110
assert db.__class__ == chrononaut.VersionedSQLAlchemy
2211
assert db.session.__class__ == sqlalchemy.orm.scoping.scoped_session
2312
assert (
2413
db.session.session_factory().__class__.__name__
25-
== chrononaut.VersionedSignallingSession.__name__ # noqa
14+
== chrononaut.VersionedSession.__name__ # noqa
2615
)
2716

2817

‎tests/test_versioning_suppression.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ def test_suppressing_version_info_delete_version_commit_out_of_scope(db, session
7777
assert len(todo.versions()) == 2
7878

7979

80-
def test_suppressing_version_info_delete_whole_record(db, session):
80+
def test_suppressing_version_info_delete_whole_record(db, session, logged_in_user):
8181
todo = db.SpecialTodo("Special 1", "A todo")
8282
session.add(todo)
8383
session.commit()

‎tox.ini

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[tox]
2-
envlist = py37,py38,py39,py310,coverage,lint
2+
envlist = py38,py39,py310,py311,py312,coverage,lint
33

44
[testenv]
55
commands =
@@ -13,7 +13,7 @@ deps =
1313

1414

1515
[testenv:lint]
16-
basepython = python3.9
16+
basepython = python3.11
1717
deps =
1818
flake8
1919
black==19.3b0

0 commit comments

Comments
 (0)
Please sign in to comment.