"""High-level GeoGebra applet controller used by notebooks and the JupyterLab plugin.
`GeoGebra` is the public-facing class that manages communication channels
(IPython Comm + out-of-band socket) and provides async methods for sending
commands and calling GeoGebra API functions. Heavy I/O helpers and
analysis tools live in the optional `ggblab_extra` package.
"""
import asyncio
import re
import ipykernel.connect
import logging
from contextlib import asynccontextmanager
from dataclasses import dataclass
from datetime import datetime
import hashlib
from typing import Optional
from IPython.core.getipython import get_ipython
from ipylab import JupyterFrontEnd
from .comm import ggb_comm
from .file import ggb_file
from .parser import ggb_parser
from .errors import (
GeoGebraError,
GeoGebraCommandError,
GeoGebraSyntaxError,
GeoGebraSemanticsError,
GeoGebraAppletError,
)
from ggblab.utils import flatten
# Exception hierarchy is defined in errors.py and imported above
[docs]
class GeoGebra:
"""Main interface for controlling GeoGebra applets from Python.
This class implements a singleton pattern to ensure only one GeoGebra
instance per kernel session. It provides async methods for sending
commands and calling GeoGebra API functions.
The communication uses a dual-channel architecture:
- IPython Comm: Primary control channel
- Unix socket/TCP WebSocket: Out-of-band response delivery during cell execution
Semantic Validation:
- check_syntax: Validates command strings can be tokenized
- check_semantics: Validates referenced objects exist in applet
- Future: Type checking, scope/visibility validation
Attributes:
file (ggb_file): GeoGebra file (.ggb) loader and saver
construction: Backward compatibility alias for file attribute
parser: Dependency graph parser with command learning
comm (ggb_comm): Communication layer (initialized after init())
kernel_id (str): Current Jupyter kernel ID
app (JupyterFrontEnd): ipylab frontend interface
check_syntax (bool): Enable syntax validation (default: False)
check_semantics (bool): Enable semantic validation (default: False)
_applet_objects (set): Cached object names from applet (updated by command/function)
Note:
The parser attribute lives in this package and provides tokenization
and command-cache features used for syntax/semantics checks.
Note:
Heavy I/O and convenience helpers (DataFrame construction,
persistence helpers such as ``ConstructionIO.save_dataframe``,
and richer parser implementations) have been moved to the
optional ``ggblab_extra`` package. Install ``ggblab_extra`` to
access those features; the core package keeps lightweight shims
and will emit DeprecationWarning when using deprecated helpers.
Example:
>>> ggb = GeoGebra()
>>> await ggb.init()
>>> await ggb.command("A=(0,0)")
>>> result = await ggb.function("getValue", ["A"])
>>> # With validation
>>> ggb.check_syntax = True
>>> ggb.check_semantics = True
>>> await ggb.command("Circle(A, B)")
"""
_instance = None
def __new__(cls):
"""Create or return the singleton GeoGebra instance for this kernel."""
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self):
"""Initialize default attributes for the GeoGebra controller."""
self.initialized = False
self.file = ggb_file() # .ggb file I/O
self.construction = self.file # Backward compatibility alias
self.parser = ggb_parser()
self.check_syntax = False
self.check_semantics = False
self._applet_objects = set() # Cache of known objects
# If a notebook-level `ggb_comm_instance` exists in the IPython
# user namespace (created by the frontend's kernel snippet), reuse
# it so newly-created GeoGebra instances immediately share the
# same communication object and avoid transient mismatches.
try:
ip = get_ipython()
user_ns = getattr(ip, 'user_ns', {}) if ip is not None else {}
inst = user_ns.get('ggb_comm_instance') or globals().get('ggb_comm_instance', None)
if inst and getattr(self, 'comm', None) is not inst:
self.comm = inst
print('Repointed ggb.comm -> ggb_comm_instance (constructor)')
except Exception:
# Non-fatal; this is a best-effort alignment only.
pass
[docs]
async def init(self):
"""Initialize the GeoGebra widget and communication channels.
This method:
1. Starts the out-of-band socket server (Unix socket on POSIX, TCP WebSocket on Windows)
2. Registers the IPython Comm target ('ggblab-comm')
3. Opens the GeoGebra widget panel via ipylab with communication settings
4. Initializes the object cache
The widget is launched programmatically to pass kernel-specific settings
(Comm target, socket path) before initialization, avoiding the limitations
of fixed arguments from Launcher/Command Palette.
Returns:
GeoGebra: Self reference for method chaining.
Example:
>>> ggb = await GeoGebra().init()
>>> # GeoGebra panel opens in split-right position
"""
if not self.initialized:
# Reuse a notebook-level or module-level singleton ggb_comm_instance
# if present. The frontend typically executes a kernel snippet that
# creates `ggb_comm_instance` in the IPython user namespace, so
# check that first before creating a new instance here.
ip = get_ipython()
user_ns = getattr(ip, 'user_ns', {}) if ip is not None else {}
comm_instance = user_ns.get('ggb_comm_instance') or globals().get('ggb_comm_instance')
if comm_instance is None:
comm_instance = ggb_comm()
globals()['ggb_comm_instance'] = comm_instance
try:
# Also expose the instance into the IPython user namespace
# so interactive snippets and debugging code can find it.
if user_ns is not None:
user_ns['ggb_comm_instance'] = comm_instance
except Exception:
pass
# If an existing GeoGebra controller is present in this
# kernel (e.g. a previously-created `ggb`), repoint its
# `comm` attribute to the newly created/registered
# `ggb_comm_instance` so both sides share the same
# communication instance and avoid transient mismatches.
try:
ggb_obj = user_ns.get('ggb') or globals().get('ggb') or getattr(GeoGebra, '_instance', None)
if comm_instance and ggb_obj and getattr(ggb_obj, 'comm', None) is not comm_instance:
ggb_obj.comm = comm_instance
print("Repointed ggb.comm -> ggb_comm_instance")
except Exception:
# Best-effort only; don't fail init on repointing issues
pass
self.comm = comm_instance
# Ensure OOB server is started on the instance we reuse/create
try:
self.comm.start()
except Exception:
pass
while self.comm.socketPath is None:
await asyncio.sleep(.01)
# Ensure the kernel-side target registration is requested on this instance
try:
self.comm.register_target()
except Exception:
pass
_connection_file = ipykernel.connect.get_connection_file()
self.kernel_id = re.search(r'kernel-(.*)\.json', _connection_file).group(1)
self.app = JupyterFrontEnd()
self.app.commands.execute('ggblab:create', {
'kernelId': self.kernel_id,
'commTarget': 'jupyter.widget',
'insertMode': 'split-right',
'socketPath': self.comm.socketPath,
# 'wsPort': self.comm.wsPort,
})
# Best-effort: wait briefly for an out-of-band client to connect.
# This improves same-cell reliability by giving the frontend a short
# window to start its helper kernel and connect to the socket.
try:
waited = 0.0
while waited < 3.0:
stat = None
try:
stat = self.comm.status()
except Exception:
stat = None
if stat and stat.get('has_oob_clients'):
break
await asyncio.sleep(0.05)
waited += 0.05
if waited >= 3.0:
# not a fatal error; continue but log for diagnostics
try:
print('ggblab: warning - no out-of-band client connected after wait')
except Exception:
pass
except Exception:
pass
# Skipping comm-stability wait: removed as it can trigger
# kernel-side comm introspection that creates transient comms
# and leads to "No such comm" errors during initialization.
# Widget-manager probing removed: unnecessary and causes hidden
# kernel-side Comm creation during init which can lead to "No such
# comm" errors. Skipping probing and continuing initialization.
# Initialize object cache
# await self.refresh_object_cache()
self._applet_objects = set()
self.initialized = True
# After initialization, perform a final best-effort repoint of any
# existing `ggb` variable to the registered module-level
# `ggb_comm_instance`. This mirrors the interactive debugging
# snippet and ensures `ggb.comm` and the registered instance
# are the same object even if the global was created elsewhere.
try:
ip = get_ipython()
user_ns = getattr(ip, 'user_ns', {}) if ip is not None else {}
inst = user_ns.get('ggb_comm_instance') or globals().get('ggb_comm_instance', None)
ggb_obj = user_ns.get('ggb') or globals().get('ggb', None)
# If inst and ggb exist and are different, make ggb use the registered instance:
if inst and ggb_obj and getattr(ggb_obj, "comm", None) is not inst:
ggb_obj.comm = inst
print("Repointed ggb.comm -> ggb_comm_instance")
except Exception:
# best-effort only; do not raise from repoint attempts
pass
return self
def _is_literal(self, token):
"""Check if token is a literal value (number, string, boolean, math function).
Literals should not be validated as object references. This includes:
- Numeric literals: 2, 3.14, -5, 1e-3
- String literals: "text", 'string'
- Boolean constants: true, false
- Math functions: sin, cos, sqrt, etc.
Args:
token: Token to check
Returns:
bool: True if token is a literal, False if it could be an object reference
"""
if not isinstance(token, str) or not token:
return True
# Numeric literals (integers, decimals, scientific notation)
try:
float(token)
return True
except ValueError:
pass
# String literals (quoted)
if token[0] in ('"', "'"):
return True
# Boolean constants
if token in ('true', 'false'):
return True
# Common GeoGebra/math functions
math_functions = {
'sin', 'cos', 'tan', 'asin', 'acos', 'atan', 'atan2',
'sinh', 'cosh', 'tanh',
'sqrt', 'abs', 'log', 'ln', 'log10', 'exp',
'floor', 'ceil', 'round', 'sgn',
'random', 'min', 'max', 'sum', 'mean',
}
if token in math_functions:
return True
return False
[docs]
async def refresh_object_cache(self):
"""Refresh the cached set of known objects from the applet.
Called automatically during init() and can be called manually to
synchronize the object cache with current applet state.
"""
try:
objects = await self.function("getAllObjectNames")
self._applet_objects = set(objects) if objects else set()
except Exception as e:
print(f"Warning: Could not refresh object cache: {type(e).__name__} {e}")
[docs]
async def function(self, f, args=None):
"""Call a GeoGebra API function.
Args:
f (str): GeoGebra API function name (e.g., "getValue", "getXML").
args (list, optional): Function arguments. Defaults to None.
Returns:
Any: Function return value from GeoGebra.
Example:
>>> value = await ggb.function("getValue", ["A"])
>>> xml = await ggb.function("getXML", ["A"])
>>> all_objs = await ggb.function("getAllObjectNames")
"""
r = await self.comm.send_recv({
"type": "function",
"payload": {
"name": f,
"args": args
}
})
return r['value']
[docs]
@asynccontextmanager
async def preserve(self):
"""Snapshot the current construction and restore it on exit.
Yields:
A `Snap` object with the following attributes and helpers:
- `xml` (str | None): GeoGebra construction XML (None on error)
- `timestamp` (datetime): UTC time when snapshot was taken
- `size_bytes` (int): byte length of the XML
- `sha1` (str): SHA1 digest of the XML
Methods:
- `await snap.restore()`: immediately restore the saved XML
- `snap.release()`: drop `xml` to free memory
Usage examples:
# automatic restore on exit (no local XML reference kept)
async with ggb.preserve():
await ggb.command("A=(1,2)")
# inspect or restore inside the block
async with ggb.preserve() as snap:
print(snap.sha1, snap.size_bytes)
await snap.restore()
Notes:
- The returned `xml` is an immutable Python string; no extra deep-copy
is performed when yielding it. If you hold `snap.xml` for long
periods it will retain memory until released or out of scope.
- If acquiring the XML fails, `xml` will be `None` and no automatic
restoration will be attempted on exit.
"""
@dataclass
class Snap:
base64_zip: Optional[str]
# Optionally decoded XML (if the snapshot helper extracts it); may be None
xml: Optional[str]
timestamp: datetime
size_bytes: int
sha1: str
_ggb: "GeoGebra"
async def restore(self) -> None:
# Prefer restoring via setBase64 when possible
if self.base64_zip is not None:
await self._ggb.function("setBase64", [self.base64_zip])
return
if self.xml is None:
return
# Fallback: attempt to restore XML directly if available
await self._ggb.function("setXML", [self.xml])
def release(self) -> None:
self.xml = None
try:
# Acquire a zip+base64 snapshot from the applet to avoid large XML strings
b64 = await self.function("getBase64")
except Exception:
logging.getLogger(__name__).exception(
"Failed to read GeoGebra base64 snapshot (getBase64). Proceeding without backup.")
b64 = None
if b64 is not None:
# Compute SHA1 and size on the decoded bytes
try:
decoded = __import__('base64').b64decode(b64)
sha1 = hashlib.sha1(decoded).hexdigest()
size = len(decoded)
except Exception:
sha1 = hashlib.sha1(b64.encode('utf8')).hexdigest()
size = len(b64.encode('utf8'))
snap = Snap(base64_zip=b64, xml=None, timestamp=datetime.utcnow(), size_bytes=size, sha1=sha1, _ggb=self)
else:
snap = Snap(base64_zip=None, xml=None, timestamp=datetime.utcnow(), size_bytes=0, sha1="", _ggb=self)
try:
yield snap
finally:
# Restore using base64 snapshot if available, otherwise attempt XML
if snap.base64_zip is not None:
try:
await self.function("setBase64", [snap.base64_zip])
except Exception:
logging.getLogger(__name__).exception(
"Failed to restore GeoGebra snapshot via setBase64.")
elif snap.xml is not None:
try:
await self.function("setXML", [snap.xml])
except Exception:
logging.getLogger(__name__).exception(
"Failed to restore GeoGebra XML (setXML).")
[docs]
async def command(self, c):
"""Execute a GeoGebra command with optional validation.
Args:
c (str): GeoGebra command string (e.g., "A=(0,0)", "Circle(A, 2)").
Returns:
dict: Response from GeoGebra (typically includes object label).
Raises:
GeoGebraSyntaxError: If syntax check is enabled and command has syntax errors.
GeoGebraSemanticsError: If semantics check is enabled and validation fails.
GeoGebraAppletError: If GeoGebra applet produces error events during execution.
Example:
>>> await ggb.command("A=(0,0)")
>>> await ggb.command("B=(3,4)")
>>> await ggb.command("Circle(A, Distance(A, B))")
>>> # With validation
>>> ggb.check_syntax = True
>>> ggb.check_semantics = True
>>> await ggb.command("Circle(A, B)") # Validates syntax and references
>>> # Error handling
>>> try:
... await ggb.command("Unbalanced(")
... except GeoGebraAppletError as e:
... print(f"Applet error: {e.error_message}")
"""
# Syntax check: validate command can be tokenized
if self.check_syntax:
try:
self.parser.tokenize_with_commas(c)
except Exception as e:
raise GeoGebraSyntaxError(c, str(e))
# Semantics check: validate referenced objects exist in applet
if self.check_semantics:
try:
# Refresh object cache before checking
await self.refresh_object_cache()
# Extract object tokens: tokens in the flattened structure that are
# not commands (not in command_cache), not commas, and not literals
t = self.parser.tokenize_with_commas(c)
object_tokens = [o for o in flatten(t)
if o not in self.parser.command_cache
and o != ","
and not self._is_literal(o)]
# Check if referenced objects exist
missing_objects = [obj for obj in object_tokens
if obj not in self._applet_objects]
if missing_objects:
raise GeoGebraSemanticsError(
c,
f"Referenced object(s) do not exist in applet: {missing_objects}",
missing_objects
)
except GeoGebraSemanticsError:
raise
except Exception as e:
raise GeoGebraSemanticsError(c, f"Validation error: {e}")
result = await self.comm.send_recv({
"type": "command",
"payload": c
})
# FUTURE: Error event queue processing for enhanced scope learning
# After command execution, GeoGebra appends error events to self.comm.recv_events.queue:
# {'type': 'Error', 'payload': 'Unbalanced brackets'}
# {'type': 'Error', 'payload': 'Circle(A, 1 '}
#
# This enables:
# 1. Real-time error capture: Complement pre-flight validation with actual GeoGebra errors
# 2. Dynamic scope updates: Track which objects were created despite errors
# 3. Cross-domain learning: Correlate error patterns with domain-specific semantics
# 4. Validation refinement: Use GeoGebra's error feedback to improve check_semantics logic
#
# Implementation strategy:
# - Drain error queue: while self.comm.recv_events.queue: event = popleft()
# - Classify errors: syntax vs semantic vs type errors
# - Update validation rules based on error patterns
# - Store error context for cross-session learning via parser.command_cache
# Update object cache on successful command
if result and 'label' in result:
self._applet_objects.add(result['label'])
return result
# Module-level repoint is no longer necessary; constructor/init handle
# alignment between `ggb` and the `ggb_comm_instance` created by the
# frontend. Leaving this file-level snippet would be redundant.