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,60 @@
1
+ from typing import TYPE_CHECKING
2
+
3
+ from textual.app import ComposeResult
4
+ from textual.containers import Horizontal
5
+ from textual.widget import Widget
6
+ from textual.widgets import Select
7
+
8
+ if TYPE_CHECKING:
9
+ from restiny.ui.app import RESTinyApp
10
+
11
+
12
+ class TopBarArea(Widget):
13
+ app: 'RESTinyApp'
14
+
15
+ DEFAULT_CSS = """
16
+ TopBarArea {
17
+ width: 1fr;
18
+ height: auto;
19
+ align: right middle;
20
+ }
21
+
22
+ Select {
23
+ width: 24;
24
+ }
25
+ """
26
+
27
+ def compose(self) -> ComposeResult:
28
+ with Horizontal(classes='w-auto h-auto'):
29
+ yield Select(
30
+ [],
31
+ prompt='No environment',
32
+ allow_blank=True,
33
+ compact=True,
34
+ id='environment',
35
+ )
36
+
37
+ def on_mount(self) -> None:
38
+ self.environment_select = self.query_one('#environment', Select)
39
+
40
+ self.populate()
41
+
42
+ @property
43
+ def environment(self) -> str:
44
+ if self.environment_select.value == Select.BLANK:
45
+ return None
46
+
47
+ return self.environment_select.value
48
+
49
+ def populate(self) -> None:
50
+ prev_selected_environment = self.environment_select.value
51
+ environments = [
52
+ environment.name
53
+ for environment in self.app.environments_repo.list().data
54
+ if environment.name != 'global'
55
+ ]
56
+ self.environment_select.set_options(
57
+ (environment, environment) for environment in environments
58
+ )
59
+ if prev_selected_environment in environments:
60
+ self.environment_select.value = prev_selected_environment
@@ -1,17 +1,11 @@
1
- from dataclasses import dataclass
2
-
3
1
  from textual import on
4
2
  from textual.app import ComposeResult
3
+ from textual.containers import Horizontal
5
4
  from textual.message import Message
6
- from textual.widgets import Button, ContentSwitcher, Input, Select, Static
5
+ from textual.widgets import Button, ContentSwitcher, Select, Static
7
6
 
8
7
  from restiny.enums import HTTPMethod
9
-
10
-
11
- @dataclass
12
- class URLAreaData:
13
- method: str
14
- url: str
8
+ from restiny.widgets import CustomInput
15
9
 
16
10
 
17
11
  class URLArea(Static):
@@ -20,9 +14,8 @@ class URLArea(Static):
20
14
  BORDER_TITLE = 'URL'
