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 CHANGED
@@ -1 +1 @@
1
- __version__ = '0.1.1'
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
- headers['content-type'] = (
241
- mimetypes.guess_type(file.name)[0]
242
- or 'application/octet-stream'
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=[DynamicField(enabled=False, key='', value='')],
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=[DynamicField(enabled=False, key='', value='')],
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
- # ('Form (multipart)', BodyMode.FORM_MULTIPART)
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
- [DynamicField(enabled=False, key='', value='')],
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
@@ -39,6 +39,5 @@ class ContentType(StrEnum):
39
39
  JSON = 'application/json'
40
40
  YAML = 'application/x-yaml'
41
41
  XML = 'application/xml'
42
-
43
42
  FORM_URLENCODED = 'application/x-www-form-urlencoded'
44
43
  FORM_MULTIPART = 'multipart/form-data'
@@ -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 DynamicField, DynamicFields
7
+ from restiny.widgets.dynamic_fields import DynamicFields, TextDynamicField
8
8
  from restiny.widgets.path_chooser import PathChooser
9
9
 
10
10
  __all__ = [
11
- 'DynamicField',
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 Button, Input, Static, Switch
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
- Enableable and removable field
11
- """
23
+ @abstractmethod
24
+ def compose(self) -> ComposeResult: ...
12
25
 
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
- """
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, control: 'DynamicField') -> None:
63
+ def __init__(self, field: 'DynamicField') -> None:
27
64
  super().__init__()
28
- self._control = control
65
+ self.field = field
29
66
 
67
+ @property
30
68
  def control(self) -> 'DynamicField':
31
- return self._control
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, control: 'DynamicField') -> None:
76
+ def __init__(self, field: 'DynamicField') -> None:
39
77
  super().__init__()
40
- self._control = control
78
+ self.field = field
41
79
 
42
80
  @property
43
81
  def control(self) -> 'DynamicField':
44
- return self._control
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, control: 'DynamicField') -> None:
89
+ def __init__(self, field: 'DynamicField') -> None:
52
90
  super().__init__()
53
- self._control = control
91
+ self.field = field
54
92
 
55
93
  @property
56
94
  def control(self) -> 'DynamicField':
57
- return self._control
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, control: 'DynamicField') -> None:
102
+ def __init__(self, field: 'DynamicField') -> None:
65
103
  super().__init__()
66
- self._control = control
104
+ self.field = field
67
105
 
68
106
  @property
69
107
  def control(self) -> 'DynamicField':
70
- return self._control
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, control: 'DynamicField') -> None:
117
+ def __init__(self, field: 'DynamicField') -> None:
80
118
  super().__init__()
81
- self._control = control
119
+ self.field = field
82
120
 
83
121
  @property
84
122
  def control(self) -> 'DynamicField':
85
- return self._control
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._initial_enabled = enabled
94
- self._initial_key = key
95
- self._initial_value = value
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._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
- )
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('#input-key')
108
- self.value_input: Input = self.query_one('#input-value')
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(control=self))
196
+ self.post_message(self.Enabled(field=self))
149
197
  elif message.value is False:
150
- self.post_message(message=self.Disabled(control=self))
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(control=self))
205
+ self.post_message(message=self.Empty(field=self))
158
206
  elif self.is_filled:
159
- self.post_message(message=self.Filled(control=self))
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(control=self))
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, control: 'DynamicFields', field: DynamicField
414
+ self, fields: 'DynamicFields', field: DynamicField
178
415
  ) -> None:
179
416
  super().__init__()
180
- self._control = control
417
+ self.fields = fields
181
418
  self.field = field
182
419
 
183
420
  @property
184
421
  def control(self) -> 'DynamicFields':
185
- return self._control
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, control: 'DynamicFields', field: DynamicField
430
+ self, fields: 'DynamicFields', field: DynamicField
194
431
  ) -> None:
195
432
  super().__init__()
196
- self._control = control
433
+ self.fields = fields
197
434
  self.field = field
198
435
 
199
436
  @property
200
437
  def control(self) -> 'DynamicFields':
201
- return self._control
438
+ return self.fields
202
439
 
203
- def __init__(self, fields: list[DynamicField], *args, **kwargs) -> None:
440
+ def __init__(
441
+ self,
442
+ fields: list[DynamicField],
443
+ *args,
444
+ **kwargs,
445
+ ) -> None:
204
446
  super().__init__(*args, **kwargs)
