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")
|