| # Licensed to the Apache Software Foundation (ASF) under one |
| # or more contributor license agreements. See the NOTICE file |
| # distributed with this work for additional information |
| # regarding copyright ownership. The ASF licenses this file |
| # to you under the Apache License, Version 2.0 (the |
| # "License"); you may not use this file except in compliance |
| # with the License. You may obtain a copy of the License at |
| # |
| # http://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, |
| # software distributed under the License is distributed on an |
| # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| # KIND, either express or implied. See the License for the |
| # specific language governing permissions and limitations |
| # under the License. |
| |
| import os |
| import shutil |
| import subprocess |
| import time |
| import webbrowser |
| from contextlib import contextmanager |
| |
| import click |
| import requests |
| from loguru import logger |
| |
| |
| def _command(command: str, capture_output: bool) -> str: |
| """Runs a simple command""" |
| logger.info(f"Running command: {command}") |
| if isinstance(command, str): |
| command = command.split(" ") |
| if capture_output: |
| try: |
| return ( |
| subprocess.check_output(command, stderr=subprocess.PIPE, shell=False) |
| .decode() |
| .strip() |
| ) |
| except subprocess.CalledProcessError as e: |
| print(e.stdout.decode()) |
| print(e.stderr.decode()) |
| raise e |
| subprocess.run(command, shell=False, check=True) |
| |
| |
| def _get_git_root() -> str: |
| return _command("git rev-parse --show-toplevel", capture_output=True) |
| |
| |
| def open_when_ready(check_url: str, open_url: str): |
| while True: |
| try: |
| response = requests.get(check_url) |
| if response.status_code == 200: |
| webbrowser.open(open_url) |
| return |
| else: |
| pass |
| except requests.exceptions.RequestException: |
| pass |
| time.sleep(1) |
| |
| |
| @contextmanager |
| def cd(path): |
| old_dir = os.getcwd() |
| os.chdir(path) |
| try: |
| yield |
| finally: |
| os.chdir(old_dir) |
| |
| |
| @click.group() |
| def cli(): |
| pass |
| |
| |
| def _build_ui(skip_install: bool = False): |
| """ |
| Build the UI from source following Burr's pattern. |
| |
| Steps (matching burr/cli/__main__.py:135-156): |
| 1. npm install (unless --skip-install) |
| 2. npm run build |
| 3. rm -rf backend/server/build |
| 4. mkdir -p backend/server/build |
| 5. cp -a frontend/build/. backend/server/build/ |
| 6. Verify critical files exist |
| """ |
| # Step 1: Install dependencies (like Burr does) |
| if not skip_install: |
| logger.info("Installing npm dependencies...") |
| cmd = "npm install --prefix ui/frontend" |
| _command(cmd, capture_output=False) |
| |
| # Step 2: Build frontend |
| logger.info("Building frontend...") |
| cmd = "npm run build --prefix ui/frontend" |
| _command(cmd, capture_output=False) |
| |
| # Step 3: Clear old build |
| logger.info("Clearing old build directory...") |
| cmd = "rm -rf ui/backend/server/build" |
| _command(cmd, capture_output=False) |
| |
| # Step 4: Ensure directory exists |
| cmd = "mkdir -p ui/backend/server/build" |
| _command(cmd, capture_output=False) |
| |
| # Step 5: Copy with archive mode (like Burr: cp -a) |
| logger.info("Copying built assets to backend...") |
| cmd = "cp -a ui/frontend/build/. ui/backend/server/build/" |
| _command(cmd, capture_output=False) |
| |
| # Step 6: Verify build succeeded |
| git_root = _get_git_root() |
| build_dir = os.path.join(git_root, "ui/backend/server/build") |
| if not os.path.exists(os.path.join(build_dir, "index.html")): |
| raise RuntimeError("Build failed: index.html not found in build directory") |
| # Vite outputs to assets/, CRA outputs to static/ |
| if not ( |
| os.path.exists(os.path.join(build_dir, "assets")) |
| or os.path.exists(os.path.join(build_dir, "static")) |
| ): |
| raise RuntimeError( |
| "Build failed: assets/ or static/ directory not found in build directory" |
| ) |
| |
| logger.info(f"✓ Build verified: {build_dir}") |
| |
| |
| @cli.command() |
| @click.option("--skip-install", is_flag=True, help="Skip npm install for faster builds") |
| def build_ui(skip_install: bool): |
| logger.info("Building UI -- this may take a bit...") |
| git_root = _get_git_root() |
| with cd(git_root): |
| _build_ui(skip_install=skip_install) |
| logger.info("Built UI!") |
| |
| |
| @cli.command(help="Publishes the package to a repository") |
| @click.option("--prod", is_flag=True, help="Publish to pypi (rather than test pypi)") |
| @click.option("--no-wipe-dist", is_flag=True, help="Wipe the dist/ directory before building") |
| def build_and_publish(prod: bool, no_wipe_dist: bool): |
| git_root = _get_git_root() |
| install_path = os.path.join(git_root, "ui/backend") |
| logger.info("Building UI -- this may take a bit...") |
| build_ui.callback() # use the underlying function, not click's object |
| with cd(install_path): |
| logger.info("Built UI!") |
| if not no_wipe_dist: |
| logger.info("Wiping dist/ directory for a clean publish.") |
| shutil.rmtree("dist", ignore_errors=True) |
| _command("uv run python -m build", capture_output=False) |
| repository = "pypi" if prod else "testpypi" |
| _command( |
| f"uv run python -m twine upload --repository {repository} dist/*", capture_output=False |
| ) |
| logger.info(f"Published to {repository}! 🎉") |
| |
| |
| if __name__ == "__main__": |
| cli() |