rushdb 2.6.0__tar.gz → 2.9.0__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.
Files changed (42) hide show
  1. {rushdb-2.6.0 → rushdb-2.9.0}/PKG-INFO +23 -1
  2. {rushdb-2.6.0 → rushdb-2.9.0}/README.md +22 -0
  3. {rushdb-2.6.0 → rushdb-2.9.0}/pyproject.toml +1 -1
  4. {rushdb-2.6.0 → rushdb-2.9.0}/src/rushdb/__init__.py +6 -0
  5. {rushdb-2.6.0 → rushdb-2.9.0}/src/rushdb/api/transactions.py +2 -1
  6. {rushdb-2.6.0 → rushdb-2.9.0}/src/rushdb/client.py +14 -3
  7. rushdb-2.9.0/src/rushdb/common.py +33 -0
  8. {rushdb-2.6.0 → rushdb-2.9.0}/src/rushdb/models/__init__.py +6 -0
  9. {rushdb-2.6.0 → rushdb-2.9.0}/src/rushdb/models/result.py +7 -2
  10. rushdb-2.9.0/src/rushdb/models/search_query.py +93 -0
  11. {rushdb-2.6.0 → rushdb-2.9.0}/tests/test_create_import.py +9 -3
  12. {rushdb-2.6.0 → rushdb-2.9.0}/tests/test_search_query.py +224 -0
  13. {rushdb-2.6.0 → rushdb-2.9.0}/uv.lock +49 -49
  14. rushdb-2.6.0/src/rushdb/common.py +0 -20
  15. rushdb-2.6.0/src/rushdb/models/search_query.py +0 -37
  16. {rushdb-2.6.0 → rushdb-2.9.0}/.env.example +0 -0
  17. {rushdb-2.6.0 → rushdb-2.9.0}/.github/workflows/ci.yml +0 -0
  18. {rushdb-2.6.0 → rushdb-2.9.0}/.gitignore +0 -0
  19. {rushdb-2.6.0 → rushdb-2.9.0}/CODE_OF_CONDUCT.md +0 -0
  20. {rushdb-2.6.0 → rushdb-2.9.0}/CONTRIBUTING.md +0 -0
  21. {rushdb-2.6.0 → rushdb-2.9.0}/LICENSE +0 -0
  22. {rushdb-2.6.0 → rushdb-2.9.0}/rushdb-logo.svg +0 -0
  23. {rushdb-2.6.0 → rushdb-2.9.0}/src/__init__.py +0 -0
  24. {rushdb-2.6.0 → rushdb-2.9.0}/src/rushdb/api/__init__.py +0 -0
  25. {rushdb-2.6.0 → rushdb-2.9.0}/src/rushdb/api/ai.py +0 -0
  26. {rushdb-2.6.0 → rushdb-2.9.0}/src/rushdb/api/base.py +0 -0
  27. {rushdb-2.6.0 → rushdb-2.9.0}/src/rushdb/api/labels.py +0 -0
  28. {rushdb-2.6.0 → rushdb-2.9.0}/src/rushdb/api/properties.py +0 -0
  29. {rushdb-2.6.0 → rushdb-2.9.0}/src/rushdb/api/query.py +0 -0
  30. {rushdb-2.6.0 → rushdb-2.9.0}/src/rushdb/api/records.py +0 -0
  31. {rushdb-2.6.0 → rushdb-2.9.0}/src/rushdb/api/relationship_patterns.py +0 -0
  32. {rushdb-2.6.0 → rushdb-2.9.0}/src/rushdb/api/relationships.py +0 -0
  33. {rushdb-2.6.0 → rushdb-2.9.0}/src/rushdb/models/api_response.py +0 -0
  34. {rushdb-2.6.0 → rushdb-2.9.0}/src/rushdb/models/property.py +0 -0
  35. {rushdb-2.6.0 → rushdb-2.9.0}/src/rushdb/models/record.py +0 -0
  36. {rushdb-2.6.0 → rushdb-2.9.0}/src/rushdb/models/relationship.py +0 -0
  37. {rushdb-2.6.0 → rushdb-2.9.0}/src/rushdb/models/transaction.py +0 -0
  38. {rushdb-2.6.0 → rushdb-2.9.0}/src/rushdb/utils/token_prefix.py +0 -0
  39. {rushdb-2.6.0 → rushdb-2.9.0}/tests/__init__.py +0 -0
  40. {rushdb-2.6.0 → rushdb-2.9.0}/tests/test_base_setup.py +0 -0
  41. {rushdb-2.6.0 → rushdb-2.9.0}/tests/test_relationship_patterns.py +0 -0
  42. {rushdb-2.6.0 → rushdb-2.9.0}/tests/test_search_result.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rushdb
3
- Version: 2.6.0
3
+ Version: 2.9.0
4
4
  Summary: RushDB Python SDK — memory layer for AI agents and modern apps
5
5
  Project-URL: Homepage, https://github.com/rush-db/rushdb-python
6
6
  Project-URL: Repository, https://github.com/rush-db/rushdb-python
@@ -135,6 +135,28 @@ authored_posts = db.records.find({
135
135
  'limit': 10,
136
136
  })
137
137
 
