Files

102 lines
5.0 KiB
Python

"""Transactional project edits, with bounded persistent undo/redo."""
import copy
import json
import os
import tempfile
from contextlib import contextmanager
from pathlib import Path
from .kernel import build, digest, dependencies
def read_json(path):
p=Path(path)
if p.stat().st_size>64*1024*1024:raise ValueError('JSON input exceeds 64 MiB')
with p.open() as f:return json.load(f)
def write_json(path,data):
p=Path(path);p.parent.mkdir(parents=True,exist_ok=True)
fd,temp=tempfile.mkstemp(prefix=p.name+'.',suffix='.tmp',dir=p.parent)
try:
with os.fdopen(fd,'w') as f:
json.dump(data,f,indent=2,allow_nan=False);f.write('\n');f.flush();os.fsync(f.fileno())
os.replace(temp,p)
finally:
if os.path.exists(temp):os.unlink(temp)
class Project:
def __init__(self,path):
self.path=Path(path).resolve()
self.history_path=self.path.parent/'.spatial_lab'/(self.path.name+'.history.json')
def read(self):return read_json(self.path)
def build(self):return build(self.read())
@contextmanager
def _lock(self):
self.history_path.parent.mkdir(parents=True,exist_ok=True)
with self.history_path.with_suffix('.lock').open('a+b') as f:
if os.name=='nt':
import msvcrt
f.seek(0,2)
if f.tell()==0:f.write(b'0');f.flush()
f.seek(0);msvcrt.locking(f.fileno(),msvcrt.LK_NBLCK,1)
else:
import fcntl
fcntl.flock(f.fileno(),fcntl.LOCK_EX|fcntl.LOCK_NB)
try:yield
finally:
if os.name=='nt':f.seek(0);msvcrt.locking(f.fileno(),msvcrt.LK_UNLCK,1)
else:fcntl.flock(f.fileno(),fcntl.LOCK_UN)
def _history(self,current):
h=read_json(self.history_path) if self.history_path.exists() else {}
# External JSON edits or an interrupted two-file write invalidate history,
# never the current project. The next edit starts a new undo chain.
if h.get('current_hash')!=digest(current):h={'past':[],'future':[],'current_hash':digest(current)}
return h
def _save(self,project,history):
history['current_hash']=digest(project)
write_json(self.history_path,history);write_json(self.path,project)
def apply(self,changes):
if not isinstance(changes,list) or not changes:raise ValueError('An edit transaction needs a nonempty list of changes')
with self._lock():
before=self.read();after=copy.deepcopy(before);h=self._history(before)
for change in changes:
action=change.get('action')
if action=='set_parameter':after.setdefault('parameters',{})[change['name']]=change['value']
elif action=='upsert_node':
node=copy.deepcopy(change['node']);ids=[n['id'] for n in after['nodes']]
if node['id'] in ids:after['nodes'][ids.index(node['id'])]=node
else:after['nodes'].append(node)
elif action=='patch_node':
node=next((n for n in after['nodes'] if n['id']==change['id']),None)
if node is None:raise ValueError('No such node: '+change['id'])
for key,value in change['patch'].items():
if key=='id':raise ValueError('Stable node IDs cannot be renamed with patch_node')
if key in ['params','inputs']:node.setdefault(key,{}).update(value)
else:node[key]=value
elif action=='remove_node':
target=change['id']
if target not in [n['id'] for n in after['nodes']]:raise ValueError('No such node: '+target)
removing={target}
while True:
dependents={n['id'] for n in after['nodes'] if any(d in removing for d in dependencies(n))}-removing
if dependents and not change.get('cascade',False):raise ValueError('Node is used by: '+', '.join(sorted(dependents)))
if not dependents:break
removing.update(dependents)
after['nodes']=[n for n in after['nodes'] if n['id'] not in removing]
else:raise ValueError(f'Unknown edit action: {action}')
result=build(after) # Failed evaluation leaves both project and history untouched.
if digest(before)==digest(after):return result
h['past']=(h['past']+[before])[-64:];h['future']=[];self._save(after,h)
return result
def travel(self,direction):
if direction not in ['undo','redo']:raise ValueError('Expected undo or redo')
with self._lock():
current=self.read();h=self._history(current);src,dst=('past','future') if direction=='undo' else ('future','past')
if not h[src]:raise ValueError('Nothing to '+direction+' (external edits start a new history chain)')
target=h[src][-1];result=build(target);h[src].pop();h[dst]=(h[dst]+[current])[-64:];self._save(target,h)
return result