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 +21 -0
- dsap/__main__.py +9 -0
- dsap/cli.py +618 -0
- dsap/config.py +158 -0
- dsap/data/blind75.yaml +405 -0
- dsap/data/grind75.yaml +401 -0
- dsap/data/neetcode150.yaml +796 -0
- dsap/database.py +774 -0
- dsap/models.py +225 -0
- dsap/problem_sets.py +195 -0
- dsap/py.typed +0 -0
- dsap/sm2.py +521 -0
- dsap/ui.py +445 -0
- dsap_cli-0.1.0.dist-info/METADATA +287 -0
- dsap_cli-0.1.0.dist-info/RECORD +18 -0
- dsap_cli-0.1.0.dist-info/WHEEL +4 -0
- dsap_cli-0.1.0.dist-info/entry_points.txt +2 -0
- dsap_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
dsap/models.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Pydantic models for DSAP.
|
|
2
|
+
|
|
3
|
+
These models define the core data structures used throughout the application.
|
|
4
|
+
Using Pydantic provides:
|
|
5
|
+
- Automatic validation of data types and constraints
|
|
6
|
+
- Clear documentation of fields and their requirements
|
|
7
|
+
- Easy serialization to/from JSON and dictionaries
|
|
8
|
+
- Type hints for better IDE support
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from enum import Enum
|
|
15
|
+
from typing import Annotated, Any
|
|
16
|
+
|
|
17
|
+
from pydantic import BaseModel, Field, HttpUrl, field_validator, model_validator
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class DSAPBaseModel(BaseModel):
|
|
21
|
+
"""Base model with common configuration for all DSAP models."""
|
|
22
|
+
|
|
23
|
+
model_config = {
|
|
24
|
+
"validate_assignment": True,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Difficulty(str, Enum):
|
|
29
|
+
"""Problem difficulty levels.
|
|
30
|
+
|
|
31
|
+
Using an enum ensures only valid difficulties are accepted
|
|
32
|
+
and provides better autocomplete in IDEs.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
EASY = "Easy"
|
|
36
|
+
MEDIUM = "Medium"
|
|
37
|
+
HARD = "Hard"
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def from_string(cls, value: str) -> Difficulty:
|
|
41
|
+
"""Convert a string to Difficulty, case-insensitive."""
|
|
42
|
+
normalized = value.strip().lower()
|
|
43
|
+
mapping: dict[str, Difficulty] = {
|
|
44
|
+
"easy": cls.EASY,
|
|
45
|
+
"medium": cls.MEDIUM,
|
|
46
|
+
"hard": cls.HARD,
|
|
47
|
+
}
|
|
48
|
+
if normalized not in mapping:
|
|
49
|
+
raise ValueError(
|
|
50
|
+
f"Invalid difficulty: {value}. Must be Easy, Medium, or Hard"
|
|
51
|
+
)
|
|
52
|
+
return mapping[normalized]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Problem(DSAPBaseModel):
|
|
56
|
+
"""Represents a DSA problem from a curated problem set."""
|
|
57
|
+
|
|
58
|
+
id: int | None = None
|
|
59
|
+
title: Annotated[str, Field(min_length=1, max_length=200)]
|
|
60
|
+
url: HttpUrl
|
|
61
|
+
difficulty: Difficulty
|
|
62
|
+
category: Annotated[str, Field(min_length=1, max_length=100)]
|
|
63
|
+
description: str = ""
|
|
64
|
+
tags: list[str] = Field(default_factory=list)
|
|
65
|
+
problem_set: str = "custom"
|
|
66
|
+
problem_number: int = Field(default=0, ge=0)
|
|
67
|
+
company_tags: list[str] = Field(default_factory=list)
|
|
68
|
+
hints: list[str] = Field(default_factory=list)
|
|
69
|
+
created_at: datetime | None = None
|
|
70
|
+
|
|
71
|
+
model_config = {
|
|
72
|
+
"str_strip_whitespace": True,
|
|
73
|
+
"validate_assignment": True,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
@field_validator("title", "category")
|
|
77
|
+
@classmethod
|
|
78
|
+
def strip_whitespace(cls, value: str) -> str:
|
|
79
|
+
"""Strip leading/trailing whitespace from string fields."""
|
|
80
|
+
return value.strip()
|
|
81
|
+
|
|
82
|
+
@field_validator("tags", "company_tags", "hints", mode="before")
|
|
83
|
+
@classmethod
|
|
84
|
+
def ensure_list(cls, value: Any) -> list[str]:
|
|
85
|
+
"""Ensure list fields are actually lists of strings."""
|
|
86
|
+
if value is None:
|
|
87
|
+
return []
|
|
88
|
+
if isinstance(value, str):
|
|
89
|
+
return [x.strip() for x in value.split(",") if x.strip()]
|
|
90
|
+
if isinstance(value, list):
|
|
91
|
+
return value
|
|
92
|
+
return [str(value)]
|
|
93
|
+
|
|
94
|
+
@field_validator("difficulty", mode="before")
|
|
95
|
+
@classmethod
|
|
96
|
+
def normalize_difficulty(cls, value: Difficulty | str) -> Difficulty:
|
|
97
|
+
"""Accept difficulty as string or enum."""
|
|
98
|
+
if isinstance(value, Difficulty):
|
|
99
|
+
return value
|
|
100
|
+
return Difficulty.from_string(value)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class ProblemProgress(DSAPBaseModel):
|
|
104
|
+
"""Tracks user's progress on a specific problem with SM-2 state."""
|
|
105
|
+
|
|
106
|
+
problem_id: int
|
|
107
|
+
|
|
108
|
+
# SM-2 state with validation
|
|
109
|
+
easiness_factor: Annotated[float, Field(ge=1.3, le=5.0)] = 2.5
|
|
110
|
+
interval: Annotated[int, Field(ge=0)] = 0
|
|
111
|
+
repetitions: Annotated[int, Field(ge=0)] = 0
|
|
112
|
+
next_review: datetime | None = None
|
|
113
|
+
last_reviewed: datetime | None = None
|
|
114
|
+
|
|
115
|
+
# Practice tracking
|
|
116
|
+
attempts: Annotated[int, Field(ge=0)] = 0
|
|
117
|
+
solved: bool = False
|
|
118
|
+
first_attempted: datetime | None = None
|
|
119
|
+
solved_at: datetime | None = None
|
|
120
|
+
last_quality: Annotated[int, Field(ge=0, le=5)] | None = None
|
|
121
|
+
notes: str = ""
|
|
122
|
+
time_spent_minutes: Annotated[int, Field(ge=0)] = 0
|
|
123
|
+
|
|
124
|
+
@model_validator(mode="after")
|
|
125
|
+
def validate_dates(self) -> ProblemProgress:
|
|
126
|
+
"""Ensure date fields are logically consistent."""
|
|
127
|
+
if (
|
|
128
|
+
self.solved_at
|
|
129
|
+
and self.first_attempted
|
|
130
|
+
and self.solved_at < self.first_attempted
|
|
131
|
+
):
|
|
132
|
+
raise ValueError("solved_at cannot be before first_attempted")
|
|
133
|
+
|
|
134
|
+
if self.solved and self.solved_at is None:
|
|
135
|
+
self.solved_at = datetime.now()
|
|
136
|
+
return self
|
|
137
|
+
|
|
138
|
+
def is_due(self, now: datetime | None = None) -> bool:
|
|
139
|
+
"""Check if this problem is due for review."""
|
|
140
|
+
if now is None:
|
|
141
|
+
now = datetime.now()
|
|
142
|
+
if self.next_review is None:
|
|
143
|
+
return True
|
|
144
|
+
return now >= self.next_review
|
|
145
|
+
|
|
146
|
+
def is_new(self) -> bool:
|
|
147
|
+
"""Check if this problem has never been attempted."""
|
|
148
|
+
return self.attempts == 0
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class ReviewSession(DSAPBaseModel):
|
|
152
|
+
"""Represents a single practice/review session."""
|
|
153
|
+
|
|
154
|
+
id: int | None = None
|
|
155
|
+
date: datetime = Field(default_factory=datetime.now)
|
|
156
|
+
problems_reviewed: Annotated[int, Field(ge=0)] = 0
|
|
157
|
+
problems_due: Annotated[int, Field(ge=0)] = 0
|
|
158
|
+
average_quality: Annotated[float, Field(ge=0, le=5)] = 0.0
|
|
159
|
+
duration_minutes: Annotated[int, Field(ge=0)] = 0
|
|
160
|
+
notes: str = ""
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class Statistics(DSAPBaseModel):
|
|
164
|
+
"""Aggregated statistics for display."""
|
|
165
|
+
|
|
166
|
+
total_problems: int = 0
|
|
167
|
+
reviewed_problems: int = 0
|
|
168
|
+
solved_problems: int = 0
|
|
169
|
+
due_today: int = 0
|
|
170
|
+
due_this_week: int = 0
|
|
171
|
+
average_easiness_factor: float = 2.5
|
|
172
|
+
current_streak: int = 0
|
|
173
|
+
best_streak: int = 0
|
|
174
|
+
total_reviews: int = 0
|
|
175
|
+
|
|
176
|
+
# Breakdown by difficulty
|
|
177
|
+
easy_total: int = 0
|
|
178
|
+
easy_solved: int = 0
|
|
179
|
+
medium_total: int = 0
|
|
180
|
+
medium_solved: int = 0
|
|
181
|
+
hard_total: int = 0
|
|
182
|
+
hard_solved: int = 0
|
|
183
|
+
|
|
184
|
+
@property
|
|
185
|
+
def solved_percentage(self) -> float:
|
|
186
|
+
"""Calculate overall solved percentage."""
|
|
187
|
+
if self.total_problems == 0:
|
|
188
|
+
return 0.0
|
|
189
|
+
return round(self.solved_problems / self.total_problems * 100, 1)
|
|
190
|
+
|
|
191
|
+
@property
|
|
192
|
+
def easy_percentage(self) -> float:
|
|
193
|
+
"""Calculate Easy solved percentage."""
|
|
194
|
+
if self.easy_total == 0:
|
|
195
|
+
return 0.0
|
|
196
|
+
return round(self.easy_solved / self.easy_total * 100, 1)
|
|
197
|
+
|
|
198
|
+
@property
|
|
199
|
+
def medium_percentage(self) -> float:
|
|
200
|
+
"""Calculate Medium solved percentage."""
|
|
201
|
+
if self.medium_total == 0:
|
|
202
|
+
return 0.0
|
|
203
|
+
return round(self.medium_solved / self.medium_total * 100, 1)
|
|
204
|
+
|
|
205
|
+
@property
|
|
206
|
+
def hard_percentage(self) -> float:
|
|
207
|
+
"""Calculate Hard solved percentage."""
|
|
208
|
+
if self.hard_total == 0:
|
|
209
|
+
return 0.0
|
|
210
|
+
return round(self.hard_solved / self.hard_total * 100, 1)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class Config(DSAPBaseModel):
|
|
214
|
+
"""User configuration settings.
|
|
215
|
+
|
|
216
|
+
Stored in ~/.dsap/config.json
|
|
217
|
+
"""
|
|
218
|
+
|
|
219
|
+
daily_goal: Annotated[int, Field(ge=1, le=100)] = 5
|
|
220
|
+
preferred_difficulty: Difficulty | None = None
|
|
221
|
+
preferred_set: str | None = None
|
|
222
|
+
favorite_categories: list[str] = Field(default_factory=list)
|
|
223
|
+
show_hints: bool = True
|
|
224
|
+
auto_open_browser: bool = True
|
|
225
|
+
theme: str = "default"
|
dsap/problem_sets.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Problem Set Loader for DSAP.
|
|
2
|
+
|
|
3
|
+
Handles loading problems from:
|
|
4
|
+
- Bundled YAML files (Blind 75, NeetCode 150, Grind 75)
|
|
5
|
+
- Custom user YAML files
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
from importlib import resources
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import yaml
|
|
14
|
+
|
|
15
|
+
from dsap.models import Difficulty, Problem
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# Bundled problem sets
|
|
22
|
+
BUNDLED_SETS: dict[str, str] = {
|
|
23
|
+
"blind75": "blind75.yaml",
|
|
24
|
+
"neetcode150": "neetcode150.yaml",
|
|
25
|
+
"grind75": "grind75.yaml",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
# Descriptions for listing
|
|
29
|
+
SET_DESCRIPTIONS: dict[str, str] = {
|
|
30
|
+
"blind75": "Blind 75 - The essential 75 LeetCode problems",
|
|
31
|
+
"neetcode150": "NeetCode 150 - Extended curated problem set",
|
|
32
|
+
"grind75": "Grind 75 - Flexible 75-question study plan",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def list_bundled_sets() -> dict[str, str]:
|
|
37
|
+
"""List available bundled problem sets.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
Dict mapping set name to description
|
|
41
|
+
"""
|
|
42
|
+
return SET_DESCRIPTIONS.copy()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_bundled_path(set_name: str) -> Path:
|
|
46
|
+
"""Get the path to a bundled problem set file.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
set_name: Name of the set (e.g., "blind75")
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
Path to the YAML file
|
|
53
|
+
|
|
54
|
+
Raises:
|
|
55
|
+
ValueError: If set_name is not a valid bundled set
|
|
56
|
+
"""
|
|
57
|
+
if set_name not in BUNDLED_SETS:
|
|
58
|
+
available = ", ".join(BUNDLED_SETS.keys())
|
|
59
|
+
raise ValueError(f"Unknown problem set: '{set_name}'. Available: {available}")
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
files = resources.files("dsap.data")
|
|
63
|
+
path = files.joinpath(BUNDLED_SETS[set_name])
|
|
64
|
+
|
|
65
|
+
with resources.as_file(path) as p:
|
|
66
|
+
return Path(p)
|
|
67
|
+
except (TypeError, AttributeError):
|
|
68
|
+
data_dir = Path(__file__).parent / "data"
|
|
69
|
+
return data_dir / BUNDLED_SETS[set_name]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def load_problem_set(source: str) -> list[Problem]:
|
|
73
|
+
"""Load problems from a YAML file or bundled set name.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
source: Either a bundled set name (e.g., "blind75")
|
|
77
|
+
or a path to a custom YAML file
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
List of Problem objects
|
|
81
|
+
|
|
82
|
+
Raises:
|
|
83
|
+
ValueError: If source is not valid
|
|
84
|
+
FileNotFoundError: If file doesn't exist
|
|
85
|
+
"""
|
|
86
|
+
# Check if it's a bundled set name
|
|
87
|
+
if source.lower() in BUNDLED_SETS:
|
|
88
|
+
path = get_bundled_path(source.lower())
|
|
89
|
+
else:
|
|
90
|
+
path = Path(source)
|
|
91
|
+
|
|
92
|
+
if not path.exists():
|
|
93
|
+
raise FileNotFoundError(f"Problem set file not found: {path}")
|
|
94
|
+
|
|
95
|
+
with open(path, encoding="utf-8") as f:
|
|
96
|
+
data = yaml.safe_load(f)
|
|
97
|
+
|
|
98
|
+
return parse_problem_set(data)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def parse_problem_set(data: dict[str, Any]) -> list[Problem]:
|
|
102
|
+
"""Parse YAML data structure into Problem objects.
|
|
103
|
+
|
|
104
|
+
Expected YAML structure:
|
|
105
|
+
```yaml
|
|
106
|
+
metadata:
|
|
107
|
+
name: "Set Name"
|
|
108
|
+
description: "Description"
|
|
109
|
+
categories:
|
|
110
|
+
- name: "Category Name"
|
|
111
|
+
problems:
|
|
112
|
+
- title: "Problem Title"
|
|
113
|
+
url: "https://..."
|
|
114
|
+
difficulty: "Easy"
|
|
115
|
+
...
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
data: Parsed YAML data
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
List of Problem objects
|
|
123
|
+
"""
|
|
124
|
+
problems: list[Problem] = []
|
|
125
|
+
|
|
126
|
+
# Get metadata
|
|
127
|
+
metadata = data.get("metadata", {})
|
|
128
|
+
set_name = metadata.get("name", "custom")
|
|
129
|
+
|
|
130
|
+
# Track problem number across all categories
|
|
131
|
+
problem_number = 1
|
|
132
|
+
|
|
133
|
+
# Parse each category
|
|
134
|
+
for category in data.get("categories", []):
|
|
135
|
+
category_name = category.get("name", "Uncategorized")
|
|
136
|
+
|
|
137
|
+
for prob_data in category.get("problems", []):
|
|
138
|
+
try:
|
|
139
|
+
problem = Problem(
|
|
140
|
+
title=prob_data["title"],
|
|
141
|
+
url=prob_data["url"],
|
|
142
|
+
difficulty=Difficulty.from_string(prob_data["difficulty"]),
|
|
143
|
+
category=category_name,
|
|
144
|
+
description=prob_data.get("description", ""),
|
|
145
|
+
tags=prob_data.get("tags", []),
|
|
146
|
+
problem_set=set_name,
|
|
147
|
+
problem_number=problem_number,
|
|
148
|
+
company_tags=prob_data.get("company_tags", []),
|
|
149
|
+
hints=prob_data.get("hints", []),
|
|
150
|
+
)
|
|
151
|
+
problems.append(problem)
|
|
152
|
+
problem_number += 1
|
|
153
|
+
except (KeyError, ValueError, TypeError) as e:
|
|
154
|
+
title = prob_data.get("title", "Unknown")
|
|
155
|
+
logger.warning("Failed to parse problem '%s': %s", title, e)
|
|
156
|
+
|
|
157
|
+
return problems
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def create_custom_set(
|
|
161
|
+
name: str,
|
|
162
|
+
problems: list[dict[str, Any]],
|
|
163
|
+
output_path: Path,
|
|
164
|
+
) -> None:
|
|
165
|
+
"""Create a custom problem set YAML file.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
name: Name of the problem set
|
|
169
|
+
problems: List of problem dictionaries
|
|
170
|
+
output_path: Where to save the YAML file
|
|
171
|
+
"""
|
|
172
|
+
# Group problems by category
|
|
173
|
+
by_category: dict[str, list[dict[str, Any]]] = {}
|
|
174
|
+
for prob in problems:
|
|
175
|
+
cat = prob.get("category", "Custom")
|
|
176
|
+
if cat not in by_category:
|
|
177
|
+
by_category[cat] = []
|
|
178
|
+
by_category[cat].append(prob)
|
|
179
|
+
|
|
180
|
+
# Build YAML structure
|
|
181
|
+
data = {
|
|
182
|
+
"metadata": {
|
|
183
|
+
"name": name,
|
|
184
|
+
"description": f"Custom problem set: {name}",
|
|
185
|
+
"total_problems": len(problems),
|
|
186
|
+
},
|
|
187
|
+
"categories": [
|
|
188
|
+
{"name": cat, "problems": probs} for cat, probs in by_category.items()
|
|
189
|
+
],
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
# Write to file
|
|
193
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
194
|
+
with open(output_path, "w", encoding="utf-8") as f:
|
|
195
|
+
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
dsap/py.typed
ADDED
|
File without changes
|