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.
Unreleased
0.4.6 - 2026-08-17
Fixed
iterdir()and every listing operation raised on any path not backed by ZFS.TnasWsPath._listdir/_scandircalledfilesystem.listdir(path)with no query options, so the middleware computed all sixteen columns of its result — includingzfs_attrs, which is ZFS-only. On a filesystem that has no ZFS attributes the middleware does not return thenullits own schema promises for that case; it fails the whole call withEFAULT ... ZFS attributes are not supported.. Soiterdir(),glob(),walk()andrglob()were unusable on/tmp,/var/tmp,/dev,/proc,/sysand every other non-pool mount, whilestat()andread_bytes()on those very same paths worked — which made it read like a permissions or existence problem rather than a projection one, on a path the caller never thought of as ZFS. Listing a dataset worked, which is why it went unnoticed.
Both callers now project with select, so the ZFS-only column is never
computed and one code path serves both kinds of filesystem — no error-string
sniffing, and no retry doubling the round trips. Each asks for exactly what it
consumes: _listdir for name, and _scandir, which seeds each child's stat
from the same listing, for name/type/size/mode. Seeded stats are
unchanged, including st_mtime: listdir has never reported mtime at all —
it is absent from the result schema and from every entry, on ZFS too — so the
narrower projection drops nothing that was previously there. stat() goes
through filesystem.stat, a different call that does report mtime and was
never affected.
Verified on a live TrueNAS 26.0.0-BETA.1 appliance: iterdir, glob and
walk on /tmp (tmpfs), which previously raised; iterdir still correct on
/root and /mnt (ZFS); and, together with the 0.4.5 encoding fix, a
directory whose name contains ? still lists itself rather than its parent.
0.4.5 - 2026-08-16
Fixed
client.path()built itstruenas://URI without encoding the path, so a filename containing?or#was truncated at construction — on every transport leg.pytruenas.fs.path()interpolated the segments straight intof"truenas://{host}{posix}"(andf"truenas+ws://..."), and both remote path types areUriPaths: they parse that string and uridecode it, so?began a query and#began a fragment.client.path("/mnt/tank/cache?v=2")returned a path already pointing at/mnt/tank/cache, and a literal%20in a name decoded into a space.
This is the same defect 0.4.4 fixed on the SFTP leg, one layer up and wider:
because the name was lost before a leg was chosen, the always-available
websocket leg — read_bytes, write_bytes, stat, iterdir, every
filesystem.* call — read and wrote the truncated name too, not just the five
SFTP-first operations. No error was raised on either leg; the call simply
addressed a different file.
Segments are now percent-encoded with the same RFC 3986 pchar safe set the
SFTP leg uses, which leaves : alone so a Windows-flavoured remote path still
reads as /C:/Temp. The safe set and the quoting moved into one internal
helper shared by both sites (pytruenas.fs._uri) rather than being spelled
twice — a second copy is how the SFTP leg was fixed in 0.4.4 while this one
stayed broken. The two encodings compose to exactly one round trip:
construction encodes, UriPath.path decodes, and the SFTP leg quotes the
decoded name, so 100% does not become 100%25 on the wire.
Constructing a path type directly from a URI string is unchanged and still
URI syntax — a ? in TruenasPath("truenas://nas/...") is a query, as it
should be. Only client.path() / pytruenas.fs.path(), which take
filesystem segments, are affected.
0.4.4 - 2026-08-16
Fixed
TruenasPath's SFTP leg built itssftp://URI without encoding the path, so a filename containing?or#silently addressed a different file._sftp()interpolated the path directly —f"sftp://{host}:{port}{self.path}"— andSftpPathparses that string and uridecodes the parts, so?began a query and#began a fragment:/mnt/tank/cache?v=2became/mnt/tank/cacheand theunlink/rmdir/rename/readlink/symlink_tothat followed operated on the truncated name with no error. A literal%xxin a name decoded into another name again (report%20final.txt→report final.txt). The path is now percent-encoded with RFC 3986pcharas the safe set, which leaves:alone so a Windows-flavoured remote path still reads assftp://host:22/C:/Temprather than/C%3A/Temp.
This is the same safe set hostctl adopted for its own SFTP leg in 0.2.6;
two SFTP legs reach the same host and they must agree on what a filename
means. The defect is only reachable from 0.4.3, which is what made this leg
engage at all — before that _sftp() returned None for every real host —
so a silent wrong-file operation is exactly as old as the repair that
exposed it.
Changed
- Dependency ranges are now pinned to a minor series, floored at the base of
that series unless a specific API says otherwise.
netimpsmoves from>=0.0.2,<0.3to>=0.2.0,<0.3: onlyget_default_portis used and it has been there since 0.0.2, so the old floor named a release far below the capped series for no reason.duhogains the missing upper bound it never had —>=0.5.2,<0.6— because duho is pre-1.0, where a minor may break (0.5.0 itself changed the arity of a list-typed option).
Three floors stay above the base of their series, each for a named API:
duho>=0.5.2 for runpath.register(step_adapter=...) (added in 0.5.2;
main.py passes the keyword at import, so 0.5.1 raises TypeError on
startup), pathlib_next>=0.9.1 for Path.symlink_to(force=) over the
_symlink_to() backend primitive (added in 0.9.1; client.path(...) routes
symlink operations to hostctl's SftpPath, a plain pathlib_next path, which
rejects the keyword below that), and hostctl>=0.2.5 for the public
hostctl.executor.write_output/capture_streams that webshell.py imports
(public from 0.2.5; private _common before it).
No floor follows the newest patch of its series. Nothing added in duho 0.5.3 or 0.5.4, netimps 0.2.1 or 0.2.2, pathlib_next 0.9.2, or hostctl 0.2.6 is called from this package, and requiring one of those would have excluded working installs without buying anything. The whole suite was exercised against the newest of each anyway — duho 0.5.4, netimps 0.2.2, pathlib_next 0.9.2, hostctl 0.2.6 — on Python 3.9 and 3.14.
Documentation
- Four places still quoted dependency floors the package had already moved
past, so a reader checking what to install got a wrong answer from the docs
and a different one from the metadata: the shipped API header named
duho>=0.4.0andpathlib_next[sftp-async]>=0.8.3, the filesystem guide named>=0.8.3, andutils/target.py's comment namednetimps>=0.0.2. All four now state the declared range and why it is where it is.
0.4.3 - 2026-08-16
Fixed
TruenasPath's SFTP leg ignored the configuredknown_hosts, so it did not apply the caller's host-key policy._connect_opts_from_ssh()hand-mappedusername/client_keys/passwordout of theSshConfigand dropped everything else — includingknown_hosts, whichSshConfig.connect_opts()does pass. A host configured with aknown_hostsfile had that policy enforced on the SSH executor leg and ignored on the SFTP leg (verified against a live appliance: the executor refused to connect, the SFTP leg connected anyway), and a caller passingknown_hosts=Noneto disable verification was likewise ignored. The mapping now delegates toSshConfig.connect_opts(), the single source of truth hostctl's own SSH and SFTP legs use, so both legs of a host authenticate and verify identically. The bug predates this release but was unreachable while the SFTP leg was dead (below) — 0.4.3 is what makes it reachable, which is why it is fixed here.TruenasPath's SFTP leg never engaged._sftp()looked the SSH target up onclient.ssh_config(removed whenTrueNASClientmerged intoTrueNASHost) and thenclient.shell(hostctl's boundShell, which has nohost), so it returnedNonefor every real host: the documented SFTP-first behaviour ofunlink/rmdir/rename/readlink/symlink_towas unreachable, andrename/readlink/symlink_toraisedNotImplementedErrorno matter how the host was configured. It now readsclient.config.ssh— thehostctl.host.SshConfigthe host actually carries. (Host-levelclient.path(...)work was mostly unaffected: hostctl's composite path routes symlink-ish operations to its ownsftpprovider.)TruenasPath.symlink_to(force=True)deleted the existing target and then failed on a host with no SFTP leg: the force-removal ran first, and only afterwards did the call discover there was no way to create the link. The creating leg is now resolved before anything is removed, so the call raisesNotImplementedErrorwith the target untouched. (Consequence of the ordering: on a host with no SFTP leg, aforce=conflict now raisesNotImplementedErrorrather thanFileExistsError— neither call could ever have succeeded.)Credentials(...)printed the secret when called with both positional and keyword arguments. That branch raisedAttributeError(args, kwargs)with the raw values — and an exception's args are exactly what a traceback or log handler renders, so a call-shape mistake leaked the credential. It now raisesValueError(matching the sibling "Credentials not supported" branch) with the keyword secrets masked as***and the positional credential reduced to its type name.
Changed
- Declare Python 3.14 support (classifier), and test it in CI — it is the routine development interpreter, so a 3.14-only breakage should not have to wait for a manual check.
Documentation
- The shipped API header (
pytruenas/AGENTS.md) documents 0.4.2's whole repo mode: everydeploy --source repoflag, andutils.bundle'scollect_repo/repo_requirements/DEFAULT_IGNORE_FILES/contents=. Therepoextra is listed there and in the README. - The changelog's reference links stopped at
[0.3.4], so every## [0.4.x]heading rendered as a dead reference. Added the missing definitions (0.2.2,0.3.0,0.3.1,0.4.0,0.4.1,0.4.2) and repointed[Unreleased]atv0.4.2...HEAD. TruenasPathtries SFTP first for five operations, not six.pathlib_next'sSftpPathimplements noresolve, soTruenasPath.resolve()has always fallen through to returningself— on every host, whether or not SFTP is configured. The module docstring, the shipped API header and the filesystem guide all listedresolvealongsideunlink/rmdir/rename/symlink_to/readlinkas SFTP-preferred; they now say what actually happens. No behaviour change — the docs were wrong, not the code.
0.4.2 - 2026-08-06
Fixed
- The web shell's stderr split could misattribute stdout as stderr.
wrap_stderrbracketed stderr with its start/end markers around the whole2> >(...)process substitution's lifetime, not each individual write — so the subshell's own scheduling decided when the markers actually fired, and stdout written while that bracket happened to still be open came back labelled as stderr. Switched to a per-line read loop that closes the bracket after each line, so a write can only be misattributed if it lands mid-line. clean_output()left stray escape bytes behind for one OSC form. It only recognized a BEL-terminated OSC sequence (ESC ] ... BEL); the equally common ST-terminated form (ESC ] ... ESC \) — including its own strayESC \— passed through into what was supposed to be cleaned output.
Added
pytruenas deploy --source repo: ship a repo working tree as-is, rather than the installed dependency closure. Copies files filtered by whichever of.gitignore/.ignore/.bundleignoreexist (--ignore-filenarrows the selection;--ignore-patternadds patterns on the command line), and reads declared dependencies straight frompyproject.toml/requirements.txt— no import, nothing installed required — logging them as a heads-up via the newbundle.repo_requirements()(--include/--excludeaccept a bare dependency name or a bracketed[extra]; an exclude always wins).
This does not vendor the repo's own dependencies alongside it — that
needs a resolved transitive closure, which requires those dependencies
installed here, the exact thing repo mode exists to avoid needing. It is
for the read-only-root workflow of shipping source to be read, edited, or
run by an interpreter that already has (or can reach) what the repo
declares; --source installed (the default, unchanged) is still what
builds a fully self-contained bundle.
Only --mode dir supports it: a zipapp needs its package importable at the
archive root, which a src/-layout repo copy does not have. --mode dir's
launcher instead puts every directory an importable package was found under
(src/, lib/, vendor/, or the repo root itself — auto-detected, or
named explicitly with --pythonpath) on PYTHONPATH.
New public pytruenas.utils.bundle functions: collect_repo() (the
gitignore-filtered tree walk, via the new optional pathspec dependency —
the repo extra) and repo_requirements() (the static dependency reader,
using tomllib/the optional tomli fallback below Python 3.11). build()
and export() gained a contents= parameter accepting either function's
output directly, so the zipapp/tree-writing code is identical regardless of
where the file list came from.
0.4.1 - 2026-08-06
Fixed
- Wrapper objects reach the API as the scalars it expects. Holding a value
as
IPv4Address, a MAC type, or aPurePathis the natural thing to do in Python, and it broke twice: serializing raisedTypeError: not JSON serializable, anddiff()compared the wrapper against the plain string the API had reported. Those are never equal, so the field looked changed on every call — an upsert rewrote it forever and reported a change that never happened.
A type may define __json__() to choose its own form (the only hook that can
produce something other than a string); otherwise a type carrying its own
__str__ is stringified. Anything else — JSON natives, containers, and
objects with the default __str__ — is left alone, so an opaque object
still raises TypeError rather than being sent as
"<module.Thing object at 0x...>".
The middleware's extended types (datetime, set, IP interfaces) keep
their {"$date": ...}-style envelopes: the reduction runs only after those
are handled. diff() normalizes for the comparison only — the value sent is
the caller's original.
Changed
- Requires
hostctl>=0.2.5(was>=0.2.3). The web-shell executor importedwrite_outputfromhostctl.executor._commonbecause it was not exported; 0.2.5 makes the stream helpers public, so the import moves tohostctl.executor. No privatehostctlimports remain.
0.4.0 - 2026-08-05
Added
- The web shell separates stdout from stderr. A PTY has one stream, so the
command is wrapped to fence its stderr in terminal escape markers and the
reader splits them back out into
CompletedProcess.stderr.
This needs a shell with process substitution (bash/zsh/ksh). The login shell
is read from auth.me(), and output stays merged under anything else
(sh, dash) with .stderr as None — always correct, just less
informative. The wrapper is applied only when the caller asks for the
distinction, so an ordinary capture_output=True costs nothing.
Reading the shell from the API rather than by running a command is deliberate: probing by driving the terminal would hang in exactly the case the probe exists to detect.
-
The web shell accepts input.
input=rides along as a here-document — no timing, no second channel — and is the reliable form.stdin=takes a readable object and pumps it on a background thread for a stream still being produced; it races the terminal's echo of the command line and is mitigated by a short delay rather than cured. A file descriptor is rejected: there is no PTY fd to attach one to. -
Web-shell output that is not captured is streamed as it arrives, in raw bytes, to
stdout(defaultsys.stdout.buffer). A long-running command reports progress instead of going silent until it exits, and colour and other escape sequences survive. Captured.stdoutremains cleaned text.
Fixed
- A patched file could lose its only undo path.
read()created the baseline snapshot, which made reading a write — and that fails outright on the read-only mount the middlewared package ships on.find_templateworked around it by defaultingbaseline=False, trading away the safety net to dodge the bug.
Reading no longer snapshots (write() already did, which is the correct
moment: the mount must be writable by then anyway). With that fixed,
find_template defaults baseline=True like everything else in the package,
and skipping the snapshot is opt-in. Overwriting a stock template with
nothing beside it has no way back — the original ships inside the middlewared
package, so recovering it means reinstalling.
- A shell prompt could end up inside a filesystem path. The web shell's
prompt stripping missed zsh's trailing prompt when its line redraw and the
prompt merged onto one line, so
middlewared_path()returned/usr/.../middlewared\n# root@HOST[~]#and every path built from it was wrong.
Changed
- The web shell no longer rejects
stdout=/stderr=.capture_outputandstdoutresolve throughhostctl.executor.capture_streams, the same helper the SSH executor uses, so the option surface matches whichever provider a host selects.stdin=as a file descriptor is still rejected. WebShellSession.run_script()returns(text, raw_bytes, returncode)and takessink=,errsink=,heredoc=, andstdin=. The extra return element is the raw byte stream.
Added
- A derived tool can supply its own shared args root.
main(args=...), or_ARGS_on aPyTrueNASsubclass, sets the class every command inherits — so a tool built on pytruenas can add global options:
class MyApp(PyTrueNAS):
_ARGS_ = MyArgs # a PyTrueNASArgs subclass
main("mytool", root=MyApp)
The value reaches both the app root and duho.runpath's base, which is
the whole point: a global flag that worked on pytruenas call but vanished
on a RunPath directory would be the obvious way to get this wrong. A plain
PyTrueNASArgs subclass is combined with PyTrueNASRunPathArgs for the
RunPath base, so a derived tool keeps its trailing TARGET positional.
This does not add per-directory arguments: the base is app-wide, and a step directory still cannot declare its own flags.
0.3.4 - 2026-08-04
Fixed
client.path()raisedModuleNotFoundError: asyncsshwhenever an SSH configuration existed but the optionalsshextra did not — even though the websocket backend could serve the call. The sequel to 0.3.3: provisioning worked without the extra, and the next path call died. Provider names were chosen from "is SSH configured", never "is it importable"; they are now gated on an import check before any provider is built.
Only defaults degrade. A caller who names executor=["ssh"] or
path=["sftp"] explicitly still gets it, and still fails loudly — silently
serving a transport the caller did not ask for is its own bug.
- A download link's query string was percent-encoded into the path
(/_download/12345%3Fauth_token%3Dabc), so the middleware 404'd. Split in
_http_target(), the one choke point every HTTP side channel uses. This also
fixed Target.uri, which parsed a query but never rendered it — so the
split alone would have dropped the query silently instead.
- repr() on any namespace raised AttributeError, reading a _client._api
attribute that no longer exists. repr is what a traceback frame renders, so
it failed while something else was already going wrong. Now renders the host's
short, credential-free name.
- The same stale attribute made fs.path() unreachable through the path
provider entirely; resolved via fs._settings(), which accepts either
spelling.
Changed
- The websocket path provider returns a
TruenasPath, not aTnasWsPath. Both ride the same backend, so the transport is unchanged — butTruenasPathcarries the documentedsymlink_to(force=, onremove=)and the SFTP→websocket fallback. Pinning the narrower type was silently dropping a documented API.
With pathlib_next>=0.9.1 and hostctl>=0.2.3, force= now survives the
whole chain: client.path(...).symlink_to(target, force=True) reaches the
backend instead of raising TypeError at the composition boundary.
Dependencies
Both floors rise for the same reason, and both came from findings filed here:
the provider hands back a TruenasPath so its documented
symlink_to(force=, onremove=) is available, and below these versions that
kwarg is stripped before it reaches the backend — the API would be advertised
and not deliverable.
pathlib_next[uri]>=0.9.1,<0.10(from>=0.9.0) —symlink_to(force=)as a genericPathextension over a_symlink_to()backend primitive.hostctl>=0.2.3,<0.3(from>=0.1.2) — signature-aware keyword forwarding through composite path dispatch. This is the first time the hostctl floor has moved past 0.1.2.
0.3.3 - 2026-08-03
Fixed
install_sshcredsno longer requires thesshextra. It provisions a keypair over the middleware API and opens no SSH connection, but importedasyncsshunconditionally to derive the public key from the private one — so the extra was required for work that never used it. The middleware already returns both halves on the paths that generate or store a keypair, so the public key is now carried through when known and derived only when genuinely absent (a caller-suppliedprivate_key=for a key the host does not have).- The root user was selected by id rather than by field name.
_upsert("username", ...)passes a barestr, whichDbAction.executereads as a record id;("username",)is the sequence form meaning "match on this field".
Added
- RunPath steps may use the module command signature —
(client, args, logger), the same shapecmd/modules use — with no decorator in the step file. Requires duho 0.5.2'sregister(step_adapter=...). Only unambiguous 3-argument steps are adapted automatically; a shorter(client, args)is indistinguishable from duho's own(cmd, ctx)and still needs@step. duho-native steps are unaffected. examples/runpath/— a runnable three-step flow showing all three step shapes side by side, with a README covering the arguments and the per-targetinithook.
Changed
- Public-key derivation prefers
cryptographyoverasyncssh.asyncsshdepends oncryptography, so thesshextra already brings it, and it is far lighter than an SSH protocol stack for pure key math.asyncsshremains the fallback. Both OpenSSH and PEM/PKCS#8 encodings are handled.
Dependencies
duho>=0.5.2(from>=0.5.1) forrunpath.register(step_adapter=...). Not optional: the keyword is passed at import, so 0.5.1 raisesTypeErroron startup.
CI
- Actions updated to current majors (
checkout@v7,setup-python@v7,upload-artifact@v7,download-artifact@v8, and the Pages actions), ahead of the Node 20 runtime deprecation.
0.3.2 - 2026-08-03
Fixed
sslverifynever reached the web shell.WebShellSession.connect()readclient.sslverify, but the flag lives onTrueNASConfigandTrueNASHostexposed no such attribute — so everywss://web shell connect raisedAttributeErrorrather than falling back to verifying.TrueNASHost.sslverifynow delegates to the config, so the JSON-RPC, REST, and web shell legs all answer from the one value the caller set.
Added
patch.zfscan get and set arbitrary dataset properties.get_property/set_property, the batchedget_properties/set_properties(one round trip instead of one per property), andinherit_propertyto clear one — ZFS has nozfs unset. Native properties and user properties (com.example:role) are both supported; an unset user property reads as absent rather than as the literal-ZFS prints, and booleans render ason/off.is_readonly/set_readonlyare now thin wrappers over this API.utils.runpath.step— a decorator letting a RunPath step use the module command signature(client, args, logger)instead of duho'smain(cmd, ctx), so the same body works in either command kind. Steps declaring fewer parameters are handed only those; undecorated duho-native steps are unaffected.
Changed
utils.runpath.default_initreadscmd.sslverifydirectly. The previousgetattr(cmd, "sslverify", False)would have turned a missing field into silently disabled TLS verification; every RunPath command inherits the field fromPyTrueNASRunPathArgs.
0.3.1 - 2026-07-29
Fixed
hostctl<0.2made the dependency set unresolvable.pathlib_next0.9.0'suriextra requiresnetimps>=0.2.0, andhostctlwidened its own ranges to admit that in 0.2.2 — so pinninghostctl<0.2left no solution for apip install pytruenasthat also pulledpathlib_next[uri].os.fspath()on aTruenasPath/TnasWsPathraisedNotImplementedError. Both now set_host_filesystem_path, sofspathreturns the host-local path (/usr/lib/x, nottruenas://root@nas/usr/lib/x). Requirespathlib_next>=0.9.0.
Changed
patch.zfs.host_pathusesos.fspath()only. It previously fell back to.path, thenas_posix(), thenstr(), forpathlib_next<0.9wherefspathraised for every non-filescheme. A URI scheme with no host-local path now raises rather than yielding a string that is not a path.
Dependencies
hostctl>=0.1.2,<0.3(from<0.2). Nothing pytruenas imports changed; the floor stays at 0.1.2 because no 0.2 API is used here. Validated on 0.2.2.pathlib_next[uri]>=0.9.0,<0.10(from>=0.8.2, uncapped) — forUriPath.__fspath__on host-filesystem schemes, and forSource.__str__/__repr__no longer emitting the password. Thesshextra'spathlib_next[sftp-async]moves to>=0.9.0,<0.10alongside it.netimps>=0.0.2,<0.3— ceiling added; 0.2.0 reworkedresolve()and madednspythonoptional, neither of which touchesget_default_port, the only function used here. Validated on 0.2.0.
0.3.0 - 2026-07-29
Added
deploycommand — installs pytruenas onto a target that has nopipand a read-only root. Queries the target's installed distributions, bundles only those it lacks, and transfers the result. On TrueNAS 26.0.0-BETA.1 that is 5 packages / ~600 KB (duho,hostctl,netimps,pathlib-next,pytruenas); the appliance already providesrequests,websocket-client,pyyaml,asyncssh,jinja2,certifi,urllib3,idna,charset-normalizeranddnspython.--mode pyz(default): a single executable zipapp.--mode dir: abin/+lib/tree, unpacked to a staging directory and swapped into place.- Arguments after
--are executed on the target after installation:pytruenas deploy nas1 -- call system.info. - Default path
/var/db/system, the mountpoint of<pool>/.systemon a data pool./var/db,/rootand/dataare datasets underboot-pool/ROOT/<version>/and are replaced by a boot-environment swap on update. - A SHA-256 digest is stored beside the payload; a redeploy with a matching
digest transfers nothing.
--forceoverrides. --pkg-root/--pkg-name, orPYTRUENAS_PKG_ROOT/PYTRUENAS_PKG_NAME: bundle a different distribution as the root, with pytruenas as a dependency of it.pytruenas.utils.bundle— dependency-closure resolution and bundle construction. Reads installed distribution metadata (importlib.metadata) rather than scanning imports. RaisesBundleErrorfor a distribution containing a compiled extension, or one resolving to data files with no importable module.pytruenas.patch.zfs—writable(client, path)context manager: clearsreadonlyon the ZFS dataset backingpathand restores the previous value on exit, including when the block raises.dataset_forwalks to the nearest existing ancestor, asfindmnt --targetexits non-zero for a path that does not exist. Alsois_readonly,set_readonly,host_path.FileTarget.revert(remove_baseline=True)— restores the baseline snapshot and removes it. ReturnsFalsewhen no baseline exists.FileTarget.is_patched()— whether the file differs from its baseline.FileTarget.would_change(content)— whether a write would modify the file. No side effects.FileTarget(..., mode=)andSystemFile(..., writable=, mode=)— mode for a newly created file;writable=Truewraps writes inpatch.zfs.writable.
Fixed
- File permissions are preserved across a rewrite. The mode was previously
reset to the umask default. On TrueNAS 26.0,
/etc/shadowis0640 root:shadow; patching it produced0644. systemctlinvocations ran as separate commands.Host.run(*cmds)treats each positional argument as its own command, sorun("systemctl", "disable", "--now", name)executed four commands. Unit names were also shell-quoted while being passed as argv. Each invocation now builds a single argv list.is-active/is-enabledraised on a non-zero exit, which is their result value for "no".services="nfs"was iterated character-wise, producing three service reloads.mkdir(755, ...)passed decimal755(0o1363: setuid, setgid, sticky andrwx-wx-wx) as the mode for created directories. Now0o755.baseline=TrueraisedFileNotFoundErrorfor a file that does not exist, fromread_bytes()on the absent original insidewrite().FileTarget.baseline()calledresolve(), absent fromhostctl.host.CompositePosixPath.BaseTemplate.renderreturnedNonewhen not overridden; the value reachedwrite()as file content. Now raisesNotImplementedError, andwrite()rejectsNone.MiddlewareFilesreadclient.middlewared_path, which does not exist. Replaced bymiddlewared_path(client), which runsimport middlewaredon the host. No API method reports the path (checked against all 781 methods on 26.0.0-BETA.1).MiddlewareFiles.find_templatedefaulted tobaseline=True. The middlewared package is on a read-only mount (boot-pool/ROOT/<version>/usr), so the snapshot write failed withOSErroron first read. Now defaults toFalse.apply_template(**kwargs)raisedTypeErrorfor an already-constructed template, and discarded the arguments in other branches.pytruenas/cmd/had no__init__.py.zipimportdoes not support namespace packages, so a zipapp built from the package exposed no commands.pytruenas/utils/io.pycalledPath(__file__).stat()at import to build an unusedSTAT_FIELDSconstant, raisingNotADirectoryErrorwhen imported from a zipapp. Removed.
Changed
PYTRUENAS_*variables are read through a singleduho.env.Envaccessor (pytruenas.utils.cmd.ENV).PYTRUENAS_CONFIGis the documented name;PYTRUENAS_CFGremains accepted.PYTRUENAS_PATHis split withos.pathsepand yields[]when unset (previously[""], which resolved to the working directory).pytruenas.opsis nowpytruenas.patch, split intotemplates/(base.py,targets.py),systemd/(unitfile.py,files.py,units.py),middleware.pyandzfs.py.ops.midclt→patch.systemd.TruenasSystemFile→SystemFile,SystemdUnit→Unit,SystemdServiceUnit→ServiceUnit,SystemdMountUnit→MountUnit,SystemdAutoMountUnit→AutomountUnit,MiddlewareCode→MiddlewareFiles.ops.template→patch.templates.Unit.enableandUnit.startacceptNone, meaning the current state is left unchanged. Previouslyboolonly.MountUnitomitsOptionsandTypewhen empty.FileTargetaccepts any object providingexists,read_bytes,write_bytesandwith_name, rather than requiringpathlib_next.Path.
Removed
pytruenas.ops.host—package/package_digest/PathPatternsare nowpytruenas.utils.bundle.tar_tree/tar_digest.is_localhost,is_local_ipandfind_adapter_in_networkare removed with no replacement; they wrappednetimps.interface_for/netimps.get_interfacesandipaddress.pytruenas.ops.main— moved toexamples/simple_client_from_yaml.py.
Dependencies
hostctl>=0.1.2(from>=0.1.0) — foruri_hostname().
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
Added
client.name/config.name— the host's short label: hostname, plus the port when it is not the scheme default;localhostfor the unix socket.TrueNASWSConnection(logger=)— the connection emits through the host's logger when one is supplied.
Changed
- Log records are prefixed with the host name (
[nas1],[nas1:8443]) instead of the full connection string, which included the scheme, port, API path and userinfo.client.loggeris bound to the name, so records are attributed without the CLI'sduho.fanoutprefix filter. The per-targetStarted:/Finished:messages no longer repeat the target. utils.target.redactandTarget.redactedremove the password rather than masking it:wss://root:secret@nasrenders aswss://root@nas, notwss://root:***@nas. The result reparses to an equivalent target;***reparsed as a literal password.redactnow delegates tohostctl.host.redact_uri. This affects only the rendering of a raw connection string — credentials are extracted during parsing, soconfig.connection_uricontained none.--logto{target}expands to the host name rather than the connection string. The name is also a valid filename component.
Fixed
- Hostname case is preserved in the log label.
urlsplitcase-foldshostname, sonasA/nasBwere logged as[nasa]/[nasb]. - A host renders identically with and without a credential. With a password
in the URI the label was
[nasa]; without one,[nasA].hostctl.redact_urirebuilt the authority from the case-folded hostname (fixed in hostctl 0.1.1, completed byuri_hostname()in 0.1.2).
Dependencies
hostctl>=0.1.2(from>=0.1.0) — foruri_hostname(). Note the behaviour it brings:config.hostholds the spelling as given, not a canonical one, so two spellings of one host are not equal configs. Routing case-folds before comparing and is unaffected.
0.2.1 - 2026-07-27
Fixed
- An unknown constructor keyword raises
ValueErrornaming it.TrueNASClient("wss://nas", passwrd="s3cret")reachedhostctl.host.SystemHost.__init__and raisedTypeError: SystemHost.__init__() got an unexpected keyword argument 'passwrd'. It now raisesValueError: unknown credential argument: 'passwrd', listing the accepted configuration options.
Documentation
- README updated for 0.2.0: removed the
pytruenas[host]extra (dropped in 0.2.0) and a pre-publication note; corrected the venv layout; added the transport table and a commands/files section. - Added the Recipes guide — 13 examples covering connections, queries, upserts, subscriptions, commands, transfers, multi-host fan-out and SSH provisioning. All executed against TrueNAS 26.0.0-BETA.1.
- Filesystem guide — added path examples and a table of operations
requiring SFTP:
rename,symlink_to,readlinkandresolvehave nofilesystem.*equivalent. docs/index.mdextras list corrected. Both CI workflows referenced the removedhostextra.
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.