From fb63bdc1aa93ae8884b535c523c82a31fbc8600a Mon Sep 17 00:00:00 2001 From: Eugene&Monty <136487467+EugeneSukharev1988@users.noreply.github.com> Date: Tue, 21 Jul 2026 22:03:15 -0500 Subject: [PATCH 1/5] init --- README.md | 19 +++++++- montycat/__init__.py | 2 +- montycat/core/engine.py | 105 +++++++++++++++++++++++++++++++++++----- montycat/core/tools.py | 34 +++++++++++-- 4 files changed, 143 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index a921759..c8a4091 100644 --- a/README.md +++ b/README.md @@ -178,7 +178,7 @@ strong = await Sales.semantic_search_get_keys("Show all Bluetooth devices", limi # Control the DB-wide switch (optional — it's already on): # switch the embedding model: 'minilm' | 'bge-small' (default) | 'bge-base' | 'e5-small' -await connection.enable_semantic_search(model="bge-base") +await connection.enable_semantic_search(model=SemanticModel.BGE_BASE) # turn it off (vectors are kept so re-enabling resumes instantly; # pass drop_vectors=True to also clear stored vectors) @@ -221,4 +221,21 @@ matching_values = await Sales.semantic_search_get_values_where( - **Do I need OpenAI or an embedding API?** No. Embeddings run on-device in the `montycat-semantic` server. No API keys, no per-query bill, no data egress. - **Is it a Pinecone / Weaviate / Chroma / Qdrant alternative?** Yes — self-hosted and open-source, with a NoSQL store built in. - **Which Python versions?** 3.9+ — fully async (`asyncio`). +## Data mesh governance +Owners can inspect their effective policy and superowners can grant delegated +keyspace authority programmatically: + +```python +await engine.policy_grant( + "alice", PolicyCapability.PROVISION_KEYSPACE, "catalog", + types=[PolicyKeyspaceType.IN_MEMORY, PolicyKeyspaceType.PERSISTENT], models=[SemanticModel.BGE_SMALL], +) +await engine.policy_view(owner="alice", store="catalog") +await engine.enable_semantic_search( + store="catalog", keyspace="products", model=SemanticModel.BGE_SMALL +) +``` + +Superowners may also call `policy_validate`, `policy_plan`, `policy_apply`, and +`policy_export` with JSON or YAML policy documents. diff --git a/montycat/__init__.py b/montycat/__init__.py index c76e382..dd3ec28 100644 --- a/montycat/__init__.py +++ b/montycat/__init__.py @@ -1,4 +1,4 @@ from .core.engine import Engine -from .core.tools import Pointer, Timestamp, Permission +from .core.tools import Pointer, Timestamp, Permission, PolicyCapability, PolicyKeyspaceType, SemanticModel, PolicyFormat from .core.schema import Schema from .core.store import Keyspace diff --git a/montycat/core/engine.py b/montycat/core/engine.py index 20d045b..35faace 100644 --- a/montycat/core/engine.py +++ b/montycat/core/engine.py @@ -1,7 +1,7 @@ import orjson from typing import Union, List, Optional, Any from urllib.parse import urlparse -from .tools import Permission +from .tools import Permission, PolicyCapability, PolicyKeyspaceType, SemanticModel, PolicyFormat from .utils import send_data class Engine: @@ -211,7 +211,7 @@ async def list_owners(self) -> Any: """ return await self._execute_query_with_credentials(['list-owners']) - async def enable_semantic_search(self, model: Union[str, None] = None, field: Union[str, None] = None, store: Union[str, None] = None) -> Any: + async def enable_semantic_search(self, model: Optional[SemanticModel] = None, field: Union[str, None] = None, store: Union[str, None] = None, keyspace: Union[str, None] = None) -> Any: """ Enable semantic (vector similarity) search. @@ -238,17 +238,21 @@ async def enable_semantic_search(self, model: Union[str, None] = None, field: Un Returns: Any: The server's response describing the enabled model and enrolled keyspaces. """ - command = ['enable-semantic-search'] + if keyspace and not store: + raise ValueError("A store is required when keyspace is specified") + command = ['enable-semantic-search'] if model: - command.extend(["model", model]) + command.extend(["model", model.value]) if field: command.extend(["field", field]) - if store: - command.extend(["store", store]) + if store: + command.extend(["store", store]) + if keyspace: + command.extend(["keyspace", keyspace]) return await self._execute_query_with_credentials(command) - async def disable_semantic_search(self, drop_vectors: bool = False, store: Union[str, None] = None) -> Any: + async def disable_semantic_search(self, drop_vectors: bool = False, store: Union[str, None] = None, keyspace: Union[str, None] = None) -> Any: """ Disable semantic search. @@ -272,13 +276,90 @@ async def disable_semantic_search(self, drop_vectors: bool = False, store: Union Returns: Any: The server's response confirming the disable. """ - command = ['disable-semantic-search'] + if keyspace and not store: + raise ValueError("A store is required when keyspace is specified") + command = ['disable-semantic-search'] if drop_vectors: command.append("drop-vectors") - if store: - command.extend(["store", store]) - - return await self._execute_query_with_credentials(command) + if store: + command.extend(["store", store]) + if keyspace: + command.extend(["keyspace", keyspace]) + + return await self._execute_query_with_credentials(command) + + async def policy_view(self, owner: Optional[str] = None, store: Optional[str] = None) -> Any: + command = ['policy-view'] + if owner: + command.extend(['owner', owner]) + if store: + command.extend(['store', store]) + return await self._execute_query_with_credentials(command) + + async def policy_history(self, owner: Optional[str] = None, store: Optional[str] = None, keyspace: Optional[str] = None) -> Any: + command = ['policy-history'] + if owner: + command.extend(['owner', owner]) + if store: + command.extend(['store', store]) + if keyspace: + command.extend(['keyspace', keyspace]) + return await self._execute_query_with_credentials(command) + + async def policy_explain(self, capability: PolicyCapability, store: str, owner: Optional[str] = None, keyspace: Optional[str] = None, keyspace_type: Optional[PolicyKeyspaceType] = None, model: Optional[SemanticModel] = None) -> Any: + command = ['policy-explain', 'capability', capability.value, 'store', store] + if owner: + command.extend(['owner', owner]) + if keyspace: + command.extend(['keyspace', keyspace]) + if keyspace_type: + command.extend(['type', keyspace_type.value]) + if model: + command.extend(['model', model.value]) + return await self._execute_query_with_credentials(command) + + async def _policy_mutation(self, operation: str, owner: str, capability: PolicyCapability, store: str, keyspace: Optional[str] = None, types: Optional[List[PolicyKeyspaceType]] = None, models: Optional[List[SemanticModel]] = None) -> Any: + command = [operation, 'owner', owner, 'capability', capability.value, 'store', store] + if keyspace: + command.extend(['keyspace', keyspace]) + if types: + command.extend(['types', *(keyspace_type.value for keyspace_type in types)]) + if models: + command.extend(['models', *(model.value for model in models)]) + return await self._execute_query_with_credentials(command) + + async def policy_grant(self, owner: str, capability: PolicyCapability, store: str, keyspace: Optional[str] = None, types: Optional[List[PolicyKeyspaceType]] = None, models: Optional[List[SemanticModel]] = None) -> Any: + return await self._policy_mutation('policy-grant', owner, capability, store, keyspace, types, models) + + async def policy_revoke(self, owner: str, capability: PolicyCapability, store: str, keyspace: Optional[str] = None, types: Optional[List[PolicyKeyspaceType]] = None, models: Optional[List[SemanticModel]] = None) -> Any: + return await self._policy_mutation('policy-revoke', owner, capability, store, keyspace, types, models) + + async def policy_deny(self, owner: str, capability: PolicyCapability, store: str, keyspace: Optional[str] = None, types: Optional[List[PolicyKeyspaceType]] = None, models: Optional[List[SemanticModel]] = None) -> Any: + return await self._policy_mutation('policy-deny', owner, capability, store, keyspace, types, models) + + async def policy_remove_denial(self, owner: str, capability: PolicyCapability, store: str, keyspace: Optional[str] = None, types: Optional[List[PolicyKeyspaceType]] = None, models: Optional[List[SemanticModel]] = None) -> Any: + return await self._policy_mutation('policy-remove-denial', owner, capability, store, keyspace, types, models) + + async def policy_preview_grant(self, owner: str, capability: PolicyCapability, store: str, keyspace: Optional[str] = None, types: Optional[List[PolicyKeyspaceType]] = None, models: Optional[List[SemanticModel]] = None) -> Any: + return await self._policy_mutation('policy-preview-grant', owner, capability, store, keyspace, types, models) + + async def policy_preview_revoke(self, owner: str, capability: PolicyCapability, store: str, keyspace: Optional[str] = None, types: Optional[List[PolicyKeyspaceType]] = None, models: Optional[List[SemanticModel]] = None) -> Any: + return await self._policy_mutation('policy-preview-revoke', owner, capability, store, keyspace, types, models) + + async def _policy_manifest(self, operation: str, document: str, format: PolicyFormat = PolicyFormat.JSON) -> Any: + return await self._execute_query_with_credentials([operation, 'format', format.value, 'document', document]) + + async def policy_validate(self, document: str, format: PolicyFormat = PolicyFormat.JSON) -> Any: + return await self._policy_manifest('policy-validate', document, format) + + async def policy_plan(self, document: str, format: PolicyFormat = PolicyFormat.JSON) -> Any: + return await self._policy_manifest('policy-plan', document, format) + + async def policy_apply(self, document: str, format: PolicyFormat = PolicyFormat.JSON) -> Any: + return await self._policy_manifest('policy-apply', document, format) + + async def policy_export(self, format: PolicyFormat = PolicyFormat.JSON) -> Any: + return await self._execute_query_with_credentials(['policy-export', 'format', format.value]) async def get_structure_available(self) -> Any: """ diff --git a/montycat/core/tools.py b/montycat/core/tools.py index b7f6e54..2a711c4 100644 --- a/montycat/core/tools.py +++ b/montycat/core/tools.py @@ -40,11 +40,39 @@ def __init__(self, start: int = 0, stop: int = 0): def serialize(self): return {"start": self.start, "stop": self.stop} -class Permission(Enum): +class Permission(Enum): """Enum for permission levels.""" READ = "read" WRITE = "write" ALL = "all" - def __str__(self): - return self.value \ No newline at end of file + def __str__(self): + return self.value + +class PolicyCapability(str, Enum): + """Capabilities that can be granted through data-mesh governance policies.""" + PROVISION_KEYSPACE = "provision-keyspace" + REMOVE_KEYSPACE = "remove-keyspace" + MANAGE_SNAPSHOTS = "manage-snapshots" + MANAGE_SEMANTIC = "manage-semantic" + MANAGE_SCHEMA = "manage-schema" + MANAGE_ACCESS = "manage-access" + +class PolicyKeyspaceType(str, Enum): + """Keyspace storage types addressable by governance policies.""" + IN_MEMORY = "inmemory" + PERSISTENT = "persistent" + DISTRIBUTED = "distributed" + +class SemanticModel(str, Enum): + """Compiled embedding models supported by Montycat semantic search.""" + MINI_LM = "minilm" + BGE_SMALL = "bge-small" + BGE_BASE = "bge-base" + E5_SMALL = "e5-small" + +class PolicyFormat(str, Enum): + """Serialization formats accepted by policy manifest commands.""" + JSON = "json" + YAML = "yaml" + YML = "yml" From 31ae9e3fe87a5c57c0d46ed0ad7f7da967fb0ef6 Mon Sep 17 00:00:00 2001 From: Eugene&Monty <136487467+EugeneSukharev1988@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:48:14 -0500 Subject: [PATCH 2/5] readme --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index c8a4091..c8f3227 100644 --- a/README.md +++ b/README.md @@ -227,6 +227,8 @@ Owners can inspect their effective policy and superowners can grant delegated keyspace authority programmatically: ```python +from montycat import PolicyCapability, PolicyKeyspaceType, SemanticModel + await engine.policy_grant( "alice", PolicyCapability.PROVISION_KEYSPACE, "catalog", types=[PolicyKeyspaceType.IN_MEMORY, PolicyKeyspaceType.PERSISTENT], models=[SemanticModel.BGE_SMALL], From 04fc9383f3a289129838f0e0debfcb42127278dd Mon Sep 17 00:00:00 2001 From: Eugene&Monty <136487467+EugeneSukharev1988@users.noreply.github.com> Date: Fri, 24 Jul 2026 23:56:14 -0500 Subject: [PATCH 3/5] changes --- montycat/core/engine.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/montycat/core/engine.py b/montycat/core/engine.py index 35faace..0aa7c2c 100644 --- a/montycat/core/engine.py +++ b/montycat/core/engine.py @@ -310,7 +310,7 @@ async def policy_explain(self, capability: PolicyCapability, store: str, owner: command = ['policy-explain', 'capability', capability.value, 'store', store] if owner: command.extend(['owner', owner]) - if keyspace: + if keyspace and capability is not PolicyCapability.PROVISION_KEYSPACE: command.extend(['keyspace', keyspace]) if keyspace_type: command.extend(['type', keyspace_type.value]) @@ -320,7 +320,7 @@ async def policy_explain(self, capability: PolicyCapability, store: str, owner: async def _policy_mutation(self, operation: str, owner: str, capability: PolicyCapability, store: str, keyspace: Optional[str] = None, types: Optional[List[PolicyKeyspaceType]] = None, models: Optional[List[SemanticModel]] = None) -> Any: command = [operation, 'owner', owner, 'capability', capability.value, 'store', store] - if keyspace: + if keyspace and capability is not PolicyCapability.PROVISION_KEYSPACE: command.extend(['keyspace', keyspace]) if types: command.extend(['types', *(keyspace_type.value for keyspace_type in types)]) From 8dddd96817dd4a957734bd71d5a95795eebbff0c Mon Sep 17 00:00:00 2001 From: Eugene&Monty <136487467+EugeneSukharev1988@users.noreply.github.com> Date: Sun, 26 Jul 2026 17:23:29 -0500 Subject: [PATCH 4/5] changes --- montycat/core/engine.py | 20 ++++---- tests/test_governance_response_contract.py | 53 ++++++++++++++++++++++ 2 files changed, 64 insertions(+), 9 deletions(-) create mode 100644 tests/test_governance_response_contract.py diff --git a/montycat/core/engine.py b/montycat/core/engine.py index 0aa7c2c..4fcf3ba 100644 --- a/montycat/core/engine.py +++ b/montycat/core/engine.py @@ -117,7 +117,7 @@ async def remove_store(self) -> Any: 'remove-store', "store", self.store ]) - async def grant_to(self, owner: str, permission: Union[str, Permission], keyspaces: Optional[Union[List[str], str, None]] = None) -> Any: + async def grant_to(self, owner: str, permission: Union[str, Permission], keyspaces: Optional[Union[List[str], str, None]] = None) -> Any: """ Grants specific permissions to a user for the current store. @@ -132,10 +132,11 @@ async def grant_to(self, owner: str, permission: Union[str, Permission], keyspac Raises: ValueError: If an invalid permission is provided. """ - if permission not in self.VALID_PERMISSIONS: - raise ValueError(f"Invalid permission: {permission}. Valid permissions are: {self.VALID_PERMISSIONS}") - - command = ['grant-to', "owner", owner, "permission", permission, "store", self.store] + normalized_permission = str(permission).strip().lower() + if normalized_permission not in self.VALID_PERMISSIONS: + raise ValueError(f"Invalid permission: {permission}. Valid permissions are: {self.VALID_PERMISSIONS}") + + command = ['grant-to', "owner", owner, "permission", normalized_permission, "store", self.store] if keyspaces: command.append("keyspaces") if isinstance(keyspaces, str): @@ -160,10 +161,11 @@ async def revoke_from(self, owner: str, permission: Union[str, Permission], keys Raises: ValueError: If an invalid permission is provided. """ - if permission not in self.VALID_PERMISSIONS: - raise ValueError(f"Invalid permission: {permission}. Valid permissions are: {self.VALID_PERMISSIONS}") - - command = ['revoke-from', "owner", owner, "permission", permission, "store", self.store] + normalized_permission = str(permission).strip().lower() + if normalized_permission not in self.VALID_PERMISSIONS: + raise ValueError(f"Invalid permission: {permission}. Valid permissions are: {self.VALID_PERMISSIONS}") + + command = ['revoke-from', "owner", owner, "permission", normalized_permission, "store", self.store] if keyspaces: command.append("keyspaces") if isinstance(keyspaces, str): diff --git a/tests/test_governance_response_contract.py b/tests/test_governance_response_contract.py new file mode 100644 index 0000000..8ad69b7 --- /dev/null +++ b/tests/test_governance_response_contract.py @@ -0,0 +1,53 @@ +import unittest +from unittest.mock import AsyncMock, patch + +from montycat.core.engine import Engine +from montycat.core.utils import recursive_parse_orjson + + +class GovernanceResponseContractTests(unittest.TestCase): + def test_preserves_governance_denial_details(self): + error = ( + "Governance permission denied: capability 'manage-schema' " + "on store 'orders', keyspace 'events'" + ) + response = recursive_parse_orjson( + {"status": False, "payload": None, "error": error} + ) + self.assertEqual(response["error"], error) + + def test_preserves_creator_revocations_in_policy_views(self): + response = recursive_parse_orjson( + { + "status": True, + "payload": { + "owned_keyspaces": [ + {"revoked_creator_capabilities": ["manage-schema"]} + ] + }, + "error": None, + } + ) + self.assertEqual( + response["payload"]["owned_keyspaces"][0][ + "revoked_creator_capabilities" + ], + ["manage-schema"], + ) + + +class PermissionNormalizationTests(unittest.IsolatedAsyncioTestCase): + async def test_grant_and_revoke_normalize_permission_tokens(self): + engine = Engine("localhost", 12777, "owner", "password", "orders") + with patch.object( + engine, "_execute_query_with_credentials", new=AsyncMock(return_value={"status": True}) + ) as execute: + await engine.grant_to("delegate", " ALL ") + await engine.revoke_from("delegate", "WrItE") + + self.assertEqual(execute.await_args_list[0].args[0][4], "all") + self.assertEqual(execute.await_args_list[1].args[0][4], "write") + + +if __name__ == "__main__": + unittest.main() From 9c7cde5ff9e98aa4cd11da88a4f547fd615d71ba Mon Sep 17 00:00:00 2001 From: Eugene&Monty <136487467+EugeneSukharev1988@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:03:05 -0500 Subject: [PATCH 5/5] changelog/tests/readme --- CHANGELOG.md | 107 ++++++++ NOTES.md | 2 + README.md | 30 ++- montycat/core/engine.py | 14 + setup.py | 2 +- tests/test_engine_commands.py | 283 +++++++++++++++++++++ tests/test_governance_response_contract.py | 64 +++++ tests/test_schema_tools_helpers.py | 187 ++++++++++++++ 8 files changed, 680 insertions(+), 9 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 NOTES.md create mode 100644 tests/test_engine_commands.py create mode 100644 tests/test_schema_tools_helpers.py diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..8802a47 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,107 @@ +# Changelog + +All notable changes to the Montycat Python client are documented here. + +The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this +project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.1.0] - 2026-07-28 + +### Added + +- **Data-mesh governance policy API** on `Engine`: + - inspection — `policy_view`, `policy_history`, `policy_explain`, `policy_export` + - mutation — `policy_grant`, `policy_revoke`, `policy_deny`, `policy_remove_denial` + - dry runs — `policy_preview_grant`, `policy_preview_revoke` + - manifests — `policy_validate`, `policy_plan`, `policy_apply` (JSON or YAML documents) +- New enums, exported from the package root: `PolicyCapability`, `PolicyKeyspaceType`, + `SemanticModel`, `PolicyFormat`. +- `keyspace=` on `enable_semantic_search` / `disable_semantic_search`, for enrolling or + dropping a single keyspace instead of the whole store. Raises `ValueError` when a + keyspace is given without a store. + +### Changed + +- `grant_to` and `revoke_from` normalize the permission token (strip + lowercase), so + `" ALL "` and `"WrItE"` are accepted and `Permission` enum members serialize correctly + instead of reaching the wire as `Permission.ALL`. +- `policy_explain` and the policy mutations drop `keyspace` for + `PolicyCapability.PROVISION_KEYSPACE`, which is a store-level capability. +- Policy qualifiers are capability-specific: `models`/`model` apply to + `PROVISION_KEYSPACE` and `MANAGE_SEMANTIC`; storage-type qualifiers apply to + keyspace-scoped capabilities except `MANAGE_SNAPSHOTS`, where in-memory storage is + implicit. Invalid combinations raise `ValueError` before a command is sent. + +### Breaking + +- `enable_semantic_search(model=...)` now takes a `SemanticModel`, not a `str`. Passing a + string raises `AttributeError`. + + ```python + # before + await engine.enable_semantic_search(model="bge-small") + + # now + from montycat import SemanticModel + await engine.enable_semantic_search(model=SemanticModel.BGE_SMALL) + ``` + +### Internal + +- Added `tests/test_governance_response_contract.py` — offline unit tests covering + governance error/payload preservation through `recursive_parse_orjson` and permission + token normalization. Not part of the distributed package. + +## [1.0.7] - 2026-07-20 + +### Added + +- Hybrid semantic search: `semantic_search_get_keys_where` and + `semantic_search_get_values_where` take a `filters` dict applied as a hard metadata + pre-filter, so only matching items are ranked and scores stay pure cosine. Empty + `filters` raises `ValueError` — use the unfiltered methods instead. +- `min_score` threading through the shared semantic search path. + +## [1.0.6] - 2026-07-19 + +### Changed + +- Semantic search response structure: ranked hits are + `{'__key__': ..., '__score__': ...}`, and the value-returning variants add + `'__value__'`. + +## [1.0.5] - 2026-07-16 + +### Added + +- Semantic search — `enable_semantic_search` / `disable_semantic_search` plus the first + `semantic_search_get_keys` / `semantic_search_get_values`. +- Engine controls — `enable_wait_for_index` / `disable_wait_for_index`, + `enable_reports` / `disable_reports`, `allow_subscriptions` / `restrict_subscriptions`, + `queue_depths`, `set_snapshot_rate`, `set_expiration_check_rate`. +- `wait_for_index` on the write paths: `insert_value`, `insert_custom_key`, + `insert_custom_key_value`, `insert_bulk`, `update_value`, `update_bulk`, `delete_key`, + `delete_bulk`. + +## [1.0.4] - 2026-05-12 + +### Changed + +- Input validation for key retrieval and keyspace creation, tighter `limit` validation in + the generic and persistent KV classes. +- Bulk key handling uses explicit concatenation; query parameters are passed directly to + `convert_to_binary_query` instead of via class attributes. +- `setup.py` became the single source of truth for package metadata; `build_and_push.sh` + derives the version from it. + +## [1.0.1] - 2026-02-04 + +### Added + +- Nullable schema fields. + +## [1.0.0] - 2025-10-17 + +### Added + +- TLS support and a dedicated subscription port. diff --git a/NOTES.md b/NOTES.md new file mode 100644 index 0000000..fc75c47 --- /dev/null +++ b/NOTES.md @@ -0,0 +1,2 @@ +RUN OFFLINE TESTS +python3 -m unittest discover -s tests -v \ No newline at end of file diff --git a/README.md b/README.md index c8f3227..b1396ae 100644 --- a/README.md +++ b/README.md @@ -221,22 +221,36 @@ matching_values = await Sales.semantic_search_get_values_where( - **Do I need OpenAI or an embedding API?** No. Embeddings run on-device in the `montycat-semantic` server. No API keys, no per-query bill, no data egress. - **Is it a Pinecone / Weaviate / Chroma / Qdrant alternative?** Yes — self-hosted and open-source, with a NoSQL store built in. - **Which Python versions?** 3.9+ — fully async (`asyncio`). -## Data mesh governance -Owners can inspect their effective policy and superowners can grant delegated -keyspace authority programmatically: +## Data-mesh governance for shared and multi-tenant deployments + +Delegate administration without handing every team full server control. Policies scope +authority to an owner and store, with optional keyspace, storage-type, and semantic-model +constraints. This lets platform teams govern shared infrastructure while domain teams +operate the data products they own. + +- Grant, revoke, or explicitly deny keyspace provisioning/removal, schema, semantic, + snapshot, and access-management capabilities. +- Inspect effective permissions and history, or preview a grant/revoke before applying it. +- Validate, plan, apply, and export JSON or YAML policy manifests for repeatable + infrastructure-as-code workflows. + +For example, a superowner can separately constrain keyspace provisioning and semantic +management within one store: ```python from montycat import PolicyCapability, PolicyKeyspaceType, SemanticModel -await engine.policy_grant( +await connection.policy_grant( "alice", PolicyCapability.PROVISION_KEYSPACE, "catalog", - types=[PolicyKeyspaceType.IN_MEMORY, PolicyKeyspaceType.PERSISTENT], models=[SemanticModel.BGE_SMALL], + types=[PolicyKeyspaceType.IN_MEMORY, PolicyKeyspaceType.PERSISTENT], + models=[SemanticModel.BGE_SMALL], ) -await engine.policy_view(owner="alice", store="catalog") -await engine.enable_semantic_search( - store="catalog", keyspace="products", model=SemanticModel.BGE_SMALL +await connection.policy_grant( + "alice", PolicyCapability.MANAGE_SEMANTIC, "catalog", + keyspace="products", models=[SemanticModel.BGE_SMALL], ) +await connection.policy_view(owner="alice", store="catalog") ``` Superowners may also call `policy_validate`, `policy_plan`, `policy_apply`, and diff --git a/montycat/core/engine.py b/montycat/core/engine.py index 4fcf3ba..1532136 100644 --- a/montycat/core/engine.py +++ b/montycat/core/engine.py @@ -309,6 +309,13 @@ async def policy_history(self, owner: Optional[str] = None, store: Optional[str] return await self._execute_query_with_credentials(command) async def policy_explain(self, capability: PolicyCapability, store: str, owner: Optional[str] = None, keyspace: Optional[str] = None, keyspace_type: Optional[PolicyKeyspaceType] = None, model: Optional[SemanticModel] = None) -> Any: + if keyspace_type and capability is PolicyCapability.MANAGE_SNAPSHOTS: + raise ValueError("keyspace_type is not valid for manage-snapshots policies; snapshots are always in-memory") + if model and capability not in ( + PolicyCapability.PROVISION_KEYSPACE, + PolicyCapability.MANAGE_SEMANTIC, + ): + raise ValueError("model is only valid for provision-keyspace or manage-semantic policies") command = ['policy-explain', 'capability', capability.value, 'store', store] if owner: command.extend(['owner', owner]) @@ -321,6 +328,13 @@ async def policy_explain(self, capability: PolicyCapability, store: str, owner: return await self._execute_query_with_credentials(command) async def _policy_mutation(self, operation: str, owner: str, capability: PolicyCapability, store: str, keyspace: Optional[str] = None, types: Optional[List[PolicyKeyspaceType]] = None, models: Optional[List[SemanticModel]] = None) -> Any: + if types and capability is PolicyCapability.MANAGE_SNAPSHOTS: + raise ValueError("types is not valid for manage-snapshots policies; snapshots are always in-memory") + if models and capability not in ( + PolicyCapability.PROVISION_KEYSPACE, + PolicyCapability.MANAGE_SEMANTIC, + ): + raise ValueError("models is only valid for provision-keyspace or manage-semantic policies") command = [operation, 'owner', owner, 'capability', capability.value, 'store', store] if keyspace and capability is not PolicyCapability.PROVISION_KEYSPACE: command.extend(['keyspace', keyspace]) diff --git a/setup.py b/setup.py index d3b4689..46c2cb4 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ setup( name='montycat', - version='1.0.7', + version='1.1.0', description=( 'Self-hosted vector database + NoSQL with built-in AI semantic search — the async ' 'Python client for Montycat. A Rust-powered, AI-native Pinecone / Weaviate / Chroma ' diff --git a/tests/test_engine_commands.py b/tests/test_engine_commands.py new file mode 100644 index 0000000..538b079 --- /dev/null +++ b/tests/test_engine_commands.py @@ -0,0 +1,283 @@ +import unittest +from unittest.mock import AsyncMock, patch + +import orjson + +from montycat.core.engine import Engine +from montycat.core.tools import ( + Permission, + PolicyCapability, + PolicyFormat, + PolicyKeyspaceType, + SemanticModel, +) + + +class EngineUriTests(unittest.TestCase): + def test_parses_uri_with_optional_store(self): + engine = Engine.from_uri("montycat://alice:secret@db.example:12777/orders") + self.assertEqual( + (engine.host, engine.port, engine.username, engine.password, engine.store), + ("db.example", 12777, "alice", "secret", "orders"), + ) + self.assertIsNone( + Engine.from_uri("montycat://alice:secret@db.example:12777").store + ) + + def test_rejects_invalid_uris(self): + invalid = ( + "https://alice:secret@db.example:12777", + "montycat://db.example:12777", + "montycat://alice:secret@db.example", + ) + for uri in invalid: + with self.subTest(uri=uri), self.assertRaises(ValueError): + Engine.from_uri(uri) + + +class EngineCommandTests(unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.engine = Engine( + "localhost", 12777, "owner", "password", "orders", tls=True + ) + self.execute = AsyncMock(return_value={"status": True}) + self.patcher = patch.object( + self.engine, "_execute_query_with_credentials", new=self.execute + ) + self.patcher.start() + self.addCleanup(self.patcher.stop) + + async def assert_command(self, awaitable, expected): + self.execute.reset_mock() + result = await awaitable + self.assertEqual(result, {"status": True}) + self.execute.assert_awaited_once_with(expected) + + async def test_store_owner_and_access_commands(self): + cases = ( + (self.engine.create_store(), ["create-store", "store", "orders"]), + (self.engine.remove_store(), ["remove-store", "store", "orders"]), + ( + self.engine.create_owner("alice", "secret"), + ["create-owner", "username", "alice", "password", "secret"], + ), + (self.engine.remove_owner("alice"), ["remove-owner", "username", "alice"]), + (self.engine.list_owners(), ["list-owners"]), + ( + self.engine.grant_to("alice", Permission.READ, ["events", "users"]), + [ + "grant-to", + "owner", + "alice", + "permission", + "read", + "store", + "orders", + "keyspaces", + "events", + "users", + ], + ), + ( + self.engine.revoke_from("alice", " WRITE ", "events"), + [ + "revoke-from", + "owner", + "alice", + "permission", + "write", + "store", + "orders", + "keyspaces", + "events", + ], + ), + ) + for awaitable, expected in cases: + await self.assert_command(awaitable, expected) + + with self.assertRaisesRegex(ValueError, "Invalid permission"): + await self.engine.grant_to("alice", "admin") + + async def test_semantic_commands_and_scope_validation(self): + await self.assert_command( + self.engine.enable_semantic_search( + SemanticModel.BGE_SMALL, + field="body", + store="catalog", + keyspace="products", + ), + [ + "enable-semantic-search", + "model", + "bge-small", + "field", + "body", + "store", + "catalog", + "keyspace", + "products", + ], + ) + await self.assert_command( + self.engine.disable_semantic_search( + drop_vectors=True, store="catalog", keyspace="products" + ), + [ + "disable-semantic-search", + "drop-vectors", + "store", + "catalog", + "keyspace", + "products", + ], + ) + with self.assertRaisesRegex(ValueError, "store is required"): + await self.engine.enable_semantic_search(keyspace="products") + with self.assertRaisesRegex(ValueError, "store is required"): + await self.engine.disable_semantic_search(keyspace="products") + + async def test_policy_read_commands(self): + await self.assert_command( + self.engine.policy_view(owner="alice", store="catalog"), + ["policy-view", "owner", "alice", "store", "catalog"], + ) + await self.assert_command( + self.engine.policy_history("alice", "catalog", "products"), + [ + "policy-history", + "owner", + "alice", + "store", + "catalog", + "keyspace", + "products", + ], + ) + await self.assert_command( + self.engine.policy_explain( + PolicyCapability.MANAGE_SEMANTIC, + "catalog", + owner="alice", + keyspace="products", + keyspace_type=PolicyKeyspaceType.PERSISTENT, + model=SemanticModel.BGE_SMALL, + ), + [ + "policy-explain", + "capability", + "manage-semantic", + "store", + "catalog", + "owner", + "alice", + "keyspace", + "products", + "type", + "persistent", + "model", + "bge-small", + ], + ) + + async def test_all_policy_mutations_and_provision_scope(self): + methods = ( + ("policy_grant", "policy-grant"), + ("policy_revoke", "policy-revoke"), + ("policy_deny", "policy-deny"), + ("policy_remove_denial", "policy-remove-denial"), + ("policy_preview_grant", "policy-preview-grant"), + ("policy_preview_revoke", "policy-preview-revoke"), + ) + for method_name, operation in methods: + with self.subTest(operation=operation): + await self.assert_command( + getattr(self.engine, method_name)( + "alice", + PolicyCapability.PROVISION_KEYSPACE, + "catalog", + keyspace="ignored-for-provision", + types=[PolicyKeyspaceType.PERSISTENT], + models=[SemanticModel.BGE_SMALL], + ), + [ + operation, + "owner", + "alice", + "capability", + "provision-keyspace", + "store", + "catalog", + "types", + "persistent", + "models", + "bge-small", + ], + ) + + async def test_policy_manifest_commands(self): + for method_name, operation in ( + ("policy_validate", "policy-validate"), + ("policy_plan", "policy-plan"), + ("policy_apply", "policy-apply"), + ): + await self.assert_command( + getattr(self.engine, method_name)("rules: []", PolicyFormat.YAML), + [operation, "format", "yaml", "document", "rules: []"], + ) + await self.assert_command( + self.engine.policy_export(PolicyFormat.YML), + ["policy-export", "format", "yml"], + ) + + async def test_operator_commands(self): + cases = ( + ( + self.engine.get_structure_available(), + ["get-structure-available", "store", "orders"], + ), + (self.engine.enable_wait_for_index(), ["enable-wait-for-index"]), + (self.engine.disable_wait_for_index(), ["disable-wait-for-index"]), + (self.engine.enable_reports(), ["enable-reports"]), + (self.engine.disable_reports(), ["disable-reports"]), + (self.engine.allow_subscriptions(), ["allow-subscriptions"]), + (self.engine.restrict_subscriptions(), ["restrict-subscriptions"]), + (self.engine.queue_depths(), ["queue-depths"]), + (self.engine.set_snapshot_rate(5), ["snapshot-rate", "5"]), + (self.engine.set_expiration_check_rate(10), ["expiration-check", "10"]), + ) + for awaitable, expected in cases: + await self.assert_command(awaitable, expected) + + no_store = Engine("localhost", 12777, "owner", "password") + with patch.object( + no_store, + "_execute_query_with_credentials", + new=AsyncMock(return_value=True), + ) as execute: + await no_store.get_structure_available() + execute.assert_awaited_once_with(["get-structure-available"]) + + async def test_execute_serializes_credentials_and_tls(self): + self.patcher.stop() + with patch( + "montycat.core.engine.send_data", + new=AsyncMock(return_value={"status": True}), + ) as send: + result = await self.engine._execute_query_with_credentials(["list-owners"]) + + self.assertEqual(result, {"status": True}) + args = send.await_args.args + self.assertEqual(args[:2], ("localhost", 12777)) + self.assertEqual( + orjson.loads(args[2]), + { + "raw": ["list-owners"], + "credentials": ["owner", "password"], + }, + ) + self.assertTrue(send.await_args.kwargs["tls"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_governance_response_contract.py b/tests/test_governance_response_contract.py index 8ad69b7..4252647 100644 --- a/tests/test_governance_response_contract.py +++ b/tests/test_governance_response_contract.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock, patch from montycat.core.engine import Engine +from montycat.core.tools import PolicyCapability, PolicyKeyspaceType, SemanticModel from montycat.core.utils import recursive_parse_orjson @@ -49,5 +50,68 @@ async def test_grant_and_revoke_normalize_permission_tokens(self): self.assertEqual(execute.await_args_list[1].args[0][4], "write") +class PolicyQualifierValidationTests(unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.engine = Engine("localhost", 12777, "owner", "password", "orders") + + async def test_accepts_capability_specific_qualifiers(self): + with patch.object( + self.engine, + "_execute_query_with_credentials", + new=AsyncMock(return_value={"status": True}), + ) as execute: + await self.engine.policy_grant( + "alice", + PolicyCapability.PROVISION_KEYSPACE, + "catalog", + types=[PolicyKeyspaceType.PERSISTENT], + models=[SemanticModel.BGE_SMALL], + ) + await self.engine.policy_grant( + "alice", + PolicyCapability.MANAGE_SEMANTIC, + "catalog", + types=[PolicyKeyspaceType.PERSISTENT], + models=[SemanticModel.BGE_SMALL], + ) + + self.assertEqual( + execute.await_args_list[0].args[0][-4:], + ["types", "persistent", "models", "bge-small"], + ) + self.assertEqual( + execute.await_args_list[1].args[0][-4:], + ["types", "persistent", "models", "bge-small"], + ) + + async def test_rejects_mismatched_qualifiers(self): + with self.assertRaisesRegex(ValueError, "models.*manage-semantic"): + await self.engine.policy_grant( + "alice", + PolicyCapability.MANAGE_SCHEMA, + "catalog", + models=[SemanticModel.BGE_SMALL], + ) + with self.assertRaisesRegex(ValueError, "types.*manage-snapshots"): + await self.engine.policy_grant( + "alice", + PolicyCapability.MANAGE_SNAPSHOTS, + "catalog", + types=[PolicyKeyspaceType.PERSISTENT], + ) + with self.assertRaisesRegex(ValueError, "model.*manage-semantic"): + await self.engine.policy_explain( + PolicyCapability.MANAGE_SCHEMA, + "catalog", + model=SemanticModel.BGE_SMALL, + ) + with self.assertRaisesRegex(ValueError, "keyspace_type.*manage-snapshots"): + await self.engine.policy_explain( + PolicyCapability.MANAGE_SNAPSHOTS, + "catalog", + keyspace_type=PolicyKeyspaceType.PERSISTENT, + ) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_schema_tools_helpers.py b/tests/test_schema_tools_helpers.py new file mode 100644 index 0000000..21f04c3 --- /dev/null +++ b/tests/test_schema_tools_helpers.py @@ -0,0 +1,187 @@ +import unittest + +import orjson + +from montycat.core.schema import Schema +from montycat.core.tools import Limit, Permission, Pointer, Timestamp +from montycat.core.utils import is_u128, recursive_parse_orjson +from montycat.store_functions.store_generic_functions import ( + convert_custom_key, + convert_custom_keys, + convert_custom_keys_values, + convert_to_binary_query, + handle_limit, + handle_pointers_for_update, + handle_timestamps_and_pointers, + modify_pointers, + normalize_bools, +) + + +class ToolsTests(unittest.TestCase): + def test_timestamp_variants_and_invalid_configuration(self): + self.assertEqual( + Timestamp(start=10, end=20).serialize(), {"range_timestamp": [10, 20]} + ) + self.assertEqual(Timestamp(after=10).serialize(), {"after_timestamp": 10}) + self.assertEqual(Timestamp(before=20).serialize(), {"before_timestamp": 20}) + self.assertEqual(Timestamp(timestamp=15).serialize(), 15) + with self.assertRaisesRegex(ValueError, "Invalid timestamp"): + Timestamp().serialize() + + def test_pointer_limit_and_permission(self): + class Keyspace: + keyspace = "events" + + self.assertEqual(Pointer(Keyspace(), "abc").serialize(), ["events", "abc"]) + self.assertEqual(Limit(2, 5).serialize(), {"start": 2, "stop": 5}) + self.assertEqual(str(Permission.ALL), "all") + + +class SchemaTests(unittest.TestCase): + class Event(Schema): + name: str + count: int + note: str | None + + class LinkedEvent(Schema): + parent: Pointer + created_at: Timestamp + + def test_validates_required_optional_extra_and_types(self): + event = self.Event(name="launch", count=2) + self.assertEqual( + event.serialize(), + { + "name": "launch", + "count": 2, + "note": None, + "schema": "Event", + }, + ) + self.assertEqual(str(self.Event), "Event") + self.assertEqual(repr(self.Event), "Event") + + with self.assertRaisesRegex(ValueError, "Missing required field"): + self.Event(name="launch") + with self.assertRaisesRegex(ValueError, "Unexpected field"): + self.Event(name="launch", count=2, surprise=True) + with self.assertRaisesRegex(TypeError, "should be of type 'int'"): + self.Event(name="launch", count="two") + + def test_extracts_pointer_and_timestamp_metadata(self): + linked = self.LinkedEvent( + parent=Pointer("events", "root"), + created_at=Timestamp(timestamp=123), + ) + self.assertEqual(linked.pointers, {"parent": ["events", "root"]}) + self.assertEqual(linked.timestamps, {"created_at": 123}) + self.assertEqual(linked.schema, "LinkedEvent") + + +class UtilityTests(unittest.TestCase): + def test_recursively_parses_nested_json_and_preserves_u128(self): + huge = "340282366920938463463374607431768211455" + parsed = recursive_parse_orjson( + {"nested": '[1, "{\\"ok\\": true}"]', "tuple": ("2",), "id": huge} + ) + self.assertEqual(parsed["nested"], [1, {"ok": True}]) + self.assertEqual(parsed["tuple"], (2,)) + self.assertEqual(parsed["id"], huge) + self.assertTrue(is_u128(huge)) + self.assertFalse(is_u128("123")) + + def test_key_pointer_boolean_and_limit_helpers(self): + self.assertEqual(convert_custom_keys(["a", 2]), [ + convert_custom_key("a"), + convert_custom_key(2), + ]) + self.assertEqual( + convert_custom_keys_values({"a": 1}), + {convert_custom_key("a"): 1}, + ) + value = {"parent": Pointer("events", "abc")} + self.assertEqual( + handle_pointers_for_update(value), {"parent": ["events", "abc"]} + ) + self.assertEqual( + modify_pointers({"pointers": {"parent": ["events", "abc"]}}), + {"pointers": {"parent": ["events", convert_custom_key("abc")]}}, + ) + self.assertEqual(orjson.loads(normalize_bools({"ok": True})), {"ok": True}) + self.assertEqual(handle_limit([2, 5]), {"start": 2, "stop": 5}) + self.assertEqual(handle_limit(5), {"start": 0, "stop": 5}) + self.assertEqual(handle_limit([]), {"start": 0, "stop": 0}) + for invalid in ([2], [5, 2], -1, "5"): + with self.subTest(invalid=invalid), self.assertRaises(ValueError): + handle_limit(invalid) + + def test_timestamp_and_pointer_search_criteria(self): + result = handle_timestamps_and_pointers( + { + "created": Timestamp(after=10), + "parent": Pointer("events", "abc"), + "active": True, + } + ) + self.assertEqual( + result, + { + "created": {"after_timestamp": 10}, + "active": True, + "pointers": {"parent": ["events", "abc"]}, + }, + ) + + def test_binary_query_serialization_and_optional_semantic_fields(self): + class Query: + username = "owner" + password = "secret" + keyspace = "events" + store = "orders" + persistent = True + distributed = False + + query = orjson.loads( + convert_to_binary_query( + Query, + command="semantic-search", + key=7, + value={"schema": "Event", "parent": Pointer("events", "abc")}, + search_criteria={"active": True}, + bulk_keys=[1, "two"], + semantic_query="launch", + min_score=0.7, + semantic_filter={"created": Timestamp(after=10)}, + wait_for_index=True, + ) + ) + self.assertEqual(query["key"], "7") + self.assertEqual(query["schema"], "Event") + self.assertEqual(query["bulk_keys"], ["1", "two"]) + self.assertEqual(query["search_criteria"], "launch") + self.assertEqual(query["min_score"], 0.7) + self.assertTrue(query["wait_for_index"]) + self.assertEqual(orjson.loads(query["value"])["parent"], ["events", "abc"]) + self.assertEqual( + orjson.loads(query["semantic_filter"]), + {"created": {"after_timestamp": 10}}, + ) + + def test_bulk_values_must_share_one_schema(self): + class Query: + username = password = keyspace = store = "" + persistent = distributed = False + + with self.assertRaisesRegex(ValueError, "only one schema"): + convert_to_binary_query( + Query, + bulk_values=[ + {"schema": "First", "value": 1}, + {"schema": "Second", "value": 2}, + ], + ) + + +if __name__ == "__main__": + unittest.main()