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