ng-form-foundry-transformers 0.2.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.
Files changed (50) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +186 -0
  3. package/dist/core/index.d.ts +14 -0
  4. package/dist/core/index.js +19 -0
  5. package/dist/core/infer.d.ts +15 -0
  6. package/dist/core/infer.js +67 -0
  7. package/dist/core/json-schema.d.ts +26 -0
  8. package/dist/core/json-schema.js +87 -0
  9. package/dist/core/registry.d.ts +18 -0
  10. package/dist/core/registry.js +40 -0
  11. package/dist/core/schema.d.ts +69 -0
  12. package/dist/core/schema.js +15 -0
  13. package/dist/core/transformer.d.ts +48 -0
  14. package/dist/core/transformer.js +2 -0
  15. package/dist/index.d.ts +22 -0
  16. package/dist/index.js +40 -0
  17. package/dist/transformers/json/index.d.ts +8 -0
  18. package/dist/transformers/json/index.js +11 -0
  19. package/dist/transformers/json/json-transformer.d.ts +38 -0
  20. package/dist/transformers/json/json-transformer.js +41 -0
  21. package/dist/transformers/yaml/index.d.ts +11 -0
  22. package/dist/transformers/yaml/index.js +15 -0
  23. package/dist/transformers/yaml/revert.d.ts +13 -0
  24. package/dist/transformers/yaml/revert.js +74 -0
  25. package/dist/transformers/yaml/yaml-transformer.d.ts +32 -0
  26. package/dist/transformers/yaml/yaml-transformer.js +37 -0
  27. package/dist/transformers/yang/adapter.d.ts +40 -0
  28. package/dist/transformers/yang/adapter.js +68 -0
  29. package/dist/transformers/yang/cache.d.ts +18 -0
  30. package/dist/transformers/yang/cache.js +19 -0
  31. package/dist/transformers/yang/engine.d.ts +41 -0
  32. package/dist/transformers/yang/engine.js +2 -0
  33. package/dist/transformers/yang/engines/fake-engine.d.ts +15 -0
  34. package/dist/transformers/yang/engines/fake-engine.js +26 -0
  35. package/dist/transformers/yang/engines/subprocess-engine.d.ts +30 -0
  36. package/dist/transformers/yang/engines/subprocess-engine.js +64 -0
  37. package/dist/transformers/yang/index.d.ts +20 -0
  38. package/dist/transformers/yang/index.js +27 -0
  39. package/dist/transformers/yang/mapper.d.ts +14 -0
  40. package/dist/transformers/yang/mapper.js +96 -0
  41. package/dist/transformers/yang/model.d.ts +111 -0
  42. package/dist/transformers/yang/model.js +13 -0
  43. package/dist/transformers/yang/revert.d.ts +6 -0
  44. package/dist/transformers/yang/revert.js +173 -0
  45. package/dist/transformers/yang/rfc7951.d.ts +38 -0
  46. package/dist/transformers/yang/rfc7951.js +80 -0
  47. package/dist/transformers/yang/yang-transformer.d.ts +16 -0
  48. package/dist/transformers/yang/yang-transformer.js +29 -0
  49. package/package.json +55 -0
  50. package/python/emit_effective_tree.py +227 -0
