Skip to main content

tues

Run commands on remote hosts over SSH, with transparent sudo elevation, and move files over SFTP. Available as a Rust library (blocking and async), a Python package (blocking and asyncio) and a fan-out command-line tool.

Key points:

  • Sans-IO core. The exec protocol (ExecMachine) is a pure state machine: bytes and channel events in, effects out. The tokio driver (tues-async) and the blocking facade (tues-sync) share it.
  • Login user vs. user. A session logs in as the login user and runs commands as the user, via sudo when they differ. The sudo prompt is intercepted in both plain and PTY mode; the prompt, the password, and any Sorry, try again. noise are removed from the conversation, so binary data on stdout stays intact.
  • Pluggable, memoizing password manager. Passwords for login, sudo and key passphrases are requested through the PasswordManager trait. The default prompts once on the TTY and caches per host and login user; swap in your own.
  • OpenSSH configuration. ~/.ssh/config (Host, Match all, Include, ProxyJump, IdentityFile, User, Port, StrictHostKeyChecking, UserKnownHostsFile, …) is honoured, and everything can be overridden from the API.
  • Standard-library-shaped APIs. In Rust, Command, Child, Output, ExitStatus, Stdio behave like std::process. In Python, Session.run, Popen, CompletedProcess, PIPE/STDOUT/DEVNULL, check_output, … behave like subprocess (and asyncio.subprocess).
  • Pure Rust SSH via russh; no libssh2 or OpenSSL to link.

Workspace layout

Crate Purpose
crates/tues-core Sans-IO state machine, sudo filter, Command, options, ssh_config parser, password manager. No I/O.
crates/tues-async tokio driver on russh: connect, auth, known_hosts, ProxyJump, exec pump, SFTP.
crates/tues-sync Blocking API; a private tokio runtime drives tues-async.
crates/tues Facade crate: blocking API at the root, async API in tues::aio.
crates/tues-cli The tues binary.
crates/tues-python PyO3 extension module (tues._tues): sessions, raw child handles, SFTP.
python/tues The Python package: the subprocess-shaped API on top of tues._tues.
crates/tues-testsupport Docker sshd fixture for the integration tests.

Command line

tues [OPTIONS] [--script <SPEC> | <COMMAND>] [PROVIDER [ARGS]...]

  -l, --login-user <USER>    Login user
  -u, --user <USER>          User to run the command as, via sudo
  -n, --num-jobs <N>         Hosts worked on concurrently (default: 1)
                             (`--pool-size` / `TUES_POOL_SIZE` also accepted)
  -p, --parallel             Up to 20 hosts at once, unless `-n` is set
  -c, --check                Stop after the first failure (one job only)
      --no-check             Keep going after a failure (default)
      --port <PORT>          SSH port
  -i, --identity <FILE>      Identity file; may be repeated
  -F, --config <FILE>        Read this ssh_config instead of ~/.ssh/config
      --no-ssh-config        Do not read any ssh_config
      --file <SRC[:DST]>     Upload a file or directory first; may be repeated
                             (exported remotely as $TUES_FILE1, $TUES_FILE2, …)
      --pty                  Request a pseudo-terminal
      --no-pty               Do not request a pseudo-terminal
      --universal-newlines   With a PTY, keep remote `\n` → `\r\n` translation
      --no-universal-newlines  With a PTY, leave `\n` untranslated (default)
      --host-key-check <P>   strict | accept-new | off
      --known-hosts <FILE>   known_hosts file
      --connect-timeout <S>  Connection timeout in seconds
  -P, --prefix               Prefix output lines even on a single host
  -N, --no-prefix            Do not prefix output lines
      --prefix-format <FMT>  Line prefix template (default: [<name>/<stream>]: )
  -s, --script <SPEC>        Run a script from --path / TUES_PATH
      --path <DIR>           Script search directory; may be repeated
      --show-hosts           Print the hosts on stderr, then run the command
      --sort-hosts           Sort hosts alphabetically before running
  -v, --verbose...           Start/finish lines; repeat for more logging
  -q, --quiet                Suppress verbose status and extra logging
      --debug                Print detailed error information

Most options also read a matching TUES_* environment variable (for example TUES_USER, TUES_LOGIN_USER, TUES_POOL_SIZE, TUES_PREFIX).

The provider, the positional after the command or --script, supplies the hosts. A --script file can name it in its header instead, and a provider on the command line overrides that. cl takes them as the remaining arguments. file reads them from files, one host per line, and - reads stdin. Any other name runs tues-provider-<name> from PATH, with the remaining arguments and options passed through, and reads the same newline-separated list from its stdout. tues options come before the command. --show-hosts prints the resolved list before connecting; without it the command runs directly. --sort-hosts sorts that list alphabetically first.

A host is host, login-user@host, host:port, [2001:db8::1]:2222, or an alias from ~/.ssh/config.

# Restart a service on three hosts, four at a time, as root.
tues -l deploy -u root -n 4 'systemctl restart nginx' cl web01 web02 web03

# One host: raw stdout/stderr, the remote exit status becomes ours.
# `--pty` allocates a pseudo-terminal; `--no-pty` overrides a script that asks for one.
tues 'tar cz /var/log' cl backup01 > logs.tgz

# When TUES_PW is set, tues uses it for login and sudo passwords.
# Otherwise it prompts on the terminal.
TUES_PW=s3cret tues -u root 'apt-get update' cl db01 db02

# Hosts from a file, and from a provider executable (tues-provider-netbox).
tues 'uptime' file web.list
tues --show-hosts 'uptime' netbox --site nyc

# Hosts from Foreman. This package installs tues-provider-fm.
# FOREMAN_URL may include credentials. -c selects a Puppet class (* globs);
# an expression is a Foreman search. Passing both keeps hosts that match both.
FOREMAN_URL='https://user:password@foreman.example/' tues 'uptime' fm 'class = role::web'

# Upload first. `deploy.sh` lands in the remote working directory and is
# removed afterwards; `app.conf` is kept at its destination.
tues --file ./deploy.sh --file ./app.conf:/etc/app/app.conf 'sh deploy.sh' cl web01

# A script from TUES_PATH (colon-separated, like PATH). It is uploaded, run
# as ./my-script, and removed. Arguments are quoted inside the option value.
TUES_PATH=./scripts tues -s "my-script --my-option arg" cl web01

# The same script can name its own hosts, so the provider can be omitted.
TUES_PATH=./scripts tues -s my-script

--script looks the script up on TUES_PATH. A name that contains a / is a file path and is not searched. The script is uploaded to each host, executed, and deleted afterwards, like a temporary --file.

A text script (not a binary) may set defaults in its top comment block. The line is a # or // comment, whitespace around the key and = does not matter, and only that first block is read:

#!/bin/sh
# tues-args = {"user": "root", "pty": false, "prefix": true, "prefix-format": "[<name>/<stream>]: "}
# tues-provider = "cl"
# tues-provider-args = ["web01", "web02"]

user is the sudo user, pty requests a pseudo-terminal, and prefix labels each output line (true always, false never; omit it to prefix only when several hosts are selected). prefix-format is the template for those labels. Omitting pty leaves the terminal off, the same as the command line. --user, --pty / --no-pty, --prefix / --no-prefix, and --prefix-format override the header.

tues-provider is a JSON string (cl, file, or a provider name) and tues-provider-args is a JSON array of strings passed to that provider. They supply the hosts when the command line omits the provider. A provider on the command line replaces both lines.

--file SRC uploads a file or directory (recursively) into the remote working directory under its own name and deletes it once the command has finished. --file SRC:DST uploads to DST and leaves it in place; an existing remote directory receives the file inside it, like cp. Escape a literal : as \: and a literal \ as \\. Uploads run as the command user.

Hosts are visited one after another. -n raises how many run at once, and -p runs 20 at a time when it is not set. With several hosts each output line is prefixed with [<name>/<stream>]: by default (stdout, stderr, or pty), and the exit status is 0 only if every host succeeded. --prefix-format changes the template; placeholders are <name> (the provider host string), <server-ip>, <client-port>, <server-port>, and <stream>. With one host, output is passed through unchanged unless --prefix is set, and the exit status is the remote one (255 on connection errors, like ssh). --check stops at the first host that fails or exits non-zero; it is rejected when more than one job runs at a time.

cargo install --path crates/tues-cli

Rust

[dependencies]
tues = { path = "crates/tues" }   # or the published crate

Blocking API

use std::io::Read;
use tues::{ConnectOptions, HostKeyPolicy, Session, Stdio};

fn main() -> tues::Result<()> {
    // `alice@web01` is resolved against ~/.ssh/config; builder values win.
    let opts = ConnectOptions::new("alice@web01")
        .identity_file("/home/alice/.ssh/id_ed25519")
        .host_key_policy(HostKeyPolicy::AcceptNew)
        .user("root"); // commands run as root unless told otherwise

    let session = Session::connect(opts)?;

    // Capture output (std::process::Command-style).
    let out = session
        .command("systemctl")
        .args(["restart", "nginx"])
        .output()?; // runs as root because of the session default
    assert!(out.status.success(), "nginx restart failed: {}", out.stderr_lossy());

    // A shell line, as the login user, with env and cwd.
    let out = session
        .shell("echo $GREETING from $(pwd)")
        .env("GREETING", "hello")
        .current_dir("/tmp")
        .as_login_user()
        .output()?;
    println!("{}", out.stdout_lossy());

    // Streaming: binary stdout through sudo stays byte-exact.
    let mut child = session
        .command("cat")
        .arg("/var/lib/secret.bin")
        .user("root")
        .stdout(Stdio::Piped)
        .spawn()?;
    let mut data = Vec::new();
    child.stdout.take().unwrap().read_to_end(&mut data)?;
    let status = child.wait()?;
    assert!(status.success());

    // Interactive: write to stdin, read stdout.
    let mut child = session.command("wc").arg("-c").spawn()?;
    {
        use std::io::Write;
        let mut stdin = child.stdin.take().unwrap();
        stdin.write_all(b"12345")?;
        stdin.close()?; // EOF
    }
    let out = child.wait_with_output()?;
    assert_eq!(out.stdout_lossy().trim(), "5");

    // SFTP.
    let sftp = session.sftp()?;
    sftp.write("/tmp/hello.txt", b"hello")?;
    for entry in sftp.read_dir("/tmp")? {
        println!("{} {}", entry.metadata.size, entry.file_name);
    }
    sftp.close()?;

    // File helpers use their own SFTP channel, opened on first use.
    session.upload("notes.txt", "/tmp/notes.txt")?;
    let meta = session.stat("/tmp/notes.txt")?;
    assert!(meta.is_file());
    session.download("/tmp/notes.txt", "notes-copy.txt")?;
    session.rename("/tmp/notes.txt", "/tmp/notes-2.txt")?;
    session.delete("/tmp/notes-2.txt")?;

    session.close()
}

Async API (tokio)

use tokio::io::AsyncReadExt;
use tues::aio::Session;
use tues::{ConnectOptions, Stdio};

#[tokio::main]
async fn main() -> tues::Result<()> {
    let session = Session::connect(ConnectOptions::new("alice@web01").user("root")).await?;

    // Fan out: commands on one session run concurrently on separate channels.
    let uptime = session.command("uptime");
    let df = session.command("df").arg("-h");
    let (a, b) = tokio::join!(uptime.output(), df.output());
    println!("{}{}", a?.stdout_lossy(), b?.stdout_lossy());

    // Stream a process.
    let mut child = session
        .command("journalctl")
        .args(["-f", "-n", "0"])
        .stdout(Stdio::Piped)
        .spawn()
        .await?;
    let mut stdout = child.stdout.take().unwrap();
    let mut buf = vec![0u8; 8192];
    for _ in 0..3 {
        let n = stdout.read(&mut buf).await?;
        print!("{}", String::from_utf8_lossy(&buf[..n]));
    }
    child.kill()?;
    child.wait().await?;

    // SFTP with tokio's AsyncRead/AsyncWrite/AsyncSeek.
    let sftp = session.sftp().await?;
    let mut f = sftp.create("/tmp/data.bin").await?;
    tokio::io::AsyncWriteExt::write_all(&mut f, &[0u8; 1024]).await?;
    tokio::io::AsyncWriteExt::shutdown(&mut f).await?;
    sftp.close().await?;

    session.close().await
}

Connection options

Everything ssh reads from ~/.ssh/config can be set on the builder; the precedence is destination string (login-user@host:port) > builder > ssh_config > defaults.

use std::time::Duration;
use tues::{ConnectOptions, HostKeyPolicy, SshConfigSource};

let opts = ConnectOptions::new("db01")
    .login_user("deploy")
    .port(2222)
    .host_name("10.0.0.5")                      // like HostName
    .identity_file("~/.ssh/deploy_ed25519")
    .identities_only(true)
    .proxy_jump("bastion.example.com")          // or "a,b,c" for several hops
    .connect_timeout(Duration::from_secs(10))
    .server_alive_interval(Duration::from_secs(30))
    .compression(true)
    .use_agent(false)
    .host_key_policy(HostKeyPolicy::Strict)
    .known_hosts_file("/etc/ssh/ssh_known_hosts")
    .ssh_config(SshConfigSource::File("/etc/tues/ssh_config".into())); // or .no_ssh_config()

// Inspect what will actually be used:
let resolved = opts.resolve()?;
println!("{}@{}:{}", resolved.login_user, resolved.host_name, resolved.port);
# Ok::<(), tues::Error>(())

Password managers

Any PasswordManager can be plugged in. The built-ins:

  • MemoizingPasswordManager<P: PasswordPrompter> — asks the prompter once per host/user, caches the answer, forgets it when sudo/sshd rejects it. Default, with TtyPrompter (reads from /dev/tty).
  • StaticPasswordManager — one fixed password for everything.
  • NoPasswordManager — never answers; sudo without NOPASSWD fails with SudoError::PasswordRequired.
use tues::{
    ConnectOptions, MemoizingPasswordManager, PasswordKind, PasswordPrompter, PasswordRequest,
    SecretString, Session, shared,
};

/// Fetches passwords from a vault; `MemoizingPasswordManager` handles caching.
struct Vault;

impl PasswordPrompter for Vault {
    fn prompt(&mut self, req: &PasswordRequest) -> tues::Result<SecretString> {
        let key = match req.kind {
            PasswordKind::Login | PasswordKind::Sudo => format!("ssh/{}/{}", req.host, req.login_user),
            PasswordKind::KeyPassphrase => format!("keys/{}", req.key_path.as_ref().unwrap().display()),
        };
        let secret = lookup_in_vault(&key).map_err(|e| tues::Error::Password(e.to_string()))?;
        Ok(SecretString::from(secret))
    }
}

let manager = shared(MemoizingPasswordManager::new(Vault));
let session = Session::connect(
    ConnectOptions::new("alice@web01").password_manager(manager.clone()),
)?;
// The same manager can be shared between sessions to reuse cached passwords.

Implement PasswordManager directly (get + invalidate) if you want to own the caching policy.

Sans-IO core

tues-core has no I/O and no runtime. If you want to drive the exec protocol over your own transport, feed ExecMachine events and act on its effects:

use tues::core::{Bytes, Command, Effect, Event, ExecMachine, Stdio};

let plan = Command::new("id").user("root").plan(Stdio::Piped, None);
let mut m = ExecMachine::new(&plan);

// Exec `plan.command_line` on a freshly opened SSH channel, then loop: feed
// m.handle(Event::Stdout(bytes)) / Event::Stderr / Event::ExitStatus(n) / Event::Eof / Event::Close
// and drain `m.poll_effect()` after every event:
m.handle(Event::Stdout(Bytes::from_static(b"...")));
while let Some(effect) = m.poll_effect() {
    match effect {
        Effect::Stdout(_bytes) => { /* clean stdout, sudo prompt already removed */ }
        Effect::Stderr(_bytes) => { /* clean stderr */ }
        Effect::WriteChannel(_bytes) => { /* send to the channel */ }
        Effect::WriteChannelSecret(_password) => { /* send to the channel, never log */ }
        Effect::ChannelEof => { /* send EOF */ }
        Effect::RequestPassword { retry: _ } => { /* obtain a password, then m.handle(Event::Password(pw)) */ }
        Effect::Finished(_result) => { /* exit status or SudoError */ }
    }
}

Python

pip install maturin
maturin develop --release        # or: pip install .

The package also installs the tues command described above (and python -m tues). It is the same Rust CLI, compiled into the extension module and started by a small console script, so no separate binary is needed.

The Python API is shaped like the standard library: a Session is the subprocess module for one remote host, an AsyncSession is asyncio.subprocess. run, Popen, CompletedProcess, CalledProcessError, TimeoutExpired, PIPE, STDOUT, DEVNULL, check_output, communicate, text=, shell=, timeout=, … all work as you know them. Popen.stdout is a real io.BufferedReader/TextIOWrapper, Process.stdout a real asyncio.StreamReader. The exceptions subclass their subprocess namesakes.

Blocking

import tues

with tues.Session("alice@web01", user="root", host_key_policy="accept-new") as s:
    # subprocess.run, on the remote host (as root because of the session default).
    s.run(["systemctl", "restart", "nginx"], check=True)

    out = s.run("df -h | tail -n +2", shell=True, capture_output=True, text=True)
    print(out.returncode, out.stdout)

    # Bytes stay bytes, even through sudo.
    out = s.run(["cat"], input=b"\x00\x01binary", stdout=tues.PIPE)
    assert out.stdout == b"\x00\x01binary"

    # Errors and output are the familiar ones.
    try:
        s.check_output(["false"])
    except tues.CalledProcessError as e:        # also a subprocess.CalledProcessError
        print(e.returncode, e.cmd)

    # Popen: stream, signal, wait.
    with s.Popen(["tail", "-f", "/var/log/syslog"], stdout=tues.PIPE, stderr=tues.DEVNULL, text=True) as p:
        for line in p.stdout:
            print(line, end="")
            break
        p.terminate()                           # SIGTERM; p.kill() for SIGKILL
    print(p.returncode)                         # -15

    # Local files as stdio; the login user instead of the session default.
    with open("logs.tgz", "wb") as f:
        s.run(["tar", "cz", "/var/log"], stdout=f, user=tues.LOGIN_USER, check=True)

    with s.sftp() as sftp:
        sftp.write("/tmp/hello.txt", b"hello")
        with sftp.open("/tmp/hello.txt", "a") as f:
            f.write(b" world")
        print(sftp.read("/tmp/hello.txt"), sftp.stat("/tmp/hello.txt").size)
        print([e.name for e in sftp.listdir("/tmp")])

    # Or skip the client. These share one cached channel, separate from sftp().
    s.upload("notes.txt", "/tmp/notes.txt")
    print(s.stat("/tmp/notes.txt").size)
    s.download("/tmp/notes.txt", "notes-copy.txt")
    s.rename("/tmp/notes.txt", "/tmp/notes-2.txt")
    s.delete("/tmp/notes-2.txt")

Also available: call, check_call, getoutput, getstatusoutput, Popen.communicate(input, timeout), Popen.wait(timeout), Popen.poll(), Popen.send_signal("USR1"), bufsize=, encoding=/errors=, env=, cwd=.

Several hosts

tues.run is the legacy API from the original package (tues.legacy), written on top of Session: one shell command, a list of hosts, and a Task per host. A single host returns the task itself. connect_options is forwarded to Session.connect. New code should use Session; this entry point stays so existing callers keep working.

import tues

tasks = tues.run(
    ["web01", "web02"],
    "id -un",
    user="root",
    prefix=True,
    capture_output=True,
    text=True,
    pool_size=4,
    connect_options={"host_key_policy": "accept-new"},
)
for task in tasks:
    print(task.host, task.stdout.strip())

files are uploaded into the remote working directory and removed afterwards; the command sees them as $TUES_FILE1, $TUES_FILE2, …. Script loads a local file from TUES_PATH (or tues.DEFAULT_PATH), uploads it and runs it. provider("cl", ["web01"]) and provider("file", ["hosts.txt"]) resolve hosts the same way the command line does. check=True stops at the first non-zero exit (one host at a time) and raises TuesTaskError. args[0] is the Task; __cause__ is set only when the host failed before a status. A refused sudo or login password raises TuesUserAbort. A parallel run collects connection failures into TuesErrorGroup (message, exceptions, results).

asyncio

import asyncio, tues

async def main():
    async with await tues.AsyncSession.connect("alice@web01", user="root") as s:
        # asyncio.subprocess, on the remote host.
        proc = await s.create_subprocess_exec("wc", "-c", stdin=tues.PIPE, stdout=tues.PIPE)
        proc.stdin.write(b"12345")
        await proc.stdin.drain()
        proc.stdin.close()
        stdout, _ = await proc.communicate()
        assert stdout.strip() == b"5" and proc.returncode == 0

        proc = await s.create_subprocess_shell("journalctl -f -n 0", stdout=tues.PIPE)
        async for line in proc.stdout:
            print(line.decode(), end="")
            break
        proc.kill()
        await proc.wait()

        # run() is the asyncio twin of subprocess.run; commands on one session
        # run concurrently on separate channels.
        results = await asyncio.gather(*(s.run(["echo", str(i)], capture_output=True, text=True) for i in range(10)))
        print([r.stdout.strip() for r in results])

        async with await s.sftp() as sftp:
            await sftp.write("/tmp/x", b"data")
            print(await sftp.read_text("/tmp/x"))

asyncio.run(main())

Differences from subprocess

Consequences of the process running on another machine:

  • stdin=None means no input (EOF), not the local stdin; pass stdin=sys.stdin (blocking API) to forward it. stdout/stderr=None do go to the local stdout/stderr.
  • env adds to / overrides the remote environment instead of replacing it; a None value unsets a variable.
  • pid is always None. returncode is -N when the process died of signal N, as on POSIX.
  • terminate()/send_signal() need a server that implements SSH channel signals (OpenSSH ≥ 7.9); kill() also closes the channel.
  • Extra keyword arguments: user and pty. user is a name (via sudo -u) or tues.LOGIN_USER (the login user, never via sudo, even when the session has a default user); leaving it out inherits the session default. With a PTY, stderr is merged into stdout by the terminal.
  • The asyncio API is bytes-only like asyncio.subprocess; AsyncSession.run adds text=/encoding=.

Passwords

import getpass, tues

# Prompt once per host/user; Rust memoizes and re-prompts after a wrong password.
s = tues.Session("alice@web01", password_manager=lambda req: getpass.getpass(f"{req.prompt}: "))

# Fixed password for login and sudo.
s = tues.Session("alice@web01", password="s3cret")

# Own the caching: an object with get() and invalidate() is called every time.
class Keyring:
    def get(self, req: tues.PasswordRequest) -> str: ...
    def invalidate(self, req: tues.PasswordRequest) -> None: ...

s = tues.Session("alice@web01", password_manager=Keyring())

Errors are raised as tues.TuesError subclasses: ConnectError, AuthError, HostKeyError, SudoError, SftpError, and CalledProcessError / TimeoutExpired (which are also subprocess.CalledProcessError / subprocess.TimeoutExpired). A sudo failure surfaces from run() / wait() / communicate() as SudoError.

How sudo is made invisible

For a command running as a user other than the login user the driver executes

sudo -S -k -H -p '[tues-sudo-<nonce>]' -u <user> -- /bin/sh -c 'printf %s "[tues-ok-<nonce>]"; <command>'

and runs the channel through SudoFilter:

  1. Output is held until either the prompt nonce or the OK marker appears.
  2. On the prompt, the password is written to the channel (never surfaced to the caller) and the prompt bytes are dropped; Sorry, try again. and echoed passwords in PTY mode are dropped too and the password manager is told to invalidate its cache. After three failures the command fails with SudoError::AuthFailed.
  3. On the marker, the filter switches to passthrough: the marker is stripped and no further scanning happens, so the command's own output — even if it contains the nonces or the password — is delivered byte-exact.
  4. Caller stdin is buffered until the marker has been seen, so nothing meant for the command can be swallowed by sudo.

Because the nonces are random per invocation, remote output cannot forge a prompt.

SFTP follows the same rule. With no session user it is the server's sftp subsystem, as the login user. With a session user, tues starts sftp-server through that sudo handshake (the marker is stripped before the SFTP greeting), so uploads and downloads run as the command user rather than the login user.

Development

Requirements: Rust 1.90+ (cargo, rustup), Docker (for the integration tests), Python 3.9+ with uv, and just.

just setup                                   # .venv, maturin develop, llvm-cov tools

cargo test --workspace                       # Rust tests and the pytest suite
cargo clippy --workspace --all-targets
pytest                                       # Python tests only

just test                                    # Per-file coverage for Rust and python/tues
cargo coverage                               # HTML report: target/llvm-cov/html

Release

scripts/release sets the workspace version (the Python package reads it from there), commits that bump on the release branch, and tags v<version>. It then builds an sdist and one wheel per CPython into dist/<version>/ for an internal index. Each wheel carries the extension module and the tues console script. Older version directories are left in place. dist/latest points at the version just built, and dist/stable points at the newest tagged final release. A release or release candidate is created only from main or master. After the build succeeds, a final release pushes that branch and the tag to the branch's upstream. A release candidate is not pushed. Unless --dry-run is set, the artifacts are published with uv publish (uv's configured index and credentials) after that push.

just release 0.2.0

just release calls scripts/release. When the version is omitted on a TTY, the script prints commit summaries since the last release and prompts for one. Hitting return accepts the guessed next release. Further arguments are passed through:

just release
just release 0.2.0 --dry-run
just release 0.2.0 --python 3.12

By default that is Python 3.9, 3.10, 3.11, 3.12, 3.13 and 3.14. The build runs in the ghcr.io/pyo3/maturin manylinux2014 container, which supplies those CPython builds and a glibc old enough for the manylinux2014 tag. Each wheel is tagged for its interpreter (cp39, cp310, cp311, cp312, cp313, cp314). The sdist is built first and the wheels are built from it. Change the set with --python 3.12,3.13 or TUES_PYTHON_VERSIONS. Docker is required.

just rc tags a release candidate of a future version. Pass the upcoming release and the next free rcN is chosen from the local tags, or pass the candidate itself:

just rc 0.3.0
# Cargo.toml 0.3.0-rc.1, wheels named 0.3.0rc1
just rc 0.3.0rc2 --dry-run
just rc

Cargo stores X.Y.Z-rc.N. That is SemVer, and PEP 440 normalizes it to X.Y.ZrcN. A version with an rcN suffix is a pre-release, so pip and uv keep installing the current final release until someone asks for the candidate (tues==0.3.0rc1) or passes --pre. The candidate is committed on the release branch and tagged (v0.3.0-rc.1) before the build. dist/latest and dist/rc point at it; dist/stable stays on the last final release.

git push --follow-tags

just pre builds a developmental pre-release from any branch. The version carries the short commit hash, a UTC timestamp, and the login name:

just pre
# Cargo.toml 4.0.5-dev0+abcdef1.20261008120000.mvb
# wheels named 4.0.5.dev0+abcdef1.20261008120000.mvb
just pre 0.3.0 --dry-run

It is committed on the current branch and tagged before the build, and it is not pushed. dist/latest points at it; dist/stable and dist/rc stay where they are. pip and uv treat it as a pre-release, so it is installed only for that exact version or with --pre.

The integration tests build docker/sshd/Dockerfile (Debian sshd with a tues user that may sudo, and a nopw NOPASSWD target) and start it on an ephemeral host port. The Rust tests do this through the testcontainers crate; the pytest suite uses the testcontainers Python library (Python 3.10+). Containers are removed when the test process exits.

License

MIT OR Apache-2.0

Metadata

Release files for tues 4.0.8

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for tues 4.0.8
File Size Uploaded Provenance Actions
tues-4.0.8.tar.gz 199.3 kB Without provenance Details for tues-4.0.8.tar.gz

Built distributions (wheels)

Table of built distributions (wheels) for tues 4.0.8
File Actions
tues-4.0.8-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl CPython 3.14 CPython 3.14 Linux glibc 2.17+ x86-64 Details for tues-4.0.8-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
tues-4.0.8-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl CPython 3.13 CPython 3.13 Linux glibc 2.17+ x86-64 Details for tues-4.0.8-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
tues-4.0.8-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl CPython 3.12 CPython 3.12 Linux glibc 2.17+ x86-64 Details for tues-4.0.8-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
tues-4.0.8-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl CPython 3.11 CPython 3.11 Linux glibc 2.17+ x86-64 Details for tues-4.0.8-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
tues-4.0.8-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl CPython 3.10 CPython 3.10 Linux glibc 2.17+ x86-64 Details for tues-4.0.8-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
tues-4.0.8-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl CPython 3.9 CPython 3.9 Linux glibc 2.17+ x86-64 Details for tues-4.0.8-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl

Total release size: 30.6 MB

Release files / tues-4.0.8.tar.gz

Download URL tues-4.0.8.tar.gz
Size 199.3 kB
Tags Source
SHA-256 checksum
How to use checksums
043ac4edac3893a7741178f28fd9457b3023836442945313637c925dc95871bc
BLAKE2b-256 checksum
How to use checksums
ae974be28ebc2631101395d4b17d16824eb3b90445c04779233ba44d4e1ff108
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.13.5 {"installer":{"name":"uv","version":"0.13.5","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Debian GNU/Linux","version":"13","id":"trixie","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Release files / tues-4.0.8-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl

Download URL tues-4.0.8-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
Size 5.1 MB
Tags CPython 3.14 Linux glibc 2.17+ x86-64
SHA-256 checksum
How to use checksums
6033a7ff0258524ce41153e6eb2be9defeab767d67b8c5c932c31e1b6b695542
BLAKE2b-256 checksum
How to use checksums
cde3dfcb685c9a58c245d8f45d1fc720bae65f307a0f501ef338a446cea1c59d
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.13.5 {"installer":{"name":"uv","version":"0.13.5","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Debian GNU/Linux","version":"13","id":"trixie","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Release files / tues-4.0.8-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl

Download URL tues-4.0.8-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
Size 5.1 MB
Tags CPython 3.13 Linux glibc 2.17+ x86-64
SHA-256 checksum
How to use checksums
f5258e302e17576b0eddd7b4914bc9ba95dd1b41c3860776bc32ea3f8b564255
BLAKE2b-256 checksum
How to use checksums
8b923ec74394434df07cc3faa18a31badbc0f4dccd98b321e860e3566a9c6f10
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.13.5 {"installer":{"name":"uv","version":"0.13.5","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Debian GNU/Linux","version":"13","id":"trixie","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Release files / tues-4.0.8-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl

Download URL tues-4.0.8-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
Size 5.1 MB
Tags CPython 3.12 Linux glibc 2.17+ x86-64
SHA-256 checksum
How to use checksums
b905b3e4dc8a3ea49d02b1865bd83823883e88580183bf7433aade3f8562b90c
BLAKE2b-256 checksum
How to use checksums
6b214b51adbea01decee9fe0b04c8bff4182f6b74f52f8f5d8a0d40f37db428d
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.13.5 {"installer":{"name":"uv","version":"0.13.5","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Debian GNU/Linux","version":"13","id":"trixie","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Release files / tues-4.0.8-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl

Download URL tues-4.0.8-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
Size 5.1 MB
Tags CPython 3.11 Linux glibc 2.17+ x86-64
SHA-256 checksum
How to use checksums
8b0add79134034b326c60a53e58a4f4b31d346ae7c0f26148b882c620fdbee7b
BLAKE2b-256 checksum
How to use checksums
5099ef914e91a10fad1a24d3a94fdea88729787ae4bc8efc27b7cbdfefa0507f
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.13.5 {"installer":{"name":"uv","version":"0.13.5","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Debian GNU/Linux","version":"13","id":"trixie","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Release files / tues-4.0.8-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl

Download URL tues-4.0.8-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
Size 5.1 MB
Tags CPython 3.10 Linux glibc 2.17+ x86-64
SHA-256 checksum
How to use checksums
41e19bb21e9a22726bbb1cac73ee98ef01d3d0c31ceee51ae47cc094c6679cfa
BLAKE2b-256 checksum
How to use checksums
aa6ef8a1cc7d75f83cb31643a8145c2a132143fd712d69370cd854b47c777f38
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.13.5 {"installer":{"name":"uv","version":"0.13.5","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Debian GNU/Linux","version":"13","id":"trixie","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Release files / tues-4.0.8-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl

Download URL tues-4.0.8-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
Size 5.1 MB
Tags CPython 3.9 Linux glibc 2.17+ x86-64
SHA-256 checksum
How to use checksums
397ec76a33722b23c3565bd28871099d70c139ba0f3abda9d86a76a6c6a352bf
BLAKE2b-256 checksum
How to use checksums
8ca218cb91f8a57d730d8b390b9e9e237758997b8010e613f8f66f7a858fc08b
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.13.5 {"installer":{"name":"uv","version":"0.13.5","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Debian GNU/Linux","version":"13","id":"trixie","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Security for tues 4.0.8

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Release history Release notifications | RSS feed

This release

4.0.8 This release

7 release files No provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page