diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 616d723..4d3df16 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,39 +1,42 @@ name: CI on: push: - branches: ["master"] + branches: ["main"] pull_request: - branches: ["master"] + branches: ["main"] jobs: test: runs-on: ${{ matrix.os }} strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: [3.5, 3.6, 3.7, 3.8, 3.9] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Setup Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Generate coverage report run: | python -m pip install --upgrade pip - pip install pytest + pip install -r requirements.txt pytest upload_coverage: runs-on: macos-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Setup Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: - python-version: '3.9' + python-version: "3.10" - name: Generate coverage report run: | python -m pip install --upgrade pip - pip install pytest-cov + pip install -r requirements.txt pytest --cov=supermemo2 --cov-report=xml - name: "Upload coverage to Codecov" - uses: codecov/codecov-action@v1 \ No newline at end of file + uses: codecov/codecov-action@v4 + with: + fail_ci_if_error: true + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/README.md b/README.md index 80ef08c..0e1c7ca 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # SuperMemo2 -![Python](https://img.shields.io/badge/python-3+-blue.svg?logo=python&longCache=true&logoColor=white&colorB=5e81ac&style=flat-square&colorA=4c566a) +![Python](https://img.shields.io/badge/python-3.8+-blue.svg?logo=python&longCache=true&logoColor=white&colorB=5e81ac&style=flat-square&colorA=4c566a) [![Version](https://img.shields.io/pypi/v/supermemo2?logo=pypi&logoColor=white&style=flat-square&colorA=4c566a&colorB=90A2BC)](https://pypi.org/project/supermemo2/) [![Build](https://img.shields.io/github/workflow/status/alankan886/SuperMemo2/CI?logo=github-actions&logoColor=white&style=flat-square&colorA=4c566a&colorB=90BCA8)](https://github.com/alankan886/SuperMemo2/actions?query=workflow%3ACI) [![Coverage](https://img.shields.io/codecov/c/github/alankan886/SuperMemo2?logo=codecov&logoColor=white&style=flat-square&colorA=4c566a&colorB=90BCA8)](https://codecov.io/gh/alankan886/SuperMemo2) @@ -35,7 +35,7 @@ The goal was to have an efficient way to calculate the next review date for stud Install and upate the package using [pip](https://pip.pypa.io/en/stable/quickstart/): ```bash -pip3 install -U supermemo2 +pip install -U supermemo2 ``` @@ -49,26 +49,26 @@ git clone https://github.com/alankan886/SuperMemo2.git Install dependencies to run the code: ```bash -pip3 install -r requirements.txt +pip install -r requirements.txt ``` -supermemo2 supports Python 3+ +supermemo2 supports Python 3.8+ ## A Simple Example ```python -from supermemo2 import SMTwo +from supermemo2 import first_review, review # first review # using quality=4 as an example, read below for what each value from 0 to 5 represents -# review date would default to date.today() if not provided -review = SMTwo.first_review(4, "2021-3-14") -# review prints SMTwo(easiness=2.36, interval=1, repetitions=1, review_date=datetime.date(2021, 3, 15)) +# review date would default to datetime.utcnow() (UTC timezone) if not provided +first_review = first_review(4, "2024-06-22") +# review prints { "easiness": 2.36, "interval": 1, "repetitions": 1, "review_datetime": "2024-06-23 01:06:02")) # second review -review = SMTwo(review.easiness, review.interval, review.repetitions).review(4, "2021-3-14") +second_review = review(4, first_review["easiness"], first_review["interval"], first_review["repetitions"], first_review["review_datetime"]) # review prints similar to example above. ``` @@ -104,78 +104,67 @@ The values are the: ## Code Reference -### *class* supermemo2.SMTwo(easiness, interval, repetitions) +**first_review(** quality, review_datetime=None**)** -**Parameters:** -- easiness (float) - the easiness determines the interval. -- interval (int) - the interval between the latest review date and the next review date. -- repetitions (int) - the count of consecutive reviews with quality larger than 2. - -
- -**first_review(** quality, review_date=None, date_fmt=None **)** - -      Static method that calcualtes the next review date for the first review without having to know the initial values, and returns a dictionary containing the new values. +      function that calcualtes the next review datetime for the your first review without having to know the initial values, and returns a dictionary containing the new values. **Parameters:** - quality (int) - the recall quality of the review. -- review_date (str or datetime.date) - optional parameter, the date of the review. -- date_fmt (string) - optional parameter, the format of the review_date. Formats like `year_mon_day`, `mon_day_year` and `day_mon_year`. +- review_datetime (str or datetime.datetime) - optional parameter, the datetime in ISO format up to seconds in UTC timezone of the review. -**Returns:** dictionary containing values like quality, easiness, interval, repetitions and review_date. +**Returns:** dictionary containing values like quality, easiness, interval, repetitions and review_datetime. **Return Type:** Dict **Usage:** ```python -from supermemo2 import SMTwo, mon_day_year -# using default date date.today() -SMTwo.first_review(3) +from supermemo2 import first_review +# using default datetime.utcnow() if you just reviewed it +first_review(3) # providing string date in Year-Month-Day format -SMTwo.first_review(3, "2021-12-01") - -# providing string date in Month-Day-Year format -SMTwo.first_review(3, "12-01-2021", mon_day_year) +first_review(3, "2024-06-22") # providing date object date -from datetime import date -d = date(2021, 12, 1) -SMTwo.first_review(3, d) +from datetime import datetime +d = datetime(2024, 1, 1) +first_review(3, d) ``` -**review(** quality, review_date=None, date_fmt=None **)** +**review(** quality, easiness, interval, repetitions, review_datetime=None **)**       Calcualtes the next review date based on previous values, and returns a dictionary containing the new values. **Parameters:** - quality (int) - the recall quality of the review. -- review_date (str or datetime.date) - optional parameter, the date of the review. -- date_fmt (string) - optional parameter, the format of the review_date. Formats like `year_mon_day`, `mon_day_year` and `day_mon_year`. +- easiness (float) - the easiness determines the interval. +- interval (int) - the interval between the latest review date and the next review date. +- repetitions (int) - the count of consecutive reviews with quality larger than 2. +- review_datetime (str or datetime.datetime) - optional parameter, the datetime in ISO format up to seconds in UTC timezone of the review. -**Returns:** dictionary containing values like quality, easiness, interval, repetitions and review_date. +**Returns:** dictionary containing values like quality, easiness, interval, repetitions and review_datetime. **Return Type:** Dict **Usage:** ```python -from supermemo2 import SMTwo, mon_day_year +from supermemo2 import first_review, review # using previous values from first_review call -r = SMTwo.first_review(3) +r = first_review(3) -# using default date date.today() -SMTwo(r.easiness, r.interval, r.repetitions).review(3) +# using default datetime.utcnow() if you just reviewed it +review(3, r["easiness"], r["interval"], r["repetitions"]) -# providing string date in Year-Month-Day format -SMTwo(r.easiness, r.interval, r.repetitions).review(3, "2021-12-01") +# providing review_datetime from previous review +review(3, r["easiness"], r["interval"], r["repetitions"], r["review_datetime"]) -# providing string date in Month-Day-Year format -SMTwo(r.easiness, r.interval, r.repetitions).review(3, "12-01-2021", mon_day_year) +# providing string review_datetime +review(3, r["easiness"], r["interval"], r["repetitions"], "2024-01-01") -# providing date object date -from datetime import date -d = date(2021, 12, 1) -SMTwo(r.easiness, r.interval, r.repetitions).review(3, d) +# providing datetime object review_datetime +from datetime import datetime +d = datetime(2024, 1, 1) +review(3, r["easiness"], r["interval"], r["repetitions"], d) ```
@@ -198,6 +187,10 @@ Check coverage on [Codecov](https://codecov.io/gh/alankan886/SuperMemo2). ## Changelog +3.0.0 (2024-06-22): Major changes/rebuild, Update recommended +- Rewrote the code to remove the class structure, simplfying the code and usability. +- Update to provide datetime instead of just date, more specific with when to review. + 2.0.0 (2021-03-28): Major changes/rebuild, Update recommended - Rebuilt and simplfied the package. diff --git a/requirements.txt b/requirements.txt index a236589..451dfe2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ pytest-cov==2.10.1 - +freezegun==1.5.1 diff --git a/supermemo2/sm_two.py b/supermemo2/sm_two.py index 15e55bc..899529e 100644 --- a/supermemo2/sm_two.py +++ b/supermemo2/sm_two.py @@ -1,69 +1,53 @@ from math import ceil -from datetime import date, datetime, timedelta +from datetime import datetime, timedelta from typing import Optional, Union, Dict -import attr +def review( + quality: int, + easiness: float, + interval: int, + repetitions: int, + review_datetime: Optional[Union[datetime, str]] = None, +) -> Dict: + if not review_datetime: + review_datetime = datetime.utcnow().isoformat(sep=" ", timespec="seconds") + + if isinstance(review_datetime, str): + review_datetime = datetime.fromisoformat(review_datetime).replace(microsecond=0) + + if quality < 3: + interval = 1 + repetitions = 0 + else: + if repetitions == 0: + interval = 1 + elif repetitions == 1: + interval = 6 + else: + interval = ceil(interval * easiness) -year_mon_day = "%Y-%m-%d" -mon_day_year = "%m-%d-%Y" -day_mon_year = "%d-%m-%Y" - - -@attr.s -class SMTwo: - easiness = attr.ib(validator=attr.validators.instance_of(float)) - interval = attr.ib(validator=attr.validators.instance_of(int)) - repetitions = attr.ib(validator=attr.validators.instance_of(int)) - review_date = attr.ib(init=False) - - @staticmethod - def first_review( - quality: int, - review_date: Optional[Union[date, str]] = None, - date_fmt: Optional[str] = None, - ) -> "SMTwo": - if not review_date: - review_date = date.today() - - if not date_fmt: - date_fmt = year_mon_day - - return SMTwo(2.5, 0, 0).review(quality, review_date, date_fmt) - - def review( - self, - quality: int, - review_date: Optional[Union[date, str]] = None, - date_fmt: Optional[str] = None, - ) -> "SMTwo": - if not review_date: - review_date = date.today() - - if not date_fmt: - date_fmt = year_mon_day + repetitions += 1 - if isinstance(review_date, str): - review_date = datetime.strptime(review_date, date_fmt).date() + easiness += 0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02) + if easiness < 1.3: + easiness = 1.3 - if quality < 3: - self.interval = 1 - self.repetitions = 0 - else: - if self.repetitions == 0: - self.interval = 1 - elif self.repetitions == 1: - self.interval = 6 - else: - self.interval = ceil(self.interval * self.easiness) + review_datetime += timedelta(days=interval) - self.repetitions = self.repetitions + 1 + return { + "easiness": easiness, + "interval": interval, + "repetitions": repetitions, + "review_date": str(review_datetime), + } - self.easiness += 0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02) - if self.easiness < 1.3: - self.easiness = 1.3 - review_date += timedelta(days=self.interval) - self.review_date = review_date +def first_review( + quality: int, + review_datetime: Optional[Union[datetime, str]] = None, +) -> Dict: + if not review_datetime: + review_datetime = datetime.utcnow() - return self + return review(quality, 2.5, 0, 0, review_datetime) diff --git a/tests/test_sm_two.py b/tests/test_sm_two.py index 02f7006..96c89d8 100644 --- a/tests/test_sm_two.py +++ b/tests/test_sm_two.py @@ -1,19 +1,60 @@ -from datetime import date, timedelta +from datetime import datetime, timedelta import pytest +from freezegun import freeze_time -from supermemo2 import SMTwo, year_mon_day, mon_day_year, day_mon_year +from supermemo2 import first_review, review +FREEZE_DATE = "2024-01-01" +MOCK_TODAY = datetime.fromisoformat(FREEZE_DATE).replace(microsecond=0) + +@freeze_time(FREEZE_DATE) @pytest.mark.parametrize( "quality, expected_easiness, expected_interval, expected_repetitions, expected_review_date", [ - (0, 1.7000000000000002, 1, 0, date.today() + timedelta(days=1)), - (1, 1.96, 1, 0, date.today() + timedelta(days=1)), - (2, 2.1799999999999997, 1, 0, date.today() + timedelta(days=1)), - (3, 2.36, 1, 1, date.today() + timedelta(days=1)), - (4, 2.5, 1, 1, date.today() + timedelta(days=1)), - (5, 2.6, 1, 1, date.today() + timedelta(days=1)), + ( + 0, + 1.7000000000000002, + 1, + 0, + str(MOCK_TODAY + timedelta(days=1)), + ), + ( + 1, + 1.96, + 1, + 0, + str(MOCK_TODAY + timedelta(days=1)), + ), + ( + 2, + 2.1799999999999997, + 1, + 0, + str(MOCK_TODAY + timedelta(days=1)), + ), + ( + 3, + 2.36, + 1, + 1, + str(MOCK_TODAY + timedelta(days=1)), + ), + ( + 4, + 2.5, + 1, + 1, + str(MOCK_TODAY + timedelta(days=1)), + ), + ( + 5, + 2.6, + 1, + 1, + str(MOCK_TODAY + timedelta(days=1)), + ), ], ) def test_first_review( @@ -24,23 +65,24 @@ def test_first_review( expected_review_date, ): - reviewed = SMTwo.first_review(quality) + reviewed = first_review(quality) - assert reviewed.easiness == expected_easiness - assert reviewed.interval == expected_interval - assert reviewed.repetitions == expected_repetitions - assert reviewed.review_date == expected_review_date + assert reviewed["easiness"] == expected_easiness + assert reviewed["interval"] == expected_interval + assert reviewed["repetitions"] == expected_repetitions + assert reviewed["review_date"] == expected_review_date +@freeze_time(FREEZE_DATE) @pytest.mark.parametrize( "quality, review_date, expected_easiness, expected_interval, expected_repetitions, expected_review_date", [ - (0, date.today(), 1.7000000000000002, 1, 0, date.today() + timedelta(days=1)), - (1, date.today(), 1.96, 1, 0, date.today() + timedelta(days=1)), - (2, date.today(), 2.1799999999999997, 1, 0, date.today() + timedelta(days=1)), - (3, date.today(), 2.36, 1, 1, date.today() + timedelta(days=1)), - (4, date.today(), 2.5, 1, 1, date.today() + timedelta(days=1)), - (5, date.today(), 2.6, 1, 1, date.today() + timedelta(days=1)), + (0, MOCK_TODAY, 1.7000000000000002, 1, 0, str(MOCK_TODAY + timedelta(days=1))), + (1, MOCK_TODAY, 1.96, 1, 0, str(MOCK_TODAY + timedelta(days=1))), + (2, MOCK_TODAY, 2.1799999999999997, 1, 0, str(MOCK_TODAY + timedelta(days=1))), + (3, MOCK_TODAY, 2.36, 1, 1, str(MOCK_TODAY + timedelta(days=1))), + (4, MOCK_TODAY, 2.5, 1, 1, str(MOCK_TODAY + timedelta(days=1))), + (5, MOCK_TODAY, 2.6, 1, 1, str(MOCK_TODAY + timedelta(days=1))), ], ) def test_first_review_given_date( @@ -51,41 +93,33 @@ def test_first_review_given_date( expected_repetitions, expected_review_date, ): - reviewed = SMTwo.first_review(quality, review_date) + reviewed = first_review(quality, review_date) - assert reviewed.easiness == expected_easiness - assert reviewed.interval == expected_interval - assert reviewed.repetitions == expected_repetitions - assert reviewed.review_date == expected_review_date - - -@pytest.mark.parametrize( - "str_date, date_fmt", - [ - ("2021-12-01", None), - ("2021-12-01", year_mon_day), - ("12-01-2021", mon_day_year), - ("01-12-2021", day_mon_year), - ], -) -def test_first_review_given_date_in_str(str_date, date_fmt): - reviewed = SMTwo.first_review(3, str_date, date_fmt) - - assert reviewed.easiness == 2.36 - assert reviewed.interval == 1 - assert reviewed.repetitions == 1 - assert reviewed.review_date == date(2021, 12, 1) + timedelta(days=1) + assert reviewed["easiness"] == expected_easiness + assert reviewed["interval"] == expected_interval + assert reviewed["repetitions"] == expected_repetitions + assert reviewed["review_date"] == expected_review_date +@freeze_time(FREEZE_DATE) @pytest.mark.parametrize( "quality, easiness, interval, repetitions, expected_easiness, expected_interval, expected_repetitions, expected_review_date", [ - (0, 2.3, 12, 3, 1.5, 1, 0, date.today() + timedelta(days=1)), - (1, 2.3, 12, 3, 1.7599999999999998, 1, 0, date.today() + timedelta(days=1)), - (2, 2.3, 12, 3, 1.9799999999999998, 1, 0, date.today() + timedelta(days=1)), - (3, 2.3, 12, 3, 2.1599999999999997, 28, 4, date.today() + timedelta(days=28)), - (4, 2.3, 12, 3, 2.3, 28, 4, date.today() + timedelta(days=28)), - (5, 2.3, 12, 3, 2.4, 28, 4, date.today() + timedelta(days=28)), + (0, 2.3, 12, 3, 1.5, 1, 0, str(MOCK_TODAY + timedelta(days=1))), + (1, 2.3, 12, 3, 1.7599999999999998, 1, 0, str(MOCK_TODAY + timedelta(days=1))), + (2, 2.3, 12, 3, 1.9799999999999998, 1, 0, str(MOCK_TODAY + timedelta(days=1))), + ( + 3, + 2.3, + 12, + 3, + 2.1599999999999997, + 28, + 4, + str(MOCK_TODAY + timedelta(days=28)), + ), + (4, 2.3, 12, 3, 2.3, 28, 4, str(MOCK_TODAY + timedelta(days=28))), + (5, 2.3, 12, 3, 2.4, 28, 4, str(MOCK_TODAY + timedelta(days=28))), ], ) def test_review( @@ -98,13 +132,12 @@ def test_review( expected_repetitions, expected_review_date, ): - sm = SMTwo(easiness, interval, repetitions) - reviewed = sm.review(quality) + reviewed = review(quality, easiness, interval, repetitions) - assert reviewed.easiness == expected_easiness - assert reviewed.interval == expected_interval - assert reviewed.repetitions == expected_repetitions - assert reviewed.review_date == expected_review_date + assert reviewed["easiness"] == expected_easiness + assert reviewed["interval"] == expected_interval + assert reviewed["repetitions"] == expected_repetitions + assert reviewed["review_date"] == expected_review_date @pytest.mark.parametrize( @@ -115,61 +148,61 @@ def test_review( 2.3, 12, 3, - date.today(), + MOCK_TODAY, 1.5, 1, 0, - date.today() + timedelta(days=1), + str(MOCK_TODAY + timedelta(days=1)), ), ( 1, 2.3, 12, 3, - date.today(), + MOCK_TODAY, 1.7599999999999998, 1, 0, - date.today() + timedelta(days=1), + str(MOCK_TODAY + timedelta(days=1)), ), ( 2, 2.3, 12, 3, - date.today(), + MOCK_TODAY, 1.9799999999999998, 1, 0, - date.today() + timedelta(days=1), + str(MOCK_TODAY + timedelta(days=1)), ), ( 3, 2.3, 12, 3, - date.today(), + MOCK_TODAY, 2.1599999999999997, 28, 4, - date.today() + timedelta(days=28), + str(MOCK_TODAY + timedelta(days=28)), ), ( 4, 2.3, 12, 3, - date.today(), + MOCK_TODAY, 2.3, 28, 4, - date.today() + timedelta(days=28), + str(MOCK_TODAY + timedelta(days=28)), ), - (5, 2.3, 12, 3, date.today(), 2.4, 28, 4, date.today() + timedelta(days=28)), + (5, 2.3, 12, 3, MOCK_TODAY, 2.4, 28, 4, str(MOCK_TODAY + timedelta(days=28))), # test case for when easiness drops lower than 1.3 - (0, 1.3, 12, 3, date.today(), 1.3, 1, 0, date.today() + timedelta(days=1)), + (0, 1.3, 12, 3, MOCK_TODAY, 1.3, 1, 0, str(MOCK_TODAY + timedelta(days=1))), # test case for for repetitions equals to 2 - (4, 2.5, 1, 1, date.today(), 2.5, 6, 2, date.today() + timedelta(days=6)), + (4, 2.5, 1, 1, MOCK_TODAY, 2.5, 6, 2, str(MOCK_TODAY + timedelta(days=6))), ], ) def test_review_given_date( @@ -183,10 +216,9 @@ def test_review_given_date( expected_repetitions, expected_review_date, ): - sm = SMTwo(easiness, interval, repetitions) - reviewed = sm.review(quality, review_date) + reviewed = review(quality, easiness, interval, repetitions, review_date) - assert reviewed.easiness == expected_easiness - assert reviewed.interval == expected_interval - assert reviewed.repetitions == expected_repetitions - assert reviewed.review_date == expected_review_date + assert reviewed["easiness"] == expected_easiness + assert reviewed["interval"] == expected_interval + assert reviewed["repetitions"] == expected_repetitions + assert reviewed["review_date"] == expected_review_date