For every problem there is always, at least, a solution which seems quite plausible. It is simple and clean, direct, neat and nice, and yet very wrong, Anawim, justtothepoint.com

Managing a mixed environment —spanning Arch Linux for local development and NixOS for remote backups— can be tedious. That’s why I wrote weekly-tasks.py, a robust Python automation daemon designed to streamline my weekly maintenance routine.
This script acts as a comprehensive pipeline executor that runs tasks ranging from system updates and mantaining tasks, SSH backups, to Hugo site deployment and live verification. It’s built with resilience in mind: if one step fails, the pipeline logs the error and continues to the next task, ensuring that non-critical maintenance (like greetings or local server starts) isn’t blocked by a temporary network glitch.
Key features include automatic virtual environment activation, colored console logging, dry-run capabilities, and a flexible CLI that allows skipping specific steps or starting from a particular point in the sequence.
#!/usr/bin/env python3
"""
weekly-tasks.py — Automated weekly maintenance pipeline
─────────────────────────────────────────────────────────────────────────────
WHAT THIS SCRIPT DOES
Runs six tasks in sequence. Each task is guarded — if it fails, the
script reports the failure clearly and continues to the next task
rather than aborting the whole pipeline.
Tasks:
1. Arch maintenance bash arch-maintain.sh --auto (includes requirements.txt update)
2. NixOS backup SSH → 192.168.1.49 → ~/dotfiles/scripts/mybackup.sh
3. Hugo deploy bash deploy.sh (hugo build + rsync to hosting)
4. Deployment check verify the newest HTML is live at justtothepoint.com
5. Hugo server bash webserver.sh (local preview)
6. Link check link_checker.py run locally from the mypython venv
7. Greetings today(config.root_todo)
Usage:
python3 weekly-tasks.py # run all steps
python3 weekly-tasks.py --skip 2,5 # skip steps by number
python3 weekly-tasks.py --dry-run # print plan, execute nothing
python3 weekly-tasks.py --from 3 # start from step 3
SCHEDULING (cron example — every Wednesday at 08:00)
crontab -e
0 8 * * 3 python3 /home/nmaximo7/dotfiles/myArch/admin/weekly-tasks.py
SCHEDULING (systemd timer — recommended on Arch)
See the unit file template at the bottom of this file.
REQUIREMENTS
pip install fabric # SSH remote execution
sudo pacman -S openssh # SSH client
The NixOS server (192.168.1.49) must:
• Be reachable on the LAN
• Have your SSH public key in ~/.ssh/authorized_keys
• Have ~/dotfiles/scripts/mybackup.sh executable
DEPENDENCIES
fabric — pip install fabric (paramiko-based SSH)
All other imports are stdlib.
"""
import subprocess
import sys
import time
import argparse
import traceback
import urllib.request
import urllib.error
import os
from pathlib import Path
from datetime import datetime
from fabric import Connection
# ── Project-specific imports ──────────────────────────────────────────────────
# These are local modules living in the same Python project on the Arch machine.
# They are imported unconditionally because the script is intended to run from
# within the project directory (or after the venv guard activates).
from link_checker import check_hugo_site # link checking logic
from hugo_config import config # site configuration (theme, URL, etc.)
from greetings import today # daily greeting message generator
# ══════════════════════════════════════════════════════════════════════════════
# CONFIGURATION — edit these to match your setup
# ══════════════════════════════════════════════════════════════════════════════
# Remote NixOS server connection details.
# No fallback to ssh-agent; the SSH key must be present and unlocked.
SSH_HOST = "192.168.1.49"
SSH_USER = "nmaximo7"
SSH_KEY = Path.home() / ".ssh/id_rsa" # or id_ed25519
# The admin directory on the local Arch machine.
# Contains shell scripts for maintenance and deployment.
ADMIN_DIR = Path.home() / "dotfiles/myArch/admin"
# Live Hugo site URL.
LIVE_URL = "https://justtothepoint.com"
# The directory where Hugo generates the site (the production public/ folder).
# This is on a separate data drive (/mnt/data).
PUBLIC_DIR = Path("/mnt/data/justtothepoint/public")
# Local Python project — link checker and greetings live here.
# The virtual environment is used to run the link checker and
# the greetings script with the correct dependencies.
LOCAL_PYTHON_DIR = Path("/mnt/data/justtothepoint/mypython")
LOCAL_VENV = LOCAL_PYTHON_DIR / "venv"
LOCAL_PYTHON = LOCAL_VENV / "bin" / "python3"
LOCAL_ACTIVATE = LOCAL_VENV / "bin" / "activate"
# After a Hugo deploy, the script waits a few seconds before hitting
# the live URL to ensure Nginx has reloaded and the new content is served.
HUGO_WARMUP_SECS = 30
# ══════════════════════════════════════════════════════════════════════════════
# VENV HELPERS
# ══════════════════════════════════════════════════════════════════════════════
def is_in_venv() -> bool:
"""
Return True if we are currently running inside a virtual environment.
Python sets sys.prefix to the venv root when activated. Comparing it
to sys.base_prefix (the real system Python) tells us if we're in a venv.
sys.base_prefix is available from Python 3.3+; sys.real_prefix is set
by older virtualenv versions, so we check both for maximum compatibility.
This check is reliable even when the venv was activated outside the script,
e.g., `source venv/bin/activate && python3 weekly-tasks.py`.
"""
in_venv = (
sys.prefix != sys.base_prefix
or getattr(sys, "real_prefix", None) is not None
)
return in_venv
def ensure_venv() -> None:
"""
If we are NOT in the project venv, re‑launch this script inside it.
This means you can call `python3 weekly-tasks.py` from any shell —
even without activating the venv first — and the script will
transparently restart itself in the correct Python environment.
WHY re‑exec instead of "activating" inside the process:
`source venv/bin/activate` is a bash command that modifies the
shell's PATH and environment variables. There is no equivalent
in Python — you cannot "activate" a venv from inside a running
Python process because sys.path and sys.prefix are already set
at startup. The only correct way to run inside a venv is to
exec() the venv's Python binary with the same script and arguments.
If the venv Python is missing, a helpful warning is printed, but the
script continues without the venv. Some steps (like the link checker)
may fail later because their dependencies are not installed globally.
"""
if is_in_venv():
return # already in the right venv — nothing to do
if not LOCAL_PYTHON.exists():
Log.warn(
f"No venv found at {LOCAL_VENV}\n"
f" Create it: python3 -m venv {LOCAL_VENV}\n"
f" Install: {LOCAL_PYTHON} -m pip install -r {LOCAL_PYTHON_DIR}/requirements.txt"
)
return # continue without venv (some steps may fail, but not fatal)
Log.info(f"Not in venv — restarting with {LOCAL_PYTHON}")
# os.execv replaces the current process image with the new Python.
# The original process does not continue; the new process starts
# fresh with the correct sys.prefix and site‑packages.
# This line replaces the current running process with a new Python interpreter — the one at LOCAL_PYTHON — and passes it the same command‑line arguments.
# The new interpreter has the right virtualenv!!!
os.execv(str(LOCAL_PYTHON), [str(LOCAL_PYTHON)] + sys.argv)
class Log:
"""
Simple colourised console logger with structured formatting.
The colours are only applied if stdout is a TTY (i.e., not piped to a
file or systemd journal). This keeps the output readable in logs while
providing visual cues during interactive runs.
All methods are classmethods because the class is never instantiated —
it serves purely as a namespace for logging functions and colour constants.
"""
# This is a common Python pattern used to determine if a program's standard output is connected to an interactive terminal.
# True means the output is going directly to a terminal screen where a user can see it in real-time.
_tty = sys.stdout.isatty()
# Colour codes (empty strings when not on a TTY).
BOLD = "\033[1m" if _tty else ""
BLUE = "\033[34m" if _tty else ""
GREEN = "\033[32m" if _tty else ""
RED = "\033[31m" if _tty else ""
YLW = "\033[33m" if _tty else ""
CYN = "\033[36m" if _tty else ""
RST = "\033[0m" if _tty else ""
@classmethod
def banner(cls, text: str) -> None:
"""Print a large, prominent title block (used for script header and summary)."""
w = 64
print()
print(f"{cls.BOLD}{cls.BLUE}{'═' * w}{cls.RESET}")
print(f"{cls.BOLD}{cls.BLUE} {text}{cls.RESET}")
print(f"{cls.BOLD}{cls.BLUE}{'═' * w}{cls.RESET}")
print()
@classmethod
def step(cls, n: int, title: str) -> None:
"""Announce a pipeline step with a number and a dashed underline."""
bar = "─" * max(0, 52 - len(title))
print(f"\n{cls.BOLD}── Step {n}: {title} {bar}{cls.RESET}\n")
@classmethod
def ok(cls, msg: str) -> None:
"""Success message with green checkmark."""
print(f" {cls.GREEN}✅{cls.RESET} {msg}")
@classmethod
def warn(cls, msg: str) -> None:
"""Warning message with yellow warning sign."""
print(f" {cls.YELLOW}⚠️ {cls.RESET} {msg}")
@classmethod
def fail(cls, msg: str) -> None:
"""Failure message with red cross (to stderr)."""
print(f" {cls.RED}❌{cls.RESET} {msg}", file=sys.stderr)
@classmethod
def info(cls, msg: str) -> None:
"""Informational message with cyan info symbol."""
print(f" {cls.CYAN}ℹ️ {cls.RESET} {msg}")
@classmethod
def detail(cls, line: str) -> None:
"""
Print a sub‑line indented with a dim vertical bar.
Used to display command output or additional context without
cluttering the main log flow.
"""
print(f" {cls.DIM}│ {line}{cls.RESET}")
@classmethod
def elapsed(cls, secs: float) -> str:
"""Format a duration in seconds as a human‑readable string (e.g., '2m 35s')."""
m, s = divmod(int(secs), 60)
return f"{m}m {s}s" if m else f"{s}s"
# ══════════════════════════════════════════════════════════════════════════════
# RESULT TRACKING
# ══════════════════════════════════════════════════════════════════════════════
class StepResult:
"""
Holds the outcome of a single pipeline step, to be assembled into a final summary.
Status constants are class attributes for consistency and readability.
Each step starts as SKIPPED until explicitly marked by the task runner.
"""
OK = "ok"
FAIL = "failed"
WARN = "warning"
SKIP = "skipped"
def __init__(self, number: int, name: str):
self.number = number
self.name = name
self.status = self.SKIP # default until proven otherwise
self.notes = "" # extra details (error messages, paths, etc.)
self.duration = 0.0 # wall‑clock time for the step
def ok(self, n="") -> None: self.status = self.OK; self.notes = n
def fail(self, n="") -> None: self.status = self.FAIL; self.notes = n
def warn(self, n="") -> None: self.status = self.WARN; self.notes = n
def skip(self, n="") -> None: self.status = self.SKIP; self.notes = n
# ══════════════════════════════════════════════════════════════════════════════
# RUNNERS
# ══════════════════════════════════════════════════════════════════════════════
def run_local(script_name: str, *args: str, cwd: Path | None = None,
python: bool = False) -> tuple[bool, str]:
"""
Run a local script and stream its output through Log.detail().
The script is executed either as a bash script (from ADMIN_DIR) or as a
Python script (from LOCAL_PYTHON_DIR using the venv Python). This dual
mode keeps the pipeline configuration simple: shell maintenance scripts
stay in the admin folder, Python tools live in the project folder.
python=True runs it with the venv Python instead of bash.
In that case, the script path is resolved relative to LOCAL_PYTHON_DIR,
and the command becomes: [args...]
WHY separate python mode:
Python tools (link checker, greetings) have dependencies managed by
the venv. Running them with bash would not work; using the venv Python
ensures the correct environment without needing to pre‑activate.
WHY stream output in real time:
Long‑running tasks (arch-maintain.sh, deploy.sh) produce continuous
feedback. The user should see it as it happens, not only at the end.
Args:
script_name: filename of the script (e.g., "arch-maintain.sh").
*args: arguments to pass to the script.
cwd: working directory; defaults to the script's own directory.
python: if True, run as a Python script via the venv Python.
Returns:
tuple of (bool, str): success flag and the combined stdout+stderr output.
On file‑not‑found, returns (False, "").
"""
# Build command and label based on script type.
if python:
# If python = true, we want the venv Python, the script inside the Python tools directory, correct dependencies
# So the command becomes: venv-python script arg1 arg2 ...
script_path = LOCAL_PYTHON_DIR / script_name
cmd = [str(LOCAL_PYTHON), str(script_path), *args]
label = f"python3 {script_name} {' '.join(args)}"
else:
# If python = false, we want a shell script, run by bash inside the admin folder
# So the command becomes: bash script arg1 arg2 ...
script_path = ADMIN_DIR / script_name
cmd = ["bash", str(script_path), *args]
label = f"bash {script_name} {' '.join(args)}"
# Early existence check avoids a cryptic subprocess error (FileNotFoundError: [Errno 2] No such file or directory)
if not script_path.exists():
Log.fail(f"Not found: {script_path}")
return False, ""
Log.info(f"$ {label}")
# Many scripts assume relative paths. Running them from the wrong directory breaks them
# Defaulting to the script’s own folder makes them robust
# If the caller wants a different working directory, they can override it.
run_cwd = cwd or script_path.parent
# Launch the subprocess with stdout and stderr merged.
# bufsize=1 enables line‑buffered reading, so we can print immediately.
proc = subprocess.Popen(
cmd,
cwd = str(run_cwd),
stdout = subprocess.PIPE, # so we can read output line by line
stderr = subprocess.STDOUT, # merge both streams for unified logging
text = True, # decode bytes into strings
bufsize = 1, # line‑buffered mode (real‑time output)
)
# Streaming output in real time
lines: list[str] = []
# We know stdout is a pipe because we set stdout=PIPE.
# This loop reads each line immediately
assert proc.stdout
for raw in proc.stdout:
# Remove (strip) trailing newline for cleaner output.
line = raw.rstrip()
# Skip empty lines to avoid clutter.
if line.strip():
Log.detail(line)
lines.append(line)
proc.wait()
# Return exit code 0 = success, plus the full combined output (stdout + stderr).
return proc.returncode == 0, "\n".join(lines)
def run_remote(command: str, label: str = "") -> tuple[bool, str]:
"""
Run a command on the NixOS server via Fabric/SSH.
Fabric (paramiko‑based) is used instead of raw subprocess+ssh because
it handles key negotiation, host key verification, and connection
management cleanly in pure Python. No need for externally configured
~/.ssh/config.
SSH authentication:
- If the SSH key file (SSH_KEY) exists, it is passed as key_filename.
- If the key file does not exist, connect_kwargs is empty; Fabric falls
back to the system's ssh‑agent or password prompt. This allows
flexibility without hard‑coding a key path.
WHY hide="both":
Fabric normally prints remote stdout/stderr live. We capture it
ourselves to process and display through Log.detail(), maintaining
a consistent output style across local and remote steps.
WHY warn=True:
By default, Fabric raises an exception if the remote command returns
a non‑zero exit code. Setting warn=True makes it return the result
object instead, so we control the error handling and reporting.
Output streaming:
The remote command's output is logged only *after* it completes.
This is fine because the backup and link‑check scripts are
relatively short‑lived. If a future step needs
real‑time streaming, we could iterate over the result's stdout
as it comes in, but the added complexity is not needed now.
Args:
command: the shell command line to execute on the remote server.
label: optional human‑readable description shown before the command.
Returns:
tuple of (bool, str): success flag and combined stdout+stderr output.
"""
if label:
Log.info(f"[{SSH_HOST}] {label}")
Log.info(f"$ {command}")
# Determine SSH key to use.
key_path = str(SSH_KEY) if SSH_KEY.exists() else None
connect_kwargs = {"key_filename": key_path} if key_path else {}
try:
conn = Connection(SSH_HOST, user=SSH_USER, connect_kwargs=connect_kwargs)
result = conn.run(command, hide="both", warn=True)
conn.close()
# Combine stdout and stderr for a full picture.
combined = result.stdout + result.stderr
for line in combined.splitlines():
if line.strip():
Log.detail(line)
return result.return_code == 0, combined
except Exception as e:
# This catches authentication failures, timeouts, network errors, etc.
Log.fail(f"SSH error: {e}")
return False, str(e)
# ══════════════════════════════════════════════════════════════════════════════
# DEPLOYMENT VERIFICATION
# ══════════════════════════════════════════════════════════════════════════════
def find_newest_html(public_dir: Path) -> Path | None:
"""
Locate the most recently modified non‑empty HTML file in the public directory.
This function is used after a Hugo deployment to find the very last page that
was written, which corresponds to the most recent content change. By retrieving
the file with the highest modification time (`st_mtime`), we can later verify
that the live server is serving that same content correctly.
The search is recursive (`rglob("*.html")`) to catch pages in subdirectories.
Empty files (st_size == 0) are ignored because Hugo may write placeholder files
that are not real content.
Returns:
The Path to the newest non‑empty HTML file, or None if no HTML files exist
or the public directory is missing.
"""
if not public_dir.exists():
return None
# Sort all .html files descending by modification time.
candidates = sorted(
public_dir.rglob("*.html"),
key=lambda p: p.stat().st_mtime,
reverse=True,
)
# Pick the first that actually has content.
for f in candidates:
if f.stat().st_size > 0:
return f
return None
def html_path_to_url(html_path: Path, public_dir: Path, base_url: str) -> str:
"""
Convert a local HTML file path to its corresponding live URL.
Hugo’s output structure follows conventions:
- A page named `index.html` is served as its parent directory (trailing slash).
e.g., `blog/my-post/index.html` → `/blog/my-post/`
- Any other `.html` file keeps its name.
e.g., `about.html` → `/about.html`
The computed relative path is appended to `base_url` (with no double slashes),
producing a fully qualified URL suitable for HTTP requests.
Args:
html_path: absolute path to the .html file.
public_dir: root of the public directory (where the site was generated).
base_url: base URL of the live site (e.g., "https://example.com").
Returns:
Full URL string.
"""
rel = html_path.relative_to(public_dir)
# If the file is an index.html, strip the filename and use a trailing slash.
url_path = ("/" + str(rel.parent) + "/") if rel.name == "index.html" else ("/" + str(rel))
# Ensure no accidental double slashes, then join with base URL.
return base_url.rstrip("/") + url_path.replace("//", "/")
def check_url(url: str, label: str) -> bool:
"""
Fetch a URL and verify that it returns HTTP 200 with a plausible body length.
The request uses a custom User‑Agent header so that the server can identify
the automated checker. A timeout of 15 seconds prevents the pipeline from
hanging on slow or unreachable hosts.
The body length threshold (> 100 bytes) helps filter out empty 200 responses
that some servers return for placeholder pages.
All network‑related exceptions (HTTP errors, DNS failures, timeouts) are
caught and logged, so a single failed check does not abort the entire pipeline.
Args:
url: The full URL to check.
label: Human‑readable description for log messages (e.g., "Homepage").
Returns:
True if the URL returned 200 and body > 100 bytes, False otherwise.
"""
try:
t0 = time.monotonic()
req = urllib.request.Request(url, headers={"User-Agent": "weekly-tasks/1.0"})
with urllib.request.urlopen(req, timeout=15) as resp:
elapsed_ms = int((time.monotonic() - t0) * 1000)
body_len = len(resp.read())
# Check that the response is positive (resp.status == 200) and meaningful (body_len > 100).
if resp.status == 200 and body_len > 100:
Log.ok(f"{label} HTTP {resp.status} {body_len:,} bytes {elapsed_ms}ms")
return True
Log.warn(f"{label} HTTP {resp.status} body={body_len} bytes")
return False
except urllib.error.HTTPError as e:
Log.fail(f"{label} HTTP {e.code} {e.reason}"); return False
except urllib.error.URLError as e:
Log.fail(f"{label} {e.reason}"); return False
except Exception as e:
Log.fail(f"{label} {e}"); return False
def verify_deployment(r: StepResult) -> None:
"""
Check that the live site is serving content after a Hugo deployment.
Two URLs are verified:
1. The homepage (base URL + "/"). This guarantees the front page works.
2. The newest HTML file found in the public directory (by modification time).
This proves that the most recent content has been published.
If the homepage check fails, the deployment is considered suspicious,
but the step is marked as a warning rather than a hard failure, because
temporary glitches (DNS propagation, Nginx reload timing) can cause
transient 404/500 errors.
Args:
r: StepResult object for the deployment step; its status is updated based on
the outcome of the verification checks.
"""
Log.info("Verifying deployment against live site…")
# Check homepage first.
all_ok = check_url(LIVE_URL + "/", "Homepage")
# Identify the newest HTML file produced by Hugo.
newest = find_newest_html(PUBLIC_DIR)
if newest:
live_url = html_path_to_url(newest, PUBLIC_DIR, LIVE_URL)
mod_time = datetime.fromtimestamp(newest.stat().st_mtime).strftime("%H:%M:%S")
Log.info(f"Newest: {newest.relative_to(PUBLIC_DIR)} (modified {mod_time})")
# Combine result with homepage check.
all_ok = check_url(live_url, "Newest page") and all_ok
else:
Log.warn(f"No HTML found in {PUBLIC_DIR}")
all_ok = False
# Final outcome: if all checks passed, mark ok; otherwise warn (non‑fatal).
if all_ok: r.ok("Live site verified ✓")
else: r.warn("Some URLs did not respond as expected")
# ══════════════════════════════════════════════════════════════════════════════
# PIPELINE STEPS
# ══════════════════════════════════════════════════════════════════════════════
def step_arch_maintain(r: StepResult, dry_run: bool) -> None:
"""
Run the Arch Linux maintenance script (arch-maintain.sh) with --auto.
The step supports two execution modes:
• Dry‑run mode:
No command is executed. A descriptive log entry is emitted and the
StepResult is marked as skipped. This allows the full deployment
pipeline to be rehearsed without touching the system.
• Normal mode:
The script is invoked via run_local() with the --auto flag. The wall‑clock
duration is recorded in the StepResult for later reporting.
Error handling:
A non‑zero exit code from the script marks the step as a failure.
Otherwise, the step is marked successful and the elapsed time is shown.
Args:
r: StepResult for this step; updated with status and duration.
dry_run: If True, skip execution and mark the step as skipped.
"""
if dry_run:
Log.skip("[dry-run] bash arch-maintain.sh --auto"); r.skip("dry-run"); return
t0 = time.monotonic()
ok, _ = run_local("arch-maintain.sh", "--auto")
r.duration = time.monotonic() - t0
if ok: r.ok(Log.elapsed(r.duration))
else: r.fail("Script exited non-zero")
def step_nixos_backup(r: StepResult, dry_run: bool) -> None:
"""
Run the NixOS backup script on the remote server via SSH.
Execution model:
• Normal mode:
The backup script (mybackup.sh) is invoked remotely using run_remote().
The command is executed as:
bash ~/dotfiles/scripts/mybackup.sh
A non‑zero exit code marks the step as a failure.
• Dry‑run mode:
No SSH connection is made and no remote command is executed.
A descriptive log entry is emitted and the step is marked as skipped.
Timing:
The wall‑clock duration of the remote execution is recorded in the
StepResult for reporting and performance visibility.
Args:
r: StepResult for this step; updated with status and duration.
dry_run: If True, skip execution and mark the step as skipped.
"""
if dry_run:
Log.skip(f"[dry-run] SSH {SSH_HOST} → mybackup.sh"); r.skip("dry-run"); return
t0 = time.monotonic()
ok, _ = run_remote("bash ~/dotfiles/scripts/mybackup.sh", "Run mybackup.sh")
r.duration = time.monotonic() - t0
if ok: r.ok(Log.elapsed(r.duration))
else: r.fail("Remote backup reported errors")
def step_hugo_deploy(r: StepResult, dry_run: bool) -> None:
"""
Deploy the Hugo site by running deploy.sh, then verify the live site.
The deployment script is expected to:
1. Build the site (hugo).
2. Sync the public/ directory to the production server.
After a successful deploy, we wait a few seconds for the hosting to propagate
(e.g., Nginx reload, CDN cache invalidation) and then call verify_deployment()
to check that key URLs are reachable.
If deploy.sh itself fails, the step is marked as failed and verification is
skipped entirely.
"""
if dry_run:
Log.skip("[dry-run] bash deploy.sh + verify"); r.skip("dry-run"); return
t0 = time.monotonic()
ok, _ = run_local("deploy.sh")
r.duration = time.monotonic() - t0
if not ok:
r.fail(f"deploy.sh failed after {Log.elapsed(r.duration)}"); return
Log.ok(f"deploy.sh in {Log.elapsed(r.duration)}")
Log.info("Waiting 5s for hosting to propagate…")
time.sleep(5)
verify_deployment(r)
def step_hugo_server(r: StepResult, dry_run: bool) -> None:
"""
Start (or restart) the Hugo development server via webserver.sh.
This step is for local purposes only; it is not part of the production
deployment pipeline but can be triggered manually.
The script is launched in the background (the caller expects it to
continue running). We wait `HUGO_WARMUP_SECS` seconds after execution
to give Hugo time to build the initial pages before marking the step
as done.
If the script exits non‑zero, we issue a warning rather than a failure
because Hugo may still be running; the exit code could reflect a
non‑critical warning during startup.
"""
if dry_run:
Log.skip("[dry-run] bash webserver.sh"); r.skip("dry-run"); return
t0 = time.monotonic()
ok, _ = run_local("webserver.sh")
r.duration = time.monotonic() - t0
Log.info(f"Waiting {HUGO_WARMUP_SECS}s for Hugo…")
time.sleep(HUGO_WARMUP_SECS)
if ok: r.ok(Log.elapsed(r.duration))
else: r.warn("webserver.sh exited non-zero (Hugo may still be running)")
def step_link_check(r: StepResult, dry_run: bool) -> None:
"""
Run link_checker.py locally from the mypython venv.
WHY locally instead of via SSH:
The Hugo dev server (started in step 5) runs on THIS machine at
localhost:1313. Running the link checker locally means it can reach
http://localhost:1313 directly without any SSH tunnel.
The link checker is imported directly (not subprocess) because we are
already running inside the correct venv (ensured at startup by ensure_venv).
The function `check_hugo_site` (imported from link_checker) is expected to
return a boolean: True means no broken links, False means problems found.
Both outcomes are not fatal; the step is marked as "warning" on broken links
so the pipeline continues.
Args:
r: StepResult for step 5 (link check).
dry_run: If True, skip execution.
"""
if dry_run:
Log.skip("[dry-run] check_hugo_site()"); r.skip("dry-run"); return
Log.info(f"Running link_checker.py locally from {LOCAL_PYTHON_DIR}...")
t0 = time.monotonic()
try:
success = check_hugo_site()
r.duration = time.monotonic() - t0
if success:
r.ok(f"No broken links ({Log.elapsed(r.duration)})")
else:
# Broken links are a quality issue, not a system failure.
r.warn(f"Broken links found — check output ({Log.elapsed(r.duration)})")
except Exception as e:
r.fail(str(e))
def step_greetings(r: StepResult, dry_run: bool) -> None:
"""
Run the daily greetings script (today) from the imported `greetings` module.
The function `today(config.root_todo)` generates and distributes morning
greetings based on the todo list and other project configuration.
This step is light and rarely fails. Any exception is captured and
recorded as a failure.
Args:
r: StepResult for step 6 (greetings).
dry_run: If True, skip execution.
"""
if dry_run:
Log.skip("[dry-run] today(config.root_todo)"); r.skip("dry-run"); return
Log.info("Running greetings…")
t0 = time.monotonic()
try:
today(config.root_todo)
r.duration = time.monotonic() - t0
r.ok(Log.elapsed(r.duration))
except Exception as e:
# If the greetings fail, the pipeline should not abort but must
# record the error so the user can investigate later.
r.duration = time.monotonic() - t0
r.fail(str(e))
# ══════════════════════════════════════════════════════════════════════════════
# SUMMARY TABLE
# ══════════════════════════════════════════════════════════════════════════════
def print_summary(results: list[StepResult], total: float) -> None:
"""
Print a final table summarising each pipeline step, its status, duration,
and any notes, along with aggregate counts.
The table uses colour to highlight statuses (green = ok, red = failed, etc.)
and displays durations in seconds for quick scanning. Steps that were skipped
(dry-run or --skip) are shown in cyan.
At the bottom, a textual summary gives counts and a final conclusion:
- All ok → celebratory message.
- Warnings present → warning reminder.
- Failures present → explicit failure notice.
Args:
results: list of StepResult objects in pipeline order.
total: total elapsed wall‑clock time for the entire pipeline.
"""
Log.banner(f"Summary — {Log.elapsed(total)} total")
# Map each status to its display colour for a compact table.
colours = {
"ok": Log.GREEN, "failed": Log.RED,
"warning": Log.YELLOW, "skipped": Log.CYAN,
}
# Table header.
print(f" {'#':<4} {'Status':<10} {'Time':>6} Name")
print(f" {'─'*4} {'─'*10} {'─'*6} {'─'*35}")
# Table rows.
for r in results:
c = colours.get(r.status, "")
dur = f"{r.duration:.0f}s" if r.duration else "—"
print(f" {r.number:<4} {c}{r.status:<10}{Log.RESET} {dur:>6} {r.name}"
+ (f" {Log.DIM}{r.notes}{Log.RESET}" if r.notes else ""))
# Count per status for aggregation.
counts = {s: sum(1 for r in results if r.status == s)
for s in ("ok", "failed", "warning", "skipped")}
print()
print(f" {Log.GREEN}{counts['ok']} ok{Log.RESET} "
f"{Log.RED}{counts['failed']} failed{Log.RESET} "
f"{Log.YELLOW}{counts['warning']} warnings{Log.RESET} "
f"{Log.CYAN}{counts['skipped']} skipped{Log.RESET}")
print()
# Final verdict: prioritise failures, then warnings, then success.
if counts["failed"]: Log.fail("Some steps failed.")
elif counts["warning"]: Log.warn("Some steps had warnings.")
else: Log.ok("All steps completed 🎉")
# ══════════════════════════════════════════════════════════════════════════════
# MAIN
# ══════════════════════════════════════════════════════════════════════════════
PIPELINE = [
(1, "Arch maintenance", step_arch_maintain),
(2, "NixOS backup (SSH)", step_nixos_backup),
(3, "Hugo deploy + verify", step_hugo_deploy),
(4, "Hugo server (local)", step_hugo_server),
(5, "Link checker (local)", step_link_check),
(6, "Greetings", step_greetings),
]
def parse_args() -> argparse.Namespace:
"""
Parse command‑line arguments.
Options:
--dry-run, -n : simulate everything; no changes made.
--skip N,N : comma‑separated list of step numbers to skip.
--from N : start from step N (skip earlier steps).
"""
p = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("--dry-run", "-n", action="store_true")
p.add_argument("--skip", metavar="N,N", default="")
p.add_argument("--from", dest="from_step", metavar="N", type=int, default=1)
return p.parse_args()
def main() -> None:
# ── Ensure we are running in the project venv ─────────────────────────
ensure_venv()
args = parse_args()
dry_run = args.dry_run
skip: set[int] = set()
if args.skip:
try:
skip = {int(x.strip()) for x in args.skip.split(",") if x.strip()}
except ValueError:
Log.fail("--skip must be comma-separated integers"); sys.exit(1)
Log.banner(f"Weekly Pipeline — {datetime.now().strftime('%Y-%m-%d %H:%M')}")
Log.info(f"Python: {sys.executable}")
Log.info(f"Venv: {'yes ✅' if is_in_venv() else 'no ⚠️ '}")
if dry_run:
Log.warn("DRY RUN — nothing will be executed")
results: list[StepResult] = []
t_start = time.monotonic()
for number, name, fn in PIPELINE:
if number < args.from_step:
r = StepResult(number, name); r.skip(f"before --from {args.from_step}")
results.append(r); continue
Log.step(number, name)
r = StepResult(number, name)
results.append(r)
if number in skip:
Log.skip(f"Step {number} skipped via --skip")
r.skip("--skip flag"); continue
try:
fn(r, dry_run)
except Exception:
r.fail("Unhandled exception"); traceback.print_exc()
print_summary(results, time.monotonic() - t_start)
if any(r.status == "failed" for r in results):
sys.exit(1)
if __name__ == "__main__":
main()