Skip to content

Commit

Permalink
v1.4 (#197)
Browse files Browse the repository at this point in the history
* Refactor the redundant output session to return optimistically, as soon as the first inferior is done transmitting. The test suite currently generates warnings due to some of the async tasks not being finalized properly.

* Fix #147

* Fix doc styling; same problem as in OpenCyphal/pydsdl#74

* Add diagnostics for GNU/Linux test on AppVeyor

* Split transmission in SocketCAN loopback test
  • Loading branch information
pavel-kirienko committed Dec 21, 2021
1 parent 99ef434 commit 28cdd22
Show file tree
Hide file tree
Showing 18 changed files with 264 additions and 120 deletions.
2 changes: 2 additions & 0 deletions .appveyor.yml
Expand Up @@ -81,6 +81,8 @@ for:
test_script:
- 'nox --non-interactive --error-on-missing-interpreters --session test pristine --python $PYTHON'
- 'nox --non-interactive --session demo check_style docs'
on_finish:
- 'ip link show' # Diagnostics aid

- # DEPLOYMENT
matrix:
Expand Down
2 changes: 2 additions & 0 deletions .idea/dictionaries/pavel.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 12 additions & 0 deletions CHANGELOG.rst
Expand Up @@ -3,6 +3,18 @@
Changelog
=========

v1.4
----

- Behavior of the redundant output session changed:
:meth:`pyuavcan.transport.redundant.RedundantOutputSession.send` returns as soon as at least one inferior is done
transmitting, the slower ones keep transmitting in the background.
In other words, the redundant transport now operates at the rate of the fastest inferior (used to be the slowest one).

- Implement the DSDL UX improvement described in `#147 <https://github.com/UAVCAN/pyuavcan/issues/147>`_.

- Fully adopt PEP 585 in generated code.

v1.3
----

Expand Down
4 changes: 4 additions & 0 deletions docs/static/custom.css
Expand Up @@ -36,6 +36,10 @@ h2 {
font-weight: bold;
}

.rst-content dl {
display: block !important;
}

.rst-content a {
color: #1700b3;
}
Expand Down
2 changes: 1 addition & 1 deletion noxfile.py
Expand Up @@ -124,7 +124,7 @@ def test(session):
# 2. At least MyPy has to be run separately per Python version we support.
# If the interpreter is not CPython, this may need to be conditionally disabled.
session.install(
"mypy == 0.910",
"mypy == 0.920",
"pylint == 2.12.*",
)
session.run("mypy", "--strict", *map(str, src_dirs), str(compiled_dir))
Expand Down
2 changes: 1 addition & 1 deletion pyuavcan/VERSION
@@ -1 +1 @@
1.3.1
1.4.0
75 changes: 62 additions & 13 deletions pyuavcan/dsdl/_builtin_form.py
Expand Up @@ -3,11 +3,10 @@
# Author: Pavel Kirienko <pavel@uavcan.org>

import typing

import logging
import numpy
from numpy.typing import NDArray
import pydsdl

from ._composite_object import CompositeObject, get_model, get_attribute, set_attribute, get_class
from ._composite_object import CompositeObjectTypeVar

Expand Down Expand Up @@ -93,6 +92,8 @@ def update_from_builtin(destination: CompositeObjectTypeVar, source: typing.Any)
Source field names shall match the original unstropped names provided in the DSDL definition;
e.g., `if`, not `if_`. If there is more than one variant specified for a union type, the last
specified variant takes precedence.
If the structure of the source does not match the destination object, the correct representation
may be deduced automatically as long as it can be done unambiguously.
:param destination: The object to update. The update will be done in-place. If you don't want the source
object modified, clone it beforehand.
Expand Down Expand Up @@ -124,23 +125,68 @@ def update_from_builtin(destination: CompositeObjectTypeVar, source: typing.Any)
... }
... }) # doctest: +NORMALIZE_WHITESPACE
uavcan.register.Access.Request...name='my.register'...value=[ 1, 2, 42,-10000]...
"""
# UX improvement; see https://github.com/UAVCAN/pyuavcan/issues/116
if not isinstance(source, dict):
raise TypeError(
f"Invalid format: cannot update an instance of type {type(destination).__name__!r} "
f"from value of type {type(source).__name__!r}"
)

source = dict(source) # Create copy to prevent mutation of the original
The following examples showcase positional initialization:
>>> from uavcan.node import Heartbeat_1
>>> update_from_builtin(Heartbeat_1(), [123456, 1, 2]) # doctest: +NORMALIZE_WHITESPACE
uavcan.node.Heartbeat.1.0(uptime=123456,
health=uavcan.node.Health.1.0(value=1),
mode=uavcan.node.Mode.1.0(value=2),
vendor_specific_status_code=0)
>>> update_from_builtin(Heartbeat_1(), 123456) # doctest: +NORMALIZE_WHITESPACE
uavcan.node.Heartbeat.1.0(uptime=123456,
health=uavcan.node.Health.1.0(value=0),
mode=uavcan.node.Mode.1.0(value=0),
vendor_specific_status_code=0)
>>> update_from_builtin(Heartbeat_1(), [0, 0, 0, 0, 0]) # doctest: +IGNORE_EXCEPTION_DETAIL
Traceback (most recent call last):
...
TypeError: ...
>>> update_from_builtin(uavcan.primitive.array.Real64_1(), 123.456) # doctest: +NORMALIZE_WHITESPACE
uavcan.primitive.array.Real64.1.0(value=[123.456])
>>> update_from_builtin(uavcan.primitive.array.Real64_1(), [123.456]) # doctest: +NORMALIZE_WHITESPACE
uavcan.primitive.array.Real64.1.0(value=[123.456])
>>> update_from_builtin(uavcan.primitive.array.Real64_1(), [123.456, -9]) # doctest: +NORMALIZE_WHITESPACE
uavcan.primitive.array.Real64.1.0(value=[123.456, -9. ])
>>> update_from_builtin(uavcan.register.Access_1_0.Request(), ["X", {"integer8": 99}]) # Same as the next one!
uavcan.register.Access.Request...name='X'...value=[99]...
>>> update_from_builtin(uavcan.register.Access_1_0.Request(), {'name': 'X', 'value': {'integer8': {'value': [99]}}})
uavcan.register.Access.Request...name='X'...value=[99]...
"""
_logger.debug("update_from_builtin: destination/source on the next lines:\n%r\n%r", destination, source)
if not isinstance(destination, CompositeObject): # pragma: no cover
raise TypeError(f"Bad destination: expected a CompositeObject, got {type(destination).__name__}")

