langgraph-checkpoint-sqlite 3.1.0__tar.gz → 3.1.1__tar.gz
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.
- {langgraph_checkpoint_sqlite-3.1.0 → langgraph_checkpoint_sqlite-3.1.1}/Makefile +2 -5
- {langgraph_checkpoint_sqlite-3.1.0 → langgraph_checkpoint_sqlite-3.1.1}/PKG-INFO +32 -2
- {langgraph_checkpoint_sqlite-3.1.0 → langgraph_checkpoint_sqlite-3.1.1}/README.md +31 -1
- {langgraph_checkpoint_sqlite-3.1.0 → langgraph_checkpoint_sqlite-3.1.1}/langgraph/store/sqlite/aio.py +9 -0
- {langgraph_checkpoint_sqlite-3.1.0 → langgraph_checkpoint_sqlite-3.1.1}/langgraph/store/sqlite/base.py +103 -20
- {langgraph_checkpoint_sqlite-3.1.0 → langgraph_checkpoint_sqlite-3.1.1}/pyproject.toml +16 -12
- {langgraph_checkpoint_sqlite-3.1.0 → langgraph_checkpoint_sqlite-3.1.1}/tests/test_async_store.py +29 -1
- {langgraph_checkpoint_sqlite-3.1.0 → langgraph_checkpoint_sqlite-3.1.1}/tests/test_store.py +212 -2
- {langgraph_checkpoint_sqlite-3.1.0 → langgraph_checkpoint_sqlite-3.1.1}/uv.lock +139 -206
- {langgraph_checkpoint_sqlite-3.1.0 → langgraph_checkpoint_sqlite-3.1.1}/.gitignore +0 -0
- {langgraph_checkpoint_sqlite-3.1.0 → langgraph_checkpoint_sqlite-3.1.1}/LICENSE +0 -0
- {langgraph_checkpoint_sqlite-3.1.0 → langgraph_checkpoint_sqlite-3.1.1}/langgraph/cache/sqlite/__init__.py +0 -0
- {langgraph_checkpoint_sqlite-3.1.0 → langgraph_checkpoint_sqlite-3.1.1}/langgraph/checkpoint/sqlite/__init__.py +0 -0
- {langgraph_checkpoint_sqlite-3.1.0 → langgraph_checkpoint_sqlite-3.1.1}/langgraph/checkpoint/sqlite/_delta.py +0 -0
- {langgraph_checkpoint_sqlite-3.1.0 → langgraph_checkpoint_sqlite-3.1.1}/langgraph/checkpoint/sqlite/aio.py +0 -0
- {langgraph_checkpoint_sqlite-3.1.0 → langgraph_checkpoint_sqlite-3.1.1}/langgraph/checkpoint/sqlite/py.typed +0 -0
- {langgraph_checkpoint_sqlite-3.1.0 → langgraph_checkpoint_sqlite-3.1.1}/langgraph/checkpoint/sqlite/utils.py +0 -0
- {langgraph_checkpoint_sqlite-3.1.0 → langgraph_checkpoint_sqlite-3.1.1}/langgraph/store/sqlite/__init__.py +0 -0
- {langgraph_checkpoint_sqlite-3.1.0 → langgraph_checkpoint_sqlite-3.1.1}/tests/__init__.py +0 -0
- {langgraph_checkpoint_sqlite-3.1.0 → langgraph_checkpoint_sqlite-3.1.1}/tests/test_aiosqlite.py +0 -0
- {langgraph_checkpoint_sqlite-3.1.0 → langgraph_checkpoint_sqlite-3.1.1}/tests/test_conformance_delta.py +0 -0
- {langgraph_checkpoint_sqlite-3.1.0 → langgraph_checkpoint_sqlite-3.1.1}/tests/test_delta_channel_migration.py +0 -0
- {langgraph_checkpoint_sqlite-3.1.0 → langgraph_checkpoint_sqlite-3.1.1}/tests/test_get_delta_channel_history.py +0 -0
- {langgraph_checkpoint_sqlite-3.1.0 → langgraph_checkpoint_sqlite-3.1.1}/tests/test_sqlite.py +0 -0
- {langgraph_checkpoint_sqlite-3.1.0 → langgraph_checkpoint_sqlite-3.1.1}/tests/test_ttl.py +0 -0
|
@@ -18,22 +18,19 @@ test_watch:
|
|
|
18
18
|
|
|
19
19
|
# Define a variable for Python and notebook files.
|
|
20
20
|
PYTHON_FILES=.
|
|
21
|
-
MYPY_CACHE=.mypy_cache
|
|
22
21
|
lint format: PYTHON_FILES=.
|
|
23
22
|
lint_diff format_diff: PYTHON_FILES=$(shell git diff --name-only --relative --diff-filter=d main . | grep -E '\.py$$|\.ipynb$$')
|
|
24
23
|
lint_package: PYTHON_FILES=langgraph
|
|
25
24
|
lint_tests: PYTHON_FILES=tests
|
|
26
|
-
lint_tests: MYPY_CACHE=.mypy_cache_test
|
|
27
25
|
|
|
28
26
|
lint lint_diff lint_package lint_tests:
|
|
29
27
|
uv run ruff check .
|
|
30
28
|
[ "$(PYTHON_FILES)" = "" ] || uv run ruff format $(PYTHON_FILES) --diff
|
|
31
29
|
[ "$(PYTHON_FILES)" = "" ] || uv run ruff check --select I $(PYTHON_FILES)
|
|
32
|
-
[ "$(PYTHON_FILES)" = "" ] ||
|
|
33
|
-
[ "$(PYTHON_FILES)" = "" ] || uv run mypy $(PYTHON_FILES) --cache-dir $(MYPY_CACHE)
|
|
30
|
+
[ "$(PYTHON_FILES)" = "" ] || uv run ty check $(PYTHON_FILES)
|
|
34
31
|
|
|
35
32
|
type:
|
|
36
|
-
|
|
33
|
+
uv run ty check $(PYTHON_FILES)
|
|
37
34
|
|
|
38
35
|
format format_diff:
|
|
39
36
|
uv run ruff format $(PYTHON_FILES)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: langgraph-checkpoint-sqlite
|
|
3
|
-
Version: 3.1.
|
|
3
|
+
Version: 3.1.1
|
|
4
4
|
Summary: Library with a SQLite implementation of LangGraph checkpoint saver.
|
|
5
5
|
Project-URL: Source, https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint-sqlite
|
|
6
6
|
Project-URL: Twitter, https://x.com/langchain_oss
|
|
@@ -16,7 +16,27 @@ Description-Content-Type: text/markdown
|
|
|
16
16
|
|
|
17
17
|
# LangGraph SQLite Checkpoint
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
[](https://pypi.org/project/langgraph-checkpoint-sqlite/#history)
|
|
20
|
+
[](https://opensource.org/licenses/MIT)
|
|
21
|
+
[](https://pypistats.org/packages/langgraph-checkpoint-sqlite)
|
|
22
|
+
[](https://x.com/langchain_oss)
|
|
23
|
+
|
|
24
|
+
To help you ship LangGraph apps to production faster, check out [LangSmith](https://www.langchain.com/langsmith).
|
|
25
|
+
[LangSmith](https://www.langchain.com/langsmith) is a unified developer platform for building, testing, and monitoring LLM applications.
|
|
26
|
+
|
|
27
|
+
## Quick Install
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
uv add langgraph-checkpoint-sqlite
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## 🤔 What is this?
|
|
34
|
+
|
|
35
|
+
This library provides a SQLite implementation of LangGraph's checkpoint saver, with both sync and async support via `aiosqlite`. Use it when you want LangGraph state persistence backed by SQLite for local development, testing, or lightweight deployments.
|
|
36
|
+
|
|
37
|
+
## 📖 Documentation
|
|
38
|
+
|
|
39
|
+
For full documentation, see the [API reference](https://reference.langchain.com/python/langgraph.checkpoint.sqlite). For conceptual guides on persistence and memory, see the [LangGraph Docs](https://docs.langchain.com/oss/python/langgraph/overview).
|
|
20
40
|
|
|
21
41
|
## Security
|
|
22
42
|
|
|
@@ -107,3 +127,13 @@ async with AsyncSqliteSaver.from_conn_string(":memory:") as checkpointer:
|
|
|
107
127
|
# list checkpoints
|
|
108
128
|
[c async for c in checkpointer.alist(read_config)]
|
|
109
129
|
```
|
|
130
|
+
|
|
131
|
+
## 📕 Releases & Versioning
|
|
132
|
+
|
|
133
|
+
See our [Releases](https://docs.langchain.com/oss/python/release-policy) and [Versioning](https://docs.langchain.com/oss/python/versioning) policies.
|
|
134
|
+
|
|
135
|
+
## 💁 Contributing
|
|
136
|
+
|
|
137
|
+
As an open-source project in a rapidly developing field, we are extremely open to contributions, whether it be in the form of a new feature, improved infrastructure, or better documentation.
|
|
138
|
+
|
|
139
|
+
For detailed information on how to contribute, see the [Contributing Guide](https://docs.langchain.com/oss/python/contributing/overview).
|
|
@@ -1,6 +1,26 @@
|
|
|
1
1
|
# LangGraph SQLite Checkpoint
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://pypi.org/project/langgraph-checkpoint-sqlite/#history)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
[](https://pypistats.org/packages/langgraph-checkpoint-sqlite)
|
|
6
|
+
[](https://x.com/langchain_oss)
|
|
7
|
+
|
|
8
|
+
To help you ship LangGraph apps to production faster, check out [LangSmith](https://www.langchain.com/langsmith).
|
|
9
|
+
[LangSmith](https://www.langchain.com/langsmith) is a unified developer platform for building, testing, and monitoring LLM applications.
|
|
10
|
+
|
|
11
|
+
## Quick Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
uv add langgraph-checkpoint-sqlite
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## 🤔 What is this?
|
|
18
|
+
|
|
19
|
+
This library provides a SQLite implementation of LangGraph's checkpoint saver, with both sync and async support via `aiosqlite`. Use it when you want LangGraph state persistence backed by SQLite for local development, testing, or lightweight deployments.
|
|
20
|
+
|
|
21
|
+
## 📖 Documentation
|
|
22
|
+
|
|
23
|
+
For full documentation, see the [API reference](https://reference.langchain.com/python/langgraph.checkpoint.sqlite). For conceptual guides on persistence and memory, see the [LangGraph Docs](https://docs.langchain.com/oss/python/langgraph/overview).
|
|
4
24
|
|
|
5
25
|
## Security
|
|
6
26
|
|
|
@@ -91,3 +111,13 @@ async with AsyncSqliteSaver.from_conn_string(":memory:") as checkpointer:
|
|
|
91
111
|
# list checkpoints
|
|
92
112
|
[c async for c in checkpointer.alist(read_config)]
|
|
93
113
|
```
|
|
114
|
+
|
|
115
|
+
## 📕 Releases & Versioning
|
|
116
|
+
|
|
117
|
+
See our [Releases](https://docs.langchain.com/oss/python/release-policy) and [Versioning](https://docs.langchain.com/oss/python/versioning) policies.
|
|
118
|
+
|
|
119
|
+
## 💁 Contributing
|
|
120
|
+
|
|
121
|
+
As an open-source project in a rapidly developing field, we are extremely open to contributions, whether it be in the form of a new feature, improved infrastructure, or better documentation.
|
|
122
|
+
|
|
123
|
+
For detailed information on how to contribute, see the [Contributing Guide](https://docs.langchain.com/oss/python/contributing/overview).
|
|
@@ -24,11 +24,13 @@ from langgraph.store.base.batch import AsyncBatchedBaseStore
|
|
|
24
24
|
|
|
25
25
|
from langgraph.store.sqlite.base import (
|
|
26
26
|
_PLACEHOLDER,
|
|
27
|
+
NS_MATCH_FUNCTION,
|
|
27
28
|
BaseSqliteStore,
|
|
28
29
|
SqliteIndexConfig,
|
|
29
30
|
_decode_ns_text,
|
|
30
31
|
_ensure_index_config,
|
|
31
32
|
_group_ops,
|
|
33
|
+
_namespace_match,
|
|
32
34
|
_row_to_item,
|
|
33
35
|
_row_to_search_item,
|
|
34
36
|
)
|
|
@@ -150,6 +152,13 @@ class AsyncSqliteStore(AsyncBatchedBaseStore, BaseSqliteStore):
|
|
|
150
152
|
if self.is_setup:
|
|
151
153
|
return
|
|
152
154
|
|
|
155
|
+
# list_namespaces needs segment-aware matching, which SQLite cannot
|
|
156
|
+
# express in LIKE or GLOB. Registered here rather than in __init__
|
|
157
|
+
# because aiosqlite's create_function is a coroutine.
|
|
158
|
+
await self.conn.create_function(
|
|
159
|
+
NS_MATCH_FUNCTION, 2, _namespace_match, deterministic=True
|
|
160
|
+
)
|
|
161
|
+
|
|
153
162
|
# Create migrations table if it doesn't exist
|
|
154
163
|
await self.conn.execute(
|
|
155
164
|
"""
|
|
@@ -93,12 +93,74 @@ class SqliteIndexConfig(IndexConfig):
|
|
|
93
93
|
pass
|
|
94
94
|
|
|
95
95
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
96
|
+
NS_MATCH_FUNCTION = "_langgraph_namespace_match"
|
|
97
|
+
"""SQLite user function backing segment-aware namespace matching.
|
|
98
|
+
|
|
99
|
+
Registered under a private name rather than overriding `REGEXP`, so a caller's
|
|
100
|
+
own `REGEXP` is left untouched.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _namespace_match(prefix: str | None, pattern: str) -> int:
|
|
105
|
+
"""Backing implementation of `NS_MATCH_FUNCTION`."""
|
|
106
|
+
if prefix is None:
|
|
107
|
+
return 0
|
|
108
|
+
return 1 if re.search(pattern, prefix) else 0
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _escape_glob_literal(text: str) -> str:
|
|
112
|
+
"""Escape GLOB metacharacters so `text` is matched literally.
|
|
113
|
+
|
|
114
|
+
GLOB has no `ESCAPE` clause, so metacharacters are wrapped in a character
|
|
115
|
+
class instead. `]` is literal outside a class and needs no escaping.
|
|
116
|
+
"""
|
|
117
|
+
return text.replace("[", "[[]").replace("*", "[*]").replace("?", "[?]")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _namespace_prefix_condition(
|
|
121
|
+
namespace_prefix: tuple[str, ...], column: str = "prefix"
|
|
122
|
+
) -> tuple[str, tuple[str, ...]]:
|
|
123
|
+
"""Build the SQL scoping a search to a namespace and its descendants.
|
|
124
|
+
|
|
125
|
+
Matches the namespace exactly or requires the `.` separator before any
|
|
126
|
+
remainder, so a prefix of `("foo",)` does not also match `("foobar",)`.
|
|
127
|
+
|
|
128
|
+
Uses GLOB rather than LIKE because SQLite's LIKE is case-insensitive for
|
|
129
|
+
ASCII, which would match `("FOO",)` for a prefix of `("foo",)` even though
|
|
130
|
+
`get`/`put`/`delete` compare with `=` and treat those as distinct.
|
|
131
|
+
|
|
132
|
+
An empty prefix is unconstrained and matches every namespace.
|
|
133
|
+
"""
|
|
134
|
+
if not namespace_prefix:
|
|
135
|
+
return "TRUE", ()
|
|
136
|
+
path = _namespace_to_text(namespace_prefix)
|
|
137
|
+
condition = f"({column} = ? OR {column} GLOB ?)"
|
|
138
|
+
return condition, (path, f"{_escape_glob_literal(path)}.*")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _namespace_match_pattern(path: tuple[str, ...], match_type: str) -> str:
|
|
142
|
+
"""Build a regex matching the dot-joined prefix on whole namespace segments.
|
|
143
|
+
|
|
144
|
+
Needed because neither LIKE nor GLOB can express "any character except the
|
|
145
|
+
separator": GLOB has character classes but no quantifier, so `[^.]*` still
|
|
146
|
+
crosses `.`. Matches how `InMemoryStore` compares namespaces element-wise.
|
|
147
|
+
|
|
148
|
+
`*` matches exactly one segment. Prefix matches stay open-ended but must end
|
|
149
|
+
on a separator; suffix matches anchor at the end and begin on one.
|
|
150
|
+
|
|
151
|
+
Examples:
|
|
152
|
+
prefix ("uid", "*", "alice") -> ^uid\\.[^.]+\\.alice(\\.|\\Z)
|
|
153
|
+
suffix ("alice",) -> (^|\\.)alice\\Z
|
|
154
|
+
"""
|
|
155
|
+
segments = ("[^.]+" if part == "*" else re.escape(part) for part in path)
|
|
156
|
+
body = r"\.".join(segments)
|
|
157
|
+
if match_type == "suffix":
|
|
158
|
+
return rf"(^|\.){body}\Z"
|
|
159
|
+
return rf"^{body}(\.|\Z)"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _namespace_to_text(namespace: tuple[str, ...]) -> str:
|
|
99
163
|
"""Convert namespace tuple to text string."""
|
|
100
|
-
if handle_wildcards:
|
|
101
|
-
namespace = tuple("%" if val == "*" else val for val in namespace)
|
|
102
164
|
return ".".join(namespace)
|
|
103
165
|
|
|
104
166
|
|
|
@@ -461,8 +523,11 @@ class BaseSqliteStore:
|
|
|
461
523
|
else " AND " + " AND ".join(filter_conditions)
|
|
462
524
|
)
|
|
463
525
|
if op.namespace_prefix:
|
|
464
|
-
|
|
465
|
-
|
|
526
|
+
ns_condition, ns_args_tuple = _namespace_prefix_condition(
|
|
527
|
+
op.namespace_prefix, column="s.prefix"
|
|
528
|
+
)
|
|
529
|
+
prefix_filter_str = f"WHERE {ns_condition} {filter_str} "
|
|
530
|
+
ns_args: Sequence = ns_args_tuple
|
|
466
531
|
else:
|
|
467
532
|
ns_args = ()
|
|
468
533
|
if filter_str:
|
|
@@ -503,12 +568,15 @@ class BaseSqliteStore:
|
|
|
503
568
|
]
|
|
504
569
|
# Regular search branch (no vector search)
|
|
505
570
|
else:
|
|
506
|
-
|
|
571
|
+
ns_condition, ns_args_tuple = _namespace_prefix_condition(
|
|
572
|
+
op.namespace_prefix
|
|
573
|
+
)
|
|
574
|
+
base_query = f"""
|
|
507
575
|
SELECT prefix, key, value, created_at, updated_at, expires_at, ttl_minutes, NULL as score
|
|
508
576
|
FROM store
|
|
509
|
-
WHERE
|
|
577
|
+
WHERE {ns_condition}
|
|
510
578
|
"""
|
|
511
|
-
params =
|
|
579
|
+
params = list(ns_args_tuple)
|
|
512
580
|
|
|
513
581
|
if filter_conditions:
|
|
514
582
|
params.extend(filter_params)
|
|
@@ -549,16 +617,28 @@ class BaseSqliteStore:
|
|
|
549
617
|
|
|
550
618
|
if op.match_conditions:
|
|
551
619
|
for cond in op.match_conditions:
|
|
552
|
-
if cond.match_type
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
620
|
+
if cond.match_type in ("prefix", "suffix"):
|
|
621
|
+
if not cond.path:
|
|
622
|
+
# An empty path constrains nothing; skipping keeps it a
|
|
623
|
+
# no-op rather than emitting a pattern that matches no
|
|
624
|
+
# namespace at all.
|
|
625
|
+
continue
|
|
626
|
+
if cond.match_type == "prefix" and "*" not in cond.path:
|
|
627
|
+
# Equivalent to the anchored pattern, but SQLite can
|
|
628
|
+
# satisfy `=` and a trailing-wildcard GLOB from
|
|
629
|
+
# store_prefix_idx. The user function is opaque to the
|
|
630
|
+
# planner, so it would scan every row and call back
|
|
631
|
+
# into Python for each one.
|
|
632
|
+
condition, args = _namespace_prefix_condition(
|
|
633
|
+
tuple(cond.path)
|
|
634
|
+
)
|
|
635
|
+
where_clauses.append(condition)
|
|
636
|
+
params.extend(args)
|
|
637
|
+
else:
|
|
638
|
+
where_clauses.append(f"{NS_MATCH_FUNCTION}(prefix, ?) = 1")
|
|
639
|
+
params.append(
|
|
640
|
+
_namespace_match_pattern(cond.path, cond.match_type)
|
|
641
|
+
)
|
|
562
642
|
else:
|
|
563
643
|
logger.warning(
|
|
564
644
|
"Unknown match_type in list_namespaces: %s", cond.match_type
|
|
@@ -785,6 +865,9 @@ class SqliteStore(BaseSqliteStore, BaseStore):
|
|
|
785
865
|
super().__init__()
|
|
786
866
|
self._deserializer = deserializer
|
|
787
867
|
self.conn = conn
|
|
868
|
+
# Registered here rather than in from_conn_string so a caller-supplied
|
|
869
|
+
# connection also gets it.
|
|
870
|
+
conn.create_function(NS_MATCH_FUNCTION, 2, _namespace_match, deterministic=True)
|
|
788
871
|
self.lock = threading.Lock()
|
|
789
872
|
self.is_setup = False
|
|
790
873
|
self.index_config = index
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "langgraph-checkpoint-sqlite"
|
|
7
|
-
version = "3.1.
|
|
7
|
+
version = "3.1.1"
|
|
8
8
|
description = "Library with a SQLite implementation of LangGraph checkpoint saver."
|
|
9
9
|
authors = []
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -35,7 +35,7 @@ test = [
|
|
|
35
35
|
lint = [
|
|
36
36
|
"ruff",
|
|
37
37
|
"codespell",
|
|
38
|
-
"
|
|
38
|
+
"ty",
|
|
39
39
|
]
|
|
40
40
|
dev = [
|
|
41
41
|
{include-group = "test"},
|
|
@@ -67,18 +67,22 @@ lint.select = [
|
|
|
67
67
|
lint.ignore = ["E501", "B008"]
|
|
68
68
|
target-version = "py310"
|
|
69
69
|
|
|
70
|
+
[tool.ty.rules]
|
|
71
|
+
invalid-argument-type = "ignore"
|
|
72
|
+
invalid-assignment = "ignore"
|
|
73
|
+
invalid-key = "ignore"
|
|
74
|
+
invalid-method-override = "ignore"
|
|
75
|
+
invalid-return-type = "ignore"
|
|
76
|
+
invalid-yield = "ignore"
|
|
77
|
+
not-iterable = "ignore"
|
|
78
|
+
not-subscriptable = "ignore"
|
|
79
|
+
unused-type-ignore-comment = "ignore"
|
|
80
|
+
unresolved-attribute = "ignore"
|
|
81
|
+
unresolved-import = "ignore"
|
|
82
|
+
unsupported-operator = "ignore"
|
|
83
|
+
|
|
70
84
|
[tool.pytest-watcher]
|
|
71
85
|
now = true
|
|
72
86
|
delay = 0.1
|
|
73
87
|
runner_args = ["--ff", "-v", "--tb", "short"]
|
|
74
88
|
patterns = ["*.py"]
|
|
75
|
-
|
|
76
|
-
[tool.mypy]
|
|
77
|
-
# https://mypy.readthedocs.io/en/stable/config_file.html
|
|
78
|
-
disallow_untyped_defs = "True"
|
|
79
|
-
explicit_package_bases = "True"
|
|
80
|
-
warn_no_return = "False"
|
|
81
|
-
warn_unused_ignores = "True"
|
|
82
|
-
warn_redundant_casts = "True"
|
|
83
|
-
allow_redefinition = "True"
|
|
84
|
-
disable_error_code = "typeddict-item, return-value"
|
{langgraph_checkpoint_sqlite-3.1.0 → langgraph_checkpoint_sqlite-3.1.1}/tests/test_async_store.py
RENAMED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
# mypy: disable-error-code="union-attr,arg-type,index,operator"
|
|
2
1
|
import asyncio
|
|
3
2
|
import os
|
|
4
3
|
import tempfile
|
|
@@ -717,3 +716,32 @@ async def test_search_items(
|
|
|
717
716
|
for ns in test_namespaces:
|
|
718
717
|
key = f"item_{ns[-1]}"
|
|
719
718
|
await store.adelete(ns, key)
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
async def test_async_namespace_segment_boundary(store: AsyncSqliteStore) -> None:
|
|
722
|
+
"""Segment-aware scoping on the async path.
|
|
723
|
+
|
|
724
|
+
Also covers that the namespace-match SQLite function is registered on the
|
|
725
|
+
async connection -- aiosqlite's create_function is a coroutine, so it is
|
|
726
|
+
registered in setup() rather than __init__.
|
|
727
|
+
"""
|
|
728
|
+
for namespace in [
|
|
729
|
+
("foo",),
|
|
730
|
+
("foo", "child"),
|
|
731
|
+
("foobar",),
|
|
732
|
+
("uid", "users", "alice"),
|
|
733
|
+
("uid", "users", "malice"),
|
|
734
|
+
("user_1",),
|
|
735
|
+
("userX1",),
|
|
736
|
+
]:
|
|
737
|
+
await store.aput(namespace, "k", {"v": 1})
|
|
738
|
+
|
|
739
|
+
found = {item.namespace for item in await store.asearch(("foo",), limit=100)}
|
|
740
|
+
assert found == {("foo",), ("foo", "child")}
|
|
741
|
+
|
|
742
|
+
found = {item.namespace for item in await store.asearch(("user_1",), limit=100)}
|
|
743
|
+
assert found == {("user_1",)}
|
|
744
|
+
|
|
745
|
+
assert set(await store.alist_namespaces(suffix=["alice"], limit=100)) == {
|
|
746
|
+
("uid", "users", "alice"),
|
|
747
|
+
}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
# mypy: disable-error-code="union-attr,arg-type,index,operator"
|
|
2
1
|
import os
|
|
3
2
|
import re
|
|
4
3
|
import tempfile
|
|
@@ -19,7 +18,13 @@ from langgraph.store.base import (
|
|
|
19
18
|
)
|
|
20
19
|
|
|
21
20
|
from langgraph.store.sqlite import SqliteStore
|
|
22
|
-
from langgraph.store.sqlite.base import
|
|
21
|
+
from langgraph.store.sqlite.base import (
|
|
22
|
+
NS_MATCH_FUNCTION,
|
|
23
|
+
BaseSqliteStore,
|
|
24
|
+
SqliteIndexConfig,
|
|
25
|
+
_escape_glob_literal,
|
|
26
|
+
_namespace_match_pattern,
|
|
27
|
+
)
|
|
23
28
|
|
|
24
29
|
|
|
25
30
|
# Local embeddings implementation for testing vector search
|
|
@@ -1230,3 +1235,208 @@ def test_non_ascii(
|
|
|
1230
1235
|
assert result3[0].key == "3"
|
|
1231
1236
|
assert result4[0].key == "4"
|
|
1232
1237
|
assert result5[0].key == "5"
|
|
1238
|
+
|
|
1239
|
+
|
|
1240
|
+
def test_escape_glob_literal() -> None:
|
|
1241
|
+
assert _escape_glob_literal("users.alice") == "users.alice"
|
|
1242
|
+
# "_" and "%" are LIKE wildcards but literal in GLOB, so they are left alone.
|
|
1243
|
+
assert _escape_glob_literal("user_1") == "user_1"
|
|
1244
|
+
assert _escape_glob_literal("100%") == "100%"
|
|
1245
|
+
assert _escape_glob_literal("a*b") == "a[*]b"
|
|
1246
|
+
assert _escape_glob_literal("a?b") == "a[?]b"
|
|
1247
|
+
assert _escape_glob_literal("a[b") == "a[[]b"
|
|
1248
|
+
|
|
1249
|
+
|
|
1250
|
+
def test_namespace_match_pattern() -> None:
|
|
1251
|
+
assert _namespace_match_pattern(("foo",), "prefix") == r"^foo(\.|\Z)"
|
|
1252
|
+
assert (
|
|
1253
|
+
_namespace_match_pattern(("uid", "*", "alice"), "prefix")
|
|
1254
|
+
== r"^uid\.[^.]+\.alice(\.|\Z)"
|
|
1255
|
+
)
|
|
1256
|
+
assert _namespace_match_pattern(("alice",), "suffix") == r"(^|\.)alice\Z"
|
|
1257
|
+
|
|
1258
|
+
|
|
1259
|
+
def test_search_namespace_segment_boundary(store: SqliteStore) -> None:
|
|
1260
|
+
"""Prefix scoping must stop at namespace segment boundaries.
|
|
1261
|
+
|
|
1262
|
+
Namespaces are stored dot-joined, so matching the raw text also returns
|
|
1263
|
+
siblings sharing leading characters.
|
|
1264
|
+
"""
|
|
1265
|
+
for namespace in [
|
|
1266
|
+
("foo",),
|
|
1267
|
+
("foo", "child"),
|
|
1268
|
+
("foo", "child", "deep"),
|
|
1269
|
+
("foobar",),
|
|
1270
|
+
("foobar", "baz"),
|
|
1271
|
+
("foo2",),
|
|
1272
|
+
]:
|
|
1273
|
+
store.put(namespace, "k", {"v": 1})
|
|
1274
|
+
|
|
1275
|
+
def _namespaces(prefix: tuple[str, ...]) -> set[tuple[str, ...]]:
|
|
1276
|
+
return {item.namespace for item in store.search(prefix, limit=100)}
|
|
1277
|
+
|
|
1278
|
+
assert _namespaces(("foo",)) == {
|
|
1279
|
+
("foo",),
|
|
1280
|
+
("foo", "child"),
|
|
1281
|
+
("foo", "child", "deep"),
|
|
1282
|
+
}
|
|
1283
|
+
# The sibling scope is independent, not merely narrower.
|
|
1284
|
+
assert _namespaces(("foobar",)) == {("foobar",), ("foobar", "baz")}
|
|
1285
|
+
assert _namespaces(("foo2",)) == {("foo2",)}
|
|
1286
|
+
assert _namespaces(("fo",)) == set()
|
|
1287
|
+
|
|
1288
|
+
|
|
1289
|
+
def test_search_namespace_wildcard_chars_are_literal(store: SqliteStore) -> None:
|
|
1290
|
+
"""LIKE and GLOB metacharacters in labels must be matched literally."""
|
|
1291
|
+
for namespace in [
|
|
1292
|
+
("user_1",),
|
|
1293
|
+
("user_1", "child"),
|
|
1294
|
+
("userX1",),
|
|
1295
|
+
("a%b",),
|
|
1296
|
+
("axxb",),
|
|
1297
|
+
("star*",),
|
|
1298
|
+
("starX",),
|
|
1299
|
+
]:
|
|
1300
|
+
store.put(namespace, "k", {"v": 1})
|
|
1301
|
+
|
|
1302
|
+
def _namespaces(prefix: tuple[str, ...]) -> set[tuple[str, ...]]:
|
|
1303
|
+
return {item.namespace for item in store.search(prefix, limit=100)}
|
|
1304
|
+
|
|
1305
|
+
# Also asserts each namespace still matches itself, which catches escaping
|
|
1306
|
+
# the equality arm by mistake.
|
|
1307
|
+
assert _namespaces(("user_1",)) == {("user_1",), ("user_1", "child")}
|
|
1308
|
+
assert _namespaces(("a%b",)) == {("a%b",)}
|
|
1309
|
+
assert _namespaces(("star*",)) == {("star*",)}
|
|
1310
|
+
|
|
1311
|
+
|
|
1312
|
+
def test_search_namespace_is_case_sensitive(store: SqliteStore) -> None:
|
|
1313
|
+
"""Search must agree with get/put, which compare namespaces with `=`.
|
|
1314
|
+
|
|
1315
|
+
SQLite's LIKE is case-insensitive for ASCII, so matching with it conflated
|
|
1316
|
+
namespaces that every other operation treats as distinct.
|
|
1317
|
+
"""
|
|
1318
|
+
store.put(("Foo",), "k", {"v": "upper"})
|
|
1319
|
+
store.put(("foo",), "k", {"v": "lower"})
|
|
1320
|
+
|
|
1321
|
+
assert {item.namespace for item in store.search(("foo",), limit=100)} == {("foo",)}
|
|
1322
|
+
assert {item.namespace for item in store.search(("Foo",), limit=100)} == {("Foo",)}
|
|
1323
|
+
|
|
1324
|
+
|
|
1325
|
+
def test_list_namespaces_segment_boundary(store: SqliteStore) -> None:
|
|
1326
|
+
for namespace in [
|
|
1327
|
+
("foo",),
|
|
1328
|
+
("foo", "child"),
|
|
1329
|
+
("foobar",),
|
|
1330
|
+
("foobar", "baz"),
|
|
1331
|
+
("uid", "users", "alice"),
|
|
1332
|
+
("uid", "users", "malice"),
|
|
1333
|
+
("uid", "a", "b", "alice"),
|
|
1334
|
+
]:
|
|
1335
|
+
store.put(namespace, "k", {"v": 1})
|
|
1336
|
+
|
|
1337
|
+
assert set(store.list_namespaces(prefix=["foo"], limit=100)) == {
|
|
1338
|
+
("foo",),
|
|
1339
|
+
("foo", "child"),
|
|
1340
|
+
}
|
|
1341
|
+
# Suffix must align to a segment: "malice" does not end with the "alice"
|
|
1342
|
+
# segment.
|
|
1343
|
+
assert set(store.list_namespaces(suffix=["alice"], limit=100)) == {
|
|
1344
|
+
("uid", "users", "alice"),
|
|
1345
|
+
("uid", "a", "b", "alice"),
|
|
1346
|
+
}
|
|
1347
|
+
# "*" spans exactly one segment.
|
|
1348
|
+
assert set(store.list_namespaces(prefix=["uid", "*", "alice"], limit=100)) == {
|
|
1349
|
+
("uid", "users", "alice"),
|
|
1350
|
+
}
|
|
1351
|
+
# Prefix matching stays open-ended across depth.
|
|
1352
|
+
assert set(store.list_namespaces(prefix=["uid"], limit=100)) == {
|
|
1353
|
+
("uid", "users", "alice"),
|
|
1354
|
+
("uid", "users", "malice"),
|
|
1355
|
+
("uid", "a", "b", "alice"),
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
|
|
1359
|
+
def test_search_empty_prefix_is_unconstrained(store: SqliteStore) -> None:
|
|
1360
|
+
"""An empty prefix constrains nothing and must return every namespace."""
|
|
1361
|
+
for namespace in [("a",), ("b", "c"), ("d", "e", "f")]:
|
|
1362
|
+
store.put(namespace, "k", {"v": 1})
|
|
1363
|
+
|
|
1364
|
+
assert {item.namespace for item in store.search((), limit=100)} == {
|
|
1365
|
+
("a",),
|
|
1366
|
+
("b", "c"),
|
|
1367
|
+
("d", "e", "f"),
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
|
|
1371
|
+
def test_namespace_labels_with_trailing_newline(store: SqliteStore) -> None:
|
|
1372
|
+
"""Labels may contain newlines, and must not match a differently-named label.
|
|
1373
|
+
|
|
1374
|
+
Python's `$` also matches just before a trailing newline, so the patterns use
|
|
1375
|
+
`\\Z` to anchor at the true end of the string.
|
|
1376
|
+
"""
|
|
1377
|
+
store.put(("users", "alice"), "k", {"v": 1})
|
|
1378
|
+
store.put(("users", "alice\n"), "k", {"v": 2})
|
|
1379
|
+
|
|
1380
|
+
assert set(store.list_namespaces(suffix=["alice"], limit=100)) == {
|
|
1381
|
+
("users", "alice"),
|
|
1382
|
+
}
|
|
1383
|
+
assert set(store.list_namespaces(prefix=["users", "alice"], limit=100)) == {
|
|
1384
|
+
("users", "alice"),
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
|
|
1388
|
+
def test_list_namespaces_prefix_uses_indexable_condition() -> None:
|
|
1389
|
+
"""Plain prefixes must use the indexable condition, not the match function.
|
|
1390
|
+
|
|
1391
|
+
A user function is opaque to the query planner, so it scans every row and
|
|
1392
|
+
calls back into Python for each one. Only suffix and wildcard paths, which
|
|
1393
|
+
no SQLite operator can express, need it.
|
|
1394
|
+
"""
|
|
1395
|
+
store = BaseSqliteStore()
|
|
1396
|
+
|
|
1397
|
+
def where(match_type: str, path: tuple[str, ...]) -> str:
|
|
1398
|
+
op = ListNamespacesOp(
|
|
1399
|
+
match_conditions=(MatchCondition(match_type=match_type, path=path),),
|
|
1400
|
+
max_depth=None,
|
|
1401
|
+
limit=10,
|
|
1402
|
+
offset=0,
|
|
1403
|
+
)
|
|
1404
|
+
query, _ = store._get_batch_list_namespaces_queries([(0, op)])[0]
|
|
1405
|
+
return " ".join(query.split())
|
|
1406
|
+
|
|
1407
|
+
assert "GLOB" in where("prefix", ("uid", "users"))
|
|
1408
|
+
assert NS_MATCH_FUNCTION not in where("prefix", ("uid", "users"))
|
|
1409
|
+
# A label that merely contains "*" is not the wildcard.
|
|
1410
|
+
assert "GLOB" in where("prefix", ("star*",))
|
|
1411
|
+
# Wildcard and suffix cannot be expressed by GLOB, so they keep the function.
|
|
1412
|
+
assert NS_MATCH_FUNCTION in where("prefix", ("uid", "*", "alice"))
|
|
1413
|
+
assert NS_MATCH_FUNCTION in where("suffix", ("alice",))
|
|
1414
|
+
|
|
1415
|
+
|
|
1416
|
+
def test_list_namespaces_metacharacter_labels(store: SqliteStore) -> None:
|
|
1417
|
+
"""Metacharacters in labels are literal on both matching paths.
|
|
1418
|
+
|
|
1419
|
+
Plain prefixes take the `= OR GLOB` condition and wildcard/suffix paths take
|
|
1420
|
+
the regex function, so escaping has to hold in two different syntaxes.
|
|
1421
|
+
"""
|
|
1422
|
+
pairs = [
|
|
1423
|
+
("star*", "starX"),
|
|
1424
|
+
("q?m", "qXm"),
|
|
1425
|
+
("br[ack]et", "brXacXket"),
|
|
1426
|
+
("user_1", "userX1"),
|
|
1427
|
+
("a%b", "axxb"),
|
|
1428
|
+
("plus+", "plusX"),
|
|
1429
|
+
]
|
|
1430
|
+
for label, decoy in pairs:
|
|
1431
|
+
store.put((label,), "k", {"v": 1})
|
|
1432
|
+
store.put((decoy,), "k", {"v": 1})
|
|
1433
|
+
store.put((label, "child"), "k", {"v": 1})
|
|
1434
|
+
|
|
1435
|
+
for label, decoy in pairs:
|
|
1436
|
+
found = set(store.list_namespaces(prefix=[label], limit=100))
|
|
1437
|
+
assert found == {(label,), (label, "child")}
|
|
1438
|
+
# The decoy differs only where the metacharacter would have matched.
|
|
1439
|
+
assert (decoy,) not in found
|
|
1440
|
+
assert set(store.list_namespaces(prefix=[label, "child"], limit=100)) == {
|
|
1441
|
+
(label, "child"),
|
|
1442
|
+
}
|