feat: initial otko import
Some checks failed
CI / lint (push) Has been cancelled
CI / test (macos-latest, 3.10) (push) Has been cancelled
CI / test (macos-latest, 3.11) (push) Has been cancelled
CI / test (macos-latest, 3.12) (push) Has been cancelled
CI / test (ubuntu-latest, 3.10) (push) Has been cancelled
CI / test (ubuntu-latest, 3.11) (push) Has been cancelled
CI / test (ubuntu-latest, 3.12) (push) Has been cancelled
CI / test (windows-latest, 3.10) (push) Has been cancelled
CI / test (windows-latest, 3.11) (push) Has been cancelled
CI / test (windows-latest, 3.12) (push) Has been cancelled
Some checks failed
CI / lint (push) Has been cancelled
CI / test (macos-latest, 3.10) (push) Has been cancelled
CI / test (macos-latest, 3.11) (push) Has been cancelled
CI / test (macos-latest, 3.12) (push) Has been cancelled
CI / test (ubuntu-latest, 3.10) (push) Has been cancelled
CI / test (ubuntu-latest, 3.11) (push) Has been cancelled
CI / test (ubuntu-latest, 3.12) (push) Has been cancelled
CI / test (windows-latest, 3.10) (push) Has been cancelled
CI / test (windows-latest, 3.11) (push) Has been cancelled
CI / test (windows-latest, 3.12) (push) Has been cancelled
This commit is contained in:
commit
612936a00b
540 changed files with 174136 additions and 0 deletions
0
tools/__init__.py
Normal file
0
tools/__init__.py
Normal file
0
tools/gidopensees_import/__init__.py
Normal file
0
tools/gidopensees_import/__init__.py
Normal file
481
tools/gidopensees_import/codegen.py
Normal file
481
tools/gidopensees_import/codegen.py
Normal file
|
|
@ -0,0 +1,481 @@
|
|||
"""
|
||||
Codegen: gidopensees schemas.json -> Pydantic v2 model stubs.
|
||||
|
||||
CLI:
|
||||
python -m tools.gidopensees_import.codegen \\
|
||||
--schemas tools/gidopensees_import/schemas.json \\
|
||||
--out src/otko/core/catalog/generated/
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Name helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _to_snake(name: str) -> str:
|
||||
"""Convert a GiD name to a valid Python snake_case identifier."""
|
||||
s = re.sub(r"[^a-zA-Z0-9]", "_", name)
|
||||
s = re.sub(r"_+", "_", s).strip("_")
|
||||
return s.lower() if s else "field"
|
||||
|
||||
|
||||
def _to_class_name(name: str) -> str:
|
||||
"""Convert a GiD entry name to PascalCaseSpec."""
|
||||
s = re.sub(r"[^a-zA-Z0-9]", "_", name)
|
||||
return "".join(p.capitalize() for p in s.split("_") if p) + "Spec"
|
||||
|
||||
|
||||
def _to_module_name(name: str) -> str:
|
||||
"""Convert a GiD entry name to a Python module filename stem."""
|
||||
s = re.sub(r"[^a-zA-Z0-9]", "_", name)
|
||||
s = re.sub(r"_+", "_", s).strip("_")
|
||||
return s.lower()
|
||||
|
||||
|
||||
def _to_book_var(book_name: str) -> str:
|
||||
"""Convert 'Standard_Uniaxial_Materials' -> 'StandardUniaxialMaterials'."""
|
||||
s = re.sub(r"[^a-zA-Z0-9]", "_", book_name)
|
||||
return "".join(p.capitalize() for p in s.split("_") if p)
|
||||
|
||||
|
||||
def _dedupe(names: list[str]) -> list[str]:
|
||||
"""Add _N suffix to duplicate identifiers, in order."""
|
||||
seen: dict[str, int] = {}
|
||||
result: list[str] = []
|
||||
for name in names:
|
||||
count = seen.get(name, 0)
|
||||
result.append(name if count == 0 else f"{name}_{count}")
|
||||
seen[name] = count + 1
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Type-inference helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _scalar_type(default: str) -> tuple[str, str]:
|
||||
"""Return (python_type_str, repr_default) for a SCALAR field."""
|
||||
if not default:
|
||||
return "str", '""'
|
||||
try:
|
||||
int(default)
|
||||
return "int", default
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
float(default)
|
||||
return "float", default
|
||||
except ValueError:
|
||||
pass
|
||||
return "str", repr(default)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Python-literal renderer (for model_config json_schema_extra)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _py_lit(obj: Any, depth: int = 0) -> str:
|
||||
"""Render a Python value as a valid Python literal expression."""
|
||||
pad = " " * depth
|
||||
inner = " " * (depth + 1)
|
||||
if isinstance(obj, dict):
|
||||
if not obj:
|
||||
return "{}"
|
||||
items = [f"{inner}{k!r}: {_py_lit(v, depth + 1)}" for k, v in obj.items()]
|
||||
return "{\n" + ",\n".join(items) + f",\n{pad}}}"
|
||||
if isinstance(obj, list):
|
||||
if not obj:
|
||||
return "[]"
|
||||
items = [f"{inner}{_py_lit(v, depth + 1)}" for v in obj]
|
||||
return "[\n" + ",\n".join(items) + f",\n{pad}]"
|
||||
return repr(obj)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# File header template
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_HEADER = """\
|
||||
# ruff: noqa
|
||||
# AUTOGENERATED - do not edit.
|
||||
# File: {module_name}.py
|
||||
# Generated: {timestamp}
|
||||
# Source: {source_file} -- Book: {book} / {entry_type}: {entry_name}
|
||||
#
|
||||
# Schema data from gidopensees (https://github.com/rclab-auth/gidopensees)
|
||||
# Copyright (C) Reinforced Concrete Laboratory,
|
||||
# Aristotle University of Thessaloniki (AUTh)
|
||||
"""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-entry source generator
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _entry_source(
|
||||
entry: dict[str, Any],
|
||||
module_name: str,
|
||||
class_name: str,
|
||||
source_file: str,
|
||||
timestamp: str,
|
||||
) -> str:
|
||||
lines: list[str] = []
|
||||
|
||||
# Header block
|
||||
lines.append(
|
||||
_HEADER.format(
|
||||
module_name=module_name,
|
||||
timestamp=timestamp,
|
||||
source_file=source_file,
|
||||
book=entry["book"],
|
||||
entry_type=entry["source_type"],
|
||||
entry_name=entry["name"],
|
||||
)
|
||||
)
|
||||
|
||||
# Visible fields
|
||||
visible = [f for f in entry["fields"] if f.get("state") != "HIDDEN"]
|
||||
|
||||
# Detect which imports are needed
|
||||
has_literal = any(f["widget_type"] == "CB" and f.get("options") for f in visible)
|
||||
tuple_fields = [f for f in visible if f["widget_type"] == "TUPLE" and f.get("options")]
|
||||
|
||||
# Inner row classes for TUPLE fields
|
||||
inner_classes: list[tuple[str, list[str]]] = []
|
||||
seen_inner: set[str] = set()
|
||||
for tf in tuple_fields:
|
||||
inner_cls = (
|
||||
"".join(p.capitalize() for p in _to_snake(tf["name"]).split("_") if p) + "Row"
|
||||
)
|
||||
if inner_cls not in seen_inner:
|
||||
seen_inner.add(inner_cls)
|
||||
rows: list[str] = [f"class {inner_cls}(BaseModel):"]
|
||||
for sub in tf.get("options") or []:
|
||||
rows.append(f" {_to_snake(sub)}: float = 0.0")
|
||||
inner_classes.append((inner_cls, rows))
|
||||
|
||||
# Imports
|
||||
lines.append("from __future__ import annotations\n\n")
|
||||
if has_literal:
|
||||
lines.append("from typing import Literal\n\n")
|
||||
lines.append("from pydantic import BaseModel, ConfigDict, Field\n\n\n")
|
||||
|
||||
# Inner models
|
||||
for _, cls_rows in inner_classes:
|
||||
for row in cls_rows:
|
||||
lines.append(row + "\n")
|
||||
lines.append("\n\n")
|
||||
|
||||
# Collect per-field dependency metadata for model_config
|
||||
deps: list[dict[str, Any]] = []
|
||||
for f in visible:
|
||||
if f.get("dependencies"):
|
||||
deps.append(
|
||||
{
|
||||
"field": _to_snake(f["name"]),
|
||||
"gid_name": f["name"],
|
||||
"rules": f["dependencies"],
|
||||
}
|
||||
)
|
||||
|
||||
# Build json_schema_extra dict
|
||||
extra: dict[str, Any] = {
|
||||
"x-gid-name": entry["name"],
|
||||
"x-book": entry["book"],
|
||||
}
|
||||
if entry.get("image"):
|
||||
extra["x-icon"] = entry["image"]
|
||||
if entry.get("tkwidgets"):
|
||||
extra["x-tkwidgets"] = entry["tkwidgets"]
|
||||
if deps:
|
||||
extra["dependencies"] = deps
|
||||
|
||||
# Class header + model_config
|
||||
lines.append(f"class {class_name}(BaseModel):\n")
|
||||
extra_lit = _py_lit(extra, depth=2)
|
||||
lines.append(f" model_config = ConfigDict(\n json_schema_extra={extra_lit}\n )\n\n")
|
||||
|
||||
# Deduplicated Python attribute names
|
||||
py_names = _dedupe([_to_snake(f["name"]) for f in visible])
|
||||
|
||||
for py_name, field in zip(py_names, visible, strict=True):
|
||||
wtype: str = field["widget_type"]
|
||||
options: list[str] = field.get("options") or []
|
||||
default: str = field.get("default") or ""
|
||||
help_text: str = field.get("help_text") or ""
|
||||
desc_arg = f", description={help_text!r}" if help_text else ""
|
||||
|
||||
if wtype == "CB":
|
||||
if options:
|
||||
opts_str = ", ".join(repr(o) for o in options)
|
||||
py_type = f"Literal[{opts_str}]"
|
||||
py_def = repr(default) if default in options else repr(options[0])
|
||||
else:
|
||||
py_type, py_def = "str", repr(default)
|
||||
if desc_arg:
|
||||
lines.append(f" {py_name}: {py_type} = Field({py_def}{desc_arg})\n")
|
||||
else:
|
||||
lines.append(f" {py_name}: {py_type} = {py_def}\n")
|
||||
|
||||
elif wtype == "UNITS":
|
||||
py_def = repr(default)
|
||||
if desc_arg:
|
||||
lines.append(
|
||||
f" {py_name}: str = Field({py_def}{desc_arg})"
|
||||
" # TODO: unit-aware type\n"
|
||||
)
|
||||
else:
|
||||
lines.append(f" {py_name}: str = {py_def} # TODO: unit-aware type\n")
|
||||
|
||||
elif wtype == "MAT":
|
||||
py_def = repr(default)
|
||||
if desc_arg:
|
||||
lines.append(f" {py_name}: str = Field({py_def}{desc_arg})\n")
|
||||
else:
|
||||
lines.append(f" {py_name}: str = {py_def}\n")
|
||||
|
||||
elif wtype == "SCALAR":
|
||||
py_type, py_def = _scalar_type(default)
|
||||
if desc_arg:
|
||||
lines.append(f" {py_name}: {py_type} = Field({py_def}{desc_arg})\n")
|
||||
else:
|
||||
lines.append(f" {py_name}: {py_type} = {py_def}\n")
|
||||
|
||||
elif wtype == "TUPLE":
|
||||
inner_cls = (
|
||||
"".join(p.capitalize() for p in _to_snake(field["name"]).split("_") if p)
|
||||
+ "Row"
|
||||
)
|
||||
lines.append(
|
||||
f" {py_name}: list[{inner_cls}] = Field(default_factory=list{desc_arg})\n"
|
||||
)
|
||||
|
||||
else:
|
||||
lines.append(f" {py_name}: str = {default!r}\n")
|
||||
|
||||
lines.append("\n")
|
||||
return "".join(lines)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# generated/__init__.py builder
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _generated_init_source(
|
||||
books: list[dict[str, Any]],
|
||||
source: str,
|
||||
module_prefix: str,
|
||||
timestamp: str,
|
||||
) -> str:
|
||||
lines: list[str] = []
|
||||
lines.append("# ruff: noqa\n")
|
||||
lines.append(f"# AUTOGENERATED - do not edit. Generated: {timestamp}\n")
|
||||
lines.append(
|
||||
"# Schema data from gidopensees.\n\n"
|
||||
)
|
||||
lines.append("from __future__ import annotations\n\n")
|
||||
lines.append("from typing import Union\n\n")
|
||||
|
||||
# Collect (module_stem, class_name) per book
|
||||
book_entries: list[tuple[str, list[tuple[str, str]]]] = []
|
||||
for book in books:
|
||||
pairs: list[tuple[str, str]] = []
|
||||
for entry in book["entries"]:
|
||||
stem = _to_module_name(entry["name"])
|
||||
cls = _to_class_name(entry["name"])
|
||||
pairs.append((stem, cls))
|
||||
book_entries.append((book["name"], pairs))
|
||||
|
||||
# Import all
|
||||
for _, pairs in book_entries:
|
||||
for stem, cls in pairs:
|
||||
lines.append(f"from {module_prefix}.{stem} import {cls}\n")
|
||||
lines.append("\n\n")
|
||||
|
||||
# One Union per book
|
||||
for book_name, pairs in book_entries:
|
||||
var_name = _to_book_var(book_name)
|
||||
union_members = ", ".join(cls for _, cls in pairs)
|
||||
if len(pairs) == 1:
|
||||
lines.append(f"{var_name} = {pairs[0][1]}\n")
|
||||
else:
|
||||
lines.append(f"{var_name} = Union[{union_members}]\n")
|
||||
lines.append("\n")
|
||||
|
||||
# __all__
|
||||
all_names = [_to_book_var(b["name"]) for b in books]
|
||||
all_cls = [cls for _, pairs in book_entries for _, cls in pairs]
|
||||
all_items = ", ".join(repr(n) for n in all_cls + all_names)
|
||||
lines.append(f"__all__ = [{all_items}]\n")
|
||||
|
||||
return "".join(lines)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# core/catalog/__init__.py builder
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _catalog_init_source(
|
||||
mat_books: list[dict[str, Any]],
|
||||
timestamp: str,
|
||||
) -> str:
|
||||
lines: list[str] = []
|
||||
lines.append("# ruff: noqa\n")
|
||||
lines.append(f"# AUTOGENERATED - do not edit. Generated: {timestamp}\n")
|
||||
lines.append("# Flat CATALOG mapping gidopensees material name -> Pydantic Spec class.\n\n")
|
||||
lines.append("from __future__ import annotations\n\n")
|
||||
lines.append("from pydantic import BaseModel\n\n")
|
||||
|
||||
base_pkg = "otko.core.catalog.generated"
|
||||
entries: list[tuple[str, str, str]] = [] # (gid_name, stem, class_name)
|
||||
for book in mat_books:
|
||||
for entry in book["entries"]:
|
||||
gid_name = entry["name"]
|
||||
stem = _to_module_name(gid_name)
|
||||
cls = _to_class_name(gid_name)
|
||||
entries.append((gid_name, stem, cls))
|
||||
|
||||
for _gid_name, stem, cls in entries:
|
||||
lines.append(f"from {base_pkg}.{stem} import {cls}\n")
|
||||
lines.append("\n\n")
|
||||
|
||||
lines.append("CATALOG: dict[str, type[BaseModel]] = {\n")
|
||||
for gid_name, _, cls in entries:
|
||||
lines.append(f" {gid_name!r}: {cls},\n")
|
||||
lines.append("}\n\n")
|
||||
|
||||
all_items = ", ".join(repr(cls) for _, _, cls in entries)
|
||||
lines.append(f'__all__ = ["CATALOG", {all_items}]\n')
|
||||
|
||||
return "".join(lines)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Orchestration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def run_codegen(schemas_path: Path, out_dir: Path) -> None:
|
||||
data: dict[str, Any] = json.loads(schemas_path.read_text(encoding="utf-8"))
|
||||
mat_books: list[dict[str, Any]] = data["mat_books"]
|
||||
cnd_books: list[dict[str, Any]] = data["cnd_books"]
|
||||
|
||||
timestamp = datetime.now(tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
source_file = schemas_path.name
|
||||
|
||||
# Ensure output directories
|
||||
cnd_dir = out_dir / "conditions"
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
cnd_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
mat_count = 0
|
||||
cnd_count = 0
|
||||
|
||||
# --- Material stubs ---
|
||||
for book in mat_books:
|
||||
for entry in book["entries"]:
|
||||
stem = _to_module_name(entry["name"])
|
||||
cls = _to_class_name(entry["name"])
|
||||
src = _entry_source(
|
||||
entry=entry,
|
||||
module_name=stem,
|
||||
class_name=cls,
|
||||
source_file=source_file,
|
||||
timestamp=timestamp,
|
||||
)
|
||||
(out_dir / f"{stem}.py").write_text(src, encoding="utf-8")
|
||||
mat_count += 1
|
||||
|
||||
# --- Condition stubs ---
|
||||
for book in cnd_books:
|
||||
for entry in book["entries"]:
|
||||
stem = _to_module_name(entry["name"])
|
||||
cls = _to_class_name(entry["name"])
|
||||
src = _entry_source(
|
||||
entry=entry,
|
||||
module_name=stem,
|
||||
class_name=cls,
|
||||
source_file=source_file,
|
||||
timestamp=timestamp,
|
||||
)
|
||||
(cnd_dir / f"{stem}.py").write_text(src, encoding="utf-8")
|
||||
cnd_count += 1
|
||||
|
||||
# --- generated/__init__.py ---
|
||||
mat_pkg = "otko.core.catalog.generated"
|
||||
gen_init = _generated_init_source(mat_books, "mat", mat_pkg, timestamp)
|
||||
(out_dir / "__init__.py").write_text(gen_init, encoding="utf-8")
|
||||
|
||||
# --- generated/conditions/__init__.py ---
|
||||
cnd_pkg = "otko.core.catalog.generated.conditions"
|
||||
cnd_init = _generated_init_source(cnd_books, "cnd", cnd_pkg, timestamp)
|
||||
(cnd_dir / "__init__.py").write_text(cnd_init, encoding="utf-8")
|
||||
|
||||
# --- core/catalog/__init__.py (parent of generated/) ---
|
||||
catalog_dir = out_dir.parent
|
||||
cat_init = _catalog_init_source(mat_books, timestamp)
|
||||
(catalog_dir / "__init__.py").write_text(cat_init, encoding="utf-8")
|
||||
|
||||
# --- core/catalog/curated/__init__.py (empty placeholder) ---
|
||||
curated_dir = catalog_dir / "curated"
|
||||
curated_dir.mkdir(exist_ok=True)
|
||||
curated_init = curated_dir / "__init__.py"
|
||||
if not curated_init.exists():
|
||||
curated_init.write_text("", encoding="utf-8")
|
||||
|
||||
print(f"Generated {mat_count} material stubs -> {out_dir}")
|
||||
print(f"Generated {cnd_count} condition stubs -> {cnd_dir}")
|
||||
print(f"Written -> {out_dir / '__init__.py'}")
|
||||
print(f"Written -> {cnd_dir / '__init__.py'}")
|
||||
print(f"Written -> {catalog_dir / '__init__.py'}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _cli(argv: list[str] | None = None) -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate Pydantic v2 stubs from gidopensees schemas.json"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--schemas",
|
||||
required=True,
|
||||
type=Path,
|
||||
help="Path to schemas.json (e.g. tools/gidopensees_import/schemas.json)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--out",
|
||||
required=True,
|
||||
type=Path,
|
||||
help="Output directory for generated stubs (e.g. src/otko/core/catalog/generated/)",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
schemas_path: Path = args.schemas
|
||||
out_dir: Path = args.out
|
||||
|
||||
if not schemas_path.exists():
|
||||
print(f"error: schemas file not found: {schemas_path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
run_codegen(schemas_path, out_dir)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_cli()
|
||||
471
tools/gidopensees_import/parse_schemas.py
Normal file
471
tools/gidopensees_import/parse_schemas.py
Normal file
|
|
@ -0,0 +1,471 @@
|
|||
"""
|
||||
Parser for GiD schema files (OpenSees.mat, OpenSees.cnd).
|
||||
|
||||
CLI usage:
|
||||
python -m tools.gidopensees_import.parse_schemas \\
|
||||
--mat path/to/OpenSees.mat \\
|
||||
--cnd path/to/OpenSees.cnd \\
|
||||
--out tools/gidopensees_import/schemas.json
|
||||
|
||||
The parser is tolerant of CRLF line endings, blank lines, and leading
|
||||
whitespace. It fails loudly (ParseError with file + line number) on any
|
||||
structurally malformed input.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from tools.gidopensees_import.schema_model import (
|
||||
BookSpec,
|
||||
CatalogSpec,
|
||||
DependencyAction,
|
||||
DependencyRule,
|
||||
EntrySpec,
|
||||
FieldSpec,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Exceptions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ParseError(Exception):
|
||||
"""Raised on unrecoverable parse failure; carries file + line context."""
|
||||
|
||||
def __init__(self, msg: str, path: Path, lineno: int) -> None:
|
||||
super().__init__(f"{path}:{lineno}: {msg}")
|
||||
self.path = path
|
||||
self.lineno = lineno
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# QUESTION-line parser helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_WIDGET_RE = re.compile(
|
||||
r"""^(?P<name>[^#(]+?) # field name (greedy up to first # or ()
|
||||
(?:
|
||||
\#(?P<wtype>CB|UNITS|MAT)\# # widget type marker
|
||||
(?:\((?P<opts>[^)]*)\))? # optional (options…)
|
||||
|
|
||||
\((?P<tuple_opts>[^)]*)\) # bare tuple: Name(sub1,sub2,…)
|
||||
)?
|
||||
\s*$
|
||||
""",
|
||||
re.VERBOSE,
|
||||
)
|
||||
|
||||
|
||||
def _parse_question(text: str) -> tuple[str, str, list[str]]:
|
||||
"""Return (normalized_name, widget_type, options) from a QUESTION value.
|
||||
|
||||
widget_type is one of: "CB", "UNITS", "MAT", "SCALAR", "TUPLE".
|
||||
"""
|
||||
m = _WIDGET_RE.match(text.strip())
|
||||
if not m:
|
||||
# Fall back to treating the whole string as a plain scalar name
|
||||
return text.strip().rstrip(":").strip(), "SCALAR", []
|
||||
|
||||
raw_name = m.group("name").strip().rstrip(":").strip()
|
||||
wtype = m.group("wtype")
|
||||
opts_str = m.group("opts")
|
||||
tuple_str = m.group("tuple_opts")
|
||||
|
||||
if wtype:
|
||||
options = [o.strip() for o in opts_str.split(",")] if opts_str else []
|
||||
return raw_name, wtype, options
|
||||
|
||||
if tuple_str is not None:
|
||||
options = [o.strip() for o in tuple_str.split(",")]
|
||||
return raw_name, "TUPLE", options
|
||||
|
||||
return raw_name, "SCALAR", []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# VALUE-line parser helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_WIDTH_RE = re.compile(r"#WIDTH#\((\d+)\)\s*$")
|
||||
|
||||
|
||||
def _parse_value(text: str) -> tuple[str, int | None]:
|
||||
"""Strip #WIDTH#(N) suffix; return (clean_value, width_hint_or_None)."""
|
||||
m = _WIDTH_RE.search(text)
|
||||
if m:
|
||||
width = int(m.group(1))
|
||||
clean = text[: m.start()].strip()
|
||||
return clean, width
|
||||
return text.strip(), None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DEPENDENCIES parser
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_DEP_GROUPS_RE = re.compile(r"\(([^)]*)\)")
|
||||
_KNOWN_ACTIONS = {"RESTORE", "HIDE", "SET"}
|
||||
|
||||
|
||||
def _parse_dep_group(content: str, path: Path, lineno: int) -> DependencyRule:
|
||||
"""Parse the content inside one (…) dependency group."""
|
||||
parts = [p.strip() for p in content.split(",")]
|
||||
if not parts:
|
||||
raise ParseError("Empty DEPENDENCIES group", path, lineno)
|
||||
|
||||
trigger = parts[0]
|
||||
actions: list[DependencyAction] = []
|
||||
rest = parts[1:]
|
||||
|
||||
i = 0
|
||||
while i < len(rest):
|
||||
token = rest[i].upper()
|
||||
if token not in _KNOWN_ACTIONS:
|
||||
# unknown token - could be a stray value; skip gracefully
|
||||
i += 1
|
||||
continue
|
||||
if i + 2 >= len(rest):
|
||||
raise ParseError(
|
||||
f"Truncated DEPENDENCIES action near '{rest[i]}'", path, lineno
|
||||
)
|
||||
action_str = rest[i]
|
||||
fname = rest[i + 1]
|
||||
target = rest[i + 2]
|
||||
actions.append(
|
||||
DependencyAction(action=action_str.upper(), field=fname, target=target) # type: ignore[arg-type]
|
||||
)
|
||||
i += 3
|
||||
|
||||
return DependencyRule(trigger=trigger, actions=actions)
|
||||
|
||||
|
||||
def _parse_dependencies(value: str, path: Path, lineno: int) -> list[DependencyRule]:
|
||||
"""Extract all dependency groups from a DEPENDENCIES line value."""
|
||||
groups = _DEP_GROUPS_RE.findall(value)
|
||||
if not groups:
|
||||
raise ParseError(f"DEPENDENCIES line has no (...) groups: {value!r}", path, lineno)
|
||||
return [_parse_dep_group(g, path, lineno) for g in groups]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# In-progress entry builder (mutable accumulator)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_END_RE = re.compile(r"^END[\s_](?:MATERIAL|CONDITION)\s*$", re.IGNORECASE)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FieldBuilder:
|
||||
name: str
|
||||
widget_type: str
|
||||
options: list[str] = field(default_factory=list)
|
||||
default: str = ""
|
||||
width_hint: int | None = None
|
||||
help_text: str = ""
|
||||
image: str = ""
|
||||
state: str | None = None
|
||||
tkwidgets: list[str] = field(default_factory=list)
|
||||
dependencies: list[DependencyRule] = field(default_factory=list)
|
||||
section_title: str | None = None
|
||||
|
||||
def build(self) -> FieldSpec:
|
||||
return FieldSpec(
|
||||
name=self.name,
|
||||
widget_type=self.widget_type, # type: ignore[arg-type]
|
||||
options=self.options,
|
||||
default=self.default,
|
||||
width_hint=self.width_hint,
|
||||
help_text=self.help_text,
|
||||
image=self.image,
|
||||
state=self.state,
|
||||
tkwidgets=self.tkwidgets,
|
||||
dependencies=self.dependencies,
|
||||
section_title=self.section_title,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _EntryBuilder:
|
||||
name: str
|
||||
book: str
|
||||
source_type: Literal["MATERIAL", "CONDITION"]
|
||||
comment: str = ""
|
||||
image: str = ""
|
||||
tkwidgets: list[str] = field(default_factory=list)
|
||||
condtype: str = ""
|
||||
condmeshtype: str = ""
|
||||
fields: list[FieldSpec] = field(default_factory=list)
|
||||
_current_field: _FieldBuilder | None = field(default=None, repr=False)
|
||||
_section_title: str | None = field(default=None, repr=False)
|
||||
|
||||
def open_field(self, name: str, widget_type: str, options: list[str]) -> None:
|
||||
self._flush_field()
|
||||
self._current_field = _FieldBuilder(
|
||||
name=name,
|
||||
widget_type=widget_type,
|
||||
options=options,
|
||||
section_title=self._section_title,
|
||||
)
|
||||
|
||||
def _flush_field(self) -> None:
|
||||
if self._current_field is not None:
|
||||
self.fields.append(self._current_field.build())
|
||||
self._current_field = None
|
||||
|
||||
def finish(self) -> EntrySpec:
|
||||
self._flush_field()
|
||||
return EntrySpec(
|
||||
name=self.name,
|
||||
book=self.book,
|
||||
source_type=self.source_type,
|
||||
comment=self.comment,
|
||||
image=self.image,
|
||||
tkwidgets=self.tkwidgets,
|
||||
fields=self.fields,
|
||||
condtype=self.condtype,
|
||||
condmeshtype=self.condmeshtype,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core file parser
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _parse_file(path: Path, source: Literal["mat", "cnd"]) -> list[BookSpec]:
|
||||
"""Parse one .mat or .cnd file; return the list of BookSpec objects."""
|
||||
source_type_for_entry: Literal["MATERIAL", "CONDITION"] = (
|
||||
"MATERIAL" if source == "mat" else "CONDITION"
|
||||
)
|
||||
entry_keyword = "MATERIAL" if source == "mat" else "CONDITION"
|
||||
|
||||
raw = path.read_bytes()
|
||||
# Normalise CRLF → LF; decode as UTF-8 with latin-1 fallback
|
||||
try:
|
||||
text = raw.replace(b"\r\n", b"\n").decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
text = raw.replace(b"\r\n", b"\n").decode("latin-1")
|
||||
|
||||
books: list[BookSpec] = []
|
||||
current_book: BookSpec | None = None
|
||||
current_entry: _EntryBuilder | None = None
|
||||
|
||||
def _close_entry() -> None:
|
||||
nonlocal current_entry
|
||||
if current_entry is not None and current_book is not None:
|
||||
current_book.entries.append(current_entry.finish())
|
||||
current_entry = None
|
||||
|
||||
for lineno, raw_line in enumerate(text.splitlines(), start=1):
|
||||
line = raw_line.strip()
|
||||
|
||||
# Blank line
|
||||
if not line:
|
||||
continue
|
||||
|
||||
# Pure comment lines (but NOT directives prefixed with #)
|
||||
if line.startswith("#") and not re.match(r"#\s*(?:QUESTION|VALUE|DEPENDENCIES):", line):
|
||||
# Could be a genuine comment OR a commented-out directive.
|
||||
# Either way, skip.
|
||||
continue
|
||||
|
||||
# Attempt keyword:value split
|
||||
if ":" not in line:
|
||||
# No colon — cannot be a directive; skip silently
|
||||
continue
|
||||
|
||||
keyword, _, rest = line.partition(":")
|
||||
keyword = keyword.strip().upper()
|
||||
value = rest.strip()
|
||||
|
||||
# ── END ──────────────────────────────────────────────────────────
|
||||
if _END_RE.match(line):
|
||||
_close_entry()
|
||||
continue
|
||||
|
||||
# ── BOOK ─────────────────────────────────────────────────────────
|
||||
if keyword == "BOOK":
|
||||
_close_entry()
|
||||
current_book = BookSpec(name=value, source=source)
|
||||
books.append(current_book)
|
||||
continue
|
||||
|
||||
# ── MATERIAL / CONDITION ─────────────────────────────────────────
|
||||
if keyword == entry_keyword:
|
||||
if current_book is None:
|
||||
raise ParseError(
|
||||
f"{entry_keyword}: block found before any BOOK:", path, lineno
|
||||
)
|
||||
_close_entry()
|
||||
current_entry = _EntryBuilder(
|
||||
name=value,
|
||||
book=current_book.name,
|
||||
source_type=source_type_for_entry,
|
||||
)
|
||||
continue
|
||||
|
||||
# Everything below requires an active entry
|
||||
if current_entry is None:
|
||||
continue
|
||||
|
||||
cf = current_entry._current_field
|
||||
|
||||
# ── CONDTYPE / CONDMESHTYPE ───────────────────────────────────────
|
||||
if keyword == "CONDTYPE":
|
||||
current_entry.condtype = value
|
||||
continue
|
||||
if keyword == "CONDMESHTYPE":
|
||||
current_entry.condmeshtype = value
|
||||
continue
|
||||
|
||||
# ── COMMENT ──────────────────────────────────────────────────────
|
||||
if keyword == "COMMENT":
|
||||
current_entry.comment = value
|
||||
continue
|
||||
|
||||
# ── TITLE ────────────────────────────────────────────────────────
|
||||
if keyword == "TITLE":
|
||||
current_entry._section_title = value
|
||||
continue
|
||||
|
||||
# ── QUESTION ─────────────────────────────────────────────────────
|
||||
if keyword == "QUESTION":
|
||||
name, wtype, options = _parse_question(value)
|
||||
current_entry.open_field(name, wtype, options)
|
||||
continue
|
||||
|
||||
# ── VALUE ────────────────────────────────────────────────────────
|
||||
if keyword == "VALUE":
|
||||
if cf is None:
|
||||
# VALUE before any QUESTION in entry — ignore
|
||||
continue
|
||||
clean, width = _parse_value(value)
|
||||
cf.default = clean
|
||||
if width is not None:
|
||||
cf.width_hint = width
|
||||
continue
|
||||
|
||||
# ── HELP ─────────────────────────────────────────────────────────
|
||||
if keyword == "HELP":
|
||||
if cf is not None:
|
||||
cf.help_text = value
|
||||
continue
|
||||
|
||||
# ── IMAGE ────────────────────────────────────────────────────────
|
||||
if keyword == "IMAGE":
|
||||
if cf is not None:
|
||||
cf.image = value
|
||||
else:
|
||||
current_entry.image = value
|
||||
continue
|
||||
|
||||
# ── STATE ────────────────────────────────────────────────────────
|
||||
if keyword == "STATE":
|
||||
if cf is not None:
|
||||
cf.state = value.upper()
|
||||
continue
|
||||
|
||||
# ── TKWIDGET ─────────────────────────────────────────────────────
|
||||
if keyword == "TKWIDGET":
|
||||
if cf is not None:
|
||||
cf.tkwidgets.append(value)
|
||||
else:
|
||||
current_entry.tkwidgets.append(value)
|
||||
continue
|
||||
|
||||
# ── DEPENDENCIES ─────────────────────────────────────────────────
|
||||
if keyword == "DEPENDENCIES":
|
||||
if cf is None:
|
||||
# DEPENDENCIES before first QUESTION — unusual, skip
|
||||
continue
|
||||
try:
|
||||
rules = _parse_dependencies(value, path, lineno)
|
||||
except ParseError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise ParseError(str(exc), path, lineno) from exc
|
||||
cf.dependencies.extend(rules)
|
||||
continue
|
||||
|
||||
# Unknown keyword — silently ignore (forward-compatible)
|
||||
|
||||
# Close any entry that was open at EOF (no explicit END)
|
||||
_close_entry()
|
||||
|
||||
return books
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def parse_mat(mat_path: Path) -> list[BookSpec]:
|
||||
return _parse_file(mat_path, "mat")
|
||||
|
||||
|
||||
def parse_cnd(cnd_path: Path) -> list[BookSpec]:
|
||||
return _parse_file(cnd_path, "cnd")
|
||||
|
||||
|
||||
def build_catalog(mat_path: Path, cnd_path: Path) -> CatalogSpec:
|
||||
return CatalogSpec(
|
||||
mat_books=parse_mat(mat_path),
|
||||
cnd_books=parse_cnd(cnd_path),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _cli(argv: list[str] | None = None) -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Parse gidopensees .mat/.cnd files into a normalised schemas.json"
|
||||
)
|
||||
parser.add_argument("--mat", required=True, type=Path, help="Path to OpenSees.mat")
|
||||
parser.add_argument("--cnd", required=True, type=Path, help="Path to OpenSees.cnd")
|
||||
parser.add_argument(
|
||||
"--out",
|
||||
required=True,
|
||||
type=Path,
|
||||
help="Output JSON file path (e.g. tools/gidopensees_import/schemas.json)",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
mat_path: Path = args.mat
|
||||
cnd_path: Path = args.cnd
|
||||
out_path: Path = args.out
|
||||
|
||||
for p in (mat_path, cnd_path):
|
||||
if not p.exists():
|
||||
print(f"error: file not found: {p}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Parsing {mat_path} ...")
|
||||
catalog = build_catalog(mat_path, cnd_path)
|
||||
|
||||
mat_entries = sum(len(b.entries) for b in catalog.mat_books)
|
||||
cnd_entries = sum(len(b.entries) for b in catalog.cnd_books)
|
||||
print(
|
||||
f" .mat: {len(catalog.mat_books)} books, {mat_entries} materials\n"
|
||||
f" .cnd: {len(catalog.cnd_books)} books, {cnd_entries} conditions"
|
||||
)
|
||||
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
out_path.write_text(
|
||||
catalog.model_dump_json(indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
print(f"Written -> {out_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_cli()
|
||||
74
tools/gidopensees_import/schema_model.py
Normal file
74
tools/gidopensees_import/schema_model.py
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
"""
|
||||
Pydantic v2 intermediate representation for GiD schema files (.mat / .cnd).
|
||||
|
||||
Each parsed MATERIAL or CONDITION block becomes an EntrySpec. Fields within a
|
||||
block become FieldSpec objects. DEPENDENCIES lines are normalised into
|
||||
DependencyRule / DependencyAction objects. The final output is a CatalogSpec
|
||||
(serialisable to JSON via model_dump_json()).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class DependencyAction(BaseModel):
|
||||
"""One action inside a DEPENDENCIES rule: RESTORE, HIDE, or SET a field."""
|
||||
|
||||
action: Literal["RESTORE", "HIDE", "SET"]
|
||||
field: str
|
||||
target: str # "#CURRENT#" or a literal value
|
||||
|
||||
|
||||
class DependencyRule(BaseModel):
|
||||
"""A complete DEPENDENCIES clause: when trigger matches, apply actions."""
|
||||
|
||||
trigger: str
|
||||
actions: list[DependencyAction]
|
||||
|
||||
|
||||
class FieldSpec(BaseModel):
|
||||
"""One QUESTION block inside a MATERIAL or CONDITION entry."""
|
||||
|
||||
name: str
|
||||
widget_type: Literal["CB", "UNITS", "MAT", "SCALAR", "TUPLE"]
|
||||
options: list[str] = Field(default_factory=list)
|
||||
default: str = ""
|
||||
width_hint: int | None = None
|
||||
help_text: str = ""
|
||||
image: str = ""
|
||||
state: str | None = None # "HIDDEN" | "DISABLED" | None
|
||||
tkwidgets: list[str] = Field(default_factory=list)
|
||||
dependencies: list[DependencyRule] = Field(default_factory=list)
|
||||
section_title: str | None = None # enclosing TITLE: context
|
||||
|
||||
|
||||
class EntrySpec(BaseModel):
|
||||
"""One MATERIAL or CONDITION block."""
|
||||
|
||||
name: str
|
||||
book: str
|
||||
source_type: Literal["MATERIAL", "CONDITION"]
|
||||
comment: str = ""
|
||||
image: str = ""
|
||||
tkwidgets: list[str] = Field(default_factory=list)
|
||||
fields: list[FieldSpec] = Field(default_factory=list)
|
||||
condtype: str = "" # CONDITION-only: CONDTYPE value
|
||||
condmeshtype: str = "" # CONDITION-only: CONDMESHTYPE value
|
||||
|
||||
|
||||
class BookSpec(BaseModel):
|
||||
"""A BOOK section grouping related entries."""
|
||||
|
||||
name: str
|
||||
source: Literal["mat", "cnd"]
|
||||
entries: list[EntrySpec] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CatalogSpec(BaseModel):
|
||||
"""Top-level output: all books from the .mat and .cnd files."""
|
||||
|
||||
mat_books: list[BookSpec] = Field(default_factory=list)
|
||||
cnd_books: list[BookSpec] = Field(default_factory=list)
|
||||
28064
tools/gidopensees_import/schemas.json
Normal file
28064
tools/gidopensees_import/schemas.json
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue