cosmonapse 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. cosmo/__init__.py +0 -0
  2. cosmo/_install.py +225 -0
  3. cosmo/commands/__init__.py +0 -0
  4. cosmo/commands/_prism.py +310 -0
  5. cosmo/commands/_prism_hero.py +249 -0
  6. cosmo/commands/_prism_view.py +550 -0
  7. cosmo/commands/_shared.py +112 -0
  8. cosmo/commands/completion.py +79 -0
  9. cosmo/commands/doppler.py +343 -0
  10. cosmo/commands/init.py +270 -0
  11. cosmo/commands/prism_dist/assets/index.css +1 -0
  12. cosmo/commands/prism_dist/assets/mark.png +0 -0
  13. cosmo/commands/prism_dist/assets/prism.js +40 -0
  14. cosmo/commands/prism_dist/index.html +14 -0
  15. cosmo/commands/prism_dist/mark.png +0 -0
  16. cosmo/commands/synapse.py +690 -0
  17. cosmo/commands/validate.py +278 -0
  18. cosmo/main.py +36 -0
  19. cosmonapse/__init__.py +242 -0
  20. cosmonapse/_hooks.py +233 -0
  21. cosmonapse/_neuron_base.py +82 -0
  22. cosmonapse/_neuron_http.py +184 -0
  23. cosmonapse/_neuron_mcp.py +365 -0
  24. cosmonapse/_url.py +72 -0
  25. cosmonapse/axon.py +297 -0
  26. cosmonapse/dendrite.py +1976 -0
  27. cosmonapse/engram/__init__.py +57 -0
  28. cosmonapse/engram/base.py +231 -0
  29. cosmonapse/engram/client.py +373 -0
  30. cosmonapse/engram/memory.py +377 -0
  31. cosmonapse/engram/postgres.py +423 -0
  32. cosmonapse/engram/sqlite.py +457 -0
  33. cosmonapse/envelope.py +1287 -0
  34. cosmonapse/neuron.py +372 -0
  35. cosmonapse/pathway.py +385 -0
  36. cosmonapse/py.typed +0 -0
  37. cosmonapse/storage/__init__.py +33 -0
  38. cosmonapse/storage/base.py +113 -0
  39. cosmonapse/storage/memory.py +73 -0
  40. cosmonapse/storage/postgres.py +214 -0
  41. cosmonapse/storage/sqlite.py +233 -0
  42. cosmonapse/synapse/__init__.py +32 -0
  43. cosmonapse/synapse/base.py +96 -0
  44. cosmonapse/synapse/dev.py +560 -0
  45. cosmonapse/synapse/kafka.py +288 -0
  46. cosmonapse/synapse/memory.py +207 -0
  47. cosmonapse/synapse/nats.py +161 -0
  48. cosmonapse-0.1.0.dist-info/METADATA +232 -0
  49. cosmonapse-0.1.0.dist-info/RECORD +51 -0
  50. cosmonapse-0.1.0.dist-info/WHEEL +4 -0
  51. cosmonapse-0.1.0.dist-info/entry_points.txt +3 -0
cosmo/__init__.py ADDED
File without changes
cosmo/_install.py ADDED
@@ -0,0 +1,225 @@
1
+ """
2
+ cosmo._install
3
+ ==============
4
+
5
+ Helpers that make `pip install -e <cosmonapse>` feel like a "real" install
6
+ on a fresh machine by also putting the Python Scripts/bin directory on the
7
+ user's persistent PATH so the `cosmo` command is callable from any shell.
8
+
9
+ This lives in the ``cosmo`` CLI package — not in the ``cosmonapse`` SDK —
10
+ because manipulating the user's shell configuration is CLI/installer
11
+ behaviour, not something an imported library should ever do.
12
+
13
+ Two entry points are exposed by pyproject.toml:
14
+
15
+ * ``cosmonapse-init-path`` — a console script created by pip at install time.
16
+ * ``python -m cosmo._install`` — works even before PATH has been updated.
17
+
18
+ The same module is also invoked automatically by the top-level installer
19
+ script (``cosmonapse-core/install.py``) right after it runs
20
+ ``pip install -e``.
21
+
22
+ Public surface:
23
+
24
+ update_path() -> bool # add scripts dir to persistent PATH
25
+ scripts_dir() -> pathlib.Path
26
+ main(argv=None) -> int # CLI entrypoint
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import argparse
31
+ import os
32
+ import sys
33
+ import sysconfig
34
+ from pathlib import Path
35
+ from typing import Iterable
36
+
37
+ __all__ = ["update_path", "scripts_dir", "main"]
38
+
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # Discovery
42
+ # ---------------------------------------------------------------------------
43
+ def scripts_dir() -> Path:
44
+ """Return the directory pip uses for console scripts in the active env.
45
+
46
+ This is the directory that contains ``cosmo`` (or ``cosmo.exe`` on
47
+ Windows) after ``pip install -e .`` has run.
48
+ """
49
+ # sysconfig knows the canonical location for the running interpreter,
50
+ # including the venv it's executing from.
51
+ paths = sysconfig.get_paths()
52
+ # 'scripts' is what gets used for console_scripts; fall back to
53
+ # the legacy distutils name on truly ancient pythons.
54
+ scripts = paths.get("scripts") or paths.get("Scripts")
55
+ if not scripts:
56
+ # Very unlikely fallback.
57
+ scripts = str(Path(sys.executable).parent)
58
+ return Path(scripts).resolve()
59
+
60
+
61
+ # ---------------------------------------------------------------------------
62
+ # Windows
63
+ # ---------------------------------------------------------------------------
64
+ def _update_path_windows(target: Path) -> bool:
65
+ """Append *target* to HKCU\\Environment\\Path and broadcast the change."""
66
+ import winreg # type: ignore[import-not-found]
67
+ import ctypes
68
+ from ctypes import wintypes
69
+
70
+ key = winreg.OpenKey(
71
+ winreg.HKEY_CURRENT_USER, "Environment", 0, winreg.KEY_READ | winreg.KEY_WRITE
72
+ )
73
+ try:
74
+ try:
75
+ current, kind = winreg.QueryValueEx(key, "Path")
76
+ except FileNotFoundError:
77
+ current, kind = "", winreg.REG_EXPAND_SZ
78
+
79
+ target_str = str(target)
80
+ entries = [e for e in current.split(os.pathsep) if e]
81
+ if any(os.path.normcase(e.rstrip("\\")) == os.path.normcase(target_str.rstrip("\\")) for e in entries):
82
+ print(f"[cosmonapse] PATH already contains {target_str}")
83
+ return False
84
+
85
+ new_value = os.pathsep.join(entries + [target_str])
86
+ # REG_EXPAND_SZ preserves things like %USERPROFILE% in the user's PATH.
87
+ winreg.SetValueEx(key, "Path", 0, winreg.REG_EXPAND_SZ, new_value)
88
+ finally:
89
+ winreg.CloseKey(key)
90
+
91
+ # Broadcast WM_SETTINGCHANGE so newly-spawned processes pick up the change.
92
+ HWND_BROADCAST = 0xFFFF
93
+ WM_SETTINGCHANGE = 0x001A
94
+ SMTO_ABORTIFHUNG = 0x0002
95
+ result = wintypes.LPARAM()
96
+ ctypes.windll.user32.SendMessageTimeoutW(
97
+ HWND_BROADCAST,
98
+ WM_SETTINGCHANGE,
99
+ 0,
100
+ "Environment",
101
+ SMTO_ABORTIFHUNG,
102
+ 5000,
103
+ ctypes.byref(result),
104
+ )
105
+
106
+ print(f"[cosmonapse] Added to user PATH: {target_str}")
107
+ print("[cosmonapse] Open a *new* terminal (or sign out / in) for the change to take effect.")
108
+ return True
109
+
110
+
111
+ # ---------------------------------------------------------------------------
112
+ # macOS / Linux
113
+ # ---------------------------------------------------------------------------
114
+ def _shell_rc_candidates() -> Iterable[Path]:
115
+ home = Path.home()
116
+ # Order matters: zsh first on macOS, bash on linux, then fallbacks.
117
+ seen: set[Path] = set()
118
+ for name in (".zshrc", ".bashrc", ".bash_profile", ".profile"):
119
+ p = home / name
120
+ if p not in seen:
121
+ seen.add(p)
122
+ yield p
123
+
124
+
125
+ def _update_path_posix(target: Path) -> bool:
126
+ target_str = str(target)
127
+ marker = "# >>> cosmonapse PATH >>>"
128
+ end_marker = "# <<< cosmonapse PATH <<<"
129
+ block = (
130
+ f"\n{marker}\n"
131
+ f'export PATH="{target_str}:$PATH"\n'
132
+ f"{end_marker}\n"
133
+ )
134
+
135
+ updated_any = False
136
+ for rc in _shell_rc_candidates():
137
+ if not rc.exists():
138
+ # Only create .zshrc / .bashrc if the shell that owns them is in use;
139
+ # otherwise we'd be littering the home directory.
140
+ if rc.name not in {".zshrc", ".bashrc"}:
141
+ continue
142
+ shell = os.environ.get("SHELL", "")
143
+ if rc.name == ".zshrc" and not shell.endswith("zsh"):
144
+ continue
145
+ if rc.name == ".bashrc" and not shell.endswith("bash"):
146
+ continue
147
+ rc.touch()
148
+
149
+ content = rc.read_text(encoding="utf-8", errors="replace") if rc.exists() else ""
150
+ if marker in content:
151
+ # Already managed — refresh the block in case the target changed.
152
+ before, _, rest = content.partition(marker)
153
+ _, _, after = rest.partition(end_marker)
154
+ new_content = before.rstrip() + block + after.lstrip()
155
+ if new_content != content:
156
+ rc.write_text(new_content, encoding="utf-8")
157
+ print(f"[cosmonapse] Refreshed cosmonapse PATH block in {rc}")
158
+ updated_any = True
159
+ else:
160
+ print(f"[cosmonapse] {rc} already up to date")
161
+ continue
162
+
163
+ # Skip if the user already has this dir on PATH some other way.
164
+ if f'PATH="{target_str}:' in content or f"PATH={target_str}:" in content:
165
+ print(f"[cosmonapse] {rc} already references {target_str}")
166
+ continue
167
+
168
+ rc.write_text(content.rstrip() + block, encoding="utf-8")
169
+ print(f"[cosmonapse] Added cosmonapse PATH block to {rc}")
170
+ updated_any = True
171
+
172
+ if updated_any:
173
+ print(
174
+ "[cosmonapse] Open a new terminal (or `source` your shell rc) "
175
+ "for the change to take effect."
176
+ )
177
+ else:
178
+ print("[cosmonapse] No shell rc file needed updating.")
179
+ return updated_any
180
+
181
+
182
+ # ---------------------------------------------------------------------------
183
+ # Public API
184
+ # ---------------------------------------------------------------------------
185
+ def update_path(target: Path | None = None) -> bool:
186
+ """Add the active env's scripts directory to the user's persistent PATH.
187
+
188
+ Returns True if any change was made.
189
+ """
190
+ target = (target or scripts_dir()).resolve()
191
+ if not target.exists():
192
+ print(f"[cosmonapse] Scripts directory does not exist yet: {target}", file=sys.stderr)
193
+ return False
194
+
195
+ if os.name == "nt":
196
+ return _update_path_windows(target)
197
+ return _update_path_posix(target)
198
+
199
+
200
+ def main(argv: list[str] | None = None) -> int:
201
+ parser = argparse.ArgumentParser(
202
+ prog="cosmonapse-init-path",
203
+ description=(
204
+ "Add the active Python environment's scripts directory to your "
205
+ "persistent PATH so the `cosmo` command is callable from any shell."
206
+ ),
207
+ )
208
+ parser.add_argument(
209
+ "--print-only",
210
+ action="store_true",
211
+ help="Show the scripts directory without modifying PATH.",
212
+ )
213
+ args = parser.parse_args(argv)
214
+
215
+ target = scripts_dir()
216
+ print(f"[cosmonapse] Active scripts dir: {target}")
217
+ if args.print_only:
218
+ return 0
219
+
220
+ changed = update_path(target)
221
+ return 0 if changed or target in [Path(p) for p in os.environ.get("PATH", "").split(os.pathsep)] else 1
222
+
223
+
224
+ if __name__ == "__main__": # pragma: no cover
225
+ raise SystemExit(main())
File without changes
@@ -0,0 +1,310 @@
1
+ """
2
+ cosmo.commands._prism
3
+ ~~~~~~~~~~~~~~~~~~~~~~~
4
+ Prism — the browser visualization for the Doppler.
5
+
6
+ Architecture
7
+ ------------
8
+ An aiohttp app on a single port (default 7071) serves the Prism single-page
9
+ app and a WebSocket bridge:
10
+
11
+ GET / -> the Prism SPA (index.html)
12
+ GET /view -> back-compat redirect to /?<query> (old two-page flow)
13
+ GET /assets/* -> the SPA's static JS/CSS bundle
14
+ WS /ws -> per-connection Synapse subscriber; broadcasts every
15
+ Signal on the wildcard subject as one JSON line
16
+
17
+ Every WebSocket connection opens its own Synapse client so the user can switch
18
+ URLs/namespaces from the SPA's form without restarting the server. The client
19
+ is closed on WS disconnect.
20
+
21
+ The frontend is a Vite + React + TypeScript app that lives in
22
+ ``packages/prism-ui`` and is built to static assets bundled into this wheel at
23
+ ``cosmo/commands/prism_dist`` (see that package's README). This module no longer
24
+ templates HTML — it serves the prebuilt SPA and the ``/ws`` bridge. The bridge
25
+ streams one JSON Signal envelope per message; that WS contract is the entire API
26
+ between this server and the SPA.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import asyncio
32
+ import json
33
+ import signal as _signal
34
+ import webbrowser
35
+ from importlib.resources import files as _pkg_files
36
+ from pathlib import Path
37
+
38
+ import click
39
+
40
+ from cosmonapse import Signal, SignalType, discover_signal
41
+
42
+ from cosmo.commands._shared import _HAS_RICH
43
+
44
+ if _HAS_RICH:
45
+ from rich.console import Console
46
+
47
+ console = Console()
48
+
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # Bundled SPA build location
52
+ # ---------------------------------------------------------------------------
53
+
54
+ def _prism_dist_dir() -> Path | None:
55
+ """Locate the bundled Prism SPA build, or None if it was never built.
56
+
57
+ The static assets are produced by ``npm run build:into-wheel`` in
58
+ packages/prism-ui and shipped inside this package as ``prism_dist/``.
59
+ Released wheels always contain it; a source checkout only has it after the
60
+ UI has been built.
61
+ """
62
+ try:
63
+ root = Path(str(_pkg_files("cosmo.commands"))) / "prism_dist"
64
+ except (ModuleNotFoundError, TypeError):
65
+ return None
66
+ return root if (root / "index.html").is_file() else None
67
+
68
+
69
+ _MISSING_BUILD_HTML = """<!DOCTYPE html>
70
+ <html><head><meta charset="utf-8"><title>Prism - not built</title>
71
+ <style>body{background:#07080c;color:#e6e7ec;font-family:ui-monospace,Menlo,monospace;
72
+ display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
73
+ .box{max-width:560px;padding:32px;border:1px solid rgba(255,255,255,.12);border-radius:14px}
74
+ code{color:#22d3ee} h1{font-size:18px;margin:0 0 12px}</style></head>
75
+ <body><div class="box">
76
+ <h1>Prism UI is not bundled in this install</h1>
77
+ <p>The static frontend was not found at <code>cosmo/commands/prism_dist</code>.</p>
78
+ <p>Build it from the repo with:</p>
79
+ <p><code>cd packages/prism-ui &amp;&amp; npm install &amp;&amp; npm run build:into-wheel</code></p>
80
+ <p>then reinstall the SDK (<code>pip install -e .</code>). Released wheels ship
81
+ the prebuilt UI, so this only appears for source checkouts that have not built
82
+ the UI yet.</p>
83
+ </div></body></html>"""
84
+
85
+
86
+ # ---------------------------------------------------------------------------
87
+ # Synapse factory (mirrors the one in doppler.py — kept here to avoid an
88
+ # import cycle and so this module is fully self-contained).
89
+ # ---------------------------------------------------------------------------
90
+
91
+ def _make_synapse(base_url: str):
92
+ scheme = base_url.split("://")[0].lower()
93
+ if scheme == "cosmo":
94
+ from cosmonapse.synapse.dev import DevSynapse
95
+ return DevSynapse(url=base_url)
96
+ elif scheme == "nats":
97
+ from cosmonapse.synapse.nats import NatsSynapse
98
+ return NatsSynapse(url=base_url)
99
+ elif scheme == "kafka":
100
+ from cosmonapse.synapse.kafka import KafkaSynapse
101
+ broker = base_url.replace("kafka://", "")
102
+ return KafkaSynapse(bootstrap_servers=broker)
103
+ else:
104
+ raise click.ClickException(
105
+ f"Unknown synapse scheme {scheme!r}. "
106
+ "Use cosmo://, nats://, or kafka://."
107
+ )
108
+
109
+
110
+ def _error_envelope(code: str, message: str) -> str:
111
+ """Synthesize a JSON Signal envelope describing a Prism-side error."""
112
+ return json.dumps({
113
+ "v": "1",
114
+ "id": f"evt_prism_{code}",
115
+ "trace_id": "trc_prism_internal",
116
+ "type": "ERROR",
117
+ "neuron": None,
118
+ "ts": "1970-01-01T00:00:00Z",
119
+ "payload": {"code": code, "message": message, "recoverable": False},
120
+ "meta": {"source": "prism"},
121
+ })
122
+
123
+
124
+ # ---------------------------------------------------------------------------
125
+ # Public entry point
126
+ # ---------------------------------------------------------------------------
127
+
128
+ async def run_prism(
129
+ initial_base_url: str | None,
130
+ initial_namespace: str | None,
131
+ port: int,
132
+ ) -> None:
133
+ """Start the aiohttp server that hosts Prism (SPA + WS bridge)."""
134
+ try:
135
+ from aiohttp import web
136
+ except ImportError:
137
+ click.echo(
138
+ " aiohttp is required for --prism mode.\n"
139
+ " Install it with: pip install aiohttp\n",
140
+ err=True,
141
+ )
142
+ raise SystemExit(1)
143
+
144
+ dist = _prism_dist_dir()
145
+
146
+ # If the CLI was given a synapse URL, send the browser straight to the
147
+ # visualization by pre-seeding the query string the SPA reads on load.
148
+ initial_qs = ""
149
+ if initial_base_url:
150
+ from urllib.parse import urlencode
151
+ initial_qs = "?" + urlencode({
152
+ "url": initial_base_url,
153
+ "namespace": initial_namespace or "dev",
154
+ })
155
+
156
+ async def handle_index(request):
157
+ if dist is None:
158
+ return web.Response(text=_MISSING_BUILD_HTML, content_type="text/html")
159
+ # Honour a CLI-seeded target on the bare path only (no query yet) so a
160
+ # reload or manual edit of the query string still works.
161
+ if initial_qs and not request.query_string:
162
+ raise web.HTTPFound("/" + initial_qs)
163
+ return web.FileResponse(dist / "index.html")
164
+
165
+ async def handle_view(request):
166
+ # Back-compat with the old two-page flow (/view?url=&namespace=): the
167
+ # SPA now lives at the root and reads the same query params, so just
168
+ # redirect preserving the query string.
169
+ qs = request.query_string
170
+ raise web.HTTPFound("/" + (("?" + qs) if qs else ""))
171
+
172
+ async def handle_ws(request):
173
+ """
174
+ Per-connection synapse subscriber.
175
+
176
+ Reads ?url=...&namespace=... from the query string, opens its own
177
+ Synapse client, broadcasts a DISCOVER, and forwards every Signal
178
+ on the wildcard subject to the WebSocket as JSON. Tears down both
179
+ the subscription and the synapse when the client disconnects.
180
+ """
181
+ ws = web.WebSocketResponse(heartbeat=30)
182
+ await ws.prepare(request)
183
+
184
+ base_url = request.query.get("url")
185
+ namespace = request.query.get("namespace") or "dev"
186
+ if not base_url:
187
+ await ws.send_str(_error_envelope("no_url", "missing url query param"))
188
+ await ws.close()
189
+ return ws
190
+
191
+ try:
192
+ syn = _make_synapse(base_url)
193
+ except click.ClickException as e:
194
+ await ws.send_str(_error_envelope("bad_url", e.format_message()))
195
+ await ws.close()
196
+ return ws
197
+
198
+ try:
199
+ await syn.connect()
200
+ except Exception as exc:
201
+ await ws.send_str(_error_envelope("connect_failed", str(exc)))
202
+ await ws.close()
203
+ return ws
204
+
205
+ async def on_signal(sig: Signal) -> None:
206
+ if ws.closed:
207
+ return
208
+ try:
209
+ await ws.send_str(sig.model_dump_json())
210
+ except Exception:
211
+ pass
212
+
213
+ # Best-effort DISCOVER so existing Dendrites re-publish their
214
+ # REGISTER snapshot and the visualization populates immediately.
215
+ try:
216
+ await syn.publish(
217
+ f"cosmonapse.{namespace}.{SignalType.DISCOVER.value}",
218
+ discover_signal(),
219
+ )
220
+ except Exception:
221
+ pass
222
+
223
+ try:
224
+ await syn.subscribe(
225
+ f"cosmonapse.{namespace}.>",
226
+ on_signal,
227
+ queue_group=None,
228
+ )
229
+ except Exception as exc:
230
+ await ws.send_str(_error_envelope("subscribe_failed", str(exc)))
231
+ try:
232
+ await syn.close()
233
+ except Exception:
234
+ pass
235
+ await ws.close()
236
+ return ws
237
+
238
+ # Keep the connection open until the client disconnects. We don't
239
+ # read meaningful messages; the WS is one-way (server -> browser).
240
+ try:
241
+ async for _ in ws:
242
+ pass
243
+ finally:
244
+ try:
245
+ await syn.close()
246
+ except Exception:
247
+ pass
248
+ return ws
249
+
250
+ app = web.Application()
251
+ app.router.add_get("/", handle_index)
252
+ app.router.add_get("/view", handle_view)
253
+ app.router.add_get("/ws", handle_ws)
254
+ if dist is not None:
255
+ app.router.add_static("/assets", str(dist / "assets"))
256
+
257
+ runner = web.AppRunner(app)
258
+ await runner.setup()
259
+ site = web.TCPSite(runner, "127.0.0.1", port)
260
+ await site.start()
261
+
262
+ ui_url = f"http://127.0.0.1:{port}"
263
+ if _HAS_RICH:
264
+ console.print()
265
+ console.print(" [bold cyan]cosmo doppler[/bold cyan] [dim]--prism[/dim]")
266
+ if initial_base_url:
267
+ console.print(
268
+ f" Synapse: [cyan]{initial_base_url}/{initial_namespace or 'dev'}[/cyan]"
269
+ )
270
+ else:
271
+ console.print(" Synapse: [dim](enter URL in the form)[/dim]")
272
+ console.print(f" Prism: [underline cyan]{ui_url}[/underline cyan]")
273
+ console.print()
274
+ console.print(" [dim]Ctrl-C to stop[/dim]")
275
+ console.print(" " + "-" * 60)
276
+ console.print()
277
+ else:
278
+ print("\n cosmo doppler --prism")
279
+ if initial_base_url:
280
+ print(f" Synapse: {initial_base_url}/{initial_namespace or 'dev'}")
281
+ else:
282
+ print(" Synapse: (enter URL in the form)")
283
+ print(f" Prism: {ui_url}")
284
+ print(" Ctrl-C to stop\n")
285
+
286
+ try:
287
+ webbrowser.open(ui_url)
288
+ except Exception:
289
+ pass
290
+
291
+ stop = asyncio.Event()
292
+ loop = asyncio.get_event_loop()
293
+ for s in (_signal.SIGINT, _signal.SIGTERM):
294
+ try:
295
+ loop.add_signal_handler(s, stop.set)
296
+ except NotImplementedError:
297
+ pass
298
+
299
+ try:
300
+ await stop.wait()
301
+ except KeyboardInterrupt:
302
+ pass
303
+ finally:
304
+ await runner.cleanup()
305
+ if _HAS_RICH:
306
+ console.print()
307
+ console.print(" [dim]Prism stopped.[/dim]")
308
+ console.print()
309
+ else:
310
+ print("\n Prism stopped.\n")