diff --git a/doc/source/reference/cells.rst b/doc/source/reference/cells.rst index ab68d05a..d2af12af 100644 --- a/doc/source/reference/cells.rst +++ b/doc/source/reference/cells.rst @@ -23,6 +23,7 @@ Basic properties ~Cells.properties ~Cells.set_property ~Cells.is_cached + ~Cells.info Cells operations diff --git a/doc/source/reference/macro.rst b/doc/source/reference/macro.rst index 2ad6d6a8..93a3045c 100644 --- a/doc/source/reference/macro.rst +++ b/doc/source/reference/macro.rst @@ -15,3 +15,4 @@ Basic properties ~Macro.formula ~Macro.parent + ~Macro.info diff --git a/doc/source/reference/model.rst b/doc/source/reference/model.rst index b5b77cb8..5ecb17f3 100644 --- a/doc/source/reference/model.rst +++ b/doc/source/reference/model.rst @@ -26,6 +26,7 @@ Model properties ~Model.macros ~Model.iospecs ~Model.tracegraph + ~Model.info Model operations ---------------- diff --git a/doc/source/reference/space/dynamicspace.rst b/doc/source/reference/space/dynamicspace.rst index 8729b86c..6411e569 100644 --- a/doc/source/reference/space/dynamicspace.rst +++ b/doc/source/reference/space/dynamicspace.rst @@ -22,6 +22,7 @@ Basic properties ~DynamicSpace.refs ~DynamicSpace.has_params ~DynamicSpace.set_property + ~DynamicSpace.info Inheritance operations ---------------------- diff --git a/doc/source/reference/space/itemspace.rst b/doc/source/reference/space/itemspace.rst index 25fd254d..0d3edd2a 100644 --- a/doc/source/reference/space/itemspace.rst +++ b/doc/source/reference/space/itemspace.rst @@ -24,6 +24,7 @@ Basic properties ~ItemSpace.cur_space ~ItemSpace.has_params ~ItemSpace.set_property + ~ItemSpace.info Inheritance properties diff --git a/doc/source/reference/space/userspace.rst b/doc/source/reference/space/userspace.rst index a1f0c2c8..26c5c1c3 100644 --- a/doc/source/reference/space/userspace.rst +++ b/doc/source/reference/space/userspace.rst @@ -22,6 +22,7 @@ Basic properties ~UserSpace.refs ~UserSpace.has_params ~UserSpace.set_property + ~UserSpace.info Space operations ---------------- diff --git a/modelx/core/base.py b/modelx/core/base.py index 4d3241ee..f4022744 100644 --- a/modelx/core/base.py +++ b/modelx/core/base.py @@ -444,6 +444,49 @@ def foo(x): """ return self._impl.doc + @property + def info(self): + """An object whose ``repr`` summarizes this Interface. + + Returns a lightweight wrapper whose string representation displays + a human-readable snapshot of this object. The exact fields depend + on the concrete type: + + * For :class:`~modelx.core.cells.Cells`: the cells' fully-qualified + representation including its signature + (e.g., ``Model1.Space1[1].foo(t, i=0)``), + :attr:`~modelx.core.cells.Cells.is_cached`, + :attr:`allow_none`, the source of the + :attr:`~modelx.core.cells.Cells.formula`, + the number of cached values together with an abbreviated listing + of cached key-value pairs, and -- when any input values have been + assigned -- the number of input values along with an abbreviated + listing of input key-value pairs. + + * For spaces: the space's fully-qualified representation + (e.g., ``Model1.Space1[1]``), + :attr:`~modelx.core.space.BaseSpace.parameters` (when defined, + shown as a signature string such as ``i, j=0``), + the number of :class:`~modelx.core.space.ItemSpace` children, + and an abbreviated list of the item-space keys. + + Example: + .. code-block:: python + + >>> space.foo.info + Cells: Model.Space.foo(t) + is_cached: True + allow_none: None + formula: + def foo(t): + return t * 2 + cached values: 2 + 0: 0 + 1: 2 + """ + from modelx.core.info import build_info + return build_info(self) + def __repr__(self): type_ = self.__class__.__name__ diff --git a/modelx/core/info.py b/modelx/core/info.py new file mode 100644 index 00000000..c2e34379 --- /dev/null +++ b/modelx/core/info.py @@ -0,0 +1,203 @@ +# Copyright (c) 2017-2026 Fumito Hamamura + +# This library is free software: you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation version 3. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. If not, see . +"""Informational reprs for Interface objects. + +This module defines lightweight wrappers returned by the +:attr:`~modelx.core.base.Interface.info` property. The wrappers carry no +new state of their own; they format a human-readable snapshot of the +underlying Cells or Space when ``repr()`` is called on them. +""" + +_INDENT = " " +_MAX_ITEMS = 5 + + +def _format_key(key): + """Render a cells data key. Single-argument tuples are unwrapped.""" + if isinstance(key, tuple) and len(key) == 1: + return repr(key[0]) + return repr(key) + + +def _format_kv_items(items, indent=_INDENT, max_items=_MAX_ITEMS): + """Format key-value items as indented lines, truncating long lists. + + When a value's ``repr`` spans multiple lines (e.g. a ``pandas`` + ``Series`` or ``DataFrame``) the value is placed on its own lines + starting immediately after the key, so the multi-line layout + survives intact. + """ + items = list(items) + lines = [] + + def add(key, value): + key_str = _format_key(key) + val_repr = repr(value) + if "\n" in val_repr: + lines.append(indent + key_str + ": ") + lines.extend(val_repr.splitlines()) + else: + lines.append(indent + key_str + ": " + val_repr) + + if len(items) <= max_items: + for key, value in items: + add(key, value) + else: + head = max_items - 1 + for key, value in items[:head]: + add(key, value) + lines.append(indent + "...") + return lines + + +def _format_keys_list(keys, max_items=_MAX_ITEMS): + """Format ``keys`` as a single-line abbreviated list literal.""" + keys = list(keys) + if len(keys) <= max_items: + return "[" + ", ".join(repr(k) for k in keys) + "]" + head = max_items - 1 + return "[" + ", ".join(repr(k) for k in keys[:head]) + ", ...]" + + +def _signature_str(formula): + """Return ``formula``'s signature without the outer parentheses.""" + s = str(formula.signature) + if s.startswith("(") and s.endswith(")"): + s = s[1:-1] + return s + + +class _InterfaceInfo: + """Base class for objects returned by ``Interface.info``.""" + + __slots__ = ("_interface",) + + def __init__(self, interface): + self._interface = interface + + def _class_name(self): + return type(self._interface).__name__ + + def _header(self): + return self._interface._get_repr(fullname=True, add_params=True) + + def _body_lines(self): + return [] + + def __repr__(self): + lines = ["%s: %s" % (self._class_name(), self._header())] + lines.extend(self._body_lines()) + return "\n".join(lines) + + +class CellsInfo(_InterfaceInfo): + """Informational repr for :class:`~modelx.core.cells.Cells`.""" + + __slots__ = () + + def _header(self): + impl = self._interface._impl + parent_repr = impl.repr_parent() + sig = str(impl.formula.signature) + if parent_repr: + return parent_repr + "." + impl.name + sig + return impl.name + sig + + def _body_lines(self): + cells = self._interface + impl = cells._impl + lines = [] + lines.append("is_derived: " + repr(cells._is_derived())) + lines.append("is_cached: " + repr(cells.is_cached)) + lines.append("allow_none: " + repr(cells.allow_none)) + + source = impl.formula.source + lines.append("formula:") + if source: + for line in source.rstrip("\n").splitlines(): + lines.append(_INDENT + line) + else: + lines.append(_INDENT + "(source not available)") + + data = impl.data + input_keys = impl.input_keys + cached_items = [(k, v) for k, v in data.items() if k not in input_keys] + input_items = [(k, v) for k, v in data.items() if k in input_keys] + + lines.append("cached values: " + str(len(cached_items))) + lines.extend(_format_kv_items(cached_items)) + + if input_items: + lines.append("input values: " + str(len(input_items))) + lines.extend(_format_kv_items(input_items)) + + return lines + + +class SpaceInfo(_InterfaceInfo): + """Informational repr for spaces (UserSpace, ItemSpace, DynamicSpace).""" + + __slots__ = () + + def _body_lines(self): + space = self._interface + lines = [] + + base_names = [b.fullname for b in space.bases] + lines.append("bases: [" + ", ".join(base_names) + "]") + + formula = getattr(space._impl, "formula", None) + if formula is not None: + lines.append("parameters: " + _signature_str(formula)) + + itemspaces = dict(space.itemspaces) + lines.append("itemspaces: " + str(len(itemspaces))) + if itemspaces: + lines.append(_INDENT + _format_keys_list(itemspaces.keys())) + return lines + + +class ModelInfo(_InterfaceInfo): + """Informational repr for :class:`~modelx.core.model.Model`.""" + + __slots__ = () + + def _header(self): + return self._interface.name + + def _body_lines(self): + spaces = dict(self._interface.spaces) + lines = ["spaces: " + str(len(spaces))] + if spaces: + lines.append(_INDENT + _format_keys_list(spaces.keys())) + return lines + + +def build_info(interface): + """Return an info wrapper appropriate for ``interface``. + + Imports are deferred so this module does not need to be imported + at package load time. + """ + from modelx.core.cells import Cells + from modelx.core.space import BaseSpace + from modelx.core.model import Model + + if isinstance(interface, Cells): + return CellsInfo(interface) + if isinstance(interface, BaseSpace): + return SpaceInfo(interface) + if isinstance(interface, Model): + return ModelInfo(interface) + return _InterfaceInfo(interface) diff --git a/modelx/tests/core/cells/test_cells_info.py b/modelx/tests/core/cells/test_cells_info.py new file mode 100644 index 00000000..b0901768 --- /dev/null +++ b/modelx/tests/core/cells/test_cells_info.py @@ -0,0 +1,204 @@ +import modelx as mx +import pytest + + +@pytest.fixture +def testmodel(): + m = mx.new_model('Model1') + s = m.new_space('Space1', formula=lambda i: None) + + def foo(t, i=0): + if t == 0: + return i + return foo(t - 1, i) + 1 + + s.new_cells('foo', formula=foo) + + yield m + m._impl._check_sanity() + m.close() + + +def test_info_type_name(testmodel): + m = testmodel + info = m.Space1.foo.info + assert type(info).__name__ == 'CellsInfo' + + +def test_info_header_uses_class_name_and_signature(testmodel): + m = testmodel + first = repr(m.Space1[1].foo.info).splitlines()[0] + assert first == 'Cells: Model1.Space1[1].foo(t, i=0)' + + +def test_info_shows_is_derived(testmodel): + m = testmodel + text = repr(m.Space1.foo.info) + lines = text.splitlines() + # is_derived appears as the second item (right after the header) + assert lines[1] == 'is_derived: False' + + # Build a derived cells via space inheritance + derived = m.new_space('Derived', bases=m.Space1) + text2 = repr(derived.foo.info) + lines2 = text2.splitlines() + assert lines2[1] == 'is_derived: True' + + +def test_info_shows_is_cached_and_allow_none(testmodel): + m = testmodel + cells = m.Space1.foo + text = repr(cells.info) + assert 'is_cached: True' in text + assert 'allow_none: None' in text + + cells.is_cached = False + cells.allow_none = True + text2 = repr(cells.info) + assert 'is_cached: False' in text2 + assert 'allow_none: True' in text2 + + +def test_info_shows_full_formula_source(testmodel): + m = testmodel + text = repr(m.Space1.foo.info) + assert 'formula:' in text + assert 'def foo(t, i=0):' in text + assert 'if t == 0:' in text + assert 'return i' in text + assert 'return foo(t - 1, i) + 1' in text + + +def test_info_cached_value_count_and_listing(testmodel): + m = testmodel + m.Space1[1].foo(2) + text = repr(m.Space1[1].foo.info) + assert 'cached values: 3' in text + assert '(0, 0): 0' in text + assert '(1, 0): 1' in text + assert '(2, 0): 2' in text + + +def test_info_abbreviates_many_cached_values(testmodel): + m = testmodel + cells = m.Space1[1].foo + for t in range(10): + cells(t) + text = repr(cells.info) + assert 'cached values: 10' in text + # Abbreviation marker present + assert text.splitlines()[-1].strip() == '...' + + +def test_info_omits_input_values_when_none(testmodel): + m = testmodel + m.Space1.foo(0) + text = repr(m.Space1.foo.info) + assert 'input values' not in text + + +def test_info_shows_input_values_when_present(testmodel): + m = testmodel + cells = m.Space1.foo + cells[10] = 100 + cells[20] = 200 + text = repr(cells.info) + assert 'input values: 2' in text + assert '(10, 0): 100' in text + assert '(20, 0): 200' in text + + +def test_info_separates_cached_from_input_counts(testmodel): + m = testmodel + cells = m.Space1.foo + cells(0) + cells(1) + cells[10] = 100 + text = repr(cells.info) + # Two computed entries (0,) and (1,), one input (10,) + assert 'cached values: 2' in text + assert 'input values: 1' in text + + +def test_info_with_scalar_cells(): + m = mx.new_model() + try: + s = m.new_space('S') + s.new_cells('scalar', formula=lambda: 42) + s.scalar() + text = repr(s.scalar.info) + first = text.splitlines()[0] + assert first.endswith('S.scalar()') + assert first.startswith('Cells: ') + assert 'cached values: 1' in text + # Scalar cells keep the empty-tuple key (no single argument to unwrap) + assert '(): 42' in text + finally: + m._impl._check_sanity() + m.close() + + +def test_info_breaks_line_for_multiline_value(): + pd = pytest.importorskip('pandas') + m = mx.new_model() + try: + s = m.new_space('S') + s.pd = pd + + def make_df(): + return pd.DataFrame({'a': [1, 2, 3], 'b': [4, 5, 6]}) + + s.new_cells('make_df', formula=make_df) + s.make_df() + + text = repr(s.make_df.info) + lines = text.splitlines() + # Find the cached-values key line + key_line_idx = next( + i for i, ln in enumerate(lines) if ln.strip().startswith('():')) + key_line = lines[key_line_idx] + next_line = lines[key_line_idx + 1] + # The value's repr is broken onto subsequent lines, so the key + # line ends with ": " (no value content) and the following line + # contains part of the DataFrame's repr. + assert key_line.rstrip() == ' ():' + df_repr_lines = repr(s.make_df()).splitlines() + assert next_line == df_repr_lines[0] + finally: + m._impl._check_sanity() + m.close() + + +def test_info_keeps_single_line_value_inline(): + m = mx.new_model() + try: + s = m.new_space('S') + s.new_cells('scal', formula=lambda: 42) + s.scal() + text = repr(s.scal.info) + # Inline format preserved for single-line values + assert '(): 42' in text + finally: + m._impl._check_sanity() + m.close() + + +def test_info_unwraps_single_arg_keys(): + m = mx.new_model() + try: + s = m.new_space('S') + s.new_cells('bar', formula=lambda x: x * 2) + s.bar(3) + s.bar(5) + s.bar[10] = 100 + text = repr(s.bar.info) + # Single-argument keys are shown without the (,) tuple form + assert '3: 6' in text + assert '5: 10' in text + assert '10: 100' in text + assert '(3,)' not in text + assert '(5,)' not in text + assert '(10,)' not in text + finally: + m._impl._check_sanity() + m.close() diff --git a/modelx/tests/core/space/properties/test_space_info.py b/modelx/tests/core/space/properties/test_space_info.py new file mode 100644 index 00000000..c0358919 --- /dev/null +++ b/modelx/tests/core/space/properties/test_space_info.py @@ -0,0 +1,101 @@ +import modelx as mx +import pytest + + +@pytest.fixture +def testmodel(): + m = mx.new_model('Model1') + parent = m.new_space('Space1', formula=lambda i, j=0: None) + parent.new_space('Child') + parent[1] + parent[2] + yield m + m._impl._check_sanity() + m.close() + + +def test_info_type_name(testmodel): + m = testmodel + info = m.Space1.info + assert type(info).__name__ == 'SpaceInfo' + + +def test_info_header_for_user_space(testmodel): + m = testmodel + first = repr(m.Space1.info).splitlines()[0] + assert first == 'UserSpace: Model1.Space1' + + +def test_info_header_for_item_space(testmodel): + m = testmodel + first = repr(m.Space1[1].info).splitlines()[0] + assert first == 'ItemSpace: Model1.Space1[1, 0]' + + +def test_info_shows_bases(testmodel): + m = testmodel + # The base UserSpace has no bases + lines = repr(m.Space1.Child.info).splitlines() + assert lines[1] == 'bases: []' + + # A derived space lists base spaces using their dotted fullnames + derived = m.new_space('Derived', bases=m.Space1) + lines2 = repr(derived.info).splitlines() + assert lines2[1] == 'bases: [Model1.Space1]' + # Plain dotted names, no wrappers + assert '