pytaskwarrior 3.0.0a1__tar.gz → 3.0.0a2__tar.gz

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.
Files changed (41) hide show
  1. {pytaskwarrior-3.0.0a1/src/pytaskwarrior.egg-info → pytaskwarrior-3.0.0a2}/PKG-INFO +1 -1
  2. {pytaskwarrior-3.0.0a1 → pytaskwarrior-3.0.0a2}/pyproject.toml +1 -1
  3. {pytaskwarrior-3.0.0a1 → pytaskwarrior-3.0.0a2/src/pytaskwarrior.egg-info}/PKG-INFO +1 -1
  4. {pytaskwarrior-3.0.0a1 → pytaskwarrior-3.0.0a2}/src/taskwarrior/adapters/taskchampion_adapter.py +181 -4
  5. {pytaskwarrior-3.0.0a1 → pytaskwarrior-3.0.0a2}/LICENSE +0 -0
  6. {pytaskwarrior-3.0.0a1 → pytaskwarrior-3.0.0a2}/PYPI_README.md +0 -0
  7. {pytaskwarrior-3.0.0a1 → pytaskwarrior-3.0.0a2}/README.md +0 -0
  8. {pytaskwarrior-3.0.0a1 → pytaskwarrior-3.0.0a2}/setup.cfg +0 -0
  9. {pytaskwarrior-3.0.0a1 → pytaskwarrior-3.0.0a2}/src/__init__.py +0 -0
  10. {pytaskwarrior-3.0.0a1 → pytaskwarrior-3.0.0a2}/src/pytaskwarrior.egg-info/SOURCES.txt +0 -0
  11. {pytaskwarrior-3.0.0a1 → pytaskwarrior-3.0.0a2}/src/pytaskwarrior.egg-info/dependency_links.txt +0 -0
  12. {pytaskwarrior-3.0.0a1 → pytaskwarrior-3.0.0a2}/src/pytaskwarrior.egg-info/requires.txt +0 -0
  13. {pytaskwarrior-3.0.0a1 → pytaskwarrior-3.0.0a2}/src/pytaskwarrior.egg-info/top_level.txt +0 -0
  14. {pytaskwarrior-3.0.0a1 → pytaskwarrior-3.0.0a2}/src/taskwarrior/__init__.py +0 -0
  15. {pytaskwarrior-3.0.0a1 → pytaskwarrior-3.0.0a2}/src/taskwarrior/adapters/__init__.py +0 -0
  16. {pytaskwarrior-3.0.0a1 → pytaskwarrior-3.0.0a2}/src/taskwarrior/adapters/taskwarrior_adapter.py +0 -0
  17. {pytaskwarrior-3.0.0a1 → pytaskwarrior-3.0.0a2}/src/taskwarrior/adapters/tc_converter.py +0 -0
  18. {pytaskwarrior-3.0.0a1 → pytaskwarrior-3.0.0a2}/src/taskwarrior/adapters/tc_filter.py +0 -0
  19. {pytaskwarrior-3.0.0a1 → pytaskwarrior-3.0.0a2}/src/taskwarrior/config/config_store.py +0 -0
  20. {pytaskwarrior-3.0.0a1 → pytaskwarrior-3.0.0a2}/src/taskwarrior/config/uda_parser.py +0 -0
  21. {pytaskwarrior-3.0.0a1 → pytaskwarrior-3.0.0a2}/src/taskwarrior/dto/__init__.py +0 -0
  22. {pytaskwarrior-3.0.0a1 → pytaskwarrior-3.0.0a2}/src/taskwarrior/dto/annotation_dto.py +0 -0
  23. {pytaskwarrior-3.0.0a1 → pytaskwarrior-3.0.0a2}/src/taskwarrior/dto/context_dto.py +0 -0
  24. {pytaskwarrior-3.0.0a1 → pytaskwarrior-3.0.0a2}/src/taskwarrior/dto/task_dto.py +0 -0
  25. {pytaskwarrior-3.0.0a1 → pytaskwarrior-3.0.0a2}/src/taskwarrior/dto/task_id.py +0 -0
  26. {pytaskwarrior-3.0.0a1 → pytaskwarrior-3.0.0a2}/src/taskwarrior/dto/uda_dto.py +0 -0
  27. {pytaskwarrior-3.0.0a1 → pytaskwarrior-3.0.0a2}/src/taskwarrior/enums.py +0 -0
  28. {pytaskwarrior-3.0.0a1 → pytaskwarrior-3.0.0a2}/src/taskwarrior/exceptions.py +0 -0
  29. {pytaskwarrior-3.0.0a1 → pytaskwarrior-3.0.0a2}/src/taskwarrior/main.py +0 -0
  30. {pytaskwarrior-3.0.0a1 → pytaskwarrior-3.0.0a2}/src/taskwarrior/py.typed +0 -0
  31. {pytaskwarrior-3.0.0a1 → pytaskwarrior-3.0.0a2}/src/taskwarrior/registry/__init__.py +0 -0
  32. {pytaskwarrior-3.0.0a1 → pytaskwarrior-3.0.0a2}/src/taskwarrior/registry/uda_registry.py +0 -0
  33. {pytaskwarrior-3.0.0a1 → pytaskwarrior-3.0.0a2}/src/taskwarrior/services/__init__.py +0 -0
  34. {pytaskwarrior-3.0.0a1 → pytaskwarrior-3.0.0a2}/src/taskwarrior/services/context_service.py +0 -0
  35. {pytaskwarrior-3.0.0a1 → pytaskwarrior-3.0.0a2}/src/taskwarrior/services/uda_service.py +0 -0
  36. {pytaskwarrior-3.0.0a1 → pytaskwarrior-3.0.0a2}/src/taskwarrior/utils/__init__.py +0 -0
  37. {pytaskwarrior-3.0.0a1 → pytaskwarrior-3.0.0a2}/src/taskwarrior/utils/conversions.py +0 -0
  38. {pytaskwarrior-3.0.0a1 → pytaskwarrior-3.0.0a2}/src/taskwarrior/utils/date_resolver.py +0 -0
  39. {pytaskwarrior-3.0.0a1 → pytaskwarrior-3.0.0a2}/src/taskwarrior/utils/dto_converter.py +0 -0
  40. {pytaskwarrior-3.0.0a1 → pytaskwarrior-3.0.0a2}/src/taskwarrior/utils/virtual_tags.py +0 -0
  41. {pytaskwarrior-3.0.0a1 → pytaskwarrior-3.0.0a2}/tests/test_main_get_udas.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pytaskwarrior
3
- Version: 3.0.0a1
3
+ Version: 3.0.0a2
4
4
  Summary: Taskwarrior wrapper python module
5
5
  Author-email: sznicolas <sznicolas@users.noreply.github.com>
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pytaskwarrior"
3
- version = "3.0.0a1"
3
+ version = "3.0.0a2"
4
4
  description = "Taskwarrior wrapper python module"
5
5
  readme = "PYPI_README.md"
6
6
  requires-python = ">=3.12"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pytaskwarrior
3
- Version: 3.0.0a1
3
+ Version: 3.0.0a2
4
4
  Summary: Taskwarrior wrapper python module
5
5
  Author-email: sznicolas <sznicolas@users.noreply.github.com>
6
6
  License-Expression: MIT
@@ -25,9 +25,14 @@ Limitations vs :class:`~taskwarrior.adapters.taskwarrior_adapter.TaskWarriorAdap
25
25
  from __future__ import annotations
26
26
 
27
27
  import logging
28
+ import threading
29
+ import time
28
30
  import uuid as _uuid
31
+ from collections.abc import Callable
32
+ from dataclasses import dataclass, field
29
33
  from datetime import UTC, datetime
30
34
  from pathlib import Path
35
+ from typing import Any, TypeVar
31
36
  from uuid import UUID
32
37
 
33
38
  from taskchampion import AccessMode, Annotation, Operations, Replica, Status
@@ -49,6 +54,47 @@ logger = logging.getLogger(__name__)
49
54
 
50
55
  _VERSION = "taskchampion-py/3.0.1"
51
56
  _AVOID_SNAPSHOTS: bool = False
57
+ _LOCK_TIMEOUT: float = 30.0
58
+ _T = TypeVar("_T")
59
+
60
+
61
+ @dataclass
62
+ class AdapterMetrics:
63
+ """Thread-safe metrics for :class:`TaskChampionAdapter` operations.
64
+
65
+ Attributes track cumulative counts and timings across all calls made
66
+ through :meth:`TaskChampionAdapter._locked_call`. All fields are
67
+ updated atomically behind an internal lock.
68
+ """
69
+
70
+ calls_total: int = 0
71
+ errors_total: int = 0
72
+ wait_seconds_total: float = 0.0
73
+ run_seconds_total: float = 0.0
74
+ _lock: threading.Lock = field(default_factory=threading.Lock, init=False, repr=False)
75
+
76
+ def record(self, wait: float, run: float, *, error: bool = False) -> None:
77
+ """Record timing and error status for one completed call."""
78
+ with self._lock:
79
+ self.calls_total += 1
80
+ self.wait_seconds_total += wait
81
+ self.run_seconds_total += run
82
+ if error:
83
+ self.errors_total += 1
84
+
85
+ def snapshot(self) -> dict[str, Any]:
86
+ """Return a point-in-time copy of the metrics."""
87
+ with self._lock:
88
+ avg_wait = (
89
+ round(self.wait_seconds_total / self.calls_total, 3)
90
+ if self.calls_total
91
+ else 0.0
92
+ )
93
+ return {
94
+ "calls_total": self.calls_total,
95
+ "errors_total": self.errors_total,
96
+ "avg_wait_seconds": avg_wait,
97
+ }
52
98
 
53
99
 
54
100
  class TaskChampionAdapter:
@@ -82,10 +128,17 @@ class TaskChampionAdapter:
82
128
 
83
129
  Thread safety
84
130
  -------------
85
- Each :class:`TaskChampionAdapter` instance **must be used only from the
86
- thread that created it**. The underlying :class:`taskchampion.Replica`
87
- is ``unsendable`` (a PyO3 constraint): calling any method from a
88
- different thread will raise a :exc:`PanicException` at the Rust level.
131
+ Each :class:`TaskChampionAdapter` instance is **bound to the thread that
132
+ created it**. The underlying :class:`taskchampion.Replica` is
133
+ ``unsendable`` (a PyO3 constraint): calling any method from a different
134
+ thread raises a Rust-level :exc:`pyo3_runtime.PanicException`.
135
+
136
+ To surface this early as a clear Python error, every method that accesses
137
+ the :class:`~taskchampion.Replica` calls :meth:`_check_thread_affinity`
138
+ on entry and raises :exc:`RuntimeError` immediately if the call originates
139
+ from a foreign thread. A :class:`threading.Lock` is also held during each
140
+ operation for internal consistency (e.g. coroutines sharing the adapter on
141
+ the same asyncio event loop).
89
142
 
90
143
  Concurrency patterns:
91
144
 
@@ -100,6 +153,12 @@ class TaskChampionAdapter:
100
153
  readers without blocking.
101
154
  * **In-memory mode** (``data_location=None``) — each instance is fully
102
155
  isolated; do not share between threads.
156
+
157
+ Metrics
158
+ -------
159
+ Call :meth:`get_metrics` to retrieve a snapshot of operation counts,
160
+ error counts, and average wait/run times recorded by the internal
161
+ :class:`AdapterMetrics` instance.
103
162
  """
104
163
 
105
164
  def __init__(
@@ -112,6 +171,9 @@ class TaskChampionAdapter:
112
171
  sync_encryption_secret: str | None = None,
113
172
  sync_local_server_dir: str | None = None,
114
173
  ) -> None:
174
+ self._owner_thread_id = threading.current_thread().ident
175
+ self._db_lock = threading.Lock()
176
+ self._metrics = AdapterMetrics()
115
177
  if data_location is None:
116
178
  self._replica = Replica.new_in_memory()
117
179
  self._data_location: str | None = None
@@ -171,12 +233,64 @@ class TaskChampionAdapter:
171
233
  ws = self._replica.working_set()
172
234
  return tc_task_to_output_dto(task, ws)
173
235
 
236
+ def _check_thread_affinity(self) -> None:
237
+ """Raise :exc:`RuntimeError` if called from a thread other than the owner.
238
+
239
+ The underlying :class:`taskchampion.Replica` is PyO3 *unsendable*:
240
+ calling it from a foreign thread would trigger a Rust-level panic.
241
+ This method turns that panic into an explicit Python error.
242
+ """
243
+ if threading.current_thread().ident != self._owner_thread_id:
244
+ raise RuntimeError(
245
+ f"TaskChampionAdapter instance was created on thread "
246
+ f"{self._owner_thread_id} but is being accessed from thread "
247
+ f"{threading.current_thread().ident}. "
248
+ "The underlying taskchampion.Replica is thread-bound "
249
+ "(PyO3 unsendable constraint). "
250
+ "Create a separate TaskChampionAdapter instance per thread, "
251
+ "or adopt a per-request adapter pattern."
252
+ )
253
+
254
+ def _locked_call(self, fn: Callable[..., _T], /, *args: Any, **kwargs: Any) -> _T:
255
+ """Run *fn* under the instance lock, recording metrics.
256
+
257
+ Raises
258
+ ------
259
+ RuntimeError
260
+ If called from a thread other than the owner thread.
261
+ TaskOperationError
262
+ If the lock cannot be acquired within :data:`_LOCK_TIMEOUT` seconds.
263
+ """
264
+ self._check_thread_affinity()
265
+ t_wait_start = time.monotonic()
266
+ acquired = self._db_lock.acquire(timeout=_LOCK_TIMEOUT)
267
+ wait = time.monotonic() - t_wait_start
268
+ if not acquired:
269
+ self._metrics.record(wait=_LOCK_TIMEOUT, run=0.0, error=True)
270
+ raise TaskOperationError(
271
+ f"Lock timeout after {_LOCK_TIMEOUT}s waiting for {fn.__name__}"
272
+ )
273
+ error = False
274
+ t_run_start = time.monotonic()
275
+ try:
276
+ return fn(*args, **kwargs)
277
+ except Exception:
278
+ error = True
279
+ raise
280
+ finally:
281
+ run = time.monotonic() - t_run_start
282
+ self._metrics.record(wait=wait, run=run, error=error)
283
+ self._db_lock.release()
284
+
174
285
  # ------------------------------------------------------------------
175
286
  # CRUD
176
287
  # ------------------------------------------------------------------
177
288
 
178
289
  def add_task(self, task: TaskInputDTO) -> TaskOutputDTO:
179
290
  """Create a new task and return the persisted DTO."""
291
+ return self._locked_call(self._add_task_internal, task)
292
+
293
+ def _add_task_internal(self, task: TaskInputDTO) -> TaskOutputDTO:
180
294
  if not task.description or not task.description.strip():
181
295
  raise TaskValidationError("Task description cannot be empty")
182
296
 
@@ -193,6 +307,9 @@ class TaskChampionAdapter:
193
307
 
194
308
  def modify_task(self, task: TaskInputDTO, task_id: TaskRef) -> TaskOutputDTO:
195
309
  """Modify an existing task and return the updated DTO."""
310
+ return self._locked_call(self._modify_task_internal, task, task_id)
311
+
312
+ def _modify_task_internal(self, task: TaskInputDTO, task_id: TaskRef) -> TaskOutputDTO:
196
313
  uuid_str = self._resolve_ref(task_id)
197
314
  tc_task = self._get_tc_task(uuid_str)
198
315
 
@@ -206,6 +323,9 @@ class TaskChampionAdapter:
206
323
 
207
324
  def get_task(self, task_id: TaskRef) -> TaskOutputDTO:
208
325
  """Retrieve a single task by ID, index, or UUID."""
326
+ return self._locked_call(self._get_task_internal, task_id)
327
+
328
+ def _get_task_internal(self, task_id: TaskRef) -> TaskOutputDTO:
209
329
  uuid_str = self._resolve_ref(task_id)
210
330
  return self._fetch_dto(uuid_str)
211
331
 
@@ -223,6 +343,16 @@ class TaskChampionAdapter:
223
343
  The filter is applied in Python; see :mod:`~taskwarrior.adapters.tc_filter`
224
344
  for the supported syntax.
225
345
  """
346
+ return self._locked_call(
347
+ self._get_tasks_internal, filter, include_completed, include_deleted
348
+ )
349
+
350
+ def _get_tasks_internal(
351
+ self,
352
+ filter: str = "",
353
+ include_completed: bool = False,
354
+ include_deleted: bool = False,
355
+ ) -> list[TaskOutputDTO]:
226
356
  if not include_completed and not include_deleted:
227
357
  all_tasks = self._replica.pending_tasks()
228
358
  else:
@@ -233,11 +363,17 @@ class TaskChampionAdapter:
233
363
 
234
364
  def get_recurring_task(self, task_id: TaskRef) -> TaskOutputDTO:
235
365
  """Retrieve a recurring task template."""
366
+ return self._locked_call(self._get_recurring_task_internal, task_id)
367
+
368
+ def _get_recurring_task_internal(self, task_id: TaskRef) -> TaskOutputDTO:
236
369
  uuid_str = self._resolve_ref(task_id)
237
370
  return self._fetch_dto(uuid_str)
238
371
 
239
372
  def get_recurring_instances(self, task_id: TaskRef) -> list[TaskOutputDTO]:
240
373
  """Return all child instances of a recurring task template."""
374
+ return self._locked_call(self._get_recurring_instances_internal, task_id)
375
+
376
+ def _get_recurring_instances_internal(self, task_id: TaskRef) -> list[TaskOutputDTO]:
241
377
  uuid_str = self._resolve_ref(task_id)
242
378
  all_tasks = list(self._replica.all_tasks().values())
243
379
  ws = self._replica.working_set()
@@ -250,6 +386,9 @@ class TaskChampionAdapter:
250
386
 
251
387
  def delete_task(self, task_id: TaskRef) -> None:
252
388
  """Mark a task as deleted (soft delete)."""
389
+ self._locked_call(self._delete_task_internal, task_id)
390
+
391
+ def _delete_task_internal(self, task_id: TaskRef) -> None:
253
392
  uuid_str = self._resolve_ref(task_id)
254
393
  tc_task = self._get_tc_task(uuid_str)
255
394
  ops = Operations()
@@ -260,6 +399,9 @@ class TaskChampionAdapter:
260
399
 
261
400
  def purge_task(self, task_id: TaskRef) -> None:
262
401
  """Permanently remove a task from the database."""
402
+ self._locked_call(self._purge_task_internal, task_id)
403
+
404
+ def _purge_task_internal(self, task_id: TaskRef) -> None:
263
405
  uuid_str = self._resolve_ref(task_id)
264
406
  tc_task = self._get_tc_task(uuid_str)
265
407
  ops = Operations()
@@ -270,6 +412,9 @@ class TaskChampionAdapter:
270
412
 
271
413
  def done_task(self, task_id: TaskRef) -> None:
272
414
  """Mark a task as completed."""
415
+ self._locked_call(self._done_task_internal, task_id)
416
+
417
+ def _done_task_internal(self, task_id: TaskRef) -> None:
273
418
  uuid_str = self._resolve_ref(task_id)
274
419
  tc_task = self._get_tc_task(uuid_str)
275
420
  ops = Operations()
@@ -280,6 +425,9 @@ class TaskChampionAdapter:
280
425
 
281
426
  def start_task(self, task_id: TaskRef) -> None:
282
427
  """Start working on a task (set start timestamp)."""
428
+ self._locked_call(self._start_task_internal, task_id)
429
+
430
+ def _start_task_internal(self, task_id: TaskRef) -> None:
283
431
  uuid_str = self._resolve_ref(task_id)
284
432
  tc_task = self._get_tc_task(uuid_str)
285
433
  ops = Operations()
@@ -289,6 +437,9 @@ class TaskChampionAdapter:
289
437
 
290
438
  def stop_task(self, task_id: TaskRef) -> None:
291
439
  """Stop working on a task (clear start timestamp)."""
440
+ self._locked_call(self._stop_task_internal, task_id)
441
+
442
+ def _stop_task_internal(self, task_id: TaskRef) -> None:
292
443
  uuid_str = self._resolve_ref(task_id)
293
444
  tc_task = self._get_tc_task(uuid_str)
294
445
  ops = Operations()
@@ -298,6 +449,9 @@ class TaskChampionAdapter:
298
449
 
299
450
  def annotate_task(self, task_id: TaskRef, annotation: str) -> None:
300
451
  """Add an annotation to a task."""
452
+ self._locked_call(self._annotate_task_internal, task_id, annotation)
453
+
454
+ def _annotate_task_internal(self, task_id: TaskRef, annotation: str) -> None:
301
455
  if not annotation or not annotation.strip():
302
456
  raise TaskOperationError("Annotation text cannot be empty")
303
457
  uuid_str = self._resolve_ref(task_id)
@@ -329,6 +483,9 @@ class TaskChampionAdapter:
329
483
  Raises :exc:`~taskwarrior.exceptions.TaskSyncError` if no sync backend
330
484
  is configured or if the sync operation fails.
331
485
  """
486
+ self._locked_call(self._synchronize_internal)
487
+
488
+ def _synchronize_internal(self) -> None:
332
489
  if not self._sync_configured:
333
490
  raise TaskSyncError(
334
491
  "No sync server configured. "
@@ -369,6 +526,9 @@ class TaskChampionAdapter:
369
526
  performing a sync. Call :meth:`synchronize` to pull remote
370
527
  changes.
371
528
  """
529
+ return self._locked_call(self._has_local_changes_internal)
530
+
531
+ def _has_local_changes_internal(self) -> bool:
372
532
  return self._replica.num_local_operations() > 0
373
533
 
374
534
  def pending_local_ops_count(self) -> int:
@@ -377,6 +537,9 @@ class TaskChampionAdapter:
377
537
  Useful for logging or debugging. ``0`` means fully synced (local side).
378
538
  See :meth:`has_local_changes` for the boolean shorthand.
379
539
  """
540
+ return self._locked_call(self._pending_local_ops_count_internal)
541
+
542
+ def _pending_local_ops_count_internal(self) -> int:
380
543
  return self._replica.num_local_operations()
381
544
 
382
545
  # ------------------------------------------------------------------
@@ -438,6 +601,9 @@ class TaskChampionAdapter:
438
601
 
439
602
  def get_projects(self) -> list[str]:
440
603
  """Return a sorted list of all project names across all tasks."""
604
+ return self._locked_call(self._get_projects_internal)
605
+
606
+ def _get_projects_internal(self) -> list[str]:
441
607
  projects: set[str] = set()
442
608
  for task in self._replica.all_tasks().values():
443
609
  proj = task.get_value("project")
@@ -454,6 +620,9 @@ class TaskChampionAdapter:
454
620
  When ``True``, TaskWarrior virtual tags (``TODAY``, ``READY``,
455
621
  ``OVERDUE``, …) are appended in addition to user-defined tags.
456
622
  """
623
+ return self._locked_call(self._get_tags_internal, include_virtual_tags)
624
+
625
+ def _get_tags_internal(self, include_virtual_tags: bool = False) -> list[str]:
457
626
  tags: set[str] = set()
458
627
  for task in self._replica.all_tasks().values():
459
628
  for t in task.get_tags():
@@ -464,3 +633,11 @@ class TaskChampionAdapter:
464
633
 
465
634
  tags.update(TASKWARRIOR_VIRTUAL_TAGS)
466
635
  return sorted(tags)
636
+
637
+ def get_metrics(self) -> dict[str, Any]:
638
+ """Return a snapshot of adapter operation metrics.
639
+
640
+ Returns a dict with ``calls_total``, ``errors_total``, and
641
+ ``avg_wait_seconds`` — useful for monitoring lock contention.
642
+ """
643
+ return self._metrics.snapshot()
File without changes