Browse Source

Merge pull request #474 from justanr/deprecations

Add deprecation helper
Peter Justin 6 years ago
parent
commit
cfb7946c12

+ 1 - 0
docs/contents.rst.inc

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

+ 50 - 0
docs/development/api/deprecations.rst

@@ -0,0 +1,50 @@
+.. _deprecations:
+
+Deprecation Helpers
+===================
+
+FlaskBB publicly provides tools for handling deprecations and are open to use
+by plugins or other extensions to FlaskBB. For example if a plugin wants to
+deprecate a particular function it could do::
+
+    from flaskbb.deprecation import FlaskBBDeprecation, deprecated
+
+    class RemovedInPluginV2(FlaskBBDeprecation):
+        version = (2, 0, 0)
+
+
+    @deprecated(category=RemovedInPluginV2)
+    def thing_removed_in_plugin_v2():
+        ...
+
+
+When used in live code, a warning will be issue like::
+
+    warning: RemovedInPluginV2: thing_removed_in_plugin_v2 and will be removed
+        in version 2.0.0.
+
+Optionally, a message can be provided to give further information about the
+warning::
+
+    @deprecated(message="Use plugin.frobinator instead.", category=RemovedInPluginV2)
+    def thing_also_removed_in_plugin_v2():
+        ...
+
+This will produce a warning like::
+
+    warning: RemovedInPluginV2: thing_removed_in_plugin_v2 and will be removed
+        in version 2.0.0. Use plugin.frobinator instead.
+
+
+If a decorated function has a docstring, the entire warning message will be
+appended to it for introspection and documentation purposes.
+
+Helpers
+~~~~~~~
+
+.. module:: flaskbb.deprecation
+
+.. autoclass:: FlaskBBWarning
+.. autoclass:: FlaskBBDeprecation
+.. autoclass:: RemovedInFlaskBB3
+.. autofunction:: deprecated

+ 1 - 0
docs/development/api/index.rst

@@ -15,3 +15,4 @@ and provided implementations where appropriate.
    authentication
    accountmanagement
    tokens
+   deprecations

+ 61 - 0
docs/releases.rst

@@ -0,0 +1,61 @@
+.. _releasing:
+
+Releases
+========
+
+Releases for FlaskBB can be found on `pypi <https://pypi.org/project/FlaskBB>`_
+as well as on `github <https://github.com/flaskbb/flaskbb>`_.
+
+FlaskBB loosely follows semantic versioning (semver) where all releases in each
+major version strive to be backwards compatible, though sometimes this will
+be broken in order to apply a bugfix or security patch. When this occurs the
+release notes will contain information about this.
+
+Releases follow no particular cadence.
+
+
+Branching and Tagging
+~~~~~~~~~~~~~~~~~~~~~
+
+Each release of FlaskBB will have a git tag such as ``v2.0.0`` as well as a
+branch such as ``2.0.0``. Minor releases and patches reside in their major
+version branch (e.g. version 2.0.1 resides in the 2.0.0 branch).
+
+The ``master`` branch is always the latest version of FlaskBB and versions are
+cut from this branch.
+
+Feature and example branches may also be found in the official FlaskBB repo
+but these are not considered release ready and may be unstable.
+
+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>`_.
+
+
+In general, a feature deprecated in a release will not be fully removed until
+the next major version. For example, a feature deprecated in 2.1.0 would not
+be removed until 3.0.0. There may be exceptions to this, such as if a deprecated
+feature is found to be a security risk.
+
+.. 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
+
+
+For more details on using deprecations in plugins or extensions, see :ref:`deprecations`.

+ 80 - 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,28 @@ 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)
+
+    debug_panels = app.config.setdefault('DEBUG_TB_PANELS', [
+        'flask_debugtoolbar.panels.versions.VersionDebugPanel',
+        'flask_debugtoolbar.panels.timer.TimerDebugPanel',
+        'flask_debugtoolbar.panels.headers.HeaderDebugPanel',
+        'flask_debugtoolbar.panels.request_vars.RequestVarsDebugPanel',
+        'flask_debugtoolbar.panels.config_vars.ConfigVarsDebugPanel',
+        'flask_debugtoolbar.panels.template.TemplateDebugPanel',
+        'flask_debugtoolbar.panels.sqlalchemy.SQLAlchemyDebugPanel',
+        'flask_debugtoolbar.panels.logger.LoggingPanel',
+        'flask_debugtoolbar.panels.route_list.RouteListDebugPanel',
+        'flask_debugtoolbar.panels.profiler.ProfilerDebugPanel',
+    ])
+
+    if all('WarningsPanel' not in p for p in debug_panels):
+        debug_panels.append('flask_debugtoolbar_warnings.WarningsPanel')
+
     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 }}"

+ 100 - 0
flaskbb/deprecation.py

@@ -0,0 +1,100 @@
+# -*- 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)
+
+        docstring = f.__doc__
+
+        if docstring:
+            docstring = "\n".join([docstring, warning])
+        else:
+            docstring = warning
+
+        f.__doc__ = docstring
+
+        @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

+ 2 - 1
flaskbb/user/models.py

@@ -19,7 +19,7 @@ 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
 
 logger = logging.getLogger(__name__)
 
@@ -250,6 +250,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.

+ 1 - 0
requirements.txt

@@ -16,6 +16,7 @@ flask-allows==0.6.0
 Flask-BabelPlus==2.1.1
 Flask-Caching==1.4.0
 Flask-DebugToolbar==0.10.1
+flask-debugtoolbar-warnings>=0.1.0
 Flask-Limiter==1.0.1
 Flask-Login==0.4.1
 Flask-Mail==0.9.1

+ 72 - 0
tests/unit/test_deprecation.py

@@ -0,0 +1,72 @@
+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():
+    """
+    Existing docstring
+    """
+    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 == 28
+        assert "only_a_drill is deprecated" in only_a_drill.__doc__
+
+    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
+
+    def test_adds_to_existing_docstring(self, recwarn):
+        docstring = default_deprecation.__doc__
+
+        assert "Existing docstring" in docstring
+        assert "default_deprecation is deprecated" in docstring
+
+    @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