restiny 0.2.0__tar.gz → 0.3.0__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 (37) hide show
  1. {restiny-0.2.0 → restiny-0.3.0}/PKG-INFO +2 -2
  2. {restiny-0.2.0 → restiny-0.3.0}/README.md +1 -1
  3. restiny-0.3.0/restiny/__about__.py +1 -0
  4. {restiny-0.2.0 → restiny-0.3.0}/restiny/assets/style.tcss +13 -1
  5. restiny-0.3.0/restiny/core/app.py +423 -0
  6. restiny-0.3.0/restiny/core/request_area.py +507 -0
  7. {restiny-0.2.0 → restiny-0.3.0}/restiny/core/response_area.py +53 -20
  8. {restiny-0.2.0 → restiny-0.3.0}/restiny/core/url_area.py +28 -20
  9. {restiny-0.2.0 → restiny-0.3.0}/restiny/enums.py +7 -0
  10. restiny-0.3.0/restiny/httpx_auths.py +52 -0
  11. restiny-0.3.0/restiny/test.py +13 -0
  12. {restiny-0.2.0 → restiny-0.3.0}/restiny/utils.py +45 -15
  13. {restiny-0.2.0 → restiny-0.3.0}/restiny/widgets/__init__.py +2 -0
  14. {restiny-0.2.0 → restiny-0.3.0}/restiny/widgets/dynamic_fields.py +129 -103
  15. restiny-0.3.0/restiny/widgets/password_input.py +159 -0
  16. {restiny-0.2.0 → restiny-0.3.0}/restiny/widgets/path_chooser.py +49 -28
  17. {restiny-0.2.0 → restiny-0.3.0}/restiny.egg-info/PKG-INFO +2 -2
  18. {restiny-0.2.0 → restiny-0.3.0}/restiny.egg-info/SOURCES.txt +3 -2
  19. restiny-0.2.0/restiny/__about__.py +0 -1
  20. restiny-0.2.0/restiny/core/app.py +0 -342
  21. restiny-0.2.0/restiny/core/request_area.py +0 -338
  22. restiny-0.2.0/restiny/screens/__init__.py +0 -0
  23. restiny-0.2.0/restiny/screens/dialog.py +0 -109
  24. {restiny-0.2.0 → restiny-0.3.0}/LICENSE +0 -0
  25. {restiny-0.2.0 → restiny-0.3.0}/pyproject.toml +0 -0
  26. {restiny-0.2.0 → restiny-0.3.0}/restiny/__init__.py +0 -0
  27. {restiny-0.2.0 → restiny-0.3.0}/restiny/__main__.py +0 -0
  28. {restiny-0.2.0 → restiny-0.3.0}/restiny/assets/__init__.py +0 -0
  29. {restiny-0.2.0 → restiny-0.3.0}/restiny/consts.py +0 -0
  30. {restiny-0.2.0 → restiny-0.3.0}/restiny/core/__init__.py +0 -0
  31. {restiny-0.2.0 → restiny-0.3.0}/restiny/widgets/custom_directory_tree.py +0 -0
  32. {restiny-0.2.0 → restiny-0.3.0}/restiny/widgets/custom_text_area.py +0 -0
  33. {restiny-0.2.0 → restiny-0.3.0}/restiny.egg-info/dependency_links.txt +0 -0
  34. {restiny-0.2.0 → restiny-0.3.0}/restiny.egg-info/entry_points.txt +0 -0
  35. {restiny-0.2.0 → restiny-0.3.0}/restiny.egg-info/requires.txt +0 -0
  36. {restiny-0.2.0 → restiny-0.3.0}/restiny.egg-info/top_level.txt +0 -0
  37. {restiny-0.2.0 → restiny-0.3.0}/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.3.0
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
@@ -254,7 +254,7 @@ Dynamic: license-file
254
254
 
