upload.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413
  1. """A file upload component."""
  2. from __future__ import annotations
  3. from collections.abc import Callable, Sequence
  4. from pathlib import Path
  5. from typing import Any, ClassVar
  6. from reflex.components.base.fragment import Fragment
  7. from reflex.components.component import (
  8. Component,
  9. ComponentNamespace,
  10. MemoizationLeaf,
  11. StatefulComponent,
  12. )
  13. from reflex.components.el.elements.forms import Input
  14. from reflex.components.radix.themes.layout.box import Box
  15. from reflex.config import environment
  16. from reflex.constants import Dirs
  17. from reflex.constants.compiler import Hooks, Imports
  18. from reflex.event import (
  19. CallableEventSpec,
  20. EventChain,
  21. EventHandler,
  22. EventSpec,
  23. call_event_fn,
  24. call_event_handler,
  25. parse_args_spec,
  26. run_script,
  27. )
  28. from reflex.utils import format
  29. from reflex.utils.imports import ImportVar
  30. from reflex.vars import VarData
  31. from reflex.vars.base import Var, get_unique_variable_name
  32. from reflex.vars.sequence import LiteralStringVar
  33. DEFAULT_UPLOAD_ID: str = "default"
  34. upload_files_context_var_data: VarData = VarData(
  35. imports={
  36. "react": "useContext",
  37. f"$/{Dirs.CONTEXTS_PATH}": "UploadFilesContext",
  38. },
  39. hooks={
  40. "const [filesById, setFilesById] = useContext(UploadFilesContext);": None,
  41. },
  42. )
  43. def upload_file(id_: str = DEFAULT_UPLOAD_ID) -> Var:
  44. """Get the file upload drop trigger.
  45. This var is passed to the dropzone component to update the file list when a
  46. drop occurs.
  47. Args:
  48. id_: The id of the upload to get the drop trigger for.
  49. Returns:
  50. A var referencing the file upload drop trigger.
  51. """
  52. id_var = LiteralStringVar.create(id_)
  53. var_name = f"""e => setFilesById(filesById => {{
  54. const updatedFilesById = Object.assign({{}}, filesById);
  55. updatedFilesById[{id_var!s}] = e;
  56. return updatedFilesById;
  57. }})
  58. """
  59. return Var(
  60. _js_expr=var_name,
  61. _var_type=EventChain,
  62. _var_data=VarData.merge(
  63. upload_files_context_var_data, id_var._get_all_var_data()
  64. ),
  65. )
  66. def selected_files(id_: str = DEFAULT_UPLOAD_ID) -> Var:
  67. """Get the list of selected files.
  68. Args:
  69. id_: The id of the upload to get the selected files for.
  70. Returns:
  71. A var referencing the list of selected file paths.
  72. """
  73. id_var = LiteralStringVar.create(id_)
  74. return Var(
  75. _js_expr=f"(filesById[{id_var!s}] ? filesById[{id_var!s}].map((f) => (f.path || f.name)) : [])",
  76. _var_type=list[str],
  77. _var_data=VarData.merge(
  78. upload_files_context_var_data, id_var._get_all_var_data()
  79. ),
  80. ).guess_type()
  81. @CallableEventSpec
  82. def clear_selected_files(id_: str = DEFAULT_UPLOAD_ID) -> EventSpec:
  83. """Clear the list of selected files.
  84. Args:
  85. id_: The id of the upload to clear.
  86. Returns:
  87. An event spec that clears the list of selected files when triggered.
  88. """
  89. # UploadFilesProvider assigns a special function to clear selected files
  90. # into the shared global refs object to make it accessible outside a React
  91. # component via `run_script` (otherwise backend could never clear files).
  92. func = Var("__clear_selected_files")._as_ref()
  93. return run_script(f"{func}({id_!r})")
  94. def cancel_upload(upload_id: str) -> EventSpec:
  95. """Cancel an upload.
  96. Args:
  97. upload_id: The id of the upload to cancel.
  98. Returns:
  99. An event spec that cancels the upload when triggered.
  100. """
  101. controller = Var(f"__upload_controllers_{upload_id}")._as_ref()
  102. return run_script(f"{controller}?.abort()")
  103. def get_upload_dir() -> Path:
  104. """Get the directory where uploaded files are stored.
  105. Returns:
  106. The directory where uploaded files are stored.
  107. """
  108. Upload.is_used = True
  109. uploaded_files_dir = environment.REFLEX_UPLOADED_FILES_DIR.get()
  110. uploaded_files_dir.mkdir(parents=True, exist_ok=True)
  111. return uploaded_files_dir
  112. uploaded_files_url_prefix = Var(
  113. _js_expr="getBackendURL(env.UPLOAD)",
  114. _var_data=VarData(
  115. imports={
  116. f"$/{Dirs.STATE_PATH}": "getBackendURL",
  117. "$/env.json": ImportVar(tag="env", is_default=True),
  118. }
  119. ),
  120. ).to(str)
  121. def get_upload_url(file_path: str | Var[str]) -> Var[str]:
  122. """Get the URL of an uploaded file.
  123. Args:
  124. file_path: The path of the uploaded file.
  125. Returns:
  126. The URL of the uploaded file to be rendered from the frontend (as a str-encoded Var).
  127. """
  128. Upload.is_used = True
  129. return Var.create(f"{uploaded_files_url_prefix}/{file_path}")
  130. def _on_drop_spec(files: Var) -> tuple[Var[Any]]:
  131. """Args spec for the on_drop event trigger.
  132. Args:
  133. files: The files to upload.
  134. Returns:
  135. Signature for on_drop handler including the files to upload.
  136. """
  137. return (files,)
  138. class UploadFilesProvider(Component):
  139. """AppWrap component that provides a dict of selected files by ID via useContext."""
  140. library = f"$/{Dirs.CONTEXTS_PATH}"
  141. tag = "UploadFilesProvider"
  142. class GhostUpload(Fragment):
  143. """A ghost upload component."""
  144. # Fired when files are dropped.
  145. on_drop: EventHandler[_on_drop_spec]
  146. class Upload(MemoizationLeaf):
  147. """A file upload component."""
  148. library = "react-dropzone@14.3.8"
  149. tag = ""
  150. # The list of accepted file types. This should be a dictionary of MIME types as keys and array of file formats as
  151. # values.
  152. # supported MIME types: https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types
  153. accept: Var[dict[str, Sequence] | None]
  154. # Whether the dropzone is disabled.
  155. disabled: Var[bool]
  156. # The maximum number of files that can be uploaded.
  157. max_files: Var[int]
  158. # The maximum file size (bytes) that can be uploaded.
  159. max_size: Var[int]
  160. # The minimum file size (bytes) that can be uploaded.
  161. min_size: Var[int]
  162. # Whether to allow multiple files to be uploaded.
  163. multiple: Var[bool]
  164. # Whether to disable click to upload.
  165. no_click: Var[bool]
  166. # Whether to disable drag and drop.
  167. no_drag: Var[bool]
  168. # Whether to disable using the space/enter keys to upload.
  169. no_keyboard: Var[bool]
  170. # Marked True when any Upload component is created.
  171. is_used: ClassVar[bool] = False
  172. # Fired when files are dropped.
  173. on_drop: EventHandler[_on_drop_spec]
  174. @classmethod
  175. def create(cls, *children, **props) -> Component:
  176. """Create an upload component.
  177. Args:
  178. *children: The children of the component.
  179. **props: The properties of the component.
  180. Returns:
  181. The upload component.
  182. """
  183. # Mark the Upload component as used in the app.
  184. cls.is_used = True
  185. props.setdefault("multiple", True)
  186. # Apply the default classname
  187. given_class_name = props.pop("class_name", [])
  188. if isinstance(given_class_name, str):
  189. given_class_name = [given_class_name]
  190. props["class_name"] = ["rx-Upload", *given_class_name]
  191. # get only upload component props
  192. supported_props = cls.get_props().union({"on_drop"})
  193. upload_props = {
  194. key: value for key, value in props.items() if key in supported_props
  195. }
  196. # Create the component.
  197. upload_props["id"] = props.get("id", DEFAULT_UPLOAD_ID)
  198. if upload_props.get("on_drop") is None:
  199. # If on_drop is not provided, save files to be uploaded later.
  200. upload_props["on_drop"] = upload_file(upload_props["id"])
  201. else:
  202. on_drop = upload_props["on_drop"]
  203. if isinstance(on_drop, (EventHandler, EventSpec)):
  204. # Call the lambda to get the event chain.
  205. on_drop = call_event_handler(on_drop, _on_drop_spec)
  206. elif isinstance(on_drop, Callable):
  207. # Call the lambda to get the event chain.
  208. on_drop = call_event_fn(on_drop, _on_drop_spec)
  209. if isinstance(on_drop, EventSpec):
  210. # Update the provided args for direct use with on_drop.
  211. on_drop = on_drop.with_args(
  212. args=tuple(
  213. cls._update_arg_tuple_for_on_drop(arg_value)
  214. for arg_value in on_drop.args
  215. ),
  216. )
  217. upload_props["on_drop"] = on_drop
  218. input_props_unique_name = get_unique_variable_name()
  219. root_props_unique_name = get_unique_variable_name()
  220. event_var, callback_str = StatefulComponent._get_memoized_event_triggers(
  221. GhostUpload.create(on_drop=upload_props["on_drop"])
  222. )["on_drop"]
  223. upload_props["on_drop"] = event_var
  224. upload_props = {
  225. format.to_camel_case(key): value for key, value in upload_props.items()
  226. }
  227. use_dropzone_arguments = Var.create(
  228. {
  229. "onDrop": event_var,
  230. **upload_props,
  231. }
  232. )
  233. left_side = f"const {{getRootProps: {root_props_unique_name}, getInputProps: {input_props_unique_name}}} "
  234. right_side = f"useDropzone({use_dropzone_arguments!s})"
  235. var_data = VarData.merge(
  236. VarData(
  237. imports=Imports.EVENTS,
  238. hooks={Hooks.EVENTS: None},
  239. ),
  240. event_var._get_all_var_data(),
  241. use_dropzone_arguments._get_all_var_data(),
  242. VarData(
  243. hooks={
  244. callback_str: None,
  245. f"{left_side} = {right_side};": None,
  246. },
  247. imports={
  248. "react-dropzone": "useDropzone",
  249. **Imports.EVENTS,
  250. },
  251. ),
  252. )
  253. # The file input to use.
  254. upload = Input.create(type="file")
  255. upload.special_props = [
  256. Var(
  257. _js_expr=f"{{...{input_props_unique_name}()}}",
  258. _var_type=None,
  259. _var_data=var_data,
  260. )
  261. ]
  262. # The dropzone to use.
  263. zone = Box.create(
  264. upload,
  265. *children,
  266. **{k: v for k, v in props.items() if k not in supported_props},
  267. )
  268. zone.special_props = [
  269. Var(
  270. _js_expr=f"{{...{root_props_unique_name}()}}",
  271. _var_type=None,
  272. _var_data=var_data,
  273. )
  274. ]
  275. return super().create(
  276. zone,
  277. )
  278. @classmethod
  279. def _update_arg_tuple_for_on_drop(cls, arg_value: tuple[Var, Var]):
  280. """Helper to update caller-provided EventSpec args for direct use with on_drop.
  281. Args:
  282. arg_value: The arg tuple to update (if necessary).
  283. Returns:
  284. The updated arg_value tuple when arg is "files", otherwise the original arg_value.
  285. """
  286. if arg_value[0]._js_expr == "files":
  287. placeholder = parse_args_spec(_on_drop_spec)[0]
  288. return (arg_value[0], placeholder)
  289. return arg_value
  290. @staticmethod
  291. def _get_app_wrap_components() -> dict[tuple[int, str], Component]:
  292. return {
  293. (5, "UploadFilesProvider"): UploadFilesProvider.create(),
  294. }
  295. class StyledUpload(Upload):
  296. """The styled Upload Component."""
  297. @classmethod
  298. def create(cls, *children, **props) -> Component:
  299. """Create the styled upload component.
  300. Args:
  301. *children: The children of the component.
  302. **props: The properties of the component.
  303. Returns:
  304. The styled upload component.
  305. """
  306. # Set default props.
  307. props.setdefault("border", "1px dashed var(--accent-12)")
  308. props.setdefault("padding", "5em")
  309. props.setdefault("textAlign", "center")
  310. # Mark the Upload component as used in the app.
  311. Upload.is_used = True
  312. return super().create(
  313. *children,
  314. **props,
  315. )
  316. class UploadNamespace(ComponentNamespace):
  317. """Upload component namespace."""
  318. root = Upload.create
  319. __call__ = StyledUpload.create
  320. upload = UploadNamespace()