|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Check spec/*.yml against the attributes iOS's handlers declare. |
| 3 | +
|
| 4 | + python3 tools/spec_vs_ios.py [path-to-phyphox-ios] |
| 5 | +
|
| 6 | +iOS states its accepted surface unusually plainly: every element handler carries |
| 7 | +an `enum Attribute` listing exactly what it reads, and registers its children by |
| 8 | +name. Walking that from the root element gives an independent inventory of the |
| 9 | +format to diff the spec against - one that does not depend on my having read the |
| 10 | +right lines. |
| 11 | +
|
| 12 | +It found a real structural error on its first run: <input> under <tone> and |
| 13 | +<noise> in the audio output, which the spec had modelled only under <audio>. |
| 14 | +
|
| 15 | +Three traps this has to avoid, all of which produced false findings before being |
| 16 | +handled: |
| 17 | +
|
| 18 | + * **Class bodies must be brace-matched.** A regex ending at the first `\\n}` |
| 19 | + runs past the end of a class and picks up the next one's attributes. That |
| 20 | + reported `experimentTime` on <events> (it belongs to <start>/<pause>) and |
| 21 | + `stride` on a bluetooth output (it belongs to a neighbouring sensor handler). |
| 22 | + * **Class names are not unique.** OutputElementHandler.swift declares its own |
| 23 | + private AudioElementHandler and BluetoothElementHandler, with the same names |
| 24 | + as the input block's. Keys are therefore file-qualified. |
| 25 | + * **childHandlers maps names to variables, not classes.** They have to be |
| 26 | + resolved through the `let x = SomeHandler()` bindings in the same class. |
| 27 | +
|
| 28 | +Anything iOS declares but never reads is listed in DECLARED_BUT_UNREAD rather |
| 29 | +than modelled: it is in the source, but it is not part of the format. |
| 30 | +""" |
| 31 | + |
| 32 | +import json |
| 33 | +import os |
| 34 | +import re |
| 35 | +import sys |
| 36 | + |
| 37 | +import yaml |
| 38 | + |
| 39 | +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) |
| 40 | +SPEC = os.path.join(ROOT, "spec") |
| 41 | +DEFAULT_IOS = os.path.join(os.path.dirname(ROOT), "phyphox-ios") |
| 42 | + |
| 43 | +# (parent, child) -> attributes iOS declares in its Attribute enum and never |
| 44 | +# reads. Checked by hand; each is dead code rather than part of the format. |
| 45 | +DECLARED_BUT_UNREAD = { |
| 46 | + ("bluetooth", "output"): {"stride"}, |
| 47 | + ("dropdown", "map"): {"replacement"}, |
| 48 | + ("tone", "input"): {"clear"}, |
| 49 | + ("noise", "input"): {"clear"}, |
| 50 | + ("input", "bluetooth"): {"mtu"}, # honoured on Android, ignored here |
| 51 | + ("phyphox", "events"): {"experimentTime", "systemTime"}, # copy of its child's enum |
| 52 | +} |
| 53 | + |
| 54 | +# Elements iOS reaches through a computed childHandler rather than a literal |
| 55 | +# switch, so the walk cannot see them. Checked by hand instead. |
| 56 | +# events/start, events/pause - accepted via TimeMappingEvent(rawValue:) |
| 57 | +# analysis/<module> - any name, dispatched by ExperimentAnalysisFactory |
| 58 | + |
| 59 | + |
| 60 | +def class_bodies(src): |
| 61 | + """Yield (class_name, body) with bodies delimited by brace matching.""" |
| 62 | + for m in re.finditer(r'class (\w+)\s*:[^{]*\{', src): |
| 63 | + depth, i = 1, m.end() |
| 64 | + while i < len(src) and depth: |
| 65 | + if src[i] == "{": |
| 66 | + depth += 1 |
| 67 | + elif src[i] == "}": |
| 68 | + depth -= 1 |
| 69 | + i += 1 |
| 70 | + yield m.group(1), src[m.end():i - 1] |
| 71 | + |
| 72 | + |
| 73 | +def scan(ios_root): |
| 74 | + handlers_dir = os.path.join(ios_root, "phyphox-iOS", "phyphox", |
| 75 | + "Experiments", "Serialization", "Handlers") |
| 76 | + handlers = {} |
| 77 | + for dirpath, _, files in os.walk(handlers_dir): |
| 78 | + for fn in files: |
| 79 | + if not fn.endswith(".swift"): |
| 80 | + continue |
| 81 | + src = open(os.path.join(dirpath, fn), encoding="utf-8").read() |
| 82 | + for cls, body in class_bodies(src): |
| 83 | + attrs = [] |
| 84 | + for em in re.finditer(r'enum Attribute[^{]*\{(.*?)\n\s*\}', body, re.S): |
| 85 | + for c in re.finditer(r'case (\w+)(?:\s*=\s*"([^"]+)")?', em.group(1)): |
| 86 | + attrs.append(c.group(2) or c.group(1)) |
| 87 | + binding = dict(re.findall( |
| 88 | + r'(?:let|var) (\w+)\s*(?::\s*\w+)?\s*=\s*(\w+ElementHandler)\(\)', body)) |
| 89 | + kids = {} |
| 90 | + ch = re.search(r'childHandlers\s*=\s*\[(.*?)\]', body, re.S) |
| 91 | + if ch: |
| 92 | + for name, var in re.findall(r'"([\w\-]+)"\s*:\s*(\w+)', ch.group(1)): |
| 93 | + kids[name] = (fn, binding.get(var, var)) |
| 94 | + for cm in re.finditer(r'case "([\w\-]+)":\s*\n?\s*handler = (\w+)', body): |
| 95 | + kids[cm.group(1)] = (fn, binding.get(cm.group(2), cm.group(2))) |
| 96 | + handlers[f"{fn}::{cls}"] = {"attrs": sorted(set(attrs)), "children": kids} |
| 97 | + return handlers |
| 98 | + |
| 99 | + |
| 100 | +def walk(handlers): |
| 101 | + def resolve(hint, cls): |
| 102 | + if f"{hint}::{cls}" in handlers: |
| 103 | + return f"{hint}::{cls}" |
| 104 | + return next((k for k in handlers if k.endswith(f"::{cls}")), None) |
| 105 | + |
| 106 | + root = "PhyphoxElementHandler.swift::PhyphoxElementHandler" |
| 107 | + pairs, frontier, seen = {}, [("phyphox", root)], set() |
| 108 | + while frontier: |
| 109 | + pname, pkey = frontier.pop() |
| 110 | + if (pname, pkey) in seen: |
| 111 | + continue |
| 112 | + seen.add((pname, pkey)) |
| 113 | + for cname, (fn, ccls) in (handlers.get(pkey, {}).get("children") or {}).items(): |
| 114 | + ckey = resolve(fn, ccls) |
| 115 | + if ckey: |
| 116 | + pairs[(pname, cname)] = ckey |
| 117 | + frontier.append((cname, ckey)) |
| 118 | + return pairs |
| 119 | + |
| 120 | + |
| 121 | +def spec_attributes(): |
| 122 | + out = {} |
| 123 | + for fn in sorted(os.listdir(SPEC)): |
| 124 | + if not fn.endswith(".yml") or fn == "rules.yml": |
| 125 | + continue |
| 126 | + doc = yaml.safe_load(open(os.path.join(SPEC, fn), encoding="utf-8")) or {} |
| 127 | + common = {} |
| 128 | + for group, items in (doc.get("common") or {}).items(): |
| 129 | + key = "input" if "input" in group else ("output" if "output" in group else None) |
| 130 | + if key: |
| 131 | + common[key] = {i["name"] for i in items or []} |
| 132 | + for el in doc.get("elements") or []: |
| 133 | + key = (el.get("parent"), el["name"]) |
| 134 | + out[key] = out.get(key, set()) | {a["name"] for a in (el.get("attributes") or [])} |
| 135 | + outputs = el.get("outputs") |
| 136 | + if isinstance(outputs, dict) and outputs.get("attribute"): |
| 137 | + k2 = (el["name"], "output") |
| 138 | + out[k2] = out.get(k2, set()) | {outputs["attribute"]} |
| 139 | + for el in doc.get("elements") or []: |
| 140 | + for child in el.get("children") or []: |
| 141 | + if child in common: |
| 142 | + k2 = (el["name"], child) |
| 143 | + out[k2] = out.get(k2, set()) | common[child] |
| 144 | + return out |
| 145 | + |
| 146 | + |
| 147 | +def main(): |
| 148 | + ios_root = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_IOS |
| 149 | + if not os.path.isdir(ios_root): |
| 150 | + print(f"phyphox-ios not found at {ios_root} - pass its path as an argument.") |
| 151 | + return 0 |
| 152 | + |
| 153 | + handlers = scan(ios_root) |
| 154 | + pairs = walk(handlers) |
| 155 | + spec = spec_attributes() |
| 156 | + |
| 157 | + problems = [] |
| 158 | + for (pname, cname), key in sorted(pairs.items()): |
| 159 | + declared = set(handlers.get(key, {}).get("attrs") or []) |
| 160 | + declared -= DECLARED_BUT_UNREAD.get((pname, cname), set()) |
| 161 | + modelled = spec.get((pname, cname)) |
| 162 | + if modelled is None: |
| 163 | + if declared: |
| 164 | + problems.append(f"{pname}/{cname}: not modelled at all " |
| 165 | + f"(iOS declares {sorted(declared)})") |
| 166 | + else: |
| 167 | + gap = sorted(declared - modelled) |
| 168 | + if gap: |
| 169 | + problems.append(f"{pname}/{cname}: iOS declares {gap}, spec does not") |
| 170 | + |
| 171 | + print(f"{len(pairs)} parent/child pairs reachable from <phyphox> in the iOS handlers") |
| 172 | + if problems: |
| 173 | + print() |
| 174 | + for p in problems: |
| 175 | + print(f" {p}") |
| 176 | + print(f"\n{len(problems)} gap(s).") |
| 177 | + return 1 |
| 178 | + print("No attribute iOS declares is missing from the spec.") |
| 179 | + return 0 |
| 180 | + |
| 181 | + |
| 182 | +if __name__ == "__main__": |
| 183 | + sys.exit(main()) |
0 commit comments