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.
- {fal-1.2.4 → fal-1.3.1}/PKG-INFO +1 -1
- {fal-1.2.4 → fal-1.3.1}/fal.egg-info/PKG-INFO +1 -1
- {fal-1.2.4 → fal-1.3.1}/fal.egg-info/SOURCES.txt +1 -0
- {fal-1.2.4 → fal-1.3.1}/src/fal/__main__.py +3 -1
- {fal-1.2.4 → fal-1.3.1}/src/fal/_fal_version.py +2 -2
- {fal-1.2.4 → fal-1.3.1}/src/fal/api.py +37 -5
- {fal-1.2.4 → fal-1.3.1}/src/fal/app.py +11 -4
- {fal-1.2.4 → fal-1.3.1}/src/fal/cli/deploy.py +14 -9
- {fal-1.2.4 → fal-1.3.1}/src/fal/cli/run.py +2 -1
- fal-1.3.1/src/fal/exceptions/__init__.py +4 -0
- fal-1.3.1/src/fal/exceptions/_base.py +51 -0
- fal-1.3.1/src/fal/exceptions/_cuda.py +44 -0
- {fal-1.2.4 → fal-1.3.1}/src/fal/toolkit/file/file.py +21 -4
- fal-1.3.1/src/fal/toolkit/file/providers/fal.py +319 -0
- {fal-1.2.4 → fal-1.3.1}/src/fal/toolkit/file/types.py +18 -0
- {fal-1.2.4 → fal-1.3.1}/src/fal/utils.py +13 -2
- {fal-1.2.4 → fal-1.3.1}/tests/test_apps.py +62 -16
- {fal-1.2.4 → fal-1.3.1}/tests/test_stability.py +18 -8
- fal-1.2.4/src/fal/exceptions/__init__.py +0 -3
- fal-1.2.4/src/fal/exceptions/_base.py +0 -7
- fal-1.2.4/src/fal/toolkit/file/providers/fal.py +0 -143
- {fal-1.2.4 → fal-1.3.1}/.gitignore +0 -0
- {fal-1.2.4 → fal-1.3.1}/README.md +0 -0
- {fal-1.2.4 → fal-1.3.1}/fal.egg-info/dependency_links.txt +0 -0
- {fal-1.2.4 → fal-1.3.1}/fal.egg-info/entry_points.txt +0 -0
- {fal-1.2.4 → fal-1.3.1}/fal.egg-info/requires.txt +0 -0
- {fal-1.2.4 → fal-1.3.1}/fal.egg-info/top_level.txt +0 -0
- {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/README.md +0 -0
- {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/__init__.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/api/__init__.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/api/applications/__init__.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/api/applications/app_metadata.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/api/billing/__init__.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/api/billing/get_user_details.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/api/comfy/__init__.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/api/comfy/create_workflow.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/api/comfy/delete_workflow.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/api/comfy/get_workflow.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/api/comfy/list_user_workflows.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/api/comfy/update_workflow.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/api/files/__init__.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/api/files/check_dir_hash.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/api/files/upload_local_file.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/api/users/__init__.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/api/users/get_current_user.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/api/workflows/__init__.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/api/workflows/create_workflow.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/api/workflows/delete_workflow.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/api/workflows/get_workflow.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/api/workflows/list_user_workflows.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/api/workflows/update_workflow.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/client.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/errors.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/__init__.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/app_metadata_response_app_metadata.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/body_upload_local_file.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/comfy_workflow_detail.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/comfy_workflow_item.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/comfy_workflow_schema.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/comfy_workflow_schema_extra_data.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/comfy_workflow_schema_fal_inputs.py +0 -0
- {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
- {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/comfy_workflow_schema_prompt.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/current_user.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/customer_details.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/hash_check.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/http_validation_error.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/lock_reason.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/page_comfy_workflow_item.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/page_workflow_item.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/team_role.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/typed_comfy_workflow.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/typed_comfy_workflow_update.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/typed_workflow.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/typed_workflow_update.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/user_member.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/validation_error.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/workflow_contents.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/workflow_contents_metadata.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/workflow_contents_nodes.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/workflow_contents_output.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/workflow_detail.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/workflow_detail_contents.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/workflow_item.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/workflow_node.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/workflow_node_type.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/workflow_schema.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/workflow_schema_input.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/workflow_schema_output.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/py.typed +0 -0
- {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/types.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/openapi-fal-rest/pyproject.toml +0 -0
- {fal-1.2.4 → fal-1.3.1}/openapi_rest.config.yaml +0 -0
- {fal-1.2.4 → fal-1.3.1}/pyproject.toml +0 -0
- {fal-1.2.4 → fal-1.3.1}/setup.cfg +0 -0
- {fal-1.2.4 → fal-1.3.1}/src/fal/__init__.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/src/fal/_serialization.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/src/fal/_version.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/src/fal/apps.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/src/fal/auth/__init__.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/src/fal/auth/auth0.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/src/fal/auth/local.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/src/fal/cli/__init__.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/src/fal/cli/apps.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/src/fal/cli/auth.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/src/fal/cli/create.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/src/fal/cli/debug.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/src/fal/cli/doctor.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/src/fal/cli/keys.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/src/fal/cli/main.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/src/fal/cli/parser.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/src/fal/cli/secrets.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/src/fal/console/__init__.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/src/fal/console/icons.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/src/fal/console/ux.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/src/fal/container.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/src/fal/exceptions/auth.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/src/fal/flags.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/src/fal/logging/__init__.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/src/fal/logging/isolate.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/src/fal/logging/style.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/src/fal/logging/trace.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/src/fal/logging/user.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/src/fal/py.typed +0 -0
- {fal-1.2.4 → fal-1.3.1}/src/fal/rest_client.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/src/fal/sdk.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/src/fal/sync.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/src/fal/toolkit/__init__.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/src/fal/toolkit/exceptions.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/src/fal/toolkit/file/__init__.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/src/fal/toolkit/file/providers/gcp.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/src/fal/toolkit/file/providers/r2.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/src/fal/toolkit/image/__init__.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/src/fal/toolkit/image/image.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/src/fal/toolkit/image/nsfw_filter/__init__.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/src/fal/toolkit/image/nsfw_filter/env.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/src/fal/toolkit/image/nsfw_filter/inference.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/src/fal/toolkit/image/nsfw_filter/model.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/src/fal/toolkit/image/nsfw_filter/requirements.txt +0 -0
- {fal-1.2.4 → fal-1.3.1}/src/fal/toolkit/image/safety_checker.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/src/fal/toolkit/optimize.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/src/fal/toolkit/utils/__init__.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/src/fal/toolkit/utils/download_utils.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/src/fal/workflows.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/tests/__init__.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/tests/cli/__init__.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/tests/cli/test_apps.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/tests/cli/test_auth.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/tests/cli/test_deploy.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/tests/cli/test_keys.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/tests/cli/test_run.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/tests/cli/test_secrets.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/tests/conftest.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/tests/integration_test.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/tests/mainify_package/__init__.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/tests/mainify_package/impl.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/tests/mainify_package/utils.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/tests/mainify_target.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/tests/toolkit/file_test.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/tests/toolkit/image_test.py +0 -0
- {fal-1.2.4 → fal-1.3.1}/tools/demo_script.py +0 -0
{fal-1.2.4 → fal-1.3.1}/PKG-INFO
RENAMED
|
@@ -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
|
|
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
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
84
|
+
loaded = load_function_from(
|
|
85
85
|
host,
|
|
86
86
|
file_path,
|
|
87
87
|
func_name,
|
|
88
88
|
)
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
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,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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|