Easier and more self documenting patterns for loading/saving Persistent
[python_utils.git] / logical_search.py
index 41ed729fc15df80a4477d3094e4dc980869741aa..e710d0b65be57912bcd7781d6c9cc80eeffe2249 100644 (file)
@@ -1,11 +1,12 @@
 #!/usr/bin/env python3
 
+# © Copyright 2021-2022, Scott Gasch
+
 """This is a module concerned with the creation of and searching of a
-corpus of documents.  The corpus is held in memory for fast
-searching."""
+corpus of documents.  The corpus and index are held in memory.
+"""
 
 from __future__ import annotations
-
 import enum
 import sys
 from collections import defaultdict
@@ -25,18 +26,27 @@ class ParseError(Exception):
 class Document:
     """A class representing a searchable document."""
 
-    # A unique identifier for each document.
     docid: str = ''
+    """A unique identifier for each document -- must be provided
+    by the caller.  See :meth:`python_modules.id_generator.get` or
+    :meth:`python_modules.string_utils.generate_uuid` for potential
+    sources."""
 
-    # A set of tag strings for this document.  May be empty.
     tags: Set[str] = field(default_factory=set)
+    """A set of tag strings for this document.  May be empty.  Tags
+    are simply text labels that are associated with a document and
+    may be used to search for it later.
+    """
 
-    # A list of key->value strings for this document.  May be empty.
     properties: List[Tuple[str, str]] = field(default_factory=list)
+    """A list of key->value strings for this document.  May be empty.
+    Properties are more flexible tags that have both a label and a
+    value.  e.g. "category:mystery" or "author:smith"."""
 
-    # An optional reference to something else; interpreted only by
-    # caller code, ignored here.
     reference: Optional[Any] = None
+    """An optional reference to something else for convenience;
+    interpreted only by caller code, ignored here.
+    """
 
 
 class Operation(enum.Enum):
@@ -66,7 +76,11 @@ class Operation(enum.Enum):
 
 
 class Corpus(object):
-    """A collection of searchable documents.
+    """A collection of searchable documents.  The caller can
+    add documents to it (or edit existing docs) via :meth:`add_doc`,
+    retrieve a document given its docid via :meth:`get_doc`, and
+    perform various lookups of documents.  The most interesting
+    lookup is implemented in :meth:`query`.
 
     >>> c = Corpus()
     >>> c.add_doc(Document(
@@ -101,6 +115,12 @@ class Corpus(object):
     ...          )
     >>> c.query('author:Scott and important')
     {1}
+    >>> c.query('*')
+    {1, 2, 3}
+    >>> c.query('*:*')
+    {1, 2, 3}
+    >>> c.query('*:Scott')
+    {1, 3}
     """
 
     def __init__(self) -> None:
@@ -114,11 +134,14 @@ class Corpus(object):
         distinct docid that will serve as its primary identifier.  If
         the same Document is added multiple times, only the most
         recent addition is indexed.  If two distinct documents with
-        the same docid are added, the latter klobbers the former in the
-        indexes.
+        the same docid are added, the latter klobbers the former in
+        the indexes.  See :meth:`python_modules.id_generator.get` or
+        :meth:`python_modules.string_utils.generate_uuid` for potential
+        sources of docids.
 
         Each Document may have an optional set of tags which can be
-        used later in expressions to the query method.
+        used later in expressions to the query method.  These are simple
+        text labels.
 
         Each Document may have an optional list of key->value tuples
         which can be used later in expressions to the query method.
@@ -127,6 +150,9 @@ class Corpus(object):
         never interpreted by this module.  This is meant to allow easy
         mapping between Documents in this corpus and external objects
         they may represent.
+
+        Args:
+            doc: the document to add or edit
         """
 
         if doc.docid in self.documents_by_docid:
@@ -152,13 +178,27 @@ class Corpus(object):
             self.docids_with_property[key].add(doc.docid)
 
     def get_docids_by_exact_tag(self, tag: str) -> Set[str]:
-        """Return the set of docids that have a particular tag."""
+        """Return the set of docids that have a particular tag.
+
+        Args:
+            tag: the tag for which to search
 
