diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8972d68..3207cf1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -25,7 +25,7 @@ jobs: make deps-install - name: Run tests run: | - pip install coverage pytest pytest-cov + pip install coverage pytest pytest-mock pytest-cov make test testdoc - name: "Upload coverage to Codecov" if: github.repository == 'thingsapi/things.py' diff --git a/Pipfile b/Pipfile index bca2bd9..506f048 100644 --- a/Pipfile +++ b/Pipfile @@ -4,9 +4,10 @@ verify_ssl = true name = "pypi" [packages] +pytest = "*" +pytest-mock = "*" [dev-packages] - black = "*" coverage = "*" flake8 = "*" diff --git a/Pipfile.lock b/Pipfile.lock index cdfe17c..cbcfee9 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "59a7465aeadc03b6617c4235a0c084fabd0c3fdc65f2b34b95ea49a6fdfee44c" + "sha256": "8b54d138f09c106d9e2b3510a90c4a6a418db4fe1acd506034f07bd9621d11bb" }, "pipfile-spec": 6, "requires": { @@ -15,7 +15,58 @@ } ] }, - "default": {}, + "default": { + "iniconfig": { + "hashes": [ + "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", + "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760" + ], + "markers": "python_version >= '3.8'", + "version": "==2.1.0" + }, + "packaging": { + "hashes": [ + "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", + "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f" + ], + "markers": "python_version >= '3.8'", + "version": "==25.0" + }, + "pluggy": { + "hashes": [ + "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", + "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746" + ], + "markers": "python_version >= '3.9'", + "version": "==1.6.0" + }, + "pygments": { + "hashes": [ + "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", + "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b" + ], + "markers": "python_version >= '3.8'", + "version": "==2.19.2" + }, + "pytest": { + "hashes": [ + "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", + "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c" + ], + "index": "pypi", + "markers": "python_version >= '3.9'", + "version": "==8.4.1" + }, + "pytest-mock": { + "hashes": [ + "sha256:159e9edac4c451ce77a5cdb9fc5d1100708d2dd4ba3c3df572f14097351af80e", + "sha256:178aefcd11307d874b4cd3100344e7e2d888d9791a6a1d9bfe90fbc1b74fd1d0" + ], + "index": "pypi", + "markers": "python_version >= '3.8'", + "version": "==3.14.1" + } + }, "develop": { "astroid": { "hashes": [ @@ -147,11 +198,11 @@ }, "iniconfig": { "hashes": [ - "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", - "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374" + "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", + "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760" ], - "markers": "python_version >= '3.7'", - "version": "==2.0.0" + "markers": "python_version >= '3.8'", + "version": "==2.1.0" }, "isort": { "hashes": [ @@ -261,11 +312,11 @@ }, "packaging": { "hashes": [ - "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61", - "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f" + "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", + "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f" ], - "markers": "python_version >= '3.7'", - "version": "==23.1" + "markers": "python_version >= '3.8'", + "version": "==25.0" }, "pathspec": { "hashes": [ @@ -285,11 +336,11 @@ }, "pluggy": { "hashes": [ - "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159", - "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3" + "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", + "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746" ], - "markers": "python_version >= '3.6'", - "version": "==1.0.0" + "markers": "python_version >= '3.9'", + "version": "==1.6.0" }, "pycodestyle": { "hashes": [ @@ -315,6 +366,14 @@ "markers": "python_version >= '3.6'", "version": "==3.0.1" }, + "pygments": { + "hashes": [ + "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", + "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b" + ], + "markers": "python_version >= '3.8'", + "version": "==2.19.2" + }, "pylama": { "hashes": [ "sha256:2d4f7aecfb5b7466216d48610c7d6bad1c3990c29cdd392ad08259b161e486f6", @@ -341,11 +400,12 @@ }, "pytest": { "hashes": [ - "sha256:3799fa815351fea3a5e96ac7e503a96fa51cc9942c3753cda7651b93c1cfa362", - "sha256:434afafd78b1d78ed0addf160ad2b77a30d35d4bdf8af234fe621919d9ed15e3" + "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", + "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c" ], "index": "pypi", - "version": "==7.3.1" + "markers": "python_version >= '3.9'", + "version": "==8.4.1" }, "pytest-cov": { "hashes": [ diff --git a/tests/test_things.py b/tests/test_things.py index c22d974..55af267 100644 --- a/tests/test_things.py +++ b/tests/test_things.py @@ -6,6 +6,7 @@ import io import os import sqlite3 +import datetime import time import tracemalloc import unittest @@ -39,7 +40,8 @@ + TRASHED_PROJECT_TRASHED_TODOS ) PROJECTS = 4 -UPCOMING = 1 +UPCOMING = 2 +UPCOMING_2020_12_18 = UPCOMING + 1 DEADLINE_PAST = 3 DEADLINE_FUTURE = 1 DEADLINE = DEADLINE_PAST + DEADLINE_FUTURE @@ -130,6 +132,56 @@ def test_trashed(self): def test_upcoming(self): tasks = things.upcoming() self.assertEqual(UPCOMING, len(tasks)) + titles = {t['title'] for t in tasks} + assert 'To-Do in Upcoming' in titles + assert 'Completed To-Do in Upcoming' not in titles + assert 'Cancelled To-Do in Upcoming' not in titles + + def test_upcoming_includes_completed_and_canceled(self): + # they are shown in upcoming if they have a start_date > today. + # those two test cases have start=1 which never happens in the + # real db- upcoming tasks always have start=2=Someday. + + completed_todo = things.tasks(uuid='LE2WEGxANmtHWD3c9g5iWA') + canceled_todo = things.tasks(uuid='ADLex1EmJzLpu2GHxFvLvc') + assert completed_todo['stop_date'] == '2021-03-28 14:14:21' + assert completed_todo['start'] == 'Someday' + assert canceled_todo['stop_date'] == '2021-03-28 14:14:24' + assert canceled_todo['start'] == 'Someday' + # as their stop dates are after the manualLogDate, they should be shown in upcoming(). + manual_log_date = datetime.datetime.fromtimestamp(1616958822.8444772).isoformat() + assert manual_log_date == '2021-03-28T14:13:42.844477' + + @unittest.mock.patch("things.database.date_today") + def test_upcoming_includes_repeating_instance(self, today_mock): + today_mock.return_value = '2020-12-18' + created_from_template_uuid = 'K9bx7h1xCJdevvyWardZDq' + created_instance = things.tasks(uuid=created_from_template_uuid) + assert created_instance['status'] == 'incomplete' + assert created_instance['start'] == 'Anytime' # never occurs in real data + # I was not able to re-create a task that has a startDate + # and start different from 2/Someday. + assert created_instance['start_date'] == '2020-12-19' + tasks = things.upcoming() + uuids = {t['uuid'] for t in tasks} + # was not found! + assert created_from_template_uuid not in uuids + + @unittest.mock.patch("things.database.date_today") + def test_upcoming_includes_repeating_template(self, today_mock): + today_mock.return_value = '2020-12-18' + tasks = things.upcoming() + self.assertEqual(UPCOMING_2020_12_18, len(tasks)) + template_uuid = 'N1PJHsbjct4mb1bhcs7aHa' + uuids = {t['uuid'] for t in tasks} + assert template_uuid in uuids + + def test_repeating_template_fields_set(self): + tasks = things.tasks(is_repeating_task_template=True) + for task in tasks: + assert 'is_repeating_task_template' in list(task) + assert str(task['is_repeating_task_template']) == 'True' + assert task['start_date'] == '2025-03-28' def test_deadlines(self): tasks = things.tasks(deadline="past") @@ -214,6 +266,8 @@ def test_todos(self): self.assertEqual(19, len(tasks)) with self.assertRaises(ValueError): things.todos(status="invalid_value") + + def test_todos_length(self): todo = things.todos("A2oPvtt4dXoypeoLc8uYzY") self.assertEqual(16, len(todo.keys())) # type: ignore diff --git a/things/.pytest.ini b/things/.pytest.ini new file mode 100644 index 0000000..86d57a4 --- /dev/null +++ b/things/.pytest.ini @@ -0,0 +1,2 @@ +[pytest] +mock_use_standalone_module = true diff --git a/things/api.py b/things/api.py index b3024e1..9b430dd 100644 --- a/things/api.py +++ b/things/api.py @@ -538,7 +538,9 @@ def upcoming(**kwargs): For details on parameters, see `things.api.tasks`. """ - return tasks(start_date="future", start="Someday", **kwargs) + scheduled_tasks = tasks(start_date="future", start="Someday", **kwargs) + repeating_task_templates = tasks(is_repeating_task_template=True, **kwargs) + return scheduled_tasks + repeating_task_templates def anytime(**kwargs): diff --git a/things/conftest.py b/things/conftest.py index 3ea8b15..4503426 100644 --- a/things/conftest.py +++ b/things/conftest.py @@ -1,6 +1,7 @@ """Helper module to test the Things API documentation.""" import pytest +from pytest_mock import MockerFixture import things @@ -9,3 +10,10 @@ def add_imports(doctest_namespace): # noqa """Import default modules.""" doctest_namespace["things"] = things + + +@pytest.fixture +def patch_today(mocker : MockerFixture) -> None: + """Fixture to patch today's date.""" + mock = mocker.patch("things.database.date_today") + mock.return_value = '2025-09-01' diff --git a/things/database.py b/things/database.py index 08e17e0..38ad96e 100755 --- a/things/database.py +++ b/things/database.py @@ -67,6 +67,7 @@ # Response modification + COLUMNS_TO_OMIT_IF_NONE = ( "area", "area_title", @@ -78,8 +79,11 @@ "reminder_time", "trashed", "tags", + "is_repeating_task_template", + "start_date_next_instance", + "start_date_instance_creation" ) -COLUMNS_TO_TRANSFORM_TO_BOOL = ("checklist", "tags", "trashed") +COLUMNS_TO_TRANSFORM_TO_BOOL = ("checklist", "tags", "trashed", "is_repeating_task_template") # -------------------------------------------------- # Table names @@ -111,6 +115,10 @@ # See 'convert_thingstime_sql_expression_to_isotime' for details. REMINDER_TIME = "reminderTime" # INTEGER: hhhhhmmmmmm00000000000000000000, in binary +DATE_START_NEXT_INSTANCE = "rt1_nextInstanceStartDate" +DATE_START_INSTANCE_CREATION = "rt1_instanceCreationStartDate" +RECURRENCE_RULE = "rt1_recurrenceRule" + # -------------------------------------------------- # Various filters # -------------------------------------------------- @@ -130,9 +138,6 @@ IS_ANYTIME = START_TO_FILTER["Anytime"] IS_SOMEDAY = START_TO_FILTER["Someday"] -# Repeats -IS_NOT_RECURRING = "rt1_recurrenceRule IS NULL" - # Trash IS_TRASHED = TRASHED_TO_FILTER[True] @@ -229,6 +234,7 @@ def get_tasks( # pylint: disable=R0914,R0917 deadline_suppressed: Optional[bool] = None, trashed: Optional[bool] = False, context_trashed: Optional[bool] = False, + is_repeating_task_template: Optional[bool] = False, last: Optional[str] = None, search_query: Optional[str] = None, index: str = "index", @@ -266,6 +272,8 @@ def get_tasks( # pylint: disable=R0914,R0917 status_filter: str = STATUS_TO_FILTER.get(status, "") # type: ignore trashed_filter: str = TRASHED_TO_FILTER.get(trashed, "") # type: ignore type_filter: str = TYPE_TO_FILTER.get(type, "") # type: ignore + not_or_not = "NOT" if is_repeating_task_template else "" + is_repeating_task_template_filter: str = f"{RECURRENCE_RULE} IS {not_or_not} NULL" # Sometimes a task is _not_ set to trashed, but its context # (project or heading it is contained within) is set to trashed. @@ -285,7 +293,7 @@ def get_tasks( # pylint: disable=R0914,R0917 ) where_predicate = f""" - TASK.{IS_NOT_RECURRING} + TASK.{is_repeating_task_template_filter} {trashed_filter and f"AND TASK.{trashed_filter}"} {project_trashed_filter} {project_of_heading_trashed_filter} @@ -528,6 +536,12 @@ def make_tasks_sql_query(where_predicate=None, order_predicate=None): start_date_expression = convert_thingsdate_sql_expression_to_isodate( f"TASK.{DATE_START}" ) + start_date_next_instance_expression = convert_thingsdate_sql_expression_to_isodate( + f"TASK.{DATE_START_NEXT_INSTANCE}" + ) + start_date_instance_creation_expression = convert_thingsdate_sql_expression_to_isodate( + f"TASK.{DATE_START_INSTANCE_CREATION}" + ) deadline_expression = convert_thingsdate_sql_expression_to_isodate( f"TASK.{DATE_DEADLINE}" ) @@ -552,6 +566,9 @@ def make_tasks_sql_query(where_predicate=None, order_predicate=None): WHEN TASK.{IS_CANCELED} THEN 'canceled' WHEN TASK.{IS_COMPLETED} THEN 'completed' END AS status, + CASE + WHEN TASK.{RECURRENCE_RULE} is not NULL THEN True + END AS is_repeating_task_template, CASE WHEN AREA.uuid IS NOT NULL THEN AREA.uuid END AS area, @@ -585,6 +602,8 @@ def make_tasks_sql_query(where_predicate=None, order_predicate=None): {start_date_expression} AS start_date, {deadline_expression} AS deadline, {reminder_time_expression} AS "reminder_time", + {start_date_next_instance_expression} AS start_date_next_instance, + {start_date_instance_creation_expression} AS start_date_instance_creation, datetime(TASK.{DATE_STOP}, "unixepoch", "localtime") AS "stop_date", datetime(TASK.{DATE_CREATED}, "unixepoch", "localtime") AS created, datetime(TASK.{DATE_MODIFIED}, "unixepoch", "localtime") AS modified, @@ -753,6 +772,19 @@ def convert_thingstime_sql_expression_to_isotime(sql_expression: str) -> str: return f"CASE WHEN {thingstime} THEN {isotime} ELSE {thingstime} END" +def _adjust_template_dates(task_dict): + if task_dict.get('start_date_next_instance', None) == '1-01-01': + task_dict.pop('start_date_next_instance') + if task_dict.get('is_repeating_task_template', False): + # 'start_date_next_instance': '1-01-01' + + if task_dict.get('start_date', None) is None: + start_date = (task_dict.get('start_date_next_instance', None) + or task_dict.get('start_date_instance_creation', None)) + if start_date: + task_dict['start_date'] = start_date + + def dict_factory(cursor, row): """ Convert SQL result into a dictionary. @@ -768,6 +800,7 @@ def dict_factory(cursor, row): if value and key in COLUMNS_TO_TRANSFORM_TO_BOOL: value = bool(value) result[key] = value + _adjust_template_dates(result) return result @@ -812,7 +845,9 @@ def isodate_to_yyyyyyyyyyymmmmddddd(value: str): >>> isodate_to_yyyyyyyyyyymmmmddddd('2021-03-28') 132464128 """ - year, month, day = map(int, value.split("-")) + datetimetag = datetime.datetime.fromisoformat(value) + year, month, day = datetimetag.year, datetimetag.month, datetimetag.day + return year << 16 | month << 12 | day << 7 @@ -914,10 +949,9 @@ def make_thingsdate_filter(date_column: str, value) -> str: >>> make_thingsdate_filter('startDate', False) 'AND startDate IS NULL' + >>> today = getfixture('patch_today') >>> make_thingsdate_filter('startDate', 'future') - "AND startDate > ((strftime('%Y', date('now', 'localtime')) << 16) \ - | (strftime('%m', date('now', 'localtime')) << 12) \ - | (strftime('%d', date('now', 'localtime')) << 7))" + 'AND startDate > 132747392' >>> make_thingsdate_filter('deadline', '2021-03-28') 'AND deadline == 132464128' @@ -949,14 +983,21 @@ def make_thingsdate_filter(date_column: str, value) -> str: else: # "future" or "past" validate("value", value, ["future", "past"]) - threshold = convert_isodate_sql_expression_to_thingsdate( - "date('now', 'localtime')", null_possible=False - ) + today = isodate_to_yyyyyyyyyyymmmmddddd(date_today()) + # threshold = convert_isodate_sql_expression_to_thingsdate( + # "date('now', 'localtime')", null_possible=False + # ) + threshold = today comparator = ">" if value == "future" else "<=" return f"AND {date_column} {comparator} {threshold}" +def date_today(): + """Return today's date. Indirection necessary to enable to mock it for testing.""" + return datetime.date.today().strftime("%Y-%m-%d") + + def make_truthy_filter(column: str, value) -> str: """ Return a SQL filter that matches if a column is truthy or falsy.