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.

Files changed (36) hide show
  1. {restiny-0.1.2 → restiny-0.2.0}/PKG-INFO +5 -4
  2. {restiny-0.1.2 → restiny-0.2.0}/README.md +2 -2
  3. {restiny-0.1.2 → restiny-0.2.0}/pyproject.toml +4 -3
  4. restiny-0.2.0/restiny/__about__.py +1 -0
  5. {restiny-0.1.2 → restiny-0.2.0}/restiny/core/app.py +5 -4
  6. {restiny-0.1.2 → restiny-0.2.0}/restiny/core/request_area.py +36 -33
  7. {restiny-0.1.2 → restiny-0.2.0}/restiny/enums.py +0 -1
  8. {restiny-0.1.2 → restiny-0.2.0}/restiny/widgets/__init__.py +2 -2
  9. restiny-0.2.0/restiny/widgets/dynamic_fields.py +540 -0
  10. {restiny-0.1.2 → restiny-0.2.0}/restiny/widgets/path_chooser.py +36 -12
  11. {restiny-0.1.2 → restiny-0.2.0}/restiny.egg-info/PKG-INFO +5 -4
  12. {restiny-0.1.2 → restiny-0.2.0}/restiny.egg-info/SOURCES.txt +0 -3
  13. {restiny-0.1.2 → restiny-0.2.0}/restiny.egg-info/requires.txt +1 -1
  14. restiny-0.1.2/restiny/__about__.py +0 -1
  15. restiny-0.1.2/restiny/assets/__pycache__/__init__.cpython-310.pyc +0 -0
  16. restiny-0.1.2/restiny/assets/__pycache__/__init__.cpython-313.pyc +0 -0
  17. restiny-0.1.2/restiny/assets/__pycache__/__init__.cpython-314.pyc +0 -0
  18. restiny-0.1.2/restiny/widgets/dynamic_fields.py +0 -287
  19. {restiny-0.1.2 → restiny-0.2.0}/LICENSE +0 -0
  20. {restiny-0.1.2 → restiny-0.2.0}/restiny/__init__.py +0 -0
  21. {restiny-0.1.2 → restiny-0.2.0}/restiny/__main__.py +0 -0
  22. {restiny-0.1.2 → restiny-0.2.0}/restiny/assets/__init__.py +0 -0
  23. {restiny-0.1.2 → restiny-0.2.0}/restiny/assets/style.tcss +0 -0
  24. {restiny-0.1.2 → restiny-0.2.0}/restiny/consts.py +0 -0
  25. {restiny-0.1.2 → restiny-0.2.0}/restiny/core/__init__.py +0 -0
  26. {restiny-0.1.2 → restiny-0.2.0}/restiny/core/response_area.py +0 -0
  27. {restiny-0.1.2 → restiny-0.2.0}/restiny/core/url_area.py +0 -0
  28. {restiny-0.1.2 → restiny-0.2.0}/restiny/screens/__init__.py +0 -0
  29. {restiny-0.1.2 → restiny-0.2.0}/restiny/screens/dialog.py +0 -0
  30. {restiny-0.1.2 → restiny-0.2.0}/restiny/utils.py +0 -0
  31. {restiny-0.1.2 → restiny-0.2.0}/restiny/widgets/custom_directory_tree.py +0 -0
  32. {restiny-0.1.2 → restiny-0.2.0}/restiny/widgets/custom_text_area.py +0 -0
  33. {restiny-0.1.2 → restiny-0.2.0}/restiny.egg-info/dependency_links.txt +0 -0
  34. {restiny-0.1.2 → restiny-0.2.0}/restiny.egg-info/entry_points.txt +0 -0
  35. {restiny-0.1.2 → restiny-0.2.0}/restiny.egg-info/top_level.txt +0 -0
  36. {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.1.2
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.3,>=6.2
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
  ![OS support](https://img.shields.io/badge/OS-macOS%20Linux%20Windows-red)
240
- ![Python versions](https://img.shields.io/badge/Python-3.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)
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
- <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)
257
258
 
258
259
  ## How to install
259
260
 
@@ -1,5 +1,5 @@
1
1
  ![OS support](https://img.shields.io/badge/OS-macOS%20Linux%20Windows-red)
2
- ![Python versions](https://img.shields.io/badge/Python-3.10%20|%203.11%20|%203.12%20|%203.13-blue)
2
+ ![Python versions](https://img.shields.io/badge/Python-3.10%20|%203.11%20|%203.12%20|%203.13%20|%203.14-blue)
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
- <img width="1905" alt="image" src="https://github.com/user-attachments/assets/e6f0c03a-e98e-40cd-af1d-38489d650fb1" />
18
+ ![image](https://github.com/user-attachments/assets/e6f0c03a-e98e-40cd-af1d-38489d650fb1)
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.2,<6.3",
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 = "py313"
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
- 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()
@@ -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',
@@ -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
- 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.2
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.3,>=6.2
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
  ![OS support](https://img.shields.io/badge/OS-macOS%20Linux%20Windows-red)
240
- ![Python versions](https://img.shields.io/badge/Python-3.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)
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
- <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)
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,4 +1,4 @@
1
- textual<6.3,>=6.2
1
+ textual<6.4,>=6.3
2
2
  textual[syntax]
3
3
  httpx<0.29,>=0.28
4
4
  pyperclip<1.10,>=1.9
@@ -1 +0,0 @@
1
- __version__ = '0.1.2'
@@ -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