magmascript 3.1.0__tar.gz → 3.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.
Files changed (96) hide show
  1. {magmascript-3.1.0 → magmascript-3.2.0}/PKG-INFO +30 -1
  2. {magmascript-3.1.0 → magmascript-3.2.0}/README.md +29 -0
  3. {magmascript-3.1.0 → magmascript-3.2.0}/magmascript/__init__.py +1 -1
  4. {magmascript-3.1.0 → magmascript-3.2.0}/magmascript/cli.py +12 -2
  5. {magmascript-3.1.0 → magmascript-3.2.0}/magmascript/core/config.py +19 -16
  6. {magmascript-3.1.0 → magmascript-3.2.0}/magmascript/core/runner.py +8 -2
  7. magmascript-3.2.0/magmascript/lang/domain_bridge.py +198 -0
  8. {magmascript-3.1.0 → magmascript-3.2.0}/magmascript/lang/interpreter.py +18 -0
  9. {magmascript-3.1.0 → magmascript-3.2.0}/magmascript.egg-info/PKG-INFO +30 -1
  10. {magmascript-3.1.0 → magmascript-3.2.0}/pyproject.toml +1 -1
  11. magmascript-3.2.0/tests/test_domain_bridge.py +368 -0
  12. magmascript-3.1.0/magmascript/lang/domain_bridge.py +0 -119
  13. magmascript-3.1.0/tests/test_domain_bridge.py +0 -178
  14. {magmascript-3.1.0 → magmascript-3.2.0}/LICENSE +0 -0
  15. {magmascript-3.1.0 → magmascript-3.2.0}/magmascript/core/__init__.py +0 -0
  16. {magmascript-3.1.0 → magmascript-3.2.0}/magmascript/core/cache.py +0 -0
  17. {magmascript-3.1.0 → magmascript-3.2.0}/magmascript/core/commands.py +0 -0
  18. {magmascript-3.1.0 → magmascript-3.2.0}/magmascript/core/exceptions.py +0 -0
  19. {magmascript-3.1.0 → magmascript-3.2.0}/magmascript/core/github.py +0 -0
  20. {magmascript-3.1.0 → magmascript-3.2.0}/magmascript/core/output.py +0 -0
  21. {magmascript-3.1.0 → magmascript-3.2.0}/magmascript/core/registry.py +0 -0
  22. {magmascript-3.1.0 → magmascript-3.2.0}/magmascript/core/rpc.py +0 -0
  23. {magmascript-3.1.0 → magmascript-3.2.0}/magmascript/domains/__init__.py +0 -0
  24. {magmascript-3.1.0 → magmascript-3.2.0}/magmascript/domains/archive/__init__.py +0 -0
  25. {magmascript-3.1.0 → magmascript-3.2.0}/magmascript/domains/archive/client.py +0 -0
  26. {magmascript-3.1.0 → magmascript-3.2.0}/magmascript/domains/gh/__init__.py +0 -0
  27. {magmascript-3.1.0 → magmascript-3.2.0}/magmascript/domains/gh/client.py +0 -0
  28. {magmascript-3.1.0 → magmascript-3.2.0}/magmascript/domains/gh/tools.py +0 -0
  29. {magmascript-3.1.0 → magmascript-3.2.0}/magmascript/domains/lastfm/__init__.py +0 -0
  30. {magmascript-3.1.0 → magmascript-3.2.0}/magmascript/domains/lastfm/client.py +0 -0
  31. {magmascript-3.1.0 → magmascript-3.2.0}/magmascript/domains/mac/__init__.py +0 -0
  32. {magmascript-3.1.0 → magmascript-3.2.0}/magmascript/domains/mac/client.py +0 -0
  33. {magmascript-3.1.0 → magmascript-3.2.0}/magmascript/domains/mac/tools.py +0 -0
  34. {magmascript-3.1.0 → magmascript-3.2.0}/magmascript/domains/mb/__init__.py +0 -0
  35. {magmascript-3.1.0 → magmascript-3.2.0}/magmascript/domains/mb/client.py +0 -0
  36. {magmascript-3.1.0 → magmascript-3.2.0}/magmascript/domains/mc1/__init__.py +0 -0
  37. {magmascript-3.1.0 → magmascript-3.2.0}/magmascript/domains/mc1/client.py +0 -0
  38. {magmascript-3.1.0 → magmascript-3.2.0}/magmascript/domains/mc1/tools.py +0 -0
  39. {magmascript-3.1.0 → magmascript-3.2.0}/magmascript/domains/mcp/__init__.py +0 -0
  40. {magmascript-3.1.0 → magmascript-3.2.0}/magmascript/domains/mcp/client.py +0 -0
  41. {magmascript-3.1.0 → magmascript-3.2.0}/magmascript/domains/mcp/tools.py +0 -0
  42. {magmascript-3.1.0 → magmascript-3.2.0}/magmascript/domains/media/__init__.py +0 -0
  43. {magmascript-3.1.0 → magmascript-3.2.0}/magmascript/domains/media/client.py +0 -0
  44. {magmascript-3.1.0 → magmascript-3.2.0}/magmascript/domains/media/providers/__init__.py +0 -0
  45. {magmascript-3.1.0 → magmascript-3.2.0}/magmascript/domains/media/providers/archive.py +0 -0
  46. {magmascript-3.1.0 → magmascript-3.2.0}/magmascript/domains/media/providers/met_museum.py +0 -0
  47. {magmascript-3.1.0 → magmascript-3.2.0}/magmascript/domains/media/providers/openverse.py +0 -0
  48. {magmascript-3.1.0 → magmascript-3.2.0}/magmascript/domains/media/providers/pexels.py +0 -0
  49. {magmascript-3.1.0 → magmascript-3.2.0}/magmascript/domains/media/providers/pixabay.py +0 -0
  50. {magmascript-3.1.0 → magmascript-3.2.0}/magmascript/domains/media/providers/smithsonian.py +0 -0
  51. {magmascript-3.1.0 → magmascript-3.2.0}/magmascript/domains/media/tools.py +0 -0
  52. {magmascript-3.1.0 → magmascript-3.2.0}/magmascript/domains/pi/__init__.py +0 -0
  53. {magmascript-3.1.0 → magmascript-3.2.0}/magmascript/domains/pi/client.py +0 -0
  54. {magmascript-3.1.0 → magmascript-3.2.0}/magmascript/domains/pi/tools.py +0 -0
  55. {magmascript-3.1.0 → magmascript-3.2.0}/magmascript/domains/rights/__init__.py +0 -0
  56. {magmascript-3.1.0 → magmascript-3.2.0}/magmascript/domains/rights/client.py +0 -0
  57. {magmascript-3.1.0 → magmascript-3.2.0}/magmascript/domains/rights/tools.py +0 -0
  58. {magmascript-3.1.0 → magmascript-3.2.0}/magmascript/domains/scores/__init__.py +0 -0
  59. {magmascript-3.1.0 → magmascript-3.2.0}/magmascript/domains/scores/client.py +0 -0
  60. {magmascript-3.1.0 → magmascript-3.2.0}/magmascript/domains/scores/tools.py +0 -0
  61. {magmascript-3.1.0 → magmascript-3.2.0}/magmascript/domains/search/__init__.py +0 -0
  62. {magmascript-3.1.0 → magmascript-3.2.0}/magmascript/domains/search/client.py +0 -0
  63. {magmascript-3.1.0 → magmascript-3.2.0}/magmascript/lang/__init__.py +0 -0
  64. {magmascript-3.1.0 → magmascript-3.2.0}/magmascript/lang/ast_nodes.py +0 -0
  65. {magmascript-3.1.0 → magmascript-3.2.0}/magmascript/lang/astheno/__init__.py +0 -0
  66. {magmascript-3.1.0 → magmascript-3.2.0}/magmascript/lang/astheno/arena.py +0 -0
  67. {magmascript-3.1.0 → magmascript-3.2.0}/magmascript/lang/astheno/dump.py +0 -0
  68. {magmascript-3.1.0 → magmascript-3.2.0}/magmascript/lang/astheno/floorplan.py +0 -0
  69. {magmascript-3.1.0 → magmascript-3.2.0}/magmascript/lang/astheno/numeric.py +0 -0
  70. {magmascript-3.1.0 → magmascript-3.2.0}/magmascript/lang/builtins.py +0 -0
  71. {magmascript-3.1.0 → magmascript-3.2.0}/magmascript/lang/environment.py +0 -0
  72. {magmascript-3.1.0 → magmascript-3.2.0}/magmascript/lang/hypnagogia.py +0 -0
  73. {magmascript-3.1.0 → magmascript-3.2.0}/magmascript/lang/lexer.py +0 -0
  74. {magmascript-3.1.0 → magmascript-3.2.0}/magmascript/lang/parser.py +0 -0
  75. {magmascript-3.1.0 → magmascript-3.2.0}/magmascript/lang/pygments_mgs.py +0 -0
  76. {magmascript-3.1.0 → magmascript-3.2.0}/magmascript/lang/tokens.py +0 -0
  77. {magmascript-3.1.0 → magmascript-3.2.0}/magmascript/lang/util.py +0 -0
  78. {magmascript-3.1.0 → magmascript-3.2.0}/magmascript/repl.py +0 -0
  79. {magmascript-3.1.0 → magmascript-3.2.0}/magmascript.egg-info/SOURCES.txt +0 -0
  80. {magmascript-3.1.0 → magmascript-3.2.0}/magmascript.egg-info/dependency_links.txt +0 -0
  81. {magmascript-3.1.0 → magmascript-3.2.0}/magmascript.egg-info/entry_points.txt +0 -0
  82. {magmascript-3.1.0 → magmascript-3.2.0}/magmascript.egg-info/requires.txt +0 -0
  83. {magmascript-3.1.0 → magmascript-3.2.0}/magmascript.egg-info/top_level.txt +0 -0
  84. {magmascript-3.1.0 → magmascript-3.2.0}/setup.cfg +0 -0
  85. {magmascript-3.1.0 → magmascript-3.2.0}/tests/test_astheno.py +0 -0
  86. {magmascript-3.1.0 → magmascript-3.2.0}/tests/test_cache.py +0 -0
  87. {magmascript-3.1.0 → magmascript-3.2.0}/tests/test_classes.py +0 -0
  88. {magmascript-3.1.0 → magmascript-3.2.0}/tests/test_cli.py +0 -0
  89. {magmascript-3.1.0 → magmascript-3.2.0}/tests/test_commands.py +0 -0
  90. {magmascript-3.1.0 → magmascript-3.2.0}/tests/test_import.py +0 -0
  91. {magmascript-3.1.0 → magmascript-3.2.0}/tests/test_lang.py +0 -0
  92. {magmascript-3.1.0 → magmascript-3.2.0}/tests/test_mac.py +0 -0
  93. {magmascript-3.1.0 → magmascript-3.2.0}/tests/test_mc1.py +0 -0
  94. {magmascript-3.1.0 → magmascript-3.2.0}/tests/test_mcp_client.py +0 -0
  95. {magmascript-3.1.0 → magmascript-3.2.0}/tests/test_rpc.py +0 -0
  96. {magmascript-3.1.0 → magmascript-3.2.0}/tests/test_try_catch.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: magmascript
