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.
Files changed (52) hide show
  1. {pyhttp_app-2.0.1.post1/pyhttp_app.egg-info → pyhttp_app-2.0.3}/PKG-INFO +45 -11
  2. {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/README.md +44 -10
  3. {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp/__init__.py +1 -1
  4. {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp/__main__.py +5 -1
  5. {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp/app.py +200 -34
  6. {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp/cli.py +21 -9
  7. {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp/db.py +62 -18
  8. pyhttp_app-2.0.3/pyhttp/docs.py +174 -0
  9. {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp/mcp.py +9 -5
  10. pyhttp_app-2.0.3/pyhttp/projects.py +154 -0
  11. {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp/runner.py +3 -0
  12. {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp/static/app.js +193 -39
  13. {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp/static/features.js +34 -16
  14. {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp/static/index.html +11 -10
  15. {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp/static/style.css +15 -1
  16. pyhttp_app-2.0.3/pyhttp/tools.py +208 -0
  17. {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp/updater.py +1 -1
  18. {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3/pyhttp_app.egg-info}/PKG-INFO +45 -11
  19. {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp_app.egg-info/SOURCES.txt +3 -0
  20. {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/tests/test_backend.py +169 -0
  21. {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/tests/test_ui.py +116 -5
  22. {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/LICENSE.md +0 -0
  23. {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp/assertions.py +0 -0
  24. {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp/console.py +0 -0
  25. {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp/curl.py +0 -0
  26. {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp/jsengine.py +0 -0
  27. {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp/jsonc.py +0 -0
  28. {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp/mock.py +0 -0
  29. {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp/monitors.py +0 -0
  30. {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp/oauth2.py +0 -0
  31. {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp/openapi.py +0 -0
  32. {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp/pm.js +0 -0
  33. {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp/postman.py +0 -0
  34. {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp/scripts.py +0 -0
  35. {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp/static/codegen.js +0 -0
  36. {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp/static/editor.js +0 -0
  37. {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp/static/features.css +0 -0
  38. {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp/static/logo.svg +0 -0
  39. {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp/static/vendor/codemirror.js +0 -0
  40. {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp/static/vendor/fonts/Inter-OFL.txt +0 -0
  41. {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp/static/vendor/fonts/inter-latin-ext-wght-normal.woff2 +0 -0
  42. {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp/static/vendor/fonts/inter-latin-wght-normal.woff2 +0 -0
  43. {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp/static/viewer.js +0 -0
  44. {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp/templating.py +0 -0
  45. {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp_app.egg-info/dependency_links.txt +0 -0
  46. {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp_app.egg-info/entry_points.txt +0 -0
  47. {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp_app.egg-info/requires.txt +0 -0
  48. {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyhttp_app.egg-info/top_level.txt +0 -0
  49. {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/pyproject.toml +0 -0
  50. {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/setup.cfg +0 -0
  51. {pyhttp_app-2.0.1.post1 → pyhttp_app-2.0.3}/tests/test_integrations.py +0 -0
  52. {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.1.post1
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-app` installs the
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 tap pyhttp2/pyhttp https://github.com/pyhttp2/pyhttp-releases && brew install --cask pyhttp2/pyhttp/pyhttp`, or `pyhttp-<version>-macos-arm64.dmg` (drag pyhttp to Applications) | `~/Library/Application Support/pyhttp` |
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 and the Homebrew cask to the public
512
- [pyhttp-releases](https://github.com/pyhttp2/pyhttp-releases) repository (needs the
513
- `RELEASE_TOKEN` secret). The Windows runner installs Inno Setup with Chocolatey when
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 from the public
576
- [pyhttp-releases](https://github.com/pyhttp2/pyhttp-releases) repository.
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-app` installs the
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 tap pyhttp2/pyhttp https://github.com/pyhttp2/pyhttp-releases && brew install --cask pyhttp2/pyhttp/pyhttp`, or `pyhttp-<version>-macos-arm64.dmg` (drag pyhttp to Applications) | `~/Library/Application Support/pyhttp` |
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 and the Homebrew cask to the public
363
- [pyhttp-releases](https://github.com/pyhttp2/pyhttp-releases) repository (needs the
364
- `RELEASE_TOKEN` secret). The Windows runner installs Inno Setup with Chocolatey when
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 from the public
427
- [pyhttp-releases](https://github.com/pyhttp2/pyhttp-releases) repository.
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.1.post1"
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, runner,
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-releases" # public repo that holds the .exe/.dmg releases
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) -> dict[str, Any]:
321
- """Create folders (parents first), requests and environments; optionally replace everything."""
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
- created.append(db.create_request(data))
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
- return {"imported": len(created), "folders": len(id_map), "environments": len(environments)}
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
- output_dir = data_dir / "outputs"
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(data_dir / "pyhttp.db")
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
- app = FastAPI(title="pyhttp", version=__version__, docs_url="/api/docs", redoc_url=None)
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
- @app.on_event("shutdown")
407
- async def _stop_monitors() -> None:
408
- scheduler.stop()
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(output_dir),
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
- return db.list_requests(q, tag)
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("")) -> list[dict[str, Any]]:
840
- return db.list_history(limit=min(max(limit, 1), 1000), q=q, status=status)
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
- return db.list_runs()
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 ("id", "created_at", "updated_at")})
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
- return {"pyhttp_version": __version__, "folders": db.list_folders(), "environments": envs,
1191
- "requests": reqs, "variables": db.get_variables()}
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
- [e.model_dump() for e in body.environments], body.variables, body.replace)
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
- html = (STATIC_DIR / "index.html").read_text(encoding="utf-8")
1325
- html = re.sub(r'((?:src|href)="/static/[^"?]+)"', rf'\1?v={__version__}"', html)
1326
- html = html.replace("<html lang=\"en\">", f"<html lang=\"en\" data-version=\"{__version__}\">", 1)
1327
- return HTMLResponse(html)
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: