mirror of
https://github.com/Grasscutters/mitmproxy.git
synced 2024-11-26 18:18:25 +00:00
Merge pull request #2302 from cortesi/flowview
commands: view.setval, view.getval, view.setval.toggle
This commit is contained in:
commit
53ad658e9f
@ -296,6 +296,45 @@ class View(collections.Sequence):
|
|||||||
"""
|
"""
|
||||||
return self._store.get(flow_id)
|
return self._store.get(flow_id)
|
||||||
|
|
||||||
|
@command.command("view.getval")
|
||||||
|
def getvalue(self, f: mitmproxy.flow.Flow, key: str, default: str) -> str:
|
||||||
|
"""
|
||||||
|
Get a value from the settings store for the specified flow.
|
||||||
|
"""
|
||||||
|
return self.settings[f].get(key, default)
|
||||||
|
|
||||||
|
@command.command("view.setval.toggle")
|
||||||
|
def setvalue_toggle(
|
||||||
|
self,
|
||||||
|
flows: typing.Sequence[mitmproxy.flow.Flow],
|
||||||
|
key: str
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Toggle a boolean value in the settings store, seting the value to
|
||||||
|
the string "true" or "false".
|
||||||
|
"""
|
||||||
|
updated = []
|
||||||
|
for f in flows:
|
||||||
|
current = self.settings[f].get("key", "false")
|
||||||
|
self.settings[f][key] = "false" if current == "true" else "true"
|
||||||
|
updated.append(f)
|
||||||
|
ctx.master.addons.trigger("update", updated)
|
||||||
|
|
||||||
|
@command.command("view.setval")
|
||||||
|
def setvalue(
|
||||||
|
self,
|
||||||
|
flows: typing.Sequence[mitmproxy.flow.Flow],
|
||||||
|
key: str, value: str
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Set a value in the settings store for the specified flows.
|
||||||
|
"""
|
||||||
|
updated = []
|
||||||
|
for f in flows:
|
||||||
|
self.settings[f][key] = value
|
||||||
|
updated.append(f)
|
||||||
|
ctx.master.addons.trigger("update", updated)
|
||||||
|
|
||||||
@command.command("view.load")
|
@command.command("view.load")
|
||||||
def load_file(self, path: str) -> None:
|
def load_file(self, path: str) -> None:
|
||||||
"""
|
"""
|
||||||
|
@ -1,24 +1,9 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
|
|
||||||
import os
|
|
||||||
|
|
||||||
import urwid
|
import urwid
|
||||||
import urwid.util
|
import urwid.util
|
||||||
|
|
||||||
import mitmproxy.net
|
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from mitmproxy.tools.console import signals
|
|
||||||
from mitmproxy.utils import human
|
from mitmproxy.utils import human
|
||||||
|
|
||||||
try:
|
|
||||||
import pyperclip
|
|
||||||
except:
|
|
||||||
pyperclip = False
|
|
||||||
|
|
||||||
|
|
||||||
VIEW_FLOW_REQUEST = 0
|
|
||||||
VIEW_FLOW_RESPONSE = 1
|
|
||||||
|
|
||||||
METHOD_OPTIONS = [
|
METHOD_OPTIONS = [
|
||||||
("get", "g"),
|
("get", "g"),
|
||||||
@ -133,178 +118,6 @@ else:
|
|||||||
SYMBOL_DOWN = " "
|
SYMBOL_DOWN = " "
|
||||||
|
|
||||||
|
|
||||||
# Save file to disk
|
|
||||||
def save_data(path, data):
|
|
||||||
if not path:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
if isinstance(data, bytes):
|
|
||||||
mode = "wb"
|
|
||||||
else:
|
|
||||||
mode = "w"
|
|
||||||
with open(path, mode) as f:
|
|
||||||
f.write(data)
|
|
||||||
except IOError as v:
|
|
||||||
signals.status_message.send(message=v.strerror)
|
|
||||||
|
|
||||||
|
|
||||||
def ask_save_overwrite(path, data):
|
|
||||||
if os.path.exists(path):
|
|
||||||
def save_overwrite(k):
|
|
||||||
if k == "y":
|
|
||||||
save_data(path, data)
|
|
||||||
|
|
||||||
signals.status_prompt_onekey.send(
|
|
||||||
prompt = "'" + path + "' already exists. Overwrite?",
|
|
||||||
keys = (
|
|
||||||
("yes", "y"),
|
|
||||||
("no", "n"),
|
|
||||||
),
|
|
||||||
callback = save_overwrite
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
save_data(path, data)
|
|
||||||
|
|
||||||
|
|
||||||
def ask_save_path(data, prompt="File path"):
|
|
||||||
signals.status_prompt_path.send(
|
|
||||||
prompt = prompt,
|
|
||||||
callback = ask_save_overwrite,
|
|
||||||
args = (data, )
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def ask_scope_and_callback(flow, cb, *args):
|
|
||||||
request_has_content = flow.request and flow.request.raw_content
|
|
||||||
response_has_content = flow.response and flow.response.raw_content
|
|
||||||
|
|
||||||
if request_has_content and response_has_content:
|
|
||||||
signals.status_prompt_onekey.send(
|
|
||||||
prompt = "Save",
|
|
||||||
keys = (
|
|
||||||
("request", "q"),
|
|
||||||
("response", "s"),
|
|
||||||
("both", "b"),
|
|
||||||
),
|
|
||||||
callback = cb,
|
|
||||||
args = (flow,) + args
|
|
||||||
)
|
|
||||||
elif response_has_content:
|
|
||||||
cb("s", flow, *args)
|
|
||||||
else:
|
|
||||||
cb("q", flow, *args)
|
|
||||||
|
|
||||||
|
|
||||||
def copy_to_clipboard_or_prompt(data):
|
|
||||||
# pyperclip calls encode('utf-8') on data to be copied without checking.
|
|
||||||
# if data are already encoded that way UnicodeDecodeError is thrown.
|
|
||||||
if isinstance(data, bytes):
|
|
||||||
toclip = data.decode("utf8", "replace")
|
|
||||||
else:
|
|
||||||
toclip = data
|
|
||||||
|
|
||||||
try:
|
|
||||||
pyperclip.copy(toclip)
|
|
||||||
except (RuntimeError, UnicodeDecodeError, AttributeError, TypeError):
|
|
||||||
def save(k):
|
|
||||||
if k == "y":
|
|
||||||
ask_save_path(data, "Save data")
|
|
||||||
signals.status_prompt_onekey.send(
|
|
||||||
prompt = "Cannot copy data to clipboard. Save as file?",
|
|
||||||
keys = (
|
|
||||||
("yes", "y"),
|
|
||||||
("no", "n"),
|
|
||||||
),
|
|
||||||
callback = save
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def format_flow_data(key, scope, flow):
|
|
||||||
data = b""
|
|
||||||
if scope in ("q", "b"):
|
|
||||||
request = flow.request.copy()
|
|
||||||
request.decode(strict=False)
|
|
||||||
if request.content is None:
|
|
||||||
return None, "Request content is missing"
|
|
||||||
if key == "h":
|
|
||||||
data += mitmproxy.net.http.http1.assemble_request(request)
|
|
||||||
elif key == "c":
|
|
||||||
data += request.get_content(strict=False)
|
|
||||||
else:
|
|
||||||
raise ValueError("Unknown key: {}".format(key))
|
|
||||||
if scope == "b" and flow.request.raw_content and flow.response:
|
|
||||||
# Add padding between request and response
|
|
||||||
data += b"\r\n" * 2
|
|
||||||
if scope in ("s", "b") and flow.response:
|
|
||||||
response = flow.response.copy()
|
|
||||||
response.decode(strict=False)
|
|
||||||
if response.content is None:
|
|
||||||
return None, "Response content is missing"
|
|
||||||
if key == "h":
|
|
||||||
data += mitmproxy.net.http.http1.assemble_response(response)
|
|
||||||
elif key == "c":
|
|
||||||
data += response.get_content(strict=False)
|
|
||||||
else:
|
|
||||||
raise ValueError("Unknown key: {}".format(key))
|
|
||||||
return data, False
|
|
||||||
|
|
||||||
|
|
||||||
def handle_flow_data(scope, flow, key, writer):
|
|
||||||
"""
|
|
||||||
key: _c_ontent, _h_eaders+content, _u_rl
|
|
||||||
scope: re_q_uest, re_s_ponse, _b_oth
|
|
||||||
writer: copy_to_clipboard_or_prompt, ask_save_path
|
|
||||||
"""
|
|
||||||
data, err = format_flow_data(key, scope, flow)
|
|
||||||
|
|
||||||
if err:
|
|
||||||
signals.status_message.send(message=err)
|
|
||||||
return
|
|
||||||
|
|
||||||
if not data:
|
|
||||||
if scope == "q":
|
|
||||||
signals.status_message.send(message="No request content.")
|
|
||||||
elif scope == "s":
|
|
||||||
signals.status_message.send(message="No response content.")
|
|
||||||
else:
|
|
||||||
signals.status_message.send(message="No content.")
|
|
||||||
return
|
|
||||||
|
|
||||||
writer(data)
|
|
||||||
|
|
||||||
|
|
||||||
def ask_save_body(scope, flow):
|
|
||||||
"""
|
|
||||||
Save either the request or the response body to disk.
|
|
||||||
|
|
||||||
scope: re_q_uest, re_s_ponse, _b_oth, None (ask user if necessary)
|
|
||||||
"""
|
|
||||||
|
|
||||||
request_has_content = flow.request and flow.request.raw_content
|
|
||||||
response_has_content = flow.response and flow.response.raw_content
|
|
||||||
|
|
||||||
if scope is None:
|
|
||||||
ask_scope_and_callback(flow, ask_save_body)
|
|
||||||
elif scope == "q" and request_has_content:
|
|
||||||
ask_save_path(
|
|
||||||
flow.request.get_content(strict=False),
|
|
||||||
"Save request content to"
|
|
||||||
)
|
|
||||||
elif scope == "s" and response_has_content:
|
|
||||||
ask_save_path(
|
|
||||||
flow.response.get_content(strict=False),
|
|
||||||
"Save response content to"
|
|
||||||
)
|
|
||||||
elif scope == "b" and request_has_content and response_has_content:
|
|
||||||
ask_save_path(
|
|
||||||
(flow.request.get_content(strict=False) + b"\n" +
|
|
||||||
flow.response.get_content(strict=False)),
|
|
||||||
"Save request & response content to"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
signals.status_message.send(message="No content.")
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=800)
|
@lru_cache(maxsize=800)
|
||||||
def raw_format_flow(f, flow):
|
def raw_format_flow(f, flow):
|
||||||
f = dict(f)
|
f = dict(f)
|
||||||
|
@ -9,7 +9,6 @@ from mitmproxy import contentviews
|
|||||||
from mitmproxy import http
|
from mitmproxy import http
|
||||||
from mitmproxy.tools.console import common
|
from mitmproxy.tools.console import common
|
||||||
from mitmproxy.tools.console import flowdetailview
|
from mitmproxy.tools.console import flowdetailview
|
||||||
from mitmproxy.tools.console import overlay
|
|
||||||
from mitmproxy.tools.console import searchable
|
from mitmproxy.tools.console import searchable
|
||||||
from mitmproxy.tools.console import signals
|
from mitmproxy.tools.console import signals
|
||||||
from mitmproxy.tools.console import tabs
|
from mitmproxy.tools.console import tabs
|
||||||
@ -117,14 +116,10 @@ class FlowViewHeader(urwid.WidgetWrap):
|
|||||||
self._w = urwid.Pile([])
|
self._w = urwid.Pile([])
|
||||||
|
|
||||||
|
|
||||||
TAB_REQ = 0
|
|
||||||
TAB_RESP = 1
|
|
||||||
|
|
||||||
|
|
||||||
class FlowDetails(tabs.Tabs):
|
class FlowDetails(tabs.Tabs):
|
||||||
def __init__(self, master, tab_offset):
|
def __init__(self, master):
|
||||||
self.master = master
|
self.master = master
|
||||||
super().__init__([], tab_offset)
|
super().__init__([])
|
||||||
self.show()
|
self.show()
|
||||||
self.last_displayed_body = None
|
self.last_displayed_body = None
|
||||||
|
|
||||||
@ -174,9 +169,8 @@ class FlowDetails(tabs.Tabs):
|
|||||||
msg, body = "", [urwid.Text([("error", "[content missing]")])]
|
msg, body = "", [urwid.Text([("error", "[content missing]")])]
|
||||||
return msg, body
|
return msg, body
|
||||||
else:
|
else:
|
||||||
s = self.view.settings[self.flow]
|
full = self.master.commands.call("view.getval @focus fullcontents false")
|
||||||
full = s.get((self.tab_offset, "fullcontents"), False)
|
if full == "true":
|
||||||
if full:
|
|
||||||
limit = sys.maxsize
|
limit = sys.maxsize
|
||||||
else:
|
else:
|
||||||
limit = contentviews.VIEW_CUTOFF
|
limit = contentviews.VIEW_CUTOFF
|
||||||
@ -232,12 +226,6 @@ class FlowDetails(tabs.Tabs):
|
|||||||
|
|
||||||
return description, text_objects
|
return description, text_objects
|
||||||
|
|
||||||
def viewmode_get(self):
|
|
||||||
return self.view.settings[self.flow].get(
|
|
||||||
(self.tab_offset, "prettyview"),
|
|
||||||
self.master.options.default_contentview
|
|
||||||
)
|
|
||||||
|
|
||||||
def conn_text(self, conn):
|
def conn_text(self, conn):
|
||||||
if conn:
|
if conn:
|
||||||
txt = common.format_keyvals(
|
txt = common.format_keyvals(
|
||||||
@ -245,7 +233,7 @@ class FlowDetails(tabs.Tabs):
|
|||||||
key = "header",
|
key = "header",
|
||||||
val = "text"
|
val = "text"
|
||||||
)
|
)
|
||||||
viewmode = self.viewmode_get()
|
viewmode = self.master.commands.call("console.flowview.mode")
|
||||||
msg, body = self.content_view(viewmode, conn)
|
msg, body = self.content_view(viewmode, conn)
|
||||||
|
|
||||||
cols = [
|
cols = [
|
||||||
@ -281,32 +269,10 @@ class FlowDetails(tabs.Tabs):
|
|||||||
]
|
]
|
||||||
return searchable.Searchable(txt)
|
return searchable.Searchable(txt)
|
||||||
|
|
||||||
def change_this_display_mode(self, t):
|
|
||||||
view = contentviews.get(t)
|
|
||||||
self.view.settings[self.flow][(self.tab_offset, "prettyview")] = view.name.lower()
|
|
||||||
|
|
||||||
def keypress(self, size, key):
|
def keypress(self, size, key):
|
||||||
key = super().keypress(size, key)
|
key = super().keypress(size, key)
|
||||||
key = common.shortcuts(key)
|
key = common.shortcuts(key)
|
||||||
if key in ("up", "down", "page up", "page down"):
|
return self._w.keypress(size, key)
|
||||||
# Pass scroll events to the wrapped widget
|
|
||||||
self._w.keypress(size, key)
|
|
||||||
elif key == "f":
|
|
||||||
self.view.settings[self.flow][(self.tab_offset, "fullcontents")] = True
|
|
||||||
signals.status_message.send(message="Loading all body data...")
|
|
||||||
elif key == "m":
|
|
||||||
opts = [i.name.lower() for i in contentviews.views]
|
|
||||||
self.master.overlay(
|
|
||||||
overlay.Chooser(
|
|
||||||
"display mode",
|
|
||||||
opts,
|
|
||||||
self.viewmode_get(),
|
|
||||||
self.change_this_display_mode
|
|
||||||
)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# Key is not handled here.
|
|
||||||
return key
|
|
||||||
|
|
||||||
|
|
||||||
class FlowView(urwid.Frame):
|
class FlowView(urwid.Frame):
|
||||||
@ -314,7 +280,7 @@ class FlowView(urwid.Frame):
|
|||||||
|
|
||||||
def __init__(self, master):
|
def __init__(self, master):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
FlowDetails(master, 0),
|
FlowDetails(master),
|
||||||
header = FlowViewHeader(master),
|
header = FlowViewHeader(master),
|
||||||
)
|
)
|
||||||
self.master = master
|
self.master = master
|
||||||
|
@ -29,6 +29,7 @@ from mitmproxy.tools.console import palettes
|
|||||||
from mitmproxy.tools.console import signals
|
from mitmproxy.tools.console import signals
|
||||||
from mitmproxy.tools.console import statusbar
|
from mitmproxy.tools.console import statusbar
|
||||||
from mitmproxy.tools.console import window
|
from mitmproxy.tools.console import window
|
||||||
|
from mitmproxy import contentviews
|
||||||
from mitmproxy.utils import strutils
|
from mitmproxy.utils import strutils
|
||||||
|
|
||||||
EVENTLOG_SIZE = 10000
|
EVENTLOG_SIZE = 10000
|
||||||
@ -225,6 +226,46 @@ class ConsoleAddon:
|
|||||||
"console.command flow.set @focus %s " % part
|
"console.command flow.set @focus %s " % part
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@command.command("console.flowview.mode.set")
|
||||||
|
def flowview_mode_set(self) -> None:
|
||||||
|
"""
|
||||||
|
Set the display mode for the current flow view.
|
||||||
|
"""
|
||||||
|
if self.master.window.focus.keyctx != "flowview":
|
||||||
|
raise exceptions.CommandError("Not viewing a flow.")
|
||||||
|
fv = self.master.window.windows["flowview"]
|
||||||
|
idx = fv.body.tab_offset
|
||||||
|
|
||||||
|
def callback(opt):
|
||||||
|
try:
|
||||||
|
self.master.commands.call_args(
|
||||||
|
"view.setval",
|
||||||
|
["@focus", "flowview_mode_%s" % idx, opt]
|
||||||
|
)
|
||||||
|
except exceptions.CommandError as e:
|
||||||
|
signals.status_message.send(message=str(e))
|
||||||
|
|
||||||
|
opts = [i.name.lower() for i in contentviews.views]
|
||||||
|
self.master.overlay(overlay.Chooser("Mode", opts, "", callback))
|
||||||
|
|
||||||
|
@command.command("console.flowview.mode")
|
||||||
|
def flowview_mode(self) -> str:
|
||||||
|
"""
|
||||||
|
Get the display mode for the current flow view.
|
||||||
|
"""
|
||||||
|
if self.master.window.focus.keyctx != "flowview":
|
||||||
|
raise exceptions.CommandError("Not viewing a flow.")
|
||||||
|
fv = self.master.window.windows["flowview"]
|
||||||
|
idx = fv.body.tab_offset
|
||||||
|
return self.master.commands.call_args(
|
||||||
|
"view.getval",
|
||||||
|
[
|
||||||
|
"@focus",
|
||||||
|
"flowview_mode_%s" % idx,
|
||||||
|
self.master.options.default_contentview,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
def running(self):
|
def running(self):
|
||||||
self.started = True
|
self.started = True
|
||||||
|
|
||||||
@ -265,7 +306,7 @@ def default_keymap(km):
|
|||||||
"console.command export.file {choice} @focus ''",
|
"console.command export.file {choice} @focus ''",
|
||||||
["flowlist", "flowview"]
|
["flowlist", "flowview"]
|
||||||
)
|
)
|
||||||
km.add("f", "console.command 'set view_filter='", ["flowlist"])
|
km.add("f", "console.command set view_filter=", ["flowlist"])
|
||||||
km.add("F", "set console_focus_follow=toggle", ["flowlist"])
|
km.add("F", "set console_focus_follow=toggle", ["flowlist"])
|
||||||
km.add("g", "view.go 0", ["flowlist"])
|
km.add("g", "view.go 0", ["flowlist"])
|
||||||
km.add("G", "view.go -1", ["flowlist"])
|
km.add("G", "view.go -1", ["flowlist"])
|
||||||
@ -285,15 +326,15 @@ def default_keymap(km):
|
|||||||
["flowlist"]
|
["flowlist"]
|
||||||
)
|
)
|
||||||
km.add("r", "replay.client @focus", ["flowlist", "flowview"])
|
km.add("r", "replay.client @focus", ["flowlist", "flowview"])
|
||||||
km.add("S", "console.command 'replay.server '", ["flowlist"])
|
km.add("S", "console.command replay.server ", ["flowlist"])
|
||||||
km.add("v", "set console_order_reversed=toggle", ["flowlist"])
|
km.add("v", "set console_order_reversed=toggle", ["flowlist"])
|
||||||
km.add("U", "flow.mark @all false", ["flowlist"])
|
km.add("U", "flow.mark @all false", ["flowlist"])
|
||||||
km.add("w", "console.command 'save.file @shown '", ["flowlist"])
|
km.add("w", "console.command save.file @shown ", ["flowlist"])
|
||||||
km.add("V", "flow.revert @focus", ["flowlist", "flowview"])
|
km.add("V", "flow.revert @focus", ["flowlist", "flowview"])
|
||||||
km.add("X", "flow.kill @focus", ["flowlist"])
|
km.add("X", "flow.kill @focus", ["flowlist"])
|
||||||
km.add("z", "view.remove @all", ["flowlist"])
|
km.add("z", "view.remove @all", ["flowlist"])
|
||||||
km.add("Z", "view.remove @hidden", ["flowlist"])
|
km.add("Z", "view.remove @hidden", ["flowlist"])
|
||||||
km.add("|", "console.command 'script.run @focus '", ["flowlist", "flowview"])
|
km.add("|", "console.command script.run @focus ", ["flowlist", "flowview"])
|
||||||
km.add("enter", "console.view.flow @focus", ["flowlist"])
|
km.add("enter", "console.view.flow @focus", ["flowlist"])
|
||||||
|
|
||||||
km.add(
|
km.add(
|
||||||
@ -302,7 +343,8 @@ def default_keymap(km):
|
|||||||
"console.edit.focus {choice}",
|
"console.edit.focus {choice}",
|
||||||
["flowview"]
|
["flowview"]
|
||||||
)
|
)
|
||||||
km.add("w", "console.command 'save.file @focus '", ["flowview"])
|
km.add("f", "view.setval.toggle @focus fullcontents", ["flowview"])
|
||||||
|
km.add("w", "console.command save.file @focus ", ["flowview"])
|
||||||
km.add(" ", "view.focus.next", ["flowview"])
|
km.add(" ", "view.focus.next", ["flowview"])
|
||||||
km.add(
|
km.add(
|
||||||
"o",
|
"o",
|
||||||
@ -318,6 +360,7 @@ def default_keymap(km):
|
|||||||
["flowview"]
|
["flowview"]
|
||||||
)
|
)
|
||||||
km.add("p", "view.focus.prev", ["flowview"])
|
km.add("p", "view.focus.prev", ["flowview"])
|
||||||
|
km.add("m", "console.flowview.mode.set", ["flowview"])
|
||||||
km.add(
|
km.add(
|
||||||
"z",
|
"z",
|
||||||
"console.choose \"Part\" request,response "
|
"console.choose \"Part\" request,response "
|
||||||
|
@ -260,6 +260,21 @@ def test_duplicate():
|
|||||||
assert v.focus.index == 2
|
assert v.focus.index == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_setgetval():
|
||||||
|
v = view.View()
|
||||||
|
with taddons.context():
|
||||||
|
f = tflow.tflow()
|
||||||
|
v.add([f])
|
||||||
|
v.setvalue([f], "key", "value")
|
||||||
|
assert v.getvalue(f, "key", "default") == "value"
|
||||||
|
assert v.getvalue(f, "unknow", "default") == "default"
|
||||||
|
|
||||||
|
v.setvalue_toggle([f], "key")
|
||||||
|
assert v.getvalue(f, "key", "default") == "true"
|
||||||
|
v.setvalue_toggle([f], "key")
|
||||||
|
assert v.getvalue(f, "key", "default") == "false"
|
||||||
|
|
||||||
|
|
||||||
def test_order():
|
def test_order():
|
||||||
v = view.View()
|
v = view.View()
|
||||||
with taddons.context() as tctx:
|
with taddons.context() as tctx:
|
||||||
|
Loading…
Reference in New Issue
Block a user