elastik 0.0.1__py3-none-win_amd64.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.
elastik/__init__.py ADDED
@@ -0,0 +1,151 @@
1
+ """elastik — pastebin with HMAC that accidentally became a web OS.
2
+
3
+ Quickstart (NumPy-style — module-level calls, no instantiation):
4
+
5
+ import elastik
6
+
7
+ e = elastik.start(token="x") # spawns the bundled rust core
8
+ e.put("/home/note", "hello") # PUT
9
+ print(e.get("/home/note", raw=True)) # bytes back
10
+ elastik.stop() # kills the child
11
+
12
+ Or skip the explicit start() and point at an already-running elastik
13
+ (env: `ELASTIK_URL=http://elastik.local:3005 ELASTIK_TOKEN=xxx`):
14
+
15
+ import elastik
16
+ elastik.put("/home/note", "hello")
17
+ print(elastik.get("/home/note", raw=True))
18
+
19
+ Reactor (declarative event handlers — see Elastik-core/README.md §L2):
20
+
21
+ @elastik.listen("/home/inbox/*")
22
+ def triage(body, world, meta):
23
+ if b"urgent" in body:
24
+ return elastik.Reply(f"/home/alerts/{world.split('/')[-1]}", body)
25
+ return elastik.Archive()
26
+
27
+ elastik.serve(port=3200) # runs as fanout sidecar
28
+
29
+ Bundled binary lives at `elastik/_bin/elastik-core[.exe]` and is invoked
30
+ as a child process. No FFI, no compile-on-install. Same shape as
31
+ NumPy shipping precompiled C kernels.
32
+ """
33
+ from __future__ import annotations
34
+
35
+ import os
36
+ from typing import Any
37
+
38
+ from elastik.sdk import Elastik, ElastikError
39
+ from elastik.reactor import (
40
+ listen,
41
+ MoveTo,
42
+ Reply,
43
+ Archive,
44
+ Drop,
45
+ Action,
46
+ Ctx,
47
+ finalize,
48
+ serve,
49
+ )
50
+ from elastik._spawn import (
51
+ start,
52
+ stop,
53
+ is_running,
54
+ default_url,
55
+ binary_info,
56
+ )
57
+
58
+ __all__ = [
59
+ # Class — for users who want explicit instances
60
+ "Elastik",
61
+ "ElastikError",
62
+ # Reactor sugar
63
+ "listen", "MoveTo", "Reply", "Archive", "Drop", "Action", "Ctx",
64
+ "finalize", "serve",
65
+ # Lifecycle
66
+ "start", "stop", "is_running", "default_url", "binary_info",
67
+ # Module-level convenience (NumPy-shaped)
68
+ "put", "get", "head", "delete", "list_worlds", "shaped",
69
+ ]
70
+
71
+ __version__ = "0.0.1"
72
+
73
+
74
+ # ── module-level singleton client ──────────────────────────────────
75
+ # Lazy: only built on first call. Lets `import elastik` succeed even if
76
+ # no server is running (you only need a server when you actually call).
77
+
78
+ _default_client: Elastik | None = None
79
+
80
+
81
+ def _client() -> Elastik:
82
+ """Get-or-build the default singleton client.
83
+
84
+ Pinned to ELASTIK_URL (default http://127.0.0.1:3005, the Python
85
+ reference impl port). Token from ELASTIK_TOKEN.
86
+
87
+ If you spawned a child via `elastik.start(port=N, token=T)`, that
88
+ call returns its own client and ALSO updates this singleton so
89
+ module-level `elastik.put(...)` lands at your child.
90
+ """
91
+ global _default_client
92
+ if _default_client is None:
93
+ _default_client = Elastik(
94
+ default_url(),
95
+ token=os.getenv("ELASTIK_TOKEN", ""),
96
+ )
97
+ return _default_client
98
+
99
+
100
+ def _set_default(client: Elastik) -> None:
101
+ """Internal: rebind the singleton (called from start()).
102
+ Public API is just: call start(), use module-level functions."""
103
+ global _default_client
104
+ _default_client = client
105
+
106
+
107
+ # Re-export start() to also bind the singleton, so the next 模式 works:
108
+ # e = elastik.start() # works (returns client)
109
+ # elastik.put("/x", "y") # also works (uses the same client)
110
+ _orig_start = start
111
+
112
+
113
+ def start(*args: Any, **kwargs: Any): # type: ignore[no-redef]
114
+ client = _orig_start(*args, **kwargs)
115
+ _set_default(client)
116
+ return client
117
+
118
+
119
+ # ── module-level CRUD ──────────────────────────────────────────────
120
+ # These are the "np.array([...])" of elastik. Brutal. One import,
121
+ # call.
122
+
123
+ def put(path: str, data, **meta: Any) -> dict:
124
+ """elastik.put('/home/note', 'hello', actor='me') -> {'ok': True, ...}"""
125
+ return _client().put(path, data, **meta)
126
+
127
+
128
+ def get(path: str, raw: bool = False) -> Any:
129
+ """elastik.get('/home/note', raw=True) -> bytes
130
+ elastik.get('/home/note') -> dict envelope"""
131
+ return _client().get(path, raw=raw)
132
+
133
+
134
+ def head(path: str) -> dict[str, str]:
135
+ """elastik.head('/home/note') -> {'x-meta-...': '...'}"""
136
+ return _client().head(path)
137
+
138
+
139
+ def delete(path: str) -> bool:
140
+ """elastik.delete('/home/note') -> True/False"""
141
+ return _client().delete(path)
142
+
143
+
144
+ def list_worlds() -> list[str]:
145
+ """elastik.list_worlds() -> ['inbox/x', 'archive/y', ...]"""
146
+ return _client().list()
147
+
148
+
149
+ def shaped(path: str, accept: str = "text/html", intent: str = "") -> bytes:
150
+ """elastik.shaped('/home/x', accept='text/html', intent='render as card')"""
151
+ return _client().shaped(path, accept=accept, intent=intent)
elastik/__main__.py ADDED
@@ -0,0 +1,67 @@
1
+ """`python -m elastik` — quick info + REPL helpers.
2
+
3
+ python -m elastik # show binary info
4
+ python -m elastik run # spawn the bundled core, block
5
+ python -m elastik run --port 3105 # custom port + env
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import sys
11
+
12
+ from elastik._spawn import binary_info, start, stop
13
+
14
+
15
+ def main() -> int:
16
+ parser = argparse.ArgumentParser(prog="elastik")
17
+ sub = parser.add_subparsers(dest="cmd")
18
+
19
+ sub.add_parser("info", help="show bundled-binary info")
20
+
21
+ p_run = sub.add_parser("run", help="launch bundled elastik-core (foreground)")
22
+ p_run.add_argument("--host", default="127.0.0.1")
23
+ p_run.add_argument("--port", type=int, default=3105)
24
+ p_run.add_argument("--key", default="elastik-default-key")
25
+ p_run.add_argument("--token", default="")
26
+ p_run.add_argument("--approve-token", dest="approve_token", default="")
27
+ p_run.add_argument("--data-dir", dest="data_dir", default=None)
28
+ p_run.add_argument("--listeners", default="")
29
+
30
+ args = parser.parse_args()
31
+
32
+ if args.cmd in (None, "info"):
33
+ info = binary_info()
34
+ for k, v in info.items():
35
+ print(f" {k}: {v}")
36
+ return 0
37
+
38
+ if args.cmd == "run":
39
+ try:
40
+ client = start(
41
+ host=args.host,
42
+ port=args.port,
43
+ key=args.key,
44
+ token=args.token,
45
+ approve_token=args.approve_token,
46
+ data_dir=args.data_dir,
47
+ listeners=args.listeners,
48
+ quiet=False,
49
+ )
50
+ print(f"elastik running at {client.url}", flush=True)
51
+ print(" Ctrl-C to stop", flush=True)
52
+ try:
53
+ while True:
54
+ import time
55
+ time.sleep(3600)
56
+ except KeyboardInterrupt:
57
+ pass
58
+ finally:
59
+ stop()
60
+ return 0
61
+
62
+ parser.print_help()
63
+ return 2
64
+
65
+
66
+ if __name__ == "__main__":
67
+ sys.exit(main())
Binary file
elastik/_spawn.py ADDED
@@ -0,0 +1,151 @@
1
+ """Spawn the bundled elastik-core Rust binary as a child process.
2
+
3
+ This is the "NumPy ships C kernels in the wheel" trick. The Rust
4
+ binary lives at `elastik/_bin/elastik-core[.exe]`, was built once on a
5
+ build machine, and is shipped as `package_data`. `elastik.start()`
6
+ launches it in a subprocess and returns a pre-bound `Elastik` client.
7
+
8
+ The user does:
9
+
10
+ import elastik
11
+ e = elastik.start(token="x")
12
+ e.put("/home/note", "hi")
13
+ print(e.get("/home/note", raw=True))
14
+ elastik.stop()
15
+
16
+ …and never sees a Cargo.toml.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import atexit
21
+ import os
22
+ import socket
23
+ import subprocess
24
+ import sys
25
+ import time
26
+ from pathlib import Path
27
+ from typing import Optional
28
+
29
+
30
+ _proc: Optional[subprocess.Popen] = None
31
+
32
+
33
+ def _binary_path() -> Path:
34
+ """Locate the bundled binary inside the installed package."""
35
+ here = Path(__file__).resolve().parent / "_bin"
36
+ name = "elastik-core.exe" if sys.platform == "win32" else "elastik-core"
37
+ return here / name
38
+
39
+
40
+ def _wait_for_port(host: str, port: int, deadline_s: float = 10.0) -> bool:
41
+ """Poll until the server accepts a TCP connection or we give up."""
42
+ end = time.time() + deadline_s
43
+ while time.time() < end:
44
+ try:
45
+ with socket.create_connection((host, port), timeout=0.3):
46
+ return True
47
+ except OSError:
48
+ time.sleep(0.05)
49
+ return False
50
+
51
+
52
+ def start(
53
+ port: int = 3105,
54
+ host: str = "127.0.0.1",
55
+ key: str = "elastik-default-key",
56
+ token: str = "",
57
+ approve_token: str = "",
58
+ data_dir: Optional[str] = None,
59
+ listeners: str = "",
60
+ quiet: bool = True,
61
+ ):
62
+ """Launch the bundled elastik-core. Returns a pre-bound Elastik client.
63
+
64
+ All process state is the user's: token, port, key, data dir. We only
65
+ set sane defaults so `elastik.start()` with no arguments produces a
66
+ working instance pinned to localhost.
67
+ """
68
+ global _proc
69
+ binary = _binary_path()
70
+ if not binary.exists():
71
+ raise RuntimeError(
72
+ f"bundled binary not found: {binary}\n"
73
+ "If you're working from source, build it first:\n"
74
+ " cd Elastik-core && cargo build --release\n"
75
+ " cp target/release/elastik-core* "
76
+ f"{binary.parent}/"
77
+ )
78
+
79
+ if _proc is not None and _proc.poll() is None:
80
+ raise RuntimeError(
81
+ "elastik already running in this process — call elastik.stop() first"
82
+ )
83
+
84
+ env = os.environ.copy()
85
+ env["ELASTIK_HOST"] = host
86
+ env["ELASTIK_PORT"] = str(port)
87
+ env["ELASTIK_KEY"] = key
88
+ if token:
89
+ env["ELASTIK_TOKEN"] = token
90
+ if approve_token:
91
+ env["ELASTIK_APPROVE_TOKEN"] = approve_token
92
+ if data_dir:
93
+ env["ELASTIK_DATA"] = str(data_dir)
94
+ if listeners:
95
+ env["ELASTIK_LISTENERS"] = listeners
96
+
97
+ out = subprocess.DEVNULL if quiet else None
98
+ _proc = subprocess.Popen([str(binary)], env=env, stdout=out, stderr=out)
99
+ atexit.register(stop)
100
+
101
+ if not _wait_for_port(host, port):
102
+ stop()
103
+ raise RuntimeError(
104
+ f"elastik-core failed to start on {host}:{port} within 10s"
105
+ )
106
+
107
+ # Re-import here to dodge a circular import at module load
108
+ from elastik.sdk import Elastik
109
+
110
+ return Elastik(f"http://{host}:{port}", token=token)
111
+
112
+
113
+ def stop() -> None:
114
+ """Kill the launched binary, if any. Safe to call multiple times."""
115
+ global _proc
116
+ if _proc is None:
117
+ return
118
+ try:
119
+ _proc.terminate()
120
+ try:
121
+ _proc.wait(timeout=3)
122
+ except subprocess.TimeoutExpired:
123
+ _proc.kill()
124
+ _proc.wait(timeout=2)
125
+ except OSError:
126
+ pass
127
+ _proc = None
128
+
129
+
130
+ def is_running() -> bool:
131
+ return _proc is not None and _proc.poll() is None
132
+
133
+
134
+ def default_url() -> str:
135
+ """Where the module-level `elastik.put`/`elastik.get` calls land by
136
+ default. Override with `ELASTIK_URL`. Falls back to the local
137
+ server.py port (3005) so this module also works against the existing
138
+ Python reference impl."""
139
+ return os.getenv("ELASTIK_URL", "http://127.0.0.1:3005")
140
+
141
+
142
+ def binary_info() -> dict:
143
+ """Where is the bundled binary? How big? Does it exist? — useful
144
+ for `python -m elastik` debugging."""
145
+ p = _binary_path()
146
+ return {
147
+ "path": str(p),
148
+ "exists": p.exists(),
149
+ "size_bytes": p.stat().st_size if p.exists() else None,
150
+ "platform": sys.platform,
151
+ }
elastik/reactor.py ADDED
@@ -0,0 +1,275 @@
1
+ """L2 — declarative reactor sugar over L1 atoms.
2
+
3
+ The reactor is the "千人千面 in one event loop" pattern, made writable.
4
+ Two deployment shapes; same `@listen` syntax for both:
5
+
6
+ 1. **Plugin** (in-process with elastik-python).
7
+ Write a plugin file, end with `ROUTES, handle = finalize()`.
8
+ elastik-python's plugin runtime hot-loads it.
9
+
10
+ 2. **Sidecar** (separate process; elastik-core fans out via HTTP).
11
+ Run `python my_agent.py` which calls `serve(host, port)`.
12
+ Configure elastik-core with
13
+ `ELASTIK_LISTENERS="/home/inbox/*=http://my-agent:port"`.
14
+
15
+ Both shapes match the manifesto: code lives in one place; the runtime
16
+ decides how to invoke it. The handler is pure — input -> Action.
17
+
18
+ Action classes describe intent. Execution happens in the reactor (not
19
+ in the handler). This is the Harvard split inside the reactor itself:
20
+ handler = judgment, executor = side effects.
21
+
22
+ stdlib + sdk.elastik. ~200 lines.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import http.server
27
+ import inspect
28
+ import json
29
+ import threading
30
+ from typing import Any, Callable
31
+
32
+ from elastik.sdk import Elastik
33
+
34
+
35
+ # ── registration ────────────────────────────────────────────────────
36
+
37
+ _routes: dict[str, Callable[..., Any]] = {}
38
+
39
+
40
+ def listen(pattern: str):
41
+ """Register a handler for a path pattern.
42
+
43
+ Pattern is prefix-with-trailing-`*`:
44
+ "/home/inbox/*" matches "/home/inbox/alice/abc"
45
+ "/home/foo" matches exactly "/home/foo"
46
+ "*" matches everything
47
+
48
+ The handler signature is introspected. It receives `body` (bytes)
49
+ plus any of these named kwargs it asks for:
50
+ world str — the URL path that was written
51
+ version int — the new version after the write
52
+ pattern str — the registered pattern that matched
53
+ meta dict — X-Meta-* headers as a flat dict
54
+ e Elastik— SDK client, pre-bound to the configured backend
55
+
56
+ Return: an Action, a list of Actions, or None.
57
+ """
58
+ def deco(func: Callable[..., Any]) -> Callable[..., Any]:
59
+ _routes[pattern] = func
60
+ return func
61
+ return deco
62
+
63
+
64
+ def _matches(pattern: str, path: str) -> bool:
65
+ if pattern == "*":
66
+ return True
67
+ if pattern.endswith("*"):
68
+ return path.startswith(pattern[:-1])
69
+ return path == pattern
70
+
71
+
72
+ # ── actions (intent objects) ───────────────────────────────────────
73
+
74
+ class Action:
75
+ """Base. Subclasses describe intent; .execute(ctx) does the work."""
76
+
77
+ def execute(self, ctx: Ctx) -> None:
78
+ raise NotImplementedError
79
+
80
+
81
+ class MoveTo(Action):
82
+ """Write to dest, then delete the source."""
83
+
84
+ def __init__(self, dest: str, **meta: Any):
85
+ self.dest = dest
86
+ self.meta = meta
87
+
88
+ def execute(self, ctx: Ctx) -> None:
89
+ ctx.e.put(self.dest, ctx.body, **self.meta)
90
+ ctx.e.delete(ctx.world)
91
+
92
+
93
+ class Reply(Action):
94
+ """Write a new world. Source untouched."""
95
+
96
+ def __init__(self, dest: str, body: bytes | str, **meta: Any):
97
+ self.dest = dest
98
+ self.body = body
99
+ self.meta = meta
100
+
101
+ def execute(self, ctx: Ctx) -> None:
102
+ ctx.e.put(self.dest, self.body, **self.meta)
103
+
104
+
105
+ class Archive(Action):
106
+ """Move source under prefix/<basename>."""
107
+
108
+ def __init__(self, prefix: str = "/home/archive/"):
109
+ self.prefix = prefix
110
+
111
+ def execute(self, ctx: Ctx) -> None:
112
+ name = ctx.world.rstrip("/").rsplit("/", 1)[-1] or "anon"
113
+ ctx.e.put(self.prefix.rstrip("/") + "/" + name, ctx.body)
114
+ ctx.e.delete(ctx.world)
115
+
116
+
117
+ class Drop(Action):
118
+ """Delete the source. No reply."""
119
+
120
+ def execute(self, ctx: Ctx) -> None:
121
+ ctx.e.delete(ctx.world)
122
+
123
+
124
+ # ── context passed to handlers + actions ────────────────────────────
125
+
126
+ class Ctx:
127
+ __slots__ = ("body", "world", "version", "pattern", "meta", "e")
128
+
129
+ def __init__(self, body: bytes, world: str, version: int,
130
+ pattern: str, meta: dict[str, str], e: Elastik):
131
+ self.body = body
132
+ self.world = world
133
+ self.version = version
134
+ self.pattern = pattern
135
+ self.meta = meta
136
+ self.e = e
137
+
138
+
139
+ # ── core dispatch (used by both plugin and sidecar shape) ──────────
140
+
141
+ def _call_handler(handler: Callable[..., Any], ctx: Ctx) -> Any:
142
+ """Pass only the kwargs the handler signature asks for."""
143
+ sig = inspect.signature(handler)
144
+ accepts = sig.parameters.keys()
145
+ candidates = {
146
+ "world": ctx.world,
147
+ "version": ctx.version,
148
+ "pattern": ctx.pattern,
149
+ "meta": ctx.meta,
150
+ "e": ctx.e,
151
+ }
152
+ kwargs = {k: v for k, v in candidates.items() if k in accepts}
153
+ has_var = any(
154
+ p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()
155
+ )
156
+ if has_var:
157
+ kwargs = candidates
158
+ return handler(ctx.body, **kwargs)
159
+
160
+
161
+ def _run_action(result: Any, ctx: Ctx) -> None:
162
+ if result is None:
163
+ return
164
+ if isinstance(result, Action):
165
+ result.execute(ctx)
166
+ return
167
+ if isinstance(result, (list, tuple)):
168
+ for item in result:
169
+ if isinstance(item, Action):
170
+ item.execute(ctx)
171
+ return
172
+ # Anything else — ignored. Reactor is forgiving by design; the
173
+ # PUT response shouldn't fail because a handler returned a string.
174
+
175
+
176
+ def _dispatch(world: str, body: bytes, version: int, meta: dict[str, str],
177
+ e: Elastik) -> None:
178
+ """Find every matching handler, run it, execute its action(s)."""
179
+ for pattern, handler in _routes.items():
180
+ if not _matches(pattern, world):
181
+ continue
182
+ ctx = Ctx(body=body, world=world, version=version,
183
+ pattern=pattern, meta=meta, e=e)
184
+ try:
185
+ result = _call_handler(handler, ctx)
186
+ _run_action(result, ctx)
187
+ except Exception as ex:
188
+ print(f" reactor [{pattern}] raised: "
189
+ f"{type(ex).__name__}: {ex}", flush=True)
190
+
191
+
192
+ # ── shape 1: emit ROUTES + handle for elastik-python plugin runtime ─
193
+
194
+ def finalize(elastik_url: str = "http://localhost:3005",
195
+ token: str = "") -> tuple[list[str], Callable]:
196
+ """Return (ROUTES, handle) for an elastik-python plugin.
197
+
198
+ Use at the bottom of a plugin file:
199
+
200
+ from sdk.reactor import listen, MoveTo, finalize
201
+ @listen("/home/inbox/*")
202
+ def triage(body): return MoveTo("/home/archive/")
203
+ ROUTES, handle = finalize()
204
+ """
205
+ e = Elastik(elastik_url, token=token)
206
+ routes = list(_routes.keys())
207
+
208
+ async def handle(method: str, body: Any, params: dict) -> dict:
209
+ if method != "PUT":
210
+ return {"_status": 200, "ok": True, "skipped": method}
211
+ scope = params.get("_scope", {})
212
+ world = scope.get("path", "")
213
+ version = int(scope.get("_version", 0))
214
+ meta = {
215
+ k.decode().lower(): v.decode()
216
+ for k, v in scope.get("headers", [])
217
+ if k.decode().lower().startswith("x-meta-")
218
+ }
219
+ body_bytes = body if isinstance(body, (bytes, bytearray)) else \
220
+ (body or "").encode("utf-8")
221
+ _dispatch(world, body_bytes, version, meta, e)
222
+ return {"_status": 200, "ok": True}
223
+
224
+ return routes, handle
225
+
226
+
227
+ # ── shape 2: standalone sidecar HTTP server ────────────────────────
228
+
229
+ def serve(host: str = "127.0.0.1", port: int = 3200,
230
+ elastik_url: str = "http://localhost:3105",
231
+ token: str = "") -> None:
232
+ """Run an HTTP server that elastik-core's fanout posts to.
233
+
234
+ elastik-core sends:
235
+ POST /<anything>
236
+ X-Elastik-World: <url path>
237
+ X-Elastik-Version: <int>
238
+ X-Elastik-Pattern: <matched pattern>
239
+ X-Meta-*: <forwarded>
240
+ body: the original PUT body
241
+ """
242
+ e = Elastik(elastik_url, token=token)
243
+ state = {"e": e}
244
+
245
+ class Handler(http.server.BaseHTTPRequestHandler):
246
+ def do_POST(self):
247
+ n = int(self.headers.get("Content-Length", "0"))
248
+ body = self.rfile.read(n)
249
+ world = self.headers.get("X-Elastik-World", "")
250
+ version = int(self.headers.get("X-Elastik-Version", "0") or "0")
251
+ pattern = self.headers.get("X-Elastik-Pattern", "")
252
+ meta = {
253
+ k.lower(): v for k, v in self.headers.items()
254
+ if k.lower().startswith("x-meta-")
255
+ }
256
+ # reactor matches all registered patterns against the world,
257
+ # not just the one elastik-core sent (X-Elastik-Pattern is
258
+ # informational — one elastik-core listener URL may serve
259
+ # many @listen patterns in one process).
260
+ _dispatch(world, body, version, meta, state["e"])
261
+ self.send_response(200)
262
+ self.send_header("Content-Length", "0")
263
+ self.end_headers()
264
+
265
+ # Quiet by default
266
+ def log_message(self, *_): pass
267
+
268
+ srv = http.server.ThreadingHTTPServer((host, port), Handler)
269
+ print(f"reactor sidecar listening on http://{host}:{port}/", flush=True)
270
+ print(f" elastik backend: {elastik_url}", flush=True)
271
+ print(f" registered patterns: {list(_routes.keys())}", flush=True)
272
+ try:
273
+ srv.serve_forever()
274
+ except KeyboardInterrupt:
275
+ srv.shutdown()
elastik/sdk.py ADDED
@@ -0,0 +1,129 @@
1
+ """L1 — atom bindings for elastik-core.
2
+
3
+ This is the SDK. It is not the frontend. The frontend is what you build
4
+ *with* these atoms (see sdk.reactor + your own scripts).
5
+
6
+ `e.put("/home/x", data)` is "Lumos" — a declaration. Underneath, the
7
+ runtime may store, audit, fan out, broadcast, cache, log. The caller
8
+ says three words.
9
+
10
+ stdlib only — no httpx, no requests. urllib + json. ~80 lines.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import urllib.error
16
+ import urllib.parse
17
+ import urllib.request
18
+ from typing import Any
19
+
20
+
21
+ class ElastikError(Exception):
22
+ def __init__(self, status: int, body: bytes):
23
+ self.status = status
24
+ self.body = body
25
+ super().__init__(f"elastik {status}: {body[:200]!r}")
26
+
27
+
28
+ class Elastik:
29
+ """Pythonic bindings to elastik-core's HTTP atoms.
30
+
31
+ >>> e = Elastik("http://localhost:3105", token="t2")
32
+ >>> e.put("/home/note", b"hello") # PUT, returns dict
33
+ >>> e.get("/home/note", raw=True) # GET ?raw, returns bytes
34
+ >>> e.head("/home/note") # HEAD, returns headers dict
35
+ >>> e.delete("/home/note") # DELETE
36
+ >>> e.list() # GET /proc/worlds
37
+ """
38
+
39
+ def __init__(self, url: str = "http://localhost:3005", token: str = ""):
40
+ self.url = url.rstrip("/")
41
+ self.token = token
42
+
43
+ # ── atoms ──────────────────────────────────────────────────────
44
+
45
+ def put(self, path: str, data: bytes | str, **meta: Any) -> dict:
46
+ """PUT body to path. kwargs become X-Meta-* headers."""
47
+ body = data.encode("utf-8") if isinstance(data, str) else data
48
+ headers = {f"X-Meta-{k.replace('_', '-')}": str(v) for k, v in meta.items()}
49
+ return self._json("PUT", path, body, headers)
50
+
51
+ def get(self, path: str, raw: bool = False) -> Any:
52
+ """GET path. raw=True returns bytes; otherwise the JSON envelope."""
53
+ suffix = "?raw" if raw else ""
54
+ status, _, body = self._raw("GET", path + suffix)
55
+ if status == 404:
56
+ raise ElastikError(404, body)
57
+ if status >= 400:
58
+ raise ElastikError(status, body)
59
+ if raw:
60
+ return body
61
+ return json.loads(body)
62
+
63
+ def head(self, path: str) -> dict[str, str]:
64
+ """HEAD path. Returns headers as a lowercased dict."""
65
+ status, headers, _ = self._raw("HEAD", path)
66
+ if status == 404:
67
+ raise ElastikError(404, b"")
68
+ return headers
69
+
70
+ def delete(self, path: str) -> bool:
71
+ """DELETE path. Returns True on 204, False on 404."""
72
+ status, _, _ = self._raw("DELETE", path)
73
+ if status == 204:
74
+ return True
75
+ if status == 404:
76
+ return False
77
+ raise ElastikError(status, b"")
78
+
79
+ def list(self) -> list[str]:
80
+ """GET /proc/worlds. Returns list of world names."""
81
+ status, _, body = self._raw("GET", "/proc/worlds")
82
+ if status >= 400:
83
+ raise ElastikError(status, body)
84
+ return [w["name"] for w in json.loads(body)]
85
+
86
+ def shaped(self, path: str, accept: str = "text/html",
87
+ intent: str = "") -> bytes:
88
+ """GET /shaped/<path> with Accept + X-Semantic-Intent. Forwards
89
+ to whatever shaper sidecar elastik routes /shaped/ to. Bytes back."""
90
+ headers = {"Accept": accept}
91
+ if intent:
92
+ headers["X-Semantic-Intent"] = intent
93
+ status, _, body = self._raw("GET", f"/shaped{path}", headers=headers)
94
+ if status >= 400:
95
+ raise ElastikError(status, body)
96
+ return body
97
+
98
+ # ── transport ─────────────────────────────────────────────────
99
+
100
+ def _raw(self, method: str, path: str, body: bytes | None = None,
101
+ headers: dict[str, str] | None = None) -> tuple[int, dict[str, str], bytes]:
102
+ url = self.url + (path if path.startswith("/") else "/" + path)
103
+ h = dict(headers or {})
104
+ if self.token:
105
+ h.setdefault("Authorization", f"Bearer {self.token}")
106
+ req = urllib.request.Request(url, data=body, method=method, headers=h)
107
+ try:
108
+ with urllib.request.urlopen(req, timeout=30) as r:
109
+ return (
110
+ r.status,
111
+ {k.lower(): v for k, v in r.headers.items()},
112
+ r.read(),
113
+ )
114
+ except urllib.error.HTTPError as e:
115
+ return (
116
+ e.code,
117
+ {k.lower(): v for k, v in (e.headers or {}).items()},
118
+ e.read() if e.fp else b"",
119
+ )
120
+
121
+ def _json(self, method: str, path: str, body: bytes | None,
122
+ headers: dict[str, str] | None = None) -> dict:
123
+ status, _, raw = self._raw(method, path, body, headers)
124
+ if status >= 400:
125
+ raise ElastikError(status, raw)
126
+ try:
127
+ return json.loads(raw)
128
+ except (ValueError, json.JSONDecodeError):
129
+ return {"raw": raw}
@@ -0,0 +1,141 @@
1
+ Metadata-Version: 2.4
2
+ Name: elastik
3
+ Version: 0.0.1
4
+ Summary: Pastebin with HMAC that accidentally became a web OS. pip install one ring.
5
+ Author: Ranger Chen
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/rangersui/Elastik
8
+ Project-URL: Repository, https://github.com/rangersui/Elastik
9
+ Keywords: http,pastebin,local-first,ai,web-os,rust
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: POSIX
14
+ Classifier: Operating System :: MacOS
15
+ Classifier: Operating System :: Microsoft :: Windows
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Rust
18
+ Classifier: Topic :: Internet :: WWW/HTTP
19
+ Requires-Python: >=3.10
20
+ Description-Content-Type: text/markdown
21
+
22
+ # elastik
23
+
24
+ > `pip install elastik` — and you get a Rust web OS pretending to be a Python module.
25
+
26
+ Same trick as NumPy: the `.whl` ships a precompiled binary inside the
27
+ package; the Python layer is a thin client. You write Python; a Rust
28
+ server does the work; you never see Cargo.
29
+
30
+ ```python
31
+ import elastik
32
+
33
+ e = elastik.start(token="x") # spawn bundled rust core
34
+ e.put("/home/note", "hello")
35
+ print(e.get("/home/note", raw=True)) # b'hello'
36
+ elastik.stop()
37
+ ```
38
+
39
+ Module-level too — point at any running elastik via `ELASTIK_URL`:
40
+
41
+ ```python
42
+ import elastik
43
+ elastik.put("/home/note", "hi")
44
+ print(elastik.get("/home/note", raw=True))
45
+ ```
46
+
47
+ Reactor (declarative event handlers):
48
+
49
+ ```python
50
+ import elastik
51
+
52
+ @elastik.listen("/home/inbox/*")
53
+ def triage(body, world, meta):
54
+ if b"urgent" in body:
55
+ return elastik.Reply(f"/home/alerts/{world.rsplit('/',1)[-1]}", body)
56
+ return elastik.Archive()
57
+
58
+ elastik.serve(port=3200)
59
+ ```
60
+
61
+ Set elastik-core to fan out to that port:
62
+
63
+ ```bash
64
+ ELASTIK_LISTENERS="/home/inbox/*=http://localhost:3200/triage" \
65
+ elastik run --port 3105 --token t
66
+ ```
67
+
68
+ ## Install
69
+
70
+ ```bash
71
+ pip install elastik
72
+ ```
73
+
74
+ (For now: build from source — see "Build from source" below.)
75
+
76
+ ## CLI
77
+
78
+ ```bash
79
+ elastik info # show bundled-binary path + size
80
+ elastik run --port 3105 # spawn the bundled core, block
81
+ ```
82
+
83
+ ## What's bundled
84
+
85
+ - **Rust binary** at `elastik/_bin/elastik-core[.exe]`
86
+ - HTTP + SQLite + HMAC + auth + harvard gate + listener fanout
87
+ - ~4.3 MB stripped
88
+ - source: `Elastik-server/Elastik-core/`
89
+ - **Python SDK** (`elastik.sdk.Elastik`)
90
+ - atom bindings: `put`, `get`, `head`, `delete`, `list`, `shaped`
91
+ - stdlib only (urllib + json), no requests / httpx
92
+ - **Python reactor sugar** (`elastik.reactor`)
93
+ - `@listen(pattern)` decorator
94
+ - `MoveTo`, `Reply`, `Archive`, `Drop` action classes
95
+ - two deployment shapes: as elastik-python plugin (`finalize()`),
96
+ or as standalone sidecar (`serve()`)
97
+
98
+ ## The PyTorch parallel
99
+
100
+ | layer | what | who writes |
101
+ |---|---|---|
102
+ | L0 — Rust core | bedrock atoms (HTTP/SQLite/HMAC) | one human, once |
103
+ | L1 — Python SDK | `elastik.put`, `elastik.get`, … | AI, once |
104
+ | L1.5 — plugin runtime | (in elastik-server, hot-reload) | already exists |
105
+ | L2 — reactor sugar | `@elastik.listen` + Action classes | AI, once |
106
+ | L3 — agent programs | composition: if/for + L1 + L2 + ai | AI, daily |
107
+
108
+ `elastik.put()` is `np.array()`. The frontend isn't this call — it's
109
+ what you compose with these atoms. See [Elastik-core/README.md] for
110
+ the full architecture.
111
+
112
+ ## Build from source
113
+
114
+ ```bash
115
+ # 1. build the rust core
116
+ cd Elastik-core
117
+ cargo build --release
118
+
119
+ # 2. drop the binary into the python package
120
+ cp target/release/elastik-core* ../elastik-pip/src/elastik/_bin/
121
+
122
+ # 3. install the python package
123
+ cd ../elastik-pip
124
+ pip install -e .
125
+ ```
126
+
127
+ Then:
128
+
129
+ ```bash
130
+ python -c "import elastik; e = elastik.start(); print(e.put('/home/x', 'hi'))"
131
+ ```
132
+
133
+ ## Status
134
+
135
+ Sketch. One platform at a time (this build is for the host you ran
136
+ `cargo build` on). Multi-platform wheels via `cibuildwheel` is the
137
+ next step.
138
+
139
+ ## License
140
+
141
+ MIT.
@@ -0,0 +1,11 @@
1
+ elastik/__init__.py,sha256=Jz7gbBLvz-vAxDjJzOigbZov-6mfnV3reH7oAl_UH-c,4716
2
+ elastik/__main__.py,sha256=sWVfw-_KE4h5PexCVTfwmn4tqNen-XOyssEyBKL8NrE,2017
3
+ elastik/_spawn.py,sha256=PH_wYunv9PRhxwSXi2k7Qg3vdzB4Hdtkbe6nec778qc,4422
4
+ elastik/reactor.py,sha256=lZhac69xjUqLMxMGh9bM1U280J8h1lx0Hwza7Vmb800,9466
5
+ elastik/sdk.py,sha256=9n34SAhFxF4bWJtgTdIKiZICF15RBZNyjlZpueq4oKo,5138
6
+ elastik/_bin/elastik-core.exe,sha256=dGY9pCRVty7yfbSKLBvyfIGg3F6FyVrWScJxeWcAYQw,4459008
7
+ elastik-0.0.1.dist-info/METADATA,sha256=4m_upx-ZCl1Vw2EPMxl67DEJQPjFDH0qdLXX7EeC9AE,4074
8
+ elastik-0.0.1.dist-info/WHEEL,sha256=QR8DNjG6Lr6bNErJWJgF4dP2dJ2N7NpY-BWly1OvcTM,97
9
+ elastik-0.0.1.dist-info/entry_points.txt,sha256=fCrNw6tXUegXaxbef2tOBuh7bKnYixVipJlIAEtNdXM,50
10
+ elastik-0.0.1.dist-info/top_level.txt,sha256=noR3uFxC-xJg60tpDis-duq7JsNUwkYM4Zw626omNzM,8
11
+ elastik-0.0.1.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-win_amd64
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ elastik = elastik.__main__:main
@@ -0,0 +1 @@
1
+ elastik