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/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