synapse-sdk 1.0.0a23__py3-none-any.whl → 2025.12.3__py3-none-any.whl
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.
- synapse_sdk/__init__.py +24 -0
- synapse_sdk/cli/__init__.py +310 -5
- synapse_sdk/cli/alias/__init__.py +22 -0
- synapse_sdk/cli/alias/create.py +36 -0
- synapse_sdk/cli/alias/dataclass.py +31 -0
- synapse_sdk/cli/alias/default.py +16 -0
- synapse_sdk/cli/alias/delete.py +15 -0
- synapse_sdk/cli/alias/list.py +19 -0
- synapse_sdk/cli/alias/read.py +15 -0
- synapse_sdk/cli/alias/update.py +17 -0
- synapse_sdk/cli/alias/utils.py +61 -0
- synapse_sdk/cli/code_server.py +687 -0
- synapse_sdk/cli/config.py +440 -0
- synapse_sdk/cli/devtools.py +90 -0
- synapse_sdk/cli/plugin/__init__.py +33 -0
- synapse_sdk/cli/{create_plugin.py → plugin/create.py} +2 -2
- synapse_sdk/{plugins/cli → cli/plugin}/publish.py +23 -15
- synapse_sdk/clients/agent/__init__.py +9 -3
- synapse_sdk/clients/agent/container.py +143 -0
- synapse_sdk/clients/agent/core.py +19 -0
- synapse_sdk/clients/agent/ray.py +298 -9
- synapse_sdk/clients/backend/__init__.py +30 -12
- synapse_sdk/clients/backend/annotation.py +13 -5
- synapse_sdk/clients/backend/core.py +31 -4
- synapse_sdk/clients/backend/data_collection.py +186 -0
- synapse_sdk/clients/backend/hitl.py +17 -0
- synapse_sdk/clients/backend/integration.py +16 -1
- synapse_sdk/clients/backend/ml.py +5 -1
- synapse_sdk/clients/backend/models.py +78 -0
- synapse_sdk/clients/base.py +384 -41
- synapse_sdk/clients/ray/serve.py +2 -0
- synapse_sdk/clients/validators/collections.py +31 -0
- synapse_sdk/devtools/config.py +94 -0
- synapse_sdk/devtools/server.py +41 -0
- synapse_sdk/devtools/streamlit_app/__init__.py +5 -0
- synapse_sdk/devtools/streamlit_app/app.py +128 -0
- synapse_sdk/devtools/streamlit_app/services/__init__.py +11 -0
- synapse_sdk/devtools/streamlit_app/services/job_service.py +233 -0
- synapse_sdk/devtools/streamlit_app/services/plugin_service.py +236 -0
- synapse_sdk/devtools/streamlit_app/services/serve_service.py +95 -0
- synapse_sdk/devtools/streamlit_app/ui/__init__.py +15 -0
- synapse_sdk/devtools/streamlit_app/ui/config_tab.py +76 -0
- synapse_sdk/devtools/streamlit_app/ui/deployment_tab.py +66 -0
- synapse_sdk/devtools/streamlit_app/ui/http_tab.py +125 -0
- synapse_sdk/devtools/streamlit_app/ui/jobs_tab.py +573 -0
- synapse_sdk/devtools/streamlit_app/ui/serve_tab.py +346 -0
- synapse_sdk/devtools/streamlit_app/ui/status_bar.py +118 -0
- synapse_sdk/devtools/streamlit_app/utils/__init__.py +40 -0
- synapse_sdk/devtools/streamlit_app/utils/json_viewer.py +197 -0
- synapse_sdk/devtools/streamlit_app/utils/log_formatter.py +38 -0
- synapse_sdk/devtools/streamlit_app/utils/styles.py +241 -0
- synapse_sdk/devtools/streamlit_app/utils/ui_components.py +289 -0
- synapse_sdk/devtools/streamlit_app.py +10 -0
- synapse_sdk/loggers.py +120 -9
- synapse_sdk/plugins/README.md +1340 -0
- synapse_sdk/plugins/__init__.py +0 -13
- synapse_sdk/plugins/categories/base.py +117 -11
- synapse_sdk/plugins/categories/data_validation/actions/validation.py +72 -0
- synapse_sdk/plugins/categories/data_validation/templates/plugin/validation.py +33 -5
- synapse_sdk/plugins/categories/export/actions/__init__.py +3 -0
- synapse_sdk/plugins/categories/export/actions/export/__init__.py +28 -0
- synapse_sdk/plugins/categories/export/actions/export/action.py +165 -0
- synapse_sdk/plugins/categories/export/actions/export/enums.py +113 -0
- synapse_sdk/plugins/categories/export/actions/export/exceptions.py +53 -0
- synapse_sdk/plugins/categories/export/actions/export/models.py +74 -0
- synapse_sdk/plugins/categories/export/actions/export/run.py +195 -0
- synapse_sdk/plugins/categories/export/actions/export/utils.py +187 -0
- synapse_sdk/plugins/categories/export/templates/config.yaml +21 -0
- synapse_sdk/plugins/categories/export/templates/plugin/__init__.py +390 -0
- synapse_sdk/plugins/categories/export/templates/plugin/export.py +160 -0
- synapse_sdk/plugins/categories/neural_net/actions/deployment.py +13 -12
- synapse_sdk/plugins/categories/neural_net/actions/train.py +1134 -31
- synapse_sdk/plugins/categories/neural_net/actions/tune.py +534 -0
- synapse_sdk/plugins/categories/neural_net/base/inference.py +1 -1
- synapse_sdk/plugins/categories/neural_net/templates/config.yaml +32 -4
- synapse_sdk/plugins/categories/neural_net/templates/plugin/inference.py +26 -10
- synapse_sdk/plugins/categories/pre_annotation/actions/__init__.py +4 -0
- synapse_sdk/plugins/categories/pre_annotation/actions/pre_annotation/__init__.py +3 -0
- synapse_sdk/plugins/categories/{export/actions/export.py → pre_annotation/actions/pre_annotation/action.py} +4 -4
- synapse_sdk/plugins/categories/pre_annotation/actions/to_task/__init__.py +28 -0
- synapse_sdk/plugins/categories/pre_annotation/actions/to_task/action.py +148 -0
- synapse_sdk/plugins/categories/pre_annotation/actions/to_task/enums.py +269 -0
- synapse_sdk/plugins/categories/pre_annotation/actions/to_task/exceptions.py +14 -0
- synapse_sdk/plugins/categories/pre_annotation/actions/to_task/factory.py +76 -0
- synapse_sdk/plugins/categories/pre_annotation/actions/to_task/models.py +100 -0
- synapse_sdk/plugins/categories/pre_annotation/actions/to_task/orchestrator.py +248 -0
- synapse_sdk/plugins/categories/pre_annotation/actions/to_task/run.py +64 -0
- synapse_sdk/plugins/categories/pre_annotation/actions/to_task/strategies/__init__.py +17 -0
- synapse_sdk/plugins/categories/pre_annotation/actions/to_task/strategies/annotation.py +265 -0
- synapse_sdk/plugins/categories/pre_annotation/actions/to_task/strategies/base.py +170 -0
- synapse_sdk/plugins/categories/pre_annotation/actions/to_task/strategies/extraction.py +83 -0
- synapse_sdk/plugins/categories/pre_annotation/actions/to_task/strategies/metrics.py +92 -0
- synapse_sdk/plugins/categories/pre_annotation/actions/to_task/strategies/preprocessor.py +243 -0
- synapse_sdk/plugins/categories/pre_annotation/actions/to_task/strategies/validation.py +143 -0
- synapse_sdk/plugins/categories/pre_annotation/templates/config.yaml +19 -0
- synapse_sdk/plugins/categories/pre_annotation/templates/plugin/to_task.py +40 -0
- synapse_sdk/plugins/categories/smart_tool/templates/config.yaml +2 -0
- synapse_sdk/plugins/categories/upload/__init__.py +0 -0
- synapse_sdk/plugins/categories/upload/actions/__init__.py +0 -0
- synapse_sdk/plugins/categories/upload/actions/upload/__init__.py +19 -0
- synapse_sdk/plugins/categories/upload/actions/upload/action.py +236 -0
- synapse_sdk/plugins/categories/upload/actions/upload/context.py +185 -0
- synapse_sdk/plugins/categories/upload/actions/upload/enums.py +493 -0
- synapse_sdk/plugins/categories/upload/actions/upload/exceptions.py +36 -0
- synapse_sdk/plugins/categories/upload/actions/upload/factory.py +138 -0
- synapse_sdk/plugins/categories/upload/actions/upload/models.py +214 -0
- synapse_sdk/plugins/categories/upload/actions/upload/orchestrator.py +183 -0
- synapse_sdk/plugins/categories/upload/actions/upload/registry.py +113 -0
- synapse_sdk/plugins/categories/upload/actions/upload/run.py +179 -0
- synapse_sdk/plugins/categories/upload/actions/upload/steps/__init__.py +1 -0
- synapse_sdk/plugins/categories/upload/actions/upload/steps/base.py +107 -0
- synapse_sdk/plugins/categories/upload/actions/upload/steps/cleanup.py +62 -0
- synapse_sdk/plugins/categories/upload/actions/upload/steps/collection.py +63 -0
- synapse_sdk/plugins/categories/upload/actions/upload/steps/generate.py +91 -0
- synapse_sdk/plugins/categories/upload/actions/upload/steps/initialize.py +82 -0
- synapse_sdk/plugins/categories/upload/actions/upload/steps/metadata.py +235 -0
- synapse_sdk/plugins/categories/upload/actions/upload/steps/organize.py +201 -0
- synapse_sdk/plugins/categories/upload/actions/upload/steps/upload.py +104 -0
- synapse_sdk/plugins/categories/upload/actions/upload/steps/validate.py +71 -0
- synapse_sdk/plugins/categories/upload/actions/upload/strategies/__init__.py +1 -0
- synapse_sdk/plugins/categories/upload/actions/upload/strategies/base.py +82 -0
- synapse_sdk/plugins/categories/upload/actions/upload/strategies/data_unit/__init__.py +1 -0
- synapse_sdk/plugins/categories/upload/actions/upload/strategies/data_unit/batch.py +39 -0
- synapse_sdk/plugins/categories/upload/actions/upload/strategies/data_unit/single.py +29 -0
- synapse_sdk/plugins/categories/upload/actions/upload/strategies/file_discovery/__init__.py +1 -0
- synapse_sdk/plugins/categories/upload/actions/upload/strategies/file_discovery/flat.py +300 -0
- synapse_sdk/plugins/categories/upload/actions/upload/strategies/file_discovery/recursive.py +287 -0
- synapse_sdk/plugins/categories/upload/actions/upload/strategies/metadata/__init__.py +1 -0
- synapse_sdk/plugins/categories/upload/actions/upload/strategies/metadata/excel.py +174 -0
- synapse_sdk/plugins/categories/upload/actions/upload/strategies/metadata/none.py +16 -0
- synapse_sdk/plugins/categories/upload/actions/upload/strategies/upload/__init__.py +1 -0
- synapse_sdk/plugins/categories/upload/actions/upload/strategies/upload/sync.py +84 -0
- synapse_sdk/plugins/categories/upload/actions/upload/strategies/validation/__init__.py +1 -0
- synapse_sdk/plugins/categories/upload/actions/upload/strategies/validation/default.py +60 -0
- synapse_sdk/plugins/categories/upload/actions/upload/utils.py +250 -0
- synapse_sdk/plugins/categories/upload/templates/README.md +470 -0
- synapse_sdk/plugins/categories/upload/templates/config.yaml +33 -0
- synapse_sdk/plugins/categories/upload/templates/plugin/__init__.py +310 -0
- synapse_sdk/plugins/categories/upload/templates/plugin/upload.py +102 -0
- synapse_sdk/plugins/enums.py +3 -1
- synapse_sdk/plugins/models.py +148 -11
- synapse_sdk/plugins/templates/plugin-config-schema.json +406 -0
- synapse_sdk/plugins/templates/schema.json +491 -0
- synapse_sdk/plugins/templates/synapse-{{cookiecutter.plugin_code}}-plugin/config.yaml +1 -0
- synapse_sdk/plugins/templates/synapse-{{cookiecutter.plugin_code}}-plugin/requirements.txt +1 -1
- synapse_sdk/plugins/utils/__init__.py +46 -0
- synapse_sdk/plugins/utils/actions.py +119 -0
- synapse_sdk/plugins/utils/config.py +203 -0
- synapse_sdk/plugins/{utils.py → utils/legacy.py} +26 -46
- synapse_sdk/plugins/utils/ray_gcs.py +66 -0
- synapse_sdk/plugins/utils/registry.py +58 -0
- synapse_sdk/shared/__init__.py +25 -0
- synapse_sdk/shared/enums.py +93 -0
- synapse_sdk/types.py +19 -0
- synapse_sdk/utils/converters/__init__.py +240 -0
- synapse_sdk/utils/converters/coco/__init__.py +0 -0
- synapse_sdk/utils/converters/coco/from_dm.py +322 -0
- synapse_sdk/utils/converters/coco/to_dm.py +215 -0
- synapse_sdk/utils/converters/dm/__init__.py +57 -0
- synapse_sdk/utils/converters/dm/base.py +137 -0
- synapse_sdk/utils/converters/dm/from_v1.py +273 -0
- synapse_sdk/utils/converters/dm/to_v1.py +321 -0
- synapse_sdk/utils/converters/dm/tools/__init__.py +214 -0
- synapse_sdk/utils/converters/dm/tools/answer.py +95 -0
- synapse_sdk/utils/converters/dm/tools/bounding_box.py +132 -0
- synapse_sdk/utils/converters/dm/tools/bounding_box_3d.py +121 -0
- synapse_sdk/utils/converters/dm/tools/classification.py +75 -0
- synapse_sdk/utils/converters/dm/tools/keypoint.py +117 -0
- synapse_sdk/utils/converters/dm/tools/named_entity.py +111 -0
- synapse_sdk/utils/converters/dm/tools/polygon.py +122 -0
- synapse_sdk/utils/converters/dm/tools/polyline.py +124 -0
- synapse_sdk/utils/converters/dm/tools/prompt.py +94 -0
- synapse_sdk/utils/converters/dm/tools/relation.py +86 -0
- synapse_sdk/utils/converters/dm/tools/segmentation.py +141 -0
- synapse_sdk/utils/converters/dm/tools/segmentation_3d.py +83 -0
- synapse_sdk/utils/converters/dm/types.py +168 -0
- synapse_sdk/utils/converters/dm/utils.py +162 -0
- synapse_sdk/utils/converters/dm_legacy/__init__.py +56 -0
- synapse_sdk/utils/converters/dm_legacy/from_v1.py +627 -0
- synapse_sdk/utils/converters/dm_legacy/to_v1.py +367 -0
- synapse_sdk/utils/converters/pascal/__init__.py +0 -0
- synapse_sdk/utils/converters/pascal/from_dm.py +244 -0
- synapse_sdk/utils/converters/pascal/to_dm.py +214 -0
- synapse_sdk/utils/converters/yolo/__init__.py +0 -0
- synapse_sdk/utils/converters/yolo/from_dm.py +384 -0
- synapse_sdk/utils/converters/yolo/to_dm.py +267 -0
- synapse_sdk/utils/dataset.py +46 -0
- synapse_sdk/utils/encryption.py +158 -0
- synapse_sdk/utils/file/__init__.py +58 -0
- synapse_sdk/utils/file/archive.py +32 -0
- synapse_sdk/utils/file/checksum.py +56 -0
- synapse_sdk/utils/file/chunking.py +31 -0
- synapse_sdk/utils/file/download.py +385 -0
- synapse_sdk/utils/file/encoding.py +40 -0
- synapse_sdk/utils/file/io.py +22 -0
- synapse_sdk/utils/file/upload.py +165 -0
- synapse_sdk/utils/file/video/__init__.py +29 -0
- synapse_sdk/utils/file/video/transcode.py +307 -0
- synapse_sdk/utils/file.py.backup +301 -0
- synapse_sdk/utils/http.py +138 -0
- synapse_sdk/utils/network.py +309 -0
- synapse_sdk/utils/storage/__init__.py +72 -0
- synapse_sdk/utils/storage/providers/__init__.py +183 -0
- synapse_sdk/utils/storage/providers/file_system.py +134 -0
- synapse_sdk/utils/storage/providers/gcp.py +13 -0
- synapse_sdk/utils/storage/providers/http.py +190 -0
- synapse_sdk/utils/storage/providers/s3.py +91 -0
- synapse_sdk/utils/storage/providers/sftp.py +47 -0
- synapse_sdk/utils/storage/registry.py +17 -0
- synapse_sdk-2025.12.3.dist-info/METADATA +123 -0
- synapse_sdk-2025.12.3.dist-info/RECORD +279 -0
- {synapse_sdk-1.0.0a23.dist-info → synapse_sdk-2025.12.3.dist-info}/WHEEL +1 -1
- synapse_sdk/clients/backend/dataset.py +0 -51
- synapse_sdk/plugins/categories/import/actions/import.py +0 -10
- synapse_sdk/plugins/cli/__init__.py +0 -21
- synapse_sdk/plugins/templates/synapse-{{cookiecutter.plugin_code}}-plugin/.env +0 -24
- synapse_sdk/plugins/templates/synapse-{{cookiecutter.plugin_code}}-plugin/.env.dist +0 -24
- synapse_sdk/plugins/templates/synapse-{{cookiecutter.plugin_code}}-plugin/main.py +0 -4
- synapse_sdk/utils/file.py +0 -168
- synapse_sdk/utils/storage.py +0 -91
- synapse_sdk-1.0.0a23.dist-info/METADATA +0 -44
- synapse_sdk-1.0.0a23.dist-info/RECORD +0 -114
- /synapse_sdk/{plugins/cli → cli/plugin}/run.py +0 -0
- /synapse_sdk/{plugins/categories/import → clients/validators}/__init__.py +0 -0
- /synapse_sdk/{plugins/categories/import/actions → devtools}/__init__.py +0 -0
- {synapse_sdk-1.0.0a23.dist-info → synapse_sdk-2025.12.3.dist-info}/entry_points.txt +0 -0
- {synapse_sdk-1.0.0a23.dist-info → synapse_sdk-2025.12.3.dist-info/licenses}/LICENSE +0 -0
- {synapse_sdk-1.0.0a23.dist-info → synapse_sdk-2025.12.3.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import operator
|
|
3
|
+
from functools import reduce
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import aiohttp
|
|
7
|
+
import requests
|
|
8
|
+
|
|
9
|
+
from synapse_sdk.utils.network import clean_url
|
|
10
|
+
from synapse_sdk.utils.string import hash_text
|
|
11
|
+
|
|
12
|
+
from .io import get_temp_path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def download_file(url, path_download, name=None, coerce=None, use_cached=True):
|
|
16
|
+
"""Download a file from a URL to a specified directory.
|
|
17
|
+
|
|
18
|
+
This function downloads a file from a URL with support for caching, custom naming,
|
|
19
|
+
and optional path transformation. Downloads are streamed in chunks for memory efficiency.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
url (str): The URL to download from. Query parameters and fragments are cleaned
|
|
23
|
+
before generating the cached filename.
|
|
24
|
+
path_download (str | Path): Directory path where the file will be saved.
|
|
25
|
+
name (str, optional): Custom filename for the downloaded file (without extension).
|
|
26
|
+
If provided, caching is disabled. If None, a hash of the URL is used as the name.
|
|
27
|
+
coerce (callable, optional): A function to transform the downloaded file path.
|
|
28
|
+
Called with the Path object after download completes.
|
|
29
|
+
Example: lambda p: str(p) to convert Path to string
|
|
30
|
+
use_cached (bool): If True (default), skip download if file already exists.
|
|
31
|
+
Automatically set to False when a custom name is provided.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
Path | Any: Path object pointing to the downloaded file, or the result of
|
|
35
|
+
coerce(path) if a coerce function was provided.
|
|
36
|
+
|
|
37
|
+
Raises:
|
|
38
|
+
requests.HTTPError: If the HTTP request fails (e.g., 404, 500 errors).
|
|
39
|
+
IOError: If file write fails due to permissions or disk space.
|
|
40
|
+
|
|
41
|
+
Examples:
|
|
42
|
+
Basic download with caching:
|
|
43
|
+
>>> path = download_file('https://example.com/image.jpg', '/tmp/downloads')
|
|
44
|
+
>>> print(path) # /tmp/downloads/abc123def456.jpg (hash-based name)
|
|
45
|
+
|
|
46
|
+
Custom filename without caching:
|
|
47
|
+
>>> path = download_file(
|
|
48
|
+
... 'https://example.com/data.json',
|
|
49
|
+
... '/tmp/downloads',
|
|
50
|
+
... name='my_data'
|
|
51
|
+
... )
|
|
52
|
+
>>> print(path) # /tmp/downloads/my_data.json
|
|
53
|
+
|
|
54
|
+
With path coercion to string:
|
|
55
|
+
>>> path_str = download_file(
|
|
56
|
+
... 'https://example.com/file.txt',
|
|
57
|
+
... '/tmp',
|
|
58
|
+
... coerce=str
|
|
59
|
+
... )
|
|
60
|
+
>>> print(type(path_str)) # <class 'str'>
|
|
61
|
+
|
|
62
|
+
Note:
|
|
63
|
+
- Downloads are streamed in 50MB chunks for memory efficiency
|
|
64
|
+
- URL is cleaned (query params removed) before generating cached filename
|
|
65
|
+
- File extension is preserved from the cleaned URL
|
|
66
|
+
- Existing files are reused when use_cached=True
|
|
67
|
+
"""
|
|
68
|
+
chunk_size = 1024 * 1024 * 50
|
|
69
|
+
cleaned_url = clean_url(url) # remove query params and fragment
|
|
70
|
+
|
|
71
|
+
if name:
|
|
72
|
+
use_cached = False
|
|
73
|
+
else:
|
|
74
|
+
name = hash_text(cleaned_url)
|
|
75
|
+
|
|
76
|
+
name += Path(cleaned_url).suffix
|
|
77
|
+
|
|
78
|
+
path = Path(path_download) / name
|
|
79
|
+
|
|
80
|
+
if not use_cached or not path.is_file():
|
|
81
|
+
response = requests.get(url, allow_redirects=True, stream=True)
|
|
82
|
+
response.raise_for_status()
|
|
83
|
+
|
|
84
|
+
with path.open('wb') as file:
|
|
85
|
+
for chunk in response.iter_content(chunk_size=chunk_size):
|
|
86
|
+
file.write(chunk)
|
|
87
|
+
|
|
88
|
+
if coerce:
|
|
89
|
+
path = coerce(path)
|
|
90
|
+
|
|
91
|
+
return path
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def files_url_to_path(files, coerce=None, file_field=None):
|
|
95
|
+
"""Convert file URLs to local file paths by downloading them.
|
|
96
|
+
|
|
97
|
+
This function downloads files from URLs and replaces the URLs with local paths
|
|
98
|
+
in the provided dictionary. Supports both flat dictionaries and nested structures.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
files (dict): Dictionary containing file URLs or file objects.
|
|
102
|
+
- If values are strings: treated as URLs and replaced with local paths
|
|
103
|
+
- If values are dicts with 'url' key: 'url' is replaced with 'path'
|
|
104
|
+
coerce (callable, optional): Function to transform downloaded paths.
|
|
105
|
+
Applied to each downloaded file path.
|
|
106
|
+
file_field (str, optional): Specific field name to process. If provided,
|
|
107
|
+
only this field is processed. If None, all fields are processed.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
None: Modifies the files dictionary in-place.
|
|
111
|
+
|
|
112
|
+
Examples:
|
|
113
|
+
Simple URL replacement:
|
|
114
|
+
>>> files = {'image': 'https://example.com/img.jpg'}
|
|
115
|
+
>>> files_url_to_path(files)
|
|
116
|
+
>>> print(files['image']) # Path('/tmp/media/abc123.jpg')
|
|
117
|
+
|
|
118
|
+
With nested objects:
|
|
119
|
+
>>> files = {'video': {'url': 'https://example.com/vid.mp4', 'size': 1024}}
|
|
120
|
+
>>> files_url_to_path(files)
|
|
121
|
+
>>> print(files['video']) # {'path': Path('/tmp/media/def456.mp4'), 'size': 1024}
|
|
122
|
+
|
|
123
|
+
Process specific field only:
|
|
124
|
+
>>> files = {'image': 'https://ex.com/a.jpg', 'doc': 'https://ex.com/b.pdf'}
|
|
125
|
+
>>> files_url_to_path(files, file_field='image')
|
|
126
|
+
>>> # Only 'image' is downloaded, 'doc' remains as URL
|
|
127
|
+
|
|
128
|
+
With path coercion:
|
|
129
|
+
>>> files = {'data': 'https://example.com/data.csv'}
|
|
130
|
+
>>> files_url_to_path(files, coerce=str)
|
|
131
|
+
>>> print(type(files['data'])) # <class 'str'>
|
|
132
|
+
|
|
133
|
+
Note:
|
|
134
|
+
- Downloads to temporary media directory: get_temp_path('media')
|
|
135
|
+
- Creates download directory if it doesn't exist
|
|
136
|
+
- Modifies input dictionary in-place
|
|
137
|
+
- Uses caching by default (via download_file)
|
|
138
|
+
"""
|
|
139
|
+
path_download = get_temp_path('media')
|
|
140
|
+
path_download.mkdir(parents=True, exist_ok=True)
|
|
141
|
+
if file_field:
|
|
142
|
+
files[file_field] = download_file(files[file_field], path_download, coerce=coerce)
|
|
143
|
+
else:
|
|
144
|
+
for file_name in files:
|
|
145
|
+
if isinstance(files[file_name], str):
|
|
146
|
+
files[file_name] = download_file(files[file_name], path_download, coerce=coerce)
|
|
147
|
+
else:
|
|
148
|
+
files[file_name]['path'] = download_file(files[file_name].pop('url'), path_download, coerce=coerce)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def files_url_to_path_from_objs(objs, files_fields, coerce=None, is_list=False, is_async=False):
|
|
152
|
+
"""Convert file URLs to paths for multiple objects with nested field support.
|
|
153
|
+
|
|
154
|
+
This function processes one or more objects, extracting file URLs from specified
|
|
155
|
+
nested fields and replacing them with local file paths. Supports both synchronous
|
|
156
|
+
and asynchronous operation.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
objs (dict | list): Single object or list of objects to process.
|
|
160
|
+
If is_list=False, can be a single dict.
|
|
161
|
+
If is_list=True, should be a list of dicts.
|
|
162
|
+
files_fields (list[str]): List of field paths to process.
|
|
163
|
+
Supports dot notation for nested fields (e.g., 'data.files', 'meta.image').
|
|
164
|
+
coerce (callable, optional): Function to transform downloaded paths.
|
|
165
|
+
is_list (bool): If True, objs is treated as a list. If False, objs is wrapped
|
|
166
|
+
in a list for processing. Default False.
|
|
167
|
+
is_async (bool): If True, uses async download (afiles_url_to_path_from_objs).
|
|
168
|
+
If False, uses synchronous download. Default False.
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
None: Modifies objects in-place, replacing URLs with local paths.
|
|
172
|
+
|
|
173
|
+
Examples:
|
|
174
|
+
Single object with simple field:
|
|
175
|
+
>>> obj = {'files': {'image': 'https://example.com/img.jpg'}}
|
|
176
|
+
>>> files_url_to_path_from_objs(obj, files_fields=['files'])
|
|
177
|
+
>>> print(obj['files']['image']) # Path('/tmp/media/abc123.jpg')
|
|
178
|
+
|
|
179
|
+
Multiple objects with nested fields:
|
|
180
|
+
>>> objs = [
|
|
181
|
+
... {'data': {'files': {'img': 'https://ex.com/1.jpg'}}},
|
|
182
|
+
... {'data': {'files': {'img': 'https://ex.com/2.jpg'}}}
|
|
183
|
+
... ]
|
|
184
|
+
>>> files_url_to_path_from_objs(objs, files_fields=['data.files'], is_list=True)
|
|
185
|
+
>>> # Both images are downloaded and URLs replaced with paths
|
|
186
|
+
|
|
187
|
+
Async download for better performance:
|
|
188
|
+
>>> objs = [{'files': {'a': 'url1', 'b': 'url2'}} for _ in range(10)]
|
|
189
|
+
>>> files_url_to_path_from_objs(
|
|
190
|
+
... objs,
|
|
191
|
+
... files_fields=['files'],
|
|
192
|
+
... is_list=True,
|
|
193
|
+
... is_async=True
|
|
194
|
+
... )
|
|
195
|
+
>>> # All files downloaded concurrently
|
|
196
|
+
|
|
197
|
+
Multiple field paths:
|
|
198
|
+
>>> obj = {
|
|
199
|
+
... 'images': {'photo': 'https://ex.com/photo.jpg'},
|
|
200
|
+
... 'videos': {'clip': 'https://ex.com/video.mp4'}
|
|
201
|
+
... }
|
|
202
|
+
>>> files_url_to_path_from_objs(obj, files_fields=['images', 'videos'])
|
|
203
|
+
>>> # Both images and videos fields are processed
|
|
204
|
+
|
|
205
|
+
Note:
|
|
206
|
+
- Silently skips missing fields (KeyError is caught and ignored)
|
|
207
|
+
- Supports dot notation for nested field access
|
|
208
|
+
- Async mode (is_async=True) provides better performance for multiple files
|
|
209
|
+
- Commonly used with API responses containing file URLs
|
|
210
|
+
- Used by BaseClient._list() with url_conversion parameter
|
|
211
|
+
"""
|
|
212
|
+
if is_async:
|
|
213
|
+
asyncio.run(afiles_url_to_path_from_objs(objs, files_fields, coerce=coerce, is_list=is_list))
|
|
214
|
+
else:
|
|
215
|
+
if not is_list:
|
|
216
|
+
objs = [objs]
|
|
217
|
+
|
|
218
|
+
for obj in objs:
|
|
219
|
+
for files_field in files_fields:
|
|
220
|
+
try:
|
|
221
|
+
files = reduce(operator.getitem, files_field.split('.'), obj)
|
|
222
|
+
if isinstance(files, str):
|
|
223
|
+
files_url_to_path(obj, coerce=coerce, file_field=files_field)
|
|
224
|
+
else:
|
|
225
|
+
files_url_to_path(files, coerce=coerce)
|
|
226
|
+
except KeyError:
|
|
227
|
+
pass
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
async def adownload_file(url, path_download, name=None, coerce=None, use_cached=True):
|
|
231
|
+
"""Asynchronously download a file from a URL to a specified directory.
|
|
232
|
+
|
|
233
|
+
Async version of download_file() using aiohttp for concurrent downloads.
|
|
234
|
+
All parameters and behavior are identical to download_file().
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
url (str): The URL to download from.
|
|
238
|
+
path_download (str | Path): Directory path where the file will be saved.
|
|
239
|
+
name (str, optional): Custom filename (without extension).
|
|
240
|
+
coerce (callable, optional): Function to transform the downloaded file path.
|
|
241
|
+
use_cached (bool): If True (default), skip download if file exists.
|
|
242
|
+
|
|
243
|
+
Returns:
|
|
244
|
+
Path | Any: Path to downloaded file, or coerce(path) if provided.
|
|
245
|
+
|
|
246
|
+
Examples:
|
|
247
|
+
Basic async download:
|
|
248
|
+
>>> path = await adownload_file('https://example.com/large.zip', '/tmp')
|
|
249
|
+
|
|
250
|
+
Multiple concurrent downloads:
|
|
251
|
+
>>> urls = ['https://ex.com/1.jpg', 'https://ex.com/2.jpg']
|
|
252
|
+
>>> paths = await asyncio.gather(*[
|
|
253
|
+
... adownload_file(url, '/tmp') for url in urls
|
|
254
|
+
... ])
|
|
255
|
+
|
|
256
|
+
Note:
|
|
257
|
+
- Uses aiohttp.ClientSession for async HTTP requests
|
|
258
|
+
- Downloads in 50MB chunks for memory efficiency
|
|
259
|
+
- Recommended for downloading multiple files concurrently
|
|
260
|
+
"""
|
|
261
|
+
chunk_size = 1024 * 1024 * 50
|
|
262
|
+
cleaned_url = clean_url(url) # remove query params and fragment
|
|
263
|
+
|
|
264
|
+
if name:
|
|
265
|
+
use_cached = False
|
|
266
|
+
else:
|
|
267
|
+
name = hash_text(cleaned_url)
|
|
268
|
+
|
|
269
|
+
name += Path(cleaned_url).suffix
|
|
270
|
+
|
|
271
|
+
path = Path(path_download) / name
|
|
272
|
+
|
|
273
|
+
if not use_cached or not path.is_file():
|
|
274
|
+
async with aiohttp.ClientSession() as session:
|
|
275
|
+
async with session.get(url) as response:
|
|
276
|
+
with path.open('wb') as file:
|
|
277
|
+
while chunk := await response.content.read(chunk_size):
|
|
278
|
+
file.write(chunk)
|
|
279
|
+
|
|
280
|
+
if coerce:
|
|
281
|
+
path = coerce(path)
|
|
282
|
+
|
|
283
|
+
return path
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
async def afiles_url_to_path(files, coerce=None):
|
|
287
|
+
"""Asynchronously convert file URLs to local paths by downloading them.
|
|
288
|
+
|
|
289
|
+
Async version of files_url_to_path() for concurrent file downloads.
|
|
290
|
+
Processes all files in the dictionary concurrently for better performance.
|
|
291
|
+
|
|
292
|
+
Args:
|
|
293
|
+
files (dict): Dictionary containing file URLs or file objects.
|
|
294
|
+
coerce (callable, optional): Function to transform downloaded paths.
|
|
295
|
+
|
|
296
|
+
Returns:
|
|
297
|
+
None: Modifies the files dictionary in-place.
|
|
298
|
+
|
|
299
|
+
Examples:
|
|
300
|
+
Download multiple files concurrently:
|
|
301
|
+
>>> files = {
|
|
302
|
+
... 'image1': 'https://ex.com/1.jpg',
|
|
303
|
+
... 'image2': 'https://ex.com/2.jpg',
|
|
304
|
+
... 'image3': 'https://ex.com/3.jpg'
|
|
305
|
+
... }
|
|
306
|
+
>>> await afiles_url_to_path(files)
|
|
307
|
+
>>> # All 3 files downloaded concurrently
|
|
308
|
+
|
|
309
|
+
With nested file objects:
|
|
310
|
+
>>> files = {
|
|
311
|
+
... 'thumb': {'url': 'https://ex.com/thumb.jpg'},
|
|
312
|
+
... 'full': {'url': 'https://ex.com/full.jpg'}
|
|
313
|
+
... }
|
|
314
|
+
>>> await afiles_url_to_path(files)
|
|
315
|
+
>>> print(files['thumb']['path']) # Path object
|
|
316
|
+
|
|
317
|
+
Note:
|
|
318
|
+
- All files are downloaded concurrently using asyncio
|
|
319
|
+
- More efficient than synchronous version for multiple files
|
|
320
|
+
- Does not support file_field parameter (processes all fields)
|
|
321
|
+
"""
|
|
322
|
+
path_download = get_temp_path('media')
|
|
323
|
+
path_download.mkdir(parents=True, exist_ok=True)
|
|
324
|
+
for file_name in files:
|
|
325
|
+
if isinstance(files[file_name], str):
|
|
326
|
+
files[file_name] = await adownload_file(files[file_name], path_download, coerce=coerce)
|
|
327
|
+
else:
|
|
328
|
+
files[file_name]['path'] = await adownload_file(files[file_name].pop('url'), path_download, coerce=coerce)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
async def afiles_url_to_path_from_objs(objs, files_fields, coerce=None, is_list=False):
|
|
332
|
+
"""Asynchronously convert file URLs to paths for multiple objects.
|
|
333
|
+
|
|
334
|
+
Async version of files_url_to_path_from_objs() that downloads all files
|
|
335
|
+
concurrently using asyncio.gather() for maximum performance.
|
|
336
|
+
|
|
337
|
+
Args:
|
|
338
|
+
objs (dict | list): Single object or list of objects to process.
|
|
339
|
+
files_fields (list[str]): List of field paths to process (supports dot notation).
|
|
340
|
+
coerce (callable, optional): Function to transform downloaded paths.
|
|
341
|
+
is_list (bool): If True, objs is treated as a list. Default False.
|
|
342
|
+
|
|
343
|
+
Returns:
|
|
344
|
+
None: Modifies objects in-place, replacing URLs with local paths.
|
|
345
|
+
|
|
346
|
+
Examples:
|
|
347
|
+
Download files from multiple objects concurrently:
|
|
348
|
+
>>> objs = [
|
|
349
|
+
... {'files': {'img': 'https://ex.com/1.jpg'}},
|
|
350
|
+
... {'files': {'img': 'https://ex.com/2.jpg'}},
|
|
351
|
+
... {'files': {'img': 'https://ex.com/3.jpg'}}
|
|
352
|
+
... ]
|
|
353
|
+
>>> await afiles_url_to_path_from_objs(objs, ['files'], is_list=True)
|
|
354
|
+
>>> # All 3 images downloaded concurrently
|
|
355
|
+
|
|
356
|
+
Process large dataset efficiently:
|
|
357
|
+
>>> # 100 objects with multiple files each
|
|
358
|
+
>>> objs = [{'data': {'files': {...}}} for _ in range(100)]
|
|
359
|
+
>>> await afiles_url_to_path_from_objs(
|
|
360
|
+
... objs,
|
|
361
|
+
... files_fields=['data.files'],
|
|
362
|
+
... is_list=True
|
|
363
|
+
... )
|
|
364
|
+
>>> # All files downloaded in parallel, much faster than sync version
|
|
365
|
+
|
|
366
|
+
Note:
|
|
367
|
+
- All file downloads happen concurrently using asyncio.gather()
|
|
368
|
+
- Significantly faster than synchronous version for large datasets
|
|
369
|
+
- Ideal for processing API responses with many file URLs
|
|
370
|
+
- Used internally when is_async=True in files_url_to_path_from_objs()
|
|
371
|
+
"""
|
|
372
|
+
if not is_list:
|
|
373
|
+
objs = [objs]
|
|
374
|
+
|
|
375
|
+
tasks = []
|
|
376
|
+
|
|
377
|
+
for obj in objs:
|
|
378
|
+
for files_field in files_fields:
|
|
379
|
+
try:
|
|
380
|
+
files = reduce(operator.getitem, files_field.split('.'), obj)
|
|
381
|
+
tasks.append(afiles_url_to_path(files, coerce=coerce))
|
|
382
|
+
except KeyError:
|
|
383
|
+
pass
|
|
384
|
+
|
|
385
|
+
await asyncio.gather(*tasks)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import mimetypes
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def convert_file_to_base64(file_path):
|
|
7
|
+
"""
|
|
8
|
+
Convert a file to base64 using pathlib.
|
|
9
|
+
|
|
10
|
+
Args:
|
|
11
|
+
file_path (str): Path to the file to convert
|
|
12
|
+
|
|
13
|
+
Returns:
|
|
14
|
+
str: Base64 encoded string of the file contents
|
|
15
|
+
"""
|
|
16
|
+
# FIXME base64 is sent sometimes.
|
|
17
|
+
if file_path.startswith('data:'):
|
|
18
|
+
return file_path
|
|
19
|
+
|
|
20
|
+
# Convert string path to Path object
|
|
21
|
+
path = Path(file_path)
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
# Read binary content of the file
|
|
25
|
+
binary_content = path.read_bytes()
|
|
26
|
+
|
|
27
|
+
# Convert to base64
|
|
28
|
+
base64_encoded = base64.b64encode(binary_content).decode('utf-8')
|
|
29
|
+
|
|
30
|
+
# Get the MIME type of the file
|
|
31
|
+
mime_type, _ = mimetypes.guess_type(path)
|
|
32
|
+
assert mime_type is not None, 'MIME type cannot be guessed'
|
|
33
|
+
|
|
34
|
+
# Convert bytes to string for readable output
|
|
35
|
+
return f'data:{mime_type};base64,{base64_encoded}'
|
|
36
|
+
|
|
37
|
+
except FileNotFoundError:
|
|
38
|
+
raise FileNotFoundError(f'File not found: {file_path}')
|
|
39
|
+
except Exception as e:
|
|
40
|
+
raise Exception(f'Error converting file to base64: {str(e)}')
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import yaml
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def get_dict_from_file(file_path):
|
|
8
|
+
if isinstance(file_path, str):
|
|
9
|
+
file_path = Path(file_path)
|
|
10
|
+
|
|
11
|
+
with open(file_path) as f:
|
|
12
|
+
if file_path.suffix == '.yaml':
|
|
13
|
+
return yaml.safe_load(f)
|
|
14
|
+
else:
|
|
15
|
+
return json.load(f)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_temp_path(sub_path=None):
|
|
19
|
+
path = Path('/tmp/datamaker')
|
|
20
|
+
if sub_path:
|
|
21
|
+
path = path / sub_path
|
|
22
|
+
return path
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""File upload utilities for HTTP requests.
|
|
2
|
+
|
|
3
|
+
This module provides utilities for processing files for upload in HTTP requests,
|
|
4
|
+
with proper type definitions for file objects used in the requests library.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from io import BufferedReader
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import BinaryIO, TypeAlias, Union
|
|
10
|
+
|
|
11
|
+
# Type definitions for HTTP request file objects
|
|
12
|
+
# Based on requests library file parameter structure
|
|
13
|
+
|
|
14
|
+
# File tuple format: (filename, file_content)
|
|
15
|
+
# This is what we actually use when processing Path objects
|
|
16
|
+
FileTuple: TypeAlias = tuple[str, BinaryIO]
|
|
17
|
+
|
|
18
|
+
# Combined file type for requests library
|
|
19
|
+
# - bytes: Raw content (for chunked uploads)
|
|
20
|
+
# - FileTuple: (filename, file_handle) for Path objects
|
|
21
|
+
RequestsFile: TypeAlias = Union[bytes, FileTuple]
|
|
22
|
+
|
|
23
|
+
# Files dictionary mapping field names to file objects
|
|
24
|
+
FilesDict: TypeAlias = dict[str, RequestsFile]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class FileUploadError(Exception):
|
|
28
|
+
"""Base exception for file upload errors."""
|
|
29
|
+
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class FileValidationError(FileUploadError):
|
|
34
|
+
"""Raised when file validation fails."""
|
|
35
|
+
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class FileProcessingError(FileUploadError):
|
|
40
|
+
"""Raised when file processing fails."""
|
|
41
|
+
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def process_files_for_upload(files: dict[str, Union[str, Path, bytes, object]]) -> tuple[FilesDict, list[BinaryIO]]:
|
|
46
|
+
"""Process files parameter for upload requests.
|
|
47
|
+
|
|
48
|
+
Converts file paths to file handles suitable for requests library.
|
|
49
|
+
Supports: str paths, Path objects, UPath objects (cloud storage), and bytes.
|
|
50
|
+
|
|
51
|
+
This function standardizes file inputs into the proper structure for HTTP requests,
|
|
52
|
+
handling various input types and ensuring proper resource management.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
files: Dictionary mapping field names to file sources.
|
|
56
|
+
Supported types:
|
|
57
|
+
- str: File path (converted to Path)
|
|
58
|
+
- pathlib.Path: Local file path
|
|
59
|
+
- upath.UPath: Cloud storage path (GCS, S3, SFTP, etc.)
|
|
60
|
+
- bytes: Raw file content (e.g., for chunked uploads)
|
|
61
|
+
|
|
62
|
+
Example:
|
|
63
|
+
{'file': Path('/tmp/test.txt')}
|
|
64
|
+
{'document': 'uploads/doc.pdf'}
|
|
65
|
+
{'data': b'raw content'}
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
tuple[FilesDict, list[BinaryIO]]: A tuple containing:
|
|
69
|
+
- processed_files: Dictionary ready for requests library
|
|
70
|
+
Maps field names to RequestsFile objects (tuples of filename + file handle)
|
|
71
|
+
- opened_file_handles: List of opened file objects that need to be closed
|
|
72
|
+
Caller is responsible for closing these handles after the request
|
|
73
|
+
|
|
74
|
+
Raises:
|
|
75
|
+
FileValidationError: If file value is None or has invalid type
|
|
76
|
+
FileProcessingError: If file cannot be opened or read
|
|
77
|
+
|
|
78
|
+
Example:
|
|
79
|
+
>>> files = {'document': Path('/tmp/report.pdf'), 'metadata': b'{"version": 1}'}
|
|
80
|
+
>>> processed, handles = process_files_for_upload(files)
|
|
81
|
+
>>> try:
|
|
82
|
+
... response = requests.post(url, files=processed)
|
|
83
|
+
... finally:
|
|
84
|
+
... for handle in handles:
|
|
85
|
+
... handle.close()
|
|
86
|
+
|
|
87
|
+
Note:
|
|
88
|
+
- String paths are automatically converted to pathlib.Path objects
|
|
89
|
+
- Cloud storage paths (UPath) are supported via duck typing (has 'open' and 'name' attributes)
|
|
90
|
+
- Bytes are passed through unchanged (useful for chunked uploads)
|
|
91
|
+
- Opened file handles must be closed by the caller to prevent resource leaks
|
|
92
|
+
- If file opening fails, any previously opened handles are automatically closed
|
|
93
|
+
"""
|
|
94
|
+
processed_files: FilesDict = {}
|
|
95
|
+
opened_file_handles: list[BinaryIO] = []
|
|
96
|
+
|
|
97
|
+
for field_name, file_value in files.items():
|
|
98
|
+
# 1. Validate: Reject None values with clear error message
|
|
99
|
+
if file_value is None:
|
|
100
|
+
raise FileValidationError(
|
|
101
|
+
f"File field '{field_name}' cannot be None. "
|
|
102
|
+
f'Provide a valid file path (str or Path), UPath object, or bytes.'
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# 2. Handle bytes directly (for chunked uploads or raw content)
|
|
106
|
+
if isinstance(file_value, bytes):
|
|
107
|
+
processed_files[field_name] = file_value
|
|
108
|
+
continue
|
|
109
|
+
|
|
110
|
+
# 3. Convert string to Path for uniform handling
|
|
111
|
+
if isinstance(file_value, str):
|
|
112
|
+
file_value = Path(file_value)
|
|
113
|
+
|
|
114
|
+
# 4. Handle Path-like objects (pathlib.Path and upath.UPath)
|
|
115
|
+
# Using duck typing to support both standard Path and cloud storage UPath
|
|
116
|
+
if hasattr(file_value, 'open') and hasattr(file_value, 'name'):
|
|
117
|
+
try:
|
|
118
|
+
# Open file in binary read mode
|
|
119
|
+
opened_file: BinaryIO = file_value.open(mode='rb')
|
|
120
|
+
|
|
121
|
+
# Extract filename, use 'file' as fallback if name is empty
|
|
122
|
+
filename = file_value.name if file_value.name else 'file'
|
|
123
|
+
|
|
124
|
+
# Create file tuple: (filename, file_handle)
|
|
125
|
+
processed_files[field_name] = (filename, opened_file)
|
|
126
|
+
|
|
127
|
+
# Track opened handle for cleanup
|
|
128
|
+
opened_file_handles.append(opened_file)
|
|
129
|
+
|
|
130
|
+
except Exception as e:
|
|
131
|
+
# Clean up already opened files before raising
|
|
132
|
+
for f in opened_file_handles:
|
|
133
|
+
try:
|
|
134
|
+
f.close()
|
|
135
|
+
except Exception:
|
|
136
|
+
pass # Ignore errors during cleanup
|
|
137
|
+
|
|
138
|
+
raise FileProcessingError(f"Failed to open file '{file_value}' for field '{field_name}': {e}") from e
|
|
139
|
+
|
|
140
|
+
else:
|
|
141
|
+
# 5. Unsupported type - provide clear error message
|
|
142
|
+
raise FileValidationError(
|
|
143
|
+
f"File field '{field_name}' has unsupported type '{type(file_value).__name__}'. "
|
|
144
|
+
f'Supported types: str (file path), pathlib.Path, upath.UPath, or bytes. '
|
|
145
|
+
f'Got: {file_value!r}'
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
return processed_files, opened_file_handles
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def close_file_handles(handles: list[BinaryIO]) -> None:
|
|
152
|
+
"""Safely close multiple file handles, ignoring errors.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
handles: List of file handles to close
|
|
156
|
+
|
|
157
|
+
Note:
|
|
158
|
+
Errors during closing are silently ignored to ensure all handles
|
|
159
|
+
are attempted to be closed even if some fail.
|
|
160
|
+
"""
|
|
161
|
+
for handle in handles:
|
|
162
|
+
try:
|
|
163
|
+
handle.close()
|
|
164
|
+
except Exception:
|
|
165
|
+
pass # Ignore errors during cleanup
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Video processing utilities
|
|
2
|
+
|
|
3
|
+
from .transcode import (
|
|
4
|
+
FFmpegNotFoundError,
|
|
5
|
+
TranscodeConfig,
|
|
6
|
+
TranscodingFailedError,
|
|
7
|
+
UnsupportedFormatError,
|
|
8
|
+
VideoTranscodeError,
|
|
9
|
+
atranscode_video,
|
|
10
|
+
get_video_info,
|
|
11
|
+
optimize_for_web,
|
|
12
|
+
transcode_batch,
|
|
13
|
+
transcode_video,
|
|
14
|
+
validate_video_format,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
'TranscodeConfig',
|
|
19
|
+
'VideoTranscodeError',
|
|
20
|
+
'UnsupportedFormatError',
|
|
21
|
+
'FFmpegNotFoundError',
|
|
22
|
+
'TranscodingFailedError',
|
|
23
|
+
'transcode_video',
|
|
24
|
+
'atranscode_video',
|
|
25
|
+
'get_video_info',
|
|
26
|
+
'validate_video_format',
|
|
27
|
+
'optimize_for_web',
|
|
28
|
+
'transcode_batch',
|
|
29
|
+
]
|