model = get_model(destination)
_raise_if_service_type(model)
fields = model.fields_except_padding

# UX improvement: https://github.com/UAVCAN/pyuavcan/issues/147 -- match the source against the data type.
if not isinstance(source, dict):
if not isinstance(source, (list, tuple)): # Assume positional initialization.
source = (source,)
can_propagate = fields and isinstance(fields[0].data_type, (pydsdl.ArrayType, pydsdl.CompositeType))
too_many_values = len(source) > (1 if isinstance(model.inner_type, pydsdl.UnionType) else len(fields))
if can_propagate and too_many_values:
_logger.debug(
"update_from_builtin: %d source values cannot be applied to %s but the first field accepts "
"positional initialization -- propagating down",
len(source),
type(destination).__name__,
)
source = [source]
if len(source) > len(fields):
raise TypeError(
f"Cannot apply {len(source)} values to {len(fields)} fields in {type(destination).__name__}"
)
source = {f.name: v for f, v in zip(fields, source)}
return update_from_builtin(destination, source)

for f in model.fields_except_padding:
source = dict(source) # Create copy to prevent mutation of the original

for f in fields:
field_type = f.data_type
try:
value = source.pop(f.name)
Expand Down Expand Up @@ -182,3 +228,6 @@ def _raise_if_service_type(model: pydsdl.SerializableType) -> None:
f"Built-in form is not defined for service types. "
f"Did you mean to use Request or Response? Input type: {model}"
)


_logger = logging.getLogger(__name__)
14 changes: 3 additions & 11 deletions pyuavcan/dsdl/_templates/base.j2
Expand Up @@ -20,7 +20,6 @@
from __future__ import annotations
import numpy as _np_
from numpy.typing import NDArray as _NDArray_
from typing import Optional as _Opt_
import pydsdl as _pydsdl_
import pyuavcan.dsdl as _dsdl_
{%- if T.deprecated %}
Expand Down Expand Up @@ -175,7 +174,7 @@ class {{ name }}(_dsdl_.{%- if type.has_fixed_port_id -%}FixedPort{%- endif -%}C
{%- for f in type.fields_except_padding -%}
,
{{ f|id }}: {{ ''.ljust(type.fields|longest_id_length - f|id|length) -}}
_Opt_[{{ relaxed_type_annotation(f.data_type) }}] = None
None | {{ relaxed_type_annotation(f.data_type) }} = None
{%- endfor -%}
) -> None:
"""
Expand Down Expand Up @@ -250,7 +249,7 @@ class {{ name }}(_dsdl_.{%- if type.has_fixed_port_id -%}FixedPort{%- endif -%}C
{%- else %} {#- IS UNION (guaranteed to contain at least 2 fields none of which are padding) #}
{%- for f in type.fields %}
self._{{ f|id }}: {{ ''.ljust(type.fields|longest_id_length - f|id|length) -}}
_Opt_[{{ strict_type_annotation(f.data_type) }}] = None
None | {{ strict_type_annotation(f.data_type) }} = None
{%- endfor %}
_init_cnt_: int = 0
{% for f in type.fields %}
Expand Down Expand Up @@ -298,14 +297,7 @@ class {{ name }}(_dsdl_.{%- if type.has_fixed_port_id -%}FixedPort{%- endif -%}C
-#}
{%- for f in type.fields_except_padding %}
@property
def {{ f|id }}(self) -> {# #}
{%- if type.inner_type is UnionType -%}
_Opt_[
{%- endif -%}
{{ strict_type_annotation(f.data_type) }}
{%- if type.inner_type is UnionType -%}
]
{%- endif -%}:
def {{ f|id }}(self) -> {{ "None | " * (type.inner_type is UnionType) }}{{ strict_type_annotation(f.data_type) }}:
"""
{{ f }}
{%- if f.data_type is VariableLengthArrayType and f.data_type.string_like %}
Expand Down
2 changes: 1 addition & 1 deletion pyuavcan/transport/can/media/_frame.py
Expand Up @@ -75,7 +75,7 @@ class Envelope:


_DLC_TO_LENGTH = [0, 1, 2, 3, 4, 5, 6, 7, 8, 12, 16, 20, 24, 32, 48, 64]
_LENGTH_TO_DLC: typing.Dict[int, int] = dict(zip(*list(zip(*enumerate(_DLC_TO_LENGTH)))[::-1])) # type: ignore
_LENGTH_TO_DLC: typing.Dict[int, int] = dict(zip(*list(zip(*enumerate(_DLC_TO_LENGTH)))[::-1]))
assert len(_LENGTH_TO_DLC) == 16 == len(_DLC_TO_LENGTH)
for item in _DLC_TO_LENGTH:
assert _DLC_TO_LENGTH[_LENGTH_TO_DLC[item]] == item, "Invalid DLC tables"
Expand Down
4 changes: 2 additions & 2 deletions pyuavcan/transport/can/media/socketcan/_socketcan.py
Expand Up @@ -195,7 +195,7 @@ def handler_wrapper(frs: typing.Sequence[typing.Tuple[Timestamp, Envelope]]) ->

def _read_frame(self, ts_mono_ns: int) -> typing.Tuple[Timestamp, Envelope]:
while True:
data, ancdata, msg_flags, _addr = self._sock.recvmsg(
data, ancdata, msg_flags, _addr = self._sock.recvmsg( # type: ignore
self._native_frame_size, self._ancillary_data_buffer_size
)
assert msg_flags & socket.MSG_TRUNC == 0, "The data buffer is not large enough"
Expand Down Expand Up @@ -295,7 +295,7 @@ class _NativeFrameDataCapacity(enum.IntEnum):
_CAN_EFF_MASK = 0x1FFFFFFF


def _make_socket(iface_name: str, can_fd: bool) -> socket.SocketType:
def _make_socket(iface_name: str, can_fd: bool) -> socket.socket:
s = socket.socket(socket.PF_CAN, socket.SOCK_RAW, socket.CAN_RAW) # type: ignore
try:
s.bind((iface_name,))
Expand Down
5 changes: 5 additions & 0 deletions pyuavcan/transport/redundant/__init__.py
Expand Up @@ -365,6 +365,11 @@
>>> tr.protocol_parameters
ProtocolParameters(transfer_id_modulo=0, max_nodes=0, mtu=0)
.. doctest::
:hide:
>>> doctest_await(asyncio.sleep(1.0)) # Let pending tasks terminate before the loop is closed.
A redundant transport can be used with just one inferior to implement ad-hoc PnP allocation as follows:
the transport is set up with an anonymous inferior which is disposed of upon completing the allocation procedure;
the new inferior is then installed in the place of the old one configured to use the newly allocated node-ID value.
Expand Down
2 changes: 1 addition & 1 deletion pyuavcan/transport/redundant/_redundant_transport.py
Expand Up @@ -330,7 +330,7 @@ def retire() -> None:
def _construct_inferior_session(transport: pyuavcan.transport.Transport, owner: RedundantSession) -> None:
assert isinstance(transport, pyuavcan.transport.Transport)
if isinstance(owner, pyuavcan.transport.InputSession):
inferior = transport.get_input_session(owner.specifier, owner.payload_metadata)
inferior: pyuavcan.transport.Session = transport.get_input_session(owner.specifier, owner.payload_metadata)
elif isinstance(owner, pyuavcan.transport.OutputSession):
inferior = transport.get_output_session(owner.specifier, owner.payload_metadata)
else:
Expand Down

0 comments on commit 28cdd22

Please sign in to comment.