restiny 0.1.2__tar.gz → 0.2.0__tar.gz
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.
Potentially problematic release.
This version of restiny might be problematic. Click here for more details.
- {restiny-0.1.2 → restiny-0.2.0}/PKG-INFO +5 -4
- {restiny-0.1.2 → restiny-0.2.0}/README.md +2 -2
- {restiny-0.1.2 → restiny-0.2.0}/pyproject.toml +4 -3
- restiny-0.2.0/restiny/__about__.py +1 -0
- {restiny-0.1.2 → restiny-0.2.0}/restiny/core/app.py +5 -4
- {restiny-0.1.2 → restiny-0.2.0}/restiny/core/request_area.py +36 -33
- {restiny-0.1.2 → restiny-0.2.0}/restiny/enums.py +0 -1
- {restiny-0.1.2 → restiny-0.2.0}/restiny/widgets/__init__.py +2 -2
- restiny-0.2.0/restiny/widgets/dynamic_fields.py +540 -0
- {restiny-0.1.2 → restiny-0.2.0}/restiny/widgets/path_chooser.py +36 -12
- {restiny-0.1.2 → restiny-0.2.0}/restiny.egg-info/PKG-INFO +5 -4
- {restiny-0.1.2 → restiny-0.2.0}/restiny.egg-info/SOURCES.txt +0 -3
- {restiny-0.1.2 → restiny-0.2.0}/restiny.egg-info/requires.txt +1 -1
- restiny-0.1.2/restiny/__about__.py +0 -1
- restiny-0.1.2/restiny/assets/__pycache__/__init__.cpython-310.pyc +0 -0
- restiny-0.1.2/restiny/assets/__pycache__/__init__.cpython-313.pyc +0 -0
- restiny-0.1.2/restiny/assets/__pycache__/__init__.cpython-314.pyc +0 -0
- restiny-0.1.2/restiny/widgets/dynamic_fields.py +0 -287
- {restiny-0.1.2 → restiny-0.2.0}/LICENSE +0 -0
- {restiny-0.1.2 → restiny-0.2.0}/restiny/__init__.py +0 -0
- {restiny-0.1.2 → restiny-0.2.0}/restiny/__main__.py +0 -0
- {restiny-0.1.2 → restiny-0.2.0}/restiny/assets/__init__.py +0 -0
- {restiny-0.1.2 → restiny-0.2.0}/restiny/assets/style.tcss +0 -0
- {restiny-0.1.2 → restiny-0.2.0}/restiny/consts.py +0 -0
- {restiny-0.1.2 → restiny-0.2.0}/restiny/core/__init__.py +0 -0
- {restiny-0.1.2 → restiny-0.2.0}/restiny/core/response_area.py +0 -0
- {restiny-0.1.2 → restiny-0.2.0}/restiny/core/url_area.py +0 -0
- {restiny-0.1.2 → restiny-0.2.0}/restiny/screens/__init__.py +0 -0
- {restiny-0.1.2 → restiny-0.2.0}/restiny/screens/dialog.py +0 -0
- {restiny-0.1.2 → restiny-0.2.0}/restiny/utils.py +0 -0
- {restiny-0.1.2 → restiny-0.2.0}/restiny/widgets/custom_directory_tree.py +0 -0
- {restiny-0.1.2 → restiny-0.2.0}/restiny/widgets/custom_text_area.py +0 -0
- {restiny-0.1.2 → restiny-0.2.0}/restiny.egg-info/dependency_links.txt +0 -0
- {restiny-0.1.2 → restiny-0.2.0}/restiny.egg-info/entry_points.txt +0 -0
- {restiny-0.1.2 → restiny-0.2.0}/restiny.egg-info/top_level.txt +0 -0
- {restiny-0.1.2 → restiny-0.2.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: restiny
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.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
|
|
@@ -220,6 +220,7 @@ Classifier: Programming Language :: Python :: 3.10
|
|
|
220
220
|
Classifier: Programming Language :: Python :: 3.11
|
|
221
221
|
Classifier: Programming Language :: Python :: 3.12
|
|
222
222
|
Classifier: Programming Language :: Python :: 3.13
|
|
223
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
223
224
|
Classifier: Operating System :: POSIX :: Linux
|
|
224
225
|
Classifier: Operating System :: MacOS
|
|
225
226
|
Classifier: Operating System :: Microsoft :: Windows :: Windows 10
|
|
@@ -230,14 +231,14 @@ Classifier: Natural Language :: English
|
|
|
230
231
|
Requires-Python: >=3.10
|
|
231
232
|
Description-Content-Type: text/markdown
|
|
232
233
|
License-File: LICENSE
|
|
233
|
-
Requires-Dist: textual<6.
|
|
234
|
+
Requires-Dist: textual<6.4,>=6.3
|
|
234
235
|
Requires-Dist: textual[syntax]
|
|
235
236
|
Requires-Dist: httpx<0.29,>=0.28
|
|
236
237
|
Requires-Dist: pyperclip<1.10,>=1.9
|
|
237
238
|
Dynamic: license-file
|
|
238
239
|
|
|
239
240
|

|
|
240
|
-

|
|
241
|
+

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

|
|
257
258
|
|
|
258
259
|
## How to install
|
|
259
260
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|

|
|
2
|
-

|
|
2
|
+

|
|
3
3
|
|
|
4
4
|
|
|
5
5
|
- [RESTiny](#restiny)
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
_A minimal, elegant HTTP client for Python — with a TUI interface powered by [Textual](https://github.com/Textualize/textual)._
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+

|
|
19
19
|
|
|
20
20
|
## How to install
|
|
21
21
|
|
|
@@ -14,7 +14,7 @@ readme = "README.md"
|
|
|
14
14
|
license = { file = "LICENSE" }
|
|
15
15
|
requires-python = ">=3.10"
|
|
16
16
|
dependencies = [
|
|
17
|
-
"textual>=6.
|
|
17
|
+
"textual>=6.3,<6.4",
|
|
18
18
|
"textual[syntax]",
|
|
19
19
|
"httpx>=0.28,<0.29",
|
|
20
20
|
"pyperclip>=1.9,<1.10",
|
|
@@ -31,6 +31,7 @@ classifiers = [
|
|
|
31
31
|
"Programming Language :: Python :: 3.11",
|
|
32
32
|
"Programming Language :: Python :: 3.12",
|
|
33
33
|
"Programming Language :: Python :: 3.13",
|
|
34
|
+
"Programming Language :: Python :: 3.14",
|
|
34
35
|
"Operating System :: POSIX :: Linux",
|
|
35
36
|
"Operating System :: MacOS",
|
|
36
37
|
"Operating System :: Microsoft :: Windows :: Windows 10",
|
|
@@ -63,11 +64,11 @@ restiny = ["assets/**/*"]
|
|
|
63
64
|
##########
|
|
64
65
|
[tool.ruff]
|
|
65
66
|
line-length = 79
|
|
66
|
-
target-version = "
|
|
67
|
+
target-version = "py314"
|
|
67
68
|
|
|
68
69
|
[tool.ruff.lint]
|
|
69
70
|
select = ["E", "F", "B", "I", "UP"]
|
|
70
|
-
ignore = ["E501", "B006"]
|
|
71
|
+
ignore = ["E501", "B006", "UP037"]
|
|
71
72
|
|
|
72
73
|
[tool.ruff.format]
|
|
73
74
|
quote-style = "single"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = '0.2.0'
|
|
@@ -237,10 +237,11 @@ class RESTinyApp(App, inherit_bindings=False):
|
|
|
237
237
|
)
|
|
238
238
|
elif request_area_data.body.type == BodyMode.FILE:
|
|
239
239
|
file = request_area_data.body.payload
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
240
|
+
if 'content-type' not in headers:
|
|
241
|
+
headers['content-type'] = (
|
|
242
|
+
mimetypes.guess_type(file.name)[0]
|
|
243
|
+
or 'application/octet-stream'
|
|
244
|
+
)
|
|
244
245
|
request = http_client.build_request(
|
|
245
246
|
method=url_area_data.method,
|
|
246
247
|
url=url_area_data.url,
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import mimetypes
|
|
2
1
|
from dataclasses import dataclass
|
|
3
2
|
from pathlib import Path
|
|
4
3
|
|
|
@@ -19,10 +18,11 @@ from textual.widgets import (
|
|
|
19
18
|
from restiny.enums import BodyMode, BodyRawLanguage
|
|
20
19
|
from restiny.widgets import (
|
|
21
20
|
CustomTextArea,
|
|
22
|
-
DynamicField,
|
|
23
21
|
DynamicFields,
|
|
24
22
|
PathChooser,
|
|
23
|
+
TextDynamicField,
|
|
25
24
|
)
|
|
25
|
+
from restiny.widgets.dynamic_fields import TextOrFileDynamicField
|
|
26
26
|
|
|
27
27
|
|
|
28
28
|
@dataclass
|
|
@@ -98,12 +98,12 @@ class RequestArea(Static):
|
|
|
98
98
|
with TabbedContent():
|
|
99
99
|
with TabPane('Headers'):
|
|
100
100
|
yield DynamicFields(
|
|
101
|
-
fields=[
|
|
101
|
+
fields=[TextDynamicField(enabled=False, key='', value='')],
|
|
102
102
|
id='headers',
|
|
103
103
|
)
|
|
104
104
|
with TabPane('Query params'):
|
|
105
105
|
yield DynamicFields(
|
|
106
|
-
fields=[
|
|
106
|
+
fields=[TextDynamicField(enabled=False, key='', value='')],
|
|
107
107
|
id='params',
|
|
108
108
|
)
|
|
109
109
|
with TabPane('Body'):
|
|
@@ -114,7 +114,7 @@ class RequestArea(Static):
|
|
|
114
114
|
('Raw', BodyMode.RAW),
|
|
115
115
|
('File', BodyMode.FILE),
|
|
116
116
|
('Form (urlencoded)', BodyMode.FORM_URLENCODED),
|
|
117
|
-
|
|
117
|
+
('Form (multipart)', BodyMode.FORM_MULTIPART),
|
|
118
118
|
),
|
|
119
119
|
allow_blank=False,
|
|
120
120
|
tooltip='Body type',
|
|
@@ -149,9 +149,24 @@ class RequestArea(Static):
|
|
|
149
149
|
id='body-mode-form-urlencoded', classes='h-auto mt-1'
|
|
150
150
|
):
|
|
151
151
|
yield DynamicFields(
|
|
152
|
-
[
|
|
152
|
+
[
|
|
153
|
+
TextDynamicField(
|
|
154
|
+
enabled=False, key='', value=''
|
|
155
|
+
)
|
|
156
|
+
],
|
|
153
157
|
id='body-form-urlencoded',
|
|
154
158
|
)
|
|
159
|
+
with Horizontal(
|
|
160
|
+
id='body-mode-form-multipart', classes='h-auto mt-1'
|
|
161
|
+
):
|
|
162
|
+
yield DynamicFields(
|
|
163
|
+
[
|
|
164
|
+
TextOrFileDynamicField(
|
|
165
|
+
enabled=False, key='', value=''
|
|
166
|
+
)
|
|
167
|
+
],
|
|
168
|
+
id='body-form-multipart',
|
|
169
|
+
)
|
|
155
170
|
|
|
156
171
|
with TabPane('Options'):
|
|
157
172
|
with Horizontal(classes='h-auto'):
|
|
@@ -187,6 +202,9 @@ class RequestArea(Static):
|
|
|
187
202
|
self.body_form_urlencoded_fields = self.query_one(
|
|
188
203
|
'#body-form-urlencoded', DynamicFields
|
|
189
204
|
)
|
|
205
|
+
self.body_form_multipart_fields = self.query_one(
|
|
206
|
+
'#body-form-multipart', DynamicFields
|
|
207
|
+
)
|
|
190
208
|
|
|
191
209
|
self.options_timeout_input = self.query_one('#options-timeout', Input)
|
|
192
210
|
self.options_follow_redirects_switch = self.query_one(
|
|
@@ -204,6 +222,8 @@ class RequestArea(Static):
|
|
|
204
222
|
self.body_mode_switcher.current = 'body-mode-raw'
|
|
205
223
|
elif message.value == BodyMode.FORM_URLENCODED:
|
|
206
224
|
self.body_mode_switcher.current = 'body-mode-form-urlencoded'
|
|
225
|
+
elif message.value == BodyMode.FORM_MULTIPART:
|
|
226
|
+
self.body_mode_switcher.current = 'body-mode-form-multipart'
|
|
207
227
|
|
|
208
228
|
@on(Select.Changed, '#body-raw-language')
|
|
209
229
|
def on_change_body_text_language(self, message: Select.Changed) -> None:
|
|
@@ -225,30 +245,6 @@ class RequestArea(Static):
|
|
|
225
245
|
else:
|
|
226
246
|
self.body_enabled_switch.value = True
|
|
227
247
|
|
|
228
|
-
@on(PathChooser.Changed)
|
|
229
|
-
async def on_change_file(self, message: PathChooser.Changed) -> None:
|
|
230
|
-
content_type_header_field: DynamicField | None = None
|
|
231
|
-
for header_field in self.header_fields.fields:
|
|
232
|
-
if header_field.key.lower() == 'content-type':
|
|
233
|
-
content_type_header_field = header_field
|
|
234
|
-
break
|
|
235
|
-
|
|
236
|
-
content_type: str | None = mimetypes.guess_type(str(message.path))[0]
|
|
237
|
-
if not content_type:
|
|
238
|
-
return
|
|
239
|
-
|
|
240
|
-
if content_type_header_field:
|
|
241
|
-
content_type_header_field.value = content_type
|
|
242
|
-
return
|
|
243
|
-
|
|
244
|
-
empty_field = self.header_fields.empty_fields[0]
|
|
245
|
-
empty_field.enabled = True
|
|
246
|
-
empty_field.key = 'Content-Type'
|
|
247
|
-
empty_field.value = content_type
|
|
248
|
-
await self.header_fields.add_field(
|
|
249
|
-
field=DynamicField(enabled=False, key='', value='')
|
|
250
|
-
)
|
|
251
|
-
|
|
252
248
|
@on(Input.Changed, '#options-timeout')
|
|
253
249
|
def on_change_timeout(self, message: Input.Changed) -> None:
|
|
254
250
|
new_value = message.value
|
|
@@ -303,6 +299,16 @@ class RequestArea(Static):
|
|
|
303
299
|
value=form_item['value'],
|
|
304
300
|
)
|
|
305
301
|
)
|
|
302
|
+
elif body_type == BodyMode.FORM_MULTIPART:
|
|
303
|
+
payload = []
|
|
304
|
+
for form_item in self.body_form_multipart_fields.values:
|
|
305
|
+
payload.append(
|
|
306
|
+
FormMultipartField(
|
|
307
|
+
enabled=form_item['enabled'],
|
|
308
|
+
key=form_item['key'],
|
|
309
|
+
value=form_item['value'],
|
|
310
|
+
)
|
|
311
|
+
)
|
|
306
312
|
|
|
307
313
|
return RequestAreaData.Body(
|
|
308
314
|
enabled=body_send,
|
|
@@ -330,6 +336,3 @@ class RequestArea(Static):
|
|
|
330
336
|
body=get_body(),
|
|
331
337
|
options=get_options(),
|
|
332
338
|
)
|
|
333
|
-
|
|
334
|
-
def set_content_type() -> None:
|
|
335
|
-
raise NotImplementedError()
|
|
@@ -4,11 +4,11 @@ This module contains reusable widgets used in the DataFox interface.
|
|
|
4
4
|
|
|
5
5
|
from restiny.widgets.custom_directory_tree import CustomDirectoryTree
|
|
6
6
|
from restiny.widgets.custom_text_area import CustomTextArea
|
|
7
|
-
from restiny.widgets.dynamic_fields import
|
|
7
|
+
from restiny.widgets.dynamic_fields import DynamicFields, TextDynamicField
|
|
8
8
|
from restiny.widgets.path_chooser import PathChooser
|
|
9
9
|
|
|
10
10
|
__all__ = [
|
|
11
|
-
'
|
|
11
|
+
'TextDynamicField',
|
|
12
12
|
'DynamicFields',
|
|
13
13
|
'CustomDirectoryTree',
|
|
14
14
|
'CustomTextArea',
|
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
from abc import abstractmethod
|
|
2
|
+
from enum import StrEnum
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from textual import on
|
|
6
|
+
from textual.app import ComposeResult
|
|
7
|
+
from textual.containers import VerticalScroll
|
|
8
|
+
from textual.message import Message
|
|
9
|
+
from textual.widgets import (
|
|
10
|
+
Button,
|
|
11
|
+
ContentSwitcher,
|
|
12
|
+
Input,
|
|
13
|
+
RadioButton,
|
|
14
|
+
RadioSet,
|
|
15
|
+
Static,
|
|
16
|
+
Switch,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
from restiny.widgets.path_chooser import PathChooser
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class DynamicField(Static):
|
|
23
|
+
@abstractmethod
|
|
24
|
+
def compose(self) -> ComposeResult: ...
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
@abstractmethod
|
|
28
|
+
def enabled(self) -> bool: ...
|
|
29
|
+
|
|
30
|
+
@enabled.setter
|
|
31
|
+
@abstractmethod
|
|
32
|
+
def enabled(self, value: bool) -> None: ...
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
@abstractmethod
|
|
36
|
+
def key(self) -> str: ...
|
|
37
|
+
|
|
38
|
+
@key.setter
|
|
39
|
+
@abstractmethod
|
|
40
|
+
def key(self, value: str) -> None: ...
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
@abstractmethod
|
|
44
|
+
def value(self) -> str | Path | None: ...
|
|
45
|
+
|
|
46
|
+
@value.setter
|
|
47
|
+
@abstractmethod
|
|
48
|
+
def value(self, value: str | Path | None) -> None: ...
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
@abstractmethod
|
|
52
|
+
def is_empty(self) -> bool: ...
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
@abstractmethod
|
|
56
|
+
def is_filled(self) -> bool: ...
|
|
57
|
+
|
|
58
|
+
class Enabled(Message):
|
|
59
|
+
"""
|
|
60
|
+
Sent when the user enables the field.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(self, field: 'DynamicField') -> None:
|
|
64
|
+
super().__init__()
|
|
65
|
+
self.field = field
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def control(self) -> 'DynamicField':
|
|
69
|
+
return self.field
|
|
70
|
+
|
|
71
|
+
class Disabled(Message):
|
|
72
|
+
"""
|
|
73
|
+
Sent when the user disables the field.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def __init__(self, field: 'DynamicField') -> None:
|
|
77
|
+
super().__init__()
|
|
78
|
+
self.field = field
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def control(self) -> 'DynamicField':
|
|
82
|
+
return self.field
|
|
83
|
+
|
|
84
|
+
class Empty(Message):
|
|
85
|
+
"""
|
|
86
|
+
Sent when the key input and value input is empty.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
def __init__(self, field: 'DynamicField') -> None:
|
|
90
|
+
super().__init__()
|
|
91
|
+
self.field = field
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def control(self) -> 'DynamicField':
|
|
95
|
+
return self.field
|
|
96
|
+
|
|
97
|
+
class Filled(Message):
|
|
98
|
+
"""
|
|
99
|
+
Sent when the key input or value input is filled.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
def __init__(self, field: 'DynamicField') -> None:
|
|
103
|
+
super().__init__()
|
|
104
|
+
self.field = field
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def control(self) -> 'DynamicField':
|
|
108
|
+
return self.field
|
|
109
|
+
|
|
110
|
+
class RemoveRequested(Message):
|
|
111
|
+
"""
|
|
112
|
+
Sent when the user clicks the remove button.
|
|
113
|
+
The listener of this event decides whether
|
|
114
|
+
to actually remove the field or not.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
def __init__(self, field: 'DynamicField') -> None:
|
|
118
|
+
super().__init__()
|
|
119
|
+
self.field = field
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def control(self) -> 'DynamicField':
|
|
123
|
+
return self.field
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class TextDynamicField(DynamicField):
|
|
127
|
+
"""
|
|
128
|
+
Enableable and removable field
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
DEFAULT_CSS = """
|
|
132
|
+
TextDynamicField {
|
|
133
|
+
layout: grid;
|
|
134
|
+
grid-size: 4 1;
|
|
135
|
+
grid-columns: auto 1fr 2fr auto; /* Set 1:2 ratio between Inputs */
|
|
136
|
+
}
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
def __init__(
|
|
140
|
+
self, enabled: bool, key: str, value: str, *args, **kwargs
|
|
141
|
+
) -> None:
|
|
142
|
+
super().__init__(*args, **kwargs)
|
|
143
|
+
|
|
144
|
+
# Store initial values temporarily; applied after mounting.
|
|
145
|
+
self._enabled = enabled
|
|
146
|
+
self._key = key
|
|
147
|
+
self._value = value
|
|
148
|
+
|
|
149
|
+
def compose(self) -> ComposeResult:
|
|
150
|
+
yield Switch(value=self._enabled, tooltip='Send this field?')
|
|
151
|
+
yield Input(value=self._key, placeholder='Key', id='key')
|
|
152
|
+
yield Input(value=self._value, placeholder='Value', id='value')
|
|
153
|
+
yield Button(label='➖', tooltip='Remove field')
|
|
154
|
+
|
|
155
|
+
def on_mount(self) -> None:
|
|
156
|
+
self.enabled_switch: Switch = self.query_one(Switch)
|
|
157
|
+
self.key_input: Input = self.query_one('#key', Input)
|
|
158
|
+
self.value_input: Input = self.query_one('#value', Input)
|
|
159
|
+
self.remove_button: Button = self.query_one(Button)
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def enabled(self) -> bool:
|
|
163
|
+
return self.enabled_switch.value
|
|
164
|
+
|
|
165
|
+
@enabled.setter
|
|
166
|
+
def enabled(self, value: bool) -> None:
|
|
167
|
+
self.enabled_switch.value = value
|
|
168
|
+
|
|
169
|
+
@property
|
|
170
|
+
def key(self) -> str:
|
|
171
|
+
return self.key_input.value
|
|
172
|
+
|
|
173
|
+
@key.setter
|
|
174
|
+
def key(self, value: str) -> None:
|
|
175
|
+
self.key_input.value = value
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def value(self) -> str:
|
|
179
|
+
return self.value_input.value
|
|
180
|
+
|
|
181
|
+
@value.setter
|
|
182
|
+
def value(self, value: str) -> None:
|
|
183
|
+
self.value_input.value = value
|
|
184
|
+
|
|
185
|
+
@property
|
|
186
|
+
def is_filled(self) -> bool:
|
|
187
|
+
return len(self.key_input.value) > 0 or len(self.value_input.value) > 0
|
|
188
|
+
|
|
189
|
+
@property
|
|
190
|
+
def is_empty(self) -> bool:
|
|
191
|
+
return not self.is_filled
|
|
192
|
+
|
|
193
|
+
@on(Switch.Changed)
|
|
194
|
+
def on_enabled_or_disabled(self, message: Switch.Changed) -> None:
|
|
195
|
+
if message.value is True:
|
|
196
|
+
self.post_message(self.Enabled(field=self))
|
|
197
|
+
elif message.value is False:
|
|
198
|
+
self.post_message(message=self.Disabled(field=self))
|
|
199
|
+
|
|
200
|
+
@on(Input.Changed)
|
|
201
|
+
def on_input_changed(self, message: Input.Changed) -> None:
|
|
202
|
+
self.enabled_switch.value = True
|
|
203
|
+
|
|
204
|
+
if self.is_empty:
|
|
205
|
+
self.post_message(message=self.Empty(field=self))
|
|
206
|
+
elif self.is_filled:
|
|
207
|
+
self.post_message(message=self.Filled(field=self))
|
|
208
|
+
|
|
209
|
+
@on(Button.Pressed)
|
|
210
|
+
def on_remove_requested(self, message: Button.Pressed) -> None:
|
|
211
|
+
self.post_message(self.RemoveRequested(field=self))
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class _ValueMode(StrEnum):
|
|
215
|
+
TEXT = 'text'
|
|
216
|
+
FILE = 'file'
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
class TextOrFileDynamicField(DynamicField):
|
|
220
|
+
DEFAULT_CSS = """
|
|
221
|
+
TextOrFileDynamicField {
|
|
222
|
+
width: 100%;
|
|
223
|
+
height: auto;
|
|
224
|
+
layout: grid;
|
|
225
|
+
grid-size: 5 1;
|
|
226
|
+
grid-columns: auto auto 1fr 2fr auto; /* Set 1:2 ratio between Inputs */
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
TextOrFileDynamicField > RadioSet > RadioButton.-selected {
|
|
230
|
+
background: $surface;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
TextOrFileDynamicField > ContentSwitcher > PathChooser{
|
|
234
|
+
margin-right: 1;
|
|
235
|
+
}
|
|
236
|
+
"""
|
|
237
|
+
|
|
238
|
+
def __init__(
|
|
239
|
+
self,
|
|
240
|
+
enabled: bool = False,
|
|
241
|
+
key: str = '',
|
|
242
|
+
value: str | Path | None = '',
|
|
243
|
+
value_mode: _ValueMode = 'text',
|
|
244
|
+
*args,
|
|
245
|
+
**kwargs,
|
|
246
|
+
) -> None:
|
|
247
|
+
super().__init__(*args, **kwargs)
|
|
248
|
+
self._enabled = enabled
|
|
249
|
+
self._key = key
|
|
250
|
+
self._value = value
|
|
251
|
+
self._value_mode = value_mode
|
|
252
|
+
|
|
253
|
+
def compose(self) -> ComposeResult:
|
|
254
|
+
with RadioSet(id='value-mode', compact=True):
|
|
255
|
+
yield RadioButton(
|
|
256
|
+
label=_ValueMode.TEXT,
|
|
257
|
+
value=bool(self._value_mode == _ValueMode.TEXT),
|
|
258
|
+
id='value-mode-text',
|
|
259
|
+
)
|
|
260
|
+
yield RadioButton(
|
|
261
|
+
label=_ValueMode.FILE,
|
|
262
|
+
value=bool(self._value_mode == _ValueMode.FILE),
|
|
263
|
+
id='value-mode-file',
|
|
264
|
+
)
|
|
265
|
+
yield Switch(
|
|
266
|
+
value=self._enabled,
|
|
267
|
+
tooltip='Send this field?',
|
|
268
|
+
id='enabled',
|
|
269
|
+
)
|
|
270
|
+
yield Input(value=self._key, placeholder='Key', id='key')
|
|
271
|
+
with ContentSwitcher(
|
|
272
|
+
initial='value-text'
|
|
273
|
+
if self._value_mode == _ValueMode.TEXT
|
|
274
|
+
else 'value-file',
|
|
275
|
+
id='value-mode-switcher',
|
|
276
|
+
):
|
|
277
|
+
yield Input(
|
|
278
|
+
value=self._value
|
|
279
|
+
if self._value_mode == _ValueMode.TEXT
|
|
280
|
+
else '',
|
|
281
|
+
placeholder='Value',
|
|
282
|
+
id='value-text',
|
|
283
|
+
)
|
|
284
|
+
yield PathChooser.file(
|
|
285
|
+
path=self._value
|
|
286
|
+
if self._value_mode == _ValueMode.FILE
|
|
287
|
+
else None,
|
|
288
|
+
id='value-file',
|
|
289
|
+
)
|
|
290
|
+
yield Button(label='➖', tooltip='Remove field', id='remove')
|
|
291
|
+
|
|
292
|
+
def on_mount(self) -> None:
|
|
293
|
+
self.value_mode_switcher = self.query_one(
|
|
294
|
+
'#value-mode-switcher', ContentSwitcher
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
self.value_mode_radioset = self.query_one('#value-mode', RadioSet)
|
|
298
|
+
self.value_mode_text_radio_button = self.query_one(
|
|
299
|
+
'#value-mode-text', RadioButton
|
|
300
|
+
)
|
|
301
|
+
self.value_mode_file_radio_button = self.query_one(
|
|
302
|
+
'#value-mode-file', RadioButton
|
|
303
|
+
)
|
|
304
|
+
self.enabled_switch = self.query_one('#enabled', Switch)
|
|
305
|
+
self.key_input = self.query_one('#key', Input)
|
|
306
|
+
self.value_text_input = self.query_one('#value-text', Input)
|
|
307
|
+
self.value_file_input = self.query_one('#value-file', PathChooser)
|
|
308
|
+
self.remove_button = self.query_one('#remove', Button)
|
|
309
|
+
|
|
310
|
+
@property
|
|
311
|
+
def enabled(self) -> bool:
|
|
312
|
+
return self.enabled_switch.value
|
|
313
|
+
|
|
314
|
+
@enabled.setter
|
|
315
|
+
def enabled(self, value: bool) -> None:
|
|
316
|
+
self.enabled_switch.value = value
|
|
317
|
+
|
|
318
|
+
@property
|
|
319
|
+
def key(self) -> str:
|
|
320
|
+
return self.key_input.value
|
|
321
|
+
|
|
322
|
+
@key.setter
|
|
323
|
+
def key(self, value: str) -> None:
|
|
324
|
+
self.key_input.value = value
|
|
325
|
+
|
|
326
|
+
@property
|
|
327
|
+
def value(self) -> str | Path | None:
|
|
328
|
+
if self.value_mode == _ValueMode.TEXT:
|
|
329
|
+
return self.value_text_input.value
|
|
330
|
+
elif self.value_mode == _ValueMode.FILE:
|
|
331
|
+
return self.value_file_input.path
|
|
332
|
+
|
|
333
|
+
@value.setter
|
|
334
|
+
def value(self, value: str | Path) -> None:
|
|
335
|
+
if isinstance(value, str):
|
|
336
|
+
self.value_text_input.value = value
|
|
337
|
+
elif isinstance(value, Path):
|
|
338
|
+
self.value_file_input.path = value
|
|
339
|
+
|
|
340
|
+
@property
|
|
341
|
+
def value_mode(self) -> _ValueMode:
|
|
342
|
+
return _ValueMode(self.value_mode_radioset.pressed_button.label)
|
|
343
|
+
|
|
344
|
+
@value_mode.setter
|
|
345
|
+
def value_mode(self, value: _ValueMode) -> None:
|
|
346
|
+
if value == _ValueMode.TEXT:
|
|
347
|
+
self.value_mode_switcher.current = 'value-text'
|
|
348
|
+
self.value_mode_text_radio_button.value = True
|
|
349
|
+
elif value == _ValueMode.FILE:
|
|
350
|
+
self.value_mode_switcher.current = 'value-file'
|
|
351
|
+
self.value_mode_file_radio_button.value = True
|
|
352
|
+
|
|
353
|
+
@property
|
|
354
|
+
def is_filled(self) -> bool:
|
|
355
|
+
if len(self.key_input.value) > 0:
|
|
356
|
+
return True
|
|
357
|
+
elif (
|
|
358
|
+
self.value_mode == _ValueMode.TEXT
|
|
359
|
+
and len(self.value_text_input.value) > 0
|
|
360
|
+
):
|
|
361
|
+
return True
|
|
362
|
+
elif (
|
|
363
|
+
self.value_mode == _ValueMode.FILE
|
|
364
|
+
and self.value_file_input.path is not None
|
|
365
|
+
):
|
|
366
|
+
return True
|
|
367
|
+
else:
|
|
368
|
+
return False
|
|
369
|
+
|
|
370
|
+
@property
|
|
371
|
+
def is_empty(self) -> bool:
|
|
372
|
+
return not self.is_filled
|
|
373
|
+
|
|
374
|
+
@on(RadioSet.Changed, '#value-mode')
|
|
375
|
+
def on_value_mode_changed(self, message: RadioSet.Changed) -> None:
|
|
376
|
+
self.value_mode = _ValueMode(message.pressed.label)
|
|
377
|
+
|
|
378
|
+
@on(Switch.Changed, '#enabled')
|
|
379
|
+
def on_enabled_or_disabled(self, message: Switch.Changed) -> None:
|
|
380
|
+
if message.value is True:
|
|
381
|
+
self.post_message(self.Enabled(field=self))
|
|
382
|
+
elif message.value is False:
|
|
383
|
+
self.post_message(message=self.Disabled(field=self))
|
|
384
|
+
|
|
385
|
+
@on(Input.Changed, '#key')
|
|
386
|
+
@on(Input.Changed, '#value-text')
|
|
387
|
+
@on(PathChooser.Changed, '#value-file')
|
|
388
|
+
def on_input_changed(
|
|
389
|
+
self, message: Input.Changed | PathChooser.Changed
|
|
390
|
+
) -> None:
|
|
391
|
+
self.enabled_switch.value = True
|
|
392
|
+
|
|
393
|
+
if self.is_empty:
|
|
394
|
+
self.post_message(message=self.Empty(field=self))
|
|
395
|
+
elif self.is_filled:
|
|
396
|
+
self.post_message(message=self.Filled(field=self))
|
|
397
|
+
|
|
398
|
+
@on(Button.Pressed, '#remove')
|
|
399
|
+
def on_remove_requested(self, message: Button.Pressed) -> None:
|
|
400
|
+
self.post_message(self.RemoveRequested(field=self))
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
class DynamicFields(Static):
|
|
404
|
+
"""
|
|
405
|
+
Enableable and removable fields
|
|
406
|
+
"""
|
|
407
|
+
|
|
408
|
+
class FieldEmpty(Message):
|
|
409
|
+
"""
|
|
410
|
+
Sent when one of the fields becomes empty.
|
|
411
|
+
"""
|
|
412
|
+
|
|
413
|
+
def __init__(
|
|
414
|
+
self, fields: 'DynamicFields', field: DynamicField
|
|
415
|
+
) -> None:
|
|
416
|
+
super().__init__()
|
|
417
|
+
self.fields = fields
|
|
418
|
+
self.field = field
|
|
419
|
+
|
|
420
|
+
@property
|
|
421
|
+
def control(self) -> 'DynamicFields':
|
|
422
|
+
return self.fields
|
|
423
|
+
|
|
424
|
+
class FieldFilled(Message):
|
|
425
|
+
"""
|
|
426
|
+
Sent when one of the fields becomes filled.
|
|
427
|
+
"""
|
|
428
|
+
|
|
429
|
+
def __init__(
|
|
430
|
+
self, fields: 'DynamicFields', field: DynamicField
|
|
431
|
+
) -> None:
|
|
432
|
+
super().__init__()
|
|
433
|
+
self.fields = fields
|
|
434
|
+
self.field = field
|
|
435
|
+
|
|
436
|
+
@property
|
|
437
|
+
def control(self) -> 'DynamicFields':
|
|
438
|
+
return self.fields
|
|
439
|
+
|
|
440
|
+
def __init__(
|
|
441
|
+
self,
|
|
442
|
+
fields: list[DynamicField],
|
|
443
|
+
*args,
|
|
444
|
+
**kwargs,
|
|
445
|
+
) -> None:
|
|
446
|
+
super().__init__(*args, **kwargs)
|
|
447
|
+
self._fields = fields
|
|
448
|
+
|
|
449
|
+
def compose(self) -> ComposeResult:
|
|
450
|
+
yield VerticalScroll()
|
|
451
|
+
|
|
452
|
+
async def on_mount(self) -> None:
|
|
453
|
+
self.fields_container = self.query_one(VerticalScroll)
|
|
454
|
+
|
|
455
|
+
# Set initial_fields
|
|
456
|
+
for field in self._fields:
|
|
457
|
+
await self.add_field(field=field)
|
|
458
|
+
|
|
459
|
+
@property
|
|
460
|
+
def fields(self) -> list[DynamicField]:
|
|
461
|
+
return list(self.query(DynamicField))
|
|
462
|
+
|
|
463
|
+
@property
|
|
464
|
+
def empty_fields(self) -> list[DynamicField]:
|
|
465
|
+
return [field for field in self.fields if field.is_empty]
|
|
466
|
+
|
|
467
|
+
@property
|
|
468
|
+
def filled_fields(self) -> list[DynamicField]:
|
|
469
|
+
return [field for field in self.fields if field.is_filled]
|
|
470
|
+
|
|
471
|
+
@property
|
|
472
|
+
def values(self) -> list[dict[str, str | bool]]:
|
|
473
|
+
return [
|
|
474
|
+
{
|
|
475
|
+
'enabled': field.enabled,
|
|
476
|
+
'key': field.key,
|
|
477
|
+
'value': field.value,
|
|
478
|
+
}
|
|
479
|
+
for field in self.fields
|
|
480
|
+
]
|
|
481
|
+
|
|
482
|
+
@on(DynamicField.Empty)
|
|
483
|
+
async def on_field_is_empty(self, message: DynamicField.Empty) -> None:
|
|
484
|
+
await self.remove_field(field=message.control)
|
|
485
|
+
self.post_message(
|
|
486
|
+
message=self.FieldEmpty(fields=self, field=message.control)
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
@on(DynamicField.Filled)
|
|
490
|
+
async def on_field_is_filled(self, message: DynamicField.Filled) -> None:
|
|
491
|
+
if len(self.empty_fields) == 0:
|
|
492
|
+
last_field = self.fields[-1]
|
|
493
|
+
if isinstance(last_field, TextDynamicField):
|
|
494
|
+
await self.add_field(
|
|
495
|
+
TextDynamicField(enabled=False, key='', value='')
|
|
496
|
+
)
|
|
497
|
+
elif isinstance(last_field, TextOrFileDynamicField):
|
|
498
|
+
await self.add_field(
|
|
499
|
+
TextOrFileDynamicField(
|
|
500
|
+
enabled=False,
|
|
501
|
+
key='',
|
|
502
|
+
value='',
|
|
503
|
+
value_mode=_ValueMode.TEXT,
|
|
504
|
+
)
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
self.post_message(
|
|
508
|
+
message=self.FieldFilled(fields=self, field=message.control)
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
@on(DynamicField.RemoveRequested)
|
|
512
|
+
async def on_field_remove_requested(
|
|
513
|
+
self, message: DynamicField.RemoveRequested
|
|
514
|
+
) -> None:
|
|
515
|
+
await self.remove_field(field=message.control)
|
|
516
|
+
|
|
517
|
+
async def add_field(self, field: DynamicField) -> None:
|
|
518
|
+
await self.fields_container.mount(field)
|
|
519
|
+
|
|
520
|
+
async def remove_field(self, field: DynamicField) -> None:
|
|
521
|
+
if len(self.fields) == 1:
|
|
522
|
+
self.app.bell()
|
|
523
|
+
return
|
|
524
|
+
elif self.fields[-1] is field: # Last field
|
|
525
|
+
self.app.bell()
|
|
526
|
+
return
|
|
527
|
+
|
|
528
|
+
if self.fields[0] is field: # First field
|
|
529
|
+
self.app.screen.focus_next()
|
|
530
|
+
self.app.screen.focus_next()
|
|
531
|
+
self.app.screen.focus_next()
|
|
532
|
+
self.app.screen.focus_next()
|
|
533
|
+
elif self.fields[-2] is field: # Penultimate field
|
|
534
|
+
self.app.screen.focus_previous()
|
|
535
|
+
self.app.screen.focus_previous()
|
|
536
|
+
self.app.screen.focus_previous()
|
|
537
|
+
self.app.screen.focus_previous()
|
|
538
|
+
|
|
539
|
+
field.add_class('hidden')
|
|
540
|
+
await field.remove() # Maybe the `await` is unnecessary
|
|
@@ -184,30 +184,42 @@ class DirectoryChooserScreen(PathChooserScreen):
|
|
|
184
184
|
class PathChooser(Widget):
|
|
185
185
|
DEFAULT_CSS = """
|
|
186
186
|
PathChooser {
|
|
187
|
+
width: 1fr;
|
|
187
188
|
height: auto;
|
|
188
|
-
|
|
189
|
+
layout: grid;
|
|
190
|
+
grid-size: 2 1;
|
|
191
|
+
grid-columns: 1fr auto;
|
|
189
192
|
}
|
|
190
193
|
|
|
191
|
-
Input {
|
|
192
|
-
|
|
194
|
+
PathChooser > Input {
|
|
195
|
+
margin-right: 0;
|
|
196
|
+
border-right: none;
|
|
193
197
|
}
|
|
194
198
|
|
|
195
|
-
Button {
|
|
196
|
-
|
|
199
|
+
PathChooser > Button {
|
|
200
|
+
margin-left: 0;
|
|
201
|
+
border-left: none;
|
|
197
202
|
}
|
|
198
203
|
"""
|
|
199
204
|
|
|
200
|
-
path: Reactive[Path | None] = Reactive(None
|
|
205
|
+
path: Reactive[Path | None] = Reactive(None)
|
|
201
206
|
|
|
202
207
|
class Changed(Message):
|
|
203
208
|
"""
|
|
204
209
|
Sent when the user change the selected path.
|
|
205
210
|
"""
|
|
206
211
|
|
|
207
|
-
def __init__(
|
|
212
|
+
def __init__(
|
|
213
|
+
self, path_chooser: PathChooser, path: Path | None
|
|
214
|
+
) -> None:
|
|
208
215
|
super().__init__()
|
|
216
|
+
self.path_chooser = path_chooser
|
|
209
217
|
self.path = path
|
|
210
218
|
|
|
219
|
+
@property
|
|
220
|
+
def control(self) -> 'PathChooser':
|
|
221
|
+
return self.path_chooser
|
|
222
|
+
|
|
211
223
|
@classmethod
|
|
212
224
|
def file(cls, *args, **kwargs) -> 'PathChooser':
|
|
213
225
|
return cls(*args, **kwargs, path_type='file')
|
|
@@ -217,20 +229,32 @@ class PathChooser(Widget):
|
|
|
217
229
|
return cls(*args, **kwargs, path_type='directory')
|
|
218
230
|
|
|
219
231
|
def __init__(
|
|
220
|
-
self,
|
|
232
|
+
self,
|
|
233
|
+
path_type: Literal['file', 'directory'],
|
|
234
|
+
path: Path | None = None,
|
|
235
|
+
*args,
|
|
236
|
+
**kwargs,
|
|
221
237
|
) -> None:
|
|
222
238
|
super().__init__(*args, **kwargs)
|
|
239
|
+
self._path = path
|
|
223
240
|
self.path_type = path_type
|
|
224
241
|
|
|
225
242
|
def compose(self) -> ComposeResult:
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
243
|
+
button_label = ''
|
|
244
|
+
if self.path_type == 'file':
|
|
245
|
+
button_label = ' 📄 '
|
|
246
|
+
elif self.path_type == 'directory':
|
|
247
|
+
button_label = ' 🗂 '
|
|
248
|
+
|
|
249
|
+
yield Input(placeholder='--empty--', disabled=True)
|
|
250
|
+
yield Button(button_label, tooltip=f'Choose {self.path_type}')
|
|
229
251
|
|
|
230
252
|
def on_mount(self) -> None:
|
|
231
253
|
self.input = self.query_one(Input)
|
|
232
254
|
self.button = self.query_one(Button)
|
|
233
255
|
|
|
256
|
+
self.path = self._path
|
|
257
|
+
|
|
234
258
|
@on(Button.Pressed)
|
|
235
259
|
def open_path_chooser(self) -> None:
|
|
236
260
|
def set_path(path: Path | None = None) -> None:
|
|
@@ -250,4 +274,4 @@ class PathChooser(Widget):
|
|
|
250
274
|
def watch_path(self, value: Path | None) -> None:
|
|
251
275
|
self.input.value = str(value) if value else ''
|
|
252
276
|
self.input.tooltip = str(value) if value else ''
|
|
253
|
-
self.post_message(message=self.Changed(path=value))
|
|
277
|
+
self.post_message(message=self.Changed(path_chooser=self, path=value))
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: restiny
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.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
|
|
@@ -220,6 +220,7 @@ Classifier: Programming Language :: Python :: 3.10
|
|
|
220
220
|
Classifier: Programming Language :: Python :: 3.11
|
|
221
221
|
Classifier: Programming Language :: Python :: 3.12
|
|
222
222
|
Classifier: Programming Language :: Python :: 3.13
|
|
223
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
223
224
|
Classifier: Operating System :: POSIX :: Linux
|
|
224
225
|
Classifier: Operating System :: MacOS
|
|
225
226
|
Classifier: Operating System :: Microsoft :: Windows :: Windows 10
|
|
@@ -230,14 +231,14 @@ Classifier: Natural Language :: English
|
|
|
230
231
|
Requires-Python: >=3.10
|
|
231
232
|
Description-Content-Type: text/markdown
|
|
232
233
|
License-File: LICENSE
|
|
233
|
-
Requires-Dist: textual<6.
|
|
234
|
+
Requires-Dist: textual<6.4,>=6.3
|
|
234
235
|
Requires-Dist: textual[syntax]
|
|
235
236
|
Requires-Dist: httpx<0.29,>=0.28
|
|
236
237
|
Requires-Dist: pyperclip<1.10,>=1.9
|
|
237
238
|
Dynamic: license-file
|
|
238
239
|
|
|
239
240
|

|
|
240
|
-

|
|
241
|
+

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

|
|
257
258
|
|
|
258
259
|
## How to install
|
|
259
260
|
|
|
@@ -15,9 +15,6 @@ restiny.egg-info/requires.txt
|
|
|
15
15
|
restiny.egg-info/top_level.txt
|
|
16
16
|
restiny/assets/__init__.py
|
|
17
17
|
restiny/assets/style.tcss
|
|
18
|
-
restiny/assets/__pycache__/__init__.cpython-310.pyc
|
|
19
|
-
restiny/assets/__pycache__/__init__.cpython-313.pyc
|
|
20
|
-
restiny/assets/__pycache__/__init__.cpython-314.pyc
|
|
21
18
|
restiny/core/__init__.py
|
|
22
19
|
restiny/core/app.py
|
|
23
20
|
restiny/core/request_area.py
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = '0.1.2'
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -1,287 +0,0 @@
|
|
|
1
|
-
from textual import on
|
|
2
|
-
from textual.app import ComposeResult
|
|
3
|
-
from textual.containers import VerticalScroll
|
|
4
|
-
from textual.message import Message
|
|
5
|
-
from textual.widgets import Button, Input, Static, Switch
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
class DynamicField(Static):
|
|
9
|
-
"""
|
|
10
|
-
Enableable and removable field
|
|
11
|
-
"""
|
|
12
|
-
|
|
13
|
-
DEFAULT_CSS = """
|
|
14
|
-
DynamicField {
|
|
15
|
-
layout: grid;
|
|
16
|
-
grid-size: 4 1;
|
|
17
|
-
grid-columns: auto 1fr 2fr auto; /* Set 1:2 ratio between Inputs */
|
|
18
|
-
}
|
|
19
|
-
"""
|
|
20
|
-
|
|
21
|
-
class Enabled(Message):
|
|
22
|
-
"""
|
|
23
|
-
Sent when the user enables the field.
|
|
24
|
-
"""
|
|
25
|
-
|
|
26
|
-
def __init__(self, control: 'DynamicField') -> None:
|
|
27
|
-
super().__init__()
|
|
28
|
-
self._control = control
|
|
29
|
-
|
|
30
|
-
def control(self) -> 'DynamicField':
|
|
31
|
-
return self._control
|
|
32
|
-
|
|
33
|
-
class Disabled(Message):
|
|
34
|
-
"""
|
|
35
|
-
Sent when the user disables the field.
|
|
36
|
-
"""
|
|
37
|
-
|
|
38
|
-
def __init__(self, control: 'DynamicField') -> None:
|
|
39
|
-
super().__init__()
|
|
40
|
-
self._control = control
|
|
41
|
-
|
|
42
|
-
@property
|
|
43
|
-
def control(self) -> 'DynamicField':
|
|
44
|
-
return self._control
|
|
45
|
-
|
|
46
|
-
class Empty(Message):
|
|
47
|
-
"""
|
|
48
|
-
Sent when the key input and value input is empty.
|
|
49
|
-
"""
|
|
50
|
-
|
|
51
|
-
def __init__(self, control: 'DynamicField') -> None:
|
|
52
|
-
super().__init__()
|
|
53
|
-
self._control = control
|
|
54
|
-
|
|
55
|
-
@property
|
|
56
|
-
def control(self) -> 'DynamicField':
|
|
57
|
-
return self._control
|
|
58
|
-
|
|
59
|
-
class Filled(Message):
|
|
60
|
-
"""
|
|
61
|
-
Sent when the key input or value input is filled.
|
|
62
|
-
"""
|
|
63
|
-
|
|
64
|
-
def __init__(self, control: 'DynamicField') -> None:
|
|
65
|
-
super().__init__()
|
|
66
|
-
self._control = control
|
|
67
|
-
|
|
68
|
-
@property
|
|
69
|
-
def control(self) -> 'DynamicField':
|
|
70
|
-
return self._control
|
|
71
|
-
|
|
72
|
-
class RemoveRequested(Message):
|
|
73
|
-
"""
|
|
74
|
-
Sent when the user clicks the remove button.
|
|
75
|
-
The listener of this event decides whether
|
|
76
|
-
to actually remove the field or not.
|
|
77
|
-
"""
|
|
78
|
-
|
|
79
|
-
def __init__(self, control: 'DynamicField') -> None:
|
|
80
|
-
super().__init__()
|
|
81
|
-
self._control = control
|
|
82
|
-
|
|
83
|
-
@property
|
|
84
|
-
def control(self) -> 'DynamicField':
|
|
85
|
-
return self._control
|
|
86
|
-
|
|
87
|
-
def __init__(
|
|
88
|
-
self, enabled: bool, key: str, value: str, *args, **kwargs
|
|
89
|
-
) -> None:
|
|
90
|
-
super().__init__(*args, **kwargs)
|
|
91
|
-
|
|
92
|
-
# Store initial values temporarily; applied after mounting.
|
|
93
|
-
self._initial_enabled = enabled
|
|
94
|
-
self._initial_key = key
|
|
95
|
-
self._initial_value = value
|
|
96
|
-
|
|
97
|
-
def compose(self) -> ComposeResult:
|
|
98
|
-
yield Switch(value=self._initial_enabled, tooltip='Send this field?')
|
|
99
|
-
yield Input(value=self._initial_key, placeholder='Key', id='input-key')
|
|
100
|
-
yield Input(
|
|
101
|
-
value=self._initial_value, placeholder='Value', id='input-value'
|
|
102
|
-
)
|
|
103
|
-
yield Button(label='➖', tooltip='Remove field')
|
|
104
|
-
|
|
105
|
-
def on_mount(self) -> None:
|
|
106
|
-
self.enabled_switch: Switch = self.query_one(Switch)
|
|
107
|
-
self.key_input: Input = self.query_one('#input-key')
|
|
108
|
-
self.value_input: Input = self.query_one('#input-value')
|
|
109
|
-
self.remove_button: Button = self.query_one(Button)
|
|
110
|
-
|
|
111
|
-
@property
|
|
112
|
-
def enabled(self) -> bool:
|
|
113
|
-
return self.enabled_switch.value
|
|
114
|
-
|
|
115
|
-
@enabled.setter
|
|
116
|
-
def enabled(self, value: bool) -> None:
|
|
117
|
-
self.enabled_switch.value = value
|
|
118
|
-
|
|
119
|
-
@property
|
|
120
|
-
def key(self) -> str:
|
|
121
|
-
return self.key_input.value
|
|
122
|
-
|
|
123
|
-
@key.setter
|
|
124
|
-
def key(self, value: str) -> None:
|
|
125
|
-
self.key_input.value = value
|
|
126
|
-
|
|
127
|
-
@property
|
|
128
|
-
def value(self) -> str:
|
|
129
|
-
return self.value_input.value
|
|
130
|
-
|
|
131
|
-
@value.setter
|
|
132
|
-
def value(self, value: str) -> None:
|
|
133
|
-
self.value_input.value = value
|
|
134
|
-
|
|
135
|
-
@property
|
|
136
|
-
def is_empty(self) -> bool:
|
|
137
|
-
return (
|
|
138
|
-
len(self.key_input.value) == 0 and len(self.value_input.value) == 0
|
|
139
|
-
)
|
|
140
|
-
|
|
141
|
-
@property
|
|
142
|
-
def is_filled(self) -> bool:
|
|
143
|
-
return len(self.key_input.value) > 0 or len(self.value_input.value) > 0
|
|
144
|
-
|
|
145
|
-
@on(Switch.Changed)
|
|
146
|
-
def on_enabled_or_disabled(self, message: Switch.Changed) -> None:
|
|
147
|
-
if message.value is True:
|
|
148
|
-
self.post_message(self.Enabled(control=self))
|
|
149
|
-
elif message.value is False:
|
|
150
|
-
self.post_message(message=self.Disabled(control=self))
|
|
151
|
-
|
|
152
|
-
@on(Input.Changed)
|
|
153
|
-
def on_input_changed(self, message: Input.Changed) -> None:
|
|
154
|
-
self.enabled_switch.value = True
|
|
155
|
-
|
|
156
|
-
if self.is_empty:
|
|
157
|
-
self.post_message(message=self.Empty(control=self))
|
|
158
|
-
elif self.is_filled:
|
|
159
|
-
self.post_message(message=self.Filled(control=self))
|
|
160
|
-
|
|
161
|
-
@on(Button.Pressed)
|
|
162
|
-
def on_remove_requested(self, message: Button.Pressed) -> None:
|
|
163
|
-
self.post_message(self.RemoveRequested(control=self))
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
class DynamicFields(Static):
|
|
167
|
-
"""
|
|
168
|
-
Enableable and removable fields
|
|
169
|
-
"""
|
|
170
|
-
|
|
171
|
-
class FieldEmpty(Message):
|
|
172
|
-
"""
|
|
173
|
-
Sent when one of the fields becomes empty.
|
|
174
|
-
"""
|
|
175
|
-
|
|
176
|
-
def __init__(
|
|
177
|
-
self, control: 'DynamicFields', field: DynamicField
|
|
178
|
-
) -> None:
|
|
179
|
-
super().__init__()
|
|
180
|
-
self._control = control
|
|
181
|
-
self.field = field
|
|
182
|
-
|
|
183
|
-
@property
|
|
184
|
-
def control(self) -> 'DynamicFields':
|
|
185
|
-
return self._control
|
|
186
|
-
|
|
187
|
-
class FieldFilled(Message):
|
|
188
|
-
"""
|
|
189
|
-
Sent when one of the fields becomes filled.
|
|
190
|
-
"""
|
|
191
|
-
|
|
192
|
-
def __init__(
|
|
193
|
-
self, control: 'DynamicFields', field: DynamicField
|
|
194
|
-
) -> None:
|
|
195
|
-
super().__init__()
|
|
196
|
-
self._control = control
|
|
197
|
-
self.field = field
|
|
198
|
-
|
|
199
|
-
@property
|
|
200
|
-
def control(self) -> 'DynamicFields':
|
|
201
|
-
return self._control
|
|
202
|
-
|
|
203
|
-
def __init__(self, fields: list[DynamicField], *args, **kwargs) -> None:
|
|
204
|
-
super().__init__(*args, **kwargs)
|
|
205
|
-
self._initial_fields = fields
|
|
206
|
-
|
|
207
|
-
def compose(self) -> ComposeResult:
|
|
208
|
-
yield VerticalScroll()
|
|
209
|
-
|
|
210
|
-
async def on_mount(self) -> None:
|
|
211
|
-
self.fields_container: VerticalScroll = self.query_one(VerticalScroll)
|
|
212
|
-
|
|
213
|
-
# Set initial_fields
|
|
214
|
-
for field in self._initial_fields:
|
|
215
|
-
await self.add_field(field=field)
|
|
216
|
-
|
|
217
|
-
@property
|
|
218
|
-
def fields(self) -> list[DynamicField]:
|
|
219
|
-
return list(self.query(DynamicField))
|
|
220
|
-
|
|
221
|
-
@property
|
|
222
|
-
def empty_fields(self) -> list[DynamicField]:
|
|
223
|
-
return [field for field in self.query(DynamicField) if field.is_empty]
|
|
224
|
-
|
|
225
|
-
@property
|
|
226
|
-
def filled_fields(self) -> list[DynamicField]:
|
|
227
|
-
return [field for field in self.query(DynamicField) if field.is_filled]
|
|
228
|
-
|
|
229
|
-
@property
|
|
230
|
-
def values(self) -> list[dict[str, str | bool]]:
|
|
231
|
-
return [
|
|
232
|
-
{
|
|
233
|
-
'enabled': field.enabled,
|
|
234
|
-
'key': field.key,
|
|
235
|
-
'value': field.value,
|
|
236
|
-
}
|
|
237
|
-
for field in self.fields
|
|
238
|
-
]
|
|
239
|
-
|
|
240
|
-
@on(DynamicField.Empty)
|
|
241
|
-
async def on_field_is_empty(self, message: DynamicField.Empty) -> None:
|
|
242
|
-
await self.remove_field(field=message.control)
|
|
243
|
-
self.post_message(
|
|
244
|
-
message=self.FieldEmpty(control=self, field=message.control)
|
|
245
|
-
)
|
|
246
|
-
|
|
247
|
-
@on(DynamicField.Filled)
|
|
248
|
-
async def on_field_is_filled(self, message: DynamicField.Filled) -> None:
|
|
249
|
-
if len(self.empty_fields) == 0:
|
|
250
|
-
await self.add_field(
|
|
251
|
-
field=DynamicField(enabled=False, key='', value='')
|
|
252
|
-
)
|
|
253
|
-
|
|
254
|
-
self.post_message(
|
|
255
|
-
message=self.FieldFilled(control=self, field=message.control)
|
|
256
|
-
)
|
|
257
|
-
|
|
258
|
-
@on(DynamicField.RemoveRequested)
|
|
259
|
-
async def on_field_remove_requested(
|
|
260
|
-
self, message: DynamicField.RemoveRequested
|
|
261
|
-
) -> None:
|
|
262
|
-
await self.remove_field(field=message.control)
|
|
263
|
-
|
|
264
|
-
async def add_field(self, field: DynamicField) -> None:
|
|
265
|
-
await self.fields_container.mount(field)
|
|
266
|
-
|
|
267
|
-
async def remove_field(self, field: DynamicField) -> None:
|
|
268
|
-
if len(self.fields) == 1:
|
|
269
|
-
self.app.bell()
|
|
270
|
-
return
|
|
271
|
-
elif self.fields[-1] is field: # Last field
|
|
272
|
-
self.app.bell()
|
|
273
|
-
return
|
|
274
|
-
|
|
275
|
-
if self.fields[0] is field: # First field
|
|
276
|
-
self.app.screen.focus_next()
|
|
277
|
-
self.app.screen.focus_next()
|
|
278
|
-
self.app.screen.focus_next()
|
|
279
|
-
self.app.screen.focus_next()
|
|
280
|
-
elif self.fields[-2] is field: # Penultimate field
|
|
281
|
-
self.app.screen.focus_previous()
|
|
282
|
-
self.app.screen.focus_previous()
|
|
283
|
-
self.app.screen.focus_previous()
|
|
284
|
-
self.app.screen.focus_previous()
|
|
285
|
-
|
|
286
|
-
field.add_class('hidden')
|
|
287
|
-
await field.remove() # Maybe the `await` is unnecessary
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|