Posts


docsig

sjwhitlock
sjwhitlock

Check signature params for proper documentation in your Python code

Supports reStructuredText ( Sphinx ), NumPy , and Google

Code | PyPI | Docs | pre-commit

Find docsig source, docsig releases, docsig documentation, and docsig pre-commit hook

Contributing

If you are interested in contributing to docsig please read about contributing here

Installation

$ pip install docsig

Usage

Commandline

usage: docsig [-h] [-v] [-l] [-c | -C] [-D] [-m] [-o] [-p] [-P] [-i] [-a] [-k]
                         [-n] [-S] [-s STR] [-d LIST] [-t LIST]
                         [path [path ...]]

Check signature params for proper documentation

positional arguments:
  path                                 directories or files to check

optional arguments:
  -h, --help                           show this help message and exit
  -v, --version                        show program's version number and exit
  -l, --list-checks                    display a list of all checks and their messages
  -c, --check-class                    check class docstrings
  -C, --check-class-constructor        check __init__ methods. Note: mutually
                                       incompatible with -c
  -D, --check-dunders                  check dunder methods
  -m, --check-protected-class-methods  check public methods belonging to protected
                                       classes
  -o, --check-overridden               check overridden methods
  -p, --check-protected                check protected functions and classes
  -P, --check-property-returns         check property return values
  -i, --ignore-no-params               ignore docstrings where parameters are not
                                       documented
  -a, --ignore-args                    ignore args prefixed with an asterisk
  -k, --ignore-kwargs                  ignore kwargs prefixed with two asterisks
  -n, --no-ansi                        disable ansi output
  -S, --summary                        print a summarised report
  -s STR, --string STR                 string to parse instead of files
  -d LIST, --disable LIST              comma separated list of rules to disable
  -t LIST, --target LIST               comma separated list of rules to target

Options can also be configured with the pyproject.toml file

If you find the output is too verbose then the report can be configured to display a summary

[tool.docsig]
check-dunders = false
check-overridden = false
check-protected = false
summary = true
disable = [
    "E101",
    "E102",
    "E103",
]
target = [
    "E102",
    "E103",
    "E104",
]

API

>>> from docsig import docsig
>>> string = """
... def function(param1, param2, param3) -> None:
...     '''
...
...     :param param1: About param1.
...     :param param2: About param2.
...     :param param3: About param3.
...     '''
...     """
>>> docsig(string=string)
0
>>> string = """
... def function(param1, param2) -> None:
...     '''
...
...     :param param1: About param1.
...     :param param2: About param2.
...     :param param3: About param3.
...     '''
... """
>>> docsig(string=string)
2
-
def function(✓param1, ✓param2, ✖None) -> ✓None:
    """
    :param param1: ✓
    :param param2: ✓
    :param param3: ✖
    """
<BLANKLINE>
E102: includes parameters that do not exist (params-do-not-exist)
<BLANKLINE>
1

A full list of checks can be found here

Message Control

To control checks docsig accepts disable and enable directives

To disable individual function checks add an inline comment similar to the example below

>>> string = """
... def function_1(param1, param2, param3) -> None:  # docsig: disable
...     '''
...
...     :param param2: Fails.
...     :param param3: Fails.
...     :param param1: Fails.
...     '''
...
... def function_2(param1, param2) -> None:
...     '''
...
...     :param param1: Fails.
...     :param param2: Fails.
...     :param param3: Fails.
...     '''
...
... def function_3(param1, param2, param3) -> None:
...     '''
...
...     :param param1: Fails.
...     :param param2: Fails.
...     '''
... """
>>> docsig(string=string)
10
--
def function_2(✓param1, ✓param2, ✖None) -> ✓None:
    """
    :param param1: ✓
    :param param2: ✓
    :param param3: ✖
    """
<BLANKLINE>
E102: includes parameters that do not exist (params-do-not-exist)
<BLANKLINE>
18
--
def function_3(✓param1, ✓param2, ✖param3) -> ✓None:
    """
    :param param1: ✓
    :param param2: ✓
    :param None: ✖
    """
<BLANKLINE>
E103: parameters missing (params-missing)
<BLANKLINE>
1

