Files
mxpic_forge/mxpic/cores/generation_metadata.py
T
2026-06-04 23:21:39 +08:00

271 lines
8.8 KiB
Python

"""Metadata recording helpers for component generation classes."""
import inspect
import json
import re
import sys
from datetime import datetime
from functools import wraps
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
DEFAULT_METADATA_DIR = PROJECT_ROOT / "metafile"
_RECORDER_MARK = "_mxpic_generation_metadata_wrapped"
_INTERNAL_PREFIX = "_mxpic_generation_"
_AUTO_METADATA_ENABLED = True
_AUTO_METADATA_DIR = DEFAULT_METADATA_DIR
_AUTO_METADATA_INCLUDE_STATE = True
_AUTO_METADATA_INCLUDE_PRIVATE = False
_CONSTRUCTION_DEPTH = 0
def _json_safe(value, depth=0):
"""Return a JSON-friendly representation without walking huge objects."""
if value is None or isinstance(value, (bool, int, float, str)):
return value
if isinstance(value, Path):
return str(value)
if depth >= 4:
return _short_repr(value)
if isinstance(value, (list, tuple)):
return [_json_safe(item, depth + 1) for item in value]
if isinstance(value, set):
return [_json_safe(item, depth + 1) for item in sorted(value, key=repr)]
if isinstance(value, dict):
return {
str(_json_safe(key, depth + 1)): _json_safe(item, depth + 1)
for key, item in value.items()
}
return _object_summary(value)
def _object_summary(value):
summary = {
"type": f"{value.__class__.__module__}.{value.__class__.__name__}",
"repr": _short_repr(value),
}
for attr in ("name", "cell_name", "basename", "length", "width"):
if hasattr(value, attr):
try:
summary[attr] = _json_safe(getattr(value, attr), depth=4)
except Exception:
summary[attr] = "<unavailable>"
return summary
def _short_repr(value, limit=240):
try:
text = repr(value)
except Exception:
text = f"<unrepresentable {value.__class__.__name__}>"
if len(text) > limit:
return text[: limit - 3] + "..."
return text
def _sanitize_filename_part(value):
text = str(value or "")
text = re.sub(r"[^A-Za-z0-9_.-]+", "_", text).strip("._")
return text or "unnamed"
def _capture_init_parameters(init, self, args, kwargs):
try:
signature = inspect.signature(init)
bound = signature.bind(self, *args, **kwargs)
bound.apply_defaults()
except Exception:
return {
"args": _json_safe(args),
"kwargs": _json_safe(kwargs),
}
return {
name: _json_safe(value)
for name, value in bound.arguments.items()
if name != "self"
}
def get_generation_metadata(self, include_state=True, include_private=False):
"""Return the recorded generation metadata for this component instance."""
metadata = {
"class": self.__class__.__name__,
"module": self.__class__.__module__,
"created_at": getattr(self, "_mxpic_generation_created_at", None),
"init_parameters": getattr(self, "_mxpic_generation_parameters", {}),
}
metadata_file = getattr(self, "_mxpic_generation_metadata_file", None)
if metadata_file is not None:
metadata["metadata_file"] = metadata_file
metadata_error = getattr(self, "_mxpic_generation_metadata_error", None)
if metadata_error is not None:
metadata["metadata_error"] = metadata_error
if include_state:
metadata["state"] = _collect_state(self, include_private=include_private)
return metadata
def _collect_state(instance, include_private=False):
state = {}
for key, value in getattr(instance, "__dict__", {}).items():
if key.startswith(_INTERNAL_PREFIX):
continue
if not include_private and key.startswith("_"):
continue
if callable(value):
continue
state[key] = _json_safe(value)
return state
def save_generation_metadata(
self,
folder=None,
filename=None,
include_state=True,
include_private=False,
):
"""Write generation metadata to JSON and return the saved file path."""
folder_path = Path(folder) if folder is not None else DEFAULT_METADATA_DIR
folder_path.mkdir(parents=True, exist_ok=True)
metadata = self.get_generation_metadata(
include_state=include_state,
include_private=include_private,
)
if filename is None:
params = metadata.get("init_parameters", {})
name = params.get("name") or getattr(self, "name", None)
suffix = datetime.now().strftime("%Y%m%d-%H%M%S-%f")
filename = (
f"{self.__class__.__name__}_"
f"{_sanitize_filename_part(name)}_"
f"{suffix}.json"
)
elif not str(filename).lower().endswith(".json"):
filename = f"{filename}.json"
file_path = folder_path / filename
file_path.write_text(json.dumps(metadata, indent=4), encoding="utf-8")
self._mxpic_generation_metadata_file = str(file_path)
return file_path
def set_generation_metadata_auto_save(
enabled=True,
folder=None,
include_state=True,
include_private=False,
):
"""Configure automatic metadata JSON writes after component construction."""
global _AUTO_METADATA_ENABLED
global _AUTO_METADATA_DIR
global _AUTO_METADATA_INCLUDE_STATE
global _AUTO_METADATA_INCLUDE_PRIVATE
_AUTO_METADATA_ENABLED = bool(enabled)
if folder is not None:
_AUTO_METADATA_DIR = Path(folder)
_AUTO_METADATA_INCLUDE_STATE = bool(include_state)
_AUTO_METADATA_INCLUDE_PRIVATE = bool(include_private)
def get_generation_metadata_auto_save_config():
"""Return the current automatic metadata write configuration."""
return {
"enabled": _AUTO_METADATA_ENABLED,
"folder": str(_AUTO_METADATA_DIR),
"include_state": _AUTO_METADATA_INCLUDE_STATE,
"include_private": _AUTO_METADATA_INCLUDE_PRIVATE,
}
def install_generation_metadata_recorders(package_prefixes=None):
"""Attach metadata methods to generation classes already imported."""
if package_prefixes is None:
package_prefixes = (
"mxpic_forge_new.components",
)
installed = []
for module in list(sys.modules.values()):
module_name = getattr(module, "__name__", "")
if not module_name.startswith(package_prefixes):
continue
for _, cls in inspect.getmembers(module, inspect.isclass):
if not getattr(cls, "__module__", "").startswith(package_prefixes):
continue
if _wrap_generation_class(cls):
installed.append(cls)
return installed
def _wrap_generation_class(cls):
if cls.__dict__.get(_RECORDER_MARK, False):
return False
init = cls.__dict__.get("__init__")
if init is None:
_attach_methods(cls)
setattr(cls, _RECORDER_MARK, True)
return True
@wraps(init)
def wrapped_init(self, *args, **kwargs):
global _CONSTRUCTION_DEPTH
captured = _capture_init_parameters(init, self, args, kwargs)
depth = getattr(self, "_mxpic_generation_record_depth", 0)
is_outermost_construction = _CONSTRUCTION_DEPTH == 0
_CONSTRUCTION_DEPTH += 1
self._mxpic_generation_record_depth = depth + 1
try:
result = init(self, *args, **kwargs)
finally:
_restore_record_depth(self, depth)
_CONSTRUCTION_DEPTH -= 1
if depth == 0:
self._mxpic_generation_parameters = captured
self._mxpic_generation_created_at = datetime.now().isoformat(timespec="seconds")
if is_outermost_construction:
_auto_save_generation_metadata(self)
return result
cls.__init__ = wrapped_init
_attach_methods(cls)
setattr(cls, _RECORDER_MARK, True)
return True
def _restore_record_depth(instance, depth):
if depth:
instance._mxpic_generation_record_depth = depth
elif hasattr(instance, "_mxpic_generation_record_depth"):
delattr(instance, "_mxpic_generation_record_depth")
def _auto_save_generation_metadata(instance):
if not _AUTO_METADATA_ENABLED:
return
try:
save_generation_metadata(
instance,
folder=_AUTO_METADATA_DIR,
include_state=_AUTO_METADATA_INCLUDE_STATE,
include_private=_AUTO_METADATA_INCLUDE_PRIVATE,
)
if hasattr(instance, "_mxpic_generation_metadata_error"):
delattr(instance, "_mxpic_generation_metadata_error")
except Exception as exc:
instance._mxpic_generation_metadata_error = f"{exc.__class__.__name__}: {exc}"
def _attach_methods(cls):
if not hasattr(cls, "get_generation_metadata"):
cls.get_generation_metadata = get_generation_metadata
if not hasattr(cls, "save_generation_metadata"):
cls.save_generation_metadata = save_generation_metadata
if not hasattr(cls, "record_generation_metadata"):
cls.record_generation_metadata = save_generation_metadata