casebook 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
casebook/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Casebook: a local YAML test case browser and editor."""
2
+
3
+ __version__ = "0.1.0"
casebook/app.py ADDED
@@ -0,0 +1,175 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import queue
5
+ import atexit
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from flask import Flask, Response, jsonify, render_template, request, stream_with_context
10
+
11
+ from . import __version__
12
+ from .editor import CaseEditor, CaseNotFoundError, EditConflictError
13
+ from .marks import MarksStore
14
+ from .scanner import CasebookStore
15
+ from .watcher import CasebookWatcher
16
+
17
+
18
+ class EventBroker:
19
+ def __init__(self):
20
+ self._subscribers: list[queue.Queue[dict[str, Any]]] = []
21
+
22
+ def subscribe(self) -> queue.Queue[dict[str, Any]]:
23
+ subscriber: queue.Queue[dict[str, Any]] = queue.Queue()
24
+ self._subscribers.append(subscriber)
25
+ return subscriber
26
+
27
+ def unsubscribe(self, subscriber: queue.Queue[dict[str, Any]]) -> None:
28
+ try:
29
+ self._subscribers.remove(subscriber)
30
+ except ValueError:
31
+ pass
32
+
33
+ def publish(self, event: dict[str, Any]) -> None:
34
+ for subscriber in list(self._subscribers):
35
+ subscriber.put(event)
36
+
37
+
38
+ def create_app(
39
+ project_root: Path,
40
+ scan_dirs: list[str] | None = None,
41
+ watch: bool = True,
42
+ ) -> Flask:
43
+ app = Flask(__name__)
44
+ store = CasebookStore(project_root=project_root, scan_dirs=scan_dirs)
45
+ editor = CaseEditor(project_root=project_root)
46
+ marks = MarksStore(project_root=project_root)
47
+ broker = EventBroker()
48
+
49
+ initial_summary = store.refresh()
50
+ app.config["CASEBOOK_INITIAL_SUMMARY"] = initial_summary
51
+
52
+ def refresh_and_publish(reason: str) -> dict[str, Any]:
53
+ summary = store.refresh()
54
+ broker.publish({"type": "reload", "reason": reason, "summary": summary})
55
+ return summary
56
+
57
+ watcher: CasebookWatcher | None = None
58
+ if watch:
59
+ watcher = CasebookWatcher(
60
+ project_root=store.project_root,
61
+ scan_dirs=store.scan_dirs,
62
+ on_change=lambda: refresh_and_publish("filesystem"),
63
+ )
64
+ watcher.start()
65
+ app.config["CASEBOOK_WATCHER"] = watcher
66
+ atexit.register(watcher.stop)
67
+
68
+ @app.after_request
69
+ def add_no_cache_headers(response):
70
+ if request.path.startswith("/api/"):
71
+ response.headers["Cache-Control"] = "no-store"
72
+ return response
73
+
74
+ @app.route("/")
75
+ def index():
76
+ return render_template("index.html", casebook_version=__version__)
77
+
78
+ @app.get("/favicon.ico")
79
+ def favicon():
80
+ return Response(status=204)
81
+
82
+ @app.get("/api/summary")
83
+ def api_summary():
84
+ return jsonify(store.summary())
85
+
86
+ @app.get("/api/tree")
87
+ def api_tree():
88
+ return jsonify(store.tree())
89
+
90
+ @app.get("/api/files")
91
+ def api_files():
92
+ return jsonify(store.list_files())
93
+
94
+ @app.get("/api/files/<path:file_path>")
95
+ def api_file(file_path: str):
96
+ entry = store.get_file(file_path)
97
+ if not entry:
98
+ return jsonify({"error": f"File not found: {file_path}"}), 404
99
+ all_marks = marks.all()
100
+ entry["marks"] = {
101
+ f"{file_path}#{case['id']}": all_marks.get(f"{file_path}#{case['id']}")
102
+ for case in entry["cases"]
103
+ if all_marks.get(f"{file_path}#{case['id']}")
104
+ }
105
+ entry["needs_update_count"] = len(entry["marks"])
106
+ return jsonify(entry)
107
+
108
+ @app.get("/api/marks")
109
+ def api_marks():
110
+ return jsonify(marks.all())
111
+
112
+ @app.post("/api/marks/toggle")
113
+ def api_toggle_mark():
114
+ payload = request.get_json(silent=True) or {}
115
+ file_path = str(payload.get("file_path") or payload.get("filePath") or "")
116
+ case_id = str(payload.get("case_id") or payload.get("caseId") or "")
117
+ if not file_path or not case_id:
118
+ return jsonify({"error": "Missing file_path or case_id"}), 400
119
+ result = marks.toggle_needs_update(file_path, case_id)
120
+ broker.publish({
121
+ "type": "marks",
122
+ "file_path": file_path,
123
+ "case_id": case_id,
124
+ "marked": result["marked"],
125
+ })
126
+ return jsonify(result)
127
+
128
+ @app.patch("/api/cases")
129
+ def api_update_case():
130
+ payload = request.get_json(silent=True) or {}
131
+ file_path = str(payload.get("file_path") or "")
132
+ case_id = str(payload.get("case_id") or "")
133
+ updates = payload.get("updates") or {}
134
+ mtime_ns = payload.get("mtime_ns")
135
+ if not file_path or not case_id or not isinstance(updates, dict):
136
+ return jsonify({"error": "Missing file_path, case_id, or updates"}), 400
137
+ try:
138
+ result = editor.update_case(file_path, case_id, updates, mtime_ns=mtime_ns)
139
+ except EditConflictError as exc:
140
+ return jsonify({"error": str(exc), "code": "edit_conflict"}), 409
141
+ except CaseNotFoundError:
142
+ return jsonify({"error": f"Case not found: {case_id}"}), 404
143
+ except FileNotFoundError:
144
+ return jsonify({"error": f"File not found: {file_path}"}), 404
145
+ summary = refresh_and_publish("edit")
146
+ return jsonify({"status": "ok", "result": result, "summary": summary})
147
+
148
+ @app.route("/api/refresh", methods=["GET", "POST"])
149
+ def api_refresh():
150
+ return jsonify(refresh_and_publish("manual"))
151
+
152
+ @app.get("/api/events")
153
+ def api_events():
154
+ subscriber = broker.subscribe()
155
+
156
+ def stream():
157
+ try:
158
+ yield "event: hello\ndata: {}\n\n"
159
+ while True:
160
+ try:
161
+ event = subscriber.get(timeout=15)
162
+ data = json.dumps(event, ensure_ascii=False)
163
+ yield f"event: {event.get('type', 'message')}\ndata: {data}\n\n"
164
+ except queue.Empty:
165
+ yield "event: ping\ndata: {}\n\n"
166
+ finally:
167
+ broker.unsubscribe(subscriber)
168
+
169
+ return Response(
170
+ stream_with_context(stream()),
171
+ mimetype="text/event-stream",
172
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
173
+ )
174
+
175
+ return app
casebook/cli.py ADDED
@@ -0,0 +1,165 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import webbrowser
5
+ from pathlib import Path
6
+ from typing import Annotated
7
+
8
+ import typer
9
+
10
+ from . import __version__
11
+
12
+
13
+ LOGGER = logging.getLogger("casebook.main")
14
+
15
+ app = typer.Typer(
16
+ help="Render, review, and edit YAML test cases locally.",
17
+ no_args_is_help=True,
18
+ add_completion=False,
19
+ )
20
+
21
+
22
+ def configure_logging() -> None:
23
+ logging.basicConfig(
24
+ level=logging.INFO,
25
+ format="[%(asctime)s] %(levelname)s/%(name)s: %(message)s",
26
+ datefmt="%Y-%m-%d %H:%M:%S",
27
+ )
28
+
29
+
30
+ def version_callback(value: bool) -> None:
31
+ if value:
32
+ typer.echo(f"casebook {__version__}")
33
+ raise typer.Exit()
34
+
35
+
36
+ @app.callback()
37
+ def root(
38
+ version: Annotated[
39
+ bool,
40
+ typer.Option(
41
+ "--version",
42
+ help="Show the Casebook version and exit.",
43
+ callback=version_callback,
44
+ is_eager=True,
45
+ ),
46
+ ] = False,
47
+ ) -> None:
48
+ configure_logging()
49
+
50
+
51
+ def serve_project(
52
+ paths: list[str],
53
+ host: str = "127.0.0.1",
54
+ port: int = 8089,
55
+ open_browser: bool = False,
56
+ watch: bool = True,
57
+ ) -> None:
58
+ from werkzeug.serving import make_server
59
+
60
+ from .app import create_app
61
+
62
+ project_root = Path.cwd()
63
+ flask_app = create_app(project_root=project_root, scan_dirs=paths, watch=watch)
64
+ summary = flask_app.config.get("CASEBOOK_INITIAL_SUMMARY", {})
65
+ url = f"http://{host}:{port}"
66
+ browser_url = f"http://localhost:{port}" if host in {
67
+ "127.0.0.1", "::"} else url
68
+
69
+ LOGGER.info("Starting web interface at %s", url)
70
+ LOGGER.info("Starting Casebook %s", __version__)
71
+ LOGGER.info("Watching YAML cases in %s", ", ".join(
72
+ summary.get("scan_dirs", paths or [])))
73
+ LOGGER.info("Loaded %s files, %s cases", summary.get(
74
+ "files", 0), summary.get("cases", 0))
75
+
76
+ if open_browser:
77
+ webbrowser.open(browser_url)
78
+
79
+ server = make_server(host, port, flask_app, threaded=True)
80
+ try:
81
+ server.serve_forever()
82
+ except KeyboardInterrupt:
83
+ LOGGER.info("Stopping Casebook")
84
+ finally:
85
+ server.server_close()
86
+
87
+
88
+ @app.command(help="Start the local Casebook web UI.")
89
+ def serve(
90
+ paths: Annotated[
91
+ list[str] | None,
92
+ typer.Argument(
93
+ help="YAML case directories relative to the current project root.",
94
+ ),
95
+ ] = None,
96
+ host: Annotated[
97
+ str,
98
+ typer.Option("--host", help="Host to bind."),
99
+ ] = "127.0.0.1",
100
+ port: Annotated[
101
+ int,
102
+ typer.Option("--port", "-p", help="Port to bind."),
103
+ ] = 8089,
104
+ open_browser: Annotated[
105
+ bool,
106
+ typer.Option("--open", "-o", help="Open the web UI in a browser."),
107
+ ] = False,
108
+ no_watch: Annotated[
109
+ bool,
110
+ typer.Option("--no-watch", help="Disable filesystem auto-refresh."),
111
+ ] = False,
112
+ ) -> None:
113
+ serve_project(
114
+ paths=paths or [],
115
+ host=host,
116
+ port=port,
117
+ open_browser=open_browser,
118
+ watch=not no_watch,
119
+ )
120
+
121
+
122
+ def initialize_project(project: str, force: bool = False) -> None:
123
+ from .initializer import ProjectInitError, init_project
124
+
125
+ try:
126
+ result = init_project(project, force=force)
127
+ except ProjectInitError as exc:
128
+ typer.echo(f"casebook init: {exc}", err=True)
129
+ raise typer.Exit(1) from exc
130
+
131
+ typer.echo(f"Initialized Casebook project at {result.project_root}")
132
+ if result.created:
133
+ typer.echo("\nCreated:")
134
+ for path in result.created:
135
+ typer.echo(f" {path.as_posix()}")
136
+ if result.skipped:
137
+ typer.echo("\nSkipped existing files:")
138
+ for path in result.skipped:
139
+ typer.echo(f" {path.as_posix()}")
140
+ typer.echo("\nUse --force to overwrite scaffold files.")
141
+ typer.echo("\nNext steps:")
142
+ typer.echo(f" cd {result.project_root}")
143
+ typer.echo(" casebook serve releases")
144
+
145
+
146
+ @app.command(help="Create a new Casebook test case project.")
147
+ def init(
148
+ project: Annotated[
149
+ str,
150
+ typer.Argument(help="Project directory to create or initialize."),
151
+ ],
152
+ force: Annotated[
153
+ bool,
154
+ typer.Option("--force", help="Overwrite existing scaffold files."),
155
+ ] = False,
156
+ ) -> None:
157
+ initialize_project(project, force=force)
158
+
159
+
160
+ def main(argv: list[str] | None = None) -> None:
161
+ app(args=argv)
162
+
163
+
164
+ if __name__ == "__main__":
165
+ main()
casebook/editor.py ADDED
@@ -0,0 +1,152 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from ruamel.yaml import YAML
7
+ from ruamel.yaml.comments import CommentedMap, CommentedSeq
8
+ from ruamel.yaml.scalarstring import LiteralScalarString
9
+
10
+ from .scanner import case_to_api, resolve_project_path
11
+
12
+
13
+ ALLOWED_FIELDS = {
14
+ "title",
15
+ "description",
16
+ "priority",
17
+ "type",
18
+ "preconditions",
19
+ "steps",
20
+ "expected_results",
21
+ "tags",
22
+ "auto",
23
+ }
24
+ LIST_FIELDS = {"preconditions", "steps", "expected_results", "tags"}
25
+ FIELD_ORDER = [
26
+ "id",
27
+ "title",
28
+ "description",
29
+ "priority",
30
+ "type",
31
+ "preconditions",
32
+ "steps",
33
+ "expected_results",
34
+ "tags",
35
+ "auto",
36
+ ]
37
+
38
+
39
+ class EditConflictError(Exception):
40
+ pass
41
+
42
+
43
+ class CaseNotFoundError(Exception):
44
+ pass
45
+
46
+
47
+ class CaseEditor:
48
+ def __init__(self, project_root: Path):
49
+ self.project_root = project_root.resolve()
50
+ self.yaml = YAML(typ="rt")
51
+ self.yaml.preserve_quotes = True
52
+ self.yaml.indent(mapping=2, sequence=4, offset=2)
53
+ self.yaml.width = 4096
54
+
55
+ def update_case(
56
+ self,
57
+ file_path: str,
58
+ case_id: str,
59
+ updates: dict[str, Any],
60
+ mtime_ns: int | None = None,
61
+ ) -> dict[str, Any]:
62
+ target = resolve_project_path(self.project_root, file_path)
63
+ if not target.exists():
64
+ raise FileNotFoundError(file_path)
65
+ current_mtime = target.stat().st_mtime_ns
66
+ if mtime_ns is not None and int(mtime_ns) != current_mtime:
67
+ raise EditConflictError("The file changed after it was loaded.")
68
+
69
+ data = self.yaml.load(target.read_text(encoding="utf-8"))
70
+ test_cases = data.get("test_cases") if isinstance(data, dict) else None
71
+ if not isinstance(test_cases, list):
72
+ raise CaseNotFoundError(case_id)
73
+
74
+ for index, case in enumerate(test_cases):
75
+ if str(case.get("id", "")) != str(case_id):
76
+ continue
77
+ self._apply_updates(case, updates)
78
+ with target.open("w", encoding="utf-8") as handle:
79
+ self.yaml.dump(data, handle)
80
+ return {
81
+ "case": case_to_api(case, index),
82
+ "mtime_ns": str(target.stat().st_mtime_ns),
83
+ }
84
+ raise CaseNotFoundError(case_id)
85
+
86
+ def _apply_updates(self, case: dict[str, Any], updates: dict[str, Any]) -> None:
87
+ for field, value in updates.items():
88
+ if field not in ALLOWED_FIELDS:
89
+ continue
90
+ if field not in case and self._empty_missing_value(field, value):
91
+ continue
92
+ if field in LIST_FIELDS:
93
+ self._set_field(case, field, self._list_value(value, case.get(field)))
94
+ elif field == "auto":
95
+ self._set_field(case, field, bool(value))
96
+ elif field == "description":
97
+ text = "" if value is None else str(value)
98
+ self._set_field(
99
+ case,
100
+ field,
101
+ LiteralScalarString(text) if "\n" in text else text,
102
+ )
103
+ else:
104
+ self._set_field(case, field, "" if value is None else str(value))
105
+
106
+ def _empty_missing_value(self, field: str, value: Any) -> bool:
107
+ if field == "auto":
108
+ return value in {False, None, ""}
109
+ if field in LIST_FIELDS:
110
+ if isinstance(value, str):
111
+ return not value.strip()
112
+ if isinstance(value, list):
113
+ return not any(str(item).strip() for item in value)
114
+ return True
115
+ if value is None:
116
+ return True
117
+ if isinstance(value, str):
118
+ return not value.strip()
119
+ return False
120
+
121
+ def _set_field(self, case: dict[str, Any], field: str, value: Any) -> None:
122
+ if field in case:
123
+ case[field] = value
124
+ return
125
+ if isinstance(case, CommentedMap):
126
+ case.insert(self._insert_index(case, field), field, value)
127
+ return
128
+ case[field] = value
129
+
130
+ def _insert_index(self, case: CommentedMap, field: str) -> int:
131
+ keys = list(case.keys())
132
+ try:
133
+ field_order_index = FIELD_ORDER.index(field)
134
+ except ValueError:
135
+ return len(keys)
136
+ for previous in reversed(FIELD_ORDER[:field_order_index]):
137
+ if previous in case:
138
+ return keys.index(previous) + 1
139
+ return len(keys)
140
+
141
+ def _list_value(self, value: Any, existing: Any) -> CommentedSeq:
142
+ seq = CommentedSeq()
143
+ if isinstance(value, str):
144
+ items = [line.strip() for line in value.splitlines()]
145
+ elif isinstance(value, list):
146
+ items = [str(item).strip() for item in value]
147
+ else:
148
+ items = []
149
+ seq.extend([item for item in items if item])
150
+ if isinstance(existing, CommentedSeq) and existing.fa.flow_style():
151
+ seq.fa.set_flow_style()
152
+ return seq
@@ -0,0 +1,54 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from importlib import resources
5
+ from pathlib import Path
6
+ from typing import Iterable
7
+
8
+
9
+ class ProjectInitError(Exception):
10
+ pass
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class ProjectInitResult:
15
+ project_root: Path
16
+ created: list[Path]
17
+ skipped: list[Path]
18
+
19
+
20
+ def init_project(project_path: str | Path, force: bool = False) -> ProjectInitResult:
21
+ project_root = Path(project_path).expanduser().resolve()
22
+ if project_root.exists() and not project_root.is_dir():
23
+ raise ProjectInitError(f"Target exists and is not a directory: {project_root}")
24
+
25
+ project_root.mkdir(parents=True, exist_ok=True)
26
+ created: list[Path] = []
27
+ skipped: list[Path] = []
28
+
29
+ template_root = resources.files("casebook").joinpath("project_template")
30
+ for relative_path, template_file in _iter_template_files(template_root):
31
+ target = project_root / relative_path
32
+ if target.exists() and not force:
33
+ skipped.append(relative_path)
34
+ continue
35
+ if target.exists() and target.is_dir():
36
+ raise ProjectInitError(f"Target path is a directory: {target}")
37
+ target.parent.mkdir(parents=True, exist_ok=True)
38
+ target.write_bytes(template_file.read_bytes())
39
+ created.append(relative_path)
40
+
41
+ return ProjectInitResult(
42
+ project_root=project_root,
43
+ created=created,
44
+ skipped=skipped,
45
+ )
46
+
47
+
48
+ def _iter_template_files(root, prefix: Path = Path()) -> Iterable[tuple[Path, object]]:
49
+ for child in sorted(root.iterdir(), key=lambda item: item.name):
50
+ relative_path = prefix / child.name
51
+ if child.is_dir():
52
+ yield from _iter_template_files(child, relative_path)
53
+ elif child.is_file():
54
+ yield relative_path, child
casebook/marks.py ADDED
@@ -0,0 +1,54 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from datetime import datetime, timezone
5
+ from pathlib import Path
6
+ from threading import RLock
7
+ from typing import Any
8
+
9
+
10
+ class MarksStore:
11
+ def __init__(self, project_root: Path):
12
+ self.project_root = project_root.resolve()
13
+ self.path = self.project_root / ".casebook" / "marks.json"
14
+ self._lock = RLock()
15
+
16
+ def _load(self) -> dict[str, Any]:
17
+ if not self.path.exists():
18
+ return {}
19
+ try:
20
+ data = json.loads(self.path.read_text(encoding="utf-8"))
21
+ except Exception:
22
+ return {}
23
+ return data if isinstance(data, dict) else {}
24
+
25
+ def _save(self, marks: dict[str, Any]) -> None:
26
+ self.path.parent.mkdir(parents=True, exist_ok=True)
27
+ self.path.write_text(
28
+ json.dumps(marks, ensure_ascii=False, indent=2),
29
+ encoding="utf-8",
30
+ )
31
+
32
+ def all(self) -> dict[str, Any]:
33
+ with self._lock:
34
+ return self._load()
35
+
36
+ def key(self, file_path: str, case_id: str) -> str:
37
+ return f"{file_path}#{case_id}"
38
+
39
+ def toggle_needs_update(self, file_path: str, case_id: str) -> dict[str, Any]:
40
+ with self._lock:
41
+ marks = self._load()
42
+ key = self.key(file_path, case_id)
43
+ current = marks.get(key)
44
+ if current and current.get("needs_update"):
45
+ marks.pop(key, None)
46
+ marked = False
47
+ else:
48
+ marks[key] = {
49
+ "needs_update": True,
50
+ "updated_at": datetime.now(timezone.utc).isoformat(),
51
+ }
52
+ marked = True
53
+ self._save(marks)
54
+ return {"key": key, "marked": marked, "marks": marks}