utils.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882
  1. """General utility functions."""
  2. from __future__ import annotations
  3. import inspect
  4. import json
  5. import os
  6. import random
  7. import re
  8. import shutil
  9. import signal
  10. import string
  11. import subprocess
  12. import sys
  13. from collections import defaultdict
  14. from subprocess import PIPE
  15. from typing import _GenericAlias # type: ignore
  16. from typing import (
  17. TYPE_CHECKING,
  18. Any,
  19. Callable,
  20. Dict,
  21. List,
  22. Optional,
  23. Tuple,
  24. Type,
  25. Union,
  26. )
  27. import plotly.graph_objects as go
  28. from plotly.io import to_json
  29. from rich.console import Console
  30. from pynecone import constants
  31. if TYPE_CHECKING:
  32. from pynecone.components.component import ImportDict
  33. from pynecone.config import Config
  34. from pynecone.event import Event, EventHandler, EventSpec
  35. from pynecone.var import Var
  36. # Shorthand for join.
  37. join = os.linesep.join
  38. # Console for pretty printing.
  39. console = Console()
  40. def get_args(alias: _GenericAlias) -> Tuple[Type, ...]:
  41. """Get the arguments of a type alias.
  42. Args:
  43. alias: The type alias.
  44. Returns:
  45. The arguments of the type alias.
  46. """
  47. return alias.__args__
  48. def get_base_class(cls: Type) -> Type:
  49. """Get the base class of a class.
  50. Args:
  51. cls: The class.
  52. Returns:
  53. The base class of the class.
  54. """
  55. # For newer versions of Python.
  56. try:
  57. from types import GenericAlias # type: ignore
  58. if isinstance(cls, GenericAlias):
  59. return get_base_class(cls.__origin__)
  60. except:
  61. pass
  62. # Check Union types first.
  63. try:
  64. from typing import _UnionGenericAlias # type: ignore
  65. if isinstance(cls, _UnionGenericAlias):
  66. return tuple(get_base_class(arg) for arg in get_args(cls))
  67. except:
  68. pass
  69. # Check other generic aliases.
  70. if isinstance(cls, _GenericAlias):
  71. if cls.__origin__ == Union:
  72. return tuple(get_base_class(arg) for arg in get_args(cls))
  73. return get_base_class(cls.__origin__)
  74. # This is the base class.
  75. return cls
  76. def _issubclass(
  77. cls: Union[Type, _GenericAlias], cls_check: Union[Type, _GenericAlias]
  78. ) -> bool:
  79. """Check if a class is a subclass of another class.
  80. Args:
  81. cls: The class to check.
  82. cls_check: The class to check against.
  83. Returns:
  84. Whether the class is a subclass of the other class.
  85. """
  86. # Special check for Any.
  87. if cls_check == Any:
  88. return True
  89. if cls == Any or cls == Callable:
  90. return False
  91. cls_base = get_base_class(cls)
  92. cls_check_base = get_base_class(cls_check)
  93. return cls_check_base == Any or issubclass(cls_base, cls_check_base)
  94. def _isinstance(obj: Any, cls: Union[Type, _GenericAlias]) -> bool:
  95. """Check if an object is an instance of a class.
  96. Args:
  97. obj: The object to check.
  98. cls: The class to check against.
  99. Returns:
  100. Whether the object is an instance of the class.
  101. """
  102. return isinstance(obj, get_base_class(cls))
  103. def rm(path: str):
  104. """Remove a file or directory.
  105. Args:
  106. path: The path to the file or directory.
  107. """
  108. if os.path.isdir(path):
  109. shutil.rmtree(path)
  110. elif os.path.isfile(path):
  111. os.remove(path)
  112. def cp(src: str, dest: str, overwrite: bool = True) -> bool:
  113. """Copy a file or directory.
  114. Args:
  115. src: The path to the file or directory.
  116. dest: The path to the destination.
  117. overwrite: Whether to overwrite the destination.
  118. Returns:
  119. Whether the copy was successful.
  120. """
  121. if src == dest:
  122. return False
  123. if not overwrite and os.path.exists(dest):
  124. return False
  125. if os.path.isdir(src):
  126. rm(dest)
  127. shutil.copytree(src, dest)
  128. else:
  129. shutil.copyfile(src, dest)
  130. return True
  131. def mv(src: str, dest: str, overwrite: bool = True) -> bool:
  132. """Move a file or directory.
  133. Args:
  134. src: The path to the file or directory.
  135. dest: The path to the destination.
  136. overwrite: Whether to overwrite the destination.
  137. Returns:
  138. Whether the move was successful.
  139. """
  140. if src == dest:
  141. return False
  142. if not overwrite and os.path.exists(dest):
  143. return False
  144. rm(dest)
  145. shutil.move(src, dest)
  146. return True
  147. def mkdir(path: str):
  148. """Create a directory.
  149. Args:
  150. path: The path to the directory.
  151. """
  152. if not os.path.exists(path):
  153. os.makedirs(path)
  154. def ln(src: str, dest: str, overwrite: bool = False) -> bool:
  155. """Create a symbolic link.
  156. Args:
  157. src: The path to the file or directory.
  158. dest: The path to the destination.
  159. overwrite: Whether to overwrite the destination.
  160. Returns:
  161. Whether the link was successful.
  162. """
  163. if src == dest:
  164. return False
  165. if not overwrite and (os.path.exists(dest) or os.path.islink(dest)):
  166. return False
  167. if os.path.isdir(src):
  168. rm(dest)
  169. os.symlink(src, dest, target_is_directory=True)
  170. else:
  171. os.symlink(src, dest)
  172. return True
  173. def kill(pid):
  174. """Kill a process.
  175. Args:
  176. pid: The process ID.
  177. """
  178. os.kill(pid, signal.SIGTERM)
  179. def which(program: str) -> Optional[str]:
  180. """Find the path to an executable.
  181. Args:
  182. program: The name of the executable.
  183. Returns:
  184. The path to the executable.
  185. """
  186. return shutil.which(program)
  187. def get_config() -> Config:
  188. """Get the app config.
  189. Returns:
  190. The app config.
  191. """
  192. from pynecone.config import Config
  193. sys.path.append(os.getcwd())
  194. try:
  195. return __import__(constants.CONFIG_MODULE).config
  196. except:
  197. return Config(app_name="")
  198. def get_bun_path():
  199. """Get the path to the bun executable.
  200. Returns:
  201. The path to the bun executable.
  202. """
  203. return os.path.expandvars(get_config().bun_path)
  204. def get_app() -> Any:
  205. """Get the app based on the default config.
  206. Returns:
  207. The app based on the default config.
  208. """
  209. config = get_config()
  210. module = ".".join([config.app_name, config.app_name])
  211. app = __import__(module, fromlist=(constants.APP_VAR,))
  212. return app
  213. def install_dependencies():
  214. """Install the dependencies."""
  215. subprocess.call([get_bun_path(), "install"], cwd=constants.WEB_DIR, stdout=PIPE)
  216. def export_app(app):
  217. """Zip up the app for deployment.
  218. Args:
  219. app: The app.
  220. """
  221. app.app.compile(ignore_env=False)
  222. cmd = r"rm -rf .web/_static; cd .web && bun run export && cd _static && zip -r ../../frontend.zip ./* && cd ../.. && zip -r backend.zip ./* -x .web/\* ./assets\* ./frontend.zip\* ./backend.zip\*"
  223. os.system(cmd)
  224. def setup_frontend(app):
  225. """Set up the frontend.
  226. Args:
  227. app: The app.
  228. """
  229. # Initialize the web directory if it doesn't exist.
  230. cp(constants.WEB_TEMPLATE_DIR, constants.WEB_DIR, overwrite=False)
  231. # Install the frontend dependencies.
  232. console.rule("[bold]Installing Dependencies")
  233. install_dependencies()
  234. # Link the assets folder.
  235. ln(src=os.path.join("..", constants.APP_ASSETS_DIR), dest=constants.WEB_ASSETS_DIR)
  236. # Compile the frontend.
  237. app.app.compile(ignore_env=False)
  238. def run_frontend(app) -> subprocess.Popen:
  239. """Run the frontend.
  240. Args:
  241. app: The app.
  242. Returns:
  243. The frontend process.
  244. """
  245. setup_frontend(app)
  246. command = [get_bun_path(), "run", "dev"]
  247. console.rule("[bold green]App Running")
  248. return subprocess.Popen(
  249. command, cwd=constants.WEB_DIR
  250. ) # stdout=PIPE to hide output
  251. def run_frontend_prod(app) -> subprocess.Popen:
  252. """Run the frontend.
  253. Args:
  254. app: The app.
  255. Returns:
  256. The frontend process.
  257. """
  258. setup_frontend(app)
  259. # Export and zip up the frontend and backend then start the frontend in production mode.
  260. cmd = r"rm -rf .web/_static || true; cd .web && bun run export"
  261. os.system(cmd)
  262. command = [get_bun_path(), "run", "prod"]
  263. return subprocess.Popen(command, cwd=constants.WEB_DIR)
  264. def run_backend(app):
  265. """Run the backend.
  266. Args:
  267. app: The app.
  268. """
  269. command = constants.RUN_BACKEND + [
  270. f"{app.__name__}:{constants.APP_VAR}.{constants.API_VAR}"
  271. ]
  272. subprocess.call(command)
  273. def run_backend_prod(app) -> None:
  274. """Run the backend.
  275. Args:
  276. app: The app.
  277. """
  278. command = constants.RUN_BACKEND_PROD + [f"{app.__name__}:{constants.API_VAR}"]
  279. subprocess.call(command)
  280. def get_production_backend_url() -> str:
  281. """Get the production backend URL.
  282. Returns:
  283. The production backend URL.
  284. """
  285. config = get_config()
  286. return constants.PRODUCTION_BACKEND_URL.format(
  287. username=config.username,
  288. app_name=config.app_name,
  289. )
  290. def to_snake_case(text: str) -> str:
  291. """Convert a string to snake case.
  292. The words in the text are converted to lowercase and
  293. separated by underscores.
  294. Args:
  295. text: The string to convert.
  296. Returns:
  297. The snake case string.
  298. """
  299. s1 = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", text)
  300. return re.sub("([a-z0-9])([A-Z])", r"\1_\2", s1).lower()
  301. def to_camel_case(text: str) -> str:
  302. """Convert a string to camel case.
  303. The first word in the text is converted to lowercase and
  304. the rest of the words are converted to title case, removing underscores.
  305. Args:
  306. text: The string to convert.
  307. Returns:
  308. The camel case string.
  309. """
  310. if "_" not in text:
  311. return text
  312. camel = "".join(
  313. word.capitalize() if i > 0 else word.lower()
  314. for i, word in enumerate(text.lstrip("_").split("_"))
  315. )
  316. prefix = "_" if text.startswith("_") else ""
  317. return prefix + camel
  318. def to_title(text: str) -> str:
  319. """Convert a string from snake case to a title.
  320. Each word is converted to title case and separated by a space.
  321. Args:
  322. text: The string to convert.
  323. Returns:
  324. The title case string.
  325. """
  326. return " ".join(word.capitalize() for word in text.split("_"))
  327. WRAP_MAP = {
  328. "{": "}",
  329. "(": ")",
  330. "[": "]",
  331. "<": ">",
  332. '"': '"',
  333. "'": "'",
  334. "`": "`",
  335. }
  336. def get_close_char(open: str, close: Optional[str] = None) -> str:
  337. """Check if the given character is a valid brace.
  338. Args:
  339. open: The open character.
  340. close: The close character if provided.
  341. Returns:
  342. The close character.
  343. Raises:
  344. ValueError: If the open character is not a valid brace.
  345. """
  346. if close is not None:
  347. return close
  348. if open not in WRAP_MAP:
  349. raise ValueError(f"Invalid wrap open: {open}, must be one of {WRAP_MAP.keys()}")
  350. return WRAP_MAP[open]
  351. def is_wrapped(text: str, open: str, close: Optional[str] = None) -> bool:
  352. """Check if the given text is wrapped in the given open and close characters.
  353. Args:
  354. text: The text to check.
  355. open: The open character.
  356. close: The close character.
  357. Returns:
  358. Whether the text is wrapped.
  359. """
  360. close = get_close_char(open, close)
  361. return text.startswith(open) and text.endswith(close)
  362. def wrap(
  363. text: str,
  364. open: str,
  365. close: Optional[str] = None,
  366. check_first: bool = True,
  367. num: int = 1,
  368. ) -> str:
  369. """Wrap the given text in the given open and close characters.
  370. Args:
  371. text: The text to wrap.
  372. open: The open character.
  373. close: The close character.
  374. check_first: Whether to check if the text is already wrapped.
  375. num: The number of times to wrap the text.
  376. Returns:
  377. The wrapped text.
  378. """
  379. close = get_close_char(open, close)
  380. # If desired, check if the text is already wrapped in braces.
  381. if check_first and is_wrapped(text=text, open=open, close=close):
  382. return text
  383. # Wrap the text in braces.
  384. return f"{open * num}{text}{close * num}"
  385. def indent(text: str, indent_level: int = 2) -> str:
  386. """Indent the given text by the given indent level.
  387. Args:
  388. text: The text to indent.
  389. indent_level: The indent level.
  390. Returns:
  391. The indented text.
  392. """
  393. lines = text.splitlines()
  394. if len(lines) < 2:
  395. return text
  396. return os.linesep.join(f"{' ' * indent_level}{line}" for line in lines) + os.linesep
  397. def get_page_path(path: str) -> str:
  398. """Get the path of the compiled JS file for the given page.
  399. Args:
  400. path: The path of the page.
  401. Returns:
  402. The path of the compiled JS file.
  403. """
  404. return os.path.join(constants.WEB_PAGES_DIR, path + constants.JS_EXT)
  405. def get_theme_path() -> str:
  406. """Get the path of the base theme style.
  407. Returns:
  408. The path of the theme style.
  409. """
  410. return os.path.join(constants.WEB_UTILS_DIR, constants.THEME + constants.JS_EXT)
  411. def write_page(path: str, code: str):
  412. """Write the given code to the given path.
  413. Args:
  414. path: The path to write the code to.
  415. code: The code to write.
  416. """
  417. mkdir(os.path.dirname(path))
  418. with open(path, "w") as f:
  419. f.write(code)
  420. def format_route(route: str):
  421. """Format the given route.
  422. Args:
  423. route: The route to format.
  424. Returns:
  425. The formatted route.
  426. """
  427. route = route.strip(os.path.sep)
  428. route = to_snake_case(route).replace("_", "-")
  429. if route == "":
  430. return constants.INDEX_ROUTE
  431. return route
  432. def format_cond(
  433. cond: str, true_value: str, false_value: str = '""', is_nested: bool = False
  434. ) -> str:
  435. """Format a conditional expression.
  436. Args:
  437. cond: The cond.
  438. true_value: The value to return if the cond is true.
  439. false_value: The value to return if the cond is false.
  440. is_nested: Whether the cond is nested.
  441. Returns:
  442. The formatted conditional expression.
  443. """
  444. expr = f"{cond} ? {true_value} : {false_value}"
  445. if not is_nested:
  446. expr = wrap(expr, "{")
  447. return expr
  448. def format_event_fn(fn: Callable) -> str:
  449. """Format a function as an event.
  450. Args:
  451. fn: The function to format.
  452. Returns:
  453. The formatted function.
  454. """
  455. from pynecone.event import EventHandler
  456. if isinstance(fn, EventHandler):
  457. fn = fn.fn
  458. return to_snake_case(fn.__qualname__)
  459. def format_event(event_spec: EventSpec) -> str:
  460. """Format an event.
  461. Args:
  462. event_spec: The event to format.
  463. Returns:
  464. The compiled event.
  465. """
  466. args = ",".join([":".join((name, val)) for name, val in event_spec.args])
  467. return f"E(\"{format_event_fn(event_spec.handler.fn)}\", {wrap(args, '{')})"
  468. USED_VARIABLES = set()
  469. def get_unique_variable_name() -> str:
  470. """Get a unique variable name.
  471. Returns:
  472. The unique variable name.
  473. """
  474. name = "".join([random.choice(string.ascii_lowercase) for _ in range(8)])
  475. if name not in USED_VARIABLES:
  476. USED_VARIABLES.add(name)
  477. return name
  478. return get_unique_variable_name()
  479. def get_default_app_name() -> str:
  480. """Get the default app name.
  481. The default app name is the name of the current directory.
  482. Returns:
  483. The default app name.
  484. """
  485. return os.getcwd().split(os.path.sep)[-1].replace("-", "_")
  486. def is_dataframe(value: Type) -> bool:
  487. """Check if the given value is a dataframe.
  488. Args:
  489. value: The value to check.
  490. Returns:
  491. Whether the value is a dataframe.
  492. """
  493. return value.__name__ == "DataFrame"
  494. def format_state(value: Any) -> Dict:
  495. """Recursively format values in the given state.
  496. Args:
  497. value: The state to format.
  498. Returns:
  499. The formatted state.
  500. """
  501. if isinstance(value, go.Figure):
  502. return json.loads(to_json(value))["data"]
  503. if is_dataframe(type(value)):
  504. return {
  505. "columns": value.columns.tolist(),
  506. "data": value.values.tolist(),
  507. }
  508. if isinstance(value, dict):
  509. return {k: format_state(v) for k, v in value.items()}
  510. return value
  511. def get_event(state, event):
  512. """Get the event from the given state.
  513. Args:
  514. state: The state.
  515. event: The event.
  516. Returns:
  517. The event.
  518. """
  519. return f"{state.get_name()}.{event}"
  520. def format_string(string: str) -> str:
  521. """Format the given string as a JS string literal..
  522. Args:
  523. string: The string to format.
  524. Returns:
  525. The formatted string.
  526. """
  527. # Escape backticks.
  528. string = string.replace(r"\`", "`")
  529. string = string.replace("`", r"\`")
  530. # Wrap the string so it looks like {`string`}.
  531. string = wrap(string, "`")
  532. string = wrap(string, "{")
  533. return string
  534. def call_event_handler(event_handler: EventHandler, arg: Var) -> EventSpec:
  535. """Call an event handler to get the event spec.
  536. This function will inspect the function signature of the event handler.
  537. If it takes in an arg, the arg will be passed to the event handler.
  538. Otherwise, the event handler will be called with no args.
  539. Args:
  540. event_handler: The event handler.
  541. arg: The argument to pass to the event handler.
  542. Returns:
  543. The event spec from calling the event handler.
  544. """
  545. args = inspect.getfullargspec(event_handler.fn).args
  546. if len(args) == 1:
  547. return event_handler()
  548. assert (
  549. len(args) == 2
  550. ), f"Event handler {event_handler.fn} must have 1 or 2 arguments."
  551. return event_handler(arg)
  552. def call_event_fn(fn: Callable, arg: Var) -> List[EventSpec]:
  553. """Call a function to a list of event specs.
  554. The function should return either a single EventSpec or a list of EventSpecs.
  555. If the function takes in an arg, the arg will be passed to the function.
  556. Otherwise, the function will be called with no args.
  557. Args:
  558. fn: The function to call.
  559. arg: The argument to pass to the function.
  560. Returns:
  561. The event specs from calling the function.
  562. Raises:
  563. ValueError: If the lambda has an invalid signature.
  564. """
  565. args = inspect.getfullargspec(fn).args
  566. if len(args) == 0:
  567. out = fn()
  568. elif len(args) == 1:
  569. out = fn(arg)
  570. else:
  571. raise ValueError(f"Lambda {fn} must have 0 or 1 arguments.")
  572. if not isinstance(out, List):
  573. out = [out]
  574. return out
  575. def get_handler_args(event_spec: EventSpec, arg: Var) -> Tuple[Tuple[str, str], ...]:
  576. """Get the handler args for the given event spec.
  577. Args:
  578. event_spec: The event spec.
  579. arg: The controlled event argument.
  580. Returns:
  581. The handler args.
  582. """
  583. args = inspect.getfullargspec(event_spec.handler.fn).args
  584. if len(args) > 2:
  585. return event_spec.args
  586. else:
  587. return ((args[1], arg.name),)
  588. def fix_events(events: Optional[List[Event]], token: str) -> List[Event]:
  589. """Fix a list of events returned by an event handler.
  590. Args:
  591. events: The events to fix.
  592. token: The user token.
  593. Returns:
  594. The fixed events.
  595. """
  596. from pynecone.event import Event, EventHandler, EventSpec
  597. # If the event handler returns nothing, return an empty list.
  598. if events is None:
  599. return []
  600. # If the handler returns a single event, wrap it in a list.
  601. if not isinstance(events, List):
  602. events = [events]
  603. # Fix the events created by the handler.
  604. out = []
  605. for e in events:
  606. # If it is already an event, don't modify it.
  607. if isinstance(e, Event):
  608. name = e.name
  609. payload = e.payload
  610. # Otherwise, create an event from the event spec.
  611. else:
  612. if isinstance(e, EventHandler):
  613. e = e()
  614. assert isinstance(e, EventSpec), f"Unexpected event type, {type(e)}."
  615. name = format_event_fn(e.handler.fn)
  616. payload = dict(e.args)
  617. # Create an event and append it to the list.
  618. out.append(
  619. Event(
  620. token=token,
  621. name=name,
  622. payload=payload,
  623. )
  624. )
  625. return out
  626. def merge_imports(*imports) -> ImportDict:
  627. """Merge two import dicts together.
  628. Args:
  629. *imports: The list of import dicts to merge.
  630. Returns:
  631. The merged import dicts.
  632. """
  633. all_imports = defaultdict(set)
  634. for import_dict in imports:
  635. for lib, fields in import_dict.items():
  636. for field in fields:
  637. all_imports[lib].add(field)
  638. return all_imports
  639. def get_hydrate_event(state) -> str:
  640. """Get the name of the hydrate event for the state.
  641. Args:
  642. state: The state.
  643. Returns:
  644. The name of the hydrate event.
  645. """
  646. return get_event(state, constants.HYDRATE)
  647. def get_redis():
  648. """Get the redis client.
  649. Returns:
  650. The redis client.
  651. """
  652. try:
  653. import redis # type: ignore
  654. except:
  655. return None
  656. config = get_config()
  657. if config.redis_url is None:
  658. return None
  659. redis_url, redis_port = config.redis_url.split(":")
  660. print("Using redis at", config.redis_url)
  661. return redis.Redis(host=redis_url, port=int(redis_port), db=0)