You cannot select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
	
	
		
			307 lines
		
	
	
		
			9.2 KiB
		
	
	
	
		
			Python
		
	
			
		
		
	
	
			307 lines
		
	
	
		
			9.2 KiB
		
	
	
	
		
			Python
		
	
"""
 | 
						|
Implementations for the history of a `Buffer`.
 | 
						|
 | 
						|
NOTE: There is no `DynamicHistory`:
 | 
						|
      This doesn't work well, because the `Buffer` needs to be able to attach
 | 
						|
      an event handler to the event when a history entry is loaded. This
 | 
						|
      loading can be done asynchronously and making the history swappable would
 | 
						|
      probably break this.
 | 
						|
"""
 | 
						|
 | 
						|
from __future__ import annotations
 | 
						|
 | 
						|
import datetime
 | 
						|
import os
 | 
						|
import threading
 | 
						|
from abc import ABCMeta, abstractmethod
 | 
						|
from asyncio import get_running_loop
 | 
						|
from typing import AsyncGenerator, Iterable, Sequence, Union
 | 
						|
 | 
						|
__all__ = [
 | 
						|
    "History",
 | 
						|
    "ThreadedHistory",
 | 
						|
    "DummyHistory",
 | 
						|
    "FileHistory",
 | 
						|
    "InMemoryHistory",
 | 
						|
]
 | 
						|
 | 
						|
 | 
						|
class History(metaclass=ABCMeta):
 | 
						|
    """
 | 
						|
    Base ``History`` class.
 | 
						|
 | 
						|
    This also includes abstract methods for loading/storing history.
 | 
						|
    """
 | 
						|
 | 
						|
    def __init__(self) -> None:
 | 
						|
        # In memory storage for strings.
 | 
						|
        self._loaded = False
 | 
						|
 | 
						|
        # History that's loaded already, in reverse order. Latest, most recent
 | 
						|
        # item first.
 | 
						|
        self._loaded_strings: list[str] = []
 | 
						|
 | 
						|
    #
 | 
						|
    # Methods expected by `Buffer`.
 | 
						|
    #
 | 
						|
 | 
						|
    async def load(self) -> AsyncGenerator[str, None]:
 | 
						|
        """
 | 
						|
        Load the history and yield all the entries in reverse order (latest,
 | 
						|
        most recent history entry first).
 | 
						|
 | 
						|
        This method can be called multiple times from the `Buffer` to
 | 
						|
        repopulate the history when prompting for a new input. So we are
 | 
						|
        responsible here for both caching, and making sure that strings that
 | 
						|
        were were appended to the history will be incorporated next time this
 | 
						|
        method is called.
 | 
						|
        """
 | 
						|
        if not self._loaded:
 | 
						|
            self._loaded_strings = list(self.load_history_strings())
 | 
						|
            self._loaded = True
 | 
						|
 | 
						|
        for item in self._loaded_strings:
 | 
						|
            yield item
 | 
						|
 | 
						|
    def get_strings(self) -> list[str]:
 | 
						|
        """
 | 
						|
        Get the strings from the history that are loaded so far.
 | 
						|
        (In order. Oldest item first.)
 | 
						|
        """
 | 
						|
        return self._loaded_strings[::-1]
 | 
						|
 | 
						|
    def append_string(self, string: str) -> None:
 | 
						|
        "Add string to the history."
 | 
						|
        self._loaded_strings.insert(0, string)
 | 
						|
        self.store_string(string)
 | 
						|
 | 
						|
    #
 | 
						|
    # Implementation for specific backends.
 | 
						|
    #
 | 
						|
 | 
						|
    @abstractmethod
 | 
						|
    def load_history_strings(self) -> Iterable[str]:
 | 
						|
        """
 | 
						|
        This should be a generator that yields `str` instances.
 | 
						|
 | 
						|
        It should yield the most recent items first, because they are the most
 | 
						|
        important. (The history can already be used, even when it's only
 | 
						|
        partially loaded.)
 | 
						|
        """
 | 
						|
        while False:
 | 
						|
            yield
 | 
						|
 | 
						|
    @abstractmethod
 | 
						|
    def store_string(self, string: str) -> None:
 | 
						|
        """
 | 
						|
        Store the string in persistent storage.
 | 
						|
        """
 | 
						|
 | 
						|
 | 
						|
