@pulsefield/protocol 0.0.2 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/PROTOCOL_GUIDELINES.md +123 -0
  2. package/Package.swift +34 -0
  3. package/README.md +92 -4
  4. package/buf.yaml +1 -1
  5. package/docs/session-scope.md +27 -0
  6. package/gen/python/pulsefield/protocol/py.typed +1 -0
  7. package/gen/python/pulsefield/protocol/v1/core_pb2.py +34 -0
  8. package/gen/python/pulsefield/protocol/v1/core_pb2.pyi +76 -0
  9. package/gen/python/pulsefield/protocol/v1/envelope_pb2.py +6 -23
  10. package/gen/python/pulsefield/protocol/v1/envelope_pb2.pyi +26 -105
  11. package/gen/python/pulsefield/protocol/v1/inference_pb2.py +48 -0
  12. package/gen/python/pulsefield/protocol/v1/inference_pb2.pyi +160 -0
  13. package/gen/python/pulsefield/protocol/v1/mapper_pb2.py +28 -0
  14. package/gen/python/pulsefield/protocol/v1/mapper_pb2.pyi +23 -0
  15. package/gen/swift/pulsefield/protocol/v1/core.pb.swift +323 -0
  16. package/gen/swift/pulsefield/protocol/v1/envelope.pb.swift +335 -671
  17. package/gen/swift/pulsefield/protocol/v1/inference.pb.swift +929 -0
  18. package/gen/swift/pulsefield/protocol/v1/mapper.pb.swift +143 -0
  19. package/package.json +17 -11
  20. package/proto/pulsefield/protocol/v1/core.proto +57 -0
  21. package/proto/pulsefield/protocol/v1/envelope.proto +30 -58
  22. package/proto/pulsefield/protocol/v1/inference.proto +145 -0
  23. package/proto/pulsefield/protocol/v1/mapper.proto +17 -0
  24. package/pyproject.toml +38 -0
  25. package/tools/check-generated.mjs +222 -0
  26. package/tools/check-python-package.mjs +63 -0
  27. package/tools/ensure-python-typed.mjs +10 -0
  28. package/fixtures/json/v1/audio-request.json +0 -12
  29. package/fixtures/json/v1/hit-object-token-event.json +0 -9
