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