Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions hugr-py/rust/metadata.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,8 @@ pub mod metadata {

#[pymodule_export]
const HUGR_USED_EXTENSIONS: &str = hugr_core::metadata::HugrUsedExtensions::KEY;

// TODO: Get from rust implementation once it exists.
#[pymodule_export]
const HUGR_DEBUG_INFO: &str = "core.debug_info";
}
1 change: 1 addition & 0 deletions hugr-py/src/hugr/_hugr/metadata.pyi
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
HUGR_GENERATOR: str
HUGR_USED_EXTENSIONS: str
HUGR_DEBUG_INFO: str
152 changes: 152 additions & 0 deletions hugr-py/src/hugr/debug_info.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
"""Typed generator source debug information metadata for HUGR nodes."""
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This docstring is hard for me to parse, can you reword?


from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import ClassVar, cast

from hugr.utils import JsonType


@dataclass
class DebugRecord(ABC):
"""Abstract base class for debug records."""

@abstractmethod
def to_json(self) -> JsonType:
"""Encodes the record as a dictionary of native types that can be serialized by
`json.dump`.
"""

@classmethod
def from_json(cls, value: JsonType) -> "DebugRecord":
"""Decode a debug record from json. This is not an abstract method because when
decoding from json by calling `DebugRecord.from_json` we do not have concrete
subtype information, so we decode from the explicit variant tag stored in `kind`
instead.
"""
if not isinstance(value, dict):
msg = f"Expected a dictionary for DebugRecord, but got {type(value)}"
raise TypeError(msg)

kind = value.get("kind")
if isinstance(kind, str):
if kind == DICompileUnit.KIND:
return DICompileUnit.from_json(value)
if kind == DISubprogram.KIND:
return DISubprogram.from_json(value)
if kind == DILocation.KIND:
return DILocation.from_json(value)
msg = f"Unknown DebugRecord kind: {kind}"
raise TypeError(msg)

msg = "Expected DebugRecord to contain string field 'kind'."
raise TypeError(msg)


@dataclass
class DICompileUnit(DebugRecord):
"""Debug information for a compilation unit, corresponds to a HUGR module node."""

KIND: ClassVar[str] = "compile_unit"

directory: str # Working directory of the compiler that generated the HUGR.
filename: int # File that contains the HUGR entrypoint.
file_table: list[str] # Global table of all files referenced in the module.

def to_json(self) -> dict[str, JsonType]:
return {
"kind": self.KIND,
"directory": self.directory,
"filename": self.filename,
"file_table": cast("list[JsonType]", self.file_table),
}

@classmethod
def from_json(cls, value: JsonType) -> "DICompileUnit":
if not isinstance(value, dict):
msg = f"Expected a dictionary for DICompileUnit, but got {type(value)}"
raise TypeError(msg)
for key in ("kind", "directory", "filename", "file_table"):
if key not in value:
msg = f"Expected DICompileUnit to have a '{key}' key but got {value}"
raise TypeError(msg)
files = value["file_table"]
if not isinstance(files, list):
msg = f"Expected 'file_table' to be a list but got {type(files)}"
raise TypeError(msg)
return DICompileUnit(
directory=str(value["directory"]),
filename=int(value["filename"]),
file_table=list[str](value["file_table"]),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
file_table=list[str](value["file_table"]),
file_table=list[str](files),

)


@dataclass
class DISubprogram(DebugRecord):
"""Debug information for a subprogram, corresponds to a function definition or
declaration node.
"""

KIND: ClassVar[str] = "subprogram"

file: int # Index into the string table for filenames.
line_no: int # First line of the function definition.
scope_line: int | None = None # First line of the function body.

def to_json(self) -> dict[str, str]:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be dict[str, JsonType]?

data = {
"kind": self.KIND,
"file": str(self.file),
"line_no": str(self.line_no),
}
# Declarations have no function body so could have no scope_line.
if self.scope_line is not None:
data["scope_line"] = str(self.scope_line)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
data["scope_line"] = str(self.scope_line)
data["scope_line"] = int(self.scope_line)

return data

@classmethod
def from_json(cls, value: JsonType) -> "DISubprogram":
if not isinstance(value, dict):
msg = f"Expected a dictionary for DISubprogram, but got {type(value)}"
raise TypeError(msg)
for key in ("kind", "file", "line_no"):
if key not in value:
msg = f"Expected DISubprogram to have a '{key}' key but got {value}"
raise TypeError(msg)
# Declarations have no function body so could have no scope_line.
scope_line = int(value["scope_line"]) if "scope_line" in value else None
return DISubprogram(
file=int(value["file"]),
line_no=int(value["line_no"]),
scope_line=scope_line,
)


@dataclass
class DILocation(DebugRecord):
"""Debug information for a location, corresponds to call or extension operation
node.
"""

KIND: ClassVar[str] = "location"

column: int
line_no: int

def to_json(self) -> dict[str, str]:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be dict[str, JsonType]?

return {
"kind": self.KIND,
"column": str(self.column),
"line_no": str(self.line_no),
Comment on lines +139 to +140
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
"column": str(self.column),
"line_no": str(self.line_no),
"column": int(self.column),
"line_no": int(self.line_no),

}

@classmethod
def from_json(cls, value: JsonType) -> "DILocation":
if not isinstance(value, dict):
msg = f"Expected a dictionary for DILocation, but got {type(value)}"
raise TypeError(msg)
for key in ("kind", "column", "line_no"):
if key not in value:
msg = f"Expected DILocation to have a '{key}' key but got {value}"
raise TypeError(msg)
return DILocation(column=int(value["column"]), line_no=int(value["line_no"]))
22 changes: 18 additions & 4 deletions hugr-py/src/hugr/metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,16 @@
)

import hugr._hugr.metadata as rust_metadata
from hugr.debug_info import DebugRecord
from hugr.envelope import ExtensionDesc, GeneratorDesc

if TYPE_CHECKING:
from collections.abc import Iterable, Iterator

Meta = TypeVar("Meta")
from hugr.utils import JsonType


# Type alias for json values.
# See <https://github.com/python/typing/issues/182#issuecomment-1320974824>
JsonType = str | int | float | bool | None | Mapping[str, "JsonType"] | list["JsonType"]
Meta = TypeVar("Meta")


class Metadata(Protocol[Meta]):
Expand Down Expand Up @@ -193,3 +193,17 @@ def from_json(cls, value: JsonType) -> list[ExtensionDesc]:
)
raise TypeError(msg)
return [ExtensionDesc.from_json(e) for e in value]


class HugrDebugInfo(Metadata[DebugRecord]):
"""Metadata storing debug information obtained from the generator source."""

KEY = rust_metadata.HUGR_DEBUG_INFO

@classmethod
def to_json(cls, value: DebugRecord) -> JsonType:
return value.to_json()

@classmethod
def from_json(cls, value: JsonType) -> DebugRecord:
return DebugRecord.from_json(value)
4 changes: 4 additions & 0 deletions hugr-py/src/hugr/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
from dataclasses import dataclass, field
from typing import Generic, Protocol, TypeVar

# Type alias for json values.
# See <https://github.com/python/typing/issues/182#issuecomment-1320974824>
JsonType = str | int | float | bool | None | Mapping[str, "JsonType"] | list["JsonType"]

L = TypeVar("L", bound=Hashable)
R = TypeVar("R", bound=Hashable)

Expand Down
53 changes: 52 additions & 1 deletion hugr-py/tests/test_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,18 @@

from semver import Version

from hugr import ops, tys
from hugr.build.function import Module
from hugr.debug_info import DICompileUnit, DILocation, DISubprogram
from hugr.envelope import EnvelopeConfig, ExtensionDesc, GeneratorDesc
from hugr.hugr import Hugr
from hugr.metadata import HugrGenerator, HugrUsedExtensions, JsonType, Metadata
from hugr.metadata import (
HugrDebugInfo,
HugrGenerator,
HugrUsedExtensions,
Metadata,
)
from hugr.utils import JsonType


class CustomMetadata(Metadata[list[JsonType]]):
Expand Down Expand Up @@ -85,3 +94,45 @@ def test_metadata_default() -> None:
) == GeneratorDesc("hugr-py-test", Version.parse("1.2.3"))
assert node.metadata.get("missing.metadata") is None
assert node.metadata.get("missing.metadata", [1, 2, 3]) == [1, 2, 3]


def test_debug_info_roundtrip() -> None:
mod = Module()

# Add DICompileUnit debug info to the module root
compile_unit = DICompileUnit(
directory="/user/project/",
filename=0,
file_table=["guppy1.py", "guppy2.py"],
)
mod.hugr[mod.hugr.module_root].metadata[HugrDebugInfo] = compile_unit

# Add a FuncDefn node to test DISubprogram debug info
func = mod.define_function("random_func", [tys.Bool], [tys.Bool])
[b] = func.inputs()
func.set_outputs(b)
subprogram = DISubprogram(file=0, line_no=10, scope_line=11)
mod.hugr[func.parent_node].metadata[HugrDebugInfo] = subprogram

# Add a call node test DILocation debug info
caller = mod.define_function("caller", [tys.Bool], [tys.Bool])
[b] = caller.inputs()
call_node = caller.call(func.parent_node, b)
caller.set_outputs(call_node)
location = DILocation(column=5, line_no=20)
mod.hugr[call_node].metadata[HugrDebugInfo] = location

# Roundtrip serialization
data = mod.hugr.to_bytes(EnvelopeConfig.TEXT)
loaded = Hugr.from_bytes(data)
module_node = loaded[loaded.module_root]

assert module_node.metadata[HugrDebugInfo] == compile_unit

[func_n, caller_n] = loaded.children(loaded.module_root)
assert loaded[func_n].metadata[HugrDebugInfo] == subprogram

call_n = next(
n for n in loaded.children(caller_n) if isinstance(loaded[n].op, ops.Call)
)
assert loaded[call_n].metadata[HugrDebugInfo] == location
Loading