class ThreadedHistory(History):
 | 
						|
    """
 | 
						|
    Wrapper around `History` implementations that run the `load()` generator in
 | 
						|
    a thread.
 | 
						|
 | 
						|
    Use this to increase the start-up time of prompt_toolkit applications.
 | 
						|
    History entries are available as soon as they are loaded. We don't have to
 | 
						|
    wait for everything to be loaded.
 | 
						|
    """
 | 
						|
 | 
						|
    def __init__(self, history: History) -> None:
 | 
						|
        super().__init__()
 | 
						|
 | 
						|
        self.history = history
 | 
						|
 | 
						|
        self._load_thread: threading.Thread | None = None
 | 
						|
 | 
						|
        # Lock for accessing/manipulating `_loaded_strings` and `_loaded`
 | 
						|
        # together in a consistent state.
 | 
						|
        self._lock = threading.Lock()
 | 
						|
 | 
						|
        # Events created by each `load()` call. Used to wait for new history
 | 
						|
        # entries from the loader thread.
 | 
						|
        self._string_load_events: list[threading.Event] = []
 | 
						|
 | 
						|
    async def load(self) -> AsyncGenerator[str, None]:
 | 
						|
        """
 | 
						|
        Like `History.load(), but call `self.load_history_strings()` in a
 | 
						|
        background thread.
 | 
						|
        """
 | 
						|
        # Start the load thread, if this is called for the first time.
 | 
						|
        if not self._load_thread:
 | 
						|
            self._load_thread = threading.Thread(
 | 
						|
                target=self._in_load_thread,
 | 
						|
                daemon=True,
 | 
						|
            )
 | 
						|
            self._load_thread.start()
 | 
						|
 | 
						|
        # Consume the `_loaded_strings` list, using asyncio.
 | 
						|
        loop = get_running_loop()
 | 
						|
 | 
						|
        # Create threading Event so that we can wait for new items.
 | 
						|
        event = threading.Event()
 | 
						|
        event.set()
 | 
						|
        self._string_load_events.append(event)
 | 
						|
 | 
						|
        items_yielded = 0
 | 
						|
 | 
						|
        try:
 | 
						|
            while True:
 | 
						|
                # Wait for new items to be available.
 | 
						|
                # (Use a timeout, because the executor thread is not a daemon
 | 
						|
                # thread. The "slow-history.py" example would otherwise hang if
 | 
						|
                # Control-C is pressed before the history is fully loaded,
 | 
						|
                # because there's still this non-daemon executor thread waiting
 | 
						|
                # for this event.)
 | 
						|
                got_timeout = await loop.run_in_executor(
 | 
						|
                    None, lambda: event.wait(timeout=0.5)
 | 
						|
                )
 | 
						|
                if not got_timeout:
 | 
						|
                    continue
 | 
						|
 | 
						|
                # Read new items (in lock).
 | 
						|
                def in_executor() -> tuple[list[str], bool]:
 | 
						|
                    with self._lock:
 | 
						|
                        new_items = self._loaded_strings[items_yielded:]
 | 
						|
                        done = self._loaded
 | 
						|
                        event.clear()
 | 
						|
                    return new_items, done
 | 
						|
 | 
						|
                new_items, done = await loop.run_in_executor(None, in_executor)
 | 
						|
 | 
						|
                items_yielded += len(new_items)
 | 
						|
 | 
						|
                for item in new_items:
 | 
						|
                    yield item
 | 
						|
 | 
						|
                if done:
 | 
						|
                    break
 | 
						|
        finally:
 | 
						|
            self._string_load_events.remove(event)
 | 
						|
 | 
						|
    def _in_load_thread(self) -> None:
 | 
						|
        try:
 | 
						|
            # Start with an empty list. In case `append_string()` was called
 | 
						|
            # before `load()` happened. Then `.store_string()` will have
 | 
						|
            # written these entries back to disk and we will reload it.
 | 
						|
            self._loaded_strings = []
 | 
						|
 | 
						|
            for item in self.history.load_history_strings():
 | 
						|
                with self._lock:
 | 
						|
                    self._loaded_strings.append(item)
 | 
						|
 | 
						|
                for event in self._string_load_events:
 | 
						|
                    event.set()
 | 
						|
        finally:
 | 
						|
            with self._lock:
 | 
						|
                self._loaded = True
 | 
						|
            for event in self._string_load_events:
 | 
						|
                event.set()
 | 
						|
 | 
						|
    def append_string(self, string: str) -> None:
 | 
						|
        with self._lock:
 | 
						|
            self._loaded_strings.insert(0, string)
 | 
						|
        self.store_string(string)
 | 
						|
 | 
						|
    # All of the following are proxied to `self.history`.
 | 
						|
 | 
						|
    def load_history_strings(self) -> Iterable[str]:
 | 
						|
        return self.history.load_history_strings()
 | 
						|
 | 
						|
    def store_string(self, string: str) -> None:
 | 
						|
        self.history.store_string(string)
 | 
						|
 | 
						|
    def __repr__(self) -> str:
 | 
						|
        return f"ThreadedHistory({self.history!r})"
 | 
						|
 | 
						|
 | 
						|
