diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..489c3d39 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(uv run pytest:*)" + ] + } +} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ad25a355..938246a0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,6 +10,8 @@ on: branches: - "main" + workflow_dispatch: + # Cancel running jobs for the same workflow and branch. concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -24,6 +26,29 @@ jobs: - name: Build and Check Package uses: hynek/build-and-inspect-python-package@v2.18 + # The docs build is `-W`, so a stale reference is an error, and the same + # env runs the doc examples as doctests -- they claim to be tested, and + # for years they silently were not. + docs: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v7 + with: + python-version: "3.13" + + - name: Install dependencies + run: pip install tox + + - name: Build the docs and run the doc examples + run: tox run -e docs + test: needs: [package] @@ -34,7 +59,7 @@ jobs: fail-fast: false matrix: os: [ windows-latest, ubuntu-latest ] - python: [ "3.10", "3.11", "3.12", "3.13", "3.14", "pypy-3.11" ] + python: [ "3.10", "3.11", "3.12", "3.13", "3.14", "3.15", "pypy-3.11" ] steps: - uses: actions/checkout@v7 @@ -51,6 +76,13 @@ jobs: uses: actions/setup-python@v7 with: python-version: ${{ matrix.python }} + # Only for the version that has no release yet: setup-python + # otherwise resolves released versions only, and a 3.15 job dies at + # setup ("version '3.15' with architecture 'x64' was not found"). + # Scoped to 3.15 rather than switched on for the matrix, so the + # released versions keep testing what users actually install -- + # move it to the next version when 3.15 ships. + allow-prereleases: ${{ matrix.python == '3.15' }} - name: Install dependencies run: pip install tox twine @@ -62,5 +94,105 @@ jobs: - name: Test shell: bash + # The suite is installed from the sdist, which leaves a dev version + # with no source tree to build a provisioning wheel from -- so every + # test that needs a provisioned worker (ssh, foreign python, via) + # would skip. Hand it the wheel from the same build instead: the + # workers then run exactly the artifact under test. run: | + export EXECNET_PROVISION_WHEEL="$PWD/$(find dist -name '*.whl' | head -1)" tox run -e py --installpkg `find dist/*.tar.gz` + + # pytest-xdist drives execnet's local parallel testing: popen gateways, + # the main-thread worker profile, crashed-worker replacement, and the + # report/warning serialization. Our own suite runs xdist as a *tool*, + # which does not exercise any of that -- so run xdist's own test suite + # against the execnet built from this branch. + # + # Two targets: + # + # * `release` is a pinned combination known to be green against the last + # *released* execnet, so a failure there means we broke something. It + # blocks. Bump `ref`/`pytest` together, and only after checking the new + # pair is green against released execnet -- otherwise the signal is + # gone. (xdist 3.8.0 needs pytest<9: with pytest 9 two of its tests + # fail against released execnet too.) + # * `default-branch` is early warning for drift on both sides. It is + # allowed to fail, because it can go red for reasons that are xdist's + # to fix. An empty `ref` follows whatever xdist's default branch is + # (`master` today) rather than hardcoding a name that could change. + # + # To reproduce locally: + # git clone https://github.com/pytest-dev/pytest-xdist + # git -C pytest-xdist checkout v3.8.0 # or stay on the default branch + # python -m venv .xdist-venv + # .xdist-venv/bin/pip install ./pytest-xdist[testing] "pytest<9" . + # cd pytest-xdist && ../.xdist-venv/bin/python -m pytest + xdist: + + needs: [package] + + runs-on: ubuntu-latest + + name: xdist (${{ matrix.name }}) + + continue-on-error: ${{ matrix.experimental }} + + strategy: + fail-fast: false + matrix: + include: + - name: release + ref: "v3.8.0" + pytest: "pytest<9" + experimental: false + - name: default-branch + ref: "" + pytest: "pytest" + experimental: true + + steps: + - uses: actions/checkout@v7 + with: + path: execnet + + - uses: actions/checkout@v7 + with: + repository: pytest-dev/pytest-xdist + ref: ${{ matrix.ref }} + path: pytest-xdist + + - name: Download Package + uses: actions/download-artifact@v8 + with: + name: Packages + path: dist + + - name: Set up Python + uses: actions/setup-python@v7 + with: + python-version: "3.13" + + - name: Install pytest-xdist and this execnet + shell: bash + run: | + python -m pip install --upgrade pip + python -m pip install "./pytest-xdist[testing]" "${{ matrix.pytest }}" + # after xdist, so its execnet>=2.1 requirement does not win + python -m pip install --force-reinstall `find dist/*.tar.gz` + python -c "import execnet, xdist; print('execnet', execnet.__version__)" + + - name: Run the pytest-xdist test suite + shell: bash + working-directory: pytest-xdist + run: | + deselect=() + # `|| [ -n "$line" ]` so a file without a trailing newline still + # contributes its last entry + while read -r line || [ -n "$line" ]; do + line="${line%%#*}" + line="$(echo "$line" | xargs)" + [ -n "$line" ] && deselect+=(--deselect "$line") + done < ../execnet/.github/xdist-known-failures.txt + printf 'deselecting %d known failure(s)\n' $(( ${#deselect[@]} / 2 )) + python -m pytest -ra "${deselect[@]}" diff --git a/.github/xdist-known-failures.txt b/.github/xdist-known-failures.txt new file mode 100644 index 00000000..23ef31c1 --- /dev/null +++ b/.github/xdist-known-failures.txt @@ -0,0 +1,19 @@ +# pytest-xdist tests that are expected to fail against this execnet, with +# the reason. Everything not listed here must pass -- the whole point of +# the `xdist (release)` CI job is that this list stays short and justified. +# +# Blank lines and `#` comments are ignored; every other line is one pytest +# node id, passed to `pytest --deselect`. The same list is used for both +# the pinned release and the `main` target; a node id that does not exist +# in one of them is silently ignored, so an entry may name a test that +# only one version has. +# +# Do not add an entry to make CI green. Add one only for a test that is +# asserting behaviour execnet deliberately changed, and say so. + +# Asserts `sys.argv == ["-c"]`, and its own docstring says it is +# documenting "the behavior due to execnet using `python -c`". execnet no +# longer ships source over the wire: workers are launched through the CLI +# (`python -m execnet worker --protocol-... --config ...`), so sys.argv +# legitimately differs. Needs an xdist-side update, not an execnet fix. +testing/test_remote.py::test_remote_inner_argv diff --git a/.gitignore b/.gitignore index d734b32f..9f676ac3 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ include/ .vagrant.d/ .config/ .local/ +.claude/settings.local.json diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index be4b6e07..91db1f29 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -31,5 +31,6 @@ repos: - id: mypy additional_dependencies: - pytest + - trio + - hypothesis - types-pywin32 - - types-gevent diff --git a/CHANGELOG.rst b/CHANGELOG.rst index a7af6272..4fd034b2 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,7 +1,431 @@ -2.2.0 (UNRELEASED) +3.0.0 (UNRELEASED) ------------------ +This release rebuilds execnet on an async-native Trio core. It is a major +release: a worker is now launched through the ``execnet`` command line +rather than by bootstrapping source over the wire, the protocol rides a +socket instead of the worker's stdin/stdout, and a worker's stdio belongs +to the code it runs. + +**pytest-xdist keeps working unmodified.** The deprecated names that +released xdist reaches for -- ``execnet.gateway_base.ExecModel``, +``execnet.dumps``, ``Group(execmodel=...)``, the ``execmodel=`` spec key -- +all still work here; they are scheduled for removal later in the 3.x +series, once the consumers that need them have released without them. + +* New ``execnet`` command line, and it is now the launch contract between a + coordinator and the worker process it starts:: + + execnet worker --protocol-stdio | --protocol-fd FD[,FD] + | --protocol-connect ADDR | --protocol-listen ADDR + | --protocol-share + --config JSON | --config-fd FD | --config-file PATH + --stdin/--stdout/--stderr DISPOSITION + execnet server [HOST:PORT] [--once] + execnet info + + ``ADDR`` is ``unix:/path`` or ``host:port``. Provisioning emits + ``python -m execnet worker ...`` for a direct interpreter launch and + ``execnet worker ...`` under ``uv run``; both are the same CLI. + ``execnet-socketserver`` still works and forwards to ``execnet server`` + with a ``DeprecationWarning``. ``execnet info`` reports version, trio + availability and supported transports as JSON, and replaces the + ``import execnet, trio`` probe used to decide whether a ``python=`` + interpreter can host a worker directly. +* The protocol no longer has to be the worker's stdin/stdout. A new + ``transport=socket|stdio`` spec key selects, and ``socket`` is the default + for every worker execnet spawns itself: an inherited socketpair for + ``popen`` on POSIX, and a socket duplicated with ``socket.share()`` on + Windows. ``ssh=``/``vagrant_ssh=`` gateways use an ``ssh -R``-forwarded + unix socket the worker dials back on, which needs ``AF_UNIX`` and + ``StreamLocal`` forwarding, so those stay on ``stdio`` on Windows. +* **A worker's stdio now belongs to the code it runs.** It used to be + redirected to the null device so it could not corrupt the protocol, which + meant a remote ``print()`` went nowhere at all. With the socket transport + the worker leaves fd 0/1/2 alone entirely; with the stdio transport it + closes stdin and folds stdout onto stderr rather than discarding both. + Remote output that used to vanish now reaches the coordinator -- and a + worker can now also *read* the coordinator's stdin. New ``stdin=``, + ``stdout=`` and ``stderr=`` spec keys (``inherit``/``devnull``, plus + ``close`` for stdin and ``stderr`` for stdout) override any of it, e.g. + ``popen//stdin=devnull``. +* **The worker config no longer travels in the remote command line** for + ssh gateways, which closes an exposure: the config carries ``env:`` + values, and a remote argv is readable by every user on that host through + ``ps``. The socket transport frees ssh's stdin, so the config goes there + (``--config-fd 0``) instead. +* A shipped wheel (dev-version coordinator) is delivered to an ssh remote + over its own connection before the worker is launched, and cached there + by name. The launch command no longer needs ``head -c `` byte + accounting, a ``mktemp`` prelude, or ``exec`` to keep an fd alive, and the + protocol stream never carries a payload. +* **A worker now refuses a ``remote_exec`` past its concurrency limit** + instead of admitting one it cannot place. Thread-shaped execs each cost a + thread of the worker's budget -- which channel callbacks and its own + protocol work also draw on -- so exec takes half of it (20 by default) and + the request after that comes back as a ``RemoteError`` naming the limit. + Admitting it instead made request 41 wait for a slot only a finishing exec + could free, which from the coordinator is indistinguishable from a hung + ``remote_exec``. ``remote_status()`` gained ``execcapacity`` (``None`` + under ``profile=trio`` and ``profile=gevent``, whose execs are tasks and + greenlets and are not bounded this way), and ``numexecuting`` now counts + what is really running. +* **A ``profile=gevent`` worker is no longer limited to as many concurrent + execs as it has threads.** Waiting for a greenlet to finish parked a pool + thread on a ``threading.Event``, so execs that cost no thread each held + one anyway -- capping exactly the concurrency the profile exists to + provide. The wait is a ``trio.Event`` woken from the exec's own thread + now; same for the main-thread exec under ``profile=thread``. +* The exec slot is released just before the exec's channel close goes out, + not after its task unwinds: that close is what tells a coordinator at + capacity it may send the next request, so ``waitclose()`` followed by + ``remote_exec()`` must not be refused for a slot that is already free. +* **An exec that finishes after its connection died no longer takes the + worker down.** Closing the channel is how an exec reports it finished, and + a connection that went away first makes that raise; the exception reached + the worker's root nursery, ending ``trio.run`` and printing an + ``ExceptionGroup`` onto the user's stderr -- which is the worker's own + since this release. The close now tolerates a dead connection, and the + exec task contains anything else it raises. +* **``socket=`` and ``installvia=`` gateways are configured by their spec + again.** ``profile=``, ``chdir=``, ``nice=`` and ``env:`` were accepted, + validated and then silently dropped: the worker is spawned by the + *server*, which built the config itself and never saw the spec. The + coordinator now sends its worker config over the connection (one JSON + line, before the protocol), and the server takes the keys a spec carries + and none of its own. A connection that sends no config line is closed + instead of served, without taking the accept loop down with it. +* ``AsyncGroup`` allocates gateway ids from a counter rather than from the + length of its gateway list, which ``terminate()`` empties -- so ``gw0`` + named two different workers in one session. +* Removed ``safe_terminate``, unused since termination moved into the async + group; the bound it protected (issues #43/#221) is now tested on the real + ``Group.terminate()`` path. +* A worker now **refuses** a coordinator whose major/minor execnet version + differs from its own, where it used to print a warning and carry on. The + two ends are installed independently now that no source is shipped, and + the protocol is unversioned, so a skew has no defined behaviour. The + refusal happens before the worker touches its stdio -- the last moment a + reason can reach the user, since afterwards the coordinator only ever + learns EOF. A patch-level difference is still tolerated, and + ``EXECNET_IGNORE_VERSION_SKEW=1`` in the worker's environment (reachable + as ``env:EXECNET_IGNORE_VERSION_SKEW=1`` in a spec) downgrades it to the + old warning. +* A worker that dies before its handshake now says so with its exit status + (``worker exited with N before the handshake``) instead of surfacing as + whatever the closed socket looked like. +* A worker that dies abruptly reports ``EOFError`` on every transport. A + killed peer *resets* a socket -- Windows reports ``WSAECONNRESET`` -- + where a pipe would simply reach EOF, so the same event used to surface + as ``trio.BrokenResourceError`` on one transport and ``EOFError`` on the + other. Endmarker callbacks and ``channel._getremoteerror()`` now behave + the same either way. +* ``channel.send()`` and ``channel.receive()`` check for a foreign event + loop before they check whether the channel is still open. Calling a + blocking API from inside a loop is a caller bug either way, and which of + the two errors you got depended on whether the peer had closed yet -- + so the more useful message lost a race. +* Whether a socket can be handed to a worker is now settled by *doing* it + once -- sharing to our own pid and rebuilding the result -- rather than + by looking for ``socket.share``. An implementation with the name but not + a working call would otherwise pass the check and fail later, at the + point where the only thing left to tell the coordinator is a closed + socket. A host that genuinely cannot hand a socket over refuses the + request up front instead. +* A socket gateway that fails to start no longer takes down the gateway it + was requested through. It ran as a task on that worker's host, so an + unsupported sub-gateway used to cost that coordinator as well. +* ``execnet server :0`` reported a port nothing was listening on. Binding a + wildcard host with an ephemeral port gives *each* address family its own + random port, and only the first was reported -- so a client dialling the + other family found nothing. Which family comes first is platform + dependent, which is why this worked on Linux and not on Windows. All + families now share the reported port. +* A worker that cannot be handed its socket now fails instead of hanging. + A ``socket=``/``installvia=`` worker is spawned by the *server*, so a + failure there used to leave the coordinator waiting forever on a + handshake byte nobody was going to send -- one unsupported gateway could + wedge a whole session. A host that cannot hand over a socket at all now + refuses the request before replying with an address, which surfaces as a + remote error rather than an unexplained wait; and a spawn that fails + anyway closes the connection, so the coordinator sees EOF. +* New ``share`` protocol transport (``execnet worker --protocol-share``) + carrying a socket to a worker on Windows, where ``subprocess`` refuses + ``pass_fds``. The socket is duplicated into the child with + ``socket.share()`` (``WSADuplicateSocket``); because that needs the + child's pid, the flag travels in argv and the blob follows in the config + on stdin. The blob is bound to that one pid, so it is inert to anything + else. This makes ``transport=socket`` the Windows default too, and fixes + ``socket=``/``installvia=`` gateways served from a Windows host. A socket + is handed over *as a socket* rather than reduced to its handle: rebuilding + one from a bare handle makes the constructor re-derive family/type/proto + by querying it, which PyPy on Windows cannot do to a handle that came + from ``WSADuplicateSocket``. +* ``transport=socket`` on a gateway that cannot provide it is now an error + at ``makegateway`` time naming the platform, instead of a gateway that + waits for a worker which was never able to reach back. ssh dial-back + needs ``AF_UNIX`` (which CPython does not expose on Windows) and + ``StreamLocal`` forwarding (which Win32-OpenSSH does not implement), so + ssh gateways there stay on stdio. +* Fixed Windows workers, which could not start at all: adopting the + inherited stdio pipes went through ``trio.lowlevel.FdStream``, which is + POSIX-only. Windows has no async equivalent -- trio's Windows pipe streams + need OVERLAPPED handles registered with an IOCP, and the stdio a process + inherits is an ordinary synchronous pipe -- so those reads and writes now + run in the thread pool. This only affects ``transport=stdio``; the socket + transport, which is the default, needs no threads. +* New ``EXECNET_PROVISION_WHEEL`` environment variable naming a prebuilt + wheel to provision remote workers from, instead of resolving the + coordinator's version from an index or building one from its source tree. + This is for testing a built artifact: an execnet installed *from* a + distribution has a dev version but no source tree, so it can do neither + and every test needing a provisioned worker would skip. Pointing this at + the wheel from the same build makes those workers run the artifact under + test. Set but not naming an existing ``.whl`` is an error rather than a + silent fallback, which would provision something else. +* ``main_thread_only`` used to *serialize*, so every sequential + ``remote_exec`` was guaranteed the worker's main thread. The ``thread`` + profile it now maps to releases its claim as an exec finishes, a moment + after the channel close that lets the coordinator send the next request -- + so a coordinator that immediately re-execs can rarely land on a pool + thread instead. The *first* request always gets the main thread; use the + ``trio`` or ``gevent`` profile where placement must never race. + +* **Exceptions say which of three things went wrong**, so a caller can act on + the answer rather than string-match it: the other side failed + (``RemoteError``), the connection is gone (``OSError`` and its subclasses), + or the call itself was wrong (``ExecnetStateError``). Those last two used to + be the same type, so anything retrying on connection loss was also retrying + on its own bugs. + + ``execnet.TimeoutError`` now derives from the **builtin** ``TimeoutError``. + It shadowed it without subclassing it, so the obvious ``except + TimeoutError:`` caught nothing and only ``except OSError`` worked -- and + since 3.11 ``asyncio.TimeoutError`` *is* the builtin, so async callers' + instincts were actively wrong. The builtin is an ``OSError``, so this widens + what catches it and narrows nothing. + + New: ``ChannelClosed`` (this channel is finished) and ``GatewayGone`` (the + connection is), both ``OSError`` subclasses so everything catching ``OSError`` + keeps working -- pytest-xdist swallows exactly that around its shutdown send. + ``GatewayGone`` is *also* an ``EOFError``, because that is how a broken + connection has always surfaced; the distinction becomes available without + anything ceasing to be caught. + + One deliberate break: two channel operations raised ``OSError`` for API + misuse -- closing a channel inside its own ``remote_exec``, and calling + ``receive()`` on a channel that has a callback registered. Both are + ``ExecnetStateError`` now, which is a ``RuntimeError`` and *not* an + ``OSError``. That is the point of the split. + +* One namespace per concurrency library you drive execnet from: + ``execnet.sync`` (plain threads; the top-level ``execnet.*`` aliases), + ``execnet.trio``, ``execnet.aio``, the new ``execnet.gevent``, whose blocking + waits park the calling greenlet instead of its OS thread (needs + ``execnet[gevent]``), and ``execnet.raw_trio``. + + Four of them put protocol IO on a shared ``execnet.ProtocolEngine`` and differ only + in how the caller waits for it, so the two blocking ones now raise when called from + inside a running asyncio or trio loop -- naming the namespace to use instead -- + rather than stalling that loop. Channels inside a worker are exempt: exec'd code may + run its own event loop and talk to its channel from within it. + + ``execnet.raw_trio`` is the exception, and the only surface that runs gateways + *directly*, as tasks in your own nursery. It is what ``execnet.trio`` was during + this release cycle; ``execnet.trio`` is now a facade over the engine, like the + others. The trade is a real one -- exact cancellation and no thread hop against a + caller loop that cannot stall the protocol, gateways that outlive the scope that + made them, and one engine shared with every other surface in the process. The + namespace reference has the table. +* The ``execnet.portal`` namespace is gone. It exposed the ``Wakener`` protocol and the + ``Mailbox``/``OneShot``/``LoopPortal`` primitives but not the registry needed to plug + a ``Wakener`` in, and execnet does not offer third-party event-loop integration: a new + concurrency library gets a namespace of its own, as gevent just did. The primitives + are internal again. +* A cancelled ``receive`` on ``execnet.trio`` or ``execnet.aio`` no longer eats an + item. The bridge cancels the engine-side receive, but the two race: if the engine + had already taken an item, it used to be dropped, which was the one place a facade + behaved worse than ``execnet.raw_trio``. Such a value is now kept and returned by + the next ``receive``, in order, so a cancelled receive consumes nothing whichever + way the race went. +* ``ProtocolEngine`` takes a ``backend=``: ``"trio"`` (the default) or ``"asyncio"`` + on Python 3.11 or newer, which is refused rather than backported below that. The + two meet the same contract -- start, a portal, one door to the root task scope, the + group registry, a stop that joins -- and are tested against the same suite. + + **Only the trio engine can host gateways.** The protocol core is still written + against trio directly, so a group built on an asyncio engine is refused with a + message saying what is missing. The backend exists so that the boundary between + execnet and the async library under it is a tested one rather than an intention. + +* Gateway groups share one ``execnet.ProtocolEngine`` per process -- one OS thread + running a loop -- instead of starting a thread each. Pass an explicit one as + ``Group(engine=...)`` (or ``AsyncGroup(engine=...)``) for an isolated loop with + deterministic teardown: it is a context manager and joins its thread on exit, where + the shared one stops at interpreter exit. + + ``ProtocolEngine.close()`` terminates the groups still running on it and warns + (``execnet.ActiveGroupsWarning``) that it had to. Their workers are real processes, + and once the loop that speaks to them is gone nothing else is going to reap them -- + but close time is the worst moment to find a worker that will not go quietly, which + is what the warning is about. ``ProtocolEngine.terminate()`` is the same drain + without the shutdown, for callers who would rather do it where they can act on the + result. + + Closing stays final, and the groups, gateways and channels it served are finished + with either way: their protocol IO has no loop to run on any more, so channels reach + EOF, sends raise, and the group refuses to build new gateways instead of quietly + starting a second loop thread that none of its existing gateways are attached to. An + engine whose thread does not join within the timeout now says so rather than + returning as though it had stopped one. +* execnet objects do not survive ``os.fork()``, and now say so instead of blocking. + The engine's loop thread is not duplicated into the child and the worker connections + belong to the parent, but the parent loop's trio token still *accepts* work in the + child -- so a forked child using an inherited group, gateway or channel (including + the module-level ``execnet.makegateway``) used to wait forever for a reply nobody + would send. Every route to the engine now checks which process it is in and raises, + naming the fork. Recovery is explicit and belongs to the child: build a new + ``ProtocolEngine`` and a new ``Group`` on it. A child that asks for the default + engine gets a fresh one, and it no longer inherits the parent's atexit cleanup. +* An engine loop that cannot start now says why, immediately. It comes up on a thread + nobody is watching, so a ``trio.run`` that died at once left the caller waiting + out the full 30s start timeout and then raising something generic, with the + actual reason only on stderr. The failure is re-raised at the call site, and + when ``gevent.monkey`` is what broke it, the message says so. +* ``execnet.gevent`` requires a process that has **not** monkey-patched. The engine + loop is a Trio program on its own OS thread and needs the real ``select`` (for + ``epoll``), ``socket``, ``thread`` and ``queue``; ``gevent.monkey`` replaces + those process-wide. Patching was never what made the namespace work -- its waits + park the calling greenlet because they wait on a gevent primitive -- but the + documentation implied patching was fine, and it is not. +* ``execnet.gevent``'s first ``makegateway`` no longer blocks the hub. Starting the + group's async side took the blocking portal call that every other management + operation on this facade deliberately avoids; it is short enough that the timing + test stayed green, which is why it survived. +* A ``makegateway`` that fails after the worker answered its handshake no longer + leaves that worker running. There is a seam between the connect helpers, which + each clean up after themselves, and the group taking ownership of the process; + a failure in it (a cancellation, realistically) used to leave a worker nothing + would ever terminate. +* ``Group.terminate()`` and ``Gateway.join()`` join the calls that refuse to run + inside a running asyncio or trio loop. Both block on the engine with no useful + bound -- ``join()`` until the worker dies -- which is the stall the guard exists to + turn into an error. Terminating a group with nothing in it stays allowed. +* A spec's ``profile=``/``execmodel=`` value is no longer rewritten in place when it + names a deprecated profile. pytest-xdist reuses one ``XSpec`` across gateways and + re-reads ``spec.execmodel`` to decide whether it still needs prefixing, so normalizing + the value it set made the second use build a spec with a duplicate key -- which broke + crashed-worker replacement. The spec keeps what the caller spelled; the mapping happens + where the value is consumed. +* The ``execmodel=`` spec key is now ``profile=``; ``execmodel=`` remains an accepted + alias. It always selected the *worker* profile -- where exec'd code runs relative to + the worker's protocol loop -- while the local execution model it was named after no + longer exists. Accordingly ``Group(execmodel=...)``, ``Group.set_execmodel()``, + ``Group.execmodel`` and ``Group.remote_execmodel`` are deprecated in favour of + ``Group(profile=...)``, ``Group.set_profile()`` and ``Group.profile``; only the + remote default they set has any effect. ``remote_status()`` reports both + ``profile`` and ``execmodel``. +* The ``main_thread_only`` profile is deprecated and now behaves like ``thread``, which + already hands the first ``remote_exec`` the worker's real main thread -- the + GUI/signal-safety property it was added for in 2.1.0. Its other behaviour is gone: + a second concurrent ``remote_exec`` used to close the channel with + ``concurrent remote_exec would cause deadlock``, and now runs on a pool thread. That + guard was a one-second timeout that reported a merely slow predecessor as a deadlock. +* The ``wait=`` spec key added earlier in this release cycle is gone. Which primitive a + blocking wait parks on describes the *caller*, which is what choosing a namespace + already says; a worker's own backend is derived from its profile. +* ``execnet.aio`` now propagates cancellation. Cancelling an awaited ``receive`` (with + ``asyncio.timeout``, say) cancels the engine-side operation, where it previously + abandoned only the asyncio side and let the operation consume an item that was then + discarded. ``send``, ``send_eof``, ``aclose`` and ``terminate`` are shielded instead, + so they cannot tear halfway. + + Its classes gained the ``Async`` prefix -- ``AsyncGroup``, ``AsyncGateway``, + ``AsyncChannel`` -- matching ``execnet.trio``, and ``AsyncGroup`` can be driven with + ``start()``/``aclose()`` from application lifespan hooks instead of ``async with``. + ``open_popen_gateway`` is renamed ``open_gateway`` on both async namespaces, since it + always accepted any spec. +* ``execnet.dumps``, the temporary pytest-xdist compatibility shim, no longer warns. + xdist reaches it from ``serialize_warning_message`` -- from inside pytest's + warning-recording hook, once per warning a *user's* test raises. Warning there put a + spurious execnet ``DeprecationWarning`` in that user's warnings summary, attributed to + their test, about a probe only xdist can port; and warning on every access made + recording one warning record another, unbounded, wedging the run. +* ``Gateway.remote_init_threads()`` raises a ``DeprecationWarning`` instead of printing + to stdout. It has been a no-operation since execnet 1.2. + +* The documentation describes what execnet does now: worker profiles instead + of threading models, the spec keys (including ``transport=`` and the stdio + dispositions), the namespaces, the protocol engine, the ``execnet`` command + line, and a namespace reference for the async surfaces. ``tox -e docs`` + now also *runs* the doc examples -- they had claimed to be automatically + tested while a ``pytest_plugins`` line in a non-top-level conftest made + collecting them an error -- and both it and the ``-W`` sphinx build run in + CI, where the docs had never been built at all. + * `#380 `__: Add support for Python 3.13 and 3.14, and drop EOL 3.8 and 3.9. +* Trio host-thread Message IO for local ``popen`` + import bootstrap (coordinator and + worker). Adds a hard ``trio`` dependency. Disable with ``EXECNET_TRIO_HOST=0``. + Other gateway types and greenlet execmodels keep the legacy thread path. +* Removed ``execnet.script.shell``, an interactive remote prompt that injected its own + source into the pre-Trio socket server. The Trio socket server never execs an incoming + source line, so the module could no longer work against it. +* Removed ``execnet.script.quitserver``, which shut a socket server down by sending it + ``"raise KeyboardInterrupt"`` to exec. It relied on the same removed handshake. +* Removed ``execnet.script.loop_socketserver``, a restart loop around a sibling + ``socketserver.py`` file. The socket server serves connections in a loop itself + (``--once`` opts out), and the sibling-file path never resolved for an installed + execnet anyway. +* Removed ``execnet.script.socketserverservice``, the pywin32 Windows service wrapper. + Wrap the ``execnet-socketserver`` console command with a service host such as NSSM + instead; the socket gateway example documents how. +* Moved the socket server to ``execnet._socketserver`` and removed the now empty + ``execnet.script`` package. The ``execnet-socketserver`` console command is unchanged + and remains the supported way to run it. +* Removed ``Gateway.reconfigure`` and ``Channel.reconfigure`` along with the + ``py2str_as_py3str`` / ``py3str_as_py2str`` arguments of ``gateway_base.loads`` and + ``gateway_base.load``. They configured string coercion between Python2 and Python3 + peers, which execnet can no longer have: ``py2str_as_py3str`` was already unreachable + because only a Python2 serializer emits the opcode it gates. The ``RECONFIGURE`` + message code stays reserved but is no longer sent or handled. +* The serializer dropped the retired ``PY2STRING`` and ``UNICODE`` opcodes and renamed + ``PY3STRING`` to ``STRING``. Values dumped by execnet running on Python2 no longer + load. Opcode bytes are unchanged for every type that survives. +* The supported API is now exactly six namespaces: ``execnet`` (aliases of + ``execnet.sync``), ``execnet.sync``, ``execnet.trio``, ``execnet.raw_trio``, + ``execnet.aio`` and ``execnet.gevent`` -- one per concurrency library you drive + execnet from, plus the engine-free trio one. The + pre-Trio modules ``execnet.gateway_base``, ``execnet.gateway``, + ``execnet.multi``, ``execnet.rsync``, ``execnet.rsync_remote`` and ``execnet.xspec`` + were only ever reachable because ``import execnet`` pulled them in transitively; they + are now deprecated forwarding shims that warn on attribute access and will be removed + in execnet 3.0. Both ``import execnet.gateway_base`` and ``execnet.gateway_base.X`` + after a plain ``import execnet`` keep working for now. Every name they exposed is + available from a public namespace, except the internals listed below. +* ``gateway_base`` was split into private modules grouped by concern: ``_trace``, + ``_errors``, ``_execmodel``, ``_message`` (IO protocols, ``Message``, + ``FrameDecoder``), ``_serialize``, ``_channel`` (``Channel``, ``ChannelFactory``, + the ``ChannelFile`` adapters) and ``_gateway_base`` (``BaseGateway``, + ``WorkerGateway``). +* Removed ``execnet.loads``, ``execnet.dump`` and ``execnet.load``, and dropped + ``execnet.dumps`` from the public surface. The standalone serializer is internal. + Added ``execnet.can_send(obj)``, which answers whether a value can cross a channel, + for callers that previously probed with ``try: execnet.dumps(x) / except DumpError``. + It lives on ``execnet`` only -- the wire contract does not vary by namespace. + + ``execnet.dumps`` itself stays *reachable* for now, warning on access, purely so + released ``pytest-xdist`` keeps working; it is absent from ``__all__`` and from + ``dir(execnet)``. It is scheduled for removal once xdist ports its probe to + ``can_send``. +* ``execnet.raw_trio`` no longer exports ``ByteStream``, ``RawChannel``, + ``RawChannelStream`` or ``serve_gateway``; the raw-channel layer is internal routing + detail. ``open_raw_channel`` and ``enqueue_frame`` on its ``AsyncGateway`` are + underscored for the same reason, and the ``execnet.trio``/``execnet.aio`` facades + do not expose them at all: they hand out channel ids from an unlocked per-gateway + counter that only works because one loop owns it. +* ``execnet.gevent`` gained ``Deployment``, ``Deployed`` and ``transfer``. The + deployment layer already had a gevent parking path; only the exports were missing. + 2.1.2 (2025-11-11) ------------------ diff --git a/HANDOFF.md b/HANDOFF.md new file mode 100644 index 00000000..aaf3d586 --- /dev/null +++ b/HANDOFF.md @@ -0,0 +1,308 @@ +# Handoff: execnet on a Trio core + +Branch `feat/trio-host-thread-io`, draft PR **pytest-dev/execnet#422**. +This is the doc to read first. Two companions: + +- **`ROADMAP-3.0.md`** — what this branch ships as, and what is still open. + The work list lives there, not here. +- **`handoff-history.md`** — the compressed record of what landed, with + commit ranges and the lessons that cost a debugging round each. + +## How to work here + +``` +uv run pytest testing/ # 583 passed, 66 skipped +uv run pytest testing/ -n 12 # must stay green (~8s) +uv run pre-commit run -a # never grep-filter its output +uv run tox -e docs # sphinx -W, then doctests all of doc/ +``` + +`tox -e docs` doctests the whole `doc/` tree, not just the examples: +`doc/basics.rst` is executed too, so a `>>>` block there is checked. The +two files with no `>>>` in them — `doc/example/test_debug.rst` (the trace +transcript) and `test_ssh_fileserver.rst` — are prose nothing verifies. + +ssh paths have a real local harness in `testing/test_ssh_local.py` (an +asyncssh server; needs a system ssh client). Hypothesis stress coverage +is `testing/test_channel_stress.py` behind `--stress=N`. + +Known flakes, all timing: + +- `test_socket_installvia` EOFs rarely under load. +- `test_gateway_status_busy` (numexecuting race) and + `test_popen_stderr_tracing` (capfd race) keep their `flakytest` marks + and XPASS when idle — see `handoff-history.md`. +- both `TestInfo` tests that shell out to `execnet info` + (`test_info_reports_what_a_coordinator_needs`, `test_probe_uses_info`) + have failed together once under `-n 12` (2026-07-31, 2026-08-01), green + in isolation and on rerun. Not diagnosed, but they share one cause — + spawning that probe subprocess under a loaded machine — rather than + being a property of either test. + +CI runs pytest-xdist's own suite against this execnet — see +"The xdist contract" in `ROADMAP-3.0.md`. That job is the one that +catches what our suite structurally cannot. + +## Where the repo stands + +There is **one protocol engine**, the async-native `AsyncGateway` +(`_trio_gateway.py`), and everything else is a surface over it. The wire +protocol (`Message` framing) is unchanged from 2.1. + +**No source is shipped over the wire, ever.** Workers are launched as +`execnet worker ` and configured by a frame on it; foreign and +remote interpreters are uv-provisioned; a dev coordinator builds and ships +a wheel. A major/minor version skew is **refused** by the worker as its +answer to that frame (`_trio_worker._version_refusal`), so the coordinator +gets the reason rather than an EOF; `EXECNET_IGNORE_VERSION_SKEW=1` in its +environment or its config `env:` downgrades that to a warning. + +### Five namespaces, one per concurrency library you drive execnet from + +| namespace | what it is | +|---|---| +| `execnet` / `execnet.sync` | the blocking API, a facade over the engine; top level aliases into `sync` | +| `execnet.trio` | `AsyncGroup`/`AsyncGateway`/`AsyncChannel` awaited in your own `trio.run`, bridged per call onto the engine | +| `execnet.aio` | the same surface for asyncio, the same bridge | +| `execnet.gevent` | the sync surface with gevent-parking waits | +| `execnet.raw_trio` | the engine-free one: gateways as tasks in *your* nursery | + +All of them load lazily via module `__getattr__`, so `import execnet` does +not import an event loop (pinned by `testing/test_namespaces.py`, which also +pins each surface's public member set — including what the facades +deliberately do *not* have). + +A `ProtocolEngine` is one thread running one loop; there is **one shared +engine per process**, `Group(engine=...)` to override. Starting stays lazy +— the thread appears at the first gateway — but `ProtocolEngine.start()` is +public, and entering one as a context manager calls it, so an application +can choose where a broken environment (gevent patching, a loop that will not +come up) reports itself. `close()` terminates the groups still on it and +warns; `terminate()` is that drain without the shutdown. Blocking calls +made from inside a running event loop raise and name `execnet.aio` / +`execnet.trio` — worker-side channels are exempt, since exec'd code may run +its own loop. + +### The CLI is the launch contract + +``` +execnet worker --protocol-stdio | --protocol-fd FD[,FD] + | --protocol-connect ADDR | --protocol-listen ADDR + | --protocol-share [--config-fd FD] + [--stdin/--stdout/--stderr DISPOSITION] +execnet server [HOST:PORT] [--once] +execnet info +``` + +Argv names a transport, nothing else. What the worker *is* — id, profile, +chdir, nice, `env:`, stdio disposition — arrives as one `GATEWAY_CONFIG` +frame on that transport, and the worker answers with one +(`{"ok": true, …}` or `{"ok": false, "error": …}`); see +`_handshake.py`. `--config-fd` is left only for the Windows `share` blob, +which describes the connection the frame would otherwise arrive on. + +`ADDR` is `unix:/path` or `host:port`. Everything that starts a worker +emits these tokens; there is no second launch path. `execnet info` +answers JSON — keys `execnet`, `trio`, `python`, `executable`, `platform`, +`protocols` — so provisioning learns a remote's version *before* +connecting. `protocols` is advisory today: nothing reads it, and it does +not list `share` (see roadmap item 1, which is about this payload). + +`transport=socket|stdio` is a spec key; **`socket` is the default for +every worker execnet spawns**, which is why a worker's stdio is free for +the code it runs. + +| gateway | handoff | +|---|---| +| popen, POSIX | `pass_fds` + `--protocol-fd` (socketpair) | +| popen, Windows | `socket.share(pid)` + `--protocol-share`, blob on stdin | +| `socket=` / `installvia=` | the same two, server-side; the server hands the accepted socket over without reading it, and the coordinator's config frame reaches the worker on it | +| `ssh=` / `vagrant_ssh=` | `ssh -R` unix socket, worker dials back (`--protocol-connect`); ssh's stdin is closed | +| `via=` | the sub's stdio, relayed over the coordinator's protocol | + +`--protocol-listen` has no user today; it is what a trampoline or a +port-forwarded worker would use (see the Kubernetes section of the +roadmap). ssh on Windows stays on stdio and cannot do otherwise: CPython +has never exposed `AF_UNIX` there (cpython#77589) and Win32-OpenSSH has no +`StreamLocal` forwarding. `resolve_transport` raises for an impossible +request rather than letting a gateway hang. + +### Worker profiles (`profile=`, spelled `execmodel=` before 3.0) + +| profile | loop thread | exec'd code runs | channel | extra deps | +|---|---|---|---|---| +| `thread` (default) | side thread | hybrid: the first `remote_exec` claims the worker's main thread, further ones overflow to pool threads | sync | — | +| `trio` | **main thread** | async sources as tasks, one thread total; sync sources rejected | `AsyncChannel` | — | +| `gevent` | side thread | a greenlet per `remote_exec` on a main-thread hub | sync | `execnet[gevent]`, auto-added by uv provisioning | +| ~~`main_thread_only`~~ | deprecated alias for `thread` | | | | + +`TrioWorkerExec` is a FIFO admission pump delegating to strategy objects +(`WORKER_EXEC_STRATEGIES`); subinterpreters are a future strategy slot, not +built. Admission is **bounded** for the thread-shaped strategies +(`exec_capacity()`, half the trio thread limiter) and a request over the +line is refused on its channel, not queued — reported as +`remote_status().execcapacity`, `None` where execs are tasks or greenlets +and cost no thread. Two ordering rules hold it together: nothing may wait +for an exec by parking a pool thread (that spends the budget it is +rationing), and the slot is released *before* the exec's channel close +goes out (that close is what tells a coordinator at capacity to send the +next request, so `waitclose(); remote_exec()` must not be refused for a +slot already freed). +`AsyncGroup.makegateway` defaults workers to `thread` — the coordinator's +shape does not dictate the worker's. + +### File map (src/execnet/) + +| file | role | +|---|---| +| `_message.py` / `_serialize.py` | wire protocol + sans-IO `FrameDecoder`; serializer (CHANNEL opcode incl. duck-typed `save_AsyncChannel`) | +| `_handshake.py` | the `GATEWAY_CONFIG` exchange, both directions: blocking for the worker (it runs before there is a loop), async over `ByteStream` for the coordinator | +| `_channel.py` / `_gateway_base.py` / `_errors.py` | sync `Channel`/`ChannelFactory`; `BaseGateway`/`WorkerGateway`; error types | +| `_trio_gateway.py` | **the engine**: `ByteStream` Protocol, `RawChannel`/`AsyncChannel`, `AsyncGateway` (outbound queue of `(frame, on_written)`, `_finalize` hook), `AsyncGroup` (all transports, reapers, bounded terminate), `ThreadedFdStream`, stream/argv helpers | +| `_trio_engine.py` | the loop thread itself: `TrioEngine` (start/stop, `call`, `start_soon`, `start_task`, the group registry) and `engine_call` | +| `_trio_host.py` | what runs *on* it: `SyncBridgeGateway`, `FacadeAsyncGroup`, `SyncIOHandle`, `RawTunnelStream`, `start_session`, the `GATEWAY_START_*` handlers | +| `_bridge.py` | `EngineBridge` + a `Carrier` per caller loop (asyncio, trio), `EngineGroup`, `targets_for_bridge` — what `execnet.trio` and `execnet.aio` are built out of | +| `_trio_worker.py` | worker entry, `TrioWorkerExec` + exec strategies, `_dup_protocol_fds`, the transports (blocking `connect()` + async `open()`), `_version_refusal` | +| `_boundary.py` / `_portal.py` | the (private) boundary kit: `Wakener`/`Mailbox`/`OneShot`/`Flag`, `LoopPortal` | +| `_engine.py` / `_gateway.py` / `_multi.py` | shared `ProtocolEngine`; sync `Gateway`; sync `Group` + `MultiChannel` | +| `sync.py` / `trio.py` / `raw_trio.py` / `aio.py` / `gevent.py` | the five public namespaces | +| `_cli.py` / `_socketserver.py` / `_provision.py` | the CLI, `execnet server`, uv provisioning + argv builders | +| `_execmodel.py` | `WORKER_PROFILES`, `resolve_profile`, and the deprecated `ExecModel` xdist shim | +| `_services.py` | the service seam: `GATEWAY_SERVICE` requests, a name→import-string registry resolved lazily, and `ServiceTarget` (the one thing the surfaces disagree about — where a channel id comes from) | +| `_deploy/` | transfers and deployments, built entirely on that seam. `_manifest` (walk a tree, compare two), `_transfer` (the async driver), `_run` (staging + the deploy steps), `serve` (both worker halves), `_api`/`_async_api`/`_facade` (the three surfaces) | +| `_rsync.py` | the deprecated `RSync`, now a thin adapter over the same transfer | +| `_rsync_remote.py` | dead: the pre-3.0 receiver, kept only so `execnet.rsync_remote` still resolves | +| `_xspec.py` / `_exec_source.py` | spec parsing, remote_exec source normalization | +| `_trace.py` / `_gevent_support.py` | `EXECNET_DEBUG` tracing; the gevent wait backend's hub plumbing | +| `__main__.py` / `_version.py` | `python -m execnet` → `_cli.main`; the generated version | +| `_shim.py` + `gateway*.py`, `multi.py`, `rsync*.py`, `xspec.py` | the deprecated pre-Trio module names, warning and forwarding | + +## Invariants — do not regress + +**Protocol and lifecycle** + +- Sends from non-loop threads block until the frame hit the OS write (120s + → `OSError`), so an abrupt `os._exit` cannot drop "sent" data; loop-thread + sends only enqueue. All sends go through one portal-posted FIFO. +- After close: `OSError("cannot send (already closed?)")`; + `trio.RunFinishedError` maps to the same. +- exec admission order == message arrival order (`TrioWorkerExec._pump`). + Trio shuffles its run batch, so never rely on task-spawn order. +- Channel callbacks run in a threadpool thread driven by a per-channel + consumer *task*: per-channel order is strict, a slow callback does not + block the reader, and `waitclose()` still returns only after every + callback including the endmarker has run. +- `Group.terminate(timeout)` never hangs (~2×timeout bound, issues + #43/#221). +- **A remotely closed channel leaves the gateway's registry.** Nothing can + arrive for that id again (ids step by two per side and are never reused), + so keeping it only grew a long-lived async coordinator by one dead channel + per `remote_exec` — the sync surface is protected by its weak registry, + `execnet.trio`/`execnet.aio` are not. The exception is a channel local + code has never asked for (`RawChannel._handed_out`): that one exists + *only* in the registry, and a passed-channel reference binding late has to + find the payloads and the close that arrived on it. +- Sync blocking waits (send-ack, receive, waitclose, join) stay on + `threading.Event`/queue so KeyboardInterrupt can interrupt them; + `portal.run` (KI-deferred) is only for management ops. +- A killed worker is `EOFError` on every transport — a dead peer *resets* + a socket where a pipe reaches EOF, and the reader maps that. +- **Every `engine.start_soon` entry point contains its own failures.** These + are tasks on the *root* nursery: an exception leaving one ends `trio.run` + and takes the process's gateways with it — and in a worker the + ExceptionGroup prints onto the user's stderr, which is theirs since 3.0. + `TrioWorkerExec._run_exec` and the socket/via handlers all catch; a new + entry point owes the same. The failure that finds this is dull: an exec + closing its channel after the connection went away. +- **Nothing posted through the portal may raise.** Trio turns an exception + from an entry-queue callback into `TrioInternalError` and tears the whole + run down, so one call losing a race with shutdown takes every gateway in + the process with it — and tells the user to file a trio bug. A posted + callback reports through its `OneShot`/future instead. +- An engine that goes away breaks what it served, loudly. + `ProtocolEngine.close()` is final (no second loop thread the existing + gateways are not on), and + nothing survives `os.fork()`: the parent loop's token still *accepts* + work in a child, so `LoopPortal` and `BaseGateway._check_usable` compare + pids and raise `ForkedResourceError` rather than let the child wait for a + reply nobody will send. Recovery after a fork is the child's, explicitly. + +**Launch and provisioning** + +- No source shipping, with no exceptions left: rsync was the last one, and + is now the `transfer` service rather than a `remote_exec` of the + receiver's source. Services claim no exec slot and work against a + `profile=trio` worker, which rejects sync sources and so could never run + the old receiver. +- **The protocol core names no feature.** One `GATEWAY_SERVICE` opcode, + and `_services._REGISTRY` maps a name to an import string. Adding a + service is a `register()` call on both ends, in or out of tree; there is + a test (`test_the_core_does_not_name_any_feature`) that greps the core + for the features built on it, prose included. +- **Service bodies are bounded to a quarter of the worker's thread pool** + (`_deploy/serve.service_limiter`). `exec_capacity` claims half and + reasons about leaving the rest to "the machinery that has to keep running + while execs are in flight" — services are that machinery, and were not in + that accounting. Unbounded they starve it: measured, 25 concurrent + transfers held 25 threads and pushed a 5ms `remote_exec` out to 3.1s. A + deployment with many roots does exactly that to its own worker. +- **A service channel's id comes from the sync factory on a coordinator.** + The sync `ChannelFactory` and the `AsyncGateway` counter both hand out + odd ids and *will* collide — `ServiceTarget.from_sync` allocates from the + factory, as the via transport does. Getting this wrong gives two + channels one id, which looks like arbitrary protocol corruption. +- **The worker config never travels in an argv, local or remote.** It is a + `GATEWAY_CONFIG` frame on the protocol stream, the same on every + transport (`_handshake.py`). It carries `env:` values, and `/proc` is + world-readable on the local machine exactly as `ps` is on a remote one. + Two properties fall out and are pinned by tests: a `via=` intermediary + never sees the config it relays, and a worker that refuses to serve + answers *on the wire*, so the reason reaches the caller instead of a + stderr that may be pointed anywhere. The one exception is the Windows + `share` blob, which describes the connection the frame would arrive on. +- **Nothing in `_provision` is called from the loop thread.** Deciding + *what* to launch runs subprocesses — an `execnet info` probe of a + `python=` target (30s timeout) and a dev coordinator's `uv build` + (seconds, cold) — and reads whole wheels off disk. Inline, that stalls + the loop: the caller's own `trio.run` for `execnet.raw_trio`, and for every + other surface the *shared* engine, i.e. every gateway in the process + including other groups'. Every call site goes through + `_trio_gateway.provision_sync`; measured at 0.76s before, pinned by + `test_provisioning_does_not_stall_the_loop`. The hop is deliberately + not `abandon_on_cancel`: the build populates a version-keyed wheel cache + that a half-written entry would poison for every later gateway. +- **Hand a socket over as a socket, never as an fd.** Rebuilding one with + `socket.socket(fileno=fd)` re-derives family/type/proto by querying the + handle, which PyPy on Windows fails with `WinError 10014`. +- **The Windows `share()` handoff is currently broken on CPython 3.14/3.15** + and nobody knows why yet. The worker's `fromshare()` returns a socket + whose handle its own process rejects (`WSAENOTSOCK`, 10038, raised by + trio's `setblocking(False)` in `adopt_socket`), so it dies before the + handshake; 3.10-3.13 on the same runner are fine, and so is every Linux + job. First seen in CI run 30685861676 (2026-08-01) after weeks of green + Windows runs, so treat it as a race that those two jobs' timing exposes + rather than a version feature. **Holding the coordinator's copy of the + socket open until the handshake does not fix it** — that theory (the blob + is not a socket until the child calls `fromshare()`) was tried and only + bought a 20s hang, because a worker that dies while we hold the pair open + produces no EOF. Needs a Windows box or a CI bisect; do not spend another + round on a theory that CI can refute in six minutes. + +**Failure modes that each cost a debugging round** + +- A socket worker that cannot be spawned must not hang the coordinator. + It is spawned by the *server*, so the exception dies there while the + coordinator waits for a handshake byte. A machine that cannot hand a + socket over refuses *before replying with an address* — the last moment + a reason can reach the coordinator — and a spawn that fails anyway + closes the connection so the wait ends. +- A failed socket gateway must not kill the gateway it was requested + through. It runs as a task on that coordinator's engine; letting it + propagate cost the coordinator too, which is how one unsupported + gateway became 51 errors. +- `_check_usable` (fork, then event loop) runs *before* the channel-state + check in `send`/`receive`. All of them are caller bugs, but which one you + were told about used to depend on whether the peer had closed yet. +- Anything that warns in a *worker* can livelock a pytest run: a warning + raised inside pytest's warning-recording hook records a warning. The + `execnet.dumps` shim warns once per process for exactly this reason. diff --git a/README.rst b/README.rst index 7624091a..baaac8fc 100644 --- a/README.rst +++ b/README.rst @@ -13,8 +13,8 @@ execnet: distributed Python deployment and communication .. image:: https://github.com/pytest-dev/execnet/workflows/test/badge.svg :target: https://github.com/pytest-dev/execnet/actions?query=workflow%3Atest -.. image:: https://img.shields.io/badge/code%20style-black-000000.svg - :target: https://github.com/python/black +.. image:: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json + :target: https://github.com/astral-sh/ruff .. _execnet: https://execnet.readthedocs.io @@ -29,7 +29,9 @@ a minimal and fast API targeting the following uses: Features -------- -* zero-install bootstrapping: no remote installation required! +* automatic provisioning: a target environment that lacks execnet is set up + with uv_, so no manual remote installation is required -- and no source of + our own is ever shipped over the wire * flexible communication: send/receive as well as callback/queue mechanisms supported @@ -40,5 +42,8 @@ Features * interoperable between Windows and Unix-ish systems. -* integrates with different threading models, including standard - os threads, eventlet and gevent based systems. +* one namespace per concurrency library you drive it from: threads + (``execnet``), trio (``execnet.trio``), asyncio (``execnet.aio``) and + gevent (``execnet.gevent``). + +.. _uv: https://docs.astral.sh/uv/ diff --git a/ROADMAP-3.0.md b/ROADMAP-3.0.md new file mode 100644 index 00000000..34d194d3 --- /dev/null +++ b/ROADMAP-3.0.md @@ -0,0 +1,627 @@ +# Road to execnet 3.0 + +State and invariants live in `HANDOFF.md`; the landed record lives in +`handoff-history.md`. This doc is what is *left*, and what the release is +for. + +## Why 3.0, not 2.2 + +The branch was drafted as 2.2. It is a major release: + +- the launch contract changed — a worker is `execnet worker ` + configured by a frame on that transport, and no source is shipped over + the wire at all any more (rsync was the last exception); +- the default transport changed — the protocol is a socket, not the + worker's stdin/stdout; +- a worker's stdio now belongs to the code it runs, so remote `print()` + reaches the coordinator instead of the null device; +- `execnet.script.*`, `dump`/`load`/`loads` and the pre-Trio module names + are gone or deprecated; a blocking call inside a running event loop now + raises; a killed worker is uniformly `EOFError`. + +Everything already in the tree that says "before execnet 3.0" (the +`execmodel=` → `profile=` rename, the worker config key) means this +release. + +### What 3.0 is *not* allowed to break + +**pytest-xdist keeps working, unmodified.** That is the headline +compatibility goal, and it is stronger than "the sync API still exists": +the currently released xdist must drive an execnet 3.0 coordinator with +no changes on its side. The deprecated shims therefore **ship in 3.0**. +They are removed later in the 3.x series, once the consumers that need +them have released a version that does not — not before. + +## The xdist contract + +Released pytest-xdist reaches into more of execnet than the documented +surface. Everything here is load-bearing until xdist stops using it: + +| what xdist does | where | +|---|---| +| `execnet.Group(execmodel="main_thread_only")` as a keyword | `workermanage.py` | +| prefixes specs with `execmodel=main_thread_only//`, **re-reading `spec.execmodel` to decide whether to prefix again** | `workermanage.py` | +| `execnet.gateway_base.ExecModel` → `RLock()`/`Event()` for the remote test queue | `remote.py` | +| `execnet.dumps` + `DumpError` to probe serializability, *inside pytest's warning-recording hook* | `remote.py` | +| subclasses `execnet.RSync`, touching `self._sourcedir` / `self._verbose` and overriding `filter` | `workermanage.py` | +| `gateway.spec.chdir`, and assigns `gateway.node` | `workermanage.py` | +| `execnet.makegateway("execmodel=main_thread_only//popen")` for looponfail | `looponfail.py` | + +Two rules fall out of that list and are recorded as invariants in +`HANDOFF.md`: normalization must not rewrite a caller's spec, and nothing +in a worker may warn unboundedly. + +**The tripwire is CI, not review.** `.github/workflows/test.yml` runs +xdist's own suite against the branch in two variants: a pinned `release` +target (xdist `v3.8.0` + `pytest<9`) that blocks, and a floating +`default-branch` target that may fail. Running it for the first time +found 16 real regressions our own suite could not see, because our suite +uses xdist as a *tool* and never exercises crash-replacement or report +serialization. Bump the pin and the pytest pin together, and only after +re-checking the new pair against *released* execnet. + +One known deselect (`.github/xdist-known-failures.txt`): +`test_remote_inner_argv` asserts `sys.argv == ["-c"]`, which the +no-source-shipping launch deliberately changed. It needs an xdist PR. + +## Before 3.0 ships + +Ordered by how expensive they are to undo afterwards. + +### 1. A neutral capability key in `execnet info` — the only irreversible one + +`_provision.target_has_execnet()` decides whether a `python=` interpreter +can host a worker directly by asking whether `info["trio"]` is non-null. +That is a *cross-version* contract: a 3.0 coordinator will keep asking a +3.5 worker that question forever, and the answer names our engine. + +Add `"worker": true` (or `"engines": ["trio"]`), have the coordinator +prefer it and fall back to `"trio"` only for a 2.x-vintage remote. Keep +emitting `"trio"` indefinitely. One line, and it buys the freedom to +answer honestly from an engine that is not Trio. + +Settle the rest of the payload in the same pass, since it is the same +cross-version contract. It is `execnet`, `trio`, `python`, `executable`, +`platform`, `protocols` today; `protocols` has no reader at all and does +not list `share`, so a Windows remote understates what it can do. Either +give it a reader or say in the docs that it is informational. + +### 2. Deprecated names out of `__all__` + +`set_execmodel` is advertised as supported API by `execnet.__all__` and +`execnet.sync.__all__`. Remove from both (it stays importable and +warning) and update `test_top_level_all_matches_sync_surface`. + +*Done* for the other half: `default_host` is `default_engine` and is no +longer in any `__all__` — isolation is `ProtocolEngine()`, sharing is the +default, and `Group.engine` reaches the shared one when you need it. + +### 3. Underscore the engine methods on `trio.AsyncGateway` — *done* + +`execnet.raw_trio.AsyncGateway` *is* `_trio_gateway.AsyncGateway`, so +`open_raw_channel` and `enqueue_frame` were public by accident — they are +the routing layer `_trio_host`/`_trio_worker` drive. Both are underscored +now, `open_channel` stays as the async `newchannel()`, and +`test_namespaces` pins the public method set of every surface. The +`execnet.trio`/`execnet.aio` facades do not expose any of them: the ids +come from an unlocked per-gateway counter that only works because one loop +owns it. + +### 4. `execnet.gevent` and monkey-patching — decide what we claim + +The facade works in a process that uses gevent *without* monkey-patching, +and its own promise holds there: blocking waits park the calling greenlet. +It does **not** work once `gevent.monkey` has patched the modules trio +reaches for from a side thread — which `TrioEngine.start` now refuses +outright rather than failing somewhere inside trio. Verified in every +variant: + +| patched | where it dies | +|---|---| +| `patch_all()` | `select.epoll` is removed; trio's IO manager cannot be built | +| `patch_all(select=False)` | trio's wakeup socketpair is a gevent socket -> `EBADF` | +| `patch_all(thread=False, socket=False, select=False)` | `queue.SimpleQueue` is gevent's; `from_thread.run` -> `LoopExit` | + +Which is a problem, because a real gevent application usually *does* +monkey-patch. The refusal is `_check_gevent_not_patched`, before the +thread exists, and the docs no longer imply patching is fine, so nothing +is silently broken. But "supported for gevent apps" is a bigger claim +than "works if you drive gevent explicitly", and only one of them is true +today. + +**Researched: can the host loop ignore the patches?** For trio, only at a +price nobody should pay; for asyncio, it is already free. + +Escaping gevent is harder than "use `monkey.get_original`", because the +originals are not self-contained: + +- The saved `socket.socketpair` *is* the real function, but its body looks + up `socket` in the module namespace it was defined in — which is the + patched one — so it still returns gevent sockets. +- Holding a reference taken *before* patching does not help either: + `gevent.monkey` sets `threading._CRLock = None` inside the real module, + so a pre-patch `threading.RLock` starts returning the Python lock the + moment the patch lands. +- Rebinding module globals inside trio (13 of them) misses everything + captured when a class body ran — `attrs.Factory(threading.RLock)` in + trio's entry queue is exactly that. +- And trio checks: `_entry_queue.task` asserts + `self.lock.__class__.__module__ == "_thread"`, deliberately, because + the alternative is "weird rare deadlocks". + +What does work is a *private* stdlib: re-execute `threading`, `socket`, +`queue`, `selectors` and `subprocess` from source with their C +dependencies presented unpatched, install them in `sys.modules` while trio +imports, then restore. Verified end to end — loop start, `from_thread` +run, `run_sync_soon`, `to_thread`, socketpair transport, `open_process`, +and the hub keeps ticking throughout. The price is a second `threading` +in the process: the engine thread does not appear in the application's +`threading.enumerate()`, `socket` identity splits in two (an +`isinstance(sock, socket.socket)` on a socket from user code no longer +means what it says, and `socket=`/`--protocol-share` take sockets from +user code), and interpreter-shutdown thread joining is split across two +registries. That is a lot of hidden seam for one namespace. + +**asyncio needs none of this.** Where gevent *deletes* `select.epoll`, it +*replaces* `selectors.DefaultSelector` with a hub-backed one — so an +asyncio loop in a monkey-patched process gets a working selector, patched +sockets work in whichever hub they land in, and asyncio asserts nothing +about primitive identity. Verified on a fully patched process, all six +paths execnet needs (call in, post, executor callbacks, socket IO, +`create_subprocess_exec`, hub not stalled), in **both** shapes: the loop +on a real OS thread, and the loop as a *greenlet on the application's own +hub* — no engine thread at all, which is the shape `execnet.gevent` would +want anyway. + +So the options are now: keep the honest limitation and document it (where +we are); adopt the private-stdlib trick and own its seams; or note that +"a non-Trio engine" below is not only an internals port — it is also what +makes `execnet.gevent` work in the environment gevent users actually have. +Decide before 3.0, because it is what the namespace promises. + +## What pins us to Trio + +**Measured, and less than it looked.** The engine seam is built and has a +second implementation: `ProtocolEngine(backend="asyncio")` runs +`_asyncio_engine.AsyncioEngine` on `asyncio.TaskGroup` (3.11+, refused +below that — no backport; 3.10 is EOL in October 2026), meeting the same +contract as `TrioEngine` and pinned by the same parametrized suite. What +is *not* ported is the core: `_trio_gateway` still uses nurseries, cancel +scopes and memory channels directly, so an asyncio engine refuses to build +gateways rather than failing inside trio. + +What the port needs, from an inventory of all 190-odd trio call sites: + +- ~34 of the core's 86 are stream/process/listener construction behind the + already-neutral `ByteStream` — four methods to implement, not a rewrite. +- ~60% of the rest have direct equivalents (`from_thread` → + `run_coroutine_threadsafe`, `CapacityLimiter` → `Semaphore`, + `open_memory_channel(inf)` → `Queue`, `checkpoint` → `sleep(0)`). +- ~26 sites are the exception vocabulary, wanting an execnet-owned set + mapped per backend. `LoopFinishedError` is the first of these. +- 3 sites need `nursery.start()`, which `TaskGroup` lacks; + `AsyncioEngine.start_task` already builds it with trio's semantics. +- The 19 shielded cleanup sites are the *cheapest* part, not the riskiest: + trio is level-triggered so the shield is mandatory there, asyncio is + edge-triggered and cleanup after catching `CancelledError` simply runs. + Verified under a `TaskGroup` aborting, `asyncio.timeout` and `wait_for` + — none of which cancel a second time. Keep the invariant that the engine + cancels once and then waits out a grace, and `shielded()` is faithful on + both. + +Cancellation *precision* is no longer load-bearing anywhere: only `receive` +could lose something, and salvage handles that at the bridge rather than by +being precise. The neutral vocabulary a ported core would be written +against is about a dozen names — task scope, shield, timeout, event, +limiter, inbox, to_thread, portal, checkpoint, byte stream, error set, run. + +The prize is the worker, not the coordinator: with a stdlib engine a worker +runs on bare Python, which removes most of what `_provision` exists to +arrange. It also fixes `execnet.gevent`, whose documented limitation is +trio's rather than execnet's (asyncio runs unmodified in a monkey-patched +process — measured, see the gevent item above). + +Two futures get conflated and have different answers: + +- **A non-Trio engine** — the engine thread runs `asyncio.run`, or execnet + drops the hard `trio` dependency. Half done: the engine is swappable, + the core is not. The remaining tripwire is that `_bridge` imports trio + at module level, so `execnet.aio` still cannot be imported without it. + + It has a second payoff, measured rather than assumed (see the gevent + item above): asyncio runs *unmodified* in a monkey-patched gevent + process where trio cannot, on a thread or as a greenlet. An asyncio + engine would hand `execnet.gevent` the environment its users actually + have, and could drop the engine thread there entirely. +- **A native asyncio surface** — `execnet.aio` running gateways on the + *caller's* loop with no engine, symmetric with `execnet.raw_trio`. + This one is visible in the API: `aio.AsyncGroup(engine=)` and + `aio.AsyncGroup.engine` would become meaningless there. The shape to + copy already exists: `execnet.trio` (facade) and `execnet.raw_trio` + (native) are exactly this split for trio, and a `raw_aio` would be the + same move. `engine=` is honest for the facade and stays. + +`ProtocolEngine` is engine-neutral in name and members; only its docstring +says Trio, which is accurate and would be a docs change. (It was called +`Host` until "host" turned out to mean three different things in one public +API — this class, the remote machine a deployment lands on, and the network +address in `socket=HOST:PORT`.) + +## Provisioning and workspaces: what the next xdist should stand on + +3.0's second goal is that the *next* pytest-xdist can stop hand-rolling +deployment. Today xdist does it itself, crudely: + +- one `execnet.Group`, specs prefixed by hand; +- `HostRSync` pushes each rsync root to `basename(root)` under the + gateway's chdir; +- `make_reltoroot()` rewrites command-line args to `root.name + "/" + rel` + and raises if an arg is not under a root; +- the remote interpreter is assumed to already have the project under test + installed — which is why remote xdist is mostly used against a shared + filesystem. + +What execnet should own instead, so xdist's version becomes a few calls: + +1. **Provision the environment, not just execnet.** *Done*, as + `execnet.Deployment` (`_deploy.py` + the `GATEWAY_DEPLOY` service): a + frozen `uv sync` from the project's lockfile, a wheel built here and + installed there, and the roots the wheel does not carry. Not done as + spec keys — it is a step *before* a gateway, because the worker is the + process that runs the tests and has to be inside the environment + already. Spec keys along the lines of `with=` would still + be worth having for the simpler "one extra dependency" case. +2. **Deploy a workspace, and hand back the mapping.** *Done*: + `Deployed.paths` maps each local root to where it landed, and + `Deployed.translate()` rewrites a path under one. Directory roots land + as their own basename under the workspace, file roots directly in it. +3. **rsync as a first-class operation.** *Done.* `execnet.transfer` (and + the `transfer` service behind it) replaces it: async-native, engine-run, + concurrent across targets, and available on all four surfaces. The + deprecated `RSync` is a ~50-line adapter over the same driver, so there + is one implementation; it goes when pytest-xdist stops subclassing it. + +**Decided, and built**: the worker is the test process, so it has to be +running inside the environment the project was installed into — which +means provisioning happens *before* it, through a gateway of its own. A +bootstrap gateway deploys over the protocol, and the workers are launched +afterwards against `Deployed.spec`. Everything travels over the gateway's +own transport, so the same code reaches a container or a pod. + +Of the open questions, three answered themselves in the building: + +- the deployment is a standalone object a gateway is handed, as `RSync` + is, rather than something on `Group` — a `Group` spans hosts, and a + deployment is per host; +- the mapping is a prefix swap over the deployed roots, and refuses a path + that is under none of them rather than passing it through (it would + otherwise name something real and unrelated on the remote); +- the workspace is caller-supplied or derived from a `name`, under + `~/.cache/execnet/workspaces` expanded *on the host*. Deployments + sharing a name share a workspace, which is what makes the second gateway + to a machine cheap. + +Still open: + +- **Flow control**, unchanged and now visible in one place: the transfer's + chunk loop bounds the memory a single file costs, but a fast sender + still outruns a slow receiver into its buffers. When the credit scheme + below lands, that loop is where it plugs in. +- **Cleanup**: nothing deletes a workspace. That is deliberate for now — + reuse is the point — but a long-lived host accumulates one per name, and + a coordinator that dies without terminating leaves it. +- **Concurrency**: two coordinators deploying the same name to one host at + the same time will race in `uv sync`. A lockfile in the workspace would + fix it; nothing does today. +- **How much is execnet's job** versus a thin xdist layer. The split as + built: execnet owns the environment, the transfer and the mapping; the + caller owns which roots matter and how to rewrite its own config. + +Doing this well is also what makes the Kubernetes goal tractable — a pod +is just a remote with no shared filesystem and a short life. + +## Exceptions: what a caller should be able to catch + +Unreleased, so this is a design decision rather than a migration. The goal +is that the three questions a caller actually asks each have an answer they +can `except` on, without string-matching: + +1. *did the other side fail?* — `RemoteError` +2. *is the connection gone?* — `OSError`, and one subclass per reason +3. *did I use the API wrong?* — not `OSError` + +Today the second and third are the same type, so a caller retrying on +connection loss also retries on its own bugs. + +### The one outright bug + +`execnet.TimeoutError` **shadows the builtin without subclassing it**: + +``` +execnet.TimeoutError.__mro__ -> TimeoutError, OSError, Exception +execnet.TimeoutError is TimeoutError -> False +except TimeoutError: -> does NOT catch it +``` + +A caller writing the obvious thing catches nothing; only `except OSError` +works. Since 3.11 `asyncio.TimeoutError` *is* the builtin, so async users' +instincts are actively wrong here. Fix: derive from the builtin (itself an +`OSError`, so everything catching `OSError` today keeps working). One line, +and it is the only change here that fixes a trap rather than sharpening a +distinction. + +### What is actually pinned + +3.0 is unreleased, so the only constraints come from execnet 2.x's public +error names and from what pytest-xdist catches -- and "xdist keeps working +unmodified" is a stated goal of this release. Grepped from the installed +3.8.0 rather than recalled: + +| xdist site | what it needs | +|---|---| +| `remote.py:328,361` | `execnet.DumpError` exists, and the serializer probe raises it | +| `looponfail.py:124` | `Channel.RemoteError` -- the *class attribute*, not the module name | +| `workermanage.py:379` | `except OSError:` around `sendcommand("shutdown")` | + +The third is load-bearing: **a send to a dead peer must be an `OSError`**, +or every xdist teardown starts raising. So `ChannelClosed`/`GatewayGone` +being `OSError` subclasses is a compatibility requirement, not a taste. + +execnet 2.1.1 exported `HostNotFound`, `RemoteError`, `TimeoutError`, +`DumpError`, `LoadError` and `DataFormatError`. Everything added during the +3.0 cycle -- `ProtocolEngine`, `ForkedResourceError`, `LoopFinishedError`, +`ActiveGroupsWarning` -- has never shipped and constrains nothing. + +### The shape to land on + +``` +Exception +├── RemoteError the other side raised; .formatted is its traceback +├── DataFormatError this value cannot cross a channel +│ ├── DumpError ...on the way out +│ └── LoadError ...on the way in +└── ExecnetStateError you called this at the wrong time or place + (was: bare RuntimeError, 51 sites) + +OSError +├── TimeoutError(builtins.TimeoutError) nothing arrived in time +├── ConnectionError +│ └── HostNotFound the remote could not be reached +├── ChannelClosed this channel is finished +├── GatewayGone(OSError, EOFError) the connection is finished +└── ForkedResourceError this object belongs to the parent process +``` + +`GatewayGone` inherits from both on purpose. A broken connection has always +surfaced as `EOFError`, which is documented behaviour and not an accident, +so narrowing `EOFError` to "the peer finished cleanly" would break 2.x-era +code that catches it to mean "the connection died". Inheriting from both +means `except EOFError:` and `except OSError:` each still catch it, and the +distinction becomes *available* without being taken away. Verified legal: +no layout conflict, both catches fire. + +`GatewayReceivedTerminate`, `LoopFinishedError` and `ActiveGroupsWarning` +stay internal and unchanged. + +### What moves, and why + +**`OSError` splits three ways.** It currently means four unrelated things +on a channel: the peer is gone (`_channel.py:413`), you already closed it +(`:349`), you registered a callback so `receive` is unavailable (`:366`), +and you called `close` inside a `remote_exec` (`:264`). The first is a +connection fact worth retrying; the rest are API misuse and never will be. +`ChannelClosed`/`GatewayGone` name the first, `ExecnetStateError` the rest. +Both new `OSError` subclasses, so `except OSError` keeps catching what it +catches now — nine `pytest.raises(OSError)` sites in the suite stay green. + +**`EOFError` gains a subclass rather than losing meaning.** It is raised +today both for "the peer finished cleanly" and for "the connection broke" -- +which is also the one place the two engines still disagree +(`TestEngineDestruction` pins trio's answer). A broken connection becomes +`GatewayGone`, which *is* an `EOFError`, so the divergence gets a name +without anything ceasing to be caught. + +**`RuntimeError` becomes `ExecnetStateError` where it means state.** Fifty- +one sites, most of them "engine closed", "group not started", "not on the +engine thread", "already started". A subclass of `RuntimeError`, so nothing +that catches it today changes. + +### The rule the port made necessary + +`_async` defines `ClosedResource`, `BrokenResource`, `EndOfChannel` and +`WouldBlock` as the neutral spelling of what a backend raises. **None of +them may reach user code.** One already did: `open_tcp_stream` translated a +*connect* failure into `BrokenResource`, which silently lost `HostNotFound` +until a test caught it. Worth a test that asserts nothing from `_async` +escapes the public surface, in the shape of the namespace-parity tests. + +### The one intentional break + +Two channel operations raise `OSError` today for API misuse rather than for +a connection fact: closing a channel inside its own `remote_exec` +(`_channel.py:264`) and calling `receive()` on a channel with a callback +registered (`:366`). These become `ExecnetStateError`, which is *not* an +`OSError` -- that is the entire point, since a caller retrying on connection +loss should not be retrying on their own bug. It is a real change from 2.x +and belongs in the changelog as one. Nothing in xdist catches either. + +### Follow-up: what to remove once xdist ports + +Three accommodations exist only because released xdist reaches for them. +Each is cheap, each is dead weight, and each should go in one commit once +xdist has released a version that does not need it: + +* `execnet.dumps` -- the serializability probe. Already tracked + (`execnet._XDIST_COMPAT`); replaced by `execnet.can_send`. +* `Channel.RemoteError` / `Channel.TimeoutError` as class attributes. The + module-level names are the API; these exist because `looponfail.py` writes + `self.channel.RemoteError`. +* `GatewayGone`'s `EOFError` base. Once nothing catches `EOFError` to mean + "the connection died", the type can say only what it means. + +None of them can be removed while "xdist keeps working unmodified" holds, so +this is a 3.x item, not a 3.0 one. + +### Order + +The `TimeoutError` base first and alone — it is a bug fix, not a design +change. Then the `OSError` split, which is where the value is. Then +`ExecnetStateError`, which is mechanical. Each is additive: every new type +subclasses what is raised today, so the suite is a regression check rather +than something to rewrite. + +## A protocol test that crosses both engines + +Not built. The port made every layer backend-agnostic and the suite runs +green on each engine *separately*, which is not the same as the two talking +to each other — and the wire is exactly where a difference would hide, +because nothing about a frame says which library wrote it. + +The shape: start a trio `execnet server` and an asyncio one, each on its own +thread in one process, then chain gateways through them with `via=` in a +nested loop — a trio coordinator through an asyncio relay to a trio worker, +and every other permutation — sending payloads through the whole chain. +What it would catch: framing or half-close differences, a transport that +only ever gets exercised same-engine, and the EOF-versus-reset behaviour the +two stream implementations report differently. + +Worth doing before anyone relies on a mixed fleet, which the deployment +story makes likely: a coordinator on whatever the developer has, workers on +whatever the pods have. + +## Kubernetes: test runs in a cluster over the protocol + +The goal is to drive a test run across pods in a Kubernetes cluster using +execnet's protocol, rather than a bespoke agent. Nothing here is built; +the design space, and what the branch already provides: + +**Getting a stream to a pod.** Three shapes, roughly in order of cost: + +1. *A command transport.* Generalize the ssh launcher to any argv that + yields a process whose stdio is the worker's protocol — e.g. + `kubectl exec -i POD -- execnet worker --protocol-stdio`. Cheapest, + works with any cluster access that `kubectl` has, and needs no new + code in the worker. Costs the worker's stdio and offers no dial-back. +2. *Listen plus a proxy.* The worker runs + `execnet worker --protocol-listen 0.0.0.0:0` and reports its address; + the coordinator connects through a `kubectl port-forward` (or directly, + where pod IPs are routable). This is precisely what `--protocol-listen` + was added for and it keeps the worker's stdio. **This is where "an + integrated Kubernetes proxy setup" belongs**: managing the forward's + lifetime, learning the port, and tearing it down. +3. *API-native.* Speak `pods/exec` (SPDY/WebSocket) from the coordinator, + no `kubectl` binary. A real dependency and a streaming adapter that + has to satisfy the `ByteStream` protocol — which is the point of that + protocol being neutral. + +**Getting code into the pod** is the workspace story above, unchanged: an +image with `uv`, execnet provisioned exactly as it is over ssh, the +project under test installed from a shipped wheel, and the tests rsynced. +Once the gateway exists, rsync over the established protocol is strictly +better than a second connection — no extra credentials, no second +authorization path. + +**The decision to take first**: does the pod and proxy machinery live in +execnet, or out of tree? It needs a Kubernetes client and cluster +credentials, which core does not want; but `AsyncGroup._make_gateway`, +`_open_via_stream` and `_resolve_socket_address` are *already* overridable, +so an out-of-tree transport is nearly possible today. Making that +extension point deliberate and documented may be the better 3.x +deliverable, with `execnet-kubernetes` on top. + +Other open questions: pod lifecycle (does execnet create a Job or attach +to something that exists?); image and Python selection; how a `Group` of N +pods maps onto scheduling; and cleanup when the coordinator dies, since a +cluster needs an owner reference or TTL and cannot rely on `terminate` +arriving. + +## Flow control: the channel has none + +A `send` never blocks and a peer never pushes back. The outbound queue is +unbounded, and the receiving end's reader drains the socket as fast as it +can into per-channel buffers that are unbounded too — so a fast producer +against a slow consumer is not throttled, it is *stored*. Measured: 500 +MiB sent in 0.33s with no blocking, all of it resident in the consumer's +mailbox (worker RSS 33 → 533 MiB). Nothing in the API says so; +`Channel.send`'s "possibly blocking if the sender queue is full" describes +2.1's write lock and is now never true. + +This is not new — 2.x's receiver thread queued just as eagerly — but the +async core is where it becomes fixable, and it is the same problem HTTP/2 +and HTTP/3 solved: per-stream and per-connection windows, a `WINDOW_UPDATE` +equivalent as the consumer drains, and the sender parking when the window +is exhausted. What that needs here: + +- a credit field in the message header or a new opcode (the protocol is + unversioned, so this is a wire change and belongs *before* the ecosystem + has more than one implementation of it); +- the sender's park has to work on all four surfaces — a trio task + awaiting, a blocking `send` on a wakener, gevent parking its greenlet; +- **reporting**, which is the part that makes it worth doing: how much a + channel has outstanding, how long a send waited, which peer is the slow + one. A hang that is really a full window must say so. + +Decide whether 3.0 ships the header space for it even if the mechanism +lands later; retrofitting a credit field into a shipped unversioned +protocol is the expensive version of this. + +## Deferred, and one rejection + +- **`execnet.aio` can drop an item when a `receive` is cancelled.** The + module docstring promises the opposite ("no item is consumed and + dropped"), and it is right about the common case: the cancel posts a + scope cancel to the host, which usually lands before the item is taken. + What it does not cover is the cancel arriving *after* the host task + produced the item — `_bridge.EngineBridge.call` then sees a cancelled + carrier and + drops the value it is holding. Fixing it means being able to put the + item back at the front of its channel, which the memory channel cannot + do; the honest interim is to narrow the docstring. Same shape as the + flow-control work above, and probably wants the same buffer rework. + +- **`execnet.anyio`** (the old Phase E) — a coordinator core on anyio with + an asyncio backend. Deferred, not cancelled; the portability invariants + keep the door cheap. +- **Async-surface gaps**: no `remote_status()`, no `MultiChannel`, no + group iteration, no `RSync` on `execnet.trio`/`execnet.aio`. Deliberate; + worth a documented line rather than silence. +- **`installvia` still needs a socket handoff at all.** It would not, if + the server spawned the worker as the *listener* + (`--protocol-listen 127.0.0.1:0`) and reported its address back: no + `pass_fds`, no `share()`, works on any interpreter, and the spawn happens + *before* the reply so failures are diagnosable by construction. The open + question is how the server learns the port. Standalone `execnet server` + still needs the handoff — it has already accepted the connection. +- **eventlet** stays dead. **Subinterpreters** are a strategy slot, not a + plan. +- **A trampoline process was considered and rejected**: the design already + frees the worker's stdio in-process, and a pump's own stdio side is still + a blocking pipe, so it relocates the thread rather than removing it — at + the cost of a process and two copies per message. Do not revisit without + a new reason. +- **Unverified**: whether the socket/`installvia` path works on the Windows + CI job at all. Assume any platform CI has not exercised is broken. +- **Windows `socket=`/`popen` share handoff, 3.14/3.15**: red in CI, cause + unknown (see the invariant in `HANDOFF.md`). Blocking for the Windows + half of the release; the first move is a bisect over this branch's recent + commits, since those jobs were green earlier the same day. +- **The server-side `share()` handoff also races on paper.** + `serve_socket_connection` closes the accepted socket when the spawn + returns, which can beat the worker's `fromshare()` — and it cannot wait + for the handshake, which goes to the coordinator, nor simply close late, + which would keep a dead worker's connection open and cost the coordinator + its EOF. The fix would be a marker byte from the worker once it has + adopted; the price is that a socket worker's stdout becomes a pipe to its + server rather than the user's. Unproven either way — the popen path shows + early closing is not the whole story. + +## Suggested order + +1. `execnet info` capability key, `__all__` cleanups, underscore the engine + methods — small, and item 1 cannot be changed after release. +2. Undraft PR #422. (The changelog and docs are renumbered already.) +3. Decide the execnet/xdist split for provisioning + workspaces, then build + points 1–3 of that section. This is what the next xdist waits on. +4. Kubernetes: decide in-tree versus extension point, then the proxy. +5. Remove the shims — later in 3.x, gated on xdist having released without + them, with the CI `release` target as the check. diff --git a/doc/api.rst b/doc/api.rst new file mode 100644 index 00000000..b77aa547 --- /dev/null +++ b/doc/api.rst @@ -0,0 +1,252 @@ +============================================================================== +Namespace reference +============================================================================== + +One namespace per concurrency library you drive execnet *from*. They all +speak the same protocol to the same kind of worker; see :doc:`basics` for +gateway specifications, channels and groups, which are common to all of +them. + +Four of them put protocol IO on a :class:`~execnet.ProtocolEngine` -- one +thread running a loop of its own, shared by the whole process -- and +differ only in how the caller waits for it: blocking the thread +(:mod:`execnet.sync`), parking a greenlet (:mod:`execnet.gevent`), or +awaiting on the caller's own loop (:mod:`execnet.trio`, +:mod:`execnet.aio`). :mod:`execnet.raw_trio` is the exception: it has no +engine, and runs the gateways as tasks in your own nursery. See +:ref:`trio-or-raw-trio` for what that buys and costs. + +.. _execnet-sync: + +execnet.sync -- blocking +============================================================================== + +.. module:: execnet.sync + +The blocking API for plain threads, and the surface ``import execnet`` +gives you: the top-level ``execnet.*`` names are aliases into this module. +It is what :doc:`basics` documents. + +Calls block the calling thread while a protocol engine does the protocol +IO, so calling one from inside a running asyncio or trio event loop raises +``RuntimeError`` rather than stalling every task on that loop. Use +:mod:`execnet.aio` or :mod:`execnet.trio` there. + +.. autoclass:: execnet.ProtocolEngine + :members: start, running, terminate, close + +Getting a project onto a host +------------------------------------------------------------------------------ + +A worker on a machine that shares no filesystem with the coordinator needs +the project before it can run anything -- and because a worker *is* the +process that runs the tests, it has to already be inside the environment +the project was installed into. Provisioning therefore happens through a +gateway of its own, and the workers come afterwards -- usually spawned +*through* that same gateway, so there is one connection per machine:: + + host = group.makegateway("ssh=host//id=h1") + target = execnet.Deployment(".", roots=["testing"]).deploy(host) + + for index in range(4): + group.makegateway(f"via=h1//{target.spec}//id=w{index}") + +.. autofunction:: execnet.transfer + +.. autoclass:: execnet.Deployment + :members: deploy, deploy_all + +.. autoclass:: execnet.Deployed + :members: spec, translate, workspace, python, paths + +:class:`execnet.RSync` still works and is what pytest-xdist uses, but it is +deprecated in favour of :func:`execnet.transfer`: it is now a thin adapter +over the same transfer, and only its optional ``callback`` behaves +differently (it is handed the gateway rather than a channel, and reports a +file when it is sent rather than when the far side confirms it). + + +.. _execnet-trio: + +execnet.trio -- trio +============================================================================== + +.. automodule:: execnet.trio + +.. autoclass:: execnet.trio.AsyncGroup + :members: start, aclose, makegateway, engine + +.. autoclass:: execnet.trio.AsyncGateway + :members: remote_exec, terminate + +.. autoclass:: execnet.trio.AsyncChannel + :members: send, receive, send_eof, aclose, wait_closed, isclosed + +.. autofunction:: execnet.trio.open_gateway + +Transfers and deployments are awaited here rather than blocking, and a +fan-out across gateways runs concurrently: + +.. autofunction:: execnet.trio.transfer +.. autofunction:: execnet.trio.deploy +.. autofunction:: execnet.trio.deploy_all + + +.. _execnet-raw-trio: + +execnet.raw_trio -- trio, without an engine +============================================================================== + +.. automodule:: execnet.raw_trio + +.. autoclass:: execnet.raw_trio.AsyncGroup + :members: makegateway + +.. autoclass:: execnet.raw_trio.AsyncGateway + :members: remote_exec, terminate + +.. autoclass:: execnet.raw_trio.AsyncChannel + :members: send, receive, send_eof, aclose, wait_closed, isclosed + +.. autofunction:: execnet.raw_trio.open_gateway +.. autofunction:: execnet.raw_trio.transfer +.. autofunction:: execnet.raw_trio.deploy +.. autofunction:: execnet.raw_trio.deploy_all + +.. _trio-or-raw-trio: + +Which trio surface +------------------------------------------------------------------------------ + +Both are trio and both are awaited in your own ``trio.run``. The +difference is where the gateways live, and it is not a detail: + +.. list-table:: + :header-rows: 1 + :widths: 22 39 39 + + * - + - ``execnet.raw_trio`` + - ``execnet.trio`` + * - Gateway lifetime + - a task in *your* nursery; cannot outlive the ``async with`` that + made it + - owned by the engine; a handle you can store and close from + anywhere + * - Cancelling ``receive`` + - exact -- the item is never taken at all + - cancels the engine-side receive too; an item taken before the + cancel landed is kept for your next ``receive``, so nothing is lost + either way + * - ``shield``\ ed calls + - not applicable -- there is nothing to shield across + - ``send``, ``send_eof``, ``aclose``, ``terminate``: the wait is + uncancellable and returns once done + * - A stalled caller loop + - stalls protocol IO for every gateway on it + - the engine keeps reading + * - An execnet failure + - lands in your nursery and cancels its siblings + - stays on the engine + * - ``trio.to_thread`` budget + - shared: a transfer's file reads compete with your own thread work + - separate; execnet's threads are the engine's + * - Cost per operation + - a direct await + - a hop to the engine and back, per call + * - Other surfaces in the process + - none: this run is the only place these gateways exist + - one engine also serves ``sync``, ``gevent`` and ``aio`` + +Reach for ``execnet.raw_trio`` when execnet is most of what your loop does +and you want exact cancellation with no hop. Reach for ``execnet.trio`` +for an application that happens to use execnet -- which is also the one to +pick if you are not sure. + + +.. _execnet-aio: + +execnet.aio -- asyncio-native +============================================================================== + +.. automodule:: execnet.aio + +.. autoclass:: execnet.aio.AsyncGroup + :members: start, aclose, makegateway, engine + +.. autoclass:: execnet.aio.AsyncGateway + :members: remote_exec, terminate + +.. autoclass:: execnet.aio.AsyncChannel + :members: send, receive, send_eof, aclose, wait_closed, isclosed + +.. autofunction:: execnet.aio.open_gateway +.. autofunction:: execnet.aio.transfer +.. autofunction:: execnet.aio.deploy +.. autofunction:: execnet.aio.deploy_all + + +.. _execnet-gevent: + +execnet.gevent -- blocking, greenlet-parking +============================================================================== + +.. module:: execnet.gevent + +Identical to :mod:`execnet.sync` except that every blocking wait parks the +calling *greenlet* rather than its OS thread, so a slow ``receive`` no +longer stalls the whole hub:: + + import execnet.gevent + + group = execnet.gevent.Group() + gateway = group.makegateway("popen") + channel = gateway.remote_exec("channel.send(6 * 7)") + print(channel.receive()) # parks this greenlet, not the hub + +Requires ``execnet[gevent]``. ``Group``, ``default_group`` and +``makegateway`` are this module's own; the remaining names (``Channel``, +``Gateway``, ``RSync``, ``Deployment``, ``transfer``, the error types) are +the ones from :mod:`execnet.sync`. + +Importing it monkey-patches nothing, and the process must not have +monkey-patched either: protocol IO is a Trio loop on its own OS thread and +needs the real ``select`` (for ``epoll``), ``socket``, ``thread`` and +``queue``, which ``gevent.monkey`` replaces process-wide. You do not need +patching here -- the waits above park the calling greenlet because they +wait on a gevent primitive. Starting a host in a patched process is +refused up front, with an error naming what was patched, rather than +failing later somewhere inside trio. + +This is about the *caller*. Whether the worker itself runs greenlets is +the independent ``profile=gevent`` spec key -- see +:ref:`worker profiles `. + + +Errors +============================================================================== + +The same types are raised by every namespace, and are re-exported from each +of them. They answer three different questions, and it is worth catching +the one you mean: + +**Did the other side fail?** + +.. autoexception:: execnet.RemoteError + +**Is the connection gone?** All of these are ``OSError`` subclasses, so +``except OSError`` catches every reason at once. + +.. autoexception:: execnet.GatewayGone +.. autoexception:: execnet.ChannelClosed +.. autoexception:: execnet.HostNotFound +.. autoexception:: execnet.TimeoutError + +**Did the call itself go wrong?** + +.. autoexception:: execnet.ExecnetStateError +.. autoexception:: execnet.DataFormatError +.. autoexception:: execnet.DumpError +.. autoexception:: execnet.LoadError + +.. autoexception:: execnet.ActiveGroupsWarning diff --git a/doc/basics.rst b/doc/basics.rst index 78672647..68916935 100644 --- a/doc/basics.rst +++ b/doc/basics.rst @@ -12,6 +12,63 @@ help to manage creation and termination of sub-interpreters. .. currentmodule:: execnet + +Namespaces +=============================================== + +execnet has one namespace per concurrency library you drive it *from*. All +of them speak the same protocol to the same kind of worker; what differs is +what a waiting call does to the caller. + +:mod:`execnet.sync` + The blocking API for plain threads. The top-level ``execnet.*`` names + are aliases into it, so ``import execnet`` is this surface. + +:mod:`execnet.trio` + ``AsyncGroup``, ``AsyncGateway``, ``AsyncChannel``, awaited inside your + own ``trio.run``. + +:mod:`execnet.aio` + The same three classes for asyncio, awaited inside your own event loop. + +:mod:`execnet.gevent` + The blocking API again, except that every wait parks the calling + *greenlet* rather than its OS thread. Needs ``execnet[gevent]``. + +:mod:`execnet.raw_trio` + execnet embedded in your own trio run: the gateways are tasks in your + nursery and there is no engine at all. See + :ref:`trio-or-raw-trio` for when to prefer it over + :mod:`execnet.trio`. + +:mod:`execnet.raw_trio` is the only surface that runs gateways *directly*, +as tasks in your own nursery. The others run protocol IO on a protocol +engine (see `The protocol engine`_); the two blocking ones then block the +caller until it answers, which inside a running event loop would stall +every task on it, so those calls raise ``RuntimeError`` naming the +namespace to use instead. + +:: + + import trio + import execnet.trio + + async def main(): + async with execnet.trio.AsyncGroup() as group: + gateway = await group.makegateway("popen") + channel = await gateway.remote_exec("channel.send(6 * 7)") + print(await channel.receive()) + + trio.run(main) + +The rest of this page shows the blocking API. Apart from ``async``/``await`` +and the ``Async`` prefix, the async namespaces mirror it; see +:doc:`the namespace reference ` for what each one offers. + +This is about the *caller*. Where remote code runs inside the worker is an +independent choice -- see `Worker profiles`_. + + Gateways: bootstrapping Python interpreters =================================================== @@ -24,15 +81,24 @@ passing it a gateway specification or URL. Here is an example which instantiates a simple Python subprocess:: + >>> import execnet >>> gateway = execnet.makegateway() Gateways allow to `remote execute code`_ and `exchange data`_ bidirectionally. +Workers are never sent their own source code: a worker imports the +``execnet`` (and ``trio``) that is installed in the environment it runs in. +Where that environment does not have execnet yet, it is provisioned with +uv_ -- so a bare ``python=`` interpreter or an ssh remote needs ``uv`` on +its ``PATH``, not a pre-installed execnet. + +.. _uv: https://docs.astral.sh/uv/ + Examples for valid gateway specifications ------------------------------------------- -* ``ssh=wyvern//python=python3.3//chdir=mycache`` specifies a Python3.3 +* ``ssh=wyvern//python=python3.13//chdir=mycache`` specifies a Python 3.13 interpreter on the host ``wyvern``. The remote process will have ``mycache`` as its current working directory. @@ -43,8 +109,8 @@ Examples for valid gateway specifications ``default`` via SSH through Vagrant's ``vagrant ssh`` command. It supports the same additional parameters as regular SSH connections. -* ``popen//python=python2.7//nice=20`` specification of - a python subprocess using the ``python2.7`` executable which must be +* ``popen//python=python3.13//nice=20`` specification of + a python subprocess using the ``python3.13`` executable which must be discoverable through the system ``PATH``; running with the lowest CPU priority ("nice" level). By default current dir will be the current dir of the instantiator. @@ -57,16 +123,76 @@ Examples for valid gateway specifications same interpreter as the one it is initiated from and additionally remotely sets an environment variable ``NAME`` to ``value``. -* ``popen//execmodel=eventlet`` specifies a subprocess that uses the - same interpreter as the one it is initiated from but will run the - other side using eventlet for handling IO and dispatching threads. +* ``socket=192.168.1.4:8888`` specifies a Python server process that + listens on ``192.168.1.4:8888``. Such a server is started with the + ``execnet server`` command, e.g. run anywhere with + ``uvx --from execnet execnet server :8888``; see + :ref:`instantiate gateways through sockets `. + +.. _spec-keys: -* ``socket=192.168.1.4:8888`` specifies a Python Socket server - process that listens on ``192.168.1.4:8888`` +Specification keys +------------------------------------------- + +*Which interpreter to reach, and how* + +``popen`` + A subprocess of this process. The default when no other target is given. + +``python=PATH`` + The interpreter to run, as a path or a ``PATH``-discoverable name. + Combined with ``popen``, ``ssh=`` or ``vagrant_ssh=``. + +``ssh=ARGS`` + Run the worker on a host reachable by the ``ssh`` client binary. The + value is passed to it as arguments, so ``ssh=-p 5000 myhost`` works. + +``ssh_config=PATH`` + An ssh configuration file to pass as ``-F PATH``. + +``vagrant_ssh=NAME`` + Like ``ssh=``, through ``vagrant ssh`` for the named box. + +``socket=HOST:PORT`` + Connect to a running ``execnet server`` and have it spawn the worker. + +``via=GATEWAY-ID`` + Create this gateway's connection *on* another gateway of the same group, + which then relays for it (see :doc:`proxy examples `). + +``installvia=GATEWAY-ID`` + Start a socket server through the named gateway and connect to it. + +*What the worker looks like* -.. versionadded:: 1.5 +``profile=thread|trio|gevent`` + Where exec'd code runs inside the worker; see `Worker profiles`_. + ``execmodel=`` is an accepted older spelling of the same key. -* ``vagarant_ssh`` opens a python interpreter via the vagarant ssh command +``transport=socket|stdio`` + Which stream carries the protocol; see `Transports`_. + +``stdin=``, ``stdout=``, ``stderr=`` + What the worker does with its standard fds; see `Worker output`_. + +``id=NAME`` + The gateway's id within its group, instead of an allocated ``gwN``. + +``chdir=PATH`` + Working directory of the worker. Defaults to the instantiator's + directory for ``popen``, and to the login home directory for ``ssh=``. + +``nice=N`` + Run the worker at that ``nice`` level (POSIX). + +``dont_write_bytecode`` + Pass ``-B`` to the worker interpreter. + +``env:NAME=value`` + Set an environment variable in the worker. May be repeated. + +Keys are separated by ``//``, may not repeat, and a key without ``=value`` +means ``True``. .. _`remote execute code`: @@ -74,7 +200,7 @@ Examples for valid gateway specifications remote_exec: execute source code remotely =================================================== -.. currentmodule:: execnet.gateway +.. currentmodule:: execnet All gateways offer a simple method to execute source code in the instantiated subprocess-interpreter: @@ -88,10 +214,6 @@ a channel object whose symmetric counterpart channel is available to the remotely executing source. -.. method:: Gateway.reconfigure([py2str_as_py3str=True, py3str_as_py2str=False]) - - Reconfigures the string-coercion behaviour of the gateway - .. _`Channel`: .. _`channel-api`: @@ -100,7 +222,7 @@ is available to the remotely executing source. Channels: exchanging data with remote code ======================================================= -.. currentmodule:: execnet.gateway_base +.. currentmodule:: execnet A channel object allows to send and receive data between two asynchronously running programs. @@ -120,7 +242,7 @@ two asynchronously running programs. Grouped Gateways and robust termination =============================================== -.. currentmodule:: execnet.multi +.. currentmodule:: execnet All created gateway instances are part of a group. If you call ``execnet.makegateway`` it actually is forwarded to @@ -138,45 +260,199 @@ processes then you often want to call ``group.terminate()`` yourself and specify a larger or not timeout. -threading models: gevent, eventlet, thread, main_thread_only +.. _worker-profiles: + +Worker profiles ==================================================================== -.. versionadded:: 1.2 (status: experimental!) +.. versionchanged:: 3.0 + The ``execmodel=`` key is now spelled ``profile=`` and only ever + described the *worker*. The local execution model it was named after + no longer exists: see `Namespaces`_ for the local choice. -execnet supports "main_thread_only", "thread", "eventlet" and "gevent" -as thread models on each of the two sides. You need to decide which -model to use before you create any gateways:: +A worker's profile says where the code you ``remote_exec`` runs relative to +the worker's own protocol loop. Pass it per gateway:: + + >>> import execnet + >>> gw = execnet.makegateway("popen//profile=trio") + +``thread`` (the default) + Exec'd code runs on the worker's main thread while that is free, and on + pool threads for anything concurrent with it. The *first* + ``remote_exec`` always gets the real main thread, which is what GUI + loops and signal handlers need. + +``trio`` + Exec'd code runs as a task on the worker's own Trio loop, in the single + thread of that process, and is handed an ``AsyncChannel``. Sources must + be async -- a plain function, or a source string with no top-level + ``await``, is rejected rather than allowed to starve the loop. + +``gevent`` + Exec'd code runs as a greenlet on a gevent hub owning the worker's main + thread, so concurrent execs cooperate on that one thread. Provisioning + adds the ``gevent`` requirement to the worker environment. + +``main_thread_only`` is deprecated and now behaves like ``thread``, whose +main-thread claim is what it existed for. Its other behaviour is gone: a +second concurrent ``remote_exec`` used to fail the channel with +``concurrent remote_exec would cause deadlock``, and now runs on a pool +thread. + +Set the default for a whole group with ``Group(profile=...)`` or +``group.set_profile(...)``; ``execnet.set_profile(...)`` sets it on the +default group. + +How many at once +------------------------------------------------------- + +.. versionadded:: 3.0 + +Under ``thread`` each exec needs a thread of the worker's thread budget, +which also has to serve channel callbacks and the worker's own protocol +work -- so a worker admits **half that budget** in concurrent +``remote_exec`` calls (20, unless the worker changed trio's default +limiter) and *refuses* the one after that with a ``RemoteError`` naming the +limit. ``remote_status().execcapacity`` reports the number. + +Refusing rather than queueing is deliberate: a request waiting for a thread +that only a finishing exec can free is indistinguishable, from the +coordinator, from an exec that hung. For genuine fan-out use more gateways +-- that is what a ``Group`` is for -- or a profile whose execs are not +threads. ``trio`` and ``gevent`` are unbounded here (``execcapacity`` is +``None``): their execs are tasks and greenlets, and spend no thread. + + +Transports +==================================================================== + +.. versionadded:: 3.0 + +The Message protocol does not have to be the worker's stdin/stdout. The +``transport=`` key selects: + +``socket`` (the default) + The worker gets a socket of its own for the protocol. For ``popen`` it + is an inherited socketpair (a socket duplicated with ``socket.share()`` + on Windows); for ``ssh=``/``vagrant_ssh=`` it is a unix socket forwarded + with ``ssh -R`` that the worker dials back on. + +``stdio`` + The classic transport: the protocol *is* the worker's stdin/stdout. + +Requesting ``transport=socket`` where it cannot work is an error at +``makegateway`` time naming the platform, rather than a gateway that waits +for a worker which was never able to reach back. That case is ssh on +Windows, where CPython does not expose ``AF_UNIX`` and Win32-OpenSSH does +not implement ``StreamLocal`` forwarding. + + +.. _worker-output: + +Worker output +==================================================================== + +.. versionchanged:: 3.0 + A worker's stdio belongs to the code it runs. It used to be redirected + to the null device, so a remote ``print()`` went nowhere at all. + +With the socket transport the worker leaves fd 0/1/2 alone: remote output +reaches your terminal (or your ``capfd``), and remote code can read *your* +stdin. With ``transport=stdio`` the protocol needs those fds, so the worker +closes stdin and folds its stdout onto stderr instead of discarding both. + +Override any of it per gateway: + +=========== ========================================== ================= +key values default +=========== ========================================== ================= +``stdin=`` ``inherit``, ``close``, ``devnull`` transport-defined +``stdout=`` ``inherit``, ``devnull``, ``stderr`` transport-defined +``stderr=`` ``inherit``, ``devnull`` transport-defined +=========== ========================================== ================= + +For example ``popen//stdin=devnull`` gives remote code an empty stdin while +keeping its output visible. - # content of threadmodel.py - import execnet - # locally use "eventlet", remotely use "thread" model - execnet.set_execmodel("eventlet", "thread") - gw = execnet.makegateway() - print (gw) - print (gw.remote_status()) - print (gw.remote_exec("channel.send(1)").receive()) -You need to have eventlet installed in your environment and then -you can execute this little test file:: +The protocol engine +==================================================================== + +.. versionadded:: 3.0 + +Protocol IO runs on a :class:`ProtocolEngine`: one OS thread running a loop +of its own, shared by every group in the process and stopped at interpreter +exit. Every surface uses it except :mod:`execnet.raw_trio`, which runs +gateways as tasks in the caller's own trio nursery instead. + +You need to know it exists in three cases. It is why a blocking call from +inside a running event loop is an error. It is what you pass when you want +an isolated loop with deterministic teardown. And it is what you close:: + + with execnet.ProtocolEngine() as engine: + group = execnet.Group(engine=engine) + ... + group.terminate() + # the thread is joined here, rather than at interpreter exit + +Terminate your groups before closing, as above. If you do not, closing +does it for you and warns +(:class:`~execnet.ActiveGroupsWarning`): the workers are real +processes, and once the loop that speaks to them is gone nothing else is +going to reap them. The warning is because close time is the worst moment +to discover a worker that will not go quietly -- there is nowhere left to +report it. :meth:`ProtocolEngine.terminate` is the same drain without the +shutdown, for when you would rather do it where you can act on the result. + +What closing cannot do is keep those groups working. Their protocol IO no +longer has a loop to run on, so their channels reach EOF, sending raises, +and the groups refuse to make new gateways. Closing is final: an engine +cannot be reopened, and a group whose engine went away needs a new engine +and a new group rather than quietly getting a second loop thread that none +of its gateways are attached to. + +The loop is trio by default. ``ProtocolEngine(backend="asyncio")`` builds +one on asyncio instead, on Python 3.11 or newer -- but only the trio engine +can host gateways today, and an asyncio one says so when you try. It +exists so that the boundary between execnet and the async library under it +is a tested one; see :doc:`implnotes` for what remains. + +``os.fork()`` is the same situation arriving by surprise: the loop thread is +not duplicated into the child and the worker connections belong to the +parent, so every group, gateway and channel the child inherits is dead there +and raises rather than waiting on a loop that will never run again. A child +that wants gateways of its own builds a new group -- and gets a fresh engine +with it. + + +The execnet command line +==================================================================== - $ python threadmodel.py - - - 1 +.. versionadded:: 3.0 -How to execute in the main thread ------------------------------------------------- +``execnet server [HOST:PORT] [--once]`` + Accept gateway connections on a socket and hand each to a fresh worker + subprocess -- the bootstrapping point for ``socket=`` gateways. See + :ref:`instantiate gateways through sockets `. This + replaces the ``execnet-socketserver`` console script, which still works + and forwards here with a ``DeprecationWarning``. -When the remote side of a gateway uses the "thread" model, execution -will preferably run in the main thread. This allows GUI loops -or other code to behave correctly. If you, however, start multiple -executions concurrently, they will run in non-main threads. +``execnet info`` + Print this interpreter's execnet version, trio availability, Python + version, executable, platform and supported protocols as JSON. A + coordinator uses it to decide whether a ``python=`` interpreter can host + a worker directly. + +``execnet worker ...`` + The launch contract between a coordinator and the worker process it + starts. You do not run this by hand; it is documented in + :doc:`implnotes`. remote_status: get low-level execution info =================================================== -.. currentmodule:: execnet.gateway +.. currentmodule:: execnet All gateways offer a simple method to obtain some status information from the remote side. @@ -185,7 +461,17 @@ information from the remote side. Calling this method tells you e.g. how many execution tasks are queued, how many are executing and how many -channels are active. +channels are active:: + + >>> import execnet + >>> gw = execnet.makegateway() + >>> gw.remote_status() + + +``execmodel`` repeats ``profile`` under its old name. ``execcapacity`` is +how many concurrent ``remote_exec`` calls this worker admits before +refusing (see `Worker profiles`_); it is ``None`` for ``profile=trio``, +whose execs are tasks rather than threads. rsync: synchronise filesystem with remote =============================================================== @@ -213,29 +499,73 @@ Debugging execnet By setting the environment variable ``EXECNET_DEBUG`` you can configure a tracing mechanism: -:EXECNET_DEBUG=1: write per-process trace-files to ``execnet-debug-PID`` +:EXECNET_DEBUG=1: write per-process trace-files to ``execnet-debug-PID`` in the system temp directory :EXECNET_DEBUG=2: perform tracing to stderr (popen-gateway workers will send this to their instantiator) +See :doc:`the debugging example ` for what a trace +looks like. + .. _`dumps/loads`: .. _`dumps/loads API`: +.. _`serialization`: -Cross-interpreter serialization of Python objects +Sending objects over a channel ======================================================= -.. versionadded:: 1.1 +A channel carries only **simple builtin data**: ``None``, ``bool``, +``int``, ``float``, ``complex``, ``bytes``, ``str`` and arbitrarily nested +``list`` / ``tuple`` / ``set`` / ``frozenset`` / ``dict`` of those -- plus +**channel references**, which arrive as channels on the peer. That is the +entire contract. -Execnet exposes a function pair which you can safely use to -store and load values from different Python interpreters -(e.g. Python2 and Python3, PyPy and Jython). Here is -a basic example:: +execnet does **not** pickle and does **not** encode rich objects for you: +arbitrary instances, functions, ``datetime``, dataclasses, pydantic models, +numpy arrays, enums, etc. have no wire representation. This is deliberate; +encoded / rich-object channels are out of scope for execnet. - >>> import execnet - >>> dump = execnet.dumps([1,2,3]) - >>> execnet.loads(dump) - [1,2,3] +Sending an unsupported value raises ``DumpError`` (a subclass of +``DataFormatError``); a corrupt or protocol-mismatched payload on receive +raises ``LoadError``. These signal a **caller error to resolve** -- reduce +the value to simple data before sending -- not a transport failure. The +standalone serializer itself is an internal implementation detail and is not +part of the public API. + +To branch *before* sending rather than handling the error, ask: + +.. autofunction:: can_send + +:: + + channel.send(value if execnet.can_send(value) else repr(value)) + +It lives on ``execnet`` itself rather than on any one namespace: the wire +contract is the same whichever surface you drive a gateway from. + +Encode rich objects yourself +------------------------------------------------------- + +Turning a rich object into simple data (and back) is the caller's job. Use +an established encoding mechanism rather than expecting the channel to do it: + +- **pydantic**: ``model.model_dump(mode="json")`` reduces a model to simple + data (``datetime`` -> ISO string, ``UUID`` / ``Enum`` / ``Decimal`` -> + primitives); ``Model.model_validate(...)`` rebuilds it on the other side. + ``TypeAdapter`` covers non-model types. + + :: + + channel.send(model.model_dump(mode="json")) + # peer: + model = MyModel.model_validate(channel.receive()) + +- **pytest** does exactly this above execnet: pytest-xdist ships + ``TestReport`` objects with the ``pytest_report_to_serializable`` / + ``pytest_report_from_serializable`` hooks (rich report <-> simple dict) + around ``channel.send`` / ``channel.receive``. -For more examples see :ref:`dumps/loads examples`. +- **stdlib**: ``dataclasses.asdict(obj)``, ``dt.isoformat()`` / + ``datetime.fromisoformat``, or ``json`` with a ``default=`` hook. -.. autofunction:: execnet.dumps(spec) -.. autofunction:: execnet.loads(spec) +Channels are the one non-builtin you *can* send: nested channel references +pass through intact, so callbacks and sub-streams need no encoding. diff --git a/doc/conf.py b/doc/conf.py index 3d380529..d8000232 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -92,10 +92,24 @@ } nitpicky = True +# private types that show up in documented signatures; the modules they +# live in are internal, so there is nothing to link them to nitpick_ignore = [ - ("py:class", "execnet.gateway_base.ChannelFileRead"), - ("py:class", "execnet.gateway_base.ChannelFileWrite"), - ("py:class", "execnet.gateway.Gateway"), + ("py:class", "execnet._channel.ChannelFileRead"), + ("py:class", "execnet._channel.ChannelFileWrite"), + ("py:class", "execnet._gateway.Gateway"), + # `from __future__ import annotations` leaves a TYPE_CHECKING-only name + # exactly as it was written, so these are the private types above + ("py:class", "Gateway"), + ("py:class", "Filter"), + ("py:class", "Progress"), + ("py:class", "ServiceTarget"), + ("py:class", "execnet._trio_gateway.ByteStream"), + ("py:class", "execnet._trio_gateway.RawChannel"), + ("py:class", "execnet._xspec.XSpec"), + ("py:class", "XSpec"), + ("py:class", "execnet._bridge.AsyncioBridge"), + ("py:class", "execnet._bridge.TrioBridge"), ] # -- Options for HTML output -------------------------------------------------- diff --git a/doc/example/conftest.py b/doc/example/conftest.py index 5044b9eb..0d8b0594 100644 --- a/doc/example/conftest.py +++ b/doc/example/conftest.py @@ -10,5 +10,3 @@ cand = pathlib.Path(__file__).parent if str(cand) not in sys.path: sys.path.insert(0, str(cand)) - -pytest_plugins = ["doctest"] diff --git a/doc/example/hybridpython.rst b/doc/example/hybridpython.rst deleted file mode 100644 index f4ad465a..00000000 --- a/doc/example/hybridpython.rst +++ /dev/null @@ -1,153 +0,0 @@ -Connecting different Python interpreters -========================================== - -.. _`dumps/loads examples`: - -Dumping and loading values across interpreter versions ----------------------------------------------------------- - -.. versionadded:: 1.1 - -Execnet offers a new safe and fast :ref:`dumps/loads API` which you -can use to dump builtin python data structures and load them -later with the same or a different python interpreter (including -between Python2 and Python3). The standard library offers -the pickle and marshal modules but they do not work safely -between different interpreter versions. Using xml/json -requires a mapping of Python objects and is not easy to -get right. Moreover, execnet allows to control handling -of bytecode/strings/unicode types. Here is an example:: - - # using python2 - import execnet - with open("data.py23", "wb") as f: - f.write(execnet.dumps(["hello", "world"])) - - # using Python3 - import execnet - with open("data.py23", "rb") as f: - val = execnet.loads(f.read(), py2str_as_py3str=True) - assert val == ["hello", "world"] - -See the :ref:`dumps/loads API` for more details on string -conversion options. Please note, that you can not dump -user-level instances, only builtin python types. - -Connect to Python2/Numpy from Python3 ----------------------------------------- - -Here we run a Python3 interpreter to connect to a Python2.7 interpreter -that has numpy installed. We send items to be added to an array and -receive back the remote "repr" of the array:: - - import execnet - gw = execnet.makegateway("popen//python=python2.7") - channel = gw.remote_exec(""" - import numpy - array = numpy.array([1,2,3]) - while 1: - x = channel.receive() - if x is None: - break - array = numpy.append(array, x) - channel.send(repr(array)) - """) - for x in range(10): - channel.send(x) - channel.send(None) - print (channel.receive()) - -will print on the CPython3.1 side:: - - array([1, 2, 3, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9]) - -A more refined real-life example of python3/python2 interaction -is the anyvc_ project which uses version-control bindings in -a Python2 subprocess in order to offer Python3-based library -functionality. - -.. _anyvc: http://bitbucket.org/RonnyPfannschmidt/anyvc/overview/ - - -Reconfiguring the string coercion between python2 and python3 -------------------------------------------------------------- - -Sometimes the default configuration of string coercion (2str to 3str, 3str to 2unicode) -is inconvient, thus it can be reconfigured via `gw.reconfigure` and `channel.reconfigure`. Here is an example session on a Python2 interpreter:: - - - >>> import execnet - >>> execnet.makegateway("popen//python=python3.2") - - >>> gw=execnet.makegateway("popen//python=python3.2") - >>> gw.remote_exec("channel.send('hello')").receive() - u'hello' - >>> gw.reconfigure(py3str_as_py2str=True) - >>> gw.remote_exec("channel.send('hello')").receive() - 'hello' - >>> ch = gw.remote_exec('channel.send(type(channel.receive()).__name__)') - >>> ch.send('a') - >>> ch.receive() - 'str' - >>> ch = gw.remote_exec('channel.send(type(channel.receive()).__name__)') - >>> ch.reconfigure(py2str_as_py3str=False) - >>> ch.send('a') - >>> ch.receive() - u'bytes' - - -Work with Java objects from CPython ----------------------------------------- - -Use your CPython interpreter to connect to a `Jython 2.5.1`_ interpreter -and work with Java types:: - - import execnet - gw = execnet.makegateway("popen//python=jython") - channel = gw.remote_exec(""" - from java.util import Vector - v = Vector() - v.add('aaa') - v.add('bbb') - for val in v: - channel.send(val) - """) - - for item in channel: - print (item) - -will print on the CPython side:: - - aaa - bbb - -.. _`Jython 2.5.1`: http://www.jython.org - -Work with C# objects from CPython ----------------------------------------- - -(Experimental) use your CPython interpreter to connect to a IronPython_ interpreter -which can work with C# classes. Here is an example for instantiating -a CLR Array instance and sending back its representation:: - - import execnet - gw = execnet.makegateway("popen//python=ipy") - - channel = gw.remote_exec(""" - import clr - clr.AddReference("System") - from System import Array - array = Array[float]([1,2]) - channel.send(str(array)) - """) - print (channel.receive()) - -using Mono 2.0 and IronPython-1.1 this will print on the CPython side:: - - System.Double[](1.0, 2.0) - -.. note:: - Using IronPython needs more testing, likely newer versions - will work better. please feedback if you have information. - -.. _IronPython: http://ironpython.net diff --git a/doc/example/py3topy2.py b/doc/example/py3topy2.py deleted file mode 100644 index 3dcf0437..00000000 --- a/doc/example/py3topy2.py +++ /dev/null @@ -1,19 +0,0 @@ -import execnet - -gw = execnet.makegateway("popen//python=python2") -channel = gw.remote_exec( - """ - import numpy - array = numpy.array([1,2,3]) - while 1: - x = channel.receive() - if x is None: - break - array = numpy.append(array, x) - channel.send(repr(array)) -""" -) -for x in range(10): - channel.send(x) -channel.send(None) -print(channel.receive()) diff --git a/doc/example/redirect_remote_output.py b/doc/example/redirect_remote_output.py index 0fea4ebb..b74e23d9 100644 --- a/doc/example/redirect_remote_output.py +++ b/doc/example/redirect_remote_output.py @@ -21,13 +21,17 @@ """ ).receive() +# receive() promises this gateway's own channel type, so a plain isinstance +# is enough to get at the channel's methods +assert isinstance(outchan, execnet.Channel) + # note: callbacks execute in receiver thread! def write(data): print("received:", repr(data)) -outchan.setcallback(write) # type: ignore[attr-defined] +outchan.setcallback(write) gw.remote_exec( """ diff --git a/doc/example/test_debug.rst b/doc/example/test_debug.rst index 144a197f..bea6b296 100644 --- a/doc/example/test_debug.rst +++ b/doc/example/test_debug.rst @@ -5,7 +5,7 @@ Debugging execnet / wire messages By setting the environment variable ``EXECNET_DEBUG`` you can configure the execnet tracing mechanism: -:EXECNET_DEBUG=1: write per-process trace-files to ``${TEMPROOT}/execnet-debug-PID`` +:EXECNET_DEBUG=1: write per-process trace-files to ``execnet-debug-PID`` in the system temp directory :EXECNET_DEBUG=2: perform tracing to stderr (popen-gateway workers will send this to their instantiator) Here is a simple example to see what goes on with a simple execution:: @@ -14,30 +14,31 @@ Here is a simple example to see what goes on with a simple execution:: python -c 'import execnet ; execnet.makegateway().remote_exec("42")' -which will show PID-prefixed trace entries:: - - [2326] gw0 starting to receive - [2326] gw0 sent - [2327] creating workergateway on - [2327] gw0-worker starting to receive - [2327] gw0-worker received - [2327] gw0-worker execution starts[1]: '42' - [2327] gw0-worker execution finished - [2327] gw0-worker sent - [2327] gw0-worker 1 sent channel close message - [2326] gw0 received - [2326] gw0 1 channel.__del__ - [2326] === atexit cleanup === - [2326] gw0 gateway.exit() called - [2326] gw0 --> sending GATEWAY_TERMINATE - [2326] gw0 sent - [2326] gw0 joining receiver thread - [2327] gw0-worker received - [2327] gw0-worker putting None to execqueue - [2327] gw0-worker io.close_read() - [2327] gw0-worker leaving - [2327] gw0-worker 1 channel.__del__ - [2327] gw0-worker io.close_write() - [2327] gw0-worker workergateway.serve finished - [2327] gw0-worker gateway.join() called while receiverthread already finished - [2326] gw0 leaving +which will show PID-prefixed trace entries -- the coordinator and its +worker write to the same stream, so their lines interleave:: + + [3451876] creating workergateway on trio id='gw0-worker' + [3451876] integrating as primary thread (trio worker) + [3451876] gw0-worker received + [3451872] gw0 sent + [3451872] gw0 1 channel.__del__ + [3451872] === atexit cleanup === + [3451872] gw0 gateway.exit() called + [3451872] gw0 --> sending GATEWAY_TERMINATE + [3451876] gw0-worker received + [3451872] gw0 sent + [3451872] gw0 --> io.close_write + [3451876] gw0-worker execution starts[1]: '42' + [3451876] gw0-worker execution finished + [3451876] gw0-worker received + [3451876] gw0-worker received GATEWAY_TERMINATE + [3451872] gw0 [trio-bridge] finishing channels + [3451872] gw0 [trio-bridge] terminating execution + [3451876] gw0-worker [trio-bridge] finishing channels + [3451876] gw0-worker shutting down execution pool + [3451876] gw0-worker waiting for receiver to finish + [3451872] gw0 waiting for receiver to finish + +Because the worker leaves its own stderr alone, a remote ``print()`` and a +remote traceback arrive the same way -- see :ref:`worker output +`. diff --git a/doc/example/test_group.rst b/doc/example/test_group.rst index dd6275b5..ad63adb3 100644 --- a/doc/example/test_group.rst +++ b/doc/example/test_group.rst @@ -14,7 +14,7 @@ multiple gateways:: >>> group >>> list(group) - [, ] + [, ] >>> 'gw0' in group and 'gw1' in group True >>> group['gw0'] == group[0] @@ -37,7 +37,7 @@ Pass an ``id=MYNAME`` part to ``group.makegateway``. Example:: >>> gw = group.makegateway("popen//id=sub1") >>> assert gw.id == "sub1" >>> group['sub1'] - + Getting (auto) IDs before instantiation ------------------------------------------------------ @@ -93,18 +93,19 @@ Using Groups to manage a certain type of gateway ------------------------------------------------------ Set ``group.defaultspec`` to determine the default gateway -specification used by ``group.makegateway()``: +specification used by ``group.makegateway()`` (this one needs a reachable +ssh account, so it is not run as part of the test suite): >>> import execnet >>> group = execnet.Group() >>> group.defaultspec = "ssh=localhost//chdir=mytmp//nice=20" - >>> gw = group.makegateway() + >>> gw = group.makegateway() # doctest: +SKIP >>> ch = gw.remote_exec(""" ... import os.path ... basename = os.path.basename(os.getcwd()) ... channel.send(basename) - ... """) - >>> ch.receive() + ... """) # doctest: +SKIP + >>> ch.receive() # doctest: +SKIP 'mytmp' This way a Group object becomes kind of a Gateway factory where diff --git a/doc/example/test_info.rst b/doc/example/test_info.rst index eefe91b3..4ef3c71b 100644 --- a/doc/example/test_info.rst +++ b/doc/example/test_info.rst @@ -89,19 +89,21 @@ A local subprocess gateway has the same working directory as the instantiatior:: Get information from remote SSH account --------------------------------------- -Use simple execution to obtain information from remote environments:: +Use simple execution to obtain information from remote environments +(this one needs an account you can actually reach, so it is not run as +part of the test suite):: >>> import execnet, os - >>> gw = execnet.makegateway("ssh=codespeak.net") + >>> gw = execnet.makegateway("ssh=wyvern") # doctest: +SKIP >>> channel = gw.remote_exec(""" ... import sys, os ... channel.send((sys.platform, tuple(sys.version_info), os.getpid())) - ... """) - >>> platform, version_info, remote_pid = channel.receive() - >>> platform - 'linux2' - >>> version_info - (2, 6, 6, 'final', 0) + ... """) # doctest: +SKIP + >>> platform, version_info, remote_pid = channel.receive() # doctest: +SKIP + >>> platform # doctest: +SKIP + 'linux' + >>> version_info # doctest: +SKIP + (3, 13, 1, 'final', 0) Use a callback instead of receive() and wait for completion ------------------------------------------------------------- @@ -117,8 +119,10 @@ Set a channel callback to immediately react on incoming data:: >>> l [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, None] -Note that the callback function will execute in the receiver thread -so it should not block on IO or long to execute. +Items reach the callback in order, one at a time per channel, each call on +a thread from a bounded pool -- so a callback that blocks holds up its own +channel and, with enough of them, that pool, but never the protocol loop +that every gateway in the process shares. Sending channels over channels ------------------------------------------------------ @@ -169,18 +173,32 @@ all incoming requests in the global name space and sends back the results. +.. _socket-server: + Instantiate gateways through sockets ----------------------------------------------------- -.. _`socketserver.py`: https://raw.githubusercontent.com/pytest-dev/execnet/main/src/execnet/script/socketserver.py +In cases where you do not have SSH-access to a machine you need a +bootstrapping-point that listens on a socket. execnet ships one as the +``execnet server`` command; run it on the target machine:: + + execnet server :8888 # bind to all IPs, port 8888 + +If execnet is not installed there, uv_ can fetch and run it in one step +without leaving anything behind:: + + uvx --from execnet execnet server :8888 + +.. _uv: https://docs.astral.sh/uv/ -In cases where you do not have SSH-access to a machine -you need to download a small version-independent standalone -`socketserver.py`_ script to provide a remote bootstrapping-point. -You do not need to install the execnet package remotely. -Simply run the script like this:: +.. versionchanged:: 2.2 + This used to be the ``execnet-socketserver`` console script, which still + works and forwards here with a ``DeprecationWarning``. - python socketserver.py :8888 # bind to all IPs, port 8888 +The server accepts connections in a loop and serves each one in its own +worker subprocess; pass ``--once`` to serve a single connection and exit. +Passing port ``0`` binds an ephemeral port -- the bound address is printed +on the first line of output either way. You can then instruct execnet on your local machine to bootstrap itself into the remote socket endpoint:: @@ -191,4 +209,47 @@ itself into the remote socket endpoint:: That's it, you can now use the gateway object just like a popen- or SSH-based one. +.. warning:: + + The socket server performs no authentication and its traffic is not + encrypted -- anyone who can reach the port can execute code on that + machine. Bind it to a trusted network only, or tunnel it over SSH. + +Keeping the socket server running ++++++++++++++++++++++++++++++++++ + +``execnet server`` is an ordinary long-running process, so restarts and +boot-time startup are the job of your platform's service manager. + +On Linux, a systemd unit does it:: + + # /etc/systemd/system/execnet-server.service + [Unit] + Description=execnet socket server + + [Service] + ExecStart=/usr/local/bin/execnet server :8888 + Restart=always + + [Install] + WantedBy=multi-user.target + +On Windows, wrap the console command with a service host such as NSSM_ or +WinSW_. Use ``where execnet`` to find the installed ``execnet.exe`` (it +lives in the ``Scripts`` directory of the environment it was installed +into), then:: + + nssm install ExecNetServer C:\path\to\Scripts\execnet.exe server :8888 + nssm set ExecNetServer Start SERVICE_AUTO_START + net start ExecNetServer + +NSSM restarts the process if it exits. Note that ``sc.exe create`` on its own +is not enough: it expects a binary that implements the Windows service control +protocol, which a plain console program does not. If you would rather not +install a service host, a Task Scheduler task with an *At startup* trigger +works too. + +.. _NSSM: https://nssm.cc/ +.. _WinSW: https://github.com/winsw/winsw + .. include:: test_ssh_fileserver.rst diff --git a/doc/example/test_multi.rst b/doc/example/test_multi.rst index 306b960d..1d2da285 100644 --- a/doc/example/test_multi.rst +++ b/doc/example/test_multi.rst @@ -49,8 +49,9 @@ data immediately and without blocking execution:: >>> ch.waitclose() >>> assert l == [42] -Note that the callback function will be executed in the -receiver thread and should not block or run for too long. +The callback runs on a pool thread, one item at a time per channel and in +order, so it may block without stalling the protocol loop -- but a channel +whose callback blocks receives nothing further until it returns. Robustly receive results and termination notification ----------------------------------------------------- diff --git a/doc/example/test_proxy.rst b/doc/example/test_proxy.rst index f2af992d..e983ef67 100644 --- a/doc/example/test_proxy.rst +++ b/doc/example/test_proxy.rst @@ -5,21 +5,21 @@ Simple proxying ---------------- Using the ``via`` arg of specs we can create a gateway -whose io is created on a remote gateway and proxied to the master. +whose io is created on a remote gateway and proxied to the coordinator. -The simplest use case, is where one creates one master process +The simplest use case, is where one creates one coordinator process and uses it to control new workers and their environment :: >>> import execnet >>> group = execnet.Group() - >>> group.defaultspec = 'popen//via=master' - >>> master = group.makegateway('popen//id=master') - >>> master - + >>> group.defaultspec = 'popen//via=coordinator' + >>> coordinator = group.makegateway('popen//id=coordinator') + >>> coordinator + >>> worker = group.makegateway() >>> worker - + >>> group - + diff --git a/doc/example/test_ssh_fileserver.rst b/doc/example/test_ssh_fileserver.rst index e7a82817..ba0b5d16 100644 --- a/doc/example/test_ssh_fileserver.rst +++ b/doc/example/test_ssh_fileserver.rst @@ -11,7 +11,7 @@ And here is some code to use it to retrieve remote contents:: import execnet import servefiles - gw = execnet.makegateway("ssh=codespeak.net") + gw = execnet.makegateway("ssh=myhost") channel = gw.remote_exec(servefiles) for fn in ('/etc/passwd', '/etc/group'): diff --git a/doc/examples.rst b/doc/examples.rst index 767fa4fc..ff6233bf 100644 --- a/doc/examples.rst +++ b/doc/examples.rst @@ -5,7 +5,9 @@ examples .. _`execnet-dev`: http://mail.python.org/mailman/listinfo/execnet-dev .. _`execnet-commit`: http://mail.python.org/mailman/listinfo/execnet-commit -Note: all examples with `>>>` prompts are automatically tested. +Note: the examples with ``>>>`` prompts are run as doctests by ``tox -e +docs``, except for the few marked ``# doctest: +SKIP``, which need a remote +account to talk to. .. toctree:: :maxdepth: 2 @@ -14,7 +16,6 @@ Note: all examples with `>>>` prompts are automatically tested. example/test_group example/test_proxy example/test_multi - example/hybridpython example/test_debug .. toctree:: diff --git a/doc/implnotes.rst b/doc/implnotes.rst index d13d9e87..0368dcb5 100644 --- a/doc/implnotes.rst +++ b/doc/implnotes.rst @@ -1,32 +1,348 @@ -gateway_base.py +============================================================================== +Implementation notes +============================================================================== + +How a gateway is actually built. Everything here is internal: no name in +this document is part of the public API, and the on-wire protocol is +deliberately unversioned and unstandardised. + +The Message protocol +---------------------- + +Both sides speak a stream of Messages: a 9-byte header (type, channel id, +payload length) followed by the payload. ``execnet._message.FrameDecoder`` +turns arbitrary byte chunks into Messages and is sans-IO -- it never reads, +writes or awaits -- so a receiver only has to stream bytes into it. Payloads +carrying channel items are encoded by ``execnet._serialize``, which handles +builtin data plus channel references and nothing else (see +:ref:`serialization`). + +No source shipping +---------------------- + +A worker is not sent its own source. It is launched as a command that runs +the ``execnet`` (and ``trio``) installed in its own environment. This is what +makes provisioning a separate concern from connecting, and it is an +invariant: nothing may reintroduce shipping the core over the wire. + +Because the two ends are now installed independently, the worker checks the +coordinator version it is handed and refuses one whose major/minor differs +from its own -- the protocol is unversioned, so a skew has no defined +behaviour. It refuses before it touches its stdio, which is the last moment +a reason can reach the user: after that the coordinator only ever learns EOF. +A patch-level difference is tolerated, and +``EXECNET_IGNORE_VERSION_SKEW=1`` in the worker's environment (which +``env:EXECNET_IGNORE_VERSION_SKEW=1`` in the spec reaches) downgrades the +refusal to a warning. + +The launch contract: ``execnet worker`` +---------------------------------------- + +Every launcher emits the same command line, so there is exactly one way a +worker starts:: + + execnet worker --protocol-stdio | --protocol-fd FD[,FD] + | --protocol-connect ADDR | --protocol-listen ADDR + | --protocol-share [--config-fd FD] + [--stdin/--stdout/--stderr DISPOSITION] + +``ADDR`` is ``unix:/path`` or ``host:port``. Provisioning emits ``python -m +execnet worker ...`` for a direct interpreter launch (where the console +script's location is not knowable) and ``execnet worker ...`` under ``uv +run``; both are the same CLI. + +Which is to say: argv names a transport and nothing else. What the worker +*is* -- its gateway id, worker profile, working directory, ``nice`` level, +``env:`` values, stdio disposition -- arrives as the first frame on that +transport, before the protocol proper begins: + +.. code-block:: text + + coordinator --> GATEWAY_CONFIG {"id": ..., "profile": ..., "env": {...}} + worker --> GATEWAY_CONFIG {"ok": true, "execnet": ..., "pid": ...} + or GATEWAY_CONFIG {"ok": false, "error": "version mismatch: ..."} + +That keeps the config out of argv everywhere rather than only remotely -- +``/proc`` is world-readable on the local machine exactly as ``ps`` is on a +remote one, and ``env:`` values are secrets often enough. It also gives a +worker that refuses to serve somewhere to say so: the reason reaches +whoever asked for the gateway instead of a stderr that may be pointed +anywhere. Do not regress either property. + +The single exception is ``--protocol-share`` on Windows, where the socket +is duplicated into the worker with ``WSADuplicateSocket``: that blob +describes the very connection a config frame would arrive on, so it goes to +the worker's stdin as a one-key JSON object (``--config-fd``). Nothing +else may travel that way. + +An intermediary never sees a config it is only relaying: a ``via=`` +coordinator is asked to *spawn* a sub-worker, and the sub's config comes +down the tunnel from the coordinator that wants the gateway. + +Naming the transport explicitly is what frees the worker's stdio. Once the +protocol has a stream of its own, fd 0/1/2 belong to the code the worker +runs, and the spec's ``stdin=``/``stdout=``/``stderr=`` keys say what to do +with them (defaults come from the transport: leave them alone for a socket, +close stdin and fold stdout onto stderr for stdio). The matching CLI flags +override the config, for a worker started by hand. + +Two more subcommands round it out: ``execnet server [HOST:PORT] [--once]`` +accepts coordinator connections and hands each to a fresh worker (no code +runs in the server process itself), and ``execnet info`` prints version, +trio availability, executable, platform and supported transports as JSON -- +which is how a coordinator decides whether a ``python=`` interpreter can +host a worker directly, *before* connecting to it. + +Transports ---------------------- -The code of this module is sent to the "other side" -as a means of bootstrapping a Gateway object -capable of receiving and executing code, -and routing data through channels. - -Gateways operate on InputOutput objects offering -a write and a read(n) method. - -Once bootstrapped a higher level protocol -based on Messages is used. Messages are serialized -to and from InputOutput objects. The details of this protocol -are locally defined in this module. There is no need -for standardizing or versioning the protocol. - -After bootstrapping the BaseGateway opens a receiver thread which -accepts encoded messages and triggers actions to interpret them. -Sending of channel data items happens directly through -write operations to InputOutput objects so there is no -separate thread. - -Code execution messages are put into an execqueue from -which they will be taken for execution. gateway.serve() -will take and execute such items, one by one. This means -that by incoming default execution is single-threaded. - -The receiver thread terminates if the remote side sends -a gateway termination message or if the IO-connection drops. -It puts an end symbol into the execqueue so -that serve() can cleanly finish as well. +``transport=socket`` is the default for every worker execnet spawns; the +protocol only rides on stdin/stdout when asked to, or when nothing else can +work. + +How the worker gets its protocol stream, by gateway: + +``popen``, POSIX + an inherited socketpair (``pass_fds``, ``--protocol-fd``) + +``popen``, Windows + a socket duplicated into the child pid with ``socket.share()``, the blob + travelling on the child's stdin (``--protocol-share``) + +``socket=`` / ``installvia=`` + the server accepts the connection, then hands that socket to the worker + it spawns, by whichever of the two mechanisms the platform has. The + coordinator's config frame arrives on that same socket, so it reaches + the worker directly: the server neither reads nor relays it, and has no + configuration of its own to merge in. + +``ssh=`` / ``vagrant_ssh=`` + an ``ssh -R`` forwarded unix socket the worker dials back on (POSIX + only) + +Windows has no ``pass_fds``, hence ``socket.share()`` (``WSADuplicateSocket``), +which duplicates into a *named pid* -- and the pid does not exist until the +child does, which is why the flag is in argv while the blob follows on +stdin. The blob is bound to that one pid, so it is inert to anything else; +that beats handle inheritance, which would need ``close_fds=False`` and leak +every inheritable handle to the child and its grandchildren. + +Hand a socket over **as a socket, never as an fd**. Rebuilding one with +``socket.socket(fileno=fd)`` makes the constructor re-derive family, type and +proto by querying the handle, and PyPy on Windows cannot do that to a handle +produced by ``WSADuplicateSocket``. + +ssh on Windows stays on stdio and cannot do otherwise: CPython has never +exposed ``AF_UNIX`` there and Win32-OpenSSH does not implement +``StreamLocal`` forwarding. Asking for ``transport=socket`` anyway is an +error at ``makegateway`` time rather than a gateway that waits forever. + +Whether a socket can be handed over at all is settled by *doing* it once -- +sharing to our own pid and rebuilding the result -- not by looking for +``socket.share``: an implementation with the name but not a working call +would pass the check and fail later, at the point where the only thing left +to tell the coordinator is a closed socket. + +Provisioning the worker environment +------------------------------------ + +``execnet._provision`` decides what command to run, by target: + +* Same-interpreter ``popen`` -> ``sys.executable -m execnet worker``. +* A ``python=`` interpreter whose ``execnet info`` answers -> that + interpreter directly, so ``sys.executable`` is preserved. +* A bare ``python=`` interpreter or an ``ssh`` remote -> uv_: + ``uv run --with execnet worker``, where ```` is + ``execnet==`` for a released coordinator and a locally built, + version-cached wheel for a development one. A dev coordinator's wheel is + not on the remote filesystem, so it is copied over its own ssh connection + and cached there by name before the worker is launched -- the protocol + stream never carries a payload. +* ``EXECNET_PROVISION_WHEEL`` names a prebuilt wheel to use instead, which + is how a built artifact gets tested by the suite that built it. + +.. _uv: https://docs.astral.sh/uv/ + +The protocol engine +---------------------- + +Protocol IO is a Trio program. :mod:`execnet.raw_trio` runs it in the +caller's own nursery; every other surface puts it on a ``ProtocolEngine``: +one OS thread running ``trio.run``, shared per process +(``execnet._engine.ProtocolEngine`` -> ``execnet._trio_engine.TrioEngine``). +``execnet._engine`` deliberately does not ``import trio``, so ``import +execnet`` does not load the event loop machinery. ``_trio_engine`` is the +loop; ``_trio_host`` is the routing layer that runs on it (the sync bridge, +the facade group, socket and via handling). + +Callers cross into it through ``execnet._portal``. The blocking surfaces +park on a wakener from ``execnet._boundary`` -- which is what makes +:mod:`execnet.gevent` possible: same engine, same tasks, a different +primitive to park on -- and would stall a running event loop, so they raise +there instead. :mod:`execnet.trio` and :mod:`execnet.aio` instead await a +``Carrier`` from ``execnet._bridge``, one implementation per caller loop +over an identical engine half. + +Nothing outside ``_trio_engine`` touches the root nursery: +``TrioEngine.start_task`` is the single door, so every long-lived task is +one the engine can account for when it shuts down. It keeps a list of the +groups running on it for the same reason -- ``close()`` terminates them +rather than leaving their workers behind, and warns that it had to. + +That door is also what makes the engine swappable. ``_asyncio_engine`` +meets the same contract on ``asyncio.TaskGroup`` (Python 3.11+, refused +below that rather than backported), and ``ProtocolEngine(backend=...)`` +picks between them; the portals raise a backend-neutral +``LoopFinishedError`` so nothing above has to know which loop it holds. +The protocol core is *not* ported: ``_trio_gateway`` still uses nurseries, +cancel scopes and memory channels directly, so an asyncio engine refuses to +build gateways instead of failing somewhere inside trio. What is proved so +far is the layer below the core -- the loop, the portal, the task scope -- +which is the part a port would otherwise have to invent. + +Cancellation is the reason that port is not as expensive as it looks. Trio +is level-triggered, so cleanup inside a cancelled scope needs an explicit +shield, and there are nineteen of those; asyncio is edge-triggered, and +cleanup after catching ``CancelledError`` simply runs -- measurably so, +including under a ``TaskGroup`` aborting, ``asyncio.timeout`` and +``wait_for``, none of which cancel a second time. + +Almost nothing crosses a cancel in the first place. Every bridge call is +shielded except ``receive``, ``wait_closed``, ``remote_exec``, +``makegateway``, ``transfer`` and ``deploy_all``, and only on the two async +facades -- the blocking surfaces have no cancellation model, and +``raw_trio`` has no bridge. ``receive(timeout=...)`` does not cross either: +the deadline is enforced engine-side. Of the crossings, only ``receive`` +could lose anything, and it no longer does: the carrier hands a value +nobody is left to take to a *salvage* the call names, and +``AsyncChannel.receive`` keeps a one-slot pushback that the next call +drains. So cancellation *precision* is not load-bearing anywhere, which is +what makes a backend with weaker cancellation an option at all. + +Sends from a thread that is not the engine's wait until the frame is +written, so an abrupt ``os._exit`` cannot drop queued data. Sends from the +engine thread itself (inside a receiver callback) only enqueue, to avoid +deadlocking the writer task. ``setcallback`` runs its callback on a bounded +thread pool rather than on the loop: a consumer task per channel keeps that +channel's order strict while a slow callback blocks nothing but its own +thread. The pool is shared by every channel in the process and bounded +(``ProtocolEngine(callback_threads=...)``, 40 by default), so callbacks that +wait on *each other* can fill it and stall the rest; work that waits belongs +on a thread of its own. + +The engine itself is not a resource that can be taken away quietly. Closing +it is final, and a fork leaves every inherited object dead in the child -- +both raise, because the alternative is a wait on a loop that will never run +again (``execnet._errors.ForkedResourceError``). For the same reason +nothing scheduled with ``portal.post`` may raise: trio turns an exception in +an entry-queue callback into a ``TrioInternalError`` that ends the whole +run, so a call that loses a race with shutdown reports through its own +result object instead. ``TrioEngine.stop`` posts its shutdown request for +the mirror-image reason: ``portal.run_sync`` refuses a caller that is itself +inside a trio run, which is exactly where an async application closes its +engine from. + +Deploying a project +---------------------- + +Workers on a host that shares no filesystem with the coordinator need the +project before they can run anything -- and because a worker *is* the +process that runs the tests, it has to already be inside the environment +the project was installed into. So provisioning is its own gateway, and +the workers come after it:: + + machine = group.makegateway("ssh=host//id=h1") + target = execnet.Deployment(project=".", roots=["testing"]).deploy(machine) + worker = group.makegateway(f"via=h1//{target.spec}//id=w0") + +That gateway usually stays on as the ``via`` coordinator the workers are +spawned through -- one connection per machine, with the test workers as its +local children -- so deploying and running are ordered rather than +concurrent, and the transfer is finished with before it starts relaying for +anyone. + +Three steps in the one order that works: a frozen environment +(``uv sync --frozen`` from the project's own lockfile, so the remote +resolves nothing), the artifact (a wheel built here and installed there, +rather than a source tree), and everything a test run needs that the wheel +does not contain -- tests, ``conftest.py``, fixture data -- transferred +into the workspace. + +Both halves travel over the gateway's own protocol, as ``transfer`` and +``deploy`` services. No second connection and no second set of +credentials, which is what lets the same code reach a container or a pod. + +The driver is async and runs on the engine, so the blocking API is a facade +that parks the way its surface parks, and every other surface awaits the +same operations over its own bridge. It also means a fan-out is +concurrent: one wheel build, one task per machine. A transfer sends one +manifest of the whole tree rather than a message per directory, the target +replies with what it is missing (plus a digest for anything whose size +matches but whose timestamp does not, which is what makes re-sending an +unchanged tree nearly free), and bodies follow in 1 MiB chunks. + +:class:`execnet.Deployed` reports where things landed, because the remote +layout is a provisioning fact and the caller knows only local paths. +Deployments sharing a ``name`` share a workspace on the host, so the second +gateway to a machine finds the environment the first one built. + +One trap worth knowing about, since a worker inherits its coordinator's +environment: ``uv pip install`` honours ``VIRTUAL_ENV``, and a coordinator +is very often running inside one. The deploy service scrubs that (and +``UV_PROJECT_ENVIRONMENT``, ``CONDA_PREFIX``) and names the target +interpreter explicitly -- otherwise the project is installed into the +*coordinator's* environment and the deployed one silently lacks it. + +Inside the worker +---------------------- + +The worker opens its transport with blocking IO, reads its config frame, +answers it, and only then builds a Trio loop and serves the Message +protocol on it. The handshake happens before the loop deliberately: the +config is what decides the worker's *shape*, and ``profile=trio`` has no +side thread to read it on. Where exec'd code +runs is the ``profile=`` axis (:ref:`worker profiles `), +implemented as an exec strategy per profile in ``execnet._trio_worker``: +``HybridExec`` (main thread while free, pool threads for overflow), +``GreenletExec`` (gevent hub on the main thread) and ``TaskExec`` (tasks on +the worker's own loop, for ``profile=trio``, which is the only profile whose +sources must be async). + +Admission is FIFO and bounded (``execnet._trio_worker.exec_capacity``): the +thread-shaped strategies spend a thread per exec out of the same budget the +callback pool and the worker's internal ``to_thread`` work draw on, so exec +gets half of it and a request over the line is refused on its channel. The +exec task itself contains whatever it raises -- it is a task on the worker's +*root* nursery, and an exception leaving it ends ``trio.run`` and prints an +ExceptionGroup onto the user's stderr. That goes for every +``engine.start_soon`` entry point; the socket and via handlers do the same. + +Infrastructure that used to be expressed by ``remote_exec``-ing source is +now protocol messages handled on the target's engine: ``GATEWAY_START_SOCKET`` +(``installvia=`` -- bind a one-shot listener and reply with its address), +``GATEWAY_START_SUB`` (``via=`` -- spawn a sub-worker and relay its +protocol over the request channel), and ``GATEWAY_SERVICE``. + +That last one is deliberately generic. Its payload is ``(name, request)``; +the worker looks the name up in ``execnet._services``, which maps names to +import strings and imports one when a request for it arrives. The core +knows nothing else -- no opcode per feature, no dispatch table naming one, +no import of one. ``execnet._deploy`` (transfers and deployments) is +built entirely on that seam, which is also how a package outside execnet +would add a service:: + + execnet._services.register("myco.thing", "myco.execnet_thing:serve") + +on both ends. A worker asked for a service it does not have says so on +the request's channel, since that is almost always a version skew. + +Service bodies stay synchronous and run in a worker thread, reaching their +channel through ``trio.from_thread``. Receiving a tree is ``lstat``, +``mkdir``, ``chmod`` and whole-file writes; building an environment is +waiting on ``uv``. Threading a loop through either would rewrite fiddly +logic for a thread each -- a real cost, since that thread comes from the +same budget exec placement rations. A sub-gateway +that fails to start must not take its coordinator down with it, and a +failure that cannot be reported must still close the connection, so the +requesting side sees EOF instead of waiting for a handshake reply nobody +will send. diff --git a/doc/index.rst b/doc/index.rst index 19a20eb8..5bffa87c 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -2,11 +2,6 @@ :align: right -.. warning:: - - execnet currently is in maintenance-only mode, mostly because it is still the backend - of the pytest-xdist plugin. Do not use in new projects. - Python_ is a mature dynamic language whose interpreters can interact with all major computing platforms today. @@ -28,26 +23,32 @@ a minimal and fast API targeting the following uses: Features ------------------ -* Automatic bootstrapping: no manual remote installation. +* Automatic bootstrapping: a worker environment that lacks execnet is + provisioned with uv_, so there is no manual remote installation -- and no + source of our own is ever shipped over the wire. * Safe and simple serialization of Python builtin - types for sending/receiving structured data messages. - (New in 1.1) execnet offers a new :ref:`dumps/loads ` - API which allows cross-interpreter compatible serialization - of Python builtin types. + types for sending/receiving structured data messages; + see :ref:`sending objects over a channel `. + Encoding rich objects is the caller's job (execnet stays + builtin-types-only). * Flexible communication: synchronous send/receive as well as callback/queue mechanisms supported * Easy creation, handling and termination of multiple processes -* Well tested interactions between CPython 2.5-2.7, CPython-3.3, Jython 2.5.1 - and PyPy interpreters. +* One :doc:`namespace ` per concurrency library you drive it from: + threads, trio, asyncio or gevent. + +* Tested against CPython 3.10+ and PyPy 3. * Fully interoperable between Windows and Unix-ish systems. * Many tested :doc:`examples` +.. _uv: https://docs.astral.sh/uv/ + Known uses ------------------- @@ -59,9 +60,6 @@ Known uses project to launch computation processes through ssh. He also compares `disco and execnet`_ in a subsequent post. -* Ronny Pfannschmidt uses it for his `anyvc`_ VCS-abstraction project - to bridge the Python2/Python3 version gap. - * Sysadmins and developers are using it for ad-hoc custom scripting .. _`quora`: http://quora.com @@ -71,15 +69,14 @@ Known uses .. _`distributed testing`: https://pypi.python.org/pypi/pytest-xdist .. _`Distributed NTLK with execnet`: http://streamhacker.com/2009/11/29/distributed-nltk-execnet/ .. _`disco and execnet`: http://streamhacker.com/2009/12/14/execnet-disco-distributed-nltk/ -.. _`anyvc`: http://bitbucket.org/RonnyPfannschmidt/anyvc/ Project status -------------------------- -The project is currently in **maintenance-only mode**, with PRs fixing bugs being gracefully accepted. - -Currently there are no plans to improve the project further, being maintained mostly because it is -used as backend of the popular `pytest-xdist `__ plugin. +``execnet`` is the backend of the popular `pytest-xdist +`__ plugin, which is both what +keeps it maintained and the compatibility bar every change is held to. +Bug reports and PRs are welcome; see :doc:`support`. ``execnet`` was conceived originally by `Holger Krekel`_ and is licensed under the MIT license since version 1.2. diff --git a/doc/install.rst b/doc/install.rst index adbb9341..778149b2 100644 --- a/doc/install.rst +++ b/doc/install.rst @@ -28,6 +28,7 @@ Next checkout the basic api and examples: examples basics + api changelog .. _pip: http://pypi.python.org/pypi/pip diff --git a/handoff-history.md b/handoff-history.md new file mode 100644 index 00000000..f0f4c27c --- /dev/null +++ b/handoff-history.md @@ -0,0 +1,178 @@ +# What landed on `feat/trio-host-thread-io`, and what it taught us + +The record, compressed. Current state is `HANDOFF.md`; open work is +`ROADMAP-3.0.md`. This file exists for two reasons: commit messages do +not carry the *why*, and several decisions were made, unmade and remade — +the last section says which ones are dead so nobody resurrects them. + +## The phases + +**A — transports and demolition** (`92969c5`, `48d328c`, `3d1d31e`, +2026-07-24). `via=` generalized to a `GATEWAY_START_SUB` protocol message +covering popen/python/ssh/vagrant sub-specs; `vagrant_ssh` ported; +`gateway_io`, `gateway_bootstrap`, `Popen2IO`, the thread receiver and +`WorkerGateway.serve` deleted. `HostNotFound` became a `ConnectionError` +subclass. This is where the pattern was set: **infra operations are +protocol messages, not `remote_exec`'d source** — possible only because +execnet is now always installed on the worker. + +**B — the inversion** (`0eb6cc5`..`4f84335`). One protocol engine +(`AsyncGateway`), with the sync API as a facade over it; `ProtocolSession` +deleted. Along the way: transports unified on a neutral stream protocol; +the **sans-IO `FrameDecoder`** (receivers pump bytes, a `feed()` state +machine yields messages) which is what made the frame-native `via` relay +and IO-free tests possible; the two-level `RawChannel` / `AsyncChannel` +model that killed the double-framed `ChannelByteIO` tunnel; and the +namespace split. + +Two fixes from B worth keeping in mind: + +- The bridge must attach itself to the sync gateway in `__init__`, *before* + serving starts (`51b9053`). Otherwise a first message replies through + the IO stub and kills the fresh gateway — invisible until `pytest -n 12`. +- A stream-closing `aclose` on the via tunnel must feed EOF to its *own* + reader, or the serve task never finishes and `terminate` hangs. + +**Boundary kit — P1..P5** (`2c78416`..`b01d009`, 2026-07-26). Consumer→loop +was already universal (`LoopPortal.post` on `TrioToken.run_sync_soon`); +loop→consumer was hardwired to threading primitives, which was the only +reason `ExecModel` still existed. The fix: the loop never blocks and never +knows who listens — it fires a thread-safe wakeup the consumer supplied. +`Wakener` / `Mailbox` / `OneShot` / `Flag` live in the trio-free +`execnet._boundary`. The sync `Channel` became a genuine facade over +(raw id, mailbox, portal): deserialization moved to the `receive()` call +site, `AsyncGateway._dispatch` became the only router, and `_receivelock`, +the classic `ChannelFactory` dispatch, `WorkerPool` and `Reply` all went. + +One fix there was subtle: an unconsumed, remotely-closed `RawChannel` must +stay registered until a consumer claims it. Call-site deserialization +means a *passed* channel can bind after both its data and its close have +arrived — a payload-loss race that was latent in the async core too. + +`ExecModel` survived as a deprecated preset **because pytest-xdist's remote +worker calls `channel.gateway.execmodel.RLock()`**. + +**C — worker profiles** (`b2f43c3`..`cbce183`). The `loop=` × `exec=` axes +framing was dropped in favour of named use-case profiles on one key. +`TrioWorkerExec` became a pure FIFO admission pump over strategy objects. +The pytest-relevant fix landed here too: `Message.GATEWAY_INFO` answers +`rinfo()` from the dispatch loop and chdir/nice/env ship in the worker +config, so coordinator bookkeeping can no longer *claim an exec slot* — +previously an info call could occupy the main thread, and pytest ended up +on a worker thread. + +**Callbacks off the loop thread** (2026-07-26). `setcallback` starts a +per-channel consumer *task* that drains an inbox and runs each callback via +`trio.to_thread` under a bounded limiter, holding a strong reference to the +channel (which replaced the old `_callback_channels` registry — lifecycle +is now the task's). `waitclose()` waits on `_consumer_done`, so it still +returns only after every callback including the endmarker. + +**Surface review** (`7aa17fe`..`e75cd0a`, 2026-07-29). The public surface +had settled commit by commit and was never looked at whole; doing that +before the docs froze it retired several earlier decisions (see below). +Result: one namespace per concurrency library you drive execnet from, one +shared `Host` per process, `profile=` as the spec key, and blocking calls +inside a running loop raising instead of hanging. + +**CLI and transports** (`5710eed`, `a84380f`, `2fc4013`, 2026-07-30). The +protocol stopped being the worker's stdin/stdout. `execnet worker` names +the transport and is the launch contract; `execnet info` replaced the +`import execnet, trio` probe. Three things this fixed, each verified while +doing it: remote `print()` used to go to `/dev/null`; the ssh worker config +carried `env:` values in the remote argv, readable by every user on that +host via `ps`; and the dev-coordinator wheel used to be framed in-band on +the protocol pipe with `head -c N`, where it now travels on its own ssh +connection into a remote cache. + +**Windows** (`9413a2e`..`f484d14`). It had never been tested and every +worker died at startup on `trio.lowlevel.FdStream`, which is POSIX-only; +`ThreadedFdStream` does those reads and writes in the thread pool. +`pass_fds` does not exist there, so popen hands the socket over with +`socket.share(pid)` — the flag in argv, the blob in the config on stdin, +because the pid does not exist until the child is spawned. The blob is +bound to that one pid and inert to anything else, which beats handle +inheritance (that would need `close_fds=False` and leak every inheritable +handle to the child *and its grandchildren*). + +**xdist in CI** (`320c89e`, `17e7c7a`, 2026-07-30). See "The xdist +contract" in `ROADMAP-3.0.md`. It found 16 regressions on the first run. + +## Lessons that cost a debugging round + +- **CI was lying.** Until `1839800`, CI had executed *zero tests* since + 2026-07-26: `testing/test_ssh_local.py` imported `asyncssh` at module + level while `tox.ini` carried its own hand-written `deps` list that had + drifted from the `testing` extra, so collection errored and every job + reported `2 skipped, 1 error` while looking like an ordinary failure. + There must never be a second list of test requirements — `tox.ini` uses + `extras = testing`. Relatedly, `[tool.uv] default-groups = ["testing"]` + had been *replacing* uv's `dev` default, so `uv sync` silently + uninstalled pytest-xdist. +- **Hand a socket over as a socket, not as an fd.** `socket.socket(fileno=fd)` + re-derives family/type/proto by querying the handle; PyPy on Windows + raises `WinError 10014` doing that to a `WSADuplicateSocket` handle. No + in-process probe catches it — `share()` and `fromshare()` both work + in-process. What cracked it was noticing which test *passed*: + `popen//transport=socket` was green while the server path failed, which + isolated the difference to one line. +- **`execnet server :0` reported a port nothing listened on.** A wildcard + bind with an ephemeral port gives *each* address family its own random + port and only the first was reported — and which family comes first is + platform-dependent (IPv4 on Linux, IPv6 on Windows). +- **A worker warning can livelock a pytest run.** `execnet.dumps` warned on + every access and xdist calls it from `serialize_warning_message`, i.e. + from inside pytest's warning-recording hook: one `DeprecationWarning` in + a worker recorded a warning that recorded a warning, unbounded. +- **Do not rewrite a caller's spec.** `makegateway` wrote the normalized + profile back onto the caller's `XSpec`; xdist reuses one spec object and + re-reads it to decide whether to prefix again, so it prefixed twice and + built `execmodel=…//execmodel=…//popen`. Every crashed-worker-replacement + test failed. Filling in a *missing* value is idempotent and fine. +- **A killed worker resets a socket where a pipe reaches EOF** — the reader + has to map `BrokenResourceError` to `EOFError` (`f484d14`). Applies to + `socket=` on POSIX too; only nobody had looked. +- `shlex.quote("~/…")` creates a directory literally named `~`; use + `"$HOME"`. And a cached-wheel skip branch must still drain stdin, or the + coordinator gets EPIPE. +- **Generated source is read as UTF-8** (PEP 3120) regardless of locale; + `test_basics` wrote it in the locale encoding and one em-dash in + `_message` broke it off UTF-8 locales. +- Hypothesis found a real serializer bug while stress-testing channels: + `_save_integral` only bounds-checked the *upper* int4 limit, so an int + below `-2**31` overflowed `struct.pack('!i', …)` instead of taking the + long path. +- The doc examples had not been collectable since pytest 7 (a + `pytest_plugins` line in a non-top-level conftest), which is why so much + of them had rotted. `tox -e docs` now runs them as doctests with `-W`. +- The 11 consistent XPASSes were investigated and the `flakytest` marks + kept deliberately: trio's single-loop dispatch plus FIFO admission makes + them pass when idle, but `test_gateway_status_busy` (a `_track_start` + scheduling race) and `test_popen_stderr_tracing` (capfd) still fail under + sustained load. To retire the status marks for real, retry-poll for + `numexecuting == 2` the way those tests already poll for `== 0`. +- The hybrid main-thread claim is **best-effort**, and that surfaced as an + `-n 12` flake rather than by reasoning: `main_thread_only` serialized, so + every sequential `remote_exec` got the main thread, whereas `thread` + releases its claim just after the channel close that lets the coordinator + send the next request — so an immediate re-exec can rarely land on a pool + thread. The *first* request is still deterministic. + +## Decisions that were made and then unmade + +Do not resurrect these; each was tried on paper or in code and dropped. + +| dropped | replaced by | why | +|---|---|---| +| `loop=` / `exec=` spec axes | named worker profiles on one key | use-cases, not axes; the combinations were not all meaningful | +| `wait=` spec key | the namespace you import | it described the *caller's* concurrency library, which the namespace already says | +| a `Wakener` registry (`register_wakener`, lazy backend modules) | a two-branch `make_wakener("thread"\|"gevent")` | exactly two backends exist and there was never a plan to let third parties add event loops | +| `execnet.portal` as public API | private `execnet._portal` + `execnet._boundary` | it published the kit but not the registration hook, so the advertised extension point was unreachable | +| an `AsyncioWakener` + `Mailbox` for `execnet.aio` | a per-call `_HostBridge` (host task + `call_soon_threadsafe` future) | real awaitables over the trio-native objects; simpler and semantically exact. Cancellation was made real in the surface review | +| `main_thread_only`'s concurrent-exec deadlock guard | nothing | it was a 1s-timeout guard whose window false-fired under CPU contention; the restored hybrid `thread` profile already gives the first exec the main thread | +| `aio.Group` / `Gateway` / `Channel` | `aio.AsyncGroup` / … | matches `execnet.trio`, so swapping the import ports the code | +| `open_popen_gateway` | `open_gateway` | it always accepted any spec | +| one `TrioHost` per `Group` | one shared `Host` per process, `Group(host=)` | a host is a thread and a loop, not something groups need isolated | +| a trampoline process for Windows stdio | `ThreadedFdStream` | see the rejection note in `ROADMAP-3.0.md` | +| an anyio/asyncio *core* port | `execnet.aio` over the trio host | rejected for now; the portability invariants keep the door open | +| eventlet | — | dead, deliberately | diff --git a/pyproject.toml b/pyproject.toml index fcd2b4b5..fc02866c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,14 @@ description = "execnet: rapid multi-Python deployment" readme = {"file" = "README.rst", "content-type" = "text/x-rst"} license = "MIT" requires-python = ">=3.10" +# Nothing on Python 3.11+, where the asyncio engine runs the protocol. +# Below that there is no ``asyncio.TaskGroup`` and trio is the only engine, +# so it stays required there; 3.10 reaches end of life in October 2026 and +# this line goes with it. ``execnet[trio]`` asks for trio anywhere, and it +# is preferred whenever it is installed -- see ``execnet._engine``. +dependencies = [ + "trio>=0.32; python_version < '3.11'", +] authors = [ { name = "holger krekel and others" }, ] @@ -27,6 +35,7 @@ classifiers = [ "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: 3.15", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Software Development :: Libraries", @@ -34,20 +43,44 @@ classifiers = [ "Topic :: System :: Networking", ] +[project.scripts] +execnet = "execnet._cli:main" +# deprecated alias for `execnet server` +execnet-socketserver = "execnet._cli:socketserver_main" + [project.optional-dependencies] +gevent = [ + "gevent", +] +trio = [ + "trio>=0.32", +] +# what running the test suite needs -- tox installs exactly this, so there +# is one list rather than two that drift (asyncssh and hypothesis were +# missing from tox's own list, which failed collection in CI) testing = [ - "pre-commit", "pytest>8.0", "pytest-timeout", - "tox", - "hatch", + "hypothesis", + "asyncssh", "uv", - "gevent", + # the suite runs against both engines, so it needs trio on every Python + "trio>=0.32", ] [dependency-groups] -testing = [ +# the default group: everything a working checkout wants. Keeping the +# test requirements here rather than in a separate non-default group means +# a plain `uv sync` can run the suite. +dev = [ "execnet[testing]", + "pytest-xdist>=3.8.0", + "pre-commit", + "tox", + "hatch", +] +gevent = [ + "gevent>=26.7.0", ] [project.urls] @@ -107,8 +140,6 @@ include = [ "/testing", "tox.ini", ] -[tool.uv] -default-groups = ["testing"] [tool.mypy] python_version = "3.10" mypy_path = ["src"] @@ -121,8 +152,5 @@ disallow_untyped_defs = false disallow_incomplete_defs = false [[tool.mypy.overrides]] -module = [ - "eventlet.*", - "gevent.thread.*", -] +module = ["asyncssh.*"] ignore_missing_imports = true diff --git a/src/execnet/__init__.py b/src/execnet/__init__.py index 1edf3254..98717623 100644 --- a/src/execnet/__init__.py +++ b/src/execnet/__init__.py @@ -4,49 +4,132 @@ pure python lib for connecting to local and remote Python Interpreters. +One namespace per concurrency library you drive execnet from: + +* :mod:`execnet.sync` — the blocking API for plain threads; the top-level + ``execnet.*`` names below are aliases into it. +* :mod:`execnet.trio` — the trio-native API, awaited inside your own + ``trio.run``, with protocol IO on a shared engine. +* :mod:`execnet.raw_trio` — execnet embedded in your own trio run: the + gateways are tasks in your nursery and there is no engine at all. +* :mod:`execnet.aio` — the asyncio-native API. +* :mod:`execnet.gevent` — the blocking API with greenlet-parking waits. + +:mod:`execnet.raw_trio` is the only one that runs gateways *directly* as +tasks in your own nursery; the others put protocol IO on a shared +:class:`ProtocolEngine`, and the two blocking ones must therefore not be +called from inside a running event loop. + +``can_send`` sits here rather than on any one of them: the wire-format +contract is the same whichever surface you drive a gateway from. + (c) 2012, Holger Krekel and others """ +from typing import Any + +from ._serialize import can_send from ._version import version as __version__ -from .gateway import Gateway -from .gateway_base import Channel -from .gateway_base import DataFormatError -from .gateway_base import DumpError -from .gateway_base import LoadError -from .gateway_base import RemoteError -from .gateway_base import TimeoutError -from .gateway_base import dump -from .gateway_base import dumps -from .gateway_base import load -from .gateway_base import loads -from .gateway_bootstrap import HostNotFound -from .multi import Group -from .multi import MultiChannel -from .multi import default_group -from .multi import makegateway -from .multi import set_execmodel -from .rsync import RSync -from .xspec import XSpec +from .sync import ActiveGroupsWarning +from .sync import Channel +from .sync import ChannelClosed +from .sync import DataFormatError +from .sync import Deployed +from .sync import Deployment +from .sync import DumpError +from .sync import ExecnetStateError +from .sync import Gateway +from .sync import GatewayGone +from .sync import Group +from .sync import HostNotFound +from .sync import LoadError +from .sync import MultiChannel +from .sync import ProtocolEngine +from .sync import RemoteError +from .sync import RSync +from .sync import TimeoutError +from .sync import XSpec +from .sync import default_group +from .sync import makegateway +from .sync import set_execmodel +from .sync import set_profile +from .sync import transfer __all__ = [ + "ActiveGroupsWarning", "Channel", + "ChannelClosed", "DataFormatError", + "Deployed", + "Deployment", "DumpError", + "ExecnetStateError", "Gateway", + "GatewayGone", "Group", "HostNotFound", "LoadError", "MultiChannel", + "ProtocolEngine", "RSync", "RemoteError", "TimeoutError", "XSpec", "__version__", + "can_send", "default_group", - "dump", - "dumps", - "load", - "loads", "makegateway", "set_execmodel", + "set_profile", + "transfer", ] + + +#: resolved lazily so ``import execnet`` does not load the trio event loop +#: machinery, plus the deprecated pre-Trio module names -- those used to be +#: reachable here only because the import chain pulled them in, and callers +#: that still do ``execnet.gateway_base.X`` must reach the warning shim. +_LAZY_MODULES = ( + "aio", + "gevent", + "raw_trio", + "trio", + "gateway", + "gateway_base", + "multi", + "rsync", + "rsync_remote", + "xspec", +) + + +#: TEMPORARY pytest-xdist compatibility. ``xdist/remote.py`` probes +#: serializability with ``try: execnet.dumps(x) / except execnet.DumpError`` +#: before shipping warning args and report attrs. The standalone serializer +#: is internal and :func:`can_send` replaces that probe, but dropping the name +#: outright breaks every released xdist, so it stays reachable -- deliberately +#: absent from ``__all__`` and from ``dir()``. +#: +#: It does NOT warn, on purpose. xdist reaches it from +#: ``serialize_warning_message``, i.e. from inside pytest's warning-recording +#: hook and once per warning a *user's* test raises. A warning there is +#: attributed to that test, in a run the user cannot change the outcome of +#: (porting the probe is xdist's call, not theirs) -- and warning on every +#: access made recording one warning record another, unbounded, wedging the +#: run. The deprecation lives in the changelog and in the xdist issue. +#: +#: FOLLOW-UP (after the execnet release): port xdist to ``execnet.can_send``, +#: then delete this and its test. Nothing else may be added here. +_XDIST_COMPAT = ("dumps",) + + +def __getattr__(name: str) -> Any: + if name in _LAZY_MODULES: + import importlib + + return importlib.import_module(f".{name}", __name__) + if name in _XDIST_COMPAT: + import importlib + + return getattr(importlib.import_module("._serialize", __name__), name) + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/execnet/__main__.py b/src/execnet/__main__.py new file mode 100644 index 00000000..83640e8d --- /dev/null +++ b/src/execnet/__main__.py @@ -0,0 +1,11 @@ +"""``python -m execnet`` -- the same CLI as the ``execnet`` console script. + +Provisioning emits this form for a direct interpreter launch, where the +console script's location on the target is not knowable; under ``uv run`` +the bare ``execnet`` command resolves inside the provisioned environment. +""" + +from ._cli import main + +if __name__ == "__main__": + main() diff --git a/src/execnet/_aio_io.py b/src/execnet/_aio_io.py new file mode 100644 index 00000000..2349565d --- /dev/null +++ b/src/execnet/_aio_io.py @@ -0,0 +1,270 @@ +"""asyncio implementations of the byte streams and processes the core needs. + +The core reaches IO through :class:`~execnet._trio_gateway.ByteStream` -- +four methods, and structurally satisfied by trio's own stream types. This +module is the other implementation, plus the process handle that goes with +it. + +asyncio's streams are a reader/writer pair rather than one object, and its +errors are ``OSError`` subclasses rather than a resource vocabulary, so +each wrapper here does two things: put the pair behind one object, and +translate what goes wrong into the words the core catches +(:mod:`execnet._async`). + +What is *not* here yet: TCP and unix listeners, which the ``socket=``, +``installvia=`` and ``ssh=`` transports need. ``popen`` -- the default, +and the one every test uses -- needs only what is below. +""" + +from __future__ import annotations + +import asyncio +import subprocess +import sys +from typing import Any + +from ._async import BrokenResource +from ._async import ClosedResource + + +def _translate(exc: BaseException) -> BaseException: + """The core's word for what went wrong with a stream.""" + if isinstance(exc, ConnectionError | BrokenPipeError): + return BrokenResource(str(exc) or type(exc).__name__) + if isinstance(exc, OSError): + return BrokenResource(str(exc) or type(exc).__name__) + return exc + + +class AsyncioByteStream: + """One :class:`ByteStream` over an asyncio reader/writer pair.""" + + def __init__( + self, reader: asyncio.StreamReader | None, writer: asyncio.StreamWriter | None + ) -> None: + self._reader = reader + self._writer = writer + self._closed = False + + async def send_all(self, data: bytes) -> None: + if self._closed or self._writer is None: + raise ClosedResource("stream is closed for sending") + try: + self._writer.write(data) + await self._writer.drain() + except Exception as exc: + raise _translate(exc) from None + + async def receive_some(self, max_bytes: int | None = None) -> bytes: + if self._reader is None: + raise ClosedResource("stream has no receive side") + try: + return await self._reader.read(max_bytes or 65536) + except Exception as exc: + raise _translate(exc) from None + + async def send_eof(self) -> None: + """Half-close the send side, so the peer reads EOF. + + Falls back to closing the writer where the transport cannot + half-close -- a pipe, mostly -- which is what trio's stapled + streams do too. + """ + if self._writer is None: + return + try: + if self._writer.can_write_eof(): + self._writer.write_eof() + else: + self._writer.close() + except Exception as exc: + raise _translate(exc) from None + + async def aclose(self) -> None: + self._closed = True + if self._writer is None: + return + try: + self._writer.close() + await self._writer.wait_closed() + except Exception: + # closing reports what already went wrong; the caller is done + # with the stream either way + pass + + +async def wrap_socket(sock: Any) -> AsyncioByteStream: + """A stream over an already-connected stdlib socket.""" + reader, writer = await asyncio.open_connection(sock=sock) + return AsyncioByteStream(reader, writer) + + +class AsyncioProcess: + """The process handle the core expects, over ``asyncio.subprocess``. + + Trio's ``Process`` exposes ``stdin``/``stdout`` as streams, ``wait``, + ``kill``, ``returncode`` and ``pid``; asyncio's has the same names with + reader/writer objects instead, so only the stream halves need wrapping. + """ + + def __init__(self, process: asyncio.subprocess.Process) -> None: + self._process = process + # as ByteStreams, not raw reader/writer: the core writes a wheel to + # ``process.stdin`` with ``send_all`` and closes it with ``aclose`` + self.stdin = AsyncioByteStream(None, process.stdin) if process.stdin else None + self.stdout = ( + AsyncioByteStream(process.stdout, None) if process.stdout else None + ) + + @property + def pid(self) -> int: + return self._process.pid + + @property + def returncode(self) -> int | None: + return self._process.returncode + + async def wait(self) -> int: + return await self._process.wait() + + def kill(self) -> None: + try: + self._process.kill() + except ProcessLookupError: + pass # already gone, which is what kill was for + + +async def open_process(argv: list[str], **kwargs: Any) -> AsyncioProcess: + """Spawn ``argv``; the asyncio spelling of ``trio.lowlevel.open_process``.""" + process = await asyncio.create_subprocess_exec(*argv, **kwargs) + return AsyncioProcess(process) + + +def staple_process(process: AsyncioProcess) -> AsyncioByteStream: + """One bidirectional stream over a process's stdin/stdout pair.""" + assert process.stdin is not None + assert process.stdout is not None + return AsyncioByteStream(process.stdout._reader, process.stdin._writer) + + +async def staple_fds(read_fd: int, write_fd: int) -> AsyncioByteStream: + """One stream over a pair of blocking fds (POSIX). + + The Windows path uses the threaded stand-in in the core, as it does for + trio, because neither library can wait on a Windows pipe handle. + """ + if sys.platform == "win32": # pragma: no cover - POSIX-only path + raise NotImplementedError("use the threaded fd stream on Windows") + loop = asyncio.get_running_loop() + + reader = asyncio.StreamReader() + await loop.connect_read_pipe( + lambda: asyncio.StreamReaderProtocol(reader), _fdopen(read_fd, "rb") + ) + transport, protocol = await loop.connect_write_pipe( + asyncio.streams.FlowControlMixin, _fdopen(write_fd, "wb") + ) + writer = asyncio.StreamWriter(transport, protocol, reader, loop) + return AsyncioByteStream(reader, writer) + + +def _fdopen(fd: int, mode: str) -> Any: + import os + + return os.fdopen(fd, mode, buffering=0) + + +#: subprocess constants re-exported so the core can name them once +DEVNULL = subprocess.DEVNULL +PIPE = subprocess.PIPE + + +class AsyncioListener: + """A listening socket, with the two methods the core uses. + + Kept socket-level rather than built on ``asyncio.start_server``: the + core *accepts* connections one at a time (a dial-back, a one-shot + socket gateway) rather than handing the loop a callback, and it reads + ``listener.socket.getsockname()`` to report the bound address. + """ + + def __init__(self, sock: Any) -> None: + self.socket = sock + + async def accept(self) -> AsyncioByteStream: + loop = asyncio.get_running_loop() + try: + conn, _ = await loop.sock_accept(self.socket) + except Exception as exc: + raise _translate(exc) from None + return await wrap_socket(conn) + + async def aclose(self) -> None: + try: + self.socket.close() + except OSError: + pass + + +async def open_tcp_listeners( + port: int, host: str | None = None +) -> list[AsyncioListener]: + """Bind ``port`` on every address ``host`` resolves to.""" + import socket as _socket + + infos = _socket.getaddrinfo( + host, port, type=_socket.SOCK_STREAM, flags=_socket.AI_PASSIVE + ) + listeners = [] + for family, kind, proto, _canon, address in infos: + sock = _socket.socket(family, kind, proto) + try: + sock.setsockopt(_socket.SOL_SOCKET, _socket.SO_REUSEADDR, 1) + if family == _socket.AF_INET6 and hasattr(_socket, "IPV6_V6ONLY"): + sock.setsockopt(_socket.IPPROTO_IPV6, _socket.IPV6_V6ONLY, 1) + sock.setblocking(False) + sock.bind(address) + sock.listen(128) + except OSError: + sock.close() + continue + listeners.append(AsyncioListener(sock)) + if not listeners: + raise OSError(f"could not bind {host or '*'}:{port}") + return listeners + + +async def unix_listener(path: str) -> AsyncioListener: + """A listening unix socket at ``path``.""" + import socket as _socket + + sock = _socket.socket(_socket.AF_UNIX, _socket.SOCK_STREAM) + sock.setblocking(False) + sock.bind(path) + sock.listen(1) + return AsyncioListener(sock) + + +async def open_tcp_stream(host: str, port: int) -> AsyncioByteStream: + """Connect to ``host:port``. + + A failure to *connect* is left as the ``OSError`` it is, rather than + translated to a stream error: it means the host could not be reached, + which is what the caller turns into ``HostNotFound``. Translation is + for a stream that broke after it was established. + """ + reader, writer = await asyncio.open_connection(host, port) + return AsyncioByteStream(reader, writer) + + +async def serve_listeners(handler: Any, listeners: list[AsyncioListener]) -> None: + """Accept forever, one task per connection.""" + async with asyncio.TaskGroup() as taskgroup: # type: ignore[attr-defined] + for listener in listeners: + + async def accept_loop(listener: AsyncioListener = listener) -> None: + while True: + stream = await listener.accept() + taskgroup.create_task(handler(stream)) + + taskgroup.create_task(accept_loop()) diff --git a/src/execnet/_async.py b/src/execnet/_async.py new file mode 100644 index 00000000..2f22d7ab --- /dev/null +++ b/src/execnet/_async.py @@ -0,0 +1,672 @@ +"""The async vocabulary the protocol core is written against. + +The core needs about a dozen things from whatever async library it is +running on: a task scope, a way to shield cleanup, two kinds of deadline, +an event, a limiter, an unbounded queue, a thread hop, and a handful of +exception types. Both trio and asyncio can provide all of them; naming +them here once is what lets one implementation of the core run on either. + +Which one you get is decided by the loop you are already in -- +:func:`current_async` -- and captured by objects at construction, so the +detection is not paid per call. + +**The two do not agree about cancellation, and this is where that is +handled.** Trio is level-triggered: inside a cancelled scope every later +``await`` raises again, so cleanup needs an explicit shield. asyncio is +edge-triggered: a cancel is delivered once, and cleanup after catching it +simply runs. So :meth:`AsyncLib.shielded` is a real cancel scope on trio +and a no-op on asyncio -- both spell "this cleanup completes". What +asyncio cannot promise is completion against a *second* cancel, which in +execnet only the engine's own shutdown can send; it sends one, then waits +out a grace, which is what makes the two equivalent in practice. + +Everything here is a context manager or a small object, and the trio side +returns trio's own objects wherever it can, so the abstraction costs +nothing on the path that has always existed. +""" + +from __future__ import annotations + +import sys +from collections.abc import Callable +from contextlib import contextmanager +from types import TracebackType +from typing import TYPE_CHECKING +from typing import Any +from typing import TypeVar + +if TYPE_CHECKING: + from collections.abc import Iterator + + from typing_extensions import Self + +T = TypeVar("T") + +#: the smallest Python the asyncio backend runs on: ``TaskGroup``, +#: ``BaseExceptionGroup`` and ``Task.uncancel`` are all 3.11. +MIN_ASYNCIO_PYTHON = (3, 11) + + +class AsyncLibUnavailable(RuntimeError): + """This interpreter cannot run the requested async backend.""" + + +# -- trio ------------------------------------------------------------------- + + +class TrioAsync: + """The vocabulary on trio, which mostly means trio's own objects.""" + + name = "trio" + + def __init__(self) -> None: + import trio + + self._trio = trio + self.Cancelled = trio.Cancelled + self.BrokenResource = trio.BrokenResourceError + self.ClosedResource = trio.ClosedResourceError + self.TooSlow = trio.TooSlowError + #: a stream that is gone, either end + self.STREAM_GONE = (trio.BrokenResourceError, trio.ClosedResourceError) + #: an inbox with nothing more coming + self.CHANNEL_EMPTY = (trio.EndOfChannel, trio.ClosedResourceError) + #: ...plus "nothing right now", for the drain loops + self.CHANNEL_UNUSABLE = ( + trio.WouldBlock, + trio.EndOfChannel, + trio.ClosedResourceError, + ) + + def event(self) -> Any: + return self._trio.Event() + + def limiter(self, total: int) -> Any: + return self._trio.CapacityLimiter(total) + + def thread_budget(self) -> int: + """How many threads this loop will run at once.""" + limiter = self._trio.to_thread.current_default_thread_limiter() + return int(limiter.total_tokens) + + def queue(self) -> tuple[Any, Any]: + return self._trio.open_memory_channel[Any](float("inf")) + + def task_scope(self) -> Any: + return _TrioTaskScope(self._trio) + + @contextmanager + def shielded(self) -> Iterator[None]: + with self._trio.CancelScope(shield=True): + yield + + def move_on_after(self, seconds: float) -> Any: + return self._trio.move_on_after(seconds) + + def fail_after(self, seconds: float) -> Any: + return self._trio.fail_after(seconds) + + def cancel_scope(self) -> Any: + """A scope some *other* task can cancel; see :meth:`AsyncioAsync.cancel_scope`.""" + return self._trio.CancelScope() + + async def to_thread( + self, + fn: Callable[..., T], + *args: Any, + limiter: Any = None, + abandon_on_cancel: bool = False, + ) -> T: + result: T = await self._trio.to_thread.run_sync( + fn, *args, limiter=limiter, abandon_on_cancel=abandon_on_cancel + ) + return result + + async def checkpoint(self) -> None: + await self._trio.lowlevel.checkpoint() + + async def sleep_forever(self) -> None: + await self._trio.sleep_forever() + + # -- IO: the streams, processes and listeners the transports build -- + + async def open_process(self, argv: list[str], **kwargs: Any) -> Any: + return await self._trio.lowlevel.open_process(argv, **kwargs) + + def staple_process(self, process: Any) -> Any: + return self._trio.StapledStream(process.stdin, process.stdout) + + async def wrap_socket(self, sock: Any) -> Any: + return self._trio.SocketStream(self._trio.socket.from_stdlib_socket(sock)) + + async def staple_fds(self, read_fd: int, write_fd: int) -> Any: + return self._trio.StapledStream( + self._trio.lowlevel.FdStream(write_fd), + self._trio.lowlevel.FdStream(read_fd), + ) + + async def open_tcp_stream(self, host: str, port: int) -> Any: + return await self._trio.open_tcp_stream(host, port) + + async def open_tcp_listeners(self, port: int, host: str | None = None) -> Any: + return await self._trio.open_tcp_listeners(port, host=host) + + async def unix_listener(self, path: str) -> Any: + sock = self._trio.socket.socket( + self._trio.socket.AF_UNIX, self._trio.socket.SOCK_STREAM + ) + await sock.bind(path) + sock.listen(1) + return self._trio.SocketListener(sock) + + async def serve_listeners(self, handler: Any, listeners: Any) -> None: + await self._trio.serve_listeners(handler, listeners) + + +class _TrioTaskScope: + """A nursery, behind the neutral name.""" + + def __init__(self, trio_module: Any) -> None: + self._trio = trio_module + self._manager: Any = None + self._nursery: Any = None + + async def __aenter__(self) -> Self: + self._manager = self._trio.open_nursery() + self._nursery = await self._manager.__aenter__() + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> bool | None: + # the handles stay live until the scope has actually finished: a + # child running during teardown may still cancel the scope, which is + # how the core races an accept against a process exiting + manager = self._manager + try: + exited: bool | None = await manager.__aexit__( + exc_type, exc_value, traceback + ) + finally: + self._manager = None + self._nursery = None + return exited + + def start_soon(self, async_fn: Callable[..., Any], *args: Any) -> None: + self._nursery.start_soon(async_fn, *args) + + async def start(self, async_fn: Callable[..., Any], *args: Any) -> Any: + return await self._nursery.start(async_fn, *args) + + def cancel(self) -> None: + self._nursery.cancel_scope.cancel() + + +# -- asyncio ---------------------------------------------------------------- + + +class AsyncioAsync: + """The same vocabulary on asyncio. + + Every difference from trio that the core can see is confined to this + class and the small objects below it. + """ + + name = "asyncio" + + def __init__(self) -> None: + if sys.version_info < MIN_ASYNCIO_PYTHON: + want = ".".join(str(part) for part in MIN_ASYNCIO_PYTHON) + raise AsyncLibUnavailable( + f"the asyncio backend needs Python {want} or newer:" + " it is written against asyncio.TaskGroup, asyncio.timeout" + " and Task.uncancel, and execnet carries no backport." + ) + import asyncio + + self._asyncio = asyncio + self.Cancelled = asyncio.CancelledError + self.BrokenResource = BrokenResource + self.ClosedResource = ClosedResource + self.TooSlow = TimeoutError + self.STREAM_GONE = (BrokenResource, ClosedResource, ConnectionError) + self.CHANNEL_EMPTY = (EndOfChannel, ClosedResource) + self.CHANNEL_UNUSABLE = (WouldBlock, EndOfChannel, ClosedResource) + + def event(self) -> Any: + return self._asyncio.Event() + + def limiter(self, total: int) -> Any: + return _Limiter(self._asyncio.Semaphore(total), total) + + def thread_budget(self) -> int: + """How many threads this loop will run at once. + + asyncio builds its default executor lazily, so before the first + ``to_thread`` there is nothing to read and this falls back to the + same default CPython would have chosen. Where execnet owns the loop + the engine installs an executor of a known size, and then this is + simply that number. + """ + import os + + executor = getattr(self._asyncio.get_running_loop(), "_default_executor", None) + workers = getattr(executor, "_max_workers", None) + if workers is None: + workers = min(32, (os.cpu_count() or 1) + 4) + return int(workers) + + def queue(self) -> tuple[Any, Any]: + shared = _Inbox(self._asyncio) + return _InboxSender(shared), _InboxReceiver(shared) + + def task_scope(self) -> Any: + return _AsyncioTaskScope(self._asyncio) + + @contextmanager + def shielded(self) -> Iterator[None]: + # Nothing to do: asyncio delivers a cancel once, so the cleanup this + # wraps runs on its own. The name stays for the reader, and for the + # trio side where the shield is mandatory. + yield + + def move_on_after(self, seconds: float) -> Any: + return _Deadline(self._asyncio, seconds, raising=False) + + def fail_after(self, seconds: float) -> Any: + return _Deadline(self._asyncio, seconds, raising=True) + + def cancel_scope(self) -> Any: + """A scope some *other* task can cancel. + + The one place the core needs cancellation it can *aim*, rather than + a deadline or a shield: the bridge cancels an engine-side operation + when the caller awaiting it goes away. On asyncio that is the + task's own cancellation, which is why the scope has to be entered by + the task it will cancel. + """ + return _Deadline(self._asyncio, None, raising=False) + + async def to_thread( + self, + fn: Callable[..., T], + *args: Any, + limiter: Any = None, + abandon_on_cancel: bool = False, + ) -> T: + # asyncio.to_thread is always abandon-on-cancel: a cancelled caller + # stops waiting and the thread runs on. Every execnet caller that + # names the flag asks for exactly that. + if limiter is None: + result: T = await self._asyncio.to_thread(fn, *args) + return result + async with limiter: + return await self._asyncio.to_thread(fn, *args) + + async def checkpoint(self) -> None: + await self._asyncio.sleep(0) + + async def sleep_forever(self) -> None: + await self._asyncio.Event().wait() + + # -- IO: see :mod:`execnet._aio_io` for the implementations -- + + async def open_process(self, argv: list[str], **kwargs: Any) -> Any: + from ._aio_io import open_process + + return await open_process(argv, **kwargs) + + def staple_process(self, process: Any) -> Any: + from ._aio_io import staple_process + + return staple_process(process) + + async def wrap_socket(self, sock: Any) -> Any: + from ._aio_io import wrap_socket + + return await wrap_socket(sock) + + async def staple_fds(self, read_fd: int, write_fd: int) -> Any: + from ._aio_io import staple_fds + + return await staple_fds(read_fd, write_fd) + + async def open_tcp_stream(self, host: str, port: int) -> Any: + from ._aio_io import open_tcp_stream + + return await open_tcp_stream(host, port) + + async def open_tcp_listeners(self, port: int, host: str | None = None) -> Any: + from ._aio_io import open_tcp_listeners + + return await open_tcp_listeners(port, host=host) + + async def unix_listener(self, path: str) -> Any: + from ._aio_io import unix_listener + + return await unix_listener(path) + + async def serve_listeners(self, handler: Any, listeners: Any) -> None: + from ._aio_io import serve_listeners + + await serve_listeners(handler, listeners) + + +class ClosedResource(Exception): + """This end was closed locally (asyncio's spelling of trio's).""" + + +class BrokenResource(Exception): + """The other end went away (asyncio's spelling of trio's).""" + + +class EndOfChannel(Exception): + """Nothing more is coming on this inbox.""" + + +class WouldBlock(Exception): + """Nothing available right now.""" + + +class _Shutdown(BaseException): + """Raised out of a TaskGroup body to cancel every child.""" + + +class _Limiter: + """A semaphore that also reports its size, like trio's CapacityLimiter.""" + + def __init__(self, semaphore: Any, total: int) -> None: + self._semaphore = semaphore + self.total_tokens = total + + async def __aenter__(self) -> None: + await self._semaphore.acquire() + + async def __aexit__(self, *exc_info: object) -> None: + self._semaphore.release() + + +class _Deadline: + """``move_on_after`` / ``fail_after`` as a *synchronous* context manager. + + Also stands in for ``trio.CancelScope`` when built without a deadline: + the two differ only in what pulls the trigger. + + ``asyncio.timeout`` is an async context manager, which would make every + deadline in the core read differently from trio's. Nothing it does on + entry or exit actually needs to await, so this does the same work + synchronously: arm a timer that cancels the current task, and on the way + out decide whether the cancellation that arrived was ours. + + That decision is the delicate part, and it follows CPython's own + ``asyncio.timeouts`` exactly: remember how many cancellations the task + had been asked for on entry, and only swallow one if ``uncancel()`` + brings the count back to that number. Anything more means an outer + cancel is also in flight and must not be eaten. + """ + + def __init__( + self, asyncio_module: Any, seconds: float | None, *, raising: bool + ) -> None: + self._asyncio = asyncio_module + self._seconds = seconds + self._raising = raising + self._task: Any = None + self._handle: Any = None + self._cancelling = 0 + self._expired = False + #: mirrors ``trio.CancelScope.cancelled_caught`` + self.cancelled_caught = False + + def _fire(self) -> None: + self._expired = True + self._task.cancel() + + def __enter__(self) -> Self: + self._task = self._asyncio.current_task() + self._cancelling = self._task.cancelling() + if self._seconds is not None: + loop = self._asyncio.get_running_loop() + self._handle = loop.call_later(self._seconds, self._fire) + return self + + def cancel(self) -> None: + """Cancel this scope from another task (or from this one).""" + self._expired = True + if self._task is not None: + self._task.cancel() + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> bool: + if self._handle is not None: + self._handle.cancel() + if not self._expired or exc_type is None: + return False + if not issubclass(exc_type, self._asyncio.CancelledError): + return False + if self._task.uncancel() > self._cancelling: + # an outer cancellation is in flight too; it is not ours to eat + return False + self.cancelled_caught = True + if self._raising: + raise TimeoutError(f"no result within {self._seconds} seconds") from None + return True + + +class _AsyncioTaskScope: + """A ``TaskGroup`` with trio's two extras: ``start`` and ``cancel``.""" + + def __init__(self, asyncio_module: Any) -> None: + self._asyncio = asyncio_module + self._group: Any = None + + async def __aenter__(self) -> Self: + self._group = self._asyncio.TaskGroup() + await self._group.__aenter__() + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> bool: + group = self._group + try: + await group.__aexit__(exc_type, exc_value, traceback) + except BaseExceptionGroup as raised: # type: ignore[name-defined] # noqa: F821 + _, remaining = raised.split(_Shutdown) + if remaining is not None: + raise remaining from None + return True + finally: + self._group = None + return False + + def start_soon(self, async_fn: Callable[..., Any], *args: Any) -> None: + self._group.create_task(async_fn(*args)) + + async def start(self, async_fn: Callable[..., Any], *args: Any) -> Any: + """Start ``async_fn`` and wait until it reports itself ready. + + ``TaskGroup`` has no equivalent, so the protocol trio uses is built + here: the task is handed a ``task_status`` to call ``started()`` on. + A failure *before* that call goes to whoever called ``start`` and + nowhere else -- trio removes such a task from the nursery, and since + a ``TaskGroup`` child cannot be un-enrolled the failure is caught + instead and the task ends quietly. + """ + status = TaskStatus(self._asyncio) + self._group.create_task(_until_ready(async_fn, args, status)) + return await status.wait() + + def cancel(self) -> None: + """Cancel every child, the way ``nursery.cancel_scope.cancel()`` does.""" + self._group.create_task(_raise_shutdown()) + + +async def _raise_shutdown() -> None: + raise _Shutdown + + +class TaskStatus: + """The ``task_status`` object a started task reports through.""" + + def __init__(self, asyncio_module: Any) -> None: + self._event = asyncio_module.Event() + self._value: Any = None + self._error: BaseException | None = None + + def started(self, value: Any = None) -> None: + if self._event.is_set(): + raise RuntimeError("task_status.started() called more than once") + self._value = value + self._event.set() + + def fail(self, error: BaseException) -> None: + self._error = error + self._event.set() + + def is_set(self) -> bool: + return bool(self._event.is_set()) + + async def wait(self) -> Any: + await self._event.wait() + if self._error is not None: + raise self._error + return self._value + + +async def _until_ready( + async_fn: Callable[..., Any], args: tuple[Any, ...], status: TaskStatus +) -> None: + try: + await async_fn(*args, task_status=status) + except BaseException as exc: + if not status.is_set(): + status.fail(exc) + return + raise + if not status.is_set(): + status.fail( + RuntimeError(f"{async_fn!r} ended without calling task_status.started()") + ) + + +class _Inbox: + """The shared state behind an unbounded queue pair.""" + + def __init__(self, asyncio_module: Any) -> None: + self.queue = asyncio_module.Queue() + self.closed = False + + +class _InboxSender: + def __init__(self, shared: _Inbox) -> None: + self._shared = shared + + def send_nowait(self, item: Any) -> None: + if self._shared.closed: + raise ClosedResource("inbox is closed") + self._shared.queue.put_nowait(item) + + async def send(self, item: Any) -> None: + """The awaitable form; never actually waits, the queue is unbounded.""" + self.send_nowait(item) + + def close(self) -> None: + if self._shared.closed: + return + self._shared.closed = True + # wake a waiting receiver so it can see the close + self._shared.queue.put_nowait(_EOF) + + +class _InboxReceiver: + def __init__(self, shared: _Inbox) -> None: + self._shared = shared + + def _unwrap(self, item: Any) -> Any: + if item is _EOF: + # put it back: every later receive must see the end too + self._shared.queue.put_nowait(_EOF) + raise EndOfChannel("inbox is closed") + return item + + def receive_nowait(self) -> Any: + try: + item = self._shared.queue.get_nowait() + except Exception as exc: # asyncio.QueueEmpty + raise WouldBlock("nothing available") from exc + return self._unwrap(item) + + async def receive(self) -> Any: + return self._unwrap(await self._shared.queue.get()) + + def __aiter__(self) -> _InboxReceiver: + return self + + async def __anext__(self) -> Any: + try: + return await self.receive() + except EndOfChannel: + raise StopAsyncIteration from None + + +#: the end-of-inbox marker; a private object so no payload can be mistaken for it +_EOF = object() + + +# -- picking one ------------------------------------------------------------ + +_TRIO: TrioAsync | None = None +_ASYNCIO: AsyncioAsync | None = None + + +def for_backend(name: str) -> Any: + """The vocabulary for a named backend, built once per process.""" + global _TRIO, _ASYNCIO + if name == "trio": + if _TRIO is None: + _TRIO = TrioAsync() + return _TRIO + if name == "asyncio": + if _ASYNCIO is None: + _ASYNCIO = AsyncioAsync() + return _ASYNCIO + raise ValueError(f"unknown async backend {name!r}") + + +def current_async() -> Any: + """The vocabulary for the loop running in *this* thread. + + Objects capture this once, at construction, so nothing pays for the + detection per operation. + """ + trio = sys.modules.get("trio") + if trio is not None: + try: + trio.lowlevel.current_trio_token() + except RuntimeError: + pass + else: + return for_backend("trio") + asyncio = sys.modules.get("asyncio") + if asyncio is not None: + try: + asyncio.get_running_loop() + except RuntimeError: + pass + else: + return for_backend("asyncio") + raise RuntimeError( + "no running event loop: execnet's protocol core has to be built" + " inside the loop it will run on" + ) diff --git a/src/execnet/_asyncio_engine.py b/src/execnet/_asyncio_engine.py new file mode 100644 index 00000000..093c3132 --- /dev/null +++ b/src/execnet/_asyncio_engine.py @@ -0,0 +1,342 @@ +"""The engine loop on asyncio, beside the one on trio. + +Same contract as :class:`~execnet._trio_engine.TrioEngine` -- a loop on a +thread of its own, a portal into it, one door to its root task scope, and +the groups it is serving -- so that :class:`~execnet.ProtocolEngine` can be +handed either. What differs is only how the loop is spelled. + +**This engine cannot host gateways yet.** The protocol core +(:mod:`execnet._trio_gateway`) is still trio-native, so a group built on an +asyncio engine is refused with a message saying so rather than failing +somewhere inside trio. What works today is everything the engine itself +promises: starting and stopping, the portal, tasks on the root scope, and +the group registry. That is the seam being proved before the core is +ported through it. + +Two places where asyncio needs saying out loud: + +* ``TaskGroup`` has no ``nursery.start()`` -- no way to start a task and + wait until it reports itself ready. :meth:`AsyncioEngine.start_task` + builds one, with trio's semantics: a failure *before* the task reports + ready goes to whoever called ``start_task`` and nowhere else, and only a + failure afterwards reaches the group. +* Cancelling the root scope is spelled by raising out of the ``TaskGroup`` + body rather than by cancelling a scope object. + +Requires Python 3.11: ``TaskGroup`` and ``asyncio.timeout`` are the +semantics the core is written against, and emulating them on 3.10 would +mean maintaining a second, worse implementation for a release that reaches +end of life in October 2026. Older Pythons keep the trio engine. + +``mypy`` and ``ruff`` check this project at its floor, 3.10, where the names +this module is built on do not exist -- hence the ignores on them. They are +the only ones here, and they go away when the floor reaches 3.11. +""" + +from __future__ import annotations + +import asyncio +import sys +import threading +from collections.abc import Awaitable +from collections.abc import Callable +from typing import Any +from typing import TypeVar + +from ._boundary import Wakener +from ._engine import DEFAULT_CALLBACK_THREADS +from ._errors import LoopFinishedError +from ._portal import AsyncioPortal +from ._portal import OneShot + +T = TypeVar("T") + +#: the floor for this backend, and why +MIN_PYTHON = (3, 11) + + +def check_asyncio_available() -> None: + """Refuse the asyncio engine where its semantics do not exist. + + Before a thread exists to fail on, and naming the fix -- the same shape + as the gevent refusal in :mod:`execnet._trio_engine`. + """ + if sys.version_info >= MIN_PYTHON: + return + have = ".".join(str(part) for part in sys.version_info[:3]) + want = ".".join(str(part) for part in MIN_PYTHON) + raise RuntimeError( + f"the asyncio engine needs Python {want} or newer (this is {have}):" + " it is written against asyncio.TaskGroup and asyncio.timeout, and" + " execnet does not carry a backport of them. Use the default trio" + " engine on this interpreter." + ) + + +class _Shutdown(BaseException): + """Raised out of the root TaskGroup body to cancel every child.""" + + +class _TaskStatus: + """The ``task_status`` object :meth:`AsyncioEngine.start_task` passes in. + + Duck-types trio's, so the same task functions work on both engines: a + task calls ``started(value)`` once it is ready to be used, and the + caller of ``start_task`` gets that value back. + """ + + def __init__(self) -> None: + self._event = asyncio.Event() + self._value: Any = None + self._error: BaseException | None = None + + def started(self, value: Any = None) -> None: + if self._event.is_set(): + raise RuntimeError("task_status.started() called more than once") + self._value = value + self._event.set() + + def fail(self, error: BaseException) -> None: + self._error = error + self._event.set() + + def is_set(self) -> bool: + return self._event.is_set() + + async def wait(self) -> Any: + await self._event.wait() + if self._error is not None: + raise self._error + return self._value + + +async def _until_ready( + async_fn: Callable[..., Any], args: tuple[Any, ...], status: _TaskStatus +) -> None: + """Run ``async_fn``, routing a pre-ready failure to the starter. + + Trio's ``nursery.start()`` hands a failure that happens before + ``started()`` to the caller of ``start()`` and does *not* also fail the + nursery. A ``TaskGroup`` child cannot be un-enrolled, so the failure is + caught here instead and the task ends quietly, leaving the starter to + raise it. + """ + try: + await async_fn(*args, task_status=status) + except BaseException as exc: + if not status.is_set(): + status.fail(exc) + return + raise + if not status.is_set(): + status.fail( + RuntimeError(f"{async_fn!r} ended without calling task_status.started()") + ) + + +class AsyncioEngine: + """Dedicated OS thread running ``asyncio.run`` for protocol IO.""" + + #: which async library this engine's loop is; see ``ProtocolEngine`` + backend = "asyncio" + + def __init__( + self, + name: str = "execnet-asyncio-engine", + callback_threads: int = DEFAULT_CALLBACK_THREADS, + ) -> None: + check_asyncio_available() + self._name = name + self._callback_threads = callback_threads + self._thread: threading.Thread | None = None + self._portal: AsyncioPortal | None = None + self._taskgroup: Any = None + self._ready = threading.Event() + self._shutdown: asyncio.Event | None = None + self._started = False + self._callback_limiter: asyncio.Semaphore | None = None + self._startup_error: BaseException | None = None + #: engine-side groups running here; engine thread only, so no lock + self._groups: list[Any] = [] + + def start(self) -> None: + if self._started: + return + self._thread = threading.Thread(target=self._run, name=self._name, daemon=True) + self._thread.start() + if not self._ready.wait(timeout=30): + raise RuntimeError("AsyncioEngine failed to start within 30s") + error = self._startup_error + if error is not None: + raise RuntimeError( + f"the execnet engine loop could not start: {error!r}" + ) from error + self._started = True + + @property + def portal(self) -> AsyncioPortal: + if self._portal is None: + raise RuntimeError("AsyncioEngine is not running") + return self._portal + + @property + def _limiter(self) -> asyncio.Semaphore: + """Bound on concurrent threadpool threads running receiver callbacks.""" + if self._callback_limiter is None: + raise RuntimeError("AsyncioEngine is not running") + return self._callback_limiter + + def _on_engine_thread(self) -> bool: + return self._portal is not None and self._portal.is_loop_thread() + + def _run(self) -> None: + try: + asyncio.run(self._main()) + except BaseException as exc: + if self._ready.is_set(): + # the loop was up and died later: nobody is waiting on us, + # so let the thread report it the loud way + raise + self._startup_error = exc + self._ready.set() + + async def _main(self) -> None: + self._portal = AsyncioPortal() + self._shutdown = asyncio.Event() + self._callback_limiter = asyncio.Semaphore(self._callback_threads) + try: + try: + async with asyncio.TaskGroup() as taskgroup: # type: ignore[attr-defined] + self._taskgroup = taskgroup + self._ready.set() + await self._shutdown.wait() + # the asyncio spelling of nursery.cancel_scope.cancel() + raise _Shutdown + except BaseExceptionGroup as group: # type: ignore[name-defined] # noqa: F821 + # split by type, not subgroup(predicate): a predicate is + # offered the *group* as well as its leaves, so + # ``not isinstance(exc, _Shutdown)`` matches the group + # itself and keeps everything. + _, remaining = group.split(_Shutdown) + if remaining is not None: + raise remaining from None + finally: + self._taskgroup = None + + def call(self, async_fn: Callable[..., Awaitable[T]], *args: Any) -> T: + return self.portal.run(async_fn, *args) + + def _call_pending( + self, + async_fn: Callable[..., Awaitable[T]], + *args: Any, + wakener: Wakener | None = None, + ) -> OneShot[T]: + """Run ``async_fn`` as an engine task, resolving a :class:`OneShot`.""" + result: OneShot[T] = OneShot(wakener) + + async def runner() -> None: + try: + value = await async_fn(*args) + except asyncio.CancelledError: + if not result.is_set(): + result.set_error(RuntimeError("asyncio engine was shut down")) + raise + except BaseException as exc: + result.set_error(exc) + else: + result.set(value) + + def spawn() -> None: + # A posted callback that raises reaches the loop's exception + # handler, which only logs -- so the waiter would hang. An + # engine that shut down between the post and here is exactly the + # failure this call already reports as a value. + try: + self.start_soon(runner) + except BaseException as exc: + error = RuntimeError("asyncio engine was shut down") + error.__cause__ = exc + if not result.is_set(): + result.set_error(error) + + self.portal.post(spawn) + return result + + def call_sync(self, sync_fn: Callable[..., T], *args: Any) -> T: + return self.portal.run_sync(sync_fn, *args) + + def start_soon(self, async_fn: Callable[..., Any], *args: Any) -> None: + """Schedule a task on the root scope (must be called on the engine thread).""" + if not self._on_engine_thread(): + raise RuntimeError("start_soon requires the asyncio engine thread") + if self._taskgroup is None: + raise RuntimeError("AsyncioEngine task group is not available") + self._taskgroup.create_task(async_fn(*args)) + + async def start_task(self, async_fn: Callable[..., Any], *args: Any) -> Any: + """Start ``async_fn`` on the root scope and wait until it is ready. + + The one door to the root scope, so that a long-lived task is + something the engine knows it is running. Returns whatever the task + passes to ``task_status.started()``. + """ + if self._taskgroup is None: + raise RuntimeError("AsyncioEngine task group is not available") + status = _TaskStatus() + self._taskgroup.create_task(_until_ready(async_fn, args, status)) + return await status.wait() + + # -- the groups running here (engine thread only) -- + + def _register_group(self, group: Any) -> None: + self._groups.append(group) + + def _forget_group(self, group: Any) -> None: + if group in self._groups: + self._groups.remove(group) + + def live_groups(self) -> str: + """What is still running here, for a message; ``""`` when nothing is.""" + ids = [ + str(gateway.id) + for group in list(self._groups) + for gateway in list(group._gateways) + ] + if not ids: + return "" + return f"{len(self._groups)} group(s), gateways {', '.join(sorted(ids))}" + + async def terminate_groups(self, timeout: float | None = None) -> None: + """Terminate every group running here, concurrently (engine loop).""" + groups = list(self._groups) + if not groups: + return + async with asyncio.TaskGroup() as taskgroup: # type: ignore[attr-defined] + for group in groups: + taskgroup.create_task(group.terminate(timeout)) + for group in groups: + group.shutdown.set() + + def stop(self, timeout: float | None = 5.0) -> bool: + """Cancel the root scope and join the thread; True if it joined.""" + if not self._started or self._portal is None or self._shutdown is None: + return True + + def _set() -> None: + assert self._shutdown is not None + self._shutdown.set() + + try: + # posted rather than run, for the same reason the trio engine + # posts: a caller inside its own running loop cannot block on + # this one, and the join below is the real wait anyway + self._portal.post(_set) + except (LoopFinishedError, RuntimeError): + pass + joined = True + if self._thread is not None: + self._thread.join(timeout=timeout) + joined = not self._thread.is_alive() + self._started = False + return joined diff --git a/src/execnet/_boundary.py b/src/execnet/_boundary.py new file mode 100644 index 00000000..fcd4bae6 --- /dev/null +++ b/src/execnet/_boundary.py @@ -0,0 +1,230 @@ +"""Trio-free half of the boundary kit: Wakener, Mailbox, OneShot. + +Importable without loading any event loop (``import execnet`` must not +import trio); :mod:`execnet._portal` re-exports these next to LoopPortal. + +All of this is internal. There are exactly two wait backends -- OS +threads and gevent greenlets -- and no plan to let third parties add +more: every other concurrency library gets a facade of its own +(:mod:`execnet.trio`, :mod:`execnet.aio`) rather than a wakener. +""" + +from __future__ import annotations + +import queue +import threading +import time +from typing import Any +from typing import Generic +from typing import Literal +from typing import Protocol +from typing import TypeVar +from typing import cast + +from ._errors import TimeoutError + +__all__ = [ + "Flag", + "Mailbox", + "OneShot", + "ThreadWakener", + "WaitBackend", + "Wakener", + "make_wakener", +] + +T = TypeVar("T") + +#: which primitive a facade's blocking waits park on +WaitBackend = Literal["thread", "gevent"] + + +class Wakener(Protocol): + """Thread-safe consumer wakeup fired by the loop. + + ``notify()`` must never block and must be safe from any thread; it is + the only thing the loop side ever calls. The blocking mailbox/oneshot + waits additionally need :meth:`wait`/:meth:`clear` executed in the + consumer's own context (a thread here, a greenlet for a gevent + wakener). + """ + + def notify(self) -> None: ... + + def wait(self, timeout: float | None = None) -> bool: ... + + def clear(self) -> None: ... + + +class ThreadWakener: + """Plain-thread wakener on a ``threading.Event``. + + ``threading.Event.wait`` stays interruptible by KeyboardInterrupt on + the main thread (a C-level ``queue.SimpleQueue.get`` does not), which + is why the carriers wait on the wakener and drain a queue instead of + blocking in the queue itself. + """ + + def __init__(self) -> None: + self._event = threading.Event() + + def notify(self) -> None: + self._event.set() + + def wait(self, timeout: float | None = None) -> bool: + return self._event.wait(timeout) + + def clear(self) -> None: + self._event.clear() + + +class Mailbox(Generic[T]): + """Loop -> consumer item stream: an unbounded queue plus a wakener. + + :meth:`put` never blocks and is safe from any thread (including the + loop thread). :meth:`get` blocks in the consumer's context via the + wakener; after draining to empty it clears and re-checks so a ``put`` + racing the clear cannot be lost. Multiple consumers are allowed. + """ + + def __init__(self, wakener: Wakener | None = None) -> None: + self._items: queue.SimpleQueue[T] = queue.SimpleQueue() + self._wakener = ThreadWakener() if wakener is None else wakener + + def put(self, item: T) -> None: + """Thread-safe; usable from a loop thread (never blocks).""" + self._items.put(item) + self._wakener.notify() + + def get_nowait(self) -> T: + """Return the next item or raise ``queue.Empty``.""" + return self._items.get_nowait() + + def get(self, timeout: float | None = None) -> T: + """Block until an item is available; TimeoutError after ``timeout``.""" + deadline = None if timeout is None else time.monotonic() + timeout + while True: + if deadline is None: + self._wakener.wait() + else: + remaining = deadline - time.monotonic() + if remaining <= 0 or not self._wakener.wait(remaining): + # Final non-blocking check: a put may have raced the + # timeout (its notify landing after our last wait). + try: + return self._items.get_nowait() + except queue.Empty: + raise TimeoutError( + "no item after %r seconds" % timeout + ) from None + try: + return self._items.get_nowait() + except queue.Empty: + # Empty: clear, then re-check so a put between get_nowait + # and clear cannot be lost. + self._wakener.clear() + try: + return self._items.get_nowait() + except queue.Empty: + continue + + +class OneShot(Generic[T]): + """A single result crossing loop -> consumer exactly once. + + The loop side calls :meth:`set` (or :meth:`set_error`) at most once; + the consumer blocks in :meth:`wait`, which returns the value, + re-raises the stored error, or raises ``TimeoutError``. + """ + + _NOTSET = object() + + def __init__(self, wakener: Wakener | None = None) -> None: + self._wakener = ThreadWakener() if wakener is None else wakener + self._value: Any = self._NOTSET + self._error: BaseException | None = None + self._done = False + + def is_set(self) -> bool: + return self._done + + def set(self, value: T) -> None: + """Thread-safe; usable from a loop thread (never blocks).""" + if self._done: + raise RuntimeError("OneShot already resolved") + self._value = value + self._done = True + self._wakener.notify() + + def set_error(self, error: BaseException) -> None: + """Resolve with an error that :meth:`wait` will re-raise.""" + if self._done: + raise RuntimeError("OneShot already resolved") + self._error = error + self._done = True + self._wakener.notify() + + def wait(self, timeout: float | None = None) -> T: + """Block until resolved; TimeoutError after ``timeout`` seconds.""" + deadline = None if timeout is None else time.monotonic() + timeout + while not self._done: + if deadline is None: + self._wakener.wait() + else: + remaining = deadline - time.monotonic() + if (remaining <= 0 or not self._wakener.wait(remaining)) and ( + not self._done + ): + raise TimeoutError("not resolved after %r seconds" % timeout) + if self._error is not None: + raise self._error + return cast("T", self._value) + + +class Flag: + """An idempotent event on a wakener: may be set any number of times. + + Each Flag owns its wakener exclusively -- sharing one wakener between + carriers would lose wakeups (another carrier's ``clear`` can swallow + this one's ``notify``). + """ + + def __init__(self, wakener: Wakener | None = None) -> None: + self._wakener = ThreadWakener() if wakener is None else wakener + self._flag = False + + def is_set(self) -> bool: + return self._flag + + def set(self) -> None: + """Thread-safe; usable from a loop thread (never blocks).""" + self._flag = True + self._wakener.notify() + + def wait(self, timeout: float | None = None) -> bool: + """Block until set; returns whether the flag is set.""" + deadline = None if timeout is None else time.monotonic() + timeout + while not self._flag: + if deadline is None: + self._wakener.wait() + else: + remaining = deadline - time.monotonic() + if remaining <= 0 or not self._wakener.wait(remaining): + break + return self._flag + + +def make_wakener(backend: WaitBackend) -> Wakener: + """Create a fresh wakener for a wait backend. + + Each carrier owns its wakener exclusively (see :class:`Flag`), so this + always returns a new instance. gevent is imported lazily -- it is an + optional dependency of the :mod:`execnet.gevent` facade. + """ + if backend == "thread": + return ThreadWakener() + if backend == "gevent": + from ._gevent_support import GeventWakener + + return GeventWakener() + raise ValueError(f"unknown wait backend {backend!r}") diff --git a/src/execnet/_bridge.py b/src/execnet/_bridge.py new file mode 100644 index 00000000..cb2e0080 --- /dev/null +++ b/src/execnet/_bridge.py @@ -0,0 +1,359 @@ +"""Awaiting engine-side work from a caller's own event loop. + +Two of execnet's surfaces are async and do not own the loop their protocol +IO runs on: :mod:`execnet.aio` cannot (its caller is asyncio and the engine +is trio), and :mod:`execnet.trio` chooses not to, so that a busy caller +loop cannot stall the protocol. Both need the same thing -- run a +trio-native coroutine on the engine, wait for it here, and forward a cancel +in the other direction -- and the engine half of that is identical. Only +*how the caller waits* differs, which is what a :class:`Carrier` is. + +The three things in :meth:`EngineBridge.call` that look incidental and are +not: + +* the ``trio.CancelScope`` is built before the task exists. A cancel + posted immediately can otherwise overtake the task's first step; + cancelling a scope nobody has entered yet still cancels it once entered. +* the function posted to the engine's entry queue must not raise. Trio + turns an exception there into ``TrioInternalError`` and tears the whole + loop down -- every gateway in the process, not just this call's. +* a task cancelled by *us* posts nothing back. The awaiter is already + gone, and resolving a carrier nobody holds is at best wasted work. + +What this cannot make identical is cancellation. The engine-side +operation is cancelled through the scope, but there is a window after the +engine took an item and before it reaches the caller in which the cancel +lands and the item is lost. Callers that must not lose it use +``shield=True``, which the two surfaces then honour in their own idiom -- +see :class:`AsyncioCarrier` and :class:`TrioCarrier`. +""" + +from __future__ import annotations + +import threading +from collections.abc import Awaitable +from collections.abc import Callable +from collections.abc import Sequence +from contextlib import suppress +from typing import TYPE_CHECKING +from typing import Any +from typing import TypeVar + +from ._async import current_async +from ._async import for_backend +from ._errors import LoopFinishedError +from ._trio_gateway import AsyncGroup as _TrioGroup + +if TYPE_CHECKING: + from ._engine import ProtocolEngine + from ._trio_engine import TrioEngine + +T = TypeVar("T") + +#: what a call reports when the engine went away underneath it +ENGINE_GONE = "the execnet engine was shut down" + + +class EngineGroup(_TrioGroup): + """A trio-native group owned by the engine rather than by a caller. + + This is the structural difference between a facade and + :mod:`execnet.raw_trio`. There the group's nursery is on the caller's + stack, so its gateways cannot outlive the ``async with`` that made + them. Here the group is itself a long-lived task on the engine, parked + on :attr:`shutdown`, so a gateway is a handle the caller can hold, pass + around, and close from wherever it likes. + """ + + def __init__(self, termination_timeout: float, engine: TrioEngine) -> None: + super().__init__(termination_timeout) + # built on the engine loop, like FacadeAsyncGroup + self._aio = current_async() + self.engine = engine + self.shutdown = self._aio.event() + self.finished = self._aio.event() + + async def run(self, task_status: Any = None) -> None: + # registered for exactly this task's lifetime, so closing the engine + # knows what it is about to take down + self.engine._register_group(self) + try: + async with self: + if task_status is not None: + task_status.started(self) + await self.shutdown.wait() + finally: + self.engine._forget_group(self) + self.finished.set() + + +class Carrier: + """One result crossing from the engine to a caller's loop. + + Two halves with different rules. :meth:`resolve` runs on the engine + thread: thread-safe, never blocking, and never raising -- it is reached + from an entry-queue callback. :meth:`wait` runs on the caller's loop + and is a normal awaitable there. + + A value that arrives with nobody left to take it is *salvaged* rather + than dropped, if the call supplied somewhere to put it. That is what + keeps a cancelled ``receive`` from eating an item: the engine either + never took one, or took one that comes back. Both the salvage decision + and the delivery run on the caller's loop thread, so they cannot race + each other however the cancel and the result interleave. + """ + + #: where an unclaimed value goes, when the call named somewhere + _salvage: Callable[[Any], None] | None = None + #: set once the awaiter is gone; read by a delivery arriving afterwards + _abandoned = False + + def set_salvage(self, salvage: Callable[[Any], None] | None) -> None: + self._salvage = salvage + + def _give_up(self, result: Any, error: BaseException | None) -> None: + """Hand an unclaimed *value* to the salvage, if there is one. + + An unclaimed *error* is dropped on purpose: it describes the + operation the caller just abandoned, and the next call will raise + its own. + """ + if error is None and self._salvage is not None: + self._salvage(result) + + def resolve(self, result: Any, error: BaseException | None) -> None: + """Deliver to the caller's loop (engine thread; must not raise).""" + raise NotImplementedError + + async def wait( + self, *, shield: bool, on_cancel: Callable[[], None] + ) -> Any: # pragma: no cover - interface + """Await the result, calling ``on_cancel`` if the caller is cancelled.""" + raise NotImplementedError + + +class AsyncioCarrier(Carrier): + """A carrier backed by an ``asyncio.Future``. + + ``shield=True`` is ``asyncio.shield``: the ``CancelledError`` still + reaches the caller, and the engine-side work runs to completion + regardless. That is asyncio's meaning of shielding and it is left + alone -- see :class:`TrioCarrier` for the other one. + """ + + def __init__(self) -> None: + import asyncio + + self._asyncio = asyncio + self._loop = asyncio.get_running_loop() + self._future: Any = self._loop.create_future() + + def resolve(self, result: Any, error: BaseException | None) -> None: + def deliver() -> None: + if self._abandoned or self._future.cancelled(): + self._give_up(result, error) + return + if error is not None: + self._future.set_exception(error) + else: + self._future.set_result(result) + + # the caller's loop may already be gone at interpreter/test teardown; + # the result is simply undeliverable then + with suppress(RuntimeError): + self._loop.call_soon_threadsafe(deliver) + + async def wait(self, *, shield: bool, on_cancel: Callable[[], None]) -> Any: + if shield: + return await self._asyncio.shield(self._future) + try: + return await self._future + except self._asyncio.CancelledError: + # the result may already be here (cancelled between delivery and + # this task being scheduled) or still on its way; mark it either + # way, so whichever of the two runs second does the salvaging + self._abandoned = True + if self._future.done() and not self._future.cancelled(): + self._give_up(self._future.result(), None) + on_cancel() + raise + + +class TrioCarrier(Carrier): + """A carrier backed by a ``trio.Event`` in the caller's own run. + + ``shield=True`` is a shielded ``trio.CancelScope``, so the wait itself + becomes uncancellable and the caller stays until the operation is done. + That differs from :class:`AsyncioCarrier`, deliberately: it is what a + shield means in trio, and it is the stronger guarantee -- an operation + that must not tear in half is also one whose completion the caller + should not run ahead of. + """ + + def __init__(self) -> None: + import trio + + self._trio = trio + self._token = trio.lowlevel.current_trio_token() + self._done = trio.Event() + self._result: Any = None + self._error: BaseException | None = None + + def resolve(self, result: Any, error: BaseException | None) -> None: + def deliver() -> None: + # runs on the caller's loop thread, from its entry queue, so it + # is under the same must-not-raise rule as the engine side + self._result = result + self._error = error + self._done.set() + if self._abandoned: + self._give_up(result, error) + + # the caller's run may already be over; nothing to deliver to then + with suppress(self._trio.RunFinishedError): + self._token.run_sync_soon(deliver) + + def _take(self) -> Any: + if self._error is not None: + raise self._error + return self._result + + async def wait(self, *, shield: bool, on_cancel: Callable[[], None]) -> Any: + if shield: + with self._trio.CancelScope(shield=True): + await self._done.wait() + return self._take() + try: + await self._done.wait() + except self._trio.Cancelled: + # trio delivers the cancel at the checkpoint even when the event + # is already set, so a result that arrived first is sitting right + # here unclaimed; one that has not arrived is salvaged by the + # delivery instead. + self._abandoned = True + if self._done.is_set(): + self._give_up(self._result, self._error) + on_cancel() + raise + return self._take() + + +class EngineBridge: + """Run trio-native coroutines on an engine, awaited from another loop.""" + + #: the caller-side carrier this bridge's surface waits on + carrier: type[Carrier] + + def __init__(self, engine: TrioEngine) -> None: + self._engine = engine + # the engine's vocabulary, not the caller's: everything below runs + # on the engine loop + self._aio = for_backend(engine.backend) + + async def call( + self, + async_fn: Callable[..., Awaitable[T]], + *args: Any, + shield: bool = False, + salvage: Callable[[Any], None] | None = None, + ) -> T: + """Run ``async_fn`` on the engine and await its result. + + Unless ``shield``, cancelling the await cancels the engine-side + operation too. That cancel and the engine's work race, so an + operation that *consumes* something -- a ``receive`` -- passes a + ``salvage``: a value the engine had already produced goes there + instead of being dropped, and the caller loses nothing whichever + way the race went. + """ + carrier = self.carrier() + carrier.set_salvage(salvage) + scope = self._aio.cancel_scope() + + async def runner() -> None: + try: + with scope: + result = await async_fn(*args) + except self._aio.Cancelled: + # engine shutdown: the nursery cancel must propagate + carrier.resolve(None, RuntimeError(ENGINE_GONE)) + raise + except BaseException as exc: + carrier.resolve(None, exc) + return + if scope.cancelled_caught: + return # cancelled by us -- the awaiter is already gone + carrier.resolve(result, None) + + def spawn() -> None: + try: + self._engine.start_soon(runner) + except BaseException as exc: + error = RuntimeError(ENGINE_GONE) + error.__cause__ = exc + carrier.resolve(None, error) + + try: + self._engine.portal.post(spawn) + except LoopFinishedError: + raise RuntimeError("the execnet engine is not running") from None + + def cancel_engine_side() -> None: + with suppress(LoopFinishedError): + self._engine.portal.post(scope.cancel) + + result: T = await carrier.wait(shield=shield, on_cancel=cancel_engine_side) + return result + + +class AsyncioBridge(EngineBridge): + carrier = AsyncioCarrier + + +class TrioBridge(EngineBridge): + carrier = TrioCarrier + + +def targets_for_bridge(gateways: Sequence[Any]) -> tuple[EngineBridge, list[Any]]: + """One bridge and one service target per gateway, or a clear error. + + A fan-out is a single task awaiting every gateway's channels, so they + all have to belong to one engine's run: a trio task cannot await a + channel that belongs to another. The blocking surface has always + checked this (``execnet._deploy._facade.run_blocking``); the facades + used to take the first gateway's bridge and hope, which turned a + two-engine mistake into a cross-run await rather than a sentence. + """ + if not gateways: + raise ValueError("no gateways to work on") + bridges = {id(gateway._bridge): gateway._bridge for gateway in gateways} + if len(bridges) > 1: + raise ValueError( + "all gateways must be served by the same execnet.ProtocolEngine:" + " one driver task cannot reach channels belonging to another" + " event loop. Gateways from one AsyncGroup always share an engine." + ) + bridge: EngineBridge = next(iter(bridges.values())) + return bridge, [gateway._target() for gateway in gateways] + + +async def start_engine(engine: ProtocolEngine, carrier: Carrier) -> Any: + """Start ``engine`` without blocking the caller's loop; return its engine. + + ``ProtocolEngine._ensure_started`` blocks until the loop is ready -- + up to 30 seconds if something is wrong with the environment -- so it + runs on a throwaway thread whose completion is posted back to the + caller's loop. Never on a shared thread pool: those belong to the + caller's application, and this one waits rather than works. + """ + if engine.running: + return engine._ensure_started() + + def start() -> None: + try: + carrier.resolve(engine._ensure_started(), None) + except BaseException as exc: + carrier.resolve(None, exc) + + threading.Thread(target=start, name="execnet-engine-start", daemon=True).start() + return await carrier.wait(shield=False, on_cancel=lambda: None) diff --git a/src/execnet/_channel.py b/src/execnet/_channel.py new file mode 100644 index 00000000..f2de3f1b --- /dev/null +++ b/src/execnet/_channel.py @@ -0,0 +1,525 @@ +"""The blocking :class:`Channel` and its registry and file adapters. + +A ``Channel`` is a facade over the async core: the gateway's Trio session +diverts inbound payloads for a channel id into a :class:`~execnet._boundary.Mailbox` +(or a registered callback), and ``receive()`` deserializes at the call site, +off the loop thread. +""" + +from __future__ import annotations + +import enum +import threading +import weakref +from collections.abc import Callable +from collections.abc import Iterator +from contextlib import suppress +from typing import TYPE_CHECKING +from typing import Any +from typing import Literal +from typing import cast +from typing import overload + +from ._boundary import Flag +from ._boundary import Mailbox +from ._errors import ChannelClosed +from ._errors import ExecnetStateError +from ._errors import GatewayGone +from ._errors import RemoteError +from ._errors import TimeoutError +from ._message import Message +from ._serialize import Payload +from ._serialize import SendPayload +from ._serialize import dumps_internal +from ._serialize import loads_internal + +if TYPE_CHECKING: + from ._gateway_base import BaseGateway + + +class NoEndmarker(enum.Enum): + """Type of the "no endmarker wanted" sentinel. + + An enum rather than a bare ``object()`` so it is nameable in an + annotation: an endmarker may be *any* object, so the only thing that + distinguishes "none wanted" from a legitimate endmarker is identity, + and a second module inventing its own ``object()`` for it would be + silently wrong. ``endmarker: object | Literal[NoEndmarker.NOT_WANTED]`` + says which sentinel a caller has to hand back. + """ + + NOT_WANTED = enum.auto() + + +NO_ENDMARKER_WANTED = NoEndmarker.NOT_WANTED + +#: what an ``endmarker=`` parameter accepts: any object to deliver at the +#: end, or the sentinel meaning "do not deliver one" +Endmarker = object | Literal[NoEndmarker.NOT_WANTED] + + +class Channel: + """Communication channel between two Python Interpreter execution points. + + A facade over the async core: the gateway's Trio session diverts + inbound payloads for this id into a :class:`Mailbox` (or a registered + callback, invoked on the loop thread); ``receive()`` deserializes at + the call site. Sends go through ``gateway._send``. + """ + + RemoteError = RemoteError + TimeoutError = TimeoutError + _INTERNALWAKEUP = 1000 + _executing = False + #: set once a receiver callback is attached. A consumer *task* on the + #: loop drains this channel and runs the callback in a threadpool thread; + #: the task holds the channel alive, so a callback channel's lifecycle is + #: bound to consumption (and to GC once the stream closes) rather than to + #: a strong registry. + _has_consumer = False + #: loop-side hooks installed while a consumer is attached: divert one + #: payload into / close the consumer task's inbox (set by the Trio session, + #: so they encapsulate the trio memory channel; this module stays trio-free). + _consumer_feed: Callable[[bytes], None] | None = None + _consumer_close_inbox: Callable[[], None] | None = None + #: thread-safe "stop the consumer" hook -- ends the task's inbox. + _consumer_stop: Callable[[], None] | None = None + #: set by the consumer task once it has drained every item and fired the + #: endmarker; ``waitclose()`` waits on this (instead of ``_receiveclosed``) + #: so it still guarantees "all callbacks have run" before returning. + _consumer_done: Flag | None = None + + def __init__(self, gateway: BaseGateway, id: int) -> None: + """:private:""" + assert isinstance(id, int) + assert not isinstance(gateway, type) + self.gateway = gateway + self.id = id + # serialized payloads (or ENDMARKER); None once a consumer is attached + self._mailbox: Mailbox[Any] | None = Mailbox(gateway._new_wakener()) + self._closed = False + self._receiveclosed = Flag(gateway._new_wakener()) + self._remoteerrors: list[RemoteError] = [] + + def _trace(self, *msg: object) -> None: + self.gateway._trace(self.id, *msg) + + def setcallback( + self, + callback: Callable[[Any], Any], + endmarker: Endmarker = NO_ENDMARKER_WANTED, + ) -> None: + """Set a callback function for receiving items. + + A consumer task on the gateway's loop drains this channel and runs + ``callback`` for each received item in a threadpool thread (so a slow + callback never blocks the loop); items for one channel are delivered + strictly in order. Already-queued items are delivered first. After + this call ``receive()`` raises an error. + + The task keeps the channel alive for as long as it is consuming, so a + callback channel need not be referenced elsewhere. If an endmarker is + specified the callback is eventually called with it when the channel + closes, and ``waitclose()`` does not return until every callback + (including the endmarker) has run. + + The pool the callbacks run on is shared and bounded (40 threads by + default, ``ProtocolEngine(callback_threads=...)``). A callback may block -- + that is the point of running it off the loop -- but callbacks that + block on *each other*, directly or through a queue only another + callback drains, can occupy the whole pool and stall every channel in + the process. Hand work that waits to a thread of your own. + """ + self.gateway._start_channel_consumer(self, callback, endmarker) + + def __repr__(self) -> str: + flag = (self.isclosed() and "closed") or "open" + return "" % (self.id, flag) + + def __del__(self) -> None: + if self.gateway is None: # can be None in tests + return # type: ignore[unreachable] + + self._trace("channel.__del__") + # no multithreading issues here, because we have the last ref to 'self' + if self._closed: + # state transition "closed" --> "deleted" + for error in self._remoteerrors: + error.warn() + elif self._receiveclosed.is_set(): + # state transition "sendonly" --> "deleted" + # the remote channel is already in "deleted" state, nothing to do + pass + else: + # state transition "opened" --> "deleted" + # check if we are in the middle of interpreter shutdown + # in which case the process will go away and we probably + # don't need to try to send a closing or last message + # (and often it won't work anymore to send things out) + # A callback channel is held by its consumer task until the stream + # closes, so by the time __del__ runs it is never in the "opened" + # state -- this branch only ever fires for a plain receive channel. + if Message is not None: + with suppress(OSError, ValueError): # ignore problems with sending + # Never wait during GC: post the close best-effort. + send = getattr( + self.gateway, "_send_nonblocking", self.gateway._send + ) + send(Message.CHANNEL_CLOSE, self.id) + with suppress(Exception): + self.gateway._release_channel(self.id) + + def _getremoteerror(self): + try: + return self._remoteerrors.pop(0) + except IndexError: + try: + return self.gateway._error + except AttributeError: + pass + return None + + # + # loop-side delivery (called by the session's raw-channel consumer) + # + def _deliver_payload(self, data: bytes) -> None: + """Route one inbound serialized payload (loop thread). + + A callback channel diverts payloads into its consumer task's inbox; a + plain channel queues them for ``receive()``. + """ + if self._closed: + return # late data for a locally closed channel: drop + feed = self._consumer_feed + if feed is not None: + feed(data) + return + mailbox = self._mailbox + if mailbox is not None: + mailbox.put(data) + # no consumer and no mailbox: closed for receiving -- drop + + def _close_from_remote(self, remoteerror=None, *, sendonly: bool = False) -> None: + """Close initiated by the peer or session shutdown (loop thread). + + For a callback channel the consumer task ends separately (its inbox is + closed) and fires the endmarker; here we only record the state. + """ + if remoteerror: + self._remoteerrors.append(remoteerror) + if self._has_consumer: + close_inbox = self._consumer_close_inbox + if close_inbox is not None: + close_inbox() # ends the consumer task (it fires the endmarker) + else: + mailbox = self._mailbox + if mailbox is not None: + mailbox.put(ENDMARKER) + self.gateway._channelfactory._no_longer_opened(self.id) + if not sendonly: # otherwise #--> "sendonly" + self._closed = True # --> "closed" + self._receiveclosed.set() + + # + # public API for channel objects + # + def isclosed(self) -> bool: + """Return True if the channel is closed. + + A closed channel may still hold items. + """ + return self._closed + + @overload + def makefile(self, mode: Literal["r"], proxyclose: bool = ...) -> ChannelFileRead: + pass + + @overload + def makefile( + self, + mode: Literal["w"] = ..., + proxyclose: bool = ..., + ) -> ChannelFileWrite: + pass + + def makefile( + self, + mode: Literal["r", "w"] = "w", + proxyclose: bool = False, + ) -> ChannelFileWrite | ChannelFileRead: + """Return a file-like object. + + mode can be 'w' or 'r' for writeable/readable files. + If proxyclose is true, file.close() will also close the channel. + """ + if mode == "w": + return ChannelFileWrite(channel=self, proxyclose=proxyclose) + elif mode == "r": + return ChannelFileRead(channel=self, proxyclose=proxyclose) + raise ValueError(f"mode {mode!r} not available") + + def close(self, error=None) -> None: + """Close down this channel with an optional error message. + + Note that closing of a channel tied to remote_exec happens + automatically at the end of execution and cannot + be done explicitly. + """ + if self._executing: + raise ExecnetStateError( + "cannot explicitly close channel within remote_exec" + ) + if self._closed: + self.gateway._trace(self, "ignoring redundant call to close()") + if not self._closed: + # state transition "opened/sendonly" --> "closed" + # threads warning: the channel might be closed under our feet, + # but it's never damaging to send too many CHANNEL_CLOSE messages + # however, if the other side triggered a close already, we + # do not send back a closed message. + if not self._receiveclosed.is_set(): + put = self.gateway._send + if error is not None: + put(Message.CHANNEL_CLOSE_ERROR, self.id, dumps_internal(error)) + else: + put(Message.CHANNEL_CLOSE, self.id) + self._trace("sent channel close message") + if isinstance(error, RemoteError): + self._remoteerrors.append(error) + self._closed = True # --> "closed" + self._receiveclosed.set() + if self._has_consumer: + # End the consumer task's inbox; it drains any buffered items, + # fires the endmarker, and sets _consumer_done. + stop = self._consumer_stop + if stop is not None: + stop() + else: + mailbox = self._mailbox + if mailbox is not None: + mailbox.put(ENDMARKER) + self.gateway._channelfactory._no_longer_opened(self.id) + self.gateway._release_channel(self.id) + + def waitclose(self, timeout: float | None = None) -> None: + """Wait until this channel is closed (or the remote side + otherwise signalled that no more data was being sent). + + The channel may still hold receiveable items, but not receive + any more after waitclose() has returned. + + Exceptions from executing code on the other side are reraised as local + channel.RemoteErrors. + + EOFError is raised if the reading-connection was prematurely closed, + which often indicates a dying process. + + self.TimeoutError is raised after the specified number of seconds + (default is None, i.e. wait indefinitely). + """ + # For a callback channel wait on the consumer task finishing (so every + # callback, including the endmarker, has run); otherwise wait for the + # non-"opened" state directly. + self.gateway._check_usable("channel.waitclose()") + signal = ( + self._consumer_done + if self._consumer_done is not None + else (self._receiveclosed) + ) + signal.wait(timeout=timeout) + if not signal.is_set(): + raise self.TimeoutError("Timeout after %r seconds" % timeout) + error = self._getremoteerror() + if error: + raise error + + def send(self, item: SendPayload) -> None: + """Sends the given item to the other side of the channel. + + The item must be a simple Python type and will be + copied to the other side by value. + + Returns once the data has reached the OS write, which is not the + same as the peer having read it: there is no flow control, so a + peer that never receives buffers everything sent to it rather than + pushing back. Sending unboundedly to one is a memory leak in *its* + process. + + OSError is raised if the write pipe was prematurely closed. + """ + # before the state check: an unusable gateway (inherited by a fork, + # or driven from inside an event loop) is a caller bug either way, + # and whether the channel has closed yet is a race -- the diagnostic + # should not depend on it + self.gateway._check_usable("channel.send()") + if self.isclosed(): + raise ChannelClosed(f"cannot send to {self!r}") + self.gateway._send(Message.CHANNEL_DATA, self.id, dumps_internal(item)) + + def receive(self, timeout: float | None = None) -> Payload[Channel]: + """Receive a data item that was sent from the other side. + + timeout: None [default] blocked waiting. A positive number + indicates the number of seconds after which a channel.TimeoutError + exception will be raised if no item was received. + + Note that exceptions from the remotely executing code will be + reraised as channel.RemoteError exceptions containing + a textual representation of the remote traceback. + """ + self.gateway._check_usable("channel.receive()") + mailbox = self._mailbox + if mailbox is None: + raise ExecnetStateError("cannot receive(), channel has receiver callback") + x = mailbox.get(timeout) + if x is ENDMARKER: + mailbox.put(x) # for other receivers + raise self._getremoteerror() or EOFError() + else: + return loads_internal(x, self) + + def __iter__(self) -> Iterator[Payload[Channel]]: + return self + + def next(self) -> Payload[Channel]: + try: + return self.receive() + except EOFError: + raise StopIteration from None + + __next__ = next + + +ENDMARKER = object() + + +class ChannelFactory: + """Registry and id allocator for a gateway's sync channels. + + Message routing lives in the Trio session (the sync channel binds a + consumer on the session's raw channel); the factory only tracks live + channels -- weakly, so dropping the last user reference triggers + ``Channel.__del__``'s close message. A callback channel is kept alive by + its consumer task rather than by any registry here. + """ + + def __init__(self, gateway: BaseGateway, startcount: int = 1) -> None: + self._channels: weakref.WeakValueDictionary[int, Channel] = ( + weakref.WeakValueDictionary() + ) + self._writelock = threading.Lock() + self.gateway = gateway + self.count = startcount + self.finished = False + self._list = list # needed during interp-shutdown + + def new(self, id: int | None = None) -> Channel: + """Create a new Channel with 'id' (or create new id if None).""" + with self._writelock: + if self.finished: + raise GatewayGone(f"connection already closed: {self.gateway}") + if id is None: + id = self.count + self.count += 2 + try: + channel = self._channels[id] + except KeyError: + channel = self._channels[id] = Channel(self.gateway, id) + self.gateway._bind_channel(channel) + return channel + + def allocate_id(self) -> int: + """Reserve a fresh channel id without creating a Channel object.""" + with self._writelock: + if self.finished: + raise GatewayGone(f"connection already closed: {self.gateway}") + id = self.count + self.count += 2 + return id + + def channels(self) -> list[Channel]: + return self._list(self._channels.values()) + + # + # internal methods, called from the loop thread (or local close paths) + # + def _no_longer_opened(self, id: int) -> None: + self._channels.pop(id, None) + + def _local_close(self, id: int, remoteerror=None, sendonly: bool = False) -> None: + """Close ``id`` as if the peer had closed it (no message is sent).""" + channel = self._channels.get(id) + if channel is None: + # channel already in "deleted" state + if remoteerror: + remoteerror.warn() + self._no_longer_opened(id) + else: + channel._close_from_remote(remoteerror, sendonly=sendonly) + + def _finished_receiving(self) -> None: + with self._writelock: + self.finished = True + for id in self._list(self._channels): + self._local_close(id, sendonly=True) + + +class ChannelFile: + def __init__(self, channel: Channel, proxyclose: bool = True) -> None: + self.channel = channel + self._proxyclose = proxyclose + + def isatty(self) -> bool: + return False + + def close(self) -> None: + if self._proxyclose: + self.channel.close() + + def __repr__(self) -> str: + state = (self.channel.isclosed() and "closed") or "open" + return "" % (self.channel.id, state) + + +class ChannelFileWrite(ChannelFile): + def write(self, out: bytes) -> None: + self.channel.send(out) + + def flush(self) -> None: + pass + + +class ChannelFileRead(ChannelFile): + def __init__(self, channel: Channel, proxyclose: bool = True) -> None: + super().__init__(channel, proxyclose) + self._buffer: str | None = None + + def read(self, n: int) -> str: + try: + if self._buffer is None: + self._buffer = cast(str, self.channel.receive()) + while len(self._buffer) < n: + self._buffer += cast(str, self.channel.receive()) + except EOFError: + self.close() + if self._buffer is None: + ret = "" + else: + ret = self._buffer[:n] + self._buffer = self._buffer[n:] + return ret + + def readline(self) -> str: + if self._buffer is not None: + i = self._buffer.find("\n") + if i != -1: + return self.read(i + 1) + line = self.read(len(self._buffer) + 1) + else: + line = self.read(1) + while line and line[-1] != "\n": + c = self.read(1) + if not c: + break + line += c + return line diff --git a/src/execnet/_cli.py b/src/execnet/_cli.py new file mode 100644 index 00000000..db20b461 --- /dev/null +++ b/src/execnet/_cli.py @@ -0,0 +1,292 @@ +"""The ``execnet`` command line. + +Three subcommands, reachable both as the ``execnet`` console script and as +``python -m execnet`` (the latter is what provisioning emits for a direct +interpreter launch, where the script's location is not knowable):: + + execnet worker ... # serve one gateway over a chosen transport + execnet server ... # accept gateway connections on a socket + execnet info # what this interpreter's execnet can do, as JSON + +``execnet worker`` is the launch contract between a coordinator and the +process it starts. Naming the protocol transport explicitly is what makes +ssh socket redirects and trampoline processes expressible: the protocol no +longer has to be the process's stdin/stdout, so a worker's stdio can belong +to the code it runs. +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +from typing import Any + +__all__ = ["main"] + +#: what the worker may do with each standard fd once the protocol has a +#: transport of its own. ``stderr`` for stdout points fd 1 at fd 2, which +#: keeps remote output visible without needing a second stream. +STDIN_DISPOSITIONS = ("inherit", "close", "devnull") +STDOUT_DISPOSITIONS = ("inherit", "devnull", "stderr") +STDERR_DISPOSITIONS = ("inherit", "devnull") + + +def _protocol_fd(value: str) -> tuple[int, ...]: + """``N`` (a bidirectional socket) or ``R,W`` (a pipe pair).""" + try: + fds = tuple(int(part) for part in value.split(",")) + except ValueError: + raise argparse.ArgumentTypeError( + f"expected FD or READFD,WRITEFD, got {value!r}" + ) from None + if len(fds) not in (1, 2): + raise argparse.ArgumentTypeError( + f"expected FD or READFD,WRITEFD, got {value!r}" + ) + return fds + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="execnet", + description="Serve and inspect execnet gateways.", + ) + sub = parser.add_subparsers(dest="command", required=True) + + worker = sub.add_parser( + "worker", + help="serve a single gateway to the coordinator that launched us", + description=( + "Serve one gateway over the given protocol transport. Normally" + " launched by a coordinator, not by hand." + ), + ) + transport = worker.add_mutually_exclusive_group() + transport.add_argument( + "--protocol-stdio", + dest="protocol", + action="store_const", + const=("stdio", None), + help="run the protocol over this process's stdin/stdout (default)", + ) + transport.add_argument( + "--protocol-fd", + metavar="FD[,FD]", + type=_protocol_fd, + help="run the protocol over an inherited socket fd, or pipe read,write pair", + ) + transport.add_argument( + "--protocol-connect", + metavar="ADDR", + help="dial out to ADDR (unix:/path or host:port) and serve there", + ) + transport.add_argument( + "--protocol-listen", + metavar="ADDR", + help="listen on ADDR (unix:/path or host:port) for one connection", + ) + transport.add_argument( + "--protocol-share", + action="store_true", + help=( + "adopt a socket duplicated into us with socket.share(); the blob" + " travels in the config (Windows, where fds cannot be inherited)" + ), + ) + + worker.add_argument( + "--config-fd", + metavar="FD", + type=int, + help=( + "read this transport's local config as JSON from FD until EOF." + " Only --protocol-share needs one (the socket blob duplicated" + " into us); the worker config itself arrives as the first frame" + " on the protocol stream" + ), + ) + + worker.add_argument( + "--stdin", choices=STDIN_DISPOSITIONS, default=None, help="what to do with fd 0" + ) + worker.add_argument( + "--stdout", + choices=STDOUT_DISPOSITIONS, + default=None, + help="what to do with fd 1", + ) + worker.add_argument( + "--stderr", + choices=STDERR_DISPOSITIONS, + default=None, + help="what to do with fd 2", + ) + worker.set_defaults(func=_run_worker) + + server = sub.add_parser( + "server", + help="accept gateway connections on a socket", + description=( + "Listen for coordinator connections and hand each one to a fresh" + " worker subprocess. No code is executed in this process." + ), + ) + server.add_argument( + "hostport", + nargs="?", + default=":8888", + help="address to bind as HOST:PORT or :PORT (default: :8888)", + ) + server.add_argument( + "--once", + action="store_true", + help="serve a single connection and exit instead of looping", + ) + server.set_defaults(func=_run_server) + + info = sub.add_parser( + "info", + help="report this execnet's version and capabilities as JSON", + description=( + "Print a JSON object describing this interpreter's execnet." + " Coordinators use it to decide whether a target interpreter can" + " host a worker directly or has to be provisioned." + ), + ) + info.set_defaults(func=_run_info) + + return parser + + +def _load_local_config(ns: argparse.Namespace) -> dict[str, Any]: + """The transport's own config, for the one transport that needs one. + + Not the worker config -- that arrives as the first frame on the + protocol stream, so it is never in argv where ``ps`` and ``/proc`` + would expose the ``env:`` values it carries. This is only for material + a transport needs *before* a stream can exist: the socket + ``share()``-ed into us on Windows, which describes the very connection + the worker config would otherwise have to arrive on. + """ + if ns.config_fd is None: + return {} + # read a dup, so ``--config-fd 0`` leaves fd 0 itself open (at EOF). + # Closing it would free the slot for the next os.open, and anything + # then writing to "stdin" would land in an unrelated file. + with os.fdopen(os.dup(ns.config_fd), "r", encoding="utf-8") as stream: + raw = stream.read() + config: dict[str, Any] = json.loads(raw) + return config + + +def _run_worker(ns: argparse.Namespace) -> None: + from . import _trio_worker + + if ns.protocol_fd is not None: + transport: _trio_worker.Transport = _trio_worker.FdTransport(ns.protocol_fd) + elif ns.protocol_connect is not None: + transport = _trio_worker.ConnectTransport(ns.protocol_connect) + elif ns.protocol_listen is not None: + transport = _trio_worker.ListenTransport(ns.protocol_listen) + elif ns.protocol_share: + transport = _trio_worker.ShareTransport() + else: + transport = _trio_worker.StdioTransport() + # The stdio transport owns fd 0/1, so it has to claim them before + # anything else reads them (--config-fd 0 would be the very fd we move). + transport.prepare() + if isinstance(transport, _trio_worker.ShareTransport): + transport.adopt(_load_local_config(ns)) + _trio_worker.serve_worker( + transport, + stdin=ns.stdin, + stdout=ns.stdout, + stderr=ns.stderr, + ) + + +def _run_server(ns: argparse.Namespace) -> None: + import trio + + from . import _socketserver + + trio.run(_socketserver.serve, ns.hostport, ns.once) + + +def _run_info(ns: argparse.Namespace) -> None: + json.dump(interpreter_info(), sys.stdout) + sys.stdout.write("\n") + + +def interpreter_info() -> dict[str, Any]: + """What a coordinator needs to know before launching a worker here. + + Distinct from ``_message.gateway_info``, which answers the in-protocol + ``GATEWAY_INFO`` request on an established gateway; this one is what a + coordinator can learn *before* connecting. + """ + from ._version import version + + try: + import trio + + trio_version: str | None = trio.__version__ + except Exception: + trio_version = None + + return { + "execnet": version, + # Can this interpreter serve a worker at all? The question a + # coordinator actually has, asked without naming an engine -- an + # install with no trio answers yes on Python 3.11+, where asyncio + # runs the protocol. ``trio`` stays for a coordinator old enough to + # have asked that instead, and because knowing the version helps. + "worker": _engines() != [], + "engines": _engines(), + "trio": trio_version, + "python": ".".join(str(part) for part in sys.version_info[:3]), + "executable": sys.executable, + "platform": sys.platform, + "protocols": _supported_protocols(), + } + + +def _engines() -> list[str]: + """Which async libraries this interpreter could run the protocol on.""" + import importlib.util + + found = [] + if importlib.util.find_spec("trio") is not None: + found.append("trio") + if sys.version_info >= (3, 11): + found.append("asyncio") + return found + + +def _supported_protocols() -> list[str]: + """Transports this platform can actually serve.""" + protocols = ["stdio", "listen", "connect"] + if hasattr(os, "dup"): + protocols.append("fd") + return sorted(protocols) + + +def main(argv: list[str] | None = None) -> None: + """Console entry point (``execnet``) and ``python -m execnet``.""" + parser = _build_parser() + ns = parser.parse_args(argv) + ns.func(ns) + + +def socketserver_main(argv: list[str] | None = None) -> None: + """Deprecated ``execnet-socketserver`` entry point; use ``execnet server``.""" + import warnings + + warnings.warn( + "execnet-socketserver is deprecated; use `execnet server` instead.", + DeprecationWarning, + stacklevel=2, + ) + main(["server", *(sys.argv[1:] if argv is None else argv)]) diff --git a/src/execnet/_deploy/__init__.py b/src/execnet/_deploy/__init__.py new file mode 100644 index 00000000..edf89c10 --- /dev/null +++ b/src/execnet/_deploy/__init__.py @@ -0,0 +1,26 @@ +"""Transfer and deployment: an independent layer over the protocol core. + +Nothing in ``execnet``'s protocol core knows this package exists. It +reaches workers through one generic mechanism -- a ``GATEWAY_SERVICE`` +request naming a service the worker resolves through +:mod:`execnet._services` -- which is the same door an out-of-tree package +would use. Deleting this directory and its two registry lines would leave +a working execnet behind. + +What it provides: + +* :class:`Deployment` / :class:`Deployed` -- a frozen ``uv`` environment, + the project's own wheel installed into it, and the roots the wheel does + not carry, put on a host *before* any worker runs against it. +* :func:`transfer` -- send a tree to a host, sending only what differs. + +The blocking entry points here are facades: the driver is async and runs +on the host loop, so a fan-out across hosts is concurrent. The same driver +is what :mod:`execnet.trio` and :mod:`execnet.aio` await directly. +""" + +from ._api import Deployed +from ._api import Deployment +from ._api import transfer + +__all__ = ["Deployed", "Deployment", "transfer"] diff --git a/src/execnet/_deploy/_api.py b/src/execnet/_deploy/_api.py new file mode 100644 index 00000000..6b481582 --- /dev/null +++ b/src/execnet/_deploy/_api.py @@ -0,0 +1,218 @@ +"""What a deployment *is*, and the blocking way to run one. + +No trio here: this module is on the ``import execnet`` path, and importing +execnet must not load an event loop. The async half +(:mod:`execnet._deploy._run`) is imported when somebody deploys. +""" + +from __future__ import annotations + +import os +from collections.abc import Iterable +from collections.abc import Sequence +from pathlib import Path +from typing import TYPE_CHECKING + +from ._manifest import Filter + +if TYPE_CHECKING: + from .._gateway import Gateway + from ._transfer import Progress + +__all__ = ["Deployed", "Deployment", "transfer"] + +#: where a workspace lands when the caller does not name a directory. Not +#: a shell fragment: the worker expands it, in Python, on the host whose +#: home directory it is. +DEFAULT_WORKSPACE_ROOT = "~/.cache/execnet/workspaces" + + +def _slug(text: str) -> str: + """Filesystem-safe name for a workspace.""" + import re + + return re.sub(r"[^0-9A-Za-z._-]+", "-", text).strip("-") or "workspace" + + +class Deployed: + """Where a :class:`Deployment` put things, and how to reach them. + + ``paths`` maps each local root to the directory it landed in. Use + :meth:`translate` for a path inside one -- a caller rewriting its own + configuration for the remote (arguments, ``rootdir``, data files) needs + that, and only the deployment knows the answer. + """ + + def __init__(self, workspace: str, python: str, paths: dict[str, str]) -> None: + #: the remote workspace directory + self.workspace = workspace + #: the remote interpreter the project is installed into + self.python = python + #: local root -> remote directory + self.paths = paths + + def __repr__(self) -> str: + return f"" + + @property + def spec(self) -> str: + """Spec keys that point a gateway at this deployment. + + Append to whichever transport reaches the host:: + + group.makegateway(f"ssh=host//{target.spec}") + """ + return f"python={self.python}//chdir={self.workspace}" + + def translate(self, path: str | os.PathLike[str]) -> str: + """The remote path for a local one under a deployed root. + + Raises :class:`ValueError` for a path that was never deployed -- + returning it unchanged would hand the remote a path that may well + exist there and mean something entirely different. + """ + local = os.path.abspath(os.fspath(path)) + for root, remote in self.paths.items(): + if local == root: + return remote + prefix = root.rstrip(os.sep) + os.sep + if local.startswith(prefix): + rest = local[len(prefix) :].replace(os.sep, "/") + return f"{remote}/{rest}" + raise ValueError( + f"{local!r} is not under any deployed root ({sorted(self.paths)})" + ) + + +class Deployment: + """A project plus the files around it, ready to be put on a host. + + Provisioning has to happen *before* the process that runs the tests + exists, because that process has to be running inside the environment + the project was installed into. So a deployment is driven through a + gateway of its own, and the workers come afterwards. + + Usually that gateway then stays as the host the workers are spawned + *through*: one connection per machine, and the test workers are local + children of it rather than N more connections:: + + host = group.makegateway("ssh=host//id=h1") + target = execnet.Deployment(".", roots=["testing"]).deploy(host) + + for index in range(4): + group.makegateway(f"via=h1//{target.spec}//id=w{index}") + + Deploying and running are ordered rather than concurrent, which is + also what keeps them off each other: the transfer is done with that + host's loop before it starts relaying for anybody. + + For several machines, :meth:`deploy_all` puts one deployment on each + at once; each of those hosts then spawns its own workers. + + ``project`` is a directory with a ``pyproject.toml`` and a ``uv.lock`` + -- the lockfile is what makes the remote environment reproducible, and + its absence is an error rather than a resolve. + + ``roots`` are the local paths a test run needs that the built wheel + does not contain: tests, ``conftest.py``, fixture data. A directory + root lands under the workspace as its own basename; a file root lands + directly in the workspace. Either way :attr:`Deployed.paths` says + where. + + ``name`` names the workspace. Deployments sharing a name share a + directory on the host, which is the point on a cluster: the second + gateway to a machine reuses the environment the first one built, and + only what changed is transferred. + """ + + def __init__( + self, + project: str | os.PathLike[str], + roots: Iterable[str | os.PathLike[str]] = (), + *, + name: str | None = None, + workspace: str | None = None, + filter: Filter | None = None, + delete: bool = False, + progress: Progress | None = None, + ) -> None: + self.project = Path(project).resolve() + self.roots = [Path(root).resolve() for root in roots] + self.name = name or _slug(self.project.name) + #: an explicit remote directory, or None to derive one from the name + self.workspace = workspace + self.workspace_root = DEFAULT_WORKSPACE_ROOT + #: which paths under a root belong in the transfer + self.filter = filter + #: whether a root's remote copy is pruned of what the local one lost + self.delete = delete + #: ``(relpath, size)`` as each file body is sent, off the loop + self.progress = progress + if not (self.project / "pyproject.toml").is_file(): + raise ValueError(f"no pyproject.toml in {self.project}") + if not (self.project / "uv.lock").is_file(): + raise ValueError( + f"no uv.lock in {self.project}: a deployment installs a frozen" + " environment, so the lockfile is what it deploys. Run" + " `uv lock` in the project first." + ) + for root in self.roots: + if not root.exists(): + raise ValueError(f"no such root: {root}") + + def __repr__(self) -> str: + return f"" + + def deploy(self, gateway: Gateway) -> Deployed: + """Deploy through ``gateway``; blocks until the host is ready. + + The gateway is used and left as it was -- it is not the one that + will run anything. Start the workers afterwards against + :attr:`Deployed.spec`, usually as ``via=`` children of this same + gateway. + """ + return self.deploy_all([gateway])[0] + + def deploy_all(self, gateways: Sequence[Gateway]) -> list[Deployed]: + """Deploy to every gateway at once; blocks until all are ready. + + One result per gateway, in order. The wheel is built once and the + hosts are worked on concurrently, which is the difference between + deploying to a cluster and deploying to a cluster N times. One + gateway per *machine* is the shape this is for -- the workers on + each are spawned through it afterwards, not deployed to. + """ + from ._facade import run_blocking + from ._run import deploy_to + + return run_blocking(gateways, deploy_to, self) + + +def transfer( + gateway: Gateway, + source: str | os.PathLike[str], + destination: str, + *, + filter: Filter | None = None, + delete: bool = False, + progress: Progress | None = None, +) -> None: + """Copy the tree at ``source`` to ``destination`` on ``gateway``. + + Blocking. Only what differs is sent: the target answers the file list + with what it is missing, plus a digest for anything whose size matches + but whose timestamp does not. + """ + from ._facade import run_blocking + from ._transfer import transfer_tree_to_all + + async def run(targets: Sequence[object]) -> None: + await transfer_tree_to_all( + [(targets[0], destination)], # type: ignore[list-item] + source, + filter=filter, + delete=delete, + progress=progress, + ) + + run_blocking([gateway], run) diff --git a/src/execnet/_deploy/_async_api.py b/src/execnet/_deploy/_async_api.py new file mode 100644 index 00000000..44699d63 --- /dev/null +++ b/src/execnet/_deploy/_async_api.py @@ -0,0 +1,78 @@ +"""The async verbs, for the surfaces that have a loop of their own. + +:mod:`execnet.trio` awaits these directly on its own gateways. +:mod:`execnet.aio` reaches the same functions through its host bridge, so +there is one implementation and three ways in. +""" + +from __future__ import annotations + +import os +from collections.abc import Sequence +from typing import TYPE_CHECKING +from typing import Any + +from .._services import ServiceTarget +from ._manifest import Filter + +if TYPE_CHECKING: + from .._trio_gateway import AsyncGateway + from ._api import Deployed + from ._api import Deployment + from ._transfer import Progress + +__all__ = ["deploy", "deploy_all", "transfer"] + + +def _target(gateway: AsyncGateway | ServiceTarget) -> ServiceTarget: + """Accept a trio-native gateway or an already-built target.""" + if isinstance(gateway, ServiceTarget): + return gateway + return ServiceTarget(gateway) + + +async def transfer( + gateway: AsyncGateway | ServiceTarget, + source: str | os.PathLike[str], + destination: str, + *, + filter: Filter | None = None, + delete: bool = False, + progress: Progress | None = None, +) -> None: + """Copy the tree at ``source`` to ``destination`` on ``gateway``. + + Only what differs is sent: the target answers the file list with what + it is missing, plus a digest for anything whose size matches but whose + timestamp does not. + """ + from ._transfer import transfer_tree + + await transfer_tree( + _target(gateway), + source, + destination, + filter=filter, + delete=delete, + progress=progress, + ) + + +async def deploy( + deployment: Deployment, gateway: AsyncGateway | ServiceTarget +) -> Deployed: + """Deploy through ``gateway`` and return where everything landed.""" + results = await deploy_all(deployment, [gateway]) + return results[0] + + +async def deploy_all(deployment: Deployment, gateways: Sequence[Any]) -> list[Deployed]: + """Deploy to every gateway at once; one result each, in order. + + The wheel is built once and the hosts are worked on concurrently -- + the difference between deploying to a cluster and deploying to a + cluster N times. + """ + from ._run import deploy_to + + return await deploy_to(deployment, [_target(gateway) for gateway in gateways]) diff --git a/src/execnet/_deploy/_facade.py b/src/execnet/_deploy/_facade.py new file mode 100644 index 00000000..b924486b --- /dev/null +++ b/src/execnet/_deploy/_facade.py @@ -0,0 +1,68 @@ +"""Running the async core from the surfaces that are not trio. + +The driver is one async function whichever way you reach it. What differs +is who waits: under :mod:`execnet.raw_trio` the caller's own loop already +runs it, and here the caller has no loop, so it goes to the engine and the +calling thread parks the way its facade parks -- an event for plain +threads, a greenlet switch under :mod:`execnet.gevent`. +""" + +from __future__ import annotations + +import functools +from collections.abc import Awaitable +from collections.abc import Callable +from collections.abc import Sequence +from typing import TYPE_CHECKING +from typing import Any +from typing import TypeVar + +from .._engine import check_not_in_event_loop +from .._services import ServiceTarget + +if TYPE_CHECKING: + from .._gateway import Gateway + +T = TypeVar("T") + + +def targets_for(gateways: Sequence[Gateway]) -> list[ServiceTarget]: + """Service targets for blocking-surface gateways.""" + return [ServiceTarget.from_sync(gateway) for gateway in gateways] + + +def run_blocking( + gateways: Sequence[Gateway], + async_fn: Callable[..., Awaitable[T]], + *args: Any, +) -> T: + """Run ``async_fn(*args, targets)`` on the gateways' engine, and wait. + + Every gateway has to be served by the same engine: the driver is one + task, and a trio task cannot await a channel belonging to a different + run. In practice they come from one :class:`~execnet.Group`, which has + one engine -- so this is a clear error rather than a real limitation. + """ + if not gateways: + raise ValueError("no gateways to work on") + check_not_in_event_loop(f"{getattr(async_fn, '__name__', 'this call')}()") + + sessions = [gateway._trio_session for gateway in gateways] + if any(session is None for session in sessions): + raise OSError("a gateway with no connection cannot be worked on") + engines = {id(session.engine) for session in sessions} + if len(engines) > 1: + raise ValueError( + "all gateways must be served by the same execnet.ProtocolEngine:" + " one driver task cannot reach channels belonging to another" + " event loop. Gateways from one Group always share an engine." + ) + + from .._trio_engine import engine_call + + targets = targets_for(gateways) + return engine_call( # type: ignore[no-any-return] + sessions[0].engine, + gateways[0]._wait_backend, + functools.partial(async_fn, *args, targets), + ) diff --git a/src/execnet/_deploy/_manifest.py b/src/execnet/_deploy/_manifest.py new file mode 100644 index 00000000..b96688c3 --- /dev/null +++ b/src/execnet/_deploy/_manifest.py @@ -0,0 +1,163 @@ +"""Describing a source tree, and deciding what a target is missing. + +Split out because it is the only part of a transfer with no IO of its own +worth speaking of: a manifest is data, the comparison against a target is +a function of two manifests, and both are testable without a gateway. + +The manifest is flat -- one entry per path, relative, ``/``-separated -- +rather than the nested structure the pre-3.0 rsync streamed one message per +node. A tree of ten thousand files is one message either way; the flat +form is one round trip instead of one per directory, and it can be +compared without walking anything. +""" + +from __future__ import annotations + +import os +import stat +from collections.abc import Callable +from typing import TYPE_CHECKING +from typing import Literal +from typing import NamedTuple + +if TYPE_CHECKING: + from .._serialize import SendPayload + +#: what a path is, as far as a transfer cares +Kind = Literal["dir", "file", "link"] + + +class Entry(NamedTuple): + """One path in a manifest. + + ``mode`` is the raw ``st_mode``. For a file ``mtime``/``size`` are its + own; for a link ``target`` is what it points at, rebased onto the tree + root when it was an absolute path pointing inside it (see + :func:`_link_target`). + """ + + path: str + kind: Kind + mode: int + mtime: float = 0.0 + size: int = 0 + target: str = "" + #: for a link: whether ``target`` is relative to the transferred root + internal: bool = False + + +class Manifest(NamedTuple): + entries: tuple[Entry, ...] + + def files(self) -> dict[str, Entry]: + return {entry.path: entry for entry in self.entries if entry.kind == "file"} + + def dump(self) -> list[tuple[SendPayload, ...]]: + """As plain builtins, for the wire.""" + return [tuple(entry) for entry in self.entries] + + @classmethod + def load(cls, data: list[tuple[object, ...]]) -> Manifest: + return cls(tuple(Entry(*item) for item in data)) # type: ignore[arg-type] + + +#: ``(path) -> bool``: whether a path belongs in the transfer. Called with +#: the absolute local path of every candidate *below* the root -- never the +#: root itself -- and may have side effects, so nothing may assume the tree +#: still looks the way it did when the walk passed by. +Filter = Callable[[str], bool] + + +def _link_target(root: str, target: str) -> tuple[str, bool]: + """A link's target as it should be recreated, and whether it was rebased. + + A *relative* link is left exactly as it is: being relative is what + makes it survive the move, and rewriting it would turn a link that + means "my neighbour" into one naming a particular directory. + + An *absolute* link is rebased when it points inside the tree, so it + points inside the copy rather than back at the original. One pointing + anywhere else is copied verbatim, whether or not the other end exists + over there. + """ + if not os.path.isabs(target): + return target, False + if ( + os.path.__name__ == "ntpath" + and target.startswith("\\\\?\\") + # Windows readlink gives an extended path for absolute links, and + # relpath refuses to mix extended and non-extended + and not root.startswith("\\\\?\\") + ): + root = "\\\\?\\" + root + try: + relative = os.path.relpath(target, root) + except ValueError: # different drives on Windows + return target, False + if relative in (os.curdir, os.pardir) or relative.startswith(os.pardir + os.sep): + return target, False + return relative.replace(os.sep, "/"), True + + +def walk(root: str, filter: Filter | None = None) -> Manifest: + """Describe the tree at ``root``; blocking, so run it in a thread. + + Entries come out parents-first, which is the order a receiver can + create them in. A path that disappears between being listed and being + stat'd is simply left out -- a filter with side effects is a thing + people write, and a transfer that raced one should still transfer the + rest. + """ + root = os.path.dirname(os.path.join(root, "x")) # normalise a trailing / + entries: list[Entry] = [] + + def visit(path: str, relative: str) -> None: + try: + st = os.lstat(path) + except OSError: + return # vanished since it was listed + if stat.S_ISDIR(st.st_mode): + if relative: + entries.append(Entry(relative, "dir", st.st_mode)) + try: + names = sorted(os.listdir(path)) + except OSError: + return + for name in names: + child = os.path.join(path, name) + if filter is not None and not filter(child): + continue + visit(child, f"{relative}/{name}" if relative else name) + elif stat.S_ISREG(st.st_mode): + entries.append(Entry(relative, "file", st.st_mode, st.st_mtime, st.st_size)) + elif stat.S_ISLNK(st.st_mode): + target, internal = _link_target(root, os.readlink(path)) + entries.append( + Entry(relative, "link", st.st_mode, target=target, internal=internal) + ) + else: + raise ValueError(f"cannot transfer {path!r}: not a file, dir or symlink") + + visit(root, "") + return Manifest(tuple(entries)) + + +class Wanted(NamedTuple): + """What a target needs, in reply to a manifest. + + ``checksums`` maps a path to the digest the target already has, for the + files whose size matches but whose mtime does not: the sender compares + it against its own and skips the body when they agree. That is the + check that makes a re-transfer of an unchanged tree nearly free. + """ + + paths: tuple[str, ...] + checksums: dict[str, bytes] + + def dump(self) -> tuple[SendPayload, ...]: + return (list(self.paths), self.checksums) + + @classmethod + def load(cls, data: tuple[object, ...]) -> Wanted: + paths, checksums = data + return cls(tuple(paths), dict(checksums)) # type: ignore[arg-type, call-overload] diff --git a/src/execnet/_deploy/_run.py b/src/execnet/_deploy/_run.py new file mode 100644 index 00000000..91ba92f5 --- /dev/null +++ b/src/execnet/_deploy/_run.py @@ -0,0 +1,165 @@ +"""The async half of a deployment: stage once, then work per target. + +Kept apart from :mod:`execnet._deploy._api` because that module is on the +``import execnet`` path and this one imports trio. The rule the namespace +tests pin is that importing execnet loads no event loop; a deployment is +the first thing that needs one, and it needs it no sooner than the moment +somebody actually deploys. +""" + +from __future__ import annotations + +import functools +import shutil +import subprocess +import tempfile +from collections.abc import Sequence +from pathlib import Path +from typing import TYPE_CHECKING + +from .._async import current_async +from . import _transfer + +if TYPE_CHECKING: + from .._services import ServiceTarget + from ._api import Deployed + from ._api import Deployment + +#: the service that runs the environment steps +SERVICE = "deploy" + + +def _build_project_wheel(project: Path, outdir: Path) -> Path: + """Build a wheel of ``project`` into ``outdir`` and return its path. + + The path comes from what ``uv build`` reports rather than being + assembled from the project name and version: a dirty tree gets a + ``.dYYYYMMDD`` local version that the caller cannot predict. + """ + from .._provision import _parse_built_wheel + + proc = subprocess.run( + ["uv", "build", "--wheel", "-o", str(outdir), str(project)], + check=True, + capture_output=True, + text=True, + ) + wheel = _parse_built_wheel(proc.stderr) + if wheel is None or not wheel.exists(): + raise RuntimeError( + f"could not determine the wheel path from uv build for {project}:\n" + f"{proc.stderr}" + ) + return wheel + + +def stage(deployment: Deployment, staging: Path) -> list[str]: + """Assemble what the environment is built from, as one directory. + + The lockfile and the wheels go together because they are installed + together, so shipping them as one tree is one transfer rather than one + per file. Blocking -- ``uv build`` is a subprocess -- so it runs in a + thread, once, however many targets follow. + """ + from .. import _provision + + shutil.copy2(deployment.project / "pyproject.toml", staging / "pyproject.toml") + shutil.copy2(deployment.project / "uv.lock", staging / "uv.lock") + # file roots (a conftest.py, a tox.ini) ride along in the staging tree + # rather than each buying its own transfer + for root in deployment.roots: + if root.is_file(): + shutil.copy2(root, staging / root.name) + + dist = staging / "dist" + dist.mkdir() + project_wheel = _build_project_wheel(deployment.project, dist) + + # execnet itself has to be in the deployed environment: the workers + # started against it are execnet workers. Shipping the wheel we already + # have keeps the remote off the index entirely -- and a dev coordinator + # has no requirement it could name instead. + execnet_wheel = _provision.provisioning_wheel() + if execnet_wheel is not None: + shutil.copy2(execnet_wheel, dist / execnet_wheel.name) + return [f"dist/{project_wheel.name}", f"dist/{execnet_wheel.name}"] + + import execnet + + return [f"dist/{project_wheel.name}", f"execnet=={execnet.__version__}"] + + +async def deploy_one( + deployment: Deployment, + target: ServiceTarget, + staging: Path, + wheels: Sequence[str], +) -> Deployed: + """Put an already-staged deployment onto one target.""" + from ._api import Deployed + + prepared = await target.request( + SERVICE, + { + "step": "prepare", + "workspace": deployment.workspace, + "root": deployment.workspace_root, + "name": deployment.name, + }, + ) + workspace = str(prepared["workspace"]) + + # the staged tree first: it is what the environment is built from + await _transfer.transfer_tree( + target, staging, workspace, progress=deployment.progress + ) + # then each directory root -- separate trees, so separate walks, but + # nothing makes them wait for each other + directories = [root for root in deployment.roots if root.is_dir()] + async with current_async().task_scope() as scope: + for root in directories: + scope.start_soon( + functools.partial( + _transfer.transfer_tree, + target, + root, + f"{workspace}/{root.name}", + filter=deployment.filter, + delete=deployment.delete, + progress=deployment.progress, + ) + ) + + installed = await target.request( + SERVICE, {"step": "install", "workspace": workspace, "wheels": list(wheels)} + ) + paths = {str(root): f"{workspace}/{root.name}" for root in deployment.roots} + return Deployed(workspace, str(installed["python"]), paths) + + +async def deploy_to( + deployment: Deployment, targets: Sequence[ServiceTarget] +) -> list[Deployed]: + """Deploy to every target, concurrently, from one staging build. + + The wheel is built once -- it is the same artifact for every host -- + and each target then prepares, receives and installs in its own task. + Twenty pods is twenty tasks, not twenty deployments in a row. + """ + results: list[Deployed | None] = [None] * len(targets) + with tempfile.TemporaryDirectory(prefix="execnet-deploy-") as directory: + staging = Path(directory) + wheels = await current_async().to_thread(stage, deployment, staging) + + async def one(index: int, target: ServiceTarget) -> None: + results[index] = await deploy_one(deployment, target, staging, wheels) + + if len(targets) == 1: + await one(0, targets[0]) + else: + async with current_async().task_scope() as scope: + for index, target in enumerate(targets): + scope.start_soon(one, index, target) + deployed = [result for result in results if result is not None] + assert len(deployed) == len(targets) + return deployed diff --git a/src/execnet/_deploy/_transfer.py b/src/execnet/_deploy/_transfer.py new file mode 100644 index 00000000..b3895d82 --- /dev/null +++ b/src/execnet/_deploy/_transfer.py @@ -0,0 +1,183 @@ +"""The coordinator half of a transfer: async, one task per target. + +The conversation, per target, over one service channel:: + + -> manifest the whole tree in one message + <- wanted paths, plus digests for the maybe-changed + -> (path, length), chunks per file, each read in its own thread + -> None no more bodies + <- "done" after modes, mtimes, links and deletes + +Everything here runs on a loop -- the caller's own under +:mod:`execnet.trio`, the host's under every other surface -- and every +blocking thing it does (walking the tree, reading a file) is a +``to_thread`` hop. That is what lets a fan-out put twenty targets in +flight at once instead of doing them one after another, which is the +difference between deploying to a cluster and deploying to a cluster +twenty times. +""" + +from __future__ import annotations + +import functools +import os +from collections.abc import Callable +from collections.abc import Sequence +from hashlib import md5 +from typing import TYPE_CHECKING +from typing import cast + +from .._async import current_async +from ._manifest import Filter +from ._manifest import Manifest +from ._manifest import Wanted +from ._manifest import walk + +if TYPE_CHECKING: + from .._services import ServiceTarget + +#: the service this drives +SERVICE = "transfer" + +#: how much of a file body travels in one message. A whole file in one +#: message spikes memory on both ends by its size, and a deployment ships +#: wheels; there is no flow control yet (a fast sender still outruns a slow +#: receiver into its buffers), so this bounds the spike, not the queue. +CHUNK_SIZE = 1 << 20 + +#: ``(path, size) -> None``, called as each body is sent. Runs in the +#: thread that read the file, so it may block. +Progress = Callable[[str, int], None] + + +async def snapshot( + source: str | os.PathLike[str], filter: Filter | None = None +) -> Manifest: + """Walk ``source`` once, off the loop, for however many targets follow.""" + manifest: Manifest = await current_async().to_thread( + functools.partial(walk, os.fspath(source), filter) + ) + return manifest + + +def _read_body( + path: str, + relpath: str, + digest: bytes | None, + progress: Progress | None, +) -> bytes | None: + """The bytes to send for ``path``: None when unchanged or gone. + + A file that vanished between the walk and here is not an error -- the + walk cannot hold a tree still, and a filter that deletes things is a + thing people write. The receiver leaves what it has. + + ``progress`` fires here, in this thread, because it is the one place + that already knows the body was read and is already off the loop: a + reporting callback that prints or takes a lock must not run on it. + """ + try: + with open(path, "rb") as stream: + data = stream.read() + except OSError: + return None + if digest is not None and md5(data).digest() == digest: + return None + if progress is not None: + progress(relpath, len(data)) + return data + + +async def send_manifest( + target: ServiceTarget, + manifest: Manifest, + source: str | os.PathLike[str], + destination: str, + *, + delete: bool = False, + progress: Progress | None = None, +) -> None: + """Send ``manifest``'s tree to ``destination`` on one target. + + Cancelling closes the channel, which is what stops the receiver: it is + waiting on this conversation and would otherwise sit mid-tree with no + way to learn that nobody is coming back. + """ + aio = current_async() + source = os.fspath(source) + channel = await target.open(SERVICE, {"destination": destination, "delete": delete}) + try: + await channel.send(manifest.dump()) + wanted = Wanted.load(cast("tuple[object, ...]", await channel.receive())) + for path in wanted.paths: + local = os.path.join(source, *path.split("/")) + body = await aio.to_thread( + _read_body, local, path, wanted.checksums.get(path), progress + ) + if body is None: + # unchanged after all, or gone since the walk + await channel.send((path, None)) + continue + # the length first, so the receiver knows how many bytes to + # expect -- an empty file is zero chunks, not one empty one + await channel.send((path, len(body))) + for start in range(0, len(body), CHUNK_SIZE): + await channel.send(body[start : start + CHUNK_SIZE]) + await channel.send(None) + reply = await channel.receive() + if reply != "done": + raise OSError(f"transfer to {destination} ended with {reply!r}") + finally: + with aio.shielded(): + await channel.aclose() + + +async def transfer_tree( + target: ServiceTarget, + source: str | os.PathLike[str], + destination: str, + *, + filter: Filter | None = None, + delete: bool = False, + progress: Progress | None = None, +) -> None: + """Walk ``source`` and send it to one target.""" + manifest = await snapshot(source, filter) + await send_manifest( + target, manifest, source, destination, delete=delete, progress=progress + ) + + +async def transfer_tree_to_all( + targets: Sequence[tuple[ServiceTarget, str]], + source: str | os.PathLike[str], + *, + filter: Filter | None = None, + delete: bool = False, + progress: Progress | None = None, +) -> None: + """Send ``source`` to every ``(target, destination)``, concurrently. + + The tree is walked once and shared; each target gets its own task, so + the slowest one bounds the wall clock rather than the sum of them. + """ + manifest = await snapshot(source, filter) + if len(targets) == 1: + target, destination = targets[0] + await send_manifest( + target, manifest, source, destination, delete=delete, progress=progress + ) + return + async with current_async().task_scope() as scope: + for target, destination in targets: + scope.start_soon( + functools.partial( + send_manifest, + target, + manifest, + source, + destination, + delete=delete, + progress=progress, + ) + ) diff --git a/src/execnet/_deploy/serve.py b/src/execnet/_deploy/serve.py new file mode 100644 index 00000000..4e075165 --- /dev/null +++ b/src/execnet/_deploy/serve.py @@ -0,0 +1,341 @@ +"""Worker side of the transfer and deploy services. + +Reached only through :mod:`execnet._services`, which imports this module +the first time a request for one of its names arrives -- a coordinator +never imports it, and a worker that is never asked to receive anything +never pays for it either. + +Both handlers keep their bodies synchronous and run them in a worker +thread, reaching the channel through a portal into the loop. Receiving a +tree is ``lstat``/``mkdir``/``chmod``/``utime``/``symlink`` and whole-file +writes; building an environment is waiting on ``uv``. Threading a loop +through either would rewrite fiddly, well-tested logic into something +harder to read, for a thread each. The cost is real and named: that +thread comes from the same budget exec placement rations, so a worker +receiving several trees at once has fewer left to run work on. +""" + +from __future__ import annotations + +import os +import shutil +import stat +import subprocess +import sys +from hashlib import md5 +from pathlib import Path +from typing import Any + +from .._errors import geterrortext +from .._trace import trace +from .._trio_worker import _loop_portal +from ._manifest import Entry +from ._manifest import Manifest +from ._manifest import Wanted + + +class _ThreadChannel: + """A channel's sync view, from a thread ``to_thread`` started. + + Built on the loop and handed a portal into it, rather than relying on + trio's ability to find the run that spawned a thread: asyncio's thread + hop gives the thread no such back-reference, and this has to work on + both. + """ + + def __init__(self, channel: Any, portal: Any) -> None: + self._channel = channel + self._portal = portal + + def send(self, item: object) -> None: + self._portal.run(self._channel.send, item) + + def receive(self) -> Any: + return self._portal.run(self._channel.receive) + + +def service_limiter(gateway: Any) -> Any: + """The bound on concurrent service bodies (call on the loop). + + A quarter of the worker's thread budget, because ``exec_capacity`` + already claims half and reasons explicitly about leaving "the rest to + the machinery that has to keep running while execs are in flight" -- + services are that machinery, and they were not in that accounting. + Unbounded, they are not: 25 concurrent transfers held 25 threads and + pushed a 5ms ``remote_exec`` out to 3.1 seconds, and a deployment with + many roots does exactly that to its own worker. + + Kept on the *gateway* rather than in a per-run variable: the budget it + slices belongs to one loop, and the gateway is the thing every service + call site already has -- including a pure-async worker, which has a + gateway but no engine object to hang it on. + """ + limiter = getattr(gateway, "_service_limiter", None) + if limiter is None: + aio = gateway._aio + limiter = aio.limiter(max(1, aio.thread_budget() // 4)) + gateway._service_limiter = limiter + return limiter + + +async def _serve(handler: Any, gateway: Any, channelid: int, request: Any) -> None: + """Run one service body in a thread, reporting on its channel. + + Contains its own failures: this is a task on the worker's *root* + nursery, so an exception leaving it would end ``trio.run`` and take + every gateway in the process with it. The coordinator is waiting on + this channel, so that is where the reason goes. + """ + aio = gateway._aio + channel = gateway.open_channel(channelid) + try: + await aio.to_thread( + handler, + _ThreadChannel(channel, _loop_portal()), + request, + abandon_on_cancel=True, + limiter=service_limiter(gateway), + ) + except aio.Cancelled: + raise + except BaseException as exc: + trace(f"service on channel {channelid} failed: {exc!r}") + with aio.shielded(): + try: + await channel.aclose(geterrortext(exc)) + except Exception: # the connection went away first + pass + return + await channel.aclose() + + +# -- the transfer service -- + + +def _existing(path: Path, entry: Entry) -> bytes | None: + """Whether ``entry`` still has to be sent, and what we have if unsure. + + Same size and same mtime: assume identical, which is what makes a + re-transfer of an unchanged tree nearly free. Same size, different + mtime: hand back a digest and let the sender decide -- that is the case + where a rebuild produced the same bytes. + """ + try: + st = os.lstat(path) + except OSError: + return b"" # missing: send it + if not stat.S_ISREG(st.st_mode): + _remove(path) + return b"" + if st.st_size != entry.size: + return b"" + if st.st_mtime == entry.mtime: + return None # identical as far as anyone can tell + with open(path, "rb") as stream: + return md5(stream.read()).digest() + + +def _remove(path: Path) -> None: + try: + os.unlink(path) + except OSError: + shutil.rmtree(path, ignore_errors=True) + + +def receive_tree(channel: Any, request: dict[str, Any]) -> None: + """Receive one tree into ``request["destination"]`` (blocking).""" + destination = Path(os.path.expanduser(str(request["destination"]))) + delete = bool(request.get("delete")) + + manifest = Manifest.load(channel.receive()) + destination.mkdir(parents=True, exist_ok=True) + + # directories first, so files and links have somewhere to land + wanted: list[str] = [] + checksums: dict[str, bytes] = {} + for entry in manifest.entries: + target = destination.joinpath(*entry.path.split("/")) + if entry.kind == "dir": + if target.exists() and not target.is_dir(): + _remove(target) + # writable whatever the mode says: a read-only directory we + # then have to put files into is a permission error later + target.mkdir(parents=True, exist_ok=True) + os.chmod(target, entry.mode | 0o700) + elif entry.kind == "file": + digest = _existing(target, entry) + if digest is not None: + wanted.append(entry.path) + if digest: + checksums[entry.path] = digest + channel.send(Wanted(tuple(wanted), checksums).dump()) + + # bodies, in the order we asked for them + pending = dict.fromkeys(wanted, True) + while True: + header = channel.receive() + if header is None: + break + path, length = header + target = destination.joinpath(*path.split("/")) + pending.pop(path, None) + if length is None: + continue # unchanged after all, or gone before it could be read + with open(target, "wb") as stream: + received = 0 + while received < length: + chunk = channel.receive() + stream.write(chunk) + received += len(chunk) + + # modes and times, once every body is in place + for entry in manifest.entries: + if entry.kind != "file": + continue + target = destination.joinpath(*entry.path.split("/")) + try: + os.chmod(target, entry.mode) + os.utime(target, (entry.mtime, entry.mtime)) + except OSError: + pass # never arrived, or is not ours to touch + + for entry in manifest.entries: + if entry.kind != "link": + continue + target = destination.joinpath(*entry.path.split("/")) + _remove(target) + source = ( + str(destination.joinpath(*entry.target.split("/"))) + if entry.internal + else entry.target + ) + os.symlink(source, target) + + if delete: + _delete_unlisted(destination, manifest) + channel.send("done") + + +def _delete_unlisted(destination: Path, manifest: Manifest) -> None: + """Remove anything under ``destination`` the manifest does not list.""" + keep = {entry.path for entry in manifest.entries} + for root, dirnames, filenames in os.walk(destination, topdown=True): + relative = os.path.relpath(root, destination) + prefix = "" if relative == os.curdir else relative.replace(os.sep, "/") + "/" + for name in list(dirnames): + if prefix + name not in keep: + _remove(Path(root) / name) + dirnames.remove(name) + for name in filenames: + if prefix + name not in keep: + _remove(Path(root) / name) + + +async def receive_transfer(gateway: Any, channelid: int, request: Any) -> None: + """``transfer`` service entry point.""" + await _serve(receive_tree, gateway, channelid, request) + + +# -- the deploy service -- + +#: environment variables that would point uv at an environment other than +#: the workspace's. A worker inherits its coordinator's environment, and a +#: coordinator is very often itself running inside a virtualenv -- under +#: which ``uv pip install`` installs into *that* one, silently, leaving the +#: deployed environment without the project and the coordinator's own with +#: a package it never asked for. +_ENV_OVERRIDES = ("VIRTUAL_ENV", "UV_PROJECT_ENVIRONMENT", "CONDA_PREFIX") + +#: how long any one uv invocation may take before it is a failure rather +#: than a slow network. Generous: a cold cache on a fresh host pays for +#: every wheel in the lockfile. +UV_TIMEOUT = 900.0 + + +def _venv_python(workspace: Path) -> Path: + if sys.platform.startswith("win"): + return workspace / ".venv" / "Scripts" / "python.exe" + return workspace / ".venv" / "bin" / "python" + + +def _uv_env() -> dict[str, str]: + env = dict(os.environ) + for name in _ENV_OVERRIDES: + env.pop(name, None) + return env + + +def _run_uv(args: list[str], cwd: Path) -> None: + try: + proc = subprocess.run( + ["uv", *args], + cwd=cwd, + env=_uv_env(), + capture_output=True, + text=True, + timeout=UV_TIMEOUT, + check=False, + ) + except FileNotFoundError: + raise RuntimeError( + "a deployment needs uv on the target host, and it is not on PATH" + f" for {sys.executable}" + ) from None + if proc.returncode != 0: + raise RuntimeError( + f"`uv {' '.join(args)}` failed in {cwd} with {proc.returncode}:\n" + f"{proc.stderr.strip()}" + ) + + +def _prepare(request: dict[str, Any]) -> dict[str, Any]: + """Expand and create the workspace, and report where it is. + + Expansion happens here because ``~`` means the home directory of + whoever runs the worker, which the coordinator cannot know. + """ + explicit = request.get("workspace") + if explicit: + workspace = Path(os.path.expanduser(str(explicit))) + else: + root = os.path.expanduser(str(request["root"])) + workspace = Path(root) / str(request["name"]) + workspace.mkdir(parents=True, exist_ok=True) + return {"workspace": str(workspace)} + + +def _install(request: dict[str, Any]) -> dict[str, Any]: + """Build the frozen environment and install the wheels into it.""" + workspace = Path(str(request["workspace"])) + # --no-install-project: the project is installed from the wheel the + # coordinator built, not from a source tree that is not even here. + _run_uv(["sync", "--frozen", "--no-install-project"], workspace) + python = _venv_python(workspace) + if not python.exists(): # pragma: no cover - uv would have failed first + raise RuntimeError(f"uv sync left no interpreter at {python}") + wheels = [str(item) for item in request.get("wheels", [])] + if wheels: + # --python, not the ambient environment: see _ENV_OVERRIDES. Naming + # the interpreter we just built leaves nothing to infer. + _run_uv(["pip", "install", "--python", str(python), *wheels], workspace) + return {"workspace": str(workspace), "python": str(python)} + + +STEPS = {"prepare": _prepare, "install": _install} + + +def deploy_step(channel: Any, request: dict[str, Any]) -> None: + """Run one deployment step and answer with its result (blocking).""" + step = str(request.get("step")) + try: + handler = STEPS[step] + except KeyError: + raise ValueError( + f"unknown deployment step {step!r} (known: {sorted(STEPS)})" + ) from None + channel.send(handler(request)) + + +async def run_deploy_step(gateway: Any, channelid: int, request: Any) -> None: + """``deploy`` service entry point.""" + await _serve(deploy_step, gateway, channelid, request) diff --git a/src/execnet/_engine.py b/src/execnet/_engine.py new file mode 100644 index 00000000..c57cead9 --- /dev/null +++ b/src/execnet/_engine.py @@ -0,0 +1,400 @@ +"""The engine that protocol IO runs on: one loop, on a thread of its own. + +:mod:`execnet.raw_trio` runs gateways *directly*, as tasks in the caller's +own nursery. Every other surface -- :mod:`execnet.sync`, +:mod:`execnet.trio`, :mod:`execnet.aio`, :mod:`execnet.gevent` -- gives them +an engine instead: one OS thread running ``trio.run``, which keeps serving +while the caller's own thread or loop is busy elsewhere. + +There is one shared engine per process by default, because an engine is a +thread and a loop, not a resource groups need isolated from each other. +Pass an explicit :class:`ProtocolEngine` when you do want isolation or +deterministic teardown:: + + with execnet.ProtocolEngine() as engine: + group = execnet.Group(engine=engine) + ... + # the thread is joined here, rather than at interpreter exit + +This module stays free of ``import trio`` so ``import execnet`` does not +load the event loop machinery: the real +:class:`~execnet._trio_engine.TrioEngine` is built on first use. +""" + +from __future__ import annotations + +import atexit +import importlib.util +import os +import sys +import threading +import warnings +from contextlib import suppress +from types import TracebackType +from typing import TYPE_CHECKING +from typing import Any + +from ._errors import ActiveGroupsWarning +from ._errors import forked_error + +if TYPE_CHECKING: + from typing_extensions import Self + +__all__ = ["ProtocolEngine", "check_not_in_event_loop", "default_engine"] + +#: default cap on concurrent threadpool threads running receiver callbacks +DEFAULT_CALLBACK_THREADS = 40 + +#: which async library an engine's loop may be, and where each one lives +BACKENDS = { + "trio": ("._trio_engine", "TrioEngine"), + "asyncio": ("._asyncio_engine", "AsyncioEngine"), +} + + +#: modules trio's cross-thread machinery needs the real versions of +_GEVENT_SENSITIVE = ("select", "socket", "thread", "queue") + + +def gevent_patched_modules() -> list[str]: + """Which modules the engine loop needs have been monkey-patched by gevent. + + The engine loop is a trio program on its own OS thread, and trio reaches + for ``select.epoll``, real sockets, a real ``SimpleQueue`` and real + locks to talk to it. ``gevent.monkey`` replaces those process-wide. + """ + monkey = sys.modules.get("gevent.monkey") + if monkey is None: + return [] + return [name for name in _GEVENT_SENSITIVE if monkey.is_module_patched(name)] + + +def pick_backend() -> str: + """Which async library to run a loop on, given this process. + + Trio when it is installed, because it is what execnet is tested most + against -- *unless* ``gevent.monkey`` has patched the modules trio's + cross-thread machinery needs, which is a process-wide fact trio cannot + work around and asyncio does not care about. So the one environment + where the trio engine has to refuse is exactly the one where asyncio + takes over, and :mod:`execnet.gevent` works in a patched process for + the first time. + + Resolved when a loop is started rather than when the engine object is + built: patching can happen after construction, which is why an + unspecified backend cannot be settled any earlier. An *explicit* + ``backend=`` is checked at construction, since that only depends on the + interpreter. + """ + trio_usable = importlib.util.find_spec("trio") is not None + if trio_usable and not gevent_patched_modules(): + return "trio" + if sys.version_info >= (3, 11): + return "asyncio" + if trio_usable: + # patched, on a Python with no TaskGroup: trio will refuse when it + # starts, and say why, which is a better message than anything here + return "trio" + raise RuntimeError( + "execnet needs an async library to run its protocol on: trio is not" + " installed and this interpreter is older than 3.11, which is where" + " the asyncio engine starts. Install execnet[trio]." + ) + + +def _running_event_loop() -> str | None: + """``"asyncio"`` / ``"trio"`` when called from inside one, else None. + + Both checks go through ``sys.modules`` first, so a program that never + imported asyncio or trio pays two dict lookups. asyncio is probed with + the private ``_get_running_loop`` because it *returns* None rather than + raising -- this sits in front of every blocking channel operation, and + building an exception per send is not free. + """ + asyncio = sys.modules.get("asyncio") + if asyncio is not None: + get_running = getattr(asyncio.events, "_get_running_loop", None) + if get_running is not None: + if get_running() is not None: + return "asyncio" + else: # pragma: no cover - every supported CPython has the private one + try: + asyncio.get_running_loop() + except RuntimeError: + pass + else: + return "asyncio" + trio = sys.modules.get("trio") + if trio is not None: + try: + trio.lowlevel.current_trio_token() + except RuntimeError: + pass + else: + return "trio" + return None + + +#: which namespace to point a caller at, per detected loop +_SURFACE_FOR_LOOP = { + "asyncio": "execnet.aio (AsyncGroup)", + "trio": "execnet.trio (AsyncGroup)", +} + + +def check_not_in_event_loop(what: str) -> None: + """Raise if ``what`` is about to block a running event loop's thread. + + The blocking surfaces park the calling thread on a wakener, which + inside a running loop stalls every task on it -- usually as a hang, so + it is worth turning into an error that names the right namespace. + """ + loop = _running_event_loop() + if loop is None: + return + raise RuntimeError( + f"{what} blocks the calling thread and you are inside a running" + f" {loop} event loop, which would stall every task on it." + f" Use {_SURFACE_FOR_LOOP[loop]} instead, or run this in a" + " worker thread." + ) + + +class ProtocolEngine: + """An event loop on a dedicated thread, shared by gateway groups. + + Constructing one costs nothing. The thread comes up when a group first + needs it, or when you ask with :meth:`start` -- which is where an + application that would rather not discover a broken environment at its + first ``makegateway()`` should ask. + + ``backend`` picks which async library the loop is: ``"trio"`` (the + default) or ``"asyncio"``. **The protocol core only runs on trio + today** -- an asyncio engine starts, runs tasks and stops, but refuses + to build gateways, because the protocol core has not been ported + through the seam yet. It exists so that the seam is a real, + tested boundary rather than an intention. The asyncio backend needs + Python 3.11 for ``TaskGroup``; an older interpreter is refused here, + when the engine is built. + """ + + def __init__( + self, + name: str = "execnet-engine", + callback_threads: int = DEFAULT_CALLBACK_THREADS, + *, + backend: str | None = None, + ) -> None: + if backend is not None and backend not in BACKENDS: + raise ValueError( + f"unknown engine backend {backend!r} (known: {sorted(BACKENDS)})" + ) + if backend == "asyncio": + # a fact about this interpreter, so it is answerable now rather + # than at start(): nothing a caller does later can change it + from ._asyncio_engine import check_asyncio_available + + check_asyncio_available() + #: None until started: an unspecified backend depends on whether + #: gevent has patched the world, which can still change + self.name = name + self.callback_threads = callback_threads + self.backend = backend + self._lock = threading.Lock() + self._trio_engine: Any = None + self._closed = False + #: pid the loop thread was started in; a fork does not copy it + self._pid: int | None = None + + def __repr__(self) -> str: + backend = self.backend or "auto" + return f"" + + @property + def _chosen_backend(self) -> str: + return self.backend if self.backend is not None else pick_backend() + + def _state(self) -> str: + if self._trio_engine is None: + return "closed" if self._closed else "idle" + return "running" if self._pid == os.getpid() else "inherited" + + @property + def running(self) -> bool: + """Whether this engine has a loop thread *in this process*.""" + return self._state() == "running" + + def start(self) -> Self: + """Bring the loop thread up now, and return this engine. + + Starting is otherwise lazy -- the thread appears when a group first + needs it -- which means everything that can go wrong with starting + one goes wrong at an arbitrary later ``makegateway()``: a + monkey-patched gevent process, a thread that cannot be created, a + loop that does not come up within 30s. Call this where you want to + find out, typically once at application startup:: + + engine = execnet.ProtocolEngine().start() + + Idempotent, and entering a ``ProtocolEngine`` as a context manager + does it for you. + """ + self._ensure_started() + return self + + def _ensure_started(self) -> Any: + """The started :class:`~execnet._trio_engine.TrioEngine` (internal).""" + with self._lock: + if self._closed: + raise RuntimeError( + f"{self!r} was closed: the loop thread is gone, and with it" + " every gateway and channel it served. Closing is final --" + " build a new ProtocolEngine (and a new Group on it)" + " instead of reusing this one." + ) + if self._trio_engine is not None and self._pid != os.getpid(): + # Recovery after a fork is the child's to make explicitly: + # silently starting a second loop here would hand back an + # engine that none of the inherited gateways are attached to. + raise forked_error(f"{self!r}", self._pid) # type: ignore[arg-type] + if self._trio_engine is None: + import importlib + + module_name, class_name = BACKENDS[self._chosen_backend] + module = importlib.import_module(module_name, __package__) + engine = getattr(module, class_name)( + name=self.name, callback_threads=self.callback_threads + ) + engine.start() + self._pid = os.getpid() + self._trio_engine = engine + return self._trio_engine + + def terminate(self, timeout: float | None = None) -> None: + """Terminate every group this engine serves; the loop stays up. + + Each group's own bounded contract applies -- termination frame, + grace period, then kill -- and the groups go concurrently, so this + takes about ``timeout`` however many there are. A no-op on an + engine with no loop thread: there is nothing running to terminate. + + This is the half of :meth:`close` you can call while there is still + somewhere to report a stuck worker to. Afterwards the engine is + still usable, and new groups can be built on it. + """ + trio_engine = self._trio_engine + if trio_engine is None or not self.running: + return + self._check_not_engine_thread("terminate()") + trio_engine.call(trio_engine.terminate_groups, timeout) + + def close(self, timeout: float | None = 5.0) -> None: + """Terminate what is still running, then stop the loop and join. + + Groups still live at close time are terminated for you and warned + about (:class:`~execnet.ActiveGroupsWarning`) -- their + workers are real processes, and leaving them behind because the + loop went away is never what anybody wanted. Doing it yourself is + still better: see :meth:`terminate`. + + What closing cannot do is keep those groups usable. Their protocol + IO no longer has a loop to run on, so they, their gateways and their + channels are finished with it. Closing is final -- for an engine + that never started, too -- so a group whose engine went away fails + loudly instead of quietly resurrecting a second loop thread that + none of its gateways are attached to. + + ``timeout`` bounds both halves: the termination grace, and then the + join. A thread that does not join is warned about rather than + passed over in silence. + """ + trio_engine = self._trio_engine + if trio_engine is not None and self.running: + self._check_not_engine_thread("close()") + live = trio_engine.live_groups() + if live: + warnings.warn( + f"{self!r} was closed with {live} still running: closing" + " terminates them, because the alternative is leaving" + " their worker processes behind. Terminate the groups" + " (or the engine) while you can still act on the result.", + ActiveGroupsWarning, + stacklevel=2, + ) + with suppress(Exception): + trio_engine.call(trio_engine.terminate_groups, timeout) + with self._lock: + trio_engine, self._trio_engine = self._trio_engine, None + self._closed = True + if trio_engine is not None and not trio_engine.stop(timeout=timeout): + warnings.warn( + f"{self!r} did not stop within {timeout}s: its thread is still" + " running, and whatever wedged the loop is still holding it.", + ActiveGroupsWarning, + stacklevel=2, + ) + + def _check_not_engine_thread(self, what: str) -> None: + """Refuse an operation that would have the loop wait for itself.""" + trio_engine = self._trio_engine + if trio_engine is not None and trio_engine._on_engine_thread(): + raise RuntimeError( + f"{what} was called from {self!r}'s own loop thread, where it" + " would wait for that loop to finish work it is itself" + " running. Call it from the thread that owns the engine." + ) + + def __enter__(self) -> Self: + # entering acquires: the block ends by closing the loop thread, so + # it should begin by having one -- and by having failed here if it + # cannot be had + return self.start() + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + self.close() + + +_default_lock = threading.Lock() +_default: ProtocolEngine | None = None +#: the pid _default was created in; a forked child inherits a dead thread +_default_pid: int | None = None + + +def default_engine() -> ProtocolEngine: + """The process-wide engine, started lazily, stopped at interpreter exit. + + After ``os.fork()`` the child inherits an engine whose thread does not + exist there, so a child asking for the default engine gets a fresh one + and can build new groups on it. What it does *not* get is the + inherited one working again: everything already attached to that engine + -- the pre-fork groups, gateways and channels, including the + module-level ``execnet.makegateway`` group -- stays dead in the child + and says so (:class:`~execnet._errors.ForkedResourceError`). + """ + global _default, _default_pid + with _default_lock: + pid = os.getpid() + if _default is None or _default_pid != pid: + _default = ProtocolEngine() + _default_pid = pid + return _default + + +def _close_default_atexit() -> None: + engine = _default + if engine is not None and _default_pid == os.getpid(): + # Terminate first, and quietly: a group that is still running here + # has already outlived every ``atexit`` handler that could have + # dealt with it, and a warning emitted this late may not be + # displayed at all. Reaping its workers still matters. + with suppress(Exception): + engine.terminate(timeout=1.0) + engine.close(timeout=1.0) + + +atexit.register(_close_default_atexit) diff --git a/src/execnet/_errors.py b/src/execnet/_errors.py new file mode 100644 index 00000000..e83518cd --- /dev/null +++ b/src/execnet/_errors.py @@ -0,0 +1,219 @@ +"""Exception types and the error texts that cross the wire. + +The channel-facing errors (:class:`RemoteError`, :class:`TimeoutError`, +:class:`HostNotFound`, and the :class:`DataFormatError` family) are the one +part of this module that is public: every namespace -- :mod:`execnet.sync`, +:mod:`execnet.trio`, :mod:`execnet.aio` -- re-exports them, because the same +errors are raised whichever surface you drive a gateway from. +""" + +from __future__ import annotations + +import builtins +import os +import sys +import traceback +from contextlib import suppress + +#: exceptions that must never be swallowed by a broad ``except`` +sysex = (KeyboardInterrupt, SystemExit) + +INTERRUPT_TEXT = "keyboard-interrupted" + + +class GatewayReceivedTerminate(Exception): + """Receiver got a gateway termination message.""" + + +class HostNotFound(ConnectionError): + """The remote side of a gateway could not be reached.""" + + +class LoopFinishedError(RuntimeError): + """Work was handed to a loop that has already finished. + + Backend-neutral on purpose: every route into an engine goes through a + :class:`~execnet._portal.Portal`, and the two backends spell this + differently (``trio.RunFinishedError``; a plain ``RuntimeError`` from + ``call_soon_threadsafe`` on a closed asyncio loop). Callers catch this + one and stay unaware of which engine they are talking to. + """ + + +class ActiveGroupsWarning(UserWarning): + """A :class:`~execnet.ProtocolEngine` was closed with groups still live. + + Closing terminates them rather than leaving their workers behind, but + it is doing the caller's job at the worst possible moment: at close + time there is nothing left to report a slow or stuck worker *to*, and + an engine closed from ``atexit`` may not get to display this warning at + all. Terminate the groups where you can still see the result. + + A ``UserWarning`` rather than a ``ResourceWarning`` on purpose: the + latter is ignored by default, and something that quietly kills worker + processes should not be quiet. + """ + + +class ChannelClosed(OSError): + """This channel is finished; the gateway may well be fine. + + Sending to a channel closed from either end, or otherwise using one that + has nothing left to do. An ``OSError``, because that is what execnet has + always meant by "gone" and what callers already catch -- pytest-xdist + swallows exactly this around its shutdown send. + """ + + +class GatewayGone(OSError, EOFError): + """The connection is finished; nothing on it will work again. + + Two bases on purpose. ``OSError`` is what execnet has always meant by + "gone", and ``EOFError`` is what a broken connection has always surfaced + as -- documented behaviour rather than an accident, so a type that meant + only one of them would take something away. Both spellings catch this, + and callers who want the distinction now have it. + + (The ``EOFError`` base is the one part of this that is a compatibility + accommodation; see the follow-up in ROADMAP-3.0.md.) + """ + + +class ExecnetStateError(RuntimeError): + """This call cannot be made in the state, or the place, you made it. + + A bug in the calling code rather than anything to do with the + connection: closing a channel from inside its own ``remote_exec``, + receiving from a channel that has a callback registered, using a group + that was never started, blocking on an engine from its own loop thread. + + Deliberately **not** an ``OSError``. That distinction is the point: + something retrying on connection loss should not also be retrying on its + own mistake, which is what a single type made it do. + """ + + +class ForkedResourceError(OSError): + """An execnet object was inherited by ``os.fork()`` and is dead here. + + Nothing execnet builds survives a fork: the engine's loop thread is not + duplicated into the child, and the worker connections belong to the + parent that opened them. Rather than let the child block forever on a + loop that will never run again, every route to the engine checks which + process it is in and raises this. + + An ``OSError`` because that is what execnet already means by "this + connection is gone" -- ``__del__`` paths and ``except OSError`` cleanup + keep working -- but a distinct type, so the paths that would otherwise + rewrite it as "cannot send (already closed?)" can let the real reason + through. + + Recovery is explicit and belongs to the child: build a new + :class:`~execnet.ProtocolEngine` and a new ``Group`` on it. + """ + + +def forked_error(what: str, origin_pid: int) -> ForkedResourceError: + """The :class:`ForkedResourceError` for using ``what`` after a fork.""" + return ForkedResourceError( + f"{what} belongs to pid {origin_pid} and this is pid {os.getpid()}:" + " execnet objects do not survive os.fork() -- the engine's loop thread" + " is not duplicated into the child, and the worker connections stay" + " with the parent. Build a new ProtocolEngine and a new Group in the" + " child." + ) + + +def geterrortext( + exc: BaseException, + format_exception=traceback.format_exception, + sysex: tuple[type[BaseException], ...] = sysex, +) -> str: + try: + # In py310, can change this to: + # l = format_exception(exc) + l = format_exception(type(exc), exc, exc.__traceback__) + errortext = "".join(l) + except sysex: + raise + except BaseException: + errortext = f"{type(exc).__name__}: {exc}" + return errortext + + +class RemoteError(Exception): + """Exception containing a stringified error from the other side.""" + + def __init__(self, formatted: str) -> None: + super().__init__() + self.formatted = formatted + + def __str__(self) -> str: + return self.formatted + + def __repr__(self) -> str: + return f"{self.__class__.__name__}: {self.formatted}" + + def warn(self) -> None: + if self.formatted != INTERRUPT_TEXT: + # A best-effort diagnostic that must not raise: it runs for a + # channel nobody kept a reference to, which can be on the engine + # loop (a close replayed to a late-bound consumer) and as late + # as interpreter shutdown, where stderr may already be closed. + # An exception on the loop ends the run for every gateway. + with suppress(Exception): + # XXX do this better + sys.stderr.write(f"[{os.getpid()}] Warning: unhandled {self!r}\n") + + +class TimeoutError(builtins.TimeoutError): + """Nothing arrived within the time allowed. + + Derived from the *builtin* ``TimeoutError``, which shadowing it without + subclassing had quietly prevented: ``except TimeoutError:`` -- the + obvious spelling, and the one every asyncio caller reaches for, since + ``asyncio.TimeoutError`` has *been* the builtin since 3.11 -- used to + catch nothing at all here, and only ``except OSError`` worked. + + The builtin is itself an ``OSError``, so this widens what catches it and + narrows nothing. + """ + + +class DataFormatError(Exception): + """A value could not cross the channel in execnet's simple wire format. + + execnet only moves *simple* builtin data over a channel -- ``None``, + ``bool``, ``int``, ``float``, ``complex``, ``bytes``, ``str``, and + (arbitrarily nested) ``list``/``tuple``/``set``/``frozenset``/``dict`` of + those -- plus channel references, which pass through as channels. It does + **not** pickle: arbitrary instances, functions, ``datetime``, dataclasses, + pydantic models, numpy arrays, etc. have no wire representation. + + A ``DataFormatError`` therefore signals a caller error to resolve, not a + transport failure: reduce the value to simple data before sending (and + reconstruct it after receiving) with an encoding mechanism of your own -- + e.g. pydantic ``model_dump`` / ``model_validate`` or pytest's + ``pytest_report_to_serializable`` / ``pytest_report_from_serializable`` + hooks. See the docs, "Sending objects over a channel". + """ + + +class DumpError(DataFormatError): + """A value being **sent** is not simple wire data; convert it first. + + Raised by ``channel.send`` (and the internal serializer) when an object is + not one of execnet's simple wire types. Fix it at the call site by turning + the rich object into simple data -- e.g. ``dt.isoformat()``, + ``dataclasses.asdict(obj)``, ``model.model_dump(mode="json")`` -- rather + than expecting the channel to pickle it. Channels are the one non-builtin + that *is* sendable, so nested channel references are fine. + """ + + +class LoadError(DataFormatError): + """Received bytes could not be turned back into an object. + + Raised while **receiving** (deserializing) -- a corrupted or + protocol-incompatible payload, or data produced by a mismatched peer. + """ diff --git a/src/execnet/_exec_source.py b/src/execnet/_exec_source.py new file mode 100644 index 00000000..617deb46 --- /dev/null +++ b/src/execnet/_exec_source.py @@ -0,0 +1,91 @@ +"""Normalize ``remote_exec`` sources (string / function / module) to code. + +Shared by the sync coordinator ``Gateway`` and the trio-native +``AsyncGateway`` so both accept the same source kinds with identical +restrictions (pure functions taking ``channel`` first, no closures, no +non-builtin globals). +""" + +from __future__ import annotations + +import inspect +import linecache +import textwrap +import types +from collections.abc import Callable +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from ._serialize import SendPayload + + +def normalize_exec_source( + source: str | types.FunctionType | Callable[..., object] | types.ModuleType, + kwargs: dict[str, SendPayload], +) -> tuple[str, str | None, str | None]: + """Return ``(source, file_name, call_name)`` for a CHANNEL_EXEC payload.""" + call_name = None + file_name = None + if isinstance(source, types.ModuleType): + file_name = inspect.getsourcefile(source) + linecache.updatecache(file_name) # type: ignore[arg-type] + source = inspect.getsource(source) + elif isinstance(source, types.FunctionType): + call_name = source.__name__ + file_name = inspect.getsourcefile(source) + source = _source_of_function(source) + else: + source = textwrap.dedent(str(source)) + + if not call_name and kwargs: + raise TypeError("can't pass kwargs to non-function remote_exec") + return source, file_name, call_name + + +def _find_non_builtin_globals(source: str, codeobj: types.CodeType) -> list[str]: + import ast + import builtins + + vars = dict.fromkeys(codeobj.co_varnames) + return [ + node.id + for node in ast.walk(ast.parse(source)) + if isinstance(node, ast.Name) + and node.id not in vars + and node.id not in builtins.__dict__ + ] + + +def _source_of_function(function: types.FunctionType | Callable[..., object]) -> str: + if function.__name__ == "": + raise ValueError("can't evaluate lambda functions'") + # XXX: we dont check before remote instantiation + # if arguments are used properly + try: + sig = inspect.getfullargspec(function) + except AttributeError: + args = inspect.getargspec(function)[0] + else: + args = sig.args + if not args or args[0] != "channel": + raise ValueError("expected first function argument to be `channel`") + + closure = function.__closure__ + codeobj = function.__code__ + + if closure is not None: + raise ValueError("functions with closures can't be passed") + + try: + source = inspect.getsource(function) + except OSError as e: + raise ValueError("can't find source file for %s" % function) from e + + source = textwrap.dedent(source) # just for inner functions + + used_globals = _find_non_builtin_globals(source, codeobj) + if used_globals: + raise ValueError("the use of non-builtin globals isn't supported", used_globals) + + leading_ws = "\n" * (codeobj.co_firstlineno - 1) + return leading_ws + source diff --git a/src/execnet/_execmodel.py b/src/execnet/_execmodel.py new file mode 100644 index 00000000..c4af3af0 --- /dev/null +++ b/src/execnet/_execmodel.py @@ -0,0 +1,147 @@ +"""Worker profiles, and the deprecated ExecModel shim. + +The machinery behind "execution models" was retired during the Trio port. +What the name actually selected -- where exec'd code runs relative to the +worker's protocol loop -- survives as the ``profile=`` spec key and +:data:`WORKER_PROFILES`. + +:class:`ExecModel` itself is kept only because pytest-xdist's remote worker +builds its test queue on ``channel.gateway.execmodel.RLock()``/``Event()``. +""" + +from __future__ import annotations + +import os +import threading +import warnings + + +class ExecModel: + """Deprecated preset name for an execution model. + + The machinery behind execution models was retired: protocol IO always + runs on the Trio host and blocking waits go through the boundary kit's + wakeners (``execnet._boundary``). What the name selected survives as + the ``profile=`` spec key (:data:`WORKER_PROFILES`). The + stdlib-delegating members stay for API compatibility (pytest-xdist + builds its test queue on ``execmodel.RLock``/``Event``) -- every preset + is thread-shaped. + """ + + def __init__(self, backend: str) -> None: + self.backend = backend + + def __repr__(self) -> str: + return "" % self.backend + + @property + def queue(self): + import queue + + return queue + + @property + def subprocess(self): + import subprocess + + return subprocess + + @property + def socket(self): + import socket + + return socket + + def get_ident(self) -> int: + import _thread + + return _thread.get_ident() + + def sleep(self, delay: float) -> None: + import time + + time.sleep(delay) + + def start(self, func, args=()) -> None: + import _thread + + _thread.start_new_thread(func, args) + + def fdopen(self, fd, mode, bufsize=1, closefd=True): + return os.fdopen(fd, mode, bufsize, encoding="utf-8", closefd=closefd) + + def Lock(self): + return threading.RLock() + + def RLock(self): + return threading.RLock() + + def Event(self) -> threading.Event: + return threading.Event() + + +#: worker profiles: where exec'd code runs relative to the protocol loop +WORKER_PROFILES = ( + "thread", # hybrid: primary on the main thread, overflow on pool threads + "trio", # pure async: loop owns the main thread, async sources as tasks + "gevent", # greenlets on a main-thread hub, one per remote_exec +) + + +#: profiles kept as accepted spellings, mapped to what they now select. +#: ``main_thread_only`` predates the restored hybrid ``thread`` profile, +#: which already hands the first remote_exec the real main thread -- the +#: GUI/signal property it existed for. What it additionally did was refuse +#: a *second* concurrent remote_exec instead of overflowing to a pool +#: thread; that guard is gone. +DEPRECATED_PROFILES = {"main_thread_only": "thread"} + + +def effective_profile(name: str) -> str: + """Validate a profile name and map deprecated spellings, silently. + + For the places that *consume* a profile (worker config, strategy + lookup). :func:`resolve_profile` is the one that warns, and is called + once where the value enters. + """ + replacement = DEPRECATED_PROFILES.get(name) + if replacement is not None: + return replacement + if name not in WORKER_PROFILES: + raise ValueError(f"unknown profile {name!r} (known: {list(WORKER_PROFILES)})") + return name + + +def resolve_profile(name: str) -> str: + """Validate a ``profile=`` value, warning about deprecated spellings. + + Callers that hold a caller-supplied :class:`~execnet._xspec.XSpec` must + *not* write the result back onto it: pytest-xdist reuses a spec object + across gateways and re-reads ``spec.execmodel`` to decide whether it + still needs prefixing, so normalizing the value it set makes the second + use build a spec with a duplicate key. Validate here, and map with + :func:`effective_profile` where the value is used. + """ + replacement = DEPRECATED_PROFILES.get(name) + if replacement is not None: + warnings.warn( + f"the {name!r} worker profile is deprecated and now behaves like" + f" {replacement!r}, which already runs the first remote_exec on" + " the worker's main thread. A second concurrent remote_exec no" + " longer fails -- it runs on a pool thread.", + DeprecationWarning, + stacklevel=3, + ) + return replacement + if name not in WORKER_PROFILES: + raise ValueError(f"unknown profile {name!r} (known: {list(WORKER_PROFILES)})") + return name + + +def get_execmodel(backend: str | ExecModel) -> ExecModel: + """Deprecated: build the xdist-facing shim for a profile name.""" + if isinstance(backend, ExecModel): + return backend + if backend in WORKER_PROFILES or backend in DEPRECATED_PROFILES: + return ExecModel(backend) + raise ValueError(f"unknown profile {backend!r}") diff --git a/src/execnet/_gateway.py b/src/execnet/_gateway.py new file mode 100644 index 00000000..d9b5c7e5 --- /dev/null +++ b/src/execnet/_gateway.py @@ -0,0 +1,168 @@ +"""Gateway code for initiating popen, socket and ssh connections. + +(c) 2004-2013, Holger Krekel and others +""" + +from __future__ import annotations + +import types +import warnings +from collections.abc import Callable +from typing import TYPE_CHECKING +from typing import Any +from typing import cast + +from ._channel import Channel +from ._exec_source import normalize_exec_source +from ._gateway_base import BaseGateway +from ._message import IO +from ._message import Message +from ._multi import Group +from ._serialize import Payload +from ._serialize import SendPayload +from ._serialize import dumps_internal +from ._xspec import XSpec + +__all__ = [ + "Gateway", + "RInfo", + "RemoteStatus", +] + + +class Gateway(BaseGateway): + """Gateway to a local or remote Python Interpreter.""" + + _group: Group + _guard_event_loop = True + + def __init__(self, io: IO, spec: XSpec) -> None: + """:private: + + The Trio session doing the Message IO is attached separately via + ``_attach_trio_session`` once the connection is established. + """ + super().__init__(io=io, id=spec.id, _startcount=1) + self.spec = spec + + @property + def remoteaddress(self) -> str: + # Only defined for remote IO types. + return self._io.remoteaddress # type: ignore[attr-defined,no-any-return] + + def __repr__(self) -> str: + """A string representing gateway type and status.""" + try: + r: str = (self.hasreceiver() and "receive-live") or "not-receiving" + i = str(len(self._channelfactory.channels())) + except AttributeError: + r = "uninitialized" + i = "no" + return f"<{self.__class__.__name__} id={self.id!r} {r}, {self.spec.profile} profile, {i} active channels>" + + def exit(self) -> None: + """Trigger gateway exit. + + Defer waiting for finishing of receiver-thread and subprocess activity + to when group.terminate() is called. + """ + self._trace("gateway.exit() called") + if self not in self._group: + self._trace("gateway already unregistered with group") + return + self._group._unregister(self) + try: + self._trace("--> sending GATEWAY_TERMINATE") + self._send(Message.GATEWAY_TERMINATE) + self._trace("--> io.close_write") + self._io.close_write() + except (ValueError, EOFError, OSError) as exc: + self._trace("io-error: could not send termination sequence") + self._trace(" exception: %r" % exc) + + def _rinfo(self, update: bool = False) -> RInfo: + """Return some sys/env information from remote. + + A native protocol request (like ``remote_status``): it never + touches the exec machinery, so it cannot claim an exec slot on + main-thread-shaped workers. + """ + if update or not hasattr(self, "_cache_rinfo"): + channel = self.newchannel() + self._send(Message.GATEWAY_INFO, channel.id) + self._cache_rinfo = RInfo( + cast("dict[str, Payload[Channel]]", channel.receive()) + ) + # the other side didn't actually instantiate a channel + # so we just delete the internal id/channel mapping + self._channelfactory._local_close(channel.id) + return self._cache_rinfo + + def hasreceiver(self) -> bool: + """Whether gateway is able to receive data.""" + session = self._trio_session + return session is not None and bool(session.is_alive()) + + def remote_status(self) -> RemoteStatus: + """Obtain information about the remote execution status.""" + channel = self.newchannel() + self._send(Message.STATUS, channel.id) + statusdict = cast("dict[str, Payload[Channel]]", channel.receive()) + # the other side didn't actually instantiate a channel + # so we just delete the internal id/channel mapping + self._channelfactory._local_close(channel.id) + return RemoteStatus(statusdict) + + def remote_exec( + self, + source: str | types.FunctionType | Callable[..., object] | types.ModuleType, + **kwargs: SendPayload, + ) -> Channel: + """Return channel object and connect it to a remote + execution thread where the given ``source`` executes. + + * ``source`` is a string: execute source string remotely + with a ``channel`` put into the global namespace. + * ``source`` is a pure function: serialize source and + call function with ``**kwargs``, adding a + ``channel`` object to the keyword arguments. + * ``source`` is a pure module: execute source of module + with a ``channel`` in its global namespace. + + In all cases the binding ``__name__='__channelexec__'`` + will be available in the global namespace of the remotely + executing code. + """ + source, file_name, call_name = normalize_exec_source(source, kwargs) + channel = self.newchannel() + self._send( + Message.CHANNEL_EXEC, + channel.id, + dumps_internal((source, file_name, call_name, kwargs)), + ) + return channel + + def remote_init_threads(self, num: int | None = None) -> None: + """DEPRECATED. Is currently a NO-OPERATION already.""" + warnings.warn( + "remote_init_threads() has been a no-operation since execnet 1.2" + " and will be removed; drop the call.", + DeprecationWarning, + stacklevel=2, + ) + + +class RInfo: + def __init__(self, kwargs: dict[str, Payload[Channel]]) -> None: + self.__dict__.update(kwargs) + + def __repr__(self) -> str: + info = ", ".join(f"{k}={v}" for k, v in sorted(self.__dict__.items())) + return "" % info + + if TYPE_CHECKING: + + def __getattr__(self, name: str) -> Any: ... + + +RemoteStatus = RInfo diff --git a/src/execnet/_gateway_base.py b/src/execnet/_gateway_base.py new file mode 100644 index 00000000..7534064a --- /dev/null +++ b/src/execnet/_gateway_base.py @@ -0,0 +1,285 @@ +"""The gateway base classes shared by coordinator and worker. + +:class:`BaseGateway` owns the channel factory, the send path and the handle on +the Trio session that does the actual Message IO; :class:`WorkerGateway` adds +the worker-side ``remote_exec`` scheduling and shutdown. + +:copyright: 2004-2015 +:authors: + - Holger Krekel + - Armin Rigo + - Benjamin Peterson + - Ronny Pfannschmidt + - many others +""" + +from __future__ import annotations + +import os +import sys +from _thread import interrupt_main +from collections.abc import Callable +from contextlib import suppress +from typing import Any + +from ._boundary import WaitBackend +from ._boundary import Wakener +from ._boundary import make_wakener +from ._channel import Channel +from ._channel import ChannelFactory +from ._channel import Endmarker +from ._errors import INTERRUPT_TEXT +from ._errors import ForkedResourceError +from ._errors import GatewayGone +from ._errors import geterrortext +from ._errors import sysex +from ._message import IO +from ._message import Message +from ._trace import trace + + +class BaseGateway: + _sysex = sysex + id = "" + _trio_session: Any = None + # Set by the receiver on EOF without a prior termination message. + _error: BaseException | None = None + #: which primitive this gateway's blocking waits park on (channels, + #: write-acks, join). Inherited from the facade coordinator-side, and + #: derived from the worker profile worker-side. + _wait_backend: WaitBackend = "thread" + #: whether blocking operations refuse to run inside a foreign event + #: loop. Only coordinator-side: exec'd code in a worker may legitimately + #: run its own loop and talk to its channel from inside it. + _guard_event_loop = False + + def _check_usable(self, what: str) -> None: + """Refuse ``what`` when this gateway cannot possibly serve it. + + Two caller bugs, checked before anything else (in particular before + the channel-state check, so which one you are told about does not + depend on whether the peer has closed yet): using a gateway that a + fork left behind in another process, and blocking a running event + loop's own thread. + """ + if self._pid != os.getpid(): + from ._errors import forked_error + + raise forked_error(what, self._pid) + if self._guard_event_loop: + from ._engine import check_not_in_event_loop + + check_not_in_event_loop(what) + + def __init__(self, io: IO, id, _startcount: int = 2) -> None: + self.execmodel = io.execmodel + self._io = io + self.id = id + #: pid this gateway's connection (and its engine loop) belongs to + self._pid = os.getpid() + self._channelfactory = ChannelFactory(self, _startcount) + # globals may be NONE at process-termination + self.__trace = trace + self._geterrortext = geterrortext + self._trio_session = None + + def _trace(self, *msg: object) -> None: + self.__trace(self.id, *msg) + + def _attach_trio_session(self, session: Any) -> None: + """Attach the Trio bridge session doing the Message IO.""" + self._trio_session = session + # Defensive: channels created before the session existed still + # need their inbound routing diverted to them. + for channel in self._channelfactory.channels(): + session.bind_sync_channel(channel) + + def _new_wakener(self) -> Wakener: + """A fresh wakener for one blocking-wait carrier.""" + return make_wakener(self._wait_backend) + + def _bind_channel(self, channel: Channel) -> None: + """Divert the session's inbound routing for ``channel.id`` to it.""" + session = self._trio_session + if session is not None: + session.bind_sync_channel(channel) + + def _release_channel(self, id: int) -> None: + """Drop the session's loop-side state for ``id`` (best-effort).""" + session = self._trio_session + if session is not None: + with suppress(Exception): + session.release_channel(id) + + def _run_on_loop(self, sync_fn: Callable[[], Any]) -> Any: + """Run ``sync_fn`` on the session's loop thread (inline without one). + + Payload dispatch happens on the loop thread, so state switches run + there to exclude interleaving with deliveries. + """ + session = self._trio_session + if session is None: + return sync_fn() + return session.run_on_loop(sync_fn) + + def _start_channel_consumer( + self, + channel: Channel, + callback: Callable[[Any], Any], + endmarker: Endmarker, + ) -> None: + """Attach a receiver callback: hand the channel to a consumer task. + + The task drains the channel on the loop and runs ``callback`` in a + threadpool thread, holding the channel alive while it consumes. + """ + session = self._trio_session + if session is None: + raise OSError(f"cannot set callback on {channel!r}: no active session") + session.attach_consumer(channel, callback, endmarker) + + def _terminate_execution(self) -> None: + pass + + def _send(self, msgcode: int, channelid: int = 0, data: bytes = b"") -> None: + message = Message(msgcode, channelid, data) + session = self._trio_session + if session is not None: + try: + session.enqueue_message(message) + self._trace("sent", message) + except ForkedResourceError: + # "already closed?" would be a guess, and a wrong one + self._trace("failed to send", message, "(inherited by a fork)") + raise + except (OSError, ValueError) as e: + self._trace("failed to send", message, e) + raise GatewayGone("cannot send (already closed?)") from e + return + try: + message.to_io(self._io) + self._trace("sent", message) + except (OSError, ValueError) as e: + self._trace("failed to send", message, e) + # ValueError might be because the IO is already closed + raise GatewayGone("cannot send (already closed?)") from e + + def _send_nonblocking(self, msgcode: int, channelid: int = 0) -> None: + """Best-effort send that never waits (used during GC). + + Safe to call from any thread, including while the interpreter or + the IO loop is shutting down. + """ + message = Message(msgcode, channelid) + session = self._trio_session + if session is not None: + session.post_message(message) + return + message.to_io(self._io) + + def _local_schedulexec(self, channel: Channel, sourcetask: bytes) -> None: + channel.close("execution disallowed") + + # _____________________________________________________________________ + # + # High Level Interface + # _____________________________________________________________________ + # + def newchannel(self) -> Channel: + """Return a new independent channel.""" + return self._channelfactory.new() + + def join(self, timeout: float | None = None) -> None: + """Wait for the receiver (Trio session) to terminate.""" + self._check_usable("gateway.join()") + self._trace("waiting for receiver to finish") + session = self._trio_session + if session is not None: + session.wait_done(timeout) + + +class WorkerGateway(BaseGateway): + _trio_exec: Any = None + # The exec pool (a TrioWorkerExec duck-typed as WorkerPool for STATUS). + _execpool: Any = None + + def _local_schedulexec(self, channel: Channel, sourcetask: bytes) -> None: + trio_exec = self._trio_exec + if trio_exec is None: + channel.close("execution disallowed") + return + trio_exec.schedule(channel, sourcetask) + + def _terminate_execution(self) -> None: + # called from receiverthread + self._trace("shutting down execution pool") + self._execpool.trigger_shutdown() + if not self._execpool.waitall(5.0): + self._trace("execution ongoing after 5 secs, trying interrupt_main") + # We try hard to terminate execution based on the assumption + # that there is only one gateway object running per-process. + if sys.platform != "win32": + self._trace("sending ourselves a SIGINT") + os.kill(os.getpid(), 2) # send ourselves a SIGINT + elif interrupt_main is not None: + self._trace("calling interrupt_main()") + interrupt_main() + if not self._execpool.waitall(10.0): + self._trace( + "execution did not finish in another 10 secs, calling os._exit()" + ) + os._exit(1) + + def executetask( + self, + item: tuple[Channel, tuple[str, str | None, str | None, dict[str, object]]], + ) -> None: + try: + channel, (source, file_name, call_name, kwargs) = item + loc: dict[str, Any] = {"channel": channel, "__name__": "__channelexec__"} + self._trace(f"execution starts[{channel.id}]: {repr(source)[:50]}") + channel._executing = True + try: + co = compile(source + "\n", file_name or "", "exec") + exec(co, loc) + if call_name: + self._trace("calling %s(**%60r)" % (call_name, kwargs)) + function = loc[call_name] + function(channel, **kwargs) + finally: + channel._executing = False + self._trace("execution finished") + except KeyboardInterrupt: + self._close_finished(channel, INTERRUPT_TEXT) + raise + except EOFError: + self._trace("ignoring EOFError because receiving finished") + + except BaseException as exc: + if not channel.gateway._channelfactory.finished: + self._trace(f"got exception: {exc!r}") + errortext = self._geterrortext(exc) + self._close_finished(channel, errortext) + return + self._close_finished(channel) + + def _close_finished(self, channel: Channel, error: str | None = None) -> None: + """Close the channel an exec ran on, tolerating a dead connection. + + The close is how the coordinator learns the source finished, so it + is attempted always -- but the connection going away first is an + ordinary teardown race (a killed worker, a terminate that outran the + exec), and there is no longer anyone to raise at. Letting the OSError + out lands it in the exec task, whose nursery is the worker's root one. + + The exec's admission slot goes back *first*: this close is also what + tells a coordinator at capacity that it may send the next request, + and it must not be able to arrive before the slot it frees. + """ + execpool = self._execpool + if execpool is not None: + execpool.release_slot(channel.id) + try: + channel.close(error) + except OSError as exc: + self._trace("could not close", channel, "after execution:", exc) diff --git a/src/execnet/_gevent_support.py b/src/execnet/_gevent_support.py new file mode 100644 index 00000000..17cb7993 --- /dev/null +++ b/src/execnet/_gevent_support.py @@ -0,0 +1,71 @@ +"""The gevent wait backend behind :mod:`execnet.gevent`. + +The boundary kit imports this lazily (``make_wakener("gevent")``) so +gevent stays an optional dependency. + +A blocking wait parks only the calling greenlet: the carrier's +event lives in the waiting greenlet's hub, and the loop side's +``notify()`` crosses threads through a ``loop.async_`` watcher -- the +one libev/libuv primitive gevent documents as safe to use from other +threads. +""" + +from __future__ import annotations + +import threading +from typing import Any + +import gevent.event +from gevent.hub import get_hub + + +class GeventWakener: + """Wakener parking greenlets instead of OS threads. + + ``notify()`` is thread-safe and non-blocking (called from the trio + host thread). The hub-bound pieces (event + async watcher) are + created lazily in the first waiter's hub, so construction is safe + from any thread -- including the host loop, which creates channels + for inbound ids. Waiters must share one hub (one gevent thread per + carrier), which is the ordinary gevent setup. + """ + + def __init__(self) -> None: + self._lock = threading.Lock() + self._notified = False + self._event: gevent.event.Event | None = None + self._watcher: Any = None + + def notify(self) -> None: + with self._lock: + self._notified = True + watcher = self._watcher + if watcher is not None: + watcher.send() + + def _ensure(self) -> gevent.event.Event: + """Create the hub-bound event/watcher in the waiter's context.""" + event = self._event + if event is None: + event = gevent.event.Event() + watcher = get_hub().loop.async_() + watcher.start(event.set) + with self._lock: + self._event = event + self._watcher = watcher + return event + + def wait(self, timeout: float | None = None) -> bool: + event = self._ensure() + with self._lock: + # a notify may have fired before the watcher existed + if self._notified and not event.is_set(): + event.set() + return bool(event.wait(timeout)) + + def clear(self) -> None: + with self._lock: + self._notified = False + event = self._event + if event is not None: + event.clear() diff --git a/src/execnet/_handshake.py b/src/execnet/_handshake.py new file mode 100644 index 00000000..03a9a5e9 --- /dev/null +++ b/src/execnet/_handshake.py @@ -0,0 +1,162 @@ +"""The worker handshake: the config frame, and the reply to it. + +Every worker execnet starts is configured the same way, over the protocol +stream it was given and before the Message protocol proper begins: + +1. the coordinator sends one ``GATEWAY_CONFIG`` frame carrying the worker + config as JSON; +2. the worker applies it (version check, ``chdir``/``nice``/``env``, stdio + disposition) and answers with a ``GATEWAY_CONFIG`` frame of its own -- + ``{"ok": true, ...}`` when it is about to serve, ``{"ok": false, + "error": ...}`` when it refuses; +3. both sides start framing normally on the same stream. + +The config travels here rather than in argv because it carries ``env:`` +values and ``/proc`` (like ``ps``) is world-readable -- to every user on +the machine, not only on remote ones. It is a *frame* rather than a bare +line so a refusal has somewhere to go: a worker that will not serve says +why on the wire, instead of dying to a stderr nobody is reading and +leaving the coordinator to infer it from an exit status. + +Two directions, deliberately in one module so they cannot drift. The +worker's side is blocking and runs before it has an event loop -- which is +what lets the config decide the worker's *shape* (``profile=trio`` has no +side thread to read it on). The coordinator's side is async and speaks +:class:`~execnet._trio_gateway.ByteStream`, without importing trio. +""" + +from __future__ import annotations + +import json +from typing import Any +from typing import Protocol + +from ._message import Message + +__all__ = [ + "BlockingChannel", + "ConfigRefused", + "read_config_frame", + "read_ready", + "send_config", + "send_ready_frame", +] + + +class ConfigRefused(Exception): + """The worker read its config and declined to serve.""" + + +class BlockingChannel(Protocol): + """The worker's pre-loop view of its protocol stream. + + Deliberately not fds: a socket handed to a Windows worker by + ``socket.share()`` has no usable fd there, and ``os.read`` on it fails. + Each transport supplies whichever pair of primitives it actually has. + """ + + def recv(self, max_bytes: int, /) -> bytes: ... + + def sendall(self, data: bytes, /) -> None: ... + + +def _recv_exactly(channel: BlockingChannel, count: int) -> bytes: + """Read exactly ``count`` bytes, never one more. + + Over-reading is not an option: whatever follows the handshake frame is + the peer's first protocol frames, and they belong to the loop that has + not started yet. + """ + chunks: list[bytes] = [] + remaining = count + while remaining: + chunk = channel.recv(remaining) + if not chunk: + raise EOFError( + f"connection closed during the worker handshake " + f"({count - remaining} of {count} bytes)" + ) + chunks.append(chunk) + remaining -= len(chunk) + return b"".join(chunks) + + +def _read_frame_blocking(channel: BlockingChannel) -> Message: + header = _recv_exactly(channel, 9) + msgcode, channelid, payload_len = Message.from_header(header) + payload = _recv_exactly(channel, payload_len) if payload_len else b"" + return Message(msgcode, channelid, payload) + + +def _config_frame(payload: dict[str, Any]) -> bytes: + return Message( + Message.GATEWAY_CONFIG, 0, json.dumps(payload).encode("utf-8") + ).pack() + + +def _decode(message: Message, what: str) -> dict[str, Any]: + if message.msgcode != Message.GATEWAY_CONFIG: + raise EOFError(f"expected a {what} frame, got {message!r}") + payload = json.loads(message.data) + if not isinstance(payload, dict): + raise EOFError(f"{what} is not an object: {payload!r}") + return payload + + +# -- worker side (blocking, before there is a loop) -- + + +def read_config_frame(channel: BlockingChannel) -> dict[str, Any]: + """Block until the coordinator's config frame arrives; return the config.""" + return _decode(_read_frame_blocking(channel), "worker config") + + +def send_ready_frame( + channel: BlockingChannel, error: str | None = None, **info: Any +) -> None: + """Answer the config frame: serving, or refusing and why.""" + if error is not None: + channel.sendall(_config_frame({"ok": False, "error": error})) + return + channel.sendall(_config_frame({"ok": True, **info})) + + +# -- coordinator side (async, over the ByteStream) -- + + +async def send_config(stream: Any, config: dict[str, Any]) -> None: + """Send the worker config as the first frame on ``stream``.""" + await stream.send_all(_config_frame(config)) + + +async def _receive_exactly(stream: Any, count: int) -> bytes: + chunks: list[bytes] = [] + remaining = count + while remaining: + chunk = await stream.receive_some(remaining) + if not chunk: + raise EOFError( + f"connection closed during the worker handshake " + f"({count - remaining} of {count} bytes)" + ) + chunks.append(bytes(chunk)) + remaining -= len(chunk) + return b"".join(chunks) + + +async def read_ready(stream: Any, what: str) -> dict[str, Any]: + """Await the worker's reply; raise :class:`ConfigRefused` if it declined. + + ``what`` names the transport, so an EOF here says which launch never + got as far as answering. + """ + header = await _receive_exactly(stream, 9) + msgcode, channelid, payload_len = Message.from_header(header) + payload = await _receive_exactly(stream, payload_len) if payload_len else b"" + try: + reply = _decode(Message(msgcode, channelid, payload), f"{what} handshake reply") + except ValueError as exc: # malformed JSON from something that is not us + raise EOFError(f"bad {what} handshake reply: {exc}") from None + if not reply.get("ok"): + raise ConfigRefused(reply.get("error") or f"worker refused the {what} config") + return reply diff --git a/src/execnet/_message.py b/src/execnet/_message.py new file mode 100644 index 00000000..02d4c9be --- /dev/null +++ b/src/execnet/_message.py @@ -0,0 +1,188 @@ +"""The wire protocol: IO protocols, Message framing and its decoder. + +A frame is a 9-byte header (``!bii``: message code, channel id, payload +length) followed by the payload. Nothing here dispatches -- routing lives in +``AsyncGateway._dispatch`` and the sync bridge session; this module only packs, +unpacks and buffers. +""" + +from __future__ import annotations + +import os +import struct +import sys +from collections.abc import Iterator +from typing import TYPE_CHECKING +from typing import Protocol + +if TYPE_CHECKING: + from ._execmodel import ExecModel + from ._serialize import SendPayload + + +class WriteIO(Protocol): + def write(self, data: bytes, /) -> None: ... + + +class ReadIO(Protocol): + def read(self, numbytes: int, /) -> bytes: ... + + +class IO(Protocol): + """What a gateway still needs from the object it was built around. + + Reading and writing moved to the Trio session long ago; what is left is + the write-side close behind ``Gateway.exit``. Waiting for and killing a + worker process belongs to whoever holds the process handle -- the async + group -- not here. + """ + + execmodel: ExecModel + + def read(self, numbytes: int, /) -> bytes: ... + + def write(self, data: bytes, /) -> None: ... + + def close_read(self) -> None: ... + + def close_write(self) -> None: ... + + +class Message: + """Encapsulates Messages and their wire protocol. + + Dispatch lives in the async core and the sync bridge session + (``AsyncGateway._dispatch`` / ``SyncBridgeGateway._dispatch``); this + class only carries the framing and the code constants. + """ + + STATUS = 0 + #: retired: the py2/py3 string coercion switch. Nothing sends or + #: handles it anymore, the code stays reserved for reuse. + RECONFIGURE = 1 + GATEWAY_TERMINATE = 2 + CHANNEL_EXEC = 3 + CHANNEL_DATA = 4 + CHANNEL_CLOSE = 5 + CHANNEL_CLOSE_ERROR = 6 + CHANNEL_LAST_MESSAGE = 7 + GATEWAY_START_SOCKET = 8 + GATEWAY_START_SUB = 9 + GATEWAY_INFO = 10 + #: the worker handshake, both directions -- see :mod:`execnet._handshake`. + #: Exchanged before either side starts serving, so it is never dispatched. + GATEWAY_CONFIG = 11 + #: a request the worker serves *itself* rather than exec'ing: the + #: payload names the service and carries its request, and what the name + #: means is none of the core's business. See :mod:`execnet._services`. + GATEWAY_SERVICE = 12 + + # message code -> name + _types: dict[int, str] = { + STATUS: "STATUS", + RECONFIGURE: "RECONFIGURE", + GATEWAY_TERMINATE: "GATEWAY_TERMINATE", + CHANNEL_EXEC: "CHANNEL_EXEC", + CHANNEL_DATA: "CHANNEL_DATA", + CHANNEL_CLOSE: "CHANNEL_CLOSE", + CHANNEL_CLOSE_ERROR: "CHANNEL_CLOSE_ERROR", + CHANNEL_LAST_MESSAGE: "CHANNEL_LAST_MESSAGE", + GATEWAY_START_SOCKET: "GATEWAY_START_SOCKET", + GATEWAY_START_SUB: "GATEWAY_START_SUB", + GATEWAY_INFO: "GATEWAY_INFO", + GATEWAY_CONFIG: "GATEWAY_CONFIG", + GATEWAY_SERVICE: "GATEWAY_SERVICE", + } + + def __init__(self, msgcode: int, channelid: int = 0, data: bytes = b"") -> None: + self.msgcode = msgcode + self.channelid = channelid + self.data = data + + def pack(self) -> bytes: + """Return the full wire frame (9-byte header + payload).""" + header = struct.pack("!bii", self.msgcode, self.channelid, len(self.data)) + return header + self.data + + @staticmethod + def from_header(header: bytes) -> tuple[int, int, int]: + """Unpack a 9-byte header into (msgtype, channelid, payload_len).""" + if len(header) != 9: + raise EOFError("couldn't load message header, short read") + msgtype, channel, payload = struct.unpack("!bii", header) + return msgtype, channel, payload + + @staticmethod + def from_parts(msgtype: int, channel: int, data: bytes) -> Message: + return Message(msgtype, channel, data) + + @staticmethod + def from_io(io: ReadIO) -> Message: + try: + header = io.read(9) # type 1, channel 4, payload 4 + if not header: + raise EOFError("empty read") + except EOFError as e: + raise EOFError("couldn't load message header, " + e.args[0]) from None + msgtype, channel, payload = Message.from_header(header) + return Message(msgtype, channel, io.read(payload)) + + def to_io(self, io: WriteIO) -> None: + io.write(self.pack()) + + def __repr__(self) -> str: + name = self._types[self.msgcode] + return f"" + + +def gateway_info() -> dict[str, SendPayload]: + """Payload for ``Message.GATEWAY_INFO``: sys/env facts about this side. + + Answered natively by the dispatch loop -- an info request never + touches the exec machinery, so it cannot claim an exec slot (with + main-thread profiles, an info call stealing the primary slot used to + push the real workload onto a worker thread). + """ + return { + "executable": sys.executable, + "version_info": tuple(sys.version_info[:5]), + "platform": sys.platform, + "cwd": os.getcwd(), + "pid": os.getpid(), + } + + +class FrameDecoder: + """Incremental decoder for the 9-byte-header Message framing. + + ``feed(data)`` accepts arbitrary byte chunks and yields every complete + Message; partial frames buffer internally until more bytes arrive. + Pure computation — no IO, no awaits, no knowledge of streams — so + receivers only ever stream bytes in (``receive_some`` loops) and the + decoder owns framing. + """ + + def __init__(self) -> None: + self._buffer = bytearray() + + def feed(self, data: bytes) -> Iterator[Message]: + self._buffer += data + return self._parse() + + def _parse(self) -> Iterator[Message]: + while len(self._buffer) >= 9: + msgtype, channelid, payload_len = Message.from_header( + bytes(self._buffer[:9]) + ) + if len(self._buffer) < 9 + payload_len: + return + payload = bytes(self._buffer[9 : 9 + payload_len]) + del self._buffer[: 9 + payload_len] + yield Message(msgtype, channelid, payload) + + def close(self) -> None: + """Signal EOF; raises EOFError if the stream ended mid-frame.""" + if self._buffer: + raise EOFError( + "connection closed mid-frame (%d buffered bytes)" % len(self._buffer) + ) diff --git a/src/execnet/_multi.py b/src/execnet/_multi.py new file mode 100644 index 00000000..3df096db --- /dev/null +++ b/src/execnet/_multi.py @@ -0,0 +1,422 @@ +""" +Managing Gateway Groups and interactions with multiple channels. + +(c) 2008-2014, Holger Krekel and others +""" + +from __future__ import annotations + +import atexit +import os +import queue +import types +import warnings +from collections.abc import Callable +from collections.abc import Iterable +from collections.abc import Iterator +from collections.abc import Sequence +from contextlib import suppress +from threading import Lock +from typing import TYPE_CHECKING +from typing import Any +from typing import Literal +from typing import overload + +from ._boundary import WaitBackend +from ._channel import NO_ENDMARKER_WANTED +from ._channel import Channel +from ._channel import Endmarker +from ._engine import ProtocolEngine +from ._engine import check_not_in_event_loop +from ._engine import default_engine +from ._execmodel import ExecModel +from ._execmodel import get_execmodel +from ._execmodel import resolve_profile +from ._trace import trace +from ._xspec import XSpec + +if TYPE_CHECKING: + from ._gateway import Gateway + from ._serialize import Payload + from ._serialize import SendPayload + + +class Group: + """Gateway Group.""" + + defaultspec = "popen" + + #: which primitive this group's blocking waits park on. Set by the + #: facade, not by a spec: it describes the *caller's* concurrency + #: library, which is exactly what picking a namespace already says. + #: ``execnet.gevent.Group`` overrides it. + _wait_backend: WaitBackend = "thread" + + def __init__( + self, + xspecs: Iterable[XSpec | str | None] = (), + profile: str | None = None, + *, + engine: ProtocolEngine | None = None, + execmodel: str | None = None, + ) -> None: + """Initialize a group and make gateways as specified. + + ``profile`` is the default worker profile for gateways created + without an explicit ``profile=`` in their spec. ``engine`` is the + :class:`~execnet.ProtocolEngine` to serve this group's protocol IO + on; it defaults to the process-wide one. ``execmodel`` is the + deprecated spelling of ``profile`` (pytest-xdist still passes it). + """ + if execmodel is not None: + if profile is not None: + raise TypeError("pass either profile= or execmodel=, not both") + warnings.warn( + "Group(execmodel=...) is deprecated; use Group(profile=...)." + " execnet has no local execution model any more -- the value" + " only selects the worker profile.", + DeprecationWarning, + stacklevel=2, + ) + profile = execmodel + self._gateways: list[Gateway] = [] + self._autoidcounter = 0 + self._autoidlock = Lock() + self._gateways_to_join: list[Gateway] = [] + #: pid this group belongs to; a fork does not carry its gateways over + self._pid = os.getpid() + self._engine = default_engine() if engine is None else engine + self._async_group: Any = None + self.set_profile("thread" if profile is None else profile) + for xspec in xspecs: + self.makegateway(xspec) + atexit.register(self._cleanup_atexit) + + @property + def engine(self) -> ProtocolEngine: + """The :class:`~execnet.ProtocolEngine` this group's IO runs on.""" + return self._engine + + def _ensure_trio_engine(self) -> Any: + return self._engine._ensure_started() + + def engine_call(self, trio_engine: Any, async_fn: Any, *args: Any) -> Any: + """Run ``async_fn`` on the engine, parking the way this facade parks.""" + from ._trio_engine import engine_call + + return engine_call(trio_engine, self._wait_backend, async_fn, *args) + + def _ensure_async_group(self) -> Any: + """The FacadeAsyncGroup owning the async side, on the engine.""" + if self._async_group is None: + from . import _trio_host + + engine = self._ensure_trio_engine() + + async def _start() -> Any: + async_group = _trio_host.FacadeAsyncGroup(self, engine) + return await engine.start_task(async_group.run) + + self._async_group = self.engine_call(engine, _start) + return self._async_group + + @property + def profile(self) -> str: + """Default worker profile for gateways created by this group.""" + return self._profile + + def set_profile(self, profile: str) -> None: + """Set the default worker profile for newly created gateways. + + NOTE: only settable before any gateway is created. + """ + if self._gateways: + raise ValueError( + "can not set the profile if gateways have been created already" + ) + self._profile = resolve_profile(profile) + + @property + def execmodel(self) -> ExecModel: + """Deprecated: there is no local execution model any more.""" + warnings.warn( + "Group.execmodel is deprecated: execnet has no local execution" + " model. Use Group.profile for the worker profile.", + DeprecationWarning, + stacklevel=2, + ) + return get_execmodel(self._profile) + + @property + def remote_execmodel(self) -> ExecModel: + """Deprecated alias for :attr:`profile`, as an ExecModel shim.""" + warnings.warn( + "Group.remote_execmodel is deprecated; use Group.profile.", + DeprecationWarning, + stacklevel=2, + ) + return get_execmodel(self._profile) + + def set_execmodel( + self, execmodel: str, remote_execmodel: str | None = None + ) -> None: + """Deprecated alias for :meth:`set_profile`. + + The *local* execution model it used to set no longer exists -- all + protocol IO runs on the Trio host -- so only the worker profile is + taken from these arguments (``remote_execmodel`` when given, else + ``execmodel``). + """ + warnings.warn( + "Group.set_execmodel is deprecated; use Group.set_profile(profile)." + " execnet has no local execution model, so only the remote value" + " has an effect.", + DeprecationWarning, + stacklevel=2, + ) + self.set_profile(execmodel if remote_execmodel is None else remote_execmodel) + + def __repr__(self) -> str: + idgateways = [gw.id for gw in self] + return "" % idgateways + + def __getitem__(self, key: int | str | Gateway) -> Gateway: + if isinstance(key, int): + return self._gateways[key] + for gw in self._gateways: + if gw == key or gw.id == key: + return gw + raise KeyError(key) + + def __contains__(self, key: str) -> bool: + try: + self[key] + return True + except KeyError: + return False + + def __len__(self) -> int: + return len(self._gateways) + + def __iter__(self) -> Iterator[Gateway]: + return iter(list(self._gateways)) + + def makegateway(self, spec: XSpec | str | None = None) -> Gateway: + """Create and configure a gateway to a Python interpreter. + + The ``spec`` string encodes the target gateway type + and configuration information. The general format is:: + + key1=value1//key2=value2//... + + If you leave out the ``=value`` part a True value is assumed. + Valid types: ``popen``, ``ssh=hostname``, ``socket=host:port``. + Valid configuration:: + + id= specifies the gateway id + python= specifies which python interpreter to execute + profile=name worker profile: where exec'd code runs relative + to the worker's protocol loop. 'thread' + (default; the first remote_exec claims the + worker main thread, further ones overflow to + pool threads), 'trio' (async sources as tasks, + single-threaded) or 'gevent' (a greenlet per + remote_exec). Spelled 'execmodel=' before + execnet 3.0; that spelling still works. + chdir= specifies to which directory to change + nice= specifies process priority of new process + env:NAME=value specifies a remote environment variable setting. + + If no spec is given, self.defaultspec is used. + """ + check_not_in_event_loop("Group.makegateway()") + if not spec: + spec = self.defaultspec + if not isinstance(spec, XSpec): + spec = XSpec(spec) + self.allocate_id(spec) + if spec.profile is None: + # filling in a missing value is idempotent; rewriting one the + # caller set is not -- see resolve_profile's docstring + spec.profile = self._profile + else: + resolve_profile(spec.profile) + from . import _trio_host + + if not (spec.socket or spec.via or spec.ssh or spec.vagrant_ssh or spec.popen): + raise ValueError(f"no gateway type found for {spec._spec!r}") + gw = _trio_host.makegateway_trio(self, spec) + gw.spec = spec + self._register(gw) + # chdir/nice/env travel in the worker config and are applied at + # worker startup -- no remote_exec, so no exec slot is claimed. + return gw + + def allocate_id(self, spec: XSpec) -> None: + """(re-entrant) allocate id for the given xspec object.""" + if spec.id is None: + with self._autoidlock: + id = "gw" + str(self._autoidcounter) + self._autoidcounter += 1 + if id in self: + raise ValueError(f"already have gateway with id {id!r}") + spec.id = id + + def _register(self, gateway: Gateway) -> None: + assert not hasattr(gateway, "_group") + assert gateway.id + assert gateway.id not in self + self._gateways.append(gateway) + gateway._group = self + + def _unregister(self, gateway: Gateway) -> None: + self._gateways.remove(gateway) + self._gateways_to_join.append(gateway) + + def _cleanup_atexit(self) -> None: + # The engine is shared and stops itself at exit; a group only owns + # its gateways and the async group task running on that engine. + if self._pid != os.getpid(): + # a forked child inherited this registration along with a group + # whose gateways are the parent's to terminate, not ours + return + trace(f"=== atexit cleanup {self!r} ===") + self.terminate(timeout=1.0) + if self._async_group is not None: + with suppress(Exception): + self._engine._ensure_started().call_sync(self._async_group.shutdown.set) + self._async_group = None + + def terminate(self, timeout: float | None = None) -> None: + """Trigger exit of member gateways and wait for termination + of member gateways and associated subprocesses. + + After waiting timeout seconds try to to kill local sub processes of + popen- and ssh-gateways. + + Timeout defaults to None meaning open-ended waiting and no kill + attempts. + """ + if self or self._gateways_to_join: + # blocks on the engine (termination grace, then joins), so it + # has the same event-loop problem as makegateway() and receive() + check_not_in_event_loop("Group.terminate()") + while self or self._gateways_to_join: + # A coordinator is held back from this pass: a tunneled gateway + # rides *its* stream, and exit() ends with close_write, so + # exiting it first would shut the outbound side the sub's own + # termination frames still have to travel through. The held-back + # coordinators come round on the next pass, by which point the + # async group has terminated them and their exit() is a no-op + # that only unregisters them for the join below. + vias: set[str] = set() + for gw in self: + if gw.spec.via: + vias.add(gw.spec.via) + for gw in self: + if gw.id not in vias: + gw.exit() + if self._async_group is not None: + # Tunneled (via) gateways terminate before their coordinators, + # each with a GATEWAY_TERMINATE + timeout grace, then kill; + # bounded at roughly twice the timeout (issues #43 / #221). + try: + self._engine_terminate(timeout) + except Exception as exc: + trace("group terminate error:", exc) + for gw in self._gateways_to_join: + gw.join() + self._gateways_to_join[:] = [] + + def _engine_terminate(self, timeout: float | None) -> None: + """Terminate the async group, parking the way this facade parks.""" + trio_engine = self._engine._ensure_started() + self.engine_call(trio_engine, self._async_group.terminate, timeout) + + def remote_exec( + self, + source: str | types.FunctionType | Callable[..., object] | types.ModuleType, + **kwargs, + ) -> MultiChannel: + """remote_exec source on all member gateways and return + a MultiChannel connecting to all sub processes.""" + channels = [] + for gw in self: + channels.append(gw.remote_exec(source, **kwargs)) + return MultiChannel(channels) + + +class MultiChannel: + def __init__(self, channels: Sequence[Channel]) -> None: + self._channels = channels + + def __len__(self) -> int: + return len(self._channels) + + def __iter__(self) -> Iterator[Channel]: + return iter(self._channels) + + def __getitem__(self, key: int) -> Channel: + return self._channels[key] + + def __contains__(self, chan: Channel) -> bool: + return chan in self._channels + + def send_each(self, item: SendPayload) -> None: + for ch in self._channels: + ch.send(item) + + @overload + def receive_each(self, withchannel: Literal[False] = ...) -> list[Payload[Channel]]: + pass + + @overload + def receive_each( + self, withchannel: Literal[True] + ) -> list[tuple[Channel, Payload[Channel]]]: + pass + + def receive_each( + self, withchannel: bool = False + ) -> list[tuple[Channel, Payload[Channel]]] | list[Payload[Channel]]: + assert not hasattr(self, "_queue") + if withchannel: + return [(ch, ch.receive()) for ch in self._channels] + return [ch.receive() for ch in self._channels] + + def make_receive_queue( + self, endmarker: Endmarker = NO_ENDMARKER_WANTED + ) -> queue.Queue[tuple[Channel, object]]: + # ``object`` rather than a payload: the queue also carries the + # endmarker, which is whatever the caller chose to be delivered last + try: + return self._queue # type: ignore[has-type] + except AttributeError: + # built up front, not on the first channel: a group with no + # members still has to hand back a queue rather than None + self._queue: queue.Queue[tuple[Channel, object]] = queue.Queue() + for ch in self._channels: + + def putreceived(obj: object, channel: Channel = ch) -> None: + self._queue.put((channel, obj)) + + ch.setcallback(putreceived, endmarker=endmarker) + return self._queue + + def waitclose(self) -> None: + first = None + for ch in self._channels: + try: + ch.waitclose() + except ch.RemoteError as exc: + if first is None: + first = exc + if first: + raise first + + +default_group = Group() +makegateway = default_group.makegateway +set_profile = default_group.set_profile +#: deprecated alias, see Group.set_execmodel +set_execmodel = default_group.set_execmodel diff --git a/src/execnet/_portal.py b/src/execnet/_portal.py new file mode 100644 index 00000000..682ffaa4 --- /dev/null +++ b/src/execnet/_portal.py @@ -0,0 +1,199 @@ +"""Cross-thread / cross-loop communication primitives — the boundary kit. + +Internal. A portal is a handle to a running loop that foreign threads use +to run functions on it or push work into it (the consumer -> loop +direction). Because each direction only needs the *receiving* loop's +handle, two loops in two threads can communicate by holding each other's +portal. + +There is one portal per engine backend -- :class:`LoopPortal` for trio, +:class:`AsyncioPortal` for asyncio -- with the same four operations and the +same failure vocabulary, so nothing above them has to know which loop it is +talking to. + +The loop -> consumer direction never blocks the loop and never knows who +is listening: the loop fires a :class:`Wakener`, a single thread-safe +``notify()`` supplied by the consumer. There are exactly two wakeners -- +OS threads and gevent greenlets -- because every other concurrency library +gets a facade of its own (:mod:`execnet.trio`, :mod:`execnet.aio`) instead. +On top of the wakener sit the two carriers: + +* :class:`Mailbox` -- an item stream (channel payloads, exec requests), +* :class:`OneShot` -- a single result (write acknowledgements, call + results a consumer wants to await instead of block on). +""" + +from __future__ import annotations + +import os +from collections.abc import Awaitable +from collections.abc import Callable +from typing import Any +from typing import TypeVar + +from ._boundary import Mailbox +from ._boundary import OneShot +from ._boundary import ThreadWakener +from ._boundary import Wakener +from ._errors import LoopFinishedError +from ._errors import forked_error + +__all__ = [ + "AsyncioPortal", + "LoopPortal", + "Mailbox", + "OneShot", + "ThreadWakener", + "Wakener", +] + +T = TypeVar("T") + + +class LoopPortal: + """Handle to a running trio loop, usable from foreign threads. + + Must be constructed on the loop's own thread (it captures the current + trio token). Imports trio itself, so a process with no trio can still + reach :class:`AsyncioPortal` from this module. + """ + + def __init__(self) -> None: + import trio + + self._trio = trio + self._token = trio.lowlevel.current_trio_token() + self._pid = os.getpid() + + def is_loop_thread(self) -> bool: + """Whether the calling thread is running this portal's loop.""" + try: + return bool(self._trio.lowlevel.current_trio_token() is self._token) + except RuntimeError: + return False + + def _check_process(self) -> None: + """Refuse a loop that lives in another process (see fork, below). + + The token of a forked parent's loop still *works* in the child -- + ``run_sync_soon`` happily queues a callback that nothing will ever + run, and ``from_thread.run`` waits for a reply forever. This is the + one choke point every route to the loop goes through, so the check + sits here rather than on each of them. + """ + if self._pid != os.getpid(): + raise forked_error("the execnet engine loop", self._pid) + + def run(self, async_fn: Callable[..., Awaitable[T]], *args: Any) -> T: + """Run ``await async_fn(*args)`` on the loop, blocking this thread.""" + self._check_process() + try: + return self._trio.from_thread.run(async_fn, *args, trio_token=self._token) + except self._trio.RunFinishedError as exc: + raise LoopFinishedError(str(exc)) from None + + def run_sync(self, sync_fn: Callable[..., T], *args: Any) -> T: + """Run ``sync_fn(*args)`` on the loop, blocking this thread.""" + self._check_process() + try: + return self._trio.from_thread.run_sync( + sync_fn, *args, trio_token=self._token + ) + except self._trio.RunFinishedError as exc: + raise LoopFinishedError(str(exc)) from None + + def post(self, sync_fn: Callable[..., object], *args: Any) -> None: + """Schedule ``sync_fn(*args)`` on the loop without waiting. + + Thread-safe and callable from the loop thread itself; all posts run + in strict FIFO order (``TrioToken.run_sync_soon``). Raises + :class:`~execnet._errors.LoopFinishedError` once the loop has shut + down, and :class:`~execnet._errors.ForkedResourceError` in a forked + child. + + ``sync_fn`` must not raise: trio turns an exception from an + entry-queue callback into ``TrioInternalError`` and tears the whole + loop down, taking every gateway in the process with it. + """ + self._check_process() + try: + self._token.run_sync_soon(sync_fn, *args) + except self._trio.RunFinishedError as exc: + raise LoopFinishedError(str(exc)) from None + + +class AsyncioPortal: + """The same handle for an asyncio loop. + + Constructed on the loop's own thread, like :class:`LoopPortal`, and + offering the same four operations with the same failure vocabulary. + + Where trio distinguishes "run a coroutine" from "run a sync function", + asyncio has only the former, so :meth:`run_sync` wraps. Both go through + ``run_coroutine_threadsafe``, which -- unlike ``call_soon_threadsafe`` + -- gives back a future to block on. + """ + + def __init__(self) -> None: + import asyncio + + self._asyncio = asyncio + self._loop = asyncio.get_running_loop() + self._pid = os.getpid() + + def is_loop_thread(self) -> bool: + """Whether the calling thread is running this portal's loop.""" + try: + return self._asyncio.get_running_loop() is self._loop + except RuntimeError: + return False + + def _check_process(self) -> None: + """Refuse a loop that lives in another process (see :class:`LoopPortal`).""" + if self._pid != os.getpid(): + raise forked_error("the execnet engine loop", self._pid) + + def _submit(self, coro: Any) -> Any: + try: + return self._asyncio.run_coroutine_threadsafe(coro, self._loop) + except RuntimeError as exc: # loop closed between the check and here + coro.close() + raise LoopFinishedError(str(exc)) from None + + def run(self, async_fn: Callable[..., Awaitable[T]], *args: Any) -> T: + """Run ``await async_fn(*args)`` on the loop, blocking this thread.""" + self._check_process() + if self.is_loop_thread(): + raise RuntimeError( + "this is a blocking function; call it from a thread that is" + " not running this loop" + ) + result: T = self._submit(async_fn(*args)).result() + return result + + def run_sync(self, sync_fn: Callable[..., T], *args: Any) -> T: + """Run ``sync_fn(*args)`` on the loop, blocking this thread.""" + + async def call() -> T: + return sync_fn(*args) + + return self.run(call) + + def post(self, sync_fn: Callable[..., object], *args: Any) -> None: + """Schedule ``sync_fn(*args)`` on the loop without waiting. + + Thread-safe and callable from the loop thread itself; all posts run + in FIFO order. Raises + :class:`~execnet._errors.LoopFinishedError` once the loop has shut + down, and :class:`~execnet._errors.ForkedResourceError` in a forked + child. + + ``sync_fn`` must not raise: an exception here reaches the loop's + exception handler, which by default only logs -- so a failure would + be silently swallowed rather than reported to whoever was waiting. + """ + self._check_process() + try: + self._loop.call_soon_threadsafe(sync_fn, *args) + except RuntimeError as exc: + raise LoopFinishedError(str(exc)) from None diff --git a/src/execnet/_provision.py b/src/execnet/_provision.py new file mode 100644 index 00000000..81ae23fb --- /dev/null +++ b/src/execnet/_provision.py @@ -0,0 +1,703 @@ +"""Coordinator-side worker provisioning via ``uv``. + +Non-same-interpreter Trio workers (foreign-python popen, later ssh/socket) are +launched inside an environment that ``uv`` provisions with a matching execnet + +trio. The worker itself is always ``python -m execnet worker`` and does the +usual ``b"1"`` handshake on whichever protocol transport it was given; only +the launch prefix differs. + +Delivery of execnet into that environment is version-aware: + +* released coordinator (``X.Y.Z``) -> ``uv run --with execnet==X.Y.Z`` +* dev coordinator (``X.Y.Z.devN+g...``) -> build a wheel from the editable + install's source tree, cache it keyed by version, and ``uv run --with `` + +``EXECNET_PROVISION_WHEEL`` overrides both: see :data:`PROVISION_WHEEL_ENV`. + +trio is pulled transitively as an execnet dependency. +""" + +from __future__ import annotations + +import json +import os +import re +import shlex +import shutil +import subprocess +import sys +import tempfile +from functools import cache +from pathlib import Path +from typing import TYPE_CHECKING +from typing import Any + +if TYPE_CHECKING: + from ._xspec import XSpec + +_RELEASED_RE = re.compile(r"^\d+\.\d+\.\d+$") + +#: ``transport=`` values: where a worker's protocol stream lives. +#: +#: ``socket`` keeps the protocol off the worker's stdio -- an inherited +#: socketpair for popen, an ``ssh -R``-forwarded unix socket for ssh -- so +#: the worker's stdin/stdout/stderr belong to the code it runs. ``stdio`` +#: is the classic shape, and the only one available where the machinery +#: ``socket`` needs is missing. +TRANSPORTS = ("socket", "stdio") + + +@cache +def socket_handoff_available() -> bool: + """Whether we can hand a socket to a worker process we spawn ourselves. + + Two different mechanisms, one question. POSIX passes the fd through + ``pass_fds``. Windows refuses that outright, but ``socket.share()`` + (``WSADuplicateSocket``) duplicates the socket into a named pid, and the + resulting blob rides in the worker config -- see the ``share`` transport + in ``_trio_worker``. + + The Windows answer is settled by *doing* it once rather than by looking + for the method: an implementation that has the name but not a working + call would otherwise pass the check and fail later, at the point where + the only thing left to tell the coordinator is a closed socket. + + Both halves get exercised, and both matter -- the coordinator calls + ``share()``, the worker calls ``fromshare()``, and either can be the + one that is missing. Sharing to our *own* pid is what makes that + possible in one process: the blob we produce is one we are entitled to + rebuild, so a full round trip needs no second process to test. + """ + import socket as _socket + + if not socket_share_required(): + return True + try: + left, right = _socket.socketpair() + try: + blob = left.share(os.getpid()) # type: ignore[attr-defined] # Windows + if not blob: + return False + # the worker's half: a blob we cannot rebuild is no use to it + _socket.fromshare(blob).close() # type: ignore[attr-defined] # Windows + finally: + left.close() + right.close() + except Exception: + return False + return True + + +def socket_share_required() -> bool: + """Whether handing a socket to a child needs ``share()`` and not ``pass_fds``. + + A function rather than a ``sys.platform`` test at each call site: those + read as dead code to a type checker running with the other platform's + assumptions, and this is the one question being asked anyway. + """ + return sys.platform.startswith("win") + + +def ssh_dialback_available() -> bool: + """Whether an ssh worker can dial back to us over a forwarded unix socket. + + Needs ``AF_UNIX`` here -- CPython has never exposed it on Windows -- and + ``StreamLocal`` forwarding in the ssh client and server, which + Win32-OpenSSH does not implement. Neither is a coordinator-side choice, + so ssh gateways there stay on the stdio transport. + """ + import socket as _socket + + return hasattr(_socket, "AF_UNIX") and not sys.platform.startswith("win") + + +def resolve_transport(spec: XSpec, *, available: bool = True) -> str: + """The transport for ``spec``: explicit if given, else the best available. + + ``available`` is the caller's capability for *its* kind of gateway -- + :func:`socket_handoff_available` for a worker we spawn, + :func:`ssh_dialback_available` for one that has to reach back to us. + Asking for a transport that cannot work is an error at makegateway time, + rather than a hang once nobody connects. + """ + requested: str | None = spec.transport + if requested is None: + return "socket" if available else "stdio" + if requested not in TRANSPORTS: + raise ValueError(f"unknown transport {requested!r} (known: {list(TRANSPORTS)})") + if requested == "socket" and not available: + raise ValueError( + "transport=socket is not available for this gateway on " + f"{sys.platform}; use transport=stdio" + ) + return requested + + +def uv_available() -> bool: + """Whether the ``uv`` launcher is on PATH.""" + return shutil.which("uv") is not None + + +def shell_split_path(path: str) -> list[str]: + """Split a ``python=`` value into argv tokens with shell lexing. + + Takes care to handle Windows' ``\\`` correctly. + """ + if sys.platform.startswith("win"): + # replace \\ by / otherwise shlex will strip them out + path = path.replace("\\", "/") + return shlex.split(path) + + +@cache +def target_info(python: str) -> dict[str, Any] | None: + """``execnet info`` from interpreter ``python``, or None if it cannot run. + + One probe answers everything provisioning wants to know before it + connects: whether execnet is importable at all, which version it is, + whether trio is there, and which transports it can serve. An execnet + too old to have the CLI fails the probe and gets uv-provisioned, which + is the right outcome. + """ + argv = [*shell_split_path(python), "-m", "execnet", "info"] + try: + completed = subprocess.run(argv, capture_output=True, timeout=30, check=False) + except (OSError, subprocess.SubprocessError): + return None + if completed.returncode != 0: + return None + try: + info: dict[str, Any] = json.loads(completed.stdout) + except ValueError: + return None + return info + + +def target_has_execnet(python: str) -> bool: + """Whether ``python`` can host a worker directly (execnet, and an engine). + + When true the worker is launched on that interpreter as-is (preserving + ``sys.executable``); otherwise it must be uv-provisioned. + + Asked through the neutral ``worker`` key rather than by looking for + trio: an install with no trio still serves a worker on Python 3.11+, + where asyncio runs the protocol. ``trio`` is the fallback for a remote + old enough to predate the neutral key -- there it *was* the answer. + """ + info = target_info(python) + if info is None: + return False + if "worker" in info: + return bool(info["worker"]) + return info.get("trio") is not None + + +def _version_slug(version: str) -> str: + """Filesystem-safe slug for a version string (may contain ``+``/``.``).""" + return re.sub(r"[^0-9A-Za-z]+", "_", version) + + +def _wheel_cache_dir() -> Path: + d = Path(tempfile.gettempdir()) / "execnet-bootstrap-wheels" + d.mkdir(parents=True, exist_ok=True) + return d + + +def _editable_source_root() -> Path | None: + """Source tree of an editable execnet install, via PEP 610 ``direct_url.json``. + + Returns ``None`` when execnet is not installed editable (nothing to build). + """ + from importlib.metadata import PackageNotFoundError + from importlib.metadata import distribution + from urllib.parse import urlparse + from urllib.request import url2pathname + + try: + dist = distribution("execnet") + except PackageNotFoundError: + return None + raw = dist.read_text("direct_url.json") + if not raw: + return None + info = json.loads(raw) + if not info.get("dir_info", {}).get("editable"): + return None + url = info.get("url", "") + if not url.startswith("file:"): + return None + return Path(url2pathname(urlparse(url).path)) + + +_BUILT_RE = re.compile(r"^Successfully built (?P.+\.whl)\s*$", re.MULTILINE) + + +def _parse_built_wheel(uv_build_stderr: str) -> Path | None: + """Extract the exact wheel path from ``uv build``'s ``Successfully built`` line. + + Building the filename ourselves is unsafe: the build-time version (dirty tree + -> ``.dYYYYMMDD``/differing dev count) can differ from the import-time + ``__version__``, so we trust the path uv reports. + """ + matches = _BUILT_RE.findall(uv_build_stderr) + if len(matches) != 1: + return None + return Path(matches[0].strip()) + + +def _build_wheel(version: str) -> Path: + """Build (and cache) a wheel of the editable execnet source for ``version``.""" + version_dir = _wheel_cache_dir() / _version_slug(version) + if version_dir.exists(): + cached = sorted(version_dir.glob("*.whl")) + if len(cached) == 1: + return cached[0] + + root = _editable_source_root() + if root is None: + raise RuntimeError( + f"cannot provision dev execnet {version!r}: no editable source tree " + "found (install a released execnet or an editable checkout)" + ) + version_dir.mkdir(parents=True, exist_ok=True) + proc = subprocess.run( + ["uv", "build", "--wheel", "-o", str(version_dir), str(root)], + check=True, + capture_output=True, + text=True, + ) + wheel = _parse_built_wheel(proc.stderr) + if wheel is None or not wheel.exists(): + raise RuntimeError( + f"could not determine wheel path from uv build for {version!r}:\n" + f"{proc.stderr}" + ) + return wheel + + +#: names a prebuilt wheel to provision remotes from, bypassing both the +#: index lookup and the build-from-source path. +#: +#: This exists for testing an *artifact*: CI installs execnet from a built +#: distribution, which leaves a dev version with no editable source tree to +#: build from -- so provisioning would be unavailable exactly where it most +#: wants exercising. Pointing this at the wheel from the same build makes +#: the workers run the code under test rather than something rebuilt from a +#: checkout that may have moved on. +PROVISION_WHEEL_ENV = "EXECNET_PROVISION_WHEEL" + + +def _explicit_wheel() -> Path | None: + """The wheel named by :data:`PROVISION_WHEEL_ENV`, if it is set. + + Set-but-wrong is a configuration error, not a reason to quietly fall + back to building: the fallback would provision something *other* than + what the caller asked to test. + """ + raw = os.environ.get(PROVISION_WHEEL_ENV) + if not raw: + return None + wheel = Path(raw) + if not wheel.is_file(): + raise RuntimeError(f"{PROVISION_WHEEL_ENV}={raw!r} is not an existing file") + if wheel.suffix != ".whl": + raise RuntimeError(f"{PROVISION_WHEEL_ENV}={raw!r} is not a wheel") + return wheel.resolve() + + +def provisioning_wheel() -> Path | None: + """The wheel to provision remotes from, or None to resolve from an index. + + The one place the "released vs dev vs explicitly given" decision is + made; every launcher (uv popen, ssh, via sub-spawn) routes through it so + they cannot disagree about what a remote ends up running. + """ + explicit = _explicit_wheel() + if explicit is not None: + return explicit + + import execnet + + version = execnet.__version__ + if _RELEASED_RE.match(version): + return None + return _build_wheel(version) + + +def provisioning_available() -> bool: + """Whether this coordinator can produce material to provision a remote. + + A released version resolves from an index. A dev version has to build + a wheel, which needs the editable checkout it came from -- so a dev + version installed *from a wheel* (what ``tox --installpkg`` produces, + and what a CI artifact test runs against) can do neither, unless + :data:`PROVISION_WHEEL_ENV` hands it one. Callers that need a remote + worker should check this rather than let :func:`coordinator_requirement` + raise. + """ + import execnet + + if _explicit_wheel() is not None: + return True + if _RELEASED_RE.match(execnet.__version__): + return True + return _editable_source_root() is not None + + +def coordinator_requirement() -> str: + """A ``uv --with`` requirement that installs this coordinator's execnet.""" + import execnet + + wheel = provisioning_wheel() + if wheel is not None: + return str(wheel) + return f"execnet=={execnet.__version__}" + + +def worker_config(spec: XSpec) -> dict[str, Any]: + """The worker config for ``spec`` (the whole 'spec thing'), as a dict. + + Every launcher builds this and every worker is configured by it, + delivered the same way on every transport: one ``GATEWAY_CONFIG`` frame + ahead of the protocol (:mod:`execnet._handshake`). It never goes in + argv -- it carries ``env:`` values, and ``/proc`` is world-readable on + the local machine just as ``ps`` is on a remote one. + """ + import execnet + + from ._execmodel import effective_profile + + # the spec keeps whatever the caller spelled; the worker gets what it + # actually has a strategy for. A spec that never went through a Group + # has no profile at all -- the default applies, as it would there. + profile = effective_profile(spec.profile or "thread") + config: dict[str, Any] = { + "id": f"{spec.id}-worker", + "profile": profile, + # pre-3.0 spelling, same value: a worker from an older execnet + # reads this one. Drop with the XSpec alias. + "execmodel": profile, + # derived, not configurable: the gevent profile parks its greenlets + # on gevent wakeners, every other profile is thread-shaped. + "wait": "gevent" if profile == "gevent" else "thread", + "coordinator_version": execnet.__version__, + } + # Startup setup applied by the worker before serving (never through + # remote_exec: an exec slot must not be claimed by bookkeeping). + if spec.chdir: + config["chdir"] = spec.chdir + if spec.nice: + config["nice"] = int(spec.nice) + if spec.env: + config["env"] = spec.env + # A worker inherits the coordinator's stdio by default now that the + # protocol has a transport of its own, which also means remote code can + # *consume* the coordinator's stdin. These keys are how a caller says + # otherwise, e.g. ``popen//stdin=devnull``. + for name in ("stdin", "stdout", "stderr"): + value = getattr(spec, name, None) + if value: + config[name] = value + return config + + +def _worker_tokens(*protocol: str, local_config_on_stdin: bool = False) -> list[str]: + """``python -u -m execnet worker`` tokens for a launch. + + The literal ``python`` token is resolved by uv (inside the provisioned + environment) or the remote shell. No config here: it arrives as the + first frame on the protocol stream. ``local_config_on_stdin`` is only + for the Windows ``share`` transport, whose socket blob has to exist + before the stream it describes does. + """ + tokens = ["python", "-u", "-m", "execnet", "worker", *protocol] + if local_config_on_stdin: + return [*tokens, "--config-fd", "0"] + return tokens + + +def _uv_tokens(python: str | None) -> list[str]: + # --no-project keeps the surrounding execnet checkout from being synced. + prefix = ["uv", "run", "--no-project"] + if python: + prefix += ["--python", python] + return prefix + + +def _extra_with_tokens(profile: str | None) -> list[str]: + """Additional ``--with`` requirements the worker env needs. + + The one thing a launcher must know about the config before the worker + can read it for itself: ``profile=gevent`` needs gevent importable in + the environment being provisioned. Every uv launcher (popen, ssh, via + sub-spawn) goes through here so they cannot provision differently. + """ + if profile == "gevent": + return ["--with", "gevent"] + return [] + + +def uv_worker_argv( + spec: XSpec, *protocol: str, local_config_on_stdin: bool = False +) -> list[str]: + """``uv run`` argv to launch the Trio worker locally (wheel path is local).""" + return [ + *_uv_tokens(spec.python), + "--with", + coordinator_requirement(), + *_extra_with_tokens(worker_profile(spec)), + *_worker_tokens(*protocol, local_config_on_stdin=local_config_on_stdin), + ] + + +def worker_profile(spec: XSpec) -> str: + """The profile ``spec``'s worker will run, as provisioning needs it.""" + from ._execmodel import effective_profile + + return effective_profile(spec.profile or "thread") + + +#: where a shipped wheel lands on the remote, keyed by name (which carries +#: the version) so repeated gateways to one host reuse it. A *shell +#: fragment*, not a path: ``$HOME`` is expanded remotely, and quoting it as +#: a literal would create a directory actually called ``~``. +REMOTE_WHEEL_DIR = '"$HOME"/.cache/execnet/wheels' + + +def remote_wheel_path(wheel: Path) -> str: + """Shell fragment for the remote path a shipped wheel is delivered to.""" + return f"{REMOTE_WHEEL_DIR}/{shlex.quote(wheel.name)}" + + +def wheel_delivery_command(wheel: Path) -> str: + """Remote sh command that receives ``wheel`` on stdin, unless already there. + + Out of band: run over its *own* ssh connection before the worker + launch, so the protocol stream never has to carry a payload and the + launch command needs neither ``head -c `` byte accounting nor + ``exec`` to keep an fd alive. + + Both branches consume stdin -- the coordinator streams the wheel + unconditionally, and a remote that skipped the write without draining + would hand it an EPIPE. + """ + path = remote_wheel_path(wheel) + return ( + f"mkdir -p {REMOTE_WHEEL_DIR} || exit 1; " + f"if [ -s {path} ]; then cat > /dev/null; else cat > {path}.tmp" + f" && mv {path}.tmp {path}; fi" + ) + + +def _remote_shell_command( + python: str | None, + profile: str | None, + *protocol: str, + requirement: str | None = None, + wheel: Path | None = None, +) -> str: + """Remote sh command launching the worker via uv. + + ``requirement`` installs from an index; ``wheel`` uses one already + delivered by :func:`wheel_delivery_command`. + """ + worker = _worker_tokens(*protocol) + uv = [*_uv_tokens(python), *_extra_with_tokens(profile)] + if wheel is None: + assert requirement is not None + return shlex.join([*uv, "--with", requirement, *worker]) + # the wheel path is remote-side and may contain ~, so it is not quoted + # by shlex.join -- splice it in after quoting the rest + return " ".join( + [shlex.join([*uv, "--with"]), remote_wheel_path(wheel), shlex.join(worker)] + ) + + +def ssh_remote_command(spec: XSpec, *protocol: str) -> str: + """Remote shell command launching the worker over ssh. + + Released coordinator -> ``uv run --with execnet== …``. Dev + coordinator -> ``uv run --with …``; delivering the + wheel is a separate step (:func:`wheel_delivery_command`). + """ + import execnet + + kwargs: dict[str, Any] = {} + wheel = provisioning_wheel() + if wheel is None: + kwargs["requirement"] = f"execnet=={execnet.__version__}" + else: + kwargs["wheel"] = wheel + return _remote_shell_command(spec.python, worker_profile(spec), *protocol, **kwargs) + + +def ssh_wheel(spec: XSpec) -> Path | None: + """The wheel this coordinator must deliver before launching, if any.""" + return provisioning_wheel() + + +def ssh_argv( + ssh: str, + ssh_config: str | None, + remote_command: str, + options: list[str] | None = None, +) -> list[str]: + """``ssh`` client argv running ``remote_command`` on host ``ssh``.""" + args = ["ssh", "-C"] + if ssh_config: + args += ["-F", ssh_config] + if options: + args += options + args += ssh.split() + args.append(remote_command) + return args + + +def vagrant_ssh_argv( + machine: str, + ssh_config: str | None, + remote_command: str, + options: list[str] | None = None, +) -> list[str]: + """``vagrant ssh`` argv running ``remote_command`` on the named VM. + + Everything after ``--`` is passed through to the underlying ssh client, + mirroring ``ssh_argv``. + """ + args = ["vagrant", "ssh", machine, "--", "-C"] + if ssh_config: + args += ["-F", ssh_config] + if options: + args += options + args.append(remote_command) + return args + + +def spawn_request(spec: XSpec) -> dict[str, Any]: + """Payload for ``GATEWAY_START_SUB``: ask a via coordinator to spawn a sub-worker. + + Carries the sub-spec essentials plus provisioning material when the sub + may need it (ssh or foreign python): a released coordinator sends a pip + requirement; a dev build ships its wheel bytes for that coordinator to + materialize into its local wheel cache. + + The sub's *config* is deliberately not in here. It reaches the sub as + a frame through the tunnel, from the coordinator that wants the + gateway, so an intermediary relaying the connection never sees the + ``env:`` values travelling through it. All this carries is what + provisioning cannot defer: which interpreter, where, and the profile + (a gevent worker needs gevent in the environment being built). + + TODO: the wheel is shipped eagerly because only that coordinator can tell + whether the target interpreter already has execnet; a wheel-on-demand + round-trip would avoid the transfer in the common provisioned case. + """ + import execnet + + request: dict[str, Any] = { + "profile": worker_profile(spec), + "python": spec.python or None, + "ssh": spec.ssh or None, + "vagrant_ssh": spec.vagrant_ssh or None, + "ssh_config": spec.ssh_config or None, + } + if spec.ssh or spec.vagrant_ssh or spec.python: + wheel = provisioning_wheel() + if wheel is None: + request["requirement"] = f"execnet=={execnet.__version__}" + else: + request["wheel"] = (wheel.name, wheel.read_bytes()) + return request + + +def materialize_wheel(name: str, data: bytes) -> Path: + """Write shipped wheel bytes into the local wheel cache (idempotent).""" + target = _wheel_cache_dir() / name + if not target.exists(): + tmp = target.with_name(f"{target.name}.{os.getpid()}.tmp") + tmp.write_bytes(data) + tmp.replace(target) + return target + + +def _requested_requirement(request: dict[str, Any]) -> tuple[str | None, Path | None]: + """(uv requirement, local wheel path) from a spawn request's material.""" + requirement = request.get("requirement") + if isinstance(requirement, str): + return requirement, None + shipped = request.get("wheel") + if shipped is not None: + name, data = shipped + path = materialize_wheel(name, data) + return str(path), path + return None, None + + +#: an out-of-band step to run before a sub-worker launch: ``(argv, stdin)`` +DeliveryStep = tuple[list[str], bytes] + + +def sub_spawn_argv( + request: dict[str, Any], +) -> tuple[list[str], DeliveryStep | None]: + """(argv, wheel delivery) spawning a requested sub-worker on this host. + + Handles a ``GATEWAY_START_SUB`` request on a via coordinator: plain popen runs + this interpreter's worker module, a foreign ``python`` runs directly when + it already has execnet and is uv-provisioned otherwise, and ``ssh`` wraps + the remote uv command. A shipped wheel is delivered by the returned + step -- its own ssh connection, run before the launch -- rather than + framed into the launch command's stdin. + + The sub's protocol is relayed over its stdio by that coordinator, so it always + gets the stdio transport -- and its config comes down that tunnel from + the coordinator that asked for it, so nothing here has to carry one. + """ + profile = request.get("profile") + python = request.get("python") + ssh = request.get("ssh") + vagrant = request.get("vagrant_ssh") + if ssh or vagrant: + requirement, wheel = _requested_requirement(request) + if requirement is None: + raise RuntimeError("remote spawn request without provisioning material") + ssh_config = request.get("ssh_config") + + def wrap(command: str, options: list[str] | None = None) -> list[str]: + if ssh: + assert isinstance(ssh, str) + return ssh_argv(ssh, ssh_config, command, options) + assert isinstance(vagrant, str) + return vagrant_ssh_argv(vagrant, ssh_config, command, options) + + delivery: DeliveryStep | None = None + if wheel is not None: + delivery = (wrap(wheel_delivery_command(wheel)), wheel.read_bytes()) + command = _remote_shell_command(python, profile, wheel=wheel) + else: + command = _remote_shell_command(python, profile, requirement=requirement) + return wrap(command), delivery + if python: + assert isinstance(python, str) + if target_has_execnet(python): + return [ + *shell_split_path(python), + "-u", + "-m", + "execnet", + "worker", + ], None + requirement, _ = _requested_requirement(request) + if requirement is None or not uv_available(): + raise RuntimeError( + f"cannot provision sub-worker for python={python!r}: " + "uv and provisioning material required" + ) + return [ + *_uv_tokens(python), + "--with", + requirement, + *_extra_with_tokens(profile), + *_worker_tokens(), + ], None + return [sys.executable, "-u", "-m", "execnet", "worker"], None diff --git a/src/execnet/_rsync.py b/src/execnet/_rsync.py new file mode 100644 index 00000000..4adffffb --- /dev/null +++ b/src/execnet/_rsync.py @@ -0,0 +1,147 @@ +"""The deprecated 1:N rsync API, on top of the transfer layer. + +``RSync`` predates :mod:`execnet._deploy` and is kept because released +pytest-xdist subclasses it -- overriding :meth:`RSync.filter` and the +private :meth:`RSync._report_send_file`, and reading ``_sourcedir`` and +``_verbose``. Everything it does is now expressed with the same transfer +:func:`execnet.transfer` uses, so there is one implementation rather than +two, and what is left here is the adaptation: a few dozen lines that can +be deleted whole once its callers have moved on. + +One behaviour did not survive the move, and it is the one nothing uses: +the optional ``callback`` is handed the *gateway* rather than a channel as +its third argument, and its ``"ack"`` fires when a file is sent rather +than when the far side confirms it -- the new protocol has no per-file +acknowledgement to hang the old timing on. The ``filter`` hook, +``_report_send_file`` and ``verbose`` reporting are unchanged. + +(c) 2006-2009, Armin Rigo, Holger Krekel, Maciej Fijalkowski +""" + +from __future__ import annotations + +import os +from collections.abc import Callable +from collections.abc import Sequence +from typing import TYPE_CHECKING +from typing import Any + +from execnet._gateway import Gateway +from execnet._gateway_base import BaseGateway + +if TYPE_CHECKING: + from execnet._deploy._manifest import Manifest + from execnet._services import ServiceTarget + +#: one added target: where it goes, what to call when it is done, and the +#: per-target options (only ``delete``) +_Target = tuple[Gateway, str, "Callable[[], None] | None", dict[str, Any]] + + +class RSync: + """Send a directory structure (recursively) to one or more remotes. + + .. deprecated:: 3.0 + Use :func:`execnet.transfer`, or :class:`execnet.Deployment` for a + whole project. This class remains because pytest-xdist subclasses + it, and is a thin adapter over the transfer those use. + + There is limited support for symlinks: one pointing inside the source + tree is recreated pointing inside the destination, and any other is + copied as it stands, whether or not its target exists over there. + """ + + def __init__(self, sourcedir, callback=None, verbose: bool = True) -> None: + # normalise a trailing separator away now rather than during send(): + # subclasses read _sourcedir before then (xdist takes its basename + # to decide what the remote directory is called) + self._sourcedir = os.path.dirname(os.path.join(str(sourcedir), "x")) + self._verbose = verbose + assert callback is None or callable(callback) + self._callback = callback + self._targets: list[_Target] = [] + + def filter(self, path: str) -> bool: + """Whether ``path`` belongs in the transfer; override to exclude.""" + return True + + def _report_send_file(self, gateway: BaseGateway, modified_rel_path: str) -> None: + """Called for each file actually sent; override to report otherwise.""" + if self._verbose: + print(f"{gateway} <= {modified_rel_path}") + + def add_target( + self, + gateway: Gateway, + destdir: str | os.PathLike[str], + finishedcallback: Callable[[], None] | None = None, + **options: Any, + ) -> None: + """Add a remote target: a gateway and a destination directory.""" + for name in options: + assert name in ("delete",) + self._targets.append((gateway, str(destdir), finishedcallback, options)) + + def send(self, raises: bool = True) -> None: + """Send the source directory to every added target. + + ``raises`` says what happens when there are no targets left -- + which is also what a second ``send()`` finds, since sending + consumes them. + """ + if not self._targets: + if raises: + raise OSError( + "no targets available, maybe you are trying call send() twice?" + ) + return + from execnet._deploy._facade import run_blocking + + targets, self._targets = self._targets, [] + run_blocking([target[0] for target in targets], self._send, targets) + + # -- the async half, run on the gateways' engine -- + + async def _send( + self, targets: Sequence[_Target], service_targets: Sequence[ServiceTarget] + ) -> None: + from execnet._async import current_async + from execnet._deploy._transfer import snapshot + + # walked once, whatever the number of targets -- as it always was + manifest = await snapshot(self._sourcedir, self.filter) + if len(targets) == 1: + await self._send_one(manifest, targets[0], service_targets[0]) + return + async with current_async().task_scope() as scope: + for target, service_target in zip(targets, service_targets, strict=True): + scope.start_soon(self._send_one, manifest, target, service_target) + + async def _send_one( + self, manifest: Manifest, target: _Target, service_target: ServiceTarget + ) -> None: + from execnet._deploy._transfer import send_manifest + + gateway, destdir, finishedcallback, options = target + sent: list[tuple[str, int]] = [] + + def progress(relpath: str, size: int) -> None: + # in the thread that read the file, which is off the loop -- an + # override that prints or takes a lock must not run on it + sent.append((relpath, size)) + self._report_send_file(gateway, relpath) + if self._callback is not None: + self._callback("ack", size, gateway) + + await send_manifest( + service_target, + manifest, + self._sourcedir, + destdir, + delete=bool(options.get("delete")), + progress=progress, + ) + if self._callback is not None: + self._callback("list", sum(size for _path, size in sent), gateway) + if finishedcallback is not None: + finishedcallback() diff --git a/src/execnet/_rsync_remote.py b/src/execnet/_rsync_remote.py new file mode 100644 index 00000000..f3687931 --- /dev/null +++ b/src/execnet/_rsync_remote.py @@ -0,0 +1,147 @@ +"""The pre-3.0 rsync receiver, kept only for its deprecated module name. + +execnet no longer drives this: :class:`execnet.RSync` and +:func:`execnet.transfer` both speak the ``transfer`` service +(:mod:`execnet._deploy`), which needs no source shipped to a worker. The +body below still works if it is ``remote_exec``-ed by hand, which is what +``execnet.rsync_remote`` used to be for, and goes when that shim does. + +(c) 2006-2013, Armin Rigo, Holger Krekel, Maciej Fijalkowski +""" + +from __future__ import annotations + +from contextlib import suppress +from typing import TYPE_CHECKING +from typing import Literal +from typing import cast + +if TYPE_CHECKING: + from execnet._channel import Channel + + +def serve_rsync( + channel: Channel, + destdir: str | None = None, + options: dict[str, object] | None = None, +) -> None: + """Receive one rsync into ``destdir``. + + The channel is the only thing this needs, so the same body serves both + ways it is reached: as a worker-side ``GATEWAY_RSYNC`` handler, which + passes the destination in (and is how execnet drives it), and as an + exec'd source that reads it off the channel first, which is the shape + the pre-3.0 protocol used. + """ + import os + import shutil + import stat + from hashlib import md5 + + if destdir is None: + destdir, options = cast("tuple[str, dict[str, object]]", channel.receive()) + assert options is not None + modifiedfiles = [] + + def remove(path: str) -> None: + assert path.startswith(destdir) + try: + os.unlink(path) + except OSError: + # assume it's a dir + shutil.rmtree(path, True) + + def receive_directory_structure(path: str, relcomponents: list[str]) -> None: + try: + st = os.lstat(path) + except OSError: + st = None + msg = channel.receive() + if isinstance(msg, list): + if st and not stat.S_ISDIR(st.st_mode): + os.unlink(path) + st = None + if not st: + os.makedirs(path) + mode = msg.pop(0) + if mode: + # Ensure directories are writable, otherwise a + # permission denied error (EACCES) would be raised + # when attempting to receive read-only directory + # structures. + os.chmod(path, mode | 0o700) + entrynames = {} + for entryname in msg: + destpath = os.path.join(path, entryname) + receive_directory_structure(destpath, [*relcomponents, entryname]) + entrynames[entryname] = True + if options.get("delete"): + for othername in os.listdir(path): + if othername not in entrynames: + otherpath = os.path.join(path, othername) + remove(otherpath) + elif msg is not None: + assert isinstance(msg, tuple) + checksum = None + if st: + if stat.S_ISREG(st.st_mode): + msg_mode, msg_mtime, msg_size = msg + if msg_size != st.st_size: + pass + elif msg_mtime != st.st_mtime: + with open(path, "rb") as fp: + checksum = md5(fp.read()).digest() + elif msg_mode and msg_mode != st.st_mode: + os.chmod(path, msg_mode | 0o700) + return + else: + return # already fine + else: + remove(path) + channel.send(("send", (relcomponents, checksum))) + modifiedfiles.append((path, msg)) + + receive_directory_structure(destdir, []) + + STRICT_CHECK = False # seems most useful this way for py.test + channel.send(("list_done", None)) + + for path, (mode, time, size) in modifiedfiles: + data = cast(bytes, channel.receive()) + channel.send(("ack", path[len(destdir) + 1 :])) + if data is not None: + if STRICT_CHECK and len(data) != size: + raise OSError(f"file modified during rsync: {path!r}") + with open(path, "wb") as fp: + fp.write(data) + try: + if mode: + os.chmod(path, mode) + os.utime(path, (time, time)) + except OSError: + pass + del data + channel.send(("links", None)) + + msg = channel.receive() + while msg != 42: + # we get symlink + _type, relpath, linkpoint = cast( + "tuple[Literal['linkbase', 'link'], str, str]", msg + ) + path = os.path.join(destdir, relpath) + with suppress(OSError): + remove(path) + + if _type == "linkbase": + src = os.path.join(destdir, linkpoint) + else: + assert _type == "link", _type + src = linkpoint + os.symlink(src, path) + msg = channel.receive() + channel.send(("done", None)) + + +if __name__ == "__channelexec__": + serve_rsync(channel) # type: ignore[name-defined] # noqa:F821 diff --git a/src/execnet/_serialize.py b/src/execnet/_serialize.py new file mode 100644 index 00000000..0b98a311 --- /dev/null +++ b/src/execnet/_serialize.py @@ -0,0 +1,568 @@ +"""The wire serializer for execnet's simple builtin data format. + +Internal: no public namespace re-exports ``dumps``/``loads``. Callers that +need to know whether a value can cross a channel use +:func:`execnet.can_send`, which is exported from every public namespace. + +The channel layer is *not* imported here -- ``Unserializer`` resolves a +channel or gateway argument by duck-typing -- so ``_channel`` may depend on +this module and not the other way round. +""" + +from __future__ import annotations + +import struct +from collections.abc import Callable +from io import BytesIO +from typing import TYPE_CHECKING +from typing import Protocol +from typing import TypeAlias +from typing import TypeVar +from typing import cast + +from ._errors import DumpError +from ._errors import LoadError + +if TYPE_CHECKING: + from collections.abc import Sequence + from collections.abc import Set as AbstractSet + + from typing_extensions import TypeIs + + # PEP 696 defaults; type-only, so no runtime dependency is added + from typing_extensions import TypeVar as DefaultedTypeVar + + from ._channel import Channel + from ._message import ReadIO + + +KeyT_co = TypeVar("KeyT_co", covariant=True) +ValueT_co = TypeVar("ValueT_co", covariant=True) +#: which surface's channel a factory builds +ChannelT_co = TypeVar("ChannelT_co", covariant=True) + + +class ChannelRef(Protocol): + """A channel of any surface; sending one transfers a reference by id. + + Structural on ``id`` alone because that is the whole of what + ``save_Channel`` writes, and because there is no one channel class to + name: the sync ``Channel``, the async core's ``AsyncChannel`` and the + two facade wrappers around it share no base. ``_Serializer`` reaches + them the same way -- it dispatches on the class *name* -- so matching + on shape here says exactly what the wire format already does. + """ + + @property + def id(self) -> int: ... + + +if TYPE_CHECKING: + #: which surface's channel a payload carries; see :data:`Payload`. + #: Defaulted (PEP 696) so a bare ``Payload`` is the permissive form + #: rather than an implicit ``Any`` -- but say :data:`SendPayload` where + #: that is what you mean. Type-only: it is never a ``Protocol`` base, + #: so unlike the others above it need not exist at runtime. + ChannelT = DefaultedTypeVar("ChannelT", default=ChannelRef) + + +class PayloadMapping(Protocol[KeyT_co, ValueT_co]): + """The dict half of the wire format: something whose items can be walked. + + Not ``Mapping``, whose key parameter is invariant -- ``dict[str, int]`` + does not satisfy ``Mapping[Payload, Payload]``, so spelling it that way + would reject the most ordinary thing anyone sends. Covariant in both, + which is sound here because ``save_dict`` only ever reads: ``.items()`` + is the whole of what it touches. + """ + + def items(self) -> AbstractSet[tuple[KeyT_co, ValueT_co]]: ... + + +class ChannelFactory(Protocol[ChannelT_co]): + """Rebuilds the channel a wire ``CHANNEL`` opcode names.""" + + def new(self, id: int, /) -> ChannelT_co: ... + + +class FactoryOwner(Protocol[ChannelT_co]): + """A gateway: it owns the factory for its own channel ids.""" + + @property + def _channelfactory(self) -> ChannelFactory[ChannelT_co]: ... + + +class ChannelLike(Protocol[ChannelT_co]): + """A channel -- sync or async -- which names the gateway that owns one. + + Spelled as a protocol rather than a ``Channel | AsyncChannel`` union to + keep the promise in this module's docstring: the channel layer depends + on the serializer and never the other way round. + """ + + @property + def gateway(self) -> FactoryOwner[ChannelT_co]: ... + + +#: Everything execnet's wire format can carry, as one recursive alias. +#: +#: Generic in the channel it carries, because a channel reference means a +#: different class on every surface. Unparameterised -- ``Payload`` -- it +#: is the permissive :class:`ChannelRef`, which is what *sending* wants: +#: any surface's channel may be sent from any other. Parameterised, it is +#: what *receiving* wants, since a gateway's factory only ever builds +#: channels of its own surface, so ``Channel.receive`` says +#: ``Payload[Channel]`` and the sync channel comes back with its own +#: methods reachable behind an ``isinstance``. +#: +#: Deliberately spelled with the *abstract* containers rather than +#: ``list``/``dict``/``set``. Those are invariant, so ``list[int]`` would +#: not satisfy ``list[Payload]`` and every honest ``channel.send([1, 2])`` +#: would be an error -- the strict version is unusable as an argument type. +#: The abstract ones are covariant in their elements, so ordinary concrete +#: containers pass. +#: +#: The cost is a little over-acceptance: ``range`` is a ``Sequence[int]`` +#: and ``memoryview`` a ``Sequence`` too, and neither has a wire +#: representation. What this is for is the large class of mistakes -- +#: functions, sockets, arbitrary instances -- and those it does catch. +#: :func:`can_send` remains the runtime answer. +Payload: TypeAlias = ( + "None | bool | int | float | complex | str | bytes" + " | Sequence[Payload[ChannelT]] | AbstractSet[Payload[ChannelT]]" + " | PayloadMapping[Payload[ChannelT], Payload[ChannelT]] | ChannelT" +) + +#: What may be *sent*: a payload carrying a channel of any surface. +#: +#: The counterpart to a parameterised :data:`Payload`, and the asymmetry is +#: the point. A gateway will happily serialize any surface's channel, so +#: the send side asks only for :class:`ChannelRef`; a gateway's factory +#: only ever builds channels of its own surface, so each ``receive`` +#: promises that concrete class instead. Round trips still work, since a +#: ``Payload[Channel]`` satisfies ``SendPayload``. +SendPayload: TypeAlias = "Payload[ChannelRef]" + + +def bchr(n: int) -> bytes: + return bytes([n]) + + +DUMPFORMAT_VERSION = bchr(2) + +FOUR_BYTE_INT_MAX = 2147483647 +FOUR_BYTE_INT_MIN = -2147483648 + +FLOAT_FORMAT = "!d" +FLOAT_FORMAT_SIZE = struct.calcsize(FLOAT_FORMAT) +COMPLEX_FORMAT = "!dd" +COMPLEX_FORMAT_SIZE = struct.calcsize(COMPLEX_FORMAT) + + +class _Stop(Exception): + pass + + +class opcode: + """Container for name -> num mappings.""" + + BUILDTUPLE = b"@" + BYTES = b"A" + CHANNEL = b"B" + FALSE = b"C" + FLOAT = b"D" + FROZENSET = b"E" + INT = b"F" + LONG = b"G" + LONGINT = b"H" + LONGLONG = b"I" + NEWDICT = b"J" + NEWLIST = b"K" + NONE = b"L" + STRING = b"N" + SET = b"O" + SETITEM = b"P" + STOP = b"Q" + TRUE = b"R" + COMPLEX = b"T" + + +class Unserializer: + num2func: dict[bytes, Callable[[Unserializer], None]] = {} + + def __init__( + self, + stream: ReadIO, + channel_or_gateway: ChannelLike[ChannelRef] + | FactoryOwner[ChannelRef] + | None = None, + ) -> None: + # A channel -- sync or trio-native -- resolves through its gateway; a + # gateway is already the right object. Duck-typed so the serializer + # stays independent of the channel layer. + self.stream = stream + self.channelfactory: ChannelFactory[ChannelRef] | None = None + if channel_or_gateway is not None: + # the two shapes cannot be told apart statically, which is the + # point: neither name is imported here + owner = cast( + "FactoryOwner[ChannelRef]", + getattr(channel_or_gateway, "gateway", channel_or_gateway), + ) + self.channelfactory = owner._channelfactory + + def load(self, versioned: bool = False) -> Payload[ChannelT]: + if versioned: + ver = self.stream.read(1) + if ver != DUMPFORMAT_VERSION: + raise LoadError("wrong dumpformat version %r" % ver) + self.stack: list[Payload] = [] + try: + while True: + opcode = self.stream.read(1) + if not opcode: + raise EOFError + try: + loader = self.num2func[opcode] + except KeyError: + raise LoadError( + f"unknown opcode {opcode!r} - wire protocol corruption?" + ) from None + loader(self) + except _Stop: + if len(self.stack) != 1: + raise LoadError("internal unserialization error") from None + return cast("Payload[ChannelT]", self.stack.pop(0)) + else: + raise LoadError("didn't get STOP") + + def load_none(self) -> None: + self.stack.append(None) + + num2func[opcode.NONE] = load_none + + def load_true(self) -> None: + self.stack.append(True) + + num2func[opcode.TRUE] = load_true + + def load_false(self) -> None: + self.stack.append(False) + + num2func[opcode.FALSE] = load_false + + def load_int(self) -> None: + i = self._read_int4() + self.stack.append(i) + + num2func[opcode.INT] = load_int + + def load_longint(self) -> None: + s = self._read_byte_string() + self.stack.append(int(s)) + + num2func[opcode.LONGINT] = load_longint + + load_long = load_int + num2func[opcode.LONG] = load_long + load_longlong = load_longint + num2func[opcode.LONGLONG] = load_longlong + + def load_float(self) -> None: + binary = self.stream.read(FLOAT_FORMAT_SIZE) + self.stack.append(struct.unpack(FLOAT_FORMAT, binary)[0]) + + num2func[opcode.FLOAT] = load_float + + def load_complex(self) -> None: + binary = self.stream.read(COMPLEX_FORMAT_SIZE) + self.stack.append(complex(*struct.unpack(COMPLEX_FORMAT, binary))) + + num2func[opcode.COMPLEX] = load_complex + + def _read_int4(self) -> int: + value: int = struct.unpack("!i", self.stream.read(4))[0] + return value + + def _read_byte_string(self) -> bytes: + length = self._read_int4() + as_bytes = self.stream.read(length) + return as_bytes + + def load_string(self) -> None: + self.stack.append(self._read_byte_string().decode("utf-8")) + + num2func[opcode.STRING] = load_string + + def load_bytes(self) -> None: + s = self._read_byte_string() + self.stack.append(s) + + num2func[opcode.BYTES] = load_bytes + + def load_newlist(self) -> None: + length = self._read_int4() + self.stack.append([None] * length) + + num2func[opcode.NEWLIST] = load_newlist + + def load_setitem(self) -> None: + if len(self.stack) < 3: + raise LoadError("not enough items for setitem") + value = self.stack.pop() + key = self.stack.pop() + self.stack[-1][key] = value # type: ignore[index] + + num2func[opcode.SETITEM] = load_setitem + + def load_newdict(self) -> None: + self.stack.append({}) + + num2func[opcode.NEWDICT] = load_newdict + + def _load_collection(self, type_: type) -> None: + length = self._read_int4() + if length: + res = type_(self.stack[-length:]) + del self.stack[-length:] + self.stack.append(res) + else: + self.stack.append(type_()) + + def load_buildtuple(self) -> None: + self._load_collection(tuple) + + num2func[opcode.BUILDTUPLE] = load_buildtuple + + def load_set(self) -> None: + self._load_collection(set) + + num2func[opcode.SET] = load_set + + def load_frozenset(self) -> None: + self._load_collection(frozenset) + + num2func[opcode.FROZENSET] = load_frozenset + + def load_stop(self) -> None: + raise _Stop + + num2func[opcode.STOP] = load_stop + + def load_channel(self) -> None: + id = self._read_int4() + assert self.channelfactory is not None + newchannel = self.channelfactory.new(id) + self.stack.append(newchannel) + + num2func[opcode.CHANNEL] = load_channel + + +def dumps(obj: SendPayload) -> bytes: + """Serialize the given obj to a bytestring. + + The obj and all contained objects must be of a builtin + Python type (so nested dicts, sets, etc. are all OK but + not user-level instances). + """ + return _Serializer().save(obj, versioned=True) # type: ignore[return-value] + + +def dump(byteio, obj: object) -> None: + """write a serialized bytestring of the given obj to the given stream.""" + _Serializer(write=byteio.write).save(obj, versioned=True) + + +def loads(bytestring: bytes) -> Payload[ChannelT]: + """Deserialize the given bytestring to an object. + + If the bytestring was dumped with an incompatible protocol + version or if the bytestring is corrupted, the + ``execnet.DataFormatError`` will be raised. + """ + return load(BytesIO(bytestring)) + + +def load(io: ReadIO) -> Payload[ChannelT]: + """Derserialize an object form the specified stream. + + Behaviour is otherwise the same as with ``loads`` + """ + return Unserializer(io).load(versioned=True) + + +def loads_internal( + bytestring: bytes, + channel_or_gateway: ChannelLike[ChannelRef] + | FactoryOwner[ChannelRef] + | None = None, +) -> Payload[ChannelT]: + io = BytesIO(bytestring) + return Unserializer(io, channel_or_gateway).load() + + +def dumps_internal(obj: SendPayload) -> bytes: + return _Serializer().save(obj) # type: ignore[return-value] + + +def can_send(obj: object) -> TypeIs[SendPayload]: + """Whether ``obj`` can cross a channel as-is. + + True for execnet's simple builtin wire data -- ``None``, ``bool``, + ``int``, ``float``, ``complex``, ``bytes``, ``str`` and arbitrarily + nested ``list``/``tuple``/``set``/``frozenset``/``dict`` of those -- + and for channel references. False for anything execnet has no wire + representation for, which ``channel.send`` would reject with + :class:`~execnet.DumpError`. + + Use it to branch *before* sending, instead of sending and handling the + error:: + + channel.send(value if execnet.can_send(value) else repr(value)) + """ + try: + _Serializer().save(obj) + except DumpError: + return False + return True + + +class _Serializer: + _dispatch: dict[type, Callable[[_Serializer, object], None]] = {} + + def __init__(self, write: Callable[[bytes], None] | None = None) -> None: + if write is None: + self._streamlist: list[bytes] = [] + write = self._streamlist.append + self._write = write + + def save(self, obj: object, versioned: bool = False) -> bytes | None: + # calling here is not re-entrant but multiple instances + # may write to the same stream because of the common platform + # atomic-write guarantee (concurrent writes each happen atomically) + if versioned: + self._write(DUMPFORMAT_VERSION) + self._save(obj) + self._write(opcode.STOP) + try: + streamlist = self._streamlist + except AttributeError: + return None + return b"".join(streamlist) + + def _save(self, obj: object) -> None: + tp = type(obj) + try: + dispatch = self._dispatch[tp] + except KeyError: + methodname = "save_" + tp.__name__ + meth: Callable[[_Serializer, object], None] | None = getattr( + self.__class__, methodname, None + ) + if meth is None: + raise DumpError(f"can't serialize {tp}") from None + dispatch = self._dispatch[tp] = meth + dispatch(self, obj) + + def save_NoneType(self, non: None) -> None: + self._write(opcode.NONE) + + def save_bool(self, boolean: bool) -> None: + if boolean: + self._write(opcode.TRUE) + else: + self._write(opcode.FALSE) + + def save_bytes(self, bytes_: bytes) -> None: + self._write(opcode.BYTES) + self._write_byte_sequence(bytes_) + + def save_str(self, s: str) -> None: + self._write(opcode.STRING) + self._write_unicode_string(s) + + def _write_unicode_string(self, s: str) -> None: + try: + as_bytes = s.encode("utf-8") + except UnicodeEncodeError as e: + raise DumpError("strings must be utf-8 encodable") from e + self._write_byte_sequence(as_bytes) + + def _write_byte_sequence(self, bytes_: bytes) -> None: + self._write_int4(len(bytes_), "string is too long") + self._write(bytes_) + + def _save_integral(self, i: int, short_op: bytes, long_op: bytes) -> None: + # The short op packs a signed 4-byte int; anything outside that range + # (in either direction) goes through the arbitrary-precision long op. + if FOUR_BYTE_INT_MIN <= i <= FOUR_BYTE_INT_MAX: + self._write(short_op) + self._write_int4(i) + else: + self._write(long_op) + self._write_byte_sequence(str(i).rstrip("L").encode("ascii")) + + def save_int(self, i: int) -> None: + self._save_integral(i, opcode.INT, opcode.LONGINT) + + def save_long(self, l: int) -> None: + self._save_integral(l, opcode.LONG, opcode.LONGLONG) + + def save_float(self, flt: float) -> None: + self._write(opcode.FLOAT) + self._write(struct.pack(FLOAT_FORMAT, flt)) + + def save_complex(self, cpx: complex) -> None: + self._write(opcode.COMPLEX) + self._write(struct.pack(COMPLEX_FORMAT, cpx.real, cpx.imag)) + + def _write_int4( + self, i: int, error: str = "int must be less than %i" % (FOUR_BYTE_INT_MAX,) + ) -> None: + if i > FOUR_BYTE_INT_MAX: + raise DumpError(error) + self._write(struct.pack("!i", i)) + + def save_list(self, L: list[object]) -> None: + self._write(opcode.NEWLIST) + self._write_int4(len(L), "list is too long") + for i, item in enumerate(L): + self._write_setitem(i, item) + + def _write_setitem(self, key: object, value: object) -> None: + self._save(key) + self._save(value) + self._write(opcode.SETITEM) + + def save_dict(self, d: dict[object, object]) -> None: + self._write(opcode.NEWDICT) + for key, value in d.items(): + self._write_setitem(key, value) + + def save_tuple(self, tup: tuple[object, ...]) -> None: + for item in tup: + self._save(item) + self._write(opcode.BUILDTUPLE) + self._write_int4(len(tup), "tuple is too long") + + def _write_set(self, s: set[object] | frozenset[object], op: bytes) -> None: + for item in s: + self._save(item) + self._write(op) + self._write_int4(len(s), "set is too long") + + def save_set(self, s: set[object]) -> None: + self._write_set(s, opcode.SET) + + def save_frozenset(self, s: frozenset[object]) -> None: + self._write_set(s, opcode.FROZENSET) + + def save_Channel(self, channel: Channel) -> None: + self._write(opcode.CHANNEL) + self._write_int4(channel.id) + + def save_AsyncChannel(self, channel: ChannelRef) -> None: + # any surface's async channel -- the core's or either facade's -- + # since dispatch is by class name; same wire opcode as the sync one + self._write(opcode.CHANNEL) + self._write_int4(channel.id) diff --git a/src/execnet/_services.py b/src/execnet/_services.py new file mode 100644 index 00000000..3836ba56 --- /dev/null +++ b/src/execnet/_services.py @@ -0,0 +1,166 @@ +"""Worker services: protocol requests a worker serves *itself*. + +A service is infrastructure that used to be a ``remote_exec`` of execnet's +own source -- receiving a file transfer, building an environment. It is +not exec'd code: it claims no exec slot, ships no source, and runs whatever +the worker's own installed execnet implements. + +The core knows only this much of it. A request is one +``GATEWAY_SERVICE`` frame carrying ``(name, request)``; the worker looks +the name up here and spawns the handler it finds. Everything else -- what +the names mean, what the payloads contain, what the conversation on the +channel looks like -- belongs to whichever package registered them, which +is why :mod:`execnet._deploy` can be lifted out of this one without the +protocol core noticing. + +Handlers are named as import strings and imported when a request for them +arrives. A coordinator never imports a handler at all, and a worker +imports only what it is actually asked for. + +Out of tree services register the same way:: + + execnet._services.register("myco.thing", "myco.execnet_thing:serve") + +on both ends -- the coordinator to name it in a request, the worker to +resolve it. Nothing in execnet has to change to make room. +""" + +from __future__ import annotations + +import importlib +from collections.abc import Awaitable +from collections.abc import Callable +from typing import TYPE_CHECKING +from typing import Any + +from ._message import Message +from ._serialize import dumps_internal + +if TYPE_CHECKING: + from ._trio_gateway import AsyncChannel + from ._trio_gateway import AsyncGateway + +#: A service handler: ``(gateway, channelid, request) -> awaited task``. It +#: runs on the worker's loop as a task of whichever nursery that worker's +#: entry point owns, and must contain its own failures -- an exception +#: leaving it ends ``trio.run`` and takes every gateway in the process with +#: it. Report on the request's channel instead. +ServiceHandler = Callable[[Any, int, Any], Awaitable[None]] + +#: name -> ``"module.path:attribute"``. Data, not imports: the one place +#: the core spells a feature's name, and one line to delete when a feature +#: leaves. +_REGISTRY: dict[str, str] = { + # the file transfer and the deployment steps built on it + "transfer": "execnet._deploy.serve:receive_transfer", + "deploy": "execnet._deploy.serve:run_deploy_step", +} + + +def register(name: str, target: str) -> None: + """Register ``name`` as served by ``target`` (``"module:attribute"``). + + Both ends need it: the coordinator to name it in a request, the worker + to resolve one. Re-registering the same target is fine (importing a + module twice must not be an error); changing one is not, since the two + ends would then disagree about what a name means. + """ + existing = _REGISTRY.get(name) + if existing is not None and existing != target: + raise ValueError( + f"service {name!r} is already registered as {existing!r};" + " a name means one thing on both ends of a connection" + ) + _REGISTRY[name] = target + + +def resolve(name: str) -> ServiceHandler: + """Import and return the handler for ``name`` (worker side).""" + try: + target = _REGISTRY[name] + except KeyError: + raise LookupError( + f"no execnet service named {name!r} on this worker" + f" (known: {sorted(_REGISTRY)}). A coordinator asking for one" + " this worker does not have usually means the two are running" + " different execnet versions." + ) from None + module_name, _, attribute = target.partition(":") + module = importlib.import_module(module_name) + handler: ServiceHandler = getattr(module, attribute) + return handler + + +def request_frame(name: str, request: Any) -> bytes: + """The ``GATEWAY_SERVICE`` payload for one request.""" + return dumps_internal((name, request)) + + +async def open_service_channel( + gateway: AsyncGateway, + name: str, + request: Any, + *, + channelid: int | None = None, +) -> AsyncChannel: + """Ask ``gateway`` for service ``name``; return the channel it runs on. + + ``channelid`` is for the blocking facade, whose gateway has a *second* + id allocator: the sync ``ChannelFactory`` and the async gateway's own + counter both hand out odd ids and would collide. Allocate from the + sync factory there and pass the result in, as the via transport does. + """ + channel = gateway.open_channel(channelid) + await gateway._send( + Message.GATEWAY_SERVICE, channel.id, request_frame(name, request) + ) + return channel + + +class ServiceTarget: + """A gateway that services can be requested on. + + The one thing the surfaces disagree about. Under :mod:`execnet.trio` a + gateway is an :class:`~execnet._trio_gateway.AsyncGateway` that + allocates its own channel ids; under the blocking and asyncio surfaces + it is a sync ``Gateway`` whose ids come from its ``ChannelFactory``, + and taking them from the async side instead would hand out ids the sync + side is also handing out. + + Everything above this -- transfers, deployments -- takes one of these + and never learns which kind it got. + """ + + def __init__(self, gateway: AsyncGateway, allocate_id: Any = None) -> None: + self.gateway = gateway + self._allocate_id = allocate_id + + @classmethod + def from_sync(cls, gateway: Any) -> ServiceTarget: + """A target for a blocking-surface ``Gateway``.""" + session = gateway._trio_session + if session is None: + raise OSError(f"{gateway!r} has no connection to run a service on") + return cls(session, gateway._channelfactory.allocate_id) + + def __repr__(self) -> str: + return f"" + + async def open(self, name: str, request: Any) -> AsyncChannel: + channelid = None if self._allocate_id is None else self._allocate_id() + return await open_service_channel( + self.gateway, name, request, channelid=channelid + ) + + async def request(self, name: str, request: Any) -> Any: + """One request, one reply, channel closed.""" + # local import: importing execnet must not load an event loop + from ._async import current_async + + aio = current_async() + channel = await self.open(name, request) + try: + return await channel.receive() + finally: + with aio.shielded(): + await channel.aclose() diff --git a/src/execnet/_shim.py b/src/execnet/_shim.py new file mode 100644 index 00000000..93ebe0b1 --- /dev/null +++ b/src/execnet/_shim.py @@ -0,0 +1,49 @@ +"""Machinery for the deprecated pre-Trio module names. + +``execnet.gateway_base``, ``execnet.gateway``, ``execnet.multi``, +``execnet.rsync``, ``execnet.rsync_remote`` and ``execnet.xspec`` were never +part of a documented public API -- they were importable only because +``import execnet`` pulled them in transitively. Their contents now live in +private modules grouped by concern; each old name survives as a thin module +whose ``__getattr__`` warns and forwards. + +The supported surfaces are :mod:`execnet` / :mod:`execnet.sync`, +:mod:`execnet.trio`, :mod:`execnet.aio` and :mod:`execnet.gevent`. +""" + +from __future__ import annotations + +import importlib +import warnings +from typing import Any + +#: shims are scheduled for removal in this release -- later in the 3.x +#: series, once the consumers that still import these names (pytest-xdist +#: above all) have released a version that does not. +REMOVED_IN = "a later execnet 3.x release" + + +def forwarder(shim: str, moved: dict[str, str]) -> Any: + """Build the ``__getattr__`` for a deprecated module. + + ``moved`` maps each previously-reachable name to the private module that + now defines it (a relative name such as ``"._channel"``). + """ + + def __getattr__(name: str) -> Any: + try: + module = moved[name] + except KeyError: + raise AttributeError( + f"module 'execnet.{shim}' has no attribute {name!r}" + ) from None + warnings.warn( + f"execnet.{shim} is private and will be removed in {REMOVED_IN}; " + f"{name} now lives in execnet{module}. The supported surfaces are " + f"execnet, execnet.sync, execnet.trio, execnet.aio and execnet.gevent.", + DeprecationWarning, + stacklevel=2, + ) + return getattr(importlib.import_module(module, __package__), name) + + return __getattr__ diff --git a/src/execnet/_socketserver.py b/src/execnet/_socketserver.py new file mode 100644 index 00000000..1a950a87 --- /dev/null +++ b/src/execnet/_socketserver.py @@ -0,0 +1,81 @@ +"""Trio socket server for execnet gateways. + +Listens on a TCP port and hands each accepted connection (by fd) to a fresh +worker subprocess that serves the gateway over it. No code is executed +inline. + +The supported entry point is ``execnet server`` (see :mod:`execnet._cli`) -- +run it on the target host, or install-free with +``uvx --from execnet execnet server``. The old ``execnet-socketserver`` +console command still works and forwards here with a DeprecationWarning. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from trio import SocketListener + + +async def _one_port( + listeners: list[SocketListener], host: str | None +) -> list[SocketListener]: + """Re-bind ``listeners`` so every address family shares a single port. + + Asking for port 0 on a wildcard host binds each family to its *own* + random port -- trio documents this. We report one address, so the other + family would then be listening somewhere the caller was never told + about, and which family gets reported first is platform-dependent: + IPv4 on Linux, IPv6 on Windows, where a client dialling 127.0.0.1 was + told a port nothing was listening on. + """ + import trio + + ports = {listener.socket.getsockname()[1] for listener in listeners} + if len(ports) < 2: + return listeners + port = listeners[0].socket.getsockname()[1] + for listener in listeners: + await listener.aclose() + return await trio.open_tcp_listeners(port, host=host) + + +async def serve(hostport: str, once: bool) -> None: + """Bind ``hostport`` and hand accepted connections to worker processes.""" + import trio + + from execnet import _trio_host + + host, _, port_str = hostport.rpartition(":") + bind_host = host or None + listeners = await trio.open_tcp_listeners(int(port_str), host=bind_host) + if int(port_str) == 0: + listeners = await _one_port(listeners, bind_host) + addr = listeners[0].socket.getsockname() + # Report the bound address (port may be ephemeral) for callers to read. + print("execnet-socketserver listening on %s %s" % (addr[0], addr[1]), flush=True) + + if once: + stream = await listeners[0].accept() + for listener in listeners: + await listener.aclose() + # The worker outlives this one-shot server. + await _trio_host.serve_socket_connection(stream, reap=False) + return + + async def handler(stream: trio.SocketStream) -> None: + await _trio_host.serve_socket_connection(stream, reap=True) + + await trio.serve_listeners(handler, listeners) + + +def main(argv: list[str] | None = None) -> None: + """Deprecated ``execnet-socketserver`` console entry point.""" + from ._cli import socketserver_main + + socketserver_main(argv) + + +if __name__ == "__main__": + main() diff --git a/src/execnet/_trace.py b/src/execnet/_trace.py new file mode 100644 index 00000000..edb650d8 --- /dev/null +++ b/src/execnet/_trace.py @@ -0,0 +1,47 @@ +"""Debug tracing, configured once from ``EXECNET_DEBUG``. + +:EXECNET_DEBUG=1: write per-process trace files to ``execnet-debug-PID`` +:EXECNET_DEBUG=2: trace to stderr (popen workers forward this to their + instantiator) + +Unset, ``trace`` is a no-op lambda so tracing costs a call and nothing else. +""" + +from __future__ import annotations + +import os +import sys + +DEBUG = os.environ.get("EXECNET_DEBUG") +pid = os.getpid() + +if DEBUG == "2": + + def trace(*msg: object) -> None: + try: + line = " ".join(map(str, msg)) + sys.stderr.write(f"[{pid}] {line}\n") + sys.stderr.flush() + except Exception: + pass # nothing we can do, likely interpreter-shutdown + +elif DEBUG: + import tempfile + + fn = os.path.join(tempfile.gettempdir(), "execnet-debug-%d" % pid) + # sys.stderr.write("execnet-debug at %r" % (fn,)) + debugfile = open(fn, "w") + + def trace(*msg: object) -> None: + try: + line = " ".join(map(str, msg)) + debugfile.write(line + "\n") + debugfile.flush() + except Exception as exc: + try: + sys.stderr.write(f"[{pid}] exception during tracing: {exc!r}\n") + except Exception: + pass # nothing we can do, likely interpreter-shutdown + +else: + notrace = trace = lambda *msg: None diff --git a/src/execnet/_trio_engine.py b/src/execnet/_trio_engine.py new file mode 100644 index 00000000..0eaea347 --- /dev/null +++ b/src/execnet/_trio_engine.py @@ -0,0 +1,334 @@ +"""The engine loop itself: one OS thread running ``trio.run``. + +:class:`~execnet._engine.ProtocolEngine` is the public handle; this is what +it starts. Kept apart from the routing layer in +:mod:`execnet._trio_host` so the loop and the things that run *on* it are +not one 1100-line module -- and so the loop's own interface stays small +enough to see: start it, run something on it, stop it. + +Everything a caller needs from the loop goes through :meth:`TrioEngine.call`, +:meth:`~TrioEngine._call_pending`, :meth:`~TrioEngine.start_soon` or +:meth:`~TrioEngine.start_task`. Nothing outside this module reaches for the +nursery: a task started anywhere else would be a task the engine cannot +account for at shutdown. +""" + +from __future__ import annotations + +import threading +from collections.abc import Awaitable +from collections.abc import Callable +from typing import Any +from typing import TypeVar + +import trio + +from ._boundary import WaitBackend +from ._boundary import Wakener +from ._engine import DEFAULT_CALLBACK_THREADS +from ._engine import gevent_patched_modules +from ._portal import LoopPortal +from ._portal import OneShot + +T = TypeVar("T") + + +def _check_gevent_not_patched() -> None: + """Refuse to start a engine loop in a monkey-patched process. + + Every patching variant we measured is broken, and each fails somewhere + inside trio with an error that says nothing about gevent: + ``patch_all()`` removes ``select.epoll`` so the IO manager cannot be + built, ``patch_all(select=False)`` makes trio's wakeup socketpair a + gevent socket (``EBADF``), and patching neither still leaves + ``queue.SimpleQueue`` gevent's, so ``from_thread.run`` raises + ``LoopExit``. Refusing here costs a dict lookup and turns all three + into one sentence, before a thread exists to fail on. + + Note this is not what makes :mod:`execnet.gevent` work: that surface's + waits park the calling greenlet because they wait on a gevent + primitive, not because the stdlib was swapped underneath them. It + works in an unpatched process and is the supported way to drive execnet + from a gevent application. + """ + patched = gevent_patched_modules() + if not patched: + return + raise RuntimeError( + "the execnet engine loop cannot run in this process: gevent has" + f" monkey-patched {', '.join(patched)}, and the loop needs the real" + " ones (it is a trio program on its own OS thread). execnet supports" + " gevent applications that do not monkey-patch these modules --" + " execnet.gevent parks the calling greenlet on its blocking waits" + " either way, which is what that namespace is for." + ) + + +def _startup_hint() -> str: + """Name gevent when patching is what kept the loop from starting. + + :func:`_check_gevent_not_patched` catches this before the thread + starts; this stays for a process that patches *after* that check, and + for whatever else ``gevent.monkey`` grows next. + """ + patched = gevent_patched_modules() + if not patched: + return "" + return ( + f" -- gevent has monkey-patched {', '.join(patched)}, and the engine loop" + " needs the real ones. execnet.gevent supports a process that uses" + " gevent without monkey-patching these modules; its blocking waits" + " park the calling greenlet either way." + ) + + +class TrioEngine: + """Dedicated OS thread running ``trio.run`` for protocol IO.""" + + #: which async library this engine's loop is; see ``ProtocolEngine`` + backend = "trio" + + def __init__( + self, + name: str = "execnet-trio-engine", + callback_threads: int = DEFAULT_CALLBACK_THREADS, + ) -> None: + self._name = name + self._callback_threads = callback_threads + self._thread: threading.Thread | None = None + self._portal: LoopPortal | None = None + self._nursery: trio.Nursery | None = None + self._ready = threading.Event() + self._shutdown: trio.Event | None = None + self._started = False + self._callback_limiter: trio.CapacityLimiter | None = None + self._startup_error: BaseException | None = None + #: engine-side groups currently running here, in start order. Only + #: touched from the engine thread (a group registers itself as its + #: task starts and drops out as it ends), so it needs no lock. + self._groups: list[Any] = [] + + def start(self) -> None: + if self._started: + return + _check_gevent_not_patched() + self._thread = threading.Thread(target=self._run, name=self._name, daemon=True) + self._thread.start() + if not self._ready.wait(timeout=30): + raise RuntimeError("TrioEngine failed to start within 30s") + error = self._startup_error + if error is not None: + raise RuntimeError( + f"the execnet engine loop could not start: {error!r}{_startup_hint()}" + ) from error + self._started = True + + @property + def portal(self) -> LoopPortal: + if self._portal is None: + raise RuntimeError("TrioEngine is not running") + return self._portal + + @property + def _limiter(self) -> trio.CapacityLimiter: + """Bound on concurrent threadpool threads running receiver callbacks.""" + if self._callback_limiter is None: + raise RuntimeError("TrioEngine is not running") + return self._callback_limiter + + def _on_engine_thread(self) -> bool: + return self._portal is not None and self._portal.is_loop_thread() + + def _run(self) -> None: + try: + trio.run(self._main) + except BaseException as exc: + if self._ready.is_set(): + # the loop was up and died later: nobody is waiting on us, + # so let the thread report it the loud way + raise + # start() is blocked on _ready and would otherwise wait out the + # full timeout and raise something generic, with the actual + # reason only on stderr + self._startup_error = exc + self._ready.set() + + async def _main(self) -> None: + self._portal = LoopPortal() + self._shutdown = trio.Event() + self._callback_limiter = trio.CapacityLimiter(self._callback_threads) + try: + async with trio.open_nursery() as nursery: + self._nursery = nursery + self._ready.set() + await self._shutdown.wait() + nursery.cancel_scope.cancel() + finally: + self._nursery = None + + def call(self, async_fn: Callable[..., Awaitable[T]], *args: Any) -> T: + return self.portal.run(async_fn, *args) + + def _call_pending( + self, + async_fn: Callable[..., Awaitable[T]], + *args: Any, + wakener: Wakener | None = None, + ) -> OneShot[T]: + """Run ``async_fn`` as an engine task, resolving a :class:`OneShot`. + + The non-blocking counterpart of :meth:`call` for consumers that + must not block their OS thread (a gevent hub: waiting on the + OneShot with a gevent wakener parks only the calling greenlet). + Unlike ``portal.run`` the wait is KeyboardInterrupt-interruptible. + """ + result: OneShot[T] = OneShot(wakener) + + async def runner() -> None: + try: + value = await async_fn(*args) + except trio.Cancelled: + if not result.is_set(): + result.set_error(RuntimeError("trio engine was shut down")) + raise + except BaseException as exc: + result.set_error(exc) + else: + result.set(value) + + def spawn() -> None: + # Posted callbacks must not raise: trio turns an exception from + # an entry-queue callback into TrioInternalError and tears the + # whole loop down, taking every gateway in the process with it. + # An engine that shut down between the post and here is exactly the + # failure this call already reports as a value. + try: + self.start_soon(runner) + except BaseException as exc: + error = RuntimeError("trio engine was shut down") + error.__cause__ = exc + if not result.is_set(): + result.set_error(error) + + self.portal.post(spawn) + return result + + def call_sync(self, sync_fn: Callable[..., T], *args: Any) -> T: + return self.portal.run_sync(sync_fn, *args) + + def start_soon(self, async_fn: Callable[..., Any], *args: Any) -> None: + """Schedule a task on the root nursery (must be called on the engine thread).""" + if not self._on_engine_thread(): + raise RuntimeError("start_soon requires the Trio engine thread") + if self._nursery is None: + raise RuntimeError("TrioEngine nursery is not available") + self._nursery.start_soon(async_fn, *args) + + async def start_task(self, async_fn: Callable[..., Any], *args: Any) -> Any: + """``await nursery.start(...)`` on the root nursery, from a task on it. + + The one door to the root nursery, so that a long-lived task -- a + gateway session, a facade group -- is something the engine knows it + is running rather than something a caller reached in and attached. + Returns whatever the task passes to ``task_status.started()``. + """ + if self._nursery is None: + raise RuntimeError("TrioEngine nursery is not available") + return await self._nursery.start(async_fn, *args) + + # -- the groups running here (engine thread only) -- + + def _register_group(self, group: Any) -> None: + self._groups.append(group) + + def _forget_group(self, group: Any) -> None: + if group in self._groups: + self._groups.remove(group) + + def live_groups(self) -> str: + """What is still running here, for a message; ``""`` when nothing is. + + Reads a list the engine thread owns, from whichever thread is + closing. Racy by construction and deliberately harmless: the worst + outcome is naming a gateway that finished terminating a moment ago. + """ + ids = [ + str(gateway.id) + for group in list(self._groups) + for gateway in list(group._gateways) + ] + if not ids: + return "" + return f"{len(self._groups)} group(s), gateways {', '.join(sorted(ids))}" + + async def terminate_groups(self, timeout: float | None = None) -> None: + """Terminate every group running here, concurrently (engine loop). + + Each group's own bounded contract applies -- termination frame, + grace, then kill -- so this ends in roughly ``timeout`` however many + groups there are, rather than in the sum of them. + """ + groups = list(self._groups) + if not groups: + return + async with trio.open_nursery() as nursery: + for group in groups: + nursery.start_soon(group.terminate, timeout) + for group in groups: + # lets each group's run task finish, which is what unregisters it + group.shutdown.set() + + def stop(self, timeout: float | None = 5.0) -> bool: + """Cancel the root nursery and join the thread; True if it joined. + + A thread that does not join is a leak worth reporting: the loop is + still running, and whatever wedged it is still holding it. + """ + if not self._started or self._portal is None or self._shutdown is None: + return True + + def _set() -> None: + assert self._shutdown is not None + self._shutdown.set() + + try: + # posted rather than run: ``portal.run_sync`` refuses a caller + # that is itself inside a trio run ("this is a blocking + # function"), which is exactly where an async application closes + # its engine from. Nothing is lost by not waiting for the + # callback -- the join below is the real wait. + self._portal.post(_set) + except Exception: + pass + joined = True + if self._thread is not None: + self._thread.join(timeout=timeout) + joined = not self._thread.is_alive() + self._started = False + return joined + + +def engine_call( + trio_engine: TrioEngine, + wait_backend: WaitBackend, + async_fn: Callable[..., Awaitable[T]], + *args: Any, +) -> T: + """Run ``async_fn`` on ``trio_engine``, parking the way ``wait_backend`` does. + + ``thread`` keeps the KI-deferred ``portal.run`` path. Any other backend + implies the caller may not own its OS thread -- a gevent hub runs every + other greenlet on it -- so the work becomes an engine task and the wait + happens on a ``OneShot`` with that backend's wakener. + + The blocking surfaces all funnel through here: ``Group`` for gateway + creation and termination, and the deployment layer for transfers. + """ + if wait_backend == "thread": + return trio_engine.call(async_fn, *args) + from ._boundary import make_wakener + + pending = trio_engine._call_pending( + async_fn, *args, wakener=make_wakener(wait_backend) + ) + return pending.wait() diff --git a/src/execnet/_trio_gateway.py b/src/execnet/_trio_gateway.py new file mode 100644 index 00000000..345b5293 --- /dev/null +++ b/src/execnet/_trio_gateway.py @@ -0,0 +1,1526 @@ +"""The gateway core: async dispatch loop and low-level raw channels. + +Async-first counterpart of the sync machinery in ``_channel`` / +``_gateway_base``: an +:class:`AsyncGateway` owns a :class:`ByteStream` and runs a single dispatch +task (stream -> ``FrameDecoder`` -> route). Message handlers execute inline +on that task, so there is no receiver thread and no receive lock. + +Two-level channel model: + +* :class:`RawChannel` (this module) -- id-routed raw byte payload streams + over the gateway: no serialization and no callbacks. + ``CHANNEL_DATA`` payloads route to the channel verbatim; the layer on top + decides what the bytes mean. +* ``AsyncChannel`` -- the serialized object API layered on a RawChannel. + +Nothing here names an async library. Everything the core needs from one +is reached through :mod:`execnet._async` -- a task scope, a shield, two +deadlines, an event, a queue, a thread hop, some streams, and a handful of +exception types -- so this one implementation runs on trio or asyncio +depending only on the loop it was built in. Objects capture that choice +once, as ``self._aio``, at construction. +""" + +from __future__ import annotations + +import functools +import math +import os +import subprocess +import sys +import types +import uuid +from collections.abc import AsyncIterator +from collections.abc import Callable +from contextlib import asynccontextmanager +from contextlib import suppress +from typing import TYPE_CHECKING +from typing import Any +from typing import Protocol +from typing import TypeVar +from typing import cast + +from ._async import current_async + +if TYPE_CHECKING: + from typing_extensions import Self + + from ._xspec import XSpec + +from ._errors import GatewayReceivedTerminate +from ._errors import HostNotFound +from ._errors import RemoteError +from ._errors import TimeoutError +from ._exec_source import normalize_exec_source +from ._execmodel import resolve_profile +from ._handshake import read_ready +from ._handshake import send_config +from ._message import FrameDecoder +from ._message import Message +from ._message import gateway_info +from ._serialize import Payload +from ._serialize import SendPayload +from ._serialize import dumps_internal +from ._serialize import loads_internal +from ._trace import trace + +RECEIVE_CHUNK = 65536 + +T = TypeVar("T") + + +async def provision_sync(fn: Callable[..., T], *args: Any, **kwargs: Any) -> T: + """Run coordinator-side provisioning work off the loop thread. + + Everything in ``_provision`` that decides *what* to launch may block for + a long time: an ``execnet info`` probe of a ``python=`` target runs a + subprocess with a 30s timeout, a dev coordinator's ``uv build`` takes + seconds on a cold cache, and shipped wheels are read whole off disk. + Run inline it stalls the loop -- the caller's own ``trio.run`` for + :mod:`execnet.trio`, and for every other surface the *shared* host, + which is every gateway in the process including other groups'. + + Not abandoned on cancel: the build populates a wheel cache keyed by + version, and a half-written entry there is one every later gateway + would pick up. + """ + result: T = await current_async().to_thread(functools.partial(fn, *args, **kwargs)) + return result + + +class ByteStream(Protocol): + """Neutral bidirectional byte-stream protocol for gateway transports. + + ``trio.StapledStream`` (process/fd pipe pairs) and ``trio.SocketStream`` + satisfy this structurally; a future anyio backend's byte streams use the + same four names. ``send_eof`` signals write-EOF to the peer (half-close + for sockets; for pipe pairs trio falls back to closing the send half). + """ + + async def send_all(self, data: bytes) -> None: ... + + async def receive_some(self, max_bytes: int | None = None) -> bytes: ... + + async def send_eof(self) -> None: ... + + async def aclose(self) -> None: ... + + +def staple_process_stream(process: Any) -> ByteStream: + """One bidirectional stream over a process's stdin/stdout pair.""" + stream: ByteStream = current_async().staple_process(process) + return stream + + +class ThreadedFdStream: + """A :class:`ByteStream` over blocking fds, doing its IO in worker threads. + + The Windows stand-in for the POSIX-only fd streams both backends offer. + Trio *does* have Windows pipe streams, but they require handles opened in + OVERLAPPED mode and register them with an IOCP -- and the stdio a process + inherits from its parent is an ordinary synchronous pipe, so a worker + cannot adopt its own fd 0/1 that way. + + Blocking reads and writes therefore go to the thread pool. A pending + read is abandoned on cancellation, since nothing can interrupt it short + of the peer closing; a write is not, because a torn write would leave a + half-message on the wire and desynchronize the framing. + """ + + def __init__(self, read_fd: int, write_fd: int) -> None: + self._aio = current_async() + self._read_fd: int | None = read_fd + self._write_fd: int | None = write_fd + + async def receive_some(self, max_bytes: int | None = None) -> bytes: + fd = self._read_fd + if fd is None: + raise self._aio.ClosedResource("stream closed") + data: bytes = await self._aio.to_thread( + os.read, fd, max_bytes or 65536, abandon_on_cancel=True + ) + return data + + async def send_all(self, data: bytes) -> None: + fd = self._write_fd + if fd is None: + raise self._aio.ClosedResource("stream closed") + view = memoryview(data) + while view: + written = await self._aio.to_thread(os.write, fd, view) + view = view[written:] + + async def send_eof(self) -> None: + fd, self._write_fd = self._write_fd, None + if fd is not None: + os.close(fd) + + async def aclose(self) -> None: + await self.send_eof() + fd, self._read_fd = self._read_fd, None + if fd is not None: + os.close(fd) + await self._aio.checkpoint() + + +async def staple_fd_stream(read_fd: int, write_fd: int) -> ByteStream: + """One bidirectional stream over OS pipe fds (worker stdio pipes).""" + if sys.platform == "win32": + return ThreadedFdStream(read_fd, write_fd) + stream: ByteStream = await current_async().staple_fds(read_fd, write_fd) + return stream + + +async def configure_worker( + stream: ByteStream, spec: XSpec | None, what: str +) -> dict[str, Any]: + """Configure the worker on ``stream`` and wait until it is serving. + + One ``GATEWAY_CONFIG`` frame out, one back (:mod:`execnet._handshake`). + Identical on every transport, which is why nothing a worker needs has + to be squeezed into its argv. + + A worker that dies mid-handshake is reported as ``EOFError`` whichever + way its transport shows that: a dead peer *resets* a socket where a + pipe reaches EOF, and callers here test for EOF. + """ + from . import _provision + + config = _provision.worker_config(spec) if spec is not None else {} + try: + await send_config(stream, config) + return await read_ready(stream, what) + except current_async().STREAM_GONE as exc: + error = EOFError(f"the worker went away during the {what} handshake: {exc}") + raise error from exc + + +async def open_popen_process(args: list[str]) -> Any: + return await current_async().open_process( + args, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + ) + + +def popen_module_args( + spec: XSpec, *protocol: str, local_config_on_stdin: bool = False +) -> list[str]: + """Launch the Trio worker as a module: ``python -m execnet worker``. + + No source is sent over the wire; the worker imports the installed execnet + + trio. Used for same-interpreter popen and for a ``python=`` interpreter that + already has execnet (so ``sys.executable`` stays that interpreter). + + Nothing about the gateway is in here: what this worker *is* arrives as + the config frame. ``local_config_on_stdin`` is only the Windows + ``share`` blob, which has to exist before the stream does. + """ + from . import _provision + + if spec.python: + interpreter = _provision.shell_split_path(spec.python) + else: + interpreter = [sys.executable] + + args = [*interpreter, "-u"] + if spec.dont_write_bytecode: + args.append("-B") + args += ["-m", "execnet", "worker", *protocol] + if local_config_on_stdin: + args += ["--config-fd", "0"] + return args + + +def popen_worker_argv( + spec: XSpec, *protocol: str, local_config_on_stdin: bool = False +) -> list[str]: + """Argv for a popen worker: direct module launch, or uv-provisioned. + + A bare ``python=`` interpreter without execnet gets execnet + trio + provisioned via ``uv``; otherwise the worker module is launched directly. + """ + from . import _provision + + if spec.python and not _provision.target_has_execnet(spec.python): + return _provision.uv_worker_argv( + spec, *protocol, local_config_on_stdin=local_config_on_stdin + ) + return popen_module_args( + spec, *protocol, local_config_on_stdin=local_config_on_stdin + ) + + +class RawChannel: + """Low-level id-routed byte payload stream over an :class:`AsyncGateway`. + + Payload boundaries are preserved: every :meth:`send_bytes` arrives as + one :meth:`receive_bytes` result on the peer. No serialization and no + flow control beyond the gateway's outbound queue. + + Close semantics mirror the sync ``Channel`` state machine: + + * :meth:`aclose` closes both directions (``CHANNEL_CLOSE`` / + ``CHANNEL_CLOSE_ERROR`` to the peer). + * :meth:`send_eof` only ends our payload stream (``CHANNEL_LAST_MESSAGE``); + the peer drains, hits EOF, and may keep sending to us. + """ + + def __init__(self, gateway: AsyncGateway, id: int) -> None: + self.gateway = gateway + self._aio = gateway._aio + self.id = id + self._closed = False # no more sends (local aclose or remote close) + self._sent_eof = False + self._remote_closed = False + #: whether local code has ever been handed this channel. If it has, + #: it owns the object and the gateway's registry is only a router; + #: if it has not, the registry *is* the only thing holding what + #: arrived, and a late binder has to find it there. + self._handed_out = False + self._receive_closed = self._aio.event() # no more payloads will arrive + self._remote_error: RemoteError | None = None + self._payload_send, self._payloads = self._aio.queue() + # Diversion hooks for a bound facade (sync channel): when set, + # inbound payloads/closes route out of the loop instead of + # buffering for receive_bytes. + self._consumer_payload: Callable[[bytes], None] | None = None + self._consumer_close: Callable[[RemoteError | None, bool], None] | None = None + self._pending_close: tuple[RemoteError | None, bool] | None = None + + def __repr__(self) -> str: + state = "closed" if self._closed else "open" + return f"" + + async def send_bytes(self, data: bytes) -> None: + """Send one payload; the peer receives it as a single item. + + OSError is raised when the channel or gateway is closed, matching + the sync ``Channel.send`` contract. + """ + if self._closed or self._sent_eof: + raise OSError(f"cannot send to {self!r}") + await self.gateway._send(Message.CHANNEL_DATA, self.id, data) + + async def receive_bytes(self) -> bytes: + """Receive the next payload. + + Raises EOFError once the peer closed or sent EOF and all payloads + are drained; a peer close-with-error raises that ``RemoteError``. + """ + try: + payload: bytes = await self._payloads.receive() + except self._aio.CHANNEL_EMPTY: + raise self._pending_error() from None + return payload + + async def send_eof(self) -> None: + """Signal that no more payloads follow (peer keeps its send side).""" + if self._closed or self._sent_eof: + raise OSError(f"cannot send EOF to {self!r}") + self._sent_eof = True + await self.gateway._send(Message.CHANNEL_LAST_MESSAGE, self.id) + + async def aclose(self, error: str | None = None) -> None: + """Close both directions; ``error`` reaches the peer as a RemoteError.""" + if self._closed: + await self._aio.checkpoint() + return + self._closed = True + self._payload_send.close() + self._receive_closed.set() + self.gateway._forget_channel(self.id) + if not self._remote_closed: + # A peer-initiated close needs no reply; a dead gateway is + # already as closed as it gets. + with suppress(OSError): + if error is not None: + await self.gateway._send( + Message.CHANNEL_CLOSE_ERROR, self.id, dumps_internal(error) + ) + else: + await self.gateway._send(Message.CHANNEL_CLOSE, self.id) + + def __aiter__(self) -> RawChannel: + return self + + async def __anext__(self) -> bytes: + try: + return await self.receive_bytes() + except EOFError: + raise StopAsyncIteration from None + + def _pending_error(self) -> BaseException: + return ( + self._remote_error + or self.gateway._error + or EOFError(f"raw channel {self.id} closed") + ) + + def set_consumer( + self, + on_payload: Callable[[bytes], None], + on_close: Callable[[RemoteError | None, bool], None], + ) -> None: + """Divert inbound payloads and the close to callbacks (loop thread). + + Already-buffered payloads flush to ``on_payload`` first, and a close + that arrived before binding is replayed to ``on_close`` -- so a + consumer bound late (facade channels bind via a portal post) sees + the exact inbound order. + """ + self._consumer_payload = on_payload + self._consumer_close = on_close + while True: + try: + data = self._payloads.receive_nowait() + except self._aio.CHANNEL_UNUSABLE: + break + on_payload(data) + if self._pending_close is not None: + error, sendonly = self._pending_close + self._pending_close = None + on_close(error, sendonly) + if not sendonly: + # the late binder has now claimed the buffered close + self.gateway._forget_channel(self.id) + + # dispatch-loop internals (inline on the gateway's serve task) + + def _feed(self, data: bytes) -> None: + if self._consumer_payload is not None: + self._consumer_payload(data) + return + try: + self._payload_send.send_nowait(data) + except self._aio.STREAM_GONE: + pass # locally closed: drop, like the sync channel + + def _close_from_remote(self, error: RemoteError | None, *, sendonly: bool) -> None: + if error is not None: + self._remote_error = error + self._remote_closed = True + self._receive_closed.set() + if not sendonly: + self._closed = True + # Nothing more can arrive for this id -- the peer closed it, and + # ids step by two per side and are never reused -- so the + # registry has no routing left to do. Dropping it here is what + # keeps a long-lived gateway from accumulating one dead channel + # per remote_exec; the object itself lives as long as whoever + # holds it, buffered payloads and all. + # + # Unless nobody holds it: a channel the local side has never + # asked for exists *only* in the registry, and a passed-channel + # reference may still bind to it late. That one has to find the + # buffered payloads and this close, not a fresh empty channel + # under the same id, so it stays until it is claimed. + if self._consumer_close is not None or self._handed_out: + self.gateway._forget_channel(self.id) + self._payload_send.close() + if self._consumer_close is not None: + self._consumer_close(error, sendonly) + else: + self._pending_close = (error, sendonly) + + +class RawChannelStream: + """``ByteStream`` over a :class:`RawChannel` -- the frame-native via tunnel. + + The gateway writer performs one ``send_all`` per frame, so every raw + payload carries exactly one whole sub-protocol frame (the relay + keeps that invariant in the other direction). ``receive_some`` buffers + payloads and honours ``max_bytes`` for the handshake read. + """ + + def __init__(self, raw: RawChannel) -> None: + self._raw = raw + self._buf = bytearray() + self._eof = False + + async def send_all(self, data: bytes) -> None: + await self._raw.send_bytes(data) + + async def receive_some(self, max_bytes: int | None = None) -> bytes: + if not self._buf and not self._eof: + try: + self._buf += await self._raw.receive_bytes() + except EOFError: + self._eof = True + except RemoteError as exc: + self._eof = True + raise EOFError(f"via tunnel closed: {exc}") from None + if max_bytes is None: + max_bytes = len(self._buf) + out = bytes(self._buf[:max_bytes]) + del self._buf[:max_bytes] + return out + + async def send_eof(self) -> None: + with suppress(OSError): + await self._raw.send_eof() + + async def aclose(self) -> None: + await self._raw.aclose() + + +class AsyncChannel: + """Serialized object API over a raw byte channel. + + Every payload is one dumps/loads-serialized item; close/EOF semantics + and error propagation come from the raw layer. Channels are + async-iterable, and :meth:`receive` supports the familiar execnet + timeout (raising ``TimeoutError``). + + Channel objects themselves serialize: sending an AsyncChannel inside an + item transfers a reference the peer receives as its own AsyncChannel + for the same id (the wire CHANNEL opcode, as with sync channels). + """ + + RemoteError = RemoteError + TimeoutError = TimeoutError + + def __init__(self, raw: RawChannel) -> None: + self._raw = raw + self.gateway = raw.gateway + self.id = raw.id + + def __repr__(self) -> str: + flag = "closed" if self.isclosed() else "open" + return f"" + + def isclosed(self) -> bool: + """Return True if the channel is closed for sending.""" + return self._raw._closed + + async def send(self, item: SendPayload) -> None: + """Serialize ``item`` and send it to the other side. + + The item must be a simple Python type; OSError is raised when the + channel or gateway is closed. + """ + if self.isclosed(): + raise OSError(f"cannot send to {self!r}") + await self._raw.send_bytes(dumps_internal(item)) + + async def receive(self, timeout: float | None = None) -> Payload[AsyncChannel]: + """Receive the next item sent from the other side. + + Raises EOFError once the peer closed or sent EOF, a RemoteError for + a peer close-with-error, and TimeoutError if no item arrived within + ``timeout`` seconds. + """ + if timeout is None: + data = await self._raw.receive_bytes() + else: + try: + with self._raw._aio.fail_after(timeout): + data = await self._raw.receive_bytes() + except self._raw._aio.TooSlow: + raise TimeoutError("no item after %r seconds" % timeout) from None + return loads_internal(data, self) + + async def send_eof(self) -> None: + """Signal that no more items follow (peer keeps its send side).""" + await self._raw.send_eof() + + async def aclose(self, error: str | None = None) -> None: + """Close the channel; ``error`` reaches the peer as a RemoteError.""" + await self._raw.aclose(error) + + async def wait_closed(self) -> None: + """Wait until the peer closed or sent EOF; reraise remote errors.""" + await self._raw._receive_closed.wait() + error = self._raw._remote_error or self.gateway._error + if error is not None: + raise error + + def __aiter__(self) -> AsyncChannel: + return self + + async def __anext__(self) -> Payload[AsyncChannel]: + try: + return await self.receive() + except EOFError: + raise StopAsyncIteration from None + + # Unserializer duck-type: loads_internal(data, self) reads + # _channelfactory off the object to resolve CHANNEL opcodes. + + @property + def _channelfactory(self) -> _AsyncChannelFactory: + return self.gateway._channelfactory + + +class _AsyncChannelFactory: + """Duck-typed factory for the Unserializer CHANNEL opcode (``.new(id)``).""" + + def __init__(self, gateway: AsyncGateway) -> None: + self.gateway = gateway + + def new(self, id: int) -> AsyncChannel: + return self.gateway.open_channel(id) + + +class AsyncGateway: + """Async-native gateway: the framed Message protocol over a ByteStream. + + Serving (``serve_gateway`` or ``nursery.start(gateway._serve)``) runs a + reader task that dispatches messages inline and a writer task draining + an unbounded outbound queue -- sends never block on the peer. + + ``_startcount`` follows the sync convention: locally allocated channel + ids step by two, coordinators from 1 (odd) and workers from 2 (even), + so the two peers never collide. + """ + + _error: BaseException | None = None + #: where the peer lives, when the transport knows (ssh host, socket addr) + remoteaddress: str | None = None + #: CHANNEL_EXEC handler ``(gateway, channelid, data) -> None`` -- set by + #: an exec strategy (the pure-async worker's TaskExec); without one, + #: exec requests are rejected. + _exec_handler: Callable[[AsyncGateway, int, bytes], None] | None = None + #: the exec strategy (for STATUS numexecuting), when serving as a worker + _task_exec: Any = None + #: ``(async_fn, *args) -> None`` spawning a worker-side service task -- + #: set by whichever worker entry point owns a nursery. Services are the + #: protocol requests a worker serves *itself*, as opposed to exec'd + #: code -- what each one does is none of this layer's business (see + #: :mod:`execnet._services`). Without a spawner they are rejected. + _service_spawn: Callable[..., None] | None = None + + def __init__(self, stream: ByteStream, *, id: str, _startcount: int = 1) -> None: + self._aio = current_async() + self._stream = stream + self.id = id + self._channels: dict[int, RawChannel] = {} + self._async_channels: dict[int, AsyncChannel] = {} + self._channelfactory = _AsyncChannelFactory(self) + self._count = _startcount + self._outbound_send, self._outbound = self._aio.queue() + self._closed = False + self._serve_started = False + self._writer_done = self._aio.event() + self._done = self._aio.event() + + def __repr__(self) -> str: + state = "closed" if self._closed else "open" + return f"" + + @property + def closed(self) -> bool: + return self._closed + + async def wait_closed(self) -> None: + """Wait until serving has fully shut down.""" + await self._done.wait() + + def _trace(self, *msg: object) -> None: + trace(self.id, *msg) + + def _open_raw_channel(self, id: int | None = None) -> RawChannel: + """Return the raw channel for ``id``, allocating a fresh id if None. + + An explicit id attaches to a channel the peer references (e.g. an id + received in a request payload); the same object is returned if the + dispatch loop already routed data to it. + """ + if self._closed: + raise OSError(f"connection already closed: {self!r}") + if id is None: + id = self._count + self._count += 2 + channel = self._channel_for(id) + channel._handed_out = True + return channel + + def open_channel(self, id: int | None = None) -> AsyncChannel: + """Return the serialized channel for ``id``, allocating one if None.""" + raw = self._open_raw_channel(id) + try: + return self._async_channels[raw.id] + except KeyError: + channel = self._async_channels[raw.id] = AsyncChannel(raw) + return channel + + async def remote_exec( + self, + source: str | types.FunctionType | Callable[..., object] | types.ModuleType, + **kwargs: SendPayload, + ) -> AsyncChannel: + """Connect a new channel to remote execution of ``source``. + + Accepts the same source kinds as the sync ``Gateway.remote_exec``: + a source string, a pure function called with ``channel`` and + ``**kwargs``, or a module. The remote end closes the channel when + execution finishes. + """ + source, file_name, call_name = normalize_exec_source(source, kwargs) + channel = self.open_channel() + await self._send( + Message.CHANNEL_EXEC, + channel.id, + dumps_internal((source, file_name, call_name, kwargs)), + ) + return channel + + async def terminate(self) -> None: + """Send GATEWAY_TERMINATE to the peer, then close this side.""" + if not self._closed: + with suppress(OSError): + await self._send(Message.GATEWAY_TERMINATE) + await self.aclose() + + async def aclose(self) -> None: + """Flush queued frames, close the stream, and wait for shutdown.""" + if not self._closed: + self._closed = True + self._outbound_send.close() + with self._aio.move_on_after(5): + await self._writer_done.wait() + with self._aio.shielded(), suppress(Exception): + await self._stream.aclose() + if self._serve_started: + await self._done.wait() + else: + self._finish_channels() + + async def _serve(self, task_status: Any = None) -> None: + """Run the reader and writer until EOF, termination, or ``aclose``.""" + self._serve_started = True + try: + async with self._aio.task_scope() as scope: + scope.start_soon(self._writer) + if task_status is not None: + task_status.started() + await self._reader() + # Reader is done: let the writer flush queued frames + # (close replies), then stop it. + self._closed = True + self._outbound_send.close() + with self._aio.move_on_after(5): + await self._writer_done.wait() + scope.cancel() + finally: + self._closed = True + self._outbound_send.close() + try: + await self._finalize() + finally: + with self._aio.shielded(), suppress(Exception): + await self._stream.aclose() + self._done.set() + + async def _finalize(self) -> None: + """Serve-shutdown hook: release the channel layer. + + The sync facade overrides this to close its sync channels and shut + down execution instead. + """ + self._finish_channels() + + async def _reader(self) -> None: + decoder = FrameDecoder() + try: + while True: + try: + data = await self._stream.receive_some(RECEIVE_CHUNK) + except self._aio.BrokenResource as exc: + # A peer that died abruptly *resets* a socket -- Windows + # reports WSAECONNRESET -- where a pipe would simply have + # reached EOF. Same event, so report the same thing: + # callers test for EOFError, and an endmarker callback + # must fire either way. + if not self._closed: + error = EOFError(f"connection closed: {exc}") + error.__cause__ = exc + self._error = error + return + except self._aio.ClosedResource as exc: + # we closed it; not the peer going away + if not self._closed: + self._error = exc + return + if not data: + decoder.close() # raises EOFError on a mid-frame EOF + raise EOFError("connection closed (no gateway termination)") + for message in decoder.feed(data): + self._trace("received", message) + self._dispatch(message) + except GatewayReceivedTerminate: + self._trace("received GATEWAY_TERMINATE") + except EOFError as exc: + self._trace("EOF without prior gateway termination message") + self._error = exc + + async def _writer(self) -> None: + error: BaseException | None = None + try: + async for frame, on_written in self._outbound: + try: + await self._stream.send_all(frame) + except BaseException as exc: + error = exc + if on_written is not None: + on_written(exc) + raise + if on_written is not None: + on_written(None) + except (*self._aio.STREAM_GONE, OSError) as exc: + self._trace("writer failed", exc) + if self._error is None: + self._error = exc + else: + # Queue closed and drained: signal write-EOF to the peer. + with suppress(Exception): + await self._stream.send_eof() + finally: + # No more writes will happen: fail queued frames instead of + # leaving their senders waiting on acknowledgements. + self._outbound_send.close() + self._fail_pending_writes(error) + self._writer_done.set() + + def _fail_pending_writes(self, error: BaseException | None) -> None: + exc = error if error is not None else OSError("cannot send (already closed?)") + while True: + try: + _frame, on_written = self._outbound.receive_nowait() + except self._aio.CHANNEL_UNUSABLE: + return + if on_written is not None: + on_written(exc) + + def _dispatch(self, message: Message) -> None: + """Route one message; runs inline on the serve task.""" + code = message.msgcode + channelid = message.channelid + if code == Message.CHANNEL_DATA: + self._channel_for(channelid)._feed(message.data) + elif code == Message.CHANNEL_CLOSE: + self._channel_for(channelid)._close_from_remote(None, sendonly=False) + elif code == Message.CHANNEL_CLOSE_ERROR: + error_message = loads_internal(message.data) + assert isinstance(error_message, str) + self._channel_for(channelid)._close_from_remote( + RemoteError(error_message), sendonly=False + ) + elif code == Message.CHANNEL_LAST_MESSAGE: + self._channel_for(channelid)._close_from_remote(None, sendonly=True) + elif code == Message.GATEWAY_TERMINATE: + raise GatewayReceivedTerminate(self) + elif code == Message.CHANNEL_EXEC and self._exec_handler is not None: + self._exec_handler(self, channelid, message.data) + elif code == Message.GATEWAY_SERVICE and self._service_spawn is not None: + self._spawn_service(channelid, message.data) + elif code == Message.STATUS: + task_exec = self._task_exec + status = { + "numchannels": len(self._channels), + "numexecuting": task_exec.active_count() if task_exec else 0, + # tasks on the worker's own loop: no thread budget to run out + # of, so nothing is refused for capacity here + "execcapacity": None, + "profile": "trio", + # legacy key, same value -- pytest-xdist reads it + "execmodel": "trio", + } + self._send_nowait(Message.CHANNEL_DATA, channelid, dumps_internal(status)) + self._send_nowait(Message.CHANNEL_CLOSE, channelid) + elif code == Message.GATEWAY_INFO: + self._send_nowait( + Message.CHANNEL_DATA, channelid, dumps_internal(gateway_info()) + ) + self._send_nowait(Message.CHANNEL_CLOSE, channelid) + else: + # CHANNEL_EXEC / GATEWAY_START_*: not served by the async core + self._trace("rejecting unsupported message", message) + self._send_nowait( + Message.CHANNEL_CLOSE_ERROR, + channelid, + dumps_internal(f"unsupported message on async gateway: {message!r}"), + ) + + def _spawn_service(self, channelid: int, data: bytes) -> None: + """Run the service a ``GATEWAY_SERVICE`` request names (loop thread). + + Resolution failures are reported on the request's channel: a + coordinator asking for a service this worker does not have is + usually a version skew, and it should hear that rather than an + unexplained close. + """ + assert self._service_spawn is not None + from . import _services + + name, request = cast("tuple[str, Payload]", loads_internal(data)) + try: + handler = _services.resolve(name) + except LookupError as exc: + self._trace("rejecting unknown service", name) + self._send_nowait( + Message.CHANNEL_CLOSE_ERROR, channelid, dumps_internal(str(exc)) + ) + return + self._service_spawn(handler, self, channelid, request) + + def _channel_for(self, id: int) -> RawChannel: + try: + return self._channels[id] + except KeyError: + channel = self._channels[id] = RawChannel(self, id) + return channel + + def _forget_channel(self, id: int) -> None: + self._channels.pop(id, None) + self._async_channels.pop(id, None) + + async def _send(self, msgcode: int, channelid: int = 0, data: bytes = b"") -> None: + # The queue is unbounded, so this never waits on the peer -- but it + # is a real checkpoint and raises once the gateway is closed. + try: + await self._outbound_send.send( + (Message(msgcode, channelid, data).pack(), None) + ) + except self._aio.STREAM_GONE as exc: + raise OSError("cannot send (already closed?)") from exc + + def _enqueue_frame( + self, + frame: bytes, + on_written: Callable[[BaseException | None], None] | None = None, + ) -> None: + """Queue one wire frame (sync, loop thread only). + + ``on_written`` fires exactly once: after the frame reached the OS + write, or with the failure when it never will. Raises OSError when + the outbound side is already closed. + """ + try: + self._outbound_send.send_nowait((frame, on_written)) + except self._aio.STREAM_GONE as exc: + raise OSError("cannot send (already closed?)") from exc + + def _send_nowait(self, msgcode: int, channelid: int = 0, data: bytes = b"") -> None: + """Enqueue a frame from a dispatch handler (sync, inline on the loop).""" + with suppress(OSError): + self._enqueue_frame(Message(msgcode, channelid, data).pack()) + + def _finish_channels(self) -> None: + for channel in list(self._channels.values()): + channel._close_from_remote(None, sendonly=True) + self._channels.clear() + self._async_channels.clear() + + +@asynccontextmanager +async def serve_gateway( + stream: ByteStream, *, id: str, _startcount: int = 1 +) -> AsyncIterator[AsyncGateway]: + """Serve an :class:`AsyncGateway` over ``stream`` for the ``with`` body.""" + gateway = AsyncGateway(stream, id=id, _startcount=_startcount) + async with current_async().task_scope() as scope: + await scope.start(gateway._serve) + try: + yield gateway + finally: + await gateway.aclose() + + +#: how long to wait for an ssh worker to dial back before giving up +SSH_CONNECT_TIMEOUT = 60.0 + + +def _ssh_argv( + spec: XSpec, command: str, forward: tuple[str, str] | None = None +) -> list[str]: + """ssh/vagrant argv running ``command``, optionally with a ``-R`` forward. + + ``forward`` is ``(remote_socket, local_socket)``. ``StreamLocalBindUnlink`` + makes sshd replace a stale socket file rather than refuse to bind. + """ + from . import _provision + + options: list[str] = [] + if forward is not None: + remote_sock, local_sock = forward + options += ["-R", f"{remote_sock}:{local_sock}"] + options += ["-o", "StreamLocalBindUnlink=yes"] + if spec.ssh is not None: + return _provision.ssh_argv(spec.ssh, spec.ssh_config, command, options) + assert spec.vagrant_ssh is not None + return _provision.vagrant_ssh_argv( + spec.vagrant_ssh, spec.ssh_config, command, options + ) + + +def ssh_transport_args(spec: XSpec) -> list[str]: + """ssh argv for a stdio-transport ssh worker.""" + from . import _provision + + assert spec.ssh is not None + return _ssh_argv(spec, _provision.ssh_remote_command(spec)) + + +def vagrant_transport_args(spec: XSpec) -> list[str]: + """vagrant-ssh argv for a stdio-transport vagrant_ssh worker.""" + from . import _provision + + assert spec.vagrant_ssh is not None + return _ssh_argv(spec, _provision.ssh_remote_command(spec)) + + +@asynccontextmanager +async def _dialback_listener() -> AsyncIterator[tuple[str, Any]]: + """A private unix socket the worker will be forwarded back to.""" + import shutil + import tempfile + + aio = current_async() + directory = tempfile.mkdtemp(prefix="execnet-dialback-") + os.chmod(directory, 0o700) + path = os.path.join(directory, "gw.sock") + listener = await aio.unix_listener(path) + try: + yield path, listener + finally: + with aio.shielded(), suppress(Exception): + await listener.aclose() + shutil.rmtree(directory, ignore_errors=True) + + +async def _accept_or_diagnose( + listener: Any, + process: Any, + remoteaddress: str, +) -> Any: + """Await the worker's dial-back, or explain why it never came. + + With the stdio transport a dead ssh shows up as EOF on the protocol + pipe. Here there is no such pipe, so the accept is raced against the + process exiting -- ssh's 255 still means "could not reach or + authenticate the host". + """ + aio = current_async() + accepted: list[Any] = [] + exited: list[int] = [] + + async def accept(scope: Any) -> None: + accepted.append(await listener.accept()) + scope.cancel() + + async def watch(scope: Any) -> None: + exited.append(await process.wait()) + scope.cancel() + + with aio.move_on_after(SSH_CONNECT_TIMEOUT): + async with aio.task_scope() as scope: + scope.start_soon(accept, scope) + scope.start_soon(watch, scope) + if accepted: + return accepted[0] + with aio.shielded(), aio.move_on_after(5): + process.kill() + await process.wait() + if exited and exited[0] == 255: + raise HostNotFound(remoteaddress) + if exited: + raise EOFError( + f"ssh worker exited with {exited[0]} before connecting back" + f" to {remoteaddress}" + ) + raise EOFError( + f"ssh worker did not connect back within {SSH_CONNECT_TIMEOUT}s" + f" ({remoteaddress})" + ) + + +async def connect_ssh_worker(spec: XSpec) -> tuple[ByteStream, Any]: + """Spawn an ssh/vagrant worker on the transport ``spec`` resolves to. + + ``transport=socket`` forwards a private unix socket to the remote with + ``ssh -R`` and lets the worker dial back on it, which leaves the + worker's stdin, stdout and stderr free for the code it runs. The + config travels on the dialled-back connection like everywhere else. + """ + aio = current_async() + from . import _provision + + remoteaddress = spec.ssh or spec.vagrant_ssh + assert remoteaddress is not None + wheel = await provision_sync(_provision.ssh_wheel, spec) + if wheel is not None: + await deliver_remote_wheel(spec, wheel) + + dialback = _provision.resolve_transport( + spec, available=_provision.ssh_dialback_available() + ) + if dialback == "stdio": + args = await provision_sync( + ssh_transport_args if spec.ssh is not None else vagrant_transport_args, + spec, + ) + return await connect_command_worker(args, spec, remoteaddress=remoteaddress) + + async with _dialback_listener() as (local_sock, listener): + remote_sock = f"/tmp/execnet-{uuid.uuid4().hex}.sock" + command = await provision_sync( + _provision.ssh_remote_command, + spec, + "--protocol-connect", + f"unix:{remote_sock}", + ) + argv = _ssh_argv(spec, command, forward=(remote_sock, local_sock)) + # stdin closed, stdout/stderr inherited: the remote's stdio is the + # user's now, and nothing of ours travels on it. + process = await aio.open_process(argv, stdin=subprocess.DEVNULL) + try: + stream = await _accept_or_diagnose(listener, process, remoteaddress) + await configure_worker(stream, spec, "ssh") + except BaseException: + with aio.shielded(), aio.move_on_after(5): + process.kill() + await process.wait() + raise + return stream, process + + +async def deliver_remote_wheel(spec: XSpec, wheel: Any) -> None: + """Ship ``wheel`` to the remote over its own connection, before launching. + + Out of band on purpose: the protocol stream never carries a payload, so + the launch command needs no ``head -c `` byte accounting and no + ``exec`` to keep an fd alive. Skipped remote-side when the file is + already cached there. + """ + from . import _provision + + aio = current_async() + argv = _ssh_argv(spec, _provision.wheel_delivery_command(wheel)) + process = await aio.open_process(argv, stdin=subprocess.PIPE) + try: + assert process.stdin is not None + await process.stdin.send_all(wheel.read_bytes()) + await process.stdin.aclose() + code = await process.wait() + except BaseException: + with aio.shielded(), aio.move_on_after(5): + process.kill() + await process.wait() + raise + if code != 0: + raise HostNotFound( + f"could not deliver the execnet wheel to {spec.ssh or spec.vagrant_ssh}" + f" (exit {code})" + ) + + +async def connect_command_worker( + args: list[str], + spec: XSpec | None = None, + *, + remoteaddress: str | None = None, +) -> tuple[ByteStream, Any]: + """Spawn ``args`` and configure the worker over its stdio. + + The stdio transport: the config frame and the protocol share the one + stream, which is what a plain ``python -m execnet worker`` reads. With + a ``remoteaddress``, a handshake EOF plus exit code 255 (ssh could not + reach or authenticate the host) becomes :class:`HostNotFound`. + """ + aio = current_async() + process = await open_popen_process(args) + try: + stream = staple_process_stream(process) + await configure_worker(stream, spec, "bootstrap") + except BaseException as exc: + host_not_found = False + with aio.shielded(): + if isinstance(exc, EOFError) and remoteaddress is not None: + with aio.move_on_after(5): + host_not_found = await process.wait() == 255 + with aio.move_on_after(5): + process.kill() + await process.wait() + if host_not_found: + assert remoteaddress is not None + raise HostNotFound(remoteaddress) from None + raise + return stream, process + + +#: config key carrying a ``socket.share()`` blob to a ``--protocol-share`` +#: worker, base64 encoded because the config is JSON. +SHARE_KEY = "protocol_share" + + +def share_socket(sock: Any, pid: int) -> str: + """``socket.share(pid)`` as a base64 string for the worker config.""" + import base64 + + return base64.b64encode(sock.share(pid)).decode("ascii") + + +async def _spawn_with_socket(spec: XSpec, theirs: Any) -> Any: + """Spawn a worker owning ``theirs``, by whichever handoff this OS has. + + POSIX passes the fd itself. Windows cannot -- ``subprocess`` refuses + ``pass_fds`` there -- so the socket is duplicated into the child with + ``WSADuplicateSocket``. That needs the child's pid, so it can only + happen once the child exists: the flag goes in argv, the blob follows on + stdin. It is the one thing that cannot travel in the config frame, + since it describes the very connection that frame would arrive on. + """ + aio = current_async() + from . import _provision + + if not _provision.socket_share_required(): + args = await provision_sync( + popen_worker_argv, spec, "--protocol-fd", str(theirs.fileno()) + ) + return await aio.open_process(args, pass_fds=(theirs.fileno(),)) + + args = await provision_sync( + popen_worker_argv, spec, "--protocol-share", local_config_on_stdin=True + ) + process = await aio.open_process(args, stdin=subprocess.PIPE) + try: + assert process.stdin is not None + await process.stdin.send_all( + dumps_config({SHARE_KEY: share_socket(theirs, process.pid)}) + ) + await process.stdin.aclose() + except BaseException: + with aio.shielded(), suppress(Exception): + process.kill() + await process.wait() + raise + return process + + +def dumps_config(config: dict[str, Any]) -> bytes: + """A transport's local config as the bytes a ``--config-fd`` worker reads.""" + import json + + return json.dumps(config).encode("utf-8") + + +async def connect_popen_worker(spec: XSpec) -> tuple[ByteStream, Any]: + """Spawn a local worker for ``spec`` on its resolved transport. + + With ``transport=socket`` the protocol runs over an inherited + socketpair, so the child's stdin/stdout/stderr stay the user's: remote + ``print()`` reaches the terminal instead of being swallowed to keep the + wire clean. ``transport=stdio`` is the classic pipe pair. + """ + aio = current_async() + from . import _provision + + transport = _provision.resolve_transport( + spec, available=_provision.socket_handoff_available() + ) + if transport == "stdio": + return await connect_command_worker( + await provision_sync(popen_worker_argv, spec), spec + ) + + import socket as _socket + + ours, theirs = _socket.socketpair() + try: + process = await _spawn_with_socket(spec, theirs) + except BaseException: + ours.close() + theirs.close() + raise + # Our copy has to go now, whichever handoff was used: while we hold it, + # a worker that dies before the handshake leaves the pair open and the + # read below waits forever instead of failing. (Holding it until the + # handshake was tried, as a fix for a Windows share() race -- it fixed + # nothing and bought exactly that hang.) + theirs.close() + stream = await aio.wrap_socket(ours) + try: + await configure_worker(stream, spec, "bootstrap") + except BaseException as exc: + with aio.shielded(): + status: int | None = None + with aio.move_on_after(5): + process.kill() + status = await process.wait() + await stream.aclose() + if status is not None: + # what the worker did with itself is the whole diagnosis + # when it never reached the handshake + raise EOFError( + f"worker exited with {status} before the handshake: {exc}" + ) from exc + raise + return stream, process + + +async def connect_socket_worker( + address: tuple[str, int], remoteaddress: str, spec: XSpec | None = None +) -> ByteStream: + """Connect to a running socketserver and configure the worker it spawns. + + The worker is spawned by the *server* and inherits this connection, so + the config frame reaches it exactly as it would any other worker -- the + server neither reads it nor needs to know what is in it. + """ + aio = current_async() + try: + stream = await aio.open_tcp_stream(*address) + except OSError as exc: + raise HostNotFound(remoteaddress) from exc + try: + await configure_worker(stream, spec, "socket") + except BaseException: + with aio.shielded(), aio.move_on_after(5): + await stream.aclose() + raise + connected: ByteStream = stream + return connected + + +async def start_socketserver_via( + gateway: AsyncGateway, bind_host: str = "localhost" +) -> tuple[str, int]: + """Ask ``gateway`` (protocol message) to start a one-shot socket listener. + + Returns the ``(host, port)`` the coordinator should connect to. + """ + channel = gateway.open_channel() + await gateway._send( + Message.GATEWAY_START_SOCKET, channel.id, dumps_internal(bind_host) + ) + realhost, realport = cast("tuple[str, int]", await channel.receive()) + await channel.wait_closed() + if not realhost or realhost in ("0.0.0.0", "::"): + realhost = "localhost" + return realhost, int(realport) + + +class AsyncGroup: + """Trio-native group: an async context manager owning the gateway nursery. + + Gateways created with :meth:`makegateway` are served as child tasks of + the group's nursery. Leaving the ``async with`` block terminates every + gateway with the safe_terminate contract: GATEWAY_TERMINATE plus a + ``timeout`` grace, then kill -- bounded at roughly twice the timeout + even when a kill gets stuck (see issues #43 / #221). + """ + + def __init__(self, termination_timeout: float = 10.0) -> None: + self._termination_timeout = termination_timeout + #: the loop's vocabulary, captured when the group is entered + self._aio: Any = None + self._scope: Any | None = None + self._gateways: list[AsyncGateway] = [] + self._processes: dict[AsyncGateway, Any] = {} + # Monotonic, not len(self._gateways): terminate() empties that list, + # and an id that comes round again names two different workers in one + # session's traces (and in whatever the caller keyed on it). + self._idcount = 0 + + def __repr__(self) -> str: + ids = [gateway.id for gateway in self._gateways] + return f"" + + async def __aenter__(self) -> Self: + self._aio = current_async() + self._scope = self._aio.task_scope() + await self._scope.__aenter__() + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: types.TracebackType | None, + ) -> bool | None: + # The serve tasks only end once their gateways shut down, so + # terminate before letting the nursery join its children. + # Shielded: cleanup stays bounded even under cancellation. + terminate_error: BaseException | None = None + try: + with self._aio.shielded(): + await self.terminate(self._termination_timeout) + except BaseException as error: + terminate_error = error + scope, self._scope = self._scope, None + assert scope is not None + suppress_body_exc: bool | None = await scope.__aexit__( + exc_type, exc_value, traceback + ) + if terminate_error is not None: + raise terminate_error + return suppress_body_exc + + async def makegateway(self, spec: str | XSpec = "popen") -> AsyncGateway: + """Create a gateway for ``spec`` served on the group's nursery. + + All transport types are supported: popen (including uv-provisioned + ``python=``), ``ssh=``, ``vagrant_ssh=``, ``socket=`` (with + ``installvia=``), and ``via=`` sub-gateways relayed through a group + member. + """ + from ._xspec import XSpec + + if self._scope is None: + raise RuntimeError(f"{self!r} is not entered") + if not isinstance(spec, XSpec): + spec = XSpec(spec) + if spec.profile is None: + # An async coordinator does not imply an async worker: the + # worker's shape is its own choice, and the default stays the + # thread profile. Pass ``profile=trio`` for a worker that runs + # exec'd async sources as tasks. + spec.profile = "thread" + else: + resolve_profile(spec.profile) + if spec.id is None: + spec.id = "gw%d" % self._idcount + self._idcount += 1 + process: Any | None = None + remoteaddress: str | None = None + if spec.via: + stream: ByteStream = await self._open_via_stream(spec) + remote = spec.ssh or spec.vagrant_ssh + if remote: + remoteaddress = f"{remote}[via {spec.via}]" + elif spec.socket: + address, remoteaddress = await self._resolve_socket_address(spec) + stream = await connect_socket_worker(address, remoteaddress, spec) + elif spec.ssh or spec.vagrant_ssh: + remoteaddress = spec.ssh or spec.vagrant_ssh + stream, process = await connect_ssh_worker(spec) + elif spec.popen or spec.python: + stream, process = await connect_popen_worker(spec) + else: + raise ValueError(f"unsupported spec for AsyncGroup: {spec!r}") + # From here to the registration below, the worker is running but + # nothing owns it yet: a failure (a cancel, most likely -- there is + # not much else) would leave a process the group never terminates. + # The connect helpers each clean up after themselves the same way; + # this is the seam between them and the group. + try: + gateway = self._make_gateway(stream, spec) + gateway.remoteaddress = remoteaddress + await self._scope.start(gateway._serve) + except BaseException: + with self._aio.shielded(): + await self._abandon(stream, process) + raise + self._gateways.append(gateway) + if process is not None: + self._processes[gateway] = process + self._scope.start_soon(self._reap_process, process) + return gateway + + async def _abandon(self, stream: ByteStream, process: Any | None) -> None: + """Drop a worker nobody took ownership of (best effort, bounded).""" + with suppress(Exception): + await stream.aclose() + if process is not None: + with self._aio.move_on_after(5), suppress(Exception): + process.kill() + await process.wait() + + def _make_gateway(self, stream: ByteStream, spec: XSpec) -> AsyncGateway: + """Construct the gateway object for a freshly connected stream. + + Overridden by the sync facade to build bridge gateways instead. + """ + # settled by makegateway before it dispatches to any transport + assert spec.id is not None + return AsyncGateway(stream, id=spec.id, _startcount=1) + + async def _reap_process(self, process: Any) -> None: + # Prompt reaping for workers that exit on their own (no zombies). + with suppress(Exception): + await process.wait() + + async def _resolve_socket_address(self, spec: XSpec) -> tuple[tuple[str, int], str]: + """``((host, port), remoteaddress)`` for a ``socket=`` spec. + + ``installvia=`` asks that group member to start a one-shot + socketserver first. The sync facade overrides this to talk to its + sync coordinator gateway. + """ + if spec.installvia: + coordinator = self._gateway_by_id(spec.installvia) + realhost, realport = await start_socketserver_via(coordinator) + return (realhost, realport), "%s:%d" % (realhost, realport) + assert spec.socket is not None + host_str, _, port_str = spec.socket.rpartition(":") + return (host_str, int(port_str)), spec.socket + + async def _open_via_stream(self, spec: XSpec) -> ByteStream: + """Ask the ``spec.via`` coordinator to spawn a sub-worker; tunnel over a + raw channel (each payload one whole sub-protocol frame).""" + from . import _provision + + # only reached for a spec that named a via= coordinator + assert spec.via is not None + coordinator = self._gateway_by_id(spec.via) + raw = coordinator._open_raw_channel() + request = await provision_sync(_provision.spawn_request, spec) + await coordinator._send( + Message.GATEWAY_START_SUB, raw.id, dumps_internal(request) + ) + stream = RawChannelStream(raw) + # the sub's config goes down the tunnel, so the coordinator relaying + # it never sees this spec's env: values + await configure_worker(stream, spec, "via") + return stream + + def _gateway_by_id(self, id: str) -> AsyncGateway: + for gateway in self._gateways: + if gateway.id == id: + return gateway + raise KeyError(f"no gateway {id!r} in {self!r}") + + async def terminate(self, timeout: float | None = None) -> None: + """Terminate all gateways; never hangs (kill after ``timeout``). + + Tunneled (``via``) gateways go first so their termination frames + still travel through a live coordinator. + """ + gateways = list(self._gateways) + self._gateways.clear() + tunneled = [gw for gw in gateways if gw not in self._processes] + spawned = [gw for gw in gateways if gw in self._processes] + for batch in (tunneled, spawned): + if not batch: + continue + async with self._aio.task_scope() as scope: + for gateway in batch: + scope.start_soon(self._terminate_one, gateway, timeout) + + async def _terminate_one( + self, gateway: AsyncGateway, timeout: float | None + ) -> None: + grace = math.inf if timeout is None else timeout + await gateway.terminate() + process = self._processes.pop(gateway, None) + if process is None: + return + with self._aio.move_on_after(grace): + await process.wait() + if process.returncode is None: + process.kill() + with self._aio.move_on_after(grace): + await process.wait() + + +@asynccontextmanager +async def open_gateway(spec: str | XSpec = "popen") -> AsyncIterator[AsyncGateway]: + """Spawn one worker for ``spec`` and serve an AsyncGateway to it. + + Runs inside the caller's own trio run -- no host thread involved. + Convenience for a single-gateway :class:`AsyncGroup`. + """ + async with AsyncGroup() as group: + yield await group.makegateway(spec) diff --git a/src/execnet/_trio_host.py b/src/execnet/_trio_host.py new file mode 100644 index 00000000..80dbcff9 --- /dev/null +++ b/src/execnet/_trio_host.py @@ -0,0 +1,907 @@ +"""Trio engine thread for execnet Message-protocol IO. + +Coordinator and worker both run framed read/write loops here. +Sync Channel/Gateway APIs talk to this engine via thread-safe queues and +the engine's portal. +""" + +from __future__ import annotations + +import functools +import queue as _queue +import subprocess +import sys +import threading +import weakref +from collections.abc import Callable +from contextlib import suppress +from typing import TYPE_CHECKING +from typing import Any +from typing import TypeVar + +from ._async import current_async +from ._boundary import Flag +from ._channel import ENDMARKER +from ._channel import NO_ENDMARKER_WANTED +from ._channel import Endmarker +from ._errors import GatewayReceivedTerminate +from ._errors import LoopFinishedError +from ._errors import RemoteError +from ._execmodel import ExecModel +from ._execmodel import get_execmodel +from ._message import FrameDecoder +from ._message import Message +from ._message import gateway_info +from ._portal import OneShot +from ._serialize import dumps_internal +from ._serialize import loads_internal +from ._trace import trace +from ._trio_gateway import RECEIVE_CHUNK +from ._trio_gateway import AsyncGateway +from ._trio_gateway import AsyncGroup +from ._trio_gateway import ByteStream +from ._trio_gateway import RawChannelStream +from ._trio_gateway import configure_worker +from ._trio_gateway import open_popen_process +from ._trio_gateway import provision_sync +from ._trio_gateway import ssh_transport_args + +#: bound on how long the endmarker callback may run during engine shutdown +CONSUMER_ENDMARKER_GRACE = 10.0 + + +def _run_callback(callback: Callable[[Any], Any], data: bytes, channel: Any) -> None: + """Deserialize one payload and invoke the receiver callback (in a thread).""" + callback(loads_internal(data, channel)) + + +if TYPE_CHECKING: + import socket + + #: either engine; only ever used as an annotation here, and importing + #: the trio one would make this module need trio + from typing import Any as Engine + + from ._gateway import Gateway + from ._gateway_base import BaseGateway + from ._multi import Group + from ._xspec import XSpec + +T = TypeVar("T") + + +# Kept name: the ssh argv/preamble builder moved to the async core. +ssh_trio_args = ssh_transport_args + + +async def adopt_socket(sock: int | socket.socket) -> ByteStream: + """Worker side: wrap an inherited socket for the loop. + + Takes an fd or an already-built socket. Rebuilding one from its fd + makes the constructor *detect* family/type/proto by querying the + handle, which is not free and not universally reliable -- PyPy on + Windows raises ``WinError 10014`` doing it to a handle that arrived + from ``socket.fromshare()``. A caller holding a real socket should + hand it over rather than reduce it to an integer first. + + Writes nothing: by the time this runs the handshake is over (it + happened on this socket, before there was a loop), and a stray byte + here would be read as the start of a frame. + """ + import socket as _socket + + if isinstance(sock, int): + sock = _socket.socket(fileno=sock) + stream: ByteStream = await current_async().wrap_socket(sock) + return stream + + +class SyncIOHandle: + """What is left of the sync IO object a ``Gateway`` is built around. + + The Message IO itself belongs to the session, so the gateway's ``_io`` + is down to one live duty: ``Gateway.exit`` closing the write side. + Waiting for and killing the worker process is the async group's + (``AsyncGroup._terminate_one``), which is where the process handle is. + """ + + remoteaddress: str + + def __init__( + self, + execmodel: ExecModel, + session: SyncBridgeGateway, + *, + remoteaddress: str | None = None, + ) -> None: + self.execmodel = execmodel + self._session = session + if remoteaddress is not None: + self.remoteaddress = remoteaddress + + def read(self, numbytes: int) -> bytes: + raise RuntimeError("sync read not supported on Trio IO handle") + + def write(self, data: bytes) -> None: + raise RuntimeError("sync write not supported on Trio IO handle") + + def close_read(self) -> None: + return + + def close_write(self) -> None: + self._session.request_close_write() + + +class SyncBridgeGateway(AsyncGateway): + """Async engine serving a sync ``BaseGateway``. + + The reader/writer/framing machinery is inherited from + :class:`AsyncGateway`; dispatch is overridden to run the classic sync + ``Message`` handlers (channel queues, callbacks, exec scheduling) under + the gateway's receive lock instead of routing to raw channels. + + Foreign threads send through :meth:`enqueue_message`: every enqueue goes + through the portal so loop callbacks and threads land in one global FIFO, + and non-loop threads wait until the frame hit the OS write (120s -> + OSError) so an abrupt ``os._exit`` cannot drop already-"sent" data. + """ + + def __init__( + self, + stream: ByteStream, + *, + id: str, + sync_gateway: BaseGateway, + engine: Engine, + ) -> None: + super().__init__(stream, id=id) + self.sync_gateway = sync_gateway + self.engine = engine + # Services run as tasks on the engine's root nursery. The request's + # channel is the *coordinator's* id, which the sync factory here + # knows nothing about, so a service works on the async channel this + # session already routes to that id. + self._service_spawn = engine.start_soon + self._done_sync: OneShot[None] = OneShot(sync_gateway._new_wakener()) + self._send_closed = False + self._send_lock = threading.Lock() + # Attach before any serving can happen: the first inbound message + # may need to reply through gateway._send, which must already + # route to this session (not the sync IO stub). + sync_gateway._attach_trio_session(self) + + # -- engine hooks (run on the engine loop) -- + + def _dispatch(self, message: Message) -> None: + """Route one message: sync-facade concerns here, the rest to the core. + + CHANNEL_DATA/CLOSE/CLOSE_ERROR/LAST_MESSAGE fall through to the + async core, which routes them to the RawChannel whose consumer is + the bound sync ``Channel``. + """ + gateway = self.sync_gateway + code = message.msgcode + try: + if code == Message.STATUS: + self._answer_status(message) + elif code == Message.GATEWAY_INFO: + gateway._send( + Message.CHANNEL_DATA, + message.channelid, + dumps_internal(gateway_info()), + ) + gateway._send(Message.CHANNEL_CLOSE, message.channelid) + elif code == Message.CHANNEL_EXEC: + channel = gateway._channelfactory.new(message.channelid) + gateway._local_schedulexec(channel=channel, sourcetask=message.data) + elif code == Message.GATEWAY_START_SOCKET: + handle_start_socket(gateway, message.channelid, message.data) + elif code == Message.GATEWAY_START_SUB: + handle_start_sub(gateway, message.channelid, message.data) + else: + super()._dispatch(message) + except (GatewayReceivedTerminate, EOFError): + raise + except Exception as exc: + gateway._trace("dispatch failed:", gateway._geterrortext(exc)) + raise EOFError("error dispatching message") from exc + + def _answer_status(self, message: Message) -> None: + # we use the channelid to send back information + # but don't instantiate a channel object + gateway = self.sync_gateway + execpool = getattr(gateway, "_execpool", None) + d = { + "numchannels": len(gateway._channelfactory._channels), + "numexecuting": execpool.active_count() if execpool is not None else 0, + # how many concurrent execs this worker admits before refusing; + # answered here on the loop, where the thread limiter is readable + "execcapacity": execpool.capacity() if execpool is not None else 0, + "profile": gateway.execmodel.backend, + # legacy key, same value -- pytest-xdist reads it + "execmodel": gateway.execmodel.backend, + } + gateway._send(Message.CHANNEL_DATA, message.channelid, dumps_internal(d)) + gateway._send(Message.CHANNEL_CLOSE, message.channelid) + + async def _finalize(self) -> None: + gateway = self.sync_gateway + with self._send_lock: + self._send_closed = True + if gateway._error is None: + gateway._error = self._error + gateway._trace("[trio-bridge] finishing channels") + gateway._channelfactory._finished_receiving() + # EOF the loop-side raw channels that have no sync consumer + # (via-tunnel relays and readers blocked in receive_bytes). + self._finish_channels() + # Unblock the worker's join() before heavy exec-pool shutdown + # so the primary thread is not waiting on _done while terminate waits + # on the primary thread draining work. + self._done_sync.set(None) + if getattr(gateway, "_execpool", None) is None: + # a coordinator has no execution to shut down (its + # _terminate_execution is a no-op) and the thread hop is not free + return + gateway._trace("[trio-bridge] terminating execution") + # May sleep/SIGINT; keep it off the Trio scheduling thread. + await self._aio.to_thread(gateway._terminate_execution, abandon_on_cancel=True) + + # -- sync session interface (any thread) -- + + def bind_sync_channel(self, channel: Any) -> None: + """Route inbound data for ``channel.id`` to the sync channel. + + Callable from any thread: binding happens on the loop via the + portal so it cannot interleave with dispatch, and the raw channel + replays anything (payloads, a close) that arrived first. The loop + side only holds a weakref, preserving the factory's weak-registry + semantics (GC of the last user reference sends the close message); + channels with callbacks are kept alive by the factory instead. + """ + ref = weakref.ref(channel) + channelid = channel.id + with suppress(LoopFinishedError): + self.engine.portal.post(self._install_sync_consumer, ref, channelid) + + def _install_sync_consumer(self, ref: weakref.ref[Any], channelid: int) -> None: + """Route ``channelid``'s raw payloads/close to the sync channel (loop). + + Idempotent: re-binding to the same hooks just re-flushes the (empty) + raw buffer, so ``attach_consumer`` can call this itself instead of + depending on the separately-posted bind having run first. + """ + raw = self._channel_for(channelid) + raw.set_consumer( + functools.partial(self._sync_payload, ref), + functools.partial(self._sync_close, ref, channelid), + ) + + def _sync_payload(self, ref: weakref.ref[Any], data: bytes) -> None: + channel = ref() + if channel is not None: + channel._deliver_payload(data) + # dead ref: data for a deleted channel is dropped, like before + + def _sync_close( + self, + ref: weakref.ref[Any], + channelid: int, + error: RemoteError | None, + sendonly: bool, + ) -> None: + channel = ref() + if channel is None: + # channel already in "deleted" state + if error is not None: + error.warn() + self.sync_gateway._channelfactory._no_longer_opened(channelid) + return + channel._close_from_remote(error, sendonly=sendonly) + + def release_channel(self, channelid: int) -> None: + """Drop the loop-side raw channel for ``channelid`` (best-effort).""" + with suppress(LoopFinishedError): + self.engine.portal.post(self._forget_channel, channelid) + + # -- receiver callbacks: a consumer task per channel -- + + def attach_consumer( + self, + channel: Any, + callback: Callable[[Any], Any], + endmarker: Endmarker, + ) -> None: + """Switch ``channel`` to callback mode: a loop task drains it. + + The switch runs on the loop (so it cannot interleave with delivery): + it moves any already-buffered items into the task's inbox, points the + channel's delivery/close at that inbox, and starts the consumer task. + It deliberately does *not* touch the raw channel's consumer (the sync + payload/close hooks bound by :meth:`bind_sync_channel` stay in place); + delivery keeps flowing through ``Channel._deliver_payload`` / + ``_close_from_remote``, which divert to the inbox once ``_has_consumer`` + is set. Rebinding the raw channel here would race the still-queued + ``bind()`` post (``run_sync`` and ``run_sync_soon`` are not mutually + ordered) and could be clobbered back to the mailbox. + + The task is handed a strong reference to ``channel`` and thus keeps it + alive for as long as it consumes -- the channel's lifecycle is bound to + the task (and to GC once the stream closes), not to a registry. + """ + + def switch() -> None: + if not self.engine._on_engine_thread(): + # run_on_loop fell back to running us inline: the loop is + # gone, so no consumer task can ever drain this channel. + # Fail before touching the channel -- a half-switched channel + # loses its buffered items, refuses receive(), and leaves + # waitclose() waiting for a consumer that will never run. + raise OSError( + f"cannot set callback on {channel!r}: the engine loop has" + " stopped, so nothing can deliver to it" + ) + mailbox = channel._mailbox + if mailbox is None: + raise OSError(f"{channel!r} has callback already registered") + inbox_send, inbox_recv = self._aio.queue() + + def feed(data: bytes) -> None: + with suppress(*self._aio.STREAM_GONE): + inbox_send.send_nowait(data) + + def close_inbox() -> None: + with suppress(self._aio.ClosedResource): + inbox_send.close() + + # Drain items buffered before the switch into the task's inbox, + # preserving order. An ENDMARKER means the channel already closed. + saw_end = False + while True: + try: + item = mailbox.get_nowait() + except _queue.Empty: + break + if item is ENDMARKER: + saw_end = True + break + inbox_send.send_nowait(item) + channel._mailbox = None + done = Flag(channel.gateway._new_wakener()) + channel._consumer_done = done + channel._consumer_feed = feed + channel._consumer_close_inbox = close_inbox + + def stop() -> None: + # thread-safe: end the task's inbox from any thread (local close) + with suppress(LoopFinishedError, self._aio.ClosedResource): + self.engine.portal.post(inbox_send.close) + + channel._consumer_stop = stop + channel._has_consumer = True + + # Guarantee the raw channel routes to us right now (idempotent with + # the bind posted at newchannel()): otherwise, if that bind has not + # run yet, inbound payloads would buffer unread in the raw channel + # and the consumer task would wait forever. + self._install_sync_consumer(weakref.ref(channel), channel.id) + + if saw_end: + close_inbox() + self.engine.start_soon( + self._run_consumer, channel, inbox_recv, callback, endmarker, done + ) + + self.run_on_loop(switch) + + async def _run_consumer( + self, + channel: Any, + inbox: Any, + callback: Callable[[Any], Any], + endmarker: Endmarker, + done: Flag, + ) -> None: + """Drain ``inbox`` into ``callback`` (each call off the loop thread). + + Runs on the engine loop; ``channel`` is held for the task's lifetime so + the channel stays alive while consuming. Items are delivered in order + and each callback runs in a threadpool thread. On completion (EOF, + local close, or a raising callback) the endmarker fires and ``done`` + is set -- which is what ``waitclose()`` waits on. + """ + limiter = self.engine._limiter + try: + async for data in inbox: + try: + await self._aio.to_thread( + functools.partial(_run_callback, callback, data, channel), + limiter=limiter, + ) + except Exception as exc: + # a cancellation is a BaseException and propagates past + # here (engine shutdown); only a real callback/deserialize + # failure closes the channel with the error. + self._consumer_failed(channel, exc) + break + finally: + # Fire the endmarker and signal done even while the engine is + # torn down, but never let a stuck callback hang shutdown forever. + with self._aio.shielded(): + if endmarker is not NO_ENDMARKER_WANTED: + with ( + self._aio.move_on_after(CONSUMER_ENDMARKER_GRACE), + suppress(BaseException), + ): + await self._aio.to_thread( + functools.partial(callback, endmarker), limiter=limiter + ) + done.set() + + def _consumer_failed(self, channel: Any, exc: BaseException) -> None: + """A callback (or its deserialization) raised: close with the error.""" + gateway = self.sync_gateway + gateway._trace("exception during callback: %s" % exc) + errortext = gateway._geterrortext(exc) + with suppress(OSError): + gateway._send( + Message.CHANNEL_CLOSE_ERROR, channel.id, dumps_internal(errortext) + ) + channel._close_from_remote(RemoteError(errortext), sendonly=False) + + def run_on_loop(self, sync_fn: Callable[[], T]) -> T: + """Run ``sync_fn`` on the engine loop, excluding dispatch interleaving. + + Falls back to running inline once the loop is gone (no more + deliveries can interleave then anyway). + """ + portal = self.engine.portal + if portal.is_loop_thread(): + return sync_fn() + try: + result: T = portal.run_sync(sync_fn) + except LoopFinishedError: + return sync_fn() + return result + + def enqueue_message(self, message: Message) -> None: + """Enqueue a frame; wait until written when safe to block. + + The Trio engine thread (receiver callbacks) must not wait — that + would deadlock the writer task on the same event loop. + """ + frame = message.pack() + wait = not self.engine.portal.is_loop_thread() + # The ack carries the write failure as a value (never raised into + # the OneShot) so a KeyboardInterrupt in wait() stays distinguishable + # from a stream error. + ack: OneShot[BaseException | None] | None = ( + OneShot(self.sync_gateway._new_wakener()) if wait else None + ) + + def post() -> None: + try: + self._enqueue_frame(frame, ack.set if ack is not None else None) + except OSError as exc: + if ack is not None: + ack.set(exc) + + with self._send_lock: + if self._send_closed: + raise OSError("cannot send (already closed?)") + try: + # Through the portal even from the engine thread so every + # send lands in one global FIFO order. + self.engine.portal.post(post) + except LoopFinishedError: + raise OSError("cannot send (already closed?)") from None + if ack is None: + return + try: + error = ack.wait(timeout=120.0) + except TimeoutError: + raise OSError("cannot send (write timed out)") from None + if error is not None: + raise OSError("cannot send (already closed?)") from error + + def post_message(self, message: Message) -> None: + """Best-effort non-waiting send (Channel.__del__ during GC).""" + frame = message.pack() + + def post() -> None: + with suppress(OSError): + self._enqueue_frame(frame) + + try: + self.engine.portal.post(post) + except LoopFinishedError: + raise OSError("cannot send (already closed?)") from None + + def request_close_write(self) -> None: + with self._send_lock: + if self._send_closed: + return + self._send_closed = True + with suppress(LoopFinishedError): + # The writer drains queued frames, then signals write-EOF. + self.engine.portal.post(self._outbound_send.close) + + def wait_done(self, timeout: float | None = None) -> bool: + try: + self._done_sync.wait(timeout) + except TimeoutError: + return False + return True + + def is_alive(self) -> bool: + return not self._done_sync.is_set() + + +async def start_session( + engine: Engine, gateway: BaseGateway, io: ByteStream +) -> SyncBridgeGateway: + """Serve ``gateway`` over ``io`` as a task on ``engine``. + + A function rather than a :class:`Engine` method because the session + it builds belongs to this layer: the engine offers a nursery to start + long-lived tasks on and stays ignorant of what they are. + """ + session = SyncBridgeGateway( + io, id=str(gateway.id), sync_gateway=gateway, engine=engine + ) + await engine.start_task(session._serve) + return session + + +class _TempIO: + """Placeholder IO used only while constructing a Trio-backed Gateway.""" + + def __init__(self, execmodel: ExecModel) -> None: + self.execmodel = execmodel + + def read(self, numbytes: int) -> bytes: + raise RuntimeError("sync read not supported on Trio temp IO") + + def write(self, data: bytes) -> None: + raise RuntimeError("sync write not supported on Trio temp IO") + + def close_read(self) -> None: + return + + def close_write(self) -> None: + return + + +class FacadeAsyncGroup(AsyncGroup): + """AsyncGroup owning the async side of a sync ``Group``. + + Runs on the group's :class:`Engine`. Gateways come out as + :class:`SyncBridgeGateway` objects bound to freshly built sync + ``Gateway`` facades, and the via / installvia flows go through the sync + sync coordinator gateway (its dispatch is sync, so async channels cannot be + on it). + """ + + def __init__(self, group: Group, engine: Engine) -> None: + super().__init__() + # built on the engine loop, so its vocabulary is available here -- + # the base class captures the same one when the group is entered + self._aio = current_async() + self.group = group + self.engine = engine + self.shutdown = self._aio.event() + + def _make_gateway(self, stream: ByteStream, spec: XSpec) -> AsyncGateway: + import execnet + + # both settled by makegateway before it dispatches to any transport + assert spec.profile is not None and spec.id is not None + sync_gw = execnet.Gateway(_TempIO(get_execmodel(spec.profile)), spec) + # the caller's concurrency library, inherited from the facade + sync_gw._wait_backend = self.group._wait_backend + return SyncBridgeGateway( + stream, id=spec.id, sync_gateway=sync_gw, engine=self.engine + ) + + async def _open_via_stream(self, spec: XSpec) -> ByteStream: + from . import _provision + + # only reached for a spec that named a via= coordinator + assert spec.via is not None + coordinator = self.group[spec.via] + session = coordinator._trio_session + assert isinstance(session, SyncBridgeGateway) + request = await provision_sync(_provision.spawn_request, spec) + channelid = coordinator._channelfactory.allocate_id() + # Create the raw channel before the request goes out so no relayed + # frame can arrive unrouted (we are on the loop: no dispatch races). + io = RawChannelStream(session._channel_for(channelid)) + coordinator._send(Message.GATEWAY_START_SUB, channelid, dumps_internal(request)) + await configure_worker(io, spec, "via") + return io + + async def _resolve_socket_address(self, spec: XSpec) -> tuple[tuple[str, int], str]: + if spec.installvia: + coordinator = self.group[spec.installvia] + # Blocking sync channel receive on that coordinator: run in a + # thread while this loop keeps dispatching its messages. + realhost, realport = await self._aio.to_thread( + start_socketserver_via, coordinator, abandon_on_cancel=True + ) + return (realhost, realport), "%s:%d" % (realhost, realport) + assert spec.socket is not None + host_str, _, port_str = spec.socket.rpartition(":") + return (host_str, int(port_str)), spec.socket + + async def run(self, task_status: Any = None) -> None: + """Own the group nursery as an engine task until :attr:`shutdown`. + + Registered with the engine for exactly this task's lifetime, so + closing the engine knows what it is about to take down. + """ + self.engine._register_group(self) + try: + async with self: + task_status.started(self) + await self.shutdown.wait() + finally: + self.engine._forget_group(self) + + +def makegateway_trio(group: Group, spec: XSpec) -> Gateway: + """Create a sync-facade Gateway for ``spec`` on the group's Trio engine.""" + engine: Engine = group._ensure_trio_engine() + async_group: FacadeAsyncGroup = group._ensure_async_group() + # e.g. a gevent app: only the calling greenlet parks while the gateway + # comes up, not the whole hub. + bridge = group.engine_call(engine, async_group.makegateway, spec) + assert isinstance(bridge, SyncBridgeGateway) + # makegateway settled the profile on its way through + assert spec.profile is not None + gw: Gateway = bridge.sync_gateway # type: ignore[assignment] + gw._io = SyncIOHandle( + get_execmodel(spec.profile), + bridge, + remoteaddress=bridge.remoteaddress, + ) + return gw + + +def _spawn_socket_worker(sock: Any) -> subprocess.Popen[bytes]: + """Spawn a worker subprocess serving the accepted socket ``sock``. + + POSIX hands the fd over with ``pass_fds``. Windows has no such thing, + so the socket is duplicated into the child with ``WSADuplicateSocket`` + and the blob goes to its stdin: it cannot be built until the child's + pid exists. + + Nothing else is passed. What the worker *is* -- its id, profile, + working directory, environment -- comes from the coordinator's config + frame, which arrives on the very socket being handed over, so the + server is not in the business of relaying, filtering or even reading + it. + + Takes the socket rather than its fd because ``share()`` needs a stdlib + socket object, and building one from a bare fd makes the constructor + *detect* family/type/proto by querying the handle. Passing what the + caller already knows skips that: it is the one difference between this + path and the popen one, and the popen one works where this did not. + """ + from . import _provision + from ._trio_gateway import SHARE_KEY + from ._trio_gateway import dumps_config + from ._trio_gateway import share_socket + + argv = [sys.executable, "-m", "execnet", "worker"] + fd = sock.fileno() + if not _provision.socket_share_required(): + return subprocess.Popen([*argv, "--protocol-fd", str(fd)], pass_fds=[fd]) + + import socket as _socket + + process = subprocess.Popen( + [*argv, "--protocol-share", "--config-fd", "0"], stdin=subprocess.PIPE + ) + try: + # a view on the accepted socket, so share() can reach it; detach so + # dropping the view does not close the fd we do not own + view = _socket.socket(sock.family, sock.type, sock.proto, fileno=fd) + try: + blob = share_socket(view, process.pid) + finally: + view.detach() + assert process.stdin is not None + process.stdin.write(dumps_config({SHARE_KEY: blob})) + process.stdin.close() + except BaseException: + process.kill() + raise + return process + + +async def serve_socket_connection(stream: Any, *, reap: bool) -> None: + """Hand an accepted socket to a fresh worker subprocess (server side). + + ``reap`` waits for the worker (loop server); when false the worker outlives + this task (one-shot / installvia). + + A failed spawn closes the connection. The coordinator is already waiting + on the other end for a handshake reply that is never coming, and an EOF is + the only thing that will move it -- without this it waits forever. + """ + try: + proc = _spawn_socket_worker(stream.socket) + except BaseException: + with current_async().shielded(), suppress(Exception): + await stream.aclose() + raise + # The child holds its own copy of the socket now; release ours. + await stream.aclose() + if reap: + await current_async().to_thread(proc.wait) + + +async def _start_socket_and_reply( + gateway: BaseGateway, channelid: int, bind_host: str +) -> None: + """Bind an ephemeral port, reply with its address, then serve one connection. + + Runs as a task on the worker's Trio engine (scheduled from the message + handler). The reply travels back on ``channelid`` like a STATUS reply. + + Refusing *before* replying is what makes an unsupported machine survivable: + once the address has gone out the coordinator will connect and wait for + a handshake, and there is no longer any way to tell it why nobody is + there. An error close instead surfaces at its ``channel.receive()``. + """ + from . import _provision + + if not _provision.socket_handoff_available(): + gateway._send( + Message.CHANNEL_CLOSE_ERROR, + channelid, + dumps_internal( + f"cannot serve a socket gateway on {sys.platform}: this machine " + "cannot hand an accepted socket to a worker process" + ), + ) + return + + listeners = await current_async().open_tcp_listeners(0, host=bind_host) + addr = listeners[0].socket.getsockname() + gateway._send(Message.CHANNEL_DATA, channelid, dumps_internal((addr[0], addr[1]))) + gateway._send(Message.CHANNEL_CLOSE, channelid) + + stream = await listeners[0].accept() + for listener in listeners: + await listener.aclose() + try: + await serve_socket_connection(stream, reap=True) + except Exception as exc: + # This runs as a task on *this worker's* engine: letting it propagate + # tears the whole gateway down, so a coordinator asking for one + # unsupported sub-gateway would lose the coordinator it asked through. + # The connection is already closed, so the coordinator gets its EOF. + trace(f"socket gateway for channel {channelid} failed: {exc!r}") + + +def handle_start_socket(gateway: BaseGateway, channelid: int, data: bytes) -> None: + """Worker handler for ``Message.GATEWAY_START_SOCKET`` (on the engine thread).""" + bind_host = loads_internal(data) + assert isinstance(bind_host, str) + engine: Engine = gateway._trio_exec.engine # type: ignore[attr-defined] + # The receiver runs on the engine thread, so schedule the async work directly. + engine.start_soon(_start_socket_and_reply, gateway, channelid, bind_host) + + +def start_socketserver_via( + via_gateway: Any, bind_host: str = "localhost" +) -> tuple[str, int]: + """Ask ``via_gateway`` (protocol message) to start a one-shot socket listener. + + Returns the ``(host, port)`` the coordinator should connect to. + """ + channel = via_gateway.newchannel() + via_gateway._send( + Message.GATEWAY_START_SOCKET, channel.id, dumps_internal(bind_host) + ) + realhost, realport = channel.receive() + channel.waitclose() + if not realhost or realhost in ("0.0.0.0", "::"): + realhost = "localhost" + return realhost, int(realport) + + +async def _run_delivery_step(argv: list[str], payload: bytes) -> None: + """Run an out-of-band delivery command, feeding ``payload`` to its stdin.""" + aio = current_async() + process = await aio.open_process(argv, stdin=subprocess.PIPE) + try: + assert process.stdin is not None + await process.stdin.send_all(payload) + await process.stdin.aclose() + code = await process.wait() + except BaseException: + with aio.shielded(), aio.move_on_after(5): + process.kill() + await process.wait() + raise + if code != 0: + raise RuntimeError(f"delivery step failed with exit {code}: {argv[0]}") + + +async def _start_sub_and_relay( + gateway: BaseGateway, channelid: int, request: dict[str, Any] +) -> None: + """Spawn a requested sub-worker and relay its Message protocol frames. + + Runs on the coordinator's Trio engine (the ``via`` transport). The tunnel is + frame-native both ways: coordinator payloads arrive verbatim through the + session's raw channel and go to the sub's stdin unchanged (each payload + one whole frame), while the sub's stdout runs through a FrameDecoder so + every CHANNEL_DATA sent back carries exactly one frame. The sub's own + config is the first of those frames, from the coordinator that wants the + gateway -- this relay passes it through without reading it. A shipped + wheel (dev-version ssh sub) is delivered first, over its own connection, + so the relayed stream carries protocol bytes only. + """ + aio = current_async() + from . import _provision + + def send_close_error(text: str) -> None: + with suppress(OSError): + gateway._send(Message.CHANNEL_CLOSE_ERROR, channelid, dumps_internal(text)) + + try: + args, delivery = await provision_sync(_provision.sub_spawn_argv, request) + if delivery is not None: + await _run_delivery_step(*delivery) + process = await open_popen_process(args) + except Exception as exc: + send_close_error(f"could not spawn via sub-gateway: {exc}") + return + session = gateway._trio_session + assert isinstance(session, SyncBridgeGateway) + raw = session._channel_for(channelid) + + async def coordinator_to_sub() -> None: + assert process.stdin is not None + with suppress(RemoteError): + async for data in raw: + await process.stdin.send_all(data) + with aio.move_on_after(5): + await process.stdin.aclose() + + async def sub_to_coordinator() -> None: + assert process.stdout is not None + decoder = FrameDecoder() + while True: + data = bytes(await process.stdout.receive_some(RECEIVE_CHUNK)) + if not data: + break + for message in decoder.feed(data): + gateway._send(Message.CHANNEL_DATA, channelid, message.pack()) + with suppress(OSError): + gateway._send(Message.CHANNEL_CLOSE, channelid) + + try: + async with aio.task_scope() as scope: + scope.start_soon(coordinator_to_sub) + scope.start_soon(sub_to_coordinator) + except Exception as exc: + # Do not let a relay failure crash the engine nursery; surface it on + # the channel so the coordinator does not hang on the handshake. + gateway._trace("via sub relay failed:", exc) + send_close_error(f"via sub-gateway relay failed: {exc}") + finally: + session._forget_channel(channelid) + with aio.move_on_after(5): + await process.wait() + + +def handle_start_sub(gateway: BaseGateway, channelid: int, data: bytes) -> None: + """Worker handler for ``Message.GATEWAY_START_SUB`` (on the engine thread).""" + request = loads_internal(data) + assert isinstance(request, dict) + engine: Engine = gateway._trio_exec.engine # type: ignore[attr-defined] + engine.start_soon(_start_sub_and_relay, gateway, channelid, request) diff --git a/src/execnet/_trio_worker.py b/src/execnet/_trio_worker.py new file mode 100644 index 00000000..7f34696c --- /dev/null +++ b/src/execnet/_trio_worker.py @@ -0,0 +1,1187 @@ +"""Worker-side Trio networking and exec scheduling for popen/import bootstrap.""" + +from __future__ import annotations + +import json +import os +import stat +import sys +import threading +from collections.abc import Callable +from collections.abc import Mapping +from collections.abc import Sequence +from contextlib import suppress +from typing import TYPE_CHECKING +from typing import Any +from typing import Protocol + +from ._boundary import Mailbox +from ._boundary import WaitBackend +from ._errors import LoopFinishedError +from ._errors import geterrortext +from ._execmodel import effective_profile +from ._execmodel import get_execmodel +from ._gateway_base import WorkerGateway +from ._handshake import read_config_frame +from ._handshake import send_ready_frame +from ._serialize import loads_internal +from ._trace import trace + +if TYPE_CHECKING: + import socket + + from . import _trio_engine + from . import _trio_host + from ._channel import Channel + from ._execmodel import ExecModel + from ._handshake import BlockingChannel + from ._trio_gateway import ByteStream + +ExecItem = tuple[Any, ...] + +#: environment variable that downgrades the fatal coordinator/worker version +#: check to a warning; read from the config's ``env:`` values too. +IGNORE_VERSION_SKEW = "EXECNET_IGNORE_VERSION_SKEW" + + +class PoolExec: + """Exec strategy: run each request on a worker thread (trio's pool). + + The building block of the ``thread`` profile; exec'd code may freely + start its own event loops (it never shares a thread with ours). + """ + + #: whether _run_worker must hand this strategy the process main thread + needs_primary_thread = False + #: whether each concurrent exec occupies a thread of the worker's budget + exec_costs_a_thread = True + + def __init__(self, gateway: WorkerGateway) -> None: + self.gateway = gateway + + async def admit(self, channel: Channel, item: ExecItem) -> bool: + """FIFO admission gate; always open for pool placement.""" + return True + + async def run(self, channel: Channel, item: ExecItem) -> None: + from ._async import current_async + + await current_async().to_thread( + self.gateway.executetask, + (channel, item), + abandon_on_cancel=True, + ) + + def integrate_as_primary_thread(self) -> None: + raise RuntimeError("pool exec strategy does not use the main thread") + + def trigger_shutdown(self) -> None: + pass + + +def _thread_signal() -> tuple[Any, Callable[[], None]]: + """A loop-side event plus the callable that sets it from a foreign thread. + + Waiting for an exec that runs *elsewhere* -- the main thread, a greenlet + -- must not park a pool thread on a ``threading.Event``: that thread is + part of the budget exec placement is rationed against, so an exec that + costs no thread would still spend one waiting for itself. + + Must be built on the loop (it captures a portal into it). + """ + from ._async import current_async + + aio = current_async() + done = aio.event() + portal = _loop_portal() + + def signal() -> None: + # posted callbacks must not raise, and a loop that ended while the + # exec ran leaves nobody to wake + with suppress(LoopFinishedError): + portal.post(done.set) + + return done, signal + + +def _loop_portal() -> Any: + """A portal into the loop this is called on, whichever library it is.""" + from ._async import current_async + + if current_async().name == "trio": + from ._portal import LoopPortal + + return LoopPortal() + from ._portal import AsyncioPortal + + return AsyncioPortal() + + +class PrimaryThreadPump: + """Runs exec requests handed to it on the process main thread. + + The building block for every strategy that needs a real main thread: + :meth:`integrate_as_primary_thread` parks there draining a mailbox, and + :meth:`run` hands one request over and awaits its completion. + """ + + #: whether _run_worker must hand this strategy the process main thread + needs_primary_thread = True + #: whether each concurrent exec occupies a thread of the worker's budget + #: (see TrioWorkerExec.capacity): the primary one does not, but the + #: overflow HybridExec sends to the pool does + exec_costs_a_thread = True + + def __init__(self, gateway: WorkerGateway) -> None: + self.gateway = gateway + self._primary: Mailbox[tuple[Channel, ExecItem, Callable[[], None]] | None] = ( + Mailbox() + ) + + async def admit(self, channel: Channel, item: ExecItem) -> bool: + """FIFO admission gate; always open.""" + return True + + async def run(self, channel: Channel, item: ExecItem) -> None: + done, signal = _thread_signal() + self._primary.put((channel, item, signal)) + await done.wait() + + def released(self) -> None: + """Hook: the main thread is free again (called on it, before done).""" + + def integrate_as_primary_thread(self) -> None: + """Block the main thread running exec tasks until shutdown.""" + while True: + task = self._primary.get() + if task is None: + break + channel, item, signal = task + try: + self.gateway.executetask((channel, item)) + finally: + # Release before signalling: the next request should see the + # main thread free as early as we can make it. + self.released() + signal() + + def trigger_shutdown(self) -> None: + self._primary.put(None) + + +class HybridExec(PrimaryThreadPump): + """Exec strategy: primary on the main thread, overflow on pool threads. + + The classic ``thread`` profile shape: a request arriving while the + main thread is idle claims it (pytest and friends get a true main + thread); requests arriving while it is busy run on worker threads + instead of queueing. The claim is decided during FIFO admission so + the *first* request always gets the main thread. + + This is also what the retired ``main_thread_only`` profile now maps to: + it existed for the main-thread guarantee, which the claim provides, + and its extra behaviour -- refusing a second concurrent remote_exec + rather than overflowing -- was a deadlock guard, not a feature. + + One difference from that profile is worth knowing: it *serialized*, so + every sequential remote_exec was guaranteed the main thread. Here the + claim is released as the exec finishes, while the channel close that + tells the coordinator it may send the next one is emitted a moment + earlier -- so a coordinator that immediately re-execs can, rarely, be + admitted before the release lands and get a pool thread instead. The + *first* request always gets the main thread; a caller that needs the + guarantee for every request wants the ``trio`` or ``gevent`` profile, + where placement is not a race. + """ + + def __init__(self, gateway: WorkerGateway) -> None: + super().__init__(gateway) + self._pool = PoolExec(gateway) + self._claim_lock = threading.Lock() + self._primary_busy = False + self._claimed: set[int] = set() + + async def admit(self, channel: Channel, item: ExecItem) -> bool: + with self._claim_lock: + if not self._primary_busy: + self._primary_busy = True + self._claimed.add(channel.id) + return True + + def released(self) -> None: + with self._claim_lock: + self._primary_busy = False + + async def run(self, channel: Channel, item: ExecItem) -> None: + with self._claim_lock: + claimed = channel.id in self._claimed + self._claimed.discard(channel.id) + if not claimed: + await self._pool.run(channel, item) + return + try: + await super().run(channel, item) + finally: + # released() normally cleared this on the main thread already; + # repeat it so a cancelled or failed run cannot strand the claim + self.released() + + +class GreenletExec: + """Exec strategy: greenlets on a gevent hub owning the main thread. + + ``profile=gevent``: each request runs as a greenlet spawned by the + integrate loop; the worker's gevent wakeners make channel + operations park the greenlet, so concurrent remote_execs cooperate on + the one main thread. Requires gevent in the worker environment + (provisioning adds the ``gevent`` requirement automatically). + """ + + needs_primary_thread = True + #: greenlets, not threads: concurrent execs here cost the thread budget + #: nothing, so they are not rationed against it + exec_costs_a_thread = False + + def __init__(self, gateway: WorkerGateway) -> None: + from ._boundary import make_wakener + + self.gateway = gateway + # The integrate loop blocks in get() on the hub thread: a gevent + # wakener parks only its root greenlet, letting exec greenlets run. + self._primary: Mailbox[tuple[Channel, ExecItem, Callable[[], None]] | None] = ( + Mailbox(make_wakener("gevent")) + ) + + async def admit(self, channel: Channel, item: ExecItem) -> bool: + return True + + async def run(self, channel: Channel, item: ExecItem) -> None: + done, signal = _thread_signal() + self._primary.put((channel, item, signal)) + await done.wait() + + def integrate_as_primary_thread(self) -> None: + """Run the hub on the main thread, spawning a greenlet per exec.""" + import gevent + + def run_exec( + channel: Channel, item: ExecItem, signal: Callable[[], None] + ) -> None: + try: + self.gateway.executetask((channel, item)) + finally: + signal() + + while True: + task = self._primary.get() + if task is None: + break + gevent.spawn(run_exec, *task) + + def trigger_shutdown(self) -> None: + self._primary.put(None) + + +# worker profile -> exec strategy for the sync-facade worker; the "trio" +# profile serves a plain AsyncGateway instead (TaskExec below). +# Future placement strategies slot in here (e.g. subinterpreters). +WORKER_EXEC_STRATEGIES: dict[str, Callable[[WorkerGateway], Any]] = { + "thread": HybridExec, + "gevent": GreenletExec, +} + + +class TaskExec: + """Exec strategy for the pure-async profile (``profile=trio``). + + Sources run as tasks on the worker's own loop, in the one and only + thread of the process, and receive an ``AsyncChannel``. Sources must + be async: a plain function or a source string without top-level + ``await`` is rejected before it can starve the loop. Gateway + termination cancels running exec tasks (a cancellation inside the + source). + """ + + def __init__(self, gateway: Any, nursery: Any) -> None: + from ._async import current_async + + self._aio = current_async() + self.gateway = gateway + self.nursery = nursery + self._running = 0 + + def active_count(self) -> int: + return self._running + + def handle_exec(self, gateway: Any, channelid: int, data: bytes) -> None: + """CHANNEL_EXEC hook; runs inline on the dispatch task.""" + item = loads_internal(data) + assert isinstance(item, tuple) + channel = gateway.open_channel(channelid) + self.nursery.start_soon(self._run_exec, channel, item) + + async def _run_exec(self, channel: Any, item: ExecItem) -> None: + import ast + import inspect + + source, file_name, call_name, kwargs = item + self._running += 1 + try: + trace(f"async execution starts[{channel.id}]: {source[:50]!r}") + loc: dict[str, Any] = {"channel": channel, "__name__": "__channelexec__"} + co = compile( + source + "\n", + file_name or "", + "exec", + flags=ast.PyCF_ALLOW_TOP_LEVEL_AWAIT, + ) + toplevel_await = bool(co.co_flags & inspect.CO_COROUTINE) + if not toplevel_await and not call_name: + await channel.aclose( + "sync source under profile=trio: the source must use" + " top-level await (or pass an async function)" + ) + return + if toplevel_await: + await eval(co, loc) + else: + exec(co, loc) # define the function (no top-level awaits) + if call_name: + function = loc[call_name] + result = function(channel, **kwargs) + if not hasattr(result, "__await__"): + await channel.aclose( + f"sync function {call_name!r} under profile=trio:" + " remote_exec functions must be async" + ) + return + await result + except self._aio.Cancelled: + raise + except EOFError: + trace("ignoring EOFError from async exec") + except BaseException as exc: + trace(f"async exec got exception: {exc!r}") + await channel.aclose(geterrortext(exc)) + return + finally: + self._running -= 1 + trace("async execution finished") + await channel.aclose() + + +def exec_capacity() -> int: + """How many concurrent execs this worker admits. + + Every placement costs a thread from trio's default limiter: a pool exec + runs there, and a main-thread exec parks there waiting for the main + thread to finish. Channel callbacks and the worker's own internal + ``to_thread`` work draw on threads too, so exec takes *half* the budget + and leaves the rest to the machinery that has to keep running while + execs are in flight. + + Must be called on the loop (the limiter is a trio run-local). + """ + from ._async import current_async + + total = current_async().thread_budget() + return max(1, int(total // 2)) + + +class TrioWorkerExec: + """FIFO admission pump feeding an exec placement strategy. + + Exec requests flow through a single pump task so admission happens + strictly in message-arrival order (trio task scheduling order is + deliberately unordered, so per-request tasks would race for e.g. the + main-thread claim). Where an admitted request runs is the + strategy's business (:data:`WORKER_EXEC_STRATEGIES`). + + Admission is bounded by :func:`exec_capacity` and a request over it is + **refused**, not queued: placement needs a thread, and a request waiting + for one it cannot get is indistinguishable from a hung exec -- it + occupies a channel the coordinator is waiting on, with nothing to say. + A refusal reaches that coordinator as a RemoteError naming the limit. + """ + + def __init__( + self, + engine: _trio_engine.TrioEngine, + gateway: WorkerGateway, + strategy: Any, + ) -> None: + self.engine = engine + self.gateway = gateway + self.strategy = strategy + self._lock = threading.Lock() + #: admitted and not yet finished -- the number STATUS reports, and + #: what admission is capped on + self._running = 0 + #: channel ids currently holding one of those slots, so releasing is + #: idempotent (it happens at the close, and again when the task ends) + self._holding: set[int] = set() + #: resolved on the loop at the first request (see exec_capacity) + self._capacity: int | None = None + self._shutting_down = False + self._idle = threading.Event() + self._idle.set() + # Built on the main thread rather than on the loop, so the + # vocabulary comes from the engine rather than from "which loop am + # I in". Without one -- a pump built in isolation, as the tests do + # -- fall back to the loop that is running. + from ._async import current_async + from ._async import for_backend + + self._aio = ( + for_backend(engine.backend) if engine is not None else current_async() + ) + self._pending_send, self._pending_recv = self._aio.queue() + self._pump_started = False + + @property + def needs_primary_thread(self) -> bool: + return bool(self.strategy.needs_primary_thread) + + def active_count(self) -> int: + with self._lock: + return self._running + + def capacity(self) -> int | None: + """Concurrent execs this worker admits, or None for unbounded. + + Only the thread-shaped strategies are rationed: a greenlet exec + spends no thread, so bounding it would cap the very thing + ``profile=gevent`` exists to provide. Resolved on first use rather + than in ``__init__``, which runs before there is a loop to read the + thread limiter from. Reported by ``remote_status()`` as + ``execcapacity``, because a coordinator that just had a request + refused wants the number it hit. + """ + if not self.strategy.exec_costs_a_thread: + return None + if self._capacity is None: + self._capacity = exec_capacity() + return self._capacity + + def release_slot(self, channelid: int) -> None: + """Give back the admission slot ``channelid`` holds, once. + + Called twice on the ordinary path, and the *early* call is the one + that matters: from ``_close_finished``, just before the exec's + channel close goes out. That close is how a coordinator learns it + may send the next request, so it must not be able to arrive before + the slot it frees -- otherwise ``waitclose(); remote_exec()`` on a + worker at capacity is refused for a slot that was already gone. + The task's own ``finally`` then covers everything that never got as + far as closing. + """ + with self._lock: + if channelid not in self._holding: + return + self._holding.discard(channelid) + self._running -= 1 + if self._running == 0: + self._idle.set() + + def schedule(self, channel: Channel, sourcetask: bytes) -> None: + """Called from the session dispatch on the Trio engine thread. + + Must not block: admission checks and exec run in a nursery task. + """ + item = loads_internal(sourcetask) + assert isinstance(item, tuple) + capacity = self.capacity() # on the loop: the limiter is readable + with self._lock: + if self._shutting_down: + channel.close("execution disallowed") + return + if capacity is not None and self._running >= capacity: + full = True + else: + full = False + self._holding.add(channel.id) + self._running += 1 + self._idle.clear() + if full: + channel.close( + f"execnet worker {self.gateway.id}: refusing remote_exec, already" + f" running {capacity} of them -- that is this worker's" + " concurrency limit (half its thread budget; the rest serves" + " channel callbacks and protocol work). Use more gateways, or" + " a profile whose execs are not threads (profile=trio," + " profile=gevent), which are not bounded this way." + ) + return + # Already on the Trio engine thread (Message handler). + if not self._pump_started: + self._pump_started = True + self.engine.start_soon(self._pump) + self._pending_send.send_nowait((channel, item)) + + async def _pump(self) -> None: + """Admit queued exec requests in FIFO order, then run each as a task.""" + async for channel, item in self._pending_recv: + if await self.strategy.admit(channel, item): + self.engine.start_soon(self._run_exec, channel, item) + + async def _run_exec(self, channel: Channel, item: ExecItem) -> None: + """Run one admitted request, containing whatever it does. + + This is a task on the worker's *root* nursery, so an exception that + leaves it ends ``trio.run`` and takes the whole worker down -- and + since 3.0 the worker's stderr is the user's, so the ExceptionGroup + lands in their terminal. The one failure that reliably gets here is + also the least interesting: ``executetask`` closes the channel when + the source returns, and a connection that went away in the meantime + makes that raise. There is nobody left to tell, so trace and stop. + + Every ``engine.start_soon`` entry point owes the loop this containment; + the socket and via handlers in ``_trio_host`` do the same. + """ + try: + await self.strategy.run(channel, item) + except self._aio.Cancelled: + raise + except BaseException as exc: + trace(f"exec task for channel {channel.id} failed: {exc!r}") + finally: + self.release_slot(channel.id) + + def integrate_as_primary_thread(self) -> None: + self.strategy.integrate_as_primary_thread() + + def trigger_shutdown(self) -> None: + with self._lock: + self._shutting_down = True + self.strategy.trigger_shutdown() + + def waitall(self, timeout: float | None = None) -> bool: + return self._idle.wait(timeout) + + +def _first(*values: str | None) -> str: + """The first disposition actually asked for.""" + for value in values: + if value is not None: + return value + raise AssertionError("the transport default is never None") + + +def _devnull() -> str: + try: + return os.devnull + except AttributeError: # pragma: no cover - defensive + return "NUL" if os.name == "nt" else "/dev/null" + + +def _dup_protocol_fds() -> tuple[int, int]: + """Move the protocol off stdin/stdout, leaving fd 0/1 free to redirect. + + Returns ``(read_fd, write_fd)`` for the Message protocol (the worker + reads what the coordinator writes to our stdin, and writes what the + coordinator reads from our stdout). What happens to fd 0/1 afterwards + is the caller's choice -- see :func:`apply_stdio`. + """ + if not hasattr(os, "dup"): # pragma: no cover - jython legacy + raise RuntimeError("the execnet worker requires os.dup") + return os.dup(0), os.dup(1) + + +def apply_stdio( + stdin: str = "inherit", stdout: str = "inherit", stderr: str = "inherit" +) -> None: + """Point the worker's standard fds where the launcher asked. + + ``inherit`` leaves an fd alone, which is the default once the protocol + has a transport of its own: a worker's output is then the user's, not + something execnet has to swallow to protect the wire. + + ``close`` (stdin only) reopens fd 0 on the null device *and* closes + ``sys.stdin``. Reads through Python raise, while the fd itself stays + reserved -- genuinely closing it would let the next ``os.open`` land on + fd 0, where anything writing to "stdin" would corrupt an unrelated file. + """ + if stdin in ("close", "devnull"): + fd = os.open(_devnull(), os.O_RDONLY) + os.dup2(fd, 0) + os.close(fd) + if stdin == "close": + with suppress(Exception): + sys.stdin.close() + sys.stdin = os.fdopen(0, "r", closefd=False) + sys.stdin.close() + else: + sys.stdin = os.fdopen(0, "r", closefd=False) + + if stdout == "stderr": + os.dup2(2, 1) + sys.stdout = os.fdopen(1, "w", buffering=1, closefd=False) + elif stdout == "devnull": + fd = os.open(_devnull(), os.O_WRONLY) + os.dup2(fd, 1) + os.close(fd) + sys.stdout = os.fdopen(1, "w", buffering=1, closefd=False) + + if stderr == "devnull": + fd = os.open(_devnull(), os.O_WRONLY) + os.dup2(fd, 2) + os.close(fd) + sys.stderr = os.fdopen(2, "w", buffering=1, closefd=False) + + +class _WorkerIOStub: + """Minimal IO stub so WorkerGateway can be constructed without sync pipes.""" + + def __init__(self, execmodel: ExecModel) -> None: + self.execmodel = execmodel + + def read(self, numbytes: int) -> bytes: + raise RuntimeError("sync read not used on Trio worker") + + def write(self, data: bytes) -> None: + raise RuntimeError("sync write not used on Trio worker") + + def close_read(self) -> None: + return + + def close_write(self) -> None: + return + + +def _build_worker_gateway( + engine: _trio_engine.TrioEngine, + id: str, + model: ExecModel, + wait: WaitBackend = "thread", +) -> tuple[WorkerGateway, TrioWorkerExec]: + """Construct the WorkerGateway + exec pump/strategy (no IO yet).""" + trace(f"creating workergateway on trio id={id!r}") + io_stub = _WorkerIOStub(model) + gateway = WorkerGateway(io=io_stub, id=id, _startcount=2) + gateway._wait_backend = wait + + try: + strategy_factory = WORKER_EXEC_STRATEGIES[effective_profile(model.backend)] + except KeyError: + raise ValueError( + f"profile {model.backend!r} has no worker exec strategy " + f"(known: {sorted(WORKER_EXEC_STRATEGIES)})" + ) from None + strategy = strategy_factory(gateway) + trio_exec = TrioWorkerExec(engine, gateway, strategy) + # Duck-type as the exec pool for STATUS / _terminate_execution. + gateway._execpool = trio_exec + gateway._trio_exec = trio_exec + return gateway, trio_exec + + +def _run_worker( + engine: _trio_engine.TrioEngine, + io: Any, + id: str, + model: ExecModel, + wait: WaitBackend = "thread", +) -> None: + """Attach ``io`` as the gateway session and serve until shutdown.""" + from . import _trio_host + + gateway, trio_exec = _build_worker_gateway(engine, id, model, wait) + + async def _start() -> _trio_host.SyncBridgeGateway: + # The bridge attaches itself to the gateway before serving starts, + # so inbound messages can reply through gateway._send right away. + return await _trio_host.start_session(engine, gateway, io) + + engine.call(_start) + + try: + if trio_exec.needs_primary_thread: + trace("integrating as primary thread (trio worker)") + trio_exec.integrate_as_primary_thread() + gateway.join() + except KeyboardInterrupt: + # Match WorkerGateway.serve(): swallow in the worker. + trace("swallowing keyboardinterrupt, serve finished") + finally: + engine.stop(timeout=5.0) + # Trio's to_thread cache uses non-daemon threads that would otherwise + # keep this disposable worker process alive after serve returns. + os._exit(0) + + +async def _make_fd_io(read_fd: int, write_fd: int) -> Any: + from . import _trio_gateway + + return await _trio_gateway.staple_fd_stream(read_fd, write_fd) + + +async def _serve_async_worker(stream: Any, id: str) -> None: + """Serve a plain AsyncGateway with task-based exec (profile=trio). + + The whole worker is this one trio run on the process main thread: the + dispatch loop and every exec'd source share it. Termination cancels + running exec tasks. + """ + from ._trio_gateway import AsyncGateway + + gateway = AsyncGateway(stream, id=id, _startcount=2) + from ._async import current_async + + aio = current_async() + async with aio.task_scope() as nursery: + task_exec = TaskExec(gateway, nursery) + gateway._exec_handler = task_exec.handle_exec + gateway._task_exec = task_exec + # a service is not exec'd code, so it runs here too -- which is the + # only reason this profile, which rejects sync sources, can receive + # a transfer at all + gateway._service_spawn = nursery.start_soon + await nursery.start(gateway._serve) + await gateway.wait_closed() + nursery.cancel_scope.cancel() + + +class _FdChannel: + """Blocking handshake IO over a pipe pair (or a POSIX socket fd).""" + + def __init__(self, read_fd: int, write_fd: int) -> None: + self._read_fd = read_fd + self._write_fd = write_fd + + def recv(self, max_bytes: int) -> bytes: + return os.read(self._read_fd, max_bytes) + + def sendall(self, data: bytes) -> None: + view = memoryview(data) + while view: + view = view[os.write(self._write_fd, view) :] + + +class _SocketChannel: + """Blocking handshake IO over a socket object. + + Not ``_FdChannel(sock.fileno(), ...)``: a Windows socket handle is not + an ``os.read``-able fd, and the share transport's socket only exists + there. + """ + + def __init__(self, sock: Any) -> None: + self._sock = sock + + def recv(self, max_bytes: int) -> bytes: + data: bytes = self._sock.recv(max_bytes) + return data + + def sendall(self, data: bytes) -> None: + self._sock.sendall(data) + + +class Transport(Protocol): + """How a worker's protocol stream comes into being. + + Three steps, because the stdio transport has to claim fd 0/1 before + anything else touches them, the config that decides this worker's + *shape* arrives over the stream itself (so it has to be read before + there is a loop -- ``profile=trio`` has no side thread to read it on), + and only wrapping the result needs a running trio loop. + """ + + #: default stdio disposition once this transport is serving + stdio_defaults: tuple[str, str, str] + + def prepare(self) -> None: + """Synchronous fd bookkeeping, before anything reads or writes.""" + + def connect(self) -> BlockingChannel: + """Make the byte channel exist; blocking, no loop yet. + + The config handshake runs over what this returns. + """ + + async def open(self) -> ByteStream: + """The protocol ByteStream, ready for the Message protocol.""" + + +class StdioTransport: + """The protocol *is* this process's stdin/stdout (the classic shape). + + Nothing else can use fd 0/1 afterwards, so the default disposition + closes stdin and folds stdout onto stderr -- remote ``print()`` stays + visible on the coordinator instead of going to the null device, and it + cannot corrupt the wire because the wire is no longer fd 1. + """ + + stdio_defaults = ("close", "stderr", "inherit") + + def __init__(self) -> None: + self._fds: tuple[int, int] | None = None + + def prepare(self) -> None: + self._fds = _dup_protocol_fds() + + def connect(self) -> BlockingChannel: + assert self._fds is not None, "prepare() first" + return _FdChannel(*self._fds) + + async def open(self) -> ByteStream: + from ._trio_gateway import staple_fd_stream + + assert self._fds is not None, "prepare() first" + read_fd, write_fd = self._fds + return await staple_fd_stream(read_fd, write_fd) + + +class ShareTransport: + """The protocol socket was duplicated into this process by its launcher. + + The Windows counterpart of an inherited fd: ``subprocess`` refuses + ``pass_fds`` there, but ``WSADuplicateSocket`` can duplicate a socket + into a named pid. The resulting blob is bound to *us*, so it is inert + to anything else that might read it -- and it cannot travel with the + rest of the config, which arrives *through* the socket it describes. + That is what ``--config-fd`` is left for. + + Like any other socket transport, the worker's stdio stays untouched. + """ + + stdio_defaults = ("inherit", "inherit", "inherit") + + def __init__(self) -> None: + self._blob: bytes | None = None + self._sock: Any = None + + def prepare(self) -> None: + pass + + def adopt(self, local_config: dict[str, Any]) -> None: + """Take the share blob out of the transport's local config.""" + import base64 + + from ._trio_gateway import SHARE_KEY + + raw = local_config.get(SHARE_KEY) + if raw is None: + raise SystemExit( + f"execnet worker: --protocol-share needs {SHARE_KEY!r}" + " in the config given by --config-fd" + ) + self._blob = base64.b64decode(raw) + + def connect(self) -> BlockingChannel: + import socket as _socket + + assert self._blob is not None, "adopt() first" + self._sock = _socket.fromshare(self._blob) # type: ignore[attr-defined] + return _SocketChannel(self._sock) + + async def open(self) -> ByteStream: + from . import _trio_host + + # hand over the socket itself, not its fd: fromshare() already knows + # what this socket is, and making adopt_socket re-derive that from + # the bare handle is what PyPy on Windows cannot do + return await _trio_host.adopt_socket(self._sock) + + +class FdTransport: + """The protocol runs over inherited fds: one socket, or a pipe pair.""" + + stdio_defaults = ("inherit", "inherit", "inherit") + + def __init__(self, fds: Sequence[int]) -> None: + self.fds = tuple(fds) + + def prepare(self) -> None: + pass + + def connect(self) -> BlockingChannel: + if len(self.fds) == 2: + return _FdChannel(*self.fds) + (fd,) = self.fds + if not stat.S_ISSOCK(os.fstat(fd).st_mode): + raise ValueError( + f"--protocol-fd {fd} is not a socket; a single fd must be" + " bidirectional, use --protocol-fd READFD,WRITEFD for a pipe pair" + ) + return _FdChannel(fd, fd) + + async def open(self) -> ByteStream: + from . import _trio_host + from ._trio_gateway import staple_fd_stream + + if len(self.fds) == 2: + read_fd, write_fd = self.fds + return await staple_fd_stream(read_fd, write_fd) + return await _trio_host.adopt_socket(self.fds[0]) + + +def parse_address(address: str) -> tuple[str, Any]: + """``unix:/path`` or ``host:port`` -> ``("unix", path)`` / ``("tcp", (h, p))``.""" + if address.startswith("unix:"): + return "unix", address[len("unix:") :] + host, sep, port = address.rpartition(":") + if not sep or not port.isdigit(): + raise ValueError(f"expected unix:/path or host:port, got {address!r}") + return "tcp", (host or "localhost", int(port)) + + +async def _socket_stream(sock: socket.socket) -> ByteStream: + """Wrap an already-connected stdlib socket for the loop.""" + from ._async import current_async + + stream: ByteStream = await current_async().wrap_socket(sock) + return stream + + +class ConnectTransport: + """The worker dials out to the coordinator and serves on that connection. + + This is what lets ssh carry the protocol without owning our stdio: the + coordinator listens on a local unix socket, ``ssh -R`` forwards it, and + we connect to the remote end of the forward. + """ + + stdio_defaults = ("inherit", "inherit", "inherit") + + def __init__(self, address: str) -> None: + self.kind, self.target = parse_address(address) + self._sock: Any = None + + def prepare(self) -> None: + pass + + def connect(self) -> BlockingChannel: + import socket as _socket + + if self.kind == "unix": + sock = _socket.socket(_socket.AF_UNIX, _socket.SOCK_STREAM) + sock.connect(self.target) + else: + sock = _socket.create_connection(self.target) + self._sock = sock + return _SocketChannel(sock) + + async def open(self) -> ByteStream: + return await _socket_stream(self._sock) + + +class ListenTransport: + """The worker listens and serves the first coordinator that connects. + + The bound address is printed to stdout as JSON before the accept, so a + launcher that asked for an ephemeral port can learn which one it got. + """ + + stdio_defaults = ("inherit", "inherit", "inherit") + + def __init__(self, address: str) -> None: + self.kind, self.target = parse_address(address) + self._sock: Any = None + + def prepare(self) -> None: + pass + + def connect(self) -> BlockingChannel: + import socket as _socket + + if self.kind == "unix": + listener = _socket.socket(_socket.AF_UNIX, _socket.SOCK_STREAM) + listener.bind(self.target) + bound: Any = self.target + else: + host, port = self.target + listener = _socket.create_server((host, port)) + bound = list(listener.getsockname()[:2]) + listener.listen(1) + print(json.dumps({"listening": bound}), flush=True) + try: + sock, _peer = listener.accept() + finally: + listener.close() + self._sock = sock + return _SocketChannel(sock) + + async def open(self) -> ByteStream: + return await _socket_stream(self._sock) + + +#: pure-async worker profiles: the profile *names the library* the exec'd +#: source may import and await, which is why there is one per library +#: rather than one "async" that follows whatever the worker happens to run. +ASYNC_PROFILES = {"trio": "trio", "asyncio": "asyncio"} + + +def run_loop(backend: str, async_fn: Any) -> None: + """Run ``async_fn`` as the whole program, on ``backend``.""" + if backend == "trio": + import trio + + trio.run(async_fn) + return + import asyncio + + asyncio.run(async_fn()) + + +def build_engine(name: str) -> Any: + """The engine a worker serves its protocol on. + + Mirrors the coordinator's choice (:func:`execnet._engine.pick_backend`): + trio when it is installed and gevent has not patched the world underneath + it, asyncio otherwise. + """ + from ._engine import pick_backend + + backend = pick_backend() + if backend == "trio": + from . import _trio_engine + + return _trio_engine.TrioEngine(name=name) + from . import _asyncio_engine + + return _asyncio_engine.AsyncioEngine(name=name) + + +def serve_worker( + transport: Transport, + *, + stdin: str | None = None, + stdout: str | None = None, + stderr: str | None = None, +) -> None: + """Handshake over ``transport``, serve the one gateway it configures, exit. + + ``transport.prepare()`` must already have run (the CLI does it before + anything touches fd 0/1). Everything after that is driven by the + coordinator's config frame: it decides this worker's id, profile, wait + backend, working directory, environment and stdio -- and a worker that + will not serve answers *on the wire*, so the reason reaches the person + who asked for the gateway rather than a stderr nobody is reading. + """ + channel = transport.connect() + config = read_config_frame(channel) + + refusal = _version_refusal(config["coordinator_version"], config.get("env")) + if refusal is not None: + send_ready_frame(channel, error=refusal) + raise SystemExit(f"execnet worker: {refusal}") + + _apply_worker_setup(config) + defaults = transport.stdio_defaults + # explicit CLI flags win over the spec's, which win over the transport's + apply_stdio( + stdin=_first(stdin, config.get("stdin"), defaults[0]), + stdout=_first(stdout, config.get("stdout"), defaults[1]), + stderr=_first(stderr, config.get("stderr"), defaults[2]), + ) + + id = config["id"] + # "execmodel" is the pre-3.0 spelling; accept both so a version-skewed + # coordinator still connects. + profile = effective_profile(config.get("profile") or config["execmodel"]) + wait: WaitBackend = config.get("wait", "thread") + + import execnet + + send_ready_frame( + channel, + execnet=execnet.__version__, + pid=os.getpid(), + profile=profile, + executable=sys.executable, + ) + + if profile in ASYNC_PROFILES: + # pure-async profile: one thread, the loop owns the main thread, and + # the profile names the library the exec'd source may use + async def main() -> None: + await _serve_async_worker(await transport.open(), id) + + run_loop(ASYNC_PROFILES[profile], main) + os._exit(0) + + engine = build_engine(name=f"execnet-worker-{id}") + engine.start() + io = engine.call(transport.open) + _run_worker(engine, io, id, get_execmodel(profile), wait) + + +def _rough_version(version: str) -> tuple[int, ...]: + """Leading numeric (major, minor) of a version string; ``()`` if unparsable.""" + parts: list[int] = [] + for chunk in version.split(".")[:2]: + number = "" + for char in chunk: + if char.isdigit(): + number += char + else: + break + if not number: + break + parts.append(int(number)) + return tuple(parts) + + +def _apply_worker_setup(config: dict[str, Any]) -> None: + """Apply chdir/nice/env from the worker config, before serving starts. + + This replaces the classic post-start ``remote_exec`` setup: valid for + every profile (a trio worker cannot run sync sources) and never + claims an exec slot. + """ + path = config.get("chdir") + if path: + if not os.path.exists(path): + os.mkdir(path) + os.chdir(path) + nice = config.get("nice") + if nice and hasattr(os, "nice"): + os.nice(nice) + for name, value in config.get("env", {}).items(): + os.environ[name] = value + + +def _version_refusal( + coordinator_version: str, env: Mapping[str, str] | None = None +) -> str | None: + """Why this worker will not serve that coordinator, or None to proceed. + + A (major/minor) execnet difference is refused; a patch-level one is + tolerated. For same-interpreter popen the versions are always + identical; this guards the remote paths, where the worker runs whatever + execnet its own environment has. + + The wire protocol is deliberately unversioned, so a skew has no defined + behaviour. Refusing is the only honest answer, and the reason is + returned rather than raised because it goes back over the handshake -- + the one moment there is still a channel to explain on. Set + :data:`IGNORE_VERSION_SKEW` (``popen//env:EXECNET_IGNORE_VERSION_SKEW=1`` + reaches this) to downgrade it to the warning it used to be. + """ + import execnet + + ours = _rough_version(execnet.__version__) + theirs = _rough_version(coordinator_version) + if not (ours and theirs and ours != theirs): + return None + versions = f"coordinator {coordinator_version}, worker {execnet.__version__}" + if _skew_ignored(env or {}): + sys.stderr.write(f"WARNING: execnet version mismatch: {versions}\n") + sys.stderr.flush() + return None + return ( + f"version mismatch: {versions}. The protocol is not compatible across " + "major/minor versions -- install a matching execnet in that " + f"environment, or set {IGNORE_VERSION_SKEW}=1 to continue anyway." + ) + + +def _skew_ignored(env: Mapping[str, str]) -> bool: + """Whether the worker was told to tolerate a version skew. + + Read from the config's ``env:`` values as well as the process + environment, because the config ones are not applied until + :func:`_apply_worker_setup`, which runs after the check. + """ + value = env.get(IGNORE_VERSION_SKEW, os.environ.get(IGNORE_VERSION_SKEW, "")) + return value not in ("", "0") diff --git a/src/execnet/_xspec.py b/src/execnet/_xspec.py new file mode 100644 index 00000000..63261a93 --- /dev/null +++ b/src/execnet/_xspec.py @@ -0,0 +1,93 @@ +""" +(c) 2008-2013, holger krekel +""" + +from __future__ import annotations + + +class XSpec: + """Execution Specification: key1=value1//key2=value2 ... + + * Keys need to be unique within the specification scope + * Neither key nor value are allowed to contain "//" + * Keys are not allowed to contain "=" + * Keys are not allowed to start with underscore + * If no "=value" is given, assume a boolean True value + """ + + #: keys accepted under an older spelling, mapped to the canonical one. + #: ``execmodel`` described a *local* execution model that no longer + #: exists; what the key actually selects is the worker's profile. + _ALIASES = {"execmodel": "profile"} + + # XXX allow customization, for only allow specific key names + chdir: str | None = None + dont_write_bytecode: bool | None = None + profile: str | None = None + id: str | None = None + installvia: str | None = None + nice: str | None = None + popen: bool | None = None + python: str | None = None + socket: str | None = None + ssh: str | None = None + ssh_config: str | None = None + stderr: str | None = None + stdin: str | None = None + stdout: str | None = None + transport: str | None = None + vagrant_ssh: str | None = None + via: str | None = None + + def __init__(self, string: str) -> None: + self._spec = string + self.env: dict[str, str | bool] = {} + for keyvalue in string.split("//"): + i = keyvalue.find("=") + value: str | bool + if i == -1: + key, value = keyvalue, True + else: + key, value = keyvalue[:i], keyvalue[i + 1 :] + if key[0] == "_": + raise AttributeError("%r not a valid XSpec key" % key) + # duplicates are checked on the canonical name, so + # ``execmodel=x//profile=y`` is rejected like any other clash + if self._ALIASES.get(key, key) in self.__dict__: + raise ValueError(f"duplicate key: {key!r} in {string!r}") + if key.startswith("env:"): + self.env[key[4:]] = value + else: + setattr(self, key, value) + + def __getattr__(self, name: str) -> None | bool | str: + if name[0] == "_": + raise AttributeError(name) + return None + + @property + def execmodel(self) -> str | None: + """Deprecated alias for :attr:`profile` (accepted indefinitely).""" + return self.profile + + @execmodel.setter + def execmodel(self, value: str | None) -> None: + self.profile = value + + def __repr__(self) -> str: + return f"" + + def __str__(self) -> str: + return self._spec + + def __hash__(self) -> int: + return hash(self._spec) + + def __eq__(self, other: object) -> bool: + return self._spec == getattr(other, "_spec", None) + + def __ne__(self, other: object) -> bool: + return self._spec != getattr(other, "_spec", None) + + def _samefilesystem(self) -> bool: + return self.popen is not None and self.chdir is None diff --git a/src/execnet/aio.py b/src/execnet/aio.py new file mode 100644 index 00000000..2c073ba2 --- /dev/null +++ b/src/execnet/aio.py @@ -0,0 +1,365 @@ +"""The asyncio-native execnet API. + +Everything here is awaited inside your own asyncio event loop:: + + import asyncio + import execnet.aio + + async def main(): + async with execnet.aio.AsyncGroup() as group: + gateway = await group.makegateway("popen") + channel = await gateway.remote_exec("channel.send(6 * 7)") + print(await channel.receive()) + + asyncio.run(main()) + +Protocol IO keeps running on a ProtocolEngine (the same engine as the +blocking and trio-native APIs, all transports included); each awaited +operation runs as a task on that engine and resolves an asyncio future via +``loop.call_soon_threadsafe``. No anyio port and no executor threads per +call. + +Cancellation crosses the bridge, and loses nothing. Cancelling an +awaited ``receive`` (say by ``asyncio.timeout``) cancels the engine-side +operation too; if the cancel lands after the engine already took an item, +that item is kept and handed to your next ``receive`` rather than dropped. +Operations that must not tear halfway -- ``send``, ``send_eof``, +``aclose``, ``terminate`` -- are shielded instead: the ``CancelledError`` +reaches you, but the operation still completes on the engine. + +The error types are shared with :mod:`execnet.sync` and +:mod:`execnet.trio`. Items you send must already be simple builtin data +(plus channels); the standalone serializer is intentionally not part of +the public API -- ``execnet.can_send`` checks a value before you send it; +see ``DumpError``. +""" + +from __future__ import annotations + +import functools +import types +from collections.abc import AsyncIterator +from collections.abc import Callable +from collections.abc import Sequence +from contextlib import asynccontextmanager +from contextlib import suppress +from typing import TYPE_CHECKING +from typing import Any +from typing import TypeVar +from typing import cast + +from ._bridge import AsyncioBridge +from ._bridge import AsyncioCarrier +from ._bridge import EngineGroup +from ._bridge import start_engine +from ._deploy import Deployed +from ._deploy import Deployment +from ._engine import ProtocolEngine +from ._engine import default_engine +from ._errors import ActiveGroupsWarning +from ._errors import ChannelClosed +from ._errors import DataFormatError +from ._errors import DumpError +from ._errors import ExecnetStateError +from ._errors import GatewayGone +from ._errors import HostNotFound +from ._errors import LoadError +from ._errors import RemoteError +from ._errors import TimeoutError +from ._trio_gateway import AsyncChannel as _TrioChannel +from ._trio_gateway import AsyncGateway as _TrioGateway +from ._xspec import XSpec + +if TYPE_CHECKING: + from typing_extensions import Self + + from ._serialize import Payload + from ._serialize import SendPayload + +__all__ = [ + "ActiveGroupsWarning", + "AsyncChannel", + "AsyncGateway", + "AsyncGroup", + "ChannelClosed", + "DataFormatError", + "Deployed", + "Deployment", + "DumpError", + "ExecnetStateError", + "GatewayGone", + "HostNotFound", + "LoadError", + "ProtocolEngine", + "RemoteError", + "TimeoutError", + "XSpec", + "deploy", + "deploy_all", + "open_gateway", + "transfer", +] + +T = TypeVar("T") + + +#: distinguishes "no salvaged item" from a salvaged ``None`` +_NOTHING = object() + + +class AsyncChannel: + """asyncio facade over a trio-native channel.""" + + RemoteError = RemoteError + TimeoutError = TimeoutError + + def __init__(self, bridge: AsyncioBridge, channel: _TrioChannel) -> None: + self._bridge = bridge + self._channel = channel + #: an item the engine produced for a receive that was cancelled + #: before it could be taken. At most one: the engine-side receive + #: that produced it has finished, so nothing else was consumed + #: behind it and the next receive is still in order. + self._salvaged: Any = _NOTHING + + @property + def id(self) -> int: + return self._channel.id + + def __repr__(self) -> str: + return f"" + + def isclosed(self) -> bool: + """Return True if the channel is closed for sending.""" + return self._channel.isclosed() + + async def send(self, item: SendPayload) -> None: + """Serialize ``item`` and send it to the other side. + + Shielded: cancelling raises in the caller but the item is still + sent, rather than leaving a half-written frame on the wire. + """ + await self._bridge.call(self._channel.send, item, shield=True) + + async def receive(self, timeout: float | None = None) -> Payload[AsyncChannel]: + """Receive the next item sent from the other side. + + EOFError once the peer closed or sent EOF, RemoteError for a peer + close-with-error, TimeoutError after ``timeout`` seconds. A + received channel reference arrives as an + :class:`~execnet.aio.AsyncChannel`. + + Cancellable, and equivalent to passing ``timeout``: the engine-side + receive is cancelled too, and an item the engine had already taken + when the cancel landed is kept for the next call rather than + dropped. Cancelling a receive never costs you an item. + """ + if self._salvaged is not _NOTHING: + result, self._salvaged = self._salvaged, _NOTHING + else: + result = await self._bridge.call( + self._channel.receive, timeout, salvage=self._stash + ) + if isinstance(result, _TrioChannel): + return AsyncChannel(self._bridge, result) + return cast("Payload[AsyncChannel]", result) + + def _stash(self, item: Payload[AsyncChannel]) -> None: + """Keep an item whose receive was cancelled before it arrived.""" + self._salvaged = item + + async def send_eof(self) -> None: + """Signal that no more items follow (peer keeps its send side).""" + await self._bridge.call(self._channel.send_eof, shield=True) + + async def aclose(self, error: str | None = None) -> None: + """Close the channel; ``error`` reaches the peer as a RemoteError.""" + await self._bridge.call(self._channel.aclose, error, shield=True) + + async def wait_closed(self) -> None: + """Wait until the peer closed or sent EOF; reraise remote errors.""" + await self._bridge.call(self._channel.wait_closed) + + def __aiter__(self) -> AsyncChannel: + return self + + async def __anext__(self) -> Payload[AsyncChannel]: + try: + return await self.receive() + except EOFError: + raise StopAsyncIteration from None + + +class AsyncGateway: + """asyncio facade over a trio-native gateway.""" + + def __init__(self, bridge: AsyncioBridge, gateway: _TrioGateway) -> None: + self._bridge = bridge + self._gateway = gateway + + @property + def id(self) -> str: + return self._gateway.id + + @property + def remoteaddress(self) -> str | None: + return self._gateway.remoteaddress + + def __repr__(self) -> str: + return f"" + + async def remote_exec( + self, + source: str | types.FunctionType | Callable[..., object] | types.ModuleType, + **kwargs: SendPayload, + ) -> AsyncChannel: + """Connect a new channel to remote execution of ``source``. + + Accepts the same source kinds as ``Gateway.remote_exec``: a source + string, a pure function called with ``channel`` and ``**kwargs``, + or a module. + """ + channel = await self._bridge.call( + functools.partial(self._gateway.remote_exec, source, **kwargs) + ) + return AsyncChannel(self._bridge, channel) + + async def terminate(self) -> None: + """Send GATEWAY_TERMINATE to the peer, then close this side.""" + await self._bridge.call(self._gateway.terminate, shield=True) + + def _target(self) -> Any: + """This gateway as a service target (transfers, deployments).""" + from ._services import ServiceTarget + + return ServiceTarget(self._gateway) + + +class AsyncGroup: + """asyncio-native gateway group served on a ProtocolEngine. + + Usable as an async context manager, or driven explicitly with + :meth:`start` / :meth:`aclose` from application lifespan hooks. + Either way, shutting down terminates every gateway with the same + bounded contract as :class:`execnet.trio.AsyncGroup`. + """ + + def __init__( + self, + termination_timeout: float = 10.0, + *, + engine: ProtocolEngine | None = None, + ) -> None: + self._termination_timeout = termination_timeout + self._engine = default_engine() if engine is None else engine + self._bridge: AsyncioBridge | None = None + self._group: EngineGroup | None = None + + def __repr__(self) -> str: + state = "running" if self._group is not None else "idle" + return f"" + + @property + def engine(self) -> ProtocolEngine: + """The :class:`~execnet.ProtocolEngine` this group's IO runs on.""" + return self._engine + + async def start(self) -> None: + """Bring the engine up and start the group task on it.""" + if self._group is not None: + raise RuntimeError(f"{self!r} is already started") + trio_engine = await start_engine(self._engine, AsyncioCarrier()) + bridge = AsyncioBridge(trio_engine) + + async def start_group() -> EngineGroup: + # runs on the engine loop + group = EngineGroup(self._termination_timeout, trio_engine) + started: EngineGroup = await trio_engine.start_task(group.run) + return started + + self._bridge = bridge + self._group = await bridge.call(start_group, shield=True) + + async def aclose(self) -> None: + """Terminate every gateway and stop the group task (idempotent). + + The engine thread is shared, so it keeps running for other groups. + """ + group, bridge = self._group, self._bridge + self._group = self._bridge = None + if group is None or bridge is None: + return + + async def stop_group() -> None: + group.shutdown.set() + await group.finished.wait() + + with suppress(RuntimeError): + await bridge.call(stop_group, shield=True) + + async def __aenter__(self) -> Self: + await self.start() + return self + + async def __aexit__(self, *exc_info: object) -> None: + await self.aclose() + + async def makegateway(self, spec: str | XSpec = "popen") -> AsyncGateway: + """Create a gateway for ``spec`` served on the group's engine. + + All transports are supported: popen (including uv-provisioned + ``python=``), ``ssh=``, ``vagrant_ssh=``, ``socket=`` (with + ``installvia=``), and ``via=`` sub-gateways. The worker profile + defaults to ``thread``; pass ``profile=trio`` for a worker that + runs exec'd async sources as tasks. + """ + group, bridge = self._group, self._bridge + if group is None or bridge is None: + raise RuntimeError(f"{self!r} is not started") + gateway = await bridge.call(group.makegateway, spec) + return AsyncGateway(bridge, gateway) + + +async def transfer( + gateway: AsyncGateway, + source: str | Any, + destination: str, + **options: Any, +) -> None: + """Copy a tree to ``destination`` on ``gateway``; see :mod:`execnet.trio`.""" + from ._deploy import _async_api + + await gateway._bridge.call( + functools.partial( + _async_api.transfer, gateway._target(), source, destination, **options + ) + ) + + +async def deploy(deployment: Deployment, gateway: AsyncGateway) -> Deployed: + """Deploy through ``gateway`` and return where everything landed.""" + results = await deploy_all(deployment, [gateway]) + return results[0] + + +async def deploy_all( + deployment: Deployment, gateways: Sequence[AsyncGateway] +) -> list[Deployed]: + """Deploy to every gateway at once, concurrently on the engine.""" + from ._bridge import targets_for_bridge + from ._deploy import _async_api + + bridge, targets = targets_for_bridge(gateways) + return await bridge.call( + functools.partial(_async_api.deploy_all, deployment, targets) + ) + + +@asynccontextmanager +async def open_gateway(spec: str | XSpec = "popen") -> AsyncIterator[AsyncGateway]: + """Spawn one worker for ``spec`` and yield an asyncio gateway to it. + + Convenience for a single-gateway :class:`AsyncGroup`. + """ + async with AsyncGroup() as group: + yield await group.makegateway(spec) diff --git a/src/execnet/gateway.py b/src/execnet/gateway.py index 1d3eb59d..d15263ca 100644 --- a/src/execnet/gateway.py +++ b/src/execnet/gateway.py @@ -1,232 +1,19 @@ -"""Gateway code for initiating popen, socket and ssh connections. +"""Deprecated alias for :mod:`execnet._gateway`. -(c) 2004-2013, Holger Krekel and others +``Gateway`` is exported from :mod:`execnet` and :mod:`execnet.sync`; use those. """ from __future__ import annotations -import inspect -import linecache -import textwrap -import types -from collections.abc import Callable -from typing import TYPE_CHECKING -from typing import Any +from ._shim import forwarder -from . import gateway_base -from .gateway_base import IO -from .gateway_base import Channel -from .gateway_base import Message -from .multi import Group -from .xspec import XSpec +_MOVED = { + "Gateway": "._gateway", + "RInfo": "._gateway", + "RemoteStatus": "._gateway", + "normalize_exec_source": "._exec_source", + "_find_non_builtin_globals": "._exec_source", + "_source_of_function": "._exec_source", +} - -class Gateway(gateway_base.BaseGateway): - """Gateway to a local or remote Python Interpreter.""" - - _group: Group - - def __init__(self, io: IO, spec: XSpec) -> None: - """:private:""" - super().__init__(io=io, id=spec.id, _startcount=1) - self.spec = spec - self._initreceive() - - @property - def remoteaddress(self) -> str: - # Only defined for remote IO types. - return self._io.remoteaddress # type: ignore[attr-defined,no-any-return] - - def __repr__(self) -> str: - """A string representing gateway type and status.""" - try: - r: str = (self.hasreceiver() and "receive-live") or "not-receiving" - i = str(len(self._channelfactory.channels())) - except AttributeError: - r = "uninitialized" - i = "no" - return f"<{self.__class__.__name__} id={self.id!r} {r}, {self.execmodel.backend} model, {i} active channels>" - - def exit(self) -> None: - """Trigger gateway exit. - - Defer waiting for finishing of receiver-thread and subprocess activity - to when group.terminate() is called. - """ - self._trace("gateway.exit() called") - if self not in self._group: - self._trace("gateway already unregistered with group") - return - self._group._unregister(self) - try: - self._trace("--> sending GATEWAY_TERMINATE") - self._send(Message.GATEWAY_TERMINATE) - self._trace("--> io.close_write") - self._io.close_write() - except (ValueError, EOFError, OSError) as exc: - self._trace("io-error: could not send termination sequence") - self._trace(" exception: %r" % exc) - - def reconfigure( - self, py2str_as_py3str: bool = True, py3str_as_py2str: bool = False - ) -> None: - """Set the string coercion for this gateway. - - The default is to try to convert py2 str as py3 str, but not to try and - convert py3 str to py2 str. - """ - self._strconfig = (py2str_as_py3str, py3str_as_py2str) - data = gateway_base.dumps_internal(self._strconfig) - self._send(Message.RECONFIGURE, data=data) - - def _rinfo(self, update: bool = False) -> RInfo: - """Return some sys/env information from remote.""" - if update or not hasattr(self, "_cache_rinfo"): - ch = self.remote_exec(rinfo_source) - try: - self._cache_rinfo = RInfo(ch.receive()) - finally: - ch.waitclose() - return self._cache_rinfo - - def hasreceiver(self) -> bool: - """Whether gateway is able to receive data.""" - return self._receivepool.active_count() > 0 - - def remote_status(self) -> RemoteStatus: - """Obtain information about the remote execution status.""" - channel = self.newchannel() - self._send(Message.STATUS, channel.id) - statusdict = channel.receive() - # the other side didn't actually instantiate a channel - # so we just delete the internal id/channel mapping - self._channelfactory._local_close(channel.id) - return RemoteStatus(statusdict) - - def remote_exec( - self, - source: str | types.FunctionType | Callable[..., object] | types.ModuleType, - **kwargs: object, - ) -> Channel: - """Return channel object and connect it to a remote - execution thread where the given ``source`` executes. - - * ``source`` is a string: execute source string remotely - with a ``channel`` put into the global namespace. - * ``source`` is a pure function: serialize source and - call function with ``**kwargs``, adding a - ``channel`` object to the keyword arguments. - * ``source`` is a pure module: execute source of module - with a ``channel`` in its global namespace. - - In all cases the binding ``__name__='__channelexec__'`` - will be available in the global namespace of the remotely - executing code. - """ - call_name = None - file_name = None - if isinstance(source, types.ModuleType): - file_name = inspect.getsourcefile(source) - linecache.updatecache(file_name) # type: ignore[arg-type] - source = inspect.getsource(source) - elif isinstance(source, types.FunctionType): - call_name = source.__name__ - file_name = inspect.getsourcefile(source) - source = _source_of_function(source) - else: - source = textwrap.dedent(str(source)) - - if not call_name and kwargs: - raise TypeError("can't pass kwargs to non-function remote_exec") - - channel = self.newchannel() - self._send( - Message.CHANNEL_EXEC, - channel.id, - gateway_base.dumps_internal((source, file_name, call_name, kwargs)), - ) - return channel - - def remote_init_threads(self, num: int | None = None) -> None: - """DEPRECATED. Is currently a NO-OPERATION already.""" - print("WARNING: remote_init_threads() is a no-operation in execnet-1.2") - - -class RInfo: - def __init__(self, kwargs) -> None: - self.__dict__.update(kwargs) - - def __repr__(self) -> str: - info = ", ".join(f"{k}={v}" for k, v in sorted(self.__dict__.items())) - return "" % info - - if TYPE_CHECKING: - - def __getattr__(self, name: str) -> Any: ... - - -RemoteStatus = RInfo - - -def rinfo_source(channel) -> None: - import os - import sys - - channel.send( - dict( - executable=sys.executable, - version_info=sys.version_info[:5], - platform=sys.platform, - cwd=os.getcwd(), - pid=os.getpid(), - ) - ) - - -def _find_non_builtin_globals(source: str, codeobj: types.CodeType) -> list[str]: - import ast - import builtins - - vars = dict.fromkeys(codeobj.co_varnames) - return [ - node.id - for node in ast.walk(ast.parse(source)) - if isinstance(node, ast.Name) - and node.id not in vars - and node.id not in builtins.__dict__ - ] - - -def _source_of_function(function: types.FunctionType | Callable[..., object]) -> str: - if function.__name__ == "": - raise ValueError("can't evaluate lambda functions'") - # XXX: we dont check before remote instantiation - # if arguments are used properly - try: - sig = inspect.getfullargspec(function) - except AttributeError: - args = inspect.getargspec(function)[0] - else: - args = sig.args - if not args or args[0] != "channel": - raise ValueError("expected first function argument to be `channel`") - - closure = function.__closure__ - codeobj = function.__code__ - - if closure is not None: - raise ValueError("functions with closures can't be passed") - - try: - source = inspect.getsource(function) - except OSError as e: - raise ValueError("can't find source file for %s" % function) from e - - source = textwrap.dedent(source) # just for inner functions - - used_globals = _find_non_builtin_globals(source, codeobj) - if used_globals: - raise ValueError("the use of non-builtin globals isn't supported", used_globals) - - leading_ws = "\n" * (codeobj.co_firstlineno - 1) - return leading_ws + source +__getattr__ = forwarder("gateway", _MOVED) diff --git a/src/execnet/gateway_base.py b/src/execnet/gateway_base.py index 73dc7175..40eea46c 100644 --- a/src/execnet/gateway_base.py +++ b/src/execnet/gateway_base.py @@ -1,1799 +1,77 @@ -"""Base execnet gateway code send to the other side for bootstrapping. +"""Deprecated: the pre-Trio core module, now split by concern. -:copyright: 2004-2015 -:authors: - - Holger Krekel - - Armin Rigo - - Benjamin Peterson - - Ronny Pfannschmidt - - many others +``execnet.gateway_base`` was never a supported public API. Its contents live +in private modules -- ``_trace``, ``_errors``, ``_execmodel``, ``_message``, +``_serialize``, ``_channel`` and ``_gateway_base`` -- and this shim forwards +to them with a :class:`DeprecationWarning`. + +Use :mod:`execnet` / :mod:`execnet.sync`, :mod:`execnet.trio`, +:mod:`execnet.aio` or :mod:`execnet.gevent` instead. The standalone +serializer stays internal; :func:`execnet.can_send` answers "can this value +cross a channel?" without it. """ from __future__ import annotations -import abc -import os -import struct -import sys -import traceback -import weakref -from _thread import interrupt_main -from collections.abc import Callable -from collections.abc import Iterator -from contextlib import suppress -from io import BytesIO -from typing import Any -from typing import Literal -from typing import Protocol -from typing import cast -from typing import overload - - -class WriteIO(Protocol): - def write(self, data: bytes, /) -> None: ... - - -class ReadIO(Protocol): - def read(self, numbytes: int, /) -> bytes: ... - - -class IO(Protocol): - execmodel: ExecModel - - def read(self, numbytes: int, /) -> bytes: ... - - def write(self, data: bytes, /) -> None: ... - - def close_read(self) -> None: ... - - def close_write(self) -> None: ... - - def wait(self) -> int | None: ... - - def kill(self) -> None: ... - - -class Event(Protocol): - """Protocol for types which look like threading.Event.""" - - def is_set(self) -> bool: ... - - def set(self) -> None: ... - - def clear(self) -> None: ... - - def wait(self, timeout: float | None = None) -> bool: ... - - -class ExecModel(metaclass=abc.ABCMeta): - @property - @abc.abstractmethod - def backend(self) -> str: - raise NotImplementedError() - - def __repr__(self) -> str: - return "" % self.backend - - @property - @abc.abstractmethod - def queue(self): - raise NotImplementedError() - - @property - @abc.abstractmethod - def subprocess(self): - raise NotImplementedError() - - @property - @abc.abstractmethod - def socket(self): - raise NotImplementedError() - - @abc.abstractmethod - def start(self, func, args=()) -> None: - raise NotImplementedError() - - @abc.abstractmethod - def get_ident(self) -> int: - raise NotImplementedError() - - @abc.abstractmethod - def sleep(self, delay: float) -> None: - raise NotImplementedError() - - @abc.abstractmethod - def fdopen(self, fd, mode, bufsize=1, closefd=True): - raise NotImplementedError() - - @abc.abstractmethod - def Lock(self): - raise NotImplementedError() - - @abc.abstractmethod - def RLock(self): - raise NotImplementedError() - - @abc.abstractmethod - def Event(self) -> Event: - raise NotImplementedError() - - -class ThreadExecModel(ExecModel): - backend = "thread" - - @property - def queue(self): - import queue - - return queue - - @property - def subprocess(self): - import subprocess - - return subprocess - - @property - def socket(self): - import socket - - return socket - - def get_ident(self) -> int: - import _thread - - return _thread.get_ident() - - def sleep(self, delay: float) -> None: - import time - - time.sleep(delay) - - def start(self, func, args=()) -> None: - import _thread - - _thread.start_new_thread(func, args) - - def fdopen(self, fd, mode, bufsize=1, closefd=True): - import os - - return os.fdopen(fd, mode, bufsize, encoding="utf-8", closefd=closefd) - - def Lock(self): - import threading - - return threading.RLock() - - def RLock(self): - import threading - - return threading.RLock() - - def Event(self): - import threading - - return threading.Event() - - -class MainThreadOnlyExecModel(ThreadExecModel): - backend = "main_thread_only" - - -class EventletExecModel(ExecModel): - backend = "eventlet" - - @property - def queue(self): - import eventlet - - return eventlet.queue - - @property - def subprocess(self): - import eventlet.green.subprocess - - return eventlet.green.subprocess - - @property - def socket(self): - import eventlet.green.socket - - return eventlet.green.socket - - def get_ident(self) -> int: - import eventlet.green.thread - - return eventlet.green.thread.get_ident() # type: ignore[no-any-return] - - def sleep(self, delay: float) -> None: - import eventlet - - eventlet.sleep(delay) - - def start(self, func, args=()) -> None: - import eventlet - - eventlet.spawn_n(func, *args) - - def fdopen(self, fd, mode, bufsize=1, closefd=True): - import eventlet.green.os - - return eventlet.green.os.fdopen(fd, mode, bufsize, closefd=closefd) - - def Lock(self): - import eventlet.green.threading - - return eventlet.green.threading.RLock() - - def RLock(self): - import eventlet.green.threading - - return eventlet.green.threading.RLock() - - def Event(self): - import eventlet.green.threading - - return eventlet.green.threading.Event() - - -class GeventExecModel(ExecModel): - backend = "gevent" - - @property - def queue(self): - import gevent.queue - - return gevent.queue - - @property - def subprocess(self): - import gevent.subprocess - - return gevent.subprocess - - @property - def socket(self): - import gevent - - return gevent.socket - - def get_ident(self) -> int: - import gevent.thread - - return gevent.thread.get_ident() # type: ignore[no-any-return] - - def sleep(self, delay: float) -> None: - import gevent - - gevent.sleep(delay) - - def start(self, func, args=()) -> None: - import gevent - - gevent.spawn(func, *args) - - def fdopen(self, fd, mode, bufsize=1, closefd=True): - import gevent.fileobject - - # Prefer FileObject (FileObjectPosix on Unix). FileObjectThread keeps a - # native threadpool alive and can prevent interpreter shutdown, which - # hangs tests/scripts that open stdio via init_popen_io and then exit. - return gevent.fileobject.FileObject(fd, mode, bufsize, closefd=closefd) - - def Lock(self): - import gevent.lock - - return gevent.lock.RLock() - - def RLock(self): - import gevent.lock - - return gevent.lock.RLock() - - def Event(self): - import gevent.event - - return gevent.event.Event() - - -def get_execmodel(backend: str | ExecModel) -> ExecModel: - if isinstance(backend, ExecModel): - return backend - if backend == "thread": - return ThreadExecModel() - elif backend == "main_thread_only": - return MainThreadOnlyExecModel() - elif backend == "eventlet": - return EventletExecModel() - elif backend == "gevent": - return GeventExecModel() - else: - raise ValueError(f"unknown execmodel {backend!r}") - - -class Reply: - """Provide access to the result of a function execution that got dispatched - through WorkerPool.spawn().""" - - def __init__(self, task, threadmodel: ExecModel) -> None: - self.task = task - self._result_ready = threadmodel.Event() - self.running = True - - def get(self, timeout: float | None = None): - """get the result object from an asynchronous function execution. - if the function execution raised an exception, - then calling get() will reraise that exception - including its traceback. - """ - self.waitfinish(timeout) - try: - return self._result - except AttributeError: - raise self._exc from None - - def waitfinish(self, timeout: float | None = None) -> None: - if not self._result_ready.wait(timeout): - raise OSError(f"timeout waiting for {self.task!r}") - - def run(self) -> None: - func, args, kwargs = self.task - try: - try: - self._result = func(*args, **kwargs) - except BaseException as exc: - self._exc = exc - finally: - self._result_ready.set() - self.running = False - - -class WorkerPool: - """A WorkerPool allows to spawn function executions - to threads, returning a reply object on which you - can ask for the result (and get exceptions reraised). - - This implementation allows the main thread to integrate - itself into performing function execution through - calling integrate_as_primary_thread() which will return - when the pool received a trigger_shutdown(). - - By default allows unlimited number of spawns. - """ - - _primary_thread_task: Reply | None - - def __init__(self, execmodel: ExecModel, hasprimary: bool = False) -> None: - self.execmodel = execmodel - self._running_lock = self.execmodel.Lock() - self._running: set[Reply] = set() - self._shuttingdown = False - self._waitall_events: list[Event] = [] - if hasprimary: - if self.execmodel.backend not in ("thread", "main_thread_only"): - raise ValueError("hasprimary=True requires thread model") - self._primary_thread_task_ready: Event | None = self.execmodel.Event() - else: - self._primary_thread_task_ready = None - - def integrate_as_primary_thread(self) -> None: - """Integrate the thread with which we are called as a primary - thread for executing functions triggered with spawn().""" - assert self.execmodel.backend in ("thread", "main_thread_only"), self.execmodel - primary_thread_task_ready = self._primary_thread_task_ready - assert primary_thread_task_ready is not None - # interacts with code at REF1 - while 1: - primary_thread_task_ready.wait() - reply = self._primary_thread_task - if reply is None: # trigger_shutdown() woke us up - break - self._perform_spawn(reply) - # we are concurrent with trigger_shutdown and spawn - with self._running_lock: - if self._shuttingdown: - break - # Only clear if _try_send_to_primary_thread has not - # yet set the next self._primary_thread_task reply - # after waiting for this one to complete. - if reply is self._primary_thread_task: - primary_thread_task_ready.clear() - - def trigger_shutdown(self) -> None: - with self._running_lock: - self._shuttingdown = True - if self._primary_thread_task_ready is not None: - self._primary_thread_task = None - self._primary_thread_task_ready.set() - - def active_count(self) -> int: - return len(self._running) - - def _perform_spawn(self, reply: Reply) -> None: - reply.run() - with self._running_lock: - self._running.remove(reply) - if not self._running: - while self._waitall_events: - waitall_event = self._waitall_events.pop() - waitall_event.set() - - def _try_send_to_primary_thread(self, reply: Reply) -> bool: - # REF1 in 'thread' model we give priority to running in main thread - # note that we should be called with _running_lock hold - primary_thread_task_ready = self._primary_thread_task_ready - if primary_thread_task_ready is not None: - if not primary_thread_task_ready.is_set(): - self._primary_thread_task = reply - # wake up primary thread - primary_thread_task_ready.set() - return True - elif ( - self.execmodel.backend == "main_thread_only" - and self._primary_thread_task is not None - ): - self._primary_thread_task.waitfinish() - self._primary_thread_task = reply - # wake up primary thread (it's okay if this is already set - # because we waited for the previous task to finish above - # and integrate_as_primary_thread will not clear it when - # it enters self._running_lock if it detects that a new - # task is available) - primary_thread_task_ready.set() - return True - return False - - def spawn(self, func, *args, **kwargs) -> Reply: - """Asynchronously dispatch func(*args, **kwargs) and return a Reply.""" - reply = Reply((func, args, kwargs), self.execmodel) - with self._running_lock: - if self._shuttingdown: - raise ValueError("pool is shutting down") - self._running.add(reply) - if not self._try_send_to_primary_thread(reply): - self.execmodel.start(self._perform_spawn, (reply,)) - return reply - - def terminate(self, timeout: float | None = None) -> bool: - """Trigger shutdown and wait for completion of all executions.""" - self.trigger_shutdown() - return self.waitall(timeout=timeout) - - def waitall(self, timeout: float | None = None) -> bool: - """Wait until all active spawns have finished executing.""" - with self._running_lock: - if not self._running: - return True - # if a Reply still runs, we let run_and_release - # signal us -- note that we are still holding the - # _running_lock to avoid race conditions - my_waitall_event = self.execmodel.Event() - self._waitall_events.append(my_waitall_event) - return my_waitall_event.wait(timeout=timeout) - - -sysex = (KeyboardInterrupt, SystemExit) - - -DEBUG = os.environ.get("EXECNET_DEBUG") -pid = os.getpid() -if DEBUG == "2": - - def trace(*msg: object) -> None: - try: - line = " ".join(map(str, msg)) - sys.stderr.write(f"[{pid}] {line}\n") - sys.stderr.flush() - except Exception: - pass # nothing we can do, likely interpreter-shutdown - -elif DEBUG: - import os - import tempfile - - fn = os.path.join(tempfile.gettempdir(), "execnet-debug-%d" % pid) - # sys.stderr.write("execnet-debug at %r" % (fn,)) - debugfile = open(fn, "w") - - def trace(*msg: object) -> None: - try: - line = " ".join(map(str, msg)) - debugfile.write(line + "\n") - debugfile.flush() - except Exception as exc: - try: - sys.stderr.write(f"[{pid}] exception during tracing: {exc!r}\n") - except Exception: - pass # nothing we can do, likely interpreter-shutdown - -else: - notrace = trace = lambda *msg: None - - -class Popen2IO: - error = (IOError, OSError, EOFError) - - def __init__(self, outfile, infile, execmodel: ExecModel) -> None: - # we need raw byte streams - self.outfile, self.infile = outfile, infile - if sys.platform == "win32": - import msvcrt - - try: - msvcrt.setmode(infile.fileno(), os.O_BINARY) - msvcrt.setmode(outfile.fileno(), os.O_BINARY) - except (AttributeError, OSError): - pass - self._read = getattr(infile, "buffer", infile).read - self._write = getattr(outfile, "buffer", outfile).write - self.execmodel = execmodel - - def read(self, numbytes: int) -> bytes: - """Read exactly 'numbytes' bytes from the pipe.""" - # a file in non-blocking mode may return less bytes, so we loop - buf = b"" - while numbytes > len(buf): - data = self._read(numbytes - len(buf)) - if not data: - raise EOFError("expected %d bytes, got %d" % (numbytes, len(buf))) - buf += data - return buf - - def write(self, data: bytes) -> None: - """Write out all data bytes.""" - assert isinstance(data, bytes) - self._write(data) - self.outfile.flush() - - def close_read(self) -> None: - self.infile.close() - - def close_write(self) -> None: - self.outfile.close() - - -class Message: - """Encapsulates Messages and their wire protocol.""" - - # message code -> name, handler - _types: dict[int, tuple[str, Callable[[Message, BaseGateway], None]]] = {} - - def __init__(self, msgcode: int, channelid: int = 0, data: bytes = b"") -> None: - self.msgcode = msgcode - self.channelid = channelid - self.data = data - - @staticmethod - def from_io(io: ReadIO) -> Message: - try: - header = io.read(9) # type 1, channel 4, payload 4 - if not header: - raise EOFError("empty read") - except EOFError as e: - raise EOFError("couldn't load message header, " + e.args[0]) from None - msgtype, channel, payload = struct.unpack("!bii", header) - return Message(msgtype, channel, io.read(payload)) - - def to_io(self, io: WriteIO) -> None: - header = struct.pack("!bii", self.msgcode, self.channelid, len(self.data)) - io.write(header + self.data) - - def received(self, gateway: BaseGateway) -> None: - handler = self._types[self.msgcode][1] - handler(self, gateway) - - def __repr__(self) -> str: - name = self._types[self.msgcode][0] - return f"" - - def _status(message: Message, gateway: BaseGateway) -> None: - # we use the channelid to send back information - # but don't instantiate a channel object - d = { - "numchannels": len(gateway._channelfactory._channels), - # TODO(typing): Attribute `_execpool` is only on WorkerGateway. - "numexecuting": gateway._execpool.active_count(), # type: ignore[attr-defined] - "execmodel": gateway.execmodel.backend, - } - gateway._send(Message.CHANNEL_DATA, message.channelid, dumps_internal(d)) - gateway._send(Message.CHANNEL_CLOSE, message.channelid) - - STATUS = 0 - _types[STATUS] = ("STATUS", _status) - - def _reconfigure(message: Message, gateway: BaseGateway) -> None: - data = loads_internal(message.data, gateway) - assert isinstance(data, tuple) - strconfig: tuple[bool, bool] = data - if message.channelid == 0: - gateway._strconfig = strconfig - else: - gateway._channelfactory.new(message.channelid)._strconfig = strconfig - - RECONFIGURE = 1 - _types[RECONFIGURE] = ("RECONFIGURE", _reconfigure) - - def _gateway_terminate(message: Message, gateway: BaseGateway) -> None: - raise GatewayReceivedTerminate(gateway) - - GATEWAY_TERMINATE = 2 - _types[GATEWAY_TERMINATE] = ("GATEWAY_TERMINATE", _gateway_terminate) - - def _channel_exec(message: Message, gateway: BaseGateway) -> None: - channel = gateway._channelfactory.new(message.channelid) - gateway._local_schedulexec(channel=channel, sourcetask=message.data) - - CHANNEL_EXEC = 3 - _types[CHANNEL_EXEC] = ("CHANNEL_EXEC", _channel_exec) - - def _channel_data(message: Message, gateway: BaseGateway) -> None: - gateway._channelfactory._local_receive(message.channelid, message.data) - - CHANNEL_DATA = 4 - _types[CHANNEL_DATA] = ("CHANNEL_DATA", _channel_data) - - def _channel_close(message: Message, gateway: BaseGateway) -> None: - gateway._channelfactory._local_close(message.channelid) - - CHANNEL_CLOSE = 5 - _types[CHANNEL_CLOSE] = ("CHANNEL_CLOSE", _channel_close) - - def _channel_close_error(message: Message, gateway: BaseGateway) -> None: - error_message = loads_internal(message.data) - assert isinstance(error_message, str) - remote_error = RemoteError(error_message) - gateway._channelfactory._local_close(message.channelid, remote_error) - - CHANNEL_CLOSE_ERROR = 6 - _types[CHANNEL_CLOSE_ERROR] = ("CHANNEL_CLOSE_ERROR", _channel_close_error) - - def _channel_last_message(message: Message, gateway: BaseGateway) -> None: - gateway._channelfactory._local_close(message.channelid, sendonly=True) - - CHANNEL_LAST_MESSAGE = 7 - _types[CHANNEL_LAST_MESSAGE] = ("CHANNEL_LAST_MESSAGE", _channel_last_message) - - -class GatewayReceivedTerminate(Exception): - """Receiverthread got termination message.""" - - -def geterrortext( - exc: BaseException, - format_exception=traceback.format_exception, - sysex: tuple[type[BaseException], ...] = sysex, -) -> str: - try: - # In py310, can change this to: - # l = format_exception(exc) - l = format_exception(type(exc), exc, exc.__traceback__) - errortext = "".join(l) - except sysex: - raise - except BaseException: - errortext = f"{type(exc).__name__}: {exc}" - return errortext - - -class RemoteError(Exception): - """Exception containing a stringified error from the other side.""" - - def __init__(self, formatted: str) -> None: - super().__init__() - self.formatted = formatted - - def __str__(self) -> str: - return self.formatted - - def __repr__(self) -> str: - return f"{self.__class__.__name__}: {self.formatted}" - - def warn(self) -> None: - if self.formatted != INTERRUPT_TEXT: - # XXX do this better - sys.stderr.write(f"[{os.getpid()}] Warning: unhandled {self!r}\n") - - -class TimeoutError(IOError): - """Exception indicating that a timeout was reached.""" - - -NO_ENDMARKER_WANTED = object() - - -class Channel: - """Communication channel between two Python Interpreter execution points.""" - - RemoteError = RemoteError - TimeoutError = TimeoutError - _INTERNALWAKEUP = 1000 - _executing = False - - def __init__(self, gateway: BaseGateway, id: int) -> None: - """:private:""" - assert isinstance(id, int) - assert not isinstance(gateway, type) - self.gateway = gateway - # XXX: defaults copied from Unserializer - self._strconfig = getattr(gateway, "_strconfig", (True, False)) - self.id = id - self._items = self.gateway.execmodel.queue.Queue() - self._closed = False - self._receiveclosed = self.gateway.execmodel.Event() - self._remoteerrors: list[RemoteError] = [] - - def _trace(self, *msg: object) -> None: - self.gateway._trace(self.id, *msg) - - def setcallback( - self, - callback: Callable[[Any], Any], - endmarker: object = NO_ENDMARKER_WANTED, - ) -> None: - """Set a callback function for receiving items. - - All already-queued items will immediately trigger the callback. - Afterwards the callback will execute in the receiver thread - for each received data item and calls to ``receive()`` will - raise an error. - If an endmarker is specified the callback will eventually - be called with the endmarker when the channel closes. - """ - _callbacks = self.gateway._channelfactory._callbacks - with self.gateway._receivelock: - if self._items is None: - raise OSError(f"{self!r} has callback already registered") - items = self._items - self._items = None - while 1: - try: - olditem = items.get(block=False) - except self.gateway.execmodel.queue.Empty: - if not (self._closed or self._receiveclosed.is_set()): - _callbacks[self.id] = (callback, endmarker, self._strconfig) - break - else: - if olditem is ENDMARKER: - items.put(olditem) # for other receivers - if endmarker is not NO_ENDMARKER_WANTED: - callback(endmarker) - break - else: - callback(olditem) - - def __repr__(self) -> str: - flag = (self.isclosed() and "closed") or "open" - return "" % (self.id, flag) - - def __del__(self) -> None: - if self.gateway is None: # can be None in tests - return # type: ignore[unreachable] - - self._trace("channel.__del__") - # no multithreading issues here, because we have the last ref to 'self' - if self._closed: - # state transition "closed" --> "deleted" - for error in self._remoteerrors: - error.warn() - elif self._receiveclosed.is_set(): - # state transition "sendonly" --> "deleted" - # the remote channel is already in "deleted" state, nothing to do - pass - else: - # state transition "opened" --> "deleted" - # check if we are in the middle of interpreter shutdown - # in which case the process will go away and we probably - # don't need to try to send a closing or last message - # (and often it won't work anymore to send things out) - if Message is not None: - if self._items is None: # has_callback - msgcode = Message.CHANNEL_LAST_MESSAGE - else: - msgcode = Message.CHANNEL_CLOSE - with suppress(OSError, ValueError): # ignore problems with sending - self.gateway._send(msgcode, self.id) - - def _getremoteerror(self): - try: - return self._remoteerrors.pop(0) - except IndexError: - try: - return self.gateway._error - except AttributeError: - pass - return None - - # - # public API for channel objects - # - def isclosed(self) -> bool: - """Return True if the channel is closed. - - A closed channel may still hold items. - """ - return self._closed - - @overload - def makefile(self, mode: Literal["r"], proxyclose: bool = ...) -> ChannelFileRead: - pass - - @overload - def makefile( - self, - mode: Literal["w"] = ..., - proxyclose: bool = ..., - ) -> ChannelFileWrite: - pass - - def makefile( - self, - mode: Literal["r", "w"] = "w", - proxyclose: bool = False, - ) -> ChannelFileWrite | ChannelFileRead: - """Return a file-like object. - - mode can be 'w' or 'r' for writeable/readable files. - If proxyclose is true, file.close() will also close the channel. - """ - if mode == "w": - return ChannelFileWrite(channel=self, proxyclose=proxyclose) - elif mode == "r": - return ChannelFileRead(channel=self, proxyclose=proxyclose) - raise ValueError(f"mode {mode!r} not available") - - def close(self, error=None) -> None: - """Close down this channel with an optional error message. - - Note that closing of a channel tied to remote_exec happens - automatically at the end of execution and cannot - be done explicitly. - """ - if self._executing: - raise OSError("cannot explicitly close channel within remote_exec") - if self._closed: - self.gateway._trace(self, "ignoring redundant call to close()") - if not self._closed: - # state transition "opened/sendonly" --> "closed" - # threads warning: the channel might be closed under our feet, - # but it's never damaging to send too many CHANNEL_CLOSE messages - # however, if the other side triggered a close already, we - # do not send back a closed message. - if not self._receiveclosed.is_set(): - put = self.gateway._send - if error is not None: - put(Message.CHANNEL_CLOSE_ERROR, self.id, dumps_internal(error)) - else: - put(Message.CHANNEL_CLOSE, self.id) - self._trace("sent channel close message") - if isinstance(error, RemoteError): - self._remoteerrors.append(error) - self._closed = True # --> "closed" - self._receiveclosed.set() - queue = self._items - if queue is not None: - queue.put(ENDMARKER) - self.gateway._channelfactory._no_longer_opened(self.id) - - def waitclose(self, timeout: float | None = None) -> None: - """Wait until this channel is closed (or the remote side - otherwise signalled that no more data was being sent). - - The channel may still hold receiveable items, but not receive - any more after waitclose() has returned. - - Exceptions from executing code on the other side are reraised as local - channel.RemoteErrors. - - EOFError is raised if the reading-connection was prematurely closed, - which often indicates a dying process. - - self.TimeoutError is raised after the specified number of seconds - (default is None, i.e. wait indefinitely). - """ - # wait for non-"opened" state - self._receiveclosed.wait(timeout=timeout) - if not self._receiveclosed.is_set(): - raise self.TimeoutError("Timeout after %r seconds" % timeout) - error = self._getremoteerror() - if error: - raise error - - def send(self, item: object) -> None: - """Sends the given item to the other side of the channel, - possibly blocking if the sender queue is full. - - The item must be a simple Python type and will be - copied to the other side by value. - - OSError is raised if the write pipe was prematurely closed. - """ - if self.isclosed(): - raise OSError(f"cannot send to {self!r}") - self.gateway._send(Message.CHANNEL_DATA, self.id, dumps_internal(item)) - - def receive(self, timeout: float | None = None) -> Any: - """Receive a data item that was sent from the other side. - - timeout: None [default] blocked waiting. A positive number - indicates the number of seconds after which a channel.TimeoutError - exception will be raised if no item was received. - - Note that exceptions from the remotely executing code will be - reraised as channel.RemoteError exceptions containing - a textual representation of the remote traceback. - """ - itemqueue = self._items - if itemqueue is None: - raise OSError("cannot receive(), channel has receiver callback") - try: - x = itemqueue.get(timeout=timeout) - except self.gateway.execmodel.queue.Empty: - raise self.TimeoutError("no item after %r seconds" % timeout) from None - if x is ENDMARKER: - itemqueue.put(x) # for other receivers - raise self._getremoteerror() or EOFError() - else: - return x - - def __iter__(self) -> Iterator[Any]: - return self - - def next(self) -> Any: - try: - return self.receive() - except EOFError: - raise StopIteration from None - - __next__ = next - - def reconfigure( - self, py2str_as_py3str: bool = True, py3str_as_py2str: bool = False - ) -> None: - """Set the string coercion for this channel. - - The default is to try to convert py2 str as py3 str, - but not to try and convert py3 str to py2 str - """ - self._strconfig = (py2str_as_py3str, py3str_as_py2str) - data = dumps_internal(self._strconfig) - self.gateway._send(Message.RECONFIGURE, self.id, data=data) - - -ENDMARKER = object() -INTERRUPT_TEXT = "keyboard-interrupted" -MAIN_THREAD_ONLY_DEADLOCK_TEXT = ( - "concurrent remote_exec would cause deadlock for main_thread_only execmodel" -) - - -class ChannelFactory: - def __init__(self, gateway: BaseGateway, startcount: int = 1) -> None: - self._channels: weakref.WeakValueDictionary[int, Channel] = ( - weakref.WeakValueDictionary() - ) - # Channel ID => (callback, end marker, strconfig) - self._callbacks: dict[ - int, tuple[Callable[[Any], Any], object, tuple[bool, bool]] - ] = {} - self._writelock = gateway.execmodel.Lock() - self.gateway = gateway - self.count = startcount - self.finished = False - self._list = list # needed during interp-shutdown - - def new(self, id: int | None = None) -> Channel: - """Create a new Channel with 'id' (or create new id if None).""" - with self._writelock: - if self.finished: - raise OSError(f"connection already closed: {self.gateway}") - if id is None: - id = self.count - self.count += 2 - try: - channel = self._channels[id] - except KeyError: - channel = self._channels[id] = Channel(self.gateway, id) - return channel - - def channels(self) -> list[Channel]: - return self._list(self._channels.values()) - - # - # internal methods, called from the receiver thread - # - def _no_longer_opened(self, id: int) -> None: - self._channels.pop(id, None) - item = self._callbacks.pop(id, None) - if item is not None: - callback, endmarker, _strconfig = item - if endmarker is not NO_ENDMARKER_WANTED: - callback(endmarker) - - def _local_close(self, id: int, remoteerror=None, sendonly: bool = False) -> None: - channel = self._channels.get(id) - if channel is None: - # channel already in "deleted" state - if remoteerror: - remoteerror.warn() - self._no_longer_opened(id) - else: - # state transition to "closed" state - if remoteerror: - channel._remoteerrors.append(remoteerror) - queue = channel._items - if queue is not None: - queue.put(ENDMARKER) - self._no_longer_opened(id) - if not sendonly: # otherwise #--> "sendonly" - channel._closed = True # --> "closed" - channel._receiveclosed.set() - - def _local_receive(self, id: int, data) -> None: - # executes in receiver thread - channel = self._channels.get(id) - try: - callback, _endmarker, strconfig = self._callbacks[id] - except KeyError: - queue = channel._items if channel is not None else None - if queue is None: - pass # drop data - else: - item = loads_internal(data, channel) - queue.put(item) - else: - try: - data = loads_internal(data, channel, strconfig) - callback(data) # even if channel may be already closed - except Exception as exc: - self.gateway._trace("exception during callback: %s" % exc) - errortext = self.gateway._geterrortext(exc) - self.gateway._send( - Message.CHANNEL_CLOSE_ERROR, id, dumps_internal(errortext) - ) - self._local_close(id, errortext) - - def _finished_receiving(self) -> None: - with self._writelock: - self.finished = True - for id in self._list(self._channels): - self._local_close(id, sendonly=True) - for id in self._list(self._callbacks): - self._no_longer_opened(id) - - -class ChannelFile: - def __init__(self, channel: Channel, proxyclose: bool = True) -> None: - self.channel = channel - self._proxyclose = proxyclose - - def isatty(self) -> bool: - return False - - def close(self) -> None: - if self._proxyclose: - self.channel.close() - - def __repr__(self) -> str: - state = (self.channel.isclosed() and "closed") or "open" - return "" % (self.channel.id, state) - - -class ChannelFileWrite(ChannelFile): - def write(self, out: bytes) -> None: - self.channel.send(out) - - def flush(self) -> None: - pass - - -class ChannelFileRead(ChannelFile): - def __init__(self, channel: Channel, proxyclose: bool = True) -> None: - super().__init__(channel, proxyclose) - self._buffer: str | None = None - - def read(self, n: int) -> str: - try: - if self._buffer is None: - self._buffer = cast(str, self.channel.receive()) - while len(self._buffer) < n: - self._buffer += cast(str, self.channel.receive()) - except EOFError: - self.close() - if self._buffer is None: - ret = "" - else: - ret = self._buffer[:n] - self._buffer = self._buffer[n:] - return ret - - def readline(self) -> str: - if self._buffer is not None: - i = self._buffer.find("\n") - if i != -1: - return self.read(i + 1) - line = self.read(len(self._buffer) + 1) - else: - line = self.read(1) - while line and line[-1] != "\n": - c = self.read(1) - if not c: - break - line += c - return line - - -class BaseGateway: - _sysex = sysex - id = "" - - def __init__(self, io: IO, id, _startcount: int = 2) -> None: - self.execmodel = io.execmodel - self._io = io - self.id = id - self._strconfig = (Unserializer.py2str_as_py3str, Unserializer.py3str_as_py2str) - self._channelfactory = ChannelFactory(self, _startcount) - self._receivelock = self.execmodel.RLock() - # globals may be NONE at process-termination - self.__trace = trace - self._geterrortext = geterrortext - self._receivepool = WorkerPool(self.execmodel) - - def _trace(self, *msg: object) -> None: - self.__trace(self.id, *msg) - - def _initreceive(self) -> None: - self._receivepool.spawn(self._thread_receiver) - - def _thread_receiver(self) -> None: - def log(*msg: object) -> None: - self._trace("[receiver-thread]", *msg) - - log("RECEIVERTHREAD: starting to run") - io = self._io - try: - while 1: - msg = Message.from_io(io) - log("received", msg) - with self._receivelock: - msg.received(self) - del msg - except (KeyboardInterrupt, GatewayReceivedTerminate): - pass - except EOFError as exc: - log("EOF without prior gateway termination message") - self._error = exc - except Exception as exc: - log(self._geterrortext(exc)) - log("finishing receiving thread") - # wake up and terminate any execution waiting to receive - self._channelfactory._finished_receiving() - log("terminating execution") - self._terminate_execution() - log("closing read") - self._io.close_read() - log("closing write") - self._io.close_write() - log("terminating our receive pseudo pool") - self._receivepool.trigger_shutdown() - - def _terminate_execution(self) -> None: - pass - - def _send(self, msgcode: int, channelid: int = 0, data: bytes = b"") -> None: - message = Message(msgcode, channelid, data) - try: - message.to_io(self._io) - self._trace("sent", message) - except (OSError, ValueError) as e: - self._trace("failed to send", message, e) - # ValueError might be because the IO is already closed - raise OSError("cannot send (already closed?)") from e - - def _local_schedulexec(self, channel: Channel, sourcetask: bytes) -> None: - channel.close("execution disallowed") - - # _____________________________________________________________________ - # - # High Level Interface - # _____________________________________________________________________ - # - def newchannel(self) -> Channel: - """Return a new independent channel.""" - return self._channelfactory.new() - - def join(self, timeout: float | None = None) -> None: - """Wait for receiverthread to terminate.""" - self._trace("waiting for receiver thread to finish") - self._receivepool.waitall(timeout) - - -class WorkerGateway(BaseGateway): - def _local_schedulexec(self, channel: Channel, sourcetask: bytes) -> None: - if self._execpool.execmodel.backend == "main_thread_only": - assert self._executetask_complete is not None - # It's necessary to wait for a short time in order to ensure - # that we do not report a false-positive deadlock error, since - # channel close does not elicit a response that would provide - # a guarantee to remote_exec callers that the previous task - # has released the main thread. If the timeout expires then it - # should be practically impossible to report a false-positive. - if not self._executetask_complete.wait(timeout=1): - channel.close(MAIN_THREAD_ONLY_DEADLOCK_TEXT) - return - # It's only safe to clear here because the above wait proves - # that there is not a previous task about to set it again. - self._executetask_complete.clear() - - sourcetask_ = loads_internal(sourcetask) - self._execpool.spawn(self.executetask, (channel, sourcetask_)) - - def _terminate_execution(self) -> None: - # called from receiverthread - self._trace("shutting down execution pool") - self._execpool.trigger_shutdown() - if not self._execpool.waitall(5.0): - self._trace("execution ongoing after 5 secs, trying interrupt_main") - # We try hard to terminate execution based on the assumption - # that there is only one gateway object running per-process. - if sys.platform != "win32": - self._trace("sending ourselves a SIGINT") - os.kill(os.getpid(), 2) # send ourselves a SIGINT - elif interrupt_main is not None: - self._trace("calling interrupt_main()") - interrupt_main() - if not self._execpool.waitall(10.0): - self._trace( - "execution did not finish in another 10 secs, calling os._exit()" - ) - os._exit(1) - - def serve(self) -> None: - def trace(msg: str) -> None: - self._trace("[serve] " + msg) - - hasprimary = self.execmodel.backend in ("thread", "main_thread_only") - self._execpool = WorkerPool(self.execmodel, hasprimary=hasprimary) - self._executetask_complete = None - if self.execmodel.backend == "main_thread_only": - self._executetask_complete = self.execmodel.Event() - # Initialize state to indicate that there is no previous task - # executing so that we don't need a separate flag to track this. - self._executetask_complete.set() - trace("spawning receiver thread") - self._initreceive() - try: - if hasprimary: - # this will return when we are in shutdown - trace("integrating as primary thread") - self._execpool.integrate_as_primary_thread() - trace("joining receiver thread") - self.join() - except KeyboardInterrupt: - # in the worker we can't really do anything sensible - trace("swallowing keyboardinterrupt, serve finished") - - def executetask( - self, - item: tuple[Channel, tuple[str, str | None, str | None, dict[str, object]]], - ) -> None: - try: - channel, (source, file_name, call_name, kwargs) = item - loc: dict[str, Any] = {"channel": channel, "__name__": "__channelexec__"} - self._trace(f"execution starts[{channel.id}]: {repr(source)[:50]}") - channel._executing = True - try: - co = compile(source + "\n", file_name or "", "exec") - exec(co, loc) - if call_name: - self._trace("calling %s(**%60r)" % (call_name, kwargs)) - function = loc[call_name] - function(channel, **kwargs) - finally: - channel._executing = False - self._trace("execution finished") - except KeyboardInterrupt: - channel.close(INTERRUPT_TEXT) - raise - except EOFError: - self._trace("ignoring EOFError because receiving finished") - - except BaseException as exc: - if not channel.gateway._channelfactory.finished: - self._trace(f"got exception: {exc!r}") - errortext = self._geterrortext(exc) - channel.close(errortext) - return - channel.close() - if self._executetask_complete is not None: - # Indicate that this task has finished executing, meaning - # that there is no possibility of it triggering a deadlock - # for the next spawn call. - self._executetask_complete.set() - - -# -# Cross-Python pickling code, tested from test_serializer.py -# - - -class DataFormatError(Exception): - pass - - -class DumpError(DataFormatError): - """Error while serializing an object.""" - - -class LoadError(DataFormatError): - """Error while unserializing an object.""" - - -def bchr(n: int) -> bytes: - return bytes([n]) - - -DUMPFORMAT_VERSION = bchr(2) - -FOUR_BYTE_INT_MAX = 2147483647 - -FLOAT_FORMAT = "!d" -FLOAT_FORMAT_SIZE = struct.calcsize(FLOAT_FORMAT) -COMPLEX_FORMAT = "!dd" -COMPLEX_FORMAT_SIZE = struct.calcsize(COMPLEX_FORMAT) - - -class _Stop(Exception): - pass - - -class opcode: - """Container for name -> num mappings.""" - - BUILDTUPLE = b"@" - BYTES = b"A" - CHANNEL = b"B" - FALSE = b"C" - FLOAT = b"D" - FROZENSET = b"E" - INT = b"F" - LONG = b"G" - LONGINT = b"H" - LONGLONG = b"I" - NEWDICT = b"J" - NEWLIST = b"K" - NONE = b"L" - PY2STRING = b"M" - PY3STRING = b"N" - SET = b"O" - SETITEM = b"P" - STOP = b"Q" - TRUE = b"R" - UNICODE = b"S" - COMPLEX = b"T" - - -class Unserializer: - num2func: dict[bytes, Callable[[Unserializer], None]] = {} - py2str_as_py3str = True # True - py3str_as_py2str = False # false means py2 will get unicode - - def __init__( - self, - stream: ReadIO, - channel_or_gateway: Channel | BaseGateway | None = None, - strconfig: tuple[bool, bool] | None = None, - ) -> None: - if isinstance(channel_or_gateway, Channel): - gw: BaseGateway | None = channel_or_gateway.gateway - else: - gw = channel_or_gateway - if channel_or_gateway is not None: - strconfig = channel_or_gateway._strconfig - if strconfig: - self.py2str_as_py3str, self.py3str_as_py2str = strconfig - self.stream = stream - if gw is None: - self.channelfactory = None - else: - self.channelfactory = gw._channelfactory - - def load(self, versioned: bool = False) -> Any: - if versioned: - ver = self.stream.read(1) - if ver != DUMPFORMAT_VERSION: - raise LoadError("wrong dumpformat version %r" % ver) - self.stack: list[object] = [] - try: - while True: - opcode = self.stream.read(1) - if not opcode: - raise EOFError - try: - loader = self.num2func[opcode] - except KeyError: - raise LoadError( - f"unknown opcode {opcode!r} - wire protocol corruption?" - ) from None - loader(self) - except _Stop: - if len(self.stack) != 1: - raise LoadError("internal unserialization error") from None - return self.stack.pop(0) - else: - raise LoadError("didn't get STOP") - - def load_none(self) -> None: - self.stack.append(None) - - num2func[opcode.NONE] = load_none - - def load_true(self) -> None: - self.stack.append(True) - - num2func[opcode.TRUE] = load_true - - def load_false(self) -> None: - self.stack.append(False) - - num2func[opcode.FALSE] = load_false - - def load_int(self) -> None: - i = self._read_int4() - self.stack.append(i) - - num2func[opcode.INT] = load_int - - def load_longint(self) -> None: - s = self._read_byte_string() - self.stack.append(int(s)) - - num2func[opcode.LONGINT] = load_longint - - load_long = load_int - num2func[opcode.LONG] = load_long - load_longlong = load_longint - num2func[opcode.LONGLONG] = load_longlong - - def load_float(self) -> None: - binary = self.stream.read(FLOAT_FORMAT_SIZE) - self.stack.append(struct.unpack(FLOAT_FORMAT, binary)[0]) - - num2func[opcode.FLOAT] = load_float - - def load_complex(self) -> None: - binary = self.stream.read(COMPLEX_FORMAT_SIZE) - self.stack.append(complex(*struct.unpack(COMPLEX_FORMAT, binary))) - - num2func[opcode.COMPLEX] = load_complex - - def _read_int4(self) -> int: - value: int = struct.unpack("!i", self.stream.read(4))[0] - return value - - def _read_byte_string(self) -> bytes: - length = self._read_int4() - as_bytes = self.stream.read(length) - return as_bytes - - def load_py3string(self) -> None: - as_bytes = self._read_byte_string() - if self.py3str_as_py2str: - # XXX Should we try to decode into latin-1? - self.stack.append(as_bytes) - else: - self.stack.append(as_bytes.decode("utf-8")) - - num2func[opcode.PY3STRING] = load_py3string - - def load_py2string(self) -> None: - as_bytes = self._read_byte_string() - if self.py2str_as_py3str: - s: bytes | str = as_bytes.decode("latin-1") - else: - s = as_bytes - self.stack.append(s) - - num2func[opcode.PY2STRING] = load_py2string - - def load_bytes(self) -> None: - s = self._read_byte_string() - self.stack.append(s) - - num2func[opcode.BYTES] = load_bytes - - def load_unicode(self) -> None: - self.stack.append(self._read_byte_string().decode("utf-8")) - - num2func[opcode.UNICODE] = load_unicode - - def load_newlist(self) -> None: - length = self._read_int4() - self.stack.append([None] * length) - - num2func[opcode.NEWLIST] = load_newlist - - def load_setitem(self) -> None: - if len(self.stack) < 3: - raise LoadError("not enough items for setitem") - value = self.stack.pop() - key = self.stack.pop() - self.stack[-1][key] = value # type: ignore[index] - - num2func[opcode.SETITEM] = load_setitem - - def load_newdict(self) -> None: - self.stack.append({}) - - num2func[opcode.NEWDICT] = load_newdict - - def _load_collection(self, type_: type) -> None: - length = self._read_int4() - if length: - res = type_(self.stack[-length:]) - del self.stack[-length:] - self.stack.append(res) - else: - self.stack.append(type_()) - - def load_buildtuple(self) -> None: - self._load_collection(tuple) - - num2func[opcode.BUILDTUPLE] = load_buildtuple - - def load_set(self) -> None: - self._load_collection(set) - - num2func[opcode.SET] = load_set - - def load_frozenset(self) -> None: - self._load_collection(frozenset) - - num2func[opcode.FROZENSET] = load_frozenset - - def load_stop(self) -> None: - raise _Stop - - num2func[opcode.STOP] = load_stop - - def load_channel(self) -> None: - id = self._read_int4() - assert self.channelfactory is not None - newchannel = self.channelfactory.new(id) - self.stack.append(newchannel) - - num2func[opcode.CHANNEL] = load_channel - - -def dumps(obj: object) -> bytes: - """Serialize the given obj to a bytestring. - - The obj and all contained objects must be of a builtin - Python type (so nested dicts, sets, etc. are all OK but - not user-level instances). - """ - return _Serializer().save(obj, versioned=True) # type: ignore[return-value] - - -def dump(byteio, obj: object) -> None: - """write a serialized bytestring of the given obj to the given stream.""" - _Serializer(write=byteio.write).save(obj, versioned=True) - - -def loads( - bytestring: bytes, py2str_as_py3str: bool = False, py3str_as_py2str: bool = False -) -> Any: - """Deserialize the given bytestring to an object. - - py2str_as_py3str: If true then string (str) objects previously - dumped on Python2 will be loaded as Python3 - strings which really are text objects. - py3str_as_py2str: If true then string (str) objects previously - dumped on Python3 will be loaded as Python2 - strings instead of unicode objects. - - If the bytestring was dumped with an incompatible protocol - version or if the bytestring is corrupted, the - ``execnet.DataFormatError`` will be raised. - """ - io = BytesIO(bytestring) - return load( - io, py2str_as_py3str=py2str_as_py3str, py3str_as_py2str=py3str_as_py2str - ) - - -def load( - io: ReadIO, py2str_as_py3str: bool = False, py3str_as_py2str: bool = False -) -> Any: - """Derserialize an object form the specified stream. - - Behaviour and parameters are otherwise the same as with ``loads`` - """ - strconfig = (py2str_as_py3str, py3str_as_py2str) - return Unserializer(io, strconfig=strconfig).load(versioned=True) - - -def loads_internal( - bytestring: bytes, - channelfactory=None, - strconfig: tuple[bool, bool] | None = None, -) -> Any: - io = BytesIO(bytestring) - return Unserializer(io, channelfactory, strconfig).load() - - -def dumps_internal(obj: object) -> bytes: - return _Serializer().save(obj) # type: ignore[return-value] - - -class _Serializer: - _dispatch: dict[type, Callable[[_Serializer, object], None]] = {} - - def __init__(self, write: Callable[[bytes], None] | None = None) -> None: - if write is None: - self._streamlist: list[bytes] = [] - write = self._streamlist.append - self._write = write - - def save(self, obj: object, versioned: bool = False) -> bytes | None: - # calling here is not re-entrant but multiple instances - # may write to the same stream because of the common platform - # atomic-write guarantee (concurrent writes each happen atomically) - if versioned: - self._write(DUMPFORMAT_VERSION) - self._save(obj) - self._write(opcode.STOP) - try: - streamlist = self._streamlist - except AttributeError: - return None - return b"".join(streamlist) - - def _save(self, obj: object) -> None: - tp = type(obj) - try: - dispatch = self._dispatch[tp] - except KeyError: - methodname = "save_" + tp.__name__ - meth: Callable[[_Serializer, object], None] | None = getattr( - self.__class__, methodname, None - ) - if meth is None: - raise DumpError(f"can't serialize {tp}") from None - dispatch = self._dispatch[tp] = meth - dispatch(self, obj) - - def save_NoneType(self, non: None) -> None: - self._write(opcode.NONE) - - def save_bool(self, boolean: bool) -> None: - if boolean: - self._write(opcode.TRUE) - else: - self._write(opcode.FALSE) - - def save_bytes(self, bytes_: bytes) -> None: - self._write(opcode.BYTES) - self._write_byte_sequence(bytes_) - - def save_str(self, s: str) -> None: - self._write(opcode.PY3STRING) - self._write_unicode_string(s) - - def _write_unicode_string(self, s: str) -> None: - try: - as_bytes = s.encode("utf-8") - except UnicodeEncodeError as e: - raise DumpError("strings must be utf-8 encodable") from e - self._write_byte_sequence(as_bytes) - - def _write_byte_sequence(self, bytes_: bytes) -> None: - self._write_int4(len(bytes_), "string is too long") - self._write(bytes_) - - def _save_integral(self, i: int, short_op: bytes, long_op: bytes) -> None: - if i <= FOUR_BYTE_INT_MAX: - self._write(short_op) - self._write_int4(i) - else: - self._write(long_op) - self._write_byte_sequence(str(i).rstrip("L").encode("ascii")) - - def save_int(self, i: int) -> None: - self._save_integral(i, opcode.INT, opcode.LONGINT) - - def save_long(self, l: int) -> None: - self._save_integral(l, opcode.LONG, opcode.LONGLONG) - - def save_float(self, flt: float) -> None: - self._write(opcode.FLOAT) - self._write(struct.pack(FLOAT_FORMAT, flt)) - - def save_complex(self, cpx: complex) -> None: - self._write(opcode.COMPLEX) - self._write(struct.pack(COMPLEX_FORMAT, cpx.real, cpx.imag)) - - def _write_int4( - self, i: int, error: str = "int must be less than %i" % (FOUR_BYTE_INT_MAX,) - ) -> None: - if i > FOUR_BYTE_INT_MAX: - raise DumpError(error) - self._write(struct.pack("!i", i)) - - def save_list(self, L: list[object]) -> None: - self._write(opcode.NEWLIST) - self._write_int4(len(L), "list is too long") - for i, item in enumerate(L): - self._write_setitem(i, item) - - def _write_setitem(self, key: object, value: object) -> None: - self._save(key) - self._save(value) - self._write(opcode.SETITEM) - - def save_dict(self, d: dict[object, object]) -> None: - self._write(opcode.NEWDICT) - for key, value in d.items(): - self._write_setitem(key, value) - - def save_tuple(self, tup: tuple[object, ...]) -> None: - for item in tup: - self._save(item) - self._write(opcode.BUILDTUPLE) - self._write_int4(len(tup), "tuple is too long") - - def _write_set(self, s: set[object] | frozenset[object], op: bytes) -> None: - for item in s: - self._save(item) - self._write(op) - self._write_int4(len(s), "set is too long") - - def save_set(self, s: set[object]) -> None: - self._write_set(s, opcode.SET) - - def save_frozenset(self, s: frozenset[object]) -> None: - self._write_set(s, opcode.FROZENSET) - - def save_Channel(self, channel: Channel) -> None: - self._write(opcode.CHANNEL) - self._write_int4(channel.id) - - -def init_popen_io(execmodel: ExecModel) -> Popen2IO: - if not hasattr(os, "dup"): # jython - io = Popen2IO(sys.stdout, sys.stdin, execmodel) - import tempfile - - sys.stdin = tempfile.TemporaryFile("r") - sys.stdout = tempfile.TemporaryFile("w") - else: - try: - devnull = os.devnull - except AttributeError: - devnull = "NUL" if os.name == "nt" else "/dev/null" - # stdin - stdin = execmodel.fdopen(os.dup(0), "r", 1) - fd = os.open(devnull, os.O_RDONLY) - os.dup2(fd, 0) - os.close(fd) - - # stdout - stdout = execmodel.fdopen(os.dup(1), "w", 1) - fd = os.open(devnull, os.O_WRONLY) - os.dup2(fd, 1) - - # stderr for win32 - if os.name == "nt": - sys.stderr = execmodel.fdopen(os.dup(2), "w", 1) - os.dup2(fd, 2) - os.close(fd) - io = Popen2IO(stdout, stdin, execmodel) - # Use closefd=False since 0 and 1 are shared with - # sys.__stdin__ and sys.__stdout__. - sys.stdin = execmodel.fdopen(0, "r", 1, closefd=False) - sys.stdout = execmodel.fdopen(1, "w", 1, closefd=False) - return io - - -def serve(io: IO, id) -> None: - trace(f"creating workergateway on {io!r}") - WorkerGateway(io=io, id=id, _startcount=2).serve() +from ._shim import forwarder + +_MOVED = { + # tracing + "DEBUG": "._trace", + "pid": "._trace", + "trace": "._trace", + "notrace": "._trace", + # errors and error texts + "sysex": "._errors", + "INTERRUPT_TEXT": "._errors", + "GatewayReceivedTerminate": "._errors", + "HostNotFound": "._errors", + "geterrortext": "._errors", + "RemoteError": "._errors", + "TimeoutError": "._errors", + "DataFormatError": "._errors", + "DumpError": "._errors", + "LoadError": "._errors", + # execution model presets + "ExecModel": "._execmodel", + "get_execmodel": "._execmodel", + # wire protocol + "WriteIO": "._message", + "ReadIO": "._message", + "IO": "._message", + "Message": "._message", + "gateway_info": "._message", + "FrameDecoder": "._message", + # serializer + "bchr": "._serialize", + "DUMPFORMAT_VERSION": "._serialize", + "FOUR_BYTE_INT_MAX": "._serialize", + "FOUR_BYTE_INT_MIN": "._serialize", + "FLOAT_FORMAT": "._serialize", + "FLOAT_FORMAT_SIZE": "._serialize", + "COMPLEX_FORMAT": "._serialize", + "COMPLEX_FORMAT_SIZE": "._serialize", + "opcode": "._serialize", + "Unserializer": "._serialize", + "_Serializer": "._serialize", + "_Stop": "._serialize", + "dumps": "._serialize", + "dump": "._serialize", + "loads": "._serialize", + "load": "._serialize", + "dumps_internal": "._serialize", + "loads_internal": "._serialize", + # channels + "Channel": "._channel", + "ChannelFactory": "._channel", + "ChannelFile": "._channel", + "ChannelFileWrite": "._channel", + "ChannelFileRead": "._channel", + "ENDMARKER": "._channel", + "NO_ENDMARKER_WANTED": "._channel", + # gateways + "BaseGateway": "._gateway_base", + "WorkerGateway": "._gateway_base", +} + +__getattr__ = forwarder("gateway_base", _MOVED) diff --git a/src/execnet/gateway_bootstrap.py b/src/execnet/gateway_bootstrap.py deleted file mode 100644 index e9d7efe1..00000000 --- a/src/execnet/gateway_bootstrap.py +++ /dev/null @@ -1,96 +0,0 @@ -"""Code to initialize the remote side of a gateway once the IO is created.""" - -from __future__ import annotations - -import inspect -import os - -import execnet - -from . import gateway_base -from .gateway_base import IO -from .xspec import XSpec - -importdir = os.path.dirname(os.path.dirname(execnet.__file__)) - - -class HostNotFound(Exception): - pass - - -def bootstrap_import(io: IO, spec: XSpec) -> None: - # Only insert the importdir into the path if we must. This prevents - # bugs where backports expect to be shadowed by the standard library on - # newer versions of python but would instead shadow the standard library. - sendexec( - io, - "import sys", - "if %r not in sys.path:" % importdir, - " sys.path.insert(0, %r)" % importdir, - "from execnet.gateway_base import serve, init_popen_io, get_execmodel", - "sys.stdout.write('1')", - "sys.stdout.flush()", - "execmodel = get_execmodel(%r)" % spec.execmodel, - "serve(init_popen_io(execmodel), id='%s-worker')" % spec.id, - ) - s = io.read(1) - assert s == b"1", repr(s) - - -def bootstrap_exec(io: IO, spec: XSpec) -> None: - try: - sendexec( - io, - inspect.getsource(gateway_base), - "execmodel = get_execmodel(%r)" % spec.execmodel, - "io = init_popen_io(execmodel)", - "io.write('1'.encode('ascii'))", - "serve(io, id='%s-worker')" % spec.id, - ) - s = io.read(1) - assert s == b"1" - except EOFError: - ret = io.wait() - if ret == 255 and hasattr(io, "remoteaddress"): - raise HostNotFound(io.remoteaddress) from None - - -def bootstrap_socket(io: IO, id) -> None: - # XXX: switch to spec - from execnet.gateway_socket import SocketIO - - sendexec( - io, - inspect.getsource(gateway_base), - "import socket", - inspect.getsource(SocketIO), - "try: execmodel", - "except NameError:", - " execmodel = get_execmodel('thread')", - "io = SocketIO(clientsock, execmodel)", - "io.write('1'.encode('ascii'))", - "serve(io, id='%s-worker')" % id, - ) - s = io.read(1) - assert s == b"1" - - -def sendexec(io: IO, *sources: str) -> None: - source = "\n".join(sources) - io.write((repr(source) + "\n").encode("utf-8")) - - -def bootstrap(io: IO, spec: XSpec) -> execnet.Gateway: - if spec.popen: - if spec.via or spec.python: - bootstrap_exec(io, spec) - else: - bootstrap_import(io, spec) - elif spec.ssh or spec.vagrant_ssh: - bootstrap_exec(io, spec) - elif spec.socket: - bootstrap_socket(io, spec) - else: - raise ValueError("unknown gateway type, can't bootstrap") - gw = execnet.Gateway(io, spec) - return gw diff --git a/src/execnet/gateway_io.py b/src/execnet/gateway_io.py deleted file mode 100644 index 21285ab4..00000000 --- a/src/execnet/gateway_io.py +++ /dev/null @@ -1,255 +0,0 @@ -"""execnet IO initialization code. - -Creates IO instances used for gateway IO. -""" - -from __future__ import annotations - -import shlex -import sys -from typing import TYPE_CHECKING -from typing import cast - -if TYPE_CHECKING: - from execnet.gateway_base import Channel - from execnet.gateway_base import ExecModel - from execnet.xspec import XSpec - -try: - from execnet.gateway_base import Message - from execnet.gateway_base import Popen2IO -except ImportError: - from __main__ import Message # type: ignore[no-redef] - from __main__ import Popen2IO # type: ignore[no-redef] - -from functools import partial - - -class Popen2IOMaster(Popen2IO): - # Set externally, for some specs only. - remoteaddress: str - - def __init__(self, args, execmodel: ExecModel) -> None: - PIPE = execmodel.subprocess.PIPE - self.popen = p = execmodel.subprocess.Popen(args, stdout=PIPE, stdin=PIPE) - super().__init__(p.stdin, p.stdout, execmodel=execmodel) - - def wait(self) -> int | None: - try: - return self.popen.wait() # type: ignore[no-any-return] - except OSError: - return None - - def kill(self) -> None: - try: - self.popen.kill() - except OSError as e: - sys.stderr.write("ERROR killing: %s\n" % e) - sys.stderr.flush() - - -popen_bootstrapline = "import sys;exec(eval(sys.stdin.readline()))" - - -def shell_split_path(path: str) -> list[str]: - """ - Use shell lexer to split the given path into a list of components, - taking care to handle Windows' '\' correctly. - """ - if sys.platform.startswith("win"): - # replace \\ by / otherwise shlex will strip them out - path = path.replace("\\", "/") - return shlex.split(path) - - -def popen_args(spec: XSpec) -> list[str]: - args = shell_split_path(spec.python) if spec.python else [sys.executable] - args.append("-u") - if spec.dont_write_bytecode: - args.append("-B") - args.extend(["-c", popen_bootstrapline]) - return args - - -def ssh_args(spec: XSpec) -> list[str]: - # NOTE: If changing this, you need to sync those changes to vagrant_args - # as well, or, take some time to further refactor the commonalities of - # ssh_args and vagrant_args. - remotepython = spec.python or "python" - args = ["ssh", "-C"] - if spec.ssh_config is not None: - args.extend(["-F", str(spec.ssh_config)]) - - assert spec.ssh is not None - args.extend(spec.ssh.split()) - remotecmd = f'{remotepython} -c "{popen_bootstrapline}"' - args.append(remotecmd) - return args - - -def vagrant_ssh_args(spec: XSpec) -> list[str]: - # This is the vagrant-wrapped version of SSH. Unfortunately the - # command lines are incompatible to just channel through ssh_args - # due to ordering/templating issues. - # NOTE: This should be kept in sync with the ssh_args behaviour. - # spec.vagrant is identical to spec.ssh in that they both carry - # the remote host "address". - assert spec.vagrant_ssh is not None - remotepython = spec.python or "python" - args = ["vagrant", "ssh", spec.vagrant_ssh, "--", "-C"] - if spec.ssh_config is not None: - args.extend(["-F", str(spec.ssh_config)]) - remotecmd = f'{remotepython} -c "{popen_bootstrapline}"' - args.extend([remotecmd]) - return args - - -def create_io(spec: XSpec, execmodel: ExecModel) -> Popen2IOMaster: - if spec.popen: - args = popen_args(spec) - return Popen2IOMaster(args, execmodel) - if spec.ssh: - args = ssh_args(spec) - io = Popen2IOMaster(args, execmodel) - io.remoteaddress = spec.ssh - return io - if spec.vagrant_ssh: - args = vagrant_ssh_args(spec) - io = Popen2IOMaster(args, execmodel) - io.remoteaddress = spec.vagrant_ssh - return io - assert False - - -# -# Proxy Gateway handling code -# -# master: proxy initiator -# forwarder: forwards between master and sub -# sub: sub process that is proxied to the initiator - -RIO_KILL = 1 -RIO_WAIT = 2 -RIO_REMOTEADDRESS = 3 -RIO_CLOSE_WRITE = 4 - - -class ProxyIO: - """A Proxy IO object allows to instantiate a Gateway - through another "via" gateway. - - A master:ProxyIO object provides an IO object effectively connected to the - sub via the forwarder. To achieve this, master:ProxyIO interacts with - forwarder:serve_proxy_io() which itself instantiates and interacts with the - sub. - """ - - def __init__(self, proxy_channel: Channel, execmodel: ExecModel) -> None: - # after exchanging the control channel we use proxy_channel - # for messaging IO - self.controlchan = proxy_channel.gateway.newchannel() - proxy_channel.send(self.controlchan) - self.iochan = proxy_channel - self.iochan_file = self.iochan.makefile("r") - self.execmodel = execmodel - - def read(self, nbytes: int) -> bytes: - # TODO(typing): The IO protocol requires bytes here but ChannelFileRead - # returns str. - return self.iochan_file.read(nbytes) # type: ignore[return-value] - - def write(self, data: bytes) -> None: - self.iochan.send(data) - - def _controll(self, event: int) -> object: - self.controlchan.send(event) - return self.controlchan.receive() - - def close_write(self) -> None: - self._controll(RIO_CLOSE_WRITE) - - def close_read(self) -> None: - raise NotImplementedError() - - def kill(self) -> None: - self._controll(RIO_KILL) - - def wait(self) -> int | None: - response = self._controll(RIO_WAIT) - assert response is None or isinstance(response, int) - return response - - @property - def remoteaddress(self) -> str: - response = self._controll(RIO_REMOTEADDRESS) - assert isinstance(response, str) - return response - - def __repr__(self) -> str: - return f"" - - -class PseudoSpec: - def __init__(self, vars) -> None: - self.__dict__.update(vars) - - def __getattr__(self, name: str) -> None: - return None - - -def serve_proxy_io(proxy_channelX: Channel) -> None: - execmodel = proxy_channelX.gateway.execmodel - log = partial( - proxy_channelX.gateway._trace, "serve_proxy_io:%s" % proxy_channelX.id - ) - spec = cast("XSpec", PseudoSpec(proxy_channelX.receive())) - # create sub IO object which we will proxy back to our proxy initiator - sub_io = create_io(spec, execmodel) - control_chan = cast("Channel", proxy_channelX.receive()) - log("got control chan", control_chan) - - # read data from master, forward it to the sub - # XXX writing might block, thus blocking the receiver thread - def forward_to_sub(data: bytes) -> None: - log("forward data to sub, size %s" % len(data)) - sub_io.write(data) - - proxy_channelX.setcallback(forward_to_sub) - - def control(data: int) -> None: - if data == RIO_WAIT: - control_chan.send(sub_io.wait()) - elif data == RIO_KILL: - sub_io.kill() - control_chan.send(None) - elif data == RIO_REMOTEADDRESS: - control_chan.send(sub_io.remoteaddress) - elif data == RIO_CLOSE_WRITE: - sub_io.close_write() - control_chan.send(None) - - control_chan.setcallback(control) - - # write data to the master coming from the sub - forward_to_master_file = proxy_channelX.makefile("w") - - # read bootstrap byte from sub, send it on to master - log("reading bootstrap byte from sub", spec.id) - initial = sub_io.read(1) - assert initial == b"1", initial - log("forwarding bootstrap byte from sub", spec.id) - forward_to_master_file.write(initial) - - # enter message forwarding loop - while True: - try: - message = Message.from_io(sub_io) - except EOFError: - log("EOF from sub, terminating proxying loop", spec.id) - break - message.to_io(forward_to_master_file) - # proxy_channelX will be closed from remote_exec's finalization code - - -if __name__ == "__channelexec__": - serve_proxy_io(channel) # type: ignore[name-defined] # noqa:F821 diff --git a/src/execnet/gateway_socket.py b/src/execnet/gateway_socket.py deleted file mode 100644 index be42f1ab..00000000 --- a/src/execnet/gateway_socket.py +++ /dev/null @@ -1,102 +0,0 @@ -from __future__ import annotations - -import sys -from typing import cast - -from execnet.gateway import Gateway -from execnet.gateway_base import ExecModel -from execnet.gateway_bootstrap import HostNotFound -from execnet.multi import Group -from execnet.xspec import XSpec - - -class SocketIO: - remoteaddress: str - - def __init__(self, sock, execmodel: ExecModel) -> None: - self.sock = sock - self.execmodel = execmodel - socket = execmodel.socket - try: - # IPTOS_LOWDELAY - sock.setsockopt(socket.SOL_IP, socket.IP_TOS, 0x10) - sock.setsockopt(socket.SOL_TCP, socket.TCP_NODELAY, 1) - except (AttributeError, OSError): - sys.stderr.write("WARNING: cannot set socketoption") - - def read(self, numbytes: int) -> bytes: - "Read exactly 'bytes' bytes from the socket." - buf = b"" - while len(buf) < numbytes: - t = self.sock.recv(numbytes - len(buf)) - if not t: - raise EOFError - buf += t - return buf - - def write(self, data: bytes) -> None: - self.sock.sendall(data) - - def close_read(self) -> None: - try: - self.sock.shutdown(0) - except self.execmodel.socket.error: - pass - - def close_write(self) -> None: - try: - self.sock.shutdown(1) - except self.execmodel.socket.error: - pass - - def wait(self) -> None: - pass - - def kill(self) -> None: - pass - - -def start_via( - gateway: Gateway, hostport: tuple[str, int] | None = None -) -> tuple[str, int]: - """Instantiate a socketserver on the given gateway. - - Returns a host, port tuple. - """ - if hostport is None: - host, port = ("localhost", 0) - else: - host, port = hostport - - from execnet.script import socketserver - - # execute the above socketserverbootstrap on the other side - channel = gateway.remote_exec(socketserver) - channel.send((host, port)) - realhost, realport = cast("tuple[str, int]", channel.receive()) - # self._trace("new_remote received" - # "port=%r, hostname = %r" %(realport, hostname)) - if not realhost or realhost == "0.0.0.0": - realhost = "localhost" - return realhost, realport - - -def create_io(spec: XSpec, group: Group, execmodel: ExecModel) -> SocketIO: - assert spec.socket is not None - assert not spec.python, "socket: specifying python executables not yet supported" - gateway_id = spec.installvia - if gateway_id: - host, port = start_via(group[gateway_id]) - else: - host, port_str = spec.socket.split(":") - port = int(port_str) - - socket = execmodel.socket - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - io = SocketIO(sock, execmodel) - io.remoteaddress = "%s:%d" % (host, port) - try: - sock.connect((host, port)) - except execmodel.socket.gaierror as e: - raise HostNotFound() from e - return io diff --git a/src/execnet/gevent.py b/src/execnet/gevent.py new file mode 100644 index 00000000..5d0d6865 --- /dev/null +++ b/src/execnet/gevent.py @@ -0,0 +1,98 @@ +"""The blocking execnet API for gevent applications. + +Identical to :mod:`execnet.sync` except that every blocking wait parks the +calling *greenlet* rather than its OS thread:: + + import execnet.gevent + + group = execnet.gevent.Group() + gateway = group.makegateway("popen") + channel = gateway.remote_exec("channel.send(6 * 7)") + print(channel.receive()) # parks this greenlet, not the hub + +Protocol IO runs on the shared :class:`~execnet.ProtocolEngine` as it does +for every +blocking surface; the difference is only which primitive a waiter parks +on, so a slow ``receive`` no longer stalls the whole hub. Requires +gevent (``execnet[gevent]``). + +This is about the *caller*: the worker's own shape is the ``profile=`` +spec key, and ``profile=gevent`` is an independent choice. + +Importing this module monkey-patches nothing, and **the process it runs in +must not have monkey-patched either**: the engine loop is a Trio program on +its own OS thread, and it needs the real ``select`` (for ``epoll``), +``socket``, ``thread`` and ``queue``, which ``gevent.monkey`` replaces +process-wide. Patching is not what makes this namespace work anyway -- +its waits park the calling greenlet because they wait on a gevent +primitive, not because the stdlib was swapped underneath them. Starting a +an engine in a patched process is refused before the loop thread exists, with +an error naming what was patched. +""" + +from __future__ import annotations + +import gevent # noqa: F401 -- fail at import time when gevent is missing + +from ._errors import ActiveGroupsWarning +from ._errors import ChannelClosed +from ._errors import DataFormatError +from ._errors import DumpError +from ._errors import ExecnetStateError +from ._errors import GatewayGone +from ._errors import HostNotFound +from ._errors import LoadError +from ._errors import RemoteError +from ._errors import TimeoutError +from ._multi import Group as _SyncGroup +from ._multi import MultiChannel +from ._xspec import XSpec +from .sync import Channel +from .sync import Deployed +from .sync import Deployment +from .sync import Gateway +from .sync import ProtocolEngine +from .sync import RSync +from .sync import transfer + +__all__ = [ + "ActiveGroupsWarning", + "Channel", + "ChannelClosed", + "DataFormatError", + "Deployed", + "Deployment", + "DumpError", + "ExecnetStateError", + "Gateway", + "GatewayGone", + "Group", + "HostNotFound", + "LoadError", + "MultiChannel", + "ProtocolEngine", + "RSync", + "RemoteError", + "TimeoutError", + "XSpec", + "default_group", + "makegateway", + "transfer", +] + + +class Group(_SyncGroup): + """A gateway group whose blocking waits park greenlets. + + Every gateway it creates inherits the gevent wait backend, so + ``channel.receive()``, ``waitclose()``, sends waiting on their write + acknowledgement, ``join()`` and ``Group.terminate()`` all yield to the + hub instead of blocking the thread running it. + """ + + _wait_backend = "gevent" + + +#: convenience group for scripts; real applications should own a Group +default_group = Group() +makegateway = default_group.makegateway diff --git a/src/execnet/multi.py b/src/execnet/multi.py index 4dbf8b89..9235b4e1 100644 --- a/src/execnet/multi.py +++ b/src/execnet/multi.py @@ -1,374 +1,21 @@ -""" -Managing Gateway Groups and interactions with multiple channels. +"""Deprecated alias for :mod:`execnet._multi`. -(c) 2008-2014, Holger Krekel and others +``Group``, ``MultiChannel``, ``default_group``, ``makegateway`` and +``set_execmodel`` are exported from :mod:`execnet` and :mod:`execnet.sync`; +use those. """ from __future__ import annotations -import atexit -import types -from collections.abc import Callable -from collections.abc import Iterable -from collections.abc import Iterator -from collections.abc import Sequence -from functools import partial -from threading import Lock -from typing import TYPE_CHECKING -from typing import Any -from typing import Literal -from typing import TypeAlias -from typing import overload - -from . import gateway_bootstrap -from . import gateway_io -from .gateway_base import Channel -from .gateway_base import ExecModel -from .gateway_base import WorkerPool -from .gateway_base import get_execmodel -from .gateway_base import trace -from .xspec import XSpec - -if TYPE_CHECKING: - from .gateway import Gateway - - -NO_ENDMARKER_WANTED = object() - - -class Group: - """Gateway Group.""" - - defaultspec = "popen" - - def __init__( - self, xspecs: Iterable[XSpec | str | None] = (), execmodel: str = "thread" - ) -> None: - """Initialize a group and make gateways as specified. - - execmodel can be one of the supported execution models. - """ - self._gateways: list[Gateway] = [] - self._autoidcounter = 0 - self._autoidlock = Lock() - self._gateways_to_join: list[Gateway] = [] - # we use the same execmodel for all of the Gateway objects - # we spawn on our side. Probably we should not allow different - # execmodels between different groups but not clear. - # Note that "other side" execmodels may differ and is typically - # specified by the spec passed to makegateway. - self.set_execmodel(execmodel) - for xspec in xspecs: - self.makegateway(xspec) - atexit.register(self._cleanup_atexit) - - @property - def execmodel(self) -> ExecModel: - return self._execmodel - - @property - def remote_execmodel(self) -> ExecModel: - return self._remote_execmodel - - def set_execmodel( - self, execmodel: str, remote_execmodel: str | None = None - ) -> None: - """Set the execution model for local and remote site. - - execmodel can be one of the supported execution models. - It determines the execution model for any newly created gateway. - If remote_execmodel is not specified it takes on the value of execmodel. - - NOTE: Execution models can only be set before any gateway is created. - """ - if self._gateways: - raise ValueError( - "can not set execution models if gateways have been created already" - ) - if remote_execmodel is None: - remote_execmodel = execmodel - self._execmodel = get_execmodel(execmodel) - self._remote_execmodel = get_execmodel(remote_execmodel) - - def __repr__(self) -> str: - idgateways = [gw.id for gw in self] - return "" % idgateways - - def __getitem__(self, key: int | str | Gateway) -> Gateway: - if isinstance(key, int): - return self._gateways[key] - for gw in self._gateways: - if gw == key or gw.id == key: - return gw - raise KeyError(key) - - def __contains__(self, key: str) -> bool: - try: - self[key] - return True - except KeyError: - return False - - def __len__(self) -> int: - return len(self._gateways) - - def __iter__(self) -> Iterator[Gateway]: - return iter(list(self._gateways)) - - def makegateway(self, spec: XSpec | str | None = None) -> Gateway: - """Create and configure a gateway to a Python interpreter. - - The ``spec`` string encodes the target gateway type - and configuration information. The general format is:: - - key1=value1//key2=value2//... - - If you leave out the ``=value`` part a True value is assumed. - Valid types: ``popen``, ``ssh=hostname``, ``socket=host:port``. - Valid configuration:: - - id= specifies the gateway id - python= specifies which python interpreter to execute - execmodel=model 'thread', 'main_thread_only', 'eventlet', 'gevent' execution model - chdir= specifies to which directory to change - nice= specifies process priority of new process - env:NAME=value specifies a remote environment variable setting. - - If no spec is given, self.defaultspec is used. - """ - if not spec: - spec = self.defaultspec - if not isinstance(spec, XSpec): - spec = XSpec(spec) - self.allocate_id(spec) - if spec.execmodel is None: - spec.execmodel = self.remote_execmodel.backend - if spec.via: - assert not spec.socket - master = self[spec.via] - proxy_channel = master.remote_exec(gateway_io) - proxy_channel.send(vars(spec)) - proxy_io_master = gateway_io.ProxyIO(proxy_channel, self.execmodel) - gw = gateway_bootstrap.bootstrap(proxy_io_master, spec) - elif spec.popen or spec.ssh or spec.vagrant_ssh: - io = gateway_io.create_io(spec, execmodel=self.execmodel) - gw = gateway_bootstrap.bootstrap(io, spec) - elif spec.socket: - from . import gateway_socket - - sio = gateway_socket.create_io(spec, self, execmodel=self.execmodel) - gw = gateway_bootstrap.bootstrap(sio, spec) - else: - raise ValueError(f"no gateway type found for {spec._spec!r}") - gw.spec = spec - self._register(gw) - if spec.chdir or spec.nice or spec.env: - channel = gw.remote_exec( - """ - import os - path, nice, env = channel.receive() - if path: - if not os.path.exists(path): - os.mkdir(path) - os.chdir(path) - if nice and hasattr(os, 'nice'): - os.nice(nice) - if env: - for name, value in env.items(): - os.environ[name] = value - """ - ) - nice = (spec.nice and int(spec.nice)) or 0 - channel.send((spec.chdir, nice, spec.env)) - channel.waitclose() - return gw - - def allocate_id(self, spec: XSpec) -> None: - """(re-entrant) allocate id for the given xspec object.""" - if spec.id is None: - with self._autoidlock: - id = "gw" + str(self._autoidcounter) - self._autoidcounter += 1 - if id in self: - raise ValueError(f"already have gateway with id {id!r}") - spec.id = id - - def _register(self, gateway: Gateway) -> None: - assert not hasattr(gateway, "_group") - assert gateway.id - assert gateway.id not in self - self._gateways.append(gateway) - gateway._group = self - - def _unregister(self, gateway: Gateway) -> None: - self._gateways.remove(gateway) - self._gateways_to_join.append(gateway) - - def _cleanup_atexit(self) -> None: - trace(f"=== atexit cleanup {self!r} ===") - self.terminate(timeout=1.0) - - def terminate(self, timeout: float | None = None) -> None: - """Trigger exit of member gateways and wait for termination - of member gateways and associated subprocesses. - - After waiting timeout seconds try to to kill local sub processes of - popen- and ssh-gateways. - - Timeout defaults to None meaning open-ended waiting and no kill - attempts. - """ - while self: - vias: set[str] = set() - for gw in self: - if gw.spec.via: - vias.add(gw.spec.via) - for gw in self: - if gw.id not in vias: - gw.exit() - - def join_wait(gw: Gateway) -> None: - gw.join() - gw._io.wait() - - def kill(gw: Gateway) -> None: - trace("Gateways did not come down after timeout: %r" % gw) - gw._io.kill() - - safe_terminate( - self.execmodel, - timeout, - [ - (partial(join_wait, gw), partial(kill, gw)) - for gw in self._gateways_to_join - ], - ) - self._gateways_to_join[:] = [] - - def remote_exec( - self, - source: str | types.FunctionType | Callable[..., object] | types.ModuleType, - **kwargs, - ) -> MultiChannel: - """remote_exec source on all member gateways and return - a MultiChannel connecting to all sub processes.""" - channels = [] - for gw in self: - channels.append(gw.remote_exec(source, **kwargs)) - return MultiChannel(channels) - - -class MultiChannel: - def __init__(self, channels: Sequence[Channel]) -> None: - self._channels = channels - - def __len__(self) -> int: - return len(self._channels) - - def __iter__(self) -> Iterator[Channel]: - return iter(self._channels) - - def __getitem__(self, key: int) -> Channel: - return self._channels[key] - - def __contains__(self, chan: Channel) -> bool: - return chan in self._channels - - def send_each(self, item: object) -> None: - for ch in self._channels: - ch.send(item) - - @overload - def receive_each(self, withchannel: Literal[False] = ...) -> list[Any]: - pass - - @overload - def receive_each(self, withchannel: Literal[True]) -> list[tuple[Channel, Any]]: - pass - - def receive_each( - self, withchannel: bool = False - ) -> list[tuple[Channel, Any]] | list[Any]: - assert not hasattr(self, "_queue") - l: list[object] = [] - for ch in self._channels: - obj = ch.receive() - if withchannel: - l.append((ch, obj)) - else: - l.append(obj) - return l - - def make_receive_queue(self, endmarker: object = NO_ENDMARKER_WANTED): - try: - return self._queue # type: ignore[has-type] - except AttributeError: - self._queue = None - for ch in self._channels: - if self._queue is None: - self._queue = ch.gateway.execmodel.queue.Queue() - - def putreceived(obj, channel: Channel = ch) -> None: - self._queue.put((channel, obj)) # type: ignore[union-attr] - - if endmarker is NO_ENDMARKER_WANTED: - ch.setcallback(putreceived) - else: - ch.setcallback(putreceived, endmarker=endmarker) - return self._queue - - def waitclose(self) -> None: - first = None - for ch in self._channels: - try: - ch.waitclose() - except ch.RemoteError as exc: - if first is None: - first = exc - if first: - raise first - - -TermKillFunc: TypeAlias = Callable[[], object] -TermKillPair: TypeAlias = tuple[TermKillFunc, TermKillFunc] - - -def safe_terminate( - execmodel: ExecModel, - timeout: float | None, - list_of_paired_functions: Sequence[TermKillPair], -) -> None: - """Run terminate/kill pairs in parallel with a hard wait bound. - - Each termfunc is given ``timeout``. If it does not finish, killfunc runs. - Waiting for the worker pool is also bounded so a stuck kill cannot hang - the caller forever (see issues #43 / #221). - """ - workerpool = WorkerPool(execmodel) - - def termkill(termfunc: TermKillFunc, killfunc: TermKillFunc) -> None: - termreply = workerpool.spawn(termfunc) - try: - termreply.get(timeout=timeout) - except OSError: - killfunc() - - replylist = [ - workerpool.spawn(termkill, termfunc, killfunc) - for termfunc, killfunc in list_of_paired_functions - ] - # Allow term timeout plus a kill attempt; never block indefinitely. - wait_timeout = None if timeout is None else timeout * 2 - for reply in replylist: - try: - reply.waitfinish(timeout=wait_timeout) - except OSError: - # termkill still running (typically stuck in killfunc). - continue - reply.get() # propagate worker exceptions, if any - workerpool.waitall(timeout=wait_timeout) +from ._shim import forwarder +_MOVED = { + "Group": "._multi", + "MultiChannel": "._multi", + "default_group": "._multi", + "makegateway": "._multi", + "set_execmodel": "._multi", + "NO_ENDMARKER_WANTED": "._multi", +} -default_group = Group() -makegateway = default_group.makegateway -set_execmodel = default_group.set_execmodel +__getattr__ = forwarder("multi", _MOVED) diff --git a/src/execnet/raw_trio.py b/src/execnet/raw_trio.py new file mode 100644 index 00000000..6be69940 --- /dev/null +++ b/src/execnet/raw_trio.py @@ -0,0 +1,82 @@ +"""execnet embedded in your own trio run: no engine, no thread. + +The gateways here are tasks in *your* nursery, and their protocol IO runs +on *your* loop:: + + import trio + import execnet.raw_trio + + async def main(): + async with execnet.raw_trio.AsyncGroup() as group: + gateway = await group.makegateway("popen") + channel = await gateway.remote_exec("channel.send(6 * 7)") + print(await channel.receive()) + + trio.run(main) + +That is the whole difference from :mod:`execnet.trio`, and it cuts both +ways. Cancelling a ``receive`` here cancels exactly that receive, with no +window in which an item is taken and lost; there is no thread hop on any +operation; and structured concurrency covers the gateways like anything +else in your nursery. In exchange, a step that does not yield -- a +CPU-bound stretch, a blocking call -- stalls protocol IO for every gateway +you have, an error in your task tree cancels gateways mid-protocol, and +execnet's own ``to_thread`` work (reading files for a transfer) competes +with yours for one run-wide thread limiter. A gateway also cannot outlive +the ``async with`` that made it. + +Reach for :mod:`execnet.trio` instead when any of that bites, or when the +same process also drives execnet from blocking or asyncio code: the +engine is shared, this is not. + +Transfers and deployments are awaited here too -- ``await transfer(...)``, +``await deploy(deployment, gateway)`` -- and a fan-out across gateways runs +them concurrently. + +The error types are shared with the blocking API in :mod:`execnet.sync`. +Items you send must already be simple builtin data (plus channels); the +standalone serializer is intentionally not part of the public API -- +``execnet.can_send`` checks a value before you send it; see ``DumpError``. +""" + +from ._deploy import Deployed +from ._deploy import Deployment +from ._deploy._async_api import deploy +from ._deploy._async_api import deploy_all +from ._deploy._async_api import transfer +from ._errors import ChannelClosed +from ._errors import DataFormatError +from ._errors import DumpError +from ._errors import ExecnetStateError +from ._errors import GatewayGone +from ._errors import HostNotFound +from ._errors import LoadError +from ._errors import RemoteError +from ._errors import TimeoutError +from ._trio_gateway import AsyncChannel +from ._trio_gateway import AsyncGateway +from ._trio_gateway import AsyncGroup +from ._trio_gateway import open_gateway +from ._xspec import XSpec + +__all__ = [ + "AsyncChannel", + "AsyncGateway", + "AsyncGroup", + "ChannelClosed", + "DataFormatError", + "Deployed", + "Deployment", + "DumpError", + "ExecnetStateError", + "GatewayGone", + "HostNotFound", + "LoadError", + "RemoteError", + "TimeoutError", + "XSpec", + "deploy", + "deploy_all", + "open_gateway", + "transfer", +] diff --git a/src/execnet/rsync.py b/src/execnet/rsync.py index f92bc37a..786ed42b 100644 --- a/src/execnet/rsync.py +++ b/src/execnet/rsync.py @@ -1,249 +1,12 @@ -""" -1:N rsync implementation on top of execnet. +"""Deprecated alias for :mod:`execnet._rsync`. -(c) 2006-2009, Armin Rigo, Holger Krekel, Maciej Fijalkowski +``RSync`` is exported from :mod:`execnet` and :mod:`execnet.sync`; use those. """ from __future__ import annotations -import os -import stat -from collections.abc import Callable -from hashlib import md5 -from queue import Queue -from typing import Literal - -import execnet.rsync_remote -from execnet.gateway import Gateway -from execnet.gateway_base import BaseGateway -from execnet.gateway_base import Channel - - -class RSync: - """This class allows to send a directory structure (recursively) - to one or multiple remote filesystems. - - There is limited support for symlinks, which means that symlinks - pointing to the sourcetree will be send "as is" while external - symlinks will be just copied (regardless of existence of such - a path on remote side). - """ - - def __init__(self, sourcedir, callback=None, verbose: bool = True) -> None: - self._sourcedir = str(sourcedir) - self._verbose = verbose - assert callback is None or callable(callback) - self._callback = callback - self._channels: dict[Channel, Callable[[], None] | None] = {} - self._receivequeue: Queue[ - tuple[ - Channel, - ( - None - | tuple[Literal["send"], tuple[list[str], bytes]] - | tuple[Literal["list_done"], None] - | tuple[Literal["ack"], str] - | tuple[Literal["links"], None] - | tuple[Literal["done"], None] - ), - ] - ] = Queue() - self._links: list[tuple[Literal["linkbase", "link"], str, str]] = [] - - def filter(self, path: str) -> bool: - return True - - def _end_of_channel(self, channel: Channel) -> None: - if channel in self._channels: - # too early! we must have got an error - channel.waitclose() - # or else we raise one - raise OSError(f"connection unexpectedly closed: {channel.gateway} ") - - def _process_link(self, channel: Channel) -> None: - for link in self._links: - channel.send(link) - # completion marker, this host is done - channel.send(42) - - def _done(self, channel: Channel) -> None: - """Call all callbacks.""" - finishedcallback = self._channels.pop(channel) - if finishedcallback: - finishedcallback() - channel.waitclose() - - def _list_done(self, channel: Channel) -> None: - # sum up all to send - if self._callback: - s = sum([self._paths[i] for i in self._to_send[channel]]) - self._callback("list", s, channel) - - def _send_item( - self, - channel: Channel, - modified_rel_path_components: list[str], - checksum: bytes, - ) -> None: - """Send one item.""" - modified_path = os.path.join(self._sourcedir, *modified_rel_path_components) - try: - with open(modified_path, "rb") as fp: - data = fp.read() - except OSError: - data = None - - # provide info to progress callback function - modified_rel_path = "/".join(modified_rel_path_components) - if data is not None: - self._paths[modified_rel_path] = len(data) - else: - self._paths[modified_rel_path] = 0 - if channel not in self._to_send: - self._to_send[channel] = [] - self._to_send[channel].append(modified_rel_path) - # print "sending", modified_rel_path, data and len(data) or 0, checksum - - if data is not None: - if checksum is not None and checksum == md5(data).digest(): - data = None # not really modified - else: - self._report_send_file(channel.gateway, modified_rel_path) - channel.send(data) - - def _report_send_file(self, gateway: BaseGateway, modified_rel_path: str) -> None: - if self._verbose: - print(f"{gateway} <= {modified_rel_path}") - - def send(self, raises: bool = True) -> None: - """Sends a sourcedir to all added targets. - - raises indicates whether to raise an error or return in case of lack of - targets. - """ - if not self._channels: - if raises: - raise OSError( - "no targets available, maybe you are trying call send() twice?" - ) - return - # normalize a trailing '/' away - self._sourcedir = os.path.dirname(os.path.join(self._sourcedir, "x")) - # send directory structure and file timestamps/sizes - self._send_directory_structure(self._sourcedir) - - # paths and to_send are only used for doing - # progress-related callbacks - self._paths: dict[str, int] = {} - self._to_send: dict[Channel, list[str]] = {} - - # send modified file to clients - while self._channels: - channel, req = self._receivequeue.get() - if req is None: - self._end_of_channel(channel) - else: - if req[0] == "links": - self._process_link(channel) - elif req[0] == "done": - self._done(channel) - elif req[0] == "ack": - if self._callback: - self._callback("ack", self._paths[req[1]], channel) - elif req[0] == "list_done": - self._list_done(channel) - elif req[0] == "send": - self._send_item(channel, req[1][0], req[1][1]) - else: - assert "Unknown command %s" % req[0] # type: ignore[unreachable] - - def add_target( - self, - gateway: Gateway, - destdir: str | os.PathLike[str], - finishedcallback: Callable[[], None] | None = None, - **options, - ) -> None: - """Add a remote target specified via a gateway and a remote destination - directory.""" - for name in options: - assert name in ("delete",) - - def itemcallback(req) -> None: - self._receivequeue.put((channel, req)) - - channel = gateway.remote_exec(execnet.rsync_remote) - channel.reconfigure(py2str_as_py3str=False, py3str_as_py2str=False) - channel.setcallback(itemcallback, endmarker=None) - channel.send((str(destdir), options)) - self._channels[channel] = finishedcallback - - def _broadcast(self, msg: object) -> None: - for channel in self._channels: - channel.send(msg) - - def _send_link( - self, - linktype: Literal["linkbase", "link"], - basename: str, - linkpoint: str, - ) -> None: - self._links.append((linktype, basename, linkpoint)) - - def _send_directory(self, path: str) -> None: - # dir: send a list of entries - names = [] - subpaths = [] - for name in os.listdir(path): - p = os.path.join(path, name) - if self.filter(p): - names.append(name) - subpaths.append(p) - mode = os.lstat(path).st_mode - self._broadcast([mode, *names]) - for p in subpaths: - self._send_directory_structure(p) +from ._shim import forwarder - def _send_link_structure(self, path: str) -> None: - sourcedir = self._sourcedir - basename = path[len(self._sourcedir) + 1 :] - linkpoint = os.readlink(path) - # On Windows, readlink returns an extended path (//?/) for - # absolute links, but relpath doesn't like mixing extended - # and non-extended paths. So fix it up ourselves. - if ( - os.path.__name__ == "ntpath" - and linkpoint.startswith("\\\\?\\") - and not self._sourcedir.startswith("\\\\?\\") - ): - sourcedir = "\\\\?\\" + self._sourcedir - try: - relpath = os.path.relpath(linkpoint, sourcedir) - except ValueError: - relpath = None - if ( - relpath is not None - and relpath not in (os.curdir, os.pardir) - and not relpath.startswith(os.pardir + os.sep) - ): - self._send_link("linkbase", basename, relpath) - else: - # relative or absolute link, just send it - self._send_link("link", basename, linkpoint) - self._broadcast(None) +_MOVED = {"RSync": "._rsync"} - def _send_directory_structure(self, path: str) -> None: - try: - st = os.lstat(path) - except OSError: - self._broadcast((None, 0, 0)) - return - if stat.S_ISREG(st.st_mode): - # regular file: send a mode/timestamp/size pair - self._broadcast((st.st_mode, st.st_mtime, st.st_size)) - elif stat.S_ISDIR(st.st_mode): - self._send_directory(path) - elif stat.S_ISLNK(st.st_mode): - self._send_link_structure(path) - else: - raise ValueError(f"cannot sync {path!r}") +__getattr__ = forwarder("rsync", _MOVED) diff --git a/src/execnet/rsync_remote.py b/src/execnet/rsync_remote.py index a8467b76..4770918a 100644 --- a/src/execnet/rsync_remote.py +++ b/src/execnet/rsync_remote.py @@ -1,126 +1,13 @@ -""" -(c) 2006-2013, Armin Rigo, Holger Krekel, Maciej Fijalkowski +"""Deprecated alias for :mod:`execnet._rsync_remote`. + +The worker half of the rsync protocol; :class:`execnet.RSync` ships it to the +remote side itself, so there is no reason to reference this module. """ from __future__ import annotations -from contextlib import suppress -from typing import TYPE_CHECKING -from typing import Literal -from typing import cast - -if TYPE_CHECKING: - from execnet.gateway_base import Channel - - -def serve_rsync(channel: Channel) -> None: - import os - import shutil - import stat - from hashlib import md5 - - destdir, options = cast("tuple[str, dict[str, object]]", channel.receive()) - modifiedfiles = [] - - def remove(path: str) -> None: - assert path.startswith(destdir) - try: - os.unlink(path) - except OSError: - # assume it's a dir - shutil.rmtree(path, True) - - def receive_directory_structure(path: str, relcomponents: list[str]) -> None: - try: - st = os.lstat(path) - except OSError: - st = None - msg = channel.receive() - if isinstance(msg, list): - if st and not stat.S_ISDIR(st.st_mode): - os.unlink(path) - st = None - if not st: - os.makedirs(path) - mode = msg.pop(0) - if mode: - # Ensure directories are writable, otherwise a - # permission denied error (EACCES) would be raised - # when attempting to receive read-only directory - # structures. - os.chmod(path, mode | 0o700) - entrynames = {} - for entryname in msg: - destpath = os.path.join(path, entryname) - receive_directory_structure(destpath, [*relcomponents, entryname]) - entrynames[entryname] = True - if options.get("delete"): - for othername in os.listdir(path): - if othername not in entrynames: - otherpath = os.path.join(path, othername) - remove(otherpath) - elif msg is not None: - assert isinstance(msg, tuple) - checksum = None - if st: - if stat.S_ISREG(st.st_mode): - msg_mode, msg_mtime, msg_size = msg - if msg_size != st.st_size: - pass - elif msg_mtime != st.st_mtime: - with open(path, "rb") as fp: - checksum = md5(fp.read()).digest() - elif msg_mode and msg_mode != st.st_mode: - os.chmod(path, msg_mode | 0o700) - return - else: - return # already fine - else: - remove(path) - channel.send(("send", (relcomponents, checksum))) - modifiedfiles.append((path, msg)) - - receive_directory_structure(destdir, []) - - STRICT_CHECK = False # seems most useful this way for py.test - channel.send(("list_done", None)) - - for path, (mode, time, size) in modifiedfiles: - data = cast(bytes, channel.receive()) - channel.send(("ack", path[len(destdir) + 1 :])) - if data is not None: - if STRICT_CHECK and len(data) != size: - raise OSError(f"file modified during rsync: {path!r}") - with open(path, "wb") as fp: - fp.write(data) - try: - if mode: - os.chmod(path, mode) - os.utime(path, (time, time)) - except OSError: - pass - del data - channel.send(("links", None)) - - msg = channel.receive() - while msg != 42: - # we get symlink - _type, relpath, linkpoint = cast( - "tuple[Literal['linkbase', 'link'], str, str]", msg - ) - path = os.path.join(destdir, relpath) - with suppress(OSError): - remove(path) - - if _type == "linkbase": - src = os.path.join(destdir, linkpoint) - else: - assert _type == "link", _type - src = linkpoint - os.symlink(src, path) - msg = channel.receive() - channel.send(("done", None)) +from ._shim import forwarder +_MOVED = {"serve_rsync": "._rsync_remote"} -if __name__ == "__channelexec__": - serve_rsync(channel) # type: ignore[name-defined] # noqa:F821 +__getattr__ = forwarder("rsync_remote", _MOVED) diff --git a/src/execnet/script/__init__.py b/src/execnet/script/__init__.py deleted file mode 100644 index 792d6005..00000000 --- a/src/execnet/script/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# diff --git a/src/execnet/script/loop_socketserver.py b/src/execnet/script/loop_socketserver.py deleted file mode 100644 index a4688a80..00000000 --- a/src/execnet/script/loop_socketserver.py +++ /dev/null @@ -1,14 +0,0 @@ -import os -import subprocess -import sys - -if __name__ == "__main__": - directory = os.path.dirname(os.path.abspath(sys.argv[0])) - script = os.path.join(directory, "socketserver.py") - while 1: - cmdlist = ["python", script] - cmdlist.extend(sys.argv[1:]) - text = "starting subcommand: " + " ".join(cmdlist) - print(text) - process = subprocess.Popen(cmdlist) - process.wait() diff --git a/src/execnet/script/quitserver.py b/src/execnet/script/quitserver.py deleted file mode 100644 index 4c94c383..00000000 --- a/src/execnet/script/quitserver.py +++ /dev/null @@ -1,17 +0,0 @@ -""" - -send a "quit" signal to a remote server - -""" - -from __future__ import annotations - -import socket -import sys - -host, port = sys.argv[1].split(":") -hostport = (host, int(port)) - -sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) -sock.connect(hostport) -sock.sendall(b'"raise KeyboardInterrupt"\n') diff --git a/src/execnet/script/shell.py b/src/execnet/script/shell.py deleted file mode 100644 index de6f8ded..00000000 --- a/src/execnet/script/shell.py +++ /dev/null @@ -1,91 +0,0 @@ -#! /usr/bin/env python -""" -a remote python shell - -for injection into startserver.py -""" - -import os -import select -import socket -import sys -from threading import Thread -from traceback import print_exc -from typing import NoReturn - - -def clientside() -> NoReturn: - print("client side starting") - host, portstr = sys.argv[1].split(":") - port = int(portstr) - with open(os.path.abspath(sys.argv[0])) as fd: - myself = fd.read() - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.connect((host, port)) - sock.sendall((repr(myself) + "\n").encode()) - print("send boot string") - inputlist = [sock, sys.stdin] - try: - while 1: - r, _w, _e = select.select(inputlist, [], []) - if sys.stdin in r: - line = input() - sock.sendall((line + "\n").encode()) - if sock in r: - line = sock.recv(4096).decode() - sys.stdout.write(line) - sys.stdout.flush() - except BaseException: - import traceback - - traceback.print_exc() - - sys.exit(1) - - -class promptagent(Thread): - def __init__(self, clientsock) -> None: - print("server side starting") - super().__init__() # type: ignore[call-overload] - self.clientsock = clientsock - - def run(self) -> None: - print("Entering thread prompt loop") - clientfile = self.clientsock.makefile("w") - - filein = self.clientsock.makefile("r") - loc = self.clientsock.getsockname() - - while 1: - try: - clientfile.write("{} {} >>> ".format(*loc)) - clientfile.flush() - line = filein.readline() - if not line: - raise EOFError("nothing") - if line.strip(): - oldout, olderr = sys.stdout, sys.stderr - sys.stdout, sys.stderr = clientfile, clientfile - try: - try: - exec(compile(line + "\n", "", "single")) - except BaseException: - print_exc() - finally: - sys.stdout = oldout - sys.stderr = olderr - clientfile.flush() - except EOFError: - sys.stderr.write("connection close, prompt thread returns") - break - - self.clientsock.close() - - -sock = globals().get("clientsock") -if sock is not None: - prompter = promptagent(sock) - prompter.start() - print("promptagent - thread started") -else: - clientside() diff --git a/src/execnet/script/socketserver.py b/src/execnet/script/socketserver.py deleted file mode 100644 index fa98743c..00000000 --- a/src/execnet/script/socketserver.py +++ /dev/null @@ -1,133 +0,0 @@ -#! /usr/bin/env python -""" -start socket based minimal readline exec server - -it can exeuted in 2 modes of operation - -1. as normal script, that listens for new connections - -2. via existing_gateway.remote_exec (as imported module) - -""" - -# this part of the program only executes on the server side -# -from __future__ import annotations - -import os -import sys -from typing import TYPE_CHECKING - -try: - import fcntl -except ImportError: - fcntl = None # type: ignore[assignment] - -if TYPE_CHECKING: - from execnet.gateway_base import Channel - from execnet.gateway_base import ExecModel - -progname = "socket_readline_exec_server-1.2" - - -debug = 0 - -if debug: # and not os.isatty(sys.stdin.fileno()) - f = open("/tmp/execnet-socket-pyout.log", "w") - old = sys.stdout, sys.stderr - sys.stdout = sys.stderr = f - - -def print_(*args) -> None: - print(" ".join(str(arg) for arg in args)) - - -exec( - """def exec_(source, locs): - exec(source, locs)""" -) - - -def exec_from_one_connection(serversock) -> None: - print_(progname, "Entering Accept loop", serversock.getsockname()) - clientsock, address = serversock.accept() - print_(progname, "got new connection from {} {}".format(*address)) - clientfile = clientsock.makefile("rb") - print_("reading line") - # rstrip so that we can use \r\n for telnet testing - source = clientfile.readline().rstrip() - clientfile.close() - g = {"clientsock": clientsock, "address": address, "execmodel": execmodel} - source = eval(source) - if source: - co = compile(source + "\n", "", "exec") - print_(progname, "compiled source, executing") - try: - exec_(co, g) # type: ignore[name-defined] # noqa: F821 - finally: - print_(progname, "finished executing code") - # background thread might hold a reference to this (!?) - # clientsock.close() - - -def bind_and_listen(hostport: str | tuple[str, int], execmodel: ExecModel): - socket = execmodel.socket - if isinstance(hostport, str): - host, port = hostport.split(":") - hostport = (host, int(port)) - serversock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - # set close-on-exec - if hasattr(fcntl, "FD_CLOEXEC"): - old = fcntl.fcntl(serversock.fileno(), fcntl.F_GETFD) - fcntl.fcntl(serversock.fileno(), fcntl.F_SETFD, old | fcntl.FD_CLOEXEC) - # allow the address to be reused in a reasonable amount of time - if os.name == "posix" and sys.platform != "cygwin": - serversock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - - serversock.bind(hostport) - serversock.listen(5) - return serversock - - -def startserver(serversock, loop: bool = False) -> None: - execute_path = os.getcwd() - try: - while 1: - try: - exec_from_one_connection(serversock) - except (KeyboardInterrupt, SystemExit): - raise - except BaseException as exc: - if debug: - import traceback - - traceback.print_exc() - else: - print_("got exception", exc) - os.chdir(execute_path) - if not loop: - break - finally: - print_("leaving socketserver execloop") - serversock.shutdown(2) - - -if __name__ == "__main__": - import sys - - hostport = sys.argv[1] if len(sys.argv) > 1 else ":8888" - from execnet.gateway_base import get_execmodel - - execmodel = get_execmodel("thread") - serversock = bind_and_listen(hostport, execmodel) - startserver(serversock, loop=True) - -elif __name__ == "__channelexec__": - chan: Channel = globals()["channel"] - execmodel = chan.gateway.execmodel - bindname = chan.receive() - assert isinstance(bindname, (str, tuple)) - sock = bind_and_listen(bindname, execmodel) - port = sock.getsockname() - chan.send(port) - startserver(sock) diff --git a/src/execnet/script/socketserverservice.py b/src/execnet/script/socketserverservice.py deleted file mode 100644 index 18e375c4..00000000 --- a/src/execnet/script/socketserverservice.py +++ /dev/null @@ -1,95 +0,0 @@ -""" -A windows service wrapper for the py.execnet socketserver. - -To use, run: - python socketserverservice.py register - net start ExecNetSocketServer -""" - -import sys -import threading - -import servicemanager -import win32event -import win32evtlogutil -import win32service -import win32serviceutil - -from execnet.gateway_base import get_execmodel - -from . import socketserver - -appname = "ExecNetSocketServer" - - -class SocketServerService(win32serviceutil.ServiceFramework): - _svc_name_ = appname - _svc_display_name_ = "%s" % appname - _svc_deps_ = ["EventLog"] - - def __init__(self, args) -> None: - # The exe-file has messages for the Event Log Viewer. - # Register the exe-file as event source. - # - # Probably it would be better if this is done at installation time, - # so that it also could be removed if the service is uninstalled. - # Unfortunately it cannot be done in the 'if __name__ == "__main__"' - # block below, because the 'frozen' exe-file does not run this code. - # - win32evtlogutil.AddSourceToRegistry( - self._svc_display_name_, servicemanager.__file__, "Application" - ) - super().__init__(args) - self.hWaitStop = win32event.CreateEvent(None, 0, 0, None) - self.WAIT_TIME = 1000 # in milliseconds - - def SvcStop(self) -> None: - self.ReportServiceStatus(win32service.SERVICE_STOP_PENDING) - win32event.SetEvent(self.hWaitStop) - - def SvcDoRun(self) -> None: - # Redirect stdout and stderr to prevent "IOError: [Errno 9] - # Bad file descriptor". Windows services don't have functional - # output streams. - sys.stdout = sys.stderr = open("nul", "w") - - # Write a 'started' event to the event log... - win32evtlogutil.ReportEvent( - self._svc_display_name_, - servicemanager.PYS_SERVICE_STARTED, - 0, # category - servicemanager.EVENTLOG_INFORMATION_TYPE, - (self._svc_name_, ""), - ) - print("Begin: %s" % self._svc_display_name_) - - hostport = ":8888" - print("Starting py.execnet SocketServer on %s" % hostport) - exec_model = get_execmodel("thread") - serversock = socketserver.bind_and_listen(hostport, exec_model) - thread = threading.Thread( - target=socketserver.startserver, args=(serversock,), kwargs={"loop": True} - ) - thread.setDaemon(True) - thread.start() - - # wait to be stopped or self.WAIT_TIME to pass - while True: - result = win32event.WaitForSingleObject(self.hWaitStop, self.WAIT_TIME) - if result == win32event.WAIT_OBJECT_0: - break - - # write a 'stopped' event to the event log. - win32evtlogutil.ReportEvent( - self._svc_display_name_, - servicemanager.PYS_SERVICE_STOPPED, - 0, # category - servicemanager.EVENTLOG_INFORMATION_TYPE, - (self._svc_name_, ""), - ) - print("End: %s" % appname) - - -if __name__ == "__main__": - # Note that this code will not be run in the 'frozen' exe-file!!! - win32serviceutil.HandleCommandLine(SocketServerService) diff --git a/src/execnet/sync.py b/src/execnet/sync.py new file mode 100644 index 00000000..4adecbaa --- /dev/null +++ b/src/execnet/sync.py @@ -0,0 +1,59 @@ +"""The blocking execnet API. + +A facade over the trio-native core in :mod:`execnet.trio`: gateways run +their protocol IO on a :class:`~execnet.ProtocolEngine` while this surface +blocks the calling thread. The top-level ``execnet.*`` names are aliases +into this module. +""" + +from ._channel import Channel +from ._deploy import Deployed +from ._deploy import Deployment +from ._deploy import transfer +from ._engine import ProtocolEngine +from ._errors import ActiveGroupsWarning +from ._errors import ChannelClosed +from ._errors import DataFormatError +from ._errors import DumpError +from ._errors import ExecnetStateError +from ._errors import GatewayGone +from ._errors import HostNotFound +from ._errors import LoadError +from ._errors import RemoteError +from ._errors import TimeoutError +from ._gateway import Gateway +from ._multi import Group +from ._multi import MultiChannel +from ._multi import default_group +from ._multi import makegateway +from ._multi import set_execmodel +from ._multi import set_profile +from ._rsync import RSync +from ._xspec import XSpec + +__all__ = [ + "ActiveGroupsWarning", + "Channel", + "ChannelClosed", + "DataFormatError", + "Deployed", + "Deployment", + "DumpError", + "ExecnetStateError", + "Gateway", + "GatewayGone", + "Group", + "HostNotFound", + "LoadError", + "MultiChannel", + "ProtocolEngine", + "RSync", + "RemoteError", + "TimeoutError", + "XSpec", + "default_group", + "makegateway", + "set_execmodel", + "set_profile", + "transfer", +] diff --git a/src/execnet/trio.py b/src/execnet/trio.py new file mode 100644 index 00000000..14e32164 --- /dev/null +++ b/src/execnet/trio.py @@ -0,0 +1,378 @@ +"""The trio execnet API, with protocol IO on an engine of its own. + +Everything here is awaited inside your own ``trio.run``:: + + import trio + import execnet.trio + + async def main(): + async with execnet.trio.AsyncGroup() as group: + gateway = await group.makegateway("popen") + channel = await gateway.remote_exec("channel.send(6 * 7)") + print(await channel.receive()) + + trio.run(main) + +The gateways are *not* tasks in your nursery: they live on a shared +:class:`~execnet.ProtocolEngine`, one thread running a loop of its own, the +same one :mod:`execnet.sync`, :mod:`execnet.gevent` and :mod:`execnet.aio` +use. So a busy stretch in your loop does not stall protocol IO, an +execnet failure does not cancel your task tree, execnet's own thread work +does not compete with yours, and a gateway is a handle you can hold past +the scope that made it. Each awaited operation costs a hop to that engine +and back. + +:mod:`execnet.raw_trio` is the other trade: gateways as tasks in your own +nursery, no hop, exact cancellation -- and your loop *is* the protocol +loop, with everything that implies. It is the right choice when execnet +is the only thing your loop does. + +**Cancellation crosses the bridge, and loses nothing.** Cancelling an +awaited ``receive`` cancels the engine-side receive too; if the cancel +lands after the engine already took an item, that item is kept and handed +to your next ``receive`` rather than dropped. So a cancelled receive +consumes nothing here either, which is what :mod:`execnet.raw_trio` gets +from having no bridge at all. Operations that must not tear in half -- +``send``, ``send_eof``, ``aclose``, ``terminate`` -- are shielded instead: +the wait is uncancellable and returns once the operation is done. + +This surface is deliberately a *subset* of :mod:`execnet.raw_trio`. The +raw channel layer and everything whose lifetime is a caller-side nursery +are absent, because they do not survive the crossing: channel ids come +from an unlocked per-gateway counter that works only because one loop owns +it. What is here behaves the same on both, cancellation aside. + +The error types are shared with :mod:`execnet.sync`. Items you send must +already be simple builtin data (plus channels); the standalone serializer +is intentionally not part of the public API -- ``execnet.can_send`` checks +a value before you send it; see ``DumpError``. +""" + +from __future__ import annotations + +import functools +import types +from collections.abc import Callable +from collections.abc import Sequence +from contextlib import asynccontextmanager +from contextlib import suppress +from typing import TYPE_CHECKING +from typing import Any +from typing import cast + +from ._bridge import EngineGroup +from ._bridge import TrioBridge +from ._bridge import TrioCarrier +from ._bridge import start_engine +from ._deploy import Deployed +from ._deploy import Deployment +from ._engine import ProtocolEngine +from ._engine import default_engine +from ._errors import ActiveGroupsWarning +from ._errors import ChannelClosed +from ._errors import DataFormatError +from ._errors import DumpError +from ._errors import ExecnetStateError +from ._errors import GatewayGone +from ._errors import HostNotFound +from ._errors import LoadError +from ._errors import RemoteError +from ._errors import TimeoutError +from ._trio_gateway import AsyncChannel as _TrioChannel +from ._trio_gateway import AsyncGateway as _TrioGateway +from ._xspec import XSpec + +if TYPE_CHECKING: + from collections.abc import AsyncIterator + + from typing_extensions import Self + + from ._serialize import Payload + from ._serialize import SendPayload + +__all__ = [ + "ActiveGroupsWarning", + "AsyncChannel", + "AsyncGateway", + "AsyncGroup", + "ChannelClosed", + "DataFormatError", + "Deployed", + "Deployment", + "DumpError", + "ExecnetStateError", + "GatewayGone", + "HostNotFound", + "LoadError", + "ProtocolEngine", + "RemoteError", + "TimeoutError", + "XSpec", + "deploy", + "deploy_all", + "open_gateway", + "transfer", +] + + +#: distinguishes "no salvaged item" from a salvaged ``None`` +_NOTHING = object() + + +class AsyncChannel: + """trio facade over a channel served on the engine.""" + + RemoteError = RemoteError + TimeoutError = TimeoutError + + def __init__(self, bridge: TrioBridge, channel: _TrioChannel) -> None: + self._bridge = bridge + self._channel = channel + #: an item the engine produced for a receive that was cancelled + #: before it could be taken. At most one: the engine-side receive + #: that produced it has finished, so nothing else was consumed + #: behind it and the next receive is still in order. + self._salvaged: Any = _NOTHING + + @property + def id(self) -> int: + return self._channel.id + + def __repr__(self) -> str: + return f"" + + def isclosed(self) -> bool: + """Return True if the channel is closed for sending.""" + return self._channel.isclosed() + + async def send(self, item: SendPayload) -> None: + """Serialize ``item`` and send it to the other side. + + Shielded: the wait is uncancellable, so the item is sent rather + than leaving a half-written frame on the wire. + """ + await self._bridge.call(self._channel.send, item, shield=True) + + async def receive(self, timeout: float | None = None) -> Payload[AsyncChannel]: + """Receive the next item sent from the other side. + + EOFError once the peer closed or sent EOF, RemoteError for a peer + close-with-error, TimeoutError after ``timeout`` seconds. A + received channel reference arrives as an + :class:`~execnet.trio.AsyncChannel`. + + Cancellable, and equivalent to passing ``timeout``: the engine-side + receive is cancelled too, and an item the engine had already taken + when the cancel landed is kept for the next call rather than + dropped. Cancelling a receive never costs you an item. + """ + if self._salvaged is not _NOTHING: + result, self._salvaged = self._salvaged, _NOTHING + else: + result = await self._bridge.call( + self._channel.receive, timeout, salvage=self._stash + ) + if isinstance(result, _TrioChannel): + return AsyncChannel(self._bridge, result) + return cast("Payload[AsyncChannel]", result) + + def _stash(self, item: Payload[AsyncChannel]) -> None: + """Keep an item whose receive was cancelled before it arrived.""" + self._salvaged = item + + async def send_eof(self) -> None: + """Signal that no more items follow (peer keeps its send side).""" + await self._bridge.call(self._channel.send_eof, shield=True) + + async def aclose(self, error: str | None = None) -> None: + """Close the channel; ``error`` reaches the peer as a RemoteError.""" + await self._bridge.call(self._channel.aclose, error, shield=True) + + async def wait_closed(self) -> None: + """Wait until the peer closed or sent EOF; reraise remote errors.""" + await self._bridge.call(self._channel.wait_closed) + + def __aiter__(self) -> AsyncChannel: + return self + + async def __anext__(self) -> Payload[AsyncChannel]: + try: + return await self.receive() + except EOFError: + raise StopAsyncIteration from None + + +class AsyncGateway: + """trio facade over a gateway served on the engine.""" + + def __init__(self, bridge: TrioBridge, gateway: _TrioGateway) -> None: + self._bridge = bridge + self._gateway = gateway + + @property + def id(self) -> str: + return self._gateway.id + + @property + def remoteaddress(self) -> str | None: + return self._gateway.remoteaddress + + def __repr__(self) -> str: + return f"" + + async def remote_exec( + self, + source: str | types.FunctionType | Callable[..., object] | types.ModuleType, + **kwargs: SendPayload, + ) -> AsyncChannel: + """Connect a new channel to remote execution of ``source``. + + Accepts the same source kinds as ``Gateway.remote_exec``: a source + string, a pure function called with ``channel`` and ``**kwargs``, + or a module. + """ + channel = await self._bridge.call( + functools.partial(self._gateway.remote_exec, source, **kwargs) + ) + return AsyncChannel(self._bridge, channel) + + async def terminate(self) -> None: + """Send GATEWAY_TERMINATE to the peer, then close this side.""" + await self._bridge.call(self._gateway.terminate, shield=True) + + def _target(self) -> Any: + """This gateway as a service target (transfers, deployments).""" + from ._services import ServiceTarget + + return ServiceTarget(self._gateway) + + +class AsyncGroup: + """trio-native gateway group served on a ProtocolEngine. + + Usable as an async context manager, or driven explicitly with + :meth:`start` / :meth:`aclose` from application lifespan hooks. Either + way, shutting down terminates every gateway with the same bounded + contract as :class:`execnet.raw_trio.AsyncGroup`. + + Unlike that one, this group is not bound to the nursery you started it + in: it runs on the engine, so its gateways outlive any particular scope + of yours and are finished with only when you say so. + """ + + def __init__( + self, + termination_timeout: float = 10.0, + *, + engine: ProtocolEngine | None = None, + ) -> None: + self._termination_timeout = termination_timeout + self._engine = default_engine() if engine is None else engine + self._bridge: TrioBridge | None = None + self._group: EngineGroup | None = None + + def __repr__(self) -> str: + state = "running" if self._group is not None else "idle" + return f"" + + @property + def engine(self) -> ProtocolEngine: + """The :class:`~execnet.ProtocolEngine` this group's IO runs on.""" + return self._engine + + async def start(self) -> None: + """Bring the engine up and start the group task on it.""" + if self._group is not None: + raise RuntimeError(f"{self!r} is already started") + trio_engine = await start_engine(self._engine, TrioCarrier()) + bridge = TrioBridge(trio_engine) + + async def start_group() -> EngineGroup: + # runs on the engine loop + group = EngineGroup(self._termination_timeout, trio_engine) + started: EngineGroup = await trio_engine.start_task(group.run) + return started + + self._bridge = bridge + self._group = await bridge.call(start_group, shield=True) + + async def aclose(self) -> None: + """Terminate every gateway and stop the group task (idempotent). + + The engine is shared, so it keeps running for other groups. + """ + group, bridge = self._group, self._bridge + self._group = self._bridge = None + if group is None or bridge is None: + return + + async def stop_group() -> None: + group.shutdown.set() + await group.finished.wait() + + with suppress(RuntimeError): + await bridge.call(stop_group, shield=True) + + async def __aenter__(self) -> Self: + await self.start() + return self + + async def __aexit__(self, *exc_info: object) -> None: + await self.aclose() + + async def makegateway(self, spec: str | XSpec = "popen") -> AsyncGateway: + """Create a gateway for ``spec`` served on the group's engine. + + All transports are supported: popen (including uv-provisioned + ``python=``), ``ssh=``, ``vagrant_ssh=``, ``socket=`` (with + ``installvia=``), and ``via=`` sub-gateways. The worker profile + defaults to ``thread``; pass ``profile=trio`` for a worker that + runs exec'd async sources as tasks. + """ + group, bridge = self._group, self._bridge + if group is None or bridge is None: + raise RuntimeError(f"{self!r} is not started") + gateway = await bridge.call(group.makegateway, spec) + return AsyncGateway(bridge, gateway) + + +@asynccontextmanager +async def open_gateway(spec: str | XSpec = "popen") -> AsyncIterator[AsyncGateway]: + """Spawn one worker for ``spec`` and serve a gateway to it.""" + async with AsyncGroup() as group: + yield await group.makegateway(spec) + + +async def transfer( + gateway: AsyncGateway, + source: str | Any, + destination: str, + **options: Any, +) -> None: + """Copy a tree to ``destination`` on ``gateway``; see :mod:`execnet.sync`.""" + from ._deploy import _async_api + + await gateway._bridge.call( + functools.partial( + _async_api.transfer, gateway._target(), source, destination, **options + ) + ) + + +async def deploy(deployment: Deployment, gateway: AsyncGateway) -> Deployed: + """Deploy through ``gateway`` and return where everything landed.""" + results = await deploy_all(deployment, [gateway]) + return results[0] + + +async def deploy_all( + deployment: Deployment, gateways: Sequence[AsyncGateway] +) -> list[Deployed]: + """Deploy to every gateway at once, concurrently on the engine.""" + from ._bridge import targets_for_bridge + from ._deploy import _async_api + + bridge, targets = targets_for_bridge(gateways) + return await bridge.call( + functools.partial(_async_api.deploy_all, deployment, targets) + ) diff --git a/src/execnet/xspec.py b/src/execnet/xspec.py index 0559ed8c..9f5a2634 100644 --- a/src/execnet/xspec.py +++ b/src/execnet/xspec.py @@ -1,73 +1,13 @@ -""" -(c) 2008-2013, holger krekel +"""Deprecated alias for :mod:`execnet._xspec`. + +``XSpec`` is exported from :mod:`execnet`, :mod:`execnet.sync`, +:mod:`execnet.trio` and :mod:`execnet.aio`; use those. """ from __future__ import annotations +from ._shim import forwarder -class XSpec: - """Execution Specification: key1=value1//key2=value2 ... - - * Keys need to be unique within the specification scope - * Neither key nor value are allowed to contain "//" - * Keys are not allowed to contain "=" - * Keys are not allowed to start with underscore - * If no "=value" is given, assume a boolean True value - """ - - # XXX allow customization, for only allow specific key names - chdir: str | None = None - dont_write_bytecode: bool | None = None - execmodel: str | None = None - id: str | None = None - installvia: str | None = None - nice: str | None = None - popen: bool | None = None - python: str | None = None - socket: str | None = None - ssh: str | None = None - ssh_config: str | None = None - vagrant_ssh: str | None = None - via: str | None = None - - def __init__(self, string: str) -> None: - self._spec = string - self.env = {} - for keyvalue in string.split("//"): - i = keyvalue.find("=") - value: str | bool - if i == -1: - key, value = keyvalue, True - else: - key, value = keyvalue[:i], keyvalue[i + 1 :] - if key[0] == "_": - raise AttributeError("%r not a valid XSpec key" % key) - if key in self.__dict__: - raise ValueError(f"duplicate key: {key!r} in {string!r}") - if key.startswith("env:"): - self.env[key[4:]] = value - else: - setattr(self, key, value) - - def __getattr__(self, name: str) -> None | bool | str: - if name[0] == "_": - raise AttributeError(name) - return None - - def __repr__(self) -> str: - return f"" - - def __str__(self) -> str: - return self._spec - - def __hash__(self) -> int: - return hash(self._spec) - - def __eq__(self, other: object) -> bool: - return self._spec == getattr(other, "_spec", None) - - def __ne__(self, other: object) -> bool: - return self._spec != getattr(other, "_spec", None) +_MOVED = {"XSpec": "._xspec"} - def _samefilesystem(self) -> bool: - return self.popen is not None and self.chdir is None +__getattr__ = forwarder("xspec", _MOVED) diff --git a/testing/conftest.py b/testing/conftest.py index c75f96c7..22c95006 100644 --- a/testing/conftest.py +++ b/testing/conftest.py @@ -5,15 +5,16 @@ from collections.abc import Callable from collections.abc import Generator from collections.abc import Iterator +from concurrent.futures import ThreadPoolExecutor from functools import lru_cache import pytest import execnet -from execnet.gateway import Gateway -from execnet.gateway_base import ExecModel -from execnet.gateway_base import WorkerPool -from execnet.gateway_base import get_execmodel +from execnet import Gateway +from execnet import _provision +from execnet._execmodel import ExecModel +from execnet._execmodel import get_execmodel collect_ignore = ["build", "doc/_build"] @@ -69,6 +70,44 @@ def pytest_addoption(parser: pytest.Parser) -> None: "page on invalid addresses" ), ) + group.addoption( + "--stress", + action="store", + dest="stress", + default=None, + metavar="N", + help=( + "how hard the Hypothesis stress tests try: number of examples " + "per test (e.g. --stress=500). Without it a quick profile runs." + ), + ) + + +def pytest_configure(config: pytest.Config) -> None: + # Register Hypothesis profiles scaled by --stress. The stress tests reuse + # a function-scoped gateway across examples on purpose (spawning one per + # example would dominate the runtime), and each round-trip can be slow, so + # the health checks for those are suppressed. + try: + from hypothesis import HealthCheck + from hypothesis import settings + except ImportError: + return + suppress = [HealthCheck.function_scoped_fixture, HealthCheck.too_slow] + settings.register_profile( + "execnet-quick", max_examples=15, deadline=None, suppress_health_check=suppress + ) + stress = config.getoption("stress") + if stress is not None: + settings.register_profile( + "execnet-stress", + max_examples=int(stress), + deadline=None, + suppress_health_check=suppress, + ) + settings.load_profile("execnet-stress") + else: + settings.load_profile("execnet-quick") @pytest.fixture @@ -128,10 +167,6 @@ def anypython(request: pytest.FixtureRequest) -> str: executable = getexecutable(name) if executable is None: pytest.skip(f"no {name} found") - if "execmodel" in request.fixturenames and name != "sys.executable": - backend = request.getfixturevalue("execmodel").backend - if backend not in ("thread", "main_thread_only"): - pytest.xfail(f"cannot run {backend!r} execmodel with bare {name}") return executable @@ -145,54 +180,58 @@ def group() -> Iterator[execnet.Group]: @pytest.fixture def gw( request: pytest.FixtureRequest, - execmodel: ExecModel, + profile: str, group: execnet.Group, ) -> Gateway: try: return group[request.param] except KeyError: if request.param == "popen": - gw = group.makegateway("popen//id=popen//execmodel=%s" % execmodel.backend) + gw = group.makegateway("popen//id=popen//profile=%s" % profile) elif request.param == "socket": - # if execmodel.backend != "thread": - # pytest.xfail( - # "cannot set remote non-thread execmodel for sockets") + if not _provision.socket_handoff_available(): + # the server accepts the connection and must then give it to + # a worker process; where neither pass_fds nor a working + # socket.share() exists (PyPy on Windows) there is no way to + pytest.skip("this interpreter cannot hand a socket to a worker") pname = "sproxy1" if pname not in group: proxygw = group.makegateway("popen//id=%s" % pname) # assert group['proxygw'].remote_status().receiving gw = group.makegateway( - f"socket//id=socket//installvia={pname}//execmodel={execmodel.backend}" + f"socket//id=socket//installvia={pname}//profile={profile}" ) # TODO(typing): Clarify this assignment. gw.proxygw = proxygw # type: ignore[attr-defined] assert pname in group elif request.param == "ssh": sshhost = request.getfixturevalue("specssh").ssh - # we don't use execmodel.backend here - # but you can set it when specifying the ssh spec + # the profile is not forced here; set it in the ssh spec instead gw = group.makegateway(f"ssh={sshhost}//id=ssh") elif request.param == "proxy": group.makegateway("popen//id=proxy-transport") gw = group.makegateway( - "popen//via=proxy-transport//id=proxy//execmodel=%s" % execmodel.backend + "popen//via=proxy-transport//id=proxy//profile=%s" % profile ) else: - assert 0, f"unknown execmodel: {request.param}" + assert 0, f"unknown gateway type: {request.param}" return gw -@pytest.fixture( - params=["thread", "main_thread_only", "eventlet", "gevent"], scope="session" -) -def execmodel(request: pytest.FixtureRequest) -> ExecModel: - if request.param not in ("thread", "main_thread_only"): - pytest.importorskip(request.param) - if request.param in ("eventlet", "gevent") and sys.platform == "win32": - pytest.xfail(request.param + " does not work on win32") - return get_execmodel(request.param) +@pytest.fixture(params=["thread"], scope="session") +def profile(request: pytest.FixtureRequest) -> str: + """The worker profile gateways in this test run are created with.""" + param: str = request.param + return param + + +@pytest.fixture(scope="session") +def execmodel(profile: str) -> ExecModel: + """The deprecated ExecModel shim for ``profile`` (pytest-xdist compat).""" + return get_execmodel(profile) @pytest.fixture -def pool(execmodel: ExecModel) -> WorkerPool: - return WorkerPool(execmodel=execmodel) +def executor() -> Iterator[ThreadPoolExecutor]: + with ThreadPoolExecutor() as tpe: + yield tpe diff --git a/testing/sshkeys/README.md b/testing/sshkeys/README.md new file mode 100644 index 00000000..d0133e2c --- /dev/null +++ b/testing/sshkeys/README.md @@ -0,0 +1,17 @@ +# Intentionally insecure test SSH keys + +These ed25519 keypairs are **committed on purpose** and are **not secret**. They +exist only so the local ssh-connect tests (`testing/test_ssh_local.py`) can run +an in-process asyncssh server that the system `ssh` client authenticates against, +without generating keys at runtime. + +- `insecure_host_ed25519[.pub]` — the test SSH **server** host key. +- `insecure_client_ed25519[.pub]` — the test **client** identity; its public key + is the server's sole authorized key. + +**Never** use these anywhere real. They grant nothing beyond a throwaway server +bound to `127.0.0.1` on an ephemeral port during a test run. + +Note: git does not preserve `0600` permissions, and OpenSSH refuses a +world-readable private key, so the tests copy the client key to a temp dir and +`chmod 0600` it before use. diff --git a/testing/sshkeys/insecure_client_ed25519 b/testing/sshkeys/insecure_client_ed25519 new file mode 100644 index 00000000..6f5650c8 --- /dev/null +++ b/testing/sshkeys/insecure_client_ed25519 @@ -0,0 +1,7 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACB6MZDwubY5LfTxiuBxhoOmRZaebHcTuRC/ahHgDDDXzwAAAKBzGIF1cxiB +dQAAAAtzc2gtZWQyNTUxOQAAACB6MZDwubY5LfTxiuBxhoOmRZaebHcTuRC/ahHgDDDXzw +AAAEBCIAXAZCBC6mZFORLaloyPr6HZsRkWpVxVd/vKSZpIhXoxkPC5tjkt9PGK4HGGg6ZF +lp5sdxO5EL9qEeAMMNfPAAAAHGV4ZWNuZXQtaW5zZWN1cmUtdGVzdC1jbGllbnQB +-----END OPENSSH PRIVATE KEY----- diff --git a/testing/sshkeys/insecure_client_ed25519.pub b/testing/sshkeys/insecure_client_ed25519.pub new file mode 100644 index 00000000..b4d824b9 --- /dev/null +++ b/testing/sshkeys/insecure_client_ed25519.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHoxkPC5tjkt9PGK4HGGg6ZFlp5sdxO5EL9qEeAMMNfP execnet-insecure-test-client diff --git a/testing/sshkeys/insecure_host_ed25519 b/testing/sshkeys/insecure_host_ed25519 new file mode 100644 index 00000000..0a31038e --- /dev/null +++ b/testing/sshkeys/insecure_host_ed25519 @@ -0,0 +1,7 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACBqqoHPiDB2eUJlGVCLM2eRtfelu138wnGLDRHd1AG9RwAAAKDBS525wUud +uQAAAAtzc2gtZWQyNTUxOQAAACBqqoHPiDB2eUJlGVCLM2eRtfelu138wnGLDRHd1AG9Rw +AAAECXfzL6Ua9c9U+UkQn+Q7A1aKlBboBhABFALH+/ojmbMGqqgc+IMHZ5QmUZUIszZ5G1 +96W7XfzCcYsNEd3UAb1HAAAAGmV4ZWNuZXQtaW5zZWN1cmUtdGVzdC1ob3N0AQID +-----END OPENSSH PRIVATE KEY----- diff --git a/testing/sshkeys/insecure_host_ed25519.pub b/testing/sshkeys/insecure_host_ed25519.pub new file mode 100644 index 00000000..93d3cf93 --- /dev/null +++ b/testing/sshkeys/insecure_host_ed25519.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGqqgc+IMHZ5QmUZUIszZ5G196W7XfzCcYsNEd3UAb1H execnet-insecure-test-host diff --git a/testing/test_aio.py b/testing/test_aio.py new file mode 100644 index 00000000..0d83cba3 --- /dev/null +++ b/testing/test_aio.py @@ -0,0 +1,220 @@ +"""The asyncio-native API: execnet.aio bridged over the Trio host.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable +from typing import Any +from typing import TypeVar +from typing import cast + +import pytest + +import execnet.aio +from execnet._engine import default_engine + +T = TypeVar("T") + +TESTTIMEOUT = 30.0 + + +def run(main: Awaitable[T]) -> T: + return asyncio.run(asyncio.wait_for(main, TESTTIMEOUT)) + + +def test_popen_roundtrip() -> None: + async def main() -> None: + async with execnet.aio.AsyncGroup() as group: + gateway = await group.makegateway("popen") + channel = await gateway.remote_exec("channel.send(channel.receive() + 1)") + await channel.send(41) + assert await channel.receive() == 42 + await channel.wait_closed() + + run(main()) + + +def test_open_gateway_iteration() -> None: + async def main() -> list[int]: + async with execnet.aio.open_gateway() as gateway: + channel = await gateway.remote_exec( + "for i in range(4): channel.send(i * 2)" + ) + return [cast("int", item) async for item in channel] + + assert run(main()) == [0, 2, 4, 6] + + +def test_receive_timeout() -> None: + async def main() -> None: + async with execnet.aio.open_gateway() as gateway: + channel = await gateway.remote_exec("channel.receive()") + with pytest.raises(channel.TimeoutError): + await channel.receive(timeout=0.05) + await channel.send(None) + + run(main()) + + +def test_remote_error() -> None: + async def main() -> None: + async with execnet.aio.open_gateway() as gateway: + channel = await gateway.remote_exec("raise ValueError(17)") + with pytest.raises(execnet.aio.RemoteError, match="ValueError"): + await channel.receive() + + run(main()) + + +def test_channel_passing_wraps_aio() -> None: + async def main() -> None: + async with execnet.aio.open_gateway() as gateway: + channel = await gateway.remote_exec( + """ + c = channel.gateway.newchannel() + channel.send(c) + c.send(42) + """ + ) + passed = await channel.receive() + assert isinstance(passed, execnet.aio.AsyncChannel) + assert await passed.receive() == 42 + + run(main()) + + +def test_multiple_gateways_and_send_each() -> None: + async def main() -> list[Any]: + async with execnet.aio.AsyncGroup() as group: + gateways = [await group.makegateway("popen") for _ in range(2)] + channels = [ + await gw.remote_exec("channel.send(channel.receive() * 2)") + for gw in gateways + ] + for i, channel in enumerate(channels): + await channel.send(i + 1) + return [await channel.receive() for channel in channels] + + assert run(main()) == [2, 4] + + +def test_group_not_entered() -> None: + async def main() -> None: + group = execnet.aio.AsyncGroup() + with pytest.raises(RuntimeError, match="not started"): + await group.makegateway("popen") + + run(main()) + + +def test_terminate_gateway_explicitly() -> None: + async def main() -> None: + async with execnet.aio.AsyncGroup() as group: + gateway = await group.makegateway("popen") + channel = await gateway.remote_exec("channel.send(1)") + assert await channel.receive() == 1 + await gateway.terminate() + + run(main()) + + +def test_cancelled_receive_does_not_consume_an_item() -> None: + # The bridge cancels the host-side receive, so the item stays queued + # instead of being consumed and dropped -- an asyncio timeout around a + # receive must behave like passing timeout=. + async def main() -> None: + async with execnet.aio.open_gateway() as gateway: + channel = await gateway.remote_exec( + """ + channel.receive() + for i in range(3): + channel.send(i) + """ + ) + with pytest.raises(asyncio.TimeoutError): + await asyncio.wait_for(channel.receive(), 0.05) + # release the worker; nothing was consumed by the cancelled wait + await channel.send("go") + assert [await channel.receive() for _ in range(3)] == [0, 1, 2] + + run(main()) + + +def test_cancelled_send_still_arrives() -> None: + # send is shielded: the caller sees CancelledError but the item is on + # the wire, rather than a frame half-written to the peer. + async def main() -> None: + async with execnet.aio.open_gateway() as gateway: + channel = await gateway.remote_exec("channel.send(channel.receive() * 2)") + task = asyncio.ensure_future(channel.send(21)) + await asyncio.sleep(0) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert await channel.receive() == 42 + + run(main()) + + +def test_group_start_and_aclose_explicitly() -> None: + # asyncio apps drive this from lifespan hooks rather than "async with" + async def main() -> None: + group = execnet.aio.AsyncGroup() + await group.start() + try: + gateway = await group.makegateway("popen") + channel = await gateway.remote_exec("channel.send(7)") + assert await channel.receive() == 7 + finally: + await group.aclose() + await group.aclose() # idempotent + with pytest.raises(RuntimeError, match="not started"): + await group.makegateway("popen") + + run(main()) + + +def test_groups_share_the_default_engine() -> None: + async def main() -> None: + async with execnet.aio.AsyncGroup() as a, execnet.aio.AsyncGroup() as b: + assert a.engine is b.engine + assert a.engine is default_engine() + + run(main()) + + +def test_an_item_taken_as_the_cancel_lands_comes_back( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A cancelled receive never costs an item; see testing/test_bridge.py. + + The window is forced rather than raced for: the cancel is queued ahead + of the engine's delivery on a FIFO loop, so the receiving task is + cancelled with the item already produced and one callback away. + """ + from execnet import _bridge + + async def main() -> None: + async with execnet.aio.open_gateway() as gateway: + channel = await gateway.remote_exec( + "channel.receive()\nfor i in range(3): channel.send(i)" + ) + await channel.send("go") + + loop = asyncio.get_running_loop() + holder: list[Any] = [] + real = _bridge.AsyncioCarrier.resolve + + def hooked(self: object, result: object, error: object) -> None: + loop.call_soon_threadsafe(holder[0].cancel) + real(self, result, error) # type: ignore[arg-type] + + monkeypatch.setattr(_bridge.AsyncioCarrier, "resolve", hooked) + holder.append(asyncio.ensure_future(channel.receive())) + with pytest.raises(asyncio.CancelledError): + await holder[0] + monkeypatch.undo() + + assert [await channel.receive() for _ in range(3)] == [0, 1, 2] + + run(main()) diff --git a/testing/test_async_vocabulary.py b/testing/test_async_vocabulary.py new file mode 100644 index 00000000..6c20e699 --- /dev/null +++ b/testing/test_async_vocabulary.py @@ -0,0 +1,572 @@ +"""The vocabulary the protocol core is written against, on both backends. + +Every test runs twice, once per async library, because the whole point of +the module is that the core cannot tell which one it got. Where the two +genuinely differ -- cancellation -- the difference is asserted rather than +smoothed over, so it stays visible. +""" + +from __future__ import annotations + +import sys +from collections.abc import Callable +from typing import Any + +import pytest + +from execnet._async import MIN_ASYNCIO_PYTHON +from execnet._async import AsyncioAsync +from execnet._async import AsyncLibUnavailable +from execnet._async import TrioAsync +from execnet._async import current_async +from execnet._async import for_backend + +BACKENDS = ["trio"] + (["asyncio"] if sys.version_info >= MIN_ASYNCIO_PYTHON else []) + + +def run(backend: str, async_fn: Callable[..., Any], *args: Any) -> Any: + """Run ``async_fn`` on the named backend and return its result.""" + if backend == "trio": + import trio + + return trio.run(async_fn, *args) + import asyncio + + return asyncio.run(async_fn(*args)) + + +@pytest.fixture(params=BACKENDS) +def backend(request: pytest.FixtureRequest) -> str: + return str(request.param) + + +class TestDetection: + def test_the_running_loop_decides(self, backend: str) -> None: + async def main() -> str: + return str(current_async().name) + + assert run(backend, main) == backend + + def test_outside_a_loop_there_is_nothing_to_detect(self) -> None: + with pytest.raises(RuntimeError, match="no running event loop"): + current_async() + + def test_each_backend_is_built_once(self) -> None: + assert for_backend("trio") is for_backend("trio") + + def test_an_unknown_backend_is_refused(self) -> None: + with pytest.raises(ValueError, match="unknown async backend"): + for_backend("curio") + + +class TestPrimitives: + def test_an_event_wakes_a_waiter(self, backend: str) -> None: + async def main() -> str: + aio = current_async() + event = aio.event() + seen = [] + + async def waiter() -> None: + await event.wait() + seen.append("woken") + + async with aio.task_scope() as scope: + scope.start_soon(waiter) + await aio.checkpoint() + event.set() + return seen[0] + + assert run(backend, main) == "woken" + + def test_a_queue_carries_items_in_order(self, backend: str) -> None: + async def main() -> list[int]: + aio = current_async() + sender, receiver = aio.queue() + for index in range(3): + sender.send_nowait(index) + return [await receiver.receive() for _ in range(3)] + + assert run(backend, main) == [0, 1, 2] + + def test_a_closed_queue_ends_and_stays_ended(self, backend: str) -> None: + async def main() -> str: + aio = current_async() + sender, receiver = aio.queue() + sender.send_nowait("last") + sender.close() + assert await receiver.receive() == "last" + for _ in range(2): # every later receive sees the end too + try: + await receiver.receive() + except aio.CHANNEL_EMPTY: + continue + return "did not end" + return "ended" + + assert run(backend, main) == "ended" + + def test_sending_to_a_closed_queue_is_refused(self, backend: str) -> None: + async def main() -> str: + aio = current_async() + sender, _ = aio.queue() + sender.close() + try: + sender.send_nowait("nope") + except aio.CHANNEL_UNUSABLE: + return "refused" + return "accepted" + + assert run(backend, main) == "refused" + + def test_receive_nowait_reports_an_empty_queue(self, backend: str) -> None: + async def main() -> str: + aio = current_async() + _, receiver = aio.queue() + try: + receiver.receive_nowait() + except aio.CHANNEL_UNUSABLE: + return "empty" + return "got something" + + assert run(backend, main) == "empty" + + def test_a_limiter_bounds_concurrency(self, backend: str) -> None: + async def main() -> int: + aio = current_async() + limiter = aio.limiter(2) + peak = 0 + live = 0 + + async def hold() -> None: + nonlocal peak, live + async with limiter: + live += 1 + peak = max(peak, live) + await aio.checkpoint() + live -= 1 + + async with aio.task_scope() as scope: + for _ in range(6): + scope.start_soon(hold) + return peak + + assert run(backend, main) <= 2 + + def test_a_thread_hop_returns_its_value(self, backend: str) -> None: + async def main() -> int: + aio = current_async() + return int(await aio.to_thread(lambda value: value * 2, 21)) + + assert run(backend, main) == 42 + + +class TestTaskScope: + def test_it_waits_for_its_children(self, backend: str) -> None: + async def main() -> list[str]: + aio = current_async() + done: list[str] = [] + + async def child(name: str) -> None: + await aio.checkpoint() + done.append(name) + + async with aio.task_scope() as scope: + scope.start_soon(child, "a") + scope.start_soon(child, "b") + return sorted(done) + + assert run(backend, main) == ["a", "b"] + + def test_start_waits_until_the_child_is_ready(self, backend: str) -> None: + async def main() -> Any: + aio = current_async() + + async def server(task_status: Any) -> None: + task_status.started("serving") + await aio.sleep_forever() + + async with aio.task_scope() as scope: + value = await scope.start(server) + scope.cancel() + return value + + assert run(backend, main) == "serving" + + def test_a_failure_before_ready_reaches_the_starter(self, backend: str) -> None: + async def main() -> str: + aio = current_async() + + async def broken(task_status: Any) -> None: + raise ValueError("never ready") + + async with aio.task_scope() as scope: + try: + await scope.start(broken) + except ValueError as exc: + return str(exc) + return "no error" + + assert run(backend, main) == "never ready" + + def test_cancel_ends_the_children(self, backend: str) -> None: + async def main() -> str: + aio = current_async() + + async def forever() -> None: + await aio.sleep_forever() + + async with aio.task_scope() as scope: + scope.start_soon(forever) + scope.start_soon(forever) + scope.cancel() + return "returned" + + assert run(backend, main) == "returned" + + +class TestDeadlines: + def test_move_on_after_gives_up_quietly(self, backend: str) -> None: + async def main() -> str: + aio = current_async() + with aio.move_on_after(0.01) as scope: + await aio.sleep_forever() + assert scope.cancelled_caught + return "moved on" + + assert run(backend, main) == "moved on" + + def test_move_on_after_does_not_fire_when_the_body_finishes( + self, backend: str + ) -> None: + async def main() -> str: + aio = current_async() + with aio.move_on_after(10) as scope: + await aio.checkpoint() + assert not scope.cancelled_caught + return "finished" + + assert run(backend, main) == "finished" + + def test_fail_after_raises(self, backend: str) -> None: + async def main() -> str: + aio = current_async() + try: + with aio.fail_after(0.01): + await aio.sleep_forever() + except aio.TooSlow: + return "raised" + return "did not raise" + + assert run(backend, main) == "raised" + + def test_a_deadline_does_not_eat_an_outer_cancel(self, backend: str) -> None: + # the delicate one: an outer cancellation arriving while an inner + # deadline is armed must still reach the outer scope + async def main() -> str: + aio = current_async() + reached: list[str] = [] + + async def child() -> None: + try: + with aio.move_on_after(30): + await aio.sleep_forever() + reached.append("deadline swallowed the outer cancel") + except aio.Cancelled: + reached.append("outer cancel got through") + raise + + async with aio.task_scope() as scope: + scope.start_soon(child) + await aio.checkpoint() + scope.cancel() + return reached[0] + + assert run(backend, main) == "outer cancel got through" + + +class TestShielding: + """Where the two backends genuinely differ, and why it does not matter.""" + + def test_shielded_cleanup_completes_after_a_cancel(self, backend: str) -> None: + async def main() -> str: + aio = current_async() + done: list[str] = [] + + async def child() -> None: + try: + await aio.sleep_forever() + except aio.Cancelled: + with aio.shielded(): + await aio.checkpoint() + done.append("cleanup ran") + raise + + async with aio.task_scope() as scope: + scope.start_soon(child) + await aio.checkpoint() + scope.cancel() + return done[0] + + assert run(backend, main) == "cleanup ran" + + def test_bounded_shielded_cleanup_completes(self, backend: str) -> None: + # the combination the core actually uses: shielded, but not forever + async def main() -> str: + aio = current_async() + done: list[str] = [] + + async def child() -> None: + try: + await aio.sleep_forever() + except aio.Cancelled: + with aio.shielded(), aio.move_on_after(5): + await aio.checkpoint() + done.append("bounded cleanup ran") + raise + + async with aio.task_scope() as scope: + scope.start_soon(child) + await aio.checkpoint() + scope.cancel() + return done[0] + + assert run(backend, main) == "bounded cleanup ran" + + def test_the_bound_still_fires_on_a_stuck_cleanup(self, backend: str) -> None: + async def main() -> str: + aio = current_async() + done: list[str] = [] + + async def child() -> None: + try: + await aio.sleep_forever() + except aio.Cancelled: + with aio.shielded(), aio.move_on_after(0.01): + await aio.sleep_forever() + done.append("gave up on the cleanup") + raise + + async with aio.task_scope() as scope: + scope.start_soon(child) + await aio.checkpoint() + scope.cancel() + return done[0] + + assert run(backend, main) == "gave up on the cleanup" + + +class TestAvailability: + def test_asyncio_is_refused_without_taskgroup( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr(sys, "version_info", (3, 10, 12)) + with pytest.raises(AsyncLibUnavailable, match=r"3\.11 or newer"): + AsyncioAsync() + + def test_trio_is_always_available(self) -> None: + assert TrioAsync().name == "trio" + + +@pytest.mark.skipif( + sys.version_info < MIN_ASYNCIO_PYTHON, reason="asyncio backend needs 3.11" +) +class TestAsyncioByteStreams: + """The other implementation of the core's four-method stream. + + Trio's own stream types satisfy ``ByteStream`` structurally; these are + what asyncio needs wrapped, because it hands out a reader and a writer + rather than one object. + """ + + def test_a_socket_pair_round_trips_and_half_closes(self) -> None: + import asyncio + import socket + + from execnet._aio_io import wrap_socket + + async def main() -> tuple[bytes, bytes]: + left_sock, right_sock = socket.socketpair() + left = await wrap_socket(left_sock) + right = await wrap_socket(right_sock) + try: + await left.send_all(b"hello") + payload = await right.receive_some(64) + await left.send_eof() + # a half-closed send side reads as EOF, not as an error + after_eof = await right.receive_some(64) + return payload, after_eof + finally: + await left.aclose() + await right.aclose() + + assert asyncio.run(main()) == (b"hello", b"") + + def test_a_process_stdio_pair_is_one_stream(self) -> None: + import asyncio + + from execnet._aio_io import PIPE + from execnet._aio_io import open_process + from execnet._aio_io import staple_process + + async def main() -> tuple[bytes, int]: + process = await open_process( + [ + sys.executable, + "-c", + "import sys; sys.stdout.write(sys.stdin.read().upper())", + ], + stdin=PIPE, + stdout=PIPE, + ) + stream = staple_process(process) + await stream.send_all(b"world") + await stream.send_eof() + return await stream.receive_some(64), await process.wait() + + assert asyncio.run(main()) == (b"WORLD", 0) + + def test_a_broken_stream_speaks_the_core_vocabulary(self) -> None: + import asyncio + import socket + + from execnet._aio_io import wrap_socket + from execnet._async import BrokenResource + from execnet._async import ClosedResource + + async def main() -> str: + left_sock, right_sock = socket.socketpair() + left = await wrap_socket(left_sock) + right = await wrap_socket(right_sock) + await right.aclose() + try: + for _ in range(50): # the reset takes a write or two to surface + await left.send_all(b"x" * 4096) + except BrokenResource: + return "broken" + except ClosedResource: # pragma: no cover - platform dependent + return "broken" + finally: + await left.aclose() + return "no error" + + assert asyncio.run(main()) == "broken" + + +class TestIOVocabulary: + """The IO half, same names on both backends.""" + + def test_a_process_round_trips_through_its_stdio(self, backend: str) -> None: + async def main() -> bytes: + aio = current_async() + import subprocess + + process = await aio.open_process( + [ + sys.executable, + "-c", + "import sys; sys.stdout.write(sys.stdin.read().upper())", + ], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + ) + stream = aio.staple_process(process) + await stream.send_all(b"ping") + await stream.send_eof() + payload = await stream.receive_some(64) + await process.wait() + return bytes(payload) + + assert run(backend, main) == b"PING" + + def test_a_socket_pair_round_trips(self, backend: str) -> None: + async def main() -> bytes: + aio = current_async() + import socket + + left_sock, right_sock = socket.socketpair() + left = await aio.wrap_socket(left_sock) + right = await aio.wrap_socket(right_sock) + try: + await left.send_all(b"pong") + return bytes(await right.receive_some(64)) + finally: + await left.aclose() + await right.aclose() + + assert run(backend, main) == b"pong" + + def test_a_tcp_listener_accepts(self, backend: str) -> None: + async def main() -> bytes: + aio = current_async() + listeners = await aio.open_tcp_listeners(0, "localhost") + port = listeners[0].socket.getsockname()[1] + got: list[bytes] = [] + + async def client() -> None: + stream = await aio.open_tcp_stream("localhost", port) + await stream.send_all(b"dialed") + await stream.aclose() + + async with aio.task_scope() as scope: + scope.start_soon(client) + accepted = await listeners[0].accept() + got.append(await accepted.receive_some(64)) + await accepted.aclose() + for listener in listeners: + await listener.aclose() + return got[0] + + assert run(backend, main) == b"dialed" + + @pytest.mark.skipif(sys.platform == "win32", reason="AF_UNIX") + def test_a_unix_listener_accepts(self, backend: str) -> None: + async def main() -> bytes: + aio = current_async() + import os + import tempfile + + directory = tempfile.mkdtemp(prefix="execnet-vocab-") + path = os.path.join(directory, "gw.sock") + listener = await aio.unix_listener(path) + got: list[bytes] = [] + + async def client() -> None: + import socket + + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + sock.connect(path) + stream = await aio.wrap_socket(sock) + await stream.send_all(b"dialed back") + await stream.aclose() + + async with aio.task_scope() as scope: + scope.start_soon(client) + accepted = await listener.accept() + got.append(await accepted.receive_some(64)) + await accepted.aclose() + await listener.aclose() + return got[0] + + assert run(backend, main) == b"dialed back" + + @pytest.mark.skipif(sys.platform == "win32", reason="POSIX fds") + def test_an_fd_pair_is_one_stream(self, backend: str) -> None: + async def main() -> bytes: + aio = current_async() + import os + + read_fd, write_fd = os.pipe() + stream = await aio.staple_fds(read_fd, write_fd) + try: + await stream.send_all(b"through a pipe") + return bytes(await stream.receive_some(64)) + finally: + await stream.aclose() + + assert run(backend, main) == b"through a pipe" + + def test_the_thread_budget_is_a_number(self, backend: str) -> None: + async def main() -> int: + return int(current_async().thread_budget()) + + assert run(backend, main) > 0 diff --git a/testing/test_basics.py b/testing/test_basics.py index 1756ec34..3e2499e4 100644 --- a/testing/test_basics.py +++ b/testing/test_basics.py @@ -15,13 +15,16 @@ import pytest import execnet -from execnet import gateway -from execnet import gateway_base -from execnet import gateway_io -from execnet.gateway_base import ChannelFactory -from execnet.gateway_base import ExecModel -from execnet.gateway_base import Message -from execnet.gateway_base import Popen2IO +from execnet import _boundary +from execnet import _errors +from execnet import _exec_source +from execnet import _gateway_base +from execnet import _message +from execnet import _serialize +from execnet._channel import ChannelFactory +from execnet._execmodel import ExecModel +from execnet._message import Message +from execnet._serialize import SendPayload skip_win_pypy = pytest.mark.xfail( condition=hasattr(sys, "pypy_version_info") and sys.platform.startswith("win"), @@ -29,37 +32,49 @@ ) +# The standalone serializer is an internal detail (execnet._serialize), +# not part of the public API -- see the docs, "Sending objects over a channel". @pytest.mark.parametrize("val", ["123", 42, [1, 2, 3], ["23", 25]]) class TestSerializeAPI: - def test_serializer_api(self, val: object) -> None: - dumped = execnet.dumps(val) - val2 = execnet.loads(dumped) + def test_serializer_api(self, val: SendPayload) -> None: + dumped = _serialize.dumps(val) + val2 = _serialize.loads(dumped) assert val == val2 - def test_mmap(self, tmp_path: Path, val: object) -> None: + def test_mmap(self, tmp_path: Path, val: SendPayload) -> None: mmap = pytest.importorskip("mmap").mmap p = tmp_path / "data.bin" - p.write_bytes(execnet.dumps(val)) + p.write_bytes(_serialize.dumps(val)) with p.open("r+b") as f: m = mmap(f.fileno(), 0) - val2 = execnet.load(m) + val2 = _serialize.load(m) assert val == val2 def test_bytesio(self, val: object) -> None: f = BytesIO() - execnet.dump(f, val) + _serialize.dump(f, val) read = BytesIO(f.getvalue()) - val2 = execnet.load(read) + val2 = _serialize.load(read) assert val == val2 +def test_serializer_not_public() -> None: + # dumps/loads/dump/load stay internal to execnet._serialize. ``dumps`` + # is still *reachable* as a temporary pytest-xdist shim, but it is not + # part of the surface -- see test_namespaces.py. + for name in ("loads", "dump", "load"): + assert not hasattr(execnet, name), name + for name in ("dumps", "loads", "dump", "load"): + assert name not in execnet.__all__, name + + def test_serializer_api_version_error(monkeypatch: pytest.MonkeyPatch) -> None: - bchr = gateway_base.bchr - monkeypatch.setattr(gateway_base, "DUMPFORMAT_VERSION", bchr(1)) - dumped = execnet.dumps(42) - monkeypatch.setattr(gateway_base, "DUMPFORMAT_VERSION", bchr(2)) - pytest.raises(execnet.DataFormatError, lambda: execnet.loads(dumped)) + bchr = _serialize.bchr + monkeypatch.setattr(_serialize, "DUMPFORMAT_VERSION", bchr(1)) + dumped = _serialize.dumps(42) + monkeypatch.setattr(_serialize, "DUMPFORMAT_VERSION", bchr(2)) + pytest.raises(execnet.DataFormatError, lambda: _serialize.loads(dumped)) def test_errors_on_execnet() -> None: @@ -68,81 +83,48 @@ def test_errors_on_execnet() -> None: assert hasattr(execnet, "DataFormatError") -def test_subprocess_interaction(anypython: str) -> None: - line = gateway_io.popen_bootstrapline - compile(line, "xyz", "exec") - args = [str(anypython), "-c", line] - popen = subprocess.Popen( - args, - bufsize=0, - universal_newlines=True, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - ) - - assert popen.stdin is not None - assert popen.stdout is not None +def standalone_protocol_source() -> str: + """The wire-protocol core as a self-contained script. - def send(line: str) -> None: - assert popen.stdin is not None - popen.stdin.write(line) - popen.stdin.flush() + ``_errors``, ``_message`` and ``_serialize`` are the layers a peer needs + to frame and serialize; between them the only runtime execnet imports are + of each other, so concatenating their sources -- dropping those imports + and keeping a single file-leading future import -- yields a script that + runs on any interpreter, which is what these checks verify. The + ``if TYPE_CHECKING:`` imports are left in place; they never execute. + """ + lines = ["from __future__ import annotations\n"] + for module in (_errors, _message, _serialize): + for line in inspect.getsource(module).splitlines(keepends=True): + if line.startswith(("from __future__ import annotations", "from ._")): + continue + lines.append(line) + return "".join(lines) - def receive() -> str: - assert popen.stdout is not None - return popen.stdout.readline() - try: - source = inspect.getsource(read_write_loop) + "read_write_loop()" - send(repr(source) + "\n") - s = receive() - assert s == "ok\n" - send("hello\n") - s = receive() - assert s == "received: hello\n" - send("world\n") - s = receive() - assert s == "received: world\n" - send("\n") # terminate loop - finally: - popen.stdin.close() - popen.stdout.close() - popen.wait() +IO_MESSAGE_EXTRA_SOURCE = """ +from io import BytesIO +class BufIO: + def __init__(self): + self.buf = BytesIO() -def read_write_loop() -> None: - sys.stdout.write("ok\n") - sys.stdout.flush() - while 1: - try: - line = sys.stdin.readline() - if not line.strip(): - break - sys.stdout.write("received: %s" % line) - sys.stdout.flush() - except (OSError, EOFError): - break + def write(self, data): + self.buf.write(data) + def read(self, numbytes): + data = self.buf.read(numbytes) + if len(data) < numbytes: + raise EOFError("expected %d bytes" % numbytes) + return data -IO_MESSAGE_EXTRA_SOURCE = """ -import sys -backend = sys.argv[1] -from io import BytesIO -import tempfile -temp_out = BytesIO() -temp_in = BytesIO() -io = Popen2IO(temp_out, temp_in, get_execmodel(backend)) for i, handler in enumerate(Message._types): print ("checking", i, handler) for data in "hello", "hello".encode('ascii'): + io = BufIO() msg1 = Message(i, i, dumps(data)) msg1.to_io(io) - x = io.outfile.getvalue() - io.outfile.truncate(0) - io.outfile.seek(0) - io.infile.seek(0) - io.infile.write(x) - io.infile.seek(0) + io.buf.seek(0) msg2 = Message.from_io(io) assert msg1.channelid == msg2.channelid, (msg1, msg2) assert msg1.data == msg2.data, (msg1.data, msg2.data) @@ -162,11 +144,16 @@ def run_check( ) -> subprocess.CompletedProcess[str]: self.idx += 1 check_path = self.path / f"check{self.idx}.py" - check_path.write_text(script) + # utf-8 explicitly: source is utf-8 by default (PEP 3120), so writing + # it in the locale encoding produces a file the interpreter cannot + # read back wherever that is not utf-8 -- Windows, where a single + # em-dash in the concatenated source was enough to break it. + check_path.write_text(script, encoding="utf-8") return subprocess.run( [self.python, os.fspath(check_path), *extra_args], capture_output=True, text=True, + encoding="utf-8", check=True, **process_args, ) @@ -177,63 +164,15 @@ def checker(anypython: str, tmp_path: Path) -> Checker: return Checker(python=anypython, path=tmp_path) -def test_io_message(checker: Checker, execmodel: ExecModel) -> None: - out = checker.run_check( - inspect.getsource(gateway_base) + IO_MESSAGE_EXTRA_SOURCE, execmodel.backend - ) - print(out.stdout) - assert "all passed" in out.stdout - - -def test_popen_io(checker: Checker, execmodel: ExecModel) -> None: - out = checker.run_check( - inspect.getsource(gateway_base) - + f""" -io = init_popen_io(get_execmodel({execmodel.backend!r})) -io.write(b"hello") -s = io.read(1) -assert s == b"x" -""", - input="x", - ) - print(out.stderr) - assert "hello" in out.stdout - - -def test_popen_io_readloop(execmodel: ExecModel) -> None: - sio = BytesIO(b"test") - io = Popen2IO(sio, sio, execmodel) - real_read = io._read - - def newread(numbytes: int) -> bytes: - if numbytes > 1: - numbytes = numbytes - 1 - return real_read(numbytes) # type: ignore[no-any-return] - - io._read = newread - result = io.read(3) - assert result == b"tes" - - -def test_rinfo_source(checker: Checker) -> None: - out = checker.run_check( - f""" -class Channel: - def send(self, data): - assert eval(repr(data), {{}}) == data -channel = Channel() -{inspect.getsource(gateway.rinfo_source)} -print ('all passed') -""" - ) - +def test_io_message(checker: Checker) -> None: + out = checker.run_check(standalone_protocol_source() + IO_MESSAGE_EXTRA_SOURCE) print(out.stdout) assert "all passed" in out.stdout def test_geterrortext(checker: Checker) -> None: out = checker.run_check( - inspect.getsource(gateway_base) + standalone_protocol_source() + """ class Arg(Exception): pass @@ -252,19 +191,20 @@ class Arg(Exception): @pytest.mark.skipif("not hasattr(os, 'dup')") -def test_stdouterrin_setnull( - execmodel: ExecModel, capfd: pytest.CaptureFixture[str] -) -> None: - # Backup and restore stdin state, and rely on capfd to handle - # this for stdout and stderr. +def test_stdio_disposition_devnull(capfd: pytest.CaptureFixture[str]) -> None: + # apply_stdio(devnull) points fd 0/1 at the null device: writes and + # reads on them must go nowhere. Back up and restore the real fds. + from execnet import _trio_worker + orig_stdin = sys.stdin - orig_stdin_fd = os.dup(0) + orig_stdout = sys.stdout + orig_fd0 = os.dup(0) + orig_fd1 = os.dup(1) try: - # The returned Popen2IO instance can be garbage collected - # prematurely since we don't hold a reference here, but we - # tolerate this because it is intended to leave behind a - # sane state afterwards. - gateway_base.init_popen_io(execmodel) + read_fd, write_fd = _trio_worker._dup_protocol_fds() + os.close(read_fd) + os.close(write_fd) + _trio_worker.apply_stdio(stdin="devnull", stdout="devnull") os.write(1, b"hello") os.read(0, 1) out, err = capfd.readouterr() @@ -272,8 +212,31 @@ def test_stdouterrin_setnull( assert not err finally: sys.stdin = orig_stdin - os.dup2(orig_stdin_fd, 0) - os.close(orig_stdin_fd) + sys.stdout = orig_stdout + os.dup2(orig_fd0, 0) + os.dup2(orig_fd1, 1) + os.close(orig_fd0) + os.close(orig_fd1) + + +@pytest.mark.skipif("not hasattr(os, 'dup')") +def test_stdio_disposition_stdout_to_stderr(capfd: pytest.CaptureFixture[str]) -> None: + # The stdio transport's default: remote output lands on stderr rather + # than the null device, so it stays visible without touching the wire. + from execnet import _trio_worker + + orig_stdout = sys.stdout + orig_fd1 = os.dup(1) + try: + _trio_worker.apply_stdio(stdout="stderr") + os.write(1, b"to-stderr") + out, err = capfd.readouterr() + assert not out + assert "to-stderr" in err + finally: + sys.stdout = orig_stdout + os.dup2(orig_fd1, 1) + os.close(orig_fd1) class PseudoChannel: @@ -296,7 +259,7 @@ def close(self, errortext: str | None = None) -> None: def test_exectask(execmodel: ExecModel) -> None: io = BytesIO() io.execmodel = execmodel # type: ignore[attr-defined] - gw = gateway_base.WorkerGateway(io, id="something") # type: ignore[arg-type] + gw = _gateway_base.WorkerGateway(io, id="something") # type: ignore[arg-type] ch = PseudoChannel() gw.executetask((ch, ("raise ValueError()", None, {}))) # type: ignore[arg-type] assert "ValueError" in str(ch._closed[0]) @@ -318,16 +281,112 @@ def test_wire_protocol(self) -> None: assert isinstance(repr(msg), str) +class TestFrameDecoder: + def _messages(self) -> list[Message]: + return [ + Message(Message.CHANNEL_DATA, 1, b"x" * 20), + Message(Message.STATUS, 42, b""), + Message(Message.CHANNEL_DATA, 7, b"y"), + ] + + def test_single_feed_yields_all(self) -> None: + decoder = _message.FrameDecoder() + blob = b"".join(m.pack() for m in self._messages()) + got = list(decoder.feed(blob)) + assert [(m.msgcode, m.channelid, m.data) for m in got] == [ + (m.msgcode, m.channelid, m.data) for m in self._messages() + ] + decoder.close() + + @pytest.mark.parametrize("chunksize", [1, 2, 3, 8, 9, 10, 13]) + def test_adversarial_chunk_splits(self, chunksize: int) -> None: + decoder = _message.FrameDecoder() + blob = b"".join(m.pack() for m in self._messages()) + got: list[Message] = [] + for start in range(0, len(blob), chunksize): + got.extend(decoder.feed(blob[start : start + chunksize])) + assert [(m.msgcode, m.channelid, m.data) for m in got] == [ + (m.msgcode, m.channelid, m.data) for m in self._messages() + ] + decoder.close() + + def test_close_mid_frame_raises(self) -> None: + decoder = _message.FrameDecoder() + blob = Message(Message.CHANNEL_DATA, 1, b"hello").pack() + assert list(decoder.feed(blob[:-2])) == [] + with pytest.raises(EOFError, match="mid-frame"): + decoder.close() + + def test_feed_buffers_even_when_not_iterated(self) -> None: + decoder = _message.FrameDecoder() + blob = Message(Message.CHANNEL_DATA, 5, b"data").pack() + decoder.feed(blob[:4]) # result deliberately not iterated + (msg,) = decoder.feed(blob[4:]) + assert (msg.msgcode, msg.channelid, msg.data) == ( + Message.CHANNEL_DATA, + 5, + b"data", + ) + + def test_memory_stream_roundtrip(self) -> None: + """Protocol-level: frames sent over a trio memory stream pair arrive + intact through the receive_some + FrameDecoder loop.""" + import trio + import trio.testing + + messages = self._messages() + + async def main() -> list[Message]: + ours, theirs = trio.testing.memory_stream_pair() + received: list[Message] = [] + + async def sender() -> None: + for m in messages: + await theirs.send_all(m.pack()) + await theirs.send_eof() + + async def receiver() -> None: + decoder = _message.FrameDecoder() + while True: + data = await ours.receive_some(4096) + if not data: + decoder.close() + break + received.extend(decoder.feed(data)) + + async with trio.open_nursery() as nursery: + nursery.start_soon(sender) + nursery.start_soon(receiver) + return received + + received = trio.run(main) + assert [(m.msgcode, m.channelid, m.data) for m in received] == [ + (m.msgcode, m.channelid, m.data) for m in messages + ] + + class TestPureChannel: @pytest.fixture def fac(self, execmodel: ExecModel) -> ChannelFactory: class FakeGateway: + _trio_session = None + _new_wakener = staticmethod(_boundary.ThreadWakener) + + def _check_usable(self, what: str) -> None: + pass + def _trace(self, *args) -> None: pass def _send(self, *k) -> None: pass + def _bind_channel(self, channel) -> None: + pass + + def _release_channel(self, id) -> None: + pass + FakeGateway.execmodel = execmodel # type: ignore[attr-defined] return ChannelFactory(FakeGateway()) # type: ignore[arg-type] @@ -355,13 +414,13 @@ def test_channel_makefile_incompatmode(self, fac) -> None: class TestSourceOfFunction: def test_lambda_unsupported(self) -> None: - pytest.raises(ValueError, gateway._source_of_function, lambda: 1) + pytest.raises(ValueError, _exec_source._source_of_function, lambda: 1) def test_wrong_prototype_fails(self) -> None: def prototype(wrong) -> None: pass - pytest.raises(ValueError, gateway._source_of_function, prototype) + pytest.raises(ValueError, _exec_source._source_of_function, prototype) def test_function_without_known_source_fails(self) -> None: # this one won't be able to find the source @@ -369,7 +428,7 @@ def test_function_without_known_source_fails(self) -> None: exec("def fail(channel): pass", mess, mess) print(inspect.getsourcefile(mess["fail"])) with pytest.raises(ValueError): - gateway._source_of_function(mess["fail"]) + _exec_source._source_of_function(mess["fail"]) def test_function_with_closure_fails(self) -> None: mess: dict[str, Any] = {} @@ -378,13 +437,13 @@ def closure(channel: object) -> None: print(mess) with pytest.raises(ValueError): - gateway._source_of_function(closure) + _exec_source._source_of_function(closure) def test_source_of_nested_function(self) -> None: def working(channel: object) -> None: pass - send_source = gateway._source_of_function(working).lstrip("\r\n") + send_source = _exec_source._source_of_function(working).lstrip("\r\n") expected = "def working(channel: object) -> None:\n pass\n" assert send_source == expected @@ -393,7 +452,7 @@ class TestGlobalFinder: def check(self, func) -> list[str]: src = textwrap.dedent(inspect.getsource(func)) code = func.__code__ - return gateway._find_non_builtin_globals(src, code) + return _exec_source._find_non_builtin_globals(src, code) def test_local(self) -> None: def f(a, b, c): @@ -420,7 +479,7 @@ def test_function_with_global_fails(self) -> None: def func(channel) -> None: sys - pytest.raises(ValueError, gateway._source_of_function, func) + pytest.raises(ValueError, _exec_source._source_of_function, func) def test_method_call(self) -> None: # method names are reason @@ -433,7 +492,7 @@ def f(channel): @skip_win_pypy def test_remote_exec_function_with_kwargs( - anypython: str, makegateway: Callable[[str], gateway.Gateway] + anypython: str, makegateway: Callable[[str], execnet.Gateway] ) -> None: def func(channel, data) -> None: channel.send(data) @@ -446,17 +505,17 @@ def func(channel, data) -> None: assert result == 1 -def test_remote_exc__no_kwargs(makegateway: Callable[[], gateway.Gateway]) -> None: +def test_remote_exc__no_kwargs(makegateway: Callable[[], execnet.Gateway]) -> None: gw = makegateway() with pytest.raises(TypeError): - gw.remote_exec(gateway_base, kwarg=1) + gw.remote_exec(_message, kwarg=1) with pytest.raises(TypeError): gw.remote_exec("pass", kwarg=1) @skip_win_pypy def test_remote_exec_inspect_stack( - makegateway: Callable[[], gateway.Gateway], + makegateway: Callable[[], execnet.Gateway], ) -> None: gw = makegateway() ch = gw.remote_exec( diff --git a/testing/test_boundary.py b/testing/test_boundary.py new file mode 100644 index 00000000..ce2a5302 --- /dev/null +++ b/testing/test_boundary.py @@ -0,0 +1,82 @@ +"""The boundary kit: Mailbox and OneShot on the thread wakener.""" + +from __future__ import annotations + +import queue +import threading + +import pytest + +from execnet import TimeoutError as ExecnetTimeoutError +from execnet._boundary import Mailbox +from execnet._boundary import OneShot + + +class TestMailbox: + def test_put_get_fifo(self) -> None: + box: Mailbox[int] = Mailbox() + for n in range(3): + box.put(n) + assert [box.get(), box.get(), box.get()] == [0, 1, 2] + + def test_get_nowait_empty(self) -> None: + box: Mailbox[int] = Mailbox() + with pytest.raises(queue.Empty): + box.get_nowait() + + def test_get_timeout(self) -> None: + # the carriers speak execnet's TimeoutError, so Channel.receive + # does not have to translate the builtin one + box: Mailbox[int] = Mailbox() + with pytest.raises(ExecnetTimeoutError): + box.get(timeout=0.01) + + def test_get_blocks_until_put_from_other_thread(self) -> None: + box: Mailbox[str] = Mailbox() + threading.Timer(0.05, box.put, args=["item"]).start() + assert box.get(timeout=5.0) == "item" + + def test_get_after_stale_wakeup(self) -> None: + # A consumed notify leaves the wakener set; the drain pattern must + # still block (and then receive) rather than spin or miss items. + box: Mailbox[int] = Mailbox() + box.put(1) + assert box.get() == 1 + threading.Timer(0.05, box.put, args=[2]).start() + assert box.get(timeout=5.0) == 2 + + +class TestOneShot: + def test_set_then_wait(self) -> None: + shot: OneShot[int] = OneShot() + shot.set(42) + assert shot.is_set() + assert shot.wait() == 42 + # a resolved OneShot stays readable + assert shot.wait(timeout=0.01) == 42 + + def test_wait_timeout(self) -> None: + shot: OneShot[int] = OneShot() + with pytest.raises(ExecnetTimeoutError): + shot.wait(timeout=0.01) + assert not shot.is_set() + + def test_set_error_reraises(self) -> None: + shot: OneShot[None] = OneShot() + shot.set_error(RuntimeError("boom")) + with pytest.raises(RuntimeError, match="boom"): + shot.wait() + + def test_wait_blocks_until_set_from_other_thread(self) -> None: + shot: OneShot[str] = OneShot() + threading.Timer(0.05, shot.set, args=["done"]).start() + assert shot.wait(timeout=5.0) == "done" + + def test_single_resolution_enforced(self) -> None: + # a real error, not an assert: it must survive python -O + shot: OneShot[int] = OneShot() + shot.set(1) + with pytest.raises(RuntimeError, match="already resolved"): + shot.set(2) + with pytest.raises(RuntimeError, match="already resolved"): + shot.set_error(RuntimeError("boom")) diff --git a/testing/test_bridge.py b/testing/test_bridge.py new file mode 100644 index 00000000..e740adb8 --- /dev/null +++ b/testing/test_bridge.py @@ -0,0 +1,149 @@ +"""The carriers: one result crossing from the engine to a caller's loop. + +The interesting behaviour is what happens when the caller is cancelled and +the engine produced something anyway. Those two events race by +construction, and both orderings are reachable in the wild but neither is +reachable *reliably* by sleeping -- a timing test here passed for a year +without the salvage path ever running. So each ordering is built +explicitly, by driving the carrier directly. + +The rule both carriers implement: a value nobody is left to take is handed +to the call's ``salvage`` rather than dropped; an *error* nobody is left to +take is dropped, because it describes the operation the caller abandoned +and the next call will raise its own. +""" + +from __future__ import annotations + +import asyncio +from typing import Any + +import pytest +import trio + +from execnet._bridge import AsyncioCarrier +from execnet._bridge import TrioCarrier + + +class TestTrioCarrier: + def test_a_result_arriving_after_the_cancel_is_salvaged(self) -> None: + salvaged: list[Any] = [] + + async def main() -> None: + carrier = TrioCarrier() + carrier.set_salvage(salvaged.append) + with trio.move_on_after(0.01): + await carrier.wait(shield=False, on_cancel=lambda: None) + # the engine finishes late and delivers to a caller that is gone + carrier.resolve(42, None) + await trio.sleep(0.01) # let the entry-queue callback run + + trio.run(main) + assert salvaged == [42] + + def test_a_result_already_here_when_the_cancel_lands_is_salvaged(self) -> None: + # trio delivers the cancel at the checkpoint even though the event is + # already set, so the value is sitting in the carrier unclaimed + salvaged: list[Any] = [] + + async def main() -> None: + carrier = TrioCarrier() + carrier.set_salvage(salvaged.append) + carrier.resolve(7, None) + await trio.sleep(0) # the delivery lands first + with trio.CancelScope() as scope: + scope.cancel() + await carrier.wait(shield=False, on_cancel=lambda: None) + + trio.run(main) + assert salvaged == [7] + + def test_an_abandoned_error_is_not_salvaged(self) -> None: + salvaged: list[Any] = [] + + async def main() -> None: + carrier = TrioCarrier() + carrier.set_salvage(salvaged.append) + with trio.move_on_after(0.01): + await carrier.wait(shield=False, on_cancel=lambda: None) + carrier.resolve(None, EOFError("gone")) + await trio.sleep(0.01) + + trio.run(main) + assert salvaged == [] + + def test_nothing_is_salvaged_when_the_caller_takes_it(self) -> None: + salvaged: list[Any] = [] + + async def main() -> None: + carrier = TrioCarrier() + carrier.set_salvage(salvaged.append) + carrier.resolve(1, None) + assert await carrier.wait(shield=False, on_cancel=lambda: None) == 1 + + trio.run(main) + assert salvaged == [] + + def test_a_call_with_no_salvage_still_works(self) -> None: + async def main() -> None: + carrier = TrioCarrier() + with trio.move_on_after(0.01): + await carrier.wait(shield=False, on_cancel=lambda: None) + carrier.resolve(5, None) + await trio.sleep(0.01) + + trio.run(main) # must not raise + + +class TestAsyncioCarrier: + def test_a_result_arriving_after_the_cancel_is_salvaged(self) -> None: + salvaged: list[Any] = [] + + async def main() -> None: + carrier = AsyncioCarrier() + carrier.set_salvage(salvaged.append) + task = asyncio.ensure_future( + carrier.wait(shield=False, on_cancel=lambda: None) + ) + await asyncio.sleep(0) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + # the engine finishes late and delivers to a caller that is gone + carrier.resolve(42, None) + await asyncio.sleep(0.01) + + asyncio.run(main()) + assert salvaged == [42] + + def test_an_abandoned_error_is_not_salvaged(self) -> None: + salvaged: list[Any] = [] + + async def main() -> None: + carrier = AsyncioCarrier() + carrier.set_salvage(salvaged.append) + task = asyncio.ensure_future( + carrier.wait(shield=False, on_cancel=lambda: None) + ) + await asyncio.sleep(0) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + carrier.resolve(None, EOFError("gone")) + await asyncio.sleep(0.01) + + asyncio.run(main()) + assert salvaged == [] + + def test_nothing_is_salvaged_when_the_caller_takes_it(self) -> None: + salvaged: list[Any] = [] + + async def main() -> None: + carrier = AsyncioCarrier() + carrier.set_salvage(salvaged.append) + carrier.resolve(1, None) + await asyncio.sleep(0.01) + assert await carrier.wait(shield=False, on_cancel=lambda: None) == 1 + + asyncio.run(main()) + assert salvaged == [] diff --git a/testing/test_channel.py b/testing/test_channel.py index d4712277..aeedb2be 100644 --- a/testing/test_channel.py +++ b/testing/test_channel.py @@ -4,12 +4,14 @@ from __future__ import annotations +import queue import time import pytest -from execnet.gateway import Gateway -from execnet.gateway_base import Channel +from execnet import ExecnetStateError +from execnet import Gateway +from execnet._channel import Channel needs_early_gc = pytest.mark.skipif("not hasattr(sys, 'getrefcount')") needs_osdup = pytest.mark.skipif("not hasattr(os, 'dup')") @@ -166,7 +168,7 @@ def test_channel_receiver_callback(self, gw: Gateway) -> None: """ ) channel.setcallback(callback=l.append) - pytest.raises(IOError, channel.receive) + pytest.raises(ExecnetStateError, channel.receive) channel.waitclose(TESTTIMEOUT) assert len(l) == 3 assert l[:2] == [42, 13] @@ -184,7 +186,7 @@ def test_channel_callback_after_receive(self, gw: Gateway) -> None: x = channel.receive() assert x == 42 channel.setcallback(callback=l.append) - pytest.raises(IOError, channel.receive) + pytest.raises(ExecnetStateError, channel.receive) channel.waitclose(TESTTIMEOUT) assert len(l) == 2 assert l[0] == 13 @@ -214,8 +216,6 @@ def test_channel_callback_stays_active(self, gw: Gateway) -> None: def check_channel_callback_stays_active( self, gw: Gateway, earlyfree: bool = True ) -> Channel | None: - if gw.spec.execmodel == "gevent": - pytest.xfail("investigate gevent failure") # with 'earlyfree==True', this tests the "sendonly" channel state. l: list[int] = [] channel = gw.remote_exec( @@ -264,7 +264,7 @@ def test_channel_endmarker_callback(self, gw: Gateway) -> None: """ ) channel.setcallback(l.append, 999) - pytest.raises(IOError, channel.receive) + pytest.raises(ExecnetStateError, channel.receive) channel.waitclose(TESTTIMEOUT) assert len(l) == 4 assert l[:2] == [42, 13] @@ -272,14 +272,14 @@ def test_channel_endmarker_callback(self, gw: Gateway) -> None: assert l[3] == 999 def test_channel_endmarker_callback_error(self, gw: Gateway) -> None: - q = gw.execmodel.queue.Queue() + q: queue.Queue[object] = queue.Queue() channel = gw.remote_exec( source=""" raise ValueError() """ ) channel.setcallback(q.put, endmarker=999) - val = q.get(TESTTIMEOUT) + val = q.get(timeout=TESTTIMEOUT) assert val == 999 err = channel._getremoteerror() assert err @@ -306,6 +306,50 @@ def f(item): channel.send(1) channel.waitclose() + @needs_early_gc + def test_callback_channel_collected_after_close(self, gw: Gateway) -> None: + # A callback channel is kept alive only by its consumer task: while it + # is consuming it survives with no user reference, and once the stream + # closes and the callback has run the object becomes collectable. + import gc + import weakref + + received: list[int] = [] + channel = gw.remote_exec("channel.send(1); channel.send(2)") + channel.setcallback(received.append) + ref = weakref.ref(channel) + del channel # only the consumer task holds it now + + deadline = time.time() + TESTTIMEOUT + while ref() is not None and time.time() < deadline: + gc.collect() + time.sleep(0.05) + assert received == [1, 2] + assert ref() is None # consumer finished -> no strong refs -> reclaimed + + def test_callbacks_run_off_the_loop_thread(self, gw: Gateway) -> None: + # A slow callback on one channel must not block delivery to another: + # callbacks run in threadpool threads, not inline on the loop. + import threading + + release = threading.Event() + fast_ran = threading.Event() + + def slow(item: object) -> None: + release.wait(TESTTIMEOUT) + + chan_slow = gw.remote_exec("channel.send(1); channel.receive()") + chan_fast = gw.remote_exec("channel.send(1)") + chan_slow.setcallback(slow) + chan_fast.setcallback(lambda item: fast_ran.set()) + + # the fast callback fires even while the slow one is still blocking + assert fast_ran.wait(TESTTIMEOUT) + release.set() + chan_slow.send(0) # let the remote finish and close + chan_slow.waitclose(TESTTIMEOUT) + chan_fast.waitclose(TESTTIMEOUT) + class TestChannelFile: def test_channel_file_write(self, gw: Gateway) -> None: diff --git a/testing/test_channel_stress.py b/testing/test_channel_stress.py new file mode 100644 index 00000000..6074d5c2 --- /dev/null +++ b/testing/test_channel_stress.py @@ -0,0 +1,122 @@ +"""Hypothesis stress tests for the channel callback (consumer-task) machinery. + +These hammer ``setcallback`` -- the loop-task-plus-threadpool consumer -- with +randomised traffic to check the invariants that must hold no matter the timing: + +* every item reaches the callback, exactly once and in send order; +* many callback channels run concurrently without cross-talk; +* switching to a callback after some ``receive()`` calls loses nothing; +* the endmarker is always delivered last; +* a callback channel with no user reference is kept alive by its consumer. + +Use ``--stress=N`` to raise the number of examples per test (default: a quick +profile registered in ``conftest.pytest_configure``). +""" + +from __future__ import annotations + +import gc +import weakref + +import pytest + +hypothesis = pytest.importorskip("hypothesis") +from hypothesis import given # noqa: E402 +from hypothesis import strategies as st # noqa: E402 + +from execnet import Gateway # noqa: E402 +from execnet._serialize import SendPayload # noqa: E402 + +# High --stress levels replay one test many times; lift the per-test timeout +# well above the default so that only a real hang (bounded by TESTTIMEOUT on +# every blocking call below) fails, not sheer example count. +pytestmark = pytest.mark.timeout(600) + +TESTTIMEOUT = 10.0 + +# Varied payloads: unbounded ints exercise both the short and long serializer +# paths (and their boundaries), on top of the callback machinery itself. +payload_strategy = st.one_of( + st.integers(), + st.text(max_size=20), + st.booleans(), + st.none(), +) +items_strategy = st.lists(payload_strategy, max_size=40) + + +def _echo(channel, items): + """Remote: send every item, in order, then let the channel close.""" + for item in items: + channel.send(item) + + +class TestCallbackStress: + # popen only: fast to spawn and the callback path is transport-independent + gwtype = "popen" + + @given(data=items_strategy) + def test_callback_receives_all_in_order(self, gw: Gateway, data: list[int]) -> None: + collected: list[int] = [] + channel = gw.remote_exec(_echo, items=data) + channel.setcallback(collected.append) + channel.waitclose(TESTTIMEOUT) + assert collected == data + + @given(batches=st.lists(items_strategy, min_size=1, max_size=6)) + def test_many_channels_stay_ordered_and_isolated( + self, gw: Gateway, batches: list[list[int]] + ) -> None: + results: list[list[int]] = [[] for _ in batches] + channels = [] + for index, items in enumerate(batches): + channel = gw.remote_exec(_echo, items=items) + channel.setcallback(results[index].append) + channels.append(channel) + for channel in channels: + channel.waitclose(TESTTIMEOUT) + assert results == batches + + @given(data=st.data()) + def test_receive_then_switch_loses_nothing( + self, gw: Gateway, data: st.DataObject + ) -> None: + items = data.draw(items_strategy) + split = data.draw(st.integers(min_value=0, max_value=len(items))) + channel = gw.remote_exec(_echo, items=items) + first = [channel.receive(TESTTIMEOUT) for _ in range(split)] + rest: list[int] = [] + channel.setcallback(rest.append) + channel.waitclose(TESTTIMEOUT) + assert first + rest == items + + @given(data=items_strategy) + def test_endmarker_is_always_last(self, gw: Gateway, data: list[int]) -> None: + endmarker = object() + collected: list[object] = [] + channel = gw.remote_exec(_echo, items=data) + channel.setcallback(collected.append, endmarker=endmarker) + channel.waitclose(TESTTIMEOUT) + assert collected == [*data, endmarker] + + @pytest.mark.skipif( + "not hasattr(sys, 'getrefcount')", reason="needs refcount GC semantics" + ) + @given(data=st.lists(payload_strategy, min_size=1, max_size=40)) + def test_callback_channel_kept_alive_then_collected( + self, gw: Gateway, data: list[SendPayload] + ) -> None: + collected: list[int] = [] + channel = gw.remote_exec(_echo, items=data) + channel.setcallback(collected.append) + ref = weakref.ref(channel) + del channel # only the consumer task holds it now + + import time + + deadline = time.time() + TESTTIMEOUT + while ref() is not None and time.time() < deadline: + gc.collect() + time.sleep(0.02) + assert collected == data + assert ref() is None # consumer finished -> reclaimed diff --git a/testing/test_cli.py b/testing/test_cli.py new file mode 100644 index 00000000..d4bd5909 --- /dev/null +++ b/testing/test_cli.py @@ -0,0 +1,914 @@ +"""The ``execnet`` command line, and the transports it exposes. + +``execnet worker`` is the launch contract between a coordinator and the +process it starts. These tests drive it the way a coordinator does -- +including the transports that keep the protocol off the worker's stdio. +""" + +from __future__ import annotations + +import json +import os +import shutil +import socket +import subprocess +import sys +import tempfile +from typing import Any +from typing import cast + +import pytest + +import execnet +from execnet import _cli +from execnet import _provision +from execnet._message import Message + +posix_only = pytest.mark.skipif( + sys.platform.startswith("win"), reason="needs POSIX fd passing / unix sockets" +) + +TESTTIMEOUT = 30.0 + + +def worker_config(**overrides: object) -> dict[str, object]: + config: dict[str, object] = { + "id": "cli-test-worker", + "profile": "thread", + "execmodel": "thread", + "wait": "thread", + "coordinator_version": execnet.__version__, + } + config.update(overrides) + return config + + +def config_frame(**overrides: object) -> bytes: + """What a coordinator sends first, on every transport.""" + return Message( + Message.GATEWAY_CONFIG, 0, json.dumps(worker_config(**overrides)).encode() + ).pack() + + +def recv_exactly(sock: socket.socket, count: int) -> bytes: + chunks = [] + while count: + chunk = sock.recv(count) + if not chunk: + raise EOFError(f"closed with {count} bytes to go") + chunks.append(chunk) + count -= len(chunk) + return b"".join(chunks) + + +def read_reply(sock: socket.socket) -> dict[str, Any]: + """The worker's answer to the config frame: serving, or refusing and why.""" + msgcode, _channel, length = Message.from_header(recv_exactly(sock, 9)) + assert msgcode == Message.GATEWAY_CONFIG + reply: dict[str, Any] = json.loads(recv_exactly(sock, length)) + return reply + + +def handshake(sock: socket.socket, **overrides: object) -> dict[str, Any]: + """Drive the whole worker handshake the way a coordinator does.""" + sock.sendall(config_frame(**overrides)) + return read_reply(sock) + + +class TestInfo: + def test_info_reports_what_a_coordinator_needs(self) -> None: + out = subprocess.run( + [sys.executable, "-m", "execnet", "info"], + capture_output=True, + text=True, + check=True, + ) + info = json.loads(out.stdout) + assert info["execnet"] == execnet.__version__ + assert info["trio"] is not None + assert info["executable"] + assert "stdio" in info["protocols"] + + def test_info_matches_the_in_process_view(self) -> None: + assert _cli.interpreter_info()["execnet"] == execnet.__version__ + + def test_probe_uses_info(self) -> None: + _provision.target_info.cache_clear() + info = _provision.target_info(sys.executable) + assert info is not None + assert info["execnet"] == execnet.__version__ + assert _provision.target_has_execnet(sys.executable) + + def test_probe_rejects_an_interpreter_without_execnet(self, tmp_path) -> None: + # a python that cannot import execnet fails the probe, which is what + # sends it down the uv-provisioning path + _provision.target_info.cache_clear() + fake = tmp_path / "fake-python" + fake.write_text("#!/bin/sh\nexit 1\n") + fake.chmod(0o755) + assert _provision.target_info(str(fake)) is None + assert not _provision.target_has_execnet(str(fake)) + + +class TestVersionSkew: + """A worker refuses a coordinator it cannot speak the protocol with. + + The protocol is unversioned, so a major/minor skew has no defined + behaviour. The refusal answers the config frame, so the reason travels + back to whoever asked for the gateway instead of only to a stderr that + may be pointed anywhere. + """ + + def test_the_same_version_is_fine(self) -> None: + from execnet import _trio_worker + + assert _trio_worker._version_refusal(execnet.__version__) is None + + def test_a_patch_level_difference_is_tolerated(self) -> None: + from execnet import _trio_worker + + major, minor = _trio_worker._rough_version(execnet.__version__) + assert _trio_worker._version_refusal(f"{major}.{minor}.999") is None + + def test_an_unparsable_version_is_not_second_guessed(self) -> None: + from execnet import _trio_worker + + assert _trio_worker._version_refusal("some-vendored-build") is None + + def test_a_minor_difference_is_refused(self) -> None: + from execnet import _trio_worker + + major, minor = _trio_worker._rough_version(execnet.__version__) + refusal = _trio_worker._version_refusal(f"{major}.{minor + 1}.0") + assert refusal is not None + assert "version mismatch" in refusal + assert _trio_worker.IGNORE_VERSION_SKEW in refusal + + def test_the_env_override_downgrades_it_to_a_warning( + self, monkeypatch: pytest.MonkeyPatch, capfd + ) -> None: + from execnet import _trio_worker + + major, minor = _trio_worker._rough_version(execnet.__version__) + monkeypatch.setenv(_trio_worker.IGNORE_VERSION_SKEW, "1") + assert _trio_worker._version_refusal(f"{major}.{minor + 1}.0") is None + assert "version mismatch" in capfd.readouterr()[1] + + def test_the_override_also_comes_from_the_config_env(self, capfd) -> None: + # config env: values are not applied until _apply_worker_setup, which + # runs after the check, so the check has to read them itself + from execnet import _trio_worker + + major, minor = _trio_worker._rough_version(execnet.__version__) + assert ( + _trio_worker._version_refusal( + f"{major}.{minor + 1}.0", {_trio_worker.IGNORE_VERSION_SKEW: "1"} + ) + is None + ) + assert "version mismatch" in capfd.readouterr()[1] + + @posix_only + def test_a_skewed_worker_says_so_on_the_wire(self) -> None: + from execnet import _trio_worker + + # derived, never spelled out: a literal "impossible" version is only + # impossible until an environment has it. A wheel built from a + # checkout without tags is 0.1.dev1, which is what CI installs -- so + # a hardcoded 0.1.2 matched there, the worker started, and this + # blocked on its output until the test timed out. + major, _ = _trio_worker._rough_version(execnet.__version__) + skewed = f"{major + 1}.0.0" + ours, theirs = socket.socketpair() + proc = subprocess.Popen( + [ + sys.executable, + "-m", + "execnet", + "worker", + "--protocol-fd", + str(theirs.fileno()), + ], + pass_fds=(theirs.fileno(),), + stderr=subprocess.PIPE, + text=True, + ) + theirs.close() + try: + reply = handshake(ours, coordinator_version=skewed) + assert reply["ok"] is False + assert "version mismatch" in reply["error"] + assert _trio_worker.IGNORE_VERSION_SKEW in reply["error"] + finally: + ours.close() + assert proc.wait(timeout=TESTTIMEOUT) != 0 + assert "version mismatch" in (proc.stderr.read() if proc.stderr else "") + + +class TestArgumentGrammar: + def test_protocol_flags_are_mutually_exclusive(self) -> None: + with pytest.raises(SystemExit): + _cli._build_parser().parse_args( + ["worker", "--protocol-fd", "3", "--protocol-connect", "unix:/x"] + ) + + def test_there_is_no_way_to_put_a_config_in_argv(self) -> None: + # the worker config arrives as a frame; --config-fd is left only for + # the Windows share blob, which describes the stream itself + for flag in ("--config", "--config-file"): + with pytest.raises(SystemExit): + _cli._build_parser().parse_args(["worker", flag, "{}"]) + + @pytest.mark.parametrize(("value", "expected"), [("3", (3,)), ("4,5", (4, 5))]) + def test_protocol_fd_accepts_one_fd_or_a_pair( + self, value: str, expected: tuple[int, ...] + ) -> None: + ns = _cli._build_parser().parse_args(["worker", "--protocol-fd", value]) + assert ns.protocol_fd == expected + + def test_protocol_fd_rejects_nonsense(self) -> None: + with pytest.raises(SystemExit): + _cli._build_parser().parse_args(["worker", "--protocol-fd", "a,b,c"]) + + @pytest.mark.parametrize( + ("address", "expected"), + [ + ("unix:/tmp/x.sock", ("unix", "/tmp/x.sock")), + ("localhost:8888", ("tcp", ("localhost", 8888))), + (":8888", ("tcp", ("localhost", 8888))), + ], + ) + def test_parse_address(self, address: str, expected: tuple[str, Any]) -> None: + from execnet._trio_worker import parse_address + + assert parse_address(address) == expected + + def test_parse_address_rejects_a_bare_path(self) -> None: + from execnet._trio_worker import parse_address + + with pytest.raises(ValueError, match="unix:/path or host:port"): + parse_address("/tmp/x.sock") + + +@posix_only +class TestProtocolFd: + """A worker serving over an inherited socket, driven by hand.""" + + def test_socketpair_roundtrip(self) -> None: + ours, theirs = socket.socketpair() + proc = subprocess.Popen( + [ + sys.executable, + "-m", + "execnet", + "worker", + "--protocol-fd", + str(theirs.fileno()), + ], + pass_fds=(theirs.fileno(),), + ) + theirs.close() + try: + reply = handshake(ours) + assert reply["ok"] is True + assert reply["pid"] == proc.pid + assert reply["profile"] == "thread" + finally: + ours.close() + proc.terminate() + proc.wait(timeout=TESTTIMEOUT) + + def test_a_plain_pipe_fd_is_rejected(self) -> None: + # one fd has to be bidirectional; a pipe end needs the read,write form + read_fd, write_fd = os.pipe() + try: + out = subprocess.run( + [ + sys.executable, + "-m", + "execnet", + "worker", + "--protocol-fd", + str(read_fd), + ], + pass_fds=(read_fd,), + capture_output=True, + text=True, + timeout=TESTTIMEOUT, + check=False, + ) + finally: + os.close(read_fd) + os.close(write_fd) + assert out.returncode != 0 + assert "not a socket" in out.stderr + + +class TestConfigDelivery: + """The config is a frame on the protocol stream, and nothing else. + + It carries ``env:`` values, so argv is the one place it must never be: + ``/proc`` is world-readable on the local machine exactly as ``ps`` is + on a remote one. + """ + + def test_a_worker_needs_no_argv_beyond_its_transport(self) -> None: + spec = execnet.XSpec("popen//env:SECRET=hunter2//chdir=/tmp") + spec.id = "gw0" + from execnet._trio_gateway import popen_worker_argv + + argv = popen_worker_argv(spec, "--protocol-fd", "7") + assert argv[-2:] == ["--protocol-fd", "7"] + assert not any("hunter2" in token for token in argv) + assert not any("chdir" in token for token in argv) + + @posix_only + def test_the_config_frame_configures_the_worker(self, tmp_path) -> None: + ours, theirs = socket.socketpair() + proc = subprocess.Popen( + [ + sys.executable, + "-m", + "execnet", + "worker", + "--protocol-fd", + str(theirs.fileno()), + ], + pass_fds=(theirs.fileno(),), + ) + theirs.close() + try: + reply = handshake(ours, id="configured", chdir=str(tmp_path)) + assert reply["ok"] is True + finally: + ours.close() + proc.terminate() + proc.wait(timeout=TESTTIMEOUT) + + def test_config_fd_carries_only_the_share_blob(self, tmp_path) -> None: + path = tmp_path / "local.json" + path.write_text(json.dumps({"protocol_share": "abc"})) + with path.open() as stream: + ns = _cli._build_parser().parse_args( + ["worker", "--protocol-share", "--config-fd", str(stream.fileno())] + ) + assert _cli._load_local_config(ns) == {"protocol_share": "abc"} + + def test_no_config_fd_means_no_local_config(self) -> None: + ns = _cli._build_parser().parse_args(["worker"]) + assert _cli._load_local_config(ns) == {} + + +class TestTransportSelection: + def test_a_spawned_worker_defaults_to_the_socket_transport(self) -> None: + # every platform now: POSIX hands over the fd, Windows duplicates the + # socket with share(). Only a host that can do neither gets stdio. + spec = execnet.XSpec("popen") + expected = "socket" if _provision.socket_handoff_available() else "stdio" + assert ( + _provision.resolve_transport( + spec, available=_provision.socket_handoff_available() + ) + == expected + ) + assert expected == "socket" + + def test_explicit_wins(self) -> None: + assert _provision.resolve_transport( + execnet.XSpec("popen//transport=stdio") + ) == ("stdio") + + def test_unknown_is_rejected(self) -> None: + with pytest.raises(ValueError, match="unknown transport"): + _provision.resolve_transport( + execnet.XSpec("popen//transport=carrier-pigeon") + ) + + def test_unavailable_falls_back_when_unasked(self) -> None: + assert ( + _provision.resolve_transport(execnet.XSpec("popen"), available=False) + == "stdio" + ) + + def test_asking_for_an_impossible_transport_is_an_error(self) -> None: + # the alternative is a gateway that hangs waiting for a worker that + # was never able to reach us -- which is what ssh on Windows did + with pytest.raises(ValueError, match="not available"): + _provision.resolve_transport( + execnet.XSpec("ssh=host//transport=socket"), available=False + ) + + def test_windows_hands_a_socket_over_by_duplicating_it(self) -> None: + # `subprocess` refuses pass_fds there, so the capability comes from + # socket.share() instead -- including on PyPy, once the socket is + # handed over as a socket rather than rebuilt from its handle + if _provision.socket_share_required(): + assert _provision.socket_handoff_available() + + def test_ssh_cannot_dial_back_on_windows(self) -> None: + # no AF_UNIX in CPython there, and Win32-OpenSSH cannot -R a unix socket + assert _provision.ssh_dialback_available() == ( + not _provision.socket_share_required() + ) + + def test_socket_transport_roundtrip(self) -> None: + # explicitly, on every platform: this is the only coverage the + # Windows socket.share() handoff gets + group = execnet.Group() + try: + gateway = group.makegateway("popen//transport=socket") + channel = gateway.remote_exec("channel.send(6 * 7)") + assert channel.receive(TESTTIMEOUT) == 42 + finally: + group.terminate(timeout=5.0) + + def test_socket_transport_keeps_the_protocol_off_stdio(self) -> None: + # whichever handoff this platform has, the point is the same: the + # protocol is named on the command line, so it is not fd 0/1 + expected = ( + "--protocol-share" + if _provision.socket_share_required() + else "--protocol-fd" + ) + group = execnet.Group() + try: + gateway = group.makegateway("popen") + channel = gateway.remote_exec( + "import sys; channel.send(sys.argv)", + ) + argv = cast("list[str]", channel.receive(TESTTIMEOUT)) + assert expected in argv + finally: + group.terminate(timeout=5.0) + + def test_stdio_transport_still_works(self) -> None: + group = execnet.Group() + try: + gateway = group.makegateway("popen//transport=stdio") + channel = gateway.remote_exec("channel.send(6 * 7)") + assert channel.receive(TESTTIMEOUT) == 42 + finally: + group.terminate(timeout=5.0) + + +class TestWorkerStdio: + """Whose stdio is it? The code the worker runs, unless told otherwise.""" + + def test_socket_transport_inherits_stdio(self, capfd) -> None: + group = execnet.Group() + try: + gateway = group.makegateway("popen") + gateway.remote_exec("print('INHERITED-STDOUT')").waitclose(TESTTIMEOUT) + finally: + group.terminate(timeout=5.0) + out, _ = capfd.readouterr() + assert "INHERITED-STDOUT" in out + + def test_stdio_transport_folds_stdout_onto_stderr(self, capfd) -> None: + # the protocol owns fd 1 here, so remote output cannot go there -- + # but it lands on stderr rather than being discarded + group = execnet.Group() + try: + gateway = group.makegateway("popen//transport=stdio") + gateway.remote_exec("print('FOLDED-ONTO-STDERR')").waitclose(TESTTIMEOUT) + finally: + group.terminate(timeout=5.0) + out, err = capfd.readouterr() + assert "FOLDED-ONTO-STDERR" not in out + assert "FOLDED-ONTO-STDERR" in err + + @posix_only + def test_stdin_can_be_sent_to_devnull(self) -> None: + group = execnet.Group() + try: + gateway = group.makegateway("popen//stdin=devnull") + channel = gateway.remote_exec( + "import os; channel.send(os.readlink('/proc/self/fd/0'))" + ) + assert channel.receive(TESTTIMEOUT) == "/dev/null" + finally: + group.terminate(timeout=5.0) + + @posix_only + def test_stdout_can_be_silenced(self, capfd) -> None: + group = execnet.Group() + try: + gateway = group.makegateway("popen//stdout=devnull") + gateway.remote_exec("print('SILENCED')").waitclose(TESTTIMEOUT) + finally: + group.terminate(timeout=5.0) + out, err = capfd.readouterr() + assert "SILENCED" not in out + assert "SILENCED" not in err + + +@posix_only +class TestListenTransport: + def test_worker_listens_and_reports_its_address(self) -> None: + directory = tempfile.mkdtemp(prefix="execnet-cli-test-") + path = os.path.join(directory, "gw.sock") + proc = subprocess.Popen( + [ + sys.executable, + "-m", + "execnet", + "worker", + "--protocol-listen", + f"unix:{path}", + ], + stdout=subprocess.PIPE, + ) + try: + assert proc.stdout is not None + announced = json.loads(proc.stdout.readline()) + assert announced == {"listening": path} + client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + client.settimeout(TESTTIMEOUT) + client.connect(path) + try: + # a worker that listens is configured like any other: the + # coordinator that reaches it sends the config frame + assert handshake(client)["ok"] is True + finally: + client.close() + finally: + proc.terminate() + proc.wait(timeout=TESTTIMEOUT) + shutil.rmtree(directory, ignore_errors=True) + + +class TestServerCommand: + def test_server_is_the_socketserver(self) -> None: + parser = _cli._build_parser() + ns = parser.parse_args(["server", "127.0.0.1:0", "--once"]) + assert ns.hostport == "127.0.0.1:0" + assert ns.once is True + + def test_socketserver_alias_warns(self, monkeypatch: pytest.MonkeyPatch) -> None: + called: list[list[str]] = [] + monkeypatch.setattr(_cli, "main", called.append) + with pytest.warns(DeprecationWarning, match="execnet server"): + _cli.socketserver_main([":0", "--once"]) + assert called == [["server", ":0", "--once"]] + + +needs_provisioning = pytest.mark.skipif( + not _provision.provisioning_available(), + reason="a dev execnet installed without its source tree cannot build a" + " wheel to provision a remote with", +) + + +class TestRemoteCommand: + """What ends up on the remote host's command line.""" + + pytestmark = needs_provisioning + + def test_no_config_reaches_the_remote_argv(self) -> None: + # env: values are secrets often enough; the remote argv is readable + # by every user on that host via ps. There is no config here at all + # any more -- it arrives as a frame on the connection. + spec = execnet.XSpec("ssh=host//id=gw0//env:TOKEN=s3cr3t//chdir=/srv") + spec.profile = "thread" + command = _provision.ssh_remote_command( + spec, "--protocol-connect", "unix:/tmp/x.sock" + ) + assert "s3cr3t" not in command + assert "chdir" not in command + assert "--config" not in command + assert command.endswith("--protocol-connect unix:/tmp/x.sock") + + def test_launch_command_frames_nothing_in_band(self) -> None: + spec = execnet.XSpec("ssh=host//id=gw0") + spec.profile = "thread" + command = _provision.ssh_remote_command(spec) + # the wheel travels on its own connection now + assert "head -c" not in command + assert "mktemp -d" not in command + + def test_dialback_argv_forwards_a_unix_socket(self) -> None: + from execnet import _trio_gateway + + spec = execnet.XSpec("ssh=host//id=gw0") + spec.profile = "thread" + argv = _trio_gateway._ssh_argv( + spec, "worker-cmd", forward=("/tmp/remote.sock", "/tmp/local.sock") + ) + assert "-R" in argv + assert argv[argv.index("-R") + 1] == "/tmp/remote.sock:/tmp/local.sock" + # a stale remote socket must not block the bind + assert "StreamLocalBindUnlink=yes" in argv + + +class TestSocketWorkerSpawnFailure: + """A server that cannot start a worker must not leave a coordinator waiting. + + The coordinator connects and blocks for the handshake reply. Nothing + else will ever move it, so a failed spawn has to close the connection -- + otherwise one unsupported gateway wedges the whole session, which is what + ``socket//installvia=`` did on Windows. + """ + + def test_a_failed_spawn_closes_the_connection( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + import trio + + from execnet import _trio_gateway + from execnet import _trio_host + + def boom(sock: Any) -> Any: + raise RuntimeError("no worker for you") + + monkeypatch.setattr(_trio_host, "_spawn_socket_worker", boom) + + async def main() -> None: + ours, theirs = socket.socketpair() + server = trio.SocketStream(trio.socket.from_stdlib_socket(theirs)) + client = trio.SocketStream(trio.socket.from_stdlib_socket(ours)) + async with client, server: + with pytest.raises(RuntimeError, match="no worker"): + await _trio_host.serve_socket_connection(server, reap=False) + # the coordinator's end: its handshake ends, and says the + # worker went away rather than surfacing a transport error + with trio.fail_after(5), pytest.raises(EOFError, match="went away"): + await _trio_gateway.configure_worker(client, None, "socket") + + trio.run(main) + + def test_a_host_that_cannot_hand_over_a_socket_refuses_up_front( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + # refusing before the address is replied is what makes it diagnosable: + # afterwards the coordinator is already connecting, and a closed + # socket can only ever say "EOF". + from execnet import _trio_host + + monkeypatch.setattr(_provision, "socket_handoff_available", lambda: False) + sent: list[tuple[int, int, bytes]] = [] + + class FakeGateway: + def _send(self, code: int, channelid: int = 0, data: bytes = b"") -> None: + sent.append((code, channelid, data)) + + import trio + + from execnet._message import Message + from execnet._serialize import loads_internal + + gateway: Any = FakeGateway() + trio.run(_trio_host._start_socket_and_reply, gateway, 7, "localhost") + + assert len(sent) == 1 + code, channelid, data = sent[0] + assert code == Message.CHANNEL_CLOSE_ERROR + assert channelid == 7 + assert "cannot hand an accepted socket" in cast("str", loads_internal(data)) + + +class TestSocketWorkerConfig: + """The server hands the connection over; it does not read what is on it. + + The worker it spawns inherits the accepted socket, so the coordinator's + config frame reaches that worker directly. Nothing about the gateway + is the server's to relay, filter, or put in an argv. + """ + + def test_the_server_passes_no_config_to_the_worker( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + from execnet import _trio_host + + recorded: list[list[str]] = [] + + class FakePopen: + pid = 4321 + stdin = None + + def __init__(self, args: list[str], **kwargs: Any) -> None: + recorded.append(args) + + monkeypatch.setattr(_provision, "socket_share_required", lambda: False) + monkeypatch.setattr(subprocess, "Popen", FakePopen) + + class FakeSocket: + def fileno(self) -> int: + return 9 + + _trio_host._spawn_socket_worker(FakeSocket()) + + (argv,) = recorded + assert argv[-2:] == ["--protocol-fd", "9"] + assert not any(token.startswith("--config") for token in argv) + + def test_a_coordinators_config_reaches_the_spawned_worker(self) -> None: + # end to end through a real server: chdir is a config key, and only + # the worker can prove it arrived + import trio + + from execnet import _trio_host + + async def main() -> dict[str, Any]: + ours, theirs = socket.socketpair() + server = trio.SocketStream(trio.socket.from_stdlib_socket(theirs)) + async with server: + await _trio_host.serve_socket_connection(server, reap=False) + ours.settimeout(TESTTIMEOUT) + try: + return handshake(ours, id="from-a-server") + finally: + ours.close() + + reply = trio.run(main) + assert reply["ok"] is True + assert reply["profile"] == "thread" + + +class TestShareTransport: + """The Windows socket handoff. Only ``adopt`` is testable off Windows.""" + + def test_adopt_decodes_the_blob_out_of_the_local_config(self) -> None: + import base64 + + from execnet import _trio_worker + from execnet._trio_gateway import SHARE_KEY + + transport = _trio_worker.ShareTransport() + # the local config holds the blob and nothing else: what the worker + # *is* comes from the frame that arrives on the shared socket + transport.adopt({SHARE_KEY: base64.b64encode(b"blobby").decode("ascii")}) + assert transport._blob == b"blobby" + + def test_adopt_without_a_blob_is_a_clear_error(self) -> None: + from execnet import _trio_worker + + transport = _trio_worker.ShareTransport() + with pytest.raises(SystemExit, match="protocol_share"): + transport.adopt({}) + + def test_the_cli_accepts_the_flag(self) -> None: + ns = _cli._build_parser().parse_args( + ["worker", "--protocol-share", "--config-fd", "0"] + ) + assert ns.protocol_share is True + + def test_share_is_exclusive_with_the_other_transports(self) -> None: + with pytest.raises(SystemExit): + _cli._build_parser().parse_args( + ["worker", "--protocol-share", "--protocol-stdio"] + ) + + +class TestShareHandoffWiring: + """How the share blob gets from coordinator to worker. + + ``WSADuplicateSocket`` itself only exists on Windows, but everything + around it -- the flag in argv, the blob on stdin -- is the part that can + be wired up wrong, and that is testable anywhere. + """ + + def test_popen_spawn_puts_the_flag_in_argv_and_the_blob_on_stdin( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + import base64 + + import trio + + from execnet import _trio_gateway + from execnet._trio_gateway import SHARE_KEY + + written: list[bytes] = [] + seen: dict[str, Any] = {} + + class FakeStdin: + async def send_all(self, data: bytes) -> None: + written.append(data) + + async def aclose(self) -> None: + pass + + class FakeProcess: + pid = 4321 + stdin = FakeStdin() + + async def fake_open_process(args: list[str], **kwargs: Any) -> Any: + seen["args"] = args + seen["kwargs"] = kwargs + return FakeProcess() + + monkeypatch.setattr(_provision, "socket_share_required", lambda: True) + monkeypatch.setattr(trio.lowlevel, "open_process", fake_open_process) + monkeypatch.setattr( + _trio_gateway, + "share_socket", + lambda sock, pid: base64.b64encode(b"dup-for-%d" % pid).decode("ascii"), + ) + + spec = execnet.XSpec("popen//id=gw0") + ours, theirs = socket.socketpair() + try: + trio.run(_trio_gateway._spawn_with_socket, spec, theirs) + finally: + ours.close() + theirs.close() + + args = seen["args"] + assert "--protocol-share" in args + # the blob is not built until we have a pid, which is only true once + # the process exists -- so it cannot be in argv even if we wanted it + assert "--config-fd" in args + + config = json.loads(b"".join(written)) + # the blob, and only the blob: everything else about this worker + # goes over the socket the blob describes + assert list(config) == [SHARE_KEY] + assert base64.b64decode(config[SHARE_KEY]) == b"dup-for-4321" + + def test_server_side_spawn_shares_the_accepted_socket( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + import base64 + + from execnet import _trio_gateway + from execnet import _trio_host + from execnet._trio_gateway import SHARE_KEY + + written: list[bytes] = [] + seen: dict[str, Any] = {} + + class FakeStdin: + def write(self, data: bytes) -> None: + written.append(data) + + def close(self) -> None: + pass + + class FakePopen: + pid = 99 + stdin = FakeStdin() + + def fake_popen(args: list[str], **kwargs: Any) -> Any: + seen["args"] = args + seen["kwargs"] = kwargs + return FakePopen() + + monkeypatch.setattr(_provision, "socket_share_required", lambda: True) + monkeypatch.setattr(subprocess, "Popen", fake_popen) + monkeypatch.setattr( + _trio_gateway, + "share_socket", + lambda sock, pid: base64.b64encode(b"accepted-%d" % pid).decode("ascii"), + ) + + ours, theirs = socket.socketpair() + try: + _trio_host._spawn_socket_worker(theirs) + # the socket we do not own must survive being viewed for share() + assert theirs.fileno() >= 0 + theirs.send(b"still open") + assert ours.recv(16) == b"still open" + finally: + ours.close() + theirs.close() + + assert "--protocol-share" in seen["args"] + assert "pass_fds" not in seen["kwargs"] + config = json.loads(b"".join(written)) + assert list(config) == [SHARE_KEY] + assert base64.b64decode(config[SHARE_KEY]) == b"accepted-99" + + +class TestViaConfigPrivacy: + """A relaying coordinator never sees the config it is relaying. + + ``via=`` asks one gateway to *spawn* another, so the intermediary + decides what to launch -- but not what it is. The sub's config comes + down the tunnel from the coordinator that wants the gateway, which is + the only side that has any business holding its ``env:`` values. + """ + + def test_the_spawn_request_carries_no_config(self) -> None: + spec = execnet.XSpec("popen//via=coord//id=gw1//env:TOKEN=s3cr3t//chdir=/srv") + request = _provision.spawn_request(spec) + assert "config" not in request + assert "s3cr3t" not in json.dumps(request) + # what provisioning genuinely cannot defer: which environment to build + assert request["profile"] == "thread" + + def test_the_sub_is_spawned_without_one(self) -> None: + argv, _delivery = _provision.sub_spawn_argv({"profile": "thread"}) + assert not any(token.startswith("--config") for token in argv) + + def test_env_reaches_a_sub_through_the_tunnel(self) -> None: + # end to end: the value never touches the intermediary's argv, and + # still arrives in the sub's environment + group = execnet.Group() + try: + group.makegateway("popen//id=coord") + sub = group.makegateway("popen//via=coord//env:TUNNELLED=yes") + channel = sub.remote_exec( + "import os; channel.send(os.environ['TUNNELLED'])" + ) + assert channel.receive(TESTTIMEOUT) == "yes" + finally: + group.terminate(timeout=TESTTIMEOUT) diff --git a/testing/test_compatibility_regressions.py b/testing/test_compatibility_regressions.py index 5343e7d5..ed06be4e 100644 --- a/testing/test_compatibility_regressions.py +++ b/testing/test_compatibility_regressions.py @@ -1,8 +1,8 @@ -from execnet import gateway_base +from execnet import _serialize def test_opcodes() -> None: - data = vars(gateway_base.opcode) + data = vars(_serialize.opcode) computed = {k: v for k, v in data.items() if "__" not in k} assert computed == { "BUILDTUPLE": b"@", @@ -18,13 +18,13 @@ def test_opcodes() -> None: "NEWDICT": b"J", "NEWLIST": b"K", "NONE": b"L", - "PY2STRING": b"M", - "PY3STRING": b"N", + # b"M" (py2 str) and b"S" (py2 unicode) are retired along with + # Python2 support -- the bytes stay unused rather than reassigned. + "STRING": b"N", "SET": b"O", "SETITEM": b"P", "STOP": b"Q", "TRUE": b"R", - "UNICODE": b"S", # added in 1.4 # causes a regression since it was ordered in # between CHANNEL and FALSE as "C" moving the other items diff --git a/testing/test_deploy.py b/testing/test_deploy.py new file mode 100644 index 00000000..9595184c --- /dev/null +++ b/testing/test_deploy.py @@ -0,0 +1,395 @@ +"""Deploying a project to a host before any worker runs against it. + +The order is the whole point: the process that runs the tests has to be +*inside* the environment the project was installed into, so provisioning +happens through a gateway of its own and the workers come afterwards. + +These run against a synthetic project over a popen gateway. That is not a +weaker test than a remote one would be -- the deployment never touches a +transport, it only drives rsync and ``GATEWAY_DEPLOY`` over whatever +gateway it is handed -- and it keeps the suite free of a network. +""" + +from __future__ import annotations + +import pathlib +import shutil +import subprocess +import sys +from collections.abc import Iterator +from typing import Any +from typing import cast + +import pytest + +import execnet +from execnet import _provision +from execnet._deploy import Deployment +from execnet._deploy._api import DEFAULT_WORKSPACE_ROOT + + +def _deploy_request( + gateway: execnet.Gateway, request: dict[str, Any] +) -> dict[str, Any]: + """One raw deploy-service round trip, for tests that need a step alone.""" + from execnet._deploy._facade import run_blocking + from execnet._deploy._run import SERVICE + + async def run(targets: Any) -> Any: + return await targets[0].request(SERVICE, request) + + reply: dict[str, Any] = run_blocking([gateway], run) + return reply + + +TESTTIMEOUT = 300.0 + +needs_uv = pytest.mark.skipif( + not _provision.uv_available(), reason="a deployment is built with uv" +) +needs_provisioning = pytest.mark.skipif( + not _provision.provisioning_available(), + reason="no execnet wheel to deploy into the environment", +) + +PYPROJECT = """\ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "deployed-demo" +version = "0.1.0" +requires-python = ">=3.10" +dependencies = [] +""" + + +@pytest.fixture(scope="module") +def project(tmp_path_factory: pytest.TempPathFactory) -> pathlib.Path: + """A locked, buildable project with tests that are not in its wheel.""" + root = tmp_path_factory.mktemp("project") + (root / "pyproject.toml").write_text(PYPROJECT) + package = root / "src" / "deployed_demo" + package.mkdir(parents=True) + (package / "__init__.py").write_text("VALUE = 42\n") + tests = root / "tests" + tests.mkdir() + (tests / "test_demo.py").write_text( + "from deployed_demo import VALUE\n\n\ndef test_value():\n assert VALUE == 42\n" + ) + (root / "conftest.py").write_text("# marker file, not part of the wheel\n") + subprocess.run(["uv", "lock"], cwd=root, check=True, capture_output=True) + return root + + +@pytest.fixture +def group() -> Iterator[execnet.Group]: + group = execnet.Group() + try: + yield group + finally: + group.terminate(timeout=30.0) + + +class TestDeploymentInputs: + def test_a_project_without_a_lockfile_is_refused(self, tmp_path) -> None: + # a deployment installs a *frozen* environment; without the lockfile + # there is nothing frozen to install, and resolving on the remote + # would silently deploy something else + (tmp_path / "pyproject.toml").write_text(PYPROJECT) + with pytest.raises(ValueError, match=r"uv\.lock"): + Deployment(tmp_path) + + def test_a_directory_without_a_project_is_refused(self, tmp_path) -> None: + with pytest.raises(ValueError, match=r"pyproject\.toml"): + Deployment(tmp_path) + + def test_a_missing_root_is_refused(self, project) -> None: + with pytest.raises(ValueError, match="no such root"): + Deployment(project, roots=[project / "nope"]) + + def test_the_workspace_name_defaults_to_the_project(self, project) -> None: + assert Deployment(project).name == project.name + + +@needs_uv +@needs_provisioning +class TestDeploy: + def test_the_workers_run_against_what_was_deployed( + self, project: pathlib.Path, group: execnet.Group, tmp_path: pathlib.Path + ) -> None: + deployment = Deployment( + project, roots=[project / "tests"], workspace=str(tmp_path / "ws") + ) + bootstrap = group.makegateway("popen//id=bootstrap") + target = deployment.deploy(bootstrap) + bootstrap.exit() + + # the interpreter is the deployed environment's, not this one's + assert target.python != sys.executable + worker = group.makegateway(f"popen//id=worker//{target.spec}") + try: + channel = worker.remote_exec( + "import deployed_demo, sys\n" + "channel.send((deployed_demo.VALUE, sys.executable))" + ) + value, executable = cast("tuple[int, str]", channel.receive(TESTTIMEOUT)) + assert value == 42 + assert executable == target.python + finally: + worker.exit() + + def test_the_workers_are_spawned_through_the_host_it_deployed_on( + self, project: pathlib.Path, group: execnet.Group, tmp_path: pathlib.Path + ) -> None: + """The usual shape: one connection per machine. + + The gateway a deployment runs through stays on as the ``via`` host, + and the test workers are its local children rather than N more + connections to the same box. Deploying first and running after is + also what keeps the two off each other -- the transfer is done with + that host's loop before it starts relaying. + """ + deployment = Deployment( + project, roots=[project / "tests"], workspace=str(tmp_path / "ws") + ) + host = group.makegateway("popen//id=viahost") + target = deployment.deploy(host) + + workers = [ + group.makegateway(f"via=viahost//{target.spec}//id=w{index}") + for index in range(3) + ] + for worker in workers: + channel = worker.remote_exec( + "import deployed_demo, os, sys\n" + "channel.send((deployed_demo.VALUE, sys.executable, os.getcwd()))" + ) + value, executable, cwd = cast( + "tuple[int, str, str]", channel.receive(TESTTIMEOUT) + ) + assert value == 42 + assert executable == target.python + assert cwd == target.workspace + + def test_it_carries_what_the_wheel_does_not( + self, project: pathlib.Path, group: execnet.Group, tmp_path: pathlib.Path + ) -> None: + # the reason a wheel is not enough: a test run needs the tests, and + # they are deliberately not in the artifact + deployment = Deployment( + project, + roots=[project / "tests", project / "conftest.py"], + workspace=str(tmp_path / "ws"), + ) + gateway = group.makegateway("popen//id=deploy-roots") + target = deployment.deploy(gateway) + + remote_tests = target.paths[str(project / "tests")] + assert (pathlib.Path(remote_tests) / "test_demo.py").is_file() + # a directory root lands as its own name, a file root directly in + # the workspace -- one rule, whichever it is + conftest = target.paths[str(project / "conftest.py")] + assert conftest == f"{target.workspace}/conftest.py" + assert pathlib.Path(conftest).is_file() + # and the caller can translate its own local paths without knowing + # the remote layout + assert target.translate(project / "tests" / "test_demo.py") == ( + f"{remote_tests}/test_demo.py" + ) + + def test_an_undeployed_path_does_not_translate( + self, project: pathlib.Path, group: execnet.Group, tmp_path: pathlib.Path + ) -> None: + # returning it unchanged would hand the remote a path that may well + # exist there and mean something entirely different + deployment = Deployment( + project, roots=[project / "tests"], workspace=str(tmp_path / "ws") + ) + gateway = group.makegateway("popen//id=deploy-translate") + target = deployment.deploy(gateway) + with pytest.raises(ValueError, match="not under any deployed root"): + target.translate("/etc/passwd") + + def test_deploying_twice_reuses_the_workspace( + self, project: pathlib.Path, group: execnet.Group, tmp_path: pathlib.Path + ) -> None: + # the point on a cluster: the second gateway to a machine finds the + # environment the first one built, and rsync moves only what changed + deployment = Deployment(project, workspace=str(tmp_path / "ws")) + gateway = group.makegateway("popen//id=deploy-reuse") + first = deployment.deploy(gateway) + second = deployment.deploy(gateway) + assert first.workspace == second.workspace + assert first.python == second.python + + def test_a_failing_step_reports_on_its_channel( + self, project: pathlib.Path, group: execnet.Group + ) -> None: + # and leaves the gateway usable: the step is a task on the worker's + # root nursery, so it has to contain what it raises + gateway = group.makegateway("popen//id=deploy-failure") + with pytest.raises(execnet.RemoteError, match="unknown deployment step"): + _deploy_request(gateway, {"step": "nonsense"}) + assert gateway.remote_exec("channel.send(1)").receive(TESTTIMEOUT) == 1 + + def test_the_default_workspace_is_the_hosts_cache( + self, project: pathlib.Path, group: execnet.Group + ) -> None: + # named, not given: the path is expanded on the *host*, where the + # home directory in question is -- the coordinator cannot know it + gateway = group.makegateway("popen//id=deploy-default") + reply = _deploy_request( + gateway, + { + "step": "prepare", + "workspace": None, + "root": DEFAULT_WORKSPACE_ROOT, + "name": "execnet-deploy-default", + }, + ) + workspace = str(reply["workspace"]) + try: + assert workspace.endswith("/execnet-deploy-default") + assert "~" not in workspace + assert pathlib.Path(workspace).is_dir() + finally: + shutil.rmtree(workspace, ignore_errors=True) + + +@needs_uv +@needs_provisioning +class TestEverySurfaceDeploys: + """The same deployment, driven from each namespace that can hold a gateway. + + There is one driver -- ``_deploy._run.deploy_to`` -- and four ways in, + which until now only the blocking one was tested through. The others + reach it over their own bridge and had never run at all; ``deploy_all`` + with more than one target had never run from anywhere, so neither had + the concurrency the docstrings promise or the same-engine check that + guards it. + """ + + def test_the_blocking_surface( + self, project: pathlib.Path, group: execnet.Group, tmp_path: pathlib.Path + ) -> None: + deployment = Deployment(project, workspace=str(tmp_path / "ws")) + target = deployment.deploy(group.makegateway("popen//id=sync-deploy")) + assert target.python != sys.executable + assert pathlib.Path(target.python).exists() + + def test_the_trio_facade( + self, project: pathlib.Path, tmp_path: pathlib.Path + ) -> None: + import trio + + import execnet.trio + + deployment = Deployment(project, workspace=str(tmp_path / "ws")) + + async def main() -> str: + async with execnet.trio.AsyncGroup() as group: + gateway = await group.makegateway("popen//id=trio-deploy") + target = await execnet.trio.deploy(deployment, gateway) + return target.python + + python = trio.run(main) + assert python != sys.executable + assert pathlib.Path(python).exists() + + def test_the_raw_trio_surface( + self, project: pathlib.Path, tmp_path: pathlib.Path + ) -> None: + import trio + + import execnet.raw_trio + + deployment = Deployment(project, workspace=str(tmp_path / "ws")) + + async def main() -> str: + async with execnet.raw_trio.open_gateway("popen//id=raw-deploy") as gateway: + target = await execnet.raw_trio.deploy(deployment, gateway) + return target.python + + python = trio.run(main) + assert python != sys.executable + assert pathlib.Path(python).exists() + + def test_the_asyncio_surface( + self, project: pathlib.Path, tmp_path: pathlib.Path + ) -> None: + import asyncio + + import execnet.aio + + deployment = Deployment(project, workspace=str(tmp_path / "ws")) + + async def main() -> str: + async with execnet.aio.AsyncGroup() as group: + gateway = await group.makegateway("popen//id=aio-deploy") + target = await execnet.aio.deploy(deployment, gateway) + return target.python + + python = asyncio.run(main()) + assert python != sys.executable + assert pathlib.Path(python).exists() + + +@needs_uv +@needs_provisioning +class TestDeployAll: + """Several targets from one staging build, which nothing exercised.""" + + def test_each_target_gets_its_own_workspace( + self, project: pathlib.Path, group: execnet.Group, tmp_path: pathlib.Path + ) -> None: + gateways = [group.makegateway(f"popen//id=fan{n}") for n in range(2)] + deployments = [ + Deployment(project, workspace=str(tmp_path / f"ws{n}")) for n in range(2) + ] + # one deployment object per workspace, but the wheel is built once + # per deploy_all call, which is the property under test + first = deployments[0].deploy_all(gateways[:1]) + second = deployments[1].deploy_all(gateways[1:]) + assert first[0].workspace != second[0].workspace + for result in (*first, *second): + assert pathlib.Path(result.python).exists() + + def test_one_deployment_reaches_every_gateway( + self, project: pathlib.Path, group: execnet.Group, tmp_path: pathlib.Path + ) -> None: + # the shape a cluster uses: the same workspace name on each machine, + # here collapsed onto one machine, so they share a workspace + gateways = [group.makegateway(f"popen//id=all{n}") for n in range(3)] + deployment = Deployment(project, workspace=str(tmp_path / "shared")) + results = deployment.deploy_all(gateways) + assert len(results) == len(gateways) + assert {result.workspace for result in results} == {str(tmp_path / "shared")} + + def test_deploying_to_no_gateways_is_refused(self, project: pathlib.Path) -> None: + with pytest.raises(ValueError, match="no gateways"): + Deployment(project).deploy_all([]) + + def test_gateways_from_two_engines_are_refused( + self, project: pathlib.Path, tmp_path: pathlib.Path + ) -> None: + # a fan-out is one task awaiting every gateway's channels, so they + # have to belong to one engine's run + from execnet._engine import ProtocolEngine + + other = ProtocolEngine(name="execnet-engine-deploy-second") + one = execnet.Group() + two = execnet.Group(engine=other) + try: + gateways = [ + one.makegateway("popen//id=e1"), + two.makegateway("popen//id=e2"), + ] + deployment = Deployment(project, workspace=str(tmp_path / "ws")) + with pytest.raises(ValueError, match=r"same execnet\.ProtocolEngine"): + deployment.deploy_all(gateways) + finally: + one.terminate(timeout=30.0) + two.terminate(timeout=30.0) + other.close() diff --git a/testing/test_engine.py b/testing/test_engine.py new file mode 100644 index 00000000..ec9dc397 --- /dev/null +++ b/testing/test_engine.py @@ -0,0 +1,839 @@ +"""The protocol engine: sharing, explicit override, and loop-misuse guards. + +``execnet.raw_trio`` runs gateways directly in the caller's own nursery; +every other surface drives a :class:`execnet.ProtocolEngine`. One is +shared per process, and blocking on it from inside a running event loop is +an error rather than a hang. +""" + +from __future__ import annotations + +import asyncio +import os +import select +import signal +import sys +import threading +import warnings +from collections.abc import Callable +from typing import cast + +import pytest +import trio + +import execnet +from execnet import _trio_engine +from execnet._engine import ProtocolEngine +from execnet._engine import default_engine +from execnet._errors import ForkedResourceError + +TESTTIMEOUT = 30.0 + + +def engine_thread_names() -> list[str]: + return [ + t.name for t in threading.enumerate() if t.name.startswith("execnet-engine") + ] + + +class TestSharedEngine: + def test_groups_share_the_default_engine(self) -> None: + a = execnet.Group() + b = execnet.Group() + assert a.engine is b.engine is default_engine() + + def test_many_groups_run_one_thread(self) -> None: + groups = [execnet.Group() for _ in range(3)] + try: + for group in groups: + group.makegateway("popen") + assert len(engine_thread_names()) == 1 + for group in groups: + channel = group[0].remote_exec("channel.send(1)") + assert channel.receive(TESTTIMEOUT) == 1 + finally: + for group in groups: + group.terminate(timeout=5.0) + + def test_explicit_engine_is_isolated_and_closes(self) -> None: + engine = ProtocolEngine(name="execnet-engine-isolated") + group = execnet.Group(engine=engine) + assert group.engine is engine + assert group.engine is not default_engine() + try: + gateway = group.makegateway("popen") + channel = gateway.remote_exec("channel.send(6 * 7)") + assert channel.receive(TESTTIMEOUT) == 42 + assert "execnet-engine-isolated" in engine_thread_names() + finally: + group.terminate(timeout=5.0) + engine.close() + assert not engine.running + assert "execnet-engine-isolated" not in engine_thread_names() + + def test_engine_context_manager_closes(self) -> None: + with ProtocolEngine(name="execnet-engine-ctx") as engine: + group = execnet.Group(engine=engine) + group.makegateway("popen") + group.terminate(timeout=5.0) + assert not engine.running + + def test_a_loop_that_cannot_start_says_why( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + # the loop comes up on a thread nobody is watching, so a trio.run + # that dies at once used to leave the caller waiting out the full + # 30s start timeout for a message that named nothing + async def boom(self: object) -> None: + raise RuntimeError("no event loop for you") + + monkeypatch.setattr(_trio_engine.TrioEngine, "_main", boom) + engine = ProtocolEngine(name="execnet-engine-doomed", backend="trio") + with pytest.raises(RuntimeError, match="could not start") as excinfo: + execnet.Group(engine=engine).makegateway("popen") + assert "no event loop for you" in str(excinfo.value) + + def test_starting_is_lazy(self) -> None: + engine = ProtocolEngine(name="execnet-engine-lazy") + execnet.Group(engine=engine) + # constructing a group must not cost a thread + assert not engine.running + assert "execnet-engine-lazy" not in engine_thread_names() + + +def run_in_fork(child: Callable[[], list[str]], timeout: float = 20.0) -> list[str]: + """Run ``child`` in a forked process and return the problems it reports. + + The child reports rather than asserts, because an assertion there dies + with the child. A child that blocks fails the test instead of hanging + the suite -- most of what can go wrong after a fork is a wait for a loop + thread that does not exist in this process. + """ + read_fd, write_fd = os.pipe() + pid = os.fork() + if pid == 0: # pragma: no cover - runs in the child + problems = ["the child died before reporting"] + try: + os.close(read_fd) + problems = child() + except BaseException as exc: + problems = [f"the child raised {type(exc).__name__}: {exc}"] + finally: + with os.fdopen(write_fd, "wb") as report: + report.write("\n".join(problems).encode()) + # not sys.exit: the parent's atexit handlers are not ours to run + os._exit(0) + os.close(write_fd) + chunks: list[bytes] = [] + try: + if not select.select([read_fd], [], [], timeout)[0]: + os.kill(pid, signal.SIGKILL) + pytest.fail(f"the forked child was still blocked after {timeout}s") + while chunk := os.read(read_fd, 4096): + chunks.append(chunk) + finally: + os.close(read_fd) + os.waitpid(pid, 0) + return [line for line in b"".join(chunks).decode().splitlines() if line] + + +@pytest.mark.skipif(not hasattr(os, "fork"), reason="requires os.fork") +class TestFork: + """Nothing execnet builds survives a fork, and it says so. + + The engine's loop thread is not duplicated into the child and the worker + connections belong to the parent, so every inherited object is dead + there. Dead has to mean "raises and names the fork": the token of the + parent's loop still *accepts* work in the child, so without a check the + child waits forever for a reply nobody will send. Recovery is the + child's to make explicitly, by building a new engine and group. + """ + + def test_a_new_group_in_the_child_works(self) -> None: + # the recovery path: default_engine() hands a child its own Engine + default_engine()._ensure_started() + + def child() -> list[str]: + problems = [] + if default_engine().running: + problems.append("the inherited default engine claims to run here") + group = execnet.Group() + gateway = group.makegateway("popen") + got = gateway.remote_exec("channel.send(3)").receive(TESTTIMEOUT) + if got != 3: + problems.append(f"a fresh group returned {got!r}") + if not group.engine.running: + problems.append("the child's own engine is not running") + group.terminate(timeout=5.0) + return problems + + assert run_in_fork(child) == [] + + def test_inherited_channels_and_gateways_are_dead(self) -> None: + group = execnet.Group() + gateway = group.makegateway("popen") + channel = gateway.remote_exec("while 1: channel.send(channel.receive())") + channel.send(1) + assert channel.receive(TESTTIMEOUT) == 1 + + def child() -> list[str]: + problems: list[str] = [] + + def expect_forked(what: str, call: Callable[[], object]) -> None: + try: + call() + except ForkedResourceError as exc: + if "fork" not in str(exc): + problems.append(f"{what}: does not mention the fork: {exc}") + except BaseException as exc: + problems.append(f"{what}: {type(exc).__name__}: {exc}") + else: + problems.append(f"{what}: did not raise") + + expect_forked("channel.send()", lambda: channel.send(2)) + expect_forked("channel.receive()", lambda: channel.receive(TESTTIMEOUT)) + expect_forked("channel.waitclose()", lambda: channel.waitclose(5.0)) + expect_forked("gateway.remote_exec()", lambda: gateway.remote_exec("pass")) + expect_forked("gateway.join()", lambda: gateway.join(5.0)) + expect_forked("group.terminate()", lambda: group.terminate(timeout=5.0)) + return problems + + assert run_in_fork(child) == [] + # ... and the parent's own gateway is untouched by all of that + channel.send(2) + assert channel.receive(TESTTIMEOUT) == 2 + group.terminate(timeout=5.0) + + def test_the_inherited_default_group_is_dead(self) -> None: + # the module-level convenience group is built at import time, so it + # is always one of the objects a fork leaves behind + execnet.makegateway("popen") + + def child() -> list[str]: + try: + execnet.makegateway("popen") + except ForkedResourceError as exc: + return [] if "fork" in str(exc) else [f"unclear message: {exc}"] + except BaseException as exc: + return [f"raised {type(exc).__name__}: {exc}"] + return ["execnet.makegateway() did not raise"] + + assert run_in_fork(child) == [] + execnet.default_group.terminate(timeout=5.0) + + def test_the_child_does_not_run_the_parents_cleanup(self) -> None: + group = execnet.Group() + group.makegateway("popen") + + def child() -> list[str]: + # what atexit would call in the child: the parent's gateways are + # not ours to terminate, and trying would raise from an exit hook + group._cleanup_atexit() + if not len(group): + return ["the child unregistered the parent's gateways"] + return [] + + assert run_in_fork(child) == [] + assert group[0].remote_exec("channel.send(4)").receive(TESTTIMEOUT) == 4 + group.terminate(timeout=5.0) + + +def take_the_loop_away(engine: ProtocolEngine) -> None: + """Stop the loop without draining what is running on it. + + What :meth:`ProtocolEngine.close` used to do unconditionally. It now + terminates the groups first, which is the right default -- but an + engine can still lose its loop without a tidy shutdown, and what the + objects it served do afterwards is the same either way. Doing it this + way here keeps these tests deterministic: a termination racing the + teardown decides whether a channel ends at a clean EOF or at a reset + connection, and that race is not what any of them is about. + """ + trio_engine = engine._ensure_started() + with engine._lock: + engine._trio_engine = None + engine._closed = True + trio_engine.stop(timeout=5.0) + + +class TestEngineDestruction: + """A loop that goes away breaks what it served -- loudly, without hanging. + + A gateway's protocol IO lives on the engine loop, so stopping that loop + is not a resource being freed underneath a working object: it ends the + connection. Every operation that needs the loop must say so at the + call site rather than hang, deliver nothing silently, or quietly start + a second loop thread that none of the existing gateways are on. + + What :meth:`ProtocolEngine.close` does *about* that -- terminate first, + and say so -- is :class:`TestEngineShutdownContract`. + + Pinned to the trio engine: *which* error a torn-down channel reports is + the one place the two backends still differ, and this suite asserts the + exact one. See ROADMAP-3.0.md. + """ + + def test_close_breaks_the_channels_it_served(self) -> None: + engine = ProtocolEngine(name="execnet-engine-broken-channel", backend="trio") + group = execnet.Group(engine=engine) + gateway = group.makegateway("popen") + channel = gateway.remote_exec("while 1: channel.send(channel.receive() + 1)") + channel.send(1) + assert channel.receive(TESTTIMEOUT) == 2 + + take_the_loop_away(engine) + + assert not gateway.hasreceiver() + with pytest.raises(EOFError): + channel.receive(TESTTIMEOUT) + with pytest.raises(OSError): + channel.send(3) + # closed for receiving, so this returns instead of timing out + channel.waitclose(TESTTIMEOUT) + group.terminate(timeout=5.0) + + def test_close_breaks_the_gateways_it_served(self) -> None: + engine = ProtocolEngine(name="execnet-engine-broken-gateway", backend="trio") + group = execnet.Group(engine=engine) + gateway = group.makegateway("popen") + assert gateway.remote_exec("channel.send(1)").receive(TESTTIMEOUT) == 1 + + take_the_loop_away(engine) + + with pytest.raises(OSError): + gateway.newchannel() + with pytest.raises(OSError): + gateway.remote_exec("channel.send(1)") + # the receiver is finished, so this must not block + gateway.join(TESTTIMEOUT) + group.terminate(timeout=5.0) + + def test_close_breaks_the_group_and_starts_no_second_loop(self) -> None: + engine = ProtocolEngine(name="execnet-engine-broken-group", backend="trio") + group = execnet.Group(engine=engine) + group.makegateway("popen") + + take_the_loop_away(engine) + + assert not engine.running + with pytest.raises(RuntimeError, match="was closed"): + group.makegateway("popen") + # the failed attempt must not have resurrected a loop thread: the + # group's existing gateways could never be attached to it + assert not engine.running + assert "execnet-engine-broken-group" not in engine_thread_names() + # cleaning up a broken group still returns + group.terminate(timeout=5.0) + + def test_aio_group_on_a_closed_engine_raises(self) -> None: + engine = ProtocolEngine(name="execnet-engine-closed-aio") + engine.close() + + async def main() -> None: + with pytest.raises(RuntimeError, match="was closed"): + await execnet.aio.AsyncGroup(engine=engine).start() + + asyncio.run(main()) + + def test_setcallback_after_close_fails_without_wedging_the_channel(self) -> None: + # the consumer task runs on the engine loop, so with the loop gone + # there is nothing to attach to -- but the failure must land on the + # caller, not on the channel: a half-switched channel drops what it + # had buffered, refuses receive(), and makes waitclose() wait for a + # consumer that will never run + engine = ProtocolEngine(name="execnet-engine-late-callback", backend="trio") + group = execnet.Group(engine=engine) + gateway = group.makegateway("popen") + channel = gateway.remote_exec("channel.send(1); channel.send(2)") + assert channel.receive(TESTTIMEOUT) == 1 + # everything the worker sent has arrived and is buffered by now + channel.waitclose(TESTTIMEOUT) + + take_the_loop_away(engine) + + received: list[object] = [] + with pytest.raises(OSError, match="engine loop"): + channel.setcallback(received.append, endmarker="END") + assert received == [] + # untouched: the buffered item is still there, then EOF + assert channel.receive(TESTTIMEOUT) == 2 + with pytest.raises(EOFError): + channel.receive(TESTTIMEOUT) + channel.waitclose(TESTTIMEOUT) + group.terminate(timeout=5.0) + + +class TestEngineShutdownContract: + """What closing does about the groups still running on it. + + Breaking them and walking away was the old behaviour, and it left real + worker processes behind: nothing else was going to reap them once the + loop that spoke to them was gone. So closing terminates -- and says so, + because doing it at close time is doing the caller's job at the moment + they can least act on the result. :meth:`ProtocolEngine.terminate` is + the half to call while they still can. + """ + + def test_close_terminates_live_groups_and_warns(self) -> None: + engine = ProtocolEngine(name="execnet-engine-terminating-close") + group = execnet.Group(engine=engine) + gateway = group.makegateway("popen") + pid = cast( + "int", + gateway.remote_exec("import os; channel.send(os.getpid())").receive( + TESTTIMEOUT + ), + ) + + with pytest.warns(execnet.ActiveGroupsWarning, match="still running"): + engine.close() + + # the worker is gone, not orphaned: the whole point of terminating + assert not _process_alive(pid) + group.terminate(timeout=5.0) + + def test_the_warning_names_what_is_still_running(self) -> None: + engine = ProtocolEngine(name="execnet-engine-named-in-warning") + group = execnet.Group(engine=engine) + group.makegateway("popen//id=stillhere") + + with pytest.warns(execnet.ActiveGroupsWarning, match="stillhere"): + engine.close() + group.terminate(timeout=5.0) + + def test_terminate_drains_without_closing(self) -> None: + # the deliberate half: workers reaped, engine still usable, and the + # group can be rebuilt on it afterwards + engine = ProtocolEngine(name="execnet-engine-drained") + group = execnet.Group(engine=engine) + gateway = group.makegateway("popen") + pid = cast( + "int", + gateway.remote_exec("import os; channel.send(os.getpid())").receive( + TESTTIMEOUT + ), + ) + + engine.terminate(timeout=5.0) + + assert not _process_alive(pid) + assert engine.running + group.terminate(timeout=5.0) + second = execnet.Group(engine=engine) + try: + channel = second.makegateway("popen").remote_exec("channel.send(7)") + assert channel.receive(TESTTIMEOUT) == 7 + finally: + second.terminate(timeout=5.0) + engine.close() + + def test_a_drained_engine_closes_quietly(self) -> None: + engine = ProtocolEngine(name="execnet-engine-quiet-close") + group = execnet.Group(engine=engine) + group.makegateway("popen") + group.terminate(timeout=5.0) + + with warnings.catch_warnings(): + warnings.simplefilter("error", execnet.ActiveGroupsWarning) + engine.close() + + def test_terminating_an_idle_engine_is_a_no_op(self) -> None: + engine = ProtocolEngine(name="execnet-engine-never-started") + engine.terminate() + assert not engine.running + # and it is still usable afterwards, unlike close() + group = execnet.Group(engine=engine) + try: + assert ( + group.makegateway("popen") + .remote_exec("channel.send(1)") + .receive(TESTTIMEOUT) + == 1 + ) + finally: + group.terminate(timeout=5.0) + engine.close() + + def test_closing_an_idle_engine_is_final_and_quiet(self) -> None: + engine = ProtocolEngine(name="execnet-engine-idle-close") + with warnings.catch_warnings(): + warnings.simplefilter("error", execnet.ActiveGroupsWarning) + engine.close() + with pytest.raises(RuntimeError, match="was closed"): + engine.start() + + def test_closing_from_the_loop_thread_is_refused(self) -> None: + # it would park the loop waiting for work only that loop can run + engine = ProtocolEngine(name="execnet-engine-self-close") + trio_engine = engine.start()._ensure_started() + errors: list[BaseException] = [] + + def close_from_the_loop() -> None: + try: + engine.close() + except BaseException as exc: + errors.append(exc) + + try: + trio_engine.call_sync(close_from_the_loop) + finally: + engine.close() + assert len(errors) == 1 + assert "own loop thread" in str(errors[0]) + + def test_closing_from_inside_another_event_loop_still_stops(self) -> None: + # the shutdown request is *posted* to the loop rather than run on it: + # portal.run_sync refuses a caller that is itself inside a trio run + # ("this is a blocking function"), which is where an async + # application closes its engine from. That refusal used to be + # swallowed, leaving the thread running for the rest of the process. + engine = ProtocolEngine(name="execnet-engine-closed-from-a-loop") + engine.start() + + async def main() -> None: + with warnings.catch_warnings(): + warnings.simplefilter("error", execnet.ActiveGroupsWarning) + engine.close(timeout=10.0) + + trio.run(main) + assert not engine.running + assert "execnet-engine-closed-from-a-loop" not in engine_thread_names() + + def test_a_thread_that_does_not_join_is_reported( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + # a wedged loop is a leaked thread; close() used to return as though + # it had stopped one. Wedged for real here: the shutdown request + # never reaches the loop, so the thread genuinely outlives close(). + engine = ProtocolEngine(name="execnet-engine-wedged") + trio_engine = engine.start()._ensure_started() + monkeypatch.setattr( + type(trio_engine.portal), "post", lambda self, fn, *args: None + ) + + try: + with pytest.warns(execnet.ActiveGroupsWarning, match="did not stop"): + engine.close(timeout=0.01) + assert trio_engine._thread is not None + assert trio_engine._thread.is_alive() + finally: + monkeypatch.undo() + trio_engine._started = True # close() cleared it; really stop now + trio_engine.stop(timeout=5.0) + + +def _process_alive(pid: int) -> bool: + """Whether ``pid`` is still a live process (not a zombie).""" + try: + os.kill(pid, 0) + except OSError: + return False + if not sys.platform.startswith("linux"): # pragma: no cover - linux CI + return True + try: + with open(f"/proc/{pid}/stat") as stat: + return stat.read().rsplit(") ", 1)[1].split()[0] != "Z" + except OSError: + return False + + +class TestPostedCallbacks: + """Work posted to the loop must never raise *on* the loop. + + Pinned to the trio engine: these reach into its nursery to build the + window between "the root scope closed" and "the run ended". + + Trio turns an exception from an entry-queue callback into a + TrioInternalError and tears the whole run down -- so one call losing a + race with shutdown would take every gateway in the process with it, and + tell the user to file a trio bug. An engine that is already going away is + an ordinary failure of that one call. + """ + + def test_a_call_racing_shutdown_reports_instead_of_killing_the_loop( + self, + ) -> None: + engine = ProtocolEngine(name="execnet-engine-late-call", backend="trio") + group = execnet.Group(engine=engine) + gateway = group.makegateway("popen") + trio_engine = engine._ensure_started() + + async def never() -> None: # pragma: no cover - never spawned + raise AssertionError("should not run") + + nursery, trio_engine._nursery = trio_engine._nursery, None + try: + # the window between the root nursery closing and the run ending + pending = trio_engine._call_pending(never) + with pytest.raises(RuntimeError, match="shut down"): + pending.wait(TESTTIMEOUT) + finally: + trio_engine._nursery = nursery + + assert trio_engine._thread is not None and trio_engine._thread.is_alive() + assert gateway.remote_exec("channel.send(7)").receive(TESTTIMEOUT) == 7 + group.terminate(timeout=5.0) + engine.close() + + def test_an_aio_call_racing_shutdown_reports_instead_of_killing_the_loop( + self, + ) -> None: + engine = ProtocolEngine(name="execnet-engine-late-aio-call", backend="trio") + + async def main() -> None: + async with execnet.aio.AsyncGroup(engine=engine) as group: + gateway = await group.makegateway("popen") + trio_engine = engine._ensure_started() + nursery, trio_engine._nursery = trio_engine._nursery, None + try: + with pytest.raises(RuntimeError, match="shut down"): + await gateway.remote_exec("channel.send(1)") + finally: + trio_engine._nursery = nursery + assert ( + trio_engine._thread is not None and trio_engine._thread.is_alive() + ) + channel = await gateway.remote_exec("channel.send(7)") + assert await channel.receive() == 7 + + asyncio.run(main()) + engine.close() + + +class TestEventLoopGuard: + """Blocking on the engine from inside a running loop must not hang.""" + + def test_makegateway_inside_asyncio_raises(self) -> None: + async def main() -> None: + with pytest.raises(RuntimeError, match=r"execnet\.aio"): + execnet.Group().makegateway("popen") + + asyncio.run(main()) + + def test_makegateway_inside_trio_raises(self) -> None: + async def main() -> None: + with pytest.raises(RuntimeError, match=r"execnet\.trio"): + execnet.Group().makegateway("popen") + + trio.run(main) + + def test_channel_receive_inside_asyncio_raises(self) -> None: + group = execnet.Group() + try: + gateway = group.makegateway("popen") + channel = gateway.remote_exec("channel.send(1)") + + async def main() -> None: + with pytest.raises(RuntimeError, match=r"execnet\.aio"): + channel.receive(TESTTIMEOUT) + with pytest.raises(RuntimeError, match=r"execnet\.aio"): + channel.send(1) + with pytest.raises(RuntimeError, match=r"execnet\.aio"): + channel.waitclose(TESTTIMEOUT) + + asyncio.run(main()) + # still usable from a plain thread afterwards + assert channel.receive(TESTTIMEOUT) == 1 + finally: + group.terminate(timeout=5.0) + + def test_terminate_and_join_inside_asyncio_raise(self) -> None: + # both block on the engine with no bound worth waiting out: join() + # until the worker dies, terminate() for the whole grace + group = execnet.Group() + try: + gateway = group.makegateway("popen") + + async def main() -> None: + with pytest.raises(RuntimeError, match=r"execnet\.aio"): + gateway.join(TESTTIMEOUT) + with pytest.raises(RuntimeError, match=r"execnet\.aio"): + group.terminate(timeout=5.0) + # an empty group has nothing to block on, so cleaning one up + # from inside a loop stays allowed + execnet.Group().terminate(timeout=5.0) + + asyncio.run(main()) + finally: + group.terminate(timeout=5.0) + + def test_worker_channels_are_not_guarded(self) -> None: + # exec'd code may run its own event loop and talk to its channel + # from inside it -- that is the caller's own loop to block + group = execnet.Group() + try: + gateway = group.makegateway("popen") + channel = gateway.remote_exec( + """ + import asyncio + + async def main(): + channel.send(channel.receive() + 1) + + asyncio.run(main()) + """ + ) + channel.send(41) + assert channel.receive(TESTTIMEOUT) == 42 + finally: + group.terminate(timeout=5.0) + + def test_a_worker_thread_is_still_fine(self) -> None: + group = execnet.Group() + result: list[object] = [] + + def work() -> None: + gateway = group.makegateway("popen") + result.append(gateway.remote_exec("channel.send(5)").receive(TESTTIMEOUT)) + + async def main() -> None: + # inside a loop, but the blocking call happens off it + await asyncio.to_thread(work) + + try: + asyncio.run(main()) + assert result == [5] + finally: + group.terminate(timeout=5.0) + + def test_no_asyncio_import_no_cost(self) -> None: + # the probe must not import asyncio/trio into a program that has + # neither; it goes through sys.modules first + code = ( + "import sys, execnet;" + " execnet._engine.check_not_in_event_loop('x');" + " print('asyncio' in sys.modules, 'trio' in sys.modules)" + ) + import subprocess + + out = subprocess.run( + [sys.executable, "-c", code], capture_output=True, text=True, check=True + ) + assert out.stdout.split() == ["False", "False"] + + +class TestGeventPatchedProcess: + """A monkey-patched process cannot engine the loop, and is told so. + + The stub stands in for ``gevent.monkey`` because the real thing patches + the interpreter irreversibly -- and the point of the check is that it + reads ``sys.modules``, so a stub exercises exactly what runs. The real + behaviour it stands for was measured in every variant: ``patch_all()`` + removes ``select.epoll``, ``patch_all(select=False)`` gives trio a + gevent socketpair (EBADF), and patching neither still leaves + ``queue.SimpleQueue`` gevent's (``LoopExit``). + """ + + @staticmethod + def fake_monkey(*patched: str) -> object: + class FakeMonkey: + @staticmethod + def is_module_patched(name: str) -> bool: + return name in patched + + return FakeMonkey() + + def test_start_refuses_and_names_what_was_patched( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setitem( + sys.modules, "gevent.monkey", self.fake_monkey("select", "socket") + ) + engine = _trio_engine.TrioEngine(name="execnet-engine-patched") + with pytest.raises(RuntimeError) as excinfo: + engine.start() + message = str(excinfo.value) + assert "gevent has monkey-patched select, socket" in message + assert "execnet.gevent" in message + # refused before the thread exists, so there is nothing to join + assert engine._thread is None + + def test_a_group_in_a_patched_process_runs_on_asyncio( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + # The trio engine cannot run here and says so; asyncio does not care + # what gevent patched, so an unasked-for backend falls through to it + # and execnet.gevent works in the environment its users actually + # have. Only an *explicit* backend="trio" still refuses. + monkeypatch.setitem(sys.modules, "gevent.monkey", self.fake_monkey("queue")) + engine = ProtocolEngine(name="execnet-engine-patched-group") + group = execnet.Group(engine=engine) + try: + channel = group.makegateway("popen").remote_exec("channel.send(1)") + assert channel.receive(TESTTIMEOUT) == 1 + assert engine._ensure_started().backend == "asyncio" + finally: + group.terminate(timeout=5.0) + engine.close(timeout=10.0) + + def test_an_explicit_trio_engine_still_refuses_when_patched( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setitem(sys.modules, "gevent.monkey", self.fake_monkey("queue")) + engine = ProtocolEngine(name="execnet-engine-patched-trio", backend="trio") + with pytest.raises(RuntimeError, match="monkey-patched queue"): + engine.start() + + def test_patching_something_else_is_none_of_our_business( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setitem(sys.modules, "gevent.monkey", self.fake_monkey("ssl")) + engine = ProtocolEngine(name="execnet-engine-unpatched") + group = execnet.Group(engine=engine) + try: + assert ( + group.makegateway("popen") + .remote_exec("channel.send(1)") + .receive(TESTTIMEOUT) + == 1 + ) + finally: + group.terminate(timeout=5.0) + engine.close() + + +class TestExplicitStart: + """Starting is lazy, but not compulsory to leave that way. + + Everything that can go wrong with bringing a loop thread up otherwise + goes wrong at an arbitrary later ``makegateway()``, in whatever code + path happened to need the first gateway. + """ + + @staticmethod + def threads(name: str) -> int: + return engine_thread_names().count(name) + + def test_start_brings_the_thread_up_now(self) -> None: + engine = ProtocolEngine(name="execnet-engine-explicit") + assert self.threads("execnet-engine-explicit") == 0 + try: + assert engine.start() is engine + assert self.threads("execnet-engine-explicit") == 1 + # idempotent: no second thread + engine.start() + assert self.threads("execnet-engine-explicit") == 1 + finally: + engine.close() + assert self.threads("execnet-engine-explicit") == 0 + + def test_entering_an_engine_starts_it(self) -> None: + with ProtocolEngine(name="execnet-engine-entered") as engine: + assert self.threads("execnet-engine-entered") == 1 + assert engine.running + assert self.threads("execnet-engine-entered") == 0 + + def test_start_is_where_a_broken_environment_shows_up( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + # the point of asking early: this is the gevent refusal, raised at + # startup instead of at whatever first needed a gateway. It takes an + # explicit backend= now, since an unasked-for one falls back to + # asyncio rather than refusing. + monkeypatch.setitem( + sys.modules, + "gevent.monkey", + TestGeventPatchedProcess.fake_monkey("socket"), + ) + with pytest.raises(RuntimeError, match="monkey-patched socket"): + ProtocolEngine(name="execnet-engine-start-fails", backend="trio").start() diff --git a/testing/test_engine_backends.py b/testing/test_engine_backends.py new file mode 100644 index 00000000..30510121 --- /dev/null +++ b/testing/test_engine_backends.py @@ -0,0 +1,300 @@ +"""The engine contract, run against every backend that claims to meet it. + +``ProtocolEngine`` can be handed a loop written in either async library. +The core still only runs on trio, so what is pinned here is the *engine's* +own promise -- start, a portal into the loop, one door to the root task +scope, a registry of what is running, and a stop that joins -- plus the +refusal that keeps an unported core from failing somewhere confusing. + +Every test that is not explicitly about one backend is parametrized over +both, so the second implementation cannot drift from the first quietly. +""" + +from __future__ import annotations + +import sys +import threading +import time +import warnings +from typing import Any + +import pytest + +import execnet +from execnet._asyncio_engine import MIN_PYTHON +from execnet._asyncio_engine import AsyncioEngine +from execnet._engine import BACKENDS +from execnet._engine import ProtocolEngine +from execnet._engine import pick_backend +from execnet._errors import ForkedResourceError +from execnet._errors import LoopFinishedError +from execnet._trio_engine import TrioEngine + +TESTTIMEOUT = 30.0 + +#: backends this interpreter can actually run +USABLE = [ + name + for name in sorted(BACKENDS) + if name != "asyncio" or sys.version_info >= MIN_PYTHON +] + + +@pytest.fixture(params=USABLE) +def engine(request: pytest.FixtureRequest) -> Any: + """A started engine per backend, closed afterwards.""" + made = ProtocolEngine(name=f"execnet-engine-{request.param}", backend=request.param) + try: + yield made.start() + finally: + made.close(timeout=10.0) + + +class TestTheContract: + def test_it_runs_a_coroutine_and_returns_the_value(self, engine: Any) -> None: + async def double(value: int) -> int: + return value * 2 + + assert engine._ensure_started().call(double, 21) == 42 + + def test_an_exception_comes_back_to_the_caller(self, engine: Any) -> None: + async def boom() -> None: + raise ValueError("from the loop") + + with pytest.raises(ValueError, match="from the loop"): + engine._ensure_started().call(boom) + + def test_call_sync_runs_on_the_loop_thread(self, engine: Any) -> None: + loop = engine._ensure_started() + assert loop.call_sync(loop._on_engine_thread) is True + assert loop._on_engine_thread() is False + + def test_the_thread_is_named_and_goes_away(self, engine: Any) -> None: + name = engine.name + assert any(t.name == name for t in threading.enumerate()) + engine.close(timeout=10.0) + assert not engine.running + assert not any(t.name == name for t in threading.enumerate()) + + def test_post_is_fire_and_forget_and_ordered(self, engine: Any) -> None: + loop = engine._ensure_started() + seen: list[int] = [] + for index in range(10): + loop.portal.post(seen.append, index) + # a round trip flushes everything posted before it + loop.call_sync(lambda: None) + assert seen == list(range(10)) + + def test_posting_to_a_stopped_loop_is_refused(self, engine: Any) -> None: + loop = engine._ensure_started() + engine.close(timeout=10.0) + with pytest.raises(LoopFinishedError): + loop.portal.post(lambda: None) + + def test_start_task_waits_until_the_task_says_it_is_ready( + self, engine: Any + ) -> None: + loop = engine._ensure_started() + running = threading.Event() + + async def server(task_status: Any) -> None: + task_status.started("the value") + running.set() + await _forever(engine.backend) + + async def start() -> Any: + return await loop.start_task(server) + + assert loop.call(start) == "the value" + assert running.is_set() + + def test_a_failure_before_ready_reaches_the_starter(self, engine: Any) -> None: + # and only the starter: the engine keeps running afterwards + loop = engine._ensure_started() + + async def broken(task_status: Any) -> None: + raise ValueError("never became ready") + + async def start() -> Any: + return await loop.start_task(broken) + + with pytest.raises(ValueError, match="never became ready"): + loop.call(start) + + async def fine() -> int: + return 1 + + assert loop.call(fine) == 1 + + def test_a_task_that_never_signals_ready_is_reported(self, engine: Any) -> None: + loop = engine._ensure_started() + + async def forgetful(task_status: Any) -> None: + return + + async def start() -> Any: + return await loop.start_task(forgetful) + + with pytest.raises(RuntimeError, match="started"): + loop.call(start) + + def test_start_soon_requires_the_loop_thread(self, engine: Any) -> None: + loop = engine._ensure_started() + + async def noop() -> None: + return + + with pytest.raises(RuntimeError, match="engine thread"): + loop.start_soon(noop) + + def test_a_forked_child_may_not_reach_the_parent_loop( + self, engine: Any, monkeypatch: pytest.MonkeyPatch + ) -> None: + # the parent loop's handle still *accepts* work in a child, so every + # portal compares pids rather than let the child wait forever + loop = engine._ensure_started() + monkeypatch.setattr(loop.portal, "_pid", loop.portal._pid + 1) + with pytest.raises(ForkedResourceError): + loop.portal.post(lambda: None) + + def test_nothing_is_registered_on_a_fresh_engine(self, engine: Any) -> None: + assert engine._ensure_started().live_groups() == "" + + def test_terminate_and_close_are_quiet_with_nothing_running( + self, engine: Any + ) -> None: + engine.terminate(timeout=5.0) + assert engine.running + with warnings.catch_warnings(): + warnings.simplefilter("error", execnet.ActiveGroupsWarning) + engine.close(timeout=10.0) + assert not engine.running + + +class TestEitherBackendHostsTheCore: + """Gateways live on whichever loop the engine happens to be.""" + + @pytest.mark.skipif( + sys.version_info < MIN_PYTHON, reason="asyncio engine needs 3.11" + ) + def test_a_group_on_an_asyncio_engine_works(self) -> None: + engine = ProtocolEngine(backend="asyncio", name="execnet-engine-aio-core") + group = execnet.Group(engine=engine) + try: + gateway = group.makegateway("popen") + channel = gateway.remote_exec("channel.send(channel.receive() * 2)") + channel.send(21) + assert channel.receive(TESTTIMEOUT) == 42 + assert engine._ensure_started().backend == "asyncio" + finally: + group.terminate(timeout=10.0) + engine.close(timeout=10.0) + + @pytest.mark.skipif( + sys.version_info < MIN_PYTHON, reason="asyncio engine needs 3.11" + ) + def test_channels_and_callbacks_work_on_asyncio(self) -> None: + engine = ProtocolEngine(backend="asyncio", name="execnet-engine-aio-channels") + group = execnet.Group(engine=engine) + try: + gateway = group.makegateway("popen") + channel = gateway.remote_exec("for i in range(4): channel.send(i * 2)") + assert [channel.receive(TESTTIMEOUT) for _ in range(4)] == [0, 2, 4, 6] + + seen: list[object] = [] + other = gateway.remote_exec("channel.send('via callback')") + other.setcallback(seen.append, endmarker="END") + other.waitclose(TESTTIMEOUT) + deadline = time.monotonic() + TESTTIMEOUT + while "END" not in seen and time.monotonic() < deadline: + time.sleep(0.01) + assert seen == ["via callback", "END"] + finally: + group.terminate(timeout=10.0) + engine.close(timeout=10.0) + + def test_a_group_on_a_trio_engine_works_too(self) -> None: + engine = ProtocolEngine(backend="trio", name="execnet-engine-ported") + group = execnet.Group(engine=engine) + try: + channel = group.makegateway("popen").remote_exec("channel.send(1)") + assert channel.receive(TESTTIMEOUT) == 1 + finally: + group.terminate(timeout=10.0) + engine.close(timeout=10.0) + + +class TestBackendSelection: + def test_an_unasked_backend_stays_open_until_the_loop_starts(self) -> None: + # gevent can patch the world after the engine object exists, and that + # is what the choice turns on, so it cannot be settled any earlier + assert ProtocolEngine().backend is None + + def test_trio_is_preferred_when_it_is_installed(self) -> None: + from execnet import _engine as engine_module + + if engine_module.pick_backend() != "trio": + pytest.skip("this run pins a backend") + assert pick_backend() == "trio" + engine = ProtocolEngine(name="execnet-engine-default-backend") + try: + assert isinstance(engine.start()._ensure_started(), TrioEngine) + finally: + engine.close(timeout=10.0) + + def test_a_patched_process_falls_back_to_asyncio( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + from execnet import _engine as engine_module + + # the one environment the trio engine has to refuse is exactly the + # one asyncio does not care about, so execnet.gevent works there + pytest.importorskip("gevent") + monkeypatch.setattr( + engine_module, "gevent_patched_modules", lambda: ["select", "socket"] + ) + if sys.version_info >= MIN_PYTHON: + assert pick_backend() == "asyncio" + else: + assert pick_backend() == "trio" # it will refuse, and say why + + def test_an_unknown_backend_is_refused_at_construction(self) -> None: + with pytest.raises(ValueError, match="unknown engine backend"): + ProtocolEngine(backend="curio") + + def test_the_repr_names_the_backend(self) -> None: + assert "asyncio" in repr(ProtocolEngine(backend="asyncio")) + assert "auto" in repr(ProtocolEngine()) + + @pytest.mark.skipif( + sys.version_info < MIN_PYTHON, reason="asyncio engine needs 3.11" + ) + def test_asyncio_builds_an_asyncio_loop(self) -> None: + engine = ProtocolEngine(backend="asyncio", name="execnet-engine-selected") + try: + assert isinstance(engine.start()._ensure_started(), AsyncioEngine) + finally: + engine.close(timeout=10.0) + + def test_an_interpreter_without_taskgroup_is_refused_with_the_reason( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + # refused when the engine is built, not at start(): the Python + # version is a fact nothing a caller does later can change + monkeypatch.setattr(sys, "version_info", (3, 10, 12)) + with pytest.raises(RuntimeError, match=r"3\.11 or newer"): + ProtocolEngine(backend="asyncio") + with pytest.raises(RuntimeError, match="does not carry a backport"): + AsyncioEngine() + + +async def _forever(backend: str) -> None: + """Park until the engine's own shutdown cancels this task.""" + if backend == "trio": + import trio + + await trio.sleep_forever() + else: + import asyncio + + await asyncio.Event().wait() diff --git a/testing/test_errors.py b/testing/test_errors.py new file mode 100644 index 00000000..e328859d --- /dev/null +++ b/testing/test_errors.py @@ -0,0 +1,144 @@ +"""The exception shape, and the compatibility it has to keep. + +Three questions a caller asks, each with an answer they can ``except`` on: +did the other side fail (``RemoteError``), is the connection gone +(``OSError`` and its subclasses), did I use this wrong +(``ExecnetStateError``, deliberately *not* an ``OSError``). + +Most of what is pinned here is inheritance rather than behaviour, because +inheritance is the whole contract: a caller writes ``except OSError`` once +and every reason a connection can be gone has to land in it. +""" + +from __future__ import annotations + +import builtins + +import pytest + +import execnet +from execnet._channel import Channel + +TESTTIMEOUT = 30.0 + + +class TestTheShape: + @pytest.mark.parametrize( + ("error", "bases"), + [ + (execnet.RemoteError, (Exception,)), + (execnet.DumpError, (execnet.DataFormatError,)), + (execnet.LoadError, (execnet.DataFormatError,)), + (execnet.HostNotFound, (ConnectionError, OSError)), + (execnet.ChannelClosed, (OSError,)), + (execnet.GatewayGone, (OSError, EOFError)), + (execnet.TimeoutError, (builtins.TimeoutError, OSError)), + (execnet.ExecnetStateError, (RuntimeError,)), + ], + ) + def test_each_error_is_catchable_as_what_it_means( + self, error: type[BaseException], bases: tuple[type[BaseException], ...] + ) -> None: + for base in bases: + assert issubclass(error, base), f"{error.__name__} is not a {base.__name__}" + + def test_api_misuse_is_not_a_connection_failure(self) -> None: + # the point of the whole exercise: something retrying on connection + # loss must not also retry on its own bug + assert not issubclass(execnet.ExecnetStateError, OSError) + + def test_every_way_a_connection_can_be_gone_is_an_oserror(self) -> None: + for error in ( + execnet.ChannelClosed, + execnet.GatewayGone, + execnet.HostNotFound, + execnet.TimeoutError, + execnet._errors.ForkedResourceError, + ): + assert issubclass(error, OSError), error.__name__ + + +class TestTimeoutErrorIsTheBuiltin: + """The bug this shape was written to fix. + + ``execnet.TimeoutError`` shadowed the builtin without subclassing it, so + the obvious ``except TimeoutError:`` caught nothing and only + ``except OSError`` worked -- and since 3.11 ``asyncio.TimeoutError`` *is* + the builtin, so async callers' instincts were actively wrong. + """ + + @pytest.mark.parametrize("catcher", [builtins.TimeoutError, OSError, IOError]) + def test_it_is_caught_by_every_spelling(self, catcher: type[BaseException]) -> None: + with pytest.raises(catcher): + raise execnet.TimeoutError("nothing arrived") + + def test_a_real_receive_timeout_is_caught_by_the_builtin(self) -> None: + group = execnet.Group() + try: + channel = group.makegateway("popen").remote_exec("channel.receive()") + with pytest.raises(builtins.TimeoutError): + channel.receive(timeout=0.05) + channel.send(None) + finally: + group.terminate(timeout=10.0) + + +class TestWhatXdistNeeds: + """Released pytest-xdist reaches for these, and 3.0 keeps it working. + + Each is an accommodation with a removal note in ROADMAP-3.0.md; none of + them may quietly stop being true in the meantime. + """ + + def test_dumperror_is_reachable_for_the_serializability_probe(self) -> None: + # xdist/remote.py: `try: execnet.dumps(x) / except execnet.DumpError` + assert issubclass(execnet.DumpError, Exception) + with pytest.raises(execnet.DumpError): + execnet.dumps(object()) + + @pytest.mark.parametrize("name", ["RemoteError", "TimeoutError"]) + def test_the_error_types_are_class_attributes_on_channel(self, name: str) -> None: + # xdist/looponfail.py writes `except self.channel.RemoteError` + assert getattr(Channel, name) is getattr(execnet, name) + + def test_a_send_to_a_dead_peer_is_an_oserror(self) -> None: + # xdist/workermanage.py swallows exactly this around its shutdown + # send; anything not an OSError makes every teardown raise + group = execnet.Group() + gateway = group.makegateway("popen") + channel = gateway.remote_exec("channel.send(1)") + assert channel.receive(TESTTIMEOUT) == 1 + gateway.exit() + gateway.join(TESTTIMEOUT) + with pytest.raises(OSError): + for _ in range(100): # the close takes a send or two to surface + channel.send("into the void") + group.terminate(timeout=10.0) + + +class TestTheInternalBoundary: + """Nothing from ``_async`` may reach user code. + + It is the neutral spelling of what a *backend* raises, and one already + escaped: ``open_tcp_stream`` translated a connect failure into + ``BrokenResource``, which silently lost ``HostNotFound``. + """ + + def test_the_backend_vocabulary_is_not_exported(self) -> None: + import importlib + + for namespace in ("", ".sync", ".trio", ".raw_trio", ".aio"): + module = importlib.import_module(f"execnet{namespace}") + exported = set(module.__all__) + for leaked in ("ClosedResource", "BrokenResource", "EndOfChannel"): + assert leaked not in exported, f"execnet{namespace} exports {leaked}" + + def test_an_unreachable_socket_is_a_hostnotfound(self) -> None: + # the exact case that leaked: a connect failure is "could not reach", + # not "the stream broke" + group = execnet.Group() + try: + with pytest.raises(execnet.HostNotFound): + group.makegateway("socket=localhost:1") + finally: + group.terminate(timeout=10.0) diff --git a/testing/test_execmodel_trio.py b/testing/test_execmodel_trio.py new file mode 100644 index 00000000..b509110c --- /dev/null +++ b/testing/test_execmodel_trio.py @@ -0,0 +1,193 @@ +"""The pure-async worker profile (profile=trio). + +One single thread in the worker: the trio loop owns the main thread and +exec'd sources run as tasks on it, talking through AsyncChannels. Sync +sources are rejected before they can starve the loop. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import cast + +import pytest +import trio as trio_lib + +import execnet +import execnet.raw_trio +from execnet import Gateway +from execnet import _provision + +TESTTIMEOUT = 10.0 + + +@pytest.fixture +def trio_gw(makegateway: Callable[[str], Gateway]) -> Gateway: + return makegateway("popen//profile=trio") + + +class TestSyncCoordinator: + def test_top_level_await_roundtrip(self, trio_gw: Gateway) -> None: + channel = trio_gw.remote_exec("await channel.send(await channel.receive() + 1)") + channel.send(41) + assert channel.receive(TESTTIMEOUT) == 42 + + def test_async_function_source(self, trio_gw: Gateway) -> None: + async def source(channel, delta) -> None: + value = await channel.receive() + await channel.send(value + delta) + + channel = trio_gw.remote_exec(source, delta=5) + channel.send(37) + assert channel.receive(TESTTIMEOUT) == 42 + + def test_iteration_and_eof(self, trio_gw: Gateway) -> None: + channel = trio_gw.remote_exec( + """ + for i in range(3): + await channel.send(i * 2) + """ + ) + assert list(channel) == [0, 2, 4] + + def test_single_thread_on_main(self, trio_gw: Gateway) -> None: + channel = trio_gw.remote_exec( + """ + import threading + await channel.send( + ( + threading.active_count(), + threading.current_thread() is threading.main_thread(), + ) + ) + """ + ) + active, on_main = cast("tuple[int, bool]", channel.receive(TESTTIMEOUT)) + assert on_main + # The profile itself needs no threads -- exec'd sources are tasks on + # the loop. The *transport* may: adopting inherited stdio has no + # async form on Windows, so those reads and writes run in the thread + # pool. Only the socket transport is genuinely single-threaded. + transport = _provision.resolve_transport( + trio_gw.spec, available=_provision.socket_handoff_available() + ) + if transport == "socket": + assert active == 1 + + def test_concurrent_execs_cooperate(self, trio_gw: Gateway) -> None: + # two execs run as tasks on one loop: the first parks in receive + # while the second completes -- no threads involved. + blocked = trio_gw.remote_exec("await channel.send(await channel.receive())") + side = trio_gw.remote_exec("await channel.send('side')") + assert side.receive(TESTTIMEOUT) == "side" + blocked.send("go") + assert blocked.receive(TESTTIMEOUT) == "go" + + def test_sync_source_rejected(self, trio_gw: Gateway) -> None: + channel = trio_gw.remote_exec("x = 40 + 2") + with pytest.raises(channel.RemoteError, match="sync source"): + channel.receive(TESTTIMEOUT) + + def test_sync_function_rejected(self, trio_gw: Gateway) -> None: + def source(channel) -> None: + pass + + channel = trio_gw.remote_exec(source) + with pytest.raises(channel.RemoteError, match="must be async"): + channel.receive(TESTTIMEOUT) + + def test_remote_error_traceback(self, trio_gw: Gateway) -> None: + async def source(channel) -> None: + raise ValueError(17) + + channel = trio_gw.remote_exec(source) + with pytest.raises(channel.RemoteError, match="ValueError"): + channel.receive(TESTTIMEOUT) + + def test_status_and_rinfo(self, trio_gw: Gateway) -> None: + status = trio_gw.remote_status() + assert status.profile == "trio" + # legacy STATUS key, kept for pytest-xdist + assert status.execmodel == "trio" + rinfo = trio_gw._rinfo() + assert rinfo.pid + assert rinfo.version_info + + +def test_unknown_profile_rejected(makegateway: Callable[[str], Gateway]) -> None: + with pytest.raises(ValueError, match="unknown profile"): + makegateway("popen//profile=nope") + # the pre-3.0 spelling routes to the same validation + with pytest.raises(ValueError, match="unknown profile"): + makegateway("popen//execmodel=nope") + + +def test_execmodel_is_an_accepted_alias_for_profile( + makegateway: Callable[[str], Gateway], +) -> None: + gw = makegateway("popen//execmodel=trio") + assert gw.spec.profile == "trio" + assert gw.spec.execmodel == "trio" + with pytest.raises(ValueError, match="duplicate key"): + execnet.XSpec("popen//execmodel=trio//profile=thread") + + +def test_trio_native_coordinator() -> None: + async def main() -> None: + async with execnet.raw_trio.AsyncGroup() as group: + gateway = await group.makegateway("popen//profile=trio") + channel = await gateway.remote_exec( + "await channel.send(await channel.receive() * 2)" + ) + await channel.send(21) + with trio_lib.fail_after(TESTTIMEOUT): + assert await channel.receive() == 42 + + trio_lib.run(main) + + +def test_async_coordinator_defaults_to_a_thread_worker() -> None: + # An async coordinator does not imply an async worker: the worker's + # shape is its own choice, so the default stays the thread profile. + async def main() -> None: + async with execnet.raw_trio.AsyncGroup() as group: + gateway = await group.makegateway("popen") + channel = await gateway.remote_exec( + "import threading;" + " channel.send(threading.current_thread() is" + " threading.main_thread())" + ) + with trio_lib.fail_after(TESTTIMEOUT): + # a sync source at all proves this is not the trio profile, + # which rejects them + assert await channel.receive() is True + + trio_lib.run(main) + + +def test_makegateway_does_not_rewrite_the_callers_profile( + makegateway: Callable[[str], Gateway], +) -> None: + # pytest-xdist reuses one XSpec across gateways and re-reads + # spec.execmodel to decide whether it still needs its + # "execmodel=main_thread_only//" prefix. Normalizing the value it set + # made the second use build a spec with a duplicate key, which broke + # crashed-worker replacement. Filling in a *missing* value is fine; + # rewriting one the caller set is not. + spec = execnet.XSpec("execmodel=main_thread_only//popen") + with pytest.warns(DeprecationWarning, match="main_thread_only"): + gw = makegateway(spec) # type: ignore[arg-type] + assert spec.execmodel == "main_thread_only" + assert spec.profile == "main_thread_only" + # ... while the worker still gets a profile it has a strategy for + assert gw.remote_status().profile == "thread" + + +def test_makegateway_fills_in_a_missing_profile() -> None: + group = execnet.Group() + try: + spec = execnet.XSpec("popen") + group.makegateway(spec) + assert spec.profile == "thread" + finally: + group.terminate(timeout=5.0) diff --git a/testing/test_gateway.py b/testing/test_gateway.py index 634f237a..37ddde4a 100644 --- a/testing/test_gateway.py +++ b/testing/test_gateway.py @@ -9,15 +9,17 @@ import shutil import signal import sys +import time from collections.abc import Callable +from contextlib import suppress from textwrap import dedent import pytest import execnet -from execnet import gateway_base -from execnet import gateway_io -from execnet.gateway import Gateway +from execnet import Gateway +from execnet import RemoteError +from execnet import _trace TESTTIMEOUT = 10.0 # seconds needs_osdup = pytest.mark.skipif("not hasattr(os, 'dup')") @@ -169,7 +171,7 @@ def run_me(channel=None): ch = gw.remote_exec(remotetest) try: ch.receive() - except execnet.gateway_base.RemoteError as e: + except execnet.RemoteError as e: assert 'remotetest.py", line 3, in run_me' in str(e) assert "ValueError: me" in str(e) finally: @@ -178,7 +180,7 @@ def run_me(channel=None): ch = gw.remote_exec(remotetest.run_me) try: ch.receive() - except execnet.gateway_base.RemoteError as e: + except execnet.RemoteError as e: assert 'remotetest.py", line 3, in run_me' in str(e) assert "ValueError: me" in str(e) finally: @@ -269,14 +271,19 @@ def test__rinfo(self, gw: Gateway) -> None: assert rinfo.cwd assert rinfo.version_info assert repr(rinfo) - old = gw.remote_exec( + chdir = gw.remote_exec( """ import os.path cwd = os.getcwd() channel.send(os.path.basename(cwd)) os.chdir('..') """ - ).receive() + ) + old = chdir.receive() + # receive() returns when the *send* arrives, and the chdir happens + # after it -- so without waiting for the exec to finish, the _rinfo + # below races it. Slower interpreters lose that race. + chdir.waitclose(TESTTIMEOUT) try: rinfo2 = gw._rinfo() assert rinfo2.cwd == rinfo.cwd @@ -286,6 +293,50 @@ def test__rinfo(self, gw: Gateway) -> None: gw._cache_rinfo = rinfo gw.remote_exec("import os ; os.chdir(%r)" % old).waitclose() + def test_hybrid_primary_then_overflow( + self, makegateway: Callable[[str], Gateway] + ) -> None: + # classic thread-model shape: the first exec claims the true main + # thread; while it is busy, further execs overflow to worker + # threads; once released the main thread is claimable again. + gw = makegateway("popen//execmodel=thread") + report = """ + import threading + channel.send(threading.current_thread() is threading.main_thread()) + channel.receive() + """ + first = gw.remote_exec(report) + assert first.receive(TESTTIMEOUT) is True + second = gw.remote_exec(report) + assert second.receive(TESTTIMEOUT) is False + second.send(None) + second.waitclose(TESTTIMEOUT) + first.send(None) + first.waitclose(TESTTIMEOUT) + # the primary slot frees shortly after the exec finishes + for _ in range(50): + probe = gw.remote_exec(report) + on_main = probe.receive(TESTTIMEOUT) + probe.send(None) + probe.waitclose(TESTTIMEOUT) + if on_main: + break + time.sleep(0.05) + assert on_main + + def test__rinfo_while_exec_busy(self, gw: Gateway) -> None: + # info is a native protocol request: it must work (and not claim + # an exec slot) while an exec occupies the worker -- under + # main_thread_only an info-by-remote_exec used to either steal + # the main thread or trip the concurrency deadlock guard. + channel = gw.remote_exec("channel.send(channel.receive())") + try: + rinfo = gw._rinfo(update=True) + assert rinfo.pid != os.getpid() + finally: + channel.send("done") + assert channel.receive(TESTTIMEOUT) == "done" + class TestPopenGateway: gwtype = "popen" @@ -302,9 +353,9 @@ def test_chdir_separation( assert x.lower() == str(tmp_path).lower() def test_remoteerror_readable_traceback(self, gw: Gateway) -> None: - with pytest.raises(gateway_base.RemoteError) as e: + with pytest.raises(execnet.RemoteError) as e: gw.remote_exec("x y").waitclose() - assert "gateway_base" in e.value.formatted + assert "_gateway_base" in e.value.formatted def test_many_popen(self, makegateway: Callable[[str], Gateway]) -> None: num = 4 @@ -381,20 +432,19 @@ def test_socket_gw_host_not_found(makegateway: Callable[[str], Gateway]) -> None class TestSshPopenGateway: gwtype = "ssh" - def test_sshconfig_config_parsing( - self, monkeypatch: pytest.MonkeyPatch, makegateway: Callable[[str], Gateway] + def test_ssh_trio_args_include_config( + self, monkeypatch: pytest.MonkeyPatch ) -> None: - l = [] + from execnet import _provision + from execnet import _trio_host + monkeypatch.setattr( - gateway_io, "Popen2IOMaster", lambda *args, **kwargs: l.append(args[0]) + _provision, "ssh_remote_command", lambda spec, *a, **kw: "worker-cmd" ) - with pytest.raises(AttributeError): - makegateway("ssh=xyz//ssh_config=qwe") - - assert len(l) == 1 - popen_args = l[0] - i = popen_args.index("-F") - assert popen_args[i + 1] == "qwe" + args = _trio_host.ssh_trio_args(execnet.XSpec("ssh=xyz//ssh_config=qwe")) + assert args[args.index("-F") + 1] == "qwe" + assert "xyz" in args + assert args[-1] == "worker-cmd" def test_sshaddress(self, gw: Gateway, specssh: execnet.XSpec) -> None: assert gw.remoteaddress == specssh.ssh @@ -477,9 +527,7 @@ def test_popen_filetracing( monkeypatch.setenv("EXECNET_DEBUG", "1") gw = makegateway("popen") # hack out the debuffilename - fn = gw.remote_exec( - "import execnet;channel.send(execnet.gateway_base.fn)" - ).receive() + fn = gw.remote_exec("import execnet;channel.send(execnet._trace.fn)").receive() assert isinstance(fn, str) workerfile = pathlib.Path(fn) assert workerfile.exists() @@ -509,7 +557,7 @@ def test_popen_stderr_tracing( gw.exit() def test_no_tracing_by_default(self): - assert gateway_base.trace == gateway_base.notrace, ( + assert _trace.trace == _trace.notrace, ( "trace does not to default to empty tracing" ) @@ -530,57 +578,24 @@ def test_no_tracing_by_default(self): ], ) def test_popen_args(spec: str, expected_args: list[str]) -> None: - expected_args = [*expected_args, "-u", "-c", gateway_io.popen_bootstrapline] - args = gateway_io.popen_args(execnet.XSpec(spec)) - assert args == expected_args - - -@pytest.mark.parametrize( - "interleave_getstatus", - [ - pytest.param(True, id="interleave-remote-status"), - pytest.param( - False, - id="no-interleave-remote-status", - marks=pytest.mark.xfail( - reason="https://github.com/pytest-dev/execnet/issues/123", - ), - ), - ], -) -def test_regression_gevent_hangs( - group: execnet.Group, interleave_getstatus: bool -) -> None: - pytest.importorskip("gevent") - gw = group.makegateway("popen//execmodel=gevent") + from execnet import _trio_gateway - print(gw.remote_status()) + args = _trio_gateway.popen_module_args(execnet.XSpec(spec + "//id=gw0")) + assert args[: len(expected_args)] == expected_args + assert args[len(expected_args) :][:4] == ["-u", "-m", "execnet", "worker"] - def sendback(channel) -> None: - channel.send(1234) - ch = gw.remote_exec(sendback) - if interleave_getstatus: - print(gw.remote_status()) - assert ch.receive(timeout=0.5) == 1234 - - -def test_assert_main_thread_only( - execmodel: gateway_base.ExecModel, makegateway: Callable[[str], Gateway] +def test_first_remote_exec_claims_the_main_thread( + makegateway: Callable[[str], Gateway], ) -> None: - if execmodel.backend != "main_thread_only": - pytest.skip("can only run with main_thread_only") - - gw = makegateway(f"execmodel={execmodel.backend}//popen") - + # The `thread` profile hands the first request the worker main thread + # -- the GUI/signal-safety property `main_thread_only` existed for. + # FIFO admission makes that one deterministic; a sequential *re*-exec + # races the claim release (see HybridExec), so only the first is + # asserted here. + gw = makegateway("profile=thread//popen") try: - # Submit multiple remote_exec requests in quick succession and - # assert that all tasks execute in the main thread. It is - # necessary to call receive on each channel before the next - # remote_exec call, since the channel will raise an error if - # concurrent remote_exec requests are submitted as in - # test_main_thread_only_concurrent_remote_exec_deadlock. - for i in range(10): + for _ in range(1): ch = gw.remote_exec( """ import time, threading @@ -588,7 +603,6 @@ def test_assert_main_thread_only( channel.send(threading.current_thread() is threading.main_thread()) """ ) - try: res = ch.receive() finally: @@ -604,45 +618,157 @@ def test_assert_main_thread_only( gw.join() -def test_main_thread_only_concurrent_remote_exec_deadlock( - execmodel: gateway_base.ExecModel, makegateway: Callable[[str], Gateway] +def test_main_thread_only_is_deprecated_and_overflows( + makegateway: Callable[[str], Gateway], ) -> None: - if execmodel.backend != "main_thread_only": - pytest.skip("can only run with main_thread_only") - - gw = makegateway(f"execmodel={execmodel.backend}//popen") + # `main_thread_only` now maps to `thread`. Where it used to refuse a + # second concurrent remote_exec with a deadlock error, the request now + # overflows to a pool thread -- the first one still gets the main + # thread, which is what the profile was for. + with pytest.warns(DeprecationWarning, match="main_thread_only"): + gw = makegateway("profile=main_thread_only//popen") channels = [] try: - # Submit multiple remote_exec requests in quick succession and - # assert that MAIN_THREAD_ONLY_DEADLOCK_TEXT is raised if - # concurrent remote_exec requests are submitted for the - # main_thread_only execmodel (as compensation for the lack of - # back pressure in remote_exec calls which do not attempt to - # block until the remote main thread is idle). - for i in range(2): + for _ in range(2): channels.append( gw.remote_exec( """ import threading channel.send(threading.current_thread() is threading.main_thread()) - # Wait forever, ensuring that the deadlock case triggers. - channel.gateway.execmodel.Event().wait() + channel.receive() """ ) ) - - expected_results = ( - True, - execnet.gateway_base.MAIN_THREAD_ONLY_DEADLOCK_TEXT, - ) - for expected, ch in zip(expected_results, channels, strict=True): - try: - res = ch.receive() - except execnet.RemoteError as e: - res = e.formatted - assert res == expected + # both run: first on the main thread, second on an overflow thread + assert [ch.receive(TESTTIMEOUT) for ch in channels] == [True, False] finally: for ch in channels: ch.close() gw.exit() gw.join() + + +class TestExecCapacity: + """A worker admits a bounded number of concurrent execs, and says so. + + Every placement costs a thread from trio's default limiter, and the + worker needs threads for channel callbacks and its own protocol work + too. The bound used to be that limiter alone: request 41 was admitted + and then waited for a slot only a finishing exec could free, which reads + exactly like a hung remote_exec on a channel nobody will ever answer. + """ + + def test_capacity_is_half_the_thread_budget(self) -> None: + import trio + + from execnet import _trio_worker + + async def measure() -> tuple[int, float]: + return ( + _trio_worker.exec_capacity(), + trio.to_thread.current_default_thread_limiter().total_tokens, + ) + + capacity, total = trio.run(measure) + assert capacity == max(1, int(total // 2)) + + def _worker_capacity(self, gw: Gateway) -> int: + # asked over the protocol: exec'd code runs on a thread, where the + # trio limiter the number comes from is not readable + capacity = gw.remote_status().execcapacity + assert isinstance(capacity, int) + return capacity + + def test_execs_up_to_capacity_all_run( + self, makegateway: Callable[[str], Gateway] + ) -> None: + gw = makegateway("popen") + channels = [] + try: + capacity = self._worker_capacity(gw) + for _ in range(capacity): + channels.append( + gw.remote_exec("channel.send('running'); channel.receive()") + ) + # every one of them is placed on a thread, not merely admitted + assert [ch.receive(TESTTIMEOUT) for ch in channels] == [ + "running" + ] * capacity + assert gw.remote_status().numexecuting == capacity + finally: + for ch in channels: + with suppress(OSError): + ch.send(None) + gw.exit() + gw.join() + + def test_one_exec_too_many_is_refused_not_hung( + self, makegateway: Callable[[str], Gateway] + ) -> None: + gw = makegateway("popen") + channels = [] + try: + capacity = self._worker_capacity(gw) + for _ in range(capacity): + channels.append( + gw.remote_exec("channel.send('running'); channel.receive()") + ) + for ch in channels: + assert ch.receive(TESTTIMEOUT) == "running" + # the one over the line is refused, promptly and with a reason + over = gw.remote_exec("channel.send('running')") + with pytest.raises(RemoteError, match="concurrency limit"): + over.receive(TESTTIMEOUT) + # and a slot is free the moment its close is observable: the + # release happens before that close goes out, so this sequence + # cannot be refused for a slot that is already gone + freed = channels.pop() + freed.send(None) + freed.waitclose(TESTTIMEOUT) + assert gw.remote_exec("channel.send(42)").receive(TESTTIMEOUT) == 42 + finally: + for ch in channels: + with suppress(OSError): + ch.send(None) + gw.exit() + gw.join() + + +def test_exec_task_contains_its_failure() -> None: + """An exec task must not let anything reach the worker's root nursery. + + ``executetask`` closes the channel when the source returns, and a + connection that went away first makes that raise. Escaping here ends + ``trio.run`` and prints an ExceptionGroup onto the user's stderr, which + is the worker's own since 3.0. + """ + import trio + + from execnet import _trio_worker + + class BoomStrategy: + needs_primary_thread = False + + async def admit(self, channel: object, item: object) -> bool: + return True + + async def run(self, channel: object, item: object) -> None: + raise OSError("cannot send (already closed?)") + + class DeadChannel: + id = 1 + + async def main() -> int: + pump = _trio_worker.TrioWorkerExec( + None, # type: ignore[arg-type] + gateway=None, # type: ignore[arg-type] + strategy=BoomStrategy(), + ) + pump._holding.add(DeadChannel.id) + pump._running = 1 + pump._idle.clear() + await pump._run_exec(DeadChannel(), ()) # type: ignore[arg-type] + return pump.active_count() + + # no exception escapes, and the slot is released either way + assert trio.run(main) == 0 diff --git a/testing/test_gevent.py b/testing/test_gevent.py new file mode 100644 index 00000000..42044343 --- /dev/null +++ b/testing/test_gevent.py @@ -0,0 +1,226 @@ +"""The execnet.gevent facade: greenlet-parking blocking waits. + +Opt-in: requires the ``gevent`` dependency group (``uv sync --group +gevent``); skipped when gevent is not installed. Monkey-patching is not +needed -- the wakener parks the waiting greenlet while the trio host +thread keeps running the protocol -- and is not supported either: the host +loop needs the real ``select``/``socket``/``thread``/``queue``, so these +tests run in an unpatched process and so must the facade. +""" + +from __future__ import annotations + +import threading +from contextlib import suppress + +import pytest + +gevent = pytest.importorskip("gevent") + +import execnet # noqa: E402 +import execnet.gevent # noqa: E402 +from execnet import _trio_engine # noqa: E402 +from execnet._boundary import Flag # noqa: E402 +from execnet._boundary import Mailbox # noqa: E402 +from execnet._boundary import make_wakener # noqa: E402 + +TESTTIMEOUT = 10.0 + + +@pytest.fixture +def gevent_gw(): + group = execnet.gevent.Group() + try: + yield group.makegateway("popen") + finally: + group.terminate(timeout=5.0) + + +class TestGeventWakener: + def test_mailbox_wakes_greenlet_from_foreign_thread(self) -> None: + box: Mailbox[str] = Mailbox(make_wakener("gevent")) + threading.Timer(0.05, box.put, args=["item"]).start() + result = gevent.spawn(box.get, 5.0) + assert result.get(timeout=TESTTIMEOUT) == "item" + + def test_notify_before_first_wait_is_not_lost(self) -> None: + flag = Flag(make_wakener("gevent")) + flag.set() + assert flag.wait(timeout=1.0) + + def test_wait_parks_greenlet_not_hub(self) -> None: + box: Mailbox[str] = Mailbox(make_wakener("gevent")) + progressed: list[int] = [] + + def other() -> None: + for i in range(5): + progressed.append(i) + gevent.sleep(0.01) + box.put("done") + + waiter = gevent.spawn(box.get, 5.0) + gevent.spawn(other) + # if get() blocked the hub, other() could never run and put() + assert waiter.get(timeout=TESTTIMEOUT) == "done" + assert progressed == [0, 1, 2, 3, 4] + + +class TestGeventGateway: + def test_receive_parks_greenlet_not_hub(self, gevent_gw: execnet.Gateway) -> None: + channel = gevent_gw.remote_exec("channel.send(channel.receive())") + progressed: list[int] = [] + + def other() -> None: + for i in range(5): + progressed.append(i) + gevent.sleep(0.01) + # sending from a greenlet blocks-until-written on the + # gevent wakener, parking only this greenlet + channel.send("hello") + + waiter = gevent.spawn(channel.receive, TESTTIMEOUT) + gevent.spawn(other) + # if receive() blocked the hub, other() could never send and + # the remote echo could never arrive -> this would hang + assert waiter.get(timeout=TESTTIMEOUT) == "hello" + assert progressed == [0, 1, 2, 3, 4] + + def test_waitclose_and_endmarker(self, gevent_gw: execnet.Gateway) -> None: + channel = gevent_gw.remote_exec("channel.send(1)") + assert gevent.spawn(channel.receive, TESTTIMEOUT).get(timeout=TESTTIMEOUT) == 1 + gevent.spawn(channel.waitclose, TESTTIMEOUT).get(timeout=TESTTIMEOUT) + + def test_makegateway_parks_greenlet_not_hub(self) -> None: + # management ops (makegateway/terminate) from a greenlet must not + # stall the hub: they wait on a OneShot with a gevent wakener. + group = execnet.gevent.Group() + progressed: list[int] = [] + + def other() -> None: + for i in range(5): + progressed.append(i) + gevent.sleep(0.01) + + try: + ticker = gevent.spawn(other) + maker = gevent.spawn(group.makegateway, "popen") + gw = maker.get(timeout=TESTTIMEOUT) + channel = gw.remote_exec("channel.send(42)") + assert gevent.spawn(channel.receive, TESTTIMEOUT).get(TESTTIMEOUT) == 42 + ticker.get(timeout=TESTTIMEOUT) + assert progressed == [0, 1, 2, 3, 4] + finally: + gevent.spawn(group.terminate, 5.0).get(timeout=TESTTIMEOUT) + + def test_no_management_op_takes_the_blocking_portal( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + # the deterministic half of the test above. TrioEngine.call waits + # for arbitrary engine-side work with the calling OS thread parked, + # which + # for a gevent caller is the hub and every greenlet on it -- so no + # facade path may take it. Easy to miss for the lazily started async + # group, whose wait is short enough that a timing test stays green. + # (A bounded scheduling hop -- portal.run_sync, for the setcallback + # switch -- is a different thing and stays allowed.) + def forbidden(self: object, async_fn: object, *args: object) -> None: + raise AssertionError("the gevent facade used the blocking portal.run") + + monkeypatch.setattr(_trio_engine.TrioEngine, "call", forbidden) + group = execnet.gevent.Group() + try: + gateway = gevent.spawn(group.makegateway, "popen").get(timeout=TESTTIMEOUT) + channel = gateway.remote_exec("channel.send(42)") + assert gevent.spawn(channel.receive, TESTTIMEOUT).get(TESTTIMEOUT) == 42 + finally: + gevent.spawn(group.terminate, 5.0).get(timeout=TESTTIMEOUT) + + +class TestGeventWorkerProfile: + """execmodel=gevent: exec'd code runs as greenlets on the main-thread hub.""" + + @pytest.fixture + def worker_gw(self): + group = execnet.Group() + try: + yield group.makegateway("popen//execmodel=gevent") + finally: + group.terminate(timeout=5.0) + + def test_execs_are_greenlets_on_main_thread(self, worker_gw) -> None: + report = """ + import threading + channel.send(threading.current_thread() is threading.main_thread()) + channel.receive() + """ + first = worker_gw.remote_exec(report) + second = worker_gw.remote_exec(report) + # both run concurrently on the one main thread: greenlets + assert first.receive(TESTTIMEOUT) is True + assert second.receive(TESTTIMEOUT) is True + first.send(None) + second.send(None) + first.waitclose(TESTTIMEOUT) + second.waitclose(TESTTIMEOUT) + + def test_execs_cooperate_via_gevent(self, worker_gw) -> None: + # the first exec parks in channel.receive() (gevent wakener) while + # the second completes -- with a blocked hub this would deadlock. + blocked = worker_gw.remote_exec("channel.send(channel.receive())") + side = worker_gw.remote_exec( + """ + import gevent + gevent.sleep(0.01) + channel.send('side') + """ + ) + assert side.receive(TESTTIMEOUT) == "side" + blocked.send("go") + assert blocked.receive(TESTTIMEOUT) == "go" + + def test_status_reports_gevent(self, worker_gw) -> None: + assert worker_gw.remote_status().execmodel == "gevent" + + def test_execs_are_not_rationed_against_the_thread_budget(self, worker_gw) -> None: + """Greenlets cost no thread, so nothing caps them at the thread limit. + + The exec-admission bound exists because a thread-shaped exec spends + a thread the callbacks and protocol work also need. A greenlet + spends none -- but waiting for one used to park a pool thread, so a + gevent worker was rationed to 20 concurrent execs, which is a cap on + exactly what the profile is for. + """ + import trio + + from execnet import _trio_worker + + async def thread_bound_capacity() -> int: + return _trio_worker.exec_capacity() + + assert worker_gw.remote_status().execcapacity is None + wanted = trio.run(thread_bound_capacity) + 5 + channels = [ + worker_gw.remote_exec("channel.send('go'); channel.receive()") + for _ in range(wanted) + ] + try: + assert [ch.receive(TESTTIMEOUT) for ch in channels] == ["go"] * wanted + finally: + for ch in channels: + with suppress(OSError): + ch.send(None) + + +def test_provisioning_adds_gevent_requirement() -> None: + from execnet import XSpec + from execnet._provision import _extra_with_tokens + from execnet._provision import worker_config + from execnet._provision import worker_profile + + spec = XSpec("popen//id=g1//execmodel=gevent") + assert worker_config(spec)["wait"] == "gevent" + # the one thing a launcher must know before the worker reads its own + # config: which environment to build + assert _extra_with_tokens(worker_profile(spec)) == ["--with", "gevent"] + plain = XSpec("popen//id=g2//execmodel=thread") + assert _extra_with_tokens(worker_profile(plain)) == [] diff --git a/testing/test_multi.py b/testing/test_multi.py index 12e0ed3d..80bff519 100644 --- a/testing/test_multi.py +++ b/testing/test_multi.py @@ -4,19 +4,18 @@ from __future__ import annotations -import gc +import time from collections.abc import Callable -from time import sleep import pytest import execnet +from execnet import Gateway +from execnet import Group from execnet import XSpec -from execnet.gateway import Gateway -from execnet.gateway_base import Channel -from execnet.gateway_base import ExecModel -from execnet.multi import Group -from execnet.multi import safe_terminate +from execnet import _provision +from execnet._channel import Channel +from execnet._execmodel import ExecModel class TestMultiChannelAndGateway: @@ -69,7 +68,7 @@ def test_Group_execmodel_setting(self) -> None: gm._gateways.append(1) # type: ignore[arg-type] try: with pytest.raises(ValueError): - gm.set_execmodel("eventlet") + gm.set_execmodel("main_thread_only") assert gm.execmodel.backend == "thread" finally: gm._gateways.pop() @@ -227,92 +226,58 @@ def fun(channel, arg) -> None: def test_terminate_with_proxying(self) -> None: group = Group() - group.makegateway("popen//id=master") - group.makegateway("popen//via=master//id=worker") + group.makegateway("popen//id=coordinator") + group.makegateway("popen//via=coordinator//id=worker") group.terminate(1.0) + @pytest.mark.skipif( + not _provision.provisioning_available(), + reason="a via sub-spec ships provisioning material eagerly", + ) + def test_via_foreign_python(self) -> None: + # A python= sub-spec through a via coordinator: it resolves the + # interpreter locally (this interpreter has execnet, so the sub runs + # the worker module directly, no uv provisioning). + import sys -@pytest.mark.xfail(reason="active_count() has been broken for some time") -def test_safe_terminate(execmodel: ExecModel) -> None: - if execmodel.backend not in ("thread", "main_thread_only"): - pytest.xfail( - "execution model %r does not support task count" % execmodel.backend - ) - import threading - - active = threading.active_count() - l = [] - - def term() -> None: - sleep(3) - - def kill() -> None: - l.append(1) - - safe_terminate(execmodel, 1, [(term, kill)] * 10) - assert len(l) == 10 - sleep(0.1) - gc.collect() - assert execmodel.active_count() == active # type: ignore[attr-defined] - - -@pytest.mark.xfail(reason="active_count() has been broken for some time") -def test_safe_terminate2(execmodel: ExecModel) -> None: - if execmodel.backend not in ("thread", "main_thread_only"): - pytest.xfail( - "execution model %r does not support task count" % execmodel.backend - ) - import threading - - active = threading.active_count() - l = [] - - def term() -> None: - return - - def kill() -> None: - l.append(1) - - safe_terminate(execmodel, 3, [(term, kill)] * 10) - assert len(l) == 0 - sleep(0.1) - gc.collect() - assert threading.active_count() == active + group = Group() + try: + group.makegateway("popen//id=coordinator") + gw = group.makegateway( + f"popen//python={sys.executable}//via=coordinator//id=sub" + ) + channel = gw.remote_exec("channel.send(channel.receive() + 1)") + channel.send(41) + assert channel.receive() == 42 + finally: + group.terminate(1.0) -@pytest.mark.timeout(5) -def test_safe_terminate_does_not_hang_when_kill_blocks( - execmodel: ExecModel, -) -> None: - """Regression for #43/#221: a stuck kill must not hang terminate forever. +@pytest.mark.timeout(30) +def test_terminate_kills_a_worker_that_will_not_go(execmodel: ExecModel) -> None: + """Regression for #43/#221: termination stays bounded by its timeout. - Before the fix, reply.get() waited without a timeout after termfunc timed - out, so a blocking killfunc made Group.terminate() hang indefinitely - (seen via pytest-xdist teardown). + The worker ignores SIGINT and never returns from its exec, so nothing + short of the kill ends it. ``terminate(timeout)`` must still come back + at roughly its own grace -- the bound used to be the thing that broke, + and it now lives in ``AsyncGroup._terminate_one`` rather than in the + retired ``safe_terminate`` helper. """ - kill_started = execmodel.Event() - release_kill = execmodel.Event() - other_killed: list[int] = [] - - def term_slow() -> None: - execmodel.sleep(10) - - def kill_hang() -> None: - kill_started.set() - release_kill.wait() - - def kill_ok() -> None: - other_killed.append(1) - - safe_terminate( - execmodel, - 0.2, - [ - (term_slow, kill_hang), - (term_slow, kill_ok), - ], + group = Group() + gw = group.makegateway("popen") + channel = gw.remote_exec( + """ + import signal, time + try: + signal.signal(signal.SIGINT, signal.SIG_IGN) + except ValueError: + pass # not the main thread: the pool thread ignores it too + channel.send("blocked") + while True: + time.sleep(0.1) + """ ) - - assert kill_started.is_set() - assert other_killed == [1] - release_kill.set() + assert channel.receive(timeout=10) == "blocked" + start = time.monotonic() + group.terminate(timeout=1.0) + assert time.monotonic() - start < 15 diff --git a/testing/test_namespaces.py b/testing/test_namespaces.py new file mode 100644 index 00000000..db3365f1 --- /dev/null +++ b/testing/test_namespaces.py @@ -0,0 +1,355 @@ +"""The public namespaces, and the deprecation shims for the private modules. + +There is one namespace per concurrency library the caller drives execnet +from: top-level ``execnet.*`` is an alias surface over ``execnet.sync`` +(threads), ``execnet.trio`` and ``execnet.aio`` await the same engine from +their own loops, ``execnet.gevent`` parks greenlets, and +``execnet.raw_trio`` embeds the core in the caller's own trio run with no +engine at all. Everything else in the package is private -- the pre-Trio +module names survive only as warning shims. + +The surface tables below are the point of this module. A namespace that +quietly loses a verb -- ``execnet.gevent`` shipped without ``Deployment`` +for a while, and nothing noticed -- is a hole a test should have closed, +and the facades' *deliberate* omissions are only credible if they are +written down somewhere that fails when they change. +""" + +from __future__ import annotations + +import importlib +import pkgutil +import subprocess +import sys +import warnings +from typing import Any + +import pytest + +import execnet +import execnet.aio +import execnet.raw_trio +import execnet.sync +import execnet.trio + +#: the only modules that may be reachable without a leading underscore +PUBLIC_NAMESPACES = ("aio", "gevent", "raw_trio", "sync", "trio") + +#: those importable without an optional dependency (execnet.gevent needs gevent) +ALWAYS_IMPORTABLE = tuple(n for n in PUBLIC_NAMESPACES if n != "gevent") + +#: pre-Trio module names kept as deprecated forwarding shims +SHIMS = ("gateway", "gateway_base", "multi", "rsync", "rsync_remote", "xspec") + + +def test_top_level_names_alias_execnet_sync() -> None: + for name in execnet.sync.__all__: + assert getattr(execnet, name) is getattr(execnet.sync, name), name + + +def test_top_level_all_matches_sync_surface() -> None: + # the top level is the sync surface plus the two package-level names: + # the version, and can_send (a wire-format fact, not a gateway API) + assert set(execnet.__all__) == set(execnet.sync.__all__) | { + "__version__", + "can_send", + } + + +@pytest.mark.parametrize( + "namespace", + [execnet, *[importlib.import_module(f"execnet.{n}") for n in ALWAYS_IMPORTABLE]], +) +def test_namespace_all_resolves(namespace: object) -> None: + missing = [n for n in namespace.__all__ if not hasattr(namespace, n)] # type: ignore[attr-defined] + assert not missing + + +def test_gevent_namespace_all_resolves() -> None: + pytest.importorskip("gevent") + namespace = importlib.import_module("execnet.gevent") + assert not [n for n in namespace.__all__ if not hasattr(namespace, n)] + # the facade is the sync surface with greenlet parking wired in + assert namespace.Group._wait_backend == "gevent" + assert issubclass(namespace.Group, execnet.Group) + + +def test_no_unexpected_public_modules() -> None: + found = { + info.name + for info in pkgutil.iter_modules(execnet.__path__) + if not info.name.startswith("_") + } + assert found == set(PUBLIC_NAMESPACES) | set(SHIMS) + + +def test_trio_namespace_exposes_async_core() -> None: + from execnet import _trio_gateway + + assert execnet.raw_trio.AsyncGroup is _trio_gateway.AsyncGroup + assert execnet.raw_trio.AsyncGateway is _trio_gateway.AsyncGateway + assert execnet.raw_trio.AsyncChannel is _trio_gateway.AsyncChannel + assert execnet.raw_trio.open_gateway is _trio_gateway.open_gateway + # error types are shared with the sync surface; the standalone serializer + # is intentionally not exposed on any public namespace + assert execnet.raw_trio.RemoteError is execnet.RemoteError + assert not hasattr(execnet.raw_trio, "dumps") + + +def test_trio_namespace_hides_raw_plumbing() -> None: + # the raw-channel/stream layer is internal routing detail: reachable from + # execnet._trio_gateway, not advertised on the public namespace + for name in ("ByteStream", "RawChannel", "RawChannelStream", "serve_gateway"): + assert name not in execnet.raw_trio.__all__, name + + +def test_can_send_lives_only_on_the_top_level() -> None: + assert execnet.can_send({"a": [1, 2.0, b"x", None, (True, frozenset({3}))]}) + assert not execnet.can_send(object()) + # the wire contract does not vary by surface, so it is not mirrored + for namespace in (execnet.sync, execnet.raw_trio, execnet.trio, execnet.aio): + assert "can_send" not in namespace.__all__, namespace.__name__ + + +def test_dumps_is_a_temporary_xdist_shim() -> None: + # FOLLOW-UP: delete this test together with execnet._XDIST_COMPAT once + # pytest-xdist stops probing with ``execnet.dumps`` / ``except DumpError`` + # and uses ``execnet.can_send`` instead. + from execnet import _serialize + + assert execnet._XDIST_COMPAT == ("dumps",) + assert execnet.dumps is _serialize.dumps + # reachable, but never advertised + assert "dumps" not in execnet.__all__ + assert "dumps" not in dir(execnet) + + +def test_dumps_shim_does_not_warn() -> None: + # xdist reaches this from serialize_warning_message -- once per warning + # a *user's* test raises, from inside pytest's warning-recording hook. + # A warning there is attributed to that test, which cannot act on it, + # and warning on every access made recording one warning record + # another, unbounded, wedging the run. + with warnings.catch_warnings(): + warnings.simplefilter("error") + for _ in range(3): + execnet.dumps # noqa: B018 + + +def test_boundary_kit_is_private() -> None: + # There is no third-party event-loop extension point: the two wait + # backends are threads and gevent, and every other concurrency library + # gets a facade instead of a wakener. + assert not hasattr(execnet, "portal") + for name in ("Wakener", "Mailbox", "OneShot", "LoopPortal"): + assert not hasattr(execnet, name), name + from execnet import _boundary + + assert not hasattr(_boundary, "register_wakener") + assert _boundary.make_wakener("thread") is not None + with pytest.raises(ValueError, match="unknown wait backend"): + _boundary.make_wakener("nope") # type: ignore[arg-type] + + +def test_lazy_submodule_attribute_access() -> None: + # After ``import execnet`` alone, execnet.raw_trio is reachable as an + # attribute (PEP 562) without having been imported. + out = subprocess.run( + [ + sys.executable, + "-c", + "import execnet; print(execnet.raw_trio.AsyncGroup.__name__)", + ], + capture_output=True, + text=True, + check=True, + ) + assert out.stdout.strip() == "AsyncGroup" + + +def test_import_execnet_does_not_import_trio() -> None: + # The blocking surface must stay importable without loading the trio + # event loop machinery (it loads lazily on first gateway / namespace + # use). + out = subprocess.run( + [ + sys.executable, + "-c", + "import sys, execnet; print('trio' in sys.modules)", + ], + capture_output=True, + text=True, + check=True, + ) + assert out.stdout.strip() == "False" + + +@pytest.mark.parametrize("shim", SHIMS) +def test_shim_reachable_as_package_attribute(shim: str) -> None: + # pytest-xdist reaches these as ``execnet.gateway_base.X`` after a plain + # ``import execnet``; that used to work because the import chain pulled + # them in, and must keep working for as long as the shims exist. + out = subprocess.run( + [sys.executable, "-c", f"import execnet; print(execnet.{shim}.__name__)"], + capture_output=True, + text=True, + check=True, + ) + assert out.stdout.strip() == f"execnet.{shim}" + + +def shim_attributes() -> list[tuple[str, str, str]]: + """``(shim, attribute, private module)`` for every forwarded name.""" + cases = [] + for shim in SHIMS: + module = importlib.import_module(f"execnet.{shim}") + for name, target in module._MOVED.items(): + cases.append((shim, name, target)) + return cases + + +@pytest.mark.parametrize(("shim", "name", "target"), shim_attributes(), ids=str) +def test_shim_warns_and_forwards(shim: str, name: str, target: str) -> None: + module = importlib.import_module(f"execnet.{shim}") + private = importlib.import_module(f"execnet{target}") + if not hasattr(private, name): + # ``trace``/``notrace``/``fn`` exist only under a given EXECNET_DEBUG + pytest.skip(f"execnet{target}.{name} not defined in this configuration") + with pytest.warns(DeprecationWarning, match=f"execnet.{shim} is private"): + value = getattr(module, name) + assert value is getattr(private, name) + + +@pytest.mark.parametrize("shim", SHIMS) +def test_shim_rejects_unknown_attribute(shim: str) -> None: + module = importlib.import_module(f"execnet.{shim}") + with warnings.catch_warnings(): + warnings.simplefilter("error", DeprecationWarning) + with pytest.raises(AttributeError, match="no attribute 'nonexistent'"): + getattr(module, "nonexistent") # noqa: B009 + + +#: the verbs every namespace that talks to a worker must offer. Kept as +#: data because the failure mode is a namespace silently missing one, not a +#: namespace getting one wrong. +COMMON_NAMES = ( + "ChannelClosed", + "DataFormatError", + "DumpError", + "ExecnetStateError", + "GatewayGone", + "HostNotFound", + "LoadError", + "RemoteError", + "TimeoutError", + "XSpec", +) + +#: the deployment layer, which reaches a worker through a service and is +#: therefore available from every surface that can hold a gateway +DEPLOYMENT_NAMES = ("Deployed", "Deployment", "transfer") + +#: surfaces that put protocol IO on a ProtocolEngine, and so expose it +ENGINE_NAMES = ("ActiveGroupsWarning", "ProtocolEngine") + + +def _namespace(name: str) -> Any: + """Import a public namespace, skipping the one with a hard dependency.""" + if name == "gevent": + pytest.importorskip("gevent") + return importlib.import_module(f"execnet.{name}") + + +@pytest.mark.parametrize("name", COMMON_NAMES) +@pytest.mark.parametrize("namespace", PUBLIC_NAMESPACES) +def test_every_namespace_exports_the_common_names(namespace: str, name: str) -> None: + module = _namespace(namespace) + assert name in module.__all__, f"execnet.{namespace} is missing {name}" + + +@pytest.mark.parametrize("name", DEPLOYMENT_NAMES) +@pytest.mark.parametrize("namespace", PUBLIC_NAMESPACES) +def test_every_namespace_can_deploy(namespace: str, name: str) -> None: + # execnet.gevent shipped without these while _deploy._facade already had + # a gevent parking path: the plumbing was there and the names were not. + # The async namespaces bind ``transfer`` to their own coroutine rather + # than the blocking one, which is the same verb either way. + module = _namespace(namespace) + assert name in module.__all__, f"execnet.{namespace} is missing {name}" + + +@pytest.mark.parametrize("name", ENGINE_NAMES) +@pytest.mark.parametrize("namespace", ["sync", "gevent", "aio", "trio"]) +def test_engine_backed_namespaces_expose_the_engine(namespace: str, name: str) -> None: + module = _namespace(namespace) + assert name in module.__all__, f"execnet.{namespace} is missing {name}" + + +def test_raw_trio_has_no_engine() -> None: + # it does not have one: the gateways are tasks in the caller's nursery + for name in ENGINE_NAMES: + assert name not in execnet.raw_trio.__all__, name + + +#: the facades' public member sets, pinned. Adding to these is a decision; +#: the point of writing them down is that it cannot happen by accident. +FACADE_SURFACE = { + "AsyncGroup": { + "aclose", + "engine", + "makegateway", + "start", + }, + "AsyncGateway": { + "id", + "remote_exec", + "remoteaddress", + "terminate", + }, + "AsyncChannel": { + "aclose", + "id", + "isclosed", + "receive", + "send", + "send_eof", + "wait_closed", + }, +} + +#: what the raw surface has and a facade deliberately does not. Channel +#: ids come from an unlocked per-gateway counter that works only because +#: one loop owns it, so a second allocator across the bridge would collide. +RAW_ONLY = { + "AsyncGateway": {"_open_raw_channel", "open_channel", "_enqueue_frame"}, +} + + +def _public_members(obj: type) -> set[str]: + return { + name + for name in dir(obj) + if not name.startswith("_") and not isinstance(getattr(obj, name, None), type) + } + + +@pytest.mark.parametrize("namespace", ["aio", "trio"]) +@pytest.mark.parametrize("classname", sorted(FACADE_SURFACE)) +def test_the_facades_have_the_same_public_surface( + namespace: str, classname: str +) -> None: + module = _namespace(namespace) + assert _public_members(getattr(module, classname)) == FACADE_SURFACE[classname] + + +@pytest.mark.parametrize("namespace", ["aio", "trio"]) +@pytest.mark.parametrize("classname", sorted(RAW_ONLY)) +def test_the_facades_omit_what_does_not_cross_the_bridge( + namespace: str, classname: str +) -> None: + module = _namespace(namespace) + facade = getattr(module, classname) + raw = getattr(execnet.raw_trio, classname) + for name in RAW_ONLY[classname]: + assert hasattr(raw, name), f"raw_trio.{classname} lost {name}" + assert not hasattr(facade, name), f"execnet.{namespace}.{classname} has {name}" diff --git a/testing/test_provision.py b/testing/test_provision.py new file mode 100644 index 00000000..f619387c --- /dev/null +++ b/testing/test_provision.py @@ -0,0 +1,169 @@ +"""Unit tests for coordinator-side uv worker provisioning.""" + +from __future__ import annotations + +import re + +import pytest + +import execnet +from execnet import _provision + +released = re.fullmatch(r"\d+\.\d+\.\d+", execnet.__version__) is not None + + +def test_worker_config_carries_what_the_worker_needs() -> None: + spec = execnet.XSpec("popen//id=gw5//execmodel=thread//env:TOKEN=s3cr3t") + config = _provision.worker_config(spec) + assert config["id"] == "gw5-worker" + assert config["execmodel"] == "thread" + assert config["coordinator_version"] == execnet.__version__ + # it goes on the wire, never in an argv -- see execnet._handshake + assert config["env"] == {"TOKEN": "s3cr3t"} + + +def test_ssh_remote_command_released(monkeypatch: pytest.MonkeyPatch) -> None: + # the index path specifically -- an explicit wheel would override it + monkeypatch.delenv(_provision.PROVISION_WHEEL_ENV, raising=False) + monkeypatch.setattr(execnet, "__version__", "9.9.9") + spec = execnet.XSpec("ssh=host//id=gw0//execmodel=thread") + command = _provision.ssh_remote_command(spec) + assert "execnet==9.9.9" in command + assert "head -c" not in command # nothing is framed into the launch + + +@pytest.mark.skipif(released, reason="released execnet resolves from an index") +@pytest.mark.skipif(not _provision.uv_available(), reason="uv required to build wheel") +@pytest.mark.skipif( + not _provision.provisioning_available(), + reason="a dev execnet installed without its source tree cannot build a wheel", +) +def test_ssh_remote_command_dev_uses_a_delivered_wheel() -> None: + # The wheel travels out of band now (its own connection, before the + # launch), so the launch command just points uv at where it landed -- + # no byte accounting, no `exec` to keep an fd alive. + spec = execnet.XSpec("ssh=host//id=gw0//execmodel=thread") + command = _provision.ssh_remote_command(spec) + wheel = _provision.ssh_wheel(spec) + assert wheel is not None + assert wheel.read_bytes()[:2] == b"PK" # a wheel is a zip archive + assert _provision.remote_wheel_path(wheel) in command + assert "head -c" not in command + assert "mktemp -d" not in command + + +@pytest.mark.skipif(released, reason="released execnet resolves from an index") +@pytest.mark.skipif(not _provision.uv_available(), reason="uv required to build wheel") +@pytest.mark.skipif( + not _provision.provisioning_available(), + reason="a dev execnet installed without its source tree cannot build a wheel", +) +def test_wheel_delivery_command_expands_home_and_drains_stdin() -> None: + wheel = _provision.ssh_wheel(execnet.XSpec("ssh=host//id=gw0")) + assert wheel is not None + command = _provision.wheel_delivery_command(wheel) + # $HOME must stay expandable: quoting it as a literal would create a + # directory actually named "~" + assert '"$HOME"' in command + assert "~" not in command + # the coordinator always streams the wheel, so the cached branch has + # to consume stdin too or it hands the coordinator an EPIPE + assert "cat > /dev/null" in command + + +def test_vagrant_ssh_argv() -> None: + argv = _provision.vagrant_ssh_argv("default", None, "run-worker") + assert argv == ["vagrant", "ssh", "default", "--", "-C", "run-worker"] + argv = _provision.vagrant_ssh_argv("default", "/tmp/cfg", "run-worker") + assert argv == [ + "vagrant", + "ssh", + "default", + "--", + "-C", + "-F", + "/tmp/cfg", + "run-worker", + ] + + +def test_sub_spawn_argv_plain_popen() -> None: + import sys + + # no config: the sub is configured by a frame from the coordinator that + # asked for it, which never passes through this intermediary + argv, delivery = _provision.sub_spawn_argv({"profile": "thread"}) + assert argv == [ + sys.executable, + "-u", + "-m", + "execnet", + "worker", + ] + assert delivery is None + + +class TestExplicitWheel: + """``EXECNET_PROVISION_WHEEL`` names the wheel remotes are provisioned from.""" + + @pytest.fixture + def wheel(self, tmp_path, monkeypatch: pytest.MonkeyPatch): + wheel = tmp_path / "execnet-9.9.9-py3-none-any.whl" + wheel.write_bytes(b"PK\x03\x04not-really-a-wheel") + monkeypatch.setenv(_provision.PROVISION_WHEEL_ENV, str(wheel)) + return wheel + + def test_wins_over_the_index_for_a_released_version( + self, wheel, monkeypatch: pytest.MonkeyPatch + ) -> None: + # a released coordinator would otherwise resolve execnet==X.Y.Z, which + # is the wrong artifact when the point is to test *this* build + monkeypatch.setattr(execnet, "__version__", "9.9.9") + assert _provision.provisioning_wheel() == wheel + assert _provision.coordinator_requirement() == str(wheel) + command = _provision.ssh_remote_command(execnet.XSpec("ssh=host//id=gw0")) + assert "execnet==9.9.9" not in command + assert _provision.remote_wheel_path(wheel) in command + + def test_makes_provisioning_available_without_a_source_tree( + self, wheel, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr(execnet, "__version__", "9.9.9.dev1+gdeadbee") + monkeypatch.setattr(_provision, "_editable_source_root", lambda: None) + assert _provision.provisioning_available() + + def test_ships_those_bytes_to_a_via_coordinator( + self, wheel, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr(execnet, "__version__", "9.9.9") + request = _provision.spawn_request(execnet.XSpec("ssh=host//id=gw0")) + assert request["wheel"] == (wheel.name, wheel.read_bytes()) + assert "requirement" not in request + + @pytest.mark.parametrize( + ("name", "exists"), + [("execnet-9.9.9-py3-none-any.whl", False), ("execnet.tar.gz", True)], + ) + def test_rejects_what_it_cannot_provision_from( + self, name: str, exists: bool, tmp_path, monkeypatch: pytest.MonkeyPatch + ) -> None: + # falling back to a build here would provision something other than + # what the caller asked for -- silently testing the wrong thing + path = tmp_path / name + if exists: + path.touch() + monkeypatch.setenv(_provision.PROVISION_WHEEL_ENV, str(path)) + with pytest.raises(RuntimeError, match=_provision.PROVISION_WHEEL_ENV): + _provision.provisioning_wheel() + + +def test_sub_spawn_argv_vagrant_released() -> None: + request = { + "profile": "thread", + "vagrant_ssh": "default", + "requirement": "execnet==9.9.9", + } + argv, delivery = _provision.sub_spawn_argv(request) + assert argv[:5] == ["vagrant", "ssh", "default", "--", "-C"] + assert "execnet==9.9.9" in argv[-1] + assert delivery is None diff --git a/testing/test_rsync.py b/testing/test_rsync.py index 2112439f..d01334b2 100644 --- a/testing/test_rsync.py +++ b/testing/test_rsync.py @@ -7,8 +7,8 @@ import pytest import execnet +from execnet import Gateway from execnet import RSync -from execnet.gateway import Gateway @pytest.fixture(scope="module") @@ -304,3 +304,131 @@ def filter(self, x: str) -> bool: assert rsync.x == 1 assert len(list(dest.iterdir())) == 1 assert len(list(source.iterdir())) == 1 + + +class TestRsyncIsAProtocolService: + """rsync is served by the worker, not exec'd into it. + + Before 3.0 an rsync target was a ``remote_exec`` of the receiver's + source: the last thing execnet shipped its own code over the wire to + do, and one that spent an exec slot on infrastructure. + """ + + def test_it_works_against_a_worker_that_refuses_sync_sources( + self, dirs: _dirs, group: execnet.Group + ) -> None: + # profile=trio runs exec'd sources as tasks and rejects sync ones, + # so the old source-shipping receiver could not run there at all + gateway = group.makegateway("popen//id=trio-rsync//profile=trio") + (dirs.source / "hello.txt").write_text("hi") + rsync = RSync(dirs.source, verbose=False) + rsync.add_target(gateway, dirs.dest1) + rsync.send() + assert (dirs.dest1 / "hello.txt").read_text() == "hi" + + def test_it_claims_no_exec_slot(self, dirs: _dirs, gw1: Gateway) -> None: + # infrastructure must not compete with the work a worker is for + (dirs.source / "hello.txt").write_text("hi") + rsync = RSync(dirs.source, verbose=False) + rsync.add_target(gw1, dirs.dest1) + rsync.send() + assert gw1.remote_status().numexecuting == 0 + + def test_a_failing_rsync_reports_on_its_channel( + self, dirs: _dirs, gw1: Gateway + ) -> None: + # and does not take the worker down with it: the receiver is a task + # on the worker's root nursery + rsync = RSync(dirs.source, verbose=False) + rsync.add_target(gw1, dirs.dest1 / "nested" / "\0bad") + with pytest.raises(execnet.RemoteError): + rsync.send() + assert gw1.remote_exec("channel.send(1)").receive() == 1 + + +class TestXdistContract: + """What pytest-xdist actually does to RSync, as a local tripwire. + + ``xdist.workermanage.HostRSync`` subclasses ``execnet.RSync``, overrides + ``filter`` and the *private* ``_report_send_file``, reads ``_sourcedir`` + and ``_verbose``, and calls ``add_target(gateway, relative_path, + finishedcallback=..., delete=True)``. Its own suite is the real + tripwire and only runs in CI; this is the shape of it, here, so a + reimplementation finds out before CI does. + """ + + class HostRSyncLike(RSync): + """A stand-in for xdist's subclass, doing what it does.""" + + def __init__(self, sourcedir, *, ignores=(), verbose=True) -> None: + self._ignores = [str(item) for item in ignores] + super().__init__(sourcedir=pathlib.Path(sourcedir), verbose=verbose) + self.reported: list[str] = [] + + def filter(self, path) -> bool: + name = pathlib.Path(path).name + return name not in self._ignores + + def add_target_host(self, gateway, finished=None) -> None: + remotepath = os.path.basename(self._sourcedir) + super().add_target( + gateway, remotepath, finishedcallback=finished, delete=True + ) + + def _report_send_file(self, gateway, modified_rel_path) -> None: + # xdist reads gateway.spec.chdir here -- so this must be handed + # the sync Gateway facade, not anything from the async core + if self._verbose > 0: + path = os.path.basename(self._sourcedir) + "/" + modified_rel_path + self.reported.append(f"{gateway.spec}:{gateway.spec.chdir} <= {path}") + + def test_the_xdist_shape_works(self, dirs: _dirs, tmp_path) -> None: + source = dirs.source + source.joinpath("keep.txt").write_text("keep") + source.joinpath("skip.pyc").write_text("skip") + source.joinpath("sub").mkdir() + source.joinpath("sub", "nested.txt").write_text("nested") + + # a relative destination, resolved against the worker's chdir -- + # which is how xdist places a synced root + workdir = tmp_path / "remote-cwd" + workdir.mkdir() + group = execnet.Group() + try: + gateway = group.makegateway(f"popen//chdir={workdir}") + finished: list[bool] = [] + rsync = self.HostRSyncLike(source, ignores=["skip.pyc"]) + rsync.add_target_host(gateway, finished=lambda: finished.append(True)) + rsync.send() + + landed = workdir / source.name + assert (landed / "keep.txt").read_text() == "keep" + assert (landed / "sub" / "nested.txt").read_text() == "nested" + assert not (landed / "skip.pyc").exists() + assert finished == [True] + assert any("keep.txt" in line for line in rsync.reported) + assert all("skip.pyc" not in line for line in rsync.reported) + finally: + group.terminate(timeout=30.0) + + def test_delete_prunes_the_remote_root(self, dirs: _dirs, tmp_path) -> None: + # xdist passes delete=True: a file removed locally must go remotely + source = dirs.source + source.joinpath("gone.txt").write_text("here for now") + workdir = tmp_path / "remote-cwd" + workdir.mkdir() + group = execnet.Group() + try: + gateway = group.makegateway(f"popen//chdir={workdir}") + rsync = self.HostRSyncLike(source, verbose=False) + rsync.add_target_host(gateway) + rsync.send() + assert (workdir / source.name / "gone.txt").exists() + + source.joinpath("gone.txt").unlink() + rsync = self.HostRSyncLike(source, verbose=False) + rsync.add_target_host(gateway) + rsync.send() + assert not (workdir / source.name / "gone.txt").exists() + finally: + group.terminate(timeout=30.0) diff --git a/testing/test_serializer.py b/testing/test_serializer.py index 06a84cd2..ae88f80d 100644 --- a/testing/test_serializer.py +++ b/testing/test_serializer.py @@ -9,8 +9,9 @@ import execnet -# We use the execnet folder in order to avoid triggering a missing apipkg. -pyimportdir = os.fspath(Path(execnet.__file__).parent) +# The package parent: the serializer is imported as execnet._serialize +# (it needs its sibling execnet._errors; only stdlib beyond that). +pyimportdir = os.fspath(Path(execnet.__file__).parent.parent) class PythonWrapper: @@ -24,7 +25,7 @@ def dump(self, obj_rep: str) -> bytes: f""" import sys sys.path.insert(0, {pyimportdir!r}) -import gateway_base as serializer +import execnet._serialize as serializer sys.stdout = sys.stdout.detach() sys.stdout.write(serializer.dumps_internal({obj_rep})) """ @@ -40,7 +41,7 @@ def load(self, data: bytes) -> list[str]: rf""" import sys sys.path.insert(0, {pyimportdir!r}) -import gateway_base as serializer +import execnet._serialize as serializer from io import BytesIO data = {data!r} io = BytesIO(data) @@ -125,6 +126,26 @@ def test_long(load, dump) -> None: assert v == really_big +@pytest.mark.parametrize( + "value", + [ + "2147483647", # int4 max: short path + "-2147483648", # int4 min: short path + "2147483648", # just over max: long path + "-2147483649", # just under min: long path (used to crash in struct.pack) + "9223372036854775807324234", + "-9223372036854775807324234", + ], +) +def test_int_boundaries(value, dump, load) -> None: + # regression: negative ints below the signed int4 minimum must take the + # arbitrary-precision long path instead of overflowing the 4-byte pack. + p = dump(value) + tp, v = load(p) + assert tp == "int" + assert v == value + + def test_bytes(dump, load) -> None: p = dump("b'hi'") tp, v = load(p) @@ -166,6 +187,7 @@ def test_tuple_nested_with_empty_in_between(dump, load) -> None: assert s == "(1, (), 3)" -def test_py2_string_loads() -> None: - """Regression test for #267.""" - assert execnet.loads(b"\x02M\x00\x00\x00\x01aQ") == b"a" +def test_py2_string_opcode_is_retired() -> None: + """The py2 ``str`` opcode ``M`` is gone; only Python2 ever emitted it.""" + with pytest.raises(execnet.DataFormatError, match="unknown opcode"): + execnet._serialize.loads(b"\x02M\x00\x00\x00\x01aQ") diff --git a/testing/test_socketserver_cli.py b/testing/test_socketserver_cli.py new file mode 100644 index 00000000..279501b0 --- /dev/null +++ b/testing/test_socketserver_cli.py @@ -0,0 +1,90 @@ +"""Test the ``execnet-socketserver`` console entry point end to end. + +The Trio socketserver binds a port and spawns a ``python -m execnet._trio_worker`` +subprocess per connection (no inline code execution); the coordinator connects +over a Trio TCP stream. +""" + +from __future__ import annotations + +import shutil +import subprocess +from collections.abc import Iterator + +import pytest + +import execnet +from execnet import _provision + +SERVER = shutil.which("execnet-socketserver") + +pytestmark = [ + pytest.mark.skipif( + SERVER is None, reason="execnet-socketserver console script not installed" + ), + pytest.mark.skipif( + not _provision.socket_handoff_available(), + reason="the server must hand the accepted socket to a worker process", + ), +] + + +@pytest.fixture +def socketserver_port() -> Iterator[int]: + assert SERVER is not None + proc = subprocess.Popen( + [SERVER, ":0"], # ephemeral port; it prints the one it bound + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + try: + assert proc.stdout is not None + port = None + while True: + line = proc.stdout.readline() + if not line: + pytest.fail("execnet-socketserver exited before binding") + if "listening on" in line: + port = int(line.split()[-1]) + break + yield port + finally: + proc.kill() + proc.wait(timeout=5) + + +def test_socketserver_cli_roundtrip(socketserver_port: int) -> None: + group = execnet.Group() + try: + gw = group.makegateway(f"socket=127.0.0.1:{socketserver_port}//id=sock") + channel = gw.remote_exec("channel.send(channel.receive() + 1)") + channel.send(41) + assert channel.receive() == 42 + finally: + group.terminate(timeout=5.0) + + +def test_ephemeral_port_is_the_same_for_every_address_family() -> None: + """One reported port has to be *the* port. + + A wildcard bind with port 0 gives each address family its own random + port, and only the first is reported -- so a client dialling the other + family finds nothing there. Which family comes first is + platform-dependent (IPv4 on Linux, IPv6 on Windows), so this passed by + luck here while failing there. + """ + import trio + + from execnet import _socketserver + + async def main() -> set[int]: + listeners = await trio.open_tcp_listeners(0, host=None) + listeners = await _socketserver._one_port(listeners, None) + try: + return {l.socket.getsockname()[1] for l in listeners} + finally: + for l in listeners: + await l.aclose() + + assert len(trio.run(main)) == 1 diff --git a/testing/test_ssh_local.py b/testing/test_ssh_local.py new file mode 100644 index 00000000..8c37155f --- /dev/null +++ b/testing/test_ssh_local.py @@ -0,0 +1,200 @@ +"""Local ssh-connect tests backed by an in-process asyncssh server. + +The coordinator shells out to the system ``ssh`` client, which connects to an +asyncssh server running in its own asyncio-loop thread; the server runs each +requested command as a subprocess with binary-safe stdio passthrough (the +execnet Message protocol needs raw bytes). +""" + +from __future__ import annotations + +import asyncio +import shutil +import sys +import threading +from collections.abc import Iterator +from pathlib import Path +from typing import TYPE_CHECKING + +import pytest + +import execnet +from execnet import _provision + +if TYPE_CHECKING: + import asyncssh +else: + # a module-level `import asyncssh` would be a collection *error* in an + # environment without it, not a skip + asyncssh = pytest.importorskip("asyncssh") + +pytestmark = [ + pytest.mark.skipif( + shutil.which("ssh") is None, reason="system ssh client required" + ), + # the harness runs each remote command through a POSIX shell, and the + # commands execnet builds are POSIX sh + pytest.mark.skipif( + sys.platform.startswith("win"), reason="POSIX-shell remote harness" + ), + pytest.mark.skipif( + not _provision.provisioning_available(), + reason="a dev execnet installed without its source tree cannot build" + " a wheel to provision the remote with", + ), +] + +# Committed, intentionally-insecure test keys (see sshkeys/README.md). +SSHKEYS = Path(__file__).parent / "sshkeys" +HOST_KEY = SSHKEYS / "insecure_host_ed25519" +CLIENT_KEY = SSHKEYS / "insecure_client_ed25519" +CLIENT_PUBKEY = SSHKEYS / "insecure_client_ed25519.pub" + + +class _ForwardingSSHServer(asyncssh.SSHServer): # type: ignore[misc] + """Accepts ``-R`` unix-socket forwards, which the socket transport needs. + + execnet's ssh worker dials back to the coordinator over a unix socket + that ``ssh -R`` forwards; asyncssh refuses such requests unless the + server opts in, and returning True asks it to do the standard + forwarding. + """ + + def unix_server_requested(self, listen_path: str) -> bool: + return True + + +class SSHServerThread: + """asyncssh server on an ephemeral port, driven from its own asyncio thread.""" + + def __init__(self, client_key_path: str) -> None: + # OpenSSH refuses a world-readable private key; git does not preserve + # 0600, so the caller hands us a temp copy already chmod'd 0600. + self.client_key_path = client_key_path + self._loop: asyncio.AbstractEventLoop | None = None + self._server: asyncssh.SSHAcceptor | None = None + self.port: int | None = None + self._ready = threading.Event() + self._thread = threading.Thread(target=self._run, daemon=True) + + async def _handle(self, process: asyncssh.SSHServerProcess) -> None: + proc = await asyncio.create_subprocess_shell( + process.command or "", + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + await process.redirect(stdin=proc.stdin, stdout=proc.stdout, stderr=proc.stderr) + process.exit(await proc.wait()) + # Drain asyncssh's redirect cleanup coroutines (Queue.join et al): + # they are stored un-awaited and only run inside wait_closed(); + # skipping this leaks them and GC prints RuntimeWarnings. + await process.wait_closed() + + async def _serve(self) -> None: + self._server = await asyncssh.listen( + "127.0.0.1", + 0, + server_host_keys=[str(HOST_KEY)], + authorized_client_keys=str(CLIENT_PUBKEY), + server_factory=_ForwardingSSHServer, + process_factory=self._handle, + encoding=None, # binary stdio + ) + self.port = self._server.get_port() + self._ready.set() + await self._server.wait_closed() + + def _run(self) -> None: + self._loop = asyncio.new_event_loop() + asyncio.set_event_loop(self._loop) + try: + self._loop.run_until_complete(self._serve()) + finally: + # Drain asyncssh's shutdown coroutines so GC does not surface an + # "un-awaited coroutine" RuntimeWarning. + pending = asyncio.all_tasks(self._loop) + for task in pending: + task.cancel() + self._loop.run_until_complete( + asyncio.gather(*pending, return_exceptions=True) + ) + self._loop.run_until_complete(self._loop.shutdown_asyncgens()) + self._loop.close() + + def start(self) -> None: + self._thread.start() + assert self._ready.wait(timeout=10), "ssh server did not start" + + def stop(self) -> None: + if self._loop is not None and self._server is not None: + self._loop.call_soon_threadsafe(self._server.close) + self._thread.join(timeout=5) + + def write_ssh_config(self, path: str) -> None: + """Write an ssh config with a ``testhost`` alias pointing at this server.""" + with open(path, "w") as f: + f.write( + "Host testhost\n" + " HostName 127.0.0.1\n" + f" Port {self.port}\n" + " User testuser\n" + f" IdentityFile {self.client_key_path}\n" + " IdentitiesOnly yes\n" + " StrictHostKeyChecking no\n" + " UserKnownHostsFile /dev/null\n" + " LogLevel ERROR\n" + ) + + +@pytest.fixture +def ssh_server(tmp_path) -> Iterator[SSHServerThread]: + # OpenSSH rejects the committed key's checkout permissions; use a 0600 copy. + client_key = tmp_path / "client_ed25519" + client_key.write_bytes(CLIENT_KEY.read_bytes()) + client_key.chmod(0o600) + server = SSHServerThread(str(client_key)) + server.start() + yield server + server.stop() + + +@pytest.fixture +def ssh_config(ssh_server: SSHServerThread, tmp_path) -> str: + path = str(tmp_path / "ssh_config") + ssh_server.write_ssh_config(path) + return path + + +def test_ssh_roundtrip(ssh_config: str) -> None: + # The worker is provisioned over ssh with uv. + group = execnet.Group() + try: + gw = group.makegateway( + f"ssh=testhost//ssh_config={ssh_config}//python={sys.executable}//id=ssh" + ) + channel = gw.remote_exec("channel.send(channel.receive() + 1)") + channel.send(41) + assert channel.receive() == 42 + finally: + group.terminate(timeout=5.0) + + +def test_ssh_via_roundtrip(ssh_config: str) -> None: + """An ssh sub-gateway spawned by a popen coordinator (GATEWAY_START_SUB relay). + + That coordinator runs the ssh client; for a dev build the wheel travels + from here into the spawn request, and on to the remote over ssh stdin. + """ + group = execnet.Group() + try: + group.makegateway("popen//id=coordinator") + gw = group.makegateway( + f"ssh=testhost//ssh_config={ssh_config}//python={sys.executable}" + "//via=coordinator//id=sshvia" + ) + channel = gw.remote_exec("channel.send(channel.receive() + 1)") + channel.send(41) + assert channel.receive() == 42 + finally: + group.terminate(timeout=5.0) diff --git a/testing/test_termination.py b/testing/test_termination.py index ca119304..4cb1406b 100644 --- a/testing/test_termination.py +++ b/testing/test_termination.py @@ -1,18 +1,19 @@ import os import pathlib +import queue import shutil import signal import subprocess import sys from collections.abc import Callable +from concurrent.futures import ThreadPoolExecutor import pytest from test_gateway import TESTTIMEOUT import execnet -from execnet.gateway import Gateway -from execnet.gateway_base import ExecModel -from execnet.gateway_base import WorkerPool +from execnet import Gateway +from execnet._execmodel import ExecModel execnetdir = pathlib.Path(execnet.__file__).parent.parent @@ -23,7 +24,7 @@ def test_exit_blocked_worker_execution_gateway( - anypython: str, makegateway: Callable[[str], Gateway], pool: WorkerPool + anypython: str, makegateway: Callable[[str], Gateway], executor: ThreadPoolExecutor ) -> None: gateway = makegateway("popen//python=%s" % anypython) gateway.remote_exec( @@ -37,8 +38,7 @@ def doit() -> int: gateway.exit() return 17 - reply = pool.spawn(doit) - x = reply.get(timeout=5.0) + x = executor.submit(doit).result(timeout=5.0) assert x == 17 @@ -48,7 +48,7 @@ def test_endmarker_delivery_on_remote_killterm( if execmodel.backend not in ("thread", "main_thread_only"): pytest.xfail("test and execnet not compatible to greenlets yet") gw = makegateway("popen") - q = execmodel.queue.Queue() + q: queue.Queue[object] = queue.Queue() channel = gw.remote_exec( source=""" import os, time @@ -60,7 +60,7 @@ def test_endmarker_delivery_on_remote_killterm( assert isinstance(pid, int) os.kill(pid, signal.SIGTERM) channel.setcallback(q.put, endmarker=999) - val = q.get(TESTTIMEOUT) + val = q.get(timeout=TESTTIMEOUT) assert val == 999 err = channel._getremoteerror() assert isinstance(err, EOFError) @@ -99,8 +99,17 @@ def test_close_initiating_remote_no_error( execnet.default_group.terminate() """ ) + # This asserts execnet's own teardown prints nothing. tox sets + # PYTHONWARNDEFAULTENCODING to catch *our* encoding bugs, but it also + # makes trio's subprocess module emit an EncodingWarning we cannot fix + # and that is not what this test guards. + env = dict(os.environ) + env.pop("PYTHONWARNDEFAULTENCODING", None) popen = subprocess.Popen( - [anypython, str(p), str(execnetdir)], stdout=None, stderr=subprocess.PIPE + [anypython, str(p), str(execnetdir)], + stdout=None, + stderr=subprocess.PIPE, + env=env, ) _out, err = popen.communicate() print(err) @@ -113,10 +122,8 @@ def test_terminate_implicit_does_trykill( pytester: pytest.Pytester, anypython: str, capfd: pytest.CaptureFixture[str], - pool: WorkerPool, + executor: ThreadPoolExecutor, ) -> None: - if pool.execmodel.backend not in ("thread", "main_thread_only"): - pytest.xfail("only os threading model supported") if sys.version_info >= (3, 12): pytest.xfail( "since python3.12 this test triggers RuntimeError: can't create new thread at interpreter shutdown" @@ -145,12 +152,16 @@ def flush(self): """ % str(execnetdir) ) - popen = subprocess.Popen([str(anypython), str(p)], stdout=subprocess.PIPE) + # as above: this asserts execnet's teardown is silent, and tox's + # PYTHONWARNDEFAULTENCODING makes trio's own subprocess module emit an + # EncodingWarning that would be counted as our noise. + env = dict(os.environ) + env.pop("PYTHONWARNDEFAULTENCODING", None) + popen = subprocess.Popen([str(anypython), str(p)], stdout=subprocess.PIPE, env=env) # sync with start-up assert popen.stdout is not None popen.stdout.readline() - reply = pool.spawn(popen.communicate) - reply.get(timeout=50) + executor.submit(popen.communicate).result(timeout=50) _out, err = capfd.readouterr() lines = [x for x in err.splitlines() if "*sys-package" not in x] assert not lines or "Killed" in err diff --git a/testing/test_threadpool.py b/testing/test_threadpool.py deleted file mode 100644 index 510ae020..00000000 --- a/testing/test_threadpool.py +++ /dev/null @@ -1,222 +0,0 @@ -import os -from pathlib import Path - -import pytest - -from execnet.gateway_base import ExecModel -from execnet.gateway_base import WorkerPool - - -def test_execmodel(execmodel: ExecModel, tmp_path: Path) -> None: - assert execmodel.backend - p = tmp_path / "somefile" - p.write_text("content") - fd = os.open(p, os.O_RDONLY) - f = execmodel.fdopen(fd, "r") - assert f.read() == "content" - f.close() - - -def test_execmodel_basic_attrs(execmodel: ExecModel) -> None: - m = execmodel - assert callable(m.start) - assert m.get_ident() - - -def test_simple(pool: WorkerPool) -> None: - reply = pool.spawn(lambda: 42) - assert reply.get() == 42 - - -def test_some(pool: WorkerPool, execmodel: ExecModel) -> None: - q = execmodel.queue.Queue() - num = 4 - - def f(i: int) -> None: - q.put(i) - while q.qsize(): - execmodel.sleep(0.01) - - for i in range(num): - pool.spawn(f, i) - for i in range(num): - q.get() - # assert len(pool._running) == 4 - assert pool.waitall(timeout=1.0) - # execmodel.sleep(1) helps on windows? - assert len(pool._running) == 0 - - -def test_running_semnatics(pool: WorkerPool, execmodel: ExecModel) -> None: - q = execmodel.queue.Queue() - - def first() -> None: - q.get() - - reply = pool.spawn(first) - assert reply.running - assert pool.active_count() == 1 - q.put(1) - assert pool.waitall() - assert pool.active_count() == 0 - assert not reply.running - - -def test_waitfinish_on_reply(pool: WorkerPool) -> None: - l = [] - reply = pool.spawn(lambda: l.append(1)) - reply.waitfinish() - assert l == [1] - reply = pool.spawn(lambda: 0 / 0) - reply.waitfinish() # no exception raised - pytest.raises(ZeroDivisionError, reply.get) - - -@pytest.mark.xfail(reason="WorkerPool does not implement limited size") -def test_limited_size(execmodel: ExecModel) -> None: - pool = WorkerPool(execmodel, size=1) # type: ignore[call-arg] - q = execmodel.queue.Queue() - q2 = execmodel.queue.Queue() - q3 = execmodel.queue.Queue() - - def first() -> None: - q.put(1) - q2.get() - - pool.spawn(first) - assert q.get() == 1 - - def second() -> None: - q3.put(3) - - # we spawn a second pool to spawn the second function - # which should block - pool2 = WorkerPool(execmodel) - pool2.spawn(pool.spawn, second) - assert not pool2.waitall(1.0) - assert q3.qsize() == 0 - q2.put(2) - assert pool2.waitall() - assert pool.waitall() - - -def test_get(pool: WorkerPool) -> None: - def f() -> int: - return 42 - - reply = pool.spawn(f) - result = reply.get() - assert result == 42 - - -def test_get_timeout(execmodel: ExecModel, pool: WorkerPool) -> None: - def f() -> int: - execmodel.sleep(0.2) - return 42 - - reply = pool.spawn(f) - with pytest.raises(IOError): - reply.get(timeout=0.01) - - -def test_get_excinfo(pool: WorkerPool) -> None: - def f() -> None: - raise ValueError("42") - - reply = pool.spawn(f) - with pytest.raises(ValueError): - reply.get(1.0) - with pytest.raises(ValueError): - reply.get(1.0) - - -def test_waitall_timeout(pool: WorkerPool, execmodel: ExecModel) -> None: - q = execmodel.queue.Queue() - - def f() -> None: - q.get() - - reply = pool.spawn(f) - assert not pool.waitall(0.01) - q.put(None) - reply.get(timeout=1.0) - assert pool.waitall(timeout=0.1) - - -@pytest.mark.skipif(not hasattr(os, "dup"), reason="no os.dup") -def test_pool_clean_shutdown( - pool: WorkerPool, capfd: pytest.CaptureFixture[str] -) -> None: - q = pool.execmodel.queue.Queue() - - def f() -> None: - q.get() - - pool.spawn(f) - assert not pool.waitall(timeout=1.0) - pool.trigger_shutdown() - with pytest.raises(ValueError): - pool.spawn(f) - - def wait_then_put() -> None: - pool.execmodel.sleep(0.1) - q.put(1) - - pool.execmodel.start(wait_then_put) - assert pool.waitall() - _out, err = capfd.readouterr() - assert err == "" - - -def test_primary_thread_integration(execmodel: ExecModel) -> None: - if execmodel.backend not in ("thread", "main_thread_only"): - with pytest.raises(ValueError): - WorkerPool(execmodel=execmodel, hasprimary=True) - return - pool = WorkerPool(execmodel=execmodel, hasprimary=True) - queue = execmodel.queue.Queue() - - def do_integrate() -> None: - queue.put(execmodel.get_ident()) - pool.integrate_as_primary_thread() - - execmodel.start(do_integrate) - - def func() -> None: - queue.put(execmodel.get_ident()) - - pool.spawn(func) - ident1 = queue.get() - ident2 = queue.get() - assert ident1 == ident2 - pool.terminate() - - -def test_primary_thread_integration_shutdown(execmodel: ExecModel) -> None: - if execmodel.backend not in ("thread", "main_thread_only"): - pytest.skip("can only run with threading") - pool = WorkerPool(execmodel=execmodel, hasprimary=True) - queue = execmodel.queue.Queue() - - def do_integrate() -> None: - queue.put(execmodel.get_ident()) - pool.integrate_as_primary_thread() - - execmodel.start(do_integrate) - queue.get() - - queue2 = execmodel.queue.Queue() - - def get_two() -> None: - queue.put(execmodel.get_ident()) - queue2.get() - - reply = pool.spawn(get_two) - # make sure get_two is running and blocked on queue2 - queue.get() - # then shut down - pool.trigger_shutdown() - # and let get_two finish - queue2.put(1) - reply.get() - assert pool.waitall(5.0) diff --git a/testing/test_transfer.py b/testing/test_transfer.py new file mode 100644 index 00000000..e3a762e3 --- /dev/null +++ b/testing/test_transfer.py @@ -0,0 +1,365 @@ +"""The transfer service and the seam it reaches workers through. + +``execnet._deploy`` is meant to be liftable out of the core: it reaches a +worker through one generic ``GATEWAY_SERVICE`` request and a registry, and +nothing in the protocol core names it. These tests cover both halves -- +the registry as an extension point, and the transfer built on it. +""" + +from __future__ import annotations + +import os +import pathlib +import sys +from collections.abc import Callable +from collections.abc import Iterator +from typing import Any + +import pytest +import trio + +import execnet +import execnet.raw_trio +from execnet import _services +from execnet._deploy._manifest import Entry +from execnet._deploy._manifest import walk + +TESTTIMEOUT = 60.0 + +needssymlink = pytest.mark.skipif( + not hasattr(os, "symlink"), reason="os.symlink not available" +) + + +@pytest.fixture +def tree(tmp_path: pathlib.Path) -> pathlib.Path: + source = tmp_path / "source" + (source / "sub").mkdir(parents=True) + (source / "top.txt").write_text("top") + (source / "sub" / "nested.txt").write_text("nested") + (source / "sub" / "empty.txt").write_text("") + return source + + +@pytest.fixture +def group() -> Iterator[execnet.Group]: + group = execnet.Group() + try: + yield group + finally: + group.terminate(timeout=30.0) + + +class TestManifest: + """Describing a tree is pure enough to test without a gateway.""" + + def test_entries_are_relative_and_parents_first(self, tree) -> None: + manifest = walk(str(tree)) + paths = [entry.path for entry in manifest.entries] + assert paths == ["sub", "sub/empty.txt", "sub/nested.txt", "top.txt"] + assert manifest.files()["top.txt"].size == 3 + + def test_the_root_itself_is_never_filtered(self, tree) -> None: + seen: list[str] = [] + + def keep(path: str) -> bool: + seen.append(path) + return True + + walk(str(tree), keep) + assert str(tree) not in seen + + def test_a_filter_excludes_whole_subtrees(self, tree) -> None: + manifest = walk(str(tree), lambda path: not path.endswith("sub")) + assert [entry.path for entry in manifest.entries] == ["top.txt"] + + def test_a_file_that_vanishes_mid_walk_is_left_out(self, tree) -> None: + # a filter with side effects is a thing people write, and a walk + # cannot hold a tree still + def delete_as_we_go(path: str) -> bool: + if path.endswith("nested.txt"): + os.unlink(path) + return True + + manifest = walk(str(tree), delete_as_we_go) + assert "sub/nested.txt" not in [entry.path for entry in manifest.entries] + + @needssymlink + def test_a_link_inside_the_tree_is_made_relative_to_it(self, tree) -> None: + (tree / "sub" / "inside").symlink_to(tree / "top.txt") + (tree / "outside").symlink_to(tree.parent / "elsewhere") + entries = {entry.path: entry for entry in walk(str(tree)).entries} + assert entries["sub/inside"] == Entry( + "sub/inside", + "link", + entries["sub/inside"].mode, + target="top.txt", + internal=True, + ) + # pointing out of the tree: copied as-is, wherever the tree lands + assert entries["outside"].internal is False + assert entries["outside"].target.endswith("elsewhere") + + +class TestServiceSeam: + """The core reaches a service by name, and knows nothing else about it.""" + + def test_the_core_does_not_name_any_feature(self) -> None: + # the whole point: grep the protocol core for the features built on + # it and find nothing + core = pathlib.Path(execnet.__file__).parent + sources = [ + (core / name).read_text() + for name in ( + "_message.py", + "_trio_gateway.py", + "_gateway.py", + "_trio_worker.py", + ) + ] + for text in sources: + assert "rsync" not in text.lower() + assert "deploy" not in text.lower() + + def test_an_unknown_service_is_refused_on_its_channel(self, group) -> None: + # a worker that does not have a service the coordinator asked for is + # usually a version skew, and should say so rather than go quiet + gateway = group.makegateway("popen") + reply = _request(gateway, "no.such.service", {}) + with pytest.raises(execnet.RemoteError, match="no execnet service"): + reply() + assert gateway.remote_exec("channel.send(1)").receive(TESTTIMEOUT) == 1 + + def test_registering_is_how_a_service_is_added(self) -> None: + _services.register("test.thing", "some.module:handler") + # idempotent, so importing a registering module twice is fine + _services.register("test.thing", "some.module:handler") + with pytest.raises(ValueError, match="already registered"): + _services.register("test.thing", "other.module:handler") + del _services._REGISTRY["test.thing"] + + +def _request(gateway: execnet.Gateway, name: str, request: object) -> Callable[[], Any]: + """Make a raw service request from the blocking surface, for tests.""" + from execnet._deploy._facade import run_blocking + + async def run(targets: Any) -> Any: + return await targets[0].request(name, request) + + def call() -> Any: + return run_blocking([gateway], run) + + return call + + +class TestServiceThreadBudget: + """Services must not eat the budget exec placement rations. + + ``exec_capacity`` claims half the worker's thread pool and reasons + about leaving the rest to "the machinery that has to keep running while + execs are in flight". Services are that machinery and were not in the + accounting: unbounded, 25 concurrent transfers held 25 threads and + pushed a 5ms ``remote_exec`` out to 3.1 seconds. + """ + + def test_service_bodies_are_bounded_to_a_quarter_of_the_pool(self) -> None: + from execnet._async import current_async + from execnet._deploy.serve import service_limiter + + class FakeGateway: + """Only what the limiter needs: the loop's vocabulary.""" + + async def main() -> None: + gateway = FakeGateway() + gateway._aio = current_async() # type: ignore[attr-defined] + budget = gateway._aio.thread_budget() # type: ignore[attr-defined] + limiter = service_limiter(gateway) + assert limiter.total_tokens == max(1, budget // 4) + # kept on the gateway, so every service body shares the one bound + assert service_limiter(gateway) is limiter + + trio.run(main) + + def test_a_worker_stays_responsive_under_concurrent_transfers( + self, tmp_path + ) -> None: + source = tmp_path / "source" + source.mkdir() + for index in range(8): + (source / f"f{index}.bin").write_bytes(b"x" * 400_000) + + async def main() -> None: + from execnet._deploy._transfer import transfer_tree + from execnet._services import ServiceTarget + + async with execnet.raw_trio.open_gateway("popen") as gateway: + target = ServiceTarget(gateway) + async with trio.open_nursery() as nursery: + for index in range(12): + nursery.start_soon( + transfer_tree, target, source, str(tmp_path / f"d{index}") + ) + await trio.sleep(0.2) + # an exec still gets placed while they run + channel = await gateway.remote_exec("channel.send(1)") + with trio.fail_after(30): + assert await channel.receive() == 1 + + trio.run(main) + + +class TestTransfer: + def test_a_tree_arrives(self, tree, tmp_path, group) -> None: + gateway = group.makegateway("popen") + destination = tmp_path / "dest" + execnet.transfer(gateway, tree, str(destination)) + assert (destination / "top.txt").read_text() == "top" + assert (destination / "sub" / "nested.txt").read_text() == "nested" + assert (destination / "sub" / "empty.txt").read_text() == "" + + def test_only_what_changed_is_sent_again(self, tree, tmp_path, group) -> None: + gateway = group.makegateway("popen") + destination = tmp_path / "dest" + execnet.transfer(gateway, tree, str(destination)) + + sent: list[str] = [] + execnet.transfer( + gateway, + tree, + str(destination), + progress=lambda path, size: sent.append(path), + ) + assert sent == [] + + (tree / "top.txt").write_text("changed") + execnet.transfer( + gateway, + tree, + str(destination), + progress=lambda path, size: sent.append(path), + ) + assert sent == ["top.txt"] + assert (destination / "top.txt").read_text() == "changed" + + def test_same_size_different_mtime_is_settled_by_checksum( + self, tree, tmp_path, group + ) -> None: + # the case a rebuild produces: the receiver hands back a digest and + # the sender skips the body when it matches + gateway = group.makegateway("popen") + destination = tmp_path / "dest" + execnet.transfer(gateway, tree, str(destination)) + os.utime(tree / "top.txt", (0, 0)) + + sent: list[str] = [] + execnet.transfer( + gateway, + tree, + str(destination), + progress=lambda path, size: sent.append(path), + ) + assert sent == [] + + def test_a_big_file_is_chunked(self, tmp_path, group) -> None: + from execnet._deploy._transfer import CHUNK_SIZE + + source = tmp_path / "source" + source.mkdir() + payload = os.urandom(CHUNK_SIZE * 2 + 17) + (source / "big.bin").write_bytes(payload) + gateway = group.makegateway("popen") + destination = tmp_path / "dest" + execnet.transfer(gateway, source, str(destination)) + assert (destination / "big.bin").read_bytes() == payload + + def test_delete_prunes_what_the_source_lost(self, tree, tmp_path, group) -> None: + gateway = group.makegateway("popen") + destination = tmp_path / "dest" + execnet.transfer(gateway, tree, str(destination)) + (tree / "top.txt").unlink() + + execnet.transfer(gateway, tree, str(destination)) + assert (destination / "top.txt").exists() # left alone by default + execnet.transfer(gateway, tree, str(destination), delete=True) + assert not (destination / "top.txt").exists() + assert (destination / "sub" / "nested.txt").exists() + + @pytest.mark.skipif(sys.platform == "win32", reason="posix modes") + def test_modes_survive(self, tree, tmp_path, group) -> None: + (tree / "top.txt").chmod(0o640) + (tree / "sub").chmod(0o750) + gateway = group.makegateway("popen") + destination = tmp_path / "dest" + execnet.transfer(gateway, tree, str(destination)) + assert (destination / "top.txt").stat().st_mode & 0o777 == 0o640 + assert (destination / "sub").stat().st_mode & 0o777 == 0o750 + + @needssymlink + def test_links_are_rebuilt(self, tree, tmp_path, group) -> None: + (tree / "inside").symlink_to(tree / "top.txt") + gateway = group.makegateway("popen") + destination = tmp_path / "dest" + execnet.transfer(gateway, tree, str(destination)) + assert (destination / "inside").is_symlink() + assert (destination / "inside").read_text() == "top" + + def test_it_works_against_a_worker_that_refuses_sync_sources( + self, tree, tmp_path, group + ) -> None: + # profile=trio runs exec'd sources as tasks and rejects sync ones; a + # service is not exec'd code, so it does not care + gateway = group.makegateway("popen//profile=trio") + destination = tmp_path / "dest" + execnet.transfer(gateway, tree, str(destination)) + assert (destination / "top.txt").read_text() == "top" + + def test_a_failure_reports_on_its_channel(self, tree, tmp_path, group) -> None: + gateway = group.makegateway("popen") + with pytest.raises(execnet.RemoteError): + execnet.transfer(gateway, tree, str(tmp_path / "dest" / "\0bad")) + # and the gateway is still usable: the service is a task on the + # worker's root nursery and has to contain what it raises + assert gateway.remote_exec("channel.send(1)").receive(TESTTIMEOUT) == 1 + + +class TestTrioSurface: + def test_targets_are_transferred_to_concurrently(self, tree, tmp_path) -> None: + # the reason the driver is async: N hosts should cost one transfer, + # not N of them + async def main() -> None: + async with execnet.raw_trio.AsyncGroup() as group: + gateways = [await group.makegateway("popen") for _ in range(3)] + destinations = [str(tmp_path / f"dest{n}") for n in range(3)] + from execnet._deploy._transfer import transfer_tree_to_all + from execnet._services import ServiceTarget + + await transfer_tree_to_all( + [ + (ServiceTarget(gateway), destination) + for gateway, destination in zip( + gateways, destinations, strict=True + ) + ], + tree, + ) + for destination in destinations: + assert (pathlib.Path(destination) / "top.txt").read_text() == "top" + + trio.run(main) + + def test_cancelling_a_transfer_leaves_the_gateway_usable(self, tmp_path) -> None: + source = tmp_path / "source" + source.mkdir() + for index in range(40): + (source / f"file{index}.bin").write_bytes(os.urandom(200_000)) + + async def main() -> None: + async with execnet.raw_trio.open_gateway("popen") as gateway: + with trio.move_on_after(0.05): + await execnet.raw_trio.transfer( + gateway, source, str(tmp_path / "dest") + ) + channel = await gateway.remote_exec("channel.send(1)") + assert await channel.receive() == 1 + + trio.run(main) diff --git a/testing/test_trio_facade.py b/testing/test_trio_facade.py new file mode 100644 index 00000000..f6f9c061 --- /dev/null +++ b/testing/test_trio_facade.py @@ -0,0 +1,399 @@ +"""``execnet.trio``: trio in the caller's run, protocol IO on the engine. + +The counterpart of :mod:`testing.test_aio`, and of ``test_trio_gateway`` +which drives the same core the other way (``execnet.raw_trio``, gateways as +tasks in the caller's own nursery). What is tested here is specifically +the facade: that the engine is where the gateways live, that cancellation +crosses the bridge, and that what the surface deliberately does *not* +expose stays unexposed. +""" + +from __future__ import annotations + +from typing import cast + +import pytest +import trio + +import execnet.raw_trio +import execnet.trio +from execnet._engine import ProtocolEngine +from execnet._engine import default_engine + +TESTTIMEOUT = 30.0 + + +def run(async_fn: object, *args: object) -> object: + async def main() -> object: + with trio.fail_after(TESTTIMEOUT): + return await async_fn(*args) # type: ignore[operator] + + return trio.run(main) + + +class TestTheSurface: + def test_popen_roundtrip(self) -> None: + async def main() -> None: + async with execnet.trio.AsyncGroup() as group: + gateway = await group.makegateway("popen") + channel = await gateway.remote_exec( + "channel.send(channel.receive() + 1)" + ) + await channel.send(41) + assert await channel.receive() == 42 + await channel.wait_closed() + + run(main) + + def test_open_gateway_iteration(self) -> None: + async def main() -> list[int]: + async with execnet.trio.open_gateway() as gateway: + channel = await gateway.remote_exec( + "for i in range(4): channel.send(i * 2)" + ) + return [cast("int", item) async for item in channel] + + assert run(main) == [0, 2, 4, 6] + + def test_receive_timeout(self) -> None: + async def main() -> None: + async with execnet.trio.open_gateway() as gateway: + channel = await gateway.remote_exec("channel.receive()") + with pytest.raises(channel.TimeoutError): + await channel.receive(timeout=0.05) + await channel.send(None) + + run(main) + + def test_remote_error(self) -> None: + async def main() -> None: + async with execnet.trio.open_gateway() as gateway: + channel = await gateway.remote_exec("raise ValueError(17)") + with pytest.raises(execnet.trio.RemoteError, match="ValueError"): + await channel.receive() + + run(main) + + def test_channel_passing_wraps_the_facade(self) -> None: + async def main() -> None: + async with execnet.trio.open_gateway() as gateway: + channel = await gateway.remote_exec( + """ + c = channel.gateway.newchannel() + channel.send(c) + c.send(42) + """ + ) + passed = await channel.receive() + assert isinstance(passed, execnet.trio.AsyncChannel) + assert await passed.receive() == 42 + + run(main) + + def test_multiple_gateways(self) -> None: + async def main() -> list[int]: + async with execnet.trio.AsyncGroup() as group: + gateways = [await group.makegateway("popen") for _ in range(2)] + channels = [ + await gw.remote_exec("channel.send(channel.receive() * 2)") + for gw in gateways + ] + for index, channel in enumerate(channels): + await channel.send(index + 1) + return [cast("int", await channel.receive()) for channel in channels] + + assert run(main) == [2, 4] + + def test_group_not_started(self) -> None: + async def main() -> None: + group = execnet.trio.AsyncGroup() + with pytest.raises(RuntimeError, match="not started"): + await group.makegateway("popen") + + run(main) + + def test_start_and_aclose_explicitly(self) -> None: + # what a lifespan hook does, rather than an "async with" + async def main() -> None: + group = execnet.trio.AsyncGroup() + await group.start() + try: + gateway = await group.makegateway("popen") + channel = await gateway.remote_exec("channel.send(7)") + assert await channel.receive() == 7 + finally: + await group.aclose() + await group.aclose() # idempotent + with pytest.raises(RuntimeError, match="not started"): + await group.makegateway("popen") + + run(main) + + def test_terminate_gateway_explicitly(self) -> None: + async def main() -> None: + async with execnet.trio.AsyncGroup() as group: + gateway = await group.makegateway("popen") + assert await (await gateway.remote_exec("channel.send(1)")).receive() + await gateway.terminate() + + run(main) + + +class TestItRunsOnTheEngine: + """The facade's reason to exist, and what follows from it.""" + + def test_groups_share_the_default_engine(self) -> None: + async def main() -> None: + async with execnet.trio.AsyncGroup() as a, execnet.trio.AsyncGroup() as b: + assert a.engine is b.engine is default_engine() + + run(main) + + def test_an_explicit_engine_is_used(self) -> None: + async def main() -> None: + engine = ProtocolEngine(name="execnet-engine-trio-facade") + async with execnet.trio.AsyncGroup(engine=engine) as group: + assert group.engine is engine + gateway = await group.makegateway("popen") + assert await (await gateway.remote_exec("channel.send(1)")).receive() + engine.close() + + run(main) + + def test_a_gateway_outlives_the_nursery_that_made_it(self) -> None: + # the structural difference from raw_trio: the group's nursery is on + # the engine, so a gateway is a handle rather than a scoped resource + async def main() -> None: + group = execnet.trio.AsyncGroup() + await group.start() + try: + holder: list[object] = [] + async with trio.open_nursery() as nursery: + + async def make() -> None: + holder.append(await group.makegateway("popen")) + + nursery.start_soon(make) + # the nursery it was created in is gone; the gateway is not + gateway = holder[0] + channel = await gateway.remote_exec("channel.send(11)") # type: ignore[attr-defined] + assert await channel.receive() == 11 + finally: + await group.aclose() + + run(main) + + def test_the_engine_keeps_serving_while_the_caller_loop_blocks(self) -> None: + # what a caller buys by not being raw_trio: a step that never yields + # stalls this loop, and the protocol keeps running regardless + import time + + async def main() -> None: + async with execnet.trio.open_gateway() as gateway: + channel = await gateway.remote_exec( + "for i in range(3): channel.send(i)" + ) + time.sleep(0.3) + assert [await channel.receive() for _ in range(3)] == [0, 1, 2] + + run(main) + + +class TestCancellation: + """Cancellation crosses the bridge, and shielding means trio's shielding.""" + + def test_a_cancelled_receive_does_not_consume_an_item(self) -> None: + async def main() -> None: + async with execnet.trio.open_gateway() as gateway: + channel = await gateway.remote_exec( + """ + channel.receive() + for i in range(3): + channel.send(i) + """ + ) + with trio.move_on_after(0.05): + await channel.receive() + # release the worker; nothing was consumed by the cancelled wait + await channel.send("go") + assert [await channel.receive() for _ in range(3)] == [0, 1, 2] + + run(main) + + def test_a_shielded_send_is_not_cancellable(self) -> None: + # the documented difference from execnet.aio, where asyncio.shield + # delivers the CancelledError while the work continues: here the wait + # itself is uncancellable, so the send completes before we move on + async def main() -> None: + async with execnet.trio.open_gateway() as gateway: + channel = await gateway.remote_exec( + "channel.send(channel.receive() * 2)" + ) + with trio.move_on_after(0.0001) as scope: + await channel.send(21) + assert not scope.cancelled_caught + assert await channel.receive() == 42 + + run(main) + + def test_a_cancelled_scope_leaves_the_gateway_usable(self) -> None: + async def main() -> None: + async with execnet.trio.open_gateway() as gateway: + blocked = await gateway.remote_exec("channel.receive()") + with trio.move_on_after(0.05): + await blocked.receive() + await blocked.send(None) + channel = await gateway.remote_exec("channel.send('still here')") + assert await channel.receive() == "still here" + + run(main) + + +class TestTheSubset: + """What the facade does not expose, and why that is not an oversight.""" + + @pytest.mark.parametrize( + "name", ["_open_raw_channel", "open_channel", "_enqueue_frame", "wait_closed"] + ) + def test_engine_internals_are_not_on_the_facade_gateway(self, name: str) -> None: + # channel ids come from an unlocked per-gateway counter that works + # only because one loop owns it; handing that out across the bridge + # would let two allocators issue the same id + assert not hasattr(execnet.trio.AsyncGateway, name) + + def test_the_raw_surface_still_has_them(self) -> None: + assert hasattr(execnet.raw_trio.AsyncGateway, "_open_raw_channel") + assert hasattr(execnet.raw_trio.AsyncGateway, "open_channel") + + def test_serve_gateway_is_raw_only(self) -> None: + assert not hasattr(execnet.trio, "serve_gateway") + + +class TestDeploymentValidation: + def test_deploying_to_no_gateways_is_refused(self) -> None: + from execnet._deploy._api import Deployment + + async def main() -> None: + with pytest.raises(ValueError, match="no gateways"): + await execnet.trio.deploy_all( + Deployment.__new__(Deployment), # never reached + [], + ) + + run(main) + + def test_gateways_from_two_engines_are_refused(self) -> None: + from execnet._deploy._api import Deployment + + async def main() -> None: + other = ProtocolEngine(name="execnet-engine-trio-second") + async with ( + execnet.trio.AsyncGroup() as one, + execnet.trio.AsyncGroup(engine=other) as two, + ): + gateways = [ + await one.makegateway("popen"), + await two.makegateway("popen"), + ] + with pytest.raises(ValueError, match=r"same execnet\.ProtocolEngine"): + await execnet.trio.deploy_all( + Deployment.__new__(Deployment), gateways + ) + other.close() + + run(main) + + +class TestSalvage: + """A cancelled receive never costs an item, end to end. + + :mod:`testing.test_bridge` covers the carrier's two orderings; what is + left to check is the wiring -- that a salvaged item reaches the + channel's slot and comes back out of the next ``receive`` in order, and + that it is still wrapped if it happens to be a channel. + + The window is forced rather than raced for: the delivery and a cancel + are queued onto the caller's loop in that order, and its entry queue is + FIFO, so the receive is guaranteed to be cancelled with the engine's + item already in hand. + """ + + @staticmethod + def _cancel_as_the_engine_answers( + monkeypatch: pytest.MonkeyPatch, scope: trio.CancelScope + ) -> None: + """Cancel ``scope`` in the instant the engine hands back its result. + + The cancel is queued *ahead* of the delivery, on a FIFO entry queue, + so the receiving task is cancelled with the engine's item already + produced and one callback away. Queuing it behind the delivery + instead proves nothing: setting the event reschedules the waiting + task, and trio will not then deliver a cancel to a task that already + has a wakeup pending -- the receive simply succeeds. + """ + from execnet import _bridge + + token = trio.lowlevel.current_trio_token() + real = _bridge.TrioCarrier.resolve + + def hooked(self: object, result: object, error: object) -> None: + token.run_sync_soon(scope.cancel) + real(self, result, error) # type: ignore[arg-type] + + monkeypatch.setattr(_bridge.TrioCarrier, "resolve", hooked) + + def test_an_item_taken_as_the_cancel_lands_comes_back( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + async def main() -> None: + async with execnet.trio.open_gateway() as gateway: + channel = await gateway.remote_exec( + "channel.receive()\nfor i in range(3): channel.send(i)" + ) + await channel.send("go") + with trio.CancelScope() as scope: + self._cancel_as_the_engine_answers(monkeypatch, scope) + await channel.receive() + assert scope.cancelled_caught + monkeypatch.undo() + assert channel._salvaged is not execnet.trio._NOTHING + # in order, and none of them missing + assert [await channel.receive() for _ in range(3)] == [0, 1, 2] + + run(main) + + def test_a_salvaged_channel_is_still_wrapped( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + async def main() -> None: + async with execnet.trio.open_gateway() as gateway: + channel = await gateway.remote_exec( + """ + channel.receive() + c = channel.gateway.newchannel() + channel.send(c) + c.send(42) + """ + ) + await channel.send("go") + with trio.CancelScope() as scope: + self._cancel_as_the_engine_answers(monkeypatch, scope) + await channel.receive() + monkeypatch.undo() + passed = await channel.receive() + assert isinstance(passed, execnet.trio.AsyncChannel) + assert await passed.receive() == 42 + + run(main) + + def test_an_uncancelled_receive_leaves_nothing_behind(self) -> None: + async def main() -> None: + async with execnet.trio.open_gateway() as gateway: + channel = await gateway.remote_exec( + "for i in range(2): channel.send(i)" + ) + assert await channel.receive() == 0 + assert channel._salvaged is execnet.trio._NOTHING + assert await channel.receive() == 1 + + run(main) diff --git a/testing/test_trio_gateway.py b/testing/test_trio_gateway.py new file mode 100644 index 00000000..a0f481f6 --- /dev/null +++ b/testing/test_trio_gateway.py @@ -0,0 +1,615 @@ +"""Protocol tests for the trio-native async gateway core (RawChannel level). + +Two AsyncGateways are wired together over an in-memory stream pair (the +transport harness, as in ``TestFrameDecoder``); the assertions cover execnet +semantics: payload routing by channel id, the close/EOF/sendonly state +machine, RemoteError propagation, and gateway termination. +""" + +from __future__ import annotations + +import os +import sys +import time +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from typing import cast + +import pytest +import trio +import trio.testing + +from execnet import _errors +from execnet import _trio_gateway +from execnet._errors import RemoteError +from execnet._message import Message +from execnet._serialize import Payload +from execnet._serialize import SendPayload +from execnet._serialize import dumps_internal +from execnet._serialize import loads_internal +from execnet._trio_gateway import AsyncChannel +from execnet._trio_gateway import AsyncGateway +from execnet._trio_gateway import AsyncGroup +from execnet._trio_gateway import ThreadedFdStream +from execnet._trio_gateway import open_gateway +from execnet._xspec import XSpec + + +@asynccontextmanager +async def gateway_pair() -> AsyncIterator[tuple[AsyncGateway, AsyncGateway]]: + left_stream, right_stream = trio.testing.memory_stream_pair() + async with trio.open_nursery() as nursery: + left = AsyncGateway(left_stream, id="left", _startcount=1) + right = AsyncGateway(right_stream, id="right", _startcount=2) + await nursery.start(left._serve) + await nursery.start(right._serve) + try: + yield left, right + finally: + await left.aclose() + await right.aclose() + + +def test_payload_boundaries_are_preserved() -> None: + async def main() -> None: + async with gateway_pair() as (left, right): + sender = left._open_raw_channel() + receiver = right._open_raw_channel(sender.id) + await sender.send_bytes(b"first payload") + await sender.send_bytes(b"second") + assert await receiver.receive_bytes() == b"first payload" + assert await receiver.receive_bytes() == b"second" + + trio.run(main) + + +def test_send_eof_makes_peer_sendonly() -> None: + async def main() -> None: + async with gateway_pair() as (left, right): + sender = left._open_raw_channel() + receiver = right._open_raw_channel(sender.id) + await sender.send_bytes(b"data") + await sender.send_eof() + assert await receiver.receive_bytes() == b"data" + with pytest.raises(EOFError): + await receiver.receive_bytes() + # the receiver of an EOF may still send back + await receiver.send_bytes(b"reply") + assert await sender.receive_bytes() == b"reply" + with pytest.raises(OSError, match="cannot send"): + await sender.send_bytes(b"after eof") + + trio.run(main) + + +def test_close_drains_then_blocks_both_directions() -> None: + async def main() -> None: + async with gateway_pair() as (left, right): + sender = left._open_raw_channel() + receiver = right._open_raw_channel(sender.id) + await sender.send_bytes(b"x") + await sender.aclose() + # payloads sent before the close still drain + assert await receiver.receive_bytes() == b"x" + with pytest.raises(EOFError): + await receiver.receive_bytes() + with pytest.raises(OSError, match="cannot send"): + await receiver.send_bytes(b"y") + with pytest.raises(OSError, match="cannot send"): + await sender.send_bytes(b"z") + + trio.run(main) + + +def test_close_with_error_raises_remote_error() -> None: + async def main() -> None: + async with gateway_pair() as (left, right): + sender = left._open_raw_channel() + receiver = right._open_raw_channel(sender.id) + await sender.aclose(error="boom happened") + with pytest.raises(RemoteError, match="boom happened"): + await receiver.receive_bytes() + + trio.run(main) + + +def test_async_iteration_yields_payloads_until_eof() -> None: + async def main() -> None: + async with gateway_pair() as (left, right): + sender = left._open_raw_channel() + receiver = right._open_raw_channel(sender.id) + payloads = [b"a", b"bb", b"ccc"] + for payload in payloads: + await sender.send_bytes(payload) + await sender.send_eof() + assert [data async for data in receiver] == payloads + + trio.run(main) + + +def test_terminate_closes_peer_cleanly() -> None: + async def main() -> None: + async with gateway_pair() as (left, right): + channel = right._open_raw_channel() + await left.terminate() + await right.wait_closed() + assert right._error is None + with pytest.raises(EOFError): + await channel.receive_bytes() + + trio.run(main) + + +def test_status_reply_travels_on_raw_channel() -> None: + async def main() -> None: + async with gateway_pair() as (left, _right): + channel = left._open_raw_channel() + await left._send(Message.STATUS, channel.id) + status = cast( + "dict[str, Payload]", loads_internal(await channel.receive_bytes()) + ) + assert status["execmodel"] == "trio" + assert status["numexecuting"] == 0 + # the peer closes the status channel after the reply + with pytest.raises(EOFError): + await channel.receive_bytes() + + trio.run(main) + + +def test_unsupported_message_is_rejected_with_remote_error() -> None: + async def main() -> None: + async with gateway_pair() as (left, _right): + channel = left._open_raw_channel() + await left._send( + Message.CHANNEL_EXEC, + channel.id, + dumps_internal(("code", None, None, {})), + ) + with pytest.raises(RemoteError, match="unsupported message"): + await channel.receive_bytes() + + trio.run(main) + + +def test_send_after_gateway_close_raises() -> None: + async def main() -> None: + async with gateway_pair() as (left, _right): + channel = left._open_raw_channel() + await left.aclose() + with pytest.raises(OSError, match="cannot send"): + await channel.send_bytes(b"x") + with pytest.raises(OSError, match="already closed"): + left._open_raw_channel() + + trio.run(main) + + +def test_peer_disappearing_surfaces_eof_error() -> None: + async def main() -> None: + left_stream, right_stream = trio.testing.memory_stream_pair() + async with trio.open_nursery() as nursery: + right = AsyncGateway(right_stream, id="right", _startcount=2) + await nursery.start(right._serve) + channel = right._open_raw_channel() + # peer vanishes without a termination message + await left_stream.aclose() + await right.wait_closed() + with pytest.raises(EOFError): + await channel.receive_bytes() + + trio.run(main) + + +def test_mid_frame_eof_is_an_error() -> None: + async def main() -> None: + left_stream, right_stream = trio.testing.memory_stream_pair() + async with trio.open_nursery() as nursery: + right = AsyncGateway(right_stream, id="right", _startcount=2) + await nursery.start(right._serve) + frame = Message(Message.CHANNEL_DATA, 1, b"payload").pack() + await left_stream.send_all(frame[:5]) + await left_stream.aclose() + await right.wait_closed() + assert isinstance(right._error, EOFError) + assert "mid-frame" in str(right._error) + + trio.run(main) + + +def test_channel_serializes_builtin_items() -> None: + items: list[SendPayload] = [ + 42, + "text", + b"bytes", + [1, 2], + ("a", 1), + {"key": [True, None]}, + {1, 2}, + ] + + async def main() -> None: + async with gateway_pair() as (left, right): + sender = left.open_channel() + receiver = right.open_channel(sender.id) + for item in items: + await sender.send(item) + await sender.send_eof() + assert [item async for item in receiver] == items + + trio.run(main) + + +def test_channel_receive_timeout() -> None: + async def main() -> None: + async with gateway_pair() as (left, _right): + channel = left.open_channel() + with pytest.raises(_errors.TimeoutError): + await channel.receive(timeout=0.05) + + trio.run(main) + + +def test_channel_close_with_error_and_wait_closed() -> None: + async def main() -> None: + async with gateway_pair() as (left, right): + sender = left.open_channel() + receiver = right.open_channel(sender.id) + await sender.aclose(error="exec exploded") + with pytest.raises(RemoteError, match="exec exploded"): + await receiver.receive() + with pytest.raises(RemoteError, match="exec exploded"): + await receiver.wait_closed() + + trio.run(main) + + +def test_channel_wait_closed_on_clean_eof() -> None: + async def main() -> None: + async with gateway_pair() as (left, right): + sender = left.open_channel() + receiver = right.open_channel(sender.id) + await sender.send(1) + await sender.send_eof() + await receiver.wait_closed() + # items sent before the EOF still drain after waitclose + assert await receiver.receive() == 1 + + trio.run(main) + + +def test_channel_objects_travel_over_the_wire() -> None: + async def main() -> None: + async with gateway_pair() as (left, right): + carrier = left.open_channel() + right_carrier = right.open_channel(carrier.id) + extra = left.open_channel() + await carrier.send({"reply-to": extra}) + received = cast("dict[str, AsyncChannel]", await right_carrier.receive()) + remote_extra = received["reply-to"] + assert remote_extra.id == extra.id + await remote_extra.send("over the transferred channel") + assert await extra.receive() == "over the transferred channel" + + trio.run(main) + + +def _remote_add(channel, a, b) -> None: # type: ignore[no-untyped-def] + channel.send(a + b) + + +class TestPopenAsyncGateway: + """Integration: an AsyncGateway serving a real popen worker inside + the user's own trio run (no host thread).""" + + def test_remote_exec_roundtrip(self) -> None: + async def main() -> None: + async with open_gateway() as gateway: + channel = await gateway.remote_exec( + "channel.send(channel.receive() + 1)" + ) + await channel.send(41) + assert await channel.receive() == 42 + await channel.wait_closed() + + trio.run(main) + + def test_remote_exec_function_with_kwargs(self) -> None: + async def main() -> None: + async with open_gateway() as gateway: + channel = await gateway.remote_exec(_remote_add, a=40, b=2) + assert await channel.receive() == 42 + + trio.run(main) + + def test_remote_error_propagates(self) -> None: + async def main() -> None: + async with open_gateway() as gateway: + channel = await gateway.remote_exec("raise ValueError('kaboom')") + with pytest.raises(RemoteError, match="kaboom"): + await channel.receive() + + trio.run(main) + + def test_exec_finish_closes_channel_ending_iteration(self) -> None: + async def main() -> None: + async with open_gateway() as gateway: + channel = await gateway.remote_exec( + "for i in range(3): channel.send(i)" + ) + assert [item async for item in channel] == [0, 1, 2] + + trio.run(main) + + def test_concurrent_remote_execs(self) -> None: + async def main() -> None: + async with open_gateway() as gateway: + results: list[int] = [] + + async def run_one(value: int) -> None: + channel = await gateway.remote_exec( + "channel.send(channel.receive() * 10)" + ) + await channel.send(value) + results.append(cast("int", await channel.receive())) + + async with trio.open_nursery() as nursery: + for value in range(5): + nursery.start_soon(run_one, value) + assert sorted(results) == [0, 10, 20, 30, 40] + + trio.run(main) + + +class TestAsyncGroup: + def test_multiple_gateways_with_auto_ids(self) -> None: + async def main() -> None: + async with AsyncGroup() as group: + first = await group.makegateway() + second = await group.makegateway() + assert {first.id, second.id} == {"gw0", "gw1"} + for gateway in (first, second): + channel = await gateway.remote_exec("channel.send(42)") + assert await channel.receive() == 42 + + trio.run(main) + + def test_group_exit_terminates_and_reaps_workers(self) -> None: + async def main() -> None: + async with AsyncGroup() as group: + await group.makegateway() + await group.makegateway() + processes = list(group._processes.values()) + # workers exited on GATEWAY_TERMINATE, nobody had to kill them + assert [process.returncode for process in processes] == [0, 0] + + trio.run(main) + + def test_terminate_kills_hung_worker_within_bound(self) -> None: + async def main() -> None: + async with AsyncGroup(termination_timeout=1.0) as group: + gateway = await group.makegateway() + await gateway.remote_exec("import time\nwhile True: time.sleep(1)") + processes = list(group._processes.values()) + assert all(process.returncode is not None for process in processes) + + trio.run(main) + + def test_finished_channels_do_not_accumulate(self) -> None: + """A long-lived gateway must not keep one dead channel per exec. + + The sync surface is protected by its weak channel registry; the + async ones hold their channels strongly, so a coordinator doing + many ``remote_exec``s -- a test run, a pod fleet -- grew for as long + as it lived. Nothing can arrive for a remotely closed id (ids step + by two per side and are never reused), so the registry drops it. + """ + + async def main() -> None: + async with AsyncGroup() as group: + gateway = await group.makegateway() + for _ in range(20): + channel = await gateway.remote_exec("channel.send(42)") + assert await channel.receive() == 42 + await channel.wait_closed() + assert gateway._channels == {} + assert gateway._async_channels == {} + + trio.run(main) + + def test_a_channel_nobody_asked_for_survives_until_it_is_claimed(self) -> None: + # the exception to the above: a channel the local side has never + # taken exists only in the registry, and a reference passed in a + # payload has to find what arrived on it -- not a fresh empty one + async def main() -> None: + async with gateway_pair() as (left, right): + passed = right.open_channel() + await passed.send(b"ignored") # ensure the id is live + sender = right.open_channel() + # right sends a reference to `passed`, plus data and a close + # on it, before left ever looks at that id + await sender.send(passed) + await passed.send("buffered") + await passed.aclose() + await trio.testing.wait_all_tasks_blocked() + + receiver = left.open_channel(sender.id) + arrived = await receiver.receive() + assert isinstance(arrived, AsyncChannel) + assert await arrived.receive() == b"ignored" + assert await arrived.receive() == "buffered" + + trio.run(main) + + def test_provisioning_does_not_stall_the_loop( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Resolving what to launch must not block the loop it runs on. + + The probe of a ``python=`` target and a dev coordinator's wheel build + are subprocesses that take from milliseconds to (probe timeout) 30s. + Run inline they stall every gateway the loop serves, which for the + shared host is every gateway in the process. + """ + from execnet import _provision + + real = _provision.target_has_execnet + + def slow_probe(python: str) -> bool: + time.sleep(0.3) + return real(python) + + monkeypatch.setattr(_provision, "target_has_execnet", slow_probe) + + async def main() -> None: + ticks = 0 + stop = trio.Event() + + async def heartbeat() -> None: + nonlocal ticks + while not stop.is_set(): + await trio.sleep(0.01) + ticks += 1 + + async with trio.open_nursery() as nursery: + nursery.start_soon(heartbeat) + async with AsyncGroup() as group: + await group.makegateway(f"popen//python={sys.executable}") + stop.set() + # the probe alone sleeps 0.3s; an inline call would have let + # through a couple of ticks at most + assert ticks > 10 + + trio.run(main) + + def test_via_gateway_relays_through_coordinator(self) -> None: + async def main() -> None: + async with AsyncGroup() as group: + coordinator = await group.makegateway("popen//id=coordinator") + sub = await group.makegateway("popen//via=coordinator") + coordinator_channel = await coordinator.remote_exec( + "import os; channel.send(os.getpid())" + ) + sub_channel = await sub.remote_exec( + "import os; channel.send(os.getpid())" + ) + coordinator_pid = await coordinator_channel.receive() + sub_pid = await sub_channel.receive() + # a real second process, reached through the coordinator's relay + assert sub_pid != coordinator_pid + echo = await sub.remote_exec("channel.send(channel.receive() * 2)") + await echo.send(21) + assert await echo.receive() == 42 + + trio.run(main) + + def test_a_makegateway_that_fails_late_leaves_no_worker( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + # between the handshake and the group taking ownership, the worker is + # running and nothing would ever terminate it -- the connect helpers + # clean up after themselves, but only until they return + spawned: list[trio.Process] = [] + connect = _trio_gateway.connect_popen_worker + + async def spy(spec: XSpec) -> tuple[object, trio.Process]: + stream, process = await connect(spec) + spawned.append(process) + return stream, process + + def boom(self: AsyncGroup, stream: object, spec: object) -> AsyncGateway: + raise RuntimeError("boom") + + monkeypatch.setattr(_trio_gateway, "connect_popen_worker", spy) + monkeypatch.setattr(AsyncGroup, "_make_gateway", boom) + + async def main() -> None: + async with AsyncGroup() as group: + with pytest.raises(RuntimeError, match="boom"): + await group.makegateway() + + trio.run(main) + assert len(spawned) == 1 + # killed and reaped, not left behind for the OS to inherit + assert spawned[0].returncode is not None + + def test_unsupported_spec_is_rejected(self) -> None: + async def main() -> None: + async with AsyncGroup() as group: + with pytest.raises(ValueError, match="unsupported spec"): + await group.makegateway("id=notype") + + trio.run(main) + + +class TestThreadedFdStream: + """The Windows stand-in for ``trio.lowlevel.FdStream``. + + Exercised on every platform, since Windows is the only place it is + *used* and the least convenient place to find out it is broken. + """ + + def test_roundtrip_through_a_pipe_pair(self) -> None: + async def main() -> None: + their_read, our_write = os.pipe() + our_read, their_write = os.pipe() + stream = ThreadedFdStream(our_read, our_write) + try: + await stream.send_all(b"hello ") + await stream.send_all(b"world") + assert os.read(their_read, 11) == b"hello world" + + os.write(their_write, b"back") + assert await stream.receive_some(4) == b"back" + finally: + await stream.aclose() + os.close(their_read) + os.close(their_write) + + trio.run(main) + + def test_receive_reports_eof_as_empty(self) -> None: + async def main() -> None: + our_read, their_write = os.pipe() + stream = ThreadedFdStream(our_read, os.open(os.devnull, os.O_WRONLY)) + os.close(their_write) + try: + assert await stream.receive_some(4) == b"" + finally: + await stream.aclose() + + trio.run(main) + + def test_send_eof_lets_the_peer_see_the_end(self) -> None: + async def main() -> None: + their_read, our_write = os.pipe() + stream = ThreadedFdStream(os.open(os.devnull, os.O_RDONLY), our_write) + try: + await stream.send_all(b"tail") + await stream.send_eof() + assert os.read(their_read, 4) == b"tail" + assert os.read(their_read, 4) == b"" # write end is gone + with pytest.raises(trio.ClosedResourceError): + await stream.send_all(b"more") + finally: + await stream.aclose() + os.close(their_read) + + trio.run(main) + + def test_a_cancelled_read_is_abandoned_not_awaited(self) -> None: + # a blocking read on a pipe cannot be interrupted, so cancellation + # must not wait for it -- otherwise shutdown hangs until the peer + # happens to write. + async def main() -> None: + our_read, their_write = os.pipe() + stream = ThreadedFdStream(our_read, os.open(os.devnull, os.O_WRONLY)) + try: + with trio.move_on_after(0.2) as scope: + await stream.receive_some(4) + assert scope.cancelled_caught + finally: + os.close(their_write) + await stream.aclose() + + trio.run(main) diff --git a/testing/test_xspec.py b/testing/test_xspec.py index f837b07a..d68e76e4 100644 --- a/testing/test_xspec.py +++ b/testing/test_xspec.py @@ -6,15 +6,15 @@ import sys from collections.abc import Callable from pathlib import Path +from typing import cast import pytest +from test_gateway import TESTTIMEOUT import execnet +from execnet import Gateway from execnet import XSpec -from execnet.gateway import Gateway -from execnet.gateway_io import popen_args -from execnet.gateway_io import ssh_args -from execnet.gateway_io import vagrant_ssh_args +from execnet import _provision skip_win_pypy = pytest.mark.xfail( condition=hasattr(sys, "pypy_version_info") and sys.platform.startswith("win"), @@ -64,27 +64,25 @@ def test_ssh_options(self) -> None: def test_execmodel(self) -> None: spec = XSpec("execmodel=thread") assert spec.execmodel == "thread" - spec = XSpec("execmodel=eventlet") - assert spec.execmodel == "eventlet" + spec = XSpec("execmodel=main_thread_only") + assert spec.execmodel == "main_thread_only" def test_ssh_options_and_config(self) -> None: spec = XSpec("ssh=-p 22100 user@host//python=python3") - spec.ssh_config = "/home/user/ssh_config" - assert ssh_args(spec)[:6] == ["ssh", "-C", "-F", spec.ssh_config, "-p", "22100"] + args = _provision.ssh_argv("-p 22100 user@host", "/home/user/ssh_config", "cmd") + assert args[:6] == ["ssh", "-C", "-F", "/home/user/ssh_config", "-p", "22100"] + assert spec.ssh is not None def test_vagrant_options(self) -> None: - spec = XSpec("vagrant_ssh=default//python=python3") - assert vagrant_ssh_args(spec)[:-1] == ["vagrant", "ssh", "default", "--", "-C"] + args = _provision.vagrant_ssh_argv("default", None, "cmd") + assert args[:-1] == ["vagrant", "ssh", "default", "--", "-C"] def test_popen_with_sudo_python(self) -> None: - spec = XSpec("popen//python=sudo python3") - assert popen_args(spec) == [ - "sudo", - "python3", - "-u", - "-c", - "import sys;exec(eval(sys.stdin.readline()))", - ] + from execnet import _trio_gateway + + spec = XSpec("popen//python=sudo python3//id=gw0") + args = _trio_gateway.popen_module_args(spec) + assert args[:6] == ["sudo", "python3", "-u", "-m", "execnet", "worker"] def test_env(self) -> None: xspec = XSpec("popen//env:NAME=value1") @@ -123,6 +121,16 @@ class TestMakegateway: def test_no_type(self, makegateway: Callable[[str], Gateway]) -> None: pytest.raises(ValueError, lambda: makegateway("hello")) + def test_wait_backend_comes_from_the_facade( + self, makegateway: Callable[[str], Gateway] + ) -> None: + # not a spec key: the blocking surface decides how *it* parks, and + # a thread-shaped worker profile parks on threads either way + gw = makegateway("popen") + assert gw._wait_backend == "thread" + channel = gw.remote_exec("channel.send(channel.gateway._wait_backend)") + assert channel.receive() == "thread" + @skip_win_pypy def test_popen_default(self, makegateway: Callable[[str], Gateway]) -> None: gw = makegateway("") @@ -247,6 +255,10 @@ def test_socket_second( assert rinfo.cwd == rinfo2.cwd assert rinfo.version_info == rinfo2.version_info + @pytest.mark.skipif( + not _provision.socket_handoff_available(), + reason="the server must hand the accepted socket to a worker process", + ) def test_socket_installvia(self) -> None: group = execnet.Group() group.makegateway("popen//id=p1") @@ -254,3 +266,43 @@ def test_socket_installvia(self) -> None: assert gw.id == "s1" assert gw.remote_status() group.terminate() + + @pytest.mark.skipif( + not _provision.socket_handoff_available(), + reason="the server must hand the accepted socket to a worker process", + ) + def test_socket_worker_gets_the_spec(self, tmp_path: Path) -> None: + """A ``socket=`` worker is configured by its spec, like any other. + + The server spawns it, so its config cannot ride in argv -- it + travels over the connection instead. Without that these keys were + accepted, validated, and then silently dropped. + """ + group = execnet.Group() + try: + group.makegateway("popen//id=p1") + gw = group.makegateway( + f"socket//installvia=p1//id=s1//chdir={tmp_path}//env:SPECVAR=here" + ) + channel = gw.remote_exec( + "import os; channel.send((os.getcwd(), os.environ.get('SPECVAR')))" + ) + cwd, var = cast("tuple[str, str]", channel.receive(TESTTIMEOUT)) + assert Path(cwd).resolve() == tmp_path.resolve() + assert var == "here" + finally: + group.terminate(timeout=10) + + @pytest.mark.skipif( + not _provision.socket_handoff_available(), + reason="the server must hand the accepted socket to a worker process", + ) + def test_socket_worker_honours_the_profile(self) -> None: + group = execnet.Group() + try: + group.makegateway("popen//id=p1") + gw = group.makegateway("socket//installvia=p1//id=s1//profile=trio") + channel = gw.remote_exec("await channel.send('async')") + assert channel.receive(TESTTIMEOUT) == "async" + finally: + group.terminate(timeout=10) diff --git a/tox.ini b/tox.ini index 5880696d..2cc15373 100644 --- a/tox.ini +++ b/tox.ini @@ -6,22 +6,25 @@ isolated_build = true usedevelop=true setenv = PYTHONWARNDEFAULTENCODING = 1 -deps= - pytest - pytest-timeout -passenv = GITHUB_ACTIONS, HOME, USER, XDG_* +# the one list, from pyproject: a hand-maintained copy here drifted and +# left asyncssh/hypothesis out, so those modules failed to import +extras = testing +passenv = GITHUB_ACTIONS, HOME, USER, XDG_*, EXECNET_PROVISION_WHEEL commands= python -m pytest {posargs:testing} [testenv:docs] skipsdist = True usedevelop = True -changedir = doc +# the doc examples are doctests and are run here, so this env needs what +# the suite needs as well as what sphinx needs +extras = testing deps = sphinx PyYAML commands = - sphinx-build -W -b html . _build + sphinx-build -W -b html doc doc/_build + pytest --doctest-glob=*.rst doc [testenv:linting] skip_install = True diff --git a/uv.lock b/uv.lock index 893f1543..428e094c 100644 --- a/uv.lock +++ b/uv.lock @@ -16,6 +16,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, ] +[[package]] +name = "asyncssh" +version = "2.24.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dd/7f/2d79247bacc562104f312d27efe541673aa177feac89de291bd61bca52be/asyncssh-2.24.0.tar.gz", hash = "sha256:4064c590e59ce2e8d82a2f66d35f3120d765828b4df5e3dbfb07b4a8c24686c9", size = 550148, upload-time = "2026-06-27T20:34:44.755Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/29/908ce0ca5e8cae76662e354a0f08df552d6d221844748b9e5ca06051cc44/asyncssh-2.24.0-py3-none-any.whl", hash = "sha256:9abd46300adcb6d4b73269b34c53cd0d17a138b9a22b5b38008ce7d5808734b7", size = 381237, upload-time = "2026-06-27T20:34:43.198Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + [[package]] name = "backports-tarfile" version = "1.2.0" @@ -136,6 +158,8 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/e9/6d7724983b3d5a0908dbf74f64038ade77c18646ff6636ec7894fd392ce1/cffi-2.1.0-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:b65f590ef2a44640f9a05dbb548a429b4ade77913ce683ac8b1480777658a6c0", size = 183837, upload-time = "2026-07-06T21:32:09.655Z" }, + { url = "https://files.pythonhosted.org/packages/69/aa/24580a278de21fd7322635556334d9b535f1cbc00b0a3919447cdf464c65/cffi-2.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:164bff1657b2a74f0b6d54e11c9b375bc97b931f2ca9c43fcf875838da1570dd", size = 184226, upload-time = "2026-07-06T21:32:11.196Z" }, { url = "https://files.pythonhosted.org/packages/88/a9/02cae418ec4beb282ace11958d9d4737793439d561fadc7e6d56f2e2b354/cffi-2.1.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:c941bb58d5a6e1c3892d86e42927ed6c180302f07e6d395d08c416e594b98b46", size = 211107, upload-time = "2026-07-06T21:32:12.328Z" }, { url = "https://files.pythonhosted.org/packages/3b/30/c806937ed5e4c2c7ac30d9d6b76b5dc57ff8b75d83800d9bb11a8253cf2a/cffi-2.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a016194dbe13d14ee9556e734b772d8d67b947092b268d757fd4290e3ba2dfc2", size = 218733, upload-time = "2026-07-06T21:32:13.67Z" }, { url = "https://files.pythonhosted.org/packages/f9/cf/398272b8bbfd58aa314fda5a7f1cdbb26d1d78ae324a11211521315dd1f0/cffi-2.1.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:03e9810d18c646077e501f661b682fbf5dee4676048527ca3cffe66faa9960dd", size = 205543, upload-time = "2026-07-06T21:32:15.148Z" }, @@ -146,6 +170,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/75/74dfb7c3fc6ebbd408038476bd4c1d7e925c62614e7b9c534ecc34218288/cffi-2.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:11b3fb55f4f8ad92274ed26705f65d8f91457de71f5380061eb6d125a768fecd", size = 220341, upload-time = "2026-07-06T21:32:21.9Z" }, { url = "https://files.pythonhosted.org/packages/70/b6/9003c33a3e7d2c1306f5962e646457dcfe5a8cd8fce6bbe02d7af25db783/cffi-2.1.0-cp310-cp310-win32.whl", hash = "sha256:9d72af0cf10a76a600a9690078fe31c63b9588c8e86bf9fd353f713c84b5db0f", size = 174578, upload-time = "2026-07-06T21:32:23.073Z" }, { url = "https://files.pythonhosted.org/packages/8a/26/710688310447531c7a22f857c7f79d9855ec18b03e04494ced723fb37e2f/cffi-2.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:fb62edb5bb52cca65fab91a63afa7561607120d26090a7e8fda6fb9f064726da", size = 185071, upload-time = "2026-07-06T21:32:24.671Z" }, + { url = "https://files.pythonhosted.org/packages/d3/67/85c89a59ba36a671e79638f44d466749f08179266a57e4f2ffdf92174072/cffi-2.1.0-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:02cb7ff33ded4f1532476731f89ede53e2e488a8e6205515a82144246ffa7dcc", size = 183845, upload-time = "2026-07-06T21:32:26.32Z" }, + { url = "https://files.pythonhosted.org/packages/ea/dd/e3b0baa2d3d6a857ac72b7efbf18e32e487c9cdafcc13049ad765495b15e/cffi-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f5bce581e6b8c235e566a14768a943b172ada3ed73537bb0c0be1edee312d4e7", size = 184186, upload-time = "2026-07-06T21:32:28.025Z" }, { url = "https://files.pythonhosted.org/packages/65/68/9f3ef890cf3c6ab97bd531c5677f67613d302165d16f8142b2811782a614/cffi-2.1.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:30b65779d598c370374fefabf138d456fd6f3216bfa7bedfab1ba82025b0cd93", size = 211892, upload-time = "2026-07-06T21:32:29.565Z" }, { url = "https://files.pythonhosted.org/packages/22/d7/1a74539db16d8bfd839ff1515948948efbb162e574650fd3d846896eea95/cffi-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88023dfe18799507b73f1dbb0d14326a17465de1bc9c9c7655c22845e9ddc3a2", size = 218793, upload-time = "2026-07-06T21:32:30.951Z" }, { url = "https://files.pythonhosted.org/packages/ec/d1/9a5b7169499e8e8d8e636de70b97ac7c9447104d2ff1a2cd94790cea5162/cffi-2.1.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:0a96b74cda968eebbad56d973efe5098974f0a9fb323865bf99ea1fd24e3e64c", size = 205737, upload-time = "2026-07-06T21:32:32.216Z" }, @@ -157,6 +183,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/eb/da/5c4918a2d61d86fa927d716cb3d8e4626ef8dc8f605a599d32f33897f59a/cffi-2.1.0-cp311-cp311-win32.whl", hash = "sha256:64c753a0f87a256020004f37a1c8c02c480e725f910f0b2a0f3f07debd1b2479", size = 174496, upload-time = "2026-07-06T21:32:40.467Z" }, { url = "https://files.pythonhosted.org/packages/f9/c8/6c2de1d55cf35ef8b92885d5ef280790f0fb9634d87ea1cc315176aecd61/cffi-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:4f26194e3d95e06501b942642855aed4f953d55e95d7d01b7c4483db3ecff458", size = 185113, upload-time = "2026-07-06T21:32:41.761Z" }, { url = "https://files.pythonhosted.org/packages/9e/4e/e8d7cb5783f1841a3c8fb3a7735838d7484d08ec08c9f984b14cac1ac0e9/cffi-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:35aaea0c7ee0e58a5cd8c2fd1a48fdf7ece0d2699b7ecdda08194e9ce5dd9b3d", size = 179927, upload-time = "2026-07-06T21:32:42.961Z" }, + { url = "https://files.pythonhosted.org/packages/1e/85/990925db5df586ec90beb97529c853497e7f85ba0234830447faf41c3057/cffi-2.1.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f", size = 184829, upload-time = "2026-07-06T21:32:44.324Z" }, + { url = "https://files.pythonhosted.org/packages/4b/92/e7bb136ad6b5352603732cf907ef862ca103f20f2031c1735a46300c20c9/cffi-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde", size = 184728, upload-time = "2026-07-06T21:32:45.683Z" }, { url = "https://files.pythonhosted.org/packages/c3/c0/d1ec30ffb370f748f2fb54425972bfef9871e0132e82fb589c46b6676049/cffi-2.1.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d", size = 214815, upload-time = "2026-07-06T21:32:48.557Z" }, { url = "https://files.pythonhosted.org/packages/1b/dc/5620cf930688be01f2d673804291de757a934c90b946dbdc3d84130c2ea4/cffi-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7", size = 222429, upload-time = "2026-07-06T21:32:49.848Z" }, { url = "https://files.pythonhosted.org/packages/4b/a4/77b53abbf7a1e0beb9637edbef2a94d15f9c822f591e85d439ffd91519a6/cffi-2.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b", size = 210315, upload-time = "2026-07-06T21:32:51.221Z" }, @@ -169,6 +197,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dd/2c/400ea43e721727dca8a65c4521390e9196757caba4a45643acb2b63271b8/cffi-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6", size = 180088, upload-time = "2026-07-06T21:33:02.278Z" }, { url = "https://files.pythonhosted.org/packages/96/88/a996879e2eeccb815f6e3a5967b12a308257412acec882039d386bd2aa7b/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda", size = 194331, upload-time = "2026-07-06T21:33:03.697Z" }, { url = "https://files.pythonhosted.org/packages/58/85/7ae00d5c8dd6266f4e944c3db630f3c5c9a98b61d469c714d848b1d8138a/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b", size = 196966, upload-time = "2026-07-06T21:33:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e9/45c3a76ad8d43ad9261f4c95436da61128d3ca545d72b9612c0ab5be0b1c/cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a", size = 184795, upload-time = "2026-07-06T21:33:06.699Z" }, + { url = "https://files.pythonhosted.org/packages/84/4c/82f132cb4418ee6d953d982b19191e87e2a6372c8a4ce36e50b69d6ade4a/cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea", size = 184746, upload-time = "2026-07-06T21:33:08.071Z" }, { url = "https://files.pythonhosted.org/packages/a0/1c/4ed5a0e5bdca6cbc275556de3328dd1b76fd0c11cc13c88fe66d1d8715f2/cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db", size = 214747, upload-time = "2026-07-06T21:33:09.671Z" }, { url = "https://files.pythonhosted.org/packages/3a/a6/e879bb68cc23a2bc9ba8f4b7d8019f0c2694bad2ab6c4a3701d429439f58/cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f", size = 222392, upload-time = "2026-07-06T21:33:10.896Z" }, { url = "https://files.pythonhosted.org/packages/88/f6/01890cfd63c08f8eb96a8319b0443690197d240a8bd6346048cf7bde9190/cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d", size = 210285, upload-time = "2026-07-06T21:33:12.251Z" }, @@ -181,6 +211,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5a/af/34fee85c48f8d94efc8597bc09470c9dd274c145f1c12e0fbc6ab6d38d74/cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f", size = 180114, upload-time = "2026-07-06T21:33:22.515Z" }, { url = "https://files.pythonhosted.org/packages/d8/f0/81478e482afa03f6d18dc8f2afb5edc45b3080853b634b5ed91961be0998/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a", size = 194142, upload-time = "2026-07-06T21:33:23.657Z" }, { url = "https://files.pythonhosted.org/packages/7d/95/8de304305cd9204974b0ca051b86d307cafca13aa575a0ef1b44d92c0d8c/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3", size = 196819, upload-time = "2026-07-06T21:33:25.007Z" }, + { url = "https://files.pythonhosted.org/packages/20/71/7c8372d30e42415602ed9f268f7cfd66f1b855fed881ecd168bcb45dbc0b/cffi-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d", size = 184965, upload-time = "2026-07-06T21:33:26.605Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5c/584e626835f0375c928176c04137c96927165cb8733cdb3150ec04e5ee5e/cffi-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac", size = 184952, upload-time = "2026-07-06T21:33:27.823Z" }, { url = "https://files.pythonhosted.org/packages/2e/d2/065fcae1c73979fac8e054462478d0ff8a29c40cdc2ed7ea5676a061df53/cffi-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6", size = 222353, upload-time = "2026-07-06T21:33:29.178Z" }, { url = "https://files.pythonhosted.org/packages/ed/a5/e8bbb1ce5b3ac2f53ad6a10bde44318a5a8d99d4f4a000d44a6e39aeb3e4/cffi-2.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913", size = 210051, upload-time = "2026-07-06T21:33:30.534Z" }, { url = "https://files.pythonhosted.org/packages/28/ed/c127d3ac36e899c965e3361357c3befacd6578c03f40125183e41c3b219e/cffi-2.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d", size = 208630, upload-time = "2026-07-06T21:33:31.753Z" }, @@ -190,6 +222,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0f/6f/ade5ce9863a57992a6ea3d0d10d7e29b8749fc127204b3d493d667b2815f/cffi-2.1.0-cp314-cp314-win32.whl", hash = "sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd", size = 177723, upload-time = "2026-07-06T21:33:51.626Z" }, { url = "https://files.pythonhosted.org/packages/41/de/92b9eeed4ae4a21d6fd9b2a2c8505cbed573299902ea73981cc13f7ff62c/cffi-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb", size = 187937, upload-time = "2026-07-06T21:33:53.403Z" }, { url = "https://files.pythonhosted.org/packages/2e/1a/cc6ae6c2913a03aab8898eee57963cf1035b8df5872ed8b9115fcc7e2be8/cffi-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804", size = 183001, upload-time = "2026-07-06T21:33:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/14/f0/134c00ce0779ec86dea2aa1aac69339c2741a8045072676763512363a2ea/cffi-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714", size = 188538, upload-time = "2026-07-06T21:33:36.792Z" }, + { url = "https://files.pythonhosted.org/packages/50/d8/3b86aba791cb610d24e8a3e1b2cd529e71fa15096b04e4d4e360049d4a4c/cffi-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376", size = 188230, upload-time = "2026-07-06T21:33:38.011Z" }, { url = "https://files.pythonhosted.org/packages/14/d0/117dcd9209255ad8571fbc8c92ef32593a1d294dcec91ddc4e4db50606f2/cffi-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98", size = 223899, upload-time = "2026-07-06T21:33:39.514Z" }, { url = "https://files.pythonhosted.org/packages/b6/3d/f20f8b886b254e3ad10e15cd4186d3aed49f3e6a35ab37aab9f8f25f7c03/cffi-2.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13", size = 211652, upload-time = "2026-07-06T21:33:40.851Z" }, { url = "https://files.pythonhosted.org/packages/28/3b/fad54de07260b93ddeef4b96d0131d57ea900675df1d410ae1deee52d7a6/cffi-2.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d", size = 210755, upload-time = "2026-07-06T21:33:42.183Z" }, @@ -201,6 +235,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d5/dd/0c7dbf815a579ff005008a2d815a55d6bb047c349eef536d9dc53d3f0a8d/cffi-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8", size = 186404, upload-time = "2026-07-06T21:33:50.309Z" }, { url = "https://files.pythonhosted.org/packages/55/c7/8c8c50cb11c6750051daf12164098a9a6f027ac4356967fd4d800a07f242/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c", size = 194121, upload-time = "2026-07-06T21:33:56.109Z" }, { url = "https://files.pythonhosted.org/packages/99/e2/67680bf19a6b60d2bb7ff83baefa2a4c3d2d7dc0f3277034b802e1fc504c/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001", size = 196820, upload-time = "2026-07-06T21:33:57.288Z" }, + { url = "https://files.pythonhosted.org/packages/ed/da/4bbe583a3b3a5c8c60892124fe17f3fa3656523faf0d3484eae90f091853/cffi-2.1.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3", size = 184936, upload-time = "2026-07-06T21:33:58.765Z" }, + { url = "https://files.pythonhosted.org/packages/e5/4b/1f4c36ab273980d7aa75bb126ea4f8971f24a96108acad3a0a084028c57b/cffi-2.1.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc", size = 185045, upload-time = "2026-07-06T21:34:00.085Z" }, { url = "https://files.pythonhosted.org/packages/ef/c3/ad299dc38f3583f8d916b299f028af418a9ec98bc695fcbebeae7420691c/cffi-2.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699", size = 222342, upload-time = "2026-07-06T21:34:01.814Z" }, { url = "https://files.pythonhosted.org/packages/eb/d8/df4543cc087245044ed02ef3ad8e0a26619d0075ac7a77a12dc81177851b/cffi-2.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022", size = 210073, upload-time = "2026-07-06T21:34:03.255Z" }, { url = "https://files.pythonhosted.org/packages/2c/0e/fac738d73728c6cea2a88a2883dca54892496cbba88a1dc1f2909cb8a6f5/cffi-2.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0", size = 208551, upload-time = "2026-07-06T21:34:04.433Z" }, @@ -210,6 +246,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/d8/772b8259bf75749adffb1c546828978381fb516f60cf701f6c83daf60c85/cffi-2.1.0-cp315-cp315-win32.whl", hash = "sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6", size = 177696, upload-time = "2026-07-06T21:34:26.355Z" }, { url = "https://files.pythonhosted.org/packages/2f/dd/afa2191fc6d57fedd26e5844a2fe2fcc0bbfa00961bbaa5a41e4921e7cca/cffi-2.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853", size = 187914, upload-time = "2026-07-06T21:34:27.58Z" }, { url = "https://files.pythonhosted.org/packages/05/ef/6cd4f8c671517162379dc79cfae5aea9106bc38abb89628d5c16adf6a838/cffi-2.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda", size = 183004, upload-time = "2026-07-06T21:34:28.905Z" }, + { url = "https://files.pythonhosted.org/packages/11/b6/12fc55092817a5faa26fb8c40c7f9d662e11a46ee248c137aafc42517d92/cffi-2.1.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc", size = 188378, upload-time = "2026-07-06T21:34:09.926Z" }, + { url = "https://files.pythonhosted.org/packages/8d/2e/cdac88979f295fde5daa69622c7d2111e56e7ceb94f211357fbe452339e4/cffi-2.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca", size = 188319, upload-time = "2026-07-06T21:34:11.101Z" }, { url = "https://files.pythonhosted.org/packages/e0/27/1d0b408497e41a74795af122d7b603c418c5fed0171450f899afd04e594f/cffi-2.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d", size = 223904, upload-time = "2026-07-06T21:34:12.606Z" }, { url = "https://files.pythonhosted.org/packages/8b/31/e115c985105dd7ffb32444505f18ceb874bb42d992af05d5dced7ecf1980/cffi-2.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8", size = 211554, upload-time = "2026-07-06T21:34:13.987Z" }, { url = "https://files.pythonhosted.org/packages/5a/67/9e6e09409336d9e515c58367e7cfcf4f89df06ad25252675595a58eb59d5/cffi-2.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd", size = 210795, upload-time = "2026-07-06T21:34:15.972Z" }, @@ -261,6 +299,7 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/12/45/870e7f4bef50e5f53b9f51d4428aee5290eedf58ba443f16b1ebb7ab8e66/cryptography-48.0.1.tar.gz", hash = "sha256:266f4ee051abb2f725b74ef8072b521ce1feacf685a3364fa6a6b45548db791a", size = 832989, upload-time = "2026-06-09T22:32:31.8Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/bc/ee4137cbbe105652c0ee4252792b78fc8e7afa4b8e61d9d5dc05a7f45731/cryptography-48.0.1-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:3e4a1a3232eef2e6c732827d5722db29a0cc8b27af2a4d865b094cf954be9ca1", size = 8008324, upload-time = "2026-06-09T22:31:00.702Z" }, { url = "https://files.pythonhosted.org/packages/d5/85/6379d42181bfc713094f081360fc5784d6c816b599d45e7f082502d173ce/cryptography-48.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:32143b24adb918f078134e1e230f1eb8cc04886b92c28b5f0041aaf3e5699225", size = 4696243, upload-time = "2026-06-09T22:32:33.446Z" }, { url = "https://files.pythonhosted.org/packages/9c/87/c85d147b53323c7eb4d850920c8901377323c2a0ff8d79c262d4fee89aa2/cryptography-48.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0d27a5696721ef7a672b8c810f6aded391058e0b9486e63e6d93baf765da691", size = 4713235, upload-time = "2026-06-09T22:31:40.141Z" }, { url = "https://files.pythonhosted.org/packages/79/58/67cbf8cf1ee7c54b439ca07bbecf8362c07afc11a3724fea70f745784add/cryptography-48.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eb86ce1af36fe65041b6db9a8bb064ee621a7e5fded0f80d475ec243477cd242", size = 4702323, upload-time = "2026-06-09T22:31:42.191Z" }, @@ -272,6 +311,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9f/13/8b765e2e12b07c74941caadb9d1c8fdc006c4dfbf2b8f2d610519758954d/cryptography-48.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:0ee6ea481db1ab889cba043ec1eda17bb9c1ea79db6722f779c3667f9f70322f", size = 4745488, upload-time = "2026-06-09T22:32:30.07Z" }, { url = "https://files.pythonhosted.org/packages/2e/aa/48972bce55049b32a94f4907eda4d75fa385aad8a39506cc2fc72196ecf0/cryptography-48.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f2ceef93cb096aa3c4cc4b5c94ca6131f9196d28c64d6111533402a9b2054d41", size = 4830256, upload-time = "2026-06-09T22:31:43.868Z" }, { url = "https://files.pythonhosted.org/packages/47/a2/e5079a032fb85cf6005046ca92bbd78b0c82dad2b5751ab8c311659da06f/cryptography-48.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9bd3f92d76217892b15df84ca256c2c113d386fdda7a7d8691aeeced976507c6", size = 4979117, upload-time = "2026-06-09T22:31:05.845Z" }, + { url = "https://files.pythonhosted.org/packages/b7/a0/8f50cae9c74e718ed769d63ed5c74bd0ea830c9550a74629cebd1b9c7bc7/cryptography-48.0.1-cp311-abi3-win32.whl", hash = "sha256:b9a32b876490d66c8bcc9963ef220199569748434ab01a9d6aaeabf88e7f5158", size = 3304154, upload-time = "2026-06-09T22:32:16.845Z" }, + { url = "https://files.pythonhosted.org/packages/c5/69/0572c77dbace6fef72f33755bd52ea399c71367250d366237f8691826b9e/cryptography-48.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:39489bfca54c7a1f6b297efcd8bc608ab92d16c4ca631b0cad4da46724588b24", size = 3817138, upload-time = "2026-06-09T22:32:00.388Z" }, + { url = "https://files.pythonhosted.org/packages/42/06/3e768b4c3bc78201583fa35a0e18f640dd782ff41afba88f8545481a8874/cryptography-48.0.1-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:f817adc181390bd54f2f700107a7419040fb7c1bdf2fc26f36551a06a68c3345", size = 7989830, upload-time = "2026-06-09T22:31:07.8Z" }, { url = "https://files.pythonhosted.org/packages/8a/13/6476736484b94041110c8340a3eb63962fea4975baea8cb4a512adb44d4d/cryptography-48.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d5d30989c6917b478b5817902e85fddaea2261efa8648383d965381ccb9e1ac4", size = 4689201, upload-time = "2026-06-09T22:31:09.745Z" }, { url = "https://files.pythonhosted.org/packages/79/62/65a87f34d2a431546e2509b85d55e8c90df86d668f6731da64d538512ac2/cryptography-48.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:df637c05205ea7c1d7fbcbe54bbfea648a52951155f997af13d895d0ecc96991", size = 4702822, upload-time = "2026-06-09T22:32:24.409Z" }, { url = "https://files.pythonhosted.org/packages/7f/59/810b5204b0a9b10f4b6bc06bd551a8b609803cd931806bc3b71884b225e5/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:869c3b8a53bfe27147832df48b32adadf558249d50e76cb3769d40e986b13265", size = 4694875, upload-time = "2026-06-09T22:32:08.737Z" }, @@ -283,6 +325,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9f/04/618f4115cfc0add0838c82507aa18a346089428da8653ad38b3ff36f5cb3/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b4e391975f038e66432328639620a4aff2d307513b004f1ca06d6225bced815c", size = 4736660, upload-time = "2026-06-09T22:32:12.676Z" }, { url = "https://files.pythonhosted.org/packages/24/9c/06e062462a0de28a3b3911322eded4c16deb9f441b1b7575d3dc59488ab5/cryptography-48.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42fcd8e26fe555d9b3577a135f5091fefa0aa4e99129c23fb56787a1bd4ada72", size = 4822229, upload-time = "2026-06-09T22:31:17.062Z" }, { url = "https://files.pythonhosted.org/packages/f4/be/0561971eaaee4b8a0e7d5113c536921063ab91aaf23278ac374eaf881e11/cryptography-48.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c1400da5e32a43253392277eac7490a60e497d810a63dd5608d71bbd7af507c9", size = 4966364, upload-time = "2026-06-09T22:31:32.842Z" }, + { url = "https://files.pythonhosted.org/packages/a4/27/728c77876f12b000820b69ae490f3c4083775e79e07827e9e60be07ad209/cryptography-48.0.1-cp314-cp314t-win32.whl", hash = "sha256:0df56b056bc17c1b7d6821dfa65216e62bd232d8ab05eb3db44e71d235651471", size = 3278498, upload-time = "2026-06-09T22:31:29.154Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/79a612c6d7b1e6ee0edd43633d53035bec2cfb78c82b76f7864f39e36f34/cryptography-48.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:9de21387aa95e2a895823d0745b430bed4f33503ba9ab5e0b5311f33e37d66d2", size = 3798790, upload-time = "2026-06-09T22:31:56.697Z" }, + { url = "https://files.pythonhosted.org/packages/ca/6c/00fa2a95997164c8b2072ce327c23d4ab20809ccc323ea5fab91e53a4bba/cryptography-48.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:4fdc69f8e4316bcf0c8c8ec1f26f285d12e8142d88d96c876a59a03be3f6ae67", size = 7987408, upload-time = "2026-06-09T22:32:20.777Z" }, { url = "https://files.pythonhosted.org/packages/b0/d9/45f309a7e4e5f3f8f121d6d3be9e94024a7726ec598d6e08ae04edb2f04d/cryptography-48.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48fe40804d4caa2288f24e70ca8c64c42dd826da0ad7e4f1b41b2128d679e6c8", size = 4690196, upload-time = "2026-06-09T22:31:54.74Z" }, { url = "https://files.pythonhosted.org/packages/5f/9f/a1bc8bcc798811b8527eb374bbccf30a3f3e806829d967118222bf1125eb/cryptography-48.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:86be3b1b0b6bf09482fb50a979c508d2950ed95f5621ec77f4e385962006b83a", size = 4696782, upload-time = "2026-06-09T22:31:45.615Z" }, { url = "https://files.pythonhosted.org/packages/66/c2/81a4fb4e4373c500bb526bc337ac5719dd31dd15b970b84a238168c6aa08/cryptography-48.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4ab0a343c807bbcd90c971cd1ecf072937cd01847a9e002bef88fb47ac6be577", size = 4696618, upload-time = "2026-06-09T22:31:11.564Z" }, @@ -294,10 +339,14 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/68/ab/8aaa12e4516ec4464033ab79b6f3b592bd5a92102467c4ace8a0d970203f/cryptography-48.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b74ca3b8e5ecdd833bf6a002ca41b4793bb27fb8f1c06ffaf2643c9e9140e31b", size = 4731388, upload-time = "2026-06-09T22:32:04.019Z" }, { url = "https://files.pythonhosted.org/packages/1b/24/50027ea4dca85ec1f40688f3c24fb32ccacd520583c9592c3cc95628e6fb/cryptography-48.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2c37f2461406063b417837f5f3daab668652acd82423efcd7f0a9f04be972de1", size = 4824186, upload-time = "2026-06-09T22:32:18.707Z" }, { url = "https://files.pythonhosted.org/packages/52/41/04cb5eb17085ade6f50cc611fb657df6a0f5885350de8764ece89c050197/cryptography-48.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:86fe77abb1bd87afb251d4d02ada7ecf53a32cee9b67d976abb2e45a13297475", size = 4964539, upload-time = "2026-06-09T22:31:18.793Z" }, + { url = "https://files.pythonhosted.org/packages/36/bf/ed70785c496e89d7e73b7cda2d21f2447fd6d4e821714b8d04ff217fed92/cryptography-48.0.1-cp39-abi3-win32.whl", hash = "sha256:6b2c0c3e6ccf3ade7750f836ef3ee36eea250cc467d45c256895573ac08cc6f1", size = 3282307, upload-time = "2026-06-09T22:30:53.162Z" }, + { url = "https://files.pythonhosted.org/packages/b3/ff/371ea7d252656ee1eb6d83eeeef3d1d0c6baf1d6497687d081ea03814670/cryptography-48.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:9a49ca6c81417f6a5edb50375a60cccdd70fa0a91a5211829dbea74eba94d2ac", size = 3793408, upload-time = "2026-06-09T22:32:15.191Z" }, + { url = "https://files.pythonhosted.org/packages/a9/d3/eb4e394e587341fdad09a09101fa76478ead3a78b0ad63e55c22f0d75c02/cryptography-48.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:08a597acce1ff37f347400087776599e2348a3a8bc53b44120e463cd274efe4a", size = 3951747, upload-time = "2026-06-09T22:31:23.871Z" }, { url = "https://files.pythonhosted.org/packages/e0/4a/3f43451b4f858bfceaaaffc649e6e787e8d4fb332a1d443af39ab02cc8f1/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:735824ec41b7f74a7c45fb1591349333e4c696cb6c044e5f46356e560143e4cd", size = 4641226, upload-time = "2026-06-09T22:31:02.532Z" }, { url = "https://files.pythonhosted.org/packages/73/4e/855584c2c23b09e4ce2d3b9c30e983e679cd60b068c513c6bbdb91e11782/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:92a46e1d638daa264ba2971c0b0489c9409787943efae4d60ffda3d091ef832c", size = 4668958, upload-time = "2026-06-09T22:32:06.213Z" }, { url = "https://files.pythonhosted.org/packages/42/3b/d35750e41d803d1e516fd6d6011f065424924da7af1748cef4cc9cb3ede1/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:7e234ac052af99f2700826a5c29ea99d9c1b1f80341cde62d11c8154dc8e0bd9", size = 4640793, upload-time = "2026-06-09T22:32:26.331Z" }, { url = "https://files.pythonhosted.org/packages/ca/aa/cdb7181fe865285e87e96825aaab239400f1de0c3bfba9bd9769b79f1a92/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:33842cf0888951cef5bc7ac724ab844a42044c1727b967b7f8997289a0464f92", size = 4668505, upload-time = "2026-06-09T22:31:27.534Z" }, + { url = "https://files.pythonhosted.org/packages/5d/8c/ce3823c06c2804f194f9e64f0d67fa3f4094a39f2bb1a990cd03603af8fc/cryptography-48.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6184ca7b174f28d7c703f1290d4b297217c45355f77a98f67e9b7f14549ac54a", size = 3742204, upload-time = "2026-06-09T22:31:34.773Z" }, ] [[package]] @@ -333,37 +382,61 @@ wheels = [ [[package]] name = "execnet" source = { editable = "." } +dependencies = [ + { name = "trio", marker = "python_full_version < '3.11'" }, +] [package.optional-dependencies] -testing = [ +gevent = [ { name = "gevent" }, - { name = "hatch" }, - { name = "pre-commit" }, +] +testing = [ + { name = "asyncssh" }, + { name = "hypothesis" }, { name = "pytest" }, { name = "pytest-timeout" }, - { name = "tox" }, + { name = "trio" }, { name = "uv" }, ] +trio = [ + { name = "trio" }, +] [package.dev-dependencies] -testing = [ +dev = [ { name = "execnet", extra = ["testing"] }, + { name = "hatch" }, + { name = "pre-commit" }, + { name = "pytest-xdist" }, + { name = "tox" }, +] +gevent = [ + { name = "gevent" }, ] [package.metadata] requires-dist = [ - { name = "gevent", marker = "extra == 'testing'" }, - { name = "hatch", marker = "extra == 'testing'" }, - { name = "pre-commit", marker = "extra == 'testing'" }, + { name = "asyncssh", marker = "extra == 'testing'" }, + { name = "gevent", marker = "extra == 'gevent'" }, + { name = "hypothesis", marker = "extra == 'testing'" }, { name = "pytest", marker = "extra == 'testing'", specifier = ">8.0" }, { name = "pytest-timeout", marker = "extra == 'testing'" }, - { name = "tox", marker = "extra == 'testing'" }, + { name = "trio", marker = "python_full_version < '3.11'", specifier = ">=0.32" }, + { name = "trio", marker = "extra == 'testing'", specifier = ">=0.32" }, + { name = "trio", marker = "extra == 'trio'", specifier = ">=0.32" }, { name = "uv", marker = "extra == 'testing'" }, ] -provides-extras = ["testing"] +provides-extras = ["gevent", "testing", "trio"] [package.metadata.requires-dev] -testing = [{ name = "execnet", extras = ["testing"] }] +dev = [ + { name = "execnet", extras = ["testing"] }, + { name = "hatch" }, + { name = "pre-commit" }, + { name = "pytest-xdist", specifier = ">=3.8.0" }, + { name = "tox" }, +] +gevent = [{ name = "gevent", specifier = ">=26.7.0" }] [[package]] name = "filelock" @@ -376,7 +449,7 @@ wheels = [ [[package]] name = "gevent" -version = "26.5.0" +version = "26.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation == 'CPython' and sys_platform == 'win32'" }, @@ -384,106 +457,139 @@ dependencies = [ { name = "zope-event" }, { name = "zope-interface" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c4/cb/98aa3a299e2fc4a2372b5d124863e02965b64579ffc29fe54d0641e65b2f/gevent-26.5.0.tar.gz", hash = "sha256:1655eb04c1e20d71b2aa4a3c7528162dd58ff6cc46a037af1f01f534c80fefba", size = 6712354, upload-time = "2026-05-20T21:22:45.132Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/72/b7/01a5880e01702f39fb09e3616c624054a0dc9a82561a865f3b1eff4bfc80/gevent-26.5.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:2ba673dcbf7747513b58fa64ca7e9d6a828bc5c604d1552d23db89006d7911df", size = 2181491, upload-time = "2026-05-20T20:35:19.326Z" }, - { url = "https://files.pythonhosted.org/packages/8d/fe/035ec5fa58a886740a744380118f03a90ac2da3f6c9cba248f28074ce40a/gevent-26.5.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:271b1474d81bb33036631adb16a35e5a1ee9dc414b05c999d6b01dc839a89975", size = 2212161, upload-time = "2026-05-20T20:43:25.678Z" }, - { url = "https://files.pythonhosted.org/packages/2a/ea/ea87c08931c9e4c6c40bb05a2cb19c2d6f93fe6e0052f9152ea5ade6d037/gevent-26.5.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:cd3dc60581687e2618286108f8e2f820d8446be4b34131065011c066e911d39c", size = 1768295, upload-time = "2026-05-20T21:17:29.438Z" }, - { url = "https://files.pythonhosted.org/packages/da/92/1d0e7287ae55700a8d25153ac736896bd9bcc3f85a12d374ef398db4b33c/gevent-26.5.0-cp311-cp311-manylinux_2_28_ppc64le.whl", hash = "sha256:dc7fa28b2d627f8e87595f39043b6dec71e8e7fb97e685e5506c47cf3ff8cb2e", size = 1862627, upload-time = "2026-05-20T21:15:59.365Z" }, - { url = "https://files.pythonhosted.org/packages/3a/4c/7f5ed67e52dfdef4ff91ae1a6fb28186d52e2496962edc8f17bdea9ab2c0/gevent-26.5.0-cp311-cp311-manylinux_2_28_s390x.whl", hash = "sha256:68c5fc21cef80268cdff88a4ae6c025fabb019b071f6f8ee4d20a7bccbddb873", size = 1804690, upload-time = "2026-05-20T21:30:51.713Z" }, - { url = "https://files.pythonhosted.org/packages/4c/75/0f5da6ca045f8a052203e1810058029f4b682507a789b413cac7d28bae28/gevent-26.5.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:d325502eb0695708ef8c899f605573ed6847f3961f8159627dba267fbf3ce457", size = 2119054, upload-time = "2026-05-20T20:35:22.678Z" }, - { url = "https://files.pythonhosted.org/packages/6e/f1/fcff7f7fad2bb33f3742db6b2145825a2191c0cd31d75789b0741fd28faf/gevent-26.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a11daf3a588b932c8bf965fb18444c69aff48badec88435e988cf8d67137075a", size = 1778784, upload-time = "2026-05-20T21:16:38.182Z" }, - { url = "https://files.pythonhosted.org/packages/98/57/151314f00bdc6ba77333febb3e9dc97fdf94d79426559b4fa8332f0c2b6e/gevent-26.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1101b5ef82a3fb178550cfd80f32293dc8dd2f3d0828292223ebba29d6f76e33", size = 2145373, upload-time = "2026-05-20T20:43:27.255Z" }, - { url = "https://files.pythonhosted.org/packages/1a/b5/7a02f711db62cbed1c1a00e1f9ff50eef95ccc78d4c04a0f93636655d1b7/gevent-26.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:5233109ad4f3af16393ba9888f238919a05ce15ce68d6831ac8a0da8dfb750ae", size = 1696576, upload-time = "2026-05-20T20:15:49.62Z" }, - { url = "https://files.pythonhosted.org/packages/f8/9b/5022adc310697ef25c6fb22eb9bf0ebcad3427b51776e882709de9a8b6d7/gevent-26.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:3be804565168ffacebeb21af9f1cd689831a89f0f12fc0c3f423c730c3c9eb31", size = 1552095, upload-time = "2026-05-20T20:16:54.81Z" }, - { url = "https://files.pythonhosted.org/packages/37/0b/1a530b2db55c97cc0cf44116201f538f3033c04c1d2aca143979b412f4be/gevent-26.5.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:e80ad2a8a1e8bdaa5605e3bf4929e0cebf9ea7b8237c83362f7257698bb14280", size = 2929714, upload-time = "2026-05-20T20:13:24.656Z" }, - { url = "https://files.pythonhosted.org/packages/b9/df/32fe851ed5f68493f354e09b19bdebae0de1185be4db0b2988e71e737fd3/gevent-26.5.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:fe42c037253580a3386fce275f8a2a845e540f5a729916934a732f13d42e72cc", size = 1784838, upload-time = "2026-05-20T21:17:31.063Z" }, - { url = "https://files.pythonhosted.org/packages/e5/9a/21332674f9a10e8cdf13b41b52e9d663647a1c6e1dc3c62b07c0aeefd360/gevent-26.5.0-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:9f463c7d6f69d13b6fe8e3b832a6175a6e95328a940f38495d25496d1ae8ad88", size = 1880440, upload-time = "2026-05-20T21:16:00.881Z" }, - { url = "https://files.pythonhosted.org/packages/9f/b1/5f8a4196113cf7f3fdd987b483f7e6b10c28ea3930c4727e31ba8cce51b6/gevent-26.5.0-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:96d5e96b1b14a4c1023dcfcc114533217f13febc3b6169254f23fc18d19fee29", size = 1831592, upload-time = "2026-05-20T21:30:53.832Z" }, - { url = "https://files.pythonhosted.org/packages/4e/69/1559b1f6b5107a9118fccd300240879bd581b6d87b03d568d0d155ea702c/gevent-26.5.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:bccff69c462e3650a0fd1d4e9cfc8b6effe15f3e9b1cad20a7bb5ce14b057efd", size = 2114915, upload-time = "2026-05-20T20:35:25.041Z" }, - { url = "https://files.pythonhosted.org/packages/e4/32/602c499d54472f64e5cdf6013aeab5ce6aa6fed005387e8b4f2d22f5dc8d/gevent-26.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f519139354d5ca7625df9ddb1b2ffada885c14abc5b4dbae3682e967ddf79669", size = 1796906, upload-time = "2026-05-20T21:16:39.65Z" }, - { url = "https://files.pythonhosted.org/packages/f9/3c/2fe77ee6e3d381b3c50c0b7d6c4c08c08b8ff5e8c0d9dd51a3b426d61eec/gevent-26.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0bf57df54f1c66273bf3601c2a1e41b12138fe848933718369663bc54f177ca2", size = 2140806, upload-time = "2026-05-20T20:43:28.895Z" }, - { url = "https://files.pythonhosted.org/packages/22/d5/4620797bbd9c88f4541188efc138b0d615f9834db540da36a2249ee929c5/gevent-26.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:e49ce0de007dfd7412edbc2b5d41cce33b049bb1b7086f50be5a09e601bde603", size = 1699995, upload-time = "2026-05-20T20:15:39.311Z" }, - { url = "https://files.pythonhosted.org/packages/cb/83/ac3477dfc0f9fd80c88110102c73cefc35dcded2b248544f45a8fa5412df/gevent-26.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:5c5ff29495a2eed2a244de8150f21893d6c1b15d8b4b5719ab4bbfa06db1e28f", size = 1547433, upload-time = "2026-05-20T20:15:51.656Z" }, - { url = "https://files.pythonhosted.org/packages/7d/47/5b992ab9c8037633cfd0fe698a97a878f59d8eb53c381e91e9a1a76fd215/gevent-26.5.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:9b4d3f34c913d1a6bec6d030365a517f3b527a9773b12e58cf56c3339bbe96e6", size = 2952523, upload-time = "2026-05-20T20:13:04.698Z" }, - { url = "https://files.pythonhosted.org/packages/74/11/c7dfc773eb43331a682efed610b49df6e976331f1b0e1c592a0c35d29872/gevent-26.5.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:1d8da4e799431feeb4c9e441ac7431f0baabb9106976790d884289d08ac08359", size = 1787044, upload-time = "2026-05-20T21:17:32.845Z" }, - { url = "https://files.pythonhosted.org/packages/ae/28/9812933dac93560f46910a9e834805fe76f822c408bd1c20cdf299d7c311/gevent-26.5.0-cp313-cp313-manylinux_2_28_ppc64le.whl", hash = "sha256:51becdb4c30a8f45c1c028ad7a97bf5a1ed141f74b159a31aa9cc6aa1e6263a6", size = 1882342, upload-time = "2026-05-20T21:16:02.645Z" }, - { url = "https://files.pythonhosted.org/packages/96/4b/514f248f69b2230b69b0bb17f4158b0b05dd4b2cb469a60ab206e9fe7496/gevent-26.5.0-cp313-cp313-manylinux_2_28_s390x.whl", hash = "sha256:c42bbcd3d453b08ad8915fd3feaf3d44a3562cdf1c7b208f9837149711e16d9d", size = 1834136, upload-time = "2026-05-20T21:30:55.739Z" }, - { url = "https://files.pythonhosted.org/packages/53/67/f5f30716efca99b6200ae89a9303a7e94dae085b7de6f6d0033c52a37f4b/gevent-26.5.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:bd3445e4fbeeb46690ed8efe94b8d1d46b14aa04af8866ae7a8da5997828d1c6", size = 2115349, upload-time = "2026-05-20T20:35:28.132Z" }, - { url = "https://files.pythonhosted.org/packages/09/d8/60e8809bde7986e6c4e6d106080b3603fa09b3bb0255fed1a4d8282e3ca2/gevent-26.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b573d5b2826edc705f31f07da6889ad483a6a0d64944ebd8d32205f7c5bf46fb", size = 1799443, upload-time = "2026-05-20T21:16:41.928Z" }, - { url = "https://files.pythonhosted.org/packages/f8/41/b388b2b1f0a026ea30687e51ddf81dbb783dfb55fac0a16708d2821d99e5/gevent-26.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4d53b1b28f2082a151bded2850b53f6baed02f742d2a1584029e8bd42d457fb4", size = 2141117, upload-time = "2026-05-20T20:43:30.694Z" }, - { url = "https://files.pythonhosted.org/packages/b1/f3/ac9a4b0de487e390c5d53a908a9347c0df0102de2bbf3e8603087769191d/gevent-26.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:23569ce0c254eb821fc3dcfe250843dde8b3180b09bae9e222e41aa3fa4885b7", size = 1699862, upload-time = "2026-05-20T20:15:33.642Z" }, - { url = "https://files.pythonhosted.org/packages/2a/cf/1ef1fc9b390563c0f97702f94a557d1649b7bbb5724f9b86c2122747e92f/gevent-26.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:40cdcdb2e404b6c82b82a4576bdb33958f23fc2deb0d933e9e022b362001e647", size = 1545341, upload-time = "2026-05-20T20:16:26.229Z" }, - { url = "https://files.pythonhosted.org/packages/17/55/7d98d3888e7bb9ad4656420dec69232ecbbea48792aff9295d0ad7cf8435/gevent-26.5.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:75a0050e4b87f08ddee7e56f59e6014cd7fcdc3153046c09a847940515d12c85", size = 2968223, upload-time = "2026-05-20T20:13:17.223Z" }, - { url = "https://files.pythonhosted.org/packages/f8/b4/e8e116fcbcb9dc0bf3acc50037f86e1204c217c8ed5defde68be11b3aab6/gevent-26.5.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:fd1a0b83a04e19378d9466ae0ee2b5937cf1d7fbfdcb916b2aea82179a208574", size = 1793926, upload-time = "2026-05-20T21:17:34.321Z" }, - { url = "https://files.pythonhosted.org/packages/28/07/7b267e9754b661defb93542e97731a4df21f8a40dc0f6c853faa717cf124/gevent-26.5.0-cp314-cp314-manylinux_2_28_ppc64le.whl", hash = "sha256:4c964c15076e76391d523ec24202f579a2535f7e301a40efb1656ae046d3eb69", size = 1887632, upload-time = "2026-05-20T21:16:04.158Z" }, - { url = "https://files.pythonhosted.org/packages/5c/50/b47d29e99449bd13b557ffa451401dc13d397a9923f562ef90a4e8514502/gevent-26.5.0-cp314-cp314-manylinux_2_28_s390x.whl", hash = "sha256:45d5438d1c84da5df7e832434627624709543630977332bb4e2d05ecca362cc9", size = 1838688, upload-time = "2026-05-20T21:30:57.979Z" }, - { url = "https://files.pythonhosted.org/packages/8b/eb/5b54ccff11bc7d7bebd40a24571ccc115d5cdae4f6c32ab457b43b436e42/gevent-26.5.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:354f35924113abc954819216c2a6ee16751958c615681e0490946e31b437bd2f", size = 2120351, upload-time = "2026-05-20T20:35:32.699Z" }, - { url = "https://files.pythonhosted.org/packages/9c/70/30fd325c30e04b1e5174c61945e17421d53ddb2450366cc52cef234f8c4b/gevent-26.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a47cd2d32f6404212d374ad8014a3491d7477dcf0cc09c5a2308ad6d325fd663", size = 1806684, upload-time = "2026-05-20T21:16:43.87Z" }, - { url = "https://files.pythonhosted.org/packages/cd/e8/fbf911ac3f9524ecfaed174d100fde671904ab8db92ceaf07faaebd13386/gevent-26.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:032157cebdedb84f2f52cdd980f2f5f2623eed6a8f083aadf44b44c47f628642", size = 2146606, upload-time = "2026-05-20T20:43:32.216Z" }, - { url = "https://files.pythonhosted.org/packages/9e/4d/284fcbbfde66fd978c2980c1fbe0eabd586af6e4b728649e9cf459e8b38f/gevent-26.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:9c414935ba5fc88359110968851d3616f119082c937390d00a1c0f4f59be814f", size = 1722497, upload-time = "2026-05-20T20:16:44.274Z" }, - { url = "https://files.pythonhosted.org/packages/15/d2/9f66eb53434704402be0ba733bf3320bf589671a4b76fac52a7d6077e972/gevent-26.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:2a0f5993a04b95a35b3a118b1a58ba272833f9b547b774001dea29f90620882f", size = 1574249, upload-time = "2026-05-20T20:15:50.873Z" }, - { url = "https://files.pythonhosted.org/packages/dc/d5/b4c50adb761878e3c96642b9f79bf44cee3120f3df55cd40876f51d89866/gevent-26.5.0-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:2e117df896a2660c9ebd4e2b5afc02dfd6e2ddf9b495e787e67c72d105432b09", size = 2971993, upload-time = "2026-05-20T20:12:50.845Z" }, - { url = "https://files.pythonhosted.org/packages/03/83/71c2a945e80198422d1d93dbe67355f249fb456b451bf9201199d3ef6a1a/gevent-26.5.0-cp315-cp315-manylinux_2_28_aarch64.whl", hash = "sha256:af5ffe9c11ffb8a39b6bef2e8b722aa2043ae4980977915c6aa8c68b4bc26e46", size = 1796658, upload-time = "2026-05-20T21:17:35.968Z" }, - { url = "https://files.pythonhosted.org/packages/42/96/548ca77aed5cb9a44e855a6c23ebceeb3554a0ea9ca0c01c311878899a3e/gevent-26.5.0-cp315-cp315-manylinux_2_28_ppc64le.whl", hash = "sha256:7da34aef7e87c43dd3662e5785e79ed505c01399a7cb42876d2d8925969fd75f", size = 1891473, upload-time = "2026-05-20T21:16:05.657Z" }, - { url = "https://files.pythonhosted.org/packages/f6/4f/f48bd47d5287afb0fbcc56165f3ed47583f1803bad401653fe27e71ade2d/gevent-26.5.0-cp315-cp315-manylinux_2_28_s390x.whl", hash = "sha256:1c6293a7046bcc6f3d8972a74b19cd7a4cfd02d3881edf0fcf827aa514bd247b", size = 1841429, upload-time = "2026-05-20T21:30:59.907Z" }, - { url = "https://files.pythonhosted.org/packages/a0/72/1925215fc720d2561fa3ec8d4af5f098f8d0cbfa76a45fafed6e5ade7718/gevent-26.5.0-cp315-cp315-manylinux_2_28_x86_64.whl", hash = "sha256:d3bde0f140a275b2fa88e4b6516bda85551930e10bc2fd95e18c1b7d11cb780c", size = 2123895, upload-time = "2026-05-20T20:35:34.964Z" }, - { url = "https://files.pythonhosted.org/packages/83/59/0f584f6b1170c9a6abd9b70ccf5e9cc5ead34eabafabc0e21876ef0fe6f7/gevent-26.5.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:e29fb4b17d9958ec8cb7f6339a111b29bc23f2c2efbef86189d1248bb4862d17", size = 1809047, upload-time = "2026-05-20T21:16:45.977Z" }, - { url = "https://files.pythonhosted.org/packages/82/88/61e854bfd98ac22eac78a97fc6db10de0f9ace46514072b435c217168729/gevent-26.5.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:b2239df2f7570efa03736678f3f053bb1bdd22a8a16cd28a2feb7d32ea5f533f", size = 2150764, upload-time = "2026-05-20T20:43:33.781Z" }, - { url = "https://files.pythonhosted.org/packages/b9/f5/af048b97433d7f9a7df7f5510b2c46918b7d073dcfb3bf6d0ef0e5a83dcc/gevent-26.5.0-cp315-cp315-win_amd64.whl", hash = "sha256:aae214952fd38d27a42dc416bb70193962ec932384b63445d29bbb5817a1c042", size = 1722600, upload-time = "2026-05-20T20:19:56.81Z" }, - { url = "https://files.pythonhosted.org/packages/11/95/fb74a2299c6a2d78d9de12deaaac640ab5d2ef96a8e0f97a3ff84b9ca84b/gevent-26.5.0-cp315-cp315-win_arm64.whl", hash = "sha256:f7067564f139e33bf26a31ee3b13d168d76eb99a44b85ced626652b158baa80c", size = 1574406, upload-time = "2026-05-20T20:17:12.125Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/9f/5c/92002455a57cb3634383e2b822e3bccf409f43cde34528e46428971475cf/gevent-26.7.0.tar.gz", hash = "sha256:5b333a556e38a302b1b8c80525bef16d437e16f1e7767947789406841856a102", size = 6729213, upload-time = "2026-07-22T20:16:04.713Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/60/878d0cdef05d952ac7f17ffe385143fb0f3720afce0f6ff5ddbf7aac0342/gevent-26.7.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:80e98fc808bd9cc5c911d78a443d214bf0c8f96c9fdd296893df7e40364d5f37", size = 2198781, upload-time = "2026-07-22T16:48:28.588Z" }, + { url = "https://files.pythonhosted.org/packages/69/79/6ce781b60049060e9d89b3d0fe60940353adeb39856aaad5ee925fd127e9/gevent-26.7.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bf4b946b47cc6fdbdf9221f891db9a44df92166435c027760ee7dbdfb4039adc", size = 2229318, upload-time = "2026-07-22T17:02:11.713Z" }, + { url = "https://files.pythonhosted.org/packages/6d/38/48898f35c2092d699b755b01918551358db72b554c63f553c8f027d2bf31/gevent-26.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:55ce0b7f87f9befcc788d77eb039b1de89a35f37afc31942e12c7ae090a563b8", size = 1700480, upload-time = "2026-07-22T16:26:16.794Z" }, + { url = "https://files.pythonhosted.org/packages/93/51/53370896942523c333699394ccad379d186648dbfb913f42ce094ddfb4b3/gevent-26.7.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:7f7143823ef99bc657534a2b6e8cbadedc910750cc0b4f4b4438a58d9fe43ab2", size = 1783048, upload-time = "2026-07-22T18:11:25.996Z" }, + { url = "https://files.pythonhosted.org/packages/ba/56/5a2cb36d75d3b626d6ffa116673b34442bf5afd6f7ab4b98e512c1b008d6/gevent-26.7.0-cp311-cp311-manylinux_2_28_ppc64le.whl", hash = "sha256:ca4019899830471910129968251c795c8aee59e225fd16326ae01c1f93f3cfa6", size = 1880257, upload-time = "2026-07-22T18:10:40.919Z" }, + { url = "https://files.pythonhosted.org/packages/e9/ed/ee7eb2f03a38a4f33b0f327bab16d3158b3a794588a84dba739cbf4a3e68/gevent-26.7.0-cp311-cp311-manylinux_2_28_s390x.whl", hash = "sha256:e4042da317a96d12110831cc404855f0c501a5a5aa476a7a18c3b480a5a59233", size = 1819378, upload-time = "2026-07-22T18:29:06.444Z" }, + { url = "https://files.pythonhosted.org/packages/5e/aa/e2a202c03cff4f49bba54cc321c3afc29eee9d6fc452f4aa94d2f00e7d3d/gevent-26.7.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:5c97ca98e1aae427a267eae0fbfe8d0884327e6b1cd51fc2ef6642b8b0b82701", size = 2136837, upload-time = "2026-07-22T16:48:29.939Z" }, + { url = "https://files.pythonhosted.org/packages/ac/64/4892fbca47aa4e06b86aef96d6146bd61175b23b7db431ec7937d73b2f72/gevent-26.7.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5d5d1864bc3db92d1f82d1790395eda99f98b47fd9f7ec02c4e182d7828a8251", size = 1794058, upload-time = "2026-07-22T18:07:10.644Z" }, + { url = "https://files.pythonhosted.org/packages/21/23/90bb7d0c6f59d2973bb8f4bd3164be00e466823dea01568e0cb36f2afb77/gevent-26.7.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:15fd2d88ed5370f8084079758758df91f26d2f68575e1ee76fce604ddba83e5e", size = 2159797, upload-time = "2026-07-22T17:02:13.229Z" }, + { url = "https://files.pythonhosted.org/packages/a3/67/4d1e315ee3052530fa8537e0cdf1f9c3a6606740f7372f420c7d12d5cf82/gevent-26.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:514bda3fff741d7e5ab108ee1d31550a7f4b2fd3dc6e3b6f38dfb8685efdafaa", size = 1682414, upload-time = "2026-07-22T16:26:22.18Z" }, + { url = "https://files.pythonhosted.org/packages/02/b1/d1b1de89677ee39e641ad8501ed72c2b99f1ddba1c14c462675f40b37a66/gevent-26.7.0-cp311-cp311-win_arm64.whl", hash = "sha256:0f26f9a8c32ac0a73f6084c59b63deeacb350e7f1fee5301d95c5e0683a390d4", size = 1562794, upload-time = "2026-07-22T16:27:53.205Z" }, + { url = "https://files.pythonhosted.org/packages/2b/66/104590ad3a9e671b3ef77ad19c1cc50e7f1c8c220b27ddfaf34e5f88bd9c/gevent-26.7.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:92f256285fb43a57f152bd2e51a59cde1cd0b20869ae1e6da583b6beab88ed8a", size = 2953977, upload-time = "2026-07-22T16:23:41.654Z" }, + { url = "https://files.pythonhosted.org/packages/64/07/350d87161378633714184828bdc57c66f9b525eca5249ad1294dc7f8cf58/gevent-26.7.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:0e4fea187c5df7168b9538b4f543fcb0fcbaeb93be3d6cd499c324652c740704", size = 1800960, upload-time = "2026-07-22T18:11:27.242Z" }, + { url = "https://files.pythonhosted.org/packages/c4/6c/ddfe298c2ecb1cfc72a03dd69ba751759f7e7835d3135f171b284eb09ed1/gevent-26.7.0-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:ce732fe08d0ea65de07eff6e46bade8ac6a6fdb65cc748c713f3d31ae122529e", size = 1900387, upload-time = "2026-07-22T18:10:42.973Z" }, + { url = "https://files.pythonhosted.org/packages/f9/89/2647bbbf1da35a1c271a54452e76065308cbaf684ee32a79f989ca4265f6/gevent-26.7.0-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:c25b3522072137aecf3389031039230190038f888e257f490b3897d0e0620f74", size = 1848046, upload-time = "2026-07-22T18:29:08.074Z" }, + { url = "https://files.pythonhosted.org/packages/31/52/4f9b4c536b5a0424e328d5a5466640d185020f1f0b08b2de0ca939cc0a0b/gevent-26.7.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:eaaa75c9014df3f8c310c64f53f1152af8c6be32e82734396bed91e1d0e6f35c", size = 2132200, upload-time = "2026-07-22T16:48:31.203Z" }, + { url = "https://files.pythonhosted.org/packages/42/88/1daffa63b257c381df68018e4d3da72b429955cf95a171a46d3220422c8d/gevent-26.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f1956032a9926ac9b4152b2a50bc5a2cc020722ec16928ccaf32e227ee0aae47", size = 1814237, upload-time = "2026-07-22T18:07:12.438Z" }, + { url = "https://files.pythonhosted.org/packages/73/4c/b996cdddca78eac435195cd97dff590c65a9e0052640bcb6f8b6f1e30b1d/gevent-26.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d989a1ad6cc54f5c69bb7304360f98b4fda80da2b773f1047db9fba61ae7379a", size = 2157938, upload-time = "2026-07-22T17:02:14.887Z" }, + { url = "https://files.pythonhosted.org/packages/08/fd/44419d7559a95e238ee45d29df30bad78a91d343cb27b13a6af552b907bb/gevent-26.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:e0c9ce2d80fc0f8894d748a1045ff26ad188e294bad656b29839271800827c85", size = 1685319, upload-time = "2026-07-22T16:26:13.954Z" }, + { url = "https://files.pythonhosted.org/packages/6f/7a/7df1762ccd9a40ce1ca626f50cb7a75758f2c930aabcc73c91cf00cb3448/gevent-26.7.0-cp312-cp312-win_arm64.whl", hash = "sha256:959effe0c56cdee0bf761e5c4e78ab62880be147a2f2aa31112ca2f7e5754e53", size = 1558945, upload-time = "2026-07-22T16:26:56.737Z" }, + { url = "https://files.pythonhosted.org/packages/75/63/0fcfbe3f5696e56424f331ec41e0e447cea79c384848d6019e3f7f340f4b/gevent-26.7.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:b1b89eb5566f75aa8b2bbdb0308e1ac8d9113ca7cff85b45366aea9faad639a1", size = 2976844, upload-time = "2026-07-22T16:24:39.275Z" }, + { url = "https://files.pythonhosted.org/packages/3c/6c/ea2d0afbe760c18df5bd1631dbe5a73d840d9b141cb71e6810157c2ae28a/gevent-26.7.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:449857ce058183442e2d71d83ff0c587a3ddff631e93c6d19a6dffb4814eccad", size = 1802332, upload-time = "2026-07-22T18:11:29.155Z" }, + { url = "https://files.pythonhosted.org/packages/59/90/36f2258f1bfe8601224f6159066103b832c31e1451ce8dc2cd2408b6ecf4/gevent-26.7.0-cp313-cp313-manylinux_2_28_ppc64le.whl", hash = "sha256:8260a3f38b05fcf3c283417b18617562dbec74f5784f748e4ba3866789d7f3a4", size = 1901253, upload-time = "2026-07-22T18:10:44.402Z" }, + { url = "https://files.pythonhosted.org/packages/dd/38/86dd67e5c2dfab016a9c935b4338d6cf8f9bfa72dc5a0f3fb879d993127a/gevent-26.7.0-cp313-cp313-manylinux_2_28_s390x.whl", hash = "sha256:30894398d06747b433c8923a6a77ede61259ce6822a99f6c6e7fa0216ccb73c3", size = 1850489, upload-time = "2026-07-22T18:29:09.694Z" }, + { url = "https://files.pythonhosted.org/packages/f1/33/f5651942a5967483298b6ce6f45572d33120dd0fd01c8991d3fca5b1e8ee/gevent-26.7.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:0b753522498118c9489753de7c612d4baed0edf384d9df2bf9492233ba1c20ff", size = 2129813, upload-time = "2026-07-22T16:48:32.551Z" }, + { url = "https://files.pythonhosted.org/packages/c0/09/abe8217a8fcd3f0e94c9eec024a5499c0f267cb269ccea6c0e2e812319b9/gevent-26.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:055a643026dc28daff2be228555a2097937448cc9b58307edebcf81b9d78ff4b", size = 1815121, upload-time = "2026-07-22T18:07:13.988Z" }, + { url = "https://files.pythonhosted.org/packages/40/d6/dbae1cd2d27b62664cefa086035530eb21203d45b12f466272441c048c9c/gevent-26.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4e1dc6a2712de67fd210e1f1a408601f6908b042f6420e188106f2f37f94ec71", size = 2155913, upload-time = "2026-07-22T17:02:16.35Z" }, + { url = "https://files.pythonhosted.org/packages/fc/42/90b662f4eb27d7727d4619d5c6be872117f1a9f187b243ec7f6fef988ce7/gevent-26.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:44e5280296129c0915addaefdb37d6e9bc124a77a433b1b1c8ddf1853c53f4e7", size = 1682483, upload-time = "2026-07-22T16:26:30.492Z" }, + { url = "https://files.pythonhosted.org/packages/e1/84/7297c56b9fff463c4ba2f685dbb913a855df903046dc68d14e8655a29ffe/gevent-26.7.0-cp313-cp313-win_arm64.whl", hash = "sha256:9f08b1aa6729f794409ca137e25f671e0d9bbda4451200c5e28a769375365388", size = 1556053, upload-time = "2026-07-22T16:26:14.365Z" }, + { url = "https://files.pythonhosted.org/packages/c1/bb/ab60d496cbdc0293ebbd6c2070b34da0632bd7a2ca20163c17e18d2d2dc9/gevent-26.7.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:0e0e3bf7ae0f82dbc5c6be26b4781e86c97f1e28d516b7a9746ac8b04bcc6948", size = 2992503, upload-time = "2026-07-22T16:24:36.503Z" }, + { url = "https://files.pythonhosted.org/packages/5c/35/75f27c06a82a5b22600aaccbd9567d89bb4091be43e96c02981f10aff23d/gevent-26.7.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:740050b53048207b080a1e183a377c47809ad0b7b7b0cd7eab0dea1045f7e480", size = 1809173, upload-time = "2026-07-22T18:11:30.724Z" }, + { url = "https://files.pythonhosted.org/packages/5b/5f/a6b32b4db3fa76bd8a070f0f46f5306123bf6336e7a0ca0cd2f9b99473df/gevent-26.7.0-cp314-cp314-manylinux_2_28_ppc64le.whl", hash = "sha256:67983607eb6c7bafa362c5c43b69a27145b936c34a3d6441ed42413d62fae0a6", size = 1906630, upload-time = "2026-07-22T18:10:45.836Z" }, + { url = "https://files.pythonhosted.org/packages/e1/87/832495d8fcc05ff7432f038b7c4decbd5632425a2cd5da2ce73cb2d800c4/gevent-26.7.0-cp314-cp314-manylinux_2_28_s390x.whl", hash = "sha256:475848518d708e07d1987c3d94cb8ff53e2b3a69df32e39feda2779cafe400b0", size = 1855278, upload-time = "2026-07-22T18:29:11.517Z" }, + { url = "https://files.pythonhosted.org/packages/72/8b/2f36c0fa389fa2b7ceb5a8972b0e7da7bc770f9135315cf4246c607ca5fc/gevent-26.7.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:0f8ed457dd616bfe6682569f92730f9ab45aafb1aeca5e80eb2f6b9a2ce26d11", size = 2136155, upload-time = "2026-07-22T16:48:33.865Z" }, + { url = "https://files.pythonhosted.org/packages/b5/98/09f2cfaa23dbce48e3271e95b0d003f93acece6b5cfd40f4cebe3850d79b/gevent-26.7.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:15373c68cf1fa14114bec2f09b16e2c65374bd5309e897e0a28740b09ce329e0", size = 1822108, upload-time = "2026-07-22T18:07:15.397Z" }, + { url = "https://files.pythonhosted.org/packages/b0/d8/05a294165c17569f04284ad3c889684c8780544885b4cdf77b1432947d0c/gevent-26.7.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:73f3d53f2f390369e290c933b75bd87f1f2261f2f2f2175aa667c43ee3049bad", size = 2162814, upload-time = "2026-07-22T17:02:18.066Z" }, + { url = "https://files.pythonhosted.org/packages/59/89/58a545c4eda33e106d6887a0387adc2249abc14c779e3eb88bbfdf3768d6/gevent-26.7.0-cp314-cp314-win_amd64.whl", hash = "sha256:f11b558d544ad2249029ba023cd6519ec3a0eee54a3d027e6515c1eaa322422a", size = 1706971, upload-time = "2026-07-22T16:27:02.263Z" }, + { url = "https://files.pythonhosted.org/packages/bb/cd/413f293e54961e5c89c54235370e3603ec0f561e7ace8357980410efbf78/gevent-26.7.0-cp314-cp314-win_arm64.whl", hash = "sha256:3871f4ca59ec2328c3ef638a0fe01a28a825443a133368dc78eb5ceadcad7609", size = 1585078, upload-time = "2026-07-22T16:30:48.145Z" }, + { url = "https://files.pythonhosted.org/packages/21/3a/47f29f632aaa38aa12410f57f1732fc50bfd4d4006d2e7e022ce731cabc9/gevent-26.7.0-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:3e3d6e20a94239ad353b776e72b8ce18c35dbe4e98c279aef3932651553d8404", size = 2996208, upload-time = "2026-07-22T16:23:23.859Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b3/4620f1ce81ecec9890229806c73f07dd022e40f76a552bd430391e7316c4/gevent-26.7.0-cp315-cp315-manylinux_2_28_aarch64.whl", hash = "sha256:ddbd3cc76b9bc69df651a216c2a62fc6415ad463b3ac9c6cbbbb8b7b8224af17", size = 1811545, upload-time = "2026-07-22T18:11:32.224Z" }, + { url = "https://files.pythonhosted.org/packages/97/fd/d285212ffd5585d511299e13e61d76262def8e826e9f20c92cb85df406f2/gevent-26.7.0-cp315-cp315-manylinux_2_28_ppc64le.whl", hash = "sha256:01ceab7e608dc1b9859d9511a0a29d7ce2e7d909ab19fddc860e70a2ed5b10ce", size = 1910418, upload-time = "2026-07-22T18:10:47.709Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e0/c5d666e6065652918cfb6e6a3cf8f721d0e152c57cec217ad81792a1323b/gevent-26.7.0-cp315-cp315-manylinux_2_28_s390x.whl", hash = "sha256:2e6c917b2b8baeb6080797a6b25e35e1fd784319a05bb92b87c53546e5578eb2", size = 1857891, upload-time = "2026-07-22T18:29:13.13Z" }, + { url = "https://files.pythonhosted.org/packages/a1/67/e945ed458fa98b34572876bfd0d35fe4fa3f1159f43660b71d982b7cb63e/gevent-26.7.0-cp315-cp315-manylinux_2_28_x86_64.whl", hash = "sha256:df75a1748b26030f2f7f10042cc45640b22954d9d0dc6b4b6f0dbe0b6751a2d4", size = 2138121, upload-time = "2026-07-22T16:48:35.358Z" }, + { url = "https://files.pythonhosted.org/packages/99/55/622468fa1a3c4cf51f20e14813f5cc1592fe6e44a42ccca9195a0b18c769/gevent-26.7.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:ee1b389587e5d5c1eb19d0455b5b4d7a0fb5c5287af4e226ec66d9dfd2548107", size = 1825114, upload-time = "2026-07-22T18:07:16.667Z" }, + { url = "https://files.pythonhosted.org/packages/0f/7a/151a2afcacf487ca25faf8b1bdd6c5b4ace2f7c1e6b4eaffe0a5e6e1df61/gevent-26.7.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:c2918641ba756f46aa01ab9dd82d6dfceec403c77c2787298746b411dcf0288e", size = 2165990, upload-time = "2026-07-22T17:02:19.817Z" }, ] [[package]] name = "greenlet" -version = "3.2.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/34/c1/a82edae11d46c0d83481aacaa1e578fea21d94a1ef400afd734d47ad95ad/greenlet-3.2.2.tar.gz", hash = "sha256:ad053d34421a2debba45aa3cc39acf454acbcd025b3fc1a9f8a0dee237abd485", size = 185797, upload-time = "2025-05-09T19:47:35.066Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/05/66/910217271189cc3f32f670040235f4bf026ded8ca07270667d69c06e7324/greenlet-3.2.2-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:c49e9f7c6f625507ed83a7485366b46cbe325717c60837f7244fc99ba16ba9d6", size = 267395, upload-time = "2025-05-09T14:50:45.357Z" }, - { url = "https://files.pythonhosted.org/packages/a8/36/8d812402ca21017c82880f399309afadb78a0aa300a9b45d741e4df5d954/greenlet-3.2.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3cc1a3ed00ecfea8932477f729a9f616ad7347a5e55d50929efa50a86cb7be7", size = 625742, upload-time = "2025-05-09T15:23:58.293Z" }, - { url = "https://files.pythonhosted.org/packages/7b/77/66d7b59dfb7cc1102b2f880bc61cb165ee8998c9ec13c96606ba37e54c77/greenlet-3.2.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7c9896249fbef2c615853b890ee854f22c671560226c9221cfd27c995db97e5c", size = 637014, upload-time = "2025-05-09T15:24:47.025Z" }, - { url = "https://files.pythonhosted.org/packages/36/a7/ff0d408f8086a0d9a5aac47fa1b33a040a9fca89bd5a3f7b54d1cd6e2793/greenlet-3.2.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7409796591d879425997a518138889d8d17e63ada7c99edc0d7a1c22007d4907", size = 632874, upload-time = "2025-05-09T15:29:20.014Z" }, - { url = "https://files.pythonhosted.org/packages/a1/75/1dc2603bf8184da9ebe69200849c53c3c1dca5b3a3d44d9f5ca06a930550/greenlet-3.2.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7791dcb496ec53d60c7f1c78eaa156c21f402dda38542a00afc3e20cae0f480f", size = 631652, upload-time = "2025-05-09T14:53:30.961Z" }, - { url = "https://files.pythonhosted.org/packages/7b/74/ddc8c3bd4c2c20548e5bf2b1d2e312a717d44e2eca3eadcfc207b5f5ad80/greenlet-3.2.2-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d8009ae46259e31bc73dc183e402f548e980c96f33a6ef58cc2e7865db012e13", size = 580619, upload-time = "2025-05-09T14:53:42.049Z" }, - { url = "https://files.pythonhosted.org/packages/7e/f2/40f26d7b3077b1c7ae7318a4de1f8ffc1d8ccbad8f1d8979bf5080250fd6/greenlet-3.2.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:fd9fb7c941280e2c837b603850efc93c999ae58aae2b40765ed682a6907ebbc5", size = 1109809, upload-time = "2025-05-09T15:26:59.063Z" }, - { url = "https://files.pythonhosted.org/packages/c5/21/9329e8c276746b0d2318b696606753f5e7b72d478adcf4ad9a975521ea5f/greenlet-3.2.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:00cd814b8959b95a546e47e8d589610534cfb71f19802ea8a2ad99d95d702057", size = 1133455, upload-time = "2025-05-09T14:53:55.823Z" }, - { url = "https://files.pythonhosted.org/packages/bb/1e/0dca9619dbd736d6981f12f946a497ec21a0ea27262f563bca5729662d4d/greenlet-3.2.2-cp310-cp310-win_amd64.whl", hash = "sha256:d0cb7d47199001de7658c213419358aa8937df767936506db0db7ce1a71f4a2f", size = 294991, upload-time = "2025-05-09T15:05:56.847Z" }, - { url = "https://files.pythonhosted.org/packages/a3/9f/a47e19261747b562ce88219e5ed8c859d42c6e01e73da6fbfa3f08a7be13/greenlet-3.2.2-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:dcb9cebbf3f62cb1e5afacae90761ccce0effb3adaa32339a0670fe7805d8068", size = 268635, upload-time = "2025-05-09T14:50:39.007Z" }, - { url = "https://files.pythonhosted.org/packages/11/80/a0042b91b66975f82a914d515e81c1944a3023f2ce1ed7a9b22e10b46919/greenlet-3.2.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf3fc9145141250907730886b031681dfcc0de1c158f3cc51c092223c0f381ce", size = 628786, upload-time = "2025-05-09T15:24:00.692Z" }, - { url = "https://files.pythonhosted.org/packages/38/a2/8336bf1e691013f72a6ebab55da04db81a11f68e82bb691f434909fa1327/greenlet-3.2.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:efcdfb9df109e8a3b475c016f60438fcd4be68cd13a365d42b35914cdab4bb2b", size = 640866, upload-time = "2025-05-09T15:24:48.153Z" }, - { url = "https://files.pythonhosted.org/packages/f8/7e/f2a3a13e424670a5d08826dab7468fa5e403e0fbe0b5f951ff1bc4425b45/greenlet-3.2.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4bd139e4943547ce3a56ef4b8b1b9479f9e40bb47e72cc906f0f66b9d0d5cab3", size = 636752, upload-time = "2025-05-09T15:29:23.182Z" }, - { url = "https://files.pythonhosted.org/packages/fd/5d/ce4a03a36d956dcc29b761283f084eb4a3863401c7cb505f113f73af8774/greenlet-3.2.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:71566302219b17ca354eb274dfd29b8da3c268e41b646f330e324e3967546a74", size = 636028, upload-time = "2025-05-09T14:53:32.854Z" }, - { url = "https://files.pythonhosted.org/packages/4b/29/b130946b57e3ceb039238413790dd3793c5e7b8e14a54968de1fe449a7cf/greenlet-3.2.2-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3091bc45e6b0c73f225374fefa1536cd91b1e987377b12ef5b19129b07d93ebe", size = 583869, upload-time = "2025-05-09T14:53:43.614Z" }, - { url = "https://files.pythonhosted.org/packages/ac/30/9f538dfe7f87b90ecc75e589d20cbd71635531a617a336c386d775725a8b/greenlet-3.2.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:44671c29da26539a5f142257eaba5110f71887c24d40df3ac87f1117df589e0e", size = 1112886, upload-time = "2025-05-09T15:27:01.304Z" }, - { url = "https://files.pythonhosted.org/packages/be/92/4b7deeb1a1e9c32c1b59fdca1cac3175731c23311ddca2ea28a8b6ada91c/greenlet-3.2.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c23ea227847c9dbe0b3910f5c0dd95658b607137614eb821e6cbaecd60d81cc6", size = 1138355, upload-time = "2025-05-09T14:53:58.011Z" }, - { url = "https://files.pythonhosted.org/packages/c5/eb/7551c751a2ea6498907b2fcbe31d7a54b602ba5e8eb9550a9695ca25d25c/greenlet-3.2.2-cp311-cp311-win_amd64.whl", hash = "sha256:0a16fb934fcabfdfacf21d79e6fed81809d8cd97bc1be9d9c89f0e4567143d7b", size = 295437, upload-time = "2025-05-09T15:00:57.733Z" }, - { url = "https://files.pythonhosted.org/packages/2c/a1/88fdc6ce0df6ad361a30ed78d24c86ea32acb2b563f33e39e927b1da9ea0/greenlet-3.2.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:df4d1509efd4977e6a844ac96d8be0b9e5aa5d5c77aa27ca9f4d3f92d3fcf330", size = 270413, upload-time = "2025-05-09T14:51:32.455Z" }, - { url = "https://files.pythonhosted.org/packages/a6/2e/6c1caffd65490c68cd9bcec8cb7feb8ac7b27d38ba1fea121fdc1f2331dc/greenlet-3.2.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da956d534a6d1b9841f95ad0f18ace637668f680b1339ca4dcfb2c1837880a0b", size = 637242, upload-time = "2025-05-09T15:24:02.63Z" }, - { url = "https://files.pythonhosted.org/packages/98/28/088af2cedf8823b6b7ab029a5626302af4ca1037cf8b998bed3a8d3cb9e2/greenlet-3.2.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9c7b15fb9b88d9ee07e076f5a683027bc3befd5bb5d25954bb633c385d8b737e", size = 651444, upload-time = "2025-05-09T15:24:49.856Z" }, - { url = "https://files.pythonhosted.org/packages/4a/9f/0116ab876bb0bc7a81eadc21c3f02cd6100dcd25a1cf2a085a130a63a26a/greenlet-3.2.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:752f0e79785e11180ebd2e726c8a88109ded3e2301d40abced2543aa5d164275", size = 646067, upload-time = "2025-05-09T15:29:24.989Z" }, - { url = "https://files.pythonhosted.org/packages/35/17/bb8f9c9580e28a94a9575da847c257953d5eb6e39ca888239183320c1c28/greenlet-3.2.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ae572c996ae4b5e122331e12bbb971ea49c08cc7c232d1bd43150800a2d6c65", size = 648153, upload-time = "2025-05-09T14:53:34.716Z" }, - { url = "https://files.pythonhosted.org/packages/2c/ee/7f31b6f7021b8df6f7203b53b9cc741b939a2591dcc6d899d8042fcf66f2/greenlet-3.2.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02f5972ff02c9cf615357c17ab713737cccfd0eaf69b951084a9fd43f39833d3", size = 603865, upload-time = "2025-05-09T14:53:45.738Z" }, - { url = "https://files.pythonhosted.org/packages/b5/2d/759fa59323b521c6f223276a4fc3d3719475dc9ae4c44c2fe7fc750f8de0/greenlet-3.2.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:4fefc7aa68b34b9224490dfda2e70ccf2131368493add64b4ef2d372955c207e", size = 1119575, upload-time = "2025-05-09T15:27:04.248Z" }, - { url = "https://files.pythonhosted.org/packages/30/05/356813470060bce0e81c3df63ab8cd1967c1ff6f5189760c1a4734d405ba/greenlet-3.2.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a31ead8411a027c2c4759113cf2bd473690517494f3d6e4bf67064589afcd3c5", size = 1147460, upload-time = "2025-05-09T14:54:00.315Z" }, - { url = "https://files.pythonhosted.org/packages/07/f4/b2a26a309a04fb844c7406a4501331b9400e1dd7dd64d3450472fd47d2e1/greenlet-3.2.2-cp312-cp312-win_amd64.whl", hash = "sha256:b24c7844c0a0afc3ccbeb0b807adeefb7eff2b5599229ecedddcfeb0ef333bec", size = 296239, upload-time = "2025-05-09T14:57:17.633Z" }, - { url = "https://files.pythonhosted.org/packages/89/30/97b49779fff8601af20972a62cc4af0c497c1504dfbb3e93be218e093f21/greenlet-3.2.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:3ab7194ee290302ca15449f601036007873028712e92ca15fc76597a0aeb4c59", size = 269150, upload-time = "2025-05-09T14:50:30.784Z" }, - { url = "https://files.pythonhosted.org/packages/21/30/877245def4220f684bc2e01df1c2e782c164e84b32e07373992f14a2d107/greenlet-3.2.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2dc5c43bb65ec3669452af0ab10729e8fdc17f87a1f2ad7ec65d4aaaefabf6bf", size = 637381, upload-time = "2025-05-09T15:24:12.893Z" }, - { url = "https://files.pythonhosted.org/packages/8e/16/adf937908e1f913856b5371c1d8bdaef5f58f251d714085abeea73ecc471/greenlet-3.2.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:decb0658ec19e5c1f519faa9a160c0fc85a41a7e6654b3ce1b44b939f8bf1325", size = 651427, upload-time = "2025-05-09T15:24:51.074Z" }, - { url = "https://files.pythonhosted.org/packages/ad/49/6d79f58fa695b618654adac64e56aff2eeb13344dc28259af8f505662bb1/greenlet-3.2.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6fadd183186db360b61cb34e81117a096bff91c072929cd1b529eb20dd46e6c5", size = 645795, upload-time = "2025-05-09T15:29:26.673Z" }, - { url = "https://files.pythonhosted.org/packages/5a/e6/28ed5cb929c6b2f001e96b1d0698c622976cd8f1e41fe7ebc047fa7c6dd4/greenlet-3.2.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1919cbdc1c53ef739c94cf2985056bcc0838c1f217b57647cbf4578576c63825", size = 648398, upload-time = "2025-05-09T14:53:36.61Z" }, - { url = "https://files.pythonhosted.org/packages/9d/70/b200194e25ae86bc57077f695b6cc47ee3118becf54130c5514456cf8dac/greenlet-3.2.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3885f85b61798f4192d544aac7b25a04ece5fe2704670b4ab73c2d2c14ab740d", size = 606795, upload-time = "2025-05-09T14:53:47.039Z" }, - { url = "https://files.pythonhosted.org/packages/f8/c8/ba1def67513a941154ed8f9477ae6e5a03f645be6b507d3930f72ed508d3/greenlet-3.2.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:85f3e248507125bf4af607a26fd6cb8578776197bd4b66e35229cdf5acf1dfbf", size = 1117976, upload-time = "2025-05-09T15:27:06.542Z" }, - { url = "https://files.pythonhosted.org/packages/c3/30/d0e88c1cfcc1b3331d63c2b54a0a3a4a950ef202fb8b92e772ca714a9221/greenlet-3.2.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:1e76106b6fc55fa3d6fe1c527f95ee65e324a13b62e243f77b48317346559708", size = 1145509, upload-time = "2025-05-09T14:54:02.223Z" }, - { url = "https://files.pythonhosted.org/packages/90/2e/59d6491834b6e289051b252cf4776d16da51c7c6ca6a87ff97e3a50aa0cd/greenlet-3.2.2-cp313-cp313-win_amd64.whl", hash = "sha256:fe46d4f8e94e637634d54477b0cfabcf93c53f29eedcbdeecaf2af32029b4421", size = 296023, upload-time = "2025-05-09T14:53:24.157Z" }, - { url = "https://files.pythonhosted.org/packages/65/66/8a73aace5a5335a1cba56d0da71b7bd93e450f17d372c5b7c5fa547557e9/greenlet-3.2.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba30e88607fb6990544d84caf3c706c4b48f629e18853fc6a646f82db9629418", size = 629911, upload-time = "2025-05-09T15:24:22.376Z" }, - { url = "https://files.pythonhosted.org/packages/48/08/c8b8ebac4e0c95dcc68ec99198842e7db53eda4ab3fb0a4e785690883991/greenlet-3.2.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:055916fafad3e3388d27dd68517478933a97edc2fc54ae79d3bec827de2c64c4", size = 635251, upload-time = "2025-05-09T15:24:52.205Z" }, - { url = "https://files.pythonhosted.org/packages/37/26/7db30868f73e86b9125264d2959acabea132b444b88185ba5c462cb8e571/greenlet-3.2.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2593283bf81ca37d27d110956b79e8723f9aa50c4bcdc29d3c0543d4743d2763", size = 632620, upload-time = "2025-05-09T15:29:28.051Z" }, - { url = "https://files.pythonhosted.org/packages/10/ec/718a3bd56249e729016b0b69bee4adea0dfccf6ca43d147ef3b21edbca16/greenlet-3.2.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:89c69e9a10670eb7a66b8cef6354c24671ba241f46152dd3eed447f79c29fb5b", size = 628851, upload-time = "2025-05-09T14:53:38.472Z" }, - { url = "https://files.pythonhosted.org/packages/9b/9d/d1c79286a76bc62ccdc1387291464af16a4204ea717f24e77b0acd623b99/greenlet-3.2.2-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02a98600899ca1ca5d3a2590974c9e3ec259503b2d6ba6527605fcd74e08e207", size = 593718, upload-time = "2025-05-09T14:53:48.313Z" }, - { url = "https://files.pythonhosted.org/packages/cd/41/96ba2bf948f67b245784cd294b84e3d17933597dffd3acdb367a210d1949/greenlet-3.2.2-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:b50a8c5c162469c3209e5ec92ee4f95c8231b11db6a04db09bbe338176723bb8", size = 1105752, upload-time = "2025-05-09T15:27:08.217Z" }, - { url = "https://files.pythonhosted.org/packages/68/3b/3b97f9d33c1f2eb081759da62bd6162159db260f602f048bc2f36b4c453e/greenlet-3.2.2-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:45f9f4853fb4cc46783085261c9ec4706628f3b57de3e68bae03e8f8b3c0de51", size = 1125170, upload-time = "2025-05-09T14:54:04.082Z" }, - { url = "https://files.pythonhosted.org/packages/31/df/b7d17d66c8d0f578d2885a3d8f565e9e4725eacc9d3fdc946d0031c055c4/greenlet-3.2.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:9ea5231428af34226c05f927e16fc7f6fa5e39e3ad3cd24ffa48ba53a47f4240", size = 269899, upload-time = "2025-05-09T14:54:01.581Z" }, +version = "3.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/74/b13368064b09053253555d3f2839cc2684d22d5aed0d2ccffbf7a6736558/greenlet-3.5.4.tar.gz", hash = "sha256:0232ae1de90a8e07867bb127d7a6ba2301e859145489f25cda8a6096dabe1d20", size = 206538, upload-time = "2026-07-22T12:47:14.468Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/9d/58f80897f4121f5c218bb931cf6d3b6514873f02ad0b729f744352926b9f/greenlet-3.5.4-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:ac5bf81d79d2c8eeb2ef6359b2e1687a1e9ebf46c2b1f970da9a9255df51d190", size = 293072, upload-time = "2026-07-22T11:38:14.299Z" }, + { url = "https://files.pythonhosted.org/packages/dd/9f/b4bc9bbd6a7855cbd8ad8a83c874eeeca56c24de9132b3323f81c03a30ba/greenlet-3.5.4-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:89f3738167bab8c1084b94e23023d41d247117ac149fa0fbcb5bd4cf6262b353", size = 609393, upload-time = "2026-07-22T12:26:37.964Z" }, + { url = "https://files.pythonhosted.org/packages/05/0e/744b5e063af127d2e3c74fe0f1aef15573064c83b6066883524f5b258b17/greenlet-3.5.4-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e9a5e3406e3ed8125ae1a3b37c12f3434e2b1f0fa053197c5557895b4fb09606", size = 622750, upload-time = "2026-07-22T12:28:59.546Z" }, + { url = "https://files.pythonhosted.org/packages/5d/5c/53d6b94742a6f1ee1877c7ff76262c909e137f3f7383ce96a8ab78e1ae31/greenlet-3.5.4-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a2d614cb2372c7101a12ea8b96dd56f81c986d247c5a73db67063f3ed1ca4a52", size = 629659, upload-time = "2026-07-22T12:43:40.451Z" }, + { url = "https://files.pythonhosted.org/packages/d6/6b/d78ea2908e8e08985348f28ac396c2950be7ab66321dfe0054c73bd1f456/greenlet-3.5.4-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4ab9f0704bccf6d3b38e0d2130b7b33271cff11453690da074fa280c3aa8e8e7", size = 622920, upload-time = "2026-07-22T11:51:06.83Z" }, + { url = "https://files.pythonhosted.org/packages/f2/34/957fc5577180ef2f57be82580ee1f59fdefad4f6c623c7d5e1b6980a76fb/greenlet-3.5.4-cp310-cp310-manylinux_2_39_riscv64.whl", hash = "sha256:188e4d142f243051d92a1f5c244a741da02dddc070a0620c842804d7b56d008c", size = 425580, upload-time = "2026-07-22T12:39:48.326Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e4/3ce7009c948920b01527f8d9da29f501a31ac3d98318829e981fd879b850/greenlet-3.5.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2cdaadc3d31445a8f782bde3cd37e49a2c2a9c6da6daf76a3e34c683b271a3c7", size = 1582262, upload-time = "2026-07-22T12:25:01.131Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4c/0408366102a33829f7bdd6a992dad75abbf75e86cc1e76caf19e57311d29/greenlet-3.5.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:70bdfacdc183dac838b2a0aaff2dd6134a457c52fe68a9c6bbab435483d2b9df", size = 1648906, upload-time = "2026-07-22T11:51:08.627Z" }, + { url = "https://files.pythonhosted.org/packages/13/52/ebfe8f6a1aeb8e430540b406c844ecc4e3367072b0192f69dcb85eeeec2b/greenlet-3.5.4-cp310-cp310-win_amd64.whl", hash = "sha256:69173331fbc5d64bfac0065d7e22c39cfcd089e9b18d125bdcd5079363b09616", size = 246036, upload-time = "2026-07-22T11:38:30.073Z" }, + { url = "https://files.pythonhosted.org/packages/61/16/71eefcf68267bbf06a9b6bff57d0b222e49432326e85d74348b67694b8d4/greenlet-3.5.4-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:e883de250e299654b1f1680f72a1a9f9ba62c9bd1bce84099c90657349a8dfbb", size = 294266, upload-time = "2026-07-22T11:37:56.142Z" }, + { url = "https://files.pythonhosted.org/packages/36/ea/a0b19adfc35d07e10acb626e9d22a3893b95f1309c42c4a20161dec16800/greenlet-3.5.4-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32802705c2c1ff25e8237b3bdacf2594fa02be80af8a66703eb7853ea7e68686", size = 613712, upload-time = "2026-07-22T12:26:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a121978b3337407d05a1ce5f79b4aa5998a43a9d8422f9726029b90b4471/greenlet-3.5.4-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:57aa201b351f7c7c75627c60d29e4d5b97a07d37efeb62b903466fca42c097d7", size = 625582, upload-time = "2026-07-22T12:29:00.814Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4a/f301f1d85c69a86b90b5d581a73e8927bba4e79450037e6e2cbca05eb4fd/greenlet-3.5.4-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9667862a2e38ad379f11b845daeda22c8989186def44f06962c9c4c05e556da7", size = 633429, upload-time = "2026-07-22T12:43:42.073Z" }, + { url = "https://files.pythonhosted.org/packages/34/c2/080f16cf870e929e592f55767f01d6c98d2ee83bfdc36c3b892f2d0459ab/greenlet-3.5.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c3fe76c2cac86b4f7a1e92865ac0a54384deb05c92986287c1a7110d9bd53071", size = 624663, upload-time = "2026-07-22T11:51:08.016Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2e/26884072b0eb343a4d5fee903341bfe5171b32b7f14553886e2b6349135a/greenlet-3.5.4-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:ae53534b5dec0f4c2ec26f898f538dc8ea1ca3ef2927d597a9439e40a09da937", size = 428238, upload-time = "2026-07-22T12:39:49.973Z" }, + { url = "https://files.pythonhosted.org/packages/9e/bb/8f3ca88370b817369008faeceeee85970adc16c92a70a3e5fe5fea495a57/greenlet-3.5.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1e1a4a684b16c45ba324e60b32a4386a87722bcb815d2a149d2182f9b401ca72", size = 1585010, upload-time = "2026-07-22T12:25:02.539Z" }, + { url = "https://files.pythonhosted.org/packages/51/c2/45877154689709ebce9a0b83c2235e6ca0f31577889b02af308c8cc5f8fb/greenlet-3.5.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e849e6e139b9671adeac505f72fc05f4af7fd1921faef40295e214fc3b361b59", size = 1651283, upload-time = "2026-07-22T11:51:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7d/8711a75cb61d85246277c07ff6e1a6504621ba473d808c11ad225ffca43f/greenlet-3.5.4-cp311-cp311-win_amd64.whl", hash = "sha256:dc418cf4c873357964d6624445ed09472e50def990c65dd4e76fc3ba8cd9cef6", size = 246434, upload-time = "2026-07-22T11:43:15.557Z" }, + { url = "https://files.pythonhosted.org/packages/00/62/e290b3bce433da8f0324ac02da0b128d683482229f1a8b789fa47818a4cd/greenlet-3.5.4-cp311-cp311-win_arm64.whl", hash = "sha256:c38c902a0986eba1f6e7ba1ab39ad5195926abde90f3fe080e08212db62176da", size = 244990, upload-time = "2026-07-22T11:39:22.626Z" }, + { url = "https://files.pythonhosted.org/packages/f3/04/81bd731d6d1e3a469d9a4c36f5eb069bcf0cbb2d5d342c9fec22245b91fc/greenlet-3.5.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3d66250e8b09f182ede05490998c818b5961f7a3640332d44c4927caec7bbfe4", size = 295909, upload-time = "2026-07-22T11:38:09.261Z" }, + { url = "https://files.pythonhosted.org/packages/cc/dd/f5f22903a6ae70f5ea328ed0beaec92ad903f0e3b7d2845133b354abc4b8/greenlet-3.5.4-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c90e930c9c192e5b3ee9fb8bcd920ea3926155e2e3ded39fc697323addecee17", size = 612011, upload-time = "2026-07-22T12:26:40.69Z" }, + { url = "https://files.pythonhosted.org/packages/8e/10/92a4a88d12b915d74ea5b6d288e4afefda4771647caa34442c156f7a454f/greenlet-3.5.4-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:791fdfeeb9c6e0c7b10fa151bf110d2a6974866f13dcb5b1c7efae698245893a", size = 624299, upload-time = "2026-07-22T12:29:02.089Z" }, + { url = "https://files.pythonhosted.org/packages/6c/f9/03e26be3487c5238e81f2b84714959a86ea8515a869828cf41f4fc54b34e/greenlet-3.5.4-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b7c895310363f310361e0fe2072af85269d2a2a285cd04c0c59e79a5e3670dcf", size = 629603, upload-time = "2026-07-22T12:43:43.456Z" }, + { url = "https://files.pythonhosted.org/packages/50/6d/0b14bb9db2989f32cd9fe7f76afedea01ee8bee3f87c07e69f24adfe7e63/greenlet-3.5.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f88193799d43dbf8c8a806d6405c9c52fe2af40bf75072a606357b33cc336c7f", size = 621541, upload-time = "2026-07-22T11:51:09.464Z" }, + { url = "https://files.pythonhosted.org/packages/57/6b/7c55ca72ef80d57c16c4a55210f82582622462dc4485799a30f4ec6f3372/greenlet-3.5.4-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:13b980043cb1b3134e81ea469da1250ddcc6bfe6d245bbaa59168d9cdc8f228f", size = 432554, upload-time = "2026-07-22T12:39:51.379Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/25e9a2d9eb6b2e8b7ca4e80a3a26cb887cce6c8e0a87c921164f11bc5574/greenlet-3.5.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b7a5f095767c4493afcd06067f2bb3b8716e3f3f9e92b99c88e7e99f885b3d4d", size = 1581444, upload-time = "2026-07-22T12:25:03.818Z" }, + { url = "https://files.pythonhosted.org/packages/b9/96/4c9bf2e2c408dcc0556edce69efa9f802e82223573c53240136a086821f1/greenlet-3.5.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:42afdc1ab5f66da8c586c32af9224a74a706b4f0ea0dc3a4188a0860a09c65c9", size = 1645842, upload-time = "2026-07-22T11:51:12.295Z" }, + { url = "https://files.pythonhosted.org/packages/b5/41/303ecb26a3a56122c0f4d4073ee078881847bd6b6f463ae0ec57ec20223b/greenlet-3.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:60149df8f462d1b230038e6590c23c3b4768bb5d6c022b3b6e82532b34b0b8a3", size = 247169, upload-time = "2026-07-22T11:38:19.893Z" }, + { url = "https://files.pythonhosted.org/packages/a4/e3/ef56864b4c35fcb3eb3b41b869f6cc46f4cd3f5e2c68e74acde8ac433951/greenlet-3.5.4-cp312-cp312-win_arm64.whl", hash = "sha256:77d6ce04fed0d9aeed42e0f37923cc43eba9b027bdd9c34546bb4ccd143d0fe0", size = 245565, upload-time = "2026-07-22T11:38:27.061Z" }, + { url = "https://files.pythonhosted.org/packages/c0/9a/e51225dcd58713f16ccbdcc501a8da21098ea14515b7870f1f94459e5ff5/greenlet-3.5.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:24e61b88cb7e1b1d794b32a10cc346ac779681d6d74ff137a3e0a444d2bf1f02", size = 294831, upload-time = "2026-07-22T11:38:53.389Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ea/de50a50fadf979713ab18b46f22ad5ff5f2dcfc637a3ebdecf669801e1a5/greenlet-3.5.4-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:870d730fec833f5a06906a32596cc099b9161594642a92a520b7a88911c95356", size = 614619, upload-time = "2026-07-22T12:26:42.282Z" }, + { url = "https://files.pythonhosted.org/packages/db/c7/2aae27fea41205b8650294c301f042a2a4bb6155eea48c995b890a92f2c1/greenlet-3.5.4-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec5ff0d1878df6af3bf9b638a5a92a7d5693291de77c91bff10fa48519c604ef", size = 627021, upload-time = "2026-07-22T12:29:03.445Z" }, + { url = "https://files.pythonhosted.org/packages/1b/80/fb4d4788bbc8e54761f1fc88533af9523a6e86299fa113d6e8a8503ed9fc/greenlet-3.5.4-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:07bd44616608d873d06735b63ef1a88191d6ca57c8d291d6559c71bc14c0893c", size = 632845, upload-time = "2026-07-22T12:43:45.19Z" }, + { url = "https://files.pythonhosted.org/packages/eb/56/79fd826f9ccaae0b84e1b4ef68dabba5e105bb044ffcd448a0b782fcba9a/greenlet-3.5.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d84d993f6e575c950d91a23c1345d18fe1a4310d447bf630849d7809196b52f0", size = 624002, upload-time = "2026-07-22T11:51:11.391Z" }, + { url = "https://files.pythonhosted.org/packages/42/e3/6086fa578ebb72772722cdc4bcd628459814b42e0c2db1e3cbd6552b3271/greenlet-3.5.4-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:3529a8a933582ad19e224792cac7372489526576b75b4c124e8e4f29948f4861", size = 435053, upload-time = "2026-07-22T12:39:52.715Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1a/27319f97e731298513dcba1a2e91b63e9d8811d9de22130f960b129b1bf1/greenlet-3.5.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:58023945f421093de5e6fa108c0985a8659d43f49e0216da25099369a121bcbd", size = 1581533, upload-time = "2026-07-22T12:25:05.322Z" }, + { url = "https://files.pythonhosted.org/packages/b1/6d/24240bf562e9786dd2799ee0a4a4dadb4ded22510f41b20245099159ac8c/greenlet-3.5.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bae2728e1897aa8df8cb1af38cd48b3a743aefe29372de7b8b7a9f532501e69f", size = 1645781, upload-time = "2026-07-22T11:51:14.805Z" }, + { url = "https://files.pythonhosted.org/packages/c1/5a/442ab1a9ef7ca6bf7210e5397a95972206a91a31033a03c8900866a10039/greenlet-3.5.4-cp313-cp313-win_amd64.whl", hash = "sha256:ca5726c0b08ca35ae873557266a78b2c3f3b2b7d7401aa5ff886c2045dd0111c", size = 247133, upload-time = "2026-07-22T11:39:20.661Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e6/9160210222386b1a378ff94db846b9508ca24a121cf684991561fdb69280/greenlet-3.5.4-cp313-cp313-win_arm64.whl", hash = "sha256:7c1303791d603080cac6fc3b34df51c3b75b723739c282c8029e48a0d241672f", size = 245500, upload-time = "2026-07-22T11:40:22.185Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a7/6ab1d4f9cd548d15ab90da29947f2076100130bb179b0bde59f795a459e3/greenlet-3.5.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:7e8afa5eac028f8140ceafe5ceec66e6aa127ddcb21452d2a564dcd2900b5f22", size = 295410, upload-time = "2026-07-22T11:40:35.747Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7a/422f63b4715cbc0b24385305407adf38b48f6bb68b3e6b04090e994d0f5a/greenlet-3.5.4-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:73b37afe369021423ea53dd3123e04bffa7e93ac64429b9f50835b2e4fcae7cf", size = 661286, upload-time = "2026-07-22T12:26:43.8Z" }, + { url = "https://files.pythonhosted.org/packages/d0/31/5a1cac663bf5582190c5a714ef81364f03cde232227f39748f8ae4c11da5/greenlet-3.5.4-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3ef964f56dfcb6f9bbef2a190d9126795eac408716aeae47b5e7c73c32aafca9", size = 673517, upload-time = "2026-07-22T12:29:04.815Z" }, + { url = "https://files.pythonhosted.org/packages/9c/bf/250c2921c7b585dde12f5239e313ca2dcbc464d161ecca36e4e6ef21762d/greenlet-3.5.4-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cef589bc65fae02d10bca2ac341191c5b33acc2967892ebf4fcbd10eabb7a74c", size = 677968, upload-time = "2026-07-22T12:43:46.788Z" }, + { url = "https://files.pythonhosted.org/packages/15/4a/2a82a1e3f8aaca020853ac8d12211280ca2b231aa08ea39f636f1060c319/greenlet-3.5.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c53ff01a5c53a40f2c16820ebc56d7c61a77f5fbe009dadd96292d5682f80f8", size = 670917, upload-time = "2026-07-22T11:51:13.589Z" }, + { url = "https://files.pythonhosted.org/packages/18/40/10bfcf6513558d82f7b95dd728001c63bd388259fe27d3e30ae01f103430/greenlet-3.5.4-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:dfc41ae893d9ceaf22c824f2153a88b30651b20e8758c2cd9ac143f23640563c", size = 480643, upload-time = "2026-07-22T12:39:54.149Z" }, + { url = "https://files.pythonhosted.org/packages/68/b0/e379a152b17bfdfa95795af4049e37c0fd1b4d81f020d426db104ed07c77/greenlet-3.5.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecca4d80d55a01ad6b23b33262662956149fbb7b2c6be2910f1705921958cbf3", size = 1628478, upload-time = "2026-07-22T12:25:06.678Z" }, + { url = "https://files.pythonhosted.org/packages/5e/43/bffdfa64f7317f954c5c1230b5dd5922676ce198689a68c1ac1ed4b1b1a5/greenlet-3.5.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ffbc533e0eaf8e80d8471411646ab88fe58f641d508c0b02b24494479f4d9ec", size = 1692021, upload-time = "2026-07-22T11:51:17.008Z" }, + { url = "https://files.pythonhosted.org/packages/d0/11/f799f9637e2c6e9b0b716015e339040598b058cf7654dfc0d67468b177ed/greenlet-3.5.4-cp314-cp314-win_amd64.whl", hash = "sha256:305f69e6c4523d7f6979ed001cff4e5853c063e5da04880296603aa0227e544c", size = 248031, upload-time = "2026-07-22T11:40:11.007Z" }, + { url = "https://files.pythonhosted.org/packages/05/75/625bcdd74d5e6b2dca1ecba3c3ac77bcf8a026c21a649a46cef23e421f97/greenlet-3.5.4-cp314-cp314-win_arm64.whl", hash = "sha256:f260930bbbbcf9caee661211235a5111c86dfe5832fdf6ae4570da1e0995320f", size = 246892, upload-time = "2026-07-22T11:40:27.357Z" }, + { url = "https://files.pythonhosted.org/packages/ec/69/35c62ed49c320cb4d98e14698ccca5467d3bfe683984172be9cb564d9ce3/greenlet-3.5.4-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:41ddab54e4b238f4a6c323f39b4e59e176affd5a94d461a9fb7583dac74240a3", size = 305571, upload-time = "2026-07-22T11:40:31.659Z" }, + { url = "https://files.pythonhosted.org/packages/5b/6c/64d60216b3640dcb0b62d913dd9e0d80030c09115bb2e4ba70c95d10ca45/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3dabe3e2809013052c68bdf0b7fa5f5f2859c43a80803131ad61af9cabd7867", size = 672568, upload-time = "2026-07-22T12:26:45.298Z" }, + { url = "https://files.pythonhosted.org/packages/5c/de/ba3ab0a96292e53039530333b0d2ae18d9e508f3a325cd7bf15f8172944c/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:27d3f00718634d4520a3a150154ac5da36f257869d41321953375b90bfbbc72c", size = 680076, upload-time = "2026-07-22T12:29:06.125Z" }, + { url = "https://files.pythonhosted.org/packages/ae/db/24a10af12bf8e639cec46c38b9ce1a282543ba42ff4fb0b31a970f1ab603/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:39169a11d87a6a263afda3e9a27d1df16d0f919d40a4837cc73986c9884c0dd8", size = 681690, upload-time = "2026-07-22T12:43:48.109Z" }, + { url = "https://files.pythonhosted.org/packages/a5/be/aeada79083c6f1c15f45d77a332f9c441af263ee298e3eb17522cd337d22/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cbd60b5763c6543c1827e48faaf14ea9bfbad245f52b1a4d76a2a2d8884c6c66", size = 676733, upload-time = "2026-07-22T11:51:16.027Z" }, + { url = "https://files.pythonhosted.org/packages/f4/60/44a2eca7b9fd71ae0fae7ff184da1cd3169d176652b97aa1cffcbb0ef961/greenlet-3.5.4-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:bd3d1145f603b2db19feb9078c2e6855eb7c67e15580c010ed815cee519b86fd", size = 510263, upload-time = "2026-07-22T12:39:55.678Z" }, + { url = "https://files.pythonhosted.org/packages/e9/10/2392fc3a98948652ef5fd1e7275c04f861dd13f74b78a2b4309f4ee4d090/greenlet-3.5.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f00f910f0e7b35416c63b23ad78b769aeccfc1775f712b43c4ee525624a2eef7", size = 1637327, upload-time = "2026-07-22T12:25:07.879Z" }, + { url = "https://files.pythonhosted.org/packages/55/c6/e7237a3dfa1f205ed0d9ea1e46d70bd2811b32d516266399fb59d28ab90a/greenlet-3.5.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:91c26423753b92caf41ab3f98fd547d7374d4d9fc2d85be041886c1579d9255e", size = 1697493, upload-time = "2026-07-22T11:51:19.214Z" }, + { url = "https://files.pythonhosted.org/packages/55/e3/4ba8154ba2a3d43729e499f72471b4b5c993f3826d3e24da81d5f06d6572/greenlet-3.5.4-cp314-cp314t-win_amd64.whl", hash = "sha256:ee032b91fd8ec29ec6c4cea2b8c561b178435134bd0752c7334b94e9c736c132", size = 251637, upload-time = "2026-07-22T11:40:37.44Z" }, + { url = "https://files.pythonhosted.org/packages/90/03/e3f96dfc100261a29545ddc8270cafe58f9195b6651466910e820910de77/greenlet-3.5.4-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:178111881dd7a6c946471fda85485ec796e1043c2b939f694b096e2ecf986809", size = 296076, upload-time = "2026-07-22T11:39:38.364Z" }, + { url = "https://files.pythonhosted.org/packages/a4/3d/da52d208e5c977bce8667e784729e584e38b5785f4c1ec0f4c836e9a1c42/greenlet-3.5.4-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d92df08dd65fede97fc37aad36c2e9dcda3b31c467f8e0c2c096456cb818e927", size = 666870, upload-time = "2026-07-22T12:26:46.691Z" }, + { url = "https://files.pythonhosted.org/packages/2d/8a/7e6dee25cb8a8cf9b362c8e597cc269593378bd916f16c736c059a52e85a/greenlet-3.5.4-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:99e8f8c4ebc4fd80aa26c1280ae9ad43a0976e786349703a181cf0bae60413e5", size = 677678, upload-time = "2026-07-22T12:29:07.508Z" }, + { url = "https://files.pythonhosted.org/packages/51/a7/dafc7415d430b0a43a16396eb49ecb3b62fd720877fb259cc4dcfaf5f31e/greenlet-3.5.4-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1f17e362d78e37559e0506c5a7d066bdd45073c36a0127a543e8a0df27242ff3", size = 681428, upload-time = "2026-07-22T12:43:49.623Z" }, + { url = "https://files.pythonhosted.org/packages/6c/21/5a38699fa45de749e3857d93b8f07e4c20489e77c2d35d915a2e1c456606/greenlet-3.5.4-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:394de08dad5ffcb1f50c2159d93e398d9d2da3ed437645eaa54771fa720db9f0", size = 676067, upload-time = "2026-07-22T11:51:18.163Z" }, + { url = "https://files.pythonhosted.org/packages/2e/d9/6298f3432de301d4718766cf934bd73c418c73f81fbb77247319364b0d96/greenlet-3.5.4-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:cd320d998cbaa032932830448e39abf3c6a12901295e386e8114db926e10cffb", size = 487446, upload-time = "2026-07-22T12:39:57.044Z" }, + { url = "https://files.pythonhosted.org/packages/5e/ba/863116ab8ff1ca7a729e327800268939d182db47aa433db70e216e7d9194/greenlet-3.5.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:c883d61f2282d72c767a14936641b3efcbde9d82f1080712aaea0b1d3126cb88", size = 1633489, upload-time = "2026-07-22T12:25:09.605Z" }, + { url = "https://files.pythonhosted.org/packages/fa/06/7466ced82818d6132462d7f26b3f83c66ea15d2b193a6c0088d558ed7d95/greenlet-3.5.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2a924f15d17957e252a810acefcb5942f5ca712298e8b6fcaed9a307d357522c", size = 1696584, upload-time = "2026-07-22T11:51:21.304Z" }, + { url = "https://files.pythonhosted.org/packages/f9/4d/55b638489260065de9ffce606c8b5d04507bef705de4b212a0c3d6a1a0df/greenlet-3.5.4-cp315-cp315-win_amd64.whl", hash = "sha256:ed17e5f3420360d5b459de8462efb52060399a5326a613d4cde31cef63ef95da", size = 248297, upload-time = "2026-07-22T11:42:04.055Z" }, + { url = "https://files.pythonhosted.org/packages/bb/08/9dd4ae635da93d41dc268bc34bd62a9d711ed8b8825c5d22ac910c7d6e6d/greenlet-3.5.4-cp315-cp315-win_arm64.whl", hash = "sha256:f908898d6fa484ce4b6f447ce70ea99b52c503fee419e53cf74d60a16bc9e667", size = 247423, upload-time = "2026-07-22T11:44:00.764Z" }, + { url = "https://files.pythonhosted.org/packages/19/66/7c87ed9cdbf1d49c2c6cd1c7b9dd4d16c33b24235ca03972293a1876b30c/greenlet-3.5.4-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:1833637f17d5e7472548a48575c394fe39f1b1890d676d162d86593610f44d8c", size = 306487, upload-time = "2026-07-22T11:41:25.118Z" }, + { url = "https://files.pythonhosted.org/packages/5b/05/0a4201e7c0054866eefc05da234f236dd4c950d0fbf9ca0517141f01b269/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:12cda9122e03341f1cb6b8207a19d7a9d375e52f1b4e9243918375f40fd7b4b9", size = 676479, upload-time = "2026-07-22T12:26:48.129Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c0/4b6b8c5a3aec70f0649cd89662d120fdd6421e2bdc8e3b15c3ab5ec568d8/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d83ae0e32d14957ab7170785a20f582635c8474deab1bfbb552b17e769a6ce25", size = 684321, upload-time = "2026-07-22T12:29:08.925Z" }, + { url = "https://files.pythonhosted.org/packages/88/15/0b167aeea95285b0e654ddce651922f666c089363c2ec528ca8b9a9ba74f/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:123aa379c962ed5fe90a880327e0c3066124ac64ec99e12a238be9fd8eb3db3d", size = 685995, upload-time = "2026-07-22T12:43:50.993Z" }, + { url = "https://files.pythonhosted.org/packages/24/c9/b49c31c9a972eee91e260445770e922244a7efc542697f51a012ec046d0f/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f1467de1bb767f75db0aa34c195e3a496d8d1278c796e70c24ce205d3e99cde", size = 681293, upload-time = "2026-07-22T11:51:20.43Z" }, + { url = "https://files.pythonhosted.org/packages/de/90/c023ec337f32ff505be7db759c80d98f0532bb94d0c6fa13645efe9bee2e/greenlet-3.5.4-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:adf2244d7f69409925a8f22ed22cc5f93cdfe5c9dc87ff3476be2c2aaae61a05", size = 516928, upload-time = "2026-07-22T12:39:58.359Z" }, + { url = "https://files.pythonhosted.org/packages/95/6f/7f2d4653770500eee667866016d42d7a68e3d3462f80df6b8e3fcd48a0eb/greenlet-3.5.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:0fa53040b78b578120eecdc0265e3f1051487cc425d11a2b7c761daadf4feaa8", size = 1642474, upload-time = "2026-07-22T12:25:10.819Z" }, + { url = "https://files.pythonhosted.org/packages/5a/d8/8cba31036a4caae448087ba5d150660ab03b4a0f54d9150f6495a3be7262/greenlet-3.5.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:60e0bc961d367df506660e9ac0177a76bc6d81305300704b0977d1634f76efe2", size = 1701012, upload-time = "2026-07-22T11:51:23.17Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cd/3f77a4cce3bae631b08eb52f53a82a976669600337e21dfdba811cb50267/greenlet-3.5.4-cp315-cp315t-win_amd64.whl", hash = "sha256:f680e549edb3eaf21eea4e7fe101e15ec180c74b7879ab46adc080f22d4015d2", size = 251977, upload-time = "2026-07-22T11:41:38.125Z" }, + { url = "https://files.pythonhosted.org/packages/93/e8/65e8707d00fe2a49bf12f609a9b2b39ba6dd23c2810eacad877c4fc94bfe/greenlet-3.5.4-cp315-cp315t-win_arm64.whl", hash = "sha256:08fc36de8442d5c3e95b044550dbea9bf144d31ec0cc58e36fb241cb6ef6a994", size = 250538, upload-time = "2026-07-22T11:40:17.985Z" }, ] [[package]] @@ -582,6 +688,79 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6e/aa/8caf6a0a3e62863cbb9dab27135660acba46903b703e224f14f447e57934/hyperlink-21.0.0-py2.py3-none-any.whl", hash = "sha256:e6b14c37ecb73e89c77d78cdb4c2cc8f3fb59a885c5b3f819ff4ed80f25af1b4", size = 74638, upload-time = "2021-01-08T05:51:22.906Z" }, ] +[[package]] +name = "hypothesis" +version = "6.161.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/17/94/d208ced653376e7e0a2f0429ee5be864dd0b59393b98a8b41a35ceb4d035/hypothesis-6.161.5.tar.gz", hash = "sha256:ba73a3c3b68e63a0bee5ea1a8a13efce60bcc7ee5fc7e71df2954db39c225b95", size = 486653, upload-time = "2026-07-25T14:39:34.866Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/58/e48aa878d119474631fc097d511b5e70807dfe56be4b244cce0275f1805e/hypothesis-6.161.5-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:4c89e0c35d7cd70af2a0a5f0b5ad69d1898c369adf15115c6d4271d47bf1b280", size = 766970, upload-time = "2026-07-25T14:38:42.613Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ff/c83c1a4d5d3c4b6a4dc1fcb5069245171b7a30ad5dd31f44282e8881e089/hypothesis-6.161.5-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:1bb987e519a6d00675bab124c925000637e2e59384196b0ded5d108dc1851449", size = 762574, upload-time = "2026-07-25T14:38:15.427Z" }, + { url = "https://files.pythonhosted.org/packages/d2/04/7a5ba16cd14340009d1d8aa46083bf3a3a55c5b0d1ccf553e6f6748ee0e8/hypothesis-6.161.5-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5990e9e2e145ff5369c95ad684bd6eee7ebd2d4de37d37eea4c6ca5e339e2a52", size = 1091754, upload-time = "2026-07-25T14:38:38.562Z" }, + { url = "https://files.pythonhosted.org/packages/70/69/031913f7408ebb41e31a9b20e5bca75c5a64ab151d57ca75e944a09ba6e0/hypothesis-6.161.5-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2a5c6400c58c9c3d616ad7177ada12ae577e5103b3b0df6e79920729250bf100", size = 1120372, upload-time = "2026-07-25T14:38:18.962Z" }, + { url = "https://files.pythonhosted.org/packages/b9/95/360884e86ab99099340fca781531286c26d40ce32c853097e76537cc0726/hypothesis-6.161.5-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:71aa718e08bdfbacd1a5d8f86ffd55f1d86e64a57cec6a107144d883b522823e", size = 1141230, upload-time = "2026-07-25T14:39:03.757Z" }, + { url = "https://files.pythonhosted.org/packages/bc/5b/6f3c9fbe9191432f33c9aaf951cf5a5416533848999f2e2c6a20ec00098b/hypothesis-6.161.5-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:66912038467e1af791f76336bc079d669f7c8bbf7aeb85bdd52e83259d885122", size = 1096611, upload-time = "2026-07-25T14:38:34.688Z" }, + { url = "https://files.pythonhosted.org/packages/32/c7/d126b9f66295fed5d5745f98ad92f485c98dee303dd67ea7a53fe4a903aa/hypothesis-6.161.5-cp310-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:99d8b2380d0bba602df963d6e42d56bb50cf1734376c1b9b14dcab3cec21814e", size = 1133382, upload-time = "2026-07-25T14:38:20.27Z" }, + { url = "https://files.pythonhosted.org/packages/56/de/277a17091687f298079c197cadd806d64908c5a08c9c91bb27b834192503/hypothesis-6.161.5-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:19e57e621c6abd98123a91bd4b022bde7760ba709f1ca121f822d9aa291bd001", size = 1265592, upload-time = "2026-07-25T14:38:11.734Z" }, + { url = "https://files.pythonhosted.org/packages/d7/e5/3f15b1a43cb70b223427ce3a9a6afbe430bf1754b3346ac546a3df38b96b/hypothesis-6.161.5-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:3b74cced62995ed2e0096fa63d0b7babd1fa08ce1944cff41134275516848708", size = 1393424, upload-time = "2026-07-25T14:38:46.738Z" }, + { url = "https://files.pythonhosted.org/packages/1a/1d/a3cf1441550dbf5a362dcfa19c831721f3bb6a749eb53ecbdfc983959027/hypothesis-6.161.5-cp310-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:fd37a0257647288be8c27d56a78a31da2b65e3b6254511f03164f5e1c786187a", size = 1266197, upload-time = "2026-07-25T14:39:33.052Z" }, + { url = "https://files.pythonhosted.org/packages/57/e5/2616432d6144057b6c8f4409c95d95610bdbdf07fecb6618e98761861c24/hypothesis-6.161.5-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:cf9dfba054164e481f05774c70b65ea88ba735b986cdc44f5210ef40871a32e9", size = 1308330, upload-time = "2026-07-25T14:39:27.86Z" }, + { url = "https://files.pythonhosted.org/packages/f7/cd/9e9c7a88858f84447e44e45c8b9fd1058120b4d5fa9bab9c3ceb9b9cf96b/hypothesis-6.161.5-cp310-abi3-win32.whl", hash = "sha256:8763938787e6c98e461f8c11139981597d083e6915a9956db1c26cc609bc7b19", size = 652819, upload-time = "2026-07-25T14:38:35.915Z" }, + { url = "https://files.pythonhosted.org/packages/03/23/aae37b5bb2014e0e01e372498f7f5977b9fd61a0179143854b7fb0538b1c/hypothesis-6.161.5-cp310-abi3-win_amd64.whl", hash = "sha256:79b5d069095a726dca5b6b7e4c5d268acc809a6595c46d7eee860708f960cb8f", size = 658970, upload-time = "2026-07-25T14:39:05.239Z" }, + { url = "https://files.pythonhosted.org/packages/70/77/fbda7005f891f534af0a74cb5acd7a67cb1a7fbee1ae170d176c7dcd5e0a/hypothesis-6.161.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:c73c86afe87b1bf81af586402d41bff69d93c8c34517a81d3a88a1e28e8662e2", size = 767688, upload-time = "2026-07-25T14:39:13.418Z" }, + { url = "https://files.pythonhosted.org/packages/d3/8d/f884e10029018504504df6100faf3795918e1dd14d4b6f4102c3fcb21acf/hypothesis-6.161.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7b289d33f0154850baed802d088564bba83dae58bae6fb9157e498c43d758afd", size = 763399, upload-time = "2026-07-25T14:38:10.49Z" }, + { url = "https://files.pythonhosted.org/packages/40/23/75d9c1f7ad8a52a14c5981c9ba97b1d4abfd263cd48ce7ffb036ce1f5a16/hypothesis-6.161.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e60afc424627a4119d89f02425049a97e500e022aef6a06d76e987e266e3d720", size = 1092278, upload-time = "2026-07-25T14:38:51.187Z" }, + { url = "https://files.pythonhosted.org/packages/be/52/efc7c45352636fc71d24590893e52f0e8b4ca42984aea870b47831720fef/hypothesis-6.161.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:defa4f24aa62b3a6d07271a4d54f31c7bb07ce1f3fa86cc0705890a071a58301", size = 1141822, upload-time = "2026-07-25T14:38:48.298Z" }, + { url = "https://files.pythonhosted.org/packages/6b/48/697b194412fed03cbf9757771b9c3b51ca062e1d78fe2b5ddf833a25b7f2/hypothesis-6.161.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4f5e8be93cf0da10b8a2f1044c12f2aa506e80c5071e84dffeec4311fddeb8d7", size = 1266206, upload-time = "2026-07-25T14:39:31.414Z" }, + { url = "https://files.pythonhosted.org/packages/2f/b8/56c7996272a8310737c44378fe181befbe3ab41f72ee3d7180e32269cb7c/hypothesis-6.161.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:46aa94fa4107f97760b63c83142cd2fe208f38a6708736494cabfcf219b137cb", size = 1308570, upload-time = "2026-07-25T14:39:09.772Z" }, + { url = "https://files.pythonhosted.org/packages/8a/ba/ed5baa948085a08ed38362e0e88ddad7a4a524b0a2a1581c8138ce7b307a/hypothesis-6.161.5-cp310-cp310-win_amd64.whl", hash = "sha256:44e25c35123a2b77ebd2df356e1a78bf2c9abbf875222ff252b3012801bfb143", size = 658822, upload-time = "2026-07-25T14:38:45.402Z" }, + { url = "https://files.pythonhosted.org/packages/9d/82/60f7213dd5262863646bbf31ca28e1dba68730080c14f39744d110733932/hypothesis-6.161.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f6d55821d13890875d5a50a1433448c8a86f7f83e85103a527dad232d18c470e", size = 767446, upload-time = "2026-07-25T14:38:39.904Z" }, + { url = "https://files.pythonhosted.org/packages/20/b3/145fe198d155ac394cac067ae852074adbca2c015a7244fdb3df2f655c19/hypothesis-6.161.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ecb5878b81beb1dfda4e573375a816b1ffa7931d7899a1a2d7216afa5e1efb4f", size = 763215, upload-time = "2026-07-25T14:38:30.624Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c0/0ea73426ba5b2504d4e3f4e6c7589f5808ad6eef4c922f15766c0537bbc2/hypothesis-6.161.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bff9062c1af02e391c0e48aa75f989393651c6e07f1b50eddeadaa8ca440f944", size = 1092115, upload-time = "2026-07-25T14:39:18.121Z" }, + { url = "https://files.pythonhosted.org/packages/dc/65/2a52daac1d39d0a1a88f96a602ff997e4665c5a9acc4d029e3168e536cd1/hypothesis-6.161.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:570ab5afc34dd7a78e4d1ab1a0ea4e026bbae6caee79dc04245d0347f1d548a7", size = 1141577, upload-time = "2026-07-25T14:38:55.841Z" }, + { url = "https://files.pythonhosted.org/packages/91/cd/77693dbb6766345d980fdfa41f7e7d3d0a676469d156c8e9455c1ebda67d/hypothesis-6.161.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6a7541433984c5b5fb4ad433a715808579cba16ebaca6a214a99d2f8c35ed49d", size = 1265879, upload-time = "2026-07-25T14:38:21.759Z" }, + { url = "https://files.pythonhosted.org/packages/ed/af/ea72a466210a43b35f3e9d86350aca94529a00d2aff698b4cdd490238955/hypothesis-6.161.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f3118cce6e66366e2c64389a5dd6e9bc49c697c366d62709e6fda0b3a8a7d997", size = 1308593, upload-time = "2026-07-25T14:38:23.221Z" }, + { url = "https://files.pythonhosted.org/packages/50/8c/d0ae6738baed9061ca29b18de506f5bc8dc84649b216b55213474ec7da9b/hypothesis-6.161.5-cp311-cp311-win_amd64.whl", hash = "sha256:d8cbd7c938b191d5f9bf846a79e81484135a239cd45bf194993949645495ee6f", size = 658671, upload-time = "2026-07-25T14:38:54.453Z" }, + { url = "https://files.pythonhosted.org/packages/bf/37/71a53545f2732574bdd7a45a6e073043bce3447fec4aa722211ebf742f2e/hypothesis-6.161.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:37016b6b4842993b06ee453dda4dedac5ef328cefa6daa36372d261abf12db43", size = 768565, upload-time = "2026-07-25T14:38:14.362Z" }, + { url = "https://files.pythonhosted.org/packages/e6/56/7f32ba1443d5819b324444e7d5ebcf207ba4a0f9219a1693a7994293fd3f/hypothesis-6.161.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d60a204b86936b29d8914f93a99c59a2ecd4e311be793bf32a3f94774d41e016", size = 760188, upload-time = "2026-07-25T14:38:07.894Z" }, + { url = "https://files.pythonhosted.org/packages/07/0d/699436929d2980103c9ddba153872ebed55cfcc13f0f78c1741ac6128205/hypothesis-6.161.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cee9f1a563830a9137b9d1505c9c4fac760bc43dfbfa5873bebefe36c8dca4ec", size = 1090570, upload-time = "2026-07-25T14:39:00.492Z" }, + { url = "https://files.pythonhosted.org/packages/8a/a5/2c28b58d16443fd8ad63e4f287440291a42135073748e5e511084f32a6f3/hypothesis-6.161.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d7fcf44f9876b7b4fdc40b607342219c634cf6a1acc708dc0ff88e8e48ae8ea2", size = 1140601, upload-time = "2026-07-25T14:39:21.403Z" }, + { url = "https://files.pythonhosted.org/packages/4e/fe/f0f87c38dc741687bace80553478f8c9796ba5b6e68793a80990d79ec5ac/hypothesis-6.161.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:59088f6458d2a6ef04724a2a639418c97dd8b8c280bfd222fcc5566854560caf", size = 1263341, upload-time = "2026-07-25T14:38:41.257Z" }, + { url = "https://files.pythonhosted.org/packages/44/db/02e30bcdb3434f4eee8fdbebf5cf151fecd37d2a76f2fc19f2745c93ca38/hypothesis-6.161.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ea8e7e6bed5407ab902026d8cac74d10cac894a496d82e261a0beaca499114dc", size = 1307604, upload-time = "2026-07-25T14:39:19.867Z" }, + { url = "https://files.pythonhosted.org/packages/1d/70/1f6349a244f13c5a383a62ca674d3175018c7fc30fbaad10faa859d5c2cf/hypothesis-6.161.5-cp312-cp312-win_amd64.whl", hash = "sha256:a4ad11f4eafd561a672cb9fa977d0f58ab4ae9cf4b160dd3c12c351089f3e2be", size = 656103, upload-time = "2026-07-25T14:38:57.639Z" }, + { url = "https://files.pythonhosted.org/packages/52/07/86f98ebd945f3f8a821c1e7bf9b530902675145d93ed44fa56a309cc24fd/hypothesis-6.161.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3b4c308ff19741ab9f795cac61cce61227f55a1b9767ad525a34de8b01391d1d", size = 768455, upload-time = "2026-07-25T14:38:27.821Z" }, + { url = "https://files.pythonhosted.org/packages/70/11/93c00ba5c77b886017ea8eaa42de8ca937e319032c4251b0540fc1838ae1/hypothesis-6.161.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ef16fac46cd5675504a4d3824f443f6842b80fae8b6d15ecffa9c90e0fcf4522", size = 760099, upload-time = "2026-07-25T14:38:04.918Z" }, + { url = "https://files.pythonhosted.org/packages/86/13/c8787cfae81c816976c1b3aaa43294c3abd6fd055a0147122edd7357a349/hypothesis-6.161.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc4c6aab8dbabb98b9c5ca8da8fa9294d2e5173432a12abcb447e83e666fa430", size = 1090486, upload-time = "2026-07-25T14:39:26.243Z" }, + { url = "https://files.pythonhosted.org/packages/f8/be/48f4f56bfb24787aa27e5ae851d1aee5214ab6e95311cf8c88a56707941f/hypothesis-6.161.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f485e3c7ead1f76a8c060b16c6b50e5615264819e95b794c6fc6882a9cbfdc4", size = 1140433, upload-time = "2026-07-25T14:38:59.043Z" }, + { url = "https://files.pythonhosted.org/packages/2e/50/c119bfec24d267e4af5bdefda8fd490f7b3a7ee36a52d5ac0b88e07c10b3/hypothesis-6.161.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:75bb899b2db5c45dbfa6ef704c26259bfd27fb3418179c3bd5788b7f2ecce72d", size = 1263296, upload-time = "2026-07-25T14:38:06.719Z" }, + { url = "https://files.pythonhosted.org/packages/81/89/eeb97a9684e3eaae801dd538058202b0b65c2aaecea0211e17d79f731170/hypothesis-6.161.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:894e5d9e2cd97798c3dc2731699098c7f800a7905572db1677e0d27d3b41f541", size = 1307324, upload-time = "2026-07-25T14:38:24.881Z" }, + { url = "https://files.pythonhosted.org/packages/15/9d/e0c64a64721ba169dff5a6f12236d5102f8c76f661598d7837591ec7b375/hypothesis-6.161.5-cp313-cp313-win_amd64.whl", hash = "sha256:62452bb19d73496a74919d82afc6306bf9bd42b8cf1dfce21da3802c596a59b1", size = 656067, upload-time = "2026-07-25T14:38:37.157Z" }, + { url = "https://files.pythonhosted.org/packages/31/27/0c6884785ab6afb41305296b2a5fac2c5d1d26dba40e85c70909b8692fce/hypothesis-6.161.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:35b41648b547a233dd89bed37a3ce8c8298a454fd7133b27b0a37ced135eddfe", size = 768668, upload-time = "2026-07-25T14:39:15.033Z" }, + { url = "https://files.pythonhosted.org/packages/7b/12/17c63bba85201d5fa9db89841e8548c4fa6b240b2437c80d606a65889eb6/hypothesis-6.161.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:429d2179b01787a54f8ccd150c02077a6feb973f14ef31664227148c6a1fadff", size = 760240, upload-time = "2026-07-25T14:38:43.93Z" }, + { url = "https://files.pythonhosted.org/packages/a2/df/1084fd695a1516120a5ea26c026d9f0a071fdf9efcba774c94c09332b372/hypothesis-6.161.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b23fc5441ef297d56797dd372889dec38ddf3662468772b347db2fd8fd43eced", size = 1090987, upload-time = "2026-07-25T14:39:06.646Z" }, + { url = "https://files.pythonhosted.org/packages/a0/a3/bef99daeaf6d1d2db09790cd4ec9ce0cdcac52faaf1fd80b4eaba5d5ea5e/hypothesis-6.161.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f42dcc7fee0d4d56d011218b8c4d5afa6cdee5dbc690652d2a1a598d21969a3", size = 1140613, upload-time = "2026-07-25T14:38:26.284Z" }, + { url = "https://files.pythonhosted.org/packages/9f/09/ce1f6e29d897c7dcb7de1665a9e30d1e00500933caec697a4338db9e7bd0/hypothesis-6.161.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2825fac0d0428cb5e7826347aa4e60106d28f32948cd58c837097f3bd96dbf6c", size = 1263802, upload-time = "2026-07-25T14:38:33.277Z" }, + { url = "https://files.pythonhosted.org/packages/81/e5/7b33aae590bc457fffba70c4f19cc150e3ca356f8e15322f9224c5372a64/hypothesis-6.161.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e9f859ecfd3e00ebb8a36fbb36a8951513fd8e09103a56c645b102e0a51dd1bc", size = 1307642, upload-time = "2026-07-25T14:39:29.729Z" }, + { url = "https://files.pythonhosted.org/packages/d4/8b/4156a9e70bf8c69f54954539ca805e7311048516b665c5f87bee2c1955b7/hypothesis-6.161.5-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:b2647c2d5da341467c1ffaad0dd86d8612e27da2419e76b3b6ee79a33bba7d86", size = 600146, upload-time = "2026-07-25T14:38:17.815Z" }, + { url = "https://files.pythonhosted.org/packages/f9/db/48a518f3f2facd7b280592f81dbb0ba09109656be4ad3dbf0d95a02b4c02/hypothesis-6.161.5-cp314-cp314-win_amd64.whl", hash = "sha256:7c40dd1a3e99497d48a3cfd4c5d71bdb0cd70402a9e09eceb160f045edc92b3a", size = 655981, upload-time = "2026-07-25T14:39:16.52Z" }, + { url = "https://files.pythonhosted.org/packages/25/92/1e9f9f44ef75fc052cc0e3700cfc7d52fd4af1ed9e8b2945d19956bb83cb/hypothesis-6.161.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:6c1b7e1d508a3cc5c10ea7a7a4a7d3b0b56fe6291e8936bb9d052af530380b15", size = 767251, upload-time = "2026-07-25T14:38:52.608Z" }, + { url = "https://files.pythonhosted.org/packages/48/31/0c3f824bbb1fccc0338930c8ef855880065d9d58dddf9547a1b9ae4e664d/hypothesis-6.161.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b0926ba51452c24b92fbcbb30d28cc43fd6cd9e636ea2134fbc1cc2c9da109de", size = 758710, upload-time = "2026-07-25T14:38:09.156Z" }, + { url = "https://files.pythonhosted.org/packages/ab/6b/5811dbb4c1dc0a772519fb9af9c4089128bceeb5deb4c36a887e1e731920/hypothesis-6.161.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba8d0346308b558bfbb49f965ed1d9d5bb65758f4530cb30ce1f9bd911a130b4", size = 1089583, upload-time = "2026-07-25T14:38:49.83Z" }, + { url = "https://files.pythonhosted.org/packages/5f/29/f9cbfd9d0aaea5370a7ceb966f6c8d47e9101ee9a6623606e9f564a59c6e/hypothesis-6.161.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c64079ef4633ab7b540f532107188d75999ee5b342116636312e09ada15783e", size = 1139525, upload-time = "2026-07-25T14:38:29.262Z" }, + { url = "https://files.pythonhosted.org/packages/99/27/f56a03be4ee21f25828e8fba8a502d6840826aea4ff784ff54cbd1f51175/hypothesis-6.161.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ed3eede1c23689edb4773b4c2303ee91518e82056a0a4893fc8415b18e5c3e8b", size = 1261963, upload-time = "2026-07-25T14:38:31.931Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ea/72e11fe6832a68674271655be5cb6f035502dd328ea629b3ee510fb130a5/hypothesis-6.161.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:82b1088ebcad5ab6d146caf9a1552019472a8a9302727f1be5939e30460a101b", size = 1306382, upload-time = "2026-07-25T14:38:13.011Z" }, + { url = "https://files.pythonhosted.org/packages/df/8b/bb039d11a805db0977904c245fa3dcf6d266808482d9dfc6dad3e5f1b256/hypothesis-6.161.5-cp314-cp314t-win_amd64.whl", hash = "sha256:bbe8705ff394a573624cd53f2192dad6255c86346704adf9873cb3acb9b940e5", size = 656131, upload-time = "2026-07-25T14:38:16.712Z" }, + { url = "https://files.pythonhosted.org/packages/21/71/0b28e9ac10f692d9b85a0df74615d308ec1e77140c61773ab107070b82d3/hypothesis-6.161.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:64167d94bcd4a15c1b0aa44c3176f019614898ff10907a2065c142ed34acf828", size = 768375, upload-time = "2026-07-25T14:39:23Z" }, + { url = "https://files.pythonhosted.org/packages/6d/5c/f518bdc85ee5a9b9153aa01b3ed6a77304b8f573a461da1a6694a6992001/hypothesis-6.161.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:22bc641a290428cfd01c62b2b7fe7686007b104dfb5e8ed5420fd3d3a281ebb9", size = 764276, upload-time = "2026-07-25T14:39:02.167Z" }, + { url = "https://files.pythonhosted.org/packages/af/3b/6c93b03adb5804b01854d2801a1f74705ea73d685cef7861484ad8804856/hypothesis-6.161.5-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:35527a795992396b82293fec2294ba5b9c0768350c8cb89085e3f5f76ed7d08e", size = 1093089, upload-time = "2026-07-25T14:39:11.442Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3d/6ebfbd76534a71c7ffdd48e0d42373a14152e45714feabf0c25bd7d6b10a/hypothesis-6.161.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ae81035628d2b80435320c43cb09f5b421bac4941bd8ea9b2778d278ae65996", size = 1142876, upload-time = "2026-07-25T14:39:24.674Z" }, + { url = "https://files.pythonhosted.org/packages/fd/07/b57cfd34f55c14a2e823284a796d4228c356e338e1195affc0546bca567b/hypothesis-6.161.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:20e3ab3b0dc300a84b58f41ddd7f5190757521282aad58701c4c76f28a3d981a", size = 659768, upload-time = "2026-07-25T14:39:08.166Z" }, +] + [[package]] name = "identify" version = "2.6.12" @@ -723,6 +902,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, ] +[[package]] +name = "outcome" +version = "1.3.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/df/77698abfac98571e65ffeb0c1fba8ffd692ab8458d617a0eed7d9a8d38f2/outcome-1.3.0.post0.tar.gz", hash = "sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8", size = 21060, upload-time = "2023-10-26T04:26:04.361Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/8b/5ab7257531a5d830fc8000c476e63c935488d74609b50f9384a643ec0a62/outcome-1.3.0.post0-py2.py3-none-any.whl", hash = "sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b", size = 10692, upload-time = "2023-10-26T04:26:02.532Z" }, +] + [[package]] name = "packaging" version = "26.2" @@ -866,6 +1057,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382, upload-time = "2025-05-05T19:44:33.502Z" }, ] +[[package]] +name = "pytest-xdist" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, +] + [[package]] name = "python-discovery" version = "1.5.0" @@ -960,21 +1164,30 @@ wheels = [ ] [[package]] -name = "setuptools" -version = "80.8.0" +name = "shellingham" +version = "1.5.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8d/d2/ec1acaaff45caed5c2dedb33b67055ba9d4e96b091094df90762e60135fe/setuptools-80.8.0.tar.gz", hash = "sha256:49f7af965996f26d43c8ae34539c8d99c5042fbff34302ea151eaa9c207cd257", size = 1319720, upload-time = "2025-05-20T14:02:53.503Z" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/58/29/93c53c098d301132196c3238c312825324740851d77a8500a2462c0fd888/setuptools-80.8.0-py3-none-any.whl", hash = "sha256:95a60484590d24103af13b686121328cc2736bee85de8936383111e421b9edc0", size = 1201470, upload-time = "2025-05-20T14:02:51.348Z" }, + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] [[package]] -name = "shellingham" -version = "1.5.4" +name = "sniffio" +version = "1.3.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, ] [[package]] @@ -1072,6 +1285,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6b/3d/7ba55871e9d794d40b6c8424f2e5d1b267ea5d9a4bd2175e08b57960ba13/tox-4.58.0-py3-none-any.whl", hash = "sha256:dcae21f5f015f3a67658e35644cce0d1aa0dedcd06f3927f95d84e1717f6cea5", size = 223298, upload-time = "2026-07-21T13:10:34.731Z" }, ] +[[package]] +name = "trio" +version = "0.33.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "cffi", marker = "implementation_name != 'pypy' and os_name == 'nt'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "outcome" }, + { name = "sniffio" }, + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/b6/c744031c6f89b18b3f5f4f7338603ab381d740a7f45938c4607b2302481f/trio-0.33.0.tar.gz", hash = "sha256:a29b92b73f09d4b48ed249acd91073281a7f1063f09caba5dc70465b5c7aa970", size = 605109, upload-time = "2026-02-14T18:40:55.386Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/93/dab25dc87ac48da0fe0f6419e07d0bfd98799bed4e05e7b9e0f85a1a4b4b/trio-0.33.0-py3-none-any.whl", hash = "sha256:3bd5d87f781d9b0192d592aef28691f8951d6c2e41b7e1da4c25cde6c180ae9b", size = 510294, upload-time = "2026-02-14T18:40:53.313Z" }, +] + [[package]] name = "trove-classifiers" version = "2025.5.9.12" @@ -1164,47 +1395,57 @@ wheels = [ [[package]] name = "zope-event" -version = "5.0" +version = "6.2" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "setuptools" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/46/c2/427f1867bb96555d1d34342f1dd97f8c420966ab564d58d18469a1db8736/zope.event-5.0.tar.gz", hash = "sha256:bac440d8d9891b4068e2b5a2c5e2c9765a9df762944bda6955f96bb9b91e67cd", size = 17350, upload-time = "2023-06-23T06:28:35.709Z" } +sdist = { url = "https://files.pythonhosted.org/packages/93/41/faa10af34d48d9cd6fa0249a1162943ad84a9590bd1a06939981e6640416/zope_event-6.2.tar.gz", hash = "sha256:b97d5d6327067ee6b9dfcbdf606ade9ade70991e19c162e808ea39e5fcf0f8d3", size = 18958, upload-time = "2026-04-28T06:24:10.578Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/42/f8dbc2b9ad59e927940325a22d6d3931d630c3644dae7e2369ef5d9ba230/zope.event-5.0-py3-none-any.whl", hash = "sha256:2832e95014f4db26c47a13fdaef84cef2f4df37e66b59d8f1f4a8f319a632c26", size = 6824, upload-time = "2023-06-23T06:28:32.652Z" }, + { url = "https://files.pythonhosted.org/packages/9e/33/848922889e946d4befc415c219fe516af75c49555d8e736e183bfd30db42/zope_event-6.2-py3-none-any.whl", hash = "sha256:5e755153ac4faf64c10a4b6dd3307680166a3edf65b38df22df592610f8fa874", size = 6525, upload-time = "2026-04-28T06:24:09.176Z" }, ] [[package]] name = "zope-interface" -version = "7.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "setuptools" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/30/93/9210e7606be57a2dfc6277ac97dcc864fd8d39f142ca194fdc186d596fda/zope.interface-7.2.tar.gz", hash = "sha256:8b49f1a3d1ee4cdaf5b32d2e738362c7f5e40ac8b46dd7d1a65e82a4872728fe", size = 252960, upload-time = "2024-11-28T08:45:39.224Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/76/71/e6177f390e8daa7e75378505c5ab974e0bf59c1d3b19155638c7afbf4b2d/zope.interface-7.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ce290e62229964715f1011c3dbeab7a4a1e4971fd6f31324c4519464473ef9f2", size = 208243, upload-time = "2024-11-28T08:47:29.781Z" }, - { url = "https://files.pythonhosted.org/packages/52/db/7e5f4226bef540f6d55acfd95cd105782bc6ee044d9b5587ce2c95558a5e/zope.interface-7.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:05b910a5afe03256b58ab2ba6288960a2892dfeef01336dc4be6f1b9ed02ab0a", size = 208759, upload-time = "2024-11-28T08:47:31.908Z" }, - { url = "https://files.pythonhosted.org/packages/28/ea/fdd9813c1eafd333ad92464d57a4e3a82b37ae57c19497bcffa42df673e4/zope.interface-7.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:550f1c6588ecc368c9ce13c44a49b8d6b6f3ca7588873c679bd8fd88a1b557b6", size = 254922, upload-time = "2024-11-28T09:18:11.795Z" }, - { url = "https://files.pythonhosted.org/packages/3b/d3/0000a4d497ef9fbf4f66bb6828b8d0a235e690d57c333be877bec763722f/zope.interface-7.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0ef9e2f865721553c6f22a9ff97da0f0216c074bd02b25cf0d3af60ea4d6931d", size = 249367, upload-time = "2024-11-28T08:48:24.238Z" }, - { url = "https://files.pythonhosted.org/packages/3e/e5/0b359e99084f033d413419eff23ee9c2bd33bca2ca9f4e83d11856f22d10/zope.interface-7.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:27f926f0dcb058211a3bb3e0e501c69759613b17a553788b2caeb991bed3b61d", size = 254488, upload-time = "2024-11-28T08:48:28.816Z" }, - { url = "https://files.pythonhosted.org/packages/7b/90/12d50b95f40e3b2fc0ba7f7782104093b9fd62806b13b98ef4e580f2ca61/zope.interface-7.2-cp310-cp310-win_amd64.whl", hash = "sha256:144964649eba4c5e4410bb0ee290d338e78f179cdbfd15813de1a664e7649b3b", size = 211947, upload-time = "2024-11-28T08:48:18.831Z" }, - { url = "https://files.pythonhosted.org/packages/98/7d/2e8daf0abea7798d16a58f2f3a2bf7588872eee54ac119f99393fdd47b65/zope.interface-7.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1909f52a00c8c3dcab6c4fad5d13de2285a4b3c7be063b239b8dc15ddfb73bd2", size = 208776, upload-time = "2024-11-28T08:47:53.009Z" }, - { url = "https://files.pythonhosted.org/packages/a0/2a/0c03c7170fe61d0d371e4c7ea5b62b8cb79b095b3d630ca16719bf8b7b18/zope.interface-7.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:80ecf2451596f19fd607bb09953f426588fc1e79e93f5968ecf3367550396b22", size = 209296, upload-time = "2024-11-28T08:47:57.993Z" }, - { url = "https://files.pythonhosted.org/packages/49/b4/451f19448772b4a1159519033a5f72672221e623b0a1bd2b896b653943d8/zope.interface-7.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:033b3923b63474800b04cba480b70f6e6243a62208071fc148354f3f89cc01b7", size = 260997, upload-time = "2024-11-28T09:18:13.935Z" }, - { url = "https://files.pythonhosted.org/packages/65/94/5aa4461c10718062c8f8711161faf3249d6d3679c24a0b81dd6fc8ba1dd3/zope.interface-7.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a102424e28c6b47c67923a1f337ede4a4c2bba3965b01cf707978a801fc7442c", size = 255038, upload-time = "2024-11-28T08:48:26.381Z" }, - { url = "https://files.pythonhosted.org/packages/9f/aa/1a28c02815fe1ca282b54f6705b9ddba20328fabdc37b8cf73fc06b172f0/zope.interface-7.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:25e6a61dcb184453bb00eafa733169ab6d903e46f5c2ace4ad275386f9ab327a", size = 259806, upload-time = "2024-11-28T08:48:30.78Z" }, - { url = "https://files.pythonhosted.org/packages/a7/2c/82028f121d27c7e68632347fe04f4a6e0466e77bb36e104c8b074f3d7d7b/zope.interface-7.2-cp311-cp311-win_amd64.whl", hash = "sha256:3f6771d1647b1fc543d37640b45c06b34832a943c80d1db214a37c31161a93f1", size = 212305, upload-time = "2024-11-28T08:49:14.525Z" }, - { url = "https://files.pythonhosted.org/packages/68/0b/c7516bc3bad144c2496f355e35bd699443b82e9437aa02d9867653203b4a/zope.interface-7.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:086ee2f51eaef1e4a52bd7d3111a0404081dadae87f84c0ad4ce2649d4f708b7", size = 208959, upload-time = "2024-11-28T08:47:47.788Z" }, - { url = "https://files.pythonhosted.org/packages/a2/e9/1463036df1f78ff8c45a02642a7bf6931ae4a38a4acd6a8e07c128e387a7/zope.interface-7.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:21328fcc9d5b80768bf051faa35ab98fb979080c18e6f84ab3f27ce703bce465", size = 209357, upload-time = "2024-11-28T08:47:50.897Z" }, - { url = "https://files.pythonhosted.org/packages/07/a8/106ca4c2add440728e382f1b16c7d886563602487bdd90004788d45eb310/zope.interface-7.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f6dd02ec01f4468da0f234da9d9c8545c5412fef80bc590cc51d8dd084138a89", size = 264235, upload-time = "2024-11-28T09:18:15.56Z" }, - { url = "https://files.pythonhosted.org/packages/fc/ca/57286866285f4b8a4634c12ca1957c24bdac06eae28fd4a3a578e30cf906/zope.interface-7.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8e7da17f53e25d1a3bde5da4601e026adc9e8071f9f6f936d0fe3fe84ace6d54", size = 259253, upload-time = "2024-11-28T08:48:29.025Z" }, - { url = "https://files.pythonhosted.org/packages/96/08/2103587ebc989b455cf05e858e7fbdfeedfc3373358320e9c513428290b1/zope.interface-7.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cab15ff4832580aa440dc9790b8a6128abd0b88b7ee4dd56abacbc52f212209d", size = 264702, upload-time = "2024-11-28T08:48:37.363Z" }, - { url = "https://files.pythonhosted.org/packages/5f/c7/3c67562e03b3752ba4ab6b23355f15a58ac2d023a6ef763caaca430f91f2/zope.interface-7.2-cp312-cp312-win_amd64.whl", hash = "sha256:29caad142a2355ce7cfea48725aa8bcf0067e2b5cc63fcf5cd9f97ad12d6afb5", size = 212466, upload-time = "2024-11-28T08:49:14.397Z" }, - { url = "https://files.pythonhosted.org/packages/c6/3b/e309d731712c1a1866d61b5356a069dd44e5b01e394b6cb49848fa2efbff/zope.interface-7.2-cp313-cp313-macosx_10_9_x86_64.whl", hash = "sha256:3e0350b51e88658d5ad126c6a57502b19d5f559f6cb0a628e3dc90442b53dd98", size = 208961, upload-time = "2024-11-28T08:48:29.865Z" }, - { url = "https://files.pythonhosted.org/packages/49/65/78e7cebca6be07c8fc4032bfbb123e500d60efdf7b86727bb8a071992108/zope.interface-7.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:15398c000c094b8855d7d74f4fdc9e73aa02d4d0d5c775acdef98cdb1119768d", size = 209356, upload-time = "2024-11-28T08:48:33.297Z" }, - { url = "https://files.pythonhosted.org/packages/11/b1/627384b745310d082d29e3695db5f5a9188186676912c14b61a78bbc6afe/zope.interface-7.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:802176a9f99bd8cc276dcd3b8512808716492f6f557c11196d42e26c01a69a4c", size = 264196, upload-time = "2024-11-28T09:18:17.584Z" }, - { url = "https://files.pythonhosted.org/packages/b8/f6/54548df6dc73e30ac6c8a7ff1da73ac9007ba38f866397091d5a82237bd3/zope.interface-7.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb23f58a446a7f09db85eda09521a498e109f137b85fb278edb2e34841055398", size = 259237, upload-time = "2024-11-28T08:48:31.71Z" }, - { url = "https://files.pythonhosted.org/packages/b6/66/ac05b741c2129fdf668b85631d2268421c5cd1a9ff99be1674371139d665/zope.interface-7.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a71a5b541078d0ebe373a81a3b7e71432c61d12e660f1d67896ca62d9628045b", size = 264696, upload-time = "2024-11-28T08:48:41.161Z" }, - { url = "https://files.pythonhosted.org/packages/0a/2f/1bccc6f4cc882662162a1158cda1a7f616add2ffe322b28c99cb031b4ffc/zope.interface-7.2-cp313-cp313-win_amd64.whl", hash = "sha256:4893395d5dd2ba655c38ceb13014fd65667740f09fa5bb01caa1e6284e48c0cd", size = 212472, upload-time = "2024-11-28T08:49:56.587Z" }, +version = "8.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/08/dc/50550cfcbb2ea3cbca5f1d7ed05c8aa840f831a0f2d63aec0a953f7c590e/zope_interface-8.5.tar.gz", hash = "sha256:7a3ba1c5877f0f3e3906b02ddf793abed2becc2948116414ce0e1dd820b68d6d", size = 257957, upload-time = "2026-05-26T06:50:14.574Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/43/9cd98bee951d23848de690ba2809f87e3b22c67c370987acc960da15ad37/zope_interface-8.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c0c8aa2bf8f3911ef37b87deb1bbe225a310e6eb6522a16d77f5d8330c4f6fbe", size = 210951, upload-time = "2026-05-26T06:49:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/17/0f/8f1a29966bcf863e3a2121edcafb81c55715de7886bcc9544749cc79e7da/zope_interface-8.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:efe234a0fafb4b6b1602e9be9245b97c2bf06d67c07af5a4bc3c0438978b555c", size = 211309, upload-time = "2026-05-26T06:49:02.732Z" }, + { url = "https://files.pythonhosted.org/packages/9f/9f/37e564eaaf85e3abc1ada40a79fa43f2ab45bdb67431b0ec0fe29e4763e2/zope_interface-8.5-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:dabeb6fe1228d411994f300811edc6866fff0cdcbc9cef98a78f05ea0da42e37", size = 254881, upload-time = "2026-05-26T06:49:04.303Z" }, + { url = "https://files.pythonhosted.org/packages/06/61/e6501d8ea7a2cac3217e03f404e1f98c1df7191d83cfe86b1895fbba5dac/zope_interface-8.5-cp310-cp310-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:147a9442dcc2b7339ecdb1be2b3cdb098e90462e39425054053ebfb50d99125a", size = 259811, upload-time = "2026-05-26T06:49:06.373Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/bfa25ef480b02af6e9452c478483fec75e87c9e2b60c407fd0b1f6054b9c/zope_interface-8.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a17e681224267880707c9ec9e730ad9a1ad2d65c371256843efba6cf48711b58", size = 260358, upload-time = "2026-05-26T06:49:08.317Z" }, + { url = "https://files.pythonhosted.org/packages/64/51/2b518072fea76242da64451d501c69b7b5ccdef9b57fead584ccf1c180d5/zope_interface-8.5-cp310-cp310-win_amd64.whl", hash = "sha256:d178968a1a611df30549a717d1624cb38ca810347339e3e37b7baa6f6781a170", size = 214822, upload-time = "2026-05-26T06:49:10.441Z" }, + { url = "https://files.pythonhosted.org/packages/ce/f1/83ad110fb847413affe71609bb50e59e1aa082e1236030122227c7c283d3/zope_interface-8.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:afc66ccaef2a3c0bef6ca02aad40d29a39276389dad16a8eac36f9f385e4d057", size = 211426, upload-time = "2026-05-26T06:49:12.595Z" }, + { url = "https://files.pythonhosted.org/packages/bb/a7/6b6e0c31ac240cb9fc015ae9ed45ca54be886c18fcf7bfa2377a4d7a8785/zope_interface-8.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c28044972187245d7a309e4699319bfdbd2ffcbf7176d1d4ddf5adffb2dea80f", size = 211850, upload-time = "2026-05-26T06:49:14.474Z" }, + { url = "https://files.pythonhosted.org/packages/37/36/7599ecabcf80ce4fef2e1ef3c5ac0d4696b61f03f724cc44022f4d226af9/zope_interface-8.5-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:03bbecc7982af713d7499d4084bc03916413d17ffd45f89009348cc0c1d9e376", size = 260711, upload-time = "2026-05-26T06:49:16.568Z" }, + { url = "https://files.pythonhosted.org/packages/03/3e/1774b0ee46ccbb5498ee3c33ece40315b6ef58bc71957be94bd345340bc1/zope_interface-8.5-cp311-cp311-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bf917009a4a7457c7290225a019f4a0aa706d96accd2cfdba2418d3bc1fcde2f", size = 265277, upload-time = "2026-05-26T06:49:18.656Z" }, + { url = "https://files.pythonhosted.org/packages/b6/09/e533b2ffabaae4e5d5730d6768a591cf335defe8e37bec2ad905d09be656/zope_interface-8.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:31cff25b2aaedb5267e6e77b1e9be6b0ec4f622032de8a069202b8ffacda7dc2", size = 266369, upload-time = "2026-05-26T06:49:20.174Z" }, + { url = "https://files.pythonhosted.org/packages/49/4a/3ebe6a4c122b2d5340db45cbe7e490663d3228b172710ec71060cd5d541e/zope_interface-8.5-cp311-cp311-win_amd64.whl", hash = "sha256:17a3114bbdddb5e75e5784cdf318944636190cbbc72d357ef9fb1a8b0351f955", size = 215161, upload-time = "2026-05-26T06:49:21.799Z" }, + { url = "https://files.pythonhosted.org/packages/d2/59/056ad97af5b16db1975ee98ec7ab03d2ce3f3355efad904ced1dbce0e39f/zope_interface-8.5-cp311-cp311-win_arm64.whl", hash = "sha256:aab6bb5bee10f38ea688b95ba054396b67f613552d2c8378be7fcb2d2fba7646", size = 213481, upload-time = "2026-05-26T06:49:25.085Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/b84123a948f3162a34623e188922827cd845244fdd043ed20f8d02228caa/zope_interface-8.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:8e6ee90c2e6de7c37058d5fa41f123c8b13a312db8d1e0fb5840d7f4bcdff9c9", size = 212165, upload-time = "2026-05-26T06:49:26.566Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/cbceec44f1b27208a76c1a688c131302685852406a23df5aab68324109cc/zope_interface-8.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c1adc90d3576b3b4c4de4953e6002c37bef28b78d7fa54c1bbfd0c50f022fe7c", size = 212341, upload-time = "2026-05-26T06:49:28.182Z" }, + { url = "https://files.pythonhosted.org/packages/e1/c3/005032195ff3b210c139b7c560ed5c534e844b0907d8e44d2b3d8919305e/zope_interface-8.5-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:e6347b8d8d12c5eca6502450a92be30079b7acfade2c4f693efa0deb8871b06e", size = 265296, upload-time = "2026-05-26T06:49:29.741Z" }, + { url = "https://files.pythonhosted.org/packages/c5/66/1036543d6a66bc04c19df3cf650f3ad938a002ab0a443c24e23e8de5e8b9/zope_interface-8.5-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5e970dabea777a24b0b0bbf9dae3ab75ce8b2d8e948edf4875627034b21f3560", size = 270689, upload-time = "2026-05-26T06:49:31.767Z" }, + { url = "https://files.pythonhosted.org/packages/30/4c/8b56259558cace4414e753ca6740396a1f59d4a95ddb55b4658600408670/zope_interface-8.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f0b48ccadaa9839e09ff81e969703cecb3f402c813bfe8b958652e699bea69f5", size = 270280, upload-time = "2026-05-26T06:49:33.489Z" }, + { url = "https://files.pythonhosted.org/packages/f9/ea/649908c83aa8fdb7faf2ddca4d3cf6fb8f2157121267dc56e8f72681e26c/zope_interface-8.5-cp312-cp312-win_amd64.whl", hash = "sha256:e0e311f1277468c08fd59a2b41f71b43d25dff639789d364747acd1705c0df6e", size = 215019, upload-time = "2026-05-26T06:49:35.607Z" }, + { url = "https://files.pythonhosted.org/packages/9f/97/da13037b4c563e4df32eedbc819f8c00b754af494f68211e3dffd48d52da/zope_interface-8.5-cp312-cp312-win_arm64.whl", hash = "sha256:652b73107a04159ec6c020db6c1543d4f1e8f4d069bd2aac88a947820923517b", size = 213569, upload-time = "2026-05-26T06:49:37.317Z" }, + { url = "https://files.pythonhosted.org/packages/f4/8c/4c15755d701f2ec0e80d64a18e1ebaf5be2c584c0ec153fd516f5d13eada/zope_interface-8.5-cp313-cp313-macosx_10_9_x86_64.whl", hash = "sha256:28e80457c134d1fa57a7d758004dece348654e1b1467ac22dcdc20fc1d127c52", size = 212512, upload-time = "2026-05-26T06:49:38.996Z" }, + { url = "https://files.pythonhosted.org/packages/9a/2e/4360c54c465db042cc8fbeeec92abac28b4cedbf6ba63c1f092fd08a190f/zope_interface-8.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:09495ce9d559c06b70f2d4855b3e4f48a822a9ddc8be1d30c5b4e5be14ae1ace", size = 212541, upload-time = "2026-05-26T06:49:41.186Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a5/692a2b8d70f78e848793231d5fae5fecbf8d0cccd73430fdc34802a6d3c1/zope_interface-8.5-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:7849ad8fa90763cc1087f4dda78ca3a233e950b3e08fac7079297c9cafbbd7bb", size = 265191, upload-time = "2026-05-26T06:49:43.449Z" }, + { url = "https://files.pythonhosted.org/packages/70/8d/454a9cfc7a050c394ab4f11b3371f7897828b7415e096afff724637e65e0/zope_interface-8.5-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5578c9421ca409a1f39f153d6f7803e4cde01da592ec75a9ac5e1b777d18d33b", size = 270626, upload-time = "2026-05-26T06:49:45.425Z" }, + { url = "https://files.pythonhosted.org/packages/51/8c/db8409cfa3575b8e9b4800babd7d49f8228433cd1f0c56814bd0ada49c33/zope_interface-8.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e1bd7d96b4ca5fa311f54c9eac16dce4886b428c1531dbe06067763ccdf123b4", size = 270444, upload-time = "2026-05-26T06:49:47.025Z" }, + { url = "https://files.pythonhosted.org/packages/4a/df/a386940e41469ef615e100a216d8b386521e9e598817147f87932ca203c4/zope_interface-8.5-cp313-cp313-win_amd64.whl", hash = "sha256:0c8123d2a4dfde2a613c7cb772605477724782c20bc2e0ad1d9435376a6a44a3", size = 215021, upload-time = "2026-05-26T06:49:48.478Z" }, + { url = "https://files.pythonhosted.org/packages/89/75/477eb5669b6b2a7a843decd1a075e9b1971a8720017654143a7183abd3d9/zope_interface-8.5-cp313-cp313-win_arm64.whl", hash = "sha256:6d02be14f3173c6c7288bc2fdf530090c01c3cf8764ad46c68024686f364278e", size = 213610, upload-time = "2026-05-26T06:49:50.01Z" }, + { url = "https://files.pythonhosted.org/packages/d4/19/5032e954827fdf02db2d2f49737ac4378bb9cfc2cd95a8f2e2a5ae2ec01a/zope_interface-8.5-cp314-cp314-macosx_10_9_x86_64.whl", hash = "sha256:ffaecf013251a89d0de6feb49a46eba48ad8cbbf8a40aeb6045e459e7bec6784", size = 212597, upload-time = "2026-05-26T06:49:51.63Z" }, + { url = "https://files.pythonhosted.org/packages/f1/53/3ef644012cf8a6a234a2d6134aab5a5c65ac5467c86296865501d4fbc406/zope_interface-8.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:126fa9d1c52295ae076d4cf968634f0a1826afa408a20808b57ff72877b8f69f", size = 212626, upload-time = "2026-05-26T06:49:53.236Z" }, + { url = "https://files.pythonhosted.org/packages/32/67/bc8b4f465d388039255003e230c284a175cedf1203c692f23cb7bff64efe/zope_interface-8.5-cp314-cp314-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:3090e3a663d20194756a59a272e0c8508b889341e31d5894223331fe6b4f9b21", size = 266827, upload-time = "2026-05-26T06:49:54.873Z" }, + { url = "https://files.pythonhosted.org/packages/a7/eb/37d05b935ede53d79690fecc8d201440084418e590bcfc05f384451c7593/zope_interface-8.5-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9342fb74e2afefdb081bf1df727d209ea56995c6e13f5a0540e6d7aff4beafb8", size = 270139, upload-time = "2026-05-26T06:49:57.116Z" }, + { url = "https://files.pythonhosted.org/packages/8b/0b/fd0c54579e2ce8dc6cf1a757903f3374bc6fbda929a46af9e0f53cb0e5f0/zope_interface-8.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6c54725d818f1b57a7efb8b16528326e1f3c257b602b32393fd255c45af8799d", size = 270338, upload-time = "2026-05-26T06:49:58.698Z" }, + { url = "https://files.pythonhosted.org/packages/c1/1d/c420dcd777bb761067ea92879ac766694a5ca78608185f1aecea64cbfc11/zope_interface-8.5-cp314-cp314-win_amd64.whl", hash = "sha256:29d74febbae1afeb6834c4ccbf42e242a673c860060f09e53142825270456140", size = 215789, upload-time = "2026-05-26T06:50:00.405Z" }, + { url = "https://files.pythonhosted.org/packages/62/94/50b5eb8f94e527edceac14f9955e58917424ea79bb572ddc18548561cbc2/zope_interface-8.5-cp314-cp314-win_arm64.whl", hash = "sha256:633c8c49396f38df030340797c533e9fe460d1b5d1e42d88e55e938e525f548c", size = 213757, upload-time = "2026-05-26T06:50:01.973Z" }, + { url = "https://files.pythonhosted.org/packages/17/6f/5d5f32c4dfcdb16ce2ec5363da686840f13c13e1a1214cb70b49e1cd6d9f/zope_interface-8.5-cp314-cp314t-macosx_10_9_x86_64.whl", hash = "sha256:133999820fdbae513c36c03d6f29ef87317aaa3edef39112222b155083664714", size = 213591, upload-time = "2026-05-26T06:50:03.529Z" }, + { url = "https://files.pythonhosted.org/packages/f3/55/de0c3459ff717fce3342f9a29464c281fdeb0d36c3171ee88d119d5f0650/zope_interface-8.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8bd75c96966e573232f0599deaff717564828031c7f05563ccc1ac35c5ee0304", size = 213733, upload-time = "2026-05-26T06:50:05.101Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/d97430abd5ae9677e8b9295b58720c0064a5b557dbb6b8bf5928484cf0d8/zope_interface-8.5-cp314-cp314t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:14b0e9799351d4c34fe99afd67f0cdd76e55ba15c66a98699d5fc22ea8241e08", size = 294905, upload-time = "2026-05-26T06:50:07.384Z" }, + { url = "https://files.pythonhosted.org/packages/41/ec/a0f8f3dad6e74992f4654bdd94802be0929eabca7b871cac3b6fbb5e961b/zope_interface-8.5-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0cd6a732ac84b94eb1ef9222a117347a27efd294ee16810ffdf7ecd307677ed5", size = 300885, upload-time = "2026-05-26T06:50:08.997Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/6881b48803a0ee8d23eb5efa30fce3ed218a2bd9de5758ce489d224fee81/zope_interface-8.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:798b7c87d0e59a7d5d086d642208d0d8700ff0d55c4029134b3c479c3bfb110f", size = 304672, upload-time = "2026-05-26T06:50:10.563Z" }, + { url = "https://files.pythonhosted.org/packages/2e/0e/b4c01320859ff1d585438bc231fd60bd258d096359bccf6654fecdf0cffb/zope_interface-8.5-cp314-cp314t-win_amd64.whl", hash = "sha256:0fc3a9d45f114d27eaa1e53beeb144533689edca8a9f66505b1e8e8b3f075e42", size = 217241, upload-time = "2026-05-26T06:50:12.171Z" }, ]