To disable all function checks add a module level comment similar to the example below

>>> string = """
... # docsig: disable
... def function_1(param1, param2, param3) -> None:
...     '''
...
...     :param param2: Fails.
...     :param param3: Fails.
...     :param param1: Fails.
...     '''
...
... def function_2(param1, param2) -> None:
...     '''
...
...     :param param1: Fails.
...     :param param2: Fails.
...     :param param3: Fails.
...     '''
...
... def function_3(param1, param2, param3) -> None:
...     '''
...
...     :param param1: Fails.
...     :param param2: Fails.
...     '''
... """
>>> docsig(string=string)
0

To disable multiple function checks add a module level disable and enable comment similar to the example below

>>> string = """
... # docsig: disable
... def function_1(param1, param2, param3) -> None:
...     '''
...
...     :param param2: Fails.
...     :param param3: Fails.
...     :param param1: Fails.
...     '''
...
... def function_2(param1, param2) -> None:
...     '''
...
...     :param param1: Fails.
...     :param param2: Fails.
...     :param param3: Fails.
...     '''
... # docsig: enable
...
... def function_3(param1, param2, param3) -> None:
...     '''
...
...     :param param1: Fails.
...     :param param2: Fails.
...     '''
... """
>>> docsig(string=string)
20
--
def function_3(✓param1, ✓param2, ✖param3) -> ✓None:
    """
    :param param1: ✓
    :param param2: ✓
    :param None: ✖
    """
<BLANKLINE>
E103: parameters missing (params-missing)
<BLANKLINE>
1

The same can be done for disabling individual rules

>>> string = """
... # docsig: disable=E101
... def function_1(param1, param2, param3) -> int:
...     '''E105.
...
...     :param param1: Fails.
...     :param param2: Fails.
...     :param param3: Fails.
...     '''
...
... def function_2(param1, param2, param3) -> None:  # docsig: disable=E102,E106
...     '''E101,E102,E106.
...
...     :param param1: Fails.
...     :param param1: Fails.
...     :param param2: Fails.
...     :param param3: Fails.
...     '''
...
... def function_3(param1, param2, param3) -> None:
...     '''E101,E102,E106,E107.
...
...     :param param1: Fails.
...     :param param1: Fails.
...     :param param2: Fails.
...     :param: Fails.
...     '''
... """
>>> docsig(string=string)
3
-
def function_1(✓param1, ✓param2, ✓param3) -> ✖int:
    """
    :param param1: ✓
    :param param2: ✓
    :param param3: ✓
    :return: ✖
    """
<BLANKLINE>
E105: return missing from docstring (return-missing)
<BLANKLINE>
20
--
def function_3(✓param1, ✖param2, ✖param3, ✖None) -> ✓None:
    """
    :param param1: ✓
    :param param1: ✖
    :param param2: ✖
    :param None: ✖
    """
<BLANKLINE>
E102: includes parameters that do not exist (params-do-not-exist)
E106: duplicate parameters found (duplicate-params-found)
E107: parameter appears to be incorrectly documented (param-incorrectly-documented)
<BLANKLINE>
1

Individual rules can also be re-enabled

Module level directives will be evaluated separately to inline directives and providing no rules will disable and enable all rules

>>> string = """
... # docsig: disable
... def function_1(param1, param2, param3) -> int:
...     '''E105.
...
...     :param param1: Fails.
...     :param param2: Fails.
...     :param param3: Fails.
...     '''
...
... def function_2(param1, param2, param3) -> None:  # docsig: enable=E102,E106
...     '''E101,E102,E106.
...
...     :param param1: Fails.
...     :param param1: Fails.
...     :param param2: Fails.
...     :param param3: Fails.
...     '''
...
... def function_3(param1, param2, param3) -> None:
...     '''E101,E102,E106,E107.
...
...     :param param1: Fails.
...     :param param1: Fails.
...     :param param2: Fails.
...     :param: Fails.
...     '''
... """
>>> docsig(string=string)
11
--
def function_2(✓param1, ✖param2, ✖param3, ✖None) -> ✓None:
    """
    :param param1: ✓
    :param param1: ✖
    :param param2: ✖
    :param param3: ✖
    """
