|
from __future__ import annotations |
|
|
|
import re |
|
import shutil |
|
import tempfile |
|
from pathlib import Path |
|
from typing import List, Optional |
|
|
|
import semantic_version |
|
from huggingface_hub import HfApi |
|
from rich import print |
|
from rich.console import Console |
|
from rich.panel import Panel |
|
from rich.prompt import Confirm, Prompt |
|
from tomlkit import parse |
|
from typer import Argument, Option |
|
from typing_extensions import Annotated |
|
|
|
from gradio.analytics import custom_component_analytics |
|
|
|
colors = ["red", "yellow", "green", "blue", "indigo", "purple", "pink", "gray"] |
|
|
|
PYPI_REGISTER_URL = "https://pypi.org/account/register/" |
|
|
|
|
|
def _ignore(_src, names): |
|
ignored = [] |
|
for n in names: |
|
if "__pycache__" in n or n.startswith("dist") or n.startswith("node_modules"): |
|
ignored.append(n) |
|
return ignored |
|
|
|
|
|
def _get_version_from_file(dist_file: Path) -> Optional[str]: |
|
match = re.search(r"-(\d+\.\d+\.\d+[a-zA-Z]*\d*)-", dist_file.name) |
|
if match: |
|
return match.group(1) |
|
|
|
|
|
def _get_max_version(distribution_files: List[Path]) -> Optional[str]: |
|
versions = [] |
|
for p in distribution_files: |
|
version = _get_version_from_file(p) |
|
|
|
|
|
if version: |
|
try: |
|
versions.append(semantic_version.Version(version)) |
|
except ValueError: |
|
return None |
|
return str(max(versions)) if versions else None |
|
|
|
|
|
def _publish( |
|
dist_dir: Annotated[ |
|
Path, |
|
Argument(help=f"Path to the wheel directory. Default is {Path('.') / 'dist'}"), |
|
] = Path(".") / "dist", |
|
upload_pypi: Annotated[bool, Option(help="Whether to upload to PyPI.")] = True, |
|
pypi_username: Annotated[str, Option(help="The username for PyPI.")] = "", |
|
pypi_password: Annotated[str, Option(help="The password for PyPI.")] = "", |
|
upload_demo: Annotated[ |
|
bool, Option(help="Whether to upload demo to HuggingFace.") |
|
] = True, |
|
demo_dir: Annotated[ |
|
Optional[Path], Option(help="Path to the demo directory.") |
|
] = None, |
|
source_dir: Annotated[ |
|
Path, |
|
Option(help="Path to the source directory of the custom component."), |
|
] = Path("."), |
|
hf_token: Annotated[ |
|
Optional[str], |
|
Option( |
|
help="HuggingFace token for uploading demo. Can be omitted if already logged in via huggingface cli." |
|
), |
|
] = None, |
|
prefer_local: Annotated[ |
|
bool, |
|
Option( |
|
help="Install the package from the local wheel in the demo space, even if it exists on PyPi." |
|
), |
|
] = False, |
|
upload_source: Annotated[ |
|
bool, |
|
Option( |
|
help="Whether to upload the source code of the custom component, to share with the community." |
|
), |
|
] = False, |
|
): |
|
custom_component_analytics( |
|
"publish", |
|
None, |
|
upload_demo=upload_demo, |
|
upload_pypi=upload_pypi, |
|
upload_source=upload_source, |
|
) |
|
console = Console() |
|
dist_dir = dist_dir.resolve() |
|
|
|
if not dist_dir.exists(): |
|
raise ValueError( |
|
f"{dist_dir} does not exist. Run `gradio cc build` to create a wheel and source distribution." |
|
) |
|
if not dist_dir.is_dir(): |
|
raise ValueError(f"{dist_dir} is not a directory") |
|
distribution_files = [ |
|
p.resolve() for p in Path(dist_dir).glob("*") if p.suffix in {".whl", ".gz"} |
|
] |
|
wheel_file = max( |
|
(p for p in distribution_files if p.suffix == ".whl"), |
|
key=lambda s: semantic_version.Version(str(s.name).split("-")[1]), |
|
) |
|
if not wheel_file: |
|
raise ValueError( |
|
"A wheel file was not found in the distribution directory. " |
|
"Run `gradio cc build` to create a wheel file." |
|
) |
|
config_file = None |
|
if upload_pypi and (not pypi_username or not pypi_password): |
|
panel = Panel( |
|
"It is recommended to upload your component to pypi so that [bold][magenta]anyone[/][/] " |
|
"can install it with [bold][magenta]pip install[/][/].\n\n" |
|
f"A PyPi account is needed. If you do not have an account, register account here: [blue]{PYPI_REGISTER_URL}[/]", |
|
) |
|
print(panel) |
|
upload_pypi = Confirm.ask(":snake: Upload to pypi?") |
|
if upload_pypi and (Path.home() / ".pypirc").exists(): |
|
print(":closed_lock_with_key: Found .pypirc file in home directory.") |
|
config_file = str(Path.home() / ".pypirc") |
|
elif upload_pypi: |
|
print( |
|
":light_bulb: If you have Two Factor Authentication enabled, the username is __token__ and your password is your API key." |
|
) |
|
pypi_username = Prompt.ask(":laptop_computer: Enter your pypi username") |
|
pypi_password = Prompt.ask( |
|
":closed_lock_with_key: Enter your pypi password", password=True |
|
) |
|
if upload_pypi: |
|
try: |
|
from twine.commands.upload import upload as twine_upload |
|
from twine.exceptions import InvalidDistribution |
|
from twine.settings import Settings |
|
except (ImportError, ModuleNotFoundError) as e: |
|
raise ValueError( |
|
"The twine library must be installed to publish to pypi." |
|
"Install it with pip, pip install twine." |
|
) from e |
|
if pypi_username and pypi_password: |
|
twine_settings = Settings(username=pypi_username, password=pypi_password) |
|
elif config_file: |
|
twine_settings = Settings(config_file=config_file) |
|
else: |
|
raise ValueError( |
|
"No pypi username or password provided and no ~/.pypirc file found." |
|
) |
|
try: |
|
|
|
max_version = _get_max_version(distribution_files) |
|
twine_files = [ |
|
str(p) |
|
for p in distribution_files |
|
if (not max_version or max_version in p.name) |
|
] |
|
print(f"Uploading files: {','.join(twine_files)}") |
|
try: |
|
twine_upload(twine_settings, twine_files) |
|
except InvalidDistribution as e: |
|
raise ValueError( |
|
"Invalid distribution when uploading to pypi. " |
|
"Try upgrading 'pkginfo' with python -m pip install pkginfo --upgrade" |
|
) from e |
|
except Exception: |
|
console.print_exception() |
|
if upload_demo and not demo_dir: |
|
panel = Panel( |
|
"It is recommended you upload a demo of your component to [blue]https://huggingface.co/spaces[/] " |
|
"so that anyone can try it from their browser." |
|
) |
|
print(panel) |
|
upload_demo = Confirm.ask(":hugging_face: Upload demo?") |
|
if upload_demo: |
|
panel = Panel( |
|
"Please provide the path to the [magenta]demo directory[/] for your custom component.\n\n" |
|
"This directory should contain [magenta]all the files[/] it needs to run successfully.\n\n" |
|
"Please make sure the gradio app is in an [magenta]space.py[/] file.\n\n" |
|
"If you need additional python requirements, add a [magenta]requirements.txt[/] file to this directory." |
|
) |
|
print(panel) |
|
demo_dir_ = Prompt.ask( |
|
f":roller_coaster: Please enter the path to the demo directory. Leave blank to use: {(Path('.') / 'demo')}" |
|
) |
|
demo_dir_ = demo_dir_ or str(Path(".") / "demo") |
|
demo_dir = Path(demo_dir_).resolve() |
|
|
|
if upload_demo and not upload_source: |
|
panel = Panel( |
|
"It is recommended that you share your [magenta]source code[/] so that others can learn from and improve your component." |
|
) |
|
print(panel) |
|
upload_source = Confirm.ask(":books: Would you like to share your source code?") |
|
if upload_source: |
|
source_dir_ = ( |
|
Prompt.ask( |
|
f":file_folder: Please enter the path to the source directory. Leave blank to use: {source_dir}" |
|
) |
|
or source_dir |
|
) |
|
source_dir = Path(source_dir_).resolve() |
|
if upload_demo: |
|
pyproject_toml_path = source_dir / "pyproject.toml" |
|
|
|
try: |
|
pyproject_toml = parse(pyproject_toml_path.read_text()) |
|
package_name = pyproject_toml["project"]["name"] |
|
except Exception: |
|
(package_name, _) = wheel_file.name.split("-")[:2] |
|
|
|
if not demo_dir: |
|
raise ValueError("demo_dir must be set") |
|
|
|
package_name, _ = wheel_file.name.split("-")[:2] |
|
with tempfile.TemporaryDirectory() as tempdir: |
|
shutil.copytree( |
|
str(demo_dir), |
|
str(tempdir), |
|
dirs_exist_ok=True, |
|
) |
|
shutil.copyfile( |
|
str(source_dir / ".gitignore"), |
|
str(Path(tempdir) / ".gitignore"), |
|
) |
|
if upload_source: |
|
shutil.copytree( |
|
str(source_dir), |
|
str(Path(tempdir) / "src"), |
|
dirs_exist_ok=True, |
|
ignore=_ignore, |
|
) |
|
|
|
shutil.copyfile( |
|
str(source_dir / "README.md"), str(Path(tempdir) / "README.md") |
|
) |
|
|
|
api = HfApi(token=hf_token) |
|
repo_url = api.create_repo( |
|
repo_id=package_name, |
|
repo_type="space", |
|
exist_ok=True, |
|
space_sdk="gradio", |
|
) |
|
repo_id = repo_url.repo_id |
|
api.upload_folder( |
|
repo_id=repo_id, |
|
folder_path=tempdir, |
|
repo_type="space", |
|
) |
|
if prefer_local: |
|
api.upload_file( |
|
repo_id=repo_id, |
|
path_or_fileobj=str(wheel_file), |
|
path_in_repo=wheel_file.name, |
|
repo_type="space", |
|
) |
|
print("\n") |
|
|
|
api.restart_space(repo_id=repo_id, factory_reboot=True, token=hf_token) |
|
print(f"Demo uploaded to https://huggingface.co/spaces/{repo_id} !") |
|
|
|
|
|
def resolve_demo(demo_dir: Path) -> Path: |
|
_demo_dir = demo_dir.resolve() |
|
if (_demo_dir / "space.py").exists(): |
|
return _demo_dir / "space.py" |
|
elif (_demo_dir / "app.py").exists(): |
|
return _demo_dir / "app.py" |
|
else: |
|
raise FileNotFoundError( |
|
f'Could not find "space.py" or "app.py" in "{demo_dir}".' |
|
) |
|
|