from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Iterable
from textwrap import dedent
from json import dumps
[docs]
@dataclass(kw_only=True)
class Container(Iterable[tuple[str, Any]]):
"""
A base class for hierarchical containers that can hold other containers or
general data objects.
Parameters
----------
name : str
A name for the container. This can be used to identify the container in
a hierarchical structure.
info : dict[str, Any]
A dictionary containing arbitrary metadata about the container. This can
include any relevant information that describes the container or its
contents.
Tip
___
You can easily iterate over the entries in a container, the same way you
would iterate over a dictionary. For example, if `c` is a container,
.. code-block:: python
for key, entry in c:
print(f"Key: {key}, Entry: {entry}")
"""
name : str
"""The name of the container."""
info : dict[str, Any]
"""A dictionary containing arbitrary metadata about the container."""
contents: dict[str, Any] = field(default_factory=dict, init=False)
"""A dictionary that holds the contents of the container."""
def _clean_info(self, info: dict[str, Any]) -> dict[str, Any]:
"""Recursively clean string fields in the info dictionary."""
cleaned = {}
for key, value in info.items():
if isinstance(value, str):
cleaned[key] = dedent(value).strip()
elif isinstance(value, dict):
cleaned[key] = self._clean_info(value)
else:
cleaned[key] = value
return cleaned
def __post_init__(self):
object.__setattr__(self, "name", dedent(self.name).strip())
object.__setattr__(self, "info", self._clean_info(self.info))
[docs]
def add(self, key: str, value: Any):
"""
Add an entry to the container.
Parameters
----------
key : str
The key for the entry.
value : Any
The value for the entry.
Raises
------
ValueError
If an entry with the specified key already exists in the container.
Tip
---
The method supports chainable calls, so you can add multiple contents
in a single statement. For example:
.. code-block:: python
container.add(content1).add(content2).add(content3)
"""
if key in self.contents:
raise ValueError(f"Entry with key '{key}' already exists in this container.")
self.contents[key] = value
[docs]
def keys(self) -> list[str]:
"""
Returns
-------
list[str]
A list of keys for the entries in this container.
"""
return list(self.contents.keys())
[docs]
def tree(self, indent: int = 2) -> str:
"""
Parameters
----------
indent : int, optional
The number of spaces to use for each level of indentation in the
tree representation. Default is 2.
Returns
-------
str
A string representing the hierarchical tree structure of the container
and its contents.
"""
lines: list[str] = []
prefix = " " * indent
lines.append(f"{prefix}<{self.__class__.__name__}> {self.name}")
for item in self.contents.values():
if hasattr(item, "tree"):
# Recursively print containers
lines.append(item.tree(indent + 1))
return "\n".join(lines)
[docs]
def print_tree(self):
"""Print the hierarchical tree of container entries, as defined by :meth:`tree`."""
print(self.tree())
[docs]
def to_json(self, *, indent: int = 2, ensure_ascii: bool = False) -> str:
"""
Parameters
----------
indent : int, optional
The number of spaces to use for each level of indentation in the
JSON output. Default is 2.
ensure_ascii : bool, optional
Whether to escape non-ASCII characters in the JSON output. Default is `False`.
Returns
-------
str
A JSON string representing the container and its contents.
"""
return dumps(self, indent=indent, ensure_ascii=ensure_ascii, default=lambda o: o.__dict__)
[docs]
def print_json(self):
"""
Print the container and its contents as formatted JSON.
Parameters
----------
indent : int, optional
The number of spaces to use for each level of indentation in the
JSON output. Default is 2.
ensure_ascii : bool, optional
Whether to escape non-ASCII characters in the JSON output. Default is `False`.
"""
print(self.to_json())
[docs]
def get(self, key: str) -> Any:
"""
Parameters
----------
key : str
The key of the entry to retrieve from the container.
Returns
-------
Any
The value associated with the specified key in the container.
Tip
---
A container object can be directly indexed to access its contents. In
other words, the following two lines are equivalent for a container `c`:
.. code-block:: python
c.get("k")
c["k"]
where `"k"` is the key of an entry in the container.
"""
if not key in self.contents:
raise KeyError(f"No entry with key '{key}' found in this container.")
return self.contents[key]
def __getitem__(self, key: str) -> Any:
return self.get(key)
def __str__(self) -> str:
text = dumps(self, indent=2, ensure_ascii=False, default=lambda o: o.__dict__)
return text.replace("\\n", "\n").replace("\\t", "\t")
@property
def __dict__(self) -> dict[str, Any]:
return {
"name" : self.name,
"info" : self.info,
"type" : self.__class__.__name__,
"contents": [c.__dict__ for c in self.contents.values()],
"keys" : self.keys(),
}
def __iter__(self):
for key, value in self.contents.items():
yield key, value