* build: relock onto test-modules and logoscore-cli master test-modules ff5dbbd brings the ext qtproxy fixture this branch consumes, and the conformance registries with the arity entries retired. logoscore-cli 1ec3683 carries ed19258 (#99) and b3f1a403 (#101), which is what retires the pre-99 cells: the daemon reads the error channel instead of inferring failure from a null RESULT, and asks the module for getPluginMethods on a null return so an unknown name becomes METHOD_NOT_FOUND. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * conformance(ext): run the ext table through the Qt consumer, twice The ext gate ran one consumer point and computed no consumer differential. It now runs the same three-consumer shape the full_api gate has — py, extqtproxy-sync, extqtproxy-async — against test_fullapi_ext_qtproxy, the Qt-typed consumer of full_api_ext (a SEPARATE module from test_fullapi_qtproxy, because a Qt consumer wrapper is generated per contract). THE PROBE IS THE ONLY DRIVER CHANGE, and it is a real one. A consumer point with a call mode proves the selected wrapper table actually ran by making one known-good call and reading `lastCallStatus()` back — `useCallMode("async")` returning true only says the flag is set. That probe was hard-coded to `echoInt(1)`, which is a full_api method. full_api_ext has none like it: no method takes a bare scalar, and probing its one zero-parameter method with a spare `1` would work only because of a REGISTERED defect (known-ext.json B-arity-overflow drops extra arguments) and would break the day that is fixed. So `--proxy-consumer` grew a fifth field: `METHOD[:JSON_ARGS]`, defaulting to `echoInt:[1]` so no existing invocation moves. Arguments go through the table's own `materialize`, so bytes are spelled `{"_bytes": ...}` in the probe exactly as they are in a case. A malformed spec is REFUSED rather than defaulted — falling back to `echoInt` would silently stop proving the call mode and the run would stay green. tests/unit/test_matrix_proxy_consumer.py pins that, the default, and the four-field spelling. Measured on framework.lan with logos-test-modules at its matching branch: 264 cells, 217 pass / 47 xfail / 0 fail / 0 xpass / 0 uncovered. * conformance: an error expectation may name a set of acceptable codes `matches` tested one code as a substring of the reported error. A cell whose answer is decided by which of two deadlines fires first cannot be spelled that way: asserting either token asserts the timing rather than the claim. A LIST means any-of. Deliberately not a general escape hatch -- widening a single-code expectation weakens it, so a case that does this owes an explanation in `why` of what the codes have in common. The one case that uses it (failure/A/module-not-loaded) gives it: both are reported on the error channel and name a transport reason, and a VALUE or a provider-side code still fails. The single-string form is untouched, and the new test pins both -- including that a successful call is still a failure for an error expectation, which is the property the widening must not cost. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * conformance: retry the SUBSCRIBE half of the event race, not just the fire half `capture_event` already knew the watcher and the fire race each other, and re-fired on a cadence to close it. Subscribing races too, and that half was attempted exactly once: `on_event` asked too early is REFUSED, the daemon answers WATCH_FAILED, and nothing tried again -- so an event cell reported a transport error instead of a value. MEASURED, across three otherwise identical runs of the same table at the same revision: event/tstr on test_fullapi_rust failed, passed, then failed. In both failing runs BOTH proxy consumers received "hello" from the same provider, which is what rules out the module: it was emitting, and the `py` subscription was losing the race. It is not a regression from the SDK bump either -- the bump is what made module startup slow enough to lose more often. Retried against the same deadline as the fire loop rather than a fixed count, so a slow start costs time instead of a false negative. A subscription that never succeeds still raises and still fails the cell, so this closes a race without hiding a refusal. Not registered as an xfail: the cell PASSES most of the time, and a registered cell that passes is an xpass, which reddens the gate. A flake has to be fixed or left visible; it cannot be parked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * conformance: an any-of expectation declares the DIFFERENTIAL too, not just the cell Widening failure/A/module-not-loaded to accept either transport code fixed the CELL and left three differential rows failing: qtproxy-sync answered RPC_FAILED while qtproxy-async answered object_unavailable, and the differential compares actual values independently of `expect`. I had assumed the delta rows follow the cell's status; they do not, and the run said so. That independence is deliberate and worth keeping -- it is what caught the `void` divergence nobody predicted -- so this does not become "both sides matched the expectation, therefore they agree". The narrow rule instead: an `expect` naming a LIST of codes has already said more than one answer satisfies the claim, which is the same kind of statement `expect_by_provider` makes for the provider axis. It gets the same treatment -- status `declared`, printed so the divergence stays visible, not counted as a failure. It applies only when the expectation names several codes AND BOTH sides satisfy it; a coordinate answering something outside the set, or a value instead of an error, still fails. One implementation, at module scope, called by the differential and exercised directly by the test -- the first draft had the rule written twice, once in a closure and once for the test to reach, which is a pair that drifts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test: move the driver tests to the file that loads the driver CI red on both arches, 2 failed / 140 passed: AttributeError: module 'matrix_report' has no attribute 'matches' AttributeError: module 'matrix_report' has no attribute 'Result' I put two tests for run_matrix.py's `matches` and `declared_multi_divergence` into tests/unit/test_matrix_report.py, whose `R` is the matrix_report module. The driver is a different file and is loaded by importlib, in tests/unit/test_matrix_proxy_consumer.py. The tests move there, unchanged in substance. Why this reached CI at all: I could not run pytest locally and shipped the tests unexecuted. That was wrong twice over — `python3 -m pytest` failed only because the homebrew interpreter has no pytest, and pyenv had one the whole time. "The tool is missing" was a conclusion I drew from one command instead of looking. Now run: 138 passed, 4 skipped locally (the 4 skips are environmental — the shared conformance table is not resolvable outside the nix build; CI resolves it and runs them). Total matches CI's 142. Both conformance matrices were already green in CI on this branch; this touches nothing they exercise. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
logos-logoscore-py
Python wrapper for the logoscore
CLI. Launch a daemon, load modules, call methods, and subscribe to events
from Python — without shelling out and parsing output by hand.
The wrapper is a thin layer over the logoscore CLI: every operation
spawns a logoscore <subcommand> --json subprocess and parses its output.
No C++ bindings, no IPC code.
Two clients
The CLI repo now ships two binaries, so this package ships two clients — one each, side by side:
| Import | Drives | |
|---|---|---|
logoscore |
the logoscore binary |
The client that exists today. Unchanged. Use this one. |
logosctl |
the logosctl binary |
Same shape, ported to the new CLI's surface. Being validated; not yet the default. |
from logoscore import LogoscoreDaemon, LogoscoreClient # today
from logosctl import LogosctlDaemon, LogosctlClient # under validation
The two share no code and no state, and neither can reach the other's
binary. Everything below documents logoscore; logosctl mirrors it
method for method, with one structural difference — the new CLI has no
--client-* flags and no LOGOSCORE_CLIENT_* env vars, so a client is
retargeted by writing <config_dir>/client/config.yaml rather than by
passing a transport per call (see help(LogosctlDaemon.remote_client)).
src/logosctl/ and tests/logosctl/ are the whole of the new client, so
whichever way the validation goes, the losing half is a delete.
Install
pip install logoscore
One distribution, both clients — import logoscore and import logosctl
both work after installing. The matching CLI must be on PATH. See
logos-logoscore-cli
for install instructions, or use the included Nix flake, which puts both
binaries in the dev shell.
Quickstart — local daemon
Spawns logoscore -D as a subprocess with an isolated config dir.
from logoscore import LogoscoreDaemon
with LogoscoreDaemon(modules_dir="./modules") as daemon:
client = daemon.client()
client.load_module("chat")
# List + introspect
modules = client.list_modules(loaded=True)
info = client.module_info("chat")
print([m["name"] for m in info["methods"]])
# Call a method
result = client.call("chat", "send_message", "hello world")
# Subscribe to events (callback runs on a background thread)
def on_msg(event: dict) -> None:
print(f"{event['event']}: {event['data']}")
sub = client.on_event("chat", "chat-message", on_msg)
try:
...
finally:
sub.cancel()
# Daemon stopped + temp config dir cleaned up on __exit__.
Quickstart — daemon in docker
Use LogoscoreDockerDaemon to run the daemon inside a container and drive
it over TCP. Good for testing your module against a real distributed
build of logoscore without polluting your dev environment, and for
anything that needs the daemon reachable from multiple processes.
from logoscore import LogoscoreDockerDaemon
with LogoscoreDockerDaemon(
image="logoscore:smoke-portable",
modules_dir="./my-module/result/modules", # host dir with your Qt plugins
) as daemon:
client = daemon.client(binary="logoscore") # host-side CLI
client.load_module("my_module")
print(client.call("my_module", "do_something", 42))
What the helper handles for you:
- Picks a free host TCP port per module; the container binds
6000(core_service) and6001(capability_module) internally, reached via-p $host_core:6000 -p $host_cap:6001. Same pattern as status-go tests-functional. - Bind-mounts three host dirs into the container:
/config(daemon writesstate.json),/persistence(--persistence-path; pre-seed for session restore, inspect after),/user-modules(your compiled Qt plugins, read-only). - Waits for
state.jsonto appear before returning. - Returns a
LogoscoreClientwhoseclient/config.jsoncarries both modules' distinct forwarded host ports (written viaLogoscoreClient.write_config), so it dials the external endpoints — not what the daemon wrote into its own connection file. Each module needs its own port, so this is config-file-driven rather than relying on the single-endpointLOGOSCORE_CLIENT_TCP_PORToverride.
Building the image: the logoscore CLI repo produces a reusable base
image via
tests/docker_smoke/build_smoke_image.sh.
The image contains only the CLI and its built-in modules — user
modules are always bind-mounted at runtime.
Knobs: host_port=, persistence_dir= (pre-seeded + not cleaned up on
exit), codec="cbor", extra_module_dirs=[...], extra_args=[...],
container_name=, network= (attach to a caller-managed docker
network). See help(LogoscoreDockerDaemon) for the full list.
Multiple daemons in a shared docker network
Pass network=<name> to attach each container to an EXISTING docker
network. The daemon never creates or removes networks — caller manages
the lifecycle. Use this when daemon containers need to discover each
other by container name via docker's embedded DNS:
import subprocess
from logoscore import LogoscoreDockerDaemon
subprocess.run(["docker", "network", "create", "my-net"], check=True)
try:
a = LogoscoreDockerDaemon(image="logoscore:smoke-portable",
modules_dir="./my-module/result/modules",
container_name="alice", network="my-net")
b = LogoscoreDockerDaemon(image="logoscore:smoke-portable",
modules_dir="./my-module/result/modules",
container_name="bob", network="my-net")
with a, b:
# alice resolves "bob" via docker DNS, and vice versa
...
finally:
subprocess.run(["docker", "network", "rm", "my-net"])
Connect to an already-running daemon
If a logoscore daemon is already running on the host (started with
logoscore -D from a shell, by a service manager, by another tool,
etc.), drop in a LogoscoreClient directly — no LogoscoreDaemon
needed.
from logoscore import LogoscoreClient
# Daemon at the default ~/.logoscore — no args.
client = LogoscoreClient()
print(client.status())
client.load_module("chat")
# Call a Q_INVOKABLE method on a loaded module.
result = client.call("chat", "send_message", "hello world")
print(result)
# Daemon launched with --config-dir /custom/path.
client = LogoscoreClient(config_dir="/custom/path")
Every method spawns a logoscore <subcommand> --json subprocess and
parses its output. The wrapper only sets LOGOSCORE_CONFIG_DIR on
that subprocess; the CLI reads <config_dir>/client/config.json for
the daemon endpoint and <config_dir>/client/auto.json for the
local-client token (both auto-emitted by the daemon at boot), so you
don't have to pass a token explicitly for a same-host, same-user daemon.
Cross-host or different-user setups need the Tokens flow. For
a daemon on another host — or any daemon whose two well-known modules
(core_service and capability_module) bound different ports — the
single transport= / tcp_host= / tcp_port= overrides aren't enough
(they describe one endpoint). Use LogoscoreClient.connect(...) instead:
see Connect to a daemon on a remote host.
Connect to a daemon on a remote host
LogoscoreClient.connect(...) builds a client from explicit per-module
endpoints, so you can reach a daemon that isn't on localhost (or whose
core_service / capability_module bound separate ports — two
QTcpServers can't share an address:port). Each DaemonEndpoint is one
module's dial spec; pass the raw token the daemon issued for this client
(see Tokens).
from logoscore import LogoscoreClient, DaemonEndpoint
client = LogoscoreClient.connect(
{
"core_service": DaemonEndpoint(transport="tcp", host="daemon.example.com", port=6000),
"capability_module": DaemonEndpoint(transport="tcp", host="daemon.example.com", port=6001),
},
token="<raw-token-issued-for-this-client>",
)
print(client.status())
client.load_module("chat")
connect() materializes a client/config.json (plus an auto.json
holding the token) in a private temp dir that is cleaned up when the
client is garbage-collected. Pass config_dir=... to write it somewhere
you control and keep it around. Unlike the transport= / tcp_*=
constructor kwargs, connect() sets no LOGOSCORE_CLIENT_* env
overrides — the on-disk config is authoritative, which is exactly what
lets it express two modules on two ports.
For TLS, use transport="tcp_ssl" and set verify_peer= per endpoint
(only honoured for tcp_ssl):
client = LogoscoreClient.connect(
{
"core_service": DaemonEndpoint("tcp_ssl", "daemon.example.com", 6000, verify_peer=True),
"capability_module": DaemonEndpoint("tcp_ssl", "daemon.example.com", 6001, verify_peer=True),
},
token="...",
)
Need the config file without a client? LogoscoreClient.write_config( config_dir, endpoints, token=...) writes the same client/config.json
into a dir you own — the lower-level primitive connect() (and the
daemon helpers) are built on.
Transports
By default the daemon listens on a local Unix socket and the client
connects to it. To open remote-reachable transports, pass
transports=[...] to LogoscoreDaemon and point the client at the
matching endpoint:
with LogoscoreDaemon(
modules_dir="./modules",
transports=["tcp"], # or ["tcp_ssl"], or ["local", "tcp"]
tcp_host="0.0.0.0",
tcp_port=6000,
tcp_codec="json", # or "cbor"
) as daemon:
client = daemon.client() # reads the per-module tcp dial spec the
# daemon wrote into client/config.json
daemon.client() needs no transport args: on startup the daemon writes a
client/config.json with the actual per-module endpoints (core_service
and capability_module each on their own bound port), and the client
dials from that. TLS (tcp_ssl) additionally accepts ssl_cert /
ssl_key / ssl_ca.
client(transport=, tcp_host=, codec=, no_verify_peer=) accepts uniform
overrides (host/transport/codec/verify are shared by both modules); they're
merged into the per-module client/config.json on disk, each module's
own port left intact — never via LOGOSCORE_CLIENT_* env vars. There is
deliberately no per-call port override: the CLI applies a single port
to every module uniformly, which would clobber capability_module onto
core_service's port. So to reach a daemon whose modules sit on different
ports at a host you specify (the general remote case, including a remote
host), use LogoscoreClient.connect(...),
which writes a full per-module config. That's how LogoscoreDockerDaemon
bridges the container boundary: it forwards each module to its own host
port and hands back a client wired to a per-module client/config.json.
Tokens
The daemon issues a signed token for each authorised client; logoscore
authenticates the client's connection with that token. When you spawn a
daemon via LogoscoreDaemon, it issues and stores one for you; the
client() factory wires it through.
For daemons you didn't spawn (e.g. a long-running one, or one in a container you want to share across several clients), manage tokens directly:
from logoscore import issue_token, revoke_token, list_tokens
token = issue_token(config_dir="/path/to/daemon-cfg", name="alice")
print(list_tokens(config_dir="/path/to/daemon-cfg"))
revoke_token(config_dir="/path/to/daemon-cfg", name="alice")
API overview
LogoscoreDaemon
Context manager that spawns logoscore -D with an isolated --config-dir
(temp dir by default). Multiple daemons can run concurrently without
colliding on ~/.logoscore/daemon/state.json.
LogoscoreDaemon(
modules_dir, # str | Path | list — one or more -m dirs
binary="logoscore",
config_dir=None, # override to share state across instances
persistence_path=None, # --persistence-path
extra_args=None, # extra flags to pass to the daemon
env=None, # extra env vars for the daemon process
startup_timeout=15.0, # seconds to wait for state.json + status
# Transports (see section above)
transports=None, # ["tcp"] | ["tcp_ssl"] | ["local", "tcp"] | ...
tcp_host="127.0.0.1",
tcp_port=0, # 0 = let daemon pick
tcp_codec="json", # "json" | "cbor"
tcp_ssl_host="127.0.0.1",
tcp_ssl_port=0,
tcp_ssl_codec="json",
ssl_cert=None, ssl_key=None, ssl_ca=None,
)
LogoscoreDockerDaemon
Same shape, but the daemon runs inside a container. Construction just
stores config; .start() / __enter__ actually runs docker run.
LogoscoreDockerDaemon(
image, # e.g. "logoscore:smoke-portable"
modules_dir, # host dir → /user-modules inside container
config_dir=None, # defaults to tmpdir (cleaned up on stop)
persistence_dir=None, # defaults to tmpdir (cleaned up on stop)
host_port=None, # None → pick_free_port()
codec="json", # "json" | "cbor"
container_name=None,
network=None, # attach to existing caller-managed docker network
extra_module_dirs=None, # extra -m paths *inside* the container
extra_args=None, # extra daemon args
startup_timeout=20.0,
)
Pass a caller-owned persistence_dir (or config_dir) to keep it
around after the container exits — useful for session-restore tests
(pre-seed → run → assert against what the modules wrote).
Also exported: docker_available(), image_present(image),
pick_free_port(), CONTAINER_TCP_PORT (= 6000).
LogoscoreClient
Obtained via daemon.client(...), LogoscoreClient.connect(endpoints, token=...) (a remote daemon — see
Connect to a daemon on a remote host),
or constructed directly for a same-host daemon. Every method returns
parsed JSON (dict or list) on success and raises on failure:
| Method | CLI equivalent |
|---|---|
status() |
logoscore status |
list_modules(loaded=False) |
logoscore list-modules [--loaded] |
module_info(name) |
logoscore module-info <name> |
load_module(name) |
logoscore load-module <name> |
unload_module(name) |
logoscore unload-module <name> |
reload_module(name) |
logoscore reload-module <name> |
call(module, method, *args) |
logoscore call <module> <method> … |
stats() |
logoscore stats |
stop() |
logoscore stop |
on_event(module, event, callback) |
logoscore watch <module> --event <event> |
call(...) returns the method's unwrapped result value. Path
arguments are passed through as @file so the CLI loads their contents.
Transport-related kwargs (transport=, tcp_host=, tcp_port=,
codec=, no_verify_peer=) set LOGOSCORE_CLIENT_* env vars on the
subprocess invocation — the CLI resolves them through its
effectiveClientTransport path, overriding whatever the daemon wrote
into state.json.
Events
sub = client.on_event("chat", "chat-message", callback, error_callback=None)
sub.alive # False once the watcher exits
sub.cancel() # SIGINT → SIGTERM → SIGKILL
The callback runs on a daemon thread; exceptions are routed to
error_callback (default: logged via logging).
Exceptions
LogoscoreError is the base class. Subclasses map to the CLI's exit
codes:
| Exit code | Exception |
|---|---|
| 2 | DaemonNotRunningError |
| 3 | ModuleError |
| 4 | MethodError |
Development
The repo ships a Nix flake that pulls both binaries out of
logos-logoscore-cli — its default output for logoscore, its ctl
output for logosctl — so tests run out of the box:
nix develop # python + pytest + logoscore + logosctl on PATH
pytest # runs unit + integration, both clients (docker smoke skipped)
nix flake check # same, under nix
Test layout — one tree per client, duplicated on purpose:
tests/
├── unit/ # no logoscore required; runs anywhere
├── integration/ # spawns local logoscore daemons; nix check covers this
├── docker_smoke/ # docker-required; see tests/docker_smoke/README.md
└── logosctl/ # the same two suites against logosctl
├── unit/
└── integration/
tests/logosctl/ is a deliberate duplicate rather than a parametrisation:
the two CLIs configure a daemon through different mechanisms (flags versus
an installed config document), so a shared suite would be mostly branches.
The nix checks are duplicated the same way — unit-logosctl and
integration-logosctl-{local,tcp,tcp_ssl} alongside the originals, in
their own CI job, so a red logosctl run cannot mask a logoscore
regression. The conformance matrix is not duplicated: it measures the
LIDL type contract in the shared runtime, which a second CLI would only
re-measure.
The logosctl suites skip unless LOGOSCTL_BIN and
LOGOSCTL_TEST_MODULES_DIR are set (the dev shell and the nix checks set
both), mirroring the LOGOSCORE_* pair.
Docker smoke tests live in their own directory because they need the
host's docker socket (not available inside nix build). Run them
explicitly:
./tests/docker_smoke/build_smoke_image.sh # FLAVOR=portable (default)
pytest tests/docker_smoke # --docker-flavor={portable|dev|both}
See tests/docker_smoke/README.md
for the full docker-side story (image flavors, mount layout, port
strategy).
Inside the logos-workspace:
ws test logos-logoscore-py --auto-local
Licence
Dual-licensed under MIT or Apache-2.0.