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.

Files changed (33) hide show
  1. {restiny-0.2.0 → restiny-0.2.1}/PKG-INFO +1 -1
  2. restiny-0.2.1/restiny/__about__.py +1 -0
  3. restiny-0.2.1/restiny/core/app.py +348 -0
  4. {restiny-0.2.0 → restiny-0.2.1}/restiny/core/request_area.py +76 -77
  5. {restiny-0.2.0 → restiny-0.2.1}/restiny/core/response_area.py +53 -20
  6. {restiny-0.2.0 → restiny-0.2.1}/restiny/core/url_area.py +28 -20
  7. {restiny-0.2.0 → restiny-0.2.1}/restiny/widgets/dynamic_fields.py +129 -103
  8. {restiny-0.2.0 → restiny-0.2.1}/restiny/widgets/path_chooser.py +49 -28
  9. {restiny-0.2.0 → restiny-0.2.1}/restiny.egg-info/PKG-INFO +1 -1
  10. {restiny-0.2.0 → restiny-0.2.1}/restiny.egg-info/SOURCES.txt +0 -2
  11. restiny-0.2.0/restiny/__about__.py +0 -1
  12. restiny-0.2.0/restiny/core/app.py +0 -342
  13. restiny-0.2.0/restiny/screens/__init__.py +0 -0
  14. restiny-0.2.0/restiny/screens/dialog.py +0 -109
  15. {restiny-0.2.0 → restiny-0.2.1}/LICENSE +0 -0
  16. {restiny-0.2.0 → restiny-0.2.1}/README.md +0 -0
  17. {restiny-0.2.0 → restiny-0.2.1}/pyproject.toml +0 -0
  18. {restiny-0.2.0 → restiny-0.2.1}/restiny/__init__.py +0 -0
  19. {restiny-0.2.0 → restiny-0.2.1}/restiny/__main__.py +0 -0
  20. {restiny-0.2.0 → restiny-0.2.1}/restiny/assets/__init__.py +0 -0
  21. {restiny-0.2.0 → restiny-0.2.1}/restiny/assets/style.tcss +0 -0
  22. {restiny-0.2.0 → restiny-0.2.1}/restiny/consts.py +0 -0
  23. {restiny-0.2.0 → restiny-0.2.1}/restiny/core/__init__.py +0 -0
  24. {restiny-0.2.0 → restiny-0.2.1}/restiny/enums.py +0 -0
  25. {restiny-0.2.0 → restiny-0.2.1}/restiny/utils.py +0 -0
  26. {restiny-0.2.0 → restiny-0.2.1}/restiny/widgets/__init__.py +0 -0
  27. {restiny-0.2.0 → restiny-0.2.1}/restiny/widgets/custom_directory_tree.py +0 -0
  28. {restiny-0.2.0 → restiny-0.2.1}/restiny/widgets/custom_text_area.py +0 -0
  29. {restiny-0.2.0 → restiny-0.2.1}/restiny.egg-info/dependency_links.txt +0 -0
  30. {restiny-0.2.0 → restiny-0.2.1}/restiny.egg-info/entry_points.txt +0 -0
  31. {restiny-0.2.0 → restiny-0.2.1}/restiny.egg-info/requires.txt +0 -0
  32. {restiny-0.2.0 → restiny-0.2.1}/restiny.egg-info/top_level.txt +0 -0
  33. {restiny-0.2.0 → restiny-0.2.1}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: restiny
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: A minimalist HTTP client, no bullshit
5
5
  Author-email: Kalebe Chimanski de Almeida <kalebe.chi.almeida@gmail.com>
6
6
  License: Apache License
@@ -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 on_change_body_type(self, message: Select.Changed) -> None:
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 on_change_body_text_language(self, message: Select.Changed) -> None:
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 on_form_filled(self, message: DynamicFields.FieldFilled) -> None:
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 on_form_empty(self, message: DynamicFields.FieldEmpty) -> None:
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 on_change_body_text(self, message: CustomTextArea.Changed) -> None:
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 on_change_timeout(self, message: Input.Changed) -> None:
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 get_data(self) -> RequestAreaData:
263
- def get_headers() -> list[HeaderField]:
264
- return [
265
- HeaderField(
266
- enabled=header_field['enabled'],
267
- key=header_field['key'],
268
- value=header_field['value'],
269
- )
270
- for header_field in self.header_fields.values
271
- ]
272
-
273
- def get_query_params() -> list[QueryParamField]:
274
- return [
275
- QueryParamField(
276
- enabled=query_param_field['enabled'],
277
- key=query_param_field['key'],
278
- value=query_param_field['value'],
279
- )
280
- for query_param_field in self.param_fields.values
281
- ]
282
-
283
- def get_body() -> RequestAreaData.Body:
284
- body_send: bool = self.body_enabled_switch.value
285
- body_type: str = BodyMode(self.body_mode_select.value)
286
-
287
- payload = None
288
- if body_type == BodyMode.RAW:
289
- payload = self.body_raw_editor.text
290
- elif body_type == BodyMode.FILE:
291
- payload = self.body_file_path_chooser.path
292
- elif body_type == BodyMode.FORM_URLENCODED:
293
- payload = []
294
- for form_item in self.body_form_urlencoded_fields.values:
295
- payload.append(
296
- FormUrlEncodedField(
297
- enabled=form_item['enabled'],
298
- key=form_item['key'],
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
- elif body_type == BodyMode.FORM_MULTIPART:
303
- payload = []
304
- for form_item in self.body_form_multipart_fields.values:
305
- payload.append(
306
- FormMultipartField(
307
- enabled=form_item['enabled'],
308
- key=form_item['key'],
309
- value=form_item['value'],
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
- return RequestAreaData.Body(
314
- enabled=body_send,
315
- raw_language=BodyRawLanguage(
316
- self.body_raw_language_select.value
317
- ),
318
- type=body_type,
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
- return RequestAreaData.Options(
328
- timeout=timeout,
329
- follow_redirects=self.options_follow_redirects_switch.value,
330
- verify_ssl=self.options_verify_ssl_switch.value,
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
- headers=get_headers(),
335
- query_params=get_query_params(),
336
- body=get_body(),
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.reactive import Reactive
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
- yield DataTable(show_cursor=False, id='headers')
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='Body type',
64
- id='body-type',
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.body_type_select = self.query_one('#body-type', Select)
77
- self.body_text_area = self.query_one('#body', CustomTextArea)
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
- @on(Select.Changed, '#body-type')
82
- def on_body_type_changed(self, message: Select.Changed) -> None:
83
- self.body_text_area.language = self.body_type_select.value
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
- def watch_has_response(self, value: bool) -> None:
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
- def reset_response(self) -> None:
93
- self.border_title = self.BORDER_TITLE
94
- self.border_subtitle = ''
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