diff --git a/pycdfpp/__init__.py b/pycdfpp/__init__.py index ffe21602..6e52bc9f 100644 --- a/pycdfpp/__init__.py +++ b/pycdfpp/__init__.py @@ -823,6 +823,30 @@ def _(attribute: Attribute) -> dict: } +@to_dict_skeleton.register(VariableAttribute) +def _(attribute: VariableAttribute) -> dict: + """ + to_dict_skeleton builds a dictionary skeleton of the VariableAttribute object for use with json.dumps or similar functions. + + A variable attribute holds a single entry, so its skeleton uses the same + shape as a global Attribute with a single-element values/types list. + + Parameters + ---------- + attribute: VariableAttribute + input VariableAttribute object + + Returns + ------- + dict + dictionary skeleton of the VariableAttribute + """ + return { + "values": [_stringify_time_values(attribute.value, attribute.type())], + "types": [str(attribute.type())], + } + + @to_dict_skeleton.register(Variable) def _(variable: Variable) -> dict: """ diff --git a/tests/meson.build b/tests/meson.build index 5b1e6cde..5f025440 100644 --- a/tests/meson.build +++ b/tests/meson.build @@ -79,4 +79,8 @@ if node.found() args: [files('wacdfpp_diff/test.mjs')], timeout: 60, ) + test('wacdfpp_yaml', node, + args: [files('wacdfpp_yaml/test.mjs')], + timeout: 60, + ) endif diff --git a/tests/python_skeletons/test.py b/tests/python_skeletons/test.py index ede1bdca..97644601 100755 --- a/tests/python_skeletons/test.py +++ b/tests/python_skeletons/test.py @@ -26,6 +26,16 @@ def test_can_extrac_skeleton(self): self.assertIsNotNone(skel) self.assertEqual(type(skel["attributes"]["tt2000_special_values"]["values"][0][0]), str) + def test_variable_attributes_are_serialized(self): + cdf = make_cdf() + skel = pycdfpp.to_dict_skeleton(cdf) + attrs = skel["variables"]["test_variable"]["attributes"] + self.assertIsNotNone(attrs["attr1"]) + self.assertEqual(attrs["attr1"]["values"], [[1, 2, 3]]) + self.assertEqual(len(attrs["attr1"]["types"]), 1) + # time-valued variable attributes must be stringified like global ones + self.assertEqual(type(attrs["attr2"]["values"][0][0]), str) + if __name__ == '__main__': unittest.main() diff --git a/tests/wacdfpp_yaml/test.mjs b/tests/wacdfpp_yaml/test.mjs new file mode 100644 index 00000000..2b29085e --- /dev/null +++ b/tests/wacdfpp_yaml/test.mjs @@ -0,0 +1,93 @@ +// Pure Node test for wacdfpp/cdf-yaml.js — no WASM required. +// node test.mjs +import { toYaml, buildSkeleton, CDF_TYPE_NAMES } from "../../wacdfpp/cdf-yaml.js"; + +let failures = 0; +function check(name, ok) { + if (ok) console.log(`ok ${name}`); + else { console.error(`FAIL ${name}`); failures += 1; } +} +const eq = (a, b) => JSON.stringify(a) === JSON.stringify(b); + +// --- toYaml: scalars ---------------------------------------------------- +check("string plain", toYaml({ a: "hello" }) === "a: hello\n"); +check("string with space stays plain", toYaml({ a: "hello world" }) === "a: hello world\n"); +check("number-like string quoted", toYaml({ a: "123" }) === 'a: "123"\n'); +check("colon string quoted", toYaml({ a: "x: y" }) === 'a: "x: y"\n'); +check("comma string quoted", toYaml({ a: "a,b" }) === 'a: "a,b"\n'); +check("empty string quoted", toYaml({ a: "" }) === 'a: ""\n'); +check("newline string escaped", toYaml({ a: "x\ny" }) === 'a: "x\\ny"\n'); +check("reserved word quoted", toYaml({ a: "true" }) === 'a: "true"\n'); +check("bool", toYaml({ a: true }) === "a: true\n"); +check("null", toYaml({ a: null }) === "a: null\n"); +check("integer", toYaml({ a: 42 }) === "a: 42\n"); +check("nan -> .nan", toYaml({ a: Number.NaN }) === "a: .nan\n"); +check("inf -> .inf", toYaml({ a: Infinity }) === "a: .inf\n"); +check("-inf -> -.inf", toYaml({ a: -Infinity }) === "a: -.inf\n"); + +// --- toYaml: arrays ----------------------------------------------------- +check("scalar array -> flow", toYaml({ a: [1, 2, 3] }) === "a: [1, 2, 3]\n"); +check("single-scalar array -> flow", toYaml({ a: ["mV"] }) === "a: [mV]\n"); +check("empty array -> []", toYaml({ a: [] }) === "a: []\n"); +check("typed array -> flow", toYaml({ a: new Float64Array([1, 2]) }) === "a: [1, 2]\n"); +check("array-of-array -> block", + toYaml({ a: [[1, 2], "x"] }) === "a:\n - [1, 2]\n - x\n"); + +// --- toYaml: maps ------------------------------------------------------- +check("nested map", toYaml({ a: { b: 1 } }) === "a:\n b: 1\n"); +check("empty map -> {}", toYaml({ a: {} }) === "a: {}\n"); + +// --- toYaml: escaping must produce round-trippable YAML ----------------- +// (keys are emitted verbatim historically; values with leading YAML +// indicators or YAML-1.1 number forms must be quoted or they re-parse wrong.) +check("special key quoted", toYaml({ "min: max": 1 }) === '"min: max": 1\n'); +check("leading-dash value quoted", toYaml({ a: "- pending" }) === 'a: "- pending"\n'); +check("leading-question value quoted", toYaml({ a: "? x" }) === 'a: "? x"\n'); +check("underscore-number string quoted", toYaml({ a: "1_000" }) === 'a: "1_000"\n'); +check("digit-leading string quoted", toYaml({ a: "3things" }) === 'a: "3things"\n'); +check("tilde string quoted", toYaml({ a: "~" }) === 'a: "~"\n'); +check("bare dash quoted", toYaml({ a: "-" }) === 'a: "-"\n'); + +// --- CDF_TYPE_NAMES ----------------------------------------------------- +check("type map double", CDF_TYPE_NAMES[45] === "CDF_DOUBLE"); +check("type map tt2000", CDF_TYPE_NAMES[33] === "CDF_TIME_TT2000"); +check("type map char", CDF_TYPE_NAMES[51] === "CDF_CHAR"); + +// --- buildSkeleton ------------------------------------------------------ +const rawVars = [{ + name: "v", + typeName: "CDF_FLOAT", + shape: [3], + isNrv: false, + compression: "none", + attributes: { units: "mV", scale: new Int32Array([1, 2, 3]) }, + attributeTypes: { units: 51, scale: 4 }, +}]; +const rawGlobals = [{ name: "Project", entries: ["THEMIS", [1, 2]], types: [51, 11] }]; +const skel = buildSkeleton(rawVars, rawGlobals, { compression: "gzip" }); + +check("top-level compression", skel.compression === "gzip"); +check("top-level key order", eq(Object.keys(skel), ["compression", "attributes", "variables"])); +check("global attr values pass-through", eq(skel.attributes.Project.values, ["THEMIS", [1, 2]])); +check("global attr type names", eq(skel.attributes.Project.types, ["CDF_CHAR", "CDF_UINT1"])); + +const v = skel.variables.v; +check("var key order", eq(Object.keys(v), ["type", "shape", "is_nrv", "compression", "attributes"])); +check("var type name", v.type === "CDF_FLOAT"); +check("var shape", eq(v.shape, [3])); +check("var is_nrv", v.is_nrv === false); +check("var compression", v.compression === "none"); +check("var attr units single-entry values", eq(v.attributes.units.values, ["mV"])); +check("var attr units type", eq(v.attributes.units.types, ["CDF_CHAR"])); +check("var attr scale wrapped single entry", + v.attributes.scale.values.length === 1 && eq(Array.from(v.attributes.scale.values[0]), [1, 2, 3])); +check("var attr scale type", eq(v.attributes.scale.types, ["CDF_INT4"])); + +// --- end to end --------------------------------------------------------- +const yaml = toYaml(skel); +check("e2e has variables block", yaml.includes("variables:\n")); +check("e2e var name indented", yaml.includes("\n v:\n")); +check("e2e renders var type name", yaml.includes("type: CDF_FLOAT\n")); +check("e2e renders global char type", yaml.includes("CDF_CHAR")); + +process.exit(failures === 0 ? 0 : 1); diff --git a/wacdfpp/app.js b/wacdfpp/app.js index 424f4804..8253befc 100644 --- a/wacdfpp/app.js +++ b/wacdfpp/app.js @@ -1,11 +1,12 @@ // Orchestration: load (file/URL/?url=/drag-drop), hold current CdfFile + model + // selection, wire search and list selection to re-render. import { loadModule } from "./wasm.js"; -import { rawFromCdfFile, buildModel, filterModel } from "./cdf-model.js"; +import { rawFromCdfFile, buildModel, filterModel, VAR_GROUPS } from "./cdf-model.js"; import { renderList, renderDetail, setSelected } from "./render.js"; import { renderPlot } from "./plot.js"; import { openValidation, openValidationBytes } from "./astralint.js"; import { runCompare, setView, setFilter } from "./compare.js"; +import { toYaml, buildSkeleton } from "./cdf-yaml.js"; const els = { fileInput: document.getElementById("fileInput"), @@ -23,6 +24,7 @@ const els = { app: document.querySelector(".app"), resizer: document.getElementById("resizer"), validateBtn: document.getElementById("validateBtn"), + exportYamlBtn: document.getElementById("exportYamlBtn"), modeToggle: document.getElementById("modeToggle"), compareInputs: document.getElementById("compareInputs"), compareBar: document.getElementById("compareBar"), @@ -42,6 +44,7 @@ let currentModel; // built once per file let currentUrl = null; // source URL of the loaded file (null for local/drag-drop) let currentBytes = null; // raw bytes of the loaded file (for the postMessage handoff) let currentName = null; // file name of the loaded file +let currentCompression = null; // file-level compression (for the YAML skeleton) let selectedName = null; let searchQuery = ""; let busy = false; @@ -52,6 +55,11 @@ function updateValidate() { els.validateBtn.title = ready ? "Validate this CDF against ISTP in AstraLint" : "Load a CDF to validate it against ISTP in AstraLint"; + const exportable = !!currentCdf; + els.exportYamlBtn.disabled = !exportable; + els.exportYamlBtn.title = exportable + ? "Download a YAML metadata skeleton of this CDF" + : "Load a CDF to export its YAML skeleton"; } function setStatus(cls, text) { els.status.className = cls; els.statusText.textContent = text; } @@ -83,6 +91,7 @@ function inspect(data, name, sourceUrl) { currentUrl = null; currentBytes = null; currentName = null; + currentCompression = null; updateValidate(); refreshList(); els.detail.innerHTML = `
ERROR: failed to parse CDF
`; @@ -98,6 +107,7 @@ function inspect(data, name, sourceUrl) { currentUrl = sourceUrl ?? null; currentBytes = data; currentName = name; + currentCompression = cdf.compression(); updateValidate(); els.fileName.textContent = name; els.parseTime.textContent = `parsed in ${dt} ms`; @@ -161,6 +171,20 @@ els.validateBtn.addEventListener("click", () => { if (currentUrl) openValidation(currentUrl); else if (currentBytes) openValidationBytes(currentBytes, currentName ?? "file.cdf"); }); +els.exportYamlBtn.addEventListener("click", () => { + if (!currentModel) return; + // Build from the already-extracted model — no WASM call, no value load. + const rawVars = VAR_GROUPS.flatMap((g) => currentModel.groups[g]); + const yaml = toYaml(buildSkeleton(rawVars, currentModel.globalAttributes, + { compression: currentCompression })); + const base = (currentName ?? "cdf").replace(/\.cdf$/i, ""); + const blob = new Blob([yaml], { type: "application/yaml" }); + const a = document.createElement("a"); + a.href = URL.createObjectURL(blob); + a.download = `${base}.skeleton.yaml`; + a.click(); + URL.revokeObjectURL(a.href); +}); // Reverse handoff: load a CDF handed to us by an opener (e.g. AstraLint's // "Open in CDFpp Explorer"). We post 'cdfpp-ready' after init (see init()). diff --git a/wacdfpp/cdf-model.js b/wacdfpp/cdf-model.js index 857106c3..023fc462 100644 --- a/wacdfpp/cdf-model.js +++ b/wacdfpp/cdf-model.js @@ -58,15 +58,21 @@ export function rawFromCdfFile(cdf) { const rawVars = cdf.variable_names().map(name => { const v = cdf.get_variable(name); const attributes = {}; - for (const an of v.attribute_names) attributes[an] = v.attributes[an]; + const attributeTypes = {}; + for (const an of v.attribute_names) { + attributes[an] = v.attributes[an]; + attributeTypes[an] = v.attribute_types[an]; + } return { name, shape: Array.from(v.shape), typeName: v.type_name, type: v.type, isNrv: v.is_nrv, + compression: v.compression, varType: attributes.VAR_TYPE, attributes, + attributeTypes, }; }); const rawGlobals = cdf.attribute_names().map(name => { diff --git a/wacdfpp/cdf-yaml.js b/wacdfpp/cdf-yaml.js new file mode 100644 index 00000000..832f6db8 --- /dev/null +++ b/wacdfpp/cdf-yaml.js @@ -0,0 +1,149 @@ +// YAML skeleton export for a loaded CDF. Mirrors the *structure* of +// pycdfpp.to_dict_skeleton: metadata only — types, shapes, attributes, never +// array data. Type/compression strings use the Explorer's clean names +// (e.g. CDF_FLOAT), not pycdfpp's DataType.* / CompressionType.* enum reprs. +// +// Pure and Node-tested: toYaml/buildSkeleton take the plain rawVars/rawGlobals +// records cdf-model.js already extracts at load time, so the export reads no +// array data of its own and needs no WASM (app.js feeds it the cached model). + +// CDF type code -> canonical name (see include/cdfpp/cdf-enums.hpp CDF_Types). +export const CDF_TYPE_NAMES = { + 0: "CDF_NONE", + 1: "CDF_INT1", 2: "CDF_INT2", 4: "CDF_INT4", 8: "CDF_INT8", + 11: "CDF_UINT1", 12: "CDF_UINT2", 14: "CDF_UINT4", + 21: "CDF_REAL4", 22: "CDF_REAL8", 41: "CDF_BYTE", + 44: "CDF_FLOAT", 45: "CDF_DOUBLE", + 31: "CDF_EPOCH", 32: "CDF_EPOCH16", 33: "CDF_TIME_TT2000", + 51: "CDF_CHAR", 52: "CDF_UCHAR", +}; + +const typeName = (code) => CDF_TYPE_NAMES[code] ?? `CDF_TYPE_${code}`; + +const isTypedArray = (v) => ArrayBuffer.isView(v) && !(v instanceof DataView); +const isList = (v) => Array.isArray(v) || isTypedArray(v); +const isScalar = (v) => + v === null || v === undefined || + typeof v === "string" || typeof v === "number" || typeof v === "boolean"; + +// Quote a plain string whenever it could be misread on re-parse as structure, +// a number, or a reserved word; otherwise emit it bare for readability. Biased +// toward quoting: a YAML round-trip must return the exact original string. +const QUOTE_RE = /[:#[\]{}&*!|>'"%@`,\n\t]/; +function needsQuote(s) { + if (s === "") return true; + if (s !== s.trim()) return true; // leading/trailing whitespace + if (QUOTE_RE.test(s)) return true; // structural / indicator chars + if (/^[-?:](\s|$)/.test(s)) return true; // leading "- " / "? " / ":" indicator + if (s[0] === "~") return true; // leading null indicator + if (/^[-+.]?\d/.test(s)) return true; // number-ish (incl. 1_000, 0x1a, .5) + if (/^[-+]?\.(inf|nan)$/i.test(s)) return true; // YAML float specials + if (/^(true|false|null|yes|no|on|off)$/i.test(s)) return true; // reserved words + return false; +} + +const keyYaml = (k) => { + const s = String(k); + return needsQuote(s) ? JSON.stringify(s) : s; +}; + +function scalarYaml(v) { + if (v === null || v === undefined) return "null"; + if (typeof v === "boolean") return v ? "true" : "false"; + if (typeof v === "number") { + if (Number.isNaN(v)) return ".nan"; + if (v === Infinity) return ".inf"; + if (v === -Infinity) return "-.inf"; + return String(v); + } + return needsQuote(v) ? JSON.stringify(v) : v; +} + +const flow = (arr) => `[${arr.map(scalarYaml).join(", ")}]`; + +function emitSeqItem(item, indent) { + const pad = " ".repeat(indent); + if (isScalar(item)) return `${pad}- ${scalarYaml(item)}\n`; + if (isList(item)) { + const arr = Array.from(item); + if (arr.length === 0) return `${pad}- []\n`; + if (arr.every(isScalar)) return `${pad}- ${flow(arr)}\n`; + let out = `${pad}-\n`; + for (const sub of arr) out += emitSeqItem(sub, indent + 2); + return out; + } + const keys = Object.keys(item); + if (keys.length === 0) return `${pad}- {}\n`; + let out = ""; + keys.forEach((k, i) => { + const entry = emitEntry(k, item[k], indent + 2); + out += i === 0 ? `${pad}- ${entry.slice(indent + 2)}` : entry; + }); + return out; +} + +function emitEntry(key, value, indent) { + const pad = " ".repeat(indent); + const kk = keyYaml(key); + if (isScalar(value)) return `${pad}${kk}: ${scalarYaml(value)}\n`; + if (isList(value)) { + const arr = Array.from(value); + if (arr.length === 0) return `${pad}${kk}: []\n`; + if (arr.every(isScalar)) return `${pad}${kk}: ${flow(arr)}\n`; + let out = `${pad}${kk}:\n`; + for (const item of arr) out += emitSeqItem(item, indent + 2); + return out; + } + const keys = Object.keys(value); + if (keys.length === 0) return `${pad}${kk}: {}\n`; + let out = `${pad}${kk}:\n`; + for (const k of keys) out += emitEntry(k, value[k], indent + 2); + return out; +} + +// Minimal YAML emitter for the restricted skeleton value space: nested plain +// objects, scalar/nested arrays (and typed arrays), strings, numbers +// (incl. NaN/Inf), booleans and null. +export function toYaml(value) { + if (isScalar(value)) return `${scalarYaml(value)}\n`; + if (isList(value)) { + const arr = Array.from(value); + if (arr.length === 0) return "[]\n"; + if (arr.every(isScalar)) return `${flow(arr)}\n`; + return arr.map((item) => emitSeqItem(item, 0)).join(""); + } + const keys = Object.keys(value); + if (keys.length === 0) return "{}\n"; + return keys.map((k) => emitEntry(k, value[k], 0)).join(""); +} + +// Build the to_dict_skeleton-shaped object from plain raw records. A variable +// attribute holds a single entry, so its values/types are single-element lists, +// matching the global-attribute shape. +export function buildSkeleton(rawVars, rawGlobals, fileMeta = {}) { + const attributes = {}; + for (const a of rawGlobals) + attributes[a.name] = { + values: a.entries, + types: Array.from(a.types).map(typeName), + }; + + const variables = {}; + for (const v of rawVars) { + const attrs = {}; + for (const an of Object.keys(v.attributes)) + attrs[an] = { + values: [v.attributes[an]], + types: [typeName(v.attributeTypes[an])], + }; + variables[v.name] = { + type: v.typeName, + shape: Array.from(v.shape), + is_nrv: v.isNrv, + compression: v.compression, + attributes: attrs, + }; + } + + return { compression: fileMeta.compression, attributes, variables }; +} diff --git a/wacdfpp/meson.build b/wacdfpp/meson.build index 1cc689fc..ebf2a987 100644 --- a/wacdfpp/meson.build +++ b/wacdfpp/meson.build @@ -23,7 +23,7 @@ if meson.get_compiler('cpp').get_id() == 'emscripten' build_by_default: true, extra_files: ['wasm.txt', 'wacdfpp.html', 'app.js', 'cdf-model.js', 'render.js', 'plot-model.js', 'spectrogram.js', 'plot.js', 'astralint.js', - 'wasm.js', 'cdf-diff.js', 'compare.js', + 'wasm.js', 'cdf-diff.js', 'compare.js', 'cdf-yaml.js', 'uPlot.esm.js', 'uPlot.min.css'] ) @@ -38,6 +38,7 @@ if meson.get_compiler('cpp').get_id() == 'emscripten' fs.copyfile('wasm.js', 'wasm.js') fs.copyfile('cdf-diff.js', 'cdf-diff.js') fs.copyfile('compare.js', 'compare.js') + fs.copyfile('cdf-yaml.js', 'cdf-yaml.js') fs.copyfile('uPlot.esm.js', 'uPlot.esm.js') fs.copyfile('uPlot.min.css', 'uPlot.min.css') diff --git a/wacdfpp/wacdfpp.html b/wacdfpp/wacdfpp.html index 7e867fd3..8b076f7c 100644 --- a/wacdfpp/wacdfpp.html +++ b/wacdfpp/wacdfpp.html @@ -427,6 +427,7 @@

CDFpp Explorer

Validate (ISTP) +