<BLANKLINE>
E102: includes parameters that do not exist (params-do-not-exist)
E106: duplicate parameters found (duplicate-params-found)
<BLANKLINE>
1

Classes

Checking a class docstring is not enabled by default, as there are two mutually exclusive choices to choose from.

This check will either check the documentation of __init__ , or check documentation of __init__ under the class docstring, and not under __init__ itself

>>> string = """
... class Klass:
...     def __init__(self, param1, param2) -> None:
...         '''
...
...         :param param1: About param1.
...         :param param2: About param2.
...         :param param3: About param3.
...         '''
... """
>>> docsig(string=string, check_class_constructor=True)
3 in Klass
----------
class Klass:
    """
    :param param1: ✓
    :param param2: ✓
    :param param3: ✖
    """
<BLANKLINE>
    def __init__(✓param1, ✓param2, ✖None) -> ✓None:
<BLANKLINE>
E102: includes parameters that do not exist (params-do-not-exist)
<BLANKLINE>
1
>>> string = """
... class Klass:
...     '''
...
...     :param param1: About param1.
...     :param param2: About param2.
...     :param param3: About param3.
...     '''
...     def __init__(self, param1, param2) -> None:
...         pass
... """
>>> docsig(string=string, check_class=True)
9 in Klass
----------
class Klass:
    """
    :param param1: ✓
    :param param2: ✓
    :param param3: ✖
    """
<BLANKLINE>
    def __init__(✓param1, ✓param2, ✖None) -> ✓None:
<BLANKLINE>
E102: includes parameters that do not exist (params-do-not-exist)
<BLANKLINE>
1

Checking class docstrings can be permanently enabled in the pyproject.toml file

[tool.docsig]
check-class-constructor = true

Or

[tool.docsig]
check-class = true

pre-commit

docsig can be used as a pre-commit hook

It can be added to your .pre-commit-config.yaml as follows:

repos:
  - repo: https://github.com/jshwi/docsig
    rev: v0.44.1
    hooks:
      - id: docsig
        args:
          - "--check-class"
          - "--check-dunders"
          - "--check-overridden"
          - "--check-protected"
          - "--summary"

If you are entering Flask from django you'll notice that Flask doesn't come with an admin interface. As per the django documentation for its admin interface:

One of the most powerful parts of Django is the automatic admin interface.

One of the most powerful parts about Flask , however, is its lack of these things. Everything is up to you as the developer.

Flask-Admin can be used to set up an admin interface, so you can manage your database through your browser.

There are 3 main issues, I found, when using Flassk-Admin features out-of-the-box

  1. It does not offer, for reasons of opinion, any security provisions; for this we'll use Flask-Login
  2. It reserves end point prefixes that you may want to use for other routes
  3. it does not use the app factory pattern (any attempt to use the app factory pattern will result in an error): This is due to the extension registering its blueprints on instantiation
AssertionError: A blueprint's name collision occurred between <flask.blueprints.Blueprint object at 0x25e5d90> and <flask.blueprints.Blueprint object at 0x21b89d0>.  Both share the same name "user".  Blueprints that are created on the fly need unique names.

Flask-Admin , however, is really easy to set up despite this.

$ pip install flask-admin flask-login

For a package to be recognised as an official Flask extension it should include the init_app method, so that the app does not need to passed directly to the extension upon instantiation. This enables the extension to be passed around within the app's modules before it even exists. This prevents circular imports, as is the reason for the app factory pattern. Luckily, Flask-Admin does not need to passed around, and therefore works when it is instantiated with the app object.

Your extensions module might look like something like this

# app/extensions.py
from flask_login import LoginManager
...
from flask_sqlalchemy import SQLAlchemy

...
login_manager = LoginManager()
...
sqlalchemy = SQLAlchemy()
...


def init_app(app):
    ...
    login_manager.init_app(app)
    ...
    sqlalchemy.init_app(app)
    ...

To be registered in the app's __init__.py file like this:

# app/__init__.py
from flask import Flask

...
from app import extensions
...


