├── .coveragerc ├── .flake8 ├── .github └── workflows │ ├── codeql-analysis.yml │ ├── lint.yml │ ├── stale.yml │ └── test.yml ├── .gitignore ├── .pylintrc ├── .python-version ├── CONTRIBUTING ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── libthumbor ├── __init__.py ├── crypto.py ├── django │ ├── __init__.py │ ├── urls.py │ └── views.py ├── url.py └── url_signers │ ├── __init__.py │ └── base64_hmac_sha1.py ├── poetry.lock ├── pyproject.toml ├── test_requirements.txt └── tests ├── __init__.py ├── test_cryptourl.py ├── test_generic_views.py ├── test_libthumbor.py ├── test_url.py ├── test_url_composer.py ├── testproj ├── manage.py └── testproj │ ├── __init__.py │ ├── asgi.py │ ├── settings.py │ ├── urls.py │ └── wsgi.py └── url_signers ├── __init__.py ├── test_base64_hmac_sha1_signer.py └── test_base_url_signer.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = 3 | test_*.py 4 | branch = True 5 | source = 6 | libthumbor 7 | 8 | [report] 9 | exclude_lines = 10 | pragma: no cover 11 | def __repr__ 12 | raise NotImplementedError 13 | if __name__ == .__main__.: 14 | from urllib.parse import parse_qs 15 | except ImportError: 16 | from nose_focus import focus 17 | @focus 18 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = W801,E501,W605,W504,W606,W503,E203,E266 3 | max-line-length = 88 4 | max-complexity = 20 5 | select = B,C,E,F,W,T4 6 | exclude = ./.tox/*,./build/*,./docs/conf.py,./.env 7 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | on: 3 | push: 4 | branches: [ master ] 5 | pull_request: 6 | branches: [ master ] 7 | schedule: 8 | - cron: '37 1 * * 3' 9 | jobs: 10 | analyze: 11 | name: Analyze 12 | runs-on: ubuntu-latest 13 | permissions: 14 | actions: read 15 | contents: read 16 | security-events: write 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | language: [ 'python' ] 21 | steps: 22 | - name: Checkout repository 23 | uses: actions/checkout@v2 24 | - name: Initialize CodeQL 25 | uses: github/codeql-action/init@v1 26 | with: 27 | languages: ${{ matrix.language }} 28 | - name: Perform CodeQL Analysis 29 | uses: github/codeql-action/analyze@v1 30 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: lint/formatter 2 | on: 3 | push: 4 | pull_request: 5 | types: [opened, reopened] 6 | jobs: 7 | black: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v2 11 | - uses: psf/black@stable 12 | with: 13 | options: "--check --verbose" 14 | flake8: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/setup-python@v3 18 | with: 19 | python-version: '3.x' 20 | - uses: actions/checkout@v2 21 | - name: Install flake8 22 | run: pip install flake8 23 | - name: Run flake8 24 | uses: suo/flake8-github-action@releases/v1 25 | with: 26 | checkName: 'flake8' 27 | env: 28 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 29 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: Close stale issues and PRs 2 | on: 3 | schedule: 4 | - cron: '* */12 * * *' 5 | jobs: 6 | stale: 7 | runs-on: ubuntu-latest 8 | permissions: 9 | issues: write 10 | pull-requests: write 11 | steps: 12 | - uses: actions/stale@v4 13 | with: 14 | repo-token: ${{ secrets.GITHUB_TOKEN }} 15 | stale-issue-message: This issue is stale because it has been open 30 days 16 | with no activity. Remove the stale label or add a comment, or this issue 17 | will be closed in 5 days. You can always re-open if you still feel this 18 | is still an issue. Tag @heynemann for more information. 19 | stale-pr-message: This PR is stale because it has been open 45 days with 20 | no activity. Remove the stale label or add a comment, or this PR will 21 | be closed in 10 days. You can always re-open if you feel this is something 22 | we should still keep working on. Tag @heynemann for more information. 23 | close-issue-message: This issue was closed because it has been stale for 24 | 5 days with no activity. 25 | close-pr-message: This PR was closed because it has been stale for 10 days 26 | with no activity. 27 | days-before-issue-stale: 30 28 | days-before-pr-stale: 45 29 | days-before-issue-close: 5 30 | days-before-pr-close: 10 31 | exempt-draft-pr: true 32 | operations-per-run: 300 33 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: 3 | push: 4 | pull_request: 5 | types: [opened, reopened] 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | python-version: ['3.7', '3.8', '3.9', '3.10.4'] 12 | steps: 13 | - uses: actions/checkout@v3 14 | - name: Set up Python 15 | id: setup-python 16 | uses: actions/setup-python@v3 17 | with: 18 | python-version: ${{ matrix.python-version }} 19 | - name: Install Poetry 20 | uses: snok/install-poetry@v1 21 | with: 22 | virtualenvs-create: true 23 | virtualenvs-in-project: true 24 | - name: Load cached venv 25 | id: cached-poetry-dependencies 26 | uses: actions/cache@v2 27 | with: 28 | path: .venv 29 | key: venv-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('**/poetry.lock') }} 30 | - name: Install dependencies 31 | if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' 32 | run: poetry install --no-interaction --no-root 33 | - name: Install libthumbor 34 | run: poetry install --no-interaction 35 | - name: Run tests 36 | run: make unit 37 | - name: Run lint 38 | run: make lint 39 | - name: Generate lcov 40 | run: make coverage 41 | - name: Coveralls 42 | uses: coverallsapp/github-action@1.1.3 43 | with: 44 | github-token: ${{ secrets.GITHUB_TOKEN }} 45 | path-to-lcov: coverage.lcov 46 | flag-name: run-${{ matrix.python-version }} 47 | parallel: true 48 | finish: 49 | needs: build 50 | runs-on: ubuntu-latest 51 | steps: 52 | - name: Coveralls Finished 53 | uses: coverallsapp/github-action@1.1.3 54 | with: 55 | github-token: ${{ secrets.github_token }} 56 | parallel-finished: true 57 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | *.pyc 3 | .DS_Store 4 | dist 5 | libthumbor.egg-info 6 | *.db 7 | .coverage 8 | /tests/testproj/db.sqlite3 9 | .env 10 | coverage.lcov 11 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | 3 | # A comma-separated list of package or module names from where C extensions may 4 | # be loaded. Extensions are loading into the active Python interpreter and may 5 | # run arbitrary code. 6 | extension-pkg-whitelist= 7 | 8 | # Add files or directories to the blacklist. They should be base names, not 9 | # paths. 10 | ignore=CVS 11 | 12 | # Add files or directories matching the regex patterns to the blacklist. The 13 | # regex matches against base names, not paths. 14 | ignore-patterns= 15 | 16 | # Python code to execute, usually for sys.path manipulation such as 17 | # pygtk.require(). 18 | #init-hook= 19 | 20 | # Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the 21 | # number of processors available to use. 22 | jobs=1 23 | 24 | # Control the amount of potential inferred values when inferring a single 25 | # object. This can help the performance when dealing with large functions or 26 | # complex, nested conditions. 27 | limit-inference-results=100 28 | 29 | # List of plugins (as comma separated values of python module names) to load, 30 | # usually to register additional checkers. 31 | load-plugins= 32 | 33 | # Pickle collected data for later comparisons. 34 | persistent=yes 35 | 36 | # Specify a configuration file. 37 | #rcfile= 38 | 39 | # When enabled, pylint would attempt to guess common misconfiguration and emit 40 | # user-friendly hints instead of false-positive error messages. 41 | suggestion-mode=yes 42 | 43 | # Allow loading of arbitrary C extensions. Extensions are imported into the 44 | # active Python interpreter and may run arbitrary code. 45 | unsafe-load-any-extension=no 46 | 47 | [MESSAGES CONTROL] 48 | 49 | # Disable the message, report, category or checker with the given id(s). You 50 | # can either give multiple identifiers separated by comma (,) or put this 51 | # option multiple times (only on the command line, not in the configuration 52 | # file where it should appear only once). You can also use "--disable=all" to 53 | # disable everything first and then reenable specific checks. For example, if 54 | # you want to run only the similarities checker, you can use "--disable=all 55 | # --enable=similarities". If you want to run only the classes checker, but have 56 | # no Warning level messages displayed, use "--disable=all --enable=classes 57 | # --disable=W". 58 | disable= 59 | missing-function-docstring, 60 | missing-module-docstring, 61 | missing-class-docstring, 62 | bad-continuation 63 | 64 | 65 | # Only show warnings with the listed confidence levels. Leave empty to show 66 | # all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED. 67 | confidence= 68 | enable= 69 | 70 | 71 | [REPORTS] 72 | 73 | # Python expression which should return a score less than or equal to 10. You 74 | # have access to the variables 'error', 'warning', 'refactor', and 'convention' 75 | # which contain the number of messages in each category, as well as 'statement' 76 | # which is the total number of statements analyzed. This score is used by the 77 | # global evaluation report (RP0004). 78 | evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) 79 | 80 | # Template used to display messages. This is a python new-style format string 81 | # used to format the message information. See doc for all details. 82 | #msg-template= 83 | 84 | # Set the output format. Available formats are text, parseable, colorized, json 85 | # and msvs (visual studio). You can also give a reporter class, e.g. 86 | # mypackage.mymodule.MyReporterClass. 87 | output-format=text 88 | 89 | # Tells whether to display a full report or only the messages. 90 | reports=no 91 | 92 | # Activate the evaluation score. 93 | score=yes 94 | 95 | 96 | [REFACTORING] 97 | 98 | # Maximum number of nested blocks for function / method body 99 | max-nested-blocks=5 100 | 101 | # Complete name of functions that never returns. When checking for 102 | # inconsistent-return-statements if a never returning function is called then 103 | # it will be considered as an explicit return statement and no message will be 104 | # printed. 105 | never-returning-functions=sys.exit 106 | 107 | 108 | [LOGGING] 109 | 110 | # Format style used to check logging format string. `old` means using % 111 | # formatting, `new` is for `{}` formatting,and `fstr` is for f-strings. 112 | logging-format-style=old 113 | 114 | # Logging modules to check that the string format arguments are in logging 115 | # function parameter format. 116 | logging-modules=logging 117 | 118 | 119 | [STRING] 120 | 121 | # This flag controls whether the implicit-str-concat-in-sequence should 122 | # generate a warning on implicit string concatenation in sequences defined over 123 | # several lines. 124 | check-str-concat-over-line-jumps=no 125 | 126 | 127 | [MISCELLANEOUS] 128 | 129 | # List of note tags to take in consideration, separated by a comma. 130 | notes=FIXME, 131 | XXX, 132 | TODO 133 | 134 | 135 | [SPELLING] 136 | 137 | # Limits count of emitted suggestions for spelling mistakes. 138 | max-spelling-suggestions=4 139 | 140 | # Spelling dictionary name. Available dictionaries: none. To make it work, 141 | # install the python-enchant package. 142 | spelling-dict= 143 | 144 | # List of comma separated words that should not be checked. 145 | spelling-ignore-words= 146 | 147 | # A path to a file that contains the private dictionary; one word per line. 148 | spelling-private-dict-file= 149 | 150 | # Tells whether to store unknown words to the private dictionary (see the 151 | # --spelling-private-dict-file option) instead of raising a message. 152 | spelling-store-unknown-words=no 153 | 154 | 155 | [BASIC] 156 | 157 | # Naming style matching correct argument names. 158 | argument-naming-style=snake_case 159 | 160 | # Regular expression matching correct argument names. Overrides argument- 161 | # naming-style. 162 | #argument-rgx= 163 | 164 | # Naming style matching correct attribute names. 165 | attr-naming-style=snake_case 166 | 167 | # Regular expression matching correct attribute names. Overrides attr-naming- 168 | # style. 169 | #attr-rgx= 170 | 171 | # Bad variable names which should always be refused, separated by a comma. 172 | bad-names=foo, 173 | bar, 174 | baz, 175 | toto, 176 | tutu, 177 | tata 178 | 179 | # Naming style matching correct class attribute names. 180 | class-attribute-naming-style=any 181 | 182 | # Regular expression matching correct class attribute names. Overrides class- 183 | # attribute-naming-style. 184 | #class-attribute-rgx= 185 | 186 | # Naming style matching correct class names. 187 | class-naming-style=PascalCase 188 | 189 | # Regular expression matching correct class names. Overrides class-naming- 190 | # style. 191 | #class-rgx= 192 | 193 | # Naming style matching correct constant names. 194 | const-naming-style=UPPER_CASE 195 | 196 | # Regular expression matching correct constant names. Overrides const-naming- 197 | # style. 198 | #const-rgx= 199 | 200 | # Minimum line length for functions/classes that require docstrings, shorter 201 | # ones are exempt. 202 | docstring-min-length=-1 203 | 204 | # Naming style matching correct function names. 205 | function-naming-style=snake_case 206 | 207 | # Regular expression matching correct function names. Overrides function- 208 | # naming-style. 209 | #function-rgx= 210 | 211 | # Good variable names which should always be accepted, separated by a comma. 212 | good-names=i, 213 | j, 214 | k, 215 | ex, 216 | Run, 217 | _ 218 | 219 | # Include a hint for the correct naming format with invalid-name. 220 | include-naming-hint=no 221 | 222 | # Naming style matching correct inline iteration names. 223 | inlinevar-naming-style=any 224 | 225 | # Regular expression matching correct inline iteration names. Overrides 226 | # inlinevar-naming-style. 227 | #inlinevar-rgx= 228 | 229 | # Naming style matching correct method names. 230 | method-naming-style=snake_case 231 | 232 | # Regular expression matching correct method names. Overrides method-naming- 233 | # style. 234 | #method-rgx= 235 | 236 | # Naming style matching correct module names. 237 | module-naming-style=snake_case 238 | 239 | # Regular expression matching correct module names. Overrides module-naming- 240 | # style. 241 | #module-rgx= 242 | 243 | # Colon-delimited sets of names that determine each other's naming style when 244 | # the name regexes allow several styles. 245 | name-group= 246 | 247 | # Regular expression which should only match function or class names that do 248 | # not require a docstring. 249 | no-docstring-rgx=^_ 250 | 251 | # List of decorators that produce properties, such as abc.abstractproperty. Add 252 | # to this list to register other decorators that produce valid properties. 253 | # These decorators are taken in consideration only for invalid-name. 254 | property-classes=abc.abstractproperty 255 | 256 | # Naming style matching correct variable names. 257 | variable-naming-style=snake_case 258 | 259 | # Regular expression matching correct variable names. Overrides variable- 260 | # naming-style. 261 | #variable-rgx= 262 | 263 | 264 | [FORMAT] 265 | 266 | # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. 267 | expected-line-ending-format= 268 | 269 | # Regexp for a line that is allowed to be longer than the limit. 270 | ignore-long-lines=^\s*(# )??$ 271 | 272 | # Number of spaces of indent required inside a hanging or continued line. 273 | indent-after-paren=4 274 | 275 | # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 276 | # tab). 277 | indent-string=' ' 278 | 279 | # Maximum number of characters on a single line. 280 | max-line-length=88 281 | 282 | # Maximum number of lines in a module. 283 | max-module-lines=1000 284 | 285 | # List of optional constructs for which whitespace checking is disabled. `dict- 286 | # separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. 287 | # `trailing-comma` allows a space between comma and closing bracket: (a, ). 288 | # `empty-line` allows space-only lines. 289 | no-space-check=trailing-comma, 290 | dict-separator 291 | 292 | # Allow the body of a class to be on the same line as the declaration if body 293 | # contains single statement. 294 | single-line-class-stmt=no 295 | 296 | # Allow the body of an if to be on the same line as the test if there is no 297 | # else. 298 | single-line-if-stmt=no 299 | 300 | 301 | [SIMILARITIES] 302 | 303 | # Ignore comments when computing similarities. 304 | ignore-comments=yes 305 | 306 | # Ignore docstrings when computing similarities. 307 | ignore-docstrings=yes 308 | 309 | # Ignore imports when computing similarities. 310 | ignore-imports=no 311 | 312 | # Minimum lines number of a similarity. 313 | min-similarity-lines=4 314 | 315 | 316 | [VARIABLES] 317 | 318 | # List of additional names supposed to be defined in builtins. Remember that 319 | # you should avoid defining new builtins when possible. 320 | additional-builtins= 321 | 322 | # Tells whether unused global variables should be treated as a violation. 323 | allow-global-unused-variables=yes 324 | 325 | # List of strings which can identify a callback function by name. A callback 326 | # name must start or end with one of those strings. 327 | callbacks=cb_, 328 | _cb 329 | 330 | # A regular expression matching the name of dummy variables (i.e. expected to 331 | # not be used). 332 | dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ 333 | 334 | # Argument names that match this expression will be ignored. Default to name 335 | # with leading underscore. 336 | ignored-argument-names=_.*|^ignored_|^unused_ 337 | 338 | # Tells whether we should check for unused import in __init__ files. 339 | init-import=no 340 | 341 | # List of qualified module names which can have objects that can redefine 342 | # builtins. 343 | redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io 344 | 345 | 346 | [TYPECHECK] 347 | 348 | # List of decorators that produce context managers, such as 349 | # contextlib.contextmanager. Add to this list to register other decorators that 350 | # produce valid context managers. 351 | contextmanager-decorators=contextlib.contextmanager 352 | 353 | # List of members which are set dynamically and missed by pylint inference 354 | # system, and so shouldn't trigger E1101 when accessed. Python regular 355 | # expressions are accepted. 356 | generated-members= 357 | 358 | # Tells whether missing members accessed in mixin class should be ignored. A 359 | # mixin class is detected if its name ends with "mixin" (case insensitive). 360 | ignore-mixin-members=yes 361 | 362 | # Tells whether to warn about missing members when the owner of the attribute 363 | # is inferred to be None. 364 | ignore-none=yes 365 | 366 | # This flag controls whether pylint should warn about no-member and similar 367 | # checks whenever an opaque object is returned when inferring. The inference 368 | # can return multiple potential results while evaluating a Python object, but 369 | # some branches might not be evaluated, which results in partial inference. In 370 | # that case, it might be useful to still emit no-member and other checks for 371 | # the rest of the inferred objects. 372 | ignore-on-opaque-inference=yes 373 | 374 | # List of class names for which member attributes should not be checked (useful 375 | # for classes with dynamically set attributes). This supports the use of 376 | # qualified names. 377 | ignored-classes=optparse.Values,thread._local,_thread._local 378 | 379 | # List of module names for which member attributes should not be checked 380 | # (useful for modules/projects where namespaces are manipulated during runtime 381 | # and thus existing member attributes cannot be deduced by static analysis). It 382 | # supports qualified module names, as well as Unix pattern matching. 383 | ignored-modules= 384 | 385 | # Show a hint with possible names when a member name was not found. The aspect 386 | # of finding the hint is based on edit distance. 387 | missing-member-hint=yes 388 | 389 | # The minimum edit distance a name should have in order to be considered a 390 | # similar match for a missing member name. 391 | missing-member-hint-distance=1 392 | 393 | # The total number of similar names that should be taken in consideration when 394 | # showing a hint for a missing member. 395 | missing-member-max-choices=1 396 | 397 | # List of decorators that change the signature of a decorated function. 398 | signature-mutators= 399 | 400 | 401 | [DESIGN] 402 | 403 | # Maximum number of arguments for function / method. 404 | max-args=5 405 | 406 | # Maximum number of attributes for a class (see R0902). 407 | max-attributes=7 408 | 409 | # Maximum number of boolean expressions in an if statement (see R0916). 410 | max-bool-expr=5 411 | 412 | # Maximum number of branch for function / method body. 413 | max-branches=12 414 | 415 | # Maximum number of locals for function / method body. 416 | max-locals=15 417 | 418 | # Maximum number of parents for a class (see R0901). 419 | max-parents=7 420 | 421 | # Maximum number of public methods for a class (see R0904). 422 | max-public-methods=20 423 | 424 | # Maximum number of return / yield for function / method body. 425 | max-returns=6 426 | 427 | # Maximum number of statements in function / method body. 428 | max-statements=50 429 | 430 | # Minimum number of public methods for a class (see R0903). 431 | min-public-methods=2 432 | 433 | 434 | [CLASSES] 435 | 436 | # List of method names used to declare (i.e. assign) instance attributes. 437 | defining-attr-methods=__init__, 438 | __new__, 439 | setUp, 440 | __post_init__ 441 | 442 | # List of member names, which should be excluded from the protected access 443 | # warning. 444 | exclude-protected=_asdict, 445 | _fields, 446 | _replace, 447 | _source, 448 | _make 449 | 450 | # List of valid names for the first argument in a class method. 451 | valid-classmethod-first-arg=cls 452 | 453 | # List of valid names for the first argument in a metaclass class method. 454 | valid-metaclass-classmethod-first-arg=cls 455 | 456 | 457 | [IMPORTS] 458 | 459 | # List of modules that can be imported at any level, not just the top level 460 | # one. 461 | allow-any-import-level= 462 | 463 | # Allow wildcard imports from modules that define __all__. 464 | allow-wildcard-with-all=no 465 | 466 | # Analyse import fallback blocks. This can be used to support both Python 2 and 467 | # 3 compatible code, which means that the block might have code that exists 468 | # only in one or another interpreter, leading to false positives when analysed. 469 | analyse-fallback-blocks=no 470 | 471 | # Deprecated modules which should not be used, separated by a comma. 472 | deprecated-modules=optparse,tkinter.tix 473 | 474 | # Create a graph of external dependencies in the given file (report RP0402 must 475 | # not be disabled). 476 | ext-import-graph= 477 | 478 | # Create a graph of every (i.e. internal and external) dependencies in the 479 | # given file (report RP0402 must not be disabled). 480 | import-graph= 481 | 482 | # Create a graph of internal dependencies in the given file (report RP0402 must 483 | # not be disabled). 484 | int-import-graph= 485 | 486 | # Force import order to recognize a module as part of the standard 487 | # compatibility libraries. 488 | known-standard-library= 489 | 490 | # Force import order to recognize a module as part of a third party library. 491 | known-third-party=enchant 492 | 493 | # Couples of modules and preferred modules, separated by a comma. 494 | preferred-modules= 495 | 496 | 497 | [EXCEPTIONS] 498 | 499 | # Exceptions that will emit a warning when being caught. Defaults to 500 | # "BaseException, Exception". 501 | overgeneral-exceptions=BaseException, 502 | Exception 503 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.8.1/envs/libthumbor 2 | -------------------------------------------------------------------------------- /CONTRIBUTING: -------------------------------------------------------------------------------- 1 | So you want to contribute with libthumbor? Welcome onboard! 2 | 3 | There are a few things you'll need in order to properly start hacking on it. 4 | 5 | First step is to fork it at http://help.github.com/fork-a-repo/ and create your own clone of libthumbor. 6 | 7 | ## Dependencies 8 | 9 | We seriously advise you to use virtualenv (http://pypi.python.org/pypi/virtualenv) since it will keep your environment clean of libthumbor's dependencies and you can choose when to "turn them on". 10 | 11 | You'll also need python >= 2.6 and < 3.0. 12 | 13 | After that, just issue a `make setup` command and you'll be ready to start hacking. 14 | 15 | ## Running the Tests 16 | 17 | Running the tests is as easy as: 18 | 19 | make test 20 | 21 | ## Pull Requests 22 | 23 | After hacking and testing your contribution, it is time to make a pull request. Make sure that your code is already integrated with the master branch of libthumbor before asking for a pull request. 24 | 25 | To add thumbor's remote as a valid remote for your repository: 26 | 27 | git remote add libthumbor git://github.com/thumbor/libthumbor.git 28 | 29 | To merge thumbor's master with your fork: 30 | 31 | git pull libthumbor master 32 | 33 | If there was anything to merge, just run your tests again. If they pass, send a pull request (http://help.github.com/send-pull-requests/). 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Globo.com 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | prune dist 2 | prune build 3 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | test ci_test: unit coverage flake8 pylint 2 | 3 | unit: 4 | @poetry run pytest --cov=libthumbor tests/ 5 | 6 | coverage: 7 | @poetry run coverage report -m --fail-under=75 8 | @poetry run coverage lcov 9 | 10 | setup: 11 | @poetry install 12 | 13 | flake flake8: 14 | @poetry run flake8 15 | 16 | pylint lint: 17 | @poetry run pylint --exit-zero libthumbor tests 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://secure.travis-ci.org/thumbor/libthumbor.png)](http://travis-ci.org/thumbor/libthumbor) [![Coverage Status](https://coveralls.io/repos/github/thumbor/libthumbor/badge.svg?branch=master)](https://coveralls.io/github/thumbor/libthumbor?branch=master) 2 | 3 | libthumbor allows easy usage of 4 | [thumbor](http://github.com/thumbor/thumbor) in Python. Check the docs for django integration. 5 | 6 | This version is compliant with the new URL generation schema (thumbor 3.0.0 and up). 7 | 8 | ## Using it 9 | 10 | ```python 11 | from libthumbor import CryptoURL 12 | 13 | crypto = CryptoURL(key='my-security-key') 14 | 15 | encrypted_url = crypto.generate( 16 | width=300, 17 | height=200, 18 | smart=True, 19 | image_url='/path/to/my/image.jpg' 20 | ) 21 | ``` 22 | 23 | ## Docs 24 | 25 | Check the wiki for more information on using libthumbor. 26 | 27 | ## Contributions 28 | 29 | ### Bernardo Heynemann 30 | 31 | * Generic URL encryption 32 | 33 | ### Rafael Caricio 34 | 35 | * Django Generic View and URL 36 | 37 | ### Fábio Costa 38 | 39 | * Django Generic View and URL 40 | -------------------------------------------------------------------------------- /libthumbor/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | 4 | # libthumbor - python extension to thumbor 5 | # http://github.com/heynemann/libthumbor 6 | 7 | # Licensed under the MIT license: 8 | # http://www.opensource.org/licenses/mit-license 9 | # Copyright (c) 2011 Bernardo Heynemann heynemann@gmail.com 10 | 11 | """libthumbor is the library used to access thumbor's images in python""" 12 | 13 | from libthumbor.crypto import CryptoURL # NOQA 14 | from libthumbor.url import Url # NOQA 15 | from libthumbor.url_signers.base64_hmac_sha1 import UrlSigner as Signer # NOQA 16 | -------------------------------------------------------------------------------- /libthumbor/crypto.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | 4 | # libthumbor - python extension to thumbor 5 | # http://github.com/heynemann/libthumbor 6 | 7 | # Licensed under the MIT license: 8 | # http://www.opensource.org/licenses/mit-license 9 | # Copyright (c) 2011 Bernardo Heynemann heynemann@gmail.com 10 | 11 | """Encrypted URLs for thumbor encryption.""" 12 | 13 | from __future__ import absolute_import 14 | 15 | import base64 16 | import hashlib 17 | import hmac 18 | 19 | from six import b, text_type 20 | 21 | from libthumbor.url import plain_image_url, unsafe_url 22 | 23 | 24 | class CryptoURL: 25 | """Class responsible for generating encrypted URLs for thumbor""" 26 | 27 | def __init__(self, key): 28 | """ 29 | Initializes the encryptor with the proper key 30 | :param key: secret key to use for hashing. 31 | """ 32 | 33 | if isinstance(key, text_type): 34 | key = b(key) 35 | self.key = key 36 | self.hmac = hmac.new(self.key, digestmod=hashlib.sha1) 37 | 38 | def generate_new(self, options): 39 | url = plain_image_url(**options) 40 | _hmac = self.hmac.copy() 41 | _hmac.update(text_type(url).encode("utf-8")) 42 | signature = base64.urlsafe_b64encode(_hmac.digest()).decode("ascii") 43 | 44 | return f"/{signature}/{url}" 45 | 46 | def generate(self, **options): 47 | """Generates an encrypted URL with the specified options""" 48 | 49 | if options.get("unsafe", False): 50 | return unsafe_url(**options) 51 | 52 | return self.generate_new(options) 53 | -------------------------------------------------------------------------------- /libthumbor/django/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thumbor/libthumbor/b41d1712d64dc5073b65958bb0bcb6f3fce22256/libthumbor/django/__init__.py -------------------------------------------------------------------------------- /libthumbor/django/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from libthumbor.django.views import generate_url 4 | 5 | urlpatterns = [ # pylint: disable=invalid-name 6 | path("gen_url/", generate_url, name="generate_thumbor_url"), 7 | ] 8 | -------------------------------------------------------------------------------- /libthumbor/django/views.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | 4 | # libthumbor - python extension to thumbor 5 | # http://github.com/heynemann/libthumbor 6 | 7 | # Licensed under the MIT license: 8 | # http://www.opensource.org/licenses/mit-license 9 | # Copyright (c) 2011 Bernardo Heynemann heynemann@gmail.com 10 | 11 | """Generic view for create thumbor encrypted urls.""" 12 | import logging 13 | 14 | from django.conf import settings 15 | from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseNotAllowed 16 | 17 | from libthumbor.crypto import CryptoURL 18 | 19 | THUMBOR_SECURITY_KEY = getattr(settings, "THUMBOR_SECURITY_KEY", "my-security-key") 20 | 21 | THUMBOR_SERVER = getattr(settings, "THUMBOR_SERVER", "http://localhost:8888/") 22 | 23 | 24 | def generate_url(request): 25 | if request.method != "GET": 26 | return HttpResponseNotAllowed(["GET"]) 27 | 28 | crypto = CryptoURL(THUMBOR_SECURITY_KEY) 29 | 30 | args = request.GET 31 | args = dict(zip(map(str, args.keys()), args.values())) 32 | error_message = None 33 | 34 | try: 35 | if "width" in args: 36 | args["width"] = int(args["width"]) 37 | except ValueError: 38 | error_message = f"The width value '{args['width']}' is not an integer." 39 | 40 | try: 41 | if "height" in args: 42 | args["height"] = int(args["height"]) 43 | except ValueError: 44 | error_message = f"The height value '{args['height']}' is not an integer." 45 | 46 | try: 47 | if ( 48 | "crop_top" in args 49 | or "crop_left" in args 50 | or "crop_right" in args 51 | or "crop_bottom" in args 52 | ): 53 | args["crop"] = ( 54 | (int(args["crop_left"]), int(args["crop_top"])), 55 | (int(args["crop_right"]), int(args["crop_bottom"])), 56 | ) 57 | except KeyError: 58 | error_message = """ 59 | Missing values for cropping. 60 | Expected all 'crop_left', 'crop_top', 61 | 'crop_right', 'crop_bottom' values. 62 | """ 63 | except ValueError: 64 | error_message = """ 65 | Invalid values for cropping. 66 | Expected all 'crop_left', 'crop_top', 67 | 'crop_right', 'crop_bottom' to be integers. 68 | """ 69 | 70 | if error_message is not None: 71 | logging.warning(error_message) 72 | return HttpResponseBadRequest(error_message) 73 | 74 | try: 75 | return HttpResponse( 76 | THUMBOR_SERVER + crypto.generate(**args).strip("/"), 77 | content_type="text/plain", 78 | ) 79 | except (ValueError, KeyError) as error: 80 | error_message = str(error) 81 | logging.warning(error_message) 82 | return HttpResponseBadRequest(error_message) 83 | -------------------------------------------------------------------------------- /libthumbor/url.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | 4 | # libthumbor - python extension to thumbor 5 | # http://github.com/heynemann/libthumbor 6 | 7 | # Licensed under the MIT license: 8 | # http://www.opensource.org/licenses/mit-license 9 | # Copyright (c) 2011 Bernardo Heynemann heynemann@gmail.com 10 | 11 | """URL composer to create options-based URLs for thumbor encryption.""" 12 | 13 | # pylint: disable=line-too-long,too-many-branches,too-many-locals,too-many-arguments 14 | 15 | import hashlib 16 | import re 17 | 18 | from six import b 19 | 20 | AVAILABLE_HALIGN = ["left", "center", "right"] 21 | AVAILABLE_VALIGN = ["top", "middle", "bottom"] 22 | 23 | 24 | def calculate_width_and_height(url_parts, options): 25 | """Appends width and height information to url""" 26 | width = options.get("width", 0) 27 | has_width = width 28 | height = options.get("height", 0) 29 | has_height = height 30 | 31 | flip = options.get("flip", False) 32 | flop = options.get("flop", False) 33 | 34 | if flip: 35 | width = width * -1 36 | if flop: 37 | height = height * -1 38 | 39 | if not has_width and not has_height: 40 | if flip: 41 | width = "-0" 42 | if flop: 43 | height = "-0" 44 | 45 | if width or height: 46 | url_parts.append(f"{width}x{height}") 47 | 48 | 49 | def url_for(**options): 50 | """Returns the url for the specified options""" 51 | 52 | url_parts = get_url_parts(**options) 53 | image_hash = hashlib.md5(b(options["image_url"])).hexdigest() 54 | url_parts.append(image_hash) 55 | 56 | return "/".join(url_parts) 57 | 58 | 59 | def unsafe_url(**options): 60 | """Returns the unsafe url with the specified options""" 61 | return f"unsafe/{plain_image_url(**options)}" 62 | 63 | 64 | def plain_image_url(**options): 65 | url_parts = get_url_parts(**options) 66 | url_parts.append(options["image_url"]) 67 | 68 | return "/".join(url_parts) 69 | 70 | 71 | def get_url_parts(**options): 72 | if "image_url" not in options: 73 | raise ValueError("The image_url argument is mandatory.") 74 | 75 | url_parts = [] 76 | 77 | if options.get("meta", False): 78 | url_parts.append("meta") 79 | 80 | trim = options.get("trim", None) 81 | if trim: 82 | bits = ["trim"] 83 | if not isinstance(trim, bool): 84 | bits.append(trim[0] if trim[0] else "") 85 | if trim[1]: 86 | bits.append(str(trim[1])) 87 | url_parts.append(":".join(bits)) 88 | 89 | crop = options.get("crop", None) 90 | if crop: 91 | crop_left = crop[0][0] 92 | crop_top = crop[0][1] 93 | crop_right = crop[1][0] 94 | crop_bottom = crop[1][1] 95 | 96 | if crop_left > 0 or crop_top > 0 or crop_bottom > 0 or crop_right > 0: 97 | url_parts.append(f"{crop_left}x{crop_top}:{crop_right}x{crop_bottom}") 98 | 99 | calculate_fit_in(options, url_parts) 100 | calculate_width_and_height(url_parts, options) 101 | 102 | halign = options.get("halign", "center") 103 | valign = options.get("valign", "middle") 104 | 105 | if halign not in AVAILABLE_HALIGN: 106 | raise ValueError( 107 | 'Only "left", "center" and "right" are' 108 | + " valid values for horizontal alignment." 109 | ) 110 | if valign not in AVAILABLE_VALIGN: 111 | raise ValueError( 112 | 'Only "top", "middle" and "bottom" are' 113 | + " valid values for vertical alignment." 114 | ) 115 | 116 | if halign != "center": 117 | url_parts.append(halign) 118 | if valign != "middle": 119 | url_parts.append(valign) 120 | 121 | if options.get("smart", False): 122 | url_parts.append("smart") 123 | 124 | if options.get("filters", False): 125 | filters_string = ["filters"] 126 | for filter_value in options["filters"]: 127 | filters_string.append(filter_value) 128 | url_parts.append(":".join(filters_string)) 129 | 130 | return url_parts 131 | 132 | 133 | def calculate_fit_in(options, url_parts): 134 | fit_in = False 135 | full_fit_in = False 136 | 137 | if options.get("fit_in", None): 138 | fit_in = True 139 | url_parts.append("fit-in") 140 | 141 | if options.get("full_fit_in", None): 142 | full_fit_in = True 143 | url_parts.append("full-fit-in") 144 | 145 | if options.get("adaptive_fit_in", None): 146 | fit_in = True 147 | url_parts.append("adaptive-fit-in") 148 | 149 | if options.get("adaptive_full_fit_in", None): 150 | full_fit_in = True 151 | url_parts.append("adaptive-full-fit-in") 152 | 153 | if (fit_in or full_fit_in) and not ( 154 | options.get("width", None) or options.get("height", None) 155 | ): 156 | raise ValueError( 157 | "When using fit-in or full-fit-in, you must specify width and/or height." 158 | ) 159 | 160 | 161 | class Url: 162 | 163 | unsafe_or_hash = r"(?:(?:(?Punsafe)|(?P.+?))/)?" 164 | debug = r"(?:(?Pdebug)/)?" 165 | meta = r"(?:(?Pmeta)/)?" 166 | trim = r"(?:(?Ptrim(?::(?:top-left|bottom-right))?(?::\d+)?)/)?" 167 | crop = r"(?:(?P\d+)x(?P\d+):(?P\d+)x(?P\d+)/)?" 168 | fit_in = r"(?:(?Padaptive-)?(?Pfull-)?(?Pfit-in)/)?" 169 | dimensions = r"(?:(?P-)?(?P(?:\d+|orig))?x(?P-)?(?P(?:\d+|orig))?/)?" 170 | halign = r"(?:(?Pleft|right|center)/)?" 171 | valign = r"(?:(?Ptop|bottom|middle)/)?" 172 | smart = r"(?:(?Psmart)/)?" 173 | filters = r"(?:filters:(?P.+?\))/)?" 174 | image = r"(?P.+)" 175 | 176 | compiled_regex = None 177 | 178 | @classmethod 179 | def regex(cls, has_unsafe_or_hash=True): 180 | reg = ["/?"] 181 | 182 | if has_unsafe_or_hash: 183 | reg.append(cls.unsafe_or_hash) 184 | reg.append(cls.debug) 185 | reg.append(cls.meta) 186 | reg.append(cls.trim) 187 | reg.append(cls.crop) 188 | reg.append(cls.fit_in) 189 | reg.append(cls.dimensions) 190 | reg.append(cls.halign) 191 | reg.append(cls.valign) 192 | reg.append(cls.smart) 193 | reg.append(cls.filters) 194 | reg.append(cls.image) 195 | 196 | return "".join(reg) 197 | 198 | @classmethod 199 | def parse_decrypted(cls, url): 200 | if cls.compiled_regex: 201 | reg = cls.compiled_regex 202 | else: 203 | reg = cls.compiled_regex = re.compile(cls.regex(has_unsafe_or_hash=False)) 204 | 205 | result = reg.match(url) 206 | 207 | if not result: 208 | return None 209 | 210 | result = result.groupdict() 211 | 212 | def int_or_0(value): 213 | return 0 if value is None else int(value) 214 | 215 | adaptive = (result.get("adaptive", "") or "").startswith("adaptive") 216 | full = (result.get("full", "") or "").startswith("full") 217 | 218 | values = { 219 | "debug": result["debug"] == "debug", 220 | "meta": result["meta"] == "meta", 221 | "trim": result["trim"], 222 | "crop": { 223 | "left": int_or_0(result["crop_left"]), 224 | "top": int_or_0(result["crop_top"]), 225 | "right": int_or_0(result["crop_right"]), 226 | "bottom": int_or_0(result["crop_bottom"]), 227 | }, 228 | "adaptive": adaptive, 229 | "full": full, 230 | "fit_in": result["fit_in"] == "fit-in", 231 | "width": result["width"] == "orig" and "orig" or int_or_0(result["width"]), 232 | "height": result["height"] == "orig" 233 | and "orig" 234 | or int_or_0(result["height"]), 235 | "horizontal_flip": result["horizontal_flip"] == "-", 236 | "vertical_flip": result["vertical_flip"] == "-", 237 | "halign": result["halign"] or "center", 238 | "valign": result["valign"] or "middle", 239 | "smart": result["smart"] == "smart", 240 | "filters": result["filters"] or "", 241 | "image": "image" in result and result["image"] or None, 242 | } 243 | 244 | return values 245 | 246 | @classmethod # NOQA 247 | def generate_options( 248 | cls, 249 | debug=False, 250 | width=0, 251 | height=0, 252 | smart=False, 253 | meta=False, 254 | trim=None, 255 | adaptive=False, 256 | full=False, 257 | fit_in=False, 258 | horizontal_flip=False, 259 | vertical_flip=False, 260 | halign="center", 261 | valign="middle", 262 | crop_left=None, 263 | crop_top=None, 264 | crop_right=None, 265 | crop_bottom=None, 266 | filters=None, 267 | ): 268 | url = [] 269 | 270 | if debug: 271 | url.append("debug") 272 | 273 | if meta: 274 | url.append("meta") 275 | 276 | if trim: 277 | if isinstance(trim, bool): 278 | url.append("trim") 279 | else: 280 | url.append(f"trim:{trim}") 281 | 282 | crop = crop_left or crop_top or crop_right or crop_bottom 283 | if crop: 284 | url.append(f"{crop_left}x{crop_top}:{crop_right}x{crop_bottom}") 285 | 286 | if fit_in: 287 | fit_ops = [] 288 | if adaptive: 289 | fit_ops.append("adaptive") 290 | if full: 291 | fit_ops.append("full") 292 | fit_ops.append("fit-in") 293 | url.append("-".join(fit_ops)) 294 | 295 | if horizontal_flip: 296 | width = f"-{width}" 297 | if vertical_flip: 298 | height = f"-{height}" 299 | 300 | if width or height: 301 | url.append(f"{width}x{height}") 302 | 303 | if halign != "center": 304 | url.append(halign) 305 | if valign != "middle": 306 | url.append(valign) 307 | 308 | if smart: 309 | url.append("smart") 310 | 311 | if filters: 312 | url.append(f"filters:{filters}") 313 | 314 | return "/".join(url) 315 | -------------------------------------------------------------------------------- /libthumbor/url_signers/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | 4 | # thumbor imaging service 5 | # https://github.com/thumbor/thumbor/wiki 6 | 7 | # Licensed under the MIT license: 8 | # http://www.opensource.org/licenses/mit-license 9 | # Copyright (c) 2011 globo.com timehome@corp.globo.com 10 | 11 | from six import text_type 12 | 13 | 14 | class BaseUrlSigner: 15 | def __init__(self, security_key): 16 | if isinstance(security_key, text_type): 17 | security_key = security_key.encode("utf-8") 18 | self.security_key = security_key 19 | 20 | def validate(self, actual_signature, url): 21 | url_signature = self.signature(url) 22 | return url_signature == actual_signature 23 | 24 | def signature(self, url): 25 | raise NotImplementedError() 26 | -------------------------------------------------------------------------------- /libthumbor/url_signers/base64_hmac_sha1.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import base64 4 | import hashlib 5 | import hmac 6 | 7 | from six import text_type 8 | 9 | from libthumbor.url_signers import BaseUrlSigner 10 | 11 | 12 | class UrlSigner(BaseUrlSigner): 13 | """Validate urls and sign them using base64 hmac-sha1""" 14 | 15 | def signature(self, url): 16 | return base64.urlsafe_b64encode( 17 | hmac.new( 18 | self.security_key, text_type(url).encode("utf-8"), hashlib.sha1 19 | ).digest() 20 | ) 21 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | [[package]] 2 | name = "asgiref" 3 | version = "3.5.0" 4 | description = "ASGI specs, helper code, and adapters" 5 | category = "dev" 6 | optional = false 7 | python-versions = ">=3.7" 8 | 9 | [package.dependencies] 10 | typing-extensions = {version = "*", markers = "python_version < \"3.8\""} 11 | 12 | [package.extras] 13 | tests = ["pytest", "pytest-asyncio", "mypy (>=0.800)"] 14 | 15 | [[package]] 16 | name = "astroid" 17 | version = "2.11.2" 18 | description = "An abstract syntax tree for Python with inference support." 19 | category = "dev" 20 | optional = false 21 | python-versions = ">=3.6.2" 22 | 23 | [package.dependencies] 24 | lazy-object-proxy = ">=1.4.0" 25 | typed-ast = {version = ">=1.4.0,<2.0", markers = "implementation_name == \"cpython\" and python_version < \"3.8\""} 26 | typing-extensions = {version = ">=3.10", markers = "python_version < \"3.10\""} 27 | wrapt = ">=1.11,<2" 28 | 29 | [[package]] 30 | name = "atomicwrites" 31 | version = "1.4.0" 32 | description = "Atomic file writes." 33 | category = "dev" 34 | optional = false 35 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 36 | 37 | [[package]] 38 | name = "attrs" 39 | version = "21.4.0" 40 | description = "Classes Without Boilerplate" 41 | category = "dev" 42 | optional = false 43 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 44 | 45 | [package.extras] 46 | dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit", "cloudpickle"] 47 | docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"] 48 | tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "cloudpickle"] 49 | tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "cloudpickle"] 50 | 51 | [[package]] 52 | name = "black" 53 | version = "22.3.0" 54 | description = "The uncompromising code formatter." 55 | category = "dev" 56 | optional = false 57 | python-versions = ">=3.6.2" 58 | 59 | [package.dependencies] 60 | click = ">=8.0.0" 61 | mypy-extensions = ">=0.4.3" 62 | pathspec = ">=0.9.0" 63 | platformdirs = ">=2" 64 | tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} 65 | typed-ast = {version = ">=1.4.2", markers = "python_version < \"3.8\" and implementation_name == \"cpython\""} 66 | typing-extensions = {version = ">=3.10.0.0", markers = "python_version < \"3.10\""} 67 | 68 | [package.extras] 69 | colorama = ["colorama (>=0.4.3)"] 70 | d = ["aiohttp (>=3.7.4)"] 71 | jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] 72 | uvloop = ["uvloop (>=0.15.2)"] 73 | 74 | [[package]] 75 | name = "click" 76 | version = "8.1.1" 77 | description = "Composable command line interface toolkit" 78 | category = "dev" 79 | optional = false 80 | python-versions = ">=3.7" 81 | 82 | [package.dependencies] 83 | colorama = {version = "*", markers = "platform_system == \"Windows\""} 84 | importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} 85 | 86 | [[package]] 87 | name = "colorama" 88 | version = "0.4.4" 89 | description = "Cross-platform colored terminal text." 90 | category = "dev" 91 | optional = false 92 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 93 | 94 | [[package]] 95 | name = "coverage" 96 | version = "6.3.2" 97 | description = "Code coverage measurement for Python" 98 | category = "dev" 99 | optional = false 100 | python-versions = ">=3.7" 101 | 102 | [package.dependencies] 103 | tomli = {version = "*", optional = true, markers = "extra == \"toml\""} 104 | 105 | [package.extras] 106 | toml = ["tomli"] 107 | 108 | [[package]] 109 | name = "dill" 110 | version = "0.3.4" 111 | description = "serialize all of python" 112 | category = "dev" 113 | optional = false 114 | python-versions = ">=2.7, !=3.0.*" 115 | 116 | [package.extras] 117 | graph = ["objgraph (>=1.7.2)"] 118 | 119 | [[package]] 120 | name = "django" 121 | version = "3.2.12" 122 | description = "A high-level Python Web framework that encourages rapid development and clean, pragmatic design." 123 | category = "dev" 124 | optional = false 125 | python-versions = ">=3.6" 126 | 127 | [package.dependencies] 128 | asgiref = ">=3.3.2,<4" 129 | pytz = "*" 130 | sqlparse = ">=0.2.2" 131 | 132 | [package.extras] 133 | argon2 = ["argon2-cffi (>=19.1.0)"] 134 | bcrypt = ["bcrypt"] 135 | 136 | [[package]] 137 | name = "flake8" 138 | version = "4.0.1" 139 | description = "the modular source code checker: pep8 pyflakes and co" 140 | category = "dev" 141 | optional = false 142 | python-versions = ">=3.6" 143 | 144 | [package.dependencies] 145 | importlib-metadata = {version = "<4.3", markers = "python_version < \"3.8\""} 146 | mccabe = ">=0.6.0,<0.7.0" 147 | pycodestyle = ">=2.8.0,<2.9.0" 148 | pyflakes = ">=2.4.0,<2.5.0" 149 | 150 | [[package]] 151 | name = "importlib-metadata" 152 | version = "4.2.0" 153 | description = "Read metadata from Python packages" 154 | category = "dev" 155 | optional = false 156 | python-versions = ">=3.6" 157 | 158 | [package.dependencies] 159 | typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} 160 | zipp = ">=0.5" 161 | 162 | [package.extras] 163 | docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] 164 | testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pep517", "pyfakefs", "flufl.flake8", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"] 165 | 166 | [[package]] 167 | name = "iniconfig" 168 | version = "1.1.1" 169 | description = "iniconfig: brain-dead simple config-ini parsing" 170 | category = "dev" 171 | optional = false 172 | python-versions = "*" 173 | 174 | [[package]] 175 | name = "isort" 176 | version = "5.10.1" 177 | description = "A Python utility / library to sort Python imports." 178 | category = "dev" 179 | optional = false 180 | python-versions = ">=3.6.1,<4.0" 181 | 182 | [package.extras] 183 | pipfile_deprecated_finder = ["pipreqs", "requirementslib"] 184 | requirements_deprecated_finder = ["pipreqs", "pip-api"] 185 | colors = ["colorama (>=0.4.3,<0.5.0)"] 186 | plugins = ["setuptools"] 187 | 188 | [[package]] 189 | name = "lazy-object-proxy" 190 | version = "1.7.1" 191 | description = "A fast and thorough lazy object proxy." 192 | category = "dev" 193 | optional = false 194 | python-versions = ">=3.6" 195 | 196 | [[package]] 197 | name = "mccabe" 198 | version = "0.6.1" 199 | description = "McCabe checker, plugin for flake8" 200 | category = "dev" 201 | optional = false 202 | python-versions = "*" 203 | 204 | [[package]] 205 | name = "mypy-extensions" 206 | version = "0.4.3" 207 | description = "Experimental type system extensions for programs checked with the mypy typechecker." 208 | category = "dev" 209 | optional = false 210 | python-versions = "*" 211 | 212 | [[package]] 213 | name = "packaging" 214 | version = "21.3" 215 | description = "Core utilities for Python packages" 216 | category = "dev" 217 | optional = false 218 | python-versions = ">=3.6" 219 | 220 | [package.dependencies] 221 | pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" 222 | 223 | [[package]] 224 | name = "pathspec" 225 | version = "0.9.0" 226 | description = "Utility library for gitignore style pattern matching of file paths." 227 | category = "dev" 228 | optional = false 229 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" 230 | 231 | [[package]] 232 | name = "platformdirs" 233 | version = "2.5.1" 234 | description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." 235 | category = "dev" 236 | optional = false 237 | python-versions = ">=3.7" 238 | 239 | [package.extras] 240 | docs = ["Sphinx (>=4)", "furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)"] 241 | test = ["appdirs (==1.4.4)", "pytest (>=6)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)"] 242 | 243 | [[package]] 244 | name = "pluggy" 245 | version = "1.0.0" 246 | description = "plugin and hook calling mechanisms for python" 247 | category = "dev" 248 | optional = false 249 | python-versions = ">=3.6" 250 | 251 | [package.dependencies] 252 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 253 | 254 | [package.extras] 255 | dev = ["pre-commit", "tox"] 256 | testing = ["pytest", "pytest-benchmark"] 257 | 258 | [[package]] 259 | name = "preggy" 260 | version = "1.4.4" 261 | description = "preggy is an assertion library for Python.** What were you `expect`ing?" 262 | category = "dev" 263 | optional = false 264 | python-versions = "*" 265 | 266 | [package.dependencies] 267 | six = "*" 268 | unidecode = "*" 269 | 270 | [package.extras] 271 | tests = ["nose", "yanc", "coverage", "tox"] 272 | 273 | [[package]] 274 | name = "py" 275 | version = "1.11.0" 276 | description = "library with cross-python path, ini-parsing, io, code, log facilities" 277 | category = "dev" 278 | optional = false 279 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 280 | 281 | [[package]] 282 | name = "pycodestyle" 283 | version = "2.8.0" 284 | description = "Python style guide checker" 285 | category = "dev" 286 | optional = false 287 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 288 | 289 | [[package]] 290 | name = "pyflakes" 291 | version = "2.4.0" 292 | description = "passive checker of Python programs" 293 | category = "dev" 294 | optional = false 295 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 296 | 297 | [[package]] 298 | name = "pylint" 299 | version = "2.13.4" 300 | description = "python code static checker" 301 | category = "dev" 302 | optional = false 303 | python-versions = ">=3.6.2" 304 | 305 | [package.dependencies] 306 | astroid = ">=2.11.2,<=2.12.0-dev0" 307 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 308 | dill = ">=0.2" 309 | isort = ">=4.2.5,<6" 310 | mccabe = ">=0.6,<0.8" 311 | platformdirs = ">=2.2.0" 312 | tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} 313 | typing-extensions = {version = ">=3.10.0", markers = "python_version < \"3.10\""} 314 | 315 | [package.extras] 316 | testutil = ["gitpython (>3)"] 317 | 318 | [[package]] 319 | name = "pyparsing" 320 | version = "3.0.7" 321 | description = "Python parsing module" 322 | category = "dev" 323 | optional = false 324 | python-versions = ">=3.6" 325 | 326 | [package.extras] 327 | diagrams = ["jinja2", "railroad-diagrams"] 328 | 329 | [[package]] 330 | name = "pytest" 331 | version = "7.1.1" 332 | description = "pytest: simple powerful testing with Python" 333 | category = "dev" 334 | optional = false 335 | python-versions = ">=3.7" 336 | 337 | [package.dependencies] 338 | atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} 339 | attrs = ">=19.2.0" 340 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 341 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 342 | iniconfig = "*" 343 | packaging = "*" 344 | pluggy = ">=0.12,<2.0" 345 | py = ">=1.8.2" 346 | tomli = ">=1.0.0" 347 | 348 | [package.extras] 349 | testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] 350 | 351 | [[package]] 352 | name = "pytest-cov" 353 | version = "3.0.0" 354 | description = "Pytest plugin for measuring coverage." 355 | category = "dev" 356 | optional = false 357 | python-versions = ">=3.6" 358 | 359 | [package.dependencies] 360 | coverage = {version = ">=5.2.1", extras = ["toml"]} 361 | pytest = ">=4.6" 362 | 363 | [package.extras] 364 | testing = ["fields", "hunter", "process-tests", "six", "pytest-xdist", "virtualenv"] 365 | 366 | [[package]] 367 | name = "pytest-tldr" 368 | version = "0.2.4" 369 | description = "A pytest plugin that limits the output to just the things you need." 370 | category = "dev" 371 | optional = false 372 | python-versions = ">=3.6" 373 | 374 | [package.dependencies] 375 | pytest = ">=3.5.0" 376 | 377 | [[package]] 378 | name = "pytz" 379 | version = "2022.1" 380 | description = "World timezone definitions, modern and historical" 381 | category = "dev" 382 | optional = false 383 | python-versions = "*" 384 | 385 | [[package]] 386 | name = "six" 387 | version = "1.16.0" 388 | description = "Python 2 and 3 compatibility utilities" 389 | category = "main" 390 | optional = false 391 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" 392 | 393 | [[package]] 394 | name = "sqlparse" 395 | version = "0.4.2" 396 | description = "A non-validating SQL parser." 397 | category = "dev" 398 | optional = false 399 | python-versions = ">=3.5" 400 | 401 | [[package]] 402 | name = "tomli" 403 | version = "2.0.1" 404 | description = "A lil' TOML parser" 405 | category = "dev" 406 | optional = false 407 | python-versions = ">=3.7" 408 | 409 | [[package]] 410 | name = "typed-ast" 411 | version = "1.5.2" 412 | description = "a fork of Python 2 and 3 ast modules with type comment support" 413 | category = "dev" 414 | optional = false 415 | python-versions = ">=3.6" 416 | 417 | [[package]] 418 | name = "typing-extensions" 419 | version = "4.1.1" 420 | description = "Backported and Experimental Type Hints for Python 3.6+" 421 | category = "dev" 422 | optional = false 423 | python-versions = ">=3.6" 424 | 425 | [[package]] 426 | name = "unidecode" 427 | version = "1.3.4" 428 | description = "ASCII transliterations of Unicode text" 429 | category = "dev" 430 | optional = false 431 | python-versions = ">=3.5" 432 | 433 | [[package]] 434 | name = "wrapt" 435 | version = "1.14.0" 436 | description = "Module for decorators, wrappers and monkey patching." 437 | category = "dev" 438 | optional = false 439 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" 440 | 441 | [[package]] 442 | name = "zipp" 443 | version = "3.7.0" 444 | description = "Backport of pathlib-compatible object wrapper for zip files" 445 | category = "dev" 446 | optional = false 447 | python-versions = ">=3.7" 448 | 449 | [package.extras] 450 | docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] 451 | testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"] 452 | 453 | [metadata] 454 | lock-version = "1.1" 455 | python-versions = "^3.7" 456 | content-hash = "9348275036606def7f798fd802e9e233918c13e28edd85e8132dc15adaad7315" 457 | 458 | [metadata.files] 459 | asgiref = [ 460 | {file = "asgiref-3.5.0-py3-none-any.whl", hash = "sha256:88d59c13d634dcffe0510be048210188edd79aeccb6a6c9028cdad6f31d730a9"}, 461 | {file = "asgiref-3.5.0.tar.gz", hash = "sha256:2f8abc20f7248433085eda803936d98992f1343ddb022065779f37c5da0181d0"}, 462 | ] 463 | astroid = [ 464 | {file = "astroid-2.11.2-py3-none-any.whl", hash = "sha256:cc8cc0d2d916c42d0a7c476c57550a4557a083081976bf42a73414322a6411d9"}, 465 | {file = "astroid-2.11.2.tar.gz", hash = "sha256:8d0a30fe6481ce919f56690076eafbb2fb649142a89dc874f1ec0e7a011492d0"}, 466 | ] 467 | atomicwrites = [ 468 | {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, 469 | {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, 470 | ] 471 | attrs = [ 472 | {file = "attrs-21.4.0-py2.py3-none-any.whl", hash = "sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4"}, 473 | {file = "attrs-21.4.0.tar.gz", hash = "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd"}, 474 | ] 475 | black = [ 476 | {file = "black-22.3.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2497f9c2386572e28921fa8bec7be3e51de6801f7459dffd6e62492531c47e09"}, 477 | {file = "black-22.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5795a0375eb87bfe902e80e0c8cfaedf8af4d49694d69161e5bd3206c18618bb"}, 478 | {file = "black-22.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e3556168e2e5c49629f7b0f377070240bd5511e45e25a4497bb0073d9dda776a"}, 479 | {file = "black-22.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67c8301ec94e3bcc8906740fe071391bce40a862b7be0b86fb5382beefecd968"}, 480 | {file = "black-22.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:fd57160949179ec517d32ac2ac898b5f20d68ed1a9c977346efbac9c2f1e779d"}, 481 | {file = "black-22.3.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:cc1e1de68c8e5444e8f94c3670bb48a2beef0e91dddfd4fcc29595ebd90bb9ce"}, 482 | {file = "black-22.3.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d2fc92002d44746d3e7db7cf9313cf4452f43e9ea77a2c939defce3b10b5c82"}, 483 | {file = "black-22.3.0-cp36-cp36m-win_amd64.whl", hash = "sha256:a6342964b43a99dbc72f72812bf88cad8f0217ae9acb47c0d4f141a6416d2d7b"}, 484 | {file = "black-22.3.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:328efc0cc70ccb23429d6be184a15ce613f676bdfc85e5fe8ea2a9354b4e9015"}, 485 | {file = "black-22.3.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06f9d8846f2340dfac80ceb20200ea5d1b3f181dd0556b47af4e8e0b24fa0a6b"}, 486 | {file = "black-22.3.0-cp37-cp37m-win_amd64.whl", hash = "sha256:ad4efa5fad66b903b4a5f96d91461d90b9507a812b3c5de657d544215bb7877a"}, 487 | {file = "black-22.3.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e8477ec6bbfe0312c128e74644ac8a02ca06bcdb8982d4ee06f209be28cdf163"}, 488 | {file = "black-22.3.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:637a4014c63fbf42a692d22b55d8ad6968a946b4a6ebc385c5505d9625b6a464"}, 489 | {file = "black-22.3.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:863714200ada56cbc366dc9ae5291ceb936573155f8bf8e9de92aef51f3ad0f0"}, 490 | {file = "black-22.3.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10dbe6e6d2988049b4655b2b739f98785a884d4d6b85bc35133a8fb9a2233176"}, 491 | {file = "black-22.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:cee3e11161dde1b2a33a904b850b0899e0424cc331b7295f2a9698e79f9a69a0"}, 492 | {file = "black-22.3.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5891ef8abc06576985de8fa88e95ab70641de6c1fca97e2a15820a9b69e51b20"}, 493 | {file = "black-22.3.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:30d78ba6bf080eeaf0b7b875d924b15cd46fec5fd044ddfbad38c8ea9171043a"}, 494 | {file = "black-22.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ee8f1f7228cce7dffc2b464f07ce769f478968bfb3dd1254a4c2eeed84928aad"}, 495 | {file = "black-22.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6ee227b696ca60dd1c507be80a6bc849a5a6ab57ac7352aad1ffec9e8b805f21"}, 496 | {file = "black-22.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:9b542ced1ec0ceeff5b37d69838106a6348e60db7b8fdd245294dc1d26136265"}, 497 | {file = "black-22.3.0-py3-none-any.whl", hash = "sha256:bc58025940a896d7e5356952228b68f793cf5fcb342be703c3a2669a1488cb72"}, 498 | {file = "black-22.3.0.tar.gz", hash = "sha256:35020b8886c022ced9282b51b5a875b6d1ab0c387b31a065b84db7c33085ca79"}, 499 | ] 500 | click = [ 501 | {file = "click-8.1.1-py3-none-any.whl", hash = "sha256:5e0d195c2067da3136efb897449ec1e9e6c98282fbf30d7f9e164af9be901a6b"}, 502 | {file = "click-8.1.1.tar.gz", hash = "sha256:7ab900e38149c9872376e8f9b5986ddcaf68c0f413cf73678a0bca5547e6f976"}, 503 | ] 504 | colorama = [ 505 | {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, 506 | {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, 507 | ] 508 | coverage = [ 509 | {file = "coverage-6.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9b27d894748475fa858f9597c0ee1d4829f44683f3813633aaf94b19cb5453cf"}, 510 | {file = "coverage-6.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:37d1141ad6b2466a7b53a22e08fe76994c2d35a5b6b469590424a9953155afac"}, 511 | {file = "coverage-6.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9987b0354b06d4df0f4d3e0ec1ae76d7ce7cbca9a2f98c25041eb79eec766f1"}, 512 | {file = "coverage-6.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:26e2deacd414fc2f97dd9f7676ee3eaecd299ca751412d89f40bc01557a6b1b4"}, 513 | {file = "coverage-6.3.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4dd8bafa458b5c7d061540f1ee9f18025a68e2d8471b3e858a9dad47c8d41903"}, 514 | {file = "coverage-6.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:46191097ebc381fbf89bdce207a6c107ac4ec0890d8d20f3360345ff5976155c"}, 515 | {file = "coverage-6.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6f89d05e028d274ce4fa1a86887b071ae1755082ef94a6740238cd7a8178804f"}, 516 | {file = "coverage-6.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:58303469e9a272b4abdb9e302a780072c0633cdcc0165db7eec0f9e32f901e05"}, 517 | {file = "coverage-6.3.2-cp310-cp310-win32.whl", hash = "sha256:2fea046bfb455510e05be95e879f0e768d45c10c11509e20e06d8fcaa31d9e39"}, 518 | {file = "coverage-6.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:a2a8b8bcc399edb4347a5ca8b9b87e7524c0967b335fbb08a83c8421489ddee1"}, 519 | {file = "coverage-6.3.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:f1555ea6d6da108e1999b2463ea1003fe03f29213e459145e70edbaf3e004aaa"}, 520 | {file = "coverage-6.3.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e5f4e1edcf57ce94e5475fe09e5afa3e3145081318e5fd1a43a6b4539a97e518"}, 521 | {file = "coverage-6.3.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7a15dc0a14008f1da3d1ebd44bdda3e357dbabdf5a0b5034d38fcde0b5c234b7"}, 522 | {file = "coverage-6.3.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21b7745788866028adeb1e0eca3bf1101109e2dc58456cb49d2d9b99a8c516e6"}, 523 | {file = "coverage-6.3.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:8ce257cac556cb03be4a248d92ed36904a59a4a5ff55a994e92214cde15c5bad"}, 524 | {file = "coverage-6.3.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b0be84e5a6209858a1d3e8d1806c46214e867ce1b0fd32e4ea03f4bd8b2e3359"}, 525 | {file = "coverage-6.3.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:acf53bc2cf7282ab9b8ba346746afe703474004d9e566ad164c91a7a59f188a4"}, 526 | {file = "coverage-6.3.2-cp37-cp37m-win32.whl", hash = "sha256:8bdde1177f2311ee552f47ae6e5aa7750c0e3291ca6b75f71f7ffe1f1dab3dca"}, 527 | {file = "coverage-6.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:b31651d018b23ec463e95cf10070d0b2c548aa950a03d0b559eaa11c7e5a6fa3"}, 528 | {file = "coverage-6.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:07e6db90cd9686c767dcc593dff16c8c09f9814f5e9c51034066cad3373b914d"}, 529 | {file = "coverage-6.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2c6dbb42f3ad25760010c45191e9757e7dce981cbfb90e42feef301d71540059"}, 530 | {file = "coverage-6.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c76aeef1b95aff3905fb2ae2d96e319caca5b76fa41d3470b19d4e4a3a313512"}, 531 | {file = "coverage-6.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8cf5cfcb1521dc3255d845d9dca3ff204b3229401994ef8d1984b32746bb45ca"}, 532 | {file = "coverage-6.3.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8fbbdc8d55990eac1b0919ca69eb5a988a802b854488c34b8f37f3e2025fa90d"}, 533 | {file = "coverage-6.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:ec6bc7fe73a938933d4178c9b23c4e0568e43e220aef9472c4f6044bfc6dd0f0"}, 534 | {file = "coverage-6.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:9baff2a45ae1f17c8078452e9e5962e518eab705e50a0aa8083733ea7d45f3a6"}, 535 | {file = "coverage-6.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:fd9e830e9d8d89b20ab1e5af09b32d33e1a08ef4c4e14411e559556fd788e6b2"}, 536 | {file = "coverage-6.3.2-cp38-cp38-win32.whl", hash = "sha256:f7331dbf301b7289013175087636bbaf5b2405e57259dd2c42fdcc9fcc47325e"}, 537 | {file = "coverage-6.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:68353fe7cdf91f109fc7d474461b46e7f1f14e533e911a2a2cbb8b0fc8613cf1"}, 538 | {file = "coverage-6.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b78e5afb39941572209f71866aa0b206c12f0109835aa0d601e41552f9b3e620"}, 539 | {file = "coverage-6.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4e21876082ed887baed0146fe222f861b5815455ada3b33b890f4105d806128d"}, 540 | {file = "coverage-6.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34626a7eee2a3da12af0507780bb51eb52dca0e1751fd1471d0810539cefb536"}, 541 | {file = "coverage-6.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1ebf730d2381158ecf3dfd4453fbca0613e16eaa547b4170e2450c9707665ce7"}, 542 | {file = "coverage-6.3.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd6fe30bd519694b356cbfcaca9bd5c1737cddd20778c6a581ae20dc8c04def2"}, 543 | {file = "coverage-6.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:96f8a1cb43ca1422f36492bebe63312d396491a9165ed3b9231e778d43a7fca4"}, 544 | {file = "coverage-6.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:dd035edafefee4d573140a76fdc785dc38829fe5a455c4bb12bac8c20cfc3d69"}, 545 | {file = "coverage-6.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5ca5aeb4344b30d0bec47481536b8ba1181d50dbe783b0e4ad03c95dc1296684"}, 546 | {file = "coverage-6.3.2-cp39-cp39-win32.whl", hash = "sha256:f5fa5803f47e095d7ad8443d28b01d48c0359484fec1b9d8606d0e3282084bc4"}, 547 | {file = "coverage-6.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:9548f10d8be799551eb3a9c74bbf2b4934ddb330e08a73320123c07f95cc2d92"}, 548 | {file = "coverage-6.3.2-pp36.pp37.pp38-none-any.whl", hash = "sha256:18d520c6860515a771708937d2f78f63cc47ab3b80cb78e86573b0a760161faf"}, 549 | {file = "coverage-6.3.2.tar.gz", hash = "sha256:03e2a7826086b91ef345ff18742ee9fc47a6839ccd517061ef8fa1976e652ce9"}, 550 | ] 551 | dill = [ 552 | {file = "dill-0.3.4-py2.py3-none-any.whl", hash = "sha256:7e40e4a70304fd9ceab3535d36e58791d9c4a776b38ec7f7ec9afc8d3dca4d4f"}, 553 | {file = "dill-0.3.4.zip", hash = "sha256:9f9734205146b2b353ab3fec9af0070237b6ddae78452af83d2fca84d739e675"}, 554 | ] 555 | django = [ 556 | {file = "Django-3.2.12-py3-none-any.whl", hash = "sha256:9b06c289f9ba3a8abea16c9c9505f25107809fb933676f6c891ded270039d965"}, 557 | {file = "Django-3.2.12.tar.gz", hash = "sha256:9772e6935703e59e993960832d66a614cf0233a1c5123bc6224ecc6ad69e41e2"}, 558 | ] 559 | flake8 = [ 560 | {file = "flake8-4.0.1-py2.py3-none-any.whl", hash = "sha256:479b1304f72536a55948cb40a32dce8bb0ffe3501e26eaf292c7e60eb5e0428d"}, 561 | {file = "flake8-4.0.1.tar.gz", hash = "sha256:806e034dda44114815e23c16ef92f95c91e4c71100ff52813adf7132a6ad870d"}, 562 | ] 563 | importlib-metadata = [ 564 | {file = "importlib_metadata-4.2.0-py3-none-any.whl", hash = "sha256:057e92c15bc8d9e8109738a48db0ccb31b4d9d5cfbee5a8670879a30be66304b"}, 565 | {file = "importlib_metadata-4.2.0.tar.gz", hash = "sha256:b7e52a1f8dec14a75ea73e0891f3060099ca1d8e6a462a4dff11c3e119ea1b31"}, 566 | ] 567 | iniconfig = [ 568 | {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, 569 | {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, 570 | ] 571 | isort = [ 572 | {file = "isort-5.10.1-py3-none-any.whl", hash = "sha256:6f62d78e2f89b4500b080fe3a81690850cd254227f27f75c3a0c491a1f351ba7"}, 573 | {file = "isort-5.10.1.tar.gz", hash = "sha256:e8443a5e7a020e9d7f97f1d7d9cd17c88bcb3bc7e218bf9cf5095fe550be2951"}, 574 | ] 575 | lazy-object-proxy = [ 576 | {file = "lazy-object-proxy-1.7.1.tar.gz", hash = "sha256:d609c75b986def706743cdebe5e47553f4a5a1da9c5ff66d76013ef396b5a8a4"}, 577 | {file = "lazy_object_proxy-1.7.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bb8c5fd1684d60a9902c60ebe276da1f2281a318ca16c1d0a96db28f62e9166b"}, 578 | {file = "lazy_object_proxy-1.7.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a57d51ed2997e97f3b8e3500c984db50a554bb5db56c50b5dab1b41339b37e36"}, 579 | {file = "lazy_object_proxy-1.7.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd45683c3caddf83abbb1249b653a266e7069a09f486daa8863fb0e7496a9fdb"}, 580 | {file = "lazy_object_proxy-1.7.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:8561da8b3dd22d696244d6d0d5330618c993a215070f473b699e00cf1f3f6443"}, 581 | {file = "lazy_object_proxy-1.7.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fccdf7c2c5821a8cbd0a9440a456f5050492f2270bd54e94360cac663398739b"}, 582 | {file = "lazy_object_proxy-1.7.1-cp310-cp310-win32.whl", hash = "sha256:898322f8d078f2654d275124a8dd19b079080ae977033b713f677afcfc88e2b9"}, 583 | {file = "lazy_object_proxy-1.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:85b232e791f2229a4f55840ed54706110c80c0a210d076eee093f2b2e33e1bfd"}, 584 | {file = "lazy_object_proxy-1.7.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:46ff647e76f106bb444b4533bb4153c7370cdf52efc62ccfc1a28bdb3cc95442"}, 585 | {file = "lazy_object_proxy-1.7.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:12f3bb77efe1367b2515f8cb4790a11cffae889148ad33adad07b9b55e0ab22c"}, 586 | {file = "lazy_object_proxy-1.7.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c19814163728941bb871240d45c4c30d33b8a2e85972c44d4e63dd7107faba44"}, 587 | {file = "lazy_object_proxy-1.7.1-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:e40f2013d96d30217a51eeb1db28c9ac41e9d0ee915ef9d00da639c5b63f01a1"}, 588 | {file = "lazy_object_proxy-1.7.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:2052837718516a94940867e16b1bb10edb069ab475c3ad84fd1e1a6dd2c0fcfc"}, 589 | {file = "lazy_object_proxy-1.7.1-cp36-cp36m-win32.whl", hash = "sha256:6a24357267aa976abab660b1d47a34aaf07259a0c3859a34e536f1ee6e76b5bb"}, 590 | {file = "lazy_object_proxy-1.7.1-cp36-cp36m-win_amd64.whl", hash = "sha256:6aff3fe5de0831867092e017cf67e2750c6a1c7d88d84d2481bd84a2e019ec35"}, 591 | {file = "lazy_object_proxy-1.7.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:6a6e94c7b02641d1311228a102607ecd576f70734dc3d5e22610111aeacba8a0"}, 592 | {file = "lazy_object_proxy-1.7.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c4ce15276a1a14549d7e81c243b887293904ad2d94ad767f42df91e75fd7b5b6"}, 593 | {file = "lazy_object_proxy-1.7.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e368b7f7eac182a59ff1f81d5f3802161932a41dc1b1cc45c1f757dc876b5d2c"}, 594 | {file = "lazy_object_proxy-1.7.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:6ecbb350991d6434e1388bee761ece3260e5228952b1f0c46ffc800eb313ff42"}, 595 | {file = "lazy_object_proxy-1.7.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:553b0f0d8dbf21890dd66edd771f9b1b5f51bd912fa5f26de4449bfc5af5e029"}, 596 | {file = "lazy_object_proxy-1.7.1-cp37-cp37m-win32.whl", hash = "sha256:c7a683c37a8a24f6428c28c561c80d5f4fd316ddcf0c7cab999b15ab3f5c5c69"}, 597 | {file = "lazy_object_proxy-1.7.1-cp37-cp37m-win_amd64.whl", hash = "sha256:df2631f9d67259dc9620d831384ed7732a198eb434eadf69aea95ad18c587a28"}, 598 | {file = "lazy_object_proxy-1.7.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:07fa44286cda977bd4803b656ffc1c9b7e3bc7dff7d34263446aec8f8c96f88a"}, 599 | {file = "lazy_object_proxy-1.7.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4dca6244e4121c74cc20542c2ca39e5c4a5027c81d112bfb893cf0790f96f57e"}, 600 | {file = "lazy_object_proxy-1.7.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:91ba172fc5b03978764d1df5144b4ba4ab13290d7bab7a50f12d8117f8630c38"}, 601 | {file = "lazy_object_proxy-1.7.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:043651b6cb706eee4f91854da4a089816a6606c1428fd391573ef8cb642ae4f7"}, 602 | {file = "lazy_object_proxy-1.7.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b9e89b87c707dd769c4ea91f7a31538888aad05c116a59820f28d59b3ebfe25a"}, 603 | {file = "lazy_object_proxy-1.7.1-cp38-cp38-win32.whl", hash = "sha256:9d166602b525bf54ac994cf833c385bfcc341b364e3ee71e3bf5a1336e677b55"}, 604 | {file = "lazy_object_proxy-1.7.1-cp38-cp38-win_amd64.whl", hash = "sha256:8f3953eb575b45480db6568306893f0bd9d8dfeeebd46812aa09ca9579595148"}, 605 | {file = "lazy_object_proxy-1.7.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:dd7ed7429dbb6c494aa9bc4e09d94b778a3579be699f9d67da7e6804c422d3de"}, 606 | {file = "lazy_object_proxy-1.7.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:70ed0c2b380eb6248abdef3cd425fc52f0abd92d2b07ce26359fcbc399f636ad"}, 607 | {file = "lazy_object_proxy-1.7.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7096a5e0c1115ec82641afbdd70451a144558ea5cf564a896294e346eb611be1"}, 608 | {file = "lazy_object_proxy-1.7.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:f769457a639403073968d118bc70110e7dce294688009f5c24ab78800ae56dc8"}, 609 | {file = "lazy_object_proxy-1.7.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:39b0e26725c5023757fc1ab2a89ef9d7ab23b84f9251e28f9cc114d5b59c1b09"}, 610 | {file = "lazy_object_proxy-1.7.1-cp39-cp39-win32.whl", hash = "sha256:2130db8ed69a48a3440103d4a520b89d8a9405f1b06e2cc81640509e8bf6548f"}, 611 | {file = "lazy_object_proxy-1.7.1-cp39-cp39-win_amd64.whl", hash = "sha256:677ea950bef409b47e51e733283544ac3d660b709cfce7b187f5ace137960d61"}, 612 | {file = "lazy_object_proxy-1.7.1-pp37.pp38-none-any.whl", hash = "sha256:d66906d5785da8e0be7360912e99c9188b70f52c422f9fc18223347235691a84"}, 613 | ] 614 | mccabe = [ 615 | {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, 616 | {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, 617 | ] 618 | mypy-extensions = [ 619 | {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, 620 | {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, 621 | ] 622 | packaging = [ 623 | {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"}, 624 | {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, 625 | ] 626 | pathspec = [ 627 | {file = "pathspec-0.9.0-py2.py3-none-any.whl", hash = "sha256:7d15c4ddb0b5c802d161efc417ec1a2558ea2653c2e8ad9c19098201dc1c993a"}, 628 | {file = "pathspec-0.9.0.tar.gz", hash = "sha256:e564499435a2673d586f6b2130bb5b95f04a3ba06f81b8f895b651a3c76aabb1"}, 629 | ] 630 | platformdirs = [ 631 | {file = "platformdirs-2.5.1-py3-none-any.whl", hash = "sha256:bcae7cab893c2d310a711b70b24efb93334febe65f8de776ee320b517471e227"}, 632 | {file = "platformdirs-2.5.1.tar.gz", hash = "sha256:7535e70dfa32e84d4b34996ea99c5e432fa29a708d0f4e394bbcb2a8faa4f16d"}, 633 | ] 634 | pluggy = [ 635 | {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, 636 | {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, 637 | ] 638 | preggy = [ 639 | {file = "preggy-1.4.4.tar.gz", hash = "sha256:25ba803afde4f35ef543a60915ced2e634926235064df717c3cb3e4e3eb4670c"}, 640 | ] 641 | py = [ 642 | {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, 643 | {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, 644 | ] 645 | pycodestyle = [ 646 | {file = "pycodestyle-2.8.0-py2.py3-none-any.whl", hash = "sha256:720f8b39dde8b293825e7ff02c475f3077124006db4f440dcbc9a20b76548a20"}, 647 | {file = "pycodestyle-2.8.0.tar.gz", hash = "sha256:eddd5847ef438ea1c7870ca7eb78a9d47ce0cdb4851a5523949f2601d0cbbe7f"}, 648 | ] 649 | pyflakes = [ 650 | {file = "pyflakes-2.4.0-py2.py3-none-any.whl", hash = "sha256:3bb3a3f256f4b7968c9c788781e4ff07dce46bdf12339dcda61053375426ee2e"}, 651 | {file = "pyflakes-2.4.0.tar.gz", hash = "sha256:05a85c2872edf37a4ed30b0cce2f6093e1d0581f8c19d7393122da7e25b2b24c"}, 652 | ] 653 | pylint = [ 654 | {file = "pylint-2.13.4-py3-none-any.whl", hash = "sha256:8672cf7441b81410f5de7defdf56e2d559c956fd0579652f2e0a0a35bea2d546"}, 655 | {file = "pylint-2.13.4.tar.gz", hash = "sha256:7cc6d0c4f61dff440f9ed8b657f4ecd615dcfe35345953eb7b1dc74afe901d7a"}, 656 | ] 657 | pyparsing = [ 658 | {file = "pyparsing-3.0.7-py3-none-any.whl", hash = "sha256:a6c06a88f252e6c322f65faf8f418b16213b51bdfaece0524c1c1bc30c63c484"}, 659 | {file = "pyparsing-3.0.7.tar.gz", hash = "sha256:18ee9022775d270c55187733956460083db60b37d0d0fb357445f3094eed3eea"}, 660 | ] 661 | pytest = [ 662 | {file = "pytest-7.1.1-py3-none-any.whl", hash = "sha256:92f723789a8fdd7180b6b06483874feca4c48a5c76968e03bb3e7f806a1869ea"}, 663 | {file = "pytest-7.1.1.tar.gz", hash = "sha256:841132caef6b1ad17a9afde46dc4f6cfa59a05f9555aae5151f73bdf2820ca63"}, 664 | ] 665 | pytest-cov = [ 666 | {file = "pytest-cov-3.0.0.tar.gz", hash = "sha256:e7f0f5b1617d2210a2cabc266dfe2f4c75a8d32fb89eafb7ad9d06f6d076d470"}, 667 | {file = "pytest_cov-3.0.0-py3-none-any.whl", hash = "sha256:578d5d15ac4a25e5f961c938b85a05b09fdaae9deef3bb6de9a6e766622ca7a6"}, 668 | ] 669 | pytest-tldr = [ 670 | {file = "pytest-tldr-0.2.4.tar.gz", hash = "sha256:764f928e9ecdc615c2b4e6f78cee3bb865c76a08c57fdf93ee5f0ee99f5f25ee"}, 671 | {file = "pytest_tldr-0.2.4-py3-none-any.whl", hash = "sha256:f538fe13764a6ea512a0b7d1c874ba8088a5c541d885cba4096fd7593cbd2943"}, 672 | ] 673 | pytz = [ 674 | {file = "pytz-2022.1-py2.py3-none-any.whl", hash = "sha256:e68985985296d9a66a881eb3193b0906246245294a881e7c8afe623866ac6a5c"}, 675 | {file = "pytz-2022.1.tar.gz", hash = "sha256:1e760e2fe6a8163bc0b3d9a19c4f84342afa0a2affebfaa84b01b978a02ecaa7"}, 676 | ] 677 | six = [ 678 | {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, 679 | {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, 680 | ] 681 | sqlparse = [ 682 | {file = "sqlparse-0.4.2-py3-none-any.whl", hash = "sha256:48719e356bb8b42991bdbb1e8b83223757b93789c00910a616a071910ca4a64d"}, 683 | {file = "sqlparse-0.4.2.tar.gz", hash = "sha256:0c00730c74263a94e5a9919ade150dfc3b19c574389985446148402998287dae"}, 684 | ] 685 | tomli = [ 686 | {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, 687 | {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, 688 | ] 689 | typed-ast = [ 690 | {file = "typed_ast-1.5.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:183b183b7771a508395d2cbffd6db67d6ad52958a5fdc99f450d954003900266"}, 691 | {file = "typed_ast-1.5.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:676d051b1da67a852c0447621fdd11c4e104827417bf216092ec3e286f7da596"}, 692 | {file = "typed_ast-1.5.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc2542e83ac8399752bc16e0b35e038bdb659ba237f4222616b4e83fb9654985"}, 693 | {file = "typed_ast-1.5.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:74cac86cc586db8dfda0ce65d8bcd2bf17b58668dfcc3652762f3ef0e6677e76"}, 694 | {file = "typed_ast-1.5.2-cp310-cp310-win_amd64.whl", hash = "sha256:18fe320f354d6f9ad3147859b6e16649a0781425268c4dde596093177660e71a"}, 695 | {file = "typed_ast-1.5.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:31d8c6b2df19a777bc8826770b872a45a1f30cfefcfd729491baa5237faae837"}, 696 | {file = "typed_ast-1.5.2-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:963a0ccc9a4188524e6e6d39b12c9ca24cc2d45a71cfdd04a26d883c922b4b78"}, 697 | {file = "typed_ast-1.5.2-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:0eb77764ea470f14fcbb89d51bc6bbf5e7623446ac4ed06cbd9ca9495b62e36e"}, 698 | {file = "typed_ast-1.5.2-cp36-cp36m-win_amd64.whl", hash = "sha256:294a6903a4d087db805a7656989f613371915fc45c8cc0ddc5c5a0a8ad9bea4d"}, 699 | {file = "typed_ast-1.5.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:26a432dc219c6b6f38be20a958cbe1abffcc5492821d7e27f08606ef99e0dffd"}, 700 | {file = "typed_ast-1.5.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c7407cfcad702f0b6c0e0f3e7ab876cd1d2c13b14ce770e412c0c4b9728a0f88"}, 701 | {file = "typed_ast-1.5.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f30ddd110634c2d7534b2d4e0e22967e88366b0d356b24de87419cc4410c41b7"}, 702 | {file = "typed_ast-1.5.2-cp37-cp37m-win_amd64.whl", hash = "sha256:8c08d6625bb258179b6e512f55ad20f9dfef019bbfbe3095247401e053a3ea30"}, 703 | {file = "typed_ast-1.5.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:90904d889ab8e81a956f2c0935a523cc4e077c7847a836abee832f868d5c26a4"}, 704 | {file = "typed_ast-1.5.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:bbebc31bf11762b63bf61aaae232becb41c5bf6b3461b80a4df7e791fabb3aca"}, 705 | {file = "typed_ast-1.5.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c29dd9a3a9d259c9fa19d19738d021632d673f6ed9b35a739f48e5f807f264fb"}, 706 | {file = "typed_ast-1.5.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:58ae097a325e9bb7a684572d20eb3e1809802c5c9ec7108e85da1eb6c1a3331b"}, 707 | {file = "typed_ast-1.5.2-cp38-cp38-win_amd64.whl", hash = "sha256:da0a98d458010bf4fe535f2d1e367a2e2060e105978873c04c04212fb20543f7"}, 708 | {file = "typed_ast-1.5.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:33b4a19ddc9fc551ebabca9765d54d04600c4a50eda13893dadf67ed81d9a098"}, 709 | {file = "typed_ast-1.5.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1098df9a0592dd4c8c0ccfc2e98931278a6c6c53cb3a3e2cf7e9ee3b06153344"}, 710 | {file = "typed_ast-1.5.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42c47c3b43fe3a39ddf8de1d40dbbfca60ac8530a36c9b198ea5b9efac75c09e"}, 711 | {file = "typed_ast-1.5.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f290617f74a610849bd8f5514e34ae3d09eafd521dceaa6cf68b3f4414266d4e"}, 712 | {file = "typed_ast-1.5.2-cp39-cp39-win_amd64.whl", hash = "sha256:df05aa5b241e2e8045f5f4367a9f6187b09c4cdf8578bb219861c4e27c443db5"}, 713 | {file = "typed_ast-1.5.2.tar.gz", hash = "sha256:525a2d4088e70a9f75b08b3f87a51acc9cde640e19cc523c7e41aa355564ae27"}, 714 | ] 715 | typing-extensions = [ 716 | {file = "typing_extensions-4.1.1-py3-none-any.whl", hash = "sha256:21c85e0fe4b9a155d0799430b0ad741cdce7e359660ccbd8b530613e8df88ce2"}, 717 | {file = "typing_extensions-4.1.1.tar.gz", hash = "sha256:1a9462dcc3347a79b1f1c0271fbe79e844580bb598bafa1ed208b94da3cdcd42"}, 718 | ] 719 | unidecode = [ 720 | {file = "Unidecode-1.3.4-py3-none-any.whl", hash = "sha256:afa04efcdd818a93237574791be9b2817d7077c25a068b00f8cff7baa4e59257"}, 721 | {file = "Unidecode-1.3.4.tar.gz", hash = "sha256:8e4352fb93d5a735c788110d2e7ac8e8031eb06ccbfe8d324ab71735015f9342"}, 722 | ] 723 | wrapt = [ 724 | {file = "wrapt-1.14.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:5a9a1889cc01ed2ed5f34574c90745fab1dd06ec2eee663e8ebeefe363e8efd7"}, 725 | {file = "wrapt-1.14.0-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:9a3ff5fb015f6feb78340143584d9f8a0b91b6293d6b5cf4295b3e95d179b88c"}, 726 | {file = "wrapt-1.14.0-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:4b847029e2d5e11fd536c9ac3136ddc3f54bc9488a75ef7d040a3900406a91eb"}, 727 | {file = "wrapt-1.14.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:9a5a544861b21e0e7575b6023adebe7a8c6321127bb1d238eb40d99803a0e8bd"}, 728 | {file = "wrapt-1.14.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:88236b90dda77f0394f878324cfbae05ae6fde8a84d548cfe73a75278d760291"}, 729 | {file = "wrapt-1.14.0-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:f0408e2dbad9e82b4c960274214af533f856a199c9274bd4aff55d4634dedc33"}, 730 | {file = "wrapt-1.14.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:9d8c68c4145041b4eeae96239802cfdfd9ef927754a5be3f50505f09f309d8c6"}, 731 | {file = "wrapt-1.14.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:22626dca56fd7f55a0733e604f1027277eb0f4f3d95ff28f15d27ac25a45f71b"}, 732 | {file = "wrapt-1.14.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:65bf3eb34721bf18b5a021a1ad7aa05947a1767d1aa272b725728014475ea7d5"}, 733 | {file = "wrapt-1.14.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:09d16ae7a13cff43660155383a2372b4aa09109c7127aa3f24c3cf99b891c330"}, 734 | {file = "wrapt-1.14.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:debaf04f813ada978d7d16c7dfa16f3c9c2ec9adf4656efdc4defdf841fc2f0c"}, 735 | {file = "wrapt-1.14.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:748df39ed634851350efa87690c2237a678ed794fe9ede3f0d79f071ee042561"}, 736 | {file = "wrapt-1.14.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1807054aa7b61ad8d8103b3b30c9764de2e9d0c0978e9d3fc337e4e74bf25faa"}, 737 | {file = "wrapt-1.14.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:763a73ab377390e2af26042f685a26787c402390f682443727b847e9496e4a2a"}, 738 | {file = "wrapt-1.14.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:8529b07b49b2d89d6917cfa157d3ea1dfb4d319d51e23030664a827fe5fd2131"}, 739 | {file = "wrapt-1.14.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:68aeefac31c1f73949662ba8affaf9950b9938b712fb9d428fa2a07e40ee57f8"}, 740 | {file = "wrapt-1.14.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:59d7d92cee84a547d91267f0fea381c363121d70fe90b12cd88241bd9b0e1763"}, 741 | {file = "wrapt-1.14.0-cp310-cp310-win32.whl", hash = "sha256:3a88254881e8a8c4784ecc9cb2249ff757fd94b911d5df9a5984961b96113fff"}, 742 | {file = "wrapt-1.14.0-cp310-cp310-win_amd64.whl", hash = "sha256:9a242871b3d8eecc56d350e5e03ea1854de47b17f040446da0e47dc3e0b9ad4d"}, 743 | {file = "wrapt-1.14.0-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:a65bffd24409454b889af33b6c49d0d9bcd1a219b972fba975ac935f17bdf627"}, 744 | {file = "wrapt-1.14.0-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:9d9fcd06c952efa4b6b95f3d788a819b7f33d11bea377be6b8980c95e7d10775"}, 745 | {file = "wrapt-1.14.0-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:db6a0ddc1282ceb9032e41853e659c9b638789be38e5b8ad7498caac00231c23"}, 746 | {file = "wrapt-1.14.0-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:14e7e2c5f5fca67e9a6d5f753d21f138398cad2b1159913ec9e9a67745f09ba3"}, 747 | {file = "wrapt-1.14.0-cp35-cp35m-win32.whl", hash = "sha256:6d9810d4f697d58fd66039ab959e6d37e63ab377008ef1d63904df25956c7db0"}, 748 | {file = "wrapt-1.14.0-cp35-cp35m-win_amd64.whl", hash = "sha256:d808a5a5411982a09fef6b49aac62986274ab050e9d3e9817ad65b2791ed1425"}, 749 | {file = "wrapt-1.14.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:b77159d9862374da213f741af0c361720200ab7ad21b9f12556e0eb95912cd48"}, 750 | {file = "wrapt-1.14.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:36a76a7527df8583112b24adc01748cd51a2d14e905b337a6fefa8b96fc708fb"}, 751 | {file = "wrapt-1.14.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a0057b5435a65b933cbf5d859cd4956624df37b8bf0917c71756e4b3d9958b9e"}, 752 | {file = "wrapt-1.14.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a0a4ca02752ced5f37498827e49c414d694ad7cf451ee850e3ff160f2bee9d3"}, 753 | {file = "wrapt-1.14.0-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:8c6be72eac3c14baa473620e04f74186c5d8f45d80f8f2b4eda6e1d18af808e8"}, 754 | {file = "wrapt-1.14.0-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:21b1106bff6ece8cb203ef45b4f5778d7226c941c83aaaa1e1f0f4f32cc148cd"}, 755 | {file = "wrapt-1.14.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:493da1f8b1bb8a623c16552fb4a1e164c0200447eb83d3f68b44315ead3f9036"}, 756 | {file = "wrapt-1.14.0-cp36-cp36m-win32.whl", hash = "sha256:89ba3d548ee1e6291a20f3c7380c92f71e358ce8b9e48161401e087e0bc740f8"}, 757 | {file = "wrapt-1.14.0-cp36-cp36m-win_amd64.whl", hash = "sha256:729d5e96566f44fccac6c4447ec2332636b4fe273f03da128fff8d5559782b06"}, 758 | {file = "wrapt-1.14.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:891c353e95bb11abb548ca95c8b98050f3620a7378332eb90d6acdef35b401d4"}, 759 | {file = "wrapt-1.14.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23f96134a3aa24cc50614920cc087e22f87439053d886e474638c68c8d15dc80"}, 760 | {file = "wrapt-1.14.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6807bcee549a8cb2f38f73f469703a1d8d5d990815c3004f21ddb68a567385ce"}, 761 | {file = "wrapt-1.14.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6915682f9a9bc4cf2908e83caf5895a685da1fbd20b6d485dafb8e218a338279"}, 762 | {file = "wrapt-1.14.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:f2f3bc7cd9c9fcd39143f11342eb5963317bd54ecc98e3650ca22704b69d9653"}, 763 | {file = "wrapt-1.14.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:3a71dbd792cc7a3d772ef8cd08d3048593f13d6f40a11f3427c000cf0a5b36a0"}, 764 | {file = "wrapt-1.14.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:5a0898a640559dec00f3614ffb11d97a2666ee9a2a6bad1259c9facd01a1d4d9"}, 765 | {file = "wrapt-1.14.0-cp37-cp37m-win32.whl", hash = "sha256:167e4793dc987f77fd476862d32fa404d42b71f6a85d3b38cbce711dba5e6b68"}, 766 | {file = "wrapt-1.14.0-cp37-cp37m-win_amd64.whl", hash = "sha256:d066ffc5ed0be00cd0352c95800a519cf9e4b5dd34a028d301bdc7177c72daf3"}, 767 | {file = "wrapt-1.14.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d9bdfa74d369256e4218000a629978590fd7cb6cf6893251dad13d051090436d"}, 768 | {file = "wrapt-1.14.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2498762814dd7dd2a1d0248eda2afbc3dd9c11537bc8200a4b21789b6df6cd38"}, 769 | {file = "wrapt-1.14.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f24ca7953f2643d59a9c87d6e272d8adddd4a53bb62b9208f36db408d7aafc7"}, 770 | {file = "wrapt-1.14.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5b835b86bd5a1bdbe257d610eecab07bf685b1af2a7563093e0e69180c1d4af1"}, 771 | {file = "wrapt-1.14.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b21650fa6907e523869e0396c5bd591cc326e5c1dd594dcdccac089561cacfb8"}, 772 | {file = "wrapt-1.14.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:354d9fc6b1e44750e2a67b4b108841f5f5ea08853453ecbf44c81fdc2e0d50bd"}, 773 | {file = "wrapt-1.14.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:1f83e9c21cd5275991076b2ba1cd35418af3504667affb4745b48937e214bafe"}, 774 | {file = "wrapt-1.14.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:61e1a064906ccba038aa3c4a5a82f6199749efbbb3cef0804ae5c37f550eded0"}, 775 | {file = "wrapt-1.14.0-cp38-cp38-win32.whl", hash = "sha256:28c659878f684365d53cf59dc9a1929ea2eecd7ac65da762be8b1ba193f7e84f"}, 776 | {file = "wrapt-1.14.0-cp38-cp38-win_amd64.whl", hash = "sha256:b0ed6ad6c9640671689c2dbe6244680fe8b897c08fd1fab2228429b66c518e5e"}, 777 | {file = "wrapt-1.14.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b3f7e671fb19734c872566e57ce7fc235fa953d7c181bb4ef138e17d607dc8a1"}, 778 | {file = "wrapt-1.14.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:87fa943e8bbe40c8c1ba4086971a6fefbf75e9991217c55ed1bcb2f1985bd3d4"}, 779 | {file = "wrapt-1.14.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4775a574e9d84e0212f5b18886cace049a42e13e12009bb0491562a48bb2b758"}, 780 | {file = "wrapt-1.14.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9d57677238a0c5411c76097b8b93bdebb02eb845814c90f0b01727527a179e4d"}, 781 | {file = "wrapt-1.14.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00108411e0f34c52ce16f81f1d308a571df7784932cc7491d1e94be2ee93374b"}, 782 | {file = "wrapt-1.14.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d332eecf307fca852d02b63f35a7872de32d5ba8b4ec32da82f45df986b39ff6"}, 783 | {file = "wrapt-1.14.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:01f799def9b96a8ec1ef6b9c1bbaf2bbc859b87545efbecc4a78faea13d0e3a0"}, 784 | {file = "wrapt-1.14.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:47045ed35481e857918ae78b54891fac0c1d197f22c95778e66302668309336c"}, 785 | {file = "wrapt-1.14.0-cp39-cp39-win32.whl", hash = "sha256:2eca15d6b947cfff51ed76b2d60fd172c6ecd418ddab1c5126032d27f74bc350"}, 786 | {file = "wrapt-1.14.0-cp39-cp39-win_amd64.whl", hash = "sha256:bb36fbb48b22985d13a6b496ea5fb9bb2a076fea943831643836c9f6febbcfdc"}, 787 | {file = "wrapt-1.14.0.tar.gz", hash = "sha256:8323a43bd9c91f62bb7d4be74cc9ff10090e7ef820e27bfe8815c57e68261311"}, 788 | ] 789 | zipp = [ 790 | {file = "zipp-3.7.0-py3-none-any.whl", hash = "sha256:b47250dd24f92b7dd6a0a8fc5244da14608f3ca90a5efcd37a3b1642fac9a375"}, 791 | {file = "zipp-3.7.0.tar.gz", hash = "sha256:9f50f446828eb9d45b267433fd3e9da8d801f614129124863f9c51ebceafb87d"}, 792 | ] 793 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "libthumbor" 3 | version = "2.0.2" 4 | description = "libthumbor is the python extension to generate thumbor URLs" 5 | authors = ["Bernardo Heynemann "] 6 | readme = "README.md" 7 | keywords = ["imaging", "face", "detection", "feature", "thumbor", "thumbnail", "imagemagick", "pil", "opencv"] 8 | license = "MIT" 9 | 10 | [tool.poetry.dependencies] 11 | python = "^3.7" 12 | six = "^1.14.0" 13 | 14 | [tool.poetry.dev-dependencies] 15 | pytest = "^7.1.1" 16 | pytest-tldr = "^0.2.1" 17 | black = "^22.3.0" 18 | flake8 = "^4.0.1" 19 | pylint = "^2.13.4" 20 | preggy = "^1.4.4" 21 | pytest-cov = "^3.0" 22 | django = "^3.0.3" 23 | coverage = "^6.3.2" 24 | 25 | [tool.black] 26 | line-length = 88 27 | target-version = ['py37'] 28 | include = '\.pyi?$' 29 | exclude = ''' 30 | 31 | ( 32 | /( 33 | \.eggs # exclude a few common directories in the 34 | | \.git # root of the project 35 | | \.hg 36 | | \.mypy_cache 37 | | \.tox 38 | | \.venv 39 | | _build 40 | | buck-out 41 | | build 42 | | dist 43 | )/ 44 | ) 45 | ''' 46 | 47 | [build-system] 48 | requires = ["poetry>=0.12"] 49 | build-backend = "poetry.masonry.api" 50 | -------------------------------------------------------------------------------- /test_requirements.txt: -------------------------------------------------------------------------------- 1 | nose 2 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thumbor/libthumbor/b41d1712d64dc5073b65958bb0bcb6f3fce22256/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_cryptourl.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | 4 | # libthumbor - python extension to thumbor 5 | # http://github.com/heynemann/libthumbor 6 | 7 | # Licensed under the MIT license: 8 | # http://www.opensource.org/licenses/mit-license 9 | # Copyright (c) 2011 Bernardo Heynemann heynemann@gmail.com 10 | 11 | """libthumbor cryptography tests""" 12 | 13 | from unittest import TestCase 14 | 15 | from preggy import expect 16 | from six import ensure_text 17 | 18 | from libthumbor.crypto import CryptoURL 19 | 20 | IMAGE_URL = "my.server.com/some/path/to/image.jpg" 21 | KEY = b"my-security-key" 22 | 23 | 24 | class NewFormatUrlTestsMixin: 25 | def test_generated_url_1(self): 26 | url = self.crypto.generate(image_url=IMAGE_URL, width=300, height=200) 27 | expect(url).to_equal( 28 | "/8ammJH8D-7tXy6kU3lTvoXlhu4o=/300x200/my.server.com/some/path/to/image.jpg" 29 | ) 30 | 31 | def test_generated_url_2(self): 32 | url = self.crypto.generate( 33 | image_url=IMAGE_URL, width=300, height=200, crop=((10, 10), (200, 200)) 34 | ) 35 | expect(url).to_equal( 36 | "/B35oBEIwztbc3jm7vsdqLez2C78=/10x10:200x200/300x200/" 37 | "my.server.com/some/path/to/image.jpg" 38 | ) 39 | 40 | def test_generated_url_3(self): 41 | url = self.crypto.generate( 42 | image_url=IMAGE_URL, 43 | width=300, 44 | height=200, 45 | crop=((10, 10), (200, 200)), 46 | filters=("brightness(20)", "contrast(10)"), 47 | ) 48 | expect(url).to_equal( 49 | "/as8U2DbUUtTMgvPF26LkjS3MocY=/10x10:200x200/300x200/" 50 | "filters:brightness(20):contrast(10)/my.server.com/some/path/to/image.jpg" 51 | ) 52 | 53 | def test_generated_url_4(self): 54 | url = self.crypto.generate( 55 | image_url=IMAGE_URL, 56 | width=300, 57 | height=200, 58 | crop=((10, 10), (200, 200)), 59 | filters=("brightness(20)", "contrast(10)"), 60 | ) 61 | expect(url).to_equal( 62 | "/as8U2DbUUtTMgvPF26LkjS3MocY=/10x10:200x200/300x200/" 63 | "filters:brightness(20):contrast(10)/my.server.com/some/path/to/image.jpg" 64 | ) 65 | # making sure no internal state affects subsequent calls. 66 | url = self.crypto.generate( 67 | image_url=IMAGE_URL, 68 | width=300, 69 | height=200, 70 | crop=((10, 10), (200, 200)), 71 | filters=("brightness(20)", "contrast(10)"), 72 | ) 73 | expect(url).to_equal( 74 | "/as8U2DbUUtTMgvPF26LkjS3MocY=/10x10:200x200/300x200/" 75 | "filters:brightness(20):contrast(10)/my.server.com/some/path/to/image.jpg" 76 | ) 77 | 78 | 79 | class NewFormatUrl(TestCase, NewFormatUrlTestsMixin): 80 | def setUp(self): 81 | self.crypto = CryptoURL(KEY) 82 | 83 | 84 | class NewFormatUrlWithUnicodeKey(TestCase, NewFormatUrlTestsMixin): 85 | def setUp(self): 86 | self.crypto = CryptoURL(ensure_text(KEY)) 87 | 88 | 89 | class GenerateWithUnsafeTestCase(TestCase): 90 | def setUp(self): 91 | self.crypto = CryptoURL(KEY) 92 | 93 | def test_should_pass_unsafe_to_generate_and_get_an_unsafe_url(self): 94 | url = self.crypto.generate( 95 | image_url=IMAGE_URL, crop=((10, 20), (30, 40)), unsafe=True 96 | ) 97 | expect(url.startswith("unsafe")).to_be_true() 98 | 99 | def test_should_not_get_an_unsafe_url_when_unsafe_is_false(self): 100 | url = self.crypto.generate( 101 | image_url=IMAGE_URL, crop=((10, 20), (30, 40)), unsafe=False 102 | ) 103 | expect(url.startswith("unsafe")).to_be_false() 104 | -------------------------------------------------------------------------------- /tests/test_generic_views.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | 4 | # libthumbor - python extension to thumbor 5 | # http://github.com/heynemann/libthumbor 6 | 7 | # Licensed under the MIT license: 8 | # http://www.opensource.org/licenses/mit-license 9 | # Copyright (c) 2011 Bernardo Heynemann heynemann@gmail.com 10 | 11 | """libthumbor generic views tests""" 12 | 13 | import os 14 | 15 | import pytest 16 | from preggy import expect 17 | 18 | from libthumbor.crypto import CryptoURL 19 | 20 | os.environ["DJANGO_SETTINGS_MODULE"] = "tests.testproj.testproj.settings" 21 | 22 | try: 23 | from django.conf import settings 24 | from django.test import TestCase 25 | from django.http import QueryDict 26 | 27 | DJANGO_PRESENT = True 28 | except ImportError: 29 | DJANGO_PRESENT = False 30 | 31 | 32 | HTTP_NOT_FOUND = 404 33 | HTTP_METHOD_NOT_ALLOWED = 405 34 | HTTP_OK = 200 35 | HTTP_BAD_REQUEST = 400 36 | 37 | 38 | @pytest.mark.skip_if(not DJANGO_PRESENT, "django must be present to run this test") 39 | class GenericViewsTestCase(TestCase): 40 | def setUp(self): 41 | self.url_query = QueryDict("", mutable=True) 42 | 43 | def test_without_url_param(self): 44 | response = self.client.get("/gen_url/") 45 | expect(response.status_code).to_equal(HTTP_BAD_REQUEST) 46 | 47 | def test_generate_url_with_params_via_post(self): 48 | image_args = {"image_url": "globo.com/media/img/my_image.jpg"} 49 | 50 | response = self.client.post("/gen_url/", image_args) 51 | 52 | expect(response.status_code).to_equal(HTTP_METHOD_NOT_ALLOWED) 53 | 54 | def test_generate_url_with_params_via_get(self): 55 | crypto = CryptoURL(settings.THUMBOR_SECURITY_KEY) 56 | image_args = {"image_url": "globo.com/media/img/my_image.jpg"} 57 | self.url_query.update(image_args) 58 | 59 | response = self.client.get("/gen_url/?" + self.url_query.urlencode()) 60 | 61 | expect(response.status_code).to_equal(HTTP_OK) 62 | expect(response.content).to_equal( 63 | settings.THUMBOR_SERVER + crypto.generate(**image_args).strip("/") 64 | ) 65 | 66 | def test_passing_invalid_value_for_width(self): 67 | self.url_query.update( 68 | {"image_url": "globo.com/media/img/my_image.jpg", "width": 1.2} 69 | ) 70 | 71 | response = self.client.get("/gen_url/?" + self.url_query.urlencode()) 72 | 73 | expect(response.status_code).to_equal(HTTP_BAD_REQUEST) 74 | expect(str(response.content)).to_include( 75 | "The width value '1.2' is not an integer." 76 | ) 77 | 78 | def test_passing_invalid_value_for_height(self): 79 | self.url_query.update( 80 | {"image_url": "globo.com/media/img/my_image.jpg", "height": "s"} 81 | ) 82 | 83 | response = self.client.get("/gen_url/?" + self.url_query.urlencode()) 84 | 85 | expect(response.status_code).to_equal(HTTP_BAD_REQUEST) 86 | expect(str(response.content)).to_include( 87 | "The height value 's' is not an integer." 88 | ) 89 | 90 | def test_passing_invalid_aligns(self): 91 | self.url_query.update( 92 | {"image_url": "globo.com/media/img/my_image.jpg", "halign": "sss"} 93 | ) 94 | 95 | response = self.client.get("/gen_url/?" + self.url_query.urlencode()) 96 | 97 | expect(response.status_code).to_equal(HTTP_BAD_REQUEST) 98 | 99 | def test_passing_only_one_crop_value(self): 100 | self.url_query.update( 101 | {"image_url": "globo.com/media/img/my_image.jpg", "crop_left": 100} 102 | ) 103 | 104 | response = self.client.get("/gen_url/?" + self.url_query.urlencode()) 105 | 106 | expect(response.status_code).to_equal(HTTP_BAD_REQUEST) 107 | expect(str(response.content)).to_include("Missing values for cropping") 108 | 109 | def test_passing_only_one_crop_with_invalid_value(self): 110 | self.url_query.update( 111 | { 112 | "image_url": "globo.com/media/img/my_image.jpg", 113 | "crop_top": "bla", 114 | "crop_left": 200, 115 | "crop_right": "1", 116 | "crop_bottom": "blas", 117 | } 118 | ) 119 | 120 | response = self.client.get("/gen_url/?" + self.url_query.urlencode()) 121 | 122 | expect(response.status_code).to_equal(HTTP_BAD_REQUEST) 123 | expect(str(response.content)).to_include("Invalid values for cropping") 124 | 125 | def test_passing_various_erroneous_values(self): 126 | self.url_query.update( 127 | { 128 | "image_url": "globo.com/media/img/my_image.jpg", 129 | "crop_left": 100, 130 | "width": "aaa", 131 | "height": 123, 132 | } 133 | ) 134 | 135 | response = self.client.get("/gen_url/?" + self.url_query.urlencode()) 136 | 137 | expect(response.status_code).to_equal(HTTP_BAD_REQUEST) 138 | 139 | def test_passing_all_params(self): 140 | image_args = { 141 | "image_url": "globo.com/media/img/my_image.jpg", 142 | "halign": "left", 143 | "valign": "middle", 144 | "meta": True, 145 | "smart": True, 146 | "width": 400, 147 | "height": 400, 148 | "flip": True, 149 | "flop": True, 150 | } 151 | self.url_query.update(image_args) 152 | self.url_query.update( 153 | {"crop_top": 100, "crop_left": 100, "crop_bottom": 200, "crop_right": 200} 154 | ) 155 | image_args.update({"crop": ((100, 100), (200, 200))}) 156 | 157 | crypto = CryptoURL(settings.THUMBOR_SECURITY_KEY) 158 | 159 | response = self.client.get("/gen_url/?" + self.url_query.urlencode()) 160 | 161 | expect(response.status_code).to_equal(HTTP_OK) 162 | expect(response.content).to_equal( 163 | settings.THUMBOR_SERVER + crypto.generate(**image_args).strip("/") 164 | ) 165 | -------------------------------------------------------------------------------- /tests/test_libthumbor.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | 4 | # libthumbor - python extension to thumbor 5 | # http://github.com/heynemann/libthumbor 6 | 7 | # Licensed under the MIT license: 8 | # http://www.opensource.org/licenses/mit-license 9 | # Copyright (c) 2011 Bernardo Heynemann heynemann@gmail.com 10 | 11 | import re 12 | 13 | from six import b 14 | 15 | from libthumbor import CryptoURL, Url, Signer 16 | 17 | 18 | def test_usage_new_format(): 19 | key = "my-security-key" 20 | image = "s.glbimg.com/et/bb/f/original/2011/03/24/VN0JiwzmOw0b0lg.jpg" 21 | 22 | thumbor_signer = Signer(key) 23 | thumbor_url = Url.generate_options( 24 | width=300, 25 | height=200, 26 | smart=True, 27 | adaptive=False, 28 | fit_in=False, 29 | horizontal_flip=False, 30 | vertical_flip=False, 31 | crop_left=0, 32 | crop_top=0, 33 | crop_right=0, 34 | crop_bottom=0, 35 | filters=[], 36 | ) 37 | thumbor_url = (f"{thumbor_url}/{image}").lstrip("/") 38 | signature = thumbor_signer.signature(thumbor_url).decode("ascii") 39 | thumbor_url = f"/{signature}/{thumbor_url}" 40 | 41 | crypto = CryptoURL(key=key) 42 | url = crypto.generate(width=300, height=200, smart=True, image_url=image) 43 | 44 | assert url == thumbor_url 45 | 46 | 47 | def test_thumbor_can_decrypt_lib_thumbor_generated_url_new_format(): 48 | key = "my-security-key" 49 | image = "s.glbimg.com/et/bb/f/original/2011/03/24/VN0JiwzmOw0b0lg.jpg" 50 | thumbor_signer = Signer(key) 51 | 52 | crypto = CryptoURL(key=key) 53 | 54 | url = crypto.generate(width=300, height=200, smart=True, image_url=image) 55 | 56 | reg = "/([^/]+)/(.+)" 57 | (signature, url) = re.match(reg, url).groups() 58 | 59 | assert thumbor_signer.validate(b(signature), url) 60 | -------------------------------------------------------------------------------- /tests/test_url.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | 4 | # thumbor imaging service 5 | # https://github.com/thumbor/thumbor/wiki 6 | 7 | # Licensed under the MIT license: 8 | # http://www.opensource.org/licenses/mit-license 9 | # Copyright (c) 2011 globo.com timehome@corp.globo.com 10 | 11 | # pylint: disable=line-too-long,no-self-use 12 | 13 | from unittest import TestCase 14 | 15 | from preggy import expect 16 | 17 | from libthumbor.url import Url 18 | 19 | 20 | class UrlTestCase(TestCase): 21 | def setUp(self): 22 | Url.compiled_regex = None 23 | 24 | def test_can_get_regex(self): 25 | regex = Url.regex() 26 | 27 | expect(regex).to_equal( 28 | "/?(?:(?:(?Punsafe)|(?P.+?))/)?(?:(?Pdebug)/)?(?:(?Pmeta)/)?" 29 | "(?:(?Ptrim(?::(?:top-left|bottom-right))?(?::\\d+)?)/)?" 30 | "(?:(?P\\d+)x(?P\\d+):(?P\\d+)x(?P\\d+)/)?" 31 | "(?:(?Padaptive-)?(?Pfull-)?(?Pfit-in)/)?(?:(?P-)?" 32 | "(?P(?:\\d+|orig))?x(?P-)?(?P(?:\\d+|orig))?/)?" 33 | "(?:(?Pleft|right|center)/)?(?:(?Ptop|bottom|middle)/)?" 34 | "(?:(?Psmart)/)?(?:filters:(?P.+?\\))/)?(?P.+)" 35 | ) 36 | 37 | def test_can_get_regex_without_unsafe(self): 38 | regex = Url.regex(False) 39 | 40 | expect(regex).to_equal( 41 | "/?(?:(?Pdebug)/)?(?:(?Pmeta)/)?" 42 | "(?:(?Ptrim(?::(?:top-left|bottom-right))?(?::\\d+)?)/)?" 43 | "(?:(?P\\d+)x(?P\\d+):(?P\\d+)x(?P\\d+)/)?" 44 | "(?:(?Padaptive-)?(?Pfull-)?(?Pfit-in)/)?(?:(?P-)?" 45 | "(?P(?:\\d+|orig))?x(?P-)?(?P(?:\\d+|orig))?/)?" 46 | "(?:(?Pleft|right|center)/)?(?:(?Ptop|bottom|middle)/)?" 47 | "(?:(?Psmart)/)?(?:filters:(?P.+?\\))/)?(?P.+)" 48 | ) 49 | 50 | def test_parsing_invalid_url(self): 51 | expect(Url.compiled_regex).to_be_null() 52 | 53 | url = "" 54 | expect(Url.parse_decrypted(url)).to_be_null() 55 | 56 | def test_parsing_complete_url(self): 57 | url = ( 58 | "/debug/meta/trim/300x200:400x500/adaptive-full-fit-in/-300x-400/" 59 | "left/top/smart/filters:brightness(100)/some/image.jpg" 60 | ) 61 | 62 | expected = { 63 | "trim": "trim", 64 | "full": True, 65 | "halign": "left", 66 | "fit_in": True, 67 | "vertical_flip": True, 68 | "image": "some/image.jpg", 69 | "crop": {"top": 200, "right": 400, "bottom": 500, "left": 300}, 70 | "height": 400, 71 | "width": 300, 72 | "meta": True, 73 | "horizontal_flip": True, 74 | "filters": "brightness(100)", 75 | "valign": "top", 76 | "debug": True, 77 | "adaptive": True, 78 | "smart": True, 79 | } 80 | 81 | result = Url.parse_decrypted(url) 82 | expect(result).not_to_be_null() 83 | expect(result).to_be_like(expected) 84 | 85 | # do it again to use compiled regex 86 | result = Url.parse_decrypted(url) 87 | expect(result).not_to_be_null() 88 | expect(result).to_be_like(expected) 89 | 90 | def test_can_generate_url(self): 91 | url = Url.generate_options( 92 | debug=True, 93 | width=300, 94 | height=200, 95 | smart=True, 96 | meta=True, 97 | trim=True, 98 | adaptive=True, 99 | full=True, 100 | fit_in=True, 101 | horizontal_flip=True, 102 | vertical_flip=True, 103 | halign="left", 104 | valign="top", 105 | crop_left=100, 106 | crop_top=100, 107 | crop_right=400, 108 | crop_bottom=400, 109 | filters="brightness(100)", 110 | ) 111 | 112 | expect(url).to_equal( 113 | "debug/meta/trim/100x100:400x400/adaptive-full-fit-in/-300x-200/left/top/smart/filters:brightness(100)" 114 | ) 115 | 116 | def test_can_generate_url_with_defaults(self): 117 | url = Url.generate_options() 118 | 119 | expect(url).to_be_empty() 120 | 121 | def test_can_generate_url_with_fitin(self): 122 | url = Url.generate_options(fit_in=True, adaptive=False, full=False) 123 | 124 | expect(url).to_equal("fit-in") 125 | 126 | def test_can_generate_url_with_custom_trim(self): 127 | url = Url.generate_options( 128 | debug=True, 129 | width=300, 130 | height=200, 131 | smart=True, 132 | meta=True, 133 | trim="300x200", 134 | adaptive=True, 135 | full=True, 136 | fit_in=True, 137 | horizontal_flip=True, 138 | vertical_flip=True, 139 | halign="left", 140 | valign="top", 141 | crop_left=100, 142 | crop_top=100, 143 | crop_right=400, 144 | crop_bottom=400, 145 | filters="brightness(100)", 146 | ) 147 | 148 | expect(url).to_equal( 149 | "debug/meta/trim:300x200/100x100:400x400/adaptive-full-fit-in/-300x-200/left/top/smart/filters:brightness(100)" 150 | ) 151 | -------------------------------------------------------------------------------- /tests/test_url_composer.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | 4 | # libthumbor - python extension to thumbor 5 | # http://github.com/heynemann/libthumbor 6 | 7 | # Licensed under the MIT license: 8 | # http://www.opensource.org/licenses/mit-license 9 | # Copyright (c) 2011 Bernardo Heynemann heynemann@gmail.com 10 | 11 | # pylint: disable=no-self-use 12 | 13 | """libthumbor URL composer tests""" 14 | from unittest import TestCase 15 | 16 | from preggy import expect 17 | 18 | from libthumbor.url import unsafe_url, url_for 19 | 20 | IMAGE_URL = "my.server.com/some/path/to/image.jpg" 21 | IMAGE_MD5 = "84996242f65a4d864aceb125e1c4c5ba" 22 | 23 | 24 | def test_no_options_specified(): 25 | """test_no_options_specified 26 | Given 27 | An image URL of "my.server.com/some/path/to/image.jpg" 28 | When 29 | I ask my library for an URL 30 | Then 31 | I get "84996242f65a4d864aceb125e1c4c5ba" as URL 32 | """ 33 | url = url_for(image_url=IMAGE_URL) 34 | 35 | expect(url).to_equal(IMAGE_MD5) 36 | 37 | 38 | def test_url_raises_if_no_url(): 39 | """test_url_raises_if_no_url 40 | Given 41 | An image URL of "" or null 42 | When 43 | I ask my library for an URL 44 | Then 45 | I get an exception that says image URL is mandatory 46 | """ 47 | with expect.error_to_happen( 48 | ValueError, message="The image_url argument is mandatory." 49 | ): 50 | url_for() 51 | 52 | 53 | def test_url_width_height_1(): 54 | """test_url_width_height_1 55 | Given 56 | An image URL of "my.server.com/some/path/to/image.jpg" 57 | And a width of 300 58 | When 59 | I ask my library for an URL 60 | Then 61 | I get "300x0/84996242f65a4d864aceb125e1c4c5ba" as URL 62 | """ 63 | url = url_for(width=300, image_url=IMAGE_URL) 64 | 65 | expect(url).to_equal("300x0/84996242f65a4d864aceb125e1c4c5ba") 66 | 67 | 68 | def test_url_width_height_2(): 69 | """test_url_width_height_2 70 | Given 71 | An image URL of "my.server.com/some/path/to/image.jpg" 72 | And a height of 300 73 | When 74 | I ask my library for an URL 75 | Then 76 | I get "0x300/84996242f65a4d864aceb125e1c4c5ba" as URL 77 | """ 78 | url = url_for(height=300, image_url=IMAGE_URL) 79 | 80 | expect(url).to_equal("0x300/84996242f65a4d864aceb125e1c4c5ba") 81 | 82 | 83 | def test_url_width_height_3(): 84 | """test_url_width_height_3 85 | Given 86 | An image URL of "my.server.com/some/path/to/image.jpg" 87 | And a width of 200 88 | And a height of 300 89 | When 90 | I ask my library for an URL 91 | Then 92 | I get "200x300/84996242f65a4d864aceb125e1c4c5ba" as URL 93 | """ 94 | url = url_for(width=200, height=300, image_url=IMAGE_URL) 95 | 96 | expect(url).to_equal("200x300/84996242f65a4d864aceb125e1c4c5ba") 97 | 98 | 99 | def test_url_width_height_4(): 100 | """test_url_width_height_4 101 | Given 102 | An image URL of "my.server.com/some/path/to/image.jpg" 103 | And a width of orig 104 | When 105 | I ask my library for an URL 106 | Then 107 | I get "origx0/84996242f65a4d864aceb125e1c4c5ba" as URL 108 | """ 109 | url = url_for(width="orig", image_url=IMAGE_URL) 110 | 111 | expect(url).to_equal("origx0/84996242f65a4d864aceb125e1c4c5ba") 112 | 113 | 114 | def test_url_width_height_5(): 115 | """test_url_width_height_5 116 | Given 117 | An image URL of "my.server.com/some/path/to/image.jpg" 118 | And a height of orig 119 | When 120 | I ask my library for an URL 121 | Then 122 | I get "0xorig/84996242f65a4d864aceb125e1c4c5ba" as URL 123 | """ 124 | url = url_for(height="orig", image_url=IMAGE_URL) 125 | 126 | expect(url).to_equal("0xorig/84996242f65a4d864aceb125e1c4c5ba") 127 | 128 | 129 | def test_url_width_height_6(): 130 | """test_url_width_height_6 131 | Given 132 | An image URL of "my.server.com/some/path/to/image.jpg" 133 | And a width of 100 134 | And a height of orig 135 | When 136 | I ask my library for an URL 137 | Then 138 | I get "100xorig/84996242f65a4d864aceb125e1c4c5ba" as URL 139 | """ 140 | url = url_for(width=100, height="orig", image_url=IMAGE_URL) 141 | 142 | expect(url).to_equal("100xorig/84996242f65a4d864aceb125e1c4c5ba") 143 | 144 | 145 | def test_url_width_height_7(): 146 | """test_url_width_height_7 147 | Given 148 | An image URL of "my.server.com/some/path/to/image.jpg" 149 | And a height of 100 150 | And a width of orig 151 | When 152 | I ask my library for an URL 153 | Then 154 | I get "origx100/84996242f65a4d864aceb125e1c4c5ba" as URL 155 | """ 156 | url = url_for(width="orig", height=100, image_url=IMAGE_URL) 157 | 158 | expect(url).to_equal("origx100/84996242f65a4d864aceb125e1c4c5ba") 159 | 160 | 161 | def test_url_width_height_8(): 162 | """test_url_width_height_8 163 | Given 164 | An image URL of "my.server.com/some/path/to/image.jpg" 165 | And a height of orig 166 | And a width of orig 167 | When 168 | I ask my library for an URL 169 | Then 170 | I get "origxorig/84996242f65a4d864aceb125e1c4c5ba" as URL 171 | """ 172 | url = url_for(width="orig", height="orig", image_url=IMAGE_URL) 173 | 174 | expect(url).to_equal("origxorig/84996242f65a4d864aceb125e1c4c5ba") 175 | 176 | 177 | def test_smart_url(): 178 | """test_smart_url 179 | Given 180 | An image URL of "my.server.com/some/path/to/image.jpg" 181 | And a width of 200 182 | And a height of 300 183 | And the smart flag 184 | When 185 | I ask my library for an URL 186 | Then 187 | I get "200x300/smart/84996242f65a4d864aceb125e1c4c5ba" as URL 188 | """ 189 | url = url_for(width=200, height=300, smart=True, image_url=IMAGE_URL) 190 | 191 | expect(url).to_equal("200x300/smart/84996242f65a4d864aceb125e1c4c5ba") 192 | 193 | 194 | def test_fit_in_url(): 195 | """test_fit_in_url 196 | Given 197 | An image URL of "my.server.com/some/path/to/image.jpg" 198 | And a width of 200 199 | And a height of 300 200 | And the fit-in flag 201 | When 202 | I ask my library for an URL 203 | Then 204 | I get "fit-in/200x300/84996242f65a4d864aceb125e1c4c5ba" as URL 205 | """ 206 | url = url_for(width=200, height=300, fit_in=True, image_url=IMAGE_URL) 207 | 208 | expect(url).to_equal("fit-in/200x300/84996242f65a4d864aceb125e1c4c5ba") 209 | 210 | 211 | def test_adaptive_fit_in_url(): 212 | """test_adaptive_fit_in_url 213 | Given 214 | An image URL of "my.server.com/some/path/to/image.jpg" 215 | And a width of 200 216 | And a height of 300 217 | And the adaptive fit-in flag 218 | When 219 | I ask my library for an URL 220 | Then 221 | I get "adaptive-fit-in/200x300/84996242f65a4d864aceb125e1c4c5ba" as URL 222 | """ 223 | url = url_for(width=200, height=300, adaptive_fit_in=True, image_url=IMAGE_URL) 224 | 225 | expect(url).to_equal("adaptive-fit-in/200x300/84996242f65a4d864aceb125e1c4c5ba") 226 | 227 | 228 | def test_fit_in_fails_if_no_width_supplied(): 229 | with expect.error_to_happen( 230 | ValueError, 231 | message="When using fit-in or full-fit-in, " 232 | "you must specify width and/or height.", 233 | ): 234 | url_for(fit_in=True, image_url=IMAGE_URL) 235 | 236 | 237 | def test_full_fit_in_fails_if_no_width_supplied(): 238 | with expect.error_to_happen( 239 | ValueError, 240 | message="When using fit-in or full-fit-in, " 241 | "you must specify width and/or height.", 242 | ): 243 | url_for(full_fit_in=True, image_url=IMAGE_URL) 244 | 245 | 246 | def test_adaptive_fit_in_fails_if_no_width_supplied(): 247 | with expect.error_to_happen( 248 | ValueError, 249 | message="When using fit-in or full-fit-in, " 250 | "you must specify width and/or height.", 251 | ): 252 | url_for(adaptive_fit_in=True, image_url=IMAGE_URL) 253 | 254 | 255 | def test_adaptive_full_fit_in_fails_if_no_width_supplied(): 256 | with expect.error_to_happen( 257 | ValueError, 258 | message="When using fit-in or full-fit-in, " 259 | "you must specify width and/or height.", 260 | ): 261 | url_for(adaptive_full_fit_in=True, image_url=IMAGE_URL) 262 | 263 | 264 | def test_full_fit_in_url(): 265 | """test_full_fit_in_url 266 | Given 267 | An image URL of "my.server.com/some/path/to/image.jpg" 268 | And a width of 200 269 | And a height of 300 270 | And the full-fit-in flag 271 | When 272 | I ask my library for an URL 273 | Then 274 | I get "full-fit-in/200x300/84996242f65a4d864aceb125e1c4c5ba" as URL 275 | """ 276 | url = url_for(width=200, height=300, full_fit_in=True, image_url=IMAGE_URL) 277 | 278 | expect(url).to_equal("full-fit-in/200x300/84996242f65a4d864aceb125e1c4c5ba") 279 | 280 | 281 | def test_adaptive_full_fit_in_url(): 282 | """test_adaptive_full_fit_in_url 283 | Given 284 | An image URL of "my.server.com/some/path/to/image.jpg" 285 | And a width of 200 286 | And a height of 300 287 | And the adaptive full-fit-in flag 288 | When 289 | I ask my library for an URL 290 | Then 291 | I get "adaptive-full-fit-in/200x300/84996242f65a4d864aceb125e1c4c5ba" as URL 292 | """ 293 | url = url_for(width=200, height=300, adaptive_full_fit_in=True, image_url=IMAGE_URL) 294 | 295 | expect(url).to_equal( 296 | "adaptive-full-fit-in/200x300/84996242f65a4d864aceb125e1c4c5ba" 297 | ) 298 | 299 | 300 | def test_flip_1(): 301 | """test_flip_1 302 | Given 303 | An image URL of "my.server.com/some/path/to/image.jpg" 304 | And the flip flag 305 | When 306 | I ask my library for an URL 307 | Then 308 | I get "-0x0/84996242f65a4d864aceb125e1c4c5ba" as URL 309 | """ 310 | url = url_for(flip=True, image_url=IMAGE_URL) 311 | 312 | expect(url).to_equal("-0x0/84996242f65a4d864aceb125e1c4c5ba") 313 | 314 | 315 | def test_flip_2(): 316 | """test_flip_2 317 | Given 318 | An image URL of "my.server.com/some/path/to/image.jpg" 319 | And a width of 200 320 | And the flip flag 321 | When 322 | I ask my library for an URL 323 | Then 324 | I get "-200x0/84996242f65a4d864aceb125e1c4c5ba" as URL 325 | """ 326 | url = url_for(flip=True, width=200, image_url=IMAGE_URL) 327 | 328 | expect(url).to_equal("-200x0/84996242f65a4d864aceb125e1c4c5ba") 329 | 330 | 331 | def test_flop_1(): 332 | """test_flop_1 333 | Given 334 | An image URL of "my.server.com/some/path/to/image.jpg" 335 | And the flop flag 336 | When 337 | I ask my library for an URL 338 | Then 339 | I get "0x-0/84996242f65a4d864aceb125e1c4c5ba" as URL 340 | """ 341 | url = url_for(flop=True, image_url=IMAGE_URL) 342 | 343 | expect(url).to_equal("0x-0/84996242f65a4d864aceb125e1c4c5ba") 344 | 345 | 346 | def test_flop_2(): 347 | """test_flop_2 348 | Given 349 | An image URL of "my.server.com/some/path/to/image.jpg" 350 | And a height of 200 351 | And the flop flag 352 | When 353 | I ask my library for an URL 354 | Then 355 | I get "0x-200/84996242f65a4d864aceb125e1c4c5ba" as URL 356 | """ 357 | url = url_for(flop=True, height=200, image_url=IMAGE_URL) 358 | 359 | expect(url).to_equal("0x-200/84996242f65a4d864aceb125e1c4c5ba") 360 | 361 | 362 | def test_flip_flop(): 363 | """test_flip_flop 364 | Given 365 | An image URL of "my.server.com/some/path/to/image.jpg" 366 | And the flip flag 367 | And the flop flag 368 | When 369 | I ask my library for an URL 370 | Then 371 | I get "-0x-0/84996242f65a4d864aceb125e1c4c5ba" as URL 372 | """ 373 | url = url_for(flip=True, flop=True, image_url=IMAGE_URL) 374 | 375 | expect(url).to_equal("-0x-0/84996242f65a4d864aceb125e1c4c5ba") 376 | 377 | 378 | def test_flip_flop2(): 379 | """test_flip_flop2 380 | Given 381 | An image URL of "my.server.com/some/path/to/image.jpg" 382 | And a width of 200 383 | And a height of 300 384 | And the flip flag 385 | And the flop flag 386 | When 387 | I ask my library for an URL 388 | Then 389 | I get "-200x-300/84996242f65a4d864aceb125e1c4c5ba" as URL 390 | """ 391 | url = url_for(flip=True, flop=True, width=200, height=300, image_url=IMAGE_URL) 392 | 393 | expect(url).to_equal("-200x-300/84996242f65a4d864aceb125e1c4c5ba") 394 | 395 | 396 | def test_horizontal_alignment(): 397 | """test_horizontal_alignment 398 | Given 399 | An image URL of "my.server.com/some/path/to/image.jpg" 400 | And a 'left' horizontal alignment option 401 | When 402 | I ask my library for an URL 403 | Then 404 | I get "left/84996242f65a4d864aceb125e1c4c5ba" as URL 405 | """ 406 | url = url_for(halign="left", image_url=IMAGE_URL) 407 | 408 | expect(url).to_equal("left/84996242f65a4d864aceb125e1c4c5ba") 409 | 410 | 411 | def test_horizontal_alignment2(): 412 | """test_horizontal_alignment2 413 | Given 414 | An image URL of "my.server.com/some/path/to/image.jpg" 415 | And a 'center' horizontal alignment option 416 | When 417 | I ask my library for an URL 418 | Then 419 | I get "84996242f65a4d864aceb125e1c4c5ba" as URL 420 | """ 421 | url = url_for(halign="center", image_url=IMAGE_URL) 422 | 423 | expect(url).to_equal("84996242f65a4d864aceb125e1c4c5ba") 424 | 425 | 426 | def test_vertical_alignment(): 427 | """test_vertical_alignment 428 | Given 429 | An image URL of "my.server.com/some/path/to/image.jpg" 430 | And a 'top' vertical alignment option 431 | When 432 | I ask my library for an URL 433 | Then 434 | I get "top/84996242f65a4d864aceb125e1c4c5ba" as URL 435 | """ 436 | url = url_for(valign="top", image_url=IMAGE_URL) 437 | 438 | expect(url).to_equal("top/84996242f65a4d864aceb125e1c4c5ba") 439 | 440 | 441 | def test_vertical_alignment2(): 442 | """test_vertical_alignment2 443 | Given 444 | An image URL of "my.server.com/some/path/to/image.jpg" 445 | And a 'middle' vertical alignment option 446 | When 447 | I ask my library for an URL 448 | Then 449 | I get "84996242f65a4d864aceb125e1c4c5ba" as URL 450 | """ 451 | url = url_for(valign="middle", image_url=IMAGE_URL) 452 | 453 | expect(url).to_equal("84996242f65a4d864aceb125e1c4c5ba") 454 | 455 | 456 | def test_both_alignments(): 457 | """test_both_alignments 458 | Given 459 | An image URL of "my.server.com/some/path/to/image.jpg" 460 | And a 'left' horizontal alignment option 461 | And a 'top' vertical alignment option 462 | When 463 | I ask my library for an URL 464 | Then 465 | I get "left/top/84996242f65a4d864aceb125e1c4c5ba" as URL 466 | """ 467 | url = url_for(halign="left", valign="top", image_url=IMAGE_URL) 468 | 469 | expect(url).to_equal("left/top/84996242f65a4d864aceb125e1c4c5ba") 470 | 471 | 472 | def test_proper_haligns(): 473 | """test_proper_haligns""" 474 | with expect.error_to_happen( 475 | ValueError, 476 | message=( 477 | 'Only "left", "center" and "right"' 478 | " are valid values for horizontal alignment." 479 | ), 480 | ): 481 | url_for(halign="wrong", image_url=IMAGE_URL) 482 | 483 | 484 | def test_proper_valigns(): 485 | """test_proper_haligns""" 486 | with expect.error_to_happen( 487 | ValueError, 488 | message=( 489 | 'Only "top", "middle" and "bottom"' 490 | " are valid values for vertical alignment." 491 | ), 492 | ): 493 | url_for(valign="wrong", image_url=IMAGE_URL) 494 | 495 | 496 | def test_proper_meta(): 497 | """test_proper_meta 498 | Given 499 | An image URL of "my.server.com/some/path/to/image.jpg" 500 | And a 'meta' flag 501 | When 502 | I ask my library for an URL 503 | Then 504 | I get "meta/84996242f65a4d864aceb125e1c4c5ba" as URL 505 | """ 506 | url = url_for(meta=True, image_url=IMAGE_URL) 507 | 508 | expect("meta/84996242f65a4d864aceb125e1c4c5ba").to_equal(url) 509 | 510 | 511 | def test_trim_standard(): 512 | url = url_for(trim=True, image_url=IMAGE_URL) 513 | expect("trim/84996242f65a4d864aceb125e1c4c5ba").to_equal(url) 514 | 515 | 516 | def test_trim_pixel_and_tolerance(): 517 | url = url_for(trim=("bottom-right", 15), image_url=IMAGE_URL) 518 | expect("trim:bottom-right:15/84996242f65a4d864aceb125e1c4c5ba").to_equal(url) 519 | 520 | 521 | def test_trim_pixel_only(): 522 | url = url_for(trim=("top-left", None), image_url=IMAGE_URL) 523 | expect("trim:top-left/84996242f65a4d864aceb125e1c4c5ba").to_equal(url) 524 | 525 | 526 | def test_trim_tolerance_only(): 527 | url = url_for(trim=(None, 15), image_url=IMAGE_URL) 528 | expect("trim::15/84996242f65a4d864aceb125e1c4c5ba").to_equal(url) 529 | 530 | 531 | def test_manual_crop_1(): 532 | """test_manual_crop_1 533 | Given 534 | An image URL of "my.server.com/some/path/to/image.jpg" 535 | And a manual crop left-top point of (10, 20) 536 | And a manual crop right-bottom point of (30, 40) 537 | When 538 | I ask my library for an URL 539 | Then 540 | I get "10x20:30x40/84996242f65a4d864aceb125e1c4c5ba" as URL 541 | """ 542 | url = url_for(crop=((10, 20), (30, 40)), image_url=IMAGE_URL) 543 | 544 | expect("10x20:30x40/84996242f65a4d864aceb125e1c4c5ba").to_equal(url) 545 | 546 | 547 | def test_manual_crop_2(): 548 | """test_manual_crop_2 549 | Given 550 | An image URL of "my.server.com/some/path/to/image.jpg" 551 | And a manual crop left-top point of (0, 0) 552 | And a manual crop right-bottom point of (0, 0) 553 | When 554 | I ask my library for an URL 555 | Then 556 | I get "84996242f65a4d864aceb125e1c4c5ba" as URL 557 | """ 558 | url = url_for(crop=((0, 0), (0, 0)), image_url=IMAGE_URL) 559 | 560 | expect("84996242f65a4d864aceb125e1c4c5ba").to_equal(url) 561 | 562 | 563 | def test_smart_after_alignments(): 564 | """test_smart_after_alignments 565 | Given 566 | An image URL of "my.server.com/some/path/to/image.jpg" 567 | And a 'smart' flag 568 | And a 'left' horizontal alignment option 569 | When 570 | I ask my library for an URL 571 | Then 572 | I get "left/smart/84996242f65a4d864aceb125e1c4c5ba" as URL 573 | """ 574 | url = url_for(smart=True, halign="left", image_url=IMAGE_URL) 575 | 576 | expect("left/smart/84996242f65a4d864aceb125e1c4c5ba").to_equal(url) 577 | 578 | 579 | class UnsafeUrlTestCase(TestCase): 580 | def test_should_return_a_valid_unsafe_url_with_no_params(self): 581 | expect(f"unsafe/{IMAGE_URL}").to_equal(unsafe_url(image_url=IMAGE_URL)) 582 | 583 | def test_should_return_an_unsafe_url_with_width_and_height(self): 584 | expect(f"unsafe/100x140/{IMAGE_URL}").to_equal( 585 | unsafe_url(image_url=IMAGE_URL, width=100, height=140), 586 | ) 587 | 588 | def test_should_return_an_unsafe_url_with_crop_and_smart(self): 589 | expect(f"unsafe/100x140/smart/{IMAGE_URL}").to_equal( 590 | unsafe_url(image_url=IMAGE_URL, width=100, height=140, smart=True), 591 | ) 592 | -------------------------------------------------------------------------------- /tests/testproj/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testproj.settings") 9 | try: 10 | from django.core.management import execute_from_command_line 11 | except ImportError as exc: 12 | raise ImportError( 13 | "Couldn't import Django. Are you sure it's installed and " 14 | "available on your PYTHONPATH environment variable? Did you " 15 | "forget to activate a virtual environment?" 16 | ) from exc 17 | execute_from_command_line(sys.argv) 18 | 19 | 20 | if __name__ == "__main__": 21 | main() 22 | -------------------------------------------------------------------------------- /tests/testproj/testproj/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thumbor/libthumbor/b41d1712d64dc5073b65958bb0bcb6f3fce22256/tests/testproj/testproj/__init__.py -------------------------------------------------------------------------------- /tests/testproj/testproj/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for testproj project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.0/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testproj.settings") 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /tests/testproj/testproj/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for testproj project. 3 | 4 | Generated by 'django-admin startproject' using Django 3.0.3. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.0/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/3.0/ref/settings/ 11 | """ 12 | 13 | import os 14 | 15 | import django 16 | 17 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 18 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 19 | 20 | 21 | # Quick-start development settings - unsuitable for production 22 | # See https://docs.djangoproject.com/en/3.0/howto/deployment/checklist/ 23 | 24 | # SECURITY WARNING: keep the secret key used in production secret! 25 | SECRET_KEY = "a@$(--e0iqkamq+4v84w$$1*cn$fa5n&g1(&2fk$e0@c!f06s9" 26 | 27 | # SECURITY WARNING: don't run with debug turned on in production! 28 | DEBUG = True 29 | 30 | ALLOWED_HOSTS = ["testserver"] 31 | 32 | 33 | # Application definition 34 | 35 | INSTALLED_APPS = [ 36 | "django.contrib.admin", 37 | "django.contrib.auth", 38 | "django.contrib.contenttypes", 39 | "django.contrib.sessions", 40 | "django.contrib.messages", 41 | "django.contrib.staticfiles", 42 | ] 43 | 44 | MIDDLEWARE = [ 45 | "django.middleware.security.SecurityMiddleware", 46 | "django.contrib.sessions.middleware.SessionMiddleware", 47 | "django.middleware.common.CommonMiddleware", 48 | "django.middleware.csrf.CsrfViewMiddleware", 49 | "django.contrib.auth.middleware.AuthenticationMiddleware", 50 | "django.contrib.messages.middleware.MessageMiddleware", 51 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 52 | ] 53 | 54 | ROOT_URLCONF = "tests.testproj.testproj.urls" 55 | 56 | TEMPLATES = [ 57 | { 58 | "BACKEND": "django.template.backends.django.DjangoTemplates", 59 | "DIRS": [], 60 | "APP_DIRS": True, 61 | "OPTIONS": { 62 | "context_processors": [ 63 | "django.template.context_processors.debug", 64 | "django.template.context_processors.request", 65 | "django.contrib.auth.context_processors.auth", 66 | "django.contrib.messages.context_processors.messages", 67 | ], 68 | }, 69 | }, 70 | ] 71 | 72 | WSGI_APPLICATION = "tests.testproj.testproj.wsgi.application" 73 | 74 | 75 | # Database 76 | # https://docs.djangoproject.com/en/3.0/ref/settings/#databases 77 | 78 | DATABASES = { 79 | "default": { 80 | "ENGINE": "django.db.backends.sqlite3", 81 | "NAME": os.path.join(BASE_DIR, "db.sqlite3"), 82 | } 83 | } 84 | 85 | 86 | # Password validation 87 | # https://docs.djangoproject.com/en/3.0/ref/settings/#auth-password-validators 88 | 89 | AUTH_PASSWORD_VALIDATORS = [ 90 | { 91 | "NAME": ( 92 | "django.contrib.auth.password_validation.UserAttributeSimilarityValidator" 93 | ), 94 | }, 95 | {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, 96 | {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, 97 | {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, 98 | ] 99 | 100 | 101 | # Internationalization 102 | # https://docs.djangoproject.com/en/3.0/topics/i18n/ 103 | 104 | LANGUAGE_CODE = "en-us" 105 | 106 | TIME_ZONE = "UTC" 107 | 108 | USE_I18N = True 109 | 110 | USE_L10N = True 111 | 112 | USE_TZ = True 113 | 114 | 115 | # Static files (CSS, JavaScript, Images) 116 | # https://docs.djangoproject.com/en/3.0/howto/static-files/ 117 | 118 | STATIC_URL = "/static/" 119 | 120 | THUMBOR_SECURITY_KEY = "my-security-key" 121 | THUMBOR_SERVER = "http://localhost:8888/" 122 | 123 | django.setup() 124 | -------------------------------------------------------------------------------- /tests/testproj/testproj/urls.py: -------------------------------------------------------------------------------- 1 | """testproj URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/3.0/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | from django.contrib import admin 17 | from django.urls import include, path 18 | 19 | urlpatterns = [ # pylint: disable=invalid-name 20 | path("admin/", admin.site.urls), 21 | path("", include("libthumbor.django.urls")), 22 | ] 23 | -------------------------------------------------------------------------------- /tests/testproj/testproj/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for testproj project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.0/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testproj.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /tests/url_signers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thumbor/libthumbor/b41d1712d64dc5073b65958bb0bcb6f3fce22256/tests/url_signers/__init__.py -------------------------------------------------------------------------------- /tests/url_signers/test_base64_hmac_sha1_signer.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | 4 | # thumbor imaging service 5 | # https://github.com/thumbor/thumbor/wiki 6 | 7 | # Licensed under the MIT license: 8 | # http://www.opensource.org/licenses/mit-license 9 | # Copyright (c) 2011 globo.com timehome@corp.globo.com 10 | 11 | # pylint: disable=no-self-use 12 | 13 | import base64 14 | import hashlib 15 | import hmac 16 | from unittest import TestCase 17 | 18 | from preggy import expect 19 | from six import text_type 20 | 21 | from libthumbor.url_signers.base64_hmac_sha1 import UrlSigner 22 | 23 | 24 | class Base64HmacSha1UrlSignerTestCase(TestCase): 25 | def test_can_create_signer(self): 26 | signer = UrlSigner(security_key="something") 27 | expect(signer).to_be_instance_of(UrlSigner) 28 | expect(signer.security_key).to_equal("something") 29 | 30 | def test_can_sign_url(self): 31 | signer = UrlSigner(security_key="something") 32 | url = "10x11:12x13/-300x-300/center/middle/smart/some/image.jpg" 33 | expected = base64.urlsafe_b64encode( 34 | hmac.new( 35 | "something".encode(), text_type(url).encode("utf-8"), hashlib.sha1 36 | ).digest() 37 | ) 38 | actual = signer.signature(url) 39 | expect(actual).to_equal(expected) 40 | -------------------------------------------------------------------------------- /tests/url_signers/test_base_url_signer.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | 4 | # thumbor imaging service 5 | # https://github.com/thumbor/thumbor/wiki 6 | 7 | # Licensed under the MIT license: 8 | # http://www.opensource.org/licenses/mit-license 9 | # Copyright (c) 2011 globo.com timehome@corp.globo.com 10 | 11 | # pylint: disable=no-self-use 12 | 13 | from __future__ import unicode_literals 14 | 15 | from unittest import TestCase 16 | 17 | from preggy import expect 18 | 19 | from libthumbor.url_signers import BaseUrlSigner 20 | 21 | 22 | class BaseSignerTestCase(TestCase): 23 | def test_can_create_signer(self): 24 | signer = BaseUrlSigner(security_key="something") 25 | expect(signer).to_be_instance_of(BaseUrlSigner) 26 | expect(signer.security_key).to_equal("something") 27 | 28 | def test_can_create_signer_with_binary_key(self): 29 | signer = BaseUrlSigner(security_key=b"something") 30 | expect(signer).to_be_instance_of(BaseUrlSigner) 31 | expect(signer.security_key).to_equal("something") 32 | 33 | def test_can_create_unicode_signer(self): 34 | signer = BaseUrlSigner(security_key="téste") 35 | expect(signer).to_be_instance_of(BaseUrlSigner) 36 | expect(signer.security_key).to_equal("téste") 37 | 38 | def test_can_validate_url(self): 39 | class TestSigner(BaseUrlSigner): 40 | def signature(self, url): 41 | return f"{url}+1" 42 | 43 | signer = TestSigner(security_key="téste") 44 | expect( 45 | signer.validate("http://www.test.com+1", "http://www.test.com") 46 | ).to_be_true() 47 | 48 | def test_has_abstract_method(self): 49 | signer = BaseUrlSigner(security_key="téste") 50 | 51 | with expect.error_to_happen(NotImplementedError): 52 | signer.signature("test-url") 53 | --------------------------------------------------------------------------------