dsap-cli 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.
dsap/__init__.py ADDED
@@ -0,0 +1,21 @@
1
+ """DSAP - DSA Practice with Spaced Repetition.
2
+
3
+ A CLI tool for mastering Data Structures and Algorithms using the
4
+ scientifically-proven SM-2 spaced repetition algorithm.
5
+
6
+ Usage:
7
+ dsap review Start a review session with due problems
8
+ dsap next Get next recommended problem
9
+ dsap list List problems with filters
10
+ dsap stats Show your statistics
11
+ dsap load Load a curated problem set
12
+ dsap add Add a custom problem
13
+ dsap config Manage settings
14
+
15
+ Quick Start:
16
+ $ dsap load blind75
17
+ $ dsap review
18
+ """
19
+
20
+ __version__ = "0.1.0"
21
+ __author__ = "Juma R. Paul"
dsap/__main__.py ADDED
@@ -0,0 +1,9 @@
1
+ """
2
+ Allow running dsap as a module: python -m dsap
3
+ """
4
+
5
+ from dsap.cli import main
6
+
7
+
8
+ if __name__ == "__main__":
9
+ main()
dsap/cli.py ADDED
@@ -0,0 +1,618 @@
1
+ """DSAP CLI - DSA Practice with Spaced Repetition.
2
+
3
+ Main command-line interface using Click.
4
+
5
+ Commands:
6
+ dsap review - Start a review session with due problems
7
+ dsap next - Get next recommended problem
8
+ dsap list - List problems with filters
9
+ dsap stats - Show statistics and progress
10
+ dsap load - Load a curated problem set
11
+ dsap add - Add a custom problem
12
+ dsap config - Manage configuration
13
+ dsap reset - Reset problems and/or progress
14
+ """
15
+
16
+ import click
17
+ import yaml
18
+
19
+ from dsap import __version__
20
+ from dsap.config import get_config
21
+ from dsap.database import Database
22
+ from dsap.models import Difficulty, Problem
23
+ from dsap.problem_sets import list_bundled_sets, load_problem_set
24
+ from dsap.sm2 import SM2State, process_review
25
+ from dsap.ui import (
26
+ console,
27
+ display_error,
28
+ display_info,
29
+ display_problem,
30
+ display_problem_list,
31
+ display_review_feedback,
32
+ display_session_summary,
33
+ display_stats,
34
+ display_success,
35
+ display_warning,
36
+ display_welcome,
37
+ prompt_open_browser,
38
+ prompt_quality_rating,
39
+ )
40
+
41
+
42
+ def normalize_set_name(name: str | None) -> str | None:
43
+ """Normalize problem set names to their canonical form."""
44
+ if name is None:
45
+ return None
46
+
47
+ name_lower = name.lower().replace(" ", "").replace("_", "")
48
+
49
+ mapping = {
50
+ "blind75": "Blind 75",
51
+ "neetcode150": "NeetCode 150",
52
+ "grind75": "Grind 75",
53
+ }
54
+
55
+ return mapping.get(name_lower, name)
56
+
57
+
58
+ @click.group(invoke_without_command=True)
59
+ @click.version_option(version=__version__, prog_name="dsap")
60
+ @click.pass_context
61
+ def cli(ctx: click.Context) -> None:
62
+ r"""DSAP - DSA Practice with Spaced Repetition.
63
+
64
+ Master data structures and algorithms with scientifically-proven
65
+ spaced repetition (SM-2 algorithm).
66
+
67
+ \b
68
+ Quick Start:
69
+ dsap load blind75 Load the Blind 75 problems
70
+ dsap review Start practicing
71
+ dsap stats Check your progress
72
+
73
+ \b
74
+ Examples:
75
+ dsap review --limit 5 Review up to 5 problems
76
+ dsap next --difficulty Easy Get an easy problem
77
+ dsap list --due Show problems due today
78
+ """
79
+ if ctx.invoked_subcommand is None:
80
+ display_welcome()
81
+
82
+
83
+ @cli.command()
84
+ @click.option(
85
+ "--limit", "-n", default=10, help="Maximum problems to review (default: 10)"
86
+ )
87
+ @click.option(
88
+ "--difficulty",
89
+ "-d",
90
+ type=click.Choice(["Easy", "Medium", "Hard"], case_sensitive=False),
91
+ help="Filter by difficulty",
92
+ )
93
+ @click.option("--category", "-c", help="Filter by category")
94
+ @click.option(
95
+ "--set",
96
+ "-s",
97
+ "problem_set",
98
+ help="Filter by problem set (e.g., blind75, neetcode150)",
99
+ )
100
+ def review(limit: int, difficulty: str, category: str, problem_set: str) -> None:
101
+ r"""Start a review session with due problems.
102
+
103
+ Reviews problems that are due based on SM-2 scheduling.
104
+ After solving each problem, rate your recall quality (0-5).
105
+
106
+ \b
107
+ Example:
108
+ dsap review Review up to 10 due problems
109
+ dsap review -n 5 Review up to 5 problems
110
+ dsap review -d Medium Review only medium difficulty
111
+ dsap review -s blind75 Review only Blind 75 problems
112
+ """
113
+ db = Database()
114
+ config = get_config()
115
+
116
+ # Use config's preferred_set if not specified
117
+ if not problem_set:
118
+ problem_set = config.get("preferred_set")
119
+
120
+ # Normalize the set name
121
+ problem_set = normalize_set_name(problem_set)
122
+
123
+ # Get due problems
124
+ due_problems = db.get_due_problems(
125
+ limit=limit,
126
+ difficulty=difficulty,
127
+ category=category,
128
+ problem_set=problem_set,
129
+ )
130
+
131
+ if not due_problems:
132
+ # Check if there are new problems to start
133
+ new_problems = db.get_new_problems(limit=limit, problem_set=problem_set)
134
+ if new_problems:
135
+ console.print(
136
+ f"[green]No problems due![/green] "
137
+ f"You have {len(new_problems)} new problems to start."
138
+ )
139
+ console.print("Run [bold]dsap next[/bold] to get a new problem.")
140
+ else:
141
+ console.print(
142
+ "[green]No problems due for review![/green] "
143
+ "Great job keeping up with your practice."
144
+ )
145
+ return
146
+
147
+ console.print(
148
+ f"[bold]Starting review session: {len(due_problems)} problems due[/bold]"
149
+ )
150
+ console.print()
151
+
152
+ qualities: list[int] = []
153
+ reviewed = 0
154
+
155
+ for i, (problem, progress) in enumerate(due_problems, 1):
156
+ # Display problem
157
+ display_problem(
158
+ problem,
159
+ progress=progress,
160
+ index=i,
161
+ total=len(due_problems),
162
+ )
163
+
164
+ # Prompt to open browser
165
+ auto_open = config.get("auto_open_browser")
166
+ prompt_open_browser(str(problem.url), auto_open=auto_open)
167
+
168
+ # Get quality rating
169
+ quality = prompt_quality_rating()
170
+
171
+ if quality is None:
172
+ # User wants to quit
173
+ console.print()
174
+ display_warning("Session paused. Progress saved.")
175
+ break
176
+
177
+ # Update SM-2 state
178
+ current_state = SM2State(
179
+ easiness_factor=progress.easiness_factor,
180
+ interval=progress.interval,
181
+ repetitions=progress.repetitions,
182
+ )
183
+ new_state = process_review(current_state, quality)
184
+
185
+ # Save to database
186
+ db.update_progress(problem.id, new_state, quality)
187
+
188
+ # Show feedback
189
+ display_review_feedback(quality, new_state.interval)
190
+
191
+ qualities.append(quality)
192
+ reviewed += 1
193
+
194
+ # Show session summary
195
+ display_session_summary(reviewed, len(due_problems), qualities)
196
+
197
+
198
+ @cli.command()
199
+ @click.option(
200
+ "--difficulty",
201
+ "-d",
202
+ type=click.Choice(["Easy", "Medium", "Hard"], case_sensitive=False),
203
+ help="Filter by difficulty",
204
+ )
205
+ @click.option("--category", "-c", help="Filter by category")
206
+ @click.option(
207
+ "--set",
208
+ "-s",
209
+ "problem_set",
210
+ help="Filter by problem set (e.g., blind75, neetcode150)",
211
+ )
212
+ @click.option(
213
+ "--new-only", "-n", is_flag=True, help="Only show problems never attempted"
214
+ )
215
+ def next(difficulty: str, category: str, problem_set: str, new_only: bool) -> None:
216
+ r"""Get the next recommended problem to practice.
217
+
218
+ Recommends based on:
219
+ 1. Problems due for review (highest priority)
220
+ 2. New problems you haven't tried yet
221
+ 3. Problems with low easiness factors (harder for you)
222
+
223
+ \b
224
+ Example:
225
+ dsap next Get next recommended problem
226
+ dsap next -d Easy Get an easy problem
227
+ dsap next --new-only Get a new problem only
228
+ dsap next -s blind75 Get from Blind 75 only
229
+ """
230
+ db = Database()
231
+ config = get_config()
232
+
233
+ # Use config's preferred_set if not specified
234
+ if not problem_set:
235
+ problem_set = config.get("preferred_set")
236
+
237
+ # Normalize the set name
238
+ problem_set = normalize_set_name(problem_set)
239
+
240
+ result = db.get_next_recommendation(
241
+ difficulty=difficulty,
242
+ category=category,
243
+ problem_set=problem_set,
244
+ new_only=new_only,
245
+ )
246
+
247
+ if not result:
248
+ if difficulty or category:
249
+ display_warning("No problems found matching your filters.")
250
+ else:
251
+ display_info("No problems available. Try loading a problem set:")
252
+ console.print(" dsap load blind75")
253
+ return
254
+
255
+ problem, progress = result
256
+
257
+ # Ensure progress record exists
258
+ if progress is None:
259
+ db.ensure_progress_exists(problem.id)
260
+
261
+ # Display the problem
262
+ display_problem(
263
+ problem,
264
+ progress=progress,
265
+ show_hints=config.get("show_hints"),
266
+ )
267
+
268
+ # Prompt to open and optionally rate
269
+ auto_open = config.get("auto_open_browser")
270
+ opened = prompt_open_browser(str(problem.url), auto_open=auto_open)
271
+
272
+ if opened:
273
+ console.print()
274
+ rate_now = click.confirm("Rate this problem now?", default=True)
275
+
276
+ if rate_now:
277
+ quality = prompt_quality_rating()
278
+
279
+ if quality is not None:
280
+ current_state = SM2State(
281
+ easiness_factor=progress.easiness_factor if progress else 2.5,
282
+ interval=progress.interval if progress else 0,
283
+ repetitions=progress.repetitions if progress else 0,
284
+ )
285
+ new_state = process_review(current_state, quality)
286
+ db.update_progress(problem.id, new_state, quality)
287
+ display_review_feedback(quality, new_state.interval)
288
+
289
+
290
+ @cli.command("list")
291
+ @click.option(
292
+ "--difficulty",
293
+ "-d",
294
+ type=click.Choice(["Easy", "Medium", "Hard"], case_sensitive=False),
295
+ help="Filter by difficulty",
296
+ )
297
+ @click.option("--category", "-c", help="Filter by category")
298
+ @click.option("--set", "problem_set", help="Filter by problem set (e.g., blind75)")
299
+ @click.option("--due", is_flag=True, help="Show only problems due for review")
300
+ @click.option(
301
+ "--limit", "-n", default=200, help="Maximum problems to show (default: 200)"
302
+ )
303
+ def list_problems(
304
+ difficulty: str,
305
+ category: str,
306
+ problem_set: str,
307
+ due: bool,
308
+ limit: int,
309
+ ) -> None:
310
+ r"""List all problems with optional filters.
311
+
312
+ \b
313
+ Example:
314
+ dsap list List all problems
315
+ dsap list --due Show only due problems
316
+ dsap list -d Hard Show only hard problems
317
+ dsap list --set blind75 Show Blind 75 problems
318
+ """
319
+ db = Database()
320
+
321
+ # Normalize set name
322
+ problem_set = normalize_set_name(problem_set)
323
+
324
+ problems = db.get_problems(
325
+ difficulty=difficulty,
326
+ category=category,
327
+ problem_set=problem_set,
328
+ due_only=due,
329
+ limit=limit,
330
+ )
331
+
332
+ if not problems:
333
+ if difficulty or category or problem_set or due:
334
+ display_warning("No problems found matching your filters.")
335
+ else:
336
+ display_info("No problems loaded. Try:")
337
+ console.print(" dsap load blind75")
338
+ return
339
+
340
+ display_problem_list(problems)
341
+
342
+
343
+ @cli.command()
344
+ def stats() -> None:
345
+ """Show your practice statistics and progress.
346
+
347
+ Displays:
348
+ - Total and solved problems
349
+ - Problems due today and this week
350
+ - Current and best streaks
351
+ - Difficulty breakdown with progress bars
352
+ """
353
+ db = Database()
354
+ statistics = db.get_statistics()
355
+
356
+ if statistics.total_problems == 0:
357
+ display_info("No problems loaded yet. Try:")
358
+ console.print(" dsap load blind75")
359
+ return
360
+
361
+ display_stats(statistics)
362
+
363
+
364
+ @cli.command()
365
+ @click.argument("source", required=False)
366
+ @click.option("--list", "show_list", is_flag=True, help="List available problem sets")
367
+ def load(source: str, show_list: bool) -> None:
368
+ r"""Load a curated problem set.
369
+
370
+ \b
371
+ Available sets:
372
+ blind75 - Blind 75 essential problems
373
+ neetcode150 - NeetCode 150 extended set
374
+ grind75 - Grind 75 flexible plan
375
+
376
+ \b
377
+ Example:
378
+ dsap load blind75 Load Blind 75
379
+ dsap load --list Show available sets
380
+ dsap load ./custom.yaml Load custom YAML file
381
+ """
382
+ if show_list:
383
+ console.print("[bold]Available Problem Sets:[/bold]")
384
+ console.print()
385
+ for name, description in list_bundled_sets().items():
386
+ console.print(f" [cyan]{name}[/cyan] - {description}")
387
+ console.print()
388
+ console.print("Usage: dsap load <set_name>")
389
+ return
390
+
391
+ if not source:
392
+ display_error("Please specify a problem set or use --list")
393
+ return
394
+
395
+ try:
396
+ with console.status(f"Loading {source}..."):
397
+ problems = load_problem_set(source)
398
+
399
+ db = Database()
400
+ added = db.add_problems(problems)
401
+
402
+ display_success(
403
+ f"Loaded {added} new problems from '{source}' "
404
+ f"({len(problems)} total in set)"
405
+ )
406
+
407
+ if added < len(problems):
408
+ display_info(
409
+ f"{len(problems) - added} problems were already in your database"
410
+ )
411
+
412
+ except FileNotFoundError as e:
413
+ display_error(str(e))
414
+ except ValueError as e:
415
+ display_error(str(e))
416
+ except yaml.YAMLError as e:
417
+ display_error(f"Failed to parse YAML file: {e}")
418
+ except OSError as e:
419
+ display_error(f"Failed to read file: {e}")
420
+
421
+
422
+ @cli.command()
423
+ @click.argument("title")
424
+ @click.argument("url")
425
+ @click.option(
426
+ "--difficulty",
427
+ "-d",
428
+ required=True,
429
+ type=click.Choice(["Easy", "Medium", "Hard"], case_sensitive=False),
430
+ help="Problem difficulty",
431
+ )
432
+ @click.option(
433
+ "--category",
434
+ "-c",
435
+ required=True,
436
+ help="Problem category (e.g., 'Arrays', 'Dynamic Programming')",
437
+ )
438
+ @click.option("--description", help="Brief problem description")
439
+ @click.option("--tags", help="Comma-separated tags")
440
+ def add(
441
+ title: str,
442
+ url: str,
443
+ difficulty: str,
444
+ category: str,
445
+ description: str,
446
+ tags: str,
447
+ ) -> None:
448
+ r"""Add a custom problem.
449
+
450
+ \b
451
+ Example:
452
+ dsap add "Two Sum" "https://leetcode.com/problems/two-sum" \\
453
+ -d Easy -c "Arrays & Hashing"
454
+ """
455
+ tag_list = [t.strip() for t in tags.split(",")] if tags else []
456
+
457
+ try:
458
+ problem = Problem(
459
+ title=title,
460
+ url=url,
461
+ difficulty=Difficulty.from_string(difficulty),
462
+ category=category,
463
+ description=description or "",
464
+ tags=tag_list,
465
+ problem_set="custom",
466
+ )
467
+
468
+ db = Database()
469
+ problem_id = db.add_problem(problem)
470
+
471
+ display_success(f"Added '{title}' with ID {problem_id}")
472
+
473
+ except (ValueError, TypeError) as e:
474
+ display_error(f"Failed to add problem: {e}")
475
+
476
+
477
+ @cli.command()
478
+ @click.argument("key", required=False)
479
+ @click.argument("value", required=False)
480
+ @click.option(
481
+ "--list", "show_list", is_flag=True, help="Show all configuration options"
482
+ )
483
+ @click.option("--reset", is_flag=True, help="Reset configuration to defaults")
484
+ def config(key: str, value: str, show_list: bool, reset: bool) -> None:
485
+ r"""View or set configuration options.
486
+
487
+ \b
488
+ Options:
489
+ daily_goal Number of problems per day (default: 5)
490
+ preferred_difficulty Preferred difficulty (Easy/Medium/Hard/None)
491
+ preferred_set Default problem set (blind75/neetcode150/grind75/None)
492
+ show_hints Show hints for problems (true/false)
493
+ auto_open_browser Auto-open problems in browser (true/false)
494
+
495
+ \b
496
+ Example:
497
+ dsap config --list Show all settings
498
+ dsap config daily_goal View daily goal
499
+ dsap config daily_goal 10 Set daily goal to 10
500
+ dsap config preferred_set blind75 Focus on Blind 75
501
+ dsap config --reset Reset to defaults
502
+ """
503
+ cfg = get_config()
504
+
505
+ if reset:
506
+ cfg.reset()
507
+ display_success("Configuration reset to defaults")
508
+ return
509
+
510
+ if show_list:
511
+ console.print("[bold]Configuration:[/bold]")
512
+ console.print()
513
+ for k, v in cfg.all().items():
514
+ console.print(f" {k}: [cyan]{v}[/cyan]")
515
+ return
516
+
517
+ if key and value:
518
+ try:
519
+ cfg.set(key, value)
520
+ display_success(f"Set {key} = {value}")
521
+ except ValueError as e:
522
+ display_error(str(e))
523
+ elif key:
524
+ val = cfg.get(key)
525
+ if val is not None:
526
+ console.print(f"{key}: [cyan]{val}[/cyan]")
527
+ else:
528
+ display_error(f"Unknown configuration key: {key}")
529
+ else:
530
+ # Show help
531
+ console.print("Usage: dsap config [key] [value]")
532
+ console.print(" dsap config --list")
533
+
534
+
535
+ @cli.command()
536
+ @click.option(
537
+ "--problems",
538
+ "-p",
539
+ is_flag=True,
540
+ help="Delete all problems (keeps progress history)",
541
+ )
542
+ @click.option(
543
+ "--progress", "-r", is_flag=True, help="Reset progress only (keeps problems)"
544
+ )
545
+ @click.option(
546
+ "--set", "-s", "problem_set", help="Only reset specific problem set (e.g., blind75)"
547
+ )
548
+ @click.option(
549
+ "--all",
550
+ "-a",
551
+ "reset_all",
552
+ is_flag=True,
553
+ help="Reset everything (problems and progress)",
554
+ )
555
+ @click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt")
556
+ def reset(
557
+ problems: bool,
558
+ progress: bool,
559
+ problem_set: str,
560
+ reset_all: bool,
561
+ yes: bool,
562
+ ) -> None:
563
+ r"""Reset problems and/or progress.
564
+
565
+ \b
566
+ Example:
567
+ dsap reset --all Reset everything
568
+ dsap reset --problems Delete all problems
569
+ dsap reset --progress Reset progress only
570
+ dsap reset -s blind75 --all Reset only Blind 75
571
+ """
572
+ if not (problems or progress or reset_all):
573
+ display_error("Please specify what to reset: --problems, --progress, or --all")
574
+ console.print("\nRun [bold]dsap reset --help[/bold] for options.")
575
+ return
576
+
577
+ db = Database()
578
+
579
+ # Normalize set name
580
+ problem_set = normalize_set_name(problem_set)
581
+
582
+ # Build description of what will be reset
583
+ scope = f"'{problem_set}'" if problem_set else "all"
584
+ actions = []
585
+ if reset_all or problems:
586
+ actions.append("delete problems")
587
+ if reset_all or progress:
588
+ actions.append("reset progress")
589
+
590
+ action_str = " and ".join(actions)
591
+
592
+ # Confirm
593
+ if not yes:
594
+ confirmed = click.confirm(
595
+ f"This will {action_str} for {scope} problems. Continue?",
596
+ default=False,
597
+ )
598
+ if not confirmed:
599
+ display_info("Reset cancelled.")
600
+ return
601
+
602
+ # Perform reset
603
+ if reset_all or progress:
604
+ count = db.reset_progress(problem_set)
605
+ display_success(f"Reset progress for {count} problems")
606
+
607
+ if reset_all or problems:
608
+ count = db.delete_all_problems(problem_set)
609
+ display_success(f"Deleted {count} problems")
610
+
611
+
612
+ def main() -> None:
613
+ """Entry point for the CLI."""
614
+ cli()
615
+
616
+
617
+ if __name__ == "__main__":
618
+ main()