fal 1.19.2__tar.gz → 1.20.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of fal might be problematic. Click here for more details.

Files changed (183) hide show
  1. {fal-1.19.2/fal.egg-info → fal-1.20.1}/PKG-INFO +1 -1
  2. {fal-1.19.2 → fal-1.20.1/fal.egg-info}/PKG-INFO +1 -1
  3. {fal-1.19.2 → fal-1.20.1}/fal.egg-info/SOURCES.txt +1 -0
  4. {fal-1.19.2 → fal-1.20.1}/src/fal/_fal_version.py +2 -2
  5. {fal-1.19.2 → fal-1.20.1}/src/fal/api.py +3 -0
  6. {fal-1.19.2 → fal-1.20.1}/src/fal/cli/files.py +30 -4
  7. {fal-1.19.2 → fal-1.20.1}/src/fal/toolkit/file/providers/fal.py +54 -32
  8. {fal-1.19.2 → fal-1.20.1}/src/fal/toolkit/utils/retry.py +5 -1
  9. {fal-1.19.2 → fal-1.20.1}/tests/test_apps.py +4 -2
  10. fal-1.20.1/tests/toolkit/file/providers/test_fal_retry.py +165 -0
  11. {fal-1.19.2 → fal-1.20.1}/.gitignore +0 -0
  12. {fal-1.19.2 → fal-1.20.1}/Makefile +0 -0
  13. {fal-1.19.2 → fal-1.20.1}/README.md +0 -0
  14. {fal-1.19.2 → fal-1.20.1}/docs/conf.py +0 -0
  15. {fal-1.19.2 → fal-1.20.1}/docs/index.rst +0 -0
  16. {fal-1.19.2 → fal-1.20.1}/fal.egg-info/dependency_links.txt +0 -0
  17. {fal-1.19.2 → fal-1.20.1}/fal.egg-info/entry_points.txt +0 -0
  18. {fal-1.19.2 → fal-1.20.1}/fal.egg-info/requires.txt +0 -0
  19. {fal-1.19.2 → fal-1.20.1}/fal.egg-info/top_level.txt +0 -0
  20. {fal-1.19.2 → fal-1.20.1}/openapi-fal-rest/README.md +0 -0
  21. {fal-1.19.2 → fal-1.20.1}/openapi-fal-rest/openapi_fal_rest/__init__.py +0 -0
  22. {fal-1.19.2 → fal-1.20.1}/openapi-fal-rest/openapi_fal_rest/api/__init__.py +0 -0
  23. {fal-1.19.2 → fal-1.20.1}/openapi-fal-rest/openapi_fal_rest/api/applications/__init__.py +0 -0
  24. {fal-1.19.2 → fal-1.20.1}/openapi-fal-rest/openapi_fal_rest/api/applications/app_metadata.py +0 -0
  25. {fal-1.19.2 → fal-1.20.1}/openapi-fal-rest/openapi_fal_rest/api/billing/__init__.py +0 -0
  26. {fal-1.19.2 → fal-1.20.1}/openapi-fal-rest/openapi_fal_rest/api/billing/get_user_details.py +0 -0
  27. {fal-1.19.2 → fal-1.20.1}/openapi-fal-rest/openapi_fal_rest/api/comfy/__init__.py +0 -0
  28. {fal-1.19.2 → fal-1.20.1}/openapi-fal-rest/openapi_fal_rest/api/comfy/create_workflow.py +0 -0
  29. {fal-1.19.2 → fal-1.20.1}/openapi-fal-rest/openapi_fal_rest/api/comfy/delete_workflow.py +0 -0
  30. {fal-1.19.2 → fal-1.20.1}/openapi-fal-rest/openapi_fal_rest/api/comfy/get_workflow.py +0 -0
  31. {fal-1.19.2 → fal-1.20.1}/openapi-fal-rest/openapi_fal_rest/api/comfy/list_user_workflows.py +0 -0
  32. {fal-1.19.2 → fal-1.20.1}/openapi-fal-rest/openapi_fal_rest/api/comfy/update_workflow.py +0 -0
  33. {fal-1.19.2 → fal-1.20.1}/openapi-fal-rest/openapi_fal_rest/api/files/__init__.py +0 -0
  34. {fal-1.19.2 → fal-1.20.1}/openapi-fal-rest/openapi_fal_rest/api/files/check_dir_hash.py +0 -0
  35. {fal-1.19.2 → fal-1.20.1}/openapi-fal-rest/openapi_fal_rest/api/files/upload_local_file.py +0 -0
  36. {fal-1.19.2 → fal-1.20.1}/openapi-fal-rest/openapi_fal_rest/api/users/__init__.py +0 -0
  37. {fal-1.19.2 → fal-1.20.1}/openapi-fal-rest/openapi_fal_rest/api/users/get_current_user.py +0 -0
  38. {fal-1.19.2 → fal-1.20.1}/openapi-fal-rest/openapi_fal_rest/api/workflows/__init__.py +0 -0
  39. {fal-1.19.2 → fal-1.20.1}/openapi-fal-rest/openapi_fal_rest/api/workflows/create_workflow.py +0 -0
  40. {fal-1.19.2 → fal-1.20.1}/openapi-fal-rest/openapi_fal_rest/api/workflows/delete_workflow.py +0 -0
  41. {fal-1.19.2 → fal-1.20.1}/openapi-fal-rest/openapi_fal_rest/api/workflows/get_workflow.py +0 -0
  42. {fal-1.19.2 → fal-1.20.1}/openapi-fal-rest/openapi_fal_rest/api/workflows/list_user_workflows.py +0 -0
  43. {fal-1.19.2 → fal-1.20.1}/openapi-fal-rest/openapi_fal_rest/api/workflows/update_workflow.py +0 -0
  44. {fal-1.19.2 → fal-1.20.1}/openapi-fal-rest/openapi_fal_rest/client.py +0 -0
  45. {fal-1.19.2 → fal-1.20.1}/openapi-fal-rest/openapi_fal_rest/errors.py +0 -0
  46. {fal-1.19.2 → fal-1.20.1}/openapi-fal-rest/openapi_fal_rest/models/__init__.py +0 -0
  47. {fal-1.19.2 → fal-1.20.1}/openapi-fal-rest/openapi_fal_rest/models/app_metadata_response_app_metadata.py +0 -0
  48. {fal-1.19.2 → fal-1.20.1}/openapi-fal-rest/openapi_fal_rest/models/body_upload_local_file.py +0 -0
  49. {fal-1.19.2 → fal-1.20.1}/openapi-fal-rest/openapi_fal_rest/models/comfy_workflow_detail.py +0 -0
  50. {fal-1.19.2 → fal-1.20.1}/openapi-fal-rest/openapi_fal_rest/models/comfy_workflow_item.py +0 -0
  51. {fal-1.19.2 → fal-1.20.1}/openapi-fal-rest/openapi_fal_rest/models/comfy_workflow_schema.py +0 -0
  52. {fal-1.19.2 → fal-1.20.1}/openapi-fal-rest/openapi_fal_rest/models/comfy_workflow_schema_extra_data.py +0 -0
  53. {fal-1.19.2 → fal-1.20.1}/openapi-fal-rest/openapi_fal_rest/models/comfy_workflow_schema_fal_inputs.py +0 -0
  54. {fal-1.19.2 → fal-1.20.1}/openapi-fal-rest/openapi_fal_rest/models/comfy_workflow_schema_fal_inputs_dev_info.py +0 -0
  55. {fal-1.19.2 → fal-1.20.1}/openapi-fal-rest/openapi_fal_rest/models/comfy_workflow_schema_prompt.py +0 -0
  56. {fal-1.19.2 → fal-1.20.1}/openapi-fal-rest/openapi_fal_rest/models/current_user.py +0 -0
  57. {fal-1.19.2 → fal-1.20.1}/openapi-fal-rest/openapi_fal_rest/models/customer_details.py +0 -0
  58. {fal-1.19.2 → fal-1.20.1}/openapi-fal-rest/openapi_fal_rest/models/hash_check.py +0 -0
  59. {fal-1.19.2 → fal-1.20.1}/openapi-fal-rest/openapi_fal_rest/models/http_validation_error.py +0 -0
  60. {fal-1.19.2 → fal-1.20.1}/openapi-fal-rest/openapi_fal_rest/models/lock_reason.py +0 -0
  61. {fal-1.19.2 → fal-1.20.1}/openapi-fal-rest/openapi_fal_rest/models/page_comfy_workflow_item.py +0 -0
  62. {fal-1.19.2 → fal-1.20.1}/openapi-fal-rest/openapi_fal_rest/models/page_workflow_item.py +0 -0
  63. {fal-1.19.2 → fal-1.20.1}/openapi-fal-rest/openapi_fal_rest/models/team_role.py +0 -0
  64. {fal-1.19.2 → fal-1.20.1}/openapi-fal-rest/openapi_fal_rest/models/typed_comfy_workflow.py +0 -0
  65. {fal-1.19.2 → fal-1.20.1}/openapi-fal-rest/openapi_fal_rest/models/typed_comfy_workflow_update.py +0 -0
  66. {fal-1.19.2 → fal-1.20.1}/openapi-fal-rest/openapi_fal_rest/models/typed_workflow.py +0 -0
  67. {fal-1.19.2 → fal-1.20.1}/openapi-fal-rest/openapi_fal_rest/models/typed_workflow_update.py +0 -0
  68. {fal-1.19.2 → fal-1.20.1}/openapi-fal-rest/openapi_fal_rest/models/user_member.py +0 -0
  69. {fal-1.19.2 → fal-1.20.1}/openapi-fal-rest/openapi_fal_rest/models/validation_error.py +0 -0
  70. {fal-1.19.2 → fal-1.20.1}/openapi-fal-rest/openapi_fal_rest/models/workflow_contents.py +0 -0
  71. {fal-1.19.2 → fal-1.20.1}/openapi-fal-rest/openapi_fal_rest/models/workflow_contents_metadata.py +0 -0
  72. {fal-1.19.2 → fal-1.20.1}/openapi-fal-rest/openapi_fal_rest/models/workflow_contents_nodes.py +0 -0
  73. {fal-1.19.2 → fal-1.20.1}/openapi-fal-rest/openapi_fal_rest/models/workflow_contents_output.py +0 -0
  74. {fal-1.19.2 → fal-1.20.1}/openapi-fal-rest/openapi_fal_rest/models/workflow_detail.py +0 -0
  75. {fal-1.19.2 → fal-1.20.1}/openapi-fal-rest/openapi_fal_rest/models/workflow_detail_contents.py +0 -0
  76. {fal-1.19.2 → fal-1.20.1}/openapi-fal-rest/openapi_fal_rest/models/workflow_item.py +0 -0
  77. {fal-1.19.2 → fal-1.20.1}/openapi-fal-rest/openapi_fal_rest/models/workflow_node.py +0 -0
  78. {fal-1.19.2 → fal-1.20.1}/openapi-fal-rest/openapi_fal_rest/models/workflow_node_type.py +0 -0
  79. {fal-1.19.2 → fal-1.20.1}/openapi-fal-rest/openapi_fal_rest/models/workflow_schema.py +0 -0
  80. {fal-1.19.2 → fal-1.20.1}/openapi-fal-rest/openapi_fal_rest/models/workflow_schema_input.py +0 -0
  81. {fal-1.19.2 → fal-1.20.1}/openapi-fal-rest/openapi_fal_rest/models/workflow_schema_output.py +0 -0
  82. {fal-1.19.2 → fal-1.20.1}/openapi-fal-rest/openapi_fal_rest/py.typed +0 -0
  83. {fal-1.19.2 → fal-1.20.1}/openapi-fal-rest/openapi_fal_rest/types.py +0 -0
  84. {fal-1.19.2 → fal-1.20.1}/openapi-fal-rest/pyproject.toml +0 -0
  85. {fal-1.19.2 → fal-1.20.1}/openapi_rest.config.yaml +0 -0
  86. {fal-1.19.2 → fal-1.20.1}/pyproject.toml +0 -0
  87. {fal-1.19.2 → fal-1.20.1}/setup.cfg +0 -0
  88. {fal-1.19.2 → fal-1.20.1}/src/fal/__init__.py +0 -0
  89. {fal-1.19.2 → fal-1.20.1}/src/fal/__main__.py +0 -0
  90. {fal-1.19.2 → fal-1.20.1}/src/fal/_serialization.py +0 -0
  91. {fal-1.19.2 → fal-1.20.1}/src/fal/_version.py +0 -0
  92. {fal-1.19.2 → fal-1.20.1}/src/fal/app.py +0 -0
  93. {fal-1.19.2 → fal-1.20.1}/src/fal/apps.py +0 -0
  94. {fal-1.19.2 → fal-1.20.1}/src/fal/auth/__init__.py +0 -0
  95. {fal-1.19.2 → fal-1.20.1}/src/fal/auth/auth0.py +0 -0
  96. {fal-1.19.2 → fal-1.20.1}/src/fal/auth/local.py +0 -0
  97. {fal-1.19.2 → fal-1.20.1}/src/fal/cli/__init__.py +0 -0
  98. {fal-1.19.2 → fal-1.20.1}/src/fal/cli/_utils.py +0 -0
  99. {fal-1.19.2 → fal-1.20.1}/src/fal/cli/api.py +0 -0
  100. {fal-1.19.2 → fal-1.20.1}/src/fal/cli/apps.py +0 -0
  101. {fal-1.19.2 → fal-1.20.1}/src/fal/cli/auth.py +0 -0
  102. {fal-1.19.2 → fal-1.20.1}/src/fal/cli/cli_nested_json.py +0 -0
  103. {fal-1.19.2 → fal-1.20.1}/src/fal/cli/create.py +0 -0
  104. {fal-1.19.2 → fal-1.20.1}/src/fal/cli/debug.py +0 -0
  105. {fal-1.19.2 → fal-1.20.1}/src/fal/cli/deploy.py +0 -0
  106. {fal-1.19.2 → fal-1.20.1}/src/fal/cli/doctor.py +0 -0
  107. {fal-1.19.2 → fal-1.20.1}/src/fal/cli/keys.py +0 -0
  108. {fal-1.19.2 → fal-1.20.1}/src/fal/cli/main.py +0 -0
  109. {fal-1.19.2 → fal-1.20.1}/src/fal/cli/parser.py +0 -0
  110. {fal-1.19.2 → fal-1.20.1}/src/fal/cli/profile.py +0 -0
  111. {fal-1.19.2 → fal-1.20.1}/src/fal/cli/run.py +0 -0
  112. {fal-1.19.2 → fal-1.20.1}/src/fal/cli/runners.py +0 -0
  113. {fal-1.19.2 → fal-1.20.1}/src/fal/cli/secrets.py +0 -0
  114. {fal-1.19.2 → fal-1.20.1}/src/fal/cli/teams.py +0 -0
  115. {fal-1.19.2 → fal-1.20.1}/src/fal/config.py +0 -0
  116. {fal-1.19.2 → fal-1.20.1}/src/fal/console/__init__.py +0 -0
  117. {fal-1.19.2 → fal-1.20.1}/src/fal/console/icons.py +0 -0
  118. {fal-1.19.2 → fal-1.20.1}/src/fal/console/ux.py +0 -0
  119. {fal-1.19.2 → fal-1.20.1}/src/fal/container.py +0 -0
  120. {fal-1.19.2 → fal-1.20.1}/src/fal/exceptions/__init__.py +0 -0
  121. {fal-1.19.2 → fal-1.20.1}/src/fal/exceptions/_base.py +0 -0
  122. {fal-1.19.2 → fal-1.20.1}/src/fal/exceptions/_cuda.py +0 -0
  123. {fal-1.19.2 → fal-1.20.1}/src/fal/exceptions/auth.py +0 -0
  124. {fal-1.19.2 → fal-1.20.1}/src/fal/files.py +0 -0
  125. {fal-1.19.2 → fal-1.20.1}/src/fal/flags.py +0 -0
  126. {fal-1.19.2 → fal-1.20.1}/src/fal/logging/__init__.py +0 -0
  127. {fal-1.19.2 → fal-1.20.1}/src/fal/logging/isolate.py +0 -0
  128. {fal-1.19.2 → fal-1.20.1}/src/fal/logging/style.py +0 -0
  129. {fal-1.19.2 → fal-1.20.1}/src/fal/logging/trace.py +0 -0
  130. {fal-1.19.2 → fal-1.20.1}/src/fal/logging/user.py +0 -0
  131. {fal-1.19.2 → fal-1.20.1}/src/fal/project.py +0 -0
  132. {fal-1.19.2 → fal-1.20.1}/src/fal/py.typed +0 -0
  133. {fal-1.19.2 → fal-1.20.1}/src/fal/rest_client.py +0 -0
  134. {fal-1.19.2 → fal-1.20.1}/src/fal/sdk.py +0 -0
  135. {fal-1.19.2 → fal-1.20.1}/src/fal/sync.py +0 -0
  136. {fal-1.19.2 → fal-1.20.1}/src/fal/toolkit/__init__.py +0 -0
  137. {fal-1.19.2 → fal-1.20.1}/src/fal/toolkit/audio/__init__.py +0 -0
  138. {fal-1.19.2 → fal-1.20.1}/src/fal/toolkit/audio/audio.py +0 -0
  139. {fal-1.19.2 → fal-1.20.1}/src/fal/toolkit/exceptions.py +0 -0
  140. {fal-1.19.2 → fal-1.20.1}/src/fal/toolkit/file/__init__.py +0 -0
  141. {fal-1.19.2 → fal-1.20.1}/src/fal/toolkit/file/file.py +0 -0
  142. {fal-1.19.2 → fal-1.20.1}/src/fal/toolkit/file/providers/gcp.py +0 -0
  143. {fal-1.19.2 → fal-1.20.1}/src/fal/toolkit/file/providers/r2.py +0 -0
  144. {fal-1.19.2 → fal-1.20.1}/src/fal/toolkit/file/providers/s3.py +0 -0
  145. {fal-1.19.2 → fal-1.20.1}/src/fal/toolkit/file/types.py +0 -0
  146. {fal-1.19.2 → fal-1.20.1}/src/fal/toolkit/image/__init__.py +0 -0
  147. {fal-1.19.2 → fal-1.20.1}/src/fal/toolkit/image/image.py +0 -0
  148. {fal-1.19.2 → fal-1.20.1}/src/fal/toolkit/image/nsfw_filter/__init__.py +0 -0
  149. {fal-1.19.2 → fal-1.20.1}/src/fal/toolkit/image/nsfw_filter/env.py +0 -0
  150. {fal-1.19.2 → fal-1.20.1}/src/fal/toolkit/image/nsfw_filter/inference.py +0 -0
  151. {fal-1.19.2 → fal-1.20.1}/src/fal/toolkit/image/nsfw_filter/model.py +0 -0
  152. {fal-1.19.2 → fal-1.20.1}/src/fal/toolkit/image/nsfw_filter/requirements.txt +0 -0
  153. {fal-1.19.2 → fal-1.20.1}/src/fal/toolkit/image/safety_checker.py +0 -0
  154. {fal-1.19.2 → fal-1.20.1}/src/fal/toolkit/optimize.py +0 -0
  155. {fal-1.19.2 → fal-1.20.1}/src/fal/toolkit/types.py +0 -0
  156. {fal-1.19.2 → fal-1.20.1}/src/fal/toolkit/utils/__init__.py +0 -0
  157. {fal-1.19.2 → fal-1.20.1}/src/fal/toolkit/utils/download_utils.py +0 -0
  158. {fal-1.19.2 → fal-1.20.1}/src/fal/toolkit/utils/endpoint.py +0 -0
  159. {fal-1.19.2 → fal-1.20.1}/src/fal/toolkit/video/__init__.py +0 -0
  160. {fal-1.19.2 → fal-1.20.1}/src/fal/toolkit/video/video.py +0 -0
  161. {fal-1.19.2 → fal-1.20.1}/src/fal/utils.py +0 -0
  162. {fal-1.19.2 → fal-1.20.1}/src/fal/workflows.py +0 -0
  163. {fal-1.19.2 → fal-1.20.1}/tests/__init__.py +0 -0
  164. {fal-1.19.2 → fal-1.20.1}/tests/assets/cat.png +0 -0
  165. {fal-1.19.2 → fal-1.20.1}/tests/cli/__init__.py +0 -0
  166. {fal-1.19.2 → fal-1.20.1}/tests/cli/test_apps.py +0 -0
  167. {fal-1.19.2 → fal-1.20.1}/tests/cli/test_auth.py +0 -0
  168. {fal-1.19.2 → fal-1.20.1}/tests/cli/test_deploy.py +0 -0
  169. {fal-1.19.2 → fal-1.20.1}/tests/cli/test_keys.py +0 -0
  170. {fal-1.19.2 → fal-1.20.1}/tests/cli/test_run.py +0 -0
  171. {fal-1.19.2 → fal-1.20.1}/tests/cli/test_secrets.py +0 -0
  172. {fal-1.19.2 → fal-1.20.1}/tests/conftest.py +0 -0
  173. {fal-1.19.2 → fal-1.20.1}/tests/integration_test.py +0 -0
  174. {fal-1.19.2 → fal-1.20.1}/tests/mainify_package/__init__.py +0 -0
  175. {fal-1.19.2 → fal-1.20.1}/tests/mainify_package/impl.py +0 -0
  176. {fal-1.19.2 → fal-1.20.1}/tests/mainify_package/utils.py +0 -0
  177. {fal-1.19.2 → fal-1.20.1}/tests/mainify_target.py +0 -0
  178. {fal-1.19.2 → fal-1.20.1}/tests/test_stability.py +0 -0
  179. {fal-1.19.2 → fal-1.20.1}/tests/toolkit/file_test.py +0 -0
  180. {fal-1.19.2 → fal-1.20.1}/tests/toolkit/image_test.py +0 -0
  181. {fal-1.19.2 → fal-1.20.1}/tests/toolkit/test_types.py +0 -0
  182. {fal-1.19.2 → fal-1.20.1}/tests/toolkit/utils/retry.py +0 -0
  183. {fal-1.19.2 → fal-1.20.1}/tools/demo_script.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fal
3
- Version: 1.19.2
3
+ Version: 1.20.1
4
4
  Summary: fal is an easy-to-use Serverless Python Framework
5
5
  Author: Features & Labels <support@fal.ai>
6
6
  Requires-Python: >=3.8
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fal
3
- Version: 1.19.2
3
+ Version: 1.20.1
4
4
  Summary: fal is an easy-to-use Serverless Python Framework
5
5
  Author: Features & Labels <support@fal.ai>
6
6
  Requires-Python: >=3.8
@@ -176,5 +176,6 @@ tests/mainify_package/utils.py
176
176
  tests/toolkit/file_test.py
177
177
  tests/toolkit/image_test.py
178
178
  tests/toolkit/test_types.py
179
+ tests/toolkit/file/providers/test_fal_retry.py
179
180
  tests/toolkit/utils/retry.py
180
181
  tools/demo_script.py
@@ -17,5 +17,5 @@ __version__: str
17
17
  __version_tuple__: VERSION_TUPLE
18
18
  version_tuple: VERSION_TUPLE
19
19
 
20
- __version__ = version = '1.19.2'
21
- __version_tuple__ = version_tuple = (1, 19, 2)
20
+ __version__ = version = '1.20.1'
21
+ __version_tuple__ = version_tuple = (1, 20, 1)
@@ -1183,6 +1183,9 @@ class BaseServable:
1183
1183
  app_info.labels(version=self.version).set(1)