255
255
  _A minimal, elegant HTTP client for Python — with a TUI interface powered by [Textual](https://github.com/Textualize/textual)._
256
256
 
257
- ![image](https://github.com/user-attachments/assets/e6f0c03a-e98e-40cd-af1d-38489d650fb1)
257
+ ![image](https://github.com/user-attachments/assets/798c994f-af7e-4be6-8f8c-87157dcf94e0)
258
258
 
259
259
  ## How to install
260
260
 
@@ -15,7 +15,7 @@
15
15
 
16
16
  _A minimal, elegant HTTP client for Python — with a TUI interface powered by [Textual](https://github.com/Textualize/textual)._
17
17
 
18
- ![image](https://github.com/user-attachments/assets/e6f0c03a-e98e-40cd-af1d-38489d650fb1)
18
+ ![image](https://github.com/user-attachments/assets/798c994f-af7e-4be6-8f8c-87157dcf94e0)
19
19
 
20
20
  ## How to install
21
21
 
@@ -0,0 +1 @@
1
+ __version__ = '0.3.0'
@@ -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 {
@@ -0,0 +1,423 @@
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 import httpx_auths
18
+ from restiny.__about__ import __version__
19
+ from restiny.assets import STYLE_TCSS
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
34
+ from restiny.enums import BodyMode, BodyRawLanguage, ContentType
35
+ from restiny.utils import build_curl_cmd
36
+
37
+
38
+ class RESTinyApp(App, inherit_bindings=False):
39
+ TITLE = f'RESTiny v{__version__}'
40
+ SUB_TITLE = 'Minimal HTTP client, no bullshit'
41
+ ENABLE_COMMAND_PALETTE = False
42
+ CSS_PATH = STYLE_TCSS
43
+ BINDINGS = [
44
+ Binding(
45
+ key='escape', action='quit', description='Quit the app', show=True
46
+ ),
47
+ Binding(
48
+ key='f10',
49
+ action='maximize_or_minimize_area',
50
+ description='Maximize/Minimize area',
51
+ show=True,
52
+ ),
53
+ Binding(
54
+ key='f9',
55
+ action='copy_as_curl',
56
+ description='Copy as curl',
57
+ show=True,
58
+ ),
59
+ ]
60
+ theme = 'textual-dark'
61
+
62
+ def __init__(self, *args, **kwargs) -> None:
63
+ super().__init__(*args, **kwargs)
64
+ self.current_request: asyncio.Task | None = None
65
+ self.last_focused_widget: Widget | None = None
66
+ self.last_focused_maximizable_area: Widget | None = None
67
+
68
+ def compose(self) -> ComposeResult:
69
+ yield Header(show_clock=True)
70
+ with Vertical(id='main-content'):
71
+ with Horizontal(classes='h-auto'):
72
+ yield URLArea()
73
+ with Horizontal(classes='h-1fr'):
74
+ yield RequestArea()
75
+ yield ResponseArea()
76
+ yield Footer()
77
+
78
+ def on_mount(self) -> None:
79
+ self.url_area = self.query_one(URLArea)
80
+ self.request_area = self.query_one(RequestArea)
81
+ self.response_area = self.query_one(ResponseArea)
82
+
83
+ def action_maximize_or_minimize_area(self) -> None:
84
+ if self.screen.maximized:
85
+ self.screen.minimize()
86
+ else:
87
+ self.screen.maximize(self.last_focused_maximizable_area)
88
+
89
+ def action_copy_as_curl(self) -> None:
90
+ url_area_data = self.url_area.get_data()
91
+ request_area_data = self.request_area.get_data()
92
+
93
+ method = url_area_data.method
94
+ url = url_area_data.url
95
+
96
+ headers = {}
97
+ for header in request_area_data.headers:
98
+ if not header.enabled:
99
+ continue
100
+
101
+ headers[header.key] = header.value
102
+
103
+ params = {}
104
+ for param in request_area_data.params:
105
+ if not param.enabled:
106
+ continue
107
+
108
+ params[param.key] = param.value
109
+
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
+ )
161
+
162
+ curl_cmd = build_curl_cmd(
163
+ method=method,
164
+ url=url,
165
+ headers=headers,
166
+ params=params,
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,
176
+ )
177
+ self.copy_to_clipboard(curl_cmd)
178
+ self.notify(
179
+ 'Command CURL copied to clipboard',
180
+ severity='information',
181
+ )
182
+
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:
212
+ while widget is not None:
213
+ if (
214
+ isinstance(widget, URLArea)
215
+ or isinstance(widget, RequestArea)
216
+ or isinstance(widget, ResponseArea)
217
+ ):
218
+ return widget
219
+ widget = widget.parent
220
+
221
+ async def _send_request(self) -> None:
222
+ url_area_data = self.url_area.get_data()
223
+ request_area_data = self.request_area.get_data()
224
+
225
+ self.response_area.set_data(data=None)
226
+ self.response_area.loading = True
227
+ self.url_area.request_pending = True
228
+ try:
229
+ async with httpx.AsyncClient(
230
+ timeout=request_area_data.options.timeout,
231
+ follow_redirects=request_area_data.options.follow_redirects,
232
+ verify=request_area_data.options.verify_ssl,
233
+ ) as http_client:
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
245
+ except httpx.RequestError as error:
246
+ error_name = type(error).__name__
247
+ error_message = str(error)
248
+ if error_message:
249
+ self.notify(f'{error_name}: {error_message}', severity='error')
250
+ else:
251
+ self.notify(f'{error_name}', severity='error')
252
+ self.response_area.set_data(data=None)
253
+ self.response_area.is_showing_response = False
254
+ except asyncio.CancelledError:
255
+ self.response_area.set_data(data=None)
256
+ self.response_area.is_showing_response = False
257
+ finally:
258
+ self.response_area.loading = False
259
+ self.url_area.request_pending = False
260
+
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,
284
+ )
285
+
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
+ )
297
+
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',
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
369
+
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,
393
+ )
394
+
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
+ )