Files
680ecfbf47 A second Python client, for logosctl, with its own parallel suite (#18)
* 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>
2026-08-04 17:04:15 -03:00

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) and 6001 (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 writes state.json), /persistence (--persistence-path; pre-seed for session restore, inspect after), /user-modules (your compiled Qt plugins, read-only).
  • Waits for state.json to appear before returning.
  • Returns a LogoscoreClient whose client/config.json carries both modules' distinct forwarded host ports (written via LogoscoreClient.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-endpoint LOGOSCORE_CLIENT_TCP_PORT override.

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.