Skip to content

Commit e43db63

Browse files
refactor: one typed endmarker sentinel, collapsing make_receive_queue
make_receive_queue branched on its endmarker only to decide between calling setcallback with and without it -- which looks redundant, because setcallback's default *is* NO_ENDMARKER_WANTED. It was not: _multi and _channel each defined their own `object()` under that name, and the consumer task compares against _channel's. Passing _multi's through would have been read as a real endmarker and queued a bare object. So share one sentinel and name its type. It is an enum member rather than a bare object() so it can appear in an annotation: an endmarker may be any object, identity is the only thing separating "none wanted" from a legitimate one, and `Endmarker = object | Literal[NoEndmarker.NOT_WANTED]` now says on every signature which sentinel a caller has to hand back. With one definition the branch collapses to a plain forward. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent a84380f commit e43db63

5 files changed

Lines changed: 44 additions & 27 deletions

File tree

src/execnet/_channel.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from __future__ import annotations
1010

11+
import enum
1112
import threading
1213
import weakref
1314
from collections.abc import Callable
@@ -30,7 +31,26 @@
3031
if TYPE_CHECKING:
3132
from ._gateway_base import BaseGateway
3233

33-
NO_ENDMARKER_WANTED = object()
34+
35+
class NoEndmarker(enum.Enum):
36+
"""Type of the "no endmarker wanted" sentinel.
37+
38+
An enum rather than a bare ``object()`` so it is nameable in an
39+
annotation: an endmarker may be *any* object, so the only thing that
40+
distinguishes "none wanted" from a legitimate endmarker is identity,
41+
and a second module inventing its own ``object()`` for it would be
42+
silently wrong. ``endmarker: object | Literal[NoEndmarker.NOT_WANTED]``
43+
says which sentinel a caller has to hand back.
44+
"""
45+
46+
NOT_WANTED = enum.auto()
47+
48+
49+
NO_ENDMARKER_WANTED = NoEndmarker.NOT_WANTED
50+
51+
#: what an ``endmarker=`` parameter accepts: any object to deliver at the
52+
#: end, or the sentinel meaning "do not deliver one"
53+
Endmarker = object | Literal[NoEndmarker.NOT_WANTED]
3454

3555

3656
class Channel:
@@ -82,7 +102,7 @@ def _trace(self, *msg: object) -> None:
82102
def setcallback(
83103
self,
84104
callback: Callable[[Any], Any],
85-
endmarker: object = NO_ENDMARKER_WANTED,
105+
endmarker: Endmarker = NO_ENDMARKER_WANTED,
86106
) -> None:
87107
"""Set a callback function for receiving items.
88108

src/execnet/_gateway_base.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from ._boundary import make_wakener
2828
from ._channel import Channel
2929
from ._channel import ChannelFactory
30+
from ._channel import Endmarker
3031
from ._errors import INTERRUPT_TEXT
3132
from ._errors import geterrortext
3233
from ._errors import sysex
@@ -109,7 +110,7 @@ def _start_channel_consumer(
109110
self,
110111
channel: Channel,
111112
callback: Callable[[Any], Any],
112-
endmarker: object,
113+
endmarker: Endmarker,
113114
) -> None:
114115
"""Attach a receiver callback: hand the channel to a consumer task.
115116

src/execnet/_multi.py

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,9 @@
2525
from typing import overload
2626

2727
from ._boundary import WaitBackend
28+
from ._channel import NO_ENDMARKER_WANTED
2829
from ._channel import Channel
30+
from ._channel import Endmarker
2931
from ._execmodel import ExecModel
3032
from ._execmodel import get_execmodel
3133
from ._execmodel import resolve_profile
@@ -39,9 +41,6 @@
3941
from ._gateway import Gateway
4042

4143

42-
NO_ENDMARKER_WANTED = object()
43-
44-
4544
class Group:
4645
"""Gateway Group."""
4746

@@ -379,7 +378,7 @@ def receive_each(
379378
l.append(obj)
380379
return l
381380

382-
def make_receive_queue(self, endmarker: object = NO_ENDMARKER_WANTED):
381+
def make_receive_queue(self, endmarker: Endmarker = NO_ENDMARKER_WANTED):
383382
try:
384383
return self._queue # type: ignore[has-type]
385384
except AttributeError:
@@ -391,10 +390,7 @@ def make_receive_queue(self, endmarker: object = NO_ENDMARKER_WANTED):
391390
def putreceived(obj, channel: Channel = ch) -> None:
392391
self._queue.put((channel, obj)) # type: ignore[union-attr]
393392

394-
if endmarker is NO_ENDMARKER_WANTED:
395-
ch.setcallback(putreceived)
396-
else:
397-
ch.setcallback(putreceived, endmarker=endmarker)
393+
ch.setcallback(putreceived, endmarker=endmarker)
398394
return self._queue
399395

400396
def waitclose(self) -> None:

src/execnet/_trio_host.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
from ._boundary import Flag
2929
from ._channel import ENDMARKER
3030
from ._channel import NO_ENDMARKER_WANTED
31+
from ._channel import Endmarker
3132
from ._errors import GatewayReceivedTerminate
3233
from ._errors import RemoteError
3334
from ._execmodel import ExecModel
@@ -315,7 +316,7 @@ def attach_consumer(
315316
self,
316317
channel: Any,
317318
callback: Callable[[Any], Any],
318-
endmarker: object,
319+
endmarker: Endmarker,
319320
) -> None:
320321
"""Switch ``channel`` to callback mode: a loop task drains it.
321322
@@ -394,7 +395,7 @@ async def _run_consumer(
394395
channel: Any,
395396
inbox: trio.MemoryReceiveChannel[bytes],
396397
callback: Callable[[Any], Any],
397-
endmarker: object,
398+
endmarker: Endmarker,
398399
done: Flag,
399400
) -> None:
400401
"""Drain ``inbox`` into ``callback`` (each call off the loop thread).

testing/test_cli.py

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
import subprocess
1515
import sys
1616
import tempfile
17-
from collections.abc import Iterator
17+
from typing import Any
1818

1919
import pytest
2020

@@ -30,7 +30,7 @@
3030

3131

3232
def worker_config(**overrides: object) -> str:
33-
config = {
33+
config: dict[str, object] = {
3434
"id": "cli-test-worker",
3535
"profile": "thread",
3636
"execmodel": "thread",
@@ -89,9 +89,7 @@ def test_config_sources_are_mutually_exclusive(self) -> None:
8989
["worker", "--config", "{}", "--config-fd", "0"]
9090
)
9191

92-
@pytest.mark.parametrize(
93-
("value", "expected"), [("3", (3,)), ("4,5", (4, 5))]
94-
)
92+
@pytest.mark.parametrize(("value", "expected"), [("3", (3,)), ("4,5", (4, 5))])
9593
def test_protocol_fd_accepts_one_fd_or_a_pair(
9694
self, value: str, expected: tuple[int, ...]
9795
) -> None:
@@ -110,7 +108,7 @@ def test_protocol_fd_rejects_nonsense(self) -> None:
110108
(":8888", ("tcp", ("localhost", 8888))),
111109
],
112110
)
113-
def test_parse_address(self, address: str, expected: tuple) -> None:
111+
def test_parse_address(self, address: str, expected: tuple[str, Any]) -> None:
114112
from execnet._trio_worker import parse_address
115113

116114
assert parse_address(address) == expected
@@ -168,6 +166,7 @@ def test_a_plain_pipe_fd_is_rejected(self) -> None:
168166
capture_output=True,
169167
text=True,
170168
timeout=TESTTIMEOUT,
169+
check=False,
171170
)
172171
finally:
173172
os.close(read_fd)
@@ -208,9 +207,7 @@ def test_config_fd_keeps_it_out_of_argv(self) -> None:
208207
def test_config_file(self, tmp_path) -> None:
209208
path = tmp_path / "config.json"
210209
path.write_text(worker_config())
211-
ns = _cli._build_parser().parse_args(
212-
["worker", "--config-file", str(path)]
213-
)
210+
ns = _cli._build_parser().parse_args(["worker", "--config-file", str(path)])
214211
assert _cli._load_config(ns)["id"] == "cli-test-worker"
215212

216213
def test_no_config_source_is_an_error(self) -> None:
@@ -226,13 +223,15 @@ def test_defaults_per_platform(self) -> None:
226223
assert _provision.resolve_transport(spec) == expected
227224

228225
def test_explicit_wins(self) -> None:
229-
assert _provision.resolve_transport(execnet.XSpec("popen//transport=stdio")) == (
230-
"stdio"
231-
)
226+
assert _provision.resolve_transport(
227+
execnet.XSpec("popen//transport=stdio")
228+
) == ("stdio")
232229

233230
def test_unknown_is_rejected(self) -> None:
234231
with pytest.raises(ValueError, match="unknown transport"):
235-
_provision.resolve_transport(execnet.XSpec("popen//transport=carrier-pigeon"))
232+
_provision.resolve_transport(
233+
execnet.XSpec("popen//transport=carrier-pigeon")
234+
)
236235

237236
@posix_only
238237
def test_socket_transport_keeps_the_protocol_off_stdio(self) -> None:
@@ -353,7 +352,7 @@ def test_server_is_the_socketserver(self) -> None:
353352

354353
def test_socketserver_alias_warns(self, monkeypatch: pytest.MonkeyPatch) -> None:
355354
called: list[list[str]] = []
356-
monkeypatch.setattr(_cli, "main", lambda argv: called.append(argv))
355+
monkeypatch.setattr(_cli, "main", called.append)
357356
with pytest.warns(DeprecationWarning, match="execnet server"):
358357
_cli.socketserver_main([":0", "--once"])
359358
assert called == [["server", ":0", "--once"]]

0 commit comments

Comments
 (0)