├── .editorconfig ├── .git-blame-ignore-revs ├── .gitattributes ├── .github ├── renovate.json5 └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .python-version ├── LICENSE ├── README.md ├── biome.jsonc ├── example ├── README.md ├── docker-compose.yml ├── manage.py └── s3ff_example │ ├── core │ ├── __init__.py │ ├── admin.py │ ├── migrations │ │ ├── 0001_initial.py │ │ └── __init__.py │ ├── models.py │ ├── rest.py │ ├── serializers.py │ ├── templates │ │ ├── base.html │ │ └── core │ │ │ ├── resource_confirm_delete.html │ │ │ ├── resource_form.html │ │ │ └── resource_list.html │ └── views.py │ ├── settings.py │ ├── urls.py │ └── wsgi.py ├── javascript-client ├── README.md ├── package-lock.json ├── package.json ├── src │ └── client.ts └── tsconfig.json ├── multi_npm_builder.py ├── pyproject.toml ├── python-client ├── .python-version ├── README.md ├── pyproject.toml ├── s3_file_field_client │ ├── __init__.py │ └── py.typed └── uv.lock ├── s3_file_field ├── __init__.py ├── _multipart.py ├── _multipart_minio.py ├── _multipart_s3.py ├── _registry.py ├── _sizes.py ├── apps.py ├── checks.py ├── fields.py ├── fixtures.py ├── forms.py ├── py.typed ├── rest_framework.py ├── signals.py ├── urls.py ├── views.py └── widgets.py ├── stubs └── minio │ ├── __init__.pyi │ ├── api.pyi │ ├── datatypes.pyi │ ├── error.pyi │ ├── provider.pyi │ └── sse.pyi ├── tests ├── conftest.py ├── fuzzy.py ├── test_app │ ├── __init__.py │ ├── forms.py │ ├── models.py │ ├── rest.py │ ├── settings.py │ └── urls.py ├── test_checks.py ├── test_fields.py ├── test_fixtures.py ├── test_forms.py ├── test_multipart.py ├── test_registry.py ├── test_rest_framework.py ├── test_serializers.py └── test_views.py ├── tox.ini ├── uv.lock └── widget ├── package-lock.json ├── package.json ├── src ├── S3FileInput.ts ├── style.scss └── widget.ts └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | end_of_line = lf 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | charset = utf-8 9 | 10 | [*.toml] 11 | indent_size = 2 12 | 13 | [*.ini] 14 | indent_size = 4 15 | 16 | [*.py] 17 | indent_size = 4 18 | max_line_length = 100 19 | 20 | [*.{js,ts,vue}] 21 | indent_size = 2 22 | max_line_length = 100 23 | 24 | [*.json] 25 | indent_size = 2 26 | 27 | [*.html] 28 | indent_size = 2 29 | 30 | [*.css] 31 | indent_size = 2 32 | 33 | [{*.yml,*.yaml}] 34 | indent_size = 2 35 | -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # Switch Python files to use double quotes 2 | bed222ed6d7c46fe21cedd4624297ee6dad92f95 3 | # Apply automated Biome fixes 4 | 99e23fc00dad3e9f8e321d518bc53dad3765f0e9 5 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | package-lock.json linguist-generated=true 2 | yarn.lock linguist-generated=true 3 | -------------------------------------------------------------------------------- /.github/renovate.json5: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "local>kitware-resonant/.github:renovate-config" 5 | ], 6 | "packageRules": [ 7 | { 8 | // This is currently broken, so evaluate upgrades separately 9 | "groupName": "CI MinIO Docker", 10 | "matchPackagePatterns": ["bitnami/minio"] 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - master 7 | permissions: 8 | contents: read 9 | jobs: 10 | test-python: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | python-version: ["3.10", "3.11", "3.12", "3.13"] 15 | services: 16 | minio: 17 | # This image does not require any command arguments (which GitHub Actions don't support) 18 | image: bitnami/minio:2025.4.22 19 | env: 20 | MINIO_ROOT_USER: minioAccessKey 21 | MINIO_ROOT_PASSWORD: minioSecretKey 22 | options: >- 23 | --health-cmd "mc ready local" 24 | ports: 25 | - 9000:9000 26 | steps: 27 | - name: Checkout repository 28 | uses: actions/checkout@v4 29 | with: 30 | # Tags are needed to compute the current version number 31 | fetch-depth: 0 32 | - name: Install uv 33 | uses: astral-sh/setup-uv@v6 34 | with: 35 | python-version: ${{ matrix.python-version }} 36 | - name: Run tests 37 | env: 38 | MINIO_STORAGE_ENDPOINT: "localhost:9000" 39 | MINIO_STORAGE_ACCESS_KEY: minioAccessKey 40 | MINIO_STORAGE_SECRET_KEY: minioSecretKey 41 | MINIO_STORAGE_MEDIA_BUCKET_NAME: s3ff-test 42 | run: | 43 | uv run tox 44 | test-javascript: 45 | runs-on: ubuntu-latest 46 | steps: 47 | - name: Checkout repository 48 | uses: actions/checkout@v4 49 | - name: Set up Node.js 50 | uses: actions/setup-node@v4 51 | with: 52 | node-version: current 53 | - name: Build and test Javascript client 54 | working-directory: ./javascript-client 55 | run: | 56 | npm ci 57 | npm run build 58 | npm run test 59 | - name: Build and test widget 60 | working-directory: ./widget 61 | run: | 62 | npm ci 63 | npm run build 64 | npm run test 65 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: 3 | release: 4 | types: [published] 5 | jobs: 6 | publish: 7 | runs-on: ubuntu-latest 8 | environment: release 9 | permissions: 10 | contents: read 11 | id-token: write 12 | steps: 13 | - name: Checkout repository 14 | uses: actions/checkout@v4 15 | with: 16 | # Tags are needed to compute the current version number 17 | fetch-depth: 0 18 | - name: Install uv 19 | uses: astral-sh/setup-uv@v6 20 | - name: Build the Python distribution 21 | run: | 22 | uv build 23 | - name: Publish the Python distributions to PyPI 24 | run: | 25 | uv publish --trusted-publishing=always 26 | 27 | publish-python-client: 28 | runs-on: ubuntu-latest 29 | environment: release 30 | permissions: 31 | contents: read 32 | id-token: write 33 | steps: 34 | - name: Checkout repository 35 | uses: actions/checkout@v4 36 | with: 37 | # Tags are needed to compute the current version number 38 | fetch-depth: 0 39 | - name: Install uv 40 | uses: astral-sh/setup-uv@v6 41 | with: 42 | working-directory: python-client 43 | - name: Build the Python distribution 44 | run: | 45 | uv build 46 | working-directory: python-client 47 | - name: Publish the Python distributions to PyPI 48 | run: | 49 | uv publish --trusted-publishing=always 50 | working-directory: python-client 51 | 52 | publish-javascript-client: 53 | runs-on: ubuntu-latest 54 | environment: release 55 | permissions: 56 | contents: read 57 | steps: 58 | - uses: actions/checkout@v4 59 | with: 60 | # Tags are needed to compute the current version number 61 | fetch-depth: 0 62 | - name: Set up Node.js 63 | uses: actions/setup-node@v4 64 | with: 65 | node-version: current 66 | - name: Publish Javascript client to npm 67 | working-directory: ./javascript-client 68 | env: 69 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 70 | run: | 71 | npm ci 72 | npm version --no-git-tag-version $(git describe --tags --match '*[0-9]*' --abbrev=0 | sed 's/^v//') 73 | echo '//registry.npmjs.org/:_authToken=${NPM_TOKEN}' > ./.npmrc 74 | npm publish 75 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /s3_file_field/static 2 | 3 | # Created by https://www.toptal.com/developers/gitignore/api/django,node 4 | # Edit at https://www.toptal.com/developers/gitignore?templates=django,node 5 | 6 | ### Django ### 7 | *.log 8 | *.pot 9 | *.pyc 10 | __pycache__/ 11 | local_settings.py 12 | db.sqlite3 13 | db.sqlite3-journal 14 | media 15 | 16 | # If your build process includes running collectstatic, then you probably don't need or want to include staticfiles/ 17 | # in your Git repository. Update and uncomment the following line accordingly. 18 | # /staticfiles/ 19 | 20 | ### Django.Python Stack ### 21 | # Byte-compiled / optimized / DLL files 22 | *.py[cod] 23 | *$py.class 24 | 25 | # C extensions 26 | *.so 27 | 28 | # Distribution / packaging 29 | .Python 30 | build/ 31 | develop-eggs/ 32 | dist/ 33 | downloads/ 34 | eggs/ 35 | .eggs/ 36 | lib/ 37 | lib64/ 38 | parts/ 39 | sdist/ 40 | var/ 41 | wheels/ 42 | share/python-wheels/ 43 | *.egg-info/ 44 | .installed.cfg 45 | *.egg 46 | MANIFEST 47 | 48 | # PyInstaller 49 | # Usually these files are written by a python script from a template 50 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 51 | *.manifest 52 | *.spec 53 | 54 | # Installer logs 55 | pip-log.txt 56 | pip-delete-this-directory.txt 57 | 58 | # Unit test / coverage reports 59 | htmlcov/ 60 | .tox/ 61 | .nox/ 62 | .coverage 63 | .coverage.* 64 | .cache 65 | nosetests.xml 66 | coverage.xml 67 | *.cover 68 | *.py,cover 69 | .hypothesis/ 70 | .pytest_cache/ 71 | cover/ 72 | 73 | # Translations 74 | *.mo 75 | 76 | # Django stuff: 77 | 78 | # Flask stuff: 79 | instance/ 80 | .webassets-cache 81 | 82 | # Scrapy stuff: 83 | .scrapy 84 | 85 | # Sphinx documentation 86 | docs/_build/ 87 | 88 | # PyBuilder 89 | .pybuilder/ 90 | target/ 91 | 92 | # Jupyter Notebook 93 | .ipynb_checkpoints 94 | 95 | # IPython 96 | profile_default/ 97 | ipython_config.py 98 | 99 | # pyenv 100 | # For a library or package, you might want to ignore these files since the code is 101 | # intended to run in multiple environments; otherwise, check them in: 102 | # .python-version 103 | 104 | # pipenv 105 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 106 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 107 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 108 | # install all needed dependencies. 109 | #Pipfile.lock 110 | 111 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 112 | __pypackages__/ 113 | 114 | # Celery stuff 115 | celerybeat-schedule 116 | celerybeat.pid 117 | 118 | # SageMath parsed files 119 | *.sage.py 120 | 121 | # Environments 122 | .env 123 | .venv 124 | env/ 125 | venv/ 126 | ENV/ 127 | env.bak/ 128 | venv.bak/ 129 | 130 | # Spyder project settings 131 | .spyderproject 132 | .spyproject 133 | 134 | # Rope project settings 135 | .ropeproject 136 | 137 | # mkdocs documentation 138 | /site 139 | 140 | # mypy 141 | .mypy_cache/ 142 | .dmypy.json 143 | dmypy.json 144 | 145 | # Pyre type checker 146 | .pyre/ 147 | 148 | # pytype static type analyzer 149 | .pytype/ 150 | 151 | # Cython debug symbols 152 | cython_debug/ 153 | 154 | ### Node ### 155 | # Logs 156 | logs 157 | npm-debug.log* 158 | yarn-debug.log* 159 | yarn-error.log* 160 | lerna-debug.log* 161 | .pnpm-debug.log* 162 | 163 | # Diagnostic reports (https://nodejs.org/api/report.html) 164 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 165 | 166 | # Runtime data 167 | pids 168 | *.pid 169 | *.seed 170 | *.pid.lock 171 | 172 | # Directory for instrumented libs generated by jscoverage/JSCover 173 | lib-cov 174 | 175 | # Coverage directory used by tools like istanbul 176 | coverage 177 | *.lcov 178 | 179 | # nyc test coverage 180 | .nyc_output 181 | 182 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 183 | .grunt 184 | 185 | # Bower dependency directory (https://bower.io/) 186 | bower_components 187 | 188 | # node-waf configuration 189 | .lock-wscript 190 | 191 | # Compiled binary addons (https://nodejs.org/api/addons.html) 192 | build/Release 193 | 194 | # Dependency directories 195 | node_modules/ 196 | jspm_packages/ 197 | 198 | # Snowpack dependency directory (https://snowpack.dev/) 199 | web_modules/ 200 | 201 | # TypeScript cache 202 | *.tsbuildinfo 203 | 204 | # Optional npm cache directory 205 | .npm 206 | 207 | # Optional eslint cache 208 | .eslintcache 209 | 210 | # Microbundle cache 211 | .rpt2_cache/ 212 | .rts2_cache_cjs/ 213 | .rts2_cache_es/ 214 | .rts2_cache_umd/ 215 | 216 | # Optional REPL history 217 | .node_repl_history 218 | 219 | # Output of 'npm pack' 220 | *.tgz 221 | 222 | # Yarn Integrity file 223 | .yarn-integrity 224 | 225 | # dotenv environment variables file 226 | .env.test 227 | .env.production 228 | 229 | # parcel-bundler cache (https://parceljs.org/) 230 | .parcel-cache 231 | 232 | # Next.js build output 233 | .next 234 | out 235 | 236 | # Nuxt.js build / generate output 237 | .nuxt 238 | dist 239 | 240 | # Gatsby files 241 | .cache/ 242 | # Comment in the public line in if your project uses Gatsby and not Next.js 243 | # https://nextjs.org/blog/next-9-1#public-directory-support 244 | # public 245 | 246 | # vuepress build output 247 | .vuepress/dist 248 | 249 | # Serverless directories 250 | .serverless/ 251 | 252 | # FuseBox cache 253 | .fusebox/ 254 | 255 | # DynamoDB Local files 256 | .dynamodb/ 257 | 258 | # TernJS port file 259 | .tern-port 260 | 261 | # Stores VSCode versions used for testing VSCode extensions 262 | .vscode-test 263 | 264 | # yarn v2 265 | .yarn/cache 266 | .yarn/unplugged 267 | .yarn/build-state.yml 268 | .yarn/install-state.gz 269 | .pnp.* 270 | 271 | ### Node Patch ### 272 | # Serverless Webpack directories 273 | .webpack/ 274 | 275 | # End of https://www.toptal.com/developers/gitignore/api/django,node 276 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v2.0.0 4 | hooks: 5 | - id: check-added-large-files 6 | exclude: package-lock.json 7 | - id: check-ast 8 | - id: check-executables-have-shebangs 9 | - id: check-json 10 | - id: check-merge-conflict 11 | - id: check-symlinks 12 | - id: check-yaml 13 | - id: debug-statements 14 | - id: detect-aws-credentials 15 | args: [--allow-missing-credentials] 16 | - id: detect-private-key 17 | - id: end-of-file-fixer 18 | - id: trailing-whitespace 19 | 20 | - repo: local 21 | hooks: 22 | - id: toxlint 23 | name: tox 24 | entry: tox 25 | pass_filenames: false 26 | language: system 27 | exclude: setup.py 28 | types: [python] 29 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.13 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # django-s3-file-field 2 | [![PyPI](https://img.shields.io/pypi/v/django-s3-file-field)](https://pypi.org/project/django-s3-file-field/) 3 | 4 | django-s3-file-field is a Django library for uploading files directly to 5 | [AWS S3](https://aws.amazon.com/s3/) or [MinIO](https://min.io/) Storage from HTTP clients 6 | (browsers, CLIs, etc.). 7 | 8 | ### Benefits 9 | django-s3-file-field makes long-running file transfers (with large files or slow connections) 10 | more efficient, as the file content is no longer proxied through the Django server. This also frees 11 | Django from needing to maintain active HTTP requests during file upload, decreasing server load and 12 | facilitating deployment to environments like 13 | [Heroku, which have short, strict request timeouts](https://devcenter.heroku.com/articles/request-timeout). 14 | 15 | ### Scope 16 | The principal API of django-s3-file-field is the `S3FileField`, which is a subclass of 17 | [Django's `FileField`](https://docs.djangoproject.com/en/4.2/ref/models/fields/#filefield). 18 | django-s3-file-field does not affect any operations other than uploading from external HTTP 19 | clients; for all other file operations (downloading, uploading from the Python API, etc.), refer to 20 | [Django's file management documentation](https://docs.djangoproject.com/en/4.2/topics/files/). 21 | 22 | django-s3-file-field supports both the creation and modification (by overwrite) of 23 | `S3FileField`-containing `Model` instances. 24 | It supports server-rendered views, via the Forms API, with Form `Field` and `Widget` subclasses 25 | which will automatically be used for `ModelForm` instances. 26 | It also supports RESTful APIs, via Django Rest Framework's Serializer API, with a 27 | Serializer `Field` subclass which will automatically be used for `ModelSerializer` instances. 28 | 29 | ## Installation 30 | django-s3-file-field must be used with a compatible Django Storage, which are: 31 | * `S3Storage` in [django-storages](https://django-storages.readthedocs.io/), 32 | for [AWS S3](https://aws.amazon.com/s3/) 33 | * `MinioStorage` or `MinioMediaStorage` in [django-minio-storage](https://django-minio-storage.readthedocs.io/), 34 | for [MinIO](https://min.io/) 35 | 36 | After the appropriate Storage is installed and configured, install django-s3-file-field, using the 37 | corresponding extra: 38 | ```bash 39 | pip install django-s3-file-field[s3] 40 | ``` 41 | or 42 | ```bash 43 | pip install django-s3-file-field[minio] 44 | ``` 45 | 46 | Enable django-s3-file-field as an installed Django app: 47 | ```python 48 | # settings.py 49 | INSTALLED_APPS = [ 50 | ..., 51 | 's3_file_field', 52 | ] 53 | ``` 54 | 55 | Add django-s3-file-field's URLconf to the root URLconf; the path prefix (`'api/s3-upload/'`) 56 | can be changed arbitrarily as desired: 57 | ```python 58 | # urls.py 59 | from django.urls import include, path 60 | 61 | urlpatterns = [ 62 | ..., 63 | path('api/s3-upload/', include('s3_file_field.urls')), 64 | ] 65 | ``` 66 | 67 | ## Usage 68 | For all usage, define an `S3FileField` on a Django `Model`, instead of a `FileField`: 69 | ```python 70 | from django.db import models 71 | from s3_file_field import S3FileField 72 | 73 | class Resource(models.Model): 74 | blob = S3FileField() 75 | ``` 76 | 77 | ### Django Forms 78 | When defining a 79 | [Django `ModelForm`](https://docs.djangoproject.com/en/4.2/topics/forms/modelforms/), 80 | the appropriate Form `Field` will be automatically used: 81 | ```python 82 | from django.forms import ModelForm 83 | from .models import Resource 84 | 85 | class ResourceForm(ModelForm): 86 | class Meta: 87 | model = Resource 88 | fields = ['blob'] 89 | ``` 90 | 91 | Forms using django-s3-file-field include additional 92 | [assets](https://docs.djangoproject.com/en/4.2/topics/forms/media/), which it's essential to render 93 | along with the Form. Typically, this can be done in any Form-containing Template as: 94 | ``` 95 | 96 | {# Assuming the Form is availible in context as "form" #} 97 | {{ form.media }} 98 | 99 | ``` 100 | 101 | ### Django Rest Framework 102 | When defining a 103 | [Django Rest Framework `ModelSerializer`](https://www.django-rest-framework.org/api-guide/serializers/#modelserializer), 104 | the appropriate Serializer `Field` will be automatically used: 105 | ```python 106 | from rest_framework import serializers 107 | from .models import Resource 108 | 109 | class ResourceSerializer(serializers.ModelSerializer): 110 | class Meta: 111 | model = Resource 112 | fields = ['blob'] 113 | ``` 114 | 115 | Clients interacting with these RESTful APIs will need to use a corresponding django-s3-file-field 116 | client library. Client libraries (and associated documentation) are available for: 117 | * [Python](python-client/README.md) 118 | * [Javascript / TypeScript](javascript-client/README.md) 119 | 120 | ### Pytest 121 | When installed, django-s3-file-field makes several 122 | [Pytest fixtures](https://docs.pytest.org/en/latest/explanation/fixtures.html) automatically 123 | available for use. 124 | 125 | The `s3ff_field_value` fixture will return a valid input value for Django `ModelForm` or 126 | Django Rest Framework `ModelSerializer` subclasses: 127 | ```python 128 | from .forms import ResourceForm 129 | 130 | def test_resource_form(s3ff_field_value: str) -> None: 131 | form = ResourceForm(data={'blob': s3ff_field_value}) 132 | assert form.is_valid() 133 | ``` 134 | 135 | Alternatively, the `s3ff_field_value_factory` fixture transforms a `File` object into a valid input 136 | value (for Django `ModelForm` or Django Rest Framework `ModelSerializer` subclasses), providing 137 | more control over the uploaded file: 138 | ```python 139 | from django.core.files.storage import default_storage 140 | from rest_framework.test import APIClient 141 | 142 | def test_resource_create(s3ff_field_value_factory): 143 | client = APIClient() 144 | stored_file = default_storage.open('some_existing_file.txt') 145 | s3ff_field_value = s3ff_field_value_factory(stored_file) 146 | resp = client.post('/resource', data={'blob': s3ff_field_value}) 147 | assert resp.status_code == 201 148 | ``` 149 | -------------------------------------------------------------------------------- /biome.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", 3 | "vcs": { 4 | "enabled": true, 5 | "clientKind": "git", 6 | "useIgnoreFile": true 7 | }, 8 | "formatter": { 9 | "useEditorconfig": true 10 | }, 11 | "linter": { 12 | "rules": { 13 | "all": true, 14 | "correctness": { 15 | "useImportExtensions": { 16 | "level": "warn", 17 | "options": { 18 | "suggestedExtensions": { 19 | "ts": { 20 | "module": "js", 21 | "component": "jsx" 22 | } 23 | } 24 | } 25 | } 26 | }, 27 | "performance": { 28 | "noBarrelFile": "off", 29 | "noReExportAll": "off" 30 | }, 31 | "suspicious": { 32 | // useAwait incorrectly flags cases when a promise is returned 33 | // https://github.com/biomejs/biome/issues/1161 34 | "useAwait": "off" 35 | }, 36 | "style": { 37 | "noDefaultExport": "off", 38 | // noDoneCallback flags on Vite context 39 | "noDoneCallback": "off", 40 | "noParameterProperties": "off", 41 | "useNamingConvention": "off" 42 | } 43 | } 44 | }, 45 | "javascript": { 46 | "formatter": { 47 | "quoteStyle": "single" 48 | } 49 | }, 50 | "overrides": [ 51 | { 52 | "include": ["tests/**"], 53 | "linter": { 54 | "rules": { 55 | "performance": { 56 | // useTopLevelRegex is unreasonable for test cases 57 | "useTopLevelRegex": "off" 58 | } 59 | } 60 | } 61 | } 62 | ] 63 | } 64 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # S3 File Field example 2 | 3 | This provides an example Django project, `s3ff_example`, 4 | for use in local development and debugging. 5 | 6 | Some settings used here are not appropriate for production use. 7 | 8 | # Setup 9 | * In a separate terminal: 10 | ```bash 11 | docker-compose up 12 | ``` 13 | 14 | * In the main terminal: 15 | ```bash 16 | uv run --extra minio ./manage.py migrate 17 | ``` 18 | 19 | * To allow usage of the admin page: 20 | ```bash 21 | uv run --extra minio ./manage.py createsuperuser 22 | ``` 23 | 24 | # Run 25 | * Ensure `docker-compose up` is still running 26 | 27 | * In the main terminal: 28 | ```bash 29 | uv run --extra minio ./manage.py runserver 30 | ``` 31 | 32 | * Load http://localhost:8000/ in a web browser 33 | -------------------------------------------------------------------------------- /example/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | minio: 3 | image: minio/minio:latest 4 | # When run with a TTY, minio prints credentials on startup 5 | tty: true 6 | command: ["server", "/data", "--console-address", ":9001"] 7 | environment: 8 | MINIO_ROOT_USER: minioAccessKey 9 | MINIO_ROOT_PASSWORD: minioSecretKey 10 | healthcheck: 11 | test: ["CMD", "mc", "ready", "local"] 12 | ports: 13 | - 9000:9000 14 | - 9001:9001 15 | -------------------------------------------------------------------------------- /example/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | from django.core.management import execute_from_command_line 6 | 7 | 8 | def main() -> None: 9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "s3ff_example.settings") 10 | execute_from_command_line(sys.argv) 11 | 12 | 13 | if __name__ == "__main__": 14 | main() 15 | -------------------------------------------------------------------------------- /example/s3ff_example/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kitware-resonant/django-s3-file-field/73be92f74f2047d3a8f132c935008ac6234e3d15/example/s3ff_example/core/__init__.py -------------------------------------------------------------------------------- /example/s3ff_example/core/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from .models import Resource 4 | 5 | 6 | @admin.register(Resource) 7 | class ResourceAdmin(admin.ModelAdmin): 8 | pass 9 | -------------------------------------------------------------------------------- /example/s3ff_example/core/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django.db import migrations, models 4 | 5 | import s3_file_field.fields 6 | 7 | 8 | class Migration(migrations.Migration): 9 | initial = True 10 | 11 | dependencies = [] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name="Resource", 16 | fields=[ 17 | ( 18 | "id", 19 | models.AutoField( 20 | auto_created=True, primary_key=True, serialize=False, verbose_name="ID" 21 | ), 22 | ), 23 | ("legacy_optional_blob", models.FileField(blank=True, upload_to="")), 24 | ("s3ff_mandatory_blob", s3_file_field.fields.S3FileField()), 25 | ("s3ff_optional_blob", s3_file_field.fields.S3FileField(blank=True)), 26 | ], 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /example/s3ff_example/core/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kitware-resonant/django-s3-file-field/73be92f74f2047d3a8f132c935008ac6234e3d15/example/s3ff_example/core/migrations/__init__.py -------------------------------------------------------------------------------- /example/s3ff_example/core/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.urls import reverse 3 | 4 | from s3_file_field.fields import S3FileField 5 | 6 | 7 | class Resource(models.Model): 8 | legacy_optional_blob = models.FileField(blank=True) 9 | s3ff_mandatory_blob = S3FileField() 10 | s3ff_optional_blob = S3FileField(blank=True) 11 | 12 | def get_absolute_url(self): 13 | return reverse("resource-update", kwargs={"pk": self.pk}) 14 | -------------------------------------------------------------------------------- /example/s3ff_example/core/rest.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers, viewsets 2 | 3 | from .models import Resource 4 | 5 | 6 | class ResourceSerializer(serializers.ModelSerializer): 7 | class Meta: 8 | model = Resource 9 | fields = "__all__" 10 | 11 | 12 | class ResourceViewSet(viewsets.ModelViewSet): 13 | queryset = Resource.objects.all() 14 | serializer_class = ResourceSerializer 15 | -------------------------------------------------------------------------------- /example/s3ff_example/core/serializers.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kitware-resonant/django-s3-file-field/73be92f74f2047d3a8f132c935008ac6234e3d15/example/s3ff_example/core/serializers.py -------------------------------------------------------------------------------- /example/s3ff_example/core/templates/base.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 | 4 | {% block title %}{% endblock %} 5 | {% block head %}{% endblock %} 6 | 7 | 8 | {% block content %}{% endblock %} 9 | 10 | 11 | -------------------------------------------------------------------------------- /example/s3ff_example/core/templates/core/resource_confirm_delete.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}Delete Resource{% endblock %} 4 | 5 | {% block content %} 6 | [Back to List] 7 |

Delete Resource

8 | 9 |
10 | {% csrf_token %} 11 |

Are you sure you want to delete "{{ object }}"?

12 | 13 |
14 | {% endblock %} 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /example/s3ff_example/core/templates/core/resource_form.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}Resource{% endblock %} 4 | 5 | {% block head %}{{ form.media }}{% endblock %} 6 | 7 | {% block content %} 8 | [Back to List] 9 |

Resource

10 | 11 | {# multipart/form-data is required to submit legacy files #} 12 |
13 | {% csrf_token %} 14 | {{ form.as_p }} 15 | 16 |
17 | {% endblock %} 18 | -------------------------------------------------------------------------------- /example/s3ff_example/core/templates/core/resource_list.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}Resource List{% endblock %} 4 | 5 | {% block content %} 6 |

Resource List

7 | 8 | 21 | 22 | Create New Resource 23 | {% endblock %} 24 | -------------------------------------------------------------------------------- /example/s3ff_example/core/views.py: -------------------------------------------------------------------------------- 1 | from django.urls import reverse_lazy 2 | from django.views import generic 3 | 4 | from .models import Resource 5 | 6 | 7 | class ResourceList(generic.ListView): 8 | model = Resource 9 | 10 | 11 | class ResourceCreate(generic.CreateView): 12 | model = Resource 13 | fields = "__all__" 14 | 15 | 16 | class ResourceUpdate(generic.UpdateView): 17 | model = Resource 18 | fields = "__all__" 19 | 20 | 21 | class ResourceDelete(generic.DeleteView): 22 | model = Resource 23 | success_url = reverse_lazy("resource-list") 24 | -------------------------------------------------------------------------------- /example/s3ff_example/settings.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | from pathlib import Path 5 | 6 | SECRET_KEY = "example-secret" 7 | 8 | DEBUG = True 9 | 10 | ALLOWED_HOSTS: list[str] = [] 11 | 12 | INSTALLED_APPS = [ 13 | "django.contrib.admin", 14 | "django.contrib.auth", 15 | "django.contrib.contenttypes", 16 | "django.contrib.sessions", 17 | "django.contrib.messages", 18 | "django.contrib.staticfiles", 19 | "rest_framework", 20 | "rest_framework.authtoken", 21 | "s3_file_field", 22 | "s3ff_example.core", 23 | ] 24 | 25 | MIDDLEWARE = [ 26 | "django.middleware.security.SecurityMiddleware", 27 | "django.contrib.sessions.middleware.SessionMiddleware", 28 | "django.middleware.common.CommonMiddleware", 29 | "django.middleware.csrf.CsrfViewMiddleware", 30 | "django.contrib.auth.middleware.AuthenticationMiddleware", 31 | "django.contrib.messages.middleware.MessageMiddleware", 32 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 33 | ] 34 | 35 | BASE_DIR = Path(__file__).resolve().parent.parent 36 | DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": BASE_DIR / "db.sqlite3"}} 37 | 38 | TEMPLATES = [ 39 | { 40 | "BACKEND": "django.template.backends.django.DjangoTemplates", 41 | "APP_DIRS": True, 42 | "OPTIONS": { 43 | "context_processors": [ 44 | "django.template.context_processors.debug", 45 | "django.template.context_processors.request", 46 | "django.contrib.auth.context_processors.auth", 47 | "django.contrib.messages.context_processors.messages", 48 | ] 49 | }, 50 | } 51 | ] 52 | 53 | STATIC_URL = "/static/" 54 | 55 | ROOT_URLCONF = "s3ff_example.urls" 56 | WSGI_APPLICATION = "s3ff_example.wsgi.application" 57 | 58 | STORAGES = { 59 | "staticfiles": { 60 | "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage", 61 | }, 62 | } 63 | if "AWS_ACCESS_KEY_ID" in os.environ: 64 | STORAGES["default"] = { 65 | "BACKEND": "storages.backends.s3.S3Storage", 66 | } 67 | AWS_S3_REGION_NAME = os.environ.get("AWS_DEFAULT_REGION", "us-east1") 68 | AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID") 69 | AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY") 70 | AWS_STORAGE_BUCKET_NAME = os.environ.get("AWS_STORAGE_BUCKET_NAME") 71 | AWS_S3_SIGNATURE_VERSION = "s3v4" 72 | else: 73 | STORAGES["default"] = { 74 | "BACKEND": "minio_storage.storage.MinioMediaStorage", 75 | } 76 | MINIO_STORAGE_ENDPOINT = "localhost:9000" 77 | MINIO_STORAGE_USE_HTTPS = False 78 | MINIO_STORAGE_ACCESS_KEY = "minioAccessKey" 79 | MINIO_STORAGE_SECRET_KEY = "minioSecretKey" 80 | MINIO_STORAGE_MEDIA_BUCKET_NAME = "s3ff-example" 81 | MINIO_STORAGE_AUTO_CREATE_MEDIA_BUCKET = True 82 | MINIO_STORAGE_AUTO_CREATE_MEDIA_POLICY = "READ_WRITE" 83 | MINIO_STORAGE_MEDIA_USE_PRESIGNED = True 84 | -------------------------------------------------------------------------------- /example/s3ff_example/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import include, path 3 | from django.views.generic import RedirectView 4 | from rest_framework import routers 5 | from s3ff_example.core import rest, views 6 | 7 | router = routers.DefaultRouter() 8 | router.register("resources", rest.ResourceViewSet, basename="api") 9 | 10 | urlpatterns = [ 11 | path("admin/", admin.site.urls), 12 | path("api/s3ff/", include("s3_file_field.urls")), 13 | path("", RedirectView.as_view(pattern_name="resource-list")), 14 | path("resources/", views.ResourceList.as_view(), name="resource-list"), 15 | path("resources/create/", views.ResourceCreate.as_view(), name="resource-create"), 16 | path("resources//", views.ResourceUpdate.as_view(), name="resource-update"), 17 | path("resources//delete/", views.ResourceDelete.as_view(), name="resource-delete"), 18 | path("api/", include(router.urls)), 19 | ] 20 | -------------------------------------------------------------------------------- /example/s3ff_example/wsgi.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.core.wsgi import get_wsgi_application 4 | 5 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "s3ff_example.settings") 6 | 7 | application = get_wsgi_application() 8 | -------------------------------------------------------------------------------- /javascript-client/README.md: -------------------------------------------------------------------------------- 1 | # django-s3-file-field-client 2 | [![npm](https://img.shields.io/npm/v/django-s3-file-field)](https://www.npmjs.com/package/django-s3-file-field) 3 | 4 | A Javascript (with TypeScript support) client library for django-s3-file-field. 5 | 6 | ## Installation 7 | ```bash 8 | npm install django-s3-file-field 9 | ``` 10 | 11 | or 12 | 13 | ```bash 14 | yarn add django-s3-file-field 15 | ``` 16 | 17 | ## Usage 18 | ```typescript 19 | import axios from 'axios'; 20 | import S3FileFieldClient, { S3FileFieldProgress, S3FileFieldProgressState } from 'django-s3-file-field'; 21 | 22 | function onUploadProgress (progress: S3FileFieldProgress) { 23 | if (progress.state == S3FileFieldProgressState.Sending) { 24 | console.log(`Uploading ${progress.uploaded} / ${progress.total}`); 25 | } 26 | } 27 | 28 | const apiClient = axios.create(...); // This can be used to set authentication headers, etc. 29 | 30 | const s3ffClient = new S3FileFieldClient({ 31 | baseUrl: process.env.S3FF_BASE_URL, // e.g. 'http://localhost:8000/api/v1/s3-upload/', the path mounted in urlpatterns 32 | apiConfig: apiClient.defaults, // This argument is optional 33 | }); 34 | 35 | // This might be run in an event handler 36 | const file = document.getElementById('my-file-input').files[0]; 37 | 38 | const fieldValue = await s3ffClient.uploadFile( 39 | file, 40 | 'core.File.blob', // The ".." to upload to, 41 | onUploadProgress, // This argument is optional 42 | ); 43 | 44 | apiClient.post( 45 | 'http://localhost:8000/api/v1/file/', // This is particular to the application 46 | { 47 | 'blob': fieldValue, // This should match the field uploaded to (e.g. 'core.File.blob') 48 | ...: ..., // Other fields for the POST request 49 | } 50 | ); 51 | ``` 52 | -------------------------------------------------------------------------------- /javascript-client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "django-s3-file-field", 3 | "version": "0.0.0", 4 | "description": "A Javascript client library for django-s3-file-field.", 5 | "repository": "github:kitware-resonant/django-s3-file-field", 6 | "author": "Kitware, Inc. ", 7 | "license": "Apache-2.0", 8 | "type": "module", 9 | "engines": { 10 | "node": ">=16" 11 | }, 12 | "module": "./dist/client.js", 13 | "exports": "./dist/client.js", 14 | "types": "./dist/client.d.ts", 15 | "files": ["/src/", "/dist/"], 16 | "scripts": { 17 | "test:lint": "biome check", 18 | "test:type": "tsc --noEmit", 19 | "test": "npm-run-all test:*", 20 | "format": "biome check --write", 21 | "watch": "tsc --watch", 22 | "build:clean": "rimraf ./dist", 23 | "build:compile": "tsc", 24 | "build": "npm-run-all build:clean build:compile", 25 | "prepack": "npm-run-all build" 26 | }, 27 | "dependencies": { 28 | "axios": "^1.9.0" 29 | }, 30 | "devDependencies": { 31 | "@biomejs/biome": "^1.9.4", 32 | "@tsconfig/recommended": "^1.0.8", 33 | "npm-run-all2": "^8.0.0", 34 | "rimraf": "^6.0.1", 35 | "typescript": "^5.8.3" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /javascript-client/src/client.ts: -------------------------------------------------------------------------------- 1 | import axios, { type AxiosInstance, type AxiosRequestConfig } from 'axios'; 2 | 3 | // Description of a part from initializeUpload() 4 | interface PartInfo { 5 | part_number: number; 6 | size: number; 7 | upload_url: string; 8 | } 9 | // Description of the upload from initializeUpload() 10 | interface MultipartInfo { 11 | upload_signature: string; 12 | object_key: string; 13 | upload_id: string; 14 | parts: PartInfo[]; 15 | } 16 | // Description of a part which has been uploaded by uploadPart() 17 | interface UploadedPart { 18 | part_number: number; 19 | size: number; 20 | etag: string; 21 | } 22 | interface CompletionResponse { 23 | complete_url: string; 24 | body: string; 25 | } 26 | interface FinalizationResponse { 27 | field_value: string; 28 | } 29 | 30 | export enum S3FileFieldProgressState { 31 | Initializing = 0, 32 | Sending = 1, 33 | Finalizing = 2, 34 | Done = 3, 35 | } 36 | 37 | export interface S3FileFieldProgress { 38 | readonly uploaded?: number; 39 | readonly total?: number; 40 | readonly state: S3FileFieldProgressState; 41 | } 42 | 43 | export type S3FileFieldProgressCallback = (progress: S3FileFieldProgress) => void; 44 | 45 | export interface S3FileFieldClientOptions { 46 | readonly baseUrl: string; 47 | readonly apiConfig?: AxiosRequestConfig; 48 | } 49 | 50 | export default class S3FileFieldClient { 51 | protected readonly api: AxiosInstance; 52 | 53 | /** 54 | * Create an S3FileFieldClient instance. 55 | * 56 | * @param options {S3FileFieldClientOptions} - A Object with all arguments. 57 | * @param options.baseUrl - The absolute URL to the Django server. 58 | * @param [options.apiConfig] - An axios configuration to use for Django API requests. 59 | * Can be extracted from an existing axios instance via `.defaults`. 60 | */ 61 | constructor({ baseUrl, apiConfig = {} }: S3FileFieldClientOptions) { 62 | this.api = axios.create({ 63 | ...apiConfig, 64 | // Add a trailing slash 65 | // biome-ignore lint/performance/useTopLevelRegex: constructor is called infrequently 66 | baseURL: baseUrl.replace(/\/?$/, '/'), 67 | }); 68 | } 69 | 70 | /** 71 | * Initializes an upload. 72 | * 73 | * @param file - The file to upload. 74 | * @param fieldId - The Django field identifier. 75 | */ 76 | protected async initializeUpload(file: File, fieldId: string): Promise { 77 | const response = await this.api.post('upload-initialize/', { 78 | field_id: fieldId, 79 | file_name: file.name, 80 | file_size: file.size, 81 | // An unknown type is '' 82 | content_type: file.type || 'application/octet-stream', 83 | }); 84 | return response.data; 85 | } 86 | 87 | /** 88 | * Uploads all the parts in a file directly to an object store in serial. 89 | * 90 | * @param file - The file to upload. 91 | * @param parts - The list of parts describing how to break up the file. 92 | * @param onProgress - A callback for upload progress. 93 | */ 94 | protected async uploadParts( 95 | file: File, 96 | parts: PartInfo[], 97 | onProgress: S3FileFieldProgressCallback, 98 | ): Promise { 99 | const uploadedParts: UploadedPart[] = []; 100 | let fileOffset = 0; 101 | for (const part of parts) { 102 | const chunk = file.slice(fileOffset, fileOffset + part.size); 103 | const response = await axios.put(part.upload_url, chunk, { 104 | onUploadProgress: (e) => { 105 | onProgress({ 106 | uploaded: fileOffset + e.loaded, 107 | total: file.size, 108 | state: S3FileFieldProgressState.Sending, 109 | }); 110 | }, 111 | }); 112 | const { etag } = response.headers; 113 | // ETag might be absent due to CORS misconfiguration, but dumb typings from Axios also make it 114 | // structurally possible to be many other types 115 | if (typeof etag !== 'string') { 116 | throw new Error('ETag header missing from response.'); 117 | } 118 | uploadedParts.push({ 119 | part_number: part.part_number, 120 | size: part.size, 121 | etag, 122 | }); 123 | fileOffset += part.size; 124 | } 125 | return uploadedParts; 126 | } 127 | 128 | /** 129 | * Completes an upload. 130 | * 131 | * The object will exist in the object store after completion. 132 | * 133 | * @param multipartInfo - The information describing the multipart upload. 134 | * @param parts - The parts that were uploaded. 135 | */ 136 | protected async completeUpload( 137 | multipartInfo: MultipartInfo, 138 | parts: UploadedPart[], 139 | ): Promise { 140 | const response = await this.api.post('upload-complete/', { 141 | upload_signature: multipartInfo.upload_signature, 142 | upload_id: multipartInfo.upload_id, 143 | parts, 144 | }); 145 | const { complete_url: completeUrl, body } = response.data; 146 | 147 | // Send the CompleteMultipartUpload operation to S3 148 | await axios.post(completeUrl, body, { 149 | headers: { 150 | // By default, Axios sets "Content-Type: application/x-www-form-urlencoded" on POST 151 | // requests. This causes AWS's API to interpret the request body as additional parameters 152 | // to include in the signature validation, causing it to fail. 153 | // So, do not send this request with any Content-Type, as that is what's specified by the 154 | // CompleteMultipartUpload docs. 155 | // Unsetting default headers via "transformRequest" is awkward (since the headers aren't 156 | // flattened), so this is actually; the most straightforward way; the null value is passed 157 | // through to XMLHttpRequest, then ignored. 158 | 'Content-Type': null as unknown as string, 159 | }, 160 | }); 161 | } 162 | 163 | /** 164 | * Finalizes an upload. 165 | * 166 | * This will only succeed if the object is already present in the object store. 167 | * 168 | * @param multipartInfo - Signed information returned from /upload-complete/. 169 | */ 170 | protected async finalize(multipartInfo: MultipartInfo): Promise { 171 | const response = await this.api.post('finalize/', { 172 | upload_signature: multipartInfo.upload_signature, 173 | }); 174 | return response.data.field_value; 175 | } 176 | 177 | /** 178 | * Uploads a file using multipart upload. 179 | * 180 | * @param file - The file to upload. 181 | * @param fieldId - The Django field identifier. 182 | * @param [onProgress] - A callback for upload progress. 183 | */ 184 | public async uploadFile( 185 | file: File, 186 | fieldId: string, 187 | onProgress: S3FileFieldProgressCallback = () => { 188 | /* no-op */ 189 | }, 190 | ): Promise { 191 | onProgress({ state: S3FileFieldProgressState.Initializing }); 192 | const multipartInfo = await this.initializeUpload(file, fieldId); 193 | onProgress({ state: S3FileFieldProgressState.Sending, uploaded: 0, total: file.size }); 194 | const parts = await this.uploadParts(file, multipartInfo.parts, onProgress); 195 | onProgress({ state: S3FileFieldProgressState.Finalizing }); 196 | await this.completeUpload(multipartInfo, parts); 197 | const fieldValue = await this.finalize(multipartInfo); 198 | onProgress({ state: S3FileFieldProgressState.Done }); 199 | return fieldValue; 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /javascript-client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/recommended/tsconfig.json", 3 | "compilerOptions": { 4 | "target": "ES2020", 5 | "module": "NodeNext", 6 | "moduleResolution": "NodeNext", 7 | "declaration": true, 8 | "sourceMap": true, 9 | "declarationMap": true, 10 | "outDir": "./dist" 11 | }, 12 | "include": ["src/**/*"] 13 | } 14 | -------------------------------------------------------------------------------- /multi_npm_builder.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TypedDict 4 | 5 | from hatch_jupyter_builder import npm_builder 6 | 7 | 8 | class BuildKwargs(TypedDict): 9 | build_cmd: str 10 | path: str 11 | source_dir: str 12 | build_dir: str 13 | 14 | 15 | def multi_npm_builder( 16 | target_name: str, 17 | version: str, 18 | *, 19 | projects: list[BuildKwargs], 20 | ) -> None: 21 | for project in projects: 22 | # TODO: allow multiple source_dir for a build, so the "widget" will be rebuilt when 23 | # the "javascript-client" changes 24 | npm_builder( 25 | target_name, 26 | version, 27 | build_cmd=project["build_cmd"], 28 | path=project["path"], 29 | source_dir=project["source_dir"], 30 | build_dir=project["build_dir"], 31 | ) 32 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling", "hatch-vcs"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "django-s3-file-field" 7 | description = "A Django library for uploading files directly to AWS S3 or MinIO Storage from HTTP clients." 8 | readme = "README.md" 9 | requires-python = ">=3.10" 10 | license = "Apache-2.0" 11 | license-files = ["LICENSE"] 12 | maintainers = [{ name = "Kitware, Inc.", email = "kitware@kitware.com" }] 13 | keywords = [ 14 | "django", 15 | "django-widget", 16 | "resonant", 17 | "kitware-resonant", 18 | "minio", 19 | "s3", 20 | ] 21 | classifiers = [ 22 | "Development Status :: 5 - Production/Stable", 23 | "Environment :: Web Environment", 24 | "Framework :: Django :: 4", 25 | "Framework :: Django :: 4.2", 26 | "Framework :: Django :: 5", 27 | "Framework :: Django :: 5.1", 28 | "Framework :: Django :: 5.2", 29 | "Framework :: Django", 30 | "Intended Audience :: Developers", 31 | "Operating System :: OS Independent", 32 | "Programming Language :: Python :: 3", 33 | "Programming Language :: Python :: 3.10", 34 | "Programming Language :: Python :: 3.11", 35 | "Programming Language :: Python :: 3.12", 36 | "Programming Language :: Python :: 3.13", 37 | "Programming Language :: Python", 38 | ] 39 | dependencies = [ 40 | "django>=4.2", 41 | "djangorestframework", 42 | ] 43 | dynamic = ["version"] 44 | 45 | [project.urls] 46 | Repository = "https://github.com/kitware-resonant/django-s3-file-field" 47 | "Bug Reports" = "https://github.com/kitware-resonant/django-s3-file-field/issues" 48 | 49 | [project.entry-points.pytest11] 50 | s3_file_field = "s3_file_field.fixtures" 51 | 52 | [project.optional-dependencies] 53 | s3 = [ 54 | # 1.14 has some breaking renames which are adopted here 55 | "django-storages[s3]>=1.14", 56 | "boto3", 57 | ] 58 | minio = [ 59 | # This is the first version which supports minio>=7, so make dependency resolution easier 60 | "django-minio-storage>=0.5", 61 | "minio>=7", 62 | ] 63 | pytest = [ 64 | # The "fixtures.py" module (containing the "pytest" requirement) is only loaded 65 | # automatically via entry point by consumers who already have "pytest" installed, so 66 | # "pytest" isn't actually a hard requirement. 67 | "pytest", 68 | ] 69 | 70 | [dependency-groups] 71 | dev = [ 72 | "tox", 73 | ] 74 | lint = [ 75 | "flake8", 76 | "flake8-black", 77 | "flake8-bugbear", 78 | "flake8-docstrings", 79 | "flake8-isort", 80 | "pep8-naming", 81 | ] 82 | format = [ 83 | "black", 84 | "isort", 85 | ] 86 | type = [ 87 | "mypy", 88 | "boto3-stubs[s3]", 89 | "django-stubs[compatible-mypy]", 90 | "djangorestframework-stubs", 91 | "types-requests", 92 | "types-factory-boy", 93 | ] 94 | test = [ 95 | "factory-boy", 96 | "pytest", 97 | "pytest-cov", 98 | "pytest-django", 99 | "pytest-mock", 100 | "requests", 101 | ] 102 | 103 | [tool.hatch.build] 104 | only-include = [ 105 | "s3_file_field", 106 | ] 107 | artifacts = [ 108 | "s3_file_field/static", 109 | ] 110 | 111 | [tool.hatch.build.targets.sdist] 112 | only-include = [ 113 | "s3_file_field", 114 | # The builder needs to run again when installing the sdist, although it will no-op 115 | "multi_npm_builder.py", 116 | ] 117 | 118 | [tool.hatch.build.hooks.jupyter-builder] 119 | dependencies = ["hatch-jupyter-builder"] 120 | build-function = "multi_npm_builder.multi_npm_builder" 121 | ensured-targets = [ 122 | "s3_file_field/static/s3_file_field/widget.js", 123 | "s3_file_field/static/s3_file_field/widget.css", 124 | ] 125 | 126 | [[tool.hatch.build.hooks.jupyter-builder.build-kwargs.projects]] 127 | build_cmd = "build" 128 | path = "javascript-client" 129 | source_dir = "javascript-client" 130 | build_dir = "javascript-client/dist" 131 | 132 | [[tool.hatch.build.hooks.jupyter-builder.build-kwargs.projects]] 133 | build_cmd = "build" 134 | path = "widget" 135 | source_dir = "widget" 136 | build_dir = "s3_file_field/static/s3_file_field" 137 | 138 | [tool.hatch.version] 139 | source = "vcs" 140 | 141 | [tool.black] 142 | line-length = 100 143 | target-version = ["py38"] 144 | 145 | [tool.coverage.run] 146 | source_pkgs = [ 147 | "s3_file_field", 148 | ] 149 | 150 | [tool.coverage.paths] 151 | source = [ 152 | "s3_file_field/", 153 | ".tox/**/site-packages/s3_file_field/", 154 | ] 155 | 156 | [tool.django-stubs] 157 | django_settings_module = "test_app.settings" 158 | 159 | [tool.isort] 160 | profile = "black" 161 | line_length = 100 162 | # Sort by name, don't cluster "from" vs "import" 163 | force_sort_within_sections = true 164 | # Combines "as" imports on the same line 165 | combine_as_imports = true 166 | 167 | # These test utilities are local, but are loaded as absolute imports 168 | known_local_folder = [ 169 | "fuzzy", 170 | "test_app", 171 | ] 172 | 173 | [tool.mypy] 174 | files = [ 175 | "s3_file_field", 176 | "tests", 177 | "python-client/s3_file_field_client", 178 | ] 179 | check_untyped_defs = true 180 | show_error_codes = true 181 | warn_redundant_casts = true 182 | warn_unused_configs = true 183 | warn_unused_ignores = true 184 | plugins = [ 185 | "mypy_django_plugin.main", 186 | "mypy_drf_plugin.main", 187 | ] 188 | mypy_path = [ 189 | "$MYPY_CONFIG_FILE_DIR/stubs", 190 | # Use the same pythonpath for MyPy as for Pytest 191 | "tests", 192 | ] 193 | # Don't allow "tests/" to be accidently imported as a namespace package, 194 | # as that allows multiple possible import paths 195 | namespace_packages = false 196 | 197 | [[tool.mypy.overrides]] 198 | module = [ 199 | "storages.backends.s3", 200 | ] 201 | ignore_missing_imports = true 202 | 203 | [tool.pytest.ini_options] 204 | testpaths = ["tests"] 205 | # Allow test utilities to be imported without adding the root directory to the pythonpath, 206 | # which would cause the local "s3_file_field" module to shadow the install. 207 | pythonpath = ["tests"] 208 | # Configure pythonpath ourselves, not based on manage.py 209 | django_find_project = false 210 | addopts = [ 211 | # Test utilies are imported absolutely from the pythonpath, 212 | # so use the Pytest-reccomended "importlib" mode 213 | "--import-mode=importlib", 214 | "--strict-config", 215 | "--strict-markers", 216 | "--showlocals", 217 | "--verbose", 218 | # Disable the entry point loading of "s3_file_field" fixtures, to support coverage. 219 | # See the comment with "pytest_plugins" in "conftest.py" for full details. 220 | "-p", "no:s3_file_field", 221 | "--cov", 222 | ] 223 | filterwarnings = [ 224 | "error", 225 | # pytest often causes unclosed socket warnings 226 | 'ignore:unclosed .." to upload to 32 | ) 33 | 34 | api_client.post( 35 | 'http://localhost:8000/api/v1/file/', # This is particular to the application 36 | json={ 37 | 'blob': field_value, # This should match the field uploaded to (e.g. 'core.File.blob') 38 | ...: ..., # Other fields for the POST request 39 | } 40 | ) 41 | ``` 42 | -------------------------------------------------------------------------------- /python-client/pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling", "hatch-vcs"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "django-s3-file-field-client" 7 | description = "A Python client library for django-s3-file-field." 8 | readme = "README.md" 9 | requires-python = ">=3.10" 10 | license = "Apache-2.0" 11 | license-files = ["LICENSE"] 12 | maintainers = [{ name = "Kitware, Inc.", email = "kitware@kitware.com" }] 13 | keywords = [ 14 | "django", 15 | "django-widget", 16 | "minio", 17 | "s3", 18 | ] 19 | classifiers = [ 20 | "Development Status :: 5 - Production/Stable", 21 | "Environment :: Web Environment", 22 | "Framework :: Django :: 4", 23 | "Framework :: Django :: 4.2", 24 | "Framework :: Django :: 5", 25 | "Framework :: Django :: 5.1", 26 | "Framework :: Django :: 5.2", 27 | "Framework :: Django", 28 | "Intended Audience :: Developers", 29 | "Operating System :: OS Independent", 30 | "Programming Language :: Python :: 3", 31 | "Programming Language :: Python :: 3.10", 32 | "Programming Language :: Python :: 3.11", 33 | "Programming Language :: Python :: 3.12", 34 | "Programming Language :: Python :: 3.13", 35 | "Programming Language :: Python", 36 | ] 37 | dependencies = [ 38 | "requests", 39 | ] 40 | dynamic = ["version"] 41 | 42 | [project.urls] 43 | Repository = "https://github.com/kitware-resonant/django-s3-file-field" 44 | "Bug Reports" = "https://github.com/kitware-resonant/django-s3-file-field/issues" 45 | 46 | [tool.hatch.build] 47 | only-include = [ 48 | "s3_file_field_client", 49 | ] 50 | 51 | [tool.hatch.version] 52 | source = "vcs" 53 | raw-options = { root = ".." } 54 | -------------------------------------------------------------------------------- /python-client/s3_file_field_client/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | import io 5 | from typing import BinaryIO, ClassVar 6 | 7 | import requests 8 | 9 | 10 | @dataclass 11 | class _File: 12 | name: str 13 | size: int 14 | content_type: str 15 | stream: BinaryIO 16 | 17 | @classmethod 18 | def from_stream(cls, stream: BinaryIO, name: str, content_type: str) -> _File: 19 | if not stream.seekable(): 20 | raise RuntimeError("File stream is not seekable.") 21 | 22 | stream.seek(0, io.SEEK_END) 23 | size = stream.tell() 24 | stream.seek(0, io.SEEK_SET) 25 | 26 | return cls(name=name, size=size, content_type=content_type, stream=stream) 27 | 28 | 29 | class S3FileFieldClient: 30 | request_timeout: ClassVar[int] = 5 31 | base_url: str 32 | api_session: requests.Session 33 | 34 | def __init__(self, base_url: str, api_session: requests.Session | None = None) -> None: 35 | self.base_url = base_url.rstrip("/") 36 | self.api_session = requests.Session() if api_session is None else api_session 37 | 38 | def _initialize_upload(self, file: _File, field_id: str) -> dict: 39 | resp = self.api_session.post( 40 | f"{self.base_url}/upload-initialize/", 41 | json={ 42 | "field_id": field_id, 43 | "file_name": file.name, 44 | "file_size": file.size, 45 | "content_type": file.content_type, 46 | }, 47 | timeout=self.request_timeout, 48 | ) 49 | resp.raise_for_status() 50 | return resp.json() 51 | 52 | def _upload_part(self, part_bytes: bytes, part_initialization: dict) -> dict: 53 | resp = requests.put( 54 | part_initialization["upload_url"], data=part_bytes, timeout=self.request_timeout 55 | ) 56 | resp.raise_for_status() 57 | 58 | etag = resp.headers["ETag"] 59 | 60 | return { 61 | "part_number": part_initialization["part_number"], 62 | "size": part_initialization["size"], 63 | "etag": etag, 64 | } 65 | 66 | def _upload_parts(self, file: _File, part_initializations: list[dict]) -> list[dict]: 67 | return [ 68 | self._upload_part(file.stream.read(part_initialization["size"]), part_initialization) 69 | for part_initialization in part_initializations 70 | ] 71 | 72 | def _complete_upload(self, multipart_info: dict, upload_infos: list[dict]) -> None: 73 | resp = self.api_session.post( 74 | f"{self.base_url}/upload-complete/", 75 | json={ 76 | "upload_id": multipart_info["upload_id"], 77 | "parts": upload_infos, 78 | "upload_signature": multipart_info["upload_signature"], 79 | }, 80 | timeout=self.request_timeout, 81 | ) 82 | resp.raise_for_status() 83 | completion_data = resp.json() 84 | 85 | complete_resp = requests.post( 86 | completion_data["complete_url"], 87 | data=completion_data["body"], 88 | timeout=self.request_timeout, 89 | ) 90 | complete_resp.raise_for_status() 91 | 92 | def _finalize(self, multipart_info: dict) -> str: 93 | resp = self.api_session.post( 94 | f"{self.base_url}/finalize/", 95 | json={ 96 | "upload_signature": multipart_info["upload_signature"], 97 | }, 98 | timeout=self.request_timeout, 99 | ) 100 | resp.raise_for_status() 101 | return resp.json()["field_value"] 102 | 103 | def upload_file( 104 | self, *, file_stream: BinaryIO, file_name: str, file_content_type: str, field_id: str 105 | ) -> str: 106 | file = _File.from_stream(file_stream, file_name, file_content_type) 107 | multipart_info = self._initialize_upload(file, field_id) 108 | upload_infos = self._upload_parts(file, multipart_info["parts"]) 109 | self._complete_upload(multipart_info, upload_infos) 110 | return self._finalize(multipart_info) 111 | -------------------------------------------------------------------------------- /python-client/s3_file_field_client/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kitware-resonant/django-s3-file-field/73be92f74f2047d3a8f132c935008ac6234e3d15/python-client/s3_file_field_client/py.typed -------------------------------------------------------------------------------- /python-client/uv.lock: -------------------------------------------------------------------------------- 1 | version = 1 2 | revision = 2 3 | requires-python = ">=3.10" 4 | 5 | [[package]] 6 | name = "certifi" 7 | version = "2025.4.26" 8 | source = { registry = "https://pypi.org/simple" } 9 | sdist = { url = "https://files.pythonhosted.org/packages/e8/9e/c05b3920a3b7d20d3d3310465f50348e5b3694f4f88c6daf736eef3024c4/certifi-2025.4.26.tar.gz", hash = "sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6", size = 160705, upload-time = "2025-04-26T02:12:29.51Z" } 10 | wheels = [ 11 | { url = "https://files.pythonhosted.org/packages/4a/7e/3db2bd1b1f9e95f7cddca6d6e75e2f2bd9f51b1246e546d88addca0106bd/certifi-2025.4.26-py3-none-any.whl", hash = "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3", size = 159618, upload-time = "2025-04-26T02:12:27.662Z" }, 12 | ] 13 | 14 | [[package]] 15 | name = "charset-normalizer" 16 | version = "3.4.2" 17 | source = { registry = "https://pypi.org/simple" } 18 | sdist = { url = "https://files.pythonhosted.org/packages/e4/33/89c2ced2b67d1c2a61c19c6751aa8902d46ce3dacb23600a283619f5a12d/charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63", size = 126367, upload-time = "2025-05-02T08:34:42.01Z" } 19 | wheels = [ 20 | { url = "https://files.pythonhosted.org/packages/95/28/9901804da60055b406e1a1c5ba7aac1276fb77f1dde635aabfc7fd84b8ab/charset_normalizer-3.4.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c48ed483eb946e6c04ccbe02c6b4d1d48e51944b6db70f697e089c193404941", size = 201818, upload-time = "2025-05-02T08:31:46.725Z" }, 21 | { url = "https://files.pythonhosted.org/packages/d9/9b/892a8c8af9110935e5adcbb06d9c6fe741b6bb02608c6513983048ba1a18/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2d318c11350e10662026ad0eb71bb51c7812fc8590825304ae0bdd4ac283acd", size = 144649, upload-time = "2025-05-02T08:31:48.889Z" }, 22 | { url = "https://files.pythonhosted.org/packages/7b/a5/4179abd063ff6414223575e008593861d62abfc22455b5d1a44995b7c101/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9cbfacf36cb0ec2897ce0ebc5d08ca44213af24265bd56eca54bee7923c48fd6", size = 155045, upload-time = "2025-05-02T08:31:50.757Z" }, 23 | { url = "https://files.pythonhosted.org/packages/3b/95/bc08c7dfeddd26b4be8c8287b9bb055716f31077c8b0ea1cd09553794665/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18dd2e350387c87dabe711b86f83c9c78af772c748904d372ade190b5c7c9d4d", size = 147356, upload-time = "2025-05-02T08:31:52.634Z" }, 24 | { url = "https://files.pythonhosted.org/packages/a8/2d/7a5b635aa65284bf3eab7653e8b4151ab420ecbae918d3e359d1947b4d61/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8075c35cd58273fee266c58c0c9b670947c19df5fb98e7b66710e04ad4e9ff86", size = 149471, upload-time = "2025-05-02T08:31:56.207Z" }, 25 | { url = "https://files.pythonhosted.org/packages/ae/38/51fc6ac74251fd331a8cfdb7ec57beba8c23fd5493f1050f71c87ef77ed0/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5bf4545e3b962767e5c06fe1738f951f77d27967cb2caa64c28be7c4563e162c", size = 151317, upload-time = "2025-05-02T08:31:57.613Z" }, 26 | { url = "https://files.pythonhosted.org/packages/b7/17/edee1e32215ee6e9e46c3e482645b46575a44a2d72c7dfd49e49f60ce6bf/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7a6ab32f7210554a96cd9e33abe3ddd86732beeafc7a28e9955cdf22ffadbab0", size = 146368, upload-time = "2025-05-02T08:31:59.468Z" }, 27 | { url = "https://files.pythonhosted.org/packages/26/2c/ea3e66f2b5f21fd00b2825c94cafb8c326ea6240cd80a91eb09e4a285830/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b33de11b92e9f75a2b545d6e9b6f37e398d86c3e9e9653c4864eb7e89c5773ef", size = 154491, upload-time = "2025-05-02T08:32:01.219Z" }, 28 | { url = "https://files.pythonhosted.org/packages/52/47/7be7fa972422ad062e909fd62460d45c3ef4c141805b7078dbab15904ff7/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8755483f3c00d6c9a77f490c17e6ab0c8729e39e6390328e42521ef175380ae6", size = 157695, upload-time = "2025-05-02T08:32:03.045Z" }, 29 | { url = "https://files.pythonhosted.org/packages/2f/42/9f02c194da282b2b340f28e5fb60762de1151387a36842a92b533685c61e/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:68a328e5f55ec37c57f19ebb1fdc56a248db2e3e9ad769919a58672958e8f366", size = 154849, upload-time = "2025-05-02T08:32:04.651Z" }, 30 | { url = "https://files.pythonhosted.org/packages/67/44/89cacd6628f31fb0b63201a618049be4be2a7435a31b55b5eb1c3674547a/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:21b2899062867b0e1fde9b724f8aecb1af14f2778d69aacd1a5a1853a597a5db", size = 150091, upload-time = "2025-05-02T08:32:06.719Z" }, 31 | { url = "https://files.pythonhosted.org/packages/1f/79/4b8da9f712bc079c0f16b6d67b099b0b8d808c2292c937f267d816ec5ecc/charset_normalizer-3.4.2-cp310-cp310-win32.whl", hash = "sha256:e8082b26888e2f8b36a042a58307d5b917ef2b1cacab921ad3323ef91901c71a", size = 98445, upload-time = "2025-05-02T08:32:08.66Z" }, 32 | { url = "https://files.pythonhosted.org/packages/7d/d7/96970afb4fb66497a40761cdf7bd4f6fca0fc7bafde3a84f836c1f57a926/charset_normalizer-3.4.2-cp310-cp310-win_amd64.whl", hash = "sha256:f69a27e45c43520f5487f27627059b64aaf160415589230992cec34c5e18a509", size = 105782, upload-time = "2025-05-02T08:32:10.46Z" }, 33 | { url = "https://files.pythonhosted.org/packages/05/85/4c40d00dcc6284a1c1ad5de5e0996b06f39d8232f1031cd23c2f5c07ee86/charset_normalizer-3.4.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:be1e352acbe3c78727a16a455126d9ff83ea2dfdcbc83148d2982305a04714c2", size = 198794, upload-time = "2025-05-02T08:32:11.945Z" }, 34 | { url = "https://files.pythonhosted.org/packages/41/d9/7a6c0b9db952598e97e93cbdfcb91bacd89b9b88c7c983250a77c008703c/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa88ca0b1932e93f2d961bf3addbb2db902198dca337d88c89e1559e066e7645", size = 142846, upload-time = "2025-05-02T08:32:13.946Z" }, 35 | { url = "https://files.pythonhosted.org/packages/66/82/a37989cda2ace7e37f36c1a8ed16c58cf48965a79c2142713244bf945c89/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d524ba3f1581b35c03cb42beebab4a13e6cdad7b36246bd22541fa585a56cccd", size = 153350, upload-time = "2025-05-02T08:32:15.873Z" }, 36 | { url = "https://files.pythonhosted.org/packages/df/68/a576b31b694d07b53807269d05ec3f6f1093e9545e8607121995ba7a8313/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28a1005facc94196e1fb3e82a3d442a9d9110b8434fc1ded7a24a2983c9888d8", size = 145657, upload-time = "2025-05-02T08:32:17.283Z" }, 37 | { url = "https://files.pythonhosted.org/packages/92/9b/ad67f03d74554bed3aefd56fe836e1623a50780f7c998d00ca128924a499/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fdb20a30fe1175ecabed17cbf7812f7b804b8a315a25f24678bcdf120a90077f", size = 147260, upload-time = "2025-05-02T08:32:18.807Z" }, 38 | { url = "https://files.pythonhosted.org/packages/a6/e6/8aebae25e328160b20e31a7e9929b1578bbdc7f42e66f46595a432f8539e/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f5d9ed7f254402c9e7d35d2f5972c9bbea9040e99cd2861bd77dc68263277c7", size = 149164, upload-time = "2025-05-02T08:32:20.333Z" }, 39 | { url = "https://files.pythonhosted.org/packages/8b/f2/b3c2f07dbcc248805f10e67a0262c93308cfa149a4cd3d1fe01f593e5fd2/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:efd387a49825780ff861998cd959767800d54f8308936b21025326de4b5a42b9", size = 144571, upload-time = "2025-05-02T08:32:21.86Z" }, 40 | { url = "https://files.pythonhosted.org/packages/60/5b/c3f3a94bc345bc211622ea59b4bed9ae63c00920e2e8f11824aa5708e8b7/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f0aa37f3c979cf2546b73e8222bbfa3dc07a641585340179d768068e3455e544", size = 151952, upload-time = "2025-05-02T08:32:23.434Z" }, 41 | { url = "https://files.pythonhosted.org/packages/e2/4d/ff460c8b474122334c2fa394a3f99a04cf11c646da895f81402ae54f5c42/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e70e990b2137b29dc5564715de1e12701815dacc1d056308e2b17e9095372a82", size = 155959, upload-time = "2025-05-02T08:32:24.993Z" }, 42 | { url = "https://files.pythonhosted.org/packages/a2/2b/b964c6a2fda88611a1fe3d4c400d39c66a42d6c169c924818c848f922415/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0c8c57f84ccfc871a48a47321cfa49ae1df56cd1d965a09abe84066f6853b9c0", size = 153030, upload-time = "2025-05-02T08:32:26.435Z" }, 43 | { url = "https://files.pythonhosted.org/packages/59/2e/d3b9811db26a5ebf444bc0fa4f4be5aa6d76fc6e1c0fd537b16c14e849b6/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6b66f92b17849b85cad91259efc341dce9c1af48e2173bf38a85c6329f1033e5", size = 148015, upload-time = "2025-05-02T08:32:28.376Z" }, 44 | { url = "https://files.pythonhosted.org/packages/90/07/c5fd7c11eafd561bb51220d600a788f1c8d77c5eef37ee49454cc5c35575/charset_normalizer-3.4.2-cp311-cp311-win32.whl", hash = "sha256:daac4765328a919a805fa5e2720f3e94767abd632ae410a9062dff5412bae65a", size = 98106, upload-time = "2025-05-02T08:32:30.281Z" }, 45 | { url = "https://files.pythonhosted.org/packages/a8/05/5e33dbef7e2f773d672b6d79f10ec633d4a71cd96db6673625838a4fd532/charset_normalizer-3.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:e53efc7c7cee4c1e70661e2e112ca46a575f90ed9ae3fef200f2a25e954f4b28", size = 105402, upload-time = "2025-05-02T08:32:32.191Z" }, 46 | { url = "https://files.pythonhosted.org/packages/d7/a4/37f4d6035c89cac7930395a35cc0f1b872e652eaafb76a6075943754f095/charset_normalizer-3.4.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7", size = 199936, upload-time = "2025-05-02T08:32:33.712Z" }, 47 | { url = "https://files.pythonhosted.org/packages/ee/8a/1a5e33b73e0d9287274f899d967907cd0bf9c343e651755d9307e0dbf2b3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3", size = 143790, upload-time = "2025-05-02T08:32:35.768Z" }, 48 | { url = "https://files.pythonhosted.org/packages/66/52/59521f1d8e6ab1482164fa21409c5ef44da3e9f653c13ba71becdd98dec3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a", size = 153924, upload-time = "2025-05-02T08:32:37.284Z" }, 49 | { url = "https://files.pythonhosted.org/packages/86/2d/fb55fdf41964ec782febbf33cb64be480a6b8f16ded2dbe8db27a405c09f/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214", size = 146626, upload-time = "2025-05-02T08:32:38.803Z" }, 50 | { url = "https://files.pythonhosted.org/packages/8c/73/6ede2ec59bce19b3edf4209d70004253ec5f4e319f9a2e3f2f15601ed5f7/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a", size = 148567, upload-time = "2025-05-02T08:32:40.251Z" }, 51 | { url = "https://files.pythonhosted.org/packages/09/14/957d03c6dc343c04904530b6bef4e5efae5ec7d7990a7cbb868e4595ee30/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd", size = 150957, upload-time = "2025-05-02T08:32:41.705Z" }, 52 | { url = "https://files.pythonhosted.org/packages/0d/c8/8174d0e5c10ccebdcb1b53cc959591c4c722a3ad92461a273e86b9f5a302/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981", size = 145408, upload-time = "2025-05-02T08:32:43.709Z" }, 53 | { url = "https://files.pythonhosted.org/packages/58/aa/8904b84bc8084ac19dc52feb4f5952c6df03ffb460a887b42615ee1382e8/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c", size = 153399, upload-time = "2025-05-02T08:32:46.197Z" }, 54 | { url = "https://files.pythonhosted.org/packages/c2/26/89ee1f0e264d201cb65cf054aca6038c03b1a0c6b4ae998070392a3ce605/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b", size = 156815, upload-time = "2025-05-02T08:32:48.105Z" }, 55 | { url = "https://files.pythonhosted.org/packages/fd/07/68e95b4b345bad3dbbd3a8681737b4338ff2c9df29856a6d6d23ac4c73cb/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d", size = 154537, upload-time = "2025-05-02T08:32:49.719Z" }, 56 | { url = "https://files.pythonhosted.org/packages/77/1a/5eefc0ce04affb98af07bc05f3bac9094513c0e23b0562d64af46a06aae4/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f", size = 149565, upload-time = "2025-05-02T08:32:51.404Z" }, 57 | { url = "https://files.pythonhosted.org/packages/37/a0/2410e5e6032a174c95e0806b1a6585eb21e12f445ebe239fac441995226a/charset_normalizer-3.4.2-cp312-cp312-win32.whl", hash = "sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c", size = 98357, upload-time = "2025-05-02T08:32:53.079Z" }, 58 | { url = "https://files.pythonhosted.org/packages/6c/4f/c02d5c493967af3eda9c771ad4d2bbc8df6f99ddbeb37ceea6e8716a32bc/charset_normalizer-3.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e", size = 105776, upload-time = "2025-05-02T08:32:54.573Z" }, 59 | { url = "https://files.pythonhosted.org/packages/ea/12/a93df3366ed32db1d907d7593a94f1fe6293903e3e92967bebd6950ed12c/charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0", size = 199622, upload-time = "2025-05-02T08:32:56.363Z" }, 60 | { url = "https://files.pythonhosted.org/packages/04/93/bf204e6f344c39d9937d3c13c8cd5bbfc266472e51fc8c07cb7f64fcd2de/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf", size = 143435, upload-time = "2025-05-02T08:32:58.551Z" }, 61 | { url = "https://files.pythonhosted.org/packages/22/2a/ea8a2095b0bafa6c5b5a55ffdc2f924455233ee7b91c69b7edfcc9e02284/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e", size = 153653, upload-time = "2025-05-02T08:33:00.342Z" }, 62 | { url = "https://files.pythonhosted.org/packages/b6/57/1b090ff183d13cef485dfbe272e2fe57622a76694061353c59da52c9a659/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1", size = 146231, upload-time = "2025-05-02T08:33:02.081Z" }, 63 | { url = "https://files.pythonhosted.org/packages/e2/28/ffc026b26f441fc67bd21ab7f03b313ab3fe46714a14b516f931abe1a2d8/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c", size = 148243, upload-time = "2025-05-02T08:33:04.063Z" }, 64 | { url = "https://files.pythonhosted.org/packages/c0/0f/9abe9bd191629c33e69e47c6ef45ef99773320e9ad8e9cb08b8ab4a8d4cb/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691", size = 150442, upload-time = "2025-05-02T08:33:06.418Z" }, 65 | { url = "https://files.pythonhosted.org/packages/67/7c/a123bbcedca91d5916c056407f89a7f5e8fdfce12ba825d7d6b9954a1a3c/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0", size = 145147, upload-time = "2025-05-02T08:33:08.183Z" }, 66 | { url = "https://files.pythonhosted.org/packages/ec/fe/1ac556fa4899d967b83e9893788e86b6af4d83e4726511eaaad035e36595/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b", size = 153057, upload-time = "2025-05-02T08:33:09.986Z" }, 67 | { url = "https://files.pythonhosted.org/packages/2b/ff/acfc0b0a70b19e3e54febdd5301a98b72fa07635e56f24f60502e954c461/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff", size = 156454, upload-time = "2025-05-02T08:33:11.814Z" }, 68 | { url = "https://files.pythonhosted.org/packages/92/08/95b458ce9c740d0645feb0e96cea1f5ec946ea9c580a94adfe0b617f3573/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b", size = 154174, upload-time = "2025-05-02T08:33:13.707Z" }, 69 | { url = "https://files.pythonhosted.org/packages/78/be/8392efc43487ac051eee6c36d5fbd63032d78f7728cb37aebcc98191f1ff/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148", size = 149166, upload-time = "2025-05-02T08:33:15.458Z" }, 70 | { url = "https://files.pythonhosted.org/packages/44/96/392abd49b094d30b91d9fbda6a69519e95802250b777841cf3bda8fe136c/charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7", size = 98064, upload-time = "2025-05-02T08:33:17.06Z" }, 71 | { url = "https://files.pythonhosted.org/packages/e9/b0/0200da600134e001d91851ddc797809e2fe0ea72de90e09bec5a2fbdaccb/charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980", size = 105641, upload-time = "2025-05-02T08:33:18.753Z" }, 72 | { url = "https://files.pythonhosted.org/packages/20/94/c5790835a017658cbfabd07f3bfb549140c3ac458cfc196323996b10095a/charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", size = 52626, upload-time = "2025-05-02T08:34:40.053Z" }, 73 | ] 74 | 75 | [[package]] 76 | name = "django-s3-file-field-client" 77 | source = { editable = "." } 78 | dependencies = [ 79 | { name = "requests" }, 80 | ] 81 | 82 | [package.metadata] 83 | requires-dist = [{ name = "requests" }] 84 | 85 | [[package]] 86 | name = "idna" 87 | version = "3.10" 88 | source = { registry = "https://pypi.org/simple" } 89 | sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } 90 | wheels = [ 91 | { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, 92 | ] 93 | 94 | [[package]] 95 | name = "requests" 96 | version = "2.32.3" 97 | source = { registry = "https://pypi.org/simple" } 98 | dependencies = [ 99 | { name = "certifi" }, 100 | { name = "charset-normalizer" }, 101 | { name = "idna" }, 102 | { name = "urllib3" }, 103 | ] 104 | sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218, upload-time = "2024-05-29T15:37:49.536Z" } 105 | wheels = [ 106 | { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928, upload-time = "2024-05-29T15:37:47.027Z" }, 107 | ] 108 | 109 | [[package]] 110 | name = "urllib3" 111 | version = "2.4.0" 112 | source = { registry = "https://pypi.org/simple" } 113 | sdist = { url = "https://files.pythonhosted.org/packages/8a/78/16493d9c386d8e60e442a35feac5e00f0913c0f4b7c217c11e8ec2ff53e0/urllib3-2.4.0.tar.gz", hash = "sha256:414bc6535b787febd7567804cc015fee39daab8ad86268f1310a9250697de466", size = 390672, upload-time = "2025-04-10T15:23:39.232Z" } 114 | wheels = [ 115 | { url = "https://files.pythonhosted.org/packages/6b/11/cc635220681e93a0183390e26485430ca2c7b5f9d33b15c74c2861cb8091/urllib3-2.4.0-py3-none-any.whl", hash = "sha256:4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813", size = 128680, upload-time = "2025-04-10T15:23:37.377Z" }, 116 | ] 117 | -------------------------------------------------------------------------------- /s3_file_field/__init__.py: -------------------------------------------------------------------------------- 1 | # The documentation should always reference s3_file_field.S3FileField 2 | # and this cannot change without breaking the migrations of downstream 3 | # projects. 4 | from .fields import S3FileField # noqa: F401 5 | -------------------------------------------------------------------------------- /s3_file_field/_multipart.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from datetime import timedelta 5 | import math 6 | from typing import TYPE_CHECKING, Any, ClassVar, Iterator 7 | 8 | from s3_file_field._sizes import gb, mb 9 | 10 | if TYPE_CHECKING: 11 | from django.core.files.storage import Storage 12 | 13 | 14 | @dataclass 15 | class PresignedPartTransfer: 16 | part_number: int 17 | size: int 18 | upload_url: str 19 | 20 | 21 | @dataclass 22 | class PresignedTransfer: 23 | object_key: str 24 | upload_id: str 25 | parts: list[PresignedPartTransfer] 26 | 27 | 28 | @dataclass 29 | class TransferredPart: 30 | part_number: int 31 | size: int 32 | etag: str 33 | 34 | 35 | @dataclass 36 | class TransferredParts: 37 | object_key: str 38 | upload_id: str 39 | parts: list[TransferredPart] 40 | 41 | 42 | @dataclass 43 | class PresignedUploadCompletion: 44 | complete_url: str 45 | body: str 46 | 47 | 48 | class UnsupportedStorageError(Exception): 49 | """Raised when MultipartManager does not support the given Storage.""" 50 | 51 | def __init__(self, *args: Any) -> None: 52 | super().__init__("Unsupported storage provider.", *args) 53 | 54 | 55 | class ObjectNotFoundError(Exception): 56 | """Raised when an object cannot be found in the object store.""" 57 | 58 | 59 | class UploadTooLargeError(Exception): 60 | """Raised when an upload exceeds the maximum object size for a Storage.""" 61 | 62 | def __init__(self, *args: Any) -> None: 63 | super().__init__("File is larger than the maximum object size.", *args) 64 | 65 | 66 | class MultipartManager: 67 | """A facade providing management of S3 multipart uploads to multiple Storages.""" 68 | 69 | part_size: ClassVar[int] = mb(64) 70 | max_object_size: ClassVar[int] 71 | 72 | def initialize_upload( 73 | self, 74 | object_key: str, 75 | file_size: int, 76 | content_type: str, 77 | ) -> PresignedTransfer: 78 | if file_size > self.max_object_size: 79 | raise UploadTooLargeError("File is larger than the S3 maximum object size.") 80 | 81 | upload_id = self._create_upload_id( 82 | object_key, 83 | content_type, 84 | ) 85 | parts = [ 86 | PresignedPartTransfer( 87 | part_number=part_number, 88 | size=part_size, 89 | upload_url=self._generate_presigned_part_url( 90 | object_key, upload_id, part_number, part_size 91 | ), 92 | ) 93 | for part_number, part_size in self._iter_part_sizes(file_size) 94 | ] 95 | return PresignedTransfer(object_key=object_key, upload_id=upload_id, parts=parts) 96 | 97 | def complete_upload(self, transferred_parts: TransferredParts) -> PresignedUploadCompletion: 98 | complete_url = self._generate_presigned_complete_url(transferred_parts) 99 | body = self._generate_presigned_complete_body(transferred_parts) 100 | return PresignedUploadCompletion(complete_url=complete_url, body=body) 101 | 102 | def _generate_presigned_complete_body(self, transferred_parts: TransferredParts) -> str: 103 | """ 104 | Generate the body of a presigned completion request. 105 | 106 | See https://docs.aws.amazon.com/AmazonS3/latest/API/API_CompleteMultipartUpload.html 107 | """ 108 | body = '' 109 | body += '' 110 | for part in transferred_parts.parts: 111 | body += "" 112 | body += f"{part.part_number}" 113 | body += f"{part.etag}" 114 | body += "" 115 | body += "" 116 | return body 117 | 118 | def test_upload(self) -> None: 119 | object_key = ".s3-file-field-test-file" 120 | # TODO: is it possible to use a shorter timeout? 121 | upload_id = self._create_upload_id(object_key, "application/octet-stream") 122 | self._abort_upload_id(object_key, upload_id) 123 | 124 | @classmethod 125 | def from_storage(cls, storage: Storage) -> MultipartManager: 126 | try: 127 | from storages.backends.s3 import S3Storage 128 | except ImportError: 129 | pass 130 | else: 131 | if isinstance(storage, S3Storage): 132 | from ._multipart_s3 import S3MultipartManager 133 | 134 | return S3MultipartManager(storage) 135 | 136 | try: 137 | from minio_storage.storage import MinioStorage 138 | except ImportError: 139 | pass 140 | else: 141 | if isinstance(storage, MinioStorage): 142 | from ._multipart_minio import MinioMultipartManager 143 | 144 | return MinioMultipartManager(storage) 145 | 146 | raise UnsupportedStorageError 147 | 148 | @classmethod 149 | def supported_storage(cls, storage: Storage) -> bool: 150 | try: 151 | cls.from_storage(storage) 152 | except UnsupportedStorageError: 153 | return False 154 | # Allow other exceptions to propagate 155 | else: 156 | return True 157 | 158 | # The AWS default expiration of 1 hour may not be enough for large uploads to complete 159 | _url_expiration = timedelta(hours=24) 160 | 161 | def _create_upload_id( 162 | self, 163 | object_key: str, 164 | content_type: str, 165 | ) -> str: 166 | # Require content headers here 167 | raise NotImplementedError 168 | 169 | def _abort_upload_id(self, object_key: str, upload_id: str) -> None: 170 | raise NotImplementedError 171 | 172 | def _generate_presigned_part_url( 173 | self, object_key: str, upload_id: str, part_number: int, part_size: int 174 | ) -> str: 175 | raise NotImplementedError 176 | 177 | def _generate_presigned_complete_url(self, transferred_parts: TransferredParts) -> str: 178 | raise NotImplementedError 179 | 180 | def get_object_size(self, object_key: str) -> int: 181 | raise NotImplementedError 182 | 183 | @classmethod 184 | def _iter_part_sizes(cls, file_size: int) -> Iterator[tuple[int, int]]: 185 | part_size = cls.part_size 186 | 187 | # 10k is the maximum number of allowed parts allowed by S3 188 | max_parts = 10_000 189 | if math.ceil(file_size / part_size) >= max_parts: 190 | part_size = math.ceil(file_size / max_parts) 191 | 192 | # 5MB is the minimum part size allowed by S3 193 | min_part_size = mb(5) 194 | if part_size < min_part_size: 195 | part_size = min_part_size 196 | 197 | # 5GB is the maximum part size allowed by S3 198 | max_part_size = gb(5) 199 | if part_size > max_part_size: 200 | part_size = max_part_size 201 | 202 | remaining_file_size = file_size 203 | part_num = 1 204 | while remaining_file_size > 0: 205 | current_part_size = ( 206 | part_size if remaining_file_size - part_size > 0 else remaining_file_size 207 | ) 208 | 209 | yield part_num, current_part_size 210 | 211 | part_num += 1 212 | remaining_file_size -= part_size 213 | 214 | # TODO: key name encoding... 215 | -------------------------------------------------------------------------------- /s3_file_field/_multipart_minio.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | import minio 6 | 7 | from ._multipart import MultipartManager, ObjectNotFoundError, TransferredParts 8 | from ._sizes import tb 9 | 10 | if TYPE_CHECKING: 11 | from minio_storage.storage import MinioStorage 12 | 13 | 14 | class MinioMultipartManager(MultipartManager): 15 | # MinIO limits: https://min.io/docs/minio/container/operations/checklists/thresholds.html 16 | max_object_size = tb(50) 17 | 18 | def __init__(self, storage: MinioStorage): 19 | self._client: minio.Minio = storage.client 20 | self._bucket_name: str = storage.bucket_name 21 | # To support MinioStorage's "base_url" functionality, an alternative client must be used 22 | # for pre-signing URLs when it exists 23 | self._signing_client: minio.Minio = getattr(storage, "base_url_client", storage.client) 24 | 25 | def _create_upload_id( 26 | self, 27 | object_key: str, 28 | content_type: str, 29 | ) -> str: 30 | return self._client._create_multipart_upload( 31 | bucket_name=self._bucket_name, 32 | object_name=object_key, 33 | headers={ 34 | "Content-Type": content_type, 35 | }, 36 | # TODO: filename in headers 37 | ) 38 | 39 | def _abort_upload_id(self, object_key: str, upload_id: str) -> None: 40 | self._client._abort_multipart_upload( 41 | bucket_name=self._bucket_name, 42 | object_name=object_key, 43 | upload_id=upload_id, 44 | ) 45 | 46 | def _generate_presigned_part_url( 47 | self, object_key: str, upload_id: str, part_number: int, part_size: int 48 | ) -> str: 49 | return self._signing_client.get_presigned_url( 50 | method="PUT", 51 | bucket_name=self._bucket_name, 52 | object_name=object_key, 53 | expires=self._url_expiration, 54 | # Both "extra_query_params" and "response_headers" add a query string, but 55 | # "extra_query_params" does not sign them properly and results in incorrect URL syntax 56 | response_headers={ 57 | "uploadId": upload_id, 58 | "partNumber": str(part_number), 59 | # MinIO server does not seem to enforce presigning of "Content-Length" 60 | }, 61 | ) 62 | 63 | def _generate_presigned_complete_url(self, transferred_parts: TransferredParts) -> str: 64 | return self._signing_client.get_presigned_url( 65 | method="POST", 66 | bucket_name=self._bucket_name, 67 | object_name=transferred_parts.object_key, 68 | expires=self._url_expiration, 69 | response_headers={ 70 | "uploadId": transferred_parts.upload_id, 71 | }, 72 | ) 73 | 74 | def get_object_size(self, object_key: str) -> int: 75 | try: 76 | stats = self._client.stat_object(bucket_name=self._bucket_name, object_name=object_key) 77 | except minio.S3Error as e: 78 | if e.code == "NoSuchKey": 79 | raise ObjectNotFoundError from e 80 | raise 81 | if stats.size is None: 82 | raise RuntimeError("MinIO did not return a size for object.", object_key) 83 | return stats.size 84 | -------------------------------------------------------------------------------- /s3_file_field/_multipart_s3.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from botocore.exceptions import ClientError 6 | 7 | from ._sizes import tb 8 | 9 | if TYPE_CHECKING: 10 | # mypy_boto3_s3 only provides types 11 | import mypy_boto3_s3 as s3 12 | from storages.backends.s3 import S3Storage 13 | 14 | from ._multipart import MultipartManager, ObjectNotFoundError, TransferredParts 15 | 16 | 17 | class S3MultipartManager(MultipartManager): 18 | # S3 multipart limits: https://docs.aws.amazon.com/AmazonS3/latest/dev/qfacts.html 19 | max_object_size = tb(5) 20 | 21 | def __init__(self, storage: S3Storage) -> None: 22 | resource: s3.ServiceResource = storage.connection 23 | self._client: s3.Client = resource.meta.client 24 | self._bucket_name: str = storage.bucket_name 25 | 26 | def _create_upload_id( 27 | self, 28 | object_key: str, 29 | content_type: str, 30 | ) -> str: 31 | resp = self._client.create_multipart_upload( 32 | Bucket=self._bucket_name, 33 | Key=object_key, 34 | ContentType=content_type, 35 | # TODO: filename in Metadata 36 | # TODO: ensure ServerSideEncryption is set, even if not specified 37 | # TODO: use client._get_write_parameters? 38 | ) 39 | return resp["UploadId"] 40 | 41 | def _abort_upload_id(self, object_key: str, upload_id: str) -> None: 42 | self._client.abort_multipart_upload( 43 | Bucket=self._bucket_name, 44 | Key=object_key, 45 | UploadId=upload_id, 46 | ) 47 | 48 | def _generate_presigned_part_url( 49 | self, object_key: str, upload_id: str, part_number: int, part_size: int 50 | ) -> str: 51 | return self._client.generate_presigned_url( 52 | ClientMethod="upload_part", 53 | Params={ 54 | "Bucket": self._bucket_name, 55 | "Key": object_key, 56 | "UploadId": upload_id, 57 | "PartNumber": part_number, 58 | "ContentLength": part_size, 59 | }, 60 | ExpiresIn=int(self._url_expiration.total_seconds()), 61 | ) 62 | 63 | def _generate_presigned_complete_url(self, transferred_parts: TransferredParts) -> str: 64 | return self._client.generate_presigned_url( 65 | ClientMethod="complete_multipart_upload", 66 | Params={ 67 | "Bucket": self._bucket_name, 68 | "Key": transferred_parts.object_key, 69 | "UploadId": transferred_parts.upload_id, 70 | }, 71 | ExpiresIn=int(self._url_expiration.total_seconds()), 72 | ) 73 | 74 | def get_object_size(self, object_key: str) -> int: 75 | try: 76 | stats = self._client.head_object( 77 | Bucket=self._bucket_name, 78 | Key=object_key, 79 | ) 80 | return stats["ContentLength"] 81 | except ClientError as e: 82 | raise ObjectNotFoundError from e 83 | -------------------------------------------------------------------------------- /s3_file_field/_registry.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Iterator 4 | import warnings 5 | from weakref import WeakValueDictionary 6 | 7 | from django.core.files.storage import Storage 8 | 9 | if TYPE_CHECKING: 10 | # Avoid circular imports 11 | from .fields import S3FileField 12 | 13 | FieldsDictType = WeakValueDictionary[str, S3FileField] 14 | StoragesDictType = WeakValueDictionary[int, Storage] 15 | 16 | 17 | _fields: FieldsDictType = WeakValueDictionary() 18 | _storages: StoragesDictType = WeakValueDictionary() 19 | 20 | 21 | def register_field(field: S3FileField) -> None: 22 | field_id = field.id 23 | if field_id in _fields and _fields[field_id] is not field: 24 | # This might be called multiple times, but it should always be consistent 25 | warnings.warn( 26 | f"Overwriting existing S3FileField declaration for {field_id}", 27 | RuntimeWarning, 28 | # This should attribute to the re-defining class (instead of S3FF or Django internals), 29 | # but it was determined empirically and could break if Django is restructured. 30 | stacklevel=5, 31 | ) 32 | _fields[field_id] = field 33 | 34 | storage = field.storage 35 | storage_label = id(storage) 36 | _storages[storage_label] = storage 37 | 38 | 39 | def get_field(field_id: str) -> S3FileField: 40 | """Get an S3FileFields by its __str__.""" 41 | return _fields[field_id] 42 | 43 | 44 | def iter_fields() -> Iterator[S3FileField]: 45 | """Iterate over the S3FileFields in use.""" 46 | return _fields.values() 47 | 48 | 49 | def iter_storages() -> Iterator[Storage]: 50 | """Iterate over the unique Storage instances used by S3FileFields.""" 51 | return _storages.values() 52 | -------------------------------------------------------------------------------- /s3_file_field/_sizes.py: -------------------------------------------------------------------------------- 1 | def kb(bytes_size: int) -> int: 2 | return bytes_size * 2**10 3 | 4 | 5 | def mb(bytes_size: int) -> int: 6 | return bytes_size * 2**20 7 | 8 | 9 | def gb(bytes_size: int) -> int: 10 | return bytes_size * 2**30 11 | 12 | 13 | def tb(bytes_size: int) -> int: 14 | return bytes_size * 2**40 15 | -------------------------------------------------------------------------------- /s3_file_field/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from rest_framework.serializers import ModelSerializer 3 | 4 | from .fields import S3FileField 5 | from .rest_framework import S3FileSerializerField 6 | 7 | 8 | class S3FileFieldConfig(AppConfig): 9 | name = "s3_file_field" 10 | verbose_name = "S3 File Field" 11 | 12 | def ready(self) -> None: 13 | # import checks to register them 14 | from . import checks # noqa: F401 15 | 16 | # Add an entry to the the base ModelSerializer, so S3FF will work out of the box. 17 | # Otherwise, downstream users would have to explicitly add this mapping (or a direct 18 | # instantiation of S3FileSerializerField) on every serializer with an S3FileField. 19 | ModelSerializer.serializer_field_mapping[S3FileField] = S3FileSerializerField 20 | -------------------------------------------------------------------------------- /s3_file_field/checks.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from typing import TYPE_CHECKING, Any, Iterable 5 | 6 | from django.core import checks 7 | 8 | from ._multipart import MultipartManager 9 | from ._registry import iter_storages 10 | 11 | if TYPE_CHECKING: 12 | from django.apps import AppConfig 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | 17 | @checks.register() 18 | def test_bucket_access( 19 | app_configs: Iterable[AppConfig] | None, **kwargs: Any 20 | ) -> list[checks.CheckMessage]: 21 | for storage in iter_storages(): 22 | if not MultipartManager.supported_storage(storage): 23 | continue 24 | multipart = MultipartManager.from_storage(storage) 25 | try: 26 | multipart.test_upload() 27 | except Exception: 28 | msg = "Unable to fully access the storage bucket." 29 | logger.exception(msg) 30 | return [checks.Error(msg, obj=storage, id="s3_file_field.E002")] 31 | return [] 32 | -------------------------------------------------------------------------------- /s3_file_field/fields.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from typing import TYPE_CHECKING, Any 5 | from uuid import uuid4 6 | 7 | from django.core import checks 8 | from django.db.models.fields.files import FileField 9 | 10 | from ._multipart import MultipartManager 11 | from ._registry import register_field 12 | from .forms import S3FormFileField 13 | from .widgets import S3PlaceholderFile 14 | 15 | if TYPE_CHECKING: 16 | from collections.abc import Sequence 17 | 18 | from django import forms 19 | from django.core.checks import CheckMessage 20 | from django.db import models 21 | 22 | logger = logging.getLogger(__name__) 23 | 24 | 25 | class S3FileField(FileField): 26 | """ 27 | A django model field that is similar to a file field. 28 | 29 | Except it supports directly uploading the file to S3 via the UI 30 | """ 31 | 32 | description = ( 33 | "A file field which is supports direct uploads to S3 via the " 34 | "UI and fallsback to uploaded to /filename." 35 | ) 36 | 37 | def __init__(self, *args, **kwargs) -> None: 38 | kwargs.setdefault("max_length", 2000) 39 | kwargs.setdefault("upload_to", self.uuid_prefix_filename) 40 | super().__init__(*args, **kwargs) 41 | 42 | def deconstruct(self) -> tuple[str, str, Sequence[Any], dict[str, Any]]: 43 | name, path, args, kwargs = super().deconstruct() 44 | if kwargs.get("max_length") == 2000: 45 | del kwargs["max_length"] 46 | if kwargs.get("upload_to") is self.uuid_prefix_filename: 47 | del kwargs["upload_to"] 48 | return name, path, args, kwargs 49 | 50 | @property 51 | def id(self) -> str: 52 | """Return the unique identifier for this field instance.""" 53 | if not hasattr(self, "model"): 54 | # TODO: raise a more specific exception 55 | raise RuntimeError("contribute_to_class has not been called yet on this field.") 56 | return str(self) 57 | 58 | def contribute_to_class( 59 | self, cls: type[models.Model], name: str, private_only: bool = False 60 | ) -> None: 61 | # This is executed when the Field is formally added to its containing class. 62 | # As a side effect, self.name is set and self.__str__ becomes usable as a unique 63 | # identifier for the Field. 64 | super().contribute_to_class(cls, name, private_only=private_only) 65 | if cls.__module__ != "__fake__": 66 | # Django's makemigrations iteratively creates fake model instances. 67 | # To avoid registration collisions, don't register these. 68 | register_field(self) 69 | 70 | @staticmethod 71 | def uuid_prefix_filename(instance: models.Model, filename: str) -> str: 72 | return f"{uuid4()}/{filename}" 73 | 74 | def formfield( 75 | self, 76 | form_class: type[forms.Field] | None = None, 77 | choices_form_class: type[forms.ChoiceField] | None = None, 78 | **kwargs: Any, 79 | ) -> forms.Field | None: 80 | """ 81 | Return a forms.Field instance for this model field. 82 | 83 | This is an instance of "form_class", with a widget of "widget". 84 | """ 85 | if MultipartManager.supported_storage(self.storage): 86 | # Use S3FormFileField as a default, instead of forms.FileField from the superclass 87 | form_class = S3FormFileField if form_class is None else form_class 88 | # Allow the form and widget to lookup this field instance later, using its id 89 | kwargs.setdefault("model_field_id", self.id) 90 | return super().formfield( 91 | form_class=form_class, choices_form_class=choices_form_class, **kwargs 92 | ) 93 | 94 | def save_form_data(self, instance: models.Model, data) -> None: 95 | """Coerce a form field value and assign it to a model instance's field.""" 96 | # The FileField's FileDescriptor behavior provides that when a File object is 97 | # assigned to the field, the content is considered uncommitted, and is saved. 98 | # If a string is assigned to the field, it is considered to be the value in the 99 | # database, and no save occurs, which is desirable here. 100 | # However, we don't want the S3FileInput or S3FormFileField to emit a string value, 101 | # since that will break most of the default validation. 102 | if isinstance(data, S3PlaceholderFile): 103 | data = data.name 104 | super().save_form_data(instance, data) 105 | 106 | def check(self, **kwargs: Any) -> list[CheckMessage]: 107 | return [ 108 | *super().check(**kwargs), 109 | *self._check_supported_storage_provider(), 110 | ] 111 | 112 | def _check_supported_storage_provider(self) -> list[checks.CheckMessage]: 113 | if not MultipartManager.supported_storage(self.storage): 114 | msg = f"Incompatible storage type used with an {self.__class__.__name__}." 115 | logger.warning(msg) 116 | return [checks.Warning(msg, obj=self, id="s3_file_field.W001")] 117 | return [] 118 | -------------------------------------------------------------------------------- /s3_file_field/fixtures.py: -------------------------------------------------------------------------------- 1 | # This module shouldn't be imported explicitly, as it will be loaded by pytest via entry point. 2 | from __future__ import annotations 3 | 4 | from typing import TYPE_CHECKING, Callable, Generator 5 | 6 | from django.core import signing 7 | from django.core.files.base import ContentFile 8 | from django.core.files.storage import default_storage 9 | import pytest 10 | 11 | if TYPE_CHECKING: 12 | from django.core.files import File 13 | 14 | 15 | @pytest.fixture() 16 | def stored_file_object() -> Generator[File[bytes], None, None]: 17 | """Return a File object, already saved directly into the default Storage.""" 18 | # Ensure the name is always randomized, even if the key doesn't exist already 19 | key = default_storage.get_alternative_name("test_key", "") 20 | # In theory, Storage.save can change the key, though this shouldn't happen with a randomized key 21 | key = default_storage.save(key, ContentFile(b"test content")) 22 | # Storage.open will return a File object, which knows its size and can access its content 23 | with default_storage.open(key) as file_object: 24 | yield file_object 25 | default_storage.delete(key) 26 | 27 | 28 | @pytest.fixture() 29 | def s3ff_field_value_factory() -> Callable[[File[bytes]], str]: 30 | """Return a function to produce a valid field_value from a File object.""" 31 | 32 | def s3ff_field_value_factory(file_object: File[bytes]) -> str: 33 | return signing.dumps( 34 | { 35 | "object_key": file_object.name, 36 | "file_size": file_object.size, 37 | } 38 | ) 39 | 40 | return s3ff_field_value_factory 41 | 42 | 43 | @pytest.fixture() 44 | def s3ff_field_value( 45 | s3ff_field_value_factory: Callable[[File[bytes]], str], stored_file_object: File[bytes] 46 | ) -> str: 47 | """Return a valid field_value for an existent File in the default Storage.""" 48 | return s3ff_field_value_factory(stored_file_object) 49 | -------------------------------------------------------------------------------- /s3_file_field/forms.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django.contrib.admin.widgets import AdminFileWidget 4 | from django.forms import FileField, Widget 5 | 6 | from .widgets import AdminS3FileInput, S3FileInput 7 | 8 | 9 | class S3FormFileField(FileField): 10 | """Form field used by render an model.S3FileField.""" 11 | 12 | widget = S3FileInput 13 | 14 | def __init__( 15 | self, *, model_field_id: str, widget: type[Widget] | Widget | None = None, **kwargs 16 | ) -> None: 17 | self.model_field_id = model_field_id 18 | 19 | # For form fields created under django.contrib.admin.options.BaseModelAdmin, any form 20 | # field representing a model.FileField subclass will request a 21 | # django.contrib.admin.widgets.AdminFileWidget as a 'widget' parameter override 22 | # Custom subclasses of BaseModelAdmin can use formfield_overrides to change 23 | # the default widget for their forms, but this is burdensome 24 | # So, instead change any requests for an AdminFileWidget to a S3AdminFileInput 25 | if widget: 26 | if isinstance(widget, type): 27 | # widget is a type 28 | if issubclass(widget, AdminFileWidget): 29 | widget = AdminS3FileInput 30 | else: 31 | # widget is an instance 32 | if isinstance(widget, AdminFileWidget): 33 | # We can't easily re-instantiate the Widget, since we need its initial 34 | # parameters, so attempt to rebuild the constructor parameters 35 | widget = AdminS3FileInput(attrs={"type": widget.input_type, **widget.attrs}) 36 | 37 | super().__init__(widget=widget, **kwargs) 38 | 39 | def widget_attrs(self, widget: Widget) -> dict[str, str]: 40 | attrs = super().widget_attrs(widget) 41 | attrs.update( 42 | { 43 | "data-field-id": self.model_field_id, 44 | "data-s3fileinput": "", 45 | } 46 | ) 47 | # 'data-s3fileinput' cannot be determined at this point, during app startup. 48 | # It will be added at render-time by "S3FileInput.get_context". 49 | return attrs 50 | -------------------------------------------------------------------------------- /s3_file_field/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kitware-resonant/django-s3-file-field/73be92f74f2047d3a8f132c935008ac6234e3d15/s3_file_field/py.typed -------------------------------------------------------------------------------- /s3_file_field/rest_framework.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django.core.files import File 4 | from rest_framework.fields import FileField as FileSerializerField 5 | 6 | from s3_file_field.widgets import S3PlaceholderFile 7 | 8 | 9 | class S3FileSerializerField(FileSerializerField): 10 | default_error_messages = { 11 | "invalid": "Not a valid signed S3 upload. Ensure that the S3 upload flow is correct.", 12 | } 13 | 14 | def to_internal_value(self, data: str | File) -> str: # type: ignore[override] 15 | if isinstance(data, File): 16 | # Although the parser may allow submission of an inline file, S3FF should refuse to 17 | # accept it. We should assume that the server doesn't want to act as a proxy, so 18 | # API callers shouldn't be rewarded for submitting inline files. 19 | self.fail("invalid") 20 | 21 | # Check the signature and load an S3PlaceholderFile 22 | file_object = S3PlaceholderFile.from_field(data) 23 | if file_object is None: 24 | self.fail("invalid") 25 | 26 | # This checks validity of the file name and size 27 | super().to_internal_value(file_object) 28 | assert file_object.name 29 | 30 | # fields.S3FileField.save_form_data is not called by DRF, so the same behavior must be 31 | # implemented here 32 | return file_object.name 33 | -------------------------------------------------------------------------------- /s3_file_field/signals.py: -------------------------------------------------------------------------------- 1 | import django.dispatch 2 | 3 | s3_file_field_upload_prepare = django.dispatch.Signal() 4 | s3_file_field_upload_finalize = django.dispatch.Signal() 5 | -------------------------------------------------------------------------------- /s3_file_field/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from .views import finalize, upload_complete, upload_initialize 4 | 5 | app_name = "s3_file_field" 6 | 7 | urlpatterns = [ 8 | path("upload-initialize/", upload_initialize, name="upload-initialize"), 9 | path( 10 | "upload-complete/", 11 | upload_complete, 12 | name="upload-complete", 13 | ), 14 | path("finalize/", finalize, name="finalize"), 15 | ] 16 | -------------------------------------------------------------------------------- /s3_file_field/views.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Any 4 | 5 | from django.core import signing 6 | from rest_framework import serializers 7 | from rest_framework.decorators import api_view, parser_classes 8 | from rest_framework.parsers import JSONParser 9 | from rest_framework.response import Response 10 | 11 | from . import _multipart, _registry 12 | from ._multipart import ObjectNotFoundError, TransferredPart, TransferredParts, UploadTooLargeError 13 | 14 | if TYPE_CHECKING: 15 | from django.http.response import HttpResponseBase 16 | from rest_framework.request import Request 17 | 18 | 19 | class UploadInitializationRequestSerializer(serializers.Serializer): 20 | field_id = serializers.CharField() 21 | file_name = serializers.CharField(trim_whitespace=False) 22 | file_size = serializers.IntegerField(min_value=1) 23 | # part_size = serializers.IntegerField(min_value=1) 24 | content_type = serializers.CharField() 25 | 26 | def validate_field_id(self, field_id: str) -> str: 27 | try: 28 | _registry.get_field(field_id) 29 | except KeyError: 30 | raise serializers.ValidationError(f'Invalid field ID: "{field_id}".') from None 31 | return field_id 32 | 33 | 34 | class PartInitializationResponseSerializer(serializers.Serializer): 35 | part_number = serializers.IntegerField(min_value=1) 36 | size = serializers.IntegerField(min_value=1) 37 | upload_url = serializers.URLField() 38 | 39 | 40 | class UploadInitializationResponseSerializer(serializers.Serializer): 41 | object_key = serializers.CharField(trim_whitespace=False) 42 | upload_id = serializers.CharField() 43 | parts = PartInitializationResponseSerializer(many=True, allow_empty=False) 44 | upload_signature = serializers.CharField(trim_whitespace=False) 45 | 46 | 47 | class TransferredPartRequestSerializer(serializers.Serializer[TransferredPart]): 48 | part_number = serializers.IntegerField(min_value=1) 49 | size = serializers.IntegerField(min_value=1) 50 | etag = serializers.CharField() 51 | 52 | 53 | class UploadCompletionRequestSerializer(serializers.Serializer[TransferredParts]): 54 | upload_signature = serializers.CharField(trim_whitespace=False) 55 | upload_id = serializers.CharField() 56 | parts = TransferredPartRequestSerializer(many=True, allow_empty=False) 57 | 58 | def create(self, validated_data: dict[str, Any]) -> TransferredParts: 59 | parts = [ 60 | TransferredPart(**part) 61 | for part in sorted(validated_data.pop("parts"), key=lambda part: part["part_number"]) 62 | ] 63 | upload_signature = signing.loads(validated_data["upload_signature"]) 64 | object_key = upload_signature["object_key"] 65 | upload_id = validated_data["upload_id"] 66 | return TransferredParts(parts=parts, object_key=object_key, upload_id=upload_id) 67 | 68 | 69 | class UploadCompletionResponseSerializer(serializers.Serializer): 70 | complete_url = serializers.URLField() 71 | body = serializers.CharField(trim_whitespace=False) 72 | 73 | 74 | class FinalizationRequestSerializer(serializers.Serializer): 75 | upload_signature = serializers.CharField(trim_whitespace=False) 76 | 77 | 78 | class FinalizationResponseSerializer(serializers.Serializer): 79 | field_value = serializers.CharField(trim_whitespace=False) 80 | 81 | 82 | @api_view(["POST"]) 83 | @parser_classes([JSONParser]) 84 | def upload_initialize(request: Request) -> HttpResponseBase: 85 | request_serializer = UploadInitializationRequestSerializer(data=request.data) 86 | request_serializer.is_valid(raise_exception=True) 87 | upload_request: dict = request_serializer.validated_data 88 | field = _registry.get_field(upload_request["field_id"]) 89 | 90 | file_name = upload_request["file_name"] 91 | # TODO: The first argument to generate_filename() is an instance of the model. 92 | # We do not and will never have an instance of the model during field upload. 93 | # Maybe we need a different generate method/upload_to with a different signature? 94 | object_key = field.generate_filename(None, file_name) 95 | 96 | try: 97 | initialization = _multipart.MultipartManager.from_storage(field.storage).initialize_upload( 98 | object_key, 99 | upload_request["file_size"], 100 | upload_request["content_type"], 101 | ) 102 | except UploadTooLargeError: 103 | return Response("Upload size is too large.", status=400) 104 | 105 | # signals.s3_file_field_upload_prepare.send( 106 | # sender=upload_prepare, name=name, object_key=object_key 107 | # ) 108 | 109 | # We sign the field_id and object_key to create a "session token" for this upload 110 | upload_signature = signing.dumps( 111 | { 112 | "field_id": upload_request["field_id"], 113 | "object_key": object_key, 114 | } 115 | ) 116 | 117 | response_serializer = UploadInitializationResponseSerializer( 118 | { 119 | "object_key": initialization.object_key, 120 | "upload_id": initialization.upload_id, 121 | "parts": initialization.parts, 122 | "upload_signature": upload_signature, 123 | } 124 | ) 125 | return Response(response_serializer.data) 126 | 127 | 128 | @api_view(["POST"]) 129 | @parser_classes([JSONParser]) 130 | def upload_complete(request: Request) -> HttpResponseBase: 131 | request_serializer = UploadCompletionRequestSerializer(data=request.data) 132 | request_serializer.is_valid(raise_exception=True) 133 | transferred_parts: TransferredParts = request_serializer.save() 134 | 135 | upload_signature = signing.loads(request_serializer.validated_data["upload_signature"]) 136 | field = _registry.get_field(upload_signature["field_id"]) 137 | 138 | # check if upload_prepare signed this less than max age ago 139 | # tsigner = TimestampSigner() 140 | # if object_key != tsigner.unsign( 141 | # upload_sig, max_age=int(MultipartManager._url_expiration.total_seconds()) 142 | # ): 143 | # raise BadSignature() 144 | 145 | completed_upload = _multipart.MultipartManager.from_storage(field.storage).complete_upload( 146 | transferred_parts 147 | ) 148 | 149 | # signals.s3_file_field_upload_finalize.send( 150 | # sender=multipart_upload_finalize, name=name, object_key=object_key 151 | # ) 152 | 153 | response_serializer = UploadCompletionResponseSerializer( 154 | { 155 | "complete_url": completed_upload.complete_url, 156 | "body": completed_upload.body, 157 | } 158 | ) 159 | return Response(response_serializer.data) 160 | 161 | 162 | @api_view(["POST"]) 163 | @parser_classes([JSONParser]) 164 | def finalize(request: Request) -> HttpResponseBase: 165 | request_serializer = FinalizationRequestSerializer(data=request.data) 166 | request_serializer.is_valid(raise_exception=True) 167 | 168 | upload_signature = signing.loads(request_serializer.validated_data["upload_signature"]) 169 | field_id = upload_signature["field_id"] 170 | object_key = upload_signature["object_key"] 171 | 172 | field = _registry.get_field(field_id) 173 | 174 | # get_object_size implicitly verifies that the object exists. 175 | # We don't want to distribute the field value if the upload did not complete. 176 | try: 177 | size = _multipart.MultipartManager.from_storage(field.storage).get_object_size(object_key) 178 | except ObjectNotFoundError: 179 | return Response("Object not found", status=400) 180 | 181 | field_value = signing.dumps( 182 | { 183 | "object_key": object_key, 184 | "file_size": size, 185 | } 186 | ) 187 | 188 | response_serializer = FinalizationResponseSerializer( 189 | { 190 | "field_value": field_value, 191 | } 192 | ) 193 | return Response(response_serializer.data) 194 | -------------------------------------------------------------------------------- /s3_file_field/widgets.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections.abc import Callable 4 | import functools 5 | import posixpath 6 | from typing import TYPE_CHECKING, Any, Mapping, NoReturn 7 | 8 | from django.core import signing 9 | from django.core.files import File 10 | from django.forms import ClearableFileInput 11 | from django.forms.widgets import FILE_INPUT_CONTRADICTION, CheckboxInput 12 | from django.urls import reverse 13 | 14 | if TYPE_CHECKING: 15 | from django.core.files.uploadedfile import UploadedFile 16 | from django.utils.datastructures import MultiValueDict 17 | 18 | 19 | @functools.lru_cache(maxsize=1) 20 | def get_base_url() -> str: 21 | prepare_url = reverse("s3_file_field:upload-initialize") 22 | complete_url = reverse("s3_file_field:upload-complete") 23 | # Use posixpath to always parse URL paths with forward slashes 24 | return posixpath.commonpath([prepare_url, complete_url]) 25 | 26 | 27 | class S3PlaceholderFile(File): 28 | name: str 29 | size: int 30 | 31 | def __init__(self, name: str, size: int) -> None: 32 | self.name = name 33 | self.size = size 34 | 35 | def open( 36 | self, 37 | mode: str | None = None, 38 | buffering: int = -1, 39 | encoding: str | None = None, 40 | errors: str | None = None, 41 | newline: str | None = None, 42 | closefd: bool = True, 43 | opener: Callable[[str, int], int] | None = None, 44 | ) -> NoReturn: 45 | raise NotImplementedError 46 | 47 | def close(self) -> NoReturn: 48 | raise NotImplementedError 49 | 50 | def chunks(self, chunk_size: int | None = None) -> NoReturn: 51 | raise NotImplementedError 52 | 53 | def multiple_chunks(self, chunk_size: int | None = None) -> bool: 54 | # Since it's in memory, we'll never have multiple chunks. 55 | return False 56 | 57 | @classmethod 58 | def from_field(cls, field_value: str) -> S3PlaceholderFile | None: 59 | try: 60 | parsed_field = signing.loads(field_value) 61 | except signing.BadSignature: 62 | return None 63 | # Since the field is signed, we know the content is structurally valid 64 | return cls(parsed_field["object_key"], parsed_field["file_size"]) 65 | 66 | 67 | class S3FileInput(ClearableFileInput): 68 | """Widget to render the S3 File Input.""" 69 | 70 | class Media: 71 | js = ["s3_file_field/widget.js"] 72 | css = {"all": ["s3_file_field/widget.css"]} 73 | 74 | def get_context(self, *args, **kwargs) -> dict[str, Any]: 75 | # The base URL cannot be determined at the time the widget is instantiated 76 | # (when S3FormFileField.widget_attrs is called). 77 | # Additionally, because this method is called on a deep copy of the widget each 78 | # time it's rendered, this assignment to an instance variable is not persisted. 79 | self.attrs["data-s3fileinput"] = get_base_url() 80 | return super().get_context(*args, **kwargs) 81 | 82 | def value_from_datadict( 83 | self, data: Mapping[str, Any], files: MultiValueDict[str, UploadedFile], name: str 84 | ) -> Any: 85 | if name in data: 86 | upload = data[name] 87 | # An empty string indicates the field was not populated, so don't wrap it in a File 88 | if upload != "": 89 | upload = S3PlaceholderFile.from_field(upload) 90 | elif name in files: 91 | # Files were uploaded, client JS library may not be functioning 92 | # So, fallback to direct upload 93 | upload = super().value_from_datadict(data, files, name) 94 | else: 95 | upload = None 96 | 97 | if not self.is_required and CheckboxInput().value_from_datadict( 98 | data, files, self.clear_checkbox_name(name) 99 | ): 100 | if upload: 101 | # If the user contradicts themselves (uploads a new file AND 102 | # checks the "clear" checkbox), we return a unique marker 103 | # object that FileField will turn into a ValidationError. 104 | return FILE_INPUT_CONTRADICTION 105 | # False signals to clear any existing value, as opposed to just None 106 | return False 107 | return upload 108 | 109 | def value_omitted_from_data( 110 | self, data: Mapping[str, Any], files: Mapping[str, Any], name: str 111 | ) -> bool: 112 | return ( 113 | (name not in data) 114 | and (name not in files) 115 | and (self.clear_checkbox_name(name) not in data) 116 | ) 117 | 118 | 119 | class AdminS3FileInput(S3FileInput): 120 | """Widget used by the admin page.""" 121 | 122 | template_name = "admin/widgets/clearable_file_input.html" 123 | -------------------------------------------------------------------------------- /stubs/minio/__init__.pyi: -------------------------------------------------------------------------------- 1 | from .api import Minio 2 | from .error import S3Error 3 | 4 | __version__: str 5 | 6 | __all__ = [ 7 | "Minio", 8 | "S3Error", 9 | ] 10 | -------------------------------------------------------------------------------- /stubs/minio/api.pyi: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | from typing import Any, Mapping 3 | 4 | from urllib3 import PoolManager 5 | 6 | from .datatypes import Object 7 | from .provider import Provider 8 | from .sse import SseCustomerKey 9 | 10 | class Minio: 11 | def __init__( 12 | self, 13 | endpoint: str, 14 | access_key: str | None = ..., 15 | secret_key: str | None = ..., 16 | session_token: str | None = ..., 17 | secure: bool = ..., 18 | region: str | None = ..., 19 | http_client: PoolManager | None = ..., 20 | credentials: Provider | None = ..., 21 | cert_check: bool = ..., 22 | ) -> None: ... 23 | def stat_object( 24 | self, 25 | bucket_name: str, 26 | object_name: str, 27 | ssec: SseCustomerKey | None = ..., 28 | version_id: str | None = ..., 29 | extra_query_params: Mapping[str, Any] | None = ..., 30 | ) -> Object: ... 31 | def get_presigned_url( 32 | self, 33 | method: str, 34 | bucket_name: str, 35 | object_name: str, 36 | expires: timedelta = ..., 37 | response_headers: Mapping[str, Any] | None = ..., 38 | request_date: datetime | None = ..., 39 | version_id: str | None = ..., 40 | extra_query_params: Mapping[str, Any] | None = ..., 41 | ) -> str: ... 42 | def _create_multipart_upload( 43 | self, bucket_name: str, object_name: str, headers: Mapping[str, Any] 44 | ) -> str: ... 45 | def _abort_multipart_upload( 46 | self, bucket_name: str, object_name: str, upload_id: str 47 | ) -> None: ... 48 | -------------------------------------------------------------------------------- /stubs/minio/datatypes.pyi: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Any, Mapping 3 | 4 | class Object: 5 | @property 6 | def bucket_name(self) -> str: ... 7 | @property 8 | def object_name(self) -> str: ... 9 | @property 10 | def is_dir(self) -> bool: ... 11 | @property 12 | def last_modified(self) -> datetime | None: ... 13 | @property 14 | def etag(self) -> str | None: ... 15 | @property 16 | def size(self) -> int | None: ... 17 | @property 18 | def metadata(self) -> Mapping[str, Any] | None: ... 19 | @property 20 | def version_id(self) -> str | None: ... 21 | @property 22 | def is_latest(self) -> bool | None: ... 23 | @property 24 | def storage_class(self) -> str | None: ... 25 | @property 26 | def owner_id(self) -> str | None: ... 27 | @property 28 | def owner_name(self) -> str | None: ... 29 | @property 30 | def is_delete_marker(self) -> bool: ... 31 | @property 32 | def content_type(self) -> str | None: ... 33 | -------------------------------------------------------------------------------- /stubs/minio/error.pyi: -------------------------------------------------------------------------------- 1 | from urllib3 import HTTPResponse 2 | 3 | class MinioException(Exception): ... 4 | 5 | class S3Error(MinioException): 6 | @property 7 | def code(self) -> str: ... 8 | @property 9 | def message(self) -> str: ... 10 | @property 11 | def response(self) -> HTTPResponse: ... 12 | -------------------------------------------------------------------------------- /stubs/minio/provider.pyi: -------------------------------------------------------------------------------- 1 | class Provider: ... 2 | -------------------------------------------------------------------------------- /stubs/minio/sse.pyi: -------------------------------------------------------------------------------- 1 | class Sse: ... 2 | class SseCustomerKey(Sse): ... 3 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from typing import Generator 2 | 3 | from django.core.files.base import ContentFile 4 | import factory 5 | import pytest 6 | from pytest_mock import MockerFixture 7 | from rest_framework.test import APIClient 8 | 9 | from s3_file_field._multipart import MultipartManager 10 | from s3_file_field._sizes import mb 11 | 12 | from test_app.models import Resource 13 | 14 | # Explicitly load s3_file_field fixtures, late in Pytest plugin load order. 15 | # If this is auto-loaded via entry point, the import happens before coverage tracing is started by 16 | # pytest-cov, and import-time code doesn't get covered. 17 | # See https://pytest-cov.readthedocs.io/en/latest/plugins.html for a description of the problem. 18 | # See 19 | # https://docs.pytest.org/en/7.1.x/how-to/writing_plugins.html#plugin-discovery-order-at-tool-startup 20 | # for info on Pytest plugin load order. 21 | pytest_plugins = ["s3_file_field.fixtures"] 22 | 23 | 24 | @pytest.fixture(autouse=True) 25 | def _reduce_part_size(mocker: MockerFixture) -> None: 26 | """To speed up tests, reduce the part size to the minimum supported by S3 (5MB).""" 27 | mocker.patch.object(MultipartManager, "part_size", new=mb(5)) 28 | 29 | 30 | @pytest.fixture() 31 | def api_client() -> APIClient: 32 | return APIClient() 33 | 34 | 35 | class ResourceFactory(factory.Factory): 36 | class Meta: 37 | model = Resource 38 | 39 | # Use a unique blob file name for each instance 40 | blob = factory.Sequence(lambda n: ContentFile(b"test content", name=f"test_key_{n}")) 41 | 42 | 43 | @pytest.fixture() 44 | def resource() -> Generator[Resource, None, None]: 45 | # Do not save by default 46 | resource = ResourceFactory.build() 47 | yield resource 48 | resource.blob.delete(save=False) 49 | -------------------------------------------------------------------------------- /tests/fuzzy.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | 5 | 6 | class Fuzzy: 7 | pattern: re.Pattern[str] 8 | 9 | def __init__(self, pattern: str | re.Pattern) -> None: 10 | self.pattern: re.Pattern = ( 11 | pattern if isinstance(pattern, re.Pattern) else re.compile(pattern) 12 | ) 13 | 14 | def __eq__(self, other: object) -> bool: 15 | return isinstance(other, str) and self.pattern.search(other) is not None 16 | 17 | def __str__(self) -> str: 18 | return self.pattern.pattern 19 | 20 | def __repr__(self) -> str: 21 | return repr(self.pattern.pattern) 22 | 23 | 24 | # This only validates the beginning of a URL, which is good enough 25 | FUZZY_URL = Fuzzy(r"^http[s]?://[a-zA-Z0-9_-]+(?::[0-9]+)?/?") 26 | 27 | # Different versions of MinIO may use the following upload ID formats: 28 | # * A UUID 29 | # * A Base64-encoded string of two dot-delimited UUIDs 30 | # * A Base64-encoded (URL-safe and unpadded) string of two dot-delimited UUIDs 31 | # AWS uses a random sequence of characters. 32 | # So, just allow any sequence of characters. 33 | FUZZY_UPLOAD_ID = Fuzzy(r"^[A-Za-z0-9+/=-]+$") 34 | -------------------------------------------------------------------------------- /tests/test_app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kitware-resonant/django-s3-file-field/73be92f74f2047d3a8f132c935008ac6234e3d15/tests/test_app/__init__.py -------------------------------------------------------------------------------- /tests/test_app/forms.py: -------------------------------------------------------------------------------- 1 | from django.forms import ModelForm 2 | 3 | from .models import Resource 4 | 5 | 6 | class ResourceForm(ModelForm): 7 | class Meta: 8 | model = Resource 9 | fields = "__all__" 10 | -------------------------------------------------------------------------------- /tests/test_app/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from s3_file_field.fields import S3FileField 4 | 5 | 6 | class Resource(models.Model): 7 | blob = S3FileField() 8 | 9 | 10 | class MultiResource(models.Model): 11 | blob = S3FileField() 12 | optional_blob = S3FileField(blank=True) 13 | -------------------------------------------------------------------------------- /tests/test_app/rest.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from .models import Resource 4 | 5 | 6 | class ResourceSerializer(serializers.ModelSerializer): 7 | class Meta: 8 | model = Resource 9 | fields = "__all__" 10 | -------------------------------------------------------------------------------- /tests/test_app/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import django 4 | 5 | SECRET_KEY = "test_key" 6 | 7 | INSTALLED_APPS = [ 8 | "django.contrib.auth", 9 | "django.contrib.contenttypes", 10 | "rest_framework", 11 | "s3_file_field", 12 | "test_app", 13 | ] 14 | 15 | ROOT_URLCONF = "test_app.urls" 16 | 17 | # Django will use a memory resident database 18 | DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3"}} 19 | 20 | if django.VERSION < (5, 0): 21 | USE_TZ = True 22 | 23 | if django.VERSION < (4, 2): 24 | DEFAULT_FILE_STORAGE = "minio_storage.storage.MinioMediaStorage" 25 | else: 26 | STORAGES = { 27 | "default": { 28 | "BACKEND": "minio_storage.storage.MinioMediaStorage", 29 | }, 30 | "staticfiles": { 31 | "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage", 32 | }, 33 | } 34 | # Use values compatible with Docker Compose as defaults, in case environment variables are not set 35 | MINIO_STORAGE_ENDPOINT = os.environ.get("MINIO_STORAGE_ENDPOINT", "localhost:9000") 36 | MINIO_STORAGE_USE_HTTPS = False 37 | MINIO_STORAGE_ACCESS_KEY = os.environ.get("MINIO_STORAGE_ACCESS_KEY", "minioAccessKey") 38 | MINIO_STORAGE_SECRET_KEY = os.environ.get("MINIO_STORAGE_SECRET_KEY", "minioSecretKey") 39 | MINIO_STORAGE_MEDIA_BUCKET_NAME = os.environ.get("MINIO_STORAGE_MEDIA_BUCKET_NAME", "s3ff-test") 40 | MINIO_STORAGE_AUTO_CREATE_MEDIA_POLICY = "READ_WRITE" 41 | MINIO_STORAGE_AUTO_CREATE_MEDIA_BUCKET = True 42 | MINIO_STORAGE_MEDIA_USE_PRESIGNED = True 43 | -------------------------------------------------------------------------------- /tests/test_app/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import include, path 2 | 3 | urlpatterns = [ 4 | # Make this distinct from typical production values, to ensure it works dynamically 5 | path("api/s3ff_test/", include("s3_file_field.urls")), 6 | ] 7 | -------------------------------------------------------------------------------- /tests/test_checks.py: -------------------------------------------------------------------------------- 1 | from s3_file_field import checks 2 | 3 | 4 | def test_checks_test_bucket_access_success() -> None: 5 | assert checks.test_bucket_access(None) == [] 6 | -------------------------------------------------------------------------------- /tests/test_fields.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from django.core.exceptions import ValidationError 4 | from django.core.files.base import ContentFile 5 | import pytest 6 | 7 | from test_app.models import Resource 8 | 9 | 10 | @pytest.mark.django_db() 11 | def test_fields_save(resource: Resource) -> None: 12 | resource.save() 13 | 14 | with resource.blob.open() as blob_stream: 15 | assert blob_stream.read() == b"test content" 16 | 17 | 18 | def test_fields_save_field() -> None: 19 | resource = Resource() 20 | # Upload the file, but do not save the model instance 21 | resource.blob.save("test_key", ContentFile(b"test content"), save=False) 22 | with resource.blob.open() as blob_stream: 23 | assert blob_stream.read() == b"test content" 24 | resource.blob.delete(save=False) 25 | 26 | 27 | @pytest.mark.django_db() 28 | def test_fields_save_refresh(resource: Resource) -> None: 29 | resource.save() 30 | resource.refresh_from_db() 31 | 32 | with resource.blob.open() as blob_stream: 33 | assert blob_stream.read() == b"test content" 34 | 35 | 36 | @pytest.mark.django_db() 37 | def test_fields_save_uuid_prefix(resource: Resource) -> None: 38 | resource.save() 39 | 40 | assert re.search( 41 | r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/test_key_", 42 | resource.blob.name, 43 | ) 44 | 45 | 46 | def test_fields_clean(resource: Resource) -> None: 47 | resource.full_clean() 48 | 49 | 50 | @pytest.mark.django_db() 51 | def test_fields_clean_refresh(resource: Resource) -> None: 52 | resource.save() 53 | resource.refresh_from_db() 54 | resource.full_clean() 55 | 56 | 57 | def test_fields_clean_empty() -> None: 58 | resource = Resource() 59 | with pytest.raises(ValidationError, match=r"This field cannot be blank\."): 60 | resource.full_clean() 61 | 62 | 63 | def test_fields_check_success(resource: Resource) -> None: 64 | assert resource._meta.get_field("blob").check() == [] 65 | -------------------------------------------------------------------------------- /tests/test_fixtures.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Callable 4 | 5 | from django.core.files.storage import default_storage 6 | 7 | from s3_file_field.widgets import S3PlaceholderFile 8 | 9 | if TYPE_CHECKING: 10 | from django.core.files import File 11 | 12 | 13 | def test_fixtures_stored_file_object(stored_file_object: File[bytes]) -> None: 14 | """Test the stored_file_object Pytest fixture.""" 15 | assert stored_file_object.name 16 | assert default_storage.exists(stored_file_object.name) 17 | 18 | 19 | def test_fixtures_s3ff_field_value_factory( 20 | s3ff_field_value_factory: Callable[[File[bytes]], str], stored_file_object: File[bytes] 21 | ) -> None: 22 | """Test the s3ff_field_value_factory Pytest fixture.""" 23 | field_value = s3ff_field_value_factory(stored_file_object) 24 | 25 | placeholder_file = S3PlaceholderFile.from_field(field_value) 26 | assert placeholder_file is not None 27 | assert placeholder_file.name == stored_file_object.name 28 | assert placeholder_file.size == stored_file_object.size 29 | 30 | 31 | def test_fixtures_s3ff_field_value(s3ff_field_value: str) -> None: 32 | """Test the s3ff_field_value Pytest fixture.""" 33 | assert S3PlaceholderFile.from_field(s3ff_field_value) is not None 34 | -------------------------------------------------------------------------------- /tests/test_forms.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from s3_file_field.forms import S3FormFileField 4 | 5 | from test_app.forms import ResourceForm 6 | 7 | 8 | def test_form_field_type() -> None: 9 | form = ResourceForm() 10 | assert isinstance(form.fields["blob"], S3FormFileField) 11 | 12 | 13 | def test_form_missing() -> None: 14 | form = ResourceForm(data={}) 15 | assert not form.is_valid() 16 | 17 | 18 | def test_form_empty() -> None: 19 | form = ResourceForm(data={"blob": ""}) 20 | assert not form.is_valid() 21 | 22 | 23 | def test_form_invalid() -> None: 24 | form = ResourceForm(data={"blob": "invalid:field_value"}) 25 | assert not form.is_valid() 26 | 27 | 28 | def test_form_validation(s3ff_field_value: str) -> None: 29 | form = ResourceForm(data={"blob": s3ff_field_value}) 30 | assert form.is_valid() 31 | 32 | 33 | def test_form_instance(s3ff_field_value: str) -> None: 34 | form = ResourceForm(data={"blob": s3ff_field_value}) 35 | 36 | # full_clean has the side effect of populating instance 37 | form.full_clean() 38 | resource = form.instance 39 | 40 | with resource.blob.open() as blob_stream: 41 | assert blob_stream.read() == b"test content" 42 | 43 | 44 | @pytest.mark.django_db() 45 | def test_form_instance_saved(s3ff_field_value: str) -> None: 46 | form = ResourceForm(data={"blob": s3ff_field_value}) 47 | 48 | resource = form.save() 49 | resource.refresh_from_db() 50 | 51 | with resource.blob.open() as blob_stream: 52 | assert blob_stream.read() == b"test content" 53 | -------------------------------------------------------------------------------- /tests/test_multipart.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from io import BytesIO 4 | from typing import TYPE_CHECKING, Callable, cast 5 | 6 | from botocore.exceptions import ClientError 7 | from django.conf import settings 8 | from django.core.files.storage import FileSystemStorage, Storage 9 | from minio import Minio 10 | from minio_storage.storage import MinioStorage 11 | import pytest 12 | from pytest_mock import MockerFixture 13 | import requests 14 | from storages.backends.s3 import S3Storage 15 | 16 | from s3_file_field._multipart import ( 17 | MultipartManager, 18 | ObjectNotFoundError, 19 | TransferredPart, 20 | TransferredParts, 21 | ) 22 | from s3_file_field._multipart_minio import MinioMultipartManager 23 | from s3_file_field._multipart_s3 import S3MultipartManager 24 | from s3_file_field._sizes import gb, mb 25 | 26 | if TYPE_CHECKING: 27 | # mypy_boto3_s3 only provides types 28 | import mypy_boto3_s3 as s3 29 | 30 | 31 | def s3_storage_factory() -> S3Storage: 32 | storage = S3Storage( 33 | access_key=settings.MINIO_STORAGE_ACCESS_KEY, 34 | secret_key=settings.MINIO_STORAGE_SECRET_KEY, 35 | region_name="test-region", 36 | bucket_name=settings.MINIO_STORAGE_MEDIA_BUCKET_NAME, 37 | # For testing, connect to a local Minio instance 38 | endpoint_url=( 39 | f'{"https" if settings.MINIO_STORAGE_USE_HTTPS else "http"}:' 40 | f"//{settings.MINIO_STORAGE_ENDPOINT}" 41 | ), 42 | ) 43 | 44 | resource: s3.ServiceResource = storage.connection 45 | client: s3.Client = resource.meta.client 46 | try: 47 | client.head_bucket(Bucket=settings.MINIO_STORAGE_MEDIA_BUCKET_NAME) 48 | except ClientError: 49 | client.create_bucket(Bucket=settings.MINIO_STORAGE_MEDIA_BUCKET_NAME) 50 | 51 | return storage 52 | 53 | 54 | def minio_storage_factory() -> MinioStorage: 55 | return MinioStorage( 56 | minio_client=Minio( 57 | endpoint=settings.MINIO_STORAGE_ENDPOINT, 58 | secure=settings.MINIO_STORAGE_USE_HTTPS, 59 | access_key=settings.MINIO_STORAGE_ACCESS_KEY, 60 | secret_key=settings.MINIO_STORAGE_SECRET_KEY, 61 | # Don't use s3_connection_params.region, let Minio set its own value internally 62 | ), 63 | bucket_name=settings.MINIO_STORAGE_MEDIA_BUCKET_NAME, 64 | auto_create_bucket=True, 65 | presign_urls=True, 66 | # TODO: Test the case of an alternate base_url 67 | # base_url='http://minio:9000/bucket-name' 68 | ) 69 | 70 | 71 | @pytest.fixture() 72 | def s3_storage() -> S3Storage: 73 | return s3_storage_factory() 74 | 75 | 76 | @pytest.fixture() 77 | def minio_storage() -> MinioStorage: 78 | return minio_storage_factory() 79 | 80 | 81 | @pytest.fixture(params=[s3_storage_factory, minio_storage_factory], ids=["s3", "minio"]) 82 | def storage(request: pytest.FixtureRequest) -> Storage: 83 | storage_factory = cast(Callable[[], Storage], request.param) 84 | return storage_factory() 85 | 86 | 87 | @pytest.fixture() 88 | def s3_multipart_manager(s3_storage: S3Storage) -> S3MultipartManager: 89 | return S3MultipartManager(s3_storage) 90 | 91 | 92 | @pytest.fixture() 93 | def minio_multipart_manager(minio_storage: MinioStorage) -> MinioMultipartManager: 94 | return MinioMultipartManager(minio_storage) 95 | 96 | 97 | @pytest.fixture() 98 | def multipart_manager(storage: Storage) -> MultipartManager: 99 | return MultipartManager.from_storage(storage) 100 | 101 | 102 | def test_multipart_manager_supported_storage(storage: Storage) -> None: 103 | assert MultipartManager.supported_storage(storage) 104 | 105 | 106 | def test_multipart_manager_supported_storage_unsupported() -> None: 107 | storage = FileSystemStorage() 108 | assert not MultipartManager.supported_storage(storage) 109 | 110 | 111 | def test_multipart_manager_initialize_upload(multipart_manager: MultipartManager) -> None: 112 | initialization = multipart_manager.initialize_upload( 113 | "new-object", 114 | 100, 115 | "text/plain", 116 | ) 117 | 118 | assert initialization 119 | 120 | 121 | @pytest.mark.parametrize("file_size", [10, mb(10), mb(12)], ids=["10B", "10MB", "12MB"]) 122 | def test_multipart_manager_complete_upload( 123 | multipart_manager: MultipartManager, file_size: int 124 | ) -> None: 125 | initialization = multipart_manager.initialize_upload("new-object", file_size, "text/plain") 126 | 127 | transferred_parts = TransferredParts( 128 | object_key=initialization.object_key, upload_id=initialization.upload_id, parts=[] 129 | ) 130 | 131 | for part in initialization.parts: 132 | resp = requests.put(part.upload_url, data=b"a" * part.size, timeout=5) 133 | resp.raise_for_status() 134 | transferred_parts.parts.append( 135 | TransferredPart(part_number=part.part_number, size=part.size, etag=resp.headers["ETag"]) 136 | ) 137 | 138 | completed_upload = multipart_manager.complete_upload(transferred_parts) 139 | assert completed_upload 140 | assert completed_upload.complete_url 141 | assert completed_upload.body 142 | 143 | 144 | def test_multipart_manager_test_upload(multipart_manager: MultipartManager) -> None: 145 | multipart_manager.test_upload() 146 | 147 | 148 | def test_multipart_manager_create_upload_id(multipart_manager: MultipartManager) -> None: 149 | upload_id = multipart_manager._create_upload_id("new-object", "text/plain") 150 | assert isinstance(upload_id, str) 151 | 152 | 153 | def test_multipart_manager_generate_presigned_part_url(multipart_manager: MultipartManager) -> None: 154 | upload_url = multipart_manager._generate_presigned_part_url( 155 | "new-object", "fake-upload-id", 1, 100 156 | ) 157 | 158 | assert isinstance(upload_url, str) 159 | 160 | 161 | @pytest.mark.skip() 162 | def test_multipart_manager_generate_presigned_part_url_content_length( 163 | multipart_manager: MultipartManager, 164 | ) -> None: 165 | # TODO: make this work for Minio 166 | upload_url = multipart_manager._generate_presigned_part_url( 167 | "new-object", "fake-upload-id", 1, 100 168 | ) 169 | # Ensure Content-Length is a signed header 170 | assert "content-length" in upload_url 171 | 172 | 173 | def test_multipart_manager_generate_presigned_complete_url( 174 | multipart_manager: MultipartManager, 175 | ) -> None: 176 | upload_url = multipart_manager._generate_presigned_complete_url( 177 | TransferredParts(object_key="new-object", upload_id="fake-upload-id", parts=[]) 178 | ) 179 | 180 | assert isinstance(upload_url, str) 181 | 182 | 183 | def test_multipart_manager_generate_presigned_complete_body( 184 | multipart_manager: MultipartManager, 185 | ) -> None: 186 | body = multipart_manager._generate_presigned_complete_body( 187 | TransferredParts( 188 | object_key="new-object", 189 | upload_id="fake-upload-id", 190 | parts=[ 191 | TransferredPart(part_number=1, size=1, etag="fake-etag-1"), 192 | TransferredPart(part_number=2, size=2, etag="fake-etag-2"), 193 | ], 194 | ) 195 | ) 196 | 197 | assert body == ( 198 | '' 199 | '' 200 | "1fake-etag-1" 201 | "2fake-etag-2" 202 | "" 203 | ) 204 | 205 | 206 | @pytest.mark.parametrize("file_size", [10, mb(10), mb(12)], ids=["10B", "10MB", "12MB"]) 207 | def test_multipart_manager_get_object_size( 208 | storage: Storage, multipart_manager: MultipartManager, file_size: int 209 | ) -> None: 210 | key = storage.get_alternative_name(f"object-with-size-{file_size}", "") 211 | # In theory, Storage.save can change the key, though this shouldn't happen with a randomized key 212 | key = storage.save(name=key, content=BytesIO(b"X" * file_size)) 213 | 214 | size = multipart_manager.get_object_size( 215 | object_key=key, 216 | ) 217 | 218 | assert size == file_size 219 | 220 | storage.delete(key) 221 | 222 | 223 | def test_multipart_manager_get_object_size_not_found(multipart_manager: MultipartManager) -> None: 224 | with pytest.raises(ObjectNotFoundError): 225 | multipart_manager.get_object_size( 226 | object_key="no-such-object", 227 | ) 228 | 229 | 230 | @pytest.mark.parametrize( 231 | ("file_size", "requested_part_size", "initial_part_size", "final_part_size", "part_count"), 232 | [ 233 | # Base 234 | (mb(50), mb(10), mb(10), mb(10), 5), 235 | # Different final size 236 | (mb(55), mb(10), mb(10), mb(5), 6), 237 | # Single part 238 | (mb(10), mb(10), 0, mb(10), 1), 239 | # Too small requested_part_size 240 | (mb(50), mb(2), mb(5), mb(5), 10), 241 | # Too large requested_part_size 242 | (gb(50), gb(10), gb(5), gb(5), 10), 243 | # Too many parts 244 | (mb(100_000), mb(5), mb(10), mb(10), 10_000), 245 | # TODO: file too large 246 | ], 247 | ids=[ 248 | "base", 249 | "different_final", 250 | "single_part", 251 | "too_small_part", 252 | "too_large_part", 253 | "too_many_part", 254 | ], 255 | ) 256 | def test_multipart_manager_iter_part_sizes( 257 | mocker: MockerFixture, 258 | file_size: int, 259 | requested_part_size: int, 260 | initial_part_size: int, 261 | final_part_size: int, 262 | part_count: int, 263 | ) -> None: 264 | mocker.patch.object(MultipartManager, "part_size", new=requested_part_size) 265 | 266 | part_nums, part_sizes = zip(*MultipartManager._iter_part_sizes(file_size)) 267 | 268 | # TODO: zip(*) returns a tuple, but semantically this should be a list 269 | assert part_nums == tuple(range(1, part_count + 1)) 270 | 271 | assert all(part_size == initial_part_size for part_size in part_sizes[:-1]) 272 | assert part_sizes[-1] == final_part_size 273 | -------------------------------------------------------------------------------- /tests/test_registry.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import sys 3 | from typing import Generator, cast 4 | 5 | from django.core.files.storage import default_storage 6 | from django.db import models 7 | import pytest 8 | 9 | from s3_file_field import _registry 10 | from s3_file_field.fields import S3FileField 11 | 12 | from test_app.models import Resource 13 | 14 | 15 | @pytest.fixture() 16 | def s3ff_field() -> S3FileField: 17 | """Return an attached S3FileField (not S3FieldFile) instance.""" 18 | return cast(S3FileField, Resource._meta.get_field("blob")) 19 | 20 | 21 | @pytest.fixture() 22 | def ephemeral_s3ff_field() -> Generator[S3FileField, None, None]: 23 | # Declaring this will implicitly register the field 24 | class EphemeralResource(models.Model): 25 | class Meta: 26 | app_label = "test_app" 27 | 28 | blob = S3FileField() 29 | 30 | field = cast(S3FileField, EphemeralResource._meta.get_field("blob")) 31 | yield field 32 | # The registry state is global to the process, so attempt to clean up 33 | del _registry._fields[field.id] 34 | 35 | 36 | def test_field_id(s3ff_field: S3FileField) -> None: 37 | assert s3ff_field.id == "test_app.Resource.blob" 38 | 39 | 40 | def test_field_id_premature() -> None: 41 | s3ff_field = S3FileField() 42 | with pytest.raises(Exception, match=r"contribute_to_class"): 43 | s3ff_field.id 44 | 45 | 46 | def test_registry_get_field(s3ff_field: S3FileField) -> None: 47 | assert _registry.get_field(s3ff_field.id) is s3ff_field 48 | 49 | 50 | def test_registry_iter_fields(s3ff_field: S3FileField) -> None: 51 | fields = list(_registry.iter_fields()) 52 | 53 | assert len(fields) == 3 54 | assert any(field is s3ff_field for field in fields) 55 | 56 | 57 | def test_registry_iter_storages() -> None: 58 | fields = list(_registry.iter_storages()) 59 | 60 | assert len(fields) == 1 61 | assert fields[0] is default_storage 62 | 63 | 64 | @pytest.mark.skipif(sys.version_info < (3, 9), reason="Bug bpo-35113") 65 | @pytest.mark.filterwarnings( 66 | "ignore:Model 'test_app\\.ephemeralresource' was already registered:RuntimeWarning" 67 | ) 68 | def test_registry_register_field_multiple(ephemeral_s3ff_field: S3FileField) -> None: 69 | with pytest.warns( 70 | RuntimeWarning, match=r"Overwriting existing S3FileField declaration" 71 | ) as records: 72 | # Try to create a field with the same id 73 | class EphemeralResource(models.Model): 74 | class Meta: 75 | app_label = "test_app" 76 | 77 | blob = S3FileField() 78 | 79 | # Ensure the warning is attributed to the re-defined model, since stacklevel is set empirically 80 | warning = next(record for record in records if str(record.message).startswith("Overwriting")) 81 | assert warning.filename == inspect.getsourcefile(EphemeralResource) 82 | assert warning.lineno == inspect.getsourcelines(EphemeralResource)[1] 83 | 84 | duplicate_field = cast(S3FileField, EphemeralResource._meta.get_field("blob")) 85 | # Sanity check 86 | assert duplicate_field.id == ephemeral_s3ff_field.id 87 | assert duplicate_field is not ephemeral_s3ff_field 88 | # The most recently registered field should by stored 89 | assert _registry.get_field(duplicate_field.id) is duplicate_field 90 | -------------------------------------------------------------------------------- /tests/test_rest_framework.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | import pytest 6 | 7 | from test_app.rest import ResourceSerializer 8 | 9 | if TYPE_CHECKING: 10 | from django.core.files import File 11 | 12 | from test_app.models import Resource 13 | 14 | 15 | def test_serializer_data_missing() -> None: 16 | serializer = ResourceSerializer( 17 | data={ 18 | # Omitted field 19 | } 20 | ) 21 | 22 | assert not serializer.is_valid() 23 | assert serializer.errors["blob"][0].code == "required" 24 | 25 | 26 | def test_serializer_data_invalid() -> None: 27 | serializer = ResourceSerializer( 28 | data={ 29 | # Invalid, this must be a signed field_value 30 | "blob": "test_key" 31 | } 32 | ) 33 | 34 | assert not serializer.is_valid() 35 | assert serializer.errors["blob"][0].code == "invalid" 36 | 37 | 38 | def test_serializer_is_valid(s3ff_field_value: str) -> None: 39 | serializer = ResourceSerializer(data={"blob": s3ff_field_value}) 40 | 41 | assert serializer.is_valid() 42 | 43 | 44 | def test_serializer_validated_data(stored_file_object: File[bytes], s3ff_field_value: str) -> None: 45 | serializer = ResourceSerializer(data={"blob": s3ff_field_value}) 46 | serializer.is_valid(raise_exception=True) 47 | 48 | assert "blob" in serializer.validated_data 49 | # The field_value fixture is created from the same stored_file_object 50 | assert serializer.validated_data["blob"] == stored_file_object.name 51 | 52 | 53 | @pytest.mark.django_db() 54 | def test_serializer_save_create(stored_file_object: File[bytes], s3ff_field_value: str) -> None: 55 | serializer = ResourceSerializer(data={"blob": s3ff_field_value}) 56 | 57 | serializer.is_valid(raise_exception=True) 58 | resource = serializer.save() 59 | 60 | assert resource.blob.name == stored_file_object.name 61 | 62 | 63 | @pytest.mark.django_db() 64 | def test_serializer_save_update( 65 | resource: Resource, stored_file_object: File[bytes], s3ff_field_value: str 66 | ) -> None: 67 | serializer = ResourceSerializer(resource, data={"blob": s3ff_field_value}) 68 | # Sanity check 69 | assert resource.blob.name != stored_file_object.name 70 | 71 | serializer.is_valid(raise_exception=True) 72 | # save() should modify an existing model instance in-place 73 | serializer.save() 74 | 75 | assert resource.blob.name == stored_file_object.name 76 | -------------------------------------------------------------------------------- /tests/test_serializers.py: -------------------------------------------------------------------------------- 1 | from django.core import signing 2 | import pytest 3 | from rest_framework.exceptions import ValidationError 4 | 5 | from s3_file_field._multipart import ( 6 | PresignedPartTransfer, 7 | PresignedTransfer, 8 | TransferredPart, 9 | TransferredParts, 10 | ) 11 | from s3_file_field.views import ( 12 | UploadCompletionRequestSerializer, 13 | UploadInitializationRequestSerializer, 14 | UploadInitializationResponseSerializer, 15 | ) 16 | 17 | 18 | @pytest.fixture() 19 | def initialization() -> PresignedTransfer: 20 | return PresignedTransfer( 21 | object_key="test-object-key", 22 | upload_id="test-upload-id", 23 | parts=[ 24 | PresignedPartTransfer( 25 | part_number=1, 26 | size=10_000, 27 | upload_url="http://minio.test/test-bucket/1", 28 | ), 29 | PresignedPartTransfer( 30 | part_number=2, 31 | size=3_500, 32 | upload_url="http://minio.test/test-bucket/2", 33 | ), 34 | ], 35 | ) 36 | 37 | 38 | def test_upload_initialization_request_deserialization() -> None: 39 | serializer = UploadInitializationRequestSerializer( 40 | data={ 41 | "field_id": "test_app.Resource.blob", 42 | "file_name": "test-name.jpg", 43 | "file_size": 15, 44 | "content_type": "image/jpeg", 45 | } 46 | ) 47 | assert serializer.is_valid(raise_exception=True) 48 | request = serializer.validated_data 49 | assert isinstance(request, dict) 50 | 51 | 52 | def test_upload_initialization_request_deserialization_file_id_invalid() -> None: 53 | serializer = UploadInitializationRequestSerializer( 54 | data={ 55 | "field_id": "bad.id", 56 | "file_name": "test-name.jpg", 57 | "file_size": 15, 58 | "content_type": "image/jpeg", 59 | } 60 | ) 61 | with pytest.raises(ValidationError) as e: 62 | serializer.is_valid(raise_exception=True) 63 | assert e.value.detail == {"field_id": ['Invalid field ID: "bad.id".']} 64 | 65 | 66 | def test_upload_initialization_response_serialization( 67 | initialization: PresignedTransfer, 68 | ) -> None: 69 | serializer = UploadInitializationResponseSerializer( 70 | { 71 | "object_key": initialization.object_key, 72 | "upload_id": initialization.upload_id, 73 | "parts": initialization.parts, 74 | "upload_signature": "test-upload-signature", 75 | } 76 | ) 77 | assert isinstance(serializer.data, dict) 78 | 79 | 80 | def test_upload_completion_request_deserialization() -> None: 81 | upload_signature = signing.dumps({"object_key": "test-object-key", "field_id": "test-field-id"}) 82 | serializer = UploadCompletionRequestSerializer( 83 | data={ 84 | "upload_signature": upload_signature, 85 | "upload_id": "test-upload-id", 86 | "parts": [ 87 | {"part_number": 1, "size": 10_000, "etag": "test-etag-1"}, 88 | {"part_number": 2, "size": 3_500, "etag": "test-etag-2"}, 89 | ], 90 | } 91 | ) 92 | 93 | assert serializer.is_valid(raise_exception=True) 94 | completion = serializer.save() 95 | assert isinstance(completion, TransferredParts) 96 | assert all(isinstance(part, TransferredPart) for part in completion.parts) 97 | -------------------------------------------------------------------------------- /tests/test_views.py: -------------------------------------------------------------------------------- 1 | from typing import cast 2 | 3 | from django.core import signing 4 | from django.core.files.storage import default_storage 5 | from django.urls import reverse 6 | import pytest 7 | import requests 8 | from rest_framework.test import APIClient 9 | 10 | from s3_file_field._sizes import mb 11 | 12 | from fuzzy import FUZZY_UPLOAD_ID, FUZZY_URL, Fuzzy 13 | 14 | 15 | def test_prepare(api_client: APIClient) -> None: 16 | resp = api_client.post( 17 | reverse("s3_file_field:upload-initialize"), 18 | { 19 | "field_id": "test_app.Resource.blob", 20 | "file_name": "test.txt", 21 | "file_size": 10, 22 | "content_type": "text/plain", 23 | }, 24 | format="json", 25 | ) 26 | assert resp.status_code == 200 27 | assert resp.data == { 28 | "object_key": Fuzzy( 29 | r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/test.txt" 30 | ), 31 | "upload_id": FUZZY_UPLOAD_ID, 32 | "parts": [{"part_number": 1, "size": 10, "upload_url": FUZZY_URL}], 33 | "upload_signature": Fuzzy(r".*:.*"), 34 | } 35 | assert signing.loads(resp.data["upload_signature"]) == { 36 | "object_key": Fuzzy( 37 | r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/test.txt" 38 | ), 39 | "field_id": "test_app.Resource.blob", 40 | } 41 | 42 | 43 | def test_prepare_two_parts(api_client: APIClient) -> None: 44 | resp = api_client.post( 45 | reverse("s3_file_field:upload-initialize"), 46 | { 47 | "field_id": "test_app.Resource.blob", 48 | "file_name": "test.txt", 49 | "file_size": mb(10), 50 | "content_type": "text/plain", 51 | }, 52 | format="json", 53 | ) 54 | assert resp.status_code == 200 55 | assert resp.data == { 56 | "object_key": Fuzzy( 57 | r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/test.txt" 58 | ), 59 | "upload_id": FUZZY_UPLOAD_ID, 60 | "parts": [ 61 | # 5 MB size 62 | {"part_number": 1, "size": mb(5), "upload_url": FUZZY_URL}, 63 | {"part_number": 2, "size": mb(5), "upload_url": FUZZY_URL}, 64 | ], 65 | "upload_signature": Fuzzy(r".*:.*"), 66 | } 67 | 68 | 69 | def test_prepare_three_parts(api_client: APIClient) -> None: 70 | resp = api_client.post( 71 | reverse("s3_file_field:upload-initialize"), 72 | { 73 | "field_id": "test_app.Resource.blob", 74 | "file_name": "test.txt", 75 | "file_size": mb(12), 76 | "content_type": "text/plain", 77 | }, 78 | format="json", 79 | ) 80 | assert resp.status_code == 200 81 | assert resp.data == { 82 | "object_key": Fuzzy( 83 | r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/test.txt" 84 | ), 85 | "upload_id": FUZZY_UPLOAD_ID, 86 | "parts": [ 87 | {"part_number": 1, "size": mb(5), "upload_url": FUZZY_URL}, 88 | {"part_number": 2, "size": mb(5), "upload_url": FUZZY_URL}, 89 | {"part_number": 3, "size": mb(2), "upload_url": FUZZY_URL}, 90 | ], 91 | "upload_signature": Fuzzy(r".*:.*"), 92 | } 93 | 94 | 95 | @pytest.mark.parametrize("file_size", [10, mb(10), mb(12)], ids=["10B", "10MB", "12MB"]) 96 | def test_full_upload_flow( 97 | api_client: APIClient, 98 | file_size: int, 99 | ) -> None: 100 | # Initialize the multipart upload 101 | resp = api_client.post( 102 | reverse("s3_file_field:upload-initialize"), 103 | { 104 | "field_id": "test_app.Resource.blob", 105 | "file_name": "test.txt", 106 | "file_size": file_size, 107 | "content_type": "text/plain", 108 | }, 109 | format="json", 110 | ) 111 | assert resp.status_code == 200 112 | initialization = resp.data 113 | assert isinstance(initialization, dict) 114 | upload_signature = initialization["upload_signature"] 115 | 116 | # Perform the upload 117 | for part in initialization["parts"]: 118 | part_resp = requests.put(part["upload_url"], data=b"a" * part["size"], timeout=5) 119 | part_resp.raise_for_status() 120 | 121 | # Modify the part to transform it from an initialization to a finalization 122 | del part["upload_url"] 123 | part["etag"] = part_resp.headers["ETag"] 124 | 125 | initialization["field_id"] = "test_app.Resource.blob" 126 | 127 | # Presign the complete request 128 | resp = api_client.post( 129 | reverse("s3_file_field:upload-complete"), 130 | { 131 | "upload_id": initialization["upload_id"], 132 | "parts": initialization["parts"], 133 | "upload_signature": upload_signature, 134 | }, 135 | format="json", 136 | ) 137 | assert resp.status_code == 200 138 | assert resp.data == { 139 | "complete_url": Fuzzy(r".*"), 140 | "body": Fuzzy(r".*"), 141 | } 142 | completion_data = cast(dict, resp.data) 143 | 144 | # Complete the upload 145 | complete_resp = requests.post( 146 | completion_data["complete_url"], 147 | data=completion_data["body"], 148 | timeout=5, 149 | ) 150 | complete_resp.raise_for_status() 151 | 152 | # Verify the object is present in the store 153 | assert default_storage.exists(initialization["object_key"]) 154 | 155 | # Finalize the upload 156 | resp = api_client.post( 157 | reverse("s3_file_field:finalize"), 158 | { 159 | "upload_signature": upload_signature, 160 | }, 161 | format="json", 162 | ) 163 | assert resp.status_code == 200 164 | assert resp.data == { 165 | "field_value": Fuzzy(r".*:.*"), 166 | } 167 | 168 | # Verify that the Content headers were stored correctly on the object 169 | object_resp = requests.get(default_storage.url(initialization["object_key"]), timeout=5) 170 | assert resp.status_code == 200 171 | assert object_resp.headers["Content-Type"] == "text/plain" 172 | 173 | default_storage.delete(initialization["object_key"]) 174 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | min_version = 4.22 3 | requires = 4 | tox-uv 5 | env_list = 6 | lint 7 | type 8 | test-django{42,51,52} 9 | 10 | [testenv] 11 | runner = uv-venv-lock-runner 12 | # Building a wheel is required to make Hatchling build hooks run 13 | package = wheel 14 | extras = 15 | s3 16 | minio 17 | pytest 18 | 19 | [testenv:lint] 20 | package = skip 21 | dependency_groups = 22 | lint 23 | commands = 24 | flake8 . 25 | 26 | [testenv:format] 27 | package = skip 28 | dependency_groups = 29 | format 30 | commands = 31 | isort . 32 | black . 33 | 34 | [testenv:type] 35 | # Editable ensures dependencies are installed, but full packaging isn't necessary 36 | package = editable 37 | dependency_groups = 38 | type 39 | test 40 | commands = 41 | mypy {posargs} 42 | 43 | [testenv:test-django{42,51,52}] 44 | pass_env = 45 | MINIO_STORAGE_ENDPOINT 46 | MINIO_STORAGE_ACCESS_KEY 47 | MINIO_STORAGE_SECRET_KEY 48 | MINIO_STORAGE_MEDIA_BUCKET_NAME 49 | dependency_groups = 50 | test 51 | deps = 52 | django42: Django==4.2.* 53 | django51: Django==5.1.* 54 | django52: Django==5.2.* 55 | commands = 56 | pytest {posargs} 57 | 58 | [flake8] 59 | max-line-length = 100 60 | show-source = True 61 | ignore = 62 | # closing bracket does not match indentation of opening bracket's line 63 | E123, 64 | # whitespace before ':' 65 | E203, 66 | # line break before binary operator 67 | W503, 68 | # Missing docstring in * 69 | D10, 70 | extend-exclude = 71 | .venv, 72 | node_modules, 73 | # Explicitly set this, so "python-client/pyproject.toml" is never used 74 | black-config = pyproject.toml 75 | -------------------------------------------------------------------------------- /widget/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "django-s3-file-field-widget", 3 | "version": "0.0.0", 4 | "private": true, 5 | "license": "Apache-2.0", 6 | "type": "module", 7 | "scripts": { 8 | "test:lint": "biome check", 9 | "test:type": "tsc --noEmit", 10 | "test": "npm-run-all test:*", 11 | "format": "biome check --write", 12 | "watch": "parcel watch --watch-dir ..", 13 | "build:clean": "rimraf ../s3_file_field/static/s3_file_field", 14 | "build:compile": "parcel build --no-source-maps --no-cache", 15 | "build": "npm-run-all build:clean build:compile" 16 | }, 17 | "dependencies": { 18 | "django-s3-file-field": "file:../javascript-client" 19 | }, 20 | "devDependencies": { 21 | "@biomejs/biome": "^1.9.4", 22 | "@parcel/transformer-sass": "^2.15.1", 23 | "@tsconfig/recommended": "^1.0.8", 24 | "buffer": "^6.0.3", 25 | "npm-run-all2": "^8.0.0", 26 | "parcel": "^2.15.1", 27 | "rimraf": "^6.0.1", 28 | "typescript": "^5.8.3" 29 | }, 30 | "targets": { 31 | "default": { 32 | "source": "./src/widget.ts", 33 | "distDir": "../s3_file_field/static/s3_file_field/" 34 | } 35 | }, 36 | "alias": { 37 | "buffer": false 38 | }, 39 | "browserslist": ["last 1 chrome version", "last 1 firefox version"] 40 | } 41 | -------------------------------------------------------------------------------- /widget/src/S3FileInput.ts: -------------------------------------------------------------------------------- 1 | import S3FileFieldClient, {} from 'django-s3-file-field'; 2 | 3 | export const EVENT_UPLOAD_STARTED = 's3UploadStarted'; 4 | export const EVENT_UPLOAD_COMPLETE = 's3UploadComplete'; 5 | 6 | function cssClass(clazz: string): string { 7 | return `s3fileinput-${clazz}`; 8 | } 9 | 10 | function i18n(text: string): string { 11 | return text; 12 | } 13 | 14 | export default class S3FileInput { 15 | private readonly node: HTMLElement; 16 | 17 | private readonly input: HTMLInputElement; 18 | 19 | private readonly info: HTMLElement; 20 | 21 | private readonly clearButton: HTMLButtonElement; 22 | 23 | private readonly spinnerWrapper: HTMLElement; 24 | 25 | private readonly baseUrl: string; 26 | 27 | private readonly fieldId: string; 28 | 29 | constructor(input: HTMLInputElement) { 30 | this.input = input; 31 | 32 | const baseUrl = this.input.dataset?.s3fileinput; 33 | if (!baseUrl) { 34 | throw new Error('Missing "data-s3fileinput" attribute on input element.'); 35 | } 36 | this.baseUrl = baseUrl; 37 | 38 | const fieldId = this.input.dataset?.fieldId; 39 | if (!fieldId) { 40 | throw new Error('Missing "data-field-id" attribute on input element.'); 41 | } 42 | this.fieldId = fieldId; 43 | 44 | this.node = input.ownerDocument.createElement('div'); 45 | this.node.classList.add(cssClass('wrapper')); 46 | this.node.innerHTML = `
47 |
48 | 51 |
52 |
53 |
54 |
`; 55 | this.input.parentElement?.replaceChild(this.node, this.input); 56 | // biome-ignore lint/style/noNonNullAssertion: the element is known to exist 57 | this.clearButton = this.node.querySelector(`.${cssClass('clear')}`)!; 58 | // biome-ignore lint/style/noNonNullAssertion: the element is known to exist 59 | this.info = this.node.querySelector(`.${cssClass('info')}`)!; 60 | this.clearButton.insertAdjacentElement('beforebegin', this.input); 61 | // biome-ignore lint/style/noNonNullAssertion: the element is known to exist 62 | this.spinnerWrapper = this.node.querySelector(`.${cssClass('spinner-wrapper')}`)!; 63 | 64 | this.input.onchange = async (evt): Promise => { 65 | evt.preventDefault(); 66 | if (this.input.type === 'file') { 67 | await this.uploadFiles(); 68 | } else if (this.input.value === '') { 69 | // already processed but user resetted it -> convert bak 70 | this.input.type = 'file'; 71 | this.info.innerText = ''; 72 | this.node.classList.remove(cssClass('set')); 73 | } 74 | }; 75 | 76 | this.clearButton.onclick = (evt): void => { 77 | evt.preventDefault(); 78 | evt.stopPropagation(); 79 | this.input.type = 'file'; 80 | this.input.value = ''; 81 | this.info.innerText = ''; 82 | this.node.classList.remove(cssClass('set'), cssClass('error')); 83 | }; 84 | } 85 | 86 | private async uploadFile(file: File): Promise { 87 | const startedEvent = new CustomEvent(EVENT_UPLOAD_STARTED, { 88 | detail: file, 89 | }); 90 | this.input.dispatchEvent(startedEvent); 91 | 92 | const client = new S3FileFieldClient({ 93 | baseUrl: this.baseUrl, 94 | apiConfig: { 95 | // This will cause session and CSRF cookies to be sent for same-site requests. 96 | // Cross-site requests with the server-rendered widget are not supported. 97 | // If the server does not enable SessionAuthentication, requests will be unauthenticated, 98 | // but still allowed. 99 | xsrfCookieName: 'csrftoken', 100 | xsrfHeaderName: 'X-CSRFToken', 101 | // Explicitly disable this, to ensure that cross-site requests fail cleanly. 102 | withCredentials: false, 103 | }, 104 | }); 105 | const fieldValue = client.uploadFile(file, this.fieldId); 106 | 107 | const completedEvent = new CustomEvent(EVENT_UPLOAD_COMPLETE, { 108 | detail: fieldValue, 109 | }); 110 | this.input.dispatchEvent(completedEvent); 111 | 112 | return fieldValue; 113 | } 114 | 115 | private async uploadFiles(): Promise { 116 | const files = Array.from(this.input.files || []); 117 | if (files.length === 0) { 118 | return; 119 | } 120 | 121 | const bb = this.input.getBoundingClientRect(); 122 | this.spinnerWrapper.style.width = `${bb.width}px`; 123 | this.spinnerWrapper.style.height = `${bb.height}px`; 124 | 125 | this.node.classList.add(cssClass('uploading')); 126 | this.input.setCustomValidity(i18n('Uploading files, wait till finished')); 127 | this.input.value = ''; // reset file selection 128 | 129 | const file = files[0]; 130 | 131 | let fieldValue: string; 132 | try { 133 | fieldValue = await this.uploadFile(file); 134 | } catch (error) { 135 | this.node.classList.add(cssClass('set'), cssClass('error')); 136 | this.input.setCustomValidity('Error uploading file, see console for details.'); 137 | this.input.type = 'hidden'; 138 | this.info.innerText = 'Error uploading file, see console for details.'; 139 | throw error; 140 | } finally { 141 | this.node.classList.remove(cssClass('uploading')); 142 | } 143 | this.node.classList.add(cssClass('set')); 144 | this.input.setCustomValidity(''); // no error 145 | this.input.type = 'hidden'; 146 | this.input.value = fieldValue; 147 | this.info.innerText = file.name; 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /widget/src/style.scss: -------------------------------------------------------------------------------- 1 | $css_prefix: s3fileinput; 2 | 3 | .#{$css_prefix}-inner { 4 | margin: 0; 5 | padding: 0; 6 | display: flex; 7 | flex: 1 1 0; 8 | position: relative; 9 | 10 | input { 11 | flex: 1 1 0; 12 | } 13 | } 14 | 15 | .#{$css_prefix}-abort, 16 | .#{$css_prefix}-info, 17 | .#{$css_prefix}-clear { 18 | display: none; 19 | } 20 | 21 | .#{$css_prefix}-error { 22 | color: #dc3545; 23 | } 24 | 25 | .#{$css_prefix}-info { 26 | flex: 1 1 0; 27 | font-style: italic; 28 | } 29 | 30 | .#{$css_prefix}-progress { 31 | width: unset; 32 | height: 5px; 33 | position: relative; 34 | border-bottom: 1px solid transparent; 35 | 36 | > div { 37 | position: absolute; 38 | left: 0; 39 | top: 0; 40 | width: 0; 41 | height: 100%; 42 | transition: width 0.25s linear, background-color 0.25 ease; 43 | background-color: #17a2b8; 44 | } 45 | 46 | &[data-state="error"] > div { 47 | background-color: #dc3545; 48 | min-width: 25%; 49 | } 50 | 51 | &[data-state="successful"] > div { 52 | background-color: #28a745; 53 | } 54 | 55 | &[data-state="aborted"] > div { 56 | background-color: #ffc107; 57 | min-width: 25%; 58 | } 59 | } 60 | 61 | // spinner based on loading.io 62 | 63 | $dot-width: 0.5em; 64 | 65 | .#{$css_prefix}-spinner-wrapper { 66 | position: absolute; 67 | left: 0; 68 | top: 0; 69 | background: rgba(255, 255, 255, 0.75); 70 | display: none; 71 | flex-direction: column; 72 | align-items: center; 73 | justify-content: center; 74 | } 75 | 76 | .#{$css_prefix}-spinner { 77 | display: inline-block; 78 | position: relative; 79 | width: $dot-width * 8; 80 | height: $dot-width; 81 | 82 | > div { 83 | position: absolute; 84 | top: 0; 85 | width: $dot-width; 86 | height: $dot-width; 87 | border-radius: 50%; 88 | background: rgb(168, 168, 168); 89 | animation-timing-function: cubic-bezier(0, 1, 1, 0); 90 | 91 | &:nth-child(1) { 92 | animation: #{$css_prefix}-in 0.6s infinite; 93 | } 94 | 95 | &:nth-child(2) { 96 | animation: #{$css_prefix}-move 0.6s infinite; 97 | } 98 | 99 | &:nth-child(3) { 100 | left: $dot-width * 2; 101 | animation: #{$css_prefix}-move 0.6s infinite; 102 | } 103 | 104 | &:nth-child(4) { 105 | left: $dot-width * 4; 106 | animation: #{$css_prefix}-out 0.6s infinite; 107 | } 108 | } 109 | } 110 | 111 | @keyframes #{$css_prefix}-in { 112 | 0% { 113 | transform: scale(0); 114 | } 115 | 100% { 116 | transform: scale(1); 117 | } 118 | } 119 | @keyframes #{$css_prefix}-out { 120 | 0% { 121 | transform: scale(1); 122 | } 123 | 100% { 124 | transform: scale(0); 125 | } 126 | } 127 | @keyframes #{$css_prefix}-move { 128 | 0% { 129 | transform: translate(0, 0); 130 | } 131 | 100% { 132 | transform: #{translate($dot-width * 2, 0)}; 133 | } 134 | } 135 | 136 | .#{$css_prefix}-uploading { 137 | .#{$css_prefix}-abort { 138 | display: unset; 139 | } 140 | 141 | .#{$css_prefix}-spinner-wrapper { 142 | display: flex; 143 | } 144 | } 145 | 146 | .#{$css_prefix}-set { 147 | .#{$css_prefix}-info { 148 | display: inline-block; 149 | } 150 | .#{$css_prefix}-clear { 151 | display: unset; 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /widget/src/widget.ts: -------------------------------------------------------------------------------- 1 | import './style.scss'; 2 | import S3FileInput from './S3FileInput.js'; 3 | 4 | function attachToFileInputs(): void { 5 | for (const element of document.querySelectorAll('input[data-s3fileinput]')) { 6 | new S3FileInput(element); 7 | } 8 | } 9 | 10 | if (document.readyState !== 'loading') { 11 | attachToFileInputs(); 12 | } else { 13 | document.addEventListener('DOMContentLoaded', attachToFileInputs.bind(this)); 14 | } 15 | -------------------------------------------------------------------------------- /widget/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/recommended/tsconfig.json", 3 | "compilerOptions": { 4 | "target": "ES2020", 5 | "module": "NodeNext", 6 | "moduleResolution": "NodeNext", 7 | "sourceMap": true 8 | }, 9 | "include": ["src/**/*"] 10 | } 11 | --------------------------------------------------------------------------------