C and Python API + Smarter account-based wait times
This commit is contained in:
1 parent
35c568333d
commit
10badb282c
18 files changed
+2204
-429
No files matched your search
Binary file not shown.
+439
@@ -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)
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user