Add linting

This commit is contained in:
Tim Van Baak 2020-01-27 12:30:40 -08:00
parent 8730e0974f
commit b69b6155b3
21 changed files with 249 additions and 113 deletions

View File

@ -46,7 +46,10 @@ def repl(args):
traceback.print_exc()
def process_doc(docstring):
return '\n'.join([line.strip() for line in (docstring or "").strip().splitlines()])
return '\n'.join([
line.strip()
for line in (docstring or "").strip().splitlines()
])
def get_parser(valid_commands):
# Set up the top-level parser.
@ -84,7 +87,10 @@ def get_parser(valid_commands):
metavar="USERNAME",
dest="tl_username",
help="Specify a user to operate on")
parser.set_defaults(func=lambda args: repl(args) if args.tl_lexicon else parser.print_help())
parser.set_defaults(
func=lambda args: repl(args)
if args.tl_lexicon
else parser.print_help())
subp = parser.add_subparsers(
metavar="COMMAND",
dest="command",

View File

@ -9,7 +9,8 @@
#
def server_commands(commands={}):
if commands: return commands
if commands:
return commands
import amanuensis.cli.server
for name, func in vars(amanuensis.cli.server).items():
if name.startswith("command_"):
@ -18,7 +19,8 @@ def server_commands(commands={}):
return commands
def lexicon_commands(commands={}):
if commands: return commands
if commands:
return commands
import amanuensis.cli.lexicon
for name, func in vars(amanuensis.cli.lexicon).items():
if name.startswith("command_"):
@ -27,7 +29,8 @@ def lexicon_commands(commands={}):
return commands
def user_commands(commands={}):
if commands: return commands
if commands:
return commands
import amanuensis.cli.user
for name, func in vars(amanuensis.cli.user).items():
if name.startswith("command_"):

View File

@ -1,6 +1,8 @@
# Standard library imports
from argparse import ArgumentParser, Namespace
from argparse import ArgumentParser
from functools import wraps
from json.decoder import JSONDecodeError
# These function wrappers allow us to use the same function for executing a
# command and for configuring it. This keeps command arg configuration close to
@ -9,70 +11,108 @@ from functools import wraps
def add_argument(*args, **kwargs):
"""Passes the given args and kwargs to subparser.add_argument"""
def argument_adder(command):
second_layer = command.__dict__.get('wrapper', False)
@wraps(command)
def augmented_command(cmd_args):
if type(cmd_args) is ArgumentParser:
# Add this wrapper's command in the parser pass
if isinstance(cmd_args, ArgumentParser):
cmd_args.add_argument(*args, **kwargs)
if type(cmd_args) is not ArgumentParser or second_layer:
# If there are more command wrappers, pass through to them
if command.__dict__.get('wrapper', False):
command(cmd_args)
# Parser pass doesn't return a value
return None
# Pass through transparently in the execute pass
return command(cmd_args)
# Mark the command as wrapped so control passes through
augmented_command.__dict__['wrapper'] = True
return augmented_command
return argument_adder
def no_argument(command):
"""Noops for subparsers"""
@wraps(command)
def augmented_command(cmd_args):
if type(cmd_args) is not ArgumentParser:
command(cmd_args)
# Noop in the parser pass
if isinstance(cmd_args, ArgumentParser):
return None
# Pass through in the execute pass
return command(cmd_args)
return augmented_command
# Wrappers for commands requiring lexicon or username options
def requires_lexicon(command):
@wraps(command)
def augmented_command(cmd_args):
if type(cmd_args) is ArgumentParser:
cmd_args.add_argument("-n", metavar="LEXICON", dest="lexicon", help="Specify a lexicon to operate on")
# Add lexicon argument in parser pass
if isinstance(cmd_args, ArgumentParser):
cmd_args.add_argument(
"-n", metavar="LEXICON", dest="lexicon",
help="Specify a lexicon to operate on")
# If there are more command wrappers, pass through to them
if command.__dict__.get('wrapper', False):
command(cmd_args)
if type(cmd_args) is Namespace:
base_val = hasattr(cmd_args, "tl_lexicon") and getattr(cmd_args, "tl_lexicon")
subp_val = hasattr(cmd_args, "lexicon") and getattr(cmd_args, "lexicon")
# Parser pass doesn't return a value
return None
# Verify lexicon argument in execute pass
base_val = (hasattr(cmd_args, "tl_lexicon")
and getattr(cmd_args, "tl_lexicon"))
subp_val = (hasattr(cmd_args, "lexicon")
and getattr(cmd_args, "lexicon"))
val = subp_val or base_val or None
if not val:
from amanuensis.config import logger
logger.error("This command requires specifying a lexicon")
return -1
from amanuensis.lexicon import LexiconModel
cmd_args.lexicon = val#LexiconModel.by(name=val).name
command(cmd_args)
# from amanuensis.lexicon import LexiconModel
cmd_args.lexicon = val#LexiconModel.by(name=val).name TODO
return command(cmd_args)
augmented_command.__dict__['wrapper'] = True
return augmented_command
def requires_username(command):
@wraps(command)
def augmented_command(cmd_args):
if type(cmd_args) is ArgumentParser:
cmd_args.add_argument("-u", metavar="USERNAME", dest="username", help="Specify a user to operate on")
# Add user argument in parser pass
if isinstance(cmd_args, ArgumentParser):
cmd_args.add_argument(
"-u", metavar="USERNAME", dest="username",
help="Specify a user to operate on")
# If there are more command wrappers, pass through to them
if command.__dict__.get('wrapper', False):
command(cmd_args)
if type(cmd_args) is Namespace:
base_val = hasattr(cmd_args, "tl_username") and getattr(cmd_args, "tl_lexicon")
subp_val = hasattr(cmd_args, "username") and getattr(cmd_args, "username")
# Parser pass doesn't return a value
return None
# Verify user argument in execute pass
base_val = (hasattr(cmd_args, "tl_username")
and getattr(cmd_args, "tl_lexicon"))
subp_val = (hasattr(cmd_args, "username")
and getattr(cmd_args, "username"))
val = subp_val or base_val or None
if not val:
from amanuensis.config import logger
logger.error("This command requires specifying a user")
return -1
from amanuensis.user import UserModel
cmd_args.username = val#UserModel.by(name=val).name
command(cmd_args)
# from amanuensis.user import UserModel
cmd_args.username = val#UserModel.by(name=val).name TODO
return command(cmd_args)
augmented_command.__dict__['wrapper'] = True
return augmented_command
# Helpers for common command tasks
CONFIG_GET_ROOT_VALUE = object()
@ -96,6 +136,7 @@ def config_get(cfg, pathspec):
return -1
cfg = cfg.get(spec)
print(json.dumps(cfg, indent=2))
return 0
def config_set(obj_id, cfg, set_tuple):
"""
@ -112,7 +153,7 @@ def config_set(obj_id, cfg, set_tuple):
path = pathspec.split('.')
try:
value = json.loads(value)
except:
except JSONDecodeError:
pass # Leave value as string
for spec in path[:-1]:
if spec not in cfg:
@ -126,3 +167,4 @@ def config_set(obj_id, cfg, set_tuple):
old_value = cfg[key]
cfg[key] = value
logger.info("{}.{}: {} -> {}".format(obj_id, pathspec, old_value, value))
return 0

View File

@ -7,7 +7,9 @@ from amanuensis.cli.helpers import (
#
@requires_lexicon
@add_argument("--editor", "-e", required=True, help="Name of the user who will be editor")
@add_argument(
"--editor", "-e", required=True,
help="Name of the user who will be editor")
def command_create(args):
"""
Create a lexicon
@ -33,6 +35,7 @@ def command_create(args):
# Internal call
create_lexicon(args.lexicon, editor)
return 0
@requires_lexicon
@ -55,6 +58,7 @@ def command_delete(args):
# Internal call
delete_lexicon(lex, args.purge)
logger.info("Deleted lexicon '{}'".format(args.lexicon))
return 0
@no_argument
@ -74,9 +78,11 @@ def command_list(args):
elif lex.turn['current'] > lex.turn['max']:
statuses.append("{0.lid} {0.name} ({1})".format(lex, "Completed"))
else:
statuses.append("{0.lid} {0.name} (Turn {1}/{2})".format(lex, lex.turn['current'], lex.turn['max']))
statuses.append("{0.lid} {0.name} (Turn {1}/{2})".format(
lex, lex.turn['current'], lex.turn['max']))
for s in statuses:
print(s)
return 0
@requires_lexicon
@ -112,6 +118,8 @@ def command_config(args):
with json_rw(lex.config_path) as cfg:
config_set(lex.id, cfg, args.set)
return 0
#
# Player/character commands
#
@ -140,6 +148,7 @@ def command_player_add(args):
# Internal call
add_player(lex, u)
return 0
@requires_lexicon
@ -172,6 +181,7 @@ def command_player_remove(args):
# Internal call
remove_player(lex, u)
return 0
@requires_lexicon
@ -181,6 +191,7 @@ def command_player_list(args):
"""
import json
# Module imports
from amanuensis.config import logger
from amanuensis.lexicon import LexiconModel
from amanuensis.user import UserModel
@ -197,6 +208,7 @@ def command_player_list(args):
players.append(u.username)
print(json.dumps(players, indent=2))
return 0
@requires_lexicon
@ -227,6 +239,7 @@ def command_char_create(args):
# Internal call
add_character(lex, u, {"name": args.charname})
return 0
@requires_lexicon
@ -251,6 +264,7 @@ def command_char_delete(args):
# Internal call
delete_character(lex, args.charname)
return 0
@requires_lexicon
def command_char_list(args):
@ -259,6 +273,7 @@ def command_char_list(args):
"""
import json
# Module imports
from amanuensis.config import logger
from amanuensis.lexicon import LexiconModel
# Verify arguments
@ -269,6 +284,7 @@ def command_char_list(args):
# Internal call
print(json.dumps(lex.character, indent=2))
return 0
#
# Procedural commands
@ -292,6 +308,5 @@ def command_publish_turn(args):
settings.
"""
# Module imports
from amanuensis.config import logger
raise NotImplementedError() # TODO

View File

@ -28,6 +28,7 @@ def command_init(args):
# Internal call
create_config_dir(args.config_dir, args.refresh)
return 0
@no_argument
@ -38,7 +39,6 @@ def command_generate_secret(args):
The Flask server will not run unless a secret key has
been generated.
"""
import os
# Module imports
from amanuensis.config import json_rw, logger
@ -46,6 +46,7 @@ def command_generate_secret(args):
with json_rw("config.json") as cfg:
cfg['secret_key'] = secret_key.hex()
logger.info("Regenerated Flask secret key")
return 0
@add_argument("-a", "--address", default="127.0.0.1")
@ -62,9 +63,11 @@ def command_run(args):
from amanuensis.config import get, logger
if get("secret_key") is None:
logger.error("Can't run server without a secret_key. Run generate-secret first")
logger.error("Can't run server without a secret_key. Run generate-sec"
"ret first")
return -1
app.run(host=args.address, port=args.port, debug=args.debug)
return 0
@add_argument("--get", metavar="PATHSPEC", dest="get",
@ -78,7 +81,6 @@ def command_config(args):
PATHSPEC is a path into the config object formatted as
a dot-separated sequence of keys.
"""
import json
# Module imports
from amanuensis.config import json_ro, json_rw, logger
@ -93,3 +95,5 @@ def command_config(args):
if args.set:
with json_rw('config.json') as cfg:
config_set("config", cfg, args.set)
return 0

View File

@ -14,11 +14,13 @@ def command_create(args):
import json
# Module imports
from amanuensis.config import logger, json_ro
from amanuensis.user import UserModel, valid_username, valid_email, create_user
from amanuensis.user import (
UserModel, valid_username, valid_email, create_user)
# Verify or query parameters
if not valid_username(args.username):
logger.error("Invalid username: usernames may only contain alphanumeric characters, dashes, and underscores")
logger.error("Invalid username: usernames may only contain alphanumer"
"ic characters, dashes, and underscores")
return -1
if UserModel.by(name=args.username) is not None:
logger.error("Invalid username: username is already taken")
@ -33,7 +35,10 @@ def command_create(args):
new_user, tmp_pw = create_user(args.username, args.displayname, args.email)
with json_ro(new_user.config_path) as js:
print(json.dumps(js, indent=2))
print("Username: {}\nUser ID: {}\nPassword: {}".format(args.username, new_user.uid, tmp_pw))
print("Username: {}\nUser ID: {}\nPassword: {}".format(
args.username, new_user.uid, tmp_pw))
return 0
@add_argument("--id", required=True, help="id of user to delete")
def command_delete(args):
@ -42,20 +47,21 @@ def command_delete(args):
"""
import os
# Module imports
from amanuensis.config import logger, prepend
from amanuensis.config import logger, prepend, json_rw
user_path = prepend('user', args.id)
if not os.path.isdir(user_path):
logger.error("No user with that id")
return -1
else:
shutil.rmtree(user_path)
with json_rw('user', 'index.json') as j:
if args.id in j:
del j[uid]
if args.id in j: # TODO this is wrong
del j[args.id]
# TODO
return 0
@no_argument
def command_list(args):
"""List all users"""
@ -66,12 +72,16 @@ def command_list(args):
user_dirs = os.listdir(prepend('user'))
users = []
for uid in user_dirs:
if uid == "index.json": continue
if uid == "index.json":
continue
with json_ro('user', uid, 'config.json') as user:
users.append(user)
users.sort(key=lambda u: u['username'])
for user in users:
print("{0} {1} ({2})".format(user['uid'], user['displayname'], user['username']))
print("{0} {1} ({2})".format(
user['uid'], user['displayname'], user['username']))
return 0
@requires_username
@add_argument(
@ -84,7 +94,6 @@ def command_config(args):
"""
Interact with a user's config
"""
import json
# Module imports
from amanuensis.config import logger, json_ro, json_rw
from amanuensis.user import UserModel
@ -106,6 +115,8 @@ def command_config(args):
with json_rw('user', u.id, 'config.json') as cfg:
config_set(u.id, cfg, args.set)
return 0
@add_argument("--username", help="The user to change password for")
@add_argument("--password", help="The password to set. Not recommended")
def command_passwd(args):
@ -113,7 +124,6 @@ def command_passwd(args):
Set a user's password
"""
import getpass
import os
# Module imports
from amanuensis.config import logger
from amanuensis.user import UserModel
@ -126,3 +136,5 @@ def command_passwd(args):
return -1
pw = args.password or getpass.getpass("Password: ")
u.set_password(pw)
return 0

View File

@ -32,7 +32,8 @@ def init_config(args):
global CONFIG_DIR, GLOBAL_CONFIG, logger
CONFIG_DIR = args.config_dir
amanuensis.config.init.verify_config_dir(CONFIG_DIR)
with amanuensis.config.loader.json_ro(os.path.join(CONFIG_DIR, "config.json")) as cfg:
with amanuensis.config.loader.json_ro(
os.path.join(CONFIG_DIR, "config.json")) as cfg:
GLOBAL_CONFIG = cfg
amanuensis.config.init.init_logging(args, GLOBAL_CONFIG['logging'])
logger = logging.getLogger("amanuensis")

View File

@ -114,14 +114,17 @@ def verify_config_dir(config_dir):
# Check that global config file exists
global_config_path = os.path.join(config_dir, "config.json")
if not os.path.isfile(global_config_path):
raise MissingConfigError("Config directory missing global config file: {}".format(config_dir))
raise MissingConfigError("Config directory missing global config file"
": {}".format(config_dir))
# Check that global config file has all the default settings
def_cfg_s = get_stream("global.json")
def_cfg = json.load(def_cfg_s)
with json_ro(global_config_path) as global_config_file:
for key in def_cfg.keys():
if key not in global_config_file.keys():
raise MalformedConfigError("Missing '{}' in global config. If you updated Amanuensis, run init --refresh to pick up new config keys".format(key))
raise MalformedConfigError("Missing '{}' in global config. If"
" you updated Amanuensis, run init --refresh to pick up n"
"ew config keys".format(key))
# Configs verified
return True
@ -147,4 +150,3 @@ def init_logging(args, logging_config):
logging.config.dictConfig(cfg)
except:
raise MalformedConfigError("Failed to load logging config")

View File

@ -2,7 +2,6 @@
from collections import OrderedDict
import fcntl
import json
import os
# Module imports
from amanuensis.errors import ReadOnlyError
@ -90,4 +89,3 @@ class json_rw(open_ex):
json.dump(self.config, self.fd, allow_nan=False, indent='\t')
self.fd.truncate()
super().__exit__(exc_type, exc_value, traceback)

View File

@ -1,23 +1,17 @@
class AmanuensisError(Exception):
"""Base class for exceptions in amanuensis"""
pass
class MissingConfigError(AmanuensisError):
"""A config file is missing that was expected to be present"""
pass
class MalformedConfigError(AmanuensisError):
"""A config file could not be read and parsed"""
pass
class ReadOnlyError(AmanuensisError):
"""A config was edited in readonly mode"""
pass
class InternalMisuseError(AmanuensisError):
"""An internal helper method was called wrongly"""
pass
class IndexMismatchError(AmanuensisError):
"""An id was obtained from an index, but the object doesn't exist"""
pass

View File

@ -1,10 +1,12 @@
import os
import time
from amanuensis.errors import InternalMisuseError, IndexMismatchError, MissingConfigError
from amanuensis.errors import (
InternalMisuseError, IndexMismatchError, MissingConfigError)
from amanuensis.config import prepend, json_ro, json_rw
class LexiconModel():
@staticmethod
def by(lid=None, name=None):
"""
Gets the LexiconModel with the given lid or username
@ -14,7 +16,8 @@ class LexiconModel():
the lexicon's config, raises an error.
"""
if lid and name:
raise InternalMisuseError("lid and name both specified to LexiconModel.by()")
raise InternalMisuseError("lid and name both specified to Lexicon"
"Model.by()")
if not lid and not name:
raise ValueError("One of lid or name must be not None")
if not lid:
@ -51,7 +54,6 @@ class LexiconModel():
def status(self):
if self.turn.current is None:
return "unstarted"
elif self.turn.current > self.turn.max:
if self.turn.current > self.turn.max:
return "completed"
else:
return "ongoing"

View File

@ -166,7 +166,9 @@ def remove_player(lex, player):
if player is None:
raise ValueError("Invalid player: '{}'".format(player))
if lex.editor == player.id:
raise ValueError("Can't remove the editor '{}' from lexicon '{}'".format(player.username, lex.name))
raise ValueError(
"Can't remove the editor '{}' from lexicon '{}'".format(
player.username, lex.name))
# Idempotently remove player
with json_rw(lex.config_path) as cfg:
@ -190,7 +192,9 @@ def add_character(lex, player, charinfo={}):
if not charinfo or not charinfo.get("name"):
raise ValueError("Invalid character info: '{}'".format(charinfo))
charname = charinfo.get("name")
if any([char.name for char in lex.character.values() if char.name == charname]):
if any([
char.name for char in lex.character.values()
if char.name == charname]):
raise ValueError("Duplicate character name: '{}'".format(charinfo))
# Load the character template
@ -221,10 +225,12 @@ def delete_character(lex, charname):
if lex is None:
raise ValueError("Invalid lexicon: '{}'".format(lex))
if charname is None:
raise ValueError("Invalid character name: '{}'".format(charinfo))
raise ValueError("Invalid character name: '{}'".format(charname))
# Find character in this lexicon
matches = [char for cid, char in lex.character.items() if char.name == charname]
matches = [
char for cid, char in lex.character.items()
if char.name == charname]
if len(matches) != 1:
raise ValueError(matches)
char = matches[0]

View File

@ -11,7 +11,10 @@ from amanuensis.server.lexicon import get_bp as get_lex_bp
# Flask app init
static_root = os.path.abspath(get("static_root"))
app = Flask(__name__, template_folder="../templates", static_folder=static_root)
app = Flask(
__name__,
template_folder="../templates",
static_folder=static_root)
app.secret_key = bytes.fromhex(get('secret_key'))
app.jinja_options['trim_blocks'] = True
app.jinja_options['lstrip_blocks'] = True

View File

@ -1,5 +1,6 @@
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, BooleanField, SubmitField, TextAreaField
from wtforms import (
StringField, PasswordField, BooleanField, SubmitField, TextAreaField)
from wtforms.validators import DataRequired, ValidationError
from amanuensis.config import json_ro
@ -7,7 +8,8 @@ from amanuensis.config import json_ro
# Custom validators
def user(exists=True):
template = 'User "{{}}" {}'.format("not found" if exists else "already exists")
template = 'User "{{}}" {}'.format(
"not found" if exists else "already exists")
should_exist = bool(exists)
def validate_user(form, field):
with json_ro('user', 'index.json') as index:
@ -17,7 +19,8 @@ def user(exists=True):
def lexicon(exists=True):
template = 'Lexicon "{{}}" {}'.format("not found" if exists else "already exists")
template = 'Lexicon "{{}}" {}'.format(
"not found" if exists else "already exists")
should_exist = bool(exists)
def validate_lexicon(form, field):
with json_ro('lexicon', 'index.json') as index:
@ -37,8 +40,12 @@ class LoginForm(FlaskForm):
class LexiconCreateForm(FlaskForm):
"""/admin/create/"""
lexiconName = StringField('Lexicon name', validators=[DataRequired(), lexicon(exists=False)])
editorName = StringField('Username of editor', validators=[DataRequired(), user(exists=True)])
lexiconName = StringField(
'Lexicon name',
validators=[DataRequired(), lexicon(exists=False)])
editorName = StringField(
'Username of editor',
validators=[DataRequired(), user(exists=True)])
promptText = TextAreaField("Prompt")
submit = SubmitField('Create')

View File

@ -1,10 +1,5 @@
from functools import wraps
import json
from flask import Blueprint, render_template, url_for, redirect, flash
from flask_login import login_required, current_user
from flask_wtf import FlaskForm
from wtforms import TextAreaField, SubmitField, StringField
from flask import Blueprint, render_template
from flask_login import login_required
from amanuensis.config import json_ro
from amanuensis.lexicon import LexiconModel
@ -22,6 +17,7 @@ def get_bp():
return render_template('home/home.html')
@bp.route('/admin/', methods=['GET'])
@login_required
@admin_required
def admin():
users = []
@ -37,6 +33,7 @@ def get_bp():
return render_template('home/admin.html', users=users, lexicons=lexicons)
@bp.route("/admin/create/", methods=['GET', 'POST'])
@login_required
@admin_required
def admin_create():
form = LexiconCreateForm()

View File

@ -1,4 +1,3 @@
from functools import wraps
import json
from flask import Blueprint, render_template, url_for, redirect, g, flash
@ -6,10 +5,8 @@ from flask_login import login_required, current_user
from amanuensis.config import json_ro, open_ex
from amanuensis.config.loader import ReadOnlyOrderedDict
from amanuensis.lexicon import LexiconModel
from amanuensis.server.forms import LexiconConfigForm
from amanuensis.server.helpers import lexicon_param, player_required
from amanuensis.user import UserModel
from amanuensis.server.helpers import lexicon_param
def get_bp():
@ -54,8 +51,9 @@ def get_bp():
if form.validate():
# Check input is valid json
try:
cfg = json.loads(form.configText.data, object_pairs_hook=ReadOnlyOrderedDict)
except:
cfg = json.loads(form.configText.data,
object_pairs_hook=ReadOnlyOrderedDict)
except json.decoder.JsonDecodeError:
flash("Invalid JSON")
return render_template("lexicon/settings.html", form=form)
# Check input has all the required fields

View File

@ -6,7 +6,8 @@ import uuid
from flask_login import UserMixin
from werkzeug.security import generate_password_hash, check_password_hash
from amanuensis.errors import InternalMisuseError, MissingConfigError, IndexMismatchError
from amanuensis.errors import (
InternalMisuseError, MissingConfigError, IndexMismatchError)
from amanuensis.config import prepend, json_ro, json_rw
from amanuensis.resources import get_stream
from amanuensis.lexicon.manage import get_all_lexicons

37
pylintrc Normal file
View File

@ -0,0 +1,37 @@
# pylint configuration
[MASTER]
[MESSAGES CONTROL]
disable=
bad-continuation,
broad-except,
dangerous-default-value,
duplicate-code,
fixme,
global-statement,
len-as-condition,
logging-format-interpolation,
import-outside-toplevel,
invalid-name,
missing-docstring,
mixed-indentation,
no-member,
no-self-use,
redefined-variable-type,
too-few-public-methods,
too-many-arguments,
too-many-branches,
too-many-instance-attributes,
too-many-lines,
too-many-locals,
too-many-public-methods,
too-many-return-statements,
too-many-statements,
unused-argument,
unused-variable,
[FORMAT]
max-line-length=79

View File

@ -1,10 +1,18 @@
astroid==2.3.3
Click==7.0
Flask==1.1.1
Flask-Login==0.4.1
Flask-WTF==0.14.2
isort==4.3.21
itsdangerous==1.1.0
Jinja2==2.10.3
lazy-object-proxy==1.4.3
MarkupSafe==1.1.1
mccabe==0.6.1
pkg-resources==0.0.0
pylint==2.4.4
six==1.14.0
typed-ast==1.4.1
Werkzeug==0.16.0
wrapt==1.11.2
WTForms==2.2.1