Merge branch 'develop' into runtime-type-checks-api

This commit is contained in:
gavin 2022-12-19 15:12:06 +05:30 committed by GitHub
commit 2c498910ba
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 265 additions and 178 deletions

View file

@ -83,4 +83,4 @@ jobs:
- uses: actions/checkout@v3
- run: |
pip install pip-audit
pip-audit ${GITHUB_WORKSPACE}
pip-audit ${GITHUB_WORKSPACE} --ignore-vuln GHSA-hcpj-qp55-gfph

View file

@ -26,11 +26,10 @@ repos:
- id: pyupgrade
args: ['--py310-plus']
- repo: https://github.com/adityahase/black
rev: 9cb0a69f4d0030cdf687eddf314468b39ed54119
- repo: https://github.com/frappe/black
rev: 951ccf4d5bb0d692b457a5ebc4215d755618eb68
hooks:
- id: black
additional_dependencies: ['click==8.0.4']
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v2.7.1

View file

@ -11,7 +11,6 @@ from frappe.commands import get_site, pass_context
from frappe.coverage import CodeCoverage
from frappe.exceptions import SiteNotSpecifiedError
from frappe.utils import cint, update_progress_bar
from frappe.utils.synchronization import filelock
find_executable = which # backwards compatibility
DATA_IMPORT_DEPRECATION = (
@ -55,6 +54,7 @@ def build(
):
"Compile JS and CSS source files"
from frappe.build import bundle, download_frappe_assets
from frappe.utils.synchronization import filelock
frappe.init("")

View file

@ -7,7 +7,7 @@ import frappe
import frappe.defaults
import frappe.permissions
import frappe.share
from frappe import _, msgprint, throw
from frappe import STANDARD_USERS, _, msgprint, throw
from frappe.core.doctype.user_type.user_type import user_linked_with_permission_on_doctype
from frappe.desk.doctype.notification_settings.notification_settings import (
create_notification_settings,
@ -33,8 +33,6 @@ from frappe.utils.password import update_password as _update_password
from frappe.utils.user import get_system_managers
from frappe.website.utils import is_signup_disabled
STANDARD_USERS = frappe.STANDARD_USERS
class User(Document):
__new_password = None
@ -126,7 +124,8 @@ class User(Document):
frappe.enqueue(
"frappe.core.doctype.user.user.create_contact", user=self, ignore_mandatory=True, now=now
)
if self.name not in ("Administrator", "Guest") and not self.user_image:
if self.name not in STANDARD_USERS and not self.user_image:
frappe.enqueue("frappe.core.doctype.user.user.update_gravatar", name=self.name, now=now)
# Set user selected timezone
@ -238,6 +237,9 @@ class User(Document):
)
def share_with_self(self):
if self.name in STANDARD_USERS:
return
frappe.share.add_docshare(
self.doctype, self.name, self.name, write=1, share=1, flags={"ignore_share_permission": True}
)

View file

@ -129,12 +129,11 @@ class MariaDBConnectionUtil:
conn_settings["local_infile"] = frappe.conf.local_infile
if frappe.conf.db_ssl_ca and frappe.conf.db_ssl_cert and frappe.conf.db_ssl_key:
ssl_params = {
conn_settings["ssl"] = {
"ca": frappe.conf.db_ssl_ca,
"cert": frappe.conf.db_ssl_cert,
"key": frappe.conf.db_ssl_key,
}
conn_settings |= ssl_params
return conn_settings

View file

@ -7,45 +7,18 @@ import re
import frappe
from frappe import _, is_whitelisted
from frappe.database.schema import SPECIAL_CHAR_PATTERN
from frappe.permissions import has_permission
from frappe.utils import cint, cstr, unique
def sanitize_searchfield(searchfield):
blacklisted_keywords = ["select", "delete", "drop", "update", "case", "and", "or", "like"]
if not searchfield:
return
def _raise_exception(searchfield):
if SPECIAL_CHAR_PATTERN.search(searchfield):
frappe.throw(_("Invalid Search Field {0}").format(searchfield), frappe.DataError)
if len(searchfield) == 1:
# do not allow special characters to pass as searchfields
regex = re.compile(r'^.*[=;*,\'"$\-+%#@()_].*')
if regex.match(searchfield):
_raise_exception(searchfield)
if len(searchfield) >= 3:
# to avoid 1=1
if "=" in searchfield:
_raise_exception(searchfield)
# in mysql -- is used for commenting the query
elif " --" in searchfield:
_raise_exception(searchfield)
# to avoid and, or and like
elif any(f" {keyword} " in searchfield.split() for keyword in blacklisted_keywords):
_raise_exception(searchfield)
# to avoid select, delete, drop, update and case
elif any(keyword in searchfield.split() for keyword in blacklisted_keywords):
_raise_exception(searchfield)
else:
regex = re.compile(r'^.*[=;*,\'"$\-+%#@()].*')
if any(regex.match(f) for f in searchfield.split()):
_raise_exception(searchfield)
# this is called by the Link Field
@frappe.whitelist()

View file

@ -106,7 +106,9 @@ class FrappeClient:
headers=self.headers,
)
def get_list(self, doctype, fields='["name"]', filters=None, limit_start=0, limit_page_length=0):
def get_list(
self, doctype, fields='["name"]', filters=None, limit_start=0, limit_page_length=None
):
"""Returns list of records of a particular type"""
if not isinstance(fields, str):
fields = json.dumps(fields)
@ -115,7 +117,7 @@ class FrappeClient:
}
if filters:
params["filters"] = json.dumps(filters)
if limit_page_length:
if limit_page_length is not None:
params["limit_start"] = limit_start
params["limit_page_length"] = limit_page_length
res = self.session.get(

View file

@ -21,7 +21,6 @@ from frappe.search.website_search import build_index_for_all_routes
from frappe.utils.connections import check_connection
from frappe.utils.dashboard import sync_dashboards
from frappe.utils.fixtures import sync_fixtures
from frappe.utils.synchronization import filelock
from frappe.website.utils import clear_website_cache
BENCH_START_MESSAGE = dedent(
@ -163,6 +162,8 @@ class SiteMigration:
"""Run Migrate operation on site specified. This method initializes
and destroys connections to the site database.
"""
from frappe.utils.synchronization import filelock
if site:
frappe.init(site=site)
frappe.connect()

View file

@ -89,8 +89,10 @@ class BaseDocument:
"meta",
"_meta",
"flags",
"parent_doc",
"_table_fields",
"_valid_columns",
"_doc_before_save",
"_table_fieldnames",
"_reserved_keywords",
"dont_update_if_missing",
@ -286,7 +288,7 @@ class BaseDocument:
return DOCTYPE_TABLE_FIELDS
# child tables don't have child tables
if self.doctype in DOCTYPES_FOR_DOCTYPE or getattr(self, "parentfield", None):
if self.doctype in DOCTYPES_FOR_DOCTYPE:
return ()
return self.meta.get_table_fields()

View file

@ -193,9 +193,10 @@ class Document(BaseDocument):
self.load_from_db()
def get_latest(self):
if not getattr(self, "latest", None):
self.latest = frappe.get_doc(self.doctype, self.name)
return self.latest
if not hasattr(self, "_doc_before_save"):
self.load_doc_before_save()
return self._doc_before_save
def check_permission(self, permtype="read", permlevel=None):
"""Raise `frappe.PermissionError` if not permitted"""

View file

@ -114,7 +114,7 @@ class Meta(Document):
# from cache
if isinstance(doctype, dict):
super().__init__(doctype)
self.init_field_map()
self.init_field_caches()
return
if isinstance(doctype, Document):
@ -137,12 +137,12 @@ class Meta(Document):
# don't process for special doctypes
# prevent's circular dependency
if self.name in self.special_doctypes:
self.init_field_map()
self.init_field_caches()
return
has_custom_fields = self.add_custom_fields()
self.apply_property_setters()
self.init_field_map()
self.init_field_caches()
if has_custom_fields:
self.sort_fields()
@ -214,12 +214,6 @@ class Meta(Document):
return self._set_only_once_fields
def get_table_fields(self):
if not hasattr(self, "_table_fields"):
if self.name != "DocType":
self._table_fields = self.get("fields", {"fieldtype": ["in", table_fields]})
else:
self._table_fields = DOCTYPE_TABLE_FIELDS
return self._table_fields
def get_global_search_fields(self):
@ -453,9 +447,16 @@ class Meta(Document):
self.set(fieldname, new_list)
def init_field_map(self):
def init_field_caches(self):
# field map
self._fields = {field.fieldname: field for field in self.fields}
# table fields
if self.name == "DocType":
self._table_fields = DOCTYPE_TABLE_FIELDS
else:
self._table_fields = self.get("fields", {"fieldtype": ["in", table_fields]})
def sort_fields(self):
"""Sort custom fields on the basis of insert_after"""

View file

@ -182,6 +182,7 @@ frappe.patches.v13_0.update_notification_channel_if_empty
frappe.patches.v13_0.set_first_day_of_the_week
frappe.patches.v13_0.encrypt_2fa_secrets
frappe.patches.v13_0.reset_corrupt_defaults
frappe.patches.v13_0.remove_share_for_std_users
execute:frappe.reload_doc('custom', 'doctype', 'custom_field')
frappe.patches.v14_0.update_workspace2 # 20.09.2021
frappe.patches.v14_0.save_ratings_in_fraction #23-12-2021

View file

@ -0,0 +1,7 @@
import frappe
import frappe.share
def execute():
for user in frappe.STANDARD_USERS:
frappe.share.remove("User", user, user)

View file

@ -9,7 +9,6 @@ from whoosh.fields import ID, TEXT, Schema
import frappe
from frappe.search.full_text_search import FullTextSearch
from frappe.utils import set_request, update_progress_bar
from frappe.utils.synchronization import filelock
from frappe.website.serve import get_response_content
INDEX_NAME = "web_routes"
@ -141,7 +140,9 @@ def remove_document_from_index(path):
return ws.remove_document_from_index(path)
@filelock("building_website_search")
def build_index_for_all_routes():
ws = WebsiteSearch(INDEX_NAME)
return ws.build()
from frappe.utils.synchronization import filelock
with filelock("building_website_search"):
ws = WebsiteSearch(INDEX_NAME)
return ws.build()

View file

@ -128,6 +128,14 @@ class TestDBUpdate(FrappeTestCase):
doctype.save()
self.check_unique_indexes(doctype.name, field)
# New column with a unique index
# This works because index name is same as fieldname.
new_field = frappe.copy_doc(doctype.fields[0])
new_field.fieldname = "duplicate_field"
doctype.append("fields", new_field)
doctype.save()
self.check_unique_indexes(doctype.name, new_field.fieldname)
doctype.delete()
frappe.db.commit()

View file

@ -6,18 +6,18 @@ from frappe.tests.utils import FrappeTestCase
class TestLinkedWith(FrappeTestCase):
def setUp(self):
parent_doc = new_doctype("Parent Doc")
parent_doc.is_submittable = 1
parent_doc.insert()
parent_doctype = new_doctype("Parent DocType")
parent_doctype.is_submittable = 1
parent_doctype.insert()
child_doc1 = new_doctype(
"Child Doc1",
child_doctype1 = new_doctype(
"Child DocType1",
fields=[
{
"label": "Parent Doc",
"fieldname": "parent_doc",
"label": "Parent DocType",
"fieldname": "parent_doctype",
"fieldtype": "Link",
"options": "Parent Doc",
"options": "Parent DocType",
},
{
"label": "Reference field",
@ -34,85 +34,99 @@ class TestLinkedWith(FrappeTestCase):
],
unique=0,
)
child_doc1.is_submittable = 1
child_doc1.insert()
child_doctype1.is_submittable = 1
child_doctype1.insert()
child_doc2 = new_doctype(
"Child Doc2",
child_doctype2 = new_doctype(
"Child DocType2",
fields=[
{
"label": "Parent Doc",
"fieldname": "parent_doc",
"label": "Parent DocType",
"fieldname": "parent_doctype",
"fieldtype": "Link",
"options": "Parent Doc",
"options": "Parent DocType",
},
{
"label": "Child Doc1",
"fieldname": "child_doc1",
"label": "Child DocType1",
"fieldname": "child_doctype1",
"fieldtype": "Link",
"options": "Child Doc1",
"options": "Child DocType1",
},
],
unique=0,
)
child_doc2.is_submittable = 1
child_doc2.insert()
child_doctype2.is_submittable = 1
child_doctype2.insert()
def tearDown(self):
for doctype in ["Parent Doc", "Child Doc1", "Child Doc2"]:
for doctype in ["Parent DocType", "Child DocType1", "Child DocType2"]:
frappe.delete_doc("DocType", doctype)
def test_get_doctype_references_by_link_field(self):
references = linked_with.get_references_across_doctypes_by_link_field(to_doctypes=["Parent Doc"])
self.assertEqual(len(references["Parent Doc"]), 3)
self.assertIn({"doctype": "Child Doc1", "fieldname": "parent_doc"}, references["Parent Doc"])
self.assertIn({"doctype": "Child Doc2", "fieldname": "parent_doc"}, references["Parent Doc"])
references = linked_with.get_references_across_doctypes_by_link_field(to_doctypes=["Child Doc1"])
self.assertEqual(len(references["Child Doc1"]), 2)
self.assertIn({"doctype": "Child Doc2", "fieldname": "child_doc1"}, references["Child Doc1"])
references = linked_with.get_references_across_doctypes_by_link_field(
to_doctypes=["Parent DocType"]
)
self.assertEqual(len(references["Parent DocType"]), 3)
self.assertIn(
{"doctype": "Child DocType1", "fieldname": "parent_doctype"}, references["Parent DocType"]
)
self.assertIn(
{"doctype": "Child DocType2", "fieldname": "parent_doctype"}, references["Parent DocType"]
)
references = linked_with.get_references_across_doctypes_by_link_field(
to_doctypes=["Child Doc1", "Parent Doc"], limit_link_doctypes=["Child Doc1"]
to_doctypes=["Child DocType1"]
)
self.assertEqual(len(references["Child DocType1"]), 2)
self.assertIn(
{"doctype": "Child DocType2", "fieldname": "child_doctype1"}, references["Child DocType1"]
)
references = linked_with.get_references_across_doctypes_by_link_field(
to_doctypes=["Child DocType1", "Parent DocType"], limit_link_doctypes=["Child DocType1"]
)
self.assertEqual(len(references["Child DocType1"]), 1)
self.assertEqual(len(references["Parent DocType"]), 1)
self.assertIn(
{"doctype": "Child DocType1", "fieldname": "parent_doctype"}, references["Parent DocType"]
)
self.assertEqual(len(references["Child Doc1"]), 1)
self.assertEqual(len(references["Parent Doc"]), 1)
self.assertIn({"doctype": "Child Doc1", "fieldname": "parent_doc"}, references["Parent Doc"])
def test_get_doctype_references_by_dlink_field(self):
references = linked_with.get_references_across_doctypes_by_dynamic_link_field(
to_doctypes=["Parent Doc"], limit_link_doctypes=["Parent Doc", "Child Doc1", "Child Doc2"]
to_doctypes=["Parent DocType"],
limit_link_doctypes=["Parent DocType", "Child DocType1", "Child DocType2"],
)
self.assertFalse(references)
parent_record = frappe.get_doc({"doctype": "Parent Doc"}).insert()
parent_record = frappe.get_doc({"doctype": "Parent DocType"}).insert()
child_record = frappe.get_doc(
{
"doctype": "Child Doc1",
"reference_doctype": "Parent Doc",
"doctype": "Child DocType1",
"reference_doctype": "Parent DocType",
"reference_name": parent_record.name,
}
).insert()
references = linked_with.get_references_across_doctypes_by_dynamic_link_field(
to_doctypes=["Parent Doc"], limit_link_doctypes=["Parent Doc", "Child Doc1", "Child Doc2"]
to_doctypes=["Parent DocType"],
limit_link_doctypes=["Parent DocType", "Child DocType1", "Child DocType2"],
)
self.assertEqual(len(references["Parent Doc"]), 1)
self.assertEqual(references["Parent Doc"][0]["doctype"], "Child Doc1")
self.assertEqual(references["Parent Doc"][0]["doctype_fieldname"], "reference_doctype")
self.assertEqual(len(references["Parent DocType"]), 1)
self.assertEqual(references["Parent DocType"][0]["doctype"], "Child DocType1")
self.assertEqual(references["Parent DocType"][0]["doctype_fieldname"], "reference_doctype")
child_record.delete()
parent_record.delete()
def test_get_submitted_linked_docs(self):
parent_record = frappe.get_doc({"doctype": "Parent Doc"}).insert()
parent_record = frappe.get_doc({"doctype": "Parent DocType"}).insert()
child_record = frappe.get_doc(
{
"doctype": "Child Doc1",
"reference_doctype": "Parent Doc",
"doctype": "Child DocType1",
"reference_doctype": "Parent DocType",
"reference_name": parent_record.name,
"docstatus": 1,
}

View file

@ -1,6 +1,7 @@
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
import re
import frappe
from frappe.app import make_form_dict
@ -26,71 +27,24 @@ class TestSearch(FrappeTestCase):
self.assertTrue("User" in result["value"])
# raise exception on injection
self.assertRaises(
frappe.DataError,
search_link,
"DocType",
"Customer",
query=None,
filters=None,
page_length=20,
searchfield="1=1",
)
self.assertRaises(
frappe.DataError,
search_link,
"DocType",
"Customer",
query=None,
filters=None,
page_length=20,
searchfield="select * from tabSessions) --",
)
self.assertRaises(
frappe.DataError,
search_link,
"DocType",
"Customer",
query=None,
filters=None,
page_length=20,
searchfield="name or (select * from tabSessions)",
)
self.assertRaises(
frappe.DataError,
search_link,
"DocType",
"Customer",
query=None,
filters=None,
page_length=20,
searchfield="*",
)
self.assertRaises(
frappe.DataError,
search_link,
"DocType",
"Customer",
query=None,
filters=None,
page_length=20,
searchfield=";",
)
self.assertRaises(
frappe.DataError,
search_link,
"DocType",
"Customer",
query=None,
filters=None,
page_length=20,
searchfield=";",
)
for searchfield in (
"1=1",
"select * from tabSessions) --",
"name or (select * from tabSessions)",
"*",
";",
"select`sid`from`tabSessions`",
):
self.assertRaises(
frappe.DataError,
search_link,
"DocType",
"User",
query=None,
filters=None,
page_length=20,
searchfield=searchfield,
)
def test_only_enabled_in_mention(self):
email = "test_disabled_user_in_mentions@example.com"

View file

@ -15,7 +15,11 @@
"section_break_7",
"content",
"likes",
"route"
"route",
"section_break_cww5",
"helpful",
"cb_00",
"not_helpful"
],
"fields": [
{
@ -78,6 +82,30 @@
"fieldtype": "Data",
"in_global_search": 1,
"label": "Route"
},
{
"default": "0",
"fieldname": "helpful",
"fieldtype": "Int",
"in_list_view": 1,
"label": "Helpful",
"read_only": 1
},
{
"fieldname": "cb_00",
"fieldtype": "Column Break"
},
{
"default": "0",
"fieldname": "not_helpful",
"fieldtype": "Int",
"in_list_view": 1,
"label": "Not Helpful",
"read_only": 1
},
{
"fieldname": "section_break_cww5",
"fieldtype": "Section Break"
}
],
"has_web_view": 1,
@ -85,7 +113,7 @@
"index_web_pages_for_search": 1,
"is_published_field": "published",
"links": [],
"modified": "2022-01-04 16:25:18.577325",
"modified": "2022-12-15 20:05:11.317400",
"modified_by": "Administrator",
"module": "Website",
"name": "Help Article",
@ -116,6 +144,7 @@
],
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"title_field": "title",
"track_changes": 1
}

View file

@ -7,4 +7,39 @@ from frappe.tests.utils import FrappeTestCase
class TestHelpArticle(FrappeTestCase):
pass
@classmethod
def setUpClass(cls) -> None:
cls.help_category = frappe.get_doc(
{
"doctype": "Help Category",
"category_name": "_Test Help Category",
}
).insert()
cls.help_article = frappe.get_doc(
{
"doctype": "Help Article",
"title": "_Test Article",
"category": cls.help_category.name,
"content": "_Test Article",
}
).insert()
def test_article_is_helpful(self):
from frappe.website.doctype.help_article.help_article import add_feedback
self.help_article.load_from_db()
self.assertEqual(self.help_article.helpful, 0)
self.assertEqual(self.help_article.not_helpful, 0)
add_feedback(self.help_article.name, "Yes")
add_feedback(self.help_article.name, "No")
self.help_article.load_from_db()
self.assertEqual(self.help_article.helpful, 1)
self.assertEqual(self.help_article.not_helpful, 1)
@classmethod
def tearDownClass(cls) -> None:
frappe.delete_doc(cls.help_article.doctype, cls.help_article.name)
frappe.delete_doc(cls.help_category.doctype, cls.help_category.name)

View file

@ -1,5 +1,9 @@
{% extends "templates/web.html" %}
{% block meta_block %}
{% include "templates/includes/meta_block.html" %}
{% endblock %}
{% block breadcrumbs %}{% endblock %}
{% block header %}

View file

@ -14,6 +14,9 @@
"published": 1,
"success_url": "/manage-events",
"title": "Manage Events",
"meta_title": "Test Meta Form Title",
"meta_description": "Test Meta Form Description",
"meta_image": "https://frappe.io/files/frappe.png",
"web_form_fields": [
{
"doctype": "Web Form Field",

View file

@ -75,3 +75,11 @@ class TestWebForm(FrappeTestCase):
self.assertIn('data-doctype="Web Form"', content)
self.assertIn('data-path="manage-events/new"', content)
self.assertIn('source-type="Generator"', content)
def test_webform_html_meta_is_added(self):
set_request(method="GET", path="manage-events/new")
content = get_response_content("manage-events/new")
self.assertIn('<meta name="name" content="Test Meta Form Title">', content)
self.assertIn('<meta property="og:description" content="Test Meta Form Description">', content)
self.assertIn('<meta property="og:image" content="https://frappe.io/files/frappe.png">', content)

View file

@ -48,6 +48,11 @@
"success_url",
"column_break_4",
"success_message",
"meta_section",
"meta_title",
"meta_description",
"column_break_khxs",
"meta_image",
"section_break_6",
"client_script",
"custom_css"
@ -328,13 +333,38 @@
"fieldname": "section_break_6",
"fieldtype": "Section Break",
"label": "Scripting / Style"
},
{
"collapsible": 1,
"fieldname": "meta_section",
"fieldtype": "Section Break",
"label": "Meta"
},
{
"fieldname": "meta_title",
"fieldtype": "Data",
"label": "Meta Title"
},
{
"fieldname": "meta_description",
"fieldtype": "Small Text",
"label": "Meta Description"
},
{
"fieldname": "column_break_khxs",
"fieldtype": "Column Break"
},
{
"fieldname": "meta_image",
"fieldtype": "Attach Image",
"label": "Meta Image"
}
],
"has_web_view": 1,
"icon": "icon-edit",
"is_published_field": "published",
"links": [],
"modified": "2022-08-17 18:58:49.451658",
"modified": "2022-12-15 17:14:44.939645",
"modified_by": "Administrator",
"module": "Website",
"name": "Web Form",

View file

@ -191,10 +191,23 @@ def get_context(context):
self.add_custom_context_and_script(context)
self.load_translations(context)
self.add_metatags(context)
context.boot = get_boot_data()
context.boot["link_title_doctypes"] = frappe.boot.get_link_title_doctypes()
def add_metatags(self, context):
description = self.meta_description
if not description and self.introduction_text:
description = self.introduction_text[:140]
context.metatags = {
"name": self.meta_title or self.title,
"description": description,
"image": self.meta_image,
}
def load_translations(self, context):
translated_messages = frappe.translate.get_dict("doctype", self.doc_type)
# Sr is not added by default, had to be added manually

View file

@ -136,7 +136,7 @@ def get_workflow_state_count(doctype, workflow_state_field, states):
states = frappe.parse_json(states)
result = frappe.get_all(
doctype,
fields=[workflow_state_field, "count(*) as count", "docstatus"],
fields=[workflow_state_field, "count(*) as count"],
filters={workflow_state_field: ["not in", states]},
group_by=workflow_state_field,
)

View file

@ -20,7 +20,7 @@ dependencies = [
"PyPDF2~=2.1.0",
"PyPika~=0.48.9",
"PyQRCode~=1.2.1",
"PyYAML~=5.4.1",
"PyYAML~=6.0",
"RestrictedPython~=6.0",
"WeasyPrint==52.5",
"Werkzeug~=2.2.2",