#!/usr/bin/env python3 """run-smarterSheet.py - start SmarterSheet (your Smartsheet projects, Gantt charts and ITM tracker in the browser). python run-smarterSheet.py first run: setup walk-through, then the page opens python run-smarterSheet.py --setup change the token, ITM sheets, edit rights or port python run-smarterSheet.py --help all options Needs Python 3.10 or newer and nothing else (no pip installs). The first start downloads the program files from https://smartersheet.microide.com/app/ into the folder "smartersheet_app" next to this script. Later starts check for a newer version and ask before installing it (--update: install without asking; --no-update: do not check). Your settings, token and data go into "smarterSheet_DB.sqlite3" next to this script - keep that file private. """ import hashlib import json import os import re import sqlite3 import sys import urllib.request from pathlib import Path SITE = os.environ.get("SMARTERSHEET_SITE", "https://smartersheet.microide.com") HERE = Path(__file__).resolve().parent APP_DIR = HERE / "smartersheet_app" def fetch(url, timeout=20): req = urllib.request.Request(url, headers={"User-Agent": "run-smarterSheet/1.0"}) with urllib.request.urlopen(req, timeout=timeout) as r: return r.read() def version_tuple(v): return tuple(int(x) for x in re.findall(r"\d+", str(v or "0"))) def local_version(): try: return re.search(r'VERSION = "(.+?)"', (APP_DIR / "smartersheet" / "__init__.py").read_text()).group(1) except (OSError, AttributeError): return None def db_setting(key, value=None): """Read (or with value: write) one setting in smarterSheet_DB.sqlite3 - here only "skip_version".""" db = HERE / "smarterSheet_DB.sqlite3" if value is None and not db.exists(): return None con = sqlite3.connect(str(db)) try: con.execute("CREATE TABLE IF NOT EXISTS settings (k TEXT PRIMARY KEY, v TEXT NOT NULL)") if value is not None: con.execute("INSERT INTO settings(k, v) VALUES (?, ?) ON CONFLICT(k) DO UPDATE SET v = excluded.v", (key, json.dumps(value))) con.commit() return value row = con.execute("SELECT v FROM settings WHERE k = ?", (key,)).fetchone() return json.loads(row[0]) if row else None finally: con.close() def download(manifest): """Bring smartersheet_app/ up to the files listed in the manifest (each checked by SHA-256).""" changed = 0 for rel, digest in manifest["files"].items(): target = (APP_DIR / rel).resolve() if APP_DIR.resolve() not in target.parents: continue # never write outside smartersheet_app/ if target.exists() and hashlib.sha256(target.read_bytes()).hexdigest() == digest: continue data = fetch(f"{SITE}/app/{rel}") if hashlib.sha256(data).hexdigest() != digest: sys.exit(f"Download of {rel} was damaged (checksum mismatch) - please try again.") target.parent.mkdir(parents=True, exist_ok=True) tmp = target.with_suffix(target.suffix + ".part") tmp.write_bytes(data) tmp.replace(target) changed += 1 return changed def update(force=False): """First start: download the program. Later: when the site has a newer version, say so and ask (yes / not now / skip this version). Without a terminal to ask in, only report it (--update installs it).""" have = local_version() try: manifest = json.loads(fetch(f"{SITE}/app/manifest.json", timeout=10)) except Exception as e: if have: print(f" (could not check for a newer version: {e})") return sys.exit(f"Could not download SmarterSheet from {SITE}: {e}\n" "Check the internet connection, or download the .zip / .tar.gz from the site and unpack it here.") latest = manifest.get("version", "") if not have: n = download(manifest) print(f" SmarterSheet {latest} downloaded ({n} files).") return if version_tuple(latest) <= version_tuple(have): if version_tuple(latest) == version_tuple(have): download(manifest) # same version: only replaces damaged or missing files return if not force: if db_setting("skip_version") == latest: return print(f"\n A newer SmarterSheet is available: {latest} (you have {have}).") print(f" What changed: {SITE}/readme.md#changes") if not (sys.stdin and sys.stdin.isatty()): print(" Not updating now; run python run-smarterSheet.py --update to install it.") return while True: a = input(" Update now? [Y]es / [n]ot now / [s]kip this version: ").strip().lower() if a in ("", "y", "yes"): break if a in ("n", "no", "not now"): print(" Starting your current version; you will be asked again next time.") return if a in ("s", "skip"): db_setting("skip_version", latest) print(f" Version {latest} skipped; you will be told again when a newer one comes out.") return n = download(manifest) print(f" Updated to SmarterSheet {latest} ({n} files).") def main(): if sys.version_info < (3, 10): sys.exit("SmarterSheet needs Python 3.10 or newer (this is %d.%d). Get it from https://www.python.org/downloads/" % sys.version_info[:2]) argv = sys.argv[1:] if "--no-update" not in argv and "-h" not in argv and "--help" not in argv: update(force="--update" in argv) os.environ.setdefault("SMARTERSHEET_SITE", SITE) # the running app checks it for newer versions too sys.path.insert(0, str(APP_DIR)) try: from smartersheet.main import main as app_main except ImportError as e: sys.exit(f"SmarterSheet's program files are missing in {APP_DIR} ({e}). Start once without --no-update.") return app_main(argv, here=HERE) def pause_on_windows(code): """A double-clicked script gets its own console window that closes at once - keep a message readable.""" if os.name == "nt" and code not in (0, None) and sys.stdin and sys.stdin.isatty(): try: input("\nPress Enter to close this window.") except EOFError: pass return code if __name__ == "__main__": try: rc = main() except SystemExit as e: if not isinstance(e.code, int) and e.code is not None: print(e.code, file=sys.stderr) rc = 1 else: rc = e.code sys.exit(pause_on_windows(rc))