From 7130212bf4d8ef58c5c933ae0b8dea75974093da Mon Sep 17 00:00:00 2001 From: Peter Braun Date: Wed, 21 Jan 2026 13:47:45 +0100 Subject: [PATCH 01/18] separated device instantiation from connection --- src/secop_ophyd/AsyncFrappyClient.py | 22 +- src/secop_ophyd/SECoPDevices.py | 26 +- tests/conftest.py | 17 +- uv.lock | 703 +++------------------------ 4 files changed, 97 insertions(+), 671 deletions(-) diff --git a/src/secop_ophyd/AsyncFrappyClient.py b/src/secop_ophyd/AsyncFrappyClient.py index 4873abd..86ca54f 100644 --- a/src/secop_ophyd/AsyncFrappyClient.py +++ b/src/secop_ophyd/AsyncFrappyClient.py @@ -8,19 +8,19 @@ class AsyncFrappyClient: - def __init__(self, host: str, port: str, loop) -> None: + def __init__(self, host: str, port: str, log=Logger) -> None: self.host: str = host self.port: str = port - self.client: SecopClient = None - - self.loop = loop + self.loop: asyncio.AbstractEventLoop self.external: bool = False self.conn_timestamp: float - self.log = None + self.client: SecopClient = SecopClient(uri=host + ":" + port, log=log) + + self.log = self.client.log @property def state(self): @@ -46,18 +46,10 @@ def uri(self): def nodename(self): return self.client.nodename - @classmethod - async def create(cls, host, port, loop, log=Logger): - self = AsyncFrappyClient(host=host, port=port, loop=loop) - self.client = SecopClient(uri=host + ":" + port, log=log) - - self.log = self.client.log - - await self.connect(3) + async def connect(self, try_period=0): - return self + self.loop = asyncio.get_running_loop() - async def connect(self, try_period=0): await asyncio.to_thread(self.client.connect, try_period) self.conn_timestamp = time.time() diff --git a/src/secop_ophyd/SECoPDevices.py b/src/secop_ophyd/SECoPDevices.py index 829c9d2..50a6aa3 100644 --- a/src/secop_ophyd/SECoPDevices.py +++ b/src/secop_ophyd/SECoPDevices.py @@ -871,28 +871,24 @@ def __init__( self.name = name self.prefix = prefix + self._secclient: AsyncFrappyClient = AsyncFrappyClient( + host=self.host, port=self.port, log=self.logger + ) + async def connect( self, mock: bool | LazyMock = False, timeout: float = DEFAULT_TIMEOUT, force_reconnect: bool = False, ): - if not hasattr(self, "_secclient"): - secclient: AsyncFrappyClient - - secclient = await AsyncFrappyClient.create( - host=self.host, - port=self.port, - loop=asyncio.get_running_loop(), - log=self.logger, - ) + if self._secclient.online is False and not force_reconnect: + + await self._secclient.connect(3) self.equipment_id: SignalR self.description: SignalR self.version: SignalR - self._secclient: AsyncFrappyClient = secclient - self._module_name: str = "" self._node_cls_name: str = "" self.mod_devices: Dict[str, SECoPReadableDevice] = {} @@ -917,7 +913,7 @@ async def connect( ): for property in self._secclient.properties: propb = PropertyBackend( - property, self._secclient.properties, secclient + property, self._secclient.properties, self._secclient ) setattr(self, property, SignalR(backend=propb)) config.append(getattr(self, property)) @@ -945,16 +941,12 @@ async def connect( # register secclient callbacks (these are useful if sec node description # changes after a reconnect) - secclient.client.register_callback( + self._secclient.client.register_callback( None, self.descriptiveDataChange, self.nodeStateChange ) super().__init__(name=self.name) - elif force_reconnect or self._secclient.client.online is False: - await self._secclient.disconnect(True) - await self._secclient.connect(try_period=DEFAULT_TIMEOUT) - def class_from_instance(self, path_to_module: str | None = None): """Dynamically generate python class file for the SECoP_Node_Device, this allows autocompletion in IDEs and eases working with the generated Ophyd diff --git a/tests/conftest.py b/tests/conftest.py index 20dca79..10f4ba0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,4 @@ # mypy: disable-error-code="attr-defined" -import asyncio import logging import os @@ -135,20 +134,20 @@ def filter(self, record): @pytest.fixture async def async_frappy_client(cryo_sim, logger, port="10769"): - loop = asyncio.get_running_loop() + client = AsyncFrappyClient(host="localhost", port=port, log=logger) - return await AsyncFrappyClient.create( - host="localhost", port=port, loop=loop, log=logger - ) + await client.connect(3) + + return client @pytest.fixture async def nested_client(nested_struct_sim, logger, port="10771"): - loop = asyncio.get_running_loop() + client = AsyncFrappyClient(host="localhost", port=port, log=logger) - return await AsyncFrappyClient.create( - host="localhost", port=port, loop=loop, log=logger - ) + await client.connect(3) + + return client @pytest.fixture diff --git a/uv.lock b/uv.lock index 5878c49..dcb4814 100644 --- a/uv.lock +++ b/uv.lock @@ -1,10 +1,6 @@ version = 1 revision = 3 -requires-python = ">=3.10" -resolution-markers = [ - "python_full_version >= '3.11'", - "python_full_version < '3.11'", -] +requires-python = ">=3.11" [[package]] name = "accessible-pygments" @@ -49,9 +45,6 @@ wheels = [ name = "astroid" version = "3.3.11" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, -] sdist = { url = "https://files.pythonhosted.org/packages/18/74/dfb75f9ccd592bbedb175d4a32fc643cf569d7c218508bfbd6ea7ef9c091/astroid-3.3.11.tar.gz", hash = "sha256:1e5a5011af2920c7c67a53f65d536d65bfa7116feeaf2354d8b94f29573bb0ce", size = 400439, upload-time = "2025-07-13T18:04:23.177Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/af/0f/3b8fdc946b4d9cc8cc1e8af42c4e409468c84441b933d037e101b3d72d86/astroid-3.3.11-py3-none-any.whl", hash = "sha256:54c760ae8322ece1abd213057c4b5bba7c49818853fc901ef09719a60dbf9dec", size = 275612, upload-time = "2025-07-13T18:04:21.07Z" }, @@ -84,15 +77,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537, upload-time = "2025-02-01T15:17:37.39Z" }, ] -[[package]] -name = "backports-asyncio-runner" -version = "1.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, -] - [[package]] name = "beautifulsoup4" version = "4.14.3" @@ -116,15 +100,9 @@ dependencies = [ { name = "packaging" }, { name = "pathspec" }, { name = "platformdirs" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d8/0d/cc2fb42b8c50d80143221515dd7e4766995bd07c56c9a3ed30baf080b6dc/black-24.10.0.tar.gz", hash = "sha256:846ea64c97afe3bc677b761787993be4991810ecc7a4a937816dd6bddedc4875", size = 645813, upload-time = "2024-10-07T19:20:50.361Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/f3/465c0eb5cddf7dbbfe1fecd9b875d1dcf51b88923cd2c1d7e9ab95c6336b/black-24.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6668650ea4b685440857138e5fe40cde4d652633b1bdffc62933d0db4ed9812", size = 1623211, upload-time = "2024-10-07T19:26:12.43Z" }, - { url = "https://files.pythonhosted.org/packages/df/57/b6d2da7d200773fdfcc224ffb87052cf283cec4d7102fab450b4a05996d8/black-24.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1c536fcf674217e87b8cc3657b81809d3c085d7bf3ef262ead700da345bfa6ea", size = 1457139, upload-time = "2024-10-07T19:25:06.453Z" }, - { url = "https://files.pythonhosted.org/packages/6e/c5/9023b7673904a5188f9be81f5e129fff69f51f5515655fbd1d5a4e80a47b/black-24.10.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:649fff99a20bd06c6f727d2a27f401331dc0cc861fb69cde910fe95b01b5928f", size = 1753774, upload-time = "2024-10-07T19:23:58.47Z" }, - { url = "https://files.pythonhosted.org/packages/e1/32/df7f18bd0e724e0d9748829765455d6643ec847b3f87e77456fc99d0edab/black-24.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:fe4d6476887de70546212c99ac9bd803d90b42fc4767f058a0baa895013fbb3e", size = 1414209, upload-time = "2024-10-07T19:24:42.54Z" }, { url = "https://files.pythonhosted.org/packages/c2/cc/7496bb63a9b06a954d3d0ac9fe7a73f3bf1cd92d7a58877c27f4ad1e9d41/black-24.10.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5a2221696a8224e335c28816a9d331a6c2ae15a2ee34ec857dcf3e45dbfa99ad", size = 1607468, upload-time = "2024-10-07T19:26:14.966Z" }, { url = "https://files.pythonhosted.org/packages/2b/e3/69a738fb5ba18b5422f50b4f143544c664d7da40f09c13969b2fd52900e0/black-24.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f9da3333530dbcecc1be13e69c250ed8dfa67f43c4005fb537bb426e19200d50", size = 1437270, upload-time = "2024-10-07T19:25:24.291Z" }, { url = "https://files.pythonhosted.org/packages/c9/9b/2db8045b45844665c720dcfe292fdaf2e49825810c0103e1191515fc101a/black-24.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4007b1393d902b48b36958a216c20c4482f601569d19ed1df294a496eb366392", size = 1737061, upload-time = "2024-10-07T19:23:52.18Z" }, @@ -150,8 +128,7 @@ dependencies = [ { name = "historydict" }, { name = "msgpack" }, { name = "msgpack-numpy" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "numpy" }, { name = "opentelemetry-api" }, { name = "toolz" }, { name = "tqdm" }, @@ -180,18 +157,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" }, - { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" }, - { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, - { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, - { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, - { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, - { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, - { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, - { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, - { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, - { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, - { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, @@ -268,22 +233,6 @@ version = "3.4.4" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1f/b8/6d51fc1d52cbd52cd4ccedd5b5b2f0f6a11bbf6765c782298b0f3e808541/charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", size = 209709, upload-time = "2025-10-14T04:40:11.385Z" }, - { url = "https://files.pythonhosted.org/packages/5c/af/1f9d7f7faafe2ddfb6f72a2e07a548a629c61ad510fe60f9630309908fef/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", size = 148814, upload-time = "2025-10-14T04:40:13.135Z" }, - { url = "https://files.pythonhosted.org/packages/79/3d/f2e3ac2bbc056ca0c204298ea4e3d9db9b4afe437812638759db2c976b5f/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", size = 144467, upload-time = "2025-10-14T04:40:14.728Z" }, - { url = "https://files.pythonhosted.org/packages/ec/85/1bf997003815e60d57de7bd972c57dc6950446a3e4ccac43bc3070721856/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", size = 162280, upload-time = "2025-10-14T04:40:16.14Z" }, - { url = "https://files.pythonhosted.org/packages/3e/8e/6aa1952f56b192f54921c436b87f2aaf7c7a7c3d0d1a765547d64fd83c13/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", size = 159454, upload-time = "2025-10-14T04:40:17.567Z" }, - { url = "https://files.pythonhosted.org/packages/36/3b/60cbd1f8e93aa25d1c669c649b7a655b0b5fb4c571858910ea9332678558/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", size = 153609, upload-time = "2025-10-14T04:40:19.08Z" }, - { url = "https://files.pythonhosted.org/packages/64/91/6a13396948b8fd3c4b4fd5bc74d045f5637d78c9675585e8e9fbe5636554/charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", size = 151849, upload-time = "2025-10-14T04:40:20.607Z" }, - { url = "https://files.pythonhosted.org/packages/b7/7a/59482e28b9981d105691e968c544cc0df3b7d6133152fb3dcdc8f135da7a/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", size = 151586, upload-time = "2025-10-14T04:40:21.719Z" }, - { url = "https://files.pythonhosted.org/packages/92/59/f64ef6a1c4bdd2baf892b04cd78792ed8684fbc48d4c2afe467d96b4df57/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", size = 145290, upload-time = "2025-10-14T04:40:23.069Z" }, - { url = "https://files.pythonhosted.org/packages/6b/63/3bf9f279ddfa641ffa1962b0db6a57a9c294361cc2f5fcac997049a00e9c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", size = 163663, upload-time = "2025-10-14T04:40:24.17Z" }, - { url = "https://files.pythonhosted.org/packages/ed/09/c9e38fc8fa9e0849b172b581fd9803bdf6e694041127933934184e19f8c3/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", size = 151964, upload-time = "2025-10-14T04:40:25.368Z" }, - { url = "https://files.pythonhosted.org/packages/d2/d1/d28b747e512d0da79d8b6a1ac18b7ab2ecfd81b2944c4c710e166d8dd09c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", size = 161064, upload-time = "2025-10-14T04:40:26.806Z" }, - { url = "https://files.pythonhosted.org/packages/bb/9a/31d62b611d901c3b9e5500c36aab0ff5eb442043fb3a1c254200d3d397d9/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", size = 155015, upload-time = "2025-10-14T04:40:28.284Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f3/107e008fa2bff0c8b9319584174418e5e5285fef32f79d8ee6a430d0039c/charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f", size = 99792, upload-time = "2025-10-14T04:40:29.613Z" }, - { url = "https://files.pythonhosted.org/packages/eb/66/e396e8a408843337d7315bab30dbf106c38966f1819f123257f5520f8a96/charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d", size = 107198, upload-time = "2025-10-14T04:40:30.644Z" }, - { url = "https://files.pythonhosted.org/packages/b5/58/01b4f815bf0312704c267f2ccb6e5d42bcc7752340cd487bc9f8c3710597/charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69", size = 100262, upload-time = "2025-10-14T04:40:32.108Z" }, { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" }, { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" }, { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" }, @@ -407,85 +356,12 @@ lz4 = [ { name = "lz4" }, ] -[[package]] -name = "contourpy" -version = "1.3.2" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.11'", -] -dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/66/54/eb9bfc647b19f2009dd5c7f5ec51c4e6ca831725f1aea7a993034f483147/contourpy-1.3.2.tar.gz", hash = "sha256:b6945942715a034c671b7fc54f9588126b0b8bf23db2696e3ca8328f3ff0ab54", size = 13466130, upload-time = "2025-04-15T17:47:53.79Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/12/a3/da4153ec8fe25d263aa48c1a4cbde7f49b59af86f0b6f7862788c60da737/contourpy-1.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ba38e3f9f330af820c4b27ceb4b9c7feee5fe0493ea53a8720f4792667465934", size = 268551, upload-time = "2025-04-15T17:34:46.581Z" }, - { url = "https://files.pythonhosted.org/packages/2f/6c/330de89ae1087eb622bfca0177d32a7ece50c3ef07b28002de4757d9d875/contourpy-1.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dc41ba0714aa2968d1f8674ec97504a8f7e334f48eeacebcaa6256213acb0989", size = 253399, upload-time = "2025-04-15T17:34:51.427Z" }, - { url = "https://files.pythonhosted.org/packages/c1/bd/20c6726b1b7f81a8bee5271bed5c165f0a8e1f572578a9d27e2ccb763cb2/contourpy-1.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9be002b31c558d1ddf1b9b415b162c603405414bacd6932d031c5b5a8b757f0d", size = 312061, upload-time = "2025-04-15T17:34:55.961Z" }, - { url = "https://files.pythonhosted.org/packages/22/fc/a9665c88f8a2473f823cf1ec601de9e5375050f1958cbb356cdf06ef1ab6/contourpy-1.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8d2e74acbcba3bfdb6d9d8384cdc4f9260cae86ed9beee8bd5f54fee49a430b9", size = 351956, upload-time = "2025-04-15T17:35:00.992Z" }, - { url = "https://files.pythonhosted.org/packages/25/eb/9f0a0238f305ad8fb7ef42481020d6e20cf15e46be99a1fcf939546a177e/contourpy-1.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e259bced5549ac64410162adc973c5e2fb77f04df4a439d00b478e57a0e65512", size = 320872, upload-time = "2025-04-15T17:35:06.177Z" }, - { url = "https://files.pythonhosted.org/packages/32/5c/1ee32d1c7956923202f00cf8d2a14a62ed7517bdc0ee1e55301227fc273c/contourpy-1.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad687a04bc802cbe8b9c399c07162a3c35e227e2daccf1668eb1f278cb698631", size = 325027, upload-time = "2025-04-15T17:35:11.244Z" }, - { url = "https://files.pythonhosted.org/packages/83/bf/9baed89785ba743ef329c2b07fd0611d12bfecbedbdd3eeecf929d8d3b52/contourpy-1.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cdd22595308f53ef2f891040ab2b93d79192513ffccbd7fe19be7aa773a5e09f", size = 1306641, upload-time = "2025-04-15T17:35:26.701Z" }, - { url = "https://files.pythonhosted.org/packages/d4/cc/74e5e83d1e35de2d28bd97033426b450bc4fd96e092a1f7a63dc7369b55d/contourpy-1.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b4f54d6a2defe9f257327b0f243612dd051cc43825587520b1bf74a31e2f6ef2", size = 1374075, upload-time = "2025-04-15T17:35:43.204Z" }, - { url = "https://files.pythonhosted.org/packages/0c/42/17f3b798fd5e033b46a16f8d9fcb39f1aba051307f5ebf441bad1ecf78f8/contourpy-1.3.2-cp310-cp310-win32.whl", hash = "sha256:f939a054192ddc596e031e50bb13b657ce318cf13d264f095ce9db7dc6ae81c0", size = 177534, upload-time = "2025-04-15T17:35:46.554Z" }, - { url = "https://files.pythonhosted.org/packages/54/ec/5162b8582f2c994721018d0c9ece9dc6ff769d298a8ac6b6a652c307e7df/contourpy-1.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:c440093bbc8fc21c637c03bafcbef95ccd963bc6e0514ad887932c18ca2a759a", size = 221188, upload-time = "2025-04-15T17:35:50.064Z" }, - { url = "https://files.pythonhosted.org/packages/b3/b9/ede788a0b56fc5b071639d06c33cb893f68b1178938f3425debebe2dab78/contourpy-1.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6a37a2fb93d4df3fc4c0e363ea4d16f83195fc09c891bc8ce072b9d084853445", size = 269636, upload-time = "2025-04-15T17:35:54.473Z" }, - { url = "https://files.pythonhosted.org/packages/e6/75/3469f011d64b8bbfa04f709bfc23e1dd71be54d05b1b083be9f5b22750d1/contourpy-1.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b7cd50c38f500bbcc9b6a46643a40e0913673f869315d8e70de0438817cb7773", size = 254636, upload-time = "2025-04-15T17:35:58.283Z" }, - { url = "https://files.pythonhosted.org/packages/8d/2f/95adb8dae08ce0ebca4fd8e7ad653159565d9739128b2d5977806656fcd2/contourpy-1.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6658ccc7251a4433eebd89ed2672c2ed96fba367fd25ca9512aa92a4b46c4f1", size = 313053, upload-time = "2025-04-15T17:36:03.235Z" }, - { url = "https://files.pythonhosted.org/packages/c3/a6/8ccf97a50f31adfa36917707fe39c9a0cbc24b3bbb58185577f119736cc9/contourpy-1.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:70771a461aaeb335df14deb6c97439973d253ae70660ca085eec25241137ef43", size = 352985, upload-time = "2025-04-15T17:36:08.275Z" }, - { url = "https://files.pythonhosted.org/packages/1d/b6/7925ab9b77386143f39d9c3243fdd101621b4532eb126743201160ffa7e6/contourpy-1.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65a887a6e8c4cd0897507d814b14c54a8c2e2aa4ac9f7686292f9769fcf9a6ab", size = 323750, upload-time = "2025-04-15T17:36:13.29Z" }, - { url = "https://files.pythonhosted.org/packages/c2/f3/20c5d1ef4f4748e52d60771b8560cf00b69d5c6368b5c2e9311bcfa2a08b/contourpy-1.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3859783aefa2b8355697f16642695a5b9792e7a46ab86da1118a4a23a51a33d7", size = 326246, upload-time = "2025-04-15T17:36:18.329Z" }, - { url = "https://files.pythonhosted.org/packages/8c/e5/9dae809e7e0b2d9d70c52b3d24cba134dd3dad979eb3e5e71f5df22ed1f5/contourpy-1.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eab0f6db315fa4d70f1d8ab514e527f0366ec021ff853d7ed6a2d33605cf4b83", size = 1308728, upload-time = "2025-04-15T17:36:33.878Z" }, - { url = "https://files.pythonhosted.org/packages/e2/4a/0058ba34aeea35c0b442ae61a4f4d4ca84d6df8f91309bc2d43bb8dd248f/contourpy-1.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d91a3ccc7fea94ca0acab82ceb77f396d50a1f67412efe4c526f5d20264e6ecd", size = 1375762, upload-time = "2025-04-15T17:36:51.295Z" }, - { url = "https://files.pythonhosted.org/packages/09/33/7174bdfc8b7767ef2c08ed81244762d93d5c579336fc0b51ca57b33d1b80/contourpy-1.3.2-cp311-cp311-win32.whl", hash = "sha256:1c48188778d4d2f3d48e4643fb15d8608b1d01e4b4d6b0548d9b336c28fc9b6f", size = 178196, upload-time = "2025-04-15T17:36:55.002Z" }, - { url = "https://files.pythonhosted.org/packages/5e/fe/4029038b4e1c4485cef18e480b0e2cd2d755448bb071eb9977caac80b77b/contourpy-1.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:5ebac872ba09cb8f2131c46b8739a7ff71de28a24c869bcad554477eb089a878", size = 222017, upload-time = "2025-04-15T17:36:58.576Z" }, - { url = "https://files.pythonhosted.org/packages/34/f7/44785876384eff370c251d58fd65f6ad7f39adce4a093c934d4a67a7c6b6/contourpy-1.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4caf2bcd2969402bf77edc4cb6034c7dd7c0803213b3523f111eb7460a51b8d2", size = 271580, upload-time = "2025-04-15T17:37:03.105Z" }, - { url = "https://files.pythonhosted.org/packages/93/3b/0004767622a9826ea3d95f0e9d98cd8729015768075d61f9fea8eeca42a8/contourpy-1.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:82199cb78276249796419fe36b7386bd8d2cc3f28b3bc19fe2454fe2e26c4c15", size = 255530, upload-time = "2025-04-15T17:37:07.026Z" }, - { url = "https://files.pythonhosted.org/packages/e7/bb/7bd49e1f4fa805772d9fd130e0d375554ebc771ed7172f48dfcd4ca61549/contourpy-1.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:106fab697af11456fcba3e352ad50effe493a90f893fca6c2ca5c033820cea92", size = 307688, upload-time = "2025-04-15T17:37:11.481Z" }, - { url = "https://files.pythonhosted.org/packages/fc/97/e1d5dbbfa170725ef78357a9a0edc996b09ae4af170927ba8ce977e60a5f/contourpy-1.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d14f12932a8d620e307f715857107b1d1845cc44fdb5da2bc8e850f5ceba9f87", size = 347331, upload-time = "2025-04-15T17:37:18.212Z" }, - { url = "https://files.pythonhosted.org/packages/6f/66/e69e6e904f5ecf6901be3dd16e7e54d41b6ec6ae3405a535286d4418ffb4/contourpy-1.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:532fd26e715560721bb0d5fc7610fce279b3699b018600ab999d1be895b09415", size = 318963, upload-time = "2025-04-15T17:37:22.76Z" }, - { url = "https://files.pythonhosted.org/packages/a8/32/b8a1c8965e4f72482ff2d1ac2cd670ce0b542f203c8e1d34e7c3e6925da7/contourpy-1.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f26b383144cf2d2c29f01a1e8170f50dacf0eac02d64139dcd709a8ac4eb3cfe", size = 323681, upload-time = "2025-04-15T17:37:33.001Z" }, - { url = "https://files.pythonhosted.org/packages/30/c6/12a7e6811d08757c7162a541ca4c5c6a34c0f4e98ef2b338791093518e40/contourpy-1.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c49f73e61f1f774650a55d221803b101d966ca0c5a2d6d5e4320ec3997489441", size = 1308674, upload-time = "2025-04-15T17:37:48.64Z" }, - { url = "https://files.pythonhosted.org/packages/2a/8a/bebe5a3f68b484d3a2b8ffaf84704b3e343ef1addea528132ef148e22b3b/contourpy-1.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3d80b2c0300583228ac98d0a927a1ba6a2ba6b8a742463c564f1d419ee5b211e", size = 1380480, upload-time = "2025-04-15T17:38:06.7Z" }, - { url = "https://files.pythonhosted.org/packages/34/db/fcd325f19b5978fb509a7d55e06d99f5f856294c1991097534360b307cf1/contourpy-1.3.2-cp312-cp312-win32.whl", hash = "sha256:90df94c89a91b7362e1142cbee7568f86514412ab8a2c0d0fca72d7e91b62912", size = 178489, upload-time = "2025-04-15T17:38:10.338Z" }, - { url = "https://files.pythonhosted.org/packages/01/c8/fadd0b92ffa7b5eb5949bf340a63a4a496a6930a6c37a7ba0f12acb076d6/contourpy-1.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:8c942a01d9163e2e5cfb05cb66110121b8d07ad438a17f9e766317bcb62abf73", size = 223042, upload-time = "2025-04-15T17:38:14.239Z" }, - { url = "https://files.pythonhosted.org/packages/2e/61/5673f7e364b31e4e7ef6f61a4b5121c5f170f941895912f773d95270f3a2/contourpy-1.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:de39db2604ae755316cb5967728f4bea92685884b1e767b7c24e983ef5f771cb", size = 271630, upload-time = "2025-04-15T17:38:19.142Z" }, - { url = "https://files.pythonhosted.org/packages/ff/66/a40badddd1223822c95798c55292844b7e871e50f6bfd9f158cb25e0bd39/contourpy-1.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3f9e896f447c5c8618f1edb2bafa9a4030f22a575ec418ad70611450720b5b08", size = 255670, upload-time = "2025-04-15T17:38:23.688Z" }, - { url = "https://files.pythonhosted.org/packages/1e/c7/cf9fdee8200805c9bc3b148f49cb9482a4e3ea2719e772602a425c9b09f8/contourpy-1.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71e2bd4a1c4188f5c2b8d274da78faab884b59df20df63c34f74aa1813c4427c", size = 306694, upload-time = "2025-04-15T17:38:28.238Z" }, - { url = "https://files.pythonhosted.org/packages/dd/e7/ccb9bec80e1ba121efbffad7f38021021cda5be87532ec16fd96533bb2e0/contourpy-1.3.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de425af81b6cea33101ae95ece1f696af39446db9682a0b56daaa48cfc29f38f", size = 345986, upload-time = "2025-04-15T17:38:33.502Z" }, - { url = "https://files.pythonhosted.org/packages/dc/49/ca13bb2da90391fa4219fdb23b078d6065ada886658ac7818e5441448b78/contourpy-1.3.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:977e98a0e0480d3fe292246417239d2d45435904afd6d7332d8455981c408b85", size = 318060, upload-time = "2025-04-15T17:38:38.672Z" }, - { url = "https://files.pythonhosted.org/packages/c8/65/5245ce8c548a8422236c13ffcdcdada6a2a812c361e9e0c70548bb40b661/contourpy-1.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:434f0adf84911c924519d2b08fc10491dd282b20bdd3fa8f60fd816ea0b48841", size = 322747, upload-time = "2025-04-15T17:38:43.712Z" }, - { url = "https://files.pythonhosted.org/packages/72/30/669b8eb48e0a01c660ead3752a25b44fdb2e5ebc13a55782f639170772f9/contourpy-1.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c66c4906cdbc50e9cba65978823e6e00b45682eb09adbb78c9775b74eb222422", size = 1308895, upload-time = "2025-04-15T17:39:00.224Z" }, - { url = "https://files.pythonhosted.org/packages/05/5a/b569f4250decee6e8d54498be7bdf29021a4c256e77fe8138c8319ef8eb3/contourpy-1.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8b7fc0cd78ba2f4695fd0a6ad81a19e7e3ab825c31b577f384aa9d7817dc3bef", size = 1379098, upload-time = "2025-04-15T17:43:29.649Z" }, - { url = "https://files.pythonhosted.org/packages/19/ba/b227c3886d120e60e41b28740ac3617b2f2b971b9f601c835661194579f1/contourpy-1.3.2-cp313-cp313-win32.whl", hash = "sha256:15ce6ab60957ca74cff444fe66d9045c1fd3e92c8936894ebd1f3eef2fff075f", size = 178535, upload-time = "2025-04-15T17:44:44.532Z" }, - { url = "https://files.pythonhosted.org/packages/12/6e/2fed56cd47ca739b43e892707ae9a13790a486a3173be063681ca67d2262/contourpy-1.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:e1578f7eafce927b168752ed7e22646dad6cd9bca673c60bff55889fa236ebf9", size = 223096, upload-time = "2025-04-15T17:44:48.194Z" }, - { url = "https://files.pythonhosted.org/packages/54/4c/e76fe2a03014a7c767d79ea35c86a747e9325537a8b7627e0e5b3ba266b4/contourpy-1.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0475b1f6604896bc7c53bb070e355e9321e1bc0d381735421a2d2068ec56531f", size = 285090, upload-time = "2025-04-15T17:43:34.084Z" }, - { url = "https://files.pythonhosted.org/packages/7b/e2/5aba47debd55d668e00baf9651b721e7733975dc9fc27264a62b0dd26eb8/contourpy-1.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c85bb486e9be652314bb5b9e2e3b0d1b2e643d5eec4992c0fbe8ac71775da739", size = 268643, upload-time = "2025-04-15T17:43:38.626Z" }, - { url = "https://files.pythonhosted.org/packages/a1/37/cd45f1f051fe6230f751cc5cdd2728bb3a203f5619510ef11e732109593c/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:745b57db7758f3ffc05a10254edd3182a2a83402a89c00957a8e8a22f5582823", size = 310443, upload-time = "2025-04-15T17:43:44.522Z" }, - { url = "https://files.pythonhosted.org/packages/8b/a2/36ea6140c306c9ff6dd38e3bcec80b3b018474ef4d17eb68ceecd26675f4/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:970e9173dbd7eba9b4e01aab19215a48ee5dd3f43cef736eebde064a171f89a5", size = 349865, upload-time = "2025-04-15T17:43:49.545Z" }, - { url = "https://files.pythonhosted.org/packages/95/b7/2fc76bc539693180488f7b6cc518da7acbbb9e3b931fd9280504128bf956/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6c4639a9c22230276b7bffb6a850dfc8258a2521305e1faefe804d006b2e532", size = 321162, upload-time = "2025-04-15T17:43:54.203Z" }, - { url = "https://files.pythonhosted.org/packages/f4/10/76d4f778458b0aa83f96e59d65ece72a060bacb20cfbee46cf6cd5ceba41/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc829960f34ba36aad4302e78eabf3ef16a3a100863f0d4eeddf30e8a485a03b", size = 327355, upload-time = "2025-04-15T17:44:01.025Z" }, - { url = "https://files.pythonhosted.org/packages/43/a3/10cf483ea683f9f8ab096c24bad3cce20e0d1dd9a4baa0e2093c1c962d9d/contourpy-1.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d32530b534e986374fc19eaa77fcb87e8a99e5431499949b828312bdcd20ac52", size = 1307935, upload-time = "2025-04-15T17:44:17.322Z" }, - { url = "https://files.pythonhosted.org/packages/78/73/69dd9a024444489e22d86108e7b913f3528f56cfc312b5c5727a44188471/contourpy-1.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e298e7e70cf4eb179cc1077be1c725b5fd131ebc81181bf0c03525c8abc297fd", size = 1372168, upload-time = "2025-04-15T17:44:33.43Z" }, - { url = "https://files.pythonhosted.org/packages/0f/1b/96d586ccf1b1a9d2004dd519b25fbf104a11589abfd05484ff12199cca21/contourpy-1.3.2-cp313-cp313t-win32.whl", hash = "sha256:d0e589ae0d55204991450bb5c23f571c64fe43adaa53f93fc902a84c96f52fe1", size = 189550, upload-time = "2025-04-15T17:44:37.092Z" }, - { url = "https://files.pythonhosted.org/packages/b0/e6/6000d0094e8a5e32ad62591c8609e269febb6e4db83a1c75ff8868b42731/contourpy-1.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:78e9253c3de756b3f6a5174d024c4835acd59eb3f8e2ca13e775dbffe1558f69", size = 238214, upload-time = "2025-04-15T17:44:40.827Z" }, - { url = "https://files.pythonhosted.org/packages/33/05/b26e3c6ecc05f349ee0013f0bb850a761016d89cec528a98193a48c34033/contourpy-1.3.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:fd93cc7f3139b6dd7aab2f26a90dde0aa9fc264dbf70f6740d498a70b860b82c", size = 265681, upload-time = "2025-04-15T17:44:59.314Z" }, - { url = "https://files.pythonhosted.org/packages/2b/25/ac07d6ad12affa7d1ffed11b77417d0a6308170f44ff20fa1d5aa6333f03/contourpy-1.3.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:107ba8a6a7eec58bb475329e6d3b95deba9440667c4d62b9b6063942b61d7f16", size = 315101, upload-time = "2025-04-15T17:45:04.165Z" }, - { url = "https://files.pythonhosted.org/packages/8f/4d/5bb3192bbe9d3f27e3061a6a8e7733c9120e203cb8515767d30973f71030/contourpy-1.3.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ded1706ed0c1049224531b81128efbd5084598f18d8a2d9efae833edbd2b40ad", size = 220599, upload-time = "2025-04-15T17:45:08.456Z" }, - { url = "https://files.pythonhosted.org/packages/ff/c0/91f1215d0d9f9f343e4773ba6c9b89e8c0cc7a64a6263f21139da639d848/contourpy-1.3.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5f5964cdad279256c084b69c3f412b7801e15356b16efa9d78aa974041903da0", size = 266807, upload-time = "2025-04-15T17:45:15.535Z" }, - { url = "https://files.pythonhosted.org/packages/d4/79/6be7e90c955c0487e7712660d6cead01fa17bff98e0ea275737cc2bc8e71/contourpy-1.3.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49b65a95d642d4efa8f64ba12558fcb83407e58a2dfba9d796d77b63ccfcaff5", size = 318729, upload-time = "2025-04-15T17:45:20.166Z" }, - { url = "https://files.pythonhosted.org/packages/87/68/7f46fb537958e87427d98a4074bcde4b67a70b04900cfc5ce29bc2f556c1/contourpy-1.3.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8c5acb8dddb0752bf252e01a3035b21443158910ac16a3b0d20e7fed7d534ce5", size = 221791, upload-time = "2025-04-15T17:45:24.794Z" }, -] - [[package]] name = "contourpy" version = "1.3.3" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.11'", -] dependencies = [ - { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "numpy" }, ] sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } wheels = [ @@ -568,18 +444,6 @@ version = "7.12.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/89/26/4a96807b193b011588099c3b5c89fbb05294e5b90e71018e065465f34eb6/coverage-7.12.0.tar.gz", hash = "sha256:fc11e0a4e372cb5f282f16ef90d4a585034050ccda536451901abfb19a57f40c", size = 819341, upload-time = "2025-11-18T13:34:20.766Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/26/4a/0dc3de1c172d35abe512332cfdcc43211b6ebce629e4cc42e6cd25ed8f4d/coverage-7.12.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:32b75c2ba3f324ee37af3ccee5b30458038c50b349ad9b88cee85096132a575b", size = 217409, upload-time = "2025-11-18T13:31:53.122Z" }, - { url = "https://files.pythonhosted.org/packages/01/c3/086198b98db0109ad4f84241e8e9ea7e5fb2db8c8ffb787162d40c26cc76/coverage-7.12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cb2a1b6ab9fe833714a483a915de350abc624a37149649297624c8d57add089c", size = 217927, upload-time = "2025-11-18T13:31:54.458Z" }, - { url = "https://files.pythonhosted.org/packages/5d/5f/34614dbf5ce0420828fc6c6f915126a0fcb01e25d16cf141bf5361e6aea6/coverage-7.12.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5734b5d913c3755e72f70bf6cc37a0518d4f4745cde760c5d8e12005e62f9832", size = 244678, upload-time = "2025-11-18T13:31:55.805Z" }, - { url = "https://files.pythonhosted.org/packages/55/7b/6b26fb32e8e4a6989ac1d40c4e132b14556131493b1d06bc0f2be169c357/coverage-7.12.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b527a08cdf15753279b7afb2339a12073620b761d79b81cbe2cdebdb43d90daa", size = 246507, upload-time = "2025-11-18T13:31:57.05Z" }, - { url = "https://files.pythonhosted.org/packages/06/42/7d70e6603d3260199b90fb48b537ca29ac183d524a65cc31366b2e905fad/coverage-7.12.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9bb44c889fb68004e94cab71f6a021ec83eac9aeabdbb5a5a88821ec46e1da73", size = 248366, upload-time = "2025-11-18T13:31:58.362Z" }, - { url = "https://files.pythonhosted.org/packages/2d/4a/d86b837923878424c72458c5b25e899a3c5ca73e663082a915f5b3c4d749/coverage-7.12.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4b59b501455535e2e5dde5881739897967b272ba25988c89145c12d772810ccb", size = 245366, upload-time = "2025-11-18T13:31:59.572Z" }, - { url = "https://files.pythonhosted.org/packages/e6/c2/2adec557e0aa9721875f06ced19730fdb7fc58e31b02b5aa56f2ebe4944d/coverage-7.12.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d8842f17095b9868a05837b7b1b73495293091bed870e099521ada176aa3e00e", size = 246408, upload-time = "2025-11-18T13:32:00.784Z" }, - { url = "https://files.pythonhosted.org/packages/5a/4b/8bd1f1148260df11c618e535fdccd1e5aaf646e55b50759006a4f41d8a26/coverage-7.12.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c5a6f20bf48b8866095c6820641e7ffbe23f2ac84a2efc218d91235e404c7777", size = 244416, upload-time = "2025-11-18T13:32:01.963Z" }, - { url = "https://files.pythonhosted.org/packages/0e/13/3a248dd6a83df90414c54a4e121fd081fb20602ca43955fbe1d60e2312a9/coverage-7.12.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:5f3738279524e988d9da2893f307c2093815c623f8d05a8f79e3eff3a7a9e553", size = 244681, upload-time = "2025-11-18T13:32:03.408Z" }, - { url = "https://files.pythonhosted.org/packages/76/30/aa833827465a5e8c938935f5d91ba055f70516941078a703740aaf1aa41f/coverage-7.12.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e0d68c1f7eabbc8abe582d11fa393ea483caf4f44b0af86881174769f185c94d", size = 245300, upload-time = "2025-11-18T13:32:04.686Z" }, - { url = "https://files.pythonhosted.org/packages/38/24/f85b3843af1370fb3739fa7571819b71243daa311289b31214fe3e8c9d68/coverage-7.12.0-cp310-cp310-win32.whl", hash = "sha256:7670d860e18b1e3ee5930b17a7d55ae6287ec6e55d9799982aa103a2cc1fa2ef", size = 220008, upload-time = "2025-11-18T13:32:05.806Z" }, - { url = "https://files.pythonhosted.org/packages/3a/a2/c7da5b9566f7164db9eefa133d17761ecb2c2fde9385d754e5b5c80f710d/coverage-7.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:f999813dddeb2a56aab5841e687b68169da0d3f6fc78ccf50952fa2463746022", size = 220943, upload-time = "2025-11-18T13:32:07.166Z" }, { url = "https://files.pythonhosted.org/packages/5a/0c/0dfe7f0487477d96432e4815537263363fb6dd7289743a796e8e51eabdf2/coverage-7.12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:aa124a3683d2af98bd9d9c2bfa7a5076ca7e5ab09fdb96b81fa7d89376ae928f", size = 217535, upload-time = "2025-11-18T13:32:08.812Z" }, { url = "https://files.pythonhosted.org/packages/9b/f5/f9a4a053a5bbff023d3bec259faac8f11a1e5a6479c2ccf586f910d8dac7/coverage-7.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d93fbf446c31c0140208dcd07c5d882029832e8ed7891a39d6d44bd65f2316c3", size = 218044, upload-time = "2025-11-18T13:32:10.329Z" }, { url = "https://files.pythonhosted.org/packages/95/c5/84fc3697c1fa10cd8571919bf9693f693b7373278daaf3b73e328d502bc8/coverage-7.12.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:52ca620260bd8cd6027317bdd8b8ba929be1d741764ee765b42c4d79a408601e", size = 248440, upload-time = "2025-11-18T13:32:12.536Z" }, @@ -681,10 +545,6 @@ version = "1.8.17" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/15/ad/71e708ff4ca377c4230530d6a7aa7992592648c122a2cd2b321cf8b35a76/debugpy-1.8.17.tar.gz", hash = "sha256:fd723b47a8c08892b1a16b2c6239a8b96637c62a59b94bb5dab4bac592a58a8e", size = 1644129, upload-time = "2025-09-17T16:33:20.633Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/38/36/b57c6e818d909f6e59c0182252921cf435e0951126a97e11de37e72ab5e1/debugpy-1.8.17-cp310-cp310-macosx_15_0_x86_64.whl", hash = "sha256:c41d2ce8bbaddcc0009cc73f65318eedfa3dbc88a8298081deb05389f1ab5542", size = 2098021, upload-time = "2025-09-17T16:33:22.556Z" }, - { url = "https://files.pythonhosted.org/packages/be/01/0363c7efdd1e9febd090bb13cee4fb1057215b157b2979a4ca5ccb678217/debugpy-1.8.17-cp310-cp310-manylinux_2_34_x86_64.whl", hash = "sha256:1440fd514e1b815edd5861ca394786f90eb24960eb26d6f7200994333b1d79e3", size = 3087399, upload-time = "2025-09-17T16:33:24.292Z" }, - { url = "https://files.pythonhosted.org/packages/79/bc/4a984729674aa9a84856650438b9665f9a1d5a748804ac6f37932ce0d4aa/debugpy-1.8.17-cp310-cp310-win32.whl", hash = "sha256:3a32c0af575749083d7492dc79f6ab69f21b2d2ad4cd977a958a07d5865316e4", size = 5230292, upload-time = "2025-09-17T16:33:26.137Z" }, - { url = "https://files.pythonhosted.org/packages/5d/19/2b9b3092d0cf81a5aa10c86271999453030af354d1a5a7d6e34c574515d7/debugpy-1.8.17-cp310-cp310-win_amd64.whl", hash = "sha256:a3aad0537cf4d9c1996434be68c6c9a6d233ac6f76c2a482c7803295b4e4f99a", size = 5261885, upload-time = "2025-09-17T16:33:27.592Z" }, { url = "https://files.pythonhosted.org/packages/d8/53/3af72b5c159278c4a0cf4cffa518675a0e73bdb7d1cac0239b815502d2ce/debugpy-1.8.17-cp311-cp311-macosx_15_0_universal2.whl", hash = "sha256:d3fce3f0e3de262a3b67e69916d001f3e767661c6e1ee42553009d445d1cd840", size = 2207154, upload-time = "2025-09-17T16:33:29.457Z" }, { url = "https://files.pythonhosted.org/packages/8f/6d/204f407df45600e2245b4a39860ed4ba32552330a0b3f5f160ae4cc30072/debugpy-1.8.17-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:c6bdf134457ae0cac6fb68205776be635d31174eeac9541e1d0c062165c6461f", size = 3170322, upload-time = "2025-09-17T16:33:30.837Z" }, { url = "https://files.pythonhosted.org/packages/f2/13/1b8f87d39cf83c6b713de2620c31205299e6065622e7dd37aff4808dd410/debugpy-1.8.17-cp311-cp311-win32.whl", hash = "sha256:e79a195f9e059edfe5d8bf6f3749b2599452d3e9380484cd261f6b7cd2c7c4da", size = 5155078, upload-time = "2025-09-17T16:33:33.331Z" }, @@ -738,8 +598,7 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "importlib-resources" }, { name = "jsonschema" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "numpy" }, { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f9/5a/de03f05fdbc4377db89fa6daf243e89370f9bcf2d21ad96b54d4549a74ed/event_model-1.23.1.tar.gz", hash = "sha256:5bb70fd8c7f345aa32afe561aff5a306b2c8a19cbdc3066b736643c8092ddaab", size = 185271, upload-time = "2025-08-28T13:26:38.647Z" } @@ -747,18 +606,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/32/e31e3363bf48ad2ba80b644b01ad9676ce154f1b755950de81eb4ed5b6bd/event_model-1.23.1-py3-none-any.whl", hash = "sha256:e0b951b829cebcf3879beff238bb370fd997d315856bc5d5ac2a66202b854958", size = 77057, upload-time = "2025-08-28T13:26:37.228Z" }, ] -[[package]] -name = "exceptiongroup" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, -] - [[package]] name = "executing" version = "2.2.1" @@ -821,14 +668,6 @@ version = "4.61.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/33/f9/0e84d593c0e12244150280a630999835a64f2852276161b62a0f98318de0/fonttools-4.61.0.tar.gz", hash = "sha256:ec520a1f0c7758d7a858a00f090c1745f6cde6a7c5e76fb70ea4044a15f712e7", size = 3561884, upload-time = "2025-11-28T17:05:49.491Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/29/f3/91bba2721fb173fc68e09d15b6ccf3ad4f83d127fbff579be7e5984888a6/fonttools-4.61.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:dc25a4a9c1225653e4431a9413d0381b1c62317b0f543bdcec24e1991f612f33", size = 2850151, upload-time = "2025-11-28T17:04:14.214Z" }, - { url = "https://files.pythonhosted.org/packages/f5/8c/a1691dec01038ac7e7bb3ab83300dcc5087b11d8f48640928c02a873eb92/fonttools-4.61.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6b493c32d2555e9944ec1b911ea649ff8f01a649ad9cba6c118d6798e932b3f0", size = 2389769, upload-time = "2025-11-28T17:04:16.443Z" }, - { url = "https://files.pythonhosted.org/packages/2d/dd/5bb369a44319d92ba25612511eb8ed2a6fa75239979e0388907525626902/fonttools-4.61.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ad751319dc532a79bdf628b8439af167181b4210a0cd28a8935ca615d9fdd727", size = 4893189, upload-time = "2025-11-28T17:04:18.398Z" }, - { url = "https://files.pythonhosted.org/packages/5e/02/51373fa8846bd22bb54e5efb30a824b417b058083f775a194a432f21a45f/fonttools-4.61.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2de14557d113faa5fb519f7f29c3abe4d69c17fe6a5a2595cc8cda7338029219", size = 4854415, upload-time = "2025-11-28T17:04:20.421Z" }, - { url = "https://files.pythonhosted.org/packages/8b/64/9cdbbb804577a7e6191448851c57e6a36eb02aa4bf6a9668b528c968e44e/fonttools-4.61.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:59587bbe455dbdf75354a9dbca1697a35a8903e01fab4248d6b98a17032cee52", size = 4870927, upload-time = "2025-11-28T17:04:22.625Z" }, - { url = "https://files.pythonhosted.org/packages/92/68/e40b22919dc96dc30a70b58fec609ab85112de950bdecfadf8dd478c5a88/fonttools-4.61.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:46cb3d9279f758ac0cf671dc3482da877104b65682679f01b246515db03dbb72", size = 4988674, upload-time = "2025-11-28T17:04:24.675Z" }, - { url = "https://files.pythonhosted.org/packages/9b/5c/e857349ce8aedb2451b9448282e86544b2b7f1c8b10ea0fe49b7cb369b72/fonttools-4.61.0-cp310-cp310-win32.whl", hash = "sha256:58b4f1b78dfbfe855bb8a6801b31b8cdcca0e2847ec769ad8e0b0b692832dd3b", size = 1497663, upload-time = "2025-11-28T17:04:26.598Z" }, - { url = "https://files.pythonhosted.org/packages/f9/0c/62961d5fe6f764d6cbc387ef2c001f5f610808c7aded837409836c0b3e7c/fonttools-4.61.0-cp310-cp310-win_amd64.whl", hash = "sha256:68704a8bbe0b61976262b255e90cde593dc0fe3676542d9b4d846bad2a890a76", size = 1546143, upload-time = "2025-11-28T17:04:28.432Z" }, { url = "https://files.pythonhosted.org/packages/fd/be/5aa89cdddf2863d8afbdc19eb8ec5d8d35d40eeeb8e6cf52c5ff1c2dbd33/fonttools-4.61.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a32a16951cbf113d38f1dd8551b277b6e06e0f6f776fece0f99f746d739e1be3", size = 2847553, upload-time = "2025-11-28T17:04:30.539Z" }, { url = "https://files.pythonhosted.org/packages/0d/3e/6ff643b07cead1236a534f51291ae2981721cf419135af5b740c002a66dd/fonttools-4.61.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:328a9c227984bebaf69f3ac9062265f8f6acc7ddf2e4e344c63358579af0aa3d", size = 2388298, upload-time = "2025-11-28T17:04:32.161Z" }, { url = "https://files.pythonhosted.org/packages/c3/15/fca8dfbe7b482e6f240b1aad0ed7c6e2e75e7a28efa3d3a03b570617b5e5/fonttools-4.61.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f0bafc8a3b3749c69cc610e5aa3da832d39c2a37a68f03d18ec9a02ecaac04a", size = 5054133, upload-time = "2025-11-28T17:04:34.035Z" }, @@ -961,8 +800,7 @@ dependencies = [ { name = "appnope", marker = "sys_platform == 'darwin'" }, { name = "comm" }, { name = "debugpy" }, - { name = "ipython", version = "8.37.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "ipython", version = "9.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "ipython" }, { name = "jupyter-client" }, { name = "jupyter-core" }, { name = "matplotlib-inline" }, @@ -978,50 +816,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a3/17/20c2552266728ceba271967b87919664ecc0e33efca29c3efc6baf88c5f9/ipykernel-7.1.0-py3-none-any.whl", hash = "sha256:763b5ec6c5b7776f6a8d7ce09b267693b4e5ce75cb50ae696aaefb3c85e1ea4c", size = 117968, upload-time = "2025-10-27T09:46:37.805Z" }, ] -[[package]] -name = "ipython" -version = "8.37.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.11'", -] -dependencies = [ - { name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" }, - { name = "decorator", marker = "python_full_version < '3.11'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, - { name = "jedi", marker = "python_full_version < '3.11'" }, - { name = "matplotlib-inline", marker = "python_full_version < '3.11'" }, - { name = "pexpect", marker = "python_full_version < '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "prompt-toolkit", marker = "python_full_version < '3.11'" }, - { name = "pygments", marker = "python_full_version < '3.11'" }, - { name = "stack-data", marker = "python_full_version < '3.11'" }, - { name = "traitlets", marker = "python_full_version < '3.11'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/85/31/10ac88f3357fc276dc8a64e8880c82e80e7459326ae1d0a211b40abf6665/ipython-8.37.0.tar.gz", hash = "sha256:ca815841e1a41a1e6b73a0b08f3038af9b2252564d01fc405356d34033012216", size = 5606088, upload-time = "2025-05-31T16:39:09.613Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/91/d0/274fbf7b0b12643cbbc001ce13e6a5b1607ac4929d1b11c72460152c9fc3/ipython-8.37.0-py3-none-any.whl", hash = "sha256:ed87326596b878932dbcb171e3e698845434d8c61b8d8cd474bf663041a9dcf2", size = 831864, upload-time = "2025-05-31T16:39:06.38Z" }, -] - [[package]] name = "ipython" version = "9.7.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.11'", -] dependencies = [ - { name = "colorama", marker = "python_full_version >= '3.11' and sys_platform == 'win32'" }, - { name = "decorator", marker = "python_full_version >= '3.11'" }, - { name = "ipython-pygments-lexers", marker = "python_full_version >= '3.11'" }, - { name = "jedi", marker = "python_full_version >= '3.11'" }, - { name = "matplotlib-inline", marker = "python_full_version >= '3.11'" }, - { name = "pexpect", marker = "python_full_version >= '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "prompt-toolkit", marker = "python_full_version >= '3.11'" }, - { name = "pygments", marker = "python_full_version >= '3.11'" }, - { name = "stack-data", marker = "python_full_version >= '3.11'" }, - { name = "traitlets", marker = "python_full_version >= '3.11'" }, - { name = "typing-extensions", marker = "python_full_version == '3.11.*'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "decorator" }, + { name = "ipython-pygments-lexers" }, + { name = "jedi" }, + { name = "matplotlib-inline" }, + { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit" }, + { name = "pygments" }, + { name = "stack-data" }, + { name = "traitlets" }, + { name = "typing-extensions", marker = "python_full_version < '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/29/e6/48c74d54039241a456add616464ea28c6ebf782e4110d419411b83dae06f/ipython-9.7.0.tar.gz", hash = "sha256:5f6de88c905a566c6a9d6c400a8fed54a638e1f7543d17aae2551133216b1e4e", size = 4422115, upload-time = "2025-11-05T12:18:54.646Z" } wheels = [ @@ -1033,7 +843,7 @@ name = "ipython-pygments-lexers" version = "1.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pygments", marker = "python_full_version >= '3.11'" }, + { name = "pygments" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } wheels = [ @@ -1135,19 +945,6 @@ version = "1.4.9" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/5c/3c/85844f1b0feb11ee581ac23fe5fce65cd049a200c1446708cc1b7f922875/kiwisolver-1.4.9.tar.gz", hash = "sha256:c3b22c26c6fd6811b0ae8363b95ca8ce4ea3c202d3d0975b2914310ceb1bcc4d", size = 97564, upload-time = "2025-08-10T21:27:49.279Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c6/5d/8ce64e36d4e3aac5ca96996457dcf33e34e6051492399a3f1fec5657f30b/kiwisolver-1.4.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b4b4d74bda2b8ebf4da5bd42af11d02d04428b2c32846e4c2c93219df8a7987b", size = 124159, upload-time = "2025-08-10T21:25:35.472Z" }, - { url = "https://files.pythonhosted.org/packages/96/1e/22f63ec454874378175a5f435d6ea1363dd33fb2af832c6643e4ccea0dc8/kiwisolver-1.4.9-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fb3b8132019ea572f4611d770991000d7f58127560c4889729248eb5852a102f", size = 66578, upload-time = "2025-08-10T21:25:36.73Z" }, - { url = "https://files.pythonhosted.org/packages/41/4c/1925dcfff47a02d465121967b95151c82d11027d5ec5242771e580e731bd/kiwisolver-1.4.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:84fd60810829c27ae375114cd379da1fa65e6918e1da405f356a775d49a62bcf", size = 65312, upload-time = "2025-08-10T21:25:37.658Z" }, - { url = "https://files.pythonhosted.org/packages/d4/42/0f333164e6307a0687d1eb9ad256215aae2f4bd5d28f4653d6cd319a3ba3/kiwisolver-1.4.9-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:b78efa4c6e804ecdf727e580dbb9cba85624d2e1c6b5cb059c66290063bd99a9", size = 1628458, upload-time = "2025-08-10T21:25:39.067Z" }, - { url = "https://files.pythonhosted.org/packages/86/b6/2dccb977d651943995a90bfe3495c2ab2ba5cd77093d9f2318a20c9a6f59/kiwisolver-1.4.9-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4efec7bcf21671db6a3294ff301d2fc861c31faa3c8740d1a94689234d1b415", size = 1225640, upload-time = "2025-08-10T21:25:40.489Z" }, - { url = "https://files.pythonhosted.org/packages/50/2b/362ebd3eec46c850ccf2bfe3e30f2fc4c008750011f38a850f088c56a1c6/kiwisolver-1.4.9-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:90f47e70293fc3688b71271100a1a5453aa9944a81d27ff779c108372cf5567b", size = 1244074, upload-time = "2025-08-10T21:25:42.221Z" }, - { url = "https://files.pythonhosted.org/packages/6f/bb/f09a1e66dab8984773d13184a10a29fe67125337649d26bdef547024ed6b/kiwisolver-1.4.9-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fdca1def57a2e88ef339de1737a1449d6dbf5fab184c54a1fca01d541317154", size = 1293036, upload-time = "2025-08-10T21:25:43.801Z" }, - { url = "https://files.pythonhosted.org/packages/ea/01/11ecf892f201cafda0f68fa59212edaea93e96c37884b747c181303fccd1/kiwisolver-1.4.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9cf554f21be770f5111a1690d42313e140355e687e05cf82cb23d0a721a64a48", size = 2175310, upload-time = "2025-08-10T21:25:45.045Z" }, - { url = "https://files.pythonhosted.org/packages/7f/5f/bfe11d5b934f500cc004314819ea92427e6e5462706a498c1d4fc052e08f/kiwisolver-1.4.9-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fc1795ac5cd0510207482c3d1d3ed781143383b8cfd36f5c645f3897ce066220", size = 2270943, upload-time = "2025-08-10T21:25:46.393Z" }, - { url = "https://files.pythonhosted.org/packages/3d/de/259f786bf71f1e03e73d87e2db1a9a3bcab64d7b4fd780167123161630ad/kiwisolver-1.4.9-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:ccd09f20ccdbbd341b21a67ab50a119b64a403b09288c27481575105283c1586", size = 2440488, upload-time = "2025-08-10T21:25:48.074Z" }, - { url = "https://files.pythonhosted.org/packages/1b/76/c989c278faf037c4d3421ec07a5c452cd3e09545d6dae7f87c15f54e4edf/kiwisolver-1.4.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:540c7c72324d864406a009d72f5d6856f49693db95d1fbb46cf86febef873634", size = 2246787, upload-time = "2025-08-10T21:25:49.442Z" }, - { url = "https://files.pythonhosted.org/packages/a2/55/c2898d84ca440852e560ca9f2a0d28e6e931ac0849b896d77231929900e7/kiwisolver-1.4.9-cp310-cp310-win_amd64.whl", hash = "sha256:ede8c6d533bc6601a47ad4046080d36b8fc99f81e6f1c17b0ac3c2dc91ac7611", size = 73730, upload-time = "2025-08-10T21:25:51.102Z" }, - { url = "https://files.pythonhosted.org/packages/e8/09/486d6ac523dd33b80b368247f238125d027964cfacb45c654841e88fb2ae/kiwisolver-1.4.9-cp310-cp310-win_arm64.whl", hash = "sha256:7b4da0d01ac866a57dd61ac258c5607b4cd677f63abaec7b148354d2b2cdd536", size = 65036, upload-time = "2025-08-10T21:25:52.063Z" }, { url = "https://files.pythonhosted.org/packages/6f/ab/c80b0d5a9d8a1a65f4f815f2afff9798b12c3b9f31f1d304dd233dd920e2/kiwisolver-1.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eb14a5da6dc7642b0f3a18f13654847cd8b7a2550e2645a5bda677862b03ba16", size = 124167, upload-time = "2025-08-10T21:25:53.403Z" }, { url = "https://files.pythonhosted.org/packages/a0/c0/27fe1a68a39cf62472a300e2879ffc13c0538546c359b86f149cc19f6ac3/kiwisolver-1.4.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:39a219e1c81ae3b103643d2aedb90f1ef22650deb266ff12a19e7773f3e5f089", size = 66579, upload-time = "2025-08-10T21:25:54.79Z" }, { url = "https://files.pythonhosted.org/packages/31/a2/a12a503ac1fd4943c50f9822678e8015a790a13b5490354c68afb8489814/kiwisolver-1.4.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2405a7d98604b87f3fc28b1716783534b1b4b8510d8142adca34ee0bc3c87543", size = 65309, upload-time = "2025-08-10T21:25:55.76Z" }, @@ -1225,11 +1022,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/99/dd/841e9a66c4715477ea0abc78da039832fbb09dac5c35c58dc4c41a407b8a/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:aedff62918805fb62d43a4aa2ecd4482c380dc76cd31bd7c8878588a61bd0369", size = 2391835, upload-time = "2025-08-10T21:27:34.23Z" }, { url = "https://files.pythonhosted.org/packages/0c/28/4b2e5c47a0da96896fdfdb006340ade064afa1e63675d01ea5ac222b6d52/kiwisolver-1.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:1fa333e8b2ce4d9660f2cda9c0e1b6bafcfb2457a9d259faa82289e73ec24891", size = 79988, upload-time = "2025-08-10T21:27:35.587Z" }, { url = "https://files.pythonhosted.org/packages/80/be/3578e8afd18c88cdf9cb4cffde75a96d2be38c5a903f1ed0ceec061bd09e/kiwisolver-1.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:4a48a2ce79d65d363597ef7b567ce3d14d68783d2b2263d98db3d9477805ba32", size = 70260, upload-time = "2025-08-10T21:27:36.606Z" }, - { url = "https://files.pythonhosted.org/packages/a2/63/fde392691690f55b38d5dd7b3710f5353bf7a8e52de93a22968801ab8978/kiwisolver-1.4.9-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:4d1d9e582ad4d63062d34077a9a1e9f3c34088a2ec5135b1f7190c07cf366527", size = 60183, upload-time = "2025-08-10T21:27:37.669Z" }, - { url = "https://files.pythonhosted.org/packages/27/b1/6aad34edfdb7cced27f371866f211332bba215bfd918ad3322a58f480d8b/kiwisolver-1.4.9-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:deed0c7258ceb4c44ad5ec7d9918f9f14fd05b2be86378d86cf50e63d1e7b771", size = 58675, upload-time = "2025-08-10T21:27:39.031Z" }, - { url = "https://files.pythonhosted.org/packages/9d/1a/23d855a702bb35a76faed5ae2ba3de57d323f48b1f6b17ee2176c4849463/kiwisolver-1.4.9-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0a590506f303f512dff6b7f75fd2fd18e16943efee932008fe7140e5fa91d80e", size = 80277, upload-time = "2025-08-10T21:27:40.129Z" }, - { url = "https://files.pythonhosted.org/packages/5a/5b/5239e3c2b8fb5afa1e8508f721bb77325f740ab6994d963e61b2b7abcc1e/kiwisolver-1.4.9-pp310-pypy310_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e09c2279a4d01f099f52d5c4b3d9e208e91edcbd1a175c9662a8b16e000fece9", size = 77994, upload-time = "2025-08-10T21:27:41.181Z" }, - { url = "https://files.pythonhosted.org/packages/f9/1c/5d4d468fb16f8410e596ed0eac02d2c68752aa7dc92997fe9d60a7147665/kiwisolver-1.4.9-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:c9e7cdf45d594ee04d5be1b24dd9d49f3d1590959b2271fb30b5ca2b262c00fb", size = 73744, upload-time = "2025-08-10T21:27:42.254Z" }, { url = "https://files.pythonhosted.org/packages/a3/0f/36d89194b5a32c054ce93e586d4049b6c2c22887b0eb229c61c68afd3078/kiwisolver-1.4.9-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:720e05574713db64c356e86732c0f3c5252818d05f9df320f0ad8380641acea5", size = 60104, upload-time = "2025-08-10T21:27:43.287Z" }, { url = "https://files.pythonhosted.org/packages/52/ba/4ed75f59e4658fd21fe7dde1fee0ac397c678ec3befba3fe6482d987af87/kiwisolver-1.4.9-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:17680d737d5335b552994a2008fab4c851bcd7de33094a82067ef3a576ff02fa", size = 58592, upload-time = "2025-08-10T21:27:44.314Z" }, { url = "https://files.pythonhosted.org/packages/33/01/a8ea7c5ea32a9b45ceeaee051a04c8ed4320f5add3c51bfa20879b765b70/kiwisolver-1.4.9-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:85b5352f94e490c028926ea567fc569c52ec79ce131dadb968d3853e809518c2", size = 80281, upload-time = "2025-08-10T21:27:45.369Z" }, @@ -1243,16 +1035,6 @@ version = "0.6.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/37/c3/cdff3c10e2e608490dc0a310ccf11ba777b3943ad4fcead2a2ade98c21e1/librt-0.6.3.tar.gz", hash = "sha256:c724a884e642aa2bbad52bb0203ea40406ad742368a5f90da1b220e970384aae", size = 54209, upload-time = "2025-11-29T14:01:56.058Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a6/84/859df8db21dedab2538ddfbe1d486dda3eb66a98c6ad7ba754a99e25e45e/librt-0.6.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:45660d26569cc22ed30adf583389d8a0d1b468f8b5e518fcf9bfe2cd298f9dd1", size = 27294, upload-time = "2025-11-29T14:00:35.053Z" }, - { url = "https://files.pythonhosted.org/packages/f7/01/ec3971cf9c4f827f17de6729bdfdbf01a67493147334f4ef8fac68936e3a/librt-0.6.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:54f3b2177fb892d47f8016f1087d21654b44f7fc4cf6571c1c6b3ea531ab0fcf", size = 27635, upload-time = "2025-11-29T14:00:36.496Z" }, - { url = "https://files.pythonhosted.org/packages/b4/f9/3efe201df84dd26388d2e0afa4c4dc668c8e406a3da7b7319152faf835a1/librt-0.6.3-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c5b31bed2c2f2fa1fcb4815b75f931121ae210dc89a3d607fb1725f5907f1437", size = 81768, upload-time = "2025-11-29T14:00:37.451Z" }, - { url = "https://files.pythonhosted.org/packages/0a/13/f63e60bc219b17f3d8f3d13423cd4972e597b0321c51cac7bfbdd5e1f7b9/librt-0.6.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f8ed5053ef9fb08d34f1fd80ff093ccbd1f67f147633a84cf4a7d9b09c0f089", size = 85884, upload-time = "2025-11-29T14:00:38.433Z" }, - { url = "https://files.pythonhosted.org/packages/c2/42/0068f14f39a79d1ce8a19d4988dd07371df1d0a7d3395fbdc8a25b1c9437/librt-0.6.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3f0e4bd9bcb0ee34fa3dbedb05570da50b285f49e52c07a241da967840432513", size = 85830, upload-time = "2025-11-29T14:00:39.418Z" }, - { url = "https://files.pythonhosted.org/packages/14/1c/87f5af3a9e6564f09e50c72f82fc3057fd42d1facc8b510a707d0438c4ad/librt-0.6.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d8f89c8d20dfa648a3f0a56861946eb00e5b00d6b00eea14bc5532b2fcfa8ef1", size = 88086, upload-time = "2025-11-29T14:00:40.555Z" }, - { url = "https://files.pythonhosted.org/packages/05/e5/22153b98b88a913b5b3f266f12e57df50a2a6960b3f8fcb825b1a0cfe40a/librt-0.6.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:ecc2c526547eacd20cb9fbba19a5268611dbc70c346499656d6cf30fae328977", size = 86470, upload-time = "2025-11-29T14:00:41.827Z" }, - { url = "https://files.pythonhosted.org/packages/18/3c/ea1edb587799b1edcc22444e0630fa422e32d7aaa5bfb5115b948acc2d1c/librt-0.6.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:fbedeb9b48614d662822ee514567d2d49a8012037fc7b4cd63f282642c2f4b7d", size = 89079, upload-time = "2025-11-29T14:00:42.882Z" }, - { url = "https://files.pythonhosted.org/packages/73/ad/50bb4ae6b07c9f3ab19653e0830a210533b30eb9a18d515efb5a2b9d0c7c/librt-0.6.3-cp310-cp310-win32.whl", hash = "sha256:0765b0fe0927d189ee14b087cd595ae636bef04992e03fe6dfdaa383866c8a46", size = 19820, upload-time = "2025-11-29T14:00:44.211Z" }, - { url = "https://files.pythonhosted.org/packages/7a/12/7426ee78f3b1dbe11a90619d54cb241ca924ca3c0ff9ade3992178e9b440/librt-0.6.3-cp310-cp310-win_amd64.whl", hash = "sha256:8c659f9fb8a2f16dc4131b803fa0144c1dadcb3ab24bb7914d01a6da58ae2457", size = 21332, upload-time = "2025-11-29T14:00:45.427Z" }, { url = "https://files.pythonhosted.org/packages/8b/80/bc60fd16fe24910bf5974fb914778a2e8540cef55385ab2cb04a0dfe42c4/librt-0.6.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:61348cc488b18d1b1ff9f3e5fcd5ac43ed22d3e13e862489d2267c2337285c08", size = 27285, upload-time = "2025-11-29T14:00:46.626Z" }, { url = "https://files.pythonhosted.org/packages/88/3c/26335536ed9ba097c79cffcee148393592e55758fe76d99015af3e47a6d0/librt-0.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:64645b757d617ad5f98c08e07620bc488d4bced9ced91c6279cec418f16056fa", size = 27629, upload-time = "2025-11-29T14:00:47.863Z" }, { url = "https://files.pythonhosted.org/packages/af/fd/2dcedeacfedee5d2eda23e7a49c1c12ce6221b5d58a13555f053203faafc/librt-0.6.3-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:26b8026393920320bb9a811b691d73c5981385d537ffc5b6e22e53f7b65d4122", size = 82039, upload-time = "2025-11-29T14:00:49.131Z" }, @@ -1325,14 +1107,6 @@ version = "4.4.5" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/57/51/f1b86d93029f418033dddf9b9f79c8d2641e7454080478ee2aab5123173e/lz4-4.4.5.tar.gz", hash = "sha256:5f0b9e53c1e82e88c10d7c180069363980136b9d7a8306c4dca4f760d60c39f0", size = 172886, upload-time = "2025-11-03T13:02:36.061Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7b/45/2466d73d79e3940cad4b26761f356f19fd33f4409c96f100e01a5c566909/lz4-4.4.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d221fa421b389ab2345640a508db57da36947a437dfe31aeddb8d5c7b646c22d", size = 207396, upload-time = "2025-11-03T13:01:24.965Z" }, - { url = "https://files.pythonhosted.org/packages/72/12/7da96077a7e8918a5a57a25f1254edaf76aefb457666fcc1066deeecd609/lz4-4.4.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7dc1e1e2dbd872f8fae529acd5e4839efd0b141eaa8ae7ce835a9fe80fbad89f", size = 207154, upload-time = "2025-11-03T13:01:26.922Z" }, - { url = "https://files.pythonhosted.org/packages/b8/0e/0fb54f84fd1890d4af5bc0a3c1fa69678451c1a6bd40de26ec0561bb4ec5/lz4-4.4.5-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e928ec2d84dc8d13285b4a9288fd6246c5cde4f5f935b479f50d986911f085e3", size = 1291053, upload-time = "2025-11-03T13:01:28.396Z" }, - { url = "https://files.pythonhosted.org/packages/15/45/8ce01cc2715a19c9e72b0e423262072c17d581a8da56e0bd4550f3d76a79/lz4-4.4.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:daffa4807ef54b927451208f5f85750c545a4abbff03d740835fc444cd97f758", size = 1278586, upload-time = "2025-11-03T13:01:29.906Z" }, - { url = "https://files.pythonhosted.org/packages/6d/34/7be9b09015e18510a09b8d76c304d505a7cbc66b775ec0b8f61442316818/lz4-4.4.5-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2a2b7504d2dffed3fd19d4085fe1cc30cf221263fd01030819bdd8d2bb101cf1", size = 1367315, upload-time = "2025-11-03T13:01:31.054Z" }, - { url = "https://files.pythonhosted.org/packages/2a/94/52cc3ec0d41e8d68c985ec3b2d33631f281d8b748fb44955bc0384c2627b/lz4-4.4.5-cp310-cp310-win32.whl", hash = "sha256:0846e6e78f374156ccf21c631de80967e03cc3c01c373c665789dc0c5431e7fc", size = 88173, upload-time = "2025-11-03T13:01:32.643Z" }, - { url = "https://files.pythonhosted.org/packages/ca/35/c3c0bdc409f551404355aeeabc8da343577d0e53592368062e371a3620e1/lz4-4.4.5-cp310-cp310-win_amd64.whl", hash = "sha256:7c4e7c44b6a31de77d4dc9772b7d2561937c9588a734681f70ec547cfbc51ecd", size = 99492, upload-time = "2025-11-03T13:01:33.813Z" }, - { url = "https://files.pythonhosted.org/packages/1d/02/4d88de2f1e97f9d05fd3d278fe412b08969bc94ff34942f5a3f09318144a/lz4-4.4.5-cp310-cp310-win_arm64.whl", hash = "sha256:15551280f5656d2206b9b43262799c89b25a25460416ec554075a8dc568e4397", size = 91280, upload-time = "2025-11-03T13:01:35.081Z" }, { url = "https://files.pythonhosted.org/packages/93/5b/6edcd23319d9e28b1bedf32768c3d1fd56eed8223960a2c47dacd2cec2af/lz4-4.4.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d6da84a26b3aa5da13a62e4b89ab36a396e9327de8cd48b436a3467077f8ccd4", size = 207391, upload-time = "2025-11-03T13:01:36.644Z" }, { url = "https://files.pythonhosted.org/packages/34/36/5f9b772e85b3d5769367a79973b8030afad0d6b724444083bad09becd66f/lz4-4.4.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:61d0ee03e6c616f4a8b69987d03d514e8896c8b1b7cc7598ad029e5c6aedfd43", size = 207146, upload-time = "2025-11-03T13:01:37.928Z" }, { url = "https://files.pythonhosted.org/packages/04/f4/f66da5647c0d72592081a37c8775feacc3d14d2625bbdaabd6307c274565/lz4-4.4.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:33dd86cea8375d8e5dd001e41f321d0a4b1eb7985f39be1b6a4f466cd480b8a7", size = 1292623, upload-time = "2025-11-03T13:01:39.341Z" }, @@ -1393,17 +1167,6 @@ version = "3.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, - { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, - { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, - { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, - { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, - { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, - { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, - { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, - { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, - { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, - { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, @@ -1477,13 +1240,11 @@ name = "matplotlib" version = "3.10.7" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "contourpy", version = "1.3.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "contourpy", version = "1.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "contourpy" }, { name = "cycler" }, { name = "fonttools" }, { name = "kiwisolver" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "numpy" }, { name = "packaging" }, { name = "pillow" }, { name = "pyparsing" }, @@ -1491,12 +1252,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/ae/e2/d2d5295be2f44c678ebaf3544ba32d20c1f9ef08c49fe47f496180e1db15/matplotlib-3.10.7.tar.gz", hash = "sha256:a06ba7e2a2ef9131c79c49e63dad355d2d878413a0376c1727c8b9335ff731c7", size = 34804865, upload-time = "2025-10-09T00:28:00.669Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6c/87/3932d5778ab4c025db22710b61f49ccaed3956c5cf46ffb2ffa7492b06d9/matplotlib-3.10.7-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:7ac81eee3b7c266dd92cee1cd658407b16c57eed08c7421fa354ed68234de380", size = 8247141, upload-time = "2025-10-09T00:26:06.023Z" }, - { url = "https://files.pythonhosted.org/packages/45/a8/bfed45339160102bce21a44e38a358a1134a5f84c26166de03fb4a53208f/matplotlib-3.10.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:667ecd5d8d37813a845053d8f5bf110b534c3c9f30e69ebd25d4701385935a6d", size = 8107995, upload-time = "2025-10-09T00:26:08.669Z" }, - { url = "https://files.pythonhosted.org/packages/e2/3c/5692a2d9a5ba848fda3f48d2b607037df96460b941a59ef236404b39776b/matplotlib-3.10.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc1c51b846aca49a5a8b44fbba6a92d583a35c64590ad9e1e950dc88940a4297", size = 8680503, upload-time = "2025-10-09T00:26:10.607Z" }, - { url = "https://files.pythonhosted.org/packages/ab/a0/86ace53c48b05d0e6e9c127b2ace097434901f3e7b93f050791c8243201a/matplotlib-3.10.7-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a11c2e9e72e7de09b7b72e62f3df23317c888299c875e2b778abf1eda8c0a42", size = 9514982, upload-time = "2025-10-09T00:26:12.594Z" }, - { url = "https://files.pythonhosted.org/packages/a6/81/ead71e2824da8f72640a64166d10e62300df4ae4db01a0bac56c5b39fa51/matplotlib-3.10.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f19410b486fdd139885ace124e57f938c1e6a3210ea13dd29cab58f5d4bc12c7", size = 9566429, upload-time = "2025-10-09T00:26:14.758Z" }, - { url = "https://files.pythonhosted.org/packages/65/7d/954b3067120456f472cce8fdcacaf4a5fcd522478db0c37bb243c7cb59dd/matplotlib-3.10.7-cp310-cp310-win_amd64.whl", hash = "sha256:b498e9e4022f93de2d5a37615200ca01297ceebbb56fe4c833f46862a490f9e3", size = 8108174, upload-time = "2025-10-09T00:26:17.015Z" }, { url = "https://files.pythonhosted.org/packages/fc/bc/0fb489005669127ec13f51be0c6adc074d7cf191075dab1da9fe3b7a3cfc/matplotlib-3.10.7-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:53b492410a6cd66c7a471de6c924f6ede976e963c0f3097a3b7abfadddc67d0a", size = 8257507, upload-time = "2025-10-09T00:26:19.073Z" }, { url = "https://files.pythonhosted.org/packages/e2/6a/d42588ad895279ff6708924645b5d2ed54a7fb2dc045c8a804e955aeace1/matplotlib-3.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d9749313deb729f08207718d29c86246beb2ea3fdba753595b55901dee5d2fd6", size = 8119565, upload-time = "2025-10-09T00:26:21.023Z" }, { url = "https://files.pythonhosted.org/packages/10/b7/4aa196155b4d846bd749cf82aa5a4c300cf55a8b5e0dfa5b722a63c0f8a0/matplotlib-3.10.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2222c7ba2cbde7fe63032769f6eb7e83ab3227f47d997a8453377709b7fe3a5a", size = 8692668, upload-time = "2025-10-09T00:26:22.967Z" }, @@ -1539,9 +1294,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e6/a5/85e2edf76ea0ad4288d174926d9454ea85f3ce5390cc4e6fab196cbf250b/matplotlib-3.10.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:702590829c30aada1e8cef0568ddbffa77ca747b4d6e36c6d173f66e301f89cc", size = 9594066, upload-time = "2025-10-09T00:27:43.694Z" }, { url = "https://files.pythonhosted.org/packages/39/69/9684368a314f6d83fe5c5ad2a4121a3a8e03723d2e5c8ea17b66c1bad0e7/matplotlib-3.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:f79d5de970fc90cd5591f60053aecfce1fcd736e0303d9f0bf86be649fa68fb8", size = 8342832, upload-time = "2025-10-09T00:27:45.543Z" }, { url = "https://files.pythonhosted.org/packages/04/5f/e22e08da14bc1a0894184640d47819d2338b792732e20d292bf86e5ab785/matplotlib-3.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:cb783436e47fcf82064baca52ce748af71725d0352e1d31564cbe9c95df92b9c", size = 8172585, upload-time = "2025-10-09T00:27:47.185Z" }, - { url = "https://files.pythonhosted.org/packages/1e/6c/a9bcf03e9afb2a873e0a5855f79bce476d1023f26f8212969f2b7504756c/matplotlib-3.10.7-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5c09cf8f2793f81368f49f118b6f9f937456362bee282eac575cca7f84cda537", size = 8241204, upload-time = "2025-10-09T00:27:48.806Z" }, - { url = "https://files.pythonhosted.org/packages/5b/fd/0e6f5aa762ed689d9fa8750b08f1932628ffa7ed30e76423c399d19407d2/matplotlib-3.10.7-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:de66744b2bb88d5cd27e80dfc2ec9f0517d0a46d204ff98fe9e5f2864eb67657", size = 8104607, upload-time = "2025-10-09T00:27:50.876Z" }, - { url = "https://files.pythonhosted.org/packages/b9/a9/21c9439d698fac5f0de8fc68b2405b738ed1f00e1279c76f2d9aa5521ead/matplotlib-3.10.7-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:53cc80662dd197ece414dd5b66e07370201515a3eaf52e7c518c68c16814773b", size = 8682257, upload-time = "2025-10-09T00:27:52.597Z" }, { url = "https://files.pythonhosted.org/packages/58/8f/76d5dc21ac64a49e5498d7f0472c0781dae442dd266a67458baec38288ec/matplotlib-3.10.7-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:15112bcbaef211bd663fa935ec33313b948e214454d949b723998a43357b17b0", size = 8252283, upload-time = "2025-10-09T00:27:54.739Z" }, { url = "https://files.pythonhosted.org/packages/27/0d/9c5d4c2317feb31d819e38c9f947c942f42ebd4eb935fc6fd3518a11eaa7/matplotlib-3.10.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d2a959c640cdeecdd2ec3136e8ea0441da59bcaf58d67e9c590740addba2cb68", size = 8116733, upload-time = "2025-10-09T00:27:56.406Z" }, { url = "https://files.pythonhosted.org/packages/9a/cc/3fe688ff1355010937713164caacf9ed443675ac48a997bab6ed23b3f7c0/matplotlib-3.10.7-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3886e47f64611046bc1db523a09dd0a0a6bed6081e6f90e13806dd1d1d1b5e91", size = 8693919, upload-time = "2025-10-09T00:27:58.41Z" }, @@ -1604,14 +1356,6 @@ version = "1.1.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/4d/f2/bfb55a6236ed8725a96b0aa3acbd0ec17588e6a2c3b62a93eb513ed8783f/msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e", size = 173581, upload-time = "2025-10-08T09:15:56.596Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f5/a2/3b68a9e769db68668b25c6108444a35f9bd163bb848c0650d516761a59c0/msgpack-1.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0051fffef5a37ca2cd16978ae4f0aef92f164df86823871b5162812bebecd8e2", size = 81318, upload-time = "2025-10-08T09:14:38.722Z" }, - { url = "https://files.pythonhosted.org/packages/5b/e1/2b720cc341325c00be44e1ed59e7cfeae2678329fbf5aa68f5bda57fe728/msgpack-1.1.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a605409040f2da88676e9c9e5853b3449ba8011973616189ea5ee55ddbc5bc87", size = 83786, upload-time = "2025-10-08T09:14:40.082Z" }, - { url = "https://files.pythonhosted.org/packages/71/e5/c2241de64bfceac456b140737812a2ab310b10538a7b34a1d393b748e095/msgpack-1.1.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b696e83c9f1532b4af884045ba7f3aa741a63b2bc22617293a2c6a7c645f251", size = 398240, upload-time = "2025-10-08T09:14:41.151Z" }, - { url = "https://files.pythonhosted.org/packages/b7/09/2a06956383c0fdebaef5aa9246e2356776f12ea6f2a44bd1368abf0e46c4/msgpack-1.1.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:365c0bbe981a27d8932da71af63ef86acc59ed5c01ad929e09a0b88c6294e28a", size = 406070, upload-time = "2025-10-08T09:14:42.821Z" }, - { url = "https://files.pythonhosted.org/packages/0e/74/2957703f0e1ef20637d6aead4fbb314330c26f39aa046b348c7edcf6ca6b/msgpack-1.1.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:41d1a5d875680166d3ac5c38573896453bbbea7092936d2e107214daf43b1d4f", size = 393403, upload-time = "2025-10-08T09:14:44.38Z" }, - { url = "https://files.pythonhosted.org/packages/a5/09/3bfc12aa90f77b37322fc33e7a8a7c29ba7c8edeadfa27664451801b9860/msgpack-1.1.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:354e81bcdebaab427c3df4281187edc765d5d76bfb3a7c125af9da7a27e8458f", size = 398947, upload-time = "2025-10-08T09:14:45.56Z" }, - { url = "https://files.pythonhosted.org/packages/4b/4f/05fcebd3b4977cb3d840f7ef6b77c51f8582086de5e642f3fefee35c86fc/msgpack-1.1.2-cp310-cp310-win32.whl", hash = "sha256:e64c8d2f5e5d5fda7b842f55dec6133260ea8f53c4257d64494c534f306bf7a9", size = 64769, upload-time = "2025-10-08T09:14:47.334Z" }, - { url = "https://files.pythonhosted.org/packages/d0/3e/b4547e3a34210956382eed1c85935fff7e0f9b98be3106b3745d7dec9c5e/msgpack-1.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:db6192777d943bdaaafb6ba66d44bf65aa0e9c5616fa1d2da9bb08828c6b39aa", size = 71293, upload-time = "2025-10-08T09:14:48.665Z" }, { url = "https://files.pythonhosted.org/packages/2c/97/560d11202bcd537abca693fd85d81cebe2107ba17301de42b01ac1677b69/msgpack-1.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2e86a607e558d22985d856948c12a3fa7b42efad264dca8a3ebbcfa2735d786c", size = 82271, upload-time = "2025-10-08T09:14:49.967Z" }, { url = "https://files.pythonhosted.org/packages/83/04/28a41024ccbd67467380b6fb440ae916c1e4f25e2cd4c63abe6835ac566e/msgpack-1.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:283ae72fc89da59aa004ba147e8fc2f766647b1251500182fac0350d8af299c0", size = 84914, upload-time = "2025-10-08T09:14:50.958Z" }, { url = "https://files.pythonhosted.org/packages/71/46/b817349db6886d79e57a966346cf0902a426375aadc1e8e7a86a75e22f19/msgpack-1.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61c8aa3bd513d87c72ed0b37b53dd5c5a0f58f2ff9f26e1555d3bd7948fb7296", size = 416962, upload-time = "2025-10-08T09:14:51.997Z" }, @@ -1665,8 +1409,7 @@ version = "0.4.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "msgpack" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "numpy" }, ] sdist = { url = "https://files.pythonhosted.org/packages/08/94/61e8aee142733ebfdc400a05bdac6e1763c4514bba3b42743d223f388450/msgpack-numpy-0.4.8.tar.gz", hash = "sha256:c667d3180513422f9c7545be5eec5d296dcbb357e06f72ed39cc683797556e69", size = 10923, upload-time = "2022-06-09T03:43:08.739Z" } wheels = [ @@ -1681,17 +1424,10 @@ dependencies = [ { name = "librt" }, { name = "mypy-extensions" }, { name = "pathspec" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f9/b5/b58cdc25fadd424552804bf410855d52324183112aa004f0732c5f6324cf/mypy-1.19.0.tar.gz", hash = "sha256:f6b874ca77f733222641e5c46e4711648c4037ea13646fd0cdc814c2eaec2528", size = 3579025, upload-time = "2025-11-28T15:49:01.26Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/98/8f/55fb488c2b7dabd76e3f30c10f7ab0f6190c1fcbc3e97b1e588ec625bbe2/mypy-1.19.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6148ede033982a8c5ca1143de34c71836a09f105068aaa8b7d5edab2b053e6c8", size = 13093239, upload-time = "2025-11-28T15:45:11.342Z" }, - { url = "https://files.pythonhosted.org/packages/72/1b/278beea978456c56b3262266274f335c3ba5ff2c8108b3b31bec1ffa4c1d/mypy-1.19.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a9ac09e52bb0f7fb912f5d2a783345c72441a08ef56ce3e17c1752af36340a39", size = 12156128, upload-time = "2025-11-28T15:46:02.566Z" }, - { url = "https://files.pythonhosted.org/packages/21/f8/e06f951902e136ff74fd7a4dc4ef9d884faeb2f8eb9c49461235714f079f/mypy-1.19.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:11f7254c15ab3f8ed68f8e8f5cbe88757848df793e31c36aaa4d4f9783fd08ab", size = 12753508, upload-time = "2025-11-28T15:44:47.538Z" }, - { url = "https://files.pythonhosted.org/packages/67/5a/d035c534ad86e09cee274d53cf0fd769c0b29ca6ed5b32e205be3c06878c/mypy-1.19.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318ba74f75899b0e78b847d8c50821e4c9637c79d9a59680fc1259f29338cb3e", size = 13507553, upload-time = "2025-11-28T15:44:39.26Z" }, - { url = "https://files.pythonhosted.org/packages/6a/17/c4a5498e00071ef29e483a01558b285d086825b61cf1fb2629fbdd019d94/mypy-1.19.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cf7d84f497f78b682edd407f14a7b6e1a2212b433eedb054e2081380b7395aa3", size = 13792898, upload-time = "2025-11-28T15:44:31.102Z" }, - { url = "https://files.pythonhosted.org/packages/67/f6/bb542422b3ee4399ae1cdc463300d2d91515ab834c6233f2fd1d52fa21e0/mypy-1.19.0-cp310-cp310-win_amd64.whl", hash = "sha256:c3385246593ac2b97f155a0e9639be906e73534630f663747c71908dfbf26134", size = 10048835, upload-time = "2025-11-28T15:48:15.744Z" }, { url = "https://files.pythonhosted.org/packages/0f/d2/010fb171ae5ac4a01cc34fbacd7544531e5ace95c35ca166dd8fd1b901d0/mypy-1.19.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a31e4c28e8ddb042c84c5e977e28a21195d086aaffaf08b016b78e19c9ef8106", size = 13010563, upload-time = "2025-11-28T15:48:23.975Z" }, { url = "https://files.pythonhosted.org/packages/41/6b/63f095c9f1ce584fdeb595d663d49e0980c735a1d2004720ccec252c5d47/mypy-1.19.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:34ec1ac66d31644f194b7c163d7f8b8434f1b49719d403a5d26c87fff7e913f7", size = 12077037, upload-time = "2025-11-28T15:47:51.582Z" }, { url = "https://files.pythonhosted.org/packages/d7/83/6cb93d289038d809023ec20eb0b48bbb1d80af40511fa077da78af6ff7c7/mypy-1.19.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb64b0ba5980466a0f3f9990d1c582bcab8db12e29815ecb57f1408d99b4bff7", size = 12680255, upload-time = "2025-11-28T15:46:57.628Z" }, @@ -1738,8 +1474,7 @@ dependencies = [ { name = "markdown-it-py" }, { name = "mdit-py-plugins" }, { name = "pyyaml" }, - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx" }, ] sdist = { url = "https://files.pythonhosted.org/packages/66/a5/9626ba4f73555b3735ad86247a8077d4603aa8628537687c839ab08bfe44/myst_parser-4.0.1.tar.gz", hash = "sha256:5cfea715e4f3574138aecbf7d54132296bfd72bb614d31168f48c477a830a7c4", size = 93985, upload-time = "2025-02-12T10:53:03.833Z" } wheels = [ @@ -1755,25 +1490,10 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" }, ] -[[package]] -name = "networkx" -version = "3.4.2" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.11'", -] -sdist = { url = "https://files.pythonhosted.org/packages/fd/1d/06475e1cd5264c0b870ea2cc6fdb3e37177c1e565c43f56ff17a10e3937f/networkx-3.4.2.tar.gz", hash = "sha256:307c3669428c5362aab27c8a1260aa8f47c4e91d3891f48be0141738d8d053e1", size = 2151368, upload-time = "2024-10-21T12:39:38.695Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/54/dd730b32ea14ea797530a4479b2ed46a6fb250f682a9cfb997e968bf0261/networkx-3.4.2-py3-none-any.whl", hash = "sha256:df5d4365b724cf81b8c6a7312509d0c22386097011ad1abe274afd5e9d3bbc5f", size = 1723263, upload-time = "2024-10-21T12:39:36.247Z" }, -] - [[package]] name = "networkx" version = "3.6" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.11'", -] sdist = { url = "https://files.pythonhosted.org/packages/e8/fc/7b6fd4d22c8c4dc5704430140d8b3f520531d4fe7328b8f8d03f5a7950e8/networkx-3.6.tar.gz", hash = "sha256:285276002ad1f7f7da0f7b42f004bcba70d381e936559166363707fdad3d72ad", size = 2511464, upload-time = "2025-11-24T03:03:47.158Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/07/c7/d64168da60332c17d24c0d2f08bdf3987e8d1ae9d84b5bbd0eec2eb26a55/networkx-3.6-py3-none-any.whl", hash = "sha256:cdb395b105806062473d3be36458d8f1459a4e4b98e236a66c3a48996e07684f", size = 2063713, upload-time = "2025-11-24T03:03:45.21Z" }, @@ -1788,78 +1508,10 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, ] -[[package]] -name = "numpy" -version = "2.2.6" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.11'", -] -sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/3e/ed6db5be21ce87955c0cbd3009f2803f59fa08df21b5df06862e2d8e2bdd/numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb", size = 21165245, upload-time = "2025-05-17T21:27:58.555Z" }, - { url = "https://files.pythonhosted.org/packages/22/c2/4b9221495b2a132cc9d2eb862e21d42a009f5a60e45fc44b00118c174bff/numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90", size = 14360048, upload-time = "2025-05-17T21:28:21.406Z" }, - { url = "https://files.pythonhosted.org/packages/fd/77/dc2fcfc66943c6410e2bf598062f5959372735ffda175b39906d54f02349/numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163", size = 5340542, upload-time = "2025-05-17T21:28:30.931Z" }, - { url = "https://files.pythonhosted.org/packages/7a/4f/1cb5fdc353a5f5cc7feb692db9b8ec2c3d6405453f982435efc52561df58/numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf", size = 6878301, upload-time = "2025-05-17T21:28:41.613Z" }, - { url = "https://files.pythonhosted.org/packages/eb/17/96a3acd228cec142fcb8723bd3cc39c2a474f7dcf0a5d16731980bcafa95/numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83", size = 14297320, upload-time = "2025-05-17T21:29:02.78Z" }, - { url = "https://files.pythonhosted.org/packages/b4/63/3de6a34ad7ad6646ac7d2f55ebc6ad439dbbf9c4370017c50cf403fb19b5/numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915", size = 16801050, upload-time = "2025-05-17T21:29:27.675Z" }, - { url = "https://files.pythonhosted.org/packages/07/b6/89d837eddef52b3d0cec5c6ba0456c1bf1b9ef6a6672fc2b7873c3ec4e2e/numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680", size = 15807034, upload-time = "2025-05-17T21:29:51.102Z" }, - { url = "https://files.pythonhosted.org/packages/01/c8/dc6ae86e3c61cfec1f178e5c9f7858584049b6093f843bca541f94120920/numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289", size = 18614185, upload-time = "2025-05-17T21:30:18.703Z" }, - { url = "https://files.pythonhosted.org/packages/5b/c5/0064b1b7e7c89137b471ccec1fd2282fceaae0ab3a9550f2568782d80357/numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d", size = 6527149, upload-time = "2025-05-17T21:30:29.788Z" }, - { url = "https://files.pythonhosted.org/packages/a3/dd/4b822569d6b96c39d1215dbae0582fd99954dcbcf0c1a13c61783feaca3f/numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3", size = 12904620, upload-time = "2025-05-17T21:30:48.994Z" }, - { url = "https://files.pythonhosted.org/packages/da/a8/4f83e2aa666a9fbf56d6118faaaf5f1974d456b1823fda0a176eff722839/numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae", size = 21176963, upload-time = "2025-05-17T21:31:19.36Z" }, - { url = "https://files.pythonhosted.org/packages/b3/2b/64e1affc7972decb74c9e29e5649fac940514910960ba25cd9af4488b66c/numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a", size = 14406743, upload-time = "2025-05-17T21:31:41.087Z" }, - { url = "https://files.pythonhosted.org/packages/4a/9f/0121e375000b5e50ffdd8b25bf78d8e1a5aa4cca3f185d41265198c7b834/numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42", size = 5352616, upload-time = "2025-05-17T21:31:50.072Z" }, - { url = "https://files.pythonhosted.org/packages/31/0d/b48c405c91693635fbe2dcd7bc84a33a602add5f63286e024d3b6741411c/numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491", size = 6889579, upload-time = "2025-05-17T21:32:01.712Z" }, - { url = "https://files.pythonhosted.org/packages/52/b8/7f0554d49b565d0171eab6e99001846882000883998e7b7d9f0d98b1f934/numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a", size = 14312005, upload-time = "2025-05-17T21:32:23.332Z" }, - { url = "https://files.pythonhosted.org/packages/b3/dd/2238b898e51bd6d389b7389ffb20d7f4c10066d80351187ec8e303a5a475/numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf", size = 16821570, upload-time = "2025-05-17T21:32:47.991Z" }, - { url = "https://files.pythonhosted.org/packages/83/6c/44d0325722cf644f191042bf47eedad61c1e6df2432ed65cbe28509d404e/numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1", size = 15818548, upload-time = "2025-05-17T21:33:11.728Z" }, - { url = "https://files.pythonhosted.org/packages/ae/9d/81e8216030ce66be25279098789b665d49ff19eef08bfa8cb96d4957f422/numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab", size = 18620521, upload-time = "2025-05-17T21:33:39.139Z" }, - { url = "https://files.pythonhosted.org/packages/6a/fd/e19617b9530b031db51b0926eed5345ce8ddc669bb3bc0044b23e275ebe8/numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47", size = 6525866, upload-time = "2025-05-17T21:33:50.273Z" }, - { url = "https://files.pythonhosted.org/packages/31/0a/f354fb7176b81747d870f7991dc763e157a934c717b67b58456bc63da3df/numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303", size = 12907455, upload-time = "2025-05-17T21:34:09.135Z" }, - { url = "https://files.pythonhosted.org/packages/82/5d/c00588b6cf18e1da539b45d3598d3557084990dcc4331960c15ee776ee41/numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff", size = 20875348, upload-time = "2025-05-17T21:34:39.648Z" }, - { url = "https://files.pythonhosted.org/packages/66/ee/560deadcdde6c2f90200450d5938f63a34b37e27ebff162810f716f6a230/numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c", size = 14119362, upload-time = "2025-05-17T21:35:01.241Z" }, - { url = "https://files.pythonhosted.org/packages/3c/65/4baa99f1c53b30adf0acd9a5519078871ddde8d2339dc5a7fde80d9d87da/numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3", size = 5084103, upload-time = "2025-05-17T21:35:10.622Z" }, - { url = "https://files.pythonhosted.org/packages/cc/89/e5a34c071a0570cc40c9a54eb472d113eea6d002e9ae12bb3a8407fb912e/numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282", size = 6625382, upload-time = "2025-05-17T21:35:21.414Z" }, - { url = "https://files.pythonhosted.org/packages/f8/35/8c80729f1ff76b3921d5c9487c7ac3de9b2a103b1cd05e905b3090513510/numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87", size = 14018462, upload-time = "2025-05-17T21:35:42.174Z" }, - { url = "https://files.pythonhosted.org/packages/8c/3d/1e1db36cfd41f895d266b103df00ca5b3cbe965184df824dec5c08c6b803/numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249", size = 16527618, upload-time = "2025-05-17T21:36:06.711Z" }, - { url = "https://files.pythonhosted.org/packages/61/c6/03ed30992602c85aa3cd95b9070a514f8b3c33e31124694438d88809ae36/numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49", size = 15505511, upload-time = "2025-05-17T21:36:29.965Z" }, - { url = "https://files.pythonhosted.org/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de", size = 18313783, upload-time = "2025-05-17T21:36:56.883Z" }, - { url = "https://files.pythonhosted.org/packages/57/0a/72d5a3527c5ebffcd47bde9162c39fae1f90138c961e5296491ce778e682/numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4", size = 6246506, upload-time = "2025-05-17T21:37:07.368Z" }, - { url = "https://files.pythonhosted.org/packages/36/fa/8c9210162ca1b88529ab76b41ba02d433fd54fecaf6feb70ef9f124683f1/numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2", size = 12614190, upload-time = "2025-05-17T21:37:26.213Z" }, - { url = "https://files.pythonhosted.org/packages/f9/5c/6657823f4f594f72b5471f1db1ab12e26e890bb2e41897522d134d2a3e81/numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84", size = 20867828, upload-time = "2025-05-17T21:37:56.699Z" }, - { url = "https://files.pythonhosted.org/packages/dc/9e/14520dc3dadf3c803473bd07e9b2bd1b69bc583cb2497b47000fed2fa92f/numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b", size = 14143006, upload-time = "2025-05-17T21:38:18.291Z" }, - { url = "https://files.pythonhosted.org/packages/4f/06/7e96c57d90bebdce9918412087fc22ca9851cceaf5567a45c1f404480e9e/numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d", size = 5076765, upload-time = "2025-05-17T21:38:27.319Z" }, - { url = "https://files.pythonhosted.org/packages/73/ed/63d920c23b4289fdac96ddbdd6132e9427790977d5457cd132f18e76eae0/numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566", size = 6617736, upload-time = "2025-05-17T21:38:38.141Z" }, - { url = "https://files.pythonhosted.org/packages/85/c5/e19c8f99d83fd377ec8c7e0cf627a8049746da54afc24ef0a0cb73d5dfb5/numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f", size = 14010719, upload-time = "2025-05-17T21:38:58.433Z" }, - { url = "https://files.pythonhosted.org/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f", size = 16526072, upload-time = "2025-05-17T21:39:22.638Z" }, - { url = "https://files.pythonhosted.org/packages/b2/6c/04b5f47f4f32f7c2b0e7260442a8cbcf8168b0e1a41ff1495da42f42a14f/numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868", size = 15503213, upload-time = "2025-05-17T21:39:45.865Z" }, - { url = "https://files.pythonhosted.org/packages/17/0a/5cd92e352c1307640d5b6fec1b2ffb06cd0dabe7d7b8227f97933d378422/numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d", size = 18316632, upload-time = "2025-05-17T21:40:13.331Z" }, - { url = "https://files.pythonhosted.org/packages/f0/3b/5cba2b1d88760ef86596ad0f3d484b1cbff7c115ae2429678465057c5155/numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd", size = 6244532, upload-time = "2025-05-17T21:43:46.099Z" }, - { url = "https://files.pythonhosted.org/packages/cb/3b/d58c12eafcb298d4e6d0d40216866ab15f59e55d148a5658bb3132311fcf/numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c", size = 12610885, upload-time = "2025-05-17T21:44:05.145Z" }, - { url = "https://files.pythonhosted.org/packages/6b/9e/4bf918b818e516322db999ac25d00c75788ddfd2d2ade4fa66f1f38097e1/numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6", size = 20963467, upload-time = "2025-05-17T21:40:44Z" }, - { url = "https://files.pythonhosted.org/packages/61/66/d2de6b291507517ff2e438e13ff7b1e2cdbdb7cb40b3ed475377aece69f9/numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda", size = 14225144, upload-time = "2025-05-17T21:41:05.695Z" }, - { url = "https://files.pythonhosted.org/packages/e4/25/480387655407ead912e28ba3a820bc69af9adf13bcbe40b299d454ec011f/numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40", size = 5200217, upload-time = "2025-05-17T21:41:15.903Z" }, - { url = "https://files.pythonhosted.org/packages/aa/4a/6e313b5108f53dcbf3aca0c0f3e9c92f4c10ce57a0a721851f9785872895/numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8", size = 6712014, upload-time = "2025-05-17T21:41:27.321Z" }, - { url = "https://files.pythonhosted.org/packages/b7/30/172c2d5c4be71fdf476e9de553443cf8e25feddbe185e0bd88b096915bcc/numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f", size = 14077935, upload-time = "2025-05-17T21:41:49.738Z" }, - { url = "https://files.pythonhosted.org/packages/12/fb/9e743f8d4e4d3c710902cf87af3512082ae3d43b945d5d16563f26ec251d/numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa", size = 16600122, upload-time = "2025-05-17T21:42:14.046Z" }, - { url = "https://files.pythonhosted.org/packages/12/75/ee20da0e58d3a66f204f38916757e01e33a9737d0b22373b3eb5a27358f9/numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571", size = 15586143, upload-time = "2025-05-17T21:42:37.464Z" }, - { url = "https://files.pythonhosted.org/packages/76/95/bef5b37f29fc5e739947e9ce5179ad402875633308504a52d188302319c8/numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1", size = 18385260, upload-time = "2025-05-17T21:43:05.189Z" }, - { url = "https://files.pythonhosted.org/packages/09/04/f2f83279d287407cf36a7a8053a5abe7be3622a4363337338f2585e4afda/numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff", size = 6377225, upload-time = "2025-05-17T21:43:16.254Z" }, - { url = "https://files.pythonhosted.org/packages/67/0e/35082d13c09c02c011cf21570543d202ad929d961c02a147493cb0c2bdf5/numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06", size = 12771374, upload-time = "2025-05-17T21:43:35.479Z" }, - { url = "https://files.pythonhosted.org/packages/9e/3b/d94a75f4dbf1ef5d321523ecac21ef23a3cd2ac8b78ae2aac40873590229/numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d", size = 21040391, upload-time = "2025-05-17T21:44:35.948Z" }, - { url = "https://files.pythonhosted.org/packages/17/f4/09b2fa1b58f0fb4f7c7963a1649c64c4d315752240377ed74d9cd878f7b5/numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db", size = 6786754, upload-time = "2025-05-17T21:44:47.446Z" }, - { url = "https://files.pythonhosted.org/packages/af/30/feba75f143bdc868a1cc3f44ccfa6c4b9ec522b36458e738cd00f67b573f/numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543", size = 16643476, upload-time = "2025-05-17T21:45:11.871Z" }, - { url = "https://files.pythonhosted.org/packages/37/48/ac2a9584402fb6c0cd5b5d1a91dcf176b15760130dd386bbafdbfe3640bf/numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00", size = 12812666, upload-time = "2025-05-17T21:45:31.426Z" }, -] - [[package]] name = "numpy" version = "2.3.5" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.11'", -] sdist = { url = "https://files.pythonhosted.org/packages/76/65/21b3bc86aac7b8f2862db1e808f1ea22b028e30a225a34a5ede9bf8678f2/numpy-2.3.5.tar.gz", hash = "sha256:784db1dcdab56bf0517743e746dfb0f885fc68d948aba86eeec2cba234bdf1c0", size = 20584950, upload-time = "2025-11-16T22:52:42.067Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/43/77/84dd1d2e34d7e2792a236ba180b5e8fcc1e3e414e761ce0253f63d7f572e/numpy-2.3.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:de5672f4a7b200c15a4127042170a694d4df43c992948f5e1af57f0174beed10", size = 17034641, upload-time = "2025-11-16T22:49:19.336Z" }, @@ -1942,9 +1594,7 @@ name = "numpydoc" version = "1.10.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "sphinx" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e9/3c/dfccc9e7dee357fb2aa13c3890d952a370dd0ed071e0f7ed62ed0df567c1/numpydoc-1.10.0.tar.gz", hash = "sha256:3f7970f6eee30912260a6b31ac72bba2432830cd6722569ec17ee8d3ef5ffa01", size = 94027, upload-time = "2025-12-02T16:39:12.937Z" } wheels = [ @@ -1969,60 +1619,32 @@ name = "ophyd" version = "1.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "networkx", version = "3.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "networkx" }, + { name = "numpy" }, { name = "opentelemetry-api" }, { name = "packaging" }, - { name = "pint", version = "0.24.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "pint", version = "0.25.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pint" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/54/02465637d447291dd8596584c9b74e8f82cc527d3c0be8dd8ed267feec42/ophyd-1.11.0.tar.gz", hash = "sha256:75e0489436035fe7ba40476f2c2ea7fd08ed02d7cd462b722e347684447ec9cc", size = 312894, upload-time = "2025-09-04T15:34:38.221Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/46/59/e11136fe6db0b1f3aea14de7bc36122aaa0ca67b715e400d45974b8848be/ophyd-1.11.0-py3-none-any.whl", hash = "sha256:216ee73f61409550916aed03773ac5dbe0e43dc2e463d3b865212d1e7ca7b877", size = 279624, upload-time = "2025-09-04T15:34:36.865Z" }, ] -[[package]] -name = "ophyd-async" -version = "0.12.3" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.11'", -] -dependencies = [ - { name = "bluesky", marker = "python_full_version < '3.11'" }, - { name = "colorlog", marker = "python_full_version < '3.11'" }, - { name = "event-model", marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "pydantic", marker = "python_full_version < '3.11'" }, - { name = "pydantic-numpy", marker = "python_full_version < '3.11'" }, - { name = "pyyaml", marker = "python_full_version < '3.11'" }, - { name = "stamina", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/aa/9a/d7a5a810e85c0c4c632db20794baece23709693ea88492650e094fdb3ead/ophyd_async-0.12.3.tar.gz", hash = "sha256:4800fede1235ac14eb48462c98ca45ea544cf579a74b3c9012915cf91ea04e2f", size = 317183, upload-time = "2025-07-31T14:03:46.799Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/cb/75a17d45ae070fa4af0b6bf79fb820b9d9137bf09f485a6728f71e4a062e/ophyd_async-0.12.3-py3-none-any.whl", hash = "sha256:3d0e98f9ab0169776dad24c072ef19ece6d53faa2933e55d3c139c525761b802", size = 180818, upload-time = "2025-07-31T14:03:45.61Z" }, -] - [[package]] name = "ophyd-async" version = "0.14.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.11'", -] dependencies = [ - { name = "bluesky", marker = "python_full_version >= '3.11'" }, - { name = "colorlog", marker = "python_full_version >= '3.11'" }, - { name = "event-model", marker = "python_full_version >= '3.11'" }, - { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "pydantic", marker = "python_full_version >= '3.11'" }, - { name = "pydantic-numpy", marker = "python_full_version >= '3.11'" }, - { name = "pyyaml", marker = "python_full_version >= '3.11'" }, - { name = "scanspec", marker = "python_full_version >= '3.11'" }, - { name = "stamina", marker = "python_full_version >= '3.11'" }, - { name = "velocity-profile", marker = "python_full_version >= '3.11'" }, + { name = "bluesky" }, + { name = "colorlog" }, + { name = "event-model" }, + { name = "numpy" }, + { name = "pydantic" }, + { name = "pydantic-numpy" }, + { name = "pyyaml" }, + { name = "scanspec" }, + { name = "stamina" }, + { name = "velocity-profile" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5e/b6/c17b511ccaa2c5b07f31f52098495a7c4fd7c8163be8321087720f56dd07/ophyd_async-0.14.0.tar.gz", hash = "sha256:f2fd2b48be15be6afd16094255044ef665c804b6b20a2a61097d288f4f09452e", size = 523384, upload-time = "2025-11-24T14:58:48.625Z" } wheels = [ @@ -2086,17 +1708,6 @@ version = "12.0.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/cace85a1b0c9775a9f8f5d5423c8261c858760e2466c79b2dd184638b056/pillow-12.0.0.tar.gz", hash = "sha256:87d4f8125c9988bfbed67af47dd7a953e2fc7b0cc1e7800ec6d2080d490bb353", size = 47008828, upload-time = "2025-10-15T18:24:14.008Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/08/26e68b6b5da219c2a2cb7b563af008b53bb8e6b6fcb3fa40715fcdb2523a/pillow-12.0.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:3adfb466bbc544b926d50fe8f4a4e6abd8c6bffd28a26177594e6e9b2b76572b", size = 5289809, upload-time = "2025-10-15T18:21:27.791Z" }, - { url = "https://files.pythonhosted.org/packages/cb/e9/4e58fb097fb74c7b4758a680aacd558810a417d1edaa7000142976ef9d2f/pillow-12.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1ac11e8ea4f611c3c0147424eae514028b5e9077dd99ab91e1bd7bc33ff145e1", size = 4650606, upload-time = "2025-10-15T18:21:29.823Z" }, - { url = "https://files.pythonhosted.org/packages/4b/e0/1fa492aa9f77b3bc6d471c468e62bfea1823056bf7e5e4f1914d7ab2565e/pillow-12.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d49e2314c373f4c2b39446fb1a45ed333c850e09d0c59ac79b72eb3b95397363", size = 6221023, upload-time = "2025-10-15T18:21:31.415Z" }, - { url = "https://files.pythonhosted.org/packages/c1/09/4de7cd03e33734ccd0c876f0251401f1314e819cbfd89a0fcb6e77927cc6/pillow-12.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c7b2a63fd6d5246349f3d3f37b14430d73ee7e8173154461785e43036ffa96ca", size = 8024937, upload-time = "2025-10-15T18:21:33.453Z" }, - { url = "https://files.pythonhosted.org/packages/2e/69/0688e7c1390666592876d9d474f5e135abb4acb39dcb583c4dc5490f1aff/pillow-12.0.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d64317d2587c70324b79861babb9c09f71fbb780bad212018874b2c013d8600e", size = 6334139, upload-time = "2025-10-15T18:21:35.395Z" }, - { url = "https://files.pythonhosted.org/packages/ed/1c/880921e98f525b9b44ce747ad1ea8f73fd7e992bafe3ca5e5644bf433dea/pillow-12.0.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d77153e14b709fd8b8af6f66a3afbb9ed6e9fc5ccf0b6b7e1ced7b036a228782", size = 7026074, upload-time = "2025-10-15T18:21:37.219Z" }, - { url = "https://files.pythonhosted.org/packages/28/03/96f718331b19b355610ef4ebdbbde3557c726513030665071fd025745671/pillow-12.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:32ed80ea8a90ee3e6fa08c21e2e091bba6eda8eccc83dbc34c95169507a91f10", size = 6448852, upload-time = "2025-10-15T18:21:39.168Z" }, - { url = "https://files.pythonhosted.org/packages/3a/a0/6a193b3f0cc9437b122978d2c5cbce59510ccf9a5b48825096ed7472da2f/pillow-12.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c828a1ae702fc712978bda0320ba1b9893d99be0badf2647f693cc01cf0f04fa", size = 7117058, upload-time = "2025-10-15T18:21:40.997Z" }, - { url = "https://files.pythonhosted.org/packages/a7/c4/043192375eaa4463254e8e61f0e2ec9a846b983929a8d0a7122e0a6d6fff/pillow-12.0.0-cp310-cp310-win32.whl", hash = "sha256:bd87e140e45399c818fac4247880b9ce719e4783d767e030a883a970be632275", size = 6295431, upload-time = "2025-10-15T18:21:42.518Z" }, - { url = "https://files.pythonhosted.org/packages/92/c6/c2f2fc7e56301c21827e689bb8b0b465f1b52878b57471a070678c0c33cd/pillow-12.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:455247ac8a4cfb7b9bc45b7e432d10421aea9fc2e74d285ba4072688a74c2e9d", size = 7000412, upload-time = "2025-10-15T18:21:44.404Z" }, - { url = "https://files.pythonhosted.org/packages/b2/d2/5f675067ba82da7a1c238a73b32e3fd78d67f9d9f80fbadd33a40b9c0481/pillow-12.0.0-cp310-cp310-win_arm64.whl", hash = "sha256:6ace95230bfb7cd79ef66caa064bbe2f2a1e63d93471c3a2e1f1348d9f22d6b7", size = 2435903, upload-time = "2025-10-15T18:21:46.29Z" }, { url = "https://files.pythonhosted.org/packages/0e/5a/a2f6773b64edb921a756eb0729068acad9fc5208a53f4a349396e9436721/pillow-12.0.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:0fd00cac9c03256c8b2ff58f162ebcd2587ad3e1f2e397eab718c47e24d231cc", size = 5289798, upload-time = "2025-10-15T18:21:47.763Z" }, { url = "https://files.pythonhosted.org/packages/2e/05/069b1f8a2e4b5a37493da6c5868531c3f77b85e716ad7a590ef87d58730d/pillow-12.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3475b96f5908b3b16c47533daaa87380c491357d197564e0ba34ae75c0f3257", size = 4650589, upload-time = "2025-10-15T18:21:49.515Z" }, { url = "https://files.pythonhosted.org/packages/61/e3/2c820d6e9a36432503ead175ae294f96861b07600a7156154a086ba7111a/pillow-12.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:110486b79f2d112cf6add83b28b627e369219388f64ef2f960fef9ebaf54c642", size = 6230472, upload-time = "2025-10-15T18:21:51.052Z" }, @@ -2178,36 +1789,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/95/7e/f896623c3c635a90537ac093c6a618ebe1a90d87206e42309cb5d98a1b9e/pillow-12.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b290fd8aa38422444d4b50d579de197557f182ef1068b75f5aa8558638b8d0a5", size = 6997850, upload-time = "2025-10-15T18:24:11.495Z" }, ] -[[package]] -name = "pint" -version = "0.24.4" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.11'", -] -dependencies = [ - { name = "flexcache", marker = "python_full_version < '3.11'" }, - { name = "flexparser", marker = "python_full_version < '3.11'" }, - { name = "platformdirs", marker = "python_full_version < '3.11'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/20/bb/52b15ddf7b7706ed591134a895dbf6e41c8348171fb635e655e0a4bbb0ea/pint-0.24.4.tar.gz", hash = "sha256:35275439b574837a6cd3020a5a4a73645eb125ce4152a73a2f126bf164b91b80", size = 342225, upload-time = "2024-11-07T16:29:46.061Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/16/bd2f5904557265882108dc2e04f18abc05ab0c2b7082ae9430091daf1d5c/Pint-0.24.4-py3-none-any.whl", hash = "sha256:aa54926c8772159fcf65f82cc0d34de6768c151b32ad1deb0331291c38fe7659", size = 302029, upload-time = "2024-11-07T16:29:43.976Z" }, -] - [[package]] name = "pint" version = "0.25.2" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.11'", -] dependencies = [ - { name = "flexcache", marker = "python_full_version >= '3.11'" }, - { name = "flexparser", marker = "python_full_version >= '3.11'" }, - { name = "platformdirs", marker = "python_full_version >= '3.11'" }, - { name = "typing-extensions", marker = "python_full_version >= '3.11'" }, + { name = "flexcache" }, + { name = "flexparser" }, + { name = "platformdirs" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5f/74/bc3f671997158aef171194c3c4041e549946f4784b8690baa0626a0a164b/pint-0.25.2.tar.gz", hash = "sha256:85a45d1da8fe9c9f7477fed8aef59ad2b939af3d6611507e1a9cbdacdcd3450a", size = 254467, upload-time = "2025-11-06T22:08:09.184Z" } wheels = [ @@ -2346,19 +1936,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298, upload-time = "2025-11-04T13:39:04.116Z" }, - { url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475, upload-time = "2025-11-04T13:39:06.055Z" }, - { url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815, upload-time = "2025-11-04T13:39:10.41Z" }, - { url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567, upload-time = "2025-11-04T13:39:12.244Z" }, - { url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442, upload-time = "2025-11-04T13:39:13.962Z" }, - { url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956, upload-time = "2025-11-04T13:39:15.889Z" }, - { url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253, upload-time = "2025-11-04T13:39:17.403Z" }, - { url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050, upload-time = "2025-11-04T13:39:19.351Z" }, - { url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178, upload-time = "2025-11-04T13:39:21Z" }, - { url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833, upload-time = "2025-11-04T13:39:22.606Z" }, - { url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156, upload-time = "2025-11-04T13:39:25.843Z" }, - { url = "https://files.pythonhosted.org/packages/7e/70/cad3acd89fde2010807354d978725ae111ddf6d0ea46d1ea1775b5c1bd0c/pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", size = 1989378, upload-time = "2025-11-04T13:39:27.92Z" }, - { url = "https://files.pythonhosted.org/packages/76/92/d338652464c6c367e5608e4488201702cd1cbb0f33f7b6a85a60fe5f3720/pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", size = 2013622, upload-time = "2025-11-04T13:39:29.848Z" }, { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, @@ -2437,14 +2014,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, - { url = "https://files.pythonhosted.org/packages/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351, upload-time = "2025-11-04T13:43:02.058Z" }, - { url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363, upload-time = "2025-11-04T13:43:05.159Z" }, - { url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615, upload-time = "2025-11-04T13:43:08.116Z" }, - { url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369, upload-time = "2025-11-04T13:43:12.49Z" }, - { url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218, upload-time = "2025-11-04T13:43:15.431Z" }, - { url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951, upload-time = "2025-11-04T13:43:18.062Z" }, - { url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428, upload-time = "2025-11-04T13:43:20.679Z" }, - { url = "https://files.pythonhosted.org/packages/2f/02/8559b1f26ee0d502c74f9cca5c0d2fd97e967e083e006bbbb4e97f3a043a/pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", size = 2147009, upload-time = "2025-11-04T13:43:23.286Z" }, { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, @@ -2461,8 +2030,7 @@ version = "8.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "compress-pickle", extra = ["lz4"] }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "numpy" }, { name = "pydantic" }, { name = "ruamel-yaml" }, { name = "semver" }, @@ -2482,8 +2050,7 @@ dependencies = [ { name = "beautifulsoup4" }, { name = "docutils" }, { name = "pygments" }, - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx" }, { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/00/20/bb50f9de3a6de69e6abd6b087b52fa2418a0418b19597601605f855ad044/pydata_sphinx_theme-0.16.1.tar.gz", hash = "sha256:a08b7f0b7f70387219dc659bff0893a7554d5eb39b59d3b8ef37b8401b7642d7", size = 2412693, upload-time = "2024-12-17T10:53:39.537Z" } @@ -2533,12 +2100,10 @@ version = "9.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "iniconfig" }, { name = "packaging" }, { name = "pluggy" }, { name = "pygments" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/07/56/f013048ac4bc4c1d9be45afd4ab209ea62822fb1598f40687e6bf45dcea4/pytest-9.0.1.tar.gz", hash = "sha256:3e9c069ea73583e255c3b21cf46b8d3c56f6e3a1a8f6da94ccb0fcf57b9d73c8", size = 1564125, upload-time = "2025-11-12T13:05:09.333Z" } wheels = [ @@ -2550,7 +2115,6 @@ name = "pytest-asyncio" version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, { name = "pytest" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] @@ -2625,15 +2189,6 @@ version = "6.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, - { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, - { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, - { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, - { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, - { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, - { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, - { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, - { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, @@ -2692,16 +2247,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/04/0b/3c9baedbdf613ecaa7aa07027780b8867f57b6293b6ee50de316c9f3222b/pyzmq-27.1.0.tar.gz", hash = "sha256:ac0765e3d44455adb6ddbf4417dcce460fc40a05978c08efdf2948072f6db540", size = 281750, upload-time = "2025-09-08T23:10:18.157Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/67/b9/52aa9ec2867528b54f1e60846728d8b4d84726630874fee3a91e66c7df81/pyzmq-27.1.0-cp310-cp310-macosx_10_15_universal2.whl", hash = "sha256:508e23ec9bc44c0005c4946ea013d9317ae00ac67778bd47519fdf5a0e930ff4", size = 1329850, upload-time = "2025-09-08T23:07:26.274Z" }, - { url = "https://files.pythonhosted.org/packages/99/64/5653e7b7425b169f994835a2b2abf9486264401fdef18df91ddae47ce2cc/pyzmq-27.1.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:507b6f430bdcf0ee48c0d30e734ea89ce5567fd7b8a0f0044a369c176aa44556", size = 906380, upload-time = "2025-09-08T23:07:29.78Z" }, - { url = "https://files.pythonhosted.org/packages/73/78/7d713284dbe022f6440e391bd1f3c48d9185673878034cfb3939cdf333b2/pyzmq-27.1.0-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf7b38f9fd7b81cb6d9391b2946382c8237fd814075c6aa9c3b746d53076023b", size = 666421, upload-time = "2025-09-08T23:07:31.263Z" }, - { url = "https://files.pythonhosted.org/packages/30/76/8f099f9d6482450428b17c4d6b241281af7ce6a9de8149ca8c1c649f6792/pyzmq-27.1.0-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03ff0b279b40d687691a6217c12242ee71f0fba28bf8626ff50e3ef0f4410e1e", size = 854149, upload-time = "2025-09-08T23:07:33.17Z" }, - { url = "https://files.pythonhosted.org/packages/59/f0/37fbfff06c68016019043897e4c969ceab18bde46cd2aca89821fcf4fb2e/pyzmq-27.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:677e744fee605753eac48198b15a2124016c009a11056f93807000ab11ce6526", size = 1655070, upload-time = "2025-09-08T23:07:35.205Z" }, - { url = "https://files.pythonhosted.org/packages/47/14/7254be73f7a8edc3587609554fcaa7bfd30649bf89cd260e4487ca70fdaa/pyzmq-27.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:dd2fec2b13137416a1c5648b7009499bcc8fea78154cd888855fa32514f3dad1", size = 2033441, upload-time = "2025-09-08T23:07:37.432Z" }, - { url = "https://files.pythonhosted.org/packages/22/dc/49f2be26c6f86f347e796a4d99b19167fc94503f0af3fd010ad262158822/pyzmq-27.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:08e90bb4b57603b84eab1d0ca05b3bbb10f60c1839dc471fc1c9e1507bef3386", size = 1891529, upload-time = "2025-09-08T23:07:39.047Z" }, - { url = "https://files.pythonhosted.org/packages/a3/3e/154fb963ae25be70c0064ce97776c937ecc7d8b0259f22858154a9999769/pyzmq-27.1.0-cp310-cp310-win32.whl", hash = "sha256:a5b42d7a0658b515319148875fcb782bbf118dd41c671b62dae33666c2213bda", size = 567276, upload-time = "2025-09-08T23:07:40.695Z" }, - { url = "https://files.pythonhosted.org/packages/62/b2/f4ab56c8c595abcb26b2be5fd9fa9e6899c1e5ad54964e93ae8bb35482be/pyzmq-27.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:c0bb87227430ee3aefcc0ade2088100e528d5d3298a0a715a64f3d04c60ba02f", size = 632208, upload-time = "2025-09-08T23:07:42.298Z" }, - { url = "https://files.pythonhosted.org/packages/3b/e3/be2cc7ab8332bdac0522fdb64c17b1b6241a795bee02e0196636ec5beb79/pyzmq-27.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:9a916f76c2ab8d045b19f2286851a38e9ac94ea91faf65bd64735924522a8b32", size = 559766, upload-time = "2025-09-08T23:07:43.869Z" }, { url = "https://files.pythonhosted.org/packages/06/5d/305323ba86b284e6fcb0d842d6adaa2999035f70f8c38a9b6d21ad28c3d4/pyzmq-27.1.0-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:226b091818d461a3bef763805e75685e478ac17e9008f49fce2d3e52b3d58b86", size = 1333328, upload-time = "2025-09-08T23:07:45.946Z" }, { url = "https://files.pythonhosted.org/packages/bd/a0/fc7e78a23748ad5443ac3275943457e8452da67fda347e05260261108cbc/pyzmq-27.1.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0790a0161c281ca9723f804871b4027f2e8b5a528d357c8952d08cd1a9c15581", size = 908803, upload-time = "2025-09-08T23:07:47.551Z" }, { url = "https://files.pythonhosted.org/packages/7e/22/37d15eb05f3bdfa4abea6f6d96eb3bb58585fbd3e4e0ded4e743bc650c97/pyzmq-27.1.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c895a6f35476b0c3a54e3eb6ccf41bf3018de937016e6e18748317f25d4e925f", size = 668836, upload-time = "2025-09-08T23:07:49.436Z" }, @@ -2744,11 +2289,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c4/59/a5f38970f9bf07cee96128de79590bb354917914a9be11272cfc7ff26af0/pyzmq-27.1.0-cp314-cp314t-win32.whl", hash = "sha256:1f0b2a577fd770aa6f053211a55d1c47901f4d537389a034c690291485e5fe92", size = 587472, upload-time = "2025-09-08T23:08:58.18Z" }, { url = "https://files.pythonhosted.org/packages/70/d8/78b1bad170f93fcf5e3536e70e8fadac55030002275c9a29e8f5719185de/pyzmq-27.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:19c9468ae0437f8074af379e986c5d3d7d7bfe033506af442e8c879732bedbe0", size = 661401, upload-time = "2025-09-08T23:08:59.802Z" }, { url = "https://files.pythonhosted.org/packages/81/d6/4bfbb40c9a0b42fc53c7cf442f6385db70b40f74a783130c5d0a5aa62228/pyzmq-27.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dc5dbf68a7857b59473f7df42650c621d7e8923fb03fa74a526890f4d33cc4d7", size = 575170, upload-time = "2025-09-08T23:09:01.418Z" }, - { url = "https://files.pythonhosted.org/packages/f3/81/a65e71c1552f74dec9dff91d95bafb6e0d33338a8dfefbc88aa562a20c92/pyzmq-27.1.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c17e03cbc9312bee223864f1a2b13a99522e0dc9f7c5df0177cd45210ac286e6", size = 836266, upload-time = "2025-09-08T23:09:40.048Z" }, - { url = "https://files.pythonhosted.org/packages/58/ed/0202ca350f4f2b69faa95c6d931e3c05c3a397c184cacb84cb4f8f42f287/pyzmq-27.1.0-pp310-pypy310_pp73-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f328d01128373cb6763823b2b4e7f73bdf767834268c565151eacb3b7a392f90", size = 800206, upload-time = "2025-09-08T23:09:41.902Z" }, - { url = "https://files.pythonhosted.org/packages/47/42/1ff831fa87fe8f0a840ddb399054ca0009605d820e2b44ea43114f5459f4/pyzmq-27.1.0-pp310-pypy310_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c1790386614232e1b3a40a958454bdd42c6d1811837b15ddbb052a032a43f62", size = 567747, upload-time = "2025-09-08T23:09:43.741Z" }, - { url = "https://files.pythonhosted.org/packages/d1/db/5c4d6807434751e3f21231bee98109aa57b9b9b55e058e450d0aef59b70f/pyzmq-27.1.0-pp310-pypy310_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:448f9cb54eb0cee4732b46584f2710c8bc178b0e5371d9e4fc8125201e413a74", size = 747371, upload-time = "2025-09-08T23:09:45.575Z" }, - { url = "https://files.pythonhosted.org/packages/26/af/78ce193dbf03567eb8c0dc30e3df2b9e56f12a670bf7eb20f9fb532c7e8a/pyzmq-27.1.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:05b12f2d32112bf8c95ef2e74ec4f1d4beb01f8b5e703b38537f8849f92cb9ba", size = 544862, upload-time = "2025-09-08T23:09:47.448Z" }, { url = "https://files.pythonhosted.org/packages/4c/c6/c4dcdecdbaa70969ee1fdced6d7b8f60cfabe64d25361f27ac4665a70620/pyzmq-27.1.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:18770c8d3563715387139060d37859c02ce40718d1faf299abddcdcc6a649066", size = 836265, upload-time = "2025-09-08T23:09:49.376Z" }, { url = "https://files.pythonhosted.org/packages/3e/79/f38c92eeaeb03a2ccc2ba9866f0439593bb08c5e3b714ac1d553e5c96e25/pyzmq-27.1.0-pp311-pypy311_pp73-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:ac25465d42f92e990f8d8b0546b01c391ad431c3bf447683fdc40565941d0604", size = 800208, upload-time = "2025-09-08T23:09:51.073Z" }, { url = "https://files.pythonhosted.org/packages/49/0e/3f0d0d335c6b3abb9b7b723776d0b21fa7f3a6c819a0db6097059aada160/pyzmq-27.1.0-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53b40f8ae006f2734ee7608d59ed661419f087521edbfc2149c3932e9c14808c", size = 567747, upload-time = "2025-09-08T23:09:52.698Z" }, @@ -2800,20 +2340,6 @@ version = "0.30.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/06/0c/0c411a0ec64ccb6d104dcabe0e713e05e153a9a2c3c2bd2b32ce412166fe/rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288", size = 370490, upload-time = "2025-11-30T20:21:33.256Z" }, - { url = "https://files.pythonhosted.org/packages/19/6a/4ba3d0fb7297ebae71171822554abe48d7cab29c28b8f9f2c04b79988c05/rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00", size = 359751, upload-time = "2025-11-30T20:21:34.591Z" }, - { url = "https://files.pythonhosted.org/packages/cd/7c/e4933565ef7f7a0818985d87c15d9d273f1a649afa6a52ea35ad011195ea/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6", size = 389696, upload-time = "2025-11-30T20:21:36.122Z" }, - { url = "https://files.pythonhosted.org/packages/5e/01/6271a2511ad0815f00f7ed4390cf2567bec1d4b1da39e2c27a41e6e3b4de/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7", size = 403136, upload-time = "2025-11-30T20:21:37.728Z" }, - { url = "https://files.pythonhosted.org/packages/55/64/c857eb7cd7541e9b4eee9d49c196e833128a55b89a9850a9c9ac33ccf897/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324", size = 524699, upload-time = "2025-11-30T20:21:38.92Z" }, - { url = "https://files.pythonhosted.org/packages/9c/ed/94816543404078af9ab26159c44f9e98e20fe47e2126d5d32c9d9948d10a/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df", size = 412022, upload-time = "2025-11-30T20:21:40.407Z" }, - { url = "https://files.pythonhosted.org/packages/61/b5/707f6cf0066a6412aacc11d17920ea2e19e5b2f04081c64526eb35b5c6e7/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3", size = 390522, upload-time = "2025-11-30T20:21:42.17Z" }, - { url = "https://files.pythonhosted.org/packages/13/4e/57a85fda37a229ff4226f8cbcf09f2a455d1ed20e802ce5b2b4a7f5ed053/rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221", size = 404579, upload-time = "2025-11-30T20:21:43.769Z" }, - { url = "https://files.pythonhosted.org/packages/f9/da/c9339293513ec680a721e0e16bf2bac3db6e5d7e922488de471308349bba/rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7", size = 421305, upload-time = "2025-11-30T20:21:44.994Z" }, - { url = "https://files.pythonhosted.org/packages/f9/be/522cb84751114f4ad9d822ff5a1aa3c98006341895d5f084779b99596e5c/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff", size = 572503, upload-time = "2025-11-30T20:21:46.91Z" }, - { url = "https://files.pythonhosted.org/packages/a2/9b/de879f7e7ceddc973ea6e4629e9b380213a6938a249e94b0cdbcc325bb66/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7", size = 598322, upload-time = "2025-11-30T20:21:48.709Z" }, - { url = "https://files.pythonhosted.org/packages/48/ac/f01fc22efec3f37d8a914fc1b2fb9bcafd56a299edbe96406f3053edea5a/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139", size = 560792, upload-time = "2025-11-30T20:21:50.024Z" }, - { url = "https://files.pythonhosted.org/packages/e2/da/4e2b19d0f131f35b6146425f846563d0ce036763e38913d917187307a671/rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464", size = 221901, upload-time = "2025-11-30T20:21:51.32Z" }, - { url = "https://files.pythonhosted.org/packages/96/cb/156d7a5cf4f78a7cc571465d8aec7a3c447c94f6749c5123f08438bcf7bc/rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169", size = 235823, upload-time = "2025-11-30T20:21:52.505Z" }, { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157, upload-time = "2025-11-30T20:21:53.789Z" }, { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676, upload-time = "2025-11-30T20:21:55.475Z" }, { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938, upload-time = "2025-11-30T20:21:57.079Z" }, @@ -2934,16 +2460,6 @@ version = "0.2.15" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/ea/97/60fda20e2fb54b83a61ae14648b0817c8f5d84a3821e40bfbdae1437026a/ruamel_yaml_clib-0.2.15.tar.gz", hash = "sha256:46e4cc8c43ef6a94885f72512094e482114a8a706d3c555a34ed4b0d20200600", size = 225794, upload-time = "2025-11-16T16:12:59.761Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/5a/4ab767cd42dcd65b83c323e1620d7c01ee60a52f4032fb7b61501f45f5c2/ruamel_yaml_clib-0.2.15-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:88eea8baf72f0ccf232c22124d122a7f26e8a24110a0273d9bcddcb0f7e1fa03", size = 147454, upload-time = "2025-11-16T16:13:02.54Z" }, - { url = "https://files.pythonhosted.org/packages/40/44/184173ac1e74fd35d308108bcbf83904d6ef8439c70763189225a166b238/ruamel_yaml_clib-0.2.15-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9b6f7d74d094d1f3a4e157278da97752f16ee230080ae331fcc219056ca54f77", size = 132467, upload-time = "2025-11-16T16:13:03.539Z" }, - { url = "https://files.pythonhosted.org/packages/49/1b/2d2077a25fe682ae335007ca831aff42e3cbc93c14066675cf87a6c7fc3e/ruamel_yaml_clib-0.2.15-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4be366220090d7c3424ac2b71c90d1044ea34fca8c0b88f250064fd06087e614", size = 693454, upload-time = "2025-11-16T20:22:41.083Z" }, - { url = "https://files.pythonhosted.org/packages/90/16/e708059c4c429ad2e33be65507fc1730641e5f239fb2964efc1ba6edea94/ruamel_yaml_clib-0.2.15-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f66f600833af58bea694d5892453f2270695b92200280ee8c625ec5a477eed3", size = 700345, upload-time = "2025-11-16T16:13:04.771Z" }, - { url = "https://files.pythonhosted.org/packages/d9/79/0e8ef51df1f0950300541222e3332f20707a9c210b98f981422937d1278c/ruamel_yaml_clib-0.2.15-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da3d6adadcf55a93c214d23941aef4abfd45652110aed6580e814152f385b862", size = 731306, upload-time = "2025-11-16T16:13:06.312Z" }, - { url = "https://files.pythonhosted.org/packages/a6/f4/2cdb54b142987ddfbd01fc45ac6bd882695fbcedb9d8bbf796adc3fc3746/ruamel_yaml_clib-0.2.15-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e9fde97ecb7bb9c41261c2ce0da10323e9227555c674989f8d9eb7572fc2098d", size = 692415, upload-time = "2025-11-16T16:13:07.465Z" }, - { url = "https://files.pythonhosted.org/packages/a0/07/40b5fc701cce8240a3e2d26488985d3bbdc446e9fe397c135528d412fea6/ruamel_yaml_clib-0.2.15-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:05c70f7f86be6f7bee53794d80050a28ae7e13e4a0087c1839dcdefd68eb36b6", size = 705007, upload-time = "2025-11-16T20:22:42.856Z" }, - { url = "https://files.pythonhosted.org/packages/82/19/309258a1df6192fb4a77ffa8eae3e8150e8d0ffa56c1b6fa92e450ba2740/ruamel_yaml_clib-0.2.15-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:6f1d38cbe622039d111b69e9ca945e7e3efebb30ba998867908773183357f3ed", size = 723974, upload-time = "2025-11-16T16:13:08.72Z" }, - { url = "https://files.pythonhosted.org/packages/67/3a/d6ee8263b521bfceb5cd2faeb904a15936480f2bb01c7ff74a14ec058ca4/ruamel_yaml_clib-0.2.15-cp310-cp310-win32.whl", hash = "sha256:fe239bdfdae2302e93bd6e8264bd9b71290218fff7084a9db250b55caaccf43f", size = 102836, upload-time = "2025-11-16T16:13:10.27Z" }, - { url = "https://files.pythonhosted.org/packages/ed/03/92aeb5c69018387abc49a8bb4f83b54a0471d9ef48e403b24bac68f01381/ruamel_yaml_clib-0.2.15-cp310-cp310-win_amd64.whl", hash = "sha256:468858e5cbde0198337e6a2a78eda8c3fb148bdf4c6498eaf4bc9ba3f8e780bd", size = 121917, upload-time = "2025-11-16T16:13:12.145Z" }, { url = "https://files.pythonhosted.org/packages/2c/80/8ce7b9af532aa94dd83360f01ce4716264db73de6bc8efd22c32341f6658/ruamel_yaml_clib-0.2.15-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c583229f336682b7212a43d2fa32c30e643d3076178fb9f7a6a14dde85a2d8bd", size = 147998, upload-time = "2025-11-16T16:13:13.241Z" }, { url = "https://files.pythonhosted.org/packages/53/09/de9d3f6b6701ced5f276d082ad0f980edf08ca67114523d1b9264cd5e2e0/ruamel_yaml_clib-0.2.15-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:56ea19c157ed8c74b6be51b5fa1c3aff6e289a041575f0556f66e5fb848bb137", size = 132743, upload-time = "2025-11-16T16:13:14.265Z" }, { url = "https://files.pythonhosted.org/packages/0e/f7/73a9b517571e214fe5c246698ff3ed232f1ef863c8ae1667486625ec688a/ruamel_yaml_clib-0.2.15-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5fea0932358e18293407feb921d4f4457db837b67ec1837f87074667449f9401", size = 731459, upload-time = "2025-11-16T20:22:44.338Z" }, @@ -2991,9 +2507,9 @@ name = "scanspec" version = "0.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "click", marker = "python_full_version >= '3.11'" }, - { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "pydantic", marker = "python_full_version >= '3.11'" }, + { name = "click" }, + { name = "numpy" }, + { name = "pydantic" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a7/56/b5359e055e6f4bd1bbd491be2d7e2f0a47dc475f8ff69413f0478a5e7f7f/scanspec-0.9.0.tar.gz", hash = "sha256:5aa03ca6fedec8a4b86cc0773d50e880f2b5b8b6d11cec24c4ceeed26e8e667d", size = 269706, upload-time = "2025-10-03T13:49:20.023Z" } wheels = [ @@ -3005,8 +2521,7 @@ name = "secop-ophyd" source = { editable = "." } dependencies = [ { name = "frappy-core" }, - { name = "ophyd-async", version = "0.12.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "ophyd-async", version = "0.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "ophyd-async" }, ] [package.dev-dependencies] @@ -3020,8 +2535,7 @@ dev = [ { name = "mlzlog" }, { name = "mypy" }, { name = "myst-parser" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "numpy" }, { name = "numpydoc" }, { name = "ophyd" }, { name = "pep8-naming" }, @@ -3032,10 +2546,8 @@ dev = [ { name = "pytest-cov" }, { name = "pytest-xprocess" }, { name = "python-dotenv" }, - { name = "snakefmt", version = "0.10.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "snakefmt", version = "0.11.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "snakefmt" }, + { name = "sphinx" }, { name = "sphinx-autodoc2" }, { name = "sphinx-click" }, { name = "sphinx-copybutton" }, @@ -3100,33 +2612,13 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] -[[package]] -name = "snakefmt" -version = "0.10.3" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.11'", -] -dependencies = [ - { name = "black", marker = "python_full_version < '3.11'" }, - { name = "click", marker = "python_full_version < '3.11'" }, - { name = "toml", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c5/ae/91b5fe5334ce8337c9b7fb0985a5649cad3491c68f2f52ffff03ef694346/snakefmt-0.10.3.tar.gz", hash = "sha256:423cfbe65beda62157b1871ad6244e9349c1313a57de1708752b5bcbf07c32e3", size = 27972, upload-time = "2025-03-21T12:25:05.974Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/04/b87d7b00dd83c540745a98dac01e2120282391d7a0603ebd83ff99f18b1c/snakefmt-0.10.3-py3-none-any.whl", hash = "sha256:f4e2f058aa96e5bb5dbd634efbe8e2f85e6c8e77b839e95041222514cdc8bc57", size = 28196, upload-time = "2025-03-21T12:25:04.637Z" }, -] - [[package]] name = "snakefmt" version = "0.11.2" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.11'", -] dependencies = [ - { name = "black", marker = "python_full_version >= '3.11'" }, - { name = "click", marker = "python_full_version >= '3.11'" }, + { name = "black" }, + { name = "click" }, ] sdist = { url = "https://files.pythonhosted.org/packages/90/93/5cfa69ca18e6a73fe2816dfef52969e770d6e90fe9bf978c9d85232dc755/snakefmt-0.11.2.tar.gz", hash = "sha256:e9ad3758401e0291f7a45360075c5738386b58a1b160d38827b40630d9f735e1", size = 118127, upload-time = "2025-09-04T00:58:47.214Z" } wheels = [ @@ -3151,62 +2643,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/a0/bb38d3b76b8cae341dad93a2dd83ab7462e6dbcdd84d43f54ee60a8dc167/soupsieve-2.8-py3-none-any.whl", hash = "sha256:0cc76456a30e20f5d7f2e14a98a4ae2ee4e5abdc7c5ea0aafe795f344bc7984c", size = 36679, upload-time = "2025-08-27T15:39:50.179Z" }, ] -[[package]] -name = "sphinx" -version = "8.1.3" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.11'", -] -dependencies = [ - { name = "alabaster", marker = "python_full_version < '3.11'" }, - { name = "babel", marker = "python_full_version < '3.11'" }, - { name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" }, - { name = "docutils", marker = "python_full_version < '3.11'" }, - { name = "imagesize", marker = "python_full_version < '3.11'" }, - { name = "jinja2", marker = "python_full_version < '3.11'" }, - { name = "packaging", marker = "python_full_version < '3.11'" }, - { name = "pygments", marker = "python_full_version < '3.11'" }, - { name = "requests", marker = "python_full_version < '3.11'" }, - { name = "snowballstemmer", marker = "python_full_version < '3.11'" }, - { name = "sphinxcontrib-applehelp", marker = "python_full_version < '3.11'" }, - { name = "sphinxcontrib-devhelp", marker = "python_full_version < '3.11'" }, - { name = "sphinxcontrib-htmlhelp", marker = "python_full_version < '3.11'" }, - { name = "sphinxcontrib-jsmath", marker = "python_full_version < '3.11'" }, - { name = "sphinxcontrib-qthelp", marker = "python_full_version < '3.11'" }, - { name = "sphinxcontrib-serializinghtml", marker = "python_full_version < '3.11'" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/be0b61178fe2cdcb67e2a92fc9ebb488e3c51c4f74a36a7824c0adf23425/sphinx-8.1.3.tar.gz", hash = "sha256:43c1911eecb0d3e161ad78611bc905d1ad0e523e4ddc202a58a821773dc4c927", size = 8184611, upload-time = "2024-10-13T20:27:13.93Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/26/60/1ddff83a56d33aaf6f10ec8ce84b4c007d9368b21008876fceda7e7381ef/sphinx-8.1.3-py3-none-any.whl", hash = "sha256:09719015511837b76bf6e03e42eb7595ac8c2e41eeb9c29c5b755c6b677992a2", size = 3487125, upload-time = "2024-10-13T20:27:10.448Z" }, -] - [[package]] name = "sphinx" version = "8.2.3" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.11'", -] -dependencies = [ - { name = "alabaster", marker = "python_full_version >= '3.11'" }, - { name = "babel", marker = "python_full_version >= '3.11'" }, - { name = "colorama", marker = "python_full_version >= '3.11' and sys_platform == 'win32'" }, - { name = "docutils", marker = "python_full_version >= '3.11'" }, - { name = "imagesize", marker = "python_full_version >= '3.11'" }, - { name = "jinja2", marker = "python_full_version >= '3.11'" }, - { name = "packaging", marker = "python_full_version >= '3.11'" }, - { name = "pygments", marker = "python_full_version >= '3.11'" }, - { name = "requests", marker = "python_full_version >= '3.11'" }, - { name = "roman-numerals-py", marker = "python_full_version >= '3.11'" }, - { name = "snowballstemmer", marker = "python_full_version >= '3.11'" }, - { name = "sphinxcontrib-applehelp", marker = "python_full_version >= '3.11'" }, - { name = "sphinxcontrib-devhelp", marker = "python_full_version >= '3.11'" }, - { name = "sphinxcontrib-htmlhelp", marker = "python_full_version >= '3.11'" }, - { name = "sphinxcontrib-jsmath", marker = "python_full_version >= '3.11'" }, - { name = "sphinxcontrib-qthelp", marker = "python_full_version >= '3.11'" }, - { name = "sphinxcontrib-serializinghtml", marker = "python_full_version >= '3.11'" }, +dependencies = [ + { name = "alabaster" }, + { name = "babel" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "docutils" }, + { name = "imagesize" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pygments" }, + { name = "requests" }, + { name = "roman-numerals-py" }, + { name = "snowballstemmer" }, + { name = "sphinxcontrib-applehelp" }, + { name = "sphinxcontrib-devhelp" }, + { name = "sphinxcontrib-htmlhelp" }, + { name = "sphinxcontrib-jsmath" }, + { name = "sphinxcontrib-qthelp" }, + { name = "sphinxcontrib-serializinghtml" }, ] sdist = { url = "https://files.pythonhosted.org/packages/38/ad/4360e50ed56cb483667b8e6dadf2d3fda62359593faabbe749a27c4eaca6/sphinx-8.2.3.tar.gz", hash = "sha256:398ad29dee7f63a75888314e9424d40f52ce5a6a87ae88e7071e80af296ec348", size = 8321876, upload-time = "2025-03-02T22:31:59.658Z" } wheels = [ @@ -3219,7 +2677,6 @@ version = "0.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "astroid" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/17/5f/5350046d1aa1a56b063ae08b9ad871025335c9d55fe2372896ea48711da9/sphinx_autodoc2-0.5.0.tar.gz", hash = "sha256:7d76044aa81d6af74447080182b6868c7eb066874edc835e8ddf810735b6565a", size = 115077, upload-time = "2023-11-27T07:27:51.407Z" } @@ -3234,8 +2691,7 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "docutils" }, - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx" }, ] sdist = { url = "https://files.pythonhosted.org/packages/de/4b/c433ea57136eac0ccb8d76d33355783f1e6e77f1f13dc7d8f15dba2dc024/sphinx_click-6.1.0.tar.gz", hash = "sha256:c702e0751c1a0b6ad649e4f7faebd0dc09a3cc7ca3b50f959698383772f50eef", size = 26855, upload-time = "2025-09-11T11:05:45.53Z" } wheels = [ @@ -3247,8 +2703,7 @@ name = "sphinx-copybutton" version = "0.5.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fc/2b/a964715e7f5295f77509e59309959f4125122d648f86b4fe7d70ca1d882c/sphinx-copybutton-0.5.2.tar.gz", hash = "sha256:4cf17c82fb9646d1bc9ca92ac280813a3b605d8c421225fd9913154103ee1fbd", size = 23039, upload-time = "2023-04-14T08:10:22.998Z" } wheels = [ @@ -3260,8 +2715,7 @@ name = "sphinx-design" version = "0.6.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx" }, ] sdist = { url = "https://files.pythonhosted.org/packages/2b/69/b34e0cb5336f09c6866d53b4a19d76c227cdec1bbc7ac4de63ca7d58c9c7/sphinx_design-0.6.1.tar.gz", hash = "sha256:b44eea3719386d04d765c1a8257caca2b3e6f8421d7b3a5e742c0fd45f84e632", size = 2193689, upload-time = "2024-08-02T13:48:44.277Z" } wheels = [ @@ -3310,8 +2764,7 @@ version = "1.2.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyyaml" }, - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f5/49/c6ddfe709a4ab76ac6e5a00e696f73626b2c189dc1e1965a361ec102e6cc/sphinxcontrib_mermaid-1.2.3.tar.gz", hash = "sha256:358699d0ec924ef679b41873d9edd97d0773446daf9760c75e18dc0adfd91371", size = 18885, upload-time = "2025-11-26T04:18:32.43Z" } wheels = [ @@ -3371,15 +2824,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/30/643397144bfbfec6f6ef821f36f33e57d35946c44a2352d3c9f0ae847619/tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138", size = 28248, upload-time = "2025-04-02T08:25:07.678Z" }, ] -[[package]] -name = "toml" -version = "0.10.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253, upload-time = "2020-11-01T01:40:22.204Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" }, -] - [[package]] name = "tomli" version = "2.3.0" @@ -3513,7 +2957,7 @@ name = "velocity-profile" version = "1.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "numpy" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f5/a0/edb76fc2b71a3cd0717a40184f512f8249a64443c472541939d915fab70d/velocity_profile-1.0.0.tar.gz", hash = "sha256:520a4dbc69519744c89438571ffe542f512ae528232f51e73cde9dbaaee2e086", size = 29716, upload-time = "2024-06-13T10:49:54.951Z" } wheels = [ @@ -3528,7 +2972,6 @@ dependencies = [ { name = "distlib" }, { name = "filelock" }, { name = "platformdirs" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/20/28/e6f1a6f655d620846bd9df527390ecc26b3805a0c5989048c210e22c5ca9/virtualenv-20.35.4.tar.gz", hash = "sha256:643d3914d73d3eeb0c552cbb12d7e82adf0e504dbf86a3182f8771a153a1971c", size = 6028799, upload-time = "2025-10-29T06:57:40.511Z" } wheels = [ From 10a15f4af5917cc86d44a647c92768aee922547e Mon Sep 17 00:00:00 2001 From: Peter Braun Date: Fri, 23 Jan 2026 15:06:52 +0100 Subject: [PATCH 02/18] =?UTF-8?q?=1B[200~2026-01-23=2015:06:31.625=20[info?= =?UTF-8?q?]=20>=20git=20for-each-ref=20--sort=20-committerdate=20--format?= =?UTF-8?q?=20%(refname)%00%(objectname)%00%(*objectname)=20[11ms]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 +- cfg/ophyd_secop_test_cfg.py | 34 + docs/GenNodeCode_Refactoring.md | 205 +++++ frappy_modules/ophyd_secop_test_modules.py | 16 + pyproject.toml | 2 + src/secop_ophyd/GenNodeCode.py | 819 ++++++++++++++---- src/secop_ophyd/SECoPDevices.py | 163 +++- .../templates/generated_classes.py.jinja2 | 72 ++ tests/conftest.py | 21 + tests/test_gencode_refactor.py | 336 +++++++ uv.lock | 16 + 11 files changed, 1478 insertions(+), 208 deletions(-) create mode 100644 docs/GenNodeCode_Refactoring.md create mode 100644 src/secop_ophyd/templates/generated_classes.py.jinja2 create mode 100644 tests/test_gencode_refactor.py diff --git a/.gitignore b/.gitignore index 579ee93..6056115 100644 --- a/.gitignore +++ b/.gitignore @@ -9,5 +9,5 @@ dist pid logs .secop-ophyd - +tests/testgen/* _api diff --git a/cfg/ophyd_secop_test_cfg.py b/cfg/ophyd_secop_test_cfg.py index a97de52..e249aa8 100644 --- a/cfg/ophyd_secop_test_cfg.py +++ b/cfg/ophyd_secop_test_cfg.py @@ -4,6 +4,8 @@ import os import sys +from frappy.core import EnumType + # Get project root from environment variable project_root = os.environ.get("FRAPPY_PROJECT_ROOT") @@ -102,3 +104,35 @@ # pollinterval = Param(export=False), # value = Param(unit = 'K', test = 'customized value'), ) + + +class GasEnum(EnumType): + def __init__(self, gas_names): + gas_dict = {name: idx for idx, name in enumerate(gas_names)} + super().__init__(**gas_dict) + + +Mod( + "enum1", + "frappy_modules.ophyd_secop_test_modules.Test_Enum", + "test module for enum codegen testing", + group="test", + value=1, + gas_type=Param( + description="gaslist of MFC", + datatype=GasEnum(["AR", "N2", "H2"]), + ), +) + + +Mod( + "enum2", + "frappy_modules.ophyd_secop_test_modules.Test_Enum", + "test module for enum codegen testing", + group="test", + value=1, + gas_type=Param( + description="gaslist of MFC", + datatype=GasEnum(["AR", "N2", "H2", "CO2"]), + ), +) diff --git a/docs/GenNodeCode_Refactoring.md b/docs/GenNodeCode_Refactoring.md new file mode 100644 index 0000000..0a3f42b --- /dev/null +++ b/docs/GenNodeCode_Refactoring.md @@ -0,0 +1,205 @@ +# GenNodeCode Refactoring Documentation + +## Overview + +The `GenNodeCode` class has been refactored to use **Jinja2 templates** and **Black formatting** for generating annotated ophyd device classes. This provides a more maintainable and extensible code generation system. + +## Key Improvements + +### 1. Template-Based Code Generation +- Uses Jinja2 templates for cleaner separation of code structure and generation logic +- Template file: `src/secop_ophyd/templates/generated_classes.py.jinja2` +- Makes it easy to modify the structure of generated classes + +### 2. Black Formatting +- All generated code is automatically formatted with Black +- Ensures consistent, PEP 8-compliant code style +- Makes generated code more readable + +### 3. Dataclass-Based Architecture +New dataclasses for better type safety and clarity: +- `Attribute`: Represents class attributes (name + type) +- `Method`: Represents class methods (name + signature + description) +- `ModuleClass`: Represents a module class to be generated +- `NodeClass`: Represents a node class to be generated + +### 4. Dual Generation Modes + +The refactored `GenNodeCode` supports two ways of generating classes: + +#### Mode 1: Device Introspection (Existing Functionality) +```python +# Generate classes from a fully instantiated SECoP device +node = SECoPNodeDevice(...) +await node.connect() +node.class_from_instance(path_to_module="my_modules") +``` + +This works exactly as before - introspecting the device at runtime. + +#### Mode 2: From JSON Describe (Future Feature) +```python +# Generate classes from SECoP JSON describe message +gen_code = GenNodeCode() +gen_code.from_json_describe(json_describe_message) +gen_code.write_gen_node_class_file() +``` + +This will allow generating classes without needing a running device (planned feature). + +## Usage Examples + +### Basic Usage (Programmatic) + +```python +from secop_ophyd.GenNodeCode import GenNodeCode, Method, Attribute +from inspect import signature + +# Create generator +gen_code = GenNodeCode(path="my_modules") + +# Add imports +gen_code.add_import("ophyd_async.core", "Device") +gen_code.add_import("ophyd_async.core", "SignalR") + +# Define a method +def my_command(self, value: float) -> str: + """Execute a command with a value""" + pass + +method = Method( + cmd_name="execute_command", + description="Execute a command with a value", + cmd_sign=signature(my_command) +) + +# Add a module class with type parameters +gen_code.add_mod_class( + module_cls="MyModule", + bases=["Device", "ABC"], + attrs=[ + ("temperature", "SignalR", "float"), # With type parameter + ("setpoint", "SignalRW", "float"), # With type parameter + ("status", "SignalR", "str"), # With type parameter + ], + cmd_plans=[method], + description="Temperature control module" +) + +# Add a node class +gen_code.add_node_class( + node_cls="MyNode", + bases=["SECoPNodeDevice", "ABC"], + attrs=[("temp_module", "MyModule")], + description="Main node device" +) + +# Generate and write +gen_code.write_gen_node_class_file() +``` + +### Using with Existing Devices + +```python +# This is the standard way - works as before +from secop_ophyd.SECoPDevices import SECoPNodeDevice + +node = SECoPNodeDevice(uri="tcp://localhost:10767", node_name="cryo") +await node.connect() + +# Generate annotated classes for IDE support +# Type parameters are automatically extracted from signal datatypes! +node.class_from_instance(path_to_module="my_modules") +``` + +## Generated Code Example + +The template generates code with type parameters like this: + +```python +from abc import ABC, abstractmethod +from ophyd_async.core import Device, SignalR, SignalRW + + +class MyModule(ABC, Device): + """Temperature control module""" + temperature: SignalR[float] + setpoint: SignalRW[float] + status: SignalR[str] + + @abstractmethod + def execute_command(self, value: float) -> str: + """Execute a command with a value""" + + +class MyNode(ABC, SECoPNodeDevice): + """Main node device""" + temp_module: MyModule +``` + +## Backward Compatibility + +The refactored implementation maintains full backward compatibility: + +- All existing methods work as before +- `Method` class still supports the old `__str__()` method +- `dimport`, `dmodule`, `dnode` attributes still exist (they reference the new structures) +- Old methods like `_write_imports_string()` are kept (but do nothing) + +## Template Customization + +To customize the generated code, edit the template file: +``` +src/secop_ophyd/templates/generated_classes.py.jinja2 +``` + +The template has access to: +- `imports`: Dictionary of module -> set of classes +- `module_classes`: List of ModuleClass objects +- `node_classes`: List of NodeClass objects + +## Future Development: JSON Describe Support + +The placeholder for JSON describe support is in the `from_json_describe()` method: + +```python +def from_json_describe(self, json_data: str | dict): + """Generate classes from a SECoP JSON describe message.""" + # Parse JSON if string + if isinstance(json_data, str): + _ = json.loads(json_data) + + # TODO: Parse the SECoP describe format + # Extract modules, parameters, commands, etc. + # Populate self.module_classes and self.node_classes + raise NotImplementedError("Not yet implemented") +``` + +To implement this, you'll need to: +1. Parse the SECoP JSON describe structure +2. Extract module information (name, properties, commands) +3. Create ModuleClass and NodeClass instances +4. Map SECoP types to Python/ophyd types + +## Testing + +Run the tests: +```bash +pytest tests/test_gencode_refactor.py # New unit tests +pytest tests/test_classgen.py # Existing integration tests +``` + +## Dependencies + +New dependency added to `pyproject.toml`: +- `jinja2`: Template engine for code generation +- `black`: Already in dev dependencies, now used for formatting + +## Migration Notes + +No migration needed! The refactored code is a drop-in replacement. Existing code will continue to work without changes. + +If you want to take advantage of new features: +1. Install jinja2: `pip install jinja2` +2. Use the new dataclasses for more type-safe code generation +3. Customize the template for project-specific needs diff --git a/frappy_modules/ophyd_secop_test_modules.py b/frappy_modules/ophyd_secop_test_modules.py index 0114637..52e7f79 100644 --- a/frappy_modules/ophyd_secop_test_modules.py +++ b/frappy_modules/ophyd_secop_test_modules.py @@ -43,6 +43,22 @@ def read_status(self): return self.status +class Test_Enum(Readable): + value = Parameter("dummy val", IntRange()) + + gas_type = Parameter("gas type", EnumType(ZERO=0, ONE=1, THREE=3), readonly=False) + + def read_value(self): + return random.choice([1, 2, 3]) + + def read_gas_type(self): + return self.gas_type + + def write_gas_type(self, val): + self.gas_type = val + return val + + class Test_ND_arrays(Readable): Status = Enum(Readable.Status) diff --git a/pyproject.toml b/pyproject.toml index bd68e92..eb53838 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,8 @@ dev = [ 'isort', 'pytest', 'black', + 'jinja2', + 'autoflake', 'pep8-naming', 'mypy', 'wheel', diff --git a/src/secop_ophyd/GenNodeCode.py b/src/secop_ophyd/GenNodeCode.py index 401ac98..8b1febb 100644 --- a/src/secop_ophyd/GenNodeCode.py +++ b/src/secop_ophyd/GenNodeCode.py @@ -1,278 +1,731 @@ +"""Code generation for annotated ophyd device classes using Jinja2 templates.""" + import inspect +import json import re +from dataclasses import dataclass, field from importlib import import_module, reload from inspect import Signature from pathlib import Path +from types import ModuleType + +import autoflake +import black +from jinja2 import Environment, PackageLoader, select_autoescape +from ophyd_async.core import Signal + + +@dataclass +class EnumMember: + """Represents an enum member with name and value.""" + + name: str # Python identifier (e.g., "LOW") + value: str # Original SECoP string (e.g., "Low Energy") + description: str | None = None # Optional description + + +@dataclass +class EnumClass: + """Represents an enum class definition.""" + + name: str # Enum class name (e.g., "TemperatureRegulatorModeEnum") + members: list[EnumMember] + description: str | None = None # Optional enum description + base_enum_class: str = "StrictEnum" # "StrictEnum" or "SupersetEnum" + + +@dataclass +class Attribute: + """Represents a class attribute with name and type.""" + + name: str + type: str + type_param: str | None = ( + None # Optional type parameter like float for SignalRW[float] + ) + description: str | None = None # Optional description from SECoP or docstrings + category: str = ( + "parameter" # "property" or "parameter" - for organizing generated code + ) class Method: + """Represents a class method with signature and description. + + This class supports both old-style initialization (for backward compatibility) + and new-style dataclass initialization. + """ + def __init__(self, cmd_name: str, description: str, cmd_sign: Signature) -> None: - self.sig_str: str + """Initialize Method (backward compatibility constructor). + Args: + cmd_name: Name of the command + description: Description of the command + cmd_sign: Signature of the command + """ raw_sig_str: str = str(cmd_sign) - raw_sig_str = raw_sig_str.replace("typing.", "") if "self" in raw_sig_str: - self.sig_str = raw_sig_str + sig_str = raw_sig_str else: - self.sig_str = "(self, " + raw_sig_str[1:] + sig_str = "(self, " + raw_sig_str[1:] + self.name: str = cmd_name + self.signature: str = sig_str self.description: str = description + self.sig_str: str = self.signature # For backward compatibility - def __str__(self) -> str: - code = "" - code += " @abstractmethod \n" - code += f" def {self.name}{self.sig_str}:\n" - code += f' """{self.description}"""' + @classmethod + def from_cmd(cls, cmd_name: str, description: str, cmd_sign: Signature) -> "Method": + """Create Method from command signature. - return code + Args: + cmd_name: Name of the command + description: Description of the command + cmd_sign: Signature of the command + + Returns: + Method instance + """ + return cls(cmd_name, description, cmd_sign) + + +@dataclass +class ModuleClass: + """Represents a module class to be generated.""" + + name: str + bases: list[str] + attributes: list[Attribute] = field(default_factory=list) + methods: list[Method] = field(default_factory=list) + description: str = "" + enums: list[EnumClass] = field(default_factory=list) # Enum classes for this module + + +@dataclass +class NodeClass: + """Represents a node class to be generated.""" + + name: str + bases: list[str] + attributes: list[Attribute] = field(default_factory=list) + description: str = "" + + +def get_python_type_from_signal(signal_obj: Signal) -> str | None: + """Extract Python type from signal backend datatype. + + Args: + signal_obj: Signal object (SignalR, SignalRW, etc.) + debug: If True, print debug information + + Returns: + Python type string (e.g., 'float', 'int', 'str') or None + """ + try: + + type_obj = signal_obj.datatype + + # Get the module name + module = type_obj.__module__ + + # For builtins, just return the name without module prefix + if module == "builtins": + return type_obj.__name__ + + return f"{module}.{type_obj.__name__}" + + except Exception as e: + print(f"DEBUG: Exception occurred: {type(e).__name__}: {e}") + import traceback + + traceback.print_exc() + return None class GenNodeCode: - """Generates A Python Class for a given SECoP_Node_Device instance. This allows - autocompletiion and type hinting in IDEs, this is needed since the attributes of - the generated Ophyd devices are only known at runtime. + """Generates annotated Python classes for SECoP ophyd devices. + + This class can generate Python code in two ways: + 1. By introspecting a fully instantiated SECoP ophyd device + 2. From a SECoP JSON describe message (future feature) + + The generated code uses Jinja2 templates and is formatted with Black. """ ModName: str = "genNodeClass" - node_mod = None - module_folder_path: Path | None = None def __init__(self, path: str | None = None, log=None): - """Instantiates GenNodeCode, internally all atrribues on a node and module level - are collected. Additionally all the needed imports are collected in a dict - """ - # prevent circular import - from secop_ophyd.SECoPDevices import ( - SECoPBaseDevice, - SECoPCommunicatorDevice, - SECoPMoveableDevice, - SECoPNodeDevice, - SECoPReadableDevice, - SECoPTriggerableDevice, - SECoPWritableDevice, - ) - - self.dimport: dict[str, set[str]] = {} - self.dmodule: dict = {} - self.dnode: dict = {} - - self.import_string: str = "" - self.mod_cls_string: str = "" - self.node_cls_string: str = "" + """Initialize the code generator. + Args: + path: Optional path to the module folder + log: Optional logger instance + """ self.log = log - + self.module_folder_path: Path | None = None if path is not None: self.module_folder_path = Path(path) - # resulting Class is supposed to be abstract - self.add_import("abc", "ABC") + # Data structures for classes and imports + self.imports: dict[str, set[str] | None] = {} + self.module_classes: list[ModuleClass] = [] + self.node_classes: list[NodeClass] = [] + self.node_mod: ModuleType | None = None + + # Required imports for abstract classes self.add_import("abc", "abstractmethod") + self.add_import("ophyd_async.core", "SignalR") + self.add_import("ophyd_async.core", "SignalRW") + self.add_import("ophyd_async.core", "StrictEnum") + self.add_import("ophyd_async.core", "SupersetEnum") self.add_import("typing", "Any") + self.add_import("numpy") + + # Setup Jinja2 environment + self.jinja_env = Environment( + loader=PackageLoader("secop_ophyd", "templates"), + autoescape=select_autoescape(), + trim_blocks=False, + lstrip_blocks=False, + keep_trailing_newline=True, + ) + # Try to load existing generated module + self._load_existing_module() + + def _load_existing_module(self): + """Load existing generated module if present.""" mod_path = self.ModName if self.module_folder_path is not None: - str_path = str(self.module_folder_path) - rep_slash = str_path.replace("/", ".").replace("\\", ".") - mod_path = f"{rep_slash}.{self.ModName}" + # For absolute paths, we need to add to sys.path and import just the module + # name + if self.module_folder_path.is_absolute(): + import sys + + str_path = str(self.module_folder_path) + if str_path not in sys.path: + sys.path.insert(0, str_path) + # Just use the module name when the folder is in sys.path + mod_path = self.ModName + else: + # For relative paths, construct the module path with dots + str_path = str(self.module_folder_path) + rep_slash = str_path.replace("/", ".").replace("\\", ".") + mod_path = f"{rep_slash}.{self.ModName}" try: self.node_mod = import_module(mod_path) + self._parse_existing_module() except ModuleNotFoundError: if self.log is None: - print("no code generated yet, building from scratch") + print("No code generated yet, building from scratch") else: - self.log.info("no code generated yet, building from scratch") + self.log.info("No code generated yet, building from scratch") + + def _parse_existing_module(self): + """Parse an existing generated module to extract class definitions.""" + # Prevent circular import + from ophyd_async.core import Device + + try: + from secop_ophyd.SECoPDevices import ( + SECoPBaseDevice, + SECoPCommunicatorDevice, + SECoPMoveableDevice, + SECoPNodeDevice, + SECoPReadableDevice, + SECoPTriggerableDevice, + SECoPWritableDevice, + ) + + has_secop_devices = True + except ImportError: + has_secop_devices = False + + if self.node_mod is None: return modules = inspect.getmembers(self.node_mod) - results = filter(lambda m: inspect.isclass(m[1]), modules) + for class_symbol, class_obj in results: module = class_obj.__module__ if module == self.ModName: - # Node Classes - - def get_attrs(source: str) -> list[tuple[str, str]]: - source_list: list[str] = source.split("\n") - # remove first line - source_list.pop(0) - source_list.pop() - - # remove whitespace - source_list = [attr.replace(" ", "") for attr in source_list] - # split at colon - attrs: list[tuple[str, str]] = [] - for attr in source_list: - parts = attr.split(":", maxsplit=1) - if len(parts) == 2: - attrs.append((parts[0], parts[1])) - - return attrs - - if issubclass(class_obj, SECoPNodeDevice): - attrs = get_attrs(inspect.getsource(class_obj)) - - bases = [base.__name__ for base in class_obj.__bases__] - - self.add_node_class(class_symbol, bases, attrs) - continue - - if issubclass( - class_obj, - ( - SECoPBaseDevice, - SECoPCommunicatorDevice, - SECoPMoveableDevice, - SECoPReadableDevice, - SECoPWritableDevice, - SECoPTriggerableDevice, - ), - ): - - attributes = [] - - for attr_name, attr in class_obj.__annotations__.items(): - attributes.append((attr_name, attr.__name__)) - - methods = [] - - for method_name, method in class_obj.__dict__.items(): - if callable(method) and not method_name.startswith("__"): - - method_source = inspect.getsource(method) - - match = re.search( - r"\s*def\s+\w+\s*\(.*\).*:\s*", method_source - ) - if match: - function_body = method_source[match.end() :] - description_list = function_body.split('"""', 2) - description = description_list[1] - else: - raise Exception( - "could not extract description function body" - ) - - methods.append( - Method( - method_name, description, inspect.signature(method) - ) - ) - - bases = [base.__name__ for base in class_obj.__bases__] - - self.add_mod_class(class_symbol, bases, attributes, methods) - continue + # Parse classes defined in the generated module + parsed = False + + # Try SECoP-specific device types first if available + if has_secop_devices: + if issubclass(class_obj, SECoPNodeDevice): + self._parse_node_class(class_symbol, class_obj) + parsed = True + elif issubclass( + class_obj, + ( + SECoPBaseDevice, + SECoPCommunicatorDevice, + SECoPMoveableDevice, + SECoPReadableDevice, + SECoPWritableDevice, + SECoPTriggerableDevice, + ), + ): + self._parse_module_class(class_symbol, class_obj) + parsed = True + + # Fall back to generic Device parsing + if not parsed and issubclass(class_obj, Device): + # Determine if it's a node or module class by checking if it has + # attributes that are themselves Device subclasses + is_node = self._is_node_class(class_obj) + if is_node: + self._parse_node_class(class_symbol, class_obj) + else: + self._parse_module_class(class_symbol, class_obj) + + def _is_node_class(self, class_obj: type) -> bool: + """Determine if a Device class is a node (contains other Device classes). + + Args: + class_obj: The class to check + + Returns: + True if this appears to be a node class, False if it's a module class + """ + from ophyd_async.core import Device + + # Check annotations for Device subclass attributes + if hasattr(class_obj, "__annotations__"): + for attr_name, attr_type in class_obj.__annotations__.items(): + # Check if the type is a class and a Device subclass + if inspect.isclass(attr_type) and issubclass(attr_type, Device): + return True + # Handle string annotations and generic types + type_str = str(attr_type) + # If any annotation refers to a custom class (not Signal*), it's likely + # a node + if "Signal" not in type_str and not type_str.startswith(" list[tuple[str, str]]: + """Extract attributes from class source code. - def add_import(self, module: str, class_str: str): - """adds an Import to the import dict + Args: + source: Source code of the class - :param module: Python Module of the dict - :type module: str - :param class_str: Class that is to be imported - :type class_str: str + Returns: + List of (name, type) tuples """ - if self.dimport.get(module): - self.dimport[module].add(class_str) + source_list: list[str] = source.split("\n") + # Remove first and last line + if len(source_list) > 1: + source_list.pop(0) + source_list.pop() + + # Extract attributes (skip methods, decorators, docstrings) + attrs: list[tuple[str, str]] = [] + for line in source_list: + stripped = line.strip() + # Skip empty lines, decorators, method definitions, and docstrings + if ( + not stripped + or stripped.startswith("@") + or stripped.startswith("def ") + or stripped.startswith('"""') + or stripped.startswith("'''") + ): + continue + + # Check if it's an attribute annotation (has : but not inside parentheses) + if ":" in stripped and "(" not in stripped.split(":", 1)[0]: + # Remove spaces for consistent parsing + line_no_space = line.replace(" ", "") + parts = line_no_space.split(":", maxsplit=1) + if len(parts) == 2: + attr_name = parts[0] + attr_type = parts[1] + attrs.append((attr_name, attr_type)) + + return attrs + + def _extract_method_description(self, method_source: str) -> str: + """Extract description from method docstring. + + Args: + method_source: Source code of the method + + Returns: + Description string + """ + match = re.search(r"\s*def\s+\w+\s*\(.*\).*:\s*", method_source) + if match: + function_body = method_source[match.end() :] + description_list = function_body.split('"""', 2) + if len(description_list) > 1: + return description_list[1] + return "" + + def add_import(self, module: str, class_str: str | None = None): + """Add an import to the import dictionary. + + Args: + module: Python module to import from + class_str: Class/symbol to import. If None or empty, imports the module + directly. + """ + if class_str is None or class_str == "": + # For module-only imports (import module), use None as value + if module not in self.imports: + self.imports[module] = None else: - self.dimport[module] = {class_str} + existing = self.imports.get(module) + if existing is None: + # Convert from module-only import to specific imports + self.imports[module] = {class_str} + elif isinstance(existing, set): + existing.add(class_str) + else: + self.imports[module] = {class_str} def add_mod_class( self, module_cls: str, bases: list[str], - attrs: list[tuple[str, str]], + attrs: list[tuple[str, str, str | None, str | None, str]], cmd_plans: list[Method], + description: str = "", + enum_classes: list[EnumClass] | None = None, ): - """adds module class to the module dict - - :param module_cls: name of the new Module class - :type module_cls: str - :param bases: bases the new class is derived from - :type bases: list[str] - :param attrs: list of attributes of the class - :type attrs: tuple[str, str] + """Add a module class to be generated. + + Args: + module_cls: Name of the module class + bases: Base classes + attrs: List of attribute tuples (name, type) or (name, type, type_param) + cmd_plans: List of method definitions + description: Optional class description """ - self.dmodule[module_cls] = {"bases": bases, "attrs": attrs, "plans": cmd_plans} + # Check if class already exists (loaded from file) + existing_class = next( + (cls for cls in self.module_classes if cls.name == module_cls), None + ) + if existing_class: + # Class already exists - merge enums if provided + if enum_classes: + existing_class.enums.extend(enum_classes) + if self.log: + self.log.info( + f"Module class {module_cls} already exists, " + f"merged {len(enum_classes)} enum(s)" + ) + return + + attributes = [] + for attr in attrs: + type_param = attr[2] if attr[2] else None + descr = attr[3] if len(attr) > 3 else None + category = attr[4] if len(attr) > 4 else "parameter" + + attributes.append( + Attribute( + name=attr[0], + type=attr[1], + type_param=type_param, + description=descr, + category=category, + ) + ) + + mod_cls = ModuleClass( + name=module_cls, + bases=bases, + attributes=attributes, + methods=cmd_plans, + description=description, + enums=enum_classes or [], + ) + self.module_classes.append(mod_cls) def add_node_class( - self, node_cls: str, bases: list[str], attrs: list[tuple[str, str]] + self, + node_cls: str, + bases: list[str], + attrs: list[tuple[str, str] | tuple[str, str, str | None]], + description: str = "", ): - self.dnode[node_cls] = {"bases": bases, "attrs": attrs} + """Add a node class to be generated. - def _write_imports_string(self): - self.import_string = "" + Args: + node_cls: Name of the node class + bases: Base classes + attrs: List of attribute tuples (name, type) or (name, type, type_param) + description: Optional class description + """ + # Check if class already exists (loaded from file) + existing_class = next( + (cls for cls in self.node_classes if cls.name == node_cls), None + ) + if existing_class: + # Class already exists, skip adding it + if self.log: + self.log.info(f"Node class {node_cls} already exists, skipping") + return - # Collect imports required for type hints (Node) - for mod, cls_set in self.dimport.items(): - if len(cls_set) == 1 and list(cls_set)[0] == "": - self.import_string += f"import {mod} \n" - continue + attributes = [] + for attr in attrs: + if len(attr) == 3: + # Clean the type_param to extract just the type name + attributes.append( + Attribute(name=attr[0], type=attr[1], type_param=attr[2]) + ) + else: + attributes.append(Attribute(name=attr[0], type=attr[1])) - cls_string = ", ".join(cls_set) + node_class = NodeClass( + name=node_cls, + bases=bases, + attributes=attributes, + description=description, + ) + self.node_classes.append(node_class) - self.import_string += f"from {mod} import {cls_string} \n" - self.import_string += "\n\n" + def from_json_describe(self, json_data: str | dict): + """Generate classes from a SECoP JSON describe message. - def _write_mod_cls_string(self): - self.mod_cls_string = "" + Args: + json_data: JSON string or dict containing SECoP describe data + """ + # Parse JSON if string + if isinstance(json_data, str): + _ = json.loads(json_data) + + # TODO: Implement parsing of SECoP JSON describe format + # This will extract module and node information from the JSON + # and populate self.module_classes and self.node_classes + raise NotImplementedError( + "Generation from JSON describe message is not yet implemented" + ) - for mod_cls, cls_dict in self.dmodule.items(): - # Generate the Python code for each Module class - bases = ", ".join(cls_dict["bases"]) + def generate_code(self) -> str: + """Generate Python code using Jinja2 template. - self.mod_cls_string += f"class {mod_cls}({bases}):\n" - # write Attributes - for attr_name, attr_type in cls_dict["attrs"]: - self.mod_cls_string += f" {attr_name}: {attr_type}\n" - self.mod_cls_string += "\n" - # write abstract methods - for plan in cls_dict["plans"]: - self.mod_cls_string += str(plan) + Returns: + Generated Python code as string + """ + template = self.jinja_env.get_template("generated_classes.py.jinja2") - self.mod_cls_string += "\n\n" + # Prepare template context + context = { + "imports": self.imports, + "module_classes": self.module_classes, + "node_classes": self.node_classes, + "enum_classes": self._collect_all_enums(), + } - def _write_node_cls_string(self): - self.node_cls_string = "" + # Render template + code = template.render(**context) - for node_cls, cls_dict in self.dnode.items(): - # Generate the Python code for each Module class - bases = ", ".join(cls_dict["bases"]) + # Remove unused imports with autoflake + try: - self.node_cls_string += f"class {node_cls}({bases}):\n" - for attr_name, attr_type in cls_dict["attrs"]: - self.node_cls_string += f" {attr_name}: {attr_type}\n" + code = autoflake.fix_code( + code, + remove_all_unused_imports=True, + remove_unused_variables=False, + remove_duplicate_keys=True, + ) + except Exception as e: + if self.log: + self.log.warning(f"Autoflake processing failed: {e}") + else: + print(f"Warning: Autoflake processing failed: {e}") + + # Format with Black + try: + code = black.format_str(code, mode=black.Mode()) + except Exception as e: + if self.log: + self.log.warning(f"Black formatting failed: {e}") + else: + print(f"Warning: Black formatting failed: {e}") - self.node_cls_string += "\n\n" + return code - def write_gen_node_class_file(self): - self._write_imports_string() - self._write_mod_cls_string() - self._write_node_cls_string() + def _collect_all_enums(self) -> list[EnumClass]: + """Collect and merge enum definitions from all module classes. - code = "" + When multiple module classes have enums with the same base name but different + members, they are merged into a single SupersetEnum containing the union of all + members. - code += self.import_string - code += self.mod_cls_string - code += self.node_cls_string + Returns: + List of deduplicated EnumClass definitions + """ + from collections import defaultdict + + # Group enums by their base name (ModuleClass + ParamName + Enum) + # We need to track which module classes use each enum + enum_groups = defaultdict(list) # base_name -> [(module_class, enum)] + + for mod_cls in self.module_classes: + for enum in mod_cls.enums: + # Extract base enum name by removing module class prefix + # e.g., "MassflowController1Gastype_selectEnum" -> need module class + # name + enum_groups[enum.name].append((mod_cls.name, enum)) + + # Process each enum group + merged_enums = [] + for enum_name, enum_list in enum_groups.items(): + if len(enum_list) == 1: + # Single enum definition - use StrictEnum + _, enum = enum_list[0] + enum.base_enum_class = "StrictEnum" + merged_enums.append(enum) + else: + # Multiple definitions - need to check if members are identical + member_sets = [ + frozenset((m.name, m.value) for m in enum.members) + for _, enum in enum_list + ] + + if len(set(member_sets)) == 1: + # All enums have identical members - use StrictEnum + _, enum = enum_list[0] + enum.base_enum_class = "StrictEnum" + merged_enums.append(enum) + else: + # Different members - merge into SupersetEnum + all_members_dict = {} # (name, value) -> EnumMember + + for _, enum in enum_list: + for member in enum.members: + key = (member.name, member.value) + if key not in all_members_dict: + all_members_dict[key] = member + + # Create merged enum with all unique members + _, base_enum = enum_list[0] + merged_enum = EnumClass( + name=enum_name, + members=list(all_members_dict.values()), + description=base_enum.description, + base_enum_class="SupersetEnum", + ) + merged_enums.append(merged_enum) + + return merged_enums - # Write the generated code to a .py file + def write_gen_node_class_file(self): + """Generate and write the class file to disk.""" + code = self.generate_code() + # Determine file path if self.module_folder_path is None: filep = Path(f"{self.ModName}.py") else: filep = self.module_folder_path / f"{self.ModName}.py" + + # Write to file with open(filep, "w") as file: file.write(code) - # Reload the Module after its source has been edited + if self.log: + self.log.info(f"Generated class file: {filep}") + else: + print(f"Generated class file: {filep}") + + # Reload the module if self.node_mod is not None: reload(self.node_mod) diff --git a/src/secop_ophyd/SECoPDevices.py b/src/secop_ophyd/SECoPDevices.py index 50a6aa3..d625bd9 100644 --- a/src/secop_ophyd/SECoPDevices.py +++ b/src/secop_ophyd/SECoPDevices.py @@ -44,7 +44,7 @@ from ophyd_async.core._utils import Callback from secop_ophyd.AsyncFrappyClient import AsyncFrappyClient -from secop_ophyd.GenNodeCode import GenNodeCode, Method +from secop_ophyd.GenNodeCode import GenNodeCode, Method, get_python_type_from_signal from secop_ophyd.logs import LOG_LEVELS, setup_logging from secop_ophyd.propertykeys import DATAINFO, EQUIPMENT_ID, INTERFACE_CLASSES from secop_ophyd.SECoPSignal import ( @@ -82,6 +82,28 @@ def clean_identifier(anystring): return str(re.sub(r"\W+|^(?=\d)", "_", anystring)) +def secop_enum_name_to_python(member_name: str) -> str: + """Convert SECoP enum member name to Python identifier. + + Examples: + 'Low Energy' -> 'LOW_ENERGY' + 'high-power' -> 'HIGH_POWER' + 'Mode 1' -> 'MODE_1' + + :param member_name: Original SECoP enum member name + :return: Python-compatible identifier in UPPER_CASE + """ + # Replace spaces and hyphens with underscores, remove other special chars + cleaned = re.sub(r"[\s-]+", "_", member_name) + cleaned = re.sub(r"[^a-zA-Z0-9_]", "", cleaned) + # Convert to uppercase + cleaned = cleaned.upper() + # Ensure it doesn't start with a digit + if cleaned and cleaned[0].isdigit(): + cleaned = "_" + cleaned + return cleaned + + class SECoPCMDDevice(StandardReadable, Flyable, Triggerable): """ Command devices that have Signals for command args, return values and a signal @@ -963,9 +985,9 @@ def class_from_instance(self, path_to_module: str | None = None): # NodeClass Name self._node_cls_name = self.name.replace("-", "_").capitalize() - node_bases = [self.__class__.__name__, "ABC"] + node_bases = [self.__class__.__name__] - node_class_attrs = [] + node_class_attrs: list[tuple[str, str] | tuple[str, str, str | None]] = [] for attr_name, attr_value in node_dict.items(): # Modules @@ -986,10 +1008,8 @@ def class_from_instance(self, path_to_module: str | None = None): # add imports for module attributes self.genCode.add_import(module, attr_type.__name__) - module_dict = attr_value.__dict__ - # modclass is baseclass of derived class - mod_bases = [attr_value.__class__.__name__, "ABC"] + mod_bases = [attr_value.__class__.__name__] module_class_attrs = [] @@ -998,35 +1018,130 @@ def class_from_instance(self, path_to_module: str | None = None): if attr_value.impl is not None: module_class_name = attr_value.impl.split(".").pop() - # Module:Acessibles - for module_attr_name, module_attr_value in module_dict.items(): - if isinstance( - module_attr_value, - (SignalR, SignalX, SignalRW, SignalR, SECoPCMDDevice), - ): - # add imports for module attributes - self.genCode.add_import( - module_attr_value.__module__, - type(module_attr_value).__name__, + # Track enum classes for this module + module_enum_classes = [] + + # Separate properties from parameters + module_properties = [] + module_parameters = [] + + # Process module properties + for prop_name, prop_signal in attr_value.mod_prop_devices.items(): + type_param = get_python_type_from_signal(prop_signal) + + module_properties.append( + ( + prop_name, + type(prop_signal).__name__, + type_param, + None, + "property", + ) + ) + + # Process module parameters + for param_name, param_signal in attr_value.param_devices.items(): + if not isinstance(param_signal, (SignalR, SignalRW)): + continue + + # Extract type parameter for signals + type_param = get_python_type_from_signal(param_signal) + + try: + param_props = param_signal._connector.backend._get_param_desc() + descr = param_props["description"] + unit = param_props["datainfo"].get("unit") + attr_descr = f"{descr}; Unit: ({unit})" if unit else descr + datainfo = param_props.get("datainfo", {}) + except Exception: + attr_descr = None + datainfo = {} + + # Handle StrictEnum types - generate enum class + if type_param and "StrictEnum" in type_param: + # Generate unique enum class name: + # ModuleClass + ParamName + Enum + enum_class_name = ( + f"{module_class_name}{param_name.capitalize()}Enum" ) - module_class_attrs.append( - (module_attr_name, type(module_attr_value).__name__) + # Extract enum members from datainfo + enum_members_dict = datainfo.get("members", {}) + if enum_members_dict: + from secop_ophyd.GenNodeCode import EnumClass, EnumMember + + enum_members = [] + for member_value, _ in enum_members_dict.items(): + # Convert member name to Python identifier + python_name = secop_enum_name_to_python(member_value) + enum_members.append( + EnumMember( + name=python_name, + value=member_value, + description=None, + ) + ) + + # Create enum class definition + enum_descr = f"{param_name} enum for `{module_class_name}`." + + enum_cls = EnumClass( + name=enum_class_name, + members=enum_members, + description=enum_descr, + ) + module_enum_classes.append(enum_cls) + + # Use the specific enum class name instead of generic + # StrictEnum + type_param = enum_class_name + + module_parameters.append( + ( + param_name, + type(param_signal).__name__, + type_param, + attr_descr, + "parameter", ) + ) + + # Combine properties and parameters with category markers + module_class_attrs = module_properties + module_parameters + + module_description: str = ( + self._secclient.modules[attr_name] + .get("properties") + .get("description") + ) + self.genCode.add_mod_class( - module_class_name, mod_bases, module_class_attrs, attr_value.plans + module_class_name, + mod_bases, + module_class_attrs, + attr_value.plans, + module_description, + module_enum_classes, ) - node_class_attrs.append((attr_name, module_class_name)) + # Type the None explicitly as str | None to match other entries + type_param_none: str | None = None + node_class_attrs.append((attr_name, module_class_name, type_param_none)) # Poperty Signals if isinstance(attr_value, (SignalR)): - self.genCode.add_import( - attr_value.__module__, type(attr_value).__name__ + + # Extract type parameter for node-level signals + type_param = get_python_type_from_signal(attr_value) + + node_class_attrs.append( + (attr_name, str(attr_value.__class__.__name__), type_param) ) - node_class_attrs.append((attr_name, attr_value.__class__.__name__)) - self.genCode.add_node_class(self._node_cls_name, node_bases, node_class_attrs) + node_description: str = self._secclient.properties.get("description") + self.genCode.add_node_class( + self._node_cls_name, node_bases, node_class_attrs, node_description + ) self.genCode.write_gen_node_class_file() diff --git a/src/secop_ophyd/templates/generated_classes.py.jinja2 b/src/secop_ophyd/templates/generated_classes.py.jinja2 new file mode 100644 index 0000000..fa54844 --- /dev/null +++ b/src/secop_ophyd/templates/generated_classes.py.jinja2 @@ -0,0 +1,72 @@ +{# Main template for generating ophyd device classes #} +{# Imports section #} +{% for module, classes in imports.items() %} +{%- if classes is none %} +import {{ module }} +{%- elif classes %} +from {{ module }} import {{ classes | sort | join(', ') }} +{%- else %} +import {{ module }} +{%- endif %} +{% endfor %} + + +{# Enum classes section #} +{% for enum_cls in enum_classes %} +class {{ enum_cls.name }}({{ enum_cls.base_enum_class }}): + """{{ enum_cls.description or 'Generated enum class' }}""" +{%- for member in enum_cls.members %} + {{ member.name }} = "{{ member.value }}" +{%- endfor %} + + +{% endfor %} + +{# Module classes section #} +{% for module_cls in module_classes %} +class {{ module_cls.name }}({{ module_cls.bases | join(', ') }}): + """{{ module_cls.description or 'Generated module class' }}""" +{%- set properties = module_cls.attributes | selectattr('category', 'equalto', 'property') | list %} +{%- set parameters = module_cls.attributes | selectattr('category', 'equalto', 'parameter') | list %} +{%- if properties %} + + # Module Properties +{%- for attr in properties %} + {{ attr.name }}: {{ attr.type }} +{%- if attr.type_param %}[{{ attr.type_param }}]{% endif %} +{%- if attr.description %} # {{ attr.description }}{% endif %} +{%- endfor %} +{%- endif %} +{%- if parameters %} + + # Module Parameters +{%- for attr in parameters %} + {{ attr.name }}: {{ attr.type }} +{%- if attr.type_param %}[{{ attr.type_param }}]{% endif %} +{%- if attr.description %} # {{ attr.description }}{% endif %} +{%- endfor %} +{%- endif %} +{%- if module_cls.methods %} + + # SECoP Commands wrapped as Bluesky Plans: +{%- for method in module_cls.methods %} + @abstractmethod + def {{ method.name }}{{ method.signature }}: + """{{ method.description }}""" +{%- endfor %} +{%- endif %} + + +{% endfor %} + +{# Node classes section #} +{% for node_cls in node_classes %} +class {{ node_cls.name }}({{ node_cls.bases | join(', ') }}): + """{{ node_cls.description or 'Generated node class' }}""" +{%- for attr in node_cls.attributes %} + {{ attr.name }}: {{ attr.type }} +{%- if attr.type_param %}[{{ attr.type_param }}]{% endif %} +{%- if attr.description %} # {{ attr.description }}{% endif %} +{%- endfor %} + +{% endfor %} diff --git a/tests/conftest.py b/tests/conftest.py index 10f4ba0..efd7d1e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,7 @@ # mypy: disable-error-code="attr-defined" import logging import os +from pathlib import Path import pytest from bluesky import RunEngine @@ -19,6 +20,26 @@ from secop_ophyd.SECoPDevices import SECoPNodeDevice +@pytest.fixture +def clean_generated_file(): + """Clean up generated genNodeClass.py file before test runs. + + This fixture ensures a fresh start for code generation tests while + allowing inspection of results after the test completes. + + Returns: + Path to the testgen directory where files should be generated + """ + testgen_dir = Path(__file__).parent / "testgen" + testgen_dir.mkdir(exist_ok=True) + + gen_file = testgen_dir / "genNodeClass.py" + if gen_file.exists(): + gen_file.unlink() + + return testgen_dir + + @pytest.fixture def frappy_env(): """Create and return environment variables and paths for frappy server.""" diff --git a/tests/test_gencode_refactor.py b/tests/test_gencode_refactor.py new file mode 100644 index 0000000..ed188e1 --- /dev/null +++ b/tests/test_gencode_refactor.py @@ -0,0 +1,336 @@ +"""Simple test to verify GenNodeCode refactoring works.""" + +import sys +from pathlib import Path + +from secop_ophyd.GenNodeCode import ( + Attribute, + GenNodeCode, + Method, + ModuleClass, + NodeClass, +) +from secop_ophyd.SECoPDevices import SECoPNodeDevice + +# Add src to path +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + + +def test_basic_functionality(clean_generated_file): + """Test basic GenNodeCode functionality.""" + print("Testing GenNodeCode refactored implementation...") + + # Create instance + gen_code = GenNodeCode(path=str(clean_generated_file), log=None) + + # Add some imports + gen_code.add_import("ophyd_async.core", "Device") + + # Create a simple method + from inspect import signature + + def sample_method(self, value: int) -> str: + """Sample method description""" + return str(value) + + # Method can be created in the old way (backward compatible) + method = Method( + cmd_name="sample_command", + description="Sample command for testing", + cmd_sign=signature(sample_method), + ) + + # Add a module class + gen_code.add_mod_class( + module_cls="TestModule", + bases=["Device"], + attrs=[ + ("temperature", "SignalR", "float", None, "parameter"), + ("pressure", "SignalR", "float", None, "parameter"), + ("count", "SignalRW", "int", None, "parameter"), + ], + cmd_plans=[method], + description="Test module class", + ) + + # Add a node class + gen_code.add_node_class( + node_cls="TestNode", + bases=["Device"], + attrs=[ + ("module1", "TestModule"), + ("status", "SignalR", "str"), + ], + description="Test node class", + ) + + # Generate code + code = gen_code.generate_code() + + gen_code.write_gen_node_class_file() + + print("\n" + "=" * 60) + print("Generated Code:") + print("=" * 60) + print(code) + print("=" * 60) + + # Verify code contains expected elements + assert "from abc import abstractmethod" in code + assert "class TestModule(Device):" in code + assert "temperature: SignalR[float]" in code + assert "count: SignalRW[int]" in code + assert "class TestNode(Device):" in code + assert "module1: TestModule" in code + assert "status: SignalR[str]" in code + assert "def sample_command" in code + + print("\n✓ All basic tests passed!") + + +def test_dataclasses(): + """Test the new dataclasses.""" + print("\nTesting dataclasses...") + + # Test Attribute + attr = Attribute(name="test_attr", type="SignalR") + assert attr.name == "test_attr" + assert attr.type == "SignalR" + + # Test ModuleClass + mod_cls = ModuleClass( + name="TestMod", bases=["Device"], attributes=[attr], methods=[] + ) + assert mod_cls.name == "TestMod" + assert len(mod_cls.attributes) == 1 + + # Test NodeClass + node_cls = NodeClass(name="TestNode", bases=["Device"], attributes=[attr]) + assert node_cls.name == "TestNode" + + print("✓ Dataclass tests passed!") + + +def test_subsequent_node_generation(clean_generated_file): + """Test generating code for two nodes sequentially, appending to the same file. + + Tests that: + - First: Generate NodeA with modules Type1 and Type2, write to file + - Second: Load existing file, add NodeB with Type1 (shared) and Type3 (new) + - Type1 should appear only once in the final file (not duplicated) + - All classes (Type1, Type2, Type3, NodeA, NodeB) are in the final file + """ + print("\nTesting subsequent node generation with file appending...") + print(f"Using output directory: {clean_generated_file}") + + from inspect import signature + + # ===== STEP 1: Generate and write first node (NodeA) ===== + print("\n--- Step 1: Generating NodeA ---") + gen_code1 = GenNodeCode(path=str(clean_generated_file), log=None) + + # Add necessary imports + gen_code1.add_import("ophyd_async.core", "Device") + + # Create sample methods + def type1_command(self, value: float) -> float: + """Type1 command""" + return value * 2.0 + + method_type1 = Method( + cmd_name="type1_cmd", + description="Type1 command", + cmd_sign=signature(type1_command), + ) + + def type2_command(self, mode: str) -> str: + """Type2 command""" + return f"Mode: {mode}" + + method_type2 = Method( + cmd_name="type2_cmd", + description="Type2 command", + cmd_sign=signature(type2_command), + ) + + # Add module class Type1 (will be shared) + gen_code1.add_mod_class( + module_cls="Type1", + bases=["Device"], + attrs=[ + ("description", "SignalR", "str", None, "property"), + ("interface_classes", "SignalR", "list", None, "property"), + ("temperature", "SignalR", "float", None, "parameter"), + ("setpoint", "SignalRW", "float", None, "parameter"), + ], + cmd_plans=[method_type1], + description="Type1 module - shared between nodes", + ) + + # Add module class Type2 (only in nodeA) + gen_code1.add_mod_class( + module_cls="Type2", + bases=["Device"], + attrs=[ + ("implementation", "SignalR", "str", None, "property"), + ("pressure", "SignalR", "float", None, "parameter"), + ("mode", "SignalRW", "str", None, "parameter"), + ], + cmd_plans=[method_type2], + description="Type2 module - only in nodeA", + ) + + # Add nodeA + gen_code1.add_node_class( + node_cls="NodeA", + bases=["Device"], + attrs=[ + ("modA", "Type1"), + ("modB", "Type2"), + ("status", "SignalR", "str"), + ], + description="NodeA with Type1 and Type2 modules", + ) + + # Generate and write first node + code1 = gen_code1.generate_code() + gen_code1.write_gen_node_class_file() + + print("\n" + "=" * 60) + print("First Generation (NodeA):") + print("=" * 60) + print(code1) + + # Verify first generation + assert "class Type1(Device):" in code1 + assert "class Type2(Device):" in code1 + assert "class NodeA(Device):" in code1 + assert "modA: Type1" in code1 + assert "modB: Type2" in code1 + + # ===== STEP 2: Load existing file and add second node (NodeB) ===== + print("\n--- Step 2: Loading file and adding NodeB ---") + gen_code2 = GenNodeCode(path=str(clean_generated_file), log=None) + + # Add necessary imports again + gen_code2.add_import("ophyd_async.core", "Device") + + # Create method for Type3 + def type3_command(self, count: int) -> int: + """Type3 command""" + return count + 1 + + method_type3 = Method( + cmd_name="type3_cmd", + description="Type3 command", + cmd_sign=signature(type3_command), + ) + + # Add Type1 again - GenNodeCode should detect it already exists + gen_code2.add_mod_class( + module_cls="Type1", + bases=["Device"], + attrs=[ + ("description", "SignalR", "str", None, "property"), + ("interface_classes", "SignalR", "list", None, "property"), + ("temperature", "SignalR", "float", None, "parameter"), + ("setpoint", "SignalRW", "float", None, "parameter"), + ], + cmd_plans=[method_type1], + description="Type1 module - shared between nodes", + ) + + # Add module class Type3 (only in nodeB) + gen_code2.add_mod_class( + module_cls="Type3", + bases=["Device"], + attrs=[ + ("group", "SignalR", "str", None, "property"), + ("count", "SignalRW", "int", None, "parameter"), + ("enabled", "SignalR", "bool", None, "parameter"), + ], + cmd_plans=[method_type3], + description="Type3 module - only in nodeB", + ) + + # Add nodeB + gen_code2.add_node_class( + node_cls="NodeB", + bases=["Device"], + attrs=[ + ("modA", "Type1"), + ("modB", "Type3"), + ("name", "SignalR", "str"), + ], + description="NodeB with Type1 and Type3 modules", + ) + + # Generate and write second node (appends to the file) + code2 = gen_code2.generate_code() + gen_code2.write_gen_node_class_file() + + print("\n" + "=" * 60) + print("Second Generation (NodeA + NodeB):") + print("=" * 60) + print(code2) + + # ===== VERIFICATION ===== + # Verify that Type1 appears only once in the final code + type1_count = code2.count("class Type1(Device):") + print(f"\nType1 class count: {type1_count}") + assert ( + type1_count == 1 + ), f"Type1 should appear exactly once, but appears {type1_count} times" + + # Verify all module classes are present + assert "class Type1(Device):" in code2 + assert "class Type2(Device):" in code2 + assert "class Type3(Device):" in code2 + + # Verify both node classes are present + assert "class NodeA(Device):" in code2 + assert "class NodeB(Device):" in code2 + + # Verify all methods are present + assert "def type1_cmd" in code2 + assert "def type2_cmd" in code2 + assert "def type3_cmd" in code2 + + # Verify section comments are present + assert "# Module Properties" in code2 + assert "# Module Parameters" in code2 + + print("\n✓ Subsequent node generation test passed!") + print("✓ Type1 module defined only ONCE (not duplicated)") + print("✓ NodeA present with Type1 and Type2") + print("✓ NodeB present with Type1 and Type3") + print("✓ All classes in the same file after appending") + print("✓ Properties and Parameters properly separated") + + +def test_real_node(clean_generated_file, cryo_sim, cryo_node_no_re: SECoPNodeDevice): + """Test generating code for a real SECoP node.""" + + cryo_node_no_re.class_from_instance(clean_generated_file) + + +async def test_cmd_real_node( + clean_generated_file, + nested_struct_sim, + RE, + nested_node_no_re: SECoPNodeDevice, # noqa: N803 +): + + nested_node_no_re.class_from_instance(clean_generated_file) + + +async def test_gas_dosing(clean_generated_file): + + from ophyd_async.core import init_devices + + async with init_devices(): + gas_dosing = SECoPNodeDevice( + sec_node_uri="localhost:10801", + ) + + gas_dosing.class_from_instance(clean_generated_file) diff --git a/uv.lock b/uv.lock index dcb4814..cd10a56 100644 --- a/uv.lock +++ b/uv.lock @@ -68,6 +68,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, ] +[[package]] +name = "autoflake" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyflakes" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/cb/486f912d6171bc5748c311a2984a301f4e2d054833a1da78485866c71522/autoflake-2.3.1.tar.gz", hash = "sha256:c98b75dc5b0a86459c4f01a1d32ac7eb4338ec4317a4469515ff1e687ecd909e", size = 27642, upload-time = "2024-03-13T03:41:28.977Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/ee/3fd29bf416eb4f1c5579cf12bf393ae954099258abd7bde03c4f9716ef6b/autoflake-2.3.1-py3-none-any.whl", hash = "sha256:3ae7495db9084b7b32818b4140e6dc4fc280b712fb414f5b8fe57b0a8e85a840", size = 32483, upload-time = "2024-03-13T03:41:26.969Z" }, +] + [[package]] name = "babel" version = "2.17.0" @@ -2526,11 +2538,13 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "autoflake" }, { name = "black" }, { name = "bluesky" }, { name = "cycler" }, { name = "ipykernel" }, { name = "isort" }, + { name = "jinja2" }, { name = "matplotlib" }, { name = "mlzlog" }, { name = "mypy" }, @@ -2564,11 +2578,13 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ + { name = "autoflake" }, { name = "black" }, { name = "bluesky" }, { name = "cycler" }, { name = "ipykernel" }, { name = "isort" }, + { name = "jinja2" }, { name = "matplotlib" }, { name = "mlzlog" }, { name = "mypy" }, From 1268d685ae8dfaf66ae0e4a2259f7abe65b20184 Mon Sep 17 00:00:00 2001 From: Peter Braun Date: Fri, 23 Jan 2026 15:08:42 +0100 Subject: [PATCH 03/18] added tests --- tests/test_enum_codegen.py | 141 ++++++++++++++++++++++++++++++ tests/test_enum_merging.py | 173 +++++++++++++++++++++++++++++++++++++ 2 files changed, 314 insertions(+) create mode 100644 tests/test_enum_codegen.py create mode 100644 tests/test_enum_merging.py diff --git a/tests/test_enum_codegen.py b/tests/test_enum_codegen.py new file mode 100644 index 0000000..8a7a3a5 --- /dev/null +++ b/tests/test_enum_codegen.py @@ -0,0 +1,141 @@ +"""Test full code generation with enums.""" + +import tempfile + +from secop_ophyd.GenNodeCode import ( + Attribute, + EnumClass, + EnumMember, + GenNodeCode, + ModuleClass, +) + + +def test_code_generation_with_enums(): + """Test that generated code includes correct enum base classes.""" + + with tempfile.TemporaryDirectory() as tmpdir: + gen_code = GenNodeCode(path=tmpdir) + + # Create two module classes with different enums for the same parameter + enum1 = EnumClass( + name="MassflowControllerGastypeEnum", + members=[ + EnumMember("H2", "H2", "Hydrogen"), + EnumMember("N2", "N2", "Nitrogen"), + ], + description="Gastype enum for MassflowController", + ) + + enum2 = EnumClass( + name="MassflowControllerGastypeEnum", + members=[ + EnumMember("N2", "N2", "Nitrogen"), + EnumMember("AR", "Ar", "Argon"), + ], + description="Gastype enum for MassflowController", + ) + + mod1 = ModuleClass( + name="MassflowController1", + bases=["Device"], + attributes=[ + Attribute("gastype", "SignalRW", "MassflowControllerGastypeEnum"), + ], + enums=[enum1], + ) + + mod2 = ModuleClass( + name="MassflowController2", + bases=["Device"], + attributes=[ + Attribute("gastype", "SignalRW", "MassflowControllerGastypeEnum"), + ], + enums=[enum2], + ) + + gen_code.module_classes = [mod1, mod2] + + # Generate code + code = gen_code.generate_code() + + print("Generated code:") + print("=" * 60) + print(code) + print("=" * 60) + + # Verify the enum uses SupersetEnum + assert "class MassflowControllerGastypeEnum(SupersetEnum):" in code + assert "SupersetEnum" in code + + # Verify all three members are present + assert 'H2 = "H2"' in code + assert 'N2 = "N2"' in code + assert 'AR = "Ar"' in code + + print("\n✓ Code generation with SupersetEnum successful!") + + +def test_code_generation_strict_enum(): + """Test that identical enums generate StrictEnum.""" + + with tempfile.TemporaryDirectory() as tmpdir: + gen_code = GenNodeCode(path=tmpdir) + + # Create two module classes with identical enums + enum1 = EnumClass( + name="StatusEnum", + members=[ + EnumMember("IDLE", "idle", "Idle state"), + EnumMember("BUSY", "busy", "Busy state"), + ], + description="Status enum", + ) + + enum2 = EnumClass( + name="StatusEnum", + members=[ + EnumMember("IDLE", "idle", "Idle state"), + EnumMember("BUSY", "busy", "Busy state"), + ], + description="Status enum", + ) + + mod1 = ModuleClass( + name="Device1", + bases=["Device"], + attributes=[ + Attribute("status", "SignalR", "StatusEnum"), + ], + enums=[enum1], + ) + + mod2 = ModuleClass( + name="Device2", + bases=["Device"], + attributes=[ + Attribute("status", "SignalR", "StatusEnum"), + ], + enums=[enum2], + ) + + gen_code.module_classes = [mod1, mod2] + + # Generate code + code = gen_code.generate_code() + + print("\nGenerated code with StrictEnum:") + print("=" * 60) + print(code) + print("=" * 60) + + # Verify the enum uses StrictEnum + assert "class StatusEnum(StrictEnum):" in code + + print("\n✓ Code generation with StrictEnum successful!") + + +if __name__ == "__main__": + test_code_generation_with_enums() + test_code_generation_strict_enum() + print("\n✓ All code generation tests passed!") diff --git a/tests/test_enum_merging.py b/tests/test_enum_merging.py new file mode 100644 index 0000000..a4aaee3 --- /dev/null +++ b/tests/test_enum_merging.py @@ -0,0 +1,173 @@ +"""Test enum merging logic for StrictEnum vs SupersetEnum.""" + +from secop_ophyd.GenNodeCode import EnumClass, EnumMember, GenNodeCode + + +def test_identical_enums_use_strict(): + """When module instances have identical enum values, use StrictEnum.""" + gen_code = GenNodeCode() + + # Create two module classes with identical enums + enum1 = EnumClass( + name="TestParamEnum", + members=[ + EnumMember("H2", "H2", "Hydrogen"), + EnumMember("N2", "N2", "Nitrogen"), + ], + ) + + enum2 = EnumClass( + name="TestParamEnum", + members=[ + EnumMember("H2", "H2", "Hydrogen"), + EnumMember("N2", "N2", "Nitrogen"), + ], + ) + + # Add module classes with these enums + gen_code.module_classes = [] + from secop_ophyd.GenNodeCode import ModuleClass + + mod1 = ModuleClass(name="Module1", bases=["Device"], enums=[enum1]) + mod2 = ModuleClass(name="Module2", bases=["Device"], enums=[enum2]) + + gen_code.module_classes = [mod1, mod2] + + # Collect enums + merged = gen_code._collect_all_enums() + + # Should have one enum using StrictEnum + assert len(merged) == 1 + assert merged[0].base_enum_class == "StrictEnum" + assert len(merged[0].members) == 2 + print("✓ Identical enums correctly use StrictEnum") + + +def test_different_enums_use_superset(): + """When module instances have different enum values, use SupersetEnum.""" + gen_code = GenNodeCode() + + # Create two module classes with different enum members + enum1 = EnumClass( + name="TestParamEnum", + members=[ + EnumMember("H2", "H2", "Hydrogen"), + EnumMember("N2", "N2", "Nitrogen"), + ], + ) + + enum2 = EnumClass( + name="TestParamEnum", + members=[ + EnumMember("N2", "N2", "Nitrogen"), + EnumMember("AR", "Ar", "Argon"), + ], + ) + + # Add module classes with these enums + from secop_ophyd.GenNodeCode import ModuleClass + + mod1 = ModuleClass(name="Module1", bases=["Device"], enums=[enum1]) + mod2 = ModuleClass(name="Module2", bases=["Device"], enums=[enum2]) + + gen_code.module_classes = [mod1, mod2] + + # Collect enums + merged = gen_code._collect_all_enums() + + # Should have one enum using SupersetEnum with all members + assert len(merged) == 1 + assert merged[0].base_enum_class == "SupersetEnum" + assert len(merged[0].members) == 3 # H2, N2, AR + + member_names = {m.name for m in merged[0].members} + assert member_names == {"H2", "N2", "AR"} + print("✓ Different enums correctly merged into SupersetEnum") + + +def test_single_enum_uses_strict(): + """When only one module has an enum, use StrictEnum.""" + gen_code = GenNodeCode() + + enum1 = EnumClass( + name="TestParamEnum", + members=[ + EnumMember("H2", "H2", "Hydrogen"), + EnumMember("N2", "N2", "Nitrogen"), + ], + ) + + from secop_ophyd.GenNodeCode import ModuleClass + + mod1 = ModuleClass(name="Module1", bases=["Device"], enums=[enum1]) + + gen_code.module_classes = [mod1] + + # Collect enums + merged = gen_code._collect_all_enums() + + # Should have one enum using StrictEnum + assert len(merged) == 1 + assert merged[0].base_enum_class == "StrictEnum" + assert len(merged[0].members) == 2 + print("✓ Single enum correctly uses StrictEnum") + + +def test_same_class_different_instances(): + """When same module class has different instances with different enums, use + SupersetEnum.""" + gen_code = GenNodeCode() + + # Simulate adding the same module class twice (different instances) + # First instance with AR, N2, H2 + enum1 = EnumClass( + name="Test_EnumGas_typeEnum", + members=[ + EnumMember("AR", "AR", "Argon"), + EnumMember("N2", "N2", "Nitrogen"), + EnumMember("H2", "H2", "Hydrogen"), + ], + ) + + # Second instance with AR, He, CO2 + enum2 = EnumClass( + name="Test_EnumGas_typeEnum", + members=[ + EnumMember("AR", "AR", "Argon"), + EnumMember("HE", "He", "Helium"), + EnumMember("CO2", "CO2", "Carbon Dioxide"), + ], + ) + + # First add_mod_class call + gen_code.add_mod_class( + module_cls="Test_Enum", + bases=["Device"], + attrs=[("gas_type", "SignalRW", "Test_EnumGas_typeEnum", None, "parameter")], + cmd_plans=[], + enum_classes=[enum1], + ) + + # Second add_mod_class call (same class name, different enum) + gen_code.add_mod_class( + module_cls="Test_Enum", + bases=["Device"], + attrs=[("gas_type", "SignalRW", "Test_EnumGas_typeEnum", None, "parameter")], + cmd_plans=[], + enum_classes=[enum2], + ) + + # Should only have one module class, but with both enums + assert len(gen_code.module_classes) == 1 + assert len(gen_code.module_classes[0].enums) == 2 + + # Collect and merge enums + merged = gen_code._collect_all_enums() + + # Should have one enum using SupersetEnum with all 5 unique members + assert len(merged) == 1 + assert merged[0].base_enum_class == "SupersetEnum" + assert len(merged[0].members) == 5 # AR, N2, H2, He, CO2 + + member_names = {m.name for m in merged[0].members} + assert member_names == {"AR", "N2", "H2", "HE", "CO2"} From b3030378b81847c95155bdb3ad7a1528bbd7943d Mon Sep 17 00:00:00 2001 From: Peter Braun Date: Wed, 28 Jan 2026 15:26:36 +0100 Subject: [PATCH 04/18] gen code migrated to generating classes from JSON description --- src/secop_ophyd/GenNodeCode.py | 347 ++++++++++++++---- src/secop_ophyd/SECoPDevices.py | 232 ++---------- .../templates/generated_classes.py.jinja2 | 17 +- tests/test_gencode_refactor.py | 63 ++-- 4 files changed, 365 insertions(+), 294 deletions(-) diff --git a/src/secop_ophyd/GenNodeCode.py b/src/secop_ophyd/GenNodeCode.py index 8b1febb..f557e71 100644 --- a/src/secop_ophyd/GenNodeCode.py +++ b/src/secop_ophyd/GenNodeCode.py @@ -11,8 +11,25 @@ import autoflake import black +from frappy.client import get_datatype +from frappy.datatypes import DataType from jinja2 import Environment, PackageLoader, select_autoescape -from ophyd_async.core import Signal +from ophyd_async.core import SignalR, SignalRW + +from secop_ophyd.SECoPDevices import ( + IGNORED_PROPS, + class_from_interface, + secop_enum_name_to_python, +) +from secop_ophyd.SECoPSignal import secop_dtype_obj_from_json +from secop_ophyd.util import SECoPdtype + + +def internalize_name(name: str) -> str: + """how to create internal names""" + if name.startswith("_"): + return name[1:] + return name @dataclass @@ -75,21 +92,6 @@ def __init__(self, cmd_name: str, description: str, cmd_sign: Signature) -> None self.name: str = cmd_name self.signature: str = sig_str self.description: str = description - self.sig_str: str = self.signature # For backward compatibility - - @classmethod - def from_cmd(cls, cmd_name: str, description: str, cmd_sign: Signature) -> "Method": - """Create Method from command signature. - - Args: - cmd_name: Name of the command - description: Description of the command - cmd_sign: Signature of the command - - Returns: - Method instance - """ - return cls(cmd_name, description, cmd_sign) @dataclass @@ -114,37 +116,6 @@ class NodeClass: description: str = "" -def get_python_type_from_signal(signal_obj: Signal) -> str | None: - """Extract Python type from signal backend datatype. - - Args: - signal_obj: Signal object (SignalR, SignalRW, etc.) - debug: If True, print debug information - - Returns: - Python type string (e.g., 'float', 'int', 'str') or None - """ - try: - - type_obj = signal_obj.datatype - - # Get the module name - module = type_obj.__module__ - - # For builtins, just return the name without module prefix - if module == "builtins": - return type_obj.__name__ - - return f"{module}.{type_obj.__name__}" - - except Exception as e: - print(f"DEBUG: Exception occurred: {type(e).__name__}: {e}") - import traceback - - traceback.print_exc() - return None - - class GenNodeCode: """Generates annotated Python classes for SECoP ophyd devices. @@ -381,7 +352,7 @@ def _parse_module_class(self, class_symbol: str, class_obj: type): method_source = inspect.getsource(method) description = self._extract_method_description(method_source) methods.append( - Method.from_cmd(method_name, description, inspect.signature(method)) + Method(method_name, description, inspect.signature(method)) ) bases = [base.__name__ for base in class_obj.__bases__] @@ -541,7 +512,7 @@ def add_node_class( self, node_cls: str, bases: list[str], - attrs: list[tuple[str, str] | tuple[str, str, str | None]], + attrs: list[tuple[str, str, str | None, str | None, str]], description: str = "", ): """Add a node class to be generated. @@ -549,8 +520,10 @@ def add_node_class( Args: node_cls: Name of the node class bases: Base classes - attrs: List of attribute tuples (name, type) or (name, type, type_param) - description: Optional class description + attrs: List of attribute tuples. Supported formats: + - (name, type) + - (name, type, type_param) + - (name, type, type_param, description, category) """ # Check if class already exists (loaded from file) existing_class = next( @@ -563,14 +536,23 @@ def add_node_class( return attributes = [] + for attr in attrs: - if len(attr) == 3: - # Clean the type_param to extract just the type name - attributes.append( - Attribute(name=attr[0], type=attr[1], type_param=attr[2]) + # attr[0] is name, attr[1] is type (both required) + name = str(attr[0]) + attr_type = str(attr[1]) + type_param = str(attr[2]) if len(attr) > 2 and attr[2] else None + descr = str(attr[3]) if len(attr) > 3 and attr[3] else None + category = str(attr[4]) if len(attr) > 4 and attr[4] else "property" + attributes.append( + Attribute( + name=name, + type=attr_type, + type_param=type_param, + description=descr, + category=category, ) - else: - attributes.append(Attribute(name=attr[0], type=attr[1])) + ) node_class = NodeClass( name=node_cls, @@ -580,6 +562,29 @@ def add_node_class( ) self.node_classes.append(node_class) + def _parse_command_signature( + self, cmd_name: str, datainfo: dict, description: str + ) -> Method: + """Parse command datainfo to create Method signature. + + Args: + cmd_name: Name of the command + datainfo: Command datainfo with argument/result types + description: Command description + + Returns: + Method object with signature + """ + # Extract argument and result types + arg_type = datainfo.get("argument") + + # Create a basic signature object + sig = Signature.from_callable(lambda self, wait_for_idle=False: None) + if arg_type is not None: + sig = Signature.from_callable(lambda self, arg, wait_for_idle=False: None) + + return Method(cmd_name=cmd_name, description=description, cmd_sign=sig) + def from_json_describe(self, json_data: str | dict): """Generate classes from a SECoP JSON describe message. @@ -588,15 +593,218 @@ def from_json_describe(self, json_data: str | dict): """ # Parse JSON if string if isinstance(json_data, str): - _ = json.loads(json_data) + describe_data = json.loads(json_data) + else: + describe_data = json_data + + modules: dict[str, dict] = describe_data.get("modules", {}) + node_properties = {k: v for k, v in describe_data.items() if k != "modules"} + + # Parse modules + node_attrs: list[tuple[str, str, str | None, str | None, str]] = [] + for modname, moddescr in modules.items(): + # separate accessibles into command and parameters + parameters = {} + commands = {} + accessibles = moddescr["accessibles"] + for aname, aentry in accessibles.items(): + iname = internalize_name(aname) + datatype = get_datatype(aentry["datainfo"], iname) + + aentry = dict(aentry, datatype=datatype) + + if datatype.IS_COMMAND: + commands[iname] = aentry + else: + parameters[iname] = aentry + + properties = {k: v for k, v in moddescr.items() if k != "accessibles"} + + # Add module class (highest secop interface class) that the actual + # module class is derived from + secop_ophyd_modclass = class_from_interface(properties) + module_bases = [secop_ophyd_modclass.__name__] + + # Add the module class, use self reported "implementation" module property, + # if not present use the module name + module_class = modname + if properties.get("implementation") is not None: + module_class = properties.get("implementation", "").split(".").pop() + + # Module enum classes + module_enum_classes = [] + + # Prepare attributes + + # Module Commands + command_plans = [] + + for command, command_data in commands.items(): + # Stop is already an ophyd native operation + if command == "stop": + continue + + argument = command_data["datainfo"].get("argument") + result = command_data["datainfo"].get("result") + + description: str = "" + description += f"{command_data['description']}\n" + + if argument: + description += ( + f" argument: {command_data['datainfo'].get('argument')}\n" + ) + if result: + description += ( + f" result: {command_data['datainfo'].get('result')}" + ) + + def command_plan(self, arg, wait_for_idle: bool = False): + pass + + def command_plan_no_arg(self, wait_for_idle: bool = False): + pass + + plan = Method( + cmd_name=command, + description=description, + cmd_sign=inspect.signature( + command_plan if argument else command_plan_no_arg + ), + ) + + command_plans.append(plan) + + # Module parameters + mod_params: list[tuple[str, str, str | None, str | None, str]] = [] + + for param_name, param_data in parameters.items(): + + descr = param_data["description"] + unit = param_data["datainfo"].get("unit") + + param_descr = f"{descr}; Unit: ({unit})" if unit else descr + signal_base = SignalR if param_data["readonly"] else SignalRW + + datainfo = param_data.get("datainfo", {}) + + # infer the ophyd type from secop datatype + type_param = get_type_param(param_data["datatype"]) + + # Handle StrictEnum types - generate enum class + if type_param and "StrictEnum" in type_param: + # Generate unique enum class name: + # ModuleClass + ParamName + Enum + enum_class_name = f"{module_class}{param_name.capitalize()}Enum" + + # Extract enum members from datainfo + enum_members_dict = datainfo.get("members", {}) + if enum_members_dict: + from secop_ophyd.GenNodeCode import EnumClass, EnumMember + + enum_members = [] + for member_value, _ in enum_members_dict.items(): + # Convert member name to Python identifier + python_name = secop_enum_name_to_python(member_value) + enum_members.append( + EnumMember( + name=python_name, + value=member_value, + description=None, + ) + ) + + # Create enum class definition + enum_descr = f"{param_name} enum for `{module_class}`." + + enum_cls = EnumClass( + name=enum_class_name, + members=enum_members, + description=enum_descr, + ) + module_enum_classes.append(enum_cls) + + # Use the specific enum class name instead of generic + # StrictEnum + type_param = enum_class_name + + mod_params.append( + ( + param_name, + signal_base.__name__, + type_param, + param_descr, + "parameter", + ) + ) + + # Module properties + mod_props: list[tuple[str, str, str | None, str | None, str]] = [] - # TODO: Implement parsing of SECoP JSON describe format - # This will extract module and node information from the JSON - # and populate self.module_classes and self.node_classes - raise NotImplementedError( - "Generation from JSON describe message is not yet implemented" + # Process module properties + for prop_name, property_value in properties.items(): + + if prop_name in IGNORED_PROPS: + continue + + type_param = get_type_param(secop_dtype_obj_from_json(property_value)) + + mod_props.append( + ( + prop_name, + SignalR.__name__, + type_param, + None, + "property", + ) + ) + + self.add_mod_class( + module_cls=module_class, + bases=module_bases, + attrs=mod_params + mod_props, + cmd_plans=command_plans, + description=properties.get("description", ""), + enum_classes=module_enum_classes, + ) + + # Add to node attributes + # Type the None explicitly as str | None to match other entries + + node_attrs.append((modname, module_class, None, None, "module")) + + # Process module properties + for prop_name, property_value in node_properties.items(): + type_param = get_type_param(secop_dtype_obj_from_json(property_value)) + + node_attrs.append( + (str(prop_name), str(SignalR.__name__), type_param, None, "property") + ) + + # Add node class + node_bases = ["SECoPNodeDevice"] + + equipment_id: str = node_properties["equipment_id"] + + # format node class accordingly + node_class_name = equipment_id.replace(".", "_").replace("-", "_").capitalize() + + self.add_node_class( + node_cls=node_class_name, + bases=node_bases, + attrs=node_attrs, + description=node_properties.get("description", ""), ) + # Add required imports + self.add_import("secop_ophyd.SECoPDevices", "SECoPNodeDevice") + self.add_import("secop_ophyd.SECoPDevices", "SECoPBaseDevice") + self.add_import("secop_ophyd.SECoPDevices", "SECoPCommunicatorDevice") + self.add_import("secop_ophyd.SECoPDevices", "SECoPReadableDevice") + self.add_import("secop_ophyd.SECoPDevices", "SECoPWritableDevice") + self.add_import("secop_ophyd.SECoPDevices", "SECoPMoveableDevice") + self.add_import("secop_ophyd.SECoPDevices", "SECoPTriggerableDevice") + def generate_code(self) -> str: """Generate Python code using Jinja2 template. @@ -729,3 +937,16 @@ def write_gen_node_class_file(self): # Reload the module if self.node_mod is not None: reload(self.node_mod) + + +def get_type_param(secop_dtype: DataType) -> str | None: + sig_type = SECoPdtype(secop_dtype).np_datatype + + # Get the module name + module = sig_type.__module__ + + # For builtins, just return the name without module prefix + if module == "builtins": + return sig_type.__name__ + + return f"{module}.{sig_type.__name__}" diff --git a/src/secop_ophyd/SECoPDevices.py b/src/secop_ophyd/SECoPDevices.py index d625bd9..3839856 100644 --- a/src/secop_ophyd/SECoPDevices.py +++ b/src/secop_ophyd/SECoPDevices.py @@ -1,5 +1,4 @@ import asyncio -import inspect import logging import re import time as ttime @@ -44,7 +43,6 @@ from ophyd_async.core._utils import Callback from secop_ophyd.AsyncFrappyClient import AsyncFrappyClient -from secop_ophyd.GenNodeCode import GenNodeCode, Method, get_python_type_from_signal from secop_ophyd.logs import LOG_LEVELS, setup_logging from secop_ophyd.propertykeys import DATAINFO, EQUIPMENT_ID, INTERFACE_CLASSES from secop_ophyd.SECoPSignal import ( @@ -78,6 +76,9 @@ UNKNOWN = 401 # not in SECoP standard (yet) +IGNORED_PROPS = ["meaning", "_plotly"] + + def clean_identifier(anystring): return str(re.sub(r"\W+|^(?=\d)", "_", anystring)) @@ -263,7 +264,6 @@ def __init__( self._module = module_name module_desc = secclient.modules[module_name] - self.plans: list[Method] = [] self.mod_prop_devices: Dict[str, SignalR] = {} self.param_devices: Dict[str, Any] = {} @@ -299,7 +299,7 @@ def __init__( if property == "implementation": self.impl = module_desc["properties"]["implementation"] - if property in ["meaning", "_plotly"]: + if property in IGNORED_PROPS: continue propb = PropertyBackend(property, module_desc["properties"], secclient) @@ -375,19 +375,6 @@ def __init__( setattr(self, command, MethodType(cmd_plan, self)) - description: str = "" - description += f"{cmd_dev.description}\n" - description += f" argument: {str(cmd_dev.arg_dtype)}\n" - description += f" result: {str(cmd_dev.res_dtype)}" - - plan = Method( - cmd_name=command, - description=description, - cmd_sign=inspect.signature(getattr(self, command)), - ) - - self.plans.append(plan) - self.set_name(module_name) # Add status Signal AFTER set_name() to avoid auto-registration as config/hinted @@ -916,8 +903,6 @@ async def connect( self.mod_devices: Dict[str, SECoPReadableDevice] = {} self.node_prop_devices: Dict[str, SignalR] = {} - self.genCode: GenNodeCode - if self.name == "": self.name = self._secclient.properties[EQUIPMENT_ID].replace(".", "-") @@ -944,9 +929,7 @@ async def connect( with self.add_children_as_readables(format=StandardReadableFormat.CHILD): for module, module_desc in self._secclient.modules.items(): - secop_dev_class = self.class_from_interface( - module_desc["properties"] - ) + secop_dev_class = class_from_interface(module_desc["properties"]) if secop_dev_class is not None: setattr( @@ -970,180 +953,16 @@ async def connect( super().__init__(name=self.name) def class_from_instance(self, path_to_module: str | None = None): - """Dynamically generate python class file for the SECoP_Node_Device, this - allows autocompletion in IDEs and eases working with the generated Ophyd - devices - """ - - # parse genClass file if already present - self.genCode = GenNodeCode(path=path_to_module, log=self._secclient.log) - - self.genCode.add_import(self.__module__, self.__class__.__name__) - - node_dict = self.__dict__ - - # NodeClass Name - self._node_cls_name = self.name.replace("-", "_").capitalize() - - node_bases = [self.__class__.__name__] - - node_class_attrs: list[tuple[str, str] | tuple[str, str, str | None]] = [] - - for attr_name, attr_value in node_dict.items(): - # Modules - if isinstance( - attr_value, - ( - SECoPBaseDevice, - SECoPCommunicatorDevice, - SECoPReadableDevice, - SECoPWritableDevice, - SECoPMoveableDevice, - SECoPTriggerableDevice, - ), - ): - attr_type = type(attr_value) - module = str(getattr(attr_type, "__module__", None)) - - # add imports for module attributes - self.genCode.add_import(module, attr_type.__name__) - - # modclass is baseclass of derived class - mod_bases = [attr_value.__class__.__name__] - - module_class_attrs = [] - - # Name for derived class - module_class_name = attr_name - if attr_value.impl is not None: - module_class_name = attr_value.impl.split(".").pop() - - # Track enum classes for this module - module_enum_classes = [] + from secop_ophyd.GenNodeCode import GenNodeCode - # Separate properties from parameters - module_properties = [] - module_parameters = [] + description = self._secclient.client.request("describe")[2] - # Process module properties - for prop_name, prop_signal in attr_value.mod_prop_devices.items(): - type_param = get_python_type_from_signal(prop_signal) - - module_properties.append( - ( - prop_name, - type(prop_signal).__name__, - type_param, - None, - "property", - ) - ) - - # Process module parameters - for param_name, param_signal in attr_value.param_devices.items(): - if not isinstance(param_signal, (SignalR, SignalRW)): - continue - - # Extract type parameter for signals - type_param = get_python_type_from_signal(param_signal) - - try: - param_props = param_signal._connector.backend._get_param_desc() - descr = param_props["description"] - unit = param_props["datainfo"].get("unit") - attr_descr = f"{descr}; Unit: ({unit})" if unit else descr - datainfo = param_props.get("datainfo", {}) - except Exception: - attr_descr = None - datainfo = {} - - # Handle StrictEnum types - generate enum class - if type_param and "StrictEnum" in type_param: - # Generate unique enum class name: - # ModuleClass + ParamName + Enum - enum_class_name = ( - f"{module_class_name}{param_name.capitalize()}Enum" - ) - - # Extract enum members from datainfo - enum_members_dict = datainfo.get("members", {}) - if enum_members_dict: - from secop_ophyd.GenNodeCode import EnumClass, EnumMember - - enum_members = [] - for member_value, _ in enum_members_dict.items(): - # Convert member name to Python identifier - python_name = secop_enum_name_to_python(member_value) - enum_members.append( - EnumMember( - name=python_name, - value=member_value, - description=None, - ) - ) - - # Create enum class definition - enum_descr = f"{param_name} enum for `{module_class_name}`." - - enum_cls = EnumClass( - name=enum_class_name, - members=enum_members, - description=enum_descr, - ) - module_enum_classes.append(enum_cls) - - # Use the specific enum class name instead of generic - # StrictEnum - type_param = enum_class_name - - module_parameters.append( - ( - param_name, - type(param_signal).__name__, - type_param, - attr_descr, - "parameter", - ) - ) - - # Combine properties and parameters with category markers - module_class_attrs = module_properties + module_parameters - - module_description: str = ( - self._secclient.modules[attr_name] - .get("properties") - .get("description") - ) - - self.genCode.add_mod_class( - module_class_name, - mod_bases, - module_class_attrs, - attr_value.plans, - module_description, - module_enum_classes, - ) - - # Type the None explicitly as str | None to match other entries - type_param_none: str | None = None - node_class_attrs.append((attr_name, module_class_name, type_param_none)) - - # Poperty Signals - if isinstance(attr_value, (SignalR)): - - # Extract type parameter for node-level signals - type_param = get_python_type_from_signal(attr_value) - - node_class_attrs.append( - (attr_name, str(attr_value.__class__.__name__), type_param) - ) + # parse genClass file if already present + genCode = GenNodeCode(path=path_to_module, log=self._secclient.log) - node_description: str = self._secclient.properties.get("description") - self.genCode.add_node_class( - self._node_cls_name, node_bases, node_class_attrs, node_description - ) + genCode.from_json_describe(description) - self.genCode.write_gen_node_class_file() + genCode.write_gen_node_class_file() def descriptiveDataChange(self, module, description): # noqa: N802 """called when the description has changed @@ -1180,7 +999,7 @@ def descriptiveDataChange(self, module, description): # noqa: N802 else: # Refresh changed modules module_desc = self._secclient.modules[module] - secop_dev_class = self.class_from_interface(module_desc["properties"]) + secop_dev_class = class_from_interface(module_desc["properties"]) setattr(self, module, secop_dev_class(self._secclient, module)) @@ -1195,22 +1014,23 @@ def nodeStateChange(self, online, state): # noqa: N802 if state == "connected" and online is True: self._secclient.conn_timestamp = ttime.time() - def class_from_interface(self, mod_properties: dict): - ophyd_class = None - # infer highest level IF class - module_interface_classes: dict = mod_properties[INTERFACE_CLASSES] - for interface_class in IF_CLASSES.keys(): - if interface_class in module_interface_classes: - ophyd_class = IF_CLASSES[interface_class] - break +def class_from_interface(mod_properties: dict): + ophyd_class = None + + # infer highest level IF class + module_interface_classes: dict = mod_properties[INTERFACE_CLASSES] + for interface_class in IF_CLASSES.keys(): + if interface_class in module_interface_classes: + ophyd_class = IF_CLASSES[interface_class] + break - # No predefined IF class was a match --> use base class (loose collection of - # accessibles) - if ophyd_class is None: - ophyd_class = SECoPBaseDevice # type: ignore + # No predefined IF class was a match --> use base class (loose collection of + # accessibles) + if ophyd_class is None: + ophyd_class = SECoPBaseDevice # type: ignore - return ophyd_class + return ophyd_class IF_CLASSES = { diff --git a/src/secop_ophyd/templates/generated_classes.py.jinja2 b/src/secop_ophyd/templates/generated_classes.py.jinja2 index fa54844..7f7819c 100644 --- a/src/secop_ophyd/templates/generated_classes.py.jinja2 +++ b/src/secop_ophyd/templates/generated_classes.py.jinja2 @@ -63,10 +63,25 @@ class {{ module_cls.name }}({{ module_cls.bases | join(', ') }}): {% for node_cls in node_classes %} class {{ node_cls.name }}({{ node_cls.bases | join(', ') }}): """{{ node_cls.description or 'Generated node class' }}""" -{%- for attr in node_cls.attributes %} +{%- set modules = node_cls.attributes | selectattr('category', 'equalto', 'module') | list %} +{%- set properties = node_cls.attributes | selectattr('category', 'equalto', 'property') | list %} +{%- if modules %} + + # Module Devices +{%- for attr in modules %} + {{ attr.name }}: {{ attr.type }} +{%- if attr.type_param %}[{{ attr.type_param }}]{% endif %} +{%- if attr.description %} # {{ attr.description }}{% endif %} +{%- endfor %} +{%- endif %} +{%- if properties %} + + # Node Properties +{%- for attr in properties %} {{ attr.name }}: {{ attr.type }} {%- if attr.type_param %}[{{ attr.type_param }}]{% endif %} {%- if attr.description %} # {{ attr.description }}{% endif %} {%- endfor %} +{%- endif %} {% endfor %} diff --git a/tests/test_gencode_refactor.py b/tests/test_gencode_refactor.py index ed188e1..d3e03d8 100644 --- a/tests/test_gencode_refactor.py +++ b/tests/test_gencode_refactor.py @@ -58,8 +58,8 @@ def sample_method(self, value: int) -> str: node_cls="TestNode", bases=["Device"], attrs=[ - ("module1", "TestModule"), - ("status", "SignalR", "str"), + ("module1", "TestModule", None, None, "module"), + ("status", "SignalR", "str", None, "property"), ], description="Test node class", ) @@ -185,9 +185,9 @@ def type2_command(self, mode: str) -> str: node_cls="NodeA", bases=["Device"], attrs=[ - ("modA", "Type1"), - ("modB", "Type2"), - ("status", "SignalR", "str"), + ("modA", "Type1", None, None, "module"), + ("modB", "Type2", None, None, "module"), + ("status", "SignalR", "str", None, "property"), ], description="NodeA with Type1 and Type2 modules", ) @@ -258,9 +258,9 @@ def type3_command(self, count: int) -> int: node_cls="NodeB", bases=["Device"], attrs=[ - ("modA", "Type1"), - ("modB", "Type3"), - ("name", "SignalR", "str"), + ("modA", "Type1", None, None, "module"), + ("modB", "Type3", None, None, "module"), + ("name", "SignalR", "str", None, "property"), ], description="NodeB with Type1 and Type3 modules", ) @@ -300,21 +300,16 @@ def type3_command(self, count: int) -> int: assert "# Module Properties" in code2 assert "# Module Parameters" in code2 - print("\n✓ Subsequent node generation test passed!") - print("✓ Type1 module defined only ONCE (not duplicated)") - print("✓ NodeA present with Type1 and Type2") - print("✓ NodeB present with Type1 and Type3") - print("✓ All classes in the same file after appending") - print("✓ Properties and Parameters properly separated") - -def test_real_node(clean_generated_file, cryo_sim, cryo_node_no_re: SECoPNodeDevice): +def test_gen_cryo_node( + clean_generated_file, cryo_sim, cryo_node_no_re: SECoPNodeDevice +): """Test generating code for a real SECoP node.""" cryo_node_no_re.class_from_instance(clean_generated_file) -async def test_cmd_real_node( +async def test_gen_real_node( clean_generated_file, nested_struct_sim, RE, @@ -323,14 +318,34 @@ async def test_cmd_real_node( nested_node_no_re.class_from_instance(clean_generated_file) + # Read the generated file and verify its contents + gen_file = clean_generated_file / "genNodeClass.py" + assert gen_file.exists(), "Generated file should exist" + + generated_code = gen_file.read_text() -async def test_gas_dosing(clean_generated_file): + # ===== Assertions for generated command plans ===== + # The ophy_struct module has a test_cmd command + assert "def test_cmd" in generated_code, "test_cmd plan should be generated" + assert ( + "@abstractmethod" in generated_code + ), "Command methods should be marked as abstract" - from ophyd_async.core import init_devices + # ===== Assertions for generated enum classes ===== + # Enum classes should be generated for enum parameters + # The gas_type parameter in enum1/enum2 modules should generate enum classes + assert ( + "class Test_EnumGas_typeEnum(SupersetEnum):" in generated_code + ), "Enum class for gas_type should be generated" - async with init_devices(): - gas_dosing = SECoPNodeDevice( - sec_node_uri="localhost:10801", - ) + # Verify enum members are present + # gas_type enums should have AR, N2, H2 (and CO2 for enum2) + assert "AR" in generated_code, "AR enum member should be present" + assert "N2" in generated_code, "N2 enum member should be present" + assert "H2" in generated_code, "H2 enum member should be present" + assert "CO2" in generated_code, "CO2 enum member should be present" - gas_dosing.class_from_instance(clean_generated_file) + # Verify SupersetEnum import + assert ( + "from enum import Enum" in generated_code or "SupersetEnum" in generated_code + ), "Enum import should be present" From 9b11f6a2c07735c224c3b2f5ac23a9c1f7698203 Mon Sep 17 00:00:00 2001 From: Peter Braun Date: Wed, 28 Jan 2026 15:32:00 +0100 Subject: [PATCH 05/18] fixed consecutive test execution --- src/secop_ophyd/GenNodeCode.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/secop_ophyd/GenNodeCode.py b/src/secop_ophyd/GenNodeCode.py index f557e71..ee260a3 100644 --- a/src/secop_ophyd/GenNodeCode.py +++ b/src/secop_ophyd/GenNodeCode.py @@ -2,7 +2,9 @@ import inspect import json +import linecache import re +import sys from dataclasses import dataclass, field from importlib import import_module, reload from inspect import Signature @@ -169,14 +171,13 @@ def __init__(self, path: str | None = None, log=None): def _load_existing_module(self): """Load existing generated module if present.""" + mod_path = self.ModName if self.module_folder_path is not None: # For absolute paths, we need to add to sys.path and import just the module # name if self.module_folder_path.is_absolute(): - import sys - str_path = str(self.module_folder_path) if str_path not in sys.path: sys.path.insert(0, str_path) @@ -188,6 +189,16 @@ def _load_existing_module(self): rep_slash = str_path.replace("/", ".").replace("\\", ".") mod_path = f"{rep_slash}.{self.ModName}" + # Remove cached module to ensure fresh import (important when module file + # has been modified or recreated between imports) + if mod_path in sys.modules: + del sys.modules[mod_path] + + # Clear linecache for the module file to ensure inspect.getsource() works + if self.module_folder_path is not None: + module_file = self.module_folder_path / f"{self.ModName}.py" + linecache.checkcache(str(module_file)) + try: self.node_mod = import_module(mod_path) self._parse_existing_module() From d174eeac62c3a26f7dd903576851c9a73836416e Mon Sep 17 00:00:00 2001 From: Peter Braun Date: Tue, 3 Feb 2026 15:47:08 +0100 Subject: [PATCH 06/18] signal hints needed --- src/secop_ophyd/SECoPDevices.py | 724 ++++++++++++-------------------- src/secop_ophyd/SECoPSignal.py | 109 +++-- 2 files changed, 333 insertions(+), 500 deletions(-) diff --git a/src/secop_ophyd/SECoPDevices.py b/src/secop_ophyd/SECoPDevices.py index 3839856..ce7c289 100644 --- a/src/secop_ophyd/SECoPDevices.py +++ b/src/secop_ophyd/SECoPDevices.py @@ -32,7 +32,11 @@ from ophyd_async.core import ( DEFAULT_TIMEOUT, AsyncStatus, + Device, + DeviceConnector, + DeviceFiller, LazyMock, + Signal, SignalR, SignalRW, SignalX, @@ -105,6 +109,118 @@ def secop_enum_name_to_python(member_name: str) -> str: return cleaned +class SECoPDeviceConnector(DeviceConnector): + def __init__( + self, + sri: str | None, + auto_fill_signals: bool = True, + ) -> None: + + self.sri: str | None = sri + self.node_id = sri.split(":")[0] + ":" + sri.split(":")[1] + self._auto_fill_signals = auto_fill_signals + + if sri.count(":") == 2: + self.module = sri.split(":")[2] + elif sri.count(":") == 1: + self.module = None + else: + raise RuntimeError(f"Invalid SECoP resource identifier: {sri}") + + if SECoPDevice.clients.get(self.node_id) is None: + raise RuntimeError(f"No AsyncFrappyClient for URI {sri} exists") + + self.client: AsyncFrappyClient = SECoPDevice.clients[self.node_id] + + def set_module(self, module_name: str): + if self.sri.count(":") != 1: + raise RuntimeError( + "Module can only be set if SRI does not already contain module" + ) + self.module = module_name + self.sri = self.sri + ":" + module_name + + def create_children_from_annotations(self, device: Device): + if not hasattr(self, "filler"): + self.filler = DeviceFiller( + device=device, + signal_backend_factory=SECoPParamBackend, + device_connector_factory=lambda: SECoPDeviceConnector( + self.sri, self._auto_fill_signals + ), + ) + list(self.filler.create_devices_from_annotations(filled=False)) + list(self.filler.create_signals_from_annotations(filled=False)) + + self.filler.check_created() + + async def connect_mock(self, device: Device, mock: LazyMock): + # Make 2 entries for each DeviceVector + self.filler.create_device_vector_entries_to_mock(2) + # Set the name of the device to name all children + device.set_name(device.name) + return await super().connect_mock(device, mock) + + async def connect_real(self, device: Device, timeout: float, force_reconnect: bool): + if not self.sri: + raise RuntimeError(f"Could not connect to SEC node: {self.sri}") + + # Establish connection to SEC Node + await self.client.connect(3) + + # Module Device: fill Parameters (commands are done via annotated plans) + if self.module: + parameters = self.client.modules[self.module]["parameters"] + # remove ignored signals + children = [ + child + for child in parameters.keys() + if child not in self.filler.ignored_signals + ] + + # Dertermine children that are declared but not yet filled + not_filled = {unfilled for unfilled, _ in device.children()} + + for param_name in children: + if self._auto_fill_signals or param_name in not_filled: + signal_type = ( + SignalR if parameters[param_name]["readonly"] else SignalRW + ) + + backend = self.filler.fill_child_signal(param_name, signal_type) + + from secop_ophyd.GenNodeCode import get_type_param + + datatype = get_type_param(parameters[param_name]["datatype"]) + backend.init_from_introspection( + datatype=datatype, + path=self.module + ":" + param_name, + secclient=self.client, + ) + + # Node Device: fill child devices (modules) + else: + modules = self.client.modules + + not_filled = {unfilled for unfilled, _ in device.children()} + + for module_name in modules.keys(): + if self._auto_fill_signals or module_name in not_filled: + module_properties = modules[module_name]["properties"] + device_sub_class = class_from_interface(module_properties) + + self.filler.fill_child_device(module_name, device_sub_class) + + mod_dev: SECoPDevice = getattr(device, module_name) + mod_dev.set_module(module_name) + + self.filler.check_filled(f"{self.node_id}") + + # Set the name of the device to name all children + device.set_name(device.name) + return await super().connect_real(device, timeout, force_reconnect) + + class SECoPCMDDevice(StandardReadable, Flyable, Triggerable): """ Command devices that have Signals for command args, return values and a signal @@ -235,186 +351,126 @@ def collect(self) -> Iterator[PartialEvent]: ) -class SECoPBaseDevice(StandardReadable): - """Base Class for generating Opyd devices from SEC Node modules, - objects of type SECoPBaseDevice are not supposed to be instanciated +class SECoPDevice(StandardReadable): - """ + clients: Dict[str, AsyncFrappyClient] = {} def __init__( self, - secclient: AsyncFrappyClient, - module_name: str, - loglevel=logging.INFO, - logdir: str | None = None, + sri: str = "", # SECoP resource identifier host:port:optional[module] + name: str = "", + connector: SECoPDeviceConnector | None = None, ) -> None: - """Initiate A SECoPBaseDevice - :param secclient: SECoP client providing communication to the SEC Node - :type secclient: AsyncFrappyClient - """ - # config_params is default - self._hinted_params: list[str] = ["value", "target"] - self._uncached_params: list[str] = [] - self._hinted_uncached_params: list[str] = [] - - self._secclient: AsyncFrappyClient = secclient + if connector and sri: + raise RuntimeError("Provide either sri or connector, not both") - self.impl: str | None = None + if connector: + sri = connector.sri - self._module = module_name - module_desc = secclient.modules[module_name] + self.sri = sri + self.host: str = sri.split(":")[0] + self.port: str = sri.split(":")[1] self.mod_prop_devices: Dict[str, SignalR] = {} self.param_devices: Dict[str, Any] = {} - name = self._secclient.properties[EQUIPMENT_ID].replace(".", "-") - - for parameter, properties in module_desc["parameters"].items(): - match properties.get("_signal_format", None): - case "HINTED_SIGNAL": - if parameter not in self._hinted_params: - self._hinted_params.append(parameter) - - case "HINTED_UNCACHED_SIGNAL": - if parameter not in self._hinted_uncached_params: - self._hinted_uncached_params.append(parameter) - case "UNCACHED_SIGNAL": - if parameter not in self._uncached_params: - self._uncached_params.append(parameter) - case _: - continue + self.module: str | None = None + if len(sri.split(":")) > 2: + self.module = sri.split(":")[2] - self.logger: Logger = setup_logging( - name=f"secop-ophyd:{name}:{module_name}", level=loglevel, log_dir=logdir - ) + if SECoPDevice.clients.get(sri) is None: + SECoPDevice.clients[sri] = AsyncFrappyClient(host=self.host, port=self.port) - self.logger.info(f"Initializing SECoPBaseDevice for module {module_name}") - # Add configuration Signals - with self.add_children_as_readables( - format=StandardReadableFormat.CONFIG_SIGNAL - ): - # generate Signals from Module Properties - for property in module_desc["properties"]: + connector = connector or SECoPDeviceConnector(sri=sri) - if property == "implementation": - self.impl = module_desc["properties"]["implementation"] + self._client: AsyncFrappyClient = SECoPDevice.clients[sri] - if property in IGNORED_PROPS: - continue + super().__init__(name=name, connector=connector) - propb = PropertyBackend(property, module_desc["properties"], secclient) + for child_name, child_dev in self.children(): + if isinstance(child_dev, Signal): + child_dev.set_name(self.name + "_" + child_name) - setattr(self, property, SignalR(backend=propb)) - self.mod_prop_devices[property] = getattr(self, property) + def set_module(self, module_name: str): + if self.module is not None: + raise RuntimeError("Module can only be set if it was not already set") - # generate Signals from Module parameters eiter r or rw - for parameter, properties in module_desc["parameters"].items(): - if ( - parameter - in self._hinted_params - + self._uncached_params - + self._hinted_uncached_params - ): - continue - # generate new root path - param_path = Path(parameter_name=parameter, module_name=module_name) - - # readonly propertyns to plans and plan stubs. - readonly: bool = properties.get("readonly", None) + self.module = module_name + self.sri = self.sri + ":" + module_name - # Normal types + (struct and tuple as JSON object Strings) - self._signal_from_parameter( - path=param_path, - sig_name=parameter, - readonly=readonly, - ) - self.param_devices[parameter] = getattr(self, parameter) + self._connector.set_module(module_name) - self.add_signals_by_format( - format=StandardReadableFormat.HINTED_SIGNAL, - format_params=self._hinted_params, - module_name=module_name, - module_desc=module_desc, - ) + async def connect( + self, + mock: bool | LazyMock = False, + timeout: float = DEFAULT_TIMEOUT, + force_reconnect: bool = False, + ): + if not self._client.online or force_reconnect: + # Establish connection to SEC Node + await self._client.connect(3) - self.add_signals_by_format( - format=StandardReadableFormat.UNCACHED_SIGNAL, - format_params=self._uncached_params, - module_name=module_name, - module_desc=module_desc, - ) + if self.module: + module_desc = self._client.modules[self.module] - self.add_signals_by_format( - format=StandardReadableFormat.HINTED_UNCACHED_SIGNAL, - format_params=self._hinted_uncached_params, - module_name=module_name, - module_desc=module_desc, - ) + # Add Signals for Module Properties + with self.add_children_as_readables( + format=StandardReadableFormat.CONFIG_SIGNAL + ): + # generate Signals from Module Properties + for property in module_desc["properties"]: - # Initialize Command Devices - for command, properties in module_desc["commands"].items(): - # generate new root path - cmd_path = Path(parameter_name=command, module_name=module_name) - cmd_dev_name = command + "_CMD" - setattr( - self, - cmd_dev_name, - SECoPCMDDevice(path=cmd_path, secclient=secclient), - ) + if property == "implementation": + self.impl = module_desc["properties"]["implementation"] - cmd_dev: SECoPCMDDevice = getattr(self, cmd_dev_name) - # Add Bluesky Plan Methods + if property in IGNORED_PROPS: + continue - # Stop is already an ophyd native operation - if command == "stop": - continue + propb = PropertyBackend( + property, module_desc["properties"], self._client + ) - cmd_plan = self.generate_cmd_plan( - cmd_dev, cmd_dev.arg_dtype, cmd_dev.res_dtype - ) + setattr(self, property, SignalR(backend=propb)) + self.mod_prop_devices[property] = getattr(self, property) - setattr(self, command, MethodType(cmd_plan, self)) + # Initialize Command Devices + for command, properties in module_desc["commands"].items(): + # generate new root path + cmd_path = Path(parameter_name=command, module_name=self.module) + cmd_dev_name = command + "_CMD" + setattr( + self, + cmd_dev_name, + SECoPCMDDevice(path=cmd_path, secclient=self._client), + ) - self.set_name(module_name) + cmd_dev: SECoPCMDDevice = getattr(self, cmd_dev_name) + # Add Bluesky Plan Methods - # Add status Signal AFTER set_name() to avoid auto-registration as config/hinted - # This is only needed as long as Tiled can't handle structured numpy arrays - if "status" in module_desc["parameters"].keys(): - properties = module_desc["parameters"]["status"] - param_path = Path(parameter_name="status", module_name=module_name) - readonly = properties.get("readonly", None) + # Stop is already an ophyd native operation + if command == "stop": + continue - # Create signal without adding to readables - self._signal_from_parameter( - path=param_path, - sig_name="status", - readonly=readonly, - ) - self.param_devices["status"] = getattr(self, "status") + cmd_plan = self.generate_cmd_plan( + cmd_dev, cmd_dev.arg_dtype, cmd_dev.res_dtype + ) - def add_signals_by_format( - self, format, format_params: list, module_name, module_desc: dict - ): - # Add hinted readable Signals - with self.add_children_as_readables(format=format): - for parameter in format_params: - if parameter not in module_desc["parameters"].keys(): - continue - properties = module_desc["parameters"][parameter] + setattr(self, command, MethodType(cmd_plan, self)) - # generate new root path - param_path = Path(parameter_name=parameter, module_name=module_name) + self.set_name(self.module) - # readonly propertyns to plans and plan stubs. - readonly = properties.get("readonly", None) + else: + # Signals for module properties + with self.add_children_as_readables( + format=StandardReadableFormat.CONFIG_SIGNAL + ): + for property in self._client.properties: + propb = PropertyBackend( + property, self._client.properties, self._client + ) + setattr(self, property, SignalR(backend=propb)) - # Normal types + (struct and tuple as JSON object Strings) - self._signal_from_parameter( - path=param_path, - sig_name=parameter, - readonly=readonly, - ) - self.param_devices[parameter] = getattr(self, parameter) + await super().connect(mock, timeout, force_reconnect) def generate_cmd_plan( self, @@ -491,52 +547,43 @@ def wait_for_idle_factory(): return cmd_meth - def _signal_from_parameter(self, path: Path, sig_name: str, readonly: bool): - """Generates an Ophyd Signal from a Module Parameter - :param path: Path to the Parameter in the secclient module dict - :type path: Path - :param sig_name: Name of the new Signal - :type sig_name: str - :param readonly: Signal is R or RW - :type readonly: bool - """ - # Normal types + (struct and tuple as JSON object Strings) - paramb = SECoPParamBackend(path=path, secclient=self._secclient) +class SECoPNodeDevice(SECoPDevice): + def __init__( + self, + sec_node_uri: str = "", # SECoP resource identifier host:port:optional[module] + name: str = "", + ): + # ensure sec_node_uri only contains host:port + if sec_node_uri.count(":") != 1: + raise RuntimeError( + f"SECoPNodeDevice SRI must only contain host:port {sec_node_uri}" + ) - # construct signal - if readonly: - setattr(self, sig_name, SignalR(paramb)) - else: - setattr(self, sig_name, SignalRW(paramb)) + super().__init__(sri=sec_node_uri, name=name) + async def connect(self, mock=False, timeout=DEFAULT_TIMEOUT, force_reconnect=False): + await super().connect(mock, timeout, force_reconnect) -class SECoPCommunicatorDevice(SECoPBaseDevice): + moddevs = [] + for _, moddev in self.children(): + if isinstance(moddev, SECoPDevice): + moddevs.append(moddev) - def __init__( - self, - secclient: AsyncFrappyClient, - module_name: str, - loglevel=logging.INFO, - logdir: str | None = None, - ): - """Initializes the SECoPCommunicatorDevice + self.add_readables(moddevs, StandardReadableFormat.CHILD) - :param secclient: SECoP client providing communication to the SEC Node - :type secclient: AsyncFrappyClient - :param module_name: Name of the SEC Node module that is represented by - this device - :type module_name: str""" - super().__init__( - secclient=secclient, - module_name=module_name, - loglevel=loglevel, - logdir=logdir, - ) +class SECoPCommunicatorDevice(SECoPDevice): + def __init__( + self, + sri: str = "", # SECoP resource identifier host:port:optional[module] + name: str = "", + connector: SECoPDeviceConnector | None = None, + ) -> None: + super().__init__(sri=sri, name=name, connector=connector) -class SECoPReadableDevice(SECoPCommunicatorDevice, Triggerable, Subscribable): +class SECoPReadableDevice(SECoPDevice, Triggerable, Subscribable): """ Standard readable SECoP device, corresponding to a SECoP module with the interface class "Readable" @@ -544,10 +591,9 @@ class SECoPReadableDevice(SECoPCommunicatorDevice, Triggerable, Subscribable): def __init__( self, - secclient: AsyncFrappyClient, - module_name: str, - loglevel=logging.INFO, - logdir: str | None = None, + sri: str = "", # SECoP resource identifier host:port:optional[module] + name: str = "", + connector: SECoPDeviceConnector | None = None, ): """Initializes the SECoPReadableDevice @@ -561,12 +607,10 @@ def __init__( self.value: SignalR self.status: SignalR - super().__init__( - secclient=secclient, - module_name=module_name, - loglevel=loglevel, - logdir=logdir, - ) + super().__init__(sri=sri, name=name, connector=connector) + + async def connect(self, mock=False, timeout=DEFAULT_TIMEOUT, force_reconnect=False): + await super().connect(mock, timeout, force_reconnect) if not hasattr(self, "value"): raise AttributeError( @@ -585,10 +629,10 @@ async def wait_for_idle(self): for running commands that are not done immediately """ - self.logger.info(f"Waiting for {self.name} to be IDLE") + # self.logger.info(f"Waiting for {self.name} to be IDLE") if self.status is None: - self.logger.error("Status Signal not initialized") + # self.logger.error("Status Signal not initialized") raise Exception("status Signal not initialized") # force reading of fresh status from device @@ -602,18 +646,18 @@ async def wait_for_idle(self): # Module is in IDLE/WARN state if IDLE <= stat_code < BUSY: - self.logger.info(f"Module {self.name} --> IDLE") + # self.logger.info(f"Module {self.name} --> IDLE") break if hasattr(self, "_stopped"): - self.logger.info(f"Module {self.name} was stopped STOPPED") + # self.logger.info(f"Module {self.name} was stopped STOPPED") if self._stopped is True: break # Error State or DISABLED if hasattr(self, "_success"): if stat_code >= ERROR or stat_code < IDLE: - self.logger.error(f"Module {self.name} --> ERROR/DISABLED") + # self.logger.error(f"Module {self.name} --> ERROR/DISABLED") self._success = False break @@ -635,12 +679,10 @@ def switch_from_status_factory(): yield from bps.wait_for([switch_from_status_factory]) def trigger(self) -> AsyncStatus: - self.logger.info(f"Triggering {self.name}: read fresh data from device") + # self.logger.info(f"Triggering {self.name}: read fresh data from device") # get fresh reading of the value Parameter from the SEC Node return AsyncStatus( - awaitable=self._secclient.get_parameter( - self._module, "value", trycache=False - ) + awaitable=self._client.get_parameter(self.module, "value", trycache=False) ) def subscribe(self, function: Callback[dict[str, Reading]]) -> None: @@ -660,10 +702,9 @@ class SECoPTriggerableDevice(SECoPReadableDevice, Stoppable): def __init__( self, - secclient: AsyncFrappyClient, - module_name: str, - loglevel=logging.info, - logdir: str | None = None, + sri: str = "", # SECoP resource identifier host:port:optional[module] + name: str = "", + connector: SECoPDeviceConnector | None = None, ): """Initialize SECoPTriggerableDevice @@ -679,57 +720,10 @@ def __init__( self._success = True self._stopped = False - super().__init__(secclient, module_name, loglevel=loglevel, logdir=logdir) - - async def __go_coro(self, wait_for_idle: bool): - await self._secclient.exec_command(module=self._module, command="go") - - self._success = True - self._stopped = False - await asyncio.sleep(0.2) - - if wait_for_idle: - await self.wait_for_idle() - - def wait_for_prepared(self): - yield from self.observe_status_change(IDLE) - yield from self.observe_status_change(PREPARING) - - def trigger(self) -> AsyncStatus: - - self.logger.info(f"Triggering {self.name} go command") - - async def go_or_read_on_busy(): - module_status = await self.status.get_value(False) - stat_code = module_status["f0"] - - if BUSY <= stat_code <= ERROR: - return - - await self.__go_coro(True) - - return AsyncStatus(awaitable=go_or_read_on_busy()) - - async def stop(self, success=True): - """Calls stop command on the SEC Node module - - :param success: - True: device is stopped as planned - False: something has gone wrong - (defaults to True) - :type success: bool, optional - """ - self._success = success - - self.logger.info(f"Stopping {self.name} success={success}") - - await self._secclient.exec_command(self._module, "stop") - self._stopped = True + super().__init__(sri=sri, name=name, connector=connector) class SECoPWritableDevice(SECoPReadableDevice): - """Fast settable device target""" - pass @@ -741,10 +735,9 @@ class SECoPMoveableDevice(SECoPWritableDevice, Locatable, Stoppable): def __init__( self, - secclient: AsyncFrappyClient, - module_name: str, - loglevel=logging.INFO, - logdir: str | None = None, + sri: str = "", # SECoP resource identifier host:port:optional[module] + name: str = "", + connector: SECoPDeviceConnector | None = None, ): """Initialize SECoPMovableDevice @@ -757,7 +750,14 @@ def __init__( self.target: SignalRW - super().__init__(secclient, module_name, loglevel=loglevel, logdir=logdir) + super().__init__(sri=sri, name=name, connector=connector) + + self._success = True + self._stopped = False + + async def connect(self, mock=False, timeout=DEFAULT_TIMEOUT, force_reconnect=False): + + await super().connect(mock, timeout, force_reconnect) if not hasattr(self, "target"): raise AttributeError( @@ -765,9 +765,6 @@ def __init__( + "but is needed for 'Drivable' interface class!" ) - self._success = True - self._stopped = False - def set(self, new_target, timeout: Optional[float] = None) -> AsyncStatus: """Sends new target to SEC Nonde and waits until module is IDLE again @@ -786,7 +783,7 @@ async def _move(self, new_target): self._stopped = False await self.target.set(new_target, wait=False) - self.logger.info(f"Moving {self.name} to {new_target}") + # self.logger.info(f"Moving {self.name} to {new_target}") # force reading of status from device await self.status.read(False) @@ -800,13 +797,13 @@ async def _move(self, new_target): # Error State or DISABLED if stat_code >= ERROR or stat_code < IDLE: - self.logger.error(f"Module {self.name} --> ERROR/DISABLED") + # self.logger.error(f"Module {self.name} --> ERROR/DISABLED") self._success = False break # Module is in IDLE/WARN state if IDLE <= stat_code < BUSY: - self.logger.info(f"Reached Target Module {self.name} --> IDLE") + # self.logger.info(f"Reached Target Module {self.name} --> IDLE") break # TODO other status transitions @@ -826,15 +823,15 @@ async def stop(self, success=True): self._success = success if not success: - self.logger.info(f"Stopping {self.name} success={success}") - await self._secclient.exec_command(self._module, "stop") + # self.logger.info(f"Stopping {self.name} success={success}") + await self._client.exec_command(self.module, "stop") self._stopped = True async def locate(self) -> Location: # return current location of the device (setpoint and readback). # Only locally cached values are returned - setpoint = await self._secclient.get_parameter(self._module, "target", True) - readback = await self._secclient.get_parameter(self._module, "value", True) + setpoint = await self._client.get_parameter(self.module, "target", True) + readback = await self._client.get_parameter(self.module, "value", True) location: Location = { "setpoint": setpoint.value, @@ -843,178 +840,6 @@ async def locate(self) -> Location: return location -class SECoPNodeDevice(StandardReadable): - """ - Generates the root ophyd device from a Sec-node. Signals of this Device correspond - to the Sec-node properties - """ - - name: str = "" - - def __init__( - self, - sec_node_uri: str, - # `prefix` not used, it's just that the device connecter requires it for - # some reason. - prefix: str = "", - name: str = "", - loglevel: str = "INFO", - logdir: str | None = None, - ): - """Initializes the node device and generates all node signals and subdevices - corresponding to the SECoP-modules of the secnode - - :param secclient: SECoP client providing communication to the SEC Node - :type secclient: AsyncFrappyClient - """ - - self.host, self.port = sec_node_uri.rsplit(":", maxsplit=1) - - self.logger: Logger = setup_logging( - name=f"frappy:{self.host}:{self.port}", - level=LOG_LEVELS[loglevel], - log_dir=logdir, - ) - self.logdir = logdir - - self.name = name - self.prefix = prefix - - self._secclient: AsyncFrappyClient = AsyncFrappyClient( - host=self.host, port=self.port, log=self.logger - ) - - async def connect( - self, - mock: bool | LazyMock = False, - timeout: float = DEFAULT_TIMEOUT, - force_reconnect: bool = False, - ): - if self._secclient.online is False and not force_reconnect: - - await self._secclient.connect(3) - - self.equipment_id: SignalR - self.description: SignalR - self.version: SignalR - - self._module_name: str = "" - self._node_cls_name: str = "" - self.mod_devices: Dict[str, SECoPReadableDevice] = {} - self.node_prop_devices: Dict[str, SignalR] = {} - - if self.name == "": - self.name = self._secclient.properties[EQUIPMENT_ID].replace(".", "-") - - self.name = self.prefix + self.name - - config = [] - - self.logger.info( - "Initializing SECoPNodeDevice " - + f"({self._secclient.host}:{self._secclient.port})" - ) - - with self.add_children_as_readables( - format=StandardReadableFormat.CONFIG_SIGNAL - ): - for property in self._secclient.properties: - propb = PropertyBackend( - property, self._secclient.properties, self._secclient - ) - setattr(self, property, SignalR(backend=propb)) - config.append(getattr(self, property)) - self.node_prop_devices[property] = getattr(self, property) - - with self.add_children_as_readables(format=StandardReadableFormat.CHILD): - for module, module_desc in self._secclient.modules.items(): - - secop_dev_class = class_from_interface(module_desc["properties"]) - - if secop_dev_class is not None: - setattr( - self, - module, - secop_dev_class( - self._secclient, - module, - loglevel=self.logger.level, - logdir=self.logdir, - ), - ) - self.mod_devices[module] = getattr(self, module) - - # register secclient callbacks (these are useful if sec node description - # changes after a reconnect) - self._secclient.client.register_callback( - None, self.descriptiveDataChange, self.nodeStateChange - ) - - super().__init__(name=self.name) - - def class_from_instance(self, path_to_module: str | None = None): - from secop_ophyd.GenNodeCode import GenNodeCode - - description = self._secclient.client.request("describe")[2] - - # parse genClass file if already present - genCode = GenNodeCode(path=path_to_module, log=self._secclient.log) - - genCode.from_json_describe(description) - - genCode.write_gen_node_class_file() - - def descriptiveDataChange(self, module, description): # noqa: N802 - """called when the description has changed - - this callback is called on the node with module=None - and on every changed module with module== - - :param module: module name of the module that has changes - :type module: _type_ - :param description: new Node description string - :type description: _type_ - """ - # TODO this functionality is untested and will probably break the generated - # ophyd device since a changed module description would lead a newly - # instanciated module object while references to the old one are broken - # mitigation: alway call methods via: - # - # 'node_obj.module_obj.method()' - - self._secclient.conn_timestamp = ttime.time() - - if module is None: - # Refresh signals that correspond to Node Properties - config = [] - for property in self._secclient.properties: - propb = PropertyBackend( - property, self._secclient.properties, self._secclient - ) - - setattr(self, property, SignalR(backend=propb)) - config.append(getattr(self, property)) - - self.add_readables(config, format=StandardReadableFormat.CONFIG_SIGNAL) - else: - # Refresh changed modules - module_desc = self._secclient.modules[module] - secop_dev_class = class_from_interface(module_desc["properties"]) - - setattr(self, module, secop_dev_class(self._secclient, module)) - - # TODO what about removing Modules during disconn - - def nodeStateChange(self, online, state): # noqa: N802 - """called when the state of the connection changes - - 'online' is True when connected or reconnecting, False when disconnected - or connecting 'state' is the connection state as a string - """ - if state == "connected" and online is True: - self._secclient.conn_timestamp = ttime.time() - - def class_from_interface(mod_properties: dict): ophyd_class = None @@ -1028,7 +853,7 @@ def class_from_interface(mod_properties: dict): # No predefined IF class was a match --> use base class (loose collection of # accessibles) if ophyd_class is None: - ophyd_class = SECoPBaseDevice # type: ignore + ophyd_class = SECoPDevice # type: ignore return ophyd_class @@ -1040,18 +865,3 @@ def class_from_interface(mod_properties: dict): "Readable": SECoPReadableDevice, "Communicator": SECoPCommunicatorDevice, } - -SECOP_TO_NEXUS_TYPE = { - "double": "NX_FLOAT64", - "int": "NX_INT64", - "scaled": "NX_FLOAT64", -} - - -ALL_IF_CLASSES = set(IF_CLASSES.values()) - -# TODO -# FEATURES = { -# 'HasLimits': SecopHasLimits, -# 'HasOffset': SecopHasOffset, -# } diff --git a/src/secop_ophyd/SECoPSignal.py b/src/secop_ophyd/SECoPSignal.py index de28639..cb5b8af 100644 --- a/src/secop_ophyd/SECoPSignal.py +++ b/src/secop_ophyd/SECoPSignal.py @@ -17,7 +17,12 @@ StructOf, TupleOf, ) -from ophyd_async.core import Callback, SignalBackend, SignalDatatypeT +from ophyd_async.core import ( + Callback, + SignalBackend, + SignalDatatypeT, + StandardReadableFormat, +) from secop_ophyd.AsyncFrappyClient import AsyncFrappyClient from secop_ophyd.util import Path, SECoPDataKey, SECoPdtype, SECoPReading, deep_get @@ -120,7 +125,6 @@ def set_callback(self, callback: Callback[Reading[SignalDatatypeT]] | None) -> N self.callback = callback # type: ignore[assignment] -# TODO add return of Asyncstatus class SECoPXBackend(SignalBackend): """ Signal backend for SignalX of a SECoP_CMD_Device, that handles command execution @@ -216,30 +220,64 @@ async def get_setpoint(self) -> SignalDatatypeT: ) -class SECoPParamBackend(SignalBackend): +class SECoPParamBackend(SignalBackend[SignalDatatypeT]): """Standard backend for a Signal that represents SECoP Parameter""" - def __init__(self, path: Path, secclient: AsyncFrappyClient) -> None: - """_summary_ + def __init__( + self, + datatype: type[SignalDatatypeT] | None, + path: str | None = None, + secclient: AsyncFrappyClient | None = None, + ): - :param path: Path to the parameter in the secclient module dict - :type path: Path - :param secclient: SECoP client providing communication to the SEC Node - :type secclient: AsyncFrappyClient - """ + if path and secclient: + module_name, parameter_name = path.split(":", maxsplit=1) - # secclient + self._module_name: str = module_name + self._parameter_name: str = parameter_name + self._secclient: AsyncFrappyClient = secclient + + self.path_str: str = path + + super().__init__(datatype) + + def init_from_introspection( + self, + datatype: type[SignalDatatypeT], + path: str, + secclient: AsyncFrappyClient, + ): + module_name, parameter_name = path.split(":", maxsplit=1) + + self._module_name: str = module_name + self._parameter_name: str = parameter_name self._secclient: AsyncFrappyClient = secclient - # module:acessible Path for reading/writing (module,accessible) - self.path: Path = path + self.path_str: str = path + + self.datatype = datatype + + def source(self, name: str, read: bool) -> str: + return self.path_str + + async def connect(self, timeout: float): + + await self._secclient.connect() self._param_description: dict = self._get_param_desc() + match self._param_description.get("_signal_format", None): + case "HINTED_SIGNAL": + self.format = StandardReadableFormat.HINTED_SIGNAL + case "HINTED_UNCACHED_SIGNAL": + self.format = StandardReadableFormat.HINTED_UNCACHED_SIGNAL + case "UNCACHED_SIGNAL": + self.format = StandardReadableFormat.UNCACHED_SIGNAL + case _: + self.format = StandardReadableFormat.CONFIG_SIGNAL + # Root datainfo or memberinfo for nested datatypes - self.datainfo: dict = deep_get( - self._param_description["datainfo"], self.path.get_memberinfo_path() - ) + self.datainfo: dict = self._param_description["datainfo"] self.readonly = self._param_description.get("readonly") @@ -250,7 +288,7 @@ def __init__(self, path: Path, secclient: AsyncFrappyClient) -> None: if self.SECoP_type_info.max_depth > MAX_DEPTH: warnings.warn( - f"The datatype of parameter '{path._accessible_name}' has a maximum " + f"The datatype of parameter '{self._parameter_name}' has a maximum " f"depth of {self.SECoP_type_info.max_depth}. Tiled & Databroker only " f"support a Depth upto {MAX_DEPTH} " f"dtype_descr: {self.SECoP_type_info.dtype_descr}" @@ -259,13 +297,13 @@ def __init__(self, path: Path, secclient: AsyncFrappyClient) -> None: self.describe_dict: dict = {} self.source_name = ( - secclient.uri + self._secclient.uri + ":" - + secclient.nodename + + self._secclient.nodename + ":" - + self.path._module_name + + self._module_name + ":" - + self.path._accessible_name + + self._parameter_name ) # SECoP metadata is static and can only change when connection is reset @@ -289,13 +327,7 @@ def __init__(self, path: Path, secclient: AsyncFrappyClient) -> None: property_name = "units" self.describe_dict[property_name] = prop_val - super().__init__(datatype=self.SECoP_type_info.np_datatype) - - def source(self, name: str, read: bool) -> str: - return self.source_name - - async def connect(self, timeout: float): - pass + self.datatype = self.SECoP_type_info.np_datatype async def put(self, value: Any | None, wait=True): # convert to frappy compatible Format @@ -367,25 +399,16 @@ def updateItem(module, parameter, entry: CacheItem): # noqa: N802 self._secclient.unregister_callback(self.get_path_tuple(), updateItem) def _get_param_desc(self) -> dict: - return deep_get(self._secclient.modules, self.path.get_param_desc_path()) + return deep_get( + self._secclient.modules, + [self._module_name, "parameters", self._parameter_name], + ) def get_param_path(self): - return self.path.get_param_path() + return {"module": self._module_name, "parameter": self._parameter_name} def get_path_tuple(self): - return self.path.get_path_tuple() - - def get_unit(self): - return self.describe_dict.get("units", None) - - def is_number(self) -> bool: - if ( - self.describe_dict["dtype"] == "number" - or self.describe_dict["dtype"] == "integer" - ): - return True - - return False + return (self._module_name, self._parameter_name) class PropertyBackend(SignalBackend): From 11c050cbe4f9f72be6626cb6afb6b909768e0905 Mon Sep 17 00:00:00 2001 From: Peter Braun Date: Wed, 4 Feb 2026 22:50:55 +0100 Subject: [PATCH 07/18] first prototype for migration to using deviceconnector and devicefiller --- src/secop_ophyd/GenNodeCode.py | 4 +- src/secop_ophyd/SECoPDevices.py | 286 ++++++++++++++++++++++++++++---- src/secop_ophyd/SECoPSignal.py | 22 ++- src/secop_ophyd/logs.py | 10 -- tests/test_Node.py | 2 + tests/test_nested.py | 17 +- 6 files changed, 277 insertions(+), 64 deletions(-) diff --git a/src/secop_ophyd/GenNodeCode.py b/src/secop_ophyd/GenNodeCode.py index ee260a3..a00d920 100644 --- a/src/secop_ophyd/GenNodeCode.py +++ b/src/secop_ophyd/GenNodeCode.py @@ -215,8 +215,8 @@ def _parse_existing_module(self): try: from secop_ophyd.SECoPDevices import ( - SECoPBaseDevice, SECoPCommunicatorDevice, + SECoPDevice, SECoPMoveableDevice, SECoPNodeDevice, SECoPReadableDevice, @@ -249,7 +249,7 @@ def _parse_existing_module(self): elif issubclass( class_obj, ( - SECoPBaseDevice, + SECoPDevice, SECoPCommunicatorDevice, SECoPMoveableDevice, SECoPReadableDevice, diff --git a/src/secop_ophyd/SECoPDevices.py b/src/secop_ophyd/SECoPDevices.py index ce7c289..b2ef0d4 100644 --- a/src/secop_ophyd/SECoPDevices.py +++ b/src/secop_ophyd/SECoPDevices.py @@ -2,6 +2,8 @@ import logging import re import time as ttime +import warnings +from abc import abstractmethod from logging import Logger from types import MethodType from typing import Any, Dict, Iterator, Optional, Type @@ -47,7 +49,7 @@ from ophyd_async.core._utils import Callback from secop_ophyd.AsyncFrappyClient import AsyncFrappyClient -from secop_ophyd.logs import LOG_LEVELS, setup_logging +from secop_ophyd.logs import setup_logging from secop_ophyd.propertykeys import DATAINFO, EQUIPMENT_ID, INTERFACE_CLASSES from secop_ophyd.SECoPSignal import ( LocalBackend, @@ -109,16 +111,51 @@ def secop_enum_name_to_python(member_name: str) -> str: return cleaned +def format_assigned(device: StandardReadable, signal: SignalR) -> bool: + if ( + signal.describe in device._describe_funcs + or signal.describe in device._describe_config_funcs + ): + # Standard readable format already assigned + return True + + return False + + +def is_read_signal(device: StandardReadable, signal: SignalR | SignalRW) -> bool: + if signal.describe() in device._describe_funcs: + return True + + return False + + +def is_config_signal(device: StandardReadable, signal: SignalR | SignalRW) -> bool: + if signal.describe() in device._describe_config_funcs: + return True + + return False + + class SECoPDeviceConnector(DeviceConnector): + + sri: str + module: str | None + node_id: str + _auto_fill_signals: bool + def __init__( self, - sri: str | None, + sri: str, auto_fill_signals: bool = True, + loglevel=logging.INFO, + logdir: str | None = None, ) -> None: - self.sri: str | None = sri + self.sri = sri self.node_id = sri.split(":")[0] + ":" + sri.split(":")[1] self._auto_fill_signals = auto_fill_signals + self.loglevel = loglevel + self.logdir = logdir if sri.count(":") == 2: self.module = sri.split(":")[2] @@ -146,7 +183,7 @@ def create_children_from_annotations(self, device: Device): device=device, signal_backend_factory=SECoPParamBackend, device_connector_factory=lambda: SECoPDeviceConnector( - self.sri, self._auto_fill_signals + self.sri, self._auto_fill_signals, self.loglevel, self.logdir ), ) list(self.filler.create_devices_from_annotations(filled=False)) @@ -159,7 +196,7 @@ async def connect_mock(self, device: Device, mock: LazyMock): self.filler.create_device_vector_entries_to_mock(2) # Set the name of the device to name all children device.set_name(device.name) - return await super().connect_mock(device, mock) + await super().connect_mock(device, mock) async def connect_real(self, device: Device, timeout: float, force_reconnect: bool): if not self.sri: @@ -218,7 +255,36 @@ async def connect_real(self, device: Device, timeout: float, force_reconnect: bo # Set the name of the device to name all children device.set_name(device.name) - return await super().connect_real(device, timeout, force_reconnect) + await super().connect_real(device, timeout, force_reconnect) + + # All Signals and child devs should be filled and connected now, in the next + # all signals and child devices need to be added to the according + # StandardReadableFormat with the hierarchiy: + # 1. Format given in Annotation + # --> these will already have been set by the DeviceFiller + # 2. Module Interface Class definition (value, target,...) + # --> these are set at the end of a .connect() method of the according + # SECoPDevice subclass skipping any signals that have already been + # set by annotations (should emit warning if there is a conflict + # config vs read sig) + # 3. & 4. Definition in Parameter property "_signal_format" + Defaults + # - _signal_format property + default CONFIG_SIGNAL for all other Signals + # --> these are set here at the end of SECoPDeviceConnector.connect_real() + # for all Signals that have not yet been set to a format + # - CHILD format for all child devices (SECoPDevice instances) + # --> these are set at the end of SECoPNodeDevice.connect() method + # the device tree has only a depth of 2 levels (Node -> Modules) + # + + # device has to be standard readable for this to make sense + if not isinstance(device, SECoPDevice): + return + + # 2. Module Interface Class definition (value, target,...) + await device._assign_interface_formats() + + # 3. & 4. Definition in Parameter property "_signal_format" + Defaults + await device._assign_default_formats() class SECoPCMDDevice(StandardReadable, Flyable, Triggerable): @@ -354,12 +420,22 @@ def collect(self) -> Iterator[PartialEvent]: class SECoPDevice(StandardReadable): clients: Dict[str, AsyncFrappyClient] = {} + node_id: str + sri: str + host: str + port: str + module: str | None + mod_prop_devices: Dict[str, SignalR] + param_devices: Dict[str, Any] + logger: Logger def __init__( self, sri: str = "", # SECoP resource identifier host:port:optional[module] name: str = "", connector: SECoPDeviceConnector | None = None, + loglevel=logging.INFO, + logdir: str | None = None, ) -> None: if connector and sri: @@ -367,30 +443,37 @@ def __init__( if connector: sri = connector.sri + loglevel = connector.loglevel + logdir = connector.logdir self.sri = sri - self.host: str = sri.split(":")[0] - self.port: str = sri.split(":")[1] - self.mod_prop_devices: Dict[str, SignalR] = {} - self.param_devices: Dict[str, Any] = {} + self.host = sri.split(":")[0] + self.port = sri.split(":")[1] + self.mod_prop_devices = {} + self.param_devices = {} + self.node_id = sri.split(":")[0] + ":" + sri.split(":")[1] + + self.logger = setup_logging( + name=f"frappy:{self.host}:{self.port}", + level=loglevel, + log_dir=logdir, + ) - self.module: str | None = None + self.module = None if len(sri.split(":")) > 2: self.module = sri.split(":")[2] - if SECoPDevice.clients.get(sri) is None: - SECoPDevice.clients[sri] = AsyncFrappyClient(host=self.host, port=self.port) + if SECoPDevice.clients.get(self.node_id) is None: + SECoPDevice.clients[self.node_id] = AsyncFrappyClient( + host=self.host, port=self.port, log=self.logger + ) connector = connector or SECoPDeviceConnector(sri=sri) - self._client: AsyncFrappyClient = SECoPDevice.clients[sri] + self._client: AsyncFrappyClient = SECoPDevice.clients[self.node_id] super().__init__(name=name, connector=connector) - for child_name, child_dev in self.children(): - if isinstance(child_dev, Signal): - child_dev.set_name(self.name + "_" + child_name) - def set_module(self, module_name: str): if self.module is not None: raise RuntimeError("Module can only be set if it was not already set") @@ -457,8 +540,6 @@ async def connect( setattr(self, command, MethodType(cmd_plan, self)) - self.set_name(self.module) - else: # Signals for module properties with self.add_children_as_readables( @@ -472,6 +553,12 @@ async def connect( await super().connect(mock, timeout, force_reconnect) + if self.module is None: + # set device name from equipment id property + self.set_name(self._client.properties[EQUIPMENT_ID].replace(".", "-")) + else: + self.set_name(self.module) + def generate_cmd_plan( self, cmd_dev: SECoPCMDDevice, @@ -547,12 +634,71 @@ def wait_for_idle_factory(): return cmd_meth + @abstractmethod + async def _assign_interface_formats(self): + """Assign signal formats specific to this device's interface class. + Subclasses override this to assign formats before default fallback.""" + + async def _assign_default_formats(self): + config_signals = [] + hinted_signals = [] + uncached_signals = [] + hinted_uncached_signals = [] + + def assert_device_is_signalr(device: Device) -> SignalR: + if not isinstance(device, SignalR): + raise TypeError(f"{device} is not a SignalR") + return device + + for _, child in self.children(): + + if not isinstance(child, Signal): + continue + + backend = child._connector.backend + if not isinstance(backend, SECoPParamBackend): + continue + + # child is a Signal with SECoPParamBackend + + # check if signal already has a format assigned + signalr_device = assert_device_is_signalr(child) + + if format_assigned(self, signalr_device): + # format already assigned by annotation or module IF class + continue + + match backend.format: + case StandardReadableFormat.CHILD: + raise RuntimeError("Signal cannot have CHILD format") + case StandardReadableFormat.CONFIG_SIGNAL: + config_signals.append(signalr_device) + case StandardReadableFormat.HINTED_SIGNAL: + hinted_signals.append(signalr_device) + case StandardReadableFormat.UNCACHED_SIGNAL: + uncached_signals.append(signalr_device) + case StandardReadableFormat.HINTED_UNCACHED_SIGNAL: + hinted_uncached_signals.append(signalr_device) + + # add signals to device in the order of their priority + self.add_readables(config_signals, StandardReadableFormat.CONFIG_SIGNAL) + + self.add_readables(hinted_signals, StandardReadableFormat.HINTED_SIGNAL) + + self.add_readables(uncached_signals, StandardReadableFormat.UNCACHED_SIGNAL) + + self.add_readables( + hinted_uncached_signals, StandardReadableFormat.HINTED_UNCACHED_SIGNAL + ) + class SECoPNodeDevice(SECoPDevice): def __init__( self, sec_node_uri: str = "", # SECoP resource identifier host:port:optional[module] name: str = "", + loglevel=logging.INFO, + logdir: str | None = None, ): # ensure sec_node_uri only contains host:port if sec_node_uri.count(":") != 1: @@ -560,7 +706,7 @@ def __init__( f"SECoPNodeDevice SRI must only contain host:port {sec_node_uri}" ) - super().__init__(sri=sec_node_uri, name=name) + super().__init__(sri=sec_node_uri, name=name, loglevel=loglevel, logdir=logdir) async def connect(self, mock=False, timeout=DEFAULT_TIMEOUT, force_reconnect=False): await super().connect(mock, timeout, force_reconnect) @@ -572,6 +718,31 @@ async def connect(self, mock=False, timeout=DEFAULT_TIMEOUT, force_reconnect=Fal self.add_readables(moddevs, StandardReadableFormat.CHILD) + # register secclient callbacks (these are useful if sec node description + # changes after a reconnect) + self._client.register_callback( + None, self.descriptiveDataChange, self.nodeStateChange + ) + + def descriptiveDataChange(self, module, description): # noqa: N802 + raise RuntimeError( + "The descriptive data has changed upon reconnect. Descriptive data changes" + "are not supported: reinstantiate device" + ) + + def nodeStateChange(self, online, state): # noqa: N802 + """called when the state of the connection changes + + 'online' is True when connected or reconnecting, False when disconnected + or connecting 'state' is the connection state as a string + """ + if state == "connected" and online is True: + self._client.conn_timestamp = ttime.time() + + async def _assign_interface_formats(self): + # Node device has no specific interface class formats + pass + class SECoPCommunicatorDevice(SECoPDevice): def __init__( @@ -579,8 +750,16 @@ def __init__( sri: str = "", # SECoP resource identifier host:port:optional[module] name: str = "", connector: SECoPDeviceConnector | None = None, + loglevel=logging.INFO, + logdir: str | None = None, ) -> None: - super().__init__(sri=sri, name=name, connector=connector) + super().__init__( + sri=sri, name=name, connector=connector, loglevel=loglevel, logdir=logdir + ) + + async def _assign_interface_formats(self): + # Communicator has no specific interface class formats + pass class SECoPReadableDevice(SECoPDevice, Triggerable, Subscribable): @@ -594,6 +773,8 @@ def __init__( sri: str = "", # SECoP resource identifier host:port:optional[module] name: str = "", connector: SECoPDeviceConnector | None = None, + loglevel=logging.INFO, + logdir: str | None = None, ): """Initializes the SECoPReadableDevice @@ -607,7 +788,9 @@ def __init__( self.value: SignalR self.status: SignalR - super().__init__(sri=sri, name=name, connector=connector) + super().__init__( + sri=sri, name=name, connector=connector, loglevel=loglevel, logdir=logdir + ) async def connect(self, mock=False, timeout=DEFAULT_TIMEOUT, force_reconnect=False): await super().connect(mock, timeout, force_reconnect) @@ -624,15 +807,28 @@ async def connect(self, mock=False, timeout=DEFAULT_TIMEOUT, force_reconnect=Fal + "but is needed for Readable interface class" ) + async def _assign_interface_formats(self): + + if format_assigned(self, self.value): + if not is_read_signal(self, self.value): + warnings.warn( + f"Signal 'value' of device {self.name} has format assigned " + + "that is not compatible with Readable interface class" + ) + else: + self.add_readables([self.value], StandardReadableFormat.HINTED_SIGNAL) + + # TODO ensure status signal must be neither config nor read format + async def wait_for_idle(self): """asynchronously waits until module is IDLE again. this is helpful, for running commands that are not done immediately """ - # self.logger.info(f"Waiting for {self.name} to be IDLE") + self.logger.info(f"Waiting for {self.name} to be IDLE") if self.status is None: - # self.logger.error("Status Signal not initialized") + self.logger.error("Status Signal not initialized") raise Exception("status Signal not initialized") # force reading of fresh status from device @@ -646,7 +842,7 @@ async def wait_for_idle(self): # Module is in IDLE/WARN state if IDLE <= stat_code < BUSY: - # self.logger.info(f"Module {self.name} --> IDLE") + self.logger.info(f"Module {self.name} --> IDLE") break if hasattr(self, "_stopped"): @@ -657,7 +853,7 @@ async def wait_for_idle(self): # Error State or DISABLED if hasattr(self, "_success"): if stat_code >= ERROR or stat_code < IDLE: - # self.logger.error(f"Module {self.name} --> ERROR/DISABLED") + self.logger.error(f"Module {self.name} --> ERROR/DISABLED") self._success = False break @@ -679,7 +875,7 @@ def switch_from_status_factory(): yield from bps.wait_for([switch_from_status_factory]) def trigger(self) -> AsyncStatus: - # self.logger.info(f"Triggering {self.name}: read fresh data from device") + self.logger.info(f"Triggering {self.name}: read fresh data from device") # get fresh reading of the value Parameter from the SEC Node return AsyncStatus( awaitable=self._client.get_parameter(self.module, "value", trycache=False) @@ -705,6 +901,8 @@ def __init__( sri: str = "", # SECoP resource identifier host:port:optional[module] name: str = "", connector: SECoPDeviceConnector | None = None, + loglevel=logging.INFO, + logdir: str | None = None, ): """Initialize SECoPTriggerableDevice @@ -720,14 +918,16 @@ def __init__( self._success = True self._stopped = False - super().__init__(sri=sri, name=name, connector=connector) + super().__init__( + sri=sri, name=name, connector=connector, loglevel=loglevel, logdir=logdir + ) class SECoPWritableDevice(SECoPReadableDevice): pass -class SECoPMoveableDevice(SECoPWritableDevice, Locatable, Stoppable): +class SECoPMoveableDevice(SECoPReadableDevice, Locatable, Stoppable): """ Standard movable SECoP device, corresponding to a SECoP module with the interface class "Drivable" @@ -738,6 +938,8 @@ def __init__( sri: str = "", # SECoP resource identifier host:port:optional[module] name: str = "", connector: SECoPDeviceConnector | None = None, + loglevel=logging.INFO, + logdir: str | None = None, ): """Initialize SECoPMovableDevice @@ -750,7 +952,9 @@ def __init__( self.target: SignalRW - super().__init__(sri=sri, name=name, connector=connector) + super().__init__( + sri=sri, name=name, connector=connector, loglevel=loglevel, logdir=logdir + ) self._success = True self._stopped = False @@ -783,7 +987,7 @@ async def _move(self, new_target): self._stopped = False await self.target.set(new_target, wait=False) - # self.logger.info(f"Moving {self.name} to {new_target}") + self.logger.info(f"Moving {self.name} to {new_target}") # force reading of status from device await self.status.read(False) @@ -797,13 +1001,13 @@ async def _move(self, new_target): # Error State or DISABLED if stat_code >= ERROR or stat_code < IDLE: - # self.logger.error(f"Module {self.name} --> ERROR/DISABLED") + self.logger.error(f"Module {self.name} --> ERROR/DISABLED") self._success = False break # Module is in IDLE/WARN state if IDLE <= stat_code < BUSY: - # self.logger.info(f"Reached Target Module {self.name} --> IDLE") + self.logger.info(f"Reached Target Module {self.name} --> IDLE") break # TODO other status transitions @@ -823,7 +1027,7 @@ async def stop(self, success=True): self._success = success if not success: - # self.logger.info(f"Stopping {self.name} success={success}") + self.logger.info(f"Stopping {self.name} success={success}") await self._client.exec_command(self.module, "stop") self._stopped = True @@ -839,6 +1043,18 @@ async def locate(self) -> Location: } return location + async def _assign_interface_formats(self): + await super()._assign_interface_formats() + + if format_assigned(self, self.target): + if not is_read_signal(self, self.target): + warnings.warn( + f"Signal 'target' of device {self.name} has format assigned " + + "that is not compatible with Movable interface class" + ) + else: + self.add_readables([self.target], StandardReadableFormat.HINTED_SIGNAL) + def class_from_interface(mod_properties: dict): ophyd_class = None diff --git a/src/secop_ophyd/SECoPSignal.py b/src/secop_ophyd/SECoPSignal.py index cb5b8af..f473ad4 100644 --- a/src/secop_ophyd/SECoPSignal.py +++ b/src/secop_ophyd/SECoPSignal.py @@ -223,6 +223,12 @@ async def get_setpoint(self) -> SignalDatatypeT: class SECoPParamBackend(SignalBackend[SignalDatatypeT]): """Standard backend for a Signal that represents SECoP Parameter""" + format: StandardReadableFormat + _module_name: str + _parameter_name: str + _secclient: AsyncFrappyClient + path_str: str + def __init__( self, datatype: type[SignalDatatypeT] | None, @@ -233,11 +239,11 @@ def __init__( if path and secclient: module_name, parameter_name = path.split(":", maxsplit=1) - self._module_name: str = module_name - self._parameter_name: str = parameter_name - self._secclient: AsyncFrappyClient = secclient + self._module_name = module_name + self._parameter_name = parameter_name + self._secclient = secclient - self.path_str: str = path + self.path_str = path super().__init__(datatype) @@ -249,11 +255,11 @@ def init_from_introspection( ): module_name, parameter_name = path.split(":", maxsplit=1) - self._module_name: str = module_name - self._parameter_name: str = parameter_name - self._secclient: AsyncFrappyClient = secclient + self._module_name = module_name + self._parameter_name = parameter_name + self._secclient = secclient - self.path_str: str = path + self.path_str = path self.datatype = datatype diff --git a/src/secop_ophyd/logs.py b/src/secop_ophyd/logs.py index 0195f4e..a41e6cb 100644 --- a/src/secop_ophyd/logs.py +++ b/src/secop_ophyd/logs.py @@ -12,16 +12,6 @@ DEFAULT_ROTATION_INTERVAL = 1 # Every 1 hour DEFAULT_BACKUP_COUNT = 48 # Keep logs for 48 hours -# Create a dictionary of log level names to their values -LOG_LEVELS = { - "DEBUG": logging.DEBUG, - "INFO": logging.INFO, - "WARNING": logging.WARNING, - "ERROR": logging.ERROR, - "CRITICAL": logging.CRITICAL, -} - - log_file_handlers: Dict[str, TimedRotatingFileHandler] = {} console_handler: logging.StreamHandler | None = None diff --git a/tests/test_Node.py b/tests/test_Node.py index 75c8afb..365afa7 100644 --- a/tests/test_Node.py +++ b/tests/test_Node.py @@ -15,7 +15,9 @@ async def test_node_structure(cryo_sim, cryo_node_no_re: SECoPNodeDevice): async def test_node_read(cryo_sim, cryo_node_no_re: SECoPNodeDevice): # Node device should return the readbacks of the read signals of the child devices + val_read = await cryo_node_no_re.read() + print(val_read) assert val_read != {} diff --git a/tests/test_nested.py b/tests/test_nested.py index a22bf2f..907ac12 100644 --- a/tests/test_nested.py +++ b/tests/test_nested.py @@ -2,7 +2,6 @@ import numpy as np from ophyd_async.core import SignalR, SignalRW -from secop_ophyd.AsyncFrappyClient import AsyncFrappyClient from secop_ophyd.SECoPDevices import SECoPNodeDevice, SECoPReadableDevice @@ -10,10 +9,11 @@ async def test_nested_connect(nested_struct_sim, nested_node_no_re: SECoPNodeDev assert isinstance(nested_node_no_re, SECoPNodeDevice) -async def test_tuple_dev(nested_client: AsyncFrappyClient): - ophy_struct = SECoPReadableDevice( - secclient=nested_client, module_name="ophy_struct" - ) +async def test_tuple_dev(nested_struct_sim): + + ophy_struct = SECoPReadableDevice("localhost:10771:ophy_struct") + + await ophy_struct.connect() status_sig: SignalR = ophy_struct.status @@ -31,10 +31,9 @@ async def test_tuple_dev(nested_client: AsyncFrappyClient): assert isinstance(stat1.item(), str) -async def test_struct_dev(nested_client: AsyncFrappyClient): - ophy_struct = SECoPReadableDevice( - secclient=nested_client, module_name="ophy_struct" - ) +async def test_struct_dev(nested_struct_sim): + ophy_struct = SECoPReadableDevice("localhost:10771:ophy_struct") + await ophy_struct.connect() nested_struct_sig: SignalR = ophy_struct.nested_struct await nested_struct_sig.read() From aad7272b637d9a019bc6d96e79a792c0ecc688f5 Mon Sep 17 00:00:00 2001 From: Peter Braun Date: Thu, 5 Feb 2026 16:15:50 +0100 Subject: [PATCH 08/18] unified parameter and property backends --- src/secop_ophyd/GenNodeCode.py | 3 +- src/secop_ophyd/SECoPDevices.py | 69 +++++++-- src/secop_ophyd/SECoPSignal.py | 259 +++++++++++++++++--------------- 3 files changed, 200 insertions(+), 131 deletions(-) diff --git a/src/secop_ophyd/GenNodeCode.py b/src/secop_ophyd/GenNodeCode.py index a00d920..8d84f5c 100644 --- a/src/secop_ophyd/GenNodeCode.py +++ b/src/secop_ophyd/GenNodeCode.py @@ -8,6 +8,7 @@ from dataclasses import dataclass, field from importlib import import_module, reload from inspect import Signature +from logging import Logger from pathlib import Path from types import ModuleType @@ -137,7 +138,7 @@ def __init__(self, path: str | None = None, log=None): path: Optional path to the module folder log: Optional logger instance """ - self.log = log + self.log: Logger | None = log self.module_folder_path: Path | None = None if path is not None: self.module_folder_path = Path(path) diff --git a/src/secop_ophyd/SECoPDevices.py b/src/secop_ophyd/SECoPDevices.py index b2ef0d4..572a3f3 100644 --- a/src/secop_ophyd/SECoPDevices.py +++ b/src/secop_ophyd/SECoPDevices.py @@ -4,6 +4,7 @@ import time as ttime import warnings from abc import abstractmethod +from dataclasses import dataclass from logging import Logger from types import MethodType from typing import Any, Dict, Iterator, Optional, Type @@ -52,9 +53,9 @@ from secop_ophyd.logs import setup_logging from secop_ophyd.propertykeys import DATAINFO, EQUIPMENT_ID, INTERFACE_CLASSES from secop_ophyd.SECoPSignal import ( + AttributeType, LocalBackend, - PropertyBackend, - SECoPParamBackend, + SECoPBackend, SECoPXBackend, ) from secop_ophyd.util import Path @@ -136,6 +137,36 @@ def is_config_signal(device: StandardReadable, signal: SignalR | SignalRW) -> bo return False +@dataclass +class ParamPath: + """Annotation for Parameter Signals, defines the path to the parameter + in the secclient module dict""" + + module: str + param: str + + def __repr__(self) -> str: + """Return repr suitable for code generation in annotations.""" + return f'ParamPath("{self.module}", "{self.param}")' + + +@dataclass +class PropPath: + """Annotation for Module Property Signals, defines the path to the property""" + + key: str + + # if module is None, property is assumed to be at node level, + # otherwise at module level + module: str | None = None + + def __repr__(self) -> str: + """Return repr suitable for code generation in annotations.""" + if self.module is None: + return f'PropPath("{self.key}")' + return f'PropPath("{self.key}", module="{self.module}")' + + class SECoPDeviceConnector(DeviceConnector): sri: str @@ -181,7 +212,7 @@ def create_children_from_annotations(self, device: Device): if not hasattr(self, "filler"): self.filler = DeviceFiller( device=device, - signal_backend_factory=SECoPParamBackend, + signal_backend_factory=SECoPBackend, device_connector_factory=lambda: SECoPDeviceConnector( self.sri, self._auto_fill_signals, self.loglevel, self.logdir ), @@ -229,7 +260,7 @@ async def connect_real(self, device: Device, timeout: float, force_reconnect: bo from secop_ophyd.GenNodeCode import get_type_param datatype = get_type_param(parameters[param_name]["datatype"]) - backend.init_from_introspection( + backend.init_parameter_from_introspection( datatype=datatype, path=self.module + ":" + param_name, secclient=self.client, @@ -509,8 +540,11 @@ async def connect( if property in IGNORED_PROPS: continue - propb = PropertyBackend( - property, module_desc["properties"], self._client + propb = SECoPBackend( + datatype=None, + path=self.module + ":" + property, + secclient=self._client, + attribute_type=AttributeType.PROPERTY, ) setattr(self, property, SignalR(backend=propb)) @@ -541,13 +575,16 @@ async def connect( setattr(self, command, MethodType(cmd_plan, self)) else: - # Signals for module properties + # Signals for Node properties with self.add_children_as_readables( format=StandardReadableFormat.CONFIG_SIGNAL ): for property in self._client.properties: - propb = PropertyBackend( - property, self._client.properties, self._client + propb = SECoPBackend( + datatype=None, + path=property, + secclient=self._client, + attribute_type=AttributeType.PROPERTY, ) setattr(self, property, SignalR(backend=propb)) @@ -656,7 +693,7 @@ def assert_device_is_signalr(device: Device) -> SignalR: continue backend = child._connector.backend - if not isinstance(backend, SECoPParamBackend): + if not isinstance(backend, SECoPBackend): continue # child is a Signal with SECoPParamBackend @@ -743,6 +780,18 @@ async def _assign_interface_formats(self): # Node device has no specific interface class formats pass + def class_from_instance(self, path_to_module: str | None = None): + from secop_ophyd.GenNodeCode import GenNodeCode + + description = self._client.client.request("describe")[2] + + # parse genClass file if already present + genCode = GenNodeCode(path=path_to_module, log=self.logger) + + genCode.from_json_describe(description) + + genCode.write_gen_node_class_file() + class SECoPCommunicatorDevice(SECoPDevice): def __init__( diff --git a/src/secop_ophyd/SECoPSignal.py b/src/secop_ophyd/SECoPSignal.py index f473ad4..8b052e7 100644 --- a/src/secop_ophyd/SECoPSignal.py +++ b/src/secop_ophyd/SECoPSignal.py @@ -1,7 +1,7 @@ import asyncio import warnings from functools import wraps -from typing import Any, Callable, Dict +from typing import Any, Callable from bluesky.protocols import DataKey, Reading from frappy.client import CacheItem @@ -22,6 +22,7 @@ SignalBackend, SignalDatatypeT, StandardReadableFormat, + StrictEnum, ) from secop_ophyd.AsyncFrappyClient import AsyncFrappyClient @@ -42,6 +43,11 @@ MAX_DEPTH = 1 +class AttributeType(StrictEnum): + PARAMETER = "parameter" + PROPERTY = "property" + + class LocalBackend(SignalBackend): """Class for the 'argument' and 'result' Signal backends of a SECoP_CMD_Device. These Signals act as a local cache for storing the command argument and result. @@ -220,56 +226,108 @@ async def get_setpoint(self) -> SignalDatatypeT: ) -class SECoPParamBackend(SignalBackend[SignalDatatypeT]): - """Standard backend for a Signal that represents SECoP Parameter""" +class SECoPBackend(SignalBackend[SignalDatatypeT]): + """Unified backend for SECoP Parameters and Properties. + + + This allows a single backend type to be used in signal_backend_factory, + with deferred initialization based on annotation metadata. + """ format: StandardReadableFormat - _module_name: str - _parameter_name: str + attribute_type: str | None + _module_name: str | None + _attribute_name: str | None # parameter or property name _secclient: AsyncFrappyClient path_str: str + SECoPdtype_obj: DataType + SECoP_type_info: SECoPdtype + describe_dict: dict def __init__( self, datatype: type[SignalDatatypeT] | None, path: str | None = None, + attribute_type: str | None = None, secclient: AsyncFrappyClient | None = None, ): + """Initialize backend (supports deferred initialization). - if path and secclient: - module_name, parameter_name = path.split(":", maxsplit=1) + Args: + datatype: Optional datatype for the signal + path: Optional path for immediate initialization (module:param or prop_key) + secclient: Optional SECoP client for immediate initialization + """ + self._module_name = None + self._attribute_name = None - self._module_name = module_name - self._parameter_name = parameter_name + self.attribute_type = attribute_type + + if secclient: self._secclient = secclient - self.path_str = path + self.path_str = path or "" + + if path and secclient: + + if path.count(":") == 0: + self._module_name = None + self._attribute_name = path + else: + self._module_name, self._attribute_name = path.split(":", maxsplit=1) super().__init__(datatype) - def init_from_introspection( + def init_parameter_from_introspection( self, datatype: type[SignalDatatypeT], path: str, secclient: AsyncFrappyClient, ): + self.attribute_type = AttributeType.PARAMETER + module_name, parameter_name = path.split(":", maxsplit=1) self._module_name = module_name - self._parameter_name = parameter_name + self._attribute_name = parameter_name self._secclient = secclient + self.datatype = datatype + self.path_str = path + def init_property_from_introspection( + self, datatype: type[SignalDatatypeT], path: str, secclient: AsyncFrappyClient + ): + self.attribute_type = AttributeType.PROPERTY + if path.count(":") == 0: + module_name = None + property_name = path + else: + module_name, property_name = path.split(":", maxsplit=1) + + self._module_name = module_name + self._attribute_name = property_name + self._secclient = secclient self.datatype = datatype + self.path_str = path + def source(self, name: str, read: bool) -> str: return self.path_str async def connect(self, timeout: float): - + """Connect and initialize backend (handles both parameters and properties).""" await self._secclient.connect() + match self.attribute_type: + case AttributeType.PROPERTY: + await self._init_property() + case AttributeType.PARAMETER: + await self._init_parameter() + + async def _init_parameter(self): + """Initialize as a parameter signal.""" self._param_description: dict = self._get_param_desc() match self._param_description.get("_signal_format", None): @@ -284,24 +342,18 @@ async def connect(self, timeout: float): # Root datainfo or memberinfo for nested datatypes self.datainfo: dict = self._param_description["datainfo"] - self.readonly = self._param_description.get("readonly") - - self.SECoPdtype_str: str - self.SECoPdtype_obj: DataType = self._param_description["datatype"] - - self.SECoP_type_info: SECoPdtype = SECoPdtype(self.SECoPdtype_obj) + self.SECoPdtype_obj = self._param_description["datatype"] + self.SECoP_type_info = SECoPdtype(self.SECoPdtype_obj) if self.SECoP_type_info.max_depth > MAX_DEPTH: warnings.warn( - f"The datatype of parameter '{self._parameter_name}' has a maximum " + f"The datatype of parameter '{self._attribute_name}' has a maximum " f"depth of {self.SECoP_type_info.max_depth}. Tiled & Databroker only " f"support a Depth upto {MAX_DEPTH} " f"dtype_descr: {self.SECoP_type_info.dtype_descr}" ) - self.describe_dict: dict = {} - self.source_name = ( self._secclient.uri + ":" @@ -309,20 +361,15 @@ async def connect(self, timeout: float): + ":" + self._module_name + ":" - + self._parameter_name + + self._attribute_name ) - # SECoP metadata is static and can only change when connection is reset self.describe_dict = {} - self.describe_dict["source"] = self.source_name - - # add gathered keys from SECoPdtype: self.describe_dict.update(self.SECoP_type_info.get_datakey()) for property_name, prop_val in self._param_description.items(): - # skip datainfo (treated seperately) - if property_name == "datainfo" or property_name == "datatype": + if property_name in ("datainfo", "datatype"): continue self.describe_dict[property_name] = prop_val @@ -335,7 +382,45 @@ async def connect(self, timeout: float): self.datatype = self.SECoP_type_info.np_datatype + async def _init_property(self): + """Initialize as a property signal.""" + + if self._module_name: + module_desc = self._secclient.modules[self._module_name] + self._property_dict = module_desc["properties"] + else: + self._property_dict = self._secclient.properties + + self._prop_value = self._property_dict[self._attribute_name] + self.SECoPdtype_obj = secop_dtype_obj_from_json(self._prop_value) + self.SECoP_type_info = SECoPdtype(self.SECoPdtype_obj) + + if self.SECoP_type_info.max_depth > MAX_DEPTH: + warnings.warn( + f"The datatype of property '{self._attribute_name}' has a maximum " + f"depth of {self.SECoP_type_info.max_depth}. Tiled & Databroker only " + f"support a Depth upto {MAX_DEPTH} " + f"dtype_descr: {self.SECoP_type_info.dtype_descr}" + ) + + self.describe_dict = {} + self.describe_dict["source"] = self.path_str + self.describe_dict.update(self.SECoP_type_info.get_datakey()) + + # Properties are always readonly + self.format = StandardReadableFormat.CONFIG_SIGNAL + self.readonly = True + self.datatype = self.SECoP_type_info.np_datatype + async def put(self, value: Any | None, wait=True): + """Put a value to the parameter. Properties are readonly.""" + + if self.attribute_type == AttributeType.PROPERTY: + # Properties are readonly + raise RuntimeError( + f"Cannot set property '{self._attribute_name}', properties are readonly" + ) + # convert to frappy compatible Format secop_val = self.SECoP_type_info.val2secop(value) @@ -348,6 +433,9 @@ async def put(self, value: Any | None, wait=True): async def get_datakey(self, source: str) -> DataKey: """Metadata like source, dtype, shape, precision, units""" + if self.attribute_type == AttributeType.PROPERTY: + # Properties have static metadata + return describedict_to_datakey(self.describe_dict) if self.SECoP_type_info._is_composite or isinstance( self.SECoPdtype_obj, ArrayOf @@ -364,23 +452,35 @@ async def get_datakey(self, source: str) -> DataKey: return describedict_to_datakey(self.describe_dict) async def get_reading(self) -> Reading[SignalDatatypeT]: - dataset = await self._secclient.get_parameter( - **self.get_param_path(), trycache=True - ) - - sec_reading = SECoPReading(entry=dataset, secop_dt=self.SECoP_type_info) + """Get reading, handling both parameters and properties.""" + if self.attribute_type == AttributeType.PROPERTY: + # Properties have static values + dataset = CacheItem( + value=self._prop_value, timestamp=self._secclient.conn_timestamp + ) + sec_reading = SECoPReading(entry=dataset, secop_dt=self.SECoP_type_info) + return sec_reading.get_reading() - return sec_reading.get_reading() + else: + # Parameters are fetched from SECoP + dataset = await self._secclient.get_parameter( + **self.get_param_path(), trycache=True + ) + sec_reading = SECoPReading(entry=dataset, secop_dt=self.SECoP_type_info) + return sec_reading.get_reading() async def get_value(self) -> SignalDatatypeT: dataset: Reading = await self.get_reading() - return dataset["value"] # type: ignore async def get_setpoint(self) -> SignalDatatypeT: return await self.get_value() def set_callback(self, callback: Callback[Reading[SignalDatatypeT]] | None) -> None: + if self.attribute_type == AttributeType.PROPERTY: + # Properties are static, no callbacks + return + def awaitify(sync_func): """Wrap a synchronous callable to allow ``await``'ing it""" @@ -407,95 +507,14 @@ def updateItem(module, parameter, entry: CacheItem): # noqa: N802 def _get_param_desc(self) -> dict: return deep_get( self._secclient.modules, - [self._module_name, "parameters", self._parameter_name], + [self._module_name, "parameters", self._attribute_name], ) def get_param_path(self): - return {"module": self._module_name, "parameter": self._parameter_name} + return {"module": self._module_name, "parameter": self._attribute_name} def get_path_tuple(self): - return (self._module_name, self._parameter_name) - - -class PropertyBackend(SignalBackend): - """Readonly backend for static SECoP Properties of Nodes/Modules""" - - def __init__( - self, prop_key: str, property_dict: Dict[str, Any], secclient: AsyncFrappyClient - ) -> None: - """Initializes PropertyBackend - - :param prop_key: Name of Property - :type prop_key: str - :param propertyDict: Dicitonary containing all properties of Node/Module - :type propertyDict: Dict[str, T] - :param secclient: SECoP client providing communication to the SEC Node - :type secclient: AsyncFrappyClient - """ - # secclient - - self._property_dict = property_dict - self._prop_key = prop_key - self._prop_value = self._property_dict[self._prop_key] - self.SECoPdtype_obj: DataType = secop_dtype_obj_from_json(self._prop_value) - self.SECoP_type_info: SECoPdtype = SECoPdtype(self.SECoPdtype_obj) - - if self.SECoP_type_info.max_depth > MAX_DEPTH: - warnings.warn( - f"The datatype of parameter '{prop_key}' has a maximum" - f"depth of {self.SECoP_type_info.max_depth}. Tiled & Databroker only" - f"support a Depth upto {MAX_DEPTH}" - f"dtype_descr: {self.SECoP_type_info.dtype_descr}" - ) - - # SECoP metadata is static and can only change when connection is reset - self.describe_dict = {} - self.source_name = prop_key - self.describe_dict["source"] = self.source_name - - # add gathered keys from SECoPdtype: - self.describe_dict.update(self.SECoP_type_info.get_datakey()) - - self._secclient: AsyncFrappyClient = secclient - # TODO full property path - - super().__init__(datatype=self.SECoP_type_info.np_datatype) - - def source(self, name: str, read: bool) -> str: - return str(self.source_name) - - async def connect(self, timeout: float): - """Connect to underlying hardware""" - pass - - async def put(self, value: SignalDatatypeT | None, wait=True): - """Put a value to the PV, if wait then wait for completion for up to timeout""" - # Properties are readonly - pass - - async def get_datakey(self, source: str) -> DataKey: - """Metadata like source, dtype, shape, precision, units""" - return describedict_to_datakey(self.describe_dict) - - async def get_reading(self) -> Reading[SignalDatatypeT]: - dataset = CacheItem( - value=self._prop_value, timestamp=self._secclient.conn_timestamp - ) - - sec_reading = SECoPReading(entry=dataset, secop_dt=self.SECoP_type_info) - - return sec_reading.get_reading() - - async def get_value(self) -> SignalDatatypeT: - dataset: Reading = await self.get_reading() - - return dataset["value"] # type: ignore - - async def get_setpoint(self) -> SignalDatatypeT: - return await self.get_value() - - def set_callback(self, callback: Callback[Reading[SignalDatatypeT]] | None) -> None: - pass + return (self._module_name, self._attribute_name) def secop_dtype_obj_from_json(prop_val): From 85c0da74ce989a6cf75d5adcca8708478975e280 Mon Sep 17 00:00:00 2001 From: Peter Braun Date: Fri, 6 Feb 2026 17:52:40 +0100 Subject: [PATCH 09/18] instatniation from annotations works, needs additional testing --- src/secop_ophyd/GenNodeCode.py | 86 +++++++- src/secop_ophyd/SECoPDevices.py | 200 +++++++++++++----- src/secop_ophyd/SECoPSignal.py | 19 +- .../templates/generated_classes.py.jinja2 | 30 ++- tests/test_enum_merging.py | 24 ++- tests/test_gencode_refactor.py | 183 +++++++++++++--- 6 files changed, 432 insertions(+), 110 deletions(-) diff --git a/src/secop_ophyd/GenNodeCode.py b/src/secop_ophyd/GenNodeCode.py index 8d84f5c..d17b9e1 100644 --- a/src/secop_ophyd/GenNodeCode.py +++ b/src/secop_ophyd/GenNodeCode.py @@ -18,9 +18,12 @@ from frappy.datatypes import DataType from jinja2 import Environment, PackageLoader, select_autoescape from ophyd_async.core import SignalR, SignalRW +from ophyd_async.core import StandardReadableFormat as Format from secop_ophyd.SECoPDevices import ( IGNORED_PROPS, + ParamPath, + PropPath, class_from_interface, secop_enum_name_to_python, ) @@ -67,6 +70,10 @@ class Attribute: category: str = ( "parameter" # "property" or "parameter" - for organizing generated code ) + path_annotation: str | None = ( + None # Annotation like ParamPath(...) or PropPath(...) + ) + format_annotation: str | None = None # StandardReadableFormat.CONFIG_SIGNAL, etc. class Method: @@ -151,12 +158,17 @@ def __init__(self, path: str | None = None, log=None): # Required imports for abstract classes self.add_import("abc", "abstractmethod") + self.add_import("typing", "Annotated as A") self.add_import("ophyd_async.core", "SignalR") self.add_import("ophyd_async.core", "SignalRW") + self.add_import("ophyd_async.core", "SignalX") + self.add_import("ophyd_async.core", "StandardReadableFormat as Format") self.add_import("ophyd_async.core", "StrictEnum") self.add_import("ophyd_async.core", "SupersetEnum") self.add_import("typing", "Any") self.add_import("numpy") + self.add_import("secop_ophyd.SECoPDevices", "ParamPath") + self.add_import("secop_ophyd.SECoPDevices", "PropPath") # Setup Jinja2 environment self.jinja_env = Environment( @@ -465,7 +477,7 @@ def add_mod_class( self, module_cls: str, bases: list[str], - attrs: list[tuple[str, str, str | None, str | None, str]], + attrs: list[tuple[str, str, str | None, str | None, str, str, str | None]], cmd_plans: list[Method], description: str = "", enum_classes: list[EnumClass] | None = None, @@ -496,9 +508,11 @@ def add_mod_class( attributes = [] for attr in attrs: - type_param = attr[2] if attr[2] else None + type_param = attr[2] if len(attr) > 2 and attr[2] else None descr = attr[3] if len(attr) > 3 else None category = attr[4] if len(attr) > 4 else "parameter" + path_annotation = attr[5] if len(attr) > 5 else None + format_annotation = attr[6] if len(attr) > 6 else None attributes.append( Attribute( @@ -507,6 +521,8 @@ def add_mod_class( type_param=type_param, description=descr, category=category, + path_annotation=path_annotation, + format_annotation=format_annotation, ) ) @@ -524,7 +540,7 @@ def add_node_class( self, node_cls: str, bases: list[str], - attrs: list[tuple[str, str, str | None, str | None, str]], + attrs: list[tuple[str, str, str | None, None, str, str | None]], description: str = "", ): """Add a node class to be generated. @@ -556,6 +572,8 @@ def add_node_class( type_param = str(attr[2]) if len(attr) > 2 and attr[2] else None descr = str(attr[3]) if len(attr) > 3 and attr[3] else None category = str(attr[4]) if len(attr) > 4 and attr[4] else "property" + path_annotation = str(attr[5]) if len(attr) > 5 and attr[5] else None + format_annotation = str(attr[6]) if len(attr) > 6 and attr[6] else None attributes.append( Attribute( name=name, @@ -563,6 +581,8 @@ def add_node_class( type_param=type_param, description=descr, category=category, + path_annotation=path_annotation, + format_annotation=format_annotation, ) ) @@ -613,7 +633,7 @@ def from_json_describe(self, json_data: str | dict): node_properties = {k: v for k, v in describe_data.items() if k != "modules"} # Parse modules - node_attrs: list[tuple[str, str, str | None, str | None, str]] = [] + node_attrs: list[tuple[str, str, str | None, None, str, str | None]] = [] for modname, moddescr in modules.items(): # separate accessibles into command and parameters parameters = {} @@ -687,8 +707,9 @@ def command_plan_no_arg(self, wait_for_idle: bool = False): command_plans.append(plan) - # Module parameters - mod_params: list[tuple[str, str, str | None, str | None, str]] = [] + mod_params: list[ + tuple[str, str, str | None, str | None, str, str, str | None] + ] = [] for param_name, param_data in parameters.items(): @@ -698,6 +719,29 @@ def command_plan_no_arg(self, wait_for_idle: bool = False): param_descr = f"{descr}; Unit: ({unit})" if unit else descr signal_base = SignalR if param_data["readonly"] else SignalRW + format = None + + # infer format from parameter property + match param_data.get("_signal_format", None): + case "HINTED_SIGNAL": + format = Format.HINTED_SIGNAL + case "HINTED_UNCACHED_SIGNAL": + format = Format.HINTED_UNCACHED_SIGNAL + case "UNCACHED_SIGNAL": + format = Format.UNCACHED_SIGNAL + case _: + format = None + + # depending on the Interface class other parameter need to be declared + # as readsignals as well + if param_name in secop_ophyd_modclass.hinted_signals: + format = format or Format.HINTED_SIGNAL + + # Remove "StandardReadable" prefix from format for cleaner annotation + format = ( + str(format).removeprefix("StandardReadable") if format else None + ) + datainfo = param_data.get("datainfo", {}) # infer the ophyd type from secop datatype @@ -740,6 +784,8 @@ def command_plan_no_arg(self, wait_for_idle: bool = False): # StrictEnum type_param = enum_class_name + # Default format for parameters is CONFIG_SIGNAL + mod_params.append( ( param_name, @@ -747,11 +793,15 @@ def command_plan_no_arg(self, wait_for_idle: bool = False): type_param, param_descr, "parameter", + str(ParamPath(f"{modname}:{param_name}")), + format, ) ) # Module properties - mod_props: list[tuple[str, str, str | None, str | None, str]] = [] + mod_props: list[ + tuple[str, str, str | None, str | None, str, str, str | None] + ] = [] # Process module properties for prop_name, property_value in properties.items(): @@ -768,6 +818,8 @@ def command_plan_no_arg(self, wait_for_idle: bool = False): type_param, None, "property", + str(PropPath(f"{modname}:{prop_name}")), + None, ) ) @@ -783,14 +835,23 @@ def command_plan_no_arg(self, wait_for_idle: bool = False): # Add to node attributes # Type the None explicitly as str | None to match other entries - node_attrs.append((modname, module_class, None, None, "module")) + node_attrs.append((modname, module_class, None, None, "module", None)) # Process module properties for prop_name, property_value in node_properties.items(): type_param = get_type_param(secop_dtype_obj_from_json(property_value)) + # Generate PropPath annotation for node-level properties + node_attrs.append( - (str(prop_name), str(SignalR.__name__), type_param, None, "property") + ( + str(prop_name), + str(SignalR.__name__), + type_param, + None, + "property", + str(PropPath(prop_name)), + ) ) # Add node class @@ -853,7 +914,7 @@ def generate_code(self) -> str: # Format with Black try: - code = black.format_str(code, mode=black.Mode()) + code = black.format_str(code, mode=black.Mode(line_length=200)) except Exception as e: if self.log: self.log.warning(f"Black formatting failed: {e}") @@ -962,3 +1023,8 @@ def get_type_param(secop_dtype: DataType) -> str | None: return sig_type.__name__ return f"{module}.{sig_type.__name__}" + + +def get_type_prop(prop_value) -> str | None: + secop_dtype: DataType = secop_dtype_obj_from_json(prop_value) + return get_type_param(secop_dtype) diff --git a/src/secop_ophyd/SECoPDevices.py b/src/secop_ophyd/SECoPDevices.py index 572a3f3..9267c3f 100644 --- a/src/secop_ophyd/SECoPDevices.py +++ b/src/secop_ophyd/SECoPDevices.py @@ -137,34 +137,52 @@ def is_config_signal(device: StandardReadable, signal: SignalR | SignalRW) -> bo return False -@dataclass +@dataclass(init=False) class ParamPath: """Annotation for Parameter Signals, defines the path to the parameter in the secclient module dict""" module: str - param: str + parameter: str + + def __init__(self, param_path: str) -> None: + # Parse from delimited string + parts = param_path.split(":") + if len(parts) != 2: + raise ValueError(f"Expected 'module:param', got '{param_path}'") + self.module = parts[0].strip() + self.parameter = parts[1].strip() def __repr__(self) -> str: """Return repr suitable for code generation in annotations.""" - return f'ParamPath("{self.module}", "{self.param}")' + return f'ParamPath("{self.module}:{self.parameter}")' -@dataclass +@dataclass(init=False) class PropPath: """Annotation for Module Property Signals, defines the path to the property""" - key: str + property: str # if module is None, property is assumed to be at node level, # otherwise at module level module: str | None = None + def __init__(self, property_path: str) -> None: + # Parse from delimited string + parts = property_path.split(":") + if len(parts) == 2: + self.module = parts[0].strip() + self.property = parts[1].strip() + else: + self.property = property_path.strip() + self.module = None # --> node level property + def __repr__(self) -> str: """Return repr suitable for code generation in annotations.""" if self.module is None: - return f'PropPath("{self.key}")' - return f'PropPath("{self.key}", module="{self.module}")' + return f'PropPath("{self.property}")' + return f'PropPath("{self.module}:{self.property}")' class SECoPDeviceConnector(DeviceConnector): @@ -217,10 +235,46 @@ def create_children_from_annotations(self, device: Device): self.sri, self._auto_fill_signals, self.loglevel, self.logdir ), ) - list(self.filler.create_devices_from_annotations(filled=False)) - list(self.filler.create_signals_from_annotations(filled=False)) - self.filler.check_created() + for backend, annotations in self.filler.create_signals_from_annotations(): + self.fill_backend_with_path(backend, annotations) + + list(self.filler.create_devices_from_annotations(filled=False)) + + self.filler.check_created() + + def fill_backend_with_path(self, backend: SECoPBackend, annotations: list[Any]): + unhandled = [] + while annotations: + annotation = annotations.pop(0) + + if isinstance(annotation, StandardReadableFormat): + backend.format = annotation + + elif isinstance(annotation, ParamPath): + backend.attribute_type = AttributeType.PARAMETER + + backend._module_name = annotation.module + backend._attribute_name = annotation.parameter + backend._secclient = self.client + backend.path_str = annotation.module + ":" + annotation.parameter + + elif isinstance(annotation, PropPath): + backend.attribute_type = AttributeType.PROPERTY + + backend._module_name = annotation.module + backend._attribute_name = annotation.property + backend._secclient = self.client + + if annotation.module: + backend.path_str = annotation.module + ":" + annotation.property + else: + backend.path_str = annotation.property + else: + unhandled.append(annotation) + + annotations.extend(unhandled) + # These leftover annotations will now be handled by the iterator async def connect_mock(self, device: Device, mock: LazyMock): # Make 2 entries for each DeviceVector @@ -236,38 +290,69 @@ async def connect_real(self, device: Device, timeout: float, force_reconnect: bo # Establish connection to SEC Node await self.client.connect(3) - # Module Device: fill Parameters (commands are done via annotated plans) + # Module Device: fill Parameters & Pproperties + # (commands are done via annotated plans) if self.module: - parameters = self.client.modules[self.module]["parameters"] + + # Fill Parmeters + parameter_dict = self.client.modules[self.module]["parameters"] # remove ignored signals - children = [ + parameters = [ child - for child in parameters.keys() + for child in parameter_dict.keys() if child not in self.filler.ignored_signals ] # Dertermine children that are declared but not yet filled not_filled = {unfilled for unfilled, _ in device.children()} - for param_name in children: + for param_name in parameters: if self._auto_fill_signals or param_name in not_filled: signal_type = ( - SignalR if parameters[param_name]["readonly"] else SignalRW + SignalR if parameter_dict[param_name]["readonly"] else SignalRW ) backend = self.filler.fill_child_signal(param_name, signal_type) from secop_ophyd.GenNodeCode import get_type_param - datatype = get_type_param(parameters[param_name]["datatype"]) + datatype = get_type_param(parameter_dict[param_name]["datatype"]) backend.init_parameter_from_introspection( datatype=datatype, path=self.module + ":" + param_name, secclient=self.client, ) + # Fill Properties + module_property_dict = self.client.modules[self.module]["properties"] + + # remove ignored signals + module_properties = [ + child + for child in module_property_dict.keys() + if child not in self.filler.ignored_signals + ] + + for mod_property_name in module_properties: + if self._auto_fill_signals or mod_property_name in not_filled: + + # properties are always read only + backend = self.filler.fill_child_signal(mod_property_name, SignalR) + + from secop_ophyd.GenNodeCode import get_type_prop + + datatype = get_type_prop(module_property_dict[mod_property_name]) + + backend.init_property_from_introspection( + datatype=datatype, + path=self.module + ":" + mod_property_name, + secclient=self.client, + ) + # Node Device: fill child devices (modules) else: + + # Fill Module devices modules = self.client.modules not_filled = {unfilled for unfilled, _ in device.children()} @@ -282,6 +367,32 @@ async def connect_real(self, device: Device, timeout: float, force_reconnect: bo mod_dev: SECoPDevice = getattr(device, module_name) mod_dev.set_module(module_name) + # Fill Node properties + node_property_dict = self.client.properties + + # remove ignored signals + node_properties = [ + child + for child in node_property_dict.keys() + if child not in self.filler.ignored_signals + ] + + for node_property_name in node_properties: + if self._auto_fill_signals or node_property_name in not_filled: + + # properties are always read only + backend = self.filler.fill_child_signal(node_property_name, SignalR) + + from secop_ophyd.GenNodeCode import get_type_prop + + datatype = get_type_prop(node_property_dict[node_property_name]) + + backend.init_property_from_introspection( + datatype=datatype, + path=node_property_name, + secclient=self.client, + ) + self.filler.check_filled(f"{self.node_id}") # Set the name of the device to name all children @@ -460,6 +571,8 @@ class SECoPDevice(StandardReadable): param_devices: Dict[str, Any] logger: Logger + hinted_signals: list[str] = [] + def __init__( self, sri: str = "", # SECoP resource identifier host:port:optional[module] @@ -527,31 +640,8 @@ async def connect( if self.module: module_desc = self._client.modules[self.module] - # Add Signals for Module Properties - with self.add_children_as_readables( - format=StandardReadableFormat.CONFIG_SIGNAL - ): - # generate Signals from Module Properties - for property in module_desc["properties"]: - - if property == "implementation": - self.impl = module_desc["properties"]["implementation"] - - if property in IGNORED_PROPS: - continue - - propb = SECoPBackend( - datatype=None, - path=self.module + ":" + property, - secclient=self._client, - attribute_type=AttributeType.PROPERTY, - ) - - setattr(self, property, SignalR(backend=propb)) - self.mod_prop_devices[property] = getattr(self, property) - # Initialize Command Devices - for command, properties in module_desc["commands"].items(): + for command, _ in module_desc["commands"].items(): # generate new root path cmd_path = Path(parameter_name=command, module_name=self.module) cmd_dev_name = command + "_CMD" @@ -574,20 +664,6 @@ async def connect( setattr(self, command, MethodType(cmd_plan, self)) - else: - # Signals for Node properties - with self.add_children_as_readables( - format=StandardReadableFormat.CONFIG_SIGNAL - ): - for property in self._client.properties: - propb = SECoPBackend( - datatype=None, - path=property, - secclient=self._client, - attribute_type=AttributeType.PROPERTY, - ) - setattr(self, property, SignalR(backend=propb)) - await super().connect(mock, timeout, force_reconnect) if self.module is None: @@ -730,6 +806,9 @@ def assert_device_is_signalr(device: Device) -> SignalR: class SECoPNodeDevice(SECoPDevice): + + hinted_signals: list[str] = [] + def __init__( self, sec_node_uri: str = "", # SECoP resource identifier host:port:optional[module] @@ -794,6 +873,9 @@ def class_from_instance(self, path_to_module: str | None = None): class SECoPCommunicatorDevice(SECoPDevice): + + hinted_signals: list[str] = [] + def __init__( self, sri: str = "", # SECoP resource identifier host:port:optional[module] @@ -817,6 +899,8 @@ class SECoPReadableDevice(SECoPDevice, Triggerable, Subscribable): interface class "Readable" """ + hinted_signals: list[str] = ["value"] + def __init__( self, sri: str = "", # SECoP resource identifier host:port:optional[module] @@ -945,6 +1029,8 @@ class SECoPTriggerableDevice(SECoPReadableDevice, Stoppable): interface class "Triggerable" """ + hinted_signals: list[str] = ["value"] + def __init__( self, sri: str = "", # SECoP resource identifier host:port:optional[module] @@ -973,6 +1059,8 @@ def __init__( class SECoPWritableDevice(SECoPReadableDevice): + hinted_signals: list[str] = ["target", "value"] + pass @@ -982,6 +1070,8 @@ class SECoPMoveableDevice(SECoPReadableDevice, Locatable, Stoppable): interface class "Drivable" """ + hinted_signals: list[str] = ["target", "value"] + def __init__( self, sri: str = "", # SECoP resource identifier host:port:optional[module] diff --git a/src/secop_ophyd/SECoPSignal.py b/src/secop_ophyd/SECoPSignal.py index 8b052e7..019f3e5 100644 --- a/src/secop_ophyd/SECoPSignal.py +++ b/src/secop_ophyd/SECoPSignal.py @@ -330,15 +330,16 @@ async def _init_parameter(self): """Initialize as a parameter signal.""" self._param_description: dict = self._get_param_desc() - match self._param_description.get("_signal_format", None): - case "HINTED_SIGNAL": - self.format = StandardReadableFormat.HINTED_SIGNAL - case "HINTED_UNCACHED_SIGNAL": - self.format = StandardReadableFormat.HINTED_UNCACHED_SIGNAL - case "UNCACHED_SIGNAL": - self.format = StandardReadableFormat.UNCACHED_SIGNAL - case _: - self.format = StandardReadableFormat.CONFIG_SIGNAL + if not hasattr(self, "format"): + match self._param_description.get("_signal_format", None): + case "HINTED_SIGNAL": + self.format = StandardReadableFormat.HINTED_SIGNAL + case "HINTED_UNCACHED_SIGNAL": + self.format = StandardReadableFormat.HINTED_UNCACHED_SIGNAL + case "UNCACHED_SIGNAL": + self.format = StandardReadableFormat.UNCACHED_SIGNAL + case _: + self.format = StandardReadableFormat.CONFIG_SIGNAL # Root datainfo or memberinfo for nested datatypes self.datainfo: dict = self._param_description["datainfo"] diff --git a/src/secop_ophyd/templates/generated_classes.py.jinja2 b/src/secop_ophyd/templates/generated_classes.py.jinja2 index 7f7819c..2c661e3 100644 --- a/src/secop_ophyd/templates/generated_classes.py.jinja2 +++ b/src/secop_ophyd/templates/generated_classes.py.jinja2 @@ -32,8 +32,13 @@ class {{ module_cls.name }}({{ module_cls.bases | join(', ') }}): # Module Properties {%- for attr in properties %} - {{ attr.name }}: {{ attr.type }} -{%- if attr.type_param %}[{{ attr.type_param }}]{% endif %} +{%- if attr.path_annotation and attr.format_annotation %} + {{ attr.name }}: A[{{ attr.type }}{% if attr.type_param %}[{{ attr.type_param }}]{% endif %}, {{ attr.path_annotation }}, {{ attr.format_annotation }}] +{%- elif attr.path_annotation %} + {{ attr.name }}: A[{{ attr.type }}{% if attr.type_param %}[{{ attr.type_param }}]{% endif %}, {{ attr.path_annotation }}] +{%- else %} + {{ attr.name }}: {{ attr.type }}{% if attr.type_param %}[{{ attr.type_param }}]{% endif %} +{%- endif %} {%- if attr.description %} # {{ attr.description }}{% endif %} {%- endfor %} {%- endif %} @@ -41,8 +46,13 @@ class {{ module_cls.name }}({{ module_cls.bases | join(', ') }}): # Module Parameters {%- for attr in parameters %} - {{ attr.name }}: {{ attr.type }} -{%- if attr.type_param %}[{{ attr.type_param }}]{% endif %} +{%- if attr.path_annotation and attr.format_annotation %} + {{ attr.name }}: A[{{ attr.type }}{% if attr.type_param %}[{{ attr.type_param }}]{% endif %}, {{ attr.path_annotation }}, {{ attr.format_annotation }}] +{%- elif attr.path_annotation %} + {{ attr.name }}: A[{{ attr.type }}{% if attr.type_param %}[{{ attr.type_param }}]{% endif %}, {{ attr.path_annotation }}] +{%- else %} + {{ attr.name }}: {{ attr.type }}{% if attr.type_param %}[{{ attr.type_param }}]{% endif %} +{%- endif %} {%- if attr.description %} # {{ attr.description }}{% endif %} {%- endfor %} {%- endif %} @@ -69,8 +79,7 @@ class {{ node_cls.name }}({{ node_cls.bases | join(', ') }}): # Module Devices {%- for attr in modules %} - {{ attr.name }}: {{ attr.type }} -{%- if attr.type_param %}[{{ attr.type_param }}]{% endif %} + {{ attr.name }}: {{ attr.type }}{% if attr.type_param %}[{{ attr.type_param }}]{% endif %} {%- if attr.description %} # {{ attr.description }}{% endif %} {%- endfor %} {%- endif %} @@ -78,8 +87,13 @@ class {{ node_cls.name }}({{ node_cls.bases | join(', ') }}): # Node Properties {%- for attr in properties %} - {{ attr.name }}: {{ attr.type }} -{%- if attr.type_param %}[{{ attr.type_param }}]{% endif %} +{%- if attr.path_annotation and attr.format_annotation %} + {{ attr.name }}: A[{{ attr.type }}{% if attr.type_param %}[{{ attr.type_param }}]{% endif %}, {{ attr.path_annotation }}, {{ attr.format_annotation }}] +{%- elif attr.path_annotation %} + {{ attr.name }}: A[{{ attr.type }}{% if attr.type_param %}[{{ attr.type_param }}]{% endif %}, {{ attr.path_annotation }}] +{%- else %} + {{ attr.name }}: {{ attr.type }}{% if attr.type_param %}[{{ attr.type_param }}]{% endif %} +{%- endif %} {%- if attr.description %} # {{ attr.description }}{% endif %} {%- endfor %} {%- endif %} diff --git a/tests/test_enum_merging.py b/tests/test_enum_merging.py index a4aaee3..7b1edaf 100644 --- a/tests/test_enum_merging.py +++ b/tests/test_enum_merging.py @@ -143,7 +143,17 @@ def test_same_class_different_instances(): gen_code.add_mod_class( module_cls="Test_Enum", bases=["Device"], - attrs=[("gas_type", "SignalRW", "Test_EnumGas_typeEnum", None, "parameter")], + attrs=[ + ( + "gas_type", + "SignalRW", + "Test_EnumGas_typeEnum", + None, + "parameter", + "balh:gas_type", + None, + ) + ], cmd_plans=[], enum_classes=[enum1], ) @@ -152,7 +162,17 @@ def test_same_class_different_instances(): gen_code.add_mod_class( module_cls="Test_Enum", bases=["Device"], - attrs=[("gas_type", "SignalRW", "Test_EnumGas_typeEnum", None, "parameter")], + attrs=[ + ( + "gas_type", + "SignalRW", + "Test_EnumGas_typeEnum", + None, + "parameter", + "balh:gas_type", + None, + ) + ], cmd_plans=[], enum_classes=[enum2], ) diff --git a/tests/test_gencode_refactor.py b/tests/test_gencode_refactor.py index d3e03d8..3c9206b 100644 --- a/tests/test_gencode_refactor.py +++ b/tests/test_gencode_refactor.py @@ -3,6 +3,8 @@ import sys from pathlib import Path +from ophyd_async.core import init_devices + from secop_ophyd.GenNodeCode import ( Attribute, GenNodeCode, @@ -45,9 +47,17 @@ def sample_method(self, value: int) -> str: module_cls="TestModule", bases=["Device"], attrs=[ - ("temperature", "SignalR", "float", None, "parameter"), - ("pressure", "SignalR", "float", None, "parameter"), - ("count", "SignalRW", "int", None, "parameter"), + ( + "temperature", + "SignalR", + "float", + None, + "parameter", + "test:temperature", + None, + ), + ("pressure", "SignalR", "float", None, "parameter", "test:pressure", None), + ("count", "SignalRW", "int", None, "parameter", "test:count", None), ], cmd_plans=[method], description="Test module class", @@ -58,8 +68,8 @@ def sample_method(self, value: int) -> str: node_cls="TestNode", bases=["Device"], attrs=[ - ("module1", "TestModule", None, None, "module"), - ("status", "SignalR", "str", None, "property"), + ("module1", "TestModule", None, None, "module", None), + ("status", "SignalR", "str", None, "property", "status"), ], description="Test node class", ) @@ -158,10 +168,42 @@ def type2_command(self, mode: str) -> str: module_cls="Type1", bases=["Device"], attrs=[ - ("description", "SignalR", "str", None, "property"), - ("interface_classes", "SignalR", "list", None, "property"), - ("temperature", "SignalR", "float", None, "parameter"), - ("setpoint", "SignalRW", "float", None, "parameter"), + ( + "description", + "SignalR", + "str", + None, + "property", + "type1:description", + None, + ), + ( + "interface_classes", + "SignalR", + "list", + None, + "property", + "type1:interface_classes", + None, + ), + ( + "temperature", + "SignalR", + "float", + None, + "parameter", + "type1:temperature", + None, + ), + ( + "setpoint", + "SignalRW", + "float", + None, + "parameter", + "type1:setpoint", + None, + ), ], cmd_plans=[method_type1], description="Type1 module - shared between nodes", @@ -172,9 +214,17 @@ def type2_command(self, mode: str) -> str: module_cls="Type2", bases=["Device"], attrs=[ - ("implementation", "SignalR", "str", None, "property"), - ("pressure", "SignalR", "float", None, "parameter"), - ("mode", "SignalRW", "str", None, "parameter"), + ( + "implementation", + "SignalR", + "str", + None, + "property", + "type2:implementation", + None, + ), + ("pressure", "SignalR", "float", None, "parameter", "type2:pressure", None), + ("mode", "SignalRW", "str", None, "parameter", "type2:mode", None), ], cmd_plans=[method_type2], description="Type2 module - only in nodeA", @@ -185,9 +235,9 @@ def type2_command(self, mode: str) -> str: node_cls="NodeA", bases=["Device"], attrs=[ - ("modA", "Type1", None, None, "module"), - ("modB", "Type2", None, None, "module"), - ("status", "SignalR", "str", None, "property"), + ("modA", "Type1", None, None, "module", None), + ("modB", "Type2", None, None, "module", None), + ("status", "SignalR", "str", None, "property", "status"), ], description="NodeA with Type1 and Type2 modules", ) @@ -231,10 +281,42 @@ def type3_command(self, count: int) -> int: module_cls="Type1", bases=["Device"], attrs=[ - ("description", "SignalR", "str", None, "property"), - ("interface_classes", "SignalR", "list", None, "property"), - ("temperature", "SignalR", "float", None, "parameter"), - ("setpoint", "SignalRW", "float", None, "parameter"), + ( + "description", + "SignalR", + "str", + None, + "property", + "type1:description", + None, + ), + ( + "interface_classes", + "SignalR", + "list", + None, + "property", + "type1:interface_classes", + None, + ), + ( + "temperature", + "SignalR", + "float", + None, + "parameter", + "type1:temperature", + None, + ), + ( + "setpoint", + "SignalRW", + "float", + None, + "parameter", + "type1:setpoint", + None, + ), ], cmd_plans=[method_type1], description="Type1 module - shared between nodes", @@ -245,9 +327,9 @@ def type3_command(self, count: int) -> int: module_cls="Type3", bases=["Device"], attrs=[ - ("group", "SignalR", "str", None, "property"), - ("count", "SignalRW", "int", None, "parameter"), - ("enabled", "SignalR", "bool", None, "parameter"), + ("group", "SignalR", "str", None, "property", "type3:group", None), + ("count", "SignalRW", "int", None, "parameter", "type3:count", None), + ("enabled", "SignalR", "bool", None, "parameter", "type3:enabled", None), ], cmd_plans=[method_type3], description="Type3 module - only in nodeB", @@ -258,9 +340,9 @@ def type3_command(self, count: int) -> int: node_cls="NodeB", bases=["Device"], attrs=[ - ("modA", "Type1", None, None, "module"), - ("modB", "Type3", None, None, "module"), - ("name", "SignalR", "str", None, "property"), + ("modA", "Type1", None, None, "module", None), + ("modB", "Type3", None, None, "module", None), + ("name", "SignalR", "str", None, "property", "name"), ], description="NodeB with Type1 and Type3 modules", ) @@ -301,13 +383,62 @@ def type3_command(self, count: int) -> int: assert "# Module Parameters" in code2 -def test_gen_cryo_node( +async def test_gen_cryo_node( clean_generated_file, cryo_sim, cryo_node_no_re: SECoPNodeDevice ): """Test generating code for a real SECoP node.""" cryo_node_no_re.class_from_instance(clean_generated_file) + from tests.testgen.genNodeClass import Cryo_7_frappy_demo + + async with init_devices(): + cryo_gen_code = Cryo_7_frappy_demo(sec_node_uri="localhost:10769") + + cryo_val = await cryo_gen_code.read() + + # target and value shoule be present in readback, since they are read signals with + # HINTED Format --> this is tested to verify that the correct annotations are + # generated and interpreted in the generated code + val_name = cryo_gen_code.cryo.value.name + target_name = cryo_gen_code.cryo.target.name + read_val = cryo_val[val_name].get("value") + read_target = cryo_val[target_name].get("value") + + print(cryo_val) + + assert read_val is not None + assert read_val > 5 + + assert read_target is not None + assert read_target == 10 + + +async def test_gen_cryo_status_not_in_cfg( + clean_generated_file, cryo_sim, cryo_node_no_re: SECoPNodeDevice +): + """Test that Status signal is not marked as configuration signal but is still + instantiated.""" + + cryo_node_no_re.class_from_instance(clean_generated_file) + + cryo_cfg = await cryo_node_no_re.cryo.read_configuration() + + stat_name = cryo_node_no_re.cryo.status.name + + print(cryo_cfg[stat_name]) + + from tests.testgen.genNodeClass import Cryo_7_frappy_demo + + async with init_devices(): + cryo_gen_code = Cryo_7_frappy_demo(sec_node_uri="localhost:10769") + + cryo_cfg = await cryo_gen_code.cryo.read_configuration() + + stat_name = cryo_gen_code.cryo.status.name + + print(cryo_cfg[stat_name]) + async def test_gen_real_node( clean_generated_file, From 9854e62a2662d75806fd33916f38b2d408e53f0b Mon Sep 17 00:00:00 2001 From: Peter Braun Date: Tue, 10 Feb 2026 15:40:33 +0100 Subject: [PATCH 10/18] moved proppath and parampath to proper device annotation protocol, ajusted tests, subsequent nodegeneration seems to work --- src/secop_ophyd/GenNodeCode.py | 278 +++++++++++++++----------------- src/secop_ophyd/SECoPDevices.py | 72 ++++++--- tests/test_commands.py | 43 ++--- tests/test_gencode_refactor.py | 213 ++++++++++++++++-------- 4 files changed, 344 insertions(+), 262 deletions(-) diff --git a/src/secop_ophyd/GenNodeCode.py b/src/secop_ophyd/GenNodeCode.py index d17b9e1..57329ba 100644 --- a/src/secop_ophyd/GenNodeCode.py +++ b/src/secop_ophyd/GenNodeCode.py @@ -11,14 +11,16 @@ from logging import Logger from pathlib import Path from types import ModuleType +from typing import get_type_hints import autoflake import black from frappy.client import get_datatype from frappy.datatypes import DataType from jinja2 import Environment, PackageLoader, select_autoescape -from ophyd_async.core import SignalR, SignalRW +from ophyd_async.core import Signal, SignalR, SignalRW, StandardReadable from ophyd_async.core import StandardReadableFormat as Format +from ophyd_async.core._utils import get_origin_class from secop_ophyd.SECoPDevices import ( IGNORED_PROPS, @@ -169,6 +171,14 @@ def __init__(self, path: str | None = None, log=None): self.add_import("numpy") self.add_import("secop_ophyd.SECoPDevices", "ParamPath") self.add_import("secop_ophyd.SECoPDevices", "PropPath") + # Add necessary Device imports + self.add_import("secop_ophyd.SECoPDevices", "SECoPDevice") + self.add_import("secop_ophyd.SECoPDevices", "SECoPCommunicatorDevice") + self.add_import("secop_ophyd.SECoPDevices", "SECoPReadableDevice") + self.add_import("secop_ophyd.SECoPDevices", "SECoPTriggerableDevice") + self.add_import("secop_ophyd.SECoPDevices", "SECoPWritableDevice") + self.add_import("secop_ophyd.SECoPDevices", "SECoPMoveableDevice") + self.add_import("secop_ophyd.SECoPDevices", "SECoPNodeDevice") # Setup Jinja2 environment self.jinja_env = Environment( @@ -224,22 +234,16 @@ def _load_existing_module(self): def _parse_existing_module(self): """Parse an existing generated module to extract class definitions.""" # Prevent circular import - from ophyd_async.core import Device - - try: - from secop_ophyd.SECoPDevices import ( - SECoPCommunicatorDevice, - SECoPDevice, - SECoPMoveableDevice, - SECoPNodeDevice, - SECoPReadableDevice, - SECoPTriggerableDevice, - SECoPWritableDevice, - ) - has_secop_devices = True - except ImportError: - has_secop_devices = False + from secop_ophyd.SECoPDevices import ( + SECoPCommunicatorDevice, + SECoPDevice, + SECoPMoveableDevice, + SECoPNodeDevice, + SECoPReadableDevice, + SECoPTriggerableDevice, + SECoPWritableDevice, + ) if self.node_mod is None: return @@ -251,67 +255,26 @@ def _parse_existing_module(self): module = class_obj.__module__ if module == self.ModName: - # Parse classes defined in the generated module - parsed = False - # Try SECoP-specific device types first if available - if has_secop_devices: - if issubclass(class_obj, SECoPNodeDevice): - self._parse_node_class(class_symbol, class_obj) - parsed = True - elif issubclass( - class_obj, - ( - SECoPDevice, - SECoPCommunicatorDevice, - SECoPMoveableDevice, - SECoPReadableDevice, - SECoPWritableDevice, - SECoPTriggerableDevice, - ), - ): - self._parse_module_class(class_symbol, class_obj) - parsed = True - - # Fall back to generic Device parsing - if not parsed and issubclass(class_obj, Device): - # Determine if it's a node or module class by checking if it has - # attributes that are themselves Device subclasses - is_node = self._is_node_class(class_obj) - if is_node: - self._parse_node_class(class_symbol, class_obj) - else: - self._parse_module_class(class_symbol, class_obj) - - def _is_node_class(self, class_obj: type) -> bool: - """Determine if a Device class is a node (contains other Device classes). - - Args: - class_obj: The class to check - Returns: - True if this appears to be a node class, False if it's a module class - """ - from ophyd_async.core import Device - - # Check annotations for Device subclass attributes - if hasattr(class_obj, "__annotations__"): - for attr_name, attr_type in class_obj.__annotations__.items(): - # Check if the type is a class and a Device subclass - if inspect.isclass(attr_type) and issubclass(attr_type, Device): - return True - # Handle string annotations and generic types - type_str = str(attr_type) - # If any annotation refers to a custom class (not Signal*), it's likely - # a node - if "Signal" not in type_str and not type_str.startswith(" dict[str, str]: + """Extract trailing comment descriptions from class source code. + + Args: + class_obj: The class object to extract descriptions from + + Returns: + Dictionary mapping attribute names to their descriptions + """ + descriptions = {} + try: + source = inspect.getsource(class_obj) + for line in source.split("\n"): + # Skip lines without comments + if "#" not in line: + continue + + # Extract the part before # (attribute assignment) + code_part, comment_part = line.split("#", 1) + + # Find attribute name (e.g., "count" from "count: A[SignalRW[int],...") + match = re.match(r"\s*(\w+)\s*:", code_part) + if match: + attr_name = match.group(1) + description = comment_part.strip() + if description: + descriptions[attr_name] = description + except Exception as e: + if self.log: + self.log.debug(f"Could not extract descriptions from source: {e}") + + return descriptions + + def _get_attr_list(self, class_obj: type) -> list[Attribute]: + hints = get_type_hints(class_obj) + # Get hints with Annotated for wrapping signals and backends + extra_hints = get_type_hints(class_obj, include_extras=True) + + # Extract description comments from source code + descriptions = self._extract_descriptions_from_source(class_obj) + + attrs = [] + + for attr_name, annotation in hints.items(): + extras = getattr(extra_hints[attr_name], "__metadata__", ()) + + origin = get_origin_class(annotation) + + if issubclass(origin, Signal): + + sig_type = annotation.__args__[0] + # Get the module name + module = sig_type.__module__ + + type_param = ( + sig_type.__name__ + if module == "builtins" + else f"{module}.{sig_type.__name__}" + ) + path_annotation = next( + (e for e in extras if isinstance(e, (ParamPath, PropPath))), None + ) + category = ( + "property" if isinstance(path_annotation, PropPath) else "parameter" + ) + format_annotation = next( + (e for e in extras if isinstance(e, Format)), None + ) + + # Get description from comments + description = descriptions.get(attr_name) + + attrs.append( + Attribute( + name=attr_name, + type=origin.__name__, + type_param=type_param, + description=description, + category=category, + path_annotation=str(path_annotation), + format_annotation=format_annotation, + ) + ) + if issubclass(origin, StandardReadable): + attrs.append( + Attribute(name=attr_name, type=origin.__name__, category="module") + ) + + return attrs + def _parse_module_class(self, class_symbol: str, class_obj: type): """Parse a module class from existing module. @@ -355,20 +398,8 @@ def _parse_module_class(self, class_symbol: str, class_obj: type): class_obj: The class object """ # Extract attributes from source code to get proper type annotations - source = inspect.getsource(class_obj) - attrs = self._extract_attrs_from_source(source) - attributes = [] - for attr_name, attr_type_str in attrs: - # Parse type and type parameter from string like "SignalR[float]" - if "[" in attr_type_str and "]" in attr_type_str: - base_type = attr_type_str.split("[")[0] - type_param = attr_type_str.split("[")[1].rstrip("]") - attributes.append( - Attribute(name=attr_name, type=base_type, type_param=type_param) - ) - else: - attributes.append(Attribute(name=attr_name, type=attr_type_str)) + attrs = self._get_attr_list(class_obj) methods = [] for method_name, method in class_obj.__dict__.items(): @@ -387,53 +418,12 @@ def _parse_module_class(self, class_symbol: str, class_obj: type): mod_cls = ModuleClass( name=class_symbol, bases=bases, - attributes=attributes, + attributes=attrs, methods=methods, description=description, ) self.module_classes.append(mod_cls) - def _extract_attrs_from_source(self, source: str) -> list[tuple[str, str]]: - """Extract attributes from class source code. - - Args: - source: Source code of the class - - Returns: - List of (name, type) tuples - """ - source_list: list[str] = source.split("\n") - # Remove first and last line - if len(source_list) > 1: - source_list.pop(0) - source_list.pop() - - # Extract attributes (skip methods, decorators, docstrings) - attrs: list[tuple[str, str]] = [] - for line in source_list: - stripped = line.strip() - # Skip empty lines, decorators, method definitions, and docstrings - if ( - not stripped - or stripped.startswith("@") - or stripped.startswith("def ") - or stripped.startswith('"""') - or stripped.startswith("'''") - ): - continue - - # Check if it's an attribute annotation (has : but not inside parentheses) - if ":" in stripped and "(" not in stripped.split(":", 1)[0]: - # Remove spaces for consistent parsing - line_no_space = line.replace(" ", "") - parts = line_no_space.split(":", maxsplit=1) - if len(parts) == 2: - attr_name = parts[0] - attr_type = parts[1] - attrs.append((attr_name, attr_type)) - - return attrs - def _extract_method_description(self, method_source: str) -> str: """Extract description from method docstring. diff --git a/src/secop_ophyd/SECoPDevices.py b/src/secop_ophyd/SECoPDevices.py index 9267c3f..260c191 100644 --- a/src/secop_ophyd/SECoPDevices.py +++ b/src/secop_ophyd/SECoPDevices.py @@ -157,6 +157,21 @@ def __repr__(self) -> str: """Return repr suitable for code generation in annotations.""" return f'ParamPath("{self.module}:{self.parameter}")' + def __call__(self, parent: Device, child: Device): + if not isinstance(child, Signal): + return + + backend = child._connector.backend + + if not isinstance(backend, SECoPBackend): + return + backend.attribute_type = AttributeType.PARAMETER + + backend._module_name = self.module + backend._attribute_name = self.parameter + backend._secclient = parent._client + backend.path_str = self.module + ":" + self.parameter + @dataclass(init=False) class PropPath: @@ -184,6 +199,26 @@ def __repr__(self) -> str: return f'PropPath("{self.property}")' return f'PropPath("{self.module}:{self.property}")' + def __call__(self, parent: Device, child: Device): + if not isinstance(child, Signal): + return + + backend = child._connector.backend + + if not isinstance(backend, SECoPBackend): + return + + backend.attribute_type = AttributeType.PROPERTY + + backend._module_name = self.module + backend._attribute_name = self.property + backend._secclient = parent._client + + if self.module: + backend.path_str = self.module + ":" + self.property + else: + backend.path_str = self.property + class SECoPDeviceConnector(DeviceConnector): @@ -236,9 +271,7 @@ def create_children_from_annotations(self, device: Device): ), ) - for backend, annotations in self.filler.create_signals_from_annotations(): - self.fill_backend_with_path(backend, annotations) - + list(self.filler.create_signals_from_annotations()) list(self.filler.create_devices_from_annotations(filled=False)) self.filler.check_created() @@ -251,25 +284,6 @@ def fill_backend_with_path(self, backend: SECoPBackend, annotations: list[Any]): if isinstance(annotation, StandardReadableFormat): backend.format = annotation - elif isinstance(annotation, ParamPath): - backend.attribute_type = AttributeType.PARAMETER - - backend._module_name = annotation.module - backend._attribute_name = annotation.parameter - backend._secclient = self.client - backend.path_str = annotation.module + ":" + annotation.parameter - - elif isinstance(annotation, PropPath): - backend.attribute_type = AttributeType.PROPERTY - - backend._module_name = annotation.module - backend._attribute_name = annotation.property - backend._secclient = self.client - - if annotation.module: - backend.path_str = annotation.module + ":" + annotation.property - else: - backend.path_str = annotation.property else: unhandled.append(annotation) @@ -769,9 +783,17 @@ def assert_device_is_signalr(device: Device) -> SignalR: continue backend = child._connector.backend + if not isinstance(backend, SECoPBackend): continue + param_name = backend.path_str.split(":")[-1] + if param_name == "status": + # status signals should not be assigned a format, + # but a SignalR children (this can be removed once tiled can + # hanlde composite dtypes) + continue + # child is a Signal with SECoPParamBackend # check if signal already has a format assigned @@ -1136,6 +1158,9 @@ async def _move(self, new_target): stat_code = current_stat["f0"] if self._stopped is True: + self.logger.info( + f"Move of {self.name} to {new_target} was stopped STOPPED" + ) break # Error State or DISABLED @@ -1152,7 +1177,7 @@ async def _move(self, new_target): # TODO other status transitions if not self._success: - raise RuntimeError("Module was stopped") + self.logger.error(f"Move of {self.name} to {new_target} was not successful") async def stop(self, success=True): """Calls stop command on the SEC Node module @@ -1167,6 +1192,7 @@ async def stop(self, success=True): if not success: self.logger.info(f"Stopping {self.name} success={success}") + print(f"Stopping {self.name} success={success}") await self._client.exec_command(self.module, "stop") self._stopped = True diff --git a/tests/test_commands.py b/tests/test_commands.py index b67db60..abf2925 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -16,43 +16,34 @@ async def test_stop_cmd(cryo_sim, cryo_node_no_re: SECoPNodeDevice): cryo: SECoPMoveableDevice = cryo_node_no_re.cryo await cryo.window.set(5) + await cryo.tolerance.set(1) + await cryo.ramp.set(20) stat = cryo.set(15) await asyncio.sleep(3) + # essentially a NOOP (stops are only passed through to SECoP on success=False) await cryo.stop(success=True) + assert cryo._stopped is False, "Move should not be stopped when success=True" + assert ( + cryo._success is True + ), "Move should be marked as successful when success=True" + assert ( + not stat.done + ), "Move should still be in progress after stop with success=True" + + # move is still going on + await cryo.stop(success=False) + assert cryo._stopped is True, "Move should be stopped when success=False" + assert ( + cryo._success is False + ), "Move should be marked as unsuccessful when success=False" await stat - assert cryo._stopped is False - - -async def test_stop_no_sucess_cmd(cryo_sim, cryo_node_no_re: SECoPNodeDevice): - cryo: SECoPMoveableDevice = cryo_node_no_re.cryo - - await cryo.window.set(5) - await cryo.tolerance.set(1) - await cryo.ramp.set(20) - - stat = cryo.set(15) - - rt_error = False - - await asyncio.sleep(3) - - try: - await cryo.stop(False) - await stat - - except RuntimeError: - rt_error = True - - assert cryo._stopped is True - assert rt_error is True - async def test_struct_inp_cmd(nested_struct_sim, nested_node_no_re: SECoPNodeDevice): test_cmd: SECoPCMDDevice = nested_node_no_re.ophy_struct.test_cmd_CMD diff --git a/tests/test_gencode_refactor.py b/tests/test_gencode_refactor.py index 3c9206b..fe4df96 100644 --- a/tests/test_gencode_refactor.py +++ b/tests/test_gencode_refactor.py @@ -3,7 +3,7 @@ import sys from pathlib import Path -from ophyd_async.core import init_devices +from ophyd_async.core import SignalR, init_devices from secop_ophyd.GenNodeCode import ( Attribute, @@ -12,7 +12,7 @@ ModuleClass, NodeClass, ) -from secop_ophyd.SECoPDevices import SECoPNodeDevice +from secop_ophyd.SECoPDevices import ParamPath, PropPath, SECoPNodeDevice # Add src to path sys.path.insert(0, str(Path(__file__).parent.parent / "src")) @@ -45,7 +45,7 @@ def sample_method(self, value: int) -> str: # Add a module class gen_code.add_mod_class( module_cls="TestModule", - bases=["Device"], + bases=["SECoPDevice"], attrs=[ ( "temperature", @@ -53,11 +53,27 @@ def sample_method(self, value: int) -> str: "float", None, "parameter", - "test:temperature", + "ParamPath('test:temperature')", + None, + ), + ( + "pressure", + "SignalR", + "float", + None, + "parameter", + "ParamPath('test:pressure')", + None, + ), + ( + "count", + "SignalRW", + "int", + None, + "parameter", + "ParamPath('test:count')", None, ), - ("pressure", "SignalR", "float", None, "parameter", "test:pressure", None), - ("count", "SignalRW", "int", None, "parameter", "test:count", None), ], cmd_plans=[method], description="Test module class", @@ -66,10 +82,10 @@ def sample_method(self, value: int) -> str: # Add a node class gen_code.add_node_class( node_cls="TestNode", - bases=["Device"], + bases=["SECoPNodeDevice"], attrs=[ ("module1", "TestModule", None, None, "module", None), - ("status", "SignalR", "str", None, "property", "status"), + ("status", "SignalR", "str", None, "property", "PropPath('status')"), ], description="Test node class", ) @@ -87,12 +103,12 @@ def sample_method(self, value: int) -> str: # Verify code contains expected elements assert "from abc import abstractmethod" in code - assert "class TestModule(Device):" in code - assert "temperature: SignalR[float]" in code - assert "count: SignalRW[int]" in code - assert "class TestNode(Device):" in code + assert "class TestModule(SECoPDevice):" in code + assert 'temperature: A[SignalR[float], ParamPath("test:temperature")]' in code + assert 'count: A[SignalRW[int], ParamPath("test:count")]' in code + assert "class TestNode(SECoPNodeDevice):" in code assert "module1: TestModule" in code - assert "status: SignalR[str]" in code + assert 'status: A[SignalR[str], PropPath("status")]' in code assert "def sample_command" in code print("\n✓ All basic tests passed!") @@ -130,17 +146,12 @@ def test_subsequent_node_generation(clean_generated_file): - Type1 should appear only once in the final file (not duplicated) - All classes (Type1, Type2, Type3, NodeA, NodeB) are in the final file """ - print("\nTesting subsequent node generation with file appending...") - print(f"Using output directory: {clean_generated_file}") from inspect import signature # ===== STEP 1: Generate and write first node (NodeA) ===== - print("\n--- Step 1: Generating NodeA ---") - gen_code1 = GenNodeCode(path=str(clean_generated_file), log=None) - # Add necessary imports - gen_code1.add_import("ophyd_async.core", "Device") + gen_code1 = GenNodeCode(path=str(clean_generated_file), log=None) # Create sample methods def type1_command(self, value: float) -> float: @@ -166,7 +177,7 @@ def type2_command(self, mode: str) -> str: # Add module class Type1 (will be shared) gen_code1.add_mod_class( module_cls="Type1", - bases=["Device"], + bases=["SECoPDevice"], attrs=[ ( "description", @@ -174,25 +185,25 @@ def type2_command(self, mode: str) -> str: "str", None, "property", - "type1:description", + str(PropPath("type1:description")), None, ), ( "interface_classes", "SignalR", - "list", + "int", None, "property", - "type1:interface_classes", + str(PropPath("type1:interface_classes")), None, ), ( "temperature", "SignalR", "float", - None, + "this has to be in the final output", "parameter", - "type1:temperature", + str(ParamPath("type1:temperature")), None, ), ( @@ -201,7 +212,7 @@ def type2_command(self, mode: str) -> str: "float", None, "parameter", - "type1:setpoint", + str(ParamPath("type1:setpoint")), None, ), ], @@ -212,7 +223,7 @@ def type2_command(self, mode: str) -> str: # Add module class Type2 (only in nodeA) gen_code1.add_mod_class( module_cls="Type2", - bases=["Device"], + bases=["SECoPDevice"], attrs=[ ( "implementation", @@ -220,11 +231,27 @@ def type2_command(self, mode: str) -> str: "str", None, "property", - "type2:implementation", + str(PropPath("type2:implementation")), + None, + ), + ( + "pressure", + "SignalR", + "float", + None, + "parameter", + str(ParamPath("type2:pressure")), + None, + ), + ( + "mode", + "SignalRW", + "str", + None, + "parameter", + str(ParamPath("type2:mode")), None, ), - ("pressure", "SignalR", "float", None, "parameter", "type2:pressure", None), - ("mode", "SignalRW", "str", None, "parameter", "type2:mode", None), ], cmd_plans=[method_type2], description="Type2 module - only in nodeA", @@ -233,11 +260,11 @@ def type2_command(self, mode: str) -> str: # Add nodeA gen_code1.add_node_class( node_cls="NodeA", - bases=["Device"], + bases=["SECoPNodeDevice"], attrs=[ ("modA", "Type1", None, None, "module", None), ("modB", "Type2", None, None, "module", None), - ("status", "SignalR", "str", None, "property", "status"), + ("status", "SignalR", "str", None, "property", str(PropPath("status"))), ], description="NodeA with Type1 and Type2 modules", ) @@ -246,24 +273,20 @@ def type2_command(self, mode: str) -> str: code1 = gen_code1.generate_code() gen_code1.write_gen_node_class_file() - print("\n" + "=" * 60) - print("First Generation (NodeA):") - print("=" * 60) - print(code1) - # Verify first generation - assert "class Type1(Device):" in code1 - assert "class Type2(Device):" in code1 - assert "class NodeA(Device):" in code1 + assert "class Type1(SECoPDevice):" in code1 + assert "class Type2(SECoPDevice):" in code1 + assert "class NodeA(SECoPNodeDevice):" in code1 assert "modA: Type1" in code1 assert "modB: Type2" in code1 # ===== STEP 2: Load existing file and add second node (NodeB) ===== - print("\n--- Step 2: Loading file and adding NodeB ---") + gen_code2 = GenNodeCode(path=str(clean_generated_file), log=None) # Add necessary imports again - gen_code2.add_import("ophyd_async.core", "Device") + gen_code2.add_import("secop_ophyd.SECoPDevices", "SECoPDevice") + gen_code2.add_import("secop_ophyd.SECoPDevices", "SECoPNodeDevice") # Create method for Type3 def type3_command(self, count: int) -> int: @@ -279,7 +302,7 @@ def type3_command(self, count: int) -> int: # Add Type1 again - GenNodeCode should detect it already exists gen_code2.add_mod_class( module_cls="Type1", - bases=["Device"], + bases=["SECoPDevice"], attrs=[ ( "description", @@ -287,7 +310,7 @@ def type3_command(self, count: int) -> int: "str", None, "property", - "type1:description", + str(PropPath("type1:description")), None, ), ( @@ -296,7 +319,7 @@ def type3_command(self, count: int) -> int: "list", None, "property", - "type1:interface_classes", + str(PropPath("type1:interface_classes")), None, ), ( @@ -305,7 +328,7 @@ def type3_command(self, count: int) -> int: "float", None, "parameter", - "type1:temperature", + str(ParamPath("type1:temperature")), None, ), ( @@ -314,7 +337,7 @@ def type3_command(self, count: int) -> int: "float", None, "parameter", - "type1:setpoint", + str(ParamPath("type1:setpoint")), None, ), ], @@ -325,11 +348,35 @@ def type3_command(self, count: int) -> int: # Add module class Type3 (only in nodeB) gen_code2.add_mod_class( module_cls="Type3", - bases=["Device"], + bases=["SECoPDevice"], attrs=[ - ("group", "SignalR", "str", None, "property", "type3:group", None), - ("count", "SignalRW", "int", None, "parameter", "type3:count", None), - ("enabled", "SignalR", "bool", None, "parameter", "type3:enabled", None), + ( + "group", + "SignalR", + "str", + None, + "property", + str(PropPath("type3:group")), + None, + ), + ( + "count", + "SignalRW", + "int", + "this is a description", + "parameter", + str(ParamPath("type3:count")), + None, + ), + ( + "enabled", + "SignalR", + "bool", + None, + "parameter", + str(ParamPath("type3:enabled")), + None, + ), ], cmd_plans=[method_type3], description="Type3 module - only in nodeB", @@ -338,11 +385,11 @@ def type3_command(self, count: int) -> int: # Add nodeB gen_code2.add_node_class( node_cls="NodeB", - bases=["Device"], + bases=["SECoPNodeDevice"], attrs=[ ("modA", "Type1", None, None, "module", None), ("modB", "Type3", None, None, "module", None), - ("name", "SignalR", "str", None, "property", "name"), + ("name", "SignalR", "str", None, "property", str(PropPath("name"))), ], description="NodeB with Type1 and Type3 modules", ) @@ -351,27 +398,22 @@ def type3_command(self, count: int) -> int: code2 = gen_code2.generate_code() gen_code2.write_gen_node_class_file() - print("\n" + "=" * 60) - print("Second Generation (NodeA + NodeB):") - print("=" * 60) - print(code2) - # ===== VERIFICATION ===== # Verify that Type1 appears only once in the final code - type1_count = code2.count("class Type1(Device):") - print(f"\nType1 class count: {type1_count}") + type1_count = code2.count("class Type1(SECoPDevice):") + assert ( type1_count == 1 ), f"Type1 should appear exactly once, but appears {type1_count} times" # Verify all module classes are present - assert "class Type1(Device):" in code2 - assert "class Type2(Device):" in code2 - assert "class Type3(Device):" in code2 + assert "class Type1(SECoPDevice):" in code2 + assert "class Type2(SECoPDevice):" in code2 + assert "class Type3(SECoPDevice):" in code2 # Verify both node classes are present - assert "class NodeA(Device):" in code2 - assert "class NodeB(Device):" in code2 + assert "class NodeA(SECoPNodeDevice):" in code2 + assert "class NodeB(SECoPNodeDevice):" in code2 # Verify all methods are present assert "def type1_cmd" in code2 @@ -382,6 +424,10 @@ def type3_command(self, count: int) -> int: assert "# Module Properties" in code2 assert "# Module Parameters" in code2 + # Verify that descriptive comments are preserved in generated code + assert "# this is a description" in code2 + assert "# this has to be in the final output" in code2 + async def test_gen_cryo_node( clean_generated_file, cryo_sim, cryo_node_no_re: SECoPNodeDevice @@ -422,22 +468,51 @@ async def test_gen_cryo_status_not_in_cfg( cryo_node_no_re.class_from_instance(clean_generated_file) - cryo_cfg = await cryo_node_no_re.cryo.read_configuration() + cryo_cfg = await cryo_node_no_re.read_configuration() + cryo_reading = await cryo_node_no_re.read() + + print(cryo_reading) + + assert hasattr(cryo_node_no_re.cryo, "status") + assert isinstance(cryo_node_no_re.cryo.status, SignalR) stat_name = cryo_node_no_re.cryo.status.name - print(cryo_cfg[stat_name]) + assert ( + cryo_cfg.get(stat_name) is None + ), "Status signal should not be in configuration" + assert cryo_reading.get(stat_name) is None, "Status signal should be readable" + + # check if status signal is working + status_reding = await cryo_node_no_re.cryo.status.read() + + assert status_reding.get(stat_name) is not None, "Status signal should be readable" + # Import generated class from tests.testgen.genNodeClass import Cryo_7_frappy_demo async with init_devices(): cryo_gen_code = Cryo_7_frappy_demo(sec_node_uri="localhost:10769") - cryo_cfg = await cryo_gen_code.cryo.read_configuration() + # Status signal should still be present and functional in the generated code, even + # though it's not in the configuration + assert hasattr(cryo_gen_code.cryo, "status") + assert isinstance(cryo_gen_code.cryo.status, SignalR) + + cryo_cfg = await cryo_gen_code.read_configuration() + cryo_reading = await cryo_gen_code.read() stat_name = cryo_gen_code.cryo.status.name - print(cryo_cfg[stat_name]) + assert ( + cryo_cfg.get(stat_name) is None + ), "Status signal should not be in configuration" + assert cryo_reading.get(stat_name) is None, "Status signal should be readable" + + # check if status signal is working + status_reding = await cryo_gen_code.cryo.status.read() + + assert status_reding.get(stat_name) is not None, "Status signal should be readable" async def test_gen_real_node( From 3a8e2f6122d24f26b428d8903b684f67529afcbe Mon Sep 17 00:00:00 2001 From: Peter Braun Date: Wed, 11 Feb 2026 21:38:01 +0100 Subject: [PATCH 11/18] added mor attribute dataclasses --- src/secop_ophyd/GenNodeCode.py | 222 ++++--- src/secop_ophyd/SECoPDevices.py | 4 +- .../templates/generated_classes.py.jinja2 | 22 +- tests/test_ classgen.py | 563 +++++++++++++++++- tests/test_enum_codegen.py | 26 +- tests/test_enum_merging.py | 39 +- tests/test_gencode_refactor.py | 557 ----------------- 7 files changed, 709 insertions(+), 724 deletions(-) delete mode 100644 tests/test_gencode_refactor.py diff --git a/src/secop_ophyd/GenNodeCode.py b/src/secop_ophyd/GenNodeCode.py index 57329ba..4e67a8e 100644 --- a/src/secop_ophyd/GenNodeCode.py +++ b/src/secop_ophyd/GenNodeCode.py @@ -60,18 +60,40 @@ class EnumClass: @dataclass -class Attribute: - """Represents a class attribute with name and type.""" +class ModuleAttribute: + """Represents a module attribute with name, type, and optional description.""" name: str type: str + + +@dataclass +class PropertyAttribute: + """Represents a module property attribute with name, type""" + + name: str type_param: str | None = ( None # Optional type parameter like float for SignalRW[float] ) - description: str | None = None # Optional description from SECoP or docstrings - category: str = ( - "parameter" # "property" or "parameter" - for organizing generated code + path_annotation: str | None = ( + None # Annotation like ParamPath(...) or PropPath(...) + ) + + type: str = "SignalR" # Default to SignalR for properties + + +@dataclass +class ParameterAttribute: + """Represents a module parameter attribute with name, type, and + optional description.""" + + name: str + type: str + type_param: str | None = ( + None # Optional type parameter like float for SignalRW[float] ) + description: str | None = None # Optional description from SECoP or docstrings + path_annotation: str | None = ( None # Annotation like ParamPath(...) or PropPath(...) ) @@ -112,7 +134,8 @@ class ModuleClass: name: str bases: list[str] - attributes: list[Attribute] = field(default_factory=list) + parameters: list[ParameterAttribute] = field(default_factory=list) + properties: list[PropertyAttribute] = field(default_factory=list) methods: list[Method] = field(default_factory=list) description: str = "" enums: list[EnumClass] = field(default_factory=list) # Enum classes for this module @@ -124,7 +147,8 @@ class NodeClass: name: str bases: list[str] - attributes: list[Attribute] = field(default_factory=list) + properties: list[PropertyAttribute] = field(default_factory=list) + modules: list[ModuleAttribute] = field(default_factory=list) description: str = "" @@ -290,12 +314,13 @@ def _parse_node_class(self, class_symbol: str, class_obj: type): # Extract description from docstring description = inspect.getdoc(class_obj) or "" - attrs = self._get_attr_list(class_obj) + _, properties, modules = self._get_attr_list(class_obj) node_cls = NodeClass( name=class_symbol, bases=bases, - attributes=attrs, + properties=properties, + modules=modules, description=description, ) self.node_classes.append(node_cls) @@ -333,7 +358,11 @@ def _extract_descriptions_from_source(self, class_obj: type) -> dict[str, str]: return descriptions - def _get_attr_list(self, class_obj: type) -> list[Attribute]: + def _get_attr_list( + self, class_obj: type + ) -> tuple[ + list[ParameterAttribute], list[PropertyAttribute], list[ModuleAttribute] + ]: hints = get_type_hints(class_obj) # Get hints with Annotated for wrapping signals and backends extra_hints = get_type_hints(class_obj, include_extras=True) @@ -341,7 +370,9 @@ def _get_attr_list(self, class_obj: type) -> list[Attribute]: # Extract description comments from source code descriptions = self._extract_descriptions_from_source(class_obj) - attrs = [] + modules = [] + properties = [] + parameters = [] for attr_name, annotation in hints.items(): extras = getattr(extra_hints[attr_name], "__metadata__", ()) @@ -372,23 +403,32 @@ def _get_attr_list(self, class_obj: type) -> list[Attribute]: # Get description from comments description = descriptions.get(attr_name) - attrs.append( - Attribute( - name=attr_name, - type=origin.__name__, - type_param=type_param, - description=description, - category=category, - path_annotation=str(path_annotation), - format_annotation=format_annotation, - ) - ) + match category: + case "property": + properties.append( + PropertyAttribute( + name=attr_name, + type=origin.__name__, + type_param=type_param, + path_annotation=str(path_annotation), + ) + ) + case "parameter": + parameters.append( + ParameterAttribute( + name=attr_name, + type=origin.__name__, + type_param=type_param, + description=description, + path_annotation=str(path_annotation), + format_annotation=format_annotation, + ) + ) + if issubclass(origin, StandardReadable): - attrs.append( - Attribute(name=attr_name, type=origin.__name__, category="module") - ) + modules.append(ModuleAttribute(name=attr_name, type=origin.__name__)) - return attrs + return parameters, properties, modules def _parse_module_class(self, class_symbol: str, class_obj: type): """Parse a module class from existing module. @@ -399,7 +439,7 @@ def _parse_module_class(self, class_symbol: str, class_obj: type): """ # Extract attributes from source code to get proper type annotations - attrs = self._get_attr_list(class_obj) + parameters, properties, _ = self._get_attr_list(class_obj) methods = [] for method_name, method in class_obj.__dict__.items(): @@ -418,7 +458,8 @@ def _parse_module_class(self, class_symbol: str, class_obj: type): mod_cls = ModuleClass( name=class_symbol, bases=bases, - attributes=attrs, + parameters=parameters, + properties=properties, methods=methods, description=description, ) @@ -467,7 +508,8 @@ def add_mod_class( self, module_cls: str, bases: list[str], - attrs: list[tuple[str, str, str | None, str | None, str, str, str | None]], + parameters: list[ParameterAttribute], + properties: list[PropertyAttribute], cmd_plans: list[Method], description: str = "", enum_classes: list[EnumClass] | None = None, @@ -477,7 +519,8 @@ def add_mod_class( Args: module_cls: Name of the module class bases: Base classes - attrs: List of attribute tuples (name, type) or (name, type, type_param) + parameters: List of parameter attributes + properties: List of property attributes cmd_plans: List of method definitions description: Optional class description """ @@ -496,30 +539,11 @@ def add_mod_class( ) return - attributes = [] - for attr in attrs: - type_param = attr[2] if len(attr) > 2 and attr[2] else None - descr = attr[3] if len(attr) > 3 else None - category = attr[4] if len(attr) > 4 else "parameter" - path_annotation = attr[5] if len(attr) > 5 else None - format_annotation = attr[6] if len(attr) > 6 else None - - attributes.append( - Attribute( - name=attr[0], - type=attr[1], - type_param=type_param, - description=descr, - category=category, - path_annotation=path_annotation, - format_annotation=format_annotation, - ) - ) - mod_cls = ModuleClass( name=module_cls, bases=bases, - attributes=attributes, + parameters=parameters, + properties=properties, methods=cmd_plans, description=description, enums=enum_classes or [], @@ -530,7 +554,8 @@ def add_node_class( self, node_cls: str, bases: list[str], - attrs: list[tuple[str, str, str | None, None, str, str | None]], + properties: list[PropertyAttribute], + modules: list[ModuleAttribute], description: str = "", ): """Add a node class to be generated. @@ -553,33 +578,11 @@ def add_node_class( self.log.info(f"Node class {node_cls} already exists, skipping") return - attributes = [] - - for attr in attrs: - # attr[0] is name, attr[1] is type (both required) - name = str(attr[0]) - attr_type = str(attr[1]) - type_param = str(attr[2]) if len(attr) > 2 and attr[2] else None - descr = str(attr[3]) if len(attr) > 3 and attr[3] else None - category = str(attr[4]) if len(attr) > 4 and attr[4] else "property" - path_annotation = str(attr[5]) if len(attr) > 5 and attr[5] else None - format_annotation = str(attr[6]) if len(attr) > 6 and attr[6] else None - attributes.append( - Attribute( - name=name, - type=attr_type, - type_param=type_param, - description=descr, - category=category, - path_annotation=path_annotation, - format_annotation=format_annotation, - ) - ) - node_class = NodeClass( name=node_cls, bases=bases, - attributes=attributes, + properties=properties, + modules=modules, description=description, ) self.node_classes.append(node_class) @@ -623,7 +626,9 @@ def from_json_describe(self, json_data: str | dict): node_properties = {k: v for k, v in describe_data.items() if k != "modules"} # Parse modules - node_attrs: list[tuple[str, str, str | None, None, str, str | None]] = [] + node_module_attrs: list[ModuleAttribute] = [] + node_property_attrs: list[PropertyAttribute] = [] + for modname, moddescr in modules.items(): # separate accessibles into command and parameters parameters = {} @@ -697,9 +702,7 @@ def command_plan_no_arg(self, wait_for_idle: bool = False): command_plans.append(plan) - mod_params: list[ - tuple[str, str, str | None, str | None, str, str, str | None] - ] = [] + mod_parameters: list[ParameterAttribute] = [] for param_name, param_data in parameters.items(): @@ -776,22 +779,19 @@ def command_plan_no_arg(self, wait_for_idle: bool = False): # Default format for parameters is CONFIG_SIGNAL - mod_params.append( - ( - param_name, - signal_base.__name__, - type_param, - param_descr, - "parameter", - str(ParamPath(f"{modname}:{param_name}")), - format, + mod_parameters.append( + ParameterAttribute( + name=param_name, + type=signal_base.__name__, + type_param=type_param, + description=param_descr, + path_annotation=str(ParamPath(f"{modname}:{param_name}")), + format_annotation=format, ) ) # Module properties - mod_props: list[ - tuple[str, str, str | None, str | None, str, str, str | None] - ] = [] + module_properties: list[PropertyAttribute] = [] # Process module properties for prop_name, property_value in properties.items(): @@ -801,22 +801,20 @@ def command_plan_no_arg(self, wait_for_idle: bool = False): type_param = get_type_param(secop_dtype_obj_from_json(property_value)) - mod_props.append( - ( - prop_name, - SignalR.__name__, - type_param, - None, - "property", - str(PropPath(f"{modname}:{prop_name}")), - None, + module_properties.append( + PropertyAttribute( + name=prop_name, + type=SignalR.__name__, + type_param=type_param, + path_annotation=str(PropPath(f"{modname}:{prop_name}")), ) ) self.add_mod_class( module_cls=module_class, bases=module_bases, - attrs=mod_params + mod_props, + parameters=mod_parameters, + properties=module_properties, cmd_plans=command_plans, description=properties.get("description", ""), enum_classes=module_enum_classes, @@ -824,8 +822,7 @@ def command_plan_no_arg(self, wait_for_idle: bool = False): # Add to node attributes # Type the None explicitly as str | None to match other entries - - node_attrs.append((modname, module_class, None, None, "module", None)) + node_module_attrs.append(ModuleAttribute(name=modname, type=module_class)) # Process module properties for prop_name, property_value in node_properties.items(): @@ -833,14 +830,12 @@ def command_plan_no_arg(self, wait_for_idle: bool = False): # Generate PropPath annotation for node-level properties - node_attrs.append( - ( - str(prop_name), - str(SignalR.__name__), - type_param, - None, - "property", - str(PropPath(prop_name)), + node_property_attrs.append( + PropertyAttribute( + name=prop_name, + type=SignalR.__name__, + type_param=type_param, + path_annotation=str(PropPath(prop_name)), ) ) @@ -855,7 +850,8 @@ def command_plan_no_arg(self, wait_for_idle: bool = False): self.add_node_class( node_cls=node_class_name, bases=node_bases, - attrs=node_attrs, + modules=node_module_attrs, + properties=node_property_attrs, description=node_properties.get("description", ""), ) diff --git a/src/secop_ophyd/SECoPDevices.py b/src/secop_ophyd/SECoPDevices.py index 260c191..cccd1fd 100644 --- a/src/secop_ophyd/SECoPDevices.py +++ b/src/secop_ophyd/SECoPDevices.py @@ -124,14 +124,14 @@ def format_assigned(device: StandardReadable, signal: SignalR) -> bool: def is_read_signal(device: StandardReadable, signal: SignalR | SignalRW) -> bool: - if signal.describe() in device._describe_funcs: + if signal.describe in device._describe_funcs: return True return False def is_config_signal(device: StandardReadable, signal: SignalR | SignalRW) -> bool: - if signal.describe() in device._describe_config_funcs: + if signal.describe in device._describe_config_funcs: return True return False diff --git a/src/secop_ophyd/templates/generated_classes.py.jinja2 b/src/secop_ophyd/templates/generated_classes.py.jinja2 index 2c661e3..574a52f 100644 --- a/src/secop_ophyd/templates/generated_classes.py.jinja2 +++ b/src/secop_ophyd/templates/generated_classes.py.jinja2 @@ -26,12 +26,10 @@ class {{ enum_cls.name }}({{ enum_cls.base_enum_class }}): {% for module_cls in module_classes %} class {{ module_cls.name }}({{ module_cls.bases | join(', ') }}): """{{ module_cls.description or 'Generated module class' }}""" -{%- set properties = module_cls.attributes | selectattr('category', 'equalto', 'property') | list %} -{%- set parameters = module_cls.attributes | selectattr('category', 'equalto', 'parameter') | list %} -{%- if properties %} +{%- if module_cls.properties %} # Module Properties -{%- for attr in properties %} +{%- for attr in module_cls.properties %} {%- if attr.path_annotation and attr.format_annotation %} {{ attr.name }}: A[{{ attr.type }}{% if attr.type_param %}[{{ attr.type_param }}]{% endif %}, {{ attr.path_annotation }}, {{ attr.format_annotation }}] {%- elif attr.path_annotation %} @@ -42,10 +40,10 @@ class {{ module_cls.name }}({{ module_cls.bases | join(', ') }}): {%- if attr.description %} # {{ attr.description }}{% endif %} {%- endfor %} {%- endif %} -{%- if parameters %} +{%- if module_cls.parameters %} # Module Parameters -{%- for attr in parameters %} +{%- for attr in module_cls.parameters %} {%- if attr.path_annotation and attr.format_annotation %} {{ attr.name }}: A[{{ attr.type }}{% if attr.type_param %}[{{ attr.type_param }}]{% endif %}, {{ attr.path_annotation }}, {{ attr.format_annotation }}] {%- elif attr.path_annotation %} @@ -73,20 +71,18 @@ class {{ module_cls.name }}({{ module_cls.bases | join(', ') }}): {% for node_cls in node_classes %} class {{ node_cls.name }}({{ node_cls.bases | join(', ') }}): """{{ node_cls.description or 'Generated node class' }}""" -{%- set modules = node_cls.attributes | selectattr('category', 'equalto', 'module') | list %} -{%- set properties = node_cls.attributes | selectattr('category', 'equalto', 'property') | list %} -{%- if modules %} +{%- if node_cls.modules %} # Module Devices -{%- for attr in modules %} - {{ attr.name }}: {{ attr.type }}{% if attr.type_param %}[{{ attr.type_param }}]{% endif %} +{%- for attr in node_cls.modules %} + {{ attr.name }}: {{ attr.type }} {%- if attr.description %} # {{ attr.description }}{% endif %} {%- endfor %} {%- endif %} -{%- if properties %} +{%- if node_cls.properties %} # Node Properties -{%- for attr in properties %} +{%- for attr in node_cls.properties %} {%- if attr.path_annotation and attr.format_annotation %} {{ attr.name }}: A[{{ attr.type }}{% if attr.type_param %}[{{ attr.type_param }}]{% endif %}, {{ attr.path_annotation }}, {{ attr.format_annotation }}] {%- elif attr.path_annotation %} diff --git a/tests/test_ classgen.py b/tests/test_ classgen.py index 8fe29d0..ef8db8a 100644 --- a/tests/test_ classgen.py +++ b/tests/test_ classgen.py @@ -1,18 +1,559 @@ -# mypy: disable-error-code="attr-defined" -import os +"""Simple test to verify GenNodeCode refactoring works.""" -from secop_ophyd.SECoPDevices import SECoPNodeDevice +import sys +from pathlib import Path +from ophyd_async.core import SignalR, init_devices -async def test_class_gen(nested_struct_sim, nested_node_no_re: SECoPNodeDevice): - nested_node_no_re.class_from_instance() +from secop_ophyd.GenNodeCode import ( + GenNodeCode, + Method, + ModuleAttribute, + ModuleClass, + NodeClass, + ParameterAttribute, + PropertyAttribute, +) +from secop_ophyd.SECoPDevices import ParamPath, PropPath, SECoPNodeDevice - if os.path.exists("genNodeClass.py"): - os.remove("genNodeClass.py") +# Add src to path +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) -async def test_class_gen_path(nested_struct_sim, nested_node_no_re: SECoPNodeDevice): - nested_node_no_re.class_from_instance("tests") +def test_basic_functionality(clean_generated_file): + """Test basic GenNodeCode functionality.""" + print("Testing GenNodeCode refactored implementation...") - if os.path.exists("tests/genNodeClass.py"): - os.remove("tests/genNodeClass.py") + # Create instance + gen_code = GenNodeCode(path=str(clean_generated_file), log=None) + + # Add some imports + gen_code.add_import("ophyd_async.core", "Device") + + # Create a simple method + from inspect import signature + + def sample_method(self, value: int) -> str: + """Sample method description""" + return str(value) + + # Method can be created in the old way (backward compatible) + method = Method( + cmd_name="sample_command", + description="Sample command for testing", + cmd_sign=signature(sample_method), + ) + + # Add a module class + gen_code.add_mod_class( + module_cls="TestModule", + bases=["SECoPDevice"], + parameters=[ + ParameterAttribute( + name="temperature", + type="SignalR", + type_param="float", + path_annotation=str(ParamPath("test:temperature")), + ), + ParameterAttribute( + name="pressure", + type="SignalR", + type_param="float", + path_annotation=str(ParamPath("test:pressure")), + ), + ParameterAttribute( + name="count", + type="SignalRW", + type_param="int", + path_annotation=str(ParamPath("test:count")), + ), + ], + properties=[], + cmd_plans=[method], + description="Test module class", + ) + + # Add a node class + gen_code.add_node_class( + node_cls="TestNode", + bases=["SECoPNodeDevice"], + modules=[ + ModuleAttribute(name="module1", type="TestModule"), + ], + properties=[ + PropertyAttribute( + name="status", + type="SignalR", + type_param="str", + path_annotation=str(PropPath("status")), + ), + ], + description="Test node class", + ) + + # Generate code + code = gen_code.generate_code() + + gen_code.write_gen_node_class_file() + + print("\n" + "=" * 60) + print("Generated Code:") + print("=" * 60) + print(code) + print("=" * 60) + + # Verify code contains expected elements + assert "from abc import abstractmethod" in code + assert "class TestModule(SECoPDevice):" in code + assert 'temperature: A[SignalR[float], ParamPath("test:temperature")]' in code + assert 'count: A[SignalRW[int], ParamPath("test:count")]' in code + assert "class TestNode(SECoPNodeDevice):" in code + assert "module1: TestModule" in code + assert 'status: A[SignalR[str], PropPath("status")]' in code + assert "def sample_command" in code + + print("\n✓ All basic tests passed!") + + +def test_dataclasses(): + """Test the new dataclasses.""" + print("\nTesting dataclasses...") + + # Test ParameterAttribute + param_attr = ParameterAttribute(name="test_param", type="SignalR") + assert param_attr.name == "test_param" + assert param_attr.type == "SignalR" + + # Test PropertyAttribute + prop_attr = PropertyAttribute(name="test_prop", type="SignalR") + assert prop_attr.name == "test_prop" + assert prop_attr.type == "SignalR" + + # Test ModuleAttribute + mod_attr = ModuleAttribute(name="test_mod", type="TestModule") + assert mod_attr.name == "test_mod" + assert mod_attr.type == "TestModule" + + # Test ModuleClass + mod_cls = ModuleClass( + name="TestMod", + bases=["Device"], + parameters=[param_attr], + properties=[prop_attr], + methods=[], + ) + assert mod_cls.name == "TestMod" + assert len(mod_cls.parameters) == 1 + assert len(mod_cls.properties) == 1 + + # Test NodeClass + node_cls = NodeClass( + name="TestNode", bases=["Device"], modules=[mod_attr], properties=[prop_attr] + ) + assert node_cls.name == "TestNode" + + print("✓ Dataclass tests passed!") + + +def test_subsequent_node_generation(clean_generated_file): + """Test generating code for two nodes sequentially, appending to the same file. + + Tests that: + - First: Generate NodeA with modules Type1 and Type2, write to file + - Second: Load existing file, add NodeB with Type1 (shared) and Type3 (new) + - Type1 should appear only once in the final file (not duplicated) + - All classes (Type1, Type2, Type3, NodeA, NodeB) are in the final file + """ + + from inspect import signature + + # ===== STEP 1: Generate and write first node (NodeA) ===== + + gen_code1 = GenNodeCode(path=str(clean_generated_file), log=None) + + # Create sample methods + def type1_command(self, value: float) -> float: + """Type1 command""" + return value * 2.0 + + method_type1 = Method( + cmd_name="type1_cmd", + description="Type1 command", + cmd_sign=signature(type1_command), + ) + + def type2_command(self, mode: str) -> str: + """Type2 command""" + return f"Mode: {mode}" + + method_type2 = Method( + cmd_name="type2_cmd", + description="Type2 command", + cmd_sign=signature(type2_command), + ) + + # Add module class Type1 (will be shared) + gen_code1.add_mod_class( + module_cls="Type1", + bases=["SECoPDevice"], + parameters=[ + ParameterAttribute( + name="temperature", + type="SignalR", + type_param="float", + description="this has to be in the final output", + path_annotation="ParamPath('type1:temperature')", + ), + ParameterAttribute( + name="setpoint", + type="SignalRW", + type_param="float", + path_annotation="ParamPath('type1:setpoint')", + ), + ], + properties=[ + PropertyAttribute( + name="description", + type="SignalR", + type_param="str", + path_annotation="PropPath('type1:description')", + ), + PropertyAttribute( + name="interface_classes", + type="SignalR", + type_param="int", + path_annotation="PropPath('type1:interface_classes')", + ), + ], + cmd_plans=[method_type1], + description="Type1 module - shared between nodes", + ) + + # Add module class Type2 (only in nodeA) + gen_code1.add_mod_class( + module_cls="Type2", + bases=["SECoPDevice"], + parameters=[ + ParameterAttribute( + name="pressure", + type="SignalR", + type_param="float", + path_annotation="ParamPath('type2:pressure')", + ), + ParameterAttribute( + name="mode", + type="SignalRW", + type_param="str", + path_annotation="ParamPath('type2:mode')", + ), + ], + properties=[ + PropertyAttribute( + name="implementation", + type="SignalR", + type_param="str", + path_annotation="PropPath('type2:implementation')", + ), + ], + cmd_plans=[method_type2], + description="Type2 module - only in nodeA", + ) + + # Add nodeA + gen_code1.add_node_class( + node_cls="NodeA", + bases=["SECoPNodeDevice"], + modules=[ + ModuleAttribute(name="modA", type="Type1"), + ModuleAttribute(name="modB", type="Type2"), + ], + properties=[ + PropertyAttribute( + name="status", + type="SignalR", + type_param="str", + path_annotation="PropPath('status')", + ), + ], + description="NodeA with Type1 and Type2 modules", + ) + + # Generate and write first node + code1 = gen_code1.generate_code() + gen_code1.write_gen_node_class_file() + + # Verify first generation + assert "class Type1(SECoPDevice):" in code1 + assert "class Type2(SECoPDevice):" in code1 + assert "class NodeA(SECoPNodeDevice):" in code1 + assert "modA: Type1" in code1 + assert "modB: Type2" in code1 + + # ===== STEP 2: Load existing file and add second node (NodeB) ===== + + gen_code2 = GenNodeCode(path=str(clean_generated_file), log=None) + + # Add necessary imports again + gen_code2.add_import("secop_ophyd.SECoPDevices", "SECoPDevice") + gen_code2.add_import("secop_ophyd.SECoPDevices", "SECoPNodeDevice") + + # Create method for Type3 + def type3_command(self, count: int) -> int: + """Type3 command""" + return count + 1 + + method_type3 = Method( + cmd_name="type3_cmd", + description="Type3 command", + cmd_sign=signature(type3_command), + ) + + # Add Type1 again - GenNodeCode should detect it already exists + gen_code2.add_mod_class( + module_cls="Type1", + bases=["SECoPDevice"], + parameters=[ + ParameterAttribute( + name="temperature", + type="SignalR", + type_param="float", + path_annotation="ParamPath('type1:temperature')", + ), + ParameterAttribute( + name="setpoint", + type="SignalRW", + type_param="float", + path_annotation="ParamPath('type1:setpoint')", + ), + ], + properties=[ + PropertyAttribute( + name="description", + type="SignalR", + type_param="str", + path_annotation="PropPath('type1:description')", + ), + PropertyAttribute( + name="interface_classes", + type="SignalR", + type_param="list", + path_annotation="PropPath('type1:interface_classes')", + ), + ], + cmd_plans=[method_type1], + description="Type1 module - shared between nodes", + ) + + # Add module class Type3 (only in nodeB) + gen_code2.add_mod_class( + module_cls="Type3", + bases=["SECoPDevice"], + parameters=[ + ParameterAttribute( + name="count", + type="SignalRW", + type_param="int", + description="this is a description", + path_annotation="ParamPath('type3:count')", + ), + ParameterAttribute( + name="enabled", + type="SignalR", + type_param="bool", + path_annotation="ParamPath('type3:enabled')", + ), + ], + properties=[ + PropertyAttribute( + name="group", + type="SignalR", + type_param="str", + path_annotation="PropPath('type3:group')", + ), + ], + cmd_plans=[method_type3], + description="Type3 module - only in nodeB", + ) + + # Add nodeB + gen_code2.add_node_class( + node_cls="NodeB", + bases=["SECoPNodeDevice"], + modules=[ + ModuleAttribute(name="modA", type="Type1"), + ModuleAttribute(name="modB", type="Type3"), + ], + properties=[ + PropertyAttribute( + name="name", + type="SignalR", + type_param="str", + path_annotation="PropPath('name')", + ), + ], + description="NodeB with Type1 and Type3 modules", + ) + + # Generate and write second node (appends to the file) + code2 = gen_code2.generate_code() + gen_code2.write_gen_node_class_file() + + # ===== VERIFICATION ===== + # Verify that Type1 appears only once in the final code + type1_count = code2.count("class Type1(SECoPDevice):") + + assert ( + type1_count == 1 + ), f"Type1 should appear exactly once, but appears {type1_count} times" + + # Verify all module classes are present + assert "class Type1(SECoPDevice):" in code2 + assert "class Type2(SECoPDevice):" in code2 + assert "class Type3(SECoPDevice):" in code2 + + # Verify both node classes are present + assert "class NodeA(SECoPNodeDevice):" in code2 + assert "class NodeB(SECoPNodeDevice):" in code2 + + # Verify all methods are present + assert "def type1_cmd" in code2 + assert "def type2_cmd" in code2 + assert "def type3_cmd" in code2 + + # Verify section comments are present + assert "# Module Properties" in code2 + assert "# Module Parameters" in code2 + + # Verify that descriptive comments are preserved in generated code + assert "# this is a description" in code2 + assert "# this has to be in the final output" in code2 + + +async def test_gen_cryo_node( + clean_generated_file, cryo_sim, cryo_node_no_re: SECoPNodeDevice +): + """Test generating code for a real SECoP node.""" + + cryo_node_no_re.class_from_instance(clean_generated_file) + + from tests.testgen.genNodeClass import Cryo_7_frappy_demo # type: ignore + + async with init_devices(): + cryo_gen_code = Cryo_7_frappy_demo(sec_node_uri="localhost:10769") + + cryo_val = await cryo_gen_code.read() + + # target and value shoule be present in readback, since they are read signals with + # HINTED Format --> this is tested to verify that the correct annotations are + # generated and interpreted in the generated code + val_name = cryo_gen_code.cryo.value.name + target_name = cryo_gen_code.cryo.target.name + read_val = cryo_val[val_name].get("value") + read_target = cryo_val[target_name].get("value") + + print(cryo_val) + + assert read_val is not None + assert read_val > 5 + + assert read_target is not None + assert read_target == 10 + + +async def test_gen_cryo_status_not_in_cfg( + clean_generated_file, cryo_sim, cryo_node_no_re: SECoPNodeDevice +): + """Test that Status signal is not marked as configuration signal but is still + instantiated.""" + + cryo_node_no_re.class_from_instance(clean_generated_file) + + cryo_cfg = await cryo_node_no_re.read_configuration() + cryo_reading = await cryo_node_no_re.read() + + print(cryo_reading) + + assert hasattr(cryo_node_no_re.cryo, "status") + assert isinstance(cryo_node_no_re.cryo.status, SignalR) + + stat_name = cryo_node_no_re.cryo.status.name + + assert ( + cryo_cfg.get(stat_name) is None + ), "Status signal should not be in configuration" + assert cryo_reading.get(stat_name) is None, "Status signal should be readable" + + # check if status signal is working + status_reding = await cryo_node_no_re.cryo.status.read() + + assert status_reding.get(stat_name) is not None, "Status signal should be readable" + + # Import generated class + from tests.testgen.genNodeClass import Cryo_7_frappy_demo # type: ignore + + async with init_devices(): + cryo_gen_code = Cryo_7_frappy_demo(sec_node_uri="localhost:10769") + + # Status signal should still be present and functional in the generated code, even + # though it's not in the configuration + assert hasattr(cryo_gen_code.cryo, "status") + assert isinstance(cryo_gen_code.cryo.status, SignalR) + + cryo_cfg = await cryo_gen_code.read_configuration() + cryo_reading = await cryo_gen_code.read() + + print(cryo_reading) + + stat_name = cryo_gen_code.cryo.status.name + + assert ( + cryo_cfg.get(stat_name) is None + ), "Status signal should not be in configuration" + assert cryo_reading.get(stat_name) is None, "Status signal should be readable" + + # check if status signal is working + status_reding = await cryo_gen_code.cryo.status.read() + + assert status_reding.get(stat_name) is not None, "Status signal should be readable" + + +async def test_gen_real_node( + clean_generated_file, + nested_struct_sim, + RE, + nested_node_no_re: SECoPNodeDevice, # noqa: N803 +): + + nested_node_no_re.class_from_instance(clean_generated_file) + + # Read the generated file and verify its contents + gen_file = clean_generated_file / "genNodeClass.py" + assert gen_file.exists(), "Generated file should exist" + + generated_code = gen_file.read_text() + + # ===== Assertions for generated command plans ===== + # The ophy_struct module has a test_cmd command + assert "def test_cmd" in generated_code, "test_cmd plan should be generated" + assert ( + "@abstractmethod" in generated_code + ), "Command methods should be marked as abstract" + + # ===== Assertions for generated enum classes ===== + # Enum classes should be generated for enum parameters + # The gas_type parameter in enum1/enum2 modules should generate enum classes + assert ( + "class Test_EnumGas_typeEnum(SupersetEnum):" in generated_code + ), "Enum class for gas_type should be generated" + + # Verify enum members are present + # gas_type enums should have AR, N2, H2 (and CO2 for enum2) + assert "AR" in generated_code, "AR enum member should be present" + assert "N2" in generated_code, "N2 enum member should be present" + assert "H2" in generated_code, "H2 enum member should be present" + assert "CO2" in generated_code, "CO2 enum member should be present" + + # Verify SupersetEnum import + assert ( + "from enum import Enum" in generated_code or "SupersetEnum" in generated_code + ), "Enum import should be present" diff --git a/tests/test_enum_codegen.py b/tests/test_enum_codegen.py index 8a7a3a5..98c99a0 100644 --- a/tests/test_enum_codegen.py +++ b/tests/test_enum_codegen.py @@ -3,11 +3,11 @@ import tempfile from secop_ophyd.GenNodeCode import ( - Attribute, EnumClass, EnumMember, GenNodeCode, ModuleClass, + ParameterAttribute, ) @@ -39,18 +39,24 @@ def test_code_generation_with_enums(): mod1 = ModuleClass( name="MassflowController1", bases=["Device"], - attributes=[ - Attribute("gastype", "SignalRW", "MassflowControllerGastypeEnum"), + parameters=[ + ParameterAttribute( + "gastype", "SignalRW", "MassflowControllerGastypeEnum" + ), ], + properties=[], enums=[enum1], ) mod2 = ModuleClass( name="MassflowController2", bases=["Device"], - attributes=[ - Attribute("gastype", "SignalRW", "MassflowControllerGastypeEnum"), + parameters=[ + ParameterAttribute( + "gastype", "SignalRW", "MassflowControllerGastypeEnum" + ), ], + properties=[], enums=[enum2], ) @@ -104,18 +110,20 @@ def test_code_generation_strict_enum(): mod1 = ModuleClass( name="Device1", bases=["Device"], - attributes=[ - Attribute("status", "SignalR", "StatusEnum"), + parameters=[ + ParameterAttribute("status", "SignalR", "StatusEnum"), ], + properties=[], enums=[enum1], ) mod2 = ModuleClass( name="Device2", bases=["Device"], - attributes=[ - Attribute("status", "SignalR", "StatusEnum"), + parameters=[ + ParameterAttribute("status", "SignalR", "StatusEnum"), ], + properties=[], enums=[enum2], ) diff --git a/tests/test_enum_merging.py b/tests/test_enum_merging.py index 7b1edaf..64ac8a5 100644 --- a/tests/test_enum_merging.py +++ b/tests/test_enum_merging.py @@ -1,6 +1,11 @@ """Test enum merging logic for StrictEnum vs SupersetEnum.""" -from secop_ophyd.GenNodeCode import EnumClass, EnumMember, GenNodeCode +from secop_ophyd.GenNodeCode import ( + EnumClass, + EnumMember, + GenNodeCode, + ParameterAttribute, +) def test_identical_enums_use_strict(): @@ -143,17 +148,15 @@ def test_same_class_different_instances(): gen_code.add_mod_class( module_cls="Test_Enum", bases=["Device"], - attrs=[ - ( - "gas_type", - "SignalRW", - "Test_EnumGas_typeEnum", - None, - "parameter", - "balh:gas_type", - None, + parameters=[ + ParameterAttribute( + name="gas_type", + type="SignalRW", + type_param="Test_EnumGas_typeEnum", + path_annotation="balh:gas_type", ) ], + properties=[], cmd_plans=[], enum_classes=[enum1], ) @@ -162,17 +165,15 @@ def test_same_class_different_instances(): gen_code.add_mod_class( module_cls="Test_Enum", bases=["Device"], - attrs=[ - ( - "gas_type", - "SignalRW", - "Test_EnumGas_typeEnum", - None, - "parameter", - "balh:gas_type", - None, + parameters=[ + ParameterAttribute( + name="gas_type", + type="SignalRW", + type_param="Test_EnumGas_typeEnum", + path_annotation="balh:gas_type", ) ], + properties=[], cmd_plans=[], enum_classes=[enum2], ) diff --git a/tests/test_gencode_refactor.py b/tests/test_gencode_refactor.py deleted file mode 100644 index fe4df96..0000000 --- a/tests/test_gencode_refactor.py +++ /dev/null @@ -1,557 +0,0 @@ -"""Simple test to verify GenNodeCode refactoring works.""" - -import sys -from pathlib import Path - -from ophyd_async.core import SignalR, init_devices - -from secop_ophyd.GenNodeCode import ( - Attribute, - GenNodeCode, - Method, - ModuleClass, - NodeClass, -) -from secop_ophyd.SECoPDevices import ParamPath, PropPath, SECoPNodeDevice - -# Add src to path -sys.path.insert(0, str(Path(__file__).parent.parent / "src")) - - -def test_basic_functionality(clean_generated_file): - """Test basic GenNodeCode functionality.""" - print("Testing GenNodeCode refactored implementation...") - - # Create instance - gen_code = GenNodeCode(path=str(clean_generated_file), log=None) - - # Add some imports - gen_code.add_import("ophyd_async.core", "Device") - - # Create a simple method - from inspect import signature - - def sample_method(self, value: int) -> str: - """Sample method description""" - return str(value) - - # Method can be created in the old way (backward compatible) - method = Method( - cmd_name="sample_command", - description="Sample command for testing", - cmd_sign=signature(sample_method), - ) - - # Add a module class - gen_code.add_mod_class( - module_cls="TestModule", - bases=["SECoPDevice"], - attrs=[ - ( - "temperature", - "SignalR", - "float", - None, - "parameter", - "ParamPath('test:temperature')", - None, - ), - ( - "pressure", - "SignalR", - "float", - None, - "parameter", - "ParamPath('test:pressure')", - None, - ), - ( - "count", - "SignalRW", - "int", - None, - "parameter", - "ParamPath('test:count')", - None, - ), - ], - cmd_plans=[method], - description="Test module class", - ) - - # Add a node class - gen_code.add_node_class( - node_cls="TestNode", - bases=["SECoPNodeDevice"], - attrs=[ - ("module1", "TestModule", None, None, "module", None), - ("status", "SignalR", "str", None, "property", "PropPath('status')"), - ], - description="Test node class", - ) - - # Generate code - code = gen_code.generate_code() - - gen_code.write_gen_node_class_file() - - print("\n" + "=" * 60) - print("Generated Code:") - print("=" * 60) - print(code) - print("=" * 60) - - # Verify code contains expected elements - assert "from abc import abstractmethod" in code - assert "class TestModule(SECoPDevice):" in code - assert 'temperature: A[SignalR[float], ParamPath("test:temperature")]' in code - assert 'count: A[SignalRW[int], ParamPath("test:count")]' in code - assert "class TestNode(SECoPNodeDevice):" in code - assert "module1: TestModule" in code - assert 'status: A[SignalR[str], PropPath("status")]' in code - assert "def sample_command" in code - - print("\n✓ All basic tests passed!") - - -def test_dataclasses(): - """Test the new dataclasses.""" - print("\nTesting dataclasses...") - - # Test Attribute - attr = Attribute(name="test_attr", type="SignalR") - assert attr.name == "test_attr" - assert attr.type == "SignalR" - - # Test ModuleClass - mod_cls = ModuleClass( - name="TestMod", bases=["Device"], attributes=[attr], methods=[] - ) - assert mod_cls.name == "TestMod" - assert len(mod_cls.attributes) == 1 - - # Test NodeClass - node_cls = NodeClass(name="TestNode", bases=["Device"], attributes=[attr]) - assert node_cls.name == "TestNode" - - print("✓ Dataclass tests passed!") - - -def test_subsequent_node_generation(clean_generated_file): - """Test generating code for two nodes sequentially, appending to the same file. - - Tests that: - - First: Generate NodeA with modules Type1 and Type2, write to file - - Second: Load existing file, add NodeB with Type1 (shared) and Type3 (new) - - Type1 should appear only once in the final file (not duplicated) - - All classes (Type1, Type2, Type3, NodeA, NodeB) are in the final file - """ - - from inspect import signature - - # ===== STEP 1: Generate and write first node (NodeA) ===== - - gen_code1 = GenNodeCode(path=str(clean_generated_file), log=None) - - # Create sample methods - def type1_command(self, value: float) -> float: - """Type1 command""" - return value * 2.0 - - method_type1 = Method( - cmd_name="type1_cmd", - description="Type1 command", - cmd_sign=signature(type1_command), - ) - - def type2_command(self, mode: str) -> str: - """Type2 command""" - return f"Mode: {mode}" - - method_type2 = Method( - cmd_name="type2_cmd", - description="Type2 command", - cmd_sign=signature(type2_command), - ) - - # Add module class Type1 (will be shared) - gen_code1.add_mod_class( - module_cls="Type1", - bases=["SECoPDevice"], - attrs=[ - ( - "description", - "SignalR", - "str", - None, - "property", - str(PropPath("type1:description")), - None, - ), - ( - "interface_classes", - "SignalR", - "int", - None, - "property", - str(PropPath("type1:interface_classes")), - None, - ), - ( - "temperature", - "SignalR", - "float", - "this has to be in the final output", - "parameter", - str(ParamPath("type1:temperature")), - None, - ), - ( - "setpoint", - "SignalRW", - "float", - None, - "parameter", - str(ParamPath("type1:setpoint")), - None, - ), - ], - cmd_plans=[method_type1], - description="Type1 module - shared between nodes", - ) - - # Add module class Type2 (only in nodeA) - gen_code1.add_mod_class( - module_cls="Type2", - bases=["SECoPDevice"], - attrs=[ - ( - "implementation", - "SignalR", - "str", - None, - "property", - str(PropPath("type2:implementation")), - None, - ), - ( - "pressure", - "SignalR", - "float", - None, - "parameter", - str(ParamPath("type2:pressure")), - None, - ), - ( - "mode", - "SignalRW", - "str", - None, - "parameter", - str(ParamPath("type2:mode")), - None, - ), - ], - cmd_plans=[method_type2], - description="Type2 module - only in nodeA", - ) - - # Add nodeA - gen_code1.add_node_class( - node_cls="NodeA", - bases=["SECoPNodeDevice"], - attrs=[ - ("modA", "Type1", None, None, "module", None), - ("modB", "Type2", None, None, "module", None), - ("status", "SignalR", "str", None, "property", str(PropPath("status"))), - ], - description="NodeA with Type1 and Type2 modules", - ) - - # Generate and write first node - code1 = gen_code1.generate_code() - gen_code1.write_gen_node_class_file() - - # Verify first generation - assert "class Type1(SECoPDevice):" in code1 - assert "class Type2(SECoPDevice):" in code1 - assert "class NodeA(SECoPNodeDevice):" in code1 - assert "modA: Type1" in code1 - assert "modB: Type2" in code1 - - # ===== STEP 2: Load existing file and add second node (NodeB) ===== - - gen_code2 = GenNodeCode(path=str(clean_generated_file), log=None) - - # Add necessary imports again - gen_code2.add_import("secop_ophyd.SECoPDevices", "SECoPDevice") - gen_code2.add_import("secop_ophyd.SECoPDevices", "SECoPNodeDevice") - - # Create method for Type3 - def type3_command(self, count: int) -> int: - """Type3 command""" - return count + 1 - - method_type3 = Method( - cmd_name="type3_cmd", - description="Type3 command", - cmd_sign=signature(type3_command), - ) - - # Add Type1 again - GenNodeCode should detect it already exists - gen_code2.add_mod_class( - module_cls="Type1", - bases=["SECoPDevice"], - attrs=[ - ( - "description", - "SignalR", - "str", - None, - "property", - str(PropPath("type1:description")), - None, - ), - ( - "interface_classes", - "SignalR", - "list", - None, - "property", - str(PropPath("type1:interface_classes")), - None, - ), - ( - "temperature", - "SignalR", - "float", - None, - "parameter", - str(ParamPath("type1:temperature")), - None, - ), - ( - "setpoint", - "SignalRW", - "float", - None, - "parameter", - str(ParamPath("type1:setpoint")), - None, - ), - ], - cmd_plans=[method_type1], - description="Type1 module - shared between nodes", - ) - - # Add module class Type3 (only in nodeB) - gen_code2.add_mod_class( - module_cls="Type3", - bases=["SECoPDevice"], - attrs=[ - ( - "group", - "SignalR", - "str", - None, - "property", - str(PropPath("type3:group")), - None, - ), - ( - "count", - "SignalRW", - "int", - "this is a description", - "parameter", - str(ParamPath("type3:count")), - None, - ), - ( - "enabled", - "SignalR", - "bool", - None, - "parameter", - str(ParamPath("type3:enabled")), - None, - ), - ], - cmd_plans=[method_type3], - description="Type3 module - only in nodeB", - ) - - # Add nodeB - gen_code2.add_node_class( - node_cls="NodeB", - bases=["SECoPNodeDevice"], - attrs=[ - ("modA", "Type1", None, None, "module", None), - ("modB", "Type3", None, None, "module", None), - ("name", "SignalR", "str", None, "property", str(PropPath("name"))), - ], - description="NodeB with Type1 and Type3 modules", - ) - - # Generate and write second node (appends to the file) - code2 = gen_code2.generate_code() - gen_code2.write_gen_node_class_file() - - # ===== VERIFICATION ===== - # Verify that Type1 appears only once in the final code - type1_count = code2.count("class Type1(SECoPDevice):") - - assert ( - type1_count == 1 - ), f"Type1 should appear exactly once, but appears {type1_count} times" - - # Verify all module classes are present - assert "class Type1(SECoPDevice):" in code2 - assert "class Type2(SECoPDevice):" in code2 - assert "class Type3(SECoPDevice):" in code2 - - # Verify both node classes are present - assert "class NodeA(SECoPNodeDevice):" in code2 - assert "class NodeB(SECoPNodeDevice):" in code2 - - # Verify all methods are present - assert "def type1_cmd" in code2 - assert "def type2_cmd" in code2 - assert "def type3_cmd" in code2 - - # Verify section comments are present - assert "# Module Properties" in code2 - assert "# Module Parameters" in code2 - - # Verify that descriptive comments are preserved in generated code - assert "# this is a description" in code2 - assert "# this has to be in the final output" in code2 - - -async def test_gen_cryo_node( - clean_generated_file, cryo_sim, cryo_node_no_re: SECoPNodeDevice -): - """Test generating code for a real SECoP node.""" - - cryo_node_no_re.class_from_instance(clean_generated_file) - - from tests.testgen.genNodeClass import Cryo_7_frappy_demo - - async with init_devices(): - cryo_gen_code = Cryo_7_frappy_demo(sec_node_uri="localhost:10769") - - cryo_val = await cryo_gen_code.read() - - # target and value shoule be present in readback, since they are read signals with - # HINTED Format --> this is tested to verify that the correct annotations are - # generated and interpreted in the generated code - val_name = cryo_gen_code.cryo.value.name - target_name = cryo_gen_code.cryo.target.name - read_val = cryo_val[val_name].get("value") - read_target = cryo_val[target_name].get("value") - - print(cryo_val) - - assert read_val is not None - assert read_val > 5 - - assert read_target is not None - assert read_target == 10 - - -async def test_gen_cryo_status_not_in_cfg( - clean_generated_file, cryo_sim, cryo_node_no_re: SECoPNodeDevice -): - """Test that Status signal is not marked as configuration signal but is still - instantiated.""" - - cryo_node_no_re.class_from_instance(clean_generated_file) - - cryo_cfg = await cryo_node_no_re.read_configuration() - cryo_reading = await cryo_node_no_re.read() - - print(cryo_reading) - - assert hasattr(cryo_node_no_re.cryo, "status") - assert isinstance(cryo_node_no_re.cryo.status, SignalR) - - stat_name = cryo_node_no_re.cryo.status.name - - assert ( - cryo_cfg.get(stat_name) is None - ), "Status signal should not be in configuration" - assert cryo_reading.get(stat_name) is None, "Status signal should be readable" - - # check if status signal is working - status_reding = await cryo_node_no_re.cryo.status.read() - - assert status_reding.get(stat_name) is not None, "Status signal should be readable" - - # Import generated class - from tests.testgen.genNodeClass import Cryo_7_frappy_demo - - async with init_devices(): - cryo_gen_code = Cryo_7_frappy_demo(sec_node_uri="localhost:10769") - - # Status signal should still be present and functional in the generated code, even - # though it's not in the configuration - assert hasattr(cryo_gen_code.cryo, "status") - assert isinstance(cryo_gen_code.cryo.status, SignalR) - - cryo_cfg = await cryo_gen_code.read_configuration() - cryo_reading = await cryo_gen_code.read() - - stat_name = cryo_gen_code.cryo.status.name - - assert ( - cryo_cfg.get(stat_name) is None - ), "Status signal should not be in configuration" - assert cryo_reading.get(stat_name) is None, "Status signal should be readable" - - # check if status signal is working - status_reding = await cryo_gen_code.cryo.status.read() - - assert status_reding.get(stat_name) is not None, "Status signal should be readable" - - -async def test_gen_real_node( - clean_generated_file, - nested_struct_sim, - RE, - nested_node_no_re: SECoPNodeDevice, # noqa: N803 -): - - nested_node_no_re.class_from_instance(clean_generated_file) - - # Read the generated file and verify its contents - gen_file = clean_generated_file / "genNodeClass.py" - assert gen_file.exists(), "Generated file should exist" - - generated_code = gen_file.read_text() - - # ===== Assertions for generated command plans ===== - # The ophy_struct module has a test_cmd command - assert "def test_cmd" in generated_code, "test_cmd plan should be generated" - assert ( - "@abstractmethod" in generated_code - ), "Command methods should be marked as abstract" - - # ===== Assertions for generated enum classes ===== - # Enum classes should be generated for enum parameters - # The gas_type parameter in enum1/enum2 modules should generate enum classes - assert ( - "class Test_EnumGas_typeEnum(SupersetEnum):" in generated_code - ), "Enum class for gas_type should be generated" - - # Verify enum members are present - # gas_type enums should have AR, N2, H2 (and CO2 for enum2) - assert "AR" in generated_code, "AR enum member should be present" - assert "N2" in generated_code, "N2 enum member should be present" - assert "H2" in generated_code, "H2 enum member should be present" - assert "CO2" in generated_code, "CO2 enum member should be present" - - # Verify SupersetEnum import - assert ( - "from enum import Enum" in generated_code or "SupersetEnum" in generated_code - ), "Enum import should be present" From 843506ebbccbc03797b2beb1d0ad1939612fd52f Mon Sep 17 00:00:00 2001 From: Peter Braun Date: Wed, 11 Feb 2026 21:50:33 +0100 Subject: [PATCH 12/18] test fixes --- docs/GenNodeCode_Refactoring.md | 205 -------------------------------- src/secop_ophyd/GenNodeCode.py | 3 +- tests/test_enum_merging.py | 4 +- 3 files changed, 4 insertions(+), 208 deletions(-) delete mode 100644 docs/GenNodeCode_Refactoring.md diff --git a/docs/GenNodeCode_Refactoring.md b/docs/GenNodeCode_Refactoring.md deleted file mode 100644 index 0a3f42b..0000000 --- a/docs/GenNodeCode_Refactoring.md +++ /dev/null @@ -1,205 +0,0 @@ -# GenNodeCode Refactoring Documentation - -## Overview - -The `GenNodeCode` class has been refactored to use **Jinja2 templates** and **Black formatting** for generating annotated ophyd device classes. This provides a more maintainable and extensible code generation system. - -## Key Improvements - -### 1. Template-Based Code Generation -- Uses Jinja2 templates for cleaner separation of code structure and generation logic -- Template file: `src/secop_ophyd/templates/generated_classes.py.jinja2` -- Makes it easy to modify the structure of generated classes - -### 2. Black Formatting -- All generated code is automatically formatted with Black -- Ensures consistent, PEP 8-compliant code style -- Makes generated code more readable - -### 3. Dataclass-Based Architecture -New dataclasses for better type safety and clarity: -- `Attribute`: Represents class attributes (name + type) -- `Method`: Represents class methods (name + signature + description) -- `ModuleClass`: Represents a module class to be generated -- `NodeClass`: Represents a node class to be generated - -### 4. Dual Generation Modes - -The refactored `GenNodeCode` supports two ways of generating classes: - -#### Mode 1: Device Introspection (Existing Functionality) -```python -# Generate classes from a fully instantiated SECoP device -node = SECoPNodeDevice(...) -await node.connect() -node.class_from_instance(path_to_module="my_modules") -``` - -This works exactly as before - introspecting the device at runtime. - -#### Mode 2: From JSON Describe (Future Feature) -```python -# Generate classes from SECoP JSON describe message -gen_code = GenNodeCode() -gen_code.from_json_describe(json_describe_message) -gen_code.write_gen_node_class_file() -``` - -This will allow generating classes without needing a running device (planned feature). - -## Usage Examples - -### Basic Usage (Programmatic) - -```python -from secop_ophyd.GenNodeCode import GenNodeCode, Method, Attribute -from inspect import signature - -# Create generator -gen_code = GenNodeCode(path="my_modules") - -# Add imports -gen_code.add_import("ophyd_async.core", "Device") -gen_code.add_import("ophyd_async.core", "SignalR") - -# Define a method -def my_command(self, value: float) -> str: - """Execute a command with a value""" - pass - -method = Method( - cmd_name="execute_command", - description="Execute a command with a value", - cmd_sign=signature(my_command) -) - -# Add a module class with type parameters -gen_code.add_mod_class( - module_cls="MyModule", - bases=["Device", "ABC"], - attrs=[ - ("temperature", "SignalR", "float"), # With type parameter - ("setpoint", "SignalRW", "float"), # With type parameter - ("status", "SignalR", "str"), # With type parameter - ], - cmd_plans=[method], - description="Temperature control module" -) - -# Add a node class -gen_code.add_node_class( - node_cls="MyNode", - bases=["SECoPNodeDevice", "ABC"], - attrs=[("temp_module", "MyModule")], - description="Main node device" -) - -# Generate and write -gen_code.write_gen_node_class_file() -``` - -### Using with Existing Devices - -```python -# This is the standard way - works as before -from secop_ophyd.SECoPDevices import SECoPNodeDevice - -node = SECoPNodeDevice(uri="tcp://localhost:10767", node_name="cryo") -await node.connect() - -# Generate annotated classes for IDE support -# Type parameters are automatically extracted from signal datatypes! -node.class_from_instance(path_to_module="my_modules") -``` - -## Generated Code Example - -The template generates code with type parameters like this: - -```python -from abc import ABC, abstractmethod -from ophyd_async.core import Device, SignalR, SignalRW - - -class MyModule(ABC, Device): - """Temperature control module""" - temperature: SignalR[float] - setpoint: SignalRW[float] - status: SignalR[str] - - @abstractmethod - def execute_command(self, value: float) -> str: - """Execute a command with a value""" - - -class MyNode(ABC, SECoPNodeDevice): - """Main node device""" - temp_module: MyModule -``` - -## Backward Compatibility - -The refactored implementation maintains full backward compatibility: - -- All existing methods work as before -- `Method` class still supports the old `__str__()` method -- `dimport`, `dmodule`, `dnode` attributes still exist (they reference the new structures) -- Old methods like `_write_imports_string()` are kept (but do nothing) - -## Template Customization - -To customize the generated code, edit the template file: -``` -src/secop_ophyd/templates/generated_classes.py.jinja2 -``` - -The template has access to: -- `imports`: Dictionary of module -> set of classes -- `module_classes`: List of ModuleClass objects -- `node_classes`: List of NodeClass objects - -## Future Development: JSON Describe Support - -The placeholder for JSON describe support is in the `from_json_describe()` method: - -```python -def from_json_describe(self, json_data: str | dict): - """Generate classes from a SECoP JSON describe message.""" - # Parse JSON if string - if isinstance(json_data, str): - _ = json.loads(json_data) - - # TODO: Parse the SECoP describe format - # Extract modules, parameters, commands, etc. - # Populate self.module_classes and self.node_classes - raise NotImplementedError("Not yet implemented") -``` - -To implement this, you'll need to: -1. Parse the SECoP JSON describe structure -2. Extract module information (name, properties, commands) -3. Create ModuleClass and NodeClass instances -4. Map SECoP types to Python/ophyd types - -## Testing - -Run the tests: -```bash -pytest tests/test_gencode_refactor.py # New unit tests -pytest tests/test_classgen.py # Existing integration tests -``` - -## Dependencies - -New dependency added to `pyproject.toml`: -- `jinja2`: Template engine for code generation -- `black`: Already in dev dependencies, now used for formatting - -## Migration Notes - -No migration needed! The refactored code is a drop-in replacement. Existing code will continue to work without changes. - -If you want to take advantage of new features: -1. Install jinja2: `pip install jinja2` -2. Use the new dataclasses for more type-safe code generation -3. Customize the template for project-specific needs diff --git a/src/secop_ophyd/GenNodeCode.py b/src/secop_ophyd/GenNodeCode.py index 4e67a8e..ab24563 100644 --- a/src/secop_ophyd/GenNodeCode.py +++ b/src/secop_ophyd/GenNodeCode.py @@ -297,7 +297,8 @@ def _parse_existing_module(self): self._parse_module_class(class_symbol, class_obj) else: raise TypeError( - "Class %s in module %s is not a valid SECoP ophyd device class" + f"Class {class_symbol} in module {module} is not a valid " + "SECoP ophyd device class" ) def _parse_node_class(self, class_symbol: str, class_obj: type): diff --git a/tests/test_enum_merging.py b/tests/test_enum_merging.py index 64ac8a5..b179488 100644 --- a/tests/test_enum_merging.py +++ b/tests/test_enum_merging.py @@ -147,7 +147,7 @@ def test_same_class_different_instances(): # First add_mod_class call gen_code.add_mod_class( module_cls="Test_Enum", - bases=["Device"], + bases=["SECoPDevice"], parameters=[ ParameterAttribute( name="gas_type", @@ -164,7 +164,7 @@ def test_same_class_different_instances(): # Second add_mod_class call (same class name, different enum) gen_code.add_mod_class( module_cls="Test_Enum", - bases=["Device"], + bases=["SECoPDevice"], parameters=[ ParameterAttribute( name="gas_type", From a06ca0c2ea6078e7da60fac3c18f620fed6da8db Mon Sep 17 00:00:00 2001 From: Peter Braun Date: Thu, 12 Feb 2026 15:24:24 +0100 Subject: [PATCH 13/18] testfixes --- src/secop_ophyd/GenNodeCode.py | 122 +++++++++++----- tests/test_ classgen.py | 56 +++++++- tests/test_enum_codegen.py | 254 ++++++++++++++++----------------- tests/test_enum_merging.py | 18 +-- 4 files changed, 269 insertions(+), 181 deletions(-) diff --git a/src/secop_ophyd/GenNodeCode.py b/src/secop_ophyd/GenNodeCode.py index ab24563..b448f48 100644 --- a/src/secop_ophyd/GenNodeCode.py +++ b/src/secop_ophyd/GenNodeCode.py @@ -6,6 +6,7 @@ import re import sys from dataclasses import dataclass, field +from enum import StrEnum from importlib import import_module, reload from inspect import Signature from logging import Logger @@ -180,6 +181,7 @@ def __init__(self, path: str | None = None, log=None): self.imports: dict[str, set[str] | None] = {} self.module_classes: list[ModuleClass] = [] self.node_classes: list[NodeClass] = [] + self.enum_classes: list[EnumClass] = [] self.node_mod: ModuleType | None = None # Required imports for abstract classes @@ -192,7 +194,7 @@ def __init__(self, path: str | None = None, log=None): self.add_import("ophyd_async.core", "StrictEnum") self.add_import("ophyd_async.core", "SupersetEnum") self.add_import("typing", "Any") - self.add_import("numpy") + self.add_import("numpy", "ndarray") self.add_import("secop_ophyd.SECoPDevices", "ParamPath") self.add_import("secop_ophyd.SECoPDevices", "PropPath") # Add necessary Device imports @@ -260,46 +262,37 @@ def _parse_existing_module(self): # Prevent circular import from secop_ophyd.SECoPDevices import ( - SECoPCommunicatorDevice, SECoPDevice, - SECoPMoveableDevice, SECoPNodeDevice, - SECoPReadableDevice, - SECoPTriggerableDevice, - SECoPWritableDevice, ) if self.node_mod is None: return modules = inspect.getmembers(self.node_mod) - results = filter(lambda m: inspect.isclass(m[1]), modules) - - for class_symbol, class_obj in results: - module = class_obj.__module__ - - if module == self.ModName: - # Try SECoP-specific device types first if available - - if issubclass(class_obj, SECoPNodeDevice): - self._parse_node_class(class_symbol, class_obj) - elif issubclass( - class_obj, - ( - SECoPDevice, - SECoPCommunicatorDevice, - SECoPMoveableDevice, - SECoPReadableDevice, - SECoPWritableDevice, - SECoPTriggerableDevice, - ), - ): - self._parse_module_class(class_symbol, class_obj) - else: - raise TypeError( - f"Class {class_symbol} in module {module} is not a valid " - "SECoP ophyd device class" - ) + # Filter to only classes defined in this module, not imported ones + class_members = [ + m + for m in modules + if inspect.isclass(m[1]) and m[1].__module__ == self.node_mod.__name__ + ] + + enum_classes = [m for m in class_members if issubclass(m[1], StrEnum)] + node_classes = [m for m in class_members if issubclass(m[1], SECoPNodeDevice)] + module_classes = [ + m + for m in class_members + if issubclass(m[1], SECoPDevice) and not issubclass(m[1], SECoPNodeDevice) + ] + + for class_symbol, class_obj in enum_classes: + self._parse_enum_class(class_symbol, class_obj) + + for class_symbol, class_obj in node_classes: + self._parse_node_class(class_symbol, class_obj) + + for class_symbol, class_obj in module_classes: + self._parse_module_class(class_symbol, class_obj) def _parse_node_class(self, class_symbol: str, class_obj: type): """Parse a node class from existing module. @@ -387,10 +380,9 @@ def _get_attr_list( module = sig_type.__module__ type_param = ( - sig_type.__name__ - if module == "builtins" - else f"{module}.{sig_type.__name__}" + sig_type.__name__ if module == "builtins" else sig_type.__name__ ) + path_annotation = next( (e for e in extras if isinstance(e, (ParamPath, PropPath))), None ) @@ -400,6 +392,8 @@ def _get_attr_list( format_annotation = next( (e for e in extras if isinstance(e, Format)), None ) + if format_annotation is not None: + format_annotation = f"Format.{format_annotation.name}" # Get description from comments description = descriptions.get(attr_name) @@ -431,6 +425,43 @@ def _get_attr_list( return parameters, properties, modules + def _parse_enum_class(self, class_symbol: str, class_obj: type): + """Parse an enum class from existing module. + + Args: + class_symbol: Name of the class + class_obj: The class object + + """ + # Extract description from docstring + description = inspect.getdoc(class_obj) or "" + + # Extract enum members from class attributes + members = [] + + for attr_name, attr_value in class_obj.__dict__.items(): + # Skip private/magic attributes and methods + if attr_name.startswith("_") or callable(attr_value): + continue + + # Create an EnumMember for each enum value + # attr_name is the member name (e.g., "RAMP") + # attr_value is the member value (e.g., "ramp") + member = EnumMember(name=attr_name, value=attr_value, description=None) + members.append(member) + + bases = [base.__name__ for base in class_obj.__bases__] + + # Create and return the EnumClass + self.enum_classes.append( + EnumClass( + name=class_symbol, + members=members, + description=description, + base_enum_class=bases[0] if bases else "StrictEnum", + ) + ) + def _parse_module_class(self, class_symbol: str, class_obj: type): """Parse a module class from existing module. @@ -456,6 +487,15 @@ def _parse_module_class(self, class_symbol: str, class_obj: type): # Extract description from docstring description = inspect.getdoc(class_obj) or "" + mod_enums: list[EnumClass] = [] + enums = {enum_class.name: enum_class for enum_class in self.enum_classes} + + for param in parameters: + if param.type_param in enums: + enum_class = enums[param.type_param] + if enum_class not in mod_enums: + mod_enums.append(enum_class) + mod_cls = ModuleClass( name=class_symbol, bases=bases, @@ -463,6 +503,7 @@ def _parse_module_class(self, class_symbol: str, class_obj: type): properties=properties, methods=methods, description=description, + enums=mod_enums, ) self.module_classes.append(mod_cls) @@ -772,6 +813,7 @@ def command_plan_no_arg(self, wait_for_idle: bool = False): members=enum_members, description=enum_descr, ) + module_enum_classes.append(enum_cls) # Use the specific enum class name instead of generic @@ -939,6 +981,12 @@ def _collect_all_enums(self) -> list[EnumClass]: if len(enum_list) == 1: # Single enum definition - use StrictEnum _, enum = enum_list[0] + + # an already merged enum read in from a file + if enum.base_enum_class != "StrictEnum": + merged_enums.append(enum) + continue + enum.base_enum_class = "StrictEnum" merged_enums.append(enum) else: @@ -1009,7 +1057,7 @@ def get_type_param(secop_dtype: DataType) -> str | None: if module == "builtins": return sig_type.__name__ - return f"{module}.{sig_type.__name__}" + return sig_type.__name__ def get_type_prop(prop_value) -> str | None: diff --git a/tests/test_ classgen.py b/tests/test_ classgen.py index ef8db8a..3e51f65 100644 --- a/tests/test_ classgen.py +++ b/tests/test_ classgen.py @@ -520,7 +520,6 @@ async def test_gen_cryo_status_not_in_cfg( async def test_gen_real_node( clean_generated_file, nested_struct_sim, - RE, nested_node_no_re: SECoPNodeDevice, # noqa: N803 ): @@ -557,3 +556,58 @@ async def test_gen_real_node( assert ( "from enum import Enum" in generated_code or "SupersetEnum" in generated_code ), "Enum import should be present" + + +async def test_subsequent_real_nodes_with_enum( + clean_generated_file, + cryo_sim, + cryo_node_no_re: SECoPNodeDevice, + nested_struct_sim, + nested_node_no_re: SECoPNodeDevice, +): + + nested_node_no_re.class_from_instance(clean_generated_file) + + # Read the generated file and verify its contents + gen_file = clean_generated_file / "genNodeClass.py" + assert gen_file.exists(), "Generated file should exist" + + generated_code = gen_file.read_text() + + # ===== Assertions for generated enum classes ===== + cls = [ + "class Test_EnumGas_typeEnum(SupersetEnum):", + "class Test_Mod_str(SECoPReadableDevice):", + "class OPHYD_test_primitive_arrays(SECoPReadableDevice):", + "class Test_Enum(SECoPReadableDevice):", + "class Test_ND_arrays(SECoPReadableDevice):", + "class Test_Struct_of_arrays(SECoPReadableDevice):", + "class Ophyd_secop_frappy_demo(SECoPNodeDevice):", + ] + for classs_str in cls: + assert classs_str in generated_code + + cryo_node_no_re.class_from_instance(clean_generated_file) + + # Read the generated file and verify its contents + gen_file = clean_generated_file / "genNodeClass.py" + assert gen_file.exists(), "Generated file should exist" + + generated_code = gen_file.read_text() + + # ===== Assertions for generated enum classes ===== + + cls = [ + "class Test_EnumGas_typeEnum(SupersetEnum):", + "class Test_Mod_str(SECoPReadableDevice):", + "class OPHYD_test_primitive_arrays(SECoPReadableDevice):", + "class Test_Enum(SECoPReadableDevice):", + "class Test_ND_arrays(SECoPReadableDevice):", + "class Test_Struct_of_arrays(SECoPReadableDevice):", + "class Ophyd_secop_frappy_demo(SECoPNodeDevice):", + "class Cryo_7_frappy_demo(SECoPNodeDevice):", + "class Cryostat(SECoPMoveableDevice):", + "class CryostatModeEnum(StrictEnum):", + ] + for classs_str in cls: + assert classs_str in generated_code diff --git a/tests/test_enum_codegen.py b/tests/test_enum_codegen.py index 98c99a0..f1d3805 100644 --- a/tests/test_enum_codegen.py +++ b/tests/test_enum_codegen.py @@ -1,7 +1,5 @@ """Test full code generation with enums.""" -import tempfile - from secop_ophyd.GenNodeCode import ( EnumClass, EnumMember, @@ -11,139 +9,127 @@ ) -def test_code_generation_with_enums(): +def test_code_generation_with_enums(clean_generated_file): """Test that generated code includes correct enum base classes.""" - with tempfile.TemporaryDirectory() as tmpdir: - gen_code = GenNodeCode(path=tmpdir) - - # Create two module classes with different enums for the same parameter - enum1 = EnumClass( - name="MassflowControllerGastypeEnum", - members=[ - EnumMember("H2", "H2", "Hydrogen"), - EnumMember("N2", "N2", "Nitrogen"), - ], - description="Gastype enum for MassflowController", - ) - - enum2 = EnumClass( - name="MassflowControllerGastypeEnum", - members=[ - EnumMember("N2", "N2", "Nitrogen"), - EnumMember("AR", "Ar", "Argon"), - ], - description="Gastype enum for MassflowController", - ) - - mod1 = ModuleClass( - name="MassflowController1", - bases=["Device"], - parameters=[ - ParameterAttribute( - "gastype", "SignalRW", "MassflowControllerGastypeEnum" - ), - ], - properties=[], - enums=[enum1], - ) - - mod2 = ModuleClass( - name="MassflowController2", - bases=["Device"], - parameters=[ - ParameterAttribute( - "gastype", "SignalRW", "MassflowControllerGastypeEnum" - ), - ], - properties=[], - enums=[enum2], - ) - - gen_code.module_classes = [mod1, mod2] - - # Generate code - code = gen_code.generate_code() - - print("Generated code:") - print("=" * 60) - print(code) - print("=" * 60) - - # Verify the enum uses SupersetEnum - assert "class MassflowControllerGastypeEnum(SupersetEnum):" in code - assert "SupersetEnum" in code - - # Verify all three members are present - assert 'H2 = "H2"' in code - assert 'N2 = "N2"' in code - assert 'AR = "Ar"' in code - - print("\n✓ Code generation with SupersetEnum successful!") - - -def test_code_generation_strict_enum(): + gen_code = GenNodeCode(path=clean_generated_file) + + # Create two module classes with different enums for the same parameter + enum1 = EnumClass( + name="MassflowControllerGastypeEnum", + members=[ + EnumMember("H2", "H2", "Hydrogen"), + EnumMember("N2", "N2", "Nitrogen"), + ], + description="Gastype enum for MassflowController", + ) + + enum2 = EnumClass( + name="MassflowControllerGastypeEnum", + members=[ + EnumMember("N2", "N2", "Nitrogen"), + EnumMember("AR", "Ar", "Argon"), + ], + description="Gastype enum for MassflowController", + ) + + mod1 = ModuleClass( + name="MassflowController1", + bases=["Device"], + parameters=[ + ParameterAttribute("gastype", "SignalRW", "MassflowControllerGastypeEnum"), + ], + properties=[], + enums=[enum1], + ) + + mod2 = ModuleClass( + name="MassflowController2", + bases=["Device"], + parameters=[ + ParameterAttribute("gastype", "SignalRW", "MassflowControllerGastypeEnum"), + ], + properties=[], + enums=[enum2], + ) + + gen_code.module_classes = [mod1, mod2] + + # Generate code + code = gen_code.generate_code() + + print("Generated code:") + print("=" * 60) + print(code) + print("=" * 60) + + # Verify the enum uses SupersetEnum + assert "class MassflowControllerGastypeEnum(SupersetEnum):" in code + assert "SupersetEnum" in code + + # Verify all three members are present + assert 'H2 = "H2"' in code + assert 'N2 = "N2"' in code + assert 'AR = "Ar"' in code + + print("\n✓ Code generation with SupersetEnum successful!") + + +def test_code_generation_strict_enum(clean_generated_file): """Test that identical enums generate StrictEnum.""" - with tempfile.TemporaryDirectory() as tmpdir: - gen_code = GenNodeCode(path=tmpdir) - - # Create two module classes with identical enums - enum1 = EnumClass( - name="StatusEnum", - members=[ - EnumMember("IDLE", "idle", "Idle state"), - EnumMember("BUSY", "busy", "Busy state"), - ], - description="Status enum", - ) - - enum2 = EnumClass( - name="StatusEnum", - members=[ - EnumMember("IDLE", "idle", "Idle state"), - EnumMember("BUSY", "busy", "Busy state"), - ], - description="Status enum", - ) - - mod1 = ModuleClass( - name="Device1", - bases=["Device"], - parameters=[ - ParameterAttribute("status", "SignalR", "StatusEnum"), - ], - properties=[], - enums=[enum1], - ) - - mod2 = ModuleClass( - name="Device2", - bases=["Device"], - parameters=[ - ParameterAttribute("status", "SignalR", "StatusEnum"), - ], - properties=[], - enums=[enum2], - ) - - gen_code.module_classes = [mod1, mod2] - - # Generate code - code = gen_code.generate_code() - - print("\nGenerated code with StrictEnum:") - print("=" * 60) - print(code) - print("=" * 60) - - # Verify the enum uses StrictEnum - assert "class StatusEnum(StrictEnum):" in code - - print("\n✓ Code generation with StrictEnum successful!") - - -if __name__ == "__main__": - test_code_generation_with_enums() - test_code_generation_strict_enum() - print("\n✓ All code generation tests passed!") + gen_code = GenNodeCode(path=clean_generated_file) + + # Create two module classes with identical enums + enum1 = EnumClass( + name="StatusEnum", + members=[ + EnumMember("IDLE", "idle", "Idle state"), + EnumMember("BUSY", "busy", "Busy state"), + ], + description="Status enum", + ) + + enum2 = EnumClass( + name="StatusEnum", + members=[ + EnumMember("IDLE", "idle", "Idle state"), + EnumMember("BUSY", "busy", "Busy state"), + ], + description="Status enum", + ) + + mod1 = ModuleClass( + name="Device1", + bases=["Device"], + parameters=[ + ParameterAttribute("status", "SignalR", "StatusEnum"), + ], + properties=[], + enums=[enum1], + ) + + mod2 = ModuleClass( + name="Device2", + bases=["Device"], + parameters=[ + ParameterAttribute("status", "SignalR", "StatusEnum"), + ], + properties=[], + enums=[enum2], + ) + + gen_code.module_classes = [mod1, mod2] + + # Generate code + code = gen_code.generate_code() + + print("\nGenerated code with StrictEnum:") + print("=" * 60) + print(code) + print("=" * 60) + + # Verify the enum uses StrictEnum + assert "class StatusEnum(StrictEnum):" in code + + print("\n✓ Code generation with StrictEnum successful!") diff --git a/tests/test_enum_merging.py b/tests/test_enum_merging.py index b179488..053292b 100644 --- a/tests/test_enum_merging.py +++ b/tests/test_enum_merging.py @@ -4,13 +4,14 @@ EnumClass, EnumMember, GenNodeCode, + ModuleClass, ParameterAttribute, ) -def test_identical_enums_use_strict(): +def test_identical_enums_use_strict(clean_generated_file): """When module instances have identical enum values, use StrictEnum.""" - gen_code = GenNodeCode() + gen_code = GenNodeCode(clean_generated_file) # Create two module classes with identical enums enum1 = EnumClass( @@ -31,7 +32,6 @@ def test_identical_enums_use_strict(): # Add module classes with these enums gen_code.module_classes = [] - from secop_ophyd.GenNodeCode import ModuleClass mod1 = ModuleClass(name="Module1", bases=["Device"], enums=[enum1]) mod2 = ModuleClass(name="Module2", bases=["Device"], enums=[enum2]) @@ -48,9 +48,9 @@ def test_identical_enums_use_strict(): print("✓ Identical enums correctly use StrictEnum") -def test_different_enums_use_superset(): +def test_different_enums_use_superset(clean_generated_file): """When module instances have different enum values, use SupersetEnum.""" - gen_code = GenNodeCode() + gen_code = GenNodeCode(clean_generated_file) # Create two module classes with different enum members enum1 = EnumClass( @@ -90,9 +90,9 @@ def test_different_enums_use_superset(): print("✓ Different enums correctly merged into SupersetEnum") -def test_single_enum_uses_strict(): +def test_single_enum_uses_strict(clean_generated_file): """When only one module has an enum, use StrictEnum.""" - gen_code = GenNodeCode() + gen_code = GenNodeCode(clean_generated_file) enum1 = EnumClass( name="TestParamEnum", @@ -118,10 +118,10 @@ def test_single_enum_uses_strict(): print("✓ Single enum correctly uses StrictEnum") -def test_same_class_different_instances(): +def test_same_class_different_instances(clean_generated_file): """When same module class has different instances with different enums, use SupersetEnum.""" - gen_code = GenNodeCode() + gen_code = GenNodeCode(clean_generated_file) # Simulate adding the same module class twice (different instances) # First instance with AR, N2, H2 From 63f142be364fd49f69f5d93e8a021fb8ed60a9a5 Mon Sep 17 00:00:00 2001 From: Peter Braun Date: Thu, 12 Feb 2026 17:17:00 +0100 Subject: [PATCH 14/18] test fixes --- src/secop_ophyd/SECoPDevices.py | 1 + tests/conftest.py | 25 ++++--- tests/{test_ classgen.py => test_classgen.py} | 0 tests/test_commands.py | 67 ++++++++++--------- 4 files changed, 52 insertions(+), 41 deletions(-) rename tests/{test_ classgen.py => test_classgen.py} (100%) diff --git a/src/secop_ophyd/SECoPDevices.py b/src/secop_ophyd/SECoPDevices.py index cccd1fd..03087b6 100644 --- a/src/secop_ophyd/SECoPDevices.py +++ b/src/secop_ophyd/SECoPDevices.py @@ -576,6 +576,7 @@ def collect(self) -> Iterator[PartialEvent]: class SECoPDevice(StandardReadable): clients: Dict[str, AsyncFrappyClient] = {} + node_id: str sri: str host: str diff --git a/tests/conftest.py b/tests/conftest.py index efd7d1e..fda6c53 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -20,6 +20,16 @@ from secop_ophyd.SECoPDevices import SECoPNodeDevice +@pytest.fixture(autouse=True) +def cleanup_secop_clients(): + """Clear SECoP clients between tests to ensure fresh connections.""" + yield + # After each test, clear the cached clients + from secop_ophyd.SECoPDevices import SECoPDevice + + SECoPDevice.clients.clear() + + @pytest.fixture def clean_generated_file(): """Clean up generated genNodeClass.py file before test runs. @@ -153,7 +163,7 @@ def filter(self, record): return logger -@pytest.fixture +@pytest.fixture() async def async_frappy_client(cryo_sim, logger, port="10769"): client = AsyncFrappyClient(host="localhost", port=port, log=logger) @@ -162,7 +172,7 @@ async def async_frappy_client(cryo_sim, logger, port="10769"): return client -@pytest.fixture +@pytest.fixture() async def nested_client(nested_struct_sim, logger, port="10771"): client = AsyncFrappyClient(host="localhost", port=port, log=logger) @@ -171,13 +181,13 @@ async def nested_client(nested_struct_sim, logger, port="10771"): return client -@pytest.fixture +@pytest.fixture() async def RE(): # noqa: N802 re = RunEngine({}) return re -@pytest.fixture +@pytest.fixture() async def nested_node_no_re(): async with init_devices(): nested = SECoPNodeDevice( @@ -187,7 +197,7 @@ async def nested_node_no_re(): return nested -@pytest.fixture +@pytest.fixture() def nested_node(RE): # noqa: N803 with init_devices(): nested = SECoPNodeDevice( @@ -197,17 +207,16 @@ def nested_node(RE): # noqa: N803 return nested -@pytest.fixture +@pytest.fixture() async def cryo_node_no_re(): async with init_devices(): cryo = SECoPNodeDevice( sec_node_uri="localhost:10769", ) - return cryo -@pytest.fixture +@pytest.fixture() def cryo_node(RE): # noqa: N803 with init_devices(): cryo = SECoPNodeDevice( diff --git a/tests/test_ classgen.py b/tests/test_classgen.py similarity index 100% rename from tests/test_ classgen.py rename to tests/test_classgen.py diff --git a/tests/test_commands.py b/tests/test_commands.py index abf2925..8bff0de 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -12,39 +12,6 @@ ) -async def test_stop_cmd(cryo_sim, cryo_node_no_re: SECoPNodeDevice): - cryo: SECoPMoveableDevice = cryo_node_no_re.cryo - - await cryo.window.set(5) - - await cryo.tolerance.set(1) - - await cryo.ramp.set(20) - - stat = cryo.set(15) - - await asyncio.sleep(3) - - # essentially a NOOP (stops are only passed through to SECoP on success=False) - await cryo.stop(success=True) - assert cryo._stopped is False, "Move should not be stopped when success=True" - assert ( - cryo._success is True - ), "Move should be marked as successful when success=True" - assert ( - not stat.done - ), "Move should still be in progress after stop with success=True" - - # move is still going on - await cryo.stop(success=False) - assert cryo._stopped is True, "Move should be stopped when success=False" - assert ( - cryo._success is False - ), "Move should be marked as unsuccessful when success=False" - - await stat - - async def test_struct_inp_cmd(nested_struct_sim, nested_node_no_re: SECoPNodeDevice): test_cmd: SECoPCMDDevice = nested_node_no_re.ophy_struct.test_cmd_CMD @@ -116,3 +83,37 @@ async def test_secop_triggering_cmd_dev( reading_res = await res.read() assert isinstance(reading_res.get(res.name)["value"], int) + + +async def test_stop_cmd(cryo_sim, cryo_node_no_re: SECoPNodeDevice): + cryo: SECoPMoveableDevice = cryo_node_no_re.cryo + + await cryo.window.set(5) + + await cryo.tolerance.set(1) + + await cryo.ramp.set(20) + + stat = cryo.set(15) + + await asyncio.sleep(3) + + # essentially a NOOP (stops are only passed through to SECoP on success=False) + await cryo.stop(success=True) + + assert cryo._stopped is False, "Move should not be stopped when success=True" + assert ( + cryo._success is True + ), "Move should be marked as successful when success=True" + assert ( + not stat.done + ), "Move should still be in progress after stop with success=True" + + # move is still going on + await cryo.stop(success=False) + assert cryo._stopped is True, "Move should be stopped when success=False" + assert ( + cryo._success is False + ), "Move should be marked as unsuccessful when success=False" + + await stat From f3d3b814e5931e6dbf4123f151da8e796fd0b2cb Mon Sep 17 00:00:00 2001 From: Peter Braun Date: Mon, 16 Feb 2026 14:31:36 +0100 Subject: [PATCH 15/18] fixes for long and multiline param/property descriptioins added tests for shall descriptive data~ --- src/secop_ophyd/GenNodeCode.py | 105 +++++- .../templates/generated_classes.py.jinja2 | 10 +- tests/conftest.py | 26 ++ .../SHALL_mass_spec_describe.txt | 344 ++++++++++++++++++ .../SHALL_mass_spec_describe_no_impl_prop.txt | 343 +++++++++++++++++ tests/test_classgen.py | 102 +++++- 6 files changed, 899 insertions(+), 31 deletions(-) create mode 100644 tests/static_test_data/SHALL_mass_spec_describe.txt create mode 100644 tests/static_test_data/SHALL_mass_spec_describe_no_impl_prop.txt diff --git a/src/secop_ophyd/GenNodeCode.py b/src/secop_ophyd/GenNodeCode.py index b448f48..2d463e0 100644 --- a/src/secop_ophyd/GenNodeCode.py +++ b/src/secop_ophyd/GenNodeCode.py @@ -183,6 +183,8 @@ def __init__(self, path: str | None = None, log=None): self.node_classes: list[NodeClass] = [] self.enum_classes: list[EnumClass] = [] self.node_mod: ModuleType | None = None + self.inline_comment_threshold: int = 120 + self.comment_wrap_width: int = 100 # Required imports for abstract classes self.add_import("abc", "abstractmethod") @@ -331,27 +333,77 @@ def _extract_descriptions_from_source(self, class_obj: type) -> dict[str, str]: descriptions = {} try: source = inspect.getsource(class_obj) - for line in source.split("\n"): - # Skip lines without comments - if "#" not in line: - continue + lines = source.split("\n") + idx = 0 + + def _comment_text(raw_comment: str) -> str: + text = raw_comment + if text.startswith(" "): + text = text[1:] + return text.rstrip() - # Extract the part before # (attribute assignment) - code_part, comment_part = line.split("#", 1) + while idx < len(lines): + line = lines[idx] + stripped_line = line.lstrip() # Find attribute name (e.g., "count" from "count: A[SignalRW[int],...") - match = re.match(r"\s*(\w+)\s*:", code_part) - if match: - attr_name = match.group(1) - description = comment_part.strip() - if description: - descriptions[attr_name] = description + # and ignore class/function/decorator lines. + if stripped_line.startswith(("class ", "def ", "@")): + idx += 1 + continue + + match = re.match(r"\s*(\w+)\s*:", line) + if not match: + idx += 1 + continue + + attr_name = match.group(1) + description_lines: list[str] = [] + + # Optional inline comment on the attribute declaration line + if "#" in line: + _, comment_part = line.split("#", 1) + description_lines.append(_comment_text(comment_part)) + + # Collect multiline comment block continuations below declaration: + # attr: Type + # # first line + # # second line + next_idx = idx + 1 + while next_idx < len(lines): + next_line = lines[next_idx] + stripped = next_line.lstrip() + + if not stripped.startswith("#"): + break + + continuation = _comment_text(stripped[1:]) + description_lines.append(continuation) + next_idx += 1 + + description = "\n".join(description_lines).rstrip() + if description: + descriptions[attr_name] = description + + idx = next_idx except Exception as e: if self.log: self.log.debug(f"Could not extract descriptions from source: {e}") return descriptions + def _normalize_description(self, description: str | None) -> str: + """Normalize description text for generated comments. + + - Trim trailing whitespace/newlines + - Preserve intentional internal newlines + """ + if description is None: + return "" + + normalized = description.rstrip() + return normalized if normalized else "" + def _get_attr_list( self, class_obj: type ) -> tuple[ @@ -697,9 +749,15 @@ def from_json_describe(self, json_data: str | dict): # Add the module class, use self reported "implementation" module property, # if not present use the module name module_class = modname - if properties.get("implementation") is not None: + if properties.get("implementation", ""): module_class = properties.get("implementation", "").split(".").pop() + module_class_list = ( + module_class.replace(" ", "_").replace("-", "_").split("_") + ) + + module_class = "".join(word.capitalize() for word in module_class_list) + # Module enum classes module_enum_classes = [] @@ -748,10 +806,15 @@ def command_plan_no_arg(self, wait_for_idle: bool = False): for param_name, param_data in parameters.items(): - descr = param_data["description"] + descr = self._normalize_description(param_data.get("description", "")) unit = param_data["datainfo"].get("unit") - param_descr = f"{descr}; Unit: ({unit})" if unit else descr + if unit: + param_descr = ( + f"{descr}; Unit: ({unit})" if descr else f"Unit: ({unit})" + ) + else: + param_descr = descr signal_base = SignalR if param_data["readonly"] else SignalRW format = None @@ -786,7 +849,15 @@ def command_plan_no_arg(self, wait_for_idle: bool = False): if type_param and "StrictEnum" in type_param: # Generate unique enum class name: # ModuleClass + ParamName + Enum - enum_class_name = f"{module_class}{param_name.capitalize()}Enum" + param_name_list = ( + param_name.replace(" ", "_").replace("-", "_").split("_") + ) + + param_name_camel = "".join( + word.capitalize() for word in param_name_list + ) + + enum_class_name = f"{module_class}_{param_name_camel}_Enum" # Extract enum members from datainfo enum_members_dict = datainfo.get("members", {}) @@ -921,6 +992,8 @@ def generate_code(self) -> str: "module_classes": self.module_classes, "node_classes": self.node_classes, "enum_classes": self._collect_all_enums(), + "inline_comment_threshold": self.inline_comment_threshold, + "comment_wrap_width": self.comment_wrap_width, } # Render template diff --git a/src/secop_ophyd/templates/generated_classes.py.jinja2 b/src/secop_ophyd/templates/generated_classes.py.jinja2 index 574a52f..9911c92 100644 --- a/src/secop_ophyd/templates/generated_classes.py.jinja2 +++ b/src/secop_ophyd/templates/generated_classes.py.jinja2 @@ -51,7 +51,15 @@ class {{ module_cls.name }}({{ module_cls.bases | join(', ') }}): {%- else %} {{ attr.name }}: {{ attr.type }}{% if attr.type_param %}[{{ attr.type_param }}]{% endif %} {%- endif %} -{%- if attr.description %} # {{ attr.description }}{% endif %} +{%- if attr.description %} +{%- set render_below = '\n' in attr.description or attr.description|length > inline_comment_threshold %} +{%- if render_below %} +{%- set wrapped_desc = attr.description | wordwrap(comment_wrap_width, break_long_words=False, wrapstring='\n') %} + # {{ (wrapped_desc | replace('\n', '\n # ')) ~ '\n' }} + +{%- else %} # {{ attr.description }} +{%- endif %} +{%- endif %} {%- endfor %} {%- endif %} {%- if module_cls.methods %} diff --git a/tests/conftest.py b/tests/conftest.py index fda6c53..baf9027 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -30,6 +30,32 @@ def cleanup_secop_clients(): SECoPDevice.clients.clear() +@pytest.fixture +def mass_spectrometer_description(): + mass_spectrometer_description = ( + Path(__file__).parent / "static_test_data" / "SHALL_mass_spec_describe.txt" + ) + + with mass_spectrometer_description.open() as f: + description = f.read() + + return description + + +@pytest.fixture +def mass_spectrometer_description_no_impl(): + mass_spectrometer_description = ( + Path(__file__).parent + / "static_test_data" + / "SHALL_mass_spec_describe_no_impl_prop.txt" + ) + + with mass_spectrometer_description.open() as f: + description = f.read() + + return description + + @pytest.fixture def clean_generated_file(): """Clean up generated genNodeClass.py file before test runs. diff --git a/tests/static_test_data/SHALL_mass_spec_describe.txt b/tests/static_test_data/SHALL_mass_spec_describe.txt new file mode 100644 index 0000000..de9a6f3 --- /dev/null +++ b/tests/static_test_data/SHALL_mass_spec_describe.txt @@ -0,0 +1,344 @@ +{ + "description":"This is the node for controllers and monitors where n devices (defined in hardware modules.cfg) call n modules.", + "equipment_id":"Hiden_MS", + "firmware":"SHALL server library (Git 3d17a8943d609f0e96cdd452244b4b4bf5243a44)", + "modules":{ + "mass_spec":{ + "accessibles":{ + "_autorange_high":{ + "datainfo":{ + "type":"double", + "unit":"mbar" + }, + "description":"This the highest range to which the input device may autorange; ", + "group":"acquisition_range", + "pollinterval":27.0, + "readonly":true + }, + "_autorange_low":{ + "datainfo":{ + "type":"double", + "unit":"mbar" + }, + "description":"The lowest range to which the input device may autorange", + "group":"acquisition_range", + "pollinterval":27.0, + "readonly":true + }, + "_cage":{ + "datainfo":{ + "type":"double", + "unit":"V" + }, + "description":" This is the Ion Source Cage voltage which controls the Ion Energy. The higher the Ion Energy the faster the Ions travel through the Mass Filter to the Detector, this reduces the oscillation effect caused by the RF which is applied to the filter.\n", + "group":"global_rga_parameters", + "pollinterval":27.0, + "readonly":true + }, + "_delta_m":{ + "datainfo":{ + "type":"double", + "unit":"%" + }, + "description":"The low mass peak width/valley adjustment used during set up and maintenance. Can also affect the high masses and should be adjusted in conjunction with the Resolution\n ", + "group":"global_rga_parameters", + "pollinterval":27.0, + "readonly":true + }, + "_dwell":{ + "datainfo":{ + "type":"double", + "unit":"%" + }, + "description":"Defines the time used to acquire a single point in the scan. Given as a percentage of the default settle time for the current range.", + "group":"acquisition_range", + "pollinterval":27.0, + "readonly":true + }, + "_electron_energy":{ + "datainfo":{ + "max":200.0, + "min":0.0, + "type":"double", + "unit":"V" + }, + "description":"The Electron energy is used to define the filament potential in the Ion Source. This is used to change the Appearance Potential of gasses with similar mass footprints so they can be looked at individually.", + "group":"global_rga_parameters", + "pollinterval":27.0, + "readonly":true + }, + "_emission":{ + "datainfo":{ + "type":"double", + "unit":"uA" + }, + "description":"The Emission current is the current which flows from the active filament to the Ion Source Cage. An increase in Emission Current causes an increase in peak intensity, can be used to increase/reduce the peak intensities.", + "group":"global_rga_parameters", + "pollinterval":27.0, + "readonly":true + }, + "_end_mass":{ + "datainfo":{ + "max":200.0, + "min":0.4, + "type":"double", + "unit":"amu" + }, + "description":"End mass number for a bar scan", + "group":"BAR_SCAN", + "pollinterval":24.0, + "readonly":false + }, + "_focus":{ + "datainfo":{ + "type":"double", + "unit":"V" + }, + "description":" This is the voltage applied to the Focus plate. This is used to extract the positive Ions from the source and into the Mass Filter, and also to block the transmission of electrons.", + "group":"global_rga_parameters", + "pollinterval":27.0, + "readonly":true + }, + "_increment":{ + "datainfo":{ + "max":20.0, + "min":0.0, + "type":"double", + "unit":"amu" + }, + "description":"Incremental mass for Bar scan ", + "group":"BAR_SCAN", + "pollinterval":27.0, + "readonly":false + }, + "_mass":{ + "datainfo":{ + "maxlen":600, + "members":{ + "type":"double" + }, + "type":"array" + }, + "description":"mass number configuration", + "pollinterval":3.0, + "readonly":true + }, + "_measure_device":{ + "datainfo":{ + "members":{ + "FARADAY":0, + "SEM":1, + "VACCUM":6 + }, + "type":"enum" + }, + "description":"Selects the detector for a bar scan", + "group":"BAR_SCAN", + "pollinterval":24.0, + "readonly":false + }, + "_mid_descriptor":{ + "datainfo":{ + "members":{ + "device":{ + "maxlen":50, + "members":{ + "type":"double" + }, + "type":"array" + }, + "mass":{ + "maxlen":50, + "members":{ + "type":"double" + }, + "type":"array", + "unit":"amu" + } + }, + "type":"struct" + }, + "description":" Data structure that describes an MID Scan. (massnumber and measurement device for each massnumber) \n Example:\n {\n mass: [12,15,28,75],\n device: [FARADAY,SEM,SEM,SEM]\n }", + "pollinterval":24.0, + "readonly":false + }, + "_multiplier":{ + "datainfo":{ + "type":"double", + "unit":"V" + }, + "description":"The voltage applied to the SEM detector; with a PIC this should be set so the SEM operates in the Plateau Region. With an Analogue system this should be set to 1000 gain, i.e. a scan in Faraday should be equal height using the SEM detector.\n ", + "group":"global_rga_parameters", + "pollinterval":27.0, + "readonly":true + }, + "_resolution":{ + "datainfo":{ + "type":"double", + "unit":"%" + }, + "description":" The high mass peak width/valley adjustment used during set up and maintenance. Can also affect the low masses and should be adjusted in conjunction with the Delta-M.\n ", + "group":"global_rga_parameters", + "pollinterval":27.0, + "readonly":true + }, + "_scan_cycle":{ + "datainfo":{ + "members":{ + "CONTINUOUS":0, + "SINGLE":1 + }, + "type":"enum" + }, + "description":"indicates if in single or continuous cycle mode", + "pollinterval":24.0, + "readonly":false + }, + "_settle":{ + "datainfo":{ + "type":"double", + "unit":"%" + }, + "description":"Defines the time to allow the electronics to settle before the scan is started. Given as a percentage of the default settle time for the current range.", + "group":"acquisition_range", + "pollinterval":27.0, + "readonly":true + }, + "_start_mass":{ + "datainfo":{ + "max":200.0, + "min":0.4, + "type":"double", + "unit":"amu" + }, + "description":"Start mass number for a bar scan", + "group":"BAR_SCAN", + "pollinterval":24.0, + "readonly":false + }, + "_start_range":{ + "datainfo":{ + "type":"double", + "unit":"mbar" + }, + "description":"Contains the range used at the start of a scan.", + "group":"acquisition_range", + "pollinterval":27.0, + "readonly":true + }, + "_vacuum_pressure":{ + "datainfo":{ + "max":2000.0, + "min":0.0, + "type":"double", + "unit":"mbar" + }, + "description":"Pressure inside the measurement chamber", + "pollinterval":3.0, + "readonly":true + }, + "go":{ + "datainfo":{ + "argument":null, + "result":null, + "type":"command" + }, + "description":"starts new scan, or initiates the continuous scan", + "pollinterval":27.0 + }, + "mode":{ + "datainfo":{ + "members":{ + "BAR_SCAN":1, + "MID_SCAN":0 + }, + "type":"enum" + }, + "description":"indicates the current scan mode", + "pollinterval":24.0, + "readonly":false + }, + "status":{ + "datainfo":{ + "members":[ + { + "members":{ + "BUSY":300, + "IDLE":100, + "PREPARING":340 + }, + "type":"enum" + }, + { + "isUTF8":true, + "type":"string" + } + ], + "type":"tuple" + }, + "description":"current status of the module ", + "pollinterval":3.0, + "readonly":false + }, + "stop":{ + "datainfo":{ + "argument":null, + "result":null, + "type":"command" + }, + "description":"stops running scan module goes back to IDLE", + "pollinterval":27.0 + }, + "value":{ + "datainfo":{ + "maxlen":600, + "members":{ + "type":"double" + }, + "type":"array" + }, + "description":"Full or partial spectrum of the currently running/last complete scan", + "pollinterval":3.0, + "readonly":true + } + }, + "description":"Analyses the gas after the reactor cell by mass spectrometry.", + "implementation":"mass_spec", + "interface_classes":[ + "Readable", + "Triggerable" + ], + "order":[ + "value", + "status", + "_vacuum_pressure", + "mode", + "_mid_descriptor", + "_measure_device", + "_start_mass", + "_end_mass", + "_scan_cycle", + "_electron_energy", + "_emission", + "_focus", + "_cage", + "_resolution", + "_delta_m", + "_start_range", + "_autorange_high", + "_autorange_low", + "_settle", + "_dwell", + "go", + "stop", + "_multiplier", + "_increment", + "_mass" + ], + "pollinterval":3.0 + } + }, + "order":[ + "mass_spec" + ] +} diff --git a/tests/static_test_data/SHALL_mass_spec_describe_no_impl_prop.txt b/tests/static_test_data/SHALL_mass_spec_describe_no_impl_prop.txt new file mode 100644 index 0000000..f9cad50 --- /dev/null +++ b/tests/static_test_data/SHALL_mass_spec_describe_no_impl_prop.txt @@ -0,0 +1,343 @@ +{ + "description":"This is the node for controllers and monitors where n devices (defined in hardware modules.cfg) call n modules.", + "equipment_id":"Hiden_MS", + "firmware":"SHALL server library (Git 3d17a8943d609f0e96cdd452244b4b4bf5243a44)", + "modules":{ + "mass_spec":{ + "accessibles":{ + "_autorange_high":{ + "datainfo":{ + "type":"double", + "unit":"mbar" + }, + "description":"This the highest range to which the input device may autorange; ", + "group":"acquisition_range", + "pollinterval":27.0, + "readonly":true + }, + "_autorange_low":{ + "datainfo":{ + "type":"double", + "unit":"mbar" + }, + "description":"The lowest range to which the input device may autorange", + "group":"acquisition_range", + "pollinterval":27.0, + "readonly":true + }, + "_cage":{ + "datainfo":{ + "type":"double", + "unit":"V" + }, + "description":" This is the Ion Source Cage voltage which controls the Ion Energy. The higher the Ion Energy the faster the Ions travel through the Mass Filter to the Detector, this reduces the oscillation effect caused by the RF which is applied to the filter.\n", + "group":"global_rga_parameters", + "pollinterval":27.0, + "readonly":true + }, + "_delta_m":{ + "datainfo":{ + "type":"double", + "unit":"%" + }, + "description":"The low mass peak width/valley adjustment used during set up and maintenance. Can also affect the high masses and should be adjusted in conjunction with the Resolution\n ", + "group":"global_rga_parameters", + "pollinterval":27.0, + "readonly":true + }, + "_dwell":{ + "datainfo":{ + "type":"double", + "unit":"%" + }, + "description":"Defines the time used to acquire a single point in the scan. Given as a percentage of the default settle time for the current range.", + "group":"acquisition_range", + "pollinterval":27.0, + "readonly":true + }, + "_electron_energy":{ + "datainfo":{ + "max":200.0, + "min":0.0, + "type":"double", + "unit":"V" + }, + "description":"The Electron energy is used to define the filament potential in the Ion Source. This is used to change the Appearance Potential of gasses with similar mass footprints so they can be looked at individually.", + "group":"global_rga_parameters", + "pollinterval":27.0, + "readonly":true + }, + "_emission":{ + "datainfo":{ + "type":"double", + "unit":"uA" + }, + "description":"The Emission current is the current which flows from the active filament to the Ion Source Cage. An increase in Emission Current causes an increase in peak intensity, can be used to increase/reduce the peak intensities.", + "group":"global_rga_parameters", + "pollinterval":27.0, + "readonly":true + }, + "_end_mass":{ + "datainfo":{ + "max":200.0, + "min":0.4, + "type":"double", + "unit":"amu" + }, + "description":"End mass number for a bar scan", + "group":"BAR_SCAN", + "pollinterval":24.0, + "readonly":false + }, + "_focus":{ + "datainfo":{ + "type":"double", + "unit":"V" + }, + "description":" This is the voltage applied to the Focus plate. This is used to extract the positive Ions from the source and into the Mass Filter, and also to block the transmission of electrons.", + "group":"global_rga_parameters", + "pollinterval":27.0, + "readonly":true + }, + "_increment":{ + "datainfo":{ + "max":20.0, + "min":0.0, + "type":"double", + "unit":"amu" + }, + "description":"Incremental mass for Bar scan ", + "group":"BAR_SCAN", + "pollinterval":27.0, + "readonly":false + }, + "_mass":{ + "datainfo":{ + "maxlen":600, + "members":{ + "type":"double" + }, + "type":"array" + }, + "description":"mass number configuration", + "pollinterval":3.0, + "readonly":true + }, + "_measure_device":{ + "datainfo":{ + "members":{ + "FARADAY":0, + "SEM":1, + "VACCUM":6 + }, + "type":"enum" + }, + "description":"Selects the detector for a bar scan", + "group":"BAR_SCAN", + "pollinterval":24.0, + "readonly":false + }, + "_mid_descriptor":{ + "datainfo":{ + "members":{ + "device":{ + "maxlen":50, + "members":{ + "type":"double" + }, + "type":"array" + }, + "mass":{ + "maxlen":50, + "members":{ + "type":"double" + }, + "type":"array", + "unit":"amu" + } + }, + "type":"struct" + }, + "description":" Data structure that describes an MID Scan. (massnumber and measurement device for each massnumber) \n Example:\n {\n mass: [12,15,28,75],\n device: [FARADAY,SEM,SEM,SEM]\n }", + "pollinterval":24.0, + "readonly":false + }, + "_multiplier":{ + "datainfo":{ + "type":"double", + "unit":"V" + }, + "description":"The voltage applied to the SEM detector; with a PIC this should be set so the SEM operates in the Plateau Region. With an Analogue system this should be set to 1000 gain, i.e. a scan in Faraday should be equal height using the SEM detector.\n ", + "group":"global_rga_parameters", + "pollinterval":27.0, + "readonly":true + }, + "_resolution":{ + "datainfo":{ + "type":"double", + "unit":"%" + }, + "description":" The high mass peak width/valley adjustment used during set up and maintenance. Can also affect the low masses and should be adjusted in conjunction with the Delta-M.\n ", + "group":"global_rga_parameters", + "pollinterval":27.0, + "readonly":true + }, + "_scan_cycle":{ + "datainfo":{ + "members":{ + "CONTINUOUS":0, + "SINGLE":1 + }, + "type":"enum" + }, + "description":"indicates if in single or continuous cycle mode", + "pollinterval":24.0, + "readonly":false + }, + "_settle":{ + "datainfo":{ + "type":"double", + "unit":"%" + }, + "description":"Defines the time to allow the electronics to settle before the scan is started. Given as a percentage of the default settle time for the current range.", + "group":"acquisition_range", + "pollinterval":27.0, + "readonly":true + }, + "_start_mass":{ + "datainfo":{ + "max":200.0, + "min":0.4, + "type":"double", + "unit":"amu" + }, + "description":"Start mass number for a bar scan", + "group":"BAR_SCAN", + "pollinterval":24.0, + "readonly":false + }, + "_start_range":{ + "datainfo":{ + "type":"double", + "unit":"mbar" + }, + "description":"Contains the range used at the start of a scan.", + "group":"acquisition_range", + "pollinterval":27.0, + "readonly":true + }, + "_vacuum_pressure":{ + "datainfo":{ + "max":2000.0, + "min":0.0, + "type":"double", + "unit":"mbar" + }, + "description":"Pressure inside the measurement chamber", + "pollinterval":3.0, + "readonly":true + }, + "go":{ + "datainfo":{ + "argument":null, + "result":null, + "type":"command" + }, + "description":"starts new scan, or initiates the continuous scan", + "pollinterval":27.0 + }, + "mode":{ + "datainfo":{ + "members":{ + "BAR_SCAN":1, + "MID_SCAN":0 + }, + "type":"enum" + }, + "description":"indicates the current scan mode", + "pollinterval":24.0, + "readonly":false + }, + "status":{ + "datainfo":{ + "members":[ + { + "members":{ + "BUSY":300, + "IDLE":100, + "PREPARING":340 + }, + "type":"enum" + }, + { + "isUTF8":true, + "type":"string" + } + ], + "type":"tuple" + }, + "description":"current status of the module ", + "pollinterval":3.0, + "readonly":false + }, + "stop":{ + "datainfo":{ + "argument":null, + "result":null, + "type":"command" + }, + "description":"stops running scan module goes back to IDLE", + "pollinterval":27.0 + }, + "value":{ + "datainfo":{ + "maxlen":600, + "members":{ + "type":"double" + }, + "type":"array" + }, + "description":"Full or partial spectrum of the currently running/last complete scan", + "pollinterval":3.0, + "readonly":true + } + }, + "description":"Analyses the gas after the reactor cell by mass spectrometry.", + "interface_classes":[ + "Readable", + "Triggerable" + ], + "order":[ + "value", + "status", + "_vacuum_pressure", + "mode", + "_mid_descriptor", + "_measure_device", + "_start_mass", + "_end_mass", + "_scan_cycle", + "_electron_energy", + "_emission", + "_focus", + "_cage", + "_resolution", + "_delta_m", + "_start_range", + "_autorange_high", + "_autorange_low", + "_settle", + "_dwell", + "go", + "stop", + "_multiplier", + "_increment", + "_mass" + ], + "pollinterval":3.0 + } + }, + "order":[ + "mass_spec" + ] +} diff --git a/tests/test_classgen.py b/tests/test_classgen.py index 3e51f65..b9c44fe 100644 --- a/tests/test_classgen.py +++ b/tests/test_classgen.py @@ -542,7 +542,7 @@ async def test_gen_real_node( # Enum classes should be generated for enum parameters # The gas_type parameter in enum1/enum2 modules should generate enum classes assert ( - "class Test_EnumGas_typeEnum(SupersetEnum):" in generated_code + "class TestEnum_GasType_Enum(SupersetEnum):" in generated_code ), "Enum class for gas_type should be generated" # Verify enum members are present @@ -576,12 +576,12 @@ async def test_subsequent_real_nodes_with_enum( # ===== Assertions for generated enum classes ===== cls = [ - "class Test_EnumGas_typeEnum(SupersetEnum):", - "class Test_Mod_str(SECoPReadableDevice):", - "class OPHYD_test_primitive_arrays(SECoPReadableDevice):", - "class Test_Enum(SECoPReadableDevice):", - "class Test_ND_arrays(SECoPReadableDevice):", - "class Test_Struct_of_arrays(SECoPReadableDevice):", + "class TestEnum_GasType_Enum(SupersetEnum):", + "class TestModStr(SECoPReadableDevice):", + "class OphydTestPrimitiveArrays(SECoPReadableDevice):", + "class TestEnum(SECoPReadableDevice):", + "class TestNdArrays(SECoPReadableDevice):", + "class TestStructOfArrays(SECoPReadableDevice):", "class Ophyd_secop_frappy_demo(SECoPNodeDevice):", ] for classs_str in cls: @@ -598,16 +598,90 @@ async def test_subsequent_real_nodes_with_enum( # ===== Assertions for generated enum classes ===== cls = [ - "class Test_EnumGas_typeEnum(SupersetEnum):", - "class Test_Mod_str(SECoPReadableDevice):", - "class OPHYD_test_primitive_arrays(SECoPReadableDevice):", - "class Test_Enum(SECoPReadableDevice):", - "class Test_ND_arrays(SECoPReadableDevice):", - "class Test_Struct_of_arrays(SECoPReadableDevice):", + "class TestEnum_GasType_Enum(SupersetEnum):", + "class TestModStr(SECoPReadableDevice):", + "class OphydTestPrimitiveArrays(SECoPReadableDevice):", + "class TestEnum(SECoPReadableDevice):", + "class TestNdArrays(SECoPReadableDevice):", + "class TestStructOfArrays(SECoPReadableDevice):", "class Ophyd_secop_frappy_demo(SECoPNodeDevice):", "class Cryo_7_frappy_demo(SECoPNodeDevice):", "class Cryostat(SECoPMoveableDevice):", - "class CryostatModeEnum(StrictEnum):", + "class Cryostat_Mode_Enum(StrictEnum):", ] for classs_str in cls: assert classs_str in generated_code + + +def test_gen_shall_mass_spec_node( + clean_generated_file, mass_spectrometer_description: str +): + """Test generating code for the SHALL mass spectrometer node using a + real description.""" + + gen_code = GenNodeCode(path=str(clean_generated_file)) + + gen_code.from_json_describe(mass_spectrometer_description) + + gen_code.write_gen_node_class_file() + + gen_file = clean_generated_file / "genNodeClass.py" + assert gen_file.exists(), "Generated file should exist" + + generated_code = gen_file.read_text() + + # Trailing newlines in source descriptions should not produce broken split comments + assert "\n# ; Unit: (V)" not in generated_code + assert "\n# ; Unit: (%)" not in generated_code + + # Intentionally multiline descriptions should be rendered as multiline comments + assert ( + 'mid_descriptor: A[SignalRW[ndarray], ParamPath("mass_spec:mid_descriptor")]' + in generated_code + ) + assert "# Example:" in generated_code + assert "# {" in generated_code + assert "# mass: [12,15,28,75]," in generated_code + assert "# device: [FARADAY,SEM,SEM,SEM]" in generated_code + + # Long descriptions should be rendered fully below the declaration and wrapped + assert ( + 'resolution: A[SignalR[float], ParamPath("mass_spec:resolution")]\n' + in generated_code + ) + assert ( + "# The high mass peak width/valley adjustment used during set up and" + in generated_code + ) + assert ( + "# low masses and should be adjusted in conjunction with the Delta-M." + in generated_code + ) + + # Reparse generated code and verify multiline comments survive round-trip generation + roundtrip_gen = GenNodeCode(path=str(clean_generated_file)) + roundtrip_code = roundtrip_gen.generate_code() + + assert ( + 'mid_descriptor: A[SignalRW[ndarray], ParamPath("mass_spec:mid_descriptor")]' + in roundtrip_code + ) + assert "Example:" in roundtrip_code + assert "\n# ; Unit: (V)" not in roundtrip_code + assert ( + 'resolution: A[SignalR[float], ParamPath("mass_spec:resolution")]\n' + in roundtrip_code + ) + + +def test_gen_shall_mass_spec_node_no_impl( + clean_generated_file, mass_spectrometer_description_no_impl: str +): + """Test generating code for the SHALL mass spectrometer node using a + real description.""" + + gen_code = GenNodeCode(path=str(clean_generated_file)) + + gen_code.from_json_describe(mass_spectrometer_description_no_impl) + + gen_code.write_gen_node_class_file() From 89fa87ff5fae3919b6a0e30870f36fabf7eb4c84 Mon Sep 17 00:00:00 2001 From: Peter Braun Date: Tue, 17 Feb 2026 09:26:31 +0100 Subject: [PATCH 16/18] fixed signal source and name --- src/secop_ophyd/SECoPSignal.py | 2 +- tests/test_Node.py | 27 +++++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/secop_ophyd/SECoPSignal.py b/src/secop_ophyd/SECoPSignal.py index 019f3e5..928a13c 100644 --- a/src/secop_ophyd/SECoPSignal.py +++ b/src/secop_ophyd/SECoPSignal.py @@ -314,7 +314,7 @@ def init_property_from_introspection( self.path_str = path def source(self, name: str, read: bool) -> str: - return self.path_str + return self._secclient.host + ":" + self._secclient.port + ":" + self.path_str async def connect(self, timeout: float): """Connect and initialize backend (handles both parameters and properties).""" diff --git a/tests/test_Node.py b/tests/test_Node.py index 365afa7..e83b0dd 100644 --- a/tests/test_Node.py +++ b/tests/test_Node.py @@ -20,6 +20,33 @@ async def test_node_read(cryo_sim, cryo_node_no_re: SECoPNodeDevice): print(val_read) assert val_read != {} + print(cryo_node_no_re.cryo.value.name) + print(cryo_node_no_re.cryo.value.source) + + +async def test_source(cryo_sim, cryo_node_no_re: SECoPNodeDevice): + # Node device should return the readbacks of the read signals of the child devices + + # Prameter name and source + val_name = cryo_node_no_re.cryo.value.name + val_source = cryo_node_no_re.cryo.value.source + + assert val_name == "cryo_7-frappy-demo-cryo-value" + assert val_source == "localhost:10769:cryo:value" + + # property name and source + prop_name = cryo_node_no_re.equipment_id.name + prop_source = cryo_node_no_re.equipment_id.source + + assert prop_name == "cryo_7-frappy-demo-equipment_id" + assert prop_source == "localhost:10769:equipment_id" + + mod_prop_name = cryo_node_no_re.cryo.description.name + mod_prop_source = cryo_node_no_re.cryo.description.source + + assert mod_prop_name == "cryo_7-frappy-demo-cryo-description" + assert mod_prop_source == "localhost:10769:cryo:description" + async def test_node_describe(cryo_sim, cryo_node_no_re: SECoPNodeDevice): # Node device should return the descriptions of the read signals of the child From e2b06094268aa06a9c4428760db308fba002aa8b Mon Sep 17 00:00:00 2001 From: Peter Braun Date: Tue, 17 Feb 2026 09:51:23 +0100 Subject: [PATCH 17/18] refactor description parsing --- src/secop_ophyd/GenNodeCode.py | 89 ++++++++++++++++++---------------- tests/test_classgen.py | 19 ++++++++ 2 files changed, 65 insertions(+), 43 deletions(-) diff --git a/src/secop_ophyd/GenNodeCode.py b/src/secop_ophyd/GenNodeCode.py index 2d463e0..4a4a3e9 100644 --- a/src/secop_ophyd/GenNodeCode.py +++ b/src/secop_ophyd/GenNodeCode.py @@ -1,10 +1,14 @@ """Code generation for annotated ophyd device classes using Jinja2 templates.""" +import ast import inspect +import io import json import linecache import re import sys +import textwrap +import tokenize from dataclasses import dataclass, field from enum import StrEnum from importlib import import_module, reload @@ -324,68 +328,67 @@ def _parse_node_class(self, class_symbol: str, class_obj: type): def _extract_descriptions_from_source(self, class_obj: type) -> dict[str, str]: """Extract trailing comment descriptions from class source code. + Uses ``ast`` to find class-level annotated attributes and ``tokenize`` to + read actual Python comment tokens. This avoids false positives from ``#`` + inside strings and ignores non-attribute annotations. + Args: class_obj: The class object to extract descriptions from Returns: Dictionary mapping attribute names to their descriptions """ - descriptions = {} + descriptions: dict[str, str] = {} try: - source = inspect.getsource(class_obj) - lines = source.split("\n") - idx = 0 - - def _comment_text(raw_comment: str) -> str: - text = raw_comment - if text.startswith(" "): - text = text[1:] - return text.rstrip() - - while idx < len(lines): - line = lines[idx] - stripped_line = line.lstrip() - - # Find attribute name (e.g., "count" from "count: A[SignalRW[int],...") - # and ignore class/function/decorator lines. - if stripped_line.startswith(("class ", "def ", "@")): - idx += 1 + source = textwrap.dedent(inspect.getsource(class_obj)) + source_lines = source.splitlines() + module_ast = ast.parse(source) + + class_nodes = [ + node for node in module_ast.body if isinstance(node, ast.ClassDef) + ] + if not class_nodes: + return descriptions + + class_node = class_nodes[0] + + comments_by_line: dict[int, list[str]] = {} + for token_info in tokenize.generate_tokens(io.StringIO(source).readline): + if token_info.type != tokenize.COMMENT: + continue + + comment_text = token_info.string[1:].lstrip().rstrip() + comments_by_line.setdefault(token_info.start[0], []).append( + comment_text + ) + + for node in class_node.body: + if not isinstance(node, ast.AnnAssign): continue - match = re.match(r"\s*(\w+)\s*:", line) - if not match: - idx += 1 + if not isinstance(node.target, ast.Name): continue - attr_name = match.group(1) + attr_name = node.target.id + annotation_end_line = getattr(node, "end_lineno", node.lineno) description_lines: list[str] = [] - # Optional inline comment on the attribute declaration line - if "#" in line: - _, comment_part = line.split("#", 1) - description_lines.append(_comment_text(comment_part)) - - # Collect multiline comment block continuations below declaration: - # attr: Type - # # first line - # # second line - next_idx = idx + 1 - while next_idx < len(lines): - next_line = lines[next_idx] - stripped = next_line.lstrip() - - if not stripped.startswith("#"): + # Inline comment on the annotation line. + description_lines.extend(comments_by_line.get(annotation_end_line, [])) + + # Multiline trailing comment block directly below the annotation. + next_line_no = annotation_end_line + 1 + while next_line_no <= len(source_lines): + stripped_line = source_lines[next_line_no - 1].lstrip() + if not stripped_line.startswith("#"): break - continuation = _comment_text(stripped[1:]) - description_lines.append(continuation) - next_idx += 1 + description_lines.extend(comments_by_line.get(next_line_no, [])) + next_line_no += 1 description = "\n".join(description_lines).rstrip() if description: descriptions[attr_name] = description - - idx = next_idx except Exception as e: if self.log: self.log.debug(f"Could not extract descriptions from source: {e}") diff --git a/tests/test_classgen.py b/tests/test_classgen.py index b9c44fe..2fa4006 100644 --- a/tests/test_classgen.py +++ b/tests/test_classgen.py @@ -20,6 +20,25 @@ sys.path.insert(0, str(Path(__file__).parent.parent / "src")) +class _DescriptionParseSample: + count: int # count comment + label: str = "value # not a comment" + # label continuation + + def helper(self): + local: int # must not be parsed # noqa: F842 + + +def test_extract_descriptions_from_source_is_token_safe(): + """Ensure parser only reads real comments and ignores '#' inside strings.""" + gen_code = GenNodeCode(log=None) + descriptions = gen_code._extract_descriptions_from_source(_DescriptionParseSample) + + assert descriptions["count"] == "count comment" + assert descriptions["label"] == "label continuation" + assert "local" not in descriptions + + def test_basic_functionality(clean_generated_file): """Test basic GenNodeCode functionality.""" print("Testing GenNodeCode refactored implementation...") From 846b2fa2cb856b073f07ea842c194d69e3c628ed Mon Sep 17 00:00:00 2001 From: Peter Braun Date: Tue, 17 Feb 2026 15:01:31 +0100 Subject: [PATCH 18/18] ensure proper enum handling --- src/secop_ophyd/GenNodeCode.py | 21 +++++----- src/secop_ophyd/SECoPDevices.py | 54 +++----------------------- src/secop_ophyd/SECoPSignal.py | 29 ++++++++++++++ src/secop_ophyd/util.py | 40 +++++++++++++++++-- tests/test_Node.py | 3 -- tests/test_classgen.py | 68 ++++++++++++++------------------- tests/test_dtype.py | 49 ++++++++++++++++++++++-- 7 files changed, 155 insertions(+), 109 deletions(-) diff --git a/src/secop_ophyd/GenNodeCode.py b/src/secop_ophyd/GenNodeCode.py index 4a4a3e9..1890a06 100644 --- a/src/secop_ophyd/GenNodeCode.py +++ b/src/secop_ophyd/GenNodeCode.py @@ -29,8 +29,8 @@ from secop_ophyd.SECoPDevices import ( IGNORED_PROPS, - ParamPath, - PropPath, + ParameterType, + PropertyType, class_from_interface, secop_enum_name_to_python, ) @@ -201,8 +201,8 @@ def __init__(self, path: str | None = None, log=None): self.add_import("ophyd_async.core", "SupersetEnum") self.add_import("typing", "Any") self.add_import("numpy", "ndarray") - self.add_import("secop_ophyd.SECoPDevices", "ParamPath") - self.add_import("secop_ophyd.SECoPDevices", "PropPath") + self.add_import("secop_ophyd.SECoPDevices", "ParameterType") + self.add_import("secop_ophyd.SECoPDevices", "PropertyType") # Add necessary Device imports self.add_import("secop_ophyd.SECoPDevices", "SECoPDevice") self.add_import("secop_ophyd.SECoPDevices", "SECoPCommunicatorDevice") @@ -439,10 +439,13 @@ def _get_attr_list( ) path_annotation = next( - (e for e in extras if isinstance(e, (ParamPath, PropPath))), None + (e for e in extras if isinstance(e, (ParameterType, PropertyType))), + None, ) category = ( - "property" if isinstance(path_annotation, PropPath) else "parameter" + "property" + if isinstance(path_annotation, PropertyType) + else "parameter" ) format_annotation = next( (e for e in extras if isinstance(e, Format)), None @@ -902,7 +905,7 @@ def command_plan_no_arg(self, wait_for_idle: bool = False): type=signal_base.__name__, type_param=type_param, description=param_descr, - path_annotation=str(ParamPath(f"{modname}:{param_name}")), + path_annotation=str(ParameterType()), format_annotation=format, ) ) @@ -923,7 +926,7 @@ def command_plan_no_arg(self, wait_for_idle: bool = False): name=prop_name, type=SignalR.__name__, type_param=type_param, - path_annotation=str(PropPath(f"{modname}:{prop_name}")), + path_annotation=str(PropertyType()), ) ) @@ -952,7 +955,7 @@ def command_plan_no_arg(self, wait_for_idle: bool = False): name=prop_name, type=SignalR.__name__, type_param=type_param, - path_annotation=str(PropPath(prop_name)), + path_annotation=str(PropertyType()), ) ) diff --git a/src/secop_ophyd/SECoPDevices.py b/src/secop_ophyd/SECoPDevices.py index 03087b6..36441f5 100644 --- a/src/secop_ophyd/SECoPDevices.py +++ b/src/secop_ophyd/SECoPDevices.py @@ -4,7 +4,6 @@ import time as ttime import warnings from abc import abstractmethod -from dataclasses import dataclass from logging import Logger from types import MethodType from typing import Any, Dict, Iterator, Optional, Type @@ -137,25 +136,13 @@ def is_config_signal(device: StandardReadable, signal: SignalR | SignalRW) -> bo return False -@dataclass(init=False) -class ParamPath: +class ParameterType: """Annotation for Parameter Signals, defines the path to the parameter in the secclient module dict""" - module: str - parameter: str - - def __init__(self, param_path: str) -> None: - # Parse from delimited string - parts = param_path.split(":") - if len(parts) != 2: - raise ValueError(f"Expected 'module:param', got '{param_path}'") - self.module = parts[0].strip() - self.parameter = parts[1].strip() - def __repr__(self) -> str: """Return repr suitable for code generation in annotations.""" - return f'ParamPath("{self.module}:{self.parameter}")' + return "ParameterType()" def __call__(self, parent: Device, child: Device): if not isinstance(child, Signal): @@ -165,39 +152,17 @@ def __call__(self, parent: Device, child: Device): if not isinstance(backend, SECoPBackend): return - backend.attribute_type = AttributeType.PARAMETER - backend._module_name = self.module - backend._attribute_name = self.parameter + backend.attribute_type = AttributeType.PARAMETER backend._secclient = parent._client - backend.path_str = self.module + ":" + self.parameter -@dataclass(init=False) -class PropPath: +class PropertyType: """Annotation for Module Property Signals, defines the path to the property""" - property: str - - # if module is None, property is assumed to be at node level, - # otherwise at module level - module: str | None = None - - def __init__(self, property_path: str) -> None: - # Parse from delimited string - parts = property_path.split(":") - if len(parts) == 2: - self.module = parts[0].strip() - self.property = parts[1].strip() - else: - self.property = property_path.strip() - self.module = None # --> node level property - def __repr__(self) -> str: """Return repr suitable for code generation in annotations.""" - if self.module is None: - return f'PropPath("{self.property}")' - return f'PropPath("{self.module}:{self.property}")' + return "PropertyType()" def __call__(self, parent: Device, child: Device): if not isinstance(child, Signal): @@ -209,16 +174,8 @@ def __call__(self, parent: Device, child: Device): return backend.attribute_type = AttributeType.PROPERTY - - backend._module_name = self.module - backend._attribute_name = self.property backend._secclient = parent._client - if self.module: - backend.path_str = self.module + ":" + self.property - else: - backend.path_str = self.property - class SECoPDeviceConnector(DeviceConnector): @@ -1193,7 +1150,6 @@ async def stop(self, success=True): if not success: self.logger.info(f"Stopping {self.name} success={success}") - print(f"Stopping {self.name} success={success}") await self._client.exec_command(self.module, "stop") self._stopped = True diff --git a/src/secop_ophyd/SECoPSignal.py b/src/secop_ophyd/SECoPSignal.py index 928a13c..3b68096 100644 --- a/src/secop_ophyd/SECoPSignal.py +++ b/src/secop_ophyd/SECoPSignal.py @@ -284,6 +284,20 @@ def init_parameter_from_introspection( path: str, secclient: AsyncFrappyClient, ): + if self.attribute_type is not None: + + if secclient != self._secclient: + raise RuntimeError( + "Backend already initialized with a different SECoP client, cannot " + "re-initialize" + ) + + if self.attribute_type != AttributeType.PARAMETER: + raise RuntimeError( + f"Backend already initialized as {self.attribute_type}, " + f"cannot re-initialize as PARAMETER" + ) + self.attribute_type = AttributeType.PARAMETER module_name, parameter_name = path.split(":", maxsplit=1) @@ -299,6 +313,20 @@ def init_parameter_from_introspection( def init_property_from_introspection( self, datatype: type[SignalDatatypeT], path: str, secclient: AsyncFrappyClient ): + if self.attribute_type is not None: + + if secclient != self._secclient: + raise RuntimeError( + "Backend already initialized with a different SECoP client, cannot " + "re-initialize" + ) + + if self.attribute_type != AttributeType.PROPERTY: + raise RuntimeError( + f"Backend already initialized as {self.attribute_type}, cannot " + f"re-initialize as PROPERTY" + ) + self.attribute_type = AttributeType.PROPERTY if path.count(":") == 0: module_name = None @@ -308,6 +336,7 @@ def init_property_from_introspection( self._module_name = module_name self._attribute_name = property_name + self._secclient = secclient self.datatype = datatype diff --git a/src/secop_ophyd/util.py b/src/secop_ophyd/util.py index 4cbe5df..4aa1aae 100644 --- a/src/secop_ophyd/util.py +++ b/src/secop_ophyd/util.py @@ -594,11 +594,15 @@ def __init__(self, datatype: DataType) -> None: self._is_composite: bool = False self._is_array: bool = False + self._is_enum: bool = False self.dtype_tree = dt_factory(datatype) self.max_depth: int = self.dtype_tree.max_depth + if isinstance(self.dtype_tree, EnumNP): + self._is_enum = True + if isinstance(self.dtype_tree, ArrayNP): self.shape = self.dtype_tree.shape self._is_array = True @@ -607,6 +611,9 @@ def __init__(self, datatype: DataType) -> None: if isinstance(self.dtype_tree.root_type, (StructNP, TupleNP)) else False ) + self._is_enum = self._is_enum or isinstance( + self.dtype_tree.root_type, EnumNP + ) if isinstance(self.dtype_tree, (TupleNP, StructNP)): self._is_composite = True @@ -666,6 +673,30 @@ def secop2val(self, reading_val) -> Any: if self._is_composite: return self._secop2numpy_array(reading_val) + elif self._is_enum: + if isinstance(self.dtype_tree, EnumNP): + exp_val = self.dtype_tree.secop_dtype(reading_val).name + return exp_val + + elif isinstance(self.dtype_tree, ArrayNP) and isinstance( + self.dtype_tree.root_type, EnumNP + ): + + def map_enum(val): + return list( + map( + lambda x: map_enum(x) if isinstance(x, tuple) else x.name, + val, + ) + ) + + exp_val = map_enum(reading_val) + + return exp_val + + else: + raise Exception("enum type is not correctly identified in dtype tree") + else: return reading_val @@ -675,6 +706,7 @@ def val2secop(self, input_val) -> Any: if self._is_composite and isinstance(input_val, np.ndarray): return self.dtype_tree.make_secop_compatible_object(input_val) + else: return self.raw_dtype.validate(input_val) @@ -722,13 +754,13 @@ def __init__( if entry.readerror is not None: raise entry.readerror - exported_val = secop_dt.raw_dtype.export_value(entry.value) + # exported_val = secop_dt.raw_dtype.export_value(entry.value) - self.secop_dt.update_dtype(exported_val) + self.secop_dt.update_dtype(entry.value) - self.value = secop_dt.secop2val(exported_val) + self.value = secop_dt.secop2val(entry.value) - self.secop_val = exported_val + self.secop_val = entry.value self.timestamp = entry.timestamp diff --git a/tests/test_Node.py b/tests/test_Node.py index e83b0dd..04ae331 100644 --- a/tests/test_Node.py +++ b/tests/test_Node.py @@ -20,9 +20,6 @@ async def test_node_read(cryo_sim, cryo_node_no_re: SECoPNodeDevice): print(val_read) assert val_read != {} - print(cryo_node_no_re.cryo.value.name) - print(cryo_node_no_re.cryo.value.source) - async def test_source(cryo_sim, cryo_node_no_re: SECoPNodeDevice): # Node device should return the readbacks of the read signals of the child devices diff --git a/tests/test_classgen.py b/tests/test_classgen.py index 2fa4006..f0fae60 100644 --- a/tests/test_classgen.py +++ b/tests/test_classgen.py @@ -14,7 +14,7 @@ ParameterAttribute, PropertyAttribute, ) -from secop_ophyd.SECoPDevices import ParamPath, PropPath, SECoPNodeDevice +from secop_ophyd.SECoPDevices import ParameterType, PropertyType, SECoPNodeDevice # Add src to path sys.path.insert(0, str(Path(__file__).parent.parent / "src")) @@ -72,19 +72,19 @@ def sample_method(self, value: int) -> str: name="temperature", type="SignalR", type_param="float", - path_annotation=str(ParamPath("test:temperature")), + path_annotation=str(ParameterType()), ), ParameterAttribute( name="pressure", type="SignalR", type_param="float", - path_annotation=str(ParamPath("test:pressure")), + path_annotation=str(ParameterType()), ), ParameterAttribute( name="count", type="SignalRW", type_param="int", - path_annotation=str(ParamPath("test:count")), + path_annotation=str(ParameterType()), ), ], properties=[], @@ -104,7 +104,7 @@ def sample_method(self, value: int) -> str: name="status", type="SignalR", type_param="str", - path_annotation=str(PropPath("status")), + path_annotation=str(PropertyType()), ), ], description="Test node class", @@ -124,11 +124,11 @@ def sample_method(self, value: int) -> str: # Verify code contains expected elements assert "from abc import abstractmethod" in code assert "class TestModule(SECoPDevice):" in code - assert 'temperature: A[SignalR[float], ParamPath("test:temperature")]' in code - assert 'count: A[SignalRW[int], ParamPath("test:count")]' in code + assert "temperature: A[SignalR[float], ParameterType()]" in code + assert "count: A[SignalRW[int], ParameterType()]" in code assert "class TestNode(SECoPNodeDevice):" in code assert "module1: TestModule" in code - assert 'status: A[SignalR[str], PropPath("status")]' in code + assert "status: A[SignalR[str], PropertyType()]" in code assert "def sample_command" in code print("\n✓ All basic tests passed!") @@ -221,13 +221,13 @@ def type2_command(self, mode: str) -> str: type="SignalR", type_param="float", description="this has to be in the final output", - path_annotation="ParamPath('type1:temperature')", + path_annotation="ParameterType()", ), ParameterAttribute( name="setpoint", type="SignalRW", type_param="float", - path_annotation="ParamPath('type1:setpoint')", + path_annotation="ParameterType()", ), ], properties=[ @@ -235,13 +235,13 @@ def type2_command(self, mode: str) -> str: name="description", type="SignalR", type_param="str", - path_annotation="PropPath('type1:description')", + path_annotation="PropertyType()", ), PropertyAttribute( name="interface_classes", type="SignalR", type_param="int", - path_annotation="PropPath('type1:interface_classes')", + path_annotation="PropertyType()", ), ], cmd_plans=[method_type1], @@ -257,13 +257,13 @@ def type2_command(self, mode: str) -> str: name="pressure", type="SignalR", type_param="float", - path_annotation="ParamPath('type2:pressure')", + path_annotation="ParameterType()", ), ParameterAttribute( name="mode", type="SignalRW", type_param="str", - path_annotation="ParamPath('type2:mode')", + path_annotation="ParameterType()", ), ], properties=[ @@ -271,7 +271,7 @@ def type2_command(self, mode: str) -> str: name="implementation", type="SignalR", type_param="str", - path_annotation="PropPath('type2:implementation')", + path_annotation="PropertyType()", ), ], cmd_plans=[method_type2], @@ -291,7 +291,7 @@ def type2_command(self, mode: str) -> str: name="status", type="SignalR", type_param="str", - path_annotation="PropPath('status')", + path_annotation="PropertyType()", ), ], description="NodeA with Type1 and Type2 modules", @@ -336,13 +336,13 @@ def type3_command(self, count: int) -> int: name="temperature", type="SignalR", type_param="float", - path_annotation="ParamPath('type1:temperature')", + path_annotation="ParameterType()", ), ParameterAttribute( name="setpoint", type="SignalRW", type_param="float", - path_annotation="ParamPath('type1:setpoint')", + path_annotation="ParameterType()", ), ], properties=[ @@ -350,13 +350,13 @@ def type3_command(self, count: int) -> int: name="description", type="SignalR", type_param="str", - path_annotation="PropPath('type1:description')", + path_annotation="PropertyType()", ), PropertyAttribute( name="interface_classes", type="SignalR", type_param="list", - path_annotation="PropPath('type1:interface_classes')", + path_annotation="PropertyType()", ), ], cmd_plans=[method_type1], @@ -373,13 +373,13 @@ def type3_command(self, count: int) -> int: type="SignalRW", type_param="int", description="this is a description", - path_annotation="ParamPath('type3:count')", + path_annotation="ParameterType()", ), ParameterAttribute( name="enabled", type="SignalR", type_param="bool", - path_annotation="ParamPath('type3:enabled')", + path_annotation="ParameterType()", ), ], properties=[ @@ -387,7 +387,7 @@ def type3_command(self, count: int) -> int: name="group", type="SignalR", type_param="str", - path_annotation="PropPath('type3:group')", + path_annotation="PropertyType()", ), ], cmd_plans=[method_type3], @@ -407,7 +407,7 @@ def type3_command(self, count: int) -> int: name="name", type="SignalR", type_param="str", - path_annotation="PropPath('name')", + path_annotation="PropertyType()", ), ], description="NodeB with Type1 and Type3 modules", @@ -654,20 +654,14 @@ def test_gen_shall_mass_spec_node( assert "\n# ; Unit: (%)" not in generated_code # Intentionally multiline descriptions should be rendered as multiline comments - assert ( - 'mid_descriptor: A[SignalRW[ndarray], ParamPath("mass_spec:mid_descriptor")]' - in generated_code - ) + assert "mid_descriptor: A[SignalRW[ndarray], ParameterType()]" in generated_code assert "# Example:" in generated_code assert "# {" in generated_code assert "# mass: [12,15,28,75]," in generated_code assert "# device: [FARADAY,SEM,SEM,SEM]" in generated_code # Long descriptions should be rendered fully below the declaration and wrapped - assert ( - 'resolution: A[SignalR[float], ParamPath("mass_spec:resolution")]\n' - in generated_code - ) + assert "resolution: A[SignalR[float], ParameterType()]\n" in generated_code assert ( "# The high mass peak width/valley adjustment used during set up and" in generated_code @@ -681,16 +675,10 @@ def test_gen_shall_mass_spec_node( roundtrip_gen = GenNodeCode(path=str(clean_generated_file)) roundtrip_code = roundtrip_gen.generate_code() - assert ( - 'mid_descriptor: A[SignalRW[ndarray], ParamPath("mass_spec:mid_descriptor")]' - in roundtrip_code - ) + assert "mid_descriptor: A[SignalRW[ndarray], ParameterType()]" in roundtrip_code assert "Example:" in roundtrip_code assert "\n# ; Unit: (V)" not in roundtrip_code - assert ( - 'resolution: A[SignalR[float], ParamPath("mass_spec:resolution")]\n' - in roundtrip_code - ) + assert "resolution: A[SignalR[float], ParameterType()]\n" in roundtrip_code def test_gen_shall_mass_spec_node_no_impl( diff --git a/tests/test_dtype.py b/tests/test_dtype.py index 3718e35..769a0b8 100644 --- a/tests/test_dtype.py +++ b/tests/test_dtype.py @@ -194,7 +194,7 @@ def test_describe_str(start_dtype, expected_dtype_descr, expected_shape, max_dep @pytest.mark.parametrize( - "start_dtype,np_input,expected_output,type_checks", + "start_dtype,np_input,expected_output,type_checks,ophy_val", [ pytest.param( StructOf( @@ -228,6 +228,16 @@ def test_describe_str(start_dtype, expected_dtype_descr, expected_shape, max_dep and isinstance(val["scaled_val"], int) and isinstance(val["enum_val"], int) ), + np.array( + (3.14, "test", 42, 123, 1), + dtype=[ + ("float_val", "