├── .gitattributes ├── .github ├── dependabot.yaml └── workflows │ └── CI.yml ├── .gitignore ├── .pylintrc ├── .readthedocs.yaml ├── .well-known └── funding-manifest-urls ├── AUTHORS ├── CHANGELOG.md ├── CONTRIBUTORS ├── LICENSE ├── MANIFEST.in ├── README.md ├── docs ├── Makefile ├── make.bat └── source │ ├── conf.py │ ├── fingerprint.rst │ ├── index.rst │ ├── storage.rst │ └── tusclient.rst ├── setup.py ├── tests ├── __init__.py ├── mixin.py ├── sample_files │ ├── binary.png │ └── text.txt ├── storage_file ├── test_async_uploader.py ├── test_client.py ├── test_filestorage.py ├── test_fingerprint.py ├── test_request.py └── test_uploader.py ├── tox.ini └── tusclient ├── __init__.py ├── client.py ├── exceptions.py ├── fingerprint ├── __init__.py ├── fingerprint.py └── interface.py ├── py.typed ├── request.py ├── storage ├── __init__.py ├── filestorage.py └── interface.py └── uploader ├── __init__.py ├── baseuploader.py └── uploader.py /.gitattributes: -------------------------------------------------------------------------------- 1 | tests/sample_files/* binary 2 | -------------------------------------------------------------------------------- /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: / 5 | schedule: 6 | interval: weekly 7 | 8 | - package-ecosystem: pip 9 | directory: / 10 | schedule: 11 | interval: weekly 12 | -------------------------------------------------------------------------------- /.github/workflows/CI.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | python: 7 | runs-on: ${{ matrix.os }} 8 | strategy: 9 | fail-fast: false 10 | matrix: 11 | os: [ubuntu-latest, windows-latest] 12 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - name: Set up Python 18 | uses: actions/setup-python@v5 19 | with: 20 | python-version: ${{ matrix.python-version }} 21 | 22 | - name: Install Dependencies 23 | run: pip install -e .[test] 24 | 25 | - name: Test with pytest 26 | run: | 27 | pytest --cov=tusclient 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | 56 | # Flask stuff: 57 | instance/ 58 | .webassets-cache 59 | 60 | # Scrapy stuff: 61 | .scrapy 62 | 63 | # Sphinx documentation 64 | docs/_build/ 65 | 66 | # PyBuilder 67 | target/ 68 | 69 | # IPython Notebook 70 | .ipynb_checkpoints 71 | 72 | # pyenv 73 | .python-version 74 | 75 | # celery beat schedule file 76 | celerybeat-schedule 77 | 78 | # dotenv 79 | .env 80 | 81 | # virtualenv 82 | venv/ 83 | ENV/ 84 | 85 | # Spyder project settings 86 | .spyderproject 87 | 88 | # Rope project settings 89 | .ropeproject 90 | 91 | # OSX 92 | .DS_Store 93 | 94 | # code editor 95 | .vscode 96 | 97 | # test generated file 98 | storage.json 99 | -------------------------------------------------------------------------------- /.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 modules 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 | 48 | [MESSAGES CONTROL] 49 | 50 | # Only show warnings with the listed confidence levels. Leave empty to show 51 | # all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED. 52 | confidence= 53 | 54 | # Disable the message, report, category or checker with the given id(s). You 55 | # can either give multiple identifiers separated by comma (,) or put this 56 | # option multiple times (only on the command line, not in the configuration 57 | # file where it should appear only once). You can also use "--disable=all" to 58 | # disable everything first and then reenable specific checks. For example, if 59 | # you want to run only the similarities checker, you can use "--disable=all 60 | # --enable=similarities". If you want to run only the classes checker, but have 61 | # no Warning level messages displayed, use "--disable=all --enable=classes 62 | # --disable=W". 63 | disable=print-statement, 64 | parameter-unpacking, 65 | unpacking-in-except, 66 | old-raise-syntax, 67 | backtick, 68 | long-suffix, 69 | old-ne-operator, 70 | old-octal-literal, 71 | import-star-module-level, 72 | non-ascii-bytes-literal, 73 | raw-checker-failed, 74 | bad-inline-option, 75 | locally-disabled, 76 | file-ignored, 77 | suppressed-message, 78 | useless-suppression, 79 | deprecated-pragma, 80 | use-symbolic-message-instead, 81 | apply-builtin, 82 | basestring-builtin, 83 | buffer-builtin, 84 | cmp-builtin, 85 | coerce-builtin, 86 | execfile-builtin, 87 | file-builtin, 88 | long-builtin, 89 | raw_input-builtin, 90 | reduce-builtin, 91 | standarderror-builtin, 92 | unicode-builtin, 93 | xrange-builtin, 94 | coerce-method, 95 | delslice-method, 96 | getslice-method, 97 | setslice-method, 98 | no-absolute-import, 99 | old-division, 100 | dict-iter-method, 101 | dict-view-method, 102 | next-method-called, 103 | metaclass-assignment, 104 | indexing-exception, 105 | raising-string, 106 | reload-builtin, 107 | oct-method, 108 | hex-method, 109 | nonzero-method, 110 | cmp-method, 111 | input-builtin, 112 | round-builtin, 113 | intern-builtin, 114 | unichr-builtin, 115 | map-builtin-not-iterating, 116 | zip-builtin-not-iterating, 117 | range-builtin-not-iterating, 118 | filter-builtin-not-iterating, 119 | using-cmp-argument, 120 | eq-without-hash, 121 | div-method, 122 | idiv-method, 123 | rdiv-method, 124 | exception-message-attribute, 125 | invalid-str-codec, 126 | sys-max-int, 127 | bad-python3-import, 128 | deprecated-string-function, 129 | deprecated-str-translate-call, 130 | deprecated-itertools-function, 131 | deprecated-types-field, 132 | next-method-defined, 133 | dict-items-not-iterating, 134 | dict-keys-not-iterating, 135 | dict-values-not-iterating, 136 | deprecated-operator-function, 137 | deprecated-urllib-function, 138 | xreadlines-attribute, 139 | deprecated-sys-function, 140 | exception-escape, 141 | comprehension-escape 142 | 143 | # Enable the message, report, category or checker with the given id(s). You can 144 | # either give multiple identifier separated by comma (,) or put this option 145 | # multiple time (only on the command line, not in the configuration file where 146 | # it should appear only once). See also the "--disable" option for examples. 147 | enable=c-extension-no-member 148 | 149 | 150 | [REPORTS] 151 | 152 | # Python expression which should return a note less than 10 (10 is the highest 153 | # note). You have access to the variables errors warning, statement which 154 | # respectively contain the number of errors / warnings messages and the total 155 | # number of statements analyzed. This is used by the global evaluation report 156 | # (RP0004). 157 | evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) 158 | 159 | # Template used to display messages. This is a python new-style format string 160 | # used to format the message information. See doc for all details. 161 | #msg-template= 162 | 163 | # Set the output format. Available formats are text, parseable, colorized, json 164 | # and msvs (visual studio). You can also give a reporter class, e.g. 165 | # mypackage.mymodule.MyReporterClass. 166 | output-format=text 167 | 168 | # Tells whether to display a full report or only the messages. 169 | reports=no 170 | 171 | # Activate the evaluation score. 172 | score=yes 173 | 174 | 175 | [REFACTORING] 176 | 177 | # Maximum number of nested blocks for function / method body 178 | max-nested-blocks=5 179 | 180 | # Complete name of functions that never returns. When checking for 181 | # inconsistent-return-statements if a never returning function is called then 182 | # it will be considered as an explicit return statement and no message will be 183 | # printed. 184 | never-returning-functions=sys.exit 185 | 186 | 187 | [LOGGING] 188 | 189 | # Format style used to check logging format string. `old` means using % 190 | # formatting, while `new` is for `{}` formatting. 191 | logging-format-style=old 192 | 193 | # Logging modules to check that the string format arguments are in logging 194 | # function parameter format. 195 | logging-modules=logging 196 | 197 | 198 | [SPELLING] 199 | 200 | # Limits count of emitted suggestions for spelling mistakes. 201 | max-spelling-suggestions=4 202 | 203 | # Spelling dictionary name. Available dictionaries: none. To make it working 204 | # install python-enchant package.. 205 | spelling-dict= 206 | 207 | # List of comma separated words that should not be checked. 208 | spelling-ignore-words= 209 | 210 | # A path to a file that contains private dictionary; one word per line. 211 | spelling-private-dict-file= 212 | 213 | # Tells whether to store unknown words to indicated private dictionary in 214 | # --spelling-private-dict-file option instead of raising a message. 215 | spelling-store-unknown-words=no 216 | 217 | 218 | [MISCELLANEOUS] 219 | 220 | # List of note tags to take in consideration, separated by a comma. 221 | notes=FIXME, 222 | XXX, 223 | TODO 224 | 225 | 226 | [TYPECHECK] 227 | 228 | # List of decorators that produce context managers, such as 229 | # contextlib.contextmanager. Add to this list to register other decorators that 230 | # produce valid context managers. 231 | contextmanager-decorators=contextlib.contextmanager 232 | 233 | # List of members which are set dynamically and missed by pylint inference 234 | # system, and so shouldn't trigger E1101 when accessed. Python regular 235 | # expressions are accepted. 236 | generated-members= 237 | 238 | # Tells whether missing members accessed in mixin class should be ignored. A 239 | # mixin class is detected if its name ends with "mixin" (case insensitive). 240 | ignore-mixin-members=yes 241 | 242 | # Tells whether to warn about missing members when the owner of the attribute 243 | # is inferred to be None. 244 | ignore-none=yes 245 | 246 | # This flag controls whether pylint should warn about no-member and similar 247 | # checks whenever an opaque object is returned when inferring. The inference 248 | # can return multiple potential results while evaluating a Python object, but 249 | # some branches might not be evaluated, which results in partial inference. In 250 | # that case, it might be useful to still emit no-member and other checks for 251 | # the rest of the inferred objects. 252 | ignore-on-opaque-inference=yes 253 | 254 | # List of class names for which member attributes should not be checked (useful 255 | # for classes with dynamically set attributes). This supports the use of 256 | # qualified names. 257 | ignored-classes=optparse.Values,thread._local,_thread._local,googleapiclient.discovery.Resource 258 | 259 | # List of module names for which member attributes should not be checked 260 | # (useful for modules/projects where namespaces are manipulated during runtime 261 | # and thus existing member attributes cannot be deduced by static analysis. It 262 | # supports qualified module names, as well as Unix pattern matching. 263 | ignored-modules=google.cloud.vision_v1.types 264 | 265 | # Show a hint with possible names when a member name was not found. The aspect 266 | # of finding the hint is based on edit distance. 267 | missing-member-hint=yes 268 | 269 | # The minimum edit distance a name should have in order to be considered a 270 | # similar match for a missing member name. 271 | missing-member-hint-distance=1 272 | 273 | # The total number of similar names that should be taken in consideration when 274 | # showing a hint for a missing member. 275 | missing-member-max-choices=1 276 | 277 | 278 | [VARIABLES] 279 | 280 | # List of additional names supposed to be defined in builtins. Remember that 281 | # you should avoid defining new builtins when possible. 282 | additional-builtins= 283 | 284 | # Tells whether unused global variables should be treated as a violation. 285 | allow-global-unused-variables=yes 286 | 287 | # List of strings which can identify a callback function by name. A callback 288 | # name must start or end with one of those strings. 289 | callbacks=cb_, 290 | _cb 291 | 292 | # A regular expression matching the name of dummy variables (i.e. expected to 293 | # not be used). 294 | dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ 295 | 296 | # Argument names that match this expression will be ignored. Default to name 297 | # with leading underscore. 298 | ignored-argument-names=_.*|^ignored_|^unused_ 299 | 300 | # Tells whether we should check for unused import in __init__ files. 301 | init-import=no 302 | 303 | # List of qualified module names which can have objects that can redefine 304 | # builtins. 305 | redefining-builtins-modules=builtins,io 306 | 307 | 308 | [FORMAT] 309 | 310 | # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. 311 | expected-line-ending-format= 312 | 313 | # Regexp for a line that is allowed to be longer than the limit. 314 | ignore-long-lines=^\s*(# )??$ 315 | 316 | # Number of spaces of indent required inside a hanging or continued line. 317 | indent-after-paren=4 318 | 319 | # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 320 | # tab). 321 | indent-string=' ' 322 | 323 | # Maximum number of characters on a single line. 324 | max-line-length=100 325 | 326 | # Maximum number of lines in a module. 327 | max-module-lines=1000 328 | 329 | # List of optional constructs for which whitespace checking is disabled. `dict- 330 | # separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. 331 | # `trailing-comma` allows a space between comma and closing bracket: (a, ). 332 | # `empty-line` allows space-only lines. 333 | no-space-check=trailing-comma, 334 | dict-separator 335 | 336 | # Allow the body of a class to be on the same line as the declaration if body 337 | # contains single statement. 338 | single-line-class-stmt=no 339 | 340 | # Allow the body of an if to be on the same line as the test if there is no 341 | # else. 342 | single-line-if-stmt=no 343 | 344 | 345 | [SIMILARITIES] 346 | 347 | # Ignore comments when computing similarities. 348 | ignore-comments=yes 349 | 350 | # Ignore docstrings when computing similarities. 351 | ignore-docstrings=yes 352 | 353 | # Ignore imports when computing similarities. 354 | ignore-imports=no 355 | 356 | # Minimum lines number of a similarity. 357 | min-similarity-lines=4 358 | 359 | 360 | [BASIC] 361 | 362 | # Naming style matching correct argument names. 363 | argument-naming-style=snake_case 364 | 365 | # Regular expression matching correct argument names. Overrides argument- 366 | # naming-style. 367 | #argument-rgx= 368 | 369 | # Naming style matching correct attribute names. 370 | attr-naming-style=snake_case 371 | 372 | # Regular expression matching correct attribute names. Overrides attr-naming- 373 | # style. 374 | #attr-rgx= 375 | 376 | # Bad variable names which should always be refused, separated by a comma. 377 | bad-names=foo, 378 | bar, 379 | baz, 380 | toto, 381 | tutu, 382 | tata 383 | 384 | # Naming style matching correct class attribute names. 385 | class-attribute-naming-style=any 386 | 387 | # Regular expression matching correct class attribute names. Overrides class- 388 | # attribute-naming-style. 389 | #class-attribute-rgx= 390 | 391 | # Naming style matching correct class names. 392 | class-naming-style=PascalCase 393 | 394 | # Regular expression matching correct class names. Overrides class-naming- 395 | # style. 396 | #class-rgx= 397 | 398 | # Naming style matching correct constant names. 399 | const-naming-style=UPPER_CASE 400 | 401 | # Regular expression matching correct constant names. Overrides const-naming- 402 | # style. 403 | #const-rgx= 404 | 405 | # Minimum line length for functions/classes that require docstrings, shorter 406 | # ones are exempt. 407 | docstring-min-length=-1 408 | 409 | # Naming style matching correct function names. 410 | function-naming-style=snake_case 411 | 412 | # Regular expression matching correct function names. Overrides function- 413 | # naming-style. 414 | #function-rgx= 415 | 416 | # Good variable names which should always be accepted, separated by a comma. 417 | good-names=i, 418 | j, 419 | k, 420 | ex, 421 | Run, 422 | _ 423 | 424 | # Include a hint for the correct naming format with invalid-name. 425 | include-naming-hint=no 426 | 427 | # Naming style matching correct inline iteration names. 428 | inlinevar-naming-style=any 429 | 430 | # Regular expression matching correct inline iteration names. Overrides 431 | # inlinevar-naming-style. 432 | #inlinevar-rgx= 433 | 434 | # Naming style matching correct method names. 435 | method-naming-style=snake_case 436 | 437 | # Regular expression matching correct method names. Overrides method-naming- 438 | # style. 439 | #method-rgx= 440 | 441 | # Naming style matching correct module names. 442 | module-naming-style=snake_case 443 | 444 | # Regular expression matching correct module names. Overrides module-naming- 445 | # style. 446 | #module-rgx= 447 | 448 | # Colon-delimited sets of names that determine each other's naming style when 449 | # the name regexes allow several styles. 450 | name-group= 451 | 452 | # Regular expression which should only match function or class names that do 453 | # not require a docstring. 454 | no-docstring-rgx=^_ 455 | 456 | # List of decorators that produce properties, such as abc.abstractproperty. Add 457 | # to this list to register other decorators that produce valid properties. 458 | # These decorators are taken in consideration only for invalid-name. 459 | property-classes=abc.abstractproperty 460 | 461 | # Naming style matching correct variable names. 462 | variable-naming-style=snake_case 463 | 464 | # Regular expression matching correct variable names. Overrides variable- 465 | # naming-style. 466 | #variable-rgx= 467 | 468 | 469 | [IMPORTS] 470 | 471 | # Allow wildcard imports from modules that define __all__. 472 | allow-wildcard-with-all=no 473 | 474 | # Analyse import fallback blocks. This can be used to support both Python 2 and 475 | # 3 compatible code, which means that the block might have code that exists 476 | # only in one or another interpreter, leading to false positives when analysed. 477 | analyse-fallback-blocks=no 478 | 479 | # Deprecated modules which should not be used, separated by a comma. 480 | deprecated-modules=optparse,tkinter.tix 481 | 482 | # Create a graph of external dependencies in the given file (report RP0402 must 483 | # not be disabled). 484 | ext-import-graph= 485 | 486 | # Create a graph of every (i.e. internal and external) dependencies in the 487 | # given file (report RP0402 must not be disabled). 488 | import-graph= 489 | 490 | # Create a graph of internal dependencies in the given file (report RP0402 must 491 | # not be disabled). 492 | int-import-graph= 493 | 494 | # Force import order to recognize a module as part of the standard 495 | # compatibility libraries. 496 | known-standard-library= 497 | 498 | # Force import order to recognize a module as part of a third party library. 499 | known-third-party=enchant 500 | 501 | 502 | [CLASSES] 503 | 504 | # List of method names used to declare (i.e. assign) instance attributes. 505 | defining-attr-methods=__init__, 506 | __new__, 507 | setUp 508 | 509 | # List of member names, which should be excluded from the protected access 510 | # warning. 511 | exclude-protected=_asdict, 512 | _fields, 513 | _replace, 514 | _source, 515 | _make 516 | 517 | # List of valid names for the first argument in a class method. 518 | valid-classmethod-first-arg=cls 519 | 520 | # List of valid names for the first argument in a metaclass class method. 521 | valid-metaclass-classmethod-first-arg=cls 522 | 523 | 524 | [DESIGN] 525 | 526 | # Maximum number of arguments for function / method. 527 | max-args=5 528 | 529 | # Maximum number of attributes for a class (see R0902). 530 | max-attributes=7 531 | 532 | # Maximum number of boolean expressions in an if statement. 533 | max-bool-expr=5 534 | 535 | # Maximum number of branch for function / method body. 536 | max-branches=12 537 | 538 | # Maximum number of locals for function / method body. 539 | max-locals=15 540 | 541 | # Maximum number of parents for a class (see R0901). 542 | max-parents=7 543 | 544 | # Maximum number of public methods for a class (see R0904). 545 | max-public-methods=20 546 | 547 | # Maximum number of return / yield for function / method body. 548 | max-returns=6 549 | 550 | # Maximum number of statements in function / method body. 551 | max-statements=50 552 | 553 | # Minimum number of public methods for a class (see R0903). 554 | min-public-methods=2 555 | 556 | 557 | [EXCEPTIONS] 558 | 559 | # Exceptions that will emit a warning when being caught. Defaults to 560 | # "Exception". 561 | overgeneral-exceptions=Exception 562 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # Read the Docs configuration file for Sphinx projects 2 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 3 | 4 | # Required 5 | version: 2 6 | 7 | # Set the OS, Python version and other tools you might need 8 | build: 9 | os: ubuntu-22.04 10 | tools: 11 | python: "3.12" 12 | # You can also specify other tool versions: 13 | # nodejs: "20" 14 | # rust: "1.70" 15 | # golang: "1.20" 16 | 17 | # Build documentation in the "docs/" directory with Sphinx 18 | sphinx: 19 | configuration: docs/source/conf.py 20 | # You can configure Sphinx to use a different builder, for instance use the dirhtml builder for simpler URLs 21 | # builder: "dirhtml" 22 | # Fail on all warnings to avoid broken references 23 | # fail_on_warning: true 24 | 25 | # Optionally build your docs in additional formats such as PDF and ePub 26 | # formats: 27 | # - pdf 28 | # - epub 29 | 30 | # Optional but recommended, declare the Python requirements required 31 | # to build your documentation 32 | # See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html 33 | # python: 34 | # install: 35 | # - requirements: docs/requirements.txt 36 | -------------------------------------------------------------------------------- /.well-known/funding-manifest-urls: -------------------------------------------------------------------------------- 1 | https://tus.io/funding.json 2 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Authors: 2 | 3 | Ifedapo Olarewaju -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### 1.1.0 / 2024-11-29 2 | 3 | - Add support for specifying client certificates for TLS by @quality-leftovers in [#85](https://github.com/tus/tus-py-client/pulls/85) 4 | 5 | ### 1.0.3 / 2023-12-13 6 | 7 | - Add explicit test fixtures to fix tests on Windows by @nhairs in https://github.com/tus/tus-py-client/pull/91 8 | - Remove unneeded `six` dependency (was used for Python 2) by @a-detiste in https://github.com/tus/tus-py-client/pull/90 9 | - Fix calls to `upload_chunk` by @Acconut in https://github.com/tus/tus-py-client/pull/92 10 | 11 | ### 1.0.2 / 2023-11-30 12 | 13 | - Remove unnecessary future install requirement [#81](https://github.com/tus/tus-py-client/pulls/81) 14 | - Expose typing information (PEP 561) [#87](https://github.com/tus/tus-py-client/issues/87) 15 | 16 | ### 1.0.1 / 2023-06-20 17 | 18 | - Fix bug preventing `verify_tls_cert` from being applied to `HEAD` requests (https://github.com/tus/tus-py-client/pull/80) 19 | - Fix bug preventing empty files from being uploaded (https://github.com/tus/tus-py-client/pull/78) 20 | 21 | ### 1.0.0 / 2022-06-17 22 | 23 | - Drop support for Python 2 (https://github.com/tus/tus-py-client/pull/35) 24 | - Add support for asyncIO using AsyncUploader class (https://github.com/tus/tus-py-client/pull/35) 25 | - Use only first block of the file for a finger print (https://github.com/tus/tus-py-client/pull/37) 26 | - Allow all 2XX status code for an upload response (https://github.com/tus/tus-py-client/pull/44) 27 | 28 | ### 0.2.5 / 2020-06-5 29 | 30 | ### 0.2.4/ 2019-14-01 31 | 32 | - Add support for tus upload-checksum header 33 | 34 | ### 0.2.3/ 2018-13-03 35 | 36 | - Refine connection error handling 37 | - Make long description render correctly on pypi.org 38 | - Set default chunksize to largest possible number 39 | 40 | ### 0.2.2/ 2018-19-03 41 | 42 | - Replace the use of PyCurl with builtin http.client 43 | - Remove unwanted debug printing 44 | 45 | ### 0.2.1 / 2017-12-02 46 | 47 | - Fix installtion and Doc autogeneration issue 48 | 49 | ### 0.2 / 2017-12-02 50 | 51 | - Support for URL storage 52 | - Use uploader without Client [#14](https://github.com/tus/tus-py-client/issues/14) 53 | 54 | ### 0.1.3 / 2017-11-15 55 | 56 | - Fix installation issues, due to missing readme. 57 | 58 | ### 0.1.2 / 2017-10-27 59 | 60 | - Fix PyCurl ssl error [#11](https://github.com/tus/tus-py-client/issues/11) 61 | 62 | ### 0.1.1 / 2017-10-12 63 | 64 | - Support relative upload urls (Thank you @ciklop) 65 | - Unpin requests library version (Thank you @peixian) 66 | - Test against Python 3.6 (Thank you @thedrow) 67 | 68 | ### 0.1 / 2017-07-10 69 | 70 | - Automatically retry a chunk upload on failure. 71 | - Read tus server response content `uploader.request.response_content`. 72 | - More http request details in Tus Exception. 73 | 74 | ### 0.1a2 / 2016-10-31 75 | 76 | - Allow upload-metadata 77 | - Upload files from file stream 78 | - Better request error handling 79 | - Cleaner retrieval of offset after chunk upload 80 | 81 | ### 0.1a1 / 2016-10-13 82 | 83 | - Alpha release 84 | -------------------------------------------------------------------------------- /CONTRIBUTORS: -------------------------------------------------------------------------------- 1 | Contributors: 2 | # Names should be added to this file as: 3 | # Name 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Ifedapo .A. Olarewaju 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, 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, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tus-py-client [![Build Status](https://github.com/tus/tus-py-client/actions/workflows/CI.yml/badge.svg)](https://github.com/tus/tus-py-client/actions/workflows/CI.yml) 2 | 3 | > **tus** is a protocol based on HTTP for _resumable file uploads_. Resumable 4 | > means that an upload can be interrupted at any moment and can be resumed without 5 | > re-uploading the previous data again. An interruption may happen willingly, if 6 | > the user wants to pause, or by accident in case of a network issue or server 7 | > outage. 8 | 9 | **tus-py-client** is a Python client for uploading files using the _tus_ protocol to any remote server supporting it. 10 | 11 | ## Documentation 12 | 13 | See documentation here: http://tus-py-client.readthedocs.io/en/latest/ 14 | 15 | ## Get started 16 | 17 | ```bash 18 | pip install tuspy 19 | ``` 20 | 21 | Now you are ready to use the api. 22 | 23 | ```python 24 | from tusclient import client 25 | 26 | # Set Authorization headers if it is required 27 | # by the tus server. 28 | my_client = client.TusClient('http://tusd.tusdemo.net/files/', 29 | headers={'Authorization': 'Basic xxyyZZAAbbCC='}) 30 | 31 | # Set more headers. 32 | my_client.set_headers({'HEADER_NAME': 'HEADER_VALUE'}) 33 | 34 | uploader = my_client.uploader('path/to/file.ext', chunk_size=200) 35 | 36 | # A file stream may also be passed in place of a file path. 37 | fs = open('path/to/file.ext', mode=) 38 | uploader = my_client.uploader(file_stream=fs, chunk_size=200) 39 | 40 | # Upload a chunk i.e 200 bytes. 41 | uploader.upload_chunk() 42 | 43 | # Uploads the entire file. 44 | # This uploads chunk by chunk. 45 | uploader.upload() 46 | 47 | # you could increase the chunk size to reduce the 48 | # number of upload_chunk cycles. 49 | uploader.chunk_size = 800 50 | uploader.upload() 51 | 52 | # Continue uploading chunks till total chunks uploaded reaches 1000 bytes. 53 | uploader.upload(stop_at=1000) 54 | ``` 55 | 56 | If the upload url is known and the client headers are not required, uploaders can also be used standalone. 57 | 58 | ```python 59 | from tusclient.uploader import Uploader 60 | 61 | my_uploader = Uploader('path/to/file.ext', 62 | url='http://tusd.tusdemo.net/files/abcdef123456', 63 | chunk_size=200) 64 | ``` 65 | 66 | ## Development 67 | 68 | If you want to work on tus-py-client internally, follow these few steps: 69 | 70 | 1. Setup virtual environment and install dependencies 71 | 72 | ```bash 73 | python -m venv env/ 74 | source env/bin/activate 75 | pip install -e .[test] 76 | ``` 77 | 78 | 2. Running tests 79 | 80 | ```bash 81 | pytest 82 | ``` 83 | 84 | 3. Releasing a new version (see https://realpython.com/pypi-publish-python-package/) 85 | 86 | ```bash 87 | # Update version in tusclient/__init__.py 88 | vim tusclient/__init__.py 89 | 90 | # Update changelogs 91 | vim CHANGELOG.md 92 | 93 | pytest 94 | 95 | # Commit and tag 96 | git commit -m 'v1.2.3' 97 | git tag v1.2.3 98 | 99 | # Build and release 100 | pip install build twine 101 | python -m build 102 | twine check dist/* 103 | twine upload dist/* 104 | 105 | # Then: make release on GitHub 106 | ``` 107 | 108 | ## License 109 | 110 | MIT 111 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = tuspy 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | set SPHINXPROJ=tuspy 13 | 14 | if "%1" == "" goto help 15 | 16 | %SPHINXBUILD% >NUL 2>NUL 17 | if errorlevel 9009 ( 18 | echo. 19 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 20 | echo.installed, then set the SPHINXBUILD environment variable to point 21 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 22 | echo.may add the Sphinx directory to PATH. 23 | echo. 24 | echo.If you don't have Sphinx installed, grab it from 25 | echo.http://sphinx-doc.org/ 26 | exit /b 1 27 | ) 28 | 29 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 30 | goto end 31 | 32 | :help 33 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 34 | 35 | :end 36 | popd 37 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Configuration file for the Sphinx documentation builder. 4 | # 5 | # This file does only contain a selection of the most common options. For a 6 | # full list see the documentation: 7 | # http://www.sphinx-doc.org/en/stable/config 8 | 9 | # -- Path setup -------------------------------------------------------------- 10 | 11 | # If extensions (or modules to document with autodoc) are in another directory, 12 | # add these directories to sys.path here. If the directory is relative to the 13 | # documentation root, use os.path.abspath to make it absolute, like shown here. 14 | # 15 | import os 16 | import sys 17 | sys.path.insert(0, os.path.abspath('../../')) 18 | 19 | 20 | # -- Project information ----------------------------------------------------- 21 | 22 | project = 'tuspy' 23 | copyright = '2018, Ifedapo Olarewaju' 24 | author = 'Ifedapo Olarewaju' 25 | 26 | # The short X.Y version 27 | version = '' 28 | # The full version, including alpha/beta/rc tags 29 | release = '' 30 | 31 | 32 | # -- General configuration --------------------------------------------------- 33 | 34 | # If your documentation needs a minimal Sphinx version, state it here. 35 | # 36 | # needs_sphinx = '1.0' 37 | 38 | # Add any Sphinx extension module names here, as strings. They can be 39 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 40 | # ones. 41 | extensions = ['sphinx.ext.autodoc'] 42 | 43 | # Add any paths that contain templates here, relative to this directory. 44 | templates_path = ['_templates'] 45 | 46 | # The suffix(es) of source filenames. 47 | # You can specify multiple suffix as a list of string: 48 | # 49 | # source_suffix = ['.rst', '.md'] 50 | source_suffix = '.rst' 51 | 52 | # The master toctree document. 53 | master_doc = 'index' 54 | 55 | # The language for content autogenerated by Sphinx. Refer to documentation 56 | # for a list of supported languages. 57 | # 58 | # This is also used if you do content translation via gettext catalogs. 59 | # Usually you set "language" from the command line for these cases. 60 | language = None 61 | 62 | # List of patterns, relative to source directory, that match files and 63 | # directories to ignore when looking for source files. 64 | # This pattern also affects html_static_path and html_extra_path . 65 | exclude_patterns = [] 66 | 67 | # The name of the Pygments (syntax highlighting) style to use. 68 | pygments_style = 'sphinx' 69 | 70 | 71 | # -- Options for HTML output ------------------------------------------------- 72 | 73 | # The theme to use for HTML and HTML Help pages. See the documentation for 74 | # a list of builtin themes. 75 | # 76 | html_theme = 'alabaster' 77 | 78 | # Theme options are theme-specific and customize the look and feel of a theme 79 | # further. For a list of options available for each theme, see the 80 | # documentation. 81 | # 82 | # html_theme_options = {} 83 | 84 | # Add any paths that contain custom static files (such as style sheets) here, 85 | # relative to this directory. They are copied after the builtin static files, 86 | # so a file named "default.css" will overwrite the builtin "default.css". 87 | html_static_path = ['_static'] 88 | 89 | # Custom sidebar templates, must be a dictionary that maps document names 90 | # to template names. 91 | # 92 | # The default sidebars (for documents that don't match any pattern) are 93 | # defined by theme itself. Builtin themes are using these templates by 94 | # default: ``['localtoc.html', 'relations.html', 'sourcelink.html', 95 | # 'searchbox.html']``. 96 | # 97 | # html_sidebars = {} 98 | 99 | 100 | # -- Options for HTMLHelp output --------------------------------------------- 101 | 102 | # Output file base name for HTML help builder. 103 | htmlhelp_basename = 'tuspydoc' 104 | 105 | 106 | # -- Options for LaTeX output ------------------------------------------------ 107 | 108 | latex_elements = { 109 | # The paper size ('letterpaper' or 'a4paper'). 110 | # 111 | # 'papersize': 'letterpaper', 112 | 113 | # The font size ('10pt', '11pt' or '12pt'). 114 | # 115 | # 'pointsize': '10pt', 116 | 117 | # Additional stuff for the LaTeX preamble. 118 | # 119 | # 'preamble': '', 120 | 121 | # Latex figure (float) alignment 122 | # 123 | # 'figure_align': 'htbp', 124 | } 125 | 126 | # Grouping the document tree into LaTeX files. List of tuples 127 | # (source start file, target name, title, 128 | # author, documentclass [howto, manual, or own class]). 129 | latex_documents = [ 130 | (master_doc, 'tuspy.tex', 'tuspy Documentation', 131 | 'Ifedapo Olarewaju', 'manual'), 132 | ] 133 | 134 | 135 | # -- Options for manual page output ------------------------------------------ 136 | 137 | # One entry per manual page. List of tuples 138 | # (source start file, name, description, authors, manual section). 139 | man_pages = [ 140 | (master_doc, 'tuspy', 'tuspy Documentation', 141 | [author], 1) 142 | ] 143 | 144 | 145 | # -- Options for Texinfo output ---------------------------------------------- 146 | 147 | # Grouping the document tree into Texinfo files. List of tuples 148 | # (source start file, target name, title, author, 149 | # dir menu entry, description, category) 150 | texinfo_documents = [ 151 | (master_doc, 'tuspy', 'tuspy Documentation', 152 | author, 'tuspy', 'One line description of project.', 153 | 'Miscellaneous'), 154 | ] -------------------------------------------------------------------------------- /docs/source/fingerprint.rst: -------------------------------------------------------------------------------- 1 | fingerprint package 2 | =================== 3 | 4 | .. automodule:: tusclient.fingerprint 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | 9 | Submodules 10 | ---------- 11 | 12 | tusclient.fingerprint.interface module 13 | -------------------------------------- 14 | 15 | .. automodule:: tusclient.fingerprint.interface 16 | :members: 17 | :undoc-members: 18 | :show-inheritance: 19 | 20 | tusclient.fingerprint.fingerprint module 21 | ---------------------------------------- 22 | 23 | .. automodule:: tusclient.fingerprint.fingerprint 24 | :members: 25 | :undoc-members: 26 | :show-inheritance: 27 | 28 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. tuspy documentation master file, created by 2 | sphinx-quickstart on Mon Mar 19 01:01:39 2018. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to tuspy's documentation! 7 | ================================= 8 | 9 | |Build Status| 10 | 11 | .. |Build Status| image:: https://travis-ci.org/tus/tus-py-client.svg?branch=master 12 | :target: https://travis-ci.org/tus/tus-py-client 13 | 14 | # tus-py-client 15 | A Python client for the tus resumable upload protocol -> http://tus.io 16 | 17 | .. toctree:: 18 | :maxdepth: 2 19 | :caption: Contents: 20 | 21 | tusclient 22 | storage 23 | fingerprint 24 | 25 | 26 | 27 | Indices and tables 28 | ================== 29 | 30 | * :ref:`genindex` 31 | * :ref:`modindex` 32 | * :ref:`search` 33 | 34 | Quickstart 35 | ========== 36 | 37 | Installation 38 | ~~~~~~~~~~~~ 39 | 40 | .. code:: bash 41 | 42 | pip install tuspy 43 | 44 | Now you are ready to use the api. 45 | 46 | .. code:: python 47 | 48 | from tusclient import client 49 | 50 | # Set Authorization headers if it is required 51 | # by the tus server. 52 | my_client = client.TusClient('http://tusd.tusdemo.net/files/', 53 | headers={'Authorization': 'Basic xxyyZZAAbbCC='}) 54 | 55 | # set more headers 56 | my_client.set_headers({'HEADER_NAME': 'HEADER_VALUE'}) 57 | 58 | uploader = my_client.uploader('path/to/file.ext', chunk_size=200) 59 | 60 | # A file stream may also be passed in place of a file path. 61 | fs = open('path/to/file.ext') 62 | uploader = my_client.uploader(file_stream=fs, chunk_size=200) 63 | 64 | # upload a chunk i.e 200 bytes 65 | uploader.upload_chunk() 66 | 67 | # uploads the entire file. 68 | # This uploads chunk by chunk. 69 | uploader.upload() 70 | 71 | # you could increase the chunk size to reduce the 72 | # number of upload_chunk cycles. 73 | uploader.chunk_size = 800 74 | uploader.upload() 75 | 76 | # Continue uploading chunks till total chunks uploaded reaches 1000 bytes. 77 | uploader.upload(stop_at=1000) 78 | 79 | If the upload url is known and the client headers are not required, 80 | uploaders can also be used standalone. 81 | 82 | .. code:: python 83 | 84 | from tusclient.uploader import Uploader 85 | 86 | my_uploader = Uploader('path/to/file.ext', 87 | url='http://tusd.tusdemo.net/files/abcdef123456', 88 | chunk_size=200) 89 | 90 | URL Storage 91 | ~~~~~~~~~~~ 92 | There is a simple filestorage implementation available to save upload URLs. 93 | 94 | .. code:: python 95 | 96 | from tusclient.uploader import Uploader 97 | from tusclient.storage import filestorage 98 | 99 | storage = filestorage.FileStorage('storage_file') 100 | my_uploader = Uploader('path/to/file.ext', store_url=True, url_storage=storage) 101 | my_uploader.upload() 102 | 103 | While the filestorage is implemented for simple usecases, you may create your own 104 | custom storage class by implementing the **tusclient.storage.interface.Storage** interface. 105 | -------------------------------------------------------------------------------- /docs/source/storage.rst: -------------------------------------------------------------------------------- 1 | storage package 2 | =============== 3 | 4 | .. automodule:: tusclient.storage 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | 9 | Submodules 10 | ---------- 11 | 12 | tusclient.storage.interface module 13 | ---------------------------------- 14 | 15 | .. automodule:: tusclient.storage.interface 16 | :members: 17 | :undoc-members: 18 | :show-inheritance: 19 | 20 | tusclient.storage.filestorage module 21 | ------------------------------------ 22 | 23 | .. automodule:: tusclient.storage.filestorage 24 | :members: 25 | :undoc-members: 26 | :show-inheritance: 27 | 28 | -------------------------------------------------------------------------------- /docs/source/tusclient.rst: -------------------------------------------------------------------------------- 1 | tusclient package 2 | ================= 3 | 4 | .. automodule:: tusclient 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | 9 | Submodules 10 | ---------- 11 | 12 | tusclient.client module 13 | ----------------------- 14 | 15 | .. automodule:: tusclient.client 16 | :members: 17 | :undoc-members: 18 | :show-inheritance: 19 | 20 | tusclient.exceptions module 21 | --------------------------- 22 | 23 | .. automodule:: tusclient.exceptions 24 | :members: 25 | :undoc-members: 26 | :show-inheritance: 27 | 28 | tusclient.request module 29 | ------------------------ 30 | 31 | .. automodule:: tusclient.request 32 | :members: 33 | :undoc-members: 34 | :show-inheritance: 35 | 36 | tusclient.uploader module 37 | ------------------------- 38 | 39 | .. automodule:: tusclient.uploader 40 | :members: 41 | :undoc-members: 42 | :show-inheritance: 43 | 44 | 45 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | import tusclient 4 | 5 | setup( 6 | name='tuspy', 7 | version=tusclient.__version__, 8 | url='http://github.com/tus/tus-py-client/', 9 | license='MIT', 10 | author='Ifedapo Olarewaju', 11 | install_requires=[ 12 | 'requests>=2.18.4', 13 | 'tinydb>=3.5.0', 14 | 'aiohttp>=3.6.2' 15 | ], 16 | extras_require={ 17 | 'test': [ 18 | 'responses>=0.5.1', 19 | 'aioresponses>=0.6.2', 20 | 'coverage>=4.2', 21 | 'pytest>=3.0.3', 22 | 'pytest-cov>=2.3.1,<2.6', 23 | 'parametrize>=0.1.1' 24 | ], 25 | 'dev': [ 26 | 'tox>=2.3.1', 27 | 'sphinx-autobuild==2021.3.14', 28 | 'Sphinx==1.7.1' 29 | ] 30 | }, 31 | author_email='ifedapoolarewaju@gmail.com', 32 | description='A Python client for the tus resumable upload protocol -> http://tus.io', 33 | long_description=open('README.md', encoding='utf-8').read(), 34 | long_description_content_type='text/markdown', 35 | packages=['tusclient', 'tusclient.fingerprint', 'tusclient.storage', 'tusclient.uploader'], 36 | include_package_data=True, 37 | platforms='any', 38 | classifiers=[ 39 | 'Programming Language :: Python', 40 | 'Natural Language :: English', 41 | 'Environment :: Web Environment', 42 | 'Intended Audience :: Developers', 43 | 'Development Status :: 3 - Alpha', 44 | 'License :: OSI Approved :: MIT License', 45 | 'Operating System :: OS Independent', 46 | 'Topic :: Software Development :: Libraries :: Python Modules', 47 | 'Topic :: Internet :: File Transfer Protocol (FTP)', 48 | 'Topic :: Communications :: File Sharing', 49 | ], 50 | python_requires=">=3.5.3", 51 | ) 52 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tus/tus-py-client/e786fa850f0d8e0a2bd137dd91b5fef7c7b703a7/tests/__init__.py -------------------------------------------------------------------------------- /tests/mixin.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import responses 4 | 5 | from tusclient import client 6 | 7 | FILEPATH_TEXT = "tests/sample_files/text.txt" 8 | 9 | class Mixin(unittest.TestCase): 10 | @responses.activate 11 | def setUp(self): 12 | self.client = client.TusClient('http://tusd.tusdemo.net/files/') 13 | self.url = 'http://tusd.tusdemo.net/files/15acd89eabdf5738ffc' 14 | responses.add(responses.HEAD, self.url, 15 | adding_headers={"upload-offset": "0"}) 16 | self.uploader = self.client.uploader(FILEPATH_TEXT, url=self.url) 17 | -------------------------------------------------------------------------------- /tests/sample_files/binary.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tus/tus-py-client/e786fa850f0d8e0a2bd137dd91b5fef7c7b703a7/tests/sample_files/binary.png -------------------------------------------------------------------------------- /tests/sample_files/text.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) The tus-py-client CONTRIBUTORS 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, 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, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/storage_file: -------------------------------------------------------------------------------- 1 | {"_default": {"1": {"key": "size:1082--md5:cbb679e9dbcf82224fe3bc5fdc881f06", "url": "http://tusd.tusdemo.net/files/foo_bar"}, "2": {"key": "size:49588--md5:ae275d47f1ef9aed4902b0251455e627", "url": "http://tusd.tusdemo.net/files/foo_bar"}}} -------------------------------------------------------------------------------- /tests/test_async_uploader.py: -------------------------------------------------------------------------------- 1 | import io 2 | import unittest 3 | from unittest import mock 4 | import asyncio 5 | 6 | from aioresponses import aioresponses, CallbackResult 7 | import responses 8 | import pytest 9 | 10 | from tusclient import exceptions, client 11 | 12 | 13 | class AsyncUploaderTest(unittest.TestCase): 14 | @responses.activate 15 | def setUp(self): 16 | self.client = client.TusClient('http://tusd.tusdemo.net/files/') 17 | self.url = 'http://tusd.tusdemo.net/files/15acd89eabdf5738ffc' 18 | responses.add(responses.HEAD, self.url, 19 | adding_headers={"upload-offset": "0"}) 20 | self.loop = asyncio.new_event_loop() 21 | self.async_uploader = self.client.async_uploader( 22 | './LICENSE', url=self.url) 23 | 24 | def tearDown(self): 25 | self.loop.stop() 26 | 27 | def _validate_request(self, url, **kwargs): 28 | self.assertEqual(self.url, str(url)) 29 | req_headers = kwargs['headers'] 30 | self.assertEqual(req_headers.get('Tus-Resumable'), '1.0.0') 31 | 32 | body = kwargs['data'] 33 | with open('./LICENSE', 'rb') as stream: 34 | expected_content = stream.read() 35 | self.assertEqual(expected_content, body) 36 | 37 | response_headers = { 38 | 'upload-offset': str(self.async_uploader.offset + self.async_uploader.get_request_length())} 39 | 40 | return CallbackResult(status=204, headers=response_headers) 41 | 42 | def test_upload_chunk(self): 43 | with aioresponses() as resps: 44 | resps.patch(self.url, callback=self._validate_request) 45 | 46 | request_length = self.async_uploader.get_request_length() 47 | self.loop.run_until_complete(self.async_uploader.upload_chunk()) 48 | self.assertEqual(self.async_uploader.offset, request_length) 49 | 50 | def test_upload_chunk_with_creation(self): 51 | with aioresponses() as resps: 52 | resps.post( 53 | self.client.url, status=201, 54 | headers={ 55 | "location": f"{self.client.url}hello" 56 | } 57 | ) 58 | resps.patch( 59 | f"{self.client.url}hello", 60 | headers={ 61 | "upload-offset": "5" 62 | } 63 | ) 64 | 65 | uploader = self.client.async_uploader( 66 | file_stream=io.BytesIO(b"hello") 67 | ) 68 | self.loop.run_until_complete(uploader.upload_chunk()) 69 | 70 | self.assertEqual(uploader.url, f"{self.client.url}hello") 71 | 72 | def test_upload(self): 73 | with aioresponses() as resps: 74 | resps.patch(self.url, callback=self._validate_request) 75 | 76 | self.loop.run_until_complete(self.async_uploader.upload()) 77 | self.assertEqual(self.async_uploader.offset, 78 | self.async_uploader.get_file_size()) 79 | 80 | def test_upload_empty(self): 81 | with aioresponses() as resps: 82 | resps.post( 83 | self.client.url, status=200, 84 | headers={ 85 | "upload-offset": "0", 86 | "location": f"{self.client.url}this-is-not-used" 87 | } 88 | ) 89 | resps.patch( 90 | f"{self.client.url}this-is-not-used", 91 | exception=ValueError( 92 | "PATCH request not allowed for empty file" 93 | ) 94 | ) 95 | 96 | # Upload an empty file 97 | async_uploader = self.client.async_uploader( 98 | file_stream=io.BytesIO(b"") 99 | ) 100 | self.loop.run_until_complete(async_uploader.upload()) 101 | 102 | # Upload URL being set means the POST request was sent and the empty 103 | # file was uploaded without a single PATCH request. 104 | self.assertTrue(async_uploader.url) 105 | 106 | def test_upload_retry(self): 107 | num_of_retries = 3 108 | self.async_uploader.retries = num_of_retries 109 | self.async_uploader.retry_delay = 3 110 | with aioresponses() as resps: 111 | resps.patch(self.url, status=00) 112 | 113 | self.assertEqual(self.async_uploader._retried, 0) 114 | with pytest.raises(exceptions.TusCommunicationError): 115 | self.loop.run_until_complete( 116 | self.async_uploader.upload_chunk()) 117 | 118 | self.assertEqual(self.async_uploader._retried, num_of_retries) 119 | 120 | def test_upload_verify_tls_cert(self): 121 | self.async_uploader.verify_tls_cert = False 122 | 123 | with aioresponses() as resps: 124 | ssl = None 125 | 126 | def validate_verify_tls_cert(url, **kwargs): 127 | nonlocal ssl 128 | ssl = kwargs['ssl'] 129 | 130 | response_headers = { 131 | 'upload-offset': str(self.async_uploader.offset + self.async_uploader.get_request_length()) 132 | } 133 | 134 | return CallbackResult(status=204, headers=response_headers) 135 | 136 | resps.patch( 137 | self.url, status=204, callback=validate_verify_tls_cert 138 | ) 139 | self.loop.run_until_complete(self.async_uploader.upload()) 140 | self.assertEqual(ssl, False) 141 | -------------------------------------------------------------------------------- /tests/test_client.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import responses 4 | 5 | from tusclient import client 6 | from tusclient.uploader import Uploader, AsyncUploader 7 | 8 | 9 | class TusClientTest(unittest.TestCase): 10 | def setUp(self): 11 | self.client = client.TusClient('http://tusd.tusdemo.net/files/', 12 | headers={'foo': 'bar'}) 13 | 14 | def test_instance_attributes(self): 15 | self.assertEqual(self.client.url, 'http://tusd.tusdemo.net/files/') 16 | self.assertEqual(self.client.headers, {'foo': 'bar'}) 17 | 18 | def test_set_headers(self): 19 | self.client.set_headers({'foo': 'bar tender'}) 20 | self.assertEqual(self.client.headers, {'foo': 'bar tender'}) 21 | 22 | # uploader headers must update when client headers change 23 | self.client.set_headers({'food': 'at the bar'}) 24 | self.assertEqual(self.client.headers, {'foo': 'bar tender', 'food': 'at the bar'}) 25 | 26 | @responses.activate 27 | def test_uploader(self): 28 | url = 'http://tusd.tusdemo.net/files/15acd89eabdf5738ffc' 29 | responses.add(responses.HEAD, url, 30 | adding_headers={"upload-offset": "0"}) 31 | 32 | uploader = self.client.uploader('./LICENSE', url=url) 33 | 34 | self.assertIsInstance(uploader, Uploader) 35 | self.assertEqual(uploader.client, self.client) 36 | 37 | @responses.activate 38 | def test_async_uploader(self): 39 | url = 'http://tusd.tusdemo.net/files/15acd89eabdf5738ffc' 40 | responses.add(responses.HEAD, url, 41 | adding_headers={"upload-offset": "0"}) 42 | 43 | async_uploader = self.client.async_uploader('./LICENSE', url=url) 44 | 45 | self.assertIsInstance(async_uploader, AsyncUploader) 46 | self.assertEqual(async_uploader.client, self.client) 47 | -------------------------------------------------------------------------------- /tests/test_filestorage.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import os 3 | 4 | from tusclient.storage import filestorage 5 | 6 | 7 | class FileStorageTest(unittest.TestCase): 8 | def setUp(self): 9 | self.storage_path = 'storage.json' 10 | self.storage = filestorage.FileStorage(self.storage_path) 11 | 12 | def tearDown(self): 13 | self.storage.close() 14 | os.remove(self.storage_path) 15 | 16 | def test_set_get_remove_item(self): 17 | url = 'http://tusd.tusdemo.net/files/unique_file_id' 18 | key = 'unique_key' 19 | 20 | url_2 = 'http://tusd.tusdemo.net/files/unique_file_id_2' 21 | key_2 = 'unique_key_2' 22 | self.storage.set_item(key, url) 23 | self.storage.set_item(key_2, url_2) 24 | 25 | self.assertEqual(self.storage.get_item(key), url) 26 | self.assertEqual(self.storage.get_item(key_2), url_2) 27 | 28 | self.storage.remove_item(key) 29 | self.assertIsNone(self.storage.get_item(key)) 30 | -------------------------------------------------------------------------------- /tests/test_fingerprint.py: -------------------------------------------------------------------------------- 1 | import io 2 | import unittest 3 | 4 | from parametrize import parametrize 5 | 6 | from tusclient.fingerprint import fingerprint 7 | 8 | FILEPATH_TEXT = "tests/sample_files/text.txt" 9 | FILEPATH_BINARY = "tests/sample_files/binary.png" 10 | 11 | 12 | class FileStorageTest(unittest.TestCase): 13 | def setUp(self): 14 | self.fingerprinter = fingerprint.Fingerprint() 15 | 16 | @parametrize( 17 | "filename", 18 | [FILEPATH_TEXT, FILEPATH_BINARY], 19 | ) 20 | def test_get_fingerpint(self, filename: str): 21 | with open(filename, "rb") as f: 22 | content = f.read() 23 | buff = io.BytesIO() 24 | buff.write(content) 25 | buff.seek(0) # reset buffer postion before reading 26 | 27 | with open(filename, "rb") as f: 28 | self.assertEqual( 29 | self.fingerprinter.get_fingerprint(buff), 30 | self.fingerprinter.get_fingerprint(f) 31 | ) 32 | 33 | @parametrize( 34 | "filename", 35 | [FILEPATH_TEXT, FILEPATH_BINARY], 36 | ) 37 | def test_unique_fingerprint(self, filename: str): 38 | with open(filename, "rb") as f: 39 | content = f.read() 40 | buff = io.BytesIO() 41 | buff.write(content + b's') # add some salt to change value 42 | buff.seek(0) # reset buffer postion before reading 43 | 44 | with open(filename, "rb") as f: 45 | self.assertNotEqual( 46 | self.fingerprinter.get_fingerprint(buff), 47 | self.fingerprinter.get_fingerprint(f) 48 | ) 49 | -------------------------------------------------------------------------------- /tests/test_request.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import hashlib 3 | 4 | from parametrize import parametrize 5 | import responses 6 | 7 | from tusclient import request 8 | from tests import mixin 9 | 10 | 11 | FILEPATH_TEXT = "tests/sample_files/text.txt" 12 | FILEPATH_BINARY = "tests/sample_files/binary.png" 13 | 14 | 15 | class TusRequestTest(mixin.Mixin): 16 | def setUp(self): 17 | super(TusRequestTest, self).setUp() 18 | self.request = request.TusRequest(self.uploader) 19 | 20 | @parametrize( 21 | "filename", 22 | [FILEPATH_TEXT, FILEPATH_BINARY], 23 | ) 24 | def test_perform(self, filename: str): 25 | with open(FILEPATH_TEXT, "rb") as stream, responses.RequestsMock() as resps: 26 | size = stream.tell() 27 | resps.add(responses.PATCH, self.url, 28 | adding_headers={'upload-offset': str(size)}, 29 | status=204) 30 | 31 | self.request.perform() 32 | self.assertEqual(str(size), self.request.response_headers['upload-offset']) 33 | 34 | def test_perform_checksum(self): 35 | self.uploader.upload_checksum = True 36 | tus_request = request.TusRequest(self.uploader) 37 | 38 | with open(FILEPATH_TEXT, "rb") as stream, responses.RequestsMock() as resps: 39 | content = stream.read() 40 | expected_checksum = "sha1 " + \ 41 | base64.standard_b64encode(hashlib.sha1( 42 | content).digest()).decode("ascii") 43 | 44 | sent_checksum = '' 45 | def validate_headers(req): 46 | nonlocal sent_checksum 47 | sent_checksum = req.headers['upload-checksum'] 48 | return (204, {}, None) 49 | 50 | resps.add_callback(responses.PATCH, self.url, callback=validate_headers) 51 | tus_request.perform() 52 | self.assertEqual(sent_checksum, expected_checksum) 53 | 54 | def test_verify_tls_cert(self): 55 | self.uploader.verify_tls_cert = False 56 | tus_request = request.TusRequest(self.uploader) 57 | 58 | with responses.RequestsMock() as resps: 59 | verify = None 60 | 61 | def validate_verify(req): 62 | nonlocal verify 63 | verify = req.req_kwargs['verify'] 64 | return (204, {}, None) 65 | 66 | resps.add_callback(responses.PATCH, self.url, callback=validate_verify) 67 | tus_request.perform() 68 | self.assertEqual(verify, False) 69 | 70 | -------------------------------------------------------------------------------- /tests/test_uploader.py: -------------------------------------------------------------------------------- 1 | import os 2 | import io 3 | import tempfile 4 | from base64 import b64encode 5 | from unittest import mock 6 | 7 | import responses 8 | from responses import matchers 9 | from parametrize import parametrize 10 | import pytest 11 | 12 | from tusclient import exceptions 13 | from tusclient.storage import filestorage 14 | from tests import mixin 15 | 16 | 17 | FILEPATH_TEXT = "tests/sample_files/text.txt" 18 | FILEPATH_BINARY = "tests/sample_files/binary.png" 19 | 20 | 21 | class UploaderTest(mixin.Mixin): 22 | 23 | def mock_request(self, request_mock): 24 | request_mock = request_mock.return_value 25 | request_mock.status_code = 204 26 | request_mock.response_headers = { 27 | 'upload-offset': self.uploader.offset + self.uploader.get_request_length()} 28 | request_mock.perform.return_value = None 29 | return request_mock 30 | 31 | def test_instance_attributes(self): 32 | self.assertEqual(self.uploader.chunk_size, self.uploader.DEFAULT_CHUNK_SIZE) 33 | self.assertEqual(self.uploader.client, self.client) 34 | self.assertEqual(self.uploader.offset, 0) 35 | 36 | def test_headers(self): 37 | self.assertEqual(self.uploader.get_headers(), {"Tus-Resumable": "1.0.0"}) 38 | 39 | self.client.set_headers({'foo': 'bar'}) 40 | self.assertEqual(self.uploader.get_headers(), {"Tus-Resumable": "1.0.0", 'foo': 'bar'}) 41 | 42 | @responses.activate 43 | def test_get_offset(self): 44 | responses.add(responses.HEAD, self.uploader.url, 45 | adding_headers={"upload-offset": "300"}) 46 | self.assertEqual(self.uploader.get_offset(), 300) 47 | 48 | def test_encode_metadata(self): 49 | self.uploader.metadata = {'foo': 'bar', 'red': 'blue'} 50 | encoded_metadata = ['foo' + ' ' + b64encode(b'bar').decode('ascii'), 51 | 'red' + ' ' + b64encode(b'blue').decode('ascii')] 52 | self.assertCountEqual(self.uploader.encode_metadata(), encoded_metadata) 53 | 54 | with pytest.raises(ValueError): 55 | self.uploader.metadata = {'foo, ': 'bar'} 56 | self.uploader.encode_metadata() 57 | 58 | def test_encode_metadata_utf8(self): 59 | self.uploader.metadata = {'foo': 'bär', 'red': '🔵'} 60 | self.uploader.metadata_encoding = 'utf-8' 61 | encoded_metadata = [ 62 | 'foo ' + b64encode('bär'.encode('utf-8')).decode('ascii'), 63 | 'red ' + b64encode('🔵'.encode('utf-8')).decode('ascii') 64 | ] 65 | self.assertCountEqual(self.uploader.encode_metadata(), encoded_metadata) 66 | 67 | @responses.activate 68 | def test_create_url_absolute(self): 69 | responses.add(responses.POST, self.client.url, 70 | adding_headers={"location": 'http://tusd.tusdemo.net/files/foo'}) 71 | self.assertEqual(self.uploader.create_url(), 'http://tusd.tusdemo.net/files/foo') 72 | 73 | @responses.activate 74 | def test_create_url_relative(self): 75 | responses.add(responses.POST, self.client.url, 76 | adding_headers={"location": "/files/foo"}) 77 | self.assertEqual(self.uploader.create_url(), 'http://tusd.tusdemo.net/files/foo') 78 | 79 | @parametrize( 80 | "filename", 81 | [FILEPATH_TEXT, FILEPATH_BINARY], 82 | ) 83 | @responses.activate 84 | def test_url(self, filename: str): 85 | # test for stored urls 86 | responses.add(responses.HEAD, 'http://tusd.tusdemo.net/files/foo_bar', 87 | adding_headers={"upload-offset": "10"}) 88 | storage_path = '{}/storage_file'.format(os.path.dirname(os.path.abspath(__file__))) 89 | resumable_uploader = self.client.uploader( 90 | file_path=filename, store_url=True, url_storage=filestorage.FileStorage(storage_path) 91 | ) 92 | self.assertEqual(resumable_uploader.url, "http://tusd.tusdemo.net/files/foo_bar") 93 | self.assertEqual(resumable_uploader.offset, 10) 94 | 95 | @parametrize( 96 | "filename", 97 | [FILEPATH_TEXT, FILEPATH_BINARY], 98 | ) 99 | @responses.activate 100 | def test_url_voided(self, filename: str): 101 | # Test that voided stored url are cleared 102 | responses.add( 103 | responses.POST, 104 | self.client.url, 105 | adding_headers={"location": "http://tusd.tusdemo.net/files/foo"}, 106 | ) 107 | responses.add( 108 | responses.HEAD, 109 | "http://tusd.tusdemo.net/files/foo", 110 | status=404, 111 | ) 112 | 113 | # Create temporary storage file. 114 | temp_fp = tempfile.NamedTemporaryFile(delete=False) 115 | storage = filestorage.FileStorage(temp_fp.name) 116 | uploader = self.client.uploader( 117 | file_path=filename, store_url=True, url_storage=storage 118 | ) 119 | 120 | # Conduct only POST creation so that we'd get a storage entry. 121 | uploader.upload(stop_at=-1) 122 | key = uploader._get_fingerprint() 123 | # First ensure that an entry was created and stored. 124 | self.assertIsNotNone(uploader.url) 125 | self.assertIsNotNone(storage.get_item(key)) 126 | 127 | # Now start a new upload, resuming where we left off. 128 | resumed_uploader = self.client.uploader( 129 | file_path=filename, store_url=True, url_storage=storage 130 | ) 131 | # HEAD response was 404 so url and storage has to be voided. 132 | self.assertIsNone(resumed_uploader.url) 133 | self.assertIsNone(storage.get_item(key)) 134 | 135 | # Remove the temporary storage file. 136 | storage.close() 137 | temp_fp.close() 138 | os.remove(temp_fp.name) 139 | 140 | def test_request_length(self): 141 | self.uploader.chunk_size = 200 142 | self.assertEqual(self.uploader.get_request_length(), 200) 143 | 144 | self.uploader.chunk_size = self.uploader.get_file_size() + 3000 145 | self.assertEqual(self.uploader.get_request_length(), self.uploader.get_file_size()) 146 | 147 | @parametrize( 148 | "filename", 149 | [FILEPATH_TEXT, FILEPATH_BINARY], 150 | ) 151 | def test_get_file_stream(self, filename: str): 152 | with open(filename, "rb") as fs: 153 | self.uploader.file_stream = fs 154 | self.uploader.file_path = None 155 | self.assertEqual(self.uploader.file_stream, self.uploader.get_file_stream()) 156 | 157 | with open(filename, "rb") as fs: 158 | self.uploader.file_stream = None 159 | self.uploader.file_path = filename 160 | with self.uploader.get_file_stream() as stream: 161 | self.assertEqual(fs.read(), stream.read()) 162 | 163 | @parametrize( 164 | "filename", 165 | [FILEPATH_TEXT, FILEPATH_BINARY], 166 | ) 167 | def test_file_size(self, filename: str): 168 | self.assertEqual(self.uploader.get_file_size(), os.path.getsize(self.uploader.file_path)) 169 | 170 | with open(filename, "rb") as fs: 171 | self.uploader.file_stream = fs 172 | self.uploader.file_path = None 173 | self.assertEqual(self.uploader.get_file_size(), os.path.getsize(filename)) 174 | 175 | @mock.patch('tusclient.uploader.uploader.TusRequest') 176 | def test_upload_chunk(self, request_mock): 177 | self.mock_request(request_mock) 178 | 179 | self.uploader.offset = 0 180 | request_length = self.uploader.get_request_length() 181 | self.uploader.upload_chunk() 182 | self.assertEqual(self.uploader.offset, request_length) 183 | 184 | @responses.activate 185 | def test_upload_chunk_with_creation(self): 186 | responses.add( 187 | responses.POST, self.client.url, 188 | adding_headers={ 189 | "location": f"{self.client.url}hello" 190 | } 191 | ) 192 | responses.add( 193 | responses.PATCH, 194 | f"{self.client.url}hello", 195 | adding_headers={ 196 | "upload-offset": "5" 197 | } 198 | ) 199 | 200 | uploader = self.client.uploader( 201 | file_stream=io.BytesIO(b"hello") 202 | ) 203 | uploader.upload_chunk() 204 | 205 | self.assertEqual(uploader.url, f"{self.client.url}hello") 206 | 207 | @mock.patch('tusclient.uploader.uploader.TusRequest') 208 | def test_upload(self, request_mock): 209 | self.mock_request(request_mock) 210 | 211 | self.uploader.upload() 212 | self.assertEqual(self.uploader.offset, self.uploader.get_file_size()) 213 | 214 | @mock.patch('tusclient.uploader.uploader.TusRequest') 215 | def test_upload_retry(self, request_mock): 216 | num_of_retries = 3 217 | self.uploader.retries = num_of_retries 218 | self.uploader.retry_delay = 3 219 | 220 | request_mock = self.mock_request(request_mock) 221 | request_mock.status_code = 00 222 | 223 | self.assertEqual(self.uploader._retried, 0) 224 | with pytest.raises(exceptions.TusCommunicationError): 225 | self.uploader.upload_chunk() 226 | self.assertEqual(self.uploader._retried, num_of_retries) 227 | 228 | @responses.activate 229 | def test_upload_empty(self): 230 | responses.add( 231 | responses.POST, self.client.url, 232 | adding_headers={ 233 | "upload-offset": "0", 234 | "location": f"{self.client.url}this-is-not-used" 235 | } 236 | ) 237 | responses.add( 238 | responses.PATCH, 239 | f"{self.client.url}this-is-not-used", 240 | body=ValueError("PATCH request not allowed for empty file") 241 | ) 242 | 243 | # Upload an empty file 244 | uploader = self.client.uploader( 245 | file_stream=io.BytesIO(b"") 246 | ) 247 | uploader.upload() 248 | 249 | # Upload URL being set means the POST request was sent and the empty 250 | # file was uploaded without a single PATCH request. 251 | self.assertTrue(uploader.url) 252 | 253 | @mock.patch('tusclient.uploader.uploader.TusRequest') 254 | def test_upload_checksum(self, request_mock): 255 | self.mock_request(request_mock) 256 | self.uploader.upload_checksum = True 257 | self.uploader.upload() 258 | self.assertEqual(self.uploader.offset, self.uploader.get_file_size()) 259 | 260 | @parametrize("chunk_size", [1, 2, 3, 4, 5, 6]) 261 | @responses.activate 262 | def test_upload_length_deferred(self, chunk_size: int): 263 | upload_url = f"{self.client.url}test_upload_length_deferred" 264 | 265 | responses.head( 266 | upload_url, 267 | adding_headers={"upload-offset": "0", "Upload-Defer-Length": "1"}, 268 | ) 269 | uploader = self.client.uploader( 270 | file_stream=io.BytesIO(b"hello"), 271 | url=upload_url, 272 | chunk_size=chunk_size, 273 | upload_length_deferred=True, 274 | ) 275 | self.assertTrue(uploader.upload_length_deferred) 276 | self.assertTrue(uploader.stop_at is None) 277 | 278 | offset = 0 279 | while not (offset + chunk_size > 5): 280 | next_offset = min(offset + chunk_size, 5) 281 | responses.patch( 282 | upload_url, 283 | adding_headers={"upload-offset": str(next_offset)}, 284 | match=[matchers.header_matcher({"upload-offset": str(offset)})], 285 | ) 286 | offset = next_offset 287 | last_req_headers = {"upload-offset": str(offset)} 288 | last_req_headers["upload-length"] = "5" 289 | responses.patch( 290 | upload_url, 291 | adding_headers={"upload-offset": "5"}, 292 | match=[matchers.header_matcher(last_req_headers)], 293 | ) 294 | 295 | uploader.upload() 296 | self.assertEqual(uploader.offset, 5) 297 | self.assertEqual(uploader.stop_at, 5) 298 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py27,py36 3 | 4 | skipsdist = True 5 | [testenv] 6 | deps = -rrequirements.txt 7 | commands=py.test --cov=tusclient 8 | -------------------------------------------------------------------------------- /tusclient/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "1.1.0" 2 | -------------------------------------------------------------------------------- /tusclient/client.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Optional, Tuple, Union 2 | 3 | from tusclient.uploader import Uploader, AsyncUploader 4 | 5 | 6 | class TusClient: 7 | """ 8 | Object representation of Tus client. 9 | 10 | :Attributes: 11 | - url (str): 12 | represents the tus server's create extension url. On instantiation this argument 13 | must be passed to the constructor. 14 | - headers (dict): 15 | This can be used to set the server specific headers. These headers would be sent 16 | along with every request made by the cleint to the server. This may be used to set 17 | authentication headers. These headers should not include headers required by tus 18 | protocol. If not set this defaults to an empty dictionary. 19 | - client_cert (str|tuple[str,str]): 20 | Path of PEM encoded client certitifacate and optionally path to PEM encoded 21 | key file. The PEM encoded key of the certificate can either be included in the 22 | certificate itself or be provided in a seperate file. 23 | Only unencrypted keys are supported! 24 | :Constructor Args: 25 | - url (str) 26 | - headers (Optiional[dict]) 27 | - client_cert (Optional[str | Tuple[str, str]]) 28 | """ 29 | 30 | def __init__(self, url: str, headers: Optional[Dict[str, str]] = None, client_cert: Optional[Union[str, Tuple[str, str]]] = None): 31 | self.url = url 32 | self.headers = headers or {} 33 | self.client_cert = client_cert 34 | 35 | def set_headers(self, headers: Dict[str, str]): 36 | """ 37 | Set tus client headers. 38 | 39 | Update and/or set new headers that would be sent along with every request made 40 | to the server. 41 | 42 | :Args: 43 | - headers (dict): 44 | key, value pairs of the headers to be set. This argument is required. 45 | """ 46 | self.headers.update(headers) 47 | 48 | def uploader(self, *args, **kwargs) -> Uploader: 49 | """ 50 | Return uploader instance pointing at current client instance. 51 | 52 | Return uploader instance with which you can control the upload of a specific 53 | file. The current instance of the tus client is passed to the uploader on creation. 54 | 55 | :Args: 56 | see tusclient.uploader.Uploader for required and optional arguments. 57 | """ 58 | kwargs["client"] = self 59 | return Uploader(*args, **kwargs) 60 | 61 | def async_uploader(self, *args, **kwargs) -> AsyncUploader: 62 | kwargs["client"] = self 63 | return AsyncUploader(*args, **kwargs) 64 | -------------------------------------------------------------------------------- /tusclient/exceptions.py: -------------------------------------------------------------------------------- 1 | """ 2 | Global Tusclient exception and warning classes. 3 | """ 4 | 5 | 6 | class TusCommunicationError(Exception): 7 | """ 8 | Should be raised when communications with tus-server behaves 9 | unexpectedly. 10 | 11 | :Attributes: 12 | - message (str): 13 | Main message of the exception 14 | - status_code (int): 15 | Status code of response indicating an error 16 | - response_content (str): 17 | Content of response indicating an error 18 | :Constructor Args: 19 | - message (Optional[str]) 20 | - status_code (Optional[int]) 21 | - response_content (Optional[str]) 22 | """ 23 | 24 | def __init__(self, message, status_code=None, response_content=None): 25 | default_message = "Communication with tus server failed with status {}".format( 26 | status_code 27 | ) 28 | message = message or default_message 29 | super(TusCommunicationError, self).__init__(message) 30 | self.status_code = status_code 31 | self.response_content = response_content 32 | 33 | 34 | class TusUploadFailed(TusCommunicationError): 35 | """Should be raised when an attempted upload fails""" 36 | -------------------------------------------------------------------------------- /tusclient/fingerprint/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tus/tus-py-client/e786fa850f0d8e0a2bd137dd91b5fef7c7b703a7/tusclient/fingerprint/__init__.py -------------------------------------------------------------------------------- /tusclient/fingerprint/fingerprint.py: -------------------------------------------------------------------------------- 1 | """ 2 | An implementation of of , 3 | using the hashlib to generate an md5 hash based on the file content 4 | """ 5 | from typing import IO 6 | import hashlib 7 | import os 8 | 9 | from . import interface 10 | 11 | 12 | class Fingerprint(interface.Fingerprint): 13 | BLOCK_SIZE = 65536 14 | 15 | def get_fingerprint(self, fs: IO): 16 | """ 17 | Return a unique fingerprint string value based on the file stream recevied 18 | 19 | :Args: 20 | - fs[IO]: The file stream instance of the file for which a fingerprint would be generated. 21 | :Returns: fingerprint[str] 22 | """ 23 | hasher = hashlib.md5() 24 | # we encode the content to avoid python 3 uncicode errors 25 | buf = self._encode_data(fs.read(self.BLOCK_SIZE)) 26 | hasher.update(buf) 27 | # add in the file size to minimize chances of collision 28 | fs.seek(0, os.SEEK_END) 29 | file_size = fs.tell() 30 | return "size:{}--md5:{}".format(file_size, hasher.hexdigest()) 31 | 32 | def _encode_data(self, data): 33 | try: 34 | return data.encode("utf-8") 35 | except AttributeError: 36 | # in case the content is already binary, this failure would happen. 37 | return data 38 | -------------------------------------------------------------------------------- /tusclient/fingerprint/interface.py: -------------------------------------------------------------------------------- 1 | """ 2 | Interface module defining a fingerprint generator based on file content. 3 | """ 4 | from typing import IO 5 | import abc 6 | 7 | 8 | class Fingerprint(abc.ABC): 9 | """An interface specifying the requirements of a file fingerprint""" 10 | 11 | @abc.abstractmethod 12 | def get_fingerprint(self, fs: IO): 13 | """ 14 | Return a unique fingerprint string value based on the file stream recevied 15 | 16 | :Args: 17 | - fs[IO]: The file stream instance of the file for which a fingerprint would be generated. 18 | :Returns: fingerprint[str] 19 | """ 20 | -------------------------------------------------------------------------------- /tusclient/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tus/tus-py-client/e786fa850f0d8e0a2bd137dd91b5fef7c7b703a7/tusclient/py.typed -------------------------------------------------------------------------------- /tusclient/request.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | import base64 3 | import asyncio 4 | from functools import wraps 5 | 6 | import requests 7 | import aiohttp 8 | import ssl 9 | 10 | from tusclient.exceptions import TusUploadFailed, TusCommunicationError 11 | 12 | 13 | # Catches requests exceptions and throws custom tuspy errors. 14 | def catch_requests_error(func): 15 | """Deocrator to catch requests exceptions""" 16 | 17 | @wraps(func) 18 | def _wrapper(*args, **kwargs): 19 | try: 20 | return func(*args, **kwargs) 21 | except requests.exceptions.RequestException as error: 22 | raise TusCommunicationError(error) 23 | 24 | return _wrapper 25 | 26 | 27 | class BaseTusRequest: 28 | """ 29 | Http Request Abstraction. 30 | 31 | Sets up tus custom http request on instantiation. 32 | 33 | requires argument 'uploader' an instance of tusclient.uploader.Uploader 34 | on instantiation. 35 | 36 | :Attributes: 37 | - response_headers (dict) 38 | - file (file): 39 | The file that is being uploaded. 40 | """ 41 | 42 | def __init__(self, uploader): 43 | self._url = uploader.url 44 | self.status_code = None 45 | self.response_headers = {} 46 | self.response_content = None 47 | self.stream_eof = False 48 | self.verify_tls_cert = bool(uploader.verify_tls_cert) 49 | self.file = uploader.get_file_stream() 50 | self.file.seek(uploader.offset) 51 | self.client_cert = uploader.client_cert 52 | 53 | self._request_headers = { 54 | "upload-offset": str(uploader.offset), 55 | "Content-Type": "application/offset+octet-stream", 56 | } 57 | self._offset = uploader.offset 58 | self._upload_length_deferred = uploader.upload_length_deferred 59 | self._request_headers.update(uploader.get_headers()) 60 | self._content_length = uploader.get_request_length() 61 | self._upload_checksum = uploader.upload_checksum 62 | self._checksum_algorithm = uploader.checksum_algorithm 63 | self._checksum_algorithm_name = uploader.checksum_algorithm_name 64 | 65 | def add_checksum(self, chunk: bytes): 66 | if self._upload_checksum: 67 | self._request_headers["upload-checksum"] = " ".join( 68 | ( 69 | self._checksum_algorithm_name, 70 | base64.b64encode(self._checksum_algorithm(chunk).digest()).decode( 71 | "ascii" 72 | ), 73 | ) 74 | ) 75 | 76 | 77 | class TusRequest(BaseTusRequest): 78 | """Class to handle async Tus upload requests""" 79 | 80 | def perform(self): 81 | """ 82 | Perform actual request. 83 | """ 84 | try: 85 | chunk = self.file.read(self._content_length) 86 | stream_eof = len(chunk) < self._content_length 87 | self.add_checksum(chunk) 88 | headers = self._request_headers 89 | if stream_eof and self._upload_length_deferred: 90 | headers["upload-length"] = str(self._offset + len(chunk)) 91 | resp = requests.patch( 92 | self._url, 93 | data=chunk, 94 | headers=headers, 95 | verify=self.verify_tls_cert, 96 | stream=True, 97 | cert=self.client_cert 98 | ) 99 | self.status_code = resp.status_code 100 | self.response_content = resp.content 101 | self.response_headers = {k.lower(): v for k, v in resp.headers.items()} 102 | self.stream_eof = stream_eof 103 | except requests.exceptions.RequestException as error: 104 | raise TusUploadFailed(error) 105 | 106 | 107 | class AsyncTusRequest(BaseTusRequest): 108 | """Class to handle async Tus upload requests""" 109 | 110 | def __init__( 111 | self, *args, io_loop: Optional[asyncio.AbstractEventLoop] = None, **kwargs 112 | ): 113 | self.io_loop = io_loop 114 | super().__init__(*args, **kwargs) 115 | 116 | async def perform(self): 117 | """ 118 | Perform actual request. 119 | """ 120 | chunk = self.file.read(self._content_length) 121 | self.add_checksum(chunk) 122 | try: 123 | ssl_ctx = ssl.create_default_context() 124 | if (self.client_cert is not None): 125 | if self.client_cert is str: 126 | ssl_ctx.load_cert_chain(certfile=self.client_cert) 127 | else: 128 | ssl_ctx.load_cert_chain(certfile=self.client_cert[0], keyfile=self.client_cert[1]) 129 | conn = aiohttp.TCPConnector(ssl=ssl_ctx) 130 | async with aiohttp.ClientSession(loop=self.io_loop, connector=conn) as session: 131 | verify_tls_cert = None if self.verify_tls_cert else False 132 | async with session.patch( 133 | self._url, data=chunk, headers=self._request_headers, ssl=verify_tls_cert 134 | ) as resp: 135 | self.status_code = resp.status 136 | self.response_headers = { 137 | k.lower(): v for k, v in resp.headers.items() 138 | } 139 | self.response_content = await resp.content.read() 140 | except aiohttp.ClientError as error: 141 | raise TusUploadFailed(error) 142 | -------------------------------------------------------------------------------- /tusclient/storage/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tus/tus-py-client/e786fa850f0d8e0a2bd137dd91b5fef7c7b703a7/tusclient/storage/__init__.py -------------------------------------------------------------------------------- /tusclient/storage/filestorage.py: -------------------------------------------------------------------------------- 1 | """ 2 | An implementation of , using a file as storage. 3 | """ 4 | from tinydb import TinyDB, Query 5 | 6 | from . import interface 7 | 8 | 9 | class FileStorage(interface.Storage): 10 | def __init__(self, fp): 11 | self._db = TinyDB(fp) 12 | self._urls = Query() 13 | 14 | def get_item(self, key: str): 15 | """ 16 | Return the tus url of a file, identified by the key specified. 17 | 18 | :Args: 19 | - key[str]: The unique id for the stored item (in this case, url) 20 | :Returns: url[str] 21 | """ 22 | result = self._db.search(self._urls.key == key) 23 | return result[0].get("url") if result else None 24 | 25 | def set_item(self, key: str, url: str): 26 | """ 27 | Store the url value under the unique key. 28 | 29 | :Args: 30 | - key[str]: The unique id to which the item (in this case, url) would be stored. 31 | - value[str]: The actual url value to be stored. 32 | """ 33 | if self._db.search(self._urls.key == key): 34 | self._db.update({"url": url}, self._urls.key == key) 35 | else: 36 | self._db.insert({"key": key, "url": url}) 37 | 38 | def remove_item(self, key: str): 39 | """ 40 | Remove/Delete the url value under the unique key from storage. 41 | """ 42 | self._db.remove(self._urls.key == key) 43 | 44 | def close(self): 45 | """ 46 | Close the file storage and release all opened files. 47 | """ 48 | self._db.close() 49 | -------------------------------------------------------------------------------- /tusclient/storage/interface.py: -------------------------------------------------------------------------------- 1 | """ 2 | Interface module defining a url storage API. 3 | """ 4 | import abc 5 | 6 | 7 | class Storage(object, metaclass=abc.ABCMeta): 8 | @abc.abstractmethod 9 | def get_item(self, key): 10 | """ 11 | Return the tus url of a file, identified by the key specified. 12 | 13 | :Args: 14 | - key[str]: The unique id for the stored item (in this case, url) 15 | :Returns: url[str] 16 | """ 17 | pass 18 | 19 | @abc.abstractmethod 20 | def set_item(self, key, value): 21 | """ 22 | Store the url value under the unique key. 23 | 24 | :Args: 25 | - key[str]: The unique id to which the item (in this case, url) would be stored. 26 | - value[str]: The actual url value to be stored. 27 | """ 28 | pass 29 | 30 | @abc.abstractmethod 31 | def remove_item(self, key): 32 | """ 33 | Remove/Delete the url value under the unique key from storage. 34 | """ 35 | pass 36 | -------------------------------------------------------------------------------- /tusclient/uploader/__init__.py: -------------------------------------------------------------------------------- 1 | from tusclient.uploader.uploader import AsyncUploader, Uploader 2 | -------------------------------------------------------------------------------- /tusclient/uploader/baseuploader.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, IO, Dict, Tuple, TYPE_CHECKING, Union 2 | import os 3 | import re 4 | from base64 import b64encode 5 | from sys import maxsize as MAXSIZE 6 | import hashlib 7 | 8 | import requests 9 | 10 | from tusclient.exceptions import TusCommunicationError 11 | from tusclient.request import TusRequest, catch_requests_error 12 | from tusclient.fingerprint import fingerprint, interface 13 | from tusclient.storage.interface import Storage 14 | 15 | if TYPE_CHECKING: 16 | from tusclient.client import TusClient 17 | 18 | 19 | class BaseUploader: 20 | """ 21 | Object to control upload related functions. 22 | 23 | :Attributes: 24 | - file_path (str): 25 | This is the path(absolute/relative) to the file that is intended for upload 26 | to the tus server. On instantiation this attribute is required. 27 | - file_stream (file): 28 | As an alternative to the `file_path`, an instance of the file to be uploaded 29 | can be passed to the constructor as `file_stream`. Do note that either the 30 | `file_stream` or the `file_path` must be passed on instantiation. 31 | - url (str): 32 | If the upload url for the file is known, it can be passed to the constructor. 33 | This may happen when you resume an upload. 34 | - client (): 35 | An instance of `tusclient.client.TusClient`. This would tell the uploader instance 36 | what client it is operating with. Although this argument is optional, it is only 37 | optional if the 'url' argument is specified. 38 | - chunk_size (int): 39 | This tells the uploader what chunk size(in bytes) should be uploaded when the 40 | method `upload_chunk` is called. This defaults to the maximum possible integer if not 41 | specified. 42 | - metadata (dict): 43 | A dictionary containing the upload-metadata. This would be encoded internally 44 | by the method `encode_metadata` to conform with the tus protocol. 45 | - metadata_encoding (str): 46 | Encoding used for each upload-metadata value. This defaults to 'utf-8'. 47 | - offset (int): 48 | The offset value of the upload indicates the current position of the file upload. 49 | - stop_at (int): 50 | At what offset value the upload should stop. 51 | - request (): 52 | A http Request instance of the last chunk uploaded. 53 | - retries (int): 54 | The number of attempts the uploader should make in the case of a failed upload. 55 | If not specified, it defaults to 0. 56 | - retry_delay (int): 57 | How long (in seconds) the uploader should wait before retrying a failed upload attempt. 58 | If not specified, it defaults to 30. 59 | - verify_tls_cert (bool): 60 | Whether or not to verify the TLS certificate of the server. 61 | If not specified, it defaults to True. 62 | - store_url (bool): 63 | Determines whether or not url should be stored, and uploads should be resumed. 64 | - url_storage (): 65 | An implementation of which is an API for URL storage. 66 | This value must be set if store_url is set to true. A ready to use implementation exists atbe used out of the box. But you can 67 | implement your own custom storage API and pass an instace of it as value. 68 | - fingerprinter (): 69 | An implementation of which is an API to generate 70 | a unique fingerprint for the uploaded file. This is used for url storage when resumability is enabled. 71 | if store_url is set to true, the default fingerprint module () 72 | would be used. But you can set your own custom fingerprint module by passing it to the constructor. 73 | - upload_checksum (bool): 74 | Whether or not to supply the Upload-Checksum header along with each 75 | chunk. Defaults to False. 76 | - upload_length_deferred (bool): 77 | Whether or not to declare the upload length when finished reading the file stream instead of when the upload is started. This is useful 78 | when uploading from a streaming resource, where the total file size isn't available when the upload is created 79 | but only becomes known when the stream finishes. The server must support the `creation-defer-length` extension. 80 | 81 | :Constructor Args: 82 | - file_path (str) 83 | - file_stream (Optional[file]) 84 | - url (Optional[str]) 85 | - client (Optional []) 86 | - chunk_size (Optional[int]) 87 | - metadata (Optional[dict]) 88 | - metadata_encoding (Optional[str]) 89 | - retries (Optional[int]) 90 | - retry_delay (Optional[int]) 91 | - verify_tls_cert (Optional[bool]) 92 | - store_url (Optional[bool]) 93 | - url_storage (Optinal []) 94 | - fingerprinter (Optional []) 95 | - upload_checksum (Optional[bool]) 96 | - upload_length_deferred (Optional[bool]) 97 | """ 98 | 99 | DEFAULT_HEADERS = {"Tus-Resumable": "1.0.0"} 100 | DEFAULT_CHUNK_SIZE = MAXSIZE 101 | CHECKSUM_ALGORITHM_PAIR = ( 102 | "sha1", 103 | hashlib.sha1, 104 | ) 105 | 106 | def __init__( 107 | self, 108 | file_path: Optional[str] = None, 109 | file_stream: Optional[IO] = None, 110 | url: Optional[str] = None, 111 | client: Optional["TusClient"] = None, 112 | chunk_size: int = MAXSIZE, 113 | metadata: Optional[Dict] = None, 114 | metadata_encoding: Optional[str] = "utf-8", 115 | retries: int = 0, 116 | retry_delay: int = 30, 117 | verify_tls_cert: bool = True, 118 | store_url=False, 119 | url_storage: Optional[Storage] = None, 120 | fingerprinter: Optional[interface.Fingerprint] = None, 121 | upload_checksum=False, 122 | upload_length_deferred=False, 123 | ): 124 | if file_path is None and file_stream is None: 125 | raise ValueError("Either 'file_path' or 'file_stream' cannot be None.") 126 | 127 | if url is None and client is None: 128 | raise ValueError("Either 'url' or 'client' cannot be None.") 129 | 130 | if store_url and url_storage is None: 131 | raise ValueError( 132 | "Please specify a storage instance to enable resumablility." 133 | ) 134 | 135 | self.verify_tls_cert = verify_tls_cert 136 | self.file_path = file_path 137 | self.file_stream = file_stream 138 | self.file_size = self.get_file_size() if not upload_length_deferred else None 139 | self.stop_at = self.file_size 140 | self.client = client 141 | self.metadata = metadata or {} 142 | self.metadata_encoding = metadata_encoding 143 | self.store_url = store_url 144 | self.url_storage = url_storage 145 | self.fingerprinter = fingerprinter or fingerprint.Fingerprint() 146 | self.offset = 0 147 | self.url = None 148 | self.__init_url_and_offset(url) 149 | self.chunk_size = chunk_size 150 | self.retries = retries 151 | self.request = None 152 | self._retried = 0 153 | self.retry_delay = retry_delay 154 | self.upload_checksum = upload_checksum 155 | self.upload_length_deferred = upload_length_deferred 156 | ( 157 | self.__checksum_algorithm_name, 158 | self.__checksum_algorithm, 159 | ) = self.CHECKSUM_ALGORITHM_PAIR 160 | 161 | def get_headers(self): 162 | """ 163 | Return headers of the uploader instance. This would include the headers of the 164 | client instance. 165 | """ 166 | client_headers = getattr(self.client, "headers", {}) 167 | return dict(self.DEFAULT_HEADERS, **client_headers) 168 | 169 | def get_url_creation_headers(self): 170 | """Return headers required to create upload url""" 171 | headers = self.get_headers() 172 | if self.upload_length_deferred: 173 | headers['upload-defer-length'] = '1' 174 | else: 175 | headers["upload-length"] = str(self.file_size) 176 | headers["upload-metadata"] = ",".join(self.encode_metadata()) 177 | return headers 178 | 179 | @property 180 | def checksum_algorithm(self): 181 | """The checksum algorithm to be used for the Upload-Checksum extension.""" 182 | return self.__checksum_algorithm 183 | 184 | @property 185 | def checksum_algorithm_name(self): 186 | """The name of the checksum algorithm to be used for the Upload-Checksum 187 | extension. 188 | """ 189 | return self.__checksum_algorithm_name 190 | 191 | @property 192 | def client_cert(self): 193 | """The client certificate used for the configured client""" 194 | return self.client.client_cert if self.client is not None else None 195 | 196 | @catch_requests_error 197 | def get_offset(self): 198 | """ 199 | Return offset from tus server. 200 | 201 | This is different from the instance attribute 'offset' because this makes an 202 | http request to the tus server to retrieve the offset. 203 | """ 204 | resp = requests.head( 205 | self.url, headers=self.get_headers(), verify=self.verify_tls_cert, cert=self.client_cert 206 | ) 207 | offset = resp.headers.get("upload-offset") 208 | if offset is None: 209 | msg = "Attempt to retrieve offset fails with status {}".format( 210 | resp.status_code 211 | ) 212 | raise TusCommunicationError(msg, resp.status_code, resp.content) 213 | return int(offset) 214 | 215 | def encode_metadata(self): 216 | """ 217 | Return list of encoded metadata as defined by the Tus protocol. 218 | """ 219 | encoded_list = [] 220 | for key, value in self.metadata.items(): 221 | key_str = str(key) # dict keys may be of any object type. 222 | 223 | # confirm that the key does not contain unwanted characters. 224 | if re.search(r"^$|[\s,]+", key_str): 225 | msg = 'Upload-metadata key "{}" cannot be empty nor contain spaces or commas.' 226 | raise ValueError(msg.format(key_str)) 227 | 228 | value_bytes = value.encode(self.metadata_encoding) 229 | encoded_list.append( 230 | "{} {}".format(key_str, b64encode(value_bytes).decode("ascii")) 231 | ) 232 | return encoded_list 233 | 234 | def __init_url_and_offset(self, url: Optional[str] = None): 235 | """ 236 | Return the tus upload url. 237 | 238 | If resumability is enabled, this would try to get the url from storage if available, 239 | otherwise it would request a new upload url from the tus server. 240 | """ 241 | key = None 242 | if url: 243 | self.set_url(url) 244 | 245 | if self.store_url and self.url_storage: 246 | key = self._get_fingerprint() 247 | self.set_url(self.url_storage.get_item(key)) 248 | 249 | if self.url: 250 | try: 251 | self.offset = self.get_offset() 252 | except TusCommunicationError as error: 253 | # Special cases where url is still considered valid with given response code. 254 | special_case_codes = [423] 255 | # Process case where stored url is no longer valid. 256 | if ( 257 | key 258 | and 400 <= error.status_code <= 499 259 | and error.status_code not in special_case_codes 260 | ): 261 | self.url = None 262 | self.url_storage.remove_item(key) 263 | else: 264 | raise error 265 | 266 | def _get_fingerprint(self): 267 | with self.get_file_stream() as stream: 268 | return self.fingerprinter.get_fingerprint(stream) 269 | 270 | def set_url(self, url: str): 271 | """Set the upload URL""" 272 | self.url = url 273 | if self.store_url and self.url_storage: 274 | key = self._get_fingerprint() 275 | self.url_storage.set_item(key, url) 276 | 277 | def get_request_length(self): 278 | """ 279 | Return length of next chunk upload. 280 | """ 281 | if self.stop_at is None: 282 | return self.chunk_size 283 | return min(self.chunk_size, self.stop_at - self.offset) 284 | 285 | def get_file_stream(self): 286 | """ 287 | Return a file stream instance of the upload. 288 | """ 289 | if self.file_stream: 290 | self.file_stream.seek(0) 291 | return self.file_stream 292 | elif os.path.isfile(self.file_path): 293 | return open(self.file_path, "rb") 294 | else: 295 | raise ValueError("invalid file {}".format(self.file_path)) 296 | 297 | def get_file_size(self): 298 | """ 299 | Return size of the file. 300 | """ 301 | stream = self.get_file_stream() 302 | stream.seek(0, os.SEEK_END) 303 | return stream.tell() 304 | -------------------------------------------------------------------------------- /tusclient/uploader/uploader.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | import time 3 | import asyncio 4 | from urllib.parse import urljoin 5 | 6 | import requests 7 | import aiohttp 8 | import ssl 9 | 10 | from tusclient.uploader.baseuploader import BaseUploader 11 | 12 | from tusclient.exceptions import TusUploadFailed, TusCommunicationError 13 | from tusclient.request import TusRequest, AsyncTusRequest, catch_requests_error 14 | 15 | 16 | def _verify_upload(request: TusRequest): 17 | if 200 <= request.status_code < 300: 18 | return True 19 | else: 20 | raise TusUploadFailed("", request.status_code, request.response_content) 21 | 22 | 23 | class Uploader(BaseUploader): 24 | def upload(self, stop_at: Optional[int] = None): 25 | """ 26 | Perform file upload. 27 | 28 | Performs continous upload of chunks of the file. The size uploaded at each cycle is 29 | the value of the attribute 'chunk_size'. 30 | 31 | :Args: 32 | - stop_at (Optional[int]): 33 | Determines at what offset value the upload should stop. If not specified this 34 | defaults to the file size. 35 | """ 36 | self.stop_at = stop_at or self.file_size 37 | 38 | if not self.url: 39 | # Ensure the POST request is performed even for empty files. 40 | # This ensures even empty files can be uploaded; in this case 41 | # only the POST request needs to be performed. 42 | self.set_url(self.create_url()) 43 | self.offset = 0 44 | 45 | while self.stop_at is None or (self.offset < self.stop_at): 46 | self.upload_chunk() 47 | 48 | def upload_chunk(self): 49 | """ 50 | Upload chunk of file. 51 | """ 52 | self._retried = 0 53 | 54 | # Ensure that we have a URL, as this is behavior we allowed previously. 55 | # See https://github.com/tus/tus-py-client/issues/82. 56 | if not self.url: 57 | self.set_url(self.create_url()) 58 | self.offset = 0 59 | 60 | self._do_request() 61 | self.offset = int(self.request.response_headers.get("upload-offset")) 62 | if self.upload_length_deferred and self.request.stream_eof: 63 | self.stop_at = self.offset 64 | 65 | @catch_requests_error 66 | def create_url(self): 67 | """ 68 | Return upload url. 69 | 70 | Makes request to tus server to create a new upload url for the required file upload. 71 | """ 72 | resp = requests.post( 73 | self.client.url, 74 | headers=self.get_url_creation_headers(), 75 | verify=self.verify_tls_cert, 76 | cert=self.client_cert, 77 | ) 78 | url = resp.headers.get("location") 79 | if url is None: 80 | msg = "Attempt to retrieve create file url with status {}".format( 81 | resp.status_code 82 | ) 83 | raise TusCommunicationError(msg, resp.status_code, resp.content) 84 | return urljoin(self.client.url, url) 85 | 86 | def _do_request(self): 87 | self.request = TusRequest(self) 88 | try: 89 | self.request.perform() 90 | _verify_upload(self.request) 91 | except TusUploadFailed as error: 92 | self._retry_or_cry(error) 93 | 94 | def _retry_or_cry(self, error): 95 | if self.retries > self._retried: 96 | time.sleep(self.retry_delay) 97 | 98 | self._retried += 1 99 | try: 100 | self.offset = self.get_offset() 101 | except TusCommunicationError as err: 102 | self._retry_or_cry(err) 103 | else: 104 | self._do_request() 105 | else: 106 | raise error 107 | 108 | 109 | class AsyncUploader(BaseUploader): 110 | def __init__(self, *args, **kwargs): 111 | super().__init__(*args, **kwargs) 112 | 113 | async def upload(self, stop_at: Optional[int] = None): 114 | """ 115 | Perform file upload. 116 | 117 | Performs continous upload of chunks of the file. The size uploaded at each cycle is 118 | the value of the attribute 'chunk_size'. 119 | 120 | :Args: 121 | - stop_at (Optional[int]): 122 | Determines at what offset value the upload should stop. If not specified this 123 | defaults to the file size. 124 | """ 125 | self.stop_at = stop_at or self.file_size 126 | 127 | if not self.url: 128 | self.set_url(await self.create_url()) 129 | self.offset = 0 130 | 131 | while self.stop_at is None or (self.offset < self.stop_at): 132 | await self.upload_chunk() 133 | 134 | async def upload_chunk(self): 135 | """ 136 | Upload chunk of file. 137 | """ 138 | self._retried = 0 139 | 140 | # Ensure that we have a URL, as this is behavior we allowed previously. 141 | # See https://github.com/tus/tus-py-client/issues/82. 142 | if not self.url: 143 | self.set_url(await self.create_url()) 144 | self.offset = 0 145 | 146 | await self._do_request() 147 | self.offset = int(self.request.response_headers.get("upload-offset")) 148 | if self.upload_length_deferred and self.request.stream_eof: 149 | self.stop_at = self.offset 150 | 151 | async def create_url(self): 152 | """ 153 | Return upload url. 154 | 155 | Makes request to tus server to create a new upload url for the required file upload. 156 | """ 157 | try: 158 | ssl_ctx = ssl.create_default_context() 159 | if (self.client_cert is not None): 160 | if self.client_cert is str: 161 | ssl_ctx.load_cert_chain(certfile=self.client_cert) 162 | else: 163 | ssl_ctx.load_cert_chain(certfile=self.client_cert[0], keyfile=self.client_cert[1]) 164 | conn = aiohttp.TCPConnector(ssl=ssl_ctx) 165 | async with aiohttp.ClientSession(connector=conn) as session: 166 | headers = self.get_url_creation_headers() 167 | verify_tls_cert = None if self.verify_tls_cert else False 168 | async with session.post( 169 | self.client.url, headers=headers, ssl=verify_tls_cert 170 | ) as resp: 171 | url = resp.headers.get("location") 172 | if url is None: 173 | msg = ( 174 | "Attempt to retrieve create file url with status {}".format( 175 | resp.status 176 | ) 177 | ) 178 | raise TusCommunicationError( 179 | msg, resp.status, await resp.content.read() 180 | ) 181 | return urljoin(self.client.url, url) 182 | except aiohttp.ClientError as error: 183 | raise TusCommunicationError(error) 184 | 185 | async def _do_request(self): 186 | self.request = AsyncTusRequest(self) 187 | try: 188 | await self.request.perform() 189 | _verify_upload(self.request) 190 | except TusUploadFailed as error: 191 | await self._retry_or_cry(error) 192 | 193 | async def _retry_or_cry(self, error): 194 | if self.retries > self._retried: 195 | await asyncio.sleep(self.retry_delay) 196 | 197 | self._retried += 1 198 | try: 199 | self.offset = self.get_offset() 200 | except TusCommunicationError as err: 201 | await self._retry_or_cry(err) 202 | else: 203 | await self._do_request() 204 | else: 205 | raise error 206 | --------------------------------------------------------------------------------