Changelog
Changelog
All notable changes to this project are documented here. The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[0.3.0] - 2026-07-29
Run pytruenas on the appliance, and patch things the API does not expose.
pytruenas.ops is now pytruenas.patch, restructured into subpackages, with
ops.host and ops.main removed (see "Removed"). Renaming it is why this is
0.3.0 rather than 0.2.3 — though in practice there was nothing to break: the
subpackage was experimental, unexercised, and most of its paths simply did not
work. MiddlewareFiles referenced an attribute that never existed, systemctl
calls ran four commands instead of one, and creating a file with a baseline
raised. Those are all fixed below; the rename is the part worth noticing.
Added
pytruenas deploy <target>— installs pytruenas onto a host that cannot install for itself. TrueNAS has a read-only root and nopip, so this probes what the target already has, bundles only the difference, and copies that over. In practice five pure-Python packages under a megabyte: the appliance already shipsrequests,websocket-client,pyyaml,asyncsshandjinja2, and which it ships varies by release, so it is asked rather than assumed.--mode pyz(default) writes one executable zipapp;--mode diran unpackedbin/+lib/tree, swapped into place via a staging directory so an interrupted transfer never leaves a half-written tree.- Everything after
--runs on the target once installed:pytruenas deploy nas1 -- call system.info. - Defaults to
/var/db/system, which is a dataset on a data pool and survives an update — unlike/var/dbitself,/rootor/data, which live in the boot environment and are replaced by one. Verified on a live appliance rather than assumed. - A digest is recorded beside the payload, so a redeploy verifies instead of
re-copying;
--forceoverrides. --pkg-root/--pkg-name(orPYTRUENAS_PKG_ROOT/PYTRUENAS_PKG_NAME) deploy your distribution with pytruenas bundled underneath, for when pytruenas is a dependency rather than the deliverable.pytruenas.utils.bundle— the machinery behind it, deliberately generic: it knows nothing about pytruenas or TrueNAS and could be lifted out. Resolves a transitive closure from installed distribution metadata (not an import scan —import yamldoes not namepyyaml), and refuses a distribution carrying a compiled extension or resolving to data files only, both of which would build cleanly and fail to import on the target.pytruenas.patch.zfs—writable(client, path)clearsreadonlyon the backing dataset and restores it however the block exits. Required for anything under/usr.dataset_forwalks up to an existing ancestor, becausefindmnt --targetfails on a path that does not exist yet.- Undo, for the patch system that promised it.
FileTarget.revert()restores the baseline and clears the snapshot;is_patched()reports whether a file currently differs from its baseline;would_change(content)is the dry run. A file the patch created is deliberately left alone — with no baseline there is nothing proving it was ours to delete. SystemFile(..., writable=True, mode=...)— patch a read-only mount without remembering the ZFS dance, and set the mode for a file the patch creates.
Fixed
- A rewrite no longer widens a file's permissions. Rewriting reset the mode
to whatever the umask gave, so patching
/etc/shadow(0640 root:shadowon a real box) would silently have made it0644. The existing mode is now captured before the write and restored after. systemctlwas invoked as four separate commands.run(*cmds)treats each positional as its own command, sorun("systemctl", "disable", "--now", name)ran four — with the unit name hand-quoted on top, which argv does not want. Every call now builds one argv list.is-active/is-enabledcould raise on the non-zero exit that is their answer, so asking "is this running?" about a stopped service threw.services="nfs"iterated into three per-character reloads.mkdir(755, ...)passed decimal 755 —0o1363, setuid plus wrong bits — on every directory it created.baseline=Trueon a file that does not exist yet raisedFileNotFoundErrorfrom insidewrite(): it tried to snapshot a missing original. That is the ordinary "create this config if absent" case.baseline()calledresolve(), which hostctl'sCompositePosixPathdoes not have, so any baseline against a real client path crashed.BaseTemplate.renderreturnedNonefor a subclass that forgot to override it, and thatNoneflowed intowrite()as the file's content. It now raises, andwrite()refusesNoneoutright.MiddlewareFilesused aclient.middlewared_paththat never existed. Replaced with a lazy lookup that asks the host's own interpreter — no API method reports it (checked against 26.0's 781 methods).find_templatedefaulted to taking a baseline beside a template on a read-only mount, which cannot work; it failed with a bareOSErroron first read.apply_template(**kwargs)raisedTypeErrorfor an already-built template and silently dropped the kwargs elsewhere.
Changed
PYTRUENAS_*settings are read through oneduho.envaccessor. The app had grown three names for two settings (PYTRUENAS_CFG,PYTRUENAS_CONFIG,PYTRUENAS_PATH);PYTRUENAS_CONFIGis now canonical withCFGstill accepted, and the set is enumerable rather than grep-discoverable.pytruenas.ops→pytruenas.patch, split intotemplates/,systemd/,middleware.py,zfs.py. The old name described neither what the code does nor what it costs: it modifies a host beyond what the middleware API supports, which is unsupported by definition and discarded by a boot environment swap on update.ops.midclt→patch.systemd(midcltis TrueNAS's own CLI binary, which nothing in the module invokes).TruenasSystemFile→SystemFile,SystemdUnit→Unit,SystemdServiceUnit→ServiceUnit,MiddlewareCode→MiddlewareFiles.- Unit
enable/startare now three-valued:Nonemeans "not mine to manage", which a bool could not express.
Removed
pytruenas.ops.host— its packaging half becamepatch.templates/utils.bundle; the remaining network helpers (is_localhost,is_local_ip,find_adapter_in_network) were thin wrappers overnetimpsandipaddressthat nothing in the package imported. Callnetimps.interface_for/get_interfacesandipaddressdirectly.pytruenas.ops.main.init— imported by nothing, duplicated the CLI's own config loading, and documented apytruenas.clientmodule deleted in 0.2.0. Nowexamples/simple_client_from_yaml.py, as a worked example.
Dependencies
hostctl>=0.1.2— foruri_hostname(), which returns a URI's host as written rather than case-folded.
[0.2.2] - 2026-07-28
Log records now identify the host they came from, and no longer carry the connection string that used to be the only identification.
Released as a patch: the public surface only gains (client.name,
config.name, TrueNASWSConnection(logger=...)). The one output change is to
pytruenas.utils.target's redact/Target.redacted, an internal utility
module — not exported from the package root, not in the docs, and reached only
by explicit deep import.
Changed
- Log records identify the host by name, not by connection string. Every
record carried the full URI — scheme, port, API path and any userinfo — which
was unreadable at fan-out width and put the password on every line. Records
are now prefixed with the machine's short name (
[nas1],[nas1:8443],localhostfor the unix socket), available asclient.name/config.name. Because the prefix always names the target, the per-targetStarted:/Finished:messages no longer repeat it. - Attribution no longer depends on the CLI.
client.loggeris now bound to the host's name, so a library caller with several clients open gets attributed records with no fan-out involved — previously only the CLI'sduho.fanoutadded a prefix, and a bare "Websocket connection was closed" did not say which host closed.TrueNASWSConnectionacceptslogger=and the host passes its own, so transport-layer records are attributed too. - Redaction removes the password instead of masking it.
pytruenas.utils.target.redactandTarget.redactedrenderedwss://root:***@nas; they now renderwss://root@nas. A***placeholder is not a real password and would reparse into a wrong credential if the rendered form were ever fed back in, whereas what comes out now is a valid, reusable URI.redactdelegates tohostctl.host.redact_uri, so pytruenas and hostctl render a target identically. Note this is only for rendering a raw connection string: credentials are extracted at parse time, soconfig.connection_uriwas already credential-free. --logtofilenames use the short name.{target}now expands tonas1rather than a full URI, which is both credential-free and a legal filename — a URI is neither.
Fixed
- A hostname's typed casing is preserved in its label.
urlsplitfolds case, so a fan-out overnasA/nasBlogged[nasa]/[nasb]. Correct for resolution, wrong for a label an operator greps for. - The same host renders the same way with or without a password. A target
whose URI carried a credential logged as
[nasa]while the identical host without one logged as[nasA], because hostctl rebuilt the authority from the case-folded hostname when stripping the password. Filed upstream and fixed across hostctl 0.1.1/0.1.2.
Dependencies
hostctl>=0.1.2(was>=0.1.0) — foruri_hostname(), which returns a URI's host as written instead of asurlsplitcase-folded it.TrueNASConfigstores that, since the value is rendered back out throughnameandconnection_uri. Both hostctl releases came from a finding filed from this repo. Note the trade-off inherited with it:config.hostis now the spelling the caller typed rather than a canonical one, so two spellings of one machine are not equal configs — casefold explicitly if you key on a host. Routing is unaffected.
0.2.1 - 2026-07-27
Documentation and one error-message fix. No API changes.
Fixed
- An unknown constructor argument now raises a useful error. A typo like
TrueNASClient("wss://nas", passwrd="s3cret")fell through to hostctl'sSystemHost.__init__and raisedTypeError: SystemHost.__init__() got an unexpected keyword argument 'passwrd'— accurate, but naming an internal class rather than the thing the caller got wrong. It now raisesValueError: unknown credential argument: 'passwrd'and lists the accepted configuration options. Found by testing the documented examples.
Documentation
- README rewritten for 0.2.0: it still advertised a
pytruenas[host]extra that no longer exists, said "once published to PyPI", and used a venv layout the project no longer follows. Adds the transport table and a commands/files section. - New Recipes guide — worked examples for connecting, querying, idempotent upserts, subscriptions, running commands, upload/download, several hosts at once, and provisioning SSH. Every example was run against a live TrueNAS 26.0.0-BETA.1 host.
- Filesystem guide expanded with real path examples and a table of which
operations need SFTP (
rename,symlink_to,readlink,resolvehave nofilesystem.*equivalent and raise rather than failing quietly). docs/index.mdextras list corrected; both CI workflows dropped a stalehostextra that pip had been silently warning about.
0.2.0 - 2026-07-27
Rebases pytruenas' generic host machinery onto hostctl, keeping only the
TrueNAS-specific parts here: the middleware websocket, the api namespace,
login/2FA, subscriptions, and the upload/download side channels. Everything
else — shell quoting, transport selection, the asyncssh lifecycle, path
backends — is now inherited.
Requires hostctl>=0.1.0,<0.2.
Changed
- BREAKING:
TrueNASClient.shellis gone..shellnow means what it means throughout hostctl — the bound shell object (client.shell.run(...)). The SSH connection target lives on the configuration asclient.config.ssh, anSshConfig. The constructor argument is still spelledshell=and still takes a connection string (shell="ssh://root@nas"). .run()and.path()now select a transport rather than branching. Which one serves a call is chosen from the available providers, and.last_selectionrecords what was tried and why — with credentials redacted. Previously.run()hard-coded a local-vs-SSH branch andTruenasPathhand-rolled its own SFTP→websocket fallback.- A remote target with no SSH can now run commands over the web shell. The
TrueNAS JSON-RPC API exposes no remote command execution (verified against
26.0.0-BETA.1: of 781 methods only
core.resize_shellanduser.shell_choicesare shell-adjacent, and the former only resizes an already-open session) — so such a host previously had norun()at all./websocket/shell, the PTY the web UI's Shell page drives, is a real command channel on the same port. Passexecutor=["ssh"]to require SSH instead. - The scheme/API-path probe moved from construction to first connect.
TrueNASClient("bad-host")now constructs successfully and raises on first use. Configs are therefore buildable offline, which is whatHostConfigrequires.
Added
pytruenas.host—TrueNASConfig(ahostctl.host.HostConfig) andTrueNASHost(aPosixHost).HostConfig("truenas+wss://nas")resolves through hostctl's registry; every connection stringTrueNASClientaccepts still works, normalized to atruenas+*scheme.TrueNASClientandTrueNASHostare now one class. They were briefly two objects that forwarded halves of their surface to each other —client.run()calledclient.host.run()whilehost.apicalledhost.client.api, each holding a reference to the other.TrueNASClientis an alias forTrueNASHost, so every existing import and call keeps working, andclient.host/host.clientboth return the object itself.TrueNASHost("wss://nas")also takes a connection string directly, with the same options asTrueNASConfig.from_target.pytruenas.providers—TnasWsPathProvider(thefilesystem.*websocket leg) andlocal_providers(), which returns hostctl's stock local executor and path providers unchanged. A local target runs plainsubprocessand uses plain local paths; there is nothing TrueNAS-specific to add, so pytruenas defines no provider class for it.pytruenas.webshell—WebShellExecutorProvider, command execution over/websocket/shell. Ordered after SSH; declares its limits rather than hiding them (stdout and stderr are one stream, no piped input, single-line commands only — pipes and here-strings work, being ordinary shell syntax).executor=/path=onTrueNASConfig— name the providers to use, in preference order, as a single name or a sequence:executor=["ssh"],path=["local", "tnasws"],executor=[]for no command channel at all. Unknown names, andssh/sftpwithout anSshConfig, raise rather than composing a host that would fail later. Matches hostctl's ownSystemConfig(executor=..., path=...)spelling.Credentials.from_host_credentials()— maps hostctl's already-parsed credential mapping (including a URI-supplied OTP) onto aCredentialssubclass, with no second round of string parsing.- Inherited from hostctl:
.capabilities(so a host that genuinely cannot run commands says so up front rather than failing mid-call),.last_selection,.info(),.spawn(),.connect()/.close(), and context-manager support. blackin thedevextra, pinned to the 3.9 floor.
Removed
- BREAKING:
pytruenas.jsonrpcis nowpytruenas.connection, and itsClientclass isTrueNASWSConnection.Clientwas doubly wrong: the class is not generic JSON-RPC (it knowscore.subscribe, TrueNAS error codes, and the middleware unix socket), and the name collided withTrueNASClient.client.connis the connection, with.websocketkept as an alias. - BREAKING:
pytruenas.clientandpytruenas._connare gone.from pytruenas import TrueNASClientis unaffected._connwas a re-export shim for swapping the client implementation, which never happened;clienthad been reduced to an alias by the host/client merge. - ~190 lines of generic host machinery: shell quoting, the local-vs-SSH branch
in
run(), the asyncssh connection, and_shellquote.
Known limitations
- A local unix-socket client cannot use
download(). The HTTP side channel resolves tohttps://localhostand trips the appliance's self-signed certificate. This is pre-existing and unrelated to the migration — the URL construction is byte-identical to 0.1.1. - The web shell merges stdout and stderr (a PTY is one stream), takes no
piped
input=, and requires single-line commands. Pipes and here-strings work, being ordinary shell syntax. It is ordered after SSH for these reasons.
0.1.1 - 2026-07-24
Changed
call/query/generate-typingsdeclare fewer field options. TheirArgsclasses no longer passNS(type=...)for a plainstr/Pathfield (duho already derivestype=from the annotation) orNS(action='append', nargs=...)for alist[str]option (duho >=0.5.0 already defaults a list-typed option toaction="append",nargs=None— one value per occurrence). OnlyNS(metavar=...)remains where the display name isn't inferable. No CLI-surface change.- An option placed between a command's own positional and the trailing
targets now parses, e.g.
pytruenas call method -p '{"a":1}' nas1— no longer only before the first positional or after the last. Was argparse's own greedy positional-run matching (bpo-15112); fixed by duho >=0.5.1's flag-between-positionals reorder, extended in 0.5.1 to a module command's subparser (this project's entire command set — 0.5.0 alone only covered duho's own declarative subcommand tree). - Dependency floor
duho>=0.4.1→duho>=0.5.1, required for both changes above.
0.1.0 - 2026-07-24
First published release with real content. 0.0.0 was a placeholder; everything
below accumulated since and is new to PyPI.
Added
- Modern
auth.login_exlogin with 2FA.client.login(login_ex=True)uses the middleware'sauth.login_exmechanism (PASSWORD_PLAIN/API_KEY_PLAIN/TOKEN_PLAIN) instead of the legacyauth.login/login_with_*. It handles anOTP_REQUIREDchallenge by continuing withauth.login_ex_continue— the OTP comes from the credential'sotp_tokenor anotp_providercallback — and raisesauth.AuthenticationErroronAUTH_ERR/DENIED/etc.login_optionsoverrides the server defaults. The legacy path remains the default and unchanged; a credential with no login_ex form (local-socket auth) falls back automatically. Validated live against TrueNAS 26.0. - Client convenience wrappers
client.me()(auth.me),client.logout()(auth.logout), andclient.ping()(core.ping). - Event subscriptions. Subscribe to middleware collection events over the
existing websocket:
client.subscribe("alert.list")(orclient.api.alert.list.subscribe()) returns aSubscription. Consume events by iteratingsub.events(timeout=...)— a bounded queue drained on the caller's thread, so backpressure is visible; a full queue drops the oldest event and counts it insub.droppedrather than blocking. An optionalcallbackis invoked inline on the reader thread (keep it fast; a raising callback is logged and contained). Each event is anEvent(collection, msg, fields, id). Close withsub.unsubscribe()or awithblock; closing the client ends everyevents()iterator cleanly. A subscription is bound to the current connection and does not survive a reconnect — theevents()iterator ending is the signal to re-subscribe. Validated live against TrueNAS 26.0. - RunPath step directories. Adopt
duho.runpath(requiresduho>=0.4.0), wired into the per-target fan-out: a directory of numberedNN-name.pysteps (no__init__.py), placed among the command sources (PYTRUENAS_PATH/--cmdspath/ configcommandspath, or nested one level inside a source directory), becomes a subcommand that runs the whole step sequence once per target, each target getting its own connectedTrueNASClient— restoring the private predecessor's per-targetRunPathCmdbehavior the current duho-basedpytruenasnever had. A directory's optional__main__.pyinit(cmd, logger)builds the per-target client (reusepytruenas.utils.runpath.default_init); steps aremain(cmd, ctx)/main(cmd);-O/--rcoptsand filename!/!strict/!enabletokens select steps. The step signature isduho's nativemain(cmd, ctx)rather than the predecessor'srun(client, args, logger)(the logger travels oncmd, the client isctx) — capability parity, not signature parity. The filename-modifier /--rcoptsgrammar follows the predecessor's intent with two of its original bugs fixed (the:!enable/.enabledattribute mismatch, and theExtend()nested-list double-collection), not reproduced.
Changed
- Local network-adapter discovery uses
netimpsinstead ofifaddr.pytruenas.ops.host.is_local_ip/find_adapter_in_networknow delegate tonetimps(a core dependency), so the optionalhostextra is removed — those helpers work out of the box, nopip install pytruenas[host]needed.find_adapter_in_networknow returns anetimps.Interface(was anifaddradapter). Requiresnetimps>=0.0.2, which also supplies thews/wssdefault ports built-in, soutils/target.pyno longer registers them at import. - The trailing
TARGET...positionals are registered centrally. Every command'sregisterhook previously had to callpytruenas.utils.cmd.register_targets(parser)last or silently lose the target grammar.pytruenas.mainnow wraps each command's hook and adds the positional after it, so targets stay trailing whatever positionals a command adds — including for a command with noregisterhook at all, and for third-party commands supplied via--cmdspath/PYTRUENAS_PATH, which now get the<command> [args...] [TARGET ...]grammar for free. - Commands declare their CLI fields via an
Argsclass.call,queryandgenerate-typingsdeclare arguments on theirArgsclass rather than adding them imperatively inregister()(which they no longer define). Previously theArgsclass was inert — duho ignored it,register()did the real work, and the two were hand-synced.register()remains supported as the escape hatch for what declarations can't express. The CLI surface is unchanged. - Dependency floor
duho>=0.4.0→duho>=0.4.1for the two behaviors the above depends on: a module command may declare its ownArgsclass (added to the subparser beforeregisterruns), and theregisterhook is gated and introspected on the object actually called, so wrapping it app-wide works even for a command that defines no hook of its own. - Dependency floor
duho>=0.3.2→duho>=0.4.0. 0.4.0 carries the RunPathregister(base=...)shared-root method inheritance, the__main__.pylifecycle, the correctedenable/!enabletoken spelling, and theExtend()nested-list fix that now flattens--cmdspath a:bto['a', 'b'](previously silently mis-collected as[['a', 'b']]for multi-value input).
Security
- Passwords in a target connection string are redacted from logs. A target
like
wss://root:secret@naspassed as a positional was logged verbatim (Started: …/Finished: …, at INFO) and, worse, embedded in the--logtofilename on disk. The password is now masked (wss://root:***@nas) at every such point viapytruenas.utils.target.redact— the username is kept, the real target still builds the client. Theauth.Credentials"not supported"ValueErrorno longer carries the rawpassword/token/api_keykwargs (which anexc_info=Truelog would have surfaced). Command text logged byclient.runis unchanged: that logging is intentional, opt-in vialoglevel(defaultTRACE, off unless enabled), and suppressible withloglevel=0.
Fixed
- Connection-string reassembly preserves reserved characters.
Target.urinow percent-encodes userinfo and path, so a credential or path containing@ : / #round-trips instead of reassembling into a URL that reparses to a different host/port/path. opsreads files as UTF-8, and narrower exception handling inauth(ValueError/TypeErrorrather than bareexcept Exception) so a genuine error surfaces instead of being swallowed behind a generic message.
0.0.0 - 2026-07-22
Initial release.
Earlier version numbers appear in this project's git history but were never tagged or published, so there is no upgrade path to describe -- everything below is simply what the package contains.
Fixed
ws://andwss://URLs no longer parse as port 0. No system services database has an entry for the websocket schemes, sogetservbyname("wss")failed -- and those are the schemes this client uses most. Port resolution now goes throughnetimps, whose scheme table is consulted before the system database.
Added
pytruenas call <method>command. Invoke any middleware method by its dotted name (system.info,core.ping,pool.dataset.details) — not just the queryable<namespace>.querymethodsquerycovers. Parameters are JSON values via-p/--param(repeatable).
Changed
-
CLI targets are now trailing positional arguments, not
-t/--target. A command's own positionals come first, then the target host(s):pytruenas query user nas1 nas2,pytruenas dump-api nas1,nas2. Comma lists and[A-Z]/[0-9]range patterns still expand; no target meanslocalhost. The-t/--targetflag has been removed. -
Dependency floors raised to the validated versions:
duho>=0.3.2(CLI parser fixes — a global option before a subcommand is no longer shadowed; a literal%in aCmddocstring no longer breaks parser build) and thesshextra'spathlib_next[sftp-async]>=0.8.3(SFTP default concurrency raised 8→16).
Fixed
- API calls no longer silently return
Noneon a dropped connection. The namespace call retry loop fell through and returnedNoneafter a singleECONNABORTED— which_getread as "record missing", turning an_upsertinto a spurious create (possible duplicate rows). It now retries then raises, and never returnsNoneon a connection error. - Long-running jobs no longer spuriously time out.
core.job_wait(waited on after uploads/downloads and mutating_upsert/_updatecalls) is now issued with no client-side timeout, so a job lasting longer than the 60s default no longer raisesCallTimeoutwhile it is still running server-side.Client.call(timeout=None)now means "wait indefinitely". -
client.run()with astrinputtogether with a textencoding/errorsno longer crashes. It used to pre-encode the string to bytes and hand the encoding tosubprocess.run, which then tried to.encode()the already-bytes input (AttributeError). Now text mode keepsstrinput as-is (and decodesbytesinput), binary mode encodes. Found by live testing on TrueNAS 26.0. -
ops.template.TemplateTarget.apply_templateno longer crashes on a plain string template (issubclass()was called on a non-type); astris now treated as literal template content and a path-like is read as file content. namespace.ioerroronly maps a middleware error toOSErrorwhen the bracketed prefix names a real POSIX errno; previously an unrecognised prefix producedIOError(None, msg), discarding the original exception type.
Internal
jsonrpc.Client.callnarrows the compatibility kwargs it ignores and logs any other unexpected keyword at debug level instead of silently swallowing it;_ioerroris no longer forwarded into the upload/download paths.Namespacechild lookups use a per-instance dict instead offunctools.cacheon the methods, so namespaces are garbage-collected with their client instead of being pinned for the process lifetime (relevant to long-lived embeddings).- The
pytruenas.opssubpackage (systemd/midclt host-config helpers) is experimental and exercised only by unit tests, not against a live host.
Added
- Packaged as
pytruenas(src layout, hatchling,pytruenasconsole script,py.typed). Python 3.9+. - Lean in-house JSON-RPC 2.0 client (
pytruenas.jsonrpc) speaking the middleware protocol overwss:///ws://and the localws+unix://socket, with extended-JSON (datetime/date/time/set/IP) round-tripping andClientException/ValidationErrorsmapping. Verified against a live host. - Attribute-style API namespace (
client.api.<namespace>.<method>(...)) with_get/_query/_create/_update/_upsertconvenience helpers. - Filesystem paths on
pathlib_next:client.path()returns aLocalPath(local) orTruenasPath(remote — SFTP-preferred via pathlib_next'sSftpPath, falling back to the middlewarefilesystem.*websocket API for delete/rename/symlink). - Typings generator (
generate-typings): produces.pyistubs for the whole API, validated to parse across every version of a real v26 dump (780 methods). - CLI (
dump-api,query,generate-typings) onduhowith multi-target fan-out (-t/--target,--parallel) and optional YAML config. - Optional extras:
ssh,config,codegen,host. - Test suite green on Python 3.9 and 3.13/3.14.
Notes
- Runtime CLI/logging/qualname/text come from
duho(>=0.3.0); path types frompathlib_next(>=0.8.2). Both are on PyPI. - Remote shell command execution (
client.runover SSH) usesasyncssh(thesshextra); the middleware API has no command-exec method. SFTP is handled bypathlib_next.