lapis-lazuli 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,22 @@
1
+ .DS_Store
2
+ .gradle/
3
+ .gradle-local/
4
+ .kotlin/
5
+ .idea/
6
+ .lapis/
7
+ .vscode/
8
+ *.iml
9
+ __pycache__/
10
+ *.pyc
11
+ .mypy_cache/
12
+ .pytest_cache/
13
+ .ruff_cache/
14
+ .venv/
15
+ venv/
16
+ **/bin/
17
+ build/
18
+ dist/
19
+ node_modules/
20
+ out/
21
+ *.class
22
+ *.log
@@ -0,0 +1,25 @@
1
+ Metadata-Version: 2.4
2
+ Name: lapis-lazuli
3
+ Version: 0.2.0
4
+ Summary: Python SDK for Lapis Lazuli plugins
5
+ Author: Lapis Lazuli
6
+ Classifier: Programming Language :: Python :: 3
7
+ Classifier: Programming Language :: Python :: 3 :: Only
8
+ Requires-Python: >=3.11
9
+ Description-Content-Type: text/markdown
10
+
11
+ # lapis-lazuli
12
+
13
+ Python SDK for authoring Lapis Lazuli plugins with a Python-first surface.
14
+
15
+ Install:
16
+
17
+ ```sh
18
+ python -m pip install lapis-lazuli
19
+ ```
20
+
21
+ Import:
22
+
23
+ ```py
24
+ from lapis_lazuli import Plugin
25
+ ```
@@ -0,0 +1,15 @@
1
+ # lapis-lazuli
2
+
3
+ Python SDK for authoring Lapis Lazuli plugins with a Python-first surface.
4
+
5
+ Install:
6
+
7
+ ```sh
8
+ python -m pip install lapis-lazuli
9
+ ```
10
+
11
+ Import:
12
+
13
+ ```py
14
+ from lapis_lazuli import Plugin
15
+ ```
@@ -0,0 +1,20 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "lapis-lazuli"
7
+ version = "0.2.0"
8
+ description = "Python SDK for Lapis Lazuli plugins"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ authors = [
12
+ { name = "Lapis Lazuli" },
13
+ ]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3 :: Only",
17
+ ]
18
+
19
+ [tool.hatch.build.targets.wheel]
20
+ packages = ["src/lapis_lazuli"]
@@ -0,0 +1,15 @@
1
+ from ._plugin import Plugin, define_plugin
2
+ from ._runtime import GuestProxy, HttpResponse, Location, PluginContext, TitleOptions, wrap
3
+
4
+ __all__ = [
5
+ "GuestProxy",
6
+ "HttpResponse",
7
+ "Location",
8
+ "Plugin",
9
+ "PluginContext",
10
+ "TitleOptions",
11
+ "define_plugin",
12
+ "wrap",
13
+ ]
14
+
15
+ __version__ = "0.2.0"
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from typing import Any
5
+
6
+ from ._runtime import PluginContext
7
+
8
+
9
+ class Plugin:
10
+ def __init__(self, name: str, version: str | None = None) -> None:
11
+ self.name = name
12
+ self.version = version
13
+ self._startup_handler: Callable[[PluginContext], Any] | None = None
14
+ self._shutdown_handler: Callable[[PluginContext], Any] | None = None
15
+
16
+ def startup(self, handler: Callable[[PluginContext], Any]) -> Callable[[PluginContext], Any]:
17
+ self._startup_handler = handler
18
+ return handler
19
+
20
+ def shutdown(self, handler: Callable[[PluginContext], Any]) -> Callable[[PluginContext], Any]:
21
+ self._shutdown_handler = handler
22
+ return handler
23
+
24
+ def on_enable(self, raw_context: Any) -> Any:
25
+ if self._startup_handler is None:
26
+ return None
27
+ return self._startup_handler(PluginContext(raw_context))
28
+
29
+ def on_disable(self, raw_context: Any) -> Any:
30
+ if self._shutdown_handler is None:
31
+ return None
32
+ return self._shutdown_handler(PluginContext(raw_context))
33
+
34
+
35
+ def define_plugin(
36
+ *,
37
+ name: str,
38
+ version: str | None = None,
39
+ on_enable: Callable[[PluginContext], Any] | None = None,
40
+ on_disable: Callable[[PluginContext], Any] | None = None,
41
+ ) -> Plugin:
42
+ plugin = Plugin(name=name, version=version)
43
+
44
+ if on_enable is not None:
45
+ plugin.startup(on_enable)
46
+
47
+ if on_disable is not None:
48
+ plugin.shutdown(on_disable)
49
+
50
+ return plugin
@@ -0,0 +1,718 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable, Mapping, Sequence
4
+ from dataclasses import asdict, dataclass, is_dataclass
5
+ import json
6
+ import inspect
7
+ from typing import Any
8
+
9
+ _MISSING = object()
10
+ _SCALAR_TYPES = (str, int, float, bool, bytes)
11
+
12
+
13
+ def _snake_to_camel(name: str) -> str:
14
+ if "_" not in name:
15
+ return name
16
+
17
+ head, *tail = name.split("_")
18
+ return head + "".join(part[:1].upper() + part[1:] for part in tail)
19
+
20
+
21
+ def _resolve_member_name(raw: Any, name: str) -> str:
22
+ if hasattr(raw, name):
23
+ return name
24
+
25
+ camel_name = _snake_to_camel(name)
26
+ if hasattr(raw, camel_name):
27
+ return camel_name
28
+
29
+ raise AttributeError(f"{type(raw).__name__} has no attribute {name!r}")
30
+
31
+
32
+ def _accepts_argument(callback: Callable[..., Any]) -> bool:
33
+ try:
34
+ signature = inspect.signature(callback)
35
+ except (TypeError, ValueError):
36
+ return True
37
+
38
+ for parameter in signature.parameters.values():
39
+ if parameter.kind in (
40
+ inspect.Parameter.POSITIONAL_ONLY,
41
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
42
+ inspect.Parameter.VAR_POSITIONAL,
43
+ ):
44
+ return True
45
+
46
+ return False
47
+
48
+
49
+ def _wrap_guest_callable(callback: Callable[..., Any]) -> Callable[..., Any]:
50
+ def invoke(*args: Any, **kwargs: Any) -> Any:
51
+ if kwargs:
52
+ raise TypeError("Keyword arguments are not supported for raw runtime methods.")
53
+ return wrap(callback(*(_unwrap(arg) for arg in args)))
54
+
55
+ return invoke
56
+
57
+
58
+ def _wrap_callback(
59
+ callback: Callable[..., Any],
60
+ *,
61
+ payload_wrapper: Callable[[Any], Any] = lambda value: value,
62
+ ) -> Callable[..., Any]:
63
+ accepts_argument = _accepts_argument(callback)
64
+
65
+ def invoke(payload: Any = _MISSING) -> Any:
66
+ if payload is _MISSING:
67
+ return callback()
68
+ if accepts_argument:
69
+ return callback(payload_wrapper(payload))
70
+ return callback()
71
+
72
+ return invoke
73
+
74
+
75
+ def _looks_like_location(value: Any) -> bool:
76
+ return all(hasattr(value, field) for field in ("x", "y", "z"))
77
+
78
+
79
+ def _looks_like_store(value: Any) -> bool:
80
+ return all(hasattr(value, field) for field in ("get", "set", "delete", "save", "reload", "keys"))
81
+
82
+
83
+ def _looks_like_file_store(value: Any) -> bool:
84
+ return all(hasattr(value, field) for field in ("path", "resolve", "readText", "writeText", "exists", "mkdirs"))
85
+
86
+
87
+ def _camelize_mapping(mapping: Mapping[Any, Any]) -> dict[Any, Any]:
88
+ result: dict[Any, Any] = {}
89
+ for key, value in mapping.items():
90
+ normalized_key = _snake_to_camel(key) if isinstance(key, str) else key
91
+ if value is None:
92
+ continue
93
+ result[normalized_key] = _unwrap(value)
94
+ return result
95
+
96
+
97
+ def _unwrap(value: Any) -> Any:
98
+ if isinstance(value, GuestProxy):
99
+ return value.raw
100
+
101
+ if isinstance(value, Location):
102
+ return value.to_payload()
103
+
104
+ if value is None or isinstance(value, _SCALAR_TYPES):
105
+ return value
106
+
107
+ if is_dataclass(value):
108
+ return _camelize_mapping(asdict(value))
109
+
110
+ if isinstance(value, Mapping):
111
+ return _camelize_mapping(value)
112
+
113
+ if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
114
+ return [_unwrap(item) for item in value]
115
+
116
+ return value
117
+
118
+
119
+ def wrap(value: Any) -> Any:
120
+ if isinstance(value, (GuestProxy, Location)) or value is None or isinstance(value, _SCALAR_TYPES):
121
+ return value
122
+
123
+ if isinstance(value, Mapping):
124
+ return MappingProxy(value)
125
+
126
+ if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
127
+ return [wrap(item) for item in value]
128
+
129
+ if callable(value):
130
+ return _wrap_guest_callable(value)
131
+
132
+ if _looks_like_location(value):
133
+ return Location.from_raw(value)
134
+
135
+ if _looks_like_store(value):
136
+ return KeyValueStore(value)
137
+
138
+ if _looks_like_file_store(value):
139
+ return FileStore(value)
140
+
141
+ return GuestProxy(value)
142
+
143
+
144
+ @dataclass(slots=True)
145
+ class Location:
146
+ x: float
147
+ y: float
148
+ z: float
149
+ world: str | None = None
150
+ yaw: float | None = None
151
+ pitch: float | None = None
152
+
153
+ @classmethod
154
+ def from_raw(cls, raw: Any) -> "Location":
155
+ return cls(
156
+ world=getattr(raw, "world", None),
157
+ x=getattr(raw, "x"),
158
+ y=getattr(raw, "y"),
159
+ z=getattr(raw, "z"),
160
+ yaw=getattr(raw, "yaw", None),
161
+ pitch=getattr(raw, "pitch", None),
162
+ )
163
+
164
+ def to_payload(self) -> dict[str, Any]:
165
+ return _camelize_mapping(asdict(self))
166
+
167
+
168
+ @dataclass(slots=True)
169
+ class TitleOptions:
170
+ subtitle: Any | None = None
171
+ fade_in_ticks: int | None = None
172
+ stay_ticks: int | None = None
173
+ fade_out_ticks: int | None = None
174
+
175
+
176
+ class GuestProxy:
177
+ __slots__ = ("_raw",)
178
+
179
+ def __init__(self, raw: Any) -> None:
180
+ object.__setattr__(self, "_raw", raw)
181
+
182
+ @property
183
+ def raw(self) -> Any:
184
+ return object.__getattribute__(self, "_raw")
185
+
186
+ def __getattr__(self, name: str) -> Any:
187
+ raw_name = _resolve_member_name(self.raw, name)
188
+ value = getattr(self.raw, raw_name)
189
+
190
+ if raw_name == "unsafe":
191
+ return UnsafeHandle(value)
192
+
193
+ if callable(value):
194
+ return _wrap_guest_callable(value)
195
+
196
+ return wrap(value)
197
+
198
+ def __setattr__(self, name: str, value: Any) -> None:
199
+ if name.startswith("_"):
200
+ object.__setattr__(self, name, value)
201
+ return
202
+
203
+ raw_name = _resolve_member_name(self.raw, name)
204
+ setattr(self.raw, raw_name, _unwrap(value))
205
+
206
+ def __repr__(self) -> str:
207
+ return f"{self.__class__.__name__}({self.raw!r})"
208
+
209
+
210
+ class MappingProxy(GuestProxy):
211
+ def __getattr__(self, name: str) -> Any:
212
+ if name in self.raw:
213
+ return wrap(self.raw[name])
214
+
215
+ camel_name = _snake_to_camel(name)
216
+ if camel_name in self.raw:
217
+ return wrap(self.raw[camel_name])
218
+
219
+ raise AttributeError(f"mapping has no attribute {name!r}")
220
+
221
+ def __setattr__(self, name: str, value: Any) -> None:
222
+ if name.startswith("_"):
223
+ object.__setattr__(self, name, value)
224
+ return
225
+
226
+ if name in self.raw:
227
+ self.raw[name] = _unwrap(value)
228
+ return
229
+
230
+ self.raw[_snake_to_camel(name)] = _unwrap(value)
231
+
232
+ def __getitem__(self, key: Any) -> Any:
233
+ return wrap(self.raw[key])
234
+
235
+ def get(self, key: Any, default: Any = None) -> Any:
236
+ if key in self.raw:
237
+ return wrap(self.raw[key])
238
+ return default
239
+
240
+ def items(self) -> list[tuple[Any, Any]]:
241
+ return [(key, wrap(value)) for key, value in self.raw.items()]
242
+
243
+ def keys(self) -> list[Any]:
244
+ return list(self.raw.keys())
245
+
246
+ def values(self) -> list[Any]:
247
+ return [wrap(value) for value in self.raw.values()]
248
+
249
+ def __contains__(self, key: Any) -> bool:
250
+ return key in self.raw
251
+
252
+ def __iter__(self):
253
+ return iter(self.raw)
254
+
255
+
256
+ class UnsafeHandle(GuestProxy):
257
+ @property
258
+ def handle(self) -> Any:
259
+ return getattr(self.raw, "handle")
260
+
261
+
262
+ class Logger(GuestProxy):
263
+ pass
264
+
265
+
266
+ class HookHandle(GuestProxy):
267
+ pass
268
+
269
+
270
+ class TaskHandle(GuestProxy):
271
+ pass
272
+
273
+
274
+ class KeyValueStore(GuestProxy):
275
+ pass
276
+
277
+
278
+ class FileStore(GuestProxy):
279
+ pass
280
+
281
+
282
+ class AppService(GuestProxy):
283
+ @property
284
+ def log(self) -> Logger:
285
+ return Logger(getattr(self.raw, "log"))
286
+
287
+ def on_shutdown(self, handler: Callable[..., Any]) -> HookHandle:
288
+ return HookHandle(getattr(self.raw, "onShutdown")(_wrap_callback(handler)))
289
+
290
+
291
+ class CommandRegistry(GuestProxy):
292
+ def register(
293
+ self,
294
+ name: str | Mapping[str, Any] | None = None,
295
+ execute: Callable[..., Any] | None = None,
296
+ **kwargs: Any,
297
+ ) -> HookHandle:
298
+ if isinstance(name, Mapping):
299
+ if execute is not None or kwargs:
300
+ raise TypeError("register() accepts either a mapping or keyword arguments, not both.")
301
+ spec = dict(name)
302
+ else:
303
+ spec = dict(kwargs)
304
+ if name is not None:
305
+ spec["name"] = name
306
+ if execute is not None:
307
+ spec["execute"] = execute
308
+
309
+ callback = spec.get("execute")
310
+ if callback is None:
311
+ raise TypeError("register() requires an execute callback.")
312
+
313
+ spec["execute"] = _wrap_callback(callback, payload_wrapper=wrap)
314
+ return HookHandle(getattr(self.raw, "register")(_unwrap(spec)))
315
+
316
+
317
+ class EventRegistry(GuestProxy):
318
+ def on(self, event_name: str, handler: Callable[..., Any]) -> HookHandle:
319
+ return HookHandle(getattr(self.raw, "on")(event_name, _wrap_callback(handler, payload_wrapper=wrap)))
320
+
321
+
322
+ class TaskRegistry(GuestProxy):
323
+ def run(self, task: Callable[..., Any]) -> TaskHandle:
324
+ return TaskHandle(getattr(self.raw, "run")(_wrap_callback(task)))
325
+
326
+ def delay(self, delay_ticks: int, task: Callable[..., Any]) -> TaskHandle:
327
+ return TaskHandle(getattr(self.raw, "delay")(delay_ticks, _wrap_callback(task)))
328
+
329
+ def repeat(self, interval_ticks: int, task: Callable[..., Any]) -> TaskHandle:
330
+ return TaskHandle(getattr(self.raw, "repeat")(interval_ticks, _wrap_callback(task)))
331
+
332
+ def timer(self, delay_ticks: int, interval_ticks: int, task: Callable[..., Any]) -> TaskHandle:
333
+ return TaskHandle(getattr(self.raw, "timer")(delay_ticks, interval_ticks, _wrap_callback(task)))
334
+
335
+
336
+ class PlayerDirectory(GuestProxy):
337
+ pass
338
+
339
+
340
+ class WorldDirectory(GuestProxy):
341
+ pass
342
+
343
+
344
+ class EntityDirectory(GuestProxy):
345
+ def spawn(
346
+ self,
347
+ entity_type: str | Mapping[str, Any],
348
+ location: Any | None = None,
349
+ *,
350
+ world: str | None = None,
351
+ ) -> GuestProxy:
352
+ if isinstance(entity_type, Mapping):
353
+ if location is not None or world is not None:
354
+ raise TypeError("spawn() accepts either a mapping or explicit arguments, not both.")
355
+ spec = dict(entity_type)
356
+ else:
357
+ if location is None:
358
+ raise TypeError("spawn() requires a location.")
359
+ spec = {"type": entity_type, "location": location, "world": world}
360
+
361
+ return wrap(getattr(self.raw, "spawn")(_unwrap(spec)))
362
+
363
+
364
+ class ItemFactory(GuestProxy):
365
+ def create(self, item_type: str | Mapping[str, Any], **kwargs: Any) -> GuestProxy:
366
+ if isinstance(item_type, Mapping):
367
+ if kwargs:
368
+ raise TypeError("create() accepts either a mapping or keyword arguments, not both.")
369
+ spec = dict(item_type)
370
+ else:
371
+ spec = {"type": item_type, **kwargs}
372
+
373
+ return wrap(getattr(self.raw, "create")(_unwrap(spec)))
374
+
375
+
376
+ class InventoryDirectory(GuestProxy):
377
+ def create(self, title: Any | Mapping[str, Any], size: int | None = None, *, id: str | None = None) -> GuestProxy:
378
+ if isinstance(title, Mapping):
379
+ if size is not None or id is not None:
380
+ raise TypeError("create() accepts either a mapping or explicit arguments, not both.")
381
+ spec = dict(title)
382
+ else:
383
+ if size is None:
384
+ raise TypeError("create() requires a size.")
385
+ spec = {"title": title, "size": size, "id": id}
386
+
387
+ return wrap(getattr(self.raw, "create")(_unwrap(spec)))
388
+
389
+ def open(self, player: Any, inventory: Any) -> None:
390
+ getattr(self.raw, "open")(_unwrap(player), _unwrap(inventory))
391
+
392
+
393
+ class ChatService(GuestProxy):
394
+ pass
395
+
396
+
397
+ class EffectsService(GuestProxy):
398
+ def play_sound(
399
+ self,
400
+ sound: str | Mapping[str, Any],
401
+ *,
402
+ location: Any | None = None,
403
+ player: Any | None = None,
404
+ volume: float | None = None,
405
+ pitch: float | None = None,
406
+ ) -> None:
407
+ if isinstance(sound, Mapping):
408
+ spec = dict(sound)
409
+ else:
410
+ spec = {
411
+ "sound": sound,
412
+ "location": location,
413
+ "player": player,
414
+ "volume": volume,
415
+ "pitch": pitch,
416
+ }
417
+
418
+ getattr(self.raw, "playSound")(_unwrap(spec))
419
+
420
+ def spawn_particle(
421
+ self,
422
+ particle: str | Mapping[str, Any],
423
+ *,
424
+ location: Any | None = None,
425
+ count: int | None = None,
426
+ offset_x: float | None = None,
427
+ offset_y: float | None = None,
428
+ offset_z: float | None = None,
429
+ extra: float | None = None,
430
+ players: Sequence[Any] | None = None,
431
+ ) -> None:
432
+ if isinstance(particle, Mapping):
433
+ spec = dict(particle)
434
+ else:
435
+ spec = {
436
+ "particle": particle,
437
+ "location": location,
438
+ "count": count,
439
+ "offset_x": offset_x,
440
+ "offset_y": offset_y,
441
+ "offset_z": offset_z,
442
+ "extra": extra,
443
+ "players": players,
444
+ }
445
+
446
+ getattr(self.raw, "spawnParticle")(_unwrap(spec))
447
+
448
+ def apply_potion(
449
+ self,
450
+ player: Any | Mapping[str, Any],
451
+ *,
452
+ effect: str | None = None,
453
+ duration_ticks: int | None = None,
454
+ amplifier: int | None = None,
455
+ ambient: bool | None = None,
456
+ particles: bool | None = None,
457
+ icon: bool | None = None,
458
+ ) -> bool:
459
+ if isinstance(player, Mapping):
460
+ spec = dict(player)
461
+ else:
462
+ spec = {
463
+ "player": player,
464
+ "effect": effect,
465
+ "duration_ticks": duration_ticks,
466
+ "amplifier": amplifier,
467
+ "ambient": ambient,
468
+ "particles": particles,
469
+ "icon": icon,
470
+ }
471
+
472
+ return bool(getattr(self.raw, "applyPotion")(_unwrap(spec)))
473
+
474
+ def clear_potion(self, player: Any, effect: str | None = None) -> None:
475
+ if effect is None:
476
+ getattr(self.raw, "clearPotion")(_unwrap(player))
477
+ return
478
+
479
+ getattr(self.raw, "clearPotion")(_unwrap(player), effect)
480
+
481
+
482
+ class RecipeService(GuestProxy):
483
+ def register(self, spec: Mapping[str, Any]) -> GuestProxy:
484
+ return wrap(getattr(self.raw, "register")(_unwrap(spec)))
485
+
486
+ def register_shaped(
487
+ self,
488
+ *,
489
+ id: str,
490
+ result: Mapping[str, Any],
491
+ shape: Sequence[str],
492
+ ingredients: Mapping[str, Any],
493
+ ) -> GuestProxy:
494
+ return self.register(
495
+ {
496
+ "kind": "shaped",
497
+ "id": id,
498
+ "result": result,
499
+ "shape": list(shape),
500
+ "ingredients": dict(ingredients),
501
+ },
502
+ )
503
+
504
+ def register_shapeless(
505
+ self,
506
+ *,
507
+ id: str,
508
+ result: Mapping[str, Any],
509
+ ingredients: Sequence[Any],
510
+ ) -> GuestProxy:
511
+ return self.register(
512
+ {
513
+ "kind": "shapeless",
514
+ "id": id,
515
+ "result": result,
516
+ "ingredients": list(ingredients),
517
+ },
518
+ )
519
+
520
+
521
+ class BossBarsService(GuestProxy):
522
+ def create(
523
+ self,
524
+ title: Any | Mapping[str, Any],
525
+ *,
526
+ id: str | None = None,
527
+ color: str | None = None,
528
+ style: str | None = None,
529
+ progress: float | None = None,
530
+ ) -> GuestProxy:
531
+ if isinstance(title, Mapping):
532
+ spec = dict(title)
533
+ else:
534
+ spec = {
535
+ "title": title,
536
+ "id": id,
537
+ "color": color,
538
+ "style": style,
539
+ "progress": progress,
540
+ }
541
+
542
+ return wrap(getattr(self.raw, "create")(_unwrap(spec)))
543
+
544
+
545
+ class ScoreboardsService(GuestProxy):
546
+ def create(self, title: Any | Mapping[str, Any], *, id: str | None = None) -> GuestProxy:
547
+ if isinstance(title, Mapping):
548
+ spec = dict(title)
549
+ else:
550
+ spec = {"title": title, "id": id}
551
+
552
+ return wrap(getattr(self.raw, "create")(_unwrap(spec)))
553
+
554
+
555
+ class StorageService:
556
+ __slots__ = ("plugin", "files", "raw")
557
+
558
+ def __init__(self, raw: Any) -> None:
559
+ self.raw = raw
560
+ self.plugin = KeyValueStore(getattr(raw, "plugin"))
561
+ self.files = FileStore(getattr(raw, "files"))
562
+
563
+
564
+ @dataclass(slots=True)
565
+ class HttpResponse:
566
+ url: str
567
+ status: int
568
+ ok: bool
569
+ headers: dict[str, str]
570
+ body: str
571
+
572
+ @classmethod
573
+ def from_raw(cls, raw: Any) -> "HttpResponse":
574
+ raw_headers = wrap(getattr(raw, "headers"))
575
+ return cls(
576
+ url=getattr(raw, "url"),
577
+ status=getattr(raw, "status"),
578
+ ok=bool(getattr(raw, "ok")),
579
+ headers={key: raw_headers[key] for key in raw_headers},
580
+ body=getattr(raw, "body"),
581
+ )
582
+
583
+ @property
584
+ def text(self) -> str:
585
+ return self.body
586
+
587
+ def json(self) -> Any:
588
+ return json.loads(self.body)
589
+
590
+
591
+ class HttpService(GuestProxy):
592
+ def fetch(
593
+ self,
594
+ url: str | Mapping[str, Any],
595
+ method: str | None = None,
596
+ *,
597
+ headers: Mapping[str, str] | None = None,
598
+ body: str | None = None,
599
+ ) -> HttpResponse:
600
+ if isinstance(url, Mapping):
601
+ if method is not None or headers is not None or body is not None:
602
+ raise TypeError("fetch() accepts either a mapping or explicit arguments, not both.")
603
+ spec = dict(url)
604
+ else:
605
+ spec = {
606
+ "url": url,
607
+ "method": method,
608
+ "headers": headers,
609
+ "body": body,
610
+ }
611
+
612
+ return HttpResponse.from_raw(getattr(self.raw, "fetch")(_unwrap(spec)))
613
+
614
+ def get(self, url: str, *, headers: Mapping[str, str] | None = None) -> HttpResponse:
615
+ return self.fetch(url, headers=headers)
616
+
617
+ def post(
618
+ self,
619
+ url: str,
620
+ *,
621
+ headers: Mapping[str, str] | None = None,
622
+ body: str | None = None,
623
+ ) -> HttpResponse:
624
+ return self.fetch(url, method="POST", headers=headers, body=body)
625
+
626
+ def put(
627
+ self,
628
+ url: str,
629
+ *,
630
+ headers: Mapping[str, str] | None = None,
631
+ body: str | None = None,
632
+ ) -> HttpResponse:
633
+ return self.fetch(url, method="PUT", headers=headers, body=body)
634
+
635
+ def delete(self, url: str, *, headers: Mapping[str, str] | None = None) -> HttpResponse:
636
+ return self.fetch(url, method="DELETE", headers=headers)
637
+
638
+
639
+ class UnsafeEvents(GuestProxy):
640
+ def on_java(self, event_class_name: str, handler: Callable[..., Any]) -> HookHandle:
641
+ return HookHandle(getattr(self.raw, "onJava")(event_class_name, _wrap_callback(handler, payload_wrapper=wrap)))
642
+
643
+
644
+ class UnsafeJava(GuestProxy):
645
+ def type(self, class_name: str) -> Any:
646
+ return getattr(self.raw, "type")(class_name)
647
+
648
+
649
+ class UnsafeBackend(GuestProxy):
650
+ @property
651
+ def server(self) -> Any:
652
+ return getattr(self.raw, "server")
653
+
654
+ @property
655
+ def plugin(self) -> Any:
656
+ return getattr(self.raw, "plugin")
657
+
658
+ @property
659
+ def console(self) -> Any:
660
+ return getattr(self.raw, "console")
661
+
662
+ def dispatch_command(self, command: str) -> bool:
663
+ return bool(getattr(self.raw, "dispatchCommand")(command))
664
+
665
+
666
+ class UnsafeBridge:
667
+ __slots__ = ("events", "java", "backend", "raw")
668
+
669
+ def __init__(self, raw: Any) -> None:
670
+ self.raw = raw
671
+ self.events = UnsafeEvents(getattr(raw, "events"))
672
+ self.java = UnsafeJava(getattr(raw, "java"))
673
+ self.backend = UnsafeBackend(getattr(raw, "backend"))
674
+
675
+
676
+ class PluginContext:
677
+ __slots__ = (
678
+ "app",
679
+ "boss_bars",
680
+ "chat",
681
+ "commands",
682
+ "config",
683
+ "effects",
684
+ "entities",
685
+ "events",
686
+ "http",
687
+ "inventory",
688
+ "items",
689
+ "players",
690
+ "raw",
691
+ "recipes",
692
+ "scoreboards",
693
+ "storage",
694
+ "tasks",
695
+ "unsafe",
696
+ "worlds",
697
+ )
698
+
699
+ def __init__(self, raw: Any) -> None:
700
+ self.raw = raw
701
+ self.app = AppService(getattr(raw, "app"))
702
+ self.commands = CommandRegistry(getattr(raw, "commands"))
703
+ self.events = EventRegistry(getattr(raw, "events"))
704
+ self.tasks = TaskRegistry(getattr(raw, "tasks"))
705
+ self.players = PlayerDirectory(getattr(raw, "players"))
706
+ self.worlds = WorldDirectory(getattr(raw, "worlds"))
707
+ self.entities = EntityDirectory(getattr(raw, "entities"))
708
+ self.items = ItemFactory(getattr(raw, "items"))
709
+ self.inventory = InventoryDirectory(getattr(raw, "inventory"))
710
+ self.chat = ChatService(getattr(raw, "chat"))
711
+ self.effects = EffectsService(getattr(raw, "effects"))
712
+ self.recipes = RecipeService(getattr(raw, "recipes"))
713
+ self.boss_bars = BossBarsService(getattr(raw, "bossBars"))
714
+ self.scoreboards = ScoreboardsService(getattr(raw, "scoreboards"))
715
+ self.storage = StorageService(getattr(raw, "storage"))
716
+ self.http = HttpService(getattr(raw, "http"))
717
+ self.config = KeyValueStore(getattr(raw, "config"))
718
+ self.unsafe = UnsafeBridge(getattr(raw, "unsafe"))
@@ -0,0 +1,265 @@
1
+ from __future__ import annotations
2
+
3
+ import unittest
4
+
5
+ from lapis_lazuli import Plugin, PluginContext
6
+
7
+
8
+ class RawLogger:
9
+ def __init__(self) -> None:
10
+ self.messages: list[str] = []
11
+
12
+ def info(self, message: str) -> None:
13
+ self.messages.append(message)
14
+
15
+
16
+ class RawApp:
17
+ def __init__(self) -> None:
18
+ self.id = "hello-python"
19
+ self.name = "Hello Python"
20
+ self.version = "0.1.0"
21
+ self.engine = "python"
22
+ self.apiVersion = "1.0"
23
+ self.backend = "paper"
24
+ self.runtime = "test"
25
+ self.log = RawLogger()
26
+
27
+ def onShutdown(self, handler):
28
+ self.handler = handler
29
+ return RawHandle()
30
+
31
+
32
+ class RawHandle:
33
+ def unsubscribe(self) -> None:
34
+ return None
35
+
36
+ def cancel(self) -> None:
37
+ return None
38
+
39
+
40
+ class RawSender:
41
+ def __init__(self) -> None:
42
+ self.messages: list[str] = []
43
+
44
+ def sendMessage(self, message: str) -> None:
45
+ self.messages.append(message)
46
+
47
+ def hasPermission(self, permission: str) -> bool:
48
+ return True
49
+
50
+
51
+ class RawCommands:
52
+ def __init__(self) -> None:
53
+ self.registrations: list[dict[str, object]] = []
54
+
55
+ def register(self, spec):
56
+ self.registrations.append(spec)
57
+ return RawHandle()
58
+
59
+
60
+ class RawEvents:
61
+ def __init__(self) -> None:
62
+ self.handlers: dict[str, object] = {}
63
+
64
+ def on(self, event_name, handler):
65
+ self.handlers[event_name] = handler
66
+ return RawHandle()
67
+
68
+
69
+ class RawNoop:
70
+ def run(self, handler):
71
+ self.handler = handler
72
+ return RawHandle()
73
+
74
+ def delay(self, delay_ticks, handler):
75
+ self.delay_ticks = delay_ticks
76
+ self.delay_handler = handler
77
+ return RawHandle()
78
+
79
+ def repeat(self, interval_ticks, handler):
80
+ self.interval_ticks = interval_ticks
81
+ self.repeat_handler = handler
82
+ return RawHandle()
83
+
84
+ def timer(self, delay_ticks, interval_ticks, handler):
85
+ self.delay_ticks = delay_ticks
86
+ self.interval_ticks = interval_ticks
87
+ self.timer_handler = handler
88
+ return RawHandle()
89
+
90
+
91
+ class RawStore:
92
+ def get(self, path):
93
+ return None
94
+
95
+ def set(self, path, value):
96
+ self.last_set = (path, value)
97
+
98
+ def delete(self, path):
99
+ self.deleted = path
100
+
101
+ def save(self):
102
+ return None
103
+
104
+ def reload(self):
105
+ return None
106
+
107
+ def keys(self):
108
+ return []
109
+
110
+
111
+ class RawFiles:
112
+ path = "/tmp"
113
+
114
+ def resolve(self, *segments):
115
+ return "/tmp/" + "/".join(segments)
116
+
117
+ def readText(self, relative_path):
118
+ return relative_path
119
+
120
+ def writeText(self, relative_path, contents):
121
+ self.last_write = (relative_path, contents)
122
+
123
+ def exists(self, relative_path):
124
+ return False
125
+
126
+ def mkdirs(self, relative_path=""):
127
+ self.last_mkdir = relative_path
128
+
129
+
130
+ class RawStorage:
131
+ def __init__(self) -> None:
132
+ self.plugin = RawStore()
133
+ self.files = RawFiles()
134
+
135
+
136
+ class RawHttpResponse:
137
+ def __init__(self, url: str, status: int, headers: dict[str, str], body: str) -> None:
138
+ self.url = url
139
+ self.status = status
140
+ self.ok = 200 <= status <= 299
141
+ self.headers = headers
142
+ self.body = body
143
+
144
+
145
+ class RawHttp:
146
+ def fetch(self, spec):
147
+ self.last_request = spec
148
+ return RawHttpResponse(
149
+ url=spec["url"],
150
+ status=201,
151
+ headers={"content-type": "application/json"},
152
+ body='{"ok": true}',
153
+ )
154
+
155
+
156
+ class RawUnsafeEvents:
157
+ def onJava(self, event_class_name, handler):
158
+ self.last_handler = (event_class_name, handler)
159
+ return RawHandle()
160
+
161
+
162
+ class RawUnsafeJava:
163
+ def type(self, class_name):
164
+ return class_name
165
+
166
+
167
+ class RawUnsafeBackend:
168
+ server = object()
169
+ plugin = object()
170
+ console = object()
171
+
172
+ def dispatchCommand(self, command):
173
+ self.last_command = command
174
+ return True
175
+
176
+
177
+ class RawUnsafe:
178
+ def __init__(self) -> None:
179
+ self.events = RawUnsafeEvents()
180
+ self.java = RawUnsafeJava()
181
+ self.backend = RawUnsafeBackend()
182
+
183
+
184
+ class RawContext:
185
+ def __init__(self) -> None:
186
+ self.app = RawApp()
187
+ self.commands = RawCommands()
188
+ self.events = RawEvents()
189
+ self.tasks = RawNoop()
190
+ self.players = RawNoop()
191
+ self.worlds = RawNoop()
192
+ self.entities = RawNoop()
193
+ self.items = RawNoop()
194
+ self.inventory = RawNoop()
195
+ self.chat = RawNoop()
196
+ self.effects = RawNoop()
197
+ self.recipes = RawNoop()
198
+ self.bossBars = RawNoop()
199
+ self.scoreboards = RawNoop()
200
+ self.storage = RawStorage()
201
+ self.http = RawHttp()
202
+ self.config = RawStore()
203
+ self.unsafe = RawUnsafe()
204
+
205
+
206
+ class RawCommandContext:
207
+ def __init__(self, sender: RawSender) -> None:
208
+ self.sender = sender
209
+ self.args = []
210
+ self.label = "hello"
211
+ self.command = "hello"
212
+
213
+
214
+ class PluginTests(unittest.TestCase):
215
+ def test_plugin_wraps_context_and_registers_pythonic_command(self) -> None:
216
+ raw_context = RawContext()
217
+ plugin = Plugin("Hello Python", version="0.1.0")
218
+
219
+ @plugin.startup
220
+ def on_enable(context: PluginContext) -> None:
221
+ context.app.log.info("enabled")
222
+ context.commands.register(
223
+ "hello",
224
+ lambda command: command.sender.send_message("Hello from Python."),
225
+ description="Send a greeting.",
226
+ )
227
+
228
+ plugin.on_enable(raw_context)
229
+
230
+ self.assertEqual(["enabled"], raw_context.app.log.messages)
231
+ registration = raw_context.commands.registrations[0]
232
+ self.assertEqual("hello", registration["name"])
233
+ self.assertEqual("Send a greeting.", registration["description"])
234
+
235
+ sender = RawSender()
236
+ registration["execute"](RawCommandContext(sender))
237
+ self.assertEqual(["Hello from Python."], sender.messages)
238
+
239
+ def test_http_service_wraps_requests_pythonically(self) -> None:
240
+ raw_context = RawContext()
241
+ context = PluginContext(raw_context)
242
+
243
+ response = context.http.post(
244
+ "https://example.test/items",
245
+ headers={"content-type": "application/json"},
246
+ body='{"name":"lapis"}',
247
+ )
248
+
249
+ self.assertEqual(
250
+ {
251
+ "url": "https://example.test/items",
252
+ "method": "POST",
253
+ "headers": {"content-type": "application/json"},
254
+ "body": '{"name":"lapis"}',
255
+ },
256
+ raw_context.http.last_request,
257
+ )
258
+ self.assertEqual(201, response.status)
259
+ self.assertTrue(response.ok)
260
+ self.assertEqual("application/json", response.headers["content-type"])
261
+ self.assertEqual({"ok": True}, response.json())
262
+
263
+
264
+ if __name__ == "__main__":
265
+ unittest.main()