3
- Version: 3.1.0
3
+ Version: 3.2.0
4
4
  Summary: Scripting toolkit with domain-first subcommands
5
5
  Author: Jake McCoy
6
6
  License-Expression: MIT
@@ -584,6 +584,35 @@ magmascript toast search-index # Remove search index
584
584
  magmascript toast all # Clear everything
585
585
  ```
586
586
 
587
+ ### Domains from other packages
588
+
589
+ A package can publish its own domain by declaring an entry point; it then
590
+ appears in `.mgs` scripts and the REPL without any change to magmascript.
591
+
592
+ ```toml
593
+ # in the other package's pyproject.toml
594
+ [project.entry-points."magmascript.domains"]
595
+ texastoast = "texastoast.mgs:TexastoastDomain"
596
+ ```
597
+
598
+ The value names a class taking a single `config` argument. It is constructed on
599
+ first use, not at startup, so a domain may hold real resources. Built-in domains
600
+ win a name clash, and an entry point that fails to import is skipped rather than
601
+ breaking the interpreter.
602
+
603
+ [texastoast](https://github.com/magmacrunchmedia/texastoast) is the first:
604
+ `pip install texastoast` and `.mgs` scripts can drive a game engine. It
605
+ registers under two names, `texastoast` and the shorter `tt`.
606
+
607
+ ```magmascript
608
+ g = tt.game({"title": "hello", "width": 400, "height": 300})
609
+ ```
610
+
611
+ A package may register the same class under several names this way; nothing
612
+ stops it, and the clients stay independent. Note that none of this touches the
613
+ `magmascript toast` CLI verb, which still clears caches — registry domains are
614
+ not dispatched as subcommands.
615
+
587
616
  ## Python Library
588
617
 
589
618
  ```python