+        Returns:
+            A set containing docids with the provided tag which
+            may be empty."""
         return self.docids_by_tag[tag]
 
     def get_docids_by_searching_tags(self, tag: str) -> Set[str]:
-        """Return the set of docids with a tag that contains a str"""
+        """Return the set of docids with a tag that contains a str.
 
+        Args:
+            tag: the tag pattern for which to search
+
+        Returns:
+            A set containing docids with tags that match the pattern
+            provided.  e.g., if the arg was "foo" tags "football", "foobar",
+            and "food" all match.
+        """
         ret = set()
         for search_tag in self.docids_by_tag:
             if tag in search_tag:
@@ -170,42 +210,65 @@ class Corpus(object):
         """Return the set of docids that have a particular property no matter
         what that property's value.
 
+        Args:
+            key: the key value to search for.
+
+        Returns:
+            A set of docids that contain the key (no matter what value)
+            which may be empty.
         """
         return self.docids_with_property[key]
 
     def get_docids_by_property(self, key: str, value: str) -> Set[str]:
         """Return the set of docids that have a particular property with a
-        particular value..
+        particular value.
+
+        Args:
+            key: the key to search for
+            value: the value that key must have in order to match a doc.
 
+        Returns:
+            A set of docids that contain key with value which may be empty.
         """
         return self.docids_by_property[(key, value)]
 
     def invert_docid_set(self, original: Set[str]) -> Set[str]:
         """Invert a set of docids."""
-
-        return set([docid for docid in self.documents_by_docid.keys() if docid not in original])
+        return {docid for docid in self.documents_by_docid if docid not in original}
 
     def get_doc(self, docid: str) -> Optional[Document]:
-        """Given a docid, retrieve the previously added Document."""
+        """Given a docid, retrieve the previously added Document.
+
+        Args:
+            docid: the docid to retrieve
 
+        Returns:
+            The Document with docid or None to indicate no match.
+        """
         return self.documents_by_docid.get(docid, None)
 
     def query(self, query: str) -> Optional[Set[str]]:
         """Query the corpus for documents that match a logical expression.
-        Returns a (potentially empty) set of docids for the matching
-        (previously added) documents or None on error.
 
-        e.g.
+        Args:
+            query: the logical query expressed using a simple language
+                that understands conjunction (and operator), disjunction
+                (or operator) and inversion (not operator) as well as
+                parenthesis.  Here are some legal sample queries::
+
+                    tag1 and tag2 and not tag3
 
-        tag1 and tag2 and not tag3
+                    (tag1 or tag2) and (tag3 or tag4)
 
-        (tag1 or tag2) and (tag3 or tag4)
+                    (tag1 and key2:value2) or (tag2 and key1:value1)
 
-        (tag1 and key2:value2) or (tag2 and key1:value1)
+                    key:*
 
-        key:*
+                    tag1 and key:*
 
-        tag1 and key:*
+        Returns:
+            A (potentially empty) set of docids for the matching
+            (previously added) documents or None on error.
         """
 
         try:
@@ -354,12 +417,24 @@ class Node(object):
                             key, value = tag.split(":")
                         except ValueError as v:
                             raise ParseError(f'Invalid key:value syntax at "{tag}"') from v
-                        if value == "*":
-                            r = self.corpus.get_docids_with_property(key)
+
+                        if key == '*':
+                            r = set()
+                            for kv, s in self.corpus.docids_by_property.items():
+                                if value in ('*', kv[1]):
+                                    r.update(s)
                         else:
-                            r = self.corpus.get_docids_by_property(key, value)
+                            if value == '*':
+                                r = self.corpus.get_docids_with_property(key)
+                            else:
+                                r = self.corpus.get_docids_by_property(key, value)
                     else:
-                        r = self.corpus.get_docids_by_exact_tag(tag)
+                        if tag == '*':
+                            r = set()
+                            for s in self.corpus.docids_by_tag.values():
+                                r.update(s)
+                        else:
+                            r = self.corpus.get_docids_by_exact_tag(tag)
                     retval.update(r)
                 else:
                     raise ParseError(f"Unexpected query {tag}")