brainlessdb 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.
brainless/__init__.py ADDED
@@ -0,0 +1,57 @@
1
+ """Brainless - Schema-first async persistence for NATS JetStream KV."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from brainless.client import Brainless
8
+ from brainless.collection import Collection
9
+ from brainless.entity import Entity
10
+
11
+ __all__ = ["Brainless", "Collection", "Entity", "setup", "stop", "flush"]
12
+
13
+ _instance: Brainless | None = None
14
+
15
+
16
+ async def setup(
17
+ nats: Any = None,
18
+ namespace: str = "default",
19
+ location: str = "local",
20
+ flush_interval: float = 0.5,
21
+ ) -> Brainless:
22
+ """Initialize and start the global Brainless instance
23
+
24
+ @param nats: Connected NATS client (optional for in-memory only mode)
25
+ @param namespace: Bucket prefix for isolation
26
+ @param location: Name of this site/node for UUID prefixes
27
+ @param flush_interval: Background flush interval in seconds
28
+ @return: The Brainless instance
29
+ """
30
+ global _instance
31
+ if _instance is not None:
32
+ await _instance.stop()
33
+ _instance = Brainless(nats, namespace, location, flush_interval)
34
+ await _instance.start()
35
+ return _instance
36
+
37
+
38
+ async def stop() -> None:
39
+ """Stop the global Brainless instance."""
40
+ global _instance
41
+ if _instance is not None:
42
+ await _instance.stop()
43
+ _instance = None
44
+
45
+
46
+ async def flush() -> int:
47
+ """Flush all dirty entities in the global instance."""
48
+ if _instance is None:
49
+ raise RuntimeError("Call brainless.setup() first")
50
+ return await _instance.flush()
51
+
52
+
53
+ def __getattr__(name: str) -> Any:
54
+ """Proxy attribute access to global instance for collection access."""
55
+ if _instance is None:
56
+ raise RuntimeError("Call brainless.setup() first")
57
+ return getattr(_instance, name)
brainless/bucket.py ADDED
@@ -0,0 +1,127 @@
1
+ """NATS JetStream KV bucket wrapper."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ from typing import TYPE_CHECKING, Any
8
+
9
+ from nats.js.errors import BucketNotFoundError
10
+
11
+ if TYPE_CHECKING:
12
+ from nats.js.kv import KeyValue
13
+
14
+ _log = logging.getLogger(__name__)
15
+
16
+
17
+ class Bucket:
18
+ """Wrapper around NATS JetStream KV bucket
19
+
20
+ Provides simple get/put/delete operations with JSON serialization.
21
+ """
22
+
23
+ def __init__(self, name: str, kv: KeyValue) -> None:
24
+ self._name = name
25
+ self._kv = kv
26
+
27
+ @property
28
+ def name(self) -> str:
29
+ return self._name
30
+
31
+ @classmethod
32
+ async def create(
33
+ cls,
34
+ js: Any,
35
+ name: str,
36
+ ttl: float | None = None,
37
+ max_bytes: int | None = None,
38
+ ) -> Bucket:
39
+ """Create or get existing KV bucket
40
+
41
+ @param js: JetStream context
42
+ @param name: Bucket name
43
+ @param ttl: Time-to-live in seconds (optional)
44
+ @param max_bytes: Max bucket size in bytes (optional)
45
+ @return: Bucket instance
46
+ """
47
+ from nats.js.api import KeyValueConfig
48
+
49
+ config = KeyValueConfig(bucket=name)
50
+ if ttl is not None:
51
+ config.ttl = ttl
52
+ if max_bytes is not None:
53
+ config.max_bytes = max_bytes
54
+
55
+ try:
56
+ kv = await js.key_value(name)
57
+ _log.debug("Using existing bucket: %s", name)
58
+ except BucketNotFoundError:
59
+ kv = await js.create_key_value(config)
60
+ _log.info("Created bucket: %s", name)
61
+
62
+ return cls(name, kv)
63
+
64
+ async def get(self, key: str) -> dict[str, Any] | None:
65
+ """Get value by key
66
+
67
+ @param key: Key to retrieve
68
+ @return: Deserialized value or None if not found
69
+ """
70
+ try:
71
+ entry = await self._kv.get(key)
72
+ if entry is None or entry.value is None:
73
+ return None
74
+ envelope = json.loads(entry.value.decode())
75
+ return envelope["data"]
76
+ except Exception as e:
77
+ if "not found" in str(e).lower():
78
+ return None
79
+ raise
80
+
81
+ async def put(self, key: str, value: dict[str, Any]) -> None:
82
+ """Store value by key
83
+
84
+ @param key: Key to store
85
+ @param value: Value to serialize and store
86
+ """
87
+ envelope = {"version": "1", "data": value}
88
+ data = json.dumps(envelope).encode()
89
+ await self._kv.put(key, data)
90
+
91
+ async def delete(self, key: str) -> bool:
92
+ """Delete key
93
+
94
+ @param key: Key to delete
95
+ @return: True if deleted, False if not found
96
+ """
97
+ try:
98
+ await self._kv.delete(key)
99
+ return True
100
+ except Exception as e:
101
+ if "not found" in str(e).lower():
102
+ return False
103
+ raise
104
+
105
+ async def keys(self) -> list[str]:
106
+ """Get all keys in bucket
107
+
108
+ @return: List of keys
109
+ """
110
+ try:
111
+ return await self._kv.keys()
112
+ except Exception as e:
113
+ if "no keys" in str(e).lower():
114
+ return []
115
+ raise
116
+
117
+ async def all(self) -> dict[str, dict[str, Any]]:
118
+ """Get all key-value pairs
119
+
120
+ @return: Dictionary of all entries
121
+ """
122
+ result = {}
123
+ for key in await self.keys():
124
+ value = await self.get(key)
125
+ if value is not None:
126
+ result[key] = value
127
+ return result
brainless/client.py ADDED
@@ -0,0 +1,150 @@
1
+ """Brainless main client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ from typing import Any
8
+
9
+ from brainless.bucket import Bucket
10
+ from brainless.collection import Collection
11
+
12
+ _log = logging.getLogger(__name__)
13
+
14
+
15
+ class Brainless:
16
+ """Schema-first async persistence for NATS JetStream KV
17
+
18
+ Manages collections backed by NATS JetStream KV buckets. Schema is stored
19
+ in NATS itself, allowing data access without predefined classes.
20
+
21
+ Access collections as attributes: db.users, db.calls, etc.
22
+ """
23
+
24
+ def __init__(
25
+ self,
26
+ nats: Any = None,
27
+ namespace: str = "default",
28
+ location: str = "local",
29
+ flush_interval: float = 0.5,
30
+ ) -> None:
31
+ """Initialize Brainless client
32
+
33
+ @param nats: Connected NATS client (optional for in-memory only mode)
34
+ @param namespace: Bucket prefix for isolation (e.g., "myapp" -> "myapp-calls")
35
+ @param location: Name of this site/node (e.g., "prague-1"), used as prefix
36
+ in record UUIDs and for ownership in multi-location sync
37
+ @param flush_interval: Background flush interval in seconds
38
+ """
39
+ self._nats = nats
40
+ self._namespace = namespace
41
+ self._location = location
42
+ self._flush_interval = flush_interval
43
+ self._started = False
44
+ self._collections: dict[str, Collection] = {}
45
+ self._js: Any = None
46
+ self._flush_task: asyncio.Task | None = None
47
+
48
+ @property
49
+ def namespace(self) -> str:
50
+ return self._namespace
51
+
52
+ @property
53
+ def location(self) -> str:
54
+ return self._location
55
+
56
+ @property
57
+ def connected(self) -> bool:
58
+ """True if connected to NATS."""
59
+ return self._nats is not None and self._js is not None
60
+
61
+ def _bucket_name(self, collection: str) -> str:
62
+ """Generate bucket name for collection."""
63
+ return f"{self._namespace}-{collection}"
64
+
65
+ async def get_bucket(self, collection: str) -> Bucket | None:
66
+ """Get or create bucket for collection."""
67
+ if not self.connected:
68
+ return None
69
+ name = self._bucket_name(collection)
70
+ return await Bucket.create(self._js, name)
71
+
72
+ def collection(self, name: str) -> Collection:
73
+ """Get or create a collection by name
74
+
75
+ @param name: Collection name
76
+ @return: Collection instance
77
+ """
78
+ if name not in self._collections:
79
+ self._collections[name] = Collection(self, name)
80
+ return self._collections[name]
81
+
82
+ def __getattr__(self, name: str) -> Collection:
83
+ """Access collections as attributes: db.users, db.calls, etc."""
84
+ if name.startswith("_"):
85
+ raise AttributeError(f"'{type(self).__name__}' has no attribute '{name}'")
86
+ return self.collection(name)
87
+
88
+ async def _flush_loop(self) -> None:
89
+ """Background task to flush dirty entities."""
90
+ try:
91
+ while self._started:
92
+ await asyncio.sleep(self._flush_interval)
93
+ await self.flush()
94
+ except asyncio.CancelledError:
95
+ pass
96
+
97
+ async def flush(self) -> int:
98
+ """Flush all dirty entities to NATS
99
+
100
+ @return: Number of entities flushed
101
+ """
102
+ total = 0
103
+ for collection in self._collections.values():
104
+ total += await collection.flush()
105
+ return total
106
+
107
+ async def start(self) -> None:
108
+ """Start the client and connect to NATS."""
109
+ if self._started:
110
+ return
111
+
112
+ # Get JetStream context if NATS is available
113
+ if self._nats is not None:
114
+ self._js = self._nats.jetstream()
115
+
116
+ self._started = True
117
+
118
+ # Start background flush task
119
+ if self.connected:
120
+ self._flush_task = asyncio.create_task(self._flush_loop())
121
+
122
+ _log.info(
123
+ "Brainless started (namespace=%s, location=%s, connected=%s)",
124
+ self._namespace,
125
+ self._location,
126
+ self.connected,
127
+ )
128
+
129
+ async def stop(self) -> None:
130
+ """Stop the client and flush pending changes."""
131
+ if not self._started:
132
+ return
133
+
134
+ self._started = False
135
+
136
+ # Cancel flush task
137
+ if self._flush_task is not None:
138
+ self._flush_task.cancel()
139
+ try:
140
+ await self._flush_task
141
+ except asyncio.CancelledError:
142
+ pass
143
+ self._flush_task = None
144
+
145
+ # Final flush
146
+ flushed = await self.flush()
147
+ if flushed > 0:
148
+ _log.info("Flushed %d entities on shutdown", flushed)
149
+
150
+ _log.info("Brainless stopped")