pyhttp-app 2.0.2__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.2/pyhttp_app.egg-info → pyhttp_app-2.0.3}/PKG-INFO +27 -2
  2. {pyhttp_app-2.0.2 → pyhttp_app-2.0.3}/README.md +26 -1
  3. {pyhttp_app-2.0.2 → pyhttp_app-2.0.3}/pyhttp/__init__.py +1 -1
  4. {pyhttp_app-2.0.2 → pyhttp_app-2.0.3}/pyhttp/__main__.py +5 -1
  5. {pyhttp_app-2.0.2 → pyhttp_app-2.0.3}/pyhttp/app.py +199 -33
  6. {pyhttp_app-2.0.2 → pyhttp_app-2.0.3}/pyhttp/cli.py +21 -9
  7. {pyhttp_app-2.0.2 → 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.2 → 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.2 → pyhttp_app-2.0.3}/pyhttp/runner.py +3 -0
  12. {pyhttp_app-2.0.2 → pyhttp_app-2.0.3}/pyhttp/static/app.js +193 -39
  13. {pyhttp_app-2.0.2 → pyhttp_app-2.0.3}/pyhttp/static/features.js +34 -16
  14. {pyhttp_app-2.0.2 → pyhttp_app-2.0.3}/pyhttp/static/index.html +11 -10
  15. {pyhttp_app-2.0.2 → 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.2 → pyhttp_app-2.0.3/pyhttp_app.egg-info}/PKG-INFO +27 -2
  18. {pyhttp_app-2.0.2 → pyhttp_app-2.0.3}/pyhttp_app.egg-info/SOURCES.txt +3 -0
  19. {pyhttp_app-2.0.2 → pyhttp_app-2.0.3}/tests/test_backend.py +169 -0
  20. {pyhttp_app-2.0.2 → pyhttp_app-2.0.3}/tests/test_ui.py +116 -5
  21. {pyhttp_app-2.0.2 → pyhttp_app-2.0.3}/LICENSE.md +0 -0
  22. {pyhttp_app-2.0.2 → pyhttp_app-2.0.3}/pyhttp/assertions.py +0 -0
  23. {pyhttp_app-2.0.2 → pyhttp_app-2.0.3}/pyhttp/console.py +0 -0
  24. {pyhttp_app-2.0.2 → pyhttp_app-2.0.3}/pyhttp/curl.py +0 -0
  25. {pyhttp_app-2.0.2 → pyhttp_app-2.0.3}/pyhttp/jsengine.py +0 -0
  26. {pyhttp_app-2.0.2 → pyhttp_app-2.0.3}/pyhttp/jsonc.py +0 -0
  27. {pyhttp_app-2.0.2 → pyhttp_app-2.0.3}/pyhttp/mock.py +0 -0
  28. {pyhttp_app-2.0.2 → pyhttp_app-2.0.3}/pyhttp/monitors.py +0 -0
  29. {pyhttp_app-2.0.2 → pyhttp_app-2.0.3}/pyhttp/oauth2.py +0 -0
  30. {pyhttp_app-2.0.2 → pyhttp_app-2.0.3}/pyhttp/openapi.py +0 -0
  31. {pyhttp_app-2.0.2 → pyhttp_app-2.0.3}/pyhttp/pm.js +0 -0
  32. {pyhttp_app-2.0.2 → pyhttp_app-2.0.3}/pyhttp/postman.py +0 -0
  33. {pyhttp_app-2.0.2 → pyhttp_app-2.0.3}/pyhttp/scripts.py +0 -0
  34. {pyhttp_app-2.0.2 → pyhttp_app-2.0.3}/pyhttp/static/codegen.js +0 -0
  35. {pyhttp_app-2.0.2 → pyhttp_app-2.0.3}/pyhttp/static/editor.js +0 -0
  36. {pyhttp_app-2.0.2 → pyhttp_app-2.0.3}/pyhttp/static/features.css +0 -0
  37. {pyhttp_app-2.0.2 → pyhttp_app-2.0.3}/pyhttp/static/logo.svg +0 -0
  38. {pyhttp_app-2.0.2 → pyhttp_app-2.0.3}/pyhttp/static/vendor/codemirror.js +0 -0
  39. {pyhttp_app-2.0.2 → pyhttp_app-2.0.3}/pyhttp/static/vendor/fonts/Inter-OFL.txt +0 -0
  40. {pyhttp_app-2.0.2 → pyhttp_app-2.0.3}/pyhttp/static/vendor/fonts/inter-latin-ext-wght-normal.woff2 +0 -0
  41. {pyhttp_app-2.0.2 → pyhttp_app-2.0.3}/pyhttp/static/vendor/fonts/inter-latin-wght-normal.woff2 +0 -0
  42. {pyhttp_app-2.0.2 → pyhttp_app-2.0.3}/pyhttp/static/viewer.js +0 -0
  43. {pyhttp_app-2.0.2 → pyhttp_app-2.0.3}/pyhttp/templating.py +0 -0
  44. {pyhttp_app-2.0.2 → pyhttp_app-2.0.3}/pyhttp/updater.py +0 -0
  45. {pyhttp_app-2.0.2 → pyhttp_app-2.0.3}/pyhttp_app.egg-info/dependency_links.txt +0 -0
  46. {pyhttp_app-2.0.2 → pyhttp_app-2.0.3}/pyhttp_app.egg-info/entry_points.txt +0 -0
  47. {pyhttp_app-2.0.2 → pyhttp_app-2.0.3}/pyhttp_app.egg-info/requires.txt +0 -0
  48. {pyhttp_app-2.0.2 → pyhttp_app-2.0.3}/pyhttp_app.egg-info/top_level.txt +0 -0
  49. {pyhttp_app-2.0.2 → pyhttp_app-2.0.3}/pyproject.toml +0 -0
  50. {pyhttp_app-2.0.2 → pyhttp_app-2.0.3}/setup.cfg +0 -0
  51. {pyhttp_app-2.0.2 → pyhttp_app-2.0.3}/tests/test_integrations.py +0 -0
  52. {pyhttp_app-2.0.2 → 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.2
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
@@ -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
@@ -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.2"
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,8 +20,8 @@ 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"
@@ -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:
@@ -20,7 +20,7 @@ from pathlib import Path
20
20
  from typing import Any
21
21
  from xml.etree import ElementTree as ET
22
22
 
23
- from . import __version__, db, openapi, postman, runner
23
+ from . import __version__, db, openapi, postman, projects, runner
24
24
 
25
25
 
26
26
  def _color(enabled: bool):
@@ -34,6 +34,7 @@ def build_parser(default_data_dir: Path) -> argparse.ArgumentParser:
34
34
  p.add_argument("targets", nargs="*", help="folder names or paths (A/B), request names or ids, or a collection "
35
35
  "file (Postman v2.1, pyhttp export, OpenAPI). Default: everything")
36
36
  p.add_argument("--data-dir", type=Path, default=default_data_dir, help=f"pyhttp data folder (default: {default_data_dir})")
37
+ p.add_argument("--project", help="project (workspace) to run from, by name or id; default: the active one")
37
38
  p.add_argument("-e", "--env", help="environment to use (name); default: the active one")
38
39
  p.add_argument("--env-file", type=Path, help="Postman environment file to use instead of a saved environment")
39
40
  p.add_argument("--var", action="append", default=[], metavar="KEY=VALUE", help="set a global variable (repeatable)")
@@ -219,18 +220,30 @@ def main(argv: list[str], default_data_dir: Path) -> int:
219
220
  data_dir = args.data_dir.resolve()
220
221
  targets = list(args.targets)
221
222
  args.targets_from_files = False
222
- if not (data_dir / "pyhttp.db").exists():
223
- print(f"No pyhttp data in {data_dir} (use --data-dir, or pass a collection file)", file=sys.stderr)
223
+ registry = projects.Registry(data_dir)
224
+ if args.project:
225
+ found = registry.find(args.project)
226
+ if found is None:
227
+ print(f"No project called {args.project!r} (projects: {', '.join(p['name'] for p in registry.list())})", file=sys.stderr)
228
+ return 2
229
+ project_id = found["id"]
230
+ else:
231
+ project_id = registry.active()
232
+ db_path, out_root = registry.paths(project_id)
233
+ if not db_path.exists():
234
+ print(f"No pyhttp data in {db_path.parent} (use --data-dir / --project, or pass a collection file)", file=sys.stderr)
224
235
  return 2
236
+ if files:
237
+ db_path, out_root = data_dir / "pyhttp.db", data_dir / "outputs"
225
238
  try:
226
- db.init(data_dir / "pyhttp.db")
239
+ db.init(db_path)
227
240
  if files:
228
241
  from .app import import_collection
229
242
  for f in files:
230
243
  conv = load_file(f)
231
244
  import_collection(conv["folders"], conv["requests"], conv.get("environments") or [],
232
245
  conv.get("variables") or {}, False)
233
- return _run(args, targets, data_dir, paint)
246
+ return _run(args, targets, out_root, paint)
234
247
  except ValueError as exc:
235
248
  print(f"pyhttp run: {exc}", file=sys.stderr)
236
249
  return 2
@@ -240,7 +253,7 @@ def main(argv: list[str], default_data_dir: Path) -> int:
240
253
  tmp.cleanup()
241
254
 
242
255
 
243
- def _run(args: argparse.Namespace, targets: list[str], data_dir: Path, paint) -> int:
256
+ def _run(args: argparse.Namespace, targets: list[str], out_root: Path, paint) -> int:
244
257
  # environment, variables and settings for this run (restored afterwards when using the user's data)
245
258
  restore: dict[str, Any] = {"active_env": db.get_setting("active_env"), "settings": db.app_settings(),
246
259
  "variables": db.get_variables()}
@@ -297,7 +310,7 @@ def _run(args: argparse.Namespace, targets: list[str], data_dir: Path, paint) ->
297
310
  if not args.quiet:
298
311
  print(paint("1", f"pyhttp {__version__} · {name}") + paint("2", f" · {len(chosen)} request(s)"
299
312
  + (f" · environment {env['name']}" if env else "") + (f" · {max(1, args.iterations) if not data_rows else len(data_rows)} iterations" if (args.iterations > 1 or data_rows) else "")))
300
- run = asyncio.run(_execute(chosen, options, data_dir, args.quiet, paint))
313
+ run = asyncio.run(_execute(chosen, options, out_root, args.quiet, paint))
301
314
  finally:
302
315
  if temp_env_id:
303
316
  db.delete_environment(temp_env_id)
@@ -336,8 +349,7 @@ def _run(args: argparse.Namespace, targets: list[str], data_dir: Path, paint) ->
336
349
  return 1 if failed or run["status"] not in ("completed",) else 0
337
350
 
338
351
 
339
- async def _execute(chosen: list[dict[str, Any]], options: dict[str, Any], data_dir: Path, quiet: bool, paint) -> dict[str, Any]:
340
- out_root = data_dir / "outputs"
352
+ async def _execute(chosen: list[dict[str, Any]], options: dict[str, Any], out_root: Path, quiet: bool, paint) -> dict[str, Any]:
341
353
  out_root.mkdir(parents=True, exist_ok=True)
342
354
  manager = runner.RunManager(out_root)
343
355
  run = await manager.start_saved([r["id"] for r in chosen], options)