@@ -549,6 +549,35 @@ magmascript toast search-index # Remove search index
549
549
  magmascript toast all # Clear everything
550
550
  ```
551
551
 
552
+ ### Domains from other packages
553
+
554
+ A package can publish its own domain by declaring an entry point; it then
555
+ appears in `.mgs` scripts and the REPL without any change to magmascript.
556
+
557
+ ```toml
558
+ # in the other package's pyproject.toml
559
+ [project.entry-points."magmascript.domains"]
560
+ texastoast = "texastoast.mgs:TexastoastDomain"
561
+ ```
562
+
563
+ The value names a class taking a single `config` argument. It is constructed on
564
+ first use, not at startup, so a domain may hold real resources. Built-in domains
565
+ win a name clash, and an entry point that fails to import is skipped rather than
566
+ breaking the interpreter.
567
+
568
+ [texastoast](https://github.com/magmacrunchmedia/texastoast) is the first:
569
+ `pip install texastoast` and `.mgs` scripts can drive a game engine. It
570
+ registers under two names, `texastoast` and the shorter `tt`.
571
+
572
+ ```magmascript
573
+ g = tt.game({"title": "hello", "width": 400, "height": 300})
574
+ ```
575
+
576
+ A package may register the same class under several names this way; nothing
577
+ stops it, and the clients stay independent. Note that none of this touches the
578
+ `magmascript toast` CLI verb, which still clears caches — registry domains are
579
+ not dispatched as subcommands.
580
+
552
581
  ## Python Library
553
582
 
554
583
  ```python
