restiny 0.1.1__py3-none-any.whl → 0.2.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.
Potentially problematic release.
This version of restiny might be problematic. Click here for more details.
- restiny/__about__.py +1 -1
- restiny/core/app.py +5 -4
- restiny/core/request_area.py +36 -33
- restiny/enums.py +0 -1
- restiny/widgets/__init__.py +2 -2
- restiny/widgets/dynamic_fields.py +317 -64
- restiny/widgets/path_chooser.py +36 -12
- {restiny-0.1.1.dist-info → restiny-0.2.0.dist-info}/METADATA +8 -9
- {restiny-0.1.1.dist-info → restiny-0.2.0.dist-info}/RECORD +13 -14
- restiny/assets/__pycache__/__init__.cpython-313.pyc +0 -0
- {restiny-0.1.1.dist-info → restiny-0.2.0.dist-info}/WHEEL +0 -0
- {restiny-0.1.1.dist-info → restiny-0.2.0.dist-info}/entry_points.txt +0 -0
- {restiny-0.1.1.dist-info → restiny-0.2.0.dist-info}/licenses/LICENSE +0 -0
- {restiny-0.1.1.dist-info → restiny-0.2.0.dist-info}/top_level.txt +0 -0
restiny/__about__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = '0.
|
|
1
|
+
__version__ = '0.2.0'
|
restiny/core/app.py
CHANGED
|
@@ -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,
|
restiny/core/request_area.py
CHANGED
|
@@ -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()
|
restiny/enums.py
CHANGED
restiny/widgets/__init__.py
CHANGED
|
@@ -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',
|
|
@@ -1,73 +1,111 @@
|
|
|
1
|
+
from abc import abstractmethod
|
|
2
|
+
from enum import StrEnum
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
1
5
|
from textual import on
|
|
2
6
|
from textual.app import ComposeResult
|
|
3
7
|
from textual.containers import VerticalScroll
|
|
4
8
|
from textual.message import Message
|
|
5
|
-
from textual.widgets import
|
|
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
|
|
6
20
|
|
|
7
21
|
|
|
8
22
|
class DynamicField(Static):
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
"""
|
|
23
|
+
@abstractmethod
|
|
24
|
+
def compose(self) -> ComposeResult: ...
|
|
12
25
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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: ...
|
|
20
57
|
|
|
21
58
|
class Enabled(Message):
|
|
22
59
|
"""
|
|
23
60
|
Sent when the user enables the field.
|
|
24
61
|
"""
|
|
25
62
|
|
|
26
|
-
def __init__(self,
|
|
63
|
+
def __init__(self, field: 'DynamicField') -> None:
|
|
27
64
|
super().__init__()
|
|
28
|
-
self.
|
|
65
|
+
self.field = field
|
|
29
66
|
|
|
67
|
+
@property
|
|
30
68
|
def control(self) -> 'DynamicField':
|
|
31
|
-
return self.
|
|
69
|
+
return self.field
|
|
32
70
|
|
|
33
71
|
class Disabled(Message):
|
|
34
72
|
"""
|
|
35
73
|
Sent when the user disables the field.
|
|
36
74
|
"""
|
|
37
75
|
|
|
38
|
-
def __init__(self,
|
|
76
|
+
def __init__(self, field: 'DynamicField') -> None:
|
|
39
77
|
super().__init__()
|
|
40
|
-
self.
|
|
78
|
+
self.field = field
|
|
41
79
|
|
|
42
80
|
@property
|
|
43
81
|
def control(self) -> 'DynamicField':
|
|
44
|
-
return self.
|
|
82
|
+
return self.field
|
|
45
83
|
|
|
46
84
|
class Empty(Message):
|
|
47
85
|
"""
|
|
48
86
|
Sent when the key input and value input is empty.
|
|
49
87
|
"""
|
|
50
88
|
|
|
51
|
-
def __init__(self,
|
|
89
|
+
def __init__(self, field: 'DynamicField') -> None:
|
|
52
90
|
super().__init__()
|
|
53
|
-
self.
|
|
91
|
+
self.field = field
|
|
54
92
|
|
|
55
93
|
@property
|
|
56
94
|
def control(self) -> 'DynamicField':
|
|
57
|
-
return self.
|
|
95
|
+
return self.field
|
|
58
96
|
|
|
59
97
|
class Filled(Message):
|
|
60
98
|
"""
|
|
61
99
|
Sent when the key input or value input is filled.
|
|
62
100
|
"""
|
|
63
101
|
|
|
64
|
-
def __init__(self,
|
|
102
|
+
def __init__(self, field: 'DynamicField') -> None:
|
|
65
103
|
super().__init__()
|
|
66
|
-
self.
|
|
104
|
+
self.field = field
|
|
67
105
|
|
|
68
106
|
@property
|
|
69
107
|
def control(self) -> 'DynamicField':
|
|
70
|
-
return self.
|
|
108
|
+
return self.field
|
|
71
109
|
|
|
72
110
|
class RemoveRequested(Message):
|
|
73
111
|
"""
|
|
@@ -76,13 +114,27 @@ class DynamicField(Static):
|
|
|
76
114
|
to actually remove the field or not.
|
|
77
115
|
"""
|
|
78
116
|
|
|
79
|
-
def __init__(self,
|
|
117
|
+
def __init__(self, field: 'DynamicField') -> None:
|
|
80
118
|
super().__init__()
|
|
81
|
-
self.
|
|
119
|
+
self.field = field
|
|
82
120
|
|
|
83
121
|
@property
|
|
84
122
|
def control(self) -> 'DynamicField':
|
|
85
|
-
return self.
|
|
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
|
+
"""
|
|
86
138
|
|
|
87
139
|
def __init__(
|
|
88
140
|
self, enabled: bool, key: str, value: str, *args, **kwargs
|
|
@@ -90,22 +142,20 @@ class DynamicField(Static):
|
|
|
90
142
|
super().__init__(*args, **kwargs)
|
|
91
143
|
|
|
92
144
|
# Store initial values temporarily; applied after mounting.
|
|
93
|
-
self.
|
|
94
|
-
self.
|
|
95
|
-
self.
|
|
145
|
+
self._enabled = enabled
|
|
146
|
+
self._key = key
|
|
147
|
+
self._value = value
|
|
96
148
|
|
|
97
149
|
def compose(self) -> ComposeResult:
|
|
98
|
-
yield Switch(value=self.
|
|
99
|
-
yield Input(value=self.
|
|
100
|
-
yield Input(
|
|
101
|
-
value=self._initial_value, placeholder='Value', id='input-value'
|
|
102
|
-
)
|
|
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')
|
|
103
153
|
yield Button(label='➖', tooltip='Remove field')
|
|
104
154
|
|
|
105
155
|
def on_mount(self) -> None:
|
|
106
156
|
self.enabled_switch: Switch = self.query_one(Switch)
|
|
107
|
-
self.key_input: Input = self.query_one('#
|
|
108
|
-
self.value_input: Input = self.query_one('#
|
|
157
|
+
self.key_input: Input = self.query_one('#key', Input)
|
|
158
|
+
self.value_input: Input = self.query_one('#value', Input)
|
|
109
159
|
self.remove_button: Button = self.query_one(Button)
|
|
110
160
|
|
|
111
161
|
@property
|
|
@@ -132,35 +182,222 @@ class DynamicField(Static):
|
|
|
132
182
|
def value(self, value: str) -> None:
|
|
133
183
|
self.value_input.value = value
|
|
134
184
|
|
|
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
185
|
@property
|
|
142
186
|
def is_filled(self) -> bool:
|
|
143
187
|
return len(self.key_input.value) > 0 or len(self.value_input.value) > 0
|
|
144
188
|
|
|
189
|
+
@property
|
|
190
|
+
def is_empty(self) -> bool:
|
|
191
|
+
return not self.is_filled
|
|
192
|
+
|
|
145
193
|
@on(Switch.Changed)
|
|
146
194
|
def on_enabled_or_disabled(self, message: Switch.Changed) -> None:
|
|
147
195
|
if message.value is True:
|
|
148
|
-
self.post_message(self.Enabled(
|
|
196
|
+
self.post_message(self.Enabled(field=self))
|
|
149
197
|
elif message.value is False:
|
|
150
|
-
self.post_message(message=self.Disabled(
|
|
198
|
+
self.post_message(message=self.Disabled(field=self))
|
|
151
199
|
|
|
152
200
|
@on(Input.Changed)
|
|
153
201
|
def on_input_changed(self, message: Input.Changed) -> None:
|
|
154
202
|
self.enabled_switch.value = True
|
|
155
203
|
|
|
156
204
|
if self.is_empty:
|
|
157
|
-
self.post_message(message=self.Empty(
|
|
205
|
+
self.post_message(message=self.Empty(field=self))
|
|
158
206
|
elif self.is_filled:
|
|
159
|
-
self.post_message(message=self.Filled(
|
|
207
|
+
self.post_message(message=self.Filled(field=self))
|
|
160
208
|
|
|
161
209
|
@on(Button.Pressed)
|
|
162
210
|
def on_remove_requested(self, message: Button.Pressed) -> None:
|
|
163
|
-
self.post_message(self.RemoveRequested(
|
|
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))
|
|
164
401
|
|
|
165
402
|
|
|
166
403
|
class DynamicFields(Static):
|
|
@@ -174,15 +411,15 @@ class DynamicFields(Static):
|
|
|
174
411
|
"""
|
|
175
412
|
|
|
176
413
|
def __init__(
|
|
177
|
-
self,
|
|
414
|
+
self, fields: 'DynamicFields', field: DynamicField
|
|
178
415
|
) -> None:
|
|
179
416
|
super().__init__()
|
|
180
|
-
self.
|
|
417
|
+
self.fields = fields
|
|
181
418
|
self.field = field
|
|
182
419
|
|
|
183
420
|
@property
|
|
184
421
|
def control(self) -> 'DynamicFields':
|
|
185
|
-
return self.
|
|
422
|
+
return self.fields
|
|
186
423
|
|
|
187
424
|
class FieldFilled(Message):
|
|
188
425
|
"""
|
|
@@ -190,28 +427,33 @@ class DynamicFields(Static):
|
|
|
190
427
|
"""
|
|
191
428
|
|
|
192
429
|
def __init__(
|
|
193
|
-
self,
|
|
430
|
+
self, fields: 'DynamicFields', field: DynamicField
|
|
194
431
|
) -> None:
|
|
195
432
|
super().__init__()
|
|
196
|
-
self.
|
|
433
|
+
self.fields = fields
|
|
197
434
|
self.field = field
|
|
198
435
|
|
|
199
436
|
@property
|
|
200
437
|
def control(self) -> 'DynamicFields':
|
|
201
|
-
return self.
|
|
438
|
+
return self.fields
|
|
202
439
|
|
|
203
|
-
def __init__(
|
|
440
|
+
def __init__(
|
|
441
|
+
self,
|
|
442
|
+
fields: list[DynamicField],
|
|
443
|
+
*args,
|
|
444
|
+
**kwargs,
|
|
445
|
+
) -> None:
|
|
204
446
|
super().__init__(*args, **kwargs)
|
|
205
|
-
self.
|
|
447
|
+
self._fields = fields
|
|
206
448
|
|
|
207
449
|
def compose(self) -> ComposeResult:
|
|
208
450
|
yield VerticalScroll()
|
|
209
451
|
|
|
210
452
|
async def on_mount(self) -> None:
|
|
211
|
-
self.fields_container
|
|
453
|
+
self.fields_container = self.query_one(VerticalScroll)
|
|
212
454
|
|
|
213
455
|
# Set initial_fields
|
|
214
|
-
for field in self.
|
|
456
|
+
for field in self._fields:
|
|
215
457
|
await self.add_field(field=field)
|
|
216
458
|
|
|
217
459
|
@property
|
|
@@ -220,11 +462,11 @@ class DynamicFields(Static):
|
|
|
220
462
|
|
|
221
463
|
@property
|
|
222
464
|
def empty_fields(self) -> list[DynamicField]:
|
|
223
|
-
return [field for field in self.
|
|
465
|
+
return [field for field in self.fields if field.is_empty]
|
|
224
466
|
|
|
225
467
|
@property
|
|
226
468
|
def filled_fields(self) -> list[DynamicField]:
|
|
227
|
-
return [field for field in self.
|
|
469
|
+
return [field for field in self.fields if field.is_filled]
|
|
228
470
|
|
|
229
471
|
@property
|
|
230
472
|
def values(self) -> list[dict[str, str | bool]]:
|
|
@@ -241,18 +483,29 @@ class DynamicFields(Static):
|
|
|
241
483
|
async def on_field_is_empty(self, message: DynamicField.Empty) -> None:
|
|
242
484
|
await self.remove_field(field=message.control)
|
|
243
485
|
self.post_message(
|
|
244
|
-
message=self.FieldEmpty(
|
|
486
|
+
message=self.FieldEmpty(fields=self, field=message.control)
|
|
245
487
|
)
|
|
246
488
|
|
|
247
489
|
@on(DynamicField.Filled)
|
|
248
490
|
async def on_field_is_filled(self, message: DynamicField.Filled) -> None:
|
|
249
491
|
if len(self.empty_fields) == 0:
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
+
)
|
|
253
506
|
|
|
254
507
|
self.post_message(
|
|
255
|
-
message=self.FieldFilled(
|
|
508
|
+
message=self.FieldFilled(fields=self, field=message.control)
|
|
256
509
|
)
|
|
257
510
|
|
|
258
511
|
@on(DynamicField.RemoveRequested)
|
restiny/widgets/path_chooser.py
CHANGED
|
@@ -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
|
|
@@ -216,12 +216,11 @@ 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.8
|
|
220
|
-
Classifier: Programming Language :: Python :: 3.9
|
|
221
219
|
Classifier: Programming Language :: Python :: 3.10
|
|
222
220
|
Classifier: Programming Language :: Python :: 3.11
|
|
223
221
|
Classifier: Programming Language :: Python :: 3.12
|
|
224
222
|
Classifier: Programming Language :: Python :: 3.13
|
|
223
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
225
224
|
Classifier: Operating System :: POSIX :: Linux
|
|
226
225
|
Classifier: Operating System :: MacOS
|
|
227
226
|
Classifier: Operating System :: Microsoft :: Windows :: Windows 10
|
|
@@ -229,20 +228,20 @@ Classifier: Operating System :: Microsoft :: Windows :: Windows 11
|
|
|
229
228
|
Classifier: Topic :: Internet :: WWW/HTTP
|
|
230
229
|
Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
|
|
231
230
|
Classifier: Natural Language :: English
|
|
232
|
-
Requires-Python: >=3.
|
|
231
|
+
Requires-Python: >=3.10
|
|
233
232
|
Description-Content-Type: text/markdown
|
|
234
233
|
License-File: LICENSE
|
|
235
|
-
Requires-Dist: textual<6.
|
|
234
|
+
Requires-Dist: textual<6.4,>=6.3
|
|
236
235
|
Requires-Dist: textual[syntax]
|
|
237
236
|
Requires-Dist: httpx<0.29,>=0.28
|
|
238
237
|
Requires-Dist: pyperclip<1.10,>=1.9
|
|
239
238
|
Dynamic: license-file
|
|
240
239
|
|
|
241
240
|

|
|
242
|
-

|
|
243
242
|
|
|
244
243
|
|
|
245
|
-
- [
|
|
244
|
+
- [RESTiny](#restiny)
|
|
246
245
|
- [How to install](#how-to-install)
|
|
247
246
|
- [How to install (Alternative: Download pre-built executables)](#how-to-install-alternative-download-pre-built-executables)
|
|
248
247
|
- [How to run](#how-to-run)
|
|
@@ -251,11 +250,11 @@ Dynamic: license-file
|
|
|
251
250
|
- [Why??](#why)
|
|
252
251
|
|
|
253
252
|
|
|
254
|
-
#
|
|
253
|
+
# RESTiny
|
|
255
254
|
|
|
256
255
|
_A minimal, elegant HTTP client for Python — with a TUI interface powered by [Textual](https://github.com/Textualize/textual)._
|
|
257
256
|
|
|
258
|
-
|
|
257
|
+

|
|
259
258
|
|
|
260
259
|
## How to install
|
|
261
260
|
|
|
@@ -1,27 +1,26 @@
|
|
|
1
|
-
restiny/__about__.py,sha256=
|
|
1
|
+
restiny/__about__.py,sha256=FVHPBGkfhbQDi_z3v0PiKJrXXqXOx0vGW_1VaqNJi7U,22
|
|
2
2
|
restiny/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
3
|
restiny/__main__.py,sha256=omMWZ9xgxXbDE6nyVhE8AnVkohPaXhTAn6cYx6OeRMk,609
|
|
4
4
|
restiny/consts.py,sha256=brPBT5j_Yf7lVPo6mVRPPNUHxGnfDfkLWC4Xr_y3UWo,237
|
|
5
|
-
restiny/enums.py,sha256=
|
|
5
|
+
restiny/enums.py,sha256=24ApCF1KoGfu-0XpIT_dbZrnOQx7SjmUKFbt5aMa_dg,873
|
|
6
6
|
restiny/utils.py,sha256=AD8mtM-AuWhcXbTk3-j9gZWs8SoVnI5SbbUw0cnYEUw,3113
|
|
7
7
|
restiny/assets/__init__.py,sha256=JL1KARlToF6ZR7KeUjlDAHgwwVM2qXYaIl4wHeFW2zU,93
|
|
8
8
|
restiny/assets/style.tcss,sha256=qq8kLab6TuaDNk7V3El9FzAVb1tjVr3JzYSjBbKwPzM,563
|
|
9
|
-
restiny/assets/__pycache__/__init__.cpython-313.pyc,sha256=w9J2aawnq4UE6wO7H8rBKMQrLzutfMYvLMRy4kEdwA8,320
|
|
10
9
|
restiny/core/__init__.py,sha256=qEyvxrQifEiazkiGoaaJwVhKgbXqVu-Y75M-2HWG73U,373
|
|
11
|
-
restiny/core/app.py,sha256=
|
|
12
|
-
restiny/core/request_area.py,sha256=
|
|
10
|
+
restiny/core/app.py,sha256=dOetJaRM4WkMJ33PVbkJ65fwIzbYthUb6qsQIHrmKXI,13621
|
|
11
|
+
restiny/core/request_area.py,sha256=ytmP99hn879E0F_K31E-ghRa7JMdfJ1bM_hdw-AggXw,11757
|
|
13
12
|
restiny/core/response_area.py,sha256=zCJz8dTDnb5APKODCF0JqQimdnpKYSDa5A81cRiKBbc,3111
|
|
14
13
|
restiny/core/url_area.py,sha256=rDcoVoLR_v8WHJJfwJUUFil1FcWnpvkPNBwzb5KaH6Y,3052
|
|
15
14
|
restiny/screens/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
16
15
|
restiny/screens/dialog.py,sha256=-AcJxMvkEUm947ch6BqcMNTRlfXr82frqamYgBqTics,2793
|
|
17
|
-
restiny/widgets/__init__.py,sha256=
|
|
16
|
+
restiny/widgets/__init__.py,sha256=ncQ1uFdIoSuQ2DYmsMxGdu3D4Sf9mMiM1VweDHJuDNQ,464
|
|
18
17
|
restiny/widgets/custom_directory_tree.py,sha256=sNTaI0DBAO56MyOy6qMZPgWXiTUQbBrJdn1GtOdxrDc,1268
|
|
19
18
|
restiny/widgets/custom_text_area.py,sha256=ykmG-6MiMhz6BqNzP8f14jUTWWKjsCOIEhgciP-01Y8,14032
|
|
20
|
-
restiny/widgets/dynamic_fields.py,sha256=
|
|
21
|
-
restiny/widgets/path_chooser.py,sha256=
|
|
22
|
-
restiny-0.
|
|
23
|
-
restiny-0.
|
|
24
|
-
restiny-0.
|
|
25
|
-
restiny-0.
|
|
26
|
-
restiny-0.
|
|
27
|
-
restiny-0.
|
|
19
|
+
restiny/widgets/dynamic_fields.py,sha256=pbBSRODP9BE2NaPBKn8egnySR_EMe5YjtTB5Rj8Wsps,15739
|
|
20
|
+
restiny/widgets/path_chooser.py,sha256=Ecp1kv33cmPHKMUEmSwM4g31qaHaaGKf3n2EN7TYh78,8718
|
|
21
|
+
restiny-0.2.0.dist-info/licenses/LICENSE,sha256=Z190MKguypkrjaCldiorEbMmBQp7ylvx09oyE4oDCTs,11361
|
|
22
|
+
restiny-0.2.0.dist-info/METADATA,sha256=4jbaAqJj6-3hpqtSL9kRhFYEZXgks8GErrLeZxfckfY,16126
|
|
23
|
+
restiny-0.2.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
24
|
+
restiny-0.2.0.dist-info/entry_points.txt,sha256=F9zW8bAPAwIihltqjzYow4ahmH_B6VkAHzQFA-8QOn4,50
|
|
25
|
+
restiny-0.2.0.dist-info/top_level.txt,sha256=1MQ_Q-fV1Dwbu4zU3g1Eg-CfRgC412X-mvMIrEdrlbk,8
|
|
26
|
+
restiny-0.2.0.dist-info/RECORD,,
|
|
Binary file
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|