1184
1184
 
1185
1185
  app = self._build_app()
1186
+
1187
+ # We use the default workers=1 config because setup function can be heavy
1188
+ # and it runs once per worker.
1186
1189
  server = Server(
1187
1190
  config=Config(app, host="0.0.0.0", port=8080, timeout_keep_alive=300)
1188
1191
  )
@@ -48,11 +48,19 @@ def add_parser(main_subparsers, parents):
48
48
  subparsers = parser.add_subparsers(
49
49
  title="Commands",
50
50
  metavar="command",
51
+ dest="cmd",
51
52
  required=True,
52
53
  parser_class=FalClientParser,
53
54
  )
54
55
 
55
- list_parser = subparsers.add_parser("list", aliases=["ls"], parents=parents)
56
+ list_help = "List files."
57
+ list_parser = subparsers.add_parser(
58
+ "list",
59
+ aliases=["ls"],
60
+ description=list_help,
61
+ help=list_help,
62
+ parents=parents,
63
+ )
56
64
  list_parser.add_argument(
57
65
  "path",
58
66
  nargs="?",
@@ -62,7 +70,13 @@ def add_parser(main_subparsers, parents):
62
70
  )
63
71
  list_parser.set_defaults(func=_list)
64
72
 
65
- download_parser = subparsers.add_parser("download", parents=parents)
73
+ download_help = "Download files."
74
+ download_parser = subparsers.add_parser(
75
+ "download",
76
+ description=download_help,
77
+ help=download_help,
78
+ parents=parents,
79
+ )
66
80
  download_parser.add_argument(
67
81
  "remote_path", type=str, help="Remote path to download"
68
82
  )
