alt-python-pynosqlc-core 1.0.4__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.
@@ -0,0 +1,28 @@
1
+ Metadata-Version: 2.4
2
+ Name: alt-python-pynosqlc-core
3
+ Version: 1.0.4
4
+ Summary: Core abstraction hierarchy for pynosqlc — Driver, DriverManager, Client, Collection, Cursor, Filter, FieldCondition, UnsupportedOperationError, compliance suite
5
+ Project-URL: Homepage, https://github.com/alt-python/pynosqlc
6
+ Project-URL: Repository, https://github.com/alt-python/pynosqlc
7
+ Project-URL: Documentation, https://github.com/alt-python/pynosqlc#getting-started
8
+ Project-URL: Bug Tracker, https://github.com/alt-python/pynosqlc/issues
9
+ Author: Craig Parravicini, Claude (Anthropic)
10
+ License: MIT
11
+ Keywords: async,database,driver,nosql
12
+ Classifier: Development Status :: 5 - Production/Stable
13
+ Classifier: Framework :: AsyncIO
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Database
19
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.12
22
+ Description-Content-Type: text/markdown
23
+
24
+ # pynosqlc-core
25
+
26
+ Core abstraction hierarchy for pynosqlc — a JDBC-inspired unified async NoSQL access layer for Python.
27
+
28
+ Provides `DriverManager`, `Driver`, `Client`, `Collection`, `Cursor`, `Filter`, `FieldCondition`, `UnsupportedOperationError`, and a portable driver compliance suite.
@@ -0,0 +1,14 @@
1
+ pynosqlc/core/__init__.py,sha256=lvHNYFoztmC1jmZo2CEeVTsoAYlW3LXISjj-cPwGQBg,1375
2
+ pynosqlc/core/client.py,sha256=_lvuY4r8pHPCtY27rbLPQbhtMEODxc_XoBp5aO869bw,3873
3
+ pynosqlc/core/collection.py,sha256=zj1YncYG1no3YEKAgG_oxVSP5Vb1F3Bf19W77u7mJCE,3915
4
+ pynosqlc/core/cursor.py,sha256=GFLHnheZq-SHZFaDQW5pvfWzfEGz3VfagoTJjQoEhdY,2979
5
+ pynosqlc/core/driver.py,sha256=VqpgbbREuMyr4TI2o4KHgPXhirjBu2Yw0qzs6pHwPT0,1174
6
+ pynosqlc/core/driver_manager.py,sha256=tLYd67kqjSIFH294Suhoh4YeONjEqgMfEhod31GsWR0,2013
7
+ pynosqlc/core/errors.py,sha256=yhgJACWHfhoTgQXXT6V0IIZKkkjw-xPPVV5ElpE8upU,297
8
+ pynosqlc/core/filter.py,sha256=I3ZL0KgpSki20L5eP7xZAocEL5Y7a6FA_k5gl1zMzyo,6152
9
+ pynosqlc/core/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ pynosqlc/core/testing/__init__.py,sha256=SVOhsktVPobQUTAmLEwslT_sQ2kk6KrALFMhZhop-5M,249
11
+ pynosqlc/core/testing/compliance.py,sha256=WDrcLEstdnG3Tot2aaoI_8-luWEDEncfXLEkPK9foVM,11894
12
+ alt_python_pynosqlc_core-1.0.4.dist-info/METADATA,sha256=3YX6BctKi9VZkoqXNXiJIJ7YJEWJSQD4ehtFxmI8jkU,1387
13
+ alt_python_pynosqlc_core-1.0.4.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
14
+ alt_python_pynosqlc_core-1.0.4.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,39 @@
1
+ """
2
+ pynosqlc.core — Core abstraction hierarchy for pynosqlc.
3
+
4
+ Provides:
5
+ Driver — ABC for NoSQL drivers
6
+ DriverManager — Registry and URL-based connection dispatcher
7
+ Client — ABC for database clients (async context manager)
8
+ ClientDataSource — Connection factory wrapping DriverManager
9
+ Collection — ABC for collections / tables / buckets
10
+ Cursor — Cursor-based and bulk document access (async iterable)
11
+ Filter — Chainable query filter builder
12
+ FieldCondition — Field-level condition within a Filter
13
+ UnsupportedOperationError — Raised when a driver does not implement an operation
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from pynosqlc.core.errors import UnsupportedOperationError
19
+ from pynosqlc.core.driver import Driver
20
+ from pynosqlc.core.driver_manager import DriverManager
21
+ from pynosqlc.core.cursor import Cursor
22
+ from pynosqlc.core.collection import Collection
23
+ from pynosqlc.core.client import Client, ClientDataSource
24
+ from pynosqlc.core.filter import Filter, FieldCondition
25
+
26
+ __author__ = "Craig Parravicini"
27
+ __collaborators__ = ["Claude (Anthropic)"]
28
+
29
+ __all__ = [
30
+ "Driver",
31
+ "DriverManager",
32
+ "Client",
33
+ "ClientDataSource",
34
+ "Collection",
35
+ "Cursor",
36
+ "Filter",
37
+ "FieldCondition",
38
+ "UnsupportedOperationError",
39
+ ]
@@ -0,0 +1,112 @@
1
+ """
2
+ client.py — Abstract base class for a pynosqlc client session.
3
+
4
+ Drivers override ``_get_collection()`` and ``_close()``.
5
+ Manages a cache of Collection instances keyed by name.
6
+ Implements the async context manager protocol (``async with``).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from abc import ABC, abstractmethod
12
+ from typing import TYPE_CHECKING
13
+
14
+ from pynosqlc.core.driver_manager import DriverManager
15
+
16
+ if TYPE_CHECKING:
17
+ from pynosqlc.core.collection import Collection
18
+
19
+
20
+ class Client(ABC):
21
+ """A session to a NoSQL database.
22
+
23
+ Args:
24
+ config: optional dict; recognises key ``'url'``.
25
+ """
26
+
27
+ def __init__(self, config: dict | None = None) -> None:
28
+ cfg = config or {}
29
+ self._url: str | None = cfg.get("url")
30
+ self._closed: bool = False
31
+ self._collections: dict[str, "Collection"] = {}
32
+
33
+ # ── Async context manager ──────────────────────────────────────────────
34
+
35
+ async def __aenter__(self) -> "Client":
36
+ return self
37
+
38
+ async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
39
+ await self.close()
40
+
41
+ # ── Public API ─────────────────────────────────────────────────────────
42
+
43
+ def get_collection(self, name: str) -> "Collection":
44
+ """Return a (cached) Collection by name.
45
+
46
+ Raises:
47
+ RuntimeError: if the client is closed
48
+ """
49
+ self._check_closed()
50
+ if name not in self._collections:
51
+ self._collections[name] = self._get_collection(name)
52
+ return self._collections[name]
53
+
54
+ async def close(self) -> None:
55
+ """Close the client and release all resources."""
56
+ self._closed = True
57
+ self._collections.clear()
58
+ await self._close()
59
+
60
+ def is_closed(self) -> bool:
61
+ """Return ``True`` if the client has been closed."""
62
+ return self._closed
63
+
64
+ def get_url(self) -> str | None:
65
+ """Return the pynosqlc URL this client was opened with."""
66
+ return self._url
67
+
68
+ # ── Abstract implementation hooks ──────────────────────────────────────
69
+
70
+ @abstractmethod
71
+ def _get_collection(self, name: str) -> "Collection":
72
+ """Create and return a new Collection instance for *name*."""
73
+
74
+ async def _close(self) -> None:
75
+ """Override to release driver-specific resources on close."""
76
+
77
+ # ── Internal helpers ───────────────────────────────────────────────────
78
+
79
+ def _check_closed(self) -> None:
80
+ if self._closed:
81
+ raise RuntimeError("Client is closed")
82
+
83
+
84
+ class ClientDataSource:
85
+ """Convenience factory that wraps :meth:`DriverManager.get_client`.
86
+
87
+ Mirrors pydbc's DataSource pattern.
88
+
89
+ Args:
90
+ config: dict with keys:
91
+ - ``url`` (required) — pynosqlc URL
92
+ - ``username`` (optional)
93
+ - ``password`` (optional)
94
+ - ``properties`` (optional) — additional driver-specific options
95
+ """
96
+
97
+ def __init__(self, config: dict | None = None) -> None:
98
+ cfg = config or {}
99
+ self._url: str = cfg["url"]
100
+ self._properties: dict = {
101
+ "username": cfg.get("username"),
102
+ "password": cfg.get("password"),
103
+ **(cfg.get("properties") or {}),
104
+ }
105
+
106
+ async def get_client(self) -> Client:
107
+ """Return a client from the configured data source."""
108
+ return await DriverManager.get_client(self._url, self._properties)
109
+
110
+ def get_url(self) -> str:
111
+ """Return the configured pynosqlc URL."""
112
+ return self._url
@@ -0,0 +1,109 @@
1
+ """
2
+ collection.py — Abstract base class for a named collection (table/bucket).
3
+
4
+ Driver implementations override the ``_`` methods. All base ``_`` methods
5
+ raise ``UnsupportedOperationError`` — drivers implement only what their
6
+ backend supports.
7
+
8
+ Operations
9
+ ----------
10
+ Key-value : get(key), store(key, doc), delete(key)
11
+ Document : insert(doc), update(key, patch)
12
+ Query : find(ast)
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from abc import ABC, abstractmethod
18
+ from typing import TYPE_CHECKING
19
+
20
+ from pynosqlc.core.errors import UnsupportedOperationError
21
+
22
+ if TYPE_CHECKING:
23
+ from pynosqlc.core.client import Client
24
+ from pynosqlc.core.cursor import Cursor
25
+
26
+
27
+ class Collection(ABC):
28
+ """Represents a named collection within a :class:`Client`."""
29
+
30
+ def __init__(self, client: "Client", name: str) -> None:
31
+ self._client = client
32
+ self._name = name
33
+ self._closed: bool = False
34
+
35
+ def get_name(self) -> str:
36
+ """Return the collection name."""
37
+ return self._name
38
+
39
+ # ── Public API ─────────────────────────────────────────────────────────
40
+
41
+ async def get(self, key: str) -> dict | None:
42
+ """Retrieve a document by its primary key.
43
+
44
+ Returns:
45
+ The document dict, or ``None`` if the key does not exist.
46
+ """
47
+ self._check_closed()
48
+ return await self._get(key)
49
+
50
+ async def store(self, key: str, doc: dict) -> None:
51
+ """Store (upsert) a document under *key*."""
52
+ self._check_closed()
53
+ await self._store(key, doc)
54
+
55
+ async def delete(self, key: str) -> None:
56
+ """Delete the document at *key*. No-op if the key does not exist."""
57
+ self._check_closed()
58
+ await self._delete(key)
59
+
60
+ async def insert(self, doc: dict) -> str:
61
+ """Insert a document and return the backend-assigned key / ``_id``."""
62
+ self._check_closed()
63
+ return await self._insert(doc)
64
+
65
+ async def update(self, key: str, patch: dict) -> None:
66
+ """Patch the document at *key*.
67
+
68
+ Only provided fields are updated; others are preserved (shallow merge).
69
+ """
70
+ self._check_closed()
71
+ await self._update(key, patch)
72
+
73
+ async def find(self, ast: dict) -> "Cursor":
74
+ """Find documents matching the given filter AST.
75
+
76
+ Args:
77
+ ast: a built filter AST from ``Filter.build()``
78
+
79
+ Returns:
80
+ A :class:`~pynosqlc.core.Cursor` over matching documents.
81
+ """
82
+ self._check_closed()
83
+ return await self._find(ast)
84
+
85
+ # ── Abstract implementation hooks ──────────────────────────────────────
86
+
87
+ async def _get(self, key: str) -> dict | None:
88
+ raise UnsupportedOperationError("get() is not supported by this driver")
89
+
90
+ async def _store(self, key: str, doc: dict) -> None:
91
+ raise UnsupportedOperationError("store() is not supported by this driver")
92
+
93
+ async def _delete(self, key: str) -> None:
94
+ raise UnsupportedOperationError("delete() is not supported by this driver")
95
+
96
+ async def _insert(self, doc: dict) -> str:
97
+ raise UnsupportedOperationError("insert() is not supported by this driver")
98
+
99
+ async def _update(self, key: str, patch: dict) -> None:
100
+ raise UnsupportedOperationError("update() is not supported by this driver")
101
+
102
+ async def _find(self, ast: dict) -> "Cursor":
103
+ raise UnsupportedOperationError("find() is not supported by this driver")
104
+
105
+ # ── Internal helpers ───────────────────────────────────────────────────
106
+
107
+ def _check_closed(self) -> None:
108
+ if self._closed:
109
+ raise RuntimeError("Collection is closed")
@@ -0,0 +1,88 @@
1
+ """
2
+ cursor.py — Cursor over a find() result set.
3
+
4
+ Provides cursor-based iteration (next/get_document) plus bulk access
5
+ (get_documents) and implements the async iterator protocol for use with
6
+ ``async for``.
7
+
8
+ The base class buffers all results in a list. Driver implementations may
9
+ subclass Cursor to support streaming from the database.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+
15
+ class Cursor:
16
+ """Async-iterable cursor over a collection of documents.
17
+
18
+ Args:
19
+ documents: pre-buffered result list (pass ``[]`` for an empty cursor)
20
+ """
21
+
22
+ def __init__(self, documents: list[dict] | None = None) -> None:
23
+ self._documents: list[dict] = documents if documents is not None else []
24
+ self._cursor: int = -1
25
+ self._closed: bool = False
26
+
27
+ async def next(self) -> bool:
28
+ """Advance to the next document.
29
+
30
+ Returns:
31
+ ``True`` if there is a current document; ``False`` when exhausted.
32
+ """
33
+ self._check_closed()
34
+ self._cursor += 1
35
+ return self._cursor < len(self._documents)
36
+
37
+ def get_document(self) -> dict:
38
+ """Return a shallow copy of the document at the current cursor position.
39
+
40
+ Raises:
41
+ RuntimeError: if the cursor is not on a valid document (call
42
+ ``next()`` first)
43
+ RuntimeError: if the cursor is closed
44
+ """
45
+ self._check_closed()
46
+ self._check_cursor()
47
+ return dict(self._documents[self._cursor])
48
+
49
+ def get_documents(self) -> list[dict]:
50
+ """Return all documents as a list of shallow copies.
51
+
52
+ Does not require ``next()`` to have been called — returns the full
53
+ buffered result set.
54
+ """
55
+ self._check_closed()
56
+ return [dict(d) for d in self._documents]
57
+
58
+ async def close(self) -> None:
59
+ """Close the cursor and release resources."""
60
+ self._closed = True
61
+
62
+ def is_closed(self) -> bool:
63
+ """Return ``True`` if the cursor has been closed."""
64
+ return self._closed
65
+
66
+ # ── Async iterator protocol ────────────────────────────────────────────
67
+
68
+ def __aiter__(self) -> "Cursor":
69
+ return self
70
+
71
+ async def __anext__(self) -> dict:
72
+ has_more = await self.next()
73
+ if has_more:
74
+ return self.get_document()
75
+ await self.close()
76
+ raise StopAsyncIteration
77
+
78
+ # ── Internal helpers ───────────────────────────────────────────────────
79
+
80
+ def _check_closed(self) -> None:
81
+ if self._closed:
82
+ raise RuntimeError("Cursor is closed")
83
+
84
+ def _check_cursor(self) -> None:
85
+ if self._cursor < 0 or self._cursor >= len(self._documents):
86
+ raise RuntimeError(
87
+ "Cursor is not on a valid document — call next() first"
88
+ )
@@ -0,0 +1,41 @@
1
+ """
2
+ driver.py — Abstract base class for pynosqlc drivers.
3
+
4
+ Each driver implementation registers itself with DriverManager on import
5
+ and declares which URL schemes it handles.
6
+
7
+ URL scheme: pynosqlc:<subprotocol>:<connection-details>
8
+ e.g. pynosqlc:mongodb://localhost:27017/mydb
9
+ pynosqlc:memory:
10
+ pynosqlc:dynamodb:us-east-1
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from abc import ABC, abstractmethod
16
+ from typing import TYPE_CHECKING
17
+
18
+ if TYPE_CHECKING:
19
+ from pynosqlc.core.client import Client
20
+
21
+
22
+ class Driver(ABC):
23
+ """Creates client connections to a specific NoSQL database type."""
24
+
25
+ @abstractmethod
26
+ def accepts_url(self, url: str) -> bool:
27
+ """Return True if this driver handles the given pynosqlc URL.
28
+
29
+ Args:
30
+ url: e.g. ``'pynosqlc:mongodb://localhost:27017/mydb'``
31
+ """
32
+
33
+ @abstractmethod
34
+ async def connect(self, url: str, properties: dict | None = None) -> "Client":
35
+ """Create a client connection to the database.
36
+
37
+ Args:
38
+ url: pynosqlc URL
39
+ properties: optional dict with driver-specific options
40
+ (e.g. username, password, endpoint)
41
+ """
@@ -0,0 +1,66 @@
1
+ """
2
+ driver_manager.py — Registry for pynosqlc drivers.
3
+
4
+ Drivers register themselves on import. When get_client() is called,
5
+ DriverManager iterates registered drivers to find one that accepts the URL.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING
11
+
12
+ if TYPE_CHECKING:
13
+ from pynosqlc.core.driver import Driver
14
+ from pynosqlc.core.client import Client
15
+
16
+
17
+ class DriverManager:
18
+ """Class-level registry for pynosqlc drivers."""
19
+
20
+ _drivers: list["Driver"] = []
21
+
22
+ @classmethod
23
+ def register_driver(cls, driver: "Driver") -> None:
24
+ """Register a driver instance (idempotent — no duplicates)."""
25
+ if driver not in cls._drivers:
26
+ cls._drivers.append(driver)
27
+
28
+ @classmethod
29
+ def deregister_driver(cls, driver: "Driver") -> None:
30
+ """Remove a previously registered driver."""
31
+ cls._drivers = [d for d in cls._drivers if d is not driver]
32
+
33
+ @classmethod
34
+ async def get_client(
35
+ cls,
36
+ url: str,
37
+ properties: dict | None = None,
38
+ ) -> "Client":
39
+ """Return a client from the first driver that accepts *url*.
40
+
41
+ Args:
42
+ url: pynosqlc URL (e.g. ``'pynosqlc:memory:'``)
43
+ properties: optional driver-specific connection properties
44
+
45
+ Raises:
46
+ ValueError: if no registered driver accepts the URL
47
+ """
48
+ if properties is None:
49
+ properties = {}
50
+ for driver in cls._drivers:
51
+ if driver.accepts_url(url):
52
+ return await driver.connect(url, properties)
53
+ raise ValueError(
54
+ f"No suitable driver found for URL: {url!r}. "
55
+ f"Registered drivers: {[type(d).__name__ for d in cls._drivers]}"
56
+ )
57
+
58
+ @classmethod
59
+ def get_drivers(cls) -> list["Driver"]:
60
+ """Return a copy of the registered driver list."""
61
+ return list(cls._drivers)
62
+
63
+ @classmethod
64
+ def clear(cls) -> None:
65
+ """Clear all registered drivers (for testing)."""
66
+ cls._drivers = []
@@ -0,0 +1,12 @@
1
+ """
2
+ errors.py — Custom exception classes for pynosqlc.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+
8
+ class UnsupportedOperationError(Exception):
9
+ """Raised when a driver does not implement an optional Collection operation.
10
+
11
+ Callers can ``isinstance``-check this to handle gracefully.
12
+ """
@@ -0,0 +1,176 @@
1
+ """
2
+ filter.py — Chainable query filter builder and field-level condition.
3
+
4
+ Usage::
5
+
6
+ ast = Filter.where('age').gt(18).and_('name').eq('Alice').build()
7
+ # → {'type': 'and', 'conditions': [
8
+ # {'type': 'condition', 'field': 'age', 'op': 'gt', 'value': 18},
9
+ # {'type': 'condition', 'field': 'name', 'op': 'eq', 'value': 'Alice'},
10
+ # ]}
11
+
12
+ Compound operators::
13
+
14
+ Filter.or_(filter1.build(), filter2.build())
15
+ # → {'type': 'or', 'conditions': [ast1, ast2]}
16
+
17
+ Filter.where('status').eq('inactive').not_()
18
+ # → {'type': 'not', 'condition': {'type': 'condition', ...}}
19
+
20
+ AST node shapes
21
+ ---------------
22
+ Leaf : ``{'type': 'condition', 'field': str, 'op': str, 'value': Any}``
23
+ And : ``{'type': 'and', 'conditions': list[node]}``
24
+ Or : ``{'type': 'or', 'conditions': list[node]}``
25
+ Not : ``{'type': 'not', 'condition': node}``
26
+
27
+ Python keyword avoidance (PEP 8 trailing underscore)
28
+ -----------------------------------------------------
29
+ ``and()`` → ``and_()``
30
+ ``or()`` → ``or_()`` (classmethod)
31
+ ``not()`` → ``not_()``
32
+ FieldCondition: ``in()`` → ``in_()``
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ from typing import Any
38
+
39
+
40
+ class FieldCondition:
41
+ """Represents a field-level condition within a :class:`Filter`.
42
+
43
+ Created by :meth:`Filter.where` or :meth:`Filter.and_`.
44
+ Operator methods add the condition to the owning Filter and return
45
+ the Filter for further chaining.
46
+
47
+ Supported operators: eq, ne, gt, gte, lt, lte, contains, in_, nin, exists
48
+ """
49
+
50
+ def __init__(self, field: str, filter_: "Filter") -> None:
51
+ self._field = field
52
+ self._filter = filter_
53
+
54
+ def eq(self, value: Any) -> "Filter":
55
+ """Equal: ``field == value``"""
56
+ return self._add("eq", value)
57
+
58
+ def ne(self, value: Any) -> "Filter":
59
+ """Not equal: ``field != value``"""
60
+ return self._add("ne", value)
61
+
62
+ def gt(self, value: Any) -> "Filter":
63
+ """Greater than: ``field > value``"""
64
+ return self._add("gt", value)
65
+
66
+ def gte(self, value: Any) -> "Filter":
67
+ """Greater than or equal: ``field >= value``"""
68
+ return self._add("gte", value)
69
+
70
+ def lt(self, value: Any) -> "Filter":
71
+ """Less than: ``field < value``"""
72
+ return self._add("lt", value)
73
+
74
+ def lte(self, value: Any) -> "Filter":
75
+ """Less than or equal: ``field <= value``"""
76
+ return self._add("lte", value)
77
+
78
+ def contains(self, value: Any) -> "Filter":
79
+ """Contains: field is a string/list that contains *value*."""
80
+ return self._add("contains", value)
81
+
82
+ def in_(self, values: list) -> "Filter":
83
+ """In: ``field`` is one of *values*."""
84
+ return self._add("in", values)
85
+
86
+ def nin(self, values: list) -> "Filter":
87
+ """Not in: ``field`` is not one of *values*."""
88
+ return self._add("nin", values)
89
+
90
+ def exists(self, value: bool = True) -> "Filter":
91
+ """Exists: field is present (True) or absent/None (False)."""
92
+ return self._add("exists", value)
93
+
94
+ # ── Internal ────────────────────────────────────────────────────────────
95
+
96
+ def _add(self, op: str, value: Any) -> "Filter":
97
+ self._filter._add_condition(
98
+ {"type": "condition", "field": self._field, "op": op, "value": value}
99
+ )
100
+ return self._filter
101
+
102
+
103
+ class Filter:
104
+ """Chainable query filter builder.
105
+
106
+ Build a filter using the fluent API, then call :meth:`build` to produce
107
+ the AST dict passed to :meth:`~pynosqlc.core.Collection.find`.
108
+ """
109
+
110
+ def __init__(self) -> None:
111
+ self._conditions: list[dict] = []
112
+
113
+ @classmethod
114
+ def where(cls, field: str) -> FieldCondition:
115
+ """Start a new filter on *field*.
116
+
117
+ Returns:
118
+ A :class:`FieldCondition` whose operator methods return the
119
+ owning :class:`Filter` for further chaining.
120
+ """
121
+ f = cls()
122
+ return FieldCondition(field, f)
123
+
124
+ @classmethod
125
+ def or_(cls, *filters: "dict | Filter") -> dict:
126
+ """Create an OR compound of two or more AST nodes or Filter instances.
127
+
128
+ Each argument may be a built AST dict (result of ``filter.build()``)
129
+ or a :class:`Filter` instance (``build()`` is called automatically).
130
+
131
+ Returns:
132
+ ``{'type': 'or', 'conditions': [...]}``
133
+ """
134
+ conditions = [f.build() if isinstance(f, Filter) else f for f in filters]
135
+ return {"type": "or", "conditions": conditions}
136
+
137
+ def and_(self, field: str) -> FieldCondition:
138
+ """Chain an additional AND condition on a new *field*.
139
+
140
+ Returns:
141
+ A :class:`FieldCondition` whose operator methods return this
142
+ :class:`Filter` for further chaining.
143
+ """
144
+ return FieldCondition(field, self)
145
+
146
+ def not_(self) -> dict:
147
+ """Negate this filter.
148
+
149
+ Calls :meth:`build` internally and wraps the result in a not node.
150
+
151
+ Returns:
152
+ ``{'type': 'not', 'condition': <ast>}``
153
+ """
154
+ return {"type": "not", "condition": self.build()}
155
+
156
+ def build(self) -> dict:
157
+ """Build and return the filter AST.
158
+
159
+ - Zero conditions → ``{'type': 'and', 'conditions': []}``
160
+ - Single condition → the leaf node directly
161
+ - Multiple conditions → ``{'type': 'and', 'conditions': [...]}``
162
+
163
+ Each call returns a fresh copy — mutating the result does not affect
164
+ the Filter.
165
+ """
166
+ if not self._conditions:
167
+ return {"type": "and", "conditions": []}
168
+ if len(self._conditions) == 1:
169
+ return dict(self._conditions[0])
170
+ return {"type": "and", "conditions": [dict(c) for c in self._conditions]}
171
+
172
+ # ── Internal ────────────────────────────────────────────────────────────
173
+
174
+ def _add_condition(self, node: dict) -> None:
175
+ """Append a condition node (called by FieldCondition)."""
176
+ self._conditions.append(node)
pynosqlc/core/py.typed ADDED
File without changes
@@ -0,0 +1,10 @@
1
+ """
2
+ pynosqlc.core.testing — Shared compliance test utilities.
3
+
4
+ Exports:
5
+ run_compliance: Register a pytest compliance suite into the calling module.
6
+ """
7
+
8
+ from pynosqlc.core.testing.compliance import run_compliance
9
+
10
+ __all__ = ["run_compliance"]
@@ -0,0 +1,280 @@
1
+ """
2
+ compliance.py — Shared driver compliance test suite for pynosqlc drivers.
3
+
4
+ Usage in a driver package's test file::
5
+
6
+ from pynosqlc.core.testing import run_compliance
7
+ from pynosqlc.core import DriverManager
8
+ import pynosqlc.memory
9
+
10
+ async def _factory():
11
+ DriverManager.clear()
12
+ import pynosqlc.memory # re-registers the driver
13
+ return await DriverManager.get_client('pynosqlc:memory:')
14
+
15
+ run_compliance(_factory)
16
+
17
+ ``run_compliance`` uses ``sys._getframe(1).f_globals`` to inject a set of
18
+ pytest ``Test*`` classes directly into the calling module's namespace so that
19
+ pytest's standard discovery collects them automatically.
20
+
21
+ Arguments:
22
+ client_factory: ``async def () -> Client`` — called before each test class
23
+ to produce a fresh, open client.
24
+ skip_find: if ``True``, the find-operator test class is omitted
25
+ (useful for backends that do not support scan/filter).
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import sys
31
+ from typing import Any, Callable, Coroutine
32
+
33
+ import pytest
34
+
35
+ from pynosqlc.core.filter import Filter
36
+
37
+
38
+ def run_compliance(
39
+ client_factory: Callable[[], Coroutine[Any, Any, Any]],
40
+ *,
41
+ skip_find: bool = False,
42
+ ) -> None:
43
+ """Register compliance test classes into the calling module's namespace.
44
+
45
+ Pytest discovers ``Test*`` classes from module globals at collection time.
46
+ This function injects those classes before collection completes by writing
47
+ directly into ``sys._getframe(1).f_globals``.
48
+
49
+ Args:
50
+ client_factory: Async callable returning an open :class:`~pynosqlc.core.Client`.
51
+ skip_find: When ``True``, the find-operator test class is not registered.
52
+ """
53
+ caller_globals = sys._getframe(1).f_globals
54
+
55
+ # ── KV test class ──────────────────────────────────────────────────────
56
+
57
+ class TestKVCompliance:
58
+ """Key-value compliance: store/get, delete, upsert."""
59
+
60
+ @pytest.fixture(autouse=True)
61
+ async def _setup(self):
62
+ self.client = await client_factory()
63
+ self.col = self.client.get_collection("compliance_kv")
64
+ yield
65
+ if not self.client.is_closed():
66
+ await self.client.close()
67
+
68
+ async def test_store_and_get_retrieves_document(self):
69
+ await self.col.store("kv-1", {"name": "Alice", "age": 30})
70
+ doc = await self.col.get("kv-1")
71
+ assert doc is not None
72
+ assert doc["name"] == "Alice"
73
+ assert doc["age"] == 30
74
+
75
+ async def test_get_returns_none_for_missing_key(self):
76
+ import time
77
+ doc = await self.col.get(f"kv-nonexistent-{int(time.time() * 1000)}")
78
+ assert doc is None
79
+
80
+ async def test_store_overwrites_existing_document(self):
81
+ await self.col.store("kv-upsert", {"name": "Bob", "age": 25})
82
+ await self.col.store("kv-upsert", {"name": "Bob", "age": 26})
83
+ doc = await self.col.get("kv-upsert")
84
+ assert doc is not None
85
+ assert doc["age"] == 26
86
+
87
+ async def test_delete_removes_document(self):
88
+ await self.col.store("kv-del", {"name": "ToDelete"})
89
+ await self.col.delete("kv-del")
90
+ doc = await self.col.get("kv-del")
91
+ assert doc is None
92
+
93
+ async def test_delete_missing_key_does_not_raise(self):
94
+ import time
95
+ await self.col.delete(f"kv-missing-{int(time.time() * 1000)}")
96
+
97
+ # ── Document test class ────────────────────────────────────────────────
98
+
99
+ class TestDocumentCompliance:
100
+ """Document compliance: insert, update."""
101
+
102
+ @pytest.fixture(autouse=True)
103
+ async def _setup(self):
104
+ self.client = await client_factory()
105
+ self.col = self.client.get_collection("compliance_doc")
106
+ yield
107
+ if not self.client.is_closed():
108
+ await self.client.close()
109
+
110
+ async def test_insert_returns_an_id(self):
111
+ id_ = await self.col.insert({"name": "Charlie", "status": "active"})
112
+ assert isinstance(id_, str)
113
+ assert len(id_) > 0
114
+
115
+ async def test_inserted_document_retrievable_by_id(self):
116
+ id_ = await self.col.insert({"name": "Dana", "score": 99})
117
+ doc = await self.col.get(id_)
118
+ assert doc is not None
119
+ assert doc["name"] == "Dana"
120
+
121
+ async def test_two_inserts_produce_different_ids(self):
122
+ id1 = await self.col.insert({"name": "E1"})
123
+ id2 = await self.col.insert({"name": "E2"})
124
+ assert id1 != id2
125
+
126
+ async def test_update_patches_fields_without_destroying_others(self):
127
+ await self.col.store("upd-1", {"name": "Frank", "age": 40, "country": "AU"})
128
+ await self.col.update("upd-1", {"age": 41})
129
+ doc = await self.col.get("upd-1")
130
+ assert doc is not None
131
+ assert doc["age"] == 41
132
+ assert doc["name"] == "Frank"
133
+ assert doc["country"] == "AU"
134
+
135
+ # ── Lifecycle test class ───────────────────────────────────────────────
136
+
137
+ class TestLifecycleCompliance:
138
+ """Client lifecycle compliance."""
139
+
140
+ @pytest.fixture(autouse=True)
141
+ async def _setup(self):
142
+ self.client = await client_factory()
143
+ yield
144
+ if not self.client.is_closed():
145
+ await self.client.close()
146
+
147
+ def test_get_collection_returns_same_instance(self):
148
+ c1 = self.client.get_collection("same-name")
149
+ c2 = self.client.get_collection("same-name")
150
+ assert c1 is c2
151
+
152
+ def test_is_closed_false_for_open_client(self):
153
+ assert self.client.is_closed() is False
154
+
155
+ # ── Register classes into caller's module globals ──────────────────────
156
+
157
+ caller_globals["TestKVCompliance"] = TestKVCompliance
158
+ caller_globals["TestDocumentCompliance"] = TestDocumentCompliance
159
+ caller_globals["TestLifecycleCompliance"] = TestLifecycleCompliance
160
+
161
+ # ── Find test class (optional) ─────────────────────────────────────────
162
+
163
+ if skip_find:
164
+ return
165
+
166
+ class TestFindCompliance:
167
+ """Find-with-filter compliance: all operators + cursor iteration."""
168
+
169
+ # Seed data: same 5 docs as JS compliance suite
170
+ _SEED = [
171
+ ("f1", {"name": "Alice", "age": 30, "status": "active", "tags": ["js", "ts"], "score": 85}),
172
+ ("f2", {"name": "Bob", "age": 25, "status": "inactive", "tags": ["py"], "score": 70}),
173
+ ("f3", {"name": "Charlie", "age": 35, "status": "active", "tags": ["js", "go"], "score": 90}),
174
+ ("f4", {"name": "Dana", "age": 22, "status": "pending", "tags": ["ts"], "score": 60}),
175
+ ("f5", {"name": "Eve", "age": 30, "status": "active", "score": 95, "email": "eve@example.com"}),
176
+ ]
177
+
178
+ @pytest.fixture(autouse=True)
179
+ async def _setup(self):
180
+ self.client = await client_factory()
181
+ self.fcol = self.client.get_collection("compliance_find")
182
+ for key, doc in self._SEED:
183
+ await self.fcol.store(key, doc)
184
+ yield
185
+ if not self.client.is_closed():
186
+ await self.client.close()
187
+
188
+ async def test_eq_finds_matching_documents(self):
189
+ cursor = await self.fcol.find(Filter.where("status").eq("active").build())
190
+ docs = cursor.get_documents()
191
+ await cursor.close()
192
+ assert len(docs) == 3
193
+ assert all(d["status"] == "active" for d in docs)
194
+
195
+ async def test_ne_finds_non_matching_documents(self):
196
+ cursor = await self.fcol.find(Filter.where("status").ne("active").build())
197
+ docs = cursor.get_documents()
198
+ await cursor.close()
199
+ assert len(docs) == 2
200
+ assert all(d["status"] != "active" for d in docs)
201
+
202
+ async def test_gt_finds_documents_greater_than_value(self):
203
+ cursor = await self.fcol.find(Filter.where("age").gt(29).build())
204
+ docs = cursor.get_documents()
205
+ await cursor.close()
206
+ assert len(docs) >= 2
207
+ assert all(d["age"] > 29 for d in docs)
208
+
209
+ async def test_lt_finds_documents_less_than_value(self):
210
+ cursor = await self.fcol.find(Filter.where("age").lt(25).build())
211
+ docs = cursor.get_documents()
212
+ await cursor.close()
213
+ assert all(d["age"] < 25 for d in docs)
214
+
215
+ async def test_gte_finds_documents_gte_value(self):
216
+ cursor = await self.fcol.find(Filter.where("score").gte(90).build())
217
+ docs = cursor.get_documents()
218
+ await cursor.close()
219
+ assert all(d["score"] >= 90 for d in docs)
220
+ assert len(docs) >= 2
221
+
222
+ async def test_lte_finds_documents_lte_value(self):
223
+ cursor = await self.fcol.find(Filter.where("score").lte(70).build())
224
+ docs = cursor.get_documents()
225
+ await cursor.close()
226
+ assert all(d["score"] <= 70 for d in docs)
227
+
228
+ async def test_contains_finds_array_field_contains_value(self):
229
+ cursor = await self.fcol.find(Filter.where("tags").contains("js").build())
230
+ docs = cursor.get_documents()
231
+ await cursor.close()
232
+ assert len(docs) >= 2
233
+ assert all("js" in d["tags"] for d in docs)
234
+
235
+ async def test_in_finds_documents_with_field_in_values(self):
236
+ cursor = await self.fcol.find(
237
+ Filter.where("status").in_(["active", "pending"]).build()
238
+ )
239
+ docs = cursor.get_documents()
240
+ await cursor.close()
241
+ assert all(d["status"] in ["active", "pending"] for d in docs)
242
+ assert len(docs) >= 3
243
+
244
+ async def test_nin_finds_documents_with_field_not_in_values(self):
245
+ cursor = await self.fcol.find(
246
+ Filter.where("status").nin(["inactive", "pending"]).build()
247
+ )
248
+ docs = cursor.get_documents()
249
+ await cursor.close()
250
+ assert all(d["status"] not in ["inactive", "pending"] for d in docs)
251
+
252
+ async def test_exists_true_finds_documents_where_field_present(self):
253
+ cursor = await self.fcol.find(Filter.where("email").exists(True).build())
254
+ docs = cursor.get_documents()
255
+ await cursor.close()
256
+ assert len(docs) >= 1
257
+ assert all(d.get("email") is not None for d in docs)
258
+
259
+ async def test_exists_false_finds_documents_where_field_absent(self):
260
+ cursor = await self.fcol.find(Filter.where("email").exists(False).build())
261
+ docs = cursor.get_documents()
262
+ await cursor.close()
263
+ assert all(d.get("email") is None for d in docs)
264
+
265
+ async def test_compound_and_applies_both_conditions(self):
266
+ ast = Filter.where("status").eq("active").and_("age").gt(29).build()
267
+ cursor = await self.fcol.find(ast)
268
+ docs = cursor.get_documents()
269
+ await cursor.close()
270
+ assert all(d["status"] == "active" and d["age"] > 29 for d in docs)
271
+ assert len(docs) >= 1
272
+
273
+ async def test_cursor_for_await_iteration(self):
274
+ cursor = await self.fcol.find(Filter.where("status").eq("active").build())
275
+ docs = []
276
+ async for doc in cursor:
277
+ docs.append(doc)
278
+ assert len(docs) >= 3
279
+
280
+ caller_globals["TestFindCompliance"] = TestFindCompliance