├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── freud ├── __init__.py ├── __main__.py ├── api │ ├── __init__.py │ ├── request.py │ └── response.py ├── key_bindings.py ├── model │ └── __init__.py ├── server_control │ ├── __init__.py │ ├── auth_dialog.py │ ├── call_editor.py │ ├── delete_server.py │ ├── headers_dialog.py │ └── server_dialog.py ├── ui │ ├── __init__.py │ ├── body_container.py │ ├── dialog.py │ ├── keys.py │ ├── root_container.py │ ├── server_container.py │ ├── sort.py │ ├── style.py │ └── text_buffers.py └── utils.py ├── img └── demo.gif ├── setup.py ├── tests ├── __init__.py ├── conftest.py ├── test_database.py ├── test_layout.py ├── test_requests.py └── utils.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.egg-info 3 | *.db 4 | .venv/ 5 | .tox/ 6 | .eggs/ 7 | config/*.ini 8 | .pytest_cache 9 | dist 10 | build 11 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.1.3 (unreleased) 2 | 3 | - No changes 4 | 5 | ## 0.1.2 (2018-08-09) 6 | 7 | - Add CHANGELOG.md 8 | - Install test dependencies through pip install -e '[dev]' 9 | - Install tox for python 3.5 and python 3.6 10 | - Add Makefile 11 | 12 | ## 0.1.1 (2018-08-08) 13 | 14 | - Initial release 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Stephen Martin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.md 2 | include LICENSE 3 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: install install-dev test test-all flake8 distclean 2 | 3 | install: 4 | python setup.py install 5 | 6 | install-dev: 7 | pip install -q -e .[dev] 8 | 9 | test: install-dev 10 | pytest 11 | 12 | test-all: install-dev 13 | tox 14 | 15 | flake8: install-dev 16 | flake8 freud tests 17 | 18 | distclean: 19 | rm -fr *.egg *.egg-info/ dist/ build/ 20 | 21 | publish: 22 | python setup.py sdist bdist_wheel 23 | twine upload dist/* 24 | distclean 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Freud terminal gif](img/demo.gif) 2 | 3 | Freud is a TUI API endpoint analyzer utilizing Python Prompt Toolkit and 4 | Requests. It allows creating and saving request headers, authentication (basic 5 | and digest), and body using both integrated forms and your native 6 | editor. 7 | 8 | Currently, it is designed and tested for receiving JSON, XML, and HTML 9 | responses, but more can be added later as needed. 10 | 11 | ## Installation 12 | 13 | > You can install through PyPI... 14 | 15 | ```shell 16 | python -m venv .venv 17 | . .venv/bin/activate 18 | pip install freud 19 | ``` 20 | 21 | > or from sources 22 | 23 | ``` 24 | git clone https://github.com/stloma/freud 25 | cd freud 26 | python -m venv .venv 27 | . .venv/bin/activate 28 | python setup.py install 29 | ``` 30 | 31 | ## Keys 32 | 33 | > Key shortcuts depend on which window you are in. There are 4 windows: server 34 | > list (left window), response headers (top right), response body (middle 35 | > right), and server summary (bottom). 36 | 37 | * Server list/left window 38 | - New server: `n` 39 | - Select server: `enter` 40 | - Edit selected server: `e` 41 | - Edit authentication: `a` 42 | - Edit body: `b` 43 | - Send request for selected server: `r` 44 | - Delete selected server: `d` 45 | - Sort servers: `s` 46 | - Top/bottom of server list: `gg/G` 47 | 48 | * Header window, Response body window, Server summary window 49 | - `h/j/k/l` Vi keybindings for movement 50 | - `/` Search text 51 | - `o`: Open response body in external editor 52 | 53 | * General Navigation 54 | - Quit: `Ctrl+c` 55 | - Key Binding Quick Reference: `Ctrl+f` 56 | - Next window: `Tab` 57 | - Previous window: `Shift+Tab` 58 | 59 | 60 | ## Advanced uses 61 | 62 | #### More keys 63 | * Copy/Paste: `Shift+Click` 64 | 65 | #### Changing default configuration 66 | 67 | * Settings file: `config/freud.ini` 68 | 69 | ## Roadmap 70 | 71 | Freud is still in development, but should work well for most use 72 | cases. 73 | 74 | Currently, it is designed to handle JSON, XML, and HTML responses; I 75 | haven't tested others. If you would like it to handle something specific, you 76 | can either submit a PR or create an issue and I'll add it! 77 | 78 | #### Goals 79 | 80 | * Add more authentication types (e.g., OAuth, Bearer Token, etc.) 81 | * Handle more Content-Types (MIME types) 82 | * Cookie handling 83 | * Add capability to organize requests under categories 84 | * Increase testing coverage 85 | 86 | ## Requirements 87 | 88 | * Python: 3.5+ 89 | * Python Prompt Toolkit, Requests, Pygments 90 | * set \$EDITOR environment variable 91 | - bash/zsh: `export EDITOR=$(which vim)` 92 | 93 | ## Testing 94 | 95 | ``` 96 | pip install -e '.[dev]' 97 | pytest 98 | ``` 99 | -------------------------------------------------------------------------------- /freud/__init__.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | import os 3 | import sys 4 | import pkg_resources 5 | 6 | folder = 'config/' 7 | 8 | config_file = os.path.join(folder, 'freud.ini') 9 | 10 | __version__ = pkg_resources.get_distribution('freud').version 11 | 12 | DEFAULT_SETTINGS = ''' 13 | [API] 14 | # Options: yes, no 15 | allow_redirects = yes 16 | 17 | [LAYOUT] 18 | # Width of sidebar that contains server names 19 | server_width = 15 20 | # Height of container that holds headers 21 | header_height = 10 22 | # Height of container that shows server summary 23 | summary_height = 10 24 | 25 | [KEYS] 26 | new_server = n 27 | edit_server = e 28 | send_request = r 29 | edit_authentication = a 30 | edit_headers = h 31 | edit_body = b 32 | delete_server = d 33 | open_response_body = o 34 | sort_servers = s 35 | key_quick_ref = c-f 36 | 37 | [DB] 38 | filename = requests.db 39 | 40 | [JSON] 41 | indentation = 2 42 | 43 | [SORT_BY] 44 | # Column options: name, timestamp, url, method, body, authtype, authuser, 45 | # authpass, headers 46 | # Order options: asc, desc 47 | column = timestamp 48 | order = asc 49 | 50 | [STYLE] 51 | # More styles here: 52 | # https://bitbucket.org/birkenfeld/pygments-main/src/stable/pygments/styles 53 | theme = freud 54 | separator_line_fg = gray 55 | # separator_line_bg = black 56 | ''' 57 | 58 | if not os.path.exists(folder): 59 | os.makedirs(folder) 60 | 61 | if not os.path.exists(config_file): 62 | with open(config_file, 'w') as cfile: 63 | cfile.write(DEFAULT_SETTINGS) 64 | 65 | config = configparser.ConfigParser() 66 | 67 | config.read(config_file) 68 | 69 | if hasattr(sys, '_called_from_test'): 70 | config['DB']['filename'] = 'delete_freud_test_database.db' 71 | 72 | redirects = config['API']['allow_redirects'] 73 | if redirects.lower() == 'no': 74 | ALLOW_REDIRECTS = False 75 | else: 76 | ALLOW_REDIRECTS = True 77 | 78 | KEYS = config['KEYS'] 79 | 80 | SERVER_WIDTH = int(config['LAYOUT']['server_width']) 81 | 82 | HEADER_HEIGHT = int(config['LAYOUT']['header_height']) 83 | 84 | SUMMARY_HEIGHT = int(config['LAYOUT']['summary_height']) 85 | 86 | STYLE = config['STYLE'] 87 | 88 | DB_FILE = config['DB']['filename'] 89 | 90 | SORT_BY = config['SORT_BY'] 91 | 92 | INDENTATION = int(config['JSON']['indentation']) 93 | -------------------------------------------------------------------------------- /freud/__main__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from prompt_toolkit.application import Application 4 | from prompt_toolkit.layout.layout import Layout 5 | from prompt_toolkit.enums import EditingMode 6 | from prompt_toolkit.eventloop import use_asyncio_event_loop 7 | 8 | from freud.key_bindings import kb 9 | from freud.ui.server_container import servers 10 | from freud.ui.root_container import root_container 11 | from freud.ui.style import style 12 | from freud.utils import on_startup 13 | 14 | 15 | use_asyncio_event_loop() 16 | 17 | 18 | def main(): 19 | app = Application( 20 | layout=Layout(root_container.create(), 21 | focused_element=servers.content), 22 | key_bindings=kb, 23 | editing_mode=EditingMode.VI, 24 | style=style, 25 | mouse_support=True, 26 | full_screen=True, 27 | after_render=on_startup 28 | ) 29 | 30 | asyncio.get_event_loop().run_until_complete( 31 | app.run_async().to_asyncio_future() 32 | ) 33 | -------------------------------------------------------------------------------- /freud/api/__init__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from prompt_toolkit.filters.utils import to_filter 4 | 5 | from freud.ui.dialog import LoadingDialog, ErrorDialog 6 | from freud.utils import ButtonManager 7 | from freud.ui.text_buffers import ( 8 | response_buffer, header_buffer) 9 | from freud.api.request import request_handler 10 | 11 | 12 | def send_request(event): 13 | """ Prepare and send request, then display the response """ 14 | 15 | name = ButtonManager.current_button 16 | 17 | # Display loading dialog 18 | loading_dialog = LoadingDialog( 19 | event=event, 20 | title='Loading', 21 | text='Connecting to {}...'.format(name) 22 | ) 23 | 24 | async def send_request_async(): 25 | # Cede control of event loop. See: 26 | # github.com/python/asyncio/issues/284ield 27 | await asyncio.sleep(0) 28 | 29 | # Build and send request to server 30 | result = await request_handler(name) 31 | 32 | loading_dialog.close_dialog() 33 | 34 | errors = result.get('errors') 35 | 36 | if errors: 37 | error_type, error_value = list(errors.items())[0] 38 | 39 | header_buffer.read_only = to_filter(False) 40 | header_buffer.text = error_type 41 | header_buffer.read_only = to_filter(True) 42 | 43 | response_buffer.read_only = to_filter(False) 44 | response_buffer.text = '' 45 | response_buffer.read_only = to_filter(True) 46 | 47 | # LoadingDialog is not removed if called a second time. Invalidate 48 | # method sends a repaint trigger 49 | event.app.invalidate() 50 | 51 | return ErrorDialog(event, title=error_type, 52 | text=error_value) 53 | 54 | headers, response_body = result.get('response') 55 | 56 | header_buffer.read_only = to_filter(False) 57 | header_buffer.text = headers 58 | header_buffer.read_only = to_filter(True) 59 | 60 | response_buffer.read_only = to_filter(False) 61 | response_buffer.text = response_body 62 | response_buffer.read_only = to_filter(True) 63 | 64 | asyncio.ensure_future(send_request_async()) 65 | -------------------------------------------------------------------------------- /freud/api/request.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import json 3 | 4 | from freud.model import db 5 | from freud.api.response import response_handler 6 | from freud import ALLOW_REDIRECTS 7 | 8 | 9 | def session_builder(req): 10 | session = requests.Session() 11 | request = requests.Request(req.method, req.url) 12 | 13 | headers = { 14 | 'user-agent': 'Freud', 15 | 'accept-encoding': 'gzip, deflate', 16 | 'accept': '*/*', 17 | 'connection': 'keep-alive', 18 | 'charset': 'UTF-8', 19 | } 20 | request.headers = headers 21 | 22 | if req.auth: 23 | 24 | auth = json.loads(req.auth) 25 | authtype = auth.get('type') 26 | authuser = auth.get('user') 27 | authpass = auth.get('password') 28 | 29 | if authtype == 'basic': 30 | request.auth = (authuser, authpass) 31 | elif authtype == 'digest': 32 | request.auth = requests.auth.HTTPDigestAuth( 33 | authuser, authpass) 34 | 35 | if req.headers: 36 | headers = json.loads(req.headers) 37 | request.headers.update(headers) 38 | 39 | for header in request.headers: 40 | if header.lower() == 'content-type': 41 | request.content_type = request.headers[header] 42 | 43 | request.data = req.body 44 | 45 | response = None 46 | errors = None 47 | 48 | try: 49 | response = session.send( 50 | request.prepare(), allow_redirects=ALLOW_REDIRECTS) 51 | 52 | except requests.exceptions.ConnectionError as e: 53 | errors = {'Connection error': str(e)} 54 | 55 | except requests.exceptions.RequestException as e: 56 | errors = {'Request Exception': str(e)} 57 | 58 | return [response, errors] 59 | 60 | 61 | async def request_handler(name): 62 | 63 | # Get selected server url 64 | server = db.fetch_one(name=name) 65 | 66 | response, errors = session_builder(server) 67 | 68 | if errors: 69 | return {'errors': errors} 70 | 71 | headers, response_body = response_handler(response) 72 | 73 | return {'response': [headers, response_body]} 74 | -------------------------------------------------------------------------------- /freud/api/response.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import json 3 | import xml 4 | 5 | from prompt_toolkit.lexers import PygmentsLexer 6 | from pygments.lexers import HtmlLexer, JsonLexer, XmlLexer 7 | 8 | from freud.ui.text_buffers import response_box 9 | from freud import INDENTATION 10 | 11 | 12 | def response_handler(response): 13 | # Format content for header_buffer 14 | # 15 | status_code_lookup = requests.status_codes._codes 16 | status_description = status_code_lookup[response.status_code][0] 17 | status_description = status_description[0].upper( 18 | ) + status_description[1:] 19 | 20 | seconds = response.elapsed.total_seconds() 21 | elapsed = str('{}ms'.format(round(seconds * 1000, 2))) 22 | 23 | # if elapsed time is 1 second or greater, display seconds instead of ms 24 | if (len(elapsed.split('.')[0])) > 3: 25 | elapsed = str('{}s'.format(round(seconds, 2))) 26 | 27 | status_code = ['{} {}'.format( 28 | response.status_code, status_description)] 29 | 30 | headers = response.headers 31 | 32 | headers_string = '' 33 | for k, v in headers.items(): 34 | headers_string += '{}: {}\n'.format(k, v) 35 | headers_string = headers_string.rstrip('\n') 36 | 37 | headers = '{} | {}\n{}'.format(status_code, elapsed, headers_string) 38 | 39 | response_body = content_type_handler(response) 40 | 41 | # Format content for response_buffer 42 | # 43 | return headers, response_body 44 | 45 | 46 | def content_type_handler(response): 47 | 48 | content_type = response.headers.get('Content-Type') 49 | response_body = None 50 | if content_type: 51 | 52 | if content_type.startswith('application/json'): 53 | try: 54 | response_body = json.dumps(response.json(), indent=INDENTATION) 55 | response_box.buffer_control.lexer = PygmentsLexer( 56 | JsonLexer) 57 | except json.decoder.JSONDecodeError as e: 58 | response_body = 'JSON Error: {}\n\n'.format(str(e)) 59 | response_body += response.text 60 | 61 | elif content_type.startswith('text/html'): 62 | response_body = response.text 63 | response_box.buffer_control.lexer = PygmentsLexer( 64 | HtmlLexer) 65 | 66 | elif content_type.startswith('text/xml'): 67 | try: 68 | xml_data = xml.dom.minidom.parseString(response.text) 69 | response_body = xml_data.toprettyxml(indent=' ' * 2) 70 | response_box.buffer_control.lexer = PygmentsLexer( 71 | XmlLexer) 72 | except xml.parsers.expat.ExpatError as e: 73 | response_body = 'XML Error: {}\n\n'.format(str(e)) 74 | response_body += response.text 75 | 76 | if not response_body: 77 | response_body = response.text 78 | 79 | return response_body 80 | -------------------------------------------------------------------------------- /freud/key_bindings.py: -------------------------------------------------------------------------------- 1 | from prompt_toolkit.key_binding import KeyBindings 2 | from prompt_toolkit.filters import Condition 3 | 4 | from freud.server_control.delete_server import DeleteDialog 5 | from freud.server_control.headers_dialog import HeadersDialog 6 | from freud.server_control.server_dialog import ServerDialog 7 | from freud.utils import ButtonManager 8 | from freud.ui.text_buffers import ( 9 | response_buffer, header_buffer, summary_buffer) 10 | from freud.server_control.call_editor import ( 11 | update_body, open_response_in_editor) 12 | from freud.server_control.auth_dialog import AuthSelector 13 | from freud.ui.keys import KeyQuickRef 14 | from freud.ui.sort import SortDialog 15 | from freud.api import send_request 16 | from freud import KEYS 17 | 18 | 19 | kb = KeyBindings() 20 | server_kb = KeyBindings() 21 | response_kb = KeyBindings() 22 | header_kb = KeyBindings() 23 | summary_kb = KeyBindings() 24 | completion = KeyBindings() 25 | 26 | 27 | @Condition 28 | def is_button(): 29 | """ Filter when there are no buttons """ 30 | 31 | return len(ButtonManager.buttons) > 0 32 | 33 | 34 | # Global Keys 35 | # 36 | 37 | @kb.add('c-c', eager=True) 38 | def quit_app(event): 39 | event.app.exit() 40 | 41 | 42 | @server_kb.add('c-f') 43 | @response_kb.add('c-f') 44 | def keys_quick_reference(event): 45 | KeyQuickRef(event) 46 | 47 | 48 | # Navigation Keys 49 | # 50 | 51 | @server_kb.add('tab', filter=is_button) 52 | def go_to_header_from_server(event): 53 | event.app.layout.focus(header_buffer) 54 | 55 | 56 | @server_kb.add('s-tab', filter=is_button) 57 | def go_to_summary_from_servers(event): 58 | event.app.layout.focus(summary_buffer) 59 | 60 | 61 | @header_kb.add('tab', filter=is_button) 62 | def go_to_response_from_header(event): 63 | event.app.layout.focus(response_buffer) 64 | 65 | 66 | @header_kb.add('s-tab', filter=is_button) 67 | def go_to_server_from_header(event): 68 | event.app.layout.focus(ButtonManager.prev_button) 69 | 70 | 71 | @response_kb.add('tab', filter=is_button) 72 | def go_to_summary_from_response(event): 73 | event.app.layout.focus(summary_buffer) 74 | 75 | 76 | @response_kb.add('s-tab', filter=is_button) 77 | def go_to_headers_from_resposne(event): 78 | event.app.layout.focus(header_buffer) 79 | 80 | 81 | @summary_kb.add('tab') 82 | def go_to_servers_from_summary(event, filter=is_button): 83 | event.app.layout.focus(ButtonManager.prev_button) 84 | 85 | 86 | @summary_kb.add('s-tab', filter=is_button) 87 | def go_to_response_from_summary(event): 88 | event.app.layout.focus(response_buffer) 89 | 90 | 91 | @response_kb.add('h', filter=is_button) 92 | @response_kb.add('left', filter=is_button) 93 | def go_to_servers(event): 94 | if (len(ButtonManager.buttons) > 0): 95 | event.app.layout.focus(ButtonManager.prev_button) 96 | 97 | 98 | @server_kb.add('l', filter=is_button) 99 | @server_kb.add('right', filter=is_button) 100 | def go_to_response(event): 101 | event.app.layout.focus(response_buffer) 102 | 103 | 104 | @server_kb.add('g', filter=is_button) 105 | def top_of_list(event): 106 | """ Adds vi binding to jump to top of server list """ 107 | 108 | buttons = ButtonManager.buttons 109 | 110 | event.app.layout.focus(buttons[0]) 111 | ButtonManager.prev_button = buttons[0] 112 | 113 | 114 | @server_kb.add('G', filter=is_button) 115 | def bottom_of_list(event): 116 | """ Adds vi binding to jump to bottom of server list """ 117 | 118 | buttons = ButtonManager.buttons 119 | 120 | event.app.layout.focus(buttons[-1]) 121 | ButtonManager.prev_button = buttons[-1] 122 | 123 | 124 | @server_kb.add('j', filter=is_button) 125 | @server_kb.add('down') 126 | def down_button(event): 127 | """ Scroll down list of servers in the server_container """ 128 | 129 | buttons = ButtonManager.buttons 130 | 131 | current_window = event.app.layout.current_window 132 | idx = buttons.index(current_window) 133 | 134 | try: 135 | event.app.layout.focus(buttons[idx + 1]) 136 | ButtonManager.prev_button = buttons[idx + 1] 137 | except IndexError: 138 | # If we're at the button item, loop to first item 139 | 140 | event.app.layout.focus(buttons[0]) 141 | ButtonManager.prev_button = buttons[0] 142 | 143 | 144 | @server_kb.add('k', filter=is_button) 145 | @server_kb.add('up') 146 | def up_button(event): 147 | """ Scroll up list of servers in the server_container """ 148 | 149 | buttons = ButtonManager.buttons 150 | 151 | current_window = event.app.layout.current_window 152 | idx = buttons.index(current_window) 153 | 154 | try: 155 | event.app.layout.focus(buttons[idx - 1]) 156 | ButtonManager.prev_button = buttons[idx - 1] 157 | except IndexError: 158 | # If we're at the top item, loop to last item 159 | 160 | event.app.layout.focus(buttons[-1]) 161 | ButtonManager.prev_button = buttons[-1] 162 | 163 | 164 | # Server control keys 165 | # 166 | @server_kb.add(KEYS['open_response_body'], filter=is_button) 167 | @response_kb.add(KEYS['open_response_body'], filter=is_button) 168 | def open_response(event): 169 | open_response_in_editor(event) 170 | 171 | 172 | @server_kb.add(KEYS['sort_servers'], filter=is_button) 173 | def sort(event): 174 | SortDialog(event) 175 | 176 | 177 | @server_kb.add(KEYS['new_server']) 178 | def new_server(event): 179 | ServerDialog(event, create_server=True) 180 | 181 | 182 | @server_kb.add(KEYS['edit_headers'], filter=is_button) 183 | def new_headers(event): 184 | HeadersDialog(event) 185 | 186 | 187 | @server_kb.add(KEYS['edit_server'], filter=is_button) 188 | def edit(event): 189 | ServerDialog(event) 190 | 191 | 192 | @server_kb.add(KEYS['edit_authentication'], filter=is_button) 193 | def edit_authentication(event): 194 | AuthSelector(event) 195 | 196 | 197 | @server_kb.add(KEYS['edit_body'], filter=is_button) 198 | def edit_body(event): 199 | update_body(event) 200 | 201 | 202 | @server_kb.add(KEYS['delete_server'], filter=is_button) 203 | def rm_server(event): 204 | DeleteDialog(event) 205 | 206 | 207 | @server_kb.add(KEYS['send_request'], filter=is_button) 208 | def request(event): 209 | send_request(event) 210 | -------------------------------------------------------------------------------- /freud/model/__init__.py: -------------------------------------------------------------------------------- 1 | import sqlite3 2 | import os 3 | import datetime 4 | from collections import namedtuple 5 | 6 | from freud import DB_FILE, SORT_BY 7 | 8 | basedir = os.path.abspath(os.path.dirname(__file__)) 9 | 10 | 11 | class Db: 12 | 13 | def __init__(self, database=DB_FILE): 14 | 15 | self.database = database 16 | self.connect() 17 | self.close() 18 | 19 | def namedtuple_factory(self, cursor, row): 20 | """ Returns namedtuple results, named with column names """ 21 | 22 | fields = [col[0] for col in cursor.description] 23 | Row = namedtuple('Row', fields) 24 | return Row(*row) 25 | 26 | def connect(self): 27 | 28 | self.conn = sqlite3.connect( 29 | os.path.join(basedir, self.database), 30 | detect_types=sqlite3.PARSE_COLNAMES 31 | ) 32 | self.conn.row_factory = self.namedtuple_factory 33 | self.cursor = self.conn.cursor() 34 | 35 | self.cursor.execute('''CREATE TABLE IF NOT EXISTS requests ( 36 | name text not null unique, 37 | timestamp timestamp, 38 | url text not null, 39 | method text not null, 40 | body text, 41 | auth text, 42 | headers text 43 | ) 44 | ''') 45 | 46 | def close(self): 47 | 48 | self.conn.commit() 49 | self.conn.close() 50 | 51 | def fetch_all(self, sort_by=None, order=None): 52 | """ Returns all rows """ 53 | 54 | sort_by = sort_by if sort_by else SORT_BY['column'] 55 | order = order if order else SORT_BY['order'] 56 | 57 | self.connect() 58 | 59 | if sort_by and order: 60 | self.cursor.execute( 61 | 'SELECT * FROM requests ORDER BY {} {}'.format(sort_by, order)) 62 | elif sort_by: 63 | self.cursor.execute( 64 | 'SELECT * FROM requests ORDER BY {sort_by}'.format(sort_by)) 65 | else: 66 | self.cursor.execute('SELECT * FROM requests') 67 | 68 | rows = self.cursor.fetchall() 69 | 70 | self.close() 71 | 72 | return rows 73 | 74 | def fetch_one(self, rowid=None, name=None): 75 | 76 | self.connect() 77 | 78 | if rowid: 79 | self.cursor.execute('''SELECT * FROM requests WHERE rowid = ?''', 80 | (rowid,)) 81 | else: 82 | self.cursor.execute(''' 83 | SELECT rowid, * FROM requests WHERE name = ?''', 84 | (name,)) 85 | 86 | result = self.cursor.fetchone() 87 | 88 | if result: 89 | return result 90 | 91 | return False 92 | 93 | self.close() 94 | 95 | def add_one(self, values): 96 | 97 | self.connect() 98 | 99 | try: 100 | with self.conn: 101 | self.conn.execute('''INSERT INTO requests 102 | ( 103 | name, url, method, timestamp, auth, 104 | body, headers) 105 | VALUES (?, ?, ?, ?, ?, ?, ?)''', 106 | ( 107 | values['name'], 108 | values['url'], 109 | values['method'], 110 | datetime.datetime.now(), 111 | values.get('auth'), 112 | values.get('body'), 113 | values.get('headers') 114 | )) 115 | except sqlite3.IntegrityError as e: 116 | return {'errors': 'sqlite error: {}'.format(e.args[0])} 117 | 118 | except KeyError as e: 119 | return {'errors': 'missing column error: {}'.format(e.args[0])} 120 | 121 | finally: 122 | self.close() 123 | 124 | return {'success': True} 125 | 126 | def delete_one(self, name): 127 | 128 | self.connect() 129 | self.cursor.execute('''DELETE FROM requests WHERE name = ?''', 130 | (name,)) 131 | 132 | self.close() 133 | 134 | def delete_all(self): 135 | """ Used for testing """ 136 | 137 | self.connect() 138 | self.cursor.execute('''DELETE FROM requests''') 139 | 140 | self.close() 141 | 142 | def update_one(self, values=None, rowid=None): 143 | """ Updates one row by rowid if supplied, else by name """ 144 | 145 | self.connect() 146 | 147 | if rowid: 148 | # If the rowid is set, we could be changing the name 149 | name = values.get('name') 150 | url = values.get('url') 151 | method = values.get('method') 152 | 153 | if not all([name, url, method]): 154 | return {'errors': 'Name, url, and method are required'} 155 | 156 | try: 157 | with self.conn: 158 | self.conn.execute('''UPDATE requests SET 159 | url=?, method=?, name=? where rowid=?''', 160 | (url, method, name, rowid)) 161 | 162 | except sqlite3.IntegrityError as e: 163 | return {'errors': 'sqlite error: {}'.format(e.args[0])} 164 | 165 | except KeyError as e: 166 | return { 167 | 'errors': 'missing column error: {}'.format(e.args[0])} 168 | 169 | finally: 170 | self.close() 171 | 172 | else: 173 | # Changes all fields passed in through values 174 | name = values.get('name') 175 | 176 | result = self.fetch_one(name=name) 177 | 178 | body = values.get('body', result.body) 179 | headers = values.get('headers', result.headers) 180 | auth = values.get('auth', result.auth) 181 | 182 | self.connect() 183 | 184 | try: 185 | 186 | with self.conn: 187 | self.conn.execute(''' 188 | UPDATE requests SET 189 | body=?, headers=?, auth=? 190 | where name=?''', 191 | (body, headers, auth, 192 | name 193 | ) 194 | ) 195 | 196 | except sqlite3.IntegrityError as e: 197 | return {'errors': 'sqlite error: {}'.format(e.args[0])} 198 | 199 | except KeyError as e: 200 | return {'errors': 'missing column error: {}'.format(e.args[0])} 201 | 202 | finally: 203 | self.close() 204 | 205 | return {'success': True} 206 | 207 | 208 | db = Db() 209 | -------------------------------------------------------------------------------- /freud/server_control/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stloma/freud/ebc16ecf7bd39ad365108e1cce4c9e8a0ddc69cd/freud/server_control/__init__.py -------------------------------------------------------------------------------- /freud/server_control/auth_dialog.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from prompt_toolkit.layout.dimension import D 4 | from prompt_toolkit.document import Document 5 | from prompt_toolkit.layout import Float 6 | from prompt_toolkit.widgets import Button, TextArea, Dialog, Label 7 | from prompt_toolkit.layout.containers import HSplit, Window 8 | from prompt_toolkit.widgets import RadioList 9 | from prompt_toolkit.key_binding import KeyBindings 10 | 11 | from freud.ui.root_container import root_container 12 | from freud.ui.dialog import ErrorDialog 13 | from freud.model import db 14 | from freud.utils import ButtonManager, select_item 15 | 16 | 17 | class DeleteConfirmation: 18 | """ Confirm deletion of authentication information """ 19 | 20 | def __init__(self, event, dialog): 21 | 22 | def ok_handler(): 23 | root_container.floats.pop() 24 | db.update_one(values={'name': name, 25 | 'auth': None}) 26 | event.app.layout.focus(ButtonManager.prev_button) 27 | select_item(event) 28 | 29 | def cancel_handler(): 30 | root_container.floats.pop() 31 | root_container.floats.append(self.auth_float) 32 | event.app.layout.focus(dialog) 33 | 34 | ok_button = Button(text='OK', handler=ok_handler) 35 | cancel_button = Button(text='Cancel', handler=cancel_handler) 36 | 37 | name = ButtonManager.current_button 38 | 39 | self.dialog = Dialog( 40 | title='Delete confirmation', 41 | body=Label( 42 | text='Are you sure you want to delete authentication for {}?' 43 | .format(name) 44 | ), 45 | buttons=[cancel_button, ok_button], 46 | width=D(preferred=80), 47 | with_background=True 48 | ) 49 | 50 | self.auth_float = root_container.floats.pop() 51 | root_container.floats.append(Float(self.dialog)) 52 | event.app.layout.focus(self.dialog) 53 | 54 | 55 | class AuthSelector: 56 | """ Provide selection of authentication types. If the server already has 57 | authentication, skip selecting and open the respective dialog box """ 58 | 59 | def __init__(self, event): 60 | 61 | def dialog_opener(authtype, auth={}): 62 | 63 | if authtype == 'basic': 64 | BasicAuthDialog(event, auth) 65 | elif authtype == 'digest': 66 | DigestAuthDialog(event, auth) 67 | 68 | def ok_handler(): 69 | 70 | root_container.floats.pop() 71 | 72 | authtype = self.radio_list.current_value['authtype'] 73 | dialog_opener(authtype) 74 | 75 | def cancel_handler(): 76 | root_container.floats.pop() 77 | root_container.float_container.key_bindings = None 78 | 79 | event.app.layout.focus(ButtonManager.prev_button) 80 | 81 | kb = KeyBindings() 82 | 83 | server = db.fetch_one(name=ButtonManager.current_button) 84 | 85 | if server.auth: 86 | 87 | auth = json.loads(server.auth) 88 | auth_type = auth.get('type') 89 | dialog_opener(auth_type, auth=auth) 90 | 91 | else: 92 | 93 | ok_button = Button(text='OK', handler=ok_handler) 94 | cancel_button = Button(text='Cancel', handler=cancel_handler) 95 | 96 | self.radio_list = RadioList(values=[ 97 | ({'authtype': 'basic'}, 'Basic'), 98 | ({'authtype': 'digest'}, 'Digest') 99 | ]) 100 | 101 | kb = self.radio_list.control.key_bindings 102 | 103 | @kb.add('j') 104 | def down(event): 105 | self.radio_list._selected_index = min( 106 | len(self.radio_list.values) - 1, 107 | self.radio_list._selected_index + 1 108 | ) 109 | 110 | @kb.add('k') 111 | def up(event): 112 | self.radio_list._selected_index = max( 113 | 0, self.radio_list._selected_index - 1) 114 | 115 | @kb.add('g', 'g') 116 | def top(event): 117 | self.radio_list._selected_index = 0 118 | 119 | @kb.add('G') 120 | def bottom(event): 121 | self.radio_list._selected_index = len( 122 | self.radio_list.values) - 1 123 | 124 | self.dialog = Dialog( 125 | title='Select auth type', 126 | body=self.radio_list, 127 | buttons=[ok_button, cancel_button], 128 | width=D(preferred=80), 129 | with_background=True, 130 | modal=True) 131 | 132 | root_container.float_container.key_bindings = kb 133 | 134 | root_container.floats.append(Float(self.dialog)) 135 | event.app.layout.focus(self.dialog) 136 | 137 | 138 | class AuthDialog: 139 | """ Parent dialog for authentication """ 140 | 141 | def __init__(self): 142 | 143 | self.name = ButtonManager.current_button 144 | 145 | self.ok_button = Button(text='OK', handler=self.ok_handler) 146 | 147 | self.cancel_button = Button( 148 | text='Cancel', handler=self.cancel_handler) 149 | 150 | self.delete_button = Button( 151 | text='Delete', handler=self.delete_handler) 152 | 153 | self.authuser = TextArea( 154 | multiline=False 155 | ) 156 | 157 | self.authpass_one = TextArea( 158 | multiline=False, 159 | password=True 160 | ) 161 | 162 | self.authpass_two = TextArea( 163 | multiline=False, 164 | password=True 165 | ) 166 | 167 | def ok_handler(self): 168 | authuser = self.authuser.text 169 | authpass = self.authpass_one.text 170 | 171 | all_fields = all([authuser, authpass]) 172 | empty_fields = not any([authuser, authpass]) 173 | 174 | if authpass != self.authpass_two.text: 175 | ErrorDialog(self.event, title='Password Error', 176 | text='Passwords do not match, please try again') 177 | 178 | elif all_fields or empty_fields: 179 | 180 | if all_fields: 181 | result = db.update_one({ 182 | 'name': self.name, 183 | 'auth': json.dumps({ 184 | 'user': authuser, 185 | 'password': authpass, 186 | 'type': self.authtype 187 | }) 188 | }) 189 | 190 | elif empty_fields: 191 | result = db.update_one(values={'name': self.name, 192 | 'auth': None}) 193 | 194 | if result.get('success'): 195 | root_container.floats.pop() 196 | self.event.app.layout.focus(ButtonManager.prev_button) 197 | select_item(self.event) 198 | 199 | else: 200 | ErrorDialog(self.event, title='Add/edit server error', 201 | text=result.get('errors')) 202 | 203 | else: 204 | ErrorDialog(self.event, title='Input Error', 205 | text='Missing one or more fields.') 206 | 207 | def delete_handler(self): 208 | DeleteConfirmation(self.event, self.dialog) 209 | 210 | def cancel_handler(self): 211 | root_container.floats.pop() 212 | self.event.app.layout.focus(ButtonManager.prev_button) 213 | 214 | 215 | class BasicAuthDialog(AuthDialog): 216 | def __init__(self, event, auth): 217 | 218 | super().__init__() 219 | 220 | self.event = event 221 | self.authtype = 'basic' 222 | 223 | authuser = auth.get('user', '') 224 | authpass = auth.get('password', '') 225 | 226 | self.authuser.text = authuser 227 | self.authuser.buffer.document = Document(authuser, len(authuser)) 228 | 229 | self.authpass_one.text = authpass 230 | self.authpass_one.buffer.document = Document(authpass, len(authpass)) 231 | 232 | self.authpass_two.text = authpass 233 | self.authpass_two.buffer.document = Document(authpass, len(authpass)) 234 | 235 | self.dialog = Dialog( 236 | title='Basic Authentication', 237 | body=HSplit([ 238 | Label(text='Username:\n'), 239 | self.authuser, 240 | Window(height=1, char=' '), 241 | Label(text='Password'), 242 | self.authpass_one, 243 | Window(height=1, char=' '), 244 | Label(text='Retype password'), 245 | self.authpass_two 246 | ]), 247 | buttons=[self.ok_button, self.cancel_button, self.delete_button], 248 | width=D(preferred=80), 249 | with_background=True, 250 | modal=True) 251 | 252 | root_container.floats.append(Float(self.dialog)) 253 | event.app.layout.focus(self.dialog) 254 | 255 | 256 | class DigestAuthDialog(AuthDialog): 257 | def __init__(self, event, auth): 258 | 259 | super().__init__() 260 | 261 | self.event = event 262 | self.authtype = 'digest' 263 | 264 | authuser = auth.get('user', '') 265 | authpass = auth.get('password', '') 266 | 267 | self.authuser.text = authuser 268 | self.authuser.buffer.document = Document(authuser, len(authuser)) 269 | 270 | self.authpass_one.text = authpass 271 | self.authpass_one.buffer.document = Document(authpass, len(authpass)) 272 | 273 | self.authpass_two.text = authpass 274 | self.authpass_two.buffer.document = Document(authpass, len(authpass)) 275 | 276 | self.dialog = Dialog( 277 | title='Digest Authentication', 278 | body=HSplit([ 279 | Label(text='Username:\n'), 280 | self.authuser, 281 | Window(height=1, char=' '), 282 | Label(text='Password'), 283 | self.authpass_one, 284 | Window(height=1, char=' '), 285 | Label(text='Retype password'), 286 | self.authpass_two 287 | ]), 288 | buttons=[self.ok_button, self.cancel_button, self.delete_button], 289 | width=D(preferred=80), 290 | with_background=True, 291 | modal=True) 292 | 293 | root_container.floats.append(Float(self.dialog)) 294 | event.app.layout.focus(self.dialog) 295 | -------------------------------------------------------------------------------- /freud/server_control/call_editor.py: -------------------------------------------------------------------------------- 1 | import tempfile 2 | from subprocess import call 3 | import os 4 | 5 | from freud.utils import select_item 6 | from freud.ui.text_buffers import response_buffer, header_buffer 7 | from freud.ui.dialog import ErrorDialog 8 | from freud.model import db 9 | from freud.utils import ButtonManager 10 | 11 | 12 | def update_body(event): 13 | """Uses external editor to create/update request body.""" 14 | 15 | EDITOR = os.environ.get('EDITOR', None) 16 | 17 | if not EDITOR: 18 | return ErrorDialog( 19 | event, title='Error', 20 | text='Please set your $EDITOR environement variable' 21 | ) 22 | 23 | tf = tempfile.NamedTemporaryFile() 24 | 25 | name = ButtonManager.current_button 26 | 27 | result = db.fetch_one(name=name) 28 | 29 | if result.body: 30 | with open(tf.name, 'w') as fout: 31 | fout.write(result.body) 32 | 33 | j = "+'set ft=json" 34 | 35 | call([EDITOR, j, tf.name]) 36 | 37 | with open(tf.name, 'r') as fin: 38 | body = fin.read() 39 | 40 | db.update_one(values={'name': name, 41 | 'body': body}) 42 | 43 | event.app.reset() 44 | select_item(event) 45 | 46 | 47 | def open_response_in_editor(event): 48 | 49 | EDITOR = os.environ.get('EDITOR', None) 50 | 51 | if not EDITOR: 52 | return ErrorDialog( 53 | event, title='Error', 54 | text='Please set your $EDITOR environement variable' 55 | ) 56 | 57 | tf = tempfile.NamedTemporaryFile() 58 | 59 | headers = header_buffer.text 60 | response = response_buffer.text 61 | 62 | set_filetype = None 63 | if 'content-type: application/json' in headers.lower(): 64 | set_filetype = 'json' 65 | elif 'text/html' in headers.lower(): 66 | set_filetype = 'html' 67 | 68 | with open(tf.name, 'w') as fout: 69 | fout.write(str(response)) 70 | 71 | if set_filetype: 72 | call([EDITOR, '+set ft={}'.format(set_filetype), tf.name]) 73 | else: 74 | call([EDITOR, tf.name]) 75 | 76 | event.app.reset() 77 | -------------------------------------------------------------------------------- /freud/server_control/delete_server.py: -------------------------------------------------------------------------------- 1 | from prompt_toolkit.layout import Float 2 | from prompt_toolkit.layout.dimension import D 3 | from prompt_toolkit.widgets import Button, Dialog, Label 4 | from prompt_toolkit.layout.containers import Window 5 | from prompt_toolkit.layout.controls import FormattedTextControl 6 | from prompt_toolkit.filters import to_filter 7 | 8 | from freud.model import db 9 | from freud.ui.root_container import root_container 10 | from freud.utils import ButtonManager, select_item 11 | from freud.ui.server_container import servers 12 | from freud.ui.text_buffers import summary_buffer 13 | 14 | 15 | class DeleteDialog: 16 | def __init__(self, event): 17 | 18 | def ok_handler(): 19 | # if len(ButtonManager.buttons) > 0: 20 | delete_server(event, name) 21 | root_container.floats.pop() 22 | 23 | def cancel_handler(): 24 | root_container.floats.pop() 25 | event.app.layout.focus(ButtonManager.prev_button) 26 | 27 | # Get data about server currently editing 28 | name = ButtonManager.current_button 29 | 30 | # Dialog configuration 31 | ok_button = Button(text='OK', handler=ok_handler) 32 | cancel_button = Button(text='Cancel', handler=cancel_handler) 33 | 34 | self.dialog = Dialog( 35 | title='Delete confirmation', 36 | body=Label( 37 | text='Are you sure you want to delete {}?'.format(name)), 38 | buttons=[cancel_button, ok_button], 39 | width=D(preferred=80), 40 | with_background=True 41 | ) 42 | 43 | root_container.floats.append(Float(self.dialog)) 44 | event.app.layout.focus(self.dialog) 45 | 46 | 47 | def delete_server(event, name): 48 | 49 | db.delete_one(name) 50 | 51 | buttons = servers.hsplit.children 52 | 53 | # The server has been removed from the db. Now, we have to remove the 54 | # button from the layout by iterating over buttons and removing it by index 55 | for idx, button in enumerate(buttons): 56 | if name == button.content.text()[1][1].strip(): 57 | 58 | del buttons[idx] 59 | 60 | if len(buttons) > 0: 61 | 62 | try: 63 | # If the deleted button was not the last button 64 | event.app.layout.focus(buttons[idx]) 65 | ButtonManager.prev_button = buttons[idx] 66 | select_item(event) 67 | 68 | except IndexError: 69 | # If the deleted button was the last button 70 | event.app.layout.focus(buttons[idx - 1]) 71 | ButtonManager.prev_button = buttons[idx - 1] 72 | select_item(event) 73 | 74 | else: 75 | # Last button was deleted, display message "No servers" 76 | 77 | control = FormattedTextControl( 78 | 'No Servers', 79 | focusable=True, 80 | show_cursor=False) 81 | 82 | window = Window( 83 | control, 84 | height=1, 85 | dont_extend_width=True, 86 | dont_extend_height=True) 87 | 88 | buttons.append(window) 89 | 90 | summary_buffer.read_only = to_filter(False) 91 | summary_buffer.text = '' 92 | summary_buffer.read_only = to_filter(True) 93 | 94 | ButtonManager.prev_button = None 95 | ButtonManager.current_button = None 96 | 97 | event.app.layout.focus(servers.content) 98 | -------------------------------------------------------------------------------- /freud/server_control/headers_dialog.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from prompt_toolkit.layout.dimension import D 4 | from prompt_toolkit.completion import WordCompleter 5 | from prompt_toolkit.widgets import Button, TextArea, Dialog, Label 6 | from prompt_toolkit.layout.containers import VSplit, HSplit, Window 7 | from prompt_toolkit.layout import Float 8 | from prompt_toolkit.key_binding import KeyBindings 9 | 10 | 11 | from freud.ui.dialog import ErrorDialog 12 | from freud.ui.root_container import root_container 13 | from freud.model import db 14 | from freud.utils import ButtonManager, select_item 15 | 16 | 17 | headers = [ 18 | 'Accept', 19 | 'Accept-Charset', 20 | 'Accept-Encoding', 21 | 'Accept-Language', 22 | 'Accept-Datetime', 23 | 'Authorization', 24 | 'Cache-Control', 25 | 'Connection', 26 | 'Cookie', 27 | 'Content-Length', 28 | 'Content-MD5', 29 | 'Content-Type', 30 | 'Date', 31 | 'Expect', 32 | 'Forwarded', 33 | 'From', 34 | 'Host', 35 | 'If-Match', 36 | 'If-Modified-Since', 37 | 'If-None-Match', 38 | 'If-Range', 39 | 'If-Unmodified-Since', 40 | 'Max-Forwards', 41 | 'Origin', 42 | 'Pragma', 43 | 'Proxy-Authorization', 44 | 'Range', 45 | 'Referer', 46 | 'TE', 47 | 'User-Agent', 48 | 'Upgrade', 49 | 'Via', 50 | 'Warning', 51 | ] 52 | 53 | header_values = [ 54 | 'application/json', 55 | 'text/html' 56 | ] 57 | 58 | headers_completer = WordCompleter(headers, 59 | ignore_case=True) 60 | 61 | header_values_completer = WordCompleter(header_values, 62 | ignore_case=True) 63 | 64 | 65 | class HeadersDialog: 66 | def __init__(self, event): 67 | 68 | def ok_handler(): 69 | result = add_headers_to_db(event, name) 70 | 71 | if result.get('success'): 72 | root_container.floats.pop() 73 | event.app.layout.focus(ButtonManager.prev_button) 74 | select_item(event) 75 | 76 | def cancel_handler(): 77 | root_container.floats.pop() 78 | event.app.layout.focus(ButtonManager.prev_button) 79 | 80 | # Get data about server currently editing 81 | name = ButtonManager.current_button 82 | result = db.fetch_one(name=name) 83 | 84 | # Dialog configuration 85 | ok_button = Button(text='OK', handler=ok_handler) 86 | cancel_button = Button(text='Cancel', handler=cancel_handler) 87 | 88 | local_kb = KeyBindings() 89 | 90 | @local_kb.add('enter') 91 | def cancel_completion(event): 92 | buff = event.app.current_buffer 93 | buff.complete_state = None 94 | event.app.layout.focus_next() 95 | 96 | @local_kb.add('c-n') 97 | def add_header(event): 98 | new_header = HeaderFactory() 99 | spacer = Window(height=1, char=' ') 100 | self.input.children.append(spacer) 101 | self.input.children.append(new_header.vsplit) 102 | 103 | # Setup initial headings for headers 104 | self.input = HSplit([ 105 | Label(text='Press ctrl+n to add another header'), 106 | Window(height=1, char='─'), 107 | VSplit([ 108 | Label(text='Header'), 109 | Window(width=1, char=' '), 110 | Label(text='Type'), 111 | ]), 112 | Window(height=1, char=' ') 113 | ], key_bindings=local_kb) 114 | 115 | # If there are existing headers, include them in the layout 116 | if result.headers: 117 | headers = json.loads(result.headers) 118 | for header in headers: 119 | new_header = HeaderFactory( 120 | header=header, value=headers[header]) 121 | spacer = Window(height=1, char=' ') 122 | self.input.children.append(spacer) 123 | self.input.children.append(new_header.vsplit) 124 | 125 | # If there are no headers currently assigned to the server, create 126 | # empty boxes for input 127 | else: 128 | new_header = HeaderFactory() 129 | spacer = Window(height=1, char=' ') 130 | self.input.children.append(spacer) 131 | self.input.children.append(new_header.vsplit) 132 | 133 | self.dialog = Dialog( 134 | title='Headers', 135 | body=self.input, 136 | buttons=[ok_button, cancel_button], 137 | width=D(preferred=80), 138 | with_background=True 139 | ) 140 | 141 | root_container.floats.append(Float(self.dialog)) 142 | event.app.layout.focus(self.dialog) 143 | 144 | 145 | class HeaderFactory: 146 | 147 | headers = [] 148 | 149 | def __init__(self, header=None, value=None): 150 | 151 | header = header if header else '' 152 | value = value if value else '' 153 | 154 | self.header = TextArea( 155 | multiline=False, 156 | password=False, 157 | completer=headers_completer, 158 | text=header 159 | ) 160 | self.value = TextArea( 161 | multiline=False, 162 | password=False, 163 | completer=header_values_completer, 164 | text=value 165 | ) 166 | self.vsplit = VSplit([ 167 | self.header, 168 | Window(width=1, char=' '), 169 | self.value, 170 | ]) 171 | HeaderFactory.headers.append(self) 172 | 173 | 174 | def add_headers_to_db(event, name): 175 | 176 | headers = {} 177 | for header in HeaderFactory.headers: 178 | if header.header.text == '' and header.value.text == '': 179 | # Skip empty input boxes 180 | continue 181 | 182 | if headers.get(header.header.text, None): 183 | 184 | ErrorDialog(event, title='Duplicate Header', 185 | text='Please remove duplicate header') 186 | 187 | return {'success': False} 188 | 189 | headers[header.header.text] = header.value.text 190 | 191 | if len(headers) == 0: 192 | # Since there are no headers, set headers to None in db 193 | result = db.update_one(values={'name': name, 194 | 'headers': None}) 195 | else: 196 | result = db.update_one(values={'name': name, 197 | 'headers': json.dumps(headers)}) 198 | 199 | # Delete all headers from factory 200 | HeaderFactory.headers = [] 201 | 202 | if result.get('errors'): 203 | ErrorDialog(event, title='Header error', 204 | text=result.get('errors')) 205 | 206 | return {'success': False} 207 | 208 | return {'success': True} 209 | -------------------------------------------------------------------------------- /freud/server_control/server_dialog.py: -------------------------------------------------------------------------------- 1 | from prompt_toolkit.layout.dimension import D 2 | from prompt_toolkit.key_binding import KeyBindings 3 | from prompt_toolkit.completion import WordCompleter 4 | from prompt_toolkit.document import Document 5 | from prompt_toolkit.layout import Layout 6 | from prompt_toolkit.widgets import TextArea, Dialog, Label, Button 7 | from prompt_toolkit.layout.containers import HSplit, Window 8 | from prompt_toolkit.layout import Float 9 | 10 | from freud.ui.root_container import root_container 11 | from freud.model import db 12 | from freud.utils import ButtonManager, select_item 13 | from freud.ui.dialog import ErrorDialog 14 | 15 | 16 | class ServerDialog: 17 | def __init__(self, event, create_server=False): 18 | 19 | def ok_handler(): 20 | 21 | # Simplistic check to see if user forgot to enter something for url 22 | url = len(self.url.text) > len('https://') 23 | 24 | if not all([self.name.text, url, self.method.text]): 25 | return ErrorDialog(event, title='Input Error', 26 | text='Name, Url, and Method are required.') 27 | 28 | result = server_to_db() 29 | 30 | if result.get('success'): 31 | 32 | root_container.floats.pop() 33 | 34 | # Rather than inserting a new button into, e.g., 35 | # hsplit.children, we recreate the layout since we have to 36 | # pay attention to sort order here 37 | event.app.layout = Layout(root_container.create()) 38 | 39 | # Find the button in the redrawn layout; then focus on it 40 | buttons = ButtonManager.update_buttons(event.app) 41 | for button in buttons: 42 | if self.name.text == button.content.text()[1][1].strip(): 43 | event.app.layout.focus(button) 44 | break 45 | 46 | select_item(event) 47 | 48 | else: 49 | # Add/update server returned an error 50 | ErrorDialog(event, title='Add/edit server error', 51 | text=str(result.get('errors'))) 52 | 53 | def cancel_handler(): 54 | root_container.floats.pop() 55 | if ButtonManager.prev_button: 56 | event.app.layout.focus(ButtonManager.prev_button) 57 | 58 | def server_to_db(): 59 | if self.rowid: 60 | # Updating an existing server 61 | return db.update_one( 62 | rowid=self.rowid, 63 | values={ 64 | 'name': self.name.text, 65 | 'url': self.url.text, 66 | 'method': self.method.text 67 | }) 68 | else: 69 | # Creating a new server 70 | return db.add_one( 71 | values={ 72 | 'name': self.name.text, 73 | 'url': self.url.text, 74 | 'method': self.method.text 75 | }) 76 | 77 | # Get data about server currently editing 78 | if create_server: 79 | title = 'New Server' 80 | self.rowid = None 81 | name = '' 82 | url = 'https://' 83 | method = '' 84 | else: 85 | title = 'Edit Server' 86 | self.name = ButtonManager.current_button 87 | result = db.fetch_one(name=self.name) 88 | 89 | self.rowid = result.rowid 90 | name = result.name 91 | url = result.url 92 | method = result.method 93 | 94 | # Dialog configuration 95 | ok_button = Button(text='OK', handler=ok_handler) 96 | cancel_button = Button(text='Cancel', handler=cancel_handler) 97 | 98 | methods = [ 99 | 'GET', 100 | 'POST', 101 | 'PUT', 102 | 'HEAD', 103 | 'DELETE', 104 | 'CONNECT', 105 | 'OPTIONS', 106 | 'TRACE', 107 | 'PATCH' 108 | ] 109 | method_completer = WordCompleter(methods, 110 | ignore_case=True) 111 | 112 | local_kb = KeyBindings() 113 | 114 | @local_kb.add('enter') 115 | def cancel_completion(event): 116 | buff = event.app.current_buffer 117 | buff.complete_state = None 118 | event.app.layout.focus_next() 119 | 120 | self.name = TextArea( 121 | multiline=False, 122 | text=name 123 | ) 124 | self.name.buffer.document = Document(name, len(name)) 125 | 126 | self.url = TextArea( 127 | multiline=False, 128 | text=url 129 | ) 130 | self.url.buffer.document = Document(url, len(url)) 131 | 132 | self.method = TextArea( 133 | multiline=False, 134 | completer=method_completer, 135 | text=method) 136 | self.method.buffer.document = Document(method, len(method)) 137 | 138 | self.dialog = Dialog( 139 | title=title, 140 | body=HSplit([ 141 | Label(text='Server name:\n'), 142 | self.name, 143 | Window(height=1, char=' '), 144 | Label(text='Url:\n'), 145 | self.url, 146 | Window(height=1, char=' '), 147 | Label(text='Method:\n'), 148 | self.method, 149 | Window(height=1, char=' ') 150 | ], 151 | key_bindings=local_kb 152 | ), 153 | buttons=[ok_button, cancel_button], 154 | width=D(preferred=80), 155 | with_background=True 156 | ) 157 | 158 | self.current_button = event.app.layout.current_window 159 | 160 | root_container.floats.append(Float(self.dialog)) 161 | event.app.layout.focus(self.dialog) 162 | -------------------------------------------------------------------------------- /freud/ui/__init__.py: -------------------------------------------------------------------------------- 1 | from pygments.style import Style 2 | from pygments.token import Keyword, Name, Comment, String, Number 3 | 4 | 5 | class FreudStyle(Style): 6 | default_style = "" 7 | styles = { 8 | Comment: '#776977', 9 | # Bool 10 | Keyword: '#fff', 11 | Name: '#516aec', 12 | Name.Tag: '#516aec', 13 | # JSON Value 14 | String: 'noinherit', 15 | Number: '#ca402b' 16 | } 17 | -------------------------------------------------------------------------------- /freud/ui/body_container.py: -------------------------------------------------------------------------------- 1 | from prompt_toolkit.layout.containers import ( 2 | VSplit, HSplit, Window, WindowAlign) 3 | from prompt_toolkit.layout.controls import FormattedTextControl 4 | 5 | from freud.ui.text_buffers import response_box, summary_box, search_toolbar 6 | from freud.ui.server_container import servers 7 | from freud import __version__ 8 | 9 | 10 | class TitledBody: 11 | 12 | def create(self, sort_by=None, order=None): 13 | 14 | self.servers = servers.refresh(sort_by=sort_by, order=order) 15 | 16 | servers_output = VSplit([ 17 | self.servers, 18 | Window(width=1, char='│', style='class:line'), 19 | response_box.create(), 20 | ]) 21 | 22 | body = HSplit([ 23 | servers_output, 24 | Window(height=1, char='─', style='class:line'), 25 | summary_box() 26 | ]) 27 | 28 | title_bar = [ 29 | ('class:title', ' Freud {}'.format(__version__)), 30 | ('class:title', 31 | ' (Press [Ctrl-C] to quit. Press [Ctrl-F] for info.)'), 32 | ] 33 | 34 | self.container = HSplit([ 35 | search_toolbar, 36 | Window(height=1, 37 | content=FormattedTextControl(title_bar), 38 | align=WindowAlign.CENTER), 39 | Window(height=1, char='─', style='class:line'), 40 | body, 41 | ]) 42 | 43 | return self.container 44 | 45 | 46 | titled_body = TitledBody() 47 | -------------------------------------------------------------------------------- /freud/ui/dialog.py: -------------------------------------------------------------------------------- 1 | from prompt_toolkit.layout.dimension import D 2 | from prompt_toolkit.widgets import Button 3 | from prompt_toolkit.layout.containers import HSplit, Window 4 | from prompt_toolkit.widgets import Dialog 5 | from prompt_toolkit.layout.containers import WindowAlign 6 | from prompt_toolkit.layout import Float 7 | from prompt_toolkit.layout.controls import FormattedTextControl 8 | 9 | from freud.ui.root_container import root_container 10 | from freud.utils import ButtonManager 11 | 12 | 13 | class ErrorDialog: 14 | def __init__(self, event, title='', text=''): 15 | 16 | def ok_handler(): 17 | root_container.floats.pop() 18 | 19 | # If there was an original dialog, insert it back into layout 20 | if self.orig_dialog: 21 | root_container.floats.append(self.orig_dialog) 22 | event.app.layout.focus(root_container.float_container) 23 | else: 24 | event.app.layout.focus(ButtonManager.prev_button) 25 | 26 | ok_button = Button(text='OK', handler=ok_handler) 27 | 28 | dialog = Dialog( 29 | Window( 30 | wrap_lines=True, 31 | content=FormattedTextControl(text=text), 32 | always_hide_cursor=True 33 | ), 34 | title=title, 35 | width=D(preferred=80), 36 | buttons=[ok_button], 37 | with_background=True 38 | ) 39 | 40 | try: 41 | # If a dialog was already up, save it 42 | self.orig_dialog = root_container.floats.pop() 43 | except IndexError: 44 | self.orig_dialog = None 45 | 46 | root_container.floats.append(Float(content=dialog)) 47 | event.app.layout.focus(ok_button) 48 | 49 | 50 | class LoadingDialog: 51 | 52 | """ Displays a loading message when waiting for a server response """ 53 | 54 | def __init__( 55 | self, event=None, title='', text=''): 56 | 57 | content = Window( 58 | height=3, 59 | align=WindowAlign.CENTER, 60 | content=FormattedTextControl( 61 | text=text, 62 | show_cursor=False, 63 | modal=True 64 | ) 65 | ) 66 | 67 | body = HSplit([ 68 | Window(height=1, char=' '), 69 | content, 70 | Window(height=1, char=' '), 71 | ], padding=1) 72 | 73 | dialog = Dialog( 74 | body, 75 | title=title, 76 | width=D(preferred=80), 77 | with_background=True 78 | ) 79 | 80 | loading_float = Float(content=dialog) 81 | 82 | root_container.floats.append(loading_float) 83 | 84 | self.focus = event.app.layout.focus 85 | self.focus(content) 86 | 87 | def close_dialog(self): 88 | root_container.floats.pop() 89 | self.focus(ButtonManager.prev_button) 90 | -------------------------------------------------------------------------------- /freud/ui/keys.py: -------------------------------------------------------------------------------- 1 | from prompt_toolkit.layout.dimension import D 2 | from prompt_toolkit import HTML 3 | from prompt_toolkit.widgets import Button, Dialog, Label 4 | from prompt_toolkit.layout.containers import ( 5 | VSplit, HSplit, Window, HorizontalAlign) 6 | from prompt_toolkit.layout import Float 7 | 8 | from freud.utils import ButtonManager 9 | from freud.ui.root_container import root_container 10 | from freud.ui.server_container import servers 11 | from freud import KEYS 12 | 13 | 14 | class KeyQuickRef: 15 | 16 | """ Displays a quick reference for key bindings """ 17 | 18 | def __init__(self, event): 19 | 20 | def ok_handler(): 21 | 22 | root_container.floats.pop() 23 | if ButtonManager.prev_button: 24 | event.app.layout.focus(ButtonManager.prev_button) 25 | else: 26 | event.app.layout.focus(servers.content) 27 | 28 | ok_button = Button(text='OK', handler=ok_handler) 29 | 30 | # Create dialog with title 31 | self.input = HSplit([ 32 | Label(text=HTML( 33 | 'Default Vi key bindings.') 34 | ), 35 | Window(height=1, char=' ') 36 | ]) 37 | 38 | # Add navigation keys to dialog 39 | self.input.children.append(VSplit([ 40 | Label(text=HTML('Basic navigation\n'))]) 41 | ) 42 | for key in navigation_keys: 43 | new_key = KeyFactory( 44 | key=key, value=navigation_keys[key]) 45 | self.input.children.append(new_key.vsplit) 46 | 47 | spacer = Window(height=1, char='─') 48 | blank_spacer = Window(height=1, char=' ') 49 | 50 | self.input.children.append(blank_spacer) 51 | self.input.children.append(spacer) 52 | 53 | # Add server keys to dialog 54 | self.input.children.append(VSplit([ 55 | Label(text=HTML('Server keys\n'))]) 56 | ) 57 | for key in server_keys: 58 | new_key = KeyFactory( 59 | key=key, value=server_keys[key]) 60 | self.input.children.append(new_key.vsplit) 61 | 62 | self.input.children.append(blank_spacer) 63 | self.input.children.append(spacer) 64 | 65 | # Add response keys to dialog 66 | self.input.children.append(VSplit([ 67 | Label( 68 | text=HTML( 69 | 'Text areas (response, headers, summary)\n'))]) 70 | ) 71 | for key in response_body_keys: 72 | new_key = KeyFactory( 73 | key=key, value=response_body_keys[key]) 74 | self.input.children.append(new_key.vsplit) 75 | self.input.children.append(blank_spacer) 76 | self.input.children.append(blank_spacer) 77 | 78 | self.dialog = Dialog( 79 | title='Key Quick Reference', 80 | body=self.input, 81 | buttons=[ok_button], 82 | width=D(preferred=80), 83 | with_background=True, 84 | modal=True) 85 | 86 | root_container.floats.append(Float(self.dialog)) 87 | event.app.layout.focus(self.dialog) 88 | 89 | 90 | class KeyFactory: 91 | 92 | def __init__(self, key=None, value=None): 93 | 94 | self.key = Label( 95 | text=key, 96 | width=20 97 | ) 98 | self.value = Label( 99 | text=value, 100 | ) 101 | self.vsplit = VSplit([ 102 | self.key, 103 | Window(width=1, char=' '), 104 | self.value 105 | ], align=HorizontalAlign.LEFT) 106 | 107 | 108 | # leader = KEYS['leader'] 109 | 110 | navigation_keys = { 111 | 'tab': 'Switch windows', 112 | 'j/down': 'Next server', 113 | 'k/up': 'Previous server', 114 | 'g': 'First server', 115 | 'G': 'Last server', 116 | } 117 | 118 | server_keys = { 119 | KEYS['send_request']: 'Send request', 120 | KEYS['new_server']: 'New server', 121 | KEYS['edit_server']: 'Edit server', 122 | KEYS['edit_headers']: 'Edit headers', 123 | KEYS['edit_authentication']: 'Edit auth', 124 | KEYS['edit_body']: 'Edit body', 125 | KEYS['delete_server']: 'Delete server', 126 | } 127 | 128 | response_body_keys = { 129 | '/': 'Forward search', 130 | '?': 'Backward search', 131 | 'n/N': 'Next/Previous search term', 132 | 'Vi Navigation': 'e.g., gg, G, ctrl+u, ctrl+d, etc.', 133 | } 134 | -------------------------------------------------------------------------------- /freud/ui/root_container.py: -------------------------------------------------------------------------------- 1 | from prompt_toolkit.layout.containers import HSplit 2 | from prompt_toolkit.layout import FloatContainer 3 | from prompt_toolkit.layout import Float 4 | from prompt_toolkit.layout.menus import CompletionsMenu 5 | 6 | from freud.ui.body_container import titled_body 7 | from freud.utils import SortOrder 8 | 9 | 10 | class RootContainer: 11 | 12 | def create(self): 13 | 14 | sort_by = SortOrder.sort_by 15 | order = SortOrder.order 16 | 17 | self.body = titled_body.create(sort_by=sort_by, order=order) 18 | 19 | self.container = HSplit([self.body]) 20 | 21 | completions = Float(xcursor=True, 22 | ycursor=True, 23 | content=CompletionsMenu(max_height=16, scroll_offset=1)) 24 | 25 | self.float_container = FloatContainer( 26 | content=self.container, floats=[completions]) 27 | 28 | self.floats = self.float_container.floats 29 | 30 | return self.float_container 31 | 32 | 33 | root_container = RootContainer() 34 | -------------------------------------------------------------------------------- /freud/ui/server_container.py: -------------------------------------------------------------------------------- 1 | from prompt_toolkit.layout import HSplit 2 | from prompt_toolkit.widgets import Box 3 | 4 | from freud.utils import CustomButton 5 | from freud.utils import ButtonManager 6 | from freud.model import db 7 | from freud import SERVER_WIDTH 8 | 9 | 10 | def update_servers(sort_by=None, order=None): 11 | 12 | rows = db.fetch_all(sort_by=sort_by, order=order) 13 | values = [] 14 | for row in rows: 15 | values.append(row[0]) 16 | 17 | return values 18 | 19 | 20 | class ServerContainer: 21 | 22 | def refresh(self, sort_by=None, order=None): 23 | 24 | names = update_servers(sort_by=sort_by, order=order) 25 | 26 | self.server_list = [] 27 | for name in names: 28 | button_obj = ButtonManager(name) 29 | button = CustomButton(name, handler=button_obj.click_handler) 30 | self.server_list.append(button) 31 | 32 | # If there are no servers 33 | if not self.server_list: 34 | from prompt_toolkit.layout.containers import Window 35 | from prompt_toolkit.layout.controls import FormattedTextControl 36 | 37 | window = Window( 38 | FormattedTextControl( 39 | 'No Servers', 40 | focusable=True, 41 | show_cursor=False 42 | ) 43 | ) 44 | 45 | self.server_list.append(window) 46 | 47 | self.hsplit = HSplit(self.server_list) 48 | 49 | boxes = Box( 50 | width=SERVER_WIDTH, 51 | padding_top=0, 52 | padding_left=1, 53 | body=self.hsplit 54 | ) 55 | 56 | from freud.key_bindings import server_kb 57 | 58 | self.content = HSplit([ 59 | boxes 60 | ], key_bindings=server_kb) 61 | 62 | return self.content 63 | 64 | 65 | servers = ServerContainer() 66 | -------------------------------------------------------------------------------- /freud/ui/sort.py: -------------------------------------------------------------------------------- 1 | from prompt_toolkit.layout.dimension import D 2 | from prompt_toolkit.layout import Float 3 | from prompt_toolkit.layout import Layout 4 | from prompt_toolkit.widgets import Button, Dialog, RadioList 5 | from prompt_toolkit.key_binding import KeyBindings 6 | 7 | from freud.utils import ButtonManager, SortOrder 8 | from freud.ui.root_container import root_container 9 | from freud.ui.server_container import servers 10 | 11 | 12 | class SortDialog: 13 | def __init__(self, event): 14 | kb = KeyBindings() 15 | 16 | def ok_handler(): 17 | 18 | sort_by = self.radio_list.current_value['sort_by'] 19 | order = self.radio_list.current_value['order'] 20 | 21 | SortOrder.sort_by = sort_by 22 | SortOrder.order = order 23 | 24 | root_container.floats.pop() 25 | 26 | event.app.layout = Layout(root_container.create(), 27 | focused_element=servers.content 28 | ) 29 | ButtonManager.prev_button = event.app.layout.current_window 30 | 31 | def cancel_handler(): 32 | root_container.floats.pop() 33 | root_container.float_container.key_bindings = None 34 | event.app.layout.focus(ButtonManager.prev_button) 35 | 36 | ok_button = Button(text='OK', handler=ok_handler) 37 | cancel_button = Button(text='Cancel', handler=cancel_handler) 38 | 39 | self.radio_list = RadioList(values=[ 40 | ({'sort_by': 'name', 'order': 'asc'}, 'Name'), 41 | ({'sort_by': 'name', 'order': 'desc'}, 'Name - Desc'), 42 | ({'sort_by': 'timestamp', 'order': 'asc'}, 'Time Added'), 43 | ({'sort_by': 'timestamp', 'order': 'desc'}, 'Time Added - Desc') 44 | ]) 45 | 46 | kb = self.radio_list.control.key_bindings 47 | 48 | @kb.add('j') 49 | def down(event): 50 | self.radio_list._selected_index = min( 51 | len(self.radio_list.values) - 1, 52 | self.radio_list._selected_index + 1 53 | ) 54 | 55 | @kb.add('k') 56 | def up(event): 57 | self.radio_list._selected_index = max( 58 | 0, self.radio_list._selected_index - 1) 59 | 60 | @kb.add('g', 'g') 61 | def top(event): 62 | self.radio_list._selected_index = 0 63 | 64 | @kb.add('G') 65 | def bottom(event): 66 | self.radio_list._selected_index = len(self.radio_list.values) - 1 67 | 68 | self.dialog = Dialog( 69 | title='Sort', 70 | body=self.radio_list, 71 | buttons=[ok_button, cancel_button], 72 | width=D(preferred=80), 73 | with_background=True, 74 | modal=True) 75 | 76 | root_container.float_container.key_bindings = kb 77 | 78 | root_container.floats.append(Float(self.dialog)) 79 | event.app.layout.focus(self.dialog) 80 | -------------------------------------------------------------------------------- /freud/ui/style.py: -------------------------------------------------------------------------------- 1 | from prompt_toolkit.styles import Style 2 | from prompt_toolkit.styles.pygments import style_from_pygments_cls 3 | from pygments.styles import get_style_by_name 4 | 5 | from freud import STYLE 6 | 7 | theme = STYLE['theme'] 8 | line_fg = STYLE.get('separator_line_fg', '') 9 | line_bg = STYLE.get('separator_line_bg', '') 10 | 11 | 12 | custom_style = [ 13 | ('line', 'bg:{} fg:{}'.format(line_bg, line_fg)), 14 | ('button.selected', 'bg:#cccccc fg:#880000'), 15 | ] 16 | 17 | style = style_from_pygments_cls(get_style_by_name(theme)) 18 | 19 | style._style_rules.extend(custom_style) 20 | -------------------------------------------------------------------------------- /freud/ui/text_buffers.py: -------------------------------------------------------------------------------- 1 | from prompt_toolkit.buffer import Buffer 2 | from prompt_toolkit.layout.controls import BufferControl 3 | from prompt_toolkit.lexers import PygmentsLexer 4 | from prompt_toolkit.layout.margins import ScrollbarMargin, NumberedMargin 5 | from prompt_toolkit.layout.containers import Window 6 | from pygments.lexers import JsonLexer 7 | from prompt_toolkit.layout import HSplit 8 | 9 | from prompt_toolkit.widgets import SearchToolbar 10 | 11 | from pygments.lexer import RegexLexer, bygroups 12 | from pygments.token import Text, Keyword, String, Name 13 | 14 | from freud import HEADER_HEIGHT, SUMMARY_HEIGHT 15 | 16 | response_buffer = Buffer( 17 | read_only=True, enable_history_search=True, name='response_buffer') 18 | header_buffer = Buffer(read_only=True, name='header_buffer') 19 | summary_buffer = Buffer(read_only=True, name='summary_buffer') 20 | 21 | search_toolbar = SearchToolbar() 22 | 23 | 24 | class HeaderLexer(RegexLexer): 25 | tokens = { 26 | 'root': [ 27 | (r'(\[)(\')(\d+.*?)(\')(\] )(\| )(\d+.*?\n)', 28 | bygroups(Keyword, None, Name, None, Keyword, None, Text)), 29 | (r'(\S+:\s)(.*?$)', 30 | bygroups(Name, Text)) 31 | ] 32 | } 33 | 34 | 35 | class ResponseBox: 36 | def create(self): 37 | from freud.key_bindings import response_kb, header_kb 38 | 39 | right_margins = [ScrollbarMargin(display_arrows=True)] 40 | left_margins = [NumberedMargin()] 41 | 42 | self.buffer_control = BufferControl( 43 | lexer=PygmentsLexer(JsonLexer), 44 | search_buffer_control=search_toolbar.control, 45 | buffer=response_buffer) 46 | 47 | header_window = Window( 48 | wrap_lines=True, 49 | right_margins=right_margins, 50 | left_margins=left_margins, 51 | height=HEADER_HEIGHT, 52 | content=BufferControl( 53 | key_bindings=header_kb, 54 | search_buffer_control=search_toolbar.control, 55 | lexer=PygmentsLexer(HeaderLexer), buffer=header_buffer) 56 | ) 57 | 58 | body_window = Window( 59 | left_margins=left_margins, 60 | right_margins=right_margins, 61 | wrap_lines=True, 62 | content=self.buffer_control 63 | ) 64 | 65 | return HSplit([ 66 | header_window, 67 | Window(height=1, char='─', style='class:line'), 68 | body_window, 69 | ], key_bindings=response_kb) 70 | 71 | 72 | response_box = ResponseBox() 73 | 74 | 75 | def summary_box(): 76 | 77 | from freud.key_bindings import summary_kb 78 | 79 | return Window( 80 | content=BufferControl( 81 | key_bindings=summary_kb, 82 | lexer=PygmentsLexer(JsonLexer), buffer=summary_buffer 83 | ), height=SUMMARY_HEIGHT 84 | ) 85 | -------------------------------------------------------------------------------- /freud/utils.py: -------------------------------------------------------------------------------- 1 | import six 2 | import json 3 | 4 | from prompt_toolkit.application import get_app 5 | from prompt_toolkit.filters import to_filter 6 | from prompt_toolkit.mouse_events import MouseEventType 7 | from prompt_toolkit.key_binding.key_bindings import KeyBindings 8 | from prompt_toolkit.layout.containers import Window 9 | from prompt_toolkit.layout.controls import FormattedTextControl 10 | 11 | from freud.model import db 12 | from freud.ui.text_buffers import summary_buffer 13 | 14 | 15 | class ButtonManager: 16 | """ 17 | Button logic flows through here. Button Manager saves the current button, 18 | previous button, list of buttons, and provides a click handler. 19 | """ 20 | 21 | current_button = None 22 | prev_button = None 23 | buttons = None 24 | 25 | def __init__(self, name): 26 | self.name = name 27 | 28 | def click_handler(self): 29 | 30 | result = db.fetch_one(name=self.name) 31 | 32 | output = { 33 | 'name': result.name, 34 | 'method': result.method, 35 | 'url': result.url 36 | } 37 | 38 | if result.headers: 39 | output.update({ 40 | 'headers': json.loads(result.headers) 41 | }) 42 | if result.body: 43 | try: 44 | body = json.loads(result.body) 45 | except json.decoder.JSONDecodeError: 46 | body = str(result.body).splitlines() 47 | 48 | output.update({ 49 | 'body': body 50 | }) 51 | if result.auth: 52 | auth = json.loads(result.auth) 53 | output.update({ 54 | 'auth': { 55 | 'type': auth['type'], 56 | 'user': auth['user'] 57 | } 58 | }) 59 | 60 | summary_buffer.read_only = to_filter(False) 61 | summary_buffer.text = json.dumps(output, indent=2) 62 | summary_buffer.read_only = to_filter(True) 63 | 64 | type(self).current_button = self.name 65 | 66 | app = get_app() 67 | type(self).prev_button = app.layout.current_window 68 | 69 | @classmethod 70 | def update_buttons(cls, app): 71 | 72 | windows = [w for w in app.layout.find_all_windows()] 73 | cls.buttons = [] 74 | for window in windows: 75 | if 'CustomButton' in str(window): 76 | cls.buttons.append(window) 77 | 78 | if cls.prev_button is None and len(cls.buttons) > 0: 79 | cls.prev_button = cls.buttons[0] 80 | 81 | return cls.buttons 82 | 83 | 84 | class SortOrder: 85 | """ Saves the sort order for reference by application """ 86 | 87 | sort_by = None 88 | order = None 89 | 90 | 91 | class CustomButton: 92 | """ 93 | Taken from Python Prompt Toolkit's Button class for customization 94 | 95 | Clickable button. 96 | :param text: The caption for the button. 97 | :param handler: `None` or callable. Called when the button is clicked. 98 | :param width: Width of the button. 99 | """ 100 | 101 | def __init__(self, text, handler=None): 102 | assert isinstance(text, six.text_type) 103 | assert handler is None or callable(handler) 104 | 105 | self.text = text 106 | self.handler = handler 107 | self.control = FormattedTextControl( 108 | self._get_text_fragments, 109 | key_bindings=self._get_key_bindings(), 110 | show_cursor=False, 111 | focusable=True) 112 | 113 | def get_style(): 114 | if get_app().layout.has_focus(self): 115 | return 'class:button.focused' 116 | 117 | return 'class:button' 118 | 119 | self.window = Window( 120 | self.control, 121 | height=1, 122 | style=get_style, 123 | dont_extend_width=True, 124 | dont_extend_height=True) 125 | 126 | def _get_text_fragments(self): 127 | text = self.text 128 | 129 | def handler(mouse_event): 130 | if mouse_event.event_type == MouseEventType.MOUSE_UP: 131 | self.handler() 132 | 133 | return [ 134 | ('class:button.arrow', '', handler), 135 | ('class:button.text', text, handler), 136 | ('class:button.arrow', '', handler), 137 | ] 138 | 139 | def _get_key_bindings(self): 140 | kb = KeyBindings() 141 | 142 | @kb.add(' ') 143 | @kb.add('enter') 144 | def _(event): 145 | if self.handler is not None: 146 | self.handler() 147 | 148 | return kb 149 | 150 | def __pt_container__(self): 151 | return self.window 152 | 153 | 154 | def on_startup(app): 155 | """ Run from __main__ after render """ 156 | 157 | ButtonManager.update_buttons(app) 158 | if not summary_buffer.text: 159 | # When starting app, select first server if none selected 160 | select_item(app) 161 | 162 | 163 | class SingleClick: 164 | """ Provides mouse click key """ 165 | 166 | def __init__(self): 167 | self.event_type = MouseEventType.MOUSE_UP 168 | 169 | 170 | def select_item(event): 171 | """ Simulate mouse click """ 172 | 173 | # Don't try to select button if there are none 174 | if len(ButtonManager.buttons) > 0: 175 | 176 | try: 177 | 178 | event.app.layout.current_window.content.text()[1][2](SingleClick()) 179 | 180 | # If app is passed in, rather than event 181 | except AttributeError: 182 | 183 | event.layout.current_window.content.text()[1][2](SingleClick()) 184 | -------------------------------------------------------------------------------- /img/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stloma/freud/ebc16ecf7bd39ad365108e1cce4c9e8a0ddc69cd/img/demo.gif -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | with open('README.md', encoding='utf-8') as f: 4 | long_description = f.read() 5 | 6 | setup( 7 | name='freud', 8 | version='0.1.3.dev0', 9 | author='Stephen Martin', 10 | author_email='lockwood@opperline.com', 11 | description='TUI REST client to analyze API endpoints', 12 | long_description=long_description, 13 | long_description_content_type='text/markdown', 14 | url='https://github.com/stloma/freud', 15 | classifiers=[ 16 | 'Development Status :: 3 - Alpha', 17 | 'Intended Audience :: Developers', 18 | 'Topic :: Software Development :: Build Tools', 19 | 'License :: OSI Approved :: MIT License', 20 | 'Programming Language :: Python :: 3.5', 21 | 'Programming Language :: Python :: 3.6', 22 | ], 23 | keywords='cli tui http rest request api', 24 | packages=find_packages(), 25 | install_requires=[ 26 | 'requests==2.21.0', 27 | 'prompt_toolkit==2.0.7', 28 | 'pygments==2.2.0' 29 | ], 30 | extras_require={ 31 | 'dev': [ 32 | 'flake8==3.6.0', 33 | 'pytest==4.1.0', 34 | 'pytest_asyncio==0.10.0', 35 | 'pytest_httpbin==0.3.0', 36 | 'tox==3.6.1' 37 | ] 38 | }, 39 | entry_points={ 40 | 'console_scripts': [ 41 | 'freud=freud.__main__:main', 42 | ], 43 | 'pygments.styles': [ 44 | 'freud=freud.ui:FreudStyle' 45 | ] 46 | }, 47 | project_urls={ 48 | 'Bug Reports': 'https://github.com/stloma/freud/issues', 49 | 'Source': 'https://github.com/stloma/freud', 50 | }, 51 | ) 52 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stloma/freud/ebc16ecf7bd39ad365108e1cce4c9e8a0ddc69cd/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import json 3 | import os 4 | import sys 5 | 6 | 7 | def pytest_configure(): 8 | """ Freud checks for this upon startup. If true, creates a new test.db """ 9 | 10 | sys._called_from_test = True 11 | 12 | 13 | def pytest_unconfigure(): 14 | from freud import DB_FILE 15 | from freud.model import basedir 16 | 17 | del sys._called_from_test 18 | os.remove(os.path.join(basedir, DB_FILE)) 19 | 20 | 21 | @pytest.fixture(scope='class') 22 | def db_conn(): 23 | 24 | from freud.model import Db 25 | db = Db() 26 | 27 | yield db 28 | 29 | 30 | @pytest.fixture(scope='function') 31 | def db(db_conn): 32 | 33 | db_conn.delete_all() 34 | 35 | return db_conn 36 | 37 | 38 | @pytest.fixture(scope='function') 39 | def db_dummy_data(db): 40 | db.add_one( 41 | {'name': 'alice', 'url': 'alice.com', 'method': 'get'}) 42 | 43 | db.add_one( 44 | {'name': 'bob', 'url': 'bob.com', 'method': 'get'}) 45 | 46 | return 47 | 48 | 49 | @pytest.fixture(scope='class') 50 | def db_request_data(httpbin, db_conn): 51 | 52 | # Get request 53 | # 54 | db_conn.add_one({ 55 | 'name': 'httpbin-get', 56 | 'url': '{}/get'.format(httpbin.url), 57 | 'method': 'GET' 58 | }) 59 | 60 | # Delete request 61 | # 62 | db_conn.add_one({ 63 | 'name': 'httpbin-delete', 64 | 'url': '{}/delete'.format(httpbin.url), 65 | 'method': 'DELETE', 66 | }) 67 | 68 | # Patch request 69 | # 70 | db_conn.add_one({ 71 | 'name': 'httpbin-patch', 72 | 'url': '{}/patch'.format(httpbin.url), 73 | 'method': 'PATCH', 74 | }) 75 | 76 | # Put request 77 | # 78 | db_conn.add_one({ 79 | 'name': 'httpbin-put', 80 | 'url': '{}/put'.format(httpbin.url), 81 | 'method': 'PUT', 82 | }) 83 | 84 | # Post request 85 | # 86 | db_conn.add_one({ 87 | 'name': 'httpbin-post', 88 | 'url': '{}/post'.format(httpbin.url), 89 | 'method': 'POST', 90 | 'body': json.dumps({'name': 'alice'}), 91 | 'headers': json.dumps({'Content-Type': 'application/json'}) 92 | }) 93 | 94 | # Post form 95 | # 96 | db_conn.add_one({ 97 | 'name': 'httpbin-post-form', 98 | 'url': '{}/post'.format(httpbin.url), 99 | 'method': 'POST', 100 | 'headers': json.dumps( 101 | {'content-type': 'application/x-www-form-urlencoded'} 102 | ), 103 | 'body': 'name=alice&email=alice@email.com' 104 | }) 105 | 106 | # Basic auth 107 | # 108 | db_conn.add_one({ 109 | 'name': 'httpbin-basic-auth-200', 110 | 'url': '{}/basic-auth/alice/password'.format(httpbin.url), 111 | 'method': 'GET', 112 | 'auth': json.dumps({ 113 | 'user': 'alice', 114 | 'password': 'password', 115 | 'type': 'basic' 116 | }) 117 | }) 118 | db_conn.add_one({ 119 | 'name': 'httpbin-basic-auth-401', 120 | 'url': '{}/basic-auth/alice/password'.format(httpbin.url), 121 | 'method': 'GET', 122 | 'auth': json.dumps({ 123 | 'user': 'eve', 124 | 'password': 'bad_password', 125 | 'type': 'basic' 126 | }) 127 | }) 128 | db_conn.add_one({ 129 | 'name': 'httpbin-digest-auth-200', 130 | 'url': '{}/digest-auth/auth/alice/password'.format(httpbin.url), 131 | 'method': 'GET', 132 | 'auth': json.dumps({ 133 | 'user': 'alice', 134 | 'password': 'password', 135 | 'type': 'digest' 136 | }) 137 | }) 138 | db_conn.add_one({ 139 | 'name': 'httpbin-digest-auth-401', 140 | 'url': '{}/digest-auth/auth/alice/password'.format(httpbin.url), 141 | 'method': 'GET', 142 | 'auth': json.dumps({ 143 | 'user': 'eve', 144 | 'password': 'bad_password', 145 | 'type': 'digest' 146 | }) 147 | }) 148 | 149 | return 150 | -------------------------------------------------------------------------------- /tests/test_database.py: -------------------------------------------------------------------------------- 1 | class TestCRUD: 2 | def test_add_one(self, db): 3 | result = db.add_one( 4 | {'name': 'alice', 'url': 'alice.com', 'method': 'get'}) 5 | 6 | errors = result.get('errors') 7 | success = result.get('success') 8 | 9 | assert errors is None 10 | assert success is True 11 | 12 | def test_fetch_one(self, db, db_dummy_data): 13 | result = db.fetch_one(name='alice') 14 | 15 | assert result.name == 'alice' 16 | assert result.url == 'alice.com' 17 | assert result.method == 'get' 18 | 19 | def test_update_one(self, db, db_dummy_data): 20 | result = db.fetch_one(name='alice') 21 | 22 | db.update_one(rowid=result.rowid, values={ 23 | 'name': 'eve', 'method': 'post', 'url': 'example.com'}) 24 | 25 | result = db.fetch_one(name='eve') 26 | 27 | assert result.name == 'eve' 28 | assert result.method == 'post' 29 | 30 | def test_fetch_all(self, db, db_dummy_data): 31 | rows = db.fetch_all() 32 | 33 | assert len(rows) == 2 34 | 35 | def test_delete_one(self, db, db_dummy_data): 36 | before_delete = db.fetch_all() 37 | 38 | db.delete_one(name='alice') 39 | 40 | after_delete = db.fetch_all() 41 | 42 | assert len(before_delete) == len(after_delete) + 1 43 | 44 | 45 | class TestErrors: 46 | 47 | def test_add_one_missing_column(self, db): 48 | result = db.add_one({}) 49 | 50 | errors = result.get('errors') 51 | 52 | assert errors == 'missing column error: name' 53 | 54 | result = db.add_one( 55 | {'name': 'alice'}) 56 | 57 | errors = result.get('errors') 58 | 59 | assert errors == 'missing column error: url' 60 | 61 | result = db.add_one( 62 | {'name': 'alice', 'url': 'alice.com'}) 63 | 64 | errors = result.get('errors') 65 | 66 | assert errors == 'missing column error: method' 67 | 68 | result = db.add_one( 69 | {'name': 'alice', 'url': 'alice.com', 'method': 'get'}) 70 | 71 | errors = result.get('errors') 72 | 73 | assert errors is None 74 | 75 | def test_constraint_violation(self, db, db_dummy_data): 76 | result = db.add_one( 77 | {'name': 'alice', 'url': 'alice.com', 'method': 'get'}) 78 | 79 | errors = result.get('errors') 80 | 81 | assert errors == '{}'.format( 82 | 'sqlite error: UNIQUE constraint failed: requests.name') 83 | 84 | 85 | class TestSort: 86 | 87 | def test_sort(self, db, db_dummy_data): 88 | 89 | name_asc = db.fetch_all(sort_by='name', order='asc') 90 | assert name_asc[0].name == 'alice' 91 | assert name_asc[1].name == 'bob' 92 | 93 | name_desc = db.fetch_all(sort_by='name', order='desc') 94 | assert name_desc[0].name == 'bob' 95 | assert name_desc[1].name == 'alice' 96 | 97 | time_asc = db.fetch_all(sort_by='timestamp', order='asc') 98 | assert time_asc[0].name == 'alice' 99 | assert time_asc[1].name == 'bob' 100 | 101 | time_desc = db.fetch_all(sort_by='timestamp', order='desc') 102 | assert time_desc[0].name == 'bob' 103 | assert time_desc[1].name == 'alice' 104 | 105 | -------------------------------------------------------------------------------- /tests/test_layout.py: -------------------------------------------------------------------------------- 1 | import json 2 | from prompt_toolkit.layout.layout import Layout 3 | from .utils import SingleClick 4 | from freud.utils import ButtonManager 5 | from freud.ui.server_container import servers 6 | from freud.ui.root_container import root_container 7 | from freud.key_bindings import server_kb 8 | 9 | 10 | class TestChangingLayout: 11 | 12 | @staticmethod 13 | def _get_windows(root_container): 14 | root_container = root_container.create() 15 | layout = Layout(root_container) 16 | all_windows = layout.find_all_windows() 17 | windows = [] 18 | for window in all_windows: 19 | windows.append(window) 20 | 21 | return windows 22 | 23 | @staticmethod 24 | def _get_all_windows(layout): 25 | return [w for w in layout.find_all_windows()] 26 | 27 | def test_check_layout(self, db_dummy_data): 28 | 29 | windows = self._get_windows(root_container) 30 | 31 | search_buffer = windows[0] 32 | title_window = windows[1] 33 | dummy_one = windows[4] 34 | button_one = windows[5] 35 | button_two = windows[6] 36 | dummy_two = windows[7] 37 | header_buffer = windows[10] 38 | response_buffer = windows[12] 39 | summary_buffer = windows[14] 40 | 41 | assert 'SearchBuffer' in str(search_buffer) 42 | assert 'Freud' in str(title_window) 43 | assert 'CustomButton' not in str(dummy_one) 44 | assert 'CustomButton' in str(button_one) 45 | assert 'CustomButton' in str(button_two) 46 | assert 'CustomButton' not in str(dummy_two) 47 | assert 'header_buffer' in str(header_buffer) 48 | assert 'response_buffer' in str(response_buffer) 49 | assert 'summary_buffer' in str(summary_buffer) 50 | 51 | def test_update_response_box(self, db_dummy_data): 52 | 53 | # Create layout 54 | layout = Layout(root_container.create(), 55 | focused_element=servers.content) 56 | 57 | summary_buffer = layout.get_buffer_by_name('summary_buffer') 58 | 59 | # Simulate mouse click on first server 60 | layout.current_window.content.text()[1][2](SingleClick()) 61 | summary_buffer_before = json.loads(summary_buffer.text) 62 | 63 | # Get buttons for focus 64 | app = App(layout) 65 | ButtonManager.update_buttons(app) 66 | 67 | # Focus and simulate click on next server 68 | layout.focus(ButtonManager.buttons[1]) 69 | layout.current_window.content.text()[1][2](SingleClick()) 70 | summary_buffer_after = json.loads(summary_buffer.text) 71 | 72 | assert summary_buffer_before['name'] == 'alice' 73 | assert summary_buffer_after['name'] == 'bob' 74 | 75 | def test_add_server(self, db, db_dummy_data): 76 | db.add_one({'name': 'john', 'url': 'john.com', 'method': 'get'}) 77 | 78 | windows = self._get_windows(root_container) 79 | 80 | dummy_one = windows[4] 81 | button_one = windows[5] 82 | button_two = windows[6] 83 | button_three = windows[7] 84 | dummy_two = windows[8] 85 | 86 | assert 'CustomButton' not in str(dummy_one) 87 | assert 'CustomButton' in str(button_one) 88 | assert 'CustomButton' in str(button_two) 89 | assert 'CustomButton' in str(button_three) 90 | assert 'CustomButton' not in str(dummy_two) 91 | 92 | def test_delete_server( 93 | self, db, db_dummy_data): 94 | 95 | # Get handler for delete server key binding 96 | for key in server_kb.bindings: 97 | if 'rm_server' in str(key): 98 | delete_handler = key.handler 99 | 100 | # Create layout 101 | root = root_container.create() 102 | layout = Layout(root, 103 | focused_element=servers.content) 104 | windows = self._get_all_windows(layout) 105 | 106 | event = Event(layout) 107 | 108 | # Simulate mouse click on first server 109 | layout.current_window.content.text()[1][2](SingleClick()) 110 | 111 | # Simulate pressing key binding for deleting a server 112 | delete_handler(event) 113 | 114 | # When a server is deleted in the app we get a delete confirmation 115 | # dialog, which is stored in root.floats. Check for that 116 | # here. 117 | windows = self._get_all_windows(layout) 118 | floats = root.floats 119 | 120 | assert len(floats) == 2 121 | 122 | # Simulate pressing the OK button in the delete confirmation dialog 123 | windows[37].content.text()[2][2](SingleClick()) 124 | 125 | assert len(floats) == 1 126 | 127 | windows = self._get_all_windows(layout) 128 | 129 | dummy_one = str(windows[4]) 130 | button_one = str(windows[5]) 131 | dummy_two = str(windows[6]) 132 | 133 | assert 'Button' not in dummy_one 134 | assert 'Button' in button_one 135 | assert 'Button' not in dummy_two 136 | 137 | def test_sort_dialog(self, db_dummy_data): 138 | 139 | for key in server_kb.bindings: 140 | if 'sort' in str(key): 141 | sort_handler = key.handler 142 | 143 | # Create layout 144 | root = root_container.create() 145 | layout = Layout(root, 146 | focused_element=servers.content) 147 | windows = self._get_all_windows(layout) 148 | 149 | assert len(root_container.floats) == 1 150 | 151 | event = Event(layout) 152 | sort_handler(event) 153 | 154 | assert len(root_container.floats) == 2 155 | 156 | windows = self._get_all_windows(layout) 157 | windows[36].content.text()[2][2](SingleClick()) 158 | 159 | assert len(root_container.floats) == 1 160 | 161 | 162 | class Event: 163 | def __init__(self, layout): 164 | self.app = App(layout) 165 | self.event = self.app 166 | 167 | 168 | class App: 169 | def __init__(self, layout): 170 | self.layout = layout 171 | -------------------------------------------------------------------------------- /tests/test_requests.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import json 3 | 4 | from freud.api.request import request_handler 5 | from freud.ui.text_buffers import response_box 6 | 7 | 8 | class TestResponses: 9 | 10 | @pytest.mark.asyncio 11 | async def test_get_request(self, db_request_data): 12 | # Need to create response_box here, so response_box.lexer is available 13 | response_box.create() 14 | 15 | response = await request_handler('httpbin-get') 16 | headers, _ = response.get('response') 17 | 18 | assert headers.startswith("['200 Ok']") 19 | 20 | @pytest.mark.asyncio 21 | async def test_delete_request(self, db_request_data): 22 | 23 | response = await request_handler('httpbin-delete') 24 | headers, _ = response.get('response') 25 | 26 | assert headers.startswith("['200 Ok']") 27 | 28 | @pytest.mark.asyncio 29 | async def test_patch_request(self, db_request_data): 30 | 31 | response = await request_handler('httpbin-patch') 32 | headers, _ = response.get('response') 33 | 34 | assert headers.startswith("['200 Ok']") 35 | 36 | @pytest.mark.asyncio 37 | async def test_post_request(self, db_request_data): 38 | 39 | response = await request_handler('httpbin-post') 40 | _, output = response.get('response') 41 | 42 | r = json.loads(output) 43 | post_data = r['data'] 44 | 45 | assert post_data == '{"name": "alice"}' 46 | 47 | @pytest.mark.asyncio 48 | async def test_put_request(self, db_request_data): 49 | 50 | response = await request_handler('httpbin-put') 51 | headers, _ = response.get('response') 52 | 53 | assert headers.startswith("['200 Ok']") 54 | 55 | @pytest.mark.asyncio 56 | async def test_post_form(self, db_request_data): 57 | 58 | response = await request_handler('httpbin-post-form') 59 | _, output = response.get('response') 60 | 61 | r = json.loads(output) 62 | form_data = r['form'] 63 | 64 | assert form_data['email'] == 'alice@email.com' 65 | assert form_data['name'] == 'alice' 66 | 67 | @pytest.mark.asyncio 68 | async def test_basic_auth(self, db_request_data): 69 | 70 | response = await request_handler('httpbin-basic-auth-200') 71 | headers, _ = response.get('response') 72 | 73 | assert headers.startswith("['200 Ok']") 74 | 75 | response = await request_handler('httpbin-basic-auth-401') 76 | headers, _ = response.get('response') 77 | 78 | assert headers.startswith("['401 Unauthorized']") 79 | 80 | @pytest.mark.asyncio 81 | async def test_digest_auth(self, db_request_data): 82 | 83 | response = await request_handler('httpbin-digest-auth-200') 84 | headers, _ = response.get('response') 85 | 86 | assert headers.startswith("['200 Ok']") 87 | 88 | response = await request_handler('httpbin-digest-auth-401') 89 | headers, _ = response.get('response') 90 | 91 | assert headers.startswith("['401 Unauthorized']") 92 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | from prompt_toolkit.mouse_events import MouseEventType 2 | 3 | 4 | class SingleClick: 5 | def __init__(self): 6 | self.event_type = MouseEventType.MOUSE_UP 7 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py35,py36,py37 3 | 4 | [testenv] 5 | commands = 6 | pip install -q -e .[dev] 7 | pytest 8 | --------------------------------------------------------------------------------