ggblab 0.9.3__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 (26) hide show
  1. ggblab/__init__.py +44 -0
  2. ggblab/_version.py +4 -0
  3. ggblab/comm.py +243 -0
  4. ggblab/construction.py +179 -0
  5. ggblab/errors.py +142 -0
  6. ggblab/ggbapplet.py +293 -0
  7. ggblab/parser.py +486 -0
  8. ggblab/persistent_counter.py +175 -0
  9. ggblab/schema.py +114 -0
  10. ggblab/utils.py +109 -0
  11. ggblab-0.9.3.data/data/share/jupyter/labextensions/ggblab/build_log.json +730 -0
  12. ggblab-0.9.3.data/data/share/jupyter/labextensions/ggblab/install.json +5 -0
  13. ggblab-0.9.3.data/data/share/jupyter/labextensions/ggblab/package.json +210 -0
  14. ggblab-0.9.3.data/data/share/jupyter/labextensions/ggblab/schemas/ggblab/package.json.orig +205 -0
  15. ggblab-0.9.3.data/data/share/jupyter/labextensions/ggblab/schemas/ggblab/plugin.json +8 -0
  16. ggblab-0.9.3.data/data/share/jupyter/labextensions/ggblab/static/lib_index_js.bbfa36bc62ee08eb62b2.js +465 -0
  17. ggblab-0.9.3.data/data/share/jupyter/labextensions/ggblab/static/lib_index_js.bbfa36bc62ee08eb62b2.js.map +1 -0
  18. ggblab-0.9.3.data/data/share/jupyter/labextensions/ggblab/static/remoteEntry.2d29364aef8b527d773e.js +568 -0
  19. ggblab-0.9.3.data/data/share/jupyter/labextensions/ggblab/static/remoteEntry.2d29364aef8b527d773e.js.map +1 -0
  20. ggblab-0.9.3.data/data/share/jupyter/labextensions/ggblab/static/style.js +4 -0
  21. ggblab-0.9.3.data/data/share/jupyter/labextensions/ggblab/static/style_index_js.aab9f5416f41ce79cac3.js +492 -0
  22. ggblab-0.9.3.data/data/share/jupyter/labextensions/ggblab/static/style_index_js.aab9f5416f41ce79cac3.js.map +1 -0
  23. ggblab-0.9.3.dist-info/METADATA +768 -0
  24. ggblab-0.9.3.dist-info/RECORD +26 -0
  25. ggblab-0.9.3.dist-info/WHEEL +4 -0
  26. ggblab-0.9.3.dist-info/licenses/LICENSE +29 -0
ggblab/__init__.py ADDED
@@ -0,0 +1,44 @@
1
+ """ggblab: Interactive geometric scene construction with Python and GeoGebra.
2
+
3
+ This package provides a JupyterLab extension that opens a GeoGebra applet
4
+ and enables bidirectional communication between Python and GeoGebra through
5
+ a dual-channel architecture (IPython Comm + Unix socket/TCP WebSocket).
6
+
7
+ Main Components:
8
+ - GeoGebra: Primary interface for controlling GeoGebra applets
9
+ - ggb_comm: Communication layer (IPython Comm + out-of-band socket)
10
+ - ggb_construction: GeoGebra file (.ggb) loader and saver
11
+ - ggb_parser: Dependency graph parser for GeoGebra constructions
12
+
13
+ Example:
14
+ >>> from ggblab import GeoGebra
15
+ >>> ggb = await GeoGebra().init()
16
+ >>> await ggb.command("A=(0,0)")
17
+ >>> value = await ggb.function("getValue", ["A"])
18
+ """
19
+
20
+ try:
21
+ from ._version import __version__
22
+ except ImportError:
23
+ # Fallback when using the package in dev mode without installing
24
+ # in editable mode with pip. It is highly recommended to install
25
+ # the package from a stable release or in editable mode: https://pip.pypa.io/en/stable/topics/local-project-installs/#editable-installs
26
+ import warnings
27
+ warnings.warn("Importing 'ggblab' outside a proper installation.")
28
+ __version__ = "dev"
29
+
30
+ from .parser import ggb_parser
31
+ from .construction import ggb_construction
32
+ from .comm import ggb_comm
33
+ from .ggbapplet import GeoGebra, GeoGebraSyntaxError, GeoGebraSemanticsError
34
+
35
+ def _jupyter_labextension_paths():
36
+ """Return the JupyterLab extension paths.
37
+
38
+ Returns:
39
+ list: Extension metadata for JupyterLab.
40
+ """
41
+ return [{
42
+ "src": "labextension",
43
+ "dest": "ggblab"
44
+ }]
ggblab/_version.py ADDED
@@ -0,0 +1,4 @@
1
+ # This file is auto-generated by Hatchling. As such, do not:
2
+ # - modify
3
+ # - track in version control e.g. be sure to add to .gitignore
4
+ __version__ = VERSION = '0.9.3'
ggblab/comm.py ADDED
@@ -0,0 +1,243 @@
1
+ import uuid
2
+ import json
3
+ # import ast
4
+ import queue
5
+
6
+ # import time
7
+ import asyncio
8
+ import threading
9
+
10
+ import tempfile
11
+ from websockets.asyncio.server import unix_serve, serve
12
+ import os
13
+
14
+ from IPython import get_ipython
15
+
16
+ from .errors import GeoGebraAppletError
17
+
18
+
19
+ class ggb_comm:
20
+ """Dual-channel communication layer for kernel↔widget messaging.
21
+
22
+ Implements a combination of IPython Comm (primary) and out-of-band socket
23
+ (Unix domain socket on POSIX, TCP WebSocket on Windows) to enable message
24
+ delivery during cell execution when IPython Comm is blocked.
25
+
26
+ IPython Comm cannot receive messages while a notebook cell is executing,
27
+ which breaks interactive workflows. The out-of-band socket solves this by
28
+ providing a secondary channel for GeoGebra responses.
29
+
30
+ Architecture:
31
+ - IPython Comm: Command dispatch, event notifications, heartbeat
32
+ - Out-of-band socket: Response delivery during cell execution
33
+
34
+ Comm target is fixed at 'ggblab-comm' because multiplexing via multiple
35
+ targets would not solve the IPython Comm receive limitation.
36
+
37
+ Attributes:
38
+ target_comm: IPython Comm object
39
+ target_name (str): Comm target name ('ggblab-comm')
40
+ server_handle: WebSocket server handle
41
+ server_thread: Background thread running the socket server
42
+ clients (set): Currently connected WebSocket clients
43
+ socketPath (str): Unix domain socket path (POSIX)
44
+ wsPort (int): TCP port number (Windows)
45
+ recv_logs (dict): Response storage keyed by message ID
46
+ recv_events (queue.Queue): Event queue for frontend notifications
47
+
48
+ See:
49
+ docs/architecture.md for detailed communication architecture.
50
+ """
51
+ # [Frontent to kernel callback - JupyterLab - Jupyter Community Forum]
52
+ # (https://discourse.jupyter.org/t/frontent-to-kernel-callback/1666)
53
+ recv_msgs = {}
54
+ recv_logs = {}
55
+ recv_events = queue.Queue()
56
+ logs = []
57
+ thread = None
58
+ mid = None
59
+
60
+ def __init__(self):
61
+ self.target_comm = None
62
+ self.target_name = 'ggblab-comm'
63
+ self.server_handle = None
64
+ self.server_thread = None
65
+ self.clients = set()
66
+ self.socketPath = None
67
+ self.wsPort = 0
68
+
69
+ # oob websocket (unix_domain socket in posix)
70
+ def start(self):
71
+ """Start the out-of-band socket server in a background thread.
72
+
73
+ Creates a Unix domain socket (POSIX) or TCP WebSocket server (Windows)
74
+ and runs it in a daemon thread. The server listens for GeoGebra responses.
75
+ """
76
+ self.server_thread = threading.Thread(target=lambda: asyncio.run(self.server()), daemon=True)
77
+ self.server_thread.start()
78
+
79
+ def stop(self):
80
+ """Stop the out-of-band socket server."""
81
+ self.server_handle.close()
82
+
83
+ async def server(self):
84
+ if os.name in [ 'posix' ]:
85
+ _fd, self.socketPath = tempfile.mkstemp(prefix="/tmp/ggb_")
86
+ os.close(_fd)
87
+ os.remove(self.socketPath)
88
+ async with unix_serve(self.client_handle, path=self.socketPath) as self.server_handle:
89
+ await asyncio.Future()
90
+ else:
91
+ async with serve(self.client_handle, "localhost", 0) as self.server_handle:
92
+ self.wsPort = self.server_handle.sockets[0].getsockname()[1]
93
+ self.logs.append(f"WebSocket server started at ws://localhost:{self.wsPort}")
94
+ await asyncio.Future()
95
+
96
+ async def client_handle(self, client_id):
97
+ self.clients.add(client_id)
98
+ self.logs.append(f"Client {client_id} registered.")
99
+
100
+ try:
101
+ async for msg in client_id:
102
+ # _data = ast.literal_eval(msg)
103
+ _data = json.loads(msg)
104
+ _id = _data.get('id')
105
+ # self.logs.append(f"Received message from client: {_id}")
106
+
107
+ # Route event-type messages to recv_events queue
108
+ # Messages with 'id' are command responses; messages without 'id' are events.
109
+ # This enables:
110
+ # - Real-time error capture during cell execution
111
+ # - Dynamic scope learning from Applet error events
112
+ # - Cross-domain error pattern analysis
113
+
114
+ if _id:
115
+ # Response message: store in recv_logs for send_recv() to retrieve
116
+ self.recv_logs[_id] = _data['payload']
117
+ else:
118
+ # Event message: queue for event processing
119
+ # Error handling is deferred to send_recv() for proper exception propagation
120
+ self.recv_events.put(_data)
121
+ except Exception as e:
122
+ pass
123
+ # self.logs.append(f"Connection closed: {e}")
124
+ finally:
125
+ self.clients.remove(client_id)
126
+ # self.logs.append(f"Client disconnected: {client_id}")
127
+
128
+ # comm
129
+ def register_target(self):
130
+ get_ipython().kernel.comm_manager.register_target(
131
+ self.target_name,
132
+ self.register_target_cb)
133
+
134
+ def register_target_cb(self, comm, msg):
135
+ self.target_comm = comm
136
+
137
+ @comm.on_msg
138
+ def _recv(msg):
139
+ self.handle_recv(msg)
140
+
141
+ @comm.on_close
142
+ def _close():
143
+ self.target_comm = None
144
+
145
+ def unregister_target_cb(self, comm, msg):
146
+ self.target_comm.close()
147
+ self.target_comm = None
148
+
149
+ def handle_recv(self, msg):
150
+ # Note: All event-type messages are now routed to recv_events via the
151
+ # out-of-band socket (client_handle). This method is reserved for command
152
+ # responses (messages with id) sent via IPython Comm.
153
+ #
154
+ # IPython Comm cannot receive messages during cell execution, so real-time
155
+ # error event processing happens on the out-of-band socket instead.
156
+ if isinstance(msg['content']['data'], str):
157
+ _data = json.loads(msg['content']['data'])
158
+ else:
159
+ _data = msg['content']['data']
160
+
161
+ # All messages here are assumed to be responses with 'id'
162
+ # (event messages are handled via client_handle in the out-of-band socket)
163
+
164
+ def send(self, msg):
165
+ return self.target_comm.send(msg)
166
+
167
+ async def send_recv(self, msg):
168
+ """Send a message via IPython Comm and wait for response via out-of-band socket.
169
+
170
+ This method:
171
+ 1. Generates a unique message ID (UUID)
172
+ 2. Sends the message via IPython Comm to the frontend
173
+ 3. Waits for the response to arrive via the out-of-band socket
174
+ 4. Raises GeoGebraAppletError if error events are received
175
+ 5. Returns the response payload
176
+
177
+ The 3-second timeout is sufficient for interactive operations.
178
+ For long-running operations, decompose into smaller steps.
179
+
180
+ Args:
181
+ msg (dict or str): Message to send (will be JSON-serialized).
182
+
183
+ Returns:
184
+ dict: Response payload from GeoGebra.
185
+
186
+ Raises:
187
+ asyncio.TimeoutError: If no response arrives within 3 seconds.
188
+ GeoGebraAppletError: If the applet produces error events.
189
+
190
+ Example:
191
+ >>> response = await comm.send_recv({
192
+ ... "type": "command",
193
+ ... "payload": "A=(0,0)"
194
+ ... })
195
+ """
196
+ try:
197
+ if isinstance(msg, str):
198
+ _data = json.loads(msg)
199
+ else:
200
+ _data = msg
201
+
202
+ _id = str(uuid.uuid4())
203
+ self.mid = _id
204
+ msg['id'] = _id
205
+ self.send(json.dumps(_data))
206
+
207
+ # Wait for response with 3-second timeout
208
+ async def wait_for_response():
209
+ while not (_id in self.recv_logs):
210
+ await asyncio.sleep(0.01)
211
+
212
+ await asyncio.wait_for(wait_for_response(), timeout=3.0)
213
+
214
+ value = self.recv_logs.pop(_id, None)
215
+
216
+ # If response value is empty, check for error events
217
+ if value is None:
218
+ # Wait a bit for error events to arrive
219
+ await asyncio.sleep(0.5)
220
+
221
+ # Check for error events in recv_events
222
+ error_messages = []
223
+ while True:
224
+ try:
225
+ event = self.recv_events.get_nowait()
226
+ if event.get('type') == 'Error':
227
+ error_messages.append(event.get('payload', 'Unknown error'))
228
+ except queue.Empty:
229
+ break
230
+
231
+ # If errors were collected, raise GeoGebraAppletError
232
+ if error_messages:
233
+ combined_message = '\n'.join(error_messages)
234
+ raise GeoGebraAppletError(
235
+ error_message=combined_message,
236
+ error_type='AppletError'
237
+ )
238
+
239
+ return value
240
+ except (asyncio.TimeoutError, TimeoutError):
241
+ # On timeout, raise the error
242
+ print(f"TimeoutError in send_recv {msg}")
243
+ raise
ggblab/construction.py ADDED
@@ -0,0 +1,179 @@
1
+ import base64
2
+ import zipfile
3
+ import json
4
+ import xml.etree.ElementTree as ET
5
+ import io
6
+ import os
7
+
8
+ from .schema import ggb_schema
9
+
10
+ class ggb_construction:
11
+ """GeoGebra construction file (.ggb) loader and saver.
12
+
13
+ Handles multiple file formats:
14
+ - .ggb files (base64-encoded ZIP archives)
15
+ - Plain ZIP archives
16
+ - JSON format
17
+ - Plain XML (geogebra.xml)
18
+
19
+ The loader automatically detects file type from magic bytes and extracts
20
+ the construction XML. The geogebra_xml is automatically stripped to the
21
+ <construction> element and scientific notation is normalized.
22
+
23
+ Attributes:
24
+ ggb_schema: XML schema for validation
25
+ source_file (str): Path to the loaded file
26
+ base64_buffer (bytes): Base64-encoded .ggb archive (if applicable)
27
+ geogebra_xml (str): Extracted construction XML
28
+
29
+ Example:
30
+ >>> construction = ggb_construction()
31
+ >>> construction.load('myfile.ggb')
32
+ >>> construction.save('output.ggb')
33
+ """
34
+ def __init__(self):
35
+ self.ggb_schema = ggb_schema().schema
36
+
37
+ def load(self, file):
38
+ """Load a GeoGebra construction from file.
39
+
40
+ Supports multiple formats:
41
+ - Base64-encoded .ggb (starts with 'UEsD')
42
+ - ZIP archive (starts with 'PK')
43
+ - JSON format (starts with '{' or '[')
44
+ - Plain XML
45
+
46
+ The construction XML is automatically extracted and normalized:
47
+ - Stripped to <construction> element only
48
+ - Scientific notation fixed (e-1 → E-1)
49
+
50
+ Args:
51
+ file (str): Path to the .ggb, .zip, .json, or .xml file.
52
+
53
+ Returns:
54
+ ggb_construction: Self reference for method chaining.
55
+
56
+ Raises:
57
+ FileNotFoundError: If the file does not exist.
58
+ RuntimeError: If file loading fails.
59
+
60
+ Example:
61
+ >>> c = ggb_construction().load('circle.ggb')
62
+ >>> print(c.geogebra_xml[:100])
63
+ """
64
+ self.source_file = file
65
+
66
+ self.base64_buffer = None
67
+ self.geogebra_xml = None
68
+
69
+ try:
70
+ with open(self.source_file, 'rb') as f:
71
+ def unzip(buff):
72
+ with zipfile.ZipFile(io.BytesIO(base64.b64decode(buff)), 'r') as zf:
73
+ # for fileinfo in zf.infolist():
74
+ # print(fileinfo)
75
+ with zf.open('geogebra.xml', 'r') as zff:
76
+ try:
77
+ s = zff.read()
78
+ except:
79
+ pass
80
+ return s
81
+
82
+ match tuple(f.read(4).decode()):
83
+ case ('U', 'E', 's', 'D'):
84
+ # base64 encoded zip
85
+ f.close()
86
+ with open(self.source_file, 'rb') as f2:
87
+ self.base64_buffer = f2.read() # base64.b64decode(f2.read())
88
+ self.geogebra_xml = unzip(self.base64_buffer)
89
+ case ('P', 'K', _, _):
90
+ # zip
91
+ f.close()
92
+ with open(self.source_file, 'rb') as f2:
93
+ # b64encode for sending GeoGebra Applet
94
+ self.base64_buffer = base64.b64encode(f2.read())
95
+ self.geogebra_xml = unzip(self.base64_buffer)
96
+ case ('{', _, _, _) | ('[', _, _, _):
97
+ # json
98
+ f.close()
99
+ with open(self.source_file, 'r', encoding='utf-8') as f2:
100
+ self.base64_buffer = json.load(f2)
101
+ for f in self.base64_buffer['archive']:
102
+ if f['fileName'] == 'geogebra.xml':
103
+ self.geogebra_xml = f['fileContent']
104
+ case _:
105
+ # xml?
106
+ with open(self.source_file, 'r', encoding='utf-8') as f2:
107
+ self.geogebra_xml = f2.read()
108
+ # return self.initialize_dataframe(file)
109
+ except FileNotFoundError:
110
+ raise FileNotFoundError(f"File not found: {self.source_file}")
111
+ except Exception as e:
112
+ raise RuntimeError(f"Failed to load the file: {e}")
113
+
114
+ # strip to construction element and fix scientific notation
115
+ self.geogebra_xml = (ET.tostring(ET.fromstring(self.geogebra_xml)
116
+ .find('./construction'), encoding='unicode')
117
+ .replace('e-1', 'E-1'))
118
+
119
+ return self
120
+
121
+ def save(self, overwrite=False, file=None):
122
+ """Save the construction to a file.
123
+
124
+ Saving behavior:
125
+ - If base64_buffer is set: writes decoded archive (.ggb format)
126
+ - If base64_buffer is None: writes plain XML (geogebra_xml)
127
+ - Target extension does not enforce format (e.g., saving to .ggb with
128
+ no base64_buffer will write plain XML bytes)
129
+
130
+ Args:
131
+ overwrite (bool): If True, overwrite source_file. Defaults to False.
132
+ file (str, optional): Target file path. If None, auto-generates
133
+ next available filename (name_1.ggb, name_2.ggb, ...).
134
+
135
+ Returns:
136
+ ggb_construction: Self reference for method chaining.
137
+
138
+ Example:
139
+ >>> c = ggb_construction().load('circle.ggb')
140
+ >>> c.save() # Saves to circle_1.ggb
141
+ >>> c.save(overwrite=True) # Overwrites circle.ggb
142
+ >>> c.save(file='output.ggb') # Saves to output.ggb
143
+
144
+ Note:
145
+ getBase64() from the applet may not include non-XML artifacts
146
+ (thumbnails, etc.) from the original archive. Saving after API
147
+ changes produces a leaner .ggb file.
148
+ """
149
+
150
+ def get_next_revised_filename(filename):
151
+ """
152
+ Generates the next available non-existing filename by appending
153
+ '_1', '_2', etc. before the file extension.
154
+ """
155
+ if not os.path.exists(filename):
156
+ return filename
157
+
158
+ root, ext = os.path.splitext(filename)
159
+ i = 1
160
+ new_filename = f"{root}_{i}{ext}"
161
+
162
+ while os.path.exists(new_filename):
163
+ i += 1
164
+ new_filename = f"{root}_{i}{ext}"
165
+
166
+ return new_filename
167
+
168
+ if file is None:
169
+ if overwrite:
170
+ file = self.source_file
171
+ else:
172
+ file = get_next_revised_filename(self.source_file)
173
+
174
+ with open(file, 'wb') as f:
175
+ if self.base64_buffer is not None:
176
+ f.write(base64.b64decode(self.base64_buffer))
177
+ else:
178
+ f.write(self.geogebra_xml.encode('utf-8'))
179
+ return self
ggblab/errors.py ADDED
@@ -0,0 +1,142 @@
1
+ """Custom exception hierarchy for ggblab.
2
+
3
+ Exceptions are organized into two main branches:
4
+ - Command validation errors: Caught before execution (syntax, semantics)
5
+ - Applet errors: From GeoGebra responses during execution
6
+
7
+ Exception Hierarchy:
8
+
9
+ GeoGebraError
10
+ ├── GeoGebraCommandError
11
+ │ ├── GeoGebraSyntaxError
12
+ │ │ Raised: Command string cannot be tokenized or has syntax errors
13
+ │ │
14
+ │ └── GeoGebraSemanticsError
15
+ │ Raised: Referenced objects don't exist in applet
16
+
17
+ └── GeoGebraAppletError
18
+ Raised: GeoGebra applet produces an error event (runtime errors)
19
+
20
+ Usage Examples:
21
+
22
+ # Catch all GeoGebra errors
23
+ try:
24
+ await ggb.command("Circle(A, B)")
25
+ except GeoGebraError as e:
26
+ print(f"GeoGebra error: {e}")
27
+
28
+ # Catch only command validation errors
29
+ except GeoGebraCommandError as e:
30
+ print(f"Command validation failed: {e}")
31
+
32
+ # Catch specific validation errors
33
+ except GeoGebraSyntaxError as e:
34
+ print(f"Syntax error: {e.command}")
35
+ except GeoGebraSemanticsError as e:
36
+ print(f"Missing objects: {e.missing_objects}")
37
+
38
+ # Catch applet runtime errors
39
+ except GeoGebraAppletError as e:
40
+ print(f"Applet error: {e.error_message}")
41
+ """
42
+
43
+
44
+ class GeoGebraError(Exception):
45
+ """Base exception for all GeoGebra-related errors.
46
+
47
+ This is the root exception for all ggblab exceptions, allowing users to catch
48
+ any GeoGebra-related error with a single except clause.
49
+ """
50
+ pass
51
+
52
+
53
+ class GeoGebraCommandError(GeoGebraError):
54
+ """Base exception for command validation errors.
55
+
56
+ Raised when a command fails pre-flight validation (syntax or semantics).
57
+ This intermediate class groups command-related errors together, allowing users
58
+ to catch validation failures separately from applet errors.
59
+ """
60
+ pass
61
+
62
+
63
+ class GeoGebraSyntaxError(GeoGebraCommandError):
64
+ """Exception raised for syntax errors in GeoGebra commands.
65
+
66
+ Raised when a command string cannot be properly tokenized or
67
+ contains invalid syntax that prevents parsing.
68
+
69
+ Attributes:
70
+ command (str): The command that caused the error
71
+ message (str): Explanation of the error
72
+ """
73
+ def __init__(self, command, message):
74
+ self.command = command
75
+ self.message = message
76
+ super().__init__(f"Syntax error in command '{command}': {message}")
77
+
78
+
79
+ class GeoGebraSemanticsError(GeoGebraCommandError):
80
+ """Exception raised for semantic errors in GeoGebra commands.
81
+
82
+ Raised when a command references objects that don't exist in the applet,
83
+ or violates other semantic constraints.
84
+
85
+ Current capabilities:
86
+ - Object existence checking: Verifies referenced objects are present
87
+ in the applet via getAllObjectNames()
88
+
89
+ Future capabilities (when metadata becomes available):
90
+ - Type checking: Validate argument types match command signatures
91
+ - Scope/visibility checking: Ensure objects are in appropriate scope
92
+ - Overload resolution: Handle commands with multiple signatures
93
+
94
+ Limitations:
95
+ Complete command validation is not performed because GeoGebra does not
96
+ maintain a public, versioned, machine-readable command schema. The official
97
+ GitHub repository is outdated and does not reflect the live API.
98
+
99
+ Strategy: Validation is passive—we check what we can (object existence),
100
+ then rely on GeoGebra to accept or reject the command. This is more robust
101
+ than maintaining a potentially incorrect static schema.
102
+
103
+ Attributes:
104
+ command (str): The command that caused the error
105
+ message (str): Explanation of the error
106
+ missing_objects (list, optional): List of referenced but non-existent objects
107
+ """
108
+ def __init__(self, command, message, missing_objects=None):
109
+ self.command = command
110
+ self.message = message
111
+ self.missing_objects = missing_objects or []
112
+ super().__init__(f"Semantics error in command '{command}': {message}")
113
+
114
+
115
+ class GeoGebraAppletError(GeoGebraError):
116
+ """Exception raised for errors from the GeoGebra applet.
117
+
118
+ Raised when the GeoGebra applet produces an error event in response to
119
+ a command or API call. These errors originate from GeoGebra itself rather
120
+ than pre-flight validation.
121
+
122
+ Attributes:
123
+ error_message (str): Error message from GeoGebra applet
124
+ command (str, optional): The command that triggered the applet error
125
+ error_type (str, optional): Error classification (e.g., 'AppletError')
126
+
127
+ Example:
128
+ >>> raise GeoGebraAppletError(
129
+ ... error_message="Unbalanced brackets",
130
+ ... error_type="AppletError"
131
+ ... )
132
+ """
133
+ def __init__(self, error_message, command=None, error_type=None):
134
+ self.error_message = error_message
135
+ self.command = command
136
+ self.error_type = error_type
137
+ msg = f"GeoGebra applet error: {error_message}"
138
+ if command:
139
+ msg += f" (in command '{command}')"
140
+ if error_type:
141
+ msg += f" [{error_type}]"
142
+ super().__init__(msg)