From 74213028ebd869fc76d584ff6ba8742864bb84f0 Mon Sep 17 00:00:00 2001 From: Matt Lewellyn Date: Thu, 20 Oct 2022 12:53:32 -0700 Subject: [PATCH 1/6] WIP resolve flask deprecations --- keg/templating.py | 22 ++++++++++++++++++---- keg/web.py | 20 ++++++++++++++++++-- tox.ini | 2 +- 3 files changed, 37 insertions(+), 7 deletions(-) diff --git a/keg/templating.py b/keg/templating.py index a5c1364..7ad335b 100644 --- a/keg/templating.py +++ b/keg/templating.py @@ -1,14 +1,28 @@ from __future__ import absolute_import -from flask.globals import _request_ctx_stack +# flask.globals.request_ctx is only available in Flask >= 2.2.0 +try: + from flask.globals import request_ctx +except ImportError: + from flask.globals import _request_ctx_stack + request_ctx = None from keg.extensions import lazy_gettext as _ +def _get_bc_request_context(): + """Flask 2.2 changed the method of fetching the request context + from globals. Flask 2.3 will remove the old way of doing it. + Support both here.""" + if request_ctx is None: + return _request_ctx_stack.top + return request_ctx + + def _keg_default_template_ctx_processor(): """Default template context processor. Injects `assets`. """ - reqctx = _request_ctx_stack.top + reqctx = _get_bc_request_context() rv = {} if reqctx is not None: rv['assets'] = reqctx.assets @@ -53,7 +67,7 @@ def parse_include(self, parser, stream, lineno): def _include_support(self, template_name, caller): """Helper callback.""" - ctx = _request_ctx_stack.top + ctx = _get_bc_request_context() ctx.assets.load_related(template_name) # have to return empty string to avoid exception about None not being iterable. @@ -74,5 +88,5 @@ def parse_content(self, parser, stream, lineno): def _content_support(self, asset_type, caller): """Helper callback.""" - ctx = _request_ctx_stack.top + ctx = _get_bc_request_context() return ctx.assets.combine_content(asset_type) diff --git a/keg/web.py b/keg/web.py index 3483935..5d912ef 100644 --- a/keg/web.py +++ b/keg/web.py @@ -6,7 +6,11 @@ from blazeutils.strings import case_cw2us, case_cw2dash import flask from flask import request -from flask.views import MethodView, MethodViewType, http_method_funcs +from flask.views import MethodView, http_method_funcs +try: + from flask.views import MethodViewType +except ImportError: + MethodViewType = None import six from werkzeug.datastructures import MultiDict @@ -82,7 +86,7 @@ def _call_with_expected_args(view, calling_args, method, method_is_bound=True): return method(*args, **kwargs) -class _ViewMeta(MethodViewType): +class _OldViewMeta(MethodViewType or object): def __init__(cls, name, bases, d): MethodViewType.__init__(cls, name, bases, d) @@ -94,6 +98,9 @@ def __init__(cls, name, bases, d): cls.assign_blueprint(cls.blueprint) +_ViewMeta = _OldViewMeta if MethodViewType is not None else type + + class BaseView(MethodView, metaclass=_ViewMeta): """ Base class for all Keg views to inherit from. `BaseView` automatically calculates and installs @@ -111,6 +118,15 @@ class BaseView(MethodView, metaclass=_ViewMeta): # names of qs arguments that should be merged w/ URL arguments and passed to view methods expected_qs_args = [] + def __init_subclass__(cls, **kwargs): + """Flask before 2.2.0 used a metaclass to perform view setup, but this + changed to using `init_subclass`. If the old way is enabled, no need to + do anything but call the super here.""" + super().__init_subclass__(**kwargs) + + if MethodViewType is None and cls.blueprint is not None: + cls.assign_blueprint(cls.blueprint) + def __init__(self, responding_method=None): self.responding_method = responding_method diff --git a/tox.ini b/tox.ini index 8b7cc2b..07a4134 100644 --- a/tox.ini +++ b/tox.ini @@ -16,7 +16,7 @@ skip_install = true recreate=True commands = pip --version - lowest: pip install flask<2 + lowest: pip install flask<2 markupsafe~=2.0.0 pip install --progress-bar off .[tests] i18n: pip install --progress-bar off .[i18n] # Output installed versions to compare with previous test runs in case a dependency's change From b02c3284dbe75357a850210737432d6f4fad6930 Mon Sep 17 00:00:00 2001 From: Matt Lewellyn Date: Thu, 20 Oct 2022 12:54:29 -0700 Subject: [PATCH 2/6] WIP flask-sqlalchemy deprecations --- keg/db/__init__.py | 12 ++++++++++++ keg/db/dialect_ops.py | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/keg/db/__init__.py b/keg/db/__init__.py index 488789e..6ce4473 100644 --- a/keg/db/__init__.py +++ b/keg/db/__init__.py @@ -33,6 +33,18 @@ def apply_driver_hacks(self, app, info, options): return super_return_value + def get_engine(self, app=None, bind=None): + if not hasattr(self, '_app_engines'): + # older version of flask-sqlalchemy, we can just call super + return super().get_engine(app=app, bind=bind) + + # More recent flask-sqlalchemy, use the cached engines directly. + # Note: we don't necessarily have an app context active here, depending + # on if this is being called during app init. But if we attempt to access + # the underlying cache directly, we get a weak ref error. + with app.app_context(): + return self.engines[bind] + def get_engines(self, app): # the default engine doesn't have a bind retval = [(None, self.get_engine(app))] diff --git a/keg/db/dialect_ops.py b/keg/db/dialect_ops.py index 3c88923..af89176 100644 --- a/keg/db/dialect_ops.py +++ b/keg/db/dialect_ops.py @@ -32,7 +32,7 @@ def execute_sql(self, statements): def create_all(self): self.create_schemas() - db.create_all(bind=self.bind_name) + db.create_all(self.bind_name) def create_schemas(self): pass From 5a0d174126a6d933620b59a09a82a5238020e425 Mon Sep 17 00:00:00 2001 From: Matt Lewellyn Date: Thu, 20 Oct 2022 16:17:01 -0400 Subject: [PATCH 3/6] resolve flask-sqlalchemy driver_hacks method rename --- keg/db/__init__.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/keg/db/__init__.py b/keg/db/__init__.py index 6ce4473..6012c45 100644 --- a/keg/db/__init__.py +++ b/keg/db/__init__.py @@ -17,10 +17,11 @@ class KegSQLAlchemy(fsa.SQLAlchemy): - - def apply_driver_hacks(self, app, info, options): + def _apply_driver_defaults(self, options, app): """Override some driver specific settings""" - super_return_value = super(KegSQLAlchemy, self).apply_driver_hacks(app, info, options) + super_return_value = None + if hasattr(super(), '_apply_driver_defaults'): + super_return_value = super()._apply_driver_defaults(options, app) # Turn on SA pessimistic disconnect handling by default: # http://docs.sqlalchemy.org/en/latest/core/pooling.html#disconnect-handling-pessimistic @@ -33,11 +34,20 @@ def apply_driver_hacks(self, app, info, options): return super_return_value + def apply_driver_hacks(self, app, info, options): + """This method is renamed to _apply_driver_defaults in flask-sqlalchemy 3.0""" + super_return_value = super().apply_driver_hacks(app, info, options) + + # follow the logic to set some defaults, but the super won't exist there + self._apply_driver_defaults(options, app) + + return super_return_value + def get_engine(self, app=None, bind=None): if not hasattr(self, '_app_engines'): # older version of flask-sqlalchemy, we can just call super return super().get_engine(app=app, bind=bind) - + # More recent flask-sqlalchemy, use the cached engines directly. # Note: we don't necessarily have an app context active here, depending # on if this is being called during app init. But if we attempt to access From 02b9c268de4fa7c71b494447f876b2c316b3cc40 Mon Sep 17 00:00:00 2001 From: Matt Lewellyn Date: Fri, 21 Oct 2022 09:45:46 -0400 Subject: [PATCH 4/6] resolve app context issues with invoking commands when a previous app context remains --- keg/testing.py | 10 ++++++++++ keg/tests/test_config.py | 4 +++- keg/tests/test_db.py | 5 ++++- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/keg/testing.py b/keg/testing.py index 9dcbd27..a5a7250 100644 --- a/keg/testing.py +++ b/keg/testing.py @@ -170,6 +170,12 @@ def invoke_command(app_cls, *args, **kwargs): return result +def cleanup_app_contexts(): + while flask.current_app or False: + cm = ContextManager.get_for(flask.current_app.__class__) + cm.cleanup() + + class CLIBase(object): """Test class base for testing Keg click commands. @@ -186,6 +192,10 @@ class CLIBase(object): @classmethod def setup_class(cls): + # If a current app context is set, it may complicate what click is doing to + # set up and run a specific app. + cleanup_app_contexts() + cls.runner = click.testing.CliRunner() def invoke(self, *args, **kwargs): diff --git a/keg/tests/test_config.py b/keg/tests/test_config.py index d6bc0a7..2fc4c25 100644 --- a/keg/tests/test_config.py +++ b/keg/tests/test_config.py @@ -6,7 +6,7 @@ from keg.app import Keg from keg.config import Config -from keg.testing import invoke_command +from keg.testing import cleanup_app_contexts, invoke_command from keg_apps.profile.cli import ProfileApp @@ -137,6 +137,7 @@ def test_invoke_command_for_testing(self): """ Using testing.invoke_command() should use a testing profile by default. """ + cleanup_app_contexts() resp = invoke_command(ProfileApp, 'show-profile') assert 'testing-default' in resp.output @@ -144,6 +145,7 @@ def test_invoke_command_with_environment(self): """ Environement overrides should still take priority for invoke_command() usage. """ + cleanup_app_contexts() resp = invoke_command(ProfileApp, 'show-profile', env={'KEG_APPS_PROFILE_CONFIG_PROFILE': 'EnvironmentProfile'}) assert 'environment' in resp.output diff --git a/keg/tests/test_db.py b/keg/tests/test_db.py index a278f7a..982f519 100644 --- a/keg/tests/test_db.py +++ b/keg/tests/test_db.py @@ -61,7 +61,10 @@ def test_init_without_db_binds(self): # Make sure we don't get an error initializing the app when the SQLALCHEMY_BINDS config # option is None app = DB2App.testing_prep() - assert app.config.get('SQLALCHEMY_BINDS') is None + value = app.config.get('SQLALCHEMY_BINDS') + # flask-sqlalchemy < 3.0: None + # flask-sqlalchemy 3.0+: {} + assert value is None or value == {} class TestDatabaseManager(object): From db51c77a242884db165dc454483a3e3564951b92 Mon Sep 17 00:00:00 2001 From: Matt Lewellyn Date: Fri, 21 Oct 2022 12:24:29 -0700 Subject: [PATCH 5/6] resolve app context cleanup for older flask, and fix cwd for dotenv tests --- keg/testing.py | 12 ++++++++++-- keg/tests/test_cli.py | 10 ++++++++-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/keg/testing.py b/keg/testing.py index a5a7250..8062b44 100644 --- a/keg/testing.py +++ b/keg/testing.py @@ -171,9 +171,17 @@ def invoke_command(app_cls, *args, **kwargs): def cleanup_app_contexts(): - while flask.current_app or False: + while flask.current_app: cm = ContextManager.get_for(flask.current_app.__class__) - cm.cleanup() + if cm.ctx: + cm.cleanup() + else: + break + + # support older flask as well + if flask.current_app and getattr(flask, '_app_ctx_stack'): + while flask._app_ctx_stack.pop(): + pass class CLIBase(object): diff --git a/keg/tests/test_cli.py b/keg/tests/test_cli.py index 5e9cdd6..9f0b01c 100644 --- a/keg/tests/test_cli.py +++ b/keg/tests/test_cli.py @@ -40,9 +40,12 @@ def test_missing_command(self): @need_dotenv def test_dotenv(self): test_dir = os.path.dirname(__file__) + # ensure flask looks in the expected working directory + working_dir = os.path.abspath(os.path.join(test_dir, '..', '..')) + os.chdir(working_dir) # Place dotenv file in search path of python-dotenv - flaskenv = os.path.abspath(os.path.join(test_dir, '..', '..', '.flaskenv')) + flaskenv = os.path.join(working_dir, '.flaskenv') try: with open(flaskenv, 'w') as f: f.write('FOO=bar') @@ -57,9 +60,12 @@ def test_dotenv(self): @mock.patch.dict(os.environ, {'FLASK_SKIP_DOTENV': '1'}) def test_disable_dotenv_from_env(self): test_dir = os.path.dirname(__file__) + # ensure flask looks in the expected working directory + working_dir = os.path.abspath(os.path.join(test_dir, '..', '..')) + os.chdir(working_dir) # Place dotenv file in search path of python-dotenv - flaskenv = os.path.abspath(os.path.join(test_dir, '..', '..', '.flaskenv')) + flaskenv = os.path.join(working_dir, '.flaskenv') try: with open(flaskenv, 'w') as f: f.write('FOO=bar') From 32e36fdf6abededd86aa6ad54927eb5d0fbe49ca Mon Sep 17 00:00:00 2001 From: Matt Lewellyn Date: Fri, 21 Oct 2022 15:31:24 -0400 Subject: [PATCH 6/6] resolve flake8 issues --- keg/web.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/keg/web.py b/keg/web.py index 5d912ef..467941b 100644 --- a/keg/web.py +++ b/keg/web.py @@ -8,9 +8,9 @@ from flask import request from flask.views import MethodView, http_method_funcs try: - from flask.views import MethodViewType + from flask.views import MethodViewType except ImportError: - MethodViewType = None + MethodViewType = None import six from werkzeug.datastructures import MultiDict @@ -123,7 +123,7 @@ def __init_subclass__(cls, **kwargs): changed to using `init_subclass`. If the old way is enabled, no need to do anything but call the super here.""" super().__init_subclass__(**kwargs) - + if MethodViewType is None and cls.blueprint is not None: cls.assign_blueprint(cls.blueprint)