Add modular primitive libraries and fix Symbol class compatibility
- Add primitive_libs/ with modular primitive loading (core, math, image, color, color_ops, filters, geometry, drawing, blending, arrays, ascii) - Effects now explicitly declare dependencies via (require-primitives "...") - Convert ascii-fx-zone from hardcoded special form to loadable primitive - Add _is_symbol/_is_keyword helpers for duck typing to support both sexp_effects.parser.Symbol and artdag.sexp.parser.Symbol classes - Auto-inject _interp and _env for primitives that need them - Remove silent error swallowing in cell_effect evaluation Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,21 @@ from .parser import Symbol, Keyword, parse, parse_file
|
||||
from .primitives import PRIMITIVES, reset_rng
|
||||
|
||||
|
||||
def _is_symbol(x) -> bool:
|
||||
"""Check if x is a Symbol (duck typing to support multiple Symbol classes)."""
|
||||
return hasattr(x, 'name') and type(x).__name__ == 'Symbol'
|
||||
|
||||
|
||||
def _is_keyword(x) -> bool:
|
||||
"""Check if x is a Keyword (duck typing to support multiple Keyword classes)."""
|
||||
return hasattr(x, 'name') and type(x).__name__ == 'Keyword'
|
||||
|
||||
|
||||
def _symbol_name(x) -> str:
|
||||
"""Get the name from a Symbol."""
|
||||
return x.name if hasattr(x, 'name') else str(x)
|
||||
|
||||
|
||||
class Environment:
|
||||
"""Lexical environment for variable bindings."""
|
||||
|
||||
@@ -68,15 +83,28 @@ class Interpreter:
|
||||
|
||||
Provides a safe execution environment where only
|
||||
whitelisted primitives can be called.
|
||||
|
||||
Args:
|
||||
minimal_primitives: If True, only load core primitives (arithmetic, comparison,
|
||||
basic data access). Additional primitives must be loaded with
|
||||
(require-primitives) or (with-primitives).
|
||||
If False (default), load all legacy primitives for backward compatibility.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self, minimal_primitives: bool = False):
|
||||
# Base environment with primitives
|
||||
self.global_env = Environment()
|
||||
self.minimal_primitives = minimal_primitives
|
||||
|
||||
# Load primitives
|
||||
for name, fn in PRIMITIVES.items():
|
||||
self.global_env.set(name, fn)
|
||||
if minimal_primitives:
|
||||
# Load only core primitives
|
||||
from .primitive_libs.core import PRIMITIVES as CORE_PRIMITIVES
|
||||
for name, fn in CORE_PRIMITIVES.items():
|
||||
self.global_env.set(name, fn)
|
||||
else:
|
||||
# Load all legacy primitives for backward compatibility
|
||||
for name, fn in PRIMITIVES.items():
|
||||
self.global_env.set(name, fn)
|
||||
|
||||
# Special values
|
||||
self.global_env.set('true', True)
|
||||
@@ -98,10 +126,12 @@ class Interpreter:
|
||||
if expr is None:
|
||||
return None
|
||||
|
||||
if isinstance(expr, Symbol):
|
||||
# Handle Symbol (duck typing to support both sexp_effects.parser.Symbol and artdag.sexp.parser.Symbol)
|
||||
if _is_symbol(expr):
|
||||
return env.get(expr.name)
|
||||
|
||||
if isinstance(expr, Keyword):
|
||||
# Handle Keyword (duck typing)
|
||||
if _is_keyword(expr):
|
||||
return expr # Keywords evaluate to themselves
|
||||
|
||||
if isinstance(expr, np.ndarray):
|
||||
@@ -115,7 +145,7 @@ class Interpreter:
|
||||
head = expr[0]
|
||||
|
||||
# Special forms
|
||||
if isinstance(head, Symbol):
|
||||
if _is_symbol(head):
|
||||
form = head.name
|
||||
|
||||
# Quote
|
||||
@@ -125,7 +155,7 @@ class Interpreter:
|
||||
# Define
|
||||
if form == 'define':
|
||||
name = expr[1]
|
||||
if isinstance(name, Symbol):
|
||||
if _is_symbol(name):
|
||||
value = self.eval(expr[2], env)
|
||||
self.global_env.set(name.name, value)
|
||||
return value
|
||||
@@ -138,7 +168,7 @@ class Interpreter:
|
||||
|
||||
# Lambda
|
||||
if form == 'lambda' or form == 'λ':
|
||||
params = [p.name if isinstance(p, Symbol) else p for p in expr[1]]
|
||||
params = [p.name if _is_symbol(p) else p for p in expr[1]]
|
||||
body = expr[2]
|
||||
return Lambda(params, body, env)
|
||||
|
||||
@@ -205,7 +235,7 @@ class Interpreter:
|
||||
|
||||
# Set! (mutation)
|
||||
if form == 'set!':
|
||||
name = expr[1].name if isinstance(expr[1], Symbol) else expr[1]
|
||||
name = expr[1].name if _is_symbol(expr[1]) else expr[1]
|
||||
value = self.eval(expr[2], env)
|
||||
# Find and update in appropriate scope
|
||||
scope = env
|
||||
@@ -220,7 +250,7 @@ class Interpreter:
|
||||
if form == 'state-get':
|
||||
state = env.get('__state__')
|
||||
key = self.eval(expr[1], env)
|
||||
if isinstance(key, Symbol):
|
||||
if _is_symbol(key):
|
||||
key = key.name
|
||||
default = self.eval(expr[2], env) if len(expr) > 2 else None
|
||||
return state.get(key, default)
|
||||
@@ -228,7 +258,7 @@ class Interpreter:
|
||||
if form == 'state-set':
|
||||
state = env.get('__state__')
|
||||
key = self.eval(expr[1], env)
|
||||
if isinstance(key, Symbol):
|
||||
if _is_symbol(key):
|
||||
key = key.name
|
||||
value = self.eval(expr[2], env)
|
||||
state[key] = value
|
||||
@@ -238,6 +268,14 @@ class Interpreter:
|
||||
if form == 'ascii-fx-zone':
|
||||
return self._eval_ascii_fx_zone(expr, env)
|
||||
|
||||
# with-primitives - load primitive library and scope to body
|
||||
if form == 'with-primitives':
|
||||
return self._eval_with_primitives(expr, env)
|
||||
|
||||
# require-primitives - load primitive library into current scope
|
||||
if form == 'require-primitives':
|
||||
return self._eval_require_primitives(expr, env)
|
||||
|
||||
# Function call
|
||||
fn = self.eval(head, env)
|
||||
args = [self.eval(arg, env) for arg in expr[1:]]
|
||||
@@ -247,7 +285,7 @@ class Interpreter:
|
||||
kw_args = {}
|
||||
i = 0
|
||||
while i < len(args):
|
||||
if isinstance(args[i], Keyword):
|
||||
if _is_keyword(args[i]):
|
||||
kw_args[args[i].name] = args[i + 1] if i + 1 < len(args) else None
|
||||
i += 2
|
||||
else:
|
||||
@@ -291,6 +329,19 @@ class Interpreter:
|
||||
else:
|
||||
wrapped_args.append(arg)
|
||||
|
||||
# Inject _interp and _env for primitives that need them
|
||||
import inspect
|
||||
try:
|
||||
sig = inspect.signature(fn)
|
||||
params = sig.parameters
|
||||
if '_interp' in params and '_interp' not in kwargs:
|
||||
kwargs['_interp'] = self
|
||||
if '_env' in params and '_env' not in kwargs:
|
||||
kwargs['_env'] = env
|
||||
except (ValueError, TypeError):
|
||||
# Some built-in functions don't have inspectable signatures
|
||||
pass
|
||||
|
||||
# Primitive function
|
||||
if kwargs:
|
||||
return fn(*wrapped_args, **kwargs)
|
||||
@@ -309,12 +360,12 @@ class Interpreter:
|
||||
return []
|
||||
|
||||
# Check if Clojure style (flat list with symbols and values alternating)
|
||||
if isinstance(bindings[0], Symbol):
|
||||
if _is_symbol(bindings[0]):
|
||||
# Clojure style: [x 1 y 2]
|
||||
pairs = []
|
||||
i = 0
|
||||
while i < len(bindings) - 1:
|
||||
name = bindings[i].name if isinstance(bindings[i], Symbol) else bindings[i]
|
||||
name = bindings[i].name if _is_symbol(bindings[i]) else bindings[i]
|
||||
value = bindings[i + 1]
|
||||
pairs.append((name, value))
|
||||
i += 2
|
||||
@@ -323,7 +374,7 @@ class Interpreter:
|
||||
# Scheme style: ((x 1) (y 2))
|
||||
pairs = []
|
||||
for binding in bindings:
|
||||
name = binding[0].name if isinstance(binding[0], Symbol) else binding[0]
|
||||
name = binding[0].name if _is_symbol(binding[0]) else binding[0]
|
||||
value = binding[1]
|
||||
pairs.append((name, value))
|
||||
return pairs
|
||||
@@ -360,12 +411,83 @@ class Interpreter:
|
||||
"""Evaluate cond expression."""
|
||||
for clause in expr[1:]:
|
||||
test = clause[0]
|
||||
if isinstance(test, Symbol) and test.name == 'else':
|
||||
if _is_symbol(test) and test.name == 'else':
|
||||
return self.eval(clause[1], env)
|
||||
if self.eval(test, env):
|
||||
return self.eval(clause[1], env)
|
||||
return None
|
||||
|
||||
def _eval_with_primitives(self, expr: Any, env: Environment) -> Any:
|
||||
"""
|
||||
Evaluate with-primitives: scoped primitive library loading.
|
||||
|
||||
Syntax:
|
||||
(with-primitives "math"
|
||||
(sin (* x pi)))
|
||||
|
||||
(with-primitives "math" :path "custom/math.py"
|
||||
body)
|
||||
|
||||
The primitives from the library are only available within the body.
|
||||
"""
|
||||
# Parse library name and optional path
|
||||
lib_name = expr[1]
|
||||
if _is_symbol(lib_name):
|
||||
lib_name = lib_name.name
|
||||
|
||||
path = None
|
||||
body_start = 2
|
||||
|
||||
# Check for :path keyword
|
||||
if len(expr) > 2 and _is_keyword(expr[2]) and expr[2].name == 'path':
|
||||
path = expr[3]
|
||||
body_start = 4
|
||||
|
||||
# Load the primitive library
|
||||
primitives = self.load_primitive_library(lib_name, path)
|
||||
|
||||
# Create new environment with primitives
|
||||
new_env = Environment(env)
|
||||
for name, fn in primitives.items():
|
||||
new_env.set(name, fn)
|
||||
|
||||
# Evaluate body in new environment
|
||||
result = None
|
||||
for e in expr[body_start:]:
|
||||
result = self.eval(e, new_env)
|
||||
return result
|
||||
|
||||
def _eval_require_primitives(self, expr: Any, env: Environment) -> Any:
|
||||
"""
|
||||
Evaluate require-primitives: load primitives into current scope.
|
||||
|
||||
Syntax:
|
||||
(require-primitives "math" "color" "filters")
|
||||
|
||||
Unlike with-primitives, this loads into the current environment
|
||||
(typically used at top-level to set up an effect's dependencies).
|
||||
"""
|
||||
for lib_expr in expr[1:]:
|
||||
if _is_symbol(lib_expr):
|
||||
lib_name = lib_expr.name
|
||||
else:
|
||||
lib_name = lib_expr
|
||||
|
||||
primitives = self.load_primitive_library(lib_name)
|
||||
for name, fn in primitives.items():
|
||||
env.set(name, fn)
|
||||
|
||||
return None
|
||||
|
||||
def load_primitive_library(self, name: str, path: str = None) -> dict:
|
||||
"""
|
||||
Load a primitive library by name or path.
|
||||
|
||||
Returns dict of {name: function}.
|
||||
"""
|
||||
from .primitive_libs import load_primitive_library
|
||||
return load_primitive_library(name, path)
|
||||
|
||||
def _eval_ascii_fx_zone(self, expr: Any, env: Environment) -> Any:
|
||||
"""
|
||||
Evaluate ascii-fx-zone special form.
|
||||
@@ -387,8 +509,18 @@ class Interpreter:
|
||||
The expression parameters (:char_hue, etc.) are NOT pre-evaluated.
|
||||
They are passed as raw S-expressions to the primitive which
|
||||
evaluates them per-zone with zone context variables injected.
|
||||
|
||||
Requires: (require-primitives "ascii")
|
||||
"""
|
||||
from .primitives import prim_ascii_fx_zone
|
||||
# Look up ascii-fx-zone primitive from environment
|
||||
# It must be loaded via (require-primitives "ascii")
|
||||
try:
|
||||
prim_ascii_fx_zone = env.get('ascii-fx-zone')
|
||||
except NameError:
|
||||
raise NameError(
|
||||
"ascii-fx-zone primitive not found. "
|
||||
"Add (require-primitives \"ascii\") to your effect file."
|
||||
)
|
||||
|
||||
# Expression parameter names that should NOT be evaluated
|
||||
expr_params = {'char_hue', 'char_saturation', 'char_brightness',
|
||||
@@ -421,7 +553,7 @@ class Interpreter:
|
||||
i = 2
|
||||
while i < len(expr):
|
||||
item = expr[i]
|
||||
if isinstance(item, Keyword):
|
||||
if _is_keyword(item):
|
||||
if i + 1 >= len(expr):
|
||||
break
|
||||
value_expr = expr[i + 1]
|
||||
@@ -431,7 +563,7 @@ class Interpreter:
|
||||
# Resolve symbol references but don't evaluate expressions
|
||||
# This handles the case where effect definition passes a param like :char_hue char_hue
|
||||
resolved = value_expr
|
||||
if isinstance(value_expr, Symbol):
|
||||
if _is_symbol(value_expr):
|
||||
try:
|
||||
resolved = env.get(value_expr.name)
|
||||
except NameError:
|
||||
@@ -458,7 +590,7 @@ class Interpreter:
|
||||
cols = int(value)
|
||||
elif kw_name == 'char_size':
|
||||
# Handle nil/None values
|
||||
if value is None or (isinstance(value, Symbol) and value.name == 'nil'):
|
||||
if value is None or (_is_symbol(value) and value.name == 'nil'):
|
||||
char_size = None
|
||||
else:
|
||||
char_size = int(value)
|
||||
@@ -471,14 +603,12 @@ class Interpreter:
|
||||
elif kw_name == 'contrast':
|
||||
contrast = float(value)
|
||||
elif kw_name == 'energy':
|
||||
if value is None or (isinstance(value, Symbol) and value.name == 'nil'):
|
||||
if value is None or (_is_symbol(value) and value.name == 'nil'):
|
||||
energy = None
|
||||
else:
|
||||
energy = float(value)
|
||||
extra_params['energy'] = energy
|
||||
elif kw_name == 'rotation_scale':
|
||||
rotation_scale = float(value)
|
||||
extra_params['rotation_scale'] = rotation_scale
|
||||
else:
|
||||
# Store any other params for lambdas to access
|
||||
extra_params[kw_name] = value
|
||||
@@ -523,10 +653,25 @@ class Interpreter:
|
||||
|
||||
# Call the primitive with interpreter and env for expression evaluation
|
||||
return prim_ascii_fx_zone(
|
||||
frame, cols, char_size, alphabet, color_mode, background, contrast,
|
||||
char_hue, char_saturation, char_brightness,
|
||||
char_scale, char_rotation, char_jitter,
|
||||
self, env, extra_params, cell_effect
|
||||
frame,
|
||||
cols=cols,
|
||||
char_size=char_size,
|
||||
alphabet=alphabet,
|
||||
color_mode=color_mode,
|
||||
background=background,
|
||||
contrast=contrast,
|
||||
char_hue=char_hue,
|
||||
char_saturation=char_saturation,
|
||||
char_brightness=char_brightness,
|
||||
char_scale=char_scale,
|
||||
char_rotation=char_rotation,
|
||||
char_jitter=char_jitter,
|
||||
cell_effect=cell_effect,
|
||||
energy=energy,
|
||||
rotation_scale=rotation_scale,
|
||||
_interp=self,
|
||||
_env=env,
|
||||
**extra_params
|
||||
)
|
||||
|
||||
def _define_effect(self, expr: Any, env: Environment) -> EffectDefinition:
|
||||
@@ -542,7 +687,7 @@ class Interpreter:
|
||||
|
||||
Effects MUST use :params syntax. Legacy ((param default) ...) is not supported.
|
||||
"""
|
||||
name = expr[1].name if isinstance(expr[1], Symbol) else expr[1]
|
||||
name = expr[1].name if _is_symbol(expr[1]) else expr[1]
|
||||
|
||||
params = {}
|
||||
body = None
|
||||
@@ -552,7 +697,7 @@ class Interpreter:
|
||||
i = 2
|
||||
while i < len(expr):
|
||||
item = expr[i]
|
||||
if isinstance(item, Keyword) and item.name == "params":
|
||||
if _is_keyword(item) and item.name == "params":
|
||||
# :params syntax
|
||||
if i + 1 >= len(expr):
|
||||
raise SyntaxError(f"Effect '{name}': Missing params list after :params keyword")
|
||||
@@ -560,7 +705,7 @@ class Interpreter:
|
||||
params = self._parse_params_block(params_list)
|
||||
found_params = True
|
||||
i += 2
|
||||
elif isinstance(item, Keyword):
|
||||
elif _is_keyword(item):
|
||||
# Skip other keywords (like :desc)
|
||||
i += 2
|
||||
elif body is None:
|
||||
@@ -605,7 +750,7 @@ class Interpreter:
|
||||
|
||||
# First element is the parameter name
|
||||
first = param_def[0]
|
||||
if isinstance(first, Symbol):
|
||||
if _is_symbol(first):
|
||||
param_name = first.name
|
||||
elif isinstance(first, str):
|
||||
param_name = first
|
||||
@@ -617,7 +762,7 @@ class Interpreter:
|
||||
i = 1
|
||||
while i < len(param_def):
|
||||
item = param_def[i]
|
||||
if isinstance(item, Keyword):
|
||||
if _is_keyword(item):
|
||||
if i + 1 >= len(param_def):
|
||||
break
|
||||
kw_value = param_def[i + 1]
|
||||
@@ -772,14 +917,26 @@ class Interpreter:
|
||||
# =============================================================================
|
||||
|
||||
_interpreter = None
|
||||
_interpreter_minimal = None
|
||||
|
||||
|
||||
def get_interpreter() -> Interpreter:
|
||||
"""Get or create the global interpreter."""
|
||||
global _interpreter
|
||||
if _interpreter is None:
|
||||
_interpreter = Interpreter()
|
||||
return _interpreter
|
||||
def get_interpreter(minimal_primitives: bool = False) -> Interpreter:
|
||||
"""Get or create the global interpreter.
|
||||
|
||||
Args:
|
||||
minimal_primitives: If True, return interpreter with only core primitives.
|
||||
Additional primitives must be loaded with require-primitives or with-primitives.
|
||||
"""
|
||||
global _interpreter, _interpreter_minimal
|
||||
|
||||
if minimal_primitives:
|
||||
if _interpreter_minimal is None:
|
||||
_interpreter_minimal = Interpreter(minimal_primitives=True)
|
||||
return _interpreter_minimal
|
||||
else:
|
||||
if _interpreter is None:
|
||||
_interpreter = Interpreter(minimal_primitives=False)
|
||||
return _interpreter
|
||||
|
||||
|
||||
def load_effect(path: str) -> EffectDefinition:
|
||||
|
||||
Reference in New Issue
Block a user