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:
gilesb
2026-01-20 09:02:34 +00:00
parent 6ceaa37ab6
commit d574d5badd
24 changed files with 2056 additions and 46 deletions

View File

@@ -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: