restiny 0.2.0__py3-none-any.whl → 0.3.0__py3-none-any.whl

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/__about__.py CHANGED
@@ -1 +1 @@
1
- __version__ = '0.2.0'
1
+ __version__ = '0.3.0'
restiny/assets/style.tcss CHANGED
@@ -3,6 +3,10 @@ Button {
3
3
  max-width: 100%;
4
4
  }
5
5
 
6
+ Input {
7
+ width: 1fr;
8
+ }
9
+
6
10
  .hidden {
7
11
  display: none;
8
12
  }
@@ -19,6 +23,10 @@ Button {
19
23
  width: 2fr;
20
24
  }
21
25
 
26
+ .w-3fr {
27
+ width: 3fr;
28
+ }
29
+
22
30
  .h-auto {
23
31
  height: auto;
24
32
  }
@@ -28,7 +36,11 @@ Button {
28
36
  }
29
37
 
30
38
  .h-2fr {
31
- width: 2fr;
39
+ height: 2fr;
40
+ }
41
+
42
+ .h-3fr {
43
+ height: 3fr;
32
44
  }
33
45
 
34
46
  .p-0 {
restiny/core/app.py CHANGED
@@ -1,6 +1,7 @@
1
1
  import asyncio
2
2
  import json
3
3
  import mimetypes
4
+ from http import HTTPStatus
4
5
  from pathlib import Path
5
6
 
6
7
  import httpx
@@ -13,9 +14,23 @@ from textual.events import DescendantFocus
13
14
  from textual.widget import Widget
14
15
  from textual.widgets import Footer, Header
15
16
 
17
+ from restiny import httpx_auths
16
18
  from restiny.__about__ import __version__
17
19
  from restiny.assets import STYLE_TCSS
18
- from restiny.core import RequestArea, ResponseArea, URLArea
20
+ from restiny.core import (
21
+ RequestArea,
22
+ RequestAreaData,
23
+ ResponseArea,
24
+ URLArea,
25
+ URLAreaData,
26
+ )
27
+ from restiny.core.request_area import (
28
+ APIKeyAuth,
29
+ BasicAuth,
30
+ BearerAuth,
31
+ DigestAuth,
32
+ )
33
+ from restiny.core.response_area import ResponseAreaData
19
34
  from restiny.enums import BodyMode, BodyRawLanguage, ContentType
20
35
  from restiny.utils import build_curl_cmd
21
36
 
@@ -56,10 +71,8 @@ class RESTinyApp(App, inherit_bindings=False):
56
71
  with Horizontal(classes='h-auto'):
57
72
  yield URLArea()
58
73
  with Horizontal(classes='h-1fr'):
59
- with Vertical():
60
- yield RequestArea()
61
- with Vertical():
62
- yield ResponseArea()
74
+ yield RequestArea()
75
+ yield ResponseArea()
63
76
  yield Footer()
64
77
 
65
78
  def on_mount(self) -> None:
@@ -67,25 +80,6 @@ class RESTinyApp(App, inherit_bindings=False):
67
80
  self.request_area = self.query_one(RequestArea)
68
81
  self.response_area = self.query_one(ResponseArea)
69
82
 
70
- @on(DescendantFocus)
71
- def on_focus(self, event: DescendantFocus) -> None:
72
- self.last_focused_widget = event.widget
73
- last_focused_maximizable_area = self.find_maximizable_area_by_widget(
74
- widget=event.widget
75
- )
76
- if last_focused_maximizable_area:
77
- self.last_focused_maximizable_area = last_focused_maximizable_area
78
-
79
- @on(URLArea.SendRequest)
80
- def on_send_request(self, message: URLArea.SendRequest) -> None:
81
- self.response_area.reset_response()
82
- self.current_request = asyncio.create_task(self.send_request())
83
-
84
- @on(URLArea.CancelRequest)
85
- def on_cancel_request(self, message: URLArea.CancelRequest) -> None:
86
- if self.current_request and not self.current_request.done():
87
- self.current_request.cancel()
88
-
89
83
  def action_maximize_or_minimize_area(self) -> None:
90
84
  if self.screen.maximized:
91
85
  self.screen.minimize()
@@ -107,76 +101,128 @@ class RESTinyApp(App, inherit_bindings=False):
107
101
  headers[header.key] = header.value
108
102
 
109
103
  params = {}
110
- for param in request_area_data.query_params:
104
+ for param in request_area_data.params:
111
105
  if not param.enabled:
112
106
  continue
113
107
 
114
108
  params[param.key] = param.value
115
109
 
116
- raw_body = None
117
- form_urlencoded = {}
118
- form_multipart = {}
119
- files = None
120
- if request_area_data.body.type == BodyMode.RAW:
121
- raw_body = request_area_data.body.payload
122
- elif request_area_data.body.type == BodyMode.FORM_URLENCODED:
123
- form_urlencoded = {
124
- form_field.key: form_field.value
125
- for form_field in request_area_data.body.payload
126
- if form_field.enabled
127
- }
128
- elif request_area_data.body.type == BodyMode.FORM_MULTIPART:
129
- form_multipart = {
130
- form_field.key: form_field.value
131
- for form_field in request_area_data.body.payload
132
- if form_field.enabled
133
- }
134
- elif request_area_data.body.type == BodyMode.FILE:
135
- files = [request_area_data.body.payload]
110
+ body_raw = None
111
+ body_form_urlencoded = {}
112
+ body_form_multipart = {}
113
+ body_files = None
114
+ if request_area_data.body.enabled:
115
+ if request_area_data.body.mode == BodyMode.RAW:
116
+ body_raw = request_area_data.body.payload
117
+ elif request_area_data.body.mode == BodyMode.FORM_URLENCODED:
118
+ body_form_urlencoded = {
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.mode == BodyMode.FORM_MULTIPART:
124
+ body_form_multipart = {
125
+ form_field.key: form_field.value
126
+ for form_field in request_area_data.body.payload
127
+ if form_field.enabled
128
+ }
129
+ elif request_area_data.body.mode == BodyMode.FILE:
130
+ body_files = [request_area_data.body.payload]
131
+
132
+ auth_basic = None
133
+ auth_bearer = None
134
+ auth_api_key_header = None
135
+ auth_api_key_param = None
136
+ auth_digest = None
137
+ if request_area_data.auth.enabled:
138
+ if isinstance(request_area_data.auth.value, BasicAuth):
139
+ auth_basic = (
140
+ request_area_data.auth.value.username,
141
+ request_area_data.auth.value.password,
142
+ )
143
+ elif isinstance(request_area_data.auth.value, BearerAuth):
144
+ auth_bearer = request_area_data.auth.value.token
145
+ elif isinstance(request_area_data.auth.value, APIKeyAuth):
146
+ if request_area_data.auth.value.where == 'header':
147
+ auth_api_key_header = (
148
+ request_area_data.auth.value.key,
149
+ request_area_data.auth.value.value,
150
+ )
151
+ elif request_area_data.auth.value.where == 'param':
152
+ auth_api_key_param = (
153
+ request_area_data.auth.value.key,
154
+ request_area_data.auth.value.value,
155
+ )
156
+ elif isinstance(request_area_data.auth.value, DigestAuth):
157
+ auth_digest = (
158
+ request_area_data.auth.value.username,
159
+ request_area_data.auth.value.password,
160
+ )
136
161
 
137
162
  curl_cmd = build_curl_cmd(
138
163
  method=method,
139
164
  url=url,
140
165
  headers=headers,
141
166
  params=params,
142
- raw_body=raw_body,
143
- form_urlencoded=form_urlencoded,
144
- form_multipart=form_multipart,
145
- files=files,
167
+ body_raw=body_raw,
168
+ body_form_urlencoded=body_form_urlencoded,
169
+ body_form_multipart=body_form_multipart,
170
+ body_files=body_files,
171
+ auth_basic=auth_basic,
172
+ auth_bearer=auth_bearer,
173
+ auth_api_key_header=auth_api_key_header,
174
+ auth_api_key_param=auth_api_key_param,
175
+ auth_digest=auth_digest,
146
176
  )
147
- self.app.copy_to_clipboard(curl_cmd)
148
- pyperclip.copy(curl_cmd)
177
+ self.copy_to_clipboard(curl_cmd)
149
178
  self.notify(
150
- f'Command cURL copied to clipboard: `{curl_cmd}`',
179
+ 'Command CURL copied to clipboard',
151
180
  severity='information',
152
181
  )
153
182
 
154
- def find_maximizable_area_by_widget(self, widget: Widget) -> Widget | None:
155
- maximizable_areas: list[str] = [
156
- URLArea.__name__,
157
- RequestArea.__name__,
158
- ResponseArea.__name__,
159
- ]
183
+ def copy_to_clipboard(self, text: str) -> None:
184
+ super().copy_to_clipboard(text)
185
+ try:
186
+ # Also copy to the system clipboard (outside of the app)
187
+ pyperclip.copy(text)
188
+ except Exception:
189
+ pass
190
+
191
+ @on(DescendantFocus)
192
+ def _on_focus(self, event: DescendantFocus) -> None:
193
+ self.last_focused_widget = event.widget
194
+ last_focused_maximizable_area = self._find_maximizable_area_by_widget(
195
+ widget=event.widget
196
+ )
197
+ if last_focused_maximizable_area:
198
+ self.last_focused_maximizable_area = last_focused_maximizable_area
199
+
200
+ @on(URLArea.SendRequest)
201
+ def _on_send_request(self, message: URLArea.SendRequest) -> None:
202
+ self.current_request = asyncio.create_task(self._send_request())
203
+
204
+ @on(URLArea.CancelRequest)
205
+ def _on_cancel_request(self, message: URLArea.CancelRequest) -> None:
206
+ if self.current_request and not self.current_request.done():
207
+ self.current_request.cancel()
208
+
209
+ def _find_maximizable_area_by_widget(
210
+ self, widget: Widget
211
+ ) -> Widget | None:
160
212
  while widget is not None:
161
- if widget.__class__.__name__ in maximizable_areas:
213
+ if (
214
+ isinstance(widget, URLArea)
215
+ or isinstance(widget, RequestArea)
216
+ or isinstance(widget, ResponseArea)
217
+ ):
162
218
  return widget
163
219
  widget = widget.parent
164
220
 
165
- async def send_request(self) -> None:
221
+ async def _send_request(self) -> None:
166
222
  url_area_data = self.url_area.get_data()
167
223
  request_area_data = self.request_area.get_data()
168
224
 
169
- headers: dict[str, str] = {
170
- header.key: header.value
171
- for header in request_area_data.headers
172
- if header.enabled
173
- }
174
- query_params: dict[str, str] = {
175
- param.key: param.value
176
- for param in request_area_data.query_params
177
- if param.enabled
178
- }
179
-
225
+ self.response_area.set_data(data=None)
180
226
  self.response_area.loading = True
181
227
  self.url_area.request_pending = True
182
228
  try:
@@ -185,115 +231,17 @@ class RESTinyApp(App, inherit_bindings=False):
185
231
  follow_redirects=request_area_data.options.follow_redirects,
186
232
  verify=request_area_data.options.verify_ssl,
187
233
  ) as http_client:
188
- request = None
189
-
190
- if not request_area_data.body.enabled:
191
- request = http_client.build_request(
192
- method=url_area_data.method,
193
- url=url_area_data.url,
194
- headers=headers,
195
- params=query_params,
196
- )
197
- else:
198
- if request_area_data.body.type == BodyMode.RAW:
199
- raw = request_area_data.body.payload
200
-
201
- if (
202
- request_area_data.body.raw_language
203
- == BodyRawLanguage.JSON
204
- ):
205
- headers['content-type'] = ContentType.JSON
206
- try:
207
- raw = json.dumps(raw)
208
- except Exception:
209
- pass
210
- elif (
211
- request_area_data.body.raw_language
212
- == BodyRawLanguage.YAML
213
- ):
214
- headers['content-type'] = ContentType.YAML
215
- elif (
216
- request_area_data.body.raw_language
217
- == BodyRawLanguage.HTML
218
- ):
219
- headers['content-type'] = ContentType.HTML
220
- elif (
221
- request_area_data.body.raw_language
222
- == BodyRawLanguage.XML
223
- ):
224
- headers['content-type'] = ContentType.XML
225
- elif (
226
- request_area_data.body.raw_language
227
- == BodyRawLanguage.PLAIN
228
- ):
229
- headers['content-type'] = ContentType.TEXT
230
-
231
- request = http_client.build_request(
232
- method=url_area_data.method,
233
- url=url_area_data.url,
234
- headers=headers,
235
- params=query_params,
236
- content=raw,
237
- )
238
- elif request_area_data.body.type == BodyMode.FILE:
239
- file = request_area_data.body.payload
240
- if 'content-type' not in headers:
241
- headers['content-type'] = (
242
- mimetypes.guess_type(file.name)[0]
243
- or 'application/octet-stream'
244
- )
245
- request = http_client.build_request(
246
- method=url_area_data.method,
247
- url=url_area_data.url,
248
- headers=headers,
249
- params=query_params,
250
- content=file.read_bytes(),
251
- )
252
- elif (
253
- request_area_data.body.type == BodyMode.FORM_URLENCODED
254
- ):
255
- form_urlencoded = {
256
- form_item.key: form_item.value
257
- for form_item in request_area_data.body.payload
258
- if form_item.enabled
259
- }
260
- request = http_client.build_request(
261
- method=url_area_data.method,
262
- url=url_area_data.url,
263
- headers=headers,
264
- params=query_params,
265
- data=form_urlencoded,
266
- )
267
- elif (
268
- request_area_data.body.type == BodyMode.FORM_MULTIPART
269
- ):
270
- form_multipart_str = {
271
- form_item.key: form_item.value
272
- for form_item in request_area_data.body.payload
273
- if form_item.enabled
274
- and isinstance(form_item.value, str)
275
- }
276
- form_multipart_files = {
277
- form_item.key: (
278
- form_item.value.name,
279
- form_item.value.read_bytes(),
280
- mimetypes.guess_type(form_item.value.name)[0]
281
- or 'application/octet-stream',
282
- )
283
- for form_item in request_area_data.body.payload
284
- if form_item.enabled
285
- and isinstance(form_item.value, Path)
286
- }
287
- request = http_client.build_request(
288
- method=url_area_data.method,
289
- url=url_area_data.url,
290
- headers=headers,
291
- params=query_params,
292
- data=form_multipart_str,
293
- files=form_multipart_files,
294
- )
295
-
296
- response = await http_client.send(request=request)
234
+ request = self._build_request(
235
+ http_client=http_client,
236
+ url_area_data=url_area_data,
237
+ request_area_data=request_area_data,
238
+ )
239
+ auth = self._build_auth(
240
+ request_area_data=request_area_data,
241
+ )
242
+ response = await http_client.send(request=request, auth=auth)
243
+ self._display_response(response=response)
244
+ self.response_area.is_showing_response = True
297
245
  except httpx.RequestError as error:
298
246
  error_name = type(error).__name__
299
247
  error_message = str(error)
@@ -301,42 +249,175 @@ class RESTinyApp(App, inherit_bindings=False):
301
249
  self.notify(f'{error_name}: {error_message}', severity='error')
302
250
  else:
303
251
  self.notify(f'{error_name}', severity='error')
304
- self.response_area.has_response = False
252
+ self.response_area.set_data(data=None)
253
+ self.response_area.is_showing_response = False
305
254
  except asyncio.CancelledError:
306
- self.response_area.has_response = False
307
- else:
308
- self.display_response(response=response)
309
- self.response_area.has_response = True
255
+ self.response_area.set_data(data=None)
256
+ self.response_area.is_showing_response = False
310
257
  finally:
311
258
  self.response_area.loading = False
312
259
  self.url_area.request_pending = False
313
260
 
314
- def display_response(self, response: httpx.Response) -> None:
315
- def display_status() -> None:
316
- self.response_area.border_title = (
317
- f'Response - {response.status_code} {response.reason_phrase}'
261
+ def _build_request(
262
+ self,
263
+ http_client: httpx.AsyncClient,
264
+ url_area_data: URLAreaData,
265
+ request_area_data: RequestAreaData,
266
+ ) -> tuple[httpx.Request, httpx.Auth | None]:
267
+ headers: dict[str, str] = {
268
+ header.key: header.value
269
+ for header in request_area_data.headers
270
+ if header.enabled
271
+ }
272
+ params: dict[str, str] = {
273
+ param.key: param.value
274
+ for param in request_area_data.params
275
+ if param.enabled
276
+ }
277
+
278
+ if not request_area_data.body.enabled:
279
+ return http_client.build_request(
280
+ method=url_area_data.method,
281
+ url=url_area_data.url,
282
+ headers=headers,
283
+ params=params,
318
284
  )
319
285
 
320
- def display_size_and_elapsed_time() -> None:
321
- self.response_area.border_subtitle = f'{response.num_bytes_downloaded} bytes in {response.elapsed.total_seconds():.2f} seconds'
286
+ if request_area_data.body.mode == BodyMode.RAW:
287
+ raw_language_to_content_type = {
288
+ BodyRawLanguage.JSON: ContentType.JSON,
289
+ BodyRawLanguage.YAML: ContentType.YAML,
290
+ BodyRawLanguage.HTML: ContentType.HTML,
291
+ BodyRawLanguage.XML: ContentType.XML,
292
+ BodyRawLanguage.PLAIN: ContentType.TEXT,
293
+ }
294
+ headers['content-type'] = raw_language_to_content_type.get(
295
+ request_area_data.body.raw_language, ContentType.TEXT
296
+ )
322
297
 
323
- def display_headers() -> None:
324
- for header_key, header_value in response.headers.multi_items():
325
- self.response_area.headers_data_table.add_row(
326
- header_key, header_value
298
+ raw = request_area_data.body.payload
299
+ if headers['content-type'] == ContentType.JSON:
300
+ try:
301
+ raw = json.dumps(raw)
302
+ except Exception:
303
+ pass
304
+
305
+ return http_client.build_request(
306
+ method=url_area_data.method,
307
+ url=url_area_data.url,
308
+ headers=headers,
309
+ params=params,
310
+ content=raw,
311
+ )
312
+ elif request_area_data.body.mode == BodyMode.FILE:
313
+ file = request_area_data.body.payload
314
+ if 'content-type' not in headers:
315
+ headers['content-type'] = (
316
+ mimetypes.guess_type(file.name)[0]
317
+ or 'application/octet-stream'
318
+ )
319
+ return http_client.build_request(
320
+ method=url_area_data.method,
321
+ url=url_area_data.url,
322
+ headers=headers,
323
+ params=params,
324
+ content=file.read_bytes(),
325
+ )
326
+ elif request_area_data.body.mode == BodyMode.FORM_URLENCODED:
327
+ form_urlencoded = {
328
+ form_item.key: form_item.value
329
+ for form_item in request_area_data.body.payload
330
+ if form_item.enabled
331
+ }
332
+ return http_client.build_request(
333
+ method=url_area_data.method,
334
+ url=url_area_data.url,
335
+ headers=headers,
336
+ params=params,
337
+ data=form_urlencoded,
338
+ )
339
+ elif request_area_data.body.mode == BodyMode.FORM_MULTIPART:
340
+ form_multipart_str = {
341
+ form_item.key: form_item.value
342
+ for form_item in request_area_data.body.payload
343
+ if form_item.enabled and isinstance(form_item.value, str)
344
+ }
345
+ form_multipart_files = {
346
+ form_item.key: (
347
+ form_item.value.name,
348
+ form_item.value.read_bytes(),
349
+ mimetypes.guess_type(form_item.value.name)[0]
350
+ or 'application/octet-stream',
327
351
  )
352
+ for form_item in request_area_data.body.payload
353
+ if form_item.enabled and isinstance(form_item.value, Path)
354
+ }
355
+ return http_client.build_request(
356
+ method=url_area_data.method,
357
+ url=url_area_data.url,
358
+ headers=headers,
359
+ params=params,
360
+ data=form_multipart_str,
361
+ files=form_multipart_files,
362
+ )
363
+
364
+ def _build_auth(
365
+ self,
366
+ request_area_data: RequestAreaData,
367
+ ) -> httpx.Auth | None:
368
+ auth = request_area_data.auth
328
369
 
329
- def display_body() -> None:
330
- resp_content_type: str = response.headers.get('Content-Type', '')
331
- body_text_language = resp_content_type.rsplit('/')[1].lower()
332
- self.response_area.body_type_select.value = body_text_language
333
- self.response_area.body_text_area.language = body_text_language
334
- self.response_area.body_text_area.insert(response.text)
335
- self.response_area.body_text_area.scroll_home(
336
- animate=False, force=True, immediate=True
370
+ if not auth.enabled:
371
+ return
372
+
373
+ if isinstance(auth.value, BasicAuth):
374
+ return httpx.BasicAuth(
375
+ username=auth.value.username,
376
+ password=auth.value.password,
377
+ )
378
+ elif isinstance(auth.value, BearerAuth):
379
+ return httpx_auths.BearerAuth(token=auth.value.token)
380
+ elif isinstance(auth.value, APIKeyAuth):
381
+ if auth.value.where == 'header':
382
+ return httpx_auths.APIKeyHeaderAuth(
383
+ key=auth.value.key, value=auth.value.value
384
+ )
385
+ elif auth.value.where == 'param':
386
+ return httpx_auths.APIKeyParamAuth(
387
+ key=auth.value.key, value=auth.value.value
388
+ )
389
+ elif isinstance(auth.value, DigestAuth):
390
+ return httpx.DigestAuth(
391
+ username=auth.value.username,
392
+ password=auth.value.password,
337
393
  )
338
394
 
339
- display_status()
340
- display_size_and_elapsed_time()
341
- display_headers()
342
- display_body()
395
+ def _display_response(self, response: httpx.Response) -> None:
396
+ status = HTTPStatus(response.status_code)
397
+ size = response.num_bytes_downloaded
398
+ elapsed_time = round(response.elapsed.total_seconds(), 2)
399
+ headers = {
400
+ header_key: header_value
401
+ for header_key, header_value in response.headers.multi_items()
402
+ }
403
+ content_type_to_body_language = {
404
+ ContentType.TEXT: BodyRawLanguage.PLAIN,
405
+ ContentType.HTML: BodyRawLanguage.HTML,
406
+ ContentType.JSON: BodyRawLanguage.JSON,
407
+ ContentType.YAML: BodyRawLanguage.YAML,
408
+ ContentType.XML: BodyRawLanguage.XML,
409
+ }
410
+ body_raw_language = content_type_to_body_language.get(
411
+ response.headers.get('Content-Type'), BodyRawLanguage.PLAIN
412
+ )
413
+ body_raw = response.text
414
+ self.response_area.set_data(
415
+ data=ResponseAreaData(
416
+ status=status,
417
+ size=size,
418
+ elapsed_time=elapsed_time,
419
+ headers=headers,
420
+ body_raw_language=body_raw_language,
421
+ body_raw=body_raw,
422
+ )
423
+ )