def create_app():
    app = Flask(__name__)
    ...
    extensions.init_app(app)
    ...
    return app

Create a new app/admin.py module and write up a new init_app function

Solution to 1

Flask-Login comes with the extremely useful login_required decorator. Put this on top of your routes, and the user is required to be logged in to access it. We can do this with an admin user too. Add a boolean value to your user model; admin

# app/models.py
from flask_login import UserMixin

from app.extensions import db

...


class User(UserMixin, db.Model):
    ...
    admin = db.Column(db.Boolean, default=False)
    ...


...

Now we can decorate the login_required functionality with a more restricted, admin function. login_required , under the hood, returns a LoginManager.unauthorized function if the user is not logged in. This raises, among other things, raises a 401 Unauthorized error.

How you make your user an admin is pretty simple, but I won't go into it here.

import functools

from flask_login import current_user

from app.extensions import login_manager


def admin_required(func):
    """Handle views that require an admin be signed in.

    :param func: View function to wrap.
    :return: The wrapped function supplied to this decorator.
    """

    @functools.wraps(func)
    def _wrapped_view(*args, **kwargs):
        if not current_user.admin:
            return login_manager.unauthorized()

        return func(*args, **kwargs)

    return _wrapped_view

Create a new admin.py module. Flask-Admin , by default, constructs its index page with the AdminIndexView class. We can override this class and its index method to be decorated with our admin_required function. Make sure to continue using login_required as a sort of hierarchy of logins, otherwise the admin_required decorator will fail. This is because the decorator needs the user to logged in to check for admin.

# app/admin.py
from flask_admin import AdminIndexView, expose
...
from flask_login import login_required

from app.utils.security import admin_required


class MyAdminIndexView(AdminIndexView):
    """Custom index view that handles login / registration."""

    @expose("/")
    @login_required
    @admin_required
    def index(self) -> str:
        """Requires user be logged in as admin."""
        return super().index()

We can then write our init_app function like this.

# app/admin.py
from flask_admin import Admin
...


def init_app(app):
    admin = Admin(  # noqa
        app,
        index_view=MyAdminIndexView(),  # type: ignore
    )
    ...

Solution to problem 2

We can subclass flask_admin.contrib.sqla.ModelView to register their blueprints under a different name.

Now you can use the /user , /post , etc. prefixes on your user, post etc. routes, and not waste them on the admin routes.

Because the views display, usually, multiple user, post etc. tables, it makes sense to end them as a plural.

It is also worth pointing out that we have blocked access to these routes for non-admins too.

# app/admin.py
from flask_admin import Admin
from flask_admin import AdminIndexView
from flask_admin.contrib import sqla
from flask_login import current_user

from app.extensions import db
from app.utils.models import User, Post, Task, Message, Notification


class MyAdminIndexView(AdminIndexView):
    ...


class MyModelView(sqla.ModelView):
    """Custom model view that determines accessibility."""

    def __init__(self, model: db.Model, session: db.session) -> None:
        super().__init__(model, session)
        self.endpoint = f"{model.__table__}s"  # change the table name here

    def is_accessible(self) -> None:
        """Only allow access if user is logged in as admin."""
        return current_user.is_authenticated and current_user.admin

    ...

def init_app(app):
    admin = Admin(  # noqa
        app,
        index_view=MyAdminIndexView(),  # type: ignore
    )
    admin.add_view(MyModelView(User, db.session))
    admin.add_view(MyModelView(Post, db.session))
    admin.add_view(MyModelView(Message, db.session))
    admin.add_view(MyModelView(Notification, db.session))
    admin.add_view(MyModelView(Task, db.session))

Finally, as a solution to 3, we initialize the app

# app/__init__.py
from flask import Flask

...
from app import extensions
from app.routes import admin
...


def create_app():
    app = Flask(__name__)
    ...
    extensions.init_app(app)
    admin.init_app(app)
    ...
    return app

SQLAlchemy has a limited versioning extension that does not support entire transactions. SQLAlchemy-Continuum offers a flexible API for implementing a versioning mechanism to your SQLAlchemy ORM database.

