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