ucall.Server takes the interfaces to answer on, and backend and cpu_class keywords naming a ucall.Backend and a ucall.CPUClass, by member or by name such as “linux-epollaio”.
Its log= callable receives each line the server writes as a ucall.LogLevel and a str, on whichever thread wrote it and holding the interpreter lock, filtered by log_level= and shaped by log_format=, and metrics=ucall.Metrics(address, sample_rate, prefix, tags, interval_microseconds) sends StatsD datagrams from every event loop.
An Interface also takes max_request_bytes=, past which a request is answered 413, and reply_directories=, the directories a ucall.Path part names by position. descriptor= takes a listening socket, or its descriptor, that systemd or a supervisor opened, TCP or AF_UNIX, in place of an address; the server accepts on duplicates and never closes it.
Without interfaces it answers on ucall.DEFAULT_ADDRESS, “127.0.0.1:8545”.
An Interface is an address, optionally pinned to a Linux device, with its own security and admission limits, so one server answers plaintext on loopback and Noise on the wire at once.
It admits 1024 connections by default, reads one request per connection at a time unless pipelined_requests allows more, and replies still leave in request order.
At its connection budget a new peer evicts the idle keep-alive connection that progressed least recently.
Port zero binds a free port, which server.addresses reports.
Endpoints register before any loop is polled, with route taking any Verb and get, post, put, delete and patch naming one.
Each decorator registers the function through add_endpoint and returns it unchanged, so it stays callable from Python.
Templates capture {name} segments and a greedy {*name} tail, literal segments match before captures, and registering one template and verb twice raises ucall.Error with Status.ALREADY_EXISTS.
Routing is exact: an empty segment is a segment, so /users and /users/ are different paths.
A path no endpoint matches answers 404, one whose endpoints take other verbs answers 405 with an allow header, a GET endpoint also serves HEAD, and OPTIONS answers 204 with the same allow list.
A request that cannot be parsed answers 400, 413 or 501 before reaching any handler.
Registration reads a handler’s signature once.
A handler taking one parameter annotated ucall.Request receives the request itself.
Any other handler takes parameters annotated str, int, float, bool or bytes, or left bare, and each binds by name from the first place that has it:
a placeholder of the template, as sent;
the query, percent-decoded with ”+” as a space, a ”%” without two hexadecimal digits answering 400;
a JSON object body, parsed once in C and only when an earlier place missed a parameter, with integers of any width kept exact;
the parameter’s default.
Text converts as int(text) and float(text) do, bool reads “1”, “true”, “yes” and “on” or “0”, “false”, “no” and “off” in any case, and bytes takes the raw bytes.
A JSON value must already have the annotated type, except that an integer widens to float and a string encodes to bytes.
A missing or malformed value answers 400 with the reason as plain text, such as “missing parameter ‘limit’”, without calling the handler.
Registration raises TypeError for *args, **kwargs, more than 16 parameters, or an annotation outside that list.
It refuses an async def handler with TypeError too, since handlers run on the polling thread; a coroutine application registers a plain function, calls request.detach(), and replies from its own event loop.
What a handler returns becomes the reply: bytes, bytearray and memoryview as application/octet-stream, str as UTF-8 text/plain, and anything else as application/json, written in C, or through json.dumps for a value C does not write, such as a dict keyed by numbers.
None from a typed handler answers 204, and None from a handler taking the request means it replies itself.
A handler’s exception reaches sys.unraisablehook and answers 500, and the loop keeps serving.
server.event_loop_statistics(loop) copies one loop’s counters as a dict, from any thread while the loop runs: polls, system calls, submissions and completions, accepted connections, requests, and the bytes received, sent and copied.
Divided by requests, they price one call.
API
Types
BackendAn I/O implementation for Server and Client; AUTOMATIC picks the best one available.
Requests matching it from every event loop share one queue, which take_batch and take_batches
hand to the handler as a list once enough arrived or the oldest waited long enough. The handler
runs on its polling owner's thread and replies to each request, in any order.
Examples
>>> @server.batch("/infer", min_size=8, max_size=64, max_latency_microseconds=500)... def infer(requests: list[ucall.Request]):... for request in requests:... request.reply(bytes(request.body))>>> infer in server.batch_endpointsTrue
take_batchRuns the handler on this thread if a batch is due, waiting for one at most timeout_microseconds.
Each counter is read on its own, so a snapshot of an endpoint being polled is not one
instant, and batches a stop released count nowhere.
Returns
dict[str, int]: batches handed to the handler and the requests in them, then the
filled_batches that reached max_size, the ready_batches that reached min_size,
and the expired_batches that waited out max_latency_microseconds.
It starts no threads: each event loop needs one owner calling take_calls, and each batch
endpoint one calling BatchEndpoint.take_batches, while run serves the simplest server on
the calling thread. Register every endpoint before the first poll.
Raises
Error: An interface that cannot be bound, such as an address in use, or a reply directory
that cannot be opened.
Examples
>>> server = ucall.Server(ucall.Interface("127.0.0.1:0"), backend="posix")>>> server.event_loops, server.backend(1, <Backend.POSIX: 1>)
A handler taking one parameter annotated Request receives the request, which it may answer
later from any thread, and what it returns other than None is the reply. Any other handler's
parameters, annotated str, int, float, bool or bytes or left bare, bind by name from
a path placeholder, then the query, then a JSON object body, then their defaults; a missing
or malformed value answers 400, and None returned answers 204. Returned bytes-like objects
reply as binary, str as UTF-8 text, and anything else as JSON. An exception answers 500.
Parameters
pathstr | bytes
A URI template such as /users/{name} or /files/{*path}.
def add_batch_endpoint(
path: str | bytes,
verb: Verb | str,
handler: Callable[[list[Request]], object],
/,
min_size: int = 0,
max_size: int = 0,
max_latency_microseconds: int = 0,
) -> BatchEndpoint
A batch is due once min_size requests arrived, max_size are queued, or the oldest waited
max_latency_microseconds. The handler runs on the thread polling the returned endpoint and
replies to each request.
The interpreter lock is released meanwhile and taken back only to run handlers, so one
thread per loop serves in parallel with the others. Signals reach Python only once it
returns, so call it from threads other than the main one.
Parameters
event_loopint
The loop to drive, below event_loops. Defaults to 0.
Raises
Error: An event_loop past the last one, or an I/O failure.
Examples
>>> pollers = [threading.Thread(target=server.take_calls, args=(loop,))... for loop in range(server.event_loops)]
runServes a server of one event loop and no batch endpoints on the calling thread.
It returns after stop() or KeyboardInterrupt, polling in slices of 100 ms and checking
for signals between them, and once stopped it lets the loop finish the requests in flight;
a second KeyboardInterrupt cuts them.
Raises
RuntimeError: The server has more than one event loop or any batch endpoint.
The first call drains: no new connections, idle ones close, and the requests
already held are answered before their connections close. A second call cuts the
replies still in flight.
Safe from any thread and from a handler. Join the polling threads before releasing
the server.
Examples
>>> server.stop()
event_loop_statisticsCounts what one event loop did since initialization.