اضافه شدن قابلیت مقایسه شماتیک
This commit is contained in:
@@ -15,8 +15,10 @@ def cleanup(batch=100):
|
||||
for job in db.scalars(q):
|
||||
try:
|
||||
storage().delete(job.input_key)
|
||||
if job.input_key.endswith('/comparison.json'):
|
||||
for side in ('old','new'): storage().delete(f'{job.id}/{side}.SchDoc')
|
||||
for attempt in (1,2):
|
||||
for name in ('data.json','drawing.svg'):
|
||||
for name in ('data.json','drawing.svg','old.svg','new.svg'):
|
||||
storage().delete(f'{job.id}/attempt-{attempt}/{name}')
|
||||
db.delete(job); removed+=1
|
||||
except Exception: log.exception('Expired artifact cleanup failed for %s',job.id)
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Comparison routes using the existing Job, storage, authentication and queue."""
|
||||
import hashlib
|
||||
import json
|
||||
import secrets
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Annotated,Literal
|
||||
from fastapi import File,Form,Header,UploadFile
|
||||
from fastapi.responses import FileResponse,JSONResponse,Response
|
||||
from sqlalchemy import select,func,text
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
from .config import settings
|
||||
from .db import Job,sessions
|
||||
from .storage import storage
|
||||
from .uploads import read_schematic
|
||||
from .models.api import ComparisonStatus,ComparisonUploadResponse
|
||||
|
||||
FRONTEND=Path(__file__).resolve().parents[2]/'frontend'
|
||||
|
||||
|
||||
def register_comparison_routes(app, *, authorize, require_api, error):
|
||||
def comparison(identifier,key,token):
|
||||
job=authorize(identifier,key,token,comparison=True)
|
||||
if not job.input_key.endswith('/comparison.json'):error('not_found','Comparison not found',404)
|
||||
return job
|
||||
|
||||
def state(job):
|
||||
result={'success':job.status!='failed','id':job.id,'comparison_id':job.id,'status':job.status,
|
||||
'url':settings().public_url+'/compare/'+job.id,'viewer_url':settings().public_url+'/compare/'+job.id,
|
||||
'status_url':settings().public_url+'/api/v1/comparisons/'+job.id,
|
||||
'expires_at':job.expires_at,'access':job.access}
|
||||
if job.summary:result.update(json.loads(job.summary))
|
||||
if job.error_code:result['error']={'code':job.error_code,'message':job.error_message}
|
||||
return result
|
||||
|
||||
@app.post('/api/compare',status_code=202,response_model=ComparisonUploadResponse,response_model_exclude_unset=True,tags=['Comparisons'])
|
||||
@app.post('/api/v1/comparisons',status_code=202,response_model=ComparisonUploadResponse,response_model_exclude_unset=True,tags=['Comparisons'])
|
||||
async def create(old_file:Annotated[UploadFile,File()],new_file:Annotated[UploadFile,File()],
|
||||
access:Annotated[Literal['private','unlisted'],Form()]='private',
|
||||
expires_in:Annotated[int|None,Form(gt=0)]=None,
|
||||
x_api_key:Annotated[str|None,Header()]=None):
|
||||
require_api(x_api_key)
|
||||
ttl=expires_in or settings().link_ttl
|
||||
if ttl>settings().link_ttl:error('invalid_expiry','expires_in exceeds the configured link lifetime',422)
|
||||
try:
|
||||
old_name,old=await read_schematic(old_file)
|
||||
new_name,new=await read_schematic(new_file)
|
||||
finally:
|
||||
await old_file.close();await new_file.close()
|
||||
identifier=secrets.token_hex(24);token=secrets.token_urlsafe(32);now=time.time()
|
||||
job=Job(id=identifier,filename='Schematic comparison',status='queued',access=access,
|
||||
token_hash=hashlib.sha256(token.encode()).hexdigest(),created_at=now,updated_at=now,
|
||||
expires_at=now+ttl,attempt=0,input_key=identifier+'/comparison.json')
|
||||
files={identifier+'/old.SchDoc':old,identifier+'/new.SchDoc':new,
|
||||
job.input_key:json.dumps({'schema_version':'1.0','old_filename':old_name,'new_filename':new_name}).encode()}
|
||||
def persist():
|
||||
written=[]
|
||||
try:
|
||||
with sessions().begin() as db:
|
||||
if db.bind.dialect.name=='postgresql':db.execute(text('SELECT pg_advisory_xact_lock(84721309)'))
|
||||
pending=db.scalar(select(func.count()).select_from(Job).where(Job.status.in_(['queued','processing']),Job.expires_at>now))
|
||||
if pending>=settings().max_pending:error('queue_full','Rendering queue is full; try again later',503)
|
||||
for key,body in files.items():
|
||||
try: storage().put(key,body)
|
||||
except Exception: error('storage_unavailable','Upload storage unavailable',503)
|
||||
written.append(key)
|
||||
db.add(job);db.flush()
|
||||
except Exception:
|
||||
for key in written:
|
||||
try:storage().delete(key)
|
||||
except Exception:pass
|
||||
raise
|
||||
try:await run_in_threadpool(persist)
|
||||
except OSError:error('storage_unavailable','Upload storage unavailable',503)
|
||||
result=state(job)
|
||||
if access=='private':
|
||||
result['viewer_token']=token
|
||||
result['viewer_url']+='#token='+token
|
||||
result['url']=result['viewer_url']
|
||||
return JSONResponse(result,status_code=202,headers={'Location':result['status_url'],'Retry-After':'1'})
|
||||
|
||||
@app.get('/api/compare/{identifier}',response_model=ComparisonStatus,response_model_exclude_unset=True,tags=['Comparisons'])
|
||||
@app.get('/api/v1/comparisons/{identifier}',response_model=ComparisonStatus,response_model_exclude_unset=True,tags=['Comparisons'])
|
||||
def get(identifier:str,x_api_key:Annotated[str|None,Header()]=None,x_viewer_token:Annotated[str|None,Header()]=None):
|
||||
job=comparison(identifier,x_api_key,x_viewer_token);result=state(job)
|
||||
if job.status=='completed':
|
||||
try:result.update(json.loads(storage().get(job.output_prefix+'/data.json')))
|
||||
except Exception:error('storage_unavailable','Comparison result unavailable',503)
|
||||
return result
|
||||
|
||||
@app.get('/api/v1/comparisons/{identifier}/svg/{side}',response_class=Response,tags=['Comparisons'])
|
||||
def svg(identifier:str,side:Literal['old','new'],x_api_key:Annotated[str|None,Header()]=None,
|
||||
x_viewer_token:Annotated[str|None,Header()]=None):
|
||||
job=comparison(identifier,x_api_key,x_viewer_token)
|
||||
if job.status=='failed':error(job.error_code,job.error_message,422)
|
||||
if job.status!='completed':error('not_ready','Comparison is still in progress',409)
|
||||
try:body=storage().get(job.output_prefix+'/'+side+'.svg')
|
||||
except Exception:error('storage_unavailable','Comparison rendering unavailable',503)
|
||||
return Response(body,media_type='image/svg+xml')
|
||||
|
||||
@app.get('/compare',include_in_schema=False)
|
||||
def upload_page():return FileResponse(FRONTEND/'compare-upload.html')
|
||||
|
||||
@app.get('/compare/{identifier}',include_in_schema=False)
|
||||
def page(identifier:str):
|
||||
import re
|
||||
if not re.fullmatch('[a-f0-9]{48}',identifier):error('not_found','Comparison not found',404)
|
||||
return FileResponse(FRONTEND/'compare.html')
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Reuse the parser's resource boundary, geometry model and SVG serializer."""
|
||||
from . import parse_task # Sets the same subprocess resource limits, without running it.
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from .documents import parse_document
|
||||
from .diff import compare_documents
|
||||
from .parser.record_decoder import ParseError
|
||||
from .render import svg_document
|
||||
|
||||
|
||||
def run():
|
||||
source,destination,_,identifier=sys.argv[1:]
|
||||
dest=Path(destination)
|
||||
try:
|
||||
manifest=json.loads(Path(source).read_text())
|
||||
old=parse_document((dest/'old.SchDoc').read_bytes(),manifest['old_filename'],identifier+'-old',allow_empty=True)
|
||||
new=parse_document((dest/'new.SchDoc').read_bytes(),manifest['new_filename'],identifier+'-new',allow_empty=True)
|
||||
report=compare_documents(old,new)
|
||||
(dest/'data.json').write_text(json.dumps(report,ensure_ascii=False),encoding='utf-8')
|
||||
for side,doc in [('old',old),('new',new)]:
|
||||
annotations={c[f'{side}_id']:{'id':c['id'],'status':c['status'],'bounds':c[f'{side}_bounds']}
|
||||
for c in report['changes'] if c[f'{side}_id'] is not None}
|
||||
(dest/f'{side}.svg').write_bytes(svg_document(doc,annotations,id_prefix=side+'-'))
|
||||
result={'ok':True,'summary':{'summary':report['summary'],'total_changed':report['total_changed']}}
|
||||
except (ParseError,ValueError) as exc:
|
||||
result={'ok':False,'code':'invalid_schematic','message':str(exc)[:500]}
|
||||
except Exception:
|
||||
result={'ok':False,'code':'comparison_error','message':'The schematic comparison could not be completed'}
|
||||
(dest/'result.json').write_text(json.dumps(result),encoding='utf-8')
|
||||
|
||||
if __name__=='__main__':run()
|
||||
@@ -0,0 +1,179 @@
|
||||
"""Structural SchDoc comparison. Physical record indexes are never cross-file identity."""
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from .models.schematic import Document
|
||||
|
||||
BOOKKEEPING = {'OWNERINDEX','OWNERINDEXADDITIONALLIST','INDEXINSHEET','UNIQUEID',
|
||||
'GRAPHICALLYLOCKED','LOCKED','READONLYSTATE','_RESOLVED_TEXT'}
|
||||
X_COORD = re.compile(r'^(LOCATION\.X|CORNER\.X|E?X\d+)$')
|
||||
Y_COORD = re.compile(r'^(LOCATION\.Y|CORNER\.Y|E?Y\d+)$')
|
||||
NET_TYPES = {17,18,25}
|
||||
|
||||
|
||||
def digest(value):
|
||||
return hashlib.sha256(json.dumps(value, sort_keys=True, ensure_ascii=False, separators=(',',':')).encode()).hexdigest()
|
||||
|
||||
|
||||
def normalized(properties, origin):
|
||||
result = {}
|
||||
keys = set(properties) | {k[:-5] for k in properties if k.endswith('_FRAC') and (X_COORD.match(k[:-5]) or Y_COORD.match(k[:-5]))}
|
||||
for key in sorted(keys):
|
||||
value = properties.get(key, '0')
|
||||
if key in BOOKKEEPING or (key.endswith('_FRAC') and (X_COORD.match(key[:-5]) or Y_COORD.match(key[:-5]))): continue
|
||||
if X_COORD.match(key) or Y_COORD.match(key):
|
||||
try:
|
||||
offset = origin[0] if X_COORD.match(key) else -origin[1]
|
||||
result[key] = round(float(value) + float(properties.get(key+'_FRAC',0))/100000 - offset, 8)
|
||||
except ValueError: result[key] = value
|
||||
else: result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
@dataclass
|
||||
class Entity:
|
||||
id: str
|
||||
kind: str
|
||||
uid: str
|
||||
reference: str
|
||||
position: tuple
|
||||
bounds: dict | None
|
||||
properties: dict
|
||||
signature: str
|
||||
primitive_ids: list
|
||||
record_type: int
|
||||
|
||||
|
||||
def entities(document):
|
||||
objects = {o.id:o for o in document.objects}
|
||||
children = defaultdict(list)
|
||||
for obj in document.objects:
|
||||
if obj.component_id: children[obj.component_id].append(obj)
|
||||
image_hashes = defaultdict(list)
|
||||
for primitive in document.primitives:
|
||||
if primitive.kind == 'image': image_hashes[primitive.object_id].append(digest(primitive.image_data))
|
||||
output = []
|
||||
for component in document.components:
|
||||
source = objects.get(component.id)
|
||||
origin = (component.position.x, component.position.y)
|
||||
records = []
|
||||
for obj in children[component.id]:
|
||||
record = normalized(obj.properties, origin)
|
||||
if image_hashes[obj.id]: record['_IMAGE_SHA256'] = image_hashes[obj.id]
|
||||
records.append(record)
|
||||
records.sort(key=digest)
|
||||
details = {key:getattr(component,key) for key in ('designator','value','comment','library_reference',
|
||||
'source_library','description','rotation','mirrored','current_part','parameters')}
|
||||
details['symbol_records'] = digest(records)
|
||||
output.append(Entity(component.id,'component',source.properties.get('UNIQUEID','').strip().upper() if source else '',
|
||||
f'{component.designator.upper()}:{component.current_part}' if component.designator else '',
|
||||
origin,component.bounds.model_dump() if component.primitive_ids else None, details,digest(details),component.primitive_ids,1))
|
||||
for obj in document.objects:
|
||||
if obj.component_id or obj.record_type in {31,39,44,45,46,47,48}: continue
|
||||
if not obj.visible and obj.record_type != 41: continue
|
||||
origin = (obj.position.x,obj.position.y)
|
||||
if 'LOCATION.X' not in obj.properties and 'LOCATION.Y' not in obj.properties and obj.connection_points:
|
||||
origin = (obj.connection_points[0].x,obj.connection_points[0].y)
|
||||
raw = obj.properties
|
||||
if obj.record_type in (26,27) and obj.connection_points:
|
||||
points = [(p.x,p.y) for p in obj.connection_points]
|
||||
points = min(points, list(reversed(points)))
|
||||
origin = points[0]
|
||||
raw = {k:v for k,v in raw.items() if not re.fullmatch(r'[XY]\d+(?:_FRAC)?',k)}
|
||||
for i,(x,y) in enumerate(points,1): raw.update({f'X{i}':str(x),f'Y{i}':str(-y)})
|
||||
properties = normalized(raw, origin)
|
||||
if image_hashes[obj.id]: properties['_IMAGE_SHA256'] = image_hashes[obj.id]
|
||||
output.append(Entity(obj.id,obj.kind,obj.properties.get('UNIQUEID','').strip().upper(),'',origin,
|
||||
obj.bounds.model_dump() if obj.primitive_ids else None,properties,digest(properties),obj.primitive_ids,obj.record_type))
|
||||
return output
|
||||
|
||||
|
||||
def match_entities(old, new):
|
||||
left = {e.id:e for e in old}; right = {e.id:e for e in new}
|
||||
pairs = []; ambiguous = 0
|
||||
# Stages only pair keys unique on BOTH sides. Coincident/duplicate objects
|
||||
# are not assigned arbitrary nearest neighbors or physical record indexes.
|
||||
stages = [
|
||||
('unique_id', lambda e:(e.kind,e.uid) if e.uid else None),
|
||||
('designator', lambda e:(e.kind,e.reference) if e.reference else None),
|
||||
('exact', lambda e:(e.kind,e.signature,e.position)),
|
||||
('structure', lambda e:(e.kind,e.signature)),
|
||||
('position', lambda e:(e.kind,e.position) if e.bounds is not None else None),
|
||||
]
|
||||
for method,key in stages:
|
||||
a=defaultdict(list); b=defaultdict(list)
|
||||
for e in left.values():
|
||||
k=key(e)
|
||||
if k is not None:a[k].append(e)
|
||||
for e in right.values():
|
||||
k=key(e)
|
||||
if k is not None:b[k].append(e)
|
||||
for k,values in a.items():
|
||||
candidates=b.get(k,[])
|
||||
if len(values)==len(candidates)==1:
|
||||
x,y=values[0],candidates[0]
|
||||
pairs.append((x,y,method));del left[x.id];del right[y.id]
|
||||
# Identical duplicates are a multiset, so reordering them is not a change.
|
||||
a=defaultdict(list);b=defaultdict(list)
|
||||
for e in left.values():a[(e.kind,e.signature,e.position)].append(e)
|
||||
for e in right.values():b[(e.kind,e.signature,e.position)].append(e)
|
||||
for key,values in a.items():
|
||||
for x,y in zip(values,b.get(key,[])):
|
||||
pairs.append((x,y,'identical_multiset'));del left[x.id];del right[y.id]
|
||||
a=defaultdict(int); b=defaultdict(int)
|
||||
for entity in left.values(): a[(entity.kind,entity.signature)]+=1
|
||||
for entity in right.values(): b[(entity.kind,entity.signature)]+=1
|
||||
ambiguous=sum(1 for k in a.keys() & b.keys() if a[k]>1 or b[k]>1)
|
||||
return pairs,list(left.values()),list(right.values()),ambiguous
|
||||
|
||||
|
||||
def compare_documents(old: Document, new: Document):
|
||||
if old.document_type != 'schematic' or new.document_type != 'schematic':
|
||||
raise ValueError('Only schematic documents can be compared')
|
||||
before,after = entities(old),entities(new)
|
||||
pairs,removed,added,ambiguous = match_entities(before,after)
|
||||
changes=[]
|
||||
def change(status,a,b,method='',fields=None):
|
||||
changes.append({'id':f'change-{len(changes)}','status':status,'kind':(b or a).kind,
|
||||
'label':(b or a).properties.get('designator') or (b or a).properties.get('TEXT') or (b or a).properties.get('NAME') or (b or a).kind,
|
||||
'old_id':a.id if a else None,'new_id':b.id if b else None,
|
||||
'old_bounds':a.bounds if a else None,'new_bounds':b.bounds if b else None,
|
||||
'old_primitive_ids':a.primitive_ids if a else [],'new_primitive_ids':b.primitive_ids if b else [],
|
||||
'match':method,'moved':bool(a and b and a.position!=b.position),
|
||||
'movement':{'dx':b.position[0]-a.position[0],'dy':b.position[1]-a.position[1]} if a and b else None,
|
||||
'fields':fields or []})
|
||||
for a,b,method in pairs:
|
||||
fields=[{'property':key,'old':a.properties.get(key),'new':b.properties.get(key)}
|
||||
for key in sorted(a.properties.keys()|b.properties.keys()) if a.properties.get(key)!=b.properties.get(key)]
|
||||
moved=a.position!=b.position
|
||||
if fields or moved:change('modified' if fields else 'moved',a,b,method,fields)
|
||||
for a in removed:change('removed',a,None)
|
||||
for b in added:change('added',None,b)
|
||||
sheet_fields=[{'property':key,'old':old.sheet.properties.get(key),'new':new.sheet.properties.get(key)}
|
||||
for key in sorted(old.sheet.properties.keys()|new.sheet.properties.keys())
|
||||
if key not in BOOKKEEPING and old.sheet.properties.get(key)!=new.sheet.properties.get(key)]
|
||||
if sheet_fields:
|
||||
changes.append({'id':f'change-{len(changes)}','status':'modified','kind':'sheet','label':'Sheet settings',
|
||||
'old_id':old.sheet.id,'new_id':new.sheet.id,'old_bounds':old.sheet.bounds.model_dump(),
|
||||
'new_bounds':new.sheet.bounds.model_dump(),'old_primitive_ids':[],'new_primitive_ids':[],
|
||||
'match':'sheet','moved':False,'movement':None,'fields':sheet_fields})
|
||||
def nets(items):
|
||||
result=defaultdict(list)
|
||||
for e in items:
|
||||
if e.record_type in NET_TYPES:
|
||||
name=e.properties.get('TEXT',e.properties.get('NAME','')).strip()
|
||||
if name:result[name].append(e.position)
|
||||
return {name:sorted(points) for name,points in result.items()}
|
||||
old_nets,new_nets=nets(before),nets(after)
|
||||
summary={status:sum(c['status']==status for c in changes) for status in ('added','removed','modified','moved')}
|
||||
return {'schema_version':'1.0','document_type':'schematic_comparison','summary':summary,'total_changed':len(changes),
|
||||
'old':{'filename':old.filename,'compatibility':old.metadata.get('compatibility'),'sha256':old.metadata.get('sha256'),'sheet':old.sheet.bounds.model_dump(),'bounds':old.bounds.model_dump(),'diagnostics':[d.model_dump() for d in old.diagnostics]},
|
||||
'new':{'filename':new.filename,'compatibility':new.metadata.get('compatibility'),'sha256':new.metadata.get('sha256'),'sheet':new.sheet.bounds.model_dump(),'bounds':new.bounds.model_dump(),'diagnostics':[d.model_dump() for d in new.diagnostics]},
|
||||
'changes':changes,'nets':{'added':sorted(new_nets.keys()-old_nets.keys()),'removed':sorted(old_nets.keys()-new_nets.keys()),
|
||||
'changed':sorted(name for name in old_nets.keys()&new_nets.keys() if old_nets[name]!=new_nets[name])},
|
||||
'limitations':['This is a structural/connection-graphics comparison, not a compiled electrical netlist.',
|
||||
'Ambiguous identities remain added/removed. Geometry/designator fallback matches are heuristic.',
|
||||
'Unsupported native objects retain the parser compatibility limits.'],
|
||||
'matching':{'matched':len(pairs),'unmatched_old':len(removed),'unmatched_new':len(added),'ambiguous_groups':ambiguous}}
|
||||
@@ -5,11 +5,11 @@ from .parser.schdoc_parser import parse_schdoc
|
||||
from .parser.record_decoder import ParseError
|
||||
|
||||
class DocumentParser(Protocol):
|
||||
def __call__(self, data: bytes, filename: str, identifier: str) -> Document: ...
|
||||
def __call__(self, data: bytes, filename: str, identifier: str, **options) -> Document: ...
|
||||
|
||||
PARSERS: dict[str, DocumentParser] = {'schematic': parse_schdoc}
|
||||
|
||||
def parse_document(data: bytes, filename: str, identifier: str, document_type='schematic') -> Document:
|
||||
def parse_document(data: bytes, filename: str, identifier: str, document_type='schematic', **options) -> Document:
|
||||
parser=PARSERS.get(document_type)
|
||||
if parser is None: raise ParseError('Unsupported document type')
|
||||
return parser(data,filename,identifier)
|
||||
return parser(data,filename,identifier,**options)
|
||||
|
||||
+13
-17
@@ -17,11 +17,11 @@ from sqlalchemy.exc import SQLAlchemyError
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
from .config import settings
|
||||
from .db import Job, initialize, sessions
|
||||
from .parser.record_decoder import ParseError, detect_format
|
||||
from .storage import storage
|
||||
from .fonts import fonts, stylesheet
|
||||
from .models.api import UploadResponse, RenderStatus, ErrorEnvelope
|
||||
from .models.schematic import Document
|
||||
from .uploads import read_schematic
|
||||
|
||||
FRONTEND = Path(__file__).resolve().parents[2] / 'frontend'
|
||||
ID = re.compile(r'^[a-f0-9]{48}$')
|
||||
@@ -54,7 +54,8 @@ class UploadLimit:
|
||||
def __init__(self, app): self.app = app
|
||||
async def __call__(self, scope, receive, send):
|
||||
if scope['type'] != 'http': return await self.app(scope, receive, send)
|
||||
limit = settings().max_upload_bytes + 1024 * 1024
|
||||
file_count = 2 if scope['path'] in ('/api/compare','/api/v1/comparisons') else 1
|
||||
limit = file_count * settings().max_upload_bytes + 1024 * 1024
|
||||
received = 0
|
||||
async def bounded_receive():
|
||||
nonlocal received
|
||||
@@ -70,7 +71,7 @@ app.add_middleware(UploadLimit)
|
||||
@app.middleware('http')
|
||||
async def security(request, call_next):
|
||||
# Reject unauthorized POST bodies before the multipart parser reads them.
|
||||
if request.method == 'POST' and request.url.path == '/api/v1/schematics/render':
|
||||
if request.method == 'POST' and request.url.path in ('/api/v1/schematics/render','/api/compare','/api/v1/comparisons'):
|
||||
if settings().api_key and not authenticated(request.headers.get('x-api-key')):
|
||||
return JSONResponse({'success': False, 'error': {'code': 'unauthorized', 'message': 'Provide a valid X-API-Key header'}}, 401)
|
||||
response = await call_next(request)
|
||||
@@ -108,10 +109,11 @@ def health():
|
||||
with sessions()() as db: db.execute(text('SELECT 1'))
|
||||
return {'status': 'ok'}
|
||||
|
||||
def authorize(identifier, key, token):
|
||||
def authorize(identifier, key, token, *, comparison=False):
|
||||
if not ID.fullmatch(identifier): error('not_found', 'Schematic not found', 404)
|
||||
with sessions()() as db: job = db.get(Job, identifier)
|
||||
if job is None: error('not_found', 'Schematic not found', 404)
|
||||
if job is None or job.input_key.endswith('/comparison.json') != comparison:
|
||||
error('not_found', 'Schematic not found', 404)
|
||||
# Private existence and expiry are not disclosed to unauthenticated callers.
|
||||
if job.access == 'private' and not (authenticated(key) or (token and hmac.compare_digest(job.token_hash, digest(token)))):
|
||||
error('not_found', 'Schematic not found', 404)
|
||||
@@ -135,19 +137,9 @@ async def upload(file: Annotated[UploadFile, File(description='Native binary OLE
|
||||
expires_in: Annotated[int | None, Form(gt=0, description='Link lifetime in seconds, up to configured maximum')] = None,
|
||||
x_api_key: Annotated[str | None, Header()] = None):
|
||||
require_api(x_api_key)
|
||||
name = (file.filename or '').replace('\\', '/').rsplit('/', 1)[-1]
|
||||
name = ''.join(c for c in name if c.isprintable())[:255]
|
||||
if not name.lower().endswith('.schdoc'): error('unsupported_file_type', 'Upload a .SchDoc file', 415)
|
||||
ttl = expires_in or settings().link_ttl
|
||||
if ttl > settings().link_ttl: error('invalid_expiry', 'expires_in exceeds the configured link lifetime', 422)
|
||||
body = bytearray()
|
||||
try:
|
||||
while chunk := await file.read(1024 * 1024):
|
||||
body.extend(chunk)
|
||||
if len(body) > settings().max_upload_bytes: error('upload_too_large', 'Schematic exceeds configured upload size', 413)
|
||||
finally: await file.close()
|
||||
try: detect_format(bytes(body))
|
||||
except ParseError as exc: error('invalid_schematic', str(exc), 422)
|
||||
name, body = await read_schematic(file)
|
||||
identifier = secrets.token_hex(24)
|
||||
token = secrets.token_urlsafe(32)
|
||||
now = time.time()
|
||||
@@ -209,7 +201,7 @@ def index(): return FileResponse(FRONTEND / 'index.html')
|
||||
|
||||
@app.get('/assets/{name}', include_in_schema=False)
|
||||
def asset(name: str):
|
||||
if name not in {'viewer.js','viewer.css','upload.js','upload.css','camera.js'}: error('not_found', 'Asset not found', 404)
|
||||
if name not in {'viewer.js','viewer.css','upload.js','upload.css','camera.js','compare.js','compare.css','compare-upload.js','interactions.js'}: error('not_found', 'Asset not found', 404)
|
||||
return FileResponse(FRONTEND / name)
|
||||
|
||||
@app.get('/docs', include_in_schema=False)
|
||||
@@ -228,3 +220,7 @@ def font_file(name: str):
|
||||
async def database_error(request, exc):
|
||||
logging.getLogger(__name__).error('Database request failed', exc_info=exc)
|
||||
return JSONResponse({'success':False,'error':{'code':'database_unavailable','message':'Database service unavailable'}},status_code=503,headers={'Cache-Control':'no-store'})
|
||||
|
||||
# Comparison jobs share the existing queue, access policy and error envelope.
|
||||
from .compare_api import register_comparison_routes
|
||||
register_comparison_routes(app, authorize=authorize, require_api=require_api, error=error)
|
||||
|
||||
@@ -28,3 +28,20 @@ class RenderStatus(BaseModel):
|
||||
class UploadResponse(RenderStatus):
|
||||
status_url: str
|
||||
viewer_token: str | None = Field(default=None,description='Read capability returned once for a private upload')
|
||||
|
||||
class ComparisonStatus(RenderStatus):
|
||||
comparison_id: str
|
||||
url: str
|
||||
status_url: str
|
||||
document_type: Literal['schematic_comparison'] | None = None
|
||||
summary: dict[Literal['added','removed','modified','moved'], int] | None = None
|
||||
total_changed: int | None = None
|
||||
changes: list[dict] | None = None
|
||||
old: dict | None = None
|
||||
new: dict | None = None
|
||||
nets: dict | None = None
|
||||
matching: dict | None = None
|
||||
limitations: list[str] | None = None
|
||||
|
||||
class ComparisonUploadResponse(ComparisonStatus):
|
||||
viewer_token: str | None = Field(default=None, description='Read capability returned once for a private comparison')
|
||||
|
||||
@@ -11,7 +11,7 @@ from .primitives import decode, KINDS, SUPPORTED
|
||||
|
||||
ELECTRICAL=['Input','Bidirectional','Output','Open collector','Passive','Tri-state','Open emitter','Power']
|
||||
|
||||
def parse_schdoc(data: bytes, filename: str, document_id: str) -> Document:
|
||||
def parse_schdoc(data: bytes, filename: str, document_id: str, *, allow_empty=False) -> Document:
|
||||
fmt,streams,main,extra=read_document(data)
|
||||
if not main or 'HEADER' not in main[0].properties:
|
||||
raise ParseError('Missing schematic version header')
|
||||
@@ -144,7 +144,7 @@ def parse_schdoc(data: bytes, filename: str, document_id: str) -> Document:
|
||||
objects.append(SchematicObject(id=r.id,kind='malformed',record_type=-1,source_stream=r.stream,source_index=r.index,
|
||||
visible=False,position=Point(x=0,y=0),bounds=bounds([]),properties=p))
|
||||
if len(primitives)>250000: raise ParseError('Drawable count limit exceeded')
|
||||
if not primitives: raise ParseError('No supported visible geometry found in this schematic')
|
||||
if not primitives and not allow_empty: raise ParseError('No supported visible geometry found in this schematic')
|
||||
by_component={}
|
||||
for shape in primitives:
|
||||
if shape.component_id: by_component.setdefault(shape.component_id,[]).append(shape.bounds)
|
||||
|
||||
@@ -9,7 +9,7 @@ def clean(value): return CONTROL.sub('', str(value))
|
||||
def node(parent, tag, **attrs):
|
||||
return SubElement(parent, tag, {k.replace('_', '-'): clean(v) for k, v in attrs.items()})
|
||||
|
||||
def svg_document(doc: Document) -> bytes:
|
||||
def svg_document(doc: Document, annotations=None, id_prefix="") -> bytes:
|
||||
b = doc.sheet.bounds
|
||||
root = Element('svg', {'xmlns': 'http://www.w3.org/2000/svg', 'viewBox': f'{b.x} {b.y} {b.width} {b.height}',
|
||||
'preserveAspectRatio': 'xMidYMid meet', 'role': 'img', 'aria-label': 'Schematic'})
|
||||
@@ -17,11 +17,14 @@ def svg_document(doc: Document) -> bytes:
|
||||
defs = node(root, 'defs')
|
||||
for i, p in enumerate(doc.primitives):
|
||||
target = root
|
||||
if annotations is not None:
|
||||
annotation = annotations.get(p.component_id or p.object_id)
|
||||
target = node(root, 'g', data_object_id=p.component_id or p.object_id, data_change_id=annotation['id'] if annotation else '')
|
||||
if p.clip:
|
||||
clip = node(defs, 'clipPath', id=f'clip-{i}', clipPathUnits='userSpaceOnUse')
|
||||
clip = node(defs, 'clipPath', id=f'{id_prefix}clip-{i}', clipPathUnits='userSpaceOnUse')
|
||||
c = p.clip
|
||||
node(clip, 'rect', x=c.x, y=c.y, width=c.width, height=c.height)
|
||||
target = node(root, 'g', clip_path=f'url(#clip-{i})')
|
||||
target = node(target, 'g', clip_path=f'url(#{id_prefix}clip-{i})')
|
||||
if p.kind == 'path':
|
||||
n = node(target, 'path', d=p.path, stroke=p.stroke, fill=p.fill,
|
||||
stroke_width=p.width, stroke_linecap='round', stroke_linejoin='round', fill_rule=p.fill_rule)
|
||||
@@ -40,4 +43,14 @@ def svg_document(doc: Document) -> bytes:
|
||||
dominant_baseline=p.baseline, text_decoration='underline' if p.underline else 'none')
|
||||
n.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
|
||||
n.text = clean(p.text)
|
||||
if annotations is not None:
|
||||
colors = {'added':'#16854b','removed':'#cf3545','modified':'#b87b09','moved':'#2676c5'}
|
||||
overlay = node(root, 'g', pointer_events='none')
|
||||
for annotation in annotations.values():
|
||||
b = annotation.get('bounds')
|
||||
if not b: continue
|
||||
color = colors[annotation['status']]
|
||||
node(overlay, 'rect', x=b['x']-2, y=b['y']-2, width=max(4,b['width']+4), height=max(4,b['height']+4),
|
||||
fill=color, fill_opacity='.09', stroke=color, stroke_width='1.5', vector_effect='non-scaling-stroke',
|
||||
data_change_id=annotation['id'], data_diff_box='true')
|
||||
return tostring(root, encoding='utf-8', xml_declaration=True)
|
||||
|
||||
@@ -6,7 +6,7 @@ from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from .config import settings
|
||||
|
||||
KEY = re.compile(r'^[a-f0-9]{48}/(?:input\.SchDoc|attempt-[12]/(?:data\.json|drawing\.svg))$')
|
||||
KEY = re.compile(r'^[a-f0-9]{48}/(?:input\.SchDoc|(?:old|new)\.SchDoc|comparison\.json|attempt-[12]/(?:data\.json|drawing\.svg|old\.svg|new\.svg))$')
|
||||
|
||||
class Storage:
|
||||
def __init__(self):
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Shared bounded native schematic upload validation."""
|
||||
from fastapi import HTTPException
|
||||
from .config import settings
|
||||
from .parser.record_decoder import ParseError,detect_format
|
||||
|
||||
async def read_schematic(file):
|
||||
try:
|
||||
name=(file.filename or '').replace('\\','/').rsplit('/',1)[-1]
|
||||
name=''.join(c for c in name if c.isprintable())[:255]
|
||||
if not name.lower().endswith('.schdoc'):
|
||||
raise HTTPException(415,{'code':'unsupported_file_type','message':'Upload a .SchDoc file'})
|
||||
body=bytearray()
|
||||
while chunk:=await file.read(1024*1024):
|
||||
body.extend(chunk)
|
||||
if len(body)>settings().max_upload_bytes:
|
||||
raise HTTPException(413,{'code':'upload_too_large','message':'Schematic exceeds configured upload size'})
|
||||
try:detect_format(bytes(body))
|
||||
except ParseError as exc:raise HTTPException(422,{'code':'invalid_schematic','message':str(exc)}) from exc
|
||||
return name,bytes(body)
|
||||
finally:await file.close()
|
||||
@@ -24,15 +24,24 @@ def process(job):
|
||||
with tempfile.TemporaryDirectory(prefix='schdoc-') as directory:
|
||||
root = Path(directory)
|
||||
source = root / 'input.SchDoc'
|
||||
source.write_bytes(storage().get(job['input_key']))
|
||||
comparison = job['input_key'].endswith('/comparison.json')
|
||||
if comparison:
|
||||
manifest = json.loads(storage().get(job['input_key']))
|
||||
source = root / 'comparison.json'
|
||||
source.write_text(json.dumps(manifest), encoding='utf-8')
|
||||
for side in ('old','new'):
|
||||
(root / f'{side}.SchDoc').write_bytes(storage().get(f"{job['id']}/{side}.SchDoc"))
|
||||
else:
|
||||
source.write_bytes(storage().get(job['input_key']))
|
||||
env = {k: v for k, v in os.environ.items() if k in ('PATH', 'LANG', 'LC_ALL', 'PYTHONPATH')}
|
||||
# Drain no untrusted output into logs. Kill/wait is handled by subprocess.run.
|
||||
subprocess.run([sys.executable, '-m', 'app.parse_task', str(source), str(root), job['filename'], job['id']],
|
||||
subprocess.run([sys.executable, '-m', 'app.compare_task' if comparison else 'app.parse_task', str(source), str(root), job['filename'], job['id']],
|
||||
timeout=config.parse_timeout, check=True, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
result = json.loads((root / 'result.json').read_text())
|
||||
if result['ok']:
|
||||
storage().put(prefix + '/data.json', (root / 'data.json').read_bytes(), 'application/json')
|
||||
storage().put(prefix + '/drawing.svg', (root / 'drawing.svg').read_bytes(), 'image/svg+xml')
|
||||
for name in (('old.svg','new.svg') if comparison else ('drawing.svg',)):
|
||||
storage().put(prefix + '/' + name, (root / name).read_bytes(), 'image/svg+xml')
|
||||
values = dict(status='completed', output_prefix=prefix, summary=json.dumps(result['summary']))
|
||||
else:
|
||||
values = dict(status='failed', error_code=result['code'], error_message=result['message'])
|
||||
|
||||
مرجع در شماره جدید
مسدود کردن کاربر