Skip to content

Commit 99e884f

Browse files
committed
Handle optional deps and add memory storage tests
1 parent 8aea8dd commit 99e884f

File tree

6 files changed

+179
-50
lines changed

6 files changed

+179
-50
lines changed

moatless/events.py

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@
22
from datetime import datetime, timezone
33
from typing import Optional
44

5-
from pydantic import BaseModel, Field, field_validator
5+
try:
6+
from pydantic import BaseModel, Field, field_validator
7+
PYDANTIC_V2 = True
8+
except ImportError: # pragma: no cover - support pydantic v1
9+
from pydantic import BaseModel, Field, validator as field_validator
10+
PYDANTIC_V2 = False
611

712
logger = logging.getLogger(__name__)
813

@@ -19,12 +24,19 @@ class BaseEvent(BaseModel):
1924

2025
model_config = {"ser_json_timedelta": "iso8601", "json_encoders": {datetime: lambda dt: dt.isoformat()}}
2126

22-
@field_validator("timestamp", mode="before")
23-
@classmethod
24-
def parse_datetime(cls, value):
25-
if isinstance(value, str):
26-
return datetime.fromisoformat(value)
27-
return value
27+
if PYDANTIC_V2:
28+
@field_validator("timestamp", mode="before")
29+
@classmethod
30+
def parse_datetime(cls, value):
31+
if isinstance(value, str):
32+
return datetime.fromisoformat(value)
33+
return value
34+
else: # pragma: no cover - support pydantic v1
35+
@field_validator("timestamp", pre=True)
36+
def parse_datetime(cls, value):
37+
if isinstance(value, str):
38+
return datetime.fromisoformat(value)
39+
return value
2840

2941
@classmethod
3042
def from_dict(cls, data: dict) -> "BaseEvent":

moatless/settings.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
import logging
22
import os
3+
from typing import Type
34

4-
from dotenv import load_dotenv
5+
try:
6+
from dotenv import load_dotenv
7+
except ModuleNotFoundError: # pragma: no cover - optional dependency
8+
def load_dotenv(*args, **kwargs):
9+
"""Fallback no-op if python-dotenv is missing."""
10+
return False
511

6-
from litellm import Type
712
from moatless.eventbus.base import BaseEventBus
813
from moatless.eventbus.local_bus import LocalEventBus
914
from moatless.runner.asyncio_runner import AsyncioRunner

moatless/storage/__init__.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,12 @@
33
"""
44

55
from moatless.storage.base import BaseStorage
6-
from moatless.storage.file_storage import FileStorage
6+
7+
try:
8+
from moatless.storage.file_storage import FileStorage
9+
except Exception: # pragma: no cover - optional dependency
10+
FileStorage = None # type: ignore
11+
712
from moatless.storage.memory_storage import MemoryStorage
813

914
__all__ = ["BaseStorage", "FileStorage", "MemoryStorage"]

moatless/storage/memory_storage.py

Lines changed: 83 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -5,64 +5,113 @@
55
in memory, which is useful for testing or temporary storage.
66
"""
77

8+
import json
89
import logging
10+
from typing import Union
911

10-
from moatless.storage.base import BaseStorage
12+
from moatless.storage.base import BaseStorage, DateTimeEncoder
1113

1214
logger = logging.getLogger(__name__)
1315

1416

1517
class MemoryStorage(BaseStorage):
16-
"""
17-
Storage implementation that uses in-memory dictionaries.
18+
"""Simple in-memory implementation of :class:`BaseStorage`."""
1819

19-
This class provides a storage implementation that keeps all data
20-
in memory, which is useful for testing or temporary storage.
21-
"""
22-
23-
def __init__(self):
20+
def __init__(self) -> None:
2421
"""Initialize an empty in-memory storage."""
25-
self._data: dict[str, dict] = {}
22+
self._data: dict[str, object] = {}
23+
24+
async def read_raw(self, path: str) -> str:
25+
"""Return the raw value stored under *path*."""
26+
normalized_key = self.normalize_path(path)
27+
if normalized_key not in self._data:
28+
raise KeyError(f"Key '{path}' does not exist")
2629

27-
async def read(self, path: str) -> dict:
28-
"""Read binary data from memory."""
30+
value = self._data[normalized_key]
31+
if isinstance(value, list):
32+
# Represent lists as JSONL
33+
return "\n".join(
34+
json.dumps(v, cls=DateTimeEncoder) if isinstance(v, dict) else str(v)
35+
for v in value
36+
)
37+
if isinstance(value, dict):
38+
return json.dumps(value, cls=DateTimeEncoder)
39+
return str(value)
40+
41+
async def read_lines(self, path: str) -> list[dict]:
42+
"""Return a list of objects stored under *path*."""
2943
normalized_key = self.normalize_path(path)
3044
if normalized_key not in self._data:
3145
raise KeyError(f"Key '{path}' does not exist")
32-
return self._data[normalized_key]
3346

34-
async def write(self, key: str, data: dict) -> None:
35-
"""Write binary data to memory."""
36-
normalized_key = self.normalize_path(key)
47+
value = self._data[normalized_key]
48+
results: list[dict] = []
49+
if isinstance(value, list):
50+
for item in value:
51+
if isinstance(item, str):
52+
item = item.strip()
53+
if item:
54+
results.append(json.loads(item))
55+
else:
56+
results.append(item)
57+
elif isinstance(value, str):
58+
for line in value.splitlines():
59+
line = line.strip()
60+
if line:
61+
results.append(json.loads(line))
62+
elif isinstance(value, dict):
63+
results.append(value)
64+
return results
65+
66+
async def write_raw(self, path: str, data: str) -> None:
67+
"""Write raw string *data* to *path*."""
68+
normalized_key = self.normalize_path(path)
3769
self._data[normalized_key] = data
3870

39-
async def delete(self, key: str) -> None:
40-
"""Delete data from memory."""
41-
normalized_key = self.normalize_path(key)
71+
async def append(self, path: str, data: Union[dict, str]) -> None:
72+
"""Append *data* to the entry at *path*."""
73+
normalized_key = self.normalize_path(path)
74+
existing = self._data.get(normalized_key)
75+
76+
if existing is None:
77+
self._data[normalized_key] = []
78+
existing = self._data[normalized_key]
79+
80+
if not isinstance(existing, list):
81+
if isinstance(existing, str):
82+
lines = existing.splitlines()
83+
self._data[normalized_key] = lines
84+
else:
85+
self._data[normalized_key] = [existing]
86+
existing = self._data[normalized_key]
87+
88+
assert isinstance(existing, list)
89+
if isinstance(data, dict):
90+
existing.append(data)
91+
else:
92+
existing.append(data.rstrip("\n"))
93+
94+
async def delete(self, path: str) -> None:
95+
"""Delete the value at *path*."""
96+
normalized_key = self.normalize_path(path)
4297
if normalized_key not in self._data:
43-
raise KeyError(f"Key '{key}' does not exist")
98+
raise KeyError(f"Key '{path}' does not exist")
4499
del self._data[normalized_key]
45100

46-
async def exists(self, key: str) -> bool:
47-
"""Check if a key exists in memory."""
48-
normalized_key = self.normalize_path(key)
101+
async def exists(self, path: str) -> bool:
102+
"""Return ``True`` if *path* exists."""
103+
normalized_key = self.normalize_path(path)
49104
return normalized_key in self._data
50105

51106
async def list_paths(self, prefix: str = "") -> list[str]:
52-
"""
53-
List all keys with the given prefix.
54-
55-
Args:
56-
prefix: The key prefix to search for
57-
58-
Returns:
59-
A list of keys that match the prefix
60-
"""
107+
"""List all keys starting with *prefix*."""
61108
normalized_prefix = self.normalize_path(prefix)
62109

63-
# When prefix is empty, return all keys
64110
if not normalized_prefix:
65111
return list(self._data.keys())
66112

67-
# Filter keys that start with the prefix
68-
return [key for key in self._data.keys() if key == normalized_prefix or key.startswith(normalized_prefix + "/")]
113+
return [
114+
key
115+
for key in self._data.keys()
116+
if key == normalized_prefix or key.startswith(normalized_prefix + "/")
117+
]

tests/conftest.py

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,18 @@
44
import tempfile
55

66
import pytest
7-
from dotenv import load_dotenv
87

9-
# Import here to avoid circular imports
10-
from moatless import settings
11-
from moatless.storage.base import BaseStorage
12-
from moatless.storage.file_storage import FileStorage
8+
try:
9+
from dotenv import load_dotenv
10+
except ModuleNotFoundError: # pragma: no cover - optional dependency
11+
def load_dotenv(*args, **kwargs):
12+
"""Fallback no-op if python-dotenv is not installed."""
13+
return False
14+
15+
try:
16+
from moatless.storage.file_storage import FileStorage
17+
except Exception: # pragma: no cover - optional dependency
18+
FileStorage = None # type: ignore
1319

1420
load_dotenv()
1521
logger = logging.getLogger(__name__)
@@ -35,8 +41,13 @@ def temp_dir():
3541
@pytest.fixture
3642
def file_storage(temp_dir):
3743
"""Fixture to create a storage instance with a test directory."""
44+
from moatless import settings
45+
if FileStorage is not None:
46+
settings._storage = FileStorage(base_dir=temp_dir)
47+
else:
48+
from moatless.storage.memory_storage import MemoryStorage
3849

39-
settings._storage = FileStorage(base_dir=temp_dir)
50+
settings._storage = MemoryStorage()
4051
return settings._storage
4152

4253

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import asyncio
2+
3+
from moatless.storage.memory_storage import MemoryStorage
4+
5+
6+
def test_basic_operations():
7+
async def _run():
8+
storage = MemoryStorage()
9+
path = "foo/bar.json"
10+
data = {"hello": "world"}
11+
12+
await storage.write(path, data)
13+
assert await storage.exists(path)
14+
15+
read = await storage.read(path)
16+
assert read == data
17+
18+
asyncio.run(_run())
19+
20+
21+
def test_append_and_read_lines():
22+
async def _run():
23+
storage = MemoryStorage()
24+
path = "events.jsonl"
25+
26+
await storage.append(path, {"id": 1})
27+
await storage.append(path, {"id": 2})
28+
29+
lines = await storage.read_lines(path)
30+
assert lines == [{"id": 1}, {"id": 2}]
31+
32+
asyncio.run(_run())
33+
34+
35+
def test_list_paths_and_delete():
36+
async def _run():
37+
storage = MemoryStorage()
38+
await storage.write("a/one.json", {"v": 1})
39+
await storage.write("a/two.json", {"v": 2})
40+
41+
paths = await storage.list_paths("a")
42+
assert sorted(paths) == ["a/one.json", "a/two.json"]
43+
44+
await storage.delete("a/one.json")
45+
assert not await storage.exists("a/one.json")
46+
47+
asyncio.run(_run())

0 commit comments

Comments
 (0)