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 +3 -0
- casebook/app.py +175 -0
- casebook/cli.py +165 -0
- casebook/editor.py +152 -0
- casebook/initializer.py +54 -0
- casebook/marks.py +54 -0
- casebook/project_template/.agents/skills/casebook-test-cases/SKILL.md +128 -0
- casebook/project_template/.vscode/settings.json +12 -0
- casebook/project_template/AGENTS.md +26 -0
- casebook/project_template/docs/requirements/login.md +73 -0
- casebook/project_template/releases/example/login.yaml +73 -0
- casebook/project_template/schema/test-case-schema.json +126 -0
- casebook/scanner.py +235 -0
- casebook/static/app.css +1287 -0
- casebook/static/app.js +643 -0
- casebook/templates/index.html +149 -0
- casebook/watcher.py +60 -0
- casebook-0.1.0.dist-info/METADATA +300 -0
- casebook-0.1.0.dist-info/RECORD +23 -0
- casebook-0.1.0.dist-info/WHEEL +5 -0
- casebook-0.1.0.dist-info/entry_points.txt +2 -0
- casebook-0.1.0.dist-info/licenses/LICENSE +201 -0
- casebook-0.1.0.dist-info/top_level.txt +1 -0
casebook/__init__.py
ADDED
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
|
casebook/initializer.py
ADDED
|
@@ -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}
|