| 1 | #!/usr/bin/env python3 |
| 2 | """Edit disposable notebook copies and check every changed document object.""" |
| 3 | import argparse |
| 4 | import copy |
| 5 | import hashlib |
| 6 | import json |
| 7 | from pathlib import Path |
| 8 | import shutil |
| 9 | import stat |
| 10 | import subprocess |
| 11 | import tempfile |
| 12 | |
| 13 | from document_model import DEFAULT_CONTEXT, EXPORTER, ordered_pages, view, walk |
| 14 | |
| 15 | ROOT = Path(__file__).resolve().parent.parent |
| 16 | EDITOR = ROOT / 'target/debug/examples/random_edit' |
| 17 | |
| 18 | |
| 19 | def export(path): |
| 20 | with tempfile.TemporaryDirectory() as temporary: |
| 21 | output = Path(temporary) / 'model' |
| 22 | subprocess.run([EXPORTER, path, output], check=True) |
| 23 | return (json.loads((output / 'document.json').read_text()), |
| 24 | json.loads((output / 'text.json').read_text()), |
| 25 | sorted(hashlib.sha256(p.read_bytes()).hexdigest() for p in (output / 'assets').iterdir())) |
| 26 | |
| 27 | |
| 28 | def verify(before, after, record): |
| 29 | old, old_text, old_assets = before |
| 30 | new, new_text, new_assets = after |
| 31 | assert old_assets == new_assets, 'An edit changed embedded payload bytes' |
| 32 | sid, oid = record['space'], record['object'] |
| 33 | old_rid, previous = view(old, sid) |
| 34 | new_rid, current = view(new, sid) |
| 35 | expected_runs = copy.deepcopy(old_text[sid][old_rid][oid]) |
| 36 | runs = previous['nodes'][oid]['kind']['runs'] |
| 37 | start, end = record['range'] |
| 38 | selected = max(i for i, run in enumerate(runs) if run['start'] <= start and end <= run['end']) |
| 39 | offset = runs[selected]['start'] |
| 40 | assert record['run_start'] == offset and record['run_before'] == expected_runs[selected]['text'], 'The CLI logged another insertion run' |
| 41 | encoded = expected_runs[selected]['text'].encode('utf-16-le') |
| 42 | prefix, suffix = encoded[:(start - offset) * 2], encoded[(end - offset) * 2:] |
| 43 | expected_runs[selected]['text'] = prefix.decode('utf-16-le') + record['replacement'] + suffix.decode('utf-16-le') |
| 44 | assert new_text[sid][new_rid][oid] == expected_runs, 'Text or inherited run formatting differs from the intended splice' |
| 45 | expected = copy.deepcopy(old) |
| 46 | space = expected['spaces'][sid] |
| 47 | space['contexts'][DEFAULT_CONTEXT] = new_rid |
| 48 | space['revisions'][new_rid] = copy.deepcopy(previous) |
| 49 | if old_rid not in space['contexts'].values(): del space['revisions'][old_rid] |
| 50 | node = space['revisions'][new_rid]['nodes'][oid] |
| 51 | encoded = node['kind']['text'].encode('utf-16-le') |
| 52 | node['kind']['text'] = encoded[:start * 2].decode('utf-16-le') + record['replacement'] + encoded[end * 2:].decode('utf-16-le') |
| 53 | revision = space['revisions'][new_rid] |
| 54 | page = revision['nodes'][record['page']] |
| 55 | titles = [(key, n['kind']['text']) for key, n in walk(revision, record['page']) |
| 56 | if n['kind']['type'] == 'RichText' and not n['kind']['boilerplate'] |
| 57 | and any(field['id'] == 0x88001cb4 for field in n['extra'][0])] |
| 58 | assert len(titles) <= 1 |
| 59 | title = titles[0][1].lstrip().split('\r')[0] if titles else '' |
| 60 | if not title or titles[0][0] == oid: |
| 61 | automatic = not title |
| 62 | if automatic: |
| 63 | body = copy.deepcopy(revision) if page['kind']['rtl'] else revision |
| 64 | if page['kind']['rtl']: |
| 65 | for item in body['nodes'].values(): |
| 66 | if item['kind']['type'] == 'Row': item['children'].reverse() |
| 67 | roots = sorted(page['children'], key=lambda key: (body['nodes'][key]['layout']['y'] or 0, |
| 68 | -(body['nodes'][key]['layout']['x'] or 0) |
| 69 | if page['kind']['rtl'] else body['nodes'][key]['layout']['x'] or 0)) |
| 70 | candidates = [n['kind']['text'].strip().split('\r')[0].rstrip() |
| 71 | for root in roots for _, n in walk(body, root) |
| 72 | if n['kind']['type'] == 'RichText' and not n['kind']['boilerplate']] |
| 73 | title = next((text for text in candidates if text), '') |
| 74 | encoded_title = title.encode('utf-16-le') |
| 75 | end = 510 |
| 76 | if len(encoded_title) > end and 0xd800 <= int.from_bytes(encoded_title[end - 2:end], 'little') <= 0xdbff: end += 2 |
| 77 | title = encoded_title[:end].decode('utf-16-le').rstrip() |
| 78 | revision['nodes'][revision['roots']['2']]['kind']['title'] = title |
| 79 | page['kind']['alternate_title'] = title if automatic else '' |
| 80 | observed = copy.deepcopy(new) |
| 81 | changed = observed['spaces'][sid]['revisions'][new_rid]['nodes'][oid] |
| 82 | assert record['started_ms'] // 1000 - 315532800 <= changed['modified'] <= record['finished_ms'] // 1000 - 315532800, 'Modification time falls outside the edit interval' |
| 83 | pending = [(record['page'], [])] |
| 84 | ancestors = set() |
| 85 | while pending: |
| 86 | key, path = pending.pop() |
| 87 | assert key not in path, 'Page content has a cycle' |
| 88 | if key == oid: |
| 89 | ancestors.update(path) |
| 90 | else: |
| 91 | parent = previous['nodes'][key] |
| 92 | pending.extend((child, path + [key]) for child in parent['children'] + parent['content'] + parent['structure']) |
| 93 | for key in ancestors: |
| 94 | if previous['nodes'][key]['modified'] is not None: |
| 95 | revision['nodes'][key]['modified'] = changed['modified'] |
| 96 | del node['modified'], changed['modified'] |
| 97 | del node['kind']['runs'], changed['kind']['runs'] |
| 98 | assert expected == observed, 'An edit changed unrelated document structure, metadata or properties' |
| 99 | |
| 100 | |
| 101 | def run(source, output, seed, rounds): |
| 102 | source = source.resolve(strict=True) |
| 103 | output = output.resolve() |
| 104 | if output.is_relative_to(source): raise ValueError('Choose an output directory outside the source notebook.') |
| 105 | output.mkdir(parents=True, exist_ok=False) |
| 106 | notebook = output / 'notebook' |
| 107 | shutil.copytree(source, notebook) |
| 108 | for file in notebook.rglob("*"): |
| 109 | if file.is_file(): file.chmod(file.stat().st_mode | stat.S_IWUSR) |
| 110 | hashes = {p.relative_to(source).as_posix(): hashlib.sha256(p.read_bytes()).hexdigest() |
| 111 | for p in source.rglob('*') if p.is_file()} |
| 112 | (output / 'source.json').write_text(json.dumps(hashes, indent=2)) |
| 113 | (output / 'run.json').write_text(json.dumps({'seed': seed, 'rounds': rounds, |
| 114 | 'editor_sha256': hashlib.sha256(EDITOR.read_bytes()).hexdigest(), |
| 115 | 'oracle_sha256': hashlib.sha256(Path(__file__).read_bytes()).hexdigest()}, indent=2)) |
| 116 | count = 0 |
| 117 | with (output / 'operations.jsonl').open('w') as log: |
| 118 | for file in sorted(notebook.rglob('*.one')): |
| 119 | before = export(file) |
| 120 | pages = [(sid, page) for sid, _, _, page in ordered_pages(before[0])] |
| 121 | for _ in range(rounds): |
| 122 | for sid, page in pages: |
| 123 | result = subprocess.run([EDITOR, file, '--in-place', str(seed + count), page], capture_output=True, text=True) |
| 124 | record = json.loads(result.stdout) if result.stdout else {} |
| 125 | record.update(file=file.relative_to(notebook).as_posix(), exit=result.returncode, stderr=result.stderr) |
| 126 | log.write(json.dumps(record, ensure_ascii=False) + '\n'); log.flush() |
| 127 | assert result.returncode == 0 and record.get('state') == 'Committed', f'Page edit failed: {record}' |
| 128 | after = export(file) |
| 129 | verify(before, after, record) |
| 130 | before = after |
| 131 | count += 1 |
| 132 | assert all(hashlib.sha256((source / path).read_bytes()).hexdigest() == digest for path, digest in hashes.items()), 'A source file changed during the campaign' |
| 133 | (output / 'result.json').write_text(json.dumps({'verified_edits': count, 'source_files_unchanged': len(hashes)}, indent=2)) |
| 134 | print(f'Verified {count} edits; {len(hashes)} source files unchanged') |
| 135 | |
| 136 | |
| 137 | if __name__ == '__main__': |
| 138 | parser = argparse.ArgumentParser(description=__doc__) |
| 139 | parser.add_argument('source', type=Path) |
| 140 | parser.add_argument('output', type=Path) |
| 141 | parser.add_argument('--seed', type=int, default=42) |
| 142 | parser.add_argument('--rounds', type=int, default=1) |
| 143 | args = parser.parse_args() |
| 144 | if args.rounds <= 0 or args.seed < 0: parser.error('Use positive rounds and a nonnegative seed.') |
| 145 | run(args.source, args.output, args.seed, args.rounds) |