Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 12 additions & 8 deletions llms/test-guides/python.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,28 +30,30 @@ MUST test dunder methods idiomatically, MUST NOT call them directly:
- Tests for class methods live in the corresponding test class.
- Tests for module-level functions are module-level test functions.
- Tests MUST be grouped by the behavior they exercise, not by implementation detail. For example, property-based tests for a method live alongside example-based tests for the same method in the same class — do NOT create separate classes by test technique.
- **Exception — behavior-pinning suites.** A cohesive suite that verifies one class across many edges, or pins stdlib/alias behavior with no first-party class under test (e.g., a `stdlib_parity` suite, or tests of a re-exported stdlib alias such as `wool.Token`), MAY group tests in `Test<Scenario>` classes named for the behavior rather than `Test<ClassName>`; a test of a re-exported alias MAY use the alias's `__name__`.
- Within a test class or module, order tests from foundational to derived: test `__init__` before methods that require a constructed instance, and test simple methods before methods that compose them. This ensures `pytest -x` stops at the most fundamental failure first.

## 5. Test Naming

Test methods mirror the qualified name of their subject:
Test names use a BDD-style pattern that names both the behavior and the scenario:

```
test_<method_name>_<brief_scenario>
test_<method_name>_should_<expected_outcome>[_when_<condition>]
```

The scenario portion is derived from the Given and When — it describes the condition and action, not the expected outcome:
The `should_<outcome>` clause describes the observable behavior being verified (the docstring's `Then`). The optional `when_<condition>` clause describes the scenario that triggers it (the docstring's `Given` + `When`). Omit `when_` only when the test covers a single canonical scenario and the condition would add no information:

```
test_dispatch_with_stopping_service
test_to_protobuf_with_unpicklable_callable
test___init___outside_task_context
test_dispatch_should_raise_when_service_stopping
test_to_protobuf_should_raise_when_callable_unpicklable
test___init___should_succeed_outside_task_context
```

Rules:
- `<method_name>` MUST match the method's `__name__` exactly, including dunder prefixes (e.g., `test___init___...`).
- `<brief_scenario>` SHOULD be 2-5 words in snake_case.
- Do NOT encode the expected outcome in the name — that belongs in the `Then` section of the docstring.
- `<expected_outcome>` MUST be a short verb phrase describing observable behavior — `return_none`, `raise`, `emit_event`. Use imperative present tense; MUST NOT use past tense or gerunds.
- `<condition>` SHOULD be 2-5 words in snake_case describing the preconditions.
- The test body MUST actually verify the behavior the name claims — drift between name and assertion is worse than either convention alone.

## 6. Docstrings (Given-When-Then)

Expand Down Expand Up @@ -265,6 +267,8 @@ pytest -m "not integration" -x

The marker MUST be registered in `pyproject.toml` under `[tool.pytest.ini_options]`.

A cohesive suite MAY register and use its own dedicated marker (e.g., `stdlib_parity`) as its selective-execution marker in place of `integration`, including for that suite's boundary-crossing tests — such tests need not also carry `@pytest.mark.integration`. A suite-specific marker MUST likewise be registered in `pyproject.toml`.

### Test Style

Integration tests follow the same conventions as unit tests: Given-When-Then docstrings on test functions and methods (not fixtures or helpers), AAA phase comments, and `@pytest.mark.asyncio` for async tests.
Expand Down
8 changes: 8 additions & 0 deletions wool/.coveragerc
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
[run]
source = src/wool
# Measure code that runs in spawned worker processes (WorkerProcess is a
# ``multiprocessing.get_context("spawn").Process``). Without this the
# integration suite's worker-side coverage (service.py/session.py) is
# invisible, understating what those tests actually exercise. ``sigterm``
# flushes coverage when a worker is stopped via SIGTERM at teardown.
concurrency = multiprocessing,thread
parallel = true
sigterm = true
omit =
src/wool/__init__.py
src/wool/cli.py
Expand Down
102 changes: 56 additions & 46 deletions wool/README.md

Large diffs are not rendered by default.

43 changes: 17 additions & 26 deletions wool/proto/wire.proto
Original file line number Diff line number Diff line change
Expand Up @@ -30,39 +30,30 @@ message TaskEnvelope {
string tag = 4;
}

// Wire shape for a single wool.ContextVar within a Context
// Wire shape for a single wool.ContextVar within a ChainManifest
// snapshot. Mirrors the in-memory wool.ContextVar identity
// ((namespace, name) pair) and carries the var's per-snapshot
// state: an optional cloudpickled value and the hex-encoded ids
// of any wool.Tokens this var has minted that were subsequently
// consumed by wool.ContextVar.reset in the enclosing Context's
// logical chain.
// and carries the var's per-snapshot state: an optional
// cloudpickled value. An absent value signals a reset-to-
// no-prior-value on the sender's chain.
message ContextVar {
// Namespace component of the ContextVar identity.
string namespace = 1;
// Name component of the ContextVar identity.
string name = 2;
// Cloudpickled value. Unset when the var has no current
// value in this snapshot (e.g. it was reset to no prior
// value but a consumed token still needs to propagate).
// Cloudpickled value. Unset when the var has no
// current value in this snapshot.
optional bytes value = 3;
// Hex-encoded ids of wool.Tokens minted by this var that
// have been consumed by wool.ContextVar.reset in this
// logical chain. Rides forward- and back-propagation so
// receivers (a) see consumed tokens as used and cannot
// double-reset and (b) pop the corresponding var from
// their local Context, completing the reset signal.
repeated string consumed_tokens = 4;
}

// wool.Context wire shape. Rides on every Request and Response
// frame to carry the caller's wool.ContextVar snapshot and the
// wool.Context id that scopes the logical execution chain.
message Context {
// wool.Context id as hex string. Stable across sequential
// wool.Chain wire shape — the manifest of a chain crossing the
// wire. Rides on every Request and Response frame to carry the
// sender's wool.ContextVar snapshot and the wool.Chain id that
// scopes the logical execution chain.
message ChainManifest {
// wool.Chain id as hex string. Stable across sequential
// awaits inside the same asyncio task; fresh on
// asyncio.create_task boundaries. Empty for root dispatches
// that will start a new context on the receiver.
// that will start a new chain on the receiver.
string id = 1;
// ContextVar entries — each one carries identity, optional
// value, and any consumed-token ids for that var.
Expand Down Expand Up @@ -104,10 +95,10 @@ message Request {
Message send = 3;
Message throw = 4;
}
// Caller's wool.Context snapshot. Propagates the caller's
// Caller's wool.Chain snapshot. Propagates the caller's
// state to the worker on task frames, and forward-propagates
// caller mutations to the worker on streaming frames.
Context context = 5;
ChainManifest context = 5;
}

message Response {
Expand All @@ -117,9 +108,9 @@ message Response {
Message result = 3;
Message exception = 4;
}
// Worker's post-yield/post-return wool.Context snapshot,
// Worker's post-yield/post-return wool.Chain snapshot,
// back-propagated to the caller.
Context context = 5;
ChainManifest context = 5;
}

message StopRequest {
Expand Down
2 changes: 2 additions & 0 deletions wool/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ dev = [
"pytest-grpc-aio~=0.3.0",
"pytest-mock",
"ruff",
"uvloop",
]

[project.scripts]
Expand Down Expand Up @@ -94,6 +95,7 @@ addopts = "--cov --cov-config=.coveragerc"
pythonpath = ["."]
markers = [
"integration: end-to-end integration tests against real WorkerPool",
"stdlib_parity: stdlib contextvars propagation parity pins",
]

[tool.ruff]
Expand Down
48 changes: 30 additions & 18 deletions wool/src/wool/__init__.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,24 @@
import contextvars
from contextvars import Token
from importlib.metadata import PackageNotFoundError
from importlib.metadata import version
from typing import TYPE_CHECKING
from typing import Final

from tblib import pickling_support

from wool.exception import WoolError
from wool.exception import WoolWarning
from wool.runtime.context import Context
from wool.runtime.context import ContextAlreadyBound
from wool.runtime.context import ContextDecodeWarning
from wool.runtime.context import ContextVar
from wool.runtime.context import ContextVarCollision
from wool.runtime.context import RuntimeContext
from wool.runtime.context import Token
from wool.runtime.context import copy_context
from wool.runtime.context import create_task
from wool.runtime.context import current_context
from wool.exceptions import WoolError
from wool.exceptions import WoolWarning
from wool.runtime.context.exceptions import ChainContention
from wool.runtime.context.exceptions import ChainSerializationError
from wool.runtime.context.exceptions import ContextVarCollision
from wool.runtime.context.exceptions import SerializationError
from wool.runtime.context.exceptions import SerializationWarning
from wool.runtime.context.exceptions import TaskFactoryDisplaced
from wool.runtime.context.factory import install_task_factory
from wool.runtime.context.runtime import RuntimeContext
from wool.runtime.context.threading import to_thread
from wool.runtime.context.var import ContextVar
from wool.runtime.discovery.base import Discovery
from wool.runtime.discovery.base import DiscoveryEvent
from wool.runtime.discovery.base import DiscoveryEventType
Expand All @@ -40,6 +42,7 @@
from wool.runtime.serializer import CloudpickleSerializer
from wool.runtime.serializer import Serializer
from wool.runtime.typing import Factory
from wool.runtime.typing import UndefinedType
from wool.runtime.worker.auth import WorkerCredentials
from wool.runtime.worker.base import BoundWorkerFactory
from wool.runtime.worker.base import Worker
Expand All @@ -59,6 +62,9 @@
from wool.runtime.worker.service import BackpressureLike
from wool.runtime.worker.service import WorkerService

if TYPE_CHECKING:
from wool.runtime.context.chain import Chain

pickling_support.install()

try:
Expand All @@ -68,6 +74,10 @@

__serializer__: Final[Serializer] = CloudpickleSerializer()

__chain__: Final[contextvars.ContextVar["Chain"]] = contextvars.ContextVar(
"__wool_chain__"
)

__proxy__: Final[contextvars.ContextVar[WorkerProxy | None]] = contextvars.ContextVar(
"__proxy__", default=None
)
Expand All @@ -89,9 +99,8 @@
"BackpressureContext",
"BackpressureLike",
"BoundWorkerFactory",
"Context",
"ContextAlreadyBound",
"ContextDecodeWarning",
"ChainContention",
"ChainSerializationError",
"ContextVar",
"ContextVarCollision",
"Discovery",
Expand All @@ -114,11 +123,15 @@
"RoundRobinLoadBalancer",
"RpcError",
"RuntimeContext",
"SerializationError",
"SerializationWarning",
"Serializer",
"Task",
"TaskException",
"TaskFactoryDisplaced",
"Token",
"TransientRpcError",
"UndefinedType",
"UnexpectedResponse",
"WoolError",
"WoolWarning",
Expand All @@ -131,11 +144,10 @@
"WorkerPool",
"WorkerProxy",
"WorkerService",
"copy_context",
"create_task",
"current_context",
"current_task",
"install_task_factory",
"routine",
"to_thread",
]

for symbol in __all__:
Expand Down
File renamed without changes.
62 changes: 24 additions & 38 deletions wool/src/wool/protocol/__init__.py
Original file line number Diff line number Diff line change
@@ -1,53 +1,39 @@
import os
import sys
from importlib.metadata import PackageNotFoundError
from importlib.metadata import version
from typing import Protocol

try:
__version__ = version("wool")
except PackageNotFoundError:
__version__ = "unknown"

sys.path.insert(0, os.path.abspath(os.path.dirname(__file__)))

try:
from wool.protocol.wire_pb2 import Ack
from wool.protocol.wire_pb2 import ChannelOptions
from wool.protocol.wire_pb2 import Context
from wool.protocol.wire_pb2 import ContextVar
from wool.protocol.wire_pb2 import Message
from wool.protocol.wire_pb2 import Nack
from wool.protocol.wire_pb2 import Request
from wool.protocol.wire_pb2 import Response
from wool.protocol.wire_pb2 import RuntimeContext
from wool.protocol.wire_pb2 import StopRequest
from wool.protocol.wire_pb2 import Task
from wool.protocol.wire_pb2 import TaskEnvelope
from wool.protocol.wire_pb2 import Void
from wool.protocol.wire_pb2 import WorkerMetadata
from wool.protocol.wire_pb2_grpc import WorkerServicer
from wool.protocol.wire_pb2_grpc import WorkerStub
from wool.protocol.wire_pb2_grpc import add_WorkerServicer_to_server
except ImportError as e:
from wool.protocol.exception import ProtobufImportError

raise ProtobufImportError(e) from e


class AddServicerToServerProtocol(Protocol):
@staticmethod
def __call__(servicer, server) -> None: ...


add_to_server: dict[type[WorkerServicer], AddServicerToServerProtocol] = {
WorkerServicer: add_WorkerServicer_to_server,
}
from wool.protocol._wire import Ack as Ack
from wool.protocol._wire import (
AddServicerToServerProtocol as AddServicerToServerProtocol,
)
from wool.protocol._wire import ChainManifest as ChainManifest
from wool.protocol._wire import ChannelOptions as ChannelOptions
from wool.protocol._wire import ContextVar as ContextVar
from wool.protocol._wire import Message as Message
from wool.protocol._wire import Nack as Nack
from wool.protocol._wire import Request as Request
from wool.protocol._wire import Response as Response
from wool.protocol._wire import RuntimeContext as RuntimeContext
from wool.protocol._wire import StopRequest as StopRequest
from wool.protocol._wire import Task as Task
from wool.protocol._wire import TaskEnvelope as TaskEnvelope
from wool.protocol._wire import Void as Void
from wool.protocol._wire import WorkerMetadata as WorkerMetadata
from wool.protocol._wire import WorkerServicer as WorkerServicer
from wool.protocol._wire import WorkerStub as WorkerStub
from wool.protocol._wire import add_to_server as add_to_server
from wool.protocol._wire import (
add_WorkerServicer_to_server as add_WorkerServicer_to_server,
)

__all__ = [
"Ack",
"ChainManifest",
"ChannelOptions",
"Context",
"ContextVar",
"Message",
"Nack",
Expand Down
Loading
Loading