@@ -71,12 +85,24 @@ def add_parser(main_subparsers, parents):
71
85
  )
72
86
  download_parser.set_defaults(func=_download)
73
87
 
74
- upload_parser = subparsers.add_parser("upload", parents=parents)
88
+ upload_help = "Upload files."
89
+ upload_parser = subparsers.add_parser(
90
+ "upload",
91
+ description=upload_help,
92
+ help=upload_help,
93
+ parents=parents,
94
+ )
75
95
  upload_parser.add_argument("local_path", type=str, help="Local path to upload")
76
96
  upload_parser.add_argument("remote_path", type=str, help="Remote path to upload to")
77
97
  upload_parser.set_defaults(func=_upload)
78
98
 
79
- upload_url_parser = subparsers.add_parser("upload-url", parents=parents)
99
+ upload_url_help = "Upload file from URL."
100
+ upload_url_parser = subparsers.add_parser(
101
+ "upload-url",
102
+ description=upload_url_help,
103
+ help=upload_url_help,
104
+ parents=parents,
105
+ )
80
106
  upload_url_parser.add_argument("url", type=str, help="URL to upload")
81
107
  upload_url_parser.add_argument(
82
108
  "remote_path", type=str, help="Remote path to upload to"
@@ -9,7 +9,7 @@ from contextlib import contextmanager
9
9
  from dataclasses import dataclass
10
10
  from datetime import datetime, timezone
11
11
  from pathlib import Path
12
- from typing import Generator, Generic, TypeVar
12
+ from typing import Any, Generator, Generic, TypeVar
13
13
  from urllib.error import HTTPError
14
14
  from urllib.parse import urlparse, urlunparse
15
15
  from urllib.request import Request, urlopen
@@ -26,15 +26,46 @@ _FAL_CDN_V3 = "https://v3.fal.media"
26
26
  DEFAULT_REQUEST_TIMEOUT = 10
27
27
  PUT_REQUEST_TIMEOUT = 5 * 60
28
28
 
29
+ MAX_ATTEMPTS = 5
30
+ BASE_DELAY = 0.1
31
+ MAX_DELAY = 30
32
+ RETRY_CODES = [408, 409, 429, 500, 502, 503, 504]
33
+
29
34
 
30
35
  @contextmanager
31
36
  def _urlopen(
32
- request: Request, timeout: int = DEFAULT_REQUEST_TIMEOUT
37
+ request: Request,
38
+ timeout: int = DEFAULT_REQUEST_TIMEOUT,
33
39
  ) -> Generator[addinfourl, None, None]:
34
40
  with urlopen(request, timeout=timeout) as response:
35
41
  yield response
36
42
 
37
43
 
44
+ def _should_retry(exc: Exception) -> bool:
45
+ if isinstance(exc, HTTPError) and exc.code in RETRY_CODES:
46
+ return True
47
+
48
+ return False
49
+
50
+
51
+ @contextmanager
52
+ def _maybe_retry_request(
53
+ request: Request,
54
+ **kwargs: Any,
55
+ ) -> Generator[addinfourl, None, None]:
56
+ _urlopen_with_retry = retry(
57
+ max_retries=MAX_ATTEMPTS,
58
+ base_delay=BASE_DELAY,
59
+ max_delay=MAX_DELAY,
60
+ backoff_type="exponential",
61
+ jitter=True,
62
+ should_retry=_should_retry,
63
+ )(_urlopen)
64
+
65
+ with _urlopen_with_retry(request, **kwargs) as response:
66
+ yield response
67
+
68
+
38
69
  @dataclass
39
70
  class FalV2Token:
40
71
  token: str
@@ -92,7 +123,7 @@ class FalV2TokenManager:
92
123
  data=b"{}",
93
124
  method="POST",
94
125
  )
95
- with _urlopen(req) as response:
126
+ with _maybe_retry_request(req) as response:
96
127
  result = json.load(response)
97
128
 
98
129
  parsed_base_url = urlparse(result["base_url"])
@@ -137,7 +168,6 @@ LIFECYCLE_PREFERENCE: VariableReference[dict[str, str] | None] = VariableReferen
137
168
 
138
169
  @dataclass
139
170
  class FalFileRepositoryBase(FileRepository):
140
- @retry(max_retries=3, base_delay=1, backoff_type="exponential", jitter=True)
141
171
  def _save(
142
172
  self, file: FileData, storage_type: str, headers: dict[str, str] | None = None
143
173
  ) -> str:
@@ -171,7 +201,7 @@ class FalFileRepositoryBase(FileRepository):
171
201
  headers=headers,
172
202
  method="POST",
173
203
  )
174
- with _urlopen(req) as response:
204
+ with _maybe_retry_request(req) as response:
175
205
  result = json.load(response)
176
206
 
177
207
  upload_url = result["upload_url"]
@@ -188,7 +218,7 @@ class FalFileRepositoryBase(FileRepository):
188
218
  headers={"Content-Type": file.content_type},
189
219
  )
190
220
 
191
- with _urlopen(req, timeout=PUT_REQUEST_TIMEOUT):
221
+ with _maybe_retry_request(req, timeout=PUT_REQUEST_TIMEOUT):
192
222
  pass
193
223
 
194
224
  return result["file_url"]
@@ -265,7 +295,7 @@ class MultipartUploadGCS:
265
295
  ).encode(),
266
296
  )
267
297
 
