C and Python API + Smarter account-based wait times

This commit is contained in:
alejo committed 2026-10-28 01:02:10 -03:00
1 parent 35c568333d
commit 10badb282c
18 files changed
+2204 -429

No files matched your search

Binary file not shown.
+439
View File
@@ -0,0 +1,439 @@
"""Python binding for the lls C API (ctypes, no compilation needed).
import lls
with lls.Database("tasks.db") as db:
db.add_account("Acrux")
db.add_task("Breeze A", account="Acrux", status="testing")
db.set_account_wait("Acrux", "4:30pm") # the account hit its limit
for task in db.tasks():
print(task.name, task.account, task.status_label)
db.save() # nothing is saved automatically
The shared library (liblls_api.so / liblls_api.dylib / lls_api.dll) is looked up in
the LLS_LIBRARY environment variable, then in ../build next to this file, then in
the current directory's build folder. Pass `library=` to Database to override.
"""
import ctypes
import os
import sys
from dataclasses import dataclass
from datetime import datetime
from enum import IntEnum
from pathlib import Path
from typing import List, Optional, Sequence, Tuple, Union
__all__ = [
"Database", "Task", "Account", "Status", "Level", "WaitState",
"LLSError", "InvalidArgument", "NotFound", "AlreadyExists", "DatabaseError",
"parse_time", "find_library",
]
# ---- Constants (mirror lls_api.h) --------------------------------------------------
OK, E_INVALID, E_NOT_FOUND, E_EXISTS, E_DATABASE, E_INTERNAL = range(6)
class Status(IntEnum):
PROMPT = 0
LIMIT_RESET = 1
TESTING = 2
WAIT = 3
UPLOADING = 4
FROZEN = 5
DONE = 6
class Level(IntEnum):
BUSY = 0
TAKEN = 1
FREE = 2
class WaitState(IntEnum):
NONE = 0
WAITING = 1
LIMIT_RESET = 2
# ---- Errors ------------------------------------------------------------------------
class LLSError(Exception):
"""Something the library refused to do; `code` is the LLS_E_* value."""
def __init__(self, code: int, message: str):
super().__init__(message)
self.code = code
class InvalidArgument(LLSError):
"""Bad name, unknown status, unreadable time..."""
class NotFound(LLSError):
"""No such task or account."""
class AlreadyExists(LLSError):
"""A task or account with that name exists."""
class DatabaseError(LLSError):
"""SQLite or file problem."""
_ERRORS = {E_INVALID: InvalidArgument, E_NOT_FOUND: NotFound, E_EXISTS: AlreadyExists, E_DATABASE: DatabaseError}
# ---- Results -----------------------------------------------------------------------
@dataclass(frozen=True)
class Task:
name: str
account: Optional[str]
status: Status # as shown: Wait / Limit reset come from the account
status_label: str # "Prompt", "Limit reset", "Wait 16:30", ...
stored_status: Status # what is saved underneath
until: Optional[datetime] # for Wait / Limit reset: when the wait ends (ended)
notes: str
notes_lines: int
@dataclass(frozen=True)
class Account:
name: str
level: Level
level_label: str # "Busy", "Taken", "Free"
wait_state: WaitState
wait_until: Optional[datetime]
wait_listed: bool # the wait is not shown by any task (account is still Busy)
tasks: Tuple[str, ...] # tasks at the account's level
# ---- ctypes plumbing -----------------------------------------------------------------
class _TaskInfo(ctypes.Structure):
_fields_ = [
("name", ctypes.c_char_p),
("account", ctypes.c_char_p),
("status", ctypes.c_int),
("status_label", ctypes.c_char_p),
("stored_status", ctypes.c_int),
("until", ctypes.c_int64),
("notes", ctypes.c_char_p),
("notes_lines", ctypes.c_int),
]
class _AccountInfo(ctypes.Structure):
_fields_ = [
("name", ctypes.c_char_p),
("level", ctypes.c_int),
("level_label", ctypes.c_char_p),
("wait_state", ctypes.c_int),
("wait_until", ctypes.c_int64),
("wait_listed", ctypes.c_int),
("task_count", ctypes.c_int),
("tasks", ctypes.POINTER(ctypes.c_char_p)),
]
def _library_names() -> List[str]:
if sys.platform == "win32":
return ["lls_api.dll"]
if sys.platform == "darwin":
return ["liblls_api.dylib"]
return ["liblls_api.so"]
def find_library() -> str:
"""Path of the shared library, or raises FileNotFoundError."""
candidates: List[Path] = []
env = os.environ.get("LLS_LIBRARY")
if env:
candidates.append(Path(env))
here = Path(__file__).resolve().parent
for folder in (here.parent / "build", here.parent / "build" / "Release", here.parent / "build" / "Debug",
Path.cwd() / "build", Path.cwd(), here):
for name in _library_names():
candidates.append(folder / name)
for candidate in candidates:
if candidate.is_file():
return str(candidate)
raise FileNotFoundError("lls shared library not found; build the project or set LLS_LIBRARY. Tried: " +
", ".join(str(c) for c in candidates))
_loaded = {}
def _load(path: Optional[str]) -> ctypes.CDLL:
path = path or find_library()
if path in _loaded:
return _loaded[path]
lib = ctypes.CDLL(path)
c_int, c_str, c_p, c_i64 = ctypes.c_int, ctypes.c_char_p, ctypes.c_void_p, ctypes.c_int64
def declare(name, restype, *argtypes):
function = getattr(lib, name)
function.restype = restype
function.argtypes = list(argtypes)
declare("lls_api_version", c_int)
declare("lls_status_name", c_str, c_int)
declare("lls_parse_time", c_int, c_str, c_i64, ctypes.POINTER(c_i64))
declare("lls_open", c_int, c_str, ctypes.POINTER(c_p))
declare("lls_close", None, c_p)
declare("lls_last_error", c_str, c_p)
declare("lls_last_messages", c_str, c_p)
declare("lls_path", c_str, c_p)
declare("lls_is_dirty", c_int, c_p)
declare("lls_save", c_int, c_p)
declare("lls_backup", c_int, c_p, c_str, ctypes.c_char_p, ctypes.c_size_t)
declare("lls_restore", c_int, c_p, c_str)
declare("lls_set_clock", None, c_p, c_i64)
declare("lls_now", c_i64, c_p)
declare("lls_account_add", c_int, c_p, c_str)
declare("lls_account_delete", c_int, c_p, c_str)
declare("lls_account_rename", c_int, c_p, c_str, c_str)
declare("lls_account_set_wait", c_int, c_p, c_str, c_str)
declare("lls_account_set_wait_at", c_int, c_p, c_str, c_i64)
declare("lls_account_clear_wait", c_int, c_p, c_str, ctypes.POINTER(c_int))
declare("lls_task_add", c_int, c_p, c_str, c_str, c_str)
declare("lls_task_delete", c_int, c_p, c_str)
declare("lls_task_rename", c_int, c_p, c_str, c_str)
declare("lls_task_set_account", c_int, c_p, c_str, c_str)
declare("lls_task_set_status", c_int, c_p, c_str, c_str)
declare("lls_task_set_notes", c_int, c_p, c_str, c_str)
declare("lls_tasks", c_int, c_p, ctypes.POINTER(c_p))
declare("lls_task_list_count", c_int, c_p)
declare("lls_task_list_get", ctypes.POINTER(_TaskInfo), c_p, c_int)
declare("lls_task_list_free", None, c_p)
declare("lls_accounts", c_int, c_p, ctypes.POINTER(c_p))
declare("lls_account_list_count", c_int, c_p)
declare("lls_account_list_get", ctypes.POINTER(_AccountInfo), c_p, c_int)
declare("lls_account_list_free", None, c_p)
if lib.lls_api_version() != 1:
raise RuntimeError("lls library has API version %d, this module expects 1" % lib.lls_api_version())
_loaded[path] = lib
return lib
def _enc(text: Optional[str]) -> Optional[bytes]:
return None if text is None else text.encode("utf-8")
def _dec(raw: Optional[bytes]) -> Optional[str]:
return None if raw is None else raw.decode("utf-8", errors="replace")
def _when(seconds: int) -> Optional[datetime]:
return datetime.fromtimestamp(seconds).astimezone() if seconds else None
def _seconds(moment: Union[datetime, int, float]) -> int:
return int(moment.timestamp() if isinstance(moment, datetime) else moment)
def parse_time(text: str, now: Union[datetime, int, float, None] = None, library: Optional[str] = None) -> datetime:
"""Reads a clock time like "16:30" or "4:30pm" the way `acc wait` does (a time that has
passed today means tomorrow). Raises InvalidArgument."""
lib = _load(library)
out = ctypes.c_int64()
code = lib.lls_parse_time(_enc(text), 0 if now is None else _seconds(now), ctypes.byref(out))
if code != OK:
raise InvalidArgument(code, "Invalid time '%s'. Use e.g. 16:30, 4:30pm or 4pm." % text)
return _when(out.value)
# ---- The database --------------------------------------------------------------------
class Database:
"""A task / account database. Changes stay in memory until save()."""
def __init__(self, path: Union[str, os.PathLike], library: Optional[str] = None):
self._lib = _load(library)
self._handle = ctypes.c_void_p()
code = self._lib.lls_open(_enc(os.fspath(path)), ctypes.byref(self._handle))
if code != OK:
self._handle = None
self._raise(code, _dec(self._lib.lls_last_error(None)))
# -- lifecycle --
def close(self) -> None:
"""Closes the database WITHOUT saving."""
if self._handle:
self._lib.lls_close(self._handle)
self._handle = None
def __enter__(self) -> "Database":
return self
def __exit__(self, *exc) -> None:
self.close()
def __del__(self):
try:
self.close()
except Exception:
pass
# -- helpers --
def _raise(self, code: int, message: str) -> None:
raise _ERRORS.get(code, LLSError)(code, message)
def _call(self, name: str, *args) -> None:
if not self._handle:
raise LLSError(E_INVALID, "The database is closed.")
code = getattr(self._lib, name)(self._handle, *args)
if code != OK:
self._raise(code, _dec(self._lib.lls_last_error(self._handle)))
# -- state --
@property
def path(self) -> str:
return _dec(self._lib.lls_path(self._handle))
@property
def dirty(self) -> bool:
"""True if there are unsaved changes."""
return bool(self._lib.lls_is_dirty(self._handle))
@property
def messages(self) -> List[str]:
"""What the last change did, as the interactive prompt would print it."""
text = _dec(self._lib.lls_last_messages(self._handle))
return text.split("\n") if text else []
def save(self) -> None:
self._call("lls_save")
def backup(self, file: Optional[str] = None) -> str:
"""Writes a copy and returns its path (a timestamped name if `file` is None)."""
buffer = ctypes.create_string_buffer(4096)
self._call("lls_backup", _enc(file), buffer, len(buffer))
return buffer.value.decode("utf-8")
def restore(self, file: str) -> None:
"""Replaces the data in memory with a file's contents (call save() to keep it)."""
self._call("lls_restore", _enc(file))
def set_clock(self, now: Union[datetime, int, float, None]) -> None:
"""Pretend it is `now` (for tests / simulations); None goes back to the real clock."""
self._lib.lls_set_clock(self._handle, 0 if now is None else _seconds(now))
@property
def now(self) -> datetime:
return _when(self._lib.lls_now(self._handle))
# -- accounts --
def add_account(self, name: str) -> None:
self._call("lls_account_add", _enc(name))
def delete_account(self, name: str) -> None:
"""Its tasks become unassigned."""
self._call("lls_account_delete", _enc(name))
def rename_account(self, name: str, new_name: str) -> None:
self._call("lls_account_rename", _enc(name), _enc(new_name))
def set_account_wait(self, name: str, until: Union[str, datetime, int, float]) -> None:
"""The account hit its limit until `until`: a clock time such as "16:30" / "4:30pm"
(a time that has passed today means tomorrow), a datetime, or Unix seconds."""
if isinstance(until, str):
self._call("lls_account_set_wait", _enc(name), _enc(until))
else:
self._call("lls_account_set_wait_at", _enc(name), _seconds(until))
def clear_account_wait(self, name: str) -> bool:
"""Clears its wait / limit reset. Returns False if it was not waiting."""
was_waiting = ctypes.c_int(0)
self._call("lls_account_clear_wait", _enc(name), ctypes.byref(was_waiting))
return bool(was_waiting.value)
# -- tasks --
def add_task(self, name: str, account: Optional[str] = None, status: Optional[str] = None) -> None:
"""`status` is what you would type at the prompt ("testing", "wait 4pm", "wait"...);
None means prompt."""
self._call("lls_task_add", _enc(name), _enc(account), _enc(status))
def delete_task(self, name: str) -> None:
self._call("lls_task_delete", _enc(name))
def rename_task(self, name: str, new_name: str) -> None:
self._call("lls_task_rename", _enc(name), _enc(new_name))
def set_task_account(self, name: str, account: Optional[str]) -> None:
self._call("lls_task_set_account", _enc(name), _enc(account))
def set_task_status(self, name: str, status: str) -> None:
self._call("lls_task_set_status", _enc(name), _enc(status))
def set_task_notes(self, name: str, notes: str) -> None:
"""Any length, any number of lines; "" clears them."""
self._call("lls_task_set_notes", _enc(name), _enc(notes))
# -- queries --
def tasks(self) -> List[Task]:
"""All tasks in display order."""
handle = ctypes.c_void_p()
self._call("lls_tasks", ctypes.byref(handle))
try:
result = []
for i in range(self._lib.lls_task_list_count(handle)):
info = self._lib.lls_task_list_get(handle, i).contents
result.append(Task(
name=_dec(info.name),
account=_dec(info.account),
status=Status(info.status),
status_label=_dec(info.status_label),
stored_status=Status(info.stored_status),
until=_when(info.until),
notes=_dec(info.notes),
notes_lines=info.notes_lines,
))
return result
finally:
self._lib.lls_task_list_free(handle)
def accounts(self) -> List[Account]:
"""All accounts, most free first."""
handle = ctypes.c_void_p()
self._call("lls_accounts", ctypes.byref(handle))
try:
result = []
for i in range(self._lib.lls_account_list_count(handle)):
info = self._lib.lls_account_list_get(handle, i).contents
result.append(Account(
name=_dec(info.name),
level=Level(info.level),
level_label=_dec(info.level_label),
wait_state=WaitState(info.wait_state),
wait_until=_when(info.wait_until),
wait_listed=bool(info.wait_listed),
tasks=tuple(_dec(info.tasks[k]) for k in range(info.task_count)),
))
return result
finally:
self._lib.lls_account_list_free(handle)
def task(self, name: str) -> Task:
for task in self.tasks():
if task.name.lower() == name.lower():
return task
raise NotFound(E_NOT_FOUND, "No task named '%s'." % name)
def account(self, name: str) -> Account:
for account in self.accounts():
if account.name.lower() == name.lower():
return account
raise NotFound(E_NOT_FOUND, "No account named '%s'." % name)
+314
View File
@@ -0,0 +1,314 @@
"""Tests for the C API through the Python binding.
LLS_LIBRARY=build/liblls_api.so python3 python/test_lls.py -v
"""
import os
import sys
import tempfile
import unittest
from datetime import datetime
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import lls # noqa: E402
NOON = datetime(2030, 6, 15, 12, 0) # the pretend "now" used by most tests
class Base(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.path = os.path.join(self.tmp.name, "tasks.db")
self.db = lls.Database(self.path)
self.db.set_clock(NOON)
self.addCleanup(self.tmp.cleanup)
self.addCleanup(self.db.close)
def at(self, hour, minute=0):
self.db.set_clock(NOON.replace(hour=hour, minute=minute))
def statuses(self):
return {t.name: t.status_label for t in self.db.tasks()}
class CrudTests(Base):
def test_accounts_and_tasks(self):
self.db.add_account("Acrux")
self.db.add_account("beta")
self.db.add_task("Breeze A", account="acrux") # names are case-insensitive
task = self.db.task("breeze a")
self.assertEqual(task.name, "Breeze A")
self.assertEqual(task.account, "Acrux") # the account's own spelling
self.assertEqual(task.status, lls.Status.PROMPT)
# most free first: beta has no tasks (Free), Acrux has a Prompt task (Taken)
self.assertEqual([a.name for a in self.db.accounts()], ["beta", "Acrux"])
def test_errors_have_types(self):
self.db.add_account("A")
with self.assertRaises(lls.AlreadyExists):
self.db.add_account("a")
with self.assertRaises(lls.NotFound):
self.db.delete_account("nope")
with self.assertRaises(lls.NotFound):
self.db.add_task("t", account="nope")
with self.assertRaises(lls.InvalidArgument):
self.db.add_account("-")
with self.assertRaises(lls.InvalidArgument):
self.db.add_task("t", status="sleeping")
with self.assertRaises(lls.InvalidArgument):
self.db.set_account_wait("A", "25:99")
self.db.add_task("t")
with self.assertRaises(lls.AlreadyExists):
self.db.add_task("T")
try:
self.db.delete_task("zzz")
except lls.LLSError as error:
self.assertEqual(error.code, lls.E_NOT_FOUND)
self.assertIn("No task named 'zzz'", str(error))
def test_rename_reassign_delete(self):
self.db.add_account("A")
self.db.add_account("B")
self.db.add_task("t1", "A")
self.db.rename_task("t1", "t2")
self.db.set_task_account("t2", "B")
self.assertEqual(self.db.task("t2").account, "B")
self.db.set_task_account("t2", None)
self.assertIsNone(self.db.task("t2").account)
self.db.set_task_account("t2", "A")
self.db.rename_account("A", "Alpha") # tasks follow the rename
self.assertEqual(self.db.task("t2").account, "Alpha")
self.db.delete_account("Alpha") # tasks become unassigned
self.assertIsNone(self.db.task("t2").account)
self.db.delete_task("t2")
self.assertEqual(self.db.tasks(), [])
def test_unicode(self):
self.db.add_account("Cuenta ñandú")
self.db.add_task("Tarea ✓", "Cuenta ñandú")
self.db.set_task_notes("Tarea ✓", "línea uno\nlínea dos é")
task = self.db.task("Tarea ✓")
self.assertEqual(task.account, "Cuenta ñandú")
self.assertEqual(task.notes, "línea uno\nlínea dos é")
self.assertEqual(task.notes_lines, 2)
class OrderingTests(Base):
def test_task_order(self):
for account in ("Zeta", "alpha", "Mid"):
self.db.add_account(account)
self.at(12)
# waiting / limit reset come from the account
self.db.add_task("d1", "Zeta", "done")
self.db.add_task("f1", "alpha", "frozen")
self.db.add_task("u1", "alpha", "uploading")
self.db.add_task("p-zeta", "Zeta")
self.db.add_task("p-alpha", "alpha")
self.db.add_task("p-none")
self.db.add_task("t1", "Zeta", "testing")
self.db.add_task("lr", "Mid", "limit reset")
order = [t.name for t in self.db.tasks()]
self.assertEqual(order, ["lr", "t1", "p-alpha", "p-zeta", "p-none", "u1", "f1", "d1"])
def test_wait_and_limit_reset_sort_by_time_before_account(self):
for account in ("A", "B", "C"):
self.db.add_account(account)
self.db.add_task("task-" + account, account)
self.db.set_account_wait("A", "6pm")
self.db.set_account_wait("B", "2pm")
self.db.set_account_wait("C", "4pm")
self.assertEqual([t.name for t in self.db.tasks()], ["task-B", "task-C", "task-A"])
self.at(17) # B and C are over now: Limit reset first, ordered by when they ended
self.assertEqual([t.status_label for t in self.db.tasks()], ["Limit reset", "Limit reset", "Wait 18:00"])
self.assertEqual([t.name for t in self.db.tasks()], ["task-B", "task-C", "task-A"])
def test_accounts_most_free_first(self):
for account in ("Busy1", "Taken1", "Free1", "Free2", "Busy2"):
self.db.add_account(account)
self.db.add_task("t", "Busy1", "testing")
self.db.add_task("w1", "Busy2", "testing")
self.db.add_task("w2", "Busy2", "testing")
self.db.add_task("p", "Taken1")
self.db.add_task("d", "Free1", "done")
accounts = self.db.accounts()
self.assertEqual([a.name for a in accounts], ["Free1", "Free2", "Taken1", "Busy1", "Busy2"])
self.assertEqual([a.level for a in accounts],
[lls.Level.FREE, lls.Level.FREE, lls.Level.TAKEN, lls.Level.BUSY, lls.Level.BUSY])
self.assertEqual(accounts[0].tasks, ("d",)) # a Free account lists its Done task
self.assertEqual(accounts[2].tasks, ("p",))
self.assertEqual(accounts[4].tasks, ("w1", "w2"))
class WaitTests(Base):
def setUp(self):
super().setUp()
self.db.add_account("A")
self.db.add_account("B")
self.db.add_task("a-testing", "A", "testing")
self.db.add_task("a-prompt", "A")
self.db.add_task("a-done", "A", "done")
self.db.add_task("b-prompt", "B")
def test_wait_belongs_to_the_account(self):
self.db.set_account_wait("A", "4:30pm")
self.assertEqual(self.statuses(), {"a-testing": "Wait 16:30", "a-prompt": "Wait 16:30",
"a-done": "Done", "b-prompt": "Prompt"})
self.assertEqual(self.db.task("a-testing").stored_status, lls.Status.TESTING) # kept underneath
self.assertEqual(self.db.task("a-testing").until, NOON.replace(hour=16, minute=30).astimezone())
account = self.db.account("A")
self.assertEqual((account.level, account.wait_state), (lls.Level.BUSY, lls.WaitState.WAITING))
self.at(17) # the wait is over -> Limit reset until cleared
self.assertEqual(self.db.task("a-prompt").status, lls.Status.LIMIT_RESET)
self.assertEqual(self.db.account("A").wait_state, lls.WaitState.LIMIT_RESET)
self.assertTrue(self.db.clear_account_wait("A"))
self.assertFalse(self.db.clear_account_wait("A"))
self.assertEqual(self.db.task("a-testing").status, lls.Status.TESTING)
self.assertEqual(self.db.task("a-prompt").status, lls.Status.PROMPT)
def test_time_in_the_past_means_tomorrow(self):
self.db.set_account_wait("A", "9am") # it is noon
until = self.db.account("A").wait_until
self.assertEqual((until.day, until.hour), (16, 9))
self.assertEqual(self.db.account("A").wait_state, lls.WaitState.WAITING)
def test_set_wait_with_datetime_and_seconds(self):
self.db.set_account_wait("A", NOON.replace(hour=15))
self.assertEqual(self.db.task("a-prompt").status_label, "Wait 15:00")
self.db.set_account_wait("A", int(NOON.replace(hour=20).timestamp()))
self.assertEqual(self.db.task("a-prompt").status_label, "Wait 20:00")
def test_task_wait_sets_the_account_wait(self):
self.db.set_task_status("b-prompt", "wait 3pm")
self.assertEqual(self.db.account("B").wait_state, lls.WaitState.WAITING)
self.assertEqual(self.db.task("b-prompt").status_label, "Wait 15:00")
def test_wait_without_time_only_while_account_is_waiting(self):
with self.assertRaises(lls.InvalidArgument):
self.db.set_task_status("b-prompt", "wait")
with self.assertRaises(lls.InvalidArgument):
self.db.add_task("new", "B", "wait")
self.db.add_task("loose")
with self.assertRaises(lls.InvalidArgument):
self.db.set_task_status("loose", "wait")
self.db.set_account_wait("B", "4pm")
self.db.add_task("follower", "B", "wait") # fine now
self.assertEqual(self.db.task("follower").status_label, "Wait 16:00")
self.at(17) # over: a time is required again
with self.assertRaises(lls.InvalidArgument):
self.db.set_task_status("b-prompt", "wait")
def test_explicit_testing_or_prompt_clears_a_finished_wait(self):
self.db.set_account_wait("A", "1pm")
self.at(14)
self.assertEqual(self.db.task("a-prompt").status, lls.Status.LIMIT_RESET)
self.db.add_task("implicit", "A") # no status given: nothing cleared
self.assertEqual(self.db.account("A").wait_state, lls.WaitState.LIMIT_RESET)
self.db.set_task_status("a-testing", "uploading") # not an "in use" status
self.assertEqual(self.db.account("A").wait_state, lls.WaitState.LIMIT_RESET)
self.db.add_task("resumed", "A", "testing") # explicit: the account is in use again
self.assertTrue(any("back in use" in m for m in self.db.messages))
self.assertEqual(self.db.account("A").wait_state, lls.WaitState.NONE) # (queries keep the messages)
self.assertEqual(self.db.task("resumed").status, lls.Status.TESTING)
self.assertEqual(self.db.task("a-prompt").status, lls.Status.PROMPT)
self.assertTrue(any("back in use" in m for m in self.db.messages))
def test_a_running_wait_is_never_cleared_automatically(self):
self.db.set_account_wait("A", "6pm")
self.db.set_task_status("a-testing", "testing")
self.assertEqual(self.db.account("A").wait_state, lls.WaitState.WAITING)
self.assertEqual(self.db.task("a-testing").status, lls.Status.WAIT)
def test_account_wait_nothing_shows(self):
self.db.add_account("Empty")
self.db.set_account_wait("Empty", "5pm")
account = self.db.account("Empty")
self.assertEqual((account.level, account.wait_listed, account.tasks), (lls.Level.BUSY, True, ()))
self.db.add_task("only-done", "Empty", "done")
self.assertTrue(self.db.account("Empty").wait_listed)
self.assertEqual(self.db.account("Empty").tasks, ())
def test_messages(self):
self.db.set_account_wait("A", "4:30pm")
self.assertEqual(len(self.db.messages), 1)
self.assertIn("waits until 16:30", self.db.messages[0])
with self.assertRaises(lls.NotFound):
self.db.delete_task("zzz")
self.assertEqual(self.db.messages, []) # a failed call leaves no notes
class NotesAndPersistenceTests(Base):
def test_notes_have_no_length_limit(self):
self.db.add_task("t")
text = ("x" * 99) + "\n"
text *= 3000 # ~300 kB, 3000 lines
text = text.rstrip("\n")
self.db.set_task_notes("t", text)
task = self.db.task("t")
self.assertEqual(task.notes, text)
self.assertEqual(task.notes_lines, 3000)
self.db.set_task_notes("t", "")
self.assertEqual(self.db.task("t").notes_lines, 0)
def test_save_reopen_and_dirty_flag(self):
self.assertFalse(self.db.dirty)
self.db.add_account("A")
self.db.add_task("t", "A", "testing")
self.db.set_task_notes("t", "one\ntwo")
self.db.set_account_wait("A", "11pm")
self.assertTrue(self.db.dirty)
self.db.save()
self.assertFalse(self.db.dirty)
with lls.Database(self.path) as other:
other.set_clock(NOON)
task = other.task("t")
self.assertEqual((task.account, task.notes, task.status_label), ("A", "one\ntwo", "Wait 23:00"))
def test_unsaved_changes_are_not_written(self):
self.db.add_account("A")
self.db.save()
self.db.add_account("B")
self.db.close()
with lls.Database(self.path) as other:
self.assertEqual([a.name for a in other.accounts()], ["A"])
def test_backup_and_restore(self):
self.db.add_account("A")
copy = self.db.backup(os.path.join(self.tmp.name, "copy.db"))
self.assertTrue(os.path.isfile(copy))
auto = self.db.backup() # timestamped name next to the database
self.assertTrue(auto.startswith(self.path) and os.path.isfile(auto))
self.db.add_account("B")
self.db.restore(copy)
self.assertEqual([a.name for a in self.db.accounts()], ["A"])
self.assertTrue(self.db.dirty)
with self.assertRaises(lls.LLSError):
self.db.restore(os.path.join(self.tmp.name, "missing.db"))
def test_opening_a_bad_path_or_file(self):
bad = os.path.join(self.tmp.name, "not-a-db.db")
Path(bad).write_text("this is not sqlite")
with self.assertRaises(lls.DatabaseError):
lls.Database(bad)
def test_closed_database_raises(self):
self.db.close()
with self.assertRaises(lls.LLSError):
self.db.add_account("A")
class UtilityTests(unittest.TestCase):
def test_parse_time(self):
self.assertEqual(lls.parse_time("4:30pm", now=NOON), NOON.replace(hour=16, minute=30).astimezone())
self.assertEqual(lls.parse_time("16:30", now=NOON).hour, 16)
self.assertEqual(lls.parse_time("12am", now=NOON).day, 16) # already passed -> tomorrow
for bad in ("", "6", "13pm", "25:00", "soon"):
with self.assertRaises(lls.InvalidArgument):
lls.parse_time(bad, now=NOON)
if __name__ == "__main__":
unittest.main()