@@ -1,6 +1,6 @@
1
1
  """magmascript — a scripting toolkit with domain-first subcommands."""
2
2
 
3
- __version__ = "3.1.0"
3
+ __version__ = "3.2.0"
4
4
 
5
5
  from magmascript.core.config import Config, GHConfig, MediaConfig, MC1Config, PIConfig, get_config, load_config, set_config
6
6
  from magmascript.core.registry import get_domain, list_domains, register_domain
@@ -409,17 +409,27 @@ def _dispatch_configure(action: str, args: list[str]):
409
409
  Fetch the API key from the MCP server and write to config file.
410
410
 
411
411
  Options:
412
- --host HOST SSH host to fetch key from (default: jake@192.168.1.16)
412
+ --host HOST SSH host to fetch the key from, as user@host (required)
413
413
  --help Show this help
414
414
  """)
415
415
  sys.exit(0)
416
416
 
417
- host = "jake@192.168.1.16"
417
+ host = ""
418
418
  if "--host" in args:
419
419
  idx = args.index("--host")
420
420
  if idx + 1 < len(args):
421
421
  host = args[idx + 1]
422
422
 
423
+ if not host:
424
+ print(
425
+ "Error: configure needs an explicit SSH target.\n"
426
+ " magmascript configure --host user@host\n"
427
+ "Or set the MCP API key directly: MAGMA_API_KEY in the environment, "
428
+ "or [mcp] api_key in ~/.config/magmascript/config.toml",
429
+ file=sys.stderr,
430
+ )
431
+ sys.exit(1)
432
+
423
433
  print(f"Fetching API key from {host}...")
424
434
 
425
435
  try:
@@ -10,6 +10,9 @@ from dataclasses import dataclass, field
10
10
  from pathlib import Path
11
11
 
12
12
 
13
+ # The MCP server is a public endpoint, so it stays a usable default. SSH
14
+ # host/user defaults are intentionally empty — a fresh install must be told
15
+ # which machines to reach rather than probing someone else's network.
13
16
  DEFAULT_MCP_URL = "https://magmacrunch.duckdns.org/mcp"
14
17
  CONFIG_DIR = Path.home() / ".config" / "magmascript"
15
18
  CONFIG_FILE = CONFIG_DIR / "config.toml"
@@ -27,24 +30,24 @@ class MCPConfig:
27
30
  class PIConfig:
28
31
  """Raspberry Pi SSH configuration."""
29
32
 
30
- host: str = "192.168.1.16"
31
- user: str = "jake"
33
+ host: str = ""
34
+ user: str = ""
32
35
 
33
36
 
34
37
  @dataclass
35
38
  class MC1Config:
36
39
  """MC1 Windows PC SSH configuration."""
37
40
 
38
- host: str = "100.75.220.87"
39
- user: str = "magma"
41
+ host: str = ""
42
+ user: str = ""
40
43
 
41
44
 
42
45
  @dataclass
43
46
  class MacConfig:
44
47
  """Mac SSH configuration (a MacBook on the Tailnet)."""
45
48
 
46
- host: str = "100.81.70.91"
47
- user: str = "jakemccoy"
49
+ host: str = ""
50
+ user: str = ""
48
51
 
49
52
 
50
53
  @dataclass
@@ -52,8 +55,8 @@ class GHConfig:
52
55
  """GitHub API configuration."""
53
56
 
54
57
  token: str = ""
55
- owner: str = "magmacrunchmedia"
56
- repo: str = "magmacrunch.com"
58
+ owner: str = ""
59
+ repo: str = ""
57
60
 
58
61
 
59
62
  @dataclass
@@ -146,23 +149,23 @@ def load_config(*, config_path: Path | None = None, env_prefix: str = "MAGMA_")
146
149
  or mcp_section.get("api_key", ""),
147
150
  ),
148
151
  pi=PIConfig(
149
- host=os.environ.get(f"{env_prefix}PI_HOST", pi_section.get("host", "192.168.1.16")),
150
- user=os.environ.get(f"{env_prefix}PI_USER", pi_section.get("user", "jake")),
152
+ host=os.environ.get(f"{env_prefix}PI_HOST", pi_section.get("host", "")),
153
+ user=os.environ.get(f"{env_prefix}PI_USER", pi_section.get("user", "")),
151
154
  ),
152
155
  mc1=MC1Config(
153
- host=os.environ.get(f"{env_prefix}MC1_HOST", mc1_section.get("host", "100.75.220.87")),
154
- user=os.environ.get(f"{env_prefix}MC1_USER", mc1_section.get("user", "magma")),
156
+ host=os.environ.get(f"{env_prefix}MC1_HOST", mc1_section.get("host", "")),
157
+ user=os.environ.get(f"{env_prefix}MC1_USER", mc1_section.get("user", "")),
155
158
  ),
156
159
  mac=MacConfig(
157
- host=os.environ.get(f"{env_prefix}MAC_HOST", mac_section.get("host", "100.81.70.91")),
158
- user=os.environ.get(f"{env_prefix}MAC_USER", mac_section.get("user", "jakemccoy")),
160
+ host=os.environ.get(f"{env_prefix}MAC_HOST", mac_section.get("host", "")),
161
+ user=os.environ.get(f"{env_prefix}MAC_USER", mac_section.get("user", "")),
159
162
  ),
160
163
  gh=GHConfig(
161
164
  token=os.environ.get(f"{env_prefix}GH_TOKEN")
162
165
  or os.environ.get("GITHUB_TOKEN", "")
163
166
  or gh_section.get("token", ""),
164
- owner=os.environ.get(f"{env_prefix}GH_OWNER", gh_section.get("owner", "magmacrunchmedia")),
165
- repo=os.environ.get(f"{env_prefix}GH_REPO", gh_section.get("repo", "magmacrunch.com")),
167
+ owner=os.environ.get(f"{env_prefix}GH_OWNER", gh_section.get("owner", "")),
168
+ repo=os.environ.get(f"{env_prefix}GH_REPO", gh_section.get("repo", "")),
166
169
  ),
167
170
  media=MediaConfig(
168
171
  pexels_key=os.environ.get(f"{env_prefix}PEXELS_KEY", media_section.get("pexels_key", "")),
@@ -16,8 +16,8 @@ class CommandRunner:
16
16
  """Execute shell commands locally or via SSH.
