@openmixer/plugin-qualify 0.13.0 → 0.14.1
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 +34 -0
- package/dist/hosting-codes.d.ts +15 -89
- package/dist/hosting-codes.generated.d.ts +76 -0
- package/dist/hosting-codes.generated.js +84 -0
- package/dist/hosting-codes.generated.js.map +1 -0
- package/dist/hosting-codes.js +10 -87
- package/dist/hosting-codes.js.map +1 -1
- package/dist/qualify-declarations.d.ts +26 -0
- package/dist/qualify-declarations.js +26 -0
- package/dist/qualify-declarations.js.map +1 -1
- package/package.json +3 -3
- package/src/hosting-codes.generated.ts +84 -0
- package/src/hosting-codes.ts +12 -178
- package/src/qualify-declarations.ts +29 -0
- package/tools/_declarations.py +11 -0
- package/tools/audit-overlap.sh +250 -0
- package/tools/clap-core-tsdir.d.mts +16 -0
- package/tools/clap-core-tsdir.mjs +93 -0
- package/tools/descriptors.py +382 -0
- package/tools/gen-hosting-codes.d.mts +8 -0
- package/tools/gen-hosting-codes.mjs +92 -0
- package/tools/gen-python-declarations.mjs +13 -1
- package/tools/scan.py +260 -4
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
3
|
+
# Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
|
|
4
|
+
"""
|
|
5
|
+
The scanned descriptors both scanners write (owner spec 6.6 and 6.9): one file per plugin, in
|
|
6
|
+
omx-contract's descriptor words, named by the plugin's id, its version and the SHA-256 of its
|
|
7
|
+
binary, in `descriptor_dir`.
|
|
8
|
+
|
|
9
|
+
This module is the one writer: it resolves the directory, names the file, holds the document to
|
|
10
|
+
the one descriptor schema (omx-contract's) and writes it whole. `scan.py` maps an LV2 plugin into a
|
|
11
|
+
descriptor through lilv and writes it here; for CLAP, `clap-qualify --json` reads the plugin and
|
|
12
|
+
`descriptors.py clap` maps its scan document. A descriptor the schema refuses is never written:
|
|
13
|
+
it is refused with omx-contract's code, `contract.file.invalid`, and the scan goes on.
|
|
14
|
+
|
|
15
|
+
A scan writes only what the plugin states. What it does not state is `unknown` (a unit, a scale,
|
|
16
|
+
a meaning), or absent; a fact of the format that has no descriptor word is carried verbatim in
|
|
17
|
+
`extensions.<format>` under the field the format names it by.
|
|
18
|
+
|
|
19
|
+
Usage:
|
|
20
|
+
clap-qualify --json <path.clap>... | descriptors.py clap [--descriptor-dir DIR | --system] [-]
|
|
21
|
+
descriptors.py clap [--descriptor-dir DIR | --system] scan.json
|
|
22
|
+
"""
|
|
23
|
+
import argparse
|
|
24
|
+
import hashlib
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import re
|
|
28
|
+
import sys
|
|
29
|
+
|
|
30
|
+
from _declarations import (
|
|
31
|
+
DESCRIPTOR_CODE_BINARY_UNHASHED,
|
|
32
|
+
DESCRIPTOR_CODE_DIR_UNWRITABLE,
|
|
33
|
+
DESCRIPTOR_CODE_SCHEMA,
|
|
34
|
+
DESCRIPTOR_CODE_SCHEMA_MISSING,
|
|
35
|
+
DESCRIPTOR_DIR_STATE_SUBDIR,
|
|
36
|
+
DESCRIPTOR_DIR_SYSTEM,
|
|
37
|
+
DESCRIPTOR_HASH_PREFIX,
|
|
38
|
+
DESCRIPTOR_SCHEMA_ENV,
|
|
39
|
+
DESCRIPTOR_SCHEMA_FLOOR,
|
|
40
|
+
DESCRIPTOR_SCHEMA_INSTALLED,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
SCHEMA_URL = "https://github.com/FreeMixer/omx-contract/raw/v3/schema/omx-contract.schema.json"
|
|
44
|
+
TOOLS = os.path.dirname(os.path.abspath(__file__))
|
|
45
|
+
PKG = os.path.dirname(TOOLS)
|
|
46
|
+
|
|
47
|
+
# the plugin features the descriptor has words for: CLAP's own names, LV2 classes mapped onto them
|
|
48
|
+
FEATURE_WORDS = ("audio-effect", "instrument", "note-effect", "analyzer")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class DescriptorRefused(Exception):
|
|
52
|
+
"""A descriptor that is not written, with the code it is refused by."""
|
|
53
|
+
|
|
54
|
+
def __init__(self, code, problem):
|
|
55
|
+
super().__init__(f"{code}: {problem}")
|
|
56
|
+
self.code = code
|
|
57
|
+
self.problem = problem
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ---- the directory and the name -----------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
def descriptor_dir(named=None, system=False, env=None, home=None):
|
|
63
|
+
"""`descriptor_dir`: the directory named, else a system service's, else the user's state directory."""
|
|
64
|
+
if named:
|
|
65
|
+
return named
|
|
66
|
+
if system:
|
|
67
|
+
return DESCRIPTOR_DIR_SYSTEM
|
|
68
|
+
env = os.environ if env is None else env
|
|
69
|
+
state = env.get("XDG_STATE_HOME") or os.path.join(home or os.path.expanduser("~"), ".local", "state")
|
|
70
|
+
return os.path.join(state, *DESCRIPTOR_DIR_STATE_SUBDIR.split("/"))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _safe(text):
|
|
74
|
+
return re.sub(r"[^A-Za-z0-9._-]", "_", text)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def descriptor_file_name(plugin_id, version, sha256):
|
|
78
|
+
"""`<id>@<version>-<sha256 first 12>.json`, the base-name rule of the qualification record."""
|
|
79
|
+
if not isinstance(sha256, str) or not re.fullmatch(r"[0-9a-f]{64}", sha256):
|
|
80
|
+
raise DescriptorRefused(DESCRIPTOR_CODE_BINARY_UNHASHED, f"{plugin_id}: no SHA-256 of its binary")
|
|
81
|
+
return f"{_safe(plugin_id)}@{_safe(version or 'unversioned')}-{sha256[:DESCRIPTOR_HASH_PREFIX]}.json"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def file_sha256(path):
|
|
85
|
+
"""The SHA-256 of a file's bytes, as plugin-hostd's pin.h computes it."""
|
|
86
|
+
h = hashlib.sha256()
|
|
87
|
+
try:
|
|
88
|
+
with open(path, "rb") as f:
|
|
89
|
+
for chunk in iter(lambda: f.read(1 << 20), b""):
|
|
90
|
+
h.update(chunk)
|
|
91
|
+
except OSError as e:
|
|
92
|
+
raise DescriptorRefused(DESCRIPTOR_CODE_BINARY_UNHASHED, f"{path}: {e.strerror}") from e
|
|
93
|
+
return h.hexdigest()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# ---- the schema ---------------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
def schema_candidates(env=None):
|
|
99
|
+
"""Where the one schema is looked for, in order: the setting, this package's omx-contract, the installed one."""
|
|
100
|
+
env = os.environ if env is None else env
|
|
101
|
+
named = env.get(DESCRIPTOR_SCHEMA_ENV)
|
|
102
|
+
if named:
|
|
103
|
+
return [named]
|
|
104
|
+
rel = os.path.join("schema", "omx-contract.schema.json")
|
|
105
|
+
return [
|
|
106
|
+
os.path.join(PKG, "node_modules", "@openmixer", "omx-contract", rel), # a checkout
|
|
107
|
+
os.path.join(os.path.dirname(PKG), "omx-contract", rel), # installed beside this package
|
|
108
|
+
DESCRIPTOR_SCHEMA_INSTALLED,
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class SchemaValidator:
|
|
113
|
+
"""omx-contract's schema, compiled once; `check` refuses a document it does not hold."""
|
|
114
|
+
|
|
115
|
+
def __init__(self, path=None, env=None):
|
|
116
|
+
found = [path] if path else [p for p in schema_candidates(env) if os.path.isfile(p)]
|
|
117
|
+
if not found or not os.path.isfile(found[0]):
|
|
118
|
+
raise DescriptorRefused(DESCRIPTOR_CODE_SCHEMA_MISSING,
|
|
119
|
+
f"no omx-contract schema at {', '.join(schema_candidates(env))}")
|
|
120
|
+
self.path = found[0]
|
|
121
|
+
try:
|
|
122
|
+
import jsonschema
|
|
123
|
+
except ImportError as e:
|
|
124
|
+
raise DescriptorRefused(DESCRIPTOR_CODE_SCHEMA_MISSING, "python3-jsonschema is not installed") from e
|
|
125
|
+
with open(self.path, encoding="utf-8") as f:
|
|
126
|
+
schema = json.load(f)
|
|
127
|
+
cls = jsonschema.validators.validator_for(schema)
|
|
128
|
+
self._validator = cls(schema)
|
|
129
|
+
self.version = schema.get("x-omx-schema-version")
|
|
130
|
+
|
|
131
|
+
def check(self, doc):
|
|
132
|
+
if not isinstance(doc, dict) or doc.get("origin") != "scanned":
|
|
133
|
+
raise DescriptorRefused(DESCRIPTOR_CODE_SCHEMA, "a scanned descriptor carries origin scanned")
|
|
134
|
+
errors = sorted(self._validator.iter_errors(doc), key=lambda e: list(e.absolute_path))
|
|
135
|
+
if errors:
|
|
136
|
+
e = errors[0]
|
|
137
|
+
where = "/".join(str(p) for p in e.absolute_path) or "(the file)"
|
|
138
|
+
raise DescriptorRefused(DESCRIPTOR_CODE_SCHEMA, f"{where}: {e.message}")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def write_descriptor(directory, doc, sha256, validator):
|
|
142
|
+
"""Hold `doc` to the schema and write it whole into `directory`; the path written, or DescriptorRefused."""
|
|
143
|
+
validator.check(doc)
|
|
144
|
+
name = descriptor_file_name(doc["plugin"]["id"], doc["plugin"].get("version"), sha256)
|
|
145
|
+
try:
|
|
146
|
+
os.makedirs(directory, mode=0o700, exist_ok=True)
|
|
147
|
+
path = os.path.join(directory, name)
|
|
148
|
+
pending = path + ".new"
|
|
149
|
+
with open(pending, "w", encoding="utf-8") as f:
|
|
150
|
+
f.write(json.dumps(doc, indent=2, ensure_ascii=False) + "\n")
|
|
151
|
+
os.replace(pending, path)
|
|
152
|
+
except OSError as e:
|
|
153
|
+
raise DescriptorRefused(DESCRIPTOR_CODE_DIR_UNWRITABLE, f"{directory}: {e.strerror}") from e
|
|
154
|
+
return path
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def write_all(directory, described, validator, err=sys.stderr):
|
|
158
|
+
"""Write each `(doc, sha256)`; a refusal is reported by its code and the others go on. (written, refused)."""
|
|
159
|
+
written, refused = [], []
|
|
160
|
+
for doc, sha256 in described:
|
|
161
|
+
try:
|
|
162
|
+
written.append(write_descriptor(directory, doc, sha256, validator))
|
|
163
|
+
except DescriptorRefused as e:
|
|
164
|
+
pid = (doc.get("plugin") or {}).get("id", "?") if isinstance(doc, dict) else "?"
|
|
165
|
+
refused.append((pid, e.code, e.problem))
|
|
166
|
+
err.write(f"descriptor refused: {pid}: {e.code}: {e.problem}\n")
|
|
167
|
+
return written, refused
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# ---- shared words -------------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
def head(fmt, plugin):
|
|
173
|
+
"""The fields every scanned descriptor opens with."""
|
|
174
|
+
return {
|
|
175
|
+
"$schema": SCHEMA_URL,
|
|
176
|
+
"requires": {"omx-contract-schema": DESCRIPTOR_SCHEMA_FLOOR},
|
|
177
|
+
"origin": "scanned",
|
|
178
|
+
"plugin": {"format": fmt, **plugin},
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def link_segment(text):
|
|
183
|
+
"""A path segment or a selector key a link can hold; a character it cannot is written `_`."""
|
|
184
|
+
return re.sub(r"[^A-Za-z0-9_+.-]", "_", text) or "_"
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def link_key(text):
|
|
188
|
+
"""A link's selector key, `[<key>]`; a character it cannot hold is written `_`."""
|
|
189
|
+
return re.sub(r"[^A-Za-z0-9_.-]", "_", text) or "_"
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def link_symbol(text):
|
|
193
|
+
"""A link's symbol: an identifier, `_` for a character it cannot hold."""
|
|
194
|
+
s = re.sub(r"[^A-Za-z0-9_]", "_", text)
|
|
195
|
+
return s if re.match(r"[A-Za-z_]", s) else "_" + s
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def one_line(text):
|
|
199
|
+
text = " ".join((text or "").split())
|
|
200
|
+
return text or None
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def choice_ids(labels):
|
|
204
|
+
"""A choice's ids, from its labels: their letters and digits, `v<n>` where that is empty or taken."""
|
|
205
|
+
ids, seen = [], set()
|
|
206
|
+
for n, label in enumerate(labels):
|
|
207
|
+
word = re.sub(r"[^A-Za-z0-9]", "", label)
|
|
208
|
+
word = word[:1].lower() + word[1:]
|
|
209
|
+
if not word or word in seen:
|
|
210
|
+
word = f"v{n}"
|
|
211
|
+
while word in seen:
|
|
212
|
+
word += "x"
|
|
213
|
+
seen.add(word)
|
|
214
|
+
ids.append(word)
|
|
215
|
+
return ids
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _number(v):
|
|
219
|
+
return isinstance(v, (int, float)) and not isinstance(v, bool) and v == v and abs(v) != float("inf")
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# ---- CLAP: clap-qualify's scan document ---------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
CLAP_DIALECTS = {"clap": "clap", "midi": "midi", "midi_mpe": "midiMpe", "midi2": "midi2"}
|
|
225
|
+
CLAP_LAYOUTS = {"mono": "mono", "stereo": "stereo", "surround": "surround"}
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def clap_control(p, entry):
|
|
229
|
+
flags = set(p.get("flags", []))
|
|
230
|
+
c = {"id": p["id"], "name": p["name"] or str(p["id"])}
|
|
231
|
+
labels = p.get("labels") or []
|
|
232
|
+
if "enum" in flags and labels:
|
|
233
|
+
ids = choice_ids([lb["label"] for lb in labels])
|
|
234
|
+
c["kind"] = "choice"
|
|
235
|
+
c["ids"] = ids
|
|
236
|
+
if all(lb["label"] for lb in labels):
|
|
237
|
+
c["labels"] = [lb["label"] for lb in labels]
|
|
238
|
+
c["values"] = [lb["value"] for lb in labels]
|
|
239
|
+
for i, lb in enumerate(labels):
|
|
240
|
+
if lb["value"] == p.get("default"):
|
|
241
|
+
c["default"] = ids[i]
|
|
242
|
+
else:
|
|
243
|
+
c["kind"] = "travel"
|
|
244
|
+
travel = {
|
|
245
|
+
"min": p["min"],
|
|
246
|
+
"max": p["max"],
|
|
247
|
+
"unit": "unknown",
|
|
248
|
+
"scale": "stepped" if "stepped" in flags else "unknown",
|
|
249
|
+
}
|
|
250
|
+
if "stepped" in flags:
|
|
251
|
+
travel["step"] = 1
|
|
252
|
+
if _number(p.get("default")):
|
|
253
|
+
travel["default"] = p["default"]
|
|
254
|
+
c["travel"] = travel
|
|
255
|
+
if p.get("module"):
|
|
256
|
+
group = [g for g in p["module"].split("/") if g]
|
|
257
|
+
if group:
|
|
258
|
+
c["group"] = group
|
|
259
|
+
c["meaning"] = "bypass" if "bypass" in flags else "unknown"
|
|
260
|
+
for flag in ("hidden", "readonly", "automatable"):
|
|
261
|
+
if flag in flags:
|
|
262
|
+
c[flag] = True
|
|
263
|
+
c["source"] = {"from": f"{entry}[{p['id']}]"}
|
|
264
|
+
return c
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def open_bound(p):
|
|
268
|
+
"""A parameter the plugin leaves unbounded: no travel holds it, so it is carried verbatim instead."""
|
|
269
|
+
labels = p.get("labels") or []
|
|
270
|
+
return not ("enum" in p.get("flags", []) and labels) and not (_number(p.get("min")) and _number(p.get("max")))
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def clap_ports(p):
|
|
274
|
+
ports = []
|
|
275
|
+
for direction, key in (("in", "inputs"), ("out", "outputs")):
|
|
276
|
+
for port in p["audio_ports"][key]:
|
|
277
|
+
q = {"id": port["id"], "name": port["name"] or f"audio {direction} {port['id']}",
|
|
278
|
+
"kind": "audio", "direction": direction, "role": port["role"]}
|
|
279
|
+
if port.get("channels", 0) >= 1:
|
|
280
|
+
q["channels"] = port["channels"]
|
|
281
|
+
layout = CLAP_LAYOUTS.get(port.get("port_type") or "")
|
|
282
|
+
if layout:
|
|
283
|
+
q["layout"] = layout
|
|
284
|
+
ports.append(q)
|
|
285
|
+
notes = p.get("note_port_info")
|
|
286
|
+
for direction, key in (("in", "inputs"), ("out", "outputs")):
|
|
287
|
+
for port in (notes or {}).get(key, []):
|
|
288
|
+
q = {"id": port["id"], "name": port["name"] or f"notes {direction} {port['id']}",
|
|
289
|
+
"kind": "note", "direction": direction}
|
|
290
|
+
dialects = [CLAP_DIALECTS[d] for d in port.get("dialects", []) if d in CLAP_DIALECTS]
|
|
291
|
+
if dialects:
|
|
292
|
+
q["dialects"] = dialects
|
|
293
|
+
ports.append(q)
|
|
294
|
+
return ports
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def clap_descriptor(p, path):
|
|
298
|
+
"""One plugin of clap-qualify's scan document as a scanned descriptor."""
|
|
299
|
+
entry = f"{link_segment(os.path.basename(path))}#clap_entry[{link_key(p['id'])}]"
|
|
300
|
+
plugin = {"id": p["id"], "name": p["name"] or p["id"]}
|
|
301
|
+
if p.get("vendor"):
|
|
302
|
+
plugin["vendor"] = p["vendor"]
|
|
303
|
+
if p.get("version"):
|
|
304
|
+
plugin["version"] = p["version"]
|
|
305
|
+
doc = one_line(p.get("description"))
|
|
306
|
+
if doc:
|
|
307
|
+
plugin["doc"] = doc
|
|
308
|
+
stated = [f for f in p.get("features", []) if f in FEATURE_WORDS]
|
|
309
|
+
plugin["features"] = list(dict.fromkeys(stated)) or ["audio-effect"]
|
|
310
|
+
d = head("clap", plugin)
|
|
311
|
+
params = p.get("params", [])
|
|
312
|
+
d["controls"] = [clap_control(q, entry) for q in params if not open_bound(q)]
|
|
313
|
+
d["ports"] = clap_ports(p)
|
|
314
|
+
if "latency_extension" in p:
|
|
315
|
+
d["latency"] = {"reports": bool(p["latency_extension"])}
|
|
316
|
+
bag = {}
|
|
317
|
+
if p.get("features"):
|
|
318
|
+
bag["clap_plugin_descriptor.features"] = list(p["features"])
|
|
319
|
+
if p.get("url"):
|
|
320
|
+
bag["clap_plugin_descriptor.url"] = p["url"]
|
|
321
|
+
if "latency" in p:
|
|
322
|
+
bag["clap_plugin_latency.get"] = p["latency"]
|
|
323
|
+
unbounded = [q for q in params if open_bound(q)]
|
|
324
|
+
if unbounded:
|
|
325
|
+
bag["clap_plugin_params.get_info"] = unbounded
|
|
326
|
+
if p.get("remote_controls"):
|
|
327
|
+
bag["clap_plugin_remote_controls.get"] = p["remote_controls"]
|
|
328
|
+
if bag:
|
|
329
|
+
d["extensions"] = {"clap": bag}
|
|
330
|
+
d["source"] = {"from": entry}
|
|
331
|
+
return d
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def clap_described(scan, err=sys.stderr):
|
|
335
|
+
"""Every plugin of a clap-qualify scan document as `(doc, sha256)`; a file that held none is reported."""
|
|
336
|
+
if not isinstance(scan, dict) or not isinstance(scan.get("files"), list):
|
|
337
|
+
raise DescriptorRefused(DESCRIPTOR_CODE_SCHEMA, "not a CLAP scan document")
|
|
338
|
+
out = []
|
|
339
|
+
for f in scan["files"]:
|
|
340
|
+
if "plugins" not in f:
|
|
341
|
+
err.write(f"not described: {f.get('path')}: {f.get('error')}\n")
|
|
342
|
+
continue
|
|
343
|
+
for p in f["plugins"]:
|
|
344
|
+
if "error" in p:
|
|
345
|
+
err.write(f"not described: {p.get('id')}: {p['error']}\n")
|
|
346
|
+
continue
|
|
347
|
+
out.append((clap_descriptor(p, f["path"]), f.get("sha256")))
|
|
348
|
+
return out
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def main(argv=None):
|
|
352
|
+
ap = argparse.ArgumentParser(description="Write the scanned descriptors of a CLAP scan document.")
|
|
353
|
+
ap.add_argument("format", choices=["clap"], help="the scan document's format")
|
|
354
|
+
ap.add_argument("scan", nargs="?", default="-", help="clap-qualify --json output (default: stdin)")
|
|
355
|
+
where = ap.add_mutually_exclusive_group()
|
|
356
|
+
where.add_argument("--descriptor-dir", help="where the descriptors go (descriptor_dir)")
|
|
357
|
+
where.add_argument("--system", action="store_true", help=f"a system service's directory, {DESCRIPTOR_DIR_SYSTEM}")
|
|
358
|
+
# intermixed: `clap --descriptor-dir DIR -` puts the scan after an option, and parse_args on Python 3.11
|
|
359
|
+
# and 3.12.3 (Debian bookworm, Ubuntu noble) fills the optional positional with its default first and
|
|
360
|
+
# then refuses the `-` as unrecognized; later interpreters accept it. Intermixed parsing reads it on all.
|
|
361
|
+
args = ap.parse_intermixed_args(argv)
|
|
362
|
+
try:
|
|
363
|
+
text = sys.stdin.read() if args.scan == "-" else open(args.scan, encoding="utf-8").read()
|
|
364
|
+
scan = json.loads(text)
|
|
365
|
+
except (OSError, ValueError) as e:
|
|
366
|
+
sys.stderr.write(f"cannot read the scan document: {e}\n")
|
|
367
|
+
return 2
|
|
368
|
+
try:
|
|
369
|
+
validator = SchemaValidator()
|
|
370
|
+
described = clap_described(scan)
|
|
371
|
+
except DescriptorRefused as e:
|
|
372
|
+
sys.stderr.write(f"{e.code}: {e.problem}\n")
|
|
373
|
+
return 2
|
|
374
|
+
directory = descriptor_dir(args.descriptor_dir, args.system)
|
|
375
|
+
written, refused = write_all(directory, described, validator)
|
|
376
|
+
for path in written:
|
|
377
|
+
print(path)
|
|
378
|
+
return 2 if refused else 0
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
if __name__ == "__main__":
|
|
382
|
+
sys.exit(main())
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
// Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
|
|
3
|
+
/** The committed generated module, `src/hosting-codes.generated.ts`. */
|
|
4
|
+
export const OUT: string;
|
|
5
|
+
/** The generated module's text for a flattened `key: code` map. */
|
|
6
|
+
export function hostingCodesFile(flat: Readonly<Record<string, string>>): string;
|
|
7
|
+
/** The generated module's text from the omx-clap-core modules in `dir`. */
|
|
8
|
+
export function hostingCodesFileFrom(dir: string): Promise<string>;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
3
|
+
// Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
|
|
4
|
+
/*
|
|
5
|
+
* gen-hosting-codes.mjs — `src/hosting-codes.generated.ts`, omx-clap-core's hosting tables flattened
|
|
6
|
+
* into one `key: code` map, as plain data.
|
|
7
|
+
*
|
|
8
|
+
* The codes are omx-clap-core's (tools/clap-core-tsdir.mjs finds its TypeScript modules). They reach
|
|
9
|
+
* `HOSTING_CODE` as a generated module rather than a read at import, so every module a public subpath
|
|
10
|
+
* reaches stays free of `node:` imports and bundles for a browser (#134). The file is committed and
|
|
11
|
+
* checked identical, as `tools/_declarations.py` is; `pnpm build` regenerates it whenever omx-clap-core's
|
|
12
|
+
* modules are found, and keeps the committed one where they are not.
|
|
13
|
+
*
|
|
14
|
+
* Usage:
|
|
15
|
+
* node tools/gen-hosting-codes.mjs --check # exit 1 if the committed file is stale, 2 if omx-clap-core is absent
|
|
16
|
+
* node tools/gen-hosting-codes.mjs --write # (re)write the committed file; omx-clap-core required
|
|
17
|
+
* node tools/gen-hosting-codes.mjs --build # --write when omx-clap-core is found, else keep the committed file
|
|
18
|
+
*/
|
|
19
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
20
|
+
import { dirname, join, resolve } from 'node:path';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
import { clapCoreTsDirCandidates, findClapCoreTsDir, flattenHostingTables, loadHostingTables } from './clap-core-tsdir.mjs';
|
|
23
|
+
|
|
24
|
+
const PKG = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
25
|
+
export const OUT = join(PKG, 'src/hosting-codes.generated.ts');
|
|
26
|
+
|
|
27
|
+
/** The generated module's text for a flattened `key: code` map. */
|
|
28
|
+
export function hostingCodesFile(flat) {
|
|
29
|
+
const lines = Object.entries(flat).map(([key, code]) => ` ${JSON.stringify(key)}: ${JSON.stringify(code)},\n`);
|
|
30
|
+
return (
|
|
31
|
+
`// SPDX-License-Identifier: GPL-3.0-or-later\n` +
|
|
32
|
+
`// Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>\n` +
|
|
33
|
+
`//\n` +
|
|
34
|
+
`// GENERATED by tools/gen-hosting-codes.mjs from omx-clap-core's hosting tables — do not hand-edit.\n` +
|
|
35
|
+
`//\n` +
|
|
36
|
+
`// Regenerate: node tools/gen-hosting-codes.mjs --write\n` +
|
|
37
|
+
`// Verify: node tools/gen-hosting-codes.mjs --check\n` +
|
|
38
|
+
`\n` +
|
|
39
|
+
`/** omx-clap-core's hosting codes, by key: every \`HOSTING_<DIMENSION>_CODES\` table, flattened. */\n` +
|
|
40
|
+
`export const HOSTING_CODE = Object.freeze({\n` +
|
|
41
|
+
lines.join('') +
|
|
42
|
+
`} as const);\n`
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The generated module's text from the omx-clap-core modules in `dir`. */
|
|
47
|
+
export async function hostingCodesFileFrom(dir) {
|
|
48
|
+
return hostingCodesFile(flattenHostingTables(await loadHostingTables(dir)));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function absent() {
|
|
52
|
+
return (
|
|
53
|
+
`omx-clap-core's TypeScript modules were not found in any of: ${clapCoreTsDirCandidates().join(', ')}. ` +
|
|
54
|
+
'Install omx-clap-core-devel (pkg-config omx-clap-core), set OMX_CLAP_CORE_TSDIR to its tsdir, or run make -C native deps.'
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function main() {
|
|
59
|
+
const mode = process.argv[2];
|
|
60
|
+
if (!['--check', '--write', '--build'].includes(mode)) {
|
|
61
|
+
console.error('usage: node tools/gen-hosting-codes.mjs --check|--write|--build');
|
|
62
|
+
process.exit(2);
|
|
63
|
+
}
|
|
64
|
+
const dir = findClapCoreTsDir();
|
|
65
|
+
if (dir === undefined) {
|
|
66
|
+
if (mode === '--build') {
|
|
67
|
+
console.log(`gen-hosting-codes: omx-clap-core not found, keeping the committed ${OUT}`);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
console.error(`gen-hosting-codes: ${absent()}`);
|
|
71
|
+
process.exit(2);
|
|
72
|
+
}
|
|
73
|
+
const content = await hostingCodesFileFrom(dir);
|
|
74
|
+
let onDisk;
|
|
75
|
+
try {
|
|
76
|
+
onDisk = readFileSync(OUT, 'utf8');
|
|
77
|
+
} catch {
|
|
78
|
+
onDisk = undefined;
|
|
79
|
+
}
|
|
80
|
+
if (mode === '--check') {
|
|
81
|
+
if (onDisk !== content) {
|
|
82
|
+
console.error(`gen-hosting-codes: ${OUT} is stale against ${dir} — run with --write`);
|
|
83
|
+
process.exit(1);
|
|
84
|
+
}
|
|
85
|
+
console.log('gen-hosting-codes: up to date');
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (onDisk !== content) writeFileSync(OUT, content);
|
|
89
|
+
console.log(`gen-hosting-codes: ${onDisk === content ? 'up to date' : 'wrote'} ${OUT} from ${dir}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) await main();
|
|
@@ -35,9 +35,21 @@ const PY_NAMES = [
|
|
|
35
35
|
'PLUGIN_TIMEOUT_S',
|
|
36
36
|
'BOOT_TIMEOUT_S',
|
|
37
37
|
'COST_TIMEOUT_S',
|
|
38
|
+
'DESCRIPTOR_DIR_KEY',
|
|
39
|
+
'DESCRIPTOR_DIR_STATE_SUBDIR',
|
|
40
|
+
'DESCRIPTOR_DIR_SYSTEM',
|
|
41
|
+
'DESCRIPTOR_HASH_PREFIX',
|
|
42
|
+
'DESCRIPTOR_SCHEMA_ENV',
|
|
43
|
+
'DESCRIPTOR_SCHEMA_INSTALLED',
|
|
44
|
+
'DESCRIPTOR_SCHEMA_FLOOR',
|
|
45
|
+
'DESCRIPTOR_CODE_SCHEMA',
|
|
46
|
+
'DESCRIPTOR_CODE_SCHEMA_MISSING',
|
|
47
|
+
'DESCRIPTOR_CODE_BINARY_UNHASHED',
|
|
48
|
+
'DESCRIPTOR_CODE_DIR_UNWRITABLE',
|
|
38
49
|
];
|
|
39
50
|
|
|
40
|
-
|
|
51
|
+
/** A Python literal: a tuple, a string (JSON's quoting is Python's for these ASCII words) or a number. */
|
|
52
|
+
const py = (v) => (Array.isArray(v) ? `(${v.join(', ')})` : typeof v === 'string' ? JSON.stringify(v) : String(v));
|
|
41
53
|
|
|
42
54
|
/** `decl` defaults to the built declarations; a test passes moved ones to prove the Python side follows. */
|
|
43
55
|
export function declarationsFile(decl = D) {
|