From de5f3a07e40a46727e9f03d513fc8d38e943ae54 Mon Sep 17 00:00:00 2001 From: gubaidulinvadim Date: Sat, 15 Aug 2026 01:25:36 +0200 Subject: [PATCH 1/8] __pyaml_repr__ prints nested __repr__ of objects --- pyaml/common/element.py | 51 ++++++++++++++++++++++------------------- 1 file changed, 27 insertions(+), 24 deletions(-) diff --git a/pyaml/common/element.py b/pyaml/common/element.py index 613d63b3..6410dbec 100644 --- a/pyaml/common/element.py +++ b/pyaml/common/element.py @@ -10,14 +10,9 @@ def __pyaml_repr__(obj, exclude: list[str] | None = None): """ - Returns a string representation of a pyaml object. - - Parameters - ---------- - exclude : list[str] | None - Attribute/property names to exclude from the output. + Returns a string representation of a pyaml object, + including inherited properties and one level of nested objects. """ - if exclude is None: exclude = [] @@ -34,31 +29,39 @@ def __pyaml_repr__(obj, exclude: list[str] | None = None): ) return repr(cfg).replace("ConfigModel", cls_name, 1) - # Generic fallback when there is no _cfg attrs = {} - # Instance attributes - for k, v in obj.__dict__.items(): - # Exclude private attributes and excluded - if not k.startswith("_") and k not in exclude: - attrs[k] = v + for name in dir(obj): + # Skip private attributes and user-excluded names + if name.startswith("_") or name in exclude: + continue + + try: + value = getattr(obj, name) + + # Skip methods/functions (we only want data) + # This prevents: BPM(get_name=) + if callable(value): + continue - # Properties - for name, attr in vars(type(obj)).items(): - if isinstance(attr, property) and name not in exclude: - try: - attrs[name] = getattr(obj, name) - except Exception as e: - attrs[name] = f"" + attrs[name] = value + except Exception as e: + attrs[name] = f"" + # Special handling for 'name' if it's an Element but not in attrs if isinstance(obj, Element) and "name" not in attrs and "name" not in exclude: try: attrs["name"] = obj.get_name() - except Exception as e: - attrs["name"] = f"" + except Exception: + pass + + # The !r flag ensures that if 'v' is another pyaml object, + # its own __repr__ is called (providing the "one level below" effect). + if not attrs: + return cls_name - parts = ", ".join(f"{k}={v!r}" for k, v in attrs.items()) - return f"{cls_name}({parts})" if parts else cls_name + parts = ", ".join(f"{k}={v!r}" for k, v in sorted(attrs.items())) + return f"{cls_name}({parts})" class ElementConfigModel(BaseModel): From 816ff88deb5f52874eadebefa7055a561c929fc0 Mon Sep 17 00:00:00 2001 From: gubaidulinvadim Date: Mon, 17 Aug 2026 13:23:14 +0200 Subject: [PATCH 2/8] Public _x_pos, _y_pos, _x_offset, _y_offset, _tilt_name --- pyaml/bpm/bpm.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/pyaml/bpm/bpm.py b/pyaml/bpm/bpm.py index ccdbc0c8..7060d152 100644 --- a/pyaml/bpm/bpm.py +++ b/pyaml/bpm/bpm.py @@ -50,11 +50,11 @@ def __init__( tilt: str | None = None, ): super().__init__(name, lattice_names, description) - self._x_pos = x_pos - self._y_pos = y_pos - self._x_offset = x_offset - self._y_offset = y_offset - self._tilt_name = tilt + self.x_pos = x_pos + self.y_pos = y_pos + self.x_offset = x_offset + self.y_offset = y_offset + self.tilt_name = tilt self._positions = None self._offset = None self._tilt = None @@ -160,7 +160,7 @@ def get_pos_devices(self) -> list[str | None]: list[DeviceAccess] Array of DeviceAcess """ - return [self._x_pos, self._y_pos] + return [self.x_pos, self.y_pos] def get_tilt_device(self) -> str | None: """ @@ -171,7 +171,7 @@ def get_tilt_device(self) -> str | None: DeviceAccess DeviceAcess """ - return self._tilt_name + return self.tilt_name def get_offset_devices(self) -> list[str | None]: """ @@ -182,7 +182,7 @@ def get_offset_devices(self) -> list[str | None]: list[DeviceAccess] Array of DeviceAcess """ - return [self._x_offset, self._y_offset] + return [self.x_offset, self.y_offset] def __repr__(self): return __pyaml_repr__(self, exclude=["positions", "offset", "tilt"]) From 426bae2349b879b6313b3647f5a6ac4089f2a37f Mon Sep 17 00:00:00 2001 From: gubaidulinvadim Date: Fri, 4 Sep 2026 16:23:14 +0200 Subject: [PATCH 3/8] Fixed recursive string representation arising from peer. --- pyaml/common/element.py | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/pyaml/common/element.py b/pyaml/common/element.py index 0581b48b..47380812 100644 --- a/pyaml/common/element.py +++ b/pyaml/common/element.py @@ -18,17 +18,6 @@ def __pyaml_repr__(obj, exclude: list[str] | None = None): cls_name = obj.__class__.__name__ - # Keep the old behavior when _cfg exists - cfg = getattr(obj, "_cfg", None) - if cfg is not None: - if isinstance(obj, Element): - return repr(cfg).replace( - "ConfigModel(", - f"{cls_name}(peer={obj.attached_to()!r}, ", - 1, - ) - return repr(cfg).replace("ConfigModel", cls_name, 1) - attrs = {} for name in dir(obj): @@ -44,6 +33,9 @@ def __pyaml_repr__(obj, exclude: list[str] | None = None): if callable(value): continue + if name == "peer": + value = obj.attached_to() if isinstance(obj, Element) else value.__class__.__name__ + attrs[name] = value except Exception as e: attrs[name] = f"" @@ -59,8 +51,8 @@ def __pyaml_repr__(obj, exclude: list[str] | None = None): # its own __repr__ is called (providing the "one level below" effect). if not attrs: return cls_name - - parts = ", ".join(f"{k}={v!r}" for k, v in sorted(attrs.items())) + parts = ", ".join(f"{k}={v!r}" for k, v in sorted(attrs.items())[:10]) + # Limit to 10 attributes to avoid overly long representations return f"{cls_name}({parts})" From 86746bf6d9f31cadf6c8aa1affc8ad21748a0b90 Mon Sep 17 00:00:00 2001 From: gubaidulinvadim Date: Fri, 4 Sep 2026 16:26:24 +0200 Subject: [PATCH 4/8] Added test to check that __pyaml_repr__ call is not recursive --- tests/test_accelerator_load.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/test_accelerator_load.py b/tests/test_accelerator_load.py index 326f0a33..966f7e20 100644 --- a/tests/test_accelerator_load.py +++ b/tests/test_accelerator_load.py @@ -17,6 +17,13 @@ def test_peer(): assert isinstance(tm.peer, ElementHolder) +def test_repr_does_not_recurse_through_peer(): + sr = Accelerator.load("tests/config/tune_monitor.yaml") + tm = sr.design.get_betatron_tune_monitor("BETATRON_TUNE") + + assert "peer='Simulator:design'" in repr(tm) + + def test_accelerator_load_rejects_non_accelerator_root(tmp_path): config_file = tmp_path / "quadrupole.yaml" config_file.write_text("type: pyaml.magnet.quadrupole\nname: QF1A-C01\n", encoding="utf-8") From 4c62ac67968db8456db9bbfac0ebcea5d7f0b375 Mon Sep 17 00:00:00 2001 From: gubaidulinvadim Date: Sat, 5 Sep 2026 15:29:42 +0200 Subject: [PATCH 5/8] Added __pyaml_repr__ for holders --- pyaml/common/holders/generic_array_holder.py | 5 +++- .../common/holders/generic_element_holder.py | 5 +++- pyaml/common/holders/rf_holder.py | 7 ++++++ pyaml/magnet/identity_model.py | 24 +++++++++---------- 4 files changed, 27 insertions(+), 14 deletions(-) diff --git a/pyaml/common/holders/generic_array_holder.py b/pyaml/common/holders/generic_array_holder.py index 1e9fa399..53e29408 100644 --- a/pyaml/common/holders/generic_array_holder.py +++ b/pyaml/common/holders/generic_array_holder.py @@ -1,7 +1,7 @@ from collections.abc import Callable from typing import TYPE_CHECKING, Generic, TypeVar -from ..element import Element +from ..element import Element, __pyaml_repr__ if TYPE_CHECKING: from .element_holder import ElementHolder @@ -65,3 +65,6 @@ def add(self, arrayName: str, elementNames: list[str]): def __getitem__(self, key): return self.get().__getitem__(key) + + def __repr__(self): + return __pyaml_repr__(self) diff --git a/pyaml/common/holders/generic_element_holder.py b/pyaml/common/holders/generic_element_holder.py index a21bda13..e8a66d76 100644 --- a/pyaml/common/holders/generic_element_holder.py +++ b/pyaml/common/holders/generic_element_holder.py @@ -1,6 +1,6 @@ from typing import TYPE_CHECKING, Generic, TypeVar -from ..element import Element +from ..element import Element, __pyaml_repr__ if TYPE_CHECKING: from .element_holder import ElementHolder @@ -50,3 +50,6 @@ def add(self, m: T): Element to be added """ self._peer._add(self._store, m) + + def __repr__(self): + return __pyaml_repr__(self) diff --git a/pyaml/common/holders/rf_holder.py b/pyaml/common/holders/rf_holder.py index 31644a8f..d9441bcc 100644 --- a/pyaml/common/holders/rf_holder.py +++ b/pyaml/common/holders/rf_holder.py @@ -3,6 +3,7 @@ from ...rf.rf_plant import RFPlant from ...rf.rf_transmitter import RFTransmitter from ..abstract import ReadWriteFloatScalar +from ..element import __pyaml_repr__ if TYPE_CHECKING: from .element_holder import ElementHolder @@ -18,6 +19,9 @@ def get(self, name: str) -> RFTransmitter: def add(self, rf: RFTransmitter): self._peer._add(self._peer._RFTRANSMITTER, rf) + def __repr__(self): + return __pyaml_repr__(self) + class RFHolder: """ @@ -75,3 +79,6 @@ def add(self, rf: RFPlant): RF Plant to be added """ self._peer._add(self._peer._RFPLANT, rf) + + def __repr__(self): + return __pyaml_repr__(self) diff --git a/pyaml/magnet/identity_model.py b/pyaml/magnet/identity_model.py index 64270f67..9cdb5357 100644 --- a/pyaml/magnet/identity_model.py +++ b/pyaml/magnet/identity_model.py @@ -44,20 +44,20 @@ def __init__( physics: str | None = None, unit: str | None = None, ): - self._physics = physics - self._powerconverter = powerconverter - self._unit = unit + self.physics = physics + self.powerconverter = powerconverter + self.unit = unit - if self._physics is None and self._powerconverter is None: + if self.physics is None and self.powerconverter is None: raise PyAMLException("Invalid IdentityMagnetModel configuration,physics or powerconverter device required") - if self._physics is not None and self._powerconverter is not None: + if self.physics is not None and self.powerconverter is not None: raise PyAMLException( "Invalid IdentityMagnetModel configuration,physics or powerconverter device required but not both" ) - if self._physics: - self.__device = self._physics + if self.physics: + self.__device = self.physics else: - self.__device = self._powerconverter + self.__device = self.powerconverter def compute_hardware_values(self, strengths: np.array) -> np.array: return strengths @@ -66,10 +66,10 @@ def compute_strengths(self, currents: np.array) -> np.array: return currents def get_strength_units(self) -> list[str]: - return [self._unit] + return [self.unit] def get_hardware_units(self) -> list[str]: - return [self._unit] + return [self.unit] def get_device_names(self) -> list[str | None]: return [self.__device] @@ -78,10 +78,10 @@ def set_magnet_rigidity(self, brho: np.double): pass def has_physics(self) -> bool: - return self._physics is not None + return self.physics is not None def has_hardware(self) -> bool: - return self._powerconverter is not None + return self.powerconverter is not None def __repr__(self): return __pyaml_repr__(self) From d80facbcf86bd351db00f3dbf44224ebd1cceaba Mon Sep 17 00:00:00 2001 From: gubaidulinvadim Date: Thu, 10 Sep 2026 19:42:46 +0200 Subject: [PATCH 6/8] =?UTF-8?q?=20Added=20shared=20configurable=20repr=20l?= =?UTF-8?q?imits=20via=20=C2=A0pyaml.set=5Frepr=5Foptions()=C2=A0=20for=20?= =?UTF-8?q?item=20count,=20nesting=20depth,=20and=20total=20length.=20-=20?= =?UTF-8?q?Repr=20now=20avoids=20recursive=20peer=20expansion=20and=20cont?= =?UTF-8?q?rol-system=20handle=20output;=20arrays=20summarize=20element=20?= =?UTF-8?q?names=20with=20an=20omission=20marker.=20-=20Added=20compact=20?= =?UTF-8?q?summaries=20for=20=C2=A0Accelerator=C2=A0,=20=C2=A0Simulator?= =?UTF-8?q?=C2=A0,=20and=20element=20arrays;=20BPM=20device-name=20attribu?= =?UTF-8?q?tes=20remain=20public=20but=20are=20excluded=20from=20its=20rep?= =?UTF-8?q?r.=20-=20Updated=20magnet=20rendering=20and=20regression=20cove?= =?UTF-8?q?rage=20for=20bounded=20outputs=20and=20custom=20limits.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyaml/__init__.py | 3 +- pyaml/accelerator.py | 10 ++ pyaml/arrays/element_array.py | 13 +- pyaml/bpm/bpm.py | 2 + pyaml/common/element.py | 226 ++++++++++++++++++++++++++++----- pyaml/lattice/simulator.py | 10 ++ pyaml/magnet/magnet.py | 15 +-- tests/test_accelerator_load.py | 36 +++++- 8 files changed, 265 insertions(+), 50 deletions(-) diff --git a/pyaml/__init__.py b/pyaml/__init__.py index 65050bc0..0bb1c8b0 100644 --- a/pyaml/__init__.py +++ b/pyaml/__init__.py @@ -16,9 +16,10 @@ import logging.config import os +from pyaml.common.element import ReprOptions, set_repr_options from pyaml.common.exception import PyAMLConfigException, PyAMLException -__all__ = ["PyAMLException", "PyAMLConfigException"] +__all__ = ["PyAMLException", "PyAMLConfigException", "ReprOptions", "set_repr_options"] config_file = os.getenv("PYAML_LOG_CONFIG", "pyaml_logging.conf") diff --git a/pyaml/accelerator.py b/pyaml/accelerator.py index bfc979ed..49d18b17 100644 --- a/pyaml/accelerator.py +++ b/pyaml/accelerator.py @@ -61,6 +61,8 @@ class Accelerator: :attr:`live` and :attr:`design` properties. """ + __pyaml_repr_exclude__ = ("description", "yellow_pages") + def __init__( self, facility: str, @@ -267,6 +269,14 @@ def modes(self) -> dict[str, "ElementHolder"]: modes.update(self._controls) return modes + def _pyaml_repr_fields(self) -> dict[str, object]: + return { + "facility": self.facility, + "machine": self.machine, + "simulators": list(self._simulators), + "controls": list(self._controls), + } + def __repr__(self): return __pyaml_repr__(self) diff --git a/pyaml/arrays/element_array.py b/pyaml/arrays/element_array.py index 942ecd85..097c92f5 100644 --- a/pyaml/arrays/element_array.py +++ b/pyaml/arrays/element_array.py @@ -5,7 +5,7 @@ import numpy as np from ..bpm.bpm import BPM -from ..common.element import Element +from ..common.element import Element, __pyaml_repr__ from ..common.exception import PyAMLException from ..magnet.cfm_magnet import CombinedFunctionMagnet from ..magnet.magnet import Magnet @@ -76,6 +76,17 @@ def names(self) -> list[str]: """ return [e.get_name() for e in self] + def _pyaml_repr_fields(self) -> dict[str, object]: + return { + "name": self.get_name(), + "size": len(self), + "peer": self.get_peer(), + "elements": self.names(), + } + + def __repr__(self): + return __pyaml_repr__(self) + def __create_array(self, array_name: str, element_type: type, elements: list): if element_type is None: element_type = Element diff --git a/pyaml/bpm/bpm.py b/pyaml/bpm/bpm.py index 7060d152..b6688635 100644 --- a/pyaml/bpm/bpm.py +++ b/pyaml/bpm/bpm.py @@ -38,6 +38,8 @@ class BPM(Element, DynamicValidation): Device catalog key for the BPM tilt. """ + __pyaml_repr_exclude__ = ("x_pos", "y_pos", "x_offset", "y_offset", "tilt_name") + def __init__( self, name: str, diff --git a/pyaml/common/element.py b/pyaml/common/element.py index 47380812..c3f388e5 100644 --- a/pyaml/common/element.py +++ b/pyaml/common/element.py @@ -1,59 +1,215 @@ +from collections.abc import Mapping, Sequence +from dataclasses import dataclass from typing import TYPE_CHECKING, Any from pydantic import BaseModel, ConfigDict +from . import abstract from .exception import PyAMLException if TYPE_CHECKING: from .holders.element_holder import ElementHolder -def __pyaml_repr__(obj, exclude: list[str] | None = None): +@dataclass(frozen=True) +class ReprOptions: + """Limits applied to PyAML object representations.""" + + max_items: int = 3 + max_depth: int = 2 + max_length: int = 800 + + +_repr_options = ReprOptions() + + +def set_repr_options( + max_items: int | None = None, + max_depth: int | None = None, + max_length: int | None = None, +) -> ReprOptions: """ - Returns a string representation of a pyaml object, - including inherited properties and one level of nested objects. + Configure the limits used by PyAML object representations. + + Passing no arguments returns the current options. Every supplied value must + be a positive integer. """ - if exclude is None: - exclude = [] + global _repr_options - cls_name = obj.__class__.__name__ + values = { + "max_items": _repr_options.max_items if max_items is None else max_items, + "max_depth": _repr_options.max_depth if max_depth is None else max_depth, + "max_length": _repr_options.max_length if max_length is None else max_length, + } + for name, value in values.items(): + if not isinstance(value, int) or value < 1: + raise ValueError(f"{name} must be a positive integer") - attrs = {} + _repr_options = ReprOptions(**values) + return _repr_options - for name in dir(obj): - # Skip private attributes and user-excluded names - if name.startswith("_") or name in exclude: - continue - try: - value = getattr(obj, name) +def _unavailable(error: Exception) -> str: + return f"" - # Skip methods/functions (we only want data) - # This prevents: BPM(get_name=) - if callable(value): - continue - if name == "peer": - value = obj.attached_to() if isinstance(obj, Element) else value.__class__.__name__ +def _class_exclusions(obj) -> set[str]: + exclusions: set[str] = set() + for cls in type(obj).__mro__: + exclusions.update(getattr(cls, "__pyaml_repr_exclude__", ())) + return exclusions + + +def _properties(obj) -> dict[str, Any]: + values: dict[str, Any] = {} + for cls in reversed(type(obj).__mro__): + for name, descriptor in vars(cls).items(): + if name.startswith("_") or not isinstance(descriptor, property): + continue + try: + values[name] = getattr(obj, name) + except Exception as error: + values[name] = _unavailable(error) + return values - attrs[name] = value - except Exception as e: - attrs[name] = f"" - # Special handling for 'name' if it's an Element but not in attrs - if isinstance(obj, Element) and "name" not in attrs and "name" not in exclude: +def _fields(obj) -> dict[str, Any]: + custom_fields = getattr(obj, "_pyaml_repr_fields", None) + if custom_fields is not None: try: - attrs["name"] = obj.get_name() - except Exception: - pass + return custom_fields() + except Exception as error: + return {"value": _unavailable(error)} + + values = _properties(obj) + for name, value in vars(obj).items(): + if not name.startswith("_"): + values.setdefault(name, value) + return values + + +def _identity(obj) -> str: + name = getattr(obj, "name", None) + try: + name = name() if callable(name) else name + except Exception: + name = None + if isinstance(name, str): + return f"{obj.__class__.__name__}:{name}" + return obj.__class__.__name__ + + +def _short_object_repr(obj) -> str: + name = getattr(obj, "name", None) + try: + name = name() if callable(name) else name + except Exception: + name = None + if isinstance(name, str): + return f"{obj.__class__.__name__}(name={_format_value(name, 0, set())})" + return obj.__class__.__name__ + + +def _selected_items(values: Sequence | set) -> tuple[list[Any], int]: + items = list(values) + omitted = len(items) - _repr_options.max_items + if omitted <= 0: + return items, 0 + + head_count = (_repr_options.max_items + 1) // 2 + tail_count = _repr_options.max_items - head_count + selected = items[:head_count] + if tail_count: + selected.extend(items[-tail_count:]) + return selected, omitted + + +def _format_sequence(values: Sequence | set, depth: int, active: set[int]) -> str: + selected, omitted = _selected_items(values) + parts = [_format_value(value, depth, active) for value in selected] + if omitted: + insert_at = (_repr_options.max_items + 1) // 2 + parts.insert(insert_at, f"... +{omitted} more ...") + + if isinstance(values, tuple): + if len(parts) == 1 and not omitted: + return f"({parts[0]},)" + return f"({', '.join(parts)})" + if isinstance(values, set): + return "{" + ", ".join(parts) + "}" + return "[" + ", ".join(parts) + "]" + + +def _format_mapping(values: Mapping, depth: int, active: set[int]) -> str: + selected, omitted = _selected_items(list(values.items())) + parts = [f"{_format_value(key, depth, active)}: {_format_value(value, depth, active)}" for key, value in selected] + if omitted: + parts.insert((_repr_options.max_items + 1) // 2, f"... +{omitted} more ...") + return "{" + ", ".join(parts) + "}" + + +def _format_object(obj, depth: int, active: set[int], extra_exclusions: set[str] | None = None) -> str: + if depth >= _repr_options.max_depth: + return _short_object_repr(obj) + if id(obj) in active: + return f"" + + active.add(id(obj)) + try: + values = _fields(obj) + exclusions = _class_exclusions(obj) + if extra_exclusions: + exclusions.update(extra_exclusions) + parts = [] + for name, value in values.items(): + if name.startswith("_") or name in exclusions or callable(value): + continue + if isinstance(value, (abstract.ReadFloatScalar, abstract.ReadFloatArray, abstract.ReadWriteFloatArray)): + continue + formatted = _identity(value) if name == "peer" and value is not None else _format_value(value, depth + 1, active) + parts.append(f"{name}={formatted}") + return f"{obj.__class__.__name__}({', '.join(parts)})" if parts else obj.__class__.__name__ + except Exception as error: + return f"{obj.__class__.__name__}({_unavailable(error)})" + finally: + active.remove(id(obj)) + + +def _format_value(value, depth: int, active: set[int]) -> str: + if isinstance(value, str): + limit = max(1, _repr_options.max_length // 4) + suffix = "..." if len(value) > limit else "" + return repr(value[:limit] + suffix) + if value is None or isinstance(value, (bool, int, float, complex)): + return repr(value) + if isinstance(value, Mapping): + return _format_mapping(value, depth, active) + if isinstance(value, (list, tuple, set, frozenset)): + return _format_sequence(value, depth, active) + if type(value).__module__.startswith("numpy"): + shape = getattr(value, "shape", None) + dtype = getattr(value, "dtype", None) + return f"{value.__class__.__name__}(shape={shape!r}, dtype={dtype!r})" + if type(value).__module__.startswith("pyaml"): + return _format_object(value, depth, active) + try: + result = repr(value) + except Exception as error: + return _unavailable(error) + limit = max(1, _repr_options.max_length // 2) + return result if len(result) <= limit else result[:limit] + "..." + - # The !r flag ensures that if 'v' is another pyaml object, - # its own __repr__ is called (providing the "one level below" effect). - if not attrs: - return cls_name - parts = ", ".join(f"{k}={v!r}" for k, v in sorted(attrs.items())[:10]) - # Limit to 10 attributes to avoid overly long representations - return f"{cls_name}({parts})" +def __pyaml_repr__(obj, exclude: list[str] | None = None): + """ + Return an informative, bounded representation of a PyAML object. + + Public attributes and read-only properties are included unless they are + excluded. Device accessors are omitted so rendering never reads a control + system value. + """ + result = _format_object(obj, 0, set(), set(exclude or ())) + return result[: _repr_options.max_length] + ("..." if len(result) > _repr_options.max_length else "") class ElementConfigModel(BaseModel): @@ -89,6 +245,8 @@ class Element: Class providing access to one element of a physical or simulated lattice """ + __pyaml_repr_exclude__ = ("description",) + def __init__( self, name: str, diff --git a/pyaml/lattice/simulator.py b/pyaml/lattice/simulator.py index c38f4280..eb6a4114 100644 --- a/pyaml/lattice/simulator.py +++ b/pyaml/lattice/simulator.py @@ -57,6 +57,8 @@ class Simulator(ElementHolder, DynamicValidation): or a custom :class:`LatticeElementsLinker`. """ + __pyaml_repr_exclude__ = ("description", "ring") + def __init__( self, name: str, @@ -357,5 +359,13 @@ def get_at_elems(self, element: Element) -> list[at.Element]: else: return [elts[idx] for idx in indices] + def _pyaml_repr_fields(self) -> dict[str, object]: + return { + "name": self.name(), + "lattice": self.lattice, + "mat_key": self.mat_key, + "n_elements": len(self.ring), + } + def __repr__(self): return __pyaml_repr__(self) diff --git a/pyaml/magnet/magnet.py b/pyaml/magnet/magnet.py index ae22deb1..320ae772 100644 --- a/pyaml/magnet/magnet.py +++ b/pyaml/magnet/magnet.py @@ -6,7 +6,7 @@ from .. import PyAMLException from ..common import abstract -from ..common.element import Element +from ..common.element import Element, __pyaml_repr__ from .model import MagnetModel @@ -100,11 +100,10 @@ def get_model_name(self) -> str: """ return self.__modelName + @property + def model_name(self) -> str: + """Name used to identify this magnet in its model.""" + return self.__modelName + def __repr__(self): - return "%s(peer='%s', name='%s', model_name='%s', magnet_model=%s)" % ( - self.__class__.__name__, - self.attached_to(), - self.get_name(), - self.__modelName, - repr(self.__model), - ) + return __pyaml_repr__(self, exclude=["strength", "hardware"]) diff --git a/tests/test_accelerator_load.py b/tests/test_accelerator_load.py index 966f7e20..65ce066f 100644 --- a/tests/test_accelerator_load.py +++ b/tests/test_accelerator_load.py @@ -1,7 +1,7 @@ import pytest from pydantic import BaseModel, ConfigDict -from pyaml import PyAMLConfigException +from pyaml import PyAMLConfigException, set_repr_options from pyaml.accelerator import Accelerator, ElementHolder from pyaml.common.element import Element, ElementConfigModel, __pyaml_repr__ from pyaml.control.controlsystem import ControlSystemAdapter @@ -17,11 +17,35 @@ def test_peer(): assert isinstance(tm.peer, ElementHolder) -def test_repr_does_not_recurse_through_peer(): - sr = Accelerator.load("tests/config/tune_monitor.yaml") - tm = sr.design.get_betatron_tune_monitor("BETATRON_TUNE") - - assert "peer='Simulator:design'" in repr(tm) +def test_repr_is_informative_and_bounded(): + sr = Accelerator.load("tests/config/EBSOrbit.yaml") + bpm = sr.design.bpm.get("BPM_C04-04") + bpms = sr.design.bpms.get("BPM") + + assert repr(bpm) == "BPM(name='BPM_C04-04', lattice_names='BPM_C04-04', peer=Simulator:design)" + assert repr(sr.design) == ( + f"Simulator(name='design', lattice={sr.design.lattice!r}, mat_key=None, n_elements={len(sr.design.ring)})" + ) + assert repr(sr) == "Accelerator(facility='ESRF', machine='sr', simulators=['design'], controls=['live'])" + assert len(repr(bpms)) < 250 + assert f"size={len(bpms)}" in repr(bpms) + assert f"... +{len(bpms) - 3} more ..." in repr(bpms) + + +def test_repr_options_limit_sequences(): + original = set_repr_options() + try: + set_repr_options(max_items=1) + sr = Accelerator.load("tests/config/EBSOrbit.yaml") + + bpms = sr.design.bpms.get("BPM") + assert f"... +{len(bpms) - 1} more ..." in repr(bpms) + finally: + set_repr_options( + max_items=original.max_items, + max_depth=original.max_depth, + max_length=original.max_length, + ) def test_accelerator_load_rejects_non_accelerator_root(tmp_path): From 1a8b80dc6e9611a32c7a0f045b934d97ebdf7c45 Mon Sep 17 00:00:00 2001 From: gubaidulinvadim Date: Thu, 10 Sep 2026 19:56:17 +0200 Subject: [PATCH 7/8] Corrected BPM output --- pyaml/bpm/bpm.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/pyaml/bpm/bpm.py b/pyaml/bpm/bpm.py index 91020c39..dd7015c2 100644 --- a/pyaml/bpm/bpm.py +++ b/pyaml/bpm/bpm.py @@ -63,8 +63,6 @@ class BPM(Element, DynamicValidation): Return configured device keys used for offset control. """ - __pyaml_repr_exclude__ = ("x_pos", "y_pos", "x_offset", "y_offset", "tilt_name") - def __init__( self, name: str, From 97c7f449b0e313c021383f3d1879a5a5b0d48adc Mon Sep 17 00:00:00 2001 From: gubaidulinvadim Date: Thu, 10 Sep 2026 22:05:24 +0200 Subject: [PATCH 8/8] Corrected expected BPM string repr in tests --- tests/test_accelerator_load.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/test_accelerator_load.py b/tests/test_accelerator_load.py index 65ce066f..f30a68e3 100644 --- a/tests/test_accelerator_load.py +++ b/tests/test_accelerator_load.py @@ -22,7 +22,15 @@ def test_repr_is_informative_and_bounded(): bpm = sr.design.bpm.get("BPM_C04-04") bpms = sr.design.bpms.get("BPM") - assert repr(bpm) == "BPM(name='BPM_C04-04', lattice_names='BPM_C04-04', peer=Simulator:design)" + assert repr(bpm) == ( + "BPM(name='BPM_C04-04', lattice_names='BPM_C04-04', " + + "peer=Simulator:design, x_pos='srdiag/bpm/c04-04/SA_HPosition', " + + "y_pos='srdiag/bpm/c04-04/SA_VPosition', " + + "x_offset='srdiag/bpm/c04-04/HOffset', " + + "y_offset='srdiag/bpm/c04-04/VOffset', " + + "tilt_name=None)" + ) + assert repr(sr.design) == ( f"Simulator(name='design', lattice={sr.design.lattice!r}, mat_key=None, n_elements={len(sr.design.ring)})" )