restiny 0.2.1__py3-none-any.whl → 0.5.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.
restiny/httpx_auths.py ADDED
@@ -0,0 +1,52 @@
1
+ from collections.abc import Generator
2
+
3
+ import httpx
4
+
5
+
6
+ class BearerAuth(httpx.Auth):
7
+ """
8
+ Adds a Bearer token to the Authorization header of each request.
9
+ """
10
+
11
+ def __init__(self, token: str) -> None:
12
+ self._token = token
13
+
14
+ def auth_flow(
15
+ self, request: httpx.Request
16
+ ) -> Generator[httpx.Request, httpx.Response]:
17
+ request.headers['authorization'] = f'Bearer {self._token}'
18
+ yield request
19
+
20
+
21
+ class APIKeyHeaderAuth(httpx.Auth):
22
+ """
23
+ Adds an API key to the request headers.
24
+ """
25
+
26
+ def __init__(self, key: str, value: str) -> None:
27
+ self._key = key
28
+ self._value = value
29
+
30
+ def auth_flow(
31
+ self, request: httpx.Request
32
+ ) -> Generator[httpx.Request, httpx.Response]:
33
+ request.headers[self._key] = self._value
34
+ yield request
35
+
36
+
37
+ class APIKeyParamAuth(httpx.Auth):
38
+ """
39
+ Adds an API key as a query parameter to the request URL.
40
+ """
41
+
42
+ def __init__(self, key: str, value: str) -> None:
43
+ self._key = key
44
+ self._value = value
45
+
46
+ def auth_flow(
47
+ self, request: httpx.Request
48
+ ) -> Generator[httpx.Request, httpx.Response]:
49
+ request.url = request.url.copy_with(
50
+ params=request.url.params.set(self._key, self._value)
51
+ )
52
+ yield request
restiny/ui/__init__.py ADDED
@@ -0,0 +1,15 @@
1
+ """
2
+ This module contains the specific sections of the DataFox user interface (UI).
3
+ """
4
+
5
+ from restiny.ui.collections_area import CollectionsArea
6
+ from restiny.ui.request_area import RequestArea
7
+ from restiny.ui.response_area import ResponseArea
8
+ from restiny.ui.url_area import URLArea
9
+
10
+ __all__ = [
11
+ 'RequestArea',
12
+ 'ResponseArea',
13
+ 'URLArea',
14
+ 'CollectionsArea',
15
+ ]
restiny/ui/app.py ADDED
@@ -0,0 +1,500 @@
1
+ import asyncio
2
+ from http import HTTPStatus
3
+
4
+ import httpx
5
+ import pyperclip
6
+ from textual import on
7
+ from textual.app import App, ComposeResult
8
+ from textual.binding import Binding
9
+ from textual.containers import Horizontal, Vertical
10
+ from textual.events import DescendantFocus
11
+ from textual.widget import Widget
12
+ from textual.widgets import Footer, Header
13
+
14
+ from restiny.__about__ import __version__
15
+ from restiny.assets import STYLE_TCSS
16
+ from restiny.consts import CUSTOM_THEMES
17
+ from restiny.data.repos import FoldersSQLRepo, RequestsSQLRepo, SettingsSQLRepo
18
+ from restiny.entities import Request, Settings
19
+ from restiny.enums import (
20
+ AuthMode,
21
+ BodyMode,
22
+ BodyRawLanguage,
23
+ ContentType,
24
+ CustomThemes,
25
+ )
26
+ from restiny.ui import (
27
+ CollectionsArea,
28
+ RequestArea,
29
+ ResponseArea,
30
+ URLArea,
31
+ )
32
+ from restiny.ui.response_area import ResponseAreaData
33
+ from restiny.ui.settings_screen import SettingsScreen
34
+ from restiny.widgets.custom_text_area import CustomTextArea
35
+
36
+
37
+ class RESTinyApp(App, inherit_bindings=False):
38
+ TITLE = f'RESTiny v{__version__}'
39
+ SUB_TITLE = 'Minimal HTTP client, no bullshit'
40
+ ENABLE_COMMAND_PALETTE = False
41
+ CSS_PATH = STYLE_TCSS
42
+ BINDINGS = [
43
+ Binding(
44
+ key='escape', action='quit', description='Quit the app', show=True
45
+ ),
46
+ Binding(
47
+ key='ctrl+b',
48
+ action='toggle_collections',
49
+ description='Toggle collections',
50
+ show=True,
51
+ ),
52
+ Binding(
53
+ key='ctrl+n',
54
+ action='prompt_add',
55
+ description='Add req/folder',
56
+ show=True,
57
+ ),
58
+ Binding(
59
+ key='f2',
60
+ action='prompt_update',
61
+ description='Update req/folder',
62
+ show=True,
63
+ ),
64
+ Binding(
65
+ key='delete',
66
+ action='prompt_delete',
67
+ description='Delete req/folder',
68
+ show=True,
69
+ ),
70
+ Binding(
71
+ key='ctrl+s',
72
+ action='save',
73
+ description='Save request',
74
+ show=True,
75
+ ),
76
+ Binding(
77
+ key='f9',
78
+ action='copy_as_curl',
79
+ description='Copy as curl',
80
+ show=True,
81
+ ),
82
+ Binding(
83
+ key='f10',
84
+ action='maximize_or_minimize_area',
85
+ description='Maximize/Minimize area',
86
+ show=True,
87
+ ),
88
+ Binding(
89
+ key='f12',
90
+ action='open_settings',
91
+ description='Settings',
92
+ show=True,
93
+ ),
94
+ ]
95
+ # theme = 'textual-dark'
96
+
97
+ def __init__(
98
+ self,
99
+ folders_repo: FoldersSQLRepo,
100
+ requests_repo: RequestsSQLRepo,
101
+ settings_repo: SettingsSQLRepo,
102
+ *args,
103
+ **kwargs,
104
+ ) -> None:
105
+ super().__init__(*args, **kwargs)
106
+ self.folders_repo = folders_repo
107
+ self.requests_repo = requests_repo
108
+ self.settings_repo = settings_repo
109
+
110
+ self.active_request_task: asyncio.Task | None = None
111
+ self.selected_request: Request | None = None
112
+ self.last_focused_widget: Widget | None = None
113
+ self.last_focused_maximizable_area: Widget | None = None
114
+
115
+ def compose(self) -> ComposeResult:
116
+ yield Header(show_clock=True)
117
+ with Horizontal():
118
+ yield CollectionsArea(classes='w-1fr')
119
+ with Vertical(classes='w-6fr'):
120
+ with Horizontal(classes='h-auto'):
121
+ yield URLArea()
122
+ with Horizontal(classes='h-1fr'):
123
+ yield RequestArea()
124
+ yield ResponseArea()
125
+ yield Footer()
126
+
127
+ def on_mount(self) -> None:
128
+ self.collections_area = self.query_one(CollectionsArea)
129
+ self.url_area = self.query_one(URLArea)
130
+ self.request_area = self.query_one(RequestArea)
131
+ self.response_area = self.query_one(ResponseArea)
132
+
133
+ self.url_area.disabled = True
134
+ self.request_area.disabled = True
135
+
136
+ self._register_themes()
137
+ self._apply_settings()
138
+
139
+ def action_toggle_collections(self) -> None:
140
+ if self.collections_area.display:
141
+ self.collections_area.display = False
142
+ else:
143
+ self.collections_area.display = True
144
+
145
+ def action_prompt_add(self) -> None:
146
+ self.collections_area.prompt_add()
147
+
148
+ def action_prompt_update(self) -> None:
149
+ self.collections_area.prompt_update()
150
+
151
+ def action_prompt_delete(self) -> None:
152
+ self.collections_area.prompt_delete()
153
+
154
+ def action_save(self) -> None:
155
+ req = self.get_request()
156
+ self.requests_repo.update(request=req)
157
+ self.collections_area._populate_children(
158
+ self.collections_area.collections_tree.current_parent_folder
159
+ )
160
+ self.notify('Saved changes', severity='information')
161
+
162
+ def action_maximize_or_minimize_area(self) -> None:
163
+ if not self.last_focused_maximizable_area:
164
+ self.notify('No area focused', severity='warning')
165
+ return
166
+
167
+ if self.screen.maximized:
168
+ self.screen.minimize()
169
+ else:
170
+ self.screen.maximize(self.last_focused_maximizable_area)
171
+
172
+ def action_copy_as_curl(self) -> None:
173
+ if not self.selected_request:
174
+ self.notify(
175
+ 'Select a request before copying as CURL.',
176
+ severity='warning',
177
+ )
178
+ return
179
+
180
+ request = self.get_request()
181
+ self.copy_to_clipboard(request.to_curl())
182
+ self.notify(
183
+ 'Command CURL copied to clipboard',
184
+ severity='information',
185
+ )
186
+
187
+ def action_open_settings(self) -> None:
188
+ def on_settings_result(result: dict | None) -> None:
189
+ if not result:
190
+ return
191
+
192
+ self.settings_repo.set(Settings(theme=result['theme']))
193
+ self._apply_settings()
194
+
195
+ settings: Settings = self.settings_repo.get().data
196
+ self.push_screen(
197
+ screen=SettingsScreen(
198
+ themes=[theme.value for theme in CustomThemes],
199
+ theme=settings.theme,
200
+ ),
201
+ callback=on_settings_result,
202
+ )
203
+
204
+ def copy_to_clipboard(self, text: str) -> None:
205
+ super().copy_to_clipboard(text)
206
+ try:
207
+ # Also copy to the system clipboard (outside of the app)
208
+ pyperclip.copy(text)
209
+ except Exception:
210
+ pass
211
+
212
+ @on(DescendantFocus)
213
+ def _on_focus(self, event: DescendantFocus) -> None:
214
+ self.last_focused_widget = event.widget
215
+ last_focused_maximizable_area = self._find_maximizable_area_by_widget(
216
+ widget=event.widget
217
+ )
218
+ if last_focused_maximizable_area:
219
+ self.last_focused_maximizable_area = last_focused_maximizable_area
220
+
221
+ @on(URLArea.SendRequest)
222
+ def _on_send_request(self, message: URLArea.SendRequest) -> None:
223
+ self.active_request_task = asyncio.create_task(self._send_request())
224
+
225
+ @on(URLArea.CancelRequest)
226
+ def _on_cancel_request(self, message: URLArea.CancelRequest) -> None:
227
+ if self.active_request_task and not self.active_request_task.done():
228
+ self.active_request_task.cancel()
229
+
230
+ @on(CollectionsArea.RequestSelected)
231
+ def _on_request_selected(
232
+ self, message: CollectionsArea.RequestSelected
233
+ ) -> None:
234
+ self.url_area.disabled = False
235
+ self.request_area.disabled = False
236
+ req = self.requests_repo.get_by_id(id=message.request_id).data
237
+ self.selected_request = req
238
+ self.set_request(request=req)
239
+ self.response_area.set_data(None)
240
+ self.response_area.is_showing_response = False
241
+
242
+ def _apply_settings(self) -> None:
243
+ settings = self.settings_repo.get().data
244
+ self.theme = settings.theme
245
+ for text_area in self.query(CustomTextArea):
246
+ text_area.theme = settings.theme
247
+
248
+ def _register_themes(self) -> None:
249
+ for theme in CUSTOM_THEMES.values():
250
+ self.register_theme(theme=theme['global'])
251
+
252
+ for text_area in self.query(CustomTextArea):
253
+ for theme in CUSTOM_THEMES.values():
254
+ text_area.register_theme(theme=theme['text_area'])
255
+
256
+ def _find_maximizable_area_by_widget(
257
+ self, widget: Widget
258
+ ) -> Widget | None:
259
+ while widget is not None:
260
+ if (
261
+ isinstance(widget, CollectionsArea)
262
+ or isinstance(widget, URLArea)
263
+ or isinstance(widget, RequestArea)
264
+ or isinstance(widget, ResponseArea)
265
+ ):
266
+ return widget
267
+ widget = widget.parent
268
+
269
+ def get_request(self) -> Request:
270
+ method = self.url_area.method
271
+ url = self.url_area.url
272
+
273
+ headers = [
274
+ Request.Header(
275
+ enabled=header['enabled'],
276
+ key=header['key'],
277
+ value=header['value'],
278
+ )
279
+ for header in self.request_area.headers
280
+ ]
281
+
282
+ params = [
283
+ Request.Param(
284
+ enabled=param['enabled'],
285
+ key=param['key'],
286
+ value=param['value'],
287
+ )
288
+ for param in self.request_area.params
289
+ ]
290
+
291
+ auth_enabled = self.request_area.auth_enabled
292
+ auth_mode = self.request_area.auth_mode
293
+ auth = None
294
+ if auth_mode == AuthMode.BASIC:
295
+ auth = Request.BasicAuth(
296
+ username=self.request_area.auth_basic_username,
297
+ password=self.request_area.auth_basic_password,
298
+ )
299
+ elif auth_mode == AuthMode.BEARER:
300
+ auth = Request.BearerAuth(
301
+ token=self.request_area.auth_bearer_token
302
+ )
303
+ elif auth_mode == AuthMode.API_KEY:
304
+ auth = Request.ApiKeyAuth(
305
+ key=self.request_area.auth_api_key_key,
306
+ value=self.request_area.auth_api_key_value,
307
+ where=self.request_area.auth_api_key_where,
308
+ )
309
+ elif auth_mode == AuthMode.DIGEST:
310
+ auth = Request.DigestAuth(
311
+ username=self.request_area.auth_digest_username,
312
+ password=self.request_area.auth_digest_password,
313
+ )
314
+
315
+ body_enabled = self.request_area.body_enabled
316
+ body_mode = self.request_area.body_mode
317
+ body = None
318
+ if body_mode == BodyMode.RAW:
319
+ body = Request.RawBody(
320
+ language=BodyRawLanguage(self.request_area.body_raw_language),
321
+ value=self.request_area.body_raw,
322
+ )
323
+ elif body_mode == BodyMode.FILE:
324
+ body = Request.FileBody(file=self.request_area.body_file)
325
+ elif body_mode == BodyMode.FORM_URLENCODED:
326
+ body = Request.UrlEncodedFormBody(
327
+ fields=[
328
+ Request.UrlEncodedFormBody.Field(
329
+ enabled=form_field['enabled'],
330
+ key=form_field['key'],
331
+ value=form_field['value'],
332
+ )
333
+ for form_field in self.request_area.body_form_urlencoded
334
+ ]
335
+ )
336
+ elif body_mode == BodyMode.FORM_MULTIPART:
337
+ body = Request.MultipartFormBody(
338
+ fields=[
339
+ Request.MultipartFormBody.Field(
340
+ enabled=form_field['enabled'],
341
+ key=form_field['key'],
342
+ value=form_field['value'],
343
+ value_kind=form_field['value_kind'],
344
+ )
345
+ for form_field in self.request_area.body_form_multipart
346
+ ]
347
+ )
348
+
349
+ options = Request.Options(
350
+ timeout=self.request_area.option_timeout,
351
+ follow_redirects=self.request_area.option_follow_redirects,
352
+ verify_ssl=self.request_area.option_verify_ssl,
353
+ )
354
+
355
+ return Request(
356
+ id=self.selected_request.id,
357
+ folder_id=self.selected_request.folder_id,
358
+ name=self.selected_request.name,
359
+ method=method,
360
+ url=url,
361
+ headers=headers,
362
+ params=params,
363
+ body_enabled=body_enabled,
364
+ body_mode=body_mode,
365
+ body=body,
366
+ auth_enabled=auth_enabled,
367
+ auth_mode=auth_mode,
368
+ auth=auth,
369
+ options=options,
370
+ )
371
+
372
+ def set_request(self, request: Request) -> None:
373
+ self.url_area.method = request.method
374
+ self.url_area.url = request.url
375
+
376
+ self.request_area.headers = [
377
+ {
378
+ 'enabled': header.enabled,
379
+ 'key': header.key,
380
+ 'value': header.value,
381
+ }
382
+ for header in request.headers
383
+ ]
384
+ self.request_area.params = [
385
+ {'enabled': param.enabled, 'key': param.key, 'value': param.value}
386
+ for param in request.params
387
+ ]
388
+
389
+ self.request_area.auth_enabled = request.auth_enabled
390
+ self.request_area.auth_mode = request.auth_mode
391
+ if request.auth is not None:
392
+ if request.auth_mode == AuthMode.BASIC:
393
+ self.request_area.auth_basic_username = request.auth.username
394
+ self.request_area.auth_basic_password = request.auth.password
395
+ elif request.auth_mode == AuthMode.BEARER:
396
+ self.request_area.auth_bearer_token = request.auth.token
397
+ elif request.auth_mode == AuthMode.API_KEY:
398
+ self.request_area.auth_api_key_key = request.auth.key
399
+ self.request_area.auth_api_key_value = request.auth.value
400
+ self.request_area.auth_api_key_where = request.auth.where
401
+ elif request.auth_mode == AuthMode.DIGEST:
402
+ self.request_area.auth_digest_username = request.auth.username
403
+ self.request_area.auth_digest_password = request.auth.password
404
+
405
+ self.request_area.body_enabled = request.body_enabled
406
+ self.request_area.body_mode = request.body_mode
407
+ if request.body is not None:
408
+ if request.body_mode == BodyMode.RAW:
409
+ self.request_area.body_raw_language = request.body.language
410
+ self.request_area.body_raw = request.body.value
411
+ elif request.body_mode == BodyMode.FILE:
412
+ self.request_area.body_file = request.body.file
413
+ elif request.body_mode == BodyMode.FORM_URLENCODED:
414
+ self.request_area.body_form_urlencoded = [
415
+ {
416
+ 'enabled': form_field.enabled,
417
+ 'key': form_field.key,
418
+ 'value': form_field.value,
419
+ }
420
+ for form_field in request.body.fields
421
+ ]
422
+ elif request.body_mode == BodyMode.FORM_MULTIPART:
423
+ self.request_area.body_form_multipart = [
424
+ {
425
+ 'enabled': form_field.enabled,
426
+ 'key': form_field.key,
427
+ 'value': form_field.value,
428
+ 'value_kind': form_field.value_kind,
429
+ }
430
+ for form_field in request.body.fields
431
+ ]
432
+
433
+ self.request_area.option_follow_redirects = (
434
+ request.options.follow_redirects
435
+ )
436
+ self.request_area.option_verify_ssl = request.options.verify_ssl
437
+ self.request_area.option_timeout = str(request.options.timeout)
438
+
439
+ async def _send_request(self) -> None:
440
+ self.response_area.set_data(data=None)
441
+ self.response_area.loading = True
442
+ self.url_area.request_pending = True
443
+ try:
444
+ request = self.get_request()
445
+ async with httpx.AsyncClient(
446
+ timeout=request.options.timeout,
447
+ follow_redirects=request.options.follow_redirects,
448
+ verify=request.options.verify_ssl,
449
+ ) as http_client:
450
+ response = await http_client.send(
451
+ request=request.to_httpx_req(),
452
+ auth=request.to_httpx_auth(),
453
+ )
454
+ self._display_response(response=response)
455
+ self.response_area.is_showing_response = True
456
+ except httpx.RequestError as error:
457
+ error_name = type(error).__name__
458
+ error_message = str(error)
459
+ if error_message:
460
+ self.notify(f'{error_name}: {error_message}', severity='error')
461
+ else:
462
+ self.notify(f'{error_name}', severity='error')
463
+ self.response_area.set_data(data=None)
464
+ self.response_area.is_showing_response = False
465
+ except asyncio.CancelledError:
466
+ self.response_area.set_data(data=None)
467
+ self.response_area.is_showing_response = False
468
+ finally:
469
+ self.response_area.loading = False
470
+ self.url_area.request_pending = False
471
+
472
+ def _display_response(self, response: httpx.Response) -> None:
473
+ status = HTTPStatus(response.status_code)
474
+ size = response.num_bytes_downloaded
475
+ elapsed_time = round(response.elapsed.total_seconds(), 2)
476
+ headers = {
477
+ header_key: header_value
478
+ for header_key, header_value in response.headers.multi_items()
479
+ }
480
+ content_type_to_body_language = {
481
+ ContentType.TEXT: BodyRawLanguage.PLAIN,
482
+ ContentType.HTML: BodyRawLanguage.HTML,
483
+ ContentType.JSON: BodyRawLanguage.JSON,
484
+ ContentType.YAML: BodyRawLanguage.YAML,
485
+ ContentType.XML: BodyRawLanguage.XML,
486
+ }
487
+ body_raw_language = content_type_to_body_language.get(
488
+ response.headers.get('Content-Type'), BodyRawLanguage.PLAIN
489
+ )
490
+ body_raw = response.text
491
+ self.response_area.set_data(
492
+ data=ResponseAreaData(
493
+ status=status,
494
+ size=size,
495
+ elapsed_time=elapsed_time,
496
+ headers=headers,
497
+ body_raw_language=body_raw_language,
498
+ body_raw=body_raw,
499
+ )
500
+ )