17
17
 
18
18
  Args:
19
- host: Remote host (e.g. "192.168.1.16"). Ignored in local mode.
20
- user: Remote user (e.g. "jake"). Ignored in local mode.
19
+ host: Remote host (e.g. "pi.local" or a Tailscale IP). Ignored in local mode.
20
+ user: Remote user. Ignored in local mode.
21
21
  local: If True, run commands locally via subprocess instead of SSH.
22
22
  """
23
23
 
@@ -33,6 +33,12 @@ class CommandRunner:
33
33
  """
34
34
  if self._local:
35
35
  return self._local_run(cmd, timeout=timeout)
36
+ if not self._host:
37
+ raise SSHError(
38
+ "no SSH host configured — set the domain's host in "
39
+ "~/.config/magmascript/config.toml or the MAGMA_*_HOST env var",
40
+ host="",
41
+ )
36
42
  return self._ssh(cmd, timeout=timeout)
37
43
 
38
44
  def _local_run(self, cmd: str, *, timeout: int = 15) -> str:
@@ -0,0 +1,198 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import fields, is_dataclass
4
+ from typing import Any
5
+
6
+ from magmascript.core.registry import get_domain, list_domains, register_domain
7
+
8
+ # Packages outside this repo publish domains under this entry-point group.
9
+ ENTRY_POINT_GROUP = "magmascript.domains"
10
+
11
+
12
+ class DomainProxy:
13
+ """A domain, exposed to scripts under its name.
14
+
15
+ The client is built on first use, not at interpreter startup. Every domain
16
+ used to be constructed for every script and every REPL session, which cost
17
+ startup time for domains nobody touched and ruled out any domain whose
18
+ construction does something — opening a window, holding a socket.
19
+ """
20
+
21
+ def __init__(self, name: str, client: Any = None, *,
22
+ factory: Any = None) -> None:
23
+ object.__setattr__(self, "_name", name)
24
+ object.__setattr__(self, "_factory", factory)
25
+ object.__setattr__(self, "_client_cache", client)
26
+
27
+ @property
28
+ def _client(self) -> Any:
29
+ client = object.__getattribute__(self, "_client_cache")
30
+ if client is None:
31
+ factory = object.__getattribute__(self, "_factory")
32
+ if factory is None:
33
+ name = object.__getattribute__(self, "_name")
34
+ raise AttributeError(f"domain '{name}' has no client")
35
+ client = factory()
36
+ object.__setattr__(self, "_client_cache", client)
37
+ return client
38
+
39
+ def __getattr__(self, name: str) -> Any:
40
+ if name.startswith("_"):
41
+ raise AttributeError(name)
42
+
43
+ method = getattr(self._client, name, None)
44
+ if method is None:
45
+ raise AttributeError(f"'{self._name}' has no method '{name}'")
46
+
47
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
48
+ result = method(*args, **kwargs)
49
+ return wrap_result(result)
50
+
51
+ return wrapper
52
+
53
+ def close(self) -> None:
54
+ # Only a client that was actually built can need closing, and touching
55
+ # self._client here would construct one just to tear it down.
56
+ client = object.__getattribute__(self, "_client_cache")
57
+ if client is not None and hasattr(client, "close"):
58
+ try:
59
+ client.close()
60
+ except Exception:
61
+ pass
62
+
63
+ def __del__(self) -> None:
64
+ self.close()
65
+
66
+ def __repr__(self) -> str:
67
+ return f"<domain:{self._name}>"
68
+
69
+
70
+ class DataclassWrapper:
71
+ def __init__(self, obj: Any) -> None:
72
+ object.__setattr__(self, "_obj", obj)
73
+
74
+ def __getattr__(self, name: str) -> Any:
75
+ obj = object.__getattribute__(self, "_obj")
76
+ if name.startswith("_"):
77
+ raise AttributeError(name)
78
+ if is_dataclass(obj) and not isinstance(obj, type):
79
+ for f in fields(obj):
80
+ if f.name == name:
81
+ return wrap_result(getattr(obj, name))
82
+ # Not a field, but dataclasses carry properties and methods too, and a
83
+ # script has no way to tell which is which. InputState.dx and
84
+ # ControllerState.up are properties over the fields; without this a
85
+ # script could read the raw buttons byte but not the buttons.
86
+ try:
87
+ return wrap_result(getattr(obj, name))
88
+ except AttributeError:
89
+ raise AttributeError(
90
+ f"'{type(obj).__name__}' has no attribute '{name}'"
91
+ ) from None
92
+
93
+ def __repr__(self) -> str:
94
+ obj = object.__getattribute__(self, "_obj")
95
+ return repr(obj)
96
+
97
+ def __str__(self) -> str:
98
+ obj = object.__getattribute__(self, "_obj")
99
+ return str(obj)
100
+
101
+
102
+ class ListWrapper:
103
+ def __init__(self, items: list[Any]) -> None:
104
+ self._items = [wrap_result(item) for item in items]
105
+
106
+ def __getitem__(self, index: int) -> Any:
107
+ return self._items[index]
108
+
109
+ def __len__(self) -> int:
110
+ return len(self._items)
111
+
112
+ def __iter__(self) -> Any:
113
+ return iter(self._items)
114
+
115
+ def __repr__(self) -> str:
116
+ return repr(self._items)
117
+
118
+ def __str__(self) -> str:
119
+ return str(self._items)
120
+
121
+
122
+ def wrap_result(result: Any) -> Any:
123
+ from magmascript.lang.interpreter import MgsInstance, MgsClass
124
+ from magmascript.lang.astheno import Fixed, Pine
125
+ if result is None:
126
+ return None
127
+ if isinstance(result, (int, float, str, bool)):
128
+ return result
129
+ # Asthenosphere values are dataclasses but are language primitives, not
130
+ # host objects to be introspected. They must not be wrapped.
131
+ if isinstance(result, (Fixed, Pine)):
132
+ return result
133
+ if callable(result):
134
+ return result
135
+ if isinstance(result, (MgsInstance, MgsClass)):
136
+ return result
137
+ if isinstance(result, list):
138
+ return ListWrapper(result)
139
+ if is_dataclass(result) and not isinstance(result, type):
140
+ return DataclassWrapper(result)
141
+ return result
142
+
143
+
144
+ def _domain_factory(name: str) -> Any:
145
+ """Build the thunk that constructs one domain's client on first use.
146
+
147
+ A constructor that raises now surfaces at the call that needed it, rather
148
+ than the domain silently not existing and the script reporting an undefined
149
+ variable several lines later.
150
+ """
151
+
152
+ def build() -> Any:
153
+ from magmascript.core.config import get_config
154
+
155
+ return get_domain(name)(get_config())
156
+
157
+ return build
158
+
159
+
160
+ def discover_domains() -> None:
161
+ """Register domains published by other installed packages.
162
+
163
+ A package declares one in its own metadata::
164
+
165
+ [project.entry-points."magmascript.domains"]
166
+ texastoast = "texastoast.mgs:TexastoastDomain"
167
+
168
+ which is what lets a domain live in the project it belongs to rather than
169
+ in this repo. Built-ins win on a name clash, and a broken entry point is
170
+ skipped rather than taking the interpreter down with it.
171
+ """
172
+ from importlib.metadata import entry_points
173
+
174
+ try:
175
+ found = entry_points(group=ENTRY_POINT_GROUP)
176
+ except Exception:
177
+ # Broken installed metadata is not this interpreter's problem to solve,
178
+ # and must not stop a script that uses only built-in domains.
179
+ return
180
+
181
+ builtins = set(list_domains())
182
+ for entry in found:
183
+ if entry.name in builtins:
184
+ continue
185
+ try:
186
+ register_domain(entry.name, entry.load())
187
+ except Exception:
188
+ # A third-party package that fails to import must not stop a script
189
+ # that never mentions it from running.
190
+ continue
191
+
192
+
193
+ def create_domain_proxies() -> dict[str, DomainProxy]:
194
+ discover_domains()
195
+ return {
196
+ name: DomainProxy(name, factory=_domain_factory(name))
197
+ for name in list_domains()
198
+ }
@@ -1110,6 +1110,24 @@ class Interpreter:
1110
1110
  obj[node.property] = value
1111
1111
  return value
1112
1112
 
1113
+ # A host object from a domain — a game entity, a config, anything the
1114
+ # bridge handed back. Reading its attributes already works
1115
+ # (exec_PropertyAccess falls through to getattr), so writing them has
1116
+ # to as well, or a script can inspect an object and never change it.
1117
+ if not node.property.startswith("_") and hasattr(obj, node.property):
1118
+ if isinstance(value, astheno.Fixed):
1119
+ # Widths are an Asthenosphere concept. Handing one to a host
1120
+ # object poisons its arithmetic downstream, so store the number.
1121
+ value = value.value
1122
+ try:
1123
+ setattr(obj, node.property, value)
1124
+ except AttributeError as e:
1125
+ raise self.error(
1126
+ f"Cannot set '{node.property}' on {type(obj).__name__}: {e}",
1127
+ node,
1128
+ ) from None
1129
+ return value
1130
+
1113
1131
  raise self.error(
1114
1132
  f"Cannot set property on {type(obj).__name__}",
1115
1133
  node,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: magmascript
3
- Version: 3.1.0
3
+ Version: 3.2.0
4
4
  Summary: Scripting toolkit with domain-first subcommands
5
5
  Author: Jake McCoy
6
6
  License-Expression: MIT
@@ -584,6 +584,35 @@ magmascript toast search-index # Remove search index
584
584
  magmascript toast all # Clear everything
585
585
  ```
586
586
 
587
+ ### Domains from other packages
588
+
589
+ A package can publish its own domain by declaring an entry point; it then
590
+ appears in `.mgs` scripts and the REPL without any change to magmascript.
591
+
592
+ ```toml
593
+ # in the other package's pyproject.toml
594
+ [project.entry-points."magmascript.domains"]
595
+ texastoast = "texastoast.mgs:TexastoastDomain"
596
+ ```
597
+
598
+ The value names a class taking a single `config` argument. It is constructed on
599
+ first use, not at startup, so a domain may hold real resources. Built-in domains
600
+ win a name clash, and an entry point that fails to import is skipped rather than
601
+ breaking the interpreter.
602
+
603
+ [texastoast](https://github.com/magmacrunchmedia/texastoast) is the first:
604
+ `pip install texastoast` and `.mgs` scripts can drive a game engine. It
605
+ registers under two names, `texastoast` and the shorter `tt`.
606
+
607
+ ```magmascript
608
+ g = tt.game({"title": "hello", "width": 400, "height": 300})
609
+ ```
610
+
611
+ A package may register the same class under several names this way; nothing
612
+ stops it, and the clients stay independent. Note that none of this touches the
613
+ `magmascript toast` CLI verb, which still clears caches — registry domains are
614
+ not dispatched as subcommands.
615
+
587
616
  ## Python Library
588
617
 
589
618
  ```python
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "magmascript"
7
- version = "3.1.0"
7
+ version = "3.2.0"
8
8
  description = "Scripting toolkit with domain-first subcommands"
9
9
  readme = "README.md"
10
10
  license = "MIT"