casecraft 1.3.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.
casecraft/__init__.py ADDED
File without changes
casecraft/app.py ADDED
@@ -0,0 +1,478 @@
1
+ """
2
+ app.py — CaseCraft 2.0 (3-pane layout with Taproom colors).
3
+ """
4
+
5
+ import asyncio
6
+ from pathlib import Path
7
+
8
+ from textual.app import App, ComposeResult
9
+ from textual.containers import Horizontal, Vertical
10
+ from textual.widgets import DataTable, Static, Input, Label, Button
11
+ from textual.binding import Binding
12
+ from textual.reactive import reactive
13
+ from textual.screen import ModalScreen
14
+
15
+ from casecraft.models import Session, TestCase, TestResult, Verdict, WorkspaceState
16
+ from casecraft.runner import run_all_test_cases_async
17
+ from casecraft.utils import (
18
+ is_initialized,
19
+ initialize_workspace,
20
+ load_sessions,
21
+ save_sessions,
22
+ get_or_create_session,
23
+ load_last_file,
24
+ save_last_file
25
+ )
26
+ from casecraft.widgets.add_modal import AddTestCaseModal
27
+
28
+ CSS = """
29
+ Screen {
30
+ background: #282c34;
31
+ color: #abb2bf;
32
+ padding: 1 2;
33
+ }
34
+
35
+ #top-bar {
36
+ height: 3;
37
+ layout: horizontal;
38
+ margin-bottom: 1;
39
+ }
40
+
41
+ #search-box {
42
+ width: 1fr;
43
+ border: round #e5c07b;
44
+ height: 3;
45
+ background: #282c34;
46
+ color: #abb2bf;
47
+ }
48
+ #search-box:focus {
49
+ border: round #98c379;
50
+ }
51
+
52
+ #viewing-box {
53
+ width: 30;
54
+ border: round #e5c07b;
55
+ height: 3;
56
+ content-align: center middle;
57
+ margin-left: 1;
58
+ }
59
+
60
+ #main-container {
61
+ height: 1fr;
62
+ layout: horizontal;
63
+ }
64
+
65
+ #left-column {
66
+ width: 35%;
67
+ height: 1fr;
68
+ border: round #e5c07b;
69
+ background: #282c34;
70
+ }
71
+
72
+ #prob-table {
73
+ height: 40%;
74
+ border-bottom: hkey #e5c07b;
75
+ background: #282c34;
76
+ }
77
+
78
+ #tc-table {
79
+ height: 60%;
80
+ background: #282c34;
81
+ }
82
+
83
+ #right-panel {
84
+ width: 65%;
85
+ height: 1fr;
86
+ border: round #e5c07b;
87
+ padding: 0 1;
88
+ margin-left: 1;
89
+ overflow-y: auto;
90
+ }
91
+
92
+ DataTable > .datatable--header {
93
+ background: #282c34;
94
+ color: #e5c07b;
95
+ text-style: bold;
96
+ }
97
+
98
+ DataTable > .datatable--cursor {
99
+ background: #3e4452;
100
+ color: #ffffff;
101
+ }
102
+
103
+ DataTable:focus > .datatable--cursor {
104
+ background: #e5c07b;
105
+ color: #282c34;
106
+ text-style: bold;
107
+ }
108
+
109
+ #footer-block {
110
+ height: 4;
111
+ margin-top: 1;
112
+ color: #abb2bf;
113
+ }
114
+
115
+ /* Modals */
116
+ PromptModal, InitModal {
117
+ align: center middle;
118
+ background: rgba(40,44,52,0.85);
119
+ }
120
+ #prompt-dialog {
121
+ width: 60;
122
+ height: auto;
123
+ background: #282c34;
124
+ border: round #e5c07b;
125
+ padding: 1 2;
126
+ }
127
+ #init-dialog {
128
+ width: 50;
129
+ height: auto;
130
+ background: #282c34;
131
+ border: round #e5c07b;
132
+ padding: 1 2;
133
+ align: center middle;
134
+ }
135
+ #init-dialog Button {
136
+ margin-top: 1;
137
+ width: 100%;
138
+ }
139
+ """
140
+
141
+ class InitModal(ModalScreen[bool]):
142
+ def compose(self) -> ComposeResult:
143
+ with Vertical(id="init-dialog"):
144
+ yield Label("[b #e5c07b]Not Initialized[/b]\n")
145
+ yield Label("CaseCraft is not initialized in this directory.")
146
+ yield Label("Would you like to initialize it by creating a `.casecraft` folder?")
147
+ yield Button("Initialize Workspace", id="btn-init", variant="success")
148
+ yield Button("Quit", id="btn-quit", variant="error")
149
+
150
+ def on_button_pressed(self, event: Button.Pressed) -> None:
151
+ if event.button.id == "btn-init":
152
+ self.dismiss(True)
153
+ else:
154
+ self.dismiss(False)
155
+
156
+
157
+ class PromptModal(ModalScreen[str | None]):
158
+ def __init__(self, title: str, placeholder: str = ""):
159
+ super().__init__()
160
+ self.title_text = title
161
+ self.placeholder = placeholder
162
+
163
+ def compose(self) -> ComposeResult:
164
+ with Vertical(id="prompt-dialog"):
165
+ yield Label(self.title_text, classes="prompt-title")
166
+ yield Input(placeholder=self.placeholder, id="prompt-input")
167
+ yield Label("[dim]Press Enter to confirm, Escape to cancel.[/dim]")
168
+
169
+ def on_mount(self):
170
+ self.query_one(Input).focus()
171
+
172
+ def on_input_submitted(self, event: Input.Submitted):
173
+ self.dismiss(event.value)
174
+
175
+ def on_key(self, event):
176
+ if event.key == "escape":
177
+ self.dismiss(None)
178
+
179
+
180
+ class CaseCraftApp(App):
181
+ CSS = CSS
182
+ TITLE = "CaseCraft ~ Taproom UI (3-Pane)"
183
+
184
+ BINDINGS = [
185
+ Binding("space", "run_all", "Run All", show=False),
186
+ Binding("p", "add_problem", "Add Prob", show=False),
187
+ Binding("a", "add_test_case", "Add TC", show=False),
188
+ Binding("e", "edit_test_case", "Edit TC", show=False),
189
+ Binding("d", "delete_selected", "Delete", show=False),
190
+ Binding("q", "quit", "Quit", show=False),
191
+ Binding("slash", "focus_search", "Search", show=False),
192
+ Binding("escape", "clear_search", "Clear Search", show=False),
193
+ ]
194
+
195
+ active_problem = reactive(None)
196
+ active_test_case = reactive(None)
197
+ search_query = reactive("", init=False)
198
+
199
+ def __init__(self):
200
+ super().__init__()
201
+ # We load workspace empty initially, will populate after mount if initialized
202
+ self.workspace = WorkspaceState(sessions={})
203
+ self.results: dict[str, TestResult] = {}
204
+
205
+ def compose(self) -> ComposeResult:
206
+ with Horizontal(id="top-bar"):
207
+ yield Input(placeholder="/ Search test cases...", id="search-box")
208
+ yield Static("Viewing: All", id="viewing-box")
209
+
210
+ with Horizontal(id="main-container"):
211
+ with Vertical(id="left-column"):
212
+ yield DataTable(id="prob-table", cursor_type="row")
213
+ yield DataTable(id="tc-table", cursor_type="row")
214
+ yield Static("Select a test case to view details...", id="right-panel")
215
+
216
+ yield Static(
217
+ "General : [b #e5c07b]q/ctrl+c[/]: quit [b #e5c07b]space[/]: run tests [b #e5c07b]/[/]: search [b #e5c07b]Esc[/]: clear search\n"
218
+ "Management : [b #e5c07b]a[/]: add test case [b #e5c07b]e[/]: edit test case [b #e5c07b]d[/]: delete\n"
219
+ "Workspace : [b #e5c07b]p[/]: load problem file [b #e5c07b]tab[/]: switch focus",
220
+ id="footer-block"
221
+ )
222
+
223
+ def on_mount(self):
224
+ prob_table = self.query_one("#prob-table", DataTable)
225
+ prob_table.add_column("Problem File")
226
+
227
+ tc_table = self.query_one("#tc-table", DataTable)
228
+ tc_table.add_columns("Verdict", "Test Case", "Time")
229
+
230
+ if not is_initialized():
231
+ def check_init(result: bool):
232
+ if result:
233
+ initialize_workspace()
234
+ self._load_workspace_data()
235
+ else:
236
+ self.exit()
237
+ self.push_screen(InitModal(), check_init)
238
+ else:
239
+ self._load_workspace_data()
240
+
241
+ def _load_workspace_data(self):
242
+ self.workspace = WorkspaceState(
243
+ sessions=load_sessions(),
244
+ active_file=load_last_file()
245
+ )
246
+ self.refresh_problems()
247
+ prob_table = self.query_one("#prob-table", DataTable)
248
+ if self.workspace.active_file and self.workspace.active_file in self.workspace.sessions:
249
+ self._select_problem_by_path(self.workspace.active_file)
250
+ prob_table.focus()
251
+ else:
252
+ prob_table.focus()
253
+
254
+ def _select_problem_by_path(self, fp: str):
255
+ pt = self.query_one("#prob-table", DataTable)
256
+ for i, row_key in enumerate(pt.rows):
257
+ if row_key.value == fp:
258
+ pt.move_cursor(row=i)
259
+ self.active_problem = fp
260
+ break
261
+
262
+ def refresh_problems(self):
263
+ pt = self.query_one("#prob-table", DataTable)
264
+ pt.clear()
265
+
266
+ q = self.search_query.lower()
267
+ for fp in sorted(self.workspace.sessions.keys()):
268
+ name = Path(fp).name
269
+ if q and q not in name.lower():
270
+ tc_match = any(q in tc.label.lower() for tc in self.workspace.sessions[fp].test_cases)
271
+ if not tc_match:
272
+ continue
273
+ pt.add_row(name, key=fp)
274
+
275
+ def refresh_test_cases(self):
276
+ tc_table = self.query_one("#tc-table", DataTable)
277
+ tc_table.clear()
278
+
279
+ if not self.active_problem:
280
+ return
281
+
282
+ session = self.workspace.sessions.get(self.active_problem)
283
+ if not session:
284
+ return
285
+
286
+ q = self.search_query.lower()
287
+ for tc in session.test_cases:
288
+ if q and q not in tc.label.lower() and q not in Path(self.active_problem).name.lower():
289
+ continue
290
+
291
+ res = self.results.get(tc.id)
292
+ verdict = res.verdict.value if res else Verdict.PENDING.value
293
+
294
+ if verdict == Verdict.ACCEPTED.value:
295
+ verdict_str = f"[#98c379]{verdict}[/]"
296
+ elif verdict in (Verdict.PENDING.value, Verdict.RUNNING.value):
297
+ verdict_str = f"[#61afef]{verdict}[/]"
298
+ else:
299
+ verdict_str = f"[#e06c75]{verdict}[/]"
300
+
301
+ time_str = f"{res.runtime_ms:.0f}ms" if res and res.runtime_ms else "—"
302
+ tc_table.add_row(verdict_str, tc.label, time_str, key=tc.id)
303
+
304
+ def watch_active_problem(self, old_val, new_val):
305
+ if new_val:
306
+ self.workspace.active_file = new_val
307
+ save_last_file(new_val)
308
+ self.query_one("#viewing-box", Static).update(f"Viewing: {Path(new_val).name}")
309
+ self.active_test_case = None
310
+ self.refresh_test_cases()
311
+ self.update_diff_view()
312
+
313
+ tc_table = self.query_one("#tc-table", DataTable)
314
+ if tc_table.row_count > 0:
315
+ tc_table.move_cursor(row=0)
316
+ self.active_test_case = tc_table.coordinate_to_cell_key(tc_table.cursor_coordinate).row_key.value
317
+
318
+ def watch_active_test_case(self, old_val, new_val):
319
+ self.update_diff_view()
320
+
321
+ def watch_search_query(self, old_val, new_val):
322
+ self.refresh_problems()
323
+ self.refresh_test_cases()
324
+
325
+ def on_input_changed(self, event: Input.Changed):
326
+ if event.input.id == "search-box":
327
+ self.search_query = event.value
328
+
329
+ def update_diff_view(self):
330
+ panel = self.query_one("#right-panel", Static)
331
+
332
+ if not self.active_problem or not self.active_test_case:
333
+ panel.update("Select a test case to view details...")
334
+ return
335
+
336
+ session = self.workspace.sessions.get(self.active_problem)
337
+ if not session:
338
+ return
339
+
340
+ tc = next((t for t in session.test_cases if t.id == self.active_test_case), None)
341
+ if tc:
342
+ res = self.results.get(tc.id)
343
+ prob_name = Path(self.active_problem).name
344
+
345
+ exp = tc.expected_output.strip() or "(empty)"
346
+
347
+ if res:
348
+ act = res.actual_output.strip() or "(empty)"
349
+ err = res.error.strip() if res.error else ""
350
+
351
+ color = "#98c379" if res.verdict == Verdict.ACCEPTED else "#e06c75"
352
+ if res.verdict in (Verdict.PENDING, Verdict.RUNNING):
353
+ color = "#61afef"
354
+
355
+ text = f"[b #e5c07b]▣ {tc.label}[/]\n"
356
+ text += f"Problem: {prob_name}\n"
357
+ text += f"Status: [{color}]{res.verdict.value}[/]\n"
358
+ text += f"Runtime: {res.runtime_ms:.0f}ms\n"
359
+ text += f"File: {self.active_problem}\n\n"
360
+
361
+ text += f"────────────────────────────────\n\n"
362
+ text += f"[b #e5c07b]Expected Output:[/]\n[#abb2bf]{exp}[/]\n\n"
363
+ text += f"[b #e5c07b]Actual Output:[/]\n[{color}]{act}[/]\n"
364
+
365
+ if err:
366
+ text += f"\n[b #e06c75]Stderr/Error:[/]\n[#d19a66]{err}[/]\n"
367
+ panel.update(text)
368
+ else:
369
+ text = f"[b #e5c07b]▣ {tc.label}[/]\n"
370
+ text += f"Problem: {prob_name}\n"
371
+ text += f"Status: [#61afef]—[/]\n"
372
+ text += f"File: {self.active_problem}\n\n"
373
+ text += f"────────────────────────────────\n\n"
374
+ text += f"[b #e5c07b]Expected Output:[/]\n[#abb2bf]{exp}[/]\n\n"
375
+ text += f"[dim]Press space to run test case and see actual output.[/]"
376
+ panel.update(text)
377
+
378
+ def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted):
379
+ if event.data_table.id == "prob-table" and event.row_key:
380
+ self.active_problem = str(event.row_key.value)
381
+ elif event.data_table.id == "tc-table" and event.row_key:
382
+ self.active_test_case = str(event.row_key.value)
383
+
384
+ def action_focus_search(self):
385
+ self.query_one("#search-box", Input).focus()
386
+
387
+ def action_clear_search(self):
388
+ sb = self.query_one("#search-box", Input)
389
+ if sb.has_focus:
390
+ sb.value = ""
391
+ self.query_one("#tc-table", DataTable).focus()
392
+ else:
393
+ self.query_one("#tc-table", DataTable).focus()
394
+
395
+ async def action_add_problem(self):
396
+ def check_reply(fp: str | None):
397
+ if fp:
398
+ path = Path(fp)
399
+ if not path.is_absolute():
400
+ path = Path.cwd() / path
401
+ path_str = str(path.resolve())
402
+ if path_str not in self.workspace.sessions:
403
+ get_or_create_session(self.workspace.sessions, path_str)
404
+ save_sessions(self.workspace.sessions)
405
+ self.refresh_problems()
406
+ self._select_problem_by_path(path_str)
407
+ self.push_screen(PromptModal("Enter file path to load (e.g. main.py):"), check_reply)
408
+
409
+ async def action_add_test_case(self):
410
+ if not self.active_problem:
411
+ return
412
+
413
+ def check_reply(tc: TestCase | None):
414
+ if tc:
415
+ self.workspace.sessions[self.active_problem].test_cases.append(tc)
416
+ save_sessions(self.workspace.sessions)
417
+ self.refresh_test_cases()
418
+ self.push_screen(AddTestCaseModal(), check_reply)
419
+
420
+ async def action_edit_test_case(self):
421
+ if not self.active_problem or not self.active_test_case:
422
+ return
423
+
424
+ session = self.workspace.sessions.get(self.active_problem)
425
+ tc = next((t for t in session.test_cases if t.id == self.active_test_case), None)
426
+ if not tc:
427
+ return
428
+
429
+ def check_reply(updated_tc: TestCase | None):
430
+ if updated_tc:
431
+ idx = next((i for i, t in enumerate(session.test_cases) if t.id == updated_tc.id), None)
432
+ if idx is not None:
433
+ session.test_cases[idx] = updated_tc
434
+ save_sessions(self.workspace.sessions)
435
+ self.refresh_test_cases()
436
+ self.update_diff_view()
437
+
438
+ self.push_screen(AddTestCaseModal(existing=tc), check_reply)
439
+
440
+ async def action_delete_selected(self):
441
+ focused = self.focused
442
+ if focused and focused.id == "prob-table":
443
+ if self.active_problem in self.workspace.sessions:
444
+ del self.workspace.sessions[self.active_problem]
445
+ save_sessions(self.workspace.sessions)
446
+ self.active_problem = None
447
+ self.refresh_problems()
448
+ elif focused and focused.id == "tc-table":
449
+ if self.active_problem and self.active_test_case:
450
+ session = self.workspace.sessions.get(self.active_problem)
451
+ if session:
452
+ session.test_cases = [t for t in session.test_cases if t.id != self.active_test_case]
453
+ save_sessions(self.workspace.sessions)
454
+ self.active_test_case = None
455
+ self.refresh_test_cases()
456
+
457
+ async def action_run_all(self):
458
+ if not self.active_problem:
459
+ return
460
+
461
+ session = self.workspace.sessions.get(self.active_problem)
462
+ if not session or not session.test_cases:
463
+ return
464
+
465
+ for tc in session.test_cases:
466
+ self.results[tc.id] = TestResult(tc, Verdict.RUNNING, 0, "", "", False)
467
+ self.refresh_test_cases()
468
+
469
+ def progress(res: TestResult):
470
+ self.results[res.test_case.id] = res
471
+ self.refresh_test_cases()
472
+ self.update_diff_view()
473
+
474
+ results = await run_all_test_cases_async(self.active_problem, session.test_cases, progress_callback=progress)
475
+
476
+ if __name__ == "__main__":
477
+ app = CaseCraftApp()
478
+ app.run()
casecraft/cli.py ADDED
@@ -0,0 +1,32 @@
1
+ """
2
+ cli.py — Command-Line Interface for CaseCraft.
3
+ """
4
+
5
+ import argparse
6
+ import sys
7
+
8
+ from casecraft.utils import is_initialized, initialize_workspace
9
+ from casecraft.app import CaseCraftApp
10
+
11
+ def main():
12
+ parser = argparse.ArgumentParser(description="CaseCraft - TUI for algorithmic test cases.")
13
+ subparsers = parser.add_subparsers(dest="command")
14
+
15
+ init_parser = subparsers.add_parser("init", help="Initialize a CaseCraft workspace in the current directory.")
16
+
17
+ args = parser.parse_args()
18
+
19
+ if args.command == "init":
20
+ if is_initialized():
21
+ print("CaseCraft is already initialized in this directory (.casecraft exists).")
22
+ else:
23
+ initialize_workspace()
24
+ print("Successfully initialized CaseCraft in .casecraft/")
25
+ sys.exit(0)
26
+
27
+ # If no command provided, run the TUI
28
+ app = CaseCraftApp()
29
+ app.run()
30
+
31
+ if __name__ == "__main__":
32
+ main()
casecraft/models.py ADDED
@@ -0,0 +1,95 @@
1
+ """
2
+ models.py — Data models for CaseCraft 2.0.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import uuid
8
+ from dataclasses import dataclass, field
9
+ from enum import Enum
10
+ from typing import Optional
11
+
12
+
13
+ class Verdict(str, Enum):
14
+ ACCEPTED = "AC"
15
+ WRONG_ANSWER = "WA"
16
+ RUNTIME_ERROR = "RE"
17
+ TIME_LIMIT_EXCEEDED = "TLE"
18
+ COMPILATION_ERROR = "CE"
19
+ PENDING = "—"
20
+ RUNNING = "↻"
21
+
22
+
23
+ VERDICT_COLOR: dict[Verdict, str] = {
24
+ Verdict.ACCEPTED: "#9ece6a",
25
+ Verdict.WRONG_ANSWER: "#f7768e",
26
+ Verdict.RUNTIME_ERROR: "#bb9af7",
27
+ Verdict.TIME_LIMIT_EXCEEDED: "#e0af68",
28
+ Verdict.COMPILATION_ERROR: "#f7768e",
29
+ Verdict.PENDING: "#565f89",
30
+ Verdict.RUNNING: "#7aa2f7",
31
+ }
32
+
33
+
34
+ @dataclass
35
+ class TestCase:
36
+ label: str
37
+ input_data: str
38
+ expected_output: str
39
+ id: str = field(default_factory=lambda: uuid.uuid4().hex[:8])
40
+
41
+ def to_dict(self) -> dict:
42
+ return {
43
+ "id": self.id,
44
+ "label": self.label,
45
+ "input_data": self.input_data,
46
+ "expected_output": self.expected_output,
47
+ }
48
+
49
+ @classmethod
50
+ def from_dict(cls, data: dict) -> TestCase:
51
+ return cls(
52
+ id=data.get("id", uuid.uuid4().hex[:8]),
53
+ label=data.get("label", "Test Case"),
54
+ input_data=data.get("input_data", ""),
55
+ expected_output=data.get("expected_output", ""),
56
+ )
57
+
58
+
59
+ @dataclass
60
+ class TestResult:
61
+ test_case: TestCase
62
+ verdict: Verdict
63
+ runtime_ms: Optional[float]
64
+ actual_output: str
65
+ error: Optional[str]
66
+ timed_out: bool
67
+
68
+
69
+ @dataclass
70
+ class Session:
71
+ """Represents a specific problem or file."""
72
+ file_path: str
73
+ test_cases: list[TestCase] = field(default_factory=list)
74
+ last_opened: str = ""
75
+
76
+ def to_dict(self) -> dict:
77
+ return {
78
+ "file_path": self.file_path,
79
+ "last_opened": self.last_opened,
80
+ "test_cases": [tc.to_dict() for tc in self.test_cases],
81
+ }
82
+
83
+ @classmethod
84
+ def from_dict(cls, data: dict) -> Session:
85
+ return cls(
86
+ file_path=data["file_path"],
87
+ last_opened=data.get("last_opened", ""),
88
+ test_cases=[TestCase.from_dict(tc) for tc in data.get("test_cases", [])],
89
+ )
90
+
91
+ @dataclass
92
+ class WorkspaceState:
93
+ """Root state for the application."""
94
+ sessions: dict[str, Session] = field(default_factory=dict)
95
+ active_file: str | None = None