datalevin 0.10.16__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.
datalevin/__init__.py ADDED
@@ -0,0 +1,37 @@
1
+ """Datalevin Python bindings over the JVM interop bridge."""
2
+
3
+ from ._interop import api_info, connect, exec_json, new_client, open_kv
4
+ from ._jvm import jvm_started, start_jvm
5
+ from ._raw import interop
6
+ from .client import Client
7
+ from .connection import Connection
8
+ from .errors import (
9
+ DatalevinConfigurationError,
10
+ DatalevinError,
11
+ DatalevinJavaError,
12
+ DatalevinJvmError,
13
+ )
14
+ from .kv import KV
15
+ from .udf import UdfRegistry, create_udf_registry
16
+
17
+ __all__ = [
18
+ "Client",
19
+ "Connection",
20
+ "DatalevinConfigurationError",
21
+ "DatalevinError",
22
+ "DatalevinJavaError",
23
+ "DatalevinJvmError",
24
+ "KV",
25
+ "UdfRegistry",
26
+ "api_info",
27
+ "connect",
28
+ "create_udf_registry",
29
+ "exec_json",
30
+ "interop",
31
+ "jvm_started",
32
+ "new_client",
33
+ "open_kv",
34
+ "start_jvm",
35
+ ]
36
+
37
+ __version__ = "0.10.16"
datalevin/_convert.py ADDED
@@ -0,0 +1,192 @@
1
+ """Python <-> Java conversion helpers for Datalevin interop."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from datetime import datetime, timezone
7
+ from decimal import Decimal
8
+ import uuid as py_uuid
9
+
10
+ import jpype
11
+ from jpype.types import JByte, JLong
12
+
13
+ from ._java import call_java, classes, is_java_object
14
+
15
+ INT64_MIN = -(2**63)
16
+ INT64_MAX = 2**63 - 1
17
+ FORM_SYMBOL_STRINGS = {"*", "...", ".", "$", "%", "_"}
18
+
19
+
20
+ def to_java(value):
21
+ """Recursively convert Python values into Java/JVM-friendly values."""
22
+
23
+ if value is None or isinstance(value, (bool, float, str)):
24
+ return value
25
+ if is_java_object(value):
26
+ return value
27
+
28
+ raw_handle = getattr(value, "raw_handle", None)
29
+ if callable(raw_handle):
30
+ return raw_handle()
31
+
32
+ if isinstance(value, int):
33
+ if INT64_MIN <= value <= INT64_MAX:
34
+ return JLong(value)
35
+ return classes().big_integer(str(value))
36
+
37
+ if isinstance(value, Decimal):
38
+ return classes().big_decimal(str(value))
39
+
40
+ if isinstance(value, py_uuid.UUID):
41
+ return classes().uuid.fromString(str(value))
42
+
43
+ if isinstance(value, datetime):
44
+ if value.tzinfo is None:
45
+ value = value.replace(tzinfo=timezone.utc)
46
+ iso = value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
47
+ return classes().instant.parse(iso)
48
+
49
+ if isinstance(value, (bytes, bytearray, memoryview)):
50
+ return jpype.JArray(JByte)(bytes(value))
51
+
52
+ if isinstance(value, Mapping):
53
+ jmap = classes().linked_hash_map()
54
+ for key, item in value.items():
55
+ jmap.put(to_java(key), to_java(item))
56
+ return jmap
57
+
58
+ if isinstance(value, (set, frozenset)):
59
+ jset = classes().linked_hash_set()
60
+ for item in value:
61
+ jset.add(to_java(item))
62
+ return jset
63
+
64
+ if isinstance(value, (list, tuple)):
65
+ jlist = classes().array_list()
66
+ for item in value:
67
+ jlist.add(to_java(item))
68
+ return jlist
69
+
70
+ return value
71
+
72
+
73
+ def to_python(value):
74
+ """Recursively convert Java results into Python-native values."""
75
+
76
+ if value is None or isinstance(value, (bool, int, float, str, bytes)):
77
+ return value
78
+
79
+ if not is_java_object(value):
80
+ return value
81
+
82
+ cls = classes()
83
+ byte_array_type = jpype.JArray(JByte)
84
+
85
+ if isinstance(value, byte_array_type):
86
+ return bytes(value)
87
+
88
+ if isinstance(value, cls.uuid):
89
+ return py_uuid.UUID(str(value))
90
+
91
+ if isinstance(value, cls.instant):
92
+ return datetime.fromisoformat(str(value).replace("Z", "+00:00"))
93
+
94
+ if isinstance(value, cls.date):
95
+ return datetime.fromtimestamp(value.getTime() / 1000.0, tz=timezone.utc)
96
+
97
+ if isinstance(value, cls.big_integer):
98
+ return int(str(value))
99
+
100
+ if isinstance(value, cls.big_decimal):
101
+ return Decimal(str(value))
102
+
103
+ if isinstance(value, (cls.keyword_type, cls.symbol_type)):
104
+ return str(value)
105
+
106
+ if isinstance(value, cls.map_type) or hasattr(value, "entrySet"):
107
+ result = {}
108
+ for entry in value.entrySet():
109
+ result[to_python(entry.getKey())] = to_python(entry.getValue())
110
+ return result
111
+
112
+ if isinstance(value, cls.set_type):
113
+ return {to_python(item) for item in value}
114
+
115
+ if isinstance(value, (cls.list_type, cls.collection_type)) or hasattr(value, "iterator"):
116
+ return [to_python(item) for item in value]
117
+
118
+ return value
119
+
120
+
121
+ def to_edn_form(value):
122
+ """Convert Python form structures into EDN-friendly JVM values."""
123
+
124
+ if value is None or is_java_object(value):
125
+ return value
126
+
127
+ raw_handle = getattr(value, "raw_handle", None)
128
+ if callable(raw_handle):
129
+ return raw_handle()
130
+
131
+ if isinstance(value, str):
132
+ if value.startswith(":"):
133
+ return call_java(classes().interop.keyword, value)
134
+ if value.startswith("?") or value in FORM_SYMBOL_STRINGS:
135
+ return call_java(classes().interop.symbol, value)
136
+ return value
137
+
138
+ if isinstance(value, Mapping):
139
+ jmap = classes().linked_hash_map()
140
+ for key, item in value.items():
141
+ jmap.put(to_edn_form(key), to_edn_form(item))
142
+ return jmap
143
+
144
+ if isinstance(value, (set, frozenset)):
145
+ jset = classes().linked_hash_set()
146
+ for item in value:
147
+ jset.add(to_edn_form(item))
148
+ return jset
149
+
150
+ if isinstance(value, (list, tuple)):
151
+ jlist = classes().array_list()
152
+ for item in value:
153
+ jlist.add(to_edn_form(item))
154
+ return jlist
155
+
156
+ return to_java(value)
157
+
158
+
159
+ def to_query_input(value):
160
+ """Convert Python query inputs while preserving ordinary string literals."""
161
+
162
+ if value is None or is_java_object(value):
163
+ return value
164
+
165
+ raw_handle = getattr(value, "raw_handle", None)
166
+ if callable(raw_handle):
167
+ return raw_handle()
168
+
169
+ if isinstance(value, str):
170
+ if value.startswith(":"):
171
+ return call_java(classes().interop.keyword, value)
172
+ return value
173
+
174
+ if isinstance(value, Mapping):
175
+ jmap = classes().linked_hash_map()
176
+ for key, item in value.items():
177
+ jmap.put(to_query_input(key), to_query_input(item))
178
+ return jmap
179
+
180
+ if isinstance(value, (set, frozenset)):
181
+ jset = classes().linked_hash_set()
182
+ for item in value:
183
+ jset.add(to_query_input(item))
184
+ return jset
185
+
186
+ if isinstance(value, (list, tuple)):
187
+ jlist = classes().array_list()
188
+ for item in value:
189
+ jlist.add(to_query_input(item))
190
+ return jlist
191
+
192
+ return to_java(value)
datalevin/_interop.py ADDED
@@ -0,0 +1,197 @@
1
+ """Internal interop bindings and public constructors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ from ._convert import to_java, to_python
8
+ from ._java import call_java, classes
9
+ from ._jvm import jvm_started, start_jvm
10
+ from .errors import DatalevinError
11
+
12
+
13
+ class InteropBindings:
14
+ """Thin wrapper around the Datalevin JVM bridge."""
15
+
16
+ def api_info_raw(self):
17
+ return call_java(classes().datalevin.apiInfo)
18
+
19
+ def exec_json_raw(self, request_json: str) -> str:
20
+ json_api_exec = getattr(classes().json_api, "exec_", None)
21
+ if json_api_exec is None:
22
+ json_api_exec = getattr(classes().json_api, "exec")
23
+ return str(call_java(json_api_exec, request_json))
24
+
25
+ def core_invoke(self, function: str, args=None):
26
+ return call_java(classes().interop.coreInvoke, function, to_java(list(args or ())))
27
+
28
+ def client_invoke(self, function: str, args=None):
29
+ return call_java(classes().interop.clientInvoke, function, to_java(list(args or ())))
30
+
31
+ def create_connection(self, dir=None, schema=None, opts=None, *, shared: bool = False):
32
+ target = classes().interop.getConnection if shared else classes().interop.createConnection
33
+ return call_java(target, dir, to_java(schema), to_java(opts))
34
+
35
+ def close_connection(self, handle) -> None:
36
+ call_java(classes().interop.closeConnection, handle)
37
+
38
+ def connection_closed(self, handle) -> bool:
39
+ return bool(call_java(classes().interop.connectionClosed, handle))
40
+
41
+ def connection_db(self, handle):
42
+ return call_java(classes().interop.connectionDb, handle)
43
+
44
+ def open_key_value(self, dir, opts=None):
45
+ return call_java(classes().interop.openKeyValue, dir, to_java(opts))
46
+
47
+ def close_key_value(self, handle) -> None:
48
+ call_java(classes().interop.closeKeyValue, handle)
49
+
50
+ def key_value_closed(self, handle) -> bool:
51
+ return bool(call_java(classes().interop.keyValueClosed, handle))
52
+
53
+ def new_client(self, uri, opts=None):
54
+ return call_java(classes().interop.newClient, uri, to_java(opts))
55
+
56
+ def close_client(self, handle) -> None:
57
+ call_java(classes().interop.closeClient, handle)
58
+
59
+ def client_disconnected(self, handle) -> bool:
60
+ return bool(call_java(classes().interop.clientDisconnected, handle))
61
+
62
+ def read_edn(self, edn: str):
63
+ return call_java(classes().interop.readEdn, edn)
64
+
65
+ def keyword(self, value: str):
66
+ return call_java(classes().interop.keyword, value)
67
+
68
+ def symbol(self, value: str):
69
+ return call_java(classes().interop.symbol, value)
70
+
71
+ def schema(self, schema):
72
+ if schema is None:
73
+ return None
74
+ return call_java(classes().interop.schema, to_java(schema))
75
+
76
+ def options(self, opts):
77
+ if opts is None:
78
+ return None
79
+ return call_java(classes().interop.options, to_java(opts))
80
+
81
+ def udf_descriptor(self, descriptor):
82
+ if descriptor is None:
83
+ return None
84
+ return call_java(classes().interop.udfDescriptor, to_java(descriptor))
85
+
86
+ def create_udf_registry(self):
87
+ return call_java(classes().interop.createUdfRegistry)
88
+
89
+ def register_udf(self, registry, descriptor, fn):
90
+ return call_java(classes().interop.registerUdf, registry, to_java(descriptor), fn)
91
+
92
+ def unregister_udf(self, registry, descriptor):
93
+ return call_java(classes().interop.unregisterUdf, registry, to_java(descriptor))
94
+
95
+ def registered_udf(self, registry, descriptor) -> bool:
96
+ return bool(call_java(classes().interop.registeredUdf, registry, to_java(descriptor)))
97
+
98
+ def rename_map(self, rename_map):
99
+ if rename_map is None:
100
+ return None
101
+ return call_java(classes().interop.renameMap, to_java(rename_map))
102
+
103
+ def delete_attrs(self, attrs):
104
+ if attrs is None:
105
+ return None
106
+ return call_java(classes().interop.deleteAttrs, to_java(list(attrs or ())))
107
+
108
+ def lookup_ref(self, value):
109
+ if value is None:
110
+ return None
111
+ return call_java(classes().interop.lookupRef, to_java(value))
112
+
113
+ def tx_data(self, tx_data):
114
+ if tx_data is None:
115
+ return None
116
+ return call_java(classes().interop.txData, to_java(tx_data))
117
+
118
+ def kv_txs(self, txs):
119
+ if txs is None:
120
+ return None
121
+ return call_java(classes().interop.kvTxs, to_java(txs))
122
+
123
+ def kv_type(self, value):
124
+ if value is None:
125
+ return None
126
+ return call_java(classes().interop.kvType, to_java(value))
127
+
128
+ def database_type(self, value: str):
129
+ return call_java(classes().interop.databaseType, value)
130
+
131
+ def role(self, role: str):
132
+ return call_java(classes().interop.role, role)
133
+
134
+ def permission_keyword(self, value: str):
135
+ return call_java(classes().interop.permissionKeyword, value)
136
+
137
+ def permission_target(self, object_type: str, target):
138
+ return call_java(classes().interop.permissionTarget, object_type, to_java(target))
139
+
140
+
141
+ _BINDINGS = InteropBindings()
142
+
143
+
144
+ def api_info():
145
+ """Return Datalevin JSON/API metadata as a Python dictionary."""
146
+
147
+ return to_python(_BINDINGS.api_info_raw())
148
+
149
+
150
+ def exec_json(op: str, args=None):
151
+ """Execute a raw JSON API operation."""
152
+
153
+ request = json.dumps({"op": op, "args": args or {}})
154
+ envelope = json.loads(_BINDINGS.exec_json_raw(request))
155
+ if envelope.get("ok"):
156
+ return envelope.get("result")
157
+ raise DatalevinError(
158
+ envelope.get("error") or "Datalevin JSON API request failed.",
159
+ type_name=envelope.get("type"),
160
+ data=envelope.get("data"),
161
+ )
162
+
163
+
164
+ def connect(dir=None, schema=None, opts=None, *, shared: bool = False) -> Connection:
165
+ """Create or open a Datalevin Datalog connection."""
166
+
167
+ from .connection import Connection
168
+
169
+ return Connection(_BINDINGS.create_connection(dir, schema, opts, shared=shared))
170
+
171
+
172
+ def open_kv(dir, opts=None) -> KV:
173
+ """Open a Datalevin KV store."""
174
+
175
+ from .kv import KV
176
+
177
+ return KV(_BINDINGS.open_key_value(dir, opts))
178
+
179
+
180
+ def new_client(uri, opts=None) -> Client:
181
+ """Open a remote Datalevin admin client."""
182
+
183
+ from .client import Client
184
+
185
+ return Client(_BINDINGS.new_client(uri, opts))
186
+
187
+
188
+ __all__ = [
189
+ "_BINDINGS",
190
+ "api_info",
191
+ "connect",
192
+ "exec_json",
193
+ "jvm_started",
194
+ "new_client",
195
+ "open_kv",
196
+ "start_jvm",
197
+ ]
datalevin/_java.py ADDED
@@ -0,0 +1,84 @@
1
+ """JPype class loading and Java call helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from functools import lru_cache
7
+
8
+ import jpype
9
+
10
+ from ._jvm import start_jvm
11
+ from .errors import DatalevinJavaError
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class JavaClasses:
16
+ """Cached Java classes used by the Python wrapper."""
17
+
18
+ datalevin: object
19
+ interop: object
20
+ json_api: object
21
+ udf_function: object
22
+ linked_hash_map: object
23
+ linked_hash_set: object
24
+ array_list: object
25
+ uuid: object
26
+ instant: object
27
+ date: object
28
+ big_integer: object
29
+ big_decimal: object
30
+ map_type: object
31
+ list_type: object
32
+ set_type: object
33
+ collection_type: object
34
+ keyword_type: object
35
+ symbol_type: object
36
+
37
+
38
+ @lru_cache(maxsize=1)
39
+ def classes() -> JavaClasses:
40
+ """Load and cache the Java classes used by the binding."""
41
+
42
+ start_jvm()
43
+ return JavaClasses(
44
+ datalevin=jpype.JClass("datalevin.Datalevin"),
45
+ interop=jpype.JClass("datalevin.DatalevinInterop"),
46
+ json_api=jpype.JClass("datalevin.JSONApi"),
47
+ udf_function=jpype.JClass("datalevin.UdfFunction"),
48
+ linked_hash_map=jpype.JClass("java.util.LinkedHashMap"),
49
+ linked_hash_set=jpype.JClass("java.util.LinkedHashSet"),
50
+ array_list=jpype.JClass("java.util.ArrayList"),
51
+ uuid=jpype.JClass("java.util.UUID"),
52
+ instant=jpype.JClass("java.time.Instant"),
53
+ date=jpype.JClass("java.util.Date"),
54
+ big_integer=jpype.JClass("java.math.BigInteger"),
55
+ big_decimal=jpype.JClass("java.math.BigDecimal"),
56
+ map_type=jpype.JClass("java.util.Map"),
57
+ list_type=jpype.JClass("java.util.List"),
58
+ set_type=jpype.JClass("java.util.Set"),
59
+ collection_type=jpype.JClass("java.util.Collection"),
60
+ keyword_type=jpype.JClass("clojure.lang.Keyword"),
61
+ symbol_type=jpype.JClass("clojure.lang.Symbol"),
62
+ )
63
+
64
+
65
+ def call_java(target, *args):
66
+ """Call a Java method and wrap JPype Java exceptions."""
67
+
68
+ try:
69
+ return target(*args)
70
+ except jpype.JException as exc:
71
+ raise DatalevinJavaError.from_java(exc) from exc
72
+
73
+
74
+ def is_java_object(value) -> bool:
75
+ """Best-effort detection for live Java objects exposed through JPype."""
76
+
77
+ if value is None or not jpype.isJVMStarted():
78
+ return False
79
+ module = type(value).__module__
80
+ return (
81
+ module.startswith("jpype")
82
+ or module.startswith("_jpype")
83
+ or str(type(value)).startswith("<java ")
84
+ )
datalevin/_jvm.py ADDED
@@ -0,0 +1,143 @@
1
+ """JVM bootstrap and runtime jar resolution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.resources as resources
6
+ import os
7
+ import re
8
+ import shlex
9
+ from pathlib import Path
10
+
11
+ import jpype
12
+
13
+ from .errors import DatalevinConfigurationError, DatalevinJvmError
14
+
15
+ DATALEVIN_JAR_ENV = "DATALEVIN_JAR"
16
+ DATALEVIN_CLASSPATH_ENV = "DATALEVIN_CLASSPATH"
17
+ DATALEVIN_JVM_ARGS_ENV = "DATALEVIN_JVM_ARGS"
18
+ DATALEVIN_JAVACPP_CACHEDIR_ENV = "DATALEVIN_JAVACPP_CACHEDIR"
19
+ PACKAGE_NAME = "datalevin"
20
+ TARGET_JAR_PATTERNS = (
21
+ "datalevin-runtime-*.jar",
22
+ "datalevin-python-runtime-*.jar",
23
+ "datalevin-java-*.jar",
24
+ )
25
+ DEFAULT_JAVACPP_CACHEDIR = Path("/tmp/datalevin-javacpp-cache")
26
+
27
+
28
+ def jvm_started() -> bool:
29
+ """Return whether the current Python process already has a JVM."""
30
+
31
+ return jpype.isJVMStarted()
32
+
33
+
34
+ def resolve_classpath(classpath: list[str] | tuple[str, ...] | None = None) -> list[str]:
35
+ """Resolve the JVM classpath for the Datalevin runtime."""
36
+
37
+ if classpath:
38
+ return [str(Path(entry).expanduser()) for entry in classpath]
39
+
40
+ env_classpath = os.environ.get(DATALEVIN_CLASSPATH_ENV)
41
+ if env_classpath:
42
+ return [entry for entry in env_classpath.split(os.pathsep) if entry]
43
+
44
+ env_jar = os.environ.get(DATALEVIN_JAR_ENV)
45
+ if env_jar:
46
+ return [str(Path(env_jar).expanduser())]
47
+
48
+ vendored = _vendored_jars()
49
+ if vendored:
50
+ return vendored
51
+
52
+ repo_local = _repo_local_jars()
53
+ if repo_local:
54
+ return repo_local
55
+
56
+ raise DatalevinConfigurationError(
57
+ "Unable to resolve a Datalevin runtime jar. "
58
+ f"Set {DATALEVIN_JAR_ENV}, set {DATALEVIN_CLASSPATH_ENV}, "
59
+ "or vendor a jar under datalevin/jars/."
60
+ )
61
+
62
+
63
+ def start_jvm(
64
+ *,
65
+ classpath: list[str] | tuple[str, ...] | None = None,
66
+ jvm_args: list[str] | tuple[str, ...] | None = None,
67
+ convert_strings: bool = False,
68
+ ) -> None:
69
+ """Start the JVM if it is not already running."""
70
+
71
+ if jpype.isJVMStarted():
72
+ return
73
+
74
+ resolved_classpath = resolve_classpath(classpath)
75
+ resolved_jvm_args = list(jvm_args or ())
76
+ if not resolved_jvm_args:
77
+ env_args = os.environ.get(DATALEVIN_JVM_ARGS_ENV)
78
+ if env_args:
79
+ resolved_jvm_args = shlex.split(env_args)
80
+ _ensure_javacpp_cachedir_arg(resolved_jvm_args)
81
+
82
+ try:
83
+ jpype.startJVM(
84
+ *resolved_jvm_args,
85
+ classpath=resolved_classpath,
86
+ convertStrings=convert_strings,
87
+ )
88
+ except OSError as exc:
89
+ raise DatalevinJvmError(
90
+ "Failed to start the JVM for Datalevin. "
91
+ "Check that Java 21+ is installed and that the runtime jar is valid.",
92
+ cause=exc,
93
+ ) from exc
94
+
95
+
96
+ def _vendored_jars() -> list[str]:
97
+ try:
98
+ jar_root = resources.files(PACKAGE_NAME).joinpath("jars")
99
+ except (FileNotFoundError, ModuleNotFoundError):
100
+ return []
101
+
102
+ if not jar_root.is_dir():
103
+ return []
104
+
105
+ jar = _preferred_runtime_jar(Path(str(jar_root)))
106
+ return [] if jar is None else [str(jar)]
107
+
108
+
109
+ def _repo_local_jars() -> list[str]:
110
+ repo_root = Path(__file__).resolve().parents[4]
111
+ target_dir = repo_root / "target"
112
+ if not target_dir.exists():
113
+ return []
114
+ jar = _preferred_runtime_jar(target_dir)
115
+ return [] if jar is None else [str(jar)]
116
+
117
+
118
+ def _preferred_runtime_jar(dir_path: Path) -> Path | None:
119
+ for pattern in TARGET_JAR_PATTERNS:
120
+ matches = sorted(dir_path.glob(pattern), key=_jar_sort_key)
121
+ if matches:
122
+ return matches[-1]
123
+ return None
124
+
125
+
126
+ def _jar_sort_key(path: Path):
127
+ stem = path.stem
128
+ version = stem.rsplit("-", 1)[-1]
129
+ parts = re.split(r"([0-9]+)", version)
130
+ return [int(part) if part.isdigit() else part for part in parts]
131
+
132
+
133
+ def _ensure_javacpp_cachedir_arg(jvm_args: list[str]) -> None:
134
+ for arg in jvm_args:
135
+ if (
136
+ arg.startswith("-Dorg.bytedeco.javacpp.cachedir=")
137
+ or arg.startswith("-Dorg.bytedeco.javacpp.cacheDir=")
138
+ ):
139
+ return
140
+
141
+ cache_dir = Path(os.environ.get(DATALEVIN_JAVACPP_CACHEDIR_ENV, DEFAULT_JAVACPP_CACHEDIR))
142
+ cache_dir.mkdir(parents=True, exist_ok=True)
143
+ jvm_args.append(f"-Dorg.bytedeco.javacpp.cachedir={cache_dir}")