biosim_schema 1.0.1__py3-none-any.whl

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.
@@ -0,0 +1,8 @@
1
+ """
2
+ example_package
3
+
4
+ chema for biomolecular simulation metadata created with LinkML.
5
+ """
6
+
7
+ __all__ = [] # Populate with public exports when needed
8
+ __version__ = "1.0.1"
File without changes
@@ -0,0 +1,287 @@
1
+ """Extract engine mappings from a JSON-LD schema.
2
+
3
+ This script walks the schema starting from a root class, collects slot-level
4
+ and enum-level `engine_mapping` annotations, and writes forward and reverse
5
+ lookup tables to `schema_enginemappings.json`.
6
+
7
+ The schema_enginemappings.json file is used to extract metadata from MD topology, trajectory, and log files via the biosim-extractor.
8
+ """
9
+
10
+ import argparse
11
+ import json
12
+
13
+
14
+ class MappingsExtractor:
15
+ """Extract forward and reverse engine mappings from a schema.
16
+
17
+ The extractor indexes schema classes, slots, and enums, then traverses the
18
+ class tree from a root class to build:
19
+ - a forward map: schema path -> engine mapping entries
20
+ - a reverse map: engine key -> schema path -> permissible values
21
+ """
22
+
23
+ def __init__(self, filepath):
24
+ """Initialize the extractor.
25
+
26
+ Args:
27
+ filepath: Path to the JSON-LD schema file.
28
+ """
29
+ self.filepath = filepath
30
+ self.schema = {}
31
+
32
+ self.slot_index = {}
33
+ self.class_index = {}
34
+ self.enum_index = {}
35
+
36
+ self.forward = {}
37
+ self.reverse = {}
38
+
39
+ # ----------------------------
40
+ # Entry
41
+ # ----------------------------
42
+ def extract(self, root_class="SimulationMetadata"):
43
+ """Extract mappings from the schema.
44
+
45
+ Args:
46
+ root_class: Name of the schema class to start traversal from.
47
+
48
+ Returns:
49
+ A dictionary with `forward` and `reverse` mapping tables.
50
+ """
51
+ with open(self.filepath) as f:
52
+ self.schema = json.load(f)
53
+
54
+ # Indexes
55
+ self.slot_index = {s["name"]: s for s in self.schema.get("slots", [])}
56
+ self.class_index = {c["name"]: c for c in self.schema.get("classes", [])}
57
+ self.enum_index = {e["name"]: e for e in self.schema.get("enums", [])}
58
+
59
+ self.forward = {}
60
+ self.reverse = {}
61
+
62
+ self._walk_class(root_class, path=root_class)
63
+
64
+ return {"forward": self.forward, "reverse": self.reverse}
65
+
66
+ # ----------------------------
67
+ # Recursive traversal
68
+ # ----------------------------
69
+ def _walk_class(self, class_name, path):
70
+ """Recursively walk a class and its nested slot ranges.
71
+
72
+ Args:
73
+ class_name: Class name to traverse.
74
+ path: Dot-separated schema path for the current class.
75
+ """
76
+ cls = self.class_index.get(class_name, {})
77
+
78
+ for slot_name in cls.get("slots", []):
79
+ slot = self.slot_index[slot_name]
80
+ slot_path = f"{path}.{slot_name}"
81
+
82
+ self._handle_slot(slot_name, slot, slot_path)
83
+
84
+ # Recurse into nested classes
85
+ range_ = slot.get("range")
86
+ if range_ in self.class_index:
87
+ self._walk_class(range_, slot_path)
88
+
89
+ # ----------------------------
90
+ # Slot handler
91
+ # ----------------------------
92
+ def _handle_slot(self, slot_name, slot, path):
93
+ """Collect mappings for a slot and its enum range, if any.
94
+
95
+ Args:
96
+ slot_name: Slot name.
97
+ slot: Slot definition.
98
+ path: Dot-separated schema path for the slot.
99
+ """
100
+ mappings = self._get_engine_mappings(slot)
101
+
102
+ for m in mappings:
103
+ for norm in self._normalise_mapping(m):
104
+ self._add_mapping(path, norm)
105
+
106
+ # Enum handling
107
+ range_ = slot.get("range")
108
+ is_multivalued = slot.get("multivalued", False)
109
+ if range_ in self.enum_index:
110
+ self._handle_enum(
111
+ slot_name, self.enum_index[range_], path, multivalued=is_multivalued
112
+ )
113
+
114
+ # ----------------------------
115
+ # Enum handler
116
+ # ----------------------------
117
+ def _handle_enum(self, slot_name, enum, path, multivalued):
118
+ """Collect mappings for all permissible values of an enum.
119
+
120
+ Args:
121
+ slot_name: Slot name using the enum.
122
+ enum: Enum definition.
123
+ path: Dot-separated schema path for the slot.
124
+ """
125
+ for pv in enum.get("permissible_values", []):
126
+ # don't use alias, can't validate against schema
127
+ # pv_name = pv.get("aliases", [pv["text"]])[0]
128
+ pv_name = pv["text"]
129
+
130
+ mappings = self._get_engine_mappings(pv)
131
+
132
+ for m in mappings:
133
+ for norm in self._normalise_mapping(m):
134
+ self._add_mapping(
135
+ path, norm, enum_value=pv_name, multivalued=multivalued
136
+ )
137
+
138
+ # ----------------------------
139
+ # Extract engine mappings
140
+ # ----------------------------
141
+ def _get_engine_mappings(self, obj):
142
+ """Return engine mappings declared in annotations.
143
+
144
+ Args:
145
+ obj: Schema object that may contain an `engine_mapping` annotation.
146
+
147
+ Returns:
148
+ A list of mapping dictionaries, or an empty list if none exist.
149
+ """
150
+ for ann in obj.get("annotations", []):
151
+ if ann.get("tag") == "engine_mapping":
152
+ return ann.get("value", [])
153
+ return []
154
+
155
+ # ----------------------------
156
+ # NORMALISATION
157
+ # ----------------------------
158
+ def _normalise_mapping(self, mapping):
159
+ """Normalize a raw mapping into one or more key/value/unit entries.
160
+
161
+ Args:
162
+ mapping: Raw engine mapping dictionary.
163
+
164
+ Yields:
165
+ Normalized mapping dictionaries with keys:
166
+ `engine`, `key`, `value`, and optional `unit`.
167
+ """
168
+ keys = mapping.get("key")
169
+
170
+ if keys is None:
171
+ return []
172
+
173
+ if not isinstance(keys, list):
174
+ keys = [keys]
175
+
176
+ for k in keys:
177
+ yield {
178
+ "engine": mapping["engine"],
179
+ "key": k,
180
+ "value": mapping.get("value"),
181
+ "unit": mapping.get("unit"),
182
+ }
183
+
184
+ # ----------------------------
185
+ # Add to forward + reverse maps
186
+ # ----------------------------
187
+ def _add_mapping(self, path, mapping, enum_value=None, multivalued=False):
188
+ """Add a normalized mapping to the forward and reverse indexes.
189
+
190
+ Args:
191
+ path: Dot-separated schema path.
192
+ mapping: Normalized mapping dictionary.
193
+ enum_value: Optional enum permissible value associated with the mapping.
194
+ """
195
+ engine = mapping["engine"]
196
+ key = mapping["key"]
197
+ unit = mapping.get("unit") # NEW
198
+
199
+ # ============================
200
+ # FORWARD MAP
201
+ # ============================
202
+ self.forward.setdefault(engine, {})
203
+
204
+ entry = {"key": key, "value": mapping.get("value")}
205
+
206
+ # only include unit if present
207
+ if unit is not None:
208
+ entry["unit"] = unit
209
+
210
+ if enum_value:
211
+ self.forward[engine].setdefault(path, {})
212
+ self.forward[engine][path].setdefault(enum_value, [])
213
+ self.forward[engine][path][enum_value].append(entry)
214
+ else:
215
+ self.forward[engine].setdefault(path, [])
216
+ self.forward[engine][path].append(entry)
217
+
218
+ # ============================
219
+ # REVERSE MAP
220
+ # ============================
221
+ if engine not in self.reverse:
222
+ self.reverse[engine] = {}
223
+
224
+ if key not in self.reverse[engine]:
225
+ self.reverse[engine][key] = {
226
+ "by_path": {},
227
+ "path_metadata": {},
228
+ }
229
+
230
+ rev = self.reverse[engine][key]
231
+ rev["by_path"].setdefault(path, {})
232
+
233
+ if multivalued:
234
+ rev["path_metadata"].setdefault(path, {})
235
+ rev["path_metadata"][path]["multivalued"] = True
236
+
237
+ if enum_value:
238
+ raw_value = mapping.get("value")
239
+
240
+ if isinstance(raw_value, list):
241
+ value_keys = raw_value
242
+ else:
243
+ value_keys = [raw_value]
244
+
245
+ for vk in value_keys:
246
+ vk = str(vk)
247
+ rev["by_path"][path].setdefault(vk, [])
248
+
249
+ if enum_value not in rev["by_path"][path][vk]:
250
+ rev["by_path"][path][vk].append(enum_value)
251
+
252
+
253
+ # =========================
254
+ # ENTRY POINT
255
+ # =========================
256
+
257
+
258
+ def parse_args():
259
+ """Parse command-line arguments.
260
+
261
+ Returns:
262
+ Parsed ``argparse.Namespace`` object.
263
+ """
264
+ parser = argparse.ArgumentParser(
265
+ description="Extract engine mappings from a JSON-LD schema."
266
+ )
267
+ parser.add_argument("jsonldschema", help="Path to JSON-LD schema file")
268
+ parser.add_argument("--output", "-o", help="Output file path (default: stdout)")
269
+ return parser.parse_args()
270
+
271
+
272
+ def main():
273
+ """Entry point: parse args, run extraction, and write output."""
274
+ args = parse_args()
275
+
276
+ extractor = MappingsExtractor(args.jsonldschema)
277
+ result = extractor.extract()
278
+
279
+ if args.output:
280
+ with open(args.output, "w") as f:
281
+ json.dump(result, f, indent=2, ensure_ascii=False)
282
+ else:
283
+ print(json.dumps(result, indent=2, ensure_ascii=False))
284
+
285
+
286
+ if __name__ == "__main__":
287
+ main()
@@ -0,0 +1,219 @@
1
+ """
2
+ Build simplified schema summaries from schema_webformfields.json.
3
+
4
+ Outputs:
5
+ - YAML nested summary (default behavior)
6
+ - CSV flattened summary (one row per leaf field)
7
+
8
+ CSV hierarchy rules:
9
+ - section: top-level schema section
10
+ - subsection_1/subsection_2: intermediate hierarchy levels only
11
+ - field: leaf field key (never duplicated as a subsection column)
12
+ """
13
+
14
+ import argparse
15
+ import csv
16
+ import json
17
+ from pathlib import Path
18
+
19
+ import yaml
20
+
21
+ METADATA_KEYS = ("label", "hint", "typehint", "placeholder", "units", "example")
22
+ """tuple[str, ...]: Field metadata keys copied from each webform node into the summary."""
23
+
24
+
25
+ def _summarise(node):
26
+ """Recursively extract field metadata, skipping enum fields.
27
+
28
+ Args:
29
+ node: A field definition dict from schema_webformfields.json.
30
+
31
+ Returns:
32
+ dict or None: Summarised field metadata, or None if the field is skipped.
33
+ """
34
+
35
+ if node.get("type") == "select":
36
+ result = {k: node[k] for k in METADATA_KEYS if node.get(k) is not None}
37
+ # result["options"] = node.get("options", [])
38
+ result["typehint"] = "string" # set enums to strings by default
39
+ return result
40
+
41
+ result = {k: node[k] for k in METADATA_KEYS if node.get(k) is not None}
42
+
43
+ if "fields" in node:
44
+ value_unit = node["fields"].get("value_unit")
45
+ if value_unit and value_unit.get("type") == "select":
46
+ # result["units"] = value_unit["options"]
47
+ if value_unit.get("default"):
48
+ result["default_unit"] = value_unit["default"]
49
+
50
+ # hoist value/vector_value typehint directly onto parent, drop fields wrapper
51
+ data_field = node["fields"].get("value") or node["fields"].get("vector_value")
52
+ other_fields = {
53
+ k: v
54
+ for k, v in node["fields"].items()
55
+ if k not in ("value", "vector_value", "value_unit")
56
+ }
57
+
58
+ if data_field and not other_fields:
59
+ if "typehint" in data_field:
60
+ result["typehint"] = data_field["typehint"]
61
+ if (
62
+ node["fields"].get("vector_value")
63
+ and data_field.get("typehint") == "float"
64
+ ):
65
+ result["typehint"] = "list"
66
+ else:
67
+ sub = {name: _summarise(child) for name, child in node["fields"].items()}
68
+ sub = {k: v for k, v in sub.items() if v is not None}
69
+ if sub:
70
+ result["fields"] = sub
71
+
72
+ return result or None
73
+
74
+
75
+ def extract_summary(webform_json_path):
76
+ """Extract a simplified field summary from a schema_webformfields.json file.
77
+
78
+ Args:
79
+ webform_json_path: Path to the schema_webformfields.json file.
80
+
81
+ Returns:
82
+ dict: Nested summary keyed by section then field name.
83
+ """
84
+ with open(webform_json_path) as f:
85
+ schema = json.load(f)
86
+
87
+ return {name: s for name, node in schema.items() if (s := _summarise(node))}
88
+
89
+
90
+ def _flatten_rows(node, path):
91
+ """Flatten nested summary nodes into CSV rows.
92
+
93
+ Args:
94
+ node (dict): A node from the simplified summary produced by _summarise.
95
+ path (list[str]): Hierarchy path from section to current node.
96
+ Example leaf path: ["composition", "molecule_ID", "InChI"].
97
+
98
+ Returns:
99
+ list[dict]: CSV-ready rows. Internal nodes recurse; leaf nodes emit one row.
100
+
101
+ Notes:
102
+ - subsection columns are intermediate levels only (path[1:-1]).
103
+ - field is always the final path element (path[-1]).
104
+ - This avoids duplicating leaf keys in subsection columns.
105
+ """
106
+ rows = []
107
+ children = node.get("fields", {})
108
+ if children:
109
+ for child_name, child_node in children.items():
110
+ rows.extend(_flatten_rows(child_node, path + [child_name]))
111
+ return rows
112
+
113
+ # leaf field row
114
+ subsections = path[
115
+ 1:-1
116
+ ] # only intermediate levels, excludes section and field name
117
+
118
+ row = {
119
+ "section": path[0] if path else "",
120
+ "subsection_1": subsections[0] if len(subsections) > 0 else "",
121
+ # "subsection_2": subsections[1] if len(subsections) > 1 else "",
122
+ "field": path[-1] if path else "",
123
+ "field_name": node.get("label", ""),
124
+ "help_text": node.get("hint", ""),
125
+ "placeholder_text": node.get("placeholder", ""),
126
+ "default_unit": node.get("default_unit", ""),
127
+ "data_type": node.get("typehint", ""),
128
+ "example": node.get("example", ""),
129
+ }
130
+ return [row]
131
+
132
+
133
+ def write_summary_csv(summary, output_path):
134
+ """Write the flattened schema summary to CSV.
135
+
136
+ Args:
137
+ summary (dict): Nested summary from extract_summary().
138
+ output_path (str): Destination CSV path.
139
+
140
+ CSV columns:
141
+ section, subsection_1, field,
142
+ field_name, help_text, placeholder_text, default_unit, data_type
143
+ """
144
+ rows = []
145
+ for section_name, section_node in summary.items():
146
+ rows.extend(_flatten_rows(section_node, [section_name]))
147
+
148
+ fieldnames = [
149
+ "section",
150
+ "subsection_1",
151
+ "field",
152
+ "field_name",
153
+ "help_text",
154
+ "placeholder_text",
155
+ "default_unit",
156
+ "data_type",
157
+ "example",
158
+ ]
159
+
160
+ with open(output_path, "w", newline="", encoding="utf-8") as f:
161
+ writer = csv.DictWriter(f, fieldnames=fieldnames)
162
+ writer.writeheader()
163
+ writer.writerows(rows)
164
+
165
+
166
+ # =========================
167
+ # ENTRY POINT
168
+ # =========================
169
+
170
+
171
+ def parse_args():
172
+ """Parses CLI arguments for summary extraction.
173
+
174
+ Returns:
175
+ argparse.Namespace: Parsed arguments containing:
176
+ webschema: Path to schema_webformfields.json.
177
+ yamloutput: Optional YAML output path. Default is schema_summary.yaml
178
+ """
179
+ parser = argparse.ArgumentParser(
180
+ description="Extract engine mappings from a webform fields schema."
181
+ )
182
+ parser.add_argument("webschema", help="Path to webform fields json schema file")
183
+ parser.add_argument("--yamloutput", "-o", help="Yaml output file path")
184
+ return parser.parse_args()
185
+
186
+
187
+ def main():
188
+ """Runs summary extraction and writes YAML and CSV outputs.
189
+
190
+ Side Effects:
191
+ Writes a YAML summary to --yamloutput (or schema_summary.yaml).
192
+ Writes a CSV summary next to the YAML output using a .csv suffix.
193
+ """
194
+ args = parse_args()
195
+
196
+ # summary from the webform fields
197
+ summary = extract_summary(args.webschema)
198
+
199
+ # output as yaml
200
+ yaml_path = (
201
+ Path(args.yamloutput) if args.yamloutput else Path("schema_summary.yaml")
202
+ )
203
+ with open(yaml_path, "w") as f:
204
+ yaml.dump(
205
+ summary, f, default_flow_style=False, allow_unicode=True, sort_keys=True
206
+ )
207
+
208
+ # output as json
209
+ json_path = yaml_path.with_suffix(".json")
210
+ with open(json_path, "w") as fp:
211
+ json.dump(summary, fp)
212
+
213
+ # flatten and output as csv
214
+ csv_path = yaml_path.with_suffix(".csv")
215
+ write_summary_csv(summary, str(csv_path))
216
+
217
+
218
+ if __name__ == "__main__":
219
+ main()