hotmemory 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hotmemory/__init__.py +26 -0
- hotmemory/_chunks.py +184 -0
- hotmemory/_rules.py +196 -0
- hotmemory/_writer.py +115 -0
- hotmemory/contract.py +645 -0
- hotmemory/filter.py +108 -0
- hotmemory/hotdata.py +1099 -0
- hotmemory/memory.py +267 -0
- hotmemory/openai.py +68 -0
- hotmemory/py.typed +0 -0
- hotmemory/record.py +145 -0
- hotmemory/store.py +178 -0
- hotmemory-0.1.0.dist-info/METADATA +263 -0
- hotmemory-0.1.0.dist-info/RECORD +16 -0
- hotmemory-0.1.0.dist-info/WHEEL +4 -0
- hotmemory-0.1.0.dist-info/licenses/LICENSE +21 -0
hotmemory/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Agent memory as tables on Hotdata."""
|
|
2
|
+
|
|
3
|
+
from hotmemory.contract import Fact, Memory
|
|
4
|
+
from hotmemory.filter import Filter, TimeRange
|
|
5
|
+
from hotmemory.memory import MemoryStore, MemoryWriter
|
|
6
|
+
from hotmemory.record import SCHEMA_VERSION, JSONValue, Kind, Record, normalize
|
|
7
|
+
from hotmemory.store import Clock, Embedder, Hit, Store, Writer
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"SCHEMA_VERSION",
|
|
11
|
+
"Clock",
|
|
12
|
+
"Embedder",
|
|
13
|
+
"Fact",
|
|
14
|
+
"Filter",
|
|
15
|
+
"Hit",
|
|
16
|
+
"JSONValue",
|
|
17
|
+
"Kind",
|
|
18
|
+
"Memory",
|
|
19
|
+
"MemoryStore",
|
|
20
|
+
"MemoryWriter",
|
|
21
|
+
"Record",
|
|
22
|
+
"Store",
|
|
23
|
+
"TimeRange",
|
|
24
|
+
"Writer",
|
|
25
|
+
"normalize",
|
|
26
|
+
]
|
hotmemory/_chunks.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""The chunking of a Markdown document for `Memory.load`, and of a text for `Memory.capture`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
_HEADING = re.compile(r"^ {0,3}(#{1,6})(?:[ \t]+|$)")
|
|
9
|
+
_FENCE = re.compile(r"^ {0,3}(`{3,}|~{3,})")
|
|
10
|
+
_TABLE_DELIMITER = re.compile(r"^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$")
|
|
11
|
+
PATH_SEPARATOR = " > "
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class Chunk:
|
|
16
|
+
"""One chunk of a document: the heading path of its first line, and its text."""
|
|
17
|
+
|
|
18
|
+
path: tuple[str, ...]
|
|
19
|
+
text: str
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class _Line:
|
|
24
|
+
text: str
|
|
25
|
+
level: int
|
|
26
|
+
fenced: bool
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def chunk_document(text: str, chunk_chars: int) -> list[str]:
|
|
30
|
+
"""Return the chunks of the Markdown `text`, each with its heading prefix.
|
|
31
|
+
|
|
32
|
+
A chunk is `<prefix>\\n\\n<body>`. The prefix is the first heading of the document,
|
|
33
|
+
then the heading path of the chunk's first line, joined by ` > `, with a heading that
|
|
34
|
+
repeats the one before it left out. A document with no heading gives chunks with no
|
|
35
|
+
prefix. The body holds at most `chunk_chars` characters, unless one line is longer.
|
|
36
|
+
A document of blank lines only gives no chunk.
|
|
37
|
+
"""
|
|
38
|
+
lines = _scan(text)
|
|
39
|
+
title = next((line.text.strip() for line in lines if line.level), "")
|
|
40
|
+
chunks = []
|
|
41
|
+
for chunk in pack(lines, chunk_chars):
|
|
42
|
+
parts: list[str] = []
|
|
43
|
+
for heading in (title, *chunk.path):
|
|
44
|
+
if heading and (not parts or parts[-1] != heading):
|
|
45
|
+
parts.append(heading)
|
|
46
|
+
prefix = PATH_SEPARATOR.join(parts)
|
|
47
|
+
chunks.append(f"{prefix}\n\n{chunk.text}" if prefix else chunk.text)
|
|
48
|
+
return chunks
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def chunk_text(text: str, chunk_chars: int) -> list[str]:
|
|
52
|
+
"""Return the chunks of `text`, cut at blank lines outside fences, with no prefix.
|
|
53
|
+
|
|
54
|
+
A heading line does not start a section. A block that does not fit is cut at line
|
|
55
|
+
ends, and a line that does not fit is cut at `chunk_chars`. The blocks are packed in
|
|
56
|
+
order into bodies of at most `chunk_chars`, joined by a blank line. A text of blank
|
|
57
|
+
lines only gives no chunk.
|
|
58
|
+
"""
|
|
59
|
+
lines = [_Line(line.text, 0, line.fenced) for line in _scan(text)]
|
|
60
|
+
return [chunk.text for chunk in pack(lines, chunk_chars)]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def pack(lines: list[_Line], chunk_chars: int) -> list[Chunk]:
|
|
64
|
+
"""Return the sections of `lines`, cut and packed into bodies of at most `chunk_chars`.
|
|
65
|
+
|
|
66
|
+
The sections start at each heading outside a fence. A section that does not fit is
|
|
67
|
+
cut into blocks at blank lines outside fences, and its heading stays with the block
|
|
68
|
+
after it when both fit. A block that does not fit is cut at
|
|
69
|
+
line ends, and a line that does not fit is cut at `chunk_chars`. A cut pipe table
|
|
70
|
+
repeats its header row and delimiter row at the top of each later piece. Then the
|
|
71
|
+
pieces are packed in order, joined by a blank line.
|
|
72
|
+
"""
|
|
73
|
+
pieces: list[Chunk] = []
|
|
74
|
+
for path, section in _sections(lines):
|
|
75
|
+
body = _join(section)
|
|
76
|
+
if not body:
|
|
77
|
+
continue
|
|
78
|
+
if len(body) <= chunk_chars:
|
|
79
|
+
pieces.append(Chunk(path, body))
|
|
80
|
+
continue
|
|
81
|
+
blocks = _blocks(section)
|
|
82
|
+
if len(blocks) > 1 and section[0].level and len(blocks[0]) == 1:
|
|
83
|
+
joined = [*blocks[0], "", *blocks[1]]
|
|
84
|
+
if len("\n".join(joined)) <= chunk_chars:
|
|
85
|
+
blocks[:2] = [joined]
|
|
86
|
+
for block in blocks:
|
|
87
|
+
pieces.extend(Chunk(path, piece) for piece in _cut_block(block, chunk_chars))
|
|
88
|
+
packed: list[Chunk] = []
|
|
89
|
+
for piece in pieces:
|
|
90
|
+
if packed and len(packed[-1].text) + 2 + len(piece.text) <= chunk_chars:
|
|
91
|
+
packed[-1] = Chunk(packed[-1].path, f"{packed[-1].text}\n\n{piece.text}")
|
|
92
|
+
else:
|
|
93
|
+
packed.append(piece)
|
|
94
|
+
return packed
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _scan(text: str) -> list[_Line]:
|
|
98
|
+
"""Return each line of `text` with its heading level, 0 for none, and its fence state."""
|
|
99
|
+
lines = []
|
|
100
|
+
fence = ""
|
|
101
|
+
for raw in text.replace("\r\n", "\n").replace("\r", "\n").split("\n"):
|
|
102
|
+
opener = _FENCE.match(raw)
|
|
103
|
+
if fence:
|
|
104
|
+
marker = opener.group(1) if opener is not None else ""
|
|
105
|
+
if marker[:1] == fence[0] and len(marker) >= len(fence) and not raw.strip(" `~"):
|
|
106
|
+
fence = ""
|
|
107
|
+
lines.append(_Line(raw, 0, True))
|
|
108
|
+
continue
|
|
109
|
+
if opener is not None:
|
|
110
|
+
fence = opener.group(1)
|
|
111
|
+
lines.append(_Line(raw, 0, True))
|
|
112
|
+
continue
|
|
113
|
+
heading = _HEADING.match(raw)
|
|
114
|
+
lines.append(_Line(raw, len(heading.group(1)) if heading else 0, False))
|
|
115
|
+
return lines
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _sections(lines: list[_Line]) -> list[tuple[tuple[str, ...], list[_Line]]]:
|
|
119
|
+
"""Return the sections of `lines`, each with the heading path of its heading."""
|
|
120
|
+
sections: list[tuple[tuple[str, ...], list[_Line]]] = [((), [])]
|
|
121
|
+
stack: list[tuple[int, str]] = []
|
|
122
|
+
for line in lines:
|
|
123
|
+
if line.level:
|
|
124
|
+
while stack and stack[-1][0] >= line.level:
|
|
125
|
+
stack.pop()
|
|
126
|
+
stack.append((line.level, line.text.strip()))
|
|
127
|
+
sections.append((tuple(text for _, text in stack), []))
|
|
128
|
+
sections[-1][1].append(line)
|
|
129
|
+
return sections
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _blocks(lines: list[_Line]) -> list[list[str]]:
|
|
133
|
+
"""Return the runs of lines between blank lines outside fences."""
|
|
134
|
+
blocks: list[list[str]] = [[]]
|
|
135
|
+
for line in lines:
|
|
136
|
+
if not line.fenced and not line.text.strip():
|
|
137
|
+
if blocks[-1]:
|
|
138
|
+
blocks.append([])
|
|
139
|
+
continue
|
|
140
|
+
blocks[-1].append(line.text)
|
|
141
|
+
return [block for block in blocks if block]
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _cut_block(block: list[str], chunk_chars: int) -> list[str]:
|
|
145
|
+
"""Return `block` cut at line ends into pieces of at most `chunk_chars` characters.
|
|
146
|
+
|
|
147
|
+
A line longer than `chunk_chars` is cut at that size. A pipe table repeats its first
|
|
148
|
+
two rows at the top of each piece after the first, when they leave room for a row.
|
|
149
|
+
"""
|
|
150
|
+
header: list[str] = []
|
|
151
|
+
if len(block) > 2 and block[0].lstrip().startswith("|") and _TABLE_DELIMITER.match(block[1]):
|
|
152
|
+
header = block[:2]
|
|
153
|
+
if len("\n".join(header)) * 2 >= chunk_chars:
|
|
154
|
+
header = []
|
|
155
|
+
pieces: list[str] = []
|
|
156
|
+
current: list[str] = []
|
|
157
|
+
for line in block:
|
|
158
|
+
for part in _cut_line(line, chunk_chars):
|
|
159
|
+
if current and len("\n".join([*current, part])) > chunk_chars:
|
|
160
|
+
pieces.append("\n".join(current))
|
|
161
|
+
current = list(header) if header and part.lstrip().startswith("|") else []
|
|
162
|
+
if current and len("\n".join([*current, part])) > chunk_chars:
|
|
163
|
+
current = []
|
|
164
|
+
current.append(part)
|
|
165
|
+
if current:
|
|
166
|
+
pieces.append("\n".join(current))
|
|
167
|
+
return pieces
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _cut_line(line: str, chunk_chars: int) -> list[str]:
|
|
171
|
+
"""Return `line` cut into parts of at most `chunk_chars` characters."""
|
|
172
|
+
if len(line) <= chunk_chars:
|
|
173
|
+
return [line]
|
|
174
|
+
return [line[start : start + chunk_chars] for start in range(0, len(line), chunk_chars)]
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _join(lines: list[_Line]) -> str:
|
|
178
|
+
"""Return the text of `lines` without the blank lines at either end."""
|
|
179
|
+
texts = [line.text for line in lines]
|
|
180
|
+
while texts and not texts[0].strip():
|
|
181
|
+
texts.pop(0)
|
|
182
|
+
while texts and not texts[-1].strip():
|
|
183
|
+
texts.pop()
|
|
184
|
+
return "\n".join(texts)
|
hotmemory/_rules.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Plain functions that every driver calls, so the drivers agree on the contract."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from dataclasses import dataclass, replace
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
|
|
10
|
+
from hotmemory.filter import Filter, kinds_of
|
|
11
|
+
from hotmemory.record import JSONValue, Kind, Record, check_label, normalize
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class Draft:
|
|
16
|
+
"""One put before a driver writes it: the record, and whether it closes the current one."""
|
|
17
|
+
|
|
18
|
+
record: Record
|
|
19
|
+
close_previous: bool = False
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def check_prefix(prefix: Sequence[str]) -> tuple[str, ...]:
|
|
23
|
+
"""Return `prefix` as a tuple of labels. An empty prefix is allowed and matches all."""
|
|
24
|
+
if isinstance(prefix, str):
|
|
25
|
+
raise TypeError("prefix must be a sequence of labels, not a string")
|
|
26
|
+
labels = tuple(prefix)
|
|
27
|
+
for label in labels:
|
|
28
|
+
check_label(label)
|
|
29
|
+
return labels
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def under_prefix(namespace: tuple[str, ...], prefix: tuple[str, ...]) -> bool:
|
|
33
|
+
"""Return True if the first labels of `namespace` are the labels of `prefix`."""
|
|
34
|
+
return namespace[: len(prefix)] == prefix
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def next_revision(current: Record | None) -> int:
|
|
38
|
+
"""Return the revision number for the next put after `current`."""
|
|
39
|
+
return 1 if current is None else current.revision + 1
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def check_episode_line(current: Record | None, kind: Kind) -> None:
|
|
43
|
+
"""Raise ValueError if a put of `kind` moves the key of `current` across the episode line.
|
|
44
|
+
|
|
45
|
+
A key holds episodes only, or holds no episode at all.
|
|
46
|
+
"""
|
|
47
|
+
if current is not None and (current.kind == "episode") != (kind == "episode"):
|
|
48
|
+
raise ValueError(
|
|
49
|
+
f"key {current.key!r} holds kind {current.kind!r}; a put of kind {kind!r} "
|
|
50
|
+
"cannot move a key between 'episode' and another kind"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def is_duplicate(current: Record, content: str, now: datetime) -> bool:
|
|
55
|
+
"""Return True if a put of `content` writes nothing because `current` holds it.
|
|
56
|
+
|
|
57
|
+
That is the case when `current` is listed at `now` and its normalized content equals
|
|
58
|
+
the normalized `content`. A current revision past its `forget_after` is never a
|
|
59
|
+
duplicate, so a put brings the fact back as a new revision.
|
|
60
|
+
"""
|
|
61
|
+
return is_listed(current, now) and normalize(current.content) == normalize(content)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def check_close(current: Record, record: Record) -> None:
|
|
65
|
+
"""Raise ValueError if a put with `close_previous` cannot close `current` for `record`.
|
|
66
|
+
|
|
67
|
+
The new `valid_from` must be set, and must not be earlier than the `valid_from` of
|
|
68
|
+
`current`.
|
|
69
|
+
"""
|
|
70
|
+
if record.valid_from is None:
|
|
71
|
+
raise ValueError("close_previous needs valid_from or observed_at on the new revision")
|
|
72
|
+
if current.valid_from is not None and current.valid_from > record.valid_from:
|
|
73
|
+
raise ValueError(
|
|
74
|
+
f"key {current.key!r} is valid from {current.valid_from.isoformat()}, which is "
|
|
75
|
+
f"later than the new valid_from {record.valid_from.isoformat()}"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def superseded(current: Record, record: Record, now: datetime, close_previous: bool) -> Record:
|
|
80
|
+
"""Return `current` with `superseded_by` set to the id of `record`.
|
|
81
|
+
|
|
82
|
+
With `close_previous`, its `valid_until` also becomes the `valid_from` of `record`, and
|
|
83
|
+
its `expired_at` becomes `now`.
|
|
84
|
+
"""
|
|
85
|
+
if not close_previous:
|
|
86
|
+
return replace(current, superseded_by=record.id)
|
|
87
|
+
return replace(current, superseded_by=record.id, valid_until=record.valid_from, expired_at=now)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def with_new_sources(current: Record, record: Record) -> Record | None:
|
|
91
|
+
"""Return `record` with the sources of `current` and then its own, or None if none is new.
|
|
92
|
+
|
|
93
|
+
A put whose content duplicates `current` calls this. The merged sources keep their
|
|
94
|
+
order and hold no repeats.
|
|
95
|
+
"""
|
|
96
|
+
sources = tuple(dict.fromkeys((*current.sources, *record.sources)))
|
|
97
|
+
if sources == current.sources:
|
|
98
|
+
return None
|
|
99
|
+
return replace(record, sources=sources)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def is_listed(record: Record, now: datetime) -> bool:
|
|
103
|
+
"""Return True if `list` and `search` can return `record` at `now`.
|
|
104
|
+
|
|
105
|
+
A superseded revision is not listed. A record is not listed once its `forget_after`
|
|
106
|
+
is at or before `now`.
|
|
107
|
+
"""
|
|
108
|
+
return record.superseded_by is None and not is_forgotten(record, now)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def is_forgotten(record: Record, now: datetime) -> bool:
|
|
112
|
+
"""Return True if `record` is past its `forget_after` at `now`."""
|
|
113
|
+
return record.forget_after is not None and record.forget_after <= now
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def matches(record: Record, filter: Filter | None) -> bool:
|
|
117
|
+
"""Return True if `record` satisfies every field that `filter` sets."""
|
|
118
|
+
if filter is None:
|
|
119
|
+
return True
|
|
120
|
+
kinds = kinds_of(filter)
|
|
121
|
+
if kinds is not None and record.kind not in kinds:
|
|
122
|
+
return False
|
|
123
|
+
if filter.subject is not None and record.subject != filter.subject:
|
|
124
|
+
return False
|
|
125
|
+
if filter.actor is not None and record.actor != filter.actor:
|
|
126
|
+
return False
|
|
127
|
+
if filter.tags is not None and not set(filter.tags) <= set(record.tags):
|
|
128
|
+
return False
|
|
129
|
+
ranges = (
|
|
130
|
+
(filter.valid_from, record.valid_from),
|
|
131
|
+
(filter.valid_until, record.valid_until),
|
|
132
|
+
(filter.created_at, record.created_at),
|
|
133
|
+
(filter.expired_at, record.expired_at),
|
|
134
|
+
)
|
|
135
|
+
return all(window is None or window.contains(value) for window, value in ranges)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def newest_first(record: Record) -> tuple[float, str]:
|
|
139
|
+
"""Sort key for the order of `list`: newest `created_at` first, then by id."""
|
|
140
|
+
return (-record.created_at.timestamp(), record.id)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def build_record(
|
|
144
|
+
namespace: Sequence[str],
|
|
145
|
+
key: str,
|
|
146
|
+
revision: int,
|
|
147
|
+
created_at: datetime,
|
|
148
|
+
*,
|
|
149
|
+
kind: Kind,
|
|
150
|
+
content: str,
|
|
151
|
+
subject: str,
|
|
152
|
+
cues: Sequence[str],
|
|
153
|
+
payload: dict[str, JSONValue] | None,
|
|
154
|
+
tags: Sequence[str],
|
|
155
|
+
sources: Sequence[str],
|
|
156
|
+
actor: str,
|
|
157
|
+
observed_at: datetime | None,
|
|
158
|
+
valid_from: datetime | None,
|
|
159
|
+
valid_until: datetime | None,
|
|
160
|
+
forget_after: datetime | None,
|
|
161
|
+
forget_reason: str,
|
|
162
|
+
) -> Record:
|
|
163
|
+
"""Return the record that a put writes. `valid_from` defaults to `observed_at`."""
|
|
164
|
+
return Record(
|
|
165
|
+
namespace=tuple(namespace),
|
|
166
|
+
key=key,
|
|
167
|
+
revision=revision,
|
|
168
|
+
kind=kind,
|
|
169
|
+
subject=subject,
|
|
170
|
+
content=content,
|
|
171
|
+
cues=tuple(cues),
|
|
172
|
+
payload={} if payload is None else payload,
|
|
173
|
+
tags=tuple(tags),
|
|
174
|
+
sources=tuple(sources),
|
|
175
|
+
actor=actor,
|
|
176
|
+
created_at=created_at,
|
|
177
|
+
observed_at=observed_at,
|
|
178
|
+
valid_from=observed_at if valid_from is None else valid_from,
|
|
179
|
+
valid_until=valid_until,
|
|
180
|
+
forget_after=forget_after,
|
|
181
|
+
forget_reason=forget_reason,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def cosine_distance(a: Sequence[float], b: Sequence[float]) -> float:
|
|
186
|
+
"""Return 1 minus the cosine similarity of `a` and `b`.
|
|
187
|
+
|
|
188
|
+
Raises ValueError if the lengths differ or a vector has zero length.
|
|
189
|
+
"""
|
|
190
|
+
if len(a) != len(b):
|
|
191
|
+
raise ValueError(f"vectors differ in length: {len(a)} and {len(b)}")
|
|
192
|
+
norm_a = math.sqrt(sum(x * x for x in a))
|
|
193
|
+
norm_b = math.sqrt(sum(x * x for x in b))
|
|
194
|
+
if norm_a == 0 or norm_b == 0:
|
|
195
|
+
raise ValueError("cannot compute a cosine distance for a zero vector")
|
|
196
|
+
return 1.0 - sum(x * y for x, y in zip(a, b, strict=True)) / (norm_a * norm_b)
|
hotmemory/_writer.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""The buffer behind every driver's `Writer`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import builtins
|
|
6
|
+
from collections.abc import Callable, Sequence
|
|
7
|
+
from datetime import datetime, timedelta
|
|
8
|
+
from types import TracebackType
|
|
9
|
+
from typing import Self
|
|
10
|
+
|
|
11
|
+
from hotmemory._rules import Draft, build_record
|
|
12
|
+
from hotmemory.record import JSONValue, Kind
|
|
13
|
+
from hotmemory.store import Clock
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def check_count(name: str, value: int) -> None:
|
|
17
|
+
"""Raise TypeError if `value` is not an integer, or ValueError if it is negative."""
|
|
18
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
19
|
+
raise TypeError(f"{name} must be an integer")
|
|
20
|
+
if value < 0:
|
|
21
|
+
raise ValueError(f"{name} must not be negative")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class BufferedWriter:
|
|
25
|
+
"""A `Writer` that hands its buffer to `write` in one call on each flush.
|
|
26
|
+
|
|
27
|
+
`write` takes the buffered drafts in order and returns the id that each one wrote.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
write: Callable[[Sequence[Draft]], builtins.list[str]],
|
|
33
|
+
clock: Clock,
|
|
34
|
+
max_rows: int,
|
|
35
|
+
interval: timedelta,
|
|
36
|
+
) -> None:
|
|
37
|
+
check_count("max_rows", max_rows)
|
|
38
|
+
if max_rows < 1:
|
|
39
|
+
raise ValueError("max_rows must be 1 or more")
|
|
40
|
+
self._write = write
|
|
41
|
+
self._clock = clock
|
|
42
|
+
self._max_rows = max_rows
|
|
43
|
+
self._interval = interval
|
|
44
|
+
self._buffer: builtins.list[Draft] = []
|
|
45
|
+
self._flushed: builtins.list[str] = []
|
|
46
|
+
self._last_flush = clock()
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def flushed(self) -> builtins.list[str]:
|
|
50
|
+
return builtins.list(self._flushed)
|
|
51
|
+
|
|
52
|
+
def put(
|
|
53
|
+
self,
|
|
54
|
+
namespace: Sequence[str],
|
|
55
|
+
key: str,
|
|
56
|
+
*,
|
|
57
|
+
kind: Kind,
|
|
58
|
+
content: str,
|
|
59
|
+
subject: str = "",
|
|
60
|
+
cues: Sequence[str] = (),
|
|
61
|
+
payload: dict[str, JSONValue] | None = None,
|
|
62
|
+
tags: Sequence[str] = (),
|
|
63
|
+
sources: Sequence[str] = (),
|
|
64
|
+
actor: str = "",
|
|
65
|
+
observed_at: datetime | None = None,
|
|
66
|
+
valid_from: datetime | None = None,
|
|
67
|
+
valid_until: datetime | None = None,
|
|
68
|
+
forget_after: datetime | None = None,
|
|
69
|
+
forget_reason: str = "",
|
|
70
|
+
close_previous: bool = False,
|
|
71
|
+
) -> None:
|
|
72
|
+
now = self._clock()
|
|
73
|
+
record = build_record(
|
|
74
|
+
namespace,
|
|
75
|
+
key,
|
|
76
|
+
1,
|
|
77
|
+
now,
|
|
78
|
+
kind=kind,
|
|
79
|
+
content=content,
|
|
80
|
+
subject=subject,
|
|
81
|
+
cues=cues,
|
|
82
|
+
payload=payload,
|
|
83
|
+
tags=tags,
|
|
84
|
+
sources=sources,
|
|
85
|
+
actor=actor,
|
|
86
|
+
observed_at=observed_at,
|
|
87
|
+
valid_from=valid_from,
|
|
88
|
+
valid_until=valid_until,
|
|
89
|
+
forget_after=forget_after,
|
|
90
|
+
forget_reason=forget_reason,
|
|
91
|
+
)
|
|
92
|
+
self._buffer.append(Draft(record, close_previous))
|
|
93
|
+
if len(self._buffer) >= self._max_rows or now - self._last_flush >= self._interval:
|
|
94
|
+
self.flush()
|
|
95
|
+
|
|
96
|
+
def flush(self) -> builtins.list[str]:
|
|
97
|
+
ids = self._write(self._buffer) if self._buffer else []
|
|
98
|
+
self._buffer.clear()
|
|
99
|
+
self._flushed.extend(ids)
|
|
100
|
+
self._last_flush = self._clock()
|
|
101
|
+
return ids
|
|
102
|
+
|
|
103
|
+
def __enter__(self) -> Self:
|
|
104
|
+
return self
|
|
105
|
+
|
|
106
|
+
def __exit__(
|
|
107
|
+
self,
|
|
108
|
+
exc_type: type[BaseException] | None,
|
|
109
|
+
exc: BaseException | None,
|
|
110
|
+
traceback: TracebackType | None,
|
|
111
|
+
) -> None:
|
|
112
|
+
if exc_type is None:
|
|
113
|
+
self.flush()
|
|
114
|
+
else:
|
|
115
|
+
self._buffer.clear()
|