268
- with _urlopen(req) as response:
298
+ with _maybe_retry_request(req) as response:
269
299
  result = json.load(response)
270
300
  self._access_url = result["file_url"]
271
301
  self._upload_url = result["upload_url"]
@@ -275,7 +305,6 @@ class MultipartUploadGCS:
275
305
  f"Error initiating upload. Status {exc.status}: {exc.reason}"
276
306
  )
277
307
 
278
- @retry(max_retries=5, base_delay=1, backoff_type="exponential", jitter=True)
279
308
  def upload_part(self, part_number: int, data: bytes) -> None:
280
309
  initiate_upload_url = self.upload_url + f"/{part_number}"
281
310
  req = Request(
@@ -285,7 +314,7 @@ class MultipartUploadGCS:
285
314
  )
286
315
 
287
316
  try:
288
- with _urlopen(req) as response:
317
+ with _maybe_retry_request(req) as response:
289
318
  result = json.load(response)
290
319
  upload_url = result["upload_url"]
291
320
  except HTTPError as exc:
@@ -301,7 +330,7 @@ class MultipartUploadGCS:
301
330
  )
302
331
 
303
332
  try:
304
- with _urlopen(req, timeout=PUT_REQUEST_TIMEOUT) as resp:
333
+ with _maybe_retry_request(req, timeout=PUT_REQUEST_TIMEOUT) as resp:
305
334
  self._parts.append(
306
335
  {
307
336
  "part_number": part_number,
@@ -331,7 +360,7 @@ class MultipartUploadGCS:
331
360
  }
332
361
  ).encode(),
333
362
  )
334
- with _urlopen(req):
363
+ with _maybe_retry_request(req):
335
364
  pass
336
365
  except HTTPError as e:
337
366
  raise FileUploadException(
@@ -427,7 +456,6 @@ class FalFileRepository(FalFileRepositoryBase):
427
456
  if object_lifecycle_preference:
428
457
  headers["X-Fal-Object-Lifecycle"] = json.dumps(object_lifecycle_preference)
429
458
 
430
- @retry(max_retries=3, base_delay=1, backoff_type="exponential", jitter=True)
431
459
  def save(
432
460
  self,
433
461
  file: FileData,
@@ -536,7 +564,7 @@ class MultipartUpload:
536
564
  }
537
565
  ).encode(),
538
566
  )
539
- with _urlopen(req) as response:
567
+ with _maybe_retry_request(req) as response:
540
568
  result = json.load(response)
541
569
  self._upload_url = result["upload_url"]
542
570
  self._file_url = result["file_url"]
@@ -556,7 +584,7 @@ class MultipartUpload:
556
584
  )
557
585
 
558
586
  try:
559
- with _urlopen(req, timeout=PUT_REQUEST_TIMEOUT) as resp:
587
+ with _maybe_retry_request(req, timeout=PUT_REQUEST_TIMEOUT) as resp:
560
588
  self._parts.append(
561
589
  {
562
590
  "part_number": part_number,
@@ -581,7 +609,7 @@ class MultipartUpload:
581
609
  },
582
610
  data=json.dumps({"parts": self._parts}).encode(),
583
611
  )
584
- with _urlopen(req):
612
+ with _maybe_retry_request(req):
585
613
  pass
586
614
  except HTTPError as e:
587
615
  raise FileUploadException(
@@ -734,7 +762,7 @@ class MultipartUploadV3:
734
762
  ).encode(),
735
763
  )
736
764
 
737
- with _urlopen(req) as response:
765
+ with _maybe_retry_request(req) as response:
738
766
  result = json.load(response)
739
767
  self._access_url = result["file_url"]
740
768
  self._upload_url = result["upload_url"]
@@ -744,7 +772,6 @@ class MultipartUploadV3:
744
772
  f"Error initiating upload. Status {exc.status}: {exc.reason}"
745
773
  )
746
774
 
747
- @retry(max_retries=5, base_delay=1, backoff_type="exponential", jitter=True)
748
775
  def upload_part(self, part_number: int, data: bytes) -> None:
749
776
  parsed = urlparse(self.upload_url)
750
777
  part_path = parsed.path + f"/{part_number}"
@@ -760,7 +787,7 @@ class MultipartUploadV3:
760
787
  )
761
788
 
762
789
  try:
763
- with _urlopen(req, timeout=PUT_REQUEST_TIMEOUT) as resp:
790
+ with _maybe_retry_request(req, timeout=PUT_REQUEST_TIMEOUT) as resp:
764
791
  self._parts.append(
765
792
  {
766
793
  "partNumber": part_number,
@@ -788,7 +815,7 @@ class MultipartUploadV3:
788
815
  },
789
816
  data=json.dumps({"parts": self._parts}).encode(),
790
817
  )
791
- with _urlopen(req):
818
+ with _maybe_retry_request(req):
792
819
  pass
793
820
  except HTTPError as e:
794
821
  raise FileUploadException(
@@ -928,7 +955,7 @@ class InternalMultipartUploadV3:
928
955
  "X-Fal-File-Name": self.file_name,
929
956
  },
930
957
  )
931
- with _urlopen(req) as response:
958
+ with _maybe_retry_request(req) as response:
932
959
  result = json.load(response)
933
960
  self._access_url = result["access_url"]
934
961
  self._upload_id = result["uploadId"]
@@ -938,7 +965,6 @@ class InternalMultipartUploadV3:
938
965
  f"Error initiating upload. Status {exc.status}: {exc.reason}"
939
966
  )
940
967
 
941
- @retry(max_retries=5, base_delay=1, backoff_type="exponential", jitter=True)
942
968
  def upload_part(self, part_number: int, data: bytes) -> None:
943
969
  url = f"{self.access_url}/multipart/{self.upload_id}/{part_number}"
944
970
 
@@ -953,7 +979,7 @@ class InternalMultipartUploadV3:
953
979
  )
954
980
 
955
981
  try:
956
- with _urlopen(req, timeout=PUT_REQUEST_TIMEOUT) as resp:
982
+ with _maybe_retry_request(req, timeout=PUT_REQUEST_TIMEOUT) as resp:
957
983
  self._parts.append(
958
984
  {
959
985
  "partNumber": part_number,
@@ -979,7 +1005,7 @@ class InternalMultipartUploadV3:
979
1005
  },
980
1006
  data=json.dumps({"parts": self._parts}).encode(),
981
1007
  )
