fal 1.2.4__tar.gz → 1.3.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 (161) hide show
  1. {fal-1.2.4 → fal-1.3.1}/PKG-INFO +1 -1
  2. {fal-1.2.4 → fal-1.3.1}/fal.egg-info/PKG-INFO +1 -1
  3. {fal-1.2.4 → fal-1.3.1}/fal.egg-info/SOURCES.txt +1 -0
  4. {fal-1.2.4 → fal-1.3.1}/src/fal/__main__.py +3 -1
  5. {fal-1.2.4 → fal-1.3.1}/src/fal/_fal_version.py +2 -2
  6. {fal-1.2.4 → fal-1.3.1}/src/fal/api.py +37 -5
  7. {fal-1.2.4 → fal-1.3.1}/src/fal/app.py +11 -4
  8. {fal-1.2.4 → fal-1.3.1}/src/fal/cli/deploy.py +14 -9
  9. {fal-1.2.4 → fal-1.3.1}/src/fal/cli/run.py +2 -1
  10. fal-1.3.1/src/fal/exceptions/__init__.py +4 -0
  11. fal-1.3.1/src/fal/exceptions/_base.py +51 -0
  12. fal-1.3.1/src/fal/exceptions/_cuda.py +44 -0
  13. {fal-1.2.4 → fal-1.3.1}/src/fal/toolkit/file/file.py +21 -4
  14. fal-1.3.1/src/fal/toolkit/file/providers/fal.py +319 -0
  15. {fal-1.2.4 → fal-1.3.1}/src/fal/toolkit/file/types.py +18 -0
  16. {fal-1.2.4 → fal-1.3.1}/src/fal/utils.py +13 -2
  17. {fal-1.2.4 → fal-1.3.1}/tests/test_apps.py +62 -16
  18. {fal-1.2.4 → fal-1.3.1}/tests/test_stability.py +18 -8
  19. fal-1.2.4/src/fal/exceptions/__init__.py +0 -3
  20. fal-1.2.4/src/fal/exceptions/_base.py +0 -7
  21. fal-1.2.4/src/fal/toolkit/file/providers/fal.py +0 -143
  22. {fal-1.2.4 → fal-1.3.1}/.gitignore +0 -0
  23. {fal-1.2.4 → fal-1.3.1}/README.md +0 -0
  24. {fal-1.2.4 → fal-1.3.1}/fal.egg-info/dependency_links.txt +0 -0
  25. {fal-1.2.4 → fal-1.3.1}/fal.egg-info/entry_points.txt +0 -0
  26. {fal-1.2.4 → fal-1.3.1}/fal.egg-info/requires.txt +0 -0
  27. {fal-1.2.4 → fal-1.3.1}/fal.egg-info/top_level.txt +0 -0
  28. {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/README.md +0 -0
  29. {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/__init__.py +0 -0
  30. {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/api/__init__.py +0 -0
  31. {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/api/applications/__init__.py +0 -0
  32. {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/api/applications/app_metadata.py +0 -0
  33. {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/api/billing/__init__.py +0 -0
  34. {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/api/billing/get_user_details.py +0 -0
  35. {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/api/comfy/__init__.py +0 -0
  36. {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/api/comfy/create_workflow.py +0 -0
  37. {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/api/comfy/delete_workflow.py +0 -0
  38. {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/api/comfy/get_workflow.py +0 -0
  39. {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/api/comfy/list_user_workflows.py +0 -0
  40. {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/api/comfy/update_workflow.py +0 -0
  41. {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/api/files/__init__.py +0 -0
  42. {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/api/files/check_dir_hash.py +0 -0
  43. {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/api/files/upload_local_file.py +0 -0
  44. {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/api/users/__init__.py +0 -0
  45. {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/api/users/get_current_user.py +0 -0
  46. {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/api/workflows/__init__.py +0 -0
  47. {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/api/workflows/create_workflow.py +0 -0
  48. {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/api/workflows/delete_workflow.py +0 -0
  49. {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/api/workflows/get_workflow.py +0 -0
  50. {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/api/workflows/list_user_workflows.py +0 -0
  51. {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/api/workflows/update_workflow.py +0 -0
  52. {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/client.py +0 -0
  53. {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/errors.py +0 -0
  54. {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/__init__.py +0 -0
  55. {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/app_metadata_response_app_metadata.py +0 -0
  56. {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/body_upload_local_file.py +0 -0
  57. {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/comfy_workflow_detail.py +0 -0
  58. {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/comfy_workflow_item.py +0 -0
  59. {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/comfy_workflow_schema.py +0 -0
  60. {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/comfy_workflow_schema_extra_data.py +0 -0
  61. {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/comfy_workflow_schema_fal_inputs.py +0 -0
  62. {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/comfy_workflow_schema_fal_inputs_dev_info.py +0 -0
  63. {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/comfy_workflow_schema_prompt.py +0 -0
  64. {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/current_user.py +0 -0
  65. {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/customer_details.py +0 -0
  66. {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/hash_check.py +0 -0
  67. {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/http_validation_error.py +0 -0
  68. {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/lock_reason.py +0 -0
  69. {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/page_comfy_workflow_item.py +0 -0
  70. {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/page_workflow_item.py +0 -0
  71. {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/team_role.py +0 -0
  72. {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/typed_comfy_workflow.py +0 -0
  73. {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/typed_comfy_workflow_update.py +0 -0
  74. {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/typed_workflow.py +0 -0
  75. {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/typed_workflow_update.py +0 -0
  76. {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/user_member.py +0 -0
  77. {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/validation_error.py +0 -0
  78. {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/workflow_contents.py +0 -0
  79. {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/workflow_contents_metadata.py +0 -0
  80. {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/workflow_contents_nodes.py +0 -0
  81. {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/workflow_contents_output.py +0 -0
  82. {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/workflow_detail.py +0 -0
  83. {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/workflow_detail_contents.py +0 -0
  84. {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/workflow_item.py +0 -0
  85. {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/workflow_node.py +0 -0
  86. {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/workflow_node_type.py +0 -0
  87. {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/workflow_schema.py +0 -0
  88. {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/workflow_schema_input.py +0 -0
  89. {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/workflow_schema_output.py +0 -0
  90. {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/py.typed +0 -0
  91. {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/types.py +0 -0
  92. {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/pyproject.toml +0 -0
  93. {fal-1.2.4 → fal-1.3.1}/openapi_rest.config.yaml +0 -0
  94. {fal-1.2.4 → fal-1.3.1}/pyproject.toml +0 -0
  95. {fal-1.2.4 → fal-1.3.1}/setup.cfg +0 -0
  96. {fal-1.2.4 → fal-1.3.1}/src/fal/__init__.py +0 -0
  97. {fal-1.2.4 → fal-1.3.1}/src/fal/_serialization.py +0 -0
  98. {fal-1.2.4 → fal-1.3.1}/src/fal/_version.py +0 -0
  99. {fal-1.2.4 → fal-1.3.1}/src/fal/apps.py +0 -0
  100. {fal-1.2.4 → fal-1.3.1}/src/fal/auth/__init__.py +0 -0
  101. {fal-1.2.4 → fal-1.3.1}/src/fal/auth/auth0.py +0 -0
  102. {fal-1.2.4 → fal-1.3.1}/src/fal/auth/local.py +0 -0
  103. {fal-1.2.4 → fal-1.3.1}/src/fal/cli/__init__.py +0 -0
  104. {fal-1.2.4 → fal-1.3.1}/src/fal/cli/apps.py +0 -0
  105. {fal-1.2.4 → fal-1.3.1}/src/fal/cli/auth.py +0 -0
  106. {fal-1.2.4 → fal-1.3.1}/src/fal/cli/create.py +0 -0
  107. {fal-1.2.4 → fal-1.3.1}/src/fal/cli/debug.py +0 -0
  108. {fal-1.2.4 → fal-1.3.1}/src/fal/cli/doctor.py +0 -0
  109. {fal-1.2.4 → fal-1.3.1}/src/fal/cli/keys.py +0 -0
  110. {fal-1.2.4 → fal-1.3.1}/src/fal/cli/main.py +0 -0
  111. {fal-1.2.4 → fal-1.3.1}/src/fal/cli/parser.py +0 -0
  112. {fal-1.2.4 → fal-1.3.1}/src/fal/cli/secrets.py +0 -0
  113. {fal-1.2.4 → fal-1.3.1}/src/fal/console/__init__.py +0 -0
  114. {fal-1.2.4 → fal-1.3.1}/src/fal/console/icons.py +0 -0
  115. {fal-1.2.4 → fal-1.3.1}/src/fal/console/ux.py +0 -0
  116. {fal-1.2.4 → fal-1.3.1}/src/fal/container.py +0 -0
  117. {fal-1.2.4 → fal-1.3.1}/src/fal/exceptions/auth.py +0 -0
  118. {fal-1.2.4 → fal-1.3.1}/src/fal/flags.py +0 -0
  119. {fal-1.2.4 → fal-1.3.1}/src/fal/logging/__init__.py +0 -0
  120. {fal-1.2.4 → fal-1.3.1}/src/fal/logging/isolate.py +0 -0
  121. {fal-1.2.4 → fal-1.3.1}/src/fal/logging/style.py +0 -0
  122. {fal-1.2.4 → fal-1.3.1}/src/fal/logging/trace.py +0 -0
  123. {fal-1.2.4 → fal-1.3.1}/src/fal/logging/user.py +0 -0
  124. {fal-1.2.4 → fal-1.3.1}/src/fal/py.typed +0 -0
  125. {fal-1.2.4 → fal-1.3.1}/src/fal/rest_client.py +0 -0
  126. {fal-1.2.4 → fal-1.3.1}/src/fal/sdk.py +0 -0
  127. {fal-1.2.4 → fal-1.3.1}/src/fal/sync.py +0 -0
  128. {fal-1.2.4 → fal-1.3.1}/src/fal/toolkit/__init__.py +0 -0
  129. {fal-1.2.4 → fal-1.3.1}/src/fal/toolkit/exceptions.py +0 -0
  130. {fal-1.2.4 → fal-1.3.1}/src/fal/toolkit/file/__init__.py +0 -0
  131. {fal-1.2.4 → fal-1.3.1}/src/fal/toolkit/file/providers/gcp.py +0 -0
  132. {fal-1.2.4 → fal-1.3.1}/src/fal/toolkit/file/providers/r2.py +0 -0
  133. {fal-1.2.4 → fal-1.3.1}/src/fal/toolkit/image/__init__.py +0 -0
  134. {fal-1.2.4 → fal-1.3.1}/src/fal/toolkit/image/image.py +0 -0
  135. {fal-1.2.4 → fal-1.3.1}/src/fal/toolkit/image/nsfw_filter/__init__.py +0 -0
  136. {fal-1.2.4 → fal-1.3.1}/src/fal/toolkit/image/nsfw_filter/env.py +0 -0
  137. {fal-1.2.4 → fal-1.3.1}/src/fal/toolkit/image/nsfw_filter/inference.py +0 -0
  138. {fal-1.2.4 → fal-1.3.1}/src/fal/toolkit/image/nsfw_filter/model.py +0 -0
  139. {fal-1.2.4 → fal-1.3.1}/src/fal/toolkit/image/nsfw_filter/requirements.txt +0 -0
  140. {fal-1.2.4 → fal-1.3.1}/src/fal/toolkit/image/safety_checker.py +0 -0
  141. {fal-1.2.4 → fal-1.3.1}/src/fal/toolkit/optimize.py +0 -0
  142. {fal-1.2.4 → fal-1.3.1}/src/fal/toolkit/utils/__init__.py +0 -0
  143. {fal-1.2.4 → fal-1.3.1}/src/fal/toolkit/utils/download_utils.py +0 -0
  144. {fal-1.2.4 → fal-1.3.1}/src/fal/workflows.py +0 -0
  145. {fal-1.2.4 → fal-1.3.1}/tests/__init__.py +0 -0
  146. {fal-1.2.4 → fal-1.3.1}/tests/cli/__init__.py +0 -0
  147. {fal-1.2.4 → fal-1.3.1}/tests/cli/test_apps.py +0 -0
  148. {fal-1.2.4 → fal-1.3.1}/tests/cli/test_auth.py +0 -0
  149. {fal-1.2.4 → fal-1.3.1}/tests/cli/test_deploy.py +0 -0
  150. {fal-1.2.4 → fal-1.3.1}/tests/cli/test_keys.py +0 -0
  151. {fal-1.2.4 → fal-1.3.1}/tests/cli/test_run.py +0 -0
  152. {fal-1.2.4 → fal-1.3.1}/tests/cli/test_secrets.py +0 -0
  153. {fal-1.2.4 → fal-1.3.1}/tests/conftest.py +0 -0
  154. {fal-1.2.4 → fal-1.3.1}/tests/integration_test.py +0 -0
  155. {fal-1.2.4 → fal-1.3.1}/tests/mainify_package/__init__.py +0 -0
  156. {fal-1.2.4 → fal-1.3.1}/tests/mainify_package/impl.py +0 -0
  157. {fal-1.2.4 → fal-1.3.1}/tests/mainify_package/utils.py +0 -0
  158. {fal-1.2.4 → fal-1.3.1}/tests/mainify_target.py +0 -0
  159. {fal-1.2.4 → fal-1.3.1}/tests/toolkit/file_test.py +0 -0
  160. {fal-1.2.4 → fal-1.3.1}/tests/toolkit/image_test.py +0 -0
  161. {fal-1.2.4 → fal-1.3.1}/tools/demo_script.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: fal
3
- Version: 1.2.4
3
+ Version: 1.3.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.1
2
2
  Name: fal
3
- Version: 1.2.4
3
+ Version: 1.3.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
@@ -109,6 +109,7 @@ src/fal/console/icons.py
109
109
  src/fal/console/ux.py
110
110
  src/fal/exceptions/__init__.py
111
111
  src/fal/exceptions/_base.py
112
+ src/fal/exceptions/_cuda.py
112
113
  src/fal/exceptions/auth.py
113
114
  src/fal/logging/__init__.py
114
115
  src/fal/logging/isolate.py
@@ -1,4 +1,6 @@
1
+ import sys
2
+
1
3
  from .cli import main
2
4
 
3
5
  if __name__ == "__main__":
4
- main()
6
+ sys.exit(main())
@@ -12,5 +12,5 @@ __version__: str
12
12
  __version_tuple__: VERSION_TUPLE
13
13
  version_tuple: VERSION_TUPLE
14
14
 
15
- __version__ = version = '1.2.4'
16
- __version_tuple__ = version_tuple = (1, 2, 4)
15
+ __version__ = version = '1.3.1'
16
+ __version_tuple__ = version_tuple = (1, 3, 1)
@@ -44,7 +44,13 @@ from typing_extensions import Concatenate, ParamSpec
44
44
  import fal.flags as flags
45
45
  from fal._serialization import include_modules_from, patch_pickle
46
46
  from fal.container import ContainerImage
47
- from fal.exceptions import FalServerlessException
47
+ from fal.exceptions import (
48
+ AppException,
49
+ CUDAOutOfMemoryException,
50
+ FalServerlessException,
51
+ FieldException,
52
+ )
53
+ from fal.exceptions._cuda import _is_cuda_oom_exception
48
54
  from fal.logging.isolate import IsolateLogPrinter
49
55
  from fal.sdk import (
50
56
  FAL_SERVERLESS_DEFAULT_KEEP_ALIVE,
@@ -1002,13 +1008,39 @@ class BaseServable:
1002
1008
  # If it's not a generic 404, just return the original message.
1003
1009
  return JSONResponse({"detail": exc.detail}, 404)
1004
1010
 
1011
+ @_app.exception_handler(AppException)
1012
+ async def app_exception_handler(request: Request, exc: AppException):
1013
+ return JSONResponse({"detail": exc.message}, exc.status_code)
1014
+
1015
+ @_app.exception_handler(FieldException)
1016
+ async def field_exception_handler(request: Request, exc: FieldException):
1017
+ return JSONResponse(exc.to_pydantic_format(), exc.status_code)
1018
+
1019
+ @_app.exception_handler(CUDAOutOfMemoryException)
1020
+ async def cuda_out_of_memory_exception_handler(
1021
+ request: Request, exc: CUDAOutOfMemoryException
1022
+ ):
1023
+ return JSONResponse({"detail": exc.message}, exc.status_code)
1024
+
1005
1025
  @_app.exception_handler(Exception)
1006
1026
  async def traceback_logging_exception_handler(request: Request, exc: Exception):
1007
- print(
1008
- json.dumps(
1009
- {"traceback": "".join(traceback.format_exception(exc)[::-1])} # type: ignore
1027
+ _, MINOR, *_ = sys.version_info
1028
+
1029
+ # traceback.format_exception() has a different signature in Python >=3.10
1030
+ if MINOR >= 10:
1031
+ formatted_exception = traceback.format_exception(exc) # type: ignore
1032
+ else:
1033
+ formatted_exception = traceback.format_exception(
1034
+ type(exc), exc, exc.__traceback__
1010
1035
  )
1011
- )
1036
+
1037
+ print(json.dumps({"traceback": "".join(formatted_exception[::-1])}))
1038
+
1039
+ if _is_cuda_oom_exception(exc):
1040
+ return await cuda_out_of_memory_exception_handler(
1041
+ request, CUDAOutOfMemoryException()
1042
+ )
1043
+
1012
1044
  return JSONResponse({"detail": "Internal Server Error"}, 500)
1013
1045
 
1014
1046
  routes = self.collect_routes()
@@ -142,15 +142,14 @@ class AppClient:
142
142
  try:
143
143
  with httpx.Client() as client:
144
144
  retries = 100
145
- while retries:
146
- resp = client.get(info.url + "/health")
145
+ for _ in range(retries):
146
+ resp = client.get(info.url + "/health", timeout=60)
147
147
 
148
148
  if resp.is_success:
149
149
  break
150
- elif resp.status_code != 500:
150
+ elif resp.status_code not in (500, 404):
151
151
  resp.raise_for_status()
152
152
  time.sleep(0.1)
153
- retries -= 1
154
153
 
155
154
  client = cls(app_cls, info.url)
156
155
  yield client
@@ -206,6 +205,14 @@ class App(fal.api.BaseServable):
206
205
  "Running apps through SDK is not implemented yet."
207
206
  )
208
207
 
208
+ @classmethod
209
+ def get_endpoints(cls) -> list[str]:
210
+ return [
211
+ signature.path
212
+ for _, endpoint in inspect.getmembers(cls, inspect.isfunction)
213
+ if (signature := getattr(endpoint, "route_signature", None))
214
+ ]
215
+
209
216
  def collect_routes(self) -> dict[RouteSignature, Callable[..., Any]]:
210
217
  return {
211
218
  signature: endpoint
@@ -81,13 +81,14 @@ def _deploy(args):
81
81
 
82
82
  user = _get_user()
83
83
  host = FalServerlessHost(args.host)
84
- isolated_function, app_name, app_auth = load_function_from(
84
+ loaded = load_function_from(
85
85
  host,
86
86
  file_path,
87
87
  func_name,
88
88
  )
89
- app_name = args.app_name or app_name
90
- app_auth = args.auth or app_auth or "private"
89
+ isolated_function = loaded.function
90
+ app_name = args.app_name or loaded.app_name
91
+ app_auth = args.auth or loaded.app_auth or "private"
91
92
  app_id = host.register(
92
93
  func=isolated_function.func,
93
94
  options=isolated_function.options,
@@ -106,12 +107,16 @@ def _deploy(args):
106
107
  "Registered a new revision for function "
107
108
  f"'{app_name}' (revision='{app_id}')."
108
109
  )
109
- args.console.print(
110
- f"Playground: https://fal.ai/models/{user.username}/{app_name}"
111
- )
112
- args.console.print(
113
- f"Endpoint: https://{gateway_host}/{user.username}/{app_name}"
114
- )
110
+ args.console.print("Playground:")
111
+ for endpoint in loaded.endpoints:
112
+ args.console.print(
113
+ f"\thttps://fal.ai/models/{user.username}/{app_name}{endpoint}"
114
+ )
115
+ args.console.print("Endpoints:")
116
+ for endpoint in loaded.endpoints:
117
+ args.console.print(
118
+ f"\thttps://{gateway_host}/{user.username}/{app_name}{endpoint}"
119
+ )
115
120
 
116
121
 
117
122
  def add_parser(main_subparsers, parents):
@@ -6,7 +6,8 @@ def _run(args):
6
6
  from fal.utils import load_function_from
7
7
 
8
8
  host = FalServerlessHost(args.host)
9
- isolated_function, _, _ = load_function_from(host, *args.func_ref)
9
+ loaded = load_function_from(host, *args.func_ref)
10
+ isolated_function = loaded.function
10
11
  # let our exc handlers handle UserFunctionException
11
12
  isolated_function.reraise = False
12
13
  isolated_function()
@@ -0,0 +1,4 @@
1
+ from __future__ import annotations
2
+
3
+ from ._base import AppException, FalServerlessException, FieldException # noqa: F401
4
+ from ._cuda import CUDAOutOfMemoryException # noqa: F401
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ class FalServerlessException(Exception):
7
+ """Base exception type for fal Serverless related flows and APIs."""
8
+
9
+ pass
10
+
11
+
12
+ @dataclass
13
+ class AppException(FalServerlessException):
14
+ """
15
+ Base exception class for application-specific errors.
16
+
17
+ Attributes:
18
+ message: A descriptive message explaining the error.
19
+ status_code: The HTTP status code associated with the error.
20
+ """
21
+
22
+ message: str
23
+ status_code: int
24
+
25
+
26
+ @dataclass
27
+ class FieldException(FalServerlessException):
28
+ """Exception raised for errors related to specific fields.
29
+
30
+ Attributes:
31
+ field: The field that caused the error.
32
+ message: A descriptive message explaining the error.
33
+ status_code: The HTTP status code associated with the error. Defaults to 422
34
+ type: The type of error. Defaults to "value_error"
35
+ """
36
+
37
+ field: str
38
+ message: str
39
+ status_code: int = 422
40
+ type: str = "value_error"
41
+
42
+ def to_pydantic_format(self) -> dict[str, list[dict]]:
43
+ return dict(
44
+ detail=[
45
+ {
46
+ "loc": ["body", self.field],
47
+ "msg": self.message,
48
+ "type": self.type,
49
+ }
50
+ ]
51
+ )
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from ._base import AppException
6
+
7
+ # PyTorch error message for out of memory
8
+ _CUDA_OOM_MESSAGE = "CUDA error: out of memory"
9
+
10
+ # Special status code for CUDA out of memory errors
11
+ _CUDA_OOM_STATUS_CODE = 503
12
+
13
+
14
+ @dataclass
15
+ class CUDAOutOfMemoryException(AppException):
16
+ """Exception raised when a CUDA operation runs out of memory."""
17
+
18
+ message: str = _CUDA_OOM_MESSAGE
19
+ status_code: int = _CUDA_OOM_STATUS_CODE
20
+
21
+
22
+ # based on https://github.com/Lightning-AI/pytorch-lightning/blob/37e04d075a5532c69b8ac7457795b4345cca30cc/src/lightning/pytorch/utilities/memory.py#L49
23
+ def _is_cuda_oom_exception(exception: BaseException) -> bool:
24
+ return _is_cuda_out_of_memory(exception) or _is_cudnn_snafu(exception)
25
+
26
+
27
+ # based on https://github.com/BlackHC/toma/blob/master/toma/torch_cuda_memory.py
28
+ def _is_cuda_out_of_memory(exception: BaseException) -> bool:
29
+ return (
30
+ isinstance(exception, RuntimeError)
31
+ and len(exception.args) == 1
32
+ and "CUDA" in exception.args[0]
33
+ and "out of memory" in exception.args[0]
34
+ )
35
+
36
+
37
+ # based on https://github.com/BlackHC/toma/blob/master/toma/torch_cuda_memory.py
38
+ def _is_cudnn_snafu(exception: BaseException) -> bool:
39
+ # For/because of https://github.com/pytorch/pytorch/issues/4107
40
+ return (
41
+ isinstance(exception, RuntimeError)
42
+ and len(exception.args) == 1
43
+ and "cuDNN error: CUDNN_STATUS_NOT_SUPPORTED." in exception.args[0]
44
+ )
@@ -149,14 +149,31 @@ class File(BaseModel):
149
149
  path: str | Path,
150
150
  content_type: Optional[str] = None,
151
151
  repository: FileRepository | RepositoryId = DEFAULT_REPOSITORY,
152
+ multipart: bool | None = None,
152
153
  ) -> File:
153
154
  file_path = Path(path)
154
155
  if not file_path.exists():
155
156
  raise FileNotFoundError(f"File {file_path} does not exist")
156
- with open(file_path, "rb") as f:
157
- data = f.read()
158
- return File.from_bytes(
159
- data, content_type, file_name=file_path.name, repository=repository
157
+
158
+ repo = (
159
+ repository
160
+ if isinstance(repository, FileRepository)
161
+ else get_builtin_repository(repository)
162
+ )
163
+
164
+ content_type = content_type or "application/octet-stream"
165
+
166
+ url, data = repo.save_file(
167
+ file_path,
168
+ content_type=content_type,
169
+ multipart=multipart,
170
+ )
171
+ return cls(
172
+ url=url,
173
+ file_data=data.data if data else None,
174
+ content_type=content_type,
175
+ file_name=file_path.name,
176
+ file_size=file_path.stat().st_size,
160
177
  )
161
178
 
162
179
  def as_bytes(self) -> bytes:
@@ -0,0 +1,319 @@
1
+ from __future__ import annotations
2
+
3
+ import dataclasses
4
+ import json
5
+ import math
6
+ import os
7
+ from base64 import b64encode
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from urllib.error import HTTPError
11
+ from urllib.request import Request, urlopen
12
+
13
+ from fal.auth import key_credentials
14
+ from fal.toolkit.exceptions import FileUploadException
15
+ from fal.toolkit.file.types import FileData, FileRepository
16
+
17
+ _FAL_CDN = "https://fal.media"
18
+
19
+
20
+ @dataclass
21
+ class ObjectLifecyclePreference:
22
+ expriation_duration_seconds: int
23
+
24
+
25
+ GLOBAL_LIFECYCLE_PREFERENCE = ObjectLifecyclePreference(
26
+ expriation_duration_seconds=86400
27
+ )
28
+
29
+
30
+ @dataclass
31
+ class FalFileRepositoryBase(FileRepository):
32
+ def _save(self, file: FileData, storage_type: str) -> str:
33
+ key_creds = key_credentials()
34
+ if not key_creds:
35
+ raise FileUploadException("FAL_KEY must be set")
36
+
37
+ key_id, key_secret = key_creds
38
+ headers = {
39
+ "Authorization": f"Key {key_id}:{key_secret}",
40
+ "Accept": "application/json",
41
+ "Content-Type": "application/json",
42
+ }
43
+
44
+ grpc_host = os.environ.get("FAL_HOST", "api.alpha.fal.ai")
45
+ rest_host = grpc_host.replace("api", "rest", 1)
46
+ storage_url = (
47
+ f"https://{rest_host}/storage/upload/initiate?storage_type={storage_type}"
48
+ )
49
+
50
+ try:
51
+ req = Request(
52
+ storage_url,
53
+ data=json.dumps(
54
+ {
55
+ "file_name": file.file_name,
56
+ "content_type": file.content_type,
57
+ }
58
+ ).encode(),
59
+ headers=headers,
60
+ method="POST",
61
+ )
62
+ with urlopen(req) as response:
63
+ result = json.load(response)
64
+
65
+ upload_url = result["upload_url"]
66
+ self._upload_file(upload_url, file)
67
+
68
+ return result["file_url"]
69
+ except HTTPError as e:
70
+ raise FileUploadException(
71
+ f"Error initiating upload. Status {e.status}: {e.reason}"
72
+ )
73
+
74
+ def _upload_file(self, upload_url: str, file: FileData):
75
+ req = Request(
76
+ upload_url,
77
+ method="PUT",
78
+ data=file.data,
79
+ headers={"Content-Type": file.content_type},
80
+ )
81
+
82
+ with urlopen(req):
83
+ return
84
+
85
+
86
+ @dataclass
87
+ class FalFileRepository(FalFileRepositoryBase):
88
+ def save(self, file: FileData) -> str:
89
+ return self._save(file, "gcs")
90
+
91
+
92
+ class MultipartUpload:
93
+ MULTIPART_THRESHOLD = 100 * 1024 * 1024
94
+ MULTIPART_CHUNK_SIZE = 100 * 1024 * 1024
95
+ MULTIPART_MAX_CONCURRENCY = 10
96
+
97
+ def __init__(
98
+ self,
99
+ file_path: str | Path,
100
+ chunk_size: int | None = None,
101
+ content_type: str | None = None,
102
+ max_concurrency: int | None = None,
103
+ ) -> None:
104
+ self.file_path = file_path
105
+ self.chunk_size = chunk_size or self.MULTIPART_CHUNK_SIZE
106
+ self.content_type = content_type or "application/octet-stream"
107
+ self.max_concurrency = max_concurrency or self.MULTIPART_MAX_CONCURRENCY
108
+
109
+ self._parts: list[dict] = []
110
+
111
+ key_creds = key_credentials()
112
+ if not key_creds:
113
+ raise FileUploadException("FAL_KEY must be set")
114
+
115
+ key_id, key_secret = key_creds
116
+
117
+ self._auth_headers = {
118
+ "Authorization": f"Key {key_id}:{key_secret}",
119
+ }
120
+ grpc_host = os.environ.get("FAL_HOST", "api.alpha.fal.ai")
121
+ rest_host = grpc_host.replace("api", "rest", 1)
122
+ self._storage_upload_url = f"https://{rest_host}/storage/upload"
123
+
124
+ def create(self):
125
+ try:
126
+ req = Request(
127
+ f"{self._storage_upload_url}/initiate-multipart",
128
+ method="POST",
129
+ headers={
130
+ **self._auth_headers,
131
+ "Accept": "application/json",
132
+ "Content-Type": "application/json",
133
+ },
134
+ data=json.dumps(
135
+ {
136
+ "file_name": os.path.basename(self.file_path),
137
+ "content_type": self.content_type,
138
+ }
139
+ ).encode(),
140
+ )
141
+ with urlopen(req) as response:
142
+ result = json.load(response)
143
+ self._upload_id = result["upload_id"]
144
+ self._file_url = result["file_url"]
145
+ except HTTPError as exc:
146
+ raise FileUploadException(
147
+ f"Error initiating upload. Status {exc.status}: {exc.reason}"
148
+ )
149
+
150
+ def _upload_part(self, url: str, part_number: int) -> dict:
151
+ with open(self.file_path, "rb") as f:
152
+ start = (part_number - 1) * self.chunk_size
153
+ f.seek(start)
154
+ data = f.read(self.chunk_size)
155
+ req = Request(
156
+ url,
157
+ method="PUT",
158
+ headers={"Content-Type": self.content_type},
159
+ data=data,
160
+ )
161
+
162
+ try:
163
+ with urlopen(req) as resp:
164
+ return {
165
+ "part_number": part_number,
166
+ "etag": resp.headers["ETag"],
167
+ }
168
+ except HTTPError as exc:
169
+ raise FileUploadException(
170
+ f"Error uploading part {part_number} to {url}. "
171
+ f"Status {exc.status}: {exc.reason}"
172
+ )
173
+
174
+ def upload(self) -> None:
175
+ import concurrent.futures
176
+
177
+ parts = math.ceil(os.path.getsize(self.file_path) / self.chunk_size)
178
+ with concurrent.futures.ThreadPoolExecutor(
179
+ max_workers=self.max_concurrency
180
+ ) as executor:
181
+ futures = []
182
+ for part_number in range(1, parts + 1):
183
+ upload_url = (
184
+ f"{self._file_url}?upload_id={self._upload_id}"
185
+ f"&part_number={part_number}"
186
+ )
187
+ futures.append(
188
+ executor.submit(self._upload_part, upload_url, part_number)
189
+ )
190
+
191
+ for future in concurrent.futures.as_completed(futures):
192
+ entry = future.result()
193
+ self._parts.append(entry)
194
+
195
+ def complete(self):
196
+ url = f"{self._file_url}?upload_id={self._upload_id}"
197
+ try:
198
+ req = Request(
199
+ url,
200
+ method="POST",
201
+ headers={
202
+ "Accept": "application/json",
203
+ "Content-Type": "application/json",
204
+ },
205
+ data=json.dumps({"parts": self._parts}).encode(),
206
+ )
207
+ with urlopen(req):
208
+ pass
209
+ except HTTPError as e:
210
+ raise FileUploadException(
211
+ f"Error completing upload {url}. Status {e.status}: {e.reason}"
212
+ )
213
+
214
+ return self._file_url
215
+
216
+
217
+ @dataclass
218
+ class FalFileRepositoryV2(FalFileRepositoryBase):
219
+ def save(self, file: FileData) -> str:
220
+ return self._save(file, "fal-cdn")
221
+
222
+ def _save_multipart(
223
+ self,
224
+ file_path: str | Path,
225
+ chunk_size: int | None = None,
226
+ content_type: str | None = None,
227
+ max_concurrency: int | None = None,
228
+ ) -> str:
229
+ multipart = MultipartUpload(
230
+ file_path,
231
+ chunk_size=chunk_size,
232
+ content_type=content_type,
233
+ max_concurrency=max_concurrency,
234
+ )
235
+ multipart.create()
236
+ multipart.upload()
237
+ return multipart.complete()
238
+
239
+ def save_file(
240
+ self,
241
+ file_path: str | Path,
242
+ content_type: str,
243
+ multipart: bool | None = None,
244
+ multipart_threshold: int | None = None,
245
+ multipart_chunk_size: int | None = None,
246
+ multipart_max_concurrency: int | None = None,
247
+ ) -> tuple[str, FileData | None]:
248
+ if multipart is None:
249
+ threshold = multipart_threshold or MultipartUpload.MULTIPART_THRESHOLD
250
+ multipart = os.path.getsize(file_path) > threshold
251
+
252
+ if multipart:
253
+ url = self._save_multipart(
254
+ file_path,
255
+ chunk_size=multipart_chunk_size,
256
+ content_type=content_type,
257
+ max_concurrency=multipart_max_concurrency,
258
+ )
259
+ data = None
260
+ else:
261
+ with open(file_path, "rb") as f:
262
+ data = FileData(
263
+ f.read(),
264
+ content_type=content_type,
265
+ file_name=os.path.basename(file_path),
266
+ )
267
+ url = self.save(data)
268
+
269
+ return url, data
270
+
271
+
272
+ @dataclass
273
+ class InMemoryRepository(FileRepository):
274
+ def save(
275
+ self,
276
+ file: FileData,
277
+ ) -> str:
278
+ return f'data:{file.content_type};base64,{b64encode(file.data).decode("utf-8")}'
279
+
280
+
281
+ @dataclass
282
+ class FalCDNFileRepository(FileRepository):
283
+ def save(
284
+ self,
285
+ file: FileData,
286
+ ) -> str:
287
+ headers = {
288
+ **self.auth_headers,
289
+ "Accept": "application/json",
290
+ "Content-Type": file.content_type,
291
+ "X-Fal-File-Name": file.file_name,
292
+ "X-Fal-Object-Lifecycle-Preference": json.dumps(
293
+ dataclasses.asdict(GLOBAL_LIFECYCLE_PREFERENCE)
294
+ ),
295
+ }
296
+ url = os.getenv("FAL_CDN_HOST", _FAL_CDN) + "/files/upload"
297
+ request = Request(url, headers=headers, method="POST", data=file.data)
298
+ try:
299
+ with urlopen(request) as response:
300
+ result = json.load(response)
301
+ except HTTPError as e:
302
+ raise FileUploadException(
303
+ f"Error initiating upload. Status {e.status}: {e.reason}"
304
+ )
305
+
306
+ access_url = result["access_url"]
307
+ return access_url
308
+
309
+ @property
310
+ def auth_headers(self) -> dict[str, str]:
311
+ key_creds = key_credentials()
312
+ if not key_creds:
313
+ raise FileUploadException("FAL_KEY must be set")
314
+
315
+ key_id, key_secret = key_creds
316
+ return {
317
+ "Authorization": f"Bearer {key_id}:{key_secret}",
318
+ "User-Agent": "fal/0.1.0",
319
+ }
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  from dataclasses import dataclass
4
4
  from mimetypes import guess_extension, guess_type
5
+ from pathlib import Path
5
6
  from typing import Literal
6
7
  from uuid import uuid4
7
8
 
@@ -35,3 +36,20 @@ RepositoryId = Literal["fal", "fal_v2", "in_memory", "gcp_storage", "r2", "cdn"]
35
36
  class FileRepository:
36
37
  def save(self, data: FileData) -> str:
37
38
  raise NotImplementedError()
39
+
40
+ def save_file(
41
+ self,
42
+ file_path: str | Path,
43
+ content_type: str,
44
+ multipart: bool | None = None,
45
+ multipart_threshold: int | None = None,
46
+ multipart_chunk_size: int | None = None,
47
+ multipart_max_concurrency: int | None = None,
48
+ ) -> tuple[str, FileData | None]:
49
+ if multipart:
50
+ raise NotImplementedError()
51
+
52
+ with open(file_path, "rb") as fobj:
53
+ data = FileData(fobj.read(), content_type, Path(file_path).name)
54
+
55
+ return self.save(data), data