class InMemoryHistory(History):
 | 
						|
    """
 | 
						|
    :class:`.History` class that keeps a list of all strings in memory.
 | 
						|
 | 
						|
    In order to prepopulate the history, it's possible to call either
 | 
						|
    `append_string` for all items or pass a list of strings to `__init__` here.
 | 
						|
    """
 | 
						|
 | 
						|
    def __init__(self, history_strings: Sequence[str] | None = None) -> None:
 | 
						|
        super().__init__()
 | 
						|
        # Emulating disk storage.
 | 
						|
        if history_strings is None:
 | 
						|
            self._storage = []
 | 
						|
        else:
 | 
						|
            self._storage = list(history_strings)
 | 
						|
 | 
						|
    def load_history_strings(self) -> Iterable[str]:
 | 
						|
        yield from self._storage[::-1]
 | 
						|
 | 
						|
    def store_string(self, string: str) -> None:
 | 
						|
        self._storage.append(string)
 | 
						|
 | 
						|
 | 
						|
class DummyHistory(History):
 | 
						|
    """
 | 
						|
    :class:`.History` object that doesn't remember anything.
 | 
						|
    """
 | 
						|
 | 
						|
    def load_history_strings(self) -> Iterable[str]:
 | 
						|
        return []
 | 
						|
 | 
						|
    def store_string(self, string: str) -> None:
 | 
						|
        pass
 | 
						|
 | 
						|
    def append_string(self, string: str) -> None:
 | 
						|
        # Don't remember this.
 | 
						|
        pass
 | 
						|
 | 
						|
 | 
						|
_StrOrBytesPath = Union[str, bytes, "os.PathLike[str]", "os.PathLike[bytes]"]
 | 
						|
 | 
						|
 | 
						|
class FileHistory(History):
 | 
						|
    """
 | 
						|
    :class:`.History` class that stores all strings in a file.
 | 
						|
    """
 | 
						|
 | 
						|
    def __init__(self, filename: _StrOrBytesPath) -> None:
 | 
						|
        self.filename = filename
 | 
						|
        super().__init__()
 | 
						|
 | 
						|
    def load_history_strings(self) -> Iterable[str]:
 | 
						|
        strings: list[str] = []
 | 
						|
        lines: list[str] = []
 | 
						|
 | 
						|
        def add() -> None:
 | 
						|
            if lines:
 | 
						|
                # Join and drop trailing newline.
 | 
						|
                string = "".join(lines)[:-1]
 | 
						|
 | 
						|
                strings.append(string)
 | 
						|
 | 
						|
        if os.path.exists(self.filename):
 | 
						|
            with open(self.filename, "rb") as f:
 | 
						|
                for line_bytes in f:
 | 
						|
                    line = line_bytes.decode("utf-8", errors="replace")
 | 
						|
 | 
						|
                    if line.startswith("+"):
 | 
						|
                        lines.append(line[1:])
 | 
						|
                    else:
 | 
						|
                        add()
 | 
						|
                        lines = []
 | 
						|
 | 
						|
                add()
 | 
						|
 | 
						|
        # Reverse the order, because newest items have to go first.
 | 
						|
        return reversed(strings)
 | 
						|
 | 
						|
    def store_string(self, string: str) -> None:
 | 
						|
        # Save to file.
 | 
						|
        with open(self.filename, "ab") as f:
 | 
						|
 | 
						|
            def write(t: str) -> None:
 | 
						|
                f.write(t.encode("utf-8"))
 | 
						|
 | 
						|
            write(f"\n# {datetime.datetime.now()}\n")
 | 
						|
            for line in string.split("\n"):
 | 
						|
                write(f"+{line}\n")
 |