@@ -0,0 +1,222 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import {
3
+ existsSync,
4
+ mkdtempSync,
5
+ readFileSync,
6
+ readdirSync,
7
+ rmSync,
8
+ statSync,
9
+ } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+ import path from "node:path";
12
+
13
+ const root = process.cwd();
14
+ const bufBin = path.join(root, "node_modules/.bin/buf");
15
+ const errors = [];
16
+
17
+ function fail(message) {
18
+ errors.push(message);
19
+ }
20
+
21
+ function listFiles(dir, options = {}) {
22
+ const files = [];
23
+ for (const entry of readdirSync(dir).sort()) {
24
+ const fullPath = path.join(dir, entry);
25
+ const relativePath = path.relative(options.rootDir ?? dir, fullPath);
26
+ if (options.ignore?.(relativePath, fullPath)) {
27
+ continue;
28
+ }
29
+ const stat = statSync(fullPath);
30
+ if (stat.isDirectory()) {
31
+ files.push(
32
+ ...listFiles(fullPath, { ...options, rootDir: options.rootDir ?? dir }),
33
+ );
34
+ } else if (stat.isFile()) {
35
+ files.push(fullPath);
36
+ }
37
+ }
38
+ return files;
39
+ }
40
+
41
+ function relativeFiles(dir, options = {}) {
42
+ return listFiles(dir, { ...options, rootDir: dir })
43
+ .map((filePath) => path.relative(dir, filePath))
44
+ .sort();
45
+ }
46
+
47
+ function compareDirectories(actualDir, expectedDir, options = {}) {
48
+ const actualFiles = relativeFiles(actualDir, options);
49
+ const expectedFiles = relativeFiles(expectedDir, options);
50
+ if (JSON.stringify(actualFiles) !== JSON.stringify(expectedFiles)) {
51
+ fail(
52
+ `${path.relative(root, expectedDir)} file list differs from fresh generation`,
53
+ );
54
+ return;
55
+ }
56
+
57
+ for (const relativePath of expectedFiles) {
58
+ const actual = readFileSync(path.join(actualDir, relativePath));
59
+ const expected = readFileSync(path.join(expectedDir, relativePath));
60
+ if (!actual.equals(expected)) {
61
+ fail(`${path.relative(root, expectedDir)}/${relativePath} is stale`);
62
+ }
63
+ }
64
+ }
65
+
66
+ function stripProtoComments(source) {
67
+ return source.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "");
68
+ }
69
+
70
+ function parseProtoSurface(protoPath) {
71
+ const source = stripProtoComments(readFileSync(protoPath, "utf8"));
72
+ const messages = [];
73
+ const enums = [];
74
+ const messageRegex = /message\s+(\w+)\s*\{([\s\S]*?)\n\}/g;
75
+ const enumRegex = /enum\s+(\w+)\s*\{/g;
76
+
77
+ for (const match of source.matchAll(enumRegex)) {
78
+ enums.push(match[1]);
79
+ }
80
+
81
+ for (const match of source.matchAll(messageRegex)) {
82
+ const [, name, body] = match;
83
+ const fields = [];
84
+ const fieldRegex =
85
+ /(?:optional\s+|repeated\s+)?(?:[\w.]+)\s+(\w+)\s*=\s*\d+\s*;/g;
86
+ for (const fieldMatch of body.matchAll(fieldRegex)) {
87
+ fields.push(fieldMatch[1]);
88
+ }
89
+ messages.push({ name, fields });
90
+ }
91
+
92
+ return { messages, enums };
93
+ }
94
+
95
+ function swiftFieldName(protoFieldName) {
96
+ const parts = protoFieldName.split("_");
97
+ return parts
98
+ .map((part, index) => {
99
+ if (index === 0) {
100
+ return part;
101
+ }
102
+ if (part === "id") {
103
+ return "ID";
104
+ }
105
+ return `${part[0].toUpperCase()}${part.slice(1)}`;
106
+ })
107
+ .join("");
108
+ }
109
+
110
+ function checkGeneratedSurface() {
111
+ const protoFiles = relativeFiles(path.join(root, "proto")).filter(
112
+ (fileName) => fileName.endsWith(".proto"),
113
+ );
114
+ const swiftFiles = new Set(relativeFiles(path.join(root, "gen/swift")));
115
+ const pythonFiles = new Set(relativeFiles(path.join(root, "gen/python")));
116
+
117
+ for (const protoFile of protoFiles) {
118
+ const base = protoFile.replace(/\.proto$/, "");
119
+ const expectedSwift = `${base}.pb.swift`;
120
+ const expectedPython = `${base}_pb2.py`;
121
+ const expectedPythonPyi = `${base}_pb2.pyi`;
122
+
123
+ if (!swiftFiles.has(expectedSwift)) {
124
+ fail(`missing Swift output for ${protoFile}: ${expectedSwift}`);
125
+ continue;
126
+ }
127
+ if (!pythonFiles.has(expectedPython)) {
128
+ fail(`missing Python output for ${protoFile}: ${expectedPython}`);
129
+ continue;
130
+ }
131
+ if (!pythonFiles.has(expectedPythonPyi)) {
132
+ fail(`missing Python type stub for ${protoFile}: ${expectedPythonPyi}`);
133
+ continue;
134
+ }
135
+
136
+ const protoSurface = parseProtoSurface(path.join(root, "proto", protoFile));
137
+ const swiftSource = readFileSync(
138
+ path.join(root, "gen/swift", expectedSwift),
139
+ "utf8",
140
+ );
141
+ const pythonPyiSource = readFileSync(
142
+ path.join(root, "gen/python", expectedPythonPyi),
143
+ "utf8",
144
+ );
145
+
146
+ for (const enumName of protoSurface.enums) {
147
+ const swiftEnum = `enum Pulsefield_Protocol_V1_${enumName}`;
148
+ if (!swiftSource.includes(swiftEnum)) {
149
+ fail(`Swift output missing enum ${enumName}`);
150
+ }
151
+ if (!pythonPyiSource.includes(`class ${enumName}(`)) {
152
+ fail(`Python stub missing enum ${enumName}`);
153
+ }
154
+ }
155
+
156
+ for (const message of protoSurface.messages) {
157
+ const swiftStruct = `struct Pulsefield_Protocol_V1_${message.name}`;
158
+ if (!swiftSource.includes(swiftStruct)) {
159
+ fail(`Swift output missing message ${message.name}`);
160
+ }
161
+ if (!pythonPyiSource.includes(`class ${message.name}(`)) {
162
+ fail(`Python stub missing message ${message.name}`);
163
+ }
164
+ for (const fieldName of message.fields) {
165
+ if (!pythonPyiSource.includes(`${fieldName}:`)) {
166
+ fail(`Python stub missing field ${message.name}.${fieldName}`);
167
+ }
168
+ const swiftName = swiftFieldName(fieldName);
169
+ if (!swiftSource.includes(`var ${swiftName}:`)) {
170
+ fail(`Swift output missing field ${message.name}.${swiftName}`);
171
+ }
172
+ }
173
+ }
174
+ }
175
+ }
176
+
177
+ function checkPythonTypedMarker() {
178
+ const markerPath = path.join(root, "gen/python/pulsefield/protocol/py.typed");
179
+ if (!existsSync(markerPath)) {
180
+ fail(
181
+ "missing Python typed marker: gen/python/pulsefield/protocol/py.typed",
182
+ );
183
+ }
184
+ }
185
+
186
+ const tempDir = mkdtempSync(path.join(tmpdir(), "pulsefield-protocol-gen-"));
187
+ try {
188
+ execFileSync(bufBin, ["generate", "--output", tempDir], {
189
+ cwd: root,
190
+ stdio: "pipe",
191
+ });
192
+ compareDirectories(
193
+ path.join(tempDir, "gen/swift"),
194
+ path.join(root, "gen/swift"),
195
+ );
196
+ compareDirectories(
197
+ path.join(tempDir, "gen/python"),
198
+ path.join(root, "gen/python"),
199
+ {
200
+ ignore: (relativePath) =>
201
+ relativePath === "pulsefield/protocol/py.typed" ||
202
+ relativePath.includes(".egg-info") ||
203
+ relativePath.includes("__pycache__"),
204
+ },
205
+ );
206
+ } finally {
207
+ rmSync(tempDir, { force: true, recursive: true });
208
+ }
209
+
210
+ checkGeneratedSurface();
211
+ checkPythonTypedMarker();
212
+
213
+ if (errors.length) {
214
+ for (const error of errors) {
215
+ console.error(error);
216
+ }
217
+ process.exit(1);
218
+ }
219
+
220
+ console.log(
221
+ "generated Swift/Python outputs match proto and fresh Buf generation",
222
+ );
@@ -0,0 +1,63 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
5
+
6
+ const root = process.cwd();
7
+ const tempDir = mkdtempSync(path.join(tmpdir(), "pulsefield-protocol-python-"));
8
+ const rootBuildDir = path.join(root, "build");
9
+ const eggInfoDir = path.join(root, "gen/python/pulsefield_protocol.egg-info");
10
+ const rootBuildDirExisted = existsSync(rootBuildDir);
11
+ const eggInfoDirExisted = existsSync(eggInfoDir);
12
+
13
+ function venvPython(venvDir) {
14
+ if (process.platform === "win32") {
15
+ return path.join(venvDir, "Scripts", "python.exe");
16
+ }
17
+ return path.join(venvDir, "bin", "python");
18
+ }
19
+
20
+ function run(command, args, options = {}) {
21
+ execFileSync(command, args, {
22
+ cwd: root,
23
+ stdio: "inherit",
24
+ ...options,
25
+ });
26
+ }
27
+
28
+ try {
29
+ const buildVenv = path.join(tempDir, "build-venv");
30
+ const installVenv = path.join(tempDir, "install-venv");
31
+ const distDir = path.join(tempDir, "dist");
32
+
33
+ run("python3", ["-m", "venv", buildVenv]);
34
+ const buildPython = venvPython(buildVenv);
35
+ run(buildPython, ["-m", "pip", "install", "--upgrade", "pip", "build"]);
36
+ run(buildPython, ["-m", "build", "--sdist", "--wheel", "--outdir", distDir]);
37
+
38
+ const wheel = readdirSync(distDir).find((fileName) =>
39
+ fileName.endsWith(".whl"),
40
+ );
41
+ if (!wheel) {
42
+ throw new Error("python build did not produce a wheel");
43
+ }
44
+
45
+ run("python3", ["-m", "venv", installVenv]);
46
+ const installPython = venvPython(installVenv);
47
+ run(installPython, ["-m", "pip", "install", "--upgrade", "pip"]);
48
+ run(installPython, ["-m", "pip", "install", path.join(distDir, wheel)]);
49
+ run(installPython, [
50
+ "-c",
51
+ "from pulsefield.protocol.v1 import envelope_pb2; envelope_pb2.Envelope(session_id='wheel')",
52
+ ]);
53
+
54
+ console.log("python wheel builds, installs, and imports");
55
+ } finally {
56
+ rmSync(tempDir, { force: true, recursive: true });
57
+ if (!rootBuildDirExisted) {
58
+ rmSync(rootBuildDir, { force: true, recursive: true });
59
+ }
60
+ if (!eggInfoDirExisted) {
61
+ rmSync(eggInfoDir, { force: true, recursive: true });
62
+ }
63
+ }
@@ -0,0 +1,10 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ const markerPath = path.join(
5
+ process.cwd(),
6
+ "gen/python/pulsefield/protocol/py.typed",
7
+ );
8
+
9
+ await mkdir(path.dirname(markerPath), { recursive: true });
10
+ await writeFile(markerPath, "\n");
@@ -1,12 +0,0 @@
1
- {
2
- "sessionId": "session-1",
3
- "sequence": "1",
4
- "sentAtUnixMs": "0",
5
- "audio": {
6
- "audioPath": "/tmp/song.wav",
7
- "audioLengthMs": 180000,
8
- "musicSource": "MUSIC_SOURCE_BACKGROUND",
9
- "difficulty": 4,
10
- "route": "INFERENCE_ROUTE_MAPPER"
11
- }
12
- }
@@ -1,9 +0,0 @@
1
- {
2
- "sessionId": "session-1",
3
- "sequence": "2",
4
- "sentAtUnixMs": "0",
5
- "hitObjectToken": {
6
- "tokenId": 30,
7
- "msInRefAudio": 1234
8
- }
9
- }