upload.py 10 KB

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