├── .cruft.json ├── .github ├── FUNDING.yml └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── CONTRIBUTORS.md ├── LICENSE ├── README.md ├── exiffield ├── __init__.py ├── exceptions.py ├── fields.py ├── getters.py └── py.typed ├── poetry.lock ├── poetry.toml ├── pyproject.toml ├── setup.cfg ├── tests ├── P1240157.JPG ├── __init__.py ├── conftest.py ├── models.py ├── test_checks.py ├── test_field.py ├── test_getters.py └── urls.py └── tox.ini /.cruft.json: -------------------------------------------------------------------------------- 1 | { 2 | "template": "https://github.com/escaped/cookiecutter-pypackage.git", 3 | "commit": "cf218660e8a8eb8171a16291b250f46b519b1e71", 4 | "context": { 5 | "cookiecutter": { 6 | "author": "Alexander Frenzel", 7 | "author_email": "alex@relatedworks.com", 8 | "github_username": "escaped", 9 | "project_name": "django-exiffield", 10 | "project_slug": "exiffield", 11 | "short_description": "django-exiffield extracts exif information by utilizing the exiftool.", 12 | "version": "2.1.0", 13 | "line_length": "88", 14 | "uses_django": "y", 15 | "_template": "https://github.com/escaped/cookiecutter-pypackage.git" 16 | } 17 | }, 18 | "directory": null 19 | } -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | --- 2 | github: [escaped] 3 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | release: 10 | name: Create release 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v2 15 | - name: Get version from tag 16 | id: tag_name 17 | run: | 18 | echo ::set-output name=current_version::${GITHUB_REF#refs/tags/} 19 | shell: bash 20 | - name: Get Changelog Entry 21 | id: changelog_reader 22 | uses: mindsers/changelog-reader-action@v2 23 | with: 24 | version: ${{ steps.tag_name.outputs.current_version }} 25 | path: ./CHANGELOG.md 26 | - name: Create Release 27 | id: create_release 28 | uses: actions/create-release@v1 29 | env: 30 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 31 | with: 32 | tag_name: ${{ steps.changelog_reader.outputs.version }} 33 | release_name: Release ${{ steps.changelog_reader.outputs.version }} 34 | body: ${{ steps.changelog_reader.outputs.changes }} 35 | prerelease: ${{ steps.changelog_reader.outputs.status == 'prereleased' }} 36 | draft: ${{ steps.changelog_reader.outputs.status == 'unreleased' }} 37 | 38 | publish: 39 | name: Build and publish Python distributions to PyPI 40 | runs-on: ubuntu-latest 41 | steps: 42 | - uses: actions/checkout@master 43 | - name: Set up Python 3.7 44 | uses: actions/setup-python@v1 45 | with: 46 | python-version: 3.7 47 | - name: Install pep517 48 | run: | 49 | python -m pip install pep517 50 | - name: Build a binary wheel and a source tarball 51 | run: | 52 | python -m pep517.build . --source --binary --out-dir dist/ 53 | - name: Publish distribution to PyPI 54 | uses: pypa/gh-action-pypi-publish@master 55 | with: 56 | password: ${{ secrets.pypi_token }} 57 | 58 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test & Lint 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - master 8 | 9 | jobs: 10 | lint: 11 | name: Lint 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout code 15 | uses: actions/checkout@v2 16 | - name: Set up Python 17 | uses: actions/setup-python@v2 18 | with: 19 | python-version: 3.8.5 20 | - name: Install dependencies 21 | run: | 22 | python -m pip install --upgrade pip 23 | pip install poetry 24 | poetry install 25 | - name: Lint 26 | run: poetry run pre-commit run -a 27 | 28 | test: 29 | name: Test 30 | runs-on: ${{ matrix.platform }} 31 | strategy: 32 | max-parallel: 4 33 | matrix: 34 | platform: [ubuntu-latest] 35 | python-version: [3.6, 3.7, 3.8] 36 | steps: 37 | - uses: actions/checkout@v1 38 | - name: Set up Python ${{ matrix.python-version }} 39 | uses: actions/setup-python@v2 40 | with: 41 | python-version: ${{ matrix.python-version }} 42 | - name: Install dependencies 43 | run: | 44 | python -m pip install --upgrade pip 45 | pip install tox tox-gh-actions coveralls 46 | sudo apt-get install -y exiftool 47 | - name: Test with tox 48 | run: tox 49 | env: 50 | PLATFORM: ${{ matrix.platform }} 51 | - name: coveralls 52 | run: coveralls 53 | env: 54 | COVERALLS_PARALLEL: true 55 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 56 | 57 | coveralls: 58 | needs: [test] 59 | runs-on: ubuntu-latest 60 | steps: 61 | - name: Set up Python 62 | uses: actions/setup-python@v2 63 | - name: Install dependencies 64 | run: | 65 | python -m pip install --upgrade pip 66 | pip install coveralls 67 | - name: coveralls 68 | run: coveralls --finish 69 | env: 70 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 71 | 72 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.toptal.com/developers/gitignore/api/vim,osx,node,linux,python,windows,visualstudiocode,git 3 | # Edit at https://www.toptal.com/developers/gitignore?templates=vim,osx,node,linux,python,windows,visualstudiocode,git 4 | 5 | ### Git ### 6 | # Created by git for backups. To disable backups in Git: 7 | # $ git config --global mergetool.keepBackup false 8 | *.orig 9 | 10 | # Created by git when using merge tools for conflicts 11 | *.BACKUP.* 12 | *.BASE.* 13 | *.LOCAL.* 14 | *.REMOTE.* 15 | *_BACKUP_*.txt 16 | *_BASE_*.txt 17 | *_LOCAL_*.txt 18 | *_REMOTE_*.txt 19 | 20 | ### Linux ### 21 | *~ 22 | 23 | # temporary files which can be created if a process still has a handle open of a deleted file 24 | .fuse_hidden* 25 | 26 | # KDE directory preferences 27 | .directory 28 | 29 | # Linux trash folder which might appear on any partition or disk 30 | .Trash-* 31 | 32 | # .nfs files are created when an open file is removed but is still being accessed 33 | .nfs* 34 | 35 | ### Node ### 36 | # Logs 37 | logs 38 | *.log 39 | npm-debug.log* 40 | yarn-debug.log* 41 | yarn-error.log* 42 | lerna-debug.log* 43 | 44 | # Diagnostic reports (https://nodejs.org/api/report.html) 45 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 46 | 47 | # Runtime data 48 | pids 49 | *.pid 50 | *.seed 51 | *.pid.lock 52 | 53 | # Directory for instrumented libs generated by jscoverage/JSCover 54 | lib-cov 55 | 56 | # Coverage directory used by tools like istanbul 57 | coverage 58 | *.lcov 59 | 60 | # nyc test coverage 61 | .nyc_output 62 | 63 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 64 | .grunt 65 | 66 | # Bower dependency directory (https://bower.io/) 67 | bower_components 68 | 69 | # node-waf configuration 70 | .lock-wscript 71 | 72 | # Compiled binary addons (https://nodejs.org/api/addons.html) 73 | build/Release 74 | 75 | # Dependency directories 76 | node_modules/ 77 | jspm_packages/ 78 | 79 | # TypeScript v1 declaration files 80 | typings/ 81 | 82 | # TypeScript cache 83 | *.tsbuildinfo 84 | 85 | # Optional npm cache directory 86 | .npm 87 | 88 | # Optional eslint cache 89 | .eslintcache 90 | 91 | # Microbundle cache 92 | .rpt2_cache/ 93 | .rts2_cache_cjs/ 94 | .rts2_cache_es/ 95 | .rts2_cache_umd/ 96 | 97 | # Optional REPL history 98 | .node_repl_history 99 | 100 | # Output of 'npm pack' 101 | *.tgz 102 | 103 | # Yarn Integrity file 104 | .yarn-integrity 105 | 106 | # dotenv environment variables file 107 | .env 108 | .env.test 109 | .env*.local 110 | 111 | # parcel-bundler cache (https://parceljs.org/) 112 | .cache 113 | .parcel-cache 114 | 115 | # Next.js build output 116 | .next 117 | 118 | # Nuxt.js build / generate output 119 | .nuxt 120 | dist 121 | 122 | # Gatsby files 123 | .cache/ 124 | # Comment in the public line in if your project uses Gatsby and not Next.js 125 | # https://nextjs.org/blog/next-9-1#public-directory-support 126 | # public 127 | 128 | # vuepress build output 129 | .vuepress/dist 130 | 131 | # Serverless directories 132 | .serverless/ 133 | 134 | # FuseBox cache 135 | .fusebox/ 136 | 137 | # DynamoDB Local files 138 | .dynamodb/ 139 | 140 | # TernJS port file 141 | .tern-port 142 | 143 | # Stores VSCode versions used for testing VSCode extensions 144 | .vscode-test 145 | 146 | ### OSX ### 147 | # General 148 | .DS_Store 149 | .AppleDouble 150 | .LSOverride 151 | 152 | # Icon must end with two \r 153 | Icon 154 | 155 | # Thumbnails 156 | ._* 157 | 158 | # Files that might appear in the root of a volume 159 | .DocumentRevisions-V100 160 | .fseventsd 161 | .Spotlight-V100 162 | .TemporaryItems 163 | .Trashes 164 | .VolumeIcon.icns 165 | .com.apple.timemachine.donotpresent 166 | 167 | # Directories potentially created on remote AFP share 168 | .AppleDB 169 | .AppleDesktop 170 | Network Trash Folder 171 | Temporary Items 172 | .apdisk 173 | 174 | ### Python ### 175 | # Byte-compiled / optimized / DLL files 176 | __pycache__/ 177 | *.py[cod] 178 | *$py.class 179 | 180 | # C extensions 181 | *.so 182 | 183 | # Distribution / packaging 184 | .Python 185 | build/ 186 | develop-eggs/ 187 | dist/ 188 | downloads/ 189 | eggs/ 190 | .eggs/ 191 | lib/ 192 | lib64/ 193 | parts/ 194 | sdist/ 195 | var/ 196 | wheels/ 197 | pip-wheel-metadata/ 198 | share/python-wheels/ 199 | *.egg-info/ 200 | .installed.cfg 201 | *.egg 202 | MANIFEST 203 | 204 | # PyInstaller 205 | # Usually these files are written by a python script from a template 206 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 207 | *.manifest 208 | *.spec 209 | 210 | # Installer logs 211 | pip-log.txt 212 | pip-delete-this-directory.txt 213 | 214 | # Unit test / coverage reports 215 | htmlcov/ 216 | .tox/ 217 | .nox/ 218 | .coverage 219 | .coverage.* 220 | nosetests.xml 221 | coverage.xml 222 | *.cover 223 | *.py,cover 224 | .hypothesis/ 225 | .pytest_cache/ 226 | pytestdebug.log 227 | 228 | # Translations 229 | *.mo 230 | *.pot 231 | 232 | # Django stuff: 233 | local_settings.py 234 | db.sqlite3 235 | db.sqlite3-journal 236 | 237 | # Flask stuff: 238 | instance/ 239 | .webassets-cache 240 | 241 | # Scrapy stuff: 242 | .scrapy 243 | 244 | # Sphinx documentation 245 | docs/_build/ 246 | doc/_build/ 247 | 248 | # PyBuilder 249 | target/ 250 | 251 | # Jupyter Notebook 252 | .ipynb_checkpoints 253 | 254 | # IPython 255 | profile_default/ 256 | ipython_config.py 257 | 258 | # pyenv 259 | .python-version 260 | 261 | # pipenv 262 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 263 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 264 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 265 | # install all needed dependencies. 266 | #Pipfile.lock 267 | 268 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 269 | __pypackages__/ 270 | 271 | # Celery stuff 272 | celerybeat-schedule 273 | celerybeat.pid 274 | 275 | # SageMath parsed files 276 | *.sage.py 277 | 278 | # Environments 279 | .venv 280 | env/ 281 | venv/ 282 | ENV/ 283 | env.bak/ 284 | venv.bak/ 285 | pythonenv* 286 | 287 | # Spyder project settings 288 | .spyderproject 289 | .spyproject 290 | 291 | # Rope project settings 292 | .ropeproject 293 | 294 | # mkdocs documentation 295 | /site 296 | 297 | # mypy 298 | .mypy_cache/ 299 | .dmypy.json 300 | dmypy.json 301 | 302 | # Pyre type checker 303 | .pyre/ 304 | 305 | # pytype static type analyzer 306 | .pytype/ 307 | 308 | # profiling data 309 | .prof 310 | 311 | ### Vim ### 312 | # Swap 313 | [._]*.s[a-v][a-z] 314 | !*.svg # comment out if you don't need vector files 315 | [._]*.sw[a-p] 316 | [._]s[a-rt-v][a-z] 317 | [._]ss[a-gi-z] 318 | [._]sw[a-p] 319 | 320 | # Session 321 | Session.vim 322 | Sessionx.vim 323 | 324 | # Temporary 325 | .netrwhist 326 | # Auto-generated tag files 327 | tags 328 | # Persistent undo 329 | [._]*.un~ 330 | 331 | ### VisualStudioCode ### 332 | .vscode/* 333 | !.vscode/settings.json 334 | !.vscode/tasks.json 335 | !.vscode/launch.json 336 | !.vscode/extensions.json 337 | *.code-workspace 338 | 339 | ### VisualStudioCode Patch ### 340 | # Ignore all local history of files 341 | .history 342 | .ionide 343 | 344 | ### Windows ### 345 | # Windows thumbnail cache files 346 | Thumbs.db 347 | Thumbs.db:encryptable 348 | ehthumbs.db 349 | ehthumbs_vista.db 350 | 351 | # Dump file 352 | *.stackdump 353 | 354 | # Folder config file 355 | [Dd]esktop.ini 356 | 357 | # Recycle Bin used on file shares 358 | $RECYCLE.BIN/ 359 | 360 | # Windows Installer files 361 | *.cab 362 | *.msi 363 | *.msix 364 | *.msm 365 | *.msp 366 | 367 | # Windows shortcuts 368 | *.lnk 369 | 370 | # End of https://www.toptal.com/developers/gitignore/api/vim,osx,node,linux,python,windows,visualstudiocode,git 371 | 372 | media 373 | *.sqlite3 374 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | repos: 3 | - repo: local 4 | hooks: 5 | - id: black 6 | name: black 7 | language: system 8 | entry: poetry run black 9 | types: [python] 10 | 11 | - repo: local 12 | hooks: 13 | - id: isort 14 | name: isort 15 | language: system 16 | entry: poetry run isort -profile black 17 | types: [python] 18 | 19 | - repo: local 20 | hooks: 21 | - id: mypy 22 | name: mypy 23 | language: system 24 | entry: poetry run mypy 25 | types: [python] 26 | 27 | - repo: local 28 | hooks: 29 | - id: flake8 30 | name: flake8 31 | language: system 32 | entry: poetry run flake8 33 | types: [python] 34 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ## [3.0.0] - 2020-10-30 11 | 12 | ### Added 13 | 14 | - Support remote storage, thanks to @jsutlovic 15 | 16 | ### Changed 17 | 18 | - BREAKING: use [jsonfield](https://github.com/rpkilby/jsonfield) as field base as jsonfield2 is deprecated 19 | 20 | ## [2.1.0] - 2018-12-24 21 | 22 | ### Added 23 | 24 | - Add django 2.2 and python 3.8 to tox 25 | - Allow newer versions of pillow 26 | 27 | ## [2.0.0] - 2018-12-24 28 | 29 | ### Changed 30 | 31 | - BREAKING: use [jsonfield2](https://pypi.org/project/jsonfield2/) as field base 32 | 33 | ### Fixed 34 | 35 | - extract exif if file changed or exif information is missing 36 | 37 | ## [1.1.0] - 2018-12-04 38 | 39 | ### Changed 40 | 41 | - change logging of exif denormalization to warning 42 | 43 | ## [1.0.1] - 2018-11-11 44 | 45 | ### Fixed 46 | 47 | - fix `update_exif` with `commit=True` 48 | 49 | ## [1.0.0] 2018-09-26 50 | 51 | ### Added 52 | 53 | - ability to extract exif from files which are not stored to the storage 54 | 55 | ### Changed 56 | 57 | - poetry as build tool 58 | 59 | ## [0.1] - 2018-03-29 60 | 61 | [Unreleased]: https://github.com/escaped/django-exiffield/compare/3.0.0...HEAD 62 | [3.0.0]: https://github.com/escaped/django-exiffield/compare/2.1.0...3.0.0 63 | [2.1.0]: https://github.com/escaped/django-exiffield/compare/2.0.0...2.1.0 64 | [2.0.0]: https://github.com/escaped/django-exiffield/compare/1.1.0...2.0.0 65 | [1.1.0]: https://github.com/escaped/django-exiffield/compare/1.0.0...1.1.0 66 | [1.0.0]: https://github.com/escaped/django-exiffield/compare/0.1...1.0.0 67 | [0.1]: https://github.com/escaped/django-exiffield/tree/0.1 68 | -------------------------------------------------------------------------------- /CONTRIBUTORS.md: -------------------------------------------------------------------------------- 1 | # Contributors 2 | 3 | * Alexander Frenzel [@escaped](https://github.com/escaped) 4 | * Balint Vekerdy [@vekerdyb](https://github.com/vekerdyb) 5 | * Jero Sutlovic [@jsutlovic](https://github.com/jsutlovic) 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) Alexander Frenzel. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in the 12 | documentation and/or other materials provided with the distribution. 13 | 14 | 3. Neither the name of Alexander Frenzel nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # django-exiffield 2 | 3 | ![PyPI](https://img.shields.io/pypi/v/django-exiffield?style=flat-square) 4 | ![GitHub Workflow Status (master)](https://img.shields.io/github/workflow/status/escaped/django-exiffield/Test%20&%20Lint/master?style=flat-square) 5 | ![Coveralls github branch](https://img.shields.io/coveralls/github/escaped/django-exiffield/master?style=flat-square) 6 | ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/django-exiffield?style=flat-square) 7 | ![PyPI - License](https://img.shields.io/pypi/l/django-exiffield?style=flat-square) 8 | 9 | django-exiffield extracts exif information by utilizing the exiftool. 10 | 11 | ## Requirements 12 | 13 | * Python 3.6.1 or newer 14 | * [exiftool](https://www.sno.phy.queensu.ca/~phil/exiftool/) 15 | * Django >= 2.2 16 | 17 | ## Installation 18 | 19 | 1. Install django-exiffield 20 | 21 | ```sh 22 | pip install django-exiffield 23 | ``` 24 | 25 | 2. Make sure `exiftool` is executable from you environment. 26 | 27 | ## Integration 28 | 29 | Let's assume we have an image Model with a single `ImageField`. 30 | To extract exif information for an attached image, add an `ExifField`, 31 | specify the name of the `ImageField` in the `source` argument 32 | 33 | ```python 34 | from django.db import models 35 | 36 | from exiffield.fields import ExifField 37 | 38 | 39 | class Image(models.Model): 40 | image = models.ImageField() 41 | exif = ExifField( 42 | source='image', 43 | ) 44 | ``` 45 | 46 | and create a migration for the new field. 47 | That's it. 48 | 49 | After attaching an image to your `ImageField`, the exif information is stored 50 | as a `dict` on the `ExifField`. 51 | Each exif information of the dictionary consists of two keys: 52 | 53 | * `desc`: A human readable description 54 | * `val`: The value for the entry. 55 | 56 | In the following example we access the camera model 57 | 58 | ```python 59 | image = Image.objects.get(...) 60 | print(image.exif['Model']) 61 | # { 62 | # 'desc': 'Camera Model Name', 63 | # 'val': 'DMC-GX7', 64 | # } 65 | ``` 66 | 67 | As the exif information is encoded in a simple `dict` you can iterate and access 68 | the values with all familiar dictionary methods. 69 | 70 | ## Denormalizing Fields 71 | 72 | Since the `ExifField` stores its data simply as text, it is not possible to filter 73 | or access indiviual values efficiently. 74 | The `ExifField` provides a convinient way to denormalize certain values using 75 | the `denormalized_fields` argument. 76 | It takes a dictionary with the target field as key and a simple getter function of 77 | type `Callable[[Dict[Dict[str, str]]], Any]`. 78 | To denormalize a simple value you can use the provided `exiffield.getters.exifgetter` 79 | 80 | ```python 81 | from django.db import models 82 | 83 | from exiffield.fields import ExifField 84 | from exiffield.getters import exifgetter 85 | 86 | 87 | class Image(models.Model): 88 | image = models.ImageField() 89 | camera = models.CharField( 90 | editable=False, 91 | max_length=100, 92 | ) 93 | exif = ExifField( 94 | source='image', 95 | denormalized_fields={ 96 | 'camera': exifgetter('Model'), 97 | }, 98 | ) 99 | ``` 100 | 101 | There are more predefined getters in `exiffield.getters`: 102 | 103 | `exifgetter(exif_key: str) -> str` 104 | Get an unmodified exif value. 105 | 106 | `get_type() -> str` 107 | Get file type, e.g. video or image 108 | 109 | `get_datetaken -> Optional[datetime]` 110 | Get when the file was created as `datetime` 111 | 112 | `get_orientation -> exiffield.getters.Orientation` 113 | Get orientation of media file. 114 | Possible values are `LANDSCAPE` and `PORTRAIT`. 115 | 116 | `get_sequenctype -> exiffield.getters.Mode` 117 | Guess if the image was taken in a sequence. 118 | Possible values are `BURST`, `BRACKETING`, `TIMELAPSE` and `SINGLE`. 119 | 120 | `get_sequencenumber -> int` 121 | Get image position in a sequence. 122 | 123 | ## Development 124 | 125 | This project uses [poetry](https://poetry.eustace.io/) for packaging and 126 | managing all dependencies and [pre-commit](https://pre-commit.com/) to run 127 | [flake8](http://flake8.pycqa.org/), [isort](https://pycqa.github.io/isort/), 128 | [mypy](http://mypy-lang.org/) and [black](https://github.com/python/black). 129 | 130 | Clone this repository and run 131 | 132 | ```bash 133 | poetry install 134 | poetry run pre-commit install 135 | ``` 136 | 137 | to create a virtual enviroment containing all dependencies. 138 | Afterwards, You can run the test suite using 139 | 140 | ```bash 141 | poetry run pytest 142 | ``` 143 | 144 | This repository follows the [Conventional Commits](https://www.conventionalcommits.org/) 145 | style. 146 | 147 | ### Cookiecutter template 148 | 149 | This project was created using [cruft](https://github.com/cruft/cruft) and the 150 | [cookiecutter-pyproject](https://github.com/escaped/cookiecutter-pypackage) template. 151 | In order to update this repository to the latest template version run 152 | 153 | ```sh 154 | cruft update 155 | ``` 156 | 157 | in the root of this repository. 158 | 159 | -------------------------------------------------------------------------------- /exiffield/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '0.1' 2 | -------------------------------------------------------------------------------- /exiffield/exceptions.py: -------------------------------------------------------------------------------- 1 | class ExifError(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /exiffield/fields.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import shutil 4 | import subprocess 5 | from pathlib import Path 6 | from typing import Generator, List 7 | 8 | from django.core import checks, exceptions 9 | from django.db import models 10 | from django.db.models.fields.files import FieldFile 11 | from django.db.models.signals import post_init, pre_save 12 | from jsonfield import JSONField 13 | 14 | from .exceptions import ExifError 15 | 16 | logger = logging.getLogger(__name__) 17 | 18 | 19 | def get_exif(file_: FieldFile) -> str: 20 | """ 21 | Use exiftool to extract exif data from the given file field. 22 | """ 23 | exiftool_path = shutil.which('exiftool') 24 | if not exiftool_path: 25 | raise ExifError('Could not find `exiftool`') 26 | 27 | if not file_._committed: 28 | # pipe file content to exiftool 29 | fo = file_._file 30 | fo.seek(0) 31 | else: 32 | fo = file_.open() 33 | 34 | process = subprocess.run( 35 | [exiftool_path, '-j', '-l', '-'], 36 | check=True, 37 | input=fo.read(), 38 | stdout=subprocess.PIPE, 39 | ) 40 | return process.stdout 41 | 42 | 43 | class ExifField(JSONField): 44 | def __init__(self, *args, **kwargs) -> None: 45 | """ 46 | Extract fields for denormalized exif values. 47 | """ 48 | self.denormalized_fields = kwargs.pop('denormalized_fields', {}) 49 | self.source = kwargs.pop('source', None) 50 | self.sync = kwargs.pop('sync', True) 51 | kwargs['editable'] = False 52 | kwargs['default'] = {} 53 | super().__init__(*args, **kwargs) 54 | 55 | def check(self, **kwargs) -> List[checks.CheckMessage]: 56 | """ 57 | Check if current configuration is valid. 58 | """ 59 | errors = super().check(**kwargs) 60 | if not self.model._meta.abstract: 61 | errors.extend(self._check_for_exiftool()) 62 | errors.extend(self._check_fields()) 63 | errors.extend(self._check_for_source()) 64 | return errors 65 | 66 | def _check_for_exiftool(self) -> Generator[checks.CheckMessage, None, None]: 67 | """ 68 | Return an error if `exiftool` is not available. 69 | """ 70 | if not shutil.which('exiftool'): 71 | yield checks.Error( 72 | '`exiftool` not found.', 73 | hint='Please install `exiftool.`', 74 | obj=self, 75 | id='exiffield.E001', 76 | ) 77 | 78 | def _check_for_source(self) -> Generator[checks.CheckMessage, None, None]: 79 | """ 80 | Return errors if the source field is invalid. 81 | """ 82 | if not self.source: 83 | yield checks.Error( 84 | f'`self.source` not set on {self.name}.', 85 | hint='Set `self.source` to an existing FileField.', 86 | obj=self, 87 | id='exiffield.E002', 88 | ) 89 | return 90 | 91 | # check wether field is valid 92 | try: 93 | field = self.model._meta.get_field(self.source) 94 | except exceptions.FieldDoesNotExist: 95 | yield checks.Error( 96 | f'`{self.source}` not found on {self.model}.', 97 | hint='Check spelling or add field to model.', 98 | obj=self, 99 | id='exiffield.E003', 100 | ) 101 | return 102 | if not isinstance(field, models.FileField): 103 | yield checks.Error( 104 | f'`{self.source}` on {self.model} must be a FileField.', 105 | obj=self, 106 | id='exiffield.E004', 107 | ) 108 | 109 | def _check_fields(self) -> Generator[checks.CheckMessage, None, None]: 110 | """ 111 | Return errors if any denormalized field is editable. 112 | python out loud 113 | """ 114 | if not isinstance(self.denormalized_fields, dict): 115 | yield checks.Error( 116 | f'`denormalized_fields` on {self.model} should be a dictionary.', 117 | hint='Check the kwargs of `ExifField`', 118 | obj=self, 119 | id='exiffield.E005', 120 | ) 121 | return 122 | 123 | for fieldname, func in self.denormalized_fields.items(): 124 | try: 125 | field = self.model._meta.get_field(fieldname) 126 | except exceptions.FieldDoesNotExist: 127 | yield checks.Error( 128 | f'`{fieldname}` not found on {self.model}.', 129 | hint='Check spelling or add field to model.', 130 | obj=self, 131 | id='exiffield.E006', 132 | ) 133 | continue 134 | 135 | if field.editable: 136 | yield checks.Error( 137 | f'`{fieldname}` on {self.model} should not be editable.', 138 | hint=f'Set `editable=False` on {fieldname}.', 139 | obj=self, 140 | id='exiffield.E007', 141 | ) 142 | 143 | if not callable(func): 144 | yield checks.Error( 145 | f'`Value for {fieldname}` on {self.model} should not be a callable.', 146 | hint='Check your values for `denormalized_fields`', 147 | obj=self, 148 | id='exiffield.E008', 149 | ) 150 | 151 | def contribute_to_class( 152 | self, 153 | cls: models.Model, 154 | name: str, 155 | **kwargs, 156 | ) -> None: 157 | """ 158 | Register signals for retrieving and writing of exif data. 159 | """ 160 | super().contribute_to_class(cls, name, **kwargs) 161 | 162 | # Only run post-initialization exif update on non-abstract models 163 | if not cls._meta.abstract: 164 | if self.sync: 165 | pre_save.connect(self.update_exif, sender=cls) 166 | 167 | # denormalize exif values 168 | pre_save.connect(self.denormalize_exif, sender=cls) 169 | post_init.connect(self.denormalize_exif, sender=cls) 170 | 171 | def denormalize_exif( 172 | self, 173 | instance: models.Model, 174 | **kwargs, 175 | ) -> None: 176 | """ 177 | Update denormalized fields with new exif values. 178 | """ 179 | exif_data = getattr(instance, self.name) 180 | if not exif_data: 181 | return 182 | 183 | for model_field, extract_from_exif in self.denormalized_fields.items(): 184 | value = None 185 | try: 186 | value = extract_from_exif(exif_data) 187 | except Exception: 188 | logger.warning( 189 | 'Could not execute `%s` to extract value for `%s.%s`', 190 | extract_from_exif.__name__, 191 | instance.__class__.__name__, 192 | model_field, 193 | exc_info=True, 194 | ) 195 | if not value: 196 | continue 197 | 198 | setattr(instance, model_field, value) 199 | 200 | def update_exif( 201 | self, 202 | instance: models.Model, 203 | force: bool = False, 204 | commit: bool = False, 205 | **kwargs, 206 | ) -> None: 207 | """ 208 | Load exif data from file. 209 | """ 210 | file_ = getattr(instance, self.source) 211 | if not file_: 212 | # there is no file attached to the FileField 213 | return 214 | 215 | # check whether extraction of the exif is required 216 | exif_data = getattr(instance, self.name, None) or {} 217 | has_exif = bool(exif_data) 218 | filename = Path(file_.name).name 219 | exif_for_filename = exif_data.get('FileName', {}).get('val', '') 220 | file_changed = exif_for_filename != filename or not file_._committed 221 | 222 | if has_exif and not file_changed and not force: 223 | # nothing to do since the file has not been changed 224 | return 225 | 226 | try: 227 | exif_json = get_exif(file_) 228 | except Exception: 229 | logger.exception('Could not read metainformation from file: %s', file_.name) 230 | return 231 | 232 | try: 233 | exif_data = json.loads(exif_json)[0] 234 | except IndexError: 235 | return 236 | else: 237 | if 'FileName' not in exif_data: 238 | # If the file is uncommited, exiftool cannot extract a filenmae 239 | # We guess, that no other file with the same filename exists in 240 | # the storage. 241 | # In the worst case the exif is extracted twice... 242 | exif_data['FileName'] = { 243 | 'desc': 'File Name', 244 | 'val': filename, 245 | } 246 | setattr(instance, self.name, exif_data) 247 | 248 | if commit: 249 | instance.save() 250 | -------------------------------------------------------------------------------- /exiffield/getters.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from enum import Enum 3 | from typing import Any, Callable, Dict, Optional 4 | 5 | from choicesenum import ChoicesEnum 6 | 7 | from .exceptions import ExifError 8 | 9 | ExifType = Dict[str, Dict[str, Any]] 10 | 11 | 12 | class Orientation(ChoicesEnum, Enum): # NOTE inherits from `Enum` to make `mypy` happy 13 | LANDSCAPE = 'landscape' 14 | PORTRAIT = 'portrait' 15 | 16 | 17 | class Mode(ChoicesEnum, Enum): # NOTE inherits from `Enum` to make `mypy` happy 18 | TIMELAPSE = 'timelapse' 19 | BURST = 'burst' 20 | BRACKETING = 'bracketing' 21 | SINGLE = 'single' 22 | 23 | 24 | def exifgetter(field: str) -> Callable[[ExifType], Any]: 25 | """ 26 | Return the unmodified value. 27 | """ 28 | 29 | def inner(exif: ExifType) -> Any: 30 | return exif[field]['val'] 31 | 32 | inner.__name__ = f'exifgetter("{field}")' 33 | return inner 34 | 35 | 36 | def get_type(exif: ExifType) -> str: 37 | """ 38 | Return type of file, e.g. image. 39 | """ 40 | return exif['MIMEType']['val'].split('/')[0] 41 | 42 | 43 | def get_datetaken(exif: ExifType) -> Optional[datetime.datetime]: 44 | """ 45 | Return when the file was created. 46 | """ 47 | for key in ['DateTimeOriginal', 'GPSDateTime']: 48 | try: 49 | datetime_str = exif[key]['val'] 50 | except KeyError: 51 | continue 52 | 53 | try: 54 | return datetime.datetime.strptime( 55 | datetime_str, 56 | '%Y:%m:%d %H:%M:%S', 57 | ) 58 | except ValueError as e: 59 | raise ExifError(f'Could not parse {datetime_str}') from e 60 | raise ExifError('Could not find date') 61 | 62 | 63 | def get_orientation(exif: ExifType) -> Orientation: 64 | """ 65 | Return orientation of the file. 66 | """ 67 | orientation = exif['Orientation']['num'] 68 | 69 | width, height = exif['ImageWidth']['val'], exif['ImageHeight']['val'] 70 | if orientation > 4: 71 | # image rotated image by 90 degrees 72 | width, height = height, width 73 | if width < height: 74 | return Orientation.PORTRAIT 75 | return Orientation.LANDSCAPE 76 | 77 | 78 | def get_sequencetype(exif) -> Mode: 79 | """ 80 | Return the recoding mode. 81 | """ 82 | # burst or bracketing 83 | try: 84 | mode = exif['BurstMode']['num'] 85 | except KeyError: 86 | pass 87 | else: 88 | if mode == 1: 89 | return Mode.BURST 90 | if mode == 2: 91 | return Mode.BRACKETING 92 | 93 | # time lapse 94 | try: 95 | mode = exif['TimerRecording']['num'] 96 | except KeyError: 97 | pass 98 | else: 99 | if mode == 1: 100 | return Mode.TIMELAPSE 101 | return Mode.SINGLE 102 | 103 | 104 | def get_sequencenumber(exif) -> int: 105 | """ 106 | Return position of image within the recoding sequence. 107 | """ 108 | try: 109 | return exif['SequenceNumber']['num'] 110 | except KeyError: 111 | return 0 112 | -------------------------------------------------------------------------------- /exiffield/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/escaped/django-exiffield/73dd6b0795e701bff5af150362b1bb2f7256a550/exiffield/py.typed -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | [[package]] 2 | name = "appdirs" 3 | version = "1.4.4" 4 | description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." 5 | category = "dev" 6 | optional = false 7 | python-versions = "*" 8 | 9 | [[package]] 10 | name = "asgiref" 11 | version = "3.2.10" 12 | description = "ASGI specs, helper code, and adapters" 13 | category = "main" 14 | optional = false 15 | python-versions = ">=3.5" 16 | 17 | [package.extras] 18 | tests = ["pytest", "pytest-asyncio"] 19 | 20 | [[package]] 21 | name = "atomicwrites" 22 | version = "1.4.0" 23 | description = "Atomic file writes." 24 | category = "dev" 25 | optional = false 26 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 27 | 28 | [[package]] 29 | name = "attrs" 30 | version = "20.2.0" 31 | description = "Classes Without Boilerplate" 32 | category = "dev" 33 | optional = false 34 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 35 | 36 | [package.extras] 37 | dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "sphinx", "sphinx-rtd-theme", "pre-commit"] 38 | docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] 39 | tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"] 40 | tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six"] 41 | 42 | [[package]] 43 | name = "black" 44 | version = "20.8b1" 45 | description = "The uncompromising code formatter." 46 | category = "dev" 47 | optional = false 48 | python-versions = ">=3.6" 49 | 50 | [package.dependencies] 51 | appdirs = "*" 52 | click = ">=7.1.2" 53 | dataclasses = {version = ">=0.6", markers = "python_version < \"3.7\""} 54 | mypy-extensions = ">=0.4.3" 55 | pathspec = ">=0.6,<1" 56 | regex = ">=2020.1.8" 57 | toml = ">=0.10.1" 58 | typed-ast = ">=1.4.0" 59 | typing-extensions = ">=3.7.4" 60 | 61 | [package.extras] 62 | colorama = ["colorama (>=0.4.3)"] 63 | d = ["aiohttp (>=3.3.2)", "aiohttp-cors"] 64 | 65 | [[package]] 66 | name = "cfgv" 67 | version = "3.2.0" 68 | description = "Validate configuration and produce human readable error messages." 69 | category = "dev" 70 | optional = false 71 | python-versions = ">=3.6.1" 72 | 73 | [[package]] 74 | name = "choicesenum" 75 | version = "0.7.0" 76 | description = "Python's Enum with extra powers to play nice with labels and choices fields" 77 | category = "main" 78 | optional = false 79 | python-versions = "*" 80 | 81 | [package.dependencies] 82 | six = "*" 83 | 84 | [[package]] 85 | name = "click" 86 | version = "7.1.2" 87 | description = "Composable command line interface toolkit" 88 | category = "dev" 89 | optional = false 90 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 91 | 92 | [[package]] 93 | name = "colorama" 94 | version = "0.4.4" 95 | description = "Cross-platform colored terminal text." 96 | category = "dev" 97 | optional = false 98 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 99 | 100 | [[package]] 101 | name = "coverage" 102 | version = "5.3" 103 | description = "Code coverage measurement for Python" 104 | category = "dev" 105 | optional = false 106 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" 107 | 108 | [package.extras] 109 | toml = ["toml"] 110 | 111 | [[package]] 112 | name = "dataclasses" 113 | version = "0.7" 114 | description = "A backport of the dataclasses module for Python 3.6" 115 | category = "dev" 116 | optional = false 117 | python-versions = ">=3.6, <3.7" 118 | 119 | [[package]] 120 | name = "distlib" 121 | version = "0.3.1" 122 | description = "Distribution utilities" 123 | category = "dev" 124 | optional = false 125 | python-versions = "*" 126 | 127 | [[package]] 128 | name = "django" 129 | version = "3.1.2" 130 | description = "A high-level Python Web framework that encourages rapid development and clean, pragmatic design." 131 | category = "main" 132 | optional = false 133 | python-versions = ">=3.6" 134 | 135 | [package.dependencies] 136 | asgiref = ">=3.2.10,<3.3.0" 137 | pytz = "*" 138 | sqlparse = ">=0.2.2" 139 | 140 | [package.extras] 141 | argon2 = ["argon2-cffi (>=16.1.0)"] 142 | bcrypt = ["bcrypt"] 143 | 144 | [[package]] 145 | name = "filelock" 146 | version = "3.0.12" 147 | description = "A platform independent file lock." 148 | category = "dev" 149 | optional = false 150 | python-versions = "*" 151 | 152 | [[package]] 153 | name = "flake8" 154 | version = "3.8.4" 155 | description = "the modular source code checker: pep8 pyflakes and co" 156 | category = "dev" 157 | optional = false 158 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" 159 | 160 | [package.dependencies] 161 | importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} 162 | mccabe = ">=0.6.0,<0.7.0" 163 | pycodestyle = ">=2.6.0a1,<2.7.0" 164 | pyflakes = ">=2.2.0,<2.3.0" 165 | 166 | [[package]] 167 | name = "identify" 168 | version = "1.5.6" 169 | description = "File identification library for Python" 170 | category = "dev" 171 | optional = false 172 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" 173 | 174 | [package.extras] 175 | license = ["editdistance"] 176 | 177 | [[package]] 178 | name = "importlib-metadata" 179 | version = "2.0.0" 180 | description = "Read metadata from Python packages" 181 | category = "dev" 182 | optional = false 183 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" 184 | 185 | [package.dependencies] 186 | zipp = ">=0.5" 187 | 188 | [package.extras] 189 | docs = ["sphinx", "rst.linker"] 190 | testing = ["packaging", "pep517", "importlib-resources (>=1.3)"] 191 | 192 | [[package]] 193 | name = "importlib-resources" 194 | version = "3.3.0" 195 | description = "Read resources from Python packages" 196 | category = "dev" 197 | optional = false 198 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" 199 | 200 | [package.dependencies] 201 | zipp = {version = ">=0.4", markers = "python_version < \"3.8\""} 202 | 203 | [package.extras] 204 | docs = ["sphinx", "rst.linker", "jaraco.packaging"] 205 | 206 | [[package]] 207 | name = "iniconfig" 208 | version = "1.1.1" 209 | description = "iniconfig: brain-dead simple config-ini parsing" 210 | category = "dev" 211 | optional = false 212 | python-versions = "*" 213 | 214 | [[package]] 215 | name = "isort" 216 | version = "5.6.4" 217 | description = "A Python utility / library to sort Python imports." 218 | category = "dev" 219 | optional = false 220 | python-versions = ">=3.6,<4.0" 221 | 222 | [package.extras] 223 | pipfile_deprecated_finder = ["pipreqs", "requirementslib"] 224 | requirements_deprecated_finder = ["pipreqs", "pip-api"] 225 | colors = ["colorama (>=0.4.3,<0.5.0)"] 226 | 227 | [[package]] 228 | name = "jsonfield" 229 | version = "3.1.0" 230 | description = "A reusable Django field that allows you to store validated JSON in your model." 231 | category = "main" 232 | optional = false 233 | python-versions = ">=3.6" 234 | 235 | [package.dependencies] 236 | Django = ">=2.2" 237 | 238 | [[package]] 239 | name = "mccabe" 240 | version = "0.6.1" 241 | description = "McCabe checker, plugin for flake8" 242 | category = "dev" 243 | optional = false 244 | python-versions = "*" 245 | 246 | [[package]] 247 | name = "mypy" 248 | version = "0.782" 249 | description = "Optional static typing for Python" 250 | category = "dev" 251 | optional = false 252 | python-versions = ">=3.5" 253 | 254 | [package.dependencies] 255 | mypy-extensions = ">=0.4.3,<0.5.0" 256 | typed-ast = ">=1.4.0,<1.5.0" 257 | typing-extensions = ">=3.7.4" 258 | 259 | [package.extras] 260 | dmypy = ["psutil (>=4.0)"] 261 | 262 | [[package]] 263 | name = "mypy-extensions" 264 | version = "0.4.3" 265 | description = "Experimental type system extensions for programs checked with the mypy typechecker." 266 | category = "dev" 267 | optional = false 268 | python-versions = "*" 269 | 270 | [[package]] 271 | name = "nodeenv" 272 | version = "1.5.0" 273 | description = "Node.js virtual environment builder" 274 | category = "dev" 275 | optional = false 276 | python-versions = "*" 277 | 278 | [[package]] 279 | name = "packaging" 280 | version = "20.4" 281 | description = "Core utilities for Python packages" 282 | category = "dev" 283 | optional = false 284 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 285 | 286 | [package.dependencies] 287 | pyparsing = ">=2.0.2" 288 | six = "*" 289 | 290 | [[package]] 291 | name = "pathspec" 292 | version = "0.8.0" 293 | description = "Utility library for gitignore style pattern matching of file paths." 294 | category = "dev" 295 | optional = false 296 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 297 | 298 | [[package]] 299 | name = "pillow" 300 | version = "8.0.1" 301 | description = "Python Imaging Library (Fork)" 302 | category = "main" 303 | optional = false 304 | python-versions = ">=3.6" 305 | 306 | [[package]] 307 | name = "pluggy" 308 | version = "0.13.1" 309 | description = "plugin and hook calling mechanisms for python" 310 | category = "dev" 311 | optional = false 312 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 313 | 314 | [package.dependencies] 315 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 316 | 317 | [package.extras] 318 | dev = ["pre-commit", "tox"] 319 | 320 | [[package]] 321 | name = "pre-commit" 322 | version = "2.8.2" 323 | description = "A framework for managing and maintaining multi-language pre-commit hooks." 324 | category = "dev" 325 | optional = false 326 | python-versions = ">=3.6.1" 327 | 328 | [package.dependencies] 329 | cfgv = ">=2.0.0" 330 | identify = ">=1.0.0" 331 | importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} 332 | importlib-resources = {version = "*", markers = "python_version < \"3.7\""} 333 | nodeenv = ">=0.11.1" 334 | pyyaml = ">=5.1" 335 | toml = "*" 336 | virtualenv = ">=20.0.8" 337 | 338 | [[package]] 339 | name = "py" 340 | version = "1.9.0" 341 | description = "library with cross-python path, ini-parsing, io, code, log facilities" 342 | category = "dev" 343 | optional = false 344 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 345 | 346 | [[package]] 347 | name = "pycodestyle" 348 | version = "2.6.0" 349 | description = "Python style guide checker" 350 | category = "dev" 351 | optional = false 352 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 353 | 354 | [[package]] 355 | name = "pyflakes" 356 | version = "2.2.0" 357 | description = "passive checker of Python programs" 358 | category = "dev" 359 | optional = false 360 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 361 | 362 | [[package]] 363 | name = "pyparsing" 364 | version = "2.4.7" 365 | description = "Python parsing module" 366 | category = "dev" 367 | optional = false 368 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" 369 | 370 | [[package]] 371 | name = "pytest" 372 | version = "6.1.2" 373 | description = "pytest: simple powerful testing with Python" 374 | category = "dev" 375 | optional = false 376 | python-versions = ">=3.5" 377 | 378 | [package.dependencies] 379 | atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} 380 | attrs = ">=17.4.0" 381 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 382 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 383 | iniconfig = "*" 384 | packaging = "*" 385 | pluggy = ">=0.12,<1.0" 386 | py = ">=1.8.2" 387 | toml = "*" 388 | 389 | [package.extras] 390 | checkqa_mypy = ["mypy (0.780)"] 391 | testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] 392 | 393 | [[package]] 394 | name = "pytest-cov" 395 | version = "2.10.1" 396 | description = "Pytest plugin for measuring coverage." 397 | category = "dev" 398 | optional = false 399 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 400 | 401 | [package.dependencies] 402 | coverage = ">=4.4" 403 | pytest = ">=4.6" 404 | 405 | [package.extras] 406 | testing = ["fields", "hunter", "process-tests (2.0.2)", "six", "pytest-xdist", "virtualenv"] 407 | 408 | [[package]] 409 | name = "pytest-django" 410 | version = "3.10.0" 411 | description = "A Django plugin for pytest." 412 | category = "dev" 413 | optional = false 414 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 415 | 416 | [package.dependencies] 417 | pytest = ">=3.6" 418 | 419 | [package.extras] 420 | docs = ["sphinx", "sphinx-rtd-theme"] 421 | testing = ["django", "django-configurations (>=2.0)", "six"] 422 | 423 | [[package]] 424 | name = "pytest-mock" 425 | version = "3.3.1" 426 | description = "Thin-wrapper around the mock package for easier use with pytest" 427 | category = "dev" 428 | optional = false 429 | python-versions = ">=3.5" 430 | 431 | [package.dependencies] 432 | pytest = ">=5.0" 433 | 434 | [package.extras] 435 | dev = ["pre-commit", "tox", "pytest-asyncio"] 436 | 437 | [[package]] 438 | name = "pytz" 439 | version = "2020.1" 440 | description = "World timezone definitions, modern and historical" 441 | category = "main" 442 | optional = false 443 | python-versions = "*" 444 | 445 | [[package]] 446 | name = "pyyaml" 447 | version = "5.3.1" 448 | description = "YAML parser and emitter for Python" 449 | category = "dev" 450 | optional = false 451 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 452 | 453 | [[package]] 454 | name = "regex" 455 | version = "2020.10.28" 456 | description = "Alternative regular expression module, to replace re." 457 | category = "dev" 458 | optional = false 459 | python-versions = "*" 460 | 461 | [[package]] 462 | name = "six" 463 | version = "1.15.0" 464 | description = "Python 2 and 3 compatibility utilities" 465 | category = "main" 466 | optional = false 467 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" 468 | 469 | [[package]] 470 | name = "sqlparse" 471 | version = "0.4.1" 472 | description = "A non-validating SQL parser." 473 | category = "main" 474 | optional = false 475 | python-versions = ">=3.5" 476 | 477 | [[package]] 478 | name = "toml" 479 | version = "0.10.1" 480 | description = "Python Library for Tom's Obvious, Minimal Language" 481 | category = "dev" 482 | optional = false 483 | python-versions = "*" 484 | 485 | [[package]] 486 | name = "tox" 487 | version = "3.20.1" 488 | description = "tox is a generic virtualenv management and test command line tool" 489 | category = "dev" 490 | optional = false 491 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" 492 | 493 | [package.dependencies] 494 | colorama = {version = ">=0.4.1", markers = "platform_system == \"Windows\""} 495 | filelock = ">=3.0.0" 496 | importlib-metadata = {version = ">=0.12,<3", markers = "python_version < \"3.8\""} 497 | packaging = ">=14" 498 | pluggy = ">=0.12.0" 499 | py = ">=1.4.17" 500 | six = ">=1.14.0" 501 | toml = ">=0.9.4" 502 | virtualenv = ">=16.0.0,<20.0.0 || >20.0.0,<20.0.1 || >20.0.1,<20.0.2 || >20.0.2,<20.0.3 || >20.0.3,<20.0.4 || >20.0.4,<20.0.5 || >20.0.5,<20.0.6 || >20.0.6,<20.0.7 || >20.0.7" 503 | 504 | [package.extras] 505 | docs = ["pygments-github-lexers (>=0.0.5)", "sphinx (>=2.0.0)", "sphinxcontrib-autoprogram (>=0.1.5)", "towncrier (>=18.5.0)"] 506 | testing = ["flaky (>=3.4.0)", "freezegun (>=0.3.11)", "pathlib2 (>=2.3.3)", "psutil (>=5.6.1)", "pytest (>=4.0.0)", "pytest-cov (>=2.5.1)", "pytest-mock (>=1.10.0)", "pytest-randomly (>=1.0.0)", "pytest-xdist (>=1.22.2)"] 507 | 508 | [[package]] 509 | name = "tox-gh-actions" 510 | version = "1.3.0" 511 | description = "Seamless integration of tox into GitHub Actions" 512 | category = "dev" 513 | optional = false 514 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" 515 | 516 | [package.dependencies] 517 | tox = ">=3.12" 518 | 519 | [package.extras] 520 | testing = ["flake8 (>=3,<4)", "pytest (>=4.0.0,<6)", "pytest-mock (>=2,<3)", "pytest-randomly (>=3)"] 521 | 522 | [[package]] 523 | name = "typed-ast" 524 | version = "1.4.1" 525 | description = "a fork of Python 2 and 3 ast modules with type comment support" 526 | category = "dev" 527 | optional = false 528 | python-versions = "*" 529 | 530 | [[package]] 531 | name = "typing-extensions" 532 | version = "3.7.4.3" 533 | description = "Backported and Experimental Type Hints for Python 3.5+" 534 | category = "dev" 535 | optional = false 536 | python-versions = "*" 537 | 538 | [[package]] 539 | name = "virtualenv" 540 | version = "20.1.0" 541 | description = "Virtual Python Environment builder" 542 | category = "dev" 543 | optional = false 544 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" 545 | 546 | [package.dependencies] 547 | appdirs = ">=1.4.3,<2" 548 | distlib = ">=0.3.1,<1" 549 | filelock = ">=3.0.0,<4" 550 | importlib-metadata = {version = ">=0.12,<3", markers = "python_version < \"3.8\""} 551 | importlib-resources = {version = ">=1.0", markers = "python_version < \"3.7\""} 552 | six = ">=1.9.0,<2" 553 | 554 | [package.extras] 555 | docs = ["proselint (>=0.10.2)", "sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=19.9.0rc1)"] 556 | testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "pytest-xdist (>=1.31.0)", "packaging (>=20.0)", "xonsh (>=0.9.16)"] 557 | 558 | [[package]] 559 | name = "zipp" 560 | version = "3.4.0" 561 | description = "Backport of pathlib-compatible object wrapper for zip files" 562 | category = "dev" 563 | optional = false 564 | python-versions = ">=3.6" 565 | 566 | [package.extras] 567 | docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] 568 | testing = ["pytest (>=3.5,<3.7.3 || >3.7.3)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pytest-cov", "jaraco.test (>=3.2.0)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"] 569 | 570 | [metadata] 571 | lock-version = "1.1" 572 | python-versions = ">=3.6.1, <4.0" 573 | content-hash = "d8b3527ac4e680d483435191615c4baa70d88318054fa4db08c26d7e8227c6e1" 574 | 575 | [metadata.files] 576 | appdirs = [ 577 | {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"}, 578 | {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"}, 579 | ] 580 | asgiref = [ 581 | {file = "asgiref-3.2.10-py3-none-any.whl", hash = "sha256:9fc6fb5d39b8af147ba40765234fa822b39818b12cc80b35ad9b0cef3a476aed"}, 582 | {file = "asgiref-3.2.10.tar.gz", hash = "sha256:7e51911ee147dd685c3c8b805c0ad0cb58d360987b56953878f8c06d2d1c6f1a"}, 583 | ] 584 | atomicwrites = [ 585 | {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, 586 | {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, 587 | ] 588 | attrs = [ 589 | {file = "attrs-20.2.0-py2.py3-none-any.whl", hash = "sha256:fce7fc47dfc976152e82d53ff92fa0407700c21acd20886a13777a0d20e655dc"}, 590 | {file = "attrs-20.2.0.tar.gz", hash = "sha256:26b54ddbbb9ee1d34d5d3668dd37d6cf74990ab23c828c2888dccdceee395594"}, 591 | ] 592 | black = [ 593 | {file = "black-20.8b1-py3-none-any.whl", hash = "sha256:70b62ef1527c950db59062cda342ea224d772abdf6adc58b86a45421bab20a6b"}, 594 | {file = "black-20.8b1.tar.gz", hash = "sha256:1c02557aa099101b9d21496f8a914e9ed2222ef70336404eeeac8edba836fbea"}, 595 | ] 596 | cfgv = [ 597 | {file = "cfgv-3.2.0-py2.py3-none-any.whl", hash = "sha256:32e43d604bbe7896fe7c248a9c2276447dbef840feb28fe20494f62af110211d"}, 598 | {file = "cfgv-3.2.0.tar.gz", hash = "sha256:cf22deb93d4bcf92f345a5c3cd39d3d41d6340adc60c78bbbd6588c384fda6a1"}, 599 | ] 600 | choicesenum = [ 601 | {file = "choicesenum-0.7.0-py2.py3-none-any.whl", hash = "sha256:8b8c1f8a374f537441303992009907234c36a587d3a93d248c878c2f104a2b7d"}, 602 | {file = "choicesenum-0.7.0.tar.gz", hash = "sha256:37d53174a66405ff178ac44396be9f3a71fe8f5b43d3a5a6ebfaa9593543d36a"}, 603 | ] 604 | click = [ 605 | {file = "click-7.1.2-py2.py3-none-any.whl", hash = "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"}, 606 | {file = "click-7.1.2.tar.gz", hash = "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a"}, 607 | ] 608 | colorama = [ 609 | {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, 610 | {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, 611 | ] 612 | coverage = [ 613 | {file = "coverage-5.3-cp27-cp27m-macosx_10_13_intel.whl", hash = "sha256:bd3166bb3b111e76a4f8e2980fa1addf2920a4ca9b2b8ca36a3bc3dedc618270"}, 614 | {file = "coverage-5.3-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:9342dd70a1e151684727c9c91ea003b2fb33523bf19385d4554f7897ca0141d4"}, 615 | {file = "coverage-5.3-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:63808c30b41f3bbf65e29f7280bf793c79f54fb807057de7e5238ffc7cc4d7b9"}, 616 | {file = "coverage-5.3-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:4d6a42744139a7fa5b46a264874a781e8694bb32f1d76d8137b68138686f1729"}, 617 | {file = "coverage-5.3-cp27-cp27m-win32.whl", hash = "sha256:86e9f8cd4b0cdd57b4ae71a9c186717daa4c5a99f3238a8723f416256e0b064d"}, 618 | {file = "coverage-5.3-cp27-cp27m-win_amd64.whl", hash = "sha256:7858847f2d84bf6e64c7f66498e851c54de8ea06a6f96a32a1d192d846734418"}, 619 | {file = "coverage-5.3-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:530cc8aaf11cc2ac7430f3614b04645662ef20c348dce4167c22d99bec3480e9"}, 620 | {file = "coverage-5.3-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:381ead10b9b9af5f64646cd27107fb27b614ee7040bb1226f9c07ba96625cbb5"}, 621 | {file = "coverage-5.3-cp35-cp35m-macosx_10_13_x86_64.whl", hash = "sha256:71b69bd716698fa62cd97137d6f2fdf49f534decb23a2c6fc80813e8b7be6822"}, 622 | {file = "coverage-5.3-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:1d44bb3a652fed01f1f2c10d5477956116e9b391320c94d36c6bf13b088a1097"}, 623 | {file = "coverage-5.3-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:1c6703094c81fa55b816f5ae542c6ffc625fec769f22b053adb42ad712d086c9"}, 624 | {file = "coverage-5.3-cp35-cp35m-win32.whl", hash = "sha256:cedb2f9e1f990918ea061f28a0f0077a07702e3819602d3507e2ff98c8d20636"}, 625 | {file = "coverage-5.3-cp35-cp35m-win_amd64.whl", hash = "sha256:7f43286f13d91a34fadf61ae252a51a130223c52bfefb50310d5b2deb062cf0f"}, 626 | {file = "coverage-5.3-cp36-cp36m-macosx_10_13_x86_64.whl", hash = "sha256:c851b35fc078389bc16b915a0a7c1d5923e12e2c5aeec58c52f4aa8085ac8237"}, 627 | {file = "coverage-5.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:aac1ba0a253e17889550ddb1b60a2063f7474155465577caa2a3b131224cfd54"}, 628 | {file = "coverage-5.3-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:2b31f46bf7b31e6aa690d4c7a3d51bb262438c6dcb0d528adde446531d0d3bb7"}, 629 | {file = "coverage-5.3-cp36-cp36m-win32.whl", hash = "sha256:c5f17ad25d2c1286436761b462e22b5020d83316f8e8fcb5deb2b3151f8f1d3a"}, 630 | {file = "coverage-5.3-cp36-cp36m-win_amd64.whl", hash = "sha256:aef72eae10b5e3116bac6957de1df4d75909fc76d1499a53fb6387434b6bcd8d"}, 631 | {file = "coverage-5.3-cp37-cp37m-macosx_10_13_x86_64.whl", hash = "sha256:e8caf961e1b1a945db76f1b5fa9c91498d15f545ac0ababbe575cfab185d3bd8"}, 632 | {file = "coverage-5.3-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:29a6272fec10623fcbe158fdf9abc7a5fa032048ac1d8631f14b50fbfc10d17f"}, 633 | {file = "coverage-5.3-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:2d43af2be93ffbad25dd959899b5b809618a496926146ce98ee0b23683f8c51c"}, 634 | {file = "coverage-5.3-cp37-cp37m-win32.whl", hash = "sha256:c3888a051226e676e383de03bf49eb633cd39fc829516e5334e69b8d81aae751"}, 635 | {file = "coverage-5.3-cp37-cp37m-win_amd64.whl", hash = "sha256:9669179786254a2e7e57f0ecf224e978471491d660aaca833f845b72a2df3709"}, 636 | {file = "coverage-5.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0203acd33d2298e19b57451ebb0bed0ab0c602e5cf5a818591b4918b1f97d516"}, 637 | {file = "coverage-5.3-cp38-cp38-manylinux1_i686.whl", hash = "sha256:582ddfbe712025448206a5bc45855d16c2e491c2dd102ee9a2841418ac1c629f"}, 638 | {file = "coverage-5.3-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:0f313707cdecd5cd3e217fc68c78a960b616604b559e9ea60cc16795c4304259"}, 639 | {file = "coverage-5.3-cp38-cp38-win32.whl", hash = "sha256:78e93cc3571fd928a39c0b26767c986188a4118edc67bc0695bc7a284da22e82"}, 640 | {file = "coverage-5.3-cp38-cp38-win_amd64.whl", hash = "sha256:8f264ba2701b8c9f815b272ad568d555ef98dfe1576802ab3149c3629a9f2221"}, 641 | {file = "coverage-5.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:50691e744714856f03a86df3e2bff847c2acede4c191f9a1da38f088df342978"}, 642 | {file = "coverage-5.3-cp39-cp39-manylinux1_i686.whl", hash = "sha256:9361de40701666b034c59ad9e317bae95c973b9ff92513dd0eced11c6adf2e21"}, 643 | {file = "coverage-5.3-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:c1b78fb9700fc961f53386ad2fd86d87091e06ede5d118b8a50dea285a071c24"}, 644 | {file = "coverage-5.3-cp39-cp39-win32.whl", hash = "sha256:cb7df71de0af56000115eafd000b867d1261f786b5eebd88a0ca6360cccfaca7"}, 645 | {file = "coverage-5.3-cp39-cp39-win_amd64.whl", hash = "sha256:47a11bdbd8ada9b7ee628596f9d97fbd3851bd9999d398e9436bd67376dbece7"}, 646 | {file = "coverage-5.3.tar.gz", hash = "sha256:280baa8ec489c4f542f8940f9c4c2181f0306a8ee1a54eceba071a449fb870a0"}, 647 | ] 648 | dataclasses = [ 649 | {file = "dataclasses-0.7-py3-none-any.whl", hash = "sha256:3459118f7ede7c8bea0fe795bff7c6c2ce287d01dd226202f7c9ebc0610a7836"}, 650 | {file = "dataclasses-0.7.tar.gz", hash = "sha256:494a6dcae3b8bcf80848eea2ef64c0cc5cd307ffc263e17cdf42f3e5420808e6"}, 651 | ] 652 | distlib = [ 653 | {file = "distlib-0.3.1-py2.py3-none-any.whl", hash = "sha256:8c09de2c67b3e7deef7184574fc060ab8a793e7adbb183d942c389c8b13c52fb"}, 654 | {file = "distlib-0.3.1.zip", hash = "sha256:edf6116872c863e1aa9d5bb7cb5e05a022c519a4594dc703843343a9ddd9bff1"}, 655 | ] 656 | django = [ 657 | {file = "Django-3.1.2-py3-none-any.whl", hash = "sha256:c93c28ccf1d094cbd00d860e83128a39e45d2c571d3b54361713aaaf9a94cac4"}, 658 | {file = "Django-3.1.2.tar.gz", hash = "sha256:a2127ad0150ec6966655bedf15dbbff9697cc86d61653db2da1afa506c0b04cc"}, 659 | ] 660 | filelock = [ 661 | {file = "filelock-3.0.12-py3-none-any.whl", hash = "sha256:929b7d63ec5b7d6b71b0fa5ac14e030b3f70b75747cef1b10da9b879fef15836"}, 662 | {file = "filelock-3.0.12.tar.gz", hash = "sha256:18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59"}, 663 | ] 664 | flake8 = [ 665 | {file = "flake8-3.8.4-py2.py3-none-any.whl", hash = "sha256:749dbbd6bfd0cf1318af27bf97a14e28e5ff548ef8e5b1566ccfb25a11e7c839"}, 666 | {file = "flake8-3.8.4.tar.gz", hash = "sha256:aadae8761ec651813c24be05c6f7b4680857ef6afaae4651a4eccaef97ce6c3b"}, 667 | ] 668 | identify = [ 669 | {file = "identify-1.5.6-py2.py3-none-any.whl", hash = "sha256:3139bf72d81dfd785b0a464e2776bd59bdc725b4cc10e6cf46b56a0db931c82e"}, 670 | {file = "identify-1.5.6.tar.gz", hash = "sha256:969d844b7a85d32a5f9ac4e163df6e846d73c87c8b75847494ee8f4bd2186421"}, 671 | ] 672 | importlib-metadata = [ 673 | {file = "importlib_metadata-2.0.0-py2.py3-none-any.whl", hash = "sha256:cefa1a2f919b866c5beb7c9f7b0ebb4061f30a8a9bf16d609b000e2dfaceb9c3"}, 674 | {file = "importlib_metadata-2.0.0.tar.gz", hash = "sha256:77a540690e24b0305878c37ffd421785a6f7e53c8b5720d211b211de8d0e95da"}, 675 | ] 676 | importlib-resources = [ 677 | {file = "importlib_resources-3.3.0-py2.py3-none-any.whl", hash = "sha256:a3d34a8464ce1d5d7c92b0ea4e921e696d86f2aa212e684451cb1482c8d84ed5"}, 678 | {file = "importlib_resources-3.3.0.tar.gz", hash = "sha256:7b51f0106c8ec564b1bef3d9c588bc694ce2b92125bbb6278f4f2f5b54ec3592"}, 679 | ] 680 | iniconfig = [ 681 | {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, 682 | {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, 683 | ] 684 | isort = [ 685 | {file = "isort-5.6.4-py3-none-any.whl", hash = "sha256:dcab1d98b469a12a1a624ead220584391648790275560e1a43e54c5dceae65e7"}, 686 | {file = "isort-5.6.4.tar.gz", hash = "sha256:dcaeec1b5f0eca77faea2a35ab790b4f3680ff75590bfcb7145986905aab2f58"}, 687 | ] 688 | jsonfield = [ 689 | {file = "jsonfield-3.1.0-py3-none-any.whl", hash = "sha256:df857811587f252b97bafba42e02805e70a398a7a47870bc6358a0308dd689ed"}, 690 | {file = "jsonfield-3.1.0.tar.gz", hash = "sha256:7e4e84597de21eeaeeaaa7cc5da08c61c48a9b64d0c446b2d71255d01812887a"}, 691 | ] 692 | mccabe = [ 693 | {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, 694 | {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, 695 | ] 696 | mypy = [ 697 | {file = "mypy-0.782-cp35-cp35m-macosx_10_6_x86_64.whl", hash = "sha256:2c6cde8aa3426c1682d35190b59b71f661237d74b053822ea3d748e2c9578a7c"}, 698 | {file = "mypy-0.782-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:9c7a9a7ceb2871ba4bac1cf7217a7dd9ccd44c27c2950edbc6dc08530f32ad4e"}, 699 | {file = "mypy-0.782-cp35-cp35m-win_amd64.whl", hash = "sha256:c05b9e4fb1d8a41d41dec8786c94f3b95d3c5f528298d769eb8e73d293abc48d"}, 700 | {file = "mypy-0.782-cp36-cp36m-macosx_10_6_x86_64.whl", hash = "sha256:6731603dfe0ce4352c555c6284c6db0dc935b685e9ce2e4cf220abe1e14386fd"}, 701 | {file = "mypy-0.782-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:f05644db6779387ccdb468cc47a44b4356fc2ffa9287135d05b70a98dc83b89a"}, 702 | {file = "mypy-0.782-cp36-cp36m-win_amd64.whl", hash = "sha256:b7fbfabdbcc78c4f6fc4712544b9b0d6bf171069c6e0e3cb82440dd10ced3406"}, 703 | {file = "mypy-0.782-cp37-cp37m-macosx_10_6_x86_64.whl", hash = "sha256:3fdda71c067d3ddfb21da4b80e2686b71e9e5c72cca65fa216d207a358827f86"}, 704 | {file = "mypy-0.782-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:d7df6eddb6054d21ca4d3c6249cae5578cb4602951fd2b6ee2f5510ffb098707"}, 705 | {file = "mypy-0.782-cp37-cp37m-win_amd64.whl", hash = "sha256:a4a2cbcfc4cbf45cd126f531dedda8485671545b43107ded25ce952aac6fb308"}, 706 | {file = "mypy-0.782-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6bb93479caa6619d21d6e7160c552c1193f6952f0668cdda2f851156e85186fc"}, 707 | {file = "mypy-0.782-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:81c7908b94239c4010e16642c9102bfc958ab14e36048fa77d0be3289dda76ea"}, 708 | {file = "mypy-0.782-cp38-cp38-win_amd64.whl", hash = "sha256:5dd13ff1f2a97f94540fd37a49e5d255950ebcdf446fb597463a40d0df3fac8b"}, 709 | {file = "mypy-0.782-py3-none-any.whl", hash = "sha256:e0b61738ab504e656d1fe4ff0c0601387a5489ca122d55390ade31f9ca0e252d"}, 710 | {file = "mypy-0.782.tar.gz", hash = "sha256:eff7d4a85e9eea55afa34888dfeaccde99e7520b51f867ac28a48492c0b1130c"}, 711 | ] 712 | mypy-extensions = [ 713 | {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, 714 | {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, 715 | ] 716 | nodeenv = [ 717 | {file = "nodeenv-1.5.0-py2.py3-none-any.whl", hash = "sha256:5304d424c529c997bc888453aeaa6362d242b6b4631e90f3d4bf1b290f1c84a9"}, 718 | {file = "nodeenv-1.5.0.tar.gz", hash = "sha256:ab45090ae383b716c4ef89e690c41ff8c2b257b85b309f01f3654df3d084bd7c"}, 719 | ] 720 | packaging = [ 721 | {file = "packaging-20.4-py2.py3-none-any.whl", hash = "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181"}, 722 | {file = "packaging-20.4.tar.gz", hash = "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8"}, 723 | ] 724 | pathspec = [ 725 | {file = "pathspec-0.8.0-py2.py3-none-any.whl", hash = "sha256:7d91249d21749788d07a2d0f94147accd8f845507400749ea19c1ec9054a12b0"}, 726 | {file = "pathspec-0.8.0.tar.gz", hash = "sha256:da45173eb3a6f2a5a487efba21f050af2b41948be6ab52b6a1e3ff22bb8b7061"}, 727 | ] 728 | pillow = [ 729 | {file = "Pillow-8.0.1-cp36-cp36m-macosx_10_10_x86_64.whl", hash = "sha256:b63d4ff734263ae4ce6593798bcfee6dbfb00523c82753a3a03cbc05555a9cc3"}, 730 | {file = "Pillow-8.0.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:5f9403af9c790cc18411ea398a6950ee2def2a830ad0cfe6dc9122e6d528b302"}, 731 | {file = "Pillow-8.0.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:6b4a8fd632b4ebee28282a9fef4c341835a1aa8671e2770b6f89adc8e8c2703c"}, 732 | {file = "Pillow-8.0.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:cc3ea6b23954da84dbee8025c616040d9aa5eaf34ea6895a0a762ee9d3e12e11"}, 733 | {file = "Pillow-8.0.1-cp36-cp36m-win32.whl", hash = "sha256:d8a96747df78cda35980905bf26e72960cba6d355ace4780d4bdde3b217cdf1e"}, 734 | {file = "Pillow-8.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:7ba0ba61252ab23052e642abdb17fd08fdcfdbbf3b74c969a30c58ac1ade7cd3"}, 735 | {file = "Pillow-8.0.1-cp37-cp37m-macosx_10_10_x86_64.whl", hash = "sha256:795e91a60f291e75de2e20e6bdd67770f793c8605b553cb6e4387ce0cb302e09"}, 736 | {file = "Pillow-8.0.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:0a2e8d03787ec7ad71dc18aec9367c946ef8ef50e1e78c71f743bc3a770f9fae"}, 737 | {file = "Pillow-8.0.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:006de60d7580d81f4a1a7e9f0173dc90a932e3905cc4d47ea909bc946302311a"}, 738 | {file = "Pillow-8.0.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:bd7bf289e05470b1bc74889d1466d9ad4a56d201f24397557b6f65c24a6844b8"}, 739 | {file = "Pillow-8.0.1-cp37-cp37m-win32.whl", hash = "sha256:95edb1ed513e68bddc2aee3de66ceaf743590bf16c023fb9977adc4be15bd3f0"}, 740 | {file = "Pillow-8.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:e38d58d9138ef972fceb7aeec4be02e3f01d383723965bfcef14d174c8ccd039"}, 741 | {file = "Pillow-8.0.1-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:d3d07c86d4efa1facdf32aa878bd508c0dc4f87c48125cc16b937baa4e5b5e11"}, 742 | {file = "Pillow-8.0.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:fbd922f702582cb0d71ef94442bfca57624352622d75e3be7a1e7e9360b07e72"}, 743 | {file = "Pillow-8.0.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:92c882b70a40c79de9f5294dc99390671e07fc0b0113d472cbea3fde15db1792"}, 744 | {file = "Pillow-8.0.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:7c9401e68730d6c4245b8e361d3d13e1035cbc94db86b49dc7da8bec235d0015"}, 745 | {file = "Pillow-8.0.1-cp38-cp38-win32.whl", hash = "sha256:6c1aca8231625115104a06e4389fcd9ec88f0c9befbabd80dc206c35561be271"}, 746 | {file = "Pillow-8.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:cc9ec588c6ef3a1325fa032ec14d97b7309db493782ea8c304666fb10c3bd9a7"}, 747 | {file = "Pillow-8.0.1-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:eb472586374dc66b31e36e14720747595c2b265ae962987261f044e5cce644b5"}, 748 | {file = "Pillow-8.0.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:0eeeae397e5a79dc088d8297a4c2c6f901f8fb30db47795113a4a605d0f1e5ce"}, 749 | {file = "Pillow-8.0.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:81f812d8f5e8a09b246515fac141e9d10113229bc33ea073fec11403b016bcf3"}, 750 | {file = "Pillow-8.0.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:895d54c0ddc78a478c80f9c438579ac15f3e27bf442c2a9aa74d41d0e4d12544"}, 751 | {file = "Pillow-8.0.1-cp39-cp39-win32.whl", hash = "sha256:2fb113757a369a6cdb189f8df3226e995acfed0a8919a72416626af1a0a71140"}, 752 | {file = "Pillow-8.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:59e903ca800c8cfd1ebe482349ec7c35687b95e98cefae213e271c8c7fffa021"}, 753 | {file = "Pillow-8.0.1-pp36-pypy36_pp73-macosx_10_10_x86_64.whl", hash = "sha256:5abd653a23c35d980b332bc0431d39663b1709d64142e3652890df4c9b6970f6"}, 754 | {file = "Pillow-8.0.1-pp36-pypy36_pp73-manylinux2010_x86_64.whl", hash = "sha256:4b0ef2470c4979e345e4e0cc1bbac65fda11d0d7b789dbac035e4c6ce3f98adb"}, 755 | {file = "Pillow-8.0.1-pp37-pypy37_pp73-win32.whl", hash = "sha256:8de332053707c80963b589b22f8e0229f1be1f3ca862a932c1bcd48dafb18dd8"}, 756 | {file = "Pillow-8.0.1.tar.gz", hash = "sha256:11c5c6e9b02c9dac08af04f093eb5a2f84857df70a7d4a6a6ad461aca803fb9e"}, 757 | ] 758 | pluggy = [ 759 | {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, 760 | {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, 761 | ] 762 | pre-commit = [ 763 | {file = "pre_commit-2.8.2-py2.py3-none-any.whl", hash = "sha256:22e6aa3bd571debb01eb7d34483f11c01b65237be4eebbf30c3d4fb65762d315"}, 764 | {file = "pre_commit-2.8.2.tar.gz", hash = "sha256:905ebc9b534b991baec87e934431f2d0606ba27f2b90f7f652985f5a5b8b6ae6"}, 765 | ] 766 | py = [ 767 | {file = "py-1.9.0-py2.py3-none-any.whl", hash = "sha256:366389d1db726cd2fcfc79732e75410e5fe4d31db13692115529d34069a043c2"}, 768 | {file = "py-1.9.0.tar.gz", hash = "sha256:9ca6883ce56b4e8da7e79ac18787889fa5206c79dcc67fb065376cd2fe03f342"}, 769 | ] 770 | pycodestyle = [ 771 | {file = "pycodestyle-2.6.0-py2.py3-none-any.whl", hash = "sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367"}, 772 | {file = "pycodestyle-2.6.0.tar.gz", hash = "sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e"}, 773 | ] 774 | pyflakes = [ 775 | {file = "pyflakes-2.2.0-py2.py3-none-any.whl", hash = "sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92"}, 776 | {file = "pyflakes-2.2.0.tar.gz", hash = "sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8"}, 777 | ] 778 | pyparsing = [ 779 | {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, 780 | {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"}, 781 | ] 782 | pytest = [ 783 | {file = "pytest-6.1.2-py3-none-any.whl", hash = "sha256:4288fed0d9153d9646bfcdf0c0428197dba1ecb27a33bb6e031d002fa88653fe"}, 784 | {file = "pytest-6.1.2.tar.gz", hash = "sha256:c0a7e94a8cdbc5422a51ccdad8e6f1024795939cc89159a0ae7f0b316ad3823e"}, 785 | ] 786 | pytest-cov = [ 787 | {file = "pytest-cov-2.10.1.tar.gz", hash = "sha256:47bd0ce14056fdd79f93e1713f88fad7bdcc583dcd7783da86ef2f085a0bb88e"}, 788 | {file = "pytest_cov-2.10.1-py2.py3-none-any.whl", hash = "sha256:45ec2d5182f89a81fc3eb29e3d1ed3113b9e9a873bcddb2a71faaab066110191"}, 789 | ] 790 | pytest-django = [ 791 | {file = "pytest-django-3.10.0.tar.gz", hash = "sha256:4de6dbd077ed8606616958f77655fed0d5e3ee45159475671c7fa67596c6dba6"}, 792 | {file = "pytest_django-3.10.0-py2.py3-none-any.whl", hash = "sha256:c33e3d3da14d8409b125d825d4e74da17bb252191bf6fc3da6856e27a8b73ea4"}, 793 | ] 794 | pytest-mock = [ 795 | {file = "pytest-mock-3.3.1.tar.gz", hash = "sha256:a4d6d37329e4a893e77d9ffa89e838dd2b45d5dc099984cf03c703ac8411bb82"}, 796 | {file = "pytest_mock-3.3.1-py3-none-any.whl", hash = "sha256:024e405ad382646318c4281948aadf6fe1135632bea9cc67366ea0c4098ef5f2"}, 797 | ] 798 | pytz = [ 799 | {file = "pytz-2020.1-py2.py3-none-any.whl", hash = "sha256:a494d53b6d39c3c6e44c3bec237336e14305e4f29bbf800b599253057fbb79ed"}, 800 | {file = "pytz-2020.1.tar.gz", hash = "sha256:c35965d010ce31b23eeb663ed3cc8c906275d6be1a34393a1d73a41febf4a048"}, 801 | ] 802 | pyyaml = [ 803 | {file = "PyYAML-5.3.1-cp27-cp27m-win32.whl", hash = "sha256:74809a57b329d6cc0fdccee6318f44b9b8649961fa73144a98735b0aaf029f1f"}, 804 | {file = "PyYAML-5.3.1-cp27-cp27m-win_amd64.whl", hash = "sha256:240097ff019d7c70a4922b6869d8a86407758333f02203e0fc6ff79c5dcede76"}, 805 | {file = "PyYAML-5.3.1-cp35-cp35m-win32.whl", hash = "sha256:4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2"}, 806 | {file = "PyYAML-5.3.1-cp35-cp35m-win_amd64.whl", hash = "sha256:cc8955cfbfc7a115fa81d85284ee61147059a753344bc51098f3ccd69b0d7e0c"}, 807 | {file = "PyYAML-5.3.1-cp36-cp36m-win32.whl", hash = "sha256:7739fc0fa8205b3ee8808aea45e968bc90082c10aef6ea95e855e10abf4a37b2"}, 808 | {file = "PyYAML-5.3.1-cp36-cp36m-win_amd64.whl", hash = "sha256:69f00dca373f240f842b2931fb2c7e14ddbacd1397d57157a9b005a6a9942648"}, 809 | {file = "PyYAML-5.3.1-cp37-cp37m-win32.whl", hash = "sha256:d13155f591e6fcc1ec3b30685d50bf0711574e2c0dfffd7644babf8b5102ca1a"}, 810 | {file = "PyYAML-5.3.1-cp37-cp37m-win_amd64.whl", hash = "sha256:73f099454b799e05e5ab51423c7bcf361c58d3206fa7b0d555426b1f4d9a3eaf"}, 811 | {file = "PyYAML-5.3.1-cp38-cp38-win32.whl", hash = "sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97"}, 812 | {file = "PyYAML-5.3.1-cp38-cp38-win_amd64.whl", hash = "sha256:95f71d2af0ff4227885f7a6605c37fd53d3a106fcab511b8860ecca9fcf400ee"}, 813 | {file = "PyYAML-5.3.1.tar.gz", hash = "sha256:b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d"}, 814 | ] 815 | regex = [ 816 | {file = "regex-2020.10.28-cp27-cp27m-win32.whl", hash = "sha256:4b5a9bcb56cc146c3932c648603b24514447eafa6ce9295234767bf92f69b504"}, 817 | {file = "regex-2020.10.28-cp27-cp27m-win_amd64.whl", hash = "sha256:c13d311a4c4a8d671f5860317eb5f09591fbe8259676b86a85769423b544451e"}, 818 | {file = "regex-2020.10.28-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:c8a2b7ccff330ae4c460aff36626f911f918555660cc28163417cb84ffb25789"}, 819 | {file = "regex-2020.10.28-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:4afa350f162551cf402bfa3cd8302165c8e03e689c897d185f16a167328cc6dd"}, 820 | {file = "regex-2020.10.28-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:b88fa3b8a3469f22b4f13d045d9bd3eda797aa4e406fde0a2644bc92bbdd4bdd"}, 821 | {file = "regex-2020.10.28-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:f43109822df2d3faac7aad79613f5f02e4eab0fc8ad7932d2e70e2a83bd49c26"}, 822 | {file = "regex-2020.10.28-cp36-cp36m-win32.whl", hash = "sha256:8092a5a06ad9a7a247f2a76ace121183dc4e1a84c259cf9c2ce3bbb69fac3582"}, 823 | {file = "regex-2020.10.28-cp36-cp36m-win_amd64.whl", hash = "sha256:49461446b783945597c4076aea3f49aee4b4ce922bd241e4fcf62a3e7c61794c"}, 824 | {file = "regex-2020.10.28-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:8ca9dca965bd86ea3631b975d63b0693566d3cc347e55786d5514988b6f5b84c"}, 825 | {file = "regex-2020.10.28-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:ea37320877d56a7f0a1e6a625d892cf963aa7f570013499f5b8d5ab8402b5625"}, 826 | {file = "regex-2020.10.28-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:3a5f08039eee9ea195a89e180c5762bfb55258bfb9abb61a20d3abee3b37fd12"}, 827 | {file = "regex-2020.10.28-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:cb905f3d2e290a8b8f1579d3984f2cfa7c3a29cc7cba608540ceeed18513f520"}, 828 | {file = "regex-2020.10.28-cp37-cp37m-win32.whl", hash = "sha256:a62162be05edf64f819925ea88d09d18b09bebf20971b363ce0c24e8b4aa14c0"}, 829 | {file = "regex-2020.10.28-cp37-cp37m-win_amd64.whl", hash = "sha256:03855ee22980c3e4863dc84c42d6d2901133362db5daf4c36b710dd895d78f0a"}, 830 | {file = "regex-2020.10.28-cp38-cp38-manylinux1_i686.whl", hash = "sha256:625116aca6c4b57c56ea3d70369cacc4d62fead4930f8329d242e4fe7a58ce4b"}, 831 | {file = "regex-2020.10.28-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:2dc522e25e57e88b4980d2bdd334825dbf6fa55f28a922fc3bfa60cc09e5ef53"}, 832 | {file = "regex-2020.10.28-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:119e0355dbdd4cf593b17f2fc5dbd4aec2b8899d0057e4957ba92f941f704bf5"}, 833 | {file = "regex-2020.10.28-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:cfcf28ed4ce9ced47b9b9670a4f0d3d3c0e4d4779ad4dadb1ad468b097f808aa"}, 834 | {file = "regex-2020.10.28-cp38-cp38-win32.whl", hash = "sha256:06b52815d4ad38d6524666e0d50fe9173533c9cc145a5779b89733284e6f688f"}, 835 | {file = "regex-2020.10.28-cp38-cp38-win_amd64.whl", hash = "sha256:c3466a84fce42c2016113101018a9981804097bacbab029c2d5b4fcb224b89de"}, 836 | {file = "regex-2020.10.28-cp39-cp39-manylinux1_i686.whl", hash = "sha256:c2c6c56ee97485a127555c9595c069201b5161de9d05495fbe2132b5ac104786"}, 837 | {file = "regex-2020.10.28-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:1ec66700a10e3c75f1f92cbde36cca0d3aaee4c73dfa26699495a3a30b09093c"}, 838 | {file = "regex-2020.10.28-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:11116d424734fe356d8777f89d625f0df783251ada95d6261b4c36ad27a394bb"}, 839 | {file = "regex-2020.10.28-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:f1fce1e4929157b2afeb4bb7069204d4370bab9f4fc03ca1fbec8bd601f8c87d"}, 840 | {file = "regex-2020.10.28-cp39-cp39-win32.whl", hash = "sha256:832339223b9ce56b7b15168e691ae654d345ac1635eeb367ade9ecfe0e66bee0"}, 841 | {file = "regex-2020.10.28-cp39-cp39-win_amd64.whl", hash = "sha256:654c1635f2313d0843028487db2191530bca45af61ca85d0b16555c399625b0e"}, 842 | {file = "regex-2020.10.28.tar.gz", hash = "sha256:dd3e6547ecf842a29cf25123fbf8d2461c53c8d37aa20d87ecee130c89b7079b"}, 843 | ] 844 | six = [ 845 | {file = "six-1.15.0-py2.py3-none-any.whl", hash = "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"}, 846 | {file = "six-1.15.0.tar.gz", hash = "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259"}, 847 | ] 848 | sqlparse = [ 849 | {file = "sqlparse-0.4.1-py3-none-any.whl", hash = "sha256:017cde379adbd6a1f15a61873f43e8274179378e95ef3fede90b5aa64d304ed0"}, 850 | {file = "sqlparse-0.4.1.tar.gz", hash = "sha256:0f91fd2e829c44362cbcfab3e9ae12e22badaa8a29ad5ff599f9ec109f0454e8"}, 851 | ] 852 | toml = [ 853 | {file = "toml-0.10.1-py2.py3-none-any.whl", hash = "sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88"}, 854 | {file = "toml-0.10.1.tar.gz", hash = "sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f"}, 855 | ] 856 | tox = [ 857 | {file = "tox-3.20.1-py2.py3-none-any.whl", hash = "sha256:42ce19ce5dc2f6d6b1fdc5666c476e1f1e2897359b47e0aa3a5b774f335d57c2"}, 858 | {file = "tox-3.20.1.tar.gz", hash = "sha256:4321052bfe28f9d85082341ca8e233e3ea901fdd14dab8a5d3fbd810269fbaf6"}, 859 | ] 860 | tox-gh-actions = [ 861 | {file = "tox-gh-actions-1.3.0.tar.gz", hash = "sha256:85d61e5f6176746497692f1ae17854656dbc1d4badfd97c6e5218f91804de176"}, 862 | {file = "tox_gh_actions-1.3.0-py2.py3-none-any.whl", hash = "sha256:4ffcdaffd271b678ff77f90eee8b59247197f8faab2f5d19b6375f62a7545318"}, 863 | ] 864 | typed-ast = [ 865 | {file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3"}, 866 | {file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb"}, 867 | {file = "typed_ast-1.4.1-cp35-cp35m-win32.whl", hash = "sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919"}, 868 | {file = "typed_ast-1.4.1-cp35-cp35m-win_amd64.whl", hash = "sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01"}, 869 | {file = "typed_ast-1.4.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75"}, 870 | {file = "typed_ast-1.4.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652"}, 871 | {file = "typed_ast-1.4.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7"}, 872 | {file = "typed_ast-1.4.1-cp36-cp36m-win32.whl", hash = "sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1"}, 873 | {file = "typed_ast-1.4.1-cp36-cp36m-win_amd64.whl", hash = "sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa"}, 874 | {file = "typed_ast-1.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614"}, 875 | {file = "typed_ast-1.4.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41"}, 876 | {file = "typed_ast-1.4.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b"}, 877 | {file = "typed_ast-1.4.1-cp37-cp37m-win32.whl", hash = "sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe"}, 878 | {file = "typed_ast-1.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355"}, 879 | {file = "typed_ast-1.4.1-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6"}, 880 | {file = "typed_ast-1.4.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907"}, 881 | {file = "typed_ast-1.4.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d"}, 882 | {file = "typed_ast-1.4.1-cp38-cp38-win32.whl", hash = "sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c"}, 883 | {file = "typed_ast-1.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4"}, 884 | {file = "typed_ast-1.4.1-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34"}, 885 | {file = "typed_ast-1.4.1.tar.gz", hash = "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b"}, 886 | ] 887 | typing-extensions = [ 888 | {file = "typing_extensions-3.7.4.3-py2-none-any.whl", hash = "sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f"}, 889 | {file = "typing_extensions-3.7.4.3-py3-none-any.whl", hash = "sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918"}, 890 | {file = "typing_extensions-3.7.4.3.tar.gz", hash = "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c"}, 891 | ] 892 | virtualenv = [ 893 | {file = "virtualenv-20.1.0-py2.py3-none-any.whl", hash = "sha256:b0011228208944ce71052987437d3843e05690b2f23d1c7da4263fde104c97a2"}, 894 | {file = "virtualenv-20.1.0.tar.gz", hash = "sha256:b8d6110f493af256a40d65e29846c69340a947669eec8ce784fcf3dd3af28380"}, 895 | ] 896 | zipp = [ 897 | {file = "zipp-3.4.0-py3-none-any.whl", hash = "sha256:102c24ef8f171fd729d46599845e95c7ab894a4cf45f5de11a44cc7444fb1108"}, 898 | {file = "zipp-3.4.0.tar.gz", hash = "sha256:ed5eee1974372595f9e416cc7bbeeb12335201d8081ca8a0743c954d4446e5cb"}, 899 | ] 900 | -------------------------------------------------------------------------------- /poetry.toml: -------------------------------------------------------------------------------- 1 | [virtualenvs] 2 | in-project = true 3 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "django-exiffield" 3 | version = "3.0.0" 4 | description = "django-exiffield extracts exif information by utilizing the exiftool." 5 | authors = [ 6 | "Alexander Frenzel ", 7 | ] 8 | 9 | license = "BSD-3-Clause" 10 | readme = "README.md" 11 | 12 | documentation = "https://github.com/escaped/django-exiffield/blob/master/README.md" 13 | homepage = "https://github.com/escaped/django-exiffield" 14 | repository = "https://github.com/escaped/django-exiffield" 15 | 16 | classifiers = [ 17 | "Development Status :: 4 - Beta", 18 | "Environment :: Web Environment", 19 | "Framework :: Django", 20 | "Intended Audience :: Developers", 21 | "Operating System :: OS Independent", 22 | "Programming Language :: Python", 23 | "Programming Language :: Python :: 3", 24 | "Programming Language :: Python :: 3.6", 25 | "Programming Language :: Python :: 3.7", 26 | "Programming Language :: Python :: 3.8", 27 | "Topic :: Software Development :: Libraries :: Python Modules", 28 | "Typing :: Typed", 29 | ] 30 | 31 | include = ["exiffield/py.typed"] 32 | packages = [ 33 | { include = "exiffield" }, 34 | ] 35 | 36 | [tool.poetry.dependencies] 37 | python = ">=3.6.1, <4.0" 38 | choicesenum = ">=0.2.2" 39 | django = ">=2.2" 40 | jsonfield = ">=3.0" 41 | pillow = ">=5.0" 42 | 43 | [tool.poetry.dev-dependencies] 44 | black = "^20.8b1" 45 | flake8 = "^3.8.3" 46 | isort = "^5.5.2" 47 | mypy = "^0.782" 48 | pre-commit = "^2.7.1" 49 | pytest = "^6.0.1" 50 | pytest-cov = "^2.10.1" 51 | pytest-django = "^3.9.0" 52 | pytest-mock = "^3.3.1" 53 | tox = "^3.20.0" 54 | tox-gh-actions = "^1.3.0" 55 | 56 | [tool.black] 57 | line-length = 88 58 | skip-string-normalization = true 59 | target_version = ['py36', 'py37', 'py38'] 60 | include = '\.pyi?$' 61 | exclude = ''' 62 | ( 63 | /( 64 | \.eggs # exclude a few common directories in the 65 | | \.git # root of the project 66 | | \.hg 67 | | \.mypy_cache 68 | | \.tox 69 | | \.venv 70 | | _build 71 | | buck-out 72 | | build 73 | | dist 74 | )/ 75 | ) 76 | ''' 77 | 78 | [build-system] 79 | requires = ["poetry-core>=1.0.0"] 80 | build-backend = "poetry.core.masonry.api" 81 | 82 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [isort] 2 | line_length = 88 3 | known_project = exiffield 4 | 5 | 6 | [flake8] 7 | exclude = 8 | .git 9 | __pycache__ 10 | dist 11 | build 12 | 13 | ignore = 14 | E501 # code is reformatted using black 15 | max-line-length = 88 16 | max-complexity = 9 17 | 18 | 19 | [coverage:run] 20 | branch = True 21 | include = exiffield/* 22 | omit = 23 | */tests/* 24 | 25 | [coverage:report] 26 | show_missing = True 27 | exclude_lines = 28 | pragma: no cover 29 | 30 | # Don't complain about missing debug-only code: 31 | def __unicode__ 32 | def __repr__ 33 | def __str__ 34 | 35 | # Don't complain if tests don't hit defensive assertion code: 36 | raise AssertionError 37 | raise NotImplementedError 38 | 39 | # Don't complain if non-runnable code isn't run: 40 | if __name__ == __main__: 41 | 42 | # No need to check type checking imports 43 | if TYPE_CHECKING: 44 | 45 | 46 | [tool:pytest] 47 | addopts = 48 | --durations=10 49 | --cov=exiffield 50 | --cov-report term 51 | norecursedirs = build dist 52 | testpaths = 53 | exiffield 54 | tests 55 | 56 | 57 | [mypy] 58 | # Specify the target platform details in config, so your developers are 59 | # free to run mypy on Windows, Linux, or macOS and get consistent 60 | # results. 61 | python_version = 3.6 62 | platform = Linux 63 | 64 | ignore_missing_imports = True 65 | -------------------------------------------------------------------------------- /tests/P1240157.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/escaped/django-exiffield/73dd6b0795e701bff5af150362b1bb2f7256a550/tests/P1240157.JPG -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/escaped/django-exiffield/73dd6b0795e701bff5af150362b1bb2f7256a550/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import django 4 | from django.conf import settings 5 | 6 | 7 | def pytest_configure(): 8 | """ 9 | Basic configuration of django for testing a django module. 10 | """ 11 | tests_dir = Path(__file__).parent 12 | settings.configure( 13 | DATABASES={ 14 | 'default': {'ENGINE': 'django.db.backends.sqlite3', 'NAME': ':memory:'}, 15 | }, 16 | ROOT_URLCONF='tests.urls', 17 | INSTALLED_APPS=('tests',), 18 | MEDIA_ROOT=tests_dir / 'media', 19 | ) 20 | 21 | django.setup() 22 | -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from exiffield.fields import ExifField 4 | from exiffield.getters import exifgetter 5 | 6 | 7 | class Image(models.Model): 8 | image = models.ImageField() 9 | camera = models.CharField( 10 | editable=False, 11 | max_length=100, 12 | ) 13 | exif = ExifField( 14 | source='image', 15 | denormalized_fields={'camera': exifgetter('Model')}, 16 | ) 17 | 18 | class Meta: 19 | app_label = 'tests' 20 | -------------------------------------------------------------------------------- /tests/test_checks.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.db import models 3 | 4 | from exiffield.fields import ExifField 5 | 6 | 7 | @pytest.fixture 8 | def mocked_which(mocker): 9 | """ 10 | Fake installed exiftool. 11 | """ 12 | mocked_which = mocker.patch('shutil.which') 13 | mocked_which.return_value = '/usr/bin/exiftool' 14 | return mocked_which 15 | 16 | 17 | @pytest.mark.django_db 18 | def test_exiftool(mocked_which): 19 | """ 20 | Test checks for external tool (exiftool). 21 | """ 22 | 23 | class Image(models.Model): 24 | image = models.ImageField() 25 | exif = ExifField(source='image') 26 | 27 | class Meta: 28 | app_label = 'exiffield-exiftool' 29 | 30 | # exiftool found 31 | mocked_which.return_value = '/usr/bin/exiftool' 32 | 33 | errors = Image.check() 34 | assert len(errors) == 0, errors 35 | 36 | # exiftool not found 37 | mocked_which.return_value = None 38 | 39 | errors = Image.check() 40 | assert len(errors) == 1 41 | assert errors[0].id == 'exiffield.E001', errors 42 | 43 | 44 | @pytest.mark.django_db 45 | @pytest.mark.parametrize( 46 | 'kwargs, error', 47 | [ 48 | ({}, 'exiffield.E002'), # `source` is not defined 49 | ({'source': 'foobar'}, 'exiffield.E003'), # `source` not found on model 50 | ({'source': 'name'}, 'exiffield.E004'), # `source` should be FileField 51 | ], 52 | ) 53 | def test_source(mocked_which, kwargs, error): 54 | """ 55 | Test checks for exif source. 56 | """ 57 | 58 | class Image(models.Model): 59 | name = models.IntegerField() 60 | exif = ExifField(**kwargs) 61 | 62 | class Meta: 63 | # Model gets registered on every call 64 | # hence we need to change the `app_label` to avoid a warning... 65 | app_label = f'exiffield-{error}' 66 | 67 | errors = Image.check() 68 | assert len(errors) == 1 69 | assert errors[0].id == error, errors 70 | 71 | 72 | @pytest.mark.django_db 73 | @pytest.mark.parametrize( 74 | 'denormalized_fields, error', 75 | [ 76 | ([], 'exiffield.E005'), # invalid type 77 | ( 78 | {'model_field': lambda exif: ''}, 79 | 'exiffield.E006', 80 | ), # field not found on model 81 | ({'camera': lambda exif: ''}, 'exiffield.E007'), # field is editable... 82 | ( 83 | {'datetaken': 'DateTimeOriginal'}, 84 | 'exiffield.E008', 85 | ), # value should be a callable 86 | ], 87 | ) 88 | def test_fields(mocked_which, denormalized_fields, error): 89 | """ 90 | Test checks for denormalized fields. 91 | """ 92 | 93 | class Image(models.Model): 94 | image = models.ImageField() 95 | camera = models.CharField(max_length=100) 96 | datetaken = models.DateTimeField(editable=False) 97 | exif = ExifField(source='image', denormalized_fields=denormalized_fields) 98 | 99 | class Meta: 100 | # Model gets registered on every call 101 | # hence we need to change the `app_label` to avoid a warning... 102 | app_label = f'exiffield-{error}' 103 | 104 | errors = Image.check() 105 | assert len(errors) == 1, error 106 | assert errors[0].id == error, errors 107 | 108 | 109 | @pytest.mark.django_db 110 | def test_valid_definition(mocked_which): 111 | """ 112 | Test for valid field definition. 113 | """ 114 | from .models import Image 115 | 116 | errors = Image.check() 117 | assert len(errors) == 0 118 | -------------------------------------------------------------------------------- /tests/test_field.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | 4 | import pytest 5 | from django.conf import settings 6 | from django.core.files.storage import FileSystemStorage 7 | from django.core.files.uploadedfile import SimpleUploadedFile 8 | 9 | from exiffield import fields 10 | 11 | from .models import Image 12 | 13 | DIR = Path(__file__).parent 14 | IMAGE_NAME = 'P1240157.JPG' 15 | 16 | 17 | @pytest.fixture 18 | def uncommitted_img(): 19 | """ 20 | Create an unsaved image instance, which mimics an uploaded file. 21 | 22 | The attached file has not yet been comitted. 23 | """ 24 | image_path = DIR / IMAGE_NAME 25 | media_image_path = Path(settings.MEDIA_ROOT) / IMAGE_NAME 26 | 27 | img = Image() 28 | with open(image_path, mode='rb') as fh: 29 | image_file = SimpleUploadedFile( 30 | media_image_path, 31 | fh.read(), 32 | ) 33 | img.image.file = image_file 34 | img.image.name = str(media_image_path) 35 | str(media_image_path) 36 | img.image._committed = False 37 | 38 | try: 39 | yield img 40 | finally: 41 | try: 42 | os.unlink(img.image.path) 43 | except FileNotFoundError: 44 | pass 45 | 46 | 47 | @pytest.fixture 48 | def committed_img(uncommitted_img): 49 | """ 50 | Create an unsaved image instance. 51 | """ 52 | img = uncommitted_img 53 | file_ = img.image 54 | img.image.save(file_.name, file_.file, save=False) 55 | 56 | try: 57 | yield img 58 | finally: 59 | os.unlink(img.image.path) 60 | 61 | 62 | @pytest.fixture(params=['committed', 'uncommitted']) 63 | def img(request, uncommitted_img): 64 | """ 65 | Return a committed and uncommitted image instance. 66 | """ 67 | img = uncommitted_img 68 | if request.param == 'committed': 69 | file_ = img.image 70 | img.image.save(file_.name, file_.file, save=False) 71 | return img 72 | 73 | 74 | @pytest.fixture 75 | def remotestorage(mocker): 76 | """ 77 | Return a patched FileSystemStorage that does not support path() 78 | """ 79 | storage = FileSystemStorage() 80 | 81 | def remote_open(name, mode): 82 | media_image_path = Path(settings.MEDIA_ROOT) / IMAGE_NAME 83 | return open(media_image_path, mode) 84 | 85 | def remote_path(): 86 | raise NotImplementedError("Remote storage does not implement path()") 87 | 88 | mocker.patch.object(storage, 'path', remote_path) 89 | mocker.patch.object(storage, 'open', remote_open) 90 | yield storage 91 | 92 | 93 | @pytest.fixture 94 | def img_remotestorage(remotestorage, img): 95 | """ 96 | Return a committed and uncommitted image instance using remote storage 97 | """ 98 | temp_storage = img.image.storage 99 | 100 | img.image.storage = remotestorage 101 | 102 | yield img 103 | 104 | img.image.storage = temp_storage 105 | 106 | 107 | @pytest.mark.django_db 108 | def test_unsupported_file(): 109 | image_path = DIR / IMAGE_NAME 110 | media_image_path = Path(settings.MEDIA_ROOT) / IMAGE_NAME 111 | 112 | img = Image() 113 | with open(image_path, mode='rb') as fh: 114 | # corrupt image 115 | fh.seek(2048) 116 | file_ = SimpleUploadedFile(media_image_path, fh.read()) 117 | 118 | try: 119 | img.image.file = file_ 120 | img.image.name = str(media_image_path) 121 | img.image._committed = False 122 | 123 | # do not fail when saving 124 | img.save() 125 | assert img.exif == {} 126 | 127 | # do not fail when saving and file is already saved to storage 128 | img.save() 129 | assert img.exif == {} 130 | 131 | finally: 132 | # cleanup 133 | os.unlink(img.image.path) 134 | 135 | 136 | @pytest.mark.django_db 137 | def test_extract_exif(mocker, img): 138 | img.save() 139 | img.refresh_from_db() # exif should be in the database 140 | 141 | assert len(img.exif) > 0 # at least one EXIF tag 142 | assert 'Aperture' in img.exif 143 | assert img.exif['Aperture'] == { 144 | 'desc': 'Aperture', 145 | 'val': 1.7, 146 | } 147 | assert img.exif['Model'] == { 148 | 'desc': 'Camera Model Name', 149 | 'val': 'DMC-GX7', 150 | } 151 | 152 | 153 | @pytest.mark.django_db 154 | def test_exif_should_contain_filename(mocker, committed_img): 155 | img = committed_img 156 | img.save() 157 | 158 | assert 'FileName' in img.exif 159 | filename = img.image.name.split('/')[-1] 160 | assert img.exif['FileName']['desc'] == 'File Name' 161 | assert img.exif['FileName']['val'] == filename 162 | 163 | 164 | @pytest.mark.django_db 165 | def test_do_not_reextract_exif_if_filename_is_known(mocker, committed_img): 166 | img = committed_img 167 | img.save() # store image and extract exif 168 | 169 | exif_field = img._meta.get_field('exif') 170 | mocker.spy(fields, 'get_exif') 171 | 172 | img.save() 173 | assert fields.get_exif.call_count == 0 174 | 175 | exif_field.update_exif(img) 176 | assert fields.get_exif.call_count == 0 177 | 178 | 179 | @pytest.mark.django_db 180 | def test_do_reextract_exif_if_new_file_is_uncommited(mocker, committed_img): 181 | img = committed_img 182 | img.save() # store image and extract exif 183 | 184 | mocker.spy(fields, 'get_exif') 185 | 186 | img.image._committed = False 187 | img.save() 188 | assert fields.get_exif.call_count == 1 189 | 190 | 191 | @pytest.mark.django_db 192 | def test_do_not_extract_exif_without_file(mocker): 193 | mocker.spy(fields, 'get_exif') 194 | 195 | img = Image() 196 | img.save() 197 | 198 | assert fields.get_exif.call_count == 0 199 | # the fallback value should always be an empty dict 200 | assert img.exif == {} 201 | 202 | 203 | @pytest.mark.django_db 204 | def test_extract_remote_backend(mocker, img_remotestorage): 205 | img = img_remotestorage 206 | 207 | exif_field = img._meta.get_field('exif') 208 | mocker.spy(fields, 'get_exif') 209 | 210 | exif_field.update_exif(img) 211 | assert fields.get_exif.call_count == 1 212 | 213 | 214 | @pytest.mark.django_db 215 | def test_extract_exif_if_missing(mocker, img): 216 | img.save() # store image and extract exif 217 | img.exif = {} 218 | 219 | exif_field = img._meta.get_field('exif') 220 | mocker.spy(fields, 'get_exif') 221 | 222 | exif_field.update_exif(img) 223 | assert fields.get_exif.call_count == 1 224 | assert isinstance(img.exif, dict) 225 | assert len(img.exif.keys()) > 0 226 | 227 | 228 | @pytest.mark.django_db 229 | def test_extract_exif_if_forced(mocker, img): 230 | img.save() # store image and extract exif 231 | img.exif = {'foo': {'desc': 'Foo', 'val': 0}} 232 | 233 | exif_field = img._meta.get_field('exif') 234 | mocker.spy(fields, 'get_exif') 235 | 236 | exif_field.update_exif(img, force=True) 237 | assert fields.get_exif.call_count == 1 238 | assert isinstance(img.exif, dict) 239 | assert 'foo' not in img.exif 240 | 241 | 242 | @pytest.mark.django_db 243 | def test_extract_exif_if_file_changes(mocker, img): 244 | img.exif = {'FileName': {'desc': 'File Name', 'val': 'foo.jpg'}} 245 | img.save() # store image and extract exif 246 | 247 | assert isinstance(img.exif, dict) 248 | # there should be more than one key (`FileName`) 249 | assert len(img.exif.keys()) > 1 250 | assert img.exif['FileName']['val'] != 'foo.jpg' 251 | 252 | 253 | @pytest.mark.django_db 254 | def test_extract_exif_and_save(mocker, img): 255 | img.save() # store image and extract exif 256 | img.exif = None 257 | 258 | exif_field = img._meta.get_field('exif') 259 | 260 | exif_field.update_exif(img, commit=True, force=True) 261 | img.refresh_from_db() 262 | assert isinstance(img.exif, dict) 263 | 264 | 265 | @pytest.mark.django_db 266 | def test_denormalization(img): 267 | img.save() # store image and extract exif 268 | assert img.camera == 'DMC-GX7' 269 | 270 | 271 | @pytest.mark.django_db 272 | def test_denormalization_invalid_exif(img, caplog): 273 | img.save() # store image and extract exif 274 | 275 | # reset model 276 | img.camera = '' 277 | del img.exif['Model'] 278 | 279 | # no error should occur if the key is not available or on any other error 280 | img._meta.get_field('exif').denormalize_exif(img) 281 | 282 | # assert logging message 283 | assert 'Could not execute' in caplog.text 284 | assert 'exifgetter' in caplog.text # name of getter function 285 | assert 'Image.camera' in caplog.text # target field 286 | 287 | # no data should be added 288 | assert img.camera == '' 289 | 290 | 291 | @pytest.mark.xfail 292 | def test_async(): 293 | raise NotImplementedError() 294 | -------------------------------------------------------------------------------- /tests/test_getters.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import pytest 4 | 5 | from exiffield import getters 6 | from exiffield.exceptions import ExifError 7 | 8 | 9 | @pytest.mark.parametrize('key, expected', [['Model', 'DMC-GX7'], ['Aperture', 1.7]]) 10 | def test_exifgetter(key, expected): 11 | exif_data = { 12 | 'Model': {'desc': 'Camera Model Name', 'val': 'DMC-GX7'}, 13 | 'Aperture': {'desc': 'Aperture', 'val': 1.7}, 14 | 'FileSize': {'desc': 'File Size', 'num': 4915200, 'val': '4.7 MB'}, 15 | } 16 | 17 | assert getters.exifgetter(key)(exif_data) == expected 18 | 19 | 20 | @pytest.mark.parametrize( 21 | 'value, expected', [['image/jpeg', 'image'], ['video/mp4', 'video']] 22 | ) 23 | def test_get_type(value, expected): 24 | exif_data = { 25 | 'MIMEType': {'val': value}, 26 | } 27 | 28 | assert getters.get_type(exif_data) == expected 29 | 30 | 31 | @pytest.mark.parametrize( 32 | 'exif_data, expected', 33 | [ 34 | [ 35 | {'DateTimeOriginal': {'val': '2018:03:02 11:33:10'}}, 36 | datetime.datetime(2018, 3, 2, 11, 33, 10), 37 | ], 38 | [ 39 | {'GPSDateTime': {'val': '2018:03:02 11:33:10'}}, 40 | datetime.datetime(2018, 3, 2, 11, 33, 10), 41 | ], 42 | ], 43 | ) 44 | def test_get_datetaken(exif_data, expected): 45 | assert getters.get_datetaken(exif_data) == expected 46 | 47 | 48 | @pytest.mark.parametrize( 49 | 'exif_data, error_msg', 50 | [ 51 | [{'DateTimeOriginal': {'val': 'invalid format'}}, 'Could not parse'], 52 | [{}, 'Could not find'], # missing key 53 | ], 54 | ) 55 | def test_get_datetaken_invalid_data(exif_data, error_msg): 56 | with pytest.raises(ExifError) as exc_info: 57 | getters.get_datetaken(exif_data) 58 | assert error_msg in exc_info.value.message 59 | 60 | 61 | @pytest.mark.parametrize( 62 | 'width, height, orientation, expected', 63 | [ 64 | [300, 200, 1, getters.Orientation.LANDSCAPE], 65 | [300, 200, 2, getters.Orientation.LANDSCAPE], 66 | [300, 200, 3, getters.Orientation.LANDSCAPE], 67 | [300, 200, 4, getters.Orientation.LANDSCAPE], 68 | [200, 300, 1, getters.Orientation.PORTRAIT], 69 | [200, 300, 2, getters.Orientation.PORTRAIT], 70 | [200, 300, 3, getters.Orientation.PORTRAIT], 71 | [200, 300, 4, getters.Orientation.PORTRAIT], 72 | [300, 200, 5, getters.Orientation.PORTRAIT], 73 | [300, 200, 6, getters.Orientation.PORTRAIT], 74 | [300, 200, 7, getters.Orientation.PORTRAIT], 75 | [300, 200, 8, getters.Orientation.PORTRAIT], 76 | [200, 300, 5, getters.Orientation.LANDSCAPE], 77 | [200, 300, 6, getters.Orientation.LANDSCAPE], 78 | [200, 300, 7, getters.Orientation.LANDSCAPE], 79 | [200, 300, 8, getters.Orientation.LANDSCAPE], 80 | ], 81 | ) 82 | def test_get_orientation(width, height, orientation, expected): 83 | exif_data = { 84 | 'Orientation': {'num': orientation}, 85 | 'ImageWidth': {'val': width}, 86 | 'ImageHeight': {'val': height}, 87 | } 88 | 89 | assert getters.get_orientation(exif_data) == expected 90 | 91 | 92 | @pytest.mark.parametrize( 93 | 'exif_data, expected', 94 | [ 95 | [{}, getters.Mode.SINGLE], 96 | [{'BurstMode': {'num': 2}}, getters.Mode.BRACKETING], 97 | [{'BurstMode': {'num': 1}}, getters.Mode.BURST], 98 | [{'TimerRecording': {'num': 1}}, getters.Mode.TIMELAPSE], 99 | ], 100 | ) 101 | def test_get_sequencetype(exif_data, expected): 102 | assert getters.get_sequencetype(exif_data) == expected 103 | 104 | 105 | @pytest.mark.parametrize( 106 | 'exif_data, expected', 107 | [[{}, 0], [{'SequenceNumber': {'num': 0}}, 0], [{'SequenceNumber': {'num': 3}}, 3]], 108 | ) 109 | def test_get_sequencenumber(exif_data, expected): 110 | assert getters.get_sequencenumber(exif_data) == expected 111 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | urlpatterns = [] # type: ignore 2 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [gh-actions] 2 | python = 3 | 3.6: py36 4 | 3.7: py37 5 | 3.8: py38 6 | 7 | [tox] 8 | skipsdist = True 9 | isolated_build = True 10 | envlist = 11 | py36-{2.2,3.0,3.1} 12 | py37-{2.2,3.0,3.1} 13 | py38-{2.2,3.0,3.1} 14 | 15 | [testenv] 16 | skip_install = True 17 | whitelist_externals = 18 | bash 19 | env 20 | grep 21 | deps = 22 | poetry 23 | 2.2: Django>=2.2,<2.3 24 | 3.0: Django>=3.0,<3.1 25 | 3.1: Django>=3.1,<3.2 26 | commands = 27 | # Poetry install automatically install the specific versions from the `poetry.lock` 28 | # file regardless whether a different version is already present or not. 29 | # Since we want to test specific versions of Django, which is installed by tox, 30 | # we need to manually install all other dependencies. 31 | # see here for more information: https://github.com/python-poetry/poetry/issues/1745 32 | bash -c 'poetry export --dev --without-hashes -f requirements.txt | grep -v "^[dD]jango==" > .requirements.txt' 33 | poetry run pip install --no-deps -r .requirements.txt 34 | poetry run pytest --cov-append 35 | coverage report 36 | 37 | --------------------------------------------------------------------------------