138
+ # Multi-hop: add hops to $relation — everyone in Alice's reporting chain, up to 4 levels
139
+ chain = db.records.find({
140
+ 'labels': ['EMPLOYEE'],
141
+ 'where': {
142
+ 'EMPLOYEE': {
143
+ '$relation': {'type': 'REPORTS_TO', 'direction': 'out', 'hops': {'min': 1, 'max': 4}},
144
+ 'name': {'$contains': 'Alice'},
145
+ }
146
+ },
147
+ })
148
+
149
+ # Cycle detection: accounts on a circular transfer ring (fraud rings, circular ownership)
150
+ ring_members = db.records.find({
151
+ 'labels': ['ACCOUNT'],
152
+ 'where': {
153
+ 'RING': { # key is a display name — the $cycle block holds only $relation
154
+ '$cycle': True,
155
+ '$relation': {'type': 'TRANSFERRED_TO', 'direction': 'out', 'hops': {'min': 2, 'max': 6}},
156
+ }
157
+ },
158
+ })
159
+
138
160
  # Manage relationships explicitly
139
161
  user = db.records.find_uniq({'labels': ['USER'], 'where': {'name': 'Alice'}})
140
162
  company = db.records.find_uniq({'labels': ['COMPANY'], 'where': {'name': 'Acme Corp'}})
@@ -110,6 +110,28 @@ authored_posts = db.records.find({
110
110
  'limit': 10,
111
111
  })
112
112
 
113
+ # Multi-hop: add hops to $relation — everyone in Alice's reporting chain, up to 4 levels
114
+ chain = db.records.find({
115
+ 'labels': ['EMPLOYEE'],
116
+ 'where': {
117
+ 'EMPLOYEE': {
118
+ '$relation': {'type': 'REPORTS_TO', 'direction': 'out', 'hops': {'min': 1, 'max': 4}},
119
+ 'name': {'$contains': 'Alice'},
120
+ }
121
+ },
122
+ })
123
+
124
+ # Cycle detection: accounts on a circular transfer ring (fraud rings, circular ownership)
125
+ ring_members = db.records.find({
126
+ 'labels': ['ACCOUNT'],
127
+ 'where': {
128
+ 'RING': { # key is a display name — the $cycle block holds only $relation
129
+ '$cycle': True,
130
+ '$relation': {'type': 'TRANSFERRED_TO', 'direction': 'out', 'hops': {'min': 2, 'max': 6}},
131
+ }
132
+ },
133
+ })
134
+
113
135
  # Manage relationships explicitly
114
136
  user = db.records.find_uniq({'labels': ['USER'], 'where': {'name': 'Alice'}})
115
137
  company = db.records.find_uniq({'labels': ['COMPANY'], 'where': {'name': 'Acme Corp'}})
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "rushdb"
3
- version = "2.6.0"
3
+ version = "2.9.0"
4
4
  description = "RushDB Python SDK — memory layer for AI agents and modern apps"
