diff --git a/tutorials/04-uplink-file-hash.md b/tutorials/04-uplink-file-hash.md new file mode 100644 index 0000000..05495a5 --- /dev/null +++ b/tutorials/04-uplink-file-hash.md @@ -0,0 +1,212 @@ +# 04 — Uplink: Hash a Local File from the Cloud + +**Goal:** Let your Anvil app call a **local Python function** (via Uplink) to compute a file’s SHA-256 hash. When Uplink is running, it works; when it isn’t, the UI shows “Uplink offline.” + +--- + +## 1) What You’ll Build +- A client UI with a **FileLoader** and a **Label** for status/result. +- A **server callable** the client invokes (so calls always go client → server). +- A **local Uplink script** that actually computes the hash. +- Graceful error handling when the Uplink agent is offline. + +--- + +## 2) Anvil App UI +On `Form1` (client): +- Add **FileLoader** → `file_loader_1` (text: “Choose file…”). +- Add **Label** → `label_status` (text: “Uplink: checking…”, foreground: muted). +- Add **TextArea** → `text_area_result` (read-only, multi-line; for hash + meta). + +Optional: add a **Button** “Copy hash” bound to `text_area_result.text`. + +--- + +## 3) Server Module +Create or open `ServerModule1` and add: + +```python +import anvil.server +from anvil import media + +@anvil.server.callable +def hash_selected_file(file_media: media.Media): + """ + Proxies the request to an Uplink-connected function 'hash_file'. + Returns a dict with sha256, name, size, and error (if any). + """ + try: + if not file_media: + return {"error": "No file provided."} + + # This calls the uplink-exposed function defined in the local script below. + result = anvil.server.call('hash_file', file_media) + # Expecting uplink to return a dict with sha256 and size (bytes). + return { + "name": getattr(file_media, 'name', 'unknown'), + "size": getattr(file_media, 'size', None), + **(result or {}) + } + except anvil.server.TimeoutError: + return {"error": "Uplink timeout — is the local agent running?"} + except anvil.server.NoServerFunctionError: + return {"error": "Uplink function not found. Did you name it 'hash_file'?"} + except Exception as e: + return {"error": f"Unexpected error: {e.__class__.__name__}: {e}"} +```` + +> Why a server proxy? It keeps your client thin and lets you add auth/role checks later without touching the UI. + +--- + +## 4) Client Code (Form1) + +Open **Code** for `Form1` and add: + +```python +from anvil import * +import anvil.server + +class Form1(Form1Template): + + def __init__(self, **properties): + self.init_components(**properties) + self.label_status.text = "Uplink: checking…" + self._check_uplink_status() + + def _check_uplink_status(self): + """ + Calls a tiny ping if available; otherwise assume offline. + You can skip this and just infer from failures, but UX is nicer with a badge. + """ + try: + # If you create a 'uplink_ping' callable in the uplink script, uncomment: + # pong = anvil.server.call('uplink_ping') + # self.label_status.text = f"Uplink: {pong}" + # For now, we’ll just set neutral. + self.label_status.text = "Uplink: ready (if agent is running)" + except Exception: + self.label_status.text = "Uplink: offline" + + def file_loader_1_change(self, file, **event_args): + """Called when a new file is loaded into this FileLoader""" + if not file: + return + self.label_status.text = "Hashing…" + self.file_loader_1.enabled = False + try: + res = anvil.server.call('hash_selected_file', file) + if res.get("error"): + Notification(res["error"], style="danger", timeout=4).show() + self.label_status.text = "Uplink: offline or error" + return + + name = res.get("name") or getattr(file, "name", "unknown") + size = res.get("size") or getattr(file, "size", None) + sha = res.get("sha256") + size_kb = f"{(size/1024):.1f} KB" if isinstance(size, (int, float)) else "n/a" + + self.text_area_result.text = ( + f"File: {name}\n" + f"Size: {size_kb}\n" + f"SHA-256:\n{sha}" + ) + self.label_status.text = "Uplink: online ✓" + finally: + self.file_loader_1.enabled = True +``` + +--- + +## 5) Local Uplink Script + +Create a local Python file on your machine, e.g. `uplink_hash.py`. +Install the Uplink package if needed: + +```bash +pip install anvil-uplink +``` + +Script: + +```python +# uplink_hash.py +import hashlib +import anvil.server + +# --- connect --- +# Replace with your key from Anvil (Settings -> Uplink) +anvil.server.connect("YOUR-UPLINK-KEY") + +# --- functions --- +@anvil.server.callable("uplink_ping") +def uplink_ping(): + return "online ✓" + +@anvil.server.callable("hash_file") +def hash_file(file_media): + """ + file_media is an anvil.Media object coming from the client. + We'll read it as bytes and return SHA-256. + """ + # .get_bytes() gives a Bytes object; .get_bytes_io() gives a BytesIO + b = file_media.get_bytes() + h = hashlib.sha256(b).hexdigest() + return {"sha256": h, "size": len(b)} + +# --- stay alive --- +anvil.server.wait_forever() +``` + +Run it locally: + +```bash +python uplink_hash.py +``` + +Leave the terminal open while you use the app. If you stop it, the app should show **Uplink: offline** behavior. + +--- + +## 6) Security Notes (read these) + +* **Never** log or expose full file contents; we only compute a hash. +* Uplink runs **on your machine**; it can read files if *you* write code to do so. Treat it like any local service. +* If you add roles (Tutorial 05), gate the server proxy (`hash_selected_file`) behind a role like `viewer` or `editor`. + +--- + +## 7) Troubleshooting + +* **`NoServerFunctionError`**: Function name mismatch. You must expose `hash_file` and call exactly that. +* **Timeout**: Local script not running or blocked by network. Try a simple `uplink_ping` to confirm. +* **Huge files**: This demo reads all bytes. For large files, stream in chunks or limit size on client. + +--- + +## 8) Demo Asset + +Record a **10–15s GIF**: + +1. Start uplink script. +2. Choose a small file. +3. Show hash result and “Uplink: online ✓”. +4. Stop uplink; try again to show error state. + +Save as: `assets/uplink-hash-demo.gif` + +Reference here: + +```markdown +![Uplink Hash Demo](../assets/uplink-hash-demo.gif) +``` + +--- + +## 9) Takeaways + +* Uplink lets your cloud app do **local** work securely. +* Keep a thin server proxy for auth + error shaping. +* Detect offline state and tell the user *why* it failed. + +---