435 lines
13 KiB
Python
435 lines
13 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
|
# MIT License. See license.txt
|
|
from __future__ import unicode_literals
|
|
|
|
import json
|
|
import os
|
|
from six import iteritems
|
|
import logging
|
|
import time
|
|
|
|
from werkzeug.wrappers import Request
|
|
from werkzeug.local import LocalManager
|
|
from werkzeug.exceptions import HTTPException, NotFound
|
|
from werkzeug.contrib.profiler import ProfilerMiddleware
|
|
from werkzeug.wsgi import SharedDataMiddleware
|
|
|
|
import frappe
|
|
import frappe.handler
|
|
import frappe.auth
|
|
import frappe.api
|
|
import frappe.utils.response
|
|
import frappe.website.render
|
|
from frappe.utils import get_site_name
|
|
from frappe.middlewares import RecorderMiddleware, StaticDataMiddleware
|
|
from frappe.utils.error import make_error_snapshot
|
|
from frappe.core.doctype.comment.comment import update_comments_in_parent_after_request
|
|
from frappe import _
|
|
|
|
local_manager = LocalManager([frappe.local])
|
|
|
|
_site = None
|
|
_sites_path = os.environ.get("SITES_PATH", ".")
|
|
|
|
class RequestContext(object):
|
|
|
|
def __init__(self, environ):
|
|
self.request = Request(environ)
|
|
|
|
def __enter__(self):
|
|
init_request(self.request)
|
|
|
|
def __exit__(self, type, value, traceback):
|
|
frappe.destroy()
|
|
|
|
def wrap_cache():
|
|
def cache_recorder(function):
|
|
def wrapper(*args, **kwargs):
|
|
# There should be a better way to record time in ms
|
|
# For now we're recording time in ns and then dividing it by one million
|
|
one_million = 1000000
|
|
|
|
start_time_ns = time.perf_counter_ns()
|
|
result = function(*args, **kwargs)
|
|
end_time_ns = time.perf_counter_ns()
|
|
|
|
import traceback
|
|
# Some elementary analysis shows that following lines are a little time consuming
|
|
# These can be made optional.
|
|
stack = "".join(traceback.format_stack())
|
|
|
|
data = {
|
|
"function": function.__name__,
|
|
#"args": args,
|
|
#"kwargs": kwargs,
|
|
# result is sometimes a nested dict, those can't be sometimes JSON serialized.
|
|
# pickle.dumps seems like a nice way to go., but JS can't understand pickle.
|
|
# Skip result for now
|
|
# "result": result,
|
|
"stack": stack,
|
|
# Exact redis query is not available for now
|
|
# Regenerate equivalent function call instead.
|
|
"call": "{}(*{},**{})".format(function.__name__, args, kwargs),
|
|
"time": {
|
|
"start": start_time_ns / one_million,
|
|
"end": end_time_ns / one_million,
|
|
"total": (end_time_ns - start_time_ns) / one_million,
|
|
},
|
|
}
|
|
|
|
wrapper.calls.append(data)
|
|
return result
|
|
wrapper.calls = list()
|
|
return wrapper
|
|
|
|
# frappe.cache() will provide an instance of RedisWrapper
|
|
# Selected methods of this class are used to do cache operations
|
|
# Recording activity of these methods will give a nice picture of cache activity
|
|
|
|
# cache_methods lists all interesting cache methods
|
|
# Override these methods with the use of wrapper function
|
|
# that records each call along with some suplimentary data
|
|
redis_server = frappe.cache()
|
|
cache_methods = [
|
|
"set_value", "get_value",
|
|
"get_all", "get_keys",
|
|
"delete_keys", "delete_key", "delete_value",
|
|
"lpush", "rpush", "lpop", "llen", "lrange",
|
|
"hset", "hgetall", "hincrby", "hget", "hdel", "hdel_keys", "hkeys",
|
|
"sadd", "srem", "sismember", "spop", "srandmember", "smembers",
|
|
"zincrby", "zrange"
|
|
]
|
|
|
|
# cache_methods will be needed again while storing recorded calls in cache
|
|
# Store cache_methods list in cache_methods attribute of wrap_cache
|
|
wrap_cache.cache_methods = cache_methods
|
|
for method in cache_methods:
|
|
# For now assume that all these methods exist on RedisWrapper instance
|
|
original = getattr(redis_server, method)
|
|
modified = cache_recorder(original)
|
|
setattr(redis_server, method, modified)
|
|
|
|
def persist_cache():
|
|
redis_server = frappe.cache()
|
|
cache_methods = wrap_cache.cache_methods
|
|
calls = []
|
|
uuid = frappe.request.environ["uuid"]
|
|
for method in cache_methods:
|
|
# Assumes that the method exists on RedisWrapper instance
|
|
# and function.calls also exists
|
|
function = getattr(redis_server, method)
|
|
calls.extend(function.calls)
|
|
|
|
# Record all calls in cache
|
|
frappe.cache().set("recorder-calls-cache-{}".format(uuid), dumps(calls))
|
|
|
|
def recorder(function):
|
|
def wrapper(*args, **kwargs):
|
|
# Execute wrapped function as is
|
|
# Record arguments as well as return value
|
|
# Record start and end time as well
|
|
one_million = 1000000
|
|
start_time_ns = time.perf_counter_ns()
|
|
result = function(*args, **kwargs)
|
|
end_time_ns = time.perf_counter_ns()
|
|
|
|
import traceback
|
|
stack = "".join(traceback.format_stack())
|
|
|
|
# Big hack here
|
|
# PyMysql stores exact DB query in cursor._executed
|
|
# Assumes that function refers to frappe.db.sql
|
|
# __self__ will refer to frappe.db
|
|
# Rest is trivial
|
|
query = function.__self__._cursor._executed
|
|
data = {
|
|
"function": function.__name__,
|
|
"args": args,
|
|
"kwargs": kwargs,
|
|
"result": result,
|
|
"query": query,
|
|
"stack": stack,
|
|
"time": {
|
|
"start": start_time_ns / one_million,
|
|
"end": end_time_ns / one_million,
|
|
"total": (end_time_ns - start_time_ns) / one_million,
|
|
},
|
|
}
|
|
|
|
# Record all calls, Will be later stored in cache
|
|
wrapper.calls.append(data)
|
|
return result
|
|
|
|
wrapper.calls = list()
|
|
wrapper.path = frappe.request.path
|
|
return wrapper
|
|
|
|
def dumps(stuff):
|
|
return json.dumps(stuff, default=lambda x: str(x))
|
|
|
|
def persist(function):
|
|
"""Stores recorded requests and calls in redis cache with following hierarchy
|
|
|
|
recorder-paths -> ["Path1", "Path2", ... ,"Path3"]
|
|
|
|
recorder-paths is a sorted set, Paths have non decreasing score,
|
|
Highest score means recently visited
|
|
|
|
recorder-requests-[path] -> ["UUID3", "UUID2", ...]
|
|
recorder-requests-[path] is a list of UUIDs of requests made on that path
|
|
in reverse chronological order
|
|
|
|
recorder-requests-[uuid] -> ["Call1", "Call2"]
|
|
recorder-requests-[uuid] is a list of calls made during that request
|
|
in chronological order
|
|
"""
|
|
path = function.path
|
|
calls = function.calls
|
|
|
|
# RecorderMiddleware creates a uuid for every request and
|
|
# stores it in request environ
|
|
uuid = frappe.request.environ["uuid"]
|
|
|
|
# Redis Sorted Set -> Ordered set (Ordereed by `score`)
|
|
# Elements are ordered from low to high score
|
|
|
|
# Get the highest score from our set
|
|
highest_score = frappe.cache().zrange("recorder-paths", -1, -1, withscores=True)
|
|
|
|
# In the begning we might not even have a highest score
|
|
# We have to start somewhere, 0 seems like a safe bet
|
|
highest_score = highest_score[0][1] if highest_score else 0
|
|
|
|
# Now insert `path` with score highest + 1
|
|
# Effectively giving `path` the highest score
|
|
# Note: Iff these two operations are done consequtively
|
|
# Note: score can grow exponentially, Will probably need a fix
|
|
frappe.cache().zincrby("recorder-paths", path, amount=float(highest_score)+1)
|
|
|
|
# Also record number of times each URL was hit
|
|
frappe.cache().hincrby("recorder-paths-counts", path, 1)
|
|
|
|
# LPUSH -> Reverse chronological order for requests
|
|
frappe.cache().lpush("recorder-requests-{}".format(path), uuid)
|
|
|
|
# LPUSH -> Chronological order for calls
|
|
# Since every request uuid is unique, no need for any heirarchy
|
|
|
|
# Datetime objects cannot be used with json.dumps
|
|
# str() seems to work with them
|
|
frappe.cache().set("recorder-calls-{}".format(uuid), dumps(calls))
|
|
|
|
@Request.application
|
|
def application(request):
|
|
response = None
|
|
|
|
try:
|
|
rollback = True
|
|
|
|
init_request(request)
|
|
|
|
# Need to record all calls to frappe.db.sql
|
|
# Should be done after frappe.db refers to an instance of Database
|
|
# Now is a good time
|
|
wrap_cache()
|
|
frappe.db.sql = recorder(frappe.db.sql)
|
|
|
|
if frappe.local.form_dict.cmd:
|
|
response = frappe.handler.handle()
|
|
|
|
elif frappe.request.path.startswith("/api/"):
|
|
if frappe.local.form_dict.data is None:
|
|
frappe.local.form_dict.data = request.get_data()
|
|
response = frappe.api.handle()
|
|
|
|
elif frappe.request.path.startswith('/backups'):
|
|
response = frappe.utils.response.download_backup(request.path)
|
|
|
|
elif frappe.request.path.startswith('/private/files/'):
|
|
response = frappe.utils.response.download_private_file(request.path)
|
|
|
|
elif frappe.local.request.method in ('GET', 'HEAD', 'POST'):
|
|
response = frappe.website.render.render()
|
|
|
|
else:
|
|
raise NotFound
|
|
|
|
except HTTPException as e:
|
|
return e
|
|
|
|
except frappe.SessionStopped as e:
|
|
response = frappe.utils.response.handle_session_stopped()
|
|
|
|
except Exception as e:
|
|
response = handle_exception(e)
|
|
|
|
else:
|
|
rollback = after_request(rollback)
|
|
|
|
finally:
|
|
if frappe.local.request.method in ("POST", "PUT") and frappe.db and rollback:
|
|
frappe.db.rollback()
|
|
|
|
# set cookies
|
|
if response and hasattr(frappe.local, 'cookie_manager'):
|
|
frappe.local.cookie_manager.flush_cookies(response=response)
|
|
|
|
# Recorded calls need to be stored in cache
|
|
# This looks like a terribe syntax, Because it actually is
|
|
persist_cache()
|
|
persist(frappe.db.sql)
|
|
frappe.destroy()
|
|
|
|
return response
|
|
|
|
def init_request(request):
|
|
frappe.local.request = request
|
|
frappe.local.is_ajax = frappe.get_request_header("X-Requested-With")=="XMLHttpRequest"
|
|
|
|
site = _site or request.headers.get('X-Frappe-Site-Name') or get_site_name(request.host)
|
|
frappe.init(site=site, sites_path=_sites_path)
|
|
|
|
if not (frappe.local.conf and frappe.local.conf.db_name):
|
|
# site does not exist
|
|
raise NotFound
|
|
|
|
if frappe.local.conf.get('maintenance_mode'):
|
|
raise frappe.SessionStopped
|
|
|
|
make_form_dict(request)
|
|
|
|
frappe.local.http_request = frappe.auth.HTTPRequest()
|
|
|
|
def make_form_dict(request):
|
|
import json
|
|
|
|
request_data = request.get_data(as_text=True)
|
|
if 'application/json' in (request.content_type or '') and request_data:
|
|
args = json.loads(request_data)
|
|
else:
|
|
args = request.form or request.args
|
|
|
|
try:
|
|
frappe.local.form_dict = frappe._dict({ k:v[0] if isinstance(v, (list, tuple)) else v \
|
|
for k, v in iteritems(args) })
|
|
except IndexError:
|
|
frappe.local.form_dict = frappe._dict(args)
|
|
|
|
if "_" in frappe.local.form_dict:
|
|
# _ is passed by $.ajax so that the request is not cached by the browser. So, remove _ from form_dict
|
|
frappe.local.form_dict.pop("_")
|
|
|
|
def handle_exception(e):
|
|
response = None
|
|
http_status_code = getattr(e, "http_status_code", 500)
|
|
return_as_message = False
|
|
|
|
if frappe.get_request_header('Accept') and (frappe.local.is_ajax or 'application/json' in frappe.get_request_header('Accept')):
|
|
# handle ajax responses first
|
|
# if the request is ajax, send back the trace or error message
|
|
response = frappe.utils.response.report_error(http_status_code)
|
|
|
|
elif (http_status_code==500
|
|
and (frappe.db and isinstance(e, frappe.db.InternalError))
|
|
and (frappe.db and (frappe.db.is_deadlocked(e) or frappe.db.is_timedout(e)))):
|
|
http_status_code = 508
|
|
|
|
elif http_status_code==401:
|
|
frappe.respond_as_web_page(_("Session Expired"),
|
|
_("Your session has expired, please login again to continue."),
|
|
http_status_code=http_status_code, indicator_color='red')
|
|
return_as_message = True
|
|
|
|
elif http_status_code==403:
|
|
frappe.respond_as_web_page(_("Not Permitted"),
|
|
_("You do not have enough permissions to complete the action"),
|
|
http_status_code=http_status_code, indicator_color='red')
|
|
return_as_message = True
|
|
|
|
elif http_status_code==404:
|
|
frappe.respond_as_web_page(_("Not Found"),
|
|
_("The resource you are looking for is not available"),
|
|
http_status_code=http_status_code, indicator_color='red')
|
|
return_as_message = True
|
|
|
|
else:
|
|
traceback = "<pre>"+frappe.get_traceback()+"</pre>"
|
|
if frappe.local.flags.disable_traceback:
|
|
traceback = ""
|
|
|
|
frappe.respond_as_web_page("Server Error",
|
|
traceback, http_status_code=http_status_code,
|
|
indicator_color='red', width=640)
|
|
return_as_message = True
|
|
|
|
if e.__class__ == frappe.AuthenticationError:
|
|
if hasattr(frappe.local, "login_manager"):
|
|
frappe.local.login_manager.clear_cookies()
|
|
|
|
if http_status_code >= 500:
|
|
frappe.logger().error('Request Error', exc_info=True)
|
|
make_error_snapshot(e)
|
|
|
|
if return_as_message:
|
|
response = frappe.website.render.render("message",
|
|
http_status_code=http_status_code)
|
|
|
|
return response
|
|
|
|
def after_request(rollback):
|
|
if (frappe.local.request.method in ("POST", "PUT") or frappe.local.flags.commit) and frappe.db:
|
|
if frappe.db.transaction_writes:
|
|
frappe.db.commit()
|
|
rollback = False
|
|
|
|
# update session
|
|
if getattr(frappe.local, "session_obj", None):
|
|
updated_in_db = frappe.local.session_obj.update()
|
|
if updated_in_db:
|
|
frappe.db.commit()
|
|
rollback = False
|
|
|
|
update_comments_in_parent_after_request()
|
|
|
|
return rollback
|
|
|
|
application = local_manager.make_middleware(application)
|
|
|
|
def serve(port=8000, profile=False, record=False, no_reload=False, no_threading=False, site=None, sites_path='.'):
|
|
global application, _site, _sites_path
|
|
_site = site
|
|
_sites_path = sites_path
|
|
|
|
from werkzeug.serving import run_simple
|
|
|
|
if profile:
|
|
application = ProfilerMiddleware(application, sort_by=('cumtime', 'calls'))
|
|
|
|
if record:
|
|
application = RecorderMiddleware(application)
|
|
|
|
if not os.environ.get('NO_STATICS'):
|
|
application = SharedDataMiddleware(application, {
|
|
str('/assets'): str(os.path.join(sites_path, 'assets'))
|
|
})
|
|
|
|
application = StaticDataMiddleware(application, {
|
|
str('/files'): str(os.path.abspath(sites_path))
|
|
})
|
|
|
|
application.debug = True
|
|
application.config = {
|
|
'SERVER_NAME': 'localhost:8000'
|
|
}
|
|
|
|
in_test_env = os.environ.get('CI')
|
|
if in_test_env:
|
|
log = logging.getLogger('werkzeug')
|
|
log.setLevel(logging.ERROR)
|
|
|
|
run_simple('0.0.0.0', int(port), application,
|
|
use_reloader=False if in_test_env else not no_reload,
|
|
use_debugger=not in_test_env,
|
|
use_evalex=not in_test_env,
|
|
threaded=not no_threading)
|