Back to all nuggets
Python
python
Published on July 15, 2026
Code Snippet
"""
ftp_deploy.py — upload only changed files, retry on drop, clear caches.
Usage:
python ftp_deploy.py
"""
import ftplib
import io
import os
import socket
import time
import urllib.request
import urllib.error
# ── config ────────────────────────────────────────────────────────────────
FTP_HOST = "example.com"
FTP_USER = "your_ftp_user"
FTP_PASS = "your_ftp_password"
REMOTE_ROOT = "public_html" # directory that contains artisan / index.php
LOCAL_ROOT = r"C:\Projects\myapp" # local project root
SITE_URL = "https://example.com"
FILES = [
"bootstrap/app.php",
"app/Http/Controllers/HomeController.php",
"resources/views/home.blade.php",
"public/css/app.css",
]
RETRIES = 4
TIMEOUT = 60
TIME_SLACK = 120 # seconds of clock skew tolerance
# ── connection wrapper with auto-reconnect ────────────────────────────────
class FTPClient:
def __init__(self):
self.ftp = None
self._connect()
def _connect(self):
if self.ftp:
try: self.ftp.close()
except Exception: pass
self.ftp = ftplib.FTP(FTP_HOST, timeout=TIMEOUT)
self.ftp.login(FTP_USER, FTP_PASS)
self.ftp.voidcmd("TYPE I")
def retry(self, fn, label: str):
for attempt in range(1, RETRIES + 1):
try:
return fn(self.ftp)
except (EOFError, ConnectionError, TimeoutError,
socket.error, ftplib.error_temp) as exc:
print(f" ! {label} dropped ({exc!r}) — reconnecting ({attempt}/{RETRIES})")
time.sleep(2 * attempt)
try: self._connect()
except Exception as ce: print(f" ! reconnect failed: {ce}")
raise RuntimeError(f"gave up on {label} after {RETRIES} attempts")
# ── remote helpers ────────────────────────────────────────────────────────
def remote_stat(client: FTPClient, rpath: str):
"""Return (size, mtime_epoch) or (None, None) if the file does not exist."""
def do(ftp):
try:
ftp.voidcmd("TYPE I")
size = ftp.size(rpath)
except ftplib.error_perm:
return (None, None)
mtime = None
try:
import calendar
ts = ftp.voidcmd(f"MDTM {rpath}") # '213 YYYYMMDDHHMMSS'
mtime = calendar.timegm(time.strptime(ts[4:18].strip(), "%Y%m%d%H%M%S"))
except Exception:
pass
return (size, mtime)
return client.retry(do, f"STAT {rpath}")
def ensure_dirs(client: FTPClient, rpath: str) -> None:
"""Create every directory component of rpath that does not yet exist."""
parts = rpath.split("/")[:-1]
cur = ""
for part in parts:
cur = f"{cur}/{part}" if cur else part
def mk(ftp, d=cur):
try: ftp.mkd(d)
except ftplib.error_perm: pass # already exists — fine
client.retry(mk, f"MKD {cur}")
def upload_bytes(client: FTPClient, data: bytes, rpath: str) -> None:
client.retry(lambda ftp: ftp.storbinary("STOR " + rpath, io.BytesIO(data)), f"STOR {rpath}")
# ── smart upload ──────────────────────────────────────────────────────────
def upload_if_changed(client: FTPClient, relpath: str) -> bool:
lpath = os.path.join(LOCAL_ROOT, relpath.replace("/", os.sep))
if not os.path.exists(lpath):
print(f" MISSING locally — skipped: {relpath}")
return False
with open(lpath, "rb") as f:
data = f.read()
rpath = f"{REMOTE_ROOT}/{relpath}"
rsize, rmtime = remote_stat(client, rpath)
lmtime = os.path.getmtime(lpath)
if rsize == len(data) and rmtime is not None and rmtime >= lmtime - TIME_SLACK:
print(f" SAME {relpath}")
return False
ensure_dirs(client, rpath)
upload_bytes(client, data, rpath)
print(f" UP {relpath} ({len(data):,} bytes)")
return True
# ── post-deploy cache clear ───────────────────────────────────────────────
RUNNER = """\
<?php
// One-time runner — self-deletes after execution.
if (($_GET['t'] ?? '') !== '__TOKEN__') { http_response_code(403); exit; }
require __DIR__ . '/../vendor/autoload.php';
$app = require __DIR__ . '/../bootstrap/app.php';
$kernel = $app->make(Illuminate\\Contracts\\Console\\Kernel::class);
foreach (['config:clear', 'route:clear', 'view:clear'] as $cmd) {
$kernel->call($cmd);
}
echo json_encode(['ok' => true]);
@unlink(__FILE__);
"""
def clear_caches(client: FTPClient) -> None:
import secrets
token = secrets.token_urlsafe(16)
fname = f"_run_{secrets.token_hex(4)}.php"
rpath = f"{REMOTE_ROOT}/public/{fname}"
upload_bytes(client, RUNNER.replace("__TOKEN__", token).encode(), rpath)
url = f"{SITE_URL}/{fname}?t={token}"
try:
with urllib.request.urlopen(url, timeout=60) as r:
print(f" Cache clear response: {r.read().decode()[:120]}")
except urllib.error.HTTPError as e:
print(f" HTTP {e.code} calling runner")
except Exception as e:
print(f" Could not reach runner: {e}")
try:
client.retry(lambda ftp: ftp.delete(rpath), f"DELE {rpath}")
except Exception:
pass # runner self-deleted — fine
# ── main ──────────────────────────────────────────────────────────────────
def main():
client = FTPClient()
changed = sum(upload_if_changed(client, f) for f in FILES)
print(f"\n{changed} file(s) uploaded.")
if changed:
clear_caches(client)
try: client.ftp.quit()
except Exception: pass
if __name__ == "__main__":
main()
Manually uploading files over FTP after every code change is error-prone and
slow. A Python script using the built-in ftplib module can
automate this completely — uploading only changed files,
recovering from dropped connections, and then triggering a remote
cache-clear via a one-time PHP runner.
The three key ideas in this pattern are:
-
Smart diffing — compare local file size and modification
time against the remote size (
SIZE) and timestamp (MDTM) so unchanged files are skipped entirely. -
Retry with reconnect — FTP connections drop on shared
hosting. A
retry()wrapper catches transient errors, reconnects, and retries up to N times before giving up. -
Remote directory creation —
ensure_dirs()walks the path components and callsMKDfor any that do not exist yet, so you never get a "directory not found" upload failure.
After uploading, the script places a one-time PHP runner in the web root, calls it over HTTPS to flush caches or run migrations, then deletes it — leaving no persistent backdoor.
Love this nugget?
Support our work to get exclusive premium nuggets and early access via a personal activation link.
Support Now & Unlock Exclusive Content