Просмотр исходного кода

Add deprecation helper

Creates a standardized way to deprecate functions and methods in FlaskBB
and allows extension by plugins.
Alec Nikolas Reiter 6 лет назад
Родитель
Сommit
f94616bdf5

+ 1 - 0
docs/contents.rst.inc

@@ -8,6 +8,7 @@ User Documentation
    cli
    plugins/index
    faq
+   deprecations
 
 
 Developer Documentation

+ 46 - 0
docs/deprecations.rst

@@ -0,0 +1,46 @@
+.. _deprecations:
+
+Deprecation Policy
+==================
+
+A release of FlaskBB may deprecate existing features and begin emitting
+:class:`~flaskbb.deprecation.FlaskBBDeprecation` warnings.
+
+These warnings are on by default and will announce for the first time each
+deprecated usage is detected. If you want to ignore these warnings, this
+behavior can be modified by setting ``DEPRECATION_LEVEL`` in your configuration
+file or setting ``FLASKBB_DEPRECATION_LEVEL`` in your environment to a level
+from the builtin warnings module.
+
+For more details on interacting with warnings see
+`the official documentation on warnings <https://docs.python.org/3/library/warnings.html>`_.
+
+
+.. note::
+
+    If you are developing on FlaskBB the level for FlaskBBDeprecation warnings
+    is always set to ``error`` when running tests to ensure that deprecated
+    behavior isn't being relied upon. If you absolutely need to downgrade to a
+    non-exception level, use pytest's recwarn fixture and set the level with
+    warnings.simplefilter
+
+
+-- Insert Details of deprecation timeline --
+
+I suggest following something like Django's policy of at least 2 feature
+versions. But semvar suggests that backwards incompatible changes are a major
+version bump. :shrug:
+
+
+Deprecation Helpers
+~~~~~~~~~~~~~~~~~~~
+
+FlaskBB also publicly provides tools for handling deprecations as well and are
+open to use by plugins or other extensions to FlaskBB.
+
+.. module:: flaskbb.deprecation
+
+.. autoclass:: FlaskBBWarning
+.. autoclass:: FlaskBBDeprecation
+.. autoclass:: RemovedInFlaskBB3
+.. autofunction:: deprecated

+ 64 - 17
flaskbb/app.py

@@ -13,6 +13,7 @@ import logging.config
 import os
 import sys
 import time
+import warnings
 from datetime import datetime
 
 from flask import Flask, request
@@ -22,39 +23,79 @@ from sqlalchemy.engine import Engine
 from sqlalchemy.exc import OperationalError, ProgrammingError
 
 from flaskbb._compat import iteritems, string_types
+
 # extensions
-from flaskbb.extensions import (alembic, allows, babel, cache, celery, csrf,
-                                db, debugtoolbar, limiter, login_manager, mail,
-                                redis_store, themes, whooshee)
+from flaskbb.extensions import (
+    alembic,
+    allows,
+    babel,
+    cache,
+    celery,
+    csrf,
+    db,
+    debugtoolbar,
+    limiter,
+    login_manager,
+    mail,
+    redis_store,
+    themes,
+    whooshee,
+)
 from flaskbb.plugins import spec
 from flaskbb.plugins.manager import FlaskBBPluginManager
 from flaskbb.plugins.models import PluginRegistry
 from flaskbb.plugins.utils import remove_zombie_plugins_from_db, template_hook
+
 # models
 from flaskbb.user.models import Guest, User
+
 # various helpers
-from flaskbb.utils.helpers import (app_config_from_env, crop_title,
-                                   format_date, format_datetime,
-                                   forum_is_unread, get_alembic_locations,
-                                   get_flaskbb_config, is_online, mark_online,
-                                   render_template, time_since, time_utcnow,
-                                   topic_is_unread)
+from flaskbb.utils.helpers import (
+    app_config_from_env,
+    crop_title,
+    format_date,
+    format_datetime,
+    forum_is_unread,
+    get_alembic_locations,
+    get_flaskbb_config,
+    is_online,
+    mark_online,
+    render_template,
+    time_since,
+    time_utcnow,
+    topic_is_unread,
+)
+
 # permission checks (here they are used for the jinja filters)
-from flaskbb.utils.requirements import (CanBanUser, CanEditUser, IsAdmin,
-                                        IsAtleastModerator, can_delete_topic,
-                                        can_edit_post, can_moderate,
-                                        can_post_reply, can_post_topic,
-                                        has_permission,
-                                        permission_with_identity)
+from flaskbb.utils.requirements import (
+    CanBanUser,
+    CanEditUser,
+    IsAdmin,
+    IsAtleastModerator,
+    can_delete_topic,
+    can_edit_post,
+    can_moderate,
+    can_post_reply,
+    can_post_topic,
+    has_permission,
+    permission_with_identity,
+)
+
 # whooshees
-from flaskbb.utils.search import (ForumWhoosheer, PostWhoosheer,
-                                  TopicWhoosheer, UserWhoosheer)
+from flaskbb.utils.search import (
+    ForumWhoosheer,
+    PostWhoosheer,
+    TopicWhoosheer,
+    UserWhoosheer,
+)
+
 # app specific configurations
 from flaskbb.utils.settings import flaskbb_config
 from flaskbb.utils.translations import FlaskBBDomain
 
 from . import markup  # noqa
 from .auth import views as auth_views  # noqa
+from .deprecation import FlaskBBDeprecation
 from .forum import views as forum_views  # noqa
 from .management import views as management_views  # noqa
 from .user import views as user_views  # noqa
@@ -135,6 +176,12 @@ def configure_app(app, config):
 
     logger.info("Using config from: {}".format(config_name))
 
+    deprecation_level = app.config.get("DEPRECATION_LEVEL", "default")
+
+    # never set the deprecation level during testing, pytest will handle it
+    if not app.testing:  # pragma: no branch
+        warnings.simplefilter(deprecation_level, FlaskBBDeprecation)
+
     app.pluggy = FlaskBBPluginManager("flaskbb", implprefix="flaskbb_")
 
 

+ 19 - 0
flaskbb/cli/main.py

@@ -401,6 +401,7 @@ def generate_config(development, output, force):
         "csrf_secret_key": binascii.hexlify(os.urandom(24)).decode(),
         "timestamp": datetime.utcnow().strftime("%A, %d. %B %Y at %H:%M"),
         "log_config_path": "",
+        "deprecation_level": "default"
     }
 
     if not force:
@@ -522,6 +523,24 @@ def generate_config(development, output, force):
         click.style("Logging Config Path", fg="magenta"),
         default=default_conf.get("log_config_path"))
 
+    deprecation_mesg = (
+        "Warning level for deprecations. options are: \n"
+        "\terror\tturns deprecation warnings into exceptions\n"
+        "\tignore\tnever warns about deprecations\n"
+        "\talways\talways warns about deprecations even if the warning has been issued\n"  # noqa
+        "\tdefault\tshows deprecation warning once per usage\n"
+        "\tmodule\tshows deprecation warning once per module\n"
+        "\tonce\tonly shows deprecation warning once regardless of location\n"
+        "If you are unsure, select default\n"
+        "for more details see: https://docs.python.org/3/library/warnings.html#the-warnings-filter"  # noqa
+    )
+
+    click.secho(deprecation_mesg, fg="cyan")
+    default_conf["deprecation_level"] = click.prompt(
+        click.style("Deperecation warning level", fg="magenta"),
+        default=default_conf.get("deprecation_level")
+    )
+
     write_config(default_conf, config_template, config_path)
 
     # Finished

+ 3 - 0
flaskbb/configs/config.cfg.template

@@ -267,3 +267,6 @@ ADMIN_URL_PREFIX = "/admin"
 # If set to `False` it will NOT remove plugins that are NOT installed on
 # the filesystem (virtualenv, site-packages).
 REMOVE_DEAD_PLUGINS = False
+
+# determines the warning level for FlaskBB Deprecations
+DEPRECATION_LEVEL = "{{ deprecation_level }}"

+ 91 - 0
flaskbb/deprecation.py

@@ -0,0 +1,91 @@
+# -*- coding: utf-8 -*-
+"""
+    flaskbb.deprecation
+    ~~~~~~~~~~~~~~~~~~~
+
+    Module used for deprecation handling in FlaskBB
+
+    :copyright: (c) 2018 the FlaskBB Team.
+    :license: BSD, see LICENSE for more details.
+"""
+
+import inspect
+import warnings
+from abc import abstractproperty
+from functools import wraps
+
+from flask_babelplus import gettext as _
+
+from ._compat import ABC
+
+
+class FlaskBBWarning(Warning):
+    """
+    Base class for any warnings that FlaskBB itself needs to issue, provided
+    for convenient filtering.
+    """
+    pass
+
+
+class FlaskBBDeprecation(DeprecationWarning, FlaskBBWarning, ABC):
+    """
+    Base class for deprecations originating from FlaskBB, subclasses must
+    provide a version attribute that represents when deprecation becomes a
+    removal::
+
+
+        class RemovedInPluginv3(FlaskBBDeprecation):
+            version = (3, 0, 0)
+    """
+    version = abstractproperty(lambda self: None)
+
+
+class RemovedInFlaskBB3(FlaskBBDeprecation):
+    """
+    warning for features removed in FlaskBB3
+    """
+    version = (3, 0, 0)
+
+
+def deprecated(message="", category=RemovedInFlaskBB3):
+    """
+    Flags a function or method as deprecated, should not be used on
+    classes as it will break inheritance and introspection.
+
+    :param message: Optional message to display along with deprecation warning.
+    :param category: Warning category to use, defaults to RemovedInFlaskBB3,
+        if provided must be a subclass of FlaskBBDeprecation.
+    """
+
+    def deprecation_decorator(f):
+        if not issubclass(category, FlaskBBDeprecation):
+            raise ValueError(
+                "Expected subclass of FlaskBBDeprecation for category, got {}".format(  # noqa
+                    str(category)
+                )
+            )
+
+        version = ".".join([str(x) for x in category.version])
+
+        warning = _(
+            "%(name)s is deprecated and will be removed in version %(version)s.",  # noqa
+            name=f.__name__,
+            version=version,
+        )
+        if message:
+            warning = "{} {}".format(warning, message)
+
+        @wraps(f)
+        def wrapper(*a, **k):
+            frame = inspect.currentframe().f_back
+            warnings.warn_explicit(
+                warning,
+                category=category,
+                filename=inspect.getfile(frame.f_code),
+                lineno=frame.f_lineno,
+            )
+            return f(*a, **k)
+
+        return wrapper
+
+    return deprecation_decorator

