import json
import os
import sys
KIND = "document-store"
PROTOCOL_VERSION = 2
MAX_PAGE_SIZE = 50
TEXT_FIELDS = ("title", "content", "title-or-content")
def refused(message):
return {"kind": "refused", "message": message}
def malformed(message):
return {"kind": "malformed", "message": message}
def a_string(value):
return isinstance(value, str)
def an_integer(value):
return isinstance(value, int) and not isinstance(value, bool)
def checked(condition, message):
if not condition:
raise Refusal(malformed(message))
def checked_string(value, what):
checked(a_string(value), "%s must be a string" % what)
return value
def checked_optional_string(value, what):
checked(value is None or a_string(value), "%s must be a string or null" % what)
return value
def checked_list(value, what):
if value is None:
return []
checked(isinstance(value, list), "%s must be a list" % what)
return value
def checked_object(value, what):
if value is None:
return {}
checked(isinstance(value, dict), "%s must be an object" % what)
return value
def checked_location(location, what):
if location is None:
return None
checked(
isinstance(location, dict)
and len(location) == 1
and ("url" in location or "path" in location),
'%s\'s location must be {"url": <link>} or {"path": <absolute path>}' % what,
)
key = "url" if "url" in location else "path"
checked_string(location[key], "%s's location %s" % (what, key))
return location
def checked_document(value, what):
checked(isinstance(value, dict), "%s must be an object" % what)
checked_string(value.get("id"), "%s needs an id that" % what)
checked_string(value.get("title"), "%s needs a title that" % what)
for member in ("content", "project", "url", "created_at", "updated_at"):
checked_optional_string(value.get(member), "%s's %s" % (what, member))
for label in checked_list(value.get("labels"), "%s's labels" % what):
checked(isinstance(label, dict), "%s's labels must each be an object" % what)
checked_string(label.get("id"), "%s's label id" % what)
checked_string(label.get("name"), "%s's label name" % what)
checked_location(value.get("location"), what)
checked_object(value.get("metadata"), "%s's metadata" % what)
for origin in checked_list(value.get("repositories"), "%s's repositories" % what):
checked_string(origin, "%s's repository origin" % what)
return value
def checked_query(query, method):
text = query.get("text")
if text is not None:
checked(isinstance(text, dict), "%s's text query must be an object or null" % method)
checked_string(text.get("terms"), "%s's search terms" % method)
checked(
text.get("fields") in TEXT_FIELDS,
"%s's search fields must be one of %s" % (method, ", ".join(TEXT_FIELDS)),
)
labels = checked_object(query.get("labels"), "%s's label filter" % method)
for member in ("any_of", "all_of", "none_of"):
names = checked_list(labels.get(member), "%s's %s label filter" % (method, member))
for name in names:
checked_string(name, "%s's %s label name" % (method, member))
project = query.get("project", "any")
checked(
project in ("any", "orphans")
or (isinstance(project, dict) and a_string(project.get("is"))),
'%s\'s project filter must be "any", "orphans" or {"is": <native id>}' % method,
)
return query
def checked_page(page, method):
cursor = page.get("cursor")
checked(
cursor is None or a_string(cursor),
"%s's page cursor must be a string or null" % method,
)
checked(
an_integer(page.get("limit", 0)),
"%s's page limit must be an integer" % method,
)
return page
def checked_write(write, method):
target = write.get("target")
checked(
target is None or a_string(target),
"%s's write target must be a native id or null" % method,
)
return target, checked_document(write.get("item"), "%s's item" % method)
def read_store(path):
try:
with open(path, encoding="utf-8") as handle:
held = json.load(handle)
except FileNotFoundError:
return []
except ValueError:
raise Refusal(malformed("%s is not JSON, so it is not this source's store" % path))
documents = held.get("documents", []) if isinstance(held, dict) else None
if not isinstance(documents, list):
raise Refusal(malformed('%s is not a store: expected {"documents": [...]}' % path))
for index, document in enumerate(documents):
checked_document(document, "%s document %d" % (path, index))
return documents
def write_store(path, documents):
parent = os.path.dirname(path)
if parent:
os.makedirs(parent, exist_ok=True)
with open(path, "w", encoding="utf-8") as handle:
json.dump({"documents": documents}, handle, indent=2)
def note(settings, method):
path = settings.get("log")
if path is None:
return
with open(path, "a", encoding="utf-8") as handle:
handle.write(method + "\n")
def capabilities(settings):
return {
"projects": "unsupported",
"documents": settings.get("documents", "native"),
"orphan_tasks": "native",
"filter_by_label": "native",
"filter_by_status": "native",
"search_title": "native",
"search_content": "native",
"task_dependencies": "both-directions",
"project_dependencies": "both-directions",
"max_page_size": MAX_PAGE_SIZE,
}
def has_documents(settings):
return settings.get("documents", "native") == "native"
def matches_text(document, query):
if query is None:
return True
terms = query["terms"].lower()
in_title = terms in (document.get("title") or "").lower()
in_content = terms in (document.get("content") or "").lower()
fields = query["fields"]
if fields == "title":
return in_title
if fields == "content":
return in_content
return in_title or in_content
def survives(document, query):
held = [label["name"].lower() for label in document.get("labels") or []]
labels = query.get("labels") or {}
any_of = labels.get("any_of") or []
if any_of and not any(name.lower() in held for name in any_of):
return False
if not all(name.lower() in held for name in labels.get("all_of") or []):
return False
if any(name.lower() in held for name in labels.get("none_of") or []):
return False
project = query.get("project", "any")
if project == "orphans" and document.get("project") is not None:
return False
if isinstance(project, dict) and document.get("project") != project.get("is"):
return False
return matches_text(document, query.get("text"))
def paginate(items, page):
cursor = page.get("cursor")
start = 0
if cursor is not None:
if not cursor.isdigit() or int(cursor) >= len(items):
raise Refusal(
malformed(
"cursor %r was not issued by this source; it addresses no row of the %d "
"result(s) available" % (cursor, len(items))
)
)
start = int(cursor)
limit = page.get("limit", 0)
if limit < 1:
raise Refusal(
{
"kind": "config",
"message": "a page limit of 0 is not a page; ask for at least 1 row",
}
)
end = min(start + min(limit, MAX_PAGE_SIZE), len(items))
return {
"items": items[start:end],
"next": str(end) if end < len(items) else None,
}
def unused(documents, wanted):
taken = {document["id"] for document in documents}
if wanted not in taken:
return wanted
attempt = 2
while "%s-%d" % (wanted, attempt) in taken:
attempt += 1
return "%s-%d" % (wanted, attempt)
class Refusal(Exception):
def __init__(self, error):
super().__init__(error["message"])
self.error = error
def parameter(params, name, kind, method):
value = params.get(name) if isinstance(params, dict) else None
if not isinstance(value, kind):
raise Refusal(
malformed(
"%s needs a %s parameter %r" % (method, kind.__name__, name)
)
)
return value
def document_side(settings):
if not has_documents(settings):
raise Refusal(refused("the %s plugin has no documents" % KIND))
def dispatch(settings, method, params):
store = settings["store"]
if method == "health":
return {
"reachable": True,
"detail": "%d document(s) on disk" % len(read_store(store)),
}
if method in ("get_task", "get_project"):
return {method.removeprefix("get_"): None}
if method in (
"query_tasks",
"query_projects",
"labels",
"task_dependencies",
"project_dependencies",
):
return {"items": [], "next": None}
if method == "get_document":
document_side(settings)
wanted = parameter(params, "id", str, method)
found = [d for d in read_store(store) if d["id"] == wanted]
return {"document": found[0] if found else None}
if method == "query_documents":
document_side(settings)
query = checked_query(parameter(params, "query", dict, method), method)
page = checked_page(parameter(params, "page", dict, method), method)
kept = [d for d in read_store(store) if survives(d, query)]
return paginate(kept, page)
if method == "write_document":
document_side(settings)
target, item = checked_write(parameter(params, "write", dict, method), method)
documents = read_store(store)
landing = dict(item)
if target is None:
landing["id"] = unused(documents, landing["id"])
documents.append(landing)
else:
at = [i for i, d in enumerate(documents) if d["id"] == target]
if not at:
raise Refusal(
refused(
"%s names no document this source holds; next: copy with --recreate "
"to create one instead of updating" % target
)
)
landing["id"] = target
documents[at[0]] = landing
write_store(store, documents)
return {"id": landing["id"]}
if method == "delete_document":
document_side(settings)
unwanted = parameter(params, "id", str, method)
documents = [d for d in read_store(store) if d["id"] != unwanted]
write_store(store, documents)
return {}
if method in ("write_task", "write_project", "delete_task", "delete_project"):
raise Refusal(refused("the %s plugin cannot be written" % KIND))
raise Refusal(
malformed("protocol version %d has no method called %r" % (PROTOCOL_VERSION, method))
)
def initialize(params):
version = params.get("protocol_version")
if version != PROTOCOL_VERSION:
raise Refusal(
{
"kind": "config",
"message": "protocol version %s is not supported by this plugin; it speaks "
"version %d" % (version, PROTOCOL_VERSION),
}
)
settings = params.get("config") or {}
if (
not isinstance(settings, dict)
or not isinstance(settings.get("store"), str)
or settings.get("documents", "native") not in ("native", "unsupported")
or not isinstance(settings.get("log", ""), str)
):
raise Refusal(
{
"kind": "config",
"message": 'this source\'s settings must be {"store": <path>, "documents": '
'"native"|"unsupported", "log": <path>}, the last two optional',
}
)
note(settings, "initialize")
return settings, {
"protocol_version": PROTOCOL_VERSION,
"kind": KIND,
"capabilities": capabilities(settings),
"writes": "supported",
}
def main():
settings = None
for line in sys.stdin:
if not line.strip():
continue
try:
request = json.loads(line)
identifier = request["id"]
except (ValueError, KeyError, TypeError):
print("%s: ignoring an unaddressed line" % KIND, file=sys.stderr)
continue
if not isinstance(identifier, str):
print("%s: ignoring a line whose id is not a string" % KIND, file=sys.stderr)
continue
method = request.get("method", "")
params = request.get("params")
try:
if not isinstance(method, str):
raise Refusal(malformed("a request names its method as a string"))
if not isinstance(params, dict):
raise Refusal(
malformed("a request carries an object `params`, present even when empty")
)
if method == "initialize":
settings, result = initialize(params)
elif settings is None:
raise Refusal(malformed("%s arrived before the handshake" % method))
else:
note(settings, method)
result = dispatch(settings, method, params)
answer = {"id": identifier, "result": result}
except Refusal as refusal:
answer = {"id": identifier, "error": refusal.error}
sys.stdout.write(json.dumps(answer) + "\n")
sys.stdout.flush()
if __name__ == "__main__":
main()