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/__about__.py +1 -1
- restiny/__main__.py +26 -14
- restiny/assets/style.tcss +47 -1
- restiny/consts.py +236 -0
- restiny/data/db.py +60 -0
- restiny/data/models.py +90 -0
- restiny/data/repos.py +351 -0
- restiny/data/sql/__init__.py +3 -0
- restiny/entities.py +320 -0
- restiny/enums.py +14 -5
- restiny/httpx_auths.py +52 -0
- restiny/ui/__init__.py +15 -0
- restiny/ui/app.py +500 -0
- restiny/ui/collections_area.py +569 -0
- restiny/ui/request_area.py +575 -0
- restiny/ui/settings_screen.py +80 -0
- restiny/{core → ui}/url_area.py +50 -38
- restiny/utils.py +52 -15
- restiny/widgets/__init__.py +15 -1
- restiny/widgets/collections_tree.py +70 -0
- restiny/widgets/confirm_prompt.py +76 -0
- restiny/widgets/custom_input.py +20 -0
- restiny/widgets/dynamic_fields.py +65 -70
- restiny/widgets/password_input.py +161 -0
- restiny/widgets/path_chooser.py +12 -12
- {restiny-0.2.1.dist-info → restiny-0.5.0.dist-info}/METADATA +7 -5
- restiny-0.5.0.dist-info/RECORD +36 -0
- restiny/core/__init__.py +0 -15
- restiny/core/app.py +0 -348
- restiny/core/request_area.py +0 -337
- restiny-0.2.1.dist-info/RECORD +0 -24
- /restiny/{core → ui}/response_area.py +0 -0
- {restiny-0.2.1.dist-info → restiny-0.5.0.dist-info}/WHEEL +0 -0
- {restiny-0.2.1.dist-info → restiny-0.5.0.dist-info}/entry_points.txt +0 -0
- {restiny-0.2.1.dist-info → restiny-0.5.0.dist-info}/licenses/LICENSE +0 -0
- {restiny-0.2.1.dist-info → restiny-0.5.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
from enum import StrEnum
|
|
2
|
+
|
|
3
|
+
from textual import on
|
|
4
|
+
from textual.app import ComposeResult
|
|
5
|
+
from textual.containers import Horizontal
|
|
6
|
+
from textual.message import Message
|
|
7
|
+
from textual.widget import Widget
|
|
8
|
+
from textual.widgets import Button
|
|
9
|
+
|
|
10
|
+
from restiny.widgets import CustomInput
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class _Icon(StrEnum):
|
|
14
|
+
SHOW = ' 🔓 '
|
|
15
|
+
HIDE = ' 🔒 '
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class _Tooltip(StrEnum):
|
|
19
|
+
SHOW = 'Show'
|
|
20
|
+
HIDE = 'Hide'
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class PasswordInput(Widget):
|
|
24
|
+
DEFAULT_CSS = """
|
|
25
|
+
PasswordInput {
|
|
26
|
+
width: 1fr;
|
|
27
|
+
height: auto;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
PasswordInput > Horizontal {
|
|
31
|
+
width: auto;
|
|
32
|
+
height: auto;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
PasswordInput CustomInput {
|
|
36
|
+
width: 1fr;
|
|
37
|
+
margin-right: 0;
|
|
38
|
+
border-right: none;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
PasswordInput CustomInput:focus {
|
|
42
|
+
border-right: none;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
PasswordInput Button {
|
|
47
|
+
width: auto;
|
|
48
|
+
margin-left: 0;
|
|
49
|
+
border-left: none;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
class Changed(Message):
|
|
55
|
+
"""
|
|
56
|
+
Sent when value changed.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(self, input: 'PasswordInput', value: str):
|
|
60
|
+
super().__init__()
|
|
61
|
+
self.input = input
|
|
62
|
+
self.value = value
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def control(self) -> 'PasswordInput':
|
|
66
|
+
return self.input
|
|
67
|
+
|
|
68
|
+
class Shown(Message):
|
|
69
|
+
"""
|
|
70
|
+
Sent when the value becomes visible.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
def __init__(self, input: 'PasswordInput') -> None:
|
|
74
|
+
super().__init__()
|
|
75
|
+
self.input = input
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def control(self) -> 'PasswordInput':
|
|
79
|
+
return self.input
|
|
80
|
+
|
|
81
|
+
class Hidden(Message):
|
|
82
|
+
"""
|
|
83
|
+
Sent when the value becomes hidden.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
def __init__(self, input: 'PasswordInput') -> None:
|
|
87
|
+
super().__init__()
|
|
88
|
+
self.input = input
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def control(self) -> 'PasswordInput':
|
|
92
|
+
return self.input
|
|
93
|
+
|
|
94
|
+
def __init__(self, *args, **kwargs) -> None:
|
|
95
|
+
super().__init__(
|
|
96
|
+
id=kwargs.pop('id', None), classes=kwargs.pop('classes', None)
|
|
97
|
+
)
|
|
98
|
+
kwargs.pop('password', None)
|
|
99
|
+
self._input_args = args
|
|
100
|
+
self._input_kwargs = kwargs
|
|
101
|
+
|
|
102
|
+
def compose(self) -> ComposeResult:
|
|
103
|
+
with Horizontal():
|
|
104
|
+
yield CustomInput(
|
|
105
|
+
*self._input_args,
|
|
106
|
+
**self._input_kwargs,
|
|
107
|
+
password=True,
|
|
108
|
+
id='value',
|
|
109
|
+
)
|
|
110
|
+
yield Button(
|
|
111
|
+
_Icon.SHOW, tooltip=_Tooltip.SHOW, id='toggle-visibility'
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
def on_mount(self) -> None:
|
|
115
|
+
self.value_input = self.query_one('#value', CustomInput)
|
|
116
|
+
self.toggle_visibility_button = self.query_one(
|
|
117
|
+
'#toggle-visibility', Button
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
def show(self) -> None:
|
|
121
|
+
self.value_input.password = False
|
|
122
|
+
self.toggle_visibility_button.label = _Icon.HIDE
|
|
123
|
+
self.toggle_visibility_button.tooltip = _Tooltip.HIDE
|
|
124
|
+
self.post_message(message=self.Hidden(input=self))
|
|
125
|
+
|
|
126
|
+
def hide(self) -> None:
|
|
127
|
+
self.value_input.password = True
|
|
128
|
+
self.toggle_visibility_button.label = _Icon.SHOW
|
|
129
|
+
self.toggle_visibility_button.tooltip = _Tooltip.SHOW
|
|
130
|
+
self.post_message(message=self.Shown(input=self))
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def value(self) -> str:
|
|
134
|
+
return self.value_input.value
|
|
135
|
+
|
|
136
|
+
@value.setter
|
|
137
|
+
def value(self, value: str) -> None:
|
|
138
|
+
self.value_input.value = value
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def shown(self) -> bool:
|
|
142
|
+
return self.value_input.password is False
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def hidden(self) -> bool:
|
|
146
|
+
return not self.shown
|
|
147
|
+
|
|
148
|
+
@on(CustomInput.Changed, '#value')
|
|
149
|
+
def _on_value_changed(self, message: CustomInput.Changed) -> None:
|
|
150
|
+
self.post_message(
|
|
151
|
+
message=self.Changed(input=self, value=message.value)
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
@on(Button.Pressed, '#toggle-visibility')
|
|
155
|
+
def _on_toggle_visibility(self, message: Button.Pressed) -> None:
|
|
156
|
+
if self.value_input.password is False:
|
|
157
|
+
self.hide()
|
|
158
|
+
elif self.value_input.password is True:
|
|
159
|
+
self.show()
|
|
160
|
+
|
|
161
|
+
self.value_input.focus()
|
restiny/widgets/path_chooser.py
CHANGED
|
@@ -9,10 +9,10 @@ from textual.message import Message
|
|
|
9
9
|
from textual.reactive import Reactive
|
|
10
10
|
from textual.screen import ModalScreen
|
|
11
11
|
from textual.widget import Widget
|
|
12
|
-
from textual.widgets import Button,
|
|
12
|
+
from textual.widgets import Button, Label, Switch
|
|
13
13
|
|
|
14
14
|
from restiny.utils import filter_paths
|
|
15
|
-
from restiny.widgets import CustomDirectoryTree
|
|
15
|
+
from restiny.widgets import CustomDirectoryTree, CustomInput
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
class PathChooserScreen(ModalScreen):
|
|
@@ -53,7 +53,7 @@ class PathChooserScreen(ModalScreen):
|
|
|
53
53
|
yield CustomDirectoryTree(path='/')
|
|
54
54
|
|
|
55
55
|
with Horizontal(classes='w-auto h-auto mt-1'):
|
|
56
|
-
yield
|
|
56
|
+
yield CustomInput(
|
|
57
57
|
placeholder='--empty--',
|
|
58
58
|
select_on_focus=False,
|
|
59
59
|
disabled=True,
|
|
@@ -74,7 +74,7 @@ class PathChooserScreen(ModalScreen):
|
|
|
74
74
|
'#option-show-hidden-dirs'
|
|
75
75
|
)
|
|
76
76
|
self.directory_tree = self.query_one(CustomDirectoryTree)
|
|
77
|
-
self.input = self.query_one(
|
|
77
|
+
self.input = self.query_one(CustomInput)
|
|
78
78
|
self.btn_cancel = self.query_one('#cancel')
|
|
79
79
|
self.btn_confirm = self.query_one('#choose')
|
|
80
80
|
|
|
@@ -197,7 +197,7 @@ class PathChooser(Widget):
|
|
|
197
197
|
grid-columns: 1fr auto;
|
|
198
198
|
}
|
|
199
199
|
|
|
200
|
-
PathChooser >
|
|
200
|
+
PathChooser > CustomInput {
|
|
201
201
|
margin-right: 0;
|
|
202
202
|
border-right: none;
|
|
203
203
|
}
|
|
@@ -214,7 +214,7 @@ class PathChooser(Widget):
|
|
|
214
214
|
"""
|
|
215
215
|
|
|
216
216
|
def __init__(
|
|
217
|
-
self, path_chooser: PathChooser, path: Path | None
|
|
217
|
+
self, path_chooser: 'PathChooser', path: Path | None
|
|
218
218
|
) -> None:
|
|
219
219
|
super().__init__()
|
|
220
220
|
self.path_chooser = path_chooser
|
|
@@ -241,7 +241,7 @@ class PathChooser(Widget):
|
|
|
241
241
|
) -> None:
|
|
242
242
|
super().__init__(*args, **kwargs)
|
|
243
243
|
self.path_type = path_type
|
|
244
|
-
self.
|
|
244
|
+
self._path = path
|
|
245
245
|
|
|
246
246
|
def compose(self) -> ComposeResult:
|
|
247
247
|
icon = ''
|
|
@@ -250,8 +250,8 @@ class PathChooser(Widget):
|
|
|
250
250
|
elif self.path_type == _PathType.DIR:
|
|
251
251
|
icon = ' 🗂 '
|
|
252
252
|
|
|
253
|
-
yield
|
|
254
|
-
self.
|
|
253
|
+
yield CustomInput(
|
|
254
|
+
str(self._path) if self._path else '',
|
|
255
255
|
placeholder='--empty--',
|
|
256
256
|
select_on_focus=False,
|
|
257
257
|
disabled=True,
|
|
@@ -260,7 +260,7 @@ class PathChooser(Widget):
|
|
|
260
260
|
yield Button(icon, tooltip=f'Choose {self.path_type}', id='choose')
|
|
261
261
|
|
|
262
262
|
def on_mount(self) -> None:
|
|
263
|
-
self.path_input = self.query_one('#path',
|
|
263
|
+
self.path_input = self.query_one('#path', CustomInput)
|
|
264
264
|
self.choose_button = self.query_one('#choose', Button)
|
|
265
265
|
|
|
266
266
|
@property
|
|
@@ -275,8 +275,8 @@ class PathChooser(Widget):
|
|
|
275
275
|
self.path_input.value = value
|
|
276
276
|
self.path_input.tooltip = value
|
|
277
277
|
|
|
278
|
-
@on(
|
|
279
|
-
def _on_path_changed(self, message:
|
|
278
|
+
@on(CustomInput.Changed, '#path')
|
|
279
|
+
def _on_path_changed(self, message: CustomInput.Changed) -> None:
|
|
280
280
|
self.post_message(
|
|
281
281
|
message=self.Changed(path_chooser=self, path=self.path)
|
|
282
282
|
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: restiny
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: A minimalist HTTP client, no bullshit
|
|
5
5
|
Author-email: Kalebe Chimanski de Almeida <kalebe.chi.almeida@gmail.com>
|
|
6
6
|
License: Apache License
|
|
@@ -216,7 +216,6 @@ Classifier: License :: OSI Approved :: Apache Software License
|
|
|
216
216
|
Classifier: Environment :: Console
|
|
217
217
|
Classifier: Typing :: Typed
|
|
218
218
|
Classifier: Programming Language :: Python :: 3
|
|
219
|
-
Classifier: Programming Language :: Python :: 3.10
|
|
220
219
|
Classifier: Programming Language :: Python :: 3.11
|
|
221
220
|
Classifier: Programming Language :: Python :: 3.12
|
|
222
221
|
Classifier: Programming Language :: Python :: 3.13
|
|
@@ -228,17 +227,20 @@ Classifier: Operating System :: Microsoft :: Windows :: Windows 11
|
|
|
228
227
|
Classifier: Topic :: Internet :: WWW/HTTP
|
|
229
228
|
Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
|
|
230
229
|
Classifier: Natural Language :: English
|
|
231
|
-
Requires-Python: >=3.
|
|
230
|
+
Requires-Python: >=3.11
|
|
232
231
|
Description-Content-Type: text/markdown
|
|
233
232
|
License-File: LICENSE
|
|
234
233
|
Requires-Dist: textual<6.4,>=6.3
|
|
235
234
|
Requires-Dist: textual[syntax]
|
|
236
235
|
Requires-Dist: httpx<0.29,>=0.28
|
|
237
236
|
Requires-Dist: pyperclip<1.10,>=1.9
|
|
237
|
+
Requires-Dist: sqlalchemy<3.0,>=2.0
|
|
238
|
+
Requires-Dist: pydantic<2.13,>=2.12
|
|
238
239
|
Dynamic: license-file
|
|
239
240
|
|
|
240
241
|

|
|
241
|
-

|
|
243
|
+
|
|
242
244
|
|
|
243
245
|
|
|
244
246
|
- [RESTiny](#restiny)
|
|
@@ -254,7 +256,7 @@ Dynamic: license-file
|
|
|
254
256
|
|
|
255
257
|
_A minimal, elegant HTTP client for Python — with a TUI interface powered by [Textual](https://github.com/Textualize/textual)._
|
|
256
258
|
|
|
257
|
-

|
|
258
260
|
|
|
259
261
|
## How to install
|
|
260
262
|
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
restiny/__about__.py,sha256=0PaI2eSOCp5kkNcpKpUSbLHf66rL9xQzFpYyLGpEtyM,22
|
|
2
|
+
restiny/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
restiny/__main__.py,sha256=uUVIpbnk2nzWV5ZSEDuUUEJPKeAZW8arDl2mbekUKKU,1066
|
|
4
|
+
restiny/consts.py,sha256=LMwWVmOrOwZAKcnpZkBs-duMDE6QWTWD4un9y6hcVEc,11319
|
|
5
|
+
restiny/entities.py,sha256=hCNoy2tNQjAz6czHmTF4Ee10tqTjxNVycL1ENZry_yc,10040
|
|
6
|
+
restiny/enums.py,sha256=57KQPeY1YFNr15Ykr8ZglkEKdez6Djqnfzm0YaBPGfQ,996
|
|
7
|
+
restiny/httpx_auths.py,sha256=FqwO6W2AYNhmG5OEbQBusumH-zVvel4A8Oa7c4cbm-4,1295
|
|
8
|
+
restiny/utils.py,sha256=ktXrnlujBYxrPi-nDYor94r1cmJ07xyezesuZESswOc,4493
|
|
9
|
+
restiny/assets/__init__.py,sha256=JL1KARlToF6ZR7KeUjlDAHgwwVM2qXYaIl4wHeFW2zU,93
|
|
10
|
+
restiny/assets/style.tcss,sha256=LCszWlsdR_4GqwmR9C8aea_RojfCY-JyV-nA3NidF9s,925
|
|
11
|
+
restiny/data/db.py,sha256=aRYrjv0ecFWToj5Y0XBJ_o4QPLPY2qzRKvgUgbzG98E,1990
|
|
12
|
+
restiny/data/models.py,sha256=QInN6uLapn55eeWcPnq13Jkd88RKobiq69XwZjexkhM,2585
|
|
13
|
+
restiny/data/repos.py,sha256=ySeXrFuqmSV_Q_nIZWPBj8TY-RjKzumcId6vE1m6jZM,12105
|
|
14
|
+
restiny/data/sql/__init__.py,sha256=4Erfs-MC_ctZ53lXqe_FQwJDRd8SrxGrZ3_rG8o7VCU,81
|
|
15
|
+
restiny/ui/__init__.py,sha256=AaxD5x6SdlMMxce0hbtAWw5V2hy1zoTfy9EzcaHKMgI,374
|
|
16
|
+
restiny/ui/app.py,sha256=c-cLV15J9SPLcXkbmHWS11L9es5oWOoRkV3HCySqR7g,18060
|
|
17
|
+
restiny/ui/collections_area.py,sha256=UwK255demkwEw5uh0_XuZpjdQaR091STcV22zDZ6ukc,18501
|
|
18
|
+
restiny/ui/request_area.py,sha256=bxtl0g2cBgRDxMFVAu0ocdLi-a4OeQTpxOsJTkdbW_8,20795
|
|
19
|
+
restiny/ui/response_area.py,sha256=Rzq82muK9gXWHIHdLqetFG403n7JdE65Rka0Siw339I,4195
|
|
20
|
+
restiny/ui/settings_screen.py,sha256=wN--I4KY87Pe0UUaBZmDZN091jHac-7YAr8GeDbXjy8,2345
|
|
21
|
+
restiny/ui/url_area.py,sha256=Cc6AF9g_dRgC5TsK9ORE8As1hFq4zfG_rWp77NrrdJg,3779
|
|
22
|
+
restiny/widgets/__init__.py,sha256=RaU2JkRWAoJULG3rpPtMNUarPU6Yu6gJwzAsSad2hgg,895
|
|
23
|
+
restiny/widgets/collections_tree.py,sha256=X-wm_bkUHK9E9XDGjJE-bjeQWEqwfNyZNFTA25nDQe4,2038
|
|
24
|
+
restiny/widgets/confirm_prompt.py,sha256=1xdCaJZUDzV4-fQ1ztbe7uX9hJ6ZZUBghtwgReBHz9w,2147
|
|
25
|
+
restiny/widgets/custom_directory_tree.py,sha256=sNTaI0DBAO56MyOy6qMZPgWXiTUQbBrJdn1GtOdxrDc,1268
|
|
26
|
+
restiny/widgets/custom_input.py,sha256=W6gE9jbTl_R1uLSA5Dz9eBX7aNID2-rYZP3j2oNi4SA,466
|
|
27
|
+
restiny/widgets/custom_text_area.py,sha256=ykmG-6MiMhz6BqNzP8f14jUTWWKjsCOIEhgciP-01Y8,14032
|
|
28
|
+
restiny/widgets/dynamic_fields.py,sha256=S2epm-_QOsHEGhVFwDlOvIqOQkUgpGnh6pK3_JoTQ1g,16104
|
|
29
|
+
restiny/widgets/password_input.py,sha256=xXOfiStcUCbP_cnrS2Rz0-GmsvmOsen4G41zOpmjLD8,4057
|
|
30
|
+
restiny/widgets/path_chooser.py,sha256=FdG9fdgY2qD8o-7aBn8F005f8H91kbYtyF99RRGLRas,9273
|
|
31
|
+
restiny-0.5.0.dist-info/licenses/LICENSE,sha256=Z190MKguypkrjaCldiorEbMmBQp7ylvx09oyE4oDCTs,11361
|
|
32
|
+
restiny-0.5.0.dist-info/METADATA,sha256=_W4JXRugmt8LSFdTuwp46BF5T-xlI9goHEJSVwf0OsU,16137
|
|
33
|
+
restiny-0.5.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
34
|
+
restiny-0.5.0.dist-info/entry_points.txt,sha256=F9zW8bAPAwIihltqjzYow4ahmH_B6VkAHzQFA-8QOn4,50
|
|
35
|
+
restiny-0.5.0.dist-info/top_level.txt,sha256=1MQ_Q-fV1Dwbu4zU3g1Eg-CfRgC412X-mvMIrEdrlbk,8
|
|
36
|
+
restiny-0.5.0.dist-info/RECORD,,
|
restiny/core/__init__.py
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
This module contains the specific sections of the DataFox user interface (UI).
|
|
3
|
-
"""
|
|
4
|
-
|
|
5
|
-
from restiny.core.request_area import RequestArea, RequestAreaData
|
|
6
|
-
from restiny.core.response_area import ResponseArea
|
|
7
|
-
from restiny.core.url_area import URLArea, URLAreaData
|
|
8
|
-
|
|
9
|
-
__all__ = [
|
|
10
|
-
'RequestArea',
|
|
11
|
-
'RequestAreaData',
|
|
12
|
-
'ResponseArea',
|
|
13
|
-
'URLArea',
|
|
14
|
-
'URLAreaData',
|
|
15
|
-
]
|
restiny/core/app.py
DELETED
|
@@ -1,348 +0,0 @@
|
|
|
1
|
-
import asyncio
|
|
2
|
-
import json
|
|
3
|
-
import mimetypes
|
|
4
|
-
from http import HTTPStatus
|
|
5
|
-
from pathlib import Path
|
|
6
|
-
|
|
7
|
-
import httpx
|
|
8
|
-
import pyperclip
|
|
9
|
-
from textual import on
|
|
10
|
-
from textual.app import App, ComposeResult
|
|
11
|
-
from textual.binding import Binding
|
|
12
|
-
from textual.containers import Horizontal, Vertical
|
|
13
|
-
from textual.events import DescendantFocus
|
|
14
|
-
from textual.widget import Widget
|
|
15
|
-
from textual.widgets import Footer, Header
|
|
16
|
-
|
|
17
|
-
from restiny.__about__ import __version__
|
|
18
|
-
from restiny.assets import STYLE_TCSS
|
|
19
|
-
from restiny.core import (
|
|
20
|
-
RequestArea,
|
|
21
|
-
RequestAreaData,
|
|
22
|
-
ResponseArea,
|
|
23
|
-
URLArea,
|
|
24
|
-
URLAreaData,
|
|
25
|
-
)
|
|
26
|
-
from restiny.core.response_area import ResponseAreaData
|
|
27
|
-
from restiny.enums import BodyMode, BodyRawLanguage, ContentType
|
|
28
|
-
from restiny.utils import build_curl_cmd
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
class RESTinyApp(App, inherit_bindings=False):
|
|
32
|
-
TITLE = f'RESTiny v{__version__}'
|
|
33
|
-
SUB_TITLE = 'Minimal HTTP client, no bullshit'
|
|
34
|
-
ENABLE_COMMAND_PALETTE = False
|
|
35
|
-
CSS_PATH = STYLE_TCSS
|
|
36
|
-
BINDINGS = [
|
|
37
|
-
Binding(
|
|
38
|
-
key='escape', action='quit', description='Quit the app', show=True
|
|
39
|
-
),
|
|
40
|
-
Binding(
|
|
41
|
-
key='f10',
|
|
42
|
-
action='maximize_or_minimize_area',
|
|
43
|
-
description='Maximize/Minimize area',
|
|
44
|
-
show=True,
|
|
45
|
-
),
|
|
46
|
-
Binding(
|
|
47
|
-
key='f9',
|
|
48
|
-
action='copy_as_curl',
|
|
49
|
-
description='Copy as curl',
|
|
50
|
-
show=True,
|
|
51
|
-
),
|
|
52
|
-
]
|
|
53
|
-
theme = 'textual-dark'
|
|
54
|
-
|
|
55
|
-
def __init__(self, *args, **kwargs) -> None:
|
|
56
|
-
super().__init__(*args, **kwargs)
|
|
57
|
-
self.current_request: asyncio.Task | None = None
|
|
58
|
-
self.last_focused_widget: Widget | None = None
|
|
59
|
-
self.last_focused_maximizable_area: Widget | None = None
|
|
60
|
-
|
|
61
|
-
def compose(self) -> ComposeResult:
|
|
62
|
-
yield Header(show_clock=True)
|
|
63
|
-
with Vertical(id='main-content'):
|
|
64
|
-
with Horizontal(classes='h-auto'):
|
|
65
|
-
yield URLArea()
|
|
66
|
-
with Horizontal(classes='h-1fr'):
|
|
67
|
-
with Vertical():
|
|
68
|
-
yield RequestArea()
|
|
69
|
-
with Vertical():
|
|
70
|
-
yield ResponseArea()
|
|
71
|
-
yield Footer()
|
|
72
|
-
|
|
73
|
-
def on_mount(self) -> None:
|
|
74
|
-
self.url_area = self.query_one(URLArea)
|
|
75
|
-
self.request_area = self.query_one(RequestArea)
|
|
76
|
-
self.response_area = self.query_one(ResponseArea)
|
|
77
|
-
|
|
78
|
-
def action_maximize_or_minimize_area(self) -> None:
|
|
79
|
-
if self.screen.maximized:
|
|
80
|
-
self.screen.minimize()
|
|
81
|
-
else:
|
|
82
|
-
self.screen.maximize(self.last_focused_maximizable_area)
|
|
83
|
-
|
|
84
|
-
def action_copy_as_curl(self) -> None:
|
|
85
|
-
url_area_data = self.url_area.get_data()
|
|
86
|
-
request_area_data = self.request_area.get_data()
|
|
87
|
-
|
|
88
|
-
method = url_area_data.method
|
|
89
|
-
url = url_area_data.url
|
|
90
|
-
|
|
91
|
-
headers = {}
|
|
92
|
-
for header in request_area_data.headers:
|
|
93
|
-
if not header.enabled:
|
|
94
|
-
continue
|
|
95
|
-
|
|
96
|
-
headers[header.key] = header.value
|
|
97
|
-
|
|
98
|
-
params = {}
|
|
99
|
-
for param in request_area_data.query_params:
|
|
100
|
-
if not param.enabled:
|
|
101
|
-
continue
|
|
102
|
-
|
|
103
|
-
params[param.key] = param.value
|
|
104
|
-
|
|
105
|
-
raw_body = None
|
|
106
|
-
form_urlencoded = {}
|
|
107
|
-
form_multipart = {}
|
|
108
|
-
files = None
|
|
109
|
-
if request_area_data.body.type == BodyMode.RAW:
|
|
110
|
-
raw_body = request_area_data.body.payload
|
|
111
|
-
elif request_area_data.body.type == BodyMode.FORM_URLENCODED:
|
|
112
|
-
form_urlencoded = {
|
|
113
|
-
form_field.key: form_field.value
|
|
114
|
-
for form_field in request_area_data.body.payload
|
|
115
|
-
if form_field.enabled
|
|
116
|
-
}
|
|
117
|
-
elif request_area_data.body.type == BodyMode.FORM_MULTIPART:
|
|
118
|
-
form_multipart = {
|
|
119
|
-
form_field.key: form_field.value
|
|
120
|
-
for form_field in request_area_data.body.payload
|
|
121
|
-
if form_field.enabled
|
|
122
|
-
}
|
|
123
|
-
elif request_area_data.body.type == BodyMode.FILE:
|
|
124
|
-
files = [request_area_data.body.payload]
|
|
125
|
-
|
|
126
|
-
curl_cmd = build_curl_cmd(
|
|
127
|
-
method=method,
|
|
128
|
-
url=url,
|
|
129
|
-
headers=headers,
|
|
130
|
-
params=params,
|
|
131
|
-
raw_body=raw_body,
|
|
132
|
-
form_urlencoded=form_urlencoded,
|
|
133
|
-
form_multipart=form_multipart,
|
|
134
|
-
files=files,
|
|
135
|
-
)
|
|
136
|
-
self.copy_to_clipboard(curl_cmd)
|
|
137
|
-
self.notify(
|
|
138
|
-
'Command CURL copied to clipboard',
|
|
139
|
-
severity='information',
|
|
140
|
-
)
|
|
141
|
-
|
|
142
|
-
def copy_to_clipboard(self, text: str) -> None:
|
|
143
|
-
super().copy_to_clipboard(text)
|
|
144
|
-
try:
|
|
145
|
-
# Also copy to the system clipboard (outside of the app)
|
|
146
|
-
pyperclip.copy(text)
|
|
147
|
-
except Exception:
|
|
148
|
-
pass
|
|
149
|
-
|
|
150
|
-
@on(DescendantFocus)
|
|
151
|
-
def _on_focus(self, event: DescendantFocus) -> None:
|
|
152
|
-
self.last_focused_widget = event.widget
|
|
153
|
-
last_focused_maximizable_area = self._find_maximizable_area_by_widget(
|
|
154
|
-
widget=event.widget
|
|
155
|
-
)
|
|
156
|
-
if last_focused_maximizable_area:
|
|
157
|
-
self.last_focused_maximizable_area = last_focused_maximizable_area
|
|
158
|
-
|
|
159
|
-
@on(URLArea.SendRequest)
|
|
160
|
-
def _on_send_request(self, message: URLArea.SendRequest) -> None:
|
|
161
|
-
self.current_request = asyncio.create_task(self._send_request())
|
|
162
|
-
|
|
163
|
-
@on(URLArea.CancelRequest)
|
|
164
|
-
def _on_cancel_request(self, message: URLArea.CancelRequest) -> None:
|
|
165
|
-
if self.current_request and not self.current_request.done():
|
|
166
|
-
self.current_request.cancel()
|
|
167
|
-
|
|
168
|
-
def _find_maximizable_area_by_widget(
|
|
169
|
-
self, widget: Widget
|
|
170
|
-
) -> Widget | None:
|
|
171
|
-
while widget is not None:
|
|
172
|
-
if (
|
|
173
|
-
isinstance(widget, URLArea)
|
|
174
|
-
or isinstance(widget, RequestArea)
|
|
175
|
-
or isinstance(widget, ResponseArea)
|
|
176
|
-
):
|
|
177
|
-
return widget
|
|
178
|
-
widget = widget.parent
|
|
179
|
-
|
|
180
|
-
async def _send_request(self) -> None:
|
|
181
|
-
url_area_data = self.url_area.get_data()
|
|
182
|
-
request_area_data = self.request_area.get_data()
|
|
183
|
-
|
|
184
|
-
self.response_area.set_data(data=None)
|
|
185
|
-
self.response_area.loading = True
|
|
186
|
-
self.url_area.request_pending = True
|
|
187
|
-
try:
|
|
188
|
-
async with httpx.AsyncClient(
|
|
189
|
-
timeout=request_area_data.options.timeout,
|
|
190
|
-
follow_redirects=request_area_data.options.follow_redirects,
|
|
191
|
-
verify=request_area_data.options.verify_ssl,
|
|
192
|
-
) as http_client:
|
|
193
|
-
request = self._build_request(
|
|
194
|
-
http_client=http_client,
|
|
195
|
-
url_area_data=url_area_data,
|
|
196
|
-
request_area_data=request_area_data,
|
|
197
|
-
)
|
|
198
|
-
response = await http_client.send(request=request)
|
|
199
|
-
self._display_response(response=response)
|
|
200
|
-
self.response_area.is_showing_response = True
|
|
201
|
-
except httpx.RequestError as error:
|
|
202
|
-
error_name = type(error).__name__
|
|
203
|
-
error_message = str(error)
|
|
204
|
-
if error_message:
|
|
205
|
-
self.notify(f'{error_name}: {error_message}', severity='error')
|
|
206
|
-
else:
|
|
207
|
-
self.notify(f'{error_name}', severity='error')
|
|
208
|
-
self.response_area.set_data(data=None)
|
|
209
|
-
self.response_area.is_showing_response = False
|
|
210
|
-
except asyncio.CancelledError:
|
|
211
|
-
self.response_area.set_data(data=None)
|
|
212
|
-
self.response_area.is_showing_response = False
|
|
213
|
-
finally:
|
|
214
|
-
self.response_area.loading = False
|
|
215
|
-
self.url_area.request_pending = False
|
|
216
|
-
|
|
217
|
-
def _build_request(
|
|
218
|
-
self,
|
|
219
|
-
http_client: httpx.Client,
|
|
220
|
-
url_area_data: URLAreaData,
|
|
221
|
-
request_area_data: RequestAreaData,
|
|
222
|
-
) -> httpx.Request:
|
|
223
|
-
headers: dict[str, str] = {
|
|
224
|
-
header.key: header.value
|
|
225
|
-
for header in request_area_data.headers
|
|
226
|
-
if header.enabled
|
|
227
|
-
}
|
|
228
|
-
query_params: dict[str, str] = {
|
|
229
|
-
param.key: param.value
|
|
230
|
-
for param in request_area_data.query_params
|
|
231
|
-
if param.enabled
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
if not request_area_data.body.enabled:
|
|
235
|
-
return http_client.build_request(
|
|
236
|
-
method=url_area_data.method,
|
|
237
|
-
url=url_area_data.url,
|
|
238
|
-
headers=headers,
|
|
239
|
-
params=query_params,
|
|
240
|
-
)
|
|
241
|
-
|
|
242
|
-
if request_area_data.body.type == BodyMode.RAW:
|
|
243
|
-
raw_language_to_content_type = {
|
|
244
|
-
BodyRawLanguage.JSON: ContentType.JSON,
|
|
245
|
-
BodyRawLanguage.YAML: ContentType.YAML,
|
|
246
|
-
BodyRawLanguage.HTML: ContentType.HTML,
|
|
247
|
-
BodyRawLanguage.XML: ContentType.XML,
|
|
248
|
-
BodyRawLanguage.PLAIN: ContentType.TEXT,
|
|
249
|
-
}
|
|
250
|
-
headers['content-type'] = raw_language_to_content_type.get(
|
|
251
|
-
request_area_data.body.raw_language, ContentType.TEXT
|
|
252
|
-
)
|
|
253
|
-
|
|
254
|
-
raw = request_area_data.body.payload
|
|
255
|
-
if headers['content-type'] == ContentType.JSON:
|
|
256
|
-
try:
|
|
257
|
-
raw = json.dumps(raw)
|
|
258
|
-
except Exception:
|
|
259
|
-
pass
|
|
260
|
-
|
|
261
|
-
return http_client.build_request(
|
|
262
|
-
method=url_area_data.method,
|
|
263
|
-
url=url_area_data.url,
|
|
264
|
-
headers=headers,
|
|
265
|
-
params=query_params,
|
|
266
|
-
content=raw,
|
|
267
|
-
)
|
|
268
|
-
elif request_area_data.body.type == BodyMode.FILE:
|
|
269
|
-
file = request_area_data.body.payload
|
|
270
|
-
if 'content-type' not in headers:
|
|
271
|
-
headers['content-type'] = (
|
|
272
|
-
mimetypes.guess_type(file.name)[0]
|
|
273
|
-
or 'application/octet-stream'
|
|
274
|
-
)
|
|
275
|
-
return http_client.build_request(
|
|
276
|
-
method=url_area_data.method,
|
|
277
|
-
url=url_area_data.url,
|
|
278
|
-
headers=headers,
|
|
279
|
-
params=query_params,
|
|
280
|
-
content=file.read_bytes(),
|
|
281
|
-
)
|
|
282
|
-
elif request_area_data.body.type == BodyMode.FORM_URLENCODED:
|
|
283
|
-
form_urlencoded = {
|
|
284
|
-
form_item.key: form_item.value
|
|
285
|
-
for form_item in request_area_data.body.payload
|
|
286
|
-
if form_item.enabled
|
|
287
|
-
}
|
|
288
|
-
return http_client.build_request(
|
|
289
|
-
method=url_area_data.method,
|
|
290
|
-
url=url_area_data.url,
|
|
291
|
-
headers=headers,
|
|
292
|
-
params=query_params,
|
|
293
|
-
data=form_urlencoded,
|
|
294
|
-
)
|
|
295
|
-
elif request_area_data.body.type == BodyMode.FORM_MULTIPART:
|
|
296
|
-
form_multipart_str = {
|
|
297
|
-
form_item.key: form_item.value
|
|
298
|
-
for form_item in request_area_data.body.payload
|
|
299
|
-
if form_item.enabled and isinstance(form_item.value, str)
|
|
300
|
-
}
|
|
301
|
-
form_multipart_files = {
|
|
302
|
-
form_item.key: (
|
|
303
|
-
form_item.value.name,
|
|
304
|
-
form_item.value.read_bytes(),
|
|
305
|
-
mimetypes.guess_type(form_item.value.name)[0]
|
|
306
|
-
or 'application/octet-stream',
|
|
307
|
-
)
|
|
308
|
-
for form_item in request_area_data.body.payload
|
|
309
|
-
if form_item.enabled and isinstance(form_item.value, Path)
|
|
310
|
-
}
|
|
311
|
-
return http_client.build_request(
|
|
312
|
-
method=url_area_data.method,
|
|
313
|
-
url=url_area_data.url,
|
|
314
|
-
headers=headers,
|
|
315
|
-
params=query_params,
|
|
316
|
-
data=form_multipart_str,
|
|
317
|
-
files=form_multipart_files,
|
|
318
|
-
)
|
|
319
|
-
|
|
320
|
-
def _display_response(self, response: httpx.Response) -> None:
|
|
321
|
-
status = HTTPStatus(response.status_code)
|
|
322
|
-
size = response.num_bytes_downloaded
|
|
323
|
-
elapsed_time = round(response.elapsed.total_seconds(), 2)
|
|
324
|
-
headers = {
|
|
325
|
-
header_key: header_value
|
|
326
|
-
for header_key, header_value in response.headers.multi_items()
|
|
327
|
-
}
|
|
328
|
-
content_type_to_body_language = {
|
|
329
|
-
ContentType.TEXT: BodyRawLanguage.PLAIN,
|
|
330
|
-
ContentType.HTML: BodyRawLanguage.HTML,
|
|
331
|
-
ContentType.JSON: BodyRawLanguage.JSON,
|
|
332
|
-
ContentType.YAML: BodyRawLanguage.YAML,
|
|
333
|
-
ContentType.XML: BodyRawLanguage.XML,
|
|
334
|
-
}
|
|
335
|
-
body_raw_language = content_type_to_body_language.get(
|
|
336
|
-
response.headers.get('Content-Type'), BodyRawLanguage.PLAIN
|
|
337
|
-
)
|
|
338
|
-
body_raw = response.text
|
|
339
|
-
self.response_area.set_data(
|
|
340
|
-
data=ResponseAreaData(
|
|
341
|
-
status=status,
|
|
342
|
-
size=size,
|
|
343
|
-
elapsed_time=elapsed_time,
|
|
344
|
-
headers=headers,
|
|
345
|
-
body_raw_language=body_raw_language,
|
|
346
|
-
body_raw=body_raw,
|
|
347
|
-
)
|
|
348
|
-
)
|