21
15
  DEFAULT_CSS = """
22
16
  URLArea {
23
- layout: grid;
24
- grid-size: 3 1;
25
- grid-columns: 1fr 6fr 1fr;
17
+ width: 1fr;
18
+ height: auto;
26
19
  border: heavy black;
27
20
  border-title-color: gray;
28
21
  }
@@ -49,25 +42,34 @@ class URLArea(Static):
49
42
  self._request_pending = False
50
43
 
51
44
  def compose(self) -> ComposeResult:
52
- yield Select.from_values(
53
- values=HTTPMethod.values(), allow_blank=False, id='method'
54
- )
55
- yield Input(placeholder='Enter URL', select_on_focus=False, id='url')
56
- with ContentSwitcher(
57
- id='request-button-switcher', initial='send-request'
58
- ):
59
- yield Button(
60
- label='Send Request',
61
- id='send-request',
45
+ with Horizontal(classes='h-auto'):
46
+ yield Select.from_values(
47
+ values=[method.value for method in HTTPMethod],
48
+ allow_blank=False,
62
49
  classes='w-1fr',
63
- variant='default',
50
+ id='method',
64
51
  )
65
- yield Button(
66
- label='Cancel Request',
67
- id='cancel-request',
68
- classes='w-1fr',
69
- variant='error',
52
+ yield CustomInput(
53
+ placeholder='Enter URL',
54
+ select_on_focus=False,
55
+ classes='w-5fr',
56
+ id='url',
70
57
  )
58
+ with ContentSwitcher(
59
+ id='request-button-switcher', initial='send-request'
60
+ ):
61
+ yield Button(
62
+ label='Send Request',
63
+ id='send-request',
64
+ classes='w-1fr',
65
+ variant='default',
66
+ )
67
+ yield Button(
68
+ label='Cancel Request',
69
+ id='cancel-request',
70
+ classes='w-1fr',
71
+ variant='error',
72
+ )
71
73
 
72
74
  def on_mount(self) -> None:
73
75
  self._request_button_switcher = self.query_one(
@@ -75,16 +77,10 @@ class URLArea(Static):
75
77
  )
76
78
 
77
79
  self.method_select = self.query_one('#method', Select)
78
- self.url_input = self.query_one('#url', Input)
80
+ self.url_input = self.query_one('#url', CustomInput)
79
81
  self.send_request_button = self.query_one('#send-request', Button)
80
82
  self.cancel_request_button = self.query_one('#cancel-request', Button)
81
83
 
82
- def get_data(self) -> URLAreaData:
83
- return URLAreaData(
84
- method=self.method_select.value,
85
- url=self.url_input.value,
86
- )
87
-
88
84
  @property
89
85
  def request_pending(self) -> bool:
90
86
  return self._request_pending
@@ -98,10 +94,30 @@ class URLArea(Static):
98
94
 
99
95
  self._request_pending = value
100
96
 
97
+ @property
98
+ def method(self) -> HTTPMethod:
99
+ return self.method_select.value
100
+
101
+ @method.setter
102
+ def method(self, value: HTTPMethod) -> None:
103
+ self.method_select.value = value
104
+
105
+ @property
106
+ def url(self) -> str:
107
+ return self.url_input.value
108
+
109
+ @url.setter
110
+ def url(self, value: str) -> None:
111
+ self.url_input.value = value
112
+
113
+ def clear(self) -> None:
114
+ self.method = HTTPMethod.GET
115
+ self.url = ''
116
+
101
117
  @on(Button.Pressed, '#send-request')
102
- @on(Input.Submitted, '#url')
118
+ @on(CustomInput.Submitted, '#url')
103
119
  def _on_send_request(
104
- self, message: Button.Pressed | Input.Submitted
120
+ self, message: Button.Pressed | CustomInput.Submitted
105
121
  ) -> None:
106
122
  if self.request_pending:
107
123
  return
@@ -109,7 +125,7 @@ class URLArea(Static):
109
125
  self.post_message(message=self.SendRequest())
110
126
 
111
127
  @on(Button.Pressed, '#cancel-request')
112
- @on(Input.Submitted, '#url')
128
+ @on(CustomInput.Submitted, '#url')
113
129
  def _on_cancel_request(self, message: Button.Pressed) -> None:
114
130
  if not self.request_pending:
115
131
  return
restiny/utils.py CHANGED
@@ -8,32 +8,43 @@ import httpx
8
8
  def build_curl_cmd(
9
9
  method: str,
10
10
  url: str,
11
- headers: dict[str, str] = {},
12
- params: dict[str, str] = {},
13
- raw_body: str | None = None,
14
- form_urlencoded: dict[str, str] = {},
15
- form_multipart: dict[str, str | Path] = {},
16
- files: list[Path] = [],
11
+ headers: dict[str, str] | None = None,
12
+ params: dict[str, str] | None = None,
13
+ body_raw: str | None = None,
14
+ body_form_urlencoded: dict[str, str] | None = None,
15
+ body_form_multipart: dict[str, str | Path] | None = None,
16
+ body_files: list[Path] | None = None,
17
+ auth_basic: tuple[str, str] | None = None,
18
+ auth_bearer: str | None = None,
19
+ auth_api_key_header: tuple[str, str] | None = None,
20
+ auth_api_key_param: tuple[str, str] | None = None,
21
+ auth_digest: tuple[str, str] | None = None,
17
22
  ) -> str:
18
23
  cmd_parts = ['curl']
24
+
25
+ # Method
19
26
  cmd_parts.extend(['--request', method])
20
27
 
21
- url = str(httpx.URL(url).copy_merge_params(params))
28
+ # URL + Params
29
+ if params:
30
+ url = str(httpx.URL(url).copy_merge_params(params))
22
31
  cmd_parts.extend(['--url', shlex.quote(url)])
23
32
 
33
+ # Headers
24
34
  for header_key, header_value in headers.items():
25
35
  header = f'{header_key}: {header_value}'
26
36
  cmd_parts.extend(['--header', shlex.quote(header)])
27
37
 
28
- if raw_body:
29
- cmd_parts.extend(['--data', shlex.quote(raw_body)])
30
- elif form_urlencoded:
31
- for form_key, form_value in form_urlencoded.items():
38
+ # Body
39
+ if body_raw:
40
+ cmd_parts.extend(['--data', shlex.quote(body_raw)])
41
+ elif body_form_urlencoded:
42
+ for form_key, form_value in body_form_urlencoded.items():
32
43
  cmd_parts.extend(
33
44
  ['--data', shlex.quote(f'{form_key}={form_value}')]
34
45
  )
35
- elif form_multipart:
36
- for form_key, form_value in form_multipart.items():
46
+ elif body_form_multipart:
47
+ for form_key, form_value in body_form_multipart.items():
37
48
  if isinstance(form_value, str):
38
49
  cmd_parts.extend(
39
50
  ['--form', shlex.quote(f'{form_key}={form_value}')]
@@ -42,10 +53,29 @@ def build_curl_cmd(
42
53
  cmd_parts.extend(
43
54
  ['--form', shlex.quote(f'{form_key}=@{form_value}')]
44
55
  )
45
- elif files:
46
- for file in files:
56
+ elif body_files:
57
+ for file in body_files:
47
58
  cmd_parts.extend(['--data', shlex.quote(f'@{file}')])
48
59
 
60
+ # Auth
61
+ if auth_basic:
62
+ user, pwd = auth_basic
63
+ cmd_parts.extend(['--user', shlex.quote(f'{user}:{pwd}')])
64
+ elif auth_bearer:
65
+ token = auth_bearer
66
+ cmd_parts.extend(['--header', shlex.quote(f'Authorization: {token}')])
67
+ elif auth_api_key_header:
68
+ key, value = auth_api_key_header
69
+ cmd_parts.extend(['--header', shlex.quote(f'{key}: {value}')])
70
+ elif auth_api_key_param:
71
+ key, value = auth_api_key_param
72
+ url_arg_index = cmd_parts.index('--url')
73
+ new_url = str(httpx.URL(url).copy_merge_params({key: value}))
74
+ cmd_parts[url_arg_index + 1] = shlex.quote(new_url)
75
+ elif auth_digest:
76
+ user, pwd = auth_digest
77
+ cmd_parts.extend(['--digest', '--user', shlex.quote(f'{user}:{pwd}')])
78
+
49
79
  return ' '.join(cmd_parts)
50
80
 
51
81
 
@@ -102,3 +132,10 @@ def first_char_non_empty(text: str) -> int | None:
102
132
 
103
133
  def seconds_to_milliseconds(seconds: int | float) -> int:
104
134
  return round(seconds * 1000)
135
+
136
+
137
+ def shorten_string(value: str, max_lenght: int, elipsis: str = '..') -> str:
138
+ if len(value) <= max_lenght:
139
+ return value
140
+
141
+ return value[: max_lenght - len(elipsis)] + elipsis
@@ -2,15 +2,29 @@
2
2
  This module contains reusable widgets used in the DataFox interface.
3
3
  """
4
4
 
5
+ from restiny.widgets.collections_tree import CollectionsTree
6
+ from restiny.widgets.confirm_prompt import ConfirmPrompt, ConfirmPromptResult
5
7
  from restiny.widgets.custom_directory_tree import CustomDirectoryTree
8
+ from restiny.widgets.custom_input import CustomInput
6
9
  from restiny.widgets.custom_text_area import CustomTextArea
7
- from restiny.widgets.dynamic_fields import DynamicFields, TextDynamicField
10
+ from restiny.widgets.dynamic_fields import (
11
+ DynamicFields,
12
+ TextDynamicField,
13
+ TextOrFileDynamicField,
14
+ )
15
+ from restiny.widgets.password_input import PasswordInput
8
16
  from restiny.widgets.path_chooser import PathChooser
9
17
 
10
18
  __all__ = [
11
19
  'TextDynamicField',
20
+ 'TextOrFileDynamicField',
12
21
  'DynamicFields',
13
22
  'CustomDirectoryTree',
14
23
  'CustomTextArea',
15
24
  'PathChooser',
25
+ 'PasswordInput',
26
+ 'CustomInput',
27
+ 'CollectionsTree',
28
+ 'ConfirmPrompt',
29
+ 'ConfirmPromptResult',
16
30
  ]
