depyo 1.0.0 → 1.0.2

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.
package/README.md CHANGED
@@ -28,7 +28,15 @@ node depyo.js --skip-path /path/to/file.pyc
28
28
 
29
29
  # Dump to stdout instead of files
30
30
  node depyo.js --out /path/to/file.pyc
31
+
32
+ # Marshal-only blob (no .pyc header)
33
+ node depyo.js --marshal --py-version 3.11 /path/to/blob.bin
34
+ node depyo.js --marshal /path/to/blob.bin
35
+
36
+ # Fast marshal scan (no decompile)
37
+ node depyo.js --marshal-scan /path/to/blob.bin
31
38
  ```
39
+ Without `--py-version`, depyo scans supported versions (oldest → newest) and accepts the first clean output when all clean candidates agree. If outputs diverge (ambiguous), it stops and asks for `--py-version`. Use `--debug` to see scan results.
32
40
 
33
41
  ### CLI options
34
42
  - `--asm` emit `.pyasm` disassembly alongside source
@@ -39,6 +47,9 @@ node depyo.js --out /path/to/file.pyc
39
47
  - `--skip-source-gen` skip writing `.py` (use with `--asm/--dump`)
40
48
  - `--skip-path` flatten output paths (write next to input)
41
49
  - `--out` print source to stdout instead of files
50
+ - `--marshal` treat input as raw marshalled data (no .pyc header, auto-scan versions)
51
+ - `--marshal-scan` fast scan marshal blobs and print version candidates
52
+ - `--py-version <x.y>` bytecode version hint (use with `--marshal`)
42
53
  - `--basedir <dir>` override output root (default: alongside input)
43
54
  - `--file-ext <ext>` change emitted extension (default `py`)
44
55
 
@@ -58,6 +69,14 @@ node depyo.js --out /path/to/file.pyc
58
69
  node scripts/run-matrix.js # full sweep
59
70
  node scripts/run-matrix.js --pattern py311_exception_groups --fail-fast
60
71
  ```
72
+ - Marshal fixtures (headerless marshal blobs):
73
+ ```bash
74
+ node scripts/run-marshal-fixtures.js
75
+ ```
76
+ - Regenerate marshal fixtures:
77
+ ```bash
78
+ node scripts/generate-marshal-fixtures.js --clean
79
+ ```
61
80
  - Modern fixtures are generated via `test/generate_modern_tests.py` (Python 3.8+ on PATH).
62
81
 
63
82
  ## Support matrix
package/depyo.js CHANGED
@@ -20,6 +20,10 @@ global.g_cliArgs = {
20
20
  skipSource: false,
21
21
  skipPath: false,
22
22
  sendToStdout: false,
23
+ marshal: false,
24
+ marshalScan: false,
25
+ pyVersion: null,
26
+ silent: false,
23
27
  fileExt: 'py',
24
28
  baseDir: null,
25
29
  filenames: []
@@ -29,6 +33,8 @@ let g_totalInThroughput = 0;
29
33
  let g_totalOutThroughput = 0;
30
34
  let g_totalExecTime = 0;
31
35
  let g_totalFiles = 0;
36
+ let g_pyVersionInfo = null;
37
+ let g_marshalScanStats = {ok: 0, ambiguous: 0, failed: 0};
32
38
 
33
39
  function printUsage() {
34
40
  console.log(`Usage: node depyo.js [options] <file.pyc|archive.zip> [...]
@@ -43,6 +49,9 @@ Options:
43
49
  --skip-source-gen Do not emit .py source (useful with --asm/--dump)
44
50
  --skip-path Flatten output paths (write files next to inputs)
45
51
  --out Print decompiled source to stdout instead of files
52
+ --marshal Treat input as raw marshalled data (no .pyc header)
53
+ --marshal-scan Fast scan of marshal blobs (no decompile, prints version)
54
+ --py-version <x.y> Python bytecode version hint (auto-scan if omitted)
46
55
  --basedir <path> Output base directory (default: alongside input)
47
56
  --file-ext <ext> Extension for generated source (default: py)
48
57
  `);
@@ -69,6 +78,13 @@ function parseCLIParams() {
69
78
  g_cliArgs.skipPath = true;
70
79
  } else if (cliParam.toLowerCase() == "--out") {
71
80
  g_cliArgs.sendToStdout = true;
81
+ } else if (cliParam.toLowerCase() == "--marshal") {
82
+ g_cliArgs.marshal = true;
83
+ } else if (cliParam.toLowerCase() == "--marshal-scan" || cliParam.toLowerCase() == "--marshal-smoke") {
84
+ g_cliArgs.marshalScan = true;
85
+ g_cliArgs.marshal = true;
86
+ } else if (cliParam.toLowerCase() == "--py-version") {
87
+ g_cliArgs.pyVersion = process.argv[++idx];
72
88
  } else if (cliParam.toLowerCase() == "--basedir") {
73
89
  g_cliArgs.baseDir = process.argv[++idx];
74
90
  } else if (cliParam.toLowerCase() == "--file-ext") {
@@ -84,37 +100,219 @@ function parseCLIParams() {
84
100
  }
85
101
  }
86
102
 
103
+ function normalizeMarshalOutput(src) {
104
+ return src
105
+ .replace(/\r\n/g, '\n')
106
+ .replace(/[ \t]+$/gm, '')
107
+ .replace(/\n{3,}/g, '\n\n')
108
+ .replace(/#[^\n]*$/gm, '')
109
+ .trim();
110
+ }
111
+
112
+ function scanMarshalBuffer(buffer, filenameLabel) {
113
+ if (g_pyVersionInfo) {
114
+ const trial = PycReader.TryParseMarshal(buffer, g_pyVersionInfo);
115
+ if (!trial) {
116
+ g_marshalScanStats.failed++;
117
+ console.log(`${filenameLabel}: no parse with ${g_pyVersionInfo.major}.${g_pyVersionInfo.minor}`);
118
+ return;
119
+ }
120
+ g_marshalScanStats.ok++;
121
+ console.log(`${filenameLabel}: forced ${g_pyVersionInfo.major}.${g_pyVersionInfo.minor} unknown=${trial.unknown}/${trial.total} remaining=${trial.remaining}`);
122
+ return;
123
+ }
124
+
125
+ const results = PycReader.ScanMarshalCandidates(buffer);
126
+ if (!results.length) {
127
+ g_marshalScanStats.failed++;
128
+ console.log(`${filenameLabel}: no candidates`);
129
+ return;
130
+ }
131
+
132
+ const best = results[0];
133
+ const ambiguous = results.filter(r =>
134
+ r.unknown === best.unknown &&
135
+ r.remaining === best.remaining &&
136
+ r.unknownRatio === best.unknownRatio
137
+ );
138
+
139
+ if (ambiguous.length > 1) {
140
+ g_marshalScanStats.ambiguous++;
141
+ const versions = ambiguous.map(r => `${r.versionInfo.major}.${r.versionInfo.minor}`).join(', ');
142
+ console.log(`${filenameLabel}: ambiguous candidates (${versions})`);
143
+ return;
144
+ }
145
+
146
+ g_marshalScanStats.ok++;
147
+ console.log(`${filenameLabel}: best=${best.versionInfo.major}.${best.versionInfo.minor} unknown=${best.unknown}/${best.total} remaining=${best.remaining}`);
148
+ }
149
+
150
+ function attemptMarshalDecompile(buffer, versionInfo, opts = {}) {
151
+ const prevSilent = g_cliArgs.silent;
152
+ const prevDebug = g_cliArgs.debug;
153
+ if (opts.silent) {
154
+ g_cliArgs.silent = true;
155
+ g_cliArgs.debug = false;
156
+ }
157
+ try {
158
+ const rdr = new PycReader(buffer, {marshal: true, versionInfo});
159
+ const obj = rdr.ReadObject();
160
+ if (!obj || obj.ClassName !== "Py_CodeObject") {
161
+ return null;
162
+ }
163
+ const opcodes = new versionInfo.opcode(obj);
164
+ const {unknown, total} = PycReader.CountUnknownOpcodes(obj, rdr, opcodes.OpCodeList);
165
+ const unknownRatio = total > 0 ? unknown / total : 1;
166
+ const remaining = rdr.m_rdr.Reader.length - rdr.m_rdr.pc;
167
+
168
+ const genStartTS = process.hrtime.bigint();
169
+ const decompiler = new PycDecompiler(obj);
170
+ const ast = decompiler.decompile();
171
+ const pycResult = ast.codeFragment();
172
+ const pySrc = pycResult.toString();
173
+ const genSecs = Number(process.hrtime.bigint() - genStartTS) / 1000000000;
174
+
175
+ return {
176
+ reader: rdr,
177
+ obj,
178
+ pySrc,
179
+ genSecs,
180
+ cleanBuild: decompiler.cleanBuild,
181
+ unknown,
182
+ total,
183
+ unknownRatio,
184
+ remaining,
185
+ versionInfo
186
+ };
187
+ } catch (ex) {
188
+ if (opts.debug) {
189
+ console.log(`Marshal decompile failed for ${versionInfo.major}.${versionInfo.minor}: ${ex.message}`);
190
+ }
191
+ return null;
192
+ } finally {
193
+ g_cliArgs.silent = prevSilent;
194
+ g_cliArgs.debug = prevDebug;
195
+ }
196
+ }
197
+
198
+ function selectMarshalCandidate(buffer) {
199
+ const candidates = PycReader.ListSupportedVersions(false);
200
+ const attempts = [];
201
+ const cleanCandidates = [];
202
+ let baselineOutput = null;
203
+ let outputsDiverged = false;
204
+ for (const candidate of candidates) {
205
+ const result = attemptMarshalDecompile(buffer, candidate, {silent: true});
206
+ if (!result) {
207
+ continue;
208
+ }
209
+ attempts.push(result);
210
+ const isWorking = result.cleanBuild && result.unknown === 0 && result.remaining === 0;
211
+ if (isWorking) {
212
+ const normalized = normalizeMarshalOutput(result.pySrc || '');
213
+ cleanCandidates.push({...result, normalized});
214
+ if (baselineOutput === null) {
215
+ baselineOutput = normalized;
216
+ } else if (baselineOutput !== normalized) {
217
+ outputsDiverged = true;
218
+ }
219
+ }
220
+ }
221
+
222
+ if (!cleanCandidates.length) {
223
+ return attempts.length ? {best: null, attempts, ambiguous: false} : null;
224
+ }
225
+
226
+ if (outputsDiverged) {
227
+ return {best: null, attempts, ambiguous: true, cleanCandidates};
228
+ }
229
+
230
+ return {best: cleanCandidates[0], attempts, ambiguous: false};
231
+ }
232
+
87
233
  function decompilePycObject(data) {
88
234
  try
89
235
  {
90
236
  let filename = null, obj = null;
91
237
  let startTS = process.hrtime.bigint();
92
- let rdr = new PycReader(data);
93
- try {
94
- obj = rdr.ReadObject();
95
- filename = g_baseDir + obj.FileName;
96
- } catch (ex) {
97
- if (ex instanceof PycReader.LoadError) {
98
- // Save the binary file if it not already exists for future manual analysis.
99
- if (!ex.FileName) {
100
- if (global.g_cliArgs?.debug) {
101
- console.log(`LoadError: ${ex.message} at position ${ex.position}`);
238
+ let buffer = data;
239
+ if (!Buffer.isBuffer(buffer)) {
240
+ buffer = fs.readFileSync(data);
241
+ }
242
+ if (g_cliArgs.marshalScan) {
243
+ const label = typeof data === 'string' ? data : '<buffer>';
244
+ scanMarshalBuffer(buffer, label);
245
+ return;
246
+ }
247
+ let rdr = null;
248
+ let pySrc = null;
249
+ let genSecs = 0;
250
+
251
+ if (g_cliArgs.marshal) {
252
+ let attemptResult = null;
253
+ if (g_pyVersionInfo) {
254
+ attemptResult = attemptMarshalDecompile(buffer, g_pyVersionInfo);
255
+ } else {
256
+ const scan = selectMarshalCandidate(buffer);
257
+ attemptResult = scan ? scan.best : null;
258
+ if (g_cliArgs.debug && scan?.attempts?.length) {
259
+ console.log("Marshal scan results:");
260
+ for (const attempt of scan.attempts) {
261
+ const info = attempt.versionInfo;
262
+ console.log(` ${info.major}.${info.minor}: clean=${attempt.cleanBuild} unknown=${attempt.unknown}/${attempt.total} remaining=${attempt.remaining}`);
102
263
  }
103
- return;
264
+ if (attemptResult) {
265
+ const info = attemptResult.versionInfo;
266
+ console.log(`Selected marshal version: ${info.major}.${info.minor}`);
267
+ }
268
+ }
269
+ if (!attemptResult && scan?.ambiguous) {
270
+ const versions = scan.cleanCandidates
271
+ ? scan.cleanCandidates.map(c => `${c.versionInfo.major}.${c.versionInfo.minor}`).join(', ')
272
+ : 'unknown';
273
+ throw new Error(`Ambiguous marshal version (${versions}). Provide --py-version X.Y to force.`);
104
274
  }
105
- filename = g_baseDir + ex.FileName;
106
- let dirPath = Path.dirname(filename);
107
- if (!g_cliArgs.skipPath && !fs.existsSync(dirPath)) {
108
- fs.mkdirSync(dirPath, {recursive: true});
275
+ }
276
+
277
+ if (!attemptResult) {
278
+ throw new Error("No clean marshal candidate found. Provide --py-version X.Y to force.");
279
+ }
280
+ rdr = attemptResult.reader;
281
+ obj = attemptResult.obj;
282
+ pySrc = attemptResult.pySrc;
283
+ genSecs = attemptResult.genSecs;
284
+ } else {
285
+ rdr = new PycReader(buffer);
286
+ try {
287
+ obj = rdr.ReadObject();
288
+ } catch (ex) {
289
+ if (ex instanceof PycReader.LoadError) {
290
+ // Save the binary file if it not already exists for future manual analysis.
291
+ if (!ex.FileName) {
292
+ if (global.g_cliArgs?.debug) {
293
+ console.log(`LoadError: ${ex.message} at position ${ex.position}`);
294
+ }
295
+ return;
296
+ }
297
+ filename = g_baseDir + ex.FileName;
298
+ let dirPath = Path.dirname(filename);
299
+ if (!g_cliArgs.skipPath && !fs.existsSync(dirPath)) {
300
+ fs.mkdirSync(dirPath, {recursive: true});
301
+ }
302
+ let filenamePyc = filename.substring(0, filename.lastIndexOf('.')) + ".pyc";
303
+ fs.writeFileSync(filenamePyc, rdr.Reader);
304
+ console.log(`Error: ${ex.message}\nFile: ${filenamePyc}\nPosition: ${ex.position}`);
305
+ return;
109
306
  }
110
- let filenamePyc = filename.substring(0, filename.lastIndexOf('.')) + ".pyc";
111
- fs.writeFileSync(filenamePyc, rdr.Reader);
112
- console.log(`Error: ${ex.message}\nFile: ${filenamePyc}\nPosition: ${ex.position}`);
307
+ console.log(`Error: ${ex.message}\nStack:\n${ex.stacktrace}`);
113
308
  return;
114
309
  }
115
- console.log(`Error: ${ex.message}\nStack:\n${ex.stacktrace}`);
310
+ }
311
+
312
+ if (!obj) {
116
313
  return;
117
314
  }
315
+ filename = g_baseDir + obj.FileName;
118
316
 
119
317
  if (!g_cliArgs.sendToStdout) {
120
318
  console.log(`Processing ${filename}...`);
@@ -139,15 +337,18 @@ function decompilePycObject(data) {
139
337
  fs.writeFileSync(filenameBase + ".pyasm", PycDisassembler.Disassemble(rdr, obj));
140
338
  }
141
339
  }
142
- let genStartTS = process.hrtime.bigint();
143
- let decompiler = new PycDecompiler(obj);
144
- let ast = decompiler.decompile();
145
- let pycResult = ast.codeFragment();
146
- let pySrc = pycResult.toString();
340
+ if (!pySrc) {
341
+ let genStartTS = process.hrtime.bigint();
342
+ let decompiler = new PycDecompiler(obj);
343
+ let ast = decompiler.decompile();
344
+ let pycResult = ast.codeFragment();
345
+ pySrc = pycResult.toString();
346
+ genSecs = Number(process.hrtime.bigint() - genStartTS) / 1000000000;
347
+ }
147
348
  if (!pySrc.endsWith("\n")) {
148
349
  pySrc += "\n";
149
350
  }
150
- let genSecs = parseInt(process.hrtime.bigint() - genStartTS) / 1000000000;
351
+ genSecs = Math.max(genSecs, 0.000000001);
151
352
  if (g_cliArgs.sendToStdout) {
152
353
  // console.log(`\n\n${filenameBase}.${g_cliArgs.fileExt}\n-------\n${pySrc}`);
153
354
  console.log(pySrc);
@@ -156,9 +357,9 @@ function decompilePycObject(data) {
156
357
  }
157
358
  let secs = parseInt(process.hrtime.bigint() - startTS) / 1000000000;
158
359
  g_totalExecTime += secs;
159
- let inThroughput = data.length / genSecs;
360
+ let inThroughput = buffer.length / genSecs;
160
361
  let outThroughput = pySrc.length / genSecs;
161
- g_totalInThroughput += data.length;
362
+ g_totalInThroughput += buffer.length;
162
363
  g_totalOutThroughput += pySrc.length;
163
364
  g_totalFiles++;
164
365
  if (g_cliArgs.stats) {
@@ -196,6 +397,17 @@ function DecompileModule(filenames)
196
397
  }
197
398
 
198
399
  parseCLIParams()
400
+ if (g_cliArgs.pyVersion && !g_cliArgs.marshal) {
401
+ console.log("Error: --py-version requires --marshal (headerless input).");
402
+ process.exit(1);
403
+ }
404
+ if (g_cliArgs.pyVersion) {
405
+ g_pyVersionInfo = PycReader.ResolveVersionTag(g_cliArgs.pyVersion);
406
+ if (!g_pyVersionInfo) {
407
+ console.log(`Error: unsupported --py-version "${g_cliArgs.pyVersion}". Use format X.Y (e.g., 3.11).`);
408
+ process.exit(1);
409
+ }
410
+ }
199
411
  if (g_cliArgs.filenames.length === 0) {
200
412
  printUsage();
201
413
  process.exit(1);
@@ -206,6 +418,17 @@ g_baseDir = Path.resolve(baseInputDir, 'decompiled') + '/';
206
418
 
207
419
  DecompileModule(g_cliArgs.filenames);
208
420
 
421
+ if (g_cliArgs.marshalScan) {
422
+ console.log(`Marshal scan summary: ok=${g_marshalScanStats.ok}, ambiguous=${g_marshalScanStats.ambiguous}, failed=${g_marshalScanStats.failed}`);
423
+ if (g_marshalScanStats.failed > 0) {
424
+ process.exit(1);
425
+ }
426
+ if (g_marshalScanStats.ambiguous > 0) {
427
+ process.exit(2);
428
+ }
429
+ process.exit(0);
430
+ }
431
+
209
432
  if (!g_cliArgs.sendToStdout) {
210
433
  const inRate = (g_totalInThroughput / g_totalExecTime).toFixed(2);
211
434
  const outRate = (g_totalOutThroughput / g_totalExecTime).toFixed(2);
@@ -712,7 +712,9 @@ class PycDecompiler {
712
712
  {
713
713
  PycDecompiler.opCodeHandlers[this.code.Current.OpCodeID].call(this);
714
714
  } else {
715
- console.error(`Unsupported opcode ${this.code.Current.InstructionName} at pos ${this.code.Current.Offset}\n`);
715
+ if (!g_cliArgs?.silent) {
716
+ console.error(`Unsupported opcode ${this.code.Current.InstructionName} at pos ${this.code.Current.Offset}\n`);
717
+ }
716
718
  this.cleanBuild = false;
717
719
  let node = new AST.ASTNodeList(this.defBlock.nodes);
718
720
  return node;
@@ -725,9 +727,11 @@ class PycDecompiler {
725
727
  && (this.curBlock.end == this.code.Next?.Offset);
726
728
 
727
729
  } catch (ex) {
728
- console.error(`EXCEPTION for OpCode ${this.code.Current.InstructionName} (${this.code.Current.Argument}) at offset ${this.code.Current.Offset} in code object '${this.object.Name}', file offset ${this.object.codeOffset + this.code.Current.Offset} : ${ex.message}\n\n`);
729
- if (global.g_cliArgs?.debug) {
730
- console.error('Stack trace:', ex.stack);
730
+ if (!g_cliArgs?.silent) {
731
+ console.error(`EXCEPTION for OpCode ${this.code.Current.InstructionName} (${this.code.Current.Argument}) at offset ${this.code.Current.Offset} in code object '${this.object.Name}', file offset ${this.object.codeOffset + this.code.Current.Offset} : ${ex.message}\n\n`);
732
+ if (global.g_cliArgs?.debug) {
733
+ console.error('Stack trace:', ex.stack);
734
+ }
731
735
  }
732
736
  }
733
737
  }
package/lib/PycReader.js CHANGED
@@ -103,6 +103,37 @@ const MagicToVersion = {
103
103
  0x0A0D0E2B: {major: 3, minor: 14, IsUnicode: true, opcode: require('./bytecode/python_3_14')}
104
104
  };
105
105
 
106
+ const VersionAliases = {
107
+ "1.2": "1.1"
108
+ };
109
+
110
+ const VersionToInfo = {};
111
+ for (const [magic, info] of Object.entries(MagicToVersion)) {
112
+ const key = `${info.major}.${info.minor}`;
113
+ const existing = VersionToInfo[key];
114
+ const candidate = {...info, magic: Number(magic)};
115
+ if (!existing || (candidate.revision || 0) > (existing.revision || 0)) {
116
+ VersionToInfo[key] = candidate;
117
+ }
118
+ }
119
+
120
+ function parseVersionTag(tag) {
121
+ if (!tag) {
122
+ return null;
123
+ }
124
+ const cleaned = String(tag).trim().replace(/^python/i, '').replace(/^py/i, '');
125
+ const match = cleaned.match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?$/);
126
+ if (!match || match[2] === undefined) {
127
+ return null;
128
+ }
129
+ const major = parseInt(match[1], 10);
130
+ const minor = parseInt(match[2], 10);
131
+ if (Number.isNaN(major) || Number.isNaN(minor)) {
132
+ return null;
133
+ }
134
+ return {major, minor};
135
+ }
136
+
106
137
 
107
138
  class PycReader
108
139
  {
@@ -123,11 +154,14 @@ class PycReader
123
154
  m_filename = null;
124
155
  m_version = null;
125
156
 
126
- constructor(data) {
157
+ constructor(data, options = {}) {
127
158
  if (!data) {
128
159
  return;
129
160
  }
130
161
 
162
+ const opts = options || {};
163
+ this.m_filename = opts.filename || null;
164
+
131
165
  let buffer = data;
132
166
  if (typeof(data) == 'string') {
133
167
  buffer = fs.readFileSync(data);
@@ -135,6 +169,21 @@ class PycReader
135
169
  throw new Error('PycReader accepts only String as a file path or Buffer as content.');
136
170
  }
137
171
  this.m_rdr = new BinaryReader(buffer);
172
+
173
+ if (opts.marshal) {
174
+ let versionInfo = opts.versionInfo;
175
+ if (!versionInfo && opts.pyVersion) {
176
+ versionInfo = PycReader.ResolveVersionTag(opts.pyVersion);
177
+ }
178
+ if (!versionInfo) {
179
+ throw new Error('Marshal mode requires a bytecode version. Pass --py-version or pre-resolve it.');
180
+ }
181
+ this.m_version = versionInfo;
182
+ if (global.g_cliArgs?.debug && !opts.silent) {
183
+ console.log(`Marshal mode: Python ${this.m_version.major}.${this.m_version.minor}`);
184
+ }
185
+ return;
186
+ }
138
187
 
139
188
  let marshalVersion = this.m_rdr.readUInt32();
140
189
  this.m_version = MagicToVersion[marshalVersion] || MagicToVersion[marshalVersion & 0xFFFFFFFE] || {major: -1, minor: -1, IsUnicode: false};
@@ -190,6 +239,157 @@ class PycReader
190
239
  return this.m_version.opcode;
191
240
  }
192
241
 
242
+ static ResolveVersionTag(tag) {
243
+ const parsed = parseVersionTag(tag);
244
+ if (!parsed) {
245
+ return null;
246
+ }
247
+ const key = `${parsed.major}.${parsed.minor}`;
248
+ const aliased = VersionAliases[key] ? VersionAliases[key] : key;
249
+ return VersionToInfo[aliased] || null;
250
+ }
251
+
252
+ static ListSupportedVersions(desc = true) {
253
+ const list = Object.values(VersionToInfo);
254
+ list.sort((a, b) => {
255
+ if (a.major !== b.major) {
256
+ return a.major - b.major;
257
+ }
258
+ return a.minor - b.minor;
259
+ });
260
+ return desc ? list.reverse() : list;
261
+ }
262
+
263
+ static GuessVersion(buffer) {
264
+ const candidates = PycReader.ListSupportedVersions(true);
265
+ let best = null;
266
+ for (const candidate of candidates) {
267
+ const trial = PycReader.TryParseMarshal(buffer, candidate);
268
+ if (!trial) {
269
+ continue;
270
+ }
271
+ if (trial.unknown === 0 && trial.remaining === 0) {
272
+ return candidate;
273
+ }
274
+ if (!best ||
275
+ trial.unknownRatio < best.unknownRatio ||
276
+ (trial.unknownRatio === best.unknownRatio && trial.remaining < best.remaining)) {
277
+ best = {...trial, candidate};
278
+ }
279
+ }
280
+ return best ? best.candidate : null;
281
+ }
282
+
283
+ static ScanMarshalCandidates(buffer) {
284
+ const candidates = PycReader.ListSupportedVersions(false);
285
+ const results = [];
286
+ for (const candidate of candidates) {
287
+ const trial = PycReader.TryParseMarshal(buffer, candidate);
288
+ if (!trial) {
289
+ continue;
290
+ }
291
+ results.push({...trial, versionInfo: candidate});
292
+ }
293
+ results.sort((a, b) => {
294
+ if (a.unknownRatio !== b.unknownRatio) {
295
+ return a.unknownRatio - b.unknownRatio;
296
+ }
297
+ if (a.remaining !== b.remaining) {
298
+ return a.remaining - b.remaining;
299
+ }
300
+ if (a.unknown !== b.unknown) {
301
+ return a.unknown - b.unknown;
302
+ }
303
+ if (a.versionInfo.major !== b.versionInfo.major) {
304
+ return a.versionInfo.major - b.versionInfo.major;
305
+ }
306
+ return a.versionInfo.minor - b.versionInfo.minor;
307
+ });
308
+ return results;
309
+ }
310
+
311
+ static CountUnknownOpcodes(codeObject, reader, opCodeList) {
312
+ const code = codeObject?.Code?.Value;
313
+ if (!code || !opCodeList) {
314
+ return {unknown: Number.MAX_SAFE_INTEGER, total: 0};
315
+ }
316
+ const wordSize = reader.getInstructionWordSize();
317
+ let unknown = 0;
318
+ let total = 0;
319
+ if (wordSize === 2) {
320
+ for (let offset = 0; offset < code.length; offset += 2) {
321
+ total++;
322
+ if (!opCodeList[code[offset]]) {
323
+ unknown++;
324
+ }
325
+ }
326
+ } else {
327
+ let offset = 0;
328
+ while (offset < code.length) {
329
+ total++;
330
+ const opcode = code[offset];
331
+ const entry = opCodeList[opcode];
332
+ if (!entry) {
333
+ unknown++;
334
+ offset += 1;
335
+ continue;
336
+ }
337
+ offset += entry.HasArgument ? 3 : 1;
338
+ }
339
+ }
340
+ return {unknown, total};
341
+ }
342
+
343
+ static TryParseMarshal(buffer, versionInfo) {
344
+ try {
345
+ const reader = new PycReader(buffer, {marshal: true, versionInfo, silent: true});
346
+ const obj = reader.ReadObject();
347
+ if (!obj || obj.ClassName !== "Py_CodeObject") {
348
+ return null;
349
+ }
350
+ const remaining = reader.m_rdr.Reader.length - reader.m_rdr.pc;
351
+ if (remaining < 0) {
352
+ return null;
353
+ }
354
+
355
+ const prevDebug = global.g_cliArgs?.debug;
356
+ if (global.g_cliArgs) {
357
+ global.g_cliArgs.debug = false;
358
+ }
359
+ let opcodes = null;
360
+ try {
361
+ opcodes = new versionInfo.opcode(obj);
362
+ } finally {
363
+ if (global.g_cliArgs) {
364
+ global.g_cliArgs.debug = prevDebug;
365
+ }
366
+ }
367
+
368
+ const {unknown, total} = PycReader.CountUnknownOpcodes(obj, reader, opcodes?.OpCodeList);
369
+ const unknownRatio = total > 0 ? unknown / total : 1;
370
+
371
+ let score = 0;
372
+ if (remaining === 0) {
373
+ score += 3;
374
+ }
375
+ if (unknown === 0) {
376
+ score += 2;
377
+ }
378
+ if (obj.Code?.Value && Buffer.isBuffer(obj.Code.Value)) {
379
+ score += 1;
380
+ }
381
+ if (obj.Consts?.ClassName === "Py_Tuple") {
382
+ score += 1;
383
+ }
384
+ if (obj.Names?.ClassName === "Py_Tuple") {
385
+ score += 1;
386
+ }
387
+ return {score, remaining, unknown, total, unknownRatio};
388
+ } catch (ex) {
389
+ return null;
390
+ }
391
+ }
392
+
193
393
  ReadObject() {
194
394
  try {
195
395
  let obj = new PythonObject(), value = null;
@@ -1863,7 +1863,7 @@ class ASTKwNamesMap extends ASTNode {
1863
1863
 
1864
1864
  constructor(values) {
1865
1865
  super();
1866
- this.m_values = values;
1866
+ this.m_values = Array.isArray(values) ? values : [];
1867
1867
  }
1868
1868
 
1869
1869
  get values() {
@@ -1876,6 +1876,9 @@ class ASTKwNamesMap extends ASTNode {
1876
1876
  }
1877
1877
 
1878
1878
  add (key, value) {
1879
+ if (!this.m_values) {
1880
+ this.m_values = [];
1881
+ }
1879
1882
  this.values.push({key, value});
1880
1883
  }
1881
1884
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "depyo",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "Python bytecode decompiler (Python 1.0–3.14) implemented in Node.js",
5
5
  "bin": {
6
6
  "depyo": "./depyo.js"