102 lines
5.0 KiB
Python
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
|