443 lines
16 KiB
Python
443 lines
16 KiB
Python
"""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
|
|
|
|
|
|
# ---- 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 becomes Limit reset when the account's wait ends)
|
|
status_label: str # "Prompt", "Limit reset", "Wait 16:30", "Testing (account-waiting)"...
|
|
stored_status: Status # what is saved underneath
|
|
until: Optional[datetime] # for Wait / Limit reset: when the wait ends (ended)
|
|
notes: str
|
|
notes_lines: int
|
|
account_waiting: bool # Prompt / Testing while its account waits (the label says so)
|
|
|
|
|
|
@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 # waiting, but no task is set to Wait (the 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),
|
|
("account_waiting", 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. Tasks
|
|
set to "wait" follow it; when the time passes they become Limit reset."""
|
|
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:
|
|
"""Ends the wait now (its Wait tasks become Limit reset). 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,
|
|
account_waiting=bool(info.account_waiting),
|
|
))
|
|
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)
|