diff --git a/builder/tbdocs.mjs b/builder/tbdocs.mjs
index 67e04bd1..da308fea 100644
--- a/builder/tbdocs.mjs
+++ b/builder/tbdocs.mjs
@@ -356,6 +356,11 @@ const TASKS = {
runOnMain: true,
async execute({ config: { config } }, ctx) {
const { pages, staticFiles } = await discover(ctx.srcRoot, config.exclude ?? []);
+ for (const entry of config.bundle_extra ?? []) {
+ const srcPath = path.resolve(ctx.srcRoot, entry.src);
+ const stat = await fs.stat(srcPath);
+ staticFiles.push({ srcPath, srcRel: entry.dest, destRel: entry.dest, size: stat.size });
+ }
return { pages, staticFiles, config };
},
submit(out, state) {
diff --git a/docs/Features/Packages/Import-export tool.md b/docs/Features/Packages/Import-export tool.md
new file mode 100644
index 00000000..5cfdcce0
--- /dev/null
+++ b/docs/Features/Packages/Import-export tool.md
@@ -0,0 +1,97 @@
+---
+title: Import/Export Tool
+parent: Package Management
+grand_parent: Features
+nav_order: 7
+permalink: /Features/Packages/Import-Export-Tool
+---
+
+# Import/export tool
+
+A standalone command-line tool for unpacking `.twinproj` and `.twinpack`
+files to a directory tree, and for repacking a directory tree back into a
+binary project file. Useful for inspecting package contents, batch-editing
+source files outside the IDE, or integrating twinBASIC projects into
+version-control workflows.
+
+> [!WARNING]
+>
+> There is no official support for reading or writing project files outside of the twinBASIC executable. This tool may break when the IDE is updated.
+
+Two functionally identical, single-file implementations are provided -- pick
+whichever runtime you already have installed:
+
+| Runtime | Download |
+|---------|----------|
+| Node.js 18+ | impexp.mjs |
+| Python 3.6+ | impexp.py |
+
+Neither script has any external dependencies.
+
+## Usage
+
+```
+impexp import [output_dir]
+impexp export
+impexp --self-test
+```
+
+### Import (unpack)
+
+Reads a `.twinproj` or `.twinpack` binary and extracts its contents to a
+directory on disk. If `output_dir` is omitted, a directory named after the
+project root entry is created in the current working directory.
+
+```
+node impexp.mjs import MyPackage.twinpack
+```
+
+```
+python impexp.py import MyProject.twinproj unpacked/
+```
+
+### Export (pack)
+
+Scans a directory tree and writes a `.twinproj` or `.twinpack` binary. The
+directory name becomes the root entry name in the output file. Well-known
+directory and file names (`Sources`, `Resources`, `Settings`, etc.) are
+tagged with the correct `mark2` category values automatically.
+
+```
+node impexp.mjs export unpacked/ MyProject.twinproj
+```
+
+```
+python impexp.py export unpacked/ MyPackage.twinpack
+```
+
+### Self-test
+
+Both implementations include a built-in test suite that exercises parsing,
+serialization, and full round-trip fidelity.
+
+```
+node impexp.mjs --self-test
+python impexp.py --self-test
+```
+
+## Round-trip notes
+
+Importing and re-exporting a binary file preserves all file contents
+byte-for-byte. The following metadata fields are reset to defaults on a
+disk round-trip (they are not stored on the filesystem):
+
+- **mark1** (revision counter) -- directories get `0x0000`; files get
+ `0x0002`.
+- **Revision entries** -- always written as zero.
+- **Entry order** -- directories first, then files, sorted alphabetically
+ within each group.
+
+The IDE regenerates these fields when the project is opened, so the
+round-tripped file is fully functional.
+
+## See also
+
+- [TWINPACK File Format](File-Format) -- binary format specification
+- [Creating a TWINPACK Package](Creating-TWINPACK)
+- [Importing a Package from a TWINPACK File](Importing-TWINPACK)
diff --git a/docs/Features/Packages/index.md b/docs/Features/Packages/index.md
index 1bd2c559..906146d9 100644
--- a/docs/Features/Packages/index.md
+++ b/docs/Features/Packages/index.md
@@ -28,5 +28,6 @@ Please be aware that TWINPACK files currently contain the full source code of yo
- [Linked Packages](Linked) -- storing a package in a shared location rather than embedding it in each project file.
- [Updating a Package](Updating) -- removing an outdated package and installing a newer version from TWINSERV.
- [TWINPACK File Format](File-Format) -- binary format specification for `.twinproj` and `.twinpack` files.
+- [Import/Export Tool](Import-Export-Tool) -- standalone command-line tool for unpacking and repacking `.twinproj` and `.twinpack` files.
[^1]: A service of TWINBASIC LTD offered to the user community.
diff --git a/docs/_config.yml b/docs/_config.yml
index c9ecfcdf..8a5cc5d2 100644
--- a/docs/_config.yml
+++ b/docs/_config.yml
@@ -79,6 +79,15 @@ offline_exclude:
#
# Patterns that start with * must be quoted so YAML does not interpret them
# as alias references.
+# Extra files from outside the docs tree to bundle as site assets.
+# Each entry maps a source path (relative to docs/) to a destination
+# path in the built site. Injected during the discover phase.
+bundle_extra:
+ - src: ../scripts/impexp.mjs
+ dest: Features/Packages/downloads/impexp.mjs
+ - src: ../scripts/impexp.py
+ dest: Features/Packages/downloads/impexp.py
+
exclude:
# Underscore-prefixed files and directories -- catches _config.yml,
# _book.yml, _site, _site-offline, _site-pdf, _pdf, _includes,
diff --git a/scripts/impexp.mjs b/scripts/impexp.mjs
new file mode 100644
index 00000000..9ecc16a0
--- /dev/null
+++ b/scripts/impexp.mjs
@@ -0,0 +1,340 @@
+#!/usr/bin/env node
+//
+// Copyright (c) 2026 TWINBASIC LTD
+// SPDX-License-Identifier: MIT
+//
+// impexp.mjs -- standalone twinpack/twinproj import/export tool.
+// No external dependencies; requires Node.js 18+.
+//
+// Usage:
+// node impexp.mjs import [output_dir]
+// node impexp.mjs export
+// node impexp.mjs --self-test
+
+import fs from 'node:fs';
+import path from 'node:path';
+import os from 'node:os';
+import { fileURLToPath } from 'node:url';
+
+const MAGIC = 0xEA0BA51C;
+
+const DIR_MARK2 = {
+ Resources: 0x02, Sources: 0x03, ImportedTypeLibraries: 0x05,
+ Miscellaneous: 0x06, Packages: 0x07,
+};
+
+// -------------------------- Parser (binary -> tree) --------------------------
+
+function parse(buffer) {
+ const buf = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
+ const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
+ const decoder = new TextDecoder('utf-8');
+ let pos = 0;
+ let entryCount = 0;
+
+ function readU32() { const v = view.getUint32(pos, true); pos += 4; return v; }
+ function readU16() { const v = view.getUint16(pos, true); pos += 2; return v; }
+ function readI16() { const v = view.getInt16(pos, true); pos += 2; return v; }
+ function readU8() { const v = view.getUint8(pos); pos += 1; return v; }
+ function readStr() {
+ const len = readU32();
+ if (len === 0) return '';
+ const s = decoder.decode(buf.subarray(pos, pos + len));
+ pos += len;
+ return s;
+ }
+ function readBlob() {
+ const len = readU32();
+ const b = Buffer.from(buf.subarray(pos, pos + len));
+ pos += len;
+ return b;
+ }
+
+ const magic = readU32();
+ if (magic !== MAGIC)
+ throw new Error(
+ `Bad magic: 0x${magic.toString(16).padStart(8, '0').toUpperCase()}, ` +
+ `expected 0x${MAGIC.toString(16).padStart(8, '0').toUpperCase()}`);
+
+ function readEntry() {
+ const kind = readI16();
+ const name = readStr();
+ const mark1 = readU16();
+ pos += 10; // reserved padding -- always zeros
+ const mark2 = readU8();
+ entryCount++;
+
+ if (kind === 1 && entryCount > 1) {
+ const content = readBlob();
+ const revisionCount = readU32();
+ const revisions = [];
+ for (let i = 0; i < revisionCount; i++) revisions.push(readU32());
+ return { kind: 'file', name, mark1, mark2, content, revisions };
+ }
+
+ const count = readU32();
+ const children = [];
+ for (let i = 0; i < count; i++) children.push(readEntry());
+ return { kind: 'directory', name, mark1, mark2, children };
+ }
+
+ return readEntry();
+}
+
+// -------------------------- Serializer (tree -> binary) ----------------------
+
+function serialize(root) {
+ const chunks = [];
+ const encoder = new TextEncoder();
+
+ function writeU32(v) { const b = Buffer.alloc(4); b.writeUInt32LE(v); chunks.push(b); }
+ function writeI16(v) { const b = Buffer.alloc(2); b.writeInt16LE(v); chunks.push(b); }
+ function writeU16(v) { const b = Buffer.alloc(2); b.writeUInt16LE(v); chunks.push(b); }
+ function writeU8(v) { chunks.push(Buffer.from([v])); }
+ function writeStr(s) {
+ const e = encoder.encode(s);
+ writeU32(e.length);
+ if (e.length) chunks.push(Buffer.from(e));
+ }
+ function writeBlob(data) {
+ writeU32(data.length);
+ if (data.length) chunks.push(Buffer.from(data));
+ }
+
+ writeU32(MAGIC);
+ let isFirst = true;
+
+ function writeEntry(entry) {
+ const isRoot = isFirst;
+ isFirst = false;
+
+ if (entry.kind === 'file' && !isRoot) {
+ writeI16(1);
+ writeStr(entry.name);
+ writeU16(entry.mark1 ?? 0x0002);
+ chunks.push(Buffer.alloc(10));
+ writeU8(entry.mark2 ?? 0x00);
+ writeBlob(entry.content);
+ const revs = entry.revisions ?? [];
+ writeU32(revs.length);
+ for (const r of revs) writeU32(r);
+ } else {
+ writeI16(isRoot ? 1 : 2);
+ writeStr(entry.name);
+ writeU16(entry.mark1 ?? 0x0000);
+ chunks.push(Buffer.alloc(10));
+ writeU8(entry.mark2 ?? 0x00);
+ const children = entry.children ?? [];
+ writeU32(children.length);
+ for (const child of children) writeEntry(child);
+ }
+ }
+
+ writeEntry(root);
+ return Buffer.concat(chunks);
+}
+
+// -------------------------- Import (binary -> disk) --------------------------
+
+function doImport(inputPath, outputDir, { quiet = false } = {}) {
+ const root = parse(fs.readFileSync(inputPath));
+ if (!outputDir) outputDir = root.name;
+
+ let fileCount = 0, dirCount = 0;
+
+ function extract(entry, parentDir) {
+ if (entry.kind === 'file') {
+ fs.writeFileSync(path.join(parentDir, entry.name), entry.content);
+ fileCount++;
+ } else {
+ const dir = path.join(parentDir, entry.name);
+ fs.mkdirSync(dir, { recursive: true });
+ dirCount++;
+ for (const child of entry.children) extract(child, dir);
+ }
+ }
+
+ fs.mkdirSync(outputDir, { recursive: true });
+ for (const child of root.children) extract(child, outputDir);
+ if (!quiet) console.log(`Imported "${root.name}" -> ${outputDir}/ (${fileCount} files, ${dirCount} directories)`);
+ return { name: root.name, fileCount, dirCount };
+}
+
+// -------------------------- Export (disk -> binary) --------------------------
+
+function mark2For(name, isDir) {
+ if (isDir) return DIR_MARK2[name] ?? 0x00;
+ return name === 'Settings' ? 0x04 : 0x00;
+}
+
+function buildTree(dirPath) {
+ const name = path.basename(dirPath);
+ const entries = fs.readdirSync(dirPath, { withFileTypes: true });
+ const subdirs = entries.filter(e => e.isDirectory()).sort((a, b) => a.name.localeCompare(b.name));
+ const files = entries.filter(e => e.isFile()).sort((a, b) => a.name.localeCompare(b.name));
+
+ const children = [];
+ for (const d of subdirs) {
+ children.push(buildTree(path.join(dirPath, d.name)));
+ }
+ for (const f of files) {
+ children.push({
+ kind: 'file', name: f.name,
+ mark1: 0x0002, mark2: mark2For(f.name, false),
+ content: fs.readFileSync(path.join(dirPath, f.name)),
+ revisions: [],
+ });
+ }
+ return {
+ kind: 'directory', name,
+ mark1: 0x0000, mark2: mark2For(name, true),
+ children,
+ };
+}
+
+function doExport(inputDir, outputPath, { quiet = false } = {}) {
+ const root = buildTree(path.resolve(inputDir));
+ const buf = serialize(root);
+ fs.writeFileSync(outputPath, buf);
+
+ let fileCount = 0, dirCount = 0;
+ function count(e) {
+ if (e.kind === 'file') fileCount++;
+ else { dirCount++; for (const c of e.children) count(c); }
+ }
+ for (const c of root.children) count(c);
+ if (!quiet) console.log(`Exported "${root.name}" -> ${outputPath} (${buf.length} bytes, ${fileCount} files, ${dirCount} directories)`);
+ return { name: root.name, size: buf.length, fileCount, dirCount };
+}
+
+// -------------------------- Self-test ----------------------------------------
+
+function selfTest() {
+ const scriptDir = path.dirname(fileURLToPath(import.meta.url));
+ const samplePath = path.join(scriptDir, '..', 'indexer', 'sample.twinpack');
+ if (!fs.existsSync(samplePath)) {
+ console.error(`Sample not found: ${samplePath}\n(requires indexer/sample.twinpack from the repository)`);
+ process.exit(1);
+ }
+
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'impexp-test-'));
+ console.log(`Self-test workdir: ${tmpDir}\n`);
+
+ let passed = 0, failed = 0;
+ function test(name, fn) {
+ try { fn(); console.log(` [PASS] ${name}`); passed++; }
+ catch (e) { console.log(` [FAIL] ${name}\n ${e.message}`); failed++; }
+ }
+ function eq(a, b, msg) {
+ if (a !== b) throw new Error(`${msg}: expected ${b}, got ${a}`);
+ }
+
+ function treeFiles(entry, prefix) {
+ if (entry.kind === 'file') return [{ p: prefix + entry.name, d: entry.content }];
+ const out = [];
+ for (const c of entry.children) out.push(...treeFiles(c, prefix + entry.name + '/'));
+ return out.sort((a, b) => a.p.localeCompare(b.p));
+ }
+
+ function diskFiles(dir, prefix) {
+ const out = [];
+ for (const e of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
+ if (e.isDirectory()) out.push(...diskFiles(path.join(dir, e.name), prefix + e.name + '/'));
+ else out.push({ p: prefix + e.name, d: fs.readFileSync(path.join(dir, e.name)) });
+ }
+ return out;
+ }
+
+ try {
+ const sampleBuf = fs.readFileSync(samplePath);
+ let root;
+
+ test('Parse sample.twinpack', () => {
+ root = parse(sampleBuf);
+ eq(root.name, 'CustomControlsPackage', 'root name');
+ let fc = 0, dc = 0;
+ function cnt(e) { if (e.kind === 'file') fc++; else { dc++; for (const c of e.children) cnt(c); } }
+ for (const c of root.children) cnt(c);
+ eq(fc, 22, 'file count');
+ eq(dc, 7, 'dir count');
+ });
+
+ test('In-memory round-trip (parse -> serialize -> re-parse)', () => {
+ const buf2 = serialize(root);
+ const root2 = parse(buf2);
+ const f1 = treeFiles(root, ''), f2 = treeFiles(root2, '');
+ eq(f1.length, f2.length, 'file count');
+ for (let i = 0; i < f1.length; i++) {
+ eq(f1[i].p, f2[i].p, `path[${i}]`);
+ if (!Buffer.from(f1[i].d).equals(Buffer.from(f2[i].d)))
+ throw new Error(`content mismatch: ${f1[i].p}`);
+ }
+ });
+
+ test('Serializer idempotence (double round-trip)', () => {
+ const once = serialize(parse(sampleBuf));
+ const twice = serialize(parse(once));
+ if (!once.equals(twice)) throw new Error(`${once.length} vs ${twice.length} bytes`);
+ });
+
+ test('Disk round-trip (import -> export -> re-import)', () => {
+ const dir1 = path.join(tmpDir, 'import1');
+ const rtFile = path.join(tmpDir, 'roundtrip.twinpack');
+ const dir2 = path.join(tmpDir, 'import2');
+ doImport(samplePath, dir1, { quiet: true });
+ doExport(dir1, rtFile, { quiet: true });
+ doImport(rtFile, dir2, { quiet: true });
+ const a = diskFiles(dir1, ''), b = diskFiles(dir2, '');
+ eq(a.length, b.length, 'file count');
+ for (let i = 0; i < a.length; i++) {
+ eq(a[i].p, b[i].p, `path[${i}]`);
+ if (!a[i].d.equals(b[i].d)) throw new Error(`content mismatch: ${a[i].p}`);
+ }
+ });
+
+ test('Empty project round-trip', () => {
+ const tree = { kind: 'directory', name: 'Empty', mark1: 0, mark2: 0, children: [] };
+ const rt = parse(serialize(tree));
+ eq(rt.name, 'Empty', 'name');
+ eq(rt.children.length, 0, 'children');
+ });
+
+ test('Single-file project round-trip', () => {
+ const content = Buffer.from('Hello twinBASIC');
+ const tree = {
+ kind: 'directory', name: 'Mini', mark1: 0, mark2: 0,
+ children: [{ kind: 'file', name: 'test.twin', mark1: 2, mark2: 0, content, revisions: [] }],
+ };
+ const rt = parse(serialize(tree));
+ eq(rt.children.length, 1, 'children');
+ eq(rt.children[0].name, 'test.twin', 'filename');
+ if (!Buffer.from(rt.children[0].content).equals(content))
+ throw new Error('content mismatch');
+ });
+
+ test('Bad magic rejected', () => {
+ try { parse(Buffer.from('not a twinpack!!')); throw new Error('should have thrown'); }
+ catch (e) { if (!e.message.includes('Bad magic')) throw e; }
+ });
+
+ } finally {
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+ }
+
+ console.log(`\n${passed}/${passed + failed} tests passed.`);
+ if (failed > 0) process.exit(1);
+}
+
+// -------------------------- CLI ----------------------------------------------
+
+const USAGE = `Usage:
+ impexp import [output_dir]
+ impexp export
+ impexp --self-test`;
+
+const [cmd, ...rest] = process.argv.slice(2);
+
+if (cmd === '--self-test') selfTest();
+else if (cmd === 'import' && rest.length >= 1) doImport(rest[0], rest[1]);
+else if (cmd === 'export' && rest.length >= 2) doExport(rest[0], rest[1]);
+else { console.error(USAGE); process.exit(1); }
diff --git a/scripts/impexp.py b/scripts/impexp.py
new file mode 100644
index 00000000..13d9ffaa
--- /dev/null
+++ b/scripts/impexp.py
@@ -0,0 +1,417 @@
+#!/usr/bin/env python3
+# Copyright (c) 2026 TWINBASIC LTD
+# SPDX-License-Identifier: MIT
+"""
+impexp.py -- standalone twinpack/twinproj import/export tool.
+No external dependencies; requires Python 3.6+.
+
+Usage:
+ python impexp.py import [output_dir]
+ python impexp.py export
+ python impexp.py --self-test
+"""
+
+import os
+import struct
+import sys
+
+MAGIC = 0xEA0BA51C
+
+DIR_MARK2 = {
+ 'Resources': 0x02, 'Sources': 0x03, 'ImportedTypeLibraries': 0x05,
+ 'Miscellaneous': 0x06, 'Packages': 0x07,
+}
+
+# -------------------------- Parser (binary -> tree) --------------------------
+
+
+def parse(data):
+ pos = [0]
+
+ def read_u32():
+ v, = struct.unpack_from(' 1:
+ content = read_blob()
+ revision_count = read_u32()
+ revisions = [read_u32() for _ in range(revision_count)]
+ return dict(kind='file', name=name, mark1=mark1, mark2=mark2,
+ content=content, revisions=revisions)
+
+ count = read_u32()
+ children = [read_entry() for _ in range(count)]
+ return dict(kind='directory', name=name, mark1=mark1, mark2=mark2,
+ children=children)
+
+ return read_entry()
+
+
+# -------------------------- Serializer (tree -> binary) ----------------------
+
+
+def serialize(root):
+ chunks = []
+
+ def write_u32(v):
+ chunks.append(struct.pack(' disk) --------------------------
+
+
+def do_import(input_path, output_dir, *, quiet=False):
+ with open(input_path, 'rb') as f:
+ root = parse(f.read())
+
+ if not output_dir:
+ output_dir = root['name']
+
+ file_count = 0
+ dir_count = 0
+
+ def extract(entry, parent_dir):
+ nonlocal file_count, dir_count
+ if entry['kind'] == 'file':
+ with open(os.path.join(parent_dir, entry['name']), 'wb') as f:
+ f.write(entry['content'])
+ file_count += 1
+ else:
+ d = os.path.join(parent_dir, entry['name'])
+ os.makedirs(d, exist_ok=True)
+ dir_count += 1
+ for child in entry['children']:
+ extract(child, d)
+
+ os.makedirs(output_dir, exist_ok=True)
+ for child in root['children']:
+ extract(child, output_dir)
+ if not quiet:
+ print(f'Imported "{root["name"]}" -> {output_dir}/'
+ f' ({file_count} files, {dir_count} directories)')
+ return dict(name=root['name'], file_count=file_count, dir_count=dir_count)
+
+
+# -------------------------- Export (disk -> binary) --------------------------
+
+
+def _mark2_for(name, is_dir):
+ if is_dir:
+ return DIR_MARK2.get(name, 0x00)
+ return 0x04 if name == 'Settings' else 0x00
+
+
+def _build_tree(dir_path):
+ name = os.path.basename(os.path.abspath(dir_path))
+ listing = sorted(os.listdir(dir_path))
+ subdirs = [e for e in listing if os.path.isdir(os.path.join(dir_path, e))]
+ files = [e for e in listing if os.path.isfile(os.path.join(dir_path, e))]
+
+ children = []
+ for d in subdirs:
+ children.append(_build_tree(os.path.join(dir_path, d)))
+ for f in files:
+ with open(os.path.join(dir_path, f), 'rb') as fh:
+ content = fh.read()
+ children.append(dict(
+ kind='file', name=f,
+ mark1=0x0002, mark2=_mark2_for(f, False),
+ content=content, revisions=[],
+ ))
+ return dict(
+ kind='directory', name=name,
+ mark1=0x0000, mark2=_mark2_for(name, True),
+ children=children,
+ )
+
+
+def do_export(input_dir, output_path, *, quiet=False):
+ root = _build_tree(input_dir)
+ buf = serialize(root)
+ with open(output_path, 'wb') as f:
+ f.write(buf)
+
+ file_count = 0
+ dir_count = 0
+
+ def count(e):
+ nonlocal file_count, dir_count
+ if e['kind'] == 'file':
+ file_count += 1
+ else:
+ dir_count += 1
+ for c in e['children']:
+ count(c)
+
+ for c in root['children']:
+ count(c)
+ if not quiet:
+ print(f'Exported "{root["name"]}" -> {output_path}'
+ f' ({len(buf)} bytes, {file_count} files, {dir_count} directories)')
+ return dict(name=root['name'], size=len(buf),
+ file_count=file_count, dir_count=dir_count)
+
+
+# -------------------------- Self-test ----------------------------------------
+
+
+def _self_test():
+ import shutil
+ import tempfile
+
+ script_dir = os.path.dirname(os.path.abspath(__file__))
+ sample_path = os.path.join(script_dir, '..', 'indexer', 'sample.twinpack')
+ if not os.path.isfile(sample_path):
+ print(f'Sample not found: {sample_path}\n'
+ f'(requires indexer/sample.twinpack from the repository)',
+ file=sys.stderr)
+ sys.exit(1)
+
+ tmp_dir = tempfile.mkdtemp(prefix='impexp-test-')
+ print(f'Self-test workdir: {tmp_dir}\n')
+
+ passed = [0]
+ failed = [0]
+
+ def test(name, fn):
+ try:
+ fn()
+ print(f' [PASS] {name}')
+ passed[0] += 1
+ except Exception as e:
+ print(f' [FAIL] {name}\n {e}')
+ failed[0] += 1
+
+ def eq(a, b, msg):
+ if a != b:
+ raise AssertionError(f'{msg}: expected {b}, got {a}')
+
+ def tree_files(entry, prefix):
+ if entry['kind'] == 'file':
+ return [(prefix + entry['name'], entry['content'])]
+ out = []
+ for c in entry['children']:
+ out.extend(tree_files(c, prefix + entry['name'] + '/'))
+ out.sort(key=lambda x: x[0])
+ return out
+
+ def disk_files(d, prefix=''):
+ out = []
+ for e in sorted(os.listdir(d)):
+ full = os.path.join(d, e)
+ if os.path.isdir(full):
+ out.extend(disk_files(full, prefix + e + '/'))
+ elif os.path.isfile(full):
+ with open(full, 'rb') as f:
+ out.append((prefix + e, f.read()))
+ return out
+
+ try:
+ with open(sample_path, 'rb') as f:
+ sample_buf = f.read()
+
+ root = [None]
+
+ def t_parse():
+ root[0] = parse(sample_buf)
+ eq(root[0]['name'], 'CustomControlsPackage', 'root name')
+ fc = dc = 0
+ def cnt(e):
+ nonlocal fc, dc
+ if e['kind'] == 'file': fc += 1
+ else:
+ dc += 1
+ for c in e['children']: cnt(c)
+ for c in root[0]['children']: cnt(c)
+ eq(fc, 22, 'file count')
+ eq(dc, 7, 'dir count')
+ test('Parse sample.twinpack', t_parse)
+
+ def t_inmem():
+ buf2 = serialize(root[0])
+ root2 = parse(buf2)
+ f1, f2 = tree_files(root[0], ''), tree_files(root2, '')
+ eq(len(f1), len(f2), 'file count')
+ for i in range(len(f1)):
+ eq(f1[i][0], f2[i][0], f'path[{i}]')
+ if f1[i][1] != f2[i][1]:
+ raise AssertionError(f'content mismatch: {f1[i][0]}')
+ test('In-memory round-trip (parse -> serialize -> re-parse)', t_inmem)
+
+ def t_idempotent():
+ once = serialize(parse(sample_buf))
+ twice = serialize(parse(once))
+ if once != twice:
+ raise AssertionError(f'{len(once)} vs {len(twice)} bytes')
+ test('Serializer idempotence (double round-trip)', t_idempotent)
+
+ def t_disk():
+ dir1 = os.path.join(tmp_dir, 'import1')
+ rt_file = os.path.join(tmp_dir, 'roundtrip.twinpack')
+ dir2 = os.path.join(tmp_dir, 'import2')
+ do_import(sample_path, dir1, quiet=True)
+ do_export(dir1, rt_file, quiet=True)
+ do_import(rt_file, dir2, quiet=True)
+ a, b = disk_files(dir1), disk_files(dir2)
+ eq(len(a), len(b), 'file count')
+ for i in range(len(a)):
+ eq(a[i][0], b[i][0], f'path[{i}]')
+ if a[i][1] != b[i][1]:
+ raise AssertionError(f'content mismatch: {a[i][0]}')
+ test('Disk round-trip (import -> export -> re-import)', t_disk)
+
+ def t_empty():
+ tree = dict(kind='directory', name='Empty',
+ mark1=0, mark2=0, children=[])
+ rt = parse(serialize(tree))
+ eq(rt['name'], 'Empty', 'name')
+ eq(len(rt['children']), 0, 'children')
+ test('Empty project round-trip', t_empty)
+
+ def t_single():
+ content = b'Hello twinBASIC'
+ tree = dict(kind='directory', name='Mini', mark1=0, mark2=0,
+ children=[
+ dict(kind='file', name='test.twin', mark1=2,
+ mark2=0, content=content, revisions=[]),
+ ])
+ rt = parse(serialize(tree))
+ eq(len(rt['children']), 1, 'children')
+ eq(rt['children'][0]['name'], 'test.twin', 'filename')
+ if rt['children'][0]['content'] != content:
+ raise AssertionError('content mismatch')
+ test('Single-file project round-trip', t_single)
+
+ def t_bad_magic():
+ try:
+ parse(b'not a twinpack!!')
+ raise AssertionError('should have thrown')
+ except ValueError as e:
+ if 'Bad magic' not in str(e):
+ raise
+ test('Bad magic rejected', t_bad_magic)
+
+ finally:
+ shutil.rmtree(tmp_dir)
+
+ print(f'\n{passed[0]}/{passed[0] + failed[0]} tests passed.')
+ if failed[0] > 0:
+ sys.exit(1)
+
+
+# -------------------------- CLI ----------------------------------------------
+
+USAGE = """\
+Usage:
+ impexp import [output_dir]
+ impexp export
+ impexp --self-test"""
+
+
+def main():
+ args = sys.argv[1:]
+ cmd = args[0] if args else ''
+ rest = args[1:]
+
+ if cmd == '--self-test':
+ _self_test()
+ elif cmd == 'import' and len(rest) >= 1:
+ do_import(rest[0], rest[1] if len(rest) > 1 else None)
+ elif cmd == 'export' and len(rest) >= 2:
+ do_export(rest[0], rest[1])
+ else:
+ print(USAGE, file=sys.stderr)
+ sys.exit(1)
+
+
+if __name__ == '__main__':
+ main()