5
5
  authors = [
6
6
  {name = "RushDB Team", email = "hi@rushdb.com"}
@@ -24,6 +24,9 @@ from .models.search_query import (
24
24
  RelationshipEndpointQuery,
25
25
  RelationshipSearchQuery,
26
26
  SearchQuery,
27
+ TraversalHops,
28
+ TraversalHopsRange,
29
+ TraversalRelationOptions,
27
30
  )
28
31
  from .models.transaction import Transaction
29
32
 
@@ -46,6 +49,9 @@ __all__ = [
46
49
  "SearchQuery",
47
50
  "RelationshipEndpointQuery",
48
51
  "RelationshipSearchQuery",
52
+ "TraversalHops",
53
+ "TraversalHopsRange",
54
+ "TraversalRelationOptions",
49
55
  "QueryAPI",
50
56
  "RelationsAPI",
51
57
  "RelationshipPatternsAPI",
@@ -51,7 +51,8 @@ class TransactionsAPI(BaseAPI):
51
51
  Args:
52
52
  ttl (Optional[int], optional): Time-to-live in milliseconds for the transaction.
53
53
  After this time, the transaction will automatically expire and be rolled back.
54
- If None, defaults to 5000ms (5 seconds). Defaults to None.
54
+ If None, defaults to 5000ms (5 seconds). Values above the server-side cap
55
+ (60 seconds) are clamped to it. Defaults to None.
55
56
 
56
57
  Returns:
57
58
  Transaction: A Transaction object that can be used with other API operations.
@@ -144,7 +144,9 @@ class RushDB:
144
144
  else None
145
145
  )
146
146
  effective_url = url or base_url or self.DEFAULT_BASE_URL
147
- if url and "/api/" not in effective_url:
147
+ # Both `url` and its deprecated alias `base_url` are documented to get
148
+ # `/api/v1` appended when the path is absent.
149
+ if (url or base_url) and "/api/" not in effective_url:
148
150
  effective_url = effective_url.rstrip("/") + "/api/v1"
149
151
  self.base_url = effective_url.rstrip("/")
150
152
  self.api_key = api_key
@@ -245,8 +247,17 @@ class RushDB:
245
247
  with urllib.request.urlopen(request) as response:
246
248
  return json.loads(response.read().decode("utf-8"))
247
249
  except urllib.error.HTTPError as e:
248
- error_body = json.loads(e.read().decode("utf-8"))
249
- raise RushDBError(error_body.get("message", str(e)), error_body)
250
+ # Error bodies are usually JSON, but a gateway/proxy may return HTML or an
251
+ # empty body — without this guard the JSONDecodeError raised here would
252
+ # escape as-is instead of surfacing as a RushDBError.
253
+ try:
254
+ error_body = json.loads(e.read().decode("utf-8"))
255
+ except (json.JSONDecodeError, UnicodeDecodeError):
256
+ error_body = {}
257
+ message = error_body.get("message")
258
+ if isinstance(message, list):
259
+ message = "; ".join(str(item) for item in message)
260
+ raise RushDBError(message or f"HTTP {e.code}", error_body, status=e.code)
250
261
  except urllib.error.URLError as e:
251
262
  raise RushDBError(f"Connection error: {str(e)}")
252
263
  except json.JSONDecodeError as e:
@@ -0,0 +1,33 @@
1
+ from typing import Dict, Optional
2
+
3
+
4
+ class RushDBError(Exception):
5
+ """Custom exception for RushDB client errors.
6
+
7
+ Attributes:
8
+ details: Parsed error response body from the server, if any.
9
+ status: HTTP status code for server errors (e.g. 403 for a write attempt
10
+ with a read-only API key, 408 for a server-side transaction timeout).
11
+ ``None`` for client-side errors such as connection failures.
12
+ """
13
+
14
+ def __init__(
15
+ self,
16
+ message: str,
17
+ details: Optional[Dict] = None,
18
+ status: Optional[int] = None,
19
+ ):
20
+ super().__init__(message)
21
+ self.details = details or {}
22
+ self.status = status
23
+
24
+
25
+ class NonUniqueResultError(Exception):
26
+ """Raised by ``records.find_uniq()`` when more than one record matches the query."""
27
+
28
+ def __init__(self, count: int):
29
+ super().__init__(
30
+ f"Expected at most one record but found {count}. "
31
+ "Refine your search query or use records.find() instead."
32
+ )
33
+ self.count = count
@@ -11,6 +11,9 @@ from .search_query import (
11
11
  RelationshipEndpointQuery,
12
12
  RelationshipSearchQuery,
13
13
  SearchQuery,
14
+ TraversalHops,
15
+ TraversalHopsRange,
16
+ TraversalRelationOptions,
14
17
  )
15
18
  from .transaction import Transaction
16
19
 
@@ -26,5 +29,8 @@ __all__ = [
26
29
  "SearchQuery",
27
30
  "RelationshipEndpointQuery",
28
31
  "RelationshipSearchQuery",
32
+ "TraversalHops",
33
+ "TraversalHopsRange",
34
+ "TraversalRelationOptions",
29
35
  "Transaction",
30
36
  ]
@@ -246,5 +246,10 @@ class SearchResult(Generic[T]):
246
246
  return pd.DataFrame(rows)
247
247
 
248
248
 
249
- # Type alias for record search results
250
- RecordSearchResult = SearchResult[Record]
249
+ class RecordSearchResult(SearchResult[Record]):
250
+ """Search result specialized for ``Record`` items.
251
+
252
+ A real subclass (not a ``SearchResult[Record]`` alias) so that
253
+ ``isinstance(result, RecordSearchResult)`` works — isinstance checks against
254
+ subscripted generics raise ``TypeError`` at runtime.
255
+ """
@@ -0,0 +1,93 @@
1
+ from enum import Enum
2
+ from typing import Any, Dict, List, Optional, TypedDict, Union
3
+
4
+
5
+ class OrderDirection(str, Enum):
6
+ ASC = "asc"
7
+ DESC = "desc"
8
+
9
+
10
+ class TraversalHopsRange(TypedDict, total=False):
11
+ """Hop range for variable-length traversal.
12
+
13
+ ``min`` defaults to 1 (2 inside a ``$cycle`` block). Omitting ``max``
14
+ requests unbounded traversal, which is only allowed on self-hosted
15
+ deployments and projects with a custom Neo4j instance; the shared cloud
16
+ connection caps ``max`` per deployment (default 25).
17
+ """
18
+
19
+ min: int
20
+ max: int
21
+
22
+
23
+ TraversalHops = Union[int, TraversalHopsRange]
24
+ """Depth of a variable-length traversal: an exact hop count or a range."""
25
+
26
+
27
+ class TraversalRelationOptions(TypedDict, total=False):
28
+ """Value for the ``$relation`` key of a traversal block in ``where``.
29
+
30
+ ``type`` and ``direction`` constrain every hop; ``hops`` makes the
31
+ traversal variable-length. The nested label constrains only the final
32
+ record — intermediate records are anonymous.
33
+
34
+ Example::
35
+
36
+ {
37
+ "labels": ["EMPLOYEE"],
38
+ "where": {
39
+ "EMPLOYEE": {
40
+ "$alias": "$manager",
41
+ "$relation": {
42
+ "type": "REPORTS_TO",
43
+ "direction": "out",
44
+ "hops": {"min": 1, "max": 4},
45
+ },
46
+ "name": {"$contains": "Alice"},
47
+ }
48
+ },
49
+ }
50
+ """
51
+
52
+ type: str
53
+ direction: str # 'in' | 'out'
54
+ hops: TraversalHops
55
+
56
+
57
+ class SearchQuery(TypedDict, total=False):
58
+ """TypedDict representing the query structure for finding records.
59
+
60
+ Inside ``where``, a nested key that is a label name traverses to related
61
+ records. Traversal blocks accept ``$alias`` (name the endpoint for
62
+ ``select``/``groupBy``), ``$relation`` (a type string or
63
+ :class:`TraversalRelationOptions`, including variable-length ``hops``),
64
+ and ``$cycle`` (``True`` binds the traversal back to the parent record to
65
+ detect rings; requires ``$relation`` with ``hops`` where ``min`` >= 2 and
66
+ accepts no other keys).
67
+ """
68
+
69
+ where: Optional[Dict[str, Any]]
70
+ labels: Optional[List[str]]
71
+ skip: Optional[int]
72
+ limit: Optional[int]
73
+ orderBy: Optional[Union[Dict[str, OrderDirection], OrderDirection]]
74
+ select: Optional[Dict[str, Any]]
75
+ groupBy: Optional[List[str]]
76
+
77
+
78
+ class RelationshipEndpointQuery(TypedDict, total=False):
79
+ """Endpoint record predicates for relationship search."""
80
+
81
+ where: Optional[Dict[str, Any]]
82
+ labels: Optional[List[str]]
83
+
84
+
85
+ class RelationshipSearchQuery(TypedDict, total=False):
86
+ """TypedDict representing the query structure for finding relationship edges."""
87
+
88
+ where: Optional[Dict[str, Any]]
89
+ source: Optional[RelationshipEndpointQuery]
90
+ target: Optional[RelationshipEndpointQuery]
91
+ skip: Optional[int]
92
+ limit: Optional[int]
93
+ orderBy: Optional[Union[Dict[str, OrderDirection], OrderDirection]]
@@ -40,7 +40,7 @@ class TestCreateImport(TestBase):
40
40
  # Test new functionality
41
41
  self.assertEqual(record.get("name"), "Google LLC")
42
42
  self.assertEqual(record.get("nonexistent", "default"), "default")
43
- self.assertTrue(record.exists())
43
+ self.assertTrue(record.exists)
44
44
 
45
45
  def test_record_methods(self):
46
46
  """Test Record class methods"""
@@ -201,7 +201,11 @@ class TestCreateImport(TestBase):
201
201
  )
