Skip to content

Commit cd2ae46

Browse files
committed
Merge branch 'mypy' into typing/test
2 parents 09087b2 + 3788cab commit cd2ae46

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

46 files changed

+849
-273
lines changed

.github/workflows/pr-linters.yaml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
name: Run PR linters
2+
3+
on:
4+
pull_request:
5+
workflow_dispatch:
6+
7+
permissions:
8+
contents: read
9+
pull-requests: read
10+
11+
jobs:
12+
13+
mypy:
14+
name: Run mypy static type checker (optional)
15+
runs-on: ubuntu-latest
16+
continue-on-error: true
17+
steps:
18+
- uses: actions/checkout@v4
19+
- uses: actions/setup-python@v5
20+
with:
21+
python-version: 3.12
22+
cache: pip
23+
cache-dependency-path: |
24+
'pyproject.toml'
25+
'requirements-dev.txt'
26+
- run: pip install -r requirements-dev.txt -e .
27+
- name: Run mypy and report
28+
run: mypy --config-file pyproject.toml .

.github/workflows/pythonpackage.yml

Lines changed: 21 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,19 @@ name: Python package
55

66
on:
77
push:
8-
branches: [ "master", "mypy" ]
8+
branches:
9+
- 'master'
10+
- 'mypy'
11+
paths-ignore:
12+
- 'README.rst'
13+
- 'LICENSE.txt'
914
pull_request:
10-
branches: [ "master", "mypy" ]
15+
branches:
16+
- 'master'
17+
- 'mypy'
18+
paths-ignore:
19+
- 'README.rst'
20+
- 'LICENSE.txt'
1121

1222
jobs:
1323
build:
@@ -20,21 +30,21 @@ jobs:
2030
features: ['', '[db_export]']
2131

2232
steps:
23-
- uses: actions/checkout@v3
33+
- uses: actions/checkout@v4
2434
- name: Set up Python ${{ matrix.python-version }}
25-
uses: actions/setup-python@v3
35+
uses: actions/setup-python@v5
2636
with:
2737
python-version: ${{ matrix.python-version }}
38+
cache: 'pip'
39+
cache-dependency-path: |
40+
'pyproject.toml'
41+
'requirements-dev.txt'
2842
- name: Install dependencies
29-
run: |
30-
python -m pip install --upgrade pip
31-
pip install pytest pytest-cov
32-
pip install -e '.${{ matrix.features }}'
43+
run: python3 -m pip install -e '.${{ matrix.features }}' -r requirements-dev.txt
3344
- name: Test with pytest
34-
run: |
35-
pytest -v --cov=canopen --cov-report=xml --cov-branch
45+
run: pytest -v --cov=canopen --cov-report=xml --cov-branch
3646
- name: Upload coverage reports to Codecov
37-
uses: codecov/codecov-action@v4
47+
uses: codecov/codecov-action@v5
3848
with:
3949
token: ${{ secrets.CODECOV_TOKEN }}
4050

@@ -53,18 +63,3 @@ jobs:
5363
run: python3 -m pip install -r doc/requirements.txt -e .
5464
- name: Build docs
5565
run: make -C doc html
56-
57-
mypy:
58-
continue-on-error: true
59-
runs-on: ubuntu-latest
60-
steps:
61-
- uses: actions/checkout@v4
62-
- uses: actions/setup-python@v5
63-
with:
64-
python-version: 3.12
65-
cache: pip
66-
cache-dependency-path: |
67-
'pyproject.toml'
68-
'requirements-dev.txt'
69-
- run: pip install -r requirements-dev.txt -e .
70-
- run: mypy --config-file pyproject.toml .

README.rst

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,18 +42,18 @@ Install from PyPI_ using :program:`pip`::
4242

4343
Install from latest ``master`` on GitHub::
4444

45-
$ pip install https://github.com/christiansandberg/canopen/archive/master.zip
45+
$ pip install https://github.com/canopen-python/canopen/archive/master.zip
4646

4747
If you want to be able to change the code while using it, clone it then install
4848
it in `develop mode`_::
4949

50-
$ git clone https://github.com/christiansandberg/canopen.git
50+
$ git clone https://github.com/canopen-python/canopen.git
5151
$ cd canopen
5252
$ pip install -e .
5353

5454
Unit tests can be run using the pytest_ framework::
5555

56-
$ pip install pytest
56+
$ pip install -r requirements-dev.txt
5757
$ pytest -v
5858

5959
You can also use :mod:`unittest` standard library module::

canopen/__init__.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
from canopen.network import Network, NodeScanner
2-
from canopen.node import RemoteNode, LocalNode
3-
from canopen.sdo import SdoCommunicationError, SdoAbortedError
4-
from canopen.objectdictionary import import_od, export_od, ObjectDictionary, ObjectDictionaryError
2+
from canopen.node import LocalNode, RemoteNode
3+
from canopen.objectdictionary import (
4+
ObjectDictionary,
5+
ObjectDictionaryError,
6+
export_od,
7+
import_od,
8+
)
59
from canopen.profiles.p402 import BaseNode402
10+
from canopen.sdo import SdoAbortedError, SdoCommunicationError
11+
612
try:
713
from canopen._version import version as __version__
814
except ImportError:

canopen/emcy.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1-
import struct
21
import logging
2+
import struct
33
import threading
44
import time
55
from typing import Callable, List, Optional
66

7+
import canopen.network
8+
9+
710
# Error code, error register, vendor specific data
811
EMCY_STRUCT = struct.Struct("<HB5s")
912

@@ -82,7 +85,7 @@ def wait(
8285
class EmcyProducer:
8386

8487
def __init__(self, cob_id: int):
85-
self.network = None
88+
self.network: canopen.network.Network = canopen.network._UNINITIALIZED_NETWORK
8689
self.cob_id = cob_id
8790

8891
def send(self, code: int, register: int = 0, data: bytes = b""):

canopen/lss.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import logging
2-
import time
3-
import struct
42
import queue
3+
import struct
4+
import time
5+
6+
import canopen.network
7+
58

69
logger = logging.getLogger(__name__)
710

@@ -78,8 +81,8 @@ class LssMaster:
7881
#: Max time in seconds to wait for response from server
7982
RESPONSE_TIMEOUT = 0.5
8083

81-
def __init__(self):
82-
self.network = None
84+
def __init__(self) -> None:
85+
self.network: canopen.network.Network = canopen.network._UNINITIALIZED_NETWORK
8386
self._node_id = 0
8487
self._data = None
8588
self.responses = queue.Queue()

canopen/network.py

Lines changed: 35 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,21 @@
11
from __future__ import annotations
22

3-
from collections.abc import MutableMapping
43
import logging
54
import threading
6-
from typing import Callable, Dict, Iterator, List, Optional, Union
5+
from collections.abc import MutableMapping
6+
from typing import Callable, Dict, Final, Iterator, List, Optional, Union
77

88
import can
99
from can import Listener
10-
from can import CanError
1110

12-
from canopen.node import RemoteNode, LocalNode
13-
from canopen.sync import SyncProducer
14-
from canopen.timestamp import TimeProducer
15-
from canopen.nmt import NmtMaster
1611
from canopen.lss import LssMaster
17-
from canopen.objectdictionary.eds import import_from_node
12+
from canopen.nmt import NmtMaster
13+
from canopen.node import LocalNode, RemoteNode
1814
from canopen.objectdictionary import ObjectDictionary
15+
from canopen.objectdictionary.eds import import_from_node
16+
from canopen.sync import SyncProducer
17+
from canopen.timestamp import TimeProducer
18+
1919

2020
logger = logging.getLogger(__name__)
2121

@@ -25,6 +25,9 @@
2525
class Network(MutableMapping):
2626
"""Representation of one CAN bus containing one or more nodes."""
2727

28+
NOTIFIER_CYCLE: float = 1.0 #: Maximum waiting time for one notifier iteration.
29+
NOTIFIER_SHUTDOWN_TIMEOUT: float = 5.0 #: Maximum waiting time to stop notifiers.
30+
2831
def __init__(self, bus: Optional[can.BusABC] = None):
2932
"""
3033
:param can.BusABC bus:
@@ -38,7 +41,7 @@ def __init__(self, bus: Optional[can.BusABC] = None):
3841
#: List of :class:`can.Listener` objects.
3942
#: Includes at least MessageListener.
4043
self.listeners = [MessageListener(self)]
41-
self.notifier = None
44+
self.notifier: Optional[can.Notifier] = None
4245
self.nodes: Dict[int, Union[RemoteNode, LocalNode]] = {}
4346
self.subscribers: Dict[int, List[Callback]] = {}
4447
self.send_lock = threading.Lock()
@@ -72,10 +75,10 @@ def unsubscribe(self, can_id, callback=None) -> None:
7275
If given, remove only this callback. Otherwise all callbacks for
7376
the CAN ID.
7477
"""
75-
if callback is None:
76-
del self.subscribers[can_id]
77-
else:
78+
if callback is not None:
7879
self.subscribers[can_id].remove(callback)
80+
if not self.subscribers[can_id] or callback is None:
81+
del self.subscribers[can_id]
7982

8083
def connect(self, *args, **kwargs) -> Network:
8184
"""Connect to CAN bus using python-can.
@@ -105,7 +108,7 @@ def connect(self, *args, **kwargs) -> Network:
105108
if self.bus is None:
106109
self.bus = can.Bus(*args, **kwargs)
107110
logger.info("Connected to '%s'", self.bus.channel_info)
108-
self.notifier = can.Notifier(self.bus, self.listeners, 1)
111+
self.notifier = can.Notifier(self.bus, self.listeners, self.NOTIFIER_CYCLE)
109112
return self
110113

111114
def disconnect(self) -> None:
@@ -117,7 +120,7 @@ def disconnect(self) -> None:
117120
if hasattr(node, "pdo"):
118121
node.pdo.stop()
119122
if self.notifier is not None:
120-
self.notifier.stop()
123+
self.notifier.stop(self.NOTIFIER_SHUTDOWN_TIMEOUT)
121124
if self.bus is not None:
122125
self.bus.shutdown()
123126
self.bus = None
@@ -279,6 +282,21 @@ def __len__(self) -> int:
279282
return len(self.nodes)
280283

281284

285+
class _UninitializedNetwork(Network):
286+
"""Empty network implementation as a placeholder before actual initialization."""
287+
288+
def __init__(self, bus: Optional[can.BusABC] = None):
289+
"""Do not initialize attributes, by skipping the parent constructor."""
290+
291+
def __getattribute__(self, name):
292+
raise RuntimeError("No actual Network object was assigned, "
293+
"try associating to a real network first.")
294+
295+
296+
#: Singleton instance
297+
_UNINITIALIZED_NETWORK: Final[Network] = _UninitializedNetwork()
298+
299+
282300
class PeriodicMessageTask:
283301
"""
284302
Task object to transmit a message periodically using python-can's
@@ -308,7 +326,6 @@ def __init__(
308326
self.msg = can.Message(is_extended_id=can_id > 0x7FF,
309327
arbitration_id=can_id,
310328
data=data, is_remote_frame=remote)
311-
self._task = None
312329
self._start()
313330

314331
def _start(self):
@@ -375,7 +392,9 @@ class NodeScanner:
375392
SERVICES = (0x700, 0x580, 0x180, 0x280, 0x380, 0x480, 0x80)
376393

377394
def __init__(self, network: Optional[Network] = None):
378-
self.network = network
395+
if network is None:
396+
network = _UNINITIALIZED_NETWORK
397+
self.network: Network = network
379398
#: A :class:`list` of nodes discovered
380399
self.nodes: List[int] = []
381400

@@ -391,8 +410,6 @@ def reset(self):
391410

392411
def search(self, limit: int = 127) -> None:
393412
"""Search for nodes by sending SDO requests to all node IDs."""
394-
if self.network is None:
395-
raise RuntimeError("A Network is required to do active scanning")
396413
sdo_req = b"\x40\x00\x10\x00\x00\x00\x00\x00"
397414
for node_id in range(1, limit + 1):
398415
self.network.send_message(0x600 + node_id, sdo_req)

canopen/nmt.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
1-
import threading
21
import logging
32
import struct
3+
import threading
44
import time
5-
from collections.abc import Callable
6-
from typing import Dict, Final, List, Optional, TYPE_CHECKING
5+
from typing import Callable, Dict, Final, List, Optional, TYPE_CHECKING
76

7+
import canopen.network
88

99
if TYPE_CHECKING:
10-
from canopen import Network
10+
from canopen.network import PeriodicMessageTask
1111

1212

1313
logger = logging.getLogger(__name__)
@@ -51,7 +51,7 @@ class NmtBase:
5151

5252
def __init__(self, node_id: int):
5353
self.id = node_id
54-
self.network: Optional[Network] = None
54+
self.network: canopen.network.Network = canopen.network._UNINITIALIZED_NETWORK
5555
self._state = 0
5656

5757
def on_command(self, can_id, data, timestamp):
@@ -113,7 +113,7 @@ class NmtMaster(NmtBase):
113113
def __init__(self, node_id: int):
114114
super(NmtMaster, self).__init__(node_id)
115115
self._state_received = None
116-
self._node_guarding_producer = None
116+
self._node_guarding_producer: Optional[PeriodicMessageTask] = None
117117
#: Timestamp of last heartbeat message
118118
self.timestamp: Optional[float] = None
119119
self.state_update = threading.Condition()
@@ -206,7 +206,7 @@ class NmtSlave(NmtBase):
206206

207207
def __init__(self, node_id: int, local_node):
208208
super(NmtSlave, self).__init__(node_id)
209-
self._send_task = None
209+
self._send_task: Optional[PeriodicMessageTask] = None
210210
self._heartbeat_time_ms = 0
211211
self._local_node = local_node
212212

canopen/node/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
from canopen.node.remote import RemoteNode
21
from canopen.node.local import LocalNode
2+
from canopen.node.remote import RemoteNode

canopen/node/base.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
from typing import TextIO, Union
2+
3+
import canopen.network
24
from canopen.objectdictionary import ObjectDictionary, import_od
35

46

@@ -17,10 +19,14 @@ def __init__(
1719
node_id: int,
1820
object_dictionary: Union[ObjectDictionary, str, TextIO],
1921
):
20-
self.network = None
22+
self.network: canopen.network.Network = canopen.network._UNINITIALIZED_NETWORK
2123

2224
if not isinstance(object_dictionary, ObjectDictionary):
2325
object_dictionary = import_od(object_dictionary, node_id)
2426
self.object_dictionary = object_dictionary
2527

2628
self.id = node_id or self.object_dictionary.node_id
29+
30+
def has_network(self) -> bool:
31+
"""Check whether the node has been associated to a network."""
32+
return not isinstance(self.network, canopen.network._UninitializedNetwork)

0 commit comments

Comments
 (0)