982
- with _urlopen(req):
1008
+ with _maybe_retry_request(req):
983
1009
  pass
984
1010
  except HTTPError as e:
985
1011
  raise FileUploadException(
@@ -1067,7 +1093,6 @@ class InternalMultipartUploadV3:
1067
1093
 
1068
1094
  @dataclass
1069
1095
  class FalFileRepositoryV2(FalFileRepositoryBase):
1070
- @retry(max_retries=3, base_delay=1, backoff_type="exponential", jitter=True)
1071
1096
  def save(
1072
1097
  self,
1073
1098
  file: FileData,
@@ -1105,7 +1130,7 @@ class FalFileRepositoryV2(FalFileRepositoryBase):
1105
1130
  headers=headers,
1106
1131
  method="PUT",
1107
1132
  )
1108
- with _urlopen(req, timeout=PUT_REQUEST_TIMEOUT) as response:
1133
+ with _maybe_retry_request(req, timeout=PUT_REQUEST_TIMEOUT) as response:
1109
1134
  result = json.load(response)
1110
1135
 
1111
1136
  return result["file_url"]
@@ -1177,7 +1202,6 @@ class FalCDNFileRepository(FileRepository):
1177
1202
  object_lifecycle_preference
1178
1203
  )
1179
1204
 
1180
- @retry(max_retries=3, base_delay=1, backoff_type="exponential", jitter=True)
1181
1205
  def save(
1182
1206
  self,
1183
1207
  file: FileData,
@@ -1199,7 +1223,7 @@ class FalCDNFileRepository(FileRepository):
1199
1223
  url = os.getenv("FAL_CDN_HOST", _FAL_CDN) + "/files/upload"
1200
1224
  request = Request(url, headers=headers, method="POST", data=file.data)
1201
1225
  try:
1202
- with _urlopen(request) as response:
1226
+ with _maybe_retry_request(request) as response:
1203
1227
  result = json.load(response)
1204
1228
  except HTTPError as e:
1205
1229
  raise FileUploadException(
@@ -1236,7 +1260,6 @@ class FalFileRepositoryV3(FileRepository):
1236
1260
  "User-Agent": "fal/0.1.0",
1237
1261
  }
1238
1262
 
1239
- @retry(max_retries=3, base_delay=1, backoff_type="exponential", jitter=True)
1240
1263
  def save(
1241
1264
  self,
1242
1265
  file: FileData,
@@ -1279,7 +1302,7 @@ class FalFileRepositoryV3(FileRepository):
1279
1302
  ).encode(),
1280
1303
  )
1281
1304
  try:
1282
- with _urlopen(request) as response:
1305
+ with _maybe_retry_request(request) as response:
1283
1306
  result = json.load(response)
1284
1307
  file_url = result["file_url"]
1285
1308
  upload_url = result["upload_url"]
@@ -1295,7 +1318,7 @@ class FalFileRepositoryV3(FileRepository):
1295
1318
  data=file.data,
1296
1319
  )
1297
1320
  try:
1298
- with _urlopen(request, timeout=PUT_REQUEST_TIMEOUT):
1321
+ with _maybe_retry_request(request, timeout=PUT_REQUEST_TIMEOUT):
1299
1322
  pass
1300
1323
  except HTTPError as e:
