Repository: open-spaced-repetition/py-fsrs

Issue:
[Feature request]: Make Card objects JSON serializable
Up till now, I've been using python's pickle module to save my Card objects as files in a blob storage, but I'd like to move them into a no-sql database instead.

The problem however, is that there's currently no good way to serialize Card objects to be stored in (and then later reconstructed from) a database. I originally looked into just storing my Card objects as byte strings with `pickle.dumps(card_object)`, but this is apparently bad practice as one's application can be harder to debug and the pickled object can now only be reconstructed in python and not in other languages.

I implemented a feature today in my fork of py-fsrs that can make Card objects JSON serializable in a backwards-compatible way (no breaking changes this time!). Am I good to open a PR for this?

Hints from issue comments:
@L-M-Sherlock: PR is welcome!@joshdavham: PR #33 

Solution patch:
diff --git a/pyproject.toml b/pyproject.toml
index c93544b..8ab5d64 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
 
 [project]
 name = "fsrs"
-version = "2.0.0"
+version = "2.1.0"
 description = "Free Spaced Repetition Scheduler"
 readme = "README.md"
 authors = [{ name = "Jarrett Ye", email = "jarrett.ye@outlook.com" }]
diff --git a/src/fsrs/__init__.py b/src/fsrs/__init__.py
index 0115358..dafda40 100644
--- a/src/fsrs/__init__.py
+++ b/src/fsrs/__init__.py
@@ -1,4 +1,4 @@
-__version__ = "2.0.0"
+__version__ = "2.1.0"
 
 from .fsrs import FSRS, Card
 
diff --git a/src/fsrs/models.py b/src/fsrs/models.py
index 5bf9612..39fc902 100644
--- a/src/fsrs/models.py
+++ b/src/fsrs/models.py
@@ -51,15 +51,81 @@ class Card:
     state: State
     last_review: datetime
 
-    def __init__(self) -> None:
-        self.due = datetime.now(UTC)
-        self.stability = 0
-        self.difficulty = 0
-        self.elapsed_days = 0
-        self.scheduled_days = 0
-        self.reps = 0
-        self.lapses = 0
-        self.state = State.New
+    def __init__(
+        self,
+        due=None,
+        stability=0,
+        difficulty=0,
+        elapsed_days=0,
+        scheduled_days=0,
+        reps=0,
+        lapses=0,
+        state=State.New,
+        last_review=None,
+    ) -> None:
+
+        if due is None:
+            self.due = datetime.now(UTC)
+        else:
+            self.due = due
+
+        self.stability = stability
+        self.difficulty = difficulty
+        self.elapsed_days = elapsed_days
+        self.scheduled_days = scheduled_days
+        self.reps = reps
+        self.lapses = lapses
+        self.state = state
+
+        if last_review is not None:
+            self.last_review = last_review
+
+    def to_dict(self):
+
+        return_dict = {
+            "due": self.due.isoformat(),
+            "stability": self.stability,
+            "difficulty": self.difficulty,
+            "elapsed_days": self.elapsed_days,
+            "scheduled_days": self.scheduled_days,
+            "reps": self.reps,
+            "lapses": self.lapses,
+            "state": self.state,
+        }
+
+        if hasattr(self, "last_review"):
+            return_dict["last_review"] = self.last_review.isoformat()
+
+        return return_dict
+
+    @staticmethod
+    def from_dict(source_dict):
+
+        due = datetime.fromisoformat(source_dict["due"])
+        stability = source_dict["stability"]
+        difficulty = source_dict["difficulty"]
+        elapsed_days = source_dict["elapsed_days"]
+        scheduled_days = source_dict["scheduled_days"]
+        reps = source_dict["reps"]
+        lapses = source_dict["lapses"]
+        state = source_dict["state"]
+
+        if "last_review" in source_dict:
+            last_review = datetime.fromisoformat(source_dict["last_review"])
+        else:
+            last_review = None
+
+        return Card(
+            due,
+            stability,
+            difficulty,
+            elapsed_days,
+            scheduled_days,
+            reps,
+            lapses,
+            state,
+            last_review,
+        )
 
     def get_retrievability(self, now: datetime) -> Optional[float]:
         DECAY = -0.5


Test patch:
diff --git a/tests/test_fsrs.py b/tests/test_fsrs.py
index 8d58494..df2b1d1 100644
--- a/tests/test_fsrs.py
+++ b/tests/test_fsrs.py
@@ -1,5 +1,6 @@
 from fsrs import *
 from datetime import datetime, UTC
+import json
 import pytest
 
 
@@ -96,3 +97,43 @@ class TestPyFSRS:
         assert card.last_review.tzinfo == UTC
         # card object's due datetime should be later than its last review
         assert card.due >= card.last_review
+
+    def test_serialize(self):
+
+        f = FSRS()
+
+        # create card object the normal way
+        card = Card()
+
+        # card object is not naturally JSON serializable
+        with pytest.raises(TypeError):
+            json.dumps(card.__dict__)
+
+        # card object's to_dict() method makes it JSON serializable
+        assert type(json.dumps(card.to_dict())) == str
+
+        # we can reconstruct a copy of the card object equivalent to the original
+        card_dict = card.to_dict()
+        copied_card = Card.from_dict(card_dict)
+
+        assert vars(card) == vars(copied_card)
+        assert card.to_dict() == copied_card.to_dict()
+
+        # (x2) perform the above tests once more with a repeated card
+        scheduling_cards = f.repeat(card, datetime.now(UTC))
+        repeated_card = scheduling_cards[Rating.Good].card
+
+        with pytest.raises(TypeError):
+            json.dumps(repeated_card.__dict__)
+
+        assert type(json.dumps(repeated_card.to_dict())) == str
+
+        repeated_card_dict = repeated_card.to_dict()
+        copied_repeated_card = Card.from_dict(repeated_card_dict)
+
+        assert vars(repeated_card) == vars(copied_repeated_card)
+        assert repeated_card.to_dict() == copied_repeated_card.to_dict()
+
+        # original card and repeated card are different
+        assert vars(card) != vars(repeated_card)
+        assert card.to_dict() != repeated_card.to_dict()