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
@@ -0,0 +1,594 @@
1
+ from dataclasses import dataclass
2
+ from typing import TYPE_CHECKING, Literal
3
+
4
+ from textual import on
5
+ from textual.app import ComposeResult
6
+ from textual.binding import Binding
7
+ from textual.containers import Horizontal, Vertical
8
+ from textual.message import Message
9
+ from textual.screen import ModalScreen
10
+ from textual.widget import Widget
11
+ from textual.widgets import (
12
+ Button,
13
+ ContentSwitcher,
14
+ RadioButton,
15
+ RadioSet,
16
+ Select,
17
+ Static,
18
+ )
19
+ from textual.widgets.tree import TreeNode
20
+
21
+ from restiny.entities import Folder, Request
22
+ from restiny.enums import HTTPMethod
23
+ from restiny.widgets import (
24
+ CollectionsTree,
25
+ ConfirmPrompt,
26
+ ConfirmPromptResult,
27
+ CustomInput,
28
+ )
29
+
30
+ if TYPE_CHECKING:
31
+ from restiny.ui.app import RESTinyApp
32
+
33
+
34
+ @dataclass
35
+ class _AddFolderResult:
36
+ id: int
37
+ parent_id: int | None
38
+ name: str
39
+
40
+
41
+ @dataclass
42
+ class _AddRequestResult:
43
+ id: int
44
+ folder_id: int
45
+ name: str
46
+
47
+
48
+ @dataclass
49
+ class _UpdateFolderResult:
50
+ id: int
51
+ parent_id: int | None
52
+ old_parent_id: int | None
53
+ name: str
54
+
55
+
56
+ @dataclass
57
+ class _UpdateRequestResult:
58
+ id: int
59
+ folder_id: int
60
+ old_folder_id: int
61
+ name: str
62
+
63
+
64
+ class _BaseEditScreen(ModalScreen):
65
+ DEFAULT_CSS = """
66
+ _BaseEditScreen {
67
+ align: center middle;
68
+ }
69
+
70
+ #modal-content {
71
+ border: heavy black;
72
+ border-title-color: gray;
73
+ background: $surface;
74
+ width: auto;
75
+ height: auto;
76
+ max-width: 40%
77
+ }
78
+
79
+ _BaseEditScreen RadioSet > RadioButton.-selected {
80
+ background: $surface;
81
+ }
82
+ """
83
+ AUTO_FOCUS = '#name'
84
+
85
+ BINDINGS = [
86
+ Binding(
87
+ key='escape',
88
+ action='dismiss',
89
+ description='Quit the screen',
90
+ show=False,
91
+ ),
92
+ ]
93
+
94
+ def __init__(
95
+ self,
96
+ kind: Literal['request', 'folder'] = 'request',
97
+ name: str = '',
98
+ parents: list[tuple[str, int | None]] = [],
99
+ parent_id: int | None = None,
100
+ ) -> None:
101
+ super().__init__()
102
+ self._kind = kind
103
+ self._name = name
104
+ self._parents = parents
105
+ self._parent_id = parent_id
106
+
107
+ def compose(self) -> ComposeResult:
108
+ with Vertical(id='modal-content'):
109
+ with Horizontal(classes='w-auto h-auto mt-1'):
110
+ with RadioSet(id='kind', classes='w-auto', compact=True):
111
+ yield RadioButton(
112
+ 'request',
113
+ value=self._kind == 'request',
114
+ classes='w-auto',
115
+ )
116
+ yield RadioButton(
117
+ 'folder',
118
+ value=self._kind == 'folder',
119
+ classes='w-auto',
120
+ )
121
+ with Horizontal(classes='w-auto h-auto mt-1'):
122
+ yield CustomInput(
123
+ value=self._name,
124
+ placeholder='Title',
125
+ select_on_focus=False,
126
+ classes='w-1fr',
127
+ id='name',
128
+ )
129
+ with Horizontal(classes='w-auto h-auto mt-1'):
130
+ yield Select(
131
+ self._parents,
132
+ value=self._parent_id,
133
+ tooltip='Parent',
134
+ allow_blank=False,
135
+ id='parent',
136
+ )
137
+ with Horizontal(classes='w-auto h-auto mt-1'):
138
+ yield Button(label='Cancel', classes='w-1fr', id='cancel')
139
+ yield Button(label='Confirm', classes='w-1fr', id='confirm')
140
+
141
+ def on_mount(self) -> None:
142
+ self.modal_content = self.query_one('#modal-content', Vertical)
143
+ self.kind_radio_set = self.query_one('#kind', RadioSet)
144
+ self.name_input = self.query_one('#name', CustomInput)
145
+ self.parent_select = self.query_one('#parent', Select)
146
+ self.cancel_button = self.query_one('#cancel', Button)
147
+ self.confirm_button = self.query_one('#confirm', Button)
148
+
149
+ self.modal_content.border_title = 'Create request/folder'
150
+
151
+ @on(Button.Pressed, '#cancel')
152
+ def _on_cancel(self, message: Button.Pressed) -> None:
153
+ self.dismiss(result=None)
154
+
155
+ def _common_validation(self) -> bool:
156
+ kind: str = self.kind_radio_set.pressed_button.label
157
+ name: str = self.name_input.value
158
+ parent_id: int | None = self.parent_select.value
159
+
160
+ if not name:
161
+ self.app.notify('Name is required', severity='error')
162
+ return False
163
+ if parent_id is None and kind == 'request':
164
+ self.app.notify(
165
+ 'Requests must belong to a folder',
166
+ severity='error',
167
+ )
168
+ return False
169
+
170
+ return True
171
+
172
+
173
+ class _AddScreen(_BaseEditScreen):
174
+ app: 'RESTinyApp'
175
+
176
+ @on(Button.Pressed, '#confirm')
177
+ def _on_confirm(self, message: Button.Pressed) -> None:
178
+ if not self._common_validation():
179
+ return
180
+
181
+ kind: str = self.kind_radio_set.pressed_button.label
182
+ name: str = self.name_input.value
183
+ parent_id: int | None = self.parent_select.value
184
+
185
+ if kind == 'folder':
186
+ resp = self.app.folders_repo.create(
187
+ Folder(name=name, parent_id=parent_id)
188
+ )
189
+ if not resp.ok:
190
+ self.app.notify(
191
+ f'Failed to create folder ({resp.status})',
192
+ severity='error',
193
+ )
194
+ return
195
+ self.app.notify('Folder created', severity='information')
196
+ self.dismiss(
197
+ result=_AddFolderResult(
198
+ id=resp.data.id,
199
+ parent_id=parent_id,
200
+ name=name,
201
+ )
202
+ )
203
+
204
+ elif kind == 'request':
205
+ resp = self.app.requests_repo.create(
206
+ Request(name=name, folder_id=parent_id)
207
+ )
208
+ if not resp.ok:
209
+ self.app.notify(
210
+ f'Failed to create request ({resp.status})',
211
+ severity='error',
212
+ )
213
+ return
214
+ self.app.notify('Request created', severity='information')
215
+ self.dismiss(
216
+ result=_AddRequestResult(
217
+ id=resp.data.id, folder_id=parent_id, name=name
218
+ )
219
+ )
220
+
221
+
222
+ class _UpdateScreen(_BaseEditScreen):
223
+ app: 'RESTinyApp'
224
+
225
+ def __init__(self, id: int, *args, **kwargs) -> None:
226
+ super().__init__(*args, **kwargs)
227
+ self._id = id
228
+
229
+ def on_mount(self) -> None:
230
+ super().on_mount()
231
+ self.kind_radio_set.disabled = True
232
+
233
+ @on(Button.Pressed, '#confirm')
234
+ def _on_confirm(self, message: Button.Pressed) -> None:
235
+ if not self._common_validation():
236
+ return
237
+
238
+ kind: str = self.kind_radio_set.pressed_button.label
239
+ name: str = self.name_input.value
240
+ parent_id: int | None = self.parent_select.value
241
+ old_parent_id: int | None = self._parent_id
242
+
243
+ if kind == 'folder':
244
+ folder = self.app.folders_repo.get_by_id(id=self._id).data
245
+ folder.name = name
246
+ folder.parent_id = parent_id
247
+ resp = self.app.folders_repo.update(folder=folder)
248
+ if not resp.ok:
249
+ self.app.notify(
250
+ f'Failed to update folder ({resp.status})',
251
+ severity='error',
252
+ )
253
+ return
254
+ self.app.notify('Folder updated', severity='information')
255
+ self.dismiss(
256
+ result=_UpdateFolderResult(
257
+ id=resp.data.id,
258
+ parent_id=parent_id,
259
+ old_parent_id=old_parent_id,
260
+ name=name,
261
+ )
262
+ )
263
+ elif kind == 'request':
264
+ request = self.app.requests_repo.get_by_id(id=self._id).data
265
+ request.name = name
266
+ request.folder_id = parent_id
267
+ update_resp = self.app.requests_repo.update(request)
268
+ if not update_resp.ok:
269
+ self.app.notify(
270
+ f'Failed to update request ({update_resp.status})',
271
+ severity='error',
272
+ )
273
+ return
274
+ self.app.notify('Request updated', severity='information')
275
+ self.dismiss(
276
+ result=_UpdateRequestResult(
277
+ id=update_resp.data.id,
278
+ folder_id=parent_id,
279
+ old_folder_id=old_parent_id,
280
+ name=name,
281
+ )
282
+ )
283
+
284
+
285
+ class CollectionsArea(Widget):
286
+ app: 'RESTinyApp'
287
+
288
+ ALLOW_MAXIMIZE = True
289
+ focusable = True
290
+ DEFAULT_CSS = """
291
+ CollectionsArea {
292
+ width: 1fr;
293
+ height: 1fr;
294
+ border: heavy black;
295
+ border-title-color: gray;
296
+ }
297
+
298
+ Static {
299
+ padding: 1;
300
+ }
301
+ """
302
+
303
+ class RequestAdded(Message):
304
+ def __init__(self, request_id: int) -> None:
305
+ super().__init__()
306
+ self.request_id = request_id
307
+
308
+ class RequestUpdated(Message):
309
+ def __init__(self, request_id: int) -> None:
310
+ super().__init__()
311
+ self.request_id = request_id
312
+
313
+ class RequestDeleted(Message):
314
+ def __init__(self, request_id: int) -> None:
315
+ super().__init__()
316
+ self.request_id = request_id
317
+
318
+ class RequestSelected(Message):
319
+ def __init__(self, request_id: int) -> None:
320
+ super().__init__()
321
+ self.request_id = request_id
322
+
323
+ class FolderAdded(Message):
324
+ def __init__(self, folder_id: int) -> None:
325
+ super().__init__()
326
+ self.folder_id = folder_id
327
+
328
+ class FolderUpdated(Message):
329
+ def __init__(self, folder_id: int) -> None:
330
+ super().__init__()
331
+ self.folder_id = folder_id
332
+
333
+ class FolderDeleted(Message):
334
+ def __init__(self, folder_id: int) -> None:
335
+ super().__init__()
336
+ self.folder_id = folder_id
337
+
338
+ class FolderSelected(Message):
339
+ def __init__(self, folder_id: int) -> None:
340
+ super().__init__()
341
+ self.folder_id = folder_id
342
+
343
+ def compose(self) -> ComposeResult:
344
+ with ContentSwitcher(id='switcher', initial='no-content'):
345
+ yield Static(
346
+ "[i]No collections yet. Press [b]'ctrl+n'[/] to create your first one.[/]",
347
+ id='no-content',
348
+ )
349
+ yield CollectionsTree('Collections', id='content')
350
+
351
+ def on_mount(self) -> None:
352
+ self.content_switcher = self.query_one(ContentSwitcher)
353
+ self.collections_tree = self.query_one(CollectionsTree)
354
+ self.border_title = 'Collections'
355
+
356
+ self._populate_children(node=self.collections_tree.root)
357
+ self._sync_content_switcher()
358
+
359
+ def prompt_add(self) -> None:
360
+ parents = [
361
+ (parent['path'], parent['id'])
362
+ for parent in self._resolve_all_folder_paths()
363
+ ]
364
+ parent_id = self.collections_tree.current_folder.data['id']
365
+ self.app.push_screen(
366
+ screen=_AddScreen(parents=parents, parent_id=parent_id),
367
+ callback=self._on_prompt_add_result,
368
+ )
369
+
370
+ def prompt_update(self) -> None:
371
+ if not self.collections_tree.cursor_node:
372
+ return
373
+
374
+ node = self.collections_tree.cursor_node
375
+ kind = None
376
+ parents = []
377
+ if node.allow_expand:
378
+ kind = 'folder'
379
+ parents = [
380
+ (parent['path'], parent['id'])
381
+ for parent in self._resolve_all_folder_paths()
382
+ if parent['id'] != node.data['id']
383
+ ]
384
+ else:
385
+ kind = 'request'
386
+ parents = [
387
+ (parent['path'], parent['id'])
388
+ for parent in self._resolve_all_folder_paths()
389
+ ]
390
+
391
+ parent_id = self.collections_tree.current_parent_folder.data['id']
392
+ self.app.push_screen(
393
+ screen=_UpdateScreen(
394
+ kind=kind,
395
+ name=node.data['name'],
396
+ parents=parents,
397
+ parent_id=parent_id,
398
+ id=node.data['id'],
399
+ ),
400
+ callback=self._on_prompt_update_result,
401
+ )
402
+
403
+ def prompt_delete(self) -> None:
404
+ if not self.collections_tree.cursor_node:
405
+ return
406
+
407
+ self.app.push_screen(
408
+ screen=ConfirmPrompt(
409
+ message='Are you sure? This action cannot be undone.'
410
+ ),
411
+ callback=self._on_prompt_delete_result,
412
+ )
413
+
414
+ @on(CollectionsTree.NodeExpanded)
415
+ def _on_node_expanded(self, message: CollectionsTree.NodeExpanded) -> None:
416
+ self._populate_children(node=message.node)
417
+
418
+ @on(CollectionsTree.NodeSelected)
419
+ def _on_node_selected(self, message: CollectionsTree.NodeSelected) -> None:
420
+ if message.node.allow_expand:
421
+ self.post_message(
422
+ message=self.FolderSelected(folder_id=message.node.data['id'])
423
+ )
424
+ else:
425
+ self.post_message(
426
+ message=self.RequestSelected(
427
+ request_id=message.node.data['id']
428
+ )
429
+ )
430
+
431
+ def _on_prompt_add_result(
432
+ self, result: _AddFolderResult | _AddRequestResult | None
433
+ ) -> None:
434
+ if result is None:
435
+ return
436
+
437
+ if isinstance(result, _AddRequestResult):
438
+ parent_node = self.collections_tree.node_by_id[result.folder_id]
439
+ self._populate_children(parent_node)
440
+ self._sync_content_switcher()
441
+ self.post_message(message=self.RequestAdded(request_id=result.id))
442
+ elif isinstance(result, _AddFolderResult):
443
+ parent_node = self.collections_tree.node_by_id[result.parent_id]
444
+ self._populate_children(parent_node)
445
+ self._sync_content_switcher()
446
+ self.post_message(message=self.FolderAdded(folder_id=result.id))
447
+
448
+ def _on_prompt_update_result(
449
+ self, result: _UpdateFolderResult | _UpdateRequestResult | None
450
+ ) -> None:
451
+ if result is None:
452
+ return
453
+
454
+ if isinstance(result, _UpdateRequestResult):
455
+ parent_node = self.collections_tree.node_by_id[result.folder_id]
456
+ old_parent_node = self.collections_tree.node_by_id[
457
+ result.old_folder_id
458
+ ]
459
+ self._populate_children(parent_node)
460
+ self._populate_children(old_parent_node)
461
+ self._sync_content_switcher()
462
+ self.post_message(
463
+ message=self.RequestUpdated(request_id=result.id)
464
+ )
465
+ elif isinstance(result, _UpdateFolderResult):
466
+ parent_node = self.collections_tree.node_by_id[result.parent_id]
467
+ old_parent_node = self.collections_tree.node_by_id[
468
+ result.old_parent_id
469
+ ]
470
+ self._populate_children(parent_node)
471
+ self._populate_children(old_parent_node)
472
+ self._sync_content_switcher()
473
+ self.post_message(message=self.FolderUpdated(folder_id=result.id))
474
+
475
+ def _on_prompt_delete_result(self, result: ConfirmPromptResult) -> None:
476
+ if not result.confirmed:
477
+ return
478
+
479
+ try:
480
+ prev_selected_index_in_parent = (
481
+ self.collections_tree.cursor_node.parent.children.index(
482
+ self.collections_tree.cursor_node
483
+ )
484
+ )
485
+ except ValueError:
486
+ prev_selected_index_in_parent = 0
487
+
488
+ if self.collections_tree.cursor_node.allow_expand:
489
+ self.app.folders_repo.delete_by_id(
490
+ self.collections_tree.cursor_node.data['id']
491
+ )
492
+ self.notify('Folder deleted', severity='information')
493
+ self._populate_children(
494
+ node=self.collections_tree.cursor_node.parent
495
+ )
496
+ self._sync_content_switcher()
497
+ self.post_message(
498
+ message=self.FolderDeleted(
499
+ folder_id=self.collections_tree.cursor_node.data['id']
500
+ )
501
+ )
502
+ else:
503
+ self.app.requests_repo.delete_by_id(
504
+ self.collections_tree.cursor_node.data['id']
505
+ )
506
+ self.notify('Request deleted', severity='information')
507
+ self._populate_children(
508
+ node=self.collections_tree.cursor_node.parent
509
+ )
510
+ self._sync_content_switcher()
511
+ self.post_message(
512
+ message=self.RequestDeleted(
513
+ request_id=self.collections_tree.cursor_node.data['id']
514
+ )
515
+ )
516
+
517
+ if self.collections_tree.cursor_node.parent.children:
518
+ next_index_to_select = min(
519
+ prev_selected_index_in_parent,
520
+ len(self.collections_tree.cursor_node.parent.children) - 1,
521
+ )
522
+ next_node_to_select = (
523
+ self.collections_tree.cursor_node.parent.children[
524
+ next_index_to_select
525
+ ]
526
+ )
527
+ else:
528
+ next_node_to_select = self.collections_tree.cursor_node.parent
529
+ self.call_after_refresh(
530
+ lambda: self.collections_tree.select_node(next_node_to_select)
531
+ )
532
+
533
+ def _populate_children(self, node: TreeNode) -> None:
534
+ folder_id = node.data['id']
535
+
536
+ folders = self.app.folders_repo.list_by_parent_id(folder_id).data
537
+ requests = self.app.requests_repo.list_by_folder_id(folder_id).data
538
+
539
+ def sort_requests(request: Request) -> tuple:
540
+ methods = [method.value for method in HTTPMethod]
541
+ method_order = {
542
+ method: index for index, method in enumerate(methods)
543
+ }
544
+ return (method_order[request.method], request.name.lower())
545
+
546
+ sorted_folders = sorted(
547
+ folders, key=lambda folder: folder.name.lower()
548
+ )
549
+ sorted_requests = sorted(requests, key=sort_requests)
550
+
551
+ for child_node in list(node.children):
552
+ self.collections_tree.remove(child_node)
553
+
554
+ for folder in sorted_folders:
555
+ self.collections_tree.add_folder(
556
+ parent_node=node, name=folder.name, id=folder.id
557
+ )
558
+
559
+ for request in sorted_requests:
560
+ self.collections_tree.add_request(
561
+ parent_node=node,
562
+ method=request.method,
563
+ name=request.name,
564
+ id=request.id,
565
+ )
566
+
567
+ node.refresh()
568
+
569
+ def _resolve_all_folder_paths(self) -> list[dict[str, str | int | None]]:
570
+ paths: list[dict[str, str | int | None]] = [{'path': '/', 'id': None}]
571
+
572
+ paths_stack: list[tuple[str, int | None]] = [('/', None)]
573
+ while paths_stack:
574
+ parent_path, parent_id = paths_stack.pop(0)
575
+
576
+ if parent_id is None:
577
+ children = self.app.folders_repo.list_roots().data
578
+ else:
579
+ children = self.app.folders_repo.list_by_parent_id(
580
+ parent_id
581
+ ).data
582
+
583
+ for folder in children:
584
+ path = f'{parent_path.rstrip("/")}/{folder.name}'
585
+ paths.append({'path': path, 'id': folder.id})
586
+ paths_stack.append((path, folder.id))
587
+
588
+ return paths
589
+
590
+ def _sync_content_switcher(self) -> None:
591
+ if self.collections_tree.root.children:
592
+ self.content_switcher.current = 'content'
593
+ else:
594
+ self.content_switcher.current = 'no-content'