diff --git a/Makefile b/Makefile index f41e80560..0a2d4cfad 100644 --- a/Makefile +++ b/Makefile @@ -14,7 +14,7 @@ clean-docs: uv run --isolated --all-packages --group docs $(MAKE) -C docs clean doctest: - uv run --isolated --all-packages --group docs $(MAKE) -C docs doctest + uv run --isolated --all-packages --group docs --directory docs pytest --doctest-glob="*.md" test-%: packages/% uv run --isolated --directory $< pytest diff --git a/docs/source/api-reference/adapters/conftest.py b/docs/source/api-reference/adapters/conftest.py new file mode 100644 index 000000000..474395a07 --- /dev/null +++ b/docs/source/api-reference/adapters/conftest.py @@ -0,0 +1,16 @@ +import pytest +from jumpstarter_driver_composite.driver import Composite +from jumpstarter_driver_network.driver import EchoNetwork + +from jumpstarter.common.utils import serve + + +@pytest.fixture +def network(): + with serve(Composite(children={"tcp_port": EchoNetwork(), "unix_socket": EchoNetwork()})) as client: + yield client + + +@pytest.fixture(autouse=True) +def adapters_namespace(doctest_namespace, network): + doctest_namespace["network"] = network diff --git a/docs/source/api-reference/adapters/network.md b/docs/source/api-reference/adapters/network.md index 485e3f243..b859973b6 100644 --- a/docs/source/api-reference/adapters/network.md +++ b/docs/source/api-reference/adapters/network.md @@ -39,36 +39,42 @@ export: ### Forward a remote TCP port to a local TCP port ```{doctest} +>>> from jumpstarter_driver_network.adapters import TcpPortforwardAdapter >>> # random port on localhost ->>> with TcpPortforwardAdapter(client=client.tcp_port) as addr: +>>> with TcpPortforwardAdapter(client=network.tcp_port) as addr: ... print(addr[0], addr[1]) 127.0.0.1 ... >>> >>> # specific address and port ->>> with TcpPortforwardAdapter(client=client.tcp_port, local_host="127.0.0.2", local_port=8080) as addr: +>>> with TcpPortforwardAdapter(client=network.tcp_port, local_host="127.0.0.2", local_port=8080) as addr: ... print(addr[0], addr[1]) 127.0.0.2 8080 + ``` ### Forward a remote Unix domain socket to a local socket ```{doctest} ->>> with UnixPortforwardAdapter(client=client.unix_socket) as addr: +>>> from jumpstarter_driver_network.adapters import UnixPortforwardAdapter +>>> with UnixPortforwardAdapter(client=network.unix_socket) as addr: ... print(addr) /tmp/jumpstarter-.../socket >>> # the type of the remote socket and the local one doesn't have to match >>> # e.g. forward a remote Unix domain socket to a local TCP port ->>> with TcpPortforwardAdapter(client=client.unix_socket) as addr: +>>> with TcpPortforwardAdapter(client=network.unix_socket) as addr: ... print(addr[0], addr[1]) 127.0.0.1 ... + ``` Connect to a remote TCP port with a web-based VNC client ```{doctest} ->>> with NovncAdapter(client=client.tcp_port) as url: +>>> from jumpstarter_driver_network.adapters import NovncAdapter +>>> with NovncAdapter(client=network.tcp_port) as url: ... print(url) # open the url in browser to access the VNC client https://novnc.com/noVNC/vnc.html?autoconnect=1&reconnect=1&host=127.0.0.1&port=... + ``` Interact with a remote TCP port as if it's a serial console @@ -76,10 +82,12 @@ Interact with a remote TCP port as if it's a serial console See [pexpect](https://pexpect.readthedocs.io/en/stable/api/fdpexpect.html) for API documentation ```{doctest} +>>> from jumpstarter_driver_network.adapters import PexpectAdapter >>> # the server echos all inputs ->>> with PexpectAdapter(client=client.tcp_port) as expect: +>>> with PexpectAdapter(client=network.tcp_port) as expect: ... assert expect.send("hello") == 5 # written 5 bytes ... assert expect.expect(["hi", "hello"]) == 1 # found string at index 1 + ``` Connect to a remote TCP port with the fabric SSH client @@ -91,18 +99,3 @@ See [fabric](https://docs.fabfile.org/en/latest/api/connection.html#fabric.conne with FabricAdapter(client=client.tcp_port, connect_kwargs={"password": "secret"}) as conn: conn.run("uname") ``` - -```{testsetup} * -from jumpstarter_driver_network.adapters import * -from jumpstarter_driver_network.driver import * -from jumpstarter_driver_composite.driver import Composite -from jumpstarter.common.utils import serve - -instance = serve(Composite(children={"tcp_port": EchoNetwork(), "unix_socket": EchoNetwork()})) - -client = instance.__enter__() -``` - -```{testcleanup} * -instance.__exit__(None, None, None) -``` diff --git a/docs/source/api-reference/basedriver.md b/docs/source/api-reference/basedriver.md index fb047e8c8..234d66720 100644 --- a/docs/source/api-reference/basedriver.md +++ b/docs/source/api-reference/basedriver.md @@ -20,60 +20,58 @@ This project is still evolving, so these docs may be incomplete or out-of-date. ## Example ```{testcode} -from sys import modules -from types import SimpleNamespace -from anyio import connect_tcp, sleep -from contextlib import asynccontextmanager -from collections.abc import Generator -from jumpstarter.driver import Driver, export, exportstream -from jumpstarter.client import DriverClient -from jumpstarter.common.utils import serve - -class ExampleDriver(Driver): - @classmethod - def client(cls) -> str: - return f"example.ExampleClient" - - @export - def echo(self, message) -> str: - return message - - # driver calls can be either sync or async - @export - async def echo_async(self, message) -> str: - await sleep(5) - return message - - @export - def echo_generator(self, message) -> Generator[str, None, None]: - for _ in range(10): - yield message - - # stream constructor has to be an AsyncContextManager - # that yield an anyio.abc.ObjectStream - @exportstream - @asynccontextmanager - async def connect_tcp(self): - async with await connect_tcp(remote_host="example.com", remote_port=80) as stream: - yield stream - -class ExampleClient(DriverClient): - # client methods are sync - def echo(self, message) -> str: - return self.call("echo", message) - # async driver methods can be invoked the same way - # return self.call("echo_async", message) - - def echo_generator(self, message) -> Generator[str, None, None]: - yield from self.streamingcall("echo_generator", message) - -modules["example"] = SimpleNamespace(ExampleClient=ExampleClient) - -with serve(ExampleDriver()) as client: - print(client.echo("hello")) - assert list(client.echo_generator("hello")) == ["hello"] * 10 -``` - -```{testoutput} +>>> from sys import modules +>>> from types import SimpleNamespace +>>> from anyio import connect_tcp, sleep +>>> from contextlib import asynccontextmanager +>>> from collections.abc import Generator +>>> from jumpstarter.driver import Driver, export, exportstream +>>> from jumpstarter.client import DriverClient +>>> from jumpstarter.common.utils import serve +>>> +>>> class ExampleDriver(Driver): +... @classmethod +... def client(cls) -> str: +... return f"example.ExampleClient" +... +... @export +... def echo(self, message) -> str: +... return message +... +... # driver calls can be either sync or async +... @export +... async def echo_async(self, message) -> str: +... await sleep(5) +... return message +... +... @export +... def echo_generator(self, message) -> Generator[str, None, None]: +... for _ in range(10): +... yield message +... +... # stream constructor has to be an AsyncContextManager +... # that yield an anyio.abc.ObjectStream +... @exportstream +... @asynccontextmanager +... async def connect_tcp(self): +... async with await connect_tcp(remote_host="example.com", remote_port=80) as stream: +... yield stream +>>> +>>> class ExampleClient(DriverClient): +... # client methods are sync +... def echo(self, message) -> str: +... return self.call("echo", message) +... # async driver methods can be invoked the same way +... # return self.call("echo_async", message) +... +... def echo_generator(self, message) -> Generator[str, None, None]: +... yield from self.streamingcall("echo_generator", message) +>>> +>>> modules["example"] = SimpleNamespace(ExampleClient=ExampleClient) +>>> +>>> with serve(ExampleDriver()) as client: +... print(client.echo("hello")) +... assert list(client.echo_generator("hello")) == ["hello"] * 10 hello + ``` diff --git a/docs/source/api-reference/drivers/conftest.py b/docs/source/api-reference/drivers/conftest.py new file mode 100644 index 000000000..43373b431 --- /dev/null +++ b/docs/source/api-reference/drivers/conftest.py @@ -0,0 +1,15 @@ +import pytest +from jumpstarter_driver_pyserial.driver import PySerial + +from jumpstarter.common.utils import serve + + +@pytest.fixture +def pyserial(): + with serve(PySerial(url="loop://")) as client: + yield client + + +@pytest.fixture(autouse=True) +def drivers_namespace(doctest_namespace, pyserial): + doctest_namespace["pyserial"] = pyserial diff --git a/docs/source/api-reference/drivers/pyserial.md b/docs/source/api-reference/drivers/pyserial.md index 56ce5961f..b39d49b04 100644 --- a/docs/source/api-reference/drivers/pyserial.md +++ b/docs/source/api-reference/drivers/pyserial.md @@ -31,42 +31,37 @@ export: ## Examples Using expect with a context manager ```{testcode} -with pyserialclient.pexpect() as session: - session.sendline("Hello, world!") - session.expect("Hello, world!") +>>> with pyserial.pexpect() as session: +... session.sendline("Hello, world!") +... session.expect("Hello, world!") +14 +0 + ``` Using expect without a context manager ```{testcode} -session = pyserialclient.open() -session.sendline("Hello, world!") -session.expect("Hello, world!") -pyserialclient.close() +>>> session = pyserial.open() +>>> session.sendline("Hello, world!") +14 +>>> session.expect("Hello, world!") +0 +>>> pyserial.close() + ``` Using a simple BlockingStream with a context manager ```{testcode} -with pyserialclient.stream() as stream: - stream.send(b"Hello, world!") - data = stream.receive() +>>> with pyserial.stream() as stream: +... stream.send(b"Hello, world!") +... data = stream.receive() + ``` Using a simple BlockingStream without a context manager ```{testcode} -stream = pyserialclient.open_stream() -stream.send(b"Hello, world!") -data = stream.receive() -``` - -```{testsetup} * -from jumpstarter_driver_pyserial.driver import PySerial -from jumpstarter.common.utils import serve - -instance = serve(PySerial(url="loop://")) - -pyserialclient = instance.__enter__() -``` +>>> stream = pyserial.open_stream() +>>> stream.send(b"Hello, world!") +>>> data = stream.receive() -```{testcleanup} * -instance.__exit__(None, None, None) ``` diff --git a/docs/source/api-reference/drivers/sdwire.md b/docs/source/api-reference/drivers/sdwire.md index 85b1f5ae7..264a0041a 100644 --- a/docs/source/api-reference/drivers/sdwire.md +++ b/docs/source/api-reference/drivers/sdwire.md @@ -17,6 +17,7 @@ exporter host. Traceback (most recent call last): ... FileNotFoundError: failed to find sd-wire device + ``` ## Client API diff --git a/docs/source/api-reference/drivers/tftp.md b/docs/source/api-reference/drivers/tftp.md index 68cc8c261..36966ee6d 100644 --- a/docs/source/api-reference/drivers/tftp.md +++ b/docs/source/api-reference/drivers/tftp.md @@ -68,22 +68,5 @@ export: ... assert "test.txt" in files ... ... tftp.stop() -``` - -```{testsetup} * -import tempfile -import os -from jumpstarter_driver_tftp.driver import Tftp -from jumpstarter.common.utils import serve - -# Create a persistent temp dir that won't be removed by the example -TEST_DIR = tempfile.mkdtemp(prefix='tftp-test-') -instance = serve(Tftp(root_dir=TEST_DIR, host="127.0.0.1")) -client = instance.__enter__() -``` -```{testcleanup} * -instance.__exit__(None, None, None) -import shutil -shutil.rmtree(TEST_DIR, ignore_errors=True) ``` diff --git a/docs/source/api-reference/drivers/ustreamer.md b/docs/source/api-reference/drivers/ustreamer.md index 073730cbb..338ae165f 100644 --- a/docs/source/api-reference/drivers/ustreamer.md +++ b/docs/source/api-reference/drivers/ustreamer.md @@ -17,6 +17,7 @@ exposes both snapshot and streaming interfaces. Traceback (most recent call last): ... io.UnsupportedOperation: fileno + ``` ## Client API