205
- self._initial_fields = fields
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: VerticalScroll = self.query_one(VerticalScroll)
453
+ self.fields_container = self.query_one(VerticalScroll)
212
454
 
213
455
  # Set initial_fields
214
- for field in self._initial_fields:
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.query(DynamicField) if field.is_empty]
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.query(DynamicField) if field.is_filled]
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(control=self, field=message.control)
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
- await self.add_field(
251
- field=DynamicField(enabled=False, key='', value='')
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(control=self, field=message.control)
508
+ message=self.FieldFilled(fields=self, field=message.control)
256
509
  )
257
510
 
258
511
  @on(DynamicField.RemoveRequested)
@@ -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
- width: auto;
189
+ layout: grid;
190
+ grid-size: 2 1;
191
+ grid-columns: 1fr auto;
189
192
  }
190
193
 
191
- Input {
192
- width: 1fr;
194
+ PathChooser > Input {
195
+ margin-right: 0;
196
+ border-right: none;
193
197
  }
194
198
 
195
- Button {
196
- width: auto;
199
+ PathChooser > Button {
200
+ margin-left: 0;
201
+ border-left: none;
197
202
  }
198
203
  """
199
204
 
200
- path: Reactive[Path | None] = Reactive(None, init=True)
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__(self, path: Path | None) -> None:
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, path_type: Literal['file', 'directory'], *args, **kwargs
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
- with Horizontal():
227
- yield Input(placeholder='--empty--', disabled=True)
228
- yield Button(f' Choose {self.path_type} ')
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.1.1
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.8
231
+ Requires-Python: >=3.10
233
232
  Description-Content-Type: text/markdown
234
233
  License-File: LICENSE
235
- Requires-Dist: textual<6.3,>=6.2
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
  ![OS support](https://img.shields.io/badge/OS-macOS%20Linux%20Windows-red)
242
- ![Python versions](https://img.shields.io/badge/Python-3.8%20|%203.9%20|%203.10%20|%203.11%20|%203.12%20|%203.13-blue)
241
+ ![Python versions](https://img.shields.io/badge/Python-3.10%20|%203.11%20|%203.12%20|%203.13%20|%203.14-blue)
243
242
 
244
243
 
245
- - [Restiny](#restiny)
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
- # Restiny
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
- <img width="1905" alt="image" src="https://github.com/user-attachments/assets/e6f0c03a-e98e-40cd-af1d-38489d650fb1" />
257
+ ![image](https://github.com/user-attachments/assets/e6f0c03a-e98e-40cd-af1d-38489d650fb1)
259
258
 
260
259
  ## How to install
261
260
 
@@ -1,27 +1,26 @@
1
- restiny/__about__.py,sha256=ls1camlIoMxEZz9gSkZ1OJo-MXqHWwKPtdPbZJmwp7E,22
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=qj8Vsa26XmVMtqjmmjmBvDTOJUE289ZaY51DjFBGNkY,874
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=ks10wIDF4DS537eLxU0vx9izCLym5zJJOSnRYu0l4LQ,13547
12
- restiny/core/request_area.py,sha256=ppUFKsGDtSmFL8s_p6sJrOhGnPl6bvuBQ-wlt9gQ2iQ,11356
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=hoJKf9MCkd_OG1A4GHZxStmGHgkr2_JHHSJA-WOPeBE,456
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=dSpaBvKM8afnauT7VpT5jNb3h1e8G1qkAQuwy_zRm7M,8551
21
- restiny/widgets/path_chooser.py,sha256=_CHBE6075R3qXoDXFiEl3FMT_jiXolAI0lcfeh2Cx4I,8068
22
- restiny-0.1.1.dist-info/licenses/LICENSE,sha256=Z190MKguypkrjaCldiorEbMmBQp7ylvx09oyE4oDCTs,11361
23
- restiny-0.1.1.dist-info/METADATA,sha256=RtDg7hTC5jrAa2HXbWCxlKcV1R5d2xV_db8AQ4U6X0E,16212
24
- restiny-0.1.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
25
- restiny-0.1.1.dist-info/entry_points.txt,sha256=F9zW8bAPAwIihltqjzYow4ahmH_B6VkAHzQFA-8QOn4,50
26
- restiny-0.1.1.dist-info/top_level.txt,sha256=1MQ_Q-fV1Dwbu4zU3g1Eg-CfRgC412X-mvMIrEdrlbk,8
27
- restiny-0.1.1.dist-info/RECORD,,
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,,