202
202
 
203
203
  def test_create_with_nested_data(self):
204
- """Test creating records with nested data structure"""
204
+ """Test importing records with nested data structure.
205
+
206
+ ``create_many`` accepts flat rows only (it raises ``ValueError`` for
207
+ nested payloads); nested JSON goes through ``import_json``.
208
+ """
205
209
  data = {
206
210
  "name": "Meta Platforms Inc",
207
211
  "rating": 4.6,
@@ -220,7 +224,9 @@ class TestCreateImport(TestBase):
220
224
  }
221
225
  ],
222
226
  }
223
- self.client.records.create_many("COMPANY", data)
227
+ with self.assertRaises(ValueError):
228
+ self.client.records.create_many("COMPANY", data)
229
+ self.client.records.import_json(data=data, label="COMPANY")
224
230
 
225
231
  def test_transaction_rollback(self):
226
232
  """Test transaction rollback"""
@@ -1,6 +1,10 @@
1
1
  """Test cases for RushDB search query functionality."""
2
2
 
3
+ import time
3
4
  import unittest
5
+ import uuid
6
+
7
+ from src.rushdb import RushDB, RushDBError
4
8
 
5
9
  from .test_base_setup import TestBase
6
10
 
@@ -389,5 +393,225 @@ class TestSearchQuery(TestBase):
389
393
  self.client.records.find(query)
390
394
 
391
395
 