If you want to ensure nothing is lost after editing your database I have found this package to be a good place to start. There are drawbacks when it comes to data retention; hashing algorithms and data compression aren't built into the API.

This article assumes you are using Flask-SQLAlchemy .

pip install sqlalchemy-continuum

In the module where your SQLAlchemy models are defined call make_versioned() before their definition and add __versioned__ to all models you wish to add versioning to.

# app/models.py
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy.orm import configure_mappers
from sqlalchemy_continuum import make_versioned

app = Flask(__name__)

db = SQLAlchemy()

db.init_app(app)

make_versioned(user_cls=None)


class Post(db.Base):
    __versioned__ = {}
    ...


configure_mappers()

Database queries will now have a list-like object called versions

from app.models import Post, db

id = 1  # first item in the db; we are using `get` and the db starts at 1
revision = 1  # however, with `post.versions` we are working with list indexes

# for a post object with at least 1 entry, and at least 2 versions
post = Post.query.get(id=id)
post.versions[revision].revert()
db.session.commit()

With the above, a third item has been added to post.versions , which is basically a duplicate of the first.

We can then craft a route that will restore the previous version:

from app.utils.models import Post, db
from flask_login import login_required
from flask import redirect, url_for, Blueprint

bp = Blueprint("views", __name__)
...

@bp.route("/<int:id>/version/<int:revision>")
@login_required
def version(id, revision):
    post = Post.query.get(id=id)
    post.versions[revision].revert()
    ...
    db.session.commit()
    return redirect(url_for("views.index"))

...

Accessing /1/version/1 after your URL will restore version 1 from post 1

If you already have an update view, much like the one demonstrated here https://flask.palletsprojects.com/en/2.0.x/tutorial/blog/ then we can also load the previous version with a query-string. With this we don't have to blindly restore versions (and add more duplicate versions along the way).

by default, the revision returned will be the last one i.e. /1/update is the same as /1/update?revision=-1 .

This time we will return the version object - this way the form will be pre-loaded with the revision. Once the form's submission is validated the current post object will be replaced with restored revision. Once this is committed either a duplicate or edited version will be added to post.versions .

from app.utils.models import Post, db
from flask import redirect, Blueprint, request, render_template
from flask_login import login_required
from app.utils.forms import PostForm

bp = Blueprint("views", __name__)
...


@bp.route("/<int:id>/update", methods=["GET", "POST"])
@login_required
def update(id):
    # by default, revision returned will be the last one
    revision = request.args.get("revision", -1, type=int)
    
    post = Post.query.get(id=id)
    
    # this time we will return the version object
    version = post.versions[revision]
    
    # this way the form will be preloaded with the revision
    form = PostForm(title=version.title, body=version.body)
    
    # once the form's submit button is pressed the current `post` object 
    # will be replaced with restored revision
    if form.validate_on_submit():
        post.title = form.title.data
        post.body = form.body.data
        ...
        
        # once this is committed either a duplicate or edited version 
        # will be added to `post.versions`
        db.session.commit()
        return redirect(url_for("views.index"))

    return render_template("update.html", post=post, form=form)

...

About

sjwhitlock
sjwhitlock

Check out The Flask Mega-Tutorial by Miguel Grinberg. This tutorial highlights the flexibility of the Flask micro framework .
With assistance from the above this site includes:

  • Flask-SqlAlchemy 's database object-relational mapping. This is a good fit for object-oriented programming, and databases can run under SQLite, and PostgreSQL (with psycopg2). Using Flask-SqlAlchemy, after using Python's native sqlite3, it is easy to see why this would be the one to roll with. Those used to working with queries in the SQL console might find a preference for the str methodology that sqlite3 incorporates, however, seeing as SQLite is not appropriate as a persistent production-grade database SQLAlchemy is the pick.
  • Flask-Login: A hassle-free way of handling user logins, and currently logged in users with Flask-Logins current_user instance.
  • Flask-Mail: Asynchronous mail, crash-reporting, user verification, and password resets.
  • User based profiles: Avatars, user profile pages, and many-to-many database relationships demonstrated through follow and unfollow features made possible through tables.
  • Flask-Boostrap: This extension lets the user use Twitter's Bootstrap framework for CSS styles, which can be tweaked through Jinja's support of template inheritence.
  • Pagination of posts: Encodes a limit for the amount of posts that any one page will show at a time - avoiding the pitfalls of having to load a (potentially) large amount of posts at once.
  • Flask-Moment: Support for moment.js and client side rendering of timestamps for user's all around the world.
  • Redis and RQ: Adds support for background tasks, such as user requested files, and notifications.

