fal 1.3.0__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.3.0 → fal-1.3.1}/PKG-INFO +1 -1
- {fal-1.3.0 → fal-1.3.1}/fal.egg-info/PKG-INFO +1 -1
- {fal-1.3.0 → fal-1.3.1}/src/fal/__main__.py +3 -1
- {fal-1.3.0 → fal-1.3.1}/src/fal/_fal_version.py +2 -2
- {fal-1.3.0 → fal-1.3.1}/src/fal/app.py +9 -1
- {fal-1.3.0 → fal-1.3.1}/src/fal/cli/deploy.py +14 -9
- {fal-1.3.0 → fal-1.3.1}/src/fal/cli/run.py +2 -1
- {fal-1.3.0 → fal-1.3.1}/src/fal/exceptions/_base.py +10 -9
- {fal-1.3.0 → 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.3.0 → fal-1.3.1}/src/fal/toolkit/file/types.py +18 -0
- {fal-1.3.0 → fal-1.3.1}/src/fal/utils.py +13 -2
- {fal-1.3.0 → fal-1.3.1}/tests/test_apps.py +17 -4
- {fal-1.3.0 → fal-1.3.1}/tests/test_stability.py +18 -8
- fal-1.3.0/src/fal/toolkit/file/providers/fal.py +0 -143
- {fal-1.3.0 → fal-1.3.1}/.gitignore +0 -0
- {fal-1.3.0 → fal-1.3.1}/README.md +0 -0
- {fal-1.3.0 → fal-1.3.1}/fal.egg-info/SOURCES.txt +0 -0
- {fal-1.3.0 → fal-1.3.1}/fal.egg-info/dependency_links.txt +0 -0
- {fal-1.3.0 → fal-1.3.1}/fal.egg-info/entry_points.txt +0 -0
- {fal-1.3.0 → fal-1.3.1}/fal.egg-info/requires.txt +0 -0
- {fal-1.3.0 → fal-1.3.1}/fal.egg-info/top_level.txt +0 -0
- {fal-1.3.0 → fal-1.3.1}/openapi-fal-rest/README.md +0 -0
- {fal-1.3.0 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/__init__.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/api/__init__.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/api/applications/__init__.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/api/applications/app_metadata.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/api/billing/__init__.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/api/billing/get_user_details.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/api/comfy/__init__.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/api/comfy/create_workflow.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/api/comfy/delete_workflow.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/api/comfy/get_workflow.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/api/comfy/list_user_workflows.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/api/comfy/update_workflow.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/api/files/__init__.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/api/files/check_dir_hash.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/api/files/upload_local_file.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/api/users/__init__.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/api/users/get_current_user.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/api/workflows/__init__.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/api/workflows/create_workflow.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/api/workflows/delete_workflow.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/api/workflows/get_workflow.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/api/workflows/list_user_workflows.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/api/workflows/update_workflow.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/client.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/errors.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/__init__.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/app_metadata_response_app_metadata.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/body_upload_local_file.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/comfy_workflow_detail.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/comfy_workflow_item.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/comfy_workflow_schema.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/comfy_workflow_schema_extra_data.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/comfy_workflow_schema_fal_inputs.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/comfy_workflow_schema_fal_inputs_dev_info.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/comfy_workflow_schema_prompt.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/current_user.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/customer_details.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/hash_check.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/http_validation_error.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/lock_reason.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/page_comfy_workflow_item.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/page_workflow_item.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/team_role.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/typed_comfy_workflow.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/typed_comfy_workflow_update.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/typed_workflow.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/typed_workflow_update.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/user_member.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/validation_error.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/workflow_contents.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/workflow_contents_metadata.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/workflow_contents_nodes.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/workflow_contents_output.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/workflow_detail.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/workflow_detail_contents.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/workflow_item.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/workflow_node.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/workflow_node_type.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/workflow_schema.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/workflow_schema_input.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/models/workflow_schema_output.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/py.typed +0 -0
- {fal-1.3.0 → fal-1.3.1}/openapi-fal-rest/openapi_fal_rest/types.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/openapi-fal-rest/pyproject.toml +0 -0
- {fal-1.3.0 → fal-1.3.1}/openapi_rest.config.yaml +0 -0
- {fal-1.3.0 → fal-1.3.1}/pyproject.toml +0 -0
- {fal-1.3.0 → fal-1.3.1}/setup.cfg +0 -0
- {fal-1.3.0 → fal-1.3.1}/src/fal/__init__.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/src/fal/_serialization.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/src/fal/_version.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/src/fal/api.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/src/fal/apps.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/src/fal/auth/__init__.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/src/fal/auth/auth0.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/src/fal/auth/local.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/src/fal/cli/__init__.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/src/fal/cli/apps.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/src/fal/cli/auth.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/src/fal/cli/create.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/src/fal/cli/debug.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/src/fal/cli/doctor.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/src/fal/cli/keys.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/src/fal/cli/main.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/src/fal/cli/parser.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/src/fal/cli/secrets.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/src/fal/console/__init__.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/src/fal/console/icons.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/src/fal/console/ux.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/src/fal/container.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/src/fal/exceptions/__init__.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/src/fal/exceptions/_cuda.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/src/fal/exceptions/auth.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/src/fal/flags.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/src/fal/logging/__init__.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/src/fal/logging/isolate.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/src/fal/logging/style.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/src/fal/logging/trace.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/src/fal/logging/user.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/src/fal/py.typed +0 -0
- {fal-1.3.0 → fal-1.3.1}/src/fal/rest_client.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/src/fal/sdk.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/src/fal/sync.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/src/fal/toolkit/__init__.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/src/fal/toolkit/exceptions.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/src/fal/toolkit/file/__init__.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/src/fal/toolkit/file/providers/gcp.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/src/fal/toolkit/file/providers/r2.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/src/fal/toolkit/image/__init__.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/src/fal/toolkit/image/image.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/src/fal/toolkit/image/nsfw_filter/__init__.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/src/fal/toolkit/image/nsfw_filter/env.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/src/fal/toolkit/image/nsfw_filter/inference.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/src/fal/toolkit/image/nsfw_filter/model.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/src/fal/toolkit/image/nsfw_filter/requirements.txt +0 -0
- {fal-1.3.0 → fal-1.3.1}/src/fal/toolkit/image/safety_checker.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/src/fal/toolkit/optimize.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/src/fal/toolkit/utils/__init__.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/src/fal/toolkit/utils/download_utils.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/src/fal/workflows.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/tests/__init__.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/tests/cli/__init__.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/tests/cli/test_apps.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/tests/cli/test_auth.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/tests/cli/test_deploy.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/tests/cli/test_keys.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/tests/cli/test_run.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/tests/cli/test_secrets.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/tests/conftest.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/tests/integration_test.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/tests/mainify_package/__init__.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/tests/mainify_package/impl.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/tests/mainify_package/utils.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/tests/mainify_target.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/tests/toolkit/file_test.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/tests/toolkit/image_test.py +0 -0
- {fal-1.3.0 → fal-1.3.1}/tools/demo_script.py +0 -0
{fal-1.3.0 → fal-1.3.1}/PKG-INFO
RENAMED
|
@@ -143,7 +143,7 @@ class AppClient:
|
|
|
143
143
|
with httpx.Client() as client:
|
|
144
144
|
retries = 100
|
|
145
145
|
for _ in range(retries):
|
|
146
|
-
resp = client.get(info.url + "/health")
|
|
146
|
+
resp = client.get(info.url + "/health", timeout=60)
|
|
147
147
|
|
|
148
148
|
if resp.is_success:
|
|
149
149
|
break
|
|
@@ -205,6 +205,14 @@ class App(fal.api.BaseServable):
|
|
|
205
205
|
"Running apps through SDK is not implemented yet."
|
|
206
206
|
)
|
|
207
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
|
+
|
|
208
216
|
def collect_routes(self) -> dict[RouteSignature, Callable[..., Any]]:
|
|
209
217
|
return {
|
|
210
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()
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from dataclasses import dataclass
|
|
4
|
-
from typing import Sequence
|
|
5
4
|
|
|
6
5
|
|
|
7
6
|
class FalServerlessException(Exception):
|
|
@@ -40,11 +39,13 @@ class FieldException(FalServerlessException):
|
|
|
40
39
|
status_code: int = 422
|
|
41
40
|
type: str = "value_error"
|
|
42
41
|
|
|
43
|
-
def to_pydantic_format(self) ->
|
|
44
|
-
return
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
+
)
|
|
@@ -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
|
|
@@ -1,16 +1,26 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
3
5
|
import fal._serialization
|
|
4
6
|
from fal import App, wrap_app
|
|
5
7
|
|
|
6
8
|
from .api import FalServerlessError, FalServerlessHost, IsolatedFunction
|
|
7
9
|
|
|
8
10
|
|
|
11
|
+
@dataclass
|
|
12
|
+
class LoadedFunction:
|
|
13
|
+
function: IsolatedFunction
|
|
14
|
+
endpoints: list[str]
|
|
15
|
+
app_name: str | None
|
|
16
|
+
app_auth: str | None
|
|
17
|
+
|
|
18
|
+
|
|
9
19
|
def load_function_from(
|
|
10
20
|
host: FalServerlessHost,
|
|
11
21
|
file_path: str,
|
|
12
22
|
function_name: str | None = None,
|
|
13
|
-
) ->
|
|
23
|
+
) -> LoadedFunction:
|
|
14
24
|
import runpy
|
|
15
25
|
|
|
16
26
|
module = runpy.run_path(file_path)
|
|
@@ -45,6 +55,7 @@ def load_function_from(
|
|
|
45
55
|
fal._serialization.include_package_from_path(file_path)
|
|
46
56
|
|
|
47
57
|
target = module[function_name]
|
|
58
|
+
endpoints = target.get_endpoints() or ["/"]
|
|
48
59
|
if isinstance(target, type) and issubclass(target, App):
|
|
49
60
|
app_name = target.app_name
|
|
50
61
|
app_auth = target.app_auth
|
|
@@ -54,4 +65,4 @@ def load_function_from(
|
|
|
54
65
|
raise FalServerlessError(
|
|
55
66
|
f"Function '{function_name}' is not a fal.function or a fal.App"
|
|
56
67
|
)
|
|
57
|
-
return target, app_name, app_auth
|
|
68
|
+
return LoadedFunction(target, endpoints, app_name=app_name, app_auth=app_auth)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import json
|
|
2
2
|
import secrets
|
|
3
|
+
import subprocess
|
|
3
4
|
import time
|
|
4
5
|
from contextlib import contextmanager
|
|
5
6
|
from datetime import datetime
|
|
@@ -19,6 +20,7 @@ from fal.rest_client import REST_CLIENT
|
|
|
19
20
|
from fal.workflows import Workflow
|
|
20
21
|
from fastapi import WebSocket
|
|
21
22
|
from httpx import HTTPStatusError
|
|
23
|
+
from isolate.backends.common import active_python
|
|
22
24
|
from openapi_fal_rest.api.applications import app_metadata
|
|
23
25
|
from pydantic import BaseModel
|
|
24
26
|
from pydantic import __version__ as pydantic_version
|
|
@@ -38,6 +40,17 @@ class Output(BaseModel):
|
|
|
38
40
|
result: int
|
|
39
41
|
|
|
40
42
|
|
|
43
|
+
actual_python = active_python()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def git_revision_short_hash() -> str:
|
|
47
|
+
return (
|
|
48
|
+
subprocess.check_output(["git", "rev-parse", "--short", "HEAD"])
|
|
49
|
+
.decode("ascii")
|
|
50
|
+
.strip()
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
41
54
|
@fal.function(
|
|
42
55
|
keep_alive=60,
|
|
43
56
|
machine_type="S",
|
|
@@ -59,7 +72,9 @@ nomad_addition_app = addition_app.on(_scheduler="nomad")
|
|
|
59
72
|
|
|
60
73
|
@fal.function(
|
|
61
74
|
kind="container",
|
|
62
|
-
image=ContainerImage.from_dockerfile_str(
|
|
75
|
+
image=ContainerImage.from_dockerfile_str(
|
|
76
|
+
f"FROM python:{actual_python}-slim\n# {git_revision_short_hash()}",
|
|
77
|
+
),
|
|
63
78
|
keep_alive=60,
|
|
64
79
|
machine_type="S",
|
|
65
80
|
serve=True,
|
|
@@ -246,9 +261,6 @@ def test_nomad_app():
|
|
|
246
261
|
yield f"{user.user_id}/{app_revision}"
|
|
247
262
|
|
|
248
263
|
|
|
249
|
-
@pytest.mark.xfail(
|
|
250
|
-
reason="The support needs to be deployed. See https://github.com/fal-ai/isolate-cloud/pull/1809"
|
|
251
|
-
)
|
|
252
264
|
@pytest.fixture(scope="module")
|
|
253
265
|
def test_container_app():
|
|
254
266
|
# Create a temporary app, register it, and return the ID of it.
|
|
@@ -361,6 +373,7 @@ def test_stateful_app_client(test_stateful_app: str):
|
|
|
361
373
|
assert response["result"] == 0
|
|
362
374
|
|
|
363
375
|
|
|
376
|
+
@pytest.mark.flaky(max_runs=3)
|
|
364
377
|
def test_app_client_async(test_app: str):
|
|
365
378
|
request_handle = apps.submit(test_app, arguments={"lhs": 1, "rhs": 2})
|
|
366
379
|
assert request_handle.get() == {"result": 3}
|