From 63cfd095e849266c0acb4331c7a86ba74ec41172 Mon Sep 17 00:00:00 2001 From: guillaumepichon Date: Wed, 4 Mar 2026 17:08:26 +0100 Subject: [PATCH 01/11] First implementation of yellow pages. The type of elements should be enhanced. --- pyaml/accelerator.py | 31 +- pyaml/common/element_holder.py | 60 ++++ pyaml/yellow_pages.py | 548 +++++++++++++++++++++++++++++++ tests/test_yellow_pages.py | 289 ++++++++++++++++ tests/test_yellow_pages_basic.py | 38 +++ 5 files changed, 964 insertions(+), 2 deletions(-) create mode 100644 pyaml/yellow_pages.py create mode 100644 tests/test_yellow_pages.py create mode 100644 tests/test_yellow_pages_basic.py diff --git a/pyaml/accelerator.py b/pyaml/accelerator.py index 09bf35f9..f84c70c4 100644 --- a/pyaml/accelerator.py +++ b/pyaml/accelerator.py @@ -8,11 +8,13 @@ from .arrays.array import ArrayConfig from .common.element import Element +from .common.element_holder import ElementHolder from .common.exception import PyAMLConfigException from .configuration.factory import Factory from .configuration.fileloader import load, set_root_folder from .control.controlsystem import ControlSystem from .lattice.simulator import Simulator +from .yellow_pages import YellowPages # Define the main class name for this module PYAMLCLASS = "Accelerator" @@ -66,24 +68,28 @@ def __init__(self, cfg: ConfigModel): self._cfg = cfg __design = None __live = None + self._controls: dict[str, ElementHolder] = {} + self._simulators: dict[str, ElementHolder] = {} if cfg.controls is not None: for c in cfg.controls: if c.name() == "live": self.__live = c else: - # Add as dynacmic attribute + # Add as dynamic attribute setattr(self, c.name(), c) c.fill_device(cfg.devices) + self._controls[c.name()] = c if cfg.simulators is not None: for s in cfg.simulators: if s.name() == "design": self.__design = s else: - # Add as dynacmic attribute + # Add as dynamic attribute setattr(self, s.name(), s) s.fill_device(cfg.devices) + self._simulators[s.name()] = s if cfg.arrays is not None: for a in cfg.arrays: @@ -97,6 +103,8 @@ def __init__(self, cfg: ConfigModel): if cfg.energy is not None: self.set_energy(cfg.energy) + self._yellow_pages = YellowPages(self) + self.post_init() def set_energy(self, E: float): @@ -156,6 +164,25 @@ def design(self) -> Simulator: """ return self.__design + @property + def yellow_pages(self) -> YellowPages: + return self._yellow_pages + + def simulators(self) -> dict[str, "ElementHolder"]: + """Return all registered control/simulator modes.""" + return self._simulators + + def controls(self) -> dict[str, "ElementHolder"]: + """Return all registered control/simulator modes.""" + return self._controls + + def modes(self) -> dict[str, "ElementHolder"]: + """Return all registered control/simulator modes.""" + modes: dict[str, "ElementHolder"] = {} + modes.update(self._simulators) + modes.update(self._controls) + return self._controls + def __repr__(self): return repr(self._cfg).replace("ConfigModel", self.__class__.__name__) diff --git a/pyaml/common/element_holder.py b/pyaml/common/element_holder.py index 49a5811d..1d48749c 100644 --- a/pyaml/common/element_holder.py +++ b/pyaml/common/element_holder.py @@ -274,3 +274,63 @@ def add_dispersion_tuning(self, dispersion: Element): @property def dispersion(self) -> "Dispersion": return self.get_dispersion_tuning("DEFAULT_DISPERSION") + + def get_array(self, name: str): + """ + Generic array resolver used by YellowPages. + + The method returns the array object referenced by 'name', regardless of its + concrete type. + """ + if name in self.__BPM_ARRAYS: + return self.__BPM_ARRAYS[name] + if name in self.__MAGNET_ARRAYS: + return self.__MAGNET_ARRAYS[name] + if name in self.__CFM_MAGNET_ARRAYS: + return self.__CFM_MAGNET_ARRAYS[name] + if name in self.__SERIALIZED_MAGNETS_ARRAYS: + return self.__SERIALIZED_MAGNETS_ARRAYS[name] + if name in self.__ELEMENT_ARRAYS: + return self.__ELEMENT_ARRAYS[name] + + raise PyAMLException(f"Array {name} not defined") + + def get_tool(self, name: str): + """ + Generic tuning tool resolver used by YellowPages. + """ + if name not in self.__TUNING_TOOLS: + raise PyAMLException(f"Tool {name} not defined") + return self.__TUNING_TOOLS[name] + + def get_diagnostic(self, name: str): + """ + Generic diagnostic resolver used by YellowPages. + """ + if name not in self.__DIAG: + raise PyAMLException(f"Diagnostic {name} not defined") + return self.__DIAG[name] + + def list_arrays(self) -> set[str]: + """ + Return all array identifiers available in this holder. + """ + return ( + set(self.__BPM_ARRAYS.keys()) + | set(self.__MAGNET_ARRAYS.keys()) + | set(self.__CFM_MAGNET_ARRAYS.keys()) + | set(self.__SERIALIZED_MAGNETS_ARRAYS.keys()) + | set(self.__ELEMENT_ARRAYS.keys()) + ) + + def list_tools(self) -> set[str]: + """ + Return all tuning tool identifiers available in this holder. + """ + return set(self.__TUNING_TOOLS.keys()) + + def list_diagnostics(self) -> set[str]: + """ + Return all diagnostic identifiers available in this holder. + """ + return set(self.__DIAG.keys()) diff --git a/pyaml/yellow_pages.py b/pyaml/yellow_pages.py new file mode 100644 index 00000000..c1e7b393 --- /dev/null +++ b/pyaml/yellow_pages.py @@ -0,0 +1,548 @@ +""" +yellow_pages.py + +Fully dynamic YellowPages service attached to Accelerator. + +Key points: +- Auto-discovery ONLY: arrays/tools/diagnostics are discovered at runtime by scanning + all modes. +- No caching: every call reflects current runtime state. + +Expected Accelerator interface: +- controls() -> dict[str, ElementHolder] +- simulators() -> dict[str, ElementHolder] +- modes() -> dict[str, ElementHolder] (union of controls + simulators) + +Expected ElementHolder interface: +- list_arrays() -> set[str] +- list_tools() -> set[str] +- list_diagnostics() -> set[str] +- get_array(name: str) -> Any +- get_tool(name: str) -> Any +- get_diagnostic(name: str) -> Any + +Query language via __getitem__: +- Operands: + - KEY: discovered identifier (e.g. BPM, HCORR, VCORR) + - Regex: use re{ ... } (recommended) or re:... (legacy simple form) + +- Operators: + - Union: | + - Intersection: & + - Difference: - + - Parentheses: ( ) + +Regex grammar: +- Preferred form: re{} + - Allows '-', parentheses, spaces, etc. + - Escape '}' as '\\}' inside the regex. + +Examples +-------- +>>> yp["BPM"] +>>> yp["HCORR|VCORR"] +>>> yp["BPM - re{BPM_C01-01}"] +>>> yp["re{^BPM_C..-..$}"] +>>> yp["(HCORR|VCORR) - re{CH_.*}"] + +Notes +----- +- KEY operands in queries are treated as arrays and converted to IDs. +- Regex operands filter over ALL known IDs gathered from all discovered arrays across + all modes. +""" + +import re +from enum import Enum +from typing import Any + +from pyaml import PyAMLException + + +class YellowPagesCategory(str, Enum): + ARRAYS = "Arrays" + TOOLS = "Tools" + DIAGNOSTICS = "Diagnostics" + + +class YellowPagesError(PyAMLException): + """YellowPages-specific error with clear user-facing messages.""" + + +class YellowPagesQueryError(YellowPagesError): + """Raised when a YellowPages query string cannot be parsed/evaluated.""" + + +_VALID_KEY_RE = re.compile(r"^[A-Z0-9_]+$") + + +# --------------------------- +# Query parsing helpers +# --------------------------- + +# Regex operand supports: +# - re{...} where ... may include operators chars like '-', '|', '&', parentheses, +# spaces, etc. +# '}' can be escaped as '\}' inside. +# +# We match: re{ (?: \\. | [^}] )* } +# meaning: either an escaped char (e.g. '\}') or any char except '}'. +_TOKEN_RE = re.compile( + r""" + \s*( + re\{(?:\\.|[^}])*\} | # regex token with braces, supports escaped chars + re:[^\s\|\&\-\(\)]+ | # legacy regex token (no spaces/operators/parens) + [A-Z0-9_]+ | # identifier token (YellowPages key) + \|\|? | # '|' or '||' + \&\&? | # '&' or '&&' + \- | # difference + \(|\) # parentheses + )\s* + """, + re.VERBOSE, +) + + +def _tokenize(expr: str) -> list[str]: + if not expr or not expr.strip(): + raise YellowPagesQueryError("Empty YellowPages query.") + + pos = 0 + tokens: list[str] = [] + while pos < len(expr): + m = _TOKEN_RE.match(expr, pos) + if not m: + snippet = expr[pos : min(len(expr), pos + 32)] + raise YellowPagesQueryError(f"Cannot tokenize near: '{snippet}'") + tok = m.group(1) + if tok == "||": + tok = "|" + if tok == "&&": + tok = "&" + tokens.append(tok) + pos = m.end() + return tokens + + +def _to_rpn(tokens: list[str]) -> list[str]: + """ + Shunting-yard to RPN. + + Precedence: + - '&' and '-' higher than '|' + - all left-associative + """ + prec = {"|": 1, "&": 2, "-": 2} + output: list[str] = [] + stack: list[str] = [] + + for tok in tokens: + if tok == "(": + stack.append(tok) + elif tok == ")": + while stack and stack[-1] != "(": + output.append(stack.pop()) + if not stack or stack[-1] != "(": + raise YellowPagesQueryError("Mismatched parentheses.") + stack.pop() + elif tok in prec: + while stack and stack[-1] in prec and prec[stack[-1]] >= prec[tok]: + output.append(stack.pop()) + stack.append(tok) + else: + output.append(tok) + + while stack: + if stack[-1] in ("(", ")"): + raise YellowPagesQueryError("Mismatched parentheses.") + output.append(stack.pop()) + + return output + + +def _extract_regex(tok: str) -> str: + """ + Extract regex pattern from a regex token. + + Supported: + - re{...} (preferred) + - re:... (legacy) + """ + if tok.startswith("re{") and tok.endswith("}"): + inner = tok[3:-1] + # Interpret escaped sequences (e.g. '\}' -> '}') + # Keep it simple: only unescape '\}' and '\\' + inner = inner.replace(r"\}", "}").replace(r"\\", "\\") + return inner + + if tok.startswith("re:"): + return tok[3:] + + raise YellowPagesQueryError(f"Invalid regex token '{tok}'") + + +# --------------------------- +# YellowPages service (full dynamic) +# --------------------------- + + +class YellowPages: + """ + Fully dynamic YellowPages service attached to Accelerator. + + Discovery: + - keys/categories are derived by scanning all modes at runtime. + + Resolution: + - get(key, mode="...") resolves in a specific mode. + - get(key) returns dict[mode_name, obj] for all modes where available. + + Query language (arrays/IDs): + - yp["BPM"] + - yp["HCORR|VCORR"] + - yp["BPM - re{BPM_C01-01}"] + """ + + def __init__(self, accelerator: Any): + self._acc = accelerator + + # --------------------------- + # Discovery API + # --------------------------- + + def has(self, key: str) -> bool: + return key in self._all_keys() + + def categories(self) -> list[str]: + discovered = self._discover() + present = {cat for cat, keys in discovered.items() if keys} + return [c.value for c in YellowPagesCategory if c in present] + + def keys(self, category: str | YellowPagesCategory | None = None) -> list[str]: + discovered = self._discover() + + if category is None: + return sorted(self._all_keys()) + + cat = YellowPagesCategory(category) + return sorted(discovered.get(cat, set())) + + # --------------------------- + # Bonus: REPL-friendly exploration + # --------------------------- + + def __dir__(self): + default = super().__dir__() + return sorted( + set(default) | {k for k in self._all_keys() if _VALID_KEY_RE.match(k)} + ) + + def __getattr__(self, name): + if name in self._all_keys(): + return self.get(name) + raise AttributeError(f"'YellowPages' object has no attribute '{name}'") + + # --------------------------- + # Resolution API + # --------------------------- + + def availability(self, key: str) -> set[str]: + self._require_key(key) + avail: set[str] = set() + for mode_name, holder in self._acc.modes().items(): + if self._try_resolve_in_holder(key, holder) is not None: + avail.add(mode_name) + return avail + + def get(self, key: str, *, mode: str | None = None): + self._require_key(key) + + if mode is not None: + holder = self._acc.modes().get(mode) + if holder is None: + raise YellowPagesError(f"Unknown mode '{mode}'.") + obj = self._try_resolve_in_holder(key, holder) + if obj is None: + raise YellowPagesError( + f"YellowPages key '{key}' not available in mode '{mode}'." + ) + return obj + + out: dict[str, Any] = {} + for mode_name, holder in self._acc.modes().items(): + obj = self._try_resolve_in_holder(key, holder) + if obj is not None: + out[mode_name] = obj + return out + + # --------------------------- + # Query language: yp["..."] (arrays/IDs only) + # --------------------------- + + def __getitem__(self, query: str) -> list[str]: + ids = self._eval_query_to_ids(query) + return sorted(ids) + + # --------------------------- + # Printing / introspection + # --------------------------- + + def __repr__(self) -> str: + lines: list[str] = [] + + lines.append("Controls:") + controls = self._acc.controls() + if controls: + for name in sorted(controls.keys()): + lines.append(f" {name}") + lines.append(" .") + lines.append("") + + lines.append("Simulators:") + simulators = self._acc.simulators() + if simulators: + for name in sorted(simulators.keys()): + lines.append(f" {name}") + lines.append(" .") + lines.append("") + + discovered = self._discover() + for cat in YellowPagesCategory: + keys = discovered.get(cat, set()) + if not keys: + continue + + lines.append(f"{cat.value}:") + for key in sorted(keys): + lines.append(self._format_key(cat, key)) + lines.append(" .") + lines.append("") + + return "\n".join(lines).rstrip() + + def __str__(self) -> str: + return self.__repr__() + + # --------------------------- + # Internals: discovery + # --------------------------- + + def _discover(self) -> dict[YellowPagesCategory, set[str]]: + arrays: set[str] = set() + tools: set[str] = set() + diags: set[str] = set() + + for _, holder in self._acc.modes().items(): + try: + arrays |= set(holder.list_arrays()) + except Exception: + pass + try: + tools |= set(holder.list_tools()) + except Exception: + pass + try: + diags |= set(holder.list_diagnostics()) + except Exception: + pass + + return { + YellowPagesCategory.ARRAYS: arrays, + YellowPagesCategory.TOOLS: tools, + YellowPagesCategory.DIAGNOSTICS: diags, + } + + def _all_keys(self) -> set[str]: + discovered = self._discover() + out: set[str] = set() + for keys in discovered.values(): + out |= set(keys) + return out + + # --------------------------- + # Internals: resolution + # --------------------------- + + def _require_key(self, key: str) -> None: + if key not in self._all_keys(): + raise KeyError(self._unknown_key_message(key)) + + def _unknown_key_message(self, key: str) -> str: + available = ", ".join(sorted(self._all_keys())) + return ( + f"Unknown YellowPages key '{key}'. " + f"Available keys: {available if available else ''}" + ) + + def _try_resolve_in_holder(self, key: str, holder: Any) -> Any | None: + try: + if key in holder.list_arrays(): + return holder.get_array(key) + except Exception: + pass + + try: + if key in holder.list_tools(): + return holder.get_tool(key) + except Exception: + pass + + try: + if key in holder.list_diagnostics(): + return holder.get_diagnostic(key) + except Exception: + pass + + return None + + # --------------------------- + # Internals: repr formatting + # --------------------------- + + def _get_type_name(self, key: str) -> str | None: + """ + Determine the fully qualified type name of a YellowPages entry. + + The type is inferred from the first resolved object found across modes. + """ + resolved = self.get(key) + + for obj in resolved.values(): + if obj is None: + continue + + cls = obj.__class__ + return f"{cls.__module__}.{cls.__name__}" + + return None + + def _format_key(self, category: YellowPagesCategory, key: str) -> str: + type_name = self._get_type_name(key) + type_part = f" ({type_name})" if type_name else "" + resolved = self.get(key) # dict[mode,obj] where available + modes = sorted(resolved.keys()) + all_modes = sorted(self._acc.modes().keys()) + + availability_part = "" + if set(modes) != set(all_modes): + missing = sorted(set(all_modes) - set(modes)) + availability_part = f" modes={modes} missing={missing}" + + if category == YellowPagesCategory.ARRAYS: + sizes: dict[str, int] = {} + for mode_name, obj in resolved.items(): + try: + sizes[mode_name] = len(obj) + except Exception: + sizes[mode_name] = 0 + + if set(modes) == set(all_modes) and sizes and len(set(sizes.values())) == 1: + size_part = f" size={next(iter(sizes.values()))}" + else: + size_part = ( + " size={" + + ", ".join(f"{m}:{n}" for m, n in sorted(sizes.items())) + + "}" + ) + + return f" {key:<10}{type_part:<40}{size_part}{availability_part}" + + return f" {key}{type_part}{availability_part}" + + # --------------------------- + # Internals: ID extraction + # --------------------------- + + def _object_to_ids(self, obj: Any) -> set[str]: + if obj is None: + return set() + + if isinstance(obj, (list, tuple, set)) and all(isinstance(x, str) for x in obj): + return set(obj) + + ids: set[str] = set() + try: + for x in obj: + if isinstance(x, str): + ids.add(x) + elif hasattr(x, "get_name") and callable(x.get_name): + ids.add(x.get_name()) + elif hasattr(x, "name") and callable(x.name): + ids.add(x.name()) + elif hasattr(x, "name") and isinstance(x.name, str): + ids.add(x.name) + else: + ids.add(str(x)) + return ids + except TypeError: + if isinstance(obj, str): + return {obj} + if hasattr(obj, "get_name") and callable(obj.get_name): + return {obj.get_name()} + return {str(obj)} + + def _ids_for_key_union_all_modes(self, key: str) -> set[str]: + out: set[str] = set() + resolved = self.get(key) # dict[mode,obj] + for obj in resolved.values(): + out |= self._object_to_ids(obj) + return out + + def _all_known_ids(self) -> set[str]: + all_ids: set[str] = set() + for array_name in self.keys(YellowPagesCategory.ARRAYS): + try: + all_ids |= self._ids_for_key_union_all_modes(array_name) + except Exception: + continue + return all_ids + + # --------------------------- + # Query evaluation + # --------------------------- + + def _eval_query_to_ids(self, expr: str) -> set[str]: + tokens = _tokenize(expr) + rpn = _to_rpn(tokens) + + stack: list[set[str]] = [] + for tok in rpn: + if tok in ("|", "&", "-"): + if len(stack) < 2: + raise YellowPagesQueryError( + f"Missing operand for operator '{tok}'." + ) + b = stack.pop() + a = stack.pop() + if tok == "|": + stack.append(a | b) + elif tok == "&": + stack.append(a & b) + else: + stack.append(a - b) + continue + + # Regex operand (preferred re{...} or legacy re:...) + if tok.startswith("re{") or tok.startswith("re:"): + pattern = _extract_regex(tok) + try: + rx = re.compile(pattern) + except re.error as ex: + raise YellowPagesQueryError( + f"Invalid regex '{pattern}': {ex}" + ) from ex + + base = self._all_known_ids() + stack.append({i for i in base if rx.search(i)}) + continue + + # KEY operand + if tok not in self._all_keys(): + raise YellowPagesQueryError(self._unknown_key_message(tok)) + + stack.append(self._ids_for_key_union_all_modes(tok)) + + if len(stack) != 1: + raise YellowPagesQueryError("Invalid expression (remaining operands).") + + return stack[0] diff --git a/tests/test_yellow_pages.py b/tests/test_yellow_pages.py new file mode 100644 index 00000000..a148a1a0 --- /dev/null +++ b/tests/test_yellow_pages.py @@ -0,0 +1,289 @@ +# tests/test_yellow_pages.py +# +# Self-contained tests for fully dynamic YellowPages (no register). +# Comments are in English. + +import re + +import pytest + +from pyaml.yellow_pages import ( + YellowPages, + YellowPagesCategory, + YellowPagesError, + YellowPagesQueryError, +) + +# --------------------------- +# Minimal test doubles +# --------------------------- + + +class _Holder: + """Minimal ElementHolder double with discovery + resolution.""" + + def __init__(self, arrays=None, tools=None, diagnostics=None): + self._arrays = dict(arrays or {}) + self._tools = dict(tools or {}) + self._diagnostics = dict(diagnostics or {}) + + # Discovery + def list_arrays(self) -> set[str]: + return set(self._arrays.keys()) + + def list_tools(self) -> set[str]: + return set(self._tools.keys()) + + def list_diagnostics(self) -> set[str]: + return set(self._diagnostics.keys()) + + # Resolution + def get_array(self, name: str): + if name not in self._arrays: + raise KeyError(name) + return self._arrays[name] + + def get_tool(self, name: str): + if name not in self._tools: + raise KeyError(name) + return self._tools[name] + + def get_diagnostic(self, name: str): + if name not in self._diagnostics: + raise KeyError(name) + return self._diagnostics[name] + + +class _Accelerator: + """Minimal Accelerator double.""" + + def __init__(self, controls: dict[str, _Holder], simulators: dict[str, _Holder]): + self._controls = dict(controls) + self._simulators = dict(simulators) + self._modes = {**self._controls, **self._simulators} + + def controls(self) -> dict[str, _Holder]: + return dict(self._controls) + + def simulators(self) -> dict[str, _Holder]: + return dict(self._simulators) + + def modes(self) -> dict[str, _Holder]: + return dict(self._modes) + + +# --------------------------- +# Fixtures +# --------------------------- + + +@pytest.fixture() +def accelerator(): + # Controls + live = _Holder( + arrays={ + "BPM": ["BPM_C01-01", "BPM_C01-02", "BPM_C02-01"], + "HCORR": ["CH_C01-01", "CH_C01-02"], + }, + tools={"DEFAULT_ORBIT_CORRECTION": object()}, + diagnostics={"DEFAULT_BETATRON_TUNE_MONITOR": object()}, + ) + tango = _Holder( + arrays={ + "BPM": ["BPM_C01-01", "BPM_C01-02", "BPM_C02-01"], # same as live + # HCORR missing in tango + }, + tools={}, # tool missing + diagnostics={}, # diag missing + ) + + # Simulators + design = _Holder( + arrays={ + "BPM": ["BPM_C01-01", "BPM_C01-02", "BPM_C02-01"], + "HCORR": ["CH_C01-01"], # different size than live + }, + tools={"DEFAULT_ORBIT_CORRECTION": object()}, + diagnostics={}, # missing + ) + + return _Accelerator( + controls={"live": live, "tango": tango}, simulators={"design": design} + ) + + +@pytest.fixture() +def yp(accelerator): + return YellowPages(accelerator) + + +# --------------------------- +# Discovery API +# --------------------------- + + +def test_has_and_keys_discovery(yp): + assert yp.has("BPM") + assert yp.has("HCORR") + assert yp.has("DEFAULT_ORBIT_CORRECTION") + assert yp.has("DEFAULT_BETATRON_TUNE_MONITOR") + assert not yp.has("DOES_NOT_EXIST") + + assert yp.keys(YellowPagesCategory.ARRAYS) == ["BPM", "HCORR"] + assert yp.keys(YellowPagesCategory.TOOLS) == ["DEFAULT_ORBIT_CORRECTION"] + assert yp.keys(YellowPagesCategory.DIAGNOSTICS) == ["DEFAULT_BETATRON_TUNE_MONITOR"] + + +def test_categories_returns_only_present_categories(yp): + # All three categories are present in our fixture + assert yp.categories() == ["Arrays", "Tools", "Diagnostics"] + + +def test_dir_includes_attribute_friendly_keys_only(yp): + d = dir(yp) + # Attribute-friendly discovered keys should be included + assert "BPM" in d + assert "HCORR" in d + assert "DEFAULT_ORBIT_CORRECTION" in d + + # Non-existent keys not present + assert "DOES_NOT_EXIST" not in d + + +def test_getattr_returns_multimode_resolution_dict(yp): + bpm = yp.BPM + assert isinstance(bpm, dict) + assert set(bpm.keys()) == {"live", "tango", "design"} + + +# --------------------------- +# Resolution +# --------------------------- + + +def test_get_without_mode_returns_only_available_modes(yp): + hcorr = yp.get("HCORR") + assert set(hcorr.keys()) == {"live", "design"} # tango missing + + +def test_get_with_mode_returns_object_or_raises(yp): + bpm_live = yp.get("BPM", mode="live") + assert bpm_live == ["BPM_C01-01", "BPM_C01-02", "BPM_C02-01"] + + with pytest.raises(YellowPagesError, match=r"Unknown mode"): + yp.get("BPM", mode="does_not_exist") + + with pytest.raises(YellowPagesError, match=r"not available in mode 'tango'"): + yp.get("HCORR", mode="tango") + + +def test_availability(yp): + assert yp.availability("BPM") == {"live", "tango", "design"} + assert yp.availability("HCORR") == {"live", "design"} + + +def test_unknown_key_errors(yp): + with pytest.raises(KeyError, match=r"Unknown YellowPages key"): + yp.get("DOES_NOT_EXIST") + + with pytest.raises(YellowPagesQueryError, match=r"Unknown YellowPages key"): + _ = yp["DOES_NOT_EXIST"] + + +# --------------------------- +# __repr__ formatting (controls/simulators + sizes + modes) +# --------------------------- + + +def test_repr_has_controls_and_simulators_headers(yp): + s = repr(yp) + assert "Controls:" in s + assert "Simulators:" in s + + assert re.search(r"^\s+live$", s, re.M) is not None + assert re.search(r"^\s+tango$", s, re.M) is not None + assert re.search(r"^\s+design$", s, re.M) is not None + + +def test_repr_array_size_compaction_when_identical_everywhere(yp): + # BPM has same size in all modes => should show "size=3" + s = repr(yp) + bpm_line = next(line for line in s.splitlines() if line.strip().startswith("BPM")) + assert "size=3" in bpm_line + assert "size={" not in bpm_line + + +def test_repr_array_size_dict_when_different_or_not_everywhere(yp): + # HCORR differs in size between live (2) and design (1) and missing in tango + s = repr(yp) + hcorr_line = next( + line for line in s.splitlines() if line.strip().startswith("HCORR") + ) + assert "size={" in hcorr_line + assert "live:2" in hcorr_line + assert "design:1" in hcorr_line + assert "modes=" in hcorr_line + assert "missing=" in hcorr_line + + +def test_repr_tools_show_modes_missing(yp): + s = repr(yp) + tool_line = next( + line for line in s.splitlines() if "DEFAULT_ORBIT_CORRECTION" in line + ) + assert "modes=" in tool_line + assert "missing=" in tool_line + + +# --------------------------- +# Query language (__getitem__) +# --------------------------- + + +def test_query_single_key_returns_union_of_ids_across_modes(yp): + out = yp["BPM"] + assert out == ["BPM_C01-01", "BPM_C01-02", "BPM_C02-01"] + + +def test_query_union_operator(yp): + out = yp["HCORR|BPM"] + assert "CH_C01-01" in out + assert "CH_C01-02" in out + assert "BPM_C01-01" in out + + +def test_query_intersection_operator(yp): + assert yp["BPM&HCORR"] == [] + + +def test_query_difference_operator(yp): + out = yp["BPM - re{BPM_C01-01}"] + assert out == ["BPM_C01-02", "BPM_C02-01"] + + +def test_query_parentheses_precedence(yp): + out = yp["(BPM|HCORR) - re:CH_.*"] + assert out == ["BPM_C01-01", "BPM_C01-02", "BPM_C02-01"] + + +def test_query_regex_alone_filters_all_known_ids(yp): + out = yp["re:^BPM_"] + assert out == ["BPM_C01-01", "BPM_C01-02", "BPM_C02-01"] + + +def test_query_tokenize_errors_raise(yp): + with pytest.raises(YellowPagesQueryError, match=r"Empty YellowPages query"): + _ = yp[""] + + with pytest.raises(YellowPagesQueryError, match=r"Cannot tokenize"): + _ = yp["BPM $$$"] + + +def test_query_mismatched_parentheses_raise(yp): + with pytest.raises(YellowPagesQueryError, match=r"Mismatched parentheses"): + _ = yp["(BPM|HCORR"] + + +def test_query_invalid_regex_raise(yp): + with pytest.raises(YellowPagesQueryError, match=r"Invalid regex"): + _ = yp["re{(}"] diff --git a/tests/test_yellow_pages_basic.py b/tests/test_yellow_pages_basic.py new file mode 100644 index 00000000..b4ba2ccb --- /dev/null +++ b/tests/test_yellow_pages_basic.py @@ -0,0 +1,38 @@ +# ruff: noqa: E501 +from pathlib import Path + +from pyaml.accelerator import Accelerator + +ebs_orbit_str_repr = """Controls: + live + . + +Simulators: + design + . + +Arrays: + BPM (pyaml.arrays.bpm_array.BPMArray) size=320 + HCorr (pyaml.arrays.magnet_array.MagnetArray) size=288 + Skews (pyaml.arrays.magnet_array.MagnetArray) size=288 + VCorr (pyaml.arrays.magnet_array.MagnetArray) size=288 + . + +Tools: + DEFAULT_DISPERSION (pyaml.tuning_tools.dispersion.Dispersion) + DEFAULT_ORBIT_CORRECTION (pyaml.tuning_tools.orbit.Orbit) + DEFAULT_ORBIT_RESPONSE_MATRIX (pyaml.tuning_tools.orbit_response_matrix.OrbitResponseMatrix) + . + +Diagnostics: + BETATRON_TUNE (pyaml.diagnostics.tune_monitor.BetatronTuneMonitor) + .""" + + +def test_load_conf_with_code(): + parent_folder = Path(__file__).parent + config_path = parent_folder.joinpath("config", "EBSOrbit.yaml").resolve() + + sr: Accelerator = Accelerator.load(config_path) + str_repr = str(sr.yellow_pages) + assert ebs_orbit_str_repr == str_repr From 28e57e856a2aa90f555e570a3f8d9d035ac4ff3d Mon Sep 17 00:00:00 2001 From: guillaumepichon Date: Thu, 5 Mar 2026 11:05:05 +0100 Subject: [PATCH 02/11] Documentation enhancement --- pyaml/yellow_pages.py | 308 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 292 insertions(+), 16 deletions(-) diff --git a/pyaml/yellow_pages.py b/pyaml/yellow_pages.py index c1e7b393..b7b3b610 100644 --- a/pyaml/yellow_pages.py +++ b/pyaml/yellow_pages.py @@ -39,11 +39,13 @@ Examples -------- ->>> yp["BPM"] ->>> yp["HCORR|VCORR"] ->>> yp["BPM - re{BPM_C01-01}"] ->>> yp["re{^BPM_C..-..$}"] ->>> yp["(HCORR|VCORR) - re{CH_.*}"] +.. code-block:: python + + >>> yp["BPM"] + >>> yp["HCORR|VCORR"] + >>> yp["BPM - re{BPM_C01-01}"] + >>> yp["re{^BPM_C..-..$}"] + >>> yp["(HCORR|VCORR) - re{CH_.*}"] Notes ----- @@ -187,20 +189,65 @@ def _extract_regex(tok: str) -> str: class YellowPages: - """ - Fully dynamic YellowPages service attached to Accelerator. + r""" + Dynamic discovery service for accelerator objects. + + :class:`YellowPages` provides a unified access layer to arrays, + tuning tools and diagnostics available in an + :class:`~pyaml.accelerator.Accelerator`. + + Entries are discovered dynamically by scanning all + :class:`~pyaml.element_holder.ElementHolder` instances + associated with the accelerator control and simulation modes. + + Notes + ----- + The :class:`~pyaml.accelerator.Accelerator` must provide: + + - ``controls() -> dict[str, ElementHolder]`` + - ``simulators() -> dict[str, ElementHolder]`` + - ``modes() -> dict[str, ElementHolder]`` + + Each :class:`~pyaml.element_holder.ElementHolder` must implement: + + - ``list_arrays()`` / ``get_array(name)`` + - ``list_tools()`` / ``get_tool(name)`` + - ``list_diagnostics()`` / ``get_diagnostic(name)`` + + Examples + -------- + + Print the global overview: + + .. code-block:: python + + print(sr.yellow_pages) + + Resolve an entry across all modes: + + .. code-block:: python - Discovery: - - keys/categories are derived by scanning all modes at runtime. + sr.yellow_pages.get("BPM") - Resolution: - - get(key, mode="...") resolves in a specific mode. - - get(key) returns dict[mode_name, obj] for all modes where available. + Resolve in a specific mode: - Query language (arrays/IDs): - - yp["BPM"] - - yp["HCORR|VCORR"] - - yp["BPM - re{BPM_C01-01}"] + .. code-block:: python + + sr.yellow_pages.get("BPM", mode="live") + + Query arrays using set expressions: + + .. code-block:: python + + sr.yellow_pages["BPM"] + sr.yellow_pages["HCORR|VCORR"] + sr.yellow_pages["BPM - re{BPM_C01-01}"] + + Regex filtering: + + .. code-block:: python + + sr.yellow_pages["re{^BPM_C..-..$}"] """ def __init__(self, accelerator: Any): @@ -211,14 +258,93 @@ def __init__(self, accelerator: Any): # --------------------------- def has(self, key: str) -> bool: + """ + Check whether a YellowPages key exists. + + Parameters + ---------- + key : str + Name of the entry. + + Returns + ------- + bool + True if the key exists. + + Examples + -------- + + .. code-block:: python + + >>> sr.yellow_pages.has("BPM") + True + + .. code-block:: python + + >>> sr.yellow_pages.has("UNKNOWN") + False + + """ return key in self._all_keys() def categories(self) -> list[str]: + """ + Return the list of available categories. + + Only categories that contain elements are returned. + + Returns + ------- + list[str] + + Examples + -------- + + .. code-block:: python + + >>> sr.yellow_pages.categories() + ['Arrays', 'Tools', 'Diagnostics'] + + """ discovered = self._discover() present = {cat for cat, keys in discovered.items() if keys} return [c.value for c in YellowPagesCategory if c in present] def keys(self, category: str | YellowPagesCategory | None = None) -> list[str]: + """ + Return available YellowPages keys. + + Parameters + ---------- + category : str or YellowPagesCategory, optional + Restrict results to a specific category. + + Returns + ------- + list[str] + + Examples + -------- + + All entries: + + .. code-block:: python + + >>> sr.yellow_pages.keys() + + Only arrays: + + .. code-block:: python + + >>> sr.yellow_pages.keys("Arrays") + + Using enum: + + .. code-block:: python + + >>> sr.yellow_pages.keys(YellowPagesCategory.ARRAYS) + + """ discovered = self._discover() if category is None: @@ -247,6 +373,31 @@ def __getattr__(self, name): # --------------------------- def availability(self, key: str) -> set[str]: + """ + Return the list of modes where a key is available. + + Parameters + ---------- + key : str + + Returns + ------- + set[str] + + Examples + -------- + + .. code-block:: python + + >>> sr.yellow_pages.availability("BPM") + {'live', 'design'} + + .. code-block:: python + + >>> sr.yellow_pages.availability("DEFAULT_ORBIT_CORRECTION") + {'live'} + + """ self._require_key(key) avail: set[str] = set() for mode_name, holder in self._acc.modes().items(): @@ -255,6 +406,42 @@ def availability(self, key: str) -> set[str]: return avail def get(self, key: str, *, mode: str | None = None): + """ + Resolve a YellowPages entry. + + Parameters + ---------- + key : str + Entry name. + mode : str, optional + Specific mode. + + Returns + ------- + object or dict[str, object] + + Examples + -------- + + Resolve in all modes: + + .. code-block:: python + + >>> sr.yellow_pages.get("BPM") + + Resolve in a specific mode: + + .. code-block:: python + + >>> sr.yellow_pages.get("BPM", mode="live") + + Using attribute access: + + .. code-block:: python + + >>> sr.yellow_pages.BPM + + """ self._require_key(key) if mode is not None: @@ -280,6 +467,61 @@ def get(self, key: str, *, mode: str | None = None): # --------------------------- def __getitem__(self, query: str) -> list[str]: + """ + Evaluate a YellowPages query expression. + + The query language allows set operations and regex filtering. + + Operators + --------- + + | : union + + & : intersection + + - : difference + + Parentheses are supported. + + Regex + ----- + + Use the form ``re{pattern}``. + + Examples + -------- + + All BPMs: + + .. code-block:: python + + >>> sr.yellow_pages["BPM"] + + Union: + + .. code-block:: python + + >>> sr.yellow_pages["HCORR|VCORR"] + + Difference: + + .. code-block:: python + + >>> sr.yellow_pages["BPM - re{BPM_C01-01}"] + + Regex filter: + + .. code-block:: python + + >>> sr.yellow_pages["re{^BPM_C..-..$}"] + + Combined expressions: + + .. code-block:: python + + >>> sr.yellow_pages["(HCORR|VCORR) - re{CH_.*}"] + + """ ids = self._eval_query_to_ids(query) return sorted(ids) @@ -288,6 +530,40 @@ def __getitem__(self, query: str) -> list[str]: # --------------------------- def __repr__(self) -> str: + """ + Return a human-readable overview of the YellowPages content. + + The output lists controls, simulators and discovered objects + grouped by category. + + Examples + -------- + + .. code-block:: python + + >>> print(sr.yellow_pages) + + Controls: + live + . + + Simulators: + design + . + + Arrays: + BPM (pyaml.arrays.bpm_array) size=224 + HCORR (pyaml.arrays.magnet_array) size=96 + . + + Tools: + DEFAULT_ORBIT_CORRECTION (pyaml.tuning_tools.orbit) + . + + Diagnostics: + BETATRON_TUNE (pyaml.diagnostics.tune_monitor) + . + """ lines: list[str] = [] lines.append("Controls:") From a354afa003bda446721a9944baeb45cf43f3dd22 Mon Sep 17 00:00:00 2001 From: guillaumepichon Date: Thu, 5 Mar 2026 12:18:36 +0100 Subject: [PATCH 03/11] the get method is now internal. --- pyaml/yellow_pages.py | 10 +++++----- tests/test_yellow_pages.py | 19 ------------------- 2 files changed, 5 insertions(+), 24 deletions(-) diff --git a/pyaml/yellow_pages.py b/pyaml/yellow_pages.py index b7b3b610..d8fed050 100644 --- a/pyaml/yellow_pages.py +++ b/pyaml/yellow_pages.py @@ -365,7 +365,7 @@ def __dir__(self): def __getattr__(self, name): if name in self._all_keys(): - return self.get(name) + return self._get(name) raise AttributeError(f"'YellowPages' object has no attribute '{name}'") # --------------------------- @@ -405,7 +405,7 @@ def availability(self, key: str) -> set[str]: avail.add(mode_name) return avail - def get(self, key: str, *, mode: str | None = None): + def _get(self, key: str, *, mode: str | None = None): """ Resolve a YellowPages entry. @@ -681,7 +681,7 @@ def _get_type_name(self, key: str) -> str | None: The type is inferred from the first resolved object found across modes. """ - resolved = self.get(key) + resolved = self._get(key) for obj in resolved.values(): if obj is None: @@ -695,7 +695,7 @@ def _get_type_name(self, key: str) -> str | None: def _format_key(self, category: YellowPagesCategory, key: str) -> str: type_name = self._get_type_name(key) type_part = f" ({type_name})" if type_name else "" - resolved = self.get(key) # dict[mode,obj] where available + resolved = self._get(key) # dict[mode,obj] where available modes = sorted(resolved.keys()) all_modes = sorted(self._acc.modes().keys()) @@ -759,7 +759,7 @@ def _object_to_ids(self, obj: Any) -> set[str]: def _ids_for_key_union_all_modes(self, key: str) -> set[str]: out: set[str] = set() - resolved = self.get(key) # dict[mode,obj] + resolved = self._get(key) # dict[mode,obj] for obj in resolved.values(): out |= self._object_to_ids(obj) return out diff --git a/tests/test_yellow_pages.py b/tests/test_yellow_pages.py index a148a1a0..5643761a 100644 --- a/tests/test_yellow_pages.py +++ b/tests/test_yellow_pages.py @@ -161,31 +161,12 @@ def test_getattr_returns_multimode_resolution_dict(yp): # --------------------------- -def test_get_without_mode_returns_only_available_modes(yp): - hcorr = yp.get("HCORR") - assert set(hcorr.keys()) == {"live", "design"} # tango missing - - -def test_get_with_mode_returns_object_or_raises(yp): - bpm_live = yp.get("BPM", mode="live") - assert bpm_live == ["BPM_C01-01", "BPM_C01-02", "BPM_C02-01"] - - with pytest.raises(YellowPagesError, match=r"Unknown mode"): - yp.get("BPM", mode="does_not_exist") - - with pytest.raises(YellowPagesError, match=r"not available in mode 'tango'"): - yp.get("HCORR", mode="tango") - - def test_availability(yp): assert yp.availability("BPM") == {"live", "tango", "design"} assert yp.availability("HCORR") == {"live", "design"} def test_unknown_key_errors(yp): - with pytest.raises(KeyError, match=r"Unknown YellowPages key"): - yp.get("DOES_NOT_EXIST") - with pytest.raises(YellowPagesQueryError, match=r"Unknown YellowPages key"): _ = yp["DOES_NOT_EXIST"] From 05d4f372807e4bb593542a65c3d7bc1e32b0dc75 Mon Sep 17 00:00:00 2001 From: guillaumepichon Date: Fri, 13 Mar 2026 12:03:43 +0100 Subject: [PATCH 04/11] YellowPages corrections: the element order is preserved as much as possible. The get method is now public and returns an array of strings. The query syntax has been updated to match the one used in the configuration file. It is also possible to query an array by its name. --- pyaml/accelerator.py | 6 +- pyaml/common/element_holder.py | 48 +-- pyaml/yellow_pages.py | 640 ++++++++++++------------------- tests/test_yellow_pages.py | 186 +++++---- tests/test_yellow_pages_basic.py | 28 +- 5 files changed, 414 insertions(+), 494 deletions(-) diff --git a/pyaml/accelerator.py b/pyaml/accelerator.py index f84c70c4..87be1045 100644 --- a/pyaml/accelerator.py +++ b/pyaml/accelerator.py @@ -181,7 +181,7 @@ def modes(self) -> dict[str, "ElementHolder"]: modes: dict[str, "ElementHolder"] = {} modes.update(self._simulators) modes.update(self._controls) - return self._controls + return modes def __repr__(self): return repr(self._cfg).replace("ConfigModel", self.__class__.__name__) @@ -209,9 +209,7 @@ def from_dict(config_dict: dict, ignore_external=False) -> "Accelerator": return Factory.depth_first_build(config_dict, ignore_external) @staticmethod - def load( - filename: str, use_fast_loader: bool = False, ignore_external=False - ) -> "Accelerator": + def load(filename: str, use_fast_loader: bool = False, ignore_external=False) -> "Accelerator": """ Load an accelerator from a config file. diff --git a/pyaml/common/element_holder.py b/pyaml/common/element_holder.py index 2c1fc997..a9d159aa 100644 --- a/pyaml/common/element_holder.py +++ b/pyaml/common/element_holder.py @@ -110,13 +110,10 @@ def fill_array( try: m = get_func(n) except Exception as err: - raise PyAMLException( - f"{constructor.__name__} {array_name} : {err} @index {len(a)}" - ) from None + raise PyAMLException(f"{constructor.__name__} {array_name} : {err} @index {len(a)}") from None if m in a: raise PyAMLException( - f"{constructor.__name__} {array_name} : " - f"duplicate name {name} @index {len(a)}" + f"{constructor.__name__} {array_name} : duplicate name {name} @index {len(a)}" ) from None a.append(m) ARR[array_name] = constructor(array_name, a) @@ -124,8 +121,7 @@ def fill_array( def __add(self, array, element: Element): if element.get_name() in self.__ALL: # Ensure name unicity raise PyAMLException( - f"Duplicate element {element.__class__.__name__} " - "name {element.get_name()}" + f"Duplicate element {element.__class__.__name__} name {{element.get_name()}}" ) from None array[element.get_name()] = element self.__ALL[element.get_name()] = element @@ -157,9 +153,7 @@ def get_all_elements(self) -> list[Element]: # Magnets def fill_magnet_array(self, arrayName: str, elementNames: list[str]): - self.fill_array( - arrayName, elementNames, self.get_magnet, MagnetArray, self.__MAGNET_ARRAYS - ) + self.fill_array(arrayName, elementNames, self.get_magnet, MagnetArray, self.__MAGNET_ARRAYS) def get_magnet(self, name: str) -> Magnet: return self.__get("Magnet", name, self.__MAGNETS) @@ -191,9 +185,7 @@ def add_cfm_magnet(self, m: Magnet): self.__add(self.__CFM_MAGNETS, m) def get_cfm_magnets(self, name: str) -> CombinedFunctionMagnetArray: - return self.__get( - "CombinedFunctionMagnet array", name, self.__CFM_MAGNET_ARRAYS - ) + return self.__get("CombinedFunctionMagnet array", name, self.__CFM_MAGNET_ARRAYS) def get_all_cfm_magnets(self) -> list[CombinedFunctionMagnet]: return [value for key, value in self.__CFM_MAGNETS.items()] @@ -216,9 +208,7 @@ def add_serialized_magnet(self, m: Magnet): self.__add(self.__SERIALIZED_MAGNETS, m) def get_serialized_magnets(self, name: str) -> SerializedMagnetsArray: - return self.__get( - "SerializedMagnets array", name, self.__SERIALIZED_MAGNETS_ARRAYS - ) + return self.__get("SerializedMagnets array", name, self.__SERIALIZED_MAGNETS_ARRAYS) def get_all_serialized_magnets(self) -> list[SerializedMagnets]: return [value for key, value in self.__SERIALIZED_MAGNETS.items()] @@ -355,26 +345,26 @@ def get_diagnostic(self, name: str): raise PyAMLException(f"Diagnostic {name} not defined") return self.__DIAG[name] - def list_arrays(self) -> set[str]: + def list_arrays(self) -> list[str]: """ Return all array identifiers available in this holder. """ - return ( - set(self.__BPM_ARRAYS.keys()) - | set(self.__MAGNET_ARRAYS.keys()) - | set(self.__CFM_MAGNET_ARRAYS.keys()) - | set(self.__SERIALIZED_MAGNETS_ARRAYS.keys()) - | set(self.__ELEMENT_ARRAYS.keys()) - ) - - def list_tools(self) -> set[str]: + arrays: list[str] = [] + arrays.extend(self.__BPM_ARRAYS.keys()) + arrays.extend(self.__MAGNET_ARRAYS.keys()) + arrays.extend(self.__CFM_MAGNET_ARRAYS.keys()) + arrays.extend(self.__SERIALIZED_MAGNETS_ARRAYS.keys()) + arrays.extend(self.__ELEMENT_ARRAYS.keys()) + return arrays + + def list_tools(self) -> list[str]: """ Return all tuning tool identifiers available in this holder. """ - return set(self.__TUNING_TOOLS.keys()) + return list(self.__TUNING_TOOLS.keys()) - def list_diagnostics(self) -> set[str]: + def list_diagnostics(self) -> list[str]: """ Return all diagnostic identifiers available in this holder. """ - return set(self.__DIAG.keys()) + return list(self.__DIAG.keys()) diff --git a/pyaml/yellow_pages.py b/pyaml/yellow_pages.py index d8fed050..e68fa28c 100644 --- a/pyaml/yellow_pages.py +++ b/pyaml/yellow_pages.py @@ -4,56 +4,34 @@ Fully dynamic YellowPages service attached to Accelerator. Key points: -- Auto-discovery ONLY: arrays/tools/diagnostics are discovered at runtime by scanning - all modes. +- Auto-discovery only: arrays, tools and diagnostics are discovered at runtime + by scanning all modes. - No caching: every call reflects current runtime state. - -Expected Accelerator interface: +- Simple query syntax for identifiers: + - wildcard / fnmatch: + yp["OH4*"] + - regular expression: + yp["re:^SH1A-C0[12]-H$"] + +Expected Accelerator interface +------------------------------ - controls() -> dict[str, ElementHolder] - simulators() -> dict[str, ElementHolder] -- modes() -> dict[str, ElementHolder] (union of controls + simulators) +- modes() -> dict[str, ElementHolder] -Expected ElementHolder interface: -- list_arrays() -> set[str] -- list_tools() -> set[str] +Expected ElementHolder interface +-------------------------------- +- list_arrays() -> set[str] +- list_tools() -> set[str] - list_diagnostics() -> set[str] -- get_array(name: str) -> Any -- get_tool(name: str) -> Any +- get_array(name: str) -> Any +- get_tool(name: str) -> Any - get_diagnostic(name: str) -> Any - -Query language via __getitem__: -- Operands: - - KEY: discovered identifier (e.g. BPM, HCORR, VCORR) - - Regex: use re{ ... } (recommended) or re:... (legacy simple form) - -- Operators: - - Union: | - - Intersection: & - - Difference: - - - Parentheses: ( ) - -Regex grammar: -- Preferred form: re{} - - Allows '-', parentheses, spaces, etc. - - Escape '}' as '\\}' inside the regex. - -Examples --------- -.. code-block:: python - - >>> yp["BPM"] - >>> yp["HCORR|VCORR"] - >>> yp["BPM - re{BPM_C01-01}"] - >>> yp["re{^BPM_C..-..$}"] - >>> yp["(HCORR|VCORR) - re{CH_.*}"] - -Notes ------ -- KEY operands in queries are treated as arrays and converted to IDs. -- Regex operands filter over ALL known IDs gathered from all discovered arrays across - all modes. """ +from __future__ import annotations + +import fnmatch import re from enum import Enum from typing import Any @@ -72,122 +50,12 @@ class YellowPagesError(PyAMLException): class YellowPagesQueryError(YellowPagesError): - """Raised when a YellowPages query string cannot be parsed/evaluated.""" + """Raised when a YellowPages query string cannot be evaluated.""" _VALID_KEY_RE = re.compile(r"^[A-Z0-9_]+$") -# --------------------------- -# Query parsing helpers -# --------------------------- - -# Regex operand supports: -# - re{...} where ... may include operators chars like '-', '|', '&', parentheses, -# spaces, etc. -# '}' can be escaped as '\}' inside. -# -# We match: re{ (?: \\. | [^}] )* } -# meaning: either an escaped char (e.g. '\}') or any char except '}'. -_TOKEN_RE = re.compile( - r""" - \s*( - re\{(?:\\.|[^}])*\} | # regex token with braces, supports escaped chars - re:[^\s\|\&\-\(\)]+ | # legacy regex token (no spaces/operators/parens) - [A-Z0-9_]+ | # identifier token (YellowPages key) - \|\|? | # '|' or '||' - \&\&? | # '&' or '&&' - \- | # difference - \(|\) # parentheses - )\s* - """, - re.VERBOSE, -) - - -def _tokenize(expr: str) -> list[str]: - if not expr or not expr.strip(): - raise YellowPagesQueryError("Empty YellowPages query.") - - pos = 0 - tokens: list[str] = [] - while pos < len(expr): - m = _TOKEN_RE.match(expr, pos) - if not m: - snippet = expr[pos : min(len(expr), pos + 32)] - raise YellowPagesQueryError(f"Cannot tokenize near: '{snippet}'") - tok = m.group(1) - if tok == "||": - tok = "|" - if tok == "&&": - tok = "&" - tokens.append(tok) - pos = m.end() - return tokens - - -def _to_rpn(tokens: list[str]) -> list[str]: - """ - Shunting-yard to RPN. - - Precedence: - - '&' and '-' higher than '|' - - all left-associative - """ - prec = {"|": 1, "&": 2, "-": 2} - output: list[str] = [] - stack: list[str] = [] - - for tok in tokens: - if tok == "(": - stack.append(tok) - elif tok == ")": - while stack and stack[-1] != "(": - output.append(stack.pop()) - if not stack or stack[-1] != "(": - raise YellowPagesQueryError("Mismatched parentheses.") - stack.pop() - elif tok in prec: - while stack and stack[-1] in prec and prec[stack[-1]] >= prec[tok]: - output.append(stack.pop()) - stack.append(tok) - else: - output.append(tok) - - while stack: - if stack[-1] in ("(", ")"): - raise YellowPagesQueryError("Mismatched parentheses.") - output.append(stack.pop()) - - return output - - -def _extract_regex(tok: str) -> str: - """ - Extract regex pattern from a regex token. - - Supported: - - re{...} (preferred) - - re:... (legacy) - """ - if tok.startswith("re{") and tok.endswith("}"): - inner = tok[3:-1] - # Interpret escaped sequences (e.g. '\}' -> '}') - # Keep it simple: only unescape '\}' and '\\' - inner = inner.replace(r"\}", "}").replace(r"\\", "\\") - return inner - - if tok.startswith("re:"): - return tok[3:] - - raise YellowPagesQueryError(f"Invalid regex token '{tok}'") - - -# --------------------------- -# YellowPages service (full dynamic) -# --------------------------- - - class YellowPages: r""" Dynamic discovery service for accelerator objects. @@ -235,41 +103,35 @@ class YellowPages: sr.yellow_pages.get("BPM", mode="live") - Query arrays using set expressions: + Search identifiers using wildcards: .. code-block:: python - sr.yellow_pages["BPM"] - sr.yellow_pages["HCORR|VCORR"] - sr.yellow_pages["BPM - re{BPM_C01-01}"] + sr.yellow_pages["OH4*"] - Regex filtering: + Search identifiers using a regular expression: .. code-block:: python - sr.yellow_pages["re{^BPM_C..-..$}"] + sr.yellow_pages["re:^SH1A-C0[12]-H$"] """ def __init__(self, accelerator: Any): self._acc = accelerator - # --------------------------- - # Discovery API - # --------------------------- - def has(self, key: str) -> bool: - """ + r""" Check whether a YellowPages key exists. Parameters ---------- key : str - Name of the entry. + Entry name. Returns ------- bool - True if the key exists. + ``True`` if the key exists. Examples -------- @@ -288,10 +150,10 @@ def has(self, key: str) -> bool: return key in self._all_keys() def categories(self) -> list[str]: - """ + r""" Return the list of available categories. - Only categories that contain elements are returned. + Only categories that contain entries are returned. Returns ------- @@ -311,13 +173,13 @@ def categories(self) -> list[str]: return [c.value for c in YellowPagesCategory if c in present] def keys(self, category: str | YellowPagesCategory | None = None) -> list[str]: - """ + r""" Return available YellowPages keys. Parameters ---------- category : str or YellowPagesCategory, optional - Restrict results to a specific category. + Restrict the result to a specific category. Returns ------- @@ -348,37 +210,41 @@ def keys(self, category: str | YellowPagesCategory | None = None) -> list[str]: discovered = self._discover() if category is None: - return sorted(self._all_keys()) + return self._all_keys() cat = YellowPagesCategory(category) - return sorted(discovered.get(cat, set())) - - # --------------------------- - # Bonus: REPL-friendly exploration - # --------------------------- + return discovered.get(cat, []) def __dir__(self): + """ + Extend ``dir()`` with attribute-friendly discovered keys. + """ default = super().__dir__() - return sorted( - set(default) | {k for k in self._all_keys() if _VALID_KEY_RE.match(k)} - ) + return sorted(set(default) | {k for k in self._all_keys() if _VALID_KEY_RE.match(k)}) def __getattr__(self, name): + """ + Allow attribute-style access for valid discovered keys. + + Examples + -------- + + .. code-block:: python + + sr.yellow_pages.BPM + """ if name in self._all_keys(): - return self._get(name) + return self._get_object(name) raise AttributeError(f"'YellowPages' object has no attribute '{name}'") - # --------------------------- - # Resolution API - # --------------------------- - def availability(self, key: str) -> set[str]: - """ - Return the list of modes where a key is available. + r""" + Return the set of modes where a key is available. Parameters ---------- key : str + Entry name. Returns ------- @@ -389,14 +255,8 @@ def availability(self, key: str) -> set[str]: .. code-block:: python - >>> sr.yellow_pages.availability("BPM") - {'live', 'design'} - - .. code-block:: python - - >>> sr.yellow_pages.availability("DEFAULT_ORBIT_CORRECTION") - {'live'} - + sr.yellow_pages.availability("BPM") + sr.yellow_pages.availability("DEFAULT_ORBIT_CORRECTION") """ self._require_key(key) avail: set[str] = set() @@ -405,8 +265,8 @@ def availability(self, key: str) -> set[str]: avail.add(mode_name) return avail - def _get(self, key: str, *, mode: str | None = None): - """ + def _get_object(self, key: str, *, mode: str | None = None): + r""" Resolve a YellowPages entry. Parameters @@ -414,16 +274,29 @@ def _get(self, key: str, *, mode: str | None = None): key : str Entry name. mode : str, optional - Specific mode. + Restrict the resolution to a specific mode. Returns ------- object or dict[str, object] + If ``mode`` is specified, returns the resolved object. + + Otherwise returns a dictionary mapping mode names + to resolved objects. + + Raises + ------ + KeyError + If the key does not exist. + YellowPagesError + If the mode is unknown or the key is not available + in the requested mode. + Examples -------- - Resolve in all modes: + Resolve across all modes: .. code-block:: python @@ -450,9 +323,7 @@ def _get(self, key: str, *, mode: str | None = None): raise YellowPagesError(f"Unknown mode '{mode}'.") obj = self._try_resolve_in_holder(key, holder) if obj is None: - raise YellowPagesError( - f"YellowPages key '{key}' not available in mode '{mode}'." - ) + raise YellowPagesError(f"YellowPages key '{key}' not available in mode '{mode}'.") return obj out: dict[str, Any] = {} @@ -462,114 +333,131 @@ def _get(self, key: str, *, mode: str | None = None): out[mode_name] = obj return out - # --------------------------- - # Query language: yp["..."] (arrays/IDs only) - # --------------------------- - def __getitem__(self, query: str) -> list[str]: """ - Evaluate a YellowPages query expression. - - The query language allows set operations and regex filtering. - - Operators - --------- - - | : union - - & : intersection - - - : difference + Alias for :meth:`get`. + """ + return self.get(query) - Parentheses are supported. + def get(self, query: str, mode: str | None = None) -> list[str]: + """ + Search identifiers using a wildcard or regular expression. - Regex - ----- + Parameters + ---------- + query : str + Search expression. + mode : str, optional + Restrict the search to a specific accelerator mode. - Use the form ``re{pattern}``. + Returns + ------- + list[str] Examples -------- - All BPMs: - - .. code-block:: python - - >>> sr.yellow_pages["BPM"] - - Union: - - .. code-block:: python - - >>> sr.yellow_pages["HCORR|VCORR"] - - Difference: - .. code-block:: python - >>> sr.yellow_pages["BPM - re{BPM_C01-01}"] + >>> sr.yellow_pages.get("OH4*") - Regex filter: + >>> sr.yellow_pages.get("OH4*", mode="live") - .. code-block:: python + >>> sr.yellow_pages.get("re:^SH1A-C0[12]-H$") - >>> sr.yellow_pages["re{^BPM_C..-..$}"] + """ + if not query or not query.strip(): + raise YellowPagesQueryError("Empty YellowPages query.") - Combined expressions: + query = query.strip() - .. code-block:: python + if mode is not None: + holder = self._acc.modes().get(mode) + if holder is None: + raise YellowPagesError(f"Unknown mode '{mode}'.") - >>> sr.yellow_pages["(HCORR|VCORR) - re{CH_.*}"] + if query in holder.list_arrays(): + return self._object_to_ids(holder.get_array(query)) + else: + ids = self._ids_from_holder(holder) + else: + if query in self.keys(YellowPagesCategory.ARRAYS): + if mode is None: + arr_list: list[str] = [] + for a_mode in self._acc.modes(): + try: + obj = self._get_object(query, mode=a_mode) + array_ids = self._object_to_ids(obj) + self._extend_unique(arr_list, array_ids) + except Exception: + continue + return arr_list + + return self._object_to_ids(self._get_object(query, mode=mode)) + ids = self._all_known_ids() + + if query.startswith("re:"): + pattern = query[3:] + try: + rx = re.compile(pattern) + except re.error as ex: + raise YellowPagesQueryError(f"Invalid regex '{pattern}': {ex}") from ex - """ - ids = self._eval_query_to_ids(query) - return sorted(ids) + return [i for i in ids if rx.search(i)] - # --------------------------- - # Printing / introspection - # --------------------------- + return [i for i in ids if fnmatch.fnmatch(i, query)] def __repr__(self) -> str: - """ + r""" Return a human-readable overview of the YellowPages content. - The output lists controls, simulators and discovered objects - grouped by category. + The representation lists: + + - controls + - simulators + - discovered arrays, tools and diagnostics + + The displayed type corresponds to the Python module + defining the resolved object. Examples -------- .. code-block:: python - >>> print(sr.yellow_pages) + print(sr.yellow_pages) - Controls: - live - . + Example output: - Simulators: - design - . + .. code-block:: text - Arrays: - BPM (pyaml.arrays.bpm_array) size=224 - HCORR (pyaml.arrays.magnet_array) size=96 - . + Controls: + live + . - Tools: - DEFAULT_ORBIT_CORRECTION (pyaml.tuning_tools.orbit) - . + Simulators: + design + . - Diagnostics: - BETATRON_TUNE (pyaml.diagnostics.tune_monitor) - . + Arrays: + BPM (pyaml.arrays.bpm_array) size=224 + HCORR (pyaml.arrays.magnet_array) size=96 + . + + Tools: + DEFAULT_ORBIT_CORRECTION (pyaml.tuning_tools.orbit) + . + + Diagnostics: + BETATRON_TUNE (pyaml.diagnostics.tune_monitor) + . """ lines: list[str] = [] lines.append("Controls:") controls = self._acc.controls() if controls: - for name in sorted(controls.keys()): + for name in controls.keys(): lines.append(f" {name}") lines.append(" .") lines.append("") @@ -577,7 +465,7 @@ def __repr__(self) -> str: lines.append("Simulators:") simulators = self._acc.simulators() if simulators: - for name in sorted(simulators.keys()): + for name in simulators.keys(): lines.append(f" {name}") lines.append(" .") lines.append("") @@ -589,7 +477,7 @@ def __repr__(self) -> str: continue lines.append(f"{cat.value}:") - for key in sorted(keys): + for key in keys: lines.append(self._format_key(cat, key)) lines.append(" .") lines.append("") @@ -599,26 +487,22 @@ def __repr__(self) -> str: def __str__(self) -> str: return self.__repr__() - # --------------------------- - # Internals: discovery - # --------------------------- - - def _discover(self) -> dict[YellowPagesCategory, set[str]]: - arrays: set[str] = set() - tools: set[str] = set() - diags: set[str] = set() + def _discover(self) -> dict[YellowPagesCategory, list[str]]: + arrays: list[str] = [] + tools: list[str] = [] + diags: list[str] = [] for _, holder in self._acc.modes().items(): try: - arrays |= set(holder.list_arrays()) + self._extend_unique(arrays, holder.list_arrays()) except Exception: pass try: - tools |= set(holder.list_tools()) + self._extend_unique(tools, holder.list_tools()) except Exception: pass try: - diags |= set(holder.list_diagnostics()) + self._extend_unique(diags, holder.list_diagnostics()) except Exception: pass @@ -628,29 +512,38 @@ def _discover(self) -> dict[YellowPagesCategory, set[str]]: YellowPagesCategory.DIAGNOSTICS: diags, } - def _all_keys(self) -> set[str]: + def _extend_unique(self, target: list[str], values: list[str]) -> None: + """ + Append values to target while preserving insertion order and uniqueness. + """ + for value in values: + if value not in target: + target.append(value) + + def _all_keys(self) -> list[str]: discovered = self._discover() - out: set[str] = set() + out: list[str] = [] for keys in discovered.values(): - out |= set(keys) + self._extend_unique(out, keys) return out - # --------------------------- - # Internals: resolution - # --------------------------- - def _require_key(self, key: str) -> None: if key not in self._all_keys(): raise KeyError(self._unknown_key_message(key)) def _unknown_key_message(self, key: str) -> str: - available = ", ".join(sorted(self._all_keys())) - return ( - f"Unknown YellowPages key '{key}'. " - f"Available keys: {available if available else ''}" - ) + available = ", ".join(self._all_keys()) + return f"Unknown YellowPages key '{key}'. Available keys: {available if available else ''}" def _try_resolve_in_holder(self, key: str, holder: Any) -> Any | None: + """ + Resolve a discovered key in a holder. + + Resolution order: + - arrays + - tools + - diagnostics + """ try: if key in holder.list_arrays(): return holder.get_array(key) @@ -671,37 +564,41 @@ def _try_resolve_in_holder(self, key: str, holder: Any) -> Any | None: return None - # --------------------------- - # Internals: repr formatting - # --------------------------- - - def _get_type_name(self, key: str) -> str | None: + def _get_type_name_from_resolved(self, resolved: dict[str, Any]) -> str | None: """ - Determine the fully qualified type name of a YellowPages entry. + Return the public type name used in ``__repr__``. - The type is inferred from the first resolved object found across modes. - """ - resolved = self._get(key) + Only the module path is displayed, not the concrete class name. + + Examples + -------- + .. code-block:: text + + pyaml.arrays.bpm_array + pyaml.tuning_tools.orbit + pyaml.diagnostics.tune_monitor + """ for obj in resolved.values(): if obj is None: continue - - cls = obj.__class__ - return f"{cls.__module__}.{cls.__name__}" - + return obj.__class__.__module__ return None def _format_key(self, category: YellowPagesCategory, key: str) -> str: - type_name = self._get_type_name(key) + """ + Format one discovered key for ``__repr__``. + """ + resolved = self._get_object(key) + type_name = self._get_type_name_from_resolved(resolved) type_part = f" ({type_name})" if type_name else "" - resolved = self._get(key) # dict[mode,obj] where available - modes = sorted(resolved.keys()) - all_modes = sorted(self._acc.modes().keys()) + + modes = list(resolved.keys()) + all_modes = list(self._acc.modes().keys()) availability_part = "" if set(modes) != set(all_modes): - missing = sorted(set(all_modes) - set(modes)) + missing = [mode for mode in all_modes if mode not in modes] availability_part = f" modes={modes} missing={missing}" if category == YellowPagesCategory.ARRAYS: @@ -712,113 +609,78 @@ def _format_key(self, category: YellowPagesCategory, key: str) -> str: except Exception: sizes[mode_name] = 0 - if set(modes) == set(all_modes) and sizes and len(set(sizes.values())) == 1: + if modes == all_modes and sizes and len(set(sizes.values())) == 1: size_part = f" size={next(iter(sizes.values()))}" else: - size_part = ( - " size={" - + ", ".join(f"{m}:{n}" for m, n in sorted(sizes.items())) - + "}" - ) + size_part = " size={" + ", ".join(f"{m}:{n}" for m, n in sizes.items()) + "}" - return f" {key:<10}{type_part:<40}{size_part}{availability_part}" + return f" {key:<21}{type_part:<40}{size_part}{availability_part}" return f" {key}{type_part}{availability_part}" - # --------------------------- - # Internals: ID extraction - # --------------------------- - - def _object_to_ids(self, obj: Any) -> set[str]: + def _object_to_ids(self, obj: Any) -> list[str]: + """ + Convert a resolved object into a set of identifiers. + """ if obj is None: - return set() + return [] if isinstance(obj, (list, tuple, set)) and all(isinstance(x, str) for x in obj): - return set(obj) + return list(obj) - ids: set[str] = set() + ids: list[str] = list() try: for x in obj: if isinstance(x, str): - ids.add(x) + if x not in ids: + ids.append(x) elif hasattr(x, "get_name") and callable(x.get_name): - ids.add(x.get_name()) + if x.get_name() not in ids: + ids.append(x.get_name()) elif hasattr(x, "name") and callable(x.name): - ids.add(x.name()) + if x.name() not in ids: + ids.append(x.name()) elif hasattr(x, "name") and isinstance(x.name, str): - ids.add(x.name) + if x.name not in ids: + ids.append(x.name) else: - ids.add(str(x)) + if str(x) not in ids: + ids.append(str(x)) return ids except TypeError: if isinstance(obj, str): - return {obj} + return [obj] if hasattr(obj, "get_name") and callable(obj.get_name): - return {obj.get_name()} - return {str(obj)} + return [obj.get_name()] + return [str(obj)] - def _ids_for_key_union_all_modes(self, key: str) -> set[str]: - out: set[str] = set() - resolved = self._get(key) # dict[mode,obj] + def _ids_for_key_union_all_modes(self, key: str) -> list[str]: + out: list[str] = [] + resolved = self._get_object(key) for obj in resolved.values(): - out |= self._object_to_ids(obj) + self._extend_unique(out, self._object_to_ids(obj)) return out - def _all_known_ids(self) -> set[str]: - all_ids: set[str] = set() + def _all_known_ids(self) -> list[str]: + """ + Collect all identifiers from all discovered arrays across all modes. + """ + all_ids: list[str] = [] for array_name in self.keys(YellowPagesCategory.ARRAYS): try: - all_ids |= self._ids_for_key_union_all_modes(array_name) + self._extend_unique(all_ids, self._ids_for_key_union_all_modes(array_name)) except Exception: continue return all_ids - # --------------------------- - # Query evaluation - # --------------------------- - - def _eval_query_to_ids(self, expr: str) -> set[str]: - tokens = _tokenize(expr) - rpn = _to_rpn(tokens) - - stack: list[set[str]] = [] - for tok in rpn: - if tok in ("|", "&", "-"): - if len(stack) < 2: - raise YellowPagesQueryError( - f"Missing operand for operator '{tok}'." - ) - b = stack.pop() - a = stack.pop() - if tok == "|": - stack.append(a | b) - elif tok == "&": - stack.append(a & b) - else: - stack.append(a - b) - continue + def _ids_from_holder(self, holder) -> list[str]: + ids: list[str] = [] - # Regex operand (preferred re{...} or legacy re:...) - if tok.startswith("re{") or tok.startswith("re:"): - pattern = _extract_regex(tok) - try: - rx = re.compile(pattern) - except re.error as ex: - raise YellowPagesQueryError( - f"Invalid regex '{pattern}': {ex}" - ) from ex - - base = self._all_known_ids() - stack.append({i for i in base if rx.search(i)}) - continue - - # KEY operand - if tok not in self._all_keys(): - raise YellowPagesQueryError(self._unknown_key_message(tok)) - - stack.append(self._ids_for_key_union_all_modes(tok)) - - if len(stack) != 1: - raise YellowPagesQueryError("Invalid expression (remaining operands).") + try: + for name in holder.list_arrays(): + arr = holder.get_array(name) + self._extend_unique(ids, self._object_to_ids(arr)) + except Exception: + pass - return stack[0] + return ids diff --git a/tests/test_yellow_pages.py b/tests/test_yellow_pages.py index 5643761a..2cdb2053 100644 --- a/tests/test_yellow_pages.py +++ b/tests/test_yellow_pages.py @@ -1,6 +1,6 @@ # tests/test_yellow_pages.py # -# Self-contained tests for fully dynamic YellowPages (no register). +# Self-contained tests for fully dynamic YellowPages. # Comments are in English. import re @@ -19,6 +19,33 @@ # --------------------------- +class _Array: + """Minimal array-like object exposing names and a stable module path.""" + + __module__ = "pyaml.arrays.test_array" + + def __init__(self, names): + self._names = list(names) + + def __len__(self): + return len(self._names) + + def __iter__(self): + return iter(self._names) + + +class _Tool: + """Minimal tool-like object exposing a stable module path.""" + + __module__ = "pyaml.tuning_tools.test_tool" + + +class _Diagnostic: + """Minimal diagnostic-like object exposing a stable module path.""" + + __module__ = "pyaml.diagnostics.test_diagnostic" + + class _Holder: """Minimal ElementHolder double with discovery + resolution.""" @@ -28,14 +55,14 @@ def __init__(self, arrays=None, tools=None, diagnostics=None): self._diagnostics = dict(diagnostics or {}) # Discovery - def list_arrays(self) -> set[str]: - return set(self._arrays.keys()) + def list_arrays(self) -> list[str]: + return list(self._arrays.keys()) - def list_tools(self) -> set[str]: - return set(self._tools.keys()) + def list_tools(self) -> list[str]: + return list(self._tools.keys()) - def list_diagnostics(self) -> set[str]: - return set(self._diagnostics.keys()) + def list_diagnostics(self) -> list[str]: + return list(self._diagnostics.keys()) # Resolution def get_array(self, name: str): @@ -82,33 +109,35 @@ def accelerator(): # Controls live = _Holder( arrays={ - "BPM": ["BPM_C01-01", "BPM_C01-02", "BPM_C02-01"], - "HCORR": ["CH_C01-01", "CH_C01-02"], + "BPM": _Array(["BPM_C01-01", "BPM_C01-02", "BPM_C02-01"]), + "HCORR": _Array(["CH_C01-01", "CH_C01-02"]), }, - tools={"DEFAULT_ORBIT_CORRECTION": object()}, - diagnostics={"DEFAULT_BETATRON_TUNE_MONITOR": object()}, + tools={"DEFAULT_ORBIT_CORRECTION": _Tool()}, + diagnostics={"DEFAULT_BETATRON_TUNE_MONITOR": _Diagnostic()}, ) + tango = _Holder( arrays={ - "BPM": ["BPM_C01-01", "BPM_C01-02", "BPM_C02-01"], # same as live - # HCORR missing in tango + "BPM": _Array(["BPM_C01-01", "BPM_C01-02", "BPM_C02-01"]), + # HCORR intentionally missing in tango }, - tools={}, # tool missing - diagnostics={}, # diag missing + tools={}, + diagnostics={}, ) # Simulators design = _Holder( arrays={ - "BPM": ["BPM_C01-01", "BPM_C01-02", "BPM_C02-01"], - "HCORR": ["CH_C01-01"], # different size than live + "BPM": _Array(["BPM_C01-01", "BPM_C01-02", "BPM_C02-01"]), + "HCORR": _Array(["CH_C01-01"]), }, - tools={"DEFAULT_ORBIT_CORRECTION": object()}, - diagnostics={}, # missing + tools={"DEFAULT_ORBIT_CORRECTION": _Tool()}, + diagnostics={}, ) return _Accelerator( - controls={"live": live, "tango": tango}, simulators={"design": design} + controls={"live": live, "tango": tango}, + simulators={"design": design}, ) @@ -161,23 +190,53 @@ def test_getattr_returns_multimode_resolution_dict(yp): # --------------------------- +def test_get_object_without_mode_returns_only_available_modes(yp): + hcorr = yp._get_object("HCORR") + assert set(hcorr.keys()) == {"live", "design"} + + +def test_get_object_with_mode_returns_object_or_raises(yp): + bpm_live = yp._get_object("BPM", mode="live") + assert len(bpm_live) == 3 + + with pytest.raises(YellowPagesError, match=r"Unknown mode"): + yp._get_object("BPM", mode="does_not_exist") + + with pytest.raises(YellowPagesError, match=r"not available in mode 'tango'"): + yp._get_object("HCORR", mode="tango") + + def test_availability(yp): assert yp.availability("BPM") == {"live", "tango", "design"} assert yp.availability("HCORR") == {"live", "design"} +# --------------------------- +# Query API (public get + __getitem__) +# --------------------------- + + +def test_get_and_getitem_are_equivalent_for_wildcards(yp): + assert yp.get("BPM_C01*") == yp["BPM_C01*"] + + +def test_get_and_getitem_are_equivalent_for_regex(yp): + assert yp.get("re:^BPM_C01-0[12]$") == yp["re:^BPM_C01-0[12]$"] + + def test_unknown_key_errors(yp): - with pytest.raises(YellowPagesQueryError, match=r"Unknown YellowPages key"): - _ = yp["DOES_NOT_EXIST"] + with pytest.raises(KeyError, match=r"Unknown YellowPages key"): + yp._get_object("DOES_NOT_EXIST") # --------------------------- -# __repr__ formatting (controls/simulators + sizes + modes) +# __repr__ formatting (controls/simulators + types + sizes + modes) # --------------------------- def test_repr_has_controls_and_simulators_headers(yp): s = repr(yp) + assert "Controls:" in s assert "Simulators:" in s @@ -187,19 +246,17 @@ def test_repr_has_controls_and_simulators_headers(yp): def test_repr_array_size_compaction_when_identical_everywhere(yp): - # BPM has same size in all modes => should show "size=3" s = repr(yp) bpm_line = next(line for line in s.splitlines() if line.strip().startswith("BPM")) + assert "(pyaml.arrays.test_array)" in bpm_line assert "size=3" in bpm_line assert "size={" not in bpm_line def test_repr_array_size_dict_when_different_or_not_everywhere(yp): - # HCORR differs in size between live (2) and design (1) and missing in tango s = repr(yp) - hcorr_line = next( - line for line in s.splitlines() if line.strip().startswith("HCORR") - ) + hcorr_line = next(line for line in s.splitlines() if line.strip().startswith("HCORR")) + assert "(pyaml.arrays.test_array)" in hcorr_line assert "size={" in hcorr_line assert "live:2" in hcorr_line assert "design:1" in hcorr_line @@ -207,44 +264,33 @@ def test_repr_array_size_dict_when_different_or_not_everywhere(yp): assert "missing=" in hcorr_line -def test_repr_tools_show_modes_missing(yp): +def test_repr_tools_show_type_and_modes_missing(yp): s = repr(yp) - tool_line = next( - line for line in s.splitlines() if "DEFAULT_ORBIT_CORRECTION" in line - ) + tool_line = next(line for line in s.splitlines() if "DEFAULT_ORBIT_CORRECTION" in line) + assert "(pyaml.tuning_tools.test_tool)" in tool_line assert "modes=" in tool_line assert "missing=" in tool_line -# --------------------------- -# Query language (__getitem__) -# --------------------------- - - -def test_query_single_key_returns_union_of_ids_across_modes(yp): - out = yp["BPM"] - assert out == ["BPM_C01-01", "BPM_C01-02", "BPM_C02-01"] - - -def test_query_union_operator(yp): - out = yp["HCORR|BPM"] - assert "CH_C01-01" in out - assert "CH_C01-02" in out - assert "BPM_C01-01" in out +def test_repr_diagnostics_show_type(yp): + s = repr(yp) + diag_line = next(line for line in s.splitlines() if "DEFAULT_BETATRON_TUNE_MONITOR" in line) + assert "(pyaml.diagnostics.test_diagnostic)" in diag_line -def test_query_intersection_operator(yp): - assert yp["BPM&HCORR"] == [] +# --------------------------- +# Query language (wildcard + regex) +# --------------------------- -def test_query_difference_operator(yp): - out = yp["BPM - re{BPM_C01-01}"] - assert out == ["BPM_C01-02", "BPM_C02-01"] +def test_query_wildcard(yp): + out = yp["BPM_C01*"] + assert out == ["BPM_C01-01", "BPM_C01-02"] -def test_query_parentheses_precedence(yp): - out = yp["(BPM|HCORR) - re:CH_.*"] - assert out == ["BPM_C01-01", "BPM_C01-02", "BPM_C02-01"] +def test_query_regex(yp): + out = yp["re:^BPM_C01-0[12]$"] + assert out == ["BPM_C01-01", "BPM_C01-02"] def test_query_regex_alone_filters_all_known_ids(yp): @@ -252,19 +298,31 @@ def test_query_regex_alone_filters_all_known_ids(yp): assert out == ["BPM_C01-01", "BPM_C01-02", "BPM_C02-01"] -def test_query_tokenize_errors_raise(yp): +def test_query_wildcard_on_hcorr(yp): + out = yp["CH_C01*"] + assert out == ["CH_C01-01", "CH_C01-02"] + + +def test_query_empty_raises(yp): with pytest.raises(YellowPagesQueryError, match=r"Empty YellowPages query"): _ = yp[""] - with pytest.raises(YellowPagesQueryError, match=r"Cannot tokenize"): - _ = yp["BPM $$$"] + +def test_query_invalid_regex_raise(yp): + with pytest.raises(YellowPagesQueryError, match=r"Invalid regex"): + _ = yp["re:("] -def test_query_mismatched_parentheses_raise(yp): - with pytest.raises(YellowPagesQueryError, match=r"Mismatched parentheses"): - _ = yp["(BPM|HCORR"] +def test_query_with_mode_wildcard(yp): + out = yp.get("CH_C01*", mode="live") + assert out == ["CH_C01-01", "CH_C01-02"] -def test_query_invalid_regex_raise(yp): - with pytest.raises(YellowPagesQueryError, match=r"Invalid regex"): - _ = yp["re{(}"] +def test_query_with_mode_regex(yp): + out = yp.get("re:^BPM_C01", mode="design") + assert out == ["BPM_C01-01", "BPM_C01-02"] + + +def test_query_with_unknown_mode(yp): + with pytest.raises(YellowPagesError, match=r"Unknown mode"): + yp.get("BPM*", mode="invalid") diff --git a/tests/test_yellow_pages_basic.py b/tests/test_yellow_pages_basic.py index b4ba2ccb..6d88bfc1 100644 --- a/tests/test_yellow_pages_basic.py +++ b/tests/test_yellow_pages_basic.py @@ -12,20 +12,20 @@ . Arrays: - BPM (pyaml.arrays.bpm_array.BPMArray) size=320 - HCorr (pyaml.arrays.magnet_array.MagnetArray) size=288 - Skews (pyaml.arrays.magnet_array.MagnetArray) size=288 - VCorr (pyaml.arrays.magnet_array.MagnetArray) size=288 + BPM (pyaml.arrays.bpm_array) size=320 + HCorr (pyaml.arrays.magnet_array) size=288 + VCorr (pyaml.arrays.magnet_array) size=288 + Skews (pyaml.arrays.magnet_array) size=288 . Tools: - DEFAULT_DISPERSION (pyaml.tuning_tools.dispersion.Dispersion) - DEFAULT_ORBIT_CORRECTION (pyaml.tuning_tools.orbit.Orbit) - DEFAULT_ORBIT_RESPONSE_MATRIX (pyaml.tuning_tools.orbit_response_matrix.OrbitResponseMatrix) + DEFAULT_ORBIT_CORRECTION (pyaml.tuning_tools.orbit) + DEFAULT_ORBIT_RESPONSE_MATRIX (pyaml.tuning_tools.orbit_response_matrix) + DEFAULT_DISPERSION (pyaml.tuning_tools.dispersion) . Diagnostics: - BETATRON_TUNE (pyaml.diagnostics.tune_monitor.BetatronTuneMonitor) + BETATRON_TUNE (pyaml.diagnostics.tune_monitor) .""" @@ -35,4 +35,16 @@ def test_load_conf_with_code(): sr: Accelerator = Accelerator.load(config_path) str_repr = str(sr.yellow_pages) + print(str_repr) assert ebs_orbit_str_repr == str_repr + + +def test_soleil(): + parent_folder = Path(__file__).parent + config_path = parent_folder.joinpath("p.yaml").resolve() + sr: Accelerator = Accelerator.load(config_path) + str_repr = str(sr.yellow_pages) + print(str_repr) + print(sr.modes()) + print(f"BPM={sr.yellow_pages.get('BPM', mode='design')}") + print(f"\n\nCell1={sr.yellow_pages['Cell1']}") From b74e98621cfbfcd1228b027969233cb0847f457a Mon Sep 17 00:00:00 2001 From: guillaumepichon Date: Fri, 13 Mar 2026 13:18:38 +0100 Subject: [PATCH 05/11] Test correction --- tests/test_yellow_pages_basic.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/tests/test_yellow_pages_basic.py b/tests/test_yellow_pages_basic.py index 6d88bfc1..17ae3879 100644 --- a/tests/test_yellow_pages_basic.py +++ b/tests/test_yellow_pages_basic.py @@ -37,14 +37,3 @@ def test_load_conf_with_code(): str_repr = str(sr.yellow_pages) print(str_repr) assert ebs_orbit_str_repr == str_repr - - -def test_soleil(): - parent_folder = Path(__file__).parent - config_path = parent_folder.joinpath("p.yaml").resolve() - sr: Accelerator = Accelerator.load(config_path) - str_repr = str(sr.yellow_pages) - print(str_repr) - print(sr.modes()) - print(f"BPM={sr.yellow_pages.get('BPM', mode='design')}") - print(f"\n\nCell1={sr.yellow_pages['Cell1']}") From 88476f0600ec84cd3dac918f9c2a2c5339e84ec3 Mon Sep 17 00:00:00 2001 From: guillaumepichon Date: Fri, 13 Mar 2026 18:09:12 +0100 Subject: [PATCH 06/11] Documentation generation for yellow_pages.py and element_array.py --- pyaml/apidoc/gen_api.py | 17 +++++------------ pyaml/arrays/element_array.py | 33 +++++++++++---------------------- pyaml/yellow_pages.py | 34 +++++++++++++++++----------------- 3 files changed, 33 insertions(+), 51 deletions(-) diff --git a/pyaml/apidoc/gen_api.py b/pyaml/apidoc/gen_api.py index 3a4cd81a..2b816107 100644 --- a/pyaml/apidoc/gen_api.py +++ b/pyaml/apidoc/gen_api.py @@ -82,6 +82,7 @@ "pyaml.tuning_tools.orbit_response_matrix", "pyaml.tuning_tools.response_matrix", "pyaml.tuning_tools.tune", + "pyaml.yellow_pages", ] @@ -111,17 +112,13 @@ def generate_selective_module(m): file.write(f".. automodule:: {p.__name__}\n") if len(all_cls) > 0: # Exclude classes that will be treated by autoclass - file.write( - f" :exclude-members: {','.join([c.__name__ for c in all_cls])}\n\n" - ) + file.write(f" :exclude-members: {','.join([c.__name__ for c in all_cls])}\n\n") for c in all_cls: file.write(f" .. autoclass:: {c.__name__}\n") file.write(" :members:\n") if m in ["pyaml.arrays.element_array"]: # Include special members for operator overloading - file.write( - " :special-members: __add__, __and__, __or__, __sub__ \n" - ) + file.write(" :special-members: __add__, __and__, __or__, __sub__ \n") file.write(" :exclude-members: model_config\n") file.write(" :undoc-members:\n") file.write(" :show-inheritance:\n\n") @@ -129,9 +126,7 @@ def generate_selective_module(m): def generate_toctree(filename: str, title: str, level: int, module: str): sub_modules = [m for m in modules if m.startswith(module)] - level_path = sorted( - set([m.split(".")[level + 1 : level + 2][0] for m in sub_modules]) - ) + level_path = sorted(set([m.split(".")[level + 1 : level + 2][0] for m in sub_modules])) paths = [] @@ -166,9 +161,7 @@ def gen_doc(): while len(paths) > 0: npaths = [] for p in paths: - npaths.extend( - generate_toctree(f"api/{'pyaml.' + p}.rst", f"{p}", level, "pyaml." + p) - ) + npaths.extend(generate_toctree(f"api/{'pyaml.' + p}.rst", f"{p}", level, "pyaml." + p)) paths = npaths level += 1 print("done") diff --git a/pyaml/arrays/element_array.py b/pyaml/arrays/element_array.py index d9415729..8a3a4770 100644 --- a/pyaml/arrays/element_array.py +++ b/pyaml/arrays/element_array.py @@ -96,9 +96,7 @@ def __create_array(self, array_name: str, element_type: type, elements: list): elif issubclass(element_type, Element): return ElementArray(array_name, elements, self.__use_aggregator) else: - raise PyAMLException( - f"Unsupported sliced array for type {str(element_type)}" - ) + raise PyAMLException(f"Unsupported sliced array for type {str(element_type)}") def __eval_field(self, attribute_name: str, element: Element) -> str: function_name = "get_" + attribute_name @@ -109,16 +107,14 @@ def __ensure_compatible_operand(self, other: object) -> "ElementArray": """Validate the operand used for set-like operations between arrays.""" if not isinstance(other, ElementArray): raise TypeError( - f"Unsupported operand type(s) for set operation: " - f"'{type(self).__name__}' and '{type(other).__name__}'" + f"Unsupported operand type(s) for set operation: '{type(self).__name__}' and '{type(other).__name__}'" ) if len(self) > 0 and len(other) > 0: if self.get_peer() is not None and other.get_peer() is not None: if self.get_peer() != other.get_peer(): raise PyAMLException( - f"{self.__class__.__name__}: cannot operate on arrays " - "attached to different peers" + f"{self.__class__.__name__}: cannot operate on arrays attached to different peers" ) return other @@ -170,9 +166,7 @@ def __is_bool_mask(self, other: object) -> bool: pass # --- python sequence of bools (but not a string/bytes) --- - if isinstance(other, Sequence) and not isinstance( - other, (str, bytes, bytearray) - ): + if isinstance(other, Sequence) and not isinstance(other, (str, bytes, bytearray)): # Avoid treating ElementArray as a mask if isinstance(other, ElementArray): return False @@ -195,8 +189,7 @@ def __and__(self, other: object): If ``other`` is an ElementArray, the result contains elements whose names are present in both arrays. - Example - ------- + **Example** .. code-block:: python @@ -208,8 +201,8 @@ def __and__(self, other: object): If ``other`` is a boolean mask (list[bool] or numpy.ndarray of bool), elements are kept where the mask is True. - Example - ------- + **Example** + .. code-block:: python >>> mask = cell1.mask_by_type(Magnet) @@ -232,8 +225,7 @@ def __and__(self, other: object): mask = list(other) # works for list/tuple and numpy arrays if len(mask) != len(self): raise ValueError( - f"{self.__class__.__name__}: mask length ({len(mask)}) " - f"does not match array length ({len(self)})" + f"{self.__class__.__name__}: mask length ({len(mask)}) does not match array length ({len(self)})" ) res = [e for e, keep in zip(self, mask, strict=True) if bool(keep)] return self.__auto_array(res) @@ -261,8 +253,7 @@ def __sub__(self, other: object): If ``other`` is an ElementArray, the result contains elements whose names are present in ``self`` but not in ``other``. - Example - ------- + **Example** .. code-block:: python @@ -275,8 +266,7 @@ def __sub__(self, other: object): elements are removed where the mask is True. This is the inverse of ``& mask``. - Example - ------- + **Example** .. code-block:: python @@ -300,8 +290,7 @@ def __sub__(self, other: object): mask = list(other) if len(mask) != len(self): raise ValueError( - f"{self.__class__.__name__}: mask length ({len(mask)}) " - f"does not match array length ({len(self)})" + f"{self.__class__.__name__}: mask length ({len(mask)}) does not match array length ({len(self)})" ) res = [e for e, remove in zip(self, mask, strict=True) if not bool(remove)] return self.__auto_array(res) diff --git a/pyaml/yellow_pages.py b/pyaml/yellow_pages.py index e68fa28c..d4b44656 100644 --- a/pyaml/yellow_pages.py +++ b/pyaml/yellow_pages.py @@ -4,29 +4,29 @@ Fully dynamic YellowPages service attached to Accelerator. Key points: -- Auto-discovery only: arrays, tools and diagnostics are discovered at runtime - by scanning all modes. -- No caching: every call reflects current runtime state. -- Simple query syntax for identifiers: - - wildcard / fnmatch: - yp["OH4*"] - - regular expression: - yp["re:^SH1A-C0[12]-H$"] + - Auto-discovery only: arrays, tools and diagnostics are discovered at runtime + by scanning all modes. + - No caching: every call reflects current runtime state. + - Simple query syntax for identifiers: + - wildcard / fnmatch: + yp["OH4*"] + - regular expression: + yp["re:^SH1A-C0[12]-H$"] Expected Accelerator interface ------------------------------ -- controls() -> dict[str, ElementHolder] -- simulators() -> dict[str, ElementHolder] -- modes() -> dict[str, ElementHolder] + - controls() -> dict[str, ElementHolder] + - simulators() -> dict[str, ElementHolder] + - modes() -> dict[str, ElementHolder] Expected ElementHolder interface -------------------------------- -- list_arrays() -> set[str] -- list_tools() -> set[str] -- list_diagnostics() -> set[str] -- get_array(name: str) -> Any -- get_tool(name: str) -> Any -- get_diagnostic(name: str) -> Any + - list_arrays() -> set[str] + - list_tools() -> set[str] + - list_diagnostics() -> set[str] + - get_array(name: str) -> Any + - get_tool(name: str) -> Any + - get_diagnostic(name: str) -> Any """ from __future__ import annotations From d06bb523967d04c1ff58f193e557c28585eabebf Mon Sep 17 00:00:00 2001 From: guillaumepichon Date: Mon, 16 Mar 2026 11:15:29 +0100 Subject: [PATCH 07/11] Typing: _acc is now an "Accelerator" --- pyaml/yellow_pages.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/pyaml/yellow_pages.py b/pyaml/yellow_pages.py index d4b44656..29eb5939 100644 --- a/pyaml/yellow_pages.py +++ b/pyaml/yellow_pages.py @@ -29,15 +29,16 @@ - get_diagnostic(name: str) -> Any """ -from __future__ import annotations - import fnmatch import re from enum import Enum -from typing import Any +from typing import TYPE_CHECKING, Any from pyaml import PyAMLException +if TYPE_CHECKING: + from .accelerator import Accelerator + class YellowPagesCategory(str, Enum): ARRAYS = "Arrays" @@ -116,7 +117,7 @@ class YellowPages: sr.yellow_pages["re:^SH1A-C0[12]-H$"] """ - def __init__(self, accelerator: Any): + def __init__(self, accelerator: "Accelerator"): self._acc = accelerator def has(self, key: str) -> bool: From 805fa0ad85c3adc99e0227d30154b1160e4bd240 Mon Sep 17 00:00:00 2001 From: guillaumepichon Date: Mon, 16 Mar 2026 11:21:08 +0100 Subject: [PATCH 08/11] Doc: include special members for exploratory overloading --- pyaml/apidoc/gen_api.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyaml/apidoc/gen_api.py b/pyaml/apidoc/gen_api.py index 2b816107..cccef14e 100644 --- a/pyaml/apidoc/gen_api.py +++ b/pyaml/apidoc/gen_api.py @@ -119,6 +119,9 @@ def generate_selective_module(m): if m in ["pyaml.arrays.element_array"]: # Include special members for operator overloading file.write(" :special-members: __add__, __and__, __or__, __sub__ \n") + if m in ["pyaml.yellow_pages"]: + # Include special members for exploratory overloading + file.write(" :special-members: __dir__, __getattr__, __getitem__, __repr__, __str__ \n") file.write(" :exclude-members: model_config\n") file.write(" :undoc-members:\n") file.write(" :show-inheritance:\n\n") From 69c1dffe58748ceb4afd0ac64668b1765f87a447 Mon Sep 17 00:00:00 2001 From: guillaumepichon Date: Mon, 16 Mar 2026 16:34:59 +0100 Subject: [PATCH 09/11] Setting the `ElementHolder` discovery methods to protected. --- pyaml/common/element_holder.py | 12 ++++++------ pyaml/yellow_pages.py | 26 +++++++++++++------------- tests/test_yellow_pages.py | 12 ++++++------ 3 files changed, 25 insertions(+), 25 deletions(-) diff --git a/pyaml/common/element_holder.py b/pyaml/common/element_holder.py index a9d159aa..72abd0b2 100644 --- a/pyaml/common/element_holder.py +++ b/pyaml/common/element_holder.py @@ -309,7 +309,7 @@ def add_dispersion_tuning(self, dispersion: Element): def dispersion(self) -> "Dispersion": return self.get_dispersion_tuning("DEFAULT_DISPERSION") - def get_array(self, name: str): + def _get_array(self, name: str): """ Generic array resolver used by YellowPages. @@ -329,7 +329,7 @@ def get_array(self, name: str): raise PyAMLException(f"Array {name} not defined") - def get_tool(self, name: str): + def _get_tool(self, name: str): """ Generic tuning tool resolver used by YellowPages. """ @@ -337,7 +337,7 @@ def get_tool(self, name: str): raise PyAMLException(f"Tool {name} not defined") return self.__TUNING_TOOLS[name] - def get_diagnostic(self, name: str): + def _get_diagnostic(self, name: str): """ Generic diagnostic resolver used by YellowPages. """ @@ -345,7 +345,7 @@ def get_diagnostic(self, name: str): raise PyAMLException(f"Diagnostic {name} not defined") return self.__DIAG[name] - def list_arrays(self) -> list[str]: + def _list_arrays(self) -> list[str]: """ Return all array identifiers available in this holder. """ @@ -357,13 +357,13 @@ def list_arrays(self) -> list[str]: arrays.extend(self.__ELEMENT_ARRAYS.keys()) return arrays - def list_tools(self) -> list[str]: + def _list_tools(self) -> list[str]: """ Return all tuning tool identifiers available in this holder. """ return list(self.__TUNING_TOOLS.keys()) - def list_diagnostics(self) -> list[str]: + def _list_diagnostics(self) -> list[str]: """ Return all diagnostic identifiers available in this holder. """ diff --git a/pyaml/yellow_pages.py b/pyaml/yellow_pages.py index 29eb5939..672fc087 100644 --- a/pyaml/yellow_pages.py +++ b/pyaml/yellow_pages.py @@ -377,8 +377,8 @@ def get(self, query: str, mode: str | None = None) -> list[str]: if holder is None: raise YellowPagesError(f"Unknown mode '{mode}'.") - if query in holder.list_arrays(): - return self._object_to_ids(holder.get_array(query)) + if query in holder._list_arrays(): + return self._object_to_ids(holder._get_array(query)) else: ids = self._ids_from_holder(holder) else: @@ -495,15 +495,15 @@ def _discover(self) -> dict[YellowPagesCategory, list[str]]: for _, holder in self._acc.modes().items(): try: - self._extend_unique(arrays, holder.list_arrays()) + self._extend_unique(arrays, holder._list_arrays()) except Exception: pass try: - self._extend_unique(tools, holder.list_tools()) + self._extend_unique(tools, holder._list_tools()) except Exception: pass try: - self._extend_unique(diags, holder.list_diagnostics()) + self._extend_unique(diags, holder._list_diagnostics()) except Exception: pass @@ -546,20 +546,20 @@ def _try_resolve_in_holder(self, key: str, holder: Any) -> Any | None: - diagnostics """ try: - if key in holder.list_arrays(): - return holder.get_array(key) + if key in holder._list_arrays(): + return holder._get_array(key) except Exception: pass try: - if key in holder.list_tools(): - return holder.get_tool(key) + if key in holder._list_tools(): + return holder._get_tool(key) except Exception: pass try: - if key in holder.list_diagnostics(): - return holder.get_diagnostic(key) + if key in holder._list_diagnostics(): + return holder._get_diagnostic(key) except Exception: pass @@ -678,8 +678,8 @@ def _ids_from_holder(self, holder) -> list[str]: ids: list[str] = [] try: - for name in holder.list_arrays(): - arr = holder.get_array(name) + for name in holder._list_arrays(): + arr = holder._get_array(name) self._extend_unique(ids, self._object_to_ids(arr)) except Exception: pass diff --git a/tests/test_yellow_pages.py b/tests/test_yellow_pages.py index 2cdb2053..9aa31cb1 100644 --- a/tests/test_yellow_pages.py +++ b/tests/test_yellow_pages.py @@ -55,27 +55,27 @@ def __init__(self, arrays=None, tools=None, diagnostics=None): self._diagnostics = dict(diagnostics or {}) # Discovery - def list_arrays(self) -> list[str]: + def _list_arrays(self) -> list[str]: return list(self._arrays.keys()) - def list_tools(self) -> list[str]: + def _list_tools(self) -> list[str]: return list(self._tools.keys()) - def list_diagnostics(self) -> list[str]: + def _list_diagnostics(self) -> list[str]: return list(self._diagnostics.keys()) # Resolution - def get_array(self, name: str): + def _get_array(self, name: str): if name not in self._arrays: raise KeyError(name) return self._arrays[name] - def get_tool(self, name: str): + def _get_tool(self, name: str): if name not in self._tools: raise KeyError(name) return self._tools[name] - def get_diagnostic(self, name: str): + def _get_diagnostic(self, name: str): if name not in self._diagnostics: raise KeyError(name) return self._diagnostics[name] From 10a5984919f4fffaf5cf2b68163c045253e0f7c9 Mon Sep 17 00:00:00 2001 From: guillaumepichon Date: Tue, 17 Mar 2026 10:30:05 +0100 Subject: [PATCH 10/11] Doc enhancement --- pyaml/yellow_pages.py | 41 +++++++++++++---------------------------- 1 file changed, 13 insertions(+), 28 deletions(-) diff --git a/pyaml/yellow_pages.py b/pyaml/yellow_pages.py index 672fc087..cc8793aa 100644 --- a/pyaml/yellow_pages.py +++ b/pyaml/yellow_pages.py @@ -1,7 +1,7 @@ """ yellow_pages.py -Fully dynamic YellowPages service attached to Accelerator. +Fully dynamic YellowPages service attached to :class:`~pyaml.accelerator.Accelerator`. Key points: - Auto-discovery only: arrays, tools and diagnostics are discovered at runtime @@ -13,20 +13,11 @@ - regular expression: yp["re:^SH1A-C0[12]-H$"] -Expected Accelerator interface +:class:`~pyaml.accelerator.Accelerator` interface ------------------------------ - controls() -> dict[str, ElementHolder] - simulators() -> dict[str, ElementHolder] - modes() -> dict[str, ElementHolder] - -Expected ElementHolder interface --------------------------------- - - list_arrays() -> set[str] - - list_tools() -> set[str] - - list_diagnostics() -> set[str] - - get_array(name: str) -> Any - - get_tool(name: str) -> Any - - get_diagnostic(name: str) -> Any """ import fnmatch @@ -67,7 +58,7 @@ class YellowPages: Entries are discovered dynamically by scanning all :class:`~pyaml.element_holder.ElementHolder` instances - associated with the accelerator control and simulation modes. + associated with the :class:`~pyaml.accelerator.Accelerator` control and simulation modes. Notes ----- @@ -77,12 +68,6 @@ class YellowPages: - ``simulators() -> dict[str, ElementHolder]`` - ``modes() -> dict[str, ElementHolder]`` - Each :class:`~pyaml.element_holder.ElementHolder` must implement: - - - ``list_arrays()`` / ``get_array(name)`` - - ``list_tools()`` / ``get_tool(name)`` - - ``list_diagnostics()`` / ``get_diagnostic(name)`` - Examples -------- @@ -90,31 +75,31 @@ class YellowPages: .. code-block:: python - print(sr.yellow_pages) + >>> print(sr.yellow_pages) Resolve an entry across all modes: .. code-block:: python - sr.yellow_pages.get("BPM") + >>> sr.yellow_pages.get("BPM") Resolve in a specific mode: .. code-block:: python - sr.yellow_pages.get("BPM", mode="live") + >>> sr.yellow_pages.get("BPM", mode="live") Search identifiers using wildcards: .. code-block:: python - sr.yellow_pages["OH4*"] + >>> sr.yellow_pages["OH4*"] Search identifiers using a regular expression: .. code-block:: python - sr.yellow_pages["re:^SH1A-C0[12]-H$"] + >>> sr.yellow_pages["re:^SH1A-C0[12]-H$"] """ def __init__(self, accelerator: "Accelerator"): @@ -232,7 +217,7 @@ def __getattr__(self, name): .. code-block:: python - sr.yellow_pages.BPM + >>> sr.yellow_pages.BPM """ if name in self._all_keys(): return self._get_object(name) @@ -256,8 +241,8 @@ def availability(self, key: str) -> set[str]: .. code-block:: python - sr.yellow_pages.availability("BPM") - sr.yellow_pages.availability("DEFAULT_ORBIT_CORRECTION") + >>> sr.yellow_pages.availability("BPM") + >>> sr.yellow_pages.availability("DEFAULT_ORBIT_CORRECTION") """ self._require_key(key) avail: set[str] = set() @@ -349,7 +334,7 @@ def get(self, query: str, mode: str | None = None) -> list[str]: query : str Search expression. mode : str, optional - Restrict the search to a specific accelerator mode. + Restrict the search to a specific :class:`~pyaml.accelerator.Accelerator` mode. Returns ------- @@ -426,7 +411,7 @@ def __repr__(self) -> str: .. code-block:: python - print(sr.yellow_pages) + >>> print(sr.yellow_pages) Example output: From cbe344a192fc22be75bf9fa68f693e0dc200b455 Mon Sep 17 00:00:00 2001 From: guillaumepichon Date: Tue, 17 Mar 2026 11:14:37 +0100 Subject: [PATCH 11/11] Doc enhancement, correction of a wrong copy/paste --- pyaml/accelerator.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pyaml/accelerator.py b/pyaml/accelerator.py index 87be1045..c82c355d 100644 --- a/pyaml/accelerator.py +++ b/pyaml/accelerator.py @@ -169,15 +169,15 @@ def yellow_pages(self) -> YellowPages: return self._yellow_pages def simulators(self) -> dict[str, "ElementHolder"]: - """Return all registered control/simulator modes.""" + """Return all registered simulator modes.""" return self._simulators def controls(self) -> dict[str, "ElementHolder"]: - """Return all registered control/simulator modes.""" + """Return all registered control modes.""" return self._controls def modes(self) -> dict[str, "ElementHolder"]: - """Return all registered control/simulator modes.""" + """Return all registered control and simulator modes.""" modes: dict[str, "ElementHolder"] = {} modes.update(self._simulators) modes.update(self._controls)