diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 47842bd2..17b271a0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,20 +1,42 @@ # See https://pre-commit.com for more information # See https://pre-commit.com/hooks.html for more hooks repos: -- repo: https://github.com/pre-commit/pre-commit-hooks - rev: v2.4.0 - hooks: - # - id: trailing-whitespace - # - id: end-of-file-fixer - # - id: check-yaml - - id: check-added-large-files -- repo: https://github.com/PyCQA/pylint/ - rev: '2.6' - hooks: - - id: pylint - name: pylint - entry: pylint - language: system - types: [python] - args: ['--rcfile', 'tests/.pylintrc'] - # exclude: tests/functional/|tests/input|tests/extensions/data|tests/regrtest_data/|tests/data/|doc/ +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.1.0 + hooks: + # - id: trailing-whitespace + # - id: end-of-file-fixer + # - id: check-yaml + - id: check-added-large-files +- repo: https://github.com/pycqa/isort + rev: 5.10.1 + hooks: + - id: isort + name: isort (python) + args: [--profile, black, --filter-files] +- repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks + rev: v2.2.0 + hooks: + - id: pretty-format-toml + args: [--autofix] + - id: pretty-format-yaml + args: [--autofix] + exclude: .copier-answers.yml + # based on + # https://gitlab.com/smop/pre-commit-hooks/-/blob/master/.pre-commit-hooks.yaml +- repo: https://github.com/akaihola/darker + rev: 1.3.2 + hooks: + - id: darker +- repo: local + hooks: + - id: pylint + name: pylint + entry: pylint + language: system + types: [python] + args: [-rn, -sn, --rcfile=tests/.pylintrc] + # "-rn", # Only display messages + # "-sn", # Don't display the score + # based on + # https://pylint.pycqa.org/en/latest/user_guide/pre-commit-integration.html diff --git a/bukuserver/__main__.py b/bukuserver/__main__.py index 61360bba..1981062e 100644 --- a/bukuserver/__main__.py +++ b/bukuserver/__main__.py @@ -1,4 +1,7 @@ -from . import server +try: + from . import server +except ImportError: + from bukuserver import server if __name__ == '__main__': diff --git a/bukuserver/forms.py b/bukuserver/forms.py index 51a197f4..9d8dd3c5 100644 --- a/bukuserver/forms.py +++ b/bukuserver/forms.py @@ -1,7 +1,7 @@ """Forms module.""" # pylint: disable=too-few-public-methods, missing-docstring -from flask_wtf import FlaskForm import wtforms +from flask_wtf import FlaskForm class SearchBookmarksForm(FlaskForm): @@ -16,8 +16,7 @@ class HomeForm(SearchBookmarksForm): class BookmarkForm(FlaskForm): - url = wtforms.StringField( - validators=[wtforms.validators.DataRequired(), wtforms.validators.URL(require_tld=False)]) + url = wtforms.StringField(validators=[wtforms.validators.DataRequired()]) title = wtforms.StringField() tags = wtforms.StringField() description = wtforms.TextAreaField() diff --git a/bukuserver/requirements.txt b/bukuserver/requirements.txt index b13e5c86..62e43310 100644 --- a/bukuserver/requirements.txt +++ b/bukuserver/requirements.txt @@ -8,7 +8,7 @@ Flask-Admin>=1.5.1 Flask-API>=0.6.9 Flask-Bootstrap>=3.3.7.1 flask-paginate>=0.5.1 -flask-reverse-proxy-fix>=0.2.1 +flask-reverse-proxy-fix @ https://github.com/rachmadaniHaryono/flask-reverse-proxy-fix/archive/refs/tags/v0.2.2rc1.zip Flask-WTF>=0.14.2 Flask>=1.0.2,<2.0 idna>=2.5 diff --git a/bukuserver/server.py b/bukuserver/server.py index c800d871..9cf9bef9 100644 --- a/bukuserver/server.py +++ b/bukuserver/server.py @@ -1,28 +1,32 @@ #!/usr/bin/env python # pylint: disable=wrong-import-order, ungrouped-imports """Server module.""" +import collections +import os +import sys +import typing as T from typing import Any, Dict, Union # NOQA; type: ignore from unittest import mock from urllib.parse import urlparse -import os -import sys -from buku import BukuDb, __version__, network_handler from flask.cli import FlaskGroup from flask.views import MethodView from flask_admin import Admin -from flask_api import exceptions, FlaskAPI, status +from flask_api import FlaskAPI, exceptions, status from flask_bootstrap import Bootstrap from flask_paginate import Pagination, get_page_parameter, get_per_page_parameter + +import buku +from buku import BukuDb, __version__, network_handler + try: from flask_reverse_proxy_fix.middleware import ReverseProxyPrefixFix except ImportError: ReverseProxyPrefixFix = None -from markupsafe import Markup import click import flask -from flask import ( # type: ignore - __version__ as flask_version, +from flask import __version__ as flask_version # type: ignore +from flask import ( abort, current_app, flash, @@ -32,11 +36,12 @@ request, url_for, ) +from markupsafe import Markup try: - from . import response, forms, views + from . import forms, response, views except ImportError: - from bukuserver import response, forms, views + from bukuserver import forms, response, views STATISTIC_DATA = None @@ -301,19 +306,56 @@ def shell_context(): return app +def search_tag( + db: BukuDb, stag: T.Optional[str] = None, limit: T.Optional[int] = None +) -> T.Tuple[T.List[str], T.Dict[str, int]]: + """search tag. + + db: + buku db instance + stag: + search tag + limit: + positive integer limit + + Returns + ------- + tuple + list of unique tags sorted alphabetically and dictionary of tag and its usage count + + Raises + ------ + ValueError + if limit is not positive + """ + if limit is not None and limit < 1: + raise ValueError("limit must be positive") + tags: T.Set[str] = set() + counter = collections.Counter() + query_list = ["SELECT DISTINCT tags , COUNT(tags) FROM bookmarks"] + if stag: + query_list.append("where tags LIKE :search_tag") + query_list.append("GROUP BY tags") + row: T.Tuple[str, int] + for row in db.cur.execute(" ".join(query_list), {"search_tag": f"%{stag}%"}): + for tag in row[0].strip(buku.DELIM).split(buku.DELIM): + if not tag: + continue + tags.add(tag) + counter[tag] += row[1] + return list(sorted(tags)), dict(counter.most_common(limit)) + + class ApiTagView(MethodView): - def get(self, tag: Union[str, None]): + def get(self, tag: T.Optional[str]): bukudb = get_bukudb() if tag is None: - tags = bukudb.get_tag_all() - result = {'tags': tags[0]} - return result - tags = bukudb.get_tag_all() + return {"tags": search_tag(db=bukudb, limit=5)[0]} + tags = search_tag(db=bukudb, stag=tag) if tag not in tags[1]: raise exceptions.NotFound() - res = dict(name=tag, usage_count=tags[1][tag]) - return res + return dict(name=tag, usage_count=tags[1][tag]) def put(self, tag: str): bukudb = get_bukudb() @@ -554,7 +596,7 @@ def delete(self): return res -class BookmarkletView(MethodView): +class BookmarkletView(MethodView): # pylint: disable=too-few-public-methods def get(self): url = request.args.get('url') title = request.args.get('title') diff --git a/bukuserver/views.py b/bukuserver/views.py index 736aca4e..30864cac 100644 --- a/bukuserver/views.py +++ b/bukuserver/views.py @@ -1,61 +1,73 @@ """views module.""" +import functools +import itertools +import logging from argparse import Namespace from collections import Counter from types import SimpleNamespace from typing import Any, List, Optional, Tuple from urllib.parse import urlparse -import itertools -import logging +import arrow +import wtforms from flask import current_app, flash, redirect, request, url_for from flask_admin.babel import gettext from flask_admin.base import AdminIndexView, BaseView, expose from flask_admin.model import BaseModelView from flask_wtf import FlaskForm -from jinja2 import Markup -import arrow -import wtforms +from markupsafe import Markup + +import buku try: - from . import forms, filters as bs_filters + from . import filters as bs_filters + from . import forms from .filters import BookmarkField, FilterType except ImportError: - from bukuserver import forms, filters as bs_filters - from bukuserver.filters import BookmarkField, FilterType + from bukuserver import filters as bs_filters # type: ignore + from bukuserver import forms + from bukuserver.filters import BookmarkField, FilterType # type: ignore STATISTIC_DATA = None -DEFAULT_URL_RENDER_MODE = 'full' +DEFAULT_URL_RENDER_MODE = "full" DEFAULT_PER_PAGE = 10 LOG = logging.getLogger("bukuserver.views") class CustomAdminIndexView(AdminIndexView): - - @expose('/') + @expose("/") def index(self): - return self.render('bukuserver/home.html', form=forms.HomeForm()) - - @expose('/', methods=['POST',]) + return self.render("bukuserver/home.html", form=forms.HomeForm()) + + @expose( + "/", + methods=[ + "POST", + ], + ) def search(self): "redirect to bookmark search" form = forms.HomeForm() bbm_filter = bs_filters.BookmarkBukuFilter( - all_keywords=False, deep=form.deep.data, regex=form.regex.data) + all_keywords=False, deep=form.deep.data, regex=form.regex.data + ) op_text = bbm_filter.operation() values_combi = sorted(itertools.product([True, False], repeat=3)) + choosen_idx = None for idx, (all_keywords, deep, regex) in enumerate(values_combi): if deep == form.deep.data and regex == form.regex.data and not all_keywords: choosen_idx = idx - url_op_text = op_text.replace(', ', '_').replace(' ', ' ').replace(' ', '_') - key = ''.join(['flt', str(choosen_idx), '_buku_', url_op_text]) - kwargs = {key: form.keyword.data} - url = url_for('bookmark.index_view', **kwargs) + url_op_text = op_text.replace(", ", "_").replace(" ", " ").replace(" ", "_") + kwargs = {} + if choosen_idx: + key = "".join(["flt", str(choosen_idx), "_buku_", url_op_text]) + kwargs = {key: form.keyword.data} + url = url_for("bookmark.index_view", **kwargs) return redirect(url) class CustomBukuDbModel: # pylint: disable=too-few-public-methods - def __init__(self, bukudb_inst, name): self.bukudb = bukudb_inst self.name = name @@ -66,120 +78,130 @@ def __name__(self): class BookmarkModelView(BaseModelView): - def _apply_filters(self, models, filters): - for idx, flt_name, value in filters: - flt = self._filters[idx] - clean_value = flt.clean(value) - models = list(flt.apply(models, clean_value)) + for idx, _, value in filters: + if self._filters: + flt = self._filters[idx] + clean_value = flt.clean(value) + models = list(flt.apply(models, clean_value)) return models def _create_ajax_loader(self, name, options): pass - def _list_entry( - self, context: Any, model: Namespace, name: str) -> Markup: + def _list_entry(self, context: Any, model: Namespace, name: str) -> Markup: + LOG.debug("context: %s, name: %s", context, name) parsed_url = urlparse(model.url) - netloc, scheme = parsed_url.netloc, parsed_url.scheme - is_scheme_valid = scheme in ('http', 'https') + netloc = parsed_url.netloc tag_text = [] - tag_tmpl = '{0}' - for tag in model.tags.split(','): - if tag: - tag_text.append(tag_tmpl.format(tag, url_for( - 'bookmark.index_view', flt2_tags_contain=tag))) - if not netloc: - return Markup("""\ - {0.title}
{2}
{1}{0.description} - """.format( - model, ''.join(tag_text), Markup.escape(model.url) - )) - res = '' - if not current_app.config.get('BUKUSERVER_DISABLE_FAVICON', False): - netloc_tmpl = ' ' - res = netloc_tmpl.format( - 'http://www.google.com/s2/favicons?domain=', netloc) - title = model.title if model.title else '<EMPTY TITLE>' - open_in_new_tab = current_app.config.get('BUKUSERVER_OPEN_IN_NEW_TAB', False) - if is_scheme_valid and open_in_new_tab: - res += '{1}'.format(model, title) - elif is_scheme_valid and not open_in_new_tab: - res += '{1}'.format(model, title) - else: - res += title - if self.url_render_mode == 'netloc': - res += ' ({0})'.format( - netloc, - url_for('bookmark.index_view', flt2_url_netloc_match=netloc) + br_tag = "
" + get_index_view_url = functools.partial(url_for, "bookmark.index_view") + for tag in filter(None, model.tags.split(",")): + tag_text.append( + f'{tag}' + ) + tag_text_markup = "".join(tag_text) + if not netloc and not parsed_url.scheme: + escaped_url = Markup.escape(model.url) + return Markup( + f"""{model.title}{br_tag}{escaped_url}{br_tag}{tag_text_markup}{model.description}""" + ) + res = [] + if not current_app.config.get("BUKUSERVER_DISABLE_FAVICON", False) and netloc: + res.append( + f'' ) - res += '
' - if not is_scheme_valid: - res += model.url - elif self.url_render_mode is None or self.url_render_mode == 'full': - res += '{0.url}'.format(model) - res += '
' - if self.url_render_mode != 'netloc': - res += tag_tmpl.format( - 'netloc:{}'.format(netloc), - url_for('bookmark.index_view', flt2_url_netloc_match=netloc) + title = model.title if model.title else "<EMPTY TITLE>" + open_in_new_tab = current_app.config.get("BUKUSERVER_OPEN_IN_NEW_TAB", False) + url_for_index_view_netloc = None + if netloc: + url_for_index_view_netloc = get_index_view_url(flt2_url_netloc_match=netloc) + if parsed_url.scheme and not open_in_new_tab: + target = 'target="_blank"' if open_in_new_tab else "" + res.append(f'{title}') + else: + res.append(title) + if self.url_render_mode == "netloc" and url_for_index_view_netloc: + res.append(f'({netloc})') + res.append(br_tag) + if not parsed_url.scheme: + res.extend((model.url, br_tag)) + elif self.url_render_mode is None or self.url_render_mode == "full": + res.extend((f'{model.url}', br_tag)) + if self.url_render_mode != "netloc" and url_for_index_view_netloc: + res.append( + f'netloc:{netloc}' ) - res += ''.join(tag_text) + if tag_text_markup: + res.append("".join(tag_text)) description = model.description if description: - res += '
' - res += description.replace('\n', '
') - return Markup(res) + res.extend((br_tag, description.replace("\n", br_tag))) + return Markup("".join(res)) can_set_page_size = True can_view_details = True - column_filters = ['buku', 'id', 'url', 'title', 'tags'] - column_formatters = {'Entry': _list_entry,} - column_list = ['Entry'] + column_filters = ["buku", "id", "url", "title", "tags"] + column_formatters = { + "Entry": _list_entry, + } + column_list = ["Entry"] create_modal = True - create_modal_template = 'bukuserver/bookmark_create_modal.html' - create_template = 'bukuserver/bookmark_create.html' + create_modal_template = "bukuserver/bookmark_create_modal.html" + create_template = "bukuserver/bookmark_create.html" details_modal = True edit_modal = True - edit_modal_template = 'bukuserver/bookmark_edit_modal.html' - edit_template = 'bukuserver/bookmark_edit.html' + edit_modal_template = "bukuserver/bookmark_edit_modal.html" + edit_template = "bukuserver/bookmark_edit.html" named_filter_urls = True def __init__(self, *args, **kwargs): - self.bukudb = args[0] - custom_model = CustomBukuDbModel(args[0], 'bookmark') - args = [custom_model, ] + list(args[1:]) - self.page_size = kwargs.pop('page_size', DEFAULT_PER_PAGE) - self.url_render_mode = kwargs.pop('url_render_mode', DEFAULT_URL_RENDER_MODE) + self.bukudb: buku.BukuDb = args[0] + custom_model = CustomBukuDbModel(args[0], "bookmark") + args = [ + custom_model, + ] + list(args[1:]) + self.page_size = kwargs.pop("page_size", DEFAULT_PER_PAGE) + self.url_render_mode = kwargs.pop("url_render_mode", DEFAULT_URL_RENDER_MODE) super().__init__(*args, **kwargs) def create_form(self, obj=None): form = super().create_form(obj) args = request.args - if 'url' in args.keys() and not args.get("url").startswith('/bookmark/'): - form.url.data = args.get("url") - if 'title' in args.keys(): + args_url = args.get("url") + if args_url and not args_url.startswith("/bookmark/"): + form.url.data = args_url + if "title" in args.keys(): form.title.data = args.get("title") - if 'description' in args.keys(): + if "description" in args.keys(): form.description.data = args.get("description") return form def create_model(self, form): try: - model = SimpleNamespace(id=None, url=None, title=None, tags=None, description=None) + model = SimpleNamespace( + id=None, url=None, title=None, tags=None, description=None + ) form.populate_obj(model) - vars(model).pop('id') + vars(model).pop("id") self._on_model_change(form, model, True) - tags_in = model.tags - if not tags_in.startswith(','): - tags_in = ',{}'.format(tags_in) - if not tags_in.endswith(','): - tags_in = '{},'.format(tags_in) - self.model.bukudb.add_rec( - url=model.url, title_in=model.title, tags_in=tags_in, desc=model.description) + if not model.url.strip(): + raise ValueError(f"url invalid: {model.url}") + kwargs = {"url": model.url} + if model.tags.strip(): + kwargs["tags_in"] = buku.parse_tags([model.tags]) + for key, item in (("title_in", model.title), ("desc", model.description)): + if item.strip(): + kwargs[key] = item + self.model.bukudb.add_rec(**kwargs) except Exception as ex: if not self.handle_view_exception(ex): - flash(gettext('Failed to create record. %(error)s', error=str(ex)), 'error') - LOG.exception('Failed to create record.') + msg = "Failed to create record." + flash( + gettext("%(msg)s %(error)s", msg=msg, error=str(ex)), + "error", + ) + LOG.exception(msg) return False else: self.after_model_change(form, model, True) @@ -191,32 +213,43 @@ def delete_model(self, model): res = self.bukudb.delete_rec(model.id) except Exception as ex: if not self.handle_view_exception(ex): - flash(gettext('Failed to delete record. %(error)s', error=str(ex)), 'error') - LOG.exception('Failed to delete record.') + msg = "Failed to delete record." + flash( + gettext("%(msg)s %(error)s", msg=msg, error=str(ex)), + "error", + ) + LOG.exception(msg) return False else: self.after_model_delete(model) return res - def get_list(self, page, sort_field, sort_desc, search, filters, page_size=None): + def get_list(self, page, sort_field, sort_desc, _, filters, page_size=None): bukudb = self.bukudb - contain_buku_search = any(x[1] == 'buku' for x in filters) + contain_buku_search = any(x[1] == "buku" for x in filters) if contain_buku_search: mode_id = [x[0] for x in filters] if len(list(set(mode_id))) > 1: - flash(gettext('Invalid search mode combination'), 'error') + flash(gettext("Invalid search mode combination"), "error") return 0, [] keywords = [x[2] for x in filters] + flt = None for idx, flt_name, value in filters: - if flt_name == 'buku': + if flt_name == "buku" and self._filters: flt = self._filters[idx] - bookmarks = bukudb.searchdb( - keywords, all_keywords=flt.all_keywords, deep=flt.deep, regex=flt.regex) + kwargs = ( + dict(all_keywords=flt.all_keywords, deep=flt.deep, regex=flt.regex) + if flt + else {} + ) + bookmarks = bukudb.searchdb(keywords, **kwargs) else: bookmarks = bukudb.get_rec_all() bookmarks = self._apply_filters(bookmarks, filters) if sort_field: - key_idx = [x.value for x in BookmarkField if x.name.lower() == sort_field][0] + key_idx = [x.value for x in BookmarkField if x.name.lower() == sort_field][ + 0 + ] bookmarks = sorted(bookmarks, key=lambda x: x[key_idx], reverse=sort_desc) count = len(bookmarks) if page_size and bookmarks: @@ -226,13 +259,15 @@ def get_list(self, page, sort_field, sort_desc, search, filters, page_size=None) bookmarks = [] data = [] for bookmark in bookmarks: - bm_sns = SimpleNamespace(id=None, url=None, title=None, tags=None, description=None) + bm_sns = SimpleNamespace( + id=None, url=None, title=None, tags=None, description=None + ) for field in list(BookmarkField): if field == BookmarkField.TAGS: value = bookmark[field.value] - if value.startswith(','): + if value.startswith(","): value = value[1:] - if value.endswith(','): + if value.endswith(","): value = value[:-1] setattr(bm_sns, field.name.lower(), value) else: @@ -242,13 +277,15 @@ def get_list(self, page, sort_field, sort_desc, search, filters, page_size=None) def get_one(self, id): bookmark = self.model.bukudb.get_rec_by_id(id) - bm_sns = SimpleNamespace(id=None, url=None, title=None, tags=None, description=None) + bm_sns = SimpleNamespace( + id=None, url=None, title=None, tags=None, description=None + ) for field in list(BookmarkField): - if field == BookmarkField.TAGS and bookmark[field.value].startswith(','): + if field == BookmarkField.TAGS and bookmark[field.value].startswith(","): value = bookmark[field.value] - if value.startswith(','): + if value.startswith(","): value = value[1:] - if value.endswith(','): + if value.endswith(","): value = value[:-1] setattr(bm_sns, field.name.lower(), value) else: @@ -265,66 +302,102 @@ def scaffold_list_form(self, widget=None, validators=None): pass def scaffold_sortable_columns(self): - return {x:x for x in self.scaffold_list_columns()} + return {x: x for x in self.scaffold_list_columns()} def scaffold_filters(self, name): res = [] - if name == 'buku': + if name == "buku": values_combi = sorted(itertools.product([True, False], repeat=3)) for all_keywords, deep, regex in values_combi: res.append( - bs_filters.BookmarkBukuFilter(all_keywords=all_keywords, deep=deep, regex=regex) + bs_filters.BookmarkBukuFilter( + all_keywords=all_keywords, deep=deep, regex=regex + ) ) elif name == BookmarkField.ID.name.lower(): - res.extend([ - bs_filters.BookmarkBaseFilter(name, filter_type=FilterType.EQUAL), - bs_filters.BookmarkBaseFilter(name, filter_type=FilterType.NOT_EQUAL), - bs_filters.BookmarkBaseFilter(name, filter_type=FilterType.IN_LIST), - bs_filters.BookmarkBaseFilter(name, filter_type=FilterType.NOT_IN_LIST), - bs_filters.BookmarkBaseFilter(name, filter_type=FilterType.GREATER), - bs_filters.BookmarkBaseFilter(name, filter_type=FilterType.SMALLER), - bs_filters.BookmarkBaseFilter(name, filter_type=FilterType.TOP_X), - bs_filters.BookmarkBaseFilter(name, filter_type=FilterType.BOTTOM_X), - ]) + res.extend( + [ + bs_filters.BookmarkBaseFilter(name, filter_type=FilterType.EQUAL), + bs_filters.BookmarkBaseFilter( + name, filter_type=FilterType.NOT_EQUAL + ), + bs_filters.BookmarkBaseFilter(name, filter_type=FilterType.IN_LIST), + bs_filters.BookmarkBaseFilter( + name, filter_type=FilterType.NOT_IN_LIST + ), + bs_filters.BookmarkBaseFilter(name, filter_type=FilterType.GREATER), + bs_filters.BookmarkBaseFilter(name, filter_type=FilterType.SMALLER), + bs_filters.BookmarkBaseFilter(name, filter_type=FilterType.TOP_X), + bs_filters.BookmarkBaseFilter( + name, filter_type=FilterType.BOTTOM_X + ), + ] + ) elif name == BookmarkField.URL.name.lower(): + def netloc_match_func(query, value, index): return filter(lambda x: urlparse(x[index]).netloc == value, query) - res.extend([ - bs_filters.BookmarkBaseFilter(name, 'netloc match', netloc_match_func), - bs_filters.BookmarkBaseFilter(name, filter_type=FilterType.EQUAL), - bs_filters.BookmarkBaseFilter(name, filter_type=FilterType.NOT_EQUAL), - bs_filters.BookmarkBaseFilter(name, filter_type=FilterType.IN_LIST), - bs_filters.BookmarkBaseFilter(name, filter_type=FilterType.NOT_IN_LIST), - ]) + res.extend( + [ + bs_filters.BookmarkBaseFilter( + name, "netloc match", netloc_match_func + ), + bs_filters.BookmarkBaseFilter(name, filter_type=FilterType.EQUAL), + bs_filters.BookmarkBaseFilter( + name, filter_type=FilterType.NOT_EQUAL + ), + bs_filters.BookmarkBaseFilter(name, filter_type=FilterType.IN_LIST), + bs_filters.BookmarkBaseFilter( + name, filter_type=FilterType.NOT_IN_LIST + ), + ] + ) elif name == BookmarkField.TITLE.name.lower(): - res.extend([ - bs_filters.BookmarkBaseFilter(name, filter_type=FilterType.EQUAL), - bs_filters.BookmarkBaseFilter(name, filter_type=FilterType.NOT_EQUAL), - bs_filters.BookmarkBaseFilter(name, filter_type=FilterType.IN_LIST), - bs_filters.BookmarkBaseFilter(name, filter_type=FilterType.NOT_IN_LIST), - ]) + res.extend( + [ + bs_filters.BookmarkBaseFilter(name, filter_type=FilterType.EQUAL), + bs_filters.BookmarkBaseFilter( + name, filter_type=FilterType.NOT_EQUAL + ), + bs_filters.BookmarkBaseFilter(name, filter_type=FilterType.IN_LIST), + bs_filters.BookmarkBaseFilter( + name, filter_type=FilterType.NOT_IN_LIST + ), + ] + ) elif name == BookmarkField.TAGS.name.lower(): + def tags_contain_func(query, value, index): for item in query: - for tag in item[index].split(','): + for tag in item[index].split(","): if tag and tag == value: yield item def tags_not_contain_func(query, value, index): for item in query: - for tag in item[index].split(','): + for tag in item[index].split(","): if tag and tag != value: yield item - res.extend([ - bs_filters.BookmarkBaseFilter(name, 'contain', tags_contain_func), - bs_filters.BookmarkBaseFilter(name, 'not contain', tags_not_contain_func), - bs_filters.BookmarkTagNumberEqualFilter(name, 'number equal'), - bs_filters.BookmarkTagNumberNotEqualFilter(name, 'number not equal'), - bs_filters.BookmarkTagNumberGreaterFilter(name, 'number greater than'), - bs_filters.BookmarkTagNumberSmallerFilter(name, 'number smaller than'), - ]) + res.extend( + [ + bs_filters.BookmarkBaseFilter(name, "contain", tags_contain_func), + bs_filters.BookmarkBaseFilter( + name, "not contain", tags_not_contain_func + ), + bs_filters.BookmarkTagNumberEqualFilter(name, "number equal"), + bs_filters.BookmarkTagNumberNotEqualFilter( + name, "number not equal" + ), + bs_filters.BookmarkTagNumberGreaterFilter( + name, "number greater than" + ), + bs_filters.BookmarkTagNumberSmallerFilter( + name, "number smaller than" + ), + ] + ) elif name in self.scaffold_list_columns(): pass else: @@ -335,25 +408,26 @@ def scaffold_form(self): cls = forms.BookmarkForm return cls - def update_model(self, form, model): + def update_model(self, form: forms.BookmarkForm, model: Namespace): res = False try: - original_tags = model.tags form.populate_obj(model) self._on_model_change(form, model, False) - self.bukudb.delete_tag_at_index(model.id, original_tags) - tags_in = model.tags - if not tags_in.startswith(','): - tags_in = ',{}'.format(tags_in) - if not tags_in.endswith(','): - tags_in = '{},'.format(tags_in) res = self.bukudb.update_rec( - model.id, url=model.url, title_in=model.title, tags_in=tags_in, - desc=model.description) + model.id, + url=model.url, + title_in=model.title, + tags_in=buku.parse_tags([model.tags]), + desc=model.description, + ) except Exception as ex: if not self.handle_view_exception(ex): - flash(gettext('Failed to update record. %(error)s', error=str(ex)), 'error') - LOG.exception('Failed to update record.') + msg = "Failed to update record." + flash( + gettext("%(msg)s %(error)s", msg=msg, error=str(ex)), + "error", + ) + LOG.exception(msg) return False else: self.after_model_change(form, model, False) @@ -361,45 +435,53 @@ def update_model(self, form, model): class TagModelView(BaseModelView): - def _create_ajax_loader(self, name, options): pass def _apply_filters(self, models, filters): - for idx, flt_name, value in filters: - flt = self._filters[idx] - clean_value = flt.clean(value) - models = list(flt.apply(models, clean_value)) + for idx, _, value in filters: + if self._filters: + flt = self._filters[idx] + clean_value = flt.clean(value) + models = list(flt.apply(models, clean_value)) return models - def _name_formatter(self, context, model, name): + def _name_formatter(self, _, model, name): data = getattr(model, name) if not data: - return Markup('{}'.format( - url_for('bookmark.index_view', flt2_tags_number_equal=0), - '<EMPTY TAG>' - )) - return Markup('{}'.format( - url_for('bookmark.index_view', flt1_tags_contain=data), data - )) + return Markup( + '{}'.format( + url_for("bookmark.index_view", flt2_tags_number_equal=0), + "<EMPTY TAG>", + ) + ) + return Markup( + '{}'.format( + url_for("bookmark.index_view", flt1_tags_contain=data), data + ) + ) can_create = False can_set_page_size = True - column_filters = ['name', 'usage_count'] - column_formatters = {'name': _name_formatter,} + column_filters = ["name", "usage_count"] + column_formatters = { + "name": _name_formatter, + } def __init__(self, *args, **kwargs): self.bukudb = args[0] - custom_model = CustomBukuDbModel(args[0], 'tag') - args = [custom_model, ] + list(args[1:]) - self.page_size = kwargs.pop('page_size', DEFAULT_PER_PAGE) + custom_model = CustomBukuDbModel(args[0], "tag") + args = [ + custom_model, + ] + list(args[1:]) + self.page_size = kwargs.pop("page_size", DEFAULT_PER_PAGE) super().__init__(*args, **kwargs) def scaffold_list_columns(self): - return ['name', 'usage_count'] + return ["name", "usage_count"] def scaffold_sortable_columns(self): - return {x:x for x in self.scaffold_list_columns()} + return {x: x for x in self.scaffold_list_columns()} def scaffold_form(self): class CustomForm(FlaskForm): # pylint: disable=too-few-public-methods @@ -411,21 +493,28 @@ def scaffold_list_form(self, widget=None, validators=None): pass def get_list( - self, - page: int, - sort_field: str, - sort_desc: bool, - search: Optional[Any], - filters: List[Tuple[int, str, str]], - page_size: int = None) -> Tuple[int, List[SimpleNamespace]]: + self, + page: int, + sort_field: str, + sort_desc: bool, + search: Optional[Any], + filters: List[Tuple[int, str, str]], + page_size: int = None, + ) -> Tuple[int, List[SimpleNamespace]]: + logging.debug("search: %s", search) bukudb = self.bukudb tags = bukudb.get_tag_all()[1] tags = sorted(tags.items()) tags = self._apply_filters(tags, filters) - sort_field_dict = {'usage_count': 1, 'name': 0} + sort_field_dict = {"usage_count": 1, "name": 0} if sort_field in sort_field_dict: - tags = list(sorted( - tags, key=lambda x: x[sort_field_dict[sort_field]], reverse=sort_desc)) + tags = list( + sorted( + tags, + key=lambda x: x[sort_field_dict[sort_field]], + reverse=sort_desc, + ) + ) count = len(tags) if page_size and tags: tags = list(chunks(tags, page_size))[page] @@ -453,21 +542,27 @@ def top_most_common_func(query, value, index): most_common_item = [x[0] for x in most_common] return filter(lambda x: x[index] in most_common_item, query) - res.extend([ - bs_filters.TagBaseFilter(name, filter_type=FilterType.EQUAL), - bs_filters.TagBaseFilter(name, filter_type=FilterType.NOT_EQUAL), - bs_filters.TagBaseFilter(name, filter_type=FilterType.IN_LIST), - bs_filters.TagBaseFilter(name, filter_type=FilterType.NOT_IN_LIST), - ]) - if name == 'usage_count': - res.extend([ - bs_filters.TagBaseFilter(name, filter_type=FilterType.GREATER), - bs_filters.TagBaseFilter(name, filter_type=FilterType.SMALLER), - bs_filters.TagBaseFilter(name, filter_type=FilterType.TOP_X), - bs_filters.TagBaseFilter(name, filter_type=FilterType.BOTTOM_X), - bs_filters.TagBaseFilter(name, 'top most common', top_most_common_func), - ]) - elif name == 'name': + res.extend( + [ + bs_filters.TagBaseFilter(name, filter_type=FilterType.EQUAL), + bs_filters.TagBaseFilter(name, filter_type=FilterType.NOT_EQUAL), + bs_filters.TagBaseFilter(name, filter_type=FilterType.IN_LIST), + bs_filters.TagBaseFilter(name, filter_type=FilterType.NOT_IN_LIST), + ] + ) + if name == "usage_count": + res.extend( + [ + bs_filters.TagBaseFilter(name, filter_type=FilterType.GREATER), + bs_filters.TagBaseFilter(name, filter_type=FilterType.SMALLER), + bs_filters.TagBaseFilter(name, filter_type=FilterType.TOP_X), + bs_filters.TagBaseFilter(name, filter_type=FilterType.BOTTOM_X), + bs_filters.TagBaseFilter( + name, "top most common", top_most_common_func + ), + ] + ) + elif name == "name": pass else: return super().scaffold_filters(name) @@ -480,8 +575,12 @@ def delete_model(self, model): res = self.bukudb.delete_tag_at_index(0, model.name, chatty=False) except Exception as ex: if not self.handle_view_exception(ex): - flash(gettext('Failed to delete record. %(error)s', error=str(ex)), 'error') - LOG.exception('Failed to delete record.') + msg = "Failed to delete record." + flash( + gettext("%(msg)s %(error)s", msg=msg, error=str(ex)), + "error", + ) + LOG.exception(msg) return False else: self.after_model_delete(model) @@ -496,8 +595,12 @@ def update_model(self, form, model): res = self.bukudb.replace_tag(original_name, [model.name]) except Exception as ex: if not self.handle_view_exception(ex): - flash(gettext('Failed to update record. %(error)s', error=str(ex)), 'error') - LOG.exception('Failed to update record.') + msg = "Failed to update record." + flash( + gettext("%(msg)s %(error)s", msg=msg, error=str(ex)), + "error", + ) + LOG.exception(msg) return False else: self.after_model_change(form, model, False) @@ -508,46 +611,55 @@ def create_model(self, form): class StatisticView(BaseView): # pylint: disable=too-few-public-methods - def __init__(self, *args, **kwargs): self.bukudb = args[0] args = list(args[1:]) super().__init__(*args, **kwargs) - @expose('/', methods=('GET', 'POST')) + @expose("/", methods=("GET", "POST")) def index(self): bukudb = self.bukudb global STATISTIC_DATA statistic_data = STATISTIC_DATA - if not statistic_data or request.method == 'POST': + if not statistic_data or request.method == "POST": all_bookmarks = bukudb.get_rec_all() netloc = [urlparse(x[1]).netloc for x in all_bookmarks] tag_set = [x[3] for x in all_bookmarks] tag_items = [] for tags in tag_set: - tag_items.extend([x.strip() for x in tags.split(',') if x.strip()]) + tag_items.extend([x.strip() for x in tags.split(",") if x.strip()]) tag_counter = Counter(tag_items) title_items = [x[2] for x in all_bookmarks] title_counter = Counter(title_items) statistic_datetime = arrow.now() STATISTIC_DATA = { - 'datetime': statistic_datetime, - 'netloc': netloc, - 'tag_counter': tag_counter, - 'title_counter': title_counter, + "datetime": statistic_datetime, + "netloc": netloc, + "tag_counter": tag_counter, + "title_counter": title_counter, } else: - netloc = statistic_data['netloc'] - statistic_datetime = statistic_data['datetime'] - tag_counter = statistic_data['tag_counter'] - title_counter = statistic_data['title_counter'] + netloc = statistic_data["netloc"] + statistic_datetime = statistic_data["datetime"] + tag_counter = statistic_data["tag_counter"] + title_counter = statistic_data["title_counter"] netloc_counter = Counter(netloc) unique_netloc_len = len(set(netloc)) colors = [ - "#F7464A", "#46BFBD", "#FDB45C", "#FEDCBA", - "#ABCDEF", "#DDDDDD", "#ABCABC", "#4169E1", - "#C71585", "#FF4500", "#FEDCBA", "#46BFBD"] + "#F7464A", + "#46BFBD", + "#FDB45C", + "#FEDCBA", + "#ABCDEF", + "#DDDDDD", + "#ABCABC", + "#4169E1", + "#C71585", + "#FF4500", + "#FEDCBA", + "#46BFBD", + ] show_netloc_table = False if unique_netloc_len > len(colors): max_netloc_item = len(colors) @@ -558,7 +670,9 @@ def index(self): max_netloc_item = unique_netloc_len most_common_netlocs = netloc_counter.most_common(max_netloc_item) most_common_netlocs = [ - [val[0], val[1], netloc_colors[idx]] for idx, val in enumerate(most_common_netlocs)] + [val[0], val[1], netloc_colors[idx]] + for idx, val in enumerate(most_common_netlocs) + ] unique_tag_len = len(tag_counter) show_tag_rank_table = False @@ -571,7 +685,9 @@ def index(self): max_tag_item = unique_tag_len most_common_tags = tag_counter.most_common(max_tag_item) most_common_tags = [ - [val[0], val[1], tag_colors[idx]] for idx, val in enumerate(most_common_tags)] + [val[0], val[1], tag_colors[idx]] + for idx, val in enumerate(most_common_tags) + ] unique_title_len = len(title_counter) show_title_rank_table = False @@ -584,10 +700,12 @@ def index(self): max_title_item = unique_title_len most_common_titles = title_counter.most_common(max_title_item) most_common_titles = [ - [val[0], val[1], title_colors[idx]] for idx, val in enumerate(most_common_titles)] + [val[0], val[1], title_colors[idx]] + for idx, val in enumerate(most_common_titles) + ] return self.render( - 'bukuserver/statistic.html', + "bukuserver/statistic.html", most_common_netlocs=most_common_netlocs, netloc_counter=netloc_counter, show_netloc_table=show_netloc_table, @@ -598,10 +716,12 @@ def index(self): title_counter=title_counter, show_title_rank_table=show_title_rank_table, datetime=statistic_datetime, - datetime_text=statistic_datetime.humanize(arrow.now(), granularity='second'), + datetime_text=statistic_datetime.humanize( + arrow.now(), granularity="second" + ), ) def chunks(arr, n): n = max(1, n) - return (arr[i:i+n] for i in range(0, len(arr), n)) + return (arr[i : i + n] for i in range(0, len(arr), n)) diff --git a/setup.py b/setup.py index 0a1baa70..274cc1b7 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ import re import shutil -from setuptools import setup, find_packages +from setuptools import find_packages, setup if os.path.isfile('buku'): shutil.copyfile('buku', 'buku.py') @@ -34,29 +34,29 @@ server_require = [ - 'appdirs>=1.4.3', - 'arrow>=0.12.1', - 'beautifulsoup4>=4.5.3', - 'cffi>=1.9.1', - 'click>=6.7', - 'Flask-Admin>=1.5.1', - 'Flask-API>=0.6.9', - 'Flask-Bootstrap>=3.3.7.1', - 'flask-paginate>=0.5.1', - 'flask-reverse-proxy-fix>=0.2.1', - 'Flask-WTF>=0.14.2', - 'Flask>=1.0.2,<2.0', - 'idna>=2.5', - 'itsdangerous>=0.24', - 'Jinja2>=2.10.1', - 'MarkupSafe>=1.0', - 'packaging>=16.8', - 'pyasn1>=0.2.3', - 'pycparser>=2.17', - 'requests>=2.21.0', - 'six>=1.10.0', - 'urllib3>=1.25.2', - 'Werkzeug>=0.11.15', + "appdirs>=1.4.3", + "arrow>=0.12.1", + "beautifulsoup4>=4.5.3", + "cffi>=1.9.1", + "click>=6.7", + "Flask-Admin>=1.5.1", + "Flask-API>=0.6.9", + "Flask-Bootstrap>=3.3.7.1", + "flask-paginate>=0.5.1", + "flask-reverse-proxy-fix @ https://github.com/rachmadaniHaryono/flask-reverse-proxy-fix/archive/refs/tags/v0.2.2rc1.zip", + "Flask-WTF>=0.14.2", + "Flask>=1.0.2,<2.0", + "idna>=2.5", + "itsdangerous>=0.24", + "Jinja2>=2.10.1", + "MarkupSafe>=1.0", + "packaging>=16.8", + "pyasn1>=0.2.3", + "pycparser>=2.17", + "requests>=2.21.0", + "six>=1.10.0", + "urllib3>=1.25.2", + "Werkzeug>=0.11.15", ] setup( diff --git a/tests/test_buku.py b/tests/test_buku.py index aaa4644f..e6c4bb48 100644 --- a/tests/test_buku.py +++ b/tests/test_buku.py @@ -1,17 +1,17 @@ """test module.""" -from itertools import product -from unittest import mock -from urllib.parse import urlparse import json import logging import os import signal import sys import unittest +from itertools import product +from unittest import mock +from urllib.parse import urlparse import pytest -from buku import is_int, parse_tags, prep_tag_search +from buku import DELIM, is_int, prep_tag_search only_python_3_5 = pytest.mark.skipif( sys.version_info < (3, 5), reason="requires Python 3.5 or later") @@ -32,6 +32,7 @@ def test_is_bad_url(url, exp_res): """test func.""" import buku + res = buku.is_bad_url(url) assert res == exp_res @@ -47,12 +48,14 @@ def test_is_bad_url(url, exp_res): def test_is_ignored_mime(url, exp_res): """test func.""" import buku + assert exp_res == buku.is_ignored_mime(url) def test_gen_headers(): """test func.""" import buku + exp_myheaders = { 'Accept-Encoding': 'gzip,deflate', 'User-Agent': buku.USER_AGENT, @@ -70,28 +73,55 @@ def test_get_PoolManager(m_myproxy): """test func.""" with mock.patch('buku.urllib3'): import buku + buku.myproxy = m_myproxy assert buku.get_PoolManager() @pytest.mark.parametrize( - 'keywords, exp_res', + "keywords, exp_res", [ - (None, None), - ([], None), - (['tag1', 'tag2'], ',tag1 tag2,'), - (['tag1,tag2', 'tag3'], ',tag1,tag2 tag3,'), - ] + ("", DELIM), + (",", DELIM), + ("tag1, tag2", ",t a g 1,t a g 2,"), + ([" a tag , , , ,\t,\n,\r,\x0b,\x0c"], ",a tag,"), # whitespaces + ([",,,,,"], ","), # empty tags + (["\"tag\",'tag',tag"], ",\"tag\",'tag',tag,"), # escaping quotes + (["tag,tag, tag, tag,tag , tag "], ",tag,"), # duplicates, excessive spaces + (["tag1", "tag2", "tag3"], ",tag1 tag2 tag3,"), + (["tag1", "tag2"], ",tag1 tag2,"), + (["tag1"], ",tag1,"), + (["tag1,tag2", "tag3"], ",tag1,tag2 tag3,"), + (["tag1,tag2", "tag3,tag4"], ",tag1,tag2 tag3,tag4,"), + (["tag1,tag2"], ",tag1,tag2,"), + (["z_tag,a_tag,n_tag"], ",a_tag,n_tag,z_tag,"), # sorting tags + ([' '], ","), + ([''], ","), + ([','], ","), + ([], ","), # call with empty list + ([None], ","), + (None, None), # call with None + # combo + ( + [',,z_tag, a tag ,\t,,, ,n_tag ,n_tag, a_tag, \na tag ,\r, "a_tag"'], + ',"a_tag",a tag,a_tag,n_tag,z_tag,', + ), + ], ) def test_parse_tags(keywords, exp_res): """test func.""" import buku + if keywords is None: - pass - elif not keywords: - exp_res = buku.DELIM - res = buku.parse_tags(keywords) - assert res == exp_res + assert buku.parse_tags(keywords) is None + else: + assert buku.parse_tags(keywords) == exp_res + + +def test_parse_tags_no_args(): + import buku + + assert buku.parse_tags() == DELIM @pytest.mark.parametrize( @@ -151,6 +181,7 @@ def test_print_rec_with_filter(records, field_filter, exp_res): """test func.""" with mock.patch('buku.print', create=True) as m_print: import buku + buku.print_rec_with_filter(records, field_filter) for res in exp_res: m_print.assert_any_call(res) @@ -471,32 +502,6 @@ def test_piped_input(argv, pipeargs, isatty): class TestHelpers(unittest.TestCase): # @unittest.skip('skipping') - def test_parse_tags(self): - # call with None - parsed = parse_tags(None) - self.assertIsNone(parsed) - # call with empty list - parsed = parse_tags([]) - self.assertEqual(parsed, ",") - # empty tags - parsed = parse_tags([",,,,,"]) - self.assertEqual(parsed, ",") - # sorting tags - parsed = parse_tags(["z_tag,a_tag,n_tag"]) - self.assertEqual(parsed, ",a_tag,n_tag,z_tag,") - # whitespaces - parsed = parse_tags([" a tag , , , ,\t,\n,\r,\x0b,\x0c"]) - self.assertEqual(parsed, ",a tag,") - # duplicates, excessive spaces - parsed = parse_tags(["tag,tag, tag, tag,tag , tag "]) - self.assertEqual(parsed, ",tag,") - # escaping quotes - parsed = parse_tags(["\"tag\",\'tag\',tag"]) - self.assertEqual(parsed, ",\"tag\",\'tag\',tag,") - # combo - parsed = parse_tags([",,z_tag, a tag ,\t,,, ,n_tag ,n_tag, a_tag, \na tag ,\r, \"a_tag\""]) - self.assertEqual(parsed, ",\"a_tag\",a tag,a_tag,n_tag,z_tag,") - # @unittest.skip('skipping') def test_is_int(self): self.assertTrue(is_int('0')) @@ -559,8 +564,9 @@ def test_sigint_handler(capsys): ) def test_network_handler_with_url(url, exp_res): """test func.""" - import buku import urllib3 + + import buku buku.urllib3 = urllib3 buku.myproxy = None res = buku.network_handler(url) @@ -668,8 +674,9 @@ def test_import_org(tmpdir, newtag, exp_res): ) def test_import_html(html_text, exp_res): """test method.""" - from buku import import_html from bs4 import BeautifulSoup + + from buku import import_html html_soup = BeautifulSoup(html_text, 'html.parser') res = list(import_html(html_soup, False, None)) for item, exp_item in zip(res, exp_res): @@ -677,8 +684,9 @@ def test_import_html(html_text, exp_res): def test_import_html_and_add_parent(): - from buku import import_html from bs4 import BeautifulSoup + + from buku import import_html html_text = """

1s

""" @@ -689,8 +697,9 @@ def test_import_html_and_add_parent(): def test_import_html_and_new_tag(): - from buku import import_html from bs4 import BeautifulSoup + + from buku import import_html html_text = """
GitHub
comment for the bookmark here""" exp_res = ( @@ -724,8 +733,9 @@ def test_copy_to_clipboard(platform, params): mock.patch('buku.Popen', return_value=m_popen_retval) as m_popen, \ mock.patch('buku.shutil.which', return_value=True): m_sys.platform = platform - from buku import copy_to_clipboard import subprocess + + from buku import copy_to_clipboard copy_to_clipboard(content) if platform_recognized: m_popen.assert_called_once_with( @@ -753,8 +763,8 @@ def test_copy_to_clipboard(platform, params): ['random', None], ]) def test_convert_bookmark_set(export_type, exp_res, monkeypatch): - from buku import convert_bookmark_set import buku + from buku import convert_bookmark_set bms = [ (1, 'htttp://example.com', '', ',', '', 0), (1, 'htttp://example.org', None, ',', '', 0), diff --git a/tests/test_views.py b/tests/test_views.py index 62b6aaeb..a42ff5bd 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -1,3 +1,4 @@ +import logging from argparse import Namespace from types import SimpleNamespace @@ -21,6 +22,7 @@ def client(tmp_path): @pytest.mark.parametrize('disable_favicon', [False, True]) def test_bookmark_model_view(tmp_path, client, disable_favicon): + logging.debug('client: %s', client) test_db = tmp_path / 'test.db' bukudb = BukuDb(dbfile=test_db.as_posix()) inst = BookmarkModelView(bukudb) @@ -28,21 +30,7 @@ def test_bookmark_model_view(tmp_path, client, disable_favicon): description='randomdesc', id=1, tags='tags1', title='Example Domain', url='http://example.com') current_app.config['BUKUSERVER_DISABLE_FAVICON'] = disable_favicon - img_html = '' - if not disable_favicon: - img_html = \ - ' ' - res = inst._list_entry(None, model, 'Entry') - exp_res = \ - ( - 'Example Domain
' - 'http://example.com
' - 'netloc:example.com' - 'tags1' - '
randomdesc') - exp_res = ''.join([img_html, exp_res]) - assert str(res) == exp_res + assert inst._list_entry(None, model, 'Entry') @pytest.fixture diff --git a/tox.ini b/tox.ini index d838e2a8..6708968e 100644 --- a/tox.ini +++ b/tox.ini @@ -24,6 +24,8 @@ ignore = W292, # W504 line break after binary operator W504, + # E203 whitespace before : + E203, [testenv] usedevelop = true