@@ -0,0 +1,74 @@
1
+ from textual.widgets import Tree
2
+ from textual.widgets.tree import TreeNode
3
+
4
+ from restiny.enums import HTTPMethod
5
+
6
+
7
+ class CollectionsTree(Tree):
8
+ show_root = False
9
+
10
+ def on_mount(self) -> None:
11
+ self.node_by_id: dict[int | None, TreeNode] = {}
12
+ self.node_by_id[None] = self.root
13
+ self.root.data = {'name': '/', 'id': None}
14
+
15
+ @property
16
+ def current_parent_folder(self) -> TreeNode:
17
+ if not self.cursor_node:
18
+ return self.root
19
+
20
+ return self.cursor_node.parent
21
+
22
+ @property
23
+ def current_folder(self) -> TreeNode:
24
+ if not self.cursor_node:
25
+ return self.root
26
+
27
+ if self.cursor_node.allow_expand:
28
+ return self.cursor_node
29
+ else:
30
+ return self.cursor_node.parent
31
+
32
+ def add_folder(
33
+ self, parent_node: TreeNode | None, name: str, id: int
34
+ ) -> TreeNode:
35
+ parent_node = parent_node or self.root
36
+
37
+ node = parent_node.add(label=name)
38
+ node.data = {
39
+ 'name': name,
40
+ 'id': id,
41
+ }
42
+ self.node_by_id[id] = node
43
+ return node
44
+
45
+ def add_request(
46
+ self, parent_node: TreeNode | None, method: str, name: str, id: int
47
+ ) -> TreeNode:
48
+ parent_node = parent_node or self.root
49
+
50
+ method_to_color = {
51
+ HTTPMethod.GET: '#00cc66', # green
52
+ HTTPMethod.POST: '#ffcc00', # yellow
53
+ HTTPMethod.PUT: '#3388ff', # blue
54
+ HTTPMethod.PATCH: '#00b3b3', # teal
55
+ HTTPMethod.DELETE: '#ff3333', # red
56
+ HTTPMethod.HEAD: '#808080', # gray
57
+ HTTPMethod.OPTIONS: '#cc66ff', # magenta
58
+ HTTPMethod.CONNECT: '#ff9966', # orange
59
+ HTTPMethod.TRACE: '#6666ff', # violet
60
+ }
61
+ node = parent_node.add_leaf(
62
+ label=f'[{method_to_color[method]}]{method}[/] {name}'
63
+ )
64
+ node.data = {
65
+ 'method': method,
66
+ 'name': name,
67
+ 'id': id,
68
+ }
69
+ self.node_by_id[id] = node
70
+ return node
71
+
72
+ def remove(self, node: TreeNode) -> None:
73
+ del self.node_by_id[node.data['id']]
74
+ node.remove()
@@ -0,0 +1,76 @@
1
+ from dataclasses import dataclass
2
+
3
+ from textual import on
4
+ from textual.app import ComposeResult
5
+ from textual.binding import Binding
6
+ from textual.containers import Horizontal, Vertical
7
+ from textual.screen import ModalScreen
8
+ from textual.widgets import Button, Label
9
+
10
+
11
+ @dataclass
12
+ class ConfirmPromptResult:
13
+ confirmed: bool
14
+
15
+
16
+ class ConfirmPrompt(ModalScreen):
17
+ DEFAULT_CSS = """
18
+ ConfirmPrompt {
19
+ align: center middle;
20
+ }
21
+
22
+ #modal-content {
23
+ border: heavy black;
24
+ border-title-color: gray;
25
+ background: $surface;
26
+ width: auto;
27
+ height: auto;
28
+ max-width: 40%;
29
+ text-align: center;
30
+ }
31
+
32
+ Horizontal {
33
+ align: center middle;
34
+ }
35
+
36
+ #message {
37
+ }
38
+ """
39
+ AUTO_FOCUS = '#confirm'
40
+
41
+ BINDINGS = [
42
+ Binding(
43
+ key='escape',
44
+ action='dismiss',
45
+ description='Quit the screen',
46
+ show=False,
47
+ ),
48
+ ]
49
+
50
+ def __init__(self, message: str = 'Are you sure?') -> None:
51
+ super().__init__()
52
+ self._message = message
53
+
54
+ def compose(self) -> ComposeResult:
55
+ with Vertical(id='modal-content'):
56
+ with Horizontal(classes='w-1fr h-auto mt-1'):
57
+ yield Label(self._message, id='message')
58
+ with Horizontal(classes='w-1fr h-auto mt-1'):
59
+ yield Button(label='Cancel', classes='w-1fr', id='cancel')
60
+ yield Button(label='Confirm', classes='w-1fr', id='confirm')
61
+
62
+ def on_mount(self) -> None:
63
+ self.modal_content = self.query_one('#modal-content', Vertical)
64
+ self.message_label = self.query_one('#message', Label)
65
+ self.cancel_button = self.query_one('#cancel', Button)
66
+ self.confirm_button = self.query_one('#confirm', Button)
67
+
68
+ self.modal_content.border_title = 'Confirm'
69
+
70
+ @on(Button.Pressed, '#cancel')
71
+ def _on_cancel(self, message: Button.Pressed) -> None:
72
+ self.dismiss(result=ConfirmPromptResult(confirmed=False))
73
+
74
+ @on(Button.Pressed, '#confirm')
75
+ def _on_confirm(self, message: Button.Pressed) -> None:
76
+ self.dismiss(result=ConfirmPromptResult(confirmed=True))
@@ -0,0 +1,20 @@
1
+ from textual import on
2
+ from textual.binding import Binding
3
+ from textual.events import Blur
4
+ from textual.widgets import Input
5
+
6
+
7
+ class CustomInput(Input):
8
+ BINDINGS = [
9
+ Binding(
10
+ key='ctrl+a',
11
+ action='select_all',
12
+ description='Select all text',
13
+ show=False,
14
+ ),
15
+ ]
16
+
17
+ @on(Blur)
18
+ def on_blur(self, event: Blur) -> None:
19
+ self.selection = 0, 0
20
+ self.cursor_position = len(self.value)