Source code for yapcad.io.meta_yaml
"""Sidecar ``.meta.yaml`` emitter for yapCAD STEP/STL exports.
Implements Step 5 of the metadata-namespace-v1.1 migration (RFC
``rfc_assembly_operation_namespaces.md``).
Usage
-----
Call :func:`dump_metadata_yaml` immediately after writing a STEP or STL file::
write_step(part, "output/my_part.step")
dump_metadata_yaml(part, "output/my_part.step") # → my_part.meta.yaml
The sidecar file is placed next to the geometry file by default. Pass an
explicit *yaml_path* to override.
The function is a no-op (and returns ``None``) when the part carries no
yapCAD metadata — so callers can always call it unconditionally.
"""
from __future__ import annotations
import datetime
import hashlib
import os
from pathlib import Path
from typing import Any, Dict, Optional
try:
import yaml as _yaml
_YAML_AVAILABLE = True
except ImportError:
_YAML_AVAILABLE = False
try:
import yapcad
_YAPCAD_VERSION = getattr(yapcad, "__version__", "unknown")
except Exception:
_YAPCAD_VERSION = "unknown"
[docs]
def dump_metadata_yaml(
part: Any,
geometry_path: str | os.PathLike,
*,
yaml_path: Optional[str | os.PathLike] = None,
source_dsl: Optional[str] = None,
source_command: Optional[str] = None,
) -> Optional[str]:
"""Write a sidecar ``.meta.yaml`` file next to a STEP or STL export.
Parameters
----------
part:
A yapCAD solid, surface, or other geometry object that may carry a
metadata dict (retrieved via :func:`yapcad.metadata.get_metadata`).
geometry_path:
Path to the already-written STEP/STL file. Used to derive the
default sidecar path and to compute ``_provenance.source_sha256``
when *source_dsl* is not provided.
yaml_path:
Explicit output path for the YAML sidecar. When omitted, the file
is placed alongside *geometry_path* with the suffix replaced by
``.meta.yaml``.
source_dsl:
Path to the originating DSL file. Stored in ``_provenance`` and
used for the SHA-256 hash when the file exists.
source_command:
The DSL command name that produced this part (e.g. ``"FORWARD_BULKHEAD"``).
Returns
-------
str or None
Absolute path of the written YAML file, or ``None`` when no metadata
was found on *part* (the sidecar is not written in that case).
Raises
------
ImportError
If PyYAML is not installed and metadata *is* present (so the caller
is aware that the sidecar was not written).
"""
meta = _extract_metadata(part)
if meta is None:
return None
if not _YAML_AVAILABLE:
raise ImportError(
"PyYAML is required for .meta.yaml export. "
"Install it with: pip install pyyaml"
)
geo_path = Path(geometry_path).resolve()
if yaml_path is None:
# Strip all suffixes, not just the last one, so "part.step" and
# "part_CURRENT.step" both produce "part.meta.yaml" / "part_CURRENT.meta.yaml".
stem = geo_path.name
for suffix in reversed(geo_path.suffixes):
stem = stem[: -len(suffix)]
out_path = geo_path.parent / (stem + ".meta.yaml")
else:
out_path = Path(yaml_path).resolve()
provenance = _build_provenance(geo_path, source_dsl, source_command)
# Merge provenance into a copy of the metadata dict.
output: Dict[str, Any] = dict(meta)
output["_provenance"] = provenance
# Ensure schema string is at the top for readability.
ordered: Dict[str, Any] = {}
for key in ("schema", "entityId", "timestamp", "tags", "layer"):
if key in output:
ordered[key] = output.pop(key)
ordered["_provenance"] = output.pop("_provenance")
ordered.update(output)
with open(out_path, "w", encoding="utf-8") as f:
_yaml.dump(ordered, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
return str(out_path)
[docs]
def load_metadata_yaml(yaml_path: str | os.PathLike) -> Dict[str, Any]:
"""Load a ``.meta.yaml`` sidecar and return the raw dict.
Parameters
----------
yaml_path:
Path to the ``.meta.yaml`` file.
Returns
-------
dict
The deserialized metadata dictionary.
Raises
------
ImportError
If PyYAML is not installed.
FileNotFoundError
If *yaml_path* does not exist.
"""
if not _YAML_AVAILABLE:
raise ImportError(
"PyYAML is required to load .meta.yaml files. "
"Install it with: pip install pyyaml"
)
path = Path(yaml_path)
if not path.exists():
raise FileNotFoundError(f"Sidecar not found: {path}")
with open(path, "r", encoding="utf-8") as f:
return _yaml.safe_load(f) or {}
[docs]
def sidecar_path_for(geometry_path: str | os.PathLike) -> Path:
"""Return the conventional sidecar path for a geometry file.
This is a pure path computation — no file I/O is performed.
Parameters
----------
geometry_path:
Path to a STEP or STL file.
Returns
-------
pathlib.Path
The conventional ``.meta.yaml`` path.
"""
geo_path = Path(geometry_path)
stem = geo_path.name
for suffix in reversed(geo_path.suffixes):
stem = stem[: -len(suffix)]
return geo_path.parent / (stem + ".meta.yaml")
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _extract_metadata(part: Any) -> Optional[Dict[str, Any]]:
"""Return the metadata dict from *part*, or ``None`` if absent."""
try:
from yapcad.metadata import get_metadata # type: ignore[import]
meta = get_metadata(part)
return meta if meta else None
except (ImportError, Exception):
pass
# Fallback: check common attribute names used in yapCAD internals.
for attr in ("_metadata", "metadata", "meta"):
val = getattr(part, attr, None)
if isinstance(val, dict) and val:
return val
return None
def _build_provenance(
geo_path: Path,
source_dsl: Optional[str],
source_command: Optional[str],
) -> Dict[str, Any]:
"""Build the ``_provenance`` block for the sidecar."""
now = datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
prov: Dict[str, Any] = {
"yapcad_version": _YAPCAD_VERSION,
"exported_at": now,
}
if source_dsl:
prov["source_dsl"] = source_dsl
dsl_path = Path(source_dsl)
if dsl_path.exists():
prov["source_dsl_sha256"] = _sha256_file(dsl_path)
else:
# Fall back to hashing the geometry file itself for a weak provenance anchor.
if geo_path.exists():
prov["geometry_sha256"] = _sha256_file(geo_path)
if source_command:
prov["source_command"] = source_command
return prov
def _sha256_file(path: Path) -> str:
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(65536), b""):
h.update(chunk)
return h.hexdigest()
__all__ = ["dump_metadata_yaml", "load_metadata_yaml", "sidecar_path_for"]