custom_components.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565
  1. """CLI for creating custom components."""
  2. from __future__ import annotations
  3. import os
  4. import re
  5. import subprocess
  6. import sys
  7. from collections import namedtuple
  8. from contextlib import contextmanager
  9. from pathlib import Path
  10. from typing import Optional
  11. import typer
  12. from reflex import constants
  13. from reflex.config import get_config
  14. from reflex.constants import CustomComponents
  15. from reflex.utils import console
  16. config = get_config()
  17. custom_components_cli = typer.Typer()
  18. @contextmanager
  19. def set_directory(working_directory: str):
  20. """Context manager that sets the working directory.
  21. Args:
  22. working_directory: The working directory to change to.
  23. Yields:
  24. Yield to the caller to perform operations in the working directory.
  25. """
  26. current_directory = os.getcwd()
  27. try:
  28. os.chdir(working_directory)
  29. yield
  30. finally:
  31. os.chdir(current_directory)
  32. def _create_package_config(module_name: str, package_name: str):
  33. """Create a package config pyproject.toml file.
  34. Args:
  35. module_name: The name of the module.
  36. package_name: The name of the package typically constructed with `reflex-` prefix and a meaningful library name.
  37. """
  38. from reflex.compiler import templates
  39. with open(CustomComponents.PYPROJECT_TOML, "w") as f:
  40. f.write(
  41. templates.CUSTOM_COMPONENTS_PYPROJECT_TOML.render(
  42. module_name=module_name, package_name=package_name
  43. )
  44. )
  45. def _create_readme(module_name: str, package_name: str):
  46. """Create a package README file.
  47. Args:
  48. module_name: The name of the module.
  49. package_name: The name of the python package to be published.
  50. """
  51. from reflex.compiler import templates
  52. with open(CustomComponents.PACKAGE_README, "w") as f:
  53. f.write(
  54. templates.CUSTOM_COMPONENTS_README.render(
  55. module_name=module_name,
  56. package_name=package_name,
  57. )
  58. )
  59. def _write_source_and_init_py(
  60. custom_component_src_dir: str,
  61. component_class_name: str,
  62. module_name: str,
  63. ):
  64. """Write the source code and init file from templates for the custom component.
  65. Args:
  66. custom_component_src_dir: The name of the custom component source directory.
  67. component_class_name: The name of the component class.
  68. module_name: The name of the module.
  69. """
  70. from reflex.compiler import templates
  71. with open(
  72. os.path.join(
  73. custom_component_src_dir,
  74. f"{module_name}.py",
  75. ),
  76. "w",
  77. ) as f:
  78. f.write(
  79. templates.CUSTOM_COMPONENTS_SOURCE.render(
  80. component_class_name=component_class_name, module_name=module_name
  81. )
  82. )
  83. with open(
  84. os.path.join(
  85. custom_component_src_dir,
  86. CustomComponents.INIT_FILE,
  87. ),
  88. "w",
  89. ) as f:
  90. f.write(templates.CUSTOM_COMPONENTS_INIT_FILE.render(module_name=module_name))
  91. def _populate_demo_app(name_variants: NameVariants):
  92. """Populate the demo app that imports the custom components.
  93. Args:
  94. name_variants: the tuple including various names such as package name, class name needed for the project.
  95. """
  96. from reflex import constants
  97. from reflex.compiler import templates
  98. from reflex.reflex import _init
  99. demo_app_dir = name_variants.demo_app_dir
  100. demo_app_name = name_variants.demo_app_name
  101. console.info(f"Creating app for testing: {demo_app_dir}")
  102. os.makedirs(demo_app_dir)
  103. with set_directory(demo_app_dir):
  104. # We start with the blank template as basis.
  105. _init(name=demo_app_name, template=constants.Templates.Kind.BLANK)
  106. # Then overwrite the app source file with the one we want for testing custom components.
  107. # This source file is rendered using jinja template file.
  108. with open(f"{demo_app_name}/{demo_app_name}.py", "w") as f:
  109. f.write(
  110. templates.CUSTOM_COMPONENTS_DEMO_APP.render(
  111. custom_component_module_dir=name_variants.custom_component_module_dir,
  112. module_name=name_variants.module_name,
  113. )
  114. )
  115. def _get_default_library_name_parts() -> list[str]:
  116. """Get the default library name. Based on the current directory name, remove any non-alphanumeric characters.
  117. Raises:
  118. ValueError: If the current directory name is not suitable for python projects, and we cannot find a valid library name based off it.
  119. Returns:
  120. The parts of default library name.
  121. """
  122. current_dir_name = os.getcwd().split(os.path.sep)[-1]
  123. cleaned_dir_name = re.sub("[^0-9a-zA-Z-_]+", "", current_dir_name)
  124. parts = re.split("-|_", cleaned_dir_name)
  125. if not parts:
  126. # The folder likely has a name not suitable for python paths.
  127. raise ValueError(
  128. f"Could not find a valid library name based on the current directory: got {current_dir_name}."
  129. )
  130. return parts
  131. NameVariants = namedtuple(
  132. "NameVariants",
  133. [
  134. "library_name",
  135. "component_class_name",
  136. "package_name",
  137. "module_name",
  138. "custom_component_module_dir",
  139. "demo_app_dir",
  140. "demo_app_name",
  141. ],
  142. )
  143. def _validate_library_name(library_name: str | None) -> NameVariants:
  144. """Validate the library name.
  145. Args:
  146. library_name: The name of the library if picked otherwise None.
  147. Raises:
  148. Exit: If the library name is not suitable for python projects.
  149. Returns:
  150. A tuple containing the various names such as package name, class name, etc., needed for the project.
  151. """
  152. if library_name is not None and not re.match(
  153. r"^[a-zA-Z-]+[a-zA-Z0-9-]*$", library_name
  154. ):
  155. console.error(
  156. f"Please use only alphanumeric characters or dashes: got {library_name}"
  157. )
  158. raise typer.Exit(code=1)
  159. # If not specified, use the current directory name to form the module name.
  160. name_parts = (
  161. [part.lower() for part in library_name.split("-")]
  162. if library_name
  163. else _get_default_library_name_parts()
  164. )
  165. if not library_name:
  166. library_name = "-".join(name_parts)
  167. # Component class name is the camel case.
  168. component_class_name = "".join([part.capitalize() for part in name_parts])
  169. console.info(f"Component class name: {component_class_name}")
  170. # Package name is commonly kebab case.
  171. package_name = f"reflex-{library_name}"
  172. console.info(f"Package name: {package_name}")
  173. # Module name is the snake case.
  174. module_name = "_".join(name_parts)
  175. custom_component_module_dir = f"reflex_{module_name}"
  176. console.info(f"Custom component source directory: {custom_component_module_dir}")
  177. # Use the same name for the directory and the app.
  178. demo_app_dir = demo_app_name = f"{module_name}_demo"
  179. console.info(f"Demo app directory: {demo_app_dir}")
  180. return NameVariants(
  181. library_name=library_name,
  182. component_class_name=component_class_name,
  183. package_name=package_name,
  184. module_name=module_name,
  185. custom_component_module_dir=custom_component_module_dir,
  186. demo_app_dir=demo_app_dir,
  187. demo_app_name=demo_app_name,
  188. )
  189. def _populate_custom_component_project(name_variants: NameVariants):
  190. """Populate the custom component source directory. This includes the pyproject.toml, README.md, and the code template for the custom component.
  191. Args:
  192. name_variants: the tuple including various names such as package name, class name needed for the project.
  193. """
  194. console.info(
  195. f"Populating pyproject.toml with package name: {name_variants.package_name}"
  196. )
  197. # write pyproject.toml, README.md, etc.
  198. _create_package_config(
  199. module_name=name_variants.library_name, package_name=name_variants.package_name
  200. )
  201. _create_readme(
  202. module_name=name_variants.library_name, package_name=name_variants.package_name
  203. )
  204. console.info(
  205. f"Initializing the component directory: {CustomComponents.SRC_DIR}/{name_variants.custom_component_module_dir}"
  206. )
  207. os.makedirs(CustomComponents.SRC_DIR)
  208. with set_directory(CustomComponents.SRC_DIR):
  209. os.makedirs(name_variants.custom_component_module_dir)
  210. _write_source_and_init_py(
  211. custom_component_src_dir=name_variants.custom_component_module_dir,
  212. component_class_name=name_variants.component_class_name,
  213. module_name=name_variants.module_name,
  214. )
  215. @custom_components_cli.command(name="init")
  216. def init(
  217. library_name: Optional[str] = typer.Option(
  218. None,
  219. help="The name of your library. On PyPI, package will be published as `reflex-{library-name}`.",
  220. ),
  221. install: bool = typer.Option(
  222. True,
  223. help="Whether to install package from this local custom component in editable mode.",
  224. ),
  225. loglevel: constants.LogLevel = typer.Option(
  226. config.loglevel, help="The log level to use."
  227. ),
  228. ):
  229. """Initialize a custom component.
  230. Args:
  231. library_name: The name of the library.
  232. install: Whether to install package from this local custom component in editable mode.
  233. loglevel: The log level to use.
  234. Raises:
  235. Exit: If the pyproject.toml already exists.
  236. """
  237. from reflex.utils import exec, prerequisites
  238. console.set_log_level(loglevel)
  239. if os.path.exists(CustomComponents.PYPROJECT_TOML):
  240. console.error(f"A {CustomComponents.PYPROJECT_TOML} already exists. Aborting.")
  241. typer.Exit(code=1)
  242. # Show system info.
  243. exec.output_system_info()
  244. # Check the name follows the convention if picked.
  245. name_variants = _validate_library_name(library_name)
  246. _populate_custom_component_project(name_variants)
  247. _populate_demo_app(name_variants)
  248. # Initialize the .gitignore.
  249. prerequisites.initialize_gitignore()
  250. if install:
  251. package_name = name_variants.package_name
  252. console.info(f"Installing {package_name} in editable mode.")
  253. if _pip_install_on_demand(package_name=".", install_args=["-e"]):
  254. console.info(f"Package {package_name} installed!")
  255. else:
  256. raise typer.Exit(code=1)
  257. console.print("Custom component initialized successfully!")
  258. console.print("Here's the summary:")
  259. console.print(
  260. f"{CustomComponents.PYPROJECT_TOML} and {CustomComponents.PACKAGE_README} created. [bold]Please fill in details such as your name, email, homepage URL.[/bold]"
  261. )
  262. console.print(
  263. f"Source code template is in {CustomComponents.SRC_DIR}. [bold]Start by editing it with your component implementation.[/bold]"
  264. )
  265. console.print(
  266. f"Demo app created in {name_variants.demo_app_dir}. [bold]Use this app to test your custom component.[/bold]"
  267. )
  268. def _pip_install_on_demand(
  269. package_name: str,
  270. install_args: list[str] | None = None,
  271. ) -> bool:
  272. """Install a package on demand.
  273. Args:
  274. package_name: The name of the package.
  275. install_args: The additional arguments for the pip install command.
  276. Returns:
  277. True if the package is installed successfully, False otherwise.
  278. """
  279. install_args = install_args or []
  280. install_cmds = [
  281. sys.executable,
  282. "-m",
  283. "pip",
  284. "install",
  285. *install_args,
  286. package_name,
  287. ]
  288. console.debug(f"Install package: {' '.join(install_cmds)}")
  289. return _run_commands_in_subprocess(install_cmds)
  290. def _run_commands_in_subprocess(cmds: list[str]) -> bool:
  291. """Run commands in a subprocess.
  292. Args:
  293. cmds: The commands to run.
  294. Returns:
  295. True if the command runs successfully, False otherwise.
  296. """
  297. console.debug(f"Running command: {' '.join(cmds)}")
  298. try:
  299. result = subprocess.run(cmds, capture_output=True, text=True, check=True)
  300. console.debug(result.stdout)
  301. return True
  302. except subprocess.CalledProcessError as cpe:
  303. console.error(cpe.stdout)
  304. console.error(cpe.stderr)
  305. return False
  306. @custom_components_cli.command(name="build")
  307. def build(
  308. loglevel: constants.LogLevel = typer.Option(
  309. config.loglevel, help="The log level to use."
  310. ),
  311. ):
  312. """Build a custom component. Must be run from the project root directory where the pyproject.toml is.
  313. Args:
  314. loglevel: The log level to use.
  315. Raises:
  316. Exit: If the build fails.
  317. """
  318. console.set_log_level(loglevel)
  319. console.print("Building custom component...")
  320. cmds = [sys.executable, "-m", "build", "."]
  321. if _run_commands_in_subprocess(cmds):
  322. console.info("Custom component built successfully!")
  323. else:
  324. raise typer.Exit(code=1)
  325. def _validate_repository_name(repository: str | None) -> str:
  326. """Validate the repository name.
  327. Args:
  328. repository: The name of the repository.
  329. Returns:
  330. The name of the repository.
  331. Raises:
  332. Exit: If the repository name is not supported.
  333. """
  334. if repository is None:
  335. return "pypi"
  336. elif repository not in CustomComponents.REPO_URLS:
  337. console.error(
  338. f"Unsupported repository name. Allow {CustomComponents.REPO_URLS.keys()}, got {repository}"
  339. )
  340. raise typer.Exit(code=1)
  341. return repository
  342. def _validate_credentials(
  343. username: str | None, password: str | None, token: str | None
  344. ) -> tuple[str, str]:
  345. """Validate the credentials.
  346. Args:
  347. username: The username to use for authentication on python package repository.
  348. password: The password to use for authentication on python package repository.
  349. token: The token to use for authentication on python package repository.
  350. Raises:
  351. Exit: If the appropriate combination of credentials is not provided.
  352. Returns:
  353. The username and password.
  354. """
  355. if token is not None:
  356. if username is not None or password is not None:
  357. console.error("Cannot use token and username/password at the same time.")
  358. raise typer.Exit(code=1)
  359. username = "__token__"
  360. password = token
  361. elif username is None or password is None:
  362. console.error(
  363. "Must provide both username and password for authentication if not using a token."
  364. )
  365. raise typer.Exit(code=1)
  366. return username, password
  367. def _ensure_dist_dir():
  368. """Ensure the distribution directory and the expected files exist.
  369. Raises:
  370. Exit: If the distribution directory does not exist or the expected files are not found.
  371. """
  372. dist_dir = Path(CustomComponents.DIST_DIR)
  373. # Check if the distribution directory exists.
  374. if not dist_dir.exists():
  375. console.error(f"Directory {dist_dir.name} does not exist. Please build first.")
  376. raise typer.Exit(code=1)
  377. # Check if the distribution directory is indeed a directory.
  378. if not dist_dir.is_dir():
  379. console.error(
  380. f"{dist_dir.name} is not a directory. If this is a file you added, move it and rebuild."
  381. )
  382. raise typer.Exit(code=1)
  383. # Check if the distribution files exist.
  384. for suffix in CustomComponents.DISTRIBUTION_FILE_SUFFIXES:
  385. if not list(dist_dir.glob(f"*{suffix}")):
  386. console.error(
  387. f"Expected distribution file with suffix {suffix} in directory {dist_dir.name}"
  388. )
  389. raise typer.Exit(code=1)
  390. @custom_components_cli.command(name="publish")
  391. def publish(
  392. repository: Optional[str] = typer.Option(
  393. None,
  394. "-r",
  395. "--repository",
  396. help="The name of the repository. Defaults to pypi. Only supports pypi and testpypi (Test PyPI) for now.",
  397. ),
  398. token: Optional[str] = typer.Option(
  399. None,
  400. "-t",
  401. "--token",
  402. help="The API token to use for authentication on python package repository. If token is provided, no username/password should be provided at the same time",
  403. ),
  404. username: Optional[str] = typer.Option(
  405. None,
  406. "-u",
  407. "--username",
  408. help="The username to use for authentication on python package repository. Username and password must both be provided.",
  409. ),
  410. password: Optional[str] = typer.Option(
  411. None,
  412. "-p",
  413. "--password",
  414. help="The password to use for authentication on python package repository. Username and password must both be provided.",
  415. ),
  416. loglevel: constants.LogLevel = typer.Option(
  417. config.loglevel, help="The log level to use."
  418. ),
  419. ):
  420. """Publish a custom component. Must be run from the project root directory where the pyproject.toml is.
  421. Args:
  422. repository: The name of the Python package repository, such pypi, testpypi.
  423. token: The token to use for authentication on python package repository. If token is provided, no username/password should be provided at the same time.
  424. username: The username to use for authentication on python package repository.
  425. password: The password to use for authentication on python package repository.
  426. loglevel: The log level to use.
  427. Raises:
  428. Exit: If arguments provided are not correct or the publish fails.
  429. """
  430. console.set_log_level(loglevel)
  431. # Validate the repository name.
  432. repository = _validate_repository_name(repository)
  433. console.print(f"Publishing custom component to {repository}...")
  434. # Validate the credentials.
  435. username, password = _validate_credentials(username, password, token)
  436. # Validate the distribution directory.
  437. _ensure_dist_dir()
  438. # We install twine on the fly if required so it is not a stable dependency of reflex.
  439. try:
  440. import twine # noqa: F401 # type: ignore
  441. except (ImportError, ModuleNotFoundError) as ex:
  442. if not _pip_install_on_demand("twine"):
  443. raise typer.Exit(code=1) from ex
  444. publish_cmds = [
  445. sys.executable,
  446. "-m",
  447. "twine",
  448. "upload",
  449. "--repository-url",
  450. CustomComponents.REPO_URLS[repository],
  451. "--username",
  452. username,
  453. "--password",
  454. password,
  455. "--non-interactive",
  456. f"{CustomComponents.DIST_DIR}/*",
  457. ]
  458. if _run_commands_in_subprocess(publish_cmds):
  459. console.info("Custom component published successfully!")
  460. else:
  461. raise typer.Exit(1)