The scrippy-remote module executes commands on remote hosts and transfers files, over SSH/SFTP, FTP/FTPS and CIFS.
disk_watch.py checks the filesystems of the host it runs on, and keeps its reports in a local directory nobody else can see. With scrippy-remote, version 1.2.0:
Add the SSH settings to /home/luiggi/.local/share/scrippy/conf/disk_watch.conf. The [publish] section is optional: Without it, the reports are neither purged nor published. Its retention key is optional too (default: 30 days).
[remote]
user = scrippy
key = /home/luiggi/.ssh/scrippy_rsa
[publish]
host = backup
dir = /srv/reports/disk_watch
retention = 30
The scrippy user must exist on the remote hosts, with the public key matching /home/luiggi/.ssh/scrippy_rsa in its ~/.ssh/authorized_keys file. The remote hosts must be known: Connect once with the ssh command to add them to ~/.ssh/known_hosts.
Starting from the version 1.1.0 of disk_watch.py, the header declares the new configuration keys and the --host option:
@conf:remote|user|str|false
@conf:remote|key|str|false
@args:host|str|Remote hosts to check over SSH (default: local host)|+||||false|false
The module is imported, after scrippy_core, and the name pattern of the report files is defined:
import shlex
import scrippy_core
from scrippy_core import logger, ScrippyCoreError
from scrippy_template import template
from scrippy_remote import ScrippyRemoteError
from scrippy_remote.ssh import Ssh
from scrippy_remote.local import list_local_dir, delete_local_file
REPORT_PATTERN = r"^disk_watch_\d{8}_\d{6}\.(txt|csv|html)$"
get_usage() is renamed get_local_usage(), and the new get_remote_usage() function runs df -P on a remote host through an open Ssh connection:
def get_remote_usage(ssh, mount_point):
logger.debug(f"[+] Checking {ssh.host}:{mount_point}")
result = ssh.exec(command=f"df -P {shlex.quote(mount_point)}", exit_on_error=False)
if result["exit_code"] != 0:
raise OSError(" ".join(result["stderr"]) or f"df exit code {result['exit_code']}")
# Filesystem 1024-blocks Used Available Capacity Mounted on
fields = result["stdout"][-1].split()
return round(int(fields[2]) * 100 / int(fields[1]), 1)
check() receives the function to use to get the usage of a mount point, and a prefix for the names of the mount points:
def check(mount_points, warning, critical, get_usage=get_local_usage, prefix=""):
results = []
for mount_point in mount_points:
name = f"{prefix}{mount_point}"
try:
usage = get_usage(mount_point)
except OSError as err:
logger.error(f" '-> {name}: {err.strerror or err}")
results.append((name, None, UNKNOWN))
continue
if usage >= critical:
logger.error(f" '-> {name}: {usage}% [CRITICAL]")
status = CRITICAL
elif usage >= warning:
logger.warning(f" '-> {name}: {usage}% [WARNING]")
status = WARNING
else:
logger.info(f" '-> {name}: {usage}% [OK]")
status = OK
results.append((name, usage, status))
return results
The new check_remote_hosts() function opens an SSH connection to each host and checks its mount points. An unreachable host does not stop the script: Its mount points get the UNKNOWN status:
def check_remote_hosts(hosts, mount_points, warning, critical, config):
results = []
for host in hosts:
logger.info(f"[+] Checking remote host: {host}")
try:
with Ssh(username=config.get("remote", "user"),
host=host,
key=config.get("remote", "key"),
missing_host_key_policy="reject") as ssh:
results += check(mount_points, warning, critical,
get_usage=lambda mount_point: get_remote_usage(ssh, mount_point),
prefix=f"{host}:")
except ScrippyRemoteError as err:
logger.error(f" '-> {host}: {err}")
results += [(f"{host}:{mount_point}", None, UNKNOWN) for mount_point in mount_points]
return results
The new purge_reports() function deletes the local reports older than the retention period. The date of a report is part of its name (disk_watch_20261008_191314.csv), so comparing names is enough:
def purge_reports(report_dir, retention):
"""Deletes the local reports older than ``retention`` days (the report date is part of its name)."""
limit = f"disk_watch_{datetime.datetime.now() - datetime.timedelta(days=retention):%Y%m%d_%H%M%S}"
logger.info(f"[+] Purging reports older than {retention} days")
for name in list_local_dir(local_dir=report_dir, file_type="f", pattern=REPORT_PATTERN):
if name < limit:
logger.info(f" '-> Deleting {name}")
delete_local_file(local_file=os.path.join(report_dir, name))
The new sync_reports() function makes the remote directory a copy of the local one. It compares the list of the local reports with the list of the remote reports, then sends the missing ones and deletes the ones that no longer exist locally. Only the files matching REPORT_PATTERN are considered on both sides: Other files of the remote directory are left untouched.
def sync_reports(report_dir, config):
"""Synchronizes the local reports to the publication host: Missing reports are sent, deleted reports are deleted."""
host = config.get("publish", "host")
remote_dir = f"{config.get('publish', 'dir')}/{socket.gethostname()}"
logger.info(f"[+] Synchronizing reports to {host}:{remote_dir}")
with Ssh(username=config.get("remote", "user"),
host=host,
key=config.get("remote", "key"),
missing_host_key_policy="reject") as ssh:
ssh.create_remote_dir(remote_dir=remote_dir, create_dirs=True)
local_reports = set(list_local_dir(local_dir=report_dir, file_type="f", pattern=REPORT_PATTERN))
remote_reports = set(ssh.list_remote_dir(remote_dir=remote_dir, file_type="f", pattern=REPORT_PATTERN))
for name in sorted(local_reports - remote_reports):
logger.info(f" '-> Sending {name}")
ssh.put_file(local_file=os.path.join(report_dir, name), remote_dir=remote_dir)
for name in sorted(remote_reports - local_reports):
logger.info(f" '-> Deleting {name}")
ssh.delete_remote_file(remote_file=f"{remote_dir}/{name}")
logger.info(f" '-> {len(local_reports - remote_reports)} sent, {len(remote_reports - local_reports)} deleted, {len(local_reports)} published")
write_report() now returns the path of the written report, and main() uses the new functions:
mount_points = get_mount_points(config, args)
if args.host:
results = check_remote_hosts(args.host, mount_points, warning, critical, config)
else:
results = check(mount_points, warning, critical)
if args.report:
report_format = args.format or "text"
content = render_report(results, report_format, warning, critical, _context.env["templatedir"])
report = write_report(content, report_format, _context.workspace_path, _context.env["reportdir"])
if config.has("publish", "host"):
retention = config.get("publish", "retention", "int") if config.has("publish", "retention") else 30
purge_reports(os.path.dirname(report), retention)
sync_reports(os.path.dirname(report), config)
Note: Comparing the lists of names sends each report only once, unlike remote_mirror() which sends every file at each call. See Synchronizing files over SFTP for other strategies.
#!/usr/bin/env python3
"""
--------------------------------------------------------------------------------
@author : Luiggi Vercotti
@date : 2026-10-08
@version : 1.2.0
@description : Monitor filesystem usage
--------------------------------------------------------------------------------
Update:
0.1.0 2026-10-08 - Luiggi Vercotti - cre: Script creation
0.2.0 2026-10-08 - Luiggi Vercotti - evo: Several mount points, log levels
0.3.0 2026-10-08 - Luiggi Vercotti - evo: Monitoring exit status
0.4.0 2026-10-08 - Luiggi Vercotti - evo: Configuration file
0.5.0 2026-10-08 - Luiggi Vercotti - evo: Command line options
0.6.0 2026-10-08 - Luiggi Vercotti - evo: Alerting API token
0.7.0 2026-10-08 - Luiggi Vercotti - evo: Report file
0.8.0 2026-10-08 - Luiggi Vercotti - evo: Error handling
0.9.0 2026-10-08 - Luiggi Vercotti - evo: Access control
1.0.0 2026-10-08 - Luiggi Vercotti - evo: Concurrent executions
1.1.0 2026-10-08 - Luiggi Vercotti - evo: Report templates
1.2.0 2026-10-08 - Luiggi Vercotti - evo: Remote hosts, reports synchronization
--------------------------------------------------------------------------------
List of authorized users or group:
@group:ops
--------------------------------------------------------------------------------
Concurrent executions:
@max_instance: 1
@exit_on_wait: true
--------------------------------------------------------------------------------
List of mandatory configuration parameters:
@conf:filesystems|mount_points|str|false
@conf:thresholds|warning|int|false
@conf:thresholds|critical|int|false
@conf:alerting|token|str|true
@conf:remote|user|str|false
@conf:remote|key|str|false
--------------------------------------------------------------------------------
List of execution options and argument parameters:
@args:host|str|Remote hosts to check over SSH (default: local host)|+||||false|false
@args:mount-point|str|Mount points to check (default: from configuration)|+||exclude||false|false
@args:exclude|str|Mount points to skip|+||||false|false
@args:warning|int|Warning threshold in percent (default: from configuration)|1||||false|false
@args:critical|int|Critical threshold in percent (default: from configuration)|1||||false|false
@args:token|str|Alerting API token (default: from configuration)|1||||false|true
@args:report|bool|Write a report into the report directory||false|||false|false
@args:format|choice|Report format (default: text)|1|text,csv,html||report|false|false
--------------------------------------------------------------------------------
Functioning:
---------------
Checks the usage of each mount point against a warning and a critical
threshold and exits with a monitoring compatible exit status:
0: OK, 1: WARNING, 2: CRITICAL, 3: UNKNOWN (a mount point can not be checked)
Configuration:
- filesystems::mount_points: Comma separated list of mount points
- thresholds::warning: Warning threshold in percent
- thresholds::critical: Critical threshold in percent
- alerting::token: Alerting API token (secret)
- remote::user: SSH user name on the remote hosts
- remote::key: SSH private key file
- publish::host, publish::dir: Optional. Remote host and directory where
the report directory is synchronized over SFTP (same user and key as
remote hosts), in a subdirectory named after the local host
- publish::retention: Optional. Number of days the reports are kept
locally and on the publication host (default: 30)
Templates:
- <templatedir>/disk_watch/report.<format>.j2
"""
# ------------------------------------------------------------------------------
# Initialization of the environment
# ------------------------------------------------------------------------------
import os
import sys
import shutil
import datetime
import shlex
import socket
import scrippy_core
from scrippy_core import logger, ScrippyCoreError
from scrippy_template import template
from scrippy_remote import ScrippyRemoteError
from scrippy_remote.ssh import Ssh
from scrippy_remote.local import list_local_dir, delete_local_file
OK, WARNING, CRITICAL, UNKNOWN = 0, 1, 2, 3
STATUS = {OK: "OK", WARNING: "WARNING", CRITICAL: "CRITICAL", UNKNOWN: "UNKNOWN"}
EXTENSIONS = {"text": "txt", "csv": "csv", "html": "html"}
REPORT_PATTERN = r"^disk_watch_\d{8}_\d{6}\.(txt|csv|html)$"
# ------------------------------------------------------------------------------
# Definition of functions and classes
# ------------------------------------------------------------------------------
def get_mount_points(config, args):
if args.mount_point:
return args.mount_point
mount_points = [mp.strip() for mp in config.get("filesystems", "mount_points").split(",")]
return [mp for mp in mount_points if mp not in (args.exclude or [])]
def get_thresholds(config, args):
warning = args.warning if args.warning is not None else config.get("thresholds", "warning", "int")
critical = args.critical if args.critical is not None else config.get("thresholds", "critical", "int")
if not 0 < warning < critical <= 100:
raise ScrippyCoreError(f"Invalid thresholds: warning={warning}, critical={critical}")
return warning, critical
def get_local_usage(mount_point):
logger.debug(f"[+] Checking {mount_point}")
usage = shutil.disk_usage(mount_point)
return round(usage.used * 100 / usage.total, 1)
def get_remote_usage(ssh, mount_point):
logger.debug(f"[+] Checking {ssh.host}:{mount_point}")
result = ssh.exec(command=f"df -P {shlex.quote(mount_point)}", exit_on_error=False)
if result["exit_code"] != 0:
raise OSError(" ".join(result["stderr"]) or f"df exit code {result['exit_code']}")
# Filesystem 1024-blocks Used Available Capacity Mounted on
fields = result["stdout"][-1].split()
return round(int(fields[2]) * 100 / int(fields[1]), 1)
def check(mount_points, warning, critical, get_usage=get_local_usage, prefix=""):
results = []
for mount_point in mount_points:
name = f"{prefix}{mount_point}"
try:
usage = get_usage(mount_point)
except OSError as err:
logger.error(f" '-> {name}: {err.strerror or err}")
results.append((name, None, UNKNOWN))
continue
if usage >= critical:
logger.error(f" '-> {name}: {usage}% [CRITICAL]")
status = CRITICAL
elif usage >= warning:
logger.warning(f" '-> {name}: {usage}% [WARNING]")
status = WARNING
else:
logger.info(f" '-> {name}: {usage}% [OK]")
status = OK
results.append((name, usage, status))
return results
def check_remote_hosts(hosts, mount_points, warning, critical, config):
results = []
for host in hosts:
logger.info(f"[+] Checking remote host: {host}")
try:
with Ssh(username=config.get("remote", "user"),
host=host,
key=config.get("remote", "key"),
missing_host_key_policy="reject") as ssh:
results += check(mount_points, warning, critical,
get_usage=lambda mount_point: get_remote_usage(ssh, mount_point),
prefix=f"{host}:")
except ScrippyRemoteError as err:
logger.error(f" '-> {host}: {err}")
results += [(f"{host}:{mount_point}", None, UNKNOWN) for mount_point in mount_points]
return results
def purge_reports(report_dir, retention):
"""Deletes the local reports older than ``retention`` days (the report date is part of its name)."""
limit = f"disk_watch_{datetime.datetime.now() - datetime.timedelta(days=retention):%Y%m%d_%H%M%S}"
logger.info(f"[+] Purging reports older than {retention} days")
for name in list_local_dir(local_dir=report_dir, file_type="f", pattern=REPORT_PATTERN):
if name < limit:
logger.info(f" '-> Deleting {name}")
delete_local_file(local_file=os.path.join(report_dir, name))
def sync_reports(report_dir, config):
"""Synchronizes the local reports to the publication host: Missing reports are sent, deleted reports are deleted."""
host = config.get("publish", "host")
remote_dir = f"{config.get('publish', 'dir')}/{socket.gethostname()}"
logger.info(f"[+] Synchronizing reports to {host}:{remote_dir}")
with Ssh(username=config.get("remote", "user"),
host=host,
key=config.get("remote", "key"),
missing_host_key_policy="reject") as ssh:
ssh.create_remote_dir(remote_dir=remote_dir, create_dirs=True)
local_reports = set(list_local_dir(local_dir=report_dir, file_type="f", pattern=REPORT_PATTERN))
remote_reports = set(ssh.list_remote_dir(remote_dir=remote_dir, file_type="f", pattern=REPORT_PATTERN))
for name in sorted(local_reports - remote_reports):
logger.info(f" '-> Sending {name}")
ssh.put_file(local_file=os.path.join(report_dir, name), remote_dir=remote_dir)
for name in sorted(remote_reports - local_reports):
logger.info(f" '-> Deleting {name}")
ssh.delete_remote_file(remote_file=f"{remote_dir}/{name}")
logger.info(f" '-> {len(local_reports - remote_reports)} sent, {len(remote_reports - local_reports)} deleted, {len(local_reports)} published")
def render_report(results, report_format, warning, critical, templatedir):
params = {"hostname": socket.gethostname(),
"date": f"{datetime.datetime.now():%Y-%m-%d %H:%M:%S}",
"warning": warning,
"critical": critical,
"status": STATUS[max(status for _, _, status in results)],
"results": [{"mount_point": mount_point, "usage": usage, "status": STATUS[status]}
for mount_point, usage, status in results]}
renderer = template.Renderer(os.path.join(templatedir, "disk_watch"),
f"report.{EXTENSIONS[report_format]}.j2")
return renderer.render(params)
def write_report(content, report_format, workspace_path, reportdir):
name = f"disk_watch_{datetime.datetime.now():%Y%m%d_%H%M%S}.{EXTENSIONS[report_format]}"
tmp_report = os.path.join(workspace_path, name)
logger.info(f"[+] Writing report: {tmp_report}")
with open(tmp_report, mode="w", encoding="utf-8") as report:
report.write(content)
report_dir = os.path.join(reportdir, "disk_watch")
os.makedirs(report_dir, exist_ok=True)
logger.info(f"[+] Publishing report into {report_dir}")
return shutil.move(tmp_report, os.path.join(report_dir, name))
# ------------------------------------------------------------------------------
# Main processing
# ------------------------------------------------------------------------------
def main():
with scrippy_core.ScriptContext(retention=100) as _context:
config = _context.config
args = _context.args
token = args.token or config.get("alerting", "token")
logger.debug(f"[+] Alerting API token: {token}")
warning, critical = get_thresholds(config, args)
logger.log_section("disk_watch", f"Thresholds: warning={warning}% critical={critical}%")
mount_points = get_mount_points(config, args)
if args.host:
results = check_remote_hosts(args.host, mount_points, warning, critical, config)
else:
results = check(mount_points, warning, critical)
if args.report:
report_format = args.format or "text"
content = render_report(results, report_format, warning, critical, _context.env["templatedir"])
report = write_report(content, report_format, _context.workspace_path, _context.env["reportdir"])
if config.has("publish", "host"):
retention = config.get("publish", "retention", "int") if config.has("publish", "retention") else 30
purge_reports(os.path.dirname(report), retention)
sync_reports(os.path.dirname(report), config)
status = max(status for _, _, status in results)
logger.info(f"[+] Status: {STATUS[status]}")
if status != OK:
sys.exit(status)
# ------------------------------------------------------------------------------
# Entry point
# ------------------------------------------------------------------------------
if __name__ == '__main__':
main()
./disk_watch.py --host srv1 srv2 nowhere --mount-point / /var
[...]
[20261008 19:25:01] [INFO ] [+] Checking remote host: srv1
[20261008 19:25:01] [INFO ] '-> srv1:/: 34.9% [OK]
[20261008 19:25:01] [INFO ] '-> srv1:/var: 40.1% [OK]
[20261008 19:25:01] [INFO ] [+] Checking remote host: srv2
[20261008 19:25:01] [INFO ] '-> srv2:/: 50.1% [OK]
[20261008 19:25:01] [WARNING ] '-> srv2:/var: 85.3% [WARNING]
[20261008 19:25:01] [INFO ] [+] Checking remote host: nowhere
[20261008 19:25:01] [CRITICAL] [SshConnectionError] gaierror: [Errno -2] Name or service not known
[20261008 19:25:01] [ERROR ] '-> nowhere: [SshConnectionError] gaierror: [Errno -2] Name or service not known
[20261008 19:25:01] [INFO ] [+] Status: UNKNOWN
[...]
With --report, the reports are purged and synchronized:
./disk_watch.py --host srv1 --report --format html
[...]
[20261008 20:53:45] [INFO ] [+] Purging reports older than 30 days
[20261008 20:53:45] [INFO ] '-> Deleting disk_watch_20260824_080000.txt
[20261008 20:53:45] [INFO ] [+] Synchronizing reports to backup:/srv/reports/disk_watch/srv.flying.circus
[20261008 20:53:45] [INFO ] '-> Sending disk_watch_20261006_080000.csv
[20261008 20:53:45] [INFO ] '-> Sending disk_watch_20261008_205345.html
[20261008 20:53:45] [INFO ] '-> Deleting disk_watch_20261005_080000.txt
[20261008 20:53:45] [INFO ] '-> 2 sent, 1 deleted, 2 published
[...]
The next execution only sends its own report:
[20261008 20:53:46] [INFO ] '-> Sending disk_watch_20261008_205346.txt
[20261008 20:53:46] [INFO ] '-> 1 sent, 0 deleted, 3 published
With --debug, scrippy-remote logs the connections, the commands and their output:
[20261008 19:25:12] [DEBUG ] Connecting to srv1:22
[20261008 19:25:12] [DEBUG ] Running command: df -P /
[20261008 19:25:12] [DEBUG ] Filesystem 1024-blocks Used Available Capacity Mounted on
[20261008 19:25:12] [DEBUG ] /dev/sda1 20511312 7158448 13352864 35% /
pip install scrippy-remote
scrippy-remote requires Python 3.11 or later, paramiko and pysmb, installed automatically.
scrippy-remote provides one class per protocol:
| Class | Module | Protocol |
|---|---|---|
Ssh |
scrippy_remote.ssh |
Remote command execution and file transfer over SSH/SFTP |
Ftp |
scrippy_remote.ftp |
File transfer over FTP, FTPS (implicit TLS) or FTPES (explicit TLS) |
Cifs |
scrippy_remote.cifs |
File transfer within a CIFS/Samba shared folder |
Each class is used as a context manager: The connection is opened when entering the with block and closed when leaving it.
The three classes share the same file and directory operations. In addition, Ssh executes commands with exec() and Cifs reads and writes remote files with open().
from scrippy_remote.ftp import Ftp
from scrippy_remote.ssh import Ssh
with Ftp(host="ftp.flying.circus", username="luiggi", password="dead_parrot") as ftp:
ftp.get_file(remote_file="sketches/parrot.txt", local_dir=workspace_path)
with Ssh(username="luiggi", host="srv.flying.circus", key="/home/luiggi/.ssh/id_rsa") as ssh:
ssh.put_file(local_file=os.path.join(workspace_path, "parrot.txt"),
remote_dir="/home/luiggi/sketches",
create_dirs=True)
ssh.exec(command="chmod 640 /home/luiggi/sketches/parrot.txt")
Tip: Download files into the workspace of the script: They are automatically deleted at the end of the execution.
All errors raise a scrippy_remote.ScrippyRemoteError. Its message starts with the error category, i.e: [SshTransferError]:
| Category | Raised by |
|---|---|
SshConnectionError, FtpConnectionError, CifsConnectionError |
Connection and authentication |
SshExecError |
Ssh.exec() |
SshTransferError, FtpTransferError, CifsTransferError |
get_file(), put_file(), Cifs.open() |
SshRemoteOSError, FtpRemoteOSError, CifsRemoteOSError |
Remote directory listing, creation and deletion |
SshArgumentError, FtpArgumentError, CifsArgumentError |
Invalid argument |
LocalOSError, LocalArgumentError |
Local operations |
The error is logged as a CRITICAL message by the module before being raised, then handled by the execution context like any other error, or by your script.
exit_on_error argumentMost methods accept an exit_on_error argument, True by default. When set to False, errors are only logged as warnings and the method goes on: A mirror skips the faulty file, a recursive deletion skips the faulty entry, exec() returns the result of the failed command.
Connection errors, argument errors and Cifs.open() errors are always raised.
from scrippy_remote import ScrippyRemoteError
try:
with Ssh(username="luiggi", host="srv.flying.circus", key="/home/luiggi/.ssh/id_rsa") as ssh:
ssh.delete_remote_dir(remote_dir="/home/luiggi/dead", recursive=True)
except ScrippyRemoteError as err:
logger.error(f"[+] Cleanup failed: {err}")
from scrippy_remote.ssh import Ssh
Ssh(username, host, port=22, password=None, key=None, missing_host_key_policy="warn",
allow_agent=True, look_for_keys=True, timeout=60)
| Parameter | Description | Default |
|---|---|---|
username |
The user name | |
host |
The remote host | |
port |
The TCP port | 22 |
password |
The password. When key is provided, the passphrase of the key |
None |
key |
The private key file | None |
missing_host_key_policy |
What to do when the remote host is not in ~/.ssh/known_hosts: auto (accept), warn (accept with a warning) or reject (refuse) |
warn |
allow_agent |
Use the SSH agent | True |
look_for_keys |
Search for private keys in ~/.ssh/ |
True |
timeout |
Waiting time in seconds for the connection and the SFTP operations. Commands run with exec() are not limited |
60 |
missing_host_key_policy="reject" to also refuse unknown hosts, which is recommended in production.ssh.client is the underlying paramiko.SSHClient and ssh.sftp the paramiko.SFTPClient, for operations not provided by Ssh.Tip: Store the password or the key passphrase in the configuration file, declared as secret.
result = ssh.exec(command, exit_on_error=True)
Runs command on the remote host and returns a dictionary:
| Key | Content |
|---|---|
exit_code |
The exit status of the command, -1 when none was received |
stdout |
The list of the lines of the standard output, stripped, empty lines dropped |
stderr |
The list of the lines of the standard error, stripped, empty lines dropped |
The standard output lines are logged in DEBUG level, the standard error lines in ERROR level, as they are received.
A non-zero exit status raises a SshExecError containing the standard error. With exit_on_error=False, it is logged as a warning and the result is returned:
result = ssh.exec(command="systemctl is-active nginx", exit_on_error=False)
if result["exit_code"] != 0:
logger.warning("[+] nginx is not running")
Warning: The command is run by the remote shell: Quote the values you insert into it, i.e. with shlex.quote() as disk_watch.py does.
from scrippy_remote.ftp import Ftp
Ftp(host, username, password, port=None, tls=True, explicit=True, ssl_verify=True,
ssl_version=None, timeout=60)
| Parameter | Description | Default |
|---|---|---|
host |
The remote host | |
username |
The user name, anonymous for an anonymous connection |
|
password |
The password | |
port |
The TCP port | 990 for implicit TLS, 21 otherwise |
tls |
Encrypt the connection and the transfers. When False, credentials and data are sent in clear text |
True |
explicit |
True: Explicit TLS (FTPES). False: Implicit TLS (FTPS) |
True |
ssl_verify |
Verify the server certificate. When False, a warning is logged at each connection |
True |
ssl_version |
The only TLS version allowed: TLSv1, TLSv1_1, TLSv1_2 or TLSv1_3 |
The highest version supported by both sides |
timeout |
Waiting time in seconds | 60 |
tls=False for a plain FTP server.list_remote_dir() requires the server to return Unix style listings (ls -l format), which is the case of most servers. Symbolic links are not listed.with Ftp(host="ftp.flying.circus", username="anonymous", password="", tls=False) as ftp:
ftp.get_file(remote_file="pub/parrot.txt", local_dir=workspace_path)
from scrippy_remote.cifs import Cifs
Cifs(host, shared_folder, username, password, port=445, use_ntlm_v2=True,
is_direct_tcp=True, timeout=60)
| Parameter | Description | Default |
|---|---|---|
host |
The remote host | |
shared_folder |
The shared folder | |
username |
The user name | |
password |
The password | |
port |
The TCP port | 445 |
use_ntlm_v2 |
Use NTLMv2 authentication | True |
is_direct_tcp |
Use direct TCP (port 445) instead of NetBIOS (port 139) | True |
timeout |
Waiting time in seconds | 60 |
/ as separator. The shared folder root is /.cifs.client is the underlying pysmb SMBConnection.Note: CIFS support is functional but not guaranteed over time.
cifs.open(remote_file, mode)
Opens a remote file in binary mode, for reading (mode="r") or writing (mode="w"), as a context manager. In read mode, the whole file is downloaded when opening it. In write mode, the content is sent when leaving the with block.
with Cifs(host="srv.flying.circus", shared_folder="storage",
username="luiggi", password="dead_parrot") as cifs:
with cifs.open(remote_file="luiggi/inquisition.txt", mode="w") as w_file:
w_file.write(b"Nobody expects the Spanish inquisition")
with cifs.open(remote_file="luiggi/inquisition.txt", mode="r") as r_file:
content = r_file.read()
The following methods are provided by Ssh, Ftp and Cifs, with the same arguments. All of them accept exit_on_error (see Error handling).
| Method | Description |
|---|---|
get_file(remote_file, local_dir, create_dirs=False) |
Retrieves remote_file into local_dir |
put_file(local_file, remote_dir, create_dirs=False) |
Sends local_file into remote_dir |
local_mirror(remote_dir, local_dir) |
Copies the remote directory tree into local_dir |
remote_mirror(local_dir, remote_dir) |
Copies the local directory tree into remote_dir |
list_remote_dir(remote_dir, file_type="f", pattern=".*") |
Lists the files or directories of remote_dir |
create_remote_dir(remote_dir, create_dirs=False) |
Creates remote_dir |
delete_remote_file(remote_file) |
Deletes remote_file |
delete_remote_dir(remote_dir, recursive=False) |
Deletes remote_dir and its files |
get_file()The file is stored in local_dir, which must exist. With create_dirs=True, the remote file path is reproduced below local_dir, created if needed: /data/parrot.txt is stored as <local_dir>/data/parrot.txt. On failure, no partial local copy is left.
put_file()The file is stored in remote_dir. With create_dirs=True, the missing directories of remote_dir are created.
local_mirror() and remote_mirror()The content of the source directory is reproduced as is in the destination directory, empty directories included: <source>/a/b.txt is stored as <destination>/a/b.txt. The destination directory is created if needed.
ssh.local_mirror(remote_dir="/var/www/site", local_dir=os.path.join(workspace_path, "site"))
See Mirrors for their exact behavior.
list_remote_dir()Returns the names (not full paths) of the entries of remote_dir matching pattern, a regular expression. file_type is f for files (default) or d for directories. It is not recursive.
logs = ssh.list_remote_dir(remote_dir="/var/log/nginx", pattern=r".*\.log$")
create_remote_dir()The parent directory must exist and remote_dir must not. With create_dirs=True, the missing parent directories are created and an existing remote_dir is accepted, as with mkdir -p.
delete_remote_file() and delete_remote_dir()delete_remote_dir() deletes the directory and its files. With recursive=False (default), it fails if the directory contains sub directories.
scrippy-remote does not provide a ready-made synchronization tool such as rsync: It provides the building blocks to write the synchronization that fits each need, with a full control over what is transferred, deleted and logged. This section describes these building blocks and gives tested recipes.
All the examples use an open Ssh connection named ssh. The same methods are available with Ftp and Cifs.
local_mirror() and remote_mirror() copy a whole directory tree in one call:
local_mirror(remote_dir, local_dir) |
remote_mirror(local_dir, remote_dir) |
|
|---|---|---|
| Direction | Remote host to local host | Local host to remote host |
| Destination directory | Created if needed | Created if needed, with its missing parents |
| Sub directories | Copied recursively, empty ones included | Copied recursively, empty ones included |
| Existing files | Overwritten | Overwritten |
| Files only present in the destination | Kept | Kept |
| Symbolic links | Followed: The target is copied | Followed: The target is copied |
| Errors | Stop at the first error, or skipped with exit_on_error=False |
Stop at the first error, or skipped with exit_on_error=False |
A mirror is a copy, not a synchronization:
sync_reports() function of disk_watch.py), or copy into an empty directory, such as a new subdirectory of the workspace.list_remote_dir() and list_local_dir() return file names, not paths, filtered by the same regular expressions: Their results can be compared directly with Python sets.
remote_files = set(ssh.list_remote_dir(remote_dir="/var/log/app", file_type="f", pattern=r"^app-\d{8}\.log$"))
local_files = set(list_local_dir(local_dir=archive_dir, file_type="f", pattern=r"^app-\d{8}\.log$"))
to_download = remote_files - local_files
to_delete = local_files - remote_files
Note: Comparing names does not detect a file whose content changed. This is the right choice for files that are written once and never modified, such as reports, logs or exports with a date in their name. For files that may change, transfer them again, i.e. with a mirror.
Tip: Anchor the patterns (^...$) and make them as specific as possible: A synchronization deletes the files that match the pattern on one side only.
The recipes below are functions of a complete Scrippy script, given at the end of this section.
The remote directory is copied into the workspace, then archived into the report directory. The copy is deleted with the workspace at the end of the execution:
def backup_directory(ssh, remote_dir, workspace_path, reportdir):
"""Copies a remote directory tree and archives it into the report directory."""
name = f"{ssh.host}_{os.path.basename(remote_dir)}_{datetime.date.today():%Y%m%d}"
local_dir = os.path.join(workspace_path, name)
ssh.local_mirror(remote_dir=remote_dir, local_dir=local_dir)
archive = shutil.make_archive(os.path.join(reportdir, name), "gztar", root_dir=workspace_path, base_dir=name)
logger.info(f"[+] Backup: {archive}")
return archive
backup_directory(ssh, "/etc/nginx", _context.workspace_path, _context.env["reportdir"])
[INFO ] [+] Backup: /home/luiggi/.local/share/scrippy/reports/srv1_nginx_20261008.tar.gz
The local directory is copied to the remote host, then a command adjusts the permissions, which are not preserved by the copy:
def deploy_directory(ssh, local_dir, remote_dir):
"""Copies a local directory tree to the remote host, then restricts its permissions."""
ssh.remote_mirror(local_dir=local_dir, remote_dir=remote_dir)
ssh.exec(command=f"chmod -R go-w {remote_dir}")
deploy_directory(ssh, os.path.join(_context.env["datadir"], "site"), "/var/www/site")
Warning: The files removed from the local directory remain on the remote host. To deploy an exact copy, deploy into a new directory (i.e: /var/www/site-<version>), then switch a symbolic link with ssh.exec().
Only the remote files that are not present locally are retrieved: Running it again transfers nothing.
def download_new_files(ssh, remote_dir, local_dir, pattern):
"""Retrieves the remote files matching the pattern that are not yet in the local directory."""
remote_files = ssh.list_remote_dir(remote_dir=remote_dir, file_type="f", pattern=pattern)
local_files = list_local_dir(local_dir=local_dir, file_type="f", pattern=pattern)
new_files = sorted(set(remote_files) - set(local_files))
for name in new_files:
ssh.get_file(remote_file=f"{remote_dir}/{name}", local_dir=local_dir)
logger.info(f"[+] {len(new_files)} new file(s) downloaded")
return new_files
[INFO ] [+] 4 new file(s) downloaded
[INFO ] [+] 0 new file(s) downloaded
The remote files are retrieved, then deleted from the remote host, i.e. to process the exports dropped by another application. A file is only deleted once successfully retrieved: A failed file stays on the remote host and is collected by the next execution.
def collect_files(ssh, remote_dir, local_dir, pattern):
"""Moves the remote files matching the pattern to the local directory: A file is deleted once retrieved."""
collected = []
for name in ssh.list_remote_dir(remote_dir=remote_dir, file_type="f", pattern=pattern):
try:
ssh.get_file(remote_file=f"{remote_dir}/{name}", local_dir=local_dir)
except ScrippyRemoteError as err:
# The file stays on the remote host and will be collected by the next execution
logger.error(f" '-> {name}: {err}")
continue
ssh.delete_remote_file(remote_file=f"{remote_dir}/{name}")
collected.append(name)
logger.info(f"[+] {len(collected)} file(s) collected")
return collected
[INFO ] [+] 2 file(s) collected
def purge_remote_files(ssh, remote_dir, pattern):
"""Deletes the remote files matching the pattern, i.e. old logs."""
for name in ssh.list_remote_dir(remote_dir=remote_dir, file_type="f", pattern=pattern):
ssh.delete_remote_file(remote_file=f"{remote_dir}/{name}")
purge_remote_files(ssh, "/var/log/app", r"^app-202609\d{2}\.log$")
To delete a whole directory tree, use ssh.delete_remote_dir(remote_dir, recursive=True).
With exit_on_error=False, a mirror skips the files it can not transfer, logs them as warnings and goes on:
def mirror_what_can_be(ssh, remote_dir, local_dir):
"""Copies a remote directory tree, skipping the files that can not be read."""
ssh.local_mirror(remote_dir=remote_dir, local_dir=local_dir, exit_on_error=False)
[WARNING ] [SshTransferError] PermissionError: [Errno 13] Permission denied
Without exit_on_error=False, the same mirror stops at the unreadable file with a ScrippyRemoteError.
#!/usr/bin/env python3
"""
--------------------------------------------------------------------------------
@author : Luiggi Vercotti
@date : 2026-10-08
@version : 1.0.0
@description : SFTP file manipulation recipes
--------------------------------------------------------------------------------
Update:
1.0.0 2026-10-08 - Luiggi Vercotti - cre: Script creation
--------------------------------------------------------------------------------
List of mandatory configuration parameters:
@conf:remote|user|str|false
@conf:remote|key|str|false
--------------------------------------------------------------------------------
List of execution options and argument parameters:
@args:host|str|Remote host|1||||true|false
--------------------------------------------------------------------------------
Functioning:
---------------
Runs the SFTP recipes of the scrippy-remote documentation against a host.
"""
import os
import shutil
import datetime
import scrippy_core
from scrippy_core import logger
from scrippy_remote import ScrippyRemoteError
from scrippy_remote.ssh import Ssh
from scrippy_remote.local import list_local_dir
def backup_directory(ssh, remote_dir, workspace_path, reportdir):
"""Copies a remote directory tree and archives it into the report directory."""
name = f"{ssh.host}_{os.path.basename(remote_dir)}_{datetime.date.today():%Y%m%d}"
local_dir = os.path.join(workspace_path, name)
ssh.local_mirror(remote_dir=remote_dir, local_dir=local_dir)
archive = shutil.make_archive(os.path.join(reportdir, name), "gztar", root_dir=workspace_path, base_dir=name)
logger.info(f"[+] Backup: {archive}")
return archive
def deploy_directory(ssh, local_dir, remote_dir):
"""Copies a local directory tree to the remote host, then restricts its permissions."""
ssh.remote_mirror(local_dir=local_dir, remote_dir=remote_dir)
ssh.exec(command=f"chmod -R go-w {remote_dir}")
def download_new_files(ssh, remote_dir, local_dir, pattern):
"""Retrieves the remote files matching the pattern that are not yet in the local directory."""
remote_files = ssh.list_remote_dir(remote_dir=remote_dir, file_type="f", pattern=pattern)
local_files = list_local_dir(local_dir=local_dir, file_type="f", pattern=pattern)
new_files = sorted(set(remote_files) - set(local_files))
for name in new_files:
ssh.get_file(remote_file=f"{remote_dir}/{name}", local_dir=local_dir)
logger.info(f"[+] {len(new_files)} new file(s) downloaded")
return new_files
def collect_files(ssh, remote_dir, local_dir, pattern):
"""Moves the remote files matching the pattern to the local directory: A file is deleted once retrieved."""
collected = []
for name in ssh.list_remote_dir(remote_dir=remote_dir, file_type="f", pattern=pattern):
try:
ssh.get_file(remote_file=f"{remote_dir}/{name}", local_dir=local_dir)
except ScrippyRemoteError as err:
# The file stays on the remote host and will be collected by the next execution
logger.error(f" '-> {name}: {err}")
continue
ssh.delete_remote_file(remote_file=f"{remote_dir}/{name}")
collected.append(name)
logger.info(f"[+] {len(collected)} file(s) collected")
return collected
def purge_remote_files(ssh, remote_dir, pattern):
"""Deletes the remote files matching the pattern, i.e. old logs."""
for name in ssh.list_remote_dir(remote_dir=remote_dir, file_type="f", pattern=pattern):
ssh.delete_remote_file(remote_file=f"{remote_dir}/{name}")
def mirror_what_can_be(ssh, remote_dir, local_dir):
"""Copies a remote directory tree, skipping the files that can not be read."""
ssh.local_mirror(remote_dir=remote_dir, local_dir=local_dir, exit_on_error=False)
def main():
with scrippy_core.ScriptContext() as _context:
config = _context.config
workspace_path = _context.workspace_path
with Ssh(username=config.get("remote", "user"),
host=_context.args.host,
key=config.get("remote", "key"),
missing_host_key_policy="reject") as ssh:
backup_directory(ssh, "/etc/nginx", workspace_path, _context.env["reportdir"])
site = os.path.join(_context.env["datadir"], "site")
deploy_directory(ssh, site, "/var/www/site")
logs = os.path.join(workspace_path, "logs")
os.makedirs(logs)
download_new_files(ssh, "/var/log/app", logs, r"^app-\d{8}\.log$")
download_new_files(ssh, "/var/log/app", logs, r"^app-\d{8}\.log$")
incoming = os.path.join(workspace_path, "incoming")
os.makedirs(incoming)
collect_files(ssh, "/data/outgoing", incoming, r".*\.csv$")
purge_remote_files(ssh, "/var/log/app", r"^app-202609\d{2}\.log$")
mirror_what_can_be(ssh, "/etc/nginx", os.path.join(workspace_path, "nginx"))
logger.info(f"[+] Remote logs: {ssh.list_remote_dir(remote_dir='/var/log/app')}")
if __name__ == '__main__':
main()
The scrippy_remote.local module provides the same operations on the local host, without any connection. They are also available as methods of Ssh, Ftp and Cifs.
| Function | Description |
|---|---|
list_local_dir(local_dir, file_type="f", pattern=".*") |
Lists the files or directories of local_dir, as list_remote_dir() |
create_local_dir(local_dir) |
Creates local_dir and its missing parents |
delete_local_file(local_file) |
Deletes local_file |
delete_local_dir(local_dir, recursive=False) |
Deletes local_dir and its files, as delete_remote_dir() |
from scrippy_remote.local import list_local_dir
reports = list_local_dir(local_dir=reportdir, pattern=r"disk_watch_.*\.html$")