+ 3 - 1
flaskbb/user/models.py

@@ -19,7 +19,8 @@ from flaskbb.utils.helpers import time_utcnow
 from flaskbb.utils.settings import flaskbb_config
 from flaskbb.utils.database import CRUDMixin, UTCDateTime, make_comparable
 from flaskbb.forum.models import Post, Topic, Forum, topictracker
-
+from flaskbb.deprecation import deprecated
+from flask_babelplus import gettext as _
 
 logger = logging.getLogger(__name__)
 
@@ -250,6 +251,7 @@ class User(db.Model, UserMixin, CRUDMixin):
         return check_password_hash(self.password, password)
 
     @classmethod
+    @deprecated(_("Use authentication services instead."))
     def authenticate(cls, login, password):
         """A classmethod for authenticating users.
         It returns the user object if the user/password combination is ok.

+ 62 - 0
tests/unit/test_deprecation.py

@@ -0,0 +1,62 @@
+import warnings
+
+import pytest
+
+from flaskbb.deprecation import RemovedInFlaskBB3, deprecated
+
+NEXT_VERSION_STRING = ".".join([str(x) for x in RemovedInFlaskBB3.version])
+
+
+@deprecated("This is only a drill")
+def only_a_drill():
+    pass
+
+
+# TODO(anr): Make the parens optional
+@deprecated()
+def default_deprecation():
+    pass
+
+
+class TestDeprecation(object):
+
+    def test_emits_default_deprecation_warning(self, recwarn):
+        warnings.simplefilter("default", RemovedInFlaskBB3)
+        default_deprecation()
+
+        assert len(recwarn) == 1
+        assert "default_deprecation is deprecated" in str(recwarn[0].message)
+        assert recwarn[0].category == RemovedInFlaskBB3
+        assert recwarn[0].filename == __file__
+        # assert on the next line is conditional on the position of the call
+        # to default_deprecation please don't jiggle it around too much
+        assert recwarn[0].lineno == 25
+
+    def tests_emits_specialized_message(self, recwarn):
+        warnings.simplefilter("default", RemovedInFlaskBB3)
+        only_a_drill()
+
+        expected = "only_a_drill is deprecated and will be removed in version {}. This is only a drill".format(  # noqa
+            NEXT_VERSION_STRING
+        )
+        assert len(recwarn) == 1
+        assert expected in str(recwarn[0].message)
+
+    def tests_only_accepts_FlaskBBDeprecationWarnings(self):
+        with pytest.raises(ValueError) as excinfo:
+            # DeprecationWarning is ignored by default
+            @deprecated("This is also a drill", category=UserWarning)
+            def also_a_drill():
+                pass
+
+        assert "Expected subclass of FlaskBBDeprecation" in str(excinfo.value)
+
+    def tests_deprecated_decorator_work_with_method(self, recwarn):
+        warnings.simplefilter("default", RemovedInFlaskBB3)
+        self.deprecated_instance_method()
+
+        assert len(recwarn) == 1
+
+    @deprecated()
+    def deprecated_instance_method(self):
+        pass

+ 2 - 2
tox.ini

@@ -10,7 +10,7 @@ setenv =
     COVERAGE_FILE = tests/.coverage.{envname}
     PYTHONDONTWRITEBYTECODE=1
 commands =
-    coverage run -m pytest {toxinidir}/tests {toxinidir}/flaskbb
+    coverage run -m pytest {toxinidir}/tests {toxinidir}/flaskbb {posargs}
 
 [testenv:cov-report]
 skip_install = true
@@ -39,4 +39,4 @@ max-line-length = 80
 exclude = flaskbb/configs/default.py,flaskbb/_compat.py
 
 [pytest]
-addopts =  -vvl --strict --flake8 --capture fd
+addopts =  -vvl --strict --flake8 --capture fd -W error::flaskbb.deprecation.FlaskBBDeprecation