From d2b8ae96c900937ef5be48fb4a64e99e80937a9d Mon Sep 17 00:00:00 2001 From: nearology Date: Sat, 7 Nov 2026 15:42:53 +0330 Subject: [PATCH] =?UTF-8?q?=D8=A7=D8=B6=D8=A7=D9=81=D9=87=20=D8=B4=D8=AF?= =?UTF-8?q?=D9=86=20=D9=82=D8=A7=D8=A8=D9=84=DB=8C=D8=AA=20=D9=85=D9=82?= =?UTF-8?q?=D8=A7=DB=8C=D8=B3=D9=87=20=D8=B4=D9=85=D8=A7=D8=AA=DB=8C=DA=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/test.yml | 3 + Caddyfile | 7 +- README.md | 6 + backend/app/cleanup.py | 4 +- backend/app/compare_api.py | 108 +++++++++++++ backend/app/compare_task.py | 32 ++++ backend/app/diff.py | 179 ++++++++++++++++++++++ backend/app/documents.py | 6 +- backend/app/main.py | 30 ++-- backend/app/models/api.py | 17 +++ backend/app/parser/schdoc_parser.py | 4 +- backend/app/render.py | 19 ++- backend/app/storage.py | 2 +- backend/app/uploads.py | 20 +++ backend/app/worker.py | 15 +- backend/tests/test_comparison.py | 227 ++++++++++++++++++++++++++++ docs/API.md | 4 + docs/SCHEMATIC_DIFF.md | 87 +++++++++++ frontend/compare-upload.html | 2 + frontend/compare-upload.js | 12 ++ frontend/compare.css | 34 +++++ frontend/compare.html | 6 + frontend/compare.js | 87 +++++++++++ frontend/docs.html | 2 +- frontend/index.html | 2 +- frontend/interactions.js | 56 +++++++ frontend/interactions.test.js | 46 ++++++ scripts/compare-smoke.py | 28 ++++ 28 files changed, 1012 insertions(+), 33 deletions(-) create mode 100644 backend/app/compare_api.py create mode 100644 backend/app/compare_task.py create mode 100644 backend/app/diff.py create mode 100644 backend/app/uploads.py create mode 100644 backend/tests/test_comparison.py create mode 100644 docs/SCHEMATIC_DIFF.md create mode 100644 frontend/compare-upload.html create mode 100644 frontend/compare-upload.js create mode 100644 frontend/compare.css create mode 100644 frontend/compare.html create mode 100644 frontend/compare.js create mode 100644 frontend/interactions.js create mode 100644 frontend/interactions.test.js create mode 100644 scripts/compare-smoke.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 15c6aee..83ac648 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -23,5 +23,8 @@ jobs: - run: .venv/bin/python scripts/concurrency-smoke.py .runtime/fixtures/Channel.SchDoc env: API_KEY: replace-with-a-long-random-api-key-at-least-32-characters + - run: .venv/bin/python scripts/compare-smoke.py .runtime/fixtures/Channel.SchDoc .runtime/fixtures/Channel.SchDoc + env: + API_KEY: replace-with-a-long-random-api-key-at-least-32-characters - if: always() run: docker compose -p altium-ci down diff --git a/Caddyfile b/Caddyfile index 0cb2c7e..9483f83 100644 --- a/Caddyfile +++ b/Caddyfile @@ -1,5 +1,10 @@ {$DOMAIN} { - request_body { + @comparison path /api/compare /api/v1/comparisons + request_body @comparison { + max_size 65MiB + } + @single not path /api/compare /api/v1/comparisons + request_body @single { max_size 33MB } reverse_proxy api:8000 diff --git a/README.md b/README.md index f7fc82e..647ca84 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,12 @@ Error responses use `{"success": false, "error": {"code": "…", "message": "… `unlisted` is an explicit alternative: anyone with its 192-bit random identifier can read the result until expiry. There is no searchable gallery. Every access is checked on the server. Link lifetime is configurable and the worker removes expired database rows and artifacts in batches. After physical cleanup the endpoint returns 404. An already downloaded document cannot be revoked from a recipient's device. The reference viewer clears itself at expiry. +## Schematic Diff Viewer + +Open `/compare` to upload an original and modified SchDoc, or call `POST /api/v1/comparisons` (alias `/api/compare`) with multipart fields `old_file` and `new_file` and the existing `X-API-Key`. It returns HTTP 202 with a private, expiring `/compare/{id}#token=…` URL. Poll `GET /api/v1/comparisons/{id}` using `X-Viewer-Token` until completed to receive summary counts and native-object differences. + +The workspace displays original/modified SVGs side by side with synchronized zoom/pan, optional change boxes, filtering, changes-only mode, navigation and property details. It reuses the native parser, renderer, queue, resource-limited worker and local/S3 storage without new packages or database migrations. Net labels and wire geometry are compared; no compiled electrical equivalence is claimed. See [Schematic Diff documentation](docs/SCHEMATIC_DIFF.md) for matching rules, API examples, deployment limits and tests. + ## Storage and configuration Local files are private, written atomically with generated keys. Client filenames never determine storage paths. For private S3-compatible storage set the same `S3_BUCKET`, `S3_ENDPOINT_URL`, AWS credentials and region on API and workers; no bucket ACL or public URL is created. Prefer workload credentials/IAM and configure bucket encryption/lifecycle at the provider. On a multi-host deployment use S3; a Docker named volume is shared only on one host. Storage/database orphan reconciliation after a machine failure is an operational maintenance task; normal completion, retries and expiry cleanup are implemented. diff --git a/backend/app/cleanup.py b/backend/app/cleanup.py index fdae662..ef06ab6 100644 --- a/backend/app/cleanup.py +++ b/backend/app/cleanup.py @@ -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) diff --git a/backend/app/compare_api.py b/backend/app/compare_api.py new file mode 100644 index 0000000..3d09ffa --- /dev/null +++ b/backend/app/compare_api.py @@ -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') diff --git a/backend/app/compare_task.py b/backend/app/compare_task.py new file mode 100644 index 0000000..fcf51aa --- /dev/null +++ b/backend/app/compare_task.py @@ -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() diff --git a/backend/app/diff.py b/backend/app/diff.py new file mode 100644 index 0000000..ff5377d --- /dev/null +++ b/backend/app/diff.py @@ -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}} diff --git a/backend/app/documents.py b/backend/app/documents.py index 67b9aa0..560f32f 100644 --- a/backend/app/documents.py +++ b/backend/app/documents.py @@ -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) diff --git a/backend/app/main.py b/backend/app/main.py index 4f29366..d224b25 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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) diff --git a/backend/app/models/api.py b/backend/app/models/api.py index eae7f06..9181628 100644 --- a/backend/app/models/api.py +++ b/backend/app/models/api.py @@ -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') diff --git a/backend/app/parser/schdoc_parser.py b/backend/app/parser/schdoc_parser.py index 608fdc6..ecbdc8e 100644 --- a/backend/app/parser/schdoc_parser.py +++ b/backend/app/parser/schdoc_parser.py @@ -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) diff --git a/backend/app/render.py b/backend/app/render.py index 8e56d4e..9f3823b 100644 --- a/backend/app/render.py +++ b/backend/app/render.py @@ -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) diff --git a/backend/app/storage.py b/backend/app/storage.py index 2c30db2..1851fe4 100644 --- a/backend/app/storage.py +++ b/backend/app/storage.py @@ -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): diff --git a/backend/app/uploads.py b/backend/app/uploads.py new file mode 100644 index 0000000..0c37ed9 --- /dev/null +++ b/backend/app/uploads.py @@ -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() diff --git a/backend/app/worker.py b/backend/app/worker.py index c46c9b5..eaae2d3 100644 --- a/backend/app/worker.py +++ b/backend/app/worker.py @@ -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']) diff --git a/backend/tests/test_comparison.py b/backend/tests/test_comparison.py new file mode 100644 index 0000000..db8fee8 --- /dev/null +++ b/backend/tests/test_comparison.py @@ -0,0 +1,227 @@ +import copy +import json +import re +import time +from pathlib import Path +from xml.etree.ElementTree import fromstring +import pytest +from sqlalchemy import update +from app.diff import compare_documents +from app.parser.schdoc_parser import parse_schdoc +from app.render import svg_document +from app.db import Job,claim,sessions +from app.storage import storage +from app.worker import process +from test_platform import client,KEY + + +def parse(raw,name='test.SchDoc'):return parse_schdoc(raw,name,'test') +def compare(a,b):return compare_documents(parse(a),parse(b)) +def create(client,old,new,**fields): + return client.post('/api/compare',headers={'X-API-Key':KEY}, + files={'old_file':('old.SchDoc',old),'new_file':('new.SchDoc',new)},data=fields) + + +def test_identical_and_record_reordering(ascii_schematic): + assert compare(ascii_schematic,ascii_schematic)['total_changed']==0 + lines=ascii_schematic.decode().splitlines();records=lines[1:] + order=list(reversed(range(len(records))));mapping={old:new for new,old in enumerate(order)} + shuffled=[re.sub(r'(?i)OwnerIndex=(\d+)',lambda m:f'OwnerIndex={mapping[int(m[1])]}',records[i]) for i in order] + result=compare(ascii_schematic,(lines[0]+'\n'+'\n'.join(shuffled)).encode()) + assert result['total_changed']==0,result['changes'] + + +def test_component_properties_and_renumbering_with_native_identity(ascii_schematic): + old=ascii_schematic.replace(b'LibReference=R_Symbol',b'UniqueId=COMPONENT01|LibReference=R_Symbol') + new=old.replace(b'Text=R1',b'Text=R9').replace(b'Text=4K7',b'Text=10K') + result=compare(old,new) + assert result['summary']=={'added':0,'removed':0,'modified':1,'moved':0} + change=result['changes'][0] + assert change['kind']=='component' and change['match']=='unique_id' + assert {'designator','value','parameters'}<={f['property'] for f in change['fields']} + + +def move_component(raw,dx=20,dy=10): + lines=[] + for line in raw.decode().splitlines(): + if '|RECORD=1|' in line or '|OwnerIndex=1|' in line: + line=re.sub(r'(Location|Corner)\.([XY])=(-?\d+)',lambda m:f'{m[1]}.{m[2]}={int(m[3])+(dx if m[2]=="X" else dy)}',line) + lines.append(line) + return ('\n'.join(lines)+'\n').encode() + + +def test_movement_without_inventing_internal_symbol_changes(ascii_schematic): + result=compare(ascii_schematic,move_component(ascii_schematic)) + assert result['summary']=={'added':0,'removed':0,'modified':0,'moved':1},result['changes'] + assert result['changes'][0]['movement']=={'dx':20,'dy':-10} + assert not result['changes'][0]['fields'] + both=compare(ascii_schematic,move_component(ascii_schematic).replace(b'Text=4K7',b'Text=10K')) + assert both['changes'][0]['moved'] and both['changes'][0]['status']=='modified' + + +def test_added_removed_component_and_wire(ascii_schematic): + added=ascii_schematic+b'''|RECORD=1|LibReference=C|UniqueId=CAP01|Location.X=300|Location.Y=400|CurrentPartId=1 +|RECORD=13|OwnerIndex=9|Location.X=300|Location.Y=400|Corner.X=310|Corner.Y=410 +|RECORD=34|OwnerIndex=9|Text=C2|Location.X=300|Location.Y=420 +|RECORD=27|UniqueId=WIRE_NEW|LocationCount=2|X1=300|Y1=400|X2=350|Y2=400 +''' + result=compare(ascii_schematic,added) + assert result['summary']['added']==2 and result['total_changed']==2 + assert {c['kind'] for c in result['changes']}=={'component','wire'} + assert compare(added,ascii_schematic)['summary']['removed']==2 + + +def test_nets_report_labels_not_compiled_connectivity(ascii_schematic): + result=compare(ascii_schematic,ascii_schematic.replace(b'Text=VCC',b'Text=VBAT')) + assert result['summary']['modified']==1 + assert result['nets']=={'added':['VBAT'],'removed':['VCC'],'changed':[]} + assert result['changes'][0]['kind']=='net_label' + assert any('compiled' in message for message in result['limitations']) + + +def test_wire_reversal_is_not_an_electrical_or_visual_change(ascii_schematic): + new=ascii_schematic.replace(b'X1=100|Y1=230|X2=150|Y2=230',b'X1=150|Y1=230|X2=100|Y2=230') + assert compare(ascii_schematic,new)['total_changed']==0 + + +def test_duplicates_are_not_arbitrary_matches(ascii_schematic): + extra=b'|RECORD=27|LocationCount=2|X1=10|Y1=10|X2=20|Y2=10\n' + assert compare(ascii_schematic+extra*2,ascii_schematic+extra*2)['total_changed']==0 + result=compare(ascii_schematic+extra*2,ascii_schematic+extra) + assert result['summary']['removed']==1 and result['total_changed']==1 + + +def test_report_does_not_mutate_documents_and_embedded_image_changes(ascii_schematic): + from app.models.schematic import Primitive,Bounds,Point + old=parse(ascii_schematic);new=old.model_copy(deep=True) + before=old.model_dump_json();compare_documents(old,new);assert old.model_dump_json()==before + # Existing parsed image record with a changed binary raster, same filename. + obj=new.objects[-1].model_copy(deep=True);obj.id='image';obj.kind='image';obj.record_type=30 + obj.properties={'RECORD':'30','FILENAME':'logo.bmp','LOCATION.X':'0','LOCATION.Y':'0'};obj.primitive_ids=['image:p'];obj.component_id=None + old.objects.append(obj);new.objects.append(obj.model_copy(deep=True)) + p=Primitive(id='image:p',kind='image',object_id='image',bounds=Bounds(x=0,y=0,width=10,height=10),image_data='data:image/png;base64,OLD') + old.primitives.append(p);new.primitives.append(p.model_copy(update={'image_data':'data:image/png;base64,NEW'})) + assert compare_documents(old,new)['summary']['modified']==1 + + +def test_highlight_svg_preserves_geometry_and_distinct_clip_ids(ascii_schematic): + raw=ascii_schematic+b'|RECORD=28|Location.X=1|Corner.X=20|Corner.Y=20|ClipToRect=T|Text=note\n' + doc=parse(raw);item=doc.objects[-1] + annotations={item.id:{'id':'change-0','status':'modified','bounds':item.bounds.model_dump()}} + svg=svg_document(doc,annotations,id_prefix='old-');tree=fromstring(svg) + assert b'data-diff-box="true"' in svg and b'data-change-id="change-0"' in svg + assert b'old-clip-' in svg and b'url(#old-clip-' in svg + assert b'M100,-230 L150,-230' in svg + assert not list(tree.iter('{http://www.w3.org/2000/svg}script')) + + +def test_comparison_api_worker_private_results_and_cleanup(client,ascii_schematic): + response=create(client,ascii_schematic,ascii_schematic.replace(b'Text=4K7',b'Text=10K')) + assert response.status_code==202,response.text + created=response.json();identifier=created['comparison_id'];headers={'X-Viewer-Token':created['viewer_token']} + assert '#token=' in created['url'] and created['status']=='queued' + base='/api/v1/comparisons/'+identifier + assert client.get(base).status_code==404 + assert client.get(base,headers={'X-Viewer-Token':'bad'}).status_code==404 + assert client.get(base+'/svg/old',headers=headers).status_code==409 + job=claim();process(job) + response=client.get(base,headers=headers);result=response.json() + assert result['status']=='completed',result + assert result['summary']['modified']==1 + assert client.get('/api/compare/'+identifier,headers=headers).json()['changes']==result['changes'] + for side in ('old','new'): + r=client.get(base+'/svg/'+side,headers=headers) + assert r.status_code==200 and b'data-diff-box' in r.content + assert client.get('/compare/'+identifier).status_code==200 + assert client.get('/compare').status_code==200 + for asset in ['compare.js','compare.css','interactions.js','compare-upload.js']: + assert client.get('/assets/'+asset).status_code==200 + with sessions().begin() as db:db.execute(update(Job).where(Job.id==identifier).values(expires_at=time.time()-1)) + assert client.get(base,headers=headers).status_code==410 + from app.cleanup import cleanup + assert cleanup()==1 + with pytest.raises(FileNotFoundError):storage().get(identifier+'/old.SchDoc') + + +def test_comparison_validation_auth_and_queue_limit(client,ascii_schematic,monkeypatch): + assert client.post('/api/compare').status_code==401 + assert client.post('/api/v1/comparisons').status_code==401 + assert create(client,ascii_schematic,b'garbage').status_code==422 + assert create(client,ascii_schematic,ascii_schematic,expires_in=999999999).status_code==422 + assert create(client,b'x'*(1048576+1),ascii_schematic).status_code==413 + monkeypatch.setenv('MAX_PENDING_JOBS','1') + from app.config import settings + settings.cache_clear() + assert create(client,ascii_schematic,ascii_schematic).status_code==202 + assert create(client,ascii_schematic,ascii_schematic).status_code==503 + + +def test_corrupt_second_file_fails_durably_and_unlisted(client,ascii_schematic): + response=create(client,ascii_schematic,b'\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1'+b'\0'*504,access='unlisted') + assert response.status_code==202 + process(claim()) + result=client.get('/api/compare/'+response.json()['id']).json() + assert result['status']=='failed' and result['error']['code']=='invalid_schematic' + + +def test_comparison_cross_origin_preflight_and_creation(client,ascii_schematic): + origin='https://git.sadentel.net' + r=client.options('/api/compare',headers={'Origin':origin,'Access-Control-Request-Method':'POST','Access-Control-Request-Headers':'x-api-key'}) + assert r.status_code==200 and r.headers['access-control-allow-origin']==origin + r=client.post('/api/compare',headers={'Origin':origin}) + assert r.status_code==401 and r.headers['access-control-allow-origin']==origin + + +def test_partial_storage_failure_leaves_no_comparison_job(client,ascii_schematic,monkeypatch): + original=storage().put;calls=[] + def failing(key,body,*args,**kw): + calls.append(key) + if len(calls)==2:raise RuntimeError('S3 unavailable') + return original(key,body,*args,**kw) + monkeypatch.setattr(storage(),'put',failing) + assert create(client,ascii_schematic,ascii_schematic).status_code==503 + assert claim() is None + assert not storage().path(calls[0]).exists() + + +def test_native_ole_comparison_preserves_original_geometry(client): + fixture=Path(__file__).resolve().parents[2]/'.runtime/fixtures/Channel.SchDoc' + if not fixture.is_file():pytest.skip('Fetch the native fixture') + response=create(client,fixture.read_bytes(),fixture.read_bytes());assert response.status_code==202 + process(claim()) + identifier=response.json()['id'];headers={'X-Viewer-Token':response.json()['viewer_token']} + result=client.get('/api/v1/comparisons/'+identifier,headers=headers).json() + assert result['status']=='completed',result + assert result['total_changed']==0 + assert result['old']['sha256']==result['new']['sha256'] + assert b'data-object-id' in client.get(f'/api/v1/comparisons/{identifier}/svg/old',headers=headers).content + + +def test_comparison_request_body_is_bounded(client): + response=client.post('/api/compare',content=b'x'*(4*1048576), + headers={'X-API-Key':KEY,'Content-Type':'multipart/form-data; boundary=foo'}) + assert response.status_code==413 + + +def test_comparison_with_valid_empty_sheet_keeps_single_parser_behavior(client,ascii_schematic): + from app.parser.record_decoder import ParseError + empty=b'|HEADER=Protel for Windows - Schematic Capture Ascii File Version 5.0\n|RECORD=31|FontIdCount=1|Size1=10|FontName1=Arial\n' + with pytest.raises(ParseError,match='No supported visible geometry'):parse(empty) + response=create(client,ascii_schematic,empty);assert response.status_code==202 + process(claim());headers={'X-Viewer-Token':response.json()['viewer_token']} + result=client.get('/api/compare/'+response.json()['id'],headers=headers).json() + assert result['status']=='completed',result + assert result['summary']['removed']==3 + assert result['summary']['added']==0 + svg=client.get('/api/v1/comparisons/'+response.json()['id']+'/svg/new',headers=headers) + assert svg.status_code==200 and b' +Compare Altium schematics

NATIVE ALTIUM · STRUCTURAL DIFF

Compare schematics.

Select the original and modified SchDoc files.

Comparison uses native objects and saved geometry. Connection graphics are compared without a compiled electrical netlist.

Single schematic viewer
diff --git a/frontend/compare-upload.js b/frontend/compare-upload.js new file mode 100644 index 0000000..0a766e0 --- /dev/null +++ b/frontend/compare-upload.js @@ -0,0 +1,12 @@ +const form=document.querySelector('#compare-upload'),result=document.querySelector('#result'); +form.addEventListener('submit',async e=>{ + e.preventDefault();const button=form.querySelector('button');button.disabled=true;result.textContent='Uploading both schematics…'; + const headers={},key=document.querySelector('#key').value; + if(key)headers['X-API-Key']=key; + const body=new FormData();body.append('old_file',document.querySelector('#old-file').files[0]);body.append('new_file',document.querySelector('#new-file').files[0]); + try { + const response=await fetch('/api/v1/comparisons',{method:'POST',headers,body});const data=await response.json(); + if(!response.ok)throw new Error(data.error?.message||'Comparison upload failed'); + result.textContent='Queued. ';const link=document.createElement('a');link.href=data.url;link.textContent='Open comparison';result.append(link); + }catch(error){result.textContent=error.message;}finally{button.disabled=false;} +}); diff --git a/frontend/compare.css b/frontend/compare.css new file mode 100644 index 0000000..a1139ac --- /dev/null +++ b/frontend/compare.css @@ -0,0 +1,34 @@ +body { display: flex; flex-direction: column; font-family: system-ui,sans-serif; color: #233431; background: #f4f6f5; } +* { box-sizing: border-box; } +header { display: flex; align-items: center; flex-wrap: wrap; gap: 14px; padding: 12px 16px; border-bottom: 1px solid #d6dfda; } +header > a { color: #244d49; font-size: 15px; font-weight: 650; text-decoration: none; } +#summary, .controls { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; font-size: 12px; } +.controls { margin-left: auto; } +label { display: inline-flex; gap: 4px; align-items: center; } +button, select { border: 1px solid #cbd5ce; border-radius: 4px; padding: 5px 9px; background: white; color: inherit; font: inherit; cursor: pointer; } +button:focus-visible, select:focus-visible, .surface:focus-visible { outline: 2px solid #2676c5; outline-offset: -2px; } +#scope { margin: 0; padding: 7px 16px; font-size: 11px; color: #60716b; border-bottom: 1px solid #d6dfda; } +.workspace { flex: 1; min-height: 0; display: grid; grid-template-columns: minmax(0,1fr) minmax(0,1fr) 270px; } +.pane { min-width: 0; min-height: 0; display: flex; flex-direction: column; border-right: 1px solid #d6dfda; } +h2 { margin: 0; padding: 9px 12px; font-size: 12px; font-weight: 650; background: #edf1ee; display: flex; gap: 10px; } +h2 span { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-weight: normal; color: #61746b; } +.surface { flex: 1; min-height: 0; width: 100%; overflow: hidden; background: white; touch-action: none; user-select: none; cursor: grab; } +.surface:active { cursor: grabbing; } +.surface > svg { display: block; width: 100%; height: 100%; } +aside { display: flex; flex-direction: column; min-height: 0; overflow: hidden; background: #fafcfb; } +.change-controls { display: flex; align-items: center; gap: 6px; padding: 10px; font-size: 12px; } +.change-controls label { margin-right: auto; } +#changes { flex: 1; min-height: 0; overflow: auto; padding: 0 8px; } +#changes button { display: flex; text-align: left; align-items: center; gap: 8px; width: 100%; margin-bottom: 5px; border-left: 3px solid currentColor; font-size: 12px; padding: 10px; } +#changes button small { margin-left: auto; color: #64756d; font-size: 10px; } +#changes button[aria-current="true"] { background: #e5eee9; box-shadow: inset 0 0 0 1px #8daba0; } +.added { color: #16854b; }.removed { color: #cf3545; }.modified { color: #b87b09; }.moved { color: #2676c5; } +#details { max-height: 35%; overflow: auto; margin: 0; padding: 10px; white-space: pre-wrap; overflow-wrap: anywhere; border-top: 1px solid #d6dfda; font-size: 11px; } +#details:empty { display: none; } +[data-diff-hidden] { display: none; } +.no-highlights [data-diff-box] { display: none; } +[data-diff-box][data-selected] { stroke-width: 3; fill-opacity: .18; } +@media (max-width: 900px) { .workspace { grid-template-columns: minmax(0,1fr) minmax(0,1fr); grid-template-rows: minmax(0,1fr) 180px; } aside { grid-column: 1/-1; } #details { max-height: 40%; } } +@media (max-width: 550px) { .workspace { grid-template-columns: 1fr; grid-template-rows: minmax(0,1fr) minmax(0,1fr) 140px; } aside { grid-column: auto; } header { gap: 8px; }.controls { margin-left: 0; } } + +#changes button[hidden] { display: none; } diff --git a/frontend/compare.html b/frontend/compare.html new file mode 100644 index 0000000..e790ade --- /dev/null +++ b/frontend/compare.html @@ -0,0 +1,6 @@ + +Schematic comparison +
Schematic Diff
+

Native object comparison · Connection graphics, not a compiled electrical netlist.

+

Original

Modified

+Loading comparison… diff --git a/frontend/compare.js b/frontend/compare.js new file mode 100644 index 0000000..b46864f --- /dev/null +++ b/frontend/compare.js @@ -0,0 +1,87 @@ +import { bindCamera } from './interactions.js'; +const state=document.querySelector('#state'),id=location.pathname.split('/').pop(); +const token=new URLSearchParams(location.hash.slice(1)).get('token')||sessionStorage.getItem(`comparison:${id}`); +if(token)sessionStorage.setItem(`comparison:${id}`,token); +if(location.hash)history.replaceState(null,'',location.pathname); +const headers=token?{'X-Viewer-Token':token}:{},base=`/api/v1/comparisons/${id}`; +const controller=new AbortController(),views=[]; +addEventListener('pagehide',()=>{controller.abort();for(const view of views)view.destroy();},{once:true}); +async function request(path='',text=false){ + const response=await fetch(base+path,{headers,signal:controller.signal,cache:'no-store'}); + if(!response.ok){const body=await response.json();throw new Error(body.error?.message||'Comparison unavailable');} + return text?response.text():response.json(); +} +function union(boxes){ + const x=Math.min(...boxes.map(b=>b.x)),y=Math.min(...boxes.map(b=>b.y)); + return {x,y,width:Math.max(...boxes.map(b=>b.x+b.width))-x,height:Math.max(...boxes.map(b=>b.y+b.height))-y}; +} +async function start(){ + let result; + for(;;){ + result=await request();if(result.status==='completed')break; + if(result.status==='failed')throw new Error(result.error?.message||'Comparison failed'); + await new Promise(resolve=>setTimeout(resolve,1000)); + } + const texts=await Promise.all(['old','new'].map(side=>request('/svg/'+side,true))); + const svgs=texts.map(text=>{ + const parsed=new DOMParser().parseFromString(text,'image/svg+xml'); + if(parsed.querySelector('parsererror')||parsed.documentElement.localName!=='svg')throw new Error('Invalid rendering artifact'); + return document.importNode(parsed.documentElement,true); + }); + const content=[result.old.bounds,result.new.bounds].filter(b=>b&&b.width>0&&b.height>0); + const bounds=union(content.length?content:[result.old.sheet,result.new.sheet]); + for(const [index,side] of ['old','new'].entries()){ + document.querySelector(`#${side}-name`).textContent=result[side].filename; + const surface=document.querySelector(`#${side}-surface`);surface.append(svgs[index]); + surface.style.backgroundColor=svgs[index].querySelector('rect')?.getAttribute('fill')||'#ffffff'; + views.push(bindCamera(surface,svgs[index],bounds,source=>{ + if(document.querySelector('#sync').checked)for(const other of views)if(other!==source)other.apply(source.pose()); + })); + } + await document.fonts.ready; + state.classList.remove('loading');state.textContent=''; + const changes=result.changes,byId=new Map(changes.map(c=>[c.id,c])),rows=new Map(),nodes=svgs.flatMap(svg=>[...svg.querySelectorAll('[data-change-id]')]); + const list=document.querySelector('#changes'),filter=document.querySelector('#filter'); + let selected=null; + for(const [status,count]of Object.entries(result.summary)){ + const badge=document.createElement('span');badge.className=status;badge.textContent=`${count} ${status}`;document.querySelector('#summary').append(badge); + } + if(!changes.length)list.textContent='No structural changes detected.'; + function visibleChanges(){return changes.filter(c=>filter.value==='all'||c.status===filter.value);} + function select(change){ + selected=change.id; + for(const [id,row]of rows)row.setAttribute('aria-current',String(id===selected)); + for(const node of nodes){if(node.dataset.changeId===selected)node.setAttribute('data-selected','');else node.removeAttribute('data-selected');} + const boxes=[change.old_bounds,change.new_bounds].filter(Boolean); + if(boxes.length)for(const view of views)view.focus(union(boxes)); + const details={kind:change.kind,match:change.match||'unmatched'}; + if(change.moved)details.movement=change.movement; + if(change.fields.length)details.fields=change.fields; + document.querySelector('#details').textContent=JSON.stringify(details,null,2); + rows.get(selected)?.scrollIntoView({block:'nearest'}); + } + for(const change of changes){ + const button=document.createElement('button');button.type='button';button.className=change.status; + const label=document.createElement('span');label.textContent=change.label; + const kind=document.createElement('small');kind.textContent=change.status+(change.moved&&change.status==='modified'?', moved':'')+' · '+change.kind; + button.append(label,kind);button.addEventListener('click',()=>select(change));list.append(button);rows.set(change.id,button); + } + function applyFilters(){ + for(const node of nodes){ + const change=byId.get(node.dataset.changeId); + const hidden=change?(filter.value!=='all'&&change.status!==filter.value):(filter.value!=='all'||document.querySelector('#only-changes').checked); + node.toggleAttribute('data-diff-hidden',hidden); + } + for(const change of changes)rows.get(change.id).hidden=filter.value!=='all'&&change.status!==filter.value; + document.body.classList.toggle('no-highlights',!document.querySelector('#highlights').checked); + } + for(const element of [filter,document.querySelector('#only-changes'),document.querySelector('#highlights')])element.addEventListener('change',applyFilters); + document.querySelector('#fit').addEventListener('click',()=>{for(const view of views)view.fit();}); + document.querySelector('#sync').addEventListener('change',()=>{if(document.querySelector('#sync').checked&&views[0])views[1].apply(views[0].pose());}); + function next(delta){const candidates=visibleChanges();if(!candidates.length)return;const index=candidates.findIndex(c=>c.id===selected);select(candidates[(index+delta+candidates.length)%candidates.length]);} + document.querySelector('#previous').addEventListener('click',()=>next(-1));document.querySelector('#next').addEventListener('click',()=>next(1)); + for(const svg of svgs)svg.addEventListener('click',event=>{const id=event.target.closest('[data-change-id]')?.dataset.changeId;if(byId.has(id))select(byId.get(id));}); + applyFilters(); + function expire(){const remaining=result.expires_at*1000-Date.now();if(remaining<=0){for(const view of views)view.destroy();document.querySelector('.workspace').replaceChildren();state.textContent='This comparison link has expired';}else setTimeout(expire,Math.min(2147483647,remaining));}expire(); +} +start().catch(error=>{state.classList.remove('loading');state.textContent=error.name==='AbortError'?'':error.message;}); diff --git a/frontend/docs.html b/frontend/docs.html index c566219..a34eaa0 100644 --- a/frontend/docs.html +++ b/frontend/docs.html @@ -1 +1 @@ -Schematic API reference

API V1

Schematic rendering API

Authenticate uploads with X-API-Key. Native binary OLE and ASCII SchDoc format 5.0 are accepted. Files are parsed asynchronously; HTTP 202 confirms admission, not rendering fidelity.

Upload

POST /api/v1/schematics/render

Multipart fields: file (required), access (private or unlisted; defaults to private), expires_in (optional seconds, bounded by server policy).

Poll

GET /api/v1/schematics/{id}/status

States: queued, processing, completed, failed. Private reads require X-Viewer-Token from the upload response, or the service API key. Completed responses include compatibility and diagnostics.

Retrieve

GET /api/v1/schematics/{id}/data returns the versioned structured document. GET /api/v1/schematics/{id}/svg returns the generated vector drawing.

View

Open the exact viewer_url from the upload response. The private token is in its fragment. It is stored for this browser tab and sent only as a request header. The viewer has no panels or toolbar.

Errors

JSON errors include success: false and error.code / error.message. Common HTTP codes: 401 invalid service key; 404 missing or inaccessible schematic; 409 still processing; 410 expired; 413 too large; 415 wrong extension; 422 invalid schematic; 503 storage unavailable or queue full.

Download OpenAPI schema · Upload a schematic

+Schematic API reference

API V1

Schematic rendering API

Authenticate uploads with X-API-Key. Native binary OLE and ASCII SchDoc format 5.0 are accepted. Files are parsed asynchronously; HTTP 202 confirms admission, not rendering fidelity.

Upload

POST /api/v1/schematics/render

Multipart fields: file (required), access (private or unlisted; defaults to private), expires_in (optional seconds, bounded by server policy).

Poll

GET /api/v1/schematics/{id}/status

States: queued, processing, completed, failed. Private reads require X-Viewer-Token from the upload response, or the service API key. Completed responses include compatibility and diagnostics.

Retrieve

GET /api/v1/schematics/{id}/data returns the versioned structured document. GET /api/v1/schematics/{id}/svg returns the generated vector drawing.

View

Open the exact viewer_url from the upload response. The private token is in its fragment. It is stored for this browser tab and sent only as a request header. The viewer has no panels or toolbar.

Compare two schematics

POST /api/v1/comparisons (alias /api/compare) accepts multipart old_file and new_file with the same API key. It returns HTTP 202 and a private comparison URL. Poll GET /api/v1/comparisons/{id} using the returned read token for the completed summary and structural changes. Open comparison upload.

Errors

JSON errors include success: false and error.code / error.message. Common HTTP codes: 401 invalid service key; 404 missing or inaccessible schematic; 409 still processing; 410 expired; 413 too large; 415 wrong extension; 422 invalid schematic; 503 storage unavailable or queue full.

Download OpenAPI schema · Upload a schematic

diff --git a/frontend/index.html b/frontend/index.html index bf4b376..47bd9db 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,2 +1,2 @@ -Altium schematic upload

NATIVE ALTIUM · SCHDOC

Open your schematic.

Upload a native schematic to create a private, expiring viewer link.

Compatibility diagnostics are returned by the API. Review them before relying on visual fidelity.

API reference
+Altium schematic upload

NATIVE ALTIUM · SCHDOC

Open your schematic.

Upload a native schematic to create a private, expiring viewer link.

Compatibility diagnostics are returned by the API. Review them before relying on visual fidelity.

Compare two schematics · API reference
diff --git a/frontend/interactions.js b/frontend/interactions.js new file mode 100644 index 0000000..1280db0 --- /dev/null +++ b/frontend/interactions.js @@ -0,0 +1,56 @@ +import { Camera } from './camera.js'; + +/** Pointer/touch camera adapter shared by both comparison panes. */ +export function bindCamera(surface, svg, bounds, onChange = () => {}) { + const camera = new Camera(bounds, surface.clientWidth, surface.clientHeight); + let frame = 0; + const pointers = new Map(); + function paint(notify = true) { + if (!frame) frame = requestAnimationFrame(() => { frame = 0; svg.setAttribute('viewBox', camera.viewBox); }); + if (notify) onChange(view); + } + const view = { camera, paint, + pose() { return { center: camera.world(camera.width/2,camera.height/2), scale: camera.scale }; }, + apply({center,scale}) { camera.scale=scale; camera.x=camera.width/2-center.x*scale; camera.y=camera.height/2-center.y*scale; paint(false); }, + fit() { camera.fit(); paint(); }, + focus(box) { + const scale=Math.min(camera.width/Math.max(20,box.width+30),camera.height/Math.max(20,box.height+30),camera.fitScale*100); + view.apply({center:{x:box.x+box.width/2,y:box.y+box.height/2},scale}); onChange(view); + }, + destroy() { cancelAnimationFrame(frame); observer.disconnect(); events.abort(); } + }; + const events = new AbortController(); const options={ signal:events.signal }; + surface.addEventListener('wheel',e => { + e.preventDefault();const rect=surface.getBoundingClientRect(); + const delta=e.deltaY*(e.deltaMode===1?16:e.deltaMode===2?camera.height:1); + camera.zoom(Math.exp(-Math.max(-400,Math.min(400,delta))*.002),e.clientX-rect.left,e.clientY-rect.top);paint(); + },{...options,passive:false}); + surface.addEventListener('pointerdown',e => { + if(e.button!==0)return;surface.focus();surface.setPointerCapture(e.pointerId); + pointers.set(e.pointerId,{x:e.clientX,y:e.clientY}); + },options); + function metrics() { + const p=[...pointers.values()], rect=surface.getBoundingClientRect(); + return { x:p.reduce((s,p)=>s+p.x,0)/p.length-rect.left,y:p.reduce((s,p)=>s+p.y,0)/p.length-rect.top, + distance:p.length>1?Math.hypot(p[0].x-p[1].x,p[0].y-p[1].y):0 }; + } + surface.addEventListener('pointermove',e => { + if(!pointers.has(e.pointerId))return; + const before=metrics();pointers.set(e.pointerId,{x:e.clientX,y:e.clientY});const after=metrics(); + if(before.distance&&after.distance)camera.zoom(after.distance/before.distance,before.x,before.y); + camera.pan(after.x-before.x,after.y-before.y);paint(); + },options); + for(const type of ['pointerup','pointercancel','lostpointercapture'])surface.addEventListener(type,e=>pointers.delete(e.pointerId),options); + surface.addEventListener('dblclick',()=>view.fit(),options); + surface.addEventListener('keydown',e=>{ + if(e.key==='Home'||e.key==='0')camera.fit(); + else if(e.key==='+'||e.key==='=')camera.zoom(1.25,camera.width/2,camera.height/2); + else if(e.key==='-')camera.zoom(.8,camera.width/2,camera.height/2); + else if(e.key.startsWith('Arrow'))camera.pan(e.key==='ArrowLeft'?40:e.key==='ArrowRight'?-40:0,e.key==='ArrowUp'?40:e.key==='ArrowDown'?-40:0); + else return;e.preventDefault();paint(); + },options); + const observer=new ResizeObserver(()=>{ + const pose=view.pose();camera.resize(surface.clientWidth,surface.clientHeight); + camera.fitScale=Math.min(camera.width/bounds.width,camera.height/bounds.height);view.apply(pose); + });observer.observe(surface);paint(false);return view; +} diff --git a/frontend/interactions.test.js b/frontend/interactions.test.js new file mode 100644 index 0000000..ea8e391 --- /dev/null +++ b/frontend/interactions.test.js @@ -0,0 +1,46 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { bindCamera } from './interactions.js'; +const close=(a,b)=>assert.ok(Math.abs(a-b)<1e-8,`${a} != ${b}`); +function setup(t) { + const saved={raf:globalThis.requestAnimationFrame,caf:globalThis.cancelAnimationFrame,observer:globalThis.ResizeObserver}; + const frames=[]; + globalThis.requestAnimationFrame=fn=>{frames.push(fn);return frames.length;};globalThis.cancelAnimationFrame=()=>{}; + globalThis.ResizeObserver=class {observe(){}disconnect(){}}; + t.after(()=>{globalThis.requestAnimationFrame=saved.raf;globalThis.cancelAnimationFrame=saved.caf;globalThis.ResizeObserver=saved.observer;}); + function surface(left=100) { + const surface=new EventTarget();Object.assign(surface,{clientWidth:800,clientHeight:600, + getBoundingClientRect:()=>({left,top:20}),focus(){},setPointerCapture(){}}); + const attributes={};return {surface,svg:{setAttribute:(k,v)=>attributes[k]=v},attributes}; + } + return {surface,flush:()=>{for(const fn of frames.splice(0))fn();}}; +} +function send(surface,type,values){const event=new Event(type,{cancelable:true});Object.assign(event,values);surface.dispatchEvent(event);return event;} + +test('wheel zoom uses the pane offset and synchronizes world cameras',t=>{ + const env=setup(t),a=env.surface(),b=env.surface(1000),bounds={x:0,y:-760,width:1150,height:760}; + let second;const first=bindCamera(a.surface,a.svg,bounds,source=>second.apply(source.pose()));second=bindCamera(b.surface,b.svg,bounds); + const anchor=first.camera.world(250,200); + const event=send(a.surface,'wheel',{deltaY:-120,deltaMode:0,clientX:350,clientY:220}); + assert.ok(event.defaultPrevented); + close(first.camera.world(250,200).x,anchor.x);close(first.camera.world(250,200).y,anchor.y); + close(first.pose().center.x,second.pose().center.x);close(first.camera.scale,second.camera.scale); + env.flush();assert.equal(a.attributes.viewBox,b.attributes.viewBox);first.destroy();second.destroy(); +}); + +test('two-pointer pinch preserves the moving center without rasterizing',t=>{ + const env=setup(t),{surface,svg}=env.surface();const view=bindCamera(surface,svg,{x:0,y:0,width:100,height:100}); + send(surface,'pointerdown',{pointerId:1,button:0,clientX:200,clientY:200}); + send(surface,'pointerdown',{pointerId:2,button:0,clientX:300,clientY:200}); + const anchor=view.camera.world(150,180),scale=view.camera.scale; + send(surface,'pointermove',{pointerId:2,clientX:400,clientY:200}); + close(view.camera.scale,scale*2);close(view.camera.world(200,180).x,anchor.x);close(view.camera.world(200,180).y,anchor.y); + view.destroy(); +}); + +test('focus fits change bounds and detaching stops interaction',t=>{ + const env=setup(t),{surface,svg}=env.surface();const view=bindCamera(surface,svg,{x:0,y:0,width:1000,height:1000}); + view.focus({x:100,y:200,width:20,height:40});const center=view.pose().center; + close(center.x,110);close(center.y,220);view.destroy();const scale=view.camera.scale; + send(surface,'wheel',{deltaY:-120,deltaMode:0,clientX:200,clientY:200});close(view.camera.scale,scale); +}); diff --git a/scripts/compare-smoke.py b/scripts/compare-smoke.py new file mode 100644 index 0000000..5cbe2ec --- /dev/null +++ b/scripts/compare-smoke.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +"""Actual HTTP upload → durable comparison worker → private result/SVG check.""" +import argparse +import json +import os +from pathlib import Path +import time +import httpx +p=argparse.ArgumentParser();p.add_argument('old_file',type=Path);p.add_argument('new_file',type=Path);p.add_argument('--url',default='http://127.0.0.1:8000');args=p.parse_args() +with httpx.Client(base_url=args.url,timeout=60) as client: + response=client.post('/api/compare',headers={'X-API-Key':os.environ.get('API_KEY','')}, + files={'old_file':(args.old_file.name,args.old_file.read_bytes()),'new_file':(args.new_file.name,args.new_file.read_bytes())}) + assert response.status_code==202,response.text + created=response.json();identifier=created['comparison_id'];headers={'X-Viewer-Token':created['viewer_token']};base='/api/v1/comparisons/'+identifier + assert client.get(base).status_code==404 + deadline=time.monotonic()+120 + while time.monotonic()