pyhttp-app 2.0.1.post1__tar.gz → 2.0.3__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.
- {pyhttp_app-2.0.1.post1/pyhttp_app.egg-info → pyhttp_app-2.0.3}/PKG-INFO +45 -11
- {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/README.md +44 -10
- {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp/__init__.py +1 -1
- {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp/__main__.py +5 -1
- {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp/app.py +200 -34
- {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp/cli.py +21 -9
- {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp/db.py +62 -18
- pyhttp_app-2.0.3/pyhttp/docs.py +174 -0
- {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp/mcp.py +9 -5
- pyhttp_app-2.0.3/pyhttp/projects.py +154 -0
- {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp/runner.py +3 -0
- {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp/static/app.js +193 -39
- {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp/static/features.js +34 -16
- {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp/static/index.html +11 -10
- {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp/static/style.css +15 -1
- pyhttp_app-2.0.3/pyhttp/tools.py +208 -0
- {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp/updater.py +1 -1
- {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3/pyhttp_app.egg-info}/PKG-INFO +45 -11
- {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp_app.egg-info/SOURCES.txt +3 -0
- {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/tests/test_backend.py +169 -0
- {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/tests/test_ui.py +116 -5
- {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/LICENSE.md +0 -0
- {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp/assertions.py +0 -0
- {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp/console.py +0 -0
- {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp/curl.py +0 -0
- {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp/jsengine.py +0 -0
- {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp/jsonc.py +0 -0
- {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp/mock.py +0 -0
- {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp/monitors.py +0 -0
- {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp/oauth2.py +0 -0
- {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp/openapi.py +0 -0
- {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp/pm.js +0 -0
- {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp/postman.py +0 -0
- {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp/scripts.py +0 -0
- {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp/static/codegen.js +0 -0
- {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp/static/editor.js +0 -0
- {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp/static/features.css +0 -0
- {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp/static/logo.svg +0 -0
- {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp/static/vendor/codemirror.js +0 -0
- {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp/static/vendor/fonts/Inter-OFL.txt +0 -0
- {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp/static/vendor/fonts/inter-latin-ext-wght-normal.woff2 +0 -0
- {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp/static/vendor/fonts/inter-latin-wght-normal.woff2 +0 -0
- {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp/static/viewer.js +0 -0
- {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp/templating.py +0 -0
- {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp_app.egg-info/dependency_links.txt +0 -0
- {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp_app.egg-info/entry_points.txt +0 -0
- {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp_app.egg-info/requires.txt +0 -0
- {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp_app.egg-info/top_level.txt +0 -0
- {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyproject.toml +0 -0
- {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/setup.cfg +0 -0
- {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/tests/test_integrations.py +0 -0
- {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/tests/test_mcp.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pyhttp-app
|
|
3
|
-
Version: 2.0.
|
|
3
|
+
Version: 2.0.3
|
|
4
4
|
Summary: Local API client and alternative to Postman: collection runner, Postman-compatible scripts, mock servers and an MCP server
|
|
5
5
|
Author-email: pyhttp Dev Team <dev@pyhttp.com>
|
|
6
6
|
License: # pyhttp Free License 1.1
|
|
@@ -171,7 +171,10 @@ only on `127.0.0.1`.
|
|
|
171
171
|
- **Named bodies**: a request can hold many bodies, each with its own name
|
|
172
172
|
(for example `valid user`, `missing email`). Search bodies by name or content,
|
|
173
173
|
and tick the ones to send. **Send** hits the request once per checked body and
|
|
174
|
-
saves every output to a run folder, with the body name in the file names.
|
|
174
|
+
saves every output to a run folder, with the body name in the file names. The
|
|
175
|
+
➤ next to a body sends just that body, without touching the checkboxes
|
|
176
|
+
(Ctrl/Cmd+Shift+Enter does the same for the body in the editor); its ⋯ menu
|
|
177
|
+
duplicates, reorders or deletes a body without opening it.
|
|
175
178
|
- **Search** across name, description, URL, tags and body names; filter by tag chips.
|
|
176
179
|
- **Monitors** (topbar → Monitors): picked requests are sent again every N
|
|
177
180
|
minutes while the app is open. A check fails when a request errors, answers
|
|
@@ -283,6 +286,28 @@ only on `127.0.0.1`.
|
|
|
283
286
|
- No Node, no Electron, no build step to run it. Python 3.10+ only (the editor is a
|
|
284
287
|
prebuilt CodeMirror bundle checked into the repo).
|
|
285
288
|
|
|
289
|
+
## Projects
|
|
290
|
+
|
|
291
|
+
A project is a separate workspace: its own requests, folders, environments, variables,
|
|
292
|
+
history, cookies, run outputs, mocks and monitors. Click the project name in the top bar to
|
|
293
|
+
switch, create, rename or delete one. Everything you had before projects existed is the
|
|
294
|
+
*Default* project and stays where it was; each new project lives in its own folder under
|
|
295
|
+
the data directory (`projects/<id>/pyhttp.db` and `projects/<id>/outputs`). App settings
|
|
296
|
+
(theme, proxy, fonts…) are copied into a new project so it does not start bare. The CLI
|
|
297
|
+
runner uses the active project, or `pyhttp run --project "Shop API" …` for another one;
|
|
298
|
+
`pyhttp mcp --project …` serves one project to an MCP client.
|
|
299
|
+
|
|
300
|
+
From a terminal: `pyhttp projects` lists them, `pyhttp projects create NAME [--use]`,
|
|
301
|
+
`use NAME`, `rename OLD NEW` and `delete NAME` manage them. `pyhttp doctor` checks every
|
|
302
|
+
project's database (integrity, row counts), reports run output sizes and orphan folders,
|
|
303
|
+
and `pyhttp doctor --clean-outputs 30` deletes run folders older than 30 days.
|
|
304
|
+
|
|
305
|
+
**Export ▾ → pyhttp JSON** writes the whole project (collection, environments, variables,
|
|
306
|
+
mocks, monitors) to one file that imports into another project or machine; monitors and
|
|
307
|
+
"send this request first" links are kept. **Export ▾ → Documentation** writes the
|
|
308
|
+
collection as Markdown: folders, requests, parameters, headers (credential-looking values
|
|
309
|
+
masked), auth, example bodies and tests.
|
|
310
|
+
|
|
286
311
|
## Mock servers
|
|
287
312
|
|
|
288
313
|
An API you need does not exist yet, or you want it to fail on demand. Open
|
|
@@ -338,8 +363,11 @@ pyhttp
|
|
|
338
363
|
```
|
|
339
364
|
|
|
340
365
|
On Linux, install the GTK webview bindings first (see *Linux* below) or use
|
|
341
|
-
`pyhttp --browser`. `brew install pyhttp2/pyhttp/pyhttp
|
|
342
|
-
same package through Homebrew (macOS and Linux, Intel and ARM
|
|
366
|
+
`pyhttp --browser`. `brew install pyhttp2/pyhttp/pyhttp` installs the
|
|
367
|
+
same package through Homebrew (macOS and Linux, Intel and ARM; the tap is
|
|
368
|
+
[pyhttp2/homebrew-pyhttp](https://github.com/pyhttp2/homebrew-pyhttp)) — or tap
|
|
369
|
+
once with `brew tap pyhttp2/pyhttp`, after which plain `brew install pyhttp`,
|
|
370
|
+
`brew upgrade pyhttp` and `brew install --cask pyhttp` all work.
|
|
343
371
|
|
|
344
372
|
**Windows**: double-click `run.bat`. The window opens without a console.
|
|
345
373
|
WebView2 ships with Windows 10/11.
|
|
@@ -446,7 +474,7 @@ Packaged builds bundle Python and every dependency, so users just double-click.
|
|
|
446
474
|
|---|---|---|
|
|
447
475
|
| Windows 10/11 | `pyhttp-<version>-windows-x64-setup.exe`: installer (per user, no admin rights, into `%LOCALAPPDATA%\Programs\pyhttp`, Start menu entry, optional desktop icon, uninstaller in *Apps*) | `%APPDATA%\pyhttp` |
|
|
448
476
|
| Windows 10/11 | `pyhttp-<version>-windows-x64.exe`: portable, one file, no install | `%APPDATA%\pyhttp` |
|
|
449
|
-
| macOS 11+ | `brew
|
|
477
|
+
| macOS 11+ | `brew install --cask pyhttp2/pyhttp/pyhttp`, or `pyhttp-<version>-macos-arm64.dmg` (drag pyhttp to Applications) | `~/Library/Application Support/pyhttp` |
|
|
450
478
|
| Ubuntu 22.04+, Debian 12+ | `sudo apt install ./pyhttp-<version>-linux-amd64.deb`: installs to `/usr/lib/pyhttp`, adds `pyhttp` to the PATH and an app-menu entry; uses the distro's GTK and WebKitGTK (pulled in as dependencies) | `~/.local/share/pyhttp` |
|
|
451
479
|
|
|
452
480
|
Running from source keeps using `./data`. In the packaged app, output that would
|
|
@@ -507,10 +535,14 @@ python packaging/bump.py 0.1.2
|
|
|
507
535
|
```
|
|
508
536
|
|
|
509
537
|
That sets `__version__`, commits, creates the tag `v0.1.2` and pushes both. The
|
|
510
|
-
tag run publishes the installer, the portable `.exe`, the `.dmg`, `SHA256SUMS.txt
|
|
511
|
-
the license files
|
|
512
|
-
|
|
513
|
-
|
|
538
|
+
tag run publishes the installer, the portable `.exe`, the `.dmg`, `SHA256SUMS.txt`
|
|
539
|
+
and the license files to this repository's Releases page, uploads `pyhttp-app` to
|
|
540
|
+
PyPI (secret `PYPI_API_TOKEN`), updates the Homebrew tap
|
|
541
|
+
([homebrew-pyhttp](https://github.com/pyhttp2/homebrew-pyhttp): cask + formula)
|
|
542
|
+
and mirrors everything to
|
|
543
|
+
[pyhttp-releases](https://github.com/pyhttp2/pyhttp-releases) so installs from
|
|
544
|
+
before the move keep updating (the mirror and taps need the `RELEASE_TOKEN`
|
|
545
|
+
secret, a fine-grained PAT with Contents: write on those two repositories). The Windows runner installs Inno Setup with Chocolatey when
|
|
514
546
|
it is not already there. The in-app updater refuses a release without `SHA256SUMS.txt`,
|
|
515
547
|
so keep that step in the workflow. A tag whose version differs from `__version__` is refused,
|
|
516
548
|
because the release name, file names and the in-app update check all come from
|
|
@@ -572,8 +604,10 @@ is not allowed, and the name and logo stay with the project. The source code is
|
|
|
572
604
|
published on PyPI as [`pyhttp-app`](https://pypi.org/project/pyhttp-app/)
|
|
573
605
|
so it can be inspected and installed with pip; you may modify your own copy but
|
|
574
606
|
not distribute the changes. The packaged installer, `.exe`, `.dmg` and `.deb`
|
|
575
|
-
builds are published
|
|
576
|
-
[
|
|
607
|
+
builds are published on this repository's
|
|
608
|
+
[Releases](https://github.com/pyhttp2/pyhttp/releases) page and mirrored to
|
|
609
|
+
[pyhttp-releases](https://github.com/pyhttp2/pyhttp-releases), the old home,
|
|
610
|
+
so installs from before the move keep updating.
|
|
577
611
|
Bundled open-source components are listed in
|
|
578
612
|
[THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md).
|
|
579
613
|
|
|
@@ -22,7 +22,10 @@ only on `127.0.0.1`.
|
|
|
22
22
|
- **Named bodies**: a request can hold many bodies, each with its own name
|
|
23
23
|
(for example `valid user`, `missing email`). Search bodies by name or content,
|
|
24
24
|
and tick the ones to send. **Send** hits the request once per checked body and
|
|
25
|
-
saves every output to a run folder, with the body name in the file names.
|
|
25
|
+
saves every output to a run folder, with the body name in the file names. The
|
|
26
|
+
➤ next to a body sends just that body, without touching the checkboxes
|
|
27
|
+
(Ctrl/Cmd+Shift+Enter does the same for the body in the editor); its ⋯ menu
|
|
28
|
+
duplicates, reorders or deletes a body without opening it.
|
|
26
29
|
- **Search** across name, description, URL, tags and body names; filter by tag chips.
|
|
27
30
|
- **Monitors** (topbar → Monitors): picked requests are sent again every N
|
|
28
31
|
minutes while the app is open. A check fails when a request errors, answers
|
|
@@ -134,6 +137,28 @@ only on `127.0.0.1`.
|
|
|
134
137
|
- No Node, no Electron, no build step to run it. Python 3.10+ only (the editor is a
|
|
135
138
|
prebuilt CodeMirror bundle checked into the repo).
|
|
136
139
|
|
|
140
|
+
## Projects
|
|
141
|
+
|
|
142
|
+
A project is a separate workspace: its own requests, folders, environments, variables,
|
|
143
|
+
history, cookies, run outputs, mocks and monitors. Click the project name in the top bar to
|
|
144
|
+
switch, create, rename or delete one. Everything you had before projects existed is the
|
|
145
|
+
*Default* project and stays where it was; each new project lives in its own folder under
|
|
146
|
+
the data directory (`projects/<id>/pyhttp.db` and `projects/<id>/outputs`). App settings
|
|
147
|
+
(theme, proxy, fonts…) are copied into a new project so it does not start bare. The CLI
|
|
148
|
+
runner uses the active project, or `pyhttp run --project "Shop API" …` for another one;
|
|
149
|
+
`pyhttp mcp --project …` serves one project to an MCP client.
|
|
150
|
+
|
|
151
|
+
From a terminal: `pyhttp projects` lists them, `pyhttp projects create NAME [--use]`,
|
|
152
|
+
`use NAME`, `rename OLD NEW` and `delete NAME` manage them. `pyhttp doctor` checks every
|
|
153
|
+
project's database (integrity, row counts), reports run output sizes and orphan folders,
|
|
154
|
+
and `pyhttp doctor --clean-outputs 30` deletes run folders older than 30 days.
|
|
155
|
+
|
|
156
|
+
**Export ▾ → pyhttp JSON** writes the whole project (collection, environments, variables,
|
|
157
|
+
mocks, monitors) to one file that imports into another project or machine; monitors and
|
|
158
|
+
"send this request first" links are kept. **Export ▾ → Documentation** writes the
|
|
159
|
+
collection as Markdown: folders, requests, parameters, headers (credential-looking values
|
|
160
|
+
masked), auth, example bodies and tests.
|
|
161
|
+
|
|
137
162
|
## Mock servers
|
|
138
163
|
|
|
139
164
|
An API you need does not exist yet, or you want it to fail on demand. Open
|
|
@@ -189,8 +214,11 @@ pyhttp
|
|
|
189
214
|
```
|
|
190
215
|
|
|
191
216
|
On Linux, install the GTK webview bindings first (see *Linux* below) or use
|
|
192
|
-
`pyhttp --browser`. `brew install pyhttp2/pyhttp/pyhttp
|
|
193
|
-
same package through Homebrew (macOS and Linux, Intel and ARM
|
|
217
|
+
`pyhttp --browser`. `brew install pyhttp2/pyhttp/pyhttp` installs the
|
|
218
|
+
same package through Homebrew (macOS and Linux, Intel and ARM; the tap is
|
|
219
|
+
[pyhttp2/homebrew-pyhttp](https://github.com/pyhttp2/homebrew-pyhttp)) — or tap
|
|
220
|
+
once with `brew tap pyhttp2/pyhttp`, after which plain `brew install pyhttp`,
|
|
221
|
+
`brew upgrade pyhttp` and `brew install --cask pyhttp` all work.
|
|
194
222
|
|
|
195
223
|
**Windows**: double-click `run.bat`. The window opens without a console.
|
|
196
224
|
WebView2 ships with Windows 10/11.
|
|
@@ -297,7 +325,7 @@ Packaged builds bundle Python and every dependency, so users just double-click.
|
|
|
297
325
|
|---|---|---|
|
|
298
326
|
| Windows 10/11 | `pyhttp-<version>-windows-x64-setup.exe`: installer (per user, no admin rights, into `%LOCALAPPDATA%\Programs\pyhttp`, Start menu entry, optional desktop icon, uninstaller in *Apps*) | `%APPDATA%\pyhttp` |
|
|
299
327
|
| Windows 10/11 | `pyhttp-<version>-windows-x64.exe`: portable, one file, no install | `%APPDATA%\pyhttp` |
|
|
300
|
-
| macOS 11+ | `brew
|
|
328
|
+
| macOS 11+ | `brew install --cask pyhttp2/pyhttp/pyhttp`, or `pyhttp-<version>-macos-arm64.dmg` (drag pyhttp to Applications) | `~/Library/Application Support/pyhttp` |
|
|
301
329
|
| Ubuntu 22.04+, Debian 12+ | `sudo apt install ./pyhttp-<version>-linux-amd64.deb`: installs to `/usr/lib/pyhttp`, adds `pyhttp` to the PATH and an app-menu entry; uses the distro's GTK and WebKitGTK (pulled in as dependencies) | `~/.local/share/pyhttp` |
|
|
302
330
|
|
|
303
331
|
Running from source keeps using `./data`. In the packaged app, output that would
|
|
@@ -358,10 +386,14 @@ python packaging/bump.py 0.1.2
|
|
|
358
386
|
```
|
|
359
387
|
|
|
360
388
|
That sets `__version__`, commits, creates the tag `v0.1.2` and pushes both. The
|
|
361
|
-
tag run publishes the installer, the portable `.exe`, the `.dmg`, `SHA256SUMS.txt
|
|
362
|
-
the license files
|
|
363
|
-
|
|
364
|
-
|
|
389
|
+
tag run publishes the installer, the portable `.exe`, the `.dmg`, `SHA256SUMS.txt`
|
|
390
|
+
and the license files to this repository's Releases page, uploads `pyhttp-app` to
|
|
391
|
+
PyPI (secret `PYPI_API_TOKEN`), updates the Homebrew tap
|
|
392
|
+
([homebrew-pyhttp](https://github.com/pyhttp2/homebrew-pyhttp): cask + formula)
|
|
393
|
+
and mirrors everything to
|
|
394
|
+
[pyhttp-releases](https://github.com/pyhttp2/pyhttp-releases) so installs from
|
|
395
|
+
before the move keep updating (the mirror and taps need the `RELEASE_TOKEN`
|
|
396
|
+
secret, a fine-grained PAT with Contents: write on those two repositories). The Windows runner installs Inno Setup with Chocolatey when
|
|
365
397
|
it is not already there. The in-app updater refuses a release without `SHA256SUMS.txt`,
|
|
366
398
|
so keep that step in the workflow. A tag whose version differs from `__version__` is refused,
|
|
367
399
|
because the release name, file names and the in-app update check all come from
|
|
@@ -423,8 +455,10 @@ is not allowed, and the name and logo stay with the project. The source code is
|
|
|
423
455
|
published on PyPI as [`pyhttp-app`](https://pypi.org/project/pyhttp-app/)
|
|
424
456
|
so it can be inspected and installed with pip; you may modify your own copy but
|
|
425
457
|
not distribute the changes. The packaged installer, `.exe`, `.dmg` and `.deb`
|
|
426
|
-
builds are published
|
|
427
|
-
[
|
|
458
|
+
builds are published on this repository's
|
|
459
|
+
[Releases](https://github.com/pyhttp2/pyhttp/releases) page and mirrored to
|
|
460
|
+
[pyhttp-releases](https://github.com/pyhttp2/pyhttp-releases), the old home,
|
|
461
|
+
so installs from before the move keep updating.
|
|
428
462
|
Bundled open-source components are listed in
|
|
429
463
|
[THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md).
|
|
430
464
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""pyhttp - a local API client and alternative to Postman, with a collection runner."""
|
|
2
2
|
|
|
3
|
-
__version__ = "2.0.
|
|
3
|
+
__version__ = "2.0.3"
|
|
4
4
|
# sent with every request unless the user sets their own in Settings; the convention is "product/version (+url)"
|
|
5
5
|
USER_AGENT = f"pyhttp/{__version__}"
|
|
6
6
|
__license__ = "pyhttp Free License 1.0 (free for everyone; source not distributed)"
|
|
@@ -189,8 +189,12 @@ def main() -> None:
|
|
|
189
189
|
from . import mcp
|
|
190
190
|
mcp.main(sys.argv[2:], DEFAULT_DATA_DIR)
|
|
191
191
|
return
|
|
192
|
+
if len(sys.argv) > 1 and sys.argv[1] in ("projects", "doctor"):
|
|
193
|
+
from . import tools
|
|
194
|
+
fn = tools.projects_main if sys.argv[1] == "projects" else tools.doctor_main
|
|
195
|
+
sys.exit(fn(sys.argv[2:], DEFAULT_DATA_DIR))
|
|
192
196
|
parser = argparse.ArgumentParser(prog="pyhttp", description="Local API client (alternative to Postman)",
|
|
193
|
-
epilog="Headless runs for CI: pyhttp run --help")
|
|
197
|
+
epilog="Headless runs for CI: pyhttp run --help · workspaces: pyhttp projects --help · checks: pyhttp doctor")
|
|
194
198
|
mode = parser.add_mutually_exclusive_group()
|
|
195
199
|
mode.add_argument("--browser", action="store_true", help="open the UI in your web browser")
|
|
196
200
|
mode.add_argument("--server", action="store_true", help="run the API server only, no UI")
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
4
|
import asyncio
|
|
5
|
+
import contextlib
|
|
5
6
|
import csv
|
|
6
7
|
import io
|
|
7
8
|
import json
|
|
@@ -19,12 +20,12 @@ from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Streamin
|
|
|
19
20
|
from fastapi.staticfiles import StaticFiles
|
|
20
21
|
from pydantic import BaseModel, Field
|
|
21
22
|
|
|
22
|
-
from . import (USER_AGENT, __version__, console, curl, db, mock, monitors, oauth2, openapi, postman,
|
|
23
|
-
templating, updater)
|
|
23
|
+
from . import (USER_AGENT, __version__, console, curl, db, docs, mock, monitors, oauth2, openapi, postman, projects,
|
|
24
|
+
runner, templating, updater)
|
|
24
25
|
from .templating import build_context, render
|
|
25
26
|
|
|
26
27
|
STATIC_DIR = Path(__file__).parent / "static"
|
|
27
|
-
DEFAULT_RELEASE_REPO = "pyhttp2/pyhttp
|
|
28
|
+
DEFAULT_RELEASE_REPO = "pyhttp2/pyhttp" # public repo whose Releases page holds the .exe/.dmg builds
|
|
28
29
|
try: # written by packaging/build.py so packaged apps know where their releases live
|
|
29
30
|
from ._build import GITHUB_REPO as _BUILT_REPO
|
|
30
31
|
except ImportError: # pragma: no cover - source checkout
|
|
@@ -215,6 +216,8 @@ class ImportIn(BaseModel):
|
|
|
215
216
|
environments: list[EnvIn] = Field(default_factory=list)
|
|
216
217
|
requests: list[RequestIn] = Field(default_factory=list)
|
|
217
218
|
variables: dict[str, str] = Field(default_factory=dict)
|
|
219
|
+
mocks: list[dict[str, Any]] = Field(default_factory=list)
|
|
220
|
+
monitors: list[dict[str, Any]] = Field(default_factory=list)
|
|
218
221
|
replace: bool = False
|
|
219
222
|
|
|
220
223
|
|
|
@@ -317,8 +320,11 @@ def _version_tuple(v: str) -> tuple[int, ...]:
|
|
|
317
320
|
|
|
318
321
|
|
|
319
322
|
def import_collection(folders: list[dict[str, Any]], requests: list[dict[str, Any]], environments: list[dict[str, Any]],
|
|
320
|
-
variables: dict[str, str], replace: bool
|
|
321
|
-
|
|
323
|
+
variables: dict[str, str], replace: bool, mocks: list[dict[str, Any]] | None = None,
|
|
324
|
+
monitors: list[dict[str, Any]] | None = None) -> dict[str, Any]:
|
|
325
|
+
"""Create folders (parents first), requests and environments; optionally replace everything.
|
|
326
|
+
A full export also carries mocks and monitors. Requests that come with their old ``id`` are
|
|
327
|
+
remapped, so monitors and "depends on" links between requests survive the round trip."""
|
|
322
328
|
if replace:
|
|
323
329
|
for r in db.list_requests():
|
|
324
330
|
db.delete_request(r["id"])
|
|
@@ -327,6 +333,12 @@ def import_collection(folders: list[dict[str, Any]], requests: list[dict[str, An
|
|
|
327
333
|
db.delete_folder(f["id"])
|
|
328
334
|
for e in db.list_environments():
|
|
329
335
|
db.delete_environment(e["id"])
|
|
336
|
+
if mocks:
|
|
337
|
+
for m in db.list_mocks(with_routes=False):
|
|
338
|
+
db.delete_mock(m["id"])
|
|
339
|
+
if monitors:
|
|
340
|
+
for m in db.list_monitors():
|
|
341
|
+
db.delete_monitor(m["id"])
|
|
330
342
|
# folders: create parents before children, remapping ids
|
|
331
343
|
id_map: dict[str, str] = {}
|
|
332
344
|
pending = list(folders)
|
|
@@ -346,17 +358,45 @@ def import_collection(folders: list[dict[str, Any]], requests: list[dict[str, An
|
|
|
346
358
|
id_map[f["id"]] = db.create_folder(f["name"])["id"]
|
|
347
359
|
break
|
|
348
360
|
created = []
|
|
361
|
+
req_map: dict[str, str] = {} # id in the file -> id in this database
|
|
349
362
|
for r in requests:
|
|
350
363
|
data = dict(r)
|
|
351
364
|
data["folder_id"] = id_map.get(r.get("folder_id") or "")
|
|
352
|
-
|
|
365
|
+
new_r = db.create_request(data)
|
|
366
|
+
created.append(new_r)
|
|
367
|
+
if r.get("id"):
|
|
368
|
+
req_map[str(r["id"])] = new_r["id"]
|
|
369
|
+
# "send this request first" links point at ids from the file: point them at the new rows
|
|
370
|
+
for old_r, new_r in zip(requests, created):
|
|
371
|
+
dep = ((old_r.get("options") or {}).get("depends") or {}) if isinstance(old_r.get("options"), dict) else {}
|
|
372
|
+
target = req_map.get(str(dep.get("request_id") or ""))
|
|
373
|
+
if target:
|
|
374
|
+
opts = dict(new_r.get("options") or {})
|
|
375
|
+
opts["depends"] = {**dep, "request_id": target}
|
|
376
|
+
db.update_request(new_r["id"], {**new_r, "options": opts})
|
|
353
377
|
for e in environments:
|
|
354
378
|
db.create_environment(e["name"], e.get("variables") or [])
|
|
355
379
|
if variables:
|
|
356
380
|
merged = {} if replace else db.get_variables()
|
|
357
381
|
merged.update(variables)
|
|
358
382
|
db.set_variables(merged)
|
|
359
|
-
|
|
383
|
+
n_mocks = 0
|
|
384
|
+
for m in mocks or []:
|
|
385
|
+
if isinstance(m, dict):
|
|
386
|
+
db.create_mock({k: v for k, v in m.items() if k not in ("id", "created_at", "hits")})
|
|
387
|
+
n_mocks += 1
|
|
388
|
+
n_monitors = 0
|
|
389
|
+
for m in monitors or []:
|
|
390
|
+
if not isinstance(m, dict):
|
|
391
|
+
continue
|
|
392
|
+
ids = [req_map.get(str(i), "") for i in (m.get("request_ids") or [])]
|
|
393
|
+
ids = [i for i in ids if i]
|
|
394
|
+
if ids: # a monitor without requests to send is pointless
|
|
395
|
+
db.create_monitor({**{k: v for k, v in m.items() if k not in ("id", "created_at", "last_run_at", "last_status")},
|
|
396
|
+
"request_ids": ids})
|
|
397
|
+
n_monitors += 1
|
|
398
|
+
return {"imported": len(created), "folders": len(id_map), "environments": len(environments),
|
|
399
|
+
"mocks": n_mocks, "monitors": n_monitors}
|
|
360
400
|
|
|
361
401
|
|
|
362
402
|
def _own_origin(request: Request) -> str:
|
|
@@ -385,35 +425,117 @@ def _mock_route_payload(data: dict[str, Any]) -> dict[str, Any]:
|
|
|
385
425
|
return data
|
|
386
426
|
|
|
387
427
|
|
|
388
|
-
def create_app(data_dir: Path) -> FastAPI:
|
|
428
|
+
def create_app(data_dir: Path, project: str | None = None) -> FastAPI:
|
|
429
|
+
"""``project`` (an id or a name) opens that project instead of the active one, without changing
|
|
430
|
+
which one is active — the CLI uses it for ``--project``."""
|
|
389
431
|
data_dir = data_dir.resolve()
|
|
390
432
|
data_dir.mkdir(parents=True, exist_ok=True)
|
|
391
|
-
|
|
433
|
+
registry = projects.Registry(data_dir)
|
|
434
|
+
if project:
|
|
435
|
+
found = registry.find(project)
|
|
436
|
+
if found is None:
|
|
437
|
+
raise ValueError(f"No project called “{project}” (projects: {', '.join(p['name'] for p in registry.list())})")
|
|
438
|
+
project_id = found["id"]
|
|
439
|
+
else:
|
|
440
|
+
project_id = registry.active()
|
|
441
|
+
db_path, output_dir = registry.paths(project_id)
|
|
392
442
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
393
|
-
db.init(
|
|
443
|
+
db.init(db_path)
|
|
394
444
|
db.mark_interrupted_runs()
|
|
395
445
|
manager = runner.RunManager(output_dir)
|
|
396
446
|
scheduler = monitors.MonitorScheduler()
|
|
397
447
|
update_cache: dict[str, Any] = {}
|
|
398
448
|
self_updater = updater.Updater(data_dir)
|
|
449
|
+
current = {"id": project_id} # the project this app instance has open
|
|
399
450
|
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
@app.on_event("startup")
|
|
403
|
-
async def _start_monitors() -> None:
|
|
451
|
+
@contextlib.asynccontextmanager
|
|
452
|
+
async def lifespan(_app: FastAPI):
|
|
404
453
|
scheduler.start()
|
|
454
|
+
try:
|
|
455
|
+
yield
|
|
456
|
+
finally:
|
|
457
|
+
scheduler.stop()
|
|
405
458
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
459
|
+
app = FastAPI(title="pyhttp", version=__version__, docs_url="/api/docs", redoc_url=None, lifespan=lifespan)
|
|
460
|
+
|
|
461
|
+
def current_project() -> dict[str, Any]:
|
|
462
|
+
return registry.get(current["id"]) or {"id": current["id"], "name": current["id"]}
|
|
463
|
+
|
|
464
|
+
def open_project(pid: str) -> None:
|
|
465
|
+
"""Switch this app to another project's database and output folder."""
|
|
466
|
+
new_db, new_out = registry.paths(pid)
|
|
467
|
+
new_out.mkdir(parents=True, exist_ok=True)
|
|
468
|
+
db.switch(new_db)
|
|
469
|
+
db.mark_interrupted_runs()
|
|
470
|
+
manager.output_root = new_out
|
|
471
|
+
oauth2.forget() # tokens belong to the project they were fetched in
|
|
472
|
+
current["id"] = pid
|
|
409
473
|
|
|
410
474
|
# ------------------------------------------------------------------ meta
|
|
411
475
|
@app.get("/api/meta")
|
|
412
476
|
def meta() -> dict[str, Any]:
|
|
413
|
-
return {"version": __version__, "data_dir": str(data_dir), "output_dir": str(
|
|
477
|
+
return {"version": __version__, "data_dir": str(data_dir), "output_dir": str(manager.output_root),
|
|
478
|
+
"project": current_project(),
|
|
414
479
|
"github_repo": github_repo(), "frozen": bool(getattr(sys, "frozen", False)), "platform": sys.platform,
|
|
415
480
|
"install": updater.info(), "user_agent": USER_AGENT, "python": sys.executable}
|
|
416
481
|
|
|
482
|
+
# -------------------------------------------------------------- projects
|
|
483
|
+
# A project is a separate workspace (its own database and output folder, see projects.py).
|
|
484
|
+
# Switching reopens the database under this process, so the UI reloads afterwards.
|
|
485
|
+
def projects_payload() -> dict[str, Any]:
|
|
486
|
+
return {"current": current["id"], "active": registry.active(), "projects": registry.list()}
|
|
487
|
+
|
|
488
|
+
@app.get("/api/projects")
|
|
489
|
+
def list_projects() -> dict[str, Any]:
|
|
490
|
+
return projects_payload()
|
|
491
|
+
|
|
492
|
+
@app.post("/api/projects")
|
|
493
|
+
def create_project(body: dict[str, Any]) -> dict[str, Any]:
|
|
494
|
+
"""Create a project (app settings are copied in so it does not start bare) and switch to it,
|
|
495
|
+
unless ``switch`` is false."""
|
|
496
|
+
try:
|
|
497
|
+
p = registry.create(str(body.get("name") or ""))
|
|
498
|
+
except ValueError as exc:
|
|
499
|
+
raise HTTPException(400, str(exc)) from exc
|
|
500
|
+
settings = db.app_settings_rows() if body.get("copy_settings", True) else None
|
|
501
|
+
db.seed_database(registry.paths(p["id"])[0], settings)
|
|
502
|
+
if body.get("switch", True):
|
|
503
|
+
return activate_project(p["id"])
|
|
504
|
+
return {**projects_payload(), "created": p}
|
|
505
|
+
|
|
506
|
+
@app.post("/api/projects/{pid}/activate")
|
|
507
|
+
def activate_project(pid: str) -> dict[str, Any]:
|
|
508
|
+
if registry.get(pid) is None:
|
|
509
|
+
raise HTTPException(404, "Project not found")
|
|
510
|
+
if manager.any_active():
|
|
511
|
+
raise HTTPException(409, "A run is still going. Wait for it to finish or cancel it, then switch.")
|
|
512
|
+
if pid != current["id"]:
|
|
513
|
+
open_project(pid)
|
|
514
|
+
registry.set_active(pid)
|
|
515
|
+
return projects_payload()
|
|
516
|
+
|
|
517
|
+
@app.patch("/api/projects/{pid}")
|
|
518
|
+
def rename_project(pid: str, body: dict[str, Any]) -> dict[str, Any]:
|
|
519
|
+
try:
|
|
520
|
+
registry.rename(pid, str(body.get("name") or ""))
|
|
521
|
+
except KeyError as exc:
|
|
522
|
+
raise HTTPException(404, "Project not found") from exc
|
|
523
|
+
except ValueError as exc:
|
|
524
|
+
raise HTTPException(400, str(exc)) from exc
|
|
525
|
+
return projects_payload()
|
|
526
|
+
|
|
527
|
+
@app.delete("/api/projects/{pid}")
|
|
528
|
+
def delete_project(pid: str) -> dict[str, Any]:
|
|
529
|
+
if pid == current["id"]:
|
|
530
|
+
raise HTTPException(409, "Switch to another project before deleting this one")
|
|
531
|
+
try:
|
|
532
|
+
registry.delete(pid)
|
|
533
|
+
except KeyError as exc:
|
|
534
|
+
raise HTTPException(404, "Project not found") from exc
|
|
535
|
+
except ValueError as exc:
|
|
536
|
+
raise HTTPException(409, str(exc)) from exc
|
|
537
|
+
return projects_payload()
|
|
538
|
+
|
|
417
539
|
@app.get("/api/update-check")
|
|
418
540
|
async def update_check(force: bool = Query(False)) -> dict[str, Any]:
|
|
419
541
|
"""Latest GitHub release, compared with the running version (cached for an hour)."""
|
|
@@ -459,8 +581,16 @@ def create_app(data_dir: Path) -> FastAPI:
|
|
|
459
581
|
|
|
460
582
|
# -------------------------------------------------------------- requests
|
|
461
583
|
@app.get("/api/requests")
|
|
462
|
-
def list_requests(q: str = Query(""), tag: str = Query("")) -> list[dict[str, Any]]:
|
|
463
|
-
|
|
584
|
+
def list_requests(q: str = Query(""), tag: str = Query(""), fields: str = Query("")) -> list[dict[str, Any]]:
|
|
585
|
+
"""``fields=list`` returns what the sidebar needs (no body text, scripts, headers…): a few
|
|
586
|
+
hundred requests with big bodies would otherwise be shipped on every autosave."""
|
|
587
|
+
items = db.list_requests(q, tag)
|
|
588
|
+
if fields != "list":
|
|
589
|
+
return items
|
|
590
|
+
return [{"id": r["id"], "name": r["name"], "method": r["method"], "url": r["url"], "tags": r["tags"],
|
|
591
|
+
"folder_id": r.get("folder_id"), "position": r.get("position", 0), "body_type": r["body_type"],
|
|
592
|
+
"bodies": [{"id": b["id"], "name": b["name"], "enabled": b["enabled"]} for b in r["bodies"]],
|
|
593
|
+
"updated_at": r.get("updated_at")} for r in items]
|
|
464
594
|
|
|
465
595
|
@app.post("/api/requests", status_code=201)
|
|
466
596
|
def create_request(body: RequestIn) -> dict[str, Any]:
|
|
@@ -836,8 +966,10 @@ def create_app(data_dir: Path) -> FastAPI:
|
|
|
836
966
|
|
|
837
967
|
# --------------------------------------------------------------- history
|
|
838
968
|
@app.get("/api/history")
|
|
839
|
-
def history(q: str = Query(""), limit: int = Query(300), status: str = Query("")
|
|
840
|
-
|
|
969
|
+
def history(q: str = Query(""), limit: int = Query(300), status: str = Query(""),
|
|
970
|
+
before: float | None = Query(None)) -> list[dict[str, Any]]:
|
|
971
|
+
"""Newest first. ``before`` (a timestamp from the last row shown) pages further back."""
|
|
972
|
+
return db.list_history(limit=min(max(limit, 1), 1000), q=q, status=status, before=before)
|
|
841
973
|
|
|
842
974
|
@app.post("/api/history/{hid}/resend")
|
|
843
975
|
async def history_resend(hid: str) -> dict[str, Any]:
|
|
@@ -938,8 +1070,9 @@ def create_app(data_dir: Path) -> FastAPI:
|
|
|
938
1070
|
return sample_data_file(requests, "json" if format == "json" else "csv")
|
|
939
1071
|
|
|
940
1072
|
@app.get("/api/runs")
|
|
941
|
-
def list_runs() -> list[dict[str, Any]]:
|
|
942
|
-
|
|
1073
|
+
def list_runs(limit: int = Query(200), before: float | None = Query(None)) -> list[dict[str, Any]]:
|
|
1074
|
+
"""Newest first; ``before`` (started_at of the last row shown) pages further back."""
|
|
1075
|
+
return db.list_runs(limit=min(max(limit, 1), 1000), before=before)
|
|
943
1076
|
|
|
944
1077
|
@app.get("/api/runs/{run_id}")
|
|
945
1078
|
def get_run(run_id: str) -> dict[str, Any]:
|
|
@@ -1183,17 +1316,41 @@ def create_app(data_dir: Path) -> FastAPI:
|
|
|
1183
1316
|
|
|
1184
1317
|
# --------------------------------------------------------- import/export
|
|
1185
1318
|
def _export_payload() -> dict[str, Any]:
|
|
1319
|
+
"""Everything in the project: collection, environments, variables, mocks and monitors.
|
|
1320
|
+
Request ids are kept so monitors and "depends on" links can be remapped on import."""
|
|
1186
1321
|
reqs = []
|
|
1187
1322
|
for r in db.list_requests():
|
|
1188
|
-
reqs.append({k: v for k, v in r.items() if k not in ("
|
|
1323
|
+
reqs.append({k: v for k, v in r.items() if k not in ("created_at", "updated_at")})
|
|
1189
1324
|
envs = [{"name": e["name"], "variables": e["variables"]} for e in db.list_environments()]
|
|
1190
|
-
|
|
1191
|
-
|
|
1325
|
+
mocks = []
|
|
1326
|
+
for m in db.list_mocks():
|
|
1327
|
+
routes = [{k: v for k, v in rt.items() if k not in ("id", "server_id", "hits", "position")} for rt in m.get("routes") or []]
|
|
1328
|
+
mocks.append({**{k: v for k, v in m.items() if k not in ("id", "created_at", "hits", "routes")}, "routes": routes})
|
|
1329
|
+
mons = [{k: v for k, v in m.items() if k not in ("id", "created_at", "last_run_at", "last_status")} for m in db.list_monitors()]
|
|
1330
|
+
return {"pyhttp_version": __version__, "project": current_project()["name"], "folders": db.list_folders(),
|
|
1331
|
+
"environments": envs, "requests": reqs, "variables": db.get_variables(), "mocks": mocks, "monitors": mons}
|
|
1192
1332
|
|
|
1193
1333
|
@app.get("/api/export")
|
|
1194
1334
|
def export_all() -> dict[str, Any]:
|
|
1195
1335
|
return _export_payload()
|
|
1196
1336
|
|
|
1337
|
+
@app.get("/api/export/docs")
|
|
1338
|
+
def export_docs(folder_id: str = Query(""), name: str = Query("")) -> dict[str, Any]:
|
|
1339
|
+
"""The collection (or one folder) as Markdown documentation."""
|
|
1340
|
+
folders = db.list_folders()
|
|
1341
|
+
requests = db.list_requests()
|
|
1342
|
+
title = name or current_project()["name"] or "API"
|
|
1343
|
+
if folder_id:
|
|
1344
|
+
keep = db.folder_descendants(folder_id)
|
|
1345
|
+
root = db.get_folder(folder_id)
|
|
1346
|
+
title = name or (root["name"] if root else title)
|
|
1347
|
+
folders = [{**f, "parent_id": None} if f.get("parent_id") == folder_id else f
|
|
1348
|
+
for f in folders if f["id"] in keep and f["id"] != folder_id]
|
|
1349
|
+
requests = [{**r, "folder_id": None} if r.get("folder_id") == folder_id else r
|
|
1350
|
+
for r in requests if r.get("folder_id") in keep]
|
|
1351
|
+
md = docs.to_markdown(title, folders, requests, db.get_variables(), db.list_environments())
|
|
1352
|
+
return {"filename": re.sub(r"[^\w.-]+", "_", title).strip("_") + ".md", "markdown": md}
|
|
1353
|
+
|
|
1197
1354
|
@app.get("/api/export/postman")
|
|
1198
1355
|
def export_postman(folder_id: str = Query(""), name: str = Query("")) -> dict[str, Any]:
|
|
1199
1356
|
"""A Postman v2.1 collection (optionally just one folder) plus environments."""
|
|
@@ -1215,7 +1372,8 @@ def create_app(data_dir: Path) -> FastAPI:
|
|
|
1215
1372
|
@app.post("/api/import")
|
|
1216
1373
|
def import_all(body: ImportIn) -> dict[str, Any]:
|
|
1217
1374
|
return import_collection([f.model_dump() for f in body.folders], [r.model_dump() for r in body.requests],
|
|
1218
|
-
|
|
1375
|
+
[e.model_dump() for e in body.environments], body.variables, body.replace,
|
|
1376
|
+
mocks=body.mocks, monitors=body.monitors)
|
|
1219
1377
|
|
|
1220
1378
|
@app.post("/api/import/detect")
|
|
1221
1379
|
def import_detect(body: dict[str, Any]) -> dict[str, Any]:
|
|
@@ -1238,7 +1396,8 @@ def create_app(data_dir: Path) -> FastAPI:
|
|
|
1238
1396
|
"folders": len(conv["folders"]), "entries": len(data["log"]["entries"])}
|
|
1239
1397
|
if isinstance(data, dict) and ("requests" in data or "folders" in data):
|
|
1240
1398
|
return {"kind": "pyhttp", "requests": len(data.get("requests") or []), "folders": len(data.get("folders") or []),
|
|
1241
|
-
"environments": len(data.get("environments") or [])
|
|
1399
|
+
"environments": len(data.get("environments") or []), "mocks": len(data.get("mocks") or []),
|
|
1400
|
+
"monitors": len(data.get("monitors") or []), "project": data.get("project") or ""}
|
|
1242
1401
|
if isinstance(data, list):
|
|
1243
1402
|
return {"kind": "pyhttp", "requests": len(data), "folders": 0, "environments": 0}
|
|
1244
1403
|
return {"kind": "unknown"}
|
|
@@ -1318,13 +1477,20 @@ def create_app(data_dir: Path) -> FastAPI:
|
|
|
1318
1477
|
response.headers["Cache-Control"] = "no-store"
|
|
1319
1478
|
return response
|
|
1320
1479
|
|
|
1480
|
+
index_cache: dict[str, Any] = {}
|
|
1481
|
+
|
|
1321
1482
|
@app.get("/", include_in_schema=False)
|
|
1322
1483
|
def index() -> HTMLResponse:
|
|
1323
|
-
# script/style URLs get ?v=<version> so every release loads fresh files
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1484
|
+
# script/style URLs get ?v=<version> so every release loads fresh files; the rewritten page is
|
|
1485
|
+
# cached per file mtime (every reload hits this route, and the regex pass is not free)
|
|
1486
|
+
src = STATIC_DIR / "index.html"
|
|
1487
|
+
mtime = src.stat().st_mtime
|
|
1488
|
+
if index_cache.get("mtime") != mtime:
|
|
1489
|
+
html = src.read_text(encoding="utf-8")
|
|
1490
|
+
html = re.sub(r'((?:src|href)="/static/[^"?]+)"', rf'\1?v={__version__}"', html)
|
|
1491
|
+
html = html.replace("<html lang=\"en\">", f"<html lang=\"en\" data-version=\"{__version__}\">", 1)
|
|
1492
|
+
index_cache.update({"mtime": mtime, "html": html})
|
|
1493
|
+
return HTMLResponse(index_cache["html"])
|
|
1328
1494
|
|
|
1329
1495
|
@app.exception_handler(ValueError)
|
|
1330
1496
|
def value_error(_request: Any, exc: ValueError) -> JSONResponse:
|