396
+ class TestMultihopAndCycles(TestBase):
397
+ """Variable-length traversal ($relation.hops) and cycle detection ($cycle).
398
+
399
+ Seeds its own graph, isolated by a unique tenantId:
400
+
401
+ Reporting chain (MHEmployee, REPORTS_TO, directed "up"):
402
+ E1 -> E2 -> E3 -> E4
403
+
404
+ Transfer ring + linear chain (MHAccount, TRANSFERRED_TO):
405
+ A -> B -> C -> A (3-hop directed ring)
406
+ X -> Y -> Z (no cycle)
407
+ """
408
+
409
+ tenant: str
410
+
411
+ @classmethod
412
+ def setUpClass(cls):
413
+ super().setUpClass()
414
+ cls.tenant = f"multihop-{uuid.uuid4().hex[:8]}"
415
+ # TestBase only creates a client per-test (setUp); seeding needs one here.
416
+ cls.client = RushDB(cls.token, base_url=cls.base_url)
417
+
418
+ # TestBase skips per-test in setUp when the server is unreachable; this
419
+ # class seeds data in setUpClass, so it must skip here for the same
420
+ # reason (e.g. CI without a running RushDB) instead of erroring.
421
+ try:
422
+ if not cls.client.ping():
423
+ raise unittest.SkipTest(
424
+ f"Could not connect to RushDB at {cls.base_url}"
425
+ )
426
+ except RushDBError as e:
427
+ raise unittest.SkipTest(f"RushDB connection error: {str(e)}")
428
+
429
+ cls.client.records.create_many(
430
+ "MHEmployee",
431
+ [
432
+ {"name": "E1", "managerName": "E2", "tenantId": cls.tenant},
433
+ {"name": "E2", "managerName": "E3", "tenantId": cls.tenant},
434
+ {"name": "E3", "managerName": "E4", "tenantId": cls.tenant},
435
+ {"name": "E4", "tenantId": cls.tenant},
436
+ ],
437
+ )
438
+ cls.client.relationships.create_many(
439
+ source={
440
+ "label": "MHEmployee",
441
+ "key": "managerName",
442
+ "where": {"tenantId": cls.tenant},
443
+ },
444
+ target={
445
+ "label": "MHEmployee",
446
+ "key": "name",
447
+ "where": {"tenantId": cls.tenant},
448
+ },
449
+ type="REPORTS_TO",
450
+ direction="out",
451
+ )
452
+
453
+ cls.client.records.create_many(
454
+ "MHAccount",
455
+ [
456
+ {"name": "A", "sendsTo": "B", "tenantId": cls.tenant},
457
+ {"name": "B", "sendsTo": "C", "tenantId": cls.tenant},
458
+ {"name": "C", "sendsTo": "A", "tenantId": cls.tenant},
459
+ {"name": "X", "sendsTo": "Y", "tenantId": cls.tenant},
460
+ {"name": "Y", "sendsTo": "Z", "tenantId": cls.tenant},
461
+ {"name": "Z", "tenantId": cls.tenant},
462
+ ],
463
+ )
464
+ cls.client.relationships.create_many(
465
+ source={
466
+ "label": "MHAccount",
467
+ "key": "sendsTo",
468
+ "where": {"tenantId": cls.tenant},
469
+ },
470
+ target={
471
+ "label": "MHAccount",
472
+ "key": "name",
473
+ "where": {"tenantId": cls.tenant},
474
+ },
475
+ type="TRANSFERRED_TO",
476
+ direction="out", # sender -> receiver
477
+ )
478
+
479
+ # relationships.create_many is applied via apoc.periodic.iterate — poll
480
+ # until both relationship sets are visible.
481
+ for _ in range(10):
482
+ employee_rels = cls.client.relationships.find(
483
+ {
484
+ "source": {
485
+ "labels": ["MHEmployee"],
486
+ "where": {"tenantId": cls.tenant},
487
+ },
488
+ "limit": 100,
489
+ }
490
+ )
491
+ account_rels = cls.client.relationships.find(
492
+ {
493
+ "source": {
494
+ "labels": ["MHAccount"],
495
+ "where": {"tenantId": cls.tenant},
496
+ },
497
+ "limit": 100,
498
+ }
499
+ )
500
+ if len(employee_rels.data) >= 3 and len(account_rels.data) >= 5:
501
+ break
502
+ time.sleep(1)
503
+
504
+ @classmethod
505
+ def tearDownClass(cls):
506
+ cls.client.records.delete({"where": {"tenantId": cls.tenant}})
507
+ super().tearDownClass()
508
+
509
+ def _names(self, result):
510
+ return sorted(record.get("name") for record in result)
511
+
512
+ def test_hops_range_reaches_chain_top(self):
513
+ """hops {max: 3} reaches E4 from every subordinate"""
514
+ result = self.client.records.find(
515
+ {
516
+ "labels": ["MHEmployee"],
517
+ "where": {
518
+ "tenantId": self.tenant,
519
+ "MHEmployee": {
520
+ "$relation": {
521
+ "type": "REPORTS_TO",
522
+ "direction": "out",
523
+ "hops": {"max": 3},
524
+ },
525
+ "name": "E4",
526
+ },
527
+ },
528
+ }
529
+ )
530
+ self.assertEqual(self._names(result), ["E1", "E2", "E3"])
531
+
532
+ def test_hops_range_is_bounded(self):
533
+ """hops {max: 2} does not reach 3 hops away"""
534
+ result = self.client.records.find(
535
+ {
536
+ "labels": ["MHEmployee"],
537
+ "where": {
538
+ "tenantId": self.tenant,
539
+ "MHEmployee": {
540
+ "$relation": {
541
+ "type": "REPORTS_TO",
542
+ "direction": "out",
543
+ "hops": {"max": 2},
544
+ },
545
+ "name": "E4",
546
+ },
547
+ },
548
+ }
549
+ )
550
+ self.assertEqual(self._names(result), ["E2", "E3"])
551
+
552
+ def test_hops_exact_count(self):
553
+ """hops: 3 matches exactly 3 hops"""
554
+ result = self.client.records.find(
555
+ {
556
+ "labels": ["MHEmployee"],
557
+ "where": {
558
+ "tenantId": self.tenant,
559
+ "MHEmployee": {
560
+ "$relation": {
561
+ "type": "REPORTS_TO",
562
+ "direction": "out",
563
+ "hops": 3,
564
+ },
565
+ "name": "E4",
566
+ },
567
+ },
568
+ }
569
+ )
570
+ self.assertEqual(self._names(result), ["E1"])
571
+
572
+ def test_cycle_flags_ring_members(self):
573
+ """$cycle returns exactly the ring participants with a deduplicated total"""
574
+ result = self.client.records.find(
575
+ {
576
+ "labels": ["MHAccount"],
577
+ "where": {
578
+ "tenantId": self.tenant,
579
+ "RING": {
580
+ "$cycle": True,
581
+ "$relation": {
582
+ "type": "TRANSFERRED_TO",
583
+ "direction": "out",
584
+ "hops": {"min": 2, "max": 6},
585
+ },
586
+ },
587
+ },
588
+ }
589
+ )
590
+ self.assertEqual(self._names(result), ["A", "B", "C"])
591
+ self.assertEqual(result.total, 3)
592
+
593
+ def test_not_cycle_excludes_ring_members(self):
594
+ """$not around a $cycle block finds acyclic accounts"""
595
+ result = self.client.records.find(
596
+ {
597
+ "labels": ["MHAccount"],
598
+ "where": {
599
+ "tenantId": self.tenant,
600
+ "$not": {
601
+ "RING": {
602
+ "$cycle": True,
603
+ "$relation": {
604
+ "type": "TRANSFERRED_TO",
605
+ "direction": "out",
606
+ "hops": {"min": 2, "max": 6},
607
+ },
608
+ }
609
+ },
610
+ },
611
+ }
612
+ )
613
+ self.assertEqual(self._names(result), ["X", "Y", "Z"])
614
+
615
+
392
616
  if __name__ == "__main__":
