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.7.1] - 2026-10-21
Changed
- Requires
duho>=0.7.8,<0.8. A library caller who runsduho.fanout.run_targetsover connection strings without alabel=now sees the host's own[name]prefix as well as duho's tag of the target; the CLI, which labels each target with the host's name, is unchanged.
0.7.0 - 2026-10-20
Upgrade notes
sslverify=isverify=, one switch for TLS and SSH host keys alike:TrueNASClient(..., verify=True | False | "<CA bundle>" | ssl.SSLContext).sslverify=is an unknown keyword (ValueError);client.sslverifyandconfig.sslverifyare gone (readclient.config.verify), andTrueNASWSConnection(verify_ssl=)isverify=. A path or a context means "verify" for SSH. "TLS off, SSH strict" isverify=False, known_hosts=<file>; "TLS on, SSH off" isssh=SSHConfig(host, username="root", verify=False).verify=Truetrusts what the operating system trusts, through its own verifier:truststoreon Python 3.10+ (Windows CryptoAPI, the macOS keychain, the system CA paths elsewhere) — what hostctl'sverify=Truemeans — and OpenSSL's default store on 3.9 as before. On Windows a public root the system fetches on demand, and on macOS a CA only in the keychain, are now trusted. The CA-bundle variables (SSL_CERT_FILE,REQUESTS_CA_BUNDLE,CURL_CA_BUNDLE,WEBSOCKET_CLIENT_CA_BUNDLE) still replace that trust.- The CLI's TLS options are
-k/--insecureand--ca-bundle FILE.--sslverify/--no-sslverifyare gone;$PYTRUENAS_SSLVERIFYand the config file'ssslverifyare replaced by$PYTRUENAS_INSECURE/$PYTRUENAS_CA_BUNDLEandinsecure/ca_bundle; the old names are no longer read.-kalso skips the SSH host-key check, asverify=Falsedoes.PyTrueNASArgs._sslverify_()is._verify_(). TrueNASWSConnection(...)does no I/O. It opened the websocket and started its reader in the constructor;.connect()(returning the connection),withor the first.call()does that now.download(..., wait=False)returns aPendingDownload(.job_id,.fetch()), not the job id: the link was discarded, so the bytes could never be fetched.- The CLI's config file is read by anycfg, and is TOML by default. It is
--config, else$PYTRUENAS_CONFIG, elsepytruenas.cfgin the user's configuration directory (%APPDATA%,$XDG_CONFIG_HOMEor~/.config);./pytruenas.yamlin the working directory is no longer read, and$PYTRUENAS_CFGis gone. The suffix picks the format, and a.cfgfile is the format its#!<format>first line names, else TOML. A YAML file keeps working aspytruenas.yaml, or as a.cfgstarting with#!yaml, with the newyamlextra, which replacesconfig. A file that does not read is now an error naming it (exit status 2) instead of being ignored.cmdspath(below) is read from any config file.PyTrueNASArgs._config_is_implicit_()is gone;._config_path_()andpytruenas.cli.default_config_path()are new. pytruenas.utils.tlsis gone (context,ca_bundle,CA_BUNDLE_ENV,SslVerify); the decision is private now. Passverify=instead.TrueNASHostis gone: the class isTrueNASClient. It was the same class under a second name;from pytruenas import TrueNASHostandpytruenas.host.TrueNASHostraiseImportError/AttributeError. Replace the name withTrueNASClient(TrueNASClient[Current]for typed.api); nothing else about the class changed with it.- The client's compatibility shims are gone. Callers change:
TrueNASClient(target, creds)→TrueNASClient(target, credentials=creds):credentialsis keyword-only, and a second positional argument raisesTypeError.client.websocket→client.conn;client.client→client.client=(accepted and ignored) andlogger=raiseValueError: unknown credential argument. Forlogger=, assignclient.logger = <name or Logger>instead, which binds it to the host's name aslogger=did;TrueNASConfig(logger=)andconfig.loggerare gone too.client.subscribe("alert.list")→client.api.alert.list.subscribe(), orclient.conn.subscribe("alert.list")for an event name that is not a namespace path.client.path()is the one path constructor.pytruenas.fs.path,pytruenas.fs.LocalPathandpytruenas.fs.symlink_toare gone:fs.path(client, *segments)→client.path(*segments);fs.path(client, ..., backend="ws")→client.path(..., backend="tnasws").fs.LocalPath→pathapi.LocalPath.fs.symlink_to(p, target, force=True)→p.symlink_to(target, force=True)(pathapi's), which replaces a file or a symlink but never removes a directory: remove a directory in the way yourself (p.rm(recursive=True)) where that is what you mean.onremove=has no replacement.upload()anddownload()take no**kwargs. No keyword could work — each reachedauth.generate_token/core.downloadand was refused — so a stray keyword is now Python's ownTypeErrornaming it. Pass the method's parameters positionally aftermethod, as before.- Every error is defined in
pytruenas.exceptionsand derivesTrueNASError, and every one is exported frompytruenas: pytruenas.utils.apicache.ApiDumpErrorisAPIDumpError(still aRuntimeError);UnknownMethod(still aLookupError) andFieldError(still aValueError) areTrueNASErrors now.ValidationErrorsis also aValueError. Atrythat catchesValueErroraround a middleware call now catches a server-side validation failure too; catchValidationErrorsfirst if the two need different handling.- A refused websocket upgrade or a failed handshake (a wrong API path is a
404) raises
ConnectError(.status, websocket-client's exception as__cause__) instead ofwebsocket.WebSocketBadStatusExceptionand the other websocket-client exceptions; catchConnectErrororTrueNASError. A refused port, an unresolvable host and a TLS error are still theOSErrors they were. - Import them from
pytruenasorpytruenas.exceptions;pytruenas.connectionandpytruenas.authno longer list them in__all__. - CapWords names spell their acronyms in capitals. Rename:
ApiKeyAuth→APIKeyAuth;pytruenas.fs.TnasWsPath→TrueNASWSPath;pytruenas.fs.TnasWsBackend→TrueNASWSBackend;pytruenas.providers.TnasWsPathProvider→TrueNASWSPathProvider;pytruenas.namespace.DbAction→DBAction. The old names are gone. - A call option has one spelling, and no keyword is ignored.
client.api.<method>(..., _tries=N)→_retries=N(the same count: the retries after the first attempt). A baretimeout=→_timeout=.- The upstream client's no-op keywords
background=,callback=,register_call=andraise_=raiseTypeError, on a namespace call and onclient.conn.call(...); drop them.job=Trueandjob="RETURN"keep working on a namespace call. TrueNASWSConnection.call(method, *params, timeout=)raisesTypeErrorfor any other keyword (job=included) instead of logging a misspelt one at debug and running the call with the default timeout.TrueNASWSConnection(py_exceptions=)and.py_exceptionsare gone; the option did nothing.- The one-time code is
otp, everywhere.BasicAuth(user, pw, otp_token=)→BasicAuth(user, pw, otp=), and the attributecredential.otp_token→credential.otp.Credentials(..., otp_token=)raisesValueError(otp=is the keyword, as onTrueNASClient), and any keyword outsideusername/password/otp/api_key/tokendoes too. _create/_update/_upsertwait only on a job. Whether thecreate/updatemethod is a job comes from its schema (core.get_methods, read once per namespace and cached), not from the answer being an integer: a method answering with a record id returns that id instead of waiting on an unrelated job of the same number. Pass{"job": True}for a method whose schema cannot be read (the lookup logs a WARNING and waits on nothing).pytruenas.apicache.service_methodsraisesUnknownMethod(still aLookupError) for an unknown service.with TrueNASClient(...)opens the API connection, not a shell. Entering (andclient.connect()) opens and authenticatesclient.conn; it no longer opens the first command and path transports, which on a host without SSH was a root web-shell session. Those connect when first used, so a wrong password now fails on entry, and an SSH or web-shell problem on the firstrun()/path()operation instead.- The environment variables are
PYTRUENAS_CREDENTIALSandPYTRUENAS_CALL_TIMEOUT.TN_CREDSandCALL_TIMEOUTare no longer read: rename them.PYTRUENAS_CALL_TIMEOUTtakes a fraction and is read when a connection is built, so it can change at run time, and a bad value raisesValueErrornaming the variable then — not atimport pytruenas, which reads no variable.connection.CALL_TIMEOUTis the constant60.0;TrueNASWSConnection(call_timeout=)left out isconnection.default_call_timeout().PYTRUENAS_CREDENTIALSis documented as a secret. - A connection URI's query is read, strictly, and
connection_urirenders the options.?verify=false,?version=,?executor=,?path=,?autologin=and?timeout/timeout_connect/timeout_readset the options; any other key (a misspelt?verfiy=0) raisesValueError, where it was dropped.connection_uri/str(config)now carries every non-default option (truenas+wss://nas?verify=false), so it round-trips; code that compared it to a baretruenas+wss://hoststring, or parsed it, sees the query.TrueNASConfigcompares and hashes byconnection_uri(it compared by identity): credentials andnameare not part of equality. - The
codegenextra is gone:pip install pytruenasrunsgenerate-typings, which builds the stubs in Python (byte for byte the same output) and no longer needs jinja2. Drop[codegen]from a requirement; pip warns about an extra that does not exist.pytruenas.codegen.jinja, its template andcodegen.Rendererare gone. pytruenas.modelsholds TypedDicts and type aliases only.Schema.get_type, everypython_declaration,TYPESand_typeddict_nameleavemodels.jsonschema; turning a schema into a type is private tocodegen. Stubs already generated keep working: they importpytruenas.models.jsonschemaforJsonValue/JsonObject/JsonArray/JsonNumber, which stay.- Renamed:
models.apidump.Api→API,models.apidump.Method→APIMethod,models.apidump.Event→APIEvent, andcodegen.BadApiName→codegen.BadAPIName, which is now apytruenas.TrueNASErroras well as aValueError. pytruenas.codegenexportsCodegenandBadAPINameonly.Namespace,Method,Parameter,PyDeclarationandSource, anddocstring(), are private to it.- Removed from
pytruenas.patch:zfs.PathLike, which wasUnion[str, Any](thezfsfunctions takestr | os.PathLike[str]), andsystemd.parse_unit();render_unit()andUnitConfigParserstay. pytruenas.utilsis gone, and the command line ispytruenas.cli. Change the imports:pytruenas.utils.query→pytruenas.query;pytruenas.utils.target→pytruenas.target;pytruenas.utils.apicache→pytruenas.apicache.pytruenas.utils.cmd(PyTrueNASArgs,with_targets,TargetsField,emit,emit_json,json_default,ENV,default_config_path) andpytruenas.main(main,PyTrueNAS) →pytruenas.cli.register_targetsis gone (with_targetsdeclares the positional) andjson_valueis private.pytruenas.utils.runpath→pytruenas.cli.runpath.pytruenas.cmd.*→pytruenas.cli.commands.*.pytruenas.utils.apihelp,pytruenas.utils.fieldsandpytruenas.utils.ioare private now;UnknownMethodandFieldErrorstay inpytruenas.exceptions.utils.cmd.CommandModuleis gone.- The console script is
pytruenas.cli:main; a wrapper that namedpytruenas.main:mainmust change. pytruenas.cli.main(argv=None, *, name=None, args=None, root=None):argvis the one positional.main("mytool", argv)becomesmain(argv, name="mytool").--cmdspathis repeated, not split:--cmdspath a --cmdspath b, not--cmdspath a:b, which cut a Windows drive letter off its path. The config file'scommandspathiscmdspath; the old key is no longer read.$PYTRUENAS_PATHis unchanged (os.pathsep-separated).generate-typings --api-cache FILEis--from FILE, which only reads: adump-apioutput, or a gzipped file from$PYTRUENAS_CACHE. Without it the definition comes fromclient.dump_api(), which already caches it per host and API version.- Uploads and downloads raise no
requestsexception. A TLS or socket failure is theOSErrorbeneath it (ssl.SSLCertVerificationError,ConnectionRefusedError, ...), as the API websocket raises for the same failure; a read that timed out isCallTimeout; an HTTP error status (a refused upload) isClientException. Code that caughtrequests.RequestExceptionaroundupload()/download()catches those instead. deployis not an MCP tool underPYTRUENAS_MCP=stdio: installing software on an appliance is for a person to choose. The command line is unchanged.client.dump_api()returns the API version the appliance runs, not every version it serves: the dump is pruned on the target before the transfer, so"versions"has one entry. Passall_versions=True(CLI:dump-api --all-versions) for all of them;generate-typings --api-versionandhelp --api-versiondo so themselves.apicache.load()/fetch()take the same keyword.redact_call_argsis imported frompytruenas.auth, not from the package root, where it was importable outside__all__.known_hosts=Noneis refused by hostctl when the SSH leg is built, not byTrueNASClientitself: a client with no SSH leg accepts it.pytruenas.patch.zfs.host_pathis gone: it wasos.fspath(), which is what to call.- The
repoextra is gone:deploy --source reponeeds nothing beyond the core install, which already carrieshostctl[deploy]. Drop[repo]from a requirement.
Changed
- Dependency floor raised to
duho>=0.7.7,<0.8. The command line names a failed target by its host name through duho's ownrun_targets(label=), andclient.logger.trace()is duho'sLoggerAdapter.trace; neither changes what is printed. - Dependency
anycfg>=0.15.0,<0.16(itscfgformat reads the CLI's config file); pytruenas no longer declarestomliitself, which anycfg brings below Python 3.11. - Dependency floor raised to
websocket-client>=1.3.3,<2. Below 1.2.2 the API websocket ignores the TLS contextverify=builds, so the OS trust, a CA bundle and a caller'sssl.SSLContextnever applied there; below 1.3.3 a refused connection leaks its socket.
Added
TrueNASClient(..., timeout=)/TrueNASConfig(timeout=)take hostctl'sTimeout:connectopens the websocket,readbounds one call and a side-channel read,totalisrun()'s default;config.timeoutsresolves them.verify=takes anssl.SSLContext, used as it is on the API websocket, the side channels and the web shell: a client certificate for a NAS behind an mTLS reverse proxy (e.g. fromsslcontext.create_context(certificate=, key=)orsslcontext.system_context()), or a pinned CA.- Dependency
truststore>=0.10.5,<0.11on Python 3.10+.
Fixed
- Uploads and downloads time out (the websocket's connect and read timeouts); a side channel that accepted the connection and then said nothing blocked them for good.
- A failed download's error message no longer carries the link's
auth_token. - Opening the web shell closes its websocket on every failure (a token that
could not be minted leaked it), reads a refusal of any shape (a string
errorraisedAttributeError), and declines when noconnectedmessage arrives instead of returning a session with no shell behind it. - Turning TLS verification off logs one WARNING per host.
TrueNASWSConnection.close()sends the close frame without waiting for the peer's answer: the wait read the socket the reader thread was blocked on, so a peer that never answered heldclose()for good.- A call's timeout bounds its send: a peer that stopped reading held
call(..., timeout=)-- and aclose()behind it -- forever once the socket buffers filled. The send is aborted and the connection closed (part of the request may be on the wire), and the call raisesCallTimeout. - A host, a
--logtotarget or an API name that is a Windows device name (nul,con,com1, ...) no longer names a device: the cache directory and the log file get a_prefix, andgenerate-typingsrefuses the name. pytruenas.patch.zfsdecodes a non-ASCII mount path fromfindmntas UTF-8 (écame back asé).- The plaintext-credentials warning knows every loopback address
(
127.0.0.0/8,::1, this machine's own), not only127.0.0.1. - An idle API connection stays open: it pings every 30 s
(
TrueNASWSConnection(keepalive=),connection.KEEPALIVE_INTERVAL). The middleware closes a websocket that sends nothing for 60 s, which dropped a client left idle during a long command and loggedconnection lost. - Behind an HTTPS proxy (
HTTPS_PROXY), uploads and downloads verify with the same trust as the websockets; they ignored a CA bundle and failed (or trusted certifi) while the API connected. generate-typingswrites LF stubs on every platform; on Windows they were CRLF, so one dump gave different bytes there than on Linux.
0.6.1 - 2026-10-20
Changed
-
Dependency floors that hold, measured by installing every floor on Python 3.9 and running the suite:
pathapi>=0.1.2(what hostctl 0.4.0 requires;0.1.0could not be installed with it),requests>=2.32.3(older releases drop the side channels' TLS context, so uploads and downloads ignoredsslverify), a newurllib3>=2.0.2,<3(1.26 fails on an IP-address target), andpytruenas[config]nowpyyaml>=6.0.1,<7. -
The package root imports its names explicitly instead of
from .auth import *, and listsCredentials,LocalAuth,ApiKeyAuth,TokenAuthandBasicAuthin__all__, so a type checker acceptsfrom pytruenas import ApiKeyAuth. Every name importable from the root before still is.auth,namespace,fs.tnasws,models.apidump,models.jsonschemaandutils.iodeclare__all__. pytruenas call --help,help --helpanddeploy --help(and their MCP tool descriptions) are one paragraph on what the command does, instead of 1.7-2.5 KB of design notes.help,call --fieldandgenerate-typingsread a method's parameters, return value, payload and enum values through one private module (pytruenas.utils._schema).apihelpandfieldskeep every public function and its signature; the generated stubs for the test fixture are byte-identical apart from the entries below.- The sdist no longer ships the repository-root
AGENTS.mdorbenchmarks/, and neither the sdist nor the wheel ships aCLAUDE*or*.local.*file at any depth, even from a tree copied without its.gitignore. The build requireshatchling>=1.27, and the project URLs name the documentation site. - The
devextra requirespytruenas[config,ssh,codegen]instead of copying their requirements; an install of.[dev]resolves to the same packages. - The test run treats a warning as an error and an
xfailthat passes as a failure (filterwarnings = ["error"],xfail_strict = true). - Releases: a
v*tag now builds only after the suite passes at every declared dependency floor on Python 3.9; the tag must equal the built version; the wheel is installed with no extras into a clean environment on 3.9 and 3.14, imported, checked forpy.typed,AGENTS.mdandREADME.md, its console script run and the suite run against it; and a broken strict docs build blocks the release. A pre-release tag (v1.0.0-rc.1) makes a GitHub pre-release only: no PyPI upload, no docs deploy. benchmarks/run.pynames a saved resultpytruenas-<version>-py<XY>-<platform tag>and recordsplatform_tag, so results from different machines for one release no longer share a file; results are written with LF line endings on every platform. New--results-dirand--compare RESULT(each median as a ratio to a saved result). The CI benchmark job measures the latest PyPI release and the checkout in the same job.- The
devextra installspyrightandmypy(from Python 3.10), which the generated-stub type check needs; without them that test skips.
Added
-
pytruenas.utils.cmd.with_targets(args_cls)andTargetsField: anArgsclass with the trailingtargetspositional declared as its last field. -
pytruenas.TrueNASError, the base of every error pytruenas raises at run time:ClientException(and with itValidationErrors,CallTimeout,ConnectionClosed,JobFailed) andAuthenticationErrornow derive from it. The package root exports all of them; each keeps its module and its existing bases. TrueNASHost(...)/TrueNASClient(...)has typing overloads that list the keywords it accepts (verify=,api_key=,executor=, ...), so an editor shows them; construction is unchanged.- A command module may define
validate_args(args), called once with the parsed arguments before any target runs; aduho.UsageErrorraised there is reported once aspytruenas: error: MESSAGEwith exit status 2. apicache.services()andapicache.service_methods()take an optionalversion=, asload()does, so a caller making several lookups reads the appliance's version once.tests/test_codegen_typecheck.pyruns pyright (3.9 and 3.14 targets) and mypy (3.10 and 3.14) over the stubs generated from the fixture and from a set of unusual schema shapes, plus a consumer whose valid calls must pass and invalid ones must be reported. It skips, saying which checker is missing, when pyright or mypy is not installed.Unit(..., restart_on_change=True)runssystemctl try-restartafter a changed unit is reloaded, so a running service picks up its new definition. Off by default: a restart interrupts the service.MiddlewareFiles.find_template(..., writable=False): every template it finds is on the read-only/usrdataset, and there was no way to ask for thezfs.writablewindow the write needs.- A guide page, "Patching a host", presenting
pytruenas.patchas a toolkit for building your own host patches, with a complete patch (a config file from a template, a systemd unit, a change layered onto a middleware template with its etc regeneration and service reload) and a stand-in client that runs it offline. The API header'spatchsection now lists every export with its signature and contract.
Fixed
- A lost connection says why: the reader logs
connection lost: <cause>(orconnection closed by the server) as a WARNING, and theConnectionClosedeach waiting call raises carries the cause as__cause__. Anyrecv()failure used to end the connection silently, so a dropped socket looked like a slow server. - Every MCP tool takes
targets. The hosts were added to each command's parser by hand, so they were missing from the tool schema: naming one was refused as an unknown argument, and every tool call ran againstlocalhost. call METHOD nas -- --field=valueand a RunPath flow's-- --applyno longer log "ignoring the argument(s) after '--'" while using them. A command module now says it reads the tail with a module-level_allow_passthrough_ = True(ascallanddeploydo); RunPath commands are never warned about.- A comma list of targets where a later part has its own scheme or user is
split there:
wss://nas1,wss://nas2waswss://wss://nas2, and innas1,root:pw@nas2the second host logged in as usernas1,root. - An empty
PYTRUENAS_SSLVERIFYno longer turns TLS verification off: it is unset, as an empty variable should be. A value that is neither a boolean nor an existing CA bundle path (here or as the config file'ssslverify) is an error naming where it came from, instead of a bundle path that verified nothing. generate-typingsno longer deletes a directory of hand-written stubs. A directory holding only.pyifiles was taken for an old output and replaced, and the default--pathwastypings/, where pyright looks for a project's own stubs. Only an absent or empty directory, or one marked.pytruenas-typingsby a previous run, is replaced, and the default--pathistruenasapi_typings/current.- Generated
_create/_update/_upsertstubs type**fieldswith the method's payload again (Unpack[UpdateUserUpdate]), notUnpack[bool](wrong since 0.5.1); the payload is found ascallfinds it. - Generated stubs import
sysunaliased, so mypy reads thesys.version_info >= (3, 11)guard and stops treating every optional key as required on a target below 3.11. - A
logging.LoggerAdapterpassed aslogger=(or assigned toclient.logger) no longer makes every API call raiseAttributeError: the per-call TRACE record is written withlogger.log(), which any logger has. client.path("/mnt", "/etc")names/etcon every leg. Its websocket (tnasws) leg joined the segments with/and operated on/mnt//etc, so on a host without SSH a read, write orunlink()reached a different file;fs.path()joins as the composite path does.- A reconnect after a login with a one-time code asks
otp_providerfor a new code instead of sending the spent one again (which the server refused, so the session never came back); without a provider it raisesAuthenticationError("OTP_REQUIRED"). - The shipped API header documented names that do not exist: a
target=keyword (the first parameter is positionalconfig),.host,connection.Clientandpytruenas._conn(the class isTrueNASWSConnection, which also takeslogger=),client.path()returning aTnasWsPath, and anUPSERTaction passed to_upsert's callback. It now points at the project URL rather than a file an installed copy does not have. - The suite passes with no extras installed: one SSH test lacked its
asyncsshguard. -
examples/simple_client_from_yaml.pymoved toexamples/scripts/, so--cmdspath examplesno longer warns that it is not a command. -
client.api.<method>(..., job=True)waits for the job and returns its result, as the upstream client does. It was accepted and dropped, so the call returned the job id while a delete or replication was still running and its failure was never seen. A failed job raisesJobFailed(the matchingOSErrorwith_ioerror=True). - On a host without SFTP,
path.chmod()andpath.chown()wait for the middleware job behind them and raiseOSErrorwhen it fails; they returned before the change was made and reported success for a job that then failed. - On a host without SFTP,
path.unlink()of a missing file raisesFileNotFoundError(it ranrm -f, which succeeds), andunlink()/rmdir()raiseIsADirectoryError,NotADirectoryError,OSError(ENOTEMPTY)or the errnormreports instead ofsubprocess.CalledProcessError, so pathapi'srm()andmove()handle them. - On a host without SFTP,
path.stat()follows a symlink, sois_dir()of a link to a directory is true;stat(follow_symlinks=False)andis_symlink()describe the link. Its result carries the owner and the access and change times (every file read as owned by root). download()(and soread_bytes()on a host without SFTP) checks the job that streamed the content and raises when it failed, instead of returning what arrived before the failure;read_bytes()raises it as anOSError.TrueNASHost("/var/run/x.sock"), a bare socket path the docs list, connects to that socket; it was read as a remote host with an empty name.HostConfig("truenas+unix://var/run/x.sock")keeps thevarsegment, andTrueNASConfig()is the local middleware socket rather than a remote target with no host.- The error for an unknown keyword to
TrueNASHost(...)lists only keywords it accepts; it offeredport,secure,socket_pathandapi_path, which are spelled in the connection string. install_sshcreds()puts the key on a copy of anssh=config you passed, rather than on your object, which another host may share.- A websocket-leg path on an IPv6 host renders its authority bracketed
(
truenas+ws://[fd00::1]/...), nottruenas+ws://fd00%3A:1/.... typing.get_type_hints()resolves the public annotations of the client, connection, auth, path and model modules on Python 3.9 and on every version; quotedX | Noneunions,typing.NotRequiredbefore 3.11, and names imported only for type checkers made it raise.--logto {target}.loggives each target only its own records under--parallel. Every target's file handler sat on the same loggers, so each file received the records of every target running at the same time.--loglevel websocket:DEBUG(orrequests,urllib3,asyncssh) takes effect: those loggers were set to WARNING after--loglevelwas applied.- A
./pytruenas.yamlfound in the current directory can no longer name command sources (commandspath), as documented. The check never recognised the implicit file on a real run, so itscommandspathwas imported. - A config file named with
--configor$PYTRUENAS_CONFIGthat does not exist stops the run with exit status 2 (config file not found: PATH) instead of running without its credentials and TLS setting. The implicit./pytruenas.yamlmay still be absent. deploy --source repo --mode pyzand a relativedeploy --pathare refused once, with exit status 2, before any client is built, instead of failing every target with status 1.helpmakes onesystem.inforound trip per invocation instead of three, cached or not.call METHOD nas -- --field=valuesends the fields untyped, with a warning, when reading the method's schema raisesKeyError; it failed the target with anUnboundLocalError.deploy nas -- COMMANDno longer logs a password contained inCOMMAND(Running: ...is redacted; the command runs as given).- Two fetches of one appliance's API dump (two processes, or one host named
twice) no longer overwrite and delete each other's file on the target: each
fetch dumps into a
mktempfile of its own and removes only that one. - A target that does not parse as a URI is refused with a message naming every
character to percent-encode in the credentials,
[and]included (%5B,%5D), and no longer quotes part of the password: urlsplit's own error ("Port could not be cast to integer value as '...'") was what the CLI logged. - The CLI guide describes the config file as it is: it supplies
credentials,sslverifyandcommandspath, nothing else. The API header givesmain()'s full signature and the$PYTRUENAS_CONFIGdefault, and the RunPath example's "TLS verification is on" line logs when it is. generate-typingsno longer aborts withKeyError: 'properties'when aget_instancereturns an optional object (anyOf[object, null]), a bare object or an array._getreads its filters through the union and returns that union; with no properties to filter on it is not emitted (logged at INFO), and the runtime helper's own signature applies._create/_update/_upsertare likewise skipped when the method has no object payload, instead of unpacking a call option.- Generated signatures no longer repeat a parameter name: two titles that
sanitize to one (
a-banda_b, or the same title twice) and a parameter titledselfproduced a stub that does not compile. A repeat gets a trailing_, and an untitled parameter isargN, ashelpshows it. - An array that admits no items (
items: false) islist[Never]whatever Python runs the generator. It waslist[typing.Never]from 3.11 and a bareliston 3.9, so stubs generated on 3.14 did not resolve for a 3.9 or 3.10 target.Nevercomes fromtyping_extensionsbelow 3.11, besideNotRequiredandUnpack. - Regenerating typings over a tree that is in use keeps the previous output
whole. On Windows a stub held open by an editor, or a shell inside the
directory, made the swap delete the old tree part-way (or entirely) and left
the new one in a
.typings.*sibling; the old tree is now renamed aside and deleted only once the new one is in place, and a failure removes the temporary directory. generate-typingswithout thecodegenextra fails before dumping the API from the host, not after (about 24 MB on 26.0).- A project that type-checks its generated
truenasapi_typings/no longer gets an incompatible-override error for every_create/_update/_upsert/_getstub: they narrow the runtime helpers on purpose and say so with# type: ignore[override]. - The typings guide and API header name the generated root class
V2600for v26.0.0 (notV26000), say--api-versiondefaults to the newest version in the dump, and no longer describe asrc/truenasapi_typings/package that does not exist. The README no longer claims the generator is validated against a full live dump; it says what the tests check. SystemFile(etc="a,b")regenerated onlya: both groups went to oneetc.generatecall, whose second parameter is a checkpoint. Each group now gets its own call.- Two units built from one
confdict no longer share settings. The second kept the first one'sExecStartandDescription, so it ran the other program; the dict passed in is no longer modified. - A patch whose follow-up fails (
daemon-reload,etc.generate, a service reload) is retried by the next apply. The file was already written, so the next apply found nothing to change and the follow-up never ran; the previous content is now put back before the error is raised. - A file rewritten by something else since a patch wrote it -- TrueNAS
regenerating an
/etcfile after a settings change -- is taken as the new original. Re-applying layered the patch onto the stale snapshot andrevert()put the stale content back, undoing that change. Each changing write now records digests beside the snapshot (<name>.baseline.applied). - A
.baselineor.absentfile found beside a target is no longer trusted blindly: a symlink there was read from, written through and restored into the target. Where the path backend haslstat(), it must be a regular file with one link, owned by root or by the target's owner, orPermissionErroris raised. The snapshot is written to a temporary sibling and renamed into place, so an interrupted copy is never taken for the original. - An unchanged write no longer takes a snapshot, and with
SystemFile(writable=True)no longer clears and restoresreadonlyon the dataset. A rewrite also makes fewer remote calls. zfs.writable(): when the block raised and the restore failed, the caller got the restore's error instead of the block's; it now gets the block's, and the restore failure is logged. An inheritedreadonly=onis restored by inheriting it again rather than set locally. A path that is not on ZFS is left alone (it failed inzfs get), anddataset_for()raisesValueErrornaming the filesystem type instead of returning its mount source. A window that never closed is marked on the dataset (pytruenas:restore-readonly) and closed by the next one.- Unit names and files systemd reads as intended: a dotted name (
my.agent) gets its.servicesuffix, the root mount unit is-.mount(was.mount), a list value inconfrenders as repeated keys (was a Python repr), and a value with a line break raisesValueError.parse_unit()reads repeated keys and sections and backslash continuations. MiddlewareFilesno longer runs a command on the host fromrepr(), andfind_file()honours a target's own baseline suffix instead of a hardcoded.baseline.TemplateTarget.apply_template()raises a clearTypeErrorfor keyword arguments with a text or path template (it said they were accepted).render_basic_template()substitutes in one pass: a value containing%{...}was substituted again, depending on the context's order.- The API header said
MiddlewareFiles.find_templatedefaults tobaseline=False; the default isTrue. examples/scripts/simple_client_from_yaml.pyverifies TLS certificates unless its config file setssslverify: false(or a CA file path); it turned verification off for every host.- README: links are absolute, so the
LICENSElink works on PyPI; the development setup creates a venv named for the interpreter it uses and installs with--only-binary=cryptography, which Windows ARM64 needs. - A connection string that does not parse names
[and](%5B,%5D) among the characters to percent-encode in the credentials. - Inside a CLI fan-out, a record logged through
client.loggernames its host once ([nas1] ...), not twice. - The
configextra is described as what it is: the CLI's YAML config file (credentials, TLS and command sources), not a targets file.
0.6.0 - 2026-10-19
Upgrade notes
pytruenas now runs on hostctl 0.4, which moves from pathlib_next to
pathapi. These change what a caller sees:
- Path types are
pathapipaths.pytruenas.fs.LocalPathispathapi.LocalPath;TnasWsPathandTruenasPathareuripath.URIPathsubclasses (distributionpathapi-uri);client.path()returns hostctl'sCompositePosixPathonpathapi. Code importingpathlib_next, or testingisinstance(..., pathlib_next.Path)against these, must usepathapi.Path. TruenasPathis gone, andpytruenas.fs.path(client, ...)of a remote client isclient.path(...): hostctl's composite path over the host's providers, whose SFTP leg is the host's own.TruenasPathkept a second SFTP leg (its own connection and login) only so a path built without a host could fall back to the websocket.fs.path(..., backend="ws")still builds aTnasWsPath, which is what thetnaswsprovider returns now; an unknownbackend=raisesValueError.symlink_to(force=, onremove=)is the functionpytruenas.fs.symlink_to(path, target, ..., force=, onremove=), which works on any path,client.path()included (the method form never reached a composite path's SFTP leg).- hostctl 0.4 names.
hostctl.host.SshConfigishostctl.SSHConfig(keyword arguments after the host); the "not started" error a web shell connection raises ishostctl.OperationNotStartedError, aConnectionErrorwherehostctl.provider.OperationNotStartedwas aRuntimeError. The rest of hostctl's renames are in its own changelog, "Migrating from 0.3". verify=Falseis the one way to skip the SSH host-key check.known_hosts=NoneraisesValueError; passverify=False(addsslverify=Trueto keep checking TLS).verify=Falsereaches the SSH leg asSSHConfig(verify=False), soverify=False, known_hosts=()no longer checks~/.ssh/known_hosts; keys named withknown_hosts=are still checked. For host keys checked with TLS off, passsslverify=False.client.last_selectionholds hostctlSelectionSteprecords, not dicts: readstep.provider/step.chosen, notstep["provider"].- The web shell runs hostctl's framed console protocol. Its executor,
webshell.WebShellExecutor, is hostctl'shostctl.console.ConsoleExecutorover the websocket, with no thread per command.WebShellSession.command_lines()andWebShellSession.execute()are gone: run a command withclient.run(...)orWebShellExecutorProvider(client).executor(command, ...), hostctl'ssubprocess-shaped call. What a caller can see: a connection lost after the command line was typed raisesConnectionError(it raisedOperationNotStartedErroruntil the command's first marker arrived, which let the host replay a command that may have run) and a command that exits the shell raisesConnectionErrorinstead ofSubprocessError; a failure before the command line was typed, including a failed write, is a decline, sorun()falls back to the next provider;stdin=subprocess.PIPEwithoutinput=raisesValueErrorand a file descriptor is read to EOF, where both raisedNotImplementedError; an unknownrun()option raisesTypeError. Uncaptured stdout is written per websocket frame, where it was batched up to 4 KiB or 0.1 s. deployruns onhostctl.deploy, which came from pytruenas's own bundler;pytruenas.utils.bundleis gone (importhostctl.deploy:bundle(),deploy(),SourceTree,Deployment), andBundleErrorishostctl.BundleError. The command line, the default path, the.pyzname,PYTRUENAS_PKG_ROOT/PYTRUENAS_PKG_NAMEand the digest markers are unchanged, so a previous deploy is recognised (and replaced once: the payload's digest differs). What differs: thedirlauncher adds no emptyPYTHONPATHentry (the working directory is never importable); each step on the target is an argv with--before its paths; a package name that is not a dotted identifier raisesValueError; and a missing extra raiseshostctl.MissingExtraError.- The SSH leg reads
~/.ssh/config, assshdoes, for commands and file operations alike: a host alias,HostName,Port,User,IdentityFileorProxyJumpthere now applies. A user or port written inshell=wins over the file; with no user in either the login is stillroot, and with no port 22 (install_sshcreds()always logs in asroot, where it installed the key). So.config.ssh.portisNoneand, for ashell=with no user,.config.ssh.usernameisNoneand.config.ssh.default_usernameis"root", where both read 22 and"root". For a leg that reads no personal file, passssh=SSHConfig(host, username="root", ssh_config=None). AnSSHConfigyou build yourself defaults to the local account name rather thanroot, as in hostctl 0.4, and itsssh_config=takes apathlib.Pathfor a file (a string is configuration text).
Added
username=andpassword=override the target's own credentials, inTrueNASClient(...)/TrueNASHost(...)andTrueNASConfig.from_target(): hostctl applies them over the URI's user and password (password=""is no password), soTrueNASClient("wss://admin@nas", password=pw)andTrueNASClient("wss://nas", username="admin", password=pw)work. The constructor now takespassword=,otp=,api_key=andtoken=asfrom_target()did; a password given both in the URI and aspassword=takes the keyword instead of raising.- A command that needs a missing optional extra fails its target in one
line, with status 125: the CLI reports
hostctl.MissingExtraError(FEATURE needs the 'EXTRA' extra: pip install "hostctl[EXTRA]") as hostctl's own command line does, instead of a traceback. pytruenas's own extras raise it too, namingpytruenas[...]:configfor a--configfile (reported bymain()aspytruenas: MESSAGEwith status 125, since it is read before any target),codegenforgenerate-typings, andsshfor deriving a public key with neithercryptographynorasyncsshinstalled. Each was a plainImportError;MissingExtraErroris one, soexcept ImportErrorstill catches it.
Changed
hostctl[deploy]>=0.4.0,<0.5replaceshostctlandpackagingin the core dependencies, sodeploy(both sources) works out of the box; it addspathspec. Therepoextra is that same requirement, anddevno longer listspathspec/tomli.- Dependencies:
hostctl>=0.4.0,<0.5,netimps>=0.4.0,<0.5,pathapi>=0.1.0,<0.2andpathapi-uri>=0.1.0,<0.2, replacinghostctl<0.4,netimps<0.4andpathlib_next[uri]. Thesshextra ishostctl[ssh]>=0.4.0,<0.5alone: pytruenas no longer importssftppath. A deploy payload carriespathapiandpathapi-uriwhere it carriedpathlib-nextanduritools. - One SSH connection and one login per host. Commands and SFTP paths
share the connection hostctl opens (it was three with
TruenasPath's own SFTP leg), sossh_config,connect_optionsand the connect timeout apply once. - A
TnasWsPathwrite stream whosewithblock raises uploads nothing; it uploaded what had been written. - A relative path on a remote target, and
client.path()with no segments, stay rooted at/on every leg (hostctl 0.4's SSH hosts read them against the login directory instead). install_sshcreds()swaps the providers through hostctl'sresolve_providers()andreplace_providers(), which closes each replaced transport once.- The provider names
executor=/path=take are hostctl descriptors (TrueNASHost.provider_resolvers), andTrueNASHost.default_providers()answers which a target gets. An unknown name raises hostctl'sValueError(unknown executor provider descriptor: 'shh' (known: ...)), and the module constantspytruenas.host.EXECUTOR_NAMESandPATH_NAMESare gone. - The fallback WARNING comes from hostctl:
[nas] run() fell back from ssh to webshell: ssh: <reason>, once per skipped provider, and now also for a path operation served bytnaswsaftersftpdeclined. Its message no longer suggests aknown_hostssetting. -
client.sshis hostctl'sSSHExecutorProvider.connection: the same asyncssh connection, bound to hostctl's own event-loop thread (see the API header for how to run its coroutines). -
Dependency floor
duho>=0.6.0,<0.7→duho>=0.7.3,<0.8. The command line is unchanged: its help, flags,--versionand exit statuses are the same as under duho 0.6. Internally, field metadata is writtenMeta(...)(duho removedNS), and the RunPath base's_initparser_forwards its build keywords by name, as duho now requires. A derived tool that importsNSfromduhoor overrides_initparser_(cls, parser, *args, **kwargs)must make the same two changes. - Dependency floor raised to
duho>=0.7.5,<0.8.main()now goes throughduho.run(..., exit="parse"):--help,--versionand usage errors still raiseSystemExitfrommain(), and every other outcome is still returned as anint. New for the program: Ctrl-C ends with status 130 and a broken pipe (the reader of standard output went away) with status 1, both without a traceback. - The test suite restores the RunPath registration with
duho.runpath.preserve_registration()and reads it withduho.runpath.registration(), and tests the step arity withduho.runpath.step_wants_context, instead of duho's private names.
Fixed
client.path(..., backend=name)uses that provider for every operation. It chose the provider that built the path, and each operation then selected again from the first capable one: on a host with an SSH leg,backend="tnasws"wrote over SFTP.- A lost connection during a patch template's
chmodstops the write.FileTargettook anyOSErrorfromstat()/chmod()for "this backend has no modes" and wrote the content anyway, which a reconnect could leave at the wider mode thechmodwas meant to prevent.
0.5.4 - 2026-10-01
Fixed
-v/-qnow change thepytruenaslogger. The root command had no declared name, so the verbosity flags set the level on a logger named after the class (PyTrueNAS) that nothing logs to. The command now declares its name,pytruenas, so-vmakes the package's own logging more verbose and-qquieter. The CLI's usage and--versionoutput are unchanged.
0.5.3 - 2026-09-28
Changed
- Dependency floor
duho>=0.5.2,<0.6→duho>=0.6.0,<0.7. Every documented[minor]break was checked against actual pytruenas usage and against the full test suite on both supported interpreters; none apply, and no pytruenas code changed. Since 0.6.0,PYTRUENAS_MCP=stdiomakespytruenasserve MCP over stdio instead of its normal CLI dispatch, exposingcall/query/deploy/etc. as MCP tools — this isduho's own opt-in trigger, and pytruenas does not opt out of it. See the "MCP" section of the CLI guide.
0.5.2 - 2026-09-25
Added
pytruenas help <method>— CLI-style help for any middleware method, read from the appliance's own API definition: every field with its type, whether it is required, its default, the values an enum allows, and whether the method is a job. Alsohelp <namespace>to list a namespace's methods andhelp allfor the index of namespaces;--jsonemits the definition slice for tooling.
It asks the API per namespace (core.get_services and
core.get_methods(<service>)), which requires no roles and no command
access — so it works for an API-key account with no shell, no SSH and no web
shell. --dump reads the full middlewared --dump-api definition instead,
which needs command access and is only necessary for --api-version (the live
API cannot describe an older version).
- call takes fields instead of hand-written JSON. After a literal --,
--field=value and bare field=value are interchangeable and typed from the
method's schema:
pytruenas call user.create nas1 -- --username=svc --full_name='Svc' --group_create=true
Booleans, integers, nullable fields, arrays (a comma list or a repeated flag)
and nested objects (--options.acl=true) are coerced; --name:json=... and
--name=@file are the escape hatches. An unknown field name or a bad enum
value is an error naming the alternatives, raised before anything is called.
--no-schema skips the definition, --refresh-api re-fetches it.
- examples/provision/ — a dry-run-by-default provisioning flow (users, DNS,
datasets and encryption, shares and services), plus examples/README.md
indexing every example.
- $PYTRUENAS_CACHE — where the fetched API definition is cached (default: the
platform user cache directory), keyed by host and API version, with
per-namespace answers in their own small files.
Fixed
dump_api()could not work on a host with no SSH leg. It capturedmiddlewared --dump-apifromrun()'s stdout, and that output is 24,103,000 bytes on 26.0 — enough to lose the web shell's connection every time, i.e. exactly the host the web shell exists for. The dump is now built and gzipped on the target (2,025,240 bytes, 11.9x) and fetched over SFTP when there is an SSH leg, otherwise in 256 KiB chunks through the command channel, with the transfer checked against the target's own sha256. Thefilesystem.getHTTP side channel is deliberately not used: it truncated both the 23 MB file and the 2 MB one.dump_api()caches its result per host and API version, so a second call (or a secondhelp) does not spend ~75 s rebuilding it.cache=Falseandrefresh=Truecontrol that.
Changed
query -fand the newcallfields share oneNAME=VALUEparser (utils.cmd.split_assignment), so a malformed one is reported the same way everywhere.
0.5.1 - 2026-09-23
Changed
- Tokens for the web shell and the HTTP side channels are now origin-bound
and single-use. pytruenas passed
match_origin=Falsewhere the server's own default isTrue, so a leaked token was usable from anywhere; both flags are on now. A setup whose HTTP request leaves from a different IP than its websocket (multi-homed, or proxied) would be refused where it used to work. - Credentials sent over plaintext
ws://to a non-loopback host log one warning per host. Not a refusal:ws://is legitimate for a loopback target or an already-private link. - Dependency:
hostctl>=0.3.2,<0.4(and thesshextra). 0.3.2'sredact_urileaves a well-formed URI alone, which this package's redaction and its "does this target parse?" check both rely on. call/query/dump-apiattribute their output when there is more than one target: each line becomes{"target": "<host>", "result": <result>}(credentials removed). A single target still prints the bare result, so existing one-host scripts are unaffected. Interleaved unattributed lines could not be matched to a host at all.- A command module's
argsis a per-target copy carryingargs.target, and a client the CLI built is closed when the command finishes. A client returned by aninithook is left alone — it belongs to the hook. - A config file found rather than asked for no longer supplies
commandspath. An implicit./pytruenas.yamlbelongs to whatever directory the CLI runs in, and that key names code to import. Pass--config(or set$PYTRUENAS_CONFIG) to load commands from a config file; every other key of an implicit file is read as before. - A keyword argument to a middleware call now raises
TypeError. Those methods take positional parameters; a keyword was swallowed and logged at debug level, soclient.api.user.query(filters=[["uid", "=", 0]])sent no filters and returned every row — which then drove an update. Pass parameters in order (query([["uid", "=", 0]])) or use the_query/_gethelpers, which take keywords by design.timeoutand the upstream-compatibility names (job,background, …) are still accepted.
Fixed
- A deployed copy could not report its own version. A zipapp carries no
installed metadata, so
importlib.metadataraisedPackageNotFoundError:__version__read0.0.0.dev0andpytruenas --versionanswered with a usage error (exit 2) instead of a version. A bundle now carries a minimal.dist-infofor every distribution in it — the realMETADATAwhere it can be read — so--versionworks in both layouts and the versions of everything bundled are visible on the target.--source repocarries none: there is no installed metadata to copy, and inventing a version would be a lie. - A unix socket path was mangled by the URI layer.
_targetinterpolated it raw, so/run/a?b/x.sockrendered as/run/a; aunix://URI's path is percent-decoded now, as a URI path must be (unix:///run/a%3Fb/x.socknames/run/a?b/x.sock, where the literal%3Fspelling would open a different file). - A client dropped without
close()leaked its websocket and reader thread for the life of the process; it is closed when the client is collected.close()(or awithblock) is still the right way — the finalizer is a safety net, not a substitute. - The SFTP leg built a fresh backend on every call, and pathlib_next keys
its connection cache on the backend object — so nothing ever hit that cache:
five
readlink()calls opened five concurrent SSH sessions and closed none (measured). One backend per client now, keyed on the SSH settings soinstall_sshcreds()cannot leave one authenticating with old credentials. install_sshcreds()swapped the provider selectors one at a time without the lifecycle lock and never closed the transports it replaced, leaking the old SSH connection.- An OTP written the documented way was mis-parsed.
"root:pw\notp:123456"produced the token"otp:123456"; the raw credential string is now split by the samehostctl.host.parse_credentialsthe URI path already used, so both spellings mean the same thing. A bare token on its own line still works. - Generated stubs describe
enum/constand maps. Both rendered asJsonValuewhile the appliance's dump uses them 6413 and 6420 times; they areLiteral[...]now (withNoneas a union member), and an object whoseadditionalPropertiescarries a schema isMapping[str, T]rather than "some object". On TrueNAS 26.0 that is 1275Literalannotations where there were none. - A middleware method whose name is also a
Namespacemethod is no longer emitted.core.subscriberedefined the realNamespace.subscribe, so a checker acceptedns.subscribe(event)— which at runtime calls the real method with a positional callback — and rejected the correctns.subscribe(). Reach the middleware one asns(_method="subscribe", ...), as the runtime documents; generation logs which methods it skipped. - Generated signatures offered the call options positionally.
_method,_ioerrorand_filetransferappeared as ordinary parameters while the runtime takes middleware parameters positionally and now rejects stray keywords — following the stub shifted a real parameter. They are keyword-only (after a*), and_timeout/_tries, which were missing entirely, are included. - Generation crashed on valid schemas. A list
type({"type": ["string", "null"]}) raisedNotImplementedErrorand{"items": true}aTypeError, each aborting the whole run; they render as a union and as a list of anything. An unknown type name degrades toJsonValueinstead of failing. - A docstring ending in
"""produced a stub that would not parse. Every double quote is escaped now, not just an interior run or a single trailing one. - Names from the dump became invalid identifiers. Only
.,_and-were treated as word boundaries, soacl(nfs4),smb:share,bracket[x]and a leading digit went straight into a class name. - Two different shapes could take the same TypedDict name, and the later definition silently replaced the earlier — so every reference to the first pointed at the wrong shape. The second gets a numbered name.
- A string default changed type. It was parsed with
literal_evaland used verbatim when that succeeded, so a schema default of"0"became the integer0and"true"/"None"changed meaning. Only this package's own synthetic defaults are Python source now (codegen.Source). JsonValue/JsonObject/JsonArraywere plain strings, so every stub annotation naming them resolved to astrvariable rather than a type.generate()renamed the methods of the caller's dump and overwrote its return titles, so generating twice from one dump gave different output.get_instancewithout anidparameter raisedUnboundLocalError(or reused the previous namespace's id type).- A name from the dump could write outside the output directory; such a
path now raises
codegen.BadApiName. - The stub template could not be read from a zipapp — the shape
deploy --mode pyzinstalls — because it was loaded by filesystem path. - The
codegenextra pinsjinja2>=3.0,<4, per the project's own convention. - A deployed bundle was missing
uritools. The dependency closure dropped a requirement's own extras, and pytruenas depends onpathlib_next[uri], whose extra requires it. The appliance does not ship it (measured on 26.0.0-BETA.1, 386 distributions), sopathlib_next.uri— which every remote path is built on — could not import on the target. A requirement's extras are followed now, and the root's extras are no longer applied to every distribution in the closure. - Environment markers were evaluated against the machine running
deploy, not the target, so a platform- or version-gated dependency was decided by the wrong environment. The probe reports the target's marker environment (bundle.parse_probereads it; an older probe that prints names only still works) and the closure is resolved with it. deploy --source reposhipped things a clone would not..gitwas matched only as the first path component, so a nested checkout's history was included; a nested.gitignorewas ignored entirely (git applies one per directory); and a symlink or junction pointing outside the repo was followed, shipping whatever it pointed at.- A missing
--ignore-filesilently disabled all filtering — the fastest way to ship a gitignored secret. A file the caller names and that is not there is now an error; the defaults stay forgiving. - An unparseable line in a
requirements.txtaborted the whole deploy, though that list is only printed as a heads-up; it is skipped with a warning. Backslash continuations and inline comments are read correctly. - Arguments after
--were parsed twice. pytruenas splitargvitself and carried the tail in a module global, duplicating duho's own_passthrough_capture;deployreads it off the parsed instance now. A tail that no command reads, or a target placed after the separator (where it becomes part of the remote command and the run silently falls back tolocalhost), is reported instead of dropped. - A RunPath step whose third parameter has a default — or which takes
*args— was mistaken for the 3-argument module-command shape and had its arguments swapped by the adapter. Only required positionals count now. - Command discovery read global options off the default app root, so a derived tool's own options were invisible to it, and the RunPath base registration was skipped for the default args — leaving a derived tool's base registered process-wide for whatever ran next.
- Concurrent targets shared one
argsobject. Per-target state a command wrote on it (andargs.target, which did not exist at all) belonged to whichever target ran last;inithooks had no way to know which target they were building a client for. - Per-target clients were never closed, so every target leaked its
websocket and reader thread for the life of the process — under
--parallel, all of them at once. --logtofiles missed the records they exist for. The handler was attached to the command's logger only, so a transport fallback warning or a connection error from the library never reached the per-target file.- Concurrent result lines interleaved mid-line (
print()is two writes), so neither line parsed as JSON. Results are written through one lock. - A set in a result serialized as its Python repr (
"{1, 2}"), and a datetime as whateverstr()produced; they are a sorted list and an ISO timestamp. generate-typingswith an unknown--api-versionraisedSystemExitinside a fan-out worker, aborting the whole run and discarding the other targets' exit codes; it fails that target with exit code 2.- A discovered command module with no
run(client, args, logger)was offered in--helpand failed on every target; it is skipped with a warning. - Target expansion split passwords. Commas and
[A-Z]/[0-9]ranges were expanded over the whole target, sowss://root:pw,with,commas@nasbecame three targets (two of them nonsense) androot:secret@nas1,nas2gave the second host no credentials at all. Only the host part is expanded now, and credentials are carried to every expanded target. - A config file was read with the console's encoding, so a UTF-8 file with a
non-ASCII value raised
UnicodeDecodeErroron Windows; a config whose top level is not a mapping is now ignored with a warning instead of crashing. - A string
commandspathin a config file was iterated per character, turningmycmdsinto six one-letter sources. A relative entry that exists beside the config file now resolves against it rather than against the process's working directory. - A command source that is neither a directory nor an importable name crashed the CLI; it is skipped with a warning, as a bad command already was.
- A timestamp came back late by its own milliseconds: the sub-second part
was added twice, so
…T03:42:00.600Zdecoded as…T03:42:01.200Z. Negative timestamps (before 1970) decode correctly now too. - A response that could not be decoded was dropped, leaving its caller to
wait out the whole timeout (or forever, with
timeout=None) for a reply that had already arrived. It fails that one call withClientException(errno=EPROTO); the connection stays usable. _update/_upserton an id with no record raisedAttributeError: 'NoneType' object has no attribute 'get', naming neither the record nor the collection; it raisesFileNotFoundErrornaming both.- A
True/Falseresult was treated as a job id (boolis anint), so the client waited on "job 1" and returned that job's result instead of the answer. - A no-op update rewrote middleware properties forever.
pool.datasetreports a property as{"value": "10G", "parsed": 10737418240, …}while a caller sets"10G", so the field looked changed on every call and_upsertreported a change that never happened. A partial dict is now compared on its own keys only. utils.query.MISSINGis an instance, matchingEXCLUDE; the untested, unusedutils.query.merge()is gone.
0.5.0 - 2026-09-23
Added
client.wait(job_id, callback=None, timeout=None)returns a middleware job's result once it has finished and raisesJobFailed(with the job record and errno) when it failed;callback(job)receives every change of state and progress.client.job_updates(job_id)yields the same updates as a generator.upload(),download()and_create/_update/_upsertaccept a callablewait=for progress.TrueNASClient(..., verify=False)— one switch that turns off every check the client makes: TLS certificate verification (websocket, HTTP side channels, web shell) and SSH host-key verification (commands and SFTP).sslverify=andknown_hosts=override it individually.sslverify=accepts a CA bundle path (a PEM file or a hashed directory), trusted instead of the OS store. Withsslverify=True,$SSL_CERT_FILE,$REQUESTS_CA_BUNDLE,$CURL_CA_BUNDLEor$WEBSOCKET_CLIENT_CA_BUNDLE(first one set) does the same.TrueNASClient(..., known_hosts=...)— the host-key policy for the SSH leg built fromshell=or byinstall_sshcreds():()(default) checks~/.ssh/known_hosts,Noneskips the check, a path or list names the file(s). An explicitssh=SshConfig(...)keeps its own setting.run()logs a warning, once per host, when it falls back from its first transport (e.g. SSH unreachable or its host key untrusted) to the next.
Changed
- The CLI verifies TLS certificates by default.
pytruenas <cmd> ... nassent its credentials over a connection whose certificate it never checked (sslverifydefaulted toFalse, the opposite of the library). Turn it off per run with-k/--insecureor--no-sslverify, or setsslverifyin the config file or$PYTRUENAS_SSLVERIFY(false, or a CA bundle path). A self-signed NAS now fails until one of those is given. - The CLI takes credentials from
$TN_CREDSor the config file when the target string carries none — before, a remote target with no userinfo connected unauthenticated and every call failed withENOTAUTHENTICATED. A config file may setcredentials:to a connection string or a mapping. - The SFTP leg now verifies the server's host key, as the SSH command leg
already did. A NAS missing from
~/.ssh/known_hostsis no longer silently trusted: add it (ssh-keyscan nas >> ~/.ssh/known_hosts) or passknown_hosts=None. Without either,client.path()operations are served by the websocket leg instead of SFTP. env=inrun()now adds to the target's environment on a local target too, instead of replacing it (env={"A": "1"}keepsPATH).- Dependencies:
hostctl>=0.3.1,<0.4,netimps>=0.3.0,<0.4,pathlib_next[uri]>=0.9.10,<0.10,requests>=2.0,<3,websocket-client>=1.0,<2; thesshextra is nowhostctl[ssh]>=0.3.1,<0.4.
Fixed
--configcrashed on every use. The field was typeddict, so it got duho'sKEY=VALUEaction and argparse raised before any command ran ('WindowsPath' object is not iterable).python -m pytruenasalways exited 0, discardingmain()'s exit code, so no shell or CI step could tell a failed run from a successful one.query -fvalues were always strings, so a filter on a numeric or boolean field silently matched nothing (-f uid=0). They are decoded as JSON with a string fallback, the same ascall -p; a filter that is notKEY=VALUEis now an error instead of a filter on"".--logtocould not create its file on Windows: both{isodate}and a{target}with a port carry colons. The substituted values are sanitized (the template's own separators are left alone).- A command's
successhook ran even when it returned a non-zero exit code. - Generated stubs made every property required. A schema with no
requiredlist requires nothing (JSON Schema), so update payloads, query-options and_getfilters all demanded every field. They also usedtyping.NotRequired/Unpack, which do not exist before 3.11 — the stubs now import them fromtyping_extensionsbelow that — and the root module exportsCurrent, so the documentedTrueNASClient[Current]resolves. deploy's "already current" skip never fired. Both payloads carried per-build timestamps (the tar from the filesexport()had just copied), so the digest changed on every build and every deploy re-uploaded everything. Archive entries now use a fixed timestamp and mode.packagingis a declared dependency. It was imported with a fallback that could not work — it fed a whole requirement line toimportlib.metadata.distribution()— so without itdeploysilently shipped a payload missing duho, hostctl and netimps.- Keyword credentials produced local-socket auth.
Credentials(api_key=…),(token=…)and(username=…, password=…)all returnedLocalAuth, so the client connected without authenticating and every call failed withENOTAUTHENTICATED. The factory now dispatches on the keyword names; an unknown name still raises (with the values masked). upload()never checked the HTTP status: a refused upload surfaced as aKeyErrornaming neither the status nor the reason. It raises for the status, and a response with no job id raisesClientExceptionquoting it.- A retry could run a call twice. After a dropped connection, the default
one retry replayed the request — including
create,deleteand job starts — even when it had already been sent and the server may have run it. Only a call that never reached the socket is repeated now; the rest raiseConnectionClosedwithsent=True. The retry also closes the connection it drops instead of leaking its socket and reader thread. - One malformed message wedged the connection. A notification whose
paramswas not a dict killed the reader thread outside its cleanup, so the connection was never marked closed, never reconnected, and every later call waited out its timeout (measured). The reader now logs and drops such a message, and always runs its cleanup. - A connection the server closed kept its socket.
close()returned early once the reader had noticed, leaking the file descriptor for the life of the process; the socket is now shut down in both paths, once. - Opening a connection had no timeout, so an unresponsive host blocked the
caller indefinitely (
connect_timeout, default 30 s, onTrueNASWSConnection). The reader is unaffected: an idle connection stays open. - A call registered just as the reader failed the outstanding calls could wait
forever (with
timeout=None); the check and registration are now atomic. - A parameter JSON cannot encode raised
ClientException(ECONNABORTED)— reported as a dropped connection, and so retried on a new one — instead ofTypeError. - A blocking call made from a subscription callback deadlocked the reader
until the call timed out; it now raises
RuntimeErrornaming the fix. - A wrong password or API key logged in "successfully". The legacy
auth.login/login_with_api_key/login_with_tokenanswer a refusal withFalse(measured on 26.0), which was ignored: every later call failed withENOTAUTHENTICATEDfar from the cause.login()— and so the first call underautologin— now raisesAuthenticationErrorand closes the connection. - A reconnect did not log in the way the session had. After a dropped
connection,
connused the config's credentials underautologin, or none at all without it, so a session opened withlogin(other_creds)orlogin_ex=Truecame back as someone else or unauthenticated. It now repeats the last successfullogin()call. - Concurrent first use of a client opened one connection per thread and leaked all but one; opening the connection is now locked.
- Secrets reached logs and messages. The per-call TRACE record printed raw
arguments, so
auth.loginpasswords, API keys and the private keyinstall_sshcreds()uploads were written to any log at TRACE; arguments toauth.*are now reduced to type names and secret-looking dict keys masked at any depth (and nothing is formatted unless TRACE is on).redact()— behind the CLI's per-target label and--logtofile name — returned a scheme-lessroot:pw@naswhole. ACredentials(...)error masked only a fixed list of names (nototp_token,private_key); every value but the user name is masked now. - A connection string that does not parse as a URI is refused, with a
message that does not quote the credentials it just rejected (an unencoded
/,?or#in a password ends the authority early, souser:secretis read as host:port). A URI that does parse is taken at face value: an@in a path is a path, and nothing can distinguish it from a password someone forgot to encode — the same contract hostctl states forConnectionStringandredact_uri. - The TLS legs trusted different certificates. The API websocket verified
against the OS trust store and the HTTP side channels (upload, download, file
reads over the websocket leg) against
requests' certifi bundle, so a certificate trusted by the OS — a private CA — connected the API and failed every download withSSLError(measured). All legs now share one context: the OS store, or the CA bundle above; certifi is no longer consulted. - A non-default API port (
wss://nas:8443) was dropped for every HTTP side channel — uploads, downloads, file reads over the websocket leg and the web shell went to port 443, with their auth tokens. On the NAS itself those URLs had no host at all; they now use middlewared'shttp://127.0.0.1:6000. - An IPv6 literal target (
https://[fe80::1]:8443) failed on first connect; a custom unix socket path (unix:///run/x.sock) was ignored in favour of the default socket. - Every
wait=Truereturned before the job finished.core.job_waitis itself a job, so calling it returned a new job id at once (0.4 s, measured on 26.0.0-BETA.1);upload(),download(buffered=True)and the_create/_update/_upserthelpers therefore returned early, a failed job went unreported, and a helper whose call ran as a job returned the waiter's job id instead of the record. They now wait on the job's state. - Web shell (
run()without SSH) rewritten; measured against TrueNAS 26.0.0-BETA.1's zsh before and after. - A captured
stdoutcontained the terminal's echo of the wrapped command and the completion marker (run("echo hi")returned 242 bytes); output with no trailing newline was dropped; the first command on a new session could capture the login banner. Captured output is now exactly the program's bytes. - A
^Cinsideinput=ended the here-document and ran the rest of the payload as a root command, andstdin=bytes the program did not read were executed by the shell. The command and its input are now sent base64-encoded and input is delivered through a temporary file; nothing typed is interpreted by the terminal. input=combined with the default stderr capture never ended and hung until the timeout; stderr lines could be lost under bash or leak into the next command.- Output handling was quadratic: 64 KiB took over 240 s. 1 MiB now takes about 2–5 s.
timeout=Nonemeant 120 s; it now waits indefinitely. A timeout raisedSubprocessErroror took up to twice as long; it now interrupts the command and raisesTimeoutExpired(withorphaned) on time.stderr=sent to a file while stdout was captured was dropped, andcapture_output="stderr"wrote stderr into the stdout target.CompletedProcess.argsandCalledProcessErrorcarried theinput=payload; they now carry the command only.bytearray/memoryviewinput arrived as itsrepr.- Opening the session had no timeout.
Changed
- Web shell:
stdin=is read to EOF and delivered likeinput=instead of being pumped into the terminal while the command runs; with no input, stdin is/dev/null(a prompting command gets EOF instead of hanging), andstdin=subprocess.DEVNULLis accepted. stderr is captured in any POSIX login shell (no longer only bash/zsh/ksh) but a separate stderr target receives it when the command finishes;stderr=subprocess.STDOUTmerges live.
Fixed (continued)
patchFileTarget(baseline=True): the snapshot of an existing file was created with the default mode, so a copy of a0640/etc/shadowsat beside it at0644;revert()of a deleted target recreated it at0644too. Both now keep the original's mode, and a new file written withmode=is chmod'ed before its content is written rather than after.patchFileTarget(baseline=True)on a file that did not exist yet: the second write snapshotted the target's own first output as the "original", so a layering template duplicated its additions on every apply andrevert()restored patched content. The first write now records that the original was absent (a.absentmarker);read()then raisesFileNotFoundErrorfor that file, as it did before the first write.TruenasPath.rename()ignored the destination's host:fs.path(nasA, x).move(fs.path(nasB, y))renamed the file on nasA underyand reported success, and withoverwrite=Truealso deleted nasB's copy.rename()now refuses a destination on another host, somove()copies and deletes.rename()returns the newTruenasPath(it returned the SFTP leg's own path object).- Moving or copying a remote file onto a separately built path for the same
file (
fs.path(c, x).move(fs.path(c, x), overwrite=True)) deleted it; a recursive copy into its own subtree recursed untilRecursionError. Such paths are now recognised as the same file. mkdir()on the websocket leg created world-writable (0o777) directories, because the middleware applies the requested mode without a umask; the default is now0o755.mkdir(parents=True)failed withClientException [Errno 2]instead of creating the parents: a middleware error of the form[Errno N] ...is now mapped to the matchingOSErrorsubclass.deploy --mode dirreplaced whatever directory--pathnamed: an existing directory that was not a previous deploy was moved aside and deleted, and the run reported "Deployed". A--pathwithout a previous deploy's digest marker is now refused (FileExistsError) in both modes,--forceincluded.- A
dirdeploy interrupted between its two renames left the last good tree only at<path>.old, which the next deploy deleted first. It is now restored. deploy --mode pyzoverwrote the live zipapp in place; it is now written beside it and renamed over it. A--pathwith a trailing slash no longer stages inside the target.generate-typings(Codegen().generate()) deleted the whole--pathdirectory before rendering — including a.gitor any other files — and a render error or a missingjinja2then left it empty. It now renders into a temporary directory and swaps it in on success, and refuses a directory that holds anything but stubs (FileExistsError). A missingjinja2raises anImportErrornamingpytruenas[codegen]._upsert()/_update()with a selector made only of!-prefixed names (("!uid",)) queried the whole collection and updated its first record (_upsert(("!uid",), username="bob", uid=1001)renamed root). It now raisesValueError._update()/_upsert()silently dropped any field set toNone, so a nullable field could not be cleared (_update(2, email=None)sent nothing).- The README and guide
_upsert("username", ...)example passed the field name as a bare string, which is read as a record id and raises; the examples now use("username",). - With SSH unreachable or its host key untrusted,
client.path()operations raised the connection error instead of falling back to the websocket leg, and everyrun()re-dialled SSH before falling back (~9 s per call). copy()/move()between two NAS hosts were refused as "Source and target are the same file" when the same path existed on both (the dependency set 0.4.6 resolved to on a fresh install).- A
rename()/symlink_to()destination containing?,#or%landed on a truncated or re-decoded name (rn?b.txt→rn) on the SFTP leg. p / "name"read the name as URI syntax, sop / "cache?v=2"namedcache, and a recursiverm()could delete the wrong file for such names.
Earlier releases
0.0.0 to 0.4.6 are in CHANGELOG-0.x.md.