things-py 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
tests/__init__.py ADDED
File without changes
tests/test_things.py ADDED
@@ -0,0 +1,115 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+
4
+ """Module documentation goes here."""
5
+
6
+ import unittest
7
+
8
+ import things
9
+
10
+
11
+ FILEPATH = dict(filepath="tests/main.sqlite")
12
+
13
+
14
+ class ThingsCase(unittest.TestCase):
15
+ """Class documentation goes here."""
16
+
17
+ def test_search(self):
18
+ """Test search."""
19
+ tasks = things.search('wrong_query', **FILEPATH)
20
+ self.assertEqual(0, len(tasks))
21
+ tasks = things.search('To-Do % Heading', **FILEPATH)
22
+ self.assertEqual(1, len(tasks))
23
+
24
+ def test_inbox(self):
25
+ """Test inbox."""
26
+ tasks = things.inbox(**FILEPATH)
27
+ self.assertEqual(1, len(tasks))
28
+
29
+ def test_upcoming(self):
30
+ """Test upcoming."""
31
+ tasks = things.upcoming(**FILEPATH)
32
+ self.assertEqual(2, len(tasks))
33
+
34
+ def test_due(self):
35
+ """Test due."""
36
+ tasks = things.due(**FILEPATH)
37
+ self.assertEqual(1, len(tasks))
38
+
39
+ def test_today(self):
40
+ """Test today."""
41
+ tasks = things.today(**FILEPATH)
42
+ self.assertEqual(1, len(tasks))
43
+
44
+ def test_anytime(self):
45
+ """Test antime."""
46
+ tasks = things.anytime(**FILEPATH)
47
+ self.assertEqual(8, len(tasks))
48
+
49
+ def test_logbook(self):
50
+ """Test logbook."""
51
+ tasks = things.logbook(**FILEPATH)
52
+ self.assertEqual(21, len(tasks))
53
+
54
+ def test_canceled(self):
55
+ """Test canceled."""
56
+ tasks = things.canceled(**FILEPATH)
57
+ self.assertEqual(11, len(tasks))
58
+
59
+ def test_completed(self):
60
+ """Test completed."""
61
+ tasks = things.completed(**FILEPATH)
62
+ self.assertEqual(10, len(tasks))
63
+
64
+ def test_someday(self):
65
+ """Test someday."""
66
+ tasks = things.someday(**FILEPATH)
67
+ self.assertEqual(1, len(tasks))
68
+
69
+ def test_get(self):
70
+ """Test get."""
71
+ tasks = things.get("wrong_uuid", **FILEPATH)
72
+ self.assertEqual(None, tasks)
73
+ tasks = things.get("wrong_uuid", "NOT FOUND", **FILEPATH)
74
+ self.assertEqual("NOT FOUND", tasks)
75
+ tasks = things.get("Qt2AY87x2QDdowSn9HKTt1", **FILEPATH)
76
+ self.assertEqual(4, len(tasks))
77
+
78
+ def test_todos(self):
79
+ """Test all tasks."""
80
+ tasks = things.todos(start="Anytime", **FILEPATH)
81
+ self.assertEqual(5, len(tasks))
82
+ tasks = things.todos(start="Anytime", status="completed", **FILEPATH)
83
+ self.assertEqual(6, len(tasks))
84
+ tasks = things.todos(status="completed", **FILEPATH)
85
+ self.assertEqual(10, len(tasks))
86
+ tasks = things.todos(include_items=True, **FILEPATH)
87
+ self.assertEqual(9, len(tasks))
88
+ with self.assertRaises(ValueError):
89
+ things.todos(status="wrong_value", **FILEPATH)
90
+ tasks = things.tasks("A2oPvtt4dXoypeoLc8uYzY", **FILEPATH)
91
+ self.assertEqual(13, len(tasks))
92
+
93
+ def test_tags(self):
94
+ """Test all tags."""
95
+ tags = things.tags(**FILEPATH)
96
+ self.assertEqual(5, len(tags))
97
+
98
+ def test_projects(self):
99
+ """Test all projects."""
100
+ projects = things.projects(**FILEPATH)
101
+ self.assertEqual(2, len(projects))
102
+
103
+ def test_areas(self):
104
+ """Test all test_areas."""
105
+ test_areas = things.areas(**FILEPATH)
106
+ self.assertEqual(1, len(test_areas))
107
+
108
+ def test_database_version(self):
109
+ """Test database version."""
110
+ version = things.Database(**FILEPATH).get_version()
111
+ self.assertEqual(18, version)
112
+
113
+
114
+ if __name__ == "__main__":
115
+ unittest.main()
things/__init__.py ADDED
@@ -0,0 +1,23 @@
1
+ """A simple Python 3 library to read your Things app data."""
2
+
3
+ from things.api import ( # noqa
4
+ anytime,
5
+ areas,
6
+ canceled,
7
+ completed,
8
+ due,
9
+ get,
10
+ inbox,
11
+ link,
12
+ logbook,
13
+ projects,
14
+ search,
15
+ show,
16
+ someday,
17
+ tags,
18
+ tasks,
19
+ today,
20
+ todos,
21
+ upcoming,
22
+ )
23
+ from things.database import Database # noqa
things/api.py ADDED
@@ -0,0 +1,485 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ """
4
+ Module implementing Things API.
5
+ """
6
+
7
+ import os
8
+ from shlex import quote
9
+ import sys
10
+ from typing import List, Dict
11
+
12
+ from .database import Database
13
+
14
+
15
+ # --------------------------------------------------
16
+ # Core functions
17
+ # --------------------------------------------------
18
+
19
+
20
+ def tasks(uuid=None, include_items=False, **kwargs): # noqa: C901
21
+ """
22
+ Read tasks into dicts.
23
+
24
+ Note: "task" is a technical term used in the database to refer to a
25
+ to-do, project, or heading. For details, check the "type"-parameter.
26
+
27
+ Per default, only tasks marked as incomplete are included. If you
28
+ want to include completed or canceled tasks in the result, check the
29
+ "status"-parameter.
30
+
31
+ Parameters
32
+ ----------
33
+ uuid : str or None, optional
34
+ Any valid task uuid. If None, then return all tasks matched.
35
+
36
+ include_items : boolean, default False
37
+ Include items contained within a task. These might include
38
+ checklist items, headings, and to-dos.
39
+
40
+ type : {'to-do', 'heading', 'project', None}, optional
41
+ Only return a specific type of task:
42
+
43
+ 'to-do': may have a checklist; may be in an area and have tags.
44
+ 'project': may have to-dos and headings; may be in an area and
45
+ have tags.
46
+ 'heading': part of a project; groups tasks.
47
+ None: return all types of tasks.
48
+
49
+ status : {'incomplete', 'completed', 'canceled', None}, optional, \
50
+ default 'incomplete'
51
+
52
+ Only include tasks matching that status. If the argument is `None`,
53
+ then include tasks with any status value.
54
+
55
+ start : {'Inbox', 'Anytime', 'Someday', None}, optional
56
+ Only include tasks matching that start value. If the argument is
57
+ `None` (default), then include tasks with any start value.
58
+
59
+ area : str, bool, or None, optional
60
+ Any valid uuid of an area. Only include tasks matching that area.
61
+ If the argument is `False`, only include tasks _without_ an area.
62
+ If the argument is `True`, only include tasks _with_ an area.
63
+ If the argument is `None` (default), then include all tasks.
64
+
65
+ project : str or None, optional
66
+ Any valid uuid of a project. Only include tasks matching that project.
67
+ If the argument is `False`, only include tasks _without_ a project.
68
+ If the argument is `True`, only include tasks _with_ a project.
69
+ If the argument is `None` (default), then include all tasks.
70
+
71
+ heading : str or None, optional
72
+ Any valid uuid of a heading. Only include tasks matching that heading.
73
+ If the argument is `False`, only include tasks _without_ a heading.
74
+ If the argument is `True`, only include tasks _with_ a heading.
75
+ If the argument is `None` (default), then include all tasks.
76
+
77
+ tag : str or None, optional
78
+ Any valid title of a tag. Only include tasks matching that tag.
79
+ If the argument is `False`, only include tasks _without_ tags.
80
+ If the argument is `True`, only include tasks _with_ tags.
81
+ If the argument is `None` (default), then include all tasks.
82
+
83
+ start_date : bool or None, optional
84
+ If the argument is `False`, only include tasks _without_ a start date.
85
+ If the argument is `True`, only include tasks _with_ a start date.
86
+ If the argument is `None` (default), then include all tasks.
87
+
88
+ due_date : bool or None, optional
89
+ If the argument is `False`, only include tasks _without_ a due date.
90
+ If the argument is `True`, only include tasks _with_ a due date.
91
+ If the argument is `None` (default), then include all tasks.
92
+
93
+ index : {'index', 'todayIndex'}, default 'index'
94
+ Database field to order result by.
95
+
96
+ count_only : boolean, default False
97
+ Only output length of result. This is done by a SQL COUNT query.
98
+
99
+ filepath : str, optional
100
+ Any valid path of a SQLite database file generated by the Things app.
101
+ If no path is provided, then access the default database path.
102
+
103
+ database : things.Database, optional
104
+ Any valid `things.Database` object previously instantiated.
105
+
106
+ Returns
107
+ -------
108
+ list of dict (default)
109
+ Representing multiple tasks.
110
+ dict (if `uuid` is given)
111
+ Representing a single task.
112
+ int (count_only == True)
113
+ Count of matching Tasks.
114
+
115
+ Examples
116
+ --------
117
+ >>> things.tasks()
118
+ ...
119
+ >>> things.tasks('2Ukg8I2nLukhyEM7wYiBeb')
120
+ ...
121
+ >>> things.tasks(area='hIo1FJlAYGKt1Yj38vzKc3', include_items=True)
122
+ ...
123
+ >>> things.tasks(status='completed', count_only=True)
124
+ 10
125
+
126
+ """
127
+ database = pop_database(kwargs)
128
+ result = database.get_tasks(
129
+ uuid=uuid, status=kwargs.pop("status", "incomplete"), **kwargs
130
+ )
131
+
132
+ if kwargs.get("count_only"):
133
+ return result
134
+
135
+ # overwrite `include_items` if fetching a single task.
136
+ if uuid:
137
+ include_items = True
138
+
139
+ for task in result:
140
+ # TK: How costly of an operation is it to do this for every task?
141
+ # IF costly, then can it be made significantly more efficient
142
+ # by optimizing SQL calls?
143
+
144
+ if task.get("tags"):
145
+ task["tags"] = database.get_tags(task=task["uuid"])
146
+
147
+ if not include_items:
148
+ continue
149
+
150
+ # include items
151
+ if task["type"] == "to-do":
152
+ if task.get("checklist"):
153
+ task["checklist"] = database.get_checklist_items(task_uuid=task["uuid"])
154
+ elif task["type"] == "project":
155
+ project = task
156
+ project["items"] = items = tasks(
157
+ project=project["uuid"],
158
+ include_items=True,
159
+ database=database,
160
+ )
161
+ # to-dos without headings appear before headings in app
162
+ items.sort(key=lambda item: item["type"], reverse=True)
163
+ elif task["type"] == "heading":
164
+ heading = task
165
+ heading["items"] = tasks(
166
+ type="to-do",
167
+ heading=heading["uuid"],
168
+ include_items=True,
169
+ database=database,
170
+ )
171
+
172
+ if uuid:
173
+ result = result[0]
174
+
175
+ return result
176
+
177
+
178
+ def areas(uuid=None, include_items=False, **kwargs):
179
+ """
180
+ Read areas into dicts.
181
+
182
+ Parameters
183
+ ----------
184
+ uuid : str or None, optional
185
+ Any valid uuid of an area. If None, then return all areas.
186
+
187
+ include_items : boolean, default False
188
+ Include tasks and projects in each area.
189
+
190
+ tag : str or None, optional
191
+ Any valid title of a tag. Only include areas matching that tag.
192
+ If the argument is `False`, only include areas _without_ tags.
193
+ If the argument is `True`, only include areas _with_ tags.
194
+ If the argument is `None`, then ignore any tags present, that is,
195
+ include areas both with and without tags.
196
+
197
+ count_only : boolean, default False
198
+ Only output length of result. This is done by a SQL COUNT query.
199
+
200
+ filepath : str, optional
201
+ Any valid path of a SQLite database file generated by the Things app.
202
+ If no path is provided, then access the default database path.
203
+
204
+ database : things.Database, optional
205
+ Any valid `things.Database` object previously instantiated.
206
+
207
+ Returns
208
+ -------
209
+ list of dict (default)
210
+ Representing Things areas.
211
+ dict (if `uuid` is given)
212
+ Representing a single Things area.
213
+ int (count_only == True)
214
+ Count of matching areas.
215
+
216
+ Examples
217
+ --------
218
+ >>> things.areas()
219
+ ...
220
+ >>> things.areas(tag='Home')
221
+ ...
222
+ >>> things.areas(uuid='Gw9QefIdgR6nPEoY5hBNSh')
223
+ ...
224
+ >>> things.areas(include_items=True, status='completed')
225
+ ...
226
+ """
227
+ database = pop_database(kwargs)
228
+ result = database.get_areas(uuid=uuid, **kwargs)
229
+
230
+ if kwargs.get("count_only"):
231
+ return result
232
+
233
+ for area in result:
234
+ if area.get("tags"):
235
+ area["tags"] = database.get_tags(area=area["uuid"])
236
+ if include_items:
237
+ area["items"] = tasks(
238
+ area=area["uuid"], include_items=True, database=database
239
+ )
240
+
241
+ if uuid:
242
+ result = result[0]
243
+
244
+ return result
245
+
246
+
247
+ def tags(title=None, include_items=False, **kwargs):
248
+ """
249
+ Read tags into dicts.
250
+
251
+ Parameters
252
+ ----------
253
+ title : str, optional
254
+ Any valid title of a tag. Include all items of said tag.
255
+ If None, then return all tags.
256
+
257
+ include_items : boolean, default False
258
+ For each tag, include items tagged with that tag.
259
+ Items may include areas, tasks, and projects.
260
+
261
+ area : str, optional
262
+ Valid uuid of an area. Return tags of said area.
263
+
264
+ task : str, optional
265
+ Valid uuid of a task. Return tags of said task.
266
+
267
+ titles_only : bool, default False
268
+ If True, only return list of titles of tags.
269
+
270
+ filepath : str, optional
271
+ Any valid path of a SQLite database file generated by the Things app.
272
+ If no path is provided, then access the default database path.
273
+
274
+ database : things.Database, optional
275
+ Any valid `things.Database` object previously instantiated.
276
+
277
+ Returns
278
+ -------
279
+ list of dict (default)
280
+ Representing tags.
281
+ list of str (if `titles_only == True` or area / task is given)
282
+ Representing tag titles.
283
+ dict (if `title` is given)
284
+ Representing a single Things tag.
285
+
286
+ Examples
287
+ --------
288
+ >>> things.tags()
289
+ ...
290
+ >>> things.tags('Home')
291
+ ...
292
+ >>> things.tags(include_items=True)
293
+ ...
294
+ >>> things.tags(task='2Ukg8I2nLukhyEM7wYiBeb')
295
+ ...
296
+ """
297
+ database = pop_database(kwargs)
298
+ result = database.get_tags(title=title, **kwargs)
299
+
300
+ if include_items:
301
+ for tag in result:
302
+ tag_title = tag["title"]
303
+ tag["items"] = [
304
+ *areas(tag=tag_title, database=database),
305
+ *tasks(tag=tag_title, database=database),
306
+ ]
307
+
308
+ if title:
309
+ result = result[0]
310
+
311
+ return result
312
+
313
+
314
+ # --------------------------------------------------
315
+ # Utility API functions derived from above
316
+ # --------------------------------------------------
317
+
318
+ def search(query: str, **kwargs) -> List[Dict]:
319
+ """
320
+ Search the database. This takes mainly notes and titles into account.
321
+
322
+ Parameters
323
+ ----------
324
+ query : str
325
+ The string with optional placeholders (%) to search for.
326
+
327
+ Returns
328
+ -------
329
+ list of dict
330
+ Representing Things items.
331
+
332
+ Examples
333
+ --------
334
+ >>> things.search('substring % within notes and titles')
335
+ ...
336
+ """
337
+ return tasks(querystr=query, **kwargs)
338
+
339
+
340
+ def get(uuid, default=None, **kwargs):
341
+ """
342
+ Find an object by uuid. If not found, return `default`.
343
+
344
+ Currently supports tasks, projects, headings, areas, and tags.
345
+ """
346
+ try:
347
+ return tasks(uuid=uuid, **kwargs)
348
+ except ValueError:
349
+ pass
350
+
351
+ try:
352
+ return areas(uuid=uuid, **kwargs)
353
+ except ValueError:
354
+ pass
355
+
356
+ for tag in tags(**kwargs):
357
+ if tag["uuid"] == uuid:
358
+ return tag
359
+
360
+ return default
361
+
362
+
363
+ # Filter by object type
364
+
365
+
366
+ def todos(uuid=None, **kwargs):
367
+ return tasks(uuid=uuid, type="to-do", **kwargs)
368
+
369
+
370
+ def projects(uuid=None, **kwargs):
371
+ return tasks(uuid=uuid, type="project", **kwargs)
372
+
373
+
374
+ # Filter by collections in the Things app sidebar.
375
+
376
+
377
+ def inbox(**kwargs):
378
+ return tasks(start="Inbox", **kwargs)
379
+
380
+
381
+ def today(**kwargs):
382
+ """
383
+ Note: This might not produce desired results if the Things app hasn't
384
+ been opened yet today. In general, you can assume that whatever state
385
+ the Things app was in when you last opened it, that's the state
386
+ reflected here by the API.
387
+ """
388
+ database = pop_database(kwargs)
389
+ if not database.was_modified_today():
390
+ print(
391
+ "[NOTE] The results reflect the state of the Things app "
392
+ "when it was last run. If the results seem out of date, "
393
+ "then run the Things app to update the database.",
394
+ file=sys.stderr,
395
+ )
396
+ return tasks(
397
+ start_date=True,
398
+ start="Anytime",
399
+ index="todayIndex",
400
+ database=database,
401
+ **kwargs,
402
+ )
403
+
404
+
405
+ def upcoming(**kwargs):
406
+ """
407
+ Note: unscheduled tasks with a due date are not included here.
408
+ See the `due` function instead.
409
+ """
410
+ return tasks(start_date=True, start="Someday", **kwargs)
411
+
412
+
413
+ def anytime(**kwargs):
414
+ return tasks(start="Anytime", **kwargs)
415
+
416
+
417
+ def someday(**kwargs):
418
+ return tasks(start_date=False, start="Someday", **kwargs)
419
+
420
+
421
+ def logbook(**kwargs):
422
+ result = [*canceled(**kwargs), *completed(**kwargs)]
423
+ result.sort(key=lambda task: task["stop_date"], reverse=True)
424
+ return result
425
+
426
+
427
+ # Filter by various task properties
428
+
429
+
430
+ def canceled(**kwargs):
431
+ return tasks(status="canceled", **kwargs)
432
+
433
+
434
+ def completed(**kwargs):
435
+ """
436
+ Examples
437
+ --------
438
+ >>> things.completed(count_only=True)
439
+ 14
440
+ >>> things.completed(type='project', count_only=True)
441
+ 4
442
+ """
443
+ return tasks(status="completed", **kwargs)
444
+
445
+
446
+ def due(**kwargs):
447
+ result = tasks(due_date=True, **kwargs)
448
+ result.sort(key=lambda task: task["due_date"])
449
+ return result
450
+
451
+
452
+ # Interact with Things app
453
+
454
+
455
+ def link(uuid):
456
+ return f"things:///show?id={uuid}"
457
+
458
+
459
+ def show(uuid):
460
+ """
461
+ Show a certain uuid in the Things app.
462
+
463
+ Parameters
464
+ ----------
465
+ uuid : str
466
+ A valid uuid of any Things object.
467
+
468
+ Examples
469
+ --------
470
+ >>> tag = things.tags('Home')
471
+ >>> things.show(tag['uuid'])
472
+ """
473
+ uri = link(uuid)
474
+ os.system(f"open {quote(uri)}")
475
+
476
+
477
+ # Helper functions
478
+
479
+
480
+ def pop_database(kwargs):
481
+ """instantiate non-default database from `kwargs` if provided"""
482
+ filepath, database = kwargs.pop("filepath", None), kwargs.pop("database", None)
483
+ if not database:
484
+ database = Database(filepath=filepath)
485
+ return database