tapestry-orm 0.0.1__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.
tapestry/edge.py ADDED
@@ -0,0 +1,271 @@
1
+ from abc import ABC
2
+ from copy import deepcopy
3
+ from typing import Self, Iterable, get_type_hints, Any, ClassVar, Union, TYPE_CHECKING
4
+ from pydantic.fields import FieldInfo, ComputedFieldInfo
5
+ from surrealdb import AsyncWsSurrealConnection, AsyncHttpSurrealConnection, RecordID
6
+
7
+
8
+ from .base import Base
9
+ from .node import Node
10
+ from .utils import replace_type
11
+ from .table import Table, Link, flatten_type
12
+ from .field import Traversal, Direction, Condition, LogicalCondition, Field
13
+
14
+
15
+ class Edge(Base, ABC, discarded=True):
16
+ """
17
+ Base class for SurrealDB edge (relation) tables.
18
+
19
+ Edge represents relationship tables in SurrealDB that connect two nodes.
20
+ All relationship tables should inherit from this class and define
21
+ 'in_' and 'out_' fields to specify the connected node types.
22
+
23
+ Class Attributes:
24
+ _directed (bool): Whether the relationship is directional (default: True)
25
+
26
+ Required Fields:
27
+ in_: The source node of the relationship
28
+ out_: The target node of the relationship
29
+
30
+ Example:
31
+ >>> from tapestry import Edge, Node
32
+ >>> from datetime import date
33
+ >>>
34
+ >>> class Person(Node):
35
+ ... name: str
36
+ ...
37
+ >>> class Role(Node):
38
+ ... title: str
39
+ ...
40
+ >>> class BelongsTo(Edge):
41
+ ... in_: Person # Person belongs to Role
42
+ ... out_: Role
43
+ ... begin_date: date
44
+ ... end_date: date | None = None
45
+ ...
46
+ >>> # Create a relationship
47
+ >>> person = Person(name="John Doe")
48
+ >>> role = Role(title="Manager")
49
+ >>> belongs = BelongsTo(
50
+ ... in_=person,
51
+ ... out_=role,
52
+ ... begin_date=date(2020, 1, 1)
53
+ ... )
54
+ >>> await belongs.relate(db)
55
+
56
+ Notes:
57
+ - Requires both 'in_' and 'out_' fields to be defined
58
+ - Automatically creates SurrealDB RELATION tables
59
+ - Supports directional and bidirectional relationships
60
+ - Can have additional fields beyond in_ and out_
61
+ - Use relate() instead of create() for edge records
62
+ """
63
+ # in_: Node
64
+ # out_: Node
65
+ #
66
+ _directed: bool = True
67
+
68
+ def __init_subclass__(cls, directed: bool = True, **kwargs):
69
+ """
70
+ Configure edge subclasses with relationship properties.
71
+
72
+ Args:
73
+ directed: Whether the relationship is directional. If False,
74
+ creates a bidirectional relationship where in/out
75
+ order doesn't matter.
76
+ **kwargs: Additional configuration passed to parent
77
+
78
+ Raises:
79
+ TypeError: If the subclass doesn't define both 'in_' and 'out_' fields
80
+ """
81
+ super().__init_subclass__(**kwargs)
82
+
83
+ # Get type hints for the subclass
84
+ hints = get_type_hints(cls)
85
+
86
+ # Check if required fields are annotated
87
+ required_fields = {'in_', 'out_'}
88
+ missing_fields = required_fields - set(hints.keys())
89
+ if missing_fields:
90
+ raise TypeError(
91
+ f"Class {cls.__name__} must have field annotations for: {missing_fields}"
92
+ )
93
+
94
+ cls._directed = directed
95
+
96
+
97
+ @classmethod
98
+ def __pydantic_init_subclass__(cls, **kwargs):
99
+ relation = None
100
+ in_, out_ = cls.model_fields.pop('in_', None), cls.model_fields.pop('out_', None)
101
+ if in_ or out_:
102
+ if in_ and out_:
103
+ assert in_.annotation
104
+ assert out_.annotation
105
+ assert all(issubclass(t, Node) for t in flatten_type(in_.annotation))
106
+ assert all(issubclass(t, Node) for t in flatten_type(out_.annotation))
107
+ relation = Link(_in=in_.annotation, _out=out_.annotation, symetric=not cls._directed)
108
+ cls.is_relation = True
109
+ else:
110
+ raise Exception("You need to specify both `_in` and `_out` to define a relation")
111
+ else:
112
+ raise Exception("You need to specify `_in` and `_out` to define an Edge")
113
+ super().__pydantic_init_subclass__(relation=relation, **kwargs)
114
+ # need to add those back for validation at instanciation
115
+ # otherwise, the field is skipped
116
+ in_.annotation = replace_type(in_.annotation, Base, Union[Base, RecordID])
117
+ out_.annotation = replace_type(out_.annotation, Base, Union[Base, RecordID])
118
+ cls.model_fields["in_"] = in_
119
+ cls.model_fields["out_"] = out_
120
+ cls.model_rebuild(force=True)
121
+
122
+
123
+
124
+ def db_dump(self) -> dict[str, Any]:
125
+ """
126
+ Serialize the edge instance for database insertion.
127
+
128
+ Converts Python field names to SurrealDB format by renaming
129
+ 'in_' to 'in' and 'out_' to 'out'.
130
+
131
+ Returns:
132
+ dict[str, Any]: Dictionary ready for SurrealDB relation operations
133
+ """
134
+ dump = super().db_dump()
135
+ dump["in"] = dump.pop("in_")
136
+ dump["out"] = dump.pop("out_")
137
+ return dump
138
+
139
+
140
+ async def relate(
141
+ self,
142
+ db: AsyncWsSurrealConnection | AsyncHttpSurrealConnection
143
+ ) -> Self:
144
+ """
145
+ Create a relationship record in the database.
146
+
147
+ Creates an edge record connecting two nodes in SurrealDB.
148
+ This is the primary method for creating relationships.
149
+
150
+ Args:
151
+ db: Active SurrealDB connection (WebSocket or HTTP)
152
+
153
+ Returns:
154
+ Self: The same instance with ID assigned
155
+
156
+ Example:
157
+ >>> person = await Person.create(db)
158
+ >>> role = await Role.create(db)
159
+ >>> belongs = BelongsTo(
160
+ ... in_=person,
161
+ ... out_=role,
162
+ ... begin_date=date.today()
163
+ ... )
164
+ >>> await belongs.relate(db)
165
+ >>> print(belongs.id) # Has an ID like belongs_to:xyz
166
+
167
+ Raises:
168
+ Exception: If called on a non-relation table
169
+
170
+ Notes:
171
+ - Both in_ and out_ nodes must exist in the database
172
+ - Creates a directed or bidirectional edge based on class configuration
173
+ - The edge record gets a unique ID from SurrealDB
174
+ """
175
+ if not self.is_relation:
176
+ raise Exception("You should use .create() to create a record")
177
+ thing = self.__class__.__name__.lower()
178
+ relation = await db.insert_relation(
179
+ thing,
180
+ self.db_dump()
181
+ )
182
+ if isinstance(relation, list):
183
+ self.id = relation[0]["id"]
184
+ else:
185
+ self.id = relation["id"]
186
+ return self
187
+
188
+
189
+ @classmethod
190
+ async def insert(
191
+ cls,
192
+ db: AsyncWsSurrealConnection | AsyncHttpSurrealConnection, others: Iterable[Self]
193
+ ) -> list[Self]:
194
+ """
195
+ Batch insert multiple edge records into the database.
196
+
197
+ Efficiently creates multiple relationships in a single operation.
198
+ All edge instances are updated with their assigned IDs.
199
+
200
+ Args:
201
+ db: Active SurrealDB connection (WebSocket or HTTP)
202
+ others: Iterable of edge instances to insert
203
+
204
+ Returns:
205
+ list[Self]: The same instances with IDs assigned
206
+
207
+ Example:
208
+ >>> relationships = [
209
+ ... BelongsTo(in_=person1, out_=role1, begin_date=date(2020, 1, 1)),
210
+ ... BelongsTo(in_=person2, out_=role2, begin_date=date(2021, 1, 1)),
211
+ ... ]
212
+ >>> inserted = await BelongsTo.insert(db, relationships)
213
+ >>> # All relationships now have IDs
214
+
215
+ Notes:
216
+ - More efficient than multiple relate() calls
217
+ - All edges are inserted in a single transaction
218
+ - Original instances are modified with IDs
219
+ """
220
+ others = list(others)
221
+ inserted = await db.insert_relation(
222
+ cls.__name__.lower(),
223
+ [other.db_dump() for other in others]
224
+ )
225
+ for insert, original in zip(inserted, others):
226
+ original.id = insert["id"]
227
+ return others
228
+
229
+ @classmethod
230
+ def where(cls, condition):
231
+ """
232
+ Add a WHERE condition to this edge for use in graph traversals.
233
+
234
+ Creates a conditional edge that can be used in query traversals
235
+ to filter relationships based on their properties.
236
+
237
+ Args:
238
+ condition: A condition expression that filters edge records
239
+
240
+ Returns:
241
+ EdgeWithCondition: A wrapped edge class with the condition attached
242
+
243
+ Example:
244
+ >>> # Find all people who belong to roles that started after 2020
245
+ >>> query = (Q(Person) >>
246
+ ... BelongsTo.where(BelongsTo.begin_date > date(2020, 1, 1)) >>
247
+ ... Role)
248
+ >>> results = await query.execute(db)
249
+
250
+ Notes:
251
+ - Used primarily in graph traversal queries
252
+ - Conditions are applied during traversal, not at definition
253
+ - Can filter based on any edge properties
254
+ """
255
+
256
+ # Create a new class-like object that wraps the Edge with a condition
257
+ class EdgeWithCondition:
258
+ def __init__(self, edge_class, condition):
259
+ self.edge_class = edge_class
260
+ self.condition = condition
261
+
262
+ def __repr__(self):
263
+ return f"{self.edge_class.__name__.lower()}[WHERE {self.condition.to_surreal()}]"
264
+
265
+ # Allow this to be used in traversals
266
+ def to_traversal(self, direction: Direction) -> Traversal:
267
+ traversal = Traversal(direction, self.edge_class)
268
+ traversal.where_condition = self.condition
269
+ return traversal
270
+
271
+ return EdgeWithCondition(cls, condition)
tapestry/edge.pyi ADDED
@@ -0,0 +1,45 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC
4
+ from typing import ClassVar, Iterable, TypeVar, Type
5
+ from surrealdb import AsyncWsSurrealConnection, AsyncHttpSurrealConnection
6
+
7
+ from .base import Base
8
+ from .node import Node
9
+ from .field import Condition, Direction, Traversal
10
+
11
+ T = TypeVar('T', bound='Edge')
12
+
13
+ class EdgeWithCondition:
14
+ edge_class: Type[Edge]
15
+ condition: Condition
16
+
17
+ def __init__(self, edge_class: Type[Edge], condition: Condition) -> None: ...
18
+ def __repr__(self) -> str: ...
19
+ def to_traversal(self, direction: Direction) -> Traversal: ...
20
+
21
+ class Edge(Base, ABC):
22
+ _directed: ClassVar[bool]
23
+ is_relation: ClassVar[bool]
24
+
25
+ def __init_subclass__(cls, directed: bool = True, **kwargs: object) -> None: ...
26
+
27
+ @classmethod
28
+ def __pydantic_init_subclass__(cls, **kwargs: object) -> None: ...
29
+
30
+ def db_dump(self) -> dict[str, object]: ...
31
+
32
+ async def relate(
33
+ self: T,
34
+ db: AsyncWsSurrealConnection | AsyncHttpSurrealConnection
35
+ ) -> T: ...
36
+
37
+ @classmethod
38
+ async def insert(
39
+ cls: type[T],
40
+ db: AsyncWsSurrealConnection | AsyncHttpSurrealConnection,
41
+ others: Iterable[T]
42
+ ) -> list[T]: ...
43
+
44
+ @classmethod
45
+ def where(cls: type[T], condition: Condition) -> EdgeWithCondition: ...
tapestry/engine.py ADDED
@@ -0,0 +1,301 @@
1
+ import asyncio
2
+ import logging
3
+ import contextlib
4
+
5
+ from collections.abc import AsyncIterator, AsyncGenerator
6
+ from surrealdb import AsyncSurreal, AsyncWsSurrealConnection, AsyncHttpSurrealConnection
7
+
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ class SurrealClientPool:
12
+ """
13
+ Connection pool manager for SurrealDB clients.
14
+
15
+ Manages a pool of authenticated SurrealDB connections that can be
16
+ reused across async operations. This improves performance by avoiding
17
+ the overhead of creating new connections for each operation.
18
+
19
+ Attributes:
20
+ url: SurrealDB connection URL
21
+ auth_payload: Authentication credentials dictionary
22
+ pool_size: Number of connections to maintain in the pool
23
+ connect_timeout: Timeout for connection attempts in seconds
24
+
25
+ Example:
26
+ >>> pool = SurrealClientPool(
27
+ ... url="ws://localhost:8000/rpc",
28
+ ... auth_payload={"username": "root", "password": "root"},
29
+ ... pool_size=5
30
+ ... )
31
+ >>> await pool.start()
32
+ >>> async with pool.acquire() as client:
33
+ ... await client.use("mydb", "myns")
34
+ ... results = await client.select("person")
35
+ """
36
+
37
+ def __init__(
38
+ self,
39
+ url: str,
40
+ auth_payload: dict,
41
+ pool_size: int = 4,
42
+ connect_timeout: float = 5.
43
+ ):
44
+ """
45
+ Initialize the connection pool.
46
+
47
+ Args:
48
+ url: SurrealDB connection URL (ws:// or http://)
49
+ auth_payload: Dictionary with authentication credentials
50
+ pool_size: Number of connections to maintain (default: 4)
51
+ connect_timeout: Connection timeout in seconds (default: 5.0)
52
+ """
53
+ self.url = url
54
+ self.auth_payload = auth_payload
55
+ self.pool_size = pool_size
56
+ self.connect_timeout = connect_timeout
57
+ self._queue: asyncio.Queue = asyncio.Queue()
58
+ self._clients = [] # keep references for shutdown
59
+ self._closed = False
60
+
61
+ async def _make_one(self):
62
+ """
63
+ Create and authenticate a single SurrealDB client.
64
+
65
+ Returns:
66
+ An authenticated AsyncSurreal client ready for use
67
+
68
+ Raises:
69
+ Exception: If connection or authentication fails
70
+ """
71
+ c = AsyncSurreal(self.url)
72
+ await c.signin(self.auth_payload)
73
+ return c
74
+
75
+
76
+ async def start(self):
77
+ """
78
+ Initialize the connection pool with authenticated clients.
79
+
80
+ Creates the specified number of clients, authenticates them,
81
+ and adds them to the pool queue for reuse.
82
+
83
+ Raises:
84
+ Exception: If unable to create the required number of clients
85
+ """
86
+ for i in range(self.pool_size):
87
+ try:
88
+ client = await self._make_one()
89
+ except Exception as e:
90
+ logger.exception("Failed to create surreal client #%d: %s", i, e)
91
+ # retry/backoff before giving up; here we re-raise after a small delay
92
+ await asyncio.sleep(0.5)
93
+ client = await self._make_one()
94
+ self._clients.append(client)
95
+ self._queue.put_nowait(client)
96
+ logger.info("SurrealDB pool started (%d clients)", len(self._clients))
97
+
98
+ async def close(self):
99
+ """
100
+ Close all connections and shut down the pool.
101
+
102
+ Drains the queue and closes all client connections gracefully.
103
+ This should be called when the application shuts down.
104
+ """
105
+ if self._closed:
106
+ return
107
+ self._closed = True
108
+ # drain queue so other tasks don't try to reuse
109
+ while not self._queue.empty():
110
+ try:
111
+ _ = self._queue.get_nowait()
112
+ self._queue.task_done()
113
+ except asyncio.QueueEmpty:
114
+ break
115
+ # close actual clients
116
+ for c in self._clients:
117
+ try:
118
+ await c.close()
119
+ except Exception:
120
+ logger.exception("Error closing surreal client")
121
+ self._clients.clear()
122
+ logger.info("SurrealDB pool closed")
123
+
124
+ @contextlib.asynccontextmanager
125
+ async def acquire(self) -> AsyncIterator[AsyncWsSurrealConnection | AsyncHttpSurrealConnection]:
126
+ """
127
+ Acquire a connection from the pool as a context manager.
128
+
129
+ Gets an authenticated client from the pool, yields it for use,
130
+ and automatically returns it to the pool when done.
131
+
132
+ Yields:
133
+ AsyncWsSurrealConnection | AsyncHttpSurrealConnection: An authenticated client
134
+
135
+ Example:
136
+ >>> async with pool.acquire() as client:
137
+ ... results = await client.select("person")
138
+
139
+ Raises:
140
+ Exception: If the acquired client encounters a fatal error
141
+ """
142
+ client = await self._queue.get()
143
+ try:
144
+ yield client
145
+ except Exception as exc:
146
+ # If the client had a fatal error, attempt to reconnect/replace it
147
+ # Simple strategy: if exception looks like connection/auth error -> recreate
148
+ if self._is_fatal_client_exc(exc):
149
+ logger.warning("Client had fatal error, replacing: %s", exc)
150
+ await self._replace_client(client)
151
+ # re-raise so caller can handle retry logic
152
+ raise
153
+ else:
154
+ raise
155
+ finally:
156
+ # If pool is closing, don't put back
157
+ if not self._closed:
158
+ self._queue.put_nowait(client)
159
+
160
+ async def run_query(self, *args, retries: int = 1, backoff: float = 0.2, **kwargs):
161
+ """
162
+ Execute a query using a pooled connection with automatic retry.
163
+
164
+ Acquires a client from the pool, executes the query, and handles
165
+ retries for transient failures.
166
+
167
+ Args:
168
+ *args: Arguments to pass to the query method
169
+ retries: Number of retry attempts for transient errors (default: 1)
170
+ backoff: Base backoff time in seconds between retries (default: 0.2)
171
+ **kwargs: Keyword arguments to pass to the query method
172
+
173
+ Returns:
174
+ Query results from SurrealDB
175
+
176
+ Raises:
177
+ Exception: If the query fails after all retry attempts
178
+ """
179
+ for attempt in range(retries + 1):
180
+ async with self.acquire() as client:
181
+ try:
182
+ # Replace with client's actual query/call method
183
+ return await client.query(*args, **kwargs)
184
+ except Exception as exc:
185
+ logger.exception("Query failed on attempt %d: %s", attempt, exc)
186
+ if attempt < retries and self._is_retryable(exc):
187
+ await asyncio.sleep(backoff * (2 ** attempt))
188
+ continue
189
+ # If fatal to client, replace it so pool remains healthy
190
+ if self._is_fatal_client_exc(exc):
191
+ await self._replace_client(client)
192
+ raise
193
+
194
+ async def _replace_client(self, dead_client):
195
+ """
196
+ Replace a failed client with a new authenticated connection.
197
+
198
+ Closes the dead client, creates a new one, and adds it back to the pool
199
+ to maintain the pool size.
200
+
201
+ Args:
202
+ dead_client: The client connection that failed
203
+ """
204
+ try:
205
+ await dead_client.close()
206
+ except Exception:
207
+ pass
208
+ try:
209
+ new_client = await self._make_one()
210
+ except Exception as e:
211
+ logger.exception("Failed to create replacement client: %s", e)
212
+ # If we can't create a replacement immediately, put back the old one to avoid starvation.
213
+ # In production, you might want to implement more sophisticated retry/backoff + alerts.
214
+ self._queue.put_nowait(dead_client)
215
+ return
216
+ # replace in internal list
217
+ try:
218
+ idx = self._clients.index(dead_client)
219
+ self._clients[idx] = new_client
220
+ except ValueError:
221
+ self._clients.append(new_client)
222
+ self._queue.put_nowait(new_client)
223
+
224
+ def _is_retryable(self, exc: Exception) -> bool:
225
+ """
226
+ Determine if an exception is retryable.
227
+
228
+ Args:
229
+ exc: The exception to check
230
+
231
+ Returns:
232
+ bool: True if the error is transient and worth retrying
233
+ """
234
+ return isinstance(exc, (asyncio.TimeoutError, ConnectionError))
235
+
236
+ def _is_fatal_client_exc(self, exc: Exception) -> bool:
237
+ """
238
+ Determine if an exception indicates a broken client connection.
239
+
240
+ Args:
241
+ exc: The exception to check
242
+
243
+ Returns:
244
+ bool: True if the client connection is likely broken and needs replacement
245
+ """
246
+ return isinstance(exc, (ConnectionError, RuntimeError))
247
+
248
+
249
+
250
+ def create_engine(
251
+ url: str,
252
+ auth_payload: dict,
253
+ pool_size: int = 4,
254
+ connect_timeout: float = 5.
255
+ ) -> AsyncGenerator[AsyncWsSurrealConnection | AsyncHttpSurrealConnection, None]:
256
+ """
257
+ Create a connection pool for SurrealDB with automatic connection management.
258
+
259
+ This function creates a pool of authenticated SurrealDB connections that can
260
+ be reused across your application. It returns a context manager that handles
261
+ acquiring and releasing connections automatically.
262
+
263
+ Args:
264
+ url: SurrealDB connection URL (e.g., "ws://localhost:8000/rpc")
265
+ auth_payload: Dictionary with authentication credentials
266
+ Example: {"username": "root", "password": "root"}
267
+ pool_size: Number of connections to maintain in the pool (default: 4)
268
+ connect_timeout: Timeout for connection attempts in seconds (default: 5.0)
269
+
270
+ Returns:
271
+ AsyncGenerator: A context manager for acquiring connections from the pool
272
+
273
+ Example:
274
+ >>> # Create an engine
275
+ >>> engine = create_engine(
276
+ ... "ws://localhost:8000/rpc",
277
+ ... {"username": "root", "password": "root"},
278
+ ... pool_size=5
279
+ ... )
280
+ >>>
281
+ >>> # Use the engine to get connections
282
+ >>> async with engine() as db:
283
+ ... await db.use("mydatabase", "mynamespace")
284
+ ... people = await Person.insert(db, [
285
+ ... Person(name="Alice"),
286
+ ... Person(name="Bob")
287
+ ... ])
288
+
289
+ Notes:
290
+ - The pool is not started automatically; you need to call pool.start()
291
+ - Connections are reused for better performance
292
+ - Failed connections are automatically replaced
293
+ - The pool should be closed when your application shuts down
294
+
295
+ See Also:
296
+ - SurrealClientPool: The underlying pool implementation
297
+ - Node.insert(): For batch operations using pooled connections
298
+ - Edge.relate(): For creating relationships using pooled connections
299
+ """
300
+ pool = SurrealClientPool(url, auth_payload, pool_size, connect_timeout)
301
+ return pool.acquire