restiny 0.2.0__tar.gz → 0.2.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of restiny might be problematic. Click here for more details.
- {restiny-0.2.0 → restiny-0.2.1}/PKG-INFO +1 -1
- restiny-0.2.1/restiny/__about__.py +1 -0
- restiny-0.2.1/restiny/core/app.py +348 -0
- {restiny-0.2.0 → restiny-0.2.1}/restiny/core/request_area.py +76 -77
- {restiny-0.2.0 → restiny-0.2.1}/restiny/core/response_area.py +53 -20
- {restiny-0.2.0 → restiny-0.2.1}/restiny/core/url_area.py +28 -20
- {restiny-0.2.0 → restiny-0.2.1}/restiny/widgets/dynamic_fields.py +129 -103
- {restiny-0.2.0 → restiny-0.2.1}/restiny/widgets/path_chooser.py +49 -28
- {restiny-0.2.0 → restiny-0.2.1}/restiny.egg-info/PKG-INFO +1 -1
- {restiny-0.2.0 → restiny-0.2.1}/restiny.egg-info/SOURCES.txt +0 -2
- restiny-0.2.0/restiny/__about__.py +0 -1
- restiny-0.2.0/restiny/core/app.py +0 -342
- restiny-0.2.0/restiny/screens/__init__.py +0 -0
- restiny-0.2.0/restiny/screens/dialog.py +0 -109
- {restiny-0.2.0 → restiny-0.2.1}/LICENSE +0 -0
- {restiny-0.2.0 → restiny-0.2.1}/README.md +0 -0
- {restiny-0.2.0 → restiny-0.2.1}/pyproject.toml +0 -0
- {restiny-0.2.0 → restiny-0.2.1}/restiny/__init__.py +0 -0
- {restiny-0.2.0 → restiny-0.2.1}/restiny/__main__.py +0 -0
- {restiny-0.2.0 → restiny-0.2.1}/restiny/assets/__init__.py +0 -0
- {restiny-0.2.0 → restiny-0.2.1}/restiny/assets/style.tcss +0 -0
- {restiny-0.2.0 → restiny-0.2.1}/restiny/consts.py +0 -0
- {restiny-0.2.0 → restiny-0.2.1}/restiny/core/__init__.py +0 -0
- {restiny-0.2.0 → restiny-0.2.1}/restiny/enums.py +0 -0
- {restiny-0.2.0 → restiny-0.2.1}/restiny/utils.py +0 -0
- {restiny-0.2.0 → restiny-0.2.1}/restiny/widgets/__init__.py +0 -0
- {restiny-0.2.0 → restiny-0.2.1}/restiny/widgets/custom_directory_tree.py +0 -0
- {restiny-0.2.0 → restiny-0.2.1}/restiny/widgets/custom_text_area.py +0 -0
- {restiny-0.2.0 → restiny-0.2.1}/restiny.egg-info/dependency_links.txt +0 -0
- {restiny-0.2.0 → restiny-0.2.1}/restiny.egg-info/entry_points.txt +0 -0
- {restiny-0.2.0 → restiny-0.2.1}/restiny.egg-info/requires.txt +0 -0
- {restiny-0.2.0 → restiny-0.2.1}/restiny.egg-info/top_level.txt +0 -0
- {restiny-0.2.0 → restiny-0.2.1}/setup.cfg +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = '0.2.1'
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import mimetypes
|
|
4
|
+
from http import HTTPStatus
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
import pyperclip
|
|
9
|
+
from textual import on
|
|
10
|
+
from textual.app import App, ComposeResult
|
|
11
|
+
from textual.binding import Binding
|
|
12
|
+
from textual.containers import Horizontal, Vertical
|
|
13
|
+
from textual.events import DescendantFocus
|
|
14
|
+
from textual.widget import Widget
|
|
15
|
+
from textual.widgets import Footer, Header
|
|
16
|
+
|
|
17
|
+
from restiny.__about__ import __version__
|
|
18
|
+
from restiny.assets import STYLE_TCSS
|
|
19
|
+
from restiny.core import (
|
|
20
|
+
RequestArea,
|
|
21
|
+
RequestAreaData,
|
|
22
|
+
ResponseArea,
|
|
23
|
+
URLArea,
|
|
24
|
+
URLAreaData,
|
|
25
|
+
)
|
|
26
|
+
from restiny.core.response_area import ResponseAreaData
|
|
27
|
+
from restiny.enums import BodyMode, BodyRawLanguage, ContentType
|
|
28
|
+
from restiny.utils import build_curl_cmd
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class RESTinyApp(App, inherit_bindings=False):
|
|
32
|
+
TITLE = f'RESTiny v{__version__}'
|
|
33
|
+
SUB_TITLE = 'Minimal HTTP client, no bullshit'
|
|
34
|
+
ENABLE_COMMAND_PALETTE = False
|
|
35
|
+
CSS_PATH = STYLE_TCSS
|
|
36
|
+
BINDINGS = [
|
|
37
|
+
Binding(
|
|
38
|
+
key='escape', action='quit', description='Quit the app', show=True
|
|
39
|
+
),
|
|
40
|
+
Binding(
|
|
41
|
+
key='f10',
|
|
42
|
+
action='maximize_or_minimize_area',
|
|
43
|
+
description='Maximize/Minimize area',
|
|
44
|
+
show=True,
|
|
45
|
+
),
|
|
46
|
+
Binding(
|
|
47
|
+
key='f9',
|
|
48
|
+
action='copy_as_curl',
|
|
49
|
+
description='Copy as curl',
|
|
50
|
+
show=True,
|
|
51
|
+
),
|
|
52
|
+
]
|
|
53
|
+
theme = 'textual-dark'
|
|
54
|
+
|
|
55
|
+
def __init__(self, *args, **kwargs) -> None:
|
|
56
|
+
super().__init__(*args, **kwargs)
|
|
57
|
+
self.current_request: asyncio.Task | None = None
|
|
58
|
+
self.last_focused_widget: Widget | None = None
|
|
59
|
+
self.last_focused_maximizable_area: Widget | None = None
|
|
60
|
+
|
|
61
|
+
def compose(self) -> ComposeResult:
|
|
62
|
+
yield Header(show_clock=True)
|
|
63
|
+
with Vertical(id='main-content'):
|
|
64
|
+
with Horizontal(classes='h-auto'):
|
|
65
|
+
yield URLArea()
|
|
66
|
+
with Horizontal(classes='h-1fr'):
|
|
67
|
+
with Vertical():
|
|
68
|
+
yield RequestArea()
|
|
69
|
+
with Vertical():
|
|
70
|
+
yield ResponseArea()
|
|
71
|
+
yield Footer()
|
|
72
|
+
|
|
73
|
+
def on_mount(self) -> None:
|
|
74
|
+
self.url_area = self.query_one(URLArea)
|
|
75
|
+
self.request_area = self.query_one(RequestArea)
|
|
76
|
+
self.response_area = self.query_one(ResponseArea)
|
|
77
|
+
|
|
78
|
+
def action_maximize_or_minimize_area(self) -> None:
|
|
79
|
+
if self.screen.maximized:
|
|
80
|
+
self.screen.minimize()
|
|
81
|
+
else:
|
|
82
|
+
self.screen.maximize(self.last_focused_maximizable_area)
|
|
83
|
+
|
|
84
|
+
def action_copy_as_curl(self) -> None:
|
|
85
|
+
url_area_data = self.url_area.get_data()
|
|
86
|
+
request_area_data = self.request_area.get_data()
|
|
87
|
+
|
|
88
|
+
method = url_area_data.method
|
|
89
|
+
url = url_area_data.url
|
|
90
|
+
|
|
91
|
+
headers = {}
|
|
92
|
+
for header in request_area_data.headers:
|
|
93
|
+
if not header.enabled:
|
|
94
|
+
continue
|
|
95
|
+
|
|
96
|
+
headers[header.key] = header.value
|
|
97
|
+
|
|
98
|
+
params = {}
|
|
99
|
+
for param in request_area_data.query_params:
|
|
100
|
+
if not param.enabled:
|
|
101
|
+
continue
|
|
102
|
+
|
|
103
|
+
params[param.key] = param.value
|
|
104
|
+
|
|
105
|
+
raw_body = None
|
|
106
|
+
form_urlencoded = {}
|
|
107
|
+
form_multipart = {}
|
|
108
|
+
files = None
|
|
109
|
+
if request_area_data.body.type == BodyMode.RAW:
|
|
110
|
+
raw_body = request_area_data.body.payload
|
|
111
|
+
elif request_area_data.body.type == BodyMode.FORM_URLENCODED:
|
|
112
|
+
form_urlencoded = {
|
|
113
|
+
form_field.key: form_field.value
|
|
114
|
+
for form_field in request_area_data.body.payload
|
|
115
|
+
if form_field.enabled
|
|
116
|
+
}
|
|
117
|
+
elif request_area_data.body.type == BodyMode.FORM_MULTIPART:
|
|
118
|
+
form_multipart = {
|
|
119
|
+
form_field.key: form_field.value
|
|
120
|
+
for form_field in request_area_data.body.payload
|
|
121
|
+
if form_field.enabled
|
|
122
|
+
}
|
|
123
|
+
elif request_area_data.body.type == BodyMode.FILE:
|
|
124
|
+
files = [request_area_data.body.payload]
|
|
125
|
+
|
|
126
|
+
curl_cmd = build_curl_cmd(
|
|
127
|
+
method=method,
|
|
128
|
+
url=url,
|
|
129
|
+
headers=headers,
|
|
130
|
+
params=params,
|
|
131
|
+
raw_body=raw_body,
|
|
132
|
+
form_urlencoded=form_urlencoded,
|
|
133
|
+
form_multipart=form_multipart,
|
|
134
|
+
files=files,
|
|
135
|
+
)
|
|
136
|
+
self.copy_to_clipboard(curl_cmd)
|
|
137
|
+
self.notify(
|
|
138
|
+
'Command CURL copied to clipboard',
|
|
139
|
+
severity='information',
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
def copy_to_clipboard(self, text: str) -> None:
|
|
143
|
+
super().copy_to_clipboard(text)
|
|
144
|
+
try:
|
|
145
|
+
# Also copy to the system clipboard (outside of the app)
|
|
146
|
+
pyperclip.copy(text)
|
|
147
|
+
except Exception:
|
|
148
|
+
pass
|
|
149
|
+
|
|
150
|
+
@on(DescendantFocus)
|
|
151
|
+
def _on_focus(self, event: DescendantFocus) -> None:
|
|
152
|
+
self.last_focused_widget = event.widget
|
|
153
|
+
last_focused_maximizable_area = self._find_maximizable_area_by_widget(
|
|
154
|
+
widget=event.widget
|
|
155
|
+
)
|
|
156
|
+
if last_focused_maximizable_area:
|
|
157
|
+
self.last_focused_maximizable_area = last_focused_maximizable_area
|
|
158
|
+
|
|
159
|
+
@on(URLArea.SendRequest)
|
|
160
|
+
def _on_send_request(self, message: URLArea.SendRequest) -> None:
|
|
161
|
+
self.current_request = asyncio.create_task(self._send_request())
|
|
162
|
+
|
|
163
|
+
@on(URLArea.CancelRequest)
|
|
164
|
+
def _on_cancel_request(self, message: URLArea.CancelRequest) -> None:
|
|
165
|
+
if self.current_request and not self.current_request.done():
|
|
166
|
+
self.current_request.cancel()
|
|
167
|
+
|
|
168
|
+
def _find_maximizable_area_by_widget(
|
|
169
|
+
self, widget: Widget
|
|
170
|
+
) -> Widget | None:
|
|
171
|
+
while widget is not None:
|
|
172
|
+
if (
|
|
173
|
+
isinstance(widget, URLArea)
|
|
174
|
+
or isinstance(widget, RequestArea)
|
|
175
|
+
or isinstance(widget, ResponseArea)
|
|
176
|
+
):
|
|
177
|
+
return widget
|
|
178
|
+
widget = widget.parent
|
|
179
|
+
|
|
180
|
+
async def _send_request(self) -> None:
|
|
181
|
+
url_area_data = self.url_area.get_data()
|
|
182
|
+
request_area_data = self.request_area.get_data()
|
|
183
|
+
|
|
184
|
+
self.response_area.set_data(data=None)
|
|
185
|
+
self.response_area.loading = True
|
|
186
|
+
self.url_area.request_pending = True
|
|
187
|
+
try:
|
|
188
|
+
async with httpx.AsyncClient(
|
|
189
|
+
timeout=request_area_data.options.timeout,
|
|
190
|
+
follow_redirects=request_area_data.options.follow_redirects,
|
|
191
|
+
verify=request_area_data.options.verify_ssl,
|
|
192
|
+
) as http_client:
|
|
193
|
+
request = self._build_request(
|
|
194
|
+
http_client=http_client,
|
|
195
|
+
url_area_data=url_area_data,
|
|
196
|
+
request_area_data=request_area_data,
|
|
197
|
+
)
|
|
198
|
+
response = await http_client.send(request=request)
|
|
199
|
+
self._display_response(response=response)
|
|
200
|
+
self.response_area.is_showing_response = True
|
|
201
|
+
except httpx.RequestError as error:
|
|
202
|
+
error_name = type(error).__name__
|
|
203
|
+
error_message = str(error)
|
|
204
|
+
if error_message:
|
|
205
|
+
self.notify(f'{error_name}: {error_message}', severity='error')
|
|
206
|
+
else:
|
|
207
|
+
self.notify(f'{error_name}', severity='error')
|
|
208
|
+
self.response_area.set_data(data=None)
|
|
209
|
+
self.response_area.is_showing_response = False
|
|
210
|
+
except asyncio.CancelledError:
|
|
211
|
+
self.response_area.set_data(data=None)
|
|
212
|
+
self.response_area.is_showing_response = False
|
|
213
|
+
finally:
|
|
214
|
+
self.response_area.loading = False
|
|
215
|
+
self.url_area.request_pending = False
|
|
216
|
+
|
|
217
|
+
def _build_request(
|
|
218
|
+
self,
|
|
219
|
+
http_client: httpx.Client,
|
|
220
|
+
url_area_data: URLAreaData,
|
|
221
|
+
request_area_data: RequestAreaData,
|
|
222
|
+
) -> httpx.Request:
|
|
223
|
+
headers: dict[str, str] = {
|
|
224
|
+
header.key: header.value
|
|
225
|
+
for header in request_area_data.headers
|
|
226
|
+
if header.enabled
|
|
227
|
+
}
|
|
228
|
+
query_params: dict[str, str] = {
|
|
229
|
+
param.key: param.value
|
|
230
|
+
for param in request_area_data.query_params
|
|
231
|
+
if param.enabled
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if not request_area_data.body.enabled:
|
|
235
|
+
return http_client.build_request(
|
|
236
|
+
method=url_area_data.method,
|
|
237
|
+
url=url_area_data.url,
|
|
238
|
+
headers=headers,
|
|
239
|
+
params=query_params,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
if request_area_data.body.type == BodyMode.RAW:
|
|
243
|
+
raw_language_to_content_type = {
|
|
244
|
+
BodyRawLanguage.JSON: ContentType.JSON,
|
|
245
|
+
BodyRawLanguage.YAML: ContentType.YAML,
|
|
246
|
+
BodyRawLanguage.HTML: ContentType.HTML,
|
|
247
|
+
BodyRawLanguage.XML: ContentType.XML,
|
|
248
|
+
BodyRawLanguage.PLAIN: ContentType.TEXT,
|
|
249
|
+
}
|
|
250
|
+
headers['content-type'] = raw_language_to_content_type.get(
|
|
251
|
+
request_area_data.body.raw_language, ContentType.TEXT
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
raw = request_area_data.body.payload
|
|
255
|
+
if headers['content-type'] == ContentType.JSON:
|
|
256
|
+
try:
|
|
257
|
+
raw = json.dumps(raw)
|
|
258
|
+
except Exception:
|
|
259
|
+
pass
|
|
260
|
+
|
|
261
|
+
return http_client.build_request(
|
|
262
|
+
method=url_area_data.method,
|
|
263
|
+
url=url_area_data.url,
|
|
264
|
+
headers=headers,
|
|
265
|
+
params=query_params,
|
|
266
|
+
content=raw,
|
|
267
|
+
)
|
|
268
|
+
elif request_area_data.body.type == BodyMode.FILE:
|
|
269
|
+
file = request_area_data.body.payload
|
|
270
|
+
if 'content-type' not in headers:
|
|
271
|
+
headers['content-type'] = (
|
|
272
|
+
mimetypes.guess_type(file.name)[0]
|
|
273
|
+
or 'application/octet-stream'
|
|
274
|
+
)
|
|
275
|
+
return http_client.build_request(
|
|
276
|
+
method=url_area_data.method,
|
|
277
|
+
url=url_area_data.url,
|
|
278
|
+
headers=headers,
|
|
279
|
+
params=query_params,
|
|
280
|
+
content=file.read_bytes(),
|
|
281
|
+
)
|
|
282
|
+
elif request_area_data.body.type == BodyMode.FORM_URLENCODED:
|
|
283
|
+
form_urlencoded = {
|
|
284
|
+
form_item.key: form_item.value
|
|
285
|
+
for form_item in request_area_data.body.payload
|
|
286
|
+
if form_item.enabled
|
|
287
|
+
}
|
|
288
|
+
return http_client.build_request(
|
|
289
|
+
method=url_area_data.method,
|
|
290
|
+
url=url_area_data.url,
|
|
291
|
+
headers=headers,
|
|
292
|
+
params=query_params,
|
|
293
|
+
data=form_urlencoded,
|
|
294
|
+
)
|
|
295
|
+
elif request_area_data.body.type == BodyMode.FORM_MULTIPART:
|
|
296
|
+
form_multipart_str = {
|
|
297
|
+
form_item.key: form_item.value
|
|
298
|
+
for form_item in request_area_data.body.payload
|
|
299
|
+
if form_item.enabled and isinstance(form_item.value, str)
|
|
300
|
+
}
|
|
301
|
+
form_multipart_files = {
|
|
302
|
+
form_item.key: (
|
|
303
|
+
form_item.value.name,
|
|
304
|
+
form_item.value.read_bytes(),
|
|
305
|
+
mimetypes.guess_type(form_item.value.name)[0]
|
|
306
|
+
or 'application/octet-stream',
|
|
307
|
+
)
|
|
308
|
+
for form_item in request_area_data.body.payload
|
|
309
|
+
if form_item.enabled and isinstance(form_item.value, Path)
|
|
310
|
+
}
|
|
311
|
+
return http_client.build_request(
|
|
312
|
+
method=url_area_data.method,
|
|
313
|
+
url=url_area_data.url,
|
|
314
|
+
headers=headers,
|
|
315
|
+
params=query_params,
|
|
316
|
+
data=form_multipart_str,
|
|
317
|
+
files=form_multipart_files,
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
def _display_response(self, response: httpx.Response) -> None:
|
|
321
|
+
status = HTTPStatus(response.status_code)
|
|
322
|
+
size = response.num_bytes_downloaded
|
|
323
|
+
elapsed_time = round(response.elapsed.total_seconds(), 2)
|
|
324
|
+
headers = {
|
|
325
|
+
header_key: header_value
|
|
326
|
+
for header_key, header_value in response.headers.multi_items()
|
|
327
|
+
}
|
|
328
|
+
content_type_to_body_language = {
|
|
329
|
+
ContentType.TEXT: BodyRawLanguage.PLAIN,
|
|
330
|
+
ContentType.HTML: BodyRawLanguage.HTML,
|
|
331
|
+
ContentType.JSON: BodyRawLanguage.JSON,
|
|
332
|
+
ContentType.YAML: BodyRawLanguage.YAML,
|
|
333
|
+
ContentType.XML: BodyRawLanguage.XML,
|
|
334
|
+
}
|
|
335
|
+
body_raw_language = content_type_to_body_language.get(
|
|
336
|
+
response.headers.get('Content-Type'), BodyRawLanguage.PLAIN
|
|
337
|
+
)
|
|
338
|
+
body_raw = response.text
|
|
339
|
+
self.response_area.set_data(
|
|
340
|
+
data=ResponseAreaData(
|
|
341
|
+
status=status,
|
|
342
|
+
size=size,
|
|
343
|
+
elapsed_time=elapsed_time,
|
|
344
|
+
headers=headers,
|
|
345
|
+
body_raw_language=body_raw_language,
|
|
346
|
+
body_raw=body_raw,
|
|
347
|
+
)
|
|
348
|
+
)
|
|
@@ -174,6 +174,7 @@ class RequestArea(Static):
|
|
|
174
174
|
yield Input(
|
|
175
175
|
'5.5',
|
|
176
176
|
placeholder='5.5',
|
|
177
|
+
select_on_focus=False,
|
|
177
178
|
id='options-timeout',
|
|
178
179
|
classes='w-1fr',
|
|
179
180
|
)
|
|
@@ -214,8 +215,16 @@ class RequestArea(Static):
|
|
|
214
215
|
'#options-verify-ssl', Switch
|
|
215
216
|
)
|
|
216
217
|
|
|
218
|
+
def get_data(self) -> RequestAreaData:
|
|
219
|
+
return RequestAreaData(
|
|
220
|
+
headers=self._get_headers(),
|
|
221
|
+
query_params=self._get_query_params(),
|
|
222
|
+
body=self._get_body(),
|
|
223
|
+
options=self._get_options(),
|
|
224
|
+
)
|
|
225
|
+
|
|
217
226
|
@on(Select.Changed, '#body-mode')
|
|
218
|
-
def
|
|
227
|
+
def _on_change_body_type(self, message: Select.Changed) -> None:
|
|
219
228
|
if message.value == BodyMode.FILE:
|
|
220
229
|
self.body_mode_switcher.current = 'body-mode-file'
|
|
221
230
|
elif message.value == BodyMode.RAW:
|
|
@@ -226,27 +235,27 @@ class RequestArea(Static):
|
|
|
226
235
|
self.body_mode_switcher.current = 'body-mode-form-multipart'
|
|
227
236
|
|
|
228
237
|
@on(Select.Changed, '#body-raw-language')
|
|
229
|
-
def
|
|
238
|
+
def _on_change_body_raw_language(self, message: Select.Changed) -> None:
|
|
230
239
|
self.body_raw_editor.language = message.value
|
|
231
240
|
|
|
232
241
|
@on(DynamicFields.FieldFilled, '#body-form-urlencoded')
|
|
233
|
-
def
|
|
242
|
+
def _on_form_filled(self, message: DynamicFields.FieldFilled) -> None:
|
|
234
243
|
self.body_enabled_switch.value = True
|
|
235
244
|
|
|
236
245
|
@on(DynamicFields.FieldEmpty, '#body-form-urlencoded')
|
|
237
|
-
def
|
|
246
|
+
def _on_form_empty(self, message: DynamicFields.FieldEmpty) -> None:
|
|
238
247
|
if not message.control.filled_fields:
|
|
239
248
|
self.body_enabled_switch.value = False
|
|
240
249
|
|
|
241
250
|
@on(CustomTextArea.Changed, '#body-raw')
|
|
242
|
-
def
|
|
251
|
+
def _on_change_body_raw(self, message: CustomTextArea.Changed) -> None:
|
|
243
252
|
if self.body_raw_editor.text == '':
|
|
244
253
|
self.body_enabled_switch.value = False
|
|
245
254
|
else:
|
|
246
255
|
self.body_enabled_switch.value = True
|
|
247
256
|
|
|
248
257
|
@on(Input.Changed, '#options-timeout')
|
|
249
|
-
def
|
|
258
|
+
def _on_change_timeout(self, message: Input.Changed) -> None:
|
|
250
259
|
new_value = message.value
|
|
251
260
|
|
|
252
261
|
if new_value == '':
|
|
@@ -259,80 +268,70 @@ class RequestArea(Static):
|
|
|
259
268
|
self.options_timeout_input.value[:-1]
|
|
260
269
|
)
|
|
261
270
|
|
|
262
|
-
def
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
value=form_item['value'],
|
|
300
|
-
)
|
|
271
|
+
def _get_headers(self) -> list[HeaderField]:
|
|
272
|
+
return [
|
|
273
|
+
HeaderField(
|
|
274
|
+
enabled=header_field['enabled'],
|
|
275
|
+
key=header_field['key'],
|
|
276
|
+
value=header_field['value'],
|
|
277
|
+
)
|
|
278
|
+
for header_field in self.header_fields.values
|
|
279
|
+
]
|
|
280
|
+
|
|
281
|
+
def _get_query_params(self) -> list[QueryParamField]:
|
|
282
|
+
return [
|
|
283
|
+
QueryParamField(
|
|
284
|
+
enabled=query_param_field['enabled'],
|
|
285
|
+
key=query_param_field['key'],
|
|
286
|
+
value=query_param_field['value'],
|
|
287
|
+
)
|
|
288
|
+
for query_param_field in self.param_fields.values
|
|
289
|
+
]
|
|
290
|
+
|
|
291
|
+
def _get_body(self) -> RequestAreaData.Body:
|
|
292
|
+
body_send: bool = self.body_enabled_switch.value
|
|
293
|
+
body_type: str = BodyMode(self.body_mode_select.value)
|
|
294
|
+
|
|
295
|
+
payload = None
|
|
296
|
+
if body_type == BodyMode.RAW:
|
|
297
|
+
payload = self.body_raw_editor.text
|
|
298
|
+
elif body_type == BodyMode.FILE:
|
|
299
|
+
payload = self.body_file_path_chooser.path
|
|
300
|
+
elif body_type == BodyMode.FORM_URLENCODED:
|
|
301
|
+
payload = []
|
|
302
|
+
for form_item in self.body_form_urlencoded_fields.values:
|
|
303
|
+
payload.append(
|
|
304
|
+
FormUrlEncodedField(
|
|
305
|
+
enabled=form_item['enabled'],
|
|
306
|
+
key=form_item['key'],
|
|
307
|
+
value=form_item['value'],
|
|
301
308
|
)
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
309
|
+
)
|
|
310
|
+
elif body_type == BodyMode.FORM_MULTIPART:
|
|
311
|
+
payload = []
|
|
312
|
+
for form_item in self.body_form_multipart_fields.values:
|
|
313
|
+
payload.append(
|
|
314
|
+
FormMultipartField(
|
|
315
|
+
enabled=form_item['enabled'],
|
|
316
|
+
key=form_item['key'],
|
|
317
|
+
value=form_item['value'],
|
|
311
318
|
)
|
|
319
|
+
)
|
|
312
320
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
payload=payload,
|
|
320
|
-
)
|
|
321
|
-
|
|
322
|
-
def get_options() -> RequestAreaData.Options:
|
|
323
|
-
timeout = None
|
|
324
|
-
if self.options_timeout_input.value:
|
|
325
|
-
timeout = float(self.options_timeout_input.value)
|
|
321
|
+
return RequestAreaData.Body(
|
|
322
|
+
enabled=body_send,
|
|
323
|
+
raw_language=BodyRawLanguage(self.body_raw_language_select.value),
|
|
324
|
+
type=body_type,
|
|
325
|
+
payload=payload,
|
|
326
|
+
)
|
|
326
327
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
)
|
|
328
|
+
def _get_options(self) -> RequestAreaData.Options:
|
|
329
|
+
timeout = None
|
|
330
|
+
if self.options_timeout_input.value:
|
|
331
|
+
timeout = float(self.options_timeout_input.value)
|
|
332
332
|
|
|
333
|
-
return RequestAreaData(
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
options=get_options(),
|
|
333
|
+
return RequestAreaData.Options(
|
|
334
|
+
timeout=timeout,
|
|
335
|
+
follow_redirects=self.options_follow_redirects_switch.value,
|
|
336
|
+
verify_ssl=self.options_verify_ssl_switch.value,
|
|
338
337
|
)
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from http import HTTPStatus
|
|
3
|
+
|
|
1
4
|
from textual import on
|
|
2
5
|
from textual.app import ComposeResult
|
|
3
|
-
from textual.
|
|
6
|
+
from textual.containers import VerticalScroll
|
|
4
7
|
from textual.widgets import (
|
|
5
8
|
ContentSwitcher,
|
|
6
9
|
DataTable,
|
|
@@ -15,6 +18,16 @@ from restiny.enums import BodyRawLanguage
|
|
|
15
18
|
from restiny.widgets import CustomTextArea
|
|
16
19
|
|
|
17
20
|
|
|
21
|
+
@dataclass
|
|
22
|
+
class ResponseAreaData:
|
|
23
|
+
status: HTTPStatus
|
|
24
|
+
size: int
|
|
25
|
+
elapsed_time: float | int
|
|
26
|
+
headers: dict
|
|
27
|
+
body_raw_language: BodyRawLanguage
|
|
28
|
+
body_raw: str
|
|
29
|
+
|
|
30
|
+
|
|
18
31
|
# TODO: Implement 'Trace' tab pane
|
|
19
32
|
class ResponseArea(Static):
|
|
20
33
|
ALLOW_MAXIMIZE = True
|
|
@@ -38,8 +51,6 @@ class ResponseArea(Static):
|
|
|
38
51
|
|
|
39
52
|
"""
|
|
40
53
|
|
|
41
|
-
has_response: bool = Reactive(False, layout=True, init=True)
|
|
42
|
-
|
|
43
54
|
def compose(self) -> ComposeResult:
|
|
44
55
|
with ContentSwitcher(id='response-switcher', initial='no-content'):
|
|
45
56
|
yield Label(
|
|
@@ -49,7 +60,8 @@ class ResponseArea(Static):
|
|
|
49
60
|
|
|
50
61
|
with TabbedContent(id='content'):
|
|
51
62
|
with TabPane('Headers'):
|
|
52
|
-
|
|
63
|
+
with VerticalScroll():
|
|
64
|
+
yield DataTable(show_cursor=False, id='headers')
|
|
53
65
|
with TabPane('Body'):
|
|
54
66
|
yield Select(
|
|
55
67
|
(
|
|
@@ -60,11 +72,11 @@ class ResponseArea(Static):
|
|
|
60
72
|
('XML', BodyRawLanguage.XML),
|
|
61
73
|
),
|
|
62
74
|
allow_blank=False,
|
|
63
|
-
tooltip='
|
|
64
|
-
id='body-
|
|
75
|
+
tooltip='Syntax highlighting for the response body',
|
|
76
|
+
id='body-raw-language',
|
|
65
77
|
)
|
|
66
78
|
yield CustomTextArea.code_editor(
|
|
67
|
-
id='body', read_only=True, classes='mt-1'
|
|
79
|
+
id='body-raw', read_only=True, classes='mt-1'
|
|
68
80
|
)
|
|
69
81
|
|
|
70
82
|
def on_mount(self) -> None:
|
|
@@ -73,25 +85,46 @@ class ResponseArea(Static):
|
|
|
73
85
|
)
|
|
74
86
|
|
|
75
87
|
self.headers_data_table = self.query_one('#headers', DataTable)
|
|
76
|
-
self.
|
|
77
|
-
|
|
88
|
+
self.body_raw_language_select = self.query_one(
|
|
89
|
+
'#body-raw-language', Select
|
|
90
|
+
)
|
|
91
|
+
self.body_raw_editor = self.query_one('#body-raw', CustomTextArea)
|
|
78
92
|
|
|
79
93
|
self.headers_data_table.add_columns('Key', 'Value')
|
|
80
94
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
self.
|
|
95
|
+
def set_data(self, data: ResponseAreaData | None) -> None:
|
|
96
|
+
self.border_title = self.BORDER_TITLE
|
|
97
|
+
self.border_subtitle = ''
|
|
98
|
+
self.headers_data_table.clear()
|
|
99
|
+
self.body_raw_language_select.value = BodyRawLanguage.PLAIN
|
|
100
|
+
self.body_raw_editor.clear()
|
|
84
101
|
|
|
85
|
-
|
|
102
|
+
if data is None:
|
|
103
|
+
return
|
|
104
|
+
|
|
105
|
+
self.border_title = f'Response - {data.status} {data.status.phrase}'
|
|
106
|
+
self.border_subtitle = (
|
|
107
|
+
f'{data.size} bytes in {data.elapsed_time} seconds'
|
|
108
|
+
)
|
|
109
|
+
for header_key, header_value in data.headers.items():
|
|
110
|
+
self.headers_data_table.add_row(header_key, header_value)
|
|
111
|
+
self.body_raw_language_select.value = data.body_raw_language
|
|
112
|
+
self.body_raw_editor.text = data.body_raw
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def is_showing_response(self) -> bool:
|
|
116
|
+
if self._response_switcher.current == 'content':
|
|
117
|
+
return True
|
|
118
|
+
elif self._response_switcher.current == 'no-content':
|
|
119
|
+
return False
|
|
120
|
+
|
|
121
|
+
@is_showing_response.setter
|
|
122
|
+
def is_showing_response(self, value: bool) -> None:
|
|
86
123
|
if value is True:
|
|
87
124
|
self._response_switcher.current = 'content'
|
|
88
125
|
elif value is False:
|
|
89
126
|
self._response_switcher.current = 'no-content'
|
|
90
|
-
self.reset_response()
|
|
91
127
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
self.
|
|
95
|
-
self.headers_data_table.clear()
|
|
96
|
-
self.body_type_select.value = BodyRawLanguage.PLAIN
|
|
97
|
-
self.body_text_area.clear()
|
|
128
|
+
@on(Select.Changed, '#body-raw-language')
|
|
129
|
+
def _on_body_raw_language_changed(self, message: Select.Changed) -> None:
|
|
130
|
+
self.body_raw_editor.language = self.body_raw_language_select.value
|