1301
1324
  raise FileUploadException(
@@ -1358,7 +1381,6 @@ class InternalFalFileRepositoryV3(FileRepository):
1358
1381
  if object_lifecycle_preference:
1359
1382
  headers["X-Fal-Object-Lifecycle"] = json.dumps(object_lifecycle_preference)
1360
1383
 
1361
- @retry(max_retries=3, base_delay=1, backoff_type="exponential", jitter=True)
1362
1384
  def save(
1363
1385
  self,
1364
1386
  file: FileData,
@@ -1393,7 +1415,7 @@ class InternalFalFileRepositoryV3(FileRepository):
1393
1415
  url = os.getenv("FAL_CDN_V3_HOST", _FAL_CDN_V3) + "/files/upload"
1394
1416
  request = Request(url, headers=headers, method="POST", data=file.data)
1395
1417
  try:
1396
- with _urlopen(request) as response:
1418
+ with _maybe_retry_request(request) as response:
1397
1419
  result = json.load(response)
1398
1420
  except HTTPError as e:
1399
1421
  raise FileUploadException(
@@ -2,7 +2,7 @@ import functools
2
2
  import random
3
3
  import time
4
4
  import traceback
5
- from typing import Any, Callable, Literal
5
+ from typing import Any, Callable, Literal, Optional
6
6
 
7
7
  BackoffType = Literal["exponential", "fixed"]
8
8
 
@@ -13,6 +13,7 @@ def retry(
13
13
  max_delay: float = 60.0,
14
14
  backoff_type: BackoffType = "exponential",
15
15
  jitter: bool = False,
16
+ should_retry: Optional[Callable[[Exception], bool]] = None,
16
17
  ) -> Callable:
17
18
  def decorator(func: Callable) -> Callable:
18
19
  @functools.wraps(func)
@@ -22,6 +23,9 @@ def retry(
22
23
  try:
23
24
  return func(*args, **kwargs)
24
25
  except Exception as e:
26
+ if should_retry is not None and not should_retry(e):
27
+ raise
28
+
25
29
  retries += 1
26
30
  print(f"Retrying {retries} of {max_retries}...")
27
31
  if retries == max_retries:
@@ -5,7 +5,7 @@ import subprocess
5
5
  import time
6
6
  import uuid
7
7
  from contextlib import contextmanager, suppress
8
- from datetime import datetime, timedelta
8
+ from datetime import datetime, timedelta, timezone
9
9
  from typing import Generator, List, Tuple
10
10
 
11
11
  import httpx
@@ -626,7 +626,9 @@ def test_app_client_async(test_sleep_app: str):
626
626
  # If the logging subsystem is not working for some nodes, this test will flake
627
627
  @pytest.mark.flaky(max_runs=10)
628
628
  def test_traceback_logs(test_exception_app: AppClient):
629
- date = (datetime.utcnow() - timedelta(seconds=1)).isoformat()
629
+ date = (
630
+ datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(seconds=1)
631
+ ).isoformat()
630
632
 
631
633
  with pytest.raises(AppClientError):
632
634
  test_exception_app.fail({})
@@ -0,0 +1,165 @@
1
+ from __future__ import annotations
2
+
3
+ from unittest import mock
4
+ from urllib.error import HTTPError
5
+ from urllib.request import Request
6
+
7
+ import pytest
8
+
9
+ from fal.toolkit.file.providers.fal import _maybe_retry_request
10
+
11
+ RETRY_CODES = [408, 409, 429, 500, 502, 503, 504]
12
+ NON_RETRY_CODES = [400, 401, 403, 404, 422]
13
+
14
+
15
+ class MockResponse:
16
+ """Mock response object that mimics urllib.response.addinfourl"""
17
+
18
+ def __init__(self, data: str = '{"result": "success"}', status: int = 200):
19
+ self.data = data.encode()
20
+ self.status = status
21
+ self.headers = {"Content-Type": "application/json"}
22
+
23
+ def read(self):
24
+ return self.data
25
+
26
+ def __enter__(self):
27
+ return self
28
+
29
+ def __exit__(self, exc_type, exc_val, exc_tb):
30
+ pass
31
+
32
+
33
+ def test_successful_request_no_retry():
34
+ request = Request("https://example.com/test")
35
+
36
+ with mock.patch("fal.toolkit.file.providers.fal._urlopen") as mock_urlopen:
37
+ mock_response = MockResponse()
38
+ mock_context = mock.MagicMock()
39
+ mock_context.__enter__.return_value = mock_response
40
+ mock_context.__exit__.return_value = None
41
+ mock_urlopen.return_value = mock_context
42
+
43
+ with _maybe_retry_request(request) as response:
44
+ assert response == mock_response
45
+
46
+ assert mock_urlopen.call_count == 1
47
+
48
+
49
+ @pytest.mark.parametrize("error_code", RETRY_CODES)
50
+ def test_retry_on_retryable_http_codes(error_code):
51
+ request = Request("https://example.com/test")
52
+
53
+ call_count = 0
54
+
55
+ def mock_urlopen_side_effect(*args, **kwargs):
56
+ nonlocal call_count
57
+ call_count += 1
58
+
59
+ if call_count == 1:
60
+ raise HTTPError(
61
+ url="https://example.com/test",
62
+ code=error_code,
63
+ msg=f"HTTP {error_code} Error",
64
+ hdrs={},
65
+ fp=None,
66
+ )
67
+ else:
68
+ mock_response = MockResponse()
69
+ mock_context = mock.MagicMock()
70
+ mock_context.__enter__.return_value = mock_response
71
+ mock_context.__exit__.return_value = None
72
+ return mock_context
73
+
74
+ with mock.patch(
75
+ "fal.toolkit.file.providers.fal._urlopen", side_effect=mock_urlopen_side_effect
76
+ ):
77
+ with mock.patch(
78
+ "fal.toolkit.utils.retry.time.sleep"
79
+ ): # Mock sleep to speed up tests
80
+ with _maybe_retry_request(request) as response:
81
+ assert response is not None
82
+
83
+ assert call_count == 2
84
+
85
+
86
+ @pytest.mark.parametrize("error_code", NON_RETRY_CODES)
87
+ def test_no_retry_on_non_retryable_http_codes(error_code):
88
+ request = Request("https://example.com/test")
89
+
90
+ call_count = 0
91
+
92
+ def mock_urlopen_side_effect(*args, **kwargs):
93
+ nonlocal call_count
94
+ call_count += 1
95
+ raise HTTPError(
96
+ url="https://example.com/test",
97
+ code=error_code,
98
+ msg=f"HTTP {error_code} Error",
99
+ hdrs={},
100
+ fp=None,
101
+ )
102
+
103
+ with mock.patch(
104
+ "fal.toolkit.file.providers.fal._urlopen", side_effect=mock_urlopen_side_effect
105
+ ):
106
+ with pytest.raises(HTTPError) as exc_info:
107
+ with _maybe_retry_request(request):
108
+ pass
109
+
110
+ assert exc_info.value.code == error_code
111
+ assert call_count == 1
112
+
113
+
114
+ def test_non_http_exception_not_retried():
115
+ """Test that non-HTTP exceptions are not retried"""
116
+ request = Request("https://example.com/test")
117
+
118
+ call_count = 0
119
+
120
+ def mock_urlopen_side_effect(*args, **kwargs):
121
+ nonlocal call_count
122
+ call_count += 1
123
+ raise ValueError("Network error")
124
+
125
+ with mock.patch(
126
+ "fal.toolkit.file.providers.fal._urlopen", side_effect=mock_urlopen_side_effect
127
+ ):
128
+ with pytest.raises(ValueError) as exc_info:
129
+ with _maybe_retry_request(request):
130
+ pass
131
+
132
+ assert str(exc_info.value) == "Network error"
133
+ assert call_count == 1
134
+
135
+
136
+ def test_max_retries_exhausted_for_retryable_errors():
137
+ """Test that retries are exhausted after MAX_ATTEMPTS for retryable HTTP errors"""
138
+ request = Request("https://example.com/test")
139
+
140
+ call_count = 0
141
+
142
+ def mock_urlopen_side_effect(*args, **kwargs):
143
+ nonlocal call_count
144
+ call_count += 1
145
+ # Always fail with a retryable error
146
+ raise HTTPError(
147
+ url="https://example.com/test",
148
+ code=500,
149
+ msg="Internal Server Error",
150
+ hdrs={},
151
+ fp=None,
152
+ )
153
+
154
+ with mock.patch(
155
+ "fal.toolkit.file.providers.fal._urlopen", side_effect=mock_urlopen_side_effect
156
+ ):
157
+ with mock.patch(
158
+ "fal.toolkit.utils.retry.time.sleep"
159
+ ): # Mock sleep to speed up tests
160
+ with pytest.raises(HTTPError) as exc_info:
161
+ with _maybe_retry_request(request):
162
+ pass
163
+
164
+ assert exc_info.value.code == 500
165
+ assert call_count == 5
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes