diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..ab9c86b --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,40 @@ +name: Build and deploy accml_lib docs + +on: + push: + branches: [ main ] # build on pushes to main (adjust as needed) + pull_request: + branches: [ main ] + +jobs: + build-deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + submodules: true + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.11' # choose your supported version + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python3 -m pip install -e ./[bluesky-epics,docs,pyat-simulator] + + - name: Build docs + working-directory: docs + run: | + python3 -m sphinx.ext.apidoc -o src/ ../src/accml_lib/ || echo "failed to create auto/API docs!" + python3 -m sphinx.cmd.build --conf-dir ./ ./ _build/html/ + + - name: Deploy to gh-pages + uses: peaceiris/actions-gh-pages@v4 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./docs/_build/html + # optional: cname: docs.example.com diff --git a/.gitignore b/.gitignore index 9e6e80f..563cf63 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,11 @@ # emacs autosave files *~ +# doc build files +doc/_build/ +# put api auto generated doc here +doc/src/ + # files for running fixtures fixtures/ diff --git a/README.md b/README.md index 3e0e115..2670103 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# accml: Accelerator middle layer +# accml_lib: Particle accelerator middle layer: library part `accml` is a software stack designed to facilitate implementing tools characterising (high) energy charged accelerator. @@ -10,6 +10,9 @@ These tools typically address: For details of its concept see [design.md](https://github.com/python-accelerator-middle-layer/accml/design.md). +Additional [![Documentation](https://github.com/python-accelerator-middle-layer/accml_lib/actions/workflows/docs.yml/badge.svg)](https://python-accelerator-middle-layer.github.io/accml_lib/) + + ## 🚀 Installation and Running Instructions ### 1. Clone the Repository @@ -25,9 +28,31 @@ git checkout dev/main git submodule update --init --recursive ``` ### 3. Install the Package + +Please note: typically, especially as a user, you would install +accml, which in turn will install accml_lib. So typically +you want to look to https://github.com/python-accelerator-middle-layer/accml +and install everything there + +#### 3.1 Installing only accml_lib for an EPICS facility + +For an EPICS facility install + ```bash -python3 -m pip install -e . +python3 -m pip install -e \ + ./[bluesky-epics,pyat-simulator] ``` + +#### 3.1 Installing only accml_lib for a TANGO facility + +**NB** this installation is not yet tested. In case of +experiencing trouble please drop us a line or share your +experience in case of success. +```bash +python3 -m pip install -e \ + ./[bluesky-tango,pyat-simulator] +``` + ### 4. Run the Virtual Accelerator (Test bench) --EPICS VERSION ```bash apptainer run oras://registry.hzdr.de/digital-twins-for-accelerators/containers/pyat-softioc-digital-twin:v0-1-2-bessy.2475331 diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d4bb2cb --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = . +BUILDDIR = _build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..62f1ad5 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,55 @@ +import os +import sys +# so accml_lib is found +sys.path.insert(0, os.path.abspath('../../')) + +project = 'accml_lib' +copyright = '2026, Helmholtz Zentrum Berlin' +author = 'Pierre Schnizer, Waheedullah Sulaiman Khail' + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.napoleon', + 'sphinx.ext.viewcode', + 'sphinx.ext.intersphinx', + 'sphinx_autodoc_typehints', + 'sphinx.ext.autosummary', + "sphinx.ext.todo", + "sphinxcontrib.bibtex", +] +bibtex_bibfiles = ["refs.bib"] + +# Optional formatting settings: +bibtex_default_style = "unsrt" # or "alpha", "plain", etc. +bibtex_reference_style = "author_year" # controls :cite: rendering style + +autosummary_generate = True +autodoc_typehints = 'description' +html_theme = 'sphinx_rtd_theme' + +templates_path = ['_templates'] +exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] + + +# optional: mock imports if your package has heavy optional deps +# autodoc_mock_imports = ['numpy', 'scipy', "lat2db", "acclerator-toolbox"] + + +# Intersphinx configuration: cross-reference external docs +intersphinx_mapping = { + "python": ("https://docs.python.org/3", None), + "numpy": ("https://numpy.org/doc/stable", None), + "scipy": ("https://docs.scipy.org/doc/scipy", None), +} + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +html_static_path = ['_static'] + +# -- Options for todo extension ---------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/extensions/todo.html#configuration + +todo_include_todos = True diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..0dd7279 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,16 @@ +accml\_lib: documentation +========================= + +accml contains all the modules which are used by +client code or twin. + +Its general concepts are explained in +the design document of `accml`. + +API +--- + +.. toctree:: + :maxdepth: 4 + + src/accml_lib diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..32bb245 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=. +set BUILDDIR=_build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/pyproject.toml b/pyproject.toml index f47b902..edc1f8a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,8 +60,18 @@ transitions = {version ="*", optional = true} # for testing pytest-asyncio = {version = "*", optional = true} +# for documentation +sphinx = {version = ">=5", optional = true} +sphinx-rtd-theme = {version = "*", optional = true} +sphinx-autodoc-typehints = {version = "*", optional = true} +sphinxcontrib-napoleon = {version = "*", optional = true} +sphinxcontrib-bibtex = {version = "*", optional = true} + + [tool.poetry.extras] bluesky-epics = ["ophyd-async", "aioca", "p4p", "epics", "bluesky", "databroker"] bluesky-tango = ["ophyd-async", "pytango", "bluesky", "databroker"] pyat-simulator = ["accelerator-toolbox", "transitions"] -testing = ["pytest-asyncio", "accelerator-toolbox", "transitions"] \ No newline at end of file +testing = ["pytest-asyncio", "accelerator-toolbox", "transitions"] + +docs = ["sphinx", "sphinx-rtd-theme", "sphinx-autodoc-typehints", "sphinxcontrib-napoleon", "sphinxcontrib-bibtex"] \ No newline at end of file diff --git a/src/accml_lib/__init__.py b/src/accml_lib/__init__.py index e69de29..359fccd 100644 --- a/src/accml_lib/__init__.py +++ b/src/accml_lib/__init__.py @@ -0,0 +1 @@ +__all__ = ["core", "custom"] \ No newline at end of file diff --git a/src/accml_lib/core/__init__.py b/src/accml_lib/core/__init__.py index e69de29..83b6aae 100644 --- a/src/accml_lib/core/__init__.py +++ b/src/accml_lib/core/__init__.py @@ -0,0 +1,18 @@ +"""accm\_lib core packages + +The `core` contains the modules which +are to be independent of any facility +and form the basis of `àccml\_lib`. + +It contains the following main parts: + +* models: data models used within the package +* bl: business logic or modules that provide + basic functionality +* config: configuration data + +Interfaces are used to export the interfaces +used within this package. +""" + +__all__ = ["model", "config", "bl", "interfaces"] \ No newline at end of file diff --git a/src/accml_lib/core/bl/__init__.py b/src/accml_lib/core/bl/__init__.py index e69de29..f7a490f 100644 --- a/src/accml_lib/core/bl/__init__.py +++ b/src/accml_lib/core/bl/__init__.py @@ -0,0 +1,8 @@ +__all__ = [ + "yellow_pages", + "liaison_manager", + "translator_service", + "command_rewritter", + "unit_conversion", + "delta_backend" +] \ No newline at end of file diff --git a/src/accml_lib/core/bl/command_rewritter.py b/src/accml_lib/core/bl/command_rewritter.py index d534080..8d6cd5b 100644 --- a/src/accml_lib/core/bl/command_rewritter.py +++ b/src/accml_lib/core/bl/command_rewritter.py @@ -2,9 +2,6 @@ Please note: here we have to map (lattice_name, property) -> (device_name, property) - -Todo: - Split up content in different modules """ from typing import Sequence @@ -14,7 +11,11 @@ from ...core.interfaces.utils.liaison_manager import LiaisonManagerBase from ...core.interfaces.utils.translator_service import TranslatorServiceBase from ...core.model.utils.command import Command -from ...core.model.utils.identifiers import DevicePropertyID, LatticeElementPropertyID, ConversionID +from ...core.model.utils.identifiers import ( + DevicePropertyID, + LatticeElementPropertyID, + ConversionID, +) class CommandRewriter(CommandRewriterBase): @@ -28,7 +29,11 @@ class CommandRewriter(CommandRewriterBase): to convert the command values between representations. """ - def __init__(self, liaison_manager: LiaisonManagerBase, translation_service: TranslatorServiceBase): + def __init__( + self, + liaison_manager: LiaisonManagerBase, + translation_service: TranslatorServiceBase, + ): """ Initialize the CommandRewriter with the required services. @@ -49,17 +54,24 @@ def inverse(self, cmd: Command) -> Sequence[Command]: Returns: A sequence of commands corresponding to the inverse translations. """ - dev_prop_id = DevicePropertyID( - device_name=cmd.id, property=cmd.property - ) + dev_prop_id = DevicePropertyID(device_name=cmd.id, property=cmd.property) rcmd = self.inverse_read_command(cmd) - lat_prop_ids = [LatticeElementPropertyID(element_name=r.id, property=r.property) for r in rcmd] - - return [self.inverse_translate_one(cmd, dev_prop_id, lat_prop_id) for lat_prop_id in lat_prop_ids] - - def inverse_translate_one(self, cmd: Command, dev_prop_id: DevicePropertyID, - lat_prop_id: LatticeElementPropertyID - ) -> Command: + lat_prop_ids = [ + LatticeElementPropertyID(element_name=r.id, property=r.property) + for r in rcmd + ] + + return [ + self.inverse_translate_one(cmd, dev_prop_id, lat_prop_id) + for lat_prop_id in lat_prop_ids + ] + + def inverse_translate_one( + self, + cmd: Command, + dev_prop_id: DevicePropertyID, + lat_prop_id: LatticeElementPropertyID, + ) -> Command: """ Perform a single inverse translation. @@ -72,11 +84,15 @@ def inverse_translate_one(self, cmd: Command, dev_prop_id: DevicePropertyID, A new Command with the value converted to the lattice state. """ translation_object = self.translator_service.get( - ConversionID(lattice_property_id=lat_prop_id, device_property_id=dev_prop_id) + ConversionID( + lattice_property_id=lat_prop_id, device_property_id=dev_prop_id + ) ) if dev_prop_id.device_name is None: - raise ValueError("Device name cannot be None in device property identifier.") + raise ValueError( + "Device name cannot be None in device property identifier." + ) ncmd = Command( id=lat_prop_id.element_name, @@ -94,7 +110,9 @@ def forward(self, cmd: Command) -> Command: dev_prop_id = DevicePropertyID(device_name=rcmd.id, property=rcmd.property) translation_object = self.translator_service.get( - ConversionID(lattice_property_id=lat_prop_id, device_property_id=dev_prop_id) + ConversionID( + lattice_property_id=lat_prop_id, device_property_id=dev_prop_id + ) ) ncmd = Command( id=dev_prop_id.device_name, @@ -116,4 +134,9 @@ def inverse_read_command(self, command: ReadCommand) -> Sequence[ReadCommand]: device_name=command.id, property=command.property ) lat_prop_ids = self.liaison_manager.inverse(dev_prop_id) - return [ReadCommand(id=lp.element_name, property=lp.property) for lp in lat_prop_ids] \ No newline at end of file + return [ + ReadCommand(id=lp.element_name, property=lp.property) for lp in lat_prop_ids + ] + + +__all__ = ["CommandRewriter"] diff --git a/src/accml_lib/core/bl/liaison_manager.py b/src/accml_lib/core/bl/liaison_manager.py index 66e8611..e0acf94 100644 --- a/src/accml_lib/core/bl/liaison_manager.py +++ b/src/accml_lib/core/bl/liaison_manager.py @@ -2,7 +2,10 @@ from typing import Mapping, Sequence from accml_lib.core.interfaces.utils.liaison_manager import LiaisonManagerBase -from accml_lib.core.model.utils.identifiers import LatticeElementPropertyID, DevicePropertyID +from accml_lib.core.model.utils.identifiers import ( + LatticeElementPropertyID, + DevicePropertyID, +) logger = logging.getLogger("accml") @@ -45,3 +48,6 @@ def inverse(self, id_: DevicePropertyID) -> LatticeElementPropertyID: f"{self.__class__.__name__} id {id_} not found in lookup table: {ke}" ) raise ke + + +__all__ = ["LiaisonManager"] diff --git a/src/accml_lib/core/bl/translator_service.py b/src/accml_lib/core/bl/translator_service.py index fe5e88d..62a4964 100644 --- a/src/accml_lib/core/bl/translator_service.py +++ b/src/accml_lib/core/bl/translator_service.py @@ -41,3 +41,6 @@ def objects_for_device(self, id_: ConversionID): for key, to in self.lut.items() if id_.device_property_id.device_name == key.device_property_id.device_name } + + +__all__ = ["TranslatorService"] diff --git a/src/accml_lib/core/bl/unit_conversion.py b/src/accml_lib/core/bl/unit_conversion.py index 3937e0f..d14810e 100644 --- a/src/accml_lib/core/bl/unit_conversion.py +++ b/src/accml_lib/core/bl/unit_conversion.py @@ -66,3 +66,6 @@ def inverse(self, state: float) -> float: state, ) return (state - self.intercept) / self.slope + + +__all__ = ["EnergyDependentLinearUnitConversion", "LinearUnitConversion"] diff --git a/src/accml_lib/core/bl/yellow_pages.py b/src/accml_lib/core/bl/yellow_pages.py index 3c9da34..0578884 100644 --- a/src/accml_lib/core/bl/yellow_pages.py +++ b/src/accml_lib/core/bl/yellow_pages.py @@ -12,9 +12,9 @@ class FamilyName(Enum): class YellowPages(YellowPagesBase): """ - or use: - get(family_name: str) - separate yellow pages for lattice elements and devices + or use: + get(family_name: str) + separate yellow pages for lattice elements and devices """ def __init__(self, d: dict): @@ -30,3 +30,6 @@ def quadrupole_names(self) -> Sequence[str]: def tune_correction_quadrupole_names(self) -> Sequence[str]: return self.get("tune_correction_quadrupoles") + + +__all__ = ["FamilyName", "YellowPages"] diff --git a/src/accml_lib/core/interfaces/utils/measurement_execution_engine.py b/src/accml_lib/core/interfaces/utils/measurement_execution_engine.py index 61906a4..6db81bf 100644 --- a/src/accml_lib/core/interfaces/utils/measurement_execution_engine.py +++ b/src/accml_lib/core/interfaces/utils/measurement_execution_engine.py @@ -7,7 +7,7 @@ class MeasurementExecutionEngine(metaclass=ABCMeta): @abstractmethod - def execute( + async def execute( self, commands_collection: Sequence[TransactionCommand], *args, **kwargs ) -> str: """ diff --git a/src/accml_lib/core/model/__init__.py b/src/accml_lib/core/model/__init__.py index e69de29..feeea61 100644 --- a/src/accml_lib/core/model/__init__.py +++ b/src/accml_lib/core/model/__init__.py @@ -0,0 +1 @@ +__all__ = ["utils", "output", "config"] diff --git a/src/accml_lib/core/model/utils/__init__.py b/src/accml_lib/core/model/utils/__init__.py index e69de29..2101377 100644 --- a/src/accml_lib/core/model/utils/__init__.py +++ b/src/accml_lib/core/model/utils/__init__.py @@ -0,0 +1 @@ +__all__ = ["identifiers", "command"] \ No newline at end of file diff --git a/src/accml_lib/custom/__init__.py b/src/accml_lib/custom/__init__.py index e69de29..6e839e4 100644 --- a/src/accml_lib/custom/__init__.py +++ b/src/accml_lib/custom/__init__.py @@ -0,0 +1,13 @@ +"""Simulation engine and accelerator machine backends + +Todo: + add bessyii_on_tango to __all__ as soon as it + is checked that it works (again) + +Furthermore commonly shared configuration data can be put +into the config_data directory here. Up to now only data +for BESSY II are stored here + + +""" +__all__ = ["pyat_simulator", "bessyii"] \ No newline at end of file diff --git a/src/accml_lib/custom/bessyii/__init__.py b/src/accml_lib/custom/bessyii/__init__.py index e69de29..29c45f4 100644 --- a/src/accml_lib/custom/bessyii/__init__.py +++ b/src/accml_lib/custom/bessyii/__init__.py @@ -0,0 +1,6 @@ +__all__ = [ + "liasion_translator_setup", + "bessyii_pyat_lattice", + "pyat_simulator_backend", + "setup" +] \ No newline at end of file diff --git a/src/accml_lib/custom/pyat_simulator/__init__.py b/src/accml_lib/custom/pyat_simulator/__init__.py index e69de29..56e6934 100644 --- a/src/accml_lib/custom/pyat_simulator/__init__.py +++ b/src/accml_lib/custom/pyat_simulator/__init__.py @@ -0,0 +1,4 @@ +"""Accelerator backend based on pyat + +""" +__all__ = ["simulator_backend", "element_proxies", "accelerator_simulator"] \ No newline at end of file diff --git a/src/accml_lib/custom/pyat_simulator/simulator_backend.py b/src/accml_lib/custom/pyat_simulator/simulator_backend.py index 1b2d1db..2d75f1e 100644 --- a/src/accml_lib/custom/pyat_simulator/simulator_backend.py +++ b/src/accml_lib/custom/pyat_simulator/simulator_backend.py @@ -41,9 +41,7 @@ class SimulationStateModel: class SimulatorBackend(BackendRW): - """ - Todo: - where to break async / sync or threaded approach? + """Simulation backend based on pyAT I assume today that the calculation engine works the following way: @@ -57,6 +55,10 @@ class SimulatorBackend(BackendRW): calculation results are protected by a lock, so no more sets are made while calculation is running nor calculation results are delivered ahead of time. + + Todo: + where to break async / sync or threaded approach? + """ def __init__(self, *, acc: AcceleratorSimulatorInterface, name: str, logger=logger): diff --git a/tests/test_core/test_liasion_translator_setup.py b/tests/test_core/test_liasion_translator_setup.py new file mode 100644 index 0000000..b429e21 --- /dev/null +++ b/tests/test_core/test_liasion_translator_setup.py @@ -0,0 +1,95 @@ +import pytest +from pathlib import Path +import shutil + +import accml_lib.custom.bessyii.liasion_translator_setup as lts +from accml_lib.core.bl.unit_conversion import EnergyDependentLinearUnitConversion +from accml_lib.core.model.utils.identifiers import ConversionID, LatticeElementPropertyID, DevicePropertyID + + +@pytest.fixture(scope="module") +def config_dir(tmp_path_factory): + """Create a temporary copy of the real config data.""" + tmp_dir = tmp_path_factory.mktemp("config_data") + + base_config_path = ( + Path(__file__).resolve().parents[2] + / "src" + / "accml_lib" + / "custom" + / "config_data" + / "bessyii" + ) + + shutil.copy(base_config_path / "magnets.yaml", tmp_dir / "magnets.yaml") + shutil.copy(base_config_path / "power_converters.yaml", tmp_dir / "power_converters.yaml") + + return tmp_dir + + +def test_build_managers_with_real_data(config_dir): + """End-to-end test of building YellowPages, LiaisonManager, and TranslatorService.""" + yp, lm, tm = lts.build_managers(config_dir) + + # --- YellowPages checks --- + assert yp is not None + assert hasattr(yp, "quadrupole_names") + assert len(yp.quadrupole_names()) > 0 + + # --- LiaisonManager checks --- + assert lm is not None + assert hasattr(lm, "forward_lut") + assert hasattr(lm, "inverse_lut") + assert all(isinstance(k, LatticeElementPropertyID) for k in lm.forward_lut) + assert all(isinstance(v, DevicePropertyID) for v in lm.forward_lut.values()) + + # --- TranslatorService checks (behavioral, not internal attr) --- + assert tm is not None + # find one known mapping and ensure it can retrieve conversion + sample_magnet = yp.quadrupole_names()[0] + conv_id = ConversionID( + lattice_property_id=LatticeElementPropertyID(element_name=sample_magnet, property="main_strength"), + device_property_id=DevicePropertyID(device_name=lm.forward_lut[ + LatticeElementPropertyID(element_name=sample_magnet, property="main_strength") + ].device_name, property="set_current"), + ) + + # Call behaviorally + conversion_obj = tm.get(conv_id) + assert isinstance(conversion_obj, EnergyDependentLinearUnitConversion) + assert hasattr(conversion_obj, "slope") + assert hasattr(conversion_obj, "intercept") + + +def test_build_managers_detects_duplicate_names(monkeypatch, config_dir): + """Ensure duplicate magnet names raise an AssertionError.""" + # Monkeypatch ConfigService.get_magnets to return duplicates + cs = lts.ConfigService( + magnet_path=config_dir / "magnets.yaml", + pc_path=config_dir / "power_converters.yaml", + ) + cs.load() + magnets = cs.get_magnets() + magnets.append(magnets[0]) # duplicate + + monkeypatch.setattr(cs, "get_magnets", lambda: magnets) + monkeypatch.setattr(lts, "ConfigService", lambda *a, **k: cs) + + with pytest.raises(AssertionError): + lts.build_managers(config_dir) + + +def test_load_managers_cache(monkeypatch): + """Ensure @lru_cache prevents multiple builds.""" + count = {"n": 0} + + def fake_build(path): + count["n"] += 1 + return ("yp", "lm", "tm") + + monkeypatch.setattr(lts, "build_managers", fake_build) + + result1 = lts.load_managers() + result2 = lts.load_managers() + assert result1 == result2 + assert count["n"] == 1