393
617
  unittest.main()
@@ -1,5 +1,5 @@
1
1
  version = 1
2
- revision = 3
2
+ revision = 2
3
3
  requires-python = ">=3.8"
4
4
  resolution-markers = [
5
5
  "python_full_version >= '3.10'",
@@ -15,13 +15,13 @@ resolution-markers = [
15
15
  "python_full_version < '3.9'",
16
16
  ]
17
17
  dependencies = [
18
- { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" } },
19
- { name = "mypy-extensions" },
20
- { name = "packaging" },
21
- { name = "pathspec" },
22
- { name = "platformdirs", version = "4.3.6", source = { registry = "https://pypi.org/simple" } },
23
- { name = "tomli" },
24
- { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" } },
18
+ { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" },
19
+ { name = "mypy-extensions", marker = "python_full_version < '3.9'" },
20
+ { name = "packaging", marker = "python_full_version < '3.9'" },
21
+ { name = "pathspec", marker = "python_full_version < '3.9'" },
22
+ { name = "platformdirs", version = "4.3.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" },
23
+ { name = "tomli", marker = "python_full_version < '3.9'" },
24
+ { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" },
25
25
  ]
26
26
  sdist = { url = "https://files.pythonhosted.org/packages/04/b0/46fb0d4e00372f4a86a6f8efa3cb193c9f64863615e39010b1477e010578/black-24.8.0.tar.gz", hash = "sha256:2500945420b6784c38b9ee885af039f5e7471ef284ab03fa35ecdde4688cd83f", size = 644810, upload-time = "2024-08-02T17:43:18.405Z" }
27
27
  wheels = [
@@ -57,14 +57,14 @@ resolution-markers = [
57
57
  "python_full_version == '3.9.*'",
58
58
  ]
59
59
  dependencies = [
60
- { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
60
+ { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" },
61
61
  { name = "click", version = "8.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
62
- { name = "mypy-extensions" },
63
- { name = "packaging" },
64
- { name = "pathspec" },
65
- { name = "platformdirs", version = "4.3.8", source = { registry = "https://pypi.org/simple" } },
66
- { name = "tomli", marker = "python_full_version < '3.11'" },
67
- { name = "typing-extensions", version = "4.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
62
+ { name = "mypy-extensions", marker = "python_full_version >= '3.9'" },
63
+ { name = "packaging", marker = "python_full_version >= '3.9'" },
64
+ { name = "pathspec", marker = "python_full_version >= '3.9'" },
65
+ { name = "platformdirs", version = "4.3.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" },
66
+ { name = "tomli", marker = "python_full_version >= '3.9' and python_full_version < '3.11'" },
67
+ { name = "typing-extensions", version = "4.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9' and python_full_version < '3.11'" },
68
68
  ]
69
69
  sdist = { url = "https://files.pythonhosted.org/packages/94/49/26a7b0f3f35da4b5a65f081943b7bcd22d7002f5f0fb8098ec1ff21cb6ef/black-25.1.0.tar.gz", hash = "sha256:33496d5cd1222ad73391352b4ae8da15253c5de89b93a80b3e2c8d9a19ec2666", size = 649449, upload-time = "2025-01-29T04:15:40.373Z" }
70
70
  wheels = [
@@ -196,7 +196,7 @@ resolution-markers = [
196
196
  "python_full_version < '3.9'",
197
197
  ]
198
198
  dependencies = [
199
- { name = "colorama", marker = "sys_platform == 'win32'" },
199
+ { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" },
200
200
  ]
201
201
  sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" }
202
202
  wheels = [
@@ -211,7 +211,7 @@ resolution-markers = [
211
211
  "python_full_version >= '3.10'",
212
212
  ]
213
213
  dependencies = [
214
- { name = "colorama", marker = "sys_platform == 'win32'" },
214
+ { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" },
215
215
  ]
216
216
  sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342, upload-time = "2025-05-20T23:19:49.832Z" }
217
217
  wheels = [
@@ -311,7 +311,7 @@ wheels = [
311
311
 
312
312
  [package.optional-dependencies]
313
313
  toml = [
314
- { name = "tomli" },
314
+ { name = "tomli", marker = "python_full_version < '3.9'" },
315
315
  ]
316
316
 
317
317
  [[package]]
@@ -394,7 +394,7 @@ wheels = [
394
394
 
395
395
  [package.optional-dependencies]
396
396
  toml = [
397
- { name = "tomli", marker = "python_full_version <= '3.11'" },
397
+ { name = "tomli", marker = "python_full_version >= '3.9' and python_full_version <= '3.11'" },
398
398
  ]
399
399
 
400
400
  [[package]]
@@ -402,8 +402,8 @@ name = "exceptiongroup"
402
402
  version = "1.3.0"
403
403
  source = { registry = "https://pypi.org/simple" }
404
404
  dependencies = [
405
- { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9' or python_full_version >= '3.11'" },
406
- { name = "typing-extensions", version = "4.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9' and python_full_version < '3.11'" },
405
+ { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" },
406
+ { name = "typing-extensions", version = "4.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9' and python_full_version < '3.13'" },
407
407
  ]
408
408
  sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" }
409
409
  wheels = [
@@ -461,9 +461,9 @@ resolution-markers = [
461
461
  "python_full_version < '3.9'",
462
462
  ]
463
463
  dependencies = [
464
- { name = "mypy-extensions" },
465
- { name = "tomli" },
466
- { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" } },
464
+ { name = "mypy-extensions", marker = "python_full_version < '3.9'" },
465
+ { name = "tomli", marker = "python_full_version < '3.9'" },
466
+ { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" },
467
467
  ]
468
468
  sdist = { url = "https://files.pythonhosted.org/packages/b9/eb/2c92d8ea1e684440f54fa49ac5d9a5f19967b7b472a281f419e69a8d228e/mypy-1.14.1.tar.gz", hash = "sha256:7ec88144fe9b510e8475ec2f5f251992690fcf89ccb4500b214b4226abcd32d6", size = 3216051, upload-time = "2024-12-30T16:39:07.335Z" }
469
469
  wheels = [
@@ -515,10 +515,10 @@ resolution-markers = [
515
515
  "python_full_version == '3.9.*'",
516
516
  ]
517
517
  dependencies = [
518
- { name = "mypy-extensions" },
519
- { name = "pathspec" },
520
- { name = "tomli", marker = "python_full_version < '3.11'" },
521
- { name = "typing-extensions", version = "4.14.0", source = { registry = "https://pypi.org/simple" } },
518
+ { name = "mypy-extensions", marker = "python_full_version >= '3.9'" },
519
+ { name = "pathspec", marker = "python_full_version >= '3.9'" },
520
+ { name = "tomli", marker = "python_full_version >= '3.9' and python_full_version < '3.11'" },
521
+ { name = "typing-extensions", version = "4.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" },
522
522
  ]
523
523
  sdist = { url = "https://files.pythonhosted.org/packages/81/69/92c7fa98112e4d9eb075a239caa4ef4649ad7d441545ccffbd5e34607cbb/mypy-1.16.1.tar.gz", hash = "sha256:6bd00a0a2094841c5e47e7374bb42b83d64c527a502e3334e1173a0c24437bab", size = 3324747, upload-time = "2025-06-16T16:51:35.145Z" }
524
524
  wheels = [
@@ -649,12 +649,12 @@ resolution-markers = [
649
649
  "python_full_version < '3.9'",
650
650
  ]
651
651
  dependencies = [
652
- { name = "colorama", marker = "sys_platform == 'win32'" },
653
- { name = "exceptiongroup" },
654
- { name = "iniconfig" },
655
- { name = "packaging" },
656
- { name = "pluggy", version = "1.5.0", source = { registry = "https://pypi.org/simple" } },
657
- { name = "tomli" },
652
+ { name = "colorama", marker = "python_full_version < '3.9' and sys_platform == 'win32'" },
653
+ { name = "exceptiongroup", marker = "python_full_version < '3.9'" },
654
+ { name = "iniconfig", marker = "python_full_version < '3.9'" },
655
+ { name = "packaging", marker = "python_full_version < '3.9'" },
656
+ { name = "pluggy", version = "1.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" },
657
+ { name = "tomli", marker = "python_full_version < '3.9'" },
658
658
  ]
659
659
  sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891, upload-time = "2025-03-02T12:54:54.503Z" }
660
660
  wheels = [
@@ -670,13 +670,13 @@ resolution-markers = [
670
670
  "python_full_version == '3.9.*'",
671
671
  ]
672
672
  dependencies = [
673
- { name = "colorama", marker = "sys_platform == 'win32'" },
674
- { name = "exceptiongroup", marker = "python_full_version < '3.11'" },
675
- { name = "iniconfig" },
676
- { name = "packaging" },
677
- { name = "pluggy", version = "1.6.0", source = { registry = "https://pypi.org/simple" } },
678
- { name = "pygments" },
679
- { name = "tomli", marker = "python_full_version < '3.11'" },
673
+ { name = "colorama", marker = "python_full_version >= '3.9' and sys_platform == 'win32'" },
674
+ { name = "exceptiongroup", marker = "python_full_version >= '3.9' and python_full_version < '3.11'" },
675
+ { name = "iniconfig", marker = "python_full_version >= '3.9'" },
676
+ { name = "packaging", marker = "python_full_version >= '3.9'" },
677
+ { name = "pluggy", version = "1.6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" },
678
+ { name = "pygments", marker = "python_full_version >= '3.9'" },
679
+ { name = "tomli", marker = "python_full_version >= '3.9' and python_full_version < '3.11'" },
680
680
  ]
681
681
  sdist = { url = "https://files.pythonhosted.org/packages/08/ba/45911d754e8eba3d5a841a5ce61a65a685ff1798421ac054f85aa8747dfb/pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c", size = 1517714, upload-time = "2025-06-18T05:48:06.109Z" }
682
682
  wheels = [
@@ -691,8 +691,8 @@ resolution-markers = [
691
691
  "python_full_version < '3.9'",
692
692
  ]
693
693
  dependencies = [
694
- { name = "coverage", version = "7.6.1", source = { registry = "https://pypi.org/simple" }, extra = ["toml"] },
695
- { name = "pytest", version = "8.3.5", source = { registry = "https://pypi.org/simple" } },
694
+ { name = "coverage", version = "7.6.1", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version < '3.9'" },
695
+ { name = "pytest", version = "8.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" },
696
696
  ]
697
697
  sdist = { url = "https://files.pythonhosted.org/packages/74/67/00efc8d11b630c56f15f4ad9c7f9223f1e5ec275aaae3fa9118c6a223ad2/pytest-cov-5.0.0.tar.gz", hash = "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857", size = 63042, upload-time = "2024-03-24T20:16:34.856Z" }
698
698
  wheels = [
@@ -708,9 +708,9 @@ resolution-markers = [
708
708
  "python_full_version == '3.9.*'",
709
709
  ]
710
710
  dependencies = [
711
- { name = "coverage", version = "7.9.1", source = { registry = "https://pypi.org/simple" }, extra = ["toml"] },
712
- { name = "pluggy", version = "1.6.0", source = { registry = "https://pypi.org/simple" } },
713
- { name = "pytest", version = "8.4.1", source = { registry = "https://pypi.org/simple" } },
711
+ { name = "coverage", version = "7.9.1", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version >= '3.9'" },
712
+ { name = "pluggy", version = "1.6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" },
713
+ { name = "pytest", version = "8.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" },
714
714
  ]
715
715
  sdist = { url = "https://files.pythonhosted.org/packages/18/99/668cade231f434aaa59bbfbf49469068d2ddd945000621d3d165d2e7dd7b/pytest_cov-6.2.1.tar.gz", hash = "sha256:25cc6cc0a5358204b8108ecedc51a9b57b34cc6b8c967cc2c01a4e00d8a67da2", size = 69432, upload-time = "2025-06-12T10:47:47.684Z" }
716
716
  wheels = [
@@ -785,7 +785,7 @@ wheels = [
785
785
 
786
786
  [[package]]
787
787
  name = "rushdb"
788
- version = "2.6.0"
788
+ version = "2.9.0"
789
789
  source = { editable = "." }
790
790
  dependencies = [
791
791
  { name = "python-dotenv", version = "1.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" },
@@ -930,7 +930,7 @@ resolution-markers = [
930
930
  "python_full_version < '3.9'",
931
931
  ]
932
932
  dependencies = [
933
- { name = "urllib3", version = "2.2.3", source = { registry = "https://pypi.org/simple" } },
933
+ { name = "urllib3", version = "2.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" },
934
934
  ]
935
935
  sdist = { url = "https://files.pythonhosted.org/packages/fa/3c/4f2a430c01a22abd49a583b6b944173e39e7d01b688190a5618bd59a2e22/types-requests-2.32.0.20241016.tar.gz", hash = "sha256:0d9cad2f27515d0e3e3da7134a1b6f28fb97129d86b867f24d9c726452634d95", size = 18065, upload-time = "2024-10-16T02:46:10.818Z" }
936
936
  wheels = [
@@ -946,7 +946,7 @@ resolution-markers = [
946
946
  "python_full_version == '3.9.*'",
947
947
  ]
948
948
  dependencies = [
949
- { name = "urllib3", version = "2.5.0", source = { registry = "https://pypi.org/simple" } },
949
+ { name = "urllib3", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" },
950
950
  ]
951
951
  sdist = { url = "https://files.pythonhosted.org/packages/6d/7f/73b3a04a53b0fd2a911d4ec517940ecd6600630b559e4505cc7b68beb5a0/types_requests-2.32.4.20250611.tar.gz", hash = "sha256:741c8777ed6425830bf51e54d6abe245f79b4dcb9019f1622b773463946bf826", size = 23118, upload-time = "2025-06-11T03:11:41.272Z" }
952
952
  wheels = [
@@ -1,20 +0,0 @@
1
- from typing import Dict, Optional
2
-
3
-
4
- class RushDBError(Exception):
5
- """Custom exception for RushDB client errors."""
6
-
7
- def __init__(self, message: str, details: Optional[Dict] = None):
8
- super().__init__(message)
9
- self.details = details or {}
10
-
11
-
12
- class NonUniqueResultError(Exception):
13
- """Raised by ``records.find_uniq()`` when more than one record matches the query."""
14
-
15
- def __init__(self, count: int):
16
- super().__init__(
17
- f"Expected at most one record but found {count}. "
18
- "Refine your search query or use records.find() instead."
19
- )
20
- self.count = count
@@ -1,37 +0,0 @@
1
- from enum import Enum
2
- from typing import Any, Dict, List, Optional, TypedDict, Union
3
-
4
-
5
- class OrderDirection(str, Enum):
6
- ASC = "asc"
7
- DESC = "desc"
8
-
9
-
10
- class SearchQuery(TypedDict, total=False):
11
- """TypedDict representing the query structure for finding records."""
12
-
13
- where: Optional[Dict[str, Any]]
14
- labels: Optional[List[str]]
15
- skip: Optional[int]
16
- limit: Optional[int]
17
- orderBy: Optional[Union[Dict[str, OrderDirection], OrderDirection]]
18
- select: Optional[Dict[str, Any]]
19
- groupBy: Optional[List[str]]
20
-
21
-
22
- class RelationshipEndpointQuery(TypedDict, total=False):
23
- """Endpoint record predicates for relationship search."""
24
-
25
- where: Optional[Dict[str, Any]]
26
- labels: Optional[List[str]]
27
-
28
-
29
- class RelationshipSearchQuery(TypedDict, total=False):
30
- """TypedDict representing the query structure for finding relationship edges."""
31
-
32
- where: Optional[Dict[str, Any]]
33
- source: Optional[RelationshipEndpointQuery]
34
- target: Optional[RelationshipEndpointQuery]
35
- skip: Optional[int]
36
- limit: Optional[int]
37
- orderBy: Optional[Union[Dict[str, OrderDirection], OrderDirection]]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes