* feat: a second Python client, for logosctl, with its own suite
This repo drove one CLI. It now drives two, side by side, the same way
the CLI repo ships two binaries: src/logoscore/ is untouched and remains
the one to use, and src/logosctl/ is the new client for the merged CLI.
The port is not a rename. logosctl deleted the flags this client was
built on -- -m, --persistence-path, --module-transport -- and, more
sharply, deleted the entire LOGOSCORE_CLIENT_* family that the old
wrapper used to point a client at a daemon. logosctl honours exactly two
variables, LOGOSCTL_CONFIG_DIR and LOGOSCTL_TOKEN. So everything those
flags expressed now has to be written as a YAML document and installed
with `daemon config set` / `client config set` BEFORE the daemon starts.
That two-phase contract is the substance of the port; the regrouped
subcommands (list-modules -> module ls) are the easy part.
Two asymmetries the port has to respect, both real and neither a typo:
the daemon says `protocol:` where the client says `transport:`, and the
daemon has cert/key/ca_file where the client has only `ca`.
tests/logosctl/ mirrors the existing suite and is a deliberate duplicate
-- the two clients drive genuinely different surfaces, and duplicating
means retiring logoscore is a delete rather than an unpick. The shared
_fullapi_module_cases.py table is IMPORTED, not copied: it describes the
module's contract, not the CLI's, and two copies would drift.
New checks (unit-logosctl, integration-logosctl-{local,tcp,tcp_ssl}) are
separate derivations, so nix builds them concurrently with the logoscore
ones and a failure in the under-validation client cannot mask a
logoscore regression.
The logosctl binary comes from a SECOND flake input pinned to the CLI
branch, rather than by re-pointing the shared one. Re-pinning would have
changed what the frozen logoscore checks build against -- a behavioural
change to the half that was supposed to be untouched, buried in a
215k-line lock diff. Verified: every shared pin is byte-identical to
master. The input is commented with the condition for removing it.
An adversarial review of the first draft found nine defects, all fixed
here. The two worth naming, because both are the same mistake made
twice:
- the "daemon exited during startup" path read only stderr, but
logosctl's LogSink dup2s stdout and stderr into one pipe, so the
reason a daemon died arrives on stdout. Every post-LogSink failure
raised a bare exit code with nothing attached -- the same dead end
the CLI's own --detach path had.
- the startup-timeout error pointed at a log file that teardown had
already deleted. The log tails now travel with the exception.
Also: the ordering test asserted the run list and the spawn list
separately, so it stayed green with the two phases swapped -- it
certified the one thing it did not check. It now records both into a
single ordered sequence.
Green: unit-logosctl, integration-logosctl-local, and
integration-logosctl-tcp_ssl, the last exercising a real certificate
with subjectAltName and verify_peer on, plus its mirror with the CA
withheld so the test can tell "verified" from "not checking".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix: drain the watcher's stderr, and don't trust token_file's type
Both from Copilot's review of #18, both real.
**A subscription could deadlock.** `Subscription.start` pipes stderr and
nothing ever reads it. A pipe nobody drains fills at ~64K and blocks the
child mid-write — and a watcher blocked writing stderr stops writing
stdout, so the event stream stalls forever with no error, no exit and no
timeout. The worst shape a bug can take: it looks like a quiet module.
stderr is now drained by its own thread into a bounded tail. Draining
rather than sending it to /dev/null keeps the diagnostics, and that
turned out to be worth having on its own: a watcher whose process exits
non-zero now reports the exit code AND what it wrote on the way out.
Before, the subscription just went silent, indistinguishable from one
with nothing to emit.
**`token_file` was assumed to be a string.** `setdefault` only fills the
key in when it is ABSENT, so a merged config could still carry a number,
a null or a list — a hand-edited or corrupt file — and `Path(42)` raises
TypeError, aborting the whole config write over a value we were always
going to reject. The function already falls back to auto.json for a path
or a traversal; a non-string is one more thing it cannot honor.
Tests first verified against the unfixed code, where they fail for their
own reasons rather than incidentally: the deadlock test times out waiting
for an event that never comes, the death test observes silence, and the
token_file cases raise TypeError. The deadlock test spawns a real process
that writes 400 KB to stderr — comfortably past any platform's pipe
buffer — and then emits one event; with an undrained pipe it hangs, and
the file takes 30s to fail instead of 1s to pass.
NOT fixed here: src/logoscore/events.py has the same undrained pipe. It
is the frozen client and this PR asserts it is untouched, so it gets its
own change rather than riding along in this one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* build: point logosctl at logos-logoscore-cli master
logos-logoscore-cli#76 has merged, so `ctl` lives on the same pin as
`cli`. Drop the temporary second input that tracked the feature branch,
repoint both logosctlBin bindings, and bump the lock to e48fc7f.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
18 KiB
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.