PyMkDB 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. pymkdb/__init__.py +6 -0
  2. pymkdb/cli.py +57 -0
  3. pymkdb-0.1.0.dist-info/METADATA +86 -0
  4. pymkdb-0.1.0.dist-info/RECORD +54 -0
  5. pymkdb-0.1.0.dist-info/WHEEL +5 -0
  6. pymkdb-0.1.0.dist-info/entry_points.txt +2 -0
  7. pymkdb-0.1.0.dist-info/top_level.txt +3 -0
  8. sdk/__init__.py +1 -0
  9. sdk/connection.py +225 -0
  10. sdk/delta.py +19 -0
  11. sdk/http_connection.py +180 -0
  12. sdk/mkdb_client.py +226 -0
  13. sdk/responses.py +154 -0
  14. src/__init__.py +1 -0
  15. src/config/db.py +227 -0
  16. src/config/server.py +52 -0
  17. src/db/__init__.py +207 -0
  18. src/db/cache/__init__.py +1 -0
  19. src/db/cache/ram_cache.py +144 -0
  20. src/db/cache/write_queue.py +156 -0
  21. src/db/maintenance/__init__.py +0 -0
  22. src/db/maintenance/compactor.py +118 -0
  23. src/db/maintenance/task_scheduler.py +73 -0
  24. src/db/objects/store.py +283 -0
  25. src/db/parity/__init__.py +0 -0
  26. src/db/parity/parity_manager.py +196 -0
  27. src/db/query/__init__.py +1 -0
  28. src/db/query/full_text_index.py +168 -0
  29. src/db/query/numeric_index.py +196 -0
  30. src/db/query/query_engine.py +308 -0
  31. src/db/query/tokenizer.py +48 -0
  32. src/db/query_workers/__init__.py +16 -0
  33. src/db/query_workers/dispatcher.py +339 -0
  34. src/db/query_workers/task.py +78 -0
  35. src/db/query_workers/worker.py +292 -0
  36. src/db/requesting/main.py +0 -0
  37. src/db/storage/__init__.py +1 -0
  38. src/db/storage/blob_store.py +47 -0
  39. src/db/storage/index_manager.py +92 -0
  40. src/db/storage/log_manager.py +119 -0
  41. src/db/storage/serializer.py +38 -0
  42. src/filing/__init__.py +31 -0
  43. src/objects/__init__.py +190 -0
  44. src/runtime/__init__.py +15 -0
  45. src/server/__init__.py +0 -0
  46. src/server/coms/actions.py +209 -0
  47. src/server/coms/http.py +46 -0
  48. src/server/coms/http_handlers.py +445 -0
  49. src/server/coms/metrics.py +231 -0
  50. src/server/coms/socket.py +461 -0
  51. src/server/coms/socket_protocol.py +54 -0
  52. src/server/control/api/actions.py +1001 -0
  53. src/server/control/server.py +404 -0
  54. src/server/event_log.py +58 -0
pymkdb/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """
2
+ PyMkDB — Python client SDK and server package for MkDB.
3
+ """
4
+
5
+ __version__ = "0.1.0"
6
+ __author__ = "MNG"
pymkdb/cli.py ADDED
@@ -0,0 +1,57 @@
1
+ """
2
+ pymkdb.cli — console entry point for the `mkdb` command.
3
+
4
+ Usage:
5
+ mkdb [PATH_TO_DB] Start the server pointing at a database directory
6
+ mkdb [PATH_TO_DB] -c Generate a default config.json in that directory
7
+ """
8
+
9
+ import os
10
+ import sys
11
+
12
+
13
+ def main():
14
+ from colorama import Fore
15
+ from src.db import mkdb
16
+ from src.config.db import mkdb_config
17
+ from src.filing import read_json, write_json
18
+ from src.runtime import runtime_settings
19
+
20
+ print(f"""{Fore.CYAN}
21
+ ╔═══════════════════════════════════════╗
22
+ ║ ║
23
+ ║ Initializing ║
24
+ ║ MkDB ║
25
+ ║ ║
26
+ ╚═══════════════════════════════════════╝
27
+ {Fore.RESET}""")
28
+
29
+ try:
30
+ print("Initialized CWD:", os.getcwd())
31
+ pointer = sys.argv[1] if len(sys.argv) > 1 else runtime_settings.args.config
32
+ print("Pointing to:", pointer)
33
+ print(f"{Fore.CYAN}Reading Config...{Fore.RESET}")
34
+ if not pointer.endswith(".json"):
35
+ os.chdir(pointer)
36
+ CONFIG = read_json(runtime_settings.args.config)
37
+ except FileNotFoundError:
38
+ CONFIG = {}
39
+ if "-c" in sys.argv or "--generate-config" in sys.argv:
40
+ print(f"{Fore.GREEN}Generating default config file at "
41
+ f"{runtime_settings.args.config}{Fore.RESET}")
42
+ write_json(runtime_settings.args.config, mkdb_config().json)
43
+ sys.exit(0)
44
+ print(f"{Fore.YELLOW}Config file not found at {runtime_settings.args.config}")
45
+ print(f"{Fore.BLUE}Use a path to a db directory or -c to generate a "
46
+ f"new config.{Fore.RESET}")
47
+ sys.exit(1)
48
+ except Exception as e:
49
+ print(f"{Fore.RED}Error loading config: {e}{Fore.RESET}")
50
+ sys.exit(1)
51
+
52
+ DATA_BASE = mkdb(CONFIG)
53
+ DATA_BASE.run()
54
+
55
+
56
+ if __name__ == "__main__":
57
+ main()
@@ -0,0 +1,86 @@
1
+ Metadata-Version: 2.4
2
+ Name: PyMkDB
3
+ Version: 0.1.0
4
+ Summary: A log-structured, partitioned NoSQL database engine with full-text search, numeric indexes, and dual TCP/HTTP protocols.
5
+ Author: MNG
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/MNG/MkDB
8
+ Project-URL: Repository, https://github.com/MNG/MkDB
9
+ Keywords: database,nosql,document-store,full-text-search,log-structured
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: Database
18
+ Classifier: Topic :: Database :: Database Engines/Servers
19
+ Requires-Python: >=3.10
20
+ Description-Content-Type: text/markdown
21
+ Requires-Dist: colorama>=0.4.6
22
+ Requires-Dist: reedsolo>=1.7.0
23
+
24
+ # MkDB
25
+
26
+ MkDB is a Custom Log-Structured Merge & Partitioned Redundant Storage Engine built entirely in Python. It provides a robust, highly-available NoSQL/Document database experience with secondary indexing, full-text search, and dual-protocol network access.
27
+
28
+ ## Architecture Highlights
29
+
30
+ - **Rolling Log Storage Engine**: Append-only storage format guaranteeing high write availability with safe background compaction.
31
+ - **RAM Cache & Debounced Write Queue**: In-memory caching and debounced batching for extreme performance under high write load.
32
+ - **Query Engine & Secondary Indexes**: Fully featured query evaluation including numeric range checks and tokenized full-text inverted indexes.
33
+ - **Data Integrity**: Multi-disk mirroring and Reed-Solomon parity encoding for proactive self-healing and failover.
34
+ - **Dual Protocols**: Accessible via high-speed, persistent TCP WebSockets or standard stateless REST HTTP endpoints.
35
+ - **Web Administration UI**: Includes an embedded web control panel out-of-the-box (`/control` endpoint).
36
+
37
+ ## Project Structure
38
+
39
+ - `src/db/`: The core database engine (storage primitives, RAM caching, query evaluator, auto-compaction and parity management).
40
+ - `src/server/`: The networking boundary. Houses the TCP Socket and HTTP REST servers, as well as the web-based Control Panel.
41
+ - `src/config/`: Configuration schemas for tailoring memory limits, storage thresholds, and cluster layout.
42
+ - `sdk/`: The official `MkDBClient` for programmatic interaction from Python code.
43
+
44
+ ## Getting Started
45
+
46
+ ### Prerequisites
47
+ - Python 3.10+
48
+ - The database storage format is built into MkDB natively, but you'll need the following for advanced data integrity features (Reed-Solomon logic):
49
+ ```bash
50
+ pip install reedsolo
51
+ ```
52
+
53
+ ### Starting the Server
54
+ MkDB operates as a CLI tool. Launch the engine by pointing it to your desired database directory (which must contain a `config.json` file configuring your stores and network bindings):
55
+ ```bash
56
+ mkdb /path/to/your/db
57
+ ```
58
+ Once running, the database will host both TCP socket and HTTP interfaces as specified in your `config.json`. The web control panel is accessible via your browser (check server output for the bound port, normally `http://localhost:<port>`).
59
+
60
+ ### Using the Python SDK
61
+ The `MkDBClient` connects seamlessly to your database and abstracts the dual-protocol system:
62
+
63
+ ```python
64
+ from sdk.mkdb_client import MkDBClient
65
+
66
+ client = MkDBClient()
67
+ client.connect(host="127.0.0.1", port=8080)
68
+
69
+ # Writing a document (computes delta updates intelligently)
70
+ client.set(
71
+ store="products",
72
+ record_id="prod_001",
73
+ data={"name": "Steel Bolt", "price": 9.99, "category": "fasteners"}
74
+ )
75
+
76
+ # Reading a document
77
+ record = client.get("products", "prod_001")
78
+
79
+ # Querying with filters
80
+ results = client.query("products", filter={
81
+ "price": {"<=": 10.00},
82
+ "category": ["fasteners"]
83
+ })
84
+ ```
85
+
86
+ See the `docs/` folder for comprehensive guides on the Query Syntax and SDK Reference.
@@ -0,0 +1,54 @@
1
+ pymkdb/__init__.py,sha256=tTWRC26zpfcuI2ejtTeFGbpKppN6DS7J7MXGnxpSMvQ,114
2
+ pymkdb/cli.py,sha256=PHY4XJnmi5hoPF28F9qgytyyO9O9l_01mBr47my-Efk,2170
3
+ sdk/__init__.py,sha256=VZALxUv5N8fhQFU04coWBy6DoqEPD0Yv0wE60R-Jo-k,12
4
+ sdk/connection.py,sha256=vh9cTf-sYQvrF2qSiXMNw-HAtiaDujo7l6BvpINRFXQ,8445
5
+ sdk/delta.py,sha256=55rdFZvYfVEEkClSaQY4DgqDGuRamxVFB7-D23BfvJo,641
6
+ sdk/http_connection.py,sha256=g_YTcbfS3neybvaOBxQxAHMFLtI86ENIFz7iBC33MWw,7254
7
+ sdk/mkdb_client.py,sha256=vCmfN9uT_XOMc8hvFhOEslZoZNCjiDoNGs7nk811zRQ,8271
8
+ sdk/responses.py,sha256=707Hbm-8M6Z83kOocQ2UyAp7F3ko-xklLCycp8-BF5U,5020
9
+ src/__init__.py,sha256=rM5xk_iNEOneevj9ArdMGCMFHUA7Ac9JfNHFVPuXW08,28
10
+ src/config/db.py,sha256=y0qYO6ZLt7F487SuUsY4fBqZtB0tqCbDVI3ap0CH_3c,9674
11
+ src/config/server.py,sha256=p8Bcy1twSItc2RvFErIlN17xjFeG1ZkMFJiqu5B6AC0,2030
12
+ src/db/__init__.py,sha256=g2zrEwJTIrJyg7IY8oaw4MAE7uFQaOhDU3InfRot4IY,8495
13
+ src/db/cache/__init__.py,sha256=frcCV1k9oG9oKj3dpUqdJg1PxRT2RSN_XKdLCPjaYaY,2
14
+ src/db/cache/ram_cache.py,sha256=Odv6bNwIwVduywAMPKiNcAro9pS3Bi3GVlFrSdgQx9E,5916
15
+ src/db/cache/write_queue.py,sha256=GECdwsOOrzlkqz_oWZOSMzhoPucelE9r-JYe2FxS-tk,6012
16
+ src/db/maintenance/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
17
+ src/db/maintenance/compactor.py,sha256=uyFWOL8WQCBJ3QjxqpEdQcUFAcRLIQXYJJTt4UxdG0w,4596
18
+ src/db/maintenance/task_scheduler.py,sha256=gWD0WMD3gI56HZyZxFvZk1ZnYpyUgKKaezhU7TOXwVc,2680
19
+ src/db/objects/store.py,sha256=HKO5uBGss9DwfqpYDIqwqhCEuXTcD-z3d7MZxhyfwDA,12908
20
+ src/db/parity/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
21
+ src/db/parity/parity_manager.py,sha256=JW2NVg7atDjFFkyeQy4v7BtHpUkiYDAA3lL8ulFUNoQ,8385
22
+ src/db/query/__init__.py,sha256=frcCV1k9oG9oKj3dpUqdJg1PxRT2RSN_XKdLCPjaYaY,2
23
+ src/db/query/full_text_index.py,sha256=j8UOdgxChWU0leTSIgkg59dLwd3DA0I5AUiFBjtd0hY,6455
24
+ src/db/query/numeric_index.py,sha256=Rye0c5Ip5ZtC3jyqgL92AZIG0qeiTtCfi6TYjcjgbMU,7824
25
+ src/db/query/query_engine.py,sha256=4_Fzi3nBrQxrqJ2GWJfCFqkTLL4niOQdNIUDSzvLBjM,13120
26
+ src/db/query/tokenizer.py,sha256=MrKWv3jo35Za-YmC6Hkhh9-qwAE08OQupLxP3nDth0Y,1589
27
+ src/db/query_workers/__init__.py,sha256=KHLEVFKnMYcU9xisLJkBw84uWwPqi0LqX8yts4VpRbM,515
28
+ src/db/query_workers/dispatcher.py,sha256=7Du5ThvzKsGKkh1PG5fEawT1zyf-kVkG8CwjErlxqSI,12308
29
+ src/db/query_workers/task.py,sha256=AcWAhNnDIWQJHvi36-6eSrH8qPp6vOEgDE4oUooxSZU,2199
30
+ src/db/query_workers/worker.py,sha256=a9MtbgI3AJvbT9VZH3EbZsfug3jshg87dtInBEk-mh0,10413
31
+ src/db/requesting/main.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
32
+ src/db/storage/__init__.py,sha256=frcCV1k9oG9oKj3dpUqdJg1PxRT2RSN_XKdLCPjaYaY,2
33
+ src/db/storage/blob_store.py,sha256=7TEMQh43S7rG9m9xaFy1-NEEVr3yvTp6MsV-ktGKqYc,1843
34
+ src/db/storage/index_manager.py,sha256=CBJNsHDA3SJF438mX1fEbKxxbCGpxbwadTr6dAUNG0I,3905
35
+ src/db/storage/log_manager.py,sha256=uHKf3axUwvEKvkCun0_q4bwLglsy6VcuKwZrZBSJ6BA,4681
36
+ src/db/storage/serializer.py,sha256=pQLj1_7u-aCCi8_Zs-2_ZHjBVENLMnlex-fK_SPA3V8,1186
37
+ src/filing/__init__.py,sha256=SbJobACQxc77TVRrwxx52EF9MEX2ndD22hzZsu7lxXE,695
38
+ src/objects/__init__.py,sha256=Di8e7nDXnpAFOaz0WnSLoY8pRZTH29M9ozOhoOT1jM4,6876
39
+ src/runtime/__init__.py,sha256=nkBs06mYlUxJBjT_rkIiMZmeutHJLrHJsJiPt23zudY,459
40
+ src/server/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
41
+ src/server/event_log.py,sha256=FJUFPVHlnALclorIh8U9oY5y1FKqXKML54ZR7_hUkfY,1805
42
+ src/server/coms/actions.py,sha256=o4nR-aI1h6HG8mKi_KVtpwJIF8ddDvx-BEwQRSzIOwg,8212
43
+ src/server/coms/http.py,sha256=5g4NcugfkrPPFSzfYLI_VoMV7d9W_1qN5IucSvzLPH8,1715
44
+ src/server/coms/http_handlers.py,sha256=H3HAViE1WOkO0q_iA-88zUq97vWzXo7t0YrrcW7Zo7A,17740
45
+ src/server/coms/metrics.py,sha256=OtfWiU4xFFhqewDCKTNuCi8kOy8EdLgCdiNXYEkzzUA,7683
46
+ src/server/coms/socket.py,sha256=icwRpGcHpCg_hpJCHqO7sWUkveSJG_S9sDMLPmwpeqY,19470
47
+ src/server/coms/socket_protocol.py,sha256=6-3PKsux6HNWeFN_aJRWxp4kYKlDnl15fFM4XlpjjM8,1738
48
+ src/server/control/server.py,sha256=D_R595I7SOME57MBLr1FdKPQ_3mEmrDXSSLIZs2pNyk,15802
49
+ src/server/control/api/actions.py,sha256=ScLMaoTJW6rkOYBArNW2H_IUChuZ8LYivHKKapWzsWA,40751
50
+ pymkdb-0.1.0.dist-info/METADATA,sha256=ZGNeM3gMJaWRi4QA3RRscjHPyU6A5lYFcnixbgM4sGc,3974
51
+ pymkdb-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
52
+ pymkdb-0.1.0.dist-info/entry_points.txt,sha256=OaR65dAk0Ctb2rAFKwqjCcr5NXAvxX_TPcDophjvE-I,41
53
+ pymkdb-0.1.0.dist-info/top_level.txt,sha256=nSypT91Rm-kq1NIv25tTLerFJt1RY1lzbQNzSTdg6FE,15
54
+ pymkdb-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ mkdb = pymkdb.cli:main
@@ -0,0 +1,3 @@
1
+ pymkdb
2
+ sdk
3
+ src
sdk/__init__.py ADDED
@@ -0,0 +1 @@
1
+ # MkDB SDK
sdk/connection.py ADDED
@@ -0,0 +1,225 @@
1
+ """
2
+ Low-level persistent TCP connection to a MkDB socket server.
3
+
4
+ Wire format: 4-byte big-endian uint32 length + UTF-8 JSON payload.
5
+ Mirrors src/server/coms/socket_protocol.py (client-side copy).
6
+ """
7
+
8
+ import json
9
+ import socket
10
+ import struct
11
+ import threading
12
+ import uuid
13
+ from typing import Callable, Optional
14
+
15
+
16
+ MAX_FRAME = 10 * 1024 * 1024 # 10 MB
17
+
18
+
19
+ def _recv_exact(sock: socket.socket, n: int) -> bytes:
20
+ buf = b""
21
+ while len(buf) < n:
22
+ chunk = sock.recv(n - len(buf))
23
+ if not chunk:
24
+ raise ConnectionError("Connection closed")
25
+ buf += chunk
26
+ return buf
27
+
28
+
29
+ def _encode(payload: dict) -> bytes:
30
+ body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
31
+ return struct.pack(">I", len(body)) + body
32
+
33
+
34
+ def _decode(data: bytes) -> dict:
35
+ return json.loads(data.decode("utf-8"))
36
+
37
+
38
+ class Connection:
39
+ """
40
+ Thread-safe persistent connection to MkDB.
41
+
42
+ Outbound: _send() serialises and writes to socket.
43
+ Inbound: background _reader_loop() dispatches to registered handlers.
44
+ """
45
+
46
+ def __init__(self, host: str = "127.0.0.1", port: int = 9001,
47
+ recv_timeout: float = 30.0,
48
+ access: str = "R",
49
+ username: str = "",
50
+ password: str = ""):
51
+ self.host = host
52
+ self.port = port
53
+ self.recv_timeout = recv_timeout
54
+ self._access = access.upper() if access.upper() in ("R", "W", "RW") else "R"
55
+ self._username = username
56
+ self._password = password
57
+ self._sock: Optional[socket.socket] = None
58
+ self._lock = threading.Lock()
59
+ self._pending: dict[str, threading.Event] = {} # correlation_id -> Event
60
+ self._results: dict[str, dict] = {} # correlation_id -> response dict
61
+ self._push_handlers: list[Callable[[dict], None]] = [] # for server-push events
62
+ self._running = False
63
+ self.can_read = False
64
+ self.can_write = False
65
+
66
+ # ------------------------------------------------------------------
67
+ # Connect / disconnect
68
+ # ------------------------------------------------------------------
69
+
70
+ def connect(self) -> None:
71
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
72
+ sock.settimeout(self.recv_timeout)
73
+ sock.connect((self.host, self.port))
74
+
75
+ # ── Handshake ────────────────────────────────────────────────
76
+ perm = self._handshake_recv(sock)
77
+ if perm.get("type") != "permissions":
78
+ sock.close()
79
+ raise ConnectionError(f"Expected 'permissions', got: {perm}")
80
+
81
+ read_protected = perm.get("read_protected", False)
82
+ write_protected = perm.get("write_protected", False)
83
+
84
+ # Declare desired access level
85
+ self._handshake_send(sock, {"access": self._access})
86
+
87
+ need_auth = (
88
+ (self._access in ("W", "RW") and write_protected)
89
+ or (self._access == "R" and read_protected)
90
+ )
91
+
92
+ if need_auth:
93
+ challenge = self._handshake_recv(sock)
94
+ if challenge.get("type") != "auth_required":
95
+ sock.close()
96
+ raise ConnectionError(f"Expected 'auth_required', got: {challenge}")
97
+ if not self._password:
98
+ sock.close()
99
+ raise PermissionError("Server requires authentication but no password provided")
100
+ self._handshake_send(sock, {
101
+ "type": "auth",
102
+ "username": self._username,
103
+ "password": self._password,
104
+ })
105
+ result = self._handshake_recv(sock)
106
+ if result.get("type") == "error":
107
+ sock.close()
108
+ raise PermissionError(result.get("error", "Authentication failed"))
109
+ if result.get("type") != "auth_ok":
110
+ sock.close()
111
+ raise ConnectionError(f"Unexpected handshake response: {result}")
112
+ self.can_read = result.get("can_read", False)
113
+ self.can_write = result.get("can_write", False)
114
+ else:
115
+ ready = self._handshake_recv(sock)
116
+ if ready.get("type") == "error":
117
+ sock.close()
118
+ raise ConnectionError(ready.get("error", "Connection refused"))
119
+ self.can_read = ready.get("can_read", True)
120
+ self.can_write = ready.get("can_write", False)
121
+
122
+ self._sock = sock
123
+ self._running = True
124
+ reader = threading.Thread(
125
+ target=self._reader_loop, daemon=True, name="MkDB-SDK-reader"
126
+ )
127
+ reader.start()
128
+
129
+ @staticmethod
130
+ def _handshake_send(sock: socket.socket, msg: dict) -> None:
131
+ body = json.dumps(msg).encode("utf-8")
132
+ sock.sendall(struct.pack(">I", len(body)) + body)
133
+
134
+ @staticmethod
135
+ def _handshake_recv(sock: socket.socket) -> dict:
136
+ hdr = _recv_exact(sock, 4)
137
+ length = struct.unpack(">I", hdr)[0]
138
+ return json.loads(_recv_exact(sock, length).decode("utf-8"))
139
+
140
+ def close(self) -> None:
141
+ self._running = False
142
+ if self._sock:
143
+ try:
144
+ self._sock.close()
145
+ except Exception:
146
+ pass
147
+ self._sock = None
148
+
149
+ # ------------------------------------------------------------------
150
+ # Send / receive
151
+ # ------------------------------------------------------------------
152
+
153
+ def send(self, payload: dict) -> dict:
154
+ """
155
+ Send a request and block until the matching response arrives.
156
+ Returns the response dict.
157
+ """
158
+ correlation_id = str(uuid.uuid4())
159
+ payload["id"] = correlation_id
160
+ payload.setdefault("type", "request")
161
+
162
+ event = threading.Event()
163
+ with self._lock:
164
+ self._pending[correlation_id] = event
165
+
166
+ frame = _encode(payload)
167
+ with self._lock:
168
+ self._sock.sendall(frame)
169
+
170
+ event.wait(timeout=self.recv_timeout)
171
+ with self._lock:
172
+ result = self._results.pop(correlation_id, None)
173
+ self._pending.pop(correlation_id, None)
174
+ if result is None:
175
+ raise TimeoutError("No response received within timeout")
176
+ return result
177
+
178
+ def send_raw(self, payload: dict) -> None:
179
+ """Fire-and-forget (used for subscribe)."""
180
+ frame = _encode(payload)
181
+ with self._lock:
182
+ self._sock.sendall(frame)
183
+
184
+ def register_push_handler(self, handler: Callable[[dict], None]) -> None:
185
+ """Register a callback for server-push (ping, update, subscribed, disconnect)."""
186
+ self._push_handlers.append(handler)
187
+
188
+ # ------------------------------------------------------------------
189
+ # Reader loop (background thread)
190
+ # ------------------------------------------------------------------
191
+
192
+ def _reader_loop(self) -> None:
193
+ while self._running and self._sock:
194
+ try:
195
+ length_bytes = _recv_exact(self._sock, 4)
196
+ length = struct.unpack(">I", length_bytes)[0]
197
+ if length > MAX_FRAME:
198
+ break # protocol violation — disconnect
199
+ payload_bytes = _recv_exact(self._sock, length)
200
+ msg = _decode(payload_bytes)
201
+ except Exception:
202
+ break
203
+
204
+ msg_type = msg.get("type")
205
+ correlation_id = msg.get("id")
206
+
207
+ if msg_type == "response" and correlation_id:
208
+ with self._lock:
209
+ event = self._pending.get(correlation_id)
210
+ if event:
211
+ self._results[correlation_id] = msg
212
+ event.set()
213
+ elif msg_type == "ping":
214
+ # Respond with pong
215
+ try:
216
+ self._sock.sendall(_encode({"type": "pong"}))
217
+ except Exception:
218
+ break
219
+ else:
220
+ # Server-push: dispatch to registered handlers
221
+ for handler in self._push_handlers:
222
+ try:
223
+ handler(msg)
224
+ except Exception:
225
+ pass
sdk/delta.py ADDED
@@ -0,0 +1,19 @@
1
+ """
2
+ Delta helpers — flatten nested dicts into dot-notation keys for MkDB writes.
3
+
4
+ Example:
5
+ flatten({"product": {"name": "Widget", "price": 9.99}})
6
+ # → {"product.name": "Widget", "product.price": 9.99}
7
+ """
8
+
9
+
10
+ def flatten(d: dict, prefix: str = "", sep: str = ".") -> dict:
11
+ """Recursively flatten a nested dict into dot-notation keys."""
12
+ result = {}
13
+ for key, value in d.items():
14
+ full_key = f"{prefix}{sep}{key}" if prefix else key
15
+ if isinstance(value, dict):
16
+ result.update(flatten(value, full_key, sep))
17
+ else:
18
+ result[full_key] = value
19
+ return result
sdk/http_connection.py ADDED
@@ -0,0 +1,180 @@
1
+ """
2
+ HTTP transport for the MkDB SDK.
3
+
4
+ Implements the same interface that Connection does (send / close / can_read /
5
+ can_write / register_push_handler) but uses plain HTTP requests against the
6
+ MkDB HTTP data-plane server instead of the persistent socket protocol.
7
+
8
+ Limitations vs. socket transport:
9
+ - No server-push / pub-sub (register_push_handler is a no-op).
10
+ - Each call opens a new HTTP request (no persistent connection).
11
+ - `send_raw` is a no-op (subscribe is not supported).
12
+ """
13
+
14
+ import base64
15
+ import json
16
+ import urllib.request
17
+ import urllib.error
18
+ from typing import Callable, Optional
19
+
20
+
21
+ class HttpConnection:
22
+ """HTTP client that mirrors the Connection interface."""
23
+
24
+ def __init__(
25
+ self,
26
+ host: str = "127.0.0.1",
27
+ port: int = 80,
28
+ recv_timeout: float = 30.0,
29
+ access: str = "RW",
30
+ username: str = "",
31
+ password: str = "",
32
+ ):
33
+ self.host = host
34
+ self.port = port
35
+ self.recv_timeout = recv_timeout
36
+ self._access = access.upper()
37
+ self._username = username
38
+ self._password = password
39
+
40
+ # Mirrors Connection public attributes
41
+ self.can_read = "R" in self._access
42
+ self.can_write = "W" in self._access
43
+
44
+ self._base_url = f"http://{host}:{port}"
45
+ self._auth_header: Optional[str] = None # populated in connect()
46
+
47
+ # ------------------------------------------------------------------
48
+ # Lifecycle (no persistent connection needed for HTTP)
49
+ # ------------------------------------------------------------------
50
+
51
+ def connect(self) -> None:
52
+ """Build auth header if credentials are provided; verify reachability."""
53
+ if self._username and self._password:
54
+ raw = f"{self._username}:{self._password}"
55
+ encoded = base64.b64encode(raw.encode()).decode()
56
+ self._auth_header = f"Basic {encoded}"
57
+ # Optional: hit /health to verify the server is up
58
+ try:
59
+ self._http_get("/health")
60
+ except Exception as exc:
61
+ raise ConnectionError(f"MkDB HTTP server unreachable at {self._base_url}: {exc}") from exc
62
+
63
+ def close(self) -> None:
64
+ """No persistent socket to close."""
65
+ pass
66
+
67
+ # ------------------------------------------------------------------
68
+ # Send (translate socket-style payload to HTTP calls)
69
+ # ------------------------------------------------------------------
70
+
71
+ def send(self, payload: dict) -> dict:
72
+ """
73
+ Translate a socket-style request dict into the appropriate HTTP call.
74
+
75
+ Supported actions: ping, read, write, delete, query.
76
+ """
77
+ action = payload.get("action", "")
78
+ store = payload.get("store", "")
79
+ record_id = payload.get("record_id", "")
80
+ delta = payload.get("delta", {})
81
+ filter_d = payload.get("filter", {})
82
+ hydrate = payload.get("hydrate", False)
83
+
84
+ if action == "ping":
85
+ data = self._http_get("/health")
86
+ return {"type": "response", "status": "ok", "data": data}
87
+
88
+ if action == "read":
89
+ if not store or not record_id:
90
+ return self._err("'store' and 'record_id' are required for read")
91
+ data = self._http_get(f"/data/{store}/{record_id}")
92
+ return {"type": "response", "status": "ok", "data": data}
93
+
94
+ if action == "write":
95
+ body = {"store": store, "record_id": record_id, "delta": delta}
96
+ data = self._http_post("/data", body)
97
+ return {"type": "response", "status": "ok", "data": data}
98
+
99
+ if action == "delete":
100
+ if not store or not record_id:
101
+ return self._err("'store' and 'record_id' are required for delete")
102
+ data = self._http_delete(f"/data/{store}/{record_id}")
103
+ return {"type": "response", "status": "ok", "data": data}
104
+
105
+ if action == "query":
106
+ body = {"store": store, "filter": filter_d, "hydrate": hydrate}
107
+ data = self._http_post("/query", body)
108
+ return {"type": "response", "status": "ok", "data": data}
109
+
110
+ return self._err(f"Action '{action}' is not supported over HTTP transport")
111
+
112
+ def send_raw(self, payload: dict) -> None:
113
+ """No-op — pub-sub subscribe is not available over HTTP."""
114
+ pass
115
+
116
+ def register_push_handler(self, handler: Callable[[dict], None]) -> None:
117
+ """No-op — server-push is not available over HTTP."""
118
+ pass
119
+
120
+ # ------------------------------------------------------------------
121
+ # HTTP helpers
122
+ # ------------------------------------------------------------------
123
+
124
+ def _headers(self, content_type: bool = False) -> dict:
125
+ h = {}
126
+ if self._auth_header:
127
+ h["Authorization"] = self._auth_header
128
+ if content_type:
129
+ h["Content-Type"] = "application/json; charset=utf-8"
130
+ return h
131
+
132
+ def _http_get(self, path: str) -> dict:
133
+ url = self._base_url + path
134
+ req = urllib.request.Request(url, headers=self._headers())
135
+ try:
136
+ with urllib.request.urlopen(req, timeout=self.recv_timeout) as resp:
137
+ body = json.loads(resp.read().decode("utf-8"))
138
+ return body.get("data", body)
139
+ except urllib.error.HTTPError as exc:
140
+ body = {}
141
+ try:
142
+ body = json.loads(exc.read().decode("utf-8"))
143
+ except Exception:
144
+ pass
145
+ raise RuntimeError(body.get("message", str(exc))) from exc
146
+
147
+ def _http_post(self, path: str, payload: dict) -> dict:
148
+ url = self._base_url + path
149
+ data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
150
+ req = urllib.request.Request(url, data=data, headers=self._headers(content_type=True), method="POST")
151
+ try:
152
+ with urllib.request.urlopen(req, timeout=self.recv_timeout) as resp:
153
+ body = json.loads(resp.read().decode("utf-8"))
154
+ return body.get("data", body)
155
+ except urllib.error.HTTPError as exc:
156
+ body = {}
157
+ try:
158
+ body = json.loads(exc.read().decode("utf-8"))
159
+ except Exception:
160
+ pass
161
+ raise RuntimeError(body.get("message", str(exc))) from exc
162
+
163
+ def _http_delete(self, path: str) -> dict:
164
+ url = self._base_url + path
165
+ req = urllib.request.Request(url, headers=self._headers(), method="DELETE")
166
+ try:
167
+ with urllib.request.urlopen(req, timeout=self.recv_timeout) as resp:
168
+ body = json.loads(resp.read().decode("utf-8"))
169
+ return body.get("data", body)
170
+ except urllib.error.HTTPError as exc:
171
+ body = {}
172
+ try:
173
+ body = json.loads(exc.read().decode("utf-8"))
174
+ except Exception:
175
+ pass
176
+ raise RuntimeError(body.get("message", str(exc))) from exc
177
+
178
+ @staticmethod
179
+ def _err(message: str) -> dict:
180
+ return {"type": "response", "status": "error", "error": message}