things.database
Read from the Things SQLite database using SQL queries.
1"""Read from the Things SQLite database using SQL queries.""" 2 3# pylint: disable=C0302 4 5import datetime 6import glob 7import os 8import plistlib 9import re 10import sqlite3 11from textwrap import dedent 12from typing import Optional, Union 13 14 15# -------------------------------------------------- 16# Core constants 17# -------------------------------------------------- 18 19 20# Database filepath with glob pattern for version 3.15.16+ 21DEFAULT_FILEPATH_31616502 = ( 22 "~/Library/Group Containers/JLMPQHK86H.com.culturedcode.ThingsMac" 23 "/ThingsData-*/Things Database.thingsdatabase/main.sqlite" 24) 25DEFAULT_FILEPATH_31516502 = ( 26 "~/Library/Group Containers/JLMPQHK86H.com.culturedcode.ThingsMac" 27 "/Things Database.thingsdatabase/main.sqlite" 28) 29 30try: 31 DEFAULT_FILEPATH = next(glob.iglob(os.path.expanduser(DEFAULT_FILEPATH_31616502))) 32except StopIteration: 33 DEFAULT_FILEPATH = os.path.expanduser(DEFAULT_FILEPATH_31516502) 34 35ENVIRONMENT_VARIABLE_WITH_FILEPATH = "THINGSDB" 36 37# Translate app language to database language 38 39START_TO_FILTER = { 40 "Inbox": "start = 0", 41 "Anytime": "start = 1", 42 "Someday": "start = 2", 43} 44 45STATUS_TO_FILTER = { 46 "incomplete": "status = 0", 47 "canceled": "status = 2", 48 "completed": "status = 3", 49} 50 51TRASHED_TO_FILTER = {True: "trashed = 1", False: "trashed = 0"} 52 53TYPE_TO_FILTER = { 54 "to-do": "type = 0", 55 "project": "type = 1", 56 "heading": "type = 2", 57} 58 59# Dates 60 61DATES = ("future", "past", True, False) 62 63# Indices 64 65INDICES = ("index", "todayIndex") 66 67# Response modification 68 69COLUMNS_TO_OMIT_IF_NONE = ( 70 "area", 71 "area_title", 72 "checklist", 73 "heading", 74 "heading_title", 75 "project", 76 "project_title", 77 "trashed", 78 "tags", 79) 80COLUMNS_TO_TRANSFORM_TO_BOOL = ("checklist", "tags", "trashed") 81 82# -------------------------------------------------- 83# Table names 84# -------------------------------------------------- 85 86TABLE_AREA = "TMArea" 87TABLE_AREATAG = "TMAreaTag" 88TABLE_CHECKLIST_ITEM = "TMChecklistItem" 89TABLE_META = "Meta" 90TABLE_TAG = "TMTag" 91TABLE_TASK = "TMTask" 92TABLE_TASKTAG = "TMTaskTag" 93TABLE_SETTINGS = "TMSettings" 94 95# -------------------------------------------------- 96# Date Columns 97# -------------------------------------------------- 98 99# Note that the columns below -- contrary to their names -- seem to 100# store the full UTC datetime, not just the date. 101DATE_CREATED = "creationDate" # REAL: Unix date & time, UTC 102DATE_MODIFIED = "userModificationDate" # REAL: Unix date & time, UTC 103DATE_STOP = "stopDate" # REAL: Unix date & time, UTC 104 105# These are stored in a Things date format. 106# See `convert_isodate_sql_expression_to_thingsdate` for details. 107DATE_DEADLINE = "deadline" # INTEGER: YYYYYYYYYYYMMMMDDDDD0000000, in binary 108DATE_START = "startDate" # INTEGER: YYYYYYYYYYYMMMMDDDDD0000000, in binary 109 110# -------------------------------------------------- 111# Various filters 112# -------------------------------------------------- 113 114# Type 115IS_TODO = TYPE_TO_FILTER["to-do"] 116IS_PROJECT = TYPE_TO_FILTER["project"] 117IS_HEADING = TYPE_TO_FILTER["heading"] 118 119# Status 120IS_INCOMPLETE = STATUS_TO_FILTER["incomplete"] 121IS_CANCELED = STATUS_TO_FILTER["canceled"] 122IS_COMPLETED = STATUS_TO_FILTER["completed"] 123 124# Start 125IS_INBOX = START_TO_FILTER["Inbox"] 126IS_ANYTIME = START_TO_FILTER["Anytime"] 127IS_SOMEDAY = START_TO_FILTER["Someday"] 128 129# Repeats 130IS_NOT_RECURRING = "rt1_recurrenceRule IS NULL" 131 132# Trash 133IS_TRASHED = TRASHED_TO_FILTER[True] 134 135# -------------------------------------------------- 136# Fields and filters not yet used in the implementation. 137# This information might be of relevance in the future. 138# -------------------------------------------------- 139# 140# IS_SCHEDULED = f"{DATE_START} IS NOT NULL" 141# IS_NOT_SCHEDULED = f"{DATE_START} IS NULL" 142# IS_DEADLINE = f"{DATE_DEADLINE} IS NOT NULL" 143# RECURRING_IS_NOT_PAUSED = "rt1_instanceCreationPaused = 0" 144# IS_RECURRING = "rt1_recurrenceRule IS NOT NULL" 145# RECURRING_HAS_NEXT_STARTDATE = ("rt1_nextInstanceStartDate IS NOT NULL") 146# IS_NOT_TRASHED = TRASHED_TO_FILTER[False] 147 148# pylint: disable=R0904,R0902 149 150 151class Database: 152 """ 153 Access Things SQL database. 154 155 Parameters 156 ---------- 157 filepath : str, optional 158 Any valid path of a SQLite database file generated by the Things app. 159 If the environment variable `THINGSDB` is set, then use that path. 160 Otherwise, access the default database path. 161 162 print_sql : bool, default False 163 Print every SQL query performed. Some may contain '?' and ':' 164 characters which correspond to SQLite parameter tokens. 165 See https://www.sqlite.org/lang_expr.html#varparam 166 167 :raises AssertionError: If the database version is too old. 168 """ 169 170 debug = False 171 connection: sqlite3.Connection 172 173 # pylint: disable=R0913 174 def __init__(self, filepath=None, print_sql=False): 175 """Set up the database.""" 176 self.filepath = ( 177 filepath 178 or os.getenv(ENVIRONMENT_VARIABLE_WITH_FILEPATH) 179 or DEFAULT_FILEPATH 180 ) 181 self.print_sql = print_sql 182 if self.print_sql: 183 self.execute_query_count = 0 184 185 # "ro" means read-only 186 # See: https://sqlite.org/uri.html#recognized_query_parameters 187 uri = f"file:{self.filepath}?mode=ro" 188 self.connection = sqlite3.connect(uri, uri=True) # pylint: disable=E1101 189 190 # Test for migrated database in Things 3.15.16+ 191 # -------------------------------- 192 assert self.get_version() > 21, ( 193 "Your database is in an older format. " 194 "Run 'pip install things.py==0.0.14' to downgrade to an older " 195 "version of this library." 196 ) 197 198 # Automated migration to new database location in Things 3.12.6/3.13.1 199 # -------------------------------- 200 try: 201 with open(self.filepath, encoding="utf-8") as file: 202 if "Your database file has been moved there" in file.readline(): 203 self.filepath = DEFAULT_FILEPATH 204 except (UnicodeDecodeError, FileNotFoundError, PermissionError): 205 pass # binary file (old database) or doesn't exist 206 # -------------------------------- 207 208 # Core methods 209 210 def get_tasks( # pylint: disable=R0914,R0917 211 self, 212 uuid: Optional[str] = None, 213 type: Optional[str] = None, # pylint: disable=W0622 214 status: Optional[str] = None, 215 start: Optional[str] = None, 216 area: Optional[Union[str, bool]] = None, 217 project: Optional[Union[str, bool]] = None, 218 heading: Optional[str] = None, 219 tag: Optional[Union[str, bool]] = None, 220 start_date: Optional[Union[str, bool]] = None, 221 stop_date: Optional[Union[str, bool]] = None, 222 deadline: Optional[Union[str, bool]] = None, 223 deadline_suppressed: Optional[bool] = None, 224 trashed: Optional[bool] = False, 225 context_trashed: Optional[bool] = False, 226 last: Optional[str] = None, 227 search_query: Optional[str] = None, 228 index: str = "index", 229 count_only: bool = False, 230 ): 231 """Get tasks. See `things.api.tasks` for details on parameters.""" 232 if uuid: 233 return self.get_task_by_uuid(uuid, count_only=count_only) 234 235 # Overwrites 236 start = start and start.title() 237 238 # Validation 239 validate_date("deadline", deadline) 240 validate("deadline_suppressed", deadline_suppressed, [None, True, False]) 241 validate("start", start, [None] + list(START_TO_FILTER)) 242 validate_date("start_date", start_date) 243 validate_date("stop_date", stop_date) 244 validate("status", status, [None] + list(STATUS_TO_FILTER)) 245 validate("trashed", trashed, [None] + list(TRASHED_TO_FILTER)) 246 validate("type", type, [None] + list(TYPE_TO_FILTER)) 247 validate("context_trashed", context_trashed, [None, True, False]) 248 validate("index", index, list(INDICES)) 249 validate_offset("last", last) 250 251 if tag is not None: 252 valid_tags = self.get_tags(titles_only=True) 253 validate("tag", tag, [None] + list(valid_tags)) 254 255 # Query 256 # TK: might consider executing SQL with parameters instead. 257 # See: https://docs.python.org/3/library/sqlite3.html#sqlite3.Cursor.execute 258 259 start_filter: str = START_TO_FILTER.get(start, "") # type: ignore 260 status_filter: str = STATUS_TO_FILTER.get(status, "") # type: ignore 261 trashed_filter: str = TRASHED_TO_FILTER.get(trashed, "") # type: ignore 262 type_filter: str = TYPE_TO_FILTER.get(type, "") # type: ignore 263 264 # Sometimes a task is _not_ set to trashed, but its context 265 # (project or heading it is contained within) is set to trashed. 266 # In those cases, the task wouldn't show up in any app view 267 # except for "Trash". 268 project_trashed_filter = make_truthy_filter("PROJECT.trashed", context_trashed) 269 project_of_heading_trashed_filter = make_truthy_filter( 270 "PROJECT_OF_HEADING.trashed", context_trashed 271 ) 272 273 # As a task assigned to a heading is not directly assigned to a project anymore, 274 # we need to check if the heading is assigned to a project. 275 # See, e.g. https://github.com/thingsapi/things.py/issues/94 276 project_filter = make_or_filter( 277 make_filter("TASK.project", project), 278 make_filter("PROJECT_OF_HEADING.uuid", project), 279 ) 280 281 where_predicate = f""" 282 TASK.{IS_NOT_RECURRING} 283 {trashed_filter and f"AND TASK.{trashed_filter}"} 284 {project_trashed_filter} 285 {project_of_heading_trashed_filter} 286 {type_filter and f"AND TASK.{type_filter}"} 287 {start_filter and f"AND TASK.{start_filter}"} 288 {status_filter and f"AND TASK.{status_filter}"} 289 {make_filter('TASK.uuid', uuid)} 290 {make_filter("TASK.area", area)} 291 {project_filter} 292 {make_filter("TASK.heading", heading)} 293 {make_filter("TASK.deadlineSuppressionDate", deadline_suppressed)} 294 {make_filter("TAG.title", tag)} 295 {make_thingsdate_filter(f"TASK.{DATE_START}", start_date)} 296 {make_unixtime_filter(f"TASK.{DATE_STOP}", stop_date)} 297 {make_thingsdate_filter(f"TASK.{DATE_DEADLINE}", deadline)} 298 {make_unixtime_range_filter(f"TASK.{DATE_CREATED}", last)} 299 {make_search_filter(search_query)} 300 """ 301 order_predicate = f'TASK."{index}"' 302 303 sql_query = make_tasks_sql_query(where_predicate, order_predicate) 304 305 if count_only: 306 return self.get_count(sql_query) 307 308 return self.execute_query(sql_query) 309 310 def get_task_by_uuid(self, uuid, count_only=False): 311 """Get a task by uuid. Raise `ValueError` if not found.""" 312 where_predicate = "TASK.uuid = ?" 313 sql_query = make_tasks_sql_query(where_predicate) 314 parameters = (uuid,) 315 316 if count_only: 317 return self.get_count(sql_query, parameters) 318 319 result = self.execute_query(sql_query, parameters) 320 if not result: 321 raise ValueError(f"No such task uuid found: {uuid!r}") 322 323 return result 324 325 def get_areas(self, uuid=None, tag=None, count_only=False): 326 """Get areas. See `api.areas` for details on parameters.""" 327 # Validation 328 if tag is not None: 329 valid_tags = self.get_tags(titles_only=True) 330 validate("tag", tag, [None] + list(valid_tags)) 331 332 if ( 333 uuid 334 and count_only is False 335 and not self.get_areas(uuid=uuid, count_only=True) 336 ): 337 raise ValueError(f"No such area uuid found: {uuid!r}") 338 339 # Query 340 sql_query = f""" 341 SELECT DISTINCT 342 AREA.uuid, 343 'area' as type, 344 AREA.title, 345 CASE 346 WHEN AREA_TAG.areas IS NOT NULL THEN 1 347 END AS tags 348 FROM 349 {TABLE_AREA} AS AREA 350 LEFT OUTER JOIN 351 {TABLE_AREATAG} AREA_TAG ON AREA_TAG.areas = AREA.uuid 352 LEFT OUTER JOIN 353 {TABLE_TAG} TAG ON TAG.uuid = AREA_TAG.tags 354 WHERE 355 TRUE 356 {make_filter('TAG.title', tag)} 357 {make_filter('AREA.uuid', uuid)} 358 ORDER BY AREA."index" 359 """ 360 361 if count_only: 362 return self.get_count(sql_query) 363 364 return self.execute_query(sql_query) 365 366 def get_checklist_items(self, todo_uuid=None): 367 """Get checklist items.""" 368 sql_query = f""" 369 SELECT 370 CHECKLIST_ITEM.title, 371 CASE 372 WHEN CHECKLIST_ITEM.{IS_INCOMPLETE} THEN 'incomplete' 373 WHEN CHECKLIST_ITEM.{IS_CANCELED} THEN 'canceled' 374 WHEN CHECKLIST_ITEM.{IS_COMPLETED} THEN 'completed' 375 END AS status, 376 date(CHECKLIST_ITEM.stopDate, "unixepoch", "localtime") AS stop_date, 377 'checklist-item' as type, 378 CHECKLIST_ITEM.uuid, 379 datetime( 380 CHECKLIST_ITEM.{DATE_MODIFIED}, "unixepoch", "localtime" 381 ) AS created, 382 datetime( 383 CHECKLIST_ITEM.{DATE_MODIFIED}, "unixepoch", "localtime" 384 ) AS modified 385 FROM 386 {TABLE_CHECKLIST_ITEM} AS CHECKLIST_ITEM 387 WHERE 388 CHECKLIST_ITEM.task = ? 389 ORDER BY CHECKLIST_ITEM."index" 390 """ 391 return self.execute_query(sql_query, (todo_uuid,)) 392 393 def get_tags(self, title=None, area=None, task=None, titles_only=False): 394 """Get tags. See `api.tags` for details on parameters.""" 395 # Validation 396 if title is not None: 397 valid_titles = self.get_tags(titles_only=True) 398 validate("title", title, [None] + list(valid_titles)) 399 400 # Query 401 if task: 402 return self.get_tags_of_task(task) 403 if area: 404 return self.get_tags_of_area(area) 405 406 if titles_only: 407 sql_query = f'SELECT title FROM {TABLE_TAG} ORDER BY "index"' 408 return self.execute_query(sql_query, row_factory=list_factory) 409 410 sql_query = f""" 411 SELECT 412 uuid, 'tag' AS type, title, shortcut 413 FROM 414 {TABLE_TAG} 415 WHERE 416 TRUE 417 {make_filter('title', title)} 418 ORDER BY "index" 419 """ 420 421 return self.execute_query(sql_query) 422 423 def get_tags_of_task(self, task_uuid): 424 """Get tag titles of task.""" 425 sql_query = f""" 426 SELECT 427 TAG.title 428 FROM 429 {TABLE_TASKTAG} AS TASK_TAG 430 LEFT OUTER JOIN 431 {TABLE_TAG} TAG ON TAG.uuid = TASK_TAG.tags 432 WHERE 433 TASK_TAG.tasks = ? 434 ORDER BY TAG."index" 435 """ 436 return self.execute_query( 437 sql_query, parameters=(task_uuid,), row_factory=list_factory 438 ) 439 440 def get_tags_of_area(self, area_uuid): 441 """Get tag titles for area.""" 442 sql_query = f""" 443 SELECT 444 AREA.title 445 FROM 446 {TABLE_AREATAG} AS AREA_TAG 447 LEFT OUTER JOIN 448 {TABLE_TAG} AREA ON AREA.uuid = AREA_TAG.tags 449 WHERE 450 AREA_TAG.areas = ? 451 ORDER BY AREA."index" 452 """ 453 return self.execute_query( 454 sql_query, parameters=(area_uuid,), row_factory=list_factory 455 ) 456 457 def get_version(self): 458 """Get Things Database version.""" 459 sql_query = f"SELECT value FROM {TABLE_META} WHERE key = 'databaseVersion'" 460 result = self.execute_query(sql_query, row_factory=list_factory) 461 plist_bytes = result[0].encode() 462 return plistlib.loads(plist_bytes) 463 464 # pylint: disable=R1710 465 def get_url_scheme_auth_token(self): 466 """Get the Things URL scheme authentication token.""" 467 sql_query = f""" 468 SELECT 469 uriSchemeAuthenticationToken 470 FROM 471 {TABLE_SETTINGS} 472 WHERE 473 uuid = 'RhAzEf6qDxCD5PmnZVtBZR' 474 """ 475 rows = self.execute_query(sql_query, row_factory=list_factory) 476 return rows[0] 477 478 def get_count(self, sql_query, parameters=()): 479 """Count number of results.""" 480 count_sql_query = f"""SELECT COUNT(uuid) FROM (\n{sql_query}\n)""" 481 rows = self.execute_query( 482 count_sql_query, row_factory=list_factory, parameters=parameters 483 ) 484 return rows[0] 485 486 # noqa todo: add type hinting for resutl (List[Tuple[str, Any]]?) 487 def execute_query(self, sql_query, parameters=(), row_factory=None): 488 """Run the actual SQL query.""" 489 if self.print_sql or self.debug: 490 if not hasattr(self, "execute_query_count"): 491 # This is needed for historical `self.debug`. 492 # TK: might consider removing `debug` flag. 493 self.execute_query_count = 0 494 self.execute_query_count += 1 495 if self.debug: 496 print(f"/* Filepath {self.filepath!r} */") 497 print(f"/* Query {self.execute_query_count} */") 498 if parameters: 499 print(f"/* Parameters: {parameters!r} */") 500 print() 501 print(prettify_sql(sql_query)) 502 print() 503 504 with self.connection: 505 # Using context manager to keep queries in separate transactions, 506 # see https://docs.python.org/3/library/sqlite3.html#sqlite3-connection-context-manager 507 self.connection.row_factory = row_factory or dict_factory 508 cursor = self.connection.cursor() 509 cursor.execute(sql_query, parameters) 510 511 return cursor.fetchall() 512 513 514# Helper functions 515 516 517def make_tasks_sql_query(where_predicate=None, order_predicate=None): 518 """Make SQL query for Task table.""" 519 where_predicate = where_predicate or "TRUE" 520 order_predicate = order_predicate or 'TASK."index"' 521 522 start_date_expression = convert_thingsdate_sql_expression_to_isodate( 523 f"TASK.{DATE_START}" 524 ) 525 deadline_expression = convert_thingsdate_sql_expression_to_isodate( 526 f"TASK.{DATE_DEADLINE}" 527 ) 528 529 return f""" 530 SELECT DISTINCT 531 TASK.uuid, 532 CASE 533 WHEN TASK.{IS_TODO} THEN 'to-do' 534 WHEN TASK.{IS_PROJECT} THEN 'project' 535 WHEN TASK.{IS_HEADING} THEN 'heading' 536 END AS type, 537 CASE 538 WHEN TASK.{IS_TRASHED} THEN 1 539 END AS trashed, 540 TASK.title, 541 CASE 542 WHEN TASK.{IS_INCOMPLETE} THEN 'incomplete' 543 WHEN TASK.{IS_CANCELED} THEN 'canceled' 544 WHEN TASK.{IS_COMPLETED} THEN 'completed' 545 END AS status, 546 CASE 547 WHEN AREA.uuid IS NOT NULL THEN AREA.uuid 548 END AS area, 549 CASE 550 WHEN AREA.uuid IS NOT NULL THEN AREA.title 551 END AS area_title, 552 CASE 553 WHEN PROJECT.uuid IS NOT NULL THEN PROJECT.uuid 554 END AS project, 555 CASE 556 WHEN PROJECT.uuid IS NOT NULL THEN PROJECT.title 557 END AS project_title, 558 CASE 559 WHEN HEADING.uuid IS NOT NULL THEN HEADING.uuid 560 END AS heading, 561 CASE 562 WHEN HEADING.uuid IS NOT NULL THEN HEADING.title 563 END AS heading_title, 564 TASK.notes, 565 CASE 566 WHEN TAG.uuid IS NOT NULL THEN 1 567 END AS tags, 568 CASE 569 WHEN TASK.{IS_INBOX} THEN 'Inbox' 570 WHEN TASK.{IS_ANYTIME} THEN 'Anytime' 571 WHEN TASK.{IS_SOMEDAY} THEN 'Someday' 572 END AS start, 573 CASE 574 WHEN CHECKLIST_ITEM.uuid IS NOT NULL THEN 1 575 END AS checklist, 576 date({start_date_expression}) AS start_date, 577 date({deadline_expression}) AS deadline, 578 datetime(TASK.{DATE_STOP}, "unixepoch", "localtime") AS "stop_date", 579 datetime(TASK.{DATE_CREATED}, "unixepoch", "localtime") AS created, 580 datetime(TASK.{DATE_MODIFIED}, "unixepoch", "localtime") AS modified, 581 TASK.'index', 582 TASK.todayIndex AS today_index 583 FROM 584 {TABLE_TASK} AS TASK 585 LEFT OUTER JOIN 586 {TABLE_TASK} PROJECT ON TASK.project = PROJECT.uuid 587 LEFT OUTER JOIN 588 {TABLE_AREA} AREA ON TASK.area = AREA.uuid 589 LEFT OUTER JOIN 590 {TABLE_TASK} HEADING ON TASK.heading = HEADING.uuid 591 LEFT OUTER JOIN 592 {TABLE_TASK} PROJECT_OF_HEADING 593 ON HEADING.project = PROJECT_OF_HEADING.uuid 594 LEFT OUTER JOIN 595 {TABLE_TASKTAG} TAGS ON TASK.uuid = TAGS.tasks 596 LEFT OUTER JOIN 597 {TABLE_TAG} TAG ON TAGS.tags = TAG.uuid 598 LEFT OUTER JOIN 599 {TABLE_CHECKLIST_ITEM} CHECKLIST_ITEM 600 ON TASK.uuid = CHECKLIST_ITEM.task 601 WHERE 602 {where_predicate} 603 ORDER BY 604 {order_predicate} 605 """ 606 607 608# In alphabetical order from here... 609 610 611def convert_isodate_sql_expression_to_thingsdate(sql_expression, null_possible=True): 612 """ 613 Return a SQL expression of an isodate converted into a "Things date". 614 615 A _Things date_ is an integer where the binary digits are 616 YYYYYYYYYYYMMMMDDDDD0000000; Y is year, M is month, and D is day. 617 618 For example, the ISO 8601 date '2021-03-28' corresponds to the Things 619 date 132464128 as integer; in binary that is: 620 111111001010011111000000000 621 YYYYYYYYYYYMMMMDDDDD0000000 622 2021 3 28 623 624 Parameters 625 ---------- 626 sql_expression : str 627 A sql expression evaluating to an ISO 8601 date str. 628 629 null_possible : bool 630 Can the input `sql_expression` evaluate to NULL? 631 632 Returns 633 ------- 634 str 635 A sql expression representing a "Things date" as integer. 636 637 Example 638 ------- 639 >>> convert_isodate_sql_expression_to_thingsdate("date('now', 'localtime')") 640 "(CASE WHEN date('now', 'localtime') THEN \ 641 ((strftime('%Y', date('now', 'localtime')) << 16) \ 642 | (strftime('%m', date('now', 'localtime')) << 12) \ 643 | (strftime('%d', date('now', 'localtime')) << 7)) \ 644 ELSE date('now', 'localtime') END)" 645 >>> convert_isodate_sql_expression_to_thingsdate("'2023-05-22'") 646 "(CASE WHEN '2023-05-22' THEN \ 647 ((strftime('%Y', '2023-05-22') << 16) \ 648 | (strftime('%m', '2023-05-22') << 12) \ 649 | (strftime('%d', '2023-05-22') << 7)) \ 650 ELSE '2023-05-22' END)" 651 """ 652 isodate = sql_expression 653 654 year = f"strftime('%Y', {isodate}) << 16" 655 month = f"strftime('%m', {isodate}) << 12" 656 day = f"strftime('%d', {isodate}) << 7" 657 658 thingsdate = f"(({year}) | ({month}) | ({day}))" 659 660 if null_possible: 661 # when isodate is NULL, return isodate as-is 662 return f"(CASE WHEN {isodate} THEN {thingsdate} ELSE {isodate} END)" 663 664 return thingsdate 665 666 667def convert_thingsdate_sql_expression_to_isodate(sql_expression): 668 """ 669 Return SQL expression as string. 670 671 Parameters 672 ---------- 673 sql_expression : str 674 A sql expression pointing to a "Things date" integer in 675 format YYYYYYYYYYYMMMMDDDDD0000000, in binary. 676 See: `convert_isodate_sql_expression_to_thingsdate` for details. 677 678 Example 679 ------- 680 >>> convert_thingsdate_sql_expression_to_isodate('132464128') 681 "CASE WHEN 132464128 THEN \ 682 printf('%d-%02d-%02d', (132464128 & 134152192) >> 16, \ 683 (132464128 & 61440) >> 12, (132464128 & 3968) >> 7) ELSE 132464128 END" 684 >>> convert_thingsdate_sql_expression_to_isodate('startDate') 685 "CASE WHEN startDate THEN \ 686 printf('%d-%02d-%02d', (startDate & 134152192) >> 16, \ 687 (startDate & 61440) >> 12, (startDate & 3968) >> 7) ELSE startDate END" 688 """ 689 y_mask = 0b111111111110000000000000000 690 m_mask = 0b000000000001111000000000000 691 d_mask = 0b000000000000000111110000000 692 693 thingsdate = sql_expression 694 year = f"({thingsdate} & {y_mask}) >> 16" 695 month = f"({thingsdate} & {m_mask}) >> 12" 696 day = f"({thingsdate} & {d_mask}) >> 7" 697 698 isodate = f"printf('%d-%02d-%02d', {year}, {month}, {day})" 699 # when thingsdate is NULL, return thingsdate as-is 700 return f"CASE WHEN {thingsdate} THEN {isodate} ELSE {thingsdate} END" 701 702 703def dict_factory(cursor, row): 704 """ 705 Convert SQL result into a dictionary. 706 707 See also: 708 https://docs.python.org/3/library/sqlite3.html#sqlite3.Connection.row_factory 709 """ 710 result = {} 711 for index, column in enumerate(cursor.description): 712 key, value = column[0], row[index] 713 if value is None and key in COLUMNS_TO_OMIT_IF_NONE: 714 continue 715 if value and key in COLUMNS_TO_TRANSFORM_TO_BOOL: 716 value = bool(value) 717 result[key] = value 718 return result 719 720 721def escape_string(string): 722 r""" 723 Escape SQLite string literal. 724 725 Three notes: 726 727 1. A single quote within a SQLite string can be encoded by putting 728 two single quotes in a row. Escapes using the backslash character 729 are not supported in SQLite. 730 731 2. Null characters '\0' within strings can lead to surprising 732 behavior. However, `cursor.execute` will already throw a `ValueError` 733 if it finds a null character in the query, so we let it handle 734 this case for us. 735 736 3. Eventually we might want to make use of parameters instead of 737 manually escaping. Since this will require some refactoring, 738 we are going with the easiest solution for now. 739 740 See: https://www.sqlite.org/lang_expr.html#literal_values_constants_ 741 """ 742 return string.replace("'", "''") 743 744 745def isodate_to_yyyyyyyyyyymmmmddddd(value: str): 746 """ 747 Return integer, in binary YYYYYYYYYYYMMMMDDDDD0000000. 748 749 Y is year, M is month, D is day as binary. 750 See also `convert_isodate_sql_expression_to_thingsdate`. 751 752 Parameters 753 ---------- 754 value : str 755 ISO 8601 date str 756 757 Example 758 ------- 759 >>> isodate_to_yyyyyyyyyyymmmmddddd('2021-03-28') 760 132464128 761 """ 762 year, month, day = map(int, value.split("-")) 763 return year << 16 | month << 12 | day << 7 764 765 766def list_factory(_cursor, row): 767 """Convert SQL selects of one column into a list.""" 768 return row[0] 769 770 771def make_filter(column, value): 772 """ 773 Return SQL filter 'AND {column} = "{value}"'. 774 775 Special handling if `value` is `bool` or `None`. 776 777 Examples 778 -------- 779 >>> make_filter('title', 'Important') 780 "AND title = 'Important'" 781 782 >>> make_filter('startDate', True) 783 'AND startDate IS NOT NULL' 784 785 >>> make_filter('startDate', False) 786 'AND startDate IS NULL' 787 788 >>> make_filter('title', None) 789 '' 790 """ 791 default = f"AND {column} = '{escape_string(str(value))}'" 792 return { 793 None: "", 794 False: f"AND {column} IS NULL", 795 True: f"AND {column} IS NOT NULL", 796 }.get(value, default) 797 798 799def make_or_filter(*filters): 800 """Join filters with OR.""" 801 filters = filter(None, filters) # type: ignore 802 filters = [remove_prefix(filter, "AND ") for filter in filters] # type: ignore 803 filters = " OR ".join(filters) # type: ignore 804 return f"AND ({filters})" if filters else "" 805 806 807def make_search_filter(query: Optional[str]) -> str: 808 """ 809 Return a SQL filter to search tasks by a string query. 810 811 Example: 812 -------- 813 >>> make_search_filter('dinner') 814 "AND (TASK.title LIKE '%dinner%' OR TASK.notes LIKE '%dinner%' OR \ 815 AREA.title LIKE '%dinner%')" 816 """ 817 if not query: 818 return "" 819 820 query = escape_string(query) 821 822 # noqa todo 'TMChecklistItem.title' 823 columns = ["TASK.title", "TASK.notes", "AREA.title"] 824 825 sub_searches = (f"{column} LIKE '%{query}%'" for column in columns) 826 827 return f"AND ({' OR '.join(sub_searches)})" 828 829 830def make_thingsdate_filter(date_column: str, value) -> str: 831 """ 832 Return a SQL filter for "Things date" columns. 833 834 Parameters 835 ---------- 836 date_column : str 837 Name of the column that has date information on a task 838 stored as an INTEGER in "Things date" format. 839 See `convert_isodate_sql_expression_to_thingsdate` for details 840 on Things dates. 841 842 value : bool, 'future', 'past', ISO 8601 date str, or None 843 `True` or `False` indicates whether a date is set or not. 844 `'future'` or `'past'` indicates a date in the future or past. 845 ISO 8601 date str is in the format "YYYY-MM-DD", possibly 846 prefixed with an operator such as ">YYYY-MM-DD", 847 "=YYYY-MM-DD", "<=YYYY-MM-DD", etc. 848 `None` indicates any value. 849 850 Returns 851 ------- 852 str 853 A date filter for the SQL query. If `value == None`, then 854 return the empty string. 855 856 Examples 857 -------- 858 >>> make_thingsdate_filter('startDate', True) 859 'AND startDate IS NOT NULL' 860 861 >>> make_thingsdate_filter('startDate', False) 862 'AND startDate IS NULL' 863 864 >>> make_thingsdate_filter('startDate', 'future') 865 "AND startDate > ((strftime('%Y', date('now', 'localtime')) << 16) \ 866 | (strftime('%m', date('now', 'localtime')) << 12) \ 867 | (strftime('%d', date('now', 'localtime')) << 7))" 868 869 >>> make_thingsdate_filter('deadline', '2021-03-28') 870 'AND deadline == 132464128' 871 872 >>> make_thingsdate_filter('deadline', '=2021-03-28') 873 'AND deadline = 132464128' 874 875 >>> make_thingsdate_filter('deadline', '<=2021-03-28') 876 'AND deadline <= 132464128' 877 878 >>> make_thingsdate_filter('deadline', None) 879 '' 880 881 """ 882 if value is None: 883 return "" 884 885 if isinstance(value, bool): 886 return make_filter(date_column, value) 887 888 # Check for ISO 8601 date str + optional operator 889 match = match_date(value) 890 if match: 891 comparator, isodate = match.groups() 892 if not comparator: 893 comparator = "==" 894 thingsdate = isodate_to_yyyyyyyyyyymmmmddddd(isodate) 895 threshold = str(thingsdate) 896 else: 897 # "future" or "past" 898 validate("value", value, ["future", "past"]) 899 threshold = convert_isodate_sql_expression_to_thingsdate( 900 "date('now', 'localtime')", null_possible=False 901 ) 902 comparator = ">" if value == "future" else "<=" 903 904 return f"AND {date_column} {comparator} {threshold}" 905 906 907def make_truthy_filter(column: str, value) -> str: 908 """ 909 Return a SQL filter that matches if a column is truthy or falsy. 910 911 Truthy means TRUE. Falsy means FALSE or NULL. This is akin 912 to how Python defines it natively. 913 914 Passing in `value == None` returns the empty string. 915 916 Examples 917 -------- 918 >>> make_truthy_filter('PROJECT.trashed', True) 919 'AND PROJECT.trashed' 920 921 >>> make_truthy_filter('PROJECT.trashed', False) 922 'AND NOT IFNULL(PROJECT.trashed, 0)' 923 924 >>> make_truthy_filter('PROJECT.trashed', None) 925 '' 926 """ 927 if value is None: 928 return "" 929 930 if value: 931 return f"AND {column}" 932 933 return f"AND NOT IFNULL({column}, 0)" 934 935 936def make_unixtime_filter(date_column: str, value) -> str: 937 """ 938 Return a SQL filter for UNIX time columns. 939 940 Parameters 941 ---------- 942 date_column : str 943 Name of the column that has datetime information on a task 944 stored in UNIX time, that is, number of seconds since 945 1970-01-01 00:00 UTC. 946 947 value : bool, 'future', 'past', ISO 8601 date str, or None 948 `True` or `False` indicates whether a date is set or not. 949 `'future'` or `'past'` indicates a date in the future or past. 950 ISO 8601 date str is in the format "YYYY-MM-DD", possibly 951 prefixed with an operator such as ">YYYY-MM-DD", 952 "=YYYY-MM-DD", "<=YYYY-MM-DD", etc. 953 `None` indicates any value. 954 955 Returns 956 ------- 957 str 958 A date filter for the SQL query. If `value == None`, then 959 return the empty string. 960 961 Examples 962 -------- 963 >>> make_unixtime_filter('stopDate', True) 964 'AND stopDate IS NOT NULL' 965 966 >>> make_unixtime_filter('stopDate', False) 967 'AND stopDate IS NULL' 968 969 >>> make_unixtime_filter('stopDate', 'future') 970 "AND date(stopDate, 'unixepoch', 'localtime') > date('now', 'localtime')" 971 972 >>> make_unixtime_filter('creationDate', '2021-03-28') 973 "AND date(creationDate, 'unixepoch', 'localtime') == date('2021-03-28')" 974 975 >>> make_unixtime_filter('creationDate', '=2021-03-28') 976 "AND date(creationDate, 'unixepoch', 'localtime') = date('2021-03-28')" 977 978 >>> make_unixtime_filter('creationDate', '<=2021-03-28') 979 "AND date(creationDate, 'unixepoch', 'localtime') <= date('2021-03-28')" 980 981 >>> make_unixtime_filter('creationDate', None) 982 '' 983 984 """ 985 if value is None: 986 return "" 987 988 if isinstance(value, bool): 989 return make_filter(date_column, value) 990 991 # Check for ISO 8601 date str + optional operator 992 match = match_date(value) 993 if match: 994 comparator, isodate = match.groups() 995 if not comparator: 996 comparator = "==" 997 threshold = f"date('{isodate}')" 998 else: 999 # "future" or "past" 1000 validate("value", value, ["future", "past"]) 1001 threshold = "date('now', 'localtime')" 1002 comparator = ">" if value == "future" else "<=" 1003 1004 date = f"date({date_column}, 'unixepoch', 'localtime')" 1005 1006 return f"AND {date} {comparator} {threshold}" 1007 1008 1009def make_unixtime_range_filter(date_column: str, offset) -> str: 1010 """ 1011 Return a SQL filter to limit a Unix time to last X days, weeks, or years. 1012 1013 Parameters 1014 ---------- 1015 date_column : str 1016 Name of the column that has datetime information on a task 1017 stored in UNIX time, that is, number of seconds since 1018 1970-01-01 00:00 UTC. 1019 1020 offset : str or None 1021 A string comprised of an integer and a single character that can 1022 be 'd', 'w', or 'y' that determines whether to return all tasks 1023 for the past X days, weeks, or years. 1024 1025 Returns 1026 ------- 1027 str 1028 A date filter for the SQL query. If `offset == None`, then 1029 return the empty string. 1030 1031 Examples 1032 -------- 1033 >>> make_unixtime_range_filter('creationDate', '3d') 1034 "AND datetime(creationDate, 'unixepoch', 'localtime') > datetime('now', '-3 days')" 1035 1036 >>> make_unixtime_range_filter('creationDate', None) 1037 '' 1038 """ 1039 if offset is None: 1040 return "" 1041 1042 validate_offset("offset", offset) 1043 number, suffix = int(offset[:-1]), offset[-1] 1044 1045 if suffix == "d": 1046 modifier = f"-{number} days" 1047 elif suffix == "w": 1048 modifier = f"-{number * 7} days" 1049 elif suffix == "y": 1050 modifier = f"-{number} years" 1051 else: 1052 # Use `typing.assert_never(suffix)` from Python 3.11 onwards. 1053 raise AssertionError("line should be unreachable.") # for static code analyzers 1054 1055 column_datetime = f"datetime({date_column}, 'unixepoch', 'localtime')" 1056 offset_datetime = f"datetime('now', '{modifier}')" 1057 1058 return f"AND {column_datetime} > {offset_datetime}" 1059 1060 1061def match_date(value): 1062 """Return a match object if value is an ISO 8601 date str.""" 1063 return re.fullmatch(r"(=|==|<|<=|>|>=)?(\d{4}-\d{2}-\d{2})", value) 1064 1065 1066def prettify_sql(sql_query): 1067 """Make a SQL query easier to read for humans.""" 1068 # remove indentation and leading and trailing whitespace 1069 result = dedent(sql_query).strip() 1070 # remove empty lines 1071 return re.sub(r"^$\n", "", result, flags=re.MULTILINE) 1072 1073 1074def remove_prefix(text, prefix): 1075 """Remove prefix from text (as removeprefix() is 3.9+ only).""" 1076 return text[text.startswith(prefix) and len(prefix) :] 1077 1078 1079def validate(parameter, argument, valid_arguments): 1080 """ 1081 For a given parameter, check if its argument type is valid. 1082 1083 If not, then raise `ValueError`. 1084 1085 Examples 1086 -------- 1087 >>> validate( 1088 ... parameter='status', 1089 ... argument='completed', 1090 ... valid_arguments=['incomplete', 'completed'] 1091 ... ) 1092 ... 1093 1094 >>> validate( 1095 ... parameter='status', 1096 ... argument='XYZXZY', 1097 ... valid_arguments=['incomplete', 'completed'] 1098 ... ) 1099 Traceback (most recent call last): 1100 ... 1101 ValueError: Unrecognized status type: 'XYZXZY' 1102 Valid status types are ['incomplete', 'completed'] 1103 """ 1104 if argument in valid_arguments: 1105 return 1106 message = f"Unrecognized {parameter} type: {argument!r}" 1107 message += f"\nValid {parameter} types are {valid_arguments}" 1108 raise ValueError(message) 1109 1110 1111def validate_date(parameter, argument): 1112 """ 1113 For a given date parameter, check if its argument is valid. 1114 1115 If not, then raise `ValueError`. 1116 1117 Examples 1118 -------- 1119 >>> validate_date(parameter='startDate', argument=None) 1120 >>> validate_date(parameter='startDate', argument='future') 1121 >>> validate_date(parameter='startDate', argument='2020-01-01') 1122 >>> validate_date(parameter='startDate', argument='<=2020-01-01') 1123 1124 >>> validate_date(parameter='stopDate', argument='=2020-01-01') 1125 1126 >>> validate_date(parameter='deadline', argument='XYZ') 1127 Traceback (most recent call last): 1128 ... 1129 ValueError: Invalid deadline argument: 'XYZ' 1130 Please see the documentation for `deadline` in `things.tasks`. 1131 """ 1132 if argument is None: 1133 return 1134 1135 if argument in list(DATES): 1136 return 1137 1138 if not isinstance(argument, str): 1139 raise ValueError( 1140 f"Invalid {parameter} argument: {argument!r}\n" 1141 f"Please specify a string or None." 1142 ) 1143 1144 match = match_date(argument) 1145 if not match: 1146 raise ValueError( 1147 f"Invalid {parameter} argument: {argument!r}\n" 1148 f"Please see the documentation for `{parameter}` in `things.tasks`." 1149 ) 1150 1151 _, isodate = match.groups() 1152 try: 1153 datetime.date.fromisoformat(isodate) 1154 except ValueError as error: 1155 raise ValueError( 1156 f"Invalid {parameter} argument: {argument!r}\n{error}" 1157 ) from error 1158 1159 1160def validate_offset(parameter, argument): 1161 """ 1162 For a given offset parameter, check if its argument is valid. 1163 1164 If not, then raise `ValueError`. 1165 1166 Examples 1167 -------- 1168 >>> validate_offset(parameter='last', argument='3d') 1169 1170 >>> validate_offset(parameter='last', argument='XYZ') 1171 Traceback (most recent call last): 1172 ... 1173 ValueError: Invalid last argument: 'XYZ' 1174 Please specify a string of the format 'X[d/w/y]' where X is ... 1175 """ 1176 if argument is None: 1177 return 1178 1179 if not isinstance(argument, str): 1180 raise ValueError( 1181 f"Invalid {parameter} argument: {argument!r}\n" 1182 f"Please specify a string or None." 1183 ) 1184 1185 suffix = argument[-1:] # slicing here to handle empty strings 1186 if suffix not in ("d", "w", "y"): 1187 raise ValueError( 1188 f"Invalid {parameter} argument: {argument!r}\n" 1189 f"Please specify a string of the format 'X[d/w/y]' " 1190 "where X is a non-negative integer followed by 'd', 'w', or 'y' " 1191 "that indicates days, weeks, or years." 1192 )
152class Database: 153 """ 154 Access Things SQL database. 155 156 Parameters 157 ---------- 158 filepath : str, optional 159 Any valid path of a SQLite database file generated by the Things app. 160 If the environment variable `THINGSDB` is set, then use that path. 161 Otherwise, access the default database path. 162 163 print_sql : bool, default False 164 Print every SQL query performed. Some may contain '?' and ':' 165 characters which correspond to SQLite parameter tokens. 166 See https://www.sqlite.org/lang_expr.html#varparam 167 168 :raises AssertionError: If the database version is too old. 169 """ 170 171 debug = False 172 connection: sqlite3.Connection 173 174 # pylint: disable=R0913 175 def __init__(self, filepath=None, print_sql=False): 176 """Set up the database.""" 177 self.filepath = ( 178 filepath 179 or os.getenv(ENVIRONMENT_VARIABLE_WITH_FILEPATH) 180 or DEFAULT_FILEPATH 181 ) 182 self.print_sql = print_sql 183 if self.print_sql: 184 self.execute_query_count = 0 185 186 # "ro" means read-only 187 # See: https://sqlite.org/uri.html#recognized_query_parameters 188 uri = f"file:{self.filepath}?mode=ro" 189 self.connection = sqlite3.connect(uri, uri=True) # pylint: disable=E1101 190 191 # Test for migrated database in Things 3.15.16+ 192 # -------------------------------- 193 assert self.get_version() > 21, ( 194 "Your database is in an older format. " 195 "Run 'pip install things.py==0.0.14' to downgrade to an older " 196 "version of this library." 197 ) 198 199 # Automated migration to new database location in Things 3.12.6/3.13.1 200 # -------------------------------- 201 try: 202 with open(self.filepath, encoding="utf-8") as file: 203 if "Your database file has been moved there" in file.readline(): 204 self.filepath = DEFAULT_FILEPATH 205 except (UnicodeDecodeError, FileNotFoundError, PermissionError): 206 pass # binary file (old database) or doesn't exist 207 # -------------------------------- 208 209 # Core methods 210 211 def get_tasks( # pylint: disable=R0914,R0917 212 self, 213 uuid: Optional[str] = None, 214 type: Optional[str] = None, # pylint: disable=W0622 215 status: Optional[str] = None, 216 start: Optional[str] = None, 217 area: Optional[Union[str, bool]] = None, 218 project: Optional[Union[str, bool]] = None, 219 heading: Optional[str] = None, 220 tag: Optional[Union[str, bool]] = None, 221 start_date: Optional[Union[str, bool]] = None, 222 stop_date: Optional[Union[str, bool]] = None, 223 deadline: Optional[Union[str, bool]] = None, 224 deadline_suppressed: Optional[bool] = None, 225 trashed: Optional[bool] = False, 226 context_trashed: Optional[bool] = False, 227 last: Optional[str] = None, 228 search_query: Optional[str] = None, 229 index: str = "index", 230 count_only: bool = False, 231 ): 232 """Get tasks. See `things.api.tasks` for details on parameters.""" 233 if uuid: 234 return self.get_task_by_uuid(uuid, count_only=count_only) 235 236 # Overwrites 237 start = start and start.title() 238 239 # Validation 240 validate_date("deadline", deadline) 241 validate("deadline_suppressed", deadline_suppressed, [None, True, False]) 242 validate("start", start, [None] + list(START_TO_FILTER)) 243 validate_date("start_date", start_date) 244 validate_date("stop_date", stop_date) 245 validate("status", status, [None] + list(STATUS_TO_FILTER)) 246 validate("trashed", trashed, [None] + list(TRASHED_TO_FILTER)) 247 validate("type", type, [None] + list(TYPE_TO_FILTER)) 248 validate("context_trashed", context_trashed, [None, True, False]) 249 validate("index", index, list(INDICES)) 250 validate_offset("last", last) 251 252 if tag is not None: 253 valid_tags = self.get_tags(titles_only=True) 254 validate("tag", tag, [None] + list(valid_tags)) 255 256 # Query 257 # TK: might consider executing SQL with parameters instead. 258 # See: https://docs.python.org/3/library/sqlite3.html#sqlite3.Cursor.execute 259 260 start_filter: str = START_TO_FILTER.get(start, "") # type: ignore 261 status_filter: str = STATUS_TO_FILTER.get(status, "") # type: ignore 262 trashed_filter: str = TRASHED_TO_FILTER.get(trashed, "") # type: ignore 263 type_filter: str = TYPE_TO_FILTER.get(type, "") # type: ignore 264 265 # Sometimes a task is _not_ set to trashed, but its context 266 # (project or heading it is contained within) is set to trashed. 267 # In those cases, the task wouldn't show up in any app view 268 # except for "Trash". 269 project_trashed_filter = make_truthy_filter("PROJECT.trashed", context_trashed) 270 project_of_heading_trashed_filter = make_truthy_filter( 271 "PROJECT_OF_HEADING.trashed", context_trashed 272 ) 273 274 # As a task assigned to a heading is not directly assigned to a project anymore, 275 # we need to check if the heading is assigned to a project. 276 # See, e.g. https://github.com/thingsapi/things.py/issues/94 277 project_filter = make_or_filter( 278 make_filter("TASK.project", project), 279 make_filter("PROJECT_OF_HEADING.uuid", project), 280 ) 281 282 where_predicate = f""" 283 TASK.{IS_NOT_RECURRING} 284 {trashed_filter and f"AND TASK.{trashed_filter}"} 285 {project_trashed_filter} 286 {project_of_heading_trashed_filter} 287 {type_filter and f"AND TASK.{type_filter}"} 288 {start_filter and f"AND TASK.{start_filter}"} 289 {status_filter and f"AND TASK.{status_filter}"} 290 {make_filter('TASK.uuid', uuid)} 291 {make_filter("TASK.area", area)} 292 {project_filter} 293 {make_filter("TASK.heading", heading)} 294 {make_filter("TASK.deadlineSuppressionDate", deadline_suppressed)} 295 {make_filter("TAG.title", tag)} 296 {make_thingsdate_filter(f"TASK.{DATE_START}", start_date)} 297 {make_unixtime_filter(f"TASK.{DATE_STOP}", stop_date)} 298 {make_thingsdate_filter(f"TASK.{DATE_DEADLINE}", deadline)} 299 {make_unixtime_range_filter(f"TASK.{DATE_CREATED}", last)} 300 {make_search_filter(search_query)} 301 """ 302 order_predicate = f'TASK."{index}"' 303 304 sql_query = make_tasks_sql_query(where_predicate, order_predicate) 305 306 if count_only: 307 return self.get_count(sql_query) 308 309 return self.execute_query(sql_query) 310 311 def get_task_by_uuid(self, uuid, count_only=False): 312 """Get a task by uuid. Raise `ValueError` if not found.""" 313 where_predicate = "TASK.uuid = ?" 314 sql_query = make_tasks_sql_query(where_predicate) 315 parameters = (uuid,) 316 317 if count_only: 318 return self.get_count(sql_query, parameters) 319 320 result = self.execute_query(sql_query, parameters) 321 if not result: 322 raise ValueError(f"No such task uuid found: {uuid!r}") 323 324 return result 325 326 def get_areas(self, uuid=None, tag=None, count_only=False): 327 """Get areas. See `api.areas` for details on parameters.""" 328 # Validation 329 if tag is not None: 330 valid_tags = self.get_tags(titles_only=True) 331 validate("tag", tag, [None] + list(valid_tags)) 332 333 if ( 334 uuid 335 and count_only is False 336 and not self.get_areas(uuid=uuid, count_only=True) 337 ): 338 raise ValueError(f"No such area uuid found: {uuid!r}") 339 340 # Query 341 sql_query = f""" 342 SELECT DISTINCT 343 AREA.uuid, 344 'area' as type, 345 AREA.title, 346 CASE 347 WHEN AREA_TAG.areas IS NOT NULL THEN 1 348 END AS tags 349 FROM 350 {TABLE_AREA} AS AREA 351 LEFT OUTER JOIN 352 {TABLE_AREATAG} AREA_TAG ON AREA_TAG.areas = AREA.uuid 353 LEFT OUTER JOIN 354 {TABLE_TAG} TAG ON TAG.uuid = AREA_TAG.tags 355 WHERE 356 TRUE 357 {make_filter('TAG.title', tag)} 358 {make_filter('AREA.uuid', uuid)} 359 ORDER BY AREA."index" 360 """ 361 362 if count_only: 363 return self.get_count(sql_query) 364 365 return self.execute_query(sql_query) 366 367 def get_checklist_items(self, todo_uuid=None): 368 """Get checklist items.""" 369 sql_query = f""" 370 SELECT 371 CHECKLIST_ITEM.title, 372 CASE 373 WHEN CHECKLIST_ITEM.{IS_INCOMPLETE} THEN 'incomplete' 374 WHEN CHECKLIST_ITEM.{IS_CANCELED} THEN 'canceled' 375 WHEN CHECKLIST_ITEM.{IS_COMPLETED} THEN 'completed' 376 END AS status, 377 date(CHECKLIST_ITEM.stopDate, "unixepoch", "localtime") AS stop_date, 378 'checklist-item' as type, 379 CHECKLIST_ITEM.uuid, 380 datetime( 381 CHECKLIST_ITEM.{DATE_MODIFIED}, "unixepoch", "localtime" 382 ) AS created, 383 datetime( 384 CHECKLIST_ITEM.{DATE_MODIFIED}, "unixepoch", "localtime" 385 ) AS modified 386 FROM 387 {TABLE_CHECKLIST_ITEM} AS CHECKLIST_ITEM 388 WHERE 389 CHECKLIST_ITEM.task = ? 390 ORDER BY CHECKLIST_ITEM."index" 391 """ 392 return self.execute_query(sql_query, (todo_uuid,)) 393 394 def get_tags(self, title=None, area=None, task=None, titles_only=False): 395 """Get tags. See `api.tags` for details on parameters.""" 396 # Validation 397 if title is not None: 398 valid_titles = self.get_tags(titles_only=True) 399 validate("title", title, [None] + list(valid_titles)) 400 401 # Query 402 if task: 403 return self.get_tags_of_task(task) 404 if area: 405 return self.get_tags_of_area(area) 406 407 if titles_only: 408 sql_query = f'SELECT title FROM {TABLE_TAG} ORDER BY "index"' 409 return self.execute_query(sql_query, row_factory=list_factory) 410 411 sql_query = f""" 412 SELECT 413 uuid, 'tag' AS type, title, shortcut 414 FROM 415 {TABLE_TAG} 416 WHERE 417 TRUE 418 {make_filter('title', title)} 419 ORDER BY "index" 420 """ 421 422 return self.execute_query(sql_query) 423 424 def get_tags_of_task(self, task_uuid): 425 """Get tag titles of task.""" 426 sql_query = f""" 427 SELECT 428 TAG.title 429 FROM 430 {TABLE_TASKTAG} AS TASK_TAG 431 LEFT OUTER JOIN 432 {TABLE_TAG} TAG ON TAG.uuid = TASK_TAG.tags 433 WHERE 434 TASK_TAG.tasks = ? 435 ORDER BY TAG."index" 436 """ 437 return self.execute_query( 438 sql_query, parameters=(task_uuid,), row_factory=list_factory 439 ) 440 441 def get_tags_of_area(self, area_uuid): 442 """Get tag titles for area.""" 443 sql_query = f""" 444 SELECT 445 AREA.title 446 FROM 447 {TABLE_AREATAG} AS AREA_TAG 448 LEFT OUTER JOIN 449 {TABLE_TAG} AREA ON AREA.uuid = AREA_TAG.tags 450 WHERE 451 AREA_TAG.areas = ? 452 ORDER BY AREA."index" 453 """ 454 return self.execute_query( 455 sql_query, parameters=(area_uuid,), row_factory=list_factory 456 ) 457 458 def get_version(self): 459 """Get Things Database version.""" 460 sql_query = f"SELECT value FROM {TABLE_META} WHERE key = 'databaseVersion'" 461 result = self.execute_query(sql_query, row_factory=list_factory) 462 plist_bytes = result[0].encode() 463 return plistlib.loads(plist_bytes) 464 465 # pylint: disable=R1710 466 def get_url_scheme_auth_token(self): 467 """Get the Things URL scheme authentication token.""" 468 sql_query = f""" 469 SELECT 470 uriSchemeAuthenticationToken 471 FROM 472 {TABLE_SETTINGS} 473 WHERE 474 uuid = 'RhAzEf6qDxCD5PmnZVtBZR' 475 """ 476 rows = self.execute_query(sql_query, row_factory=list_factory) 477 return rows[0] 478 479 def get_count(self, sql_query, parameters=()): 480 """Count number of results.""" 481 count_sql_query = f"""SELECT COUNT(uuid) FROM (\n{sql_query}\n)""" 482 rows = self.execute_query( 483 count_sql_query, row_factory=list_factory, parameters=parameters 484 ) 485 return rows[0] 486 487 # noqa todo: add type hinting for resutl (List[Tuple[str, Any]]?) 488 def execute_query(self, sql_query, parameters=(), row_factory=None): 489 """Run the actual SQL query.""" 490 if self.print_sql or self.debug: 491 if not hasattr(self, "execute_query_count"): 492 # This is needed for historical `self.debug`. 493 # TK: might consider removing `debug` flag. 494 self.execute_query_count = 0 495 self.execute_query_count += 1 496 if self.debug: 497 print(f"/* Filepath {self.filepath!r} */") 498 print(f"/* Query {self.execute_query_count} */") 499 if parameters: 500 print(f"/* Parameters: {parameters!r} */") 501 print() 502 print(prettify_sql(sql_query)) 503 print() 504 505 with self.connection: 506 # Using context manager to keep queries in separate transactions, 507 # see https://docs.python.org/3/library/sqlite3.html#sqlite3-connection-context-manager 508 self.connection.row_factory = row_factory or dict_factory 509 cursor = self.connection.cursor() 510 cursor.execute(sql_query, parameters) 511 512 return cursor.fetchall()
Access Things SQL database.
Parameters
- filepath (str, optional):
Any valid path of a SQLite database file generated by the Things app.
If the environment variable
THINGSDB
is set, then use that path. Otherwise, access the default database path. - print_sql (bool, default False): Print every SQL query performed. Some may contain '?' and ':' characters which correspond to SQLite parameter tokens. See https://www.sqlite.org/lang_expr.html#varparam
- ###### Raises: - AssertionError: If the database version is too old.
175 def __init__(self, filepath=None, print_sql=False): 176 """Set up the database.""" 177 self.filepath = ( 178 filepath 179 or os.getenv(ENVIRONMENT_VARIABLE_WITH_FILEPATH) 180 or DEFAULT_FILEPATH 181 ) 182 self.print_sql = print_sql 183 if self.print_sql: 184 self.execute_query_count = 0 185 186 # "ro" means read-only 187 # See: https://sqlite.org/uri.html#recognized_query_parameters 188 uri = f"file:{self.filepath}?mode=ro" 189 self.connection = sqlite3.connect(uri, uri=True) # pylint: disable=E1101 190 191 # Test for migrated database in Things 3.15.16+ 192 # -------------------------------- 193 assert self.get_version() > 21, ( 194 "Your database is in an older format. " 195 "Run 'pip install things.py==0.0.14' to downgrade to an older " 196 "version of this library." 197 ) 198 199 # Automated migration to new database location in Things 3.12.6/3.13.1 200 # -------------------------------- 201 try: 202 with open(self.filepath, encoding="utf-8") as file: 203 if "Your database file has been moved there" in file.readline(): 204 self.filepath = DEFAULT_FILEPATH 205 except (UnicodeDecodeError, FileNotFoundError, PermissionError): 206 pass # binary file (old database) or doesn't exist 207 # --------------------------------
Set up the database.
211 def get_tasks( # pylint: disable=R0914,R0917 212 self, 213 uuid: Optional[str] = None, 214 type: Optional[str] = None, # pylint: disable=W0622 215 status: Optional[str] = None, 216 start: Optional[str] = None, 217 area: Optional[Union[str, bool]] = None, 218 project: Optional[Union[str, bool]] = None, 219 heading: Optional[str] = None, 220 tag: Optional[Union[str, bool]] = None, 221 start_date: Optional[Union[str, bool]] = None, 222 stop_date: Optional[Union[str, bool]] = None, 223 deadline: Optional[Union[str, bool]] = None, 224 deadline_suppressed: Optional[bool] = None, 225 trashed: Optional[bool] = False, 226 context_trashed: Optional[bool] = False, 227 last: Optional[str] = None, 228 search_query: Optional[str] = None, 229 index: str = "index", 230 count_only: bool = False, 231 ): 232 """Get tasks. See `things.api.tasks` for details on parameters.""" 233 if uuid: 234 return self.get_task_by_uuid(uuid, count_only=count_only) 235 236 # Overwrites 237 start = start and start.title() 238 239 # Validation 240 validate_date("deadline", deadline) 241 validate("deadline_suppressed", deadline_suppressed, [None, True, False]) 242 validate("start", start, [None] + list(START_TO_FILTER)) 243 validate_date("start_date", start_date) 244 validate_date("stop_date", stop_date) 245 validate("status", status, [None] + list(STATUS_TO_FILTER)) 246 validate("trashed", trashed, [None] + list(TRASHED_TO_FILTER)) 247 validate("type", type, [None] + list(TYPE_TO_FILTER)) 248 validate("context_trashed", context_trashed, [None, True, False]) 249 validate("index", index, list(INDICES)) 250 validate_offset("last", last) 251 252 if tag is not None: 253 valid_tags = self.get_tags(titles_only=True) 254 validate("tag", tag, [None] + list(valid_tags)) 255 256 # Query 257 # TK: might consider executing SQL with parameters instead. 258 # See: https://docs.python.org/3/library/sqlite3.html#sqlite3.Cursor.execute 259 260 start_filter: str = START_TO_FILTER.get(start, "") # type: ignore 261 status_filter: str = STATUS_TO_FILTER.get(status, "") # type: ignore 262 trashed_filter: str = TRASHED_TO_FILTER.get(trashed, "") # type: ignore 263 type_filter: str = TYPE_TO_FILTER.get(type, "") # type: ignore 264 265 # Sometimes a task is _not_ set to trashed, but its context 266 # (project or heading it is contained within) is set to trashed. 267 # In those cases, the task wouldn't show up in any app view 268 # except for "Trash". 269 project_trashed_filter = make_truthy_filter("PROJECT.trashed", context_trashed) 270 project_of_heading_trashed_filter = make_truthy_filter( 271 "PROJECT_OF_HEADING.trashed", context_trashed 272 ) 273 274 # As a task assigned to a heading is not directly assigned to a project anymore, 275 # we need to check if the heading is assigned to a project. 276 # See, e.g. https://github.com/thingsapi/things.py/issues/94 277 project_filter = make_or_filter( 278 make_filter("TASK.project", project), 279 make_filter("PROJECT_OF_HEADING.uuid", project), 280 ) 281 282 where_predicate = f""" 283 TASK.{IS_NOT_RECURRING} 284 {trashed_filter and f"AND TASK.{trashed_filter}"} 285 {project_trashed_filter} 286 {project_of_heading_trashed_filter} 287 {type_filter and f"AND TASK.{type_filter}"} 288 {start_filter and f"AND TASK.{start_filter}"} 289 {status_filter and f"AND TASK.{status_filter}"} 290 {make_filter('TASK.uuid', uuid)} 291 {make_filter("TASK.area", area)} 292 {project_filter} 293 {make_filter("TASK.heading", heading)} 294 {make_filter("TASK.deadlineSuppressionDate", deadline_suppressed)} 295 {make_filter("TAG.title", tag)} 296 {make_thingsdate_filter(f"TASK.{DATE_START}", start_date)} 297 {make_unixtime_filter(f"TASK.{DATE_STOP}", stop_date)} 298 {make_thingsdate_filter(f"TASK.{DATE_DEADLINE}", deadline)} 299 {make_unixtime_range_filter(f"TASK.{DATE_CREATED}", last)} 300 {make_search_filter(search_query)} 301 """ 302 order_predicate = f'TASK."{index}"' 303 304 sql_query = make_tasks_sql_query(where_predicate, order_predicate) 305 306 if count_only: 307 return self.get_count(sql_query) 308 309 return self.execute_query(sql_query)
Get tasks. See things.api.tasks
for details on parameters.
311 def get_task_by_uuid(self, uuid, count_only=False): 312 """Get a task by uuid. Raise `ValueError` if not found.""" 313 where_predicate = "TASK.uuid = ?" 314 sql_query = make_tasks_sql_query(where_predicate) 315 parameters = (uuid,) 316 317 if count_only: 318 return self.get_count(sql_query, parameters) 319 320 result = self.execute_query(sql_query, parameters) 321 if not result: 322 raise ValueError(f"No such task uuid found: {uuid!r}") 323 324 return result
Get a task by uuid. Raise ValueError
if not found.
326 def get_areas(self, uuid=None, tag=None, count_only=False): 327 """Get areas. See `api.areas` for details on parameters.""" 328 # Validation 329 if tag is not None: 330 valid_tags = self.get_tags(titles_only=True) 331 validate("tag", tag, [None] + list(valid_tags)) 332 333 if ( 334 uuid 335 and count_only is False 336 and not self.get_areas(uuid=uuid, count_only=True) 337 ): 338 raise ValueError(f"No such area uuid found: {uuid!r}") 339 340 # Query 341 sql_query = f""" 342 SELECT DISTINCT 343 AREA.uuid, 344 'area' as type, 345 AREA.title, 346 CASE 347 WHEN AREA_TAG.areas IS NOT NULL THEN 1 348 END AS tags 349 FROM 350 {TABLE_AREA} AS AREA 351 LEFT OUTER JOIN 352 {TABLE_AREATAG} AREA_TAG ON AREA_TAG.areas = AREA.uuid 353 LEFT OUTER JOIN 354 {TABLE_TAG} TAG ON TAG.uuid = AREA_TAG.tags 355 WHERE 356 TRUE 357 {make_filter('TAG.title', tag)} 358 {make_filter('AREA.uuid', uuid)} 359 ORDER BY AREA."index" 360 """ 361 362 if count_only: 363 return self.get_count(sql_query) 364 365 return self.execute_query(sql_query)
Get areas. See api.areas
for details on parameters.
367 def get_checklist_items(self, todo_uuid=None): 368 """Get checklist items.""" 369 sql_query = f""" 370 SELECT 371 CHECKLIST_ITEM.title, 372 CASE 373 WHEN CHECKLIST_ITEM.{IS_INCOMPLETE} THEN 'incomplete' 374 WHEN CHECKLIST_ITEM.{IS_CANCELED} THEN 'canceled' 375 WHEN CHECKLIST_ITEM.{IS_COMPLETED} THEN 'completed' 376 END AS status, 377 date(CHECKLIST_ITEM.stopDate, "unixepoch", "localtime") AS stop_date, 378 'checklist-item' as type, 379 CHECKLIST_ITEM.uuid, 380 datetime( 381 CHECKLIST_ITEM.{DATE_MODIFIED}, "unixepoch", "localtime" 382 ) AS created, 383 datetime( 384 CHECKLIST_ITEM.{DATE_MODIFIED}, "unixepoch", "localtime" 385 ) AS modified 386 FROM 387 {TABLE_CHECKLIST_ITEM} AS CHECKLIST_ITEM 388 WHERE 389 CHECKLIST_ITEM.task = ? 390 ORDER BY CHECKLIST_ITEM."index" 391 """ 392 return self.execute_query(sql_query, (todo_uuid,))
Get checklist items.
458 def get_version(self): 459 """Get Things Database version.""" 460 sql_query = f"SELECT value FROM {TABLE_META} WHERE key = 'databaseVersion'" 461 result = self.execute_query(sql_query, row_factory=list_factory) 462 plist_bytes = result[0].encode() 463 return plistlib.loads(plist_bytes)
Get Things Database version.
466 def get_url_scheme_auth_token(self): 467 """Get the Things URL scheme authentication token.""" 468 sql_query = f""" 469 SELECT 470 uriSchemeAuthenticationToken 471 FROM 472 {TABLE_SETTINGS} 473 WHERE 474 uuid = 'RhAzEf6qDxCD5PmnZVtBZR' 475 """ 476 rows = self.execute_query(sql_query, row_factory=list_factory) 477 return rows[0]
Get the Things URL scheme authentication token.
479 def get_count(self, sql_query, parameters=()): 480 """Count number of results.""" 481 count_sql_query = f"""SELECT COUNT(uuid) FROM (\n{sql_query}\n)""" 482 rows = self.execute_query( 483 count_sql_query, row_factory=list_factory, parameters=parameters 484 ) 485 return rows[0]
Count number of results.
488 def execute_query(self, sql_query, parameters=(), row_factory=None): 489 """Run the actual SQL query.""" 490 if self.print_sql or self.debug: 491 if not hasattr(self, "execute_query_count"): 492 # This is needed for historical `self.debug`. 493 # TK: might consider removing `debug` flag. 494 self.execute_query_count = 0 495 self.execute_query_count += 1 496 if self.debug: 497 print(f"/* Filepath {self.filepath!r} */") 498 print(f"/* Query {self.execute_query_count} */") 499 if parameters: 500 print(f"/* Parameters: {parameters!r} */") 501 print() 502 print(prettify_sql(sql_query)) 503 print() 504 505 with self.connection: 506 # Using context manager to keep queries in separate transactions, 507 # see https://docs.python.org/3/library/sqlite3.html#sqlite3-connection-context-manager 508 self.connection.row_factory = row_factory or dict_factory 509 cursor = self.connection.cursor() 510 cursor.execute(sql_query, parameters) 511 512 return cursor.fetchall()
Run the actual SQL query.
518def make_tasks_sql_query(where_predicate=None, order_predicate=None): 519 """Make SQL query for Task table.""" 520 where_predicate = where_predicate or "TRUE" 521 order_predicate = order_predicate or 'TASK."index"' 522 523 start_date_expression = convert_thingsdate_sql_expression_to_isodate( 524 f"TASK.{DATE_START}" 525 ) 526 deadline_expression = convert_thingsdate_sql_expression_to_isodate( 527 f"TASK.{DATE_DEADLINE}" 528 ) 529 530 return f""" 531 SELECT DISTINCT 532 TASK.uuid, 533 CASE 534 WHEN TASK.{IS_TODO} THEN 'to-do' 535 WHEN TASK.{IS_PROJECT} THEN 'project' 536 WHEN TASK.{IS_HEADING} THEN 'heading' 537 END AS type, 538 CASE 539 WHEN TASK.{IS_TRASHED} THEN 1 540 END AS trashed, 541 TASK.title, 542 CASE 543 WHEN TASK.{IS_INCOMPLETE} THEN 'incomplete' 544 WHEN TASK.{IS_CANCELED} THEN 'canceled' 545 WHEN TASK.{IS_COMPLETED} THEN 'completed' 546 END AS status, 547 CASE 548 WHEN AREA.uuid IS NOT NULL THEN AREA.uuid 549 END AS area, 550 CASE 551 WHEN AREA.uuid IS NOT NULL THEN AREA.title 552 END AS area_title, 553 CASE 554 WHEN PROJECT.uuid IS NOT NULL THEN PROJECT.uuid 555 END AS project, 556 CASE 557 WHEN PROJECT.uuid IS NOT NULL THEN PROJECT.title 558 END AS project_title, 559 CASE 560 WHEN HEADING.uuid IS NOT NULL THEN HEADING.uuid 561 END AS heading, 562 CASE 563 WHEN HEADING.uuid IS NOT NULL THEN HEADING.title 564 END AS heading_title, 565 TASK.notes, 566 CASE 567 WHEN TAG.uuid IS NOT NULL THEN 1 568 END AS tags, 569 CASE 570 WHEN TASK.{IS_INBOX} THEN 'Inbox' 571 WHEN TASK.{IS_ANYTIME} THEN 'Anytime' 572 WHEN TASK.{IS_SOMEDAY} THEN 'Someday' 573 END AS start, 574 CASE 575 WHEN CHECKLIST_ITEM.uuid IS NOT NULL THEN 1 576 END AS checklist, 577 date({start_date_expression}) AS start_date, 578 date({deadline_expression}) AS deadline, 579 datetime(TASK.{DATE_STOP}, "unixepoch", "localtime") AS "stop_date", 580 datetime(TASK.{DATE_CREATED}, "unixepoch", "localtime") AS created, 581 datetime(TASK.{DATE_MODIFIED}, "unixepoch", "localtime") AS modified, 582 TASK.'index', 583 TASK.todayIndex AS today_index 584 FROM 585 {TABLE_TASK} AS TASK 586 LEFT OUTER JOIN 587 {TABLE_TASK} PROJECT ON TASK.project = PROJECT.uuid 588 LEFT OUTER JOIN 589 {TABLE_AREA} AREA ON TASK.area = AREA.uuid 590 LEFT OUTER JOIN 591 {TABLE_TASK} HEADING ON TASK.heading = HEADING.uuid 592 LEFT OUTER JOIN 593 {TABLE_TASK} PROJECT_OF_HEADING 594 ON HEADING.project = PROJECT_OF_HEADING.uuid 595 LEFT OUTER JOIN 596 {TABLE_TASKTAG} TAGS ON TASK.uuid = TAGS.tasks 597 LEFT OUTER JOIN 598 {TABLE_TAG} TAG ON TAGS.tags = TAG.uuid 599 LEFT OUTER JOIN 600 {TABLE_CHECKLIST_ITEM} CHECKLIST_ITEM 601 ON TASK.uuid = CHECKLIST_ITEM.task 602 WHERE 603 {where_predicate} 604 ORDER BY 605 {order_predicate} 606 """
Make SQL query for Task table.
612def convert_isodate_sql_expression_to_thingsdate(sql_expression, null_possible=True): 613 """ 614 Return a SQL expression of an isodate converted into a "Things date". 615 616 A _Things date_ is an integer where the binary digits are 617 YYYYYYYYYYYMMMMDDDDD0000000; Y is year, M is month, and D is day. 618 619 For example, the ISO 8601 date '2021-03-28' corresponds to the Things 620 date 132464128 as integer; in binary that is: 621 111111001010011111000000000 622 YYYYYYYYYYYMMMMDDDDD0000000 623 2021 3 28 624 625 Parameters 626 ---------- 627 sql_expression : str 628 A sql expression evaluating to an ISO 8601 date str. 629 630 null_possible : bool 631 Can the input `sql_expression` evaluate to NULL? 632 633 Returns 634 ------- 635 str 636 A sql expression representing a "Things date" as integer. 637 638 Example 639 ------- 640 >>> convert_isodate_sql_expression_to_thingsdate("date('now', 'localtime')") 641 "(CASE WHEN date('now', 'localtime') THEN \ 642 ((strftime('%Y', date('now', 'localtime')) << 16) \ 643 | (strftime('%m', date('now', 'localtime')) << 12) \ 644 | (strftime('%d', date('now', 'localtime')) << 7)) \ 645 ELSE date('now', 'localtime') END)" 646 >>> convert_isodate_sql_expression_to_thingsdate("'2023-05-22'") 647 "(CASE WHEN '2023-05-22' THEN \ 648 ((strftime('%Y', '2023-05-22') << 16) \ 649 | (strftime('%m', '2023-05-22') << 12) \ 650 | (strftime('%d', '2023-05-22') << 7)) \ 651 ELSE '2023-05-22' END)" 652 """ 653 isodate = sql_expression 654 655 year = f"strftime('%Y', {isodate}) << 16" 656 month = f"strftime('%m', {isodate}) << 12" 657 day = f"strftime('%d', {isodate}) << 7" 658 659 thingsdate = f"(({year}) | ({month}) | ({day}))" 660 661 if null_possible: 662 # when isodate is NULL, return isodate as-is 663 return f"(CASE WHEN {isodate} THEN {thingsdate} ELSE {isodate} END)" 664 665 return thingsdate
Return a SQL expression of an isodate converted into a "Things date".
A _Things date_ is an integer where the binary digits are YYYYYYYYYYYMMMMDDDDD0000000; Y is year, M is month, and D is day.
For example, the ISO 8601 date '2021-03-28' corresponds to the Things date 132464128 as integer; in binary that is: 111111001010011111000000000 YYYYYYYYYYYMMMMDDDDD0000000 2021 3 28
Parameters
- sql_expression (str): A sql expression evaluating to an ISO 8601 date str.
- null_possible (bool):
Can the input
sql_expression
evaluate to NULL?
Returns
- str: A sql expression representing a "Things date" as integer.
Example
>>> convert_isodate_sql_expression_to_thingsdate("date('now', 'localtime')")
"(CASE WHEN date('now', 'localtime') THEN ((strftime('%Y', date('now', 'localtime')) << 16) | (strftime('%m', date('now', 'localtime')) << 12) | (strftime('%d', date('now', 'localtime')) << 7)) ELSE date('now', 'localtime') END)"
>>> convert_isodate_sql_expression_to_thingsdate("'2023-05-22'")
"(CASE WHEN '2023-05-22' THEN ((strftime('%Y', '2023-05-22') << 16) | (strftime('%m', '2023-05-22') << 12) | (strftime('%d', '2023-05-22') << 7)) ELSE '2023-05-22' END)"
668def convert_thingsdate_sql_expression_to_isodate(sql_expression): 669 """ 670 Return SQL expression as string. 671 672 Parameters 673 ---------- 674 sql_expression : str 675 A sql expression pointing to a "Things date" integer in 676 format YYYYYYYYYYYMMMMDDDDD0000000, in binary. 677 See: `convert_isodate_sql_expression_to_thingsdate` for details. 678 679 Example 680 ------- 681 >>> convert_thingsdate_sql_expression_to_isodate('132464128') 682 "CASE WHEN 132464128 THEN \ 683 printf('%d-%02d-%02d', (132464128 & 134152192) >> 16, \ 684 (132464128 & 61440) >> 12, (132464128 & 3968) >> 7) ELSE 132464128 END" 685 >>> convert_thingsdate_sql_expression_to_isodate('startDate') 686 "CASE WHEN startDate THEN \ 687 printf('%d-%02d-%02d', (startDate & 134152192) >> 16, \ 688 (startDate & 61440) >> 12, (startDate & 3968) >> 7) ELSE startDate END" 689 """ 690 y_mask = 0b111111111110000000000000000 691 m_mask = 0b000000000001111000000000000 692 d_mask = 0b000000000000000111110000000 693 694 thingsdate = sql_expression 695 year = f"({thingsdate} & {y_mask}) >> 16" 696 month = f"({thingsdate} & {m_mask}) >> 12" 697 day = f"({thingsdate} & {d_mask}) >> 7" 698 699 isodate = f"printf('%d-%02d-%02d', {year}, {month}, {day})" 700 # when thingsdate is NULL, return thingsdate as-is 701 return f"CASE WHEN {thingsdate} THEN {isodate} ELSE {thingsdate} END"
Return SQL expression as string.
Parameters
- sql_expression (str):
A sql expression pointing to a "Things date" integer in
format YYYYYYYYYYYMMMMDDDDD0000000, in binary.
See:
convert_isodate_sql_expression_to_thingsdate
for details.
Example
>>> convert_thingsdate_sql_expression_to_isodate('132464128')
"CASE WHEN 132464128 THEN printf('%d-%02d-%02d', (132464128 & 134152192) >> 16, (132464128 & 61440) >> 12, (132464128 & 3968) >> 7) ELSE 132464128 END"
>>> convert_thingsdate_sql_expression_to_isodate('startDate')
"CASE WHEN startDate THEN printf('%d-%02d-%02d', (startDate & 134152192) >> 16, (startDate & 61440) >> 12, (startDate & 3968) >> 7) ELSE startDate END"
704def dict_factory(cursor, row): 705 """ 706 Convert SQL result into a dictionary. 707 708 See also: 709 https://docs.python.org/3/library/sqlite3.html#sqlite3.Connection.row_factory 710 """ 711 result = {} 712 for index, column in enumerate(cursor.description): 713 key, value = column[0], row[index] 714 if value is None and key in COLUMNS_TO_OMIT_IF_NONE: 715 continue 716 if value and key in COLUMNS_TO_TRANSFORM_TO_BOOL: 717 value = bool(value) 718 result[key] = value 719 return result
Convert SQL result into a dictionary.
See also: https://docs.python.org/3/library/sqlite3.html#sqlite3.Connection.row_factory
722def escape_string(string): 723 r""" 724 Escape SQLite string literal. 725 726 Three notes: 727 728 1. A single quote within a SQLite string can be encoded by putting 729 two single quotes in a row. Escapes using the backslash character 730 are not supported in SQLite. 731 732 2. Null characters '\0' within strings can lead to surprising 733 behavior. However, `cursor.execute` will already throw a `ValueError` 734 if it finds a null character in the query, so we let it handle 735 this case for us. 736 737 3. Eventually we might want to make use of parameters instead of 738 manually escaping. Since this will require some refactoring, 739 we are going with the easiest solution for now. 740 741 See: https://www.sqlite.org/lang_expr.html#literal_values_constants_ 742 """ 743 return string.replace("'", "''")
Escape SQLite string literal.
Three notes:
A single quote within a SQLite string can be encoded by putting two single quotes in a row. Escapes using the backslash character are not supported in SQLite.
Null characters '\0' within strings can lead to surprising behavior. However,
cursor.execute
will already throw aValueError
if it finds a null character in the query, so we let it handle this case for us.Eventually we might want to make use of parameters instead of manually escaping. Since this will require some refactoring, we are going with the easiest solution for now.
See: https://www.sqlite.org/lang_expr.html#literal_values_constants_
746def isodate_to_yyyyyyyyyyymmmmddddd(value: str): 747 """ 748 Return integer, in binary YYYYYYYYYYYMMMMDDDDD0000000. 749 750 Y is year, M is month, D is day as binary. 751 See also `convert_isodate_sql_expression_to_thingsdate`. 752 753 Parameters 754 ---------- 755 value : str 756 ISO 8601 date str 757 758 Example 759 ------- 760 >>> isodate_to_yyyyyyyyyyymmmmddddd('2021-03-28') 761 132464128 762 """ 763 year, month, day = map(int, value.split("-")) 764 return year << 16 | month << 12 | day << 7
Return integer, in binary YYYYYYYYYYYMMMMDDDDD0000000.
Y is year, M is month, D is day as binary.
See also convert_isodate_sql_expression_to_thingsdate
.
Parameters
- value (str): ISO 8601 date str
Example
>>> isodate_to_yyyyyyyyyyymmmmddddd('2021-03-28')
132464128
767def list_factory(_cursor, row): 768 """Convert SQL selects of one column into a list.""" 769 return row[0]
Convert SQL selects of one column into a list.
772def make_filter(column, value): 773 """ 774 Return SQL filter 'AND {column} = "{value}"'. 775 776 Special handling if `value` is `bool` or `None`. 777 778 Examples 779 -------- 780 >>> make_filter('title', 'Important') 781 "AND title = 'Important'" 782 783 >>> make_filter('startDate', True) 784 'AND startDate IS NOT NULL' 785 786 >>> make_filter('startDate', False) 787 'AND startDate IS NULL' 788 789 >>> make_filter('title', None) 790 '' 791 """ 792 default = f"AND {column} = '{escape_string(str(value))}'" 793 return { 794 None: "", 795 False: f"AND {column} IS NULL", 796 True: f"AND {column} IS NOT NULL", 797 }.get(value, default)
Return SQL filter 'AND {column} = "{value}"'.
Special handling if value
is bool
or None
.
Examples
>>> make_filter('title', 'Important')
"AND title = 'Important'"
>>> make_filter('startDate', True)
'AND startDate IS NOT NULL'
>>> make_filter('startDate', False)
'AND startDate IS NULL'
>>> make_filter('title', None)
''
800def make_or_filter(*filters): 801 """Join filters with OR.""" 802 filters = filter(None, filters) # type: ignore 803 filters = [remove_prefix(filter, "AND ") for filter in filters] # type: ignore 804 filters = " OR ".join(filters) # type: ignore 805 return f"AND ({filters})" if filters else ""
Join filters with OR.
808def make_search_filter(query: Optional[str]) -> str: 809 """ 810 Return a SQL filter to search tasks by a string query. 811 812 Example: 813 -------- 814 >>> make_search_filter('dinner') 815 "AND (TASK.title LIKE '%dinner%' OR TASK.notes LIKE '%dinner%' OR \ 816 AREA.title LIKE '%dinner%')" 817 """ 818 if not query: 819 return "" 820 821 query = escape_string(query) 822 823 # noqa todo 'TMChecklistItem.title' 824 columns = ["TASK.title", "TASK.notes", "AREA.title"] 825 826 sub_searches = (f"{column} LIKE '%{query}%'" for column in columns) 827 828 return f"AND ({' OR '.join(sub_searches)})"
Return a SQL filter to search tasks by a string query.
Example:
>>> make_search_filter('dinner')
"AND (TASK.title LIKE '%dinner%' OR TASK.notes LIKE '%dinner%' OR AREA.title LIKE '%dinner%')"
831def make_thingsdate_filter(date_column: str, value) -> str: 832 """ 833 Return a SQL filter for "Things date" columns. 834 835 Parameters 836 ---------- 837 date_column : str 838 Name of the column that has date information on a task 839 stored as an INTEGER in "Things date" format. 840 See `convert_isodate_sql_expression_to_thingsdate` for details 841 on Things dates. 842 843 value : bool, 'future', 'past', ISO 8601 date str, or None 844 `True` or `False` indicates whether a date is set or not. 845 `'future'` or `'past'` indicates a date in the future or past. 846 ISO 8601 date str is in the format "YYYY-MM-DD", possibly 847 prefixed with an operator such as ">YYYY-MM-DD", 848 "=YYYY-MM-DD", "<=YYYY-MM-DD", etc. 849 `None` indicates any value. 850 851 Returns 852 ------- 853 str 854 A date filter for the SQL query. If `value == None`, then 855 return the empty string. 856 857 Examples 858 -------- 859 >>> make_thingsdate_filter('startDate', True) 860 'AND startDate IS NOT NULL' 861 862 >>> make_thingsdate_filter('startDate', False) 863 'AND startDate IS NULL' 864 865 >>> make_thingsdate_filter('startDate', 'future') 866 "AND startDate > ((strftime('%Y', date('now', 'localtime')) << 16) \ 867 | (strftime('%m', date('now', 'localtime')) << 12) \ 868 | (strftime('%d', date('now', 'localtime')) << 7))" 869 870 >>> make_thingsdate_filter('deadline', '2021-03-28') 871 'AND deadline == 132464128' 872 873 >>> make_thingsdate_filter('deadline', '=2021-03-28') 874 'AND deadline = 132464128' 875 876 >>> make_thingsdate_filter('deadline', '<=2021-03-28') 877 'AND deadline <= 132464128' 878 879 >>> make_thingsdate_filter('deadline', None) 880 '' 881 882 """ 883 if value is None: 884 return "" 885 886 if isinstance(value, bool): 887 return make_filter(date_column, value) 888 889 # Check for ISO 8601 date str + optional operator 890 match = match_date(value) 891 if match: 892 comparator, isodate = match.groups() 893 if not comparator: 894 comparator = "==" 895 thingsdate = isodate_to_yyyyyyyyyyymmmmddddd(isodate) 896 threshold = str(thingsdate) 897 else: 898 # "future" or "past" 899 validate("value", value, ["future", "past"]) 900 threshold = convert_isodate_sql_expression_to_thingsdate( 901 "date('now', 'localtime')", null_possible=False 902 ) 903 comparator = ">" if value == "future" else "<=" 904 905 return f"AND {date_column} {comparator} {threshold}"
Return a SQL filter for "Things date" columns.
Parameters
- date_column (str):
Name of the column that has date information on a task
stored as an INTEGER in "Things date" format.
See
convert_isodate_sql_expression_to_thingsdate
for details on Things dates. - value (bool, 'future', 'past', ISO 8601 date str, or None):
True
orFalse
indicates whether a date is set or not.'future'
or'past'
indicates a date in the future or past. ISO 8601 date str is in the format "YYYY-MM-DD", possibly prefixed with an operator such as ">YYYY-MM-DD", "=YYYY-MM-DD", "<=YYYY-MM-DD", etc.None
indicates any value.
Returns
- str: A date filter for the SQL query. If
value == None
, then return the empty string.
Examples
>>> make_thingsdate_filter('startDate', True)
'AND startDate IS NOT NULL'
>>> make_thingsdate_filter('startDate', False)
'AND startDate IS NULL'
>>> 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))"
>>> make_thingsdate_filter('deadline', '2021-03-28')
'AND deadline == 132464128'
>>> make_thingsdate_filter('deadline', '=2021-03-28')
'AND deadline = 132464128'
>>> make_thingsdate_filter('deadline', '<=2021-03-28')
'AND deadline <= 132464128'
>>> make_thingsdate_filter('deadline', None)
''
908def make_truthy_filter(column: str, value) -> str: 909 """ 910 Return a SQL filter that matches if a column is truthy or falsy. 911 912 Truthy means TRUE. Falsy means FALSE or NULL. This is akin 913 to how Python defines it natively. 914 915 Passing in `value == None` returns the empty string. 916 917 Examples 918 -------- 919 >>> make_truthy_filter('PROJECT.trashed', True) 920 'AND PROJECT.trashed' 921 922 >>> make_truthy_filter('PROJECT.trashed', False) 923 'AND NOT IFNULL(PROJECT.trashed, 0)' 924 925 >>> make_truthy_filter('PROJECT.trashed', None) 926 '' 927 """ 928 if value is None: 929 return "" 930 931 if value: 932 return f"AND {column}" 933 934 return f"AND NOT IFNULL({column}, 0)"
Return a SQL filter that matches if a column is truthy or falsy.
Truthy means TRUE. Falsy means FALSE or NULL. This is akin to how Python defines it natively.
Passing in value == None
returns the empty string.
Examples
>>> make_truthy_filter('PROJECT.trashed', True)
'AND PROJECT.trashed'
>>> make_truthy_filter('PROJECT.trashed', False)
'AND NOT IFNULL(PROJECT.trashed, 0)'
>>> make_truthy_filter('PROJECT.trashed', None)
''
937def make_unixtime_filter(date_column: str, value) -> str: 938 """ 939 Return a SQL filter for UNIX time columns. 940 941 Parameters 942 ---------- 943 date_column : str 944 Name of the column that has datetime information on a task 945 stored in UNIX time, that is, number of seconds since 946 1970-01-01 00:00 UTC. 947 948 value : bool, 'future', 'past', ISO 8601 date str, or None 949 `True` or `False` indicates whether a date is set or not. 950 `'future'` or `'past'` indicates a date in the future or past. 951 ISO 8601 date str is in the format "YYYY-MM-DD", possibly 952 prefixed with an operator such as ">YYYY-MM-DD", 953 "=YYYY-MM-DD", "<=YYYY-MM-DD", etc. 954 `None` indicates any value. 955 956 Returns 957 ------- 958 str 959 A date filter for the SQL query. If `value == None`, then 960 return the empty string. 961 962 Examples 963 -------- 964 >>> make_unixtime_filter('stopDate', True) 965 'AND stopDate IS NOT NULL' 966 967 >>> make_unixtime_filter('stopDate', False) 968 'AND stopDate IS NULL' 969 970 >>> make_unixtime_filter('stopDate', 'future') 971 "AND date(stopDate, 'unixepoch', 'localtime') > date('now', 'localtime')" 972 973 >>> make_unixtime_filter('creationDate', '2021-03-28') 974 "AND date(creationDate, 'unixepoch', 'localtime') == date('2021-03-28')" 975 976 >>> make_unixtime_filter('creationDate', '=2021-03-28') 977 "AND date(creationDate, 'unixepoch', 'localtime') = date('2021-03-28')" 978 979 >>> make_unixtime_filter('creationDate', '<=2021-03-28') 980 "AND date(creationDate, 'unixepoch', 'localtime') <= date('2021-03-28')" 981 982 >>> make_unixtime_filter('creationDate', None) 983 '' 984 985 """ 986 if value is None: 987 return "" 988 989 if isinstance(value, bool): 990 return make_filter(date_column, value) 991 992 # Check for ISO 8601 date str + optional operator 993 match = match_date(value) 994 if match: 995 comparator, isodate = match.groups() 996 if not comparator: 997 comparator = "==" 998 threshold = f"date('{isodate}')" 999 else: 1000 # "future" or "past" 1001 validate("value", value, ["future", "past"]) 1002 threshold = "date('now', 'localtime')" 1003 comparator = ">" if value == "future" else "<=" 1004 1005 date = f"date({date_column}, 'unixepoch', 'localtime')" 1006 1007 return f"AND {date} {comparator} {threshold}"
Return a SQL filter for UNIX time columns.
Parameters
- date_column (str): Name of the column that has datetime information on a task stored in UNIX time, that is, number of seconds since 1970-01-01 00:00 UTC.
- value (bool, 'future', 'past', ISO 8601 date str, or None):
True
orFalse
indicates whether a date is set or not.'future'
or'past'
indicates a date in the future or past. ISO 8601 date str is in the format "YYYY-MM-DD", possibly prefixed with an operator such as ">YYYY-MM-DD", "=YYYY-MM-DD", "<=YYYY-MM-DD", etc.None
indicates any value.
Returns
- str: A date filter for the SQL query. If
value == None
, then return the empty string.
Examples
>>> make_unixtime_filter('stopDate', True)
'AND stopDate IS NOT NULL'
>>> make_unixtime_filter('stopDate', False)
'AND stopDate IS NULL'
>>> make_unixtime_filter('stopDate', 'future')
"AND date(stopDate, 'unixepoch', 'localtime') > date('now', 'localtime')"
>>> make_unixtime_filter('creationDate', '2021-03-28')
"AND date(creationDate, 'unixepoch', 'localtime') == date('2021-03-28')"
>>> make_unixtime_filter('creationDate', '=2021-03-28')
"AND date(creationDate, 'unixepoch', 'localtime') = date('2021-03-28')"
>>> make_unixtime_filter('creationDate', '<=2021-03-28')
"AND date(creationDate, 'unixepoch', 'localtime') <= date('2021-03-28')"
>>> make_unixtime_filter('creationDate', None)
''
1010def make_unixtime_range_filter(date_column: str, offset) -> str: 1011 """ 1012 Return a SQL filter to limit a Unix time to last X days, weeks, or years. 1013 1014 Parameters 1015 ---------- 1016 date_column : str 1017 Name of the column that has datetime information on a task 1018 stored in UNIX time, that is, number of seconds since 1019 1970-01-01 00:00 UTC. 1020 1021 offset : str or None 1022 A string comprised of an integer and a single character that can 1023 be 'd', 'w', or 'y' that determines whether to return all tasks 1024 for the past X days, weeks, or years. 1025 1026 Returns 1027 ------- 1028 str 1029 A date filter for the SQL query. If `offset == None`, then 1030 return the empty string. 1031 1032 Examples 1033 -------- 1034 >>> make_unixtime_range_filter('creationDate', '3d') 1035 "AND datetime(creationDate, 'unixepoch', 'localtime') > datetime('now', '-3 days')" 1036 1037 >>> make_unixtime_range_filter('creationDate', None) 1038 '' 1039 """ 1040 if offset is None: 1041 return "" 1042 1043 validate_offset("offset", offset) 1044 number, suffix = int(offset[:-1]), offset[-1] 1045 1046 if suffix == "d": 1047 modifier = f"-{number} days" 1048 elif suffix == "w": 1049 modifier = f"-{number * 7} days" 1050 elif suffix == "y": 1051 modifier = f"-{number} years" 1052 else: 1053 # Use `typing.assert_never(suffix)` from Python 3.11 onwards. 1054 raise AssertionError("line should be unreachable.") # for static code analyzers 1055 1056 column_datetime = f"datetime({date_column}, 'unixepoch', 'localtime')" 1057 offset_datetime = f"datetime('now', '{modifier}')" 1058 1059 return f"AND {column_datetime} > {offset_datetime}"
Return a SQL filter to limit a Unix time to last X days, weeks, or years.
Parameters
- date_column (str): Name of the column that has datetime information on a task stored in UNIX time, that is, number of seconds since 1970-01-01 00:00 UTC.
- offset (str or None): A string comprised of an integer and a single character that can be 'd', 'w', or 'y' that determines whether to return all tasks for the past X days, weeks, or years.
Returns
- str: A date filter for the SQL query. If
offset == None
, then return the empty string.
Examples
>>> make_unixtime_range_filter('creationDate', '3d')
"AND datetime(creationDate, 'unixepoch', 'localtime') > datetime('now', '-3 days')"
>>> make_unixtime_range_filter('creationDate', None)
''
1062def match_date(value): 1063 """Return a match object if value is an ISO 8601 date str.""" 1064 return re.fullmatch(r"(=|==|<|<=|>|>=)?(\d{4}-\d{2}-\d{2})", value)
Return a match object if value is an ISO 8601 date str.
1067def prettify_sql(sql_query): 1068 """Make a SQL query easier to read for humans.""" 1069 # remove indentation and leading and trailing whitespace 1070 result = dedent(sql_query).strip() 1071 # remove empty lines 1072 return re.sub(r"^$\n", "", result, flags=re.MULTILINE)
Make a SQL query easier to read for humans.
1075def remove_prefix(text, prefix): 1076 """Remove prefix from text (as removeprefix() is 3.9+ only).""" 1077 return text[text.startswith(prefix) and len(prefix) :]
Remove prefix from text (as removeprefix() is 3.9+ only).
1080def validate(parameter, argument, valid_arguments): 1081 """ 1082 For a given parameter, check if its argument type is valid. 1083 1084 If not, then raise `ValueError`. 1085 1086 Examples 1087 -------- 1088 >>> validate( 1089 ... parameter='status', 1090 ... argument='completed', 1091 ... valid_arguments=['incomplete', 'completed'] 1092 ... ) 1093 ... 1094 1095 >>> validate( 1096 ... parameter='status', 1097 ... argument='XYZXZY', 1098 ... valid_arguments=['incomplete', 'completed'] 1099 ... ) 1100 Traceback (most recent call last): 1101 ... 1102 ValueError: Unrecognized status type: 'XYZXZY' 1103 Valid status types are ['incomplete', 'completed'] 1104 """ 1105 if argument in valid_arguments: 1106 return 1107 message = f"Unrecognized {parameter} type: {argument!r}" 1108 message += f"\nValid {parameter} types are {valid_arguments}" 1109 raise ValueError(message)
For a given parameter, check if its argument type is valid.
If not, then raise ValueError
.
Examples
>>> validate(
... parameter='status',
... argument='completed',
... valid_arguments=['incomplete', 'completed']
... )
...
>>> validate(
... parameter='status',
... argument='XYZXZY',
... valid_arguments=['incomplete', 'completed']
... )
Traceback (most recent call last):
...
ValueError: Unrecognized status type: 'XYZXZY'
Valid status types are ['incomplete', 'completed']
1112def validate_date(parameter, argument): 1113 """ 1114 For a given date parameter, check if its argument is valid. 1115 1116 If not, then raise `ValueError`. 1117 1118 Examples 1119 -------- 1120 >>> validate_date(parameter='startDate', argument=None) 1121 >>> validate_date(parameter='startDate', argument='future') 1122 >>> validate_date(parameter='startDate', argument='2020-01-01') 1123 >>> validate_date(parameter='startDate', argument='<=2020-01-01') 1124 1125 >>> validate_date(parameter='stopDate', argument='=2020-01-01') 1126 1127 >>> validate_date(parameter='deadline', argument='XYZ') 1128 Traceback (most recent call last): 1129 ... 1130 ValueError: Invalid deadline argument: 'XYZ' 1131 Please see the documentation for `deadline` in `things.tasks`. 1132 """ 1133 if argument is None: 1134 return 1135 1136 if argument in list(DATES): 1137 return 1138 1139 if not isinstance(argument, str): 1140 raise ValueError( 1141 f"Invalid {parameter} argument: {argument!r}\n" 1142 f"Please specify a string or None." 1143 ) 1144 1145 match = match_date(argument) 1146 if not match: 1147 raise ValueError( 1148 f"Invalid {parameter} argument: {argument!r}\n" 1149 f"Please see the documentation for `{parameter}` in `things.tasks`." 1150 ) 1151 1152 _, isodate = match.groups() 1153 try: 1154 datetime.date.fromisoformat(isodate) 1155 except ValueError as error: 1156 raise ValueError( 1157 f"Invalid {parameter} argument: {argument!r}\n{error}" 1158 ) from error
For a given date parameter, check if its argument is valid.
If not, then raise ValueError
.
Examples
>>> validate_date(parameter='startDate', argument=None)
>>> validate_date(parameter='startDate', argument='future')
>>> validate_date(parameter='startDate', argument='2020-01-01')
>>> validate_date(parameter='startDate', argument='<=2020-01-01')
>>> validate_date(parameter='stopDate', argument='=2020-01-01')
>>> validate_date(parameter='deadline', argument='XYZ')
Traceback (most recent call last):
...
ValueError: Invalid deadline argument: 'XYZ'
Please see the documentation for `deadline` in `things.tasks`.
1161def validate_offset(parameter, argument): 1162 """ 1163 For a given offset parameter, check if its argument is valid. 1164 1165 If not, then raise `ValueError`. 1166 1167 Examples 1168 -------- 1169 >>> validate_offset(parameter='last', argument='3d') 1170 1171 >>> validate_offset(parameter='last', argument='XYZ') 1172 Traceback (most recent call last): 1173 ... 1174 ValueError: Invalid last argument: 'XYZ' 1175 Please specify a string of the format 'X[d/w/y]' where X is ... 1176 """ 1177 if argument is None: 1178 return 1179 1180 if not isinstance(argument, str): 1181 raise ValueError( 1182 f"Invalid {parameter} argument: {argument!r}\n" 1183 f"Please specify a string or None." 1184 ) 1185 1186 suffix = argument[-1:] # slicing here to handle empty strings 1187 if suffix not in ("d", "w", "y"): 1188 raise ValueError( 1189 f"Invalid {parameter} argument: {argument!r}\n" 1190 f"Please specify a string of the format 'X[d/w/y]' " 1191 "where X is a non-negative integer followed by 'd', 'w', or 'y' " 1192 "that indicates days, weeks, or years." 1193 )
For a given offset parameter, check if its argument is valid.
If not, then raise ValueError
.
Examples
>>> validate_offset(parameter='last', argument='3d')
>>> validate_offset(parameter='last', argument='XYZ')
Traceback (most recent call last):
...
ValueError: Invalid last argument: 'XYZ'
Please specify a string of the format 'X[d/w/y]' where X is ...