dsap-cli 1.1.2__tar.gz → 1.1.4__tar.gz

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.
Files changed (32) hide show
  1. {dsap_cli-1.1.2 → dsap_cli-1.1.4}/CHANGELOG.md +13 -0
  2. {dsap_cli-1.1.2 → dsap_cli-1.1.4}/PKG-INFO +1 -1
  3. {dsap_cli-1.1.2 → dsap_cli-1.1.4}/src/dsap/_version.py +2 -2
  4. {dsap_cli-1.1.2 → dsap_cli-1.1.4}/src/dsap/database.py +22 -11
  5. {dsap_cli-1.1.2 → dsap_cli-1.1.4}/src/dsap/models.py +4 -1
  6. {dsap_cli-1.1.2 → dsap_cli-1.1.4}/src/dsap/sm2.py +1 -2
  7. {dsap_cli-1.1.2 → dsap_cli-1.1.4}/src/dsap/ui.py +5 -5
  8. {dsap_cli-1.1.2 → dsap_cli-1.1.4}/tests/test_models.py +8 -6
  9. {dsap_cli-1.1.2 → dsap_cli-1.1.4}/.github/workflows/ci.yml +0 -0
  10. {dsap_cli-1.1.2 → dsap_cli-1.1.4}/.gitignore +0 -0
  11. {dsap_cli-1.1.2 → dsap_cli-1.1.4}/.pre-commit-config.yaml +0 -0
  12. {dsap_cli-1.1.2 → dsap_cli-1.1.4}/LICENSE +0 -0
  13. {dsap_cli-1.1.2 → dsap_cli-1.1.4}/README.md +0 -0
  14. {dsap_cli-1.1.2 → dsap_cli-1.1.4}/USER_GUIDE.md +0 -0
  15. {dsap_cli-1.1.2 → dsap_cli-1.1.4}/assets/dsap-cli.gif +0 -0
  16. {dsap_cli-1.1.2 → dsap_cli-1.1.4}/pyproject.toml +0 -0
  17. {dsap_cli-1.1.2 → dsap_cli-1.1.4}/src/dsap/__init__.py +0 -0
  18. {dsap_cli-1.1.2 → dsap_cli-1.1.4}/src/dsap/__main__.py +0 -0
  19. {dsap_cli-1.1.2 → dsap_cli-1.1.4}/src/dsap/cli.py +0 -0
  20. {dsap_cli-1.1.2 → dsap_cli-1.1.4}/src/dsap/config.py +0 -0
  21. {dsap_cli-1.1.2 → dsap_cli-1.1.4}/src/dsap/data/blind75.yaml +0 -0
  22. {dsap_cli-1.1.2 → dsap_cli-1.1.4}/src/dsap/data/grind75.yaml +0 -0
  23. {dsap_cli-1.1.2 → dsap_cli-1.1.4}/src/dsap/data/neetcode150.yaml +0 -0
  24. {dsap_cli-1.1.2 → dsap_cli-1.1.4}/src/dsap/problem_sets.py +0 -0
  25. {dsap_cli-1.1.2 → dsap_cli-1.1.4}/src/dsap/py.typed +0 -0
  26. {dsap_cli-1.1.2 → dsap_cli-1.1.4}/tests/__init__.py +0 -0
  27. {dsap_cli-1.1.2 → dsap_cli-1.1.4}/tests/test_cli.py +0 -0
  28. {dsap_cli-1.1.2 → dsap_cli-1.1.4}/tests/test_config.py +0 -0
  29. {dsap_cli-1.1.2 → dsap_cli-1.1.4}/tests/test_database.py +0 -0
  30. {dsap_cli-1.1.2 → dsap_cli-1.1.4}/tests/test_problem_sets.py +0 -0
  31. {dsap_cli-1.1.2 → dsap_cli-1.1.4}/tests/test_sm2.py +0 -0
  32. {dsap_cli-1.1.2 → dsap_cli-1.1.4}/uv.lock +0 -0
@@ -2,6 +2,18 @@
2
2
 
3
3
  All notable changes to DSAP will be documented here.
4
4
 
5
+ ## [1.1.3] - 2026-05-08
6
+
7
+ ### Fixed
8
+
9
+ - `dsap list` crashed with `ValidationError: solved_at cannot be before
10
+ first_attempted` for users who reviewed problems before v1.1.0. Root cause:
11
+ v1.1.0 back-filled `first_attempted` with the current timestamp on the next
12
+ review, which landed after the already-stored `solved_at`. Fix: the validator
13
+ now coerces `first_attempted` to `solved_at` instead of raising; a one-time
14
+ migration runs on startup to correct existing rows; and the UPDATE query
15
+ no longer sets `first_attempted` later than `solved_at`.
16
+
5
17
  ## [1.1.2] - 2026-05-08
6
18
 
7
19
  ### Fixed
@@ -98,6 +110,7 @@ Initial release.
98
110
 
99
111
  ---
100
112
 
113
+ [1.1.3]: https://github.com/juma-paul/dsap-cli/compare/v1.1.2...v1.1.3
101
114
  [1.1.2]: https://github.com/juma-paul/dsap-cli/compare/v1.1.1...v1.1.2
102
115
  [1.1.1]: https://github.com/juma-paul/dsap-cli/compare/v1.1.0...v1.1.1
103
116
  [1.1.0]: https://github.com/juma-paul/dsap-cli/compare/v1.0.2...v1.1.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dsap-cli
3
- Version: 1.1.2
3
+ Version: 1.1.4
4
4
  Summary: CLI tool for practicing data structures and algorithms using spaced repetition.
5
5
  Project-URL: Repository, https://github.com/juma-paul/dsap-cli
6
6
  Project-URL: Issues, https://github.com/juma-paul/dsap-cli/issues
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '1.1.2'
22
- __version_tuple__ = version_tuple = (1, 1, 2)
21
+ __version__ = version = '1.1.4'
22
+ __version_tuple__ = version_tuple = (1, 1, 4)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -12,7 +12,7 @@ import os
12
12
  import sqlite3
13
13
  from collections.abc import Iterator
14
14
  from contextlib import contextmanager
15
- from datetime import datetime, timedelta
15
+ from datetime import date, datetime, timedelta
16
16
  from pathlib import Path
17
17
  from typing import Any
18
18
 
@@ -141,6 +141,16 @@ class Database:
141
141
  ON sessions(date);
142
142
  """)
143
143
 
144
+ # Fix rows where first_attempted was back-filled after solved_at
145
+ # (introduced by v1.1.0 when first_attempted was NULL for old records)
146
+ conn.execute("""
147
+ UPDATE progress
148
+ SET first_attempted = solved_at
149
+ WHERE first_attempted IS NOT NULL
150
+ AND solved_at IS NOT NULL
151
+ AND first_attempted > solved_at
152
+ """)
153
+
144
154
  # ==================== Problem Operations ====================
145
155
 
146
156
  def add_problem(self, problem: Problem) -> int:
@@ -241,9 +251,9 @@ class Database:
241
251
  params.append(problem_set)
242
252
 
243
253
  if due_only:
244
- now = datetime.now().isoformat()
245
- conditions.append("(pr.next_review <= ? OR pr.next_review IS NULL)")
246
- params.append(now)
254
+ today = datetime.now().date().isoformat()
255
+ conditions.append("(DATE(pr.next_review) <= ? OR pr.next_review IS NULL)")
256
+ params.append(today)
247
257
 
248
258
  where_clause = "WHERE " + " AND ".join(conditions) if conditions else ""
249
259
  params.append(limit)
@@ -472,8 +482,9 @@ class Database:
472
482
  attempts = attempts + 1,
473
483
  solved = CASE WHEN ? >= 3 THEN 1 ELSE solved END,
474
484
  first_attempted = CASE
475
- WHEN first_attempted IS NULL THEN ?
476
- ELSE first_attempted
485
+ WHEN first_attempted IS NOT NULL THEN first_attempted
486
+ WHEN solved_at IS NOT NULL THEN solved_at
487
+ ELSE ?
477
488
  END,
478
489
  solved_at = CASE
479
490
  WHEN ? >= 3 AND solved = 0 THEN ?
@@ -566,21 +577,21 @@ class Database:
566
577
  """).fetchone()
567
578
 
568
579
  # Due counts
569
- now = datetime.now().isoformat()
570
- week_later = (datetime.now() + timedelta(days=7)).isoformat()
580
+ today = date.today().isoformat()
581
+ week_later = (date.today() + timedelta(days=7)).isoformat()
571
582
 
572
583
  due_today = conn.execute(
573
584
  """
574
585
  SELECT COUNT(*) FROM progress
575
- WHERE next_review <= ? OR next_review IS NULL
586
+ WHERE DATE(next_review) <= ? OR next_review IS NULL
576
587
  """,
577
- (now,),
588
+ (today,),
578
589
  ).fetchone()[0]
579
590
 
580
591
  due_week = conn.execute(
581
592
  """
582
593
  SELECT COUNT(*) FROM progress
583
- WHERE next_review <= ?
594
+ WHERE DATE(next_review) <= ? OR next_review IS NULL
584
595
  """,
585
596
  (week_later,),
586
597
  ).fetchone()[0]
@@ -129,7 +129,10 @@ class ProblemProgress(DSAPBaseModel):
129
129
  and self.first_attempted
130
130
  and self.solved_at < self.first_attempted
131
131
  ):
132
- raise ValueError("solved_at cannot be before first_attempted")
132
+ # Coerce: first_attempted cannot be later than solved_at.
133
+ # This can happen with data written by older versions where
134
+ # first_attempted was back-filled after solved_at was set.
135
+ self.first_attempted = self.solved_at
133
136
 
134
137
  if self.solved and self.solved_at is None:
135
138
  self.solved_at = datetime.now()
@@ -252,8 +252,7 @@ class SM2State:
252
252
  now = datetime.now()
253
253
  if self.next_review is None:
254
254
  return 0
255
- delta = self.next_review - now
256
- return delta.days
255
+ return (self.next_review.date() - now.date()).days
257
256
 
258
257
 
259
258
  def calculate_easiness_factor(current_ef: float, quality: int) -> float:
@@ -8,7 +8,7 @@ Provides beautiful terminal output using Rich library:
8
8
  """
9
9
 
10
10
  import webbrowser
11
- from datetime import datetime
11
+ from datetime import date
12
12
 
13
13
  from rich import box
14
14
  from rich.console import Console
@@ -137,8 +137,7 @@ def display_problem(
137
137
  f" EF: {progress.easiness_factor:.2f} | Interval: {progress.interval} days"
138
138
  )
139
139
  if progress.next_review:
140
- now = datetime.now()
141
- delta = (progress.next_review - now).days
140
+ delta = (progress.next_review.date() - date.today()).days
142
141
  if delta < 0:
143
142
  due_str = f"Overdue by {abs(delta)} day{'s' if abs(delta) != 1 else ''}"
144
143
  elif delta == 0:
@@ -372,12 +371,13 @@ def display_problem_list(
372
371
  ef = "-"
373
372
 
374
373
  if progress and progress.next_review:
375
- now = datetime.now()
376
- delta = (progress.next_review - now).days
374
+ delta = (progress.next_review.date() - date.today()).days
377
375
  if delta < 0:
378
376
  due_text = f"[red]{abs(delta)}d late[/red]"
379
377
  elif delta == 0:
380
378
  due_text = "[yellow]today[/yellow]"
379
+ elif delta == 1:
380
+ due_text = "[dim]tomorrow[/dim]"
381
381
  else:
382
382
  due_text = f"[dim]in {delta}d[/dim]"
383
383
  else:
@@ -194,15 +194,17 @@ class TestProblemProgress:
194
194
  make_progress(last_quality=-1)
195
195
 
196
196
  def test_solved_date_validation(self) -> None:
197
+ # When solved_at is before first_attempted, first_attempted is coerced
198
+ # to match solved_at rather than raising — supports legacy data.
197
199
  first = datetime.now()
198
200
  before_first = first - timedelta(days=1)
199
201
 
200
- with pytest.raises(ValueError, match="solved_at cannot be before"):
201
- make_progress(
202
- first_attempted=first,
203
- solved_at=before_first,
204
- solved=True,
205
- )
202
+ progress = make_progress(
203
+ first_attempted=first,
204
+ solved_at=before_first,
205
+ solved=True,
206
+ )
207
+ assert progress.first_attempted == before_first
206
208
 
207
209
  def test_solved_sets_solved_at(self) -> None:
208
210
  progress = make_progress(solved=True)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes