diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..9d74a16e --- /dev/null +++ b/.env.example @@ -0,0 +1,28 @@ +# .env — generated by ./scripts/setup +# +# This file is gitignored. Do not commit a real credentials file. +# Run `./scripts/setup` and pick the auth-credentials step to create +# the live `.env`. Re-running is safe and idempotent. + +# ---- common ---- +# One of: developer_token, jwt, ccg, oauth +BOX_AUTH_MODE=developer_token + +# ---- developer_token ---- +# Mint at https://app.box.com/developers/console — expires after 60 min. +BOX_DEVELOPER_TOKEN=YOUR_DEVELOPER_TOKEN_HERE + +# ---- jwt ---- +# Absolute path to the config.json downloaded from the developer console. +# BOX_JWT_CONFIG_PATH=/absolute/path/to/box-jwt-config.json + +# ---- ccg ---- +# BOX_CCG_CLIENT_ID=YOUR_CLIENT_ID +# BOX_CCG_CLIENT_SECRET=YOUR_CLIENT_SECRET +# BOX_CCG_SUBJECT_KIND=enterprise +# BOX_CCG_SUBJECT_ID=YOUR_ENTERPRISE_OR_USER_ID + +# ---- oauth ---- +# BOX_OAUTH_CLIENT_ID=YOUR_CLIENT_ID +# BOX_OAUTH_CLIENT_SECRET=YOUR_CLIENT_SECRET +# BOX_OAUTH_REDIRECT_URI=http://localhost:8080/callback diff --git a/.gitignore b/.gitignore index 6769e21d..2176848c 100644 --- a/.gitignore +++ b/.gitignore @@ -121,6 +121,10 @@ celerybeat.pid # Environments .env +!.env.example + +# setup wizard outputs +box-jwt-config.json .venv env/ venv/ diff --git a/WIZARD_FLOWS.md b/WIZARD_FLOWS.md new file mode 100644 index 00000000..fda2d306 --- /dev/null +++ b/WIZARD_FLOWS.md @@ -0,0 +1,191 @@ +# Box Python SDK (v10) — Setup Wizard Quick Reference + +A visual reference for the optional setup wizard added by the +`./scripts/setup` script. The wizard is **opt-in** — the SDK works +fine without it; this just streamlines first-time onboarding. + +--- + +## At a glance + +| Command | What it does | +| -------------------------------------- | ------------------------------------------------------------------------- | +| `./scripts/setup` | First-run + repair wizard. Picker-driven, idempotent. | +| `./scripts/doctor` | Read-only audit. Re-runs each step's check function and prints results. | +| `./scripts/setup` then `recommended` | Verify Python + pip + SDK importable, capture auth credentials, print a smoke snippet. | +| `WIZARD_PACE_MS=0 ./scripts/setup` | Disable pacing for CI / scripted onboarding. | +| `WIZARD_PACE_MS=400 ./scripts/setup` | Slow pacing for first-time users who want extra read time. | + +The wizard never modifies SDK source files. It writes one file at the +repo root: **`.env`** (gitignored). The Python package is named +`dev_setup/` (not `setup/`) to avoid colliding with `setup.py` and +`setuptools.find_packages()`. + +--- + +## Decision tree + +```mermaid +flowchart TD + Start([./scripts/setup]) --> Banner[Print banner +
3-step picker] + Banner --> Picker{Picker} + Picker -->|recommended
or `all`| AllSet[Run steps 1, 2, 3] + Picker -->|1| OnlyPre[Step 1 only] + Picker -->|2| OnlyAuth[Step 2 only] + Picker -->|3| OnlySmoke[Step 3 only] + Picker -->|none| Exit([Exit cleanly]) + + AllSet --> S1[Step 1
Python + dependencies preflight] + OnlyPre --> S1 + S1 --> PythonCheck{Python >= 3.8
and pip works?} + PythonCheck -->|no| FailHint[Print install hint, abort step] + PythonCheck -->|yes| ImportCheck{box_sdk_gen
importable?} + ImportCheck -->|yes| S2[Step 2
Auth credentials] + ImportCheck -->|no| OfferInstall{Run pip install -e .[test,dev]?} + OfferInstall -->|yes| PipInstall[pip install with progress watchdog] + OfferInstall -->|no| S2 + PipInstall --> S2 + + OnlyAuth --> S2 + S2 --> ModePicker{Auth mode?} + ModePicker -->|1| DevToken[Mint Developer Token
at app.box.com/developers/console
capture via getpass] + ModePicker -->|2| JWT[Capture JWT config.json
path] + ModePicker -->|3| CCG[Capture client ID/secret/
EID-or-UID] + ModePicker -->|4| OAuth[Capture client ID/secret/
redirect URI] + + DevToken --> WriteEnv[Write .env] + JWT --> WriteEnv + CCG --> WriteEnv + OAuth --> WriteEnv + + WriteEnv --> S3[Step 3
Smoke test snippet] + OnlySmoke --> S3 + S3 --> SnippetCheck{.env exists?} + SnippetCheck -->|yes| PrintSnippet[Print Python snippet
keyed to chosen mode] + SnippetCheck -->|no| HintGoBack[Hint: pick step 2 first] + + PrintSnippet --> Footer[Print completion footer +
doctor + docs links] + HintGoBack --> Footer + Footer --> Done([User runs ./scripts/doctor
to verify]) +``` + +--- + +## Steps + +| # | Step | Recommended | Check | Repair | Writes | +| - | --------------------------------- | ----------- | ------------------------------------------------------------------ | -------------------------------------------------------------------------- | ---------------------------- | +| 1 | Python + dependencies preflight | yes | `python >= 3.8`, `python -m pip --version`, `import box_sdk_gen` | offer `pip install -e .[test,dev]` (progress watchdog while it runs) | nothing (modifies site-packages via pip) | +| 2 | Auth credentials | yes | `.env` exists with `BOX_AUTH_MODE` set | inner picker for Developer Token / JWT / CCG / OAuth, then capture creds | `.env` | +| 3 | Smoke test snippet | yes | (none — printing-only step) | print a copy-pasteable Python program keyed to the chosen auth mode | nothing | + +--- + +## Picker grammar + +| Input | Means | +| ------------- | ------------------------------------------------ | +| `1,2,3` | comma-separated indices | +| `1-3` | a range (inclusive) | +| `all` | every step | +| `recommended` | every `[R]` step (the default if you press Enter) | +| `none` | exit without running anything | + +--- + +## Auth modes + +| Mode | When to pick | What the wizard collects | Where it lands in `.env` | +| ----------------------- | ------------------------------------------- | --------------------------------------------------------- | ----------------------------------------- | +| Developer Token | Local dev, testing on your own account | One token (60-min TTL) | `BOX_DEVELOPER_TOKEN` | +| JWT | Server-to-server, no user impersonation | Path to JWT config JSON | `BOX_JWT_CONFIG_PATH` | +| Client Credentials (CCG) | Server-to-server, modern flow | Client ID, secret, enterprise OR user ID | `BOX_CCG_*` | +| OAuth 2.0 | Multi-user, end-user-facing app | Client ID, secret, redirect URI | `BOX_OAUTH_*` | + +The wizard always sets `BOX_AUTH_MODE` to one of `developer_token`, +`jwt`, `ccg`, or `oauth`. The doctor reads it to decide which +mode-specific check function should run. + +--- + +## Cross-cutting features + +### Pacing +Output is paced so multi-paragraph blocks become readable. Override +with `WIZARD_PACE_MS=` (`0` disables, `80` is default, `400` is a +slow ramp). Pacing auto-disables when stdout isn't a TTY. + +### Self-contained credential capture +The wizard reads `.env` itself when needed and never asks you to +"first source X" or "first export Y". Cloning the repo and running +`./scripts/setup` is the only prerequisite. + +### Doctor-symmetric checks +Every wizard step has a check function in `dev_setup/checks.py` that +the doctor calls. Re-running `./scripts/doctor` after a wizard run +prints all results in one shot, no prompts, exit code 0 when +everything's OK. + +### Hidden secret entry +Tokens, client secrets, etc. are read with `getpass.getpass` so they +never echo to the terminal or land in shell history. + +### Owner-only secrets file +`.env` is chmod 0600 on POSIX systems. Best-effort on Windows / +unusual filesystems. + +### Progress watchdog +Long-running subprocesses (>5s) print `current action: ` +so they don't look hung. Step 1's `pip install` run uses this; you'll +see something like `current action: installing SDK in editable mode +(pip)` after a few seconds, then `→ done in 47s` when complete. + +--- + +## Common recovery paths + +| Symptom | What to try | +| ---------------------------------------------------- | --------------------------------------------------------------------------------- | +| `[FAIL] python: ... < 3.8` | Install Python 3.8+ from python.org, then re-run from a shell using that interpreter. | +| `[FAIL] pip: ...` | `python3 -m ensurepip --upgrade`, or reinstall Python. | +| `[WARN] box_sdk_gen: not importable` | `./scripts/setup` and accept the pip-install prompt. | +| `[WARN] box-env: not found` | `./scripts/setup` and pick step 2. | +| `[FAIL] developer-token: empty` | Mint a fresh one at https://app.box.com/developers/console, re-run step 2. | +| `[FAIL] jwt-config: not at ` | Verify the path; the developer console downloads as `config.json`. | +| Picker rejected my input | Use commas, ranges, `all`, `recommended`, or `none`. No spaces inside numbers. | +| Smoke snippet 401's when run | Token expired (Developer Tokens last 60 min). Re-run step 2 for a fresh one. | + +--- + +## Verifying success + +```bash +./scripts/doctor # Should report all OK. +pytest # Run the SDK's own tests +``` + +For a real end-to-end check, paste the snippet from the wizard's step 3 +into a `.py` file with `box-sdk-gen` on `sys.path` (use +`pip install -e .` from the repo root), run it, and confirm it prints +your name. + +--- + +## What this wizard does NOT do + +- **Run the OAuth callback flow**: OAuth needs an HTTP server to + receive the redirect, which is application-specific. The wizard + captures credentials; running the flow is on you. +- **Refresh tokens**: Developer Tokens expire after 60 minutes. The + doctor reminds you of that but doesn't auto-refresh. +- **Manage virtualenvs**: it uses whichever Python runs the wizard. + Activate your venv before running `./scripts/setup`. +- **Manage multiple environments**: one repo = one `.env`. If you need + profiles, layer that on top. + +--- + +Built using +[`setup-wizard-squared`](https://github.com/NatalieNobile/setup-wizard-squared). +See its `reference/patterns.md` for the seven design principles that +shape this wizard. diff --git a/dev_setup/__init__.py b/dev_setup/__init__.py new file mode 100644 index 00000000..677c1fb8 --- /dev/null +++ b/dev_setup/__init__.py @@ -0,0 +1,6 @@ +"""``setup`` — first-run wizard + doctor scaffold (setup-wizard-squared). + +Customize ``wizard.py`` and ``checks.py`` per repo. The package layout +is fixed so ``python3 -m setup.wizard`` and ``python3 -m setup.doctor`` +both resolve cleanly. +""" diff --git a/dev_setup/checks.py b/dev_setup/checks.py new file mode 100644 index 00000000..ce2e3d84 --- /dev/null +++ b/dev_setup/checks.py @@ -0,0 +1,366 @@ +"""Health checks shared by the wizard and doctor. + +Every check returns one or more :class:`CheckResult`. The same functions +are called from the wizard (per-step verification) and the doctor (full +report) so validation logic lives in exactly one place. This is the +"doctor-symmetric" principle from ``reference/patterns.md``. + +When porting: + +1. Replace the example checks at the bottom with your repo's checks. +2. Add a category grouping if you have many checks (the bq_ranger + reference splits `core` vs `extras` so the doctor can group output). +3. Each check that talks to a network service should accept an optional + ``timeout`` kwarg — default 10-15s. Long-hung probes break the + doctor's "fast read-only audit" contract. + +Conventions: + * ``fix_hint`` is required on every ``fail``. The hint must name a + concrete next action, not generic advice. + * Checks never prompt. If you need user input, that's a wizard step. + * Checks are pure read-only audits — they never write files or + mutate state. +""" + +from __future__ import annotations + +import shutil +import subprocess +from collections.abc import Iterable +from dataclasses import dataclass, field +from typing import Literal + +Severity = Literal["ok", "warn", "fail"] + + +@dataclass +class CheckResult: + """One row in the doctor's report. + + ``fix_hint`` is required on every ``fail`` (enforced by convention, + not the type system). The hint must name the next concrete action — + "re-run ``./scripts/setup``", not "fix your config". + """ + + name: str + severity: Severity + message: str + fix_hint: str | None = None + + @property + def ok(self) -> bool: + return self.severity == "ok" + + +@dataclass +class DoctorReport: + """Aggregated checks plus optional structured evidence. + + Add fields here when the wizard needs to consume *typed* values from + the doctor (e.g. the resolved current-user name, the active project + id) instead of just severity rows. Keep the field count small — + most wizards never need more than a half-dozen. + """ + + results: list[CheckResult] = field(default_factory=list) + + @property + def all_ok(self) -> bool: + """True iff no check failed (warnings tolerated).""" + return all(r.severity != "fail" for r in self.results) + + @property + def any_failures(self) -> bool: + return any(r.severity == "fail" for r in self.results) + + def add(self, *results: CheckResult) -> None: + self.results.extend(results) + + +# ---- generic helpers ---------------------------------------------------- + + +def _run( + args: list[str], + *, + timeout: int = 15, + input_text: str | None = None, + env: dict[str, str] | None = None, +) -> tuple[int, str, str]: + """Run a subprocess and capture ``(returncode, stdout, stderr)``. + + Returns ``(127, "", str(exc))`` if the binary is missing — same + convention as ``which`` so callers don't need to special-case + ``FileNotFoundError`` separately from non-zero exit codes. Returns + ``(124, "", "timed out after Ns")`` on timeout. + + ``env`` lets callers layer dotenv-derived values onto a copy of + ``os.environ`` so checks against tools that read tokens from env + (jira-cli, gh, gcloud) work without forcing the user to pre-source. + """ + try: + proc = subprocess.run( + args, + capture_output=True, + text=True, + timeout=timeout, + input=input_text, + check=False, + env=env, + ) + return proc.returncode, proc.stdout, proc.stderr + except FileNotFoundError as exc: + return 127, "", str(exc) + except subprocess.TimeoutExpired: + return 124, "", f"timed out after {timeout}s" + + +def _on_path(name: str) -> bool: + """True if ``name`` resolves to an executable on the user's PATH.""" + return shutil.which(name) is not None + + +def is_ssl_trust_failure(message: str) -> bool: + """True if ``message`` looks like a TLS trust-store error. + + SSL verification failures aren't credential / config / network + problems — re-prompting the user for tokens or rerunning the check + would waste their time. Branch on this so the wizard can route to + a "fix your trust store" remediation block instead of treating it + as a generic auth failure. + + Patterns cover every flavor of "I couldn't verify the server cert" + surfaced by urllib, requests, curl, openssl on macOS / Linux. We + don't detect *valid* certs rejected for other reasons (expiry, + hostname mismatch) — those are real network problems and a generic + "check VPN" hint is correct. + """ + return ( + "CERTIFICATE_VERIFY_FAILED" in message + or "[SSL:" in message + or "SSL certificate problem" in message + or "unable to get local issuer certificate" in message + or "self-signed certificate" in message + ) + + +def has_ssl_trust_failure(results: Iterable["CheckResult"]) -> bool: + """True iff any non-OK result in ``results`` looks like an SSL trust error.""" + return any( + r.severity != "ok" and is_ssl_trust_failure(r.message) + for r in results + ) + + +# ---- box-python-sdk-gen-specific checks -------------------------------- + + +import sys +from pathlib import Path + +from . import config as cfg + + +def check_python() -> list[CheckResult]: + """Python interpreter on PATH and meets the minimum version.""" + cur = sys.version_info + detail = f"Python {cur.major}.{cur.minor}.{cur.micro}" + if (cur.major, cur.minor) < cfg.MIN_PYTHON: + need = ".".join(str(x) for x in cfg.MIN_PYTHON) + return [ + CheckResult( + "python", + "fail", + f"{detail} found; SDK requires {need}+", + fix_hint=( + f"install Python {need}+ from https://www.python.org/downloads/, " + "then re-run from a shell using that interpreter." + ), + ) + ] + return [CheckResult("python", "ok", detail)] + + +def check_pip() -> list[CheckResult]: + """``pip`` is available via the active interpreter (``python -m pip``). + + We check ``python -m pip`` rather than a bare ``pip`` binary because + pip is reliably bundled with the Python that's running this code, + while ``pip`` on PATH varies by install method (homebrew, pyenv, + system Python, virtualenv, etc.). + """ + rc, out, err = _run([sys.executable, "-m", "pip", "--version"], timeout=10) + if rc != 0: + return [ + CheckResult( + "pip", + "fail", + f"`{sys.executable} -m pip --version` exited {rc}: {(err or out).strip()[:200]}", + fix_hint=( + "ensure pip is installed: `python3 -m ensurepip --upgrade` " + "or reinstall Python from python.org." + ), + ) + ] + return [CheckResult("pip", "ok", out.strip().splitlines()[0])] + + +def check_sdk_importable() -> list[CheckResult]: + """``box_sdk_gen`` resolves on the current sys.path. + + A WARN (not FAIL) when missing — the wizard's preflight step offers + to run ``pip install -e .[test,dev]`` to fix it. The user can also + skip and install manually. + """ + try: + import box_sdk_gen # noqa: F401 + except ImportError as exc: + return [ + CheckResult( + "box_sdk_gen", + "warn", + f"not importable: {exc}", + fix_hint=( + "from the repo root, run `python3 -m pip install -e .[test,dev]` " + "(or pick the dependency-install option in `./scripts/setup`)." + ), + ) + ] + return [ + CheckResult( + "box_sdk_gen", + "ok", + f"importable from {Path(box_sdk_gen.__file__).parent}", + ) + ] + + +def check_box_env() -> list[CheckResult]: + """``.env`` exists at the repo root and declares an auth mode.""" + if not cfg.BOX_ENV_FILE.exists(): + return [ + CheckResult( + "box-env", + "warn", + f"{cfg.BOX_ENV_FILE.name} not found", + fix_hint="run `./scripts/setup` and pick the auth-credentials step.", + ) + ] + values = cfg.read_dotenv(cfg.BOX_ENV_FILE) + mode = values.get("BOX_AUTH_MODE", "").strip() + if not mode: + return [ + CheckResult( + "box-env", + "warn", + f"{cfg.BOX_ENV_FILE.name} present but `BOX_AUTH_MODE` not set", + fix_hint="re-run `./scripts/setup` and pick the auth-credentials step.", + ) + ] + if mode not in cfg.ALL_AUTH_MODES: + return [ + CheckResult( + "box-env", + "fail", + f"unknown auth mode `{mode}` (expected one of {cfg.ALL_AUTH_MODES})", + fix_hint="re-run `./scripts/setup` to pick a supported mode.", + ) + ] + return [ + CheckResult( + "box-env", + "ok", + f"auth mode = {mode}", + ) + ] + + +def check_developer_token() -> list[CheckResult]: + """When mode=developer_token, the token is set in .env. + + No-op for other modes (returns OK so the doctor stays quiet). + Developer tokens have a 60-min TTL — freshness, not just presence, + matters, but we can't detect expiry without a network call. + """ + if not cfg.BOX_ENV_FILE.exists(): + return [CheckResult("developer-token", "ok", "n/a (no .env yet)")] + values = cfg.read_dotenv(cfg.BOX_ENV_FILE) + if values.get("BOX_AUTH_MODE", "").strip() != cfg.AUTH_MODE_DEV_TOKEN: + return [CheckResult("developer-token", "ok", "n/a (other auth mode)")] + token = values.get("BOX_DEVELOPER_TOKEN", "").strip() + if not token: + return [ + CheckResult( + "developer-token", + "fail", + "mode=developer_token but `BOX_DEVELOPER_TOKEN` is empty", + fix_hint=( + "re-run `./scripts/setup` and pick auth-credentials. Mint " + f"a fresh token at {cfg.DEV_CONSOLE_URL}." + ), + ) + ] + return [ + CheckResult( + "developer-token", + "ok", + f"token set ({len(token)} chars). Note: tokens expire after 60 min.", + ) + ] + + +def check_jwt_config() -> list[CheckResult]: + """When mode=jwt, the JWT config JSON exists and parses.""" + if not cfg.BOX_ENV_FILE.exists(): + return [CheckResult("jwt-config", "ok", "n/a (no .env yet)")] + values = cfg.read_dotenv(cfg.BOX_ENV_FILE) + if values.get("BOX_AUTH_MODE", "").strip() != cfg.AUTH_MODE_JWT: + return [CheckResult("jwt-config", "ok", "n/a (other auth mode)")] + path_str = values.get("BOX_JWT_CONFIG_PATH", "").strip() + if not path_str: + return [ + CheckResult( + "jwt-config", + "fail", + "mode=jwt but `BOX_JWT_CONFIG_PATH` not set", + fix_hint="re-run `./scripts/setup` and pick auth-credentials.", + ) + ] + p = Path(path_str).expanduser() + if not p.exists(): + return [ + CheckResult( + "jwt-config", + "fail", + f"JWT config not at {p}", + fix_hint=( + "verify the path; the developer console downloads it as " + "config.json and you can place it anywhere." + ), + ) + ] + import json + + try: + data = json.loads(p.read_text(encoding="utf-8")) + except (json.JSONDecodeError, UnicodeDecodeError) as exc: + return [ + CheckResult( + "jwt-config", + "fail", + f"JWT config at {p} is not valid JSON: {exc}", + fix_hint="re-download the JWT app config from the developer console.", + ) + ] + expected_keys = {"boxAppSettings", "enterpriseID"} + missing = expected_keys - set(data.keys()) + if missing: + return [ + CheckResult( + "jwt-config", + "warn", + f"JWT config at {p} missing keys: {sorted(missing)}", + fix_hint="re-download the JWT app config from the developer console.", + ) + ] + return [CheckResult("jwt-config", "ok", f"valid JWT config at {p}")] diff --git a/dev_setup/config.py b/dev_setup/config.py new file mode 100644 index 00000000..6df89a1e --- /dev/null +++ b/dev_setup/config.py @@ -0,0 +1,202 @@ +"""Paths and small config helpers shared by the wizard and doctor. + +Discovers the target repo root relative to this file so the package +works no matter where the user invokes it from. Per-user state lives +in ``~/./`` (overridable via ``$WIZARD_STATE_DIR``) so the +wizard never writes per-machine state into the git checkout. + +Customization checklist when porting to a new repo: + +1. Update ``PROJECT_NAME`` to whatever short name you want for the + ``~/./`` state dir. +2. Decide where ``parents[N]`` should land — the scaffold assumes the + ``setup/`` package sits one directory below the repo root. If you + nest deeper (e.g. ``scripts/setup/``), bump the index. +3. Add module-level constants for any well-known config files the + wizard creates (``BOX_CONFIG_FILE``, ``LOCAL_PROPERTIES``, etc.). + Mirror the ``ZD_ENV_FILE`` / ``PAT_ENV_FILE`` shape from bq_ranger: + accept an env-var override so tests can swap paths. +4. Drop any constants you don't need. There's no charge for empty + space here — the file should be small. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +# ---- repo + state ------------------------------------------------------- + +PROJECT_NAME: str = "box-python-sdk-gen" + +# REPO_ROOT discovery. Adjust the parents[N] depth to match where you +# install the setup package. Default: setup/ sits at /setup/, so +# parents[1] resolves to . +REPO_ROOT: Path = Path(__file__).resolve().parents[1] + +# Per-user state directory (created lazily, never at import time so +# read-only callers don't trigger a filesystem mutation just by +# importing this module). +_STATE_DIR_ENV = f"{PROJECT_NAME.upper().replace('-', '_')}_STATE_DIR" +STATE_DIR: Path = Path( + os.environ.get(_STATE_DIR_ENV, str(Path.home() / f".{PROJECT_NAME}")) +).expanduser() + + +def ensure_state_dir() -> Path: + """Create ``STATE_DIR`` if missing and return it. + + Lazy so read-only callers (the doctor, ad-hoc imports in CI or + sandboxed environments without a writable ``$HOME``) don't trigger + a filesystem mutation just by importing the package. Idempotent. + """ + STATE_DIR.mkdir(parents=True, exist_ok=True) + return STATE_DIR + + +# ---- Box-specific config files ------------------------------------------ +# +# The wizard writes one ``.env`` at the repo root with the user's chosen +# auth mode and credentials. ``.env`` is the Python ecosystem's default +# convention; the wizard's ``read_dotenv``/``write_dotenv_key`` helpers +# parse it natively. +# +# JWT auth uses an additional JSON config file (the one the developer +# console exports). The wizard records its absolute path in ``.env`` +# under ``BOX_JWT_CONFIG_PATH``. + +BOX_ENV_FILE: Path = Path( + os.environ.get("BOX_SDK_DOTENV", str(REPO_ROOT / ".env")) +).expanduser() + +BOX_ENV_EXAMPLE: Path = REPO_ROOT / ".env.example" + +DEFAULT_JWT_CONFIG_PATH: Path = Path( + os.environ.get("BOX_JWT_CONFIG_PATH", str(REPO_ROOT / "box-jwt-config.json")) +).expanduser() + +# URLs printed in the token-entry prompts so the user knows where to +# mint each credential. Centralized so docs + wizard cite the same +# canonical pages. +DEV_CONSOLE_URL: str = "https://app.box.com/developers/console" +JWT_AUTH_DOCS_URL: str = ( + "https://developer.box.com/guides/authentication/jwt/jwt-setup/" +) +OAUTH_DOCS_URL: str = ( + "https://developer.box.com/guides/authentication/oauth2/" +) +CCG_DOCS_URL: str = ( + "https://developer.box.com/guides/authentication/client-credentials/" +) + +# Minimum required Python (matches setup.py classifiers). +MIN_PYTHON: tuple[int, int] = (3, 8) + +# Auth mode constants. Stored verbatim in .env under BOX_AUTH_MODE and +# consumed by the smoke-test step. +AUTH_MODE_DEV_TOKEN: str = "developer_token" +AUTH_MODE_JWT: str = "jwt" +AUTH_MODE_CCG: str = "ccg" +AUTH_MODE_OAUTH: str = "oauth" + +ALL_AUTH_MODES: tuple[str, ...] = ( + AUTH_MODE_DEV_TOKEN, + AUTH_MODE_JWT, + AUTH_MODE_CCG, + AUTH_MODE_OAUTH, +) + + +# ---- dotenv helpers ----------------------------------------------------- + + +def read_dotenv(path: Path) -> dict[str, str]: + """Parse a tiny .env-style file into a dict. + + Handles ``KEY=value`` and ``KEY="value"`` / ``KEY='value'``. Ignores + blank lines and ``#`` comments. Does NOT support multiline values, + backslash escapes, or shell expansion — kept intentionally minimal + so we don't pull in python-dotenv as a dependency. + """ + out: dict[str, str] = {} + if not path.exists(): + return out + for raw in path.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#"): + continue + if "=" not in line: + continue + k, v = line.split("=", 1) + k = k.strip() + v = v.strip() + if (len(v) >= 2) and (v[0] == v[-1]) and v[0] in ("'", '"'): + v = v[1:-1] + out[k] = v + return out + + +def write_dotenv_key(path: Path, key: str, value: str) -> None: + """Idempotently set ``key=value`` in a tiny .env file. + + Creates the file if missing, replaces an existing key in place, or + appends if the key isn't there yet. Quotes the value with double + quotes if it contains a space, ``#``, or ``=`` so the same parser + that wrote it can read it back. + + Sets owner-only (0600) permissions on POSIX systems — best-effort + on Windows / unusual filesystems where chmod isn't supported. + """ + path.parent.mkdir(parents=True, exist_ok=True) + needs_quote = any(c in value for c in (" ", "#", "=")) + safe_value = value.replace('"', r"\"") if needs_quote else value + rendered = f'{key}="{safe_value}"' if needs_quote else f"{key}={value}" + + if not path.exists(): + path.write_text(rendered + "\n", encoding="utf-8") + chmod_owner_only(path) + return + + lines = path.read_text(encoding="utf-8").splitlines() + replaced = False + for i, line in enumerate(lines): + stripped = line.lstrip() + if stripped.startswith(f"{key}="): + lines[i] = rendered + replaced = True + break + if not replaced: + lines.append(rendered) + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + chmod_owner_only(path) + + +def chmod_owner_only(path: Path) -> None: + """Best-effort owner-only (0600) permissions for local secrets. + + Windows and some network filesystems don't support POSIX modes. + The write still succeeded — keep setup flowing and lean on the + docs to call out platform-specific hardening if needed. + """ + try: + path.chmod(0o600) + except OSError: + pass + + +def atomic_write_text(path: Path, content: str, *, mode: int = 0o644) -> None: + """Write ``content`` to ``path`` atomically. + + Writes to a sibling tempfile, then ``os.replace`` to swap it in. + Avoids leaving a half-written secrets file if the process is killed + mid-write. ``mode`` is best-effort (skipped on filesystems without + POSIX permissions). + """ + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text(content, encoding="utf-8") + try: + tmp.chmod(mode) + except OSError: + pass + os.replace(tmp, path) diff --git a/dev_setup/doctor.py b/dev_setup/doctor.py new file mode 100644 index 00000000..5f96b100 --- /dev/null +++ b/dev_setup/doctor.py @@ -0,0 +1,93 @@ +"""``doctor`` — read-only environment audit. + +Calls every registered check function and prints results. Same +``CheckResult`` rendering as the wizard, so the user gets the same +``[OK]`` / ``[WARN]`` / ``[FAIL]`` glyphs from both surfaces. + +Exits non-zero only on hard failures. Warnings are tolerated — they +mean "optional thing isn't set up" rather than "your environment is +broken". Tune ``DOCTOR_FAIL_ON_WARN`` per repo if your audience needs +strict exit codes. +""" + +from __future__ import annotations + +import sys +from collections.abc import Callable + +from . import checks +from .checks import CheckResult, DoctorReport +from .prompts import banner, paced_print, print_results + +# ---- customize per repo ------------------------------------------------- + +DOCTOR_TITLE: str = "Box Python SDK doctor" +DOCTOR_TAGLINE: str = ( + "Read-only audit. Re-runs each setup step's check function so you can\n" + "see what's wired up and what isn't, without rerunning the wizard." +) + +# Whether to exit non-zero on warnings. Default False — warnings here +# usually mean "auth mode picked but credential file path doesn't exist +# yet", which is not a hard failure. +DOCTOR_FAIL_ON_WARN: bool = False + +CheckFn = Callable[[], list[CheckResult]] +DOCTOR_CHECKS: list[CheckFn] = [ + checks.check_python, + checks.check_pip, + checks.check_sdk_importable, + checks.check_box_env, + checks.check_developer_token, + checks.check_jwt_config, +] + + +# ---- runner ------------------------------------------------------------- + + +def run_all_checks() -> DoctorReport: + """Call every registered check and assemble a ``DoctorReport``.""" + report = DoctorReport() + for check in DOCTOR_CHECKS: + try: + results = check() + except Exception as exc: # noqa: BLE001 - keep the doctor robust + results = [ + CheckResult( + name=check.__name__, + severity="fail", + message=f"check raised {type(exc).__name__}: {exc}", + fix_hint="report this as a doctor bug.", + ) + ] + report.add(*results) + return report + + +def main(argv: list[str] | None = None) -> int: + """Entry point. Prints banner, runs all checks, returns exit code.""" + argv = list(argv if argv is not None else sys.argv[1:]) + + banner(DOCTOR_TITLE) + paced_print(DOCTOR_TAGLINE, after_ms=300) + print() + + report = run_all_checks() + print_results(report.results, show_ok=True, show_fix=True) + + print() + fails = sum(1 for r in report.results if r.severity == "fail") + warns = sum(1 for r in report.results if r.severity == "warn") + oks = sum(1 for r in report.results if r.severity == "ok") + paced_print(f" Summary: {oks} OK, {warns} WARN, {fails} FAIL", after_ms=150) + + if fails > 0: + return 1 + if DOCTOR_FAIL_ON_WARN and warns > 0: + return 2 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/dev_setup/prompts.py b/dev_setup/prompts.py new file mode 100644 index 00000000..f92f9ff0 --- /dev/null +++ b/dev_setup/prompts.py @@ -0,0 +1,393 @@ +"""TTY helpers for the wizard and doctor. + +Stdlib-only. Centralized here so the wizard and doctor render identically — +the user gets the same ``[OK]`` / ``[WARN]`` / ``[FAIL]`` glyphs and the +same fix-hint formatting from both tools. + +This is part of the ``setup-wizard-squared`` scaffold. Drop the file +into a target repo's ``setup/`` package and customize ``wizard.py``; +``prompts.py`` itself shouldn't usually need editing per repo. + +Tunables: + ``WIZARD_PACE_MS=`` — per-line pause when pacing framing text. + Default 80ms. ``0`` disables pacing. Auto-disabled when stdout + isn't a TTY. +""" + +from __future__ import annotations + +import contextlib +import os +import sys +import threading +import time +from collections.abc import Iterable, Iterator +from typing import Literal + +# Import readline so input() supports arrow-key line editing and history +# instead of inserting raw escape codes (^[[A / ^[[B). On macOS, Python's +# stdlib readline links against libedit rather than GNU readline; on +# Windows the module isn't shipped — input() still works, just without +# the line editor. +try: + import readline # noqa: F401 -- side-effect import (enables line editing) +except ImportError: + pass + +from .checks import CheckResult + +Severity = Literal["ok", "warn", "fail"] + +_GLYPH = {"ok": "[OK] ", "warn": "[WARN]", "fail": "[FAIL]"} + +# ---- pacing ------------------------------------------------------------- +# +# Sectional pacing: small inter-line pauses inside framing blocks +# (banners, step intros, completion summaries) plus a longer settle +# pause AFTER each block. Status rows, prompts, and command output stay +# instant so the wizard never feels frozen. +# +# Tuning: WIZARD_PACE_MS= overrides the default per-line ms. +# 0 -> pacing disabled (same as non-TTY). +# 80 -> default; calm but not slow. +# 200+ -> deliberate / dramatic for live demos or first-time users. +# Auto-disabled when stdout isn't a TTY (CI, piped output, capture). + +_DEFAULT_PACE_MS = 80 +_PACE_ENV_VAR = "WIZARD_PACE_MS" + + +def _pace_ms() -> int: + """Resolve effective per-line pause in ms; 0 disables pacing.""" + if not sys.stdout.isatty(): + return 0 + raw = os.environ.get(_PACE_ENV_VAR) + if raw is None: + return _DEFAULT_PACE_MS + try: + v = int(raw) + except ValueError: + return _DEFAULT_PACE_MS + return max(0, v) + + +def paced_print(*lines: str, pause_ms: int | None = None, after_ms: int = 0) -> None: + """Print each line with the per-line pause between, then sleep ``after_ms``. + + Use for FRAMING text — banners, step intros, completion summaries. + Don't pace status rows, command output, or prompt text: pacing + those makes the wizard feel slow without aiding reading. + + ``pause_ms`` overrides the global per-line rate FOR THIS CALL ONLY. + Use it to slow-roll a long prose block ("Star Wars crawl" feel) so + a multi-paragraph explainer scrolls in one breath instead of dumping + all at once. Typical values: + + None -> use global WIZARD_PACE_MS (default 80ms, snappy) + 150 -> slower scroll for paragraph prose + 250 -> deliberate "read this carefully" rhythm + + The global kill switch (``WIZARD_PACE_MS=0`` or non-TTY) still wins — + passing ``pause_ms`` does NOT force pacing on when it would otherwise + be off. + + ``after_ms`` is the sectional settle pause that separates this block + from whatever prints next (a prompt, a status row, the next framing + block). Typical values: + + 0 -> no settle (block flows directly into the next line) + 200 -> short settle (after a step completion line) + 400 -> banner settle (give the user a beat to read it) + """ + pace = _pace_ms() + if pace and pause_ms is not None: + pace = max(0, pause_ms) + last = len(lines) - 1 + for i, line in enumerate(lines): + print(line) + if pace and i < last: + time.sleep(pace / 1000) + if pace and after_ms > 0: + time.sleep(after_ms / 1000) + + +def paced_pause(ms: int) -> None: + """Sleep ``ms`` milliseconds when pacing is enabled; no-op otherwise.""" + pace = _pace_ms() + if pace and ms > 0: + time.sleep(ms / 1000) + + +# ---- progress watchdog -------------------------------------------------- +# +# Wrap subprocess calls (or any potentially-slow block) so the user gets +# a "current action: " status line if the wrapped block goes silent +# for more than a few seconds. Without this, long silent stretches during +# `brew install`, `npm install -g`, dependency syncs, etc. look exactly +# like a hung wizard. +# +# Don't wrap commands that have their own interactive prompts (e.g. +# `gh auth login`'s device-flow prompts, CLIs that ask for tokens) — +# the watcher line interleaves with the active prompt and confuses +# the user. Use this only for silent or progress-streaming subprocesses. + +_PROGRESS_THRESHOLD_MS = 5000 + + +@contextlib.contextmanager +def progress_watch( + description: str, + *, + threshold_ms: int = _PROGRESS_THRESHOLD_MS, +) -> Iterator[None]: + """Print ``current action: `` if the block runs >threshold_ms. + + Use as a context manager around subprocess calls that can stall:: + + with progress_watch("installing jira-cli (brew)"): + rc = _run_interactive(["brew", "install", "jira-cli"]) + + A daemon thread sleeps for ``threshold_ms``; if the block hasn't + exited by then it prints the heads-up line once. On exit, if the + threshold was crossed, also prints ``done in Ns`` so the user knows + the silent stretch ended. + + No-op for non-TTY (CI / piped output / capture). Re-entrant: each + nested level announces independently with its own watcher thread. + """ + if not sys.stdout.isatty(): + yield + return + + done = threading.Event() + crossed = threading.Event() + + def _watch() -> None: + if not done.wait(threshold_ms / 1000): + crossed.set() + print(f" current action: {description}") + + t = threading.Thread(target=_watch, daemon=True) + t.start() + start = time.monotonic() + try: + yield + finally: + done.set() + if crossed.is_set(): + elapsed = time.monotonic() - start + print(f" → done in {elapsed:.0f}s") + + +# ---- framing ------------------------------------------------------------ + + +def banner(title: str, *, after_ms: int = 400) -> None: + """Print a thick bordered title line with a settle pause after.""" + bar = "=" * (len(title) + 4) + paced_print(bar, f"= {title} =", bar, after_ms=after_ms) + + +def step(num: int, total: int, title: str) -> None: + """Print ``--- Step N/T: title ---`` with a leading blank + settle pause.""" + print() + paced_print(f"--- Step {num}/{total}: {title} ---", after_ms=200) + + +# ---- input prompts ------------------------------------------------------ + + +def yes_no(prompt: str, default: bool = True) -> bool: + """Prompt for yes/no. Empty input picks ``default``. + + Treats EOF / Ctrl-D as "decline" so piping ``echo "" |`` to the + wizard doesn't accidentally agree to destructive actions. + """ + suffix = "Y/n" if default else "y/N" + try: + raw = input(f"{prompt} ({suffix}): ").strip().lower() + except EOFError: + return False + if not raw: + return default + return raw in {"y", "yes"} + + +def ask(label: str, *, hint: str | None = None, default: str | None = None) -> str: + """Single-line free-text prompt with optional hint and default.""" + if hint: + print(f" ({hint})") + suffix = f" [{default}]" if default else "" + try: + raw = input(f" {label}{suffix}: ").strip() + except EOFError: + return default or "" + return raw or (default or "") + + +def ask_secret(label: str, *, hint: str | None = None) -> str: + """Hidden-input prompt for a token / password / API key. + + Wraps ``getpass.getpass`` so the value doesn't echo to the terminal + or land in shell history. EOF returns an empty string (caller should + detect and re-prompt or skip). + """ + from getpass import getpass + + if hint: + print(f" ({hint})") + try: + return getpass(f" {label} (input hidden): ").strip() + except EOFError: + return "" + + +# ---- step picker -------------------------------------------------------- +# +# Render a numbered "what would you like to set up?" menu and parse +# the user's response into the set of step ids to run. Lets users +# pick a subset up front instead of being walked through every +# component sequentially. + +PickerItem = tuple[str, str, bool] +"""A single menu item: ``(step_id, label, recommended)``. + +``step_id`` is the caller's stable identifier (returned in the selection +list). ``label`` is what the user sees on the menu line. +``recommended=True`` tags the item with ``[R]`` and includes it in the +default selection when the user just hits Enter. +""" + + +def _parse_picker_input( + raw: str, + items: list[PickerItem], + default_ids: list[str], +) -> tuple[list[str] | None, str | None]: + """Parse picker input into a list of selected step ids. + + Returns ``(selected_ids, None)`` on success or ``(None, error_msg)`` + on parse failure so the caller can re-prompt. + + Accepts: + - empty string -> ``default_ids`` + - ``"all"`` -> every item id + - ``"none"`` -> empty list (skip everything) + - ``"recommended"`` -> ids of items tagged recommended + - comma-separated 1-based numbers: ``"1,3,5"`` + - inclusive ranges: ``"1-3"`` + - mixed: ``"1-3,5,7"`` + + Selected ids are returned in menu order, not input order, so the + caller can iterate the items list once and run picked entries in + their canonical sequence. + """ + raw = raw.strip().lower() + if not raw: + return list(default_ids), None + if raw == "all": + return [iid for iid, _, _ in items], None + if raw == "none": + return [], None + if raw == "recommended": + return [iid for iid, _, recommended in items if recommended], None + + selected_indices: set[int] = set() + for token in raw.replace(" ", "").split(","): + if not token: + continue + if "-" in token: + try: + lo_s, hi_s = token.split("-", 1) + lo, hi = int(lo_s), int(hi_s) + except ValueError: + return None, f"bad range `{token}`" + if lo < 1 or hi > len(items) or lo > hi: + return None, f"range `{token}` out of bounds (1-{len(items)})" + selected_indices.update(range(lo, hi + 1)) + else: + try: + n = int(token) + except ValueError: + return None, f"not a number: `{token}`" + if n < 1 or n > len(items): + return None, f"`{n}` out of range (1-{len(items)})" + selected_indices.add(n) + + return [iid for i, (iid, _, _) in enumerate(items, 1) if i in selected_indices], None + + +def pick_steps( + items: list[PickerItem], + *, + title: str = "What would you like to set up?", + indent: str = " ", +) -> list[str]: + """Render a numbered picker; return selected step ids in menu order. + + Each entry's ``recommended`` flag shows as a leading ``[R]`` tag and + determines the default selection when the user just hits Enter. The + caller iterates its own item list and runs any step whose id is in + the returned list — this helper doesn't invoke step bodies itself. + + Empty selection (``none`` or no recommended items) is valid and + returns an empty list; callers should handle that gracefully (e.g., + print "skipping all extras" and exit cleanly). + + EOF / Ctrl-D returns the default selection so non-interactive + invocations (CI, ``echo "" | ...``) don't accidentally pick or skip + everything. + """ + default_ids = [iid for iid, _, recommended in items if recommended] + + paced_print(title, after_ms=150) + print() + for i, (_, label, recommended) in enumerate(items, 1): + tag = "[R]" if recommended else " " + print(f"{indent}{i}. {tag} {label}") + print() + print(f"{indent}Pick: comma-separated numbers (1,3,5), ranges (1-3),") + print(f"{indent} `all`, `recommended`, or `none`. [R] = recommended.") + + while True: + try: + raw = input(f"{indent}Choice [recommended]: ") + except EOFError: + return list(default_ids) + + selected, err = _parse_picker_input(raw, items, default_ids) + if selected is None: + print(f"{indent} invalid input: {err}. Try again.") + continue + return selected + + +# ---- result rendering --------------------------------------------------- + + +def print_results( + results: Iterable[CheckResult], + *, + show_ok: bool = True, + show_fix: bool = True, + indent: str = " ", +) -> None: + """Render a sequence of CheckResult. + + By default prints all severities so the doctor's full report is + visible; set ``show_ok=False`` in the wizard to suppress noise from + successful steps and only surface the warnings/failures. + + Set ``show_fix=False`` at call sites where the wizard immediately + follows up with its own multi-line remediation block, so the user + doesn't see the same fix command twice (once as the terse one-line + ``fix:`` hint, then again in the prose block). The doctor leaves + this at the default so its standalone report is fully self-contained. + """ + for r in results: + if r.severity == "ok" and not show_ok: + continue + glyph = _GLYPH[r.severity] + print(f"{indent}{glyph} {r.name}: {r.message}") + if r.fix_hint and r.severity != "ok" and show_fix: + print(f"{indent} fix: {r.fix_hint}") diff --git a/dev_setup/wizard.py b/dev_setup/wizard.py new file mode 100644 index 00000000..9f5b08a1 --- /dev/null +++ b/dev_setup/wizard.py @@ -0,0 +1,468 @@ +"""``setup-wizard`` — first-run / repair wizard for box-python-sdk-gen. + +Picker-driven, idempotent. Three steps: + +1. **Python + dependencies preflight** — verifies Python 3.8+, pip, and + that ``box_sdk_gen`` is importable. Optionally runs + ``pip install -e .[test,dev]``. +2. **Auth credentials** — pick one of Developer Token / JWT / Client + Credentials / OAuth, then capture the credentials it needs and write + them to ``.env`` at the repo root. +3. **Smoke test snippet** — print a short Python program the user can + run to verify their setup talks to Box (``client.users.get_user_me()``). + +Re-running is safe. The wizard writes only ``.env`` (gitignored). + +Run from the repo root:: + + ./scripts/setup + ./scripts/doctor # read-only audit afterwards + +Built using `setup-wizard-squared +`_ — see that +repo's ``reference/patterns.md`` for design rationale. The package is +named ``dev_setup/`` (not ``setup/``) to avoid colliding with +``setup.py`` and ``setuptools.find_packages()``. +""" + +from __future__ import annotations + +import subprocess +import sys +from collections.abc import Callable +from pathlib import Path + +from . import checks +from . import config as cfg +from .prompts import ( + ask, + ask_secret, + banner, + paced_print, + pick_steps, + print_results, + progress_watch, + step, + yes_no, +) + +WIZARD_TITLE: str = "Box Python SDK setup" +WIZARD_TAGLINE: str = ( + "Walks you through verifying Python + pip, installing the SDK in\n" + "editable mode, picking an auth mode, and capturing credentials.\n" + "Re-running is always safe — every step skips work already done." +) + +COMPLETION_FOOTER_LINES: list[str] = [ + " Health check: ./scripts/doctor", + " Auth docs: docs/authentication.md", + " Wizard flows: WIZARD_FLOWS.md", + f" Mint a token: {cfg.DEV_CONSOLE_URL}", +] + + +# ---- subprocess runners ------------------------------------------------- + + +def _run_interactive( + args: list[str], + *, + timeout: int | None = None, + env: dict[str, str] | None = None, +) -> int: + """Run a command with the user's TTY connected. + + ``env`` overrides subprocess environment when provided. Build it by + copying ``os.environ`` and layering ``.env`` values on top so child + commands see ``BOX_*`` variables even if the user never sourced + ``.env`` in their shell. + """ + print(f" $ {' '.join(args)}") + try: + return subprocess.run(args, check=False, timeout=timeout, env=env).returncode + except FileNotFoundError: + print(f" !! {args[0]} not found on PATH; skipping.") + return 127 + + +# ---- step 1: preflight + dependency install ----------------------------- + + +def step_preflight() -> bool: + """Verify Python + pip, optionally install dev dependencies.""" + step(1, len(WIZARD_STEPS), "Python + dependencies preflight") + print(" Verifies Python is recent enough and the SDK installs cleanly.") + print() + py = checks.check_python() + pip_ = checks.check_pip() + print_results(py + pip_, show_ok=True) + if any(r.severity == "fail" for r in py + pip_): + return False + + sdk = checks.check_sdk_importable() + print_results(sdk, show_ok=True) + if all(r.severity == "ok" for r in sdk): + return True + + print() + paced_print( + " The SDK isn't importable yet. Install it in editable mode so", + " changes you make show up immediately:", + f" {sys.executable} -m pip install -e .[test,dev]", + after_ms=200, + ) + print() + if not yes_no("Run `pip install -e .[test,dev]` now?", default=True): + print(" Skipped. Install manually when ready, then re-run ./scripts/doctor.") + return True + + with progress_watch("installing SDK in editable mode (pip)"): + rc = _run_interactive( + [sys.executable, "-m", "pip", "install", "-e", ".[test,dev]"] + ) + if rc != 0: + print(" [FAIL] pip install exited non-zero. See output above.") + return False + paced_print(" → install complete.", after_ms=150) + return True + + +# ---- step 2: auth credentials ------------------------------------------- + + +_AUTH_MODE_LABELS: dict[str, str] = { + cfg.AUTH_MODE_DEV_TOKEN: "Developer Token", + cfg.AUTH_MODE_JWT: "JWT", + cfg.AUTH_MODE_CCG: "Client Credentials Grant (CCG)", + cfg.AUTH_MODE_OAUTH: "OAuth 2.0", +} + + +def _pick_auth_mode() -> str | None: + """Inline mini-picker for auth mode. Returns mode string or None.""" + paced_print( + " Which auth mode are you setting up?", + "", + " 1. Developer Token — fastest; 60-min TTL; testing only", + " 2. JWT — server-to-server; needs config.json from devconsole", + " 3. Client Credentials — server-to-server; client ID + secret", + " 4. OAuth 2.0 — end-user app; client ID + secret + redirect URI", + after_ms=200, + ) + map_choice = { + "1": cfg.AUTH_MODE_DEV_TOKEN, + "2": cfg.AUTH_MODE_JWT, + "3": cfg.AUTH_MODE_CCG, + "4": cfg.AUTH_MODE_OAUTH, + } + while True: + raw = ask("Choice", default="1").strip() + if raw in map_choice: + return map_choice[raw] + if raw == "": + return None + print(f" Pick 1, 2, 3, or 4 (got {raw!r}). Try again, or empty to skip.") + + +def _collect_developer_token() -> bool: + paced_print( + " Mint a Developer Token:", + f" {cfg.DEV_CONSOLE_URL}", + " → Open your app's configuration page → 'Generate Developer Token'", + " → copy the value (it's shown once).", + "", + " Tokens expire after 60 minutes; re-run this step for a fresh one.", + after_ms=250, + ) + token = ask_secret("Developer Token", hint="not echoed; ~64-char alphanumeric") + if not token: + print(" Skipped — no token entered.") + return False + cfg.write_dotenv_key(cfg.BOX_ENV_FILE, "BOX_AUTH_MODE", cfg.AUTH_MODE_DEV_TOKEN) + cfg.write_dotenv_key(cfg.BOX_ENV_FILE, "BOX_DEVELOPER_TOKEN", token) + paced_print(f" → wrote {cfg.BOX_ENV_FILE.name}", after_ms=150) + return True + + +def _collect_jwt_config() -> bool: + paced_print( + " JWT auth reads a JSON config the developer console exports.", + f" Setup guide: {cfg.JWT_AUTH_DOCS_URL}", + "", + " → In the developer console, open your JWT-enabled app →", + " Configuration → Add and Manage Public Keys → Generate Keypair.", + " The browser downloads config.json. Save it somewhere durable", + " (outside the repo if you can — it's a private key).", + after_ms=300, + ) + default = str(cfg.DEFAULT_JWT_CONFIG_PATH) + raw = ask("Path to JWT config JSON", default=default) + p = Path(raw).expanduser() + if not p.exists(): + print(f" [WARN] {p} doesn't exist yet. Saving the path anyway —") + print(" re-run ./scripts/doctor once the file's in place to validate.") + cfg.write_dotenv_key(cfg.BOX_ENV_FILE, "BOX_AUTH_MODE", cfg.AUTH_MODE_JWT) + cfg.write_dotenv_key(cfg.BOX_ENV_FILE, "BOX_JWT_CONFIG_PATH", str(p)) + paced_print(f" → wrote {cfg.BOX_ENV_FILE.name}", after_ms=150) + return True + + +def _collect_ccg_creds() -> bool: + paced_print( + " Client Credentials Grant uses client ID + secret to mint tokens.", + f" Setup: {cfg.CCG_DOCS_URL}", + "", + " CCG can act as the Service Account (enterpriseID) OR a specific", + " user (userID), depending on what the app's authorized for.", + after_ms=250, + ) + client_id = ask("Client ID", hint="from devconsole → Configuration").strip() + client_secret = ask_secret("Client Secret", hint="not echoed") + if not (client_id and client_secret): + print(" Skipped — client ID or secret empty.") + return False + subject_kind = ask( + "Subject", hint="`enterprise` for service account, `user` for user-based", + default="enterprise", + ).strip().lower() + if subject_kind not in {"enterprise", "user"}: + print(f" Unrecognized subject `{subject_kind}`; defaulting to enterprise.") + subject_kind = "enterprise" + subject_id = ask( + f"{subject_kind.capitalize()} ID", + hint="numeric (EID for enterprise, UID for user)", + ).strip() + if not subject_id: + print(" Skipped — subject ID empty.") + return False + cfg.write_dotenv_key(cfg.BOX_ENV_FILE, "BOX_AUTH_MODE", cfg.AUTH_MODE_CCG) + cfg.write_dotenv_key(cfg.BOX_ENV_FILE, "BOX_CCG_CLIENT_ID", client_id) + cfg.write_dotenv_key(cfg.BOX_ENV_FILE, "BOX_CCG_CLIENT_SECRET", client_secret) + cfg.write_dotenv_key(cfg.BOX_ENV_FILE, "BOX_CCG_SUBJECT_KIND", subject_kind) + cfg.write_dotenv_key(cfg.BOX_ENV_FILE, "BOX_CCG_SUBJECT_ID", subject_id) + paced_print(f" → wrote {cfg.BOX_ENV_FILE.name}", after_ms=150) + return True + + +def _collect_oauth_creds() -> bool: + paced_print( + " OAuth 2.0 is for apps that ask end-users to grant Box access.", + f" Setup: {cfg.OAUTH_DOCS_URL}", + "", + " You'll need client ID, client secret, and a redirect URI", + " registered in the developer console.", + after_ms=250, + ) + client_id = ask("Client ID", hint="from devconsole → Configuration").strip() + client_secret = ask_secret("Client Secret", hint="not echoed") + if not (client_id and client_secret): + print(" Skipped — client ID or secret empty.") + return False + redirect = ask( + "Redirect URI", + hint="must match what's registered in the devconsole", + default="http://localhost:8080/callback", + ).strip() + cfg.write_dotenv_key(cfg.BOX_ENV_FILE, "BOX_AUTH_MODE", cfg.AUTH_MODE_OAUTH) + cfg.write_dotenv_key(cfg.BOX_ENV_FILE, "BOX_OAUTH_CLIENT_ID", client_id) + cfg.write_dotenv_key(cfg.BOX_ENV_FILE, "BOX_OAUTH_CLIENT_SECRET", client_secret) + cfg.write_dotenv_key(cfg.BOX_ENV_FILE, "BOX_OAUTH_REDIRECT_URI", redirect) + paced_print(f" → wrote {cfg.BOX_ENV_FILE.name}", after_ms=150) + return True + + +def step_auth_credentials() -> bool: + """Pick an auth mode and capture credentials. Mode lives in BOX_AUTH_MODE.""" + step(2, len(WIZARD_STEPS), "Auth credentials") + print(" Picks an auth mode and captures the credentials it needs.") + print() + existing = cfg.read_dotenv(cfg.BOX_ENV_FILE) if cfg.BOX_ENV_FILE.exists() else {} + current_mode = existing.get("BOX_AUTH_MODE", "").strip() + if current_mode: + label = _AUTH_MODE_LABELS.get(current_mode, current_mode) + paced_print(f" Existing auth mode: {label}", after_ms=150) + if not yes_no("Re-collect / change auth mode?", default=False): + return True + print() + + mode = _pick_auth_mode() + if mode is None: + print(" Skipped.") + return True + print() + + if mode == cfg.AUTH_MODE_DEV_TOKEN: + return _collect_developer_token() + if mode == cfg.AUTH_MODE_JWT: + return _collect_jwt_config() + if mode == cfg.AUTH_MODE_CCG: + return _collect_ccg_creds() + if mode == cfg.AUTH_MODE_OAUTH: + return _collect_oauth_creds() + return False + + +# ---- step 3: smoke test snippet ----------------------------------------- + + +def step_smoke_test() -> bool: + """Print a copy-pasteable Python snippet that calls users.get_user_me().""" + step(3, len(WIZARD_STEPS), "Smoke test snippet") + print(" A 6-line Python program that authenticates and prints your name.") + print() + + if not cfg.BOX_ENV_FILE.exists(): + paced_print( + " [WARN] no .env yet — pick step 2 (Auth credentials) first,", + " then re-run setup and pick step 3.", + after_ms=200, + ) + return True + + values = cfg.read_dotenv(cfg.BOX_ENV_FILE) + mode = values.get("BOX_AUTH_MODE", "").strip() + label = _AUTH_MODE_LABELS.get(mode, "(unknown)") + paced_print( + f" Auth mode in {cfg.BOX_ENV_FILE.name}: {label}", + "", + " Drop this into a .py file at the repo root (or any project that", + " has box-sdk-gen on its sys.path):", + "", + after_ms=150, + ) + + if mode == cfg.AUTH_MODE_DEV_TOKEN: + token = values.get("BOX_DEVELOPER_TOKEN", "") + preview = token[:8] + "…" if len(token) > 8 else "" + print(' import os') + print(' from box_sdk_gen import BoxClient, BoxDeveloperTokenAuth') + print('') + print(f' auth = BoxDeveloperTokenAuth(token=os.environ["BOX_DEVELOPER_TOKEN"]) # or "{preview}"') + print(' client = BoxClient(auth=auth)') + print(' me = client.users.get_user_me()') + print(' print("Hello,", me.name)') + print() + print(" Run after `set -a; source .env; set +a` (or `python-dotenv` etc).") + elif mode == cfg.AUTH_MODE_JWT: + path = values.get("BOX_JWT_CONFIG_PATH", "") + print(' from box_sdk_gen import BoxClient, BoxJWTAuth, JWTConfig') + print('') + print(f' config = JWTConfig.from_config_file({path!r})') + print(' auth = BoxJWTAuth(config=config)') + print(' client = BoxClient(auth=auth)') + print(' print("Hello,", client.users.get_user_me().name)') + elif mode == cfg.AUTH_MODE_CCG: + kind = values.get("BOX_CCG_SUBJECT_KIND", "enterprise") + sid = values.get("BOX_CCG_SUBJECT_ID", "") + print(' import os') + print(' from box_sdk_gen import BoxClient, BoxCCGAuth, CCGConfig') + print('') + if kind == "enterprise": + print(' config = CCGConfig(') + print(' client_id=os.environ["BOX_CCG_CLIENT_ID"],') + print(' client_secret=os.environ["BOX_CCG_CLIENT_SECRET"],') + print(f' enterprise_id="{sid}",') + print(' )') + else: + print(' config = CCGConfig(') + print(' client_id=os.environ["BOX_CCG_CLIENT_ID"],') + print(' client_secret=os.environ["BOX_CCG_CLIENT_SECRET"],') + print(f' user_id="{sid}",') + print(' )') + print(' auth = BoxCCGAuth(config=config)') + print(' client = BoxClient(auth=auth)') + print(' print("Hello,", client.users.get_user_me().name)') + elif mode == cfg.AUTH_MODE_OAUTH: + print(' import os') + print(' from box_sdk_gen import BoxClient, BoxOAuth, OAuthConfig') + print('') + print(' config = OAuthConfig(') + print(' client_id=os.environ["BOX_OAUTH_CLIENT_ID"],') + print(' client_secret=os.environ["BOX_OAUTH_CLIENT_SECRET"],') + print(' )') + print(' auth = BoxOAuth(config=config)') + print(' # Then run the OAuth flow — see docs/authentication.md.') + else: + print(" (see docs/authentication.md for the snippet matching your mode)") + + print() + paced_print( + " Run it from the repo root with the SDK on sys.path:", + " python3 smoke.py", + "", + " Or use python-dotenv to auto-load .env:", + " pip install python-dotenv", + " python3 -c \"from dotenv import load_dotenv; load_dotenv(); exec(open('smoke.py').read())\"", + after_ms=200, + ) + return True + + +# ---- step registry ------------------------------------------------------ + +WizardStepFn = Callable[[], bool] +WIZARD_STEPS: list[tuple[str, str, bool, WizardStepFn]] = [ + ("preflight", "Python + dependencies preflight", True, step_preflight), + ("auth", "Auth credentials (Developer Token / JWT / CCG / OAuth)", True, step_auth_credentials), + ("smoke", "Smoke test snippet", True, step_smoke_test), +] + + +# ---- runner ------------------------------------------------------------- + + +def run_steps(selected_ids: list[str]) -> int: + if not selected_ids: + paced_print(" Nothing selected. Exiting.", after_ms=200) + return 0 + + paced_print( + f" → Will set up: {', '.join(sid for sid in selected_ids)}", + after_ms=200, + ) + + failures: list[str] = [] + for sid, _label, _recommended, fn in WIZARD_STEPS: + if sid not in selected_ids: + continue + try: + ok = fn() + except KeyboardInterrupt: + print("\n Interrupted by user.") + return 130 + except Exception as exc: # noqa: BLE001 - wizard must stay friendly + print(f" [FAIL] step `{sid}` raised {type(exc).__name__}: {exc}") + failures.append(sid) + continue + if not ok: + failures.append(sid) + + print() + banner(f"{WIZARD_TITLE} complete") + for line in COMPLETION_FOOTER_LINES: + paced_print(line, after_ms=80) + + if failures: + print() + print(f" {len(failures)} step(s) reported issues: {', '.join(failures)}") + print(" Re-run `./scripts/setup` and pick those steps to retry.") + return 1 + return 0 + + +def main(argv: list[str] | None = None) -> int: + argv = list(argv if argv is not None else sys.argv[1:]) + + banner(WIZARD_TITLE) + paced_print(*WIZARD_TAGLINE.splitlines(), after_ms=300) + print() + + picker_items = [(sid, label, recommended) for sid, label, recommended, _ in WIZARD_STEPS] + selected = pick_steps(picker_items) + print() + + return run_steps(selected) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/doctor b/scripts/doctor new file mode 100755 index 00000000..c07bf04d --- /dev/null +++ b/scripts/doctor @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# Doctor launcher (read-only audit). See scripts/setup for why the +# package is named dev_setup/ rather than setup/. + +set -euo pipefail +cd "$(dirname "$0")/.." +exec python3 -m dev_setup.doctor "$@" diff --git a/scripts/setup b/scripts/setup new file mode 100755 index 00000000..77ac10da --- /dev/null +++ b/scripts/setup @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# Setup wizard launcher. +# +# The wizard package is named `dev_setup/` (not `setup/`) to avoid +# colliding with the SDK's own `setup.py` and `find_packages()` +# discovery. `dev_setup/` is intentionally NOT installed by pip — it's +# a developer-only onboarding helper and stays in the source tree. + +set -euo pipefail +cd "$(dirname "$0")/.." +exec python3 -m dev_setup.wizard "$@" diff --git a/setup.py b/setup.py index 1f89d304..4ea9beab 100644 --- a/setup.py +++ b/setup.py @@ -59,7 +59,7 @@ def main(): install_requires=install_requires, tests_require=tests_require, extras_require=extras_require, - packages=find_packages(exclude=['docs', '*test*']), + packages=find_packages(exclude=['docs', '*test*', 'dev_setup', 'dev_setup.*']), )