pyhttp-app 2.0.1__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.
pyhttp/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """pyhttp - a local, Postman-like HTTP client with a collection runner."""
2
+
3
+ __version__ = "2.0.1"
4
+ # sent with every request unless the user sets their own in Settings; the convention is "product/version (+url)"
5
+ USER_AGENT = f"pyhttp/{__version__}"
6
+ __license__ = "pyhttp Free License 1.0 (free for everyone; source not distributed)"
7
+ __copyright__ = "Copyright (c) 2026 pyhttp Dev Team"
8
+ __author__ = "pyhttp Dev Team"
9
+ __tagline__ = "From the unknown to the unowned"
pyhttp/__main__.py ADDED
@@ -0,0 +1,222 @@
1
+ """Command line entry point.
2
+
3
+ python -m pyhttp # native desktop window (default)
4
+ python -m pyhttp --browser # serve the UI and open it in the browser
5
+ python -m pyhttp --server # serve only, no UI (for scripting via the API)
6
+ python -m pyhttp run [targets] # run requests headless and report (for CI); see `run --help`
7
+ python -m pyhttp mcp # MCP server on stdio, so AI tools can manage the collection
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import socket
13
+ import sys
14
+ import threading
15
+ import time
16
+ import webbrowser
17
+ from pathlib import Path
18
+
19
+ import uvicorn
20
+
21
+ from . import __version__, updater
22
+ from .app import create_app
23
+
24
+ FROZEN = bool(getattr(sys, "frozen", False)) # running from a packaged .exe / .app
25
+
26
+
27
+ def _user_data_dir() -> Path:
28
+ """Per-user, writable data folder for the packaged app."""
29
+ if sys.platform.startswith("win"):
30
+ import os
31
+ return Path(os.environ.get("APPDATA") or Path.home() / "AppData" / "Roaming") / "pyhttp"
32
+ if sys.platform == "darwin":
33
+ return Path.home() / "Library" / "Application Support" / "pyhttp"
34
+ import os
35
+ return Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share") / "pyhttp"
36
+
37
+
38
+ # From source: ./data next to the project (unchanged). Packaged: the per-user folder,
39
+ # because the app's own files are read-only (macOS .app) or temporary (onefile .exe).
40
+ DEFAULT_DATA_DIR = _user_data_dir() if FROZEN else Path(__file__).resolve().parent.parent / "data"
41
+
42
+
43
+ def _redirect_output_when_windowed(data_dir: Path) -> None:
44
+ """A windowed build has no console (stdout/stderr are None); log to a file instead."""
45
+ if sys.stdout is not None and sys.stderr is not None:
46
+ return
47
+ data_dir.mkdir(parents=True, exist_ok=True)
48
+ log = open(data_dir / "pyhttp.log", "a", encoding="utf-8", buffering=1) # noqa: SIM115
49
+ sys.stdout = sys.stdout or log
50
+ sys.stderr = sys.stderr or log
51
+
52
+
53
+ def _free_port(host: str) -> int:
54
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
55
+ s.bind((host, 0))
56
+ return s.getsockname()[1]
57
+
58
+
59
+ def _preferred_port(host: str, preferred: int) -> int:
60
+ """The usual port when it is free, any free one otherwise. The window mode used
61
+ to always take a random port; keeping it steady means mock server URLs
62
+ (http://host:<port>/mock/...) stay the same from launch to launch."""
63
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
64
+ try:
65
+ s.bind((host, preferred))
66
+ except OSError:
67
+ return _free_port(host)
68
+ return preferred
69
+
70
+
71
+ def _start_server_thread(app, host: str, port: int) -> uvicorn.Server:
72
+ server = uvicorn.Server(uvicorn.Config(app, host=host, port=port, log_level="warning"))
73
+ thread = threading.Thread(target=server.run, daemon=True)
74
+ thread.start()
75
+ deadline = time.time() + 15
76
+ while not server.started:
77
+ if not thread.is_alive() or time.time() > deadline:
78
+ raise RuntimeError(f"Could not start the local server on {host}:{port}")
79
+ time.sleep(0.05)
80
+ return server
81
+
82
+
83
+ class NativeApi:
84
+ """Methods callable from JavaScript as `window.pywebview.api.<name>(...)`."""
85
+
86
+ def __init__(self) -> None:
87
+ self._window = None # private: pywebview does not expose _attrs to JS
88
+
89
+ @staticmethod
90
+ def _types_for(filename: str) -> tuple[str, ...]:
91
+ ext = Path(filename).suffix.lower()
92
+ named = {
93
+ ".json": "JSON files (*.json)", ".html": "HTML files (*.html)", ".xml": "XML files (*.xml)",
94
+ ".txt": "Text files (*.txt)", ".csv": "CSV files (*.csv)", ".png": "PNG images (*.png)",
95
+ ".jpg": "JPEG images (*.jpg)", ".pdf": "PDF files (*.pdf)",
96
+ }
97
+ first = named.get(ext)
98
+ return (first, "All files (*.*)") if first else ("All files (*.*)",)
99
+
100
+ def save_file(self, content: str, filename: str) -> str | None:
101
+ import webview
102
+
103
+ result = self._window.create_file_dialog(
104
+ webview.FileDialog.SAVE, save_filename=filename, file_types=self._types_for(filename),
105
+ )
106
+ if not result:
107
+ return None
108
+ path = result if isinstance(result, str) else result[0]
109
+ Path(path).write_text(content, encoding="utf-8")
110
+ return path
111
+
112
+ def save_file_b64(self, content_b64: str, filename: str) -> str | None:
113
+ """Save binary content (base64 from the UI), e.g. an image response."""
114
+ import base64
115
+ import webview
116
+
117
+ result = self._window.create_file_dialog(
118
+ webview.FileDialog.SAVE, save_filename=filename, file_types=self._types_for(filename),
119
+ )
120
+ if not result:
121
+ return None
122
+ path = result if isinstance(result, str) else result[0]
123
+ Path(path).write_bytes(base64.b64decode(content_b64))
124
+ return path
125
+
126
+ def open_file(self, kind: str = "json") -> dict | None:
127
+ import webview
128
+
129
+ types = ("Variables files (*.csv;*.json)", "All files (*.*)") if kind == "data" else \
130
+ ("Collections, OpenAPI, HAR or cURL (*.json;*.yaml;*.yml;*.har;*.txt;*.sh)", "All files (*.*)")
131
+ result = self._window.create_file_dialog(webview.FileDialog.OPEN, file_types=types)
132
+ if not result:
133
+ return None
134
+ path = result if isinstance(result, str) else result[0]
135
+ return {"name": Path(path).name, "content": Path(path).read_text(encoding="utf-8")}
136
+
137
+ def pick_file(self) -> str | None:
138
+ """Choose a file and return its path (for file fields and binary bodies)."""
139
+ import webview
140
+
141
+ result = self._window.create_file_dialog(webview.FileDialog.OPEN, file_types=("All files (*.*)",))
142
+ if not result:
143
+ return None
144
+ return result if isinstance(result, str) else result[0]
145
+
146
+ def open_url(self, url: str) -> bool:
147
+ if not url.startswith(("http://", "https://")):
148
+ return False
149
+ webbrowser.open(url)
150
+ return True
151
+
152
+
153
+ def run_window(app, host: str, port: int, debug: bool) -> bool:
154
+ """Open the native window. Returns False if no native webview is available."""
155
+ try:
156
+ import webview
157
+ except ImportError:
158
+ print("pywebview is not installed; falling back to the browser.", file=sys.stderr)
159
+ return False
160
+
161
+ port = _preferred_port(host, port)
162
+ from . import db # the window background matches the saved theme (no flash)
163
+ background = "#f6f7f9" if db.get_setting("theme") == "light" else "#111418"
164
+ server = _start_server_thread(app, host, port)
165
+ print(f"pyhttp window open (API at http://{host}:{port}/api/docs)", flush=True)
166
+ api = NativeApi()
167
+ window = webview.create_window(
168
+ f"pyhttp {__version__}", f"http://{host}:{port}/", js_api=api,
169
+ width=1440, height=900, min_size=(960, 600), background_color=background,
170
+ text_select=True,
171
+ )
172
+ api._window = window
173
+ updater.quit_hook = window.destroy # the in-app updater closes the window when the new version is ready
174
+ try:
175
+ webview.start(debug=debug, private_mode=False)
176
+ except Exception as exc: # noqa: BLE001 - missing WebView2 / WebKitGTK etc.
177
+ print(f"Native window unavailable ({exc}); falling back to the browser.", file=sys.stderr)
178
+ server.should_exit = True
179
+ return False
180
+ server.should_exit = True
181
+ return True
182
+
183
+
184
+ def main() -> None:
185
+ if len(sys.argv) > 1 and sys.argv[1] == "run":
186
+ from . import cli
187
+ sys.exit(cli.main(sys.argv[2:], DEFAULT_DATA_DIR))
188
+ if len(sys.argv) > 1 and sys.argv[1] == "mcp":
189
+ from . import mcp
190
+ mcp.main(sys.argv[2:], DEFAULT_DATA_DIR)
191
+ return
192
+ parser = argparse.ArgumentParser(prog="pyhttp", description="Local Postman-like HTTP client",
193
+ epilog="Headless runs for CI: pyhttp run --help")
194
+ mode = parser.add_mutually_exclusive_group()
195
+ mode.add_argument("--browser", action="store_true", help="open the UI in your web browser")
196
+ mode.add_argument("--server", action="store_true", help="run the API server only, no UI")
197
+ parser.add_argument("--host", default="127.0.0.1", help="bind address (default: 127.0.0.1)")
198
+ parser.add_argument("--port", type=int, default=8765,
199
+ help="port to listen on (default: 8765; a free port is used when it is taken)")
200
+ parser.add_argument("--data-dir", type=Path, default=DEFAULT_DATA_DIR,
201
+ help=f"where the database and outputs live (default: {DEFAULT_DATA_DIR})")
202
+ parser.add_argument("--debug", action="store_true", help="enable devtools in the native window")
203
+ # parse_known_args: macOS may pass a "-psn_…" argument when launching an .app
204
+ args, _unknown = parser.parse_known_args()
205
+
206
+ _redirect_output_when_windowed(args.data_dir)
207
+ updater.cleanup(args.data_dir) # leftovers of a previous in-app update
208
+ app = create_app(args.data_dir)
209
+
210
+ if not args.browser and not args.server:
211
+ if run_window(app, args.host, args.port, args.debug):
212
+ return
213
+
214
+ url = f"http://{args.host}:{args.port}/"
215
+ print(f"pyhttp running at {url} (data: {args.data_dir.resolve()}) Ctrl+C to stop")
216
+ if not args.server:
217
+ threading.Timer(1.0, lambda: webbrowser.open(url)).start()
218
+ uvicorn.run(app, host=args.host, port=args.port, log_level="warning")
219
+
220
+
221
+ if __name__ == "__main__":
222
+ main()