@@ -0,0 +1,173 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.toFormValue = toFormValue;
4
+ exports.toYangData = toYangData;
5
+ const schema_1 = require("../../core/schema");
6
+ const rfc7951_1 = require("./rfc7951");
7
+ /**
8
+ * The round-trip between a plain form value and RFC 7951 instance data, driven
9
+ * entirely by the resolved {@link EffectiveModel} (the server-side binding).
10
+ *
11
+ * `toFormValue` strips module qualification and decodes each leaf to the shape
12
+ * the form renders; `toYangData` re-encodes, keeps list keys as ordinary
13
+ * members, and drops `config false` state. Neither coerces plain scalar values,
14
+ * so int64/uint64/decimal64 (carried as strings) keep full precision.
15
+ *
16
+ * Per-type wire handling (RFC 7951 §6): `empty` is present as `[null]` /
17
+ * absent; `bits` is a space-separated flag string modeled as a boolean group;
18
+ * `identityref` is `module:identity` qualified only across module boundaries.
19
+ * A `choice` (§7.9) has no wrapper on the wire — the active case's fields are
20
+ * inline; in the form value it becomes `{ __case, ...fields }`, which this
21
+ * module flattens on encode and reconstructs on decode.
22
+ *
23
+ * Counterpart of the forward `mapToSchema` in `mapper.ts`.
24
+ */
25
+ /** Sentinel returned by the encoder to mean "omit this member entirely". */
26
+ const OMIT = Symbol('omit');
27
+ /** RFC 7951 JSON instance data → a plain form value keyed by bare names. */
28
+ function toFormValue(rfc7951, model) {
29
+ return decodeObject(model.roots, asObject(rfc7951), null);
30
+ }
31
+ /** A plain form value → RFC 7951 JSON instance data ready for write-back. */
32
+ function toYangData(value, model) {
33
+ return encodeObject(model.roots, value, null);
34
+ }
35
+ // --- decode (RFC 7951 -> form) ------------------------------------------------
36
+ function decodeObject(children, raw, parentModule) {
37
+ const out = {};
38
+ for (const child of children) {
39
+ if (child.kind === 'choice') {
40
+ const decoded = decodeChoice(child, raw, parentModule);
41
+ if (decoded !== undefined)
42
+ out[child.name] = decoded;
43
+ }
44
+ else {
45
+ const rawChild = readMember(raw, child, parentModule);
46
+ if (rawChild !== undefined)
47
+ out[child.name] = decodeNode(child, rawChild);
48
+ }
49
+ }
50
+ return out;
51
+ }
52
+ function decodeChoice(choice, raw, parentModule) {
53
+ for (const c of choice.cases) {
54
+ const fields = c.children.filter((f) => f.kind !== 'choice');
55
+ if (!fields.some((f) => readMember(raw, f, parentModule) !== undefined))
56
+ continue;
57
+ const caseValue = { [schema_1.CASE_KEY]: c.name };
58
+ for (const f of fields) {
59
+ const rawF = readMember(raw, f, parentModule);
60
+ if (rawF !== undefined)
61
+ caseValue[f.name] = decodeNode(f, rawF);
62
+ }
63
+ return caseValue;
64
+ }
65
+ return choice.default !== undefined ? { [schema_1.CASE_KEY]: choice.default } : undefined;
66
+ }
67
+ function decodeNode(node, raw) {
68
+ switch (node.kind) {
69
+ case 'leaf':
70
+ return decodeLeaf(node, raw);
71
+ case 'leaf-list':
72
+ return Array.isArray(raw) ? [...raw] : raw;
73
+ case 'container':
74
+ return decodeObject(node.children, asObject(raw), node.module);
75
+ case 'list':
76
+ return (Array.isArray(raw) ? raw : []).map((entry) => decodeObject(node.children, asObject(entry), node.module));
77
+ }
78
+ }
79
+ function decodeLeaf(node, raw) {
80
+ switch (node.type.base) {
81
+ case 'empty':
82
+ return true;
83
+ case 'bits': {
84
+ const set = new Set(typeof raw === 'string' ? raw.split(/\s+/).filter(Boolean) : []);
85
+ const group = {};
86
+ for (const bit of node.type.bits ?? [])
87
+ group[bit] = set.has(bit);
88
+ return group;
89
+ }
90
+ case 'identityref':
91
+ return typeof raw === 'string' ? (0, rfc7951_1.bareIdentity)(raw) : raw;
92
+ default:
93
+ return raw;
94
+ }
95
+ }
96
+ // --- encode (form -> RFC 7951) ------------------------------------------------
97
+ function encodeObject(children, value, parentModule) {
98
+ const out = {};
99
+ for (const child of children) {
100
+ if (child.kind === 'choice') {
101
+ encodeChoice(child, value[child.name], parentModule, out);
102
+ continue;
103
+ }
104
+ if (isExcluded(child))
105
+ continue;
106
+ const v = value[child.name];
107
+ if (v === undefined)
108
+ continue;
109
+ const encoded = encodeNode(child, v);
110
+ if (encoded !== OMIT)
111
+ out[(0, rfc7951_1.qualifiedName)(child.name, child.module, parentModule)] = encoded;
112
+ }
113
+ return out;
114
+ }
115
+ function encodeChoice(choice, choiceValue, parentModule, out) {
116
+ if (!choiceValue || typeof choiceValue !== 'object')
117
+ return;
118
+ const caseName = choiceValue[schema_1.CASE_KEY];
119
+ const active = choice.cases.find((c) => c.name === caseName);
120
+ if (!active)
121
+ return;
122
+ // The active case's fields serialize inline at the choice's location.
123
+ for (const f of active.children) {
124
+ if (f.kind === 'choice' || isExcluded(f))
125
+ continue;
126
+ const v = choiceValue[f.name];
127
+ if (v === undefined)
128
+ continue;
129
+ const encoded = encodeNode(f, v);
130
+ if (encoded !== OMIT)
131
+ out[(0, rfc7951_1.qualifiedName)(f.name, f.module, parentModule)] = encoded;
132
+ }
133
+ }
134
+ function encodeNode(node, value) {
135
+ switch (node.kind) {
136
+ case 'leaf':
137
+ return encodeLeaf(node, value);
138
+ case 'leaf-list':
139
+ return Array.isArray(value) ? [...value] : value;
140
+ case 'container':
141
+ return encodeObject(node.children, asObject(value), node.module);
142
+ case 'list':
143
+ return (Array.isArray(value) ? value : []).map((entry) => encodeObject(node.children, asObject(entry), node.module));
144
+ }
145
+ }
146
+ function encodeLeaf(node, value) {
147
+ switch (node.type.base) {
148
+ case 'empty':
149
+ return value ? [null] : OMIT;
150
+ case 'bits': {
151
+ const group = asObject(value);
152
+ return (node.type.bits ?? []).filter((bit) => group[bit]).join(' ');
153
+ }
154
+ case 'identityref':
155
+ return typeof value === 'string' ? (0, rfc7951_1.qualifyIdentity)(value, node.module, node.type) : value;
156
+ default:
157
+ return value;
158
+ }
159
+ }
160
+ // --- helpers ------------------------------------------------------------------
161
+ /** Read a node's value from an RFC 7951 object, tolerating a missing module prefix. */
162
+ function readMember(obj, node, parentModule) {
163
+ const qualified = (0, rfc7951_1.qualifiedName)(node.name, node.module, parentModule);
164
+ if (qualified in obj)
165
+ return obj[qualified];
166
+ return node.name in obj ? obj[node.name] : undefined;
167
+ }
168
+ function isExcluded(node) {
169
+ return 'config' in node && node.config === false;
170
+ }
171
+ function asObject(v) {
172
+ return v && typeof v === 'object' && !Array.isArray(v) ? v : {};
173
+ }
@@ -0,0 +1,38 @@
1
+ import { LeafType } from '../../core/schema';
2
+ import { YangBase, YangType } from './model';
3
+ /** Whether a leaf of this base must keep a string form to preserve precision. */
4
+ export declare function isStringEncoded(base: YangBase): boolean;
5
+ /**
6
+ * Map a resolved YANG type to the form model's four leaf value types.
7
+ *
8
+ * - `boolean` and `empty` → boolean (empty is a present/absent checkbox).
9
+ * - `enumeration` and `identityref` → enum (a dropdown of names).
10
+ * - the fixed-width integers (int/uint 8..32) → number.
11
+ * - everything else — string, binary, leafref, instance-identifier, union, and
12
+ * the string-encoded int64/uint64/decimal64 — renders as text.
13
+ *
14
+ * 64-bit integers and decimal64 map to `'string'` deliberately: a JS `number`
15
+ * cannot hold them without loss, so they stay strings end to end. `bits` has no
16
+ * leaf equivalent and is mapped to a group of boolean checkboxes by the mapper,
17
+ * so it never reaches this function.
18
+ */
19
+ export declare function toFormLeafType(t: YangType): LeafType;
20
+ /**
21
+ * RFC 7951 identityref value (§6.8): `module:identity` when the identity is
22
+ * defined in a different module than the referencing leaf, otherwise the bare
23
+ * identity name.
24
+ */
25
+ export declare function qualifyIdentity(name: string, leafModule: string, t: YangType): string;
26
+ /** The local identity name, dropping any `module:` prefix. */
27
+ export declare function bareIdentity(value: string): string;
28
+ /**
29
+ * RFC 7951 member name for a node (§4). A name is qualified `module:name` when
30
+ * the node is at the top level (no parent module) or its module differs from its
31
+ * parent's; otherwise the bare name is used.
32
+ */
33
+ export declare function qualifiedName(name: string, module: string, parentModule: string | null): string;
34
+ /** Split an RFC 7951 member name into its optional module prefix and local name. */
35
+ export declare function splitQualified(member: string): {
36
+ module?: string;
37
+ name: string;
38
+ };
@@ -0,0 +1,80 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isStringEncoded = isStringEncoded;
4
+ exports.toFormLeafType = toFormLeafType;
5
+ exports.qualifyIdentity = qualifyIdentity;
6
+ exports.bareIdentity = bareIdentity;
7
+ exports.qualifiedName = qualifiedName;
8
+ exports.splitQualified = splitQualified;
9
+ /**
10
+ * RFC 7951 (JSON Encoding of YANG Data) helpers.
11
+ *
12
+ * The two round-trip hazards this module owns: member-name namespace
13
+ * qualification (§4) and the value encodings that differ from a naive JS mapping
14
+ * (§6) — notably that int64/uint64/decimal64 are JSON *strings* to avoid IEEE-754
15
+ * precision loss.
16
+ */
17
+ /** Base types whose RFC 7951 value is a JSON string, not a number (§6.1). */
18
+ const STRING_ENCODED = new Set([
19
+ 'int64',
20
+ 'uint64',
21
+ 'decimal64',
22
+ ]);
23
+ const NUMBER_BASES = new Set([
24
+ 'int8', 'int16', 'int32',
25
+ 'uint8', 'uint16', 'uint32',
26
+ ]);
27
+ /** Whether a leaf of this base must keep a string form to preserve precision. */
28
+ function isStringEncoded(base) {
29
+ return STRING_ENCODED.has(base);
30
+ }
31
+ /**
32
+ * Map a resolved YANG type to the form model's four leaf value types.
33
+ *
34
+ * - `boolean` and `empty` → boolean (empty is a present/absent checkbox).
35
+ * - `enumeration` and `identityref` → enum (a dropdown of names).
36
+ * - the fixed-width integers (int/uint 8..32) → number.
37
+ * - everything else — string, binary, leafref, instance-identifier, union, and
38
+ * the string-encoded int64/uint64/decimal64 — renders as text.
39
+ *
40
+ * 64-bit integers and decimal64 map to `'string'` deliberately: a JS `number`
41
+ * cannot hold them without loss, so they stay strings end to end. `bits` has no
42
+ * leaf equivalent and is mapped to a group of boolean checkboxes by the mapper,
43
+ * so it never reaches this function.
44
+ */
45
+ function toFormLeafType(t) {
46
+ if (t.base === 'boolean' || t.base === 'empty')
47
+ return 'boolean';
48
+ if (t.base === 'enumeration' || t.base === 'identityref')
49
+ return 'enum';
50
+ if (NUMBER_BASES.has(t.base))
51
+ return 'number';
52
+ return 'string';
53
+ }
54
+ /**
55
+ * RFC 7951 identityref value (§6.8): `module:identity` when the identity is
56
+ * defined in a different module than the referencing leaf, otherwise the bare
57
+ * identity name.
58
+ */
59
+ function qualifyIdentity(name, leafModule, t) {
60
+ const id = t.identities?.find((i) => i.name === name);
61
+ return id && id.module !== leafModule ? `${id.module}:${name}` : name;
62
+ }
63
+ /** The local identity name, dropping any `module:` prefix. */
64
+ function bareIdentity(value) {
65
+ const i = value.indexOf(':');
66
+ return i === -1 ? value : value.slice(i + 1);
67
+ }
68
+ /**
69
+ * RFC 7951 member name for a node (§4). A name is qualified `module:name` when
70
+ * the node is at the top level (no parent module) or its module differs from its
71
+ * parent's; otherwise the bare name is used.
72
+ */
73
+ function qualifiedName(name, module, parentModule) {
74
+ return parentModule === null || module !== parentModule ? `${module}:${name}` : name;
75
+ }
76
+ /** Split an RFC 7951 member name into its optional module prefix and local name. */
77
+ function splitQualified(member) {
78
+ const i = member.indexOf(':');
79
+ return i === -1 ? { name: member } : { module: member.slice(0, i), name: member.slice(i + 1) };
80
+ }
@@ -0,0 +1,16 @@
1
+ import type { Transformer } from '../../core/transformer';
2
+ import type { YangEngine, CompileRequest } from './engine';
3
+ import type { EffectiveModel } from './model';
4
+ /**
5
+ * A YANG {@link Transformer} for the catalog: resolves a YANG source through an
6
+ * engine into a form schema, and reverts the edited value to RFC 7951 data.
7
+ *
8
+ * This is the stateless, catalog-conformant view. For caching by `modelId`,
9
+ * engine-side validation on write-back, and RFC 7951 → form-value conversion,
10
+ * use the fuller {@link import('./adapter').YangFormAdapter} instead — both sit
11
+ * on the same engine, {@link mapToSchema}, and {@link toYangData}.
12
+ *
13
+ * The `binding` it round-trips is the resolved {@link EffectiveModel}, which the
14
+ * server keeps and never sends to the form UI.
15
+ */
16
+ export declare function createYangTransformer(engine: YangEngine): Transformer<CompileRequest, Record<string, unknown>, EffectiveModel, void>;
@@ -0,0 +1,29 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createYangTransformer = createYangTransformer;
4
+ const mapper_1 = require("./mapper");
5
+ const revert_1 = require("./revert");
6
+ /**
7
+ * A YANG {@link Transformer} for the catalog: resolves a YANG source through an
8
+ * engine into a form schema, and reverts the edited value to RFC 7951 data.
9
+ *
10
+ * This is the stateless, catalog-conformant view. For caching by `modelId`,
11
+ * engine-side validation on write-back, and RFC 7951 → form-value conversion,
12
+ * use the fuller {@link import('./adapter').YangFormAdapter} instead — both sit
13
+ * on the same engine, {@link mapToSchema}, and {@link toYangData}.
14
+ *
15
+ * The `binding` it round-trips is the resolved {@link EffectiveModel}, which the
16
+ * server keeps and never sends to the form UI.
17
+ */
18
+ function createYangTransformer(engine) {
19
+ return {
20
+ id: 'yang',
21
+ async toSchema(source) {
22
+ const binding = await engine.resolve(source);
23
+ return { schema: (0, mapper_1.mapToSchema)(binding), binding };
24
+ },
25
+ toSource(value, binding) {
26
+ return (0, revert_1.toYangData)(value, binding);
27
+ },
28
+ };
29
+ }
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "ng-form-foundry-transformers",
3
+ "version": "0.2.1",
4
+ "description": "Framework-agnostic Node + TypeScript catalog of source-format transformers: turn a YANG model (and more) into an ng-form-foundry schema and revert the edited form value back to the source format.",
5
+ "keywords": [
6
+ "ng-form-foundry",
7
+ "form",
8
+ "transformer",
9
+ "yang",
10
+ "netconf",
11
+ "restconf",
12
+ "rfc7950",
13
+ "rfc7951",
14
+ "json-schema",
15
+ "yaml",
16
+ "json",
17
+ "network-management",
18
+ "typescript"
19
+ ],
20
+ "author": "Mathias Santos de Brito",
21
+ "license": "Apache-2.0",
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/mathiasbrito/ng-form-foundry.git",
25
+ "directory": "packages/ng-form-foundry-transformers"
26
+ },
27
+ "homepage": "https://ng-form-foundry.readthedocs.io",
28
+ "bugs": {
29
+ "url": "https://github.com/mathiasbrito/ng-form-foundry/issues"
30
+ },
31
+ "type": "commonjs",
32
+ "main": "dist/index.js",
33
+ "types": "dist/index.d.ts",
34
+ "files": [
35
+ "dist",
36
+ "python"
37
+ ],
38
+ "engines": {
39
+ "node": ">=18"
40
+ },
41
+ "scripts": {
42
+ "build": "tsc -p tsconfig.build.json",
43
+ "typecheck": "tsc -p tsconfig.json --noEmit",
44
+ "pretest": "tsc -p tsconfig.json",
45
+ "test": "node --test out-tsc/test/*.test.js",
46
+ "clean": "rm -rf dist out-tsc"
47
+ },
48
+ "devDependencies": {
49
+ "@types/node": "^20.0.0",
50
+ "typescript": "~5.8.2"
51
+ },
52
+ "dependencies": {
53
+ "yaml": "^2.9.0"
54
+ }
55
+ }
@@ -0,0 +1,227 @@
1
+ #!/usr/bin/env python3
2
+ """Resolve a YANG module with pyang and emit the effective model as JSON.
3
+
4
+ This is the integration counterpart of the TypeScript ``SubprocessEngine``. It
5
+ uses pyang to parse and validate a module (resolving uses/augment/typedef/
6
+ groupings), then walks pyang's resolved child tree (``i_children``) and prints an
7
+ ``EffectiveModel`` matching ``src/model.ts`` to stdout.
8
+
9
+ Requires pyang on the host: ``pip install pyang``. The JS unit tests do not run
10
+ this — they use ``FakeEngine``. It emits container/list/leaf/leaf-list, choice
11
+ and the built-in types, with keys, config, default, mandatory, and the type
12
+ metadata (enum names, bits, union members, identities, leafref path).
13
+
14
+ Usage:
15
+ emit_effective_tree.py --entry <module> --source <dir> [--path <dir> ...] \\
16
+ [--datastore config|operational]
17
+ """
18
+ import argparse
19
+ import json
20
+ import sys
21
+
22
+ from pyang import repository, context, statements
23
+
24
+
25
+ def resolved_base(type_stmt):
26
+ """Follow typedefs to a built-in base type name."""
27
+ spec = getattr(type_stmt, "i_type_spec", None)
28
+ if spec is not None and getattr(spec, "name", None):
29
+ return spec.name
30
+ # Fall back to walking i_typedef chains, else the literal type argument.
31
+ td = getattr(type_stmt, "i_typedef", None)
32
+ while td is not None:
33
+ inner = td.search_one("type")
34
+ if inner is None:
35
+ break
36
+ nxt = getattr(inner, "i_typedef", None)
37
+ if nxt is None:
38
+ return inner.arg
39
+ td = nxt
40
+ return type_stmt.arg
41
+
42
+
43
+ def build_type(stmt):
44
+ type_stmt = stmt.search_one("type")
45
+ if type_stmt is None:
46
+ return {"base": "string"}
47
+ return type_info(type_stmt)
48
+
49
+
50
+ def type_info(type_stmt):
51
+ base = resolved_base(type_stmt)
52
+ out = {"base": base}
53
+ if base == "enumeration":
54
+ out["enums"] = [e.arg for e in type_stmt.search("enum")]
55
+ elif base == "bits":
56
+ out["bits"] = [b.arg for b in type_stmt.search("bit")]
57
+ elif base == "union":
58
+ out["members"] = [type_info(t) for t in type_stmt.search("type")]
59
+ elif base == "identityref":
60
+ out["identities"] = collect_identities(type_stmt)
61
+ elif base == "decimal64":
62
+ fd = type_stmt.search_one("fraction-digits")
63
+ if fd is not None:
64
+ out["fractionDigits"] = int(fd.arg)
65
+ elif base == "leafref":
66
+ path = type_stmt.search_one("path")
67
+ if path is not None:
68
+ out["leafrefPath"] = path.arg
69
+ return out
70
+
71
+
72
+ def collect_identities(type_stmt):
73
+ """Derived identities for an identityref, each tagged with its module.
74
+
75
+ Best-effort against pyang internals (``i_identity.i_derived``).
76
+ """
77
+ result = []
78
+ base = type_stmt.search_one("base")
79
+ identity = getattr(base, "i_identity", None) if base is not None else None
80
+ derived = getattr(identity, "i_derived", None) if identity is not None else None
81
+ for ident in derived or []:
82
+ result.append({"name": ident.arg, "module": ident.i_module.i_modulename})
83
+ return result
84
+
85
+
86
+ def module_name(stmt):
87
+ mod = getattr(stmt, "i_module", None)
88
+ return mod.i_modulename if mod is not None else stmt.main_module().arg
89
+
90
+
91
+ def is_config(stmt):
92
+ # pyang sets i_config on data nodes after validation.
93
+ val = getattr(stmt, "i_config", None)
94
+ return True if val is None else bool(val)
95
+
96
+
97
+ def walk(stmt):
98
+ kw = stmt.keyword
99
+ common = {"name": stmt.arg, "module": module_name(stmt), "config": is_config(stmt)}
100
+
101
+ if kw == "leaf":
102
+ node = {"kind": "leaf", **common, "type": build_type(stmt)}
103
+ if stmt.search_one("mandatory", "true") is not None:
104
+ node["mandatory"] = True
105
+ default = stmt.search_one("default")
106
+ if default is not None:
107
+ node["default"] = default.arg
108
+ return node
109
+
110
+ if kw == "leaf-list":
111
+ node = {"kind": "leaf-list", **common, "type": build_type(stmt)}
112
+ return node
113
+
114
+ if kw == "container":
115
+ node = {"kind": "container", **common, "children": walk_children(stmt)}
116
+ if stmt.search_one("presence") is not None:
117
+ node["presence"] = True
118
+ return node
119
+
120
+ if kw == "list":
121
+ key = stmt.search_one("key")
122
+ node = {
123
+ "kind": "list",
124
+ **common,
125
+ "keys": key.arg.split() if key is not None else [],
126
+ "children": walk_children(stmt),
127
+ }
128
+ return node
129
+
130
+ if kw == "choice":
131
+ cases = []
132
+ for case in getattr(stmt, "i_children", []):
133
+ if case.keyword == "case":
134
+ cases.append({"name": case.arg, "children": walk_children(case)})
135
+ else:
136
+ # shorthand case: a single data node directly under the choice
137
+ child = walk(case)
138
+ if child is not None:
139
+ cases.append({"name": case.arg, "children": [child]})
140
+ node = {"kind": "choice", "name": stmt.arg, "module": module_name(stmt), "cases": cases}
141
+ default = stmt.search_one("default")
142
+ if default is not None:
143
+ node["default"] = default.arg
144
+ if stmt.search_one("mandatory", "true") is not None:
145
+ node["mandatory"] = True
146
+ return node
147
+
148
+ return None # anydata/anyxml are not emitted
149
+
150
+
151
+ def walk_children(stmt):
152
+ out = []
153
+ for child in getattr(stmt, "i_children", []):
154
+ node = walk(child)
155
+ if node is not None:
156
+ out.append(node)
157
+ return out
158
+
159
+
160
+ def main():
161
+ ap = argparse.ArgumentParser()
162
+ ap.add_argument("--entry", required=True)
163
+ ap.add_argument("--source", required=True)
164
+ ap.add_argument("--path", action="append", default=[])
165
+ ap.add_argument("--datastore", default="config", choices=["config", "operational"])
166
+ args = ap.parse_args()
167
+
168
+ repo = repository.FileRepository(":".join([args.source, *args.path]))
169
+ ctx = context.Context(repo)
170
+ module = ctx.search_module(0, args.entry)
171
+ if module is None:
172
+ with open_module(ctx, args.entry, args.source) as text:
173
+ module = ctx.add_module(args.entry, text)
174
+ statements.validate_module(ctx, module)
175
+ if ctx.errors:
176
+ for _pos, tag, arg in ctx.errors:
177
+ print(f"pyang: {tag} {arg}", file=sys.stderr)
178
+ sys.exit(1)
179
+
180
+ roots = walk_children(module)
181
+ if args.datastore == "config":
182
+ roots = strip_state(roots)
183
+
184
+ model = {
185
+ "modules": [{"name": module.i_modulename, "namespace": namespace(module)}],
186
+ "roots": roots,
187
+ }
188
+ json.dump(model, sys.stdout)
189
+
190
+
191
+ def strip_state(nodes):
192
+ kept = []
193
+ for n in nodes:
194
+ if n.get("config") is False:
195
+ continue
196
+ if "children" in n:
197
+ n["children"] = strip_state(n["children"])
198
+ for case in n.get("cases", []):
199
+ case["children"] = strip_state(case["children"])
200
+ kept.append(n)
201
+ return kept
202
+
203
+
204
+ def namespace(module):
205
+ ns = module.search_one("namespace")
206
+ return ns.arg if ns is not None else ""
207
+
208
+
209
+ def open_module(ctx, name, source):
210
+ import glob
211
+ import os
212
+ from contextlib import contextmanager
213
+
214
+ @contextmanager
215
+ def _open():
216
+ matches = glob.glob(os.path.join(source, f"{name}*.yang"))
217
+ if not matches:
218
+ print(f"module '{name}' not found in {source}", file=sys.stderr)
219
+ sys.exit(1)
220
+ with open(matches[0], "r", encoding="utf-8") as fh:
221
+ yield fh.read()
222
+
223
+ return _open()
224
+
225
+
226
+ if __name__ == "__main__":
227
+ main()