diff options
| -rw-r--r-- | AUTHORS | 1 | ||||
| -rw-r--r-- | LICENSE-BSD | 30 | ||||
| -rw-r--r-- | LICENSE-LGPL (renamed from LICENSE) | 0 | ||||
| -rw-r--r-- | README.md | 8 | ||||
| -rw-r--r--[-rwxr-xr-x] | examples/oecho.py | 61 | ||||
| -rw-r--r--[-rwxr-xr-x] | examples/oecho_set.py | 63 | ||||
| -rw-r--r-- | ffi/fccntl_wrap.h | 4 | ||||
| -rw-r--r-- | ffi/pyouroboros_build_dev.py | 29 | ||||
| -rw-r--r-- | ffi/pyouroboros_build_irm.py | 29 | ||||
| -rw-r--r-- | ouroboros/__init__.py | 36 | ||||
| -rw-r--r-- | ouroboros/_qosspec.py | 49 | ||||
| -rw-r--r-- | ouroboros/_timespec.py | 51 | ||||
| -rw-r--r-- | ouroboros/cli.py | 302 | ||||
| -rw-r--r-- | ouroboros/dev.py | 356 | ||||
| -rw-r--r-- | ouroboros/errors.py | 222 | ||||
| -rw-r--r-- | ouroboros/event.py | 114 | ||||
| -rw-r--r-- | ouroboros/irm.py | 292 | ||||
| -rw-r--r-- | ouroboros/qos.py | 37 | ||||
| -rw-r--r-- | pyproject.toml | 15 | ||||
| -rwxr-xr-x | setup.py | 24 |
20 files changed, 979 insertions, 744 deletions
@@ -0,0 +1 @@ +Dimitri Staessens <dimitri@ouroboros.rocks> diff --git a/LICENSE-BSD b/LICENSE-BSD new file mode 100644 index 0000000..258ef3b --- /dev/null +++ b/LICENSE-BSD @@ -0,0 +1,30 @@ +Copyright (C) 2016 - 2026, Dimitri Staessens + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +1. Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following +disclaimer in the documentation and/or other materials provided +with the distribution. + +3. Neither the name of the copyright holder nor the names of its +contributors may be used to endorse or promote products derived +from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, +INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, +STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED +OF THE POSSIBILITY OF SUCH DAMAGE. @@ -314,7 +314,7 @@ from ouroboros.irm import ( For most use cases, the higher-level [`ouroboros.cli`](#cli-helpers) wrappers are easier to work with — they mirror the `irm` CLI tool and -take IPCP names rather than pids. +are keyed by IPCP name. ### IPCP Management @@ -534,7 +534,7 @@ See the module docstring for the full CLI-command ↔ Python mapping. ## Examples -See the [`examples/`](examples/) folder for runnable client/server +See the [`examples/`](examples/) directory for runnable client/server demos. ## Versioning @@ -545,8 +545,8 @@ pyOuroboros uses `setuptools_scm` to derive its version from git tags. | Scope | Rule | |---|---| -| ouroboros (C) ↔ pyouroboros / rumba | Shared `major.minor` — pyouroboros requires at least the same ouroboros `major.minor` | -| pyouroboros ↔ rumba ↔ ouroboros-integration | Strict lockstep `major.minor.patch` — always released together | +| ouroboros (C) ↔ pyouroboros / rumba | Shared `major.minor`, C side at least equal | +| pyouroboros ↔ rumba ↔ ouroboros-integration | Lockstep `major.minor.patch`, released together | ## License diff --git a/examples/oecho.py b/examples/oecho.py index 4bf3e05..ae94103 100755..100644 --- a/examples/oecho.py +++ b/examples/oecho.py @@ -1,43 +1,13 @@ -#!/bin/python - -# Ouroboros - Copyright (C) 2016 - 2026 -# -# A simple echo application -# -# Dimitri Staessens <dimitri@ouroboros.rocks> -# -# Redistribution and use in source and binary forms, with or without -# modification, are permitted provided that the following conditions -# are met: # -# 1. Redistributions of source code must retain the above copyright -# notice, this list of conditions and the following disclaimer. +# SPDX-FileCopyrightText: 2016 - 2026 Dimitri Staessens +# SPDX-License-Identifier: BSD-3-Clause # -# 2. Redistributions in binary form must reproduce the above -# copyright notice, this list of conditions and the following -# disclaimer in the documentation and/or other materials provided -# with the distribution. -# -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived -# from this software without specific prior written permission. -# -# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS -# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE -# COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, -# INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) -# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, -# STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) -# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED -# OF THE POSSIBILITY OF SUCH DAMAGE. - -"""A simple echo client/server example using Ouroboros flows.""" - -# Standalone demo; intentionally duplicates oecho_set.py. + +""" +A simple echo client/server example using Ouroboros flows. +""" + +# Standalone demo; duplicates oecho_set.py. # pylint: disable=duplicate-code from __future__ import annotations @@ -48,27 +18,38 @@ from ouroboros.dev import flow_accept, flow_alloc def client() -> None: - """Send a message and print the echo reply.""" + """ + Send a message and print the echo reply. + """ + with flow_alloc("oecho") as f: f.writeline("Hello, PyOuroboros!") print(f.readline()) def server() -> None: - """Accept flows and echo back received messages.""" + """ + Accept flows and echo back received messages. + """ + print("Starting the server.") + while True: with flow_accept() as f: print("New flow.") + line = f.readline() + print(f"Message from client is {line}") f.writeline(line) if __name__ == "__main__": parser = argparse.ArgumentParser(description='A simple echo client/server') + parser.add_argument('-l', '--listen', help='run as a server', action='store_true') + args = parser.parse_args() if args.listen: server() diff --git a/examples/oecho_set.py b/examples/oecho_set.py index 72265df..469fdb0 100755..100644 --- a/examples/oecho_set.py +++ b/examples/oecho_set.py @@ -1,43 +1,13 @@ -#!/bin/python - -# Ouroboros - Copyright (C) 2016 - 2026 -# -# A simple echo application -# -# Dimitri Staessens <dimitri@ouroboros.rocks> -# -# Redistribution and use in source and binary forms, with or without -# modification, are permitted provided that the following conditions -# are met: -# -# 1. Redistributions of source code must retain the above copyright -# notice, this list of conditions and the following disclaimer. # -# 2. Redistributions in binary form must reproduce the above -# copyright notice, this list of conditions and the following -# disclaimer in the documentation and/or other materials provided -# with the distribution. +# SPDX-FileCopyrightText: 2016 - 2026 Dimitri Staessens +# SPDX-License-Identifier: BSD-3-Clause # -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived -# from this software without specific prior written permission. -# -# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS -# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE -# COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, -# INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) -# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, -# STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) -# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED -# OF THE POSSIBILITY OF SUCH DAMAGE. - -"""A simple echo client/server example using Ouroboros flow event sets.""" - -# Standalone demo; intentionally duplicates oecho.py. + +""" +A simple echo client/server example using Ouroboros flow event sets. +""" + +# Standalone demo; duplicates oecho.py. # pylint: disable=duplicate-code from __future__ import annotations @@ -49,32 +19,45 @@ from ouroboros.event import FEventQueue, FEventType, FlowSet def client() -> None: - """Send a message and print the echo reply.""" + """ + Send a message and print the echo reply. + """ + with flow_alloc("oecho") as f: f.writeline("Hello, PyOuroboros!") print(f.readline()) def server() -> None: - """Accept flows and echo back received messages using flow events.""" + """ + Accept flows and echo back received messages using flow events. + """ + print("Starting the server.") + while True: with flow_accept() as f: print("New flow.") with FEventQueue() as fq, FlowSet([f]) as fs: fs.wait(fq) + f2, t = fq.next() + if t != FEventType.FLOW_PKT: continue + line = f2.readline() + print(f"Message from client is {line}") f2.writeline(line) if __name__ == "__main__": parser = argparse.ArgumentParser(description='A simple echo client/server') + parser.add_argument('-l', '--listen', help='run as a server', action='store_true') + args = parser.parse_args() if args.listen: server() diff --git a/ffi/fccntl_wrap.h b/ffi/fccntl_wrap.h index 0555e24..7cfc902 100644 --- a/ffi/fccntl_wrap.h +++ b/ffi/fccntl_wrap.h @@ -71,7 +71,7 @@ int flow_get_flags(int fd) { uint32_t flags; - if (fccntl(fd, FLOWGFLAGS, &flags)) + if (fccntl(fd, FLOWGFLAGS, &flags) < 0) return -EPERM; return (int) flags; @@ -86,7 +86,7 @@ int flow_get_frct_flags(int fd) { uint16_t flags; - if (fccntl(fd, FRCTGFLAGS, &flags)) + if (fccntl(fd, FRCTGFLAGS, &flags) < 0) return -EPERM; return (int) flags; diff --git a/ffi/pyouroboros_build_dev.py b/ffi/pyouroboros_build_dev.py index d32d577..fc7a05a 100644 --- a/ffi/pyouroboros_build_dev.py +++ b/ffi/pyouroboros_build_dev.py @@ -1,29 +1,18 @@ # -# Ouroboros - Copyright (C) 2016 - 2026 -# -# Python API for Ouroboros -# -# Dimitri Staessens <dimitri@ouroboros.rocks> -# -# This library is free software; you can redistribute it and/or -# modify it under the terms of the GNU Lesser General Public License -# version 2.1 as published by the Free Software Foundation. -# -# This library is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -# Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public -# License along with this library; if not, write to the Free Software -# Foundation, Inc., http://www.fsf.org/about/contact/. +# SPDX-FileCopyrightText: 2016 - 2026 Dimitri Staessens +# SPDX-License-Identifier: LGPL-2.1-only # -"""CFFI build script for the ouroboros-dev library.""" +""" +CFFI build script for the ouroboros-dev library. +""" -# qosspec_t / timespec cdef is duplicated in the irm builder per CFFI module. +# qosspec_t / timespec cdef is duplicated in the irm builder, one copy +# per CFFI module. # pylint: disable=duplicate-code +from __future__ import annotations + from cffi import FFI ffibuilder: FFI = FFI() diff --git a/ffi/pyouroboros_build_irm.py b/ffi/pyouroboros_build_irm.py index 1773788..a9f4bc9 100644 --- a/ffi/pyouroboros_build_irm.py +++ b/ffi/pyouroboros_build_irm.py @@ -1,29 +1,18 @@ # -# Ouroboros - Copyright (C) 2016 - 2026 -# -# Python API for Ouroboros -# -# Dimitri Staessens <dimitri@ouroboros.rocks> -# -# This library is free software; you can redistribute it and/or -# modify it under the terms of the GNU Lesser General Public License -# version 2.1 as published by the Free Software Foundation. -# -# This library is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -# Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public -# License along with this library; if not, write to the Free Software -# Foundation, Inc., http://www.fsf.org/about/contact/. +# SPDX-FileCopyrightText: 2016 - 2026 Dimitri Staessens +# SPDX-License-Identifier: LGPL-2.1-only # -"""CFFI build script for the ouroboros-irm library.""" +""" +CFFI build script for the ouroboros-irm library. +""" -# qosspec_t / timespec cdef is duplicated in the dev builder per CFFI module. +# qosspec_t / timespec cdef is duplicated in the dev builder, one copy +# per CFFI module. # pylint: disable=duplicate-code +from __future__ import annotations + from cffi import FFI ffibuilder: FFI = FFI() diff --git a/ouroboros/__init__.py b/ouroboros/__init__.py index a4de9bf..cbca95d 100644 --- a/ouroboros/__init__.py +++ b/ouroboros/__init__.py @@ -1,35 +1,15 @@ # -# Ouroboros - Copyright (C) 2016 - 2026 -# -# Python API for Ouroboros -# -# Dimitri Staessens <dimitri@ouroboros.rocks> -# -# This library is free software; you can redistribute it and/or -# modify it under the terms of the GNU Lesser General Public License -# version 2.1 as published by the Free Software Foundation. -# -# This library is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -# Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public -# License along with this library; if not, write to the Free Software -# Foundation, Inc., http://www.fsf.org/about/contact/. +# SPDX-FileCopyrightText: 2016 - 2026 Dimitri Staessens +# SPDX-License-Identifier: LGPL-2.1-only # -"""Public Python API for Ouroboros. +""" +Public Python API for Ouroboros. -The package surface only re-exports pure-Python helpers (errors and -QoS). Applications import the load-bearing parts of the API from -their submodule explicitly: flows from :mod:`ouroboros.dev`, the -event loop from :mod:`ouroboros.event`, and the control plane from -:mod:`ouroboros.irm` (or :mod:`ouroboros.cli`). Submodule paths -make the caller's intent explicit and keep the package importable -in environments that have no business loading the libouroboros CFFI -extensions (see ``CLAUDE.md`` for the underlying init-on-load -behaviour that makes this contract load-bearing in practice). +The package surface re-exports the pure-Python helpers only: errors and +QoS. The rest of the API is imported from its submodule: flows from +:mod:`ouroboros.dev`, the event loop from :mod:`ouroboros.event`, the +control plane from :mod:`ouroboros.irm` (or :mod:`ouroboros.cli`). """ from __future__ import annotations diff --git a/ouroboros/_qosspec.py b/ouroboros/_qosspec.py index 6031c42..5c8b245 100644 --- a/ouroboros/_qosspec.py +++ b/ouroboros/_qosspec.py @@ -1,47 +1,35 @@ # -# Ouroboros - Copyright (C) 2016 - 2026 -# -# Internal helpers for translating QoSSpec <-> CFFI qosspec_t. -# -# Dimitri Staessens <dimitri@ouroboros.rocks> -# -# This library is free software; you can redistribute it and/or -# modify it under the terms of the GNU Lesser General Public License -# version 2.1 as published by the Free Software Foundation. -# -# This library is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -# Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public -# License along with this library; if not, write to the Free Software -# Foundation, Inc., http://www.fsf.org/about/contact/. +# SPDX-FileCopyrightText: 2016 - 2026 Dimitri Staessens +# SPDX-License-Identifier: LGPL-2.1-only # -"""Shared QoSSpec <-> qosspec_t conversion helpers. +""" +Shared QoSSpec <-> qosspec_t conversion helpers. -dev.py and irm.py each link against their own CFFI extension module -(``_ouroboros_dev_cffi`` and ``_ouroboros_irm_cffi``), so they each -have their own ``ffi`` instance and their own ``qosspec_t`` type. -CFFI types are not interchangeable across modules, so these helpers -take the caller's ``ffi`` instance as a parameter. +CFFI types are not interchangeable across extension modules, so the +caller passes its own ``ffi`` instance. """ from __future__ import annotations -from typing import Optional +from typing import TYPE_CHECKING, Any, Optional from ouroboros.qos import QoSSpec +if TYPE_CHECKING: + from cffi import FFI -def qos_to_qosspec(ffi, qos: Optional[QoSSpec]): - """Convert a :class:`QoSSpec` to a freshly-allocated ``qosspec_t *``. + +def qos_to_qosspec(ffi: FFI, qos: Optional[QoSSpec]) -> Any: + """ + Convert a :class:`QoSSpec` to a freshly-allocated ``qosspec_t *``. Returns ``ffi.NULL`` when *qos* is ``None``. """ + if qos is None: return ffi.NULL + return ffi.new("qosspec_t *", [qos.service, qos.delay, @@ -53,13 +41,16 @@ def qos_to_qosspec(ffi, qos: Optional[QoSSpec]): qos.timeout]) -def qosspec_to_qos(ffi, _qos) -> Optional[QoSSpec]: - """Convert a ``qosspec_t *`` back to a :class:`QoSSpec`. +def qosspec_to_qos(ffi: FFI, _qos: Any) -> Optional[QoSSpec]: + """ + Convert a ``qosspec_t *`` back to a :class:`QoSSpec`. Returns ``None`` when *_qos* is ``ffi.NULL``. """ + if _qos == ffi.NULL: return None + return QoSSpec(service=_qos.service, delay=_qos.delay, bandwidth=_qos.bandwidth, diff --git a/ouroboros/_timespec.py b/ouroboros/_timespec.py index 2fcf666..1656eb2 100644 --- a/ouroboros/_timespec.py +++ b/ouroboros/_timespec.py @@ -1,64 +1,59 @@ # -# Ouroboros - Copyright (C) 2016 - 2026 -# -# Internal helpers for translating float seconds <-> struct timespec. -# -# Dimitri Staessens <dimitri@ouroboros.rocks> -# -# This library is free software; you can redistribute it and/or -# modify it under the terms of the GNU Lesser General Public License -# version 2.1 as published by the Free Software Foundation. -# -# This library is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -# Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public -# License along with this library; if not, write to the Free Software -# Foundation, Inc., http://www.fsf.org/about/contact/. +# SPDX-FileCopyrightText: 2016 - 2026 Dimitri Staessens +# SPDX-License-Identifier: LGPL-2.1-only # -"""Shared float-seconds <-> ``struct timespec *`` conversions. +""" +Shared float-seconds <-> ``struct timespec *`` conversions. -The dev and event modules both deal in seconds-as-float timeouts on -the Python side and pass ``struct timespec *`` to the C library. -The helpers take the caller's ``ffi`` instance as a parameter so the -returned CData is bound to the right CFFI module. +The caller passes its own ``ffi`` instance so the returned CData is +bound to the right CFFI extension module. """ from __future__ import annotations from math import modf -from typing import Optional +from typing import TYPE_CHECKING, Any, Optional + +if TYPE_CHECKING: + from cffi import FFI BILLION = 1000 * 1000 * 1000 -def fl_to_timespec(ffi, timeo: Optional[float]): - """Convert *timeo* (seconds, or None) to a ``struct timespec *``. +def fl_to_timespec(ffi: FFI, timeo: Optional[float]) -> Any: + """ + Convert *timeo* (seconds, or None) to a ``struct timespec *``. *None* yields ``ffi.NULL`` (block forever). A non-positive *timeo* yields a zeroed timespec (async / poll). """ + if timeo is None: return ffi.NULL + if timeo <= 0: return ffi.new("struct timespec *", [0, 0]) + frac, whole = modf(timeo) _timeo = ffi.new("struct timespec *") _timeo.tv_sec = int(whole) _timeo.tv_nsec = int(frac * BILLION) + return _timeo -def timespec_to_fl(ffi, _timeo) -> Optional[float]: - """Convert a ``struct timespec *`` to seconds-as-float. +def timespec_to_fl(ffi: FFI, _timeo: Any) -> Optional[float]: + """ + Convert a ``struct timespec *`` to seconds-as-float. ``ffi.NULL`` yields *None* (block forever). """ + if _timeo == ffi.NULL: return None + if _timeo.tv_sec <= 0 and _timeo.tv_nsec == 0: return 0.0 + return _timeo.tv_sec + _timeo.tv_nsec / BILLION diff --git a/ouroboros/cli.py b/ouroboros/cli.py index 4b9ebb7..e887410 100644 --- a/ouroboros/cli.py +++ b/ouroboros/cli.py @@ -1,64 +1,13 @@ # -# Ouroboros - Copyright (C) 2016 - 2026 -# -# Python API for Ouroboros - CLI equivalents -# -# Dimitri Staessens <dimitri@ouroboros.rocks> -# -# This library is free software; you can redistribute it and/or -# modify it under the terms of the GNU Lesser General Public License -# version 2.1 as published by the Free Software Foundation. -# -# This library is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -# Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public -# License along with this library; if not, write to the Free Software -# Foundation, Inc., http://www.fsf.org/about/contact/. +# SPDX-FileCopyrightText: 2016 - 2026 Dimitri Staessens +# SPDX-License-Identifier: LGPL-2.1-only # -"""Higher-level wrappers that mirror the ``irm`` CLI tool behaviour. - -This is the Python counterpart of the C project's ``ouroboros/tools/irm`` -command-line tool. The ``irm`` CLI performs steps that the raw C -library API does not, such as resolving program names via ``realpath``, -looking up IPCP pids, and handling the ``autobind`` flag during -bootstrap/enroll. This module exposes those same patterns as a Python -API so callers do not need to re-implement them. - -Each wrapper corresponds to a specific ``irm`` sub-command: - -========================= ==================================== -Python CLI equivalent -========================= ==================================== -``create_ipcp`` ``irm ipcp create`` -``destroy_ipcp`` ``irm ipcp destroy`` -``bootstrap_ipcp`` ``irm ipcp bootstrap [autobind]`` -``enroll_ipcp`` ``irm ipcp enroll [autobind]`` -``connect_ipcp`` ``irm ipcp connect`` -``disconnect_ipcp`` ``irm ipcp disconnect`` -``list_ipcps`` ``irm ipcp list`` -``bind_program`` ``irm bind program`` -``bind_process`` ``irm bind process`` -``bind_ipcp`` ``irm bind ipcp`` -``unbind_program`` ``irm unbind program`` -``unbind_process`` ``irm unbind process`` -``unbind_ipcp`` ``irm unbind ipcp`` -``create_name`` ``irm name create`` -``destroy_name`` ``irm name destroy`` -``reg_name`` ``irm name register`` -``unreg_name`` ``irm name unregister`` -``list_names`` ``irm name list`` -``autoboot`` ``irm ipcp bootstrap autobind`` - (with implicit ``create``) -========================= ==================================== - -Usage:: - - from ouroboros.cli import create_ipcp, bootstrap_ipcp, enroll_ipcp - from ouroboros.cli import bind_program, autoboot +""" +Higher-level wrappers that mirror the ``irm`` CLI tool behaviour. + +Adds program path resolution, IPCP name-to-pid lookup and ``autobind`` +handling on top of the raw IRM API. """ from __future__ import annotations @@ -110,7 +59,7 @@ __all__ = [ "unbind_program", "unbind_process", "unbind_ipcp", "create_name", "destroy_name", "reg_name", "unreg_name", "list_names", "autoboot", - # Re-exported configuration types (convenience) + # Re-exported configuration types "DhtConfig", "DirConfig", "DtConfig", "EthConfig", "IpcpConfig", "IpcpInfo", "IpcpType", "LinkStateConfig", "NameInfo", "RoutingConfig", @@ -119,10 +68,14 @@ __all__ = [ def _pid_of(ipcp_name: str) -> int: - """Look up the pid of a running IPCP by its name.""" + """ + Look up the pid of a running IPCP by its name. + """ + for info in _irm_list_ipcps(): if info.name == ipcp_name: return info.pid + raise ValueError(f"No IPCP named {ipcp_name!r}") @@ -130,68 +83,83 @@ def _collect_pids(ipcp: Optional[str], ipcps: Optional[List[str]], layer: Optional[str], layers: Optional[List[str]]) -> Set[int]: - """Collect the set of pids matching the given name/layer filters.""" + """ + Collect the set of pids matching the given name/layer filters. + """ + ipcp_names: List[str] = [] + if ipcp is not None: ipcp_names.append(ipcp) + if ipcps is not None: ipcp_names.extend(ipcps) layer_names: List[str] = [] + if layer is not None: layer_names.append(layer) + if layers is not None: layer_names.extend(layers) pids: Set[int] = set() + if not ipcp_names and not layer_names: return pids all_ipcps = _irm_list_ipcps() + for ipcp_name in ipcp_names: for i in all_ipcps: if i.name == ipcp_name: pids.add(i.pid) break + for lyr in layer_names: for i in all_ipcps: if i.layer == lyr: pids.add(i.pid) + return pids def destroy_ipcp(name: str) -> None: - """Destroy an IPCP by name. - - Mirrors ``irm ipcp destroy name <name>``. - - Resolves the IPCP name to a pid, then destroys the IPCP. + """ + Destroy an IPCP by name. :param name: Name of the IPCP to destroy. :raises ValueError: If no IPCP with *name* exists. """ + _irm_destroy_ipcp(_pid_of(name)) def list_ipcps(name: Optional[str] = None, layer: Optional[str] = None, ipcp_type: Optional[IpcpType] = None) -> List[IpcpInfo]: - """List running IPCPs, optionally filtered. + """ + List running IPCPs, optionally filtered. - Mirrors ``irm ipcp list [name <n>] [type <t>] [layer <l>]``. + Filters are exact matches. :param name: Filter by IPCP name (exact match). :param layer: Filter by layer name (exact match). :param ipcp_type: Filter by IPCP type. :return: List of matching :class:`IpcpInfo` objects. """ + result = _irm_list_ipcps() + if name is not None: result = [i for i in result if i.name == name] + if layer is not None: result = [i for i in result if i.layer == layer] + if ipcp_type is not None: result = [i for i in result if i.type == ipcp_type] + return result @@ -200,21 +168,10 @@ def reg_name(name: str, ipcps: Optional[List[str]] = None, layer: Optional[str] = None, layers: Optional[List[str]] = None) -> None: - """Register a name with IPCP(s), creating it first if needed. - - Mirrors ``irm name register <name> ipcp <ipcp> [ipcp ...] - layer <layer> [layer ...]``. - - The C CLI tool resolves IPCP names and layer names to pids, - checks whether the name already exists and calls - ``irm_create_name`` before ``irm_reg_name`` for each IPCP. - - The function accepts flexible input: + """ + Register a name with IPCP(s), creating it first if needed. - - A single *ipcp* name or list of *ipcps* names. - - A single *layer* name or list of *layers* names (registers - with every IPCP in each of those layers). - - Any combination of the above. + Registers with every IPCP in *layer* and *layers*. :param name: The name to register. :param ipcp: Single IPCP name to register with. @@ -222,6 +179,7 @@ def reg_name(name: str, :param layer: Single layer name to register with. :param layers: List of layer names to register with. """ + existing = {n.name for n in _irm_list_names()} if name not in existing: _irm_create_name(NameInfo(name=name)) @@ -234,129 +192,120 @@ def bind_program(prog: str, name: str, opts: int = 0, argv: Optional[List[str]] = None) -> None: - """Bind a program to a name, resolving bare names to full paths. - - Mirrors ``irm bind program <prog> name <name>``. - - The ``irm bind program`` CLI tool calls ``realpath()`` on *prog* - before passing it to the library. The raw C function - ``irm_bind_program`` contains a ``check_prog_path`` helper that - corrupts the ``PATH`` environment variable (writes NUL over ``:`` - separators) when given a bare program name. Only the first such - call would succeed in a long-running process. - - This wrapper resolves *prog* via ``shutil.which()`` before calling - the library, avoiding the bug entirely. + """ + Bind a program to a name. :param prog: Program name or path. Bare names (without ``/``) are - resolved on ``PATH`` via ``shutil.which()``. + resolved on ``PATH``. :param name: Name to bind to. :param opts: Bind options (e.g. ``BIND_AUTO``). - :param argv: Arguments to pass when the program is auto-started. - :raises BindError: If the program cannot be found or the bind - call fails. + :param argv: Arguments passed when the program is auto-started. + :raises BindError: If the program is not found on ``PATH``. """ + if '/' not in prog: resolved = shutil.which(prog) if resolved is None: raise BindError(f"Program {prog!r} not found on PATH") + prog = resolved + _irm_bind_program(prog, name, opts=opts, argv=argv) def unbind_program(prog: str, name: str) -> None: - """Unbind a program from a name. - - Mirrors ``irm unbind program <prog> name <name>``. + """ + Unbind a program from a name. :param prog: Path to the program. :param name: Name to unbind from. """ + _irm_unbind_program(prog, name) def bind_process(pid: int, name: str) -> None: - """Bind a running process to a name. - - Mirrors ``irm bind process <pid> name <name>``. + """ + Bind a running process to a name. :param pid: PID of the process. :param name: Name to bind to. """ + _irm_bind_process(pid, name) def unbind_process(pid: int, name: str) -> None: - """Unbind a process from a name. - - Mirrors ``irm unbind process <pid> name <name>``. + """ + Unbind a process from a name. :param pid: PID of the process. :param name: Name to unbind from. """ + _irm_unbind_process(pid, name) def bind_ipcp(ipcp: str, name: str) -> None: - """Bind an IPCP to a name. - - Mirrors ``irm bind ipcp <ipcp> name <name>``. - - Resolves the IPCP name to a pid, then calls :func:`bind_process`. + """ + Bind an IPCP to a name. :param ipcp: IPCP instance name. :param name: Name to bind to. :raises ValueError: If no IPCP with *ipcp* exists. """ + _irm_bind_process(_pid_of(ipcp), name) def unbind_ipcp(ipcp: str, name: str) -> None: - """Unbind an IPCP from a name. - - Mirrors ``irm unbind ipcp <ipcp> name <name>``. - - Resolves the IPCP name to a pid, then calls :func:`unbind_process`. + """ + Unbind an IPCP from a name. :param ipcp: IPCP instance name. :param name: Name to unbind from. :raises ValueError: If no IPCP with *ipcp* exists. """ + _irm_unbind_process(_pid_of(ipcp), name) def create_name(name: str, pol_lb: Optional[int] = None, info: Optional[NameInfo] = None) -> None: - """Create a registered name. - - Mirrors ``irm name create <name> [lb <policy>]``. + """ + Create a registered name. :param name: The name to create. - :param pol_lb: Load-balance policy (optional). - :param info: Full :class:`NameInfo` (overrides *name* / *pol_lb* - if given). + :param pol_lb: Load-balance policy. + :param info: Full :class:`NameInfo`; overrides *name* and + *pol_lb*. """ + if info is not None: _irm_create_name(info) else: ni = NameInfo(name=name) + if pol_lb is not None: ni.pol_lb = pol_lb + _irm_create_name(ni) def list_names(name: Optional[str] = None) -> List[NameInfo]: - """List all registered names, optionally filtered. - - Mirrors ``irm name list [<name>]``. + """ + List all registered names, optionally filtered on exact name. :param name: Filter by name (exact match). :return: List of :class:`NameInfo` objects. """ + result = _irm_list_names() + if name is not None: result = [n for n in result if n.name == name] + return result @@ -365,12 +314,10 @@ def unreg_name(name: str, ipcps: Optional[List[str]] = None, layer: Optional[str] = None, layers: Optional[List[str]] = None) -> None: - """Unregister a name from IPCP(s). - - Mirrors ``irm name unregister <name> ipcp <ipcp> [ipcp ...] - layer <layer> [layer ...]``. + """ + Unregister a name from IPCP(s). - Accepts the same flexible input as :func:`reg_name`. + Unregisters from every IPCP in *layer* and *layers*. :param name: The name to unregister. :param ipcp: Single IPCP name to unregister from. @@ -378,6 +325,7 @@ def unreg_name(name: str, :param layer: Single layer name to unregister from. :param layers: List of layer names to unregister from. """ + for pid in _collect_pids(ipcp, ipcps, layer, layers): _irm_unreg_name(name, pid) @@ -385,28 +333,21 @@ def unreg_name(name: str, def bootstrap_ipcp(name: str, conf: IpcpConfig, autobind: bool = False) -> None: - """Bootstrap an IPCP, optionally binding it to its name and layer. - - Mirrors ``irm ipcp bootstrap name <n> layer <l> [autobind]``. - - When *autobind* is ``True`` and the IPCP type is ``UNICAST`` or - ``BROADCAST``, the sequence is:: - - bind_process(pid, ipcp_name) # accept flows for ipcp name - bind_process(pid, layer_name) # accept flows for layer name - bootstrap_ipcp(pid, conf) # bootstrap into the layer + """ + Bootstrap an IPCP, optionally binding it to its name and layer. - This matches the C ``irm ipcp bootstrap`` tool exactly. If - bootstrap fails after autobind, the bindings are rolled back. + Autobind applies to UNICAST and BROADCAST IPCPs only, and is rolled + back if the bootstrap fails. :param name: Name of the IPCP. - :param conf: IPCP configuration (includes layer name & type). + :param conf: IPCP configuration, including layer name and type. :param autobind: Bind the IPCP process to its name and layer. """ + pid = _pid_of(name) layer_name = conf.layer_name - autobind_types = (IpcpType.UNICAST, IpcpType.BROADCAST) + autobind_types = (IpcpType.UNICAST, IpcpType.BROADCAST) if autobind and conf.ipcp_type in autobind_types: _irm_bind_process(pid, name) _irm_bind_process(pid, layer_name) @@ -417,33 +358,26 @@ def bootstrap_ipcp(name: str, if autobind and conf.ipcp_type in autobind_types: _irm_unbind_process(pid, name) _irm_unbind_process(pid, layer_name) + raise def enroll_ipcp(name: str, dst: str, autobind: bool = False) -> None: - """Enroll an IPCP, optionally binding it to its name and layer. - - Mirrors ``irm ipcp enroll name <n> layer <dst> [autobind]``. - - When *autobind* is ``True``, the sequence is:: - - enroll_ipcp(pid, dst) - bind_process(pid, ipcp_name) - bind_process(pid, layer_name) # layer learned from enrollment - - This matches the C ``irm ipcp enroll`` tool exactly. + """ + Enroll an IPCP, optionally binding it to its name and layer. :param name: Name of the IPCP. :param dst: Destination name or layer to enroll with. - :param autobind: Bind the IPCP process to its name and layer - after successful enrollment. + :param autobind: Bind the IPCP process to its name and to the layer + learned during enrollment. """ + pid = _pid_of(name) + _irm_enroll_ipcp(pid, dst) if autobind: - # Look up enrolled layer from the IPCP list. for info in _irm_list_ipcps(): if info.pid == pid: _irm_bind_process(pid, info.name) @@ -453,44 +387,38 @@ def enroll_ipcp(name: str, dst: str, def connect_ipcp(name: str, dst: str, comp: str = "*", qos: Optional[QoSSpec] = None) -> None: - """Connect IPCP components to a destination. - - Mirrors ``irm ipcp connect name <n> dst <dst> [component <c>] - [qos <qos>]``. - - When *comp* is ``"*"`` (default), both ``dt`` and ``mgmt`` - components are connected, matching the CLI default. + """ + Connect IPCP components to a destination. :param name: Name of the IPCP. :param dst: Destination IPCP name. - :param comp: Component to connect: ``"dt"``, ``"mgmt"``, or - ``"*"`` for both (default). + :param comp: ``"dt"``, ``"mgmt"``, or ``"*"`` for both. :param qos: QoS specification for the dt component. """ + pid = _pid_of(name) + if comp in ("*", "mgmt"): _irm_connect_ipcp(pid, MGMT_COMP, dst) + if comp in ("*", "dt"): _irm_connect_ipcp(pid, DT_COMP, dst, qos=qos) def disconnect_ipcp(name: str, dst: str, comp: str = "*") -> None: - """Disconnect IPCP components from a destination. - - Mirrors ``irm ipcp disconnect name <n> dst <dst> - [component <c>]``. - - When *comp* is ``"*"`` (default), both ``dt`` and ``mgmt`` - components are disconnected, matching the CLI default. + """ + Disconnect IPCP components from a destination. :param name: Name of the IPCP. :param dst: Destination IPCP name. - :param comp: Component to disconnect: ``"dt"``, ``"mgmt"``, or - ``"*"`` for both (default). + :param comp: ``"dt"``, ``"mgmt"``, or ``"*"`` for both. """ + pid = _pid_of(name) + if comp in ("*", "mgmt"): _irm_disconnect_ipcp(pid, MGMT_COMP, dst) + if comp in ("*", "dt"): _irm_disconnect_ipcp(pid, DT_COMP, dst) @@ -499,24 +427,22 @@ def autoboot(name: str, ipcp_type: IpcpType, layer: str, conf: Optional[IpcpConfig] = None) -> None: - """Create, autobind and bootstrap an IPCP in one step. - - Convenience wrapper equivalent to:: - - irm ipcp bootstrap name <name> type <type> layer <layer> autobind - - (with an implicit ``create`` if the IPCP does not yet exist). + """ + Create, autobind and bootstrap an IPCP in one step. :param name: Name for the IPCP. :param ipcp_type: Type of IPCP to create. :param layer: Layer name to bootstrap into. - :param conf: Optional IPCP configuration. If *None*, a default - :class:`IpcpConfig` is created for the given - *ipcp_type* and *layer*. + :param conf: IPCP configuration. If *None*, a default + :class:`IpcpConfig` is built from *ipcp_type* and + *layer*. """ + create_ipcp(name, ipcp_type) + if conf is None: conf = IpcpConfig(ipcp_type=ipcp_type, layer_name=layer) else: conf.layer_name = layer + bootstrap_ipcp(name, conf, autobind=True) diff --git a/ouroboros/dev.py b/ouroboros/dev.py index ffa59b6..1397bca 100644 --- a/ouroboros/dev.py +++ b/ouroboros/dev.py @@ -1,31 +1,17 @@ # -# Ouroboros - Copyright (C) 2016 - 2026 -# -# Python API for Ouroboros -# -# Dimitri Staessens <dimitri@ouroboros.rocks> -# -# This library is free software; you can redistribute it and/or -# modify it under the terms of the GNU Lesser General Public License -# version 2.1 as published by the Free Software Foundation. -# -# This library is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -# Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public -# License along with this library; if not, write to the Free Software -# Foundation, Inc., http://www.fsf.org/about/contact/. +# SPDX-FileCopyrightText: 2016 - 2026 Dimitri Staessens +# SPDX-License-Identifier: LGPL-2.1-only # -"""Flow allocation, acceptance and I/O for the Ouroboros dev API.""" +""" +Flow allocation, acceptance and I/O for the Ouroboros dev API. +""" from __future__ import annotations import warnings from enum import IntFlag -from typing import Optional +from typing import TYPE_CHECKING, Optional, Type from _ouroboros_dev_cffi import ffi, lib @@ -40,19 +26,28 @@ from ouroboros.errors import ( ) from ouroboros.qos import QoSSpec +if TYPE_CHECKING: + from types import TracebackType + check_ouroboros_version(lib.OUROBOROS_VERSION_MAJOR, lib.OUROBOROS_VERSION_MINOR) class FrctFlags(IntFlag): - """FRCT-level feature flags.""" + """ + FRCT-level feature flags. + """ + RETRANSMIT = 0o1 RESCNTL = 0o2 LINGER = 0o4 class FlowProperties(IntFlag): - """Flags describing flow properties and blocking behaviour.""" + """ + Flags describing flow properties and blocking behaviour. + """ + READ_ONLY = 0o0 WRITE_ONLY = 0o1 READ_WRITE = 0o2 @@ -65,47 +60,68 @@ class FlowProperties(IntFlag): class Flow: - """Represents an allocated Ouroboros flow.""" + """ + Represents an allocated Ouroboros flow. + """ def __init__(self, fd: int = -1) -> None: - """Construct a Flow wrapper. + """ + Construct a Flow wrapper. - :param fd: Existing flow descriptor to wrap, or -1 for an - unallocated Flow that will be populated by - :meth:`alloc`, :meth:`accept` or :meth:`join`. + :param fd: Existing flow descriptor, or -1 for an unallocated + flow. """ + self._fd: int = fd def __enter__(self) -> Flow: return self - def __exit__(self, exc_type, exc_value, tb) -> None: + def __exit__(self, exc_type: Optional[Type[BaseException]], + exc_value: Optional[BaseException], + tb: Optional[TracebackType]) -> None: self.dealloc() def __del__(self) -> None: + """ + Deallocate on collection; errors are swallowed as the library + may already be torn down at interpreter shutdown. + """ + try: self.dealloc() except Exception: # pylint: disable=broad-exception-caught - pass # interpreter shutdown may have torn down lib already + pass def __repr__(self) -> str: return f"Flow(fd={self._fd})" def fileno(self) -> int: - """Return the underlying ouroboros flow descriptor.""" + """ + Return the underlying ouroboros flow descriptor. + + :return: The ouroboros flow descriptor, or -1 if unallocated. + """ + return self._fd def alloc(self, dst: str, qos: Optional[QoSSpec] = None, timeo: Optional[float] = None) -> Optional[QoSSpec]: - """Allocate a flow with a certain QoS to a destination. + """ + Allocate a flow with a certain QoS to a destination. :param dst: Destination name. :param qos: Requested QoS. - :param timeo: Allocation timeout (None blocks forever, 0 is async). + :param timeo: Allocation timeout (None blocks forever, 0 is + async). :return: The QoS the IRM granted for the new flow. + :raises FlowAlreadyAllocatedError: If this Flow is already + allocated. + :raises FlowError: On a negative ouroboros return code. """ + if self._fd >= 0: raise FlowAlreadyAllocatedError() @@ -113,18 +129,25 @@ class Flow: _timeo = fl_to_timespec(ffi, timeo) rc = lib.flow_alloc(dst.encode(), _qos, _timeo) + raise_errno(rc) + self._fd = rc return qosspec_to_qos(ffi, _qos) def accept(self, timeo: Optional[float] = None) -> Optional[QoSSpec]: - """Accept an incoming flow and return its QoS. + """ + Accept an incoming flow and return its QoS. :param timeo: Accept timeout (None blocks forever, 0 is async). :return: The QoS of the accepted flow. + :raises FlowAlreadyAllocatedError: If this Flow is already + allocated. + :raises FlowError: On a negative ouroboros return code. """ + if self._fd >= 0: raise FlowAlreadyAllocatedError() @@ -132,7 +155,9 @@ class Flow: _timeo = fl_to_timespec(ffi, timeo) rc = lib.flow_accept(_qos, _timeo) + raise_errno(rc) + self._fd = rc return qosspec_to_qos(ffi, _qos) @@ -140,26 +165,38 @@ class Flow: def join(self, dst: str, timeo: Optional[float] = None) -> None: - """Join a broadcast layer. + """ + Join a broadcast layer. - :param dst: Destination broadcast layer name. + :param dst: Broadcast layer name. :param timeo: Join timeout (None blocks forever, 0 is async). + :raises FlowAlreadyAllocatedError: If this Flow is already + allocated. + :raises FlowError: On a negative ouroboros return code. """ + if self._fd >= 0: raise FlowAlreadyAllocatedError() _timeo = fl_to_timespec(ffi, timeo) rc = lib.flow_join(dst.encode(), _timeo) + raise_errno(rc) + self._fd = rc def dealloc(self) -> None: - """Deallocate this flow. Idempotent: a no-op on an unallocated flow.""" + """ + Deallocate this flow; a no-op on an unallocated flow. + """ + if self._fd < 0: return + rc = lib.flow_dealloc(self._fd) self._fd = -1 + if rc < 0: warnings.warn(f"flow_dealloc returned {rc}", FlowDeallocWarning, stacklevel=2) @@ -167,13 +204,17 @@ class Flow: def write(self, buf: bytes, count: Optional[int] = None) -> int: - """Write up to *count* bytes to the flow. + """ + Write up to *count* bytes to the flow. :param buf: Buffer to write from. - :param count: Number of bytes to write (defaults to ``len(buf)``). + :param count: Number of bytes to write (defaults to + ``len(buf)``). :return: Number of bytes written. - :raises FlowError: (or subclass) on negative ouroboros return codes. + :raises FlowError: On a negative ouroboros return code. + :raises FlowNotAllocatedError: If the flow is not allocated. """ + if self._fd < 0: raise FlowNotAllocatedError() @@ -181,30 +222,38 @@ class Flow: count = len(buf) rc = lib.flow_write(self._fd, ffi.from_buffer(buf), count) + return raise_errno(rc) def writeline(self, ln: str) -> int: - """Encode *ln* as UTF-8 and write it to the flow. + """ + Encode *ln* as UTF-8 and write it to the flow. :param ln: String to write. :return: Number of bytes written. + :raises FlowNotAllocatedError: If the flow is not allocated. """ + if self._fd < 0: raise FlowNotAllocatedError() data = ln.encode() + return self.write(data, len(data)) def read(self, count: Optional[int] = None) -> bytes: - """Read up to *count* bytes from the flow. + """ + Read up to *count* bytes from the flow. :param count: Maximum number of bytes to read (default 2048). - :return: Bytes read (may be empty when an SDU was just - fully consumed by a previous matching read). - :raises FlowError: (or subclass) on negative ouroboros return codes. + :return: Bytes read; empty when a previous read fully + consumed the SDU. + :raises FlowError: On a negative ouroboros return code. + :raises FlowNotAllocatedError: If the flow is not allocated. """ + if self._fd < 0: raise FlowNotAllocatedError() @@ -214,181 +263,320 @@ class Flow: _buf = ffi.new("char []", count) rc = lib.flow_read(self._fd, _buf, count) + raise_errno(rc) return ffi.unpack(_buf, rc) def readline(self) -> str: - """Read from the flow and decode the result as UTF-8.""" + """ + Read from the flow and decode the result as UTF-8. + + :return: Decoded UTF-8 string read from the flow. + :raises FlowNotAllocatedError: If the flow is not allocated. + """ + if self._fd < 0: raise FlowNotAllocatedError() return self.read().decode() def set_snd_timeout(self, timeo: float) -> None: - """Set the timeout for blocking writes (seconds).""" + """ + Set the timeout for blocking writes (seconds). + + :param timeo: Write timeout in seconds. + :raises FlowError: On a negative ouroboros return code. + """ + _timeo = fl_to_timespec(ffi, timeo) + raise_errno(lib.flow_set_snd_timeout(self._fd, _timeo)) def get_snd_timeout(self) -> Optional[float]: - """Return the timeout for blocking writes (seconds).""" + """ + Return the timeout for blocking writes (seconds). + + :return: Timeout for blocking writes, in seconds. + :raises FlowError: On a negative ouroboros return code. + """ + _timeo = ffi.new("struct timespec *") + raise_errno(lib.flow_get_snd_timeout(self._fd, _timeo)) + return timespec_to_fl(ffi, _timeo) def set_rcv_timeout(self, timeo: float) -> None: - """Set the timeout for blocking reads (seconds).""" + """ + Set the timeout for blocking reads (seconds). + + :param timeo: Read timeout in seconds. + :raises FlowError: On a negative ouroboros return code. + """ + _timeo = fl_to_timespec(ffi, timeo) + raise_errno(lib.flow_set_rcv_timeout(self._fd, _timeo)) def get_rcv_timeout(self) -> Optional[float]: - """Return the timeout for blocking reads (seconds).""" + """ + Return the timeout for blocking reads (seconds). + + :return: Timeout for blocking reads, in seconds. + :raises FlowError: On a negative ouroboros return code. + """ + _timeo = ffi.new("struct timespec *") + raise_errno(lib.flow_get_rcv_timeout(self._fd, _timeo)) + return timespec_to_fl(ffi, _timeo) def get_qos(self) -> Optional[QoSSpec]: - """Return the current QoS in effect on the flow.""" + """ + Return the current QoS in effect on the flow. + + :return: The QoS currently in effect on the flow. + :raises FlowError: On a negative ouroboros return code. + """ + _qos = ffi.new("qosspec_t *") + raise_errno(lib.flow_get_qos(self._fd, _qos)) + return qosspec_to_qos(ffi, _qos) def get_rx_queue_len(self) -> int: - """Return the receive queue length (bytes).""" + """ + Return the receive queue length (bytes). + + :return: Receive queue length in bytes. + :raises FlowError: On a negative ouroboros return code. + """ + size = ffi.new("size_t *") + raise_errno(lib.flow_get_rx_qlen(self._fd, size)) + return int(size[0]) def get_tx_queue_len(self) -> int: - """Return the transmit queue length (bytes).""" + """ + Return the transmit queue length (bytes). + + :return: Transmit queue length in bytes. + :raises FlowError: On a negative ouroboros return code. + """ + size = ffi.new("size_t *") + raise_errno(lib.flow_get_tx_qlen(self._fd, size)) + return int(size[0]) def get_mtu(self) -> int: - """Return the per-packet MTU. + """ + Return the maximum user payload that fits in one n-1 PDU after + crypto headers (bytes), or 0 if unknown. - This is the maximum user payload that fits in one n-1 PDU, - after crypto headers. Returns 0 if unknown. + :return: Maximum user payload in bytes, or 0 if unknown. + :raises FlowError: On a negative ouroboros return code. """ + mtu = ffi.new("size_t *") + raise_errno(lib.flow_get_mtu(self._fd, mtu)) + return int(mtu[0]) def set_flags(self, flags: FlowProperties) -> None: - """Replace the full set of flags for this flow. - - .. warning:: - This OVERWRITES the entire ``oflags`` value, including - the access-mode bits (``READ_ONLY`` / ``WRITE_ONLY`` / - ``READ_WRITE``). Passing only ``NO_PARTIAL_READ`` (for - example) silently downgrades the flow to read-only and - subsequent writes will fail with :class:`FlowPermissionError`. - - To preserve the existing access mode use - :meth:`add_flags` / :meth:`remove_flags`, or read the - current flags with :meth:`get_flags` and OR in the new - bits explicitly. + """ + Replace the full set of flags for this flow. :param flags: Bitmask of :class:`FlowProperties` values. + :raises FlowError: On a negative ouroboros return code. """ + raise_errno(lib.flow_set_flags(self._fd, int(flags))) def add_flags(self, flags: FlowProperties) -> None: - """OR *flags* into the current ``oflags`` value. + """ + OR *flags* into the current flags, leaving other bits set. - Preserves the access mode and any other already-set bits. + :param flags: :class:`FlowProperties` bits to set. + :raises FlowError: On a negative ouroboros return code. """ + current = self.get_flags() + self.set_flags(current | FlowProperties(int(flags))) def remove_flags(self, flags: FlowProperties) -> None: - """Clear *flags* from the current ``oflags`` value. + """ + Clear *flags* from the current flags, leaving other bits set. - Preserves every other bit (including the access mode). + :param flags: :class:`FlowProperties` bits to clear. + :raises FlowError: On a negative ouroboros return code. """ + current = self.get_flags() + self.set_flags(current & ~FlowProperties(int(flags))) def get_flags(self) -> FlowProperties: - """Return the current flags for this flow.""" + """ + Return the current flags for this flow. + + :return: The current :class:`FlowProperties` bitmask. + :raises FlowError: On a negative ouroboros return code. + """ + flags = raise_errno(lib.flow_get_flags(self._fd)) + return FlowProperties(int(flags)) def set_frct_flags(self, flags: FrctFlags) -> None: - """Set FRCT flags for this flow. + """ + Set FRCT flags for this flow. :param flags: Bitmask of :class:`FrctFlags`. + :raises FlowError: On a negative ouroboros return code. """ + raise_errno(lib.flow_set_frct_flags(self._fd, int(flags))) def get_frct_flags(self) -> FrctFlags: - """Return the FRCT flags for this flow.""" + """ + Return the FRCT flags for this flow. + + :return: The current :class:`FrctFlags` bitmask. + :raises FlowError: On a negative ouroboros return code. + """ + flags = raise_errno(lib.flow_get_frct_flags(self._fd)) + return FrctFlags(int(flags)) def set_frct_max_sdu(self, size: int) -> None: - """Set the maximum reassembly SDU size for FRCT (bytes).""" + """ + Set the maximum reassembly SDU size for FRCT (bytes). + + :param size: Maximum reassembly SDU size in bytes. + :raises FlowError: On a negative ouroboros return code. + """ + raise_errno(lib.flow_set_frct_max_sdu(self._fd, size)) def get_frct_max_sdu(self) -> int: - """Return the maximum reassembly SDU size for FRCT (bytes).""" + """ + Return the maximum reassembly SDU size for FRCT (bytes). + + :return: Maximum reassembly SDU size in bytes. + :raises FlowError: On a negative ouroboros return code. + """ + size = ffi.new("size_t *") + raise_errno(lib.flow_get_frct_max_sdu(self._fd, size)) + return int(size[0]) def set_frct_rcv_ring_size(self, size: int) -> None: - """Set the stream receive ring size (bytes, power of two).""" + """ + Set the stream receive ring size (bytes, power of two). + + :param size: Stream receive ring size in bytes (power of two). + :raises FlowError: On a negative ouroboros return code. + """ + raise_errno(lib.flow_set_frct_rcv_ring_sz(self._fd, size)) def get_frct_rcv_ring_size(self) -> int: - """Return the stream receive ring size (bytes).""" + """ + Return the stream receive ring size (bytes). + + :return: Stream receive ring size in bytes. + :raises FlowError: On a negative ouroboros return code. + """ + size = ffi.new("size_t *") + raise_errno(lib.flow_get_frct_rcv_ring_sz(self._fd, size)) + return int(size[0]) def set_frct_rto_min(self, rto_ns: int) -> None: - """Set the FRCT RTO floor (nanoseconds).""" + """ + Set the FRCT RTO floor (nanoseconds). + + :param rto_ns: FRCT RTO floor in nanoseconds. + :raises FlowError: On a negative ouroboros return code. + """ + raise_errno(lib.flow_set_frct_rto_min(self._fd, rto_ns)) def get_frct_rto_min(self) -> int: - """Return the FRCT RTO floor (nanoseconds).""" + """ + Return the FRCT RTO floor (nanoseconds). + + :return: FRCT RTO floor in nanoseconds. + :raises FlowError: On a negative ouroboros return code. + """ + rto = ffi.new("time_t *") + raise_errno(lib.flow_get_frct_rto_min(self._fd, rto)) + return int(rto[0]) def flow_alloc(dst: str, qos: Optional[QoSSpec] = None, timeo: Optional[float] = None) -> Flow: - """Allocate a new flow and return the :class:`Flow` wrapper. + """ + Allocate a new flow and return the :class:`Flow` wrapper. :param dst: Destination name. :param qos: Requested QoS. :param timeo: Allocation timeout (None blocks forever, 0 is async). + :return: The allocated flow. """ + f = Flow() + f.alloc(dst, qos, timeo) + return f def flow_accept(timeo: Optional[float] = None) -> Flow: - """Accept an incoming flow and return the :class:`Flow` wrapper. + """ + Accept an incoming flow and return the :class:`Flow` wrapper. :param timeo: Accept timeout (None blocks forever, 0 is async). + :return: The accepted flow. """ + f = Flow() + f.accept(timeo) + return f def flow_join(dst: str, timeo: Optional[float] = None) -> Flow: - """Join a broadcast layer and return the :class:`Flow` wrapper. + """ + Join a broadcast layer and return the :class:`Flow` wrapper. :param dst: Broadcast layer name. :param timeo: Join timeout (None blocks forever, 0 is async). + :return: The joined broadcast flow. """ + f = Flow() + f.join(dst, timeo) + return f diff --git a/ouroboros/errors.py b/ouroboros/errors.py index aa1e174..6ab6053 100644 --- a/ouroboros/errors.py +++ b/ouroboros/errors.py @@ -1,30 +1,13 @@ # -# Ouroboros - Copyright (C) 2016 - 2026 -# -# Python API for Ouroboros - Exceptions and errno translation -# -# Dimitri Staessens <dimitri@ouroboros.rocks> -# -# This library is free software; you can redistribute it and/or -# modify it under the terms of the GNU Lesser General Public License -# version 2.1 as published by the Free Software Foundation. -# -# This library is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -# Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public -# License along with this library; if not, write to the Free Software -# Foundation, Inc., http://www.fsf.org/about/contact/. +# SPDX-FileCopyrightText: 2016 - 2026 Dimitri Staessens +# SPDX-License-Identifier: LGPL-2.1-only # -"""Exception hierarchy and errno translation for pyouroboros. +""" +Exception hierarchy and errno translation for pyouroboros. -The hierarchy follows stdlib precedent (``socket``, ``ssl``, ``json``): -a single root :class:`OuroborosError`, semantically-named subclasses, -and multiple inheritance from Python builtins where the semantics -match (e.g. :class:`FlowTimeout` is a :class:`TimeoutError`). +The ``E*`` constants mirror the Ouroboros-specific errno values in +``include/ouroboros/errno.h``. """ from __future__ import annotations @@ -33,8 +16,6 @@ import errno import os from importlib.metadata import PackageNotFoundError, version -# Ouroboros-specific errno values, mirroring include/ouroboros/errno.h. -# Imported here (rather than via CFFI) so this module stays FFI-free ENOTALLOC = 1000 # Flow is not allocated EIPCPTYPE = 1001 # Unknown IPCP type EIRMD = 1002 # Failed to communicate with IRMD @@ -47,128 +28,177 @@ ECRYPT = 1008 # Encryption error EAUTH = 1009 # Authentication error EREPLAY = 1010 # OAP replay detected +_O7S_ERRNO_STR: dict[int, str] = { + ENOTALLOC: "flow is not allocated", + EIPCPTYPE: "unknown IPCP type", + EIRMD: "failed to communicate with IRMd", + EIPCP: "failed to communicate with IPCP", + EIPCPSTATE: "target in wrong state", + EFLOWDOWN: "flow is down", + EFLOWPEER: "flow peer timed out", + ENAME: "naming error", + ECRYPT: "encryption error", + EAUTH: "authentication error", + EREPLAY: "OAP replay detected", +} -# --- Root --- class OuroborosError(Exception): - """Base class for all pyouroboros exceptions.""" - + """ + Base class for all pyouroboros exceptions. + """ -# --- IRM-side --- class IrmError(OuroborosError): - """Base class for IRM (control plane) errors.""" + """ + Base class for IRM (control plane) errors. + """ class IpcpCreateError(IrmError): - """Raised when IPCP creation fails.""" + """ + Raised when IPCP creation fails. + """ class IpcpBootstrapError(IrmError): - """Raised when IPCP bootstrapping fails.""" + """ + Raised when IPCP bootstrapping fails. + """ class IpcpEnrollError(IrmError): - """Raised when IPCP enrollment fails.""" + """ + Raised when IPCP enrollment fails. + """ class IpcpConnectError(IrmError): - """Raised when IPCP connection or disconnection fails.""" + """ + Raised when IPCP connection or disconnection fails. + """ class IpcpTypeError(IrmError, ValueError): - """Raised when an unknown IPCP type is encountered.""" + """ + Raised when an unknown IPCP type is encountered. + """ class IpcpStateError(IrmError): - """Raised when an IPCP/IRMd is in the wrong state for the request.""" + """ + Raised when an IPCP/IRMd is in the wrong state for the request. + """ class IrmdError(IrmError): - """Raised when the IRM daemon is unreachable.""" + """ + Raised when the IRM daemon is unreachable. + """ class IpcpdError(IrmError): - """Raised when the IPCP daemon is unreachable.""" + """ + Raised when the IPCP daemon is unreachable. + """ class BindError(IrmError): - """Raised when binding a program/process/IPCP to a name fails.""" + """ + Raised when binding a program/process/IPCP to a name fails. + """ class NameNotFoundError(IrmError): - """Raised when a name lookup or unregister target does not exist.""" + """ + Raised when a name lookup or unregister target is missing. + """ class NameExistsError(IrmError): - """Raised when a name already exists.""" + """ + Raised when a name already exists. + """ class InvalidNameError(IrmError, ValueError): - """Raised when a name is malformed or otherwise invalid.""" - + """ + Raised when a name is malformed or otherwise invalid. + """ -# --- Flow / dev-side --- class FlowError(OuroborosError): - """Base class for flow-plane errors.""" + """ + Base class for flow-plane errors. + """ class FlowAlreadyAllocatedError(FlowError): - """Raised when allocating on a Flow that already holds an fd.""" + """ + Raised when allocating on a Flow that already holds an fd. + """ class FlowNotAllocatedError(FlowError): - """Raised when operating on a Flow that has no fd.""" + """ + Raised when operating on a Flow that has no fd. + """ class FlowDownError(FlowError, ConnectionError): - """Raised when the flow has gone down.""" + """ + Raised when the flow has gone down. + """ class FlowPeerError(FlowDownError): - """Raised when the flow's peer has timed out.""" + """ + Raised when the flow's peer has timed out. + """ class FlowPermissionError(FlowError, PermissionError): - """Raised when an operation is not permitted on the flow.""" + """ + Raised when an operation is not permitted on the flow. + """ class FlowTimeout(FlowError, TimeoutError): - """Raised when a flow operation times out.""" + """ + Raised when a flow operation times out. + """ class FlowCryptError(FlowError): - """Raised on flow encryption errors.""" + """ + Raised on flow encryption errors. + """ class FlowAuthError(FlowError): - """Raised on flow authentication errors.""" + """ + Raised on flow authentication errors. + """ class FlowReplayError(FlowError): - """Raised when OAP replay is detected.""" + """ + Raised when OAP replay is detected. + """ class FlowEventError(FlowError): - """Raised on errors from the flow event subsystem.""" + """ + Raised on errors from the flow event subsystem. + """ class FlowDeallocWarning(Warning): - """Warning issued when a deallocation reports a non-fatal problem.""" - + """ + Warning issued when a deallocation reports a non-fatal problem. + """ -# --- errno translation --- -# Errno -> Python exception class. Errnos are in their canonical -# positive form here; raise_errno() flips the sign. -# -# Mapped errnos override the caller's `default=` argument, so we only -# map errnos whose semantics are unambiguous across both flow and IRM -# contexts. EPERM and EINVAL are intentionally NOT mapped: they can -# mean very different things on different operations, and a global -# mapping would silently override the call-site's chosen default (e.g. -# masking IpcpBootstrapError as a bare ValueError). _ERRNO_MAP: dict[int, type[BaseException]] = { errno.ETIMEDOUT: TimeoutError, errno.EAGAIN: BlockingIOError, @@ -194,49 +224,57 @@ _ERRNO_MAP: dict[int, type[BaseException]] = { def _strerror(err: int) -> str: if err >= 1000: return _O7S_ERRNO_STR.get(err, f"ouroboros errno {err}") - return os.strerror(err) - -_O7S_ERRNO_STR: dict[int, str] = { - ENOTALLOC: "flow is not allocated", - EIPCPTYPE: "unknown IPCP type", - EIRMD: "failed to communicate with IRMd", - EIPCP: "failed to communicate with IPCP", - EIPCPSTATE: "target in wrong state", - EFLOWDOWN: "flow is down", - EFLOWPEER: "flow peer timed out", - ENAME: "naming error", - ECRYPT: "encryption error", - EAUTH: "authentication error", - EREPLAY: "OAP replay detected", -} + return os.strerror(err) def raise_errno(rc: int, default: type[OuroborosError] = FlowError) -> int: - """Translate a non-negative-or-negative-errno return code. - - Returns *rc* unchanged when it is non-negative. Otherwise raises - the mapped exception (or *default* if no mapping exists). """ + Translate a non-negative-or-negative-errno return code. + + ``_ERRNO_MAP`` holds errnos in their canonical positive form. + + :param rc: The return code to translate; a negative + value is a negated errno. + :param default: Exception class to raise when the errno + has no mapping; a mapped errno overrides + it. + :return: *rc* unchanged when it is non-negative. + :raises OuroborosError: (or the builtin mapped for ``-rc``) when + *rc* is negative. + """ + if rc >= 0: return rc + err = -rc exc_cls = _ERRNO_MAP.get(err, default) raise exc_cls(_strerror(err)) def check_ouroboros_version(o7s_major: int, o7s_minor: int) -> None: - """Verify that the installed pyouroboros matches the linked library. - - Compares *o7s_major*/*o7s_minor* (from - ``OUROBOROS_VERSION_{MAJOR,MINOR}`` in the CFFI module) against - the installed pyouroboros distribution metadata. Silently returns - when running from a source tree without distribution metadata. """ + Verify that the installed pyouroboros matches the linked library. + + Compares the linked library version against the installed + pyouroboros distribution metadata. Silently returns when running + from a source tree without distribution metadata. + + :param o7s_major: Major version of the linked library, from + ``OUROBOROS_VERSION_MAJOR`` in the CFFI + module. + :param o7s_minor: Minor version of the linked library, from + ``OUROBOROS_VERSION_MINOR`` in the CFFI + module. + :raises RuntimeError: If the library and pyouroboros ``major.minor`` + differ. + """ + try: pyo7s_parts = version("PyOuroboros").split(".") except PackageNotFoundError: return + if o7s_major != int(pyo7s_parts[0]) or \ o7s_minor != int(pyo7s_parts[1]): raise RuntimeError( diff --git a/ouroboros/event.py b/ouroboros/event.py index aa03ad4..24b58b8 100644 --- a/ouroboros/event.py +++ b/ouroboros/event.py @@ -1,30 +1,16 @@ # -# Ouroboros - Copyright (C) 2016 - 2026 -# -# Python API for Ouroboros -# -# Dimitri Staessens <dimitri@ouroboros.rocks> -# -# This library is free software; you can redistribute it and/or -# modify it under the terms of the GNU Lesser General Public License -# version 2.1 as published by the Free Software Foundation. -# -# This library is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -# Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public -# License along with this library; if not, write to the Free Software -# Foundation, Inc., http://www.fsf.org/about/contact/. +# SPDX-FileCopyrightText: 2016 - 2026 Dimitri Staessens +# SPDX-License-Identifier: LGPL-2.1-only # -"""Asynchronous flow event monitoring for Ouroboros.""" +""" +Asynchronous flow event monitoring for Ouroboros. +""" from __future__ import annotations from enum import IntFlag -from typing import List, Optional, Tuple +from typing import TYPE_CHECKING, Any, List, Optional, Tuple, Type from _ouroboros_dev_cffi import ffi, lib @@ -32,9 +18,15 @@ from ouroboros._timespec import fl_to_timespec from ouroboros.dev import Flow from ouroboros.errors import FlowEventError, raise_errno +if TYPE_CHECKING: + from types import TracebackType + class FEventType(IntFlag): - """Types of flow events.""" + """ + Types of flow events. + """ + FLOW_PKT = lib.FLOW_PKT FLOW_DOWN = lib.FLOW_DOWN FLOW_UP = lib.FLOW_UP @@ -44,7 +36,9 @@ class FEventType(IntFlag): class FEventQueue: - """A queue of flow events waiting to be drained.""" + """ + A queue of flow events waiting to be drained. + """ def __init__(self) -> None: self._fq = lib.fqueue_create() @@ -54,40 +48,58 @@ class FEventQueue: def __enter__(self) -> FEventQueue: return self - def __exit__(self, exc_type, exc_value, tb) -> None: + def __exit__(self, exc_type: Optional[Type[BaseException]], + exc_value: Optional[BaseException], + tb: Optional[TracebackType]) -> None: self.destroy() def __del__(self) -> None: self.destroy() def destroy(self) -> None: - """Destroy the underlying queue. Idempotent.""" + """ + Destroy the underlying queue. Idempotent. + """ + if self._fq != ffi.NULL: lib.fqueue_destroy(self._fq) + self._fq = ffi.NULL def next(self) -> Tuple[Flow, FEventType]: - """Return the next ``(flow, event_type)`` pair from the queue.""" + """ + Return the next ``(flow, event_type)`` pair from the queue. + + :return: The :class:`~ouroboros.dev.Flow` the event occurred + on, and its :class:`FEventType`. + :raises FlowEventError: On a negative ouroboros return code. + """ + fd = lib.fqueue_next(self._fq) + raise_errno(fd, default=FlowEventError) _type = lib.fqueue_type(self._fq) + raise_errno(_type, default=FlowEventError) return Flow(fd), FEventType(_type) @property - def handle(self): - """Return the underlying ``fqueue_t *``. + def handle(self) -> Any: + """ + Return the underlying ``fqueue_t *``. - Intended for use by :meth:`FlowSet.wait` within the same - package; not part of the user-facing API. + :return: The underlying ``fqueue_t *`` CFFI pointer. """ + return self._fq class FlowSet: - """A set of flows that can be monitored for events.""" + """ + A set of flows that can be monitored for events. + """ def __init__(self, flows: Optional[List[Flow]] = None) -> None: self._set = lib.fset_create() @@ -98,26 +110,40 @@ class FlowSet: for flow in flows: if lib.fset_add(self._set, flow.fileno()) != 0: lib.fset_destroy(self._set) + self._set = ffi.NULL raise MemoryError(f"Failed to add flow {flow.fileno()}.") def __enter__(self) -> FlowSet: return self - def __exit__(self, exc_type, exc_value, tb) -> None: + def __exit__(self, exc_type: Optional[Type[BaseException]], + exc_value: Optional[BaseException], + tb: Optional[TracebackType]) -> None: self.destroy() def __del__(self) -> None: self.destroy() def destroy(self) -> None: - """Destroy the underlying set. Idempotent.""" + """ + Destroy the underlying set. Idempotent. + """ + if self._set != ffi.NULL: lib.fset_destroy(self._set) + self._set = ffi.NULL def add(self, flow: Flow) -> None: - """Add *flow* to this set.""" + """ + Add *flow* to this set. + + :param flow: The flow to add to this set. + :raises ValueError: If the FlowSet has been destroyed. + :raises MemoryError: If the flow could not be added. + """ + if self._set == ffi.NULL: raise ValueError("FlowSet has been destroyed") @@ -125,14 +151,25 @@ class FlowSet: raise MemoryError(f"Failed to add flow {flow.fileno()}") def zero(self) -> None: - """Remove all flows from this set.""" + """ + Remove all flows from this set. + + :raises ValueError: If the FlowSet has been destroyed. + """ + if self._set == ffi.NULL: raise ValueError("FlowSet has been destroyed") lib.fset_zero(self._set) def remove(self, flow: Flow) -> None: - """Remove *flow* from this set.""" + """ + Remove *flow* from this set. + + :param flow: The flow to remove from this set. + :raises ValueError: If the FlowSet has been destroyed. + """ + if self._set == ffi.NULL: raise ValueError("FlowSet has been destroyed") @@ -141,15 +178,20 @@ class FlowSet: def wait(self, fq: FEventQueue, timeo: Optional[float] = None) -> None: - """Wait for at least one event on one of the monitored flows. + """ + Wait for at least one event on one of the monitored flows. :param fq: The :class:`FEventQueue` to drain events into. :param timeo: Wait timeout in seconds (None blocks forever). + :raises ValueError: If the FlowSet has been destroyed. + :raises FlowEventError: On a negative ouroboros return code. """ + if self._set == ffi.NULL: raise ValueError("FlowSet has been destroyed") _timeo = fl_to_timespec(ffi, timeo) rc = lib.fevent(self._set, fq.handle, _timeo) + raise_errno(rc, default=FlowEventError) diff --git a/ouroboros/irm.py b/ouroboros/irm.py index 34a65fb..ea7d665 100644 --- a/ouroboros/irm.py +++ b/ouroboros/irm.py @@ -1,31 +1,17 @@ # -# Ouroboros - Copyright (C) 2016 - 2026 -# -# Python API for Ouroboros - IRM -# -# Dimitri Staessens <dimitri@ouroboros.rocks> -# -# This library is free software; you can redistribute it and/or -# modify it under the terms of the GNU Lesser General Public License -# version 2.1 as published by the Free Software Foundation. -# -# This library is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -# Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public -# License along with this library; if not, write to the Free Software -# Foundation, Inc., http://www.fsf.org/about/contact/. +# SPDX-FileCopyrightText: 2016 - 2026 Dimitri Staessens +# SPDX-License-Identifier: LGPL-2.1-only # -"""IRM (IPC Resource Manager) bindings for Ouroboros.""" +""" +IRM (IPC Resource Manager) bindings for Ouroboros. +""" from __future__ import annotations from dataclasses import dataclass, field from enum import IntEnum -from typing import List, Optional +from typing import Any, List, Optional from _ouroboros_irm_cffi import ffi, lib @@ -47,11 +33,17 @@ from ouroboros.qos import QoSSpec check_ouroboros_version(lib.OUROBOROS_VERSION_MAJOR, lib.OUROBOROS_VERSION_MINOR) +BIND_AUTO = lib.BIND_AUTO + +DT_COMP = "Data Transfer" +MGMT_COMP = "Management" -# --- Enumerations --- class IpcpType(IntEnum): - """IPCP types available in Ouroboros.""" + """ + IPCP types available in Ouroboros. + """ + LOCAL = lib.IPCP_LOCAL UNICAST = lib.IPCP_UNICAST BROADCAST = lib.IPCP_BROADCAST @@ -62,35 +54,53 @@ class IpcpType(IntEnum): class AddressAuthPolicy(IntEnum): - """Address authority policies for unicast IPCPs.""" + """ + Address authority policies for unicast IPCPs. + """ + FLAT_RANDOM = lib.ADDR_AUTH_FLAT_RANDOM class LinkStatePolicy(IntEnum): - """Link state routing policies.""" + """ + Link state routing policies. + """ + SIMPLE = lib.LS_SIMPLE LFA = lib.LS_LFA ECMP = lib.LS_ECMP class RoutingPolicy(IntEnum): - """Routing policies.""" + """ + Routing policies. + """ + LINK_STATE = lib.ROUTING_LINK_STATE class CongestionAvoidPolicy(IntEnum): - """Congestion avoidance policies.""" + """ + Congestion avoidance policies. + """ + NONE = lib.CA_NONE MB_ECN = lib.CA_MB_ECN class DirectoryPolicy(IntEnum): - """Directory policies.""" + """ + Directory policies. + """ + DHT = lib.DIR_DHT class DirectoryHashAlgo(IntEnum): - """Directory hash algorithms.""" + """ + Directory hash algorithms. + """ + SHA3_224 = lib.DIR_HASH_SHA3_224 SHA3_256 = lib.DIR_HASH_SHA3_256 SHA3_384 = lib.DIR_HASH_SHA3_384 @@ -98,23 +108,20 @@ class DirectoryHashAlgo(IntEnum): class LoadBalancePolicy(IntEnum): - """Load balancing policies for names.""" + """ + Load balancing policies for names. + """ + ROUND_ROBIN = lib.LB_RR SPILL = lib.LB_SPILL -BIND_AUTO = lib.BIND_AUTO - -# Unicast IPCP component names -DT_COMP = "Data Transfer" -MGMT_COMP = "Management" - - -# --- Configuration classes --- - @dataclass class LinkStateConfig: - """Configuration for link state routing.""" + """ + Configuration for link state routing. + """ + pol: LinkStatePolicy = LinkStatePolicy.SIMPLE t_recalc: int = 4 t_update: int = 15 @@ -123,14 +130,20 @@ class LinkStateConfig: @dataclass class RoutingConfig: - """Routing configuration.""" + """ + Routing configuration. + """ + pol: RoutingPolicy = RoutingPolicy.LINK_STATE ls: LinkStateConfig = field(default_factory=LinkStateConfig) @dataclass class DtConfig: - """Data transfer configuration for unicast IPCPs.""" + """ + Data transfer configuration for unicast IPCPs. + """ + addr_size: int = 4 eid_size: int = 8 max_ttl: int = 60 @@ -139,7 +152,10 @@ class DtConfig: @dataclass class DhtConfig: - """DHT directory configuration.""" + """ + DHT directory configuration. + """ + alpha: int = 3 k: int = 8 t_expire: int = 86400 @@ -149,14 +165,20 @@ class DhtConfig: @dataclass class DirConfig: - """Directory configuration.""" + """ + Directory configuration. + """ + pol: DirectoryPolicy = DirectoryPolicy.DHT dht: DhtConfig = field(default_factory=DhtConfig) @dataclass class UnicastConfig: - """Configuration for unicast IPCPs.""" + """ + Configuration for unicast IPCPs. + """ + dt: DtConfig = field(default_factory=DtConfig) dir: DirConfig = field(default_factory=DirConfig) addr_auth: AddressAuthPolicy = AddressAuthPolicy.FLAT_RANDOM @@ -165,14 +187,20 @@ class UnicastConfig: @dataclass class EthConfig: - """Configuration for Ethernet IPCPs (LLC or DIX).""" + """ + Configuration for Ethernet IPCPs (LLC or DIX). + """ + dev: str = "" ethertype: int = 0xA000 @dataclass class Udp4Config: - """Configuration for UDP over IPv4 IPCPs.""" + """ + Configuration for UDP over IPv4 IPCPs. + """ + ip_addr: str = "0.0.0.0" dns_addr: str = "0.0.0.0" port: int = 3435 @@ -180,7 +208,10 @@ class Udp4Config: @dataclass class Udp6Config: - """Configuration for UDP over IPv6 IPCPs.""" + """ + Configuration for UDP over IPv6 IPCPs. + """ + ip_addr: str = "::" dns_addr: str = "::" port: int = 3435 @@ -188,18 +219,10 @@ class Udp6Config: @dataclass class IpcpConfig: - """Configuration for bootstrapping an IPCP. - - Depending on the IPCP type, set the appropriate sub-configuration: - - - UNICAST: ``unicast`` (:class:`UnicastConfig`) - - ETH_LLC: ``eth`` (:class:`EthConfig`) - - ETH_DIX: ``eth`` (:class:`EthConfig`) - - UDP4: ``udp4`` (:class:`Udp4Config`) - - UDP6: ``udp6`` (:class:`Udp6Config`) - - LOCAL: no extra config needed - - BROADCAST: no extra config needed """ + Configuration for bootstrapping an IPCP. + """ + ipcp_type: IpcpType layer_name: str = "" dir_hash_algo: DirectoryHashAlgo = DirectoryHashAlgo.SHA3_256 @@ -211,7 +234,10 @@ class IpcpConfig: @dataclass class NameSecPaths: - """Security paths for a name (security config, key, certificate).""" + """ + Security paths for a name. + """ + sec: str = "" key: str = "" crt: str = "" @@ -219,7 +245,10 @@ class NameSecPaths: @dataclass class NameInfo: - """Information about a registered name.""" + """ + Information about a registered name. + """ + name: str pol_lb: LoadBalancePolicy = LoadBalancePolicy.ROUND_ROBIN server_sec: NameSecPaths = field(default_factory=NameSecPaths) @@ -228,23 +257,30 @@ class NameInfo: @dataclass class IpcpInfo: - """Information about a running IPCP (from list_ipcps).""" + """ + Information about a running IPCP. + """ + pid: int type: IpcpType name: str layer: str -# --- Internal conversion functions --- +def _ipcp_config_to_c(conf: IpcpConfig) -> Any: + """ + Convert an :class:`IpcpConfig` to a C ``struct ipcp_config *``. + + The layer name is truncated to the 255 bytes the C struct holds. + """ -def _ipcp_config_to_c(conf: IpcpConfig): - """Convert an :class:`IpcpConfig` to a C ``struct ipcp_config *``.""" _conf = ffi.new("struct ipcp_config *") - # Layer info layer_name = conf.layer_name.encode() + ffi.memmove(_conf.layer_info.name, layer_name, min(len(layer_name), 255)) + _conf.layer_info.dir_hash_algo = conf.dir_hash_algo _conf.type = conf.ipcp_type @@ -271,7 +307,9 @@ def _ipcp_config_to_c(conf: IpcpConfig): elif conf.ipcp_type in (IpcpType.ETH_LLC, IpcpType.ETH_DIX): ec = conf.eth or EthConfig() dev = ec.dev.encode() + ffi.memmove(_conf.eth.dev, dev, min(len(dev), 255)) + _conf.eth.ethertype = ec.ethertype elif conf.ipcp_type == IpcpType.UDP4: @@ -279,6 +317,7 @@ def _ipcp_config_to_c(conf: IpcpConfig): _conf.udp4.port = uc4.port if lib.ipcp_config_udp4_set_ip(_conf, uc4.ip_addr.encode()) != 0: raise ValueError(f"Invalid IPv4 address: {uc4.ip_addr}") + if lib.ipcp_config_udp4_set_dns(_conf, uc4.dns_addr.encode()) != 0: raise ValueError(f"Invalid IPv4 DNS address: {uc4.dns_addr}") @@ -287,45 +326,52 @@ def _ipcp_config_to_c(conf: IpcpConfig): _conf.udp6.port = uc6.port if lib.ipcp_config_udp6_set_ip(_conf, uc6.ip_addr.encode()) != 0: raise ValueError(f"Invalid IPv6 address: {uc6.ip_addr}") + if lib.ipcp_config_udp6_set_dns(_conf, uc6.dns_addr.encode()) != 0: raise ValueError(f"Invalid IPv6 DNS address: {uc6.dns_addr}") return _conf -def _name_info_to_c(info: NameInfo): - """Convert a :class:`NameInfo` to a C ``struct name_info *``.""" +def _name_info_to_c(info: NameInfo) -> Any: + """ + Convert a :class:`NameInfo` to a C ``struct name_info *``. + """ + _info = ffi.new("struct name_info *") name = info.name.encode() + ffi.memmove(_info.name, name, min(len(name), 255)) + _info.pol_lb = info.pol_lb for attr, sec in (('s', info.server_sec), ('c', info.client_sec)): sec_paths = getattr(_info, attr) + for fld in ('sec', 'key', 'crt'): val = getattr(sec, fld).encode() + ffi.memmove(getattr(sec_paths, fld), val, min(len(val), 511)) return _info -# --- IRM API functions --- - def create_ipcp(name: str, ipcp_type: IpcpType) -> int: - """Create a new IPCP. + """ + Create a new IPCP. :param name: Name for the IPCP. :param ipcp_type: Type of IPCP to create. :return: PID of the created IPCP. + :raises IpcpCreateError: If creation fails or the IPCP is not found. """ + raise_errno(lib.irm_create_ipcp(name.encode(), ipcp_type), default=IpcpCreateError) - # The C function returns 0 on success, not the pid. - # Look up the actual pid by name. for info in list_ipcps(): if info.name == name: return info.pid @@ -334,24 +380,32 @@ def create_ipcp(name: str, def destroy_ipcp(pid: int) -> None: - """Destroy an IPCP. + """ + Destroy an IPCP. :param pid: PID of the IPCP to destroy. + :raises IrmError: If the IPCP could not be destroyed. """ + raise_errno(lib.irm_destroy_ipcp(pid), default=IrmError) def list_ipcps() -> List[IpcpInfo]: - """List all running IPCPs. + """ + List all running IPCPs. :return: List of :class:`IpcpInfo` objects. + :raises IrmError: If the IPCPs could not be listed. """ + _ipcps = ffi.new("struct ipcp_list_info **") n = raise_errno(lib.irm_list_ipcps(_ipcps), default=IrmError) result = [] + for i in range(n): info = _ipcps[0][i] + result.append(IpcpInfo( pid=info.pid, type=IpcpType(info.type), @@ -366,22 +420,29 @@ def list_ipcps() -> List[IpcpInfo]: def enroll_ipcp(pid: int, dst: str) -> None: - """Enroll an IPCP in a layer. + """ + Enroll an IPCP in a layer. :param pid: PID of the IPCP to enroll. - :param dst: Name to use for enrollment. + :param dst: Name of a member of the layer to enroll with. + :raises IpcpEnrollError: If the IPCP could not enroll with *dst*. """ + raise_errno(lib.irm_enroll_ipcp(pid, dst.encode()), default=IpcpEnrollError) def bootstrap_ipcp(pid: int, conf: IpcpConfig) -> None: - """Bootstrap an IPCP. + """ + Bootstrap an IPCP. :param pid: PID of the IPCP to bootstrap. :param conf: Configuration for the IPCP. + :raises IpcpBootstrapError: If the IPCP could not be bootstrapped. """ + _conf = _ipcp_config_to_c(conf) + raise_errno(lib.irm_bootstrap_ipcp(pid, _conf), default=IpcpBootstrapError) @@ -390,17 +451,21 @@ def connect_ipcp(pid: int, component: str, dst: str, qos: Optional[QoSSpec] = None) -> None: - """Connect an IPCP component to a destination. + """ + Connect an IPCP component to a destination. :param pid: PID of the IPCP. - :param component: Component to connect (:data:`DT_COMP` or - :data:`MGMT_COMP`). + :param component: :data:`DT_COMP` or :data:`MGMT_COMP`. :param dst: Destination name. :param qos: QoS specification for the connection. + :raises IpcpConnectError: If *component* could not be + connected to *dst*. """ + _qos = qos_to_qosspec(ffi, qos) if _qos == ffi.NULL: _qos = ffi.new("qosspec_t *") + raise_errno( lib.irm_connect_ipcp(pid, dst.encode(), component.encode(), _qos[0]), @@ -411,12 +476,16 @@ def connect_ipcp(pid: int, def disconnect_ipcp(pid: int, component: str, dst: str) -> None: - """Disconnect an IPCP component from a destination. + """ + Disconnect an IPCP component from a destination. :param pid: PID of the IPCP. - :param component: Component to disconnect. + :param component: :data:`DT_COMP` or :data:`MGMT_COMP`. :param dst: Destination name. + :raises IpcpConnectError: If *component* could not be + disconnected from *dst*. """ + raise_errno( lib.irm_disconnect_ipcp(pid, dst.encode(), component.encode()), default=IpcpConnectError, @@ -427,17 +496,24 @@ def bind_program(prog: str, name: str, opts: int = 0, argv: Optional[List[str]] = None) -> None: - """Bind a program to a name. + """ + Bind a program to a name. :param prog: Path to the program. :param name: Name to bind to. :param opts: Bind options (e.g. :data:`BIND_AUTO`). - :param argv: Arguments to pass when the program is started. + :param argv: Arguments passed when the IRM starts the program. + :raises BindError: If *prog* could not be bound to *name*. """ + if argv: argc = len(argv) - _argv = ffi.new("char *[]", [ffi.new("char[]", a.encode()) - for a in argv]) + _args = [] + + for a in argv: + _args.append(ffi.new("char[]", a.encode())) + + _argv = ffi.new("char *[]", _args) else: argc = 0 _argv = ffi.NULL @@ -450,64 +526,85 @@ def bind_program(prog: str, def unbind_program(prog: str, name: str) -> None: - """Unbind a program from a name. + """ + Unbind a program from a name. :param prog: Path to the program. :param name: Name to unbind from. + :raises BindError: If *prog* could not be unbound from *name*. """ + raise_errno(lib.irm_unbind_program(prog.encode(), name.encode()), default=BindError) def bind_process(pid: int, name: str) -> None: - """Bind a running process to a name. + """ + Bind a running process to a name. :param pid: PID of the process. :param name: Name to bind to. + :raises BindError: If the process could not be bound to *name*. """ + raise_errno(lib.irm_bind_process(pid, name.encode()), default=BindError) def unbind_process(pid: int, name: str) -> None: - """Unbind a process from a name. + """ + Unbind a process from a name. :param pid: PID of the process. :param name: Name to unbind from. + :raises BindError: If the process could not be unbound from *name*. """ + raise_errno(lib.irm_unbind_process(pid, name.encode()), default=BindError) def create_name(info: NameInfo) -> None: - """Create a name in the IRM. + """ + Create a name in the IRM. :param info: :class:`NameInfo` describing the name to create. + :raises NameExistsError: If the name already exists. """ + _info = _name_info_to_c(info) + raise_errno(lib.irm_create_name(_info), default=NameExistsError) def destroy_name(name: str) -> None: - """Destroy a name in the IRM. + """ + Destroy a name in the IRM. :param name: The name to destroy. + :raises NameNotFoundError: If the name is unknown. """ + raise_errno(lib.irm_destroy_name(name.encode()), default=NameNotFoundError) def list_names() -> List[NameInfo]: - """List all registered names. + """ + List all registered names. :return: List of :class:`NameInfo` objects. + :raises IrmError: If the names could not be listed. """ + _names = ffi.new("struct name_info **") n = raise_errno(lib.irm_list_names(_names), default=IrmError) result = [] + for i in range(n): info = _names[0][i] + result.append(NameInfo( name=ffi.string(info.name).decode(), pol_lb=LoadBalancePolicy(info.pol_lb), @@ -530,20 +627,27 @@ def list_names() -> List[NameInfo]: def reg_name(name: str, pid: int) -> None: - """Register an IPCP to a name. + """ + Register an IPCP to a name. :param name: The name to register. :param pid: PID of the IPCP to register. + :raises NameNotFoundError: If the name is unknown. """ + raise_errno(lib.irm_reg_name(name.encode(), pid), default=NameNotFoundError) def unreg_name(name: str, pid: int) -> None: - """Unregister an IPCP from a name. + """ + Unregister an IPCP from a name. :param name: The name to unregister. :param pid: PID of the IPCP to unregister. + :raises NameNotFoundError: If the name or the registration + is unknown. """ + raise_errno(lib.irm_unreg_name(name.encode(), pid), default=NameNotFoundError) diff --git a/ouroboros/qos.py b/ouroboros/qos.py index f1b6d11..c42a62e 100644 --- a/ouroboros/qos.py +++ b/ouroboros/qos.py @@ -1,25 +1,11 @@ # -# Ouroboros - Copyright (C) 2016 - 2026 -# -# Python API for Ouroboros - QoS -# -# Dimitri Staessens <dimitri@ouroboros.rocks> -# -# This library is free software; you can redistribute it and/or -# modify it under the terms of the GNU Lesser General Public License -# version 2.1 as published by the Free Software Foundation. -# -# This library is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -# Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public -# License along with this library; if not, write to the Free Software -# Foundation, Inc., http://www.fsf.org/about/contact/. +# SPDX-FileCopyrightText: 2016 - 2026 Dimitri Staessens +# SPDX-License-Identifier: LGPL-2.1-only # -"""QoS specification for Ouroboros flows.""" +""" +QoS specification for Ouroboros flows. +""" from __future__ import annotations @@ -32,7 +18,10 @@ UINT64_MAX = 0xFFFFFFFFFFFFFFFF class QoSService(IntEnum): - """Mirrors ``enum qos_service`` in ``ouroboros/qos.h``.""" + """ + Mirrors ``enum qos_service`` in ``ouroboros/qos.h``. + """ + RAW = 0 # No FRCT; best-effort raw messages MESSAGE = 1 # FRCT, reliable ordered messages STREAM = 2 # FRCT, reliable ordered byte stream @@ -40,11 +29,8 @@ class QoSService(IntEnum): @dataclass(frozen=True) class QoSSpec: - """QoS specification for a flow. - - Frozen so the module-level ``QOS_*`` predefined cubes are safe to - share. Construct a new spec with ``dataclasses.replace(qos, ...)`` - or ``QoSSpec(...)`` to override fields. + """ + QoS specification for a flow. :ivar service: :class:`QoSService` (gates FRCT when > 0). :ivar delay: Maximum one-way delay in ms. @@ -55,6 +41,7 @@ class QoSSpec: :ivar max_gap: Maximum interruption in ms. :ivar timeout: Peer timeout in ms (default 120000 ms). """ + service: QoSService = QoSService.RAW delay: int = UINT32_MAX bandwidth: int = 0 diff --git a/pyproject.toml b/pyproject.toml index 9a9f8b6..d6f94c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,6 @@ +# SPDX-FileCopyrightText: 2016 - 2026 Dimitri Staessens +# SPDX-License-Identifier: LGPL-2.1-only + [build-system] requires = ["setuptools>=64", "setuptools-scm>=8", "cffi>=1.0.0"] build-backend = "setuptools.build_meta" @@ -8,9 +11,7 @@ dynamic = ["version"] description = "Python API for Ouroboros" license = "LGPL-2.1-only" requires-python = ">=3.8" -authors = [ - { name = "Dimitri Staessens", email = "dimitri@ouroboros.rocks" } -] +authors = [{ name = "Dimitri Staessens", email = "dimitri@ouroboros.rocks" }] keywords = ["ouroboros", "IPC"] dependencies = ["cffi>=1.0.0"] @@ -23,8 +24,11 @@ packages = ["ouroboros"] [tool.setuptools_scm] [tool.pylint.main] -extension-pkg-allow-list = ["_ouroboros_dev_cffi", "_ouroboros_irm_cffi"] -ignored-modules = ["setuptools_scm", "_ouroboros_dev_cffi", "_ouroboros_irm_cffi"] +ignored-modules = [ + "setuptools_scm", + "_ouroboros_dev_cffi", + "_ouroboros_irm_cffi", +] [tool.pylint.format] max-line-length = 100 @@ -34,4 +38,3 @@ max-args = 10 max-positional-arguments = 10 max-attributes = 10 max-public-methods = 30 - @@ -1,9 +1,15 @@ -"""Build script for the PyOuroboros CFFI extensions. +# +# SPDX-FileCopyrightText: 2016 - 2026 Dimitri Staessens +# SPDX-License-Identifier: LGPL-2.1-only +# + +""" +Build script for the PyOuroboros CFFI extensions. Resolves the ouroboros library version via ``pkg-config`` and the pyouroboros source version via ``setuptools_scm``, verifies that the -``major.minor`` halves match, and delegates the actual build to the -CFFI builders under ``ffi/``. +``major.minor`` halves match, and delegates the actual build to the CFFI +builders under ``ffi/``. """ from __future__ import annotations @@ -28,19 +34,21 @@ def _get_ouroboros_version() -> str: def _check_build_version_compat() -> None: + """ + Exit when the ouroboros and pyouroboros ``major.minor`` disagree. + + Returns without checking when the tree carries no SCM information. + """ + try: pyo7s_ver = get_version(root='.', relative_to=__file__) except (LookupError, OSError): - return # no SCM info, skip check + return o7s_ver = _get_ouroboros_version() - # setuptools_scm: '0.23.1.dev3+g<hash>' or '0.23.0' - # pkg-config: '0.23.0' - # Compare major.minor only. o7s_parts = o7s_ver.split('.') pyo7s_parts = pyo7s_ver.split('.') - if o7s_parts[0] != pyo7s_parts[0] or o7s_parts[1] != pyo7s_parts[1]: sys.exit( f"ERROR: Version mismatch: ouroboros {o7s_ver} " |