Flask as a micro framework:
In contrast to Django (these two frameworks being Python's most used web frameworks by far), Flask is un-opinionated, meaning there is no right way to do any one task. Instead, Flask functions through an ecosystem of extensions. Authorization for example can be handled by Flask-Login, Flask-Security, Flask-User, to name a few. This can be a good and a bad thing. The options can be daunting, and some projects have been abandoned (or close to). Flask is ideal for learning application development at a finer level, whereas Django has many aspects of web development included out of the box, such as an admin dashboard interface. An admin dashboard can be achieved with Flask, but only if the user requires it (see Flask-Admin).

See me on Github

commit cad6a56 : feat: Adds support for background tasks
Resource: The Flask Mega-Tutorial Part XXII: Background Jobs
Introduces: Redis . rq

commit afaeffe : feat: Adds private messaging feature
Resource: The Flask Mega-Tutorial Part XXI: User Notifications

commit 655cd08 : add: Adds syntax highlighting
Introduces: highlight.js

commit 94a0e87 : change: Adds Flask-Moment for displaying timestamps
Resource: The Flask Mega-Tutorial Part XII: Dates and Times
Introduces: Flask-Moment (Flask implementation of moment.js )

commit 7bc2771 : feat: Adds pagination to posts
Resource: The Flask Mega-Tutorial Part IX: Pagination

commit 20ea7e0 : add: Adds Flask-Bootstrap
Resource: The Flask Mega-Tutorial Part XI: Facelift
Introduces: Flask-Bootstrap (Flask implementation of Bootstrap )

commit 3861349 : add: Adds support for markdown
Introduces: Flask-PageDown , Flask-Misaka (Flask implementation of misaka )

commit a8a6dd3 : add: Adds followers functionality
Resource: The Flask Mega-Tutorial Part VIII: Followers

commit edd1135 : add: Adds edit profile page
commit eb393db : add: Records last login time for user
commit 7bd9dfb : add: Adds user profiles
commit 88ab80c : add: Adds user avatars
Resource: The Flask Mega-Tutorial Part VI: Profile Page and Avatars

commit 4774391 : add: Adds error logging email handler
Resource: The Flask Mega-Tutorial Part X: Email Support

commit bac8824 : feat: Adds reset password functionality
Resource: The Flask Mega-Tutorial Part X: Email Support
Introduces: pyjwt (Python implementation of JSON Web Tokens )

commit c356764 : feat: Adds email verification
Resource: Handling Email Confirmation During Registration in Flask
Introduces: itsdangerous

commit 8c9c72f : change: Adds Flask-WTF for handling forms
Resource: The Flask Mega-Tutorial Part III: Web Forms
Introduces: Flask-WTF (Flask implementation of WTForms )

commit aeaf4f1 : change: Adds Flask-Login for handling logins
Resource: The Flask Mega-Tutorial Part V: User Logins
Introduces: Flask-Login

commit 67a9e12 : add: Adds app.mail
Resource: The Flask Mega-Tutorial Part X: Email Support
Introduces: Flask-Mail

commit 70acb5f : change: moves database over to SQLAlchemy
Resource: The Flask Mega-Tutorial Part IV: Database
Introduces: Flask-Sqlalchemy , Flask-Migrate (Flask implementation of alembic )

commit 9c62db7 : Initial commit
Flask's Tutorial: Flaskr


v1.0.0: CHANGELOG.md

Written in Python with Flask using PyCharm
Template Engine: Jinja
CSS Framework: Flask-Bootstrap
CSS Dark Mode: Darkreader
Source: https://github.com/jshwi/jss
Documentation: https://jshwi.github.io/jss/
Hosted on Heroku: https://jshwisolutions.herokuapp.com