"""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