diff --git a/nbs/00_core.ipynb b/nbs/00_cli.ipynb similarity index 66% rename from nbs/00_core.ipynb rename to nbs/00_cli.ipynb index 0bdb5c2..78db5e6 100644 --- a/nbs/00_core.ipynb +++ b/nbs/00_cli.ipynb @@ -5,11 +5,20 @@ "id": "fdd6961e", "metadata": {}, "source": [ - "# Core\n", + "# CLI\n", "\n", "> The Plash CLI tool" ] }, + { + "cell_type": "markdown", + "id": "1bdb6ac6", + "metadata": {}, + "source": [ + "This module implements the Plash CLI functions.\n", + "As they're built with fastcore `@call_parse` they can also be used in python directly." + ] + }, { "cell_type": "code", "execution_count": null, @@ -17,7 +26,7 @@ "metadata": {}, "outputs": [], "source": [ - "#| default_exp core" + "#| default_exp cli" ] }, { @@ -47,7 +56,8 @@ "outputs": [], "source": [ "#| hide\n", - "from tempfile import TemporaryDirectory" + "from tempfile import TemporaryDirectory\n", + "from nbdev.showdoc import show_doc" ] }, { @@ -55,7 +65,7 @@ "id": "bfa048dd", "metadata": {}, "source": [ - "## Helpers" + "## Helpers -" ] }, { @@ -78,7 +88,7 @@ "outputs": [], "source": [ "#| export\n", - "def get_client(cookie_file):\n", + "def _get_client(cookie_file):\n", " client = httpx.Client()\n", " if not cookie_file.exists():\n", " raise FileNotFoundError(\"Plash config not found. Please run plash_login and try again.\")\n", @@ -96,7 +106,7 @@ "outputs": [], "source": [ "#| export\n", - "def mk_auth_req(url:str, method:str='get', **kwargs): return getattr(get_client(PLASH_CONFIG_HOME), method)(url, **kwargs)" + "def _mk_auth_req(url:str, method:str='get', **kwargs): return getattr(_get_client(PLASH_CONFIG_HOME), method)(url, **kwargs)" ] }, { @@ -107,7 +117,7 @@ "outputs": [], "source": [ "#| export\n", - "def get_app_name(path:Path):\n", + "def _get_app_name(path:Path):\n", " plash_app = Path(path) / '.plash'\n", " if not plash_app.exists(): raise FileNotFoundError(f\"File not found: {plash_app=}\")\n", " env = parse_env(fn=plash_app)\n", @@ -126,7 +136,7 @@ "outputs": [], "source": [ "#| export\n", - "def endpoint(sub='', rt=''):\n", + "def _endpoint(sub='', rt=''):\n", " p = \"http\" if \"localhost\" in PLASH_DOMAIN else \"https\"\n", " return f\"{p}://{sub}{'.' if sub else ''}{PLASH_DOMAIN}{rt}\"" ] @@ -139,7 +149,7 @@ "outputs": [], "source": [ "#| export\n", - "def is_included(path):\n", + "def _is_included(path):\n", " \"Returns True if path should be included in deployment\"\n", " if path.name.startswith('.'): return False\n", " if path.suffix == '.pyc': return False\n", @@ -157,44 +167,47 @@ "outputs": [], "source": [ "#| export\n", + "#| hide\n", "class PlashError(Exception): pass" ] }, - { - "cell_type": "markdown", - "id": "38272c9a", - "metadata": {}, - "source": [ - "## Plash - login" - ] - }, { "cell_type": "code", "execution_count": null, - "id": "e6219c6e", + "id": "b7d4028e", "metadata": {}, "outputs": [], "source": [ "#| export\n", - "def poll_cookies(paircode, interval=1, timeout=180):\n", + "def _poll_cookies(paircode, interval=1, timeout=180):\n", " \"Poll server for token until received or timeout\"\n", " start = time()\n", " client = httpx.Client()\n", - " url = endpoint(rt=f\"/cli_token?paircode={paircode}\")\n", + " url = _endpoint(rt=f\"/cli_token?paircode={paircode}\")\n", " while time()-start < timeout:\n", " resp = client.get(url).raise_for_status()\n", " if resp.text.strip(): return dict(client.cookies)\n", " sleep(interval)\n", - " \n", + " " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e6219c6e", + "metadata": {}, + "outputs": [], + "source": [ + "#| export \n", "@call_parse\n", "def login():\n", " \"Authenticate CLI with server and save config\"\n", " paircode = secrets.token_urlsafe(16)\n", - " login_url = httpx.get(endpoint(rt=f\"/cli_login?paircode={paircode}\")).text\n", + " login_url = httpx.get(_endpoint(rt=f\"/cli_login?paircode={paircode}\")).text\n", " print(f\"Opening browser for authentication:\\n{login_url}\\n\")\n", " webbrowser.open(login_url)\n", " \n", - " cookies = poll_cookies(paircode)\n", + " cookies = _poll_cookies(paircode)\n", " if cookies:\n", " Path(PLASH_CONFIG_HOME).write_text(json.dumps(cookies))\n", " print(f\"Authentication successful! Config saved to {PLASH_CONFIG_HOME}\")\n", @@ -203,18 +216,34 @@ }, { "cell_type": "markdown", - "id": "e48bf226", + "id": "466bd08d", "metadata": {}, "source": [ - "## App - deploy" + "CLI usage:" ] }, { - "cell_type": "markdown", - "id": "00b09f08", + "cell_type": "code", + "execution_count": null, + "id": "d8f6668b", "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "usage: plash_login [-h]\n", + "\n", + "Authenticate CLI with server and save config\n", + "\n", + "options:\n", + " -h, --help show this help message and exit\n" + ] + } + ], "source": [ - "Dependencies can be provided via a requirements.txt file or with inline dependencies following PEP 723." + "%%bash\n", + "plash_login --help" ] }, { @@ -240,14 +269,6 @@ " else: return None" ] }, - { - "cell_type": "markdown", - "id": "eaf59f06", - "metadata": {}, - "source": [ - "Lets test on some demo apps:" - ] - }, { "cell_type": "code", "execution_count": null, @@ -255,8 +276,10 @@ "metadata": {}, "outputs": [], "source": [ - "test_eq(_deps(Path(\"../examples/script_app/main.py\").read_text()),\"python-fasthtml\")\n", - "test_is(_deps(Path(\"../examples/minimal/main.py\").read_text()),None)" + "#| hide\n", + "# Lets test on some demo apps:\n", + "test_eq(_deps(Path(\"../examples/inline_dependencies/main.py\").read_text()),\"python-fasthtml\")\n", + "test_is(_deps(Path(\"../examples/fasthtml/main.py\").read_text()),None)" ] }, { @@ -267,7 +290,7 @@ "outputs": [], "source": [ "#| export\n", - "def validate_app(path):\n", + "def _validate_app(path):\n", " \"Validates directory `path` is a deployable Plash app\"\n", " if not (path / 'main.py').exists():\n", " raise PlashError('A Plash app requires a main.py file.')\n", @@ -276,14 +299,6 @@ " raise PlashError('A Plash app should not contain both a requirements.txt file and inline dependencies (see PEP723).')" ] }, - { - "cell_type": "markdown", - "id": "d7ca0a5d", - "metadata": {}, - "source": [ - "All test apps should be valid:" - ] - }, { "cell_type": "code", "execution_count": null, @@ -291,15 +306,9 @@ "metadata": {}, "outputs": [], "source": [ - "for d in Path(\"../examples/\").iterdir(): test_is(validate_app(d),None)" - ] - }, - { - "cell_type": "markdown", - "id": "370eedf5", - "metadata": {}, - "source": [ - "Lets test each failure case:" + "#| hide\n", + "# All test apps should be valid:\n", + "for d in Path(\"../examples/\").iterdir(): test_is(_validate_app(d),None)" ] }, { @@ -309,14 +318,16 @@ "metadata": {}, "outputs": [], "source": [ + "#| hide\n", + "# Lets test each failure case:\n", "with TemporaryDirectory() as td:\n", " td = Path(td)\n", - " test_fail(validate_app, args=(td,), contains=\"main.py\")\n", + " test_fail(_validate_app, args=(td,), contains=\"main.py\")\n", " \n", " # test failure case of deps in both main.py and requirements.txt\n", " (td / \"requirements.txt\").write_text(\"\")\n", - " (td / \"main.py\").write_text(Path(\"../examples/script_app/main.py\").read_text())\n", - " test_fail(validate_app, args=(td,), contains=\"not contain both\")" + " (td / \"main.py\").write_text(Path(\"../examples/inline_dependencies/main.py\").read_text())\n", + " test_fail(_validate_app, args=(td,), contains=\"not contain both\")" ] }, { @@ -327,10 +338,11 @@ "outputs": [], "source": [ "#| export\n", + "#| hide\n", "def create_tar_archive(path:Path, force_data:bool=False) -> tuple[io.BytesIO, int]:\n", " \"Creates a tar archive of a directory, excluding files based on is_included\"\n", " tarz = io.BytesIO()\n", - " files = L(path if path.is_file() else Path(path).iterdir()).filter(is_included)\n", + " files = L(path if path.is_file() else Path(path).iterdir()).filter(_is_included)\n", " if not force_data: files = files.filter(lambda f: f.name != 'data')\n", " with tarfile.open(fileobj=tarz, mode='w:gz') as tar:\n", " for f in files: tar.add(f, arcname=f.name)\n", @@ -373,14 +385,14 @@ " force_data:bool=False): # Overwrite data/ directory during deployment\n", " \"\"\"\n", " Deploys app to production. By default, this command erases all files in your app which are not in data/.\n", - " Then uploads all files and folders, except paths starting with '.' and except the local data/ directory.\n", - " If `--force data` is used, then it erases all files in production. Then it uploads all files and folders,\n", - " including `data/`, except paths starting with '.'.\n", + " Then uploads all files and folders, except paths starting with `.` and except the local data/ directory.\n", + " If `--force_data` is used, then it erases all files in production. Then it uploads all files and folders,\n", + " including `data/`, except paths starting with `.`.\n", " \"\"\"\n", " print('Initializing deployment...')\n", " if name == '': print('Error: App name cannot be an empty string'); return\n", " if not path.is_dir(): print(\"Error: Path should point to the project directory\"); return\n", - " try: validate_app(path)\n", + " try: _validate_app(path)\n", " except PlashError as e: print(f\"Error: {str(e)}\\nInvalid path: {path}\"); return\n", " \n", " try: \n", @@ -391,20 +403,50 @@ " plash_app.write_text(f'export PLASH_APP_NAME={name}')\n", " \n", " tarz, _ = create_tar_archive(path, force_data)\n", - " resp = mk_auth_req(endpoint(rt=\"/upload\"), \"post\", files={'file': tarz}, timeout=300.0, \n", + " resp = _mk_auth_req(_endpoint(rt=\"/upload\"), \"post\", files={'file': tarz}, timeout=300.0, \n", " data={'name': name, 'force_data': force_data})\n", " if resp.status_code == 200:\n", " print('✅ Upload complete! Your app is currently being built.')\n", - " print(f'It will be live at {name if \".\" in name else endpoint(sub=name)}')\n", + " print(f'It will be live at {name if \".\" in name else _endpoint(sub=name)}')\n", " else: print(f'Failure: {resp.status_code}\\n{resp.text}')" ] }, { "cell_type": "markdown", - "id": "492feaa4", + "id": "89457337", "metadata": {}, "source": [ - "## App - view" + "CLI usage:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "43aef875", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "usage: plash_deploy [-h] [--path PATH] [--name NAME] [--force_data]\n", + "\n", + "Deploys app to production. By default, this command erases all files in your app which are not in data/. Then uploads\n", + "all files and folders, except paths starting with '.' and except the local data/ directory. If `--force data` is used,\n", + "then it erases all files in production. Then it uploads all files and folders, including `data/`, except paths starting\n", + "with '.'.\n", + "\n", + "options:\n", + " -h, --help show this help message and exit\n", + " --path PATH Path to project (default: .)\n", + " --name NAME Overrides the .plash file in project root if provided\n", + " --force_data Overwrite data/ directory during deployment (default: False)\n" + ] + } + ], + "source": [ + "%%bash\n", + "plash_deploy --help" ] }, { @@ -421,18 +463,44 @@ " name:str=None, # Overrides the .plash file in project root if provided\n", "):\n", " \"Open your app in the browser\"\n", - " if not name: name = get_app_name(path)\n", - " url = name if '.' in name else endpoint(sub=name)\n", + " if not name: name = _get_app_name(path)\n", + " url = name if '.' in name else _endpoint(sub=name)\n", " print(f\"Opening browser to view app :\\n{url}\\n\")\n", " webbrowser.open(url)" ] }, { "cell_type": "markdown", - "id": "1a389b5a", + "id": "fdf68773", "metadata": {}, "source": [ - "## App - delete" + "CLI usage:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0b3a7e90", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "usage: plash_view [-h] [--path PATH] [--name NAME]\n", + "\n", + "Open your app in the browser\n", + "\n", + "options:\n", + " -h, --help show this help message and exit\n", + " --path PATH Path to project directory (default: .)\n", + " --name NAME Overrides the .plash file in project root if provided\n" + ] + } + ], + "source": [ + "%%bash\n", + "plash_view --help" ] }, { @@ -457,53 +525,143 @@ " return\n", " \n", " print(f\"Deleting app '{name}'...\")\n", - " r = mk_auth_req(endpoint(rt=f\"/delete?name={name}\"), \"delete\")\n", + " r = _mk_auth_req(_endpoint(rt=f\"/delete?name={name}\"), \"delete\")\n", + " return r.text" + ] + }, + { + "cell_type": "markdown", + "id": "02ce4416", + "metadata": {}, + "source": [ + "CLI usage:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "df6721eb", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "usage: plash_delete [-h] [--path PATH] [--name NAME] [--force]\n", + "\n", + "Delete your deployed app\n", + "\n", + "options:\n", + " -h, --help show this help message and exit\n", + " --path PATH Path to project (default: .)\n", + " --name NAME Overrides the .plash file in project root if provided\n", + " --force Skip confirmation prompt (default: False)\n" + ] + } + ], + "source": [ + "%%bash\n", + "plash_delete --help" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8133e01d", + "metadata": {}, + "outputs": [], + "source": [ + "#| export\n", + "@call_parse\n", + "def start(path:Path=Path('.'), name:str=None):\n", + " \"Start your deployed app\"\n", + " if not name: name = get_app_name(path)\n", + " r = _mk_auth_req(_endpoint(rt=f\"/start?name={name}\"))\n", " return r.text" ] }, { "cell_type": "markdown", - "id": "a7272487", + "id": "4bce5102", "metadata": {}, "source": [ - "## App - start stop" + "CLI usage:" ] }, { "cell_type": "code", "execution_count": null, - "id": "a58efc94", + "id": "74a2460d", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "usage: plash_start [-h] [--path PATH] [--name NAME]\n", + "\n", + "Start your deployed app\n", + "\n", + "options:\n", + " -h, --help show this help message and exit\n", + " --path PATH (default: .)\n", + " --name NAME\n" + ] + } + ], + "source": [ + "%%bash\n", + "plash_start --help" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b7a25b65", "metadata": {}, "outputs": [], "source": [ "#| export\n", - "def endpoint_func(endpoint_name):\n", - " 'Creates a function for a specific API endpoint'\n", - " def func(\n", - " path:Path=Path('.'), # Path to project\n", - " name:str=None, # Overrides the .plash file in project root if provided\n", - " ):\n", - " if not name: name = get_app_name(path)\n", - " r = mk_auth_req(endpoint(rt=f\"{endpoint_name}?name={name}\"))\n", - " return r.text\n", - " \n", - " # Set the function name and docstring\n", - " func.__name__ = endpoint_name\n", - " func.__doc__ = f\"Access the '{endpoint_name}' endpoint for your app\"\n", - " \n", - " return call_parse(func)\n", - "\n", - "# Create endpoint-specific functions\n", - "stop = endpoint_func('/stop')\n", - "start = endpoint_func('/start')" + "@call_parse \n", + "def stop(path:Path=Path('.'), name:str=None):\n", + " \"Stop your deployed app\" \n", + " if not name: name = get_app_name(path)\n", + " r = _mk_auth_req(_endpoint(rt=f\"/stop?name={name}\"))\n", + " return r.text" ] }, { "cell_type": "markdown", - "id": "6d463f8a", + "id": "280e6dc7", "metadata": {}, "source": [ - "## App - logs" + "CLI usage:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2e280af5", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "usage: plash_stop [-h] [--path PATH] [--name NAME]\n", + "\n", + "Stop your deployed app\n", + "\n", + "options:\n", + " -h, --help show this help message and exit\n", + " --path PATH (default: .)\n", + " --name NAME\n" + ] + } + ], + "source": [ + "%%bash\n", + "plash_stop --help" ] }, { @@ -532,12 +690,12 @@ " mode:log_modes='build', # Choose between build or app logs\n", " tail:bool=False): # Tail the logs\n", " 'Prints the logs for your deployed app'\n", - " if not name: name = get_app_name(path)\n", + " if not name: name = _get_app_name(path)\n", " if tail:\n", " text = ''\n", " while True:\n", " try:\n", - " r = mk_auth_req(endpoint(rt=f\"/logs?name={name}&mode={mode}\"))\n", + " r = _mk_auth_req(_endpoint(rt=f\"/logs?name={name}&mode={mode}\"))\n", " if r.status_code == 200:\n", " print(r.text[len(text):], end='') # Only print updates\n", " text = r.text\n", @@ -547,16 +705,44 @@ " print(f\"Error: {r.status_code}\")\n", " except KeyboardInterrupt:\n", " return \"\\nExiting\"\n", - " r = mk_auth_req(endpoint(rt=f\"/logs?name={name}&mode={mode}\"))\n", + " r = _mk_auth_req(endpoint(rt=f\"/logs?name={name}&mode={mode}\"))\n", " return r.text" ] }, { "cell_type": "markdown", - "id": "7b1f0408", + "id": "390a7b59", "metadata": {}, "source": [ - "## App - download" + "CLI usage:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "89253eec", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "usage: plash_logs [-h] [--path PATH] [--name NAME] [--mode {build,app}] [--tail]\n", + "\n", + "Prints the logs for your deployed app\n", + "\n", + "options:\n", + " -h, --help show this help message and exit\n", + " --path PATH Path to project (default: .)\n", + " --name NAME Overrides the .plash file in project root if provided\n", + " --mode {build,app} Choose between build or app logs (default: build)\n", + " --tail Tail the logs (default: False)\n" + ] + } + ], + "source": [ + "%%bash\n", + "plash_logs --help" ] }, { @@ -577,7 +763,7 @@ " try: save_path.mkdir(exist_ok=False)\n", " except: print(f\"ERROR: Save path ({save_path}) already exists. Please rename or delete this folder to avoid accidental overwrites.\")\n", " else:\n", - " response = mk_auth_req(endpoint(rt=f'/download?name={name}')).raise_for_status()\n", + " response = mk_auth_req(_endpoint(rt=f'/download?name={name}')).raise_for_status()\n", " file_bytes = io.BytesIO(response.content)\n", " with tarfile.open(fileobj=file_bytes, mode=\"r:gz\") as tar: tar.extractall(path=save_path)\n", " print(f\"Downloaded your app to: {save_path}\")" @@ -585,10 +771,37 @@ }, { "cell_type": "markdown", - "id": "7de8c8e8", + "id": "c095f745", "metadata": {}, "source": [ - "## List Apps" + "CLI usage:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "024db41a", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "usage: plash_download [-h] [--path PATH] [--name NAME] [--save_path SAVE_PATH]\n", + "\n", + "Download your deployed app\n", + "\n", + "options:\n", + " -h, --help show this help message and exit\n", + " --path PATH Path to project (default: .)\n", + " --name NAME Overrides the .plash file in project root if provided\n", + " --save_path SAVE_PATH Save path (optional) (default: download)\n" + ] + } + ], + "source": [ + "%%bash\n", + "plash_download --help" ] }, { @@ -602,19 +815,52 @@ "@call_parse\n", "def apps(verbose:bool=False):\n", " \"List your deployed apps (verbose shows status table: 1=running, 0=stopped)\"\n", - " r = mk_auth_req(endpoint(rt=\"/user_apps\")).raise_for_status()\n", + " r = _mk_auth_req(_endpoint(rt=\"/user_apps\")).raise_for_status()\n", " apps = r.json()\n", " if not apps: return \"You don't have any deployed Plash apps.\"\n", " if verbose: [print(f\"{a['running']} {a['name']}\") for a in apps]\n", " else: [print(a['name']) for a in apps]" ] }, + { + "cell_type": "markdown", + "id": "fd9bdff7", + "metadata": {}, + "source": [ + "CLI usage:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9a0189c8", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "usage: plash_apps [-h] [--verbose]\n", + "\n", + "List your deployed apps (verbose shows status table: 1=running, 0=stopped)\n", + "\n", + "options:\n", + " -h, --help show this help message and exit\n", + " --verbose (default: False)\n" + ] + } + ], + "source": [ + "%%bash\n", + "plash_apps --help" + ] + }, { "cell_type": "markdown", "id": "3748636e", "metadata": {}, "source": [ - "## Export" + "## Export -" ] }, { diff --git a/nbs/01_auth.ipynb b/nbs/01_auth.ipynb index 8a439bb..3d415d2 100644 --- a/nbs/01_auth.ipynb +++ b/nbs/01_auth.ipynb @@ -15,7 +15,17 @@ "id": "188fb79d", "metadata": {}, "source": [ - "For an end2end example on how to use this module see `./examples/auth/main.py`" + "This page describes how Plash Auth is implemented client side. \n", + "\n", + "Please see the [how to](how_to/auth.html) for instructions on how to use it." + ] + }, + { + "cell_type": "markdown", + "id": "59ce8def", + "metadata": {}, + "source": [ + "## Setup -" ] }, { @@ -43,74 +53,58 @@ "from plash_cli import __version__" ] }, - { - "cell_type": "markdown", - "id": "980c7492", - "metadata": {}, - "source": [ - "The signin completion route is where Google redirects users after authentication. Your app needs to add this route to handle the OAuth callback." - ] - }, { "cell_type": "code", "execution_count": null, - "id": "4b15411c", + "id": "d3504f51", "metadata": {}, "outputs": [], "source": [ "#| export\n", - "signin_completed_rt = \"/signin_completed\"" - ] - }, - { - "cell_type": "markdown", - "id": "a4873761", - "metadata": {}, - "source": [ - "The production flag lets developers use mock authentication during development." + "_in_prod = os.getenv('PLASH_PRODUCTION', '') == '1'" ] }, { "cell_type": "code", "execution_count": null, - "id": "718ef4d6", + "id": "e6590075", "metadata": {}, "outputs": [], "source": [ "#| export\n", - "_in_prod = os.getenv('PLASH_PRODUCTION', '') == '1'" + "def _signin_url(email_re: str=None, hd_re: str=None):\n", + " res = httpx.post(os.environ['PLASH_AUTH_URL'], json=dict(email_re=email_re, hd_re=hd_re), \n", + " auth=(os.environ['PLASH_APP_ID'], os.environ['PLASH_APP_SECRET']), \n", + " headers={'X-PLASH-AUTH-VERSION': __version__}).raise_for_status().json()\n", + " if \"warning\" in res: warn(res.pop('warning'))\n", + " return res" ] }, { "cell_type": "markdown", - "id": "f15e27d0", + "id": "04e96cb6", "metadata": {}, "source": [ - "This function makes the actual HTTP request to the Plash authentication service. It sends the email and domain filters to get back a signin URL and request ID that we'll use to track this authentication attempt." + "### Redirect route" ] }, { "cell_type": "code", "execution_count": null, - "id": "73a7d63b", + "id": "4b15411c", "metadata": {}, "outputs": [], "source": [ - "#| export\n", - "def _signin_url(email_re: str=None, hd_re: str=None):\n", - " res = httpx.post(os.environ['PLASH_AUTH_URL'], json=dict(email_re=email_re, hd_re=hd_re), \n", - " auth=(os.environ['PLASH_APP_ID'], os.environ['PLASH_APP_SECRET']), \n", - " headers={'X-PLASH-AUTH-VERSION': __version__}).raise_for_status().json()\n", - " if \"warning\" in res: warn(res.pop('warning'))\n", - " return res" + "#| exports\n", + "signin_completed_rt = \"/signin_completed\"" ] }, { "cell_type": "markdown", - "id": "acf6707c", + "id": "78f8fc38", "metadata": {}, "source": [ - "This is the main function your app calls to get a Google signin URL. In development mode, it returns a mock URL to make testing easier. In production, it calls the Plash auth service and stores the request ID in the session for later verification." + "The signin completion route is where Plash Auth redirects users after authentication. Your app needs to add this route to complete the login." ] }, { @@ -133,12 +127,14 @@ }, { "cell_type": "markdown", - "id": "1b33d30d", + "id": "ea4836e1", "metadata": {}, "source": [ - "After Google authentication, Plash sends back a JSON Web Token (JWT) containing the user's information. This function decodes and validates that token using the ES256 public key. If anything goes wrong with the JWT, it returns error details instead of crashing.\n", + "`mk_signin_url` is the function your app calls to create a Google signin URL for the user. \n", "\n", - "Note: a JWT does not mean the message is encrypted. It ensures data integrity and authenticity, it protects against tampering and forgery. We use JWT tokens so your app can trust that the sign-in information and user details it receives after authentication really come from Plash (and by extension, Google), and have not been modified by an attacker." + "In development mode, it returns a mock URL to make testing easier. \n", + "\n", + "In production, it calls the Plash Auth service and stores the request ID in the session for later verification." ] }, { @@ -159,11 +155,14 @@ }, { "cell_type": "markdown", - "id": "6c1fe53f", + "id": "2018d4e9", "metadata": {}, "source": [ - "A custom exception for when authentication fails. This makes it easier for your app to handle auth errors specifically.\n", - "See `./examples/auth/main.py` for an example on how you can catch this exception in your application." + "After Google authentication, Plash sends back a JSON Web Token (JWT) containing the user's information. This function decodes and validates that token using the ES256 public key. If anything goes wrong with the JWT, it returns error details instead of crashing.\n", + "\n", + "::: {.callout-note}\n", + "A JWT does not mean the message is encrypted. It ensures data integrity and authenticity, it protects against tampering and forgery. We use JWT tokens so your app can trust that the sign-in information and user details it receives after authentication really come from Plash (and by extension, Google), and have not been modified by an attacker.\n", + ":::" ] }, { @@ -181,12 +180,12 @@ }, { "cell_type": "markdown", - "id": "62320729", + "id": "bb5e03fd", "metadata": {}, "source": [ - "This is the main function your app calls in the signin completion route. It verifies the JWT reply matches the original request (preventing CSRF attacks), checks for any authentication errors, and returns the user's Google ID if everything is valid.\n", + "`PlashAuthError` is a custom exception for when authentication fails. This makes it easier for your app to handle auth errors specifically.\n", "\n", - "When testing locally this will always return the mock Google ID `'424242424242424242424'`." + "Please see the [auth example](https://github.com/AnswerDotAI/plash_cli/blob/main/examples/auth/main.py) for an example on how you can catch this exception in your application." ] }, { @@ -207,6 +206,16 @@ " return parsed['sub']" ] }, + { + "cell_type": "markdown", + "id": "3b9c0959", + "metadata": {}, + "source": [ + "`goog_id_from_signin_reply` is the function your app calls in the signin completion route. It verifies the JWT reply matches the original request (preventing CSRF attacks), checks for any authentication errors, and returns the user's Google ID if everything is valid.\n", + "\n", + "When testing locally this will always return the mock Google ID `'424242424242424242424'`." + ] + }, { "cell_type": "markdown", "id": "72eaabaa", diff --git a/nbs/_quarto.yml b/nbs/_quarto.yml index 3beac76..c349eab 100644 --- a/nbs/_quarto.yml +++ b/nbs/_quarto.yml @@ -26,7 +26,7 @@ website: left: - text: "Home" href: https://pla.sh - - text: "Learn" + - text: "Docs" href: https://docs.pla.sh right: - icon: github @@ -44,6 +44,8 @@ website: - section: Explanations contents: explains/* - section: Reference - contents: reference/* + contents: + - 00_cli.ipynb + - 01_auth.ipynb metadata-files: [nbdev.yml] \ No newline at end of file diff --git a/nbs/reference/00_cli.ipynb b/nbs/reference/00_cli.ipynb deleted file mode 100644 index 106435f..0000000 --- a/nbs/reference/00_cli.ipynb +++ /dev/null @@ -1,344 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "97776099", - "metadata": {}, - "source": [ - "# CLI Reference\n", - "\n", - "> An overview of the Plash CLI commands" - ] - }, - { - "cell_type": "markdown", - "id": "897236fc", - "metadata": {}, - "source": [ - "## plash_login" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "1267196a", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "usage: plash_login [-h]\n", - "\n", - "Authenticate CLI with server and save config\n", - "\n", - "options:\n", - " -h, --help show this help message and exit\n" - ] - } - ], - "source": [ - "%%bash\n", - "plash_login --help" - ] - }, - { - "cell_type": "markdown", - "id": "d68f6131", - "metadata": {}, - "source": [ - "## plash_apps" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "a27502f9", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "usage: plash_apps [-h] [--verbose]\n", - "\n", - "List your deployed apps (verbose shows status table: 1=running, 0=stopped)\n", - "\n", - "options:\n", - " -h, --help show this help message and exit\n", - " --verbose (default: False)\n" - ] - } - ], - "source": [ - "%%bash\n", - "plash_apps --help" - ] - }, - { - "cell_type": "markdown", - "id": "0fed0889", - "metadata": {}, - "source": [ - "## plash_deploy" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "5b50aa51", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "usage: plash_deploy [-h] [--path PATH] [--name NAME] [--force_data]\n", - "\n", - "Deploys app to production. By default, this command erases all files in your app which are not in data/. Then uploads\n", - "all files and folders, except paths starting with '.' and except the local data/ directory. If `--force data` is used,\n", - "then it erases all files in production. Then it uploads all files and folders, including `data/`, except paths starting\n", - "with '.'.\n", - "\n", - "options:\n", - " -h, --help show this help message and exit\n", - " --path PATH Path to project (default: .)\n", - " --name NAME Overrides the .plash file in project root if provided\n", - " --force_data Overwrite data/ directory during deployment (default: False)\n" - ] - } - ], - "source": [ - "%%bash\n", - "plash_deploy --help" - ] - }, - { - "cell_type": "markdown", - "id": "c9429698", - "metadata": {}, - "source": [ - "::: {.callout-warning title=\"Use --force_data carefully\"}\n", - "The `--force_data` flag will completely replace your production data. Always backup before using on production applications.\n", - ":::" - ] - }, - { - "cell_type": "markdown", - "id": "ddd7b14f", - "metadata": {}, - "source": [ - "## plash_view" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "7d9e7e13", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "usage: plash_view [-h] [--path PATH] [--name NAME]\n", - "\n", - "Open your app in the browser\n", - "\n", - "options:\n", - " -h, --help show this help message and exit\n", - " --path PATH Path to project directory (default: .)\n", - " --name NAME Overrides the .plash file in project root if provided\n" - ] - } - ], - "source": [ - "%%bash\n", - "plash_view --help" - ] - }, - { - "cell_type": "markdown", - "id": "9b9395d8", - "metadata": {}, - "source": [ - "## plash_start" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "38fc6718", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "usage: plash_start [-h] [--path PATH] [--name NAME]\n", - "\n", - "Access the '/start' endpoint for your app\n", - "\n", - "options:\n", - " -h, --help show this help message and exit\n", - " --path PATH Path to project (default: .)\n", - " --name NAME Overrides the .plash file in project root if provided\n" - ] - } - ], - "source": [ - "%%bash\n", - "plash_start --help" - ] - }, - { - "cell_type": "markdown", - "id": "249700ef", - "metadata": {}, - "source": [ - "## plash_stop" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "0614451f", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "usage: plash_stop [-h] [--path PATH] [--name NAME]\n", - "\n", - "Access the '/stop' endpoint for your app\n", - "\n", - "options:\n", - " -h, --help show this help message and exit\n", - " --path PATH Path to project (default: .)\n", - " --name NAME Overrides the .plash file in project root if provided\n" - ] - } - ], - "source": [ - "%%bash\n", - "plash_stop --help" - ] - }, - { - "cell_type": "markdown", - "id": "b790500f", - "metadata": {}, - "source": [ - "## plash_logs" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "fc8cc9a7", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "usage: plash_logs [-h] [--path PATH] [--name NAME] [--mode {build,app}] [--tail]\n", - "\n", - "Prints the logs for your deployed app\n", - "\n", - "options:\n", - " -h, --help show this help message and exit\n", - " --path PATH Path to project (default: .)\n", - " --name NAME Overrides the .plash file in project root if provided\n", - " --mode {build,app} Choose between build or app logs (default: build)\n", - " --tail Tail the logs (default: False)\n" - ] - } - ], - "source": [ - "%%bash\n", - "plash_logs --help" - ] - }, - { - "cell_type": "markdown", - "id": "ad406d88", - "metadata": {}, - "source": [ - "## plash_download" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "dfed47f7", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "usage: plash_download [-h] [--path PATH] [--name NAME] [--save_path SAVE_PATH]\n", - "\n", - "Download your deployed app\n", - "\n", - "options:\n", - " -h, --help show this help message and exit\n", - " --path PATH Path to project (default: .)\n", - " --name NAME Overrides the .plash file in project root if provided\n", - " --save_path SAVE_PATH Save path (optional) (default: download)\n" - ] - } - ], - "source": [ - "%%bash\n", - "plash_download --help" - ] - }, - { - "cell_type": "markdown", - "id": "486c9780", - "metadata": {}, - "source": [ - "## plash_delete" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "0c3663fa", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "usage: plash_delete [-h] [--path PATH] [--name NAME] [--force]\n", - "\n", - "Delete your deployed app\n", - "\n", - "options:\n", - " -h, --help show this help message and exit\n", - " --path PATH Path to project (default: .)\n", - " --name NAME Overrides the .plash file in project root if provided\n", - " --force Skip confirmation prompt (default: False)\n" - ] - } - ], - "source": [ - "%%bash\n", - "plash_delete --help" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "python3", - "language": "python", - "name": "python3" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/nbs/sidebar.yml b/nbs/sidebar.yml index 3157b49..cbfb243 100644 --- a/nbs/sidebar.yml +++ b/nbs/sidebar.yml @@ -2,7 +2,7 @@ website: sidebar: contents: - index.ipynb - - 00_core.ipynb + - 00_cli.ipynb - 01_auth.ipynb - section: explains contents: @@ -16,6 +16,3 @@ website: - how_to/02_add_custom_domain.ipynb - how_to/03_restore_backups.ipynb - how_to/04_auth.ipynb - - section: reference - contents: - - reference/00_cli.ipynb diff --git a/plash_cli/_modidx.py b/plash_cli/_modidx.py index 5e7c581..9524e79 100644 --- a/plash_cli/_modidx.py +++ b/plash_cli/_modidx.py @@ -10,22 +10,23 @@ 'plash_cli.auth._signin_url': ('auth.html#_signin_url', 'plash_cli/auth.py'), 'plash_cli.auth.goog_id_from_signin_reply': ('auth.html#goog_id_from_signin_reply', 'plash_cli/auth.py'), 'plash_cli.auth.mk_signin_url': ('auth.html#mk_signin_url', 'plash_cli/auth.py')}, - 'plash_cli.core': { 'plash_cli.core.PlashError': ('core.html#plasherror', 'plash_cli/core.py'), - 'plash_cli.core._deps': ('core.html#_deps', 'plash_cli/core.py'), - 'plash_cli.core._gen_app_name': ('core.html#_gen_app_name', 'plash_cli/core.py'), - 'plash_cli.core.apps': ('core.html#apps', 'plash_cli/core.py'), - 'plash_cli.core.create_tar_archive': ('core.html#create_tar_archive', 'plash_cli/core.py'), - 'plash_cli.core.delete': ('core.html#delete', 'plash_cli/core.py'), - 'plash_cli.core.deploy': ('core.html#deploy', 'plash_cli/core.py'), - 'plash_cli.core.download': ('core.html#download', 'plash_cli/core.py'), - 'plash_cli.core.endpoint': ('core.html#endpoint', 'plash_cli/core.py'), - 'plash_cli.core.endpoint_func': ('core.html#endpoint_func', 'plash_cli/core.py'), - 'plash_cli.core.get_app_name': ('core.html#get_app_name', 'plash_cli/core.py'), - 'plash_cli.core.get_client': ('core.html#get_client', 'plash_cli/core.py'), - 'plash_cli.core.is_included': ('core.html#is_included', 'plash_cli/core.py'), - 'plash_cli.core.login': ('core.html#login', 'plash_cli/core.py'), - 'plash_cli.core.logs': ('core.html#logs', 'plash_cli/core.py'), - 'plash_cli.core.mk_auth_req': ('core.html#mk_auth_req', 'plash_cli/core.py'), - 'plash_cli.core.poll_cookies': ('core.html#poll_cookies', 'plash_cli/core.py'), - 'plash_cli.core.validate_app': ('core.html#validate_app', 'plash_cli/core.py'), - 'plash_cli.core.view': ('core.html#view', 'plash_cli/core.py')}}} + 'plash_cli.cli': { 'plash_cli.cli.PlashError': ('cli.html#plasherror', 'plash_cli/cli.py'), + 'plash_cli.cli._deps': ('cli.html#_deps', 'plash_cli/cli.py'), + 'plash_cli.cli._endpoint': ('cli.html#_endpoint', 'plash_cli/cli.py'), + 'plash_cli.cli._gen_app_name': ('cli.html#_gen_app_name', 'plash_cli/cli.py'), + 'plash_cli.cli._get_app_name': ('cli.html#_get_app_name', 'plash_cli/cli.py'), + 'plash_cli.cli._get_client': ('cli.html#_get_client', 'plash_cli/cli.py'), + 'plash_cli.cli._is_included': ('cli.html#_is_included', 'plash_cli/cli.py'), + 'plash_cli.cli._mk_auth_req': ('cli.html#_mk_auth_req', 'plash_cli/cli.py'), + 'plash_cli.cli._poll_cookies': ('cli.html#_poll_cookies', 'plash_cli/cli.py'), + 'plash_cli.cli._validate_app': ('cli.html#_validate_app', 'plash_cli/cli.py'), + 'plash_cli.cli.apps': ('cli.html#apps', 'plash_cli/cli.py'), + 'plash_cli.cli.create_tar_archive': ('cli.html#create_tar_archive', 'plash_cli/cli.py'), + 'plash_cli.cli.delete': ('cli.html#delete', 'plash_cli/cli.py'), + 'plash_cli.cli.deploy': ('cli.html#deploy', 'plash_cli/cli.py'), + 'plash_cli.cli.download': ('cli.html#download', 'plash_cli/cli.py'), + 'plash_cli.cli.login': ('cli.html#login', 'plash_cli/cli.py'), + 'plash_cli.cli.logs': ('cli.html#logs', 'plash_cli/cli.py'), + 'plash_cli.cli.start': ('cli.html#start', 'plash_cli/cli.py'), + 'plash_cli.cli.stop': ('cli.html#stop', 'plash_cli/cli.py'), + 'plash_cli.cli.view': ('cli.html#view', 'plash_cli/cli.py')}}} diff --git a/plash_cli/auth.py b/plash_cli/auth.py index ad7f552..71f931a 100644 --- a/plash_cli/auth.py +++ b/plash_cli/auth.py @@ -5,7 +5,7 @@ # %% auto 0 __all__ = ['signin_completed_rt', 'mk_signin_url', 'PlashAuthError', 'goog_id_from_signin_reply'] -# %% ../nbs/01_auth.ipynb 3 +# %% ../nbs/01_auth.ipynb 4 import httpx,os,jwt from pathlib import Path from warnings import warn @@ -13,12 +13,9 @@ from . import __version__ # %% ../nbs/01_auth.ipynb 5 -signin_completed_rt = "/signin_completed" - -# %% ../nbs/01_auth.ipynb 7 _in_prod = os.getenv('PLASH_PRODUCTION', '') == '1' -# %% ../nbs/01_auth.ipynb 9 +# %% ../nbs/01_auth.ipynb 6 def _signin_url(email_re: str=None, hd_re: str=None): res = httpx.post(os.environ['PLASH_AUTH_URL'], json=dict(email_re=email_re, hd_re=hd_re), auth=(os.environ['PLASH_APP_ID'], os.environ['PLASH_APP_SECRET']), @@ -26,7 +23,10 @@ def _signin_url(email_re: str=None, hd_re: str=None): if "warning" in res: warn(res.pop('warning')) return res -# %% ../nbs/01_auth.ipynb 11 +# %% ../nbs/01_auth.ipynb 8 +signin_completed_rt = "/signin_completed" + +# %% ../nbs/01_auth.ipynb 10 def mk_signin_url(session: dict, # Session dictionary email_re: str=None, # Regex filter for allowed email addresses hd_re: str=None): # Regex filter for allowed Google hosted domains @@ -36,7 +36,7 @@ def mk_signin_url(session: dict, # Session dictionary session['req_id'] = res['req_id'] return res['plash_signin_url'] -# %% ../nbs/01_auth.ipynb 13 +# %% ../nbs/01_auth.ipynb 12 def _parse_jwt(reply: str) -> dict: "Parse JWT reply and return decoded claims or error info" try: decoded = jwt.decode(reply, key=open(Path(__file__).parent / "assets" / "es256_public_key.pem","rb").read(), algorithms=["ES256"], @@ -44,12 +44,12 @@ def _parse_jwt(reply: str) -> dict: except Exception as e: return dict(req_id=None, sub=None, err=f'JWT validation failed: {e}') return dict(req_id=decoded.get('req_id'), sub=decoded.get('sub'), err=decoded.get('err')) -# %% ../nbs/01_auth.ipynb 15 +# %% ../nbs/01_auth.ipynb 14 class PlashAuthError(Exception): """Raised when Plash authentication fails""" pass -# %% ../nbs/01_auth.ipynb 17 +# %% ../nbs/01_auth.ipynb 16 def goog_id_from_signin_reply(session: dict, # Session dictionary containing 'req_id' reply: str): # The JWT reply string from Plash after Google authentication "Validate Google sign-in reply and returns Google user ID if valid." diff --git a/plash_cli/core.py b/plash_cli/cli.py similarity index 80% rename from plash_cli/core.py rename to plash_cli/cli.py index 763a61b..b5f4e43 100644 --- a/plash_cli/core.py +++ b/plash_cli/cli.py @@ -1,13 +1,12 @@ """The Plash CLI tool""" -# AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/00_core.ipynb. +# AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/00_cli.ipynb. # %% auto 0 -__all__ = ['PLASH_CONFIG_HOME', 'PLASH_DOMAIN', 'pat', 'stop', 'start', 'log_modes', 'get_client', 'mk_auth_req', 'get_app_name', - 'endpoint', 'is_included', 'PlashError', 'poll_cookies', 'login', 'validate_app', 'create_tar_archive', - 'deploy', 'view', 'delete', 'endpoint_func', 'logs', 'download', 'apps'] +__all__ = ['PLASH_CONFIG_HOME', 'PLASH_DOMAIN', 'pat', 'log_modes', 'PlashError', 'login', 'create_tar_archive', 'deploy', 'view', + 'delete', 'start', 'stop', 'logs', 'download', 'apps'] -# %% ../nbs/00_core.ipynb 2 +# %% ../nbs/00_cli.ipynb 3 from fastcore.all import * from fastcore.xdg import * import secrets, webbrowser, json, httpx, io, tarfile, random, string @@ -18,12 +17,12 @@ from . import __version__ -# %% ../nbs/00_core.ipynb 5 +# %% ../nbs/00_cli.ipynb 6 PLASH_CONFIG_HOME = xdg_config_home() / 'plash_config.json' PLASH_DOMAIN = os.getenv("PLASH_DOMAIN","pla.sh") # pla.sh plash-dev.answer.ai localhost:5002 -# %% ../nbs/00_core.ipynb 6 -def get_client(cookie_file): +# %% ../nbs/00_cli.ipynb 7 +def _get_client(cookie_file): client = httpx.Client() if not cookie_file.exists(): raise FileNotFoundError("Plash config not found. Please run plash_login and try again.") @@ -32,11 +31,11 @@ def get_client(cookie_file): client.headers.update({'X-PLASH': 'true', 'User-Agent': f'plash_cli/{__version__}'}) return client -# %% ../nbs/00_core.ipynb 7 -def mk_auth_req(url:str, method:str='get', **kwargs): return getattr(get_client(PLASH_CONFIG_HOME), method)(url, **kwargs) +# %% ../nbs/00_cli.ipynb 8 +def _mk_auth_req(url:str, method:str='get', **kwargs): return getattr(_get_client(PLASH_CONFIG_HOME), method)(url, **kwargs) -# %% ../nbs/00_core.ipynb 8 -def get_app_name(path:Path): +# %% ../nbs/00_cli.ipynb 9 +def _get_app_name(path:Path): plash_app = Path(path) / '.plash' if not plash_app.exists(): raise FileNotFoundError(f"File not found: {plash_app=}") env = parse_env(fn=plash_app) @@ -47,13 +46,13 @@ def get_app_name(path:Path): raise RuntimeError(f"{plash_app=} did not have a PLASH_APP_NAME") -# %% ../nbs/00_core.ipynb 9 -def endpoint(sub='', rt=''): +# %% ../nbs/00_cli.ipynb 10 +def _endpoint(sub='', rt=''): p = "http" if "localhost" in PLASH_DOMAIN else "https" return f"{p}://{sub}{'.' if sub else ''}{PLASH_DOMAIN}{rt}" -# %% ../nbs/00_core.ipynb 10 -def is_included(path): +# %% ../nbs/00_cli.ipynb 11 +def _is_included(path): "Returns True if path should be included in deployment" if path.name.startswith('.'): return False if path.suffix == '.pyc': return False @@ -62,35 +61,37 @@ def is_included(path): '.vscode', '.idea', '.sesskey'} return not any(p in excludes for p in path.parts) -# %% ../nbs/00_core.ipynb 11 +# %% ../nbs/00_cli.ipynb 12 class PlashError(Exception): pass -# %% ../nbs/00_core.ipynb 13 -def poll_cookies(paircode, interval=1, timeout=180): +# %% ../nbs/00_cli.ipynb 13 +def _poll_cookies(paircode, interval=1, timeout=180): "Poll server for token until received or timeout" start = time() client = httpx.Client() - url = endpoint(rt=f"/cli_token?paircode={paircode}") + url = _endpoint(rt=f"/cli_token?paircode={paircode}") while time()-start < timeout: resp = client.get(url).raise_for_status() if resp.text.strip(): return dict(client.cookies) sleep(interval) - + + +# %% ../nbs/00_cli.ipynb 14 @call_parse def login(): "Authenticate CLI with server and save config" paircode = secrets.token_urlsafe(16) - login_url = httpx.get(endpoint(rt=f"/cli_login?paircode={paircode}")).text + login_url = httpx.get(_endpoint(rt=f"/cli_login?paircode={paircode}")).text print(f"Opening browser for authentication:\n{login_url}\n") webbrowser.open(login_url) - cookies = poll_cookies(paircode) + cookies = _poll_cookies(paircode) if cookies: Path(PLASH_CONFIG_HOME).write_text(json.dumps(cookies)) print(f"Authentication successful! Config saved to {PLASH_CONFIG_HOME}") else: print("Authentication timed out.") -# %% ../nbs/00_core.ipynb 16 +# %% ../nbs/00_cli.ipynb 17 pat = r'(?m)^# /// (?P[a-zA-Z0-9-]+)$\s(?P(^#(| .*)$\s)+)^# ///$' def _deps(script: bytes | str) -> dict | None: @@ -105,8 +106,8 @@ def _deps(script: bytes | str) -> dict | None: return '\n'.join(tomllib.loads(content)['dependencies']) else: return None -# %% ../nbs/00_core.ipynb 19 -def validate_app(path): +# %% ../nbs/00_cli.ipynb 19 +def _validate_app(path): "Validates directory `path` is a deployable Plash app" if not (path / 'main.py').exists(): raise PlashError('A Plash app requires a main.py file.') @@ -114,11 +115,11 @@ def validate_app(path): if deps and (path/"requirements.txt").exists(): raise PlashError('A Plash app should not contain both a requirements.txt file and inline dependencies (see PEP723).') -# %% ../nbs/00_core.ipynb 24 +# %% ../nbs/00_cli.ipynb 22 def create_tar_archive(path:Path, force_data:bool=False) -> tuple[io.BytesIO, int]: "Creates a tar archive of a directory, excluding files based on is_included" tarz = io.BytesIO() - files = L(path if path.is_file() else Path(path).iterdir()).filter(is_included) + files = L(path if path.is_file() else Path(path).iterdir()).filter(_is_included) if not force_data: files = files.filter(lambda f: f.name != 'data') with tarfile.open(fileobj=tarz, mode='w:gz') as tar: for f in files: tar.add(f, arcname=f.name) @@ -129,7 +130,7 @@ def create_tar_archive(path:Path, force_data:bool=False) -> tuple[io.BytesIO, in tarz.seek(0) return tarz, len(files) -# %% ../nbs/00_core.ipynb 25 +# %% ../nbs/00_cli.ipynb 23 def _gen_app_name(): adjectives = ['admiring', 'adoring', 'amazing', 'awesome', 'beautiful', 'blissful', 'bold', 'brave', 'busy', 'charming', 'clever', 'compassionate', 'confident', 'cool', 'dazzling', 'determined', 'dreamy', 'eager', 'ecstatic', 'elastic', 'elated', 'elegant', 'epic', 'exciting', 'fervent', 'festive', 'flamboyant', 'focused', 'friendly', 'frosty', 'funny', 'gallant', 'gifted', 'goofy', 'gracious', 'great', 'happy', 'hopeful', 'hungry', 'inspiring', 'intelligent', 'interesting', 'jolly', 'jovial', 'keen', 'kind', 'laughing', 'loving', 'lucid', 'magical', 'modest', 'nice', 'nifty', 'nostalgic', 'objective', 'optimistic', 'peaceful', 'pensive', 'practical', 'priceless', 'quirky', 'quizzical', 'relaxed', 'reverent', 'romantic', 'serene', 'sharp', 'silly', 'sleepy', 'stoic', 'sweet', 'tender', 'trusting', 'upbeat', 'vibrant', 'vigilant', 'vigorous', 'wizardly', 'wonderful', 'youthful', 'zealous', 'zen', 'golden', 'silver', 'crimson', 'azure', 'emerald', 'violet', 'amber', 'coral', 'turquoise', 'lavender', 'minty', 'citrus', 'vanilla', 'woody', 'floral', 'fresh', 'gentle', 'sparkling', 'precise', 'curious'] nouns = ['tiger', 'eagle', 'river', 'mountain', 'forest', 'ocean', 'star', 'moon', 'wind', 'dragon', 'phoenix', 'wolf', 'bear', 'lion', 'shark', 'falcon', 'raven', 'crystal', 'diamond', 'ruby', 'sapphire', 'pearl', 'wave', 'tide', 'cloud', 'rainbow', 'sunset', 'sunrise', 'galaxy', 'comet', 'meteor', 'planet', 'nebula', 'cosmos', 'universe', 'atom', 'photon', 'quantum', 'matrix', 'cipher', 'code', 'signal', 'pulse', 'beam', 'ray', 'spark', 'frost', 'ice', 'snow', 'mist', 'fog', 'dew', 'rain', 'hail', 'helix', 'prism', 'lens', 'mirror', 'echo', 'heart', 'mind', 'dream', 'vision', 'hope', 'wish', 'magic', 'spell', 'charm', 'rune', 'symbol', 'token', 'key', 'door', 'gate', 'bridge', 'tower', 'castle', 'fortress', 'shield', 'dolphin', 'whale', 'penguin', 'butterfly', 'hummingbird', 'deer', 'rabbit', 'fox', 'otter', 'panda', 'koala', 'zebra', 'giraffe', 'elephant', 'valley', 'canyon', 'meadow', 'prairie', 'island', 'lake', 'pond', 'stream', 'waterfall', 'cliff', 'peak', 'hill', 'grove', 'garden', 'sunlight', 'breeze', 'melody', 'sparkle', 'whirlpool', 'windmill', 'carousel', 'spiral', 'glow'] @@ -137,7 +138,7 @@ def _gen_app_name(): suffix = ''.join(random.choices(string.ascii_lowercase + string.digits, k=3)) return f"{random.choice(adjectives)}-{random.choice(nouns)}-{random.choice(verbs)}-{suffix}" -# %% ../nbs/00_core.ipynb 26 +# %% ../nbs/00_cli.ipynb 24 @call_parse def deploy( path:Path=Path('.'), # Path to project @@ -145,14 +146,14 @@ def deploy( force_data:bool=False): # Overwrite data/ directory during deployment """ Deploys app to production. By default, this command erases all files in your app which are not in data/. - Then uploads all files and folders, except paths starting with '.' and except the local data/ directory. - If `--force data` is used, then it erases all files in production. Then it uploads all files and folders, - including `data/`, except paths starting with '.'. + Then uploads all files and folders, except paths starting with `.` and except the local data/ directory. + If `--force_data` is used, then it erases all files in production. Then it uploads all files and folders, + including `data/`, except paths starting with `.`. """ print('Initializing deployment...') if name == '': print('Error: App name cannot be an empty string'); return if not path.is_dir(): print("Error: Path should point to the project directory"); return - try: validate_app(path) + try: _validate_app(path) except PlashError as e: print(f"Error: {str(e)}\nInvalid path: {path}"); return try: @@ -163,26 +164,26 @@ def deploy( plash_app.write_text(f'export PLASH_APP_NAME={name}') tarz, _ = create_tar_archive(path, force_data) - resp = mk_auth_req(endpoint(rt="/upload"), "post", files={'file': tarz}, timeout=300.0, + resp = _mk_auth_req(_endpoint(rt="/upload"), "post", files={'file': tarz}, timeout=300.0, data={'name': name, 'force_data': force_data}) if resp.status_code == 200: print('✅ Upload complete! Your app is currently being built.') - print(f'It will be live at {name if "." in name else endpoint(sub=name)}') + print(f'It will be live at {name if "." in name else _endpoint(sub=name)}') else: print(f'Failure: {resp.status_code}\n{resp.text}') -# %% ../nbs/00_core.ipynb 28 +# %% ../nbs/00_cli.ipynb 27 @call_parse def view( path:Path=Path('.'), # Path to project directory name:str=None, # Overrides the .plash file in project root if provided ): "Open your app in the browser" - if not name: name = get_app_name(path) - url = name if '.' in name else endpoint(sub=name) + if not name: name = _get_app_name(path) + url = name if '.' in name else _endpoint(sub=name) print(f"Opening browser to view app :\n{url}\n") webbrowser.open(url) -# %% ../nbs/00_core.ipynb 30 +# %% ../nbs/00_cli.ipynb 30 @call_parse def delete( path:Path=Path('.'), # Path to project @@ -197,34 +198,29 @@ def delete( return print(f"Deleting app '{name}'...") - r = mk_auth_req(endpoint(rt=f"/delete?name={name}"), "delete") + r = _mk_auth_req(_endpoint(rt=f"/delete?name={name}"), "delete") return r.text -# %% ../nbs/00_core.ipynb 32 -def endpoint_func(endpoint_name): - 'Creates a function for a specific API endpoint' - def func( - path:Path=Path('.'), # Path to project - name:str=None, # Overrides the .plash file in project root if provided - ): - if not name: name = get_app_name(path) - r = mk_auth_req(endpoint(rt=f"{endpoint_name}?name={name}")) - return r.text - - # Set the function name and docstring - func.__name__ = endpoint_name - func.__doc__ = f"Access the '{endpoint_name}' endpoint for your app" - - return call_parse(func) +# %% ../nbs/00_cli.ipynb 33 +@call_parse +def start(path:Path=Path('.'), name:str=None): + "Start your deployed app" + if not name: name = get_app_name(path) + r = _mk_auth_req(_endpoint(rt=f"/start?name={name}")) + return r.text -# Create endpoint-specific functions -stop = endpoint_func('/stop') -start = endpoint_func('/start') +# %% ../nbs/00_cli.ipynb 36 +@call_parse +def stop(path:Path=Path('.'), name:str=None): + "Stop your deployed app" + if not name: name = get_app_name(path) + r = _mk_auth_req(_endpoint(rt=f"/stop?name={name}")) + return r.text -# %% ../nbs/00_core.ipynb 34 +# %% ../nbs/00_cli.ipynb 39 log_modes = str_enum('log_modes', 'build', 'app') -# %% ../nbs/00_core.ipynb 35 +# %% ../nbs/00_cli.ipynb 40 @call_parse def logs( path:Path=Path('.'), # Path to project @@ -232,12 +228,12 @@ def logs( mode:log_modes='build', # Choose between build or app logs tail:bool=False): # Tail the logs 'Prints the logs for your deployed app' - if not name: name = get_app_name(path) + if not name: name = _get_app_name(path) if tail: text = '' while True: try: - r = mk_auth_req(endpoint(rt=f"/logs?name={name}&mode={mode}")) + r = _mk_auth_req(_endpoint(rt=f"/logs?name={name}&mode={mode}")) if r.status_code == 200: print(r.text[len(text):], end='') # Only print updates text = r.text @@ -247,10 +243,10 @@ def logs( print(f"Error: {r.status_code}") except KeyboardInterrupt: return "\nExiting" - r = mk_auth_req(endpoint(rt=f"/logs?name={name}&mode={mode}")) + r = _mk_auth_req(endpoint(rt=f"/logs?name={name}&mode={mode}")) return r.text -# %% ../nbs/00_core.ipynb 37 +# %% ../nbs/00_cli.ipynb 43 @call_parse def download( path:Path=Path('.'), # Path to project @@ -261,16 +257,16 @@ def download( try: save_path.mkdir(exist_ok=False) except: print(f"ERROR: Save path ({save_path}) already exists. Please rename or delete this folder to avoid accidental overwrites.") else: - response = mk_auth_req(endpoint(rt=f'/download?name={name}')).raise_for_status() + response = mk_auth_req(_endpoint(rt=f'/download?name={name}')).raise_for_status() file_bytes = io.BytesIO(response.content) with tarfile.open(fileobj=file_bytes, mode="r:gz") as tar: tar.extractall(path=save_path) print(f"Downloaded your app to: {save_path}") -# %% ../nbs/00_core.ipynb 39 +# %% ../nbs/00_cli.ipynb 46 @call_parse def apps(verbose:bool=False): "List your deployed apps (verbose shows status table: 1=running, 0=stopped)" - r = mk_auth_req(endpoint(rt="/user_apps")).raise_for_status() + r = _mk_auth_req(_endpoint(rt="/user_apps")).raise_for_status() apps = r.json() if not apps: return "You don't have any deployed Plash apps." if verbose: [print(f"{a['running']} {a['name']}") for a in apps] diff --git a/settings.ini b/settings.ini index 540b593..c9aa142 100644 --- a/settings.ini +++ b/settings.ini @@ -29,15 +29,15 @@ status = 3 user = AnswerDotAI requirements = fastcore httpx>=0.28.1 python-dotenv pyjwt cryptography dev_requirements = bash_kernel nbdev -console_scripts = plash_deploy=plash_cli.core:deploy - plash_login=plash_cli.core:login - plash_view=plash_cli.core:view - plash_logs=plash_cli.core:logs - plash_delete=plash_cli.core:delete - plash_stop=plash_cli.core:stop - plash_start=plash_cli.core:start - plash_download=plash_cli.core:download - plash_apps=plash_cli.core:apps +console_scripts = plash_deploy=plash_cli.cli:deploy + plash_login=plash_cli.cli:login + plash_view=plash_cli.cli:view + plash_logs=plash_cli.cli:logs + plash_delete=plash_cli.cli:delete + plash_stop=plash_cli.cli:stop + plash_start=plash_cli.cli:start + plash_download=plash_cli.cli:download + plash_apps=plash_cli.cli:apps readme_nb = index.ipynb allowed_metadata_keys = allowed_cell_metadata_keys =