Skip to content

Commit 971c331

Browse files
authored
Test on Python 3.13 (#1833)
* update linters * test python 3.13 * make pywin32 optional, refactor broadcastmanager.py * fix pylint * update pytest to fix python3.12 CI * fix test * fix deprecation warning * make _Pywin32Event private * try to fix PyPy * add classifier for 3.13 * reduce scope of send_lock context manager
1 parent aeff58d commit 971c331

File tree

9 files changed

+133
-83
lines changed

9 files changed

+133
-83
lines changed

.github/workflows/ci.yml

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ jobs:
2323
"3.10",
2424
"3.11",
2525
"3.12",
26+
"3.13",
2627
"pypy-3.8",
2728
"pypy-3.9",
2829
]
@@ -34,19 +35,14 @@ jobs:
3435
include:
3536
- { python-version: "3.8", os: "macos-13", experimental: false }
3637
- { python-version: "3.9", os: "macos-13", experimental: false }
37-
# uncomment when python 3.13.0 alpha is available
38-
#include:
39-
# # Only test on a single configuration while there are just pre-releases
40-
# - os: ubuntu-latest
41-
# experimental: true
42-
# python-version: "3.13.0-alpha - 3.13.0"
4338
fail-fast: false
4439
steps:
4540
- uses: actions/checkout@v4
4641
- name: Set up Python ${{ matrix.python-version }}
4742
uses: actions/setup-python@v5
4843
with:
4944
python-version: ${{ matrix.python-version }}
45+
allow-prereleases: true
5046
- name: Install dependencies
5147
run: |
5248
python -m pip install --upgrade pip

can/broadcastmanager.py

Lines changed: 104 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,21 @@
77

88
import abc
99
import logging
10+
import platform
1011
import sys
1112
import threading
1213
import time
13-
from typing import TYPE_CHECKING, Callable, Final, Optional, Sequence, Tuple, Union
14+
import warnings
15+
from typing import (
16+
TYPE_CHECKING,
17+
Callable,
18+
Final,
19+
Optional,
20+
Sequence,
21+
Tuple,
22+
Union,
23+
cast,
24+
)
1425

1526
from can import typechecking
1627
from can.message import Message
@@ -19,22 +30,61 @@
1930
from can.bus import BusABC
2031

2132

22-
# try to import win32event for event-based cyclic send task (needs the pywin32 package)
23-
USE_WINDOWS_EVENTS = False
24-
try:
25-
import pywintypes
26-
import win32event
33+
log = logging.getLogger("can.bcm")
34+
NANOSECONDS_IN_SECOND: Final[int] = 1_000_000_000
2735

28-
# Python 3.11 provides a more precise sleep implementation on Windows, so this is not necessary.
29-
# Put version check here, so mypy does not complain about `win32event` not being defined.
30-
if sys.version_info < (3, 11):
31-
USE_WINDOWS_EVENTS = True
32-
except ImportError:
33-
pass
3436

35-
log = logging.getLogger("can.bcm")
37+
class _Pywin32Event:
38+
handle: int
3639

37-
NANOSECONDS_IN_SECOND: Final[int] = 1_000_000_000
40+
41+
class _Pywin32:
42+
def __init__(self) -> None:
43+
import pywintypes # pylint: disable=import-outside-toplevel,import-error
44+
import win32event # pylint: disable=import-outside-toplevel,import-error
45+
46+
self.pywintypes = pywintypes
47+
self.win32event = win32event
48+
49+
def create_timer(self) -> _Pywin32Event:
50+
try:
51+
event = self.win32event.CreateWaitableTimerEx(
52+
None,
53+
None,
54+
self.win32event.CREATE_WAITABLE_TIMER_HIGH_RESOLUTION,
55+
self.win32event.TIMER_ALL_ACCESS,
56+
)
57+
except (
58+
AttributeError,
59+
OSError,
60+
self.pywintypes.error, # pylint: disable=no-member
61+
):
62+
event = self.win32event.CreateWaitableTimer(None, False, None)
63+
64+
return cast(_Pywin32Event, event)
65+
66+
def set_timer(self, event: _Pywin32Event, period_ms: int) -> None:
67+
self.win32event.SetWaitableTimer(event.handle, 0, period_ms, None, None, False)
68+
69+
def stop_timer(self, event: _Pywin32Event) -> None:
70+
self.win32event.SetWaitableTimer(event.handle, 0, 0, None, None, False)
71+
72+
def wait_0(self, event: _Pywin32Event) -> None:
73+
self.win32event.WaitForSingleObject(event.handle, 0)
74+
75+
def wait_inf(self, event: _Pywin32Event) -> None:
76+
self.win32event.WaitForSingleObject(
77+
event.handle,
78+
self.win32event.INFINITE,
79+
)
80+
81+
82+
PYWIN32: Optional[_Pywin32] = None
83+
if sys.platform == "win32" and sys.version_info < (3, 11):
84+
try:
85+
PYWIN32 = _Pywin32()
86+
except ImportError:
87+
pass
3888

3989

4090
class CyclicTask(abc.ABC):
@@ -254,25 +304,30 @@ def __init__(
254304
self.on_error = on_error
255305
self.modifier_callback = modifier_callback
256306

257-
if USE_WINDOWS_EVENTS:
258-
self.period_ms = int(round(period * 1000, 0))
259-
try:
260-
self.event = win32event.CreateWaitableTimerEx(
261-
None,
262-
None,
263-
win32event.CREATE_WAITABLE_TIMER_HIGH_RESOLUTION,
264-
win32event.TIMER_ALL_ACCESS,
265-
)
266-
except (AttributeError, OSError, pywintypes.error):
267-
self.event = win32event.CreateWaitableTimer(None, False, None)
307+
self.period_ms = int(round(period * 1000, 0))
308+
309+
self.event: Optional[_Pywin32Event] = None
310+
if PYWIN32:
311+
self.event = PYWIN32.create_timer()
312+
elif (
313+
sys.platform == "win32"
314+
and sys.version_info < (3, 11)
315+
and platform.python_implementation() == "CPython"
316+
):
317+
warnings.warn(
318+
f"{self.__class__.__name__} may achieve better timing accuracy "
319+
f"if the 'pywin32' package is installed.",
320+
RuntimeWarning,
321+
stacklevel=1,
322+
)
268323

269324
self.start()
270325

271326
def stop(self) -> None:
272327
self.stopped = True
273-
if USE_WINDOWS_EVENTS:
328+
if self.event and PYWIN32:
274329
# Reset and signal any pending wait by setting the timer to 0
275-
win32event.SetWaitableTimer(self.event.handle, 0, 0, None, None, False)
330+
PYWIN32.stop_timer(self.event)
276331

277332
def start(self) -> None:
278333
self.stopped = False
@@ -281,54 +336,49 @@ def start(self) -> None:
281336
self.thread = threading.Thread(target=self._run, name=name)
282337
self.thread.daemon = True
283338

284-
if USE_WINDOWS_EVENTS:
285-
win32event.SetWaitableTimer(
286-
self.event.handle, 0, self.period_ms, None, None, False
287-
)
339+
if self.event and PYWIN32:
340+
PYWIN32.set_timer(self.event, self.period_ms)
288341

289342
self.thread.start()
290343

291344
def _run(self) -> None:
292345
msg_index = 0
293346
msg_due_time_ns = time.perf_counter_ns()
294347

295-
if USE_WINDOWS_EVENTS:
348+
if self.event and PYWIN32:
296349
# Make sure the timer is non-signaled before entering the loop
297-
win32event.WaitForSingleObject(self.event.handle, 0)
350+
PYWIN32.wait_0(self.event)
298351

299352
while not self.stopped:
300353
if self.end_time is not None and time.perf_counter() >= self.end_time:
301354
break
302355

303-
# Prevent calling bus.send from multiple threads
304-
with self.send_lock:
305-
try:
306-
if self.modifier_callback is not None:
307-
self.modifier_callback(self.messages[msg_index])
356+
try:
357+
if self.modifier_callback is not None:
358+
self.modifier_callback(self.messages[msg_index])
359+
with self.send_lock:
360+
# Prevent calling bus.send from multiple threads
308361
self.bus.send(self.messages[msg_index])
309-
except Exception as exc: # pylint: disable=broad-except
310-
log.exception(exc)
362+
except Exception as exc: # pylint: disable=broad-except
363+
log.exception(exc)
311364

312-
# stop if `on_error` callback was not given
313-
if self.on_error is None:
314-
self.stop()
315-
raise exc
365+
# stop if `on_error` callback was not given
366+
if self.on_error is None:
367+
self.stop()
368+
raise exc
316369

317-
# stop if `on_error` returns False
318-
if not self.on_error(exc):
319-
self.stop()
320-
break
370+
# stop if `on_error` returns False
371+
if not self.on_error(exc):
372+
self.stop()
373+
break
321374

322-
if not USE_WINDOWS_EVENTS:
375+
if not self.event:
323376
msg_due_time_ns += self.period_ns
324377

325378
msg_index = (msg_index + 1) % len(self.messages)
326379

327-
if USE_WINDOWS_EVENTS:
328-
win32event.WaitForSingleObject(
329-
self.event.handle,
330-
win32event.INFINITE,
331-
)
380+
if self.event and PYWIN32:
381+
PYWIN32.wait_inf(self.event)
332382
else:
333383
# Compensate for the time it takes to send the message
334384
delay_ns = msg_due_time_ns - time.perf_counter_ns()

can/interfaces/usb2can/serial_selector.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@
99
try:
1010
import win32com.client
1111
except ImportError:
12-
log.warning("win32com.client module required for usb2can")
12+
log.warning(
13+
"win32com.client module required for usb2can. Install the 'pywin32' package."
14+
)
1315
raise
1416

1517

can/io/trc.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -343,7 +343,9 @@ def _write_header_v1_0(self, start_time: datetime) -> None:
343343
self.file.writelines(line + "\n" for line in lines)
344344

345345
def _write_header_v2_1(self, start_time: datetime) -> None:
346-
header_time = start_time - datetime(year=1899, month=12, day=30)
346+
header_time = start_time - datetime(
347+
year=1899, month=12, day=30, tzinfo=timezone.utc
348+
)
347349
lines = [
348350
";$FILEVERSION=2.1",
349351
f";$STARTTIME={header_time/timedelta(days=1)}",
@@ -399,7 +401,7 @@ def _format_message_init(self, msg, channel):
399401

400402
def write_header(self, timestamp: float) -> None:
401403
# write start of file header
402-
start_time = datetime.utcfromtimestamp(timestamp)
404+
start_time = datetime.fromtimestamp(timestamp, timezone.utc)
403405

404406
if self.file_version == TRCFileVersion.V1_0:
405407
self._write_header_v1_0(start_time)

can/notifier.py

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import logging
88
import threading
99
import time
10-
from typing import Awaitable, Callable, Iterable, List, Optional, Union, cast
10+
from typing import Any, Awaitable, Callable, Iterable, List, Optional, Union
1111

1212
from can.bus import BusABC
1313
from can.listener import Listener
@@ -110,16 +110,13 @@ def stop(self, timeout: float = 5) -> None:
110110

111111
def _rx_thread(self, bus: BusABC) -> None:
112112
# determine message handling callable early, not inside while loop
113-
handle_message = cast(
114-
Callable[[Message], None],
115-
(
116-
self._on_message_received
117-
if self._loop is None
118-
else functools.partial(
119-
self._loop.call_soon_threadsafe, self._on_message_received
120-
)
121-
),
122-
)
113+
if self._loop:
114+
handle_message: Callable[[Message], Any] = functools.partial(
115+
self._loop.call_soon_threadsafe,
116+
self._on_message_received, # type: ignore[arg-type]
117+
)
118+
else:
119+
handle_message = self._on_message_received
123120

124121
while self._running:
125122
try:

pyproject.toml

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ dependencies = [
1212
"packaging >= 23.1",
1313
"typing_extensions>=3.10.0.0",
1414
"msgpack~=1.0.0; platform_system != 'Windows'",
15-
"pywin32>=305; platform_system == 'Windows' and platform_python_implementation == 'CPython'",
1615
]
1716
requires-python = ">=3.8"
1817
license = { text = "LGPL v3" }
@@ -35,6 +34,7 @@ classifiers = [
3534
"Programming Language :: Python :: 3.10",
3635
"Programming Language :: Python :: 3.11",
3736
"Programming Language :: Python :: 3.12",
37+
"Programming Language :: Python :: 3.13",
3838
"Programming Language :: Python :: Implementation :: CPython",
3939
"Programming Language :: Python :: Implementation :: PyPy",
4040
"Topic :: Software Development :: Embedded Systems",
@@ -61,10 +61,11 @@ changelog = "https://github.com/hardbyte/python-can/blob/develop/CHANGELOG.md"
6161
[project.optional-dependencies]
6262
lint = [
6363
"pylint==3.2.*",
64-
"ruff==0.4.8",
65-
"black==24.4.*",
66-
"mypy==1.10.*",
64+
"ruff==0.5.7",
65+
"black==24.8.*",
66+
"mypy==1.11.*",
6767
]
68+
pywin32 = ["pywin32>=305"]
6869
seeedstudio = ["pyserial>=3.0"]
6970
serial = ["pyserial~=3.0"]
7071
neovi = ["filelock", "python-ics>=2.12"]
@@ -171,6 +172,7 @@ known-first-party = ["can"]
171172

172173
[tool.pylint]
173174
disable = [
175+
"c-extension-no-member",
174176
"cyclic-import",
175177
"duplicate-code",
176178
"fixme",

test/network_test.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ def testProducerConsumer(self):
8484
ready = threading.Event()
8585
msg_read = threading.Event()
8686

87-
self.server_bus = can.interface.Bus(channel=channel)
87+
self.server_bus = can.interface.Bus(channel=channel, interface="virtual")
8888

8989
t = threading.Thread(target=self.producer, args=(ready, msg_read))
9090
t.start()

test/simplecyclic_test.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ def test_stopping_perodic_tasks(self):
154154

155155
def test_restart_perodic_tasks(self):
156156
period = 0.01
157-
safe_timeout = period * 5
157+
safe_timeout = period * 5 if not IS_PYPY else 1.0
158158

159159
msg = can.Message(
160160
is_extended_id=False, arbitration_id=0x123, data=[0, 1, 2, 3, 4, 5, 6, 7]
@@ -241,7 +241,7 @@ def test_modifier_callback(self) -> None:
241241
msg_list: List[can.Message] = []
242242

243243
def increment_first_byte(msg: can.Message) -> None:
244-
msg.data[0] += 1
244+
msg.data[0] = (msg.data[0] + 1) % 256
245245

246246
original_msg = can.Message(
247247
is_extended_id=False, arbitration_id=0x123, data=[0] * 8

tox.ini

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,16 @@ isolated_build = true
33

44
[testenv]
55
deps =
6-
pytest==7.3.*
6+
pytest==8.3.*
77
pytest-timeout==2.1.*
88
coveralls==3.3.1
99
pytest-cov==4.0.0
1010
coverage==6.5.0
1111
hypothesis~=6.35.0
1212
pyserial~=3.5
1313
parameterized~=0.8
14-
asammdf>=6.0;platform_python_implementation=="CPython" and python_version < "3.12"
14+
asammdf>=6.0; platform_python_implementation=="CPython" and python_version<"3.13"
15+
pywin32>=305; platform_system=="Windows" and platform_python_implementation=="CPython" and python_version<"3.13"
1516

1617
commands =
1718
pytest {posargs}

0 commit comments

Comments
 (0)