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.
- ggblab/__init__.py +44 -0
- ggblab/_version.py +4 -0
- ggblab/comm.py +243 -0
- ggblab/construction.py +179 -0
- ggblab/errors.py +142 -0
- ggblab/ggbapplet.py +293 -0
- ggblab/parser.py +486 -0
- ggblab/persistent_counter.py +175 -0
- ggblab/schema.py +114 -0
- ggblab/utils.py +109 -0
- ggblab-0.9.3.data/data/share/jupyter/labextensions/ggblab/build_log.json +730 -0
- ggblab-0.9.3.data/data/share/jupyter/labextensions/ggblab/install.json +5 -0
- ggblab-0.9.3.data/data/share/jupyter/labextensions/ggblab/package.json +210 -0
- ggblab-0.9.3.data/data/share/jupyter/labextensions/ggblab/schemas/ggblab/package.json.orig +205 -0
- ggblab-0.9.3.data/data/share/jupyter/labextensions/ggblab/schemas/ggblab/plugin.json +8 -0
- ggblab-0.9.3.data/data/share/jupyter/labextensions/ggblab/static/lib_index_js.bbfa36bc62ee08eb62b2.js +465 -0
- ggblab-0.9.3.data/data/share/jupyter/labextensions/ggblab/static/lib_index_js.bbfa36bc62ee08eb62b2.js.map +1 -0
- ggblab-0.9.3.data/data/share/jupyter/labextensions/ggblab/static/remoteEntry.2d29364aef8b527d773e.js +568 -0
- ggblab-0.9.3.data/data/share/jupyter/labextensions/ggblab/static/remoteEntry.2d29364aef8b527d773e.js.map +1 -0
- ggblab-0.9.3.data/data/share/jupyter/labextensions/ggblab/static/style.js +4 -0
- ggblab-0.9.3.data/data/share/jupyter/labextensions/ggblab/static/style_index_js.aab9f5416f41ce79cac3.js +492 -0
- ggblab-0.9.3.data/data/share/jupyter/labextensions/ggblab/static/style_index_js.aab9f5416f41ce79cac3.js.map +1 -0
- ggblab-0.9.3.dist-info/METADATA +768 -0
- ggblab-0.9.3.dist-info/RECORD +26 -0
- ggblab-0.9.3.dist-info/WHEEL +4 -0
- 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
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)
|