diff --git a/.copier-answers.yml b/.copier-answers.yml index ebd9ae2..9ccad6f 100644 --- a/.copier-answers.yml +++ b/.copier-answers.yml @@ -1,9 +1,11 @@ # Changes here will be overwritten by Copier -_commit: 2.1.0-40-g9e70b8b +_commit: 2.3.0 _src_path: gh:DiamondLightSource/python-copier-template author_email: gary.yendell@diamond.ac.uk author_name: Gary Yendell +component_lifecycle: experimental component_owner: user:mef65357 +component_type: library description: Control system agnostic framework for building Device support in Python that will work for both EPICS and Tango distribution_name: fastcs @@ -14,4 +16,4 @@ github_org: DiamondLightSource package_name: fastcs pypi: true repo_name: FastCS -type_checker: mypy +type_checker: pyright diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 949e38a..ea0f96c 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -24,4 +24,4 @@ It is recommended that developers use a [vscode devcontainer](https://code.visua This project was created using the [Diamond Light Source Copier Template](https://github.com/DiamondLightSource/python-copier-template) for Python projects. -For more information on common tasks like setting up a developer environment, running the tests, and setting a pre-commit hook, see the template's [How-to guides](https://diamondlightsource.github.io/python-copier-template/2.1.0/how-to.html). +For more information on common tasks like setting up a developer environment, running the tests, and setting a pre-commit hook, see the template's [How-to guides](https://diamondlightsource.github.io/python-copier-template/2.3.0/how-to.html). diff --git a/.github/pages/make_switcher.py b/.github/pages/make_switcher.py index 29f646c..c06813a 100755 --- a/.github/pages/make_switcher.py +++ b/.github/pages/make_switcher.py @@ -1,3 +1,5 @@ +"""Make switcher.json to allow docs to switch between different versions.""" + import json import logging from argparse import ArgumentParser @@ -6,6 +8,7 @@ def report_output(stdout: bytes, label: str) -> list[str]: + """Print and return something received frm stdout.""" ret = stdout.decode().strip().split("\n") print(f"{label}: {ret}") return ret @@ -52,14 +55,12 @@ def get_versions(ref: str, add: str | None) -> list[str]: return versions -def write_json(path: Path, repository: str, versions: str): +def write_json(path: Path, repository: str, versions: list[str]): + """Write the JSON switcher to path.""" org, repo_name = repository.split("/") - pages_url = f"https://{org}.github.io" - if repo_name != f"{org}.github.io": - # Only add the repo name if it isn't the source for the org pages site - pages_url += f"/{repo_name}" struct = [ - {"version": version, "url": f"{pages_url}/{version}/"} for version in versions + {"version": version, "url": f"https://{org}.github.io/{repo_name}/{version}/"} + for version in versions ] text = json.dumps(struct, indent=2) print(f"JSON switcher:\n{text}") @@ -67,6 +68,7 @@ def write_json(path: Path, repository: str, versions: str): def main(args=None): + """Parse args and write switcher.""" parser = ArgumentParser( description="Make a versions.json file from gh-pages directories" ) diff --git a/.gitignore b/.gitignore index 2593ec7..0f33bf2 100644 --- a/.gitignore +++ b/.gitignore @@ -55,6 +55,7 @@ cov.xml # Sphinx documentation docs/_build/ +docs/_api # PyBuilder target/ diff --git a/catalog-info.yaml b/catalog-info.yaml index 7de8ca3..20292e0 100644 --- a/catalog-info.yaml +++ b/catalog-info.yaml @@ -5,6 +5,6 @@ metadata: title: FastCS description: Control system agnostic framework for building Device support in Python that will work for both EPICS and Tango spec: - type: documentation + type: library lifecycle: experimental owner: user:mef65357 diff --git a/docs/_api.rst b/docs/_api.rst new file mode 100644 index 0000000..c570592 --- /dev/null +++ b/docs/_api.rst @@ -0,0 +1,16 @@ +:orphan: + +.. + This page is not included in the TOC tree, but must exist so that the + autosummary pages are generated for fastcs and all its + subpackages + +API +=== + +.. autosummary:: + :toctree: _api + :template: custom-module-template.rst + :recursive: + + fastcs diff --git a/docs/_templates/custom-module-template.rst b/docs/_templates/custom-module-template.rst new file mode 100644 index 0000000..9aeca54 --- /dev/null +++ b/docs/_templates/custom-module-template.rst @@ -0,0 +1,37 @@ +{{ ('``' + fullname + '``') | underline }} + +{%- set filtered_members = [] %} +{%- for item in members %} + {%- if item in functions + classes + exceptions + attributes %} + {% set _ = filtered_members.append(item) %} + {%- endif %} +{%- endfor %} + +.. automodule:: {{ fullname }} + :members: + + {% block modules %} + {% if modules %} + .. rubric:: Submodules + + .. autosummary:: + :toctree: + :template: custom-module-template.rst + :recursive: + {% for item in modules %} + {{ item }} + {%- endfor %} + {% endif %} + {% endblock %} + + {% block members %} + {% if filtered_members %} + .. rubric:: Members + + .. autosummary:: + :nosignatures: + {% for item in filtered_members %} + {{ item }} + {%- endfor %} + {% endif %} + {% endblock %} diff --git a/docs/conf.py b/docs/conf.py index d023312..cdfc8f4 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,8 +1,9 @@ -# Configuration file for the Sphinx documentation builder. -# -# This file only contains a selection of the most common options. For a full -# list see the documentation: -# https://www.sphinx-doc.org/en/master/usage/configuration.html +"""Configuration file for the Sphinx documentation builder. + +This file only contains a selection of the most common options. For a full +list see the documentation: +https://www.sphinx-doc.org/en/master/usage/configuration.html +""" import sys from pathlib import Path @@ -32,6 +33,8 @@ extensions = [ # Use this for generating API docs "sphinx.ext.autodoc", + # and making summary tables at the top of API docs + "sphinx.ext.autosummary", # This can parse google style docstrings "sphinx.ext.napoleon", # For linking to external sphinx documentation @@ -83,6 +86,12 @@ # Don't inherit docstrings from baseclasses autodoc_inherit_docstrings = False +# Document only what is in __all__ +autosummary_ignore_module_all = False + +# Add any paths that contain templates here, relative to this directory. +templates_path = ["_templates"] + # Output graphviz directive produced images in a scalable format graphviz_output_format = "svg" diff --git a/docs/reference.md b/docs/reference.md index 34cc710..977d246 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -6,7 +6,7 @@ Technical reference material including APIs and release notes. :maxdepth: 1 :glob: -reference/* +API <_api/fastcs> genindex Release Notes ``` diff --git a/docs/reference/api.md b/docs/reference/api.md deleted file mode 100644 index 4d73688..0000000 --- a/docs/reference/api.md +++ /dev/null @@ -1,17 +0,0 @@ -# API - -```{eval-rst} -.. automodule:: fastcs - - ``fastcs`` - ----------------------------------- -``` - -This is the internal API reference for fastcs - -```{eval-rst} -.. data:: fastcs.__version__ - :type: str - - Version number as calculated by https://github.com/pypa/setuptools_scm -``` diff --git a/pyproject.toml b/pyproject.toml index a2a94a8..ff870e0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ dependencies = [ "aioserial", "numpy", "pydantic", - "pvi~=0.9.0", + "pvi~=0.10.0", "pytango", "softioc", ] @@ -26,11 +26,11 @@ requires-python = ">=3.11" [project.optional-dependencies] dev = [ "copier", - "mypy", "myst-parser", "pipdeptree", "pre-commit", "pydata-sphinx-theme>=0.12", + "pyright", "pytest", "pytest-cov", "pytest-mock", @@ -59,8 +59,9 @@ name = "Martin Gaughran" [tool.setuptools_scm] version_file = "src/fastcs/_version.py" -[tool.mypy] -ignore_missing_imports = true # Ignore missing stubs in imported modules +[tool.pyright] +typeCheckingMode = "standard" +reportMissingImports = false # Ignore missing stubs in imported modules [tool.pytest.ini_options] # Run pytest with all our checkers, and don't spam us with massive tracebacks on error @@ -93,12 +94,12 @@ passenv = * allowlist_externals = pytest pre-commit - mypy + pyright sphinx-build sphinx-autobuild commands = pre-commit: pre-commit run --all-files --show-diff-on-failure {posargs} - type-checking: mypy src tests {posargs} + type-checking: pyright src tests {posargs} tests: pytest --cov=fastcs --cov-report term --cov-report xml:cov.xml {posargs} docs: sphinx-{posargs:build -EW --keep-going} -T docs build/html """ diff --git a/src/fastcs/__init__.py b/src/fastcs/__init__.py index 26d23ba..a2ffbf3 100644 --- a/src/fastcs/__init__.py +++ b/src/fastcs/__init__.py @@ -1,3 +1,11 @@ +"""Top level API. + +.. data:: __version__ + :type: str + + Version number as calculated by https://github.com/pypa/setuptools_scm +""" + from ._version import __version__ __all__ = ["__version__"] diff --git a/src/fastcs/__main__.py b/src/fastcs/__main__.py index 55cee2f..8274cf0 100644 --- a/src/fastcs/__main__.py +++ b/src/fastcs/__main__.py @@ -1,16 +1,24 @@ +"""Interface for ``python -m fastcs``.""" + from argparse import ArgumentParser +from collections.abc import Sequence from . import __version__ __all__ = ["main"] -def main(args=None): +def main(args: Sequence[str] | None = None) -> None: + """Argument parser for the CLI.""" parser = ArgumentParser() - parser.add_argument("-v", "--version", action="version", version=__version__) - args = parser.parse_args(args) + parser.add_argument( + "-v", + "--version", + action="version", + version=__version__, + ) + parser.parse_args(args) -# test with: python -m fastcs if __name__ == "__main__": main() diff --git a/src/fastcs/backends/epics/gui.py b/src/fastcs/backends/epics/gui.py index 7d2b4bc..b9c4875 100644 --- a/src/fastcs/backends/epics/gui.py +++ b/src/fastcs/backends/epics/gui.py @@ -7,11 +7,11 @@ LED, ButtonPanel, ComboBox, - Component, + ComponentUnion, Device, Grid, Group, - ReadWidget, + ReadWidgetUnion, SignalR, SignalRW, SignalW, @@ -22,7 +22,7 @@ TextWrite, ToggleButton, Tree, - WriteWidget, + WriteWidgetUnion, ) from pydantic import ValidationError @@ -56,7 +56,7 @@ def _get_pv(self, attr_path: list[str], name: str): return f"{attr_prefix}:{name.title().replace('_', '')}" @staticmethod - def _get_read_widget(attribute: AttrR) -> ReadWidget: + def _get_read_widget(attribute: AttrR) -> ReadWidgetUnion: match attribute.datatype: case Bool(): return LED() @@ -68,7 +68,7 @@ def _get_read_widget(attribute: AttrR) -> ReadWidget: raise FastCSException(f"Unsupported type {type(datatype)}: {datatype}") @staticmethod - def _get_write_widget(attribute: AttrW) -> WriteWidget: + def _get_write_widget(attribute: AttrW) -> WriteWidgetUnion: match attribute.allowed_values: case allowed_values if allowed_values is not None: return ComboBox(choices=allowed_values) @@ -87,7 +87,7 @@ def _get_write_widget(attribute: AttrW) -> WriteWidget: def _get_attribute_component( self, attr_path: list[str], name: str, attribute: Attribute - ): + ) -> SignalR | SignalW | SignalRW: pv = self._get_pv(attr_path, name) name = name.title().replace("_", "") @@ -108,6 +108,8 @@ def _get_attribute_component( case AttrW(): write_widget = self._get_write_widget(attribute) return SignalW(name=name, write_pv=pv, write_widget=write_widget) + case _: + raise FastCSException(f"Unsupported attribute type: {type(attribute)}") def _get_command_component(self, attr_path: list[str], name: str): pv = self._get_pv(attr_path, name) @@ -136,8 +138,8 @@ def create_gui(self, options: EpicsGUIOptions | None = None) -> None: formatter = DLSFormatter() formatter.format(device, options.output_path) - def extract_mapping_components(self, mapping: SingleMapping) -> list[Component]: - components: Tree[Component] = [] + def extract_mapping_components(self, mapping: SingleMapping) -> Tree: + components: Tree = [] attr_path = mapping.controller.path for name, sub_controller in mapping.controller.get_sub_controllers().items(): @@ -151,7 +153,7 @@ def extract_mapping_components(self, mapping: SingleMapping) -> list[Component]: ) ) - groups: dict[str, list[Component]] = {} + groups: dict[str, list[ComponentUnion]] = {} for attr_name, attribute in mapping.attributes.items(): try: signal = self._get_attribute_component( diff --git a/src/fastcs/backends/epics/ioc.py b/src/fastcs/backends/epics/ioc.py index ea39eae..7aa0e89 100644 --- a/src/fastcs/backends/epics/ioc.py +++ b/src/fastcs/backends/epics/ioc.py @@ -162,7 +162,7 @@ async def async_record_set(value: T): record.set(enum_value_to_index(attribute, value)) else: - async def async_record_set(value: T): # type: ignore + async def async_record_set(value: T): record.set(value) record = _get_input_record(f"{pv_prefix}:{pv_name}", attribute) @@ -173,8 +173,10 @@ async def async_record_set(value: T): # type: ignore def _get_input_record(pv: str, attribute: AttrR) -> RecordWrapper: if attr_is_enum(attribute): - # https://github.com/python/mypy/issues/16789 - state_keys = dict(zip(MBB_STATE_FIELDS, attribute.allowed_values, strict=False)) # type: ignore + assert attribute.allowed_values is not None and all( + isinstance(v, str) for v in attribute.allowed_values + ) + state_keys = dict(zip(MBB_STATE_FIELDS, attribute.allowed_values, strict=False)) return builder.mbbIn(pv, **state_keys) match attribute.datatype: @@ -210,7 +212,7 @@ async def async_write_display(value: T): async def on_update(value): await attribute.process_without_display_update(value) - async def async_write_display(value: T): # type: ignore + async def async_write_display(value: T): record.set(value, process=False) record = _get_output_record( @@ -223,7 +225,10 @@ async def async_write_display(value: T): # type: ignore def _get_output_record(pv: str, attribute: AttrW, on_update: Callable) -> Any: if attr_is_enum(attribute): - state_keys = dict(zip(MBB_STATE_FIELDS, attribute.allowed_values, strict=False)) # type: ignore + assert attribute.allowed_values is not None and all( + isinstance(v, str) for v in attribute.allowed_values + ) + state_keys = dict(zip(MBB_STATE_FIELDS, attribute.allowed_values, strict=False)) return builder.mbbOut(pv, always_update=True, on_update=on_update, **state_keys) match attribute.datatype: diff --git a/src/fastcs/backends/tango/dsr.py b/src/fastcs/backends/tango/dsr.py index 9932938..d8689e4 100644 --- a/src/fastcs/backends/tango/dsr.py +++ b/src/fastcs/backends/tango/dsr.py @@ -144,7 +144,7 @@ def _collect_dev_properties(mapping: Mapping) -> dict[str, Any]: def _collect_dev_init(mapping: Mapping) -> dict[str, Callable]: async def init_device(tango_device: Device): - await server.Device.init_device(tango_device) + await server.Device.init_device(tango_device) # type: ignore tango_device.set_state(DevState.ON) await mapping.controller.connect() diff --git a/src/fastcs/connections/ip_connection.py b/src/fastcs/connections/ip_connection.py index b121066..aab2ac4 100644 --- a/src/fastcs/connections/ip_connection.py +++ b/src/fastcs/connections/ip_connection.py @@ -12,43 +12,59 @@ class IPConnectionSettings: port: int = 25565 -class IPConnection: - def __init__(self): - self._reader, self._writer = (None, None) +@dataclass +class StreamConnection: + reader: asyncio.StreamReader + writer: asyncio.StreamWriter + + def __post_init__(self): self._lock = asyncio.Lock() - async def connect(self, settings: IPConnectionSettings): - self._reader, self._writer = await asyncio.open_connection( - settings.ip, settings.port - ) + async def __aenter__(self): + await self._lock.acquire() + return self - def ensure_connected(self): - if self._reader is None or self._writer is None: + async def __aexit__(self, exc_type, exc_val, exc_tb): + self._lock.release() + + async def send_message(self, message) -> None: + self.writer.write(message.encode("utf-8")) + await self.writer.drain() + + async def receive_response(self) -> str: + data = await self.reader.readline() + return data.decode("utf-8") + + async def close(self): + self.writer.close() + await self.writer.wait_closed() + + +class IPConnection: + def __init__(self): + self.__connection = None + + @property + def _connection(self) -> StreamConnection: + if self.__connection is None: raise DisconnectedError("Need to call connect() before using IPConnection.") + return self.__connection + + async def connect(self, settings: IPConnectionSettings): + reader, writer = await asyncio.open_connection(settings.ip, settings.port) + self.__connection = StreamConnection(reader, writer) + async def send_command(self, message) -> None: - async with self._lock: - self.ensure_connected() - await self._send_message(message) + async with self._connection as connection: + await connection.send_message(message) async def send_query(self, message) -> str: - async with self._lock: - self.ensure_connected() - await self._send_message(message) - return await self._receive_response() + async with self._connection as connection: + await connection.send_message(message) + return await connection.receive_response() - # TODO: Figure out type hinting for connections. TypeGuard fails to work as expected async def close(self): - async with self._lock: - self.ensure_connected() - self._writer.close() - await self._writer.wait_closed() - self._reader, self._writer = (None, None) - - async def _send_message(self, message) -> None: - self._writer.write(message.encode("utf-8")) - await self._writer.drain() - - async def _receive_response(self) -> str: - data = await self._reader.readline() - return data.decode("utf-8") + async with self._connection as connection: + await connection.close() + self.__connection = None diff --git a/src/fastcs/connections/serial_connection.py b/src/fastcs/connections/serial_connection.py index 6fcbc28..8c8e157 100644 --- a/src/fastcs/connections/serial_connection.py +++ b/src/fastcs/connections/serial_connection.py @@ -20,33 +20,33 @@ def __init__(self): self._lock = asyncio.Lock() async def connect(self, settings: SerialConnectionSettings) -> None: - self.stream = aioserial.AioSerial(port=settings.port, baudrate=settings.baud) + self.__stream = aioserial.AioSerial(port=settings.port, baudrate=settings.baud) - def ensure_open(self): - if self.stream is None: + @property + def _stream(self) -> aioserial.AioSerial: + if self.__stream is None: raise NotOpenedError( "Need to call connect() before using SerialConnection." ) + return self.__stream + async def send_command(self, message: bytes) -> None: async with self._lock: - self.ensure_open() await self._send_message(message) async def send_query(self, message: bytes, response_size: int) -> bytes: async with self._lock: - self.ensure_open() await self._send_message(message) return await self._receive_response(response_size) - async def close(self) -> None: - async with self._lock: - self.ensure_open() - self.stream.close() - self.stream = None - async def _send_message(self, message): - await self.stream.write_async(message) + await self._stream.write_async(message) async def _receive_response(self, size): - return await self.stream.read_async(size) + return await self._stream.read_async(size) + + async def close(self) -> None: + async with self._lock: + self._stream.close() + self.__stream = None diff --git a/tests/backends/epics/test_gui.py b/tests/backends/epics/test_gui.py index 8d19841..0ecabaf 100644 --- a/tests/backends/epics/test_gui.py +++ b/tests/backends/epics/test_gui.py @@ -6,6 +6,7 @@ SignalRW, SignalW, SignalX, + TextFormat, TextRead, TextWrite, ToggleButton, @@ -53,7 +54,7 @@ def test_get_components(mapping): SignalRW( name="StringEnum", read_pv="DEVICE:StringEnum_RBV", - read_widget=TextRead(format="string"), + read_widget=TextRead(format=TextFormat.string), write_pv="DEVICE:StringEnum", write_widget=ComboBox(choices=["red", "green", "blue"]), ), diff --git a/tests/backends/epics/test_ioc_system.py b/tests/backends/epics/test_ioc_system.py index 52b90c0..78920e4 100644 --- a/tests/backends/epics/test_ioc_system.py +++ b/tests/backends/epics/test_ioc_system.py @@ -1,10 +1,13 @@ +from p4p import Value from p4p.client.thread import Context def test_ioc(ioc: None): ctxt = Context("pva") - parent_pvi = ctxt.get("DEVICE:PVI").todict() + _parent_pvi = ctxt.get("DEVICE:PVI") + assert isinstance(_parent_pvi, Value) + parent_pvi = _parent_pvi.todict() assert all(f in parent_pvi for f in ("alarm", "display", "timeStamp", "value")) assert parent_pvi["display"] == {"description": "The records in this controller"} assert parent_pvi["value"] == { @@ -14,7 +17,9 @@ def test_ioc(ioc: None): } child_pvi_pv = parent_pvi["value"]["child"]["d"] - child_pvi = ctxt.get(child_pvi_pv).todict() + _child_pvi = ctxt.get(child_pvi_pv) + assert isinstance(_child_pvi, Value) + child_pvi = _child_pvi.todict() assert all(f in child_pvi for f in ("alarm", "display", "timeStamp", "value")) assert child_pvi["display"] == {"description": "The records in this controller"} assert child_pvi["value"] == { diff --git a/tests/conftest.py b/tests/conftest.py index 82af95d..f8b0f48 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,6 +4,7 @@ import subprocess import time from pathlib import Path +from typing import Any import pytest from aioca import purge_channel_caches @@ -19,11 +20,16 @@ if os.getenv("PYTEST_RAISE", "0") == "1": @pytest.hookimpl(tryfirst=True) - def pytest_exception_interact(call): - raise call.excinfo.value + def pytest_exception_interact(call: pytest.CallInfo[Any]): + if call.excinfo is not None: + raise call.excinfo.value + else: + raise RuntimeError( + f"{call} has no exception data, an unknown error has occurred" + ) @pytest.hookimpl(tryfirst=True) - def pytest_internalerror(excinfo): + def pytest_internalerror(excinfo: pytest.ExceptionInfo[Any]): raise excinfo.value @@ -100,7 +106,7 @@ def ioc(): start_time = time.monotonic() while "iocRun: All initialization complete" not in ( - process.stdout.readline().strip() + process.stdout.readline().strip() # type: ignore ): if time.monotonic() - start_time > 10: raise TimeoutError("IOC did not start in time")