├── tests ├── __init__.py ├── v1 │ ├── __init__.py │ ├── test_init.py │ ├── test_build_request.py │ ├── test_resource.py │ └── test_errors.py ├── test_response.py ├── test_client.py ├── test_errors.py ├── mock.py └── test_uploads.py ├── jwplatform ├── __init__.py ├── version.py ├── v1 │ ├── __init__.py │ ├── errors.py │ ├── resource.py │ └── client.py ├── response.py ├── errors.py ├── upload.py └── client.py ├── requirements.txt ├── tox.ini ├── MANIFEST.in ├── setup.cfg ├── .travis.yml ├── Makefile ├── LICENSE ├── examples ├── video_conversions_list.py ├── video_update.py ├── video_channel_create.py ├── video_channel_insert.py ├── video_update_custom_params.py ├── videos_direct_replace_video.py ├── video_create.py ├── video_singlepart_create.py ├── video_multipart_upload.py ├── video_thumbnail_update.py ├── video_multipart_create.py └── video_list_to_csv.py ├── CHANGES.rst ├── setup.py ├── .gitignore └── README.rst /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/v1/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /jwplatform/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from . import v1 3 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | --index-url https://pypi.python.org/simple/ 2 | -e . 3 | -------------------------------------------------------------------------------- /jwplatform/version.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | __version__ = '2.2.2' 3 | -------------------------------------------------------------------------------- /jwplatform/v1/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from .client import Client 3 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py35,py36,py37,py38 3 | 4 | [testenv] 5 | deps= 6 | pytest 7 | responses 8 | commands=py.test tests 9 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | include CHANGES.rst 3 | include LICENSE 4 | include *.txt 5 | include tox.ini 6 | recursive-include tests *.py 7 | prune examples 8 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file=README.rst 3 | 4 | [aliases] 5 | test=pytest 6 | 7 | [tool:pytest] 8 | addopts=-vvs --cov=jwplatform --cov-report term-missing 9 | 10 | [bdist_wheel] 11 | universal=1 12 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: python 3 | python: 4 | - "3.5" 5 | - "3.6" 6 | - "3.7" 7 | install: pip install tox-travis 8 | script: tox 9 | cache: 10 | - pip 11 | - directories: 12 | - ${HOME}/.cache 13 | notifications: 14 | email: false 15 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: clean-build clean install install-all version 2 | 3 | help: 4 | @echo "clean-build - remove build artifacts" 5 | @echo "test - run tests quickly with the default Python" 6 | @echo "version - get the package version" 7 | @echo "release - package and upload a release" 8 | @echo "dist - package" 9 | 10 | clean: clean-build 11 | rm -rf .coverage 12 | rm -rf .pytest_cache 13 | 14 | clean-build: 15 | rm -rf build/ 16 | rm -rf dist/ 17 | rm -rf .eggs 18 | rm -rf *.egg-info 19 | 20 | install: clean-build 21 | python3 setup.py install 22 | 23 | install-all: 24 | pip3 install -e .[all] 25 | 26 | test: 27 | python3 setup.py test 28 | 29 | version: 30 | python3 setup.py --version 31 | 32 | build: 33 | python3 setup.py sdist bdist_wheel 34 | 35 | release: clean build 36 | twine upload dist/* 37 | 38 | dist: clean build 39 | python3 setup.py sdist 40 | python3 setup.py bdist_wheel 41 | ls -l dist 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2019 JW Player 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | 6 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | 8 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 9 | -------------------------------------------------------------------------------- /examples/video_conversions_list.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import logging 5 | import sys 6 | 7 | import jwplatform 8 | 9 | 10 | def list_conversions(api_key, api_secret, video_key, **kwargs): 11 | """ 12 | Function which retrieves a list of a video object's conversions. 13 | 14 | :param api_key: JWPlatform api-key 15 | :param api_secret: JWPlatform shared-secret 16 | :param video_key: Video's object ID. Can be found within JWPlayer Dashboard. 17 | :param kwargs: Arguments conforming to standards found @ https://developer.jwplayer.com/jw-platform/reference/v1/methods/videos/conversions/list.html 18 | :return: Dict which represents the JSON response. 19 | """ 20 | jwplatform_client = jwplatform.v1.Client(api_key, api_secret) 21 | logging.info("Querying for video conversions.") 22 | try: 23 | response = jwplatform_client.videos.conversions.list(video_key=video_key, **kwargs) 24 | except jwplatform.v1.errors.JWPlatformError as e: 25 | logging.error("Encountered an error querying for video conversions.\n{}".format(e)) 26 | sys.exit(e.message) 27 | return response 28 | -------------------------------------------------------------------------------- /examples/video_update.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import logging 5 | import sys 6 | 7 | import jwplatform 8 | from jwplatform.client import JWPlatformClient 9 | 10 | 11 | logging.basicConfig(level=logging.INFO) 12 | 13 | 14 | def update_video(secret, site_id, media_id, body): 15 | """ 16 | Function which allows you to update a video 17 | 18 | :param secret: Secret value for your JWPlatform API key 19 | :param site_id: ID of a JWPlatform site 20 | :param media_id: Video's object ID. Can be found within JWPlayer Dashboard. 21 | :param kwargs: Arguments conforming to standards found @ https://developer.jwplayer.com/jwplayer/reference#patch_v2-sites-site-id-media-media-id- 22 | :return: 23 | """ 24 | # Setup API client 25 | jwplatform_client = JWPlatformClient(secret) 26 | logging.info("Updating Video") 27 | try: 28 | response = jwplatform_client.Media.update(site_id=site_id, media_id=media_id, body=body) 29 | except jwplatform.errors.APIError as e: 30 | logging.error("Encountered an error updating the video\n{}".format(e)) 31 | sys.exit(str(e)) 32 | logging.info(response.json_body) 33 | -------------------------------------------------------------------------------- /examples/video_channel_create.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import logging 5 | import sys 6 | 7 | import jwplatform 8 | 9 | 10 | def create_channel(api_key, api_secret, channel_type='manual', **kwargs): 11 | """ 12 | Function which creates a new channel. Channels serve as containers of video/media objects. 13 | 14 | :param api_key: JWPlatform api-key 15 | :param api_secret: JWPlatform shared-secret 16 | :param channel_type: REQUIRED Acceptable values include 'manual','dynamic','trending','feed','search' 17 | :param kwargs: Arguments conforming to standards found @ https://developer.jwplayer.com/jw-platform/reference/v1/methods/channels/create.html 18 | :return: Dict which represents the JSON response. 19 | """ 20 | jwplatform_client = jwplatform.v1.Client(api_key, api_secret) 21 | logging.info("Creating new channel with keyword args.") 22 | try: 23 | response = jwplatform_client.channels.create(type=channel_type, **kwargs) 24 | except jwplatform.v1.errors.JWPlatformError as e: 25 | logging.error("Encountered an error creating new channel.\n{}".format(e)) 26 | sys.exit(e.message) 27 | return response 28 | -------------------------------------------------------------------------------- /examples/video_channel_insert.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import logging 5 | import sys 6 | 7 | import jwplatform 8 | 9 | 10 | def insert_into_channel(api_key, api_secret, channel_key, video_key, **kwargs): 11 | """ 12 | Function which inserts video into a channel/playlist. 13 | 14 | :param api_key: JWPlatform api-key 15 | :param api_secret: JWPlatform shared-secret 16 | :param channel_key: Key of the channel to which add a video. 17 | :param video_key: Key of the video that should be added to the channel. 18 | :param kwargs: Arguments conforming to standards found @ https://developer.jwplayer.com/jw-platform/reference/v1/methods/videos/create.html 19 | :return: Dict which represents the JSON response. 20 | """ 21 | jwplatform_client = jwplatform.v1.Client(api_key, api_secret) 22 | logging.info("Inserting video into channel") 23 | try: 24 | response = jwplatform_client.channels.videos.create( 25 | channel_key=channel_key, 26 | video_key=video_key, 27 | **kwargs) 28 | except jwplatform.v1.errors.JWPlatformError as e: 29 | logging.error("Encountered an error inserting {} into channel {}.\n{}".format(video_key, channel_key, e)) 30 | sys.exit(e.message) 31 | return response 32 | -------------------------------------------------------------------------------- /tests/test_response.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from unittest.mock import patch 3 | 4 | from jwplatform.client import JWPlatformClient 5 | 6 | from .mock import JWPlatformMock 7 | 8 | 9 | def test_success_response_object(): 10 | client = JWPlatformClient() 11 | 12 | with JWPlatformMock(): 13 | response = client.raw_request("POST", "/v2/test_request/") 14 | 15 | assert response.status == 200 16 | assert response.body == b'{"field": "value"}' 17 | assert response.json_body["field"] == "value" 18 | 19 | def test_resource_response(): 20 | client = JWPlatformClient() 21 | 22 | with JWPlatformMock(): 23 | response = client.Media.get(site_id="testsite", media_id="mediaid1") 24 | 25 | assert response.status == 200 26 | assert response.json_body["id"] == "mediaid1" 27 | assert response.json_body["type"] == "media" 28 | assert isinstance(response, client.Media.__class__), response.__class__.__name__ 29 | 30 | def test_resources_response(): 31 | client = JWPlatformClient() 32 | 33 | with JWPlatformMock(): 34 | response = client.Media.list(site_id="testsite") 35 | 36 | assert response.status == 200 37 | assert len(response) == 1 38 | for media in response: 39 | assert media["id"] == "mediaid1" 40 | assert media["type"] == "media" 41 | assert isinstance(response, client.Media.__class__), response.__class__.__name__ 42 | -------------------------------------------------------------------------------- /tests/test_client.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from unittest.mock import patch 3 | 4 | from jwplatform.version import __version__ 5 | from jwplatform.client import JWPlatformClient 6 | 7 | from .mock import JWPlatformMock 8 | 9 | 10 | def test_raw_request_sends(): 11 | client = JWPlatformClient() 12 | 13 | with JWPlatformMock() as mock_api: 14 | client.raw_request("POST", "/v2/test_request/") 15 | 16 | mock_api.testRequest.request_mock.assert_called_once() 17 | 18 | def test_request_sends(): 19 | client = JWPlatformClient() 20 | 21 | with JWPlatformMock() as mock_api: 22 | client.request("POST", "/v2/test_request/") 23 | 24 | mock_api.testRequest.request_mock.assert_called_once() 25 | 26 | def test_request_modifies_input(): 27 | client = JWPlatformClient(secret="test_secret") 28 | 29 | with patch.object(client, 'raw_request') as mock_raw_request: 30 | client.request( 31 | "POST", "/v2/test_request/", 32 | body={"field": "value"}, 33 | query_params={"param": "value"} 34 | ) 35 | 36 | mock_raw_request.assert_called_once() 37 | kwargs = mock_raw_request.call_args[1] 38 | assert kwargs["method"] == "POST" 39 | assert kwargs["url"] == "/v2/test_request/?param=value" 40 | assert kwargs["body"] == '{"field": "value"}' 41 | assert kwargs["headers"]["User-Agent"] == f"jwplatform_client-python/{__version__}" 42 | assert kwargs["headers"]["Content-Type"] == "application/json" 43 | assert kwargs["headers"]["Authorization"] == "Bearer test_secret" 44 | -------------------------------------------------------------------------------- /examples/video_update_custom_params.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import logging 5 | import sys 6 | 7 | import jwplatform 8 | from jwplatform.client import JWPlatformClient 9 | 10 | 11 | logging.basicConfig(level=logging.INFO) 12 | 13 | 14 | def update_custom_params(secret, site_id, media_id, params): 15 | """ 16 | Function which allows you to update a video's custom params. Custom params are indicated by key-values of 17 | "" = "" so they must be provided as a dictionary and passed to the platform API call. 18 | 19 | :param secret: Secret value for your JWPlatform API key 20 | :param site_id: ID of a JWPlatform site 21 | :param media_id: Video's object ID. Can be found within JWPlayer Dashboard. 22 | :param params: Custom params in the format of a dictionary, e.g. 23 | 24 | >>> params = {'year': '2017', 'category': 'comedy'} 25 | >>> update_custom_params('XXXXXXXX', 'XXXXXXXXXXXXXXXXX', 'dfT6JSb2', params) 26 | 27 | :return: None 28 | """ 29 | # Setup API client 30 | jwplatform_client = JWPlatformClient(secret) 31 | logging.info("Updating Video") 32 | try: 33 | response = jwplatform_client.Media.update(site_id=site_id, media_id=media_id, body={ 34 | "metadata": { 35 | "custom_params": params, 36 | }, 37 | }) 38 | except jwplatform.errors.APIError as e: 39 | logging.error("Encountered an error updating the video\n{}".format(e)) 40 | sys.exit(str(e)) 41 | logging.info(response.json_body) 42 | -------------------------------------------------------------------------------- /jwplatform/response.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import json 3 | 4 | 5 | class APIResponse: 6 | """ 7 | Class returned when JWPlatformClient is used to make an API request. 8 | """ 9 | def __init__(self, response): 10 | self.response = response 11 | self.status = response.status 12 | self.body = None 13 | self.json_body = None 14 | 15 | body = response.read() 16 | 17 | if body and len(body) > 0: 18 | self.body = body 19 | 20 | try: 21 | self.json_body = json.loads(self.body.decode("utf-8")) 22 | except (json.JSONDecodeError, UnicodeDecodeError): 23 | pass 24 | 25 | @classmethod 26 | def from_copy(cls, original_response): 27 | copy_response = cls(original_response.response) 28 | copy_response.body = original_response.body 29 | copy_response.json_body = original_response.json_body 30 | return copy_response 31 | 32 | 33 | class ResourceResponse(APIResponse): 34 | 35 | @classmethod 36 | def from_client(cls, response, resource_class): 37 | class ClientResponse(cls, resource_class): 38 | pass 39 | return ClientResponse.from_copy(response) 40 | 41 | 42 | class ResourcesResponse(APIResponse): 43 | 44 | _resources = [] 45 | 46 | def __iter__(self): 47 | return self._resources.__iter__() 48 | 49 | def __len__(self): 50 | return self._resources.__len__() 51 | 52 | @classmethod 53 | def from_client(cls, response, resource_name, resource_class): 54 | class ClientResponse(cls, resource_class): 55 | pass 56 | client_response = ClientResponse.from_copy(response) 57 | client_response._resources = client_response.json_body[resource_name] 58 | return client_response 59 | -------------------------------------------------------------------------------- /examples/videos_direct_replace_video.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import os 5 | import logging 6 | import sys 7 | 8 | import jwplatform 9 | from jwplatform.client import JWPlatformClient 10 | import requests 11 | 12 | logging.basicConfig(level=logging.INFO) 13 | 14 | 15 | def replace_video(secret, site_id, local_video_path, media_id): 16 | """ 17 | Function which allows to replace the content of an EXISTING video object. 18 | 19 | :param secret: Secret value for your JWPlatform API key 20 | :param site_id: ID of a JWPlatform site 21 | :param local_video_path: Path to media on local machine. 22 | :param media_id: Video's object ID. Can be found within JWPlayer Dashboard. 23 | :return: 24 | """ 25 | filename = os.path.basename(local_video_path) 26 | 27 | # Setup API client 28 | jwplatform_client = JWPlatformClient(secret) 29 | logging.info("Updating Video") 30 | try: 31 | response = jwplatform_client.Media.reupload(site_id=site_id, media_id=media_id, body={ 32 | "upload": { 33 | "method": "direct", 34 | }, 35 | }) 36 | except jwplatform.errors.APIError as e: 37 | logging.error("Encountered an error updating the video\n{}".format(e)) 38 | sys.exit(str(e)) 39 | logging.info(response.json_body) 40 | 41 | # HTTP PUT upload using requests 42 | upload_url = response.json_body["upload_link"] 43 | headers = {'Content-Disposition': 'attachment; filename="{}"'.format(filename)} 44 | with open(local_video_path, 'rb') as f: 45 | r = requests.put(upload_url, headers=headers, data=f) 46 | logging.info('uploading file {} to url {}'.format(local_video_path, r.url)) 47 | logging.info('upload response: {}'.format(r.text)) 48 | logging.info(r) 49 | -------------------------------------------------------------------------------- /examples/video_create.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import os 5 | import logging 6 | import sys 7 | 8 | import jwplatform 9 | from jwplatform.client import JWPlatformClient 10 | import requests 11 | 12 | logging.basicConfig(level=logging.INFO) 13 | 14 | 15 | def create_video(secret, site_id, local_video_path, body=None): 16 | """ 17 | Function which creates new video object via direct upload method. 18 | 19 | :param secret: Secret value for your JWPlatform API key 20 | :param site_id: ID of a JWPlatform site 21 | :param local_video_path: Path to media on local machine. 22 | :param body: Arguments conforming to standards found @ https://developer.jwplayer.com/jwplayer/reference#post_v2-sites-site-id-media 23 | :return: 24 | """ 25 | filename = os.path.basename(local_video_path) 26 | if body is None: 27 | body = {} 28 | body["upload"] = { 29 | "method": "direct", 30 | } 31 | 32 | # Setup API client 33 | jwplatform_client = JWPlatformClient(secret) 34 | 35 | # Make /videos/create API call 36 | logging.info("creating video") 37 | try: 38 | response = jwplatform_client.Media.create(site_id=site_id, body=body) 39 | except jwplatform.errors.APIError as e: 40 | logging.error("Encountered an error creating a video\n{}".format(e)) 41 | sys.exit(str(e)) 42 | logging.info(response.json_body) 43 | 44 | # HTTP PUT upload using requests 45 | upload_url = response.json_body["upload_link"] 46 | headers = {'Content-Disposition': 'attachment; filename="{}"'.format(filename)} 47 | with open(local_video_path, 'rb') as f: 48 | r = requests.put(upload_url, headers=headers, data=f) 49 | logging.info('uploading file {} to url {}'.format(local_video_path, r.url)) 50 | logging.info('upload response: {}'.format(r.text)) 51 | logging.info(r) 52 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | 2.2.2 (2022-12-13) 5 | ------------------ 6 | 7 | * Fix an issue with v2 media uploads 8 | * Fix an issue with the v2 client import in some examples 9 | 10 | 2.2.1 (2022-05-05) 11 | ------------------ 12 | 13 | - Address Issue #64 by having ThumbnailClient inherit from SiteResourceClient 14 | 15 | 2.2.0 (2021-11-01) 16 | ------------------ 17 | 18 | - Add support for remaining v2 routes as of official release. 19 | 20 | 2.1.3 (2021-08-19) 21 | ------------------ 22 | 23 | - Fixed exception handler failing to `_str_` represent itself 24 | 25 | 2.1.2 (2021-03-23) 26 | ------------------ 27 | 28 | - Fixed missing dependency causing import errors after install. 29 | 30 | 2.1.1 (2021-01-13) 31 | ------------------ 32 | 33 | - Fixed an issue where the v1 client could not be imported from the jwplatform module. 34 | 35 | 2.1.0 (2021-01-12) 36 | ------------------ 37 | 38 | - Added support for JWPlatform file upload using a multi-part mechanism. 39 | 40 | 2.0.1 (2021-01-11) 41 | ------------------ 42 | 43 | - Fix a bug on generating the signature when array value is in the query string. 44 | 45 | 2.0.0 (2020-12-03) 46 | ------------------ 47 | 48 | - Added support for JWPlatform API v2 49 | - All existing v1 API functionality has been moved to the jwplatform.v1 submodule (from jwplatform). 50 | 51 | 1.3.0 (2019-12-22) 52 | ------------------ 53 | 54 | - remove Python 2 compatibility 55 | 56 | 1.2.2 (2018-04-10) 57 | ------------------ 58 | 59 | - parameters are now included in the request body by default for POST requests 60 | 61 | 1.2.1 (2017-11-20) 62 | ------------------ 63 | 64 | - improved default parameters handling when instantiating client 65 | - added exponential connection backoff 66 | 67 | 1.2.0 (2016-11-22) 68 | ------------------ 69 | 70 | - allow additional Request package params in API requests 71 | 72 | 1.1.0 (2016-11-03) 73 | ------------------ 74 | 75 | - added JWPlatformRateLimitExceededError exception 76 | 77 | 1.0.0 (2016-07-21) 78 | ------------------ 79 | 80 | - Initial release. 81 | -------------------------------------------------------------------------------- /examples/video_singlepart_create.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import logging 5 | 6 | import jwplatform 7 | import requests 8 | 9 | logging.basicConfig(level=logging.INFO) 10 | 11 | 12 | def create_video(api_key, api_secret, local_video_path, api_format='json', **kwargs): 13 | """ 14 | Function which creates new video object via singlefile upload method. 15 | 16 | :param api_key: JWPlatform api-key 17 | :param api_secret: JWPlatform shared-secret 18 | :param local_video_path: Path to media on local machine. 19 | :param api_format: Acceptable values include 'py','xml','json',and 'php' 20 | :param kwargs: Arguments conforming to standards found @ https://developer.jwplayer.com/jw-platform/reference/v1/methods/videos/create.html 21 | :return: 22 | """ 23 | # Setup API client 24 | jwplatform_client = jwplatform.v1.Client(api_key, api_secret) 25 | 26 | # Make /videos/create API call 27 | logging.info("Registering new Video-Object") 28 | try: 29 | response = jwplatform_client.videos.create(upload_method='single', **kwargs) 30 | except jwplatform.errors.JWPlatformError as e: 31 | logging.error("Encountered an error creating a video\n{}".format(e)) 32 | logging.info(response) 33 | 34 | # Construct base url for upload 35 | upload_url = '{}://{}{}'.format( 36 | response['link']['protocol'], 37 | response['link']['address'], 38 | response['link']['path'] 39 | ) 40 | 41 | # Query parameters for the upload 42 | query_parameters = response['link']['query'] 43 | query_parameters['api_format'] = api_format 44 | 45 | with open(local_video_path, 'rb') as f: 46 | files = {'file': f} 47 | r = requests.post(upload_url, 48 | params=query_parameters, 49 | files=files) 50 | logging.info('uploading file {} to url {}'.format(local_video_path, r.url)) 51 | logging.info('upload response: {}'.format(r.text)) 52 | logging.info(r) 53 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import re 5 | from os import path 6 | from codecs import open 7 | 8 | from setuptools import setup, find_packages 9 | 10 | here = path.abspath(path.dirname(__file__)) 11 | 12 | 13 | def read_file(*names, **kwargs): 14 | with open( 15 | path.join(here, *names), 16 | encoding=kwargs.get('encoding', 'utf8') 17 | ) as f: 18 | return f.read() 19 | 20 | 21 | def get_version(): 22 | version_file = read_file('jwplatform', 'version.py') 23 | version_match = re.search(r"^__version__ = ['\"]([^'\"]*)['\"]", 24 | version_file, re.M) 25 | if version_match: 26 | return version_match.group(1) 27 | raise RuntimeError('Unable to find version string.') 28 | 29 | 30 | setup( 31 | name='jwplatform', 32 | version=get_version(), 33 | description='A Python client library for accessing JW Platform API', 34 | long_description=read_file('README.rst') + '\n\n' + read_file('CHANGES.rst'), 35 | url='https://github.com/jwplayer/jwplatform-py', 36 | author='Kamil Sindi', 37 | author_email='support@jwplayer.com', 38 | license='MIT', 39 | classifiers=[ 40 | 'Development Status :: 5 - Production/Stable', 41 | 'Intended Audience :: Developers', 42 | 'Topic :: Software Development :: Libraries :: Python Modules', 43 | 'License :: OSI Approved :: MIT License', 44 | 'Programming Language :: Python', 45 | 'Programming Language :: Python :: 3', 46 | 'Programming Language :: Python :: 3.5', 47 | 'Programming Language :: Python :: 3.6', 48 | 'Programming Language :: Python :: 3.7', 49 | 'Programming Language :: Python :: 3.8', 50 | 'Programming Language :: Python :: 3 :: Only', 51 | ], 52 | keywords=['JW Platform', 'api', 'client', 'JW Player'], 53 | packages=find_packages(exclude=['docs', 'tests', 'examples']), 54 | include_package_data=True, 55 | zip_safe=False, 56 | install_requires=[ 57 | 'requests>=2.24.0', 58 | 'neterr~=1.1.1', 59 | ], 60 | setup_requires=[ 61 | 'pytest-runner', 62 | ], 63 | tests_require=[ 64 | 'pytest', 65 | 'pytest-cov', 66 | 'responses>=0.12.0', 67 | 'networktest' 68 | ], 69 | ) 70 | -------------------------------------------------------------------------------- /.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 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | # Pycharm 132 | .idea/ 133 | -------------------------------------------------------------------------------- /examples/video_multipart_upload.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | from jwplatform.client import JWPlatformClient 4 | from jwplatform.upload import PartUploadError, DataIntegrityError 5 | 6 | logging.basicConfig(level=logging.DEBUG) 7 | JW_API_SECRET = os.environ.get('JW_API_SECRET') 8 | 9 | 10 | def run_multipart_upload(site_id, video_file_path): 11 | """ 12 | Creates a media and uploads the media file using a direct or multi-part mechanism. 13 | Args: 14 | site_id: The site ID 15 | video_file_path: The containing the absolute path to the media file. 16 | 17 | Returns: None 18 | 19 | """ 20 | media_client_instance = JWPlatformClient(JW_API_SECRET).Media 21 | upload_parameters = { 22 | 'site_id': site_id, 23 | 'target_part_size': 5 * 1024 * 1024, 24 | 'retry_count': 3 25 | } 26 | with open(video_file_path, "rb") as file: 27 | upload_context = media_client_instance.create_media_and_get_upload_context(file, **upload_parameters) 28 | media_client_instance.upload(file, upload_context, **upload_parameters) 29 | logging.info(f"Successfully uploaded file:{file.name}") 30 | 31 | 32 | def run_multipart_upload_with_auto_resume(site_id, video_file_path, retry_count): 33 | """ 34 | Creates a media and uploads the media file using a direct or multi-part mechanism. 35 | Args: 36 | site_id: The site ID 37 | video_file_path: The containing the absolute path to the media file. 38 | retry_count: Number of retries to attempt before exiting. 39 | 40 | Returns: None 41 | 42 | """ 43 | media_client_instance = JWPlatformClient(JW_API_SECRET).Media 44 | upload_parameters = { 45 | 'site_id': site_id, 46 | 'target_part_size': 5 * 1024 * 1024 47 | } 48 | 49 | with open(video_file_path, "rb") as file: 50 | upload_context = media_client_instance.create_media_and_get_upload_context(file, **upload_parameters) 51 | try: 52 | media_client_instance.upload(file, upload_context, **upload_parameters) 53 | return 54 | except Exception as ex: 55 | logging.exception(ex) 56 | logging.debug("Resuming upload.") 57 | while retry_count < 10: 58 | try: 59 | media_client_instance.resume(file, upload_context, **upload_parameters) 60 | return 61 | except (DataIntegrityError, PartUploadError, IOError, OSError) as ex: 62 | retry_count = retry_count + 1 63 | logging.debug(f"Resuming upload again. Retry attempt:{retry_count}") 64 | logging.exception(ex) 65 | -------------------------------------------------------------------------------- /tests/test_errors.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from unittest.mock import patch 3 | 4 | import pytest 5 | 6 | from jwplatform.client import JWPlatformClient 7 | from jwplatform.errors import BadRequestError, ClientError, NotFoundError, ServerError, UnexpectedStatusError 8 | 9 | from .mock import JWPlatformMock 10 | 11 | 12 | def test_client_error(): 13 | client = JWPlatformClient() 14 | 15 | with JWPlatformMock(): 16 | with pytest.raises(ClientError): 17 | client.raw_request("POST", "/v2/test_client_error/") 18 | 19 | def test_server_error(): 20 | client = JWPlatformClient() 21 | 22 | with JWPlatformMock(): 23 | with pytest.raises(ServerError): 24 | client.raw_request("POST", "/v2/test_server_error/") 25 | 26 | def test_unknown_status_error(): 27 | client = JWPlatformClient() 28 | 29 | with JWPlatformMock(): 30 | with pytest.raises(UnexpectedStatusError): 31 | client.raw_request("POST", "/v2/test_unknown_status_error/") 32 | 33 | def test_unknown_body_error(): 34 | client = JWPlatformClient() 35 | 36 | with JWPlatformMock(): 37 | try: 38 | client.raw_request("POST", "/v2/test_unknown_body_error/") 39 | pytest.fail("Expected to raise ClientError") 40 | except ClientError as ex: 41 | assert ex.errors is None 42 | 43 | 44 | def test_not_found_error(): 45 | client = JWPlatformClient() 46 | 47 | with JWPlatformMock(): 48 | try: 49 | client.raw_request("PATCH", "/v2/test_not_found_error/") 50 | pytest.fail("Expected to raise ClientError") 51 | except ClientError as ex: 52 | assert ex.errors is None 53 | 54 | 55 | def test_error_code_access(): 56 | client = JWPlatformClient() 57 | 58 | with JWPlatformMock(): 59 | try: 60 | client.raw_request("POST", "/v2/test_bad_request/") 61 | pytest.fail("Expected to raise ClientError") 62 | except ClientError as ex: 63 | assert ex.has_error_code("invalid_body") is True 64 | assert ex.has_error_code("invalid_code") is False 65 | assert len(ex.get_errors_by_code("invalid_body")) == 1, ex.get_errors_by_code("invalid_body") 66 | assert len(ex.get_errors_by_code("invalid_code")) == 0, ex.get_errors_by_code("invalid_code") 67 | assert str(ex) == "JWPlatform API Error:\n\ninvalid_body: The provided request body is invalid.\n" 68 | 69 | def test_specific_error_class(): 70 | client = JWPlatformClient() 71 | 72 | with JWPlatformMock(): 73 | with pytest.raises(BadRequestError): 74 | client.raw_request("POST", "/v2/test_bad_request/") 75 | -------------------------------------------------------------------------------- /tests/v1/test_init.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import jwplatform 4 | 5 | 6 | def test_default_initialization(): 7 | 8 | KEY = 'api_key' 9 | SECRET = 'api_secret' 10 | 11 | jwp_client = jwplatform.v1.Client(KEY, SECRET) 12 | 13 | assert jwp_client._Client__key == KEY 14 | assert jwp_client._Client__secret == SECRET 15 | assert jwp_client._scheme == 'https' 16 | assert jwp_client._host == 'api.jwplatform.com' 17 | assert jwp_client._port is None 18 | assert jwp_client._api_version == 'v1' 19 | assert jwp_client._agent is None 20 | assert 'User-Agent' in jwp_client._connection.headers 21 | assert jwp_client._connection.headers['User-Agent'] == \ 22 | 'python-jwplatform/{}'.format(jwplatform.version.__version__) 23 | 24 | 25 | def test_custom_initialization(): 26 | 27 | KEY = '_key_' 28 | SECRET = '_secret_' 29 | SCHEME = 'http' 30 | HOST = 'api.host.domain' 31 | PORT = 8080 32 | API_VERSION = 'v7' 33 | AGENT = 'test_agent' 34 | 35 | jwp_client = jwplatform.v1.Client( 36 | KEY, SECRET, 37 | scheme=SCHEME, 38 | host=HOST, 39 | port=PORT, 40 | version=API_VERSION, 41 | agent=AGENT) 42 | 43 | assert jwp_client._Client__key == KEY 44 | assert jwp_client._Client__secret == SECRET 45 | assert jwp_client._scheme == SCHEME 46 | assert jwp_client._host == HOST 47 | assert jwp_client._port == PORT 48 | assert jwp_client._api_version == API_VERSION 49 | assert jwp_client._agent == AGENT 50 | assert 'User-Agent' in jwp_client._connection.headers 51 | assert jwp_client._connection.headers['User-Agent'] == \ 52 | 'python-jwplatform/{}-{}'.format(jwplatform.version.__version__, AGENT) 53 | 54 | 55 | def test_custom_initialization_empty_kwargs(): 56 | 57 | KEY = 'api_key' 58 | SECRET = 'api_secret' 59 | SCHEME = None 60 | HOST = None 61 | PORT = None 62 | API_VERSION = None 63 | AGENT = None 64 | 65 | jwp_client = jwplatform.v1.Client( 66 | KEY, SECRET, 67 | scheme=SCHEME, 68 | host=HOST, 69 | port=PORT, 70 | version=API_VERSION, 71 | agent=AGENT) 72 | 73 | assert jwp_client._Client__key == KEY 74 | assert jwp_client._Client__secret == SECRET 75 | assert jwp_client._scheme == 'https' 76 | assert jwp_client._host == 'api.jwplatform.com' 77 | assert jwp_client._port is None 78 | assert jwp_client._api_version == 'v1' 79 | assert jwp_client._agent is None 80 | assert 'User-Agent' in jwp_client._connection.headers 81 | assert jwp_client._connection.headers['User-Agent'] == \ 82 | 'python-jwplatform/{}'.format(jwplatform.version.__version__) 83 | -------------------------------------------------------------------------------- /jwplatform/errors.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from collections import defaultdict 3 | 4 | from jwplatform.response import APIResponse 5 | 6 | 7 | class APIError(APIResponse, Exception): 8 | """ 9 | Class returned when an error happens while JWPlatformClient is used to make an API request. 10 | """ 11 | def __init__(self, response): 12 | super().__init__(response) 13 | self.errors = None 14 | if self.json_body is not None and isinstance(self.json_body, dict) and "errors" in self.json_body and isinstance(self.json_body["errors"], list): 15 | self.errors = self.json_body["errors"] 16 | self._error_code_map = defaultdict(list) 17 | 18 | if len(self.errors) > 0: 19 | for error in self.errors: 20 | if isinstance(error, dict) and "code" in error and "description" in error: 21 | self._error_code_map[error["code"]].append(error) 22 | 23 | @classmethod 24 | def from_response(cls, response): 25 | if response.status in ERROR_MAP: 26 | return ERROR_MAP[response.status](response) 27 | if response.status >= 400 and response.status <= 499: 28 | return ClientError(response) 29 | if response.status >= 500 and response.status <= 599: 30 | return ServerError(response) 31 | return UnexpectedStatusError(response) 32 | 33 | def has_error_code(self, code): 34 | if self.errors is None: 35 | return False 36 | return code in self._error_code_map 37 | 38 | def get_errors_by_code(self, code): 39 | if self.errors is None: 40 | return [] 41 | return self._error_code_map[code] 42 | 43 | def __str__(self): 44 | msg = "JWPlatform API Error:\n\n" 45 | # If self.errors is None, construct message from response 46 | if self.errors is None: 47 | msg += f"code: {self.response.status}, description: {self.response.reason}" 48 | else: 49 | for error in self.errors: 50 | msg += "{code}: {desc}\n".format(code=error["code"], desc=error["description"]) 51 | return msg 52 | 53 | 54 | class ClientError(APIError): 55 | pass 56 | 57 | class ServerError(APIError): 58 | pass 59 | 60 | class UnexpectedStatusError(ServerError): 61 | pass 62 | 63 | class InternalServerError(ServerError): 64 | pass 65 | 66 | class BadGatewayError(ServerError): 67 | pass 68 | 69 | class ServiceUnavailableError(ServerError): 70 | pass 71 | 72 | class GatewayTimeoutError(ServerError): 73 | pass 74 | 75 | class BadRequestError(ClientError): 76 | pass 77 | 78 | class UnauthorizedError(ClientError): 79 | pass 80 | 81 | class ForbiddenError(ClientError): 82 | pass 83 | 84 | class NotFoundError(ClientError): 85 | pass 86 | 87 | class MethodNotAllowedError(ClientError): 88 | pass 89 | 90 | class ConflictError(ClientError): 91 | pass 92 | 93 | class UnprocessableEntityError(ClientError): 94 | pass 95 | 96 | class TooManyRequestsError(ClientError): 97 | pass 98 | 99 | 100 | ERROR_MAP = { 101 | 500: InternalServerError, 102 | 502: BadGatewayError, 103 | 503: ServiceUnavailableError, 104 | 504: GatewayTimeoutError, 105 | 400: BadRequestError, 106 | 401: UnauthorizedError, 107 | 403: ForbiddenError, 108 | 404: NotFoundError, 109 | 405: MethodNotAllowedError, 110 | 409: ConflictError, 111 | 422: UnprocessableEntityError, 112 | 429: TooManyRequestsError, 113 | } 114 | -------------------------------------------------------------------------------- /jwplatform/v1/errors.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | 4 | class JWPlatformError(Exception): 5 | def __init__(self, message: str): 6 | self.message = message 7 | 8 | def __str__(self): 9 | return repr(self.message) 10 | 11 | 12 | class JWPlatformUnknownError(JWPlatformError): 13 | """An Unknown Error occurred""" 14 | 15 | 16 | class JWPlatformNotFoundError(JWPlatformError): 17 | """Not Found""" 18 | 19 | 20 | class JWPlatformNoMethodError(JWPlatformError): 21 | """No Method Specified""" 22 | 23 | 24 | class JWPlatformNotImplementedError(JWPlatformError): 25 | """Method Not Implemented""" 26 | 27 | 28 | class JWPlatformNotSupportedError(JWPlatformError): 29 | """Method or parameter not supported""" 30 | 31 | 32 | class JWPlatformCallFailedError(JWPlatformError): 33 | """Call Failed""" 34 | 35 | 36 | class JWPlatformCallUnavailableError(JWPlatformError): 37 | """Call Unavailable""" 38 | 39 | 40 | class JWPlatformCallInvalidError(JWPlatformError): 41 | """Call Invalid""" 42 | 43 | 44 | class JWPlatformParameterMissingError(JWPlatformError): 45 | """Missing Parameter""" 46 | 47 | 48 | class JWPlatformParameterEmptyError(JWPlatformError): 49 | """Empty Parameter""" 50 | 51 | 52 | class JWPlatformParameterEncodingError(JWPlatformError): 53 | """Parameter Encoding Error""" 54 | 55 | 56 | class JWPlatformParameterInvalidError(JWPlatformError): 57 | """Invalid Parameter""" 58 | 59 | 60 | class JWPlatformPreconditionFailedError(JWPlatformError): 61 | """Precondition Failed""" 62 | 63 | 64 | class JWPlatformItemAlreadyExistsError(JWPlatformError): 65 | """Item Already Exists""" 66 | 67 | 68 | class JWPlatformPermissionDeniedError(JWPlatformError): 69 | """Permission Denied""" 70 | 71 | 72 | class JWPlatformDatabaseError(JWPlatformError): 73 | """Database Error""" 74 | 75 | 76 | class JWPlatformIntegrityError(JWPlatformError): 77 | """Integrity Error""" 78 | 79 | 80 | class JWPlatformDigestMissingError(JWPlatformError): 81 | """Digest Missing""" 82 | 83 | 84 | class JWPlatformDigestInvalidError(JWPlatformError): 85 | """Digest Invalid""" 86 | 87 | 88 | class JWPlatformFileUploadFailedError(JWPlatformError): 89 | """File Upload Failed""" 90 | 91 | 92 | class JWPlatformFileSizeMissingError(JWPlatformError): 93 | """File Size Missing""" 94 | 95 | 96 | class JWPlatformFileSizeInvalidError(JWPlatformError): 97 | """File Size Invalid""" 98 | 99 | 100 | class JWPlatformInternalError(JWPlatformError): 101 | """Internal Error""" 102 | 103 | 104 | class JWPlatformApiKeyMissingError(JWPlatformError): 105 | """User Key Missing""" 106 | 107 | 108 | class JWPlatformApiKeyInvalidError(JWPlatformError): 109 | """User Key Invalid""" 110 | 111 | 112 | class JWPlatformTimestampMissingError(JWPlatformError): 113 | """Timestamp Missing""" 114 | 115 | 116 | class JWPlatformTimestampInvalidError(JWPlatformError): 117 | """Timestamp Invalid""" 118 | 119 | 120 | class JWPlatformTimestampExpiredError(JWPlatformError): 121 | """Timestamp Expired""" 122 | 123 | 124 | class JWPlatformNonceMissingError(JWPlatformError): 125 | """Nonce Missing""" 126 | 127 | 128 | class JWPlatformNonceInvalidError(JWPlatformError): 129 | """Nonce Invalid""" 130 | 131 | 132 | class JWPlatformSignatureMissingError(JWPlatformError): 133 | """Signature Missing""" 134 | 135 | 136 | class JWPlatformSignatureInvalidError(JWPlatformError): 137 | """Signature Invalid""" 138 | 139 | 140 | class JWPlatformRateLimitExceededError(JWPlatformError): 141 | """Rate Limit Exceeded""" 142 | -------------------------------------------------------------------------------- /examples/video_thumbnail_update.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import logging 5 | import sys 6 | 7 | import jwplatform 8 | import requests 9 | 10 | 11 | def update_thumbnail(api_key, api_secret, video_key, position=7.0, **kwargs): 12 | """ 13 | Function which updates the thumbnail for an EXISTING video utilizing position parameter. 14 | This function is useful for selecting a new thumbnail from with the already existing video content. 15 | Instead of position parameter, user may opt to utilize thumbnail_index parameter. 16 | Please eee documentation for further information. 17 | 18 | :param api_key: JWPlatform api-key 19 | :param api_secret: JWPlatform shared-secret 20 | :param video_key: Video's object ID. Can be found within JWPlayer Dashboard. 21 | :param position: Represents seconds into the duration of a video, for thumbnail extraction. 22 | :param kwargs: Arguments conforming to standards found @ https://developer.jwplayer.com/jw-platform/reference/v1/methods/videos/thumbnails/update.html 23 | :return: Dict which represents the JSON response. 24 | """ 25 | jwplatform_client = jwplatform.v1.Client(api_key, api_secret) 26 | logging.info("Updating video thumbnail.") 27 | try: 28 | response = jwplatform_client.videos.thumbnails.update( 29 | video_key=video_key, 30 | position=position, # Parameter which specifies seconds into video to extract thumbnail from. 31 | **kwargs) 32 | except jwplatform.v1.errors.JWPlatformError as e: 33 | logging.error("Encountered an error updating thumbnail.\n{}".format(e)) 34 | sys.exit(e.message) 35 | return response 36 | 37 | 38 | def update_thumbnail_via_upload(api_key, api_secret, video_key, local_video_image_path='', api_format='json', 39 | **kwargs): 40 | """ 41 | Function which updates the thumbnail for a particular video object with a locally saved image. 42 | 43 | :param api_key: JWPlatform api-key 44 | :param api_secret: JWPlatform shared-secret 45 | :param video_key: Video's object ID. Can be found within JWPlayer Dashboard. 46 | :param local_video_image_path: Local system path to an image. 47 | :param api_format: REQUIRED Acceptable values include 'py','xml','json',and 'php' 48 | :param kwargs: Arguments conforming to standards found @ https://developer.jwplayer.com/jw-platform/reference/v1/methods/videos/thumbnails/update.html 49 | :return: Dict which represents the JSON response. 50 | """ 51 | jwplatform_client = jwplatform.v1.Client(api_key, api_secret) 52 | logging.info("Updating video thumbnail.") 53 | try: 54 | response = jwplatform_client.videos.thumbnails.update( 55 | video_key=video_key, 56 | **kwargs) 57 | except jwplatform.v1.errors.JWPlatformError as e: 58 | logging.error("Encountered an error updating thumbnail.\n{}".format(e)) 59 | sys.exit(e.message) 60 | logging.info(response) 61 | 62 | # Construct base url for upload 63 | upload_url = '{}://{}{}'.format( 64 | response['link']['protocol'], 65 | response['link']['address'], 66 | response['link']['path'] 67 | ) 68 | 69 | # Query parameters for the upload 70 | query_parameters = response['link']['query'] 71 | query_parameters['api_format'] = api_format 72 | 73 | with open(local_video_image_path, 'rb') as f: 74 | files = {'file': f} 75 | r = requests.post(upload_url, params=query_parameters, files=files) 76 | logging.info('uploading file {} to url {}'.format(local_video_image_path, r.url)) 77 | logging.info('upload response: {}'.format(r.text)) 78 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ====================== 2 | JW Platform API Client 3 | ====================== 4 | 5 | A Python client library for accessing `JW Platform`_ API. Visit `JW Player Developer`_ site for more information about JW Platform API. 6 | 7 | Installation 8 | ------------ 9 | 10 | JW Platform API library can be installed using pip: 11 | 12 | .. code-block:: bash 13 | 14 | pip install jwplatform 15 | 16 | Library has `Requests`_ package as dependency. It will be installed automatically when installing using ``pip``. 17 | 18 | Usage 19 | ----- 20 | 21 | Import ``jwplatform`` library: 22 | 23 | .. code-block:: python 24 | 25 | from jwplatform.client import JWPlatformClient 26 | 27 | Initialize ``jwplatform`` client instance. API keys can be created in the JW Platform dashboard on the API Credentials page. Copy the secret value to use here. 28 | 29 | .. code-block:: python 30 | 31 | jwplatform_client = JWPlatformClient('API_SECRET') 32 | 33 | Make an API request: 34 | 35 | .. code-block:: python 36 | 37 | response = jwplatform_client.Media.get(site_id='SITE_ID', media_id='MEDIA_ID') 38 | 39 | If API request is successful, ``response`` variable will contain dictionary with information related to the response and the actual video data in ``response.json_body``: 40 | 41 | .. code-block:: python 42 | 43 | >>> response.json_body 44 | {"id": "Ny05CEfj", 45 | "type": "media", 46 | "created": "2019-09-25T15:29:11.042095+00:00", 47 | "last_modified": "2019-09-25T15:29:11.042095+00:00", 48 | "metadata": { 49 | "title": "Example video", 50 | "tags": ["new", "video"] 51 | }} 52 | 53 | JW Platform API library will raise exception inherited from ``jwplatform.errors.APIError`` if anything goes wrong. For example, if there is no media with the specified media_id requesting it will raise ``jwplatform.errors.NotFoundError``: 54 | 55 | .. code-block:: python 56 | 57 | try: 58 | jwplatform_client.Media.get(site_id='SITE_ID', media_id='BAD_MEDIA_ID') 59 | except jwplatform.errors.NotFoundError as err: 60 | print(err) 61 | 62 | For the complete list of available exception see `jwplatform/errors.py`_ file. 63 | 64 | List calls allow for (optional) querying and filtering. This can be done by passing the query parameters as a dict to the `query_params` keyword argument on list calls: 65 | 66 | .. code-block:: python 67 | 68 | response = jwplatform_client.Media.list( 69 | site_id="SITE_ID", 70 | query_params={ 71 | "page": 1, 72 | "page_length": 10, 73 | "sort": "title:asc", 74 | "q": "external_id: abcdefgh", 75 | }, 76 | ) 77 | 78 | All query parameters are optional. `page`, `page_length`, and `sort` parameters default to 1, 10, and "created:dsc", respectively. The `q` parameter allows for filtering on different 79 | attributes and may allow for AND/OR querying depending on the resource. For full documentation on the query syntax and endpoint specific details please refer to developer.jwplayer.com. 80 | 81 | 82 | Source Code 83 | ----------- 84 | 85 | Source code for the JW Platform API library provided on `GitHub`_. 86 | 87 | V1 Client 88 | --------- 89 | 90 | The V1 Client remains available for use, but is deprecated. We strongly recommend using the V2 Client when possible. 91 | 92 | To use the V1 Client, import the Client from the `v1` namespace. 93 | 94 | .. code-block:: python 95 | 96 | import jwplatform.v1 97 | 98 | api_client = jwplatform.v1.Client('SITE_ID', 'V1_API_SECRET') 99 | 100 | License 101 | ------- 102 | 103 | JW Platform API library is distributed under the `MIT license`_. 104 | 105 | .. _`JW Platform`: https://www.jwplayer.com/products/jwplatform/ 106 | .. _`JW Player Developer`: https://developer.jwplayer.com/jwplayer/reference#introduction-to-api-v2 107 | .. _`jwplatform/errors.py`: https://github.com/jwplayer/jwplatform-py/blob/master/jwplatform/errors.py 108 | .. _`MIT license`: https://github.com/jwplayer/jwplatform-py/blob/master/LICENSE 109 | .. _`GitHub`: https://github.com/jwplayer/jwplatform-py 110 | .. _`Requests`: https://pypi.python.org/pypi/requests/ 111 | -------------------------------------------------------------------------------- /tests/mock.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from networktest.mock import HttpApiMock, HttpApiMockEndpoint 3 | 4 | from jwplatform.client import JWPLATFORM_API_HOST 5 | 6 | 7 | class JWPlatformMock(HttpApiMock): 8 | hostnames = [JWPLATFORM_API_HOST] 9 | 10 | endpoints = [ 11 | HttpApiMockEndpoint( 12 | operation_id='testRequest', 13 | match_pattern=b'^POST /v2/test_request/', 14 | response=lambda _: (200, {"field": "value"}), 15 | ), 16 | HttpApiMockEndpoint( 17 | operation_id='testClientError', 18 | match_pattern=b'^POST /v2/test_bad_request/', 19 | response=lambda _: (400, { 20 | "errors": [{ 21 | "code": "invalid_body", 22 | "description": "The provided request body is invalid.", 23 | }] 24 | }), 25 | ), 26 | HttpApiMockEndpoint( 27 | operation_id='testClientError', 28 | match_pattern=b'^POST /v2/test_client_error/', 29 | response=lambda _: (499, { 30 | "errors": [{ 31 | "code": "invalid_body", 32 | "description": "The provided request body is invalid.", 33 | }] 34 | }), 35 | ), 36 | HttpApiMockEndpoint( 37 | operation_id='testServerError', 38 | match_pattern=b'^POST /v2/test_server_error/', 39 | response=lambda _: (599, { 40 | "errors": [{ 41 | "code": "server_error", 42 | "description": "Something unexpectedly went wrong.", 43 | }] 44 | }), 45 | ), 46 | HttpApiMockEndpoint( 47 | operation_id='testUnknownStatusError', 48 | match_pattern=b'^POST /v2/test_unknown_status_error/', 49 | response=lambda _: (999, None) 50 | ), 51 | HttpApiMockEndpoint( 52 | operation_id='testUnknownBodyError', 53 | match_pattern=b'^POST /v2/test_unknown_body_error/', 54 | response=lambda _: (400, "unexpected") 55 | ), 56 | HttpApiMockEndpoint( 57 | operation_id='testNotFoundError', 58 | match_pattern=b'^PATCH /v2/test_not_found_error/', 59 | response=lambda _: (404, "not found") 60 | ), 61 | HttpApiMockEndpoint( 62 | operation_id='getMedia', 63 | match_pattern=b'^GET /v2/sites/(?P[A-Za-z0-9]{8}?)/media/(?P[A-Za-z0-9]{8}?)/', 64 | response=lambda groups: (200, { 65 | "id": groups["media_id"], 66 | "type": "media", 67 | }) 68 | ), 69 | HttpApiMockEndpoint( 70 | operation_id='listMedia', 71 | match_pattern=b'^GET /v2/sites/(?P[A-Za-z0-9]{8}?)/media/', 72 | response=lambda groups: (200, { 73 | "media": [{ 74 | "id": "mediaid1", 75 | "type": "media", 76 | }], 77 | }) 78 | ), 79 | HttpApiMockEndpoint( 80 | operation_id='createMedia', 81 | match_pattern=b'^POST /v2/sites/(?P[A-Za-z0-9]{8}?)/media/', 82 | response=lambda _: (200, 83 | {"upload_id": "uploADid", 84 | "upload_token": "upload_token", 85 | "upload_link": "http://s3server/upload-link" 86 | } 87 | ) 88 | ), 89 | HttpApiMockEndpoint( 90 | operation_id='completeUpload', 91 | match_pattern=b'^PUT /v1/uploads/(?P[A-Za-z0-9]{8}?)/complete', 92 | response=lambda _: (202, {}) 93 | ) 94 | ] 95 | 96 | 97 | class S3Mock(HttpApiMock): 98 | hostnames = ['s3server'] 99 | 100 | endpoints = [ 101 | HttpApiMockEndpoint( 102 | operation_id='uploadToS3', 103 | match_pattern=b'^PUT ', 104 | response=lambda _: (200, None), 105 | ) 106 | ] 107 | -------------------------------------------------------------------------------- /tests/v1/test_build_request.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import pytest 4 | import time 5 | import hashlib 6 | import jwplatform 7 | 8 | from urllib.parse import quote 9 | 10 | def test_required_parameters_present(): 11 | 12 | KEY = 'api_key' 13 | SECRET = 'api_secret' 14 | 15 | jwp_client = jwplatform.v1.Client(KEY, SECRET) 16 | 17 | url, params = jwp_client._build_request('') 18 | 19 | assert url == 'https://api.jwplatform.com/v1' 20 | 21 | assert 'api_nonce' in params 22 | assert len(params['api_nonce']) == 9 23 | assert 0 <= int(params['api_nonce']) <= 999999999 24 | 25 | assert 'api_timestamp' in params 26 | assert params['api_timestamp'] <= int(time.time()) 27 | 28 | assert 'api_key' in params 29 | assert params['api_key'] == KEY 30 | 31 | assert 'api_format' in params 32 | assert params['api_format'] == 'json' 33 | 34 | assert 'api_kit' in params 35 | assert params['api_kit'] == 'py-{}'.format(jwplatform.version.__version__) 36 | 37 | assert 'api_signature' in params 38 | 39 | 40 | def test_request_url(): 41 | 42 | KEY = '_key_' 43 | SECRET = '_secret_' 44 | SCHEME = 'http' 45 | HOST = 'api.host.domain' 46 | PORT = 8080 47 | API_VERSION = 'v3' 48 | AGENT = 'test_request_url' 49 | PATH = '/a/b/c/d' 50 | 51 | jwp_client = jwplatform.v1.Client( 52 | KEY, SECRET, 53 | scheme=SCHEME, 54 | host=HOST, 55 | port=PORT, 56 | version=API_VERSION, 57 | agent=AGENT) 58 | 59 | url, params = jwp_client._build_request(PATH) 60 | 61 | assert url == '{scheme}://{host}:{port}/{version}{path}'.format( 62 | scheme=SCHEME, 63 | host=HOST, 64 | port=PORT, 65 | version=API_VERSION, 66 | path=PATH) 67 | 68 | 69 | SIGNATURE_TEST_CASES = [ 70 | { 71 | 'request_params': { 72 | 'a': 1, 73 | 'b': 'two', 74 | 'c3': 'Param 3', 75 | u'❄': u'⛄', 76 | 't1': True, 77 | 'n0': None, 78 | }, 79 | 'expected_query_string': f'a=1&api_format=json&api_key=API_KEY_VALUE&api_kit=py-{jwplatform.version.__version__}' 80 | '&api_nonce=API_NONCE_VALUE&api_timestamp=API_TIMESTAMP_VALUE' 81 | '&b=two&c3=Param%203&n0=None&t1=True&%E2%9D%84=%E2%9B%84', 82 | }, 83 | { 84 | 'request_params': { 85 | 'a': 1, 86 | 'b': 'two', 87 | 'c3': 'Param 3', 88 | u'❄': u'⛄', 89 | 't1': True, 90 | 'n0': None, 91 | 'test_array1': [1, 2, 3, 4], 92 | 'test_array2': ["test item1", "test item2"], 93 | }, 94 | 'expected_query_string': f'a=1&api_format=json&api_key=API_KEY_VALUE&api_kit=py-{jwplatform.version.__version__}' 95 | '&api_nonce=API_NONCE_VALUE&api_timestamp=API_TIMESTAMP_VALUE' 96 | '&b=two&c3=Param%203&n0=None&t1=True&test_array1=1&test_array1=2' 97 | '&test_array1=3&test_array1=4&test_array2=test%20item1' 98 | '&test_array2=test%20item2&%E2%9D%84=%E2%9B%84', 99 | }, 100 | ] 101 | @pytest.mark.parametrize('test_case', SIGNATURE_TEST_CASES) 102 | def test_signature_none_array_values_only(test_case): 103 | 104 | KEY = 'api_key' 105 | SECRET = 'api_secret' 106 | PATH = '/test/resource/show' 107 | 108 | request_params = test_case['request_params'] 109 | 110 | jwp_client = jwplatform.v1.Client(KEY, SECRET) 111 | 112 | url, params = jwp_client._build_request(PATH, request_params) 113 | 114 | assert url == 'https://api.jwplatform.com/v1{}'.format(PATH) 115 | assert 'api_nonce' in params 116 | assert 'api_timestamp' in params 117 | assert 'api_key' in params 118 | assert 'api_format' in params 119 | assert 'api_kit' in params 120 | assert 'api_signature' in params 121 | 122 | base_str = test_case['expected_query_string'] 123 | base_str = base_str.replace('API_KEY_VALUE', KEY) 124 | base_str = base_str.replace('API_NONCE_VALUE', str(params['api_nonce'])) 125 | base_str = base_str.replace('API_TIMESTAMP_VALUE', str(params['api_timestamp'])) 126 | 127 | assert params['api_signature'] == hashlib.sha1( 128 | '{}{}'.format(base_str, SECRET).encode('utf-8')).hexdigest() 129 | -------------------------------------------------------------------------------- /jwplatform/v1/resource.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from jwplatform.v1 import client 4 | from jwplatform.v1 import errors 5 | 6 | 7 | class Resource: 8 | """JW Platform API resource. 9 | 10 | Provides access to the JW Platform API resources using dot notation. 11 | 12 | Args: 13 | name (str): JW Platform API resource (sub-)name. 14 | client (:obj:`jwplatform.Client`): Instance of :jwplatform.Client: 15 | 16 | Examples: 17 | '/videos/tracks/show' can be called as: 18 | 19 | >>> track = jwplatform_client.videos.tracks.show(track_key='abcd1234') 20 | """ 21 | 22 | def __init__(self, name: str, client): 23 | self._name = name 24 | self._client = client 25 | 26 | def __getattr__(self, resource_name): 27 | return Resource('.'.join((self._name, resource_name)), self._client) 28 | 29 | @property 30 | def path(self): 31 | """str: JW Platform API resource path. 32 | 33 | Path of the API resource represented by this instance, 34 | e.g. '/videos/tracks/show'. 35 | """ 36 | return '/{}'.format(self._name.replace('.', '/')) 37 | 38 | def __call__(self, http_method='GET', request_params=None, use_body=None, 39 | **kwargs): 40 | """Requests API resource method. 41 | 42 | Args: 43 | http_method (str): HTTP method. Defaults to 'GET' if not specified. 44 | 45 | request_params (dict): Additional parameters that requests.request 46 | method accepts. See Request package documentation for details: 47 | http://docs.python-requests.org/en/master/api/#requests.request 48 | Note: 'method', 'url', 'params' and 'data' keys should not be 49 | included in the request_params dictionary. 50 | 51 | use_body (bool): If True, pass parameters in the request body, 52 | otherwise pass parameters via the URL query string. For the POST 53 | methods, this defaults to True. For other methods it defaults to 54 | False. 55 | 56 | **kwargs (dict): Keyword arguments specific to the API resource method. 57 | 58 | Returns: 59 | dict: Dictionary with API resource data. If request is successful and 60 | response 'status' is 'ok'. 61 | 62 | Raises: 63 | jwplatform.errors.JWPlatformError: If response 'status' is 'error'. 64 | requests.RequestException: :requests: packages specific exception. 65 | """ 66 | 67 | _request_params = {} if request_params is None else request_params.copy() 68 | 69 | # Remove certain parameters from _request_params dictionary as they are 70 | # provided as separate arguments. 71 | _request_params.pop('method', None) 72 | _request_params.pop('url', None) 73 | _request_params.pop('params', None) 74 | _request_params.pop('data', None) 75 | 76 | # Whether we default to using the request body to pass parameters or not 77 | # depends on the method. Respect the value of use_body which was passed 78 | # above the default. 79 | use_body = use_body if use_body is not None else http_method == 'POST' 80 | 81 | url, params = self._client._build_request(self.path, kwargs) 82 | 83 | if use_body: 84 | _request_params['data'] = params 85 | else: 86 | _request_params['params'] = params 87 | 88 | response = self._client._connection.request( 89 | http_method, url, **_request_params) 90 | 91 | try: 92 | _response = response.json() 93 | except ValueError: 94 | raise errors.JWPlatformUnknownError( 95 | 'Not a valid JSON string: {}'.format(response.text)) 96 | except: 97 | raise 98 | 99 | if response.status_code != 200: 100 | if _response['status'] == 'error': 101 | try: 102 | error_class = getattr(errors, 'JWPlatform{}Error'.format( 103 | _response['code'].rstrip('Error'))) 104 | except AttributeError: 105 | error_class = errors.JWPlatformUnknownError 106 | raise error_class(_response['message']) 107 | else: 108 | errors.JWPlatformUnknownError(response.text) 109 | else: 110 | return _response 111 | -------------------------------------------------------------------------------- /jwplatform/v1/client.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import time 4 | import random 5 | import hashlib 6 | from urllib.parse import quote 7 | from typing import Dict, Optional 8 | 9 | import requests 10 | from requests.adapters import HTTPAdapter 11 | from requests.packages.urllib3.util.retry import Retry 12 | 13 | from jwplatform.version import __version__ 14 | from jwplatform.v1.resource import Resource 15 | 16 | BACKOFF_FACTOR = 1.7 17 | RETRY_COUNT = 5 18 | 19 | 20 | class RetryAdapter(HTTPAdapter): 21 | """Exponential backoff http adapter.""" 22 | def __init__(self, *args, **kwargs): 23 | super(RetryAdapter, self).__init__(*args, **kwargs) 24 | self.max_retries = Retry(total=RETRY_COUNT, 25 | backoff_factor=BACKOFF_FACTOR) 26 | 27 | 28 | class Client: 29 | """JW Platform API client. 30 | 31 | An API client for the JW Platform. For the API documentation see: 32 | https://developer.jwplayer.com/jw-platform/reference/v1/index.html 33 | 34 | Args: 35 | key (str): API User key 36 | secret (str): API User secret 37 | scheme (str, optional): Connection scheme: 'http' or 'https'. 38 | Default is 'https'. 39 | host (str, optional): API server host name. 40 | Default is 'api.jwplatform.com'. 41 | port (int, optional): API server port. Default is 443. 42 | version (str, optional): Version of the API to use. 43 | Default is 'v1'. 44 | agent (str, optional): API client agent identification string. 45 | 46 | Examples: 47 | >>> jwplatform_client = jwplatform.Client('API_KEY', 'API_SECRET') 48 | """ 49 | 50 | def __init__(self, key: str, secret: str, *args, **kwargs): 51 | self.__key = key 52 | self.__secret = secret 53 | 54 | self._scheme = kwargs.get('scheme') or 'https' 55 | self._host = kwargs.get('host') or 'api.jwplatform.com' 56 | self._port = int(kwargs['port']) if kwargs.get('port') else None 57 | self._api_version = kwargs.get('version') or 'v1' 58 | self._agent = kwargs.get('agent') 59 | 60 | self._connection = requests.Session() 61 | self._connection.mount(self._scheme, RetryAdapter()) 62 | 63 | self._connection.headers['User-Agent'] = 'python-jwplatform/{}{}'.format( 64 | __version__, '-{}'.format(self._agent) if self._agent else '') 65 | 66 | def __getattr__(self, resource_name): 67 | return Resource(resource_name, self) 68 | 69 | def _build_request(self, path: str, params: Optional[Dict] = None): 70 | """Build API request.""" 71 | 72 | _url = '{scheme}://{host}{port}/{version}{path}'.format( 73 | scheme=self._scheme, 74 | host=self._host, 75 | port=':{}'.format(self._port) if self._port else '', 76 | version=self._api_version, 77 | path=path) 78 | 79 | if params is not None: 80 | _params = params.copy() 81 | else: 82 | _params = dict() 83 | 84 | # Add required API parameters 85 | _params['api_nonce'] = str(random.randint(0, 999999999)).zfill(9) 86 | _params['api_timestamp'] = int(time.time()) 87 | _params['api_key'] = self.__key 88 | _params['api_format'] = 'json' 89 | _params['api_kit'] = 'py-{}{}'.format( 90 | __version__, '-{}'.format(self._agent) if self._agent else '') 91 | 92 | # Collect params to a list 93 | # The reason using a list instead of a dict is 94 | # to allow the same key multiple times with the different values in the query string 95 | params_for_sbs = list() 96 | for key, value in sorted(_params.items()): 97 | key = quote(str(key).encode('utf-8'), safe='~') 98 | if isinstance(value, list): 99 | for item in value: 100 | item = quote(str(item).encode('utf-8'), safe='~') 101 | params_for_sbs.append(f"{key}={item}") 102 | else: 103 | value = quote(str(value).encode('utf-8'), safe='~') 104 | params_for_sbs.append(f"{key}={value}") 105 | 106 | # Construct Signature Base String 107 | sbs = "&".join(params_for_sbs) 108 | 109 | # Add signature to the _params dict 110 | _params['api_signature'] = hashlib.sha1( 111 | '{}{}'.format(sbs, self.__secret).encode('utf-8')).hexdigest() 112 | 113 | return _url, _params 114 | -------------------------------------------------------------------------------- /examples/video_multipart_create.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import logging 5 | import os 6 | import requests 7 | 8 | from jwplatform.v1 import Client 9 | from jwplatform.v1.errors import JWPlatformError 10 | 11 | 12 | logging.basicConfig(level=logging.INFO) 13 | 14 | JW_API_KEY = os.environ.get('JW_API_KEY') 15 | JW_API_SECRET = os.environ.get('JW_API_SECRET') 16 | 17 | BYTES_TO_BUFFER = 10000000 18 | 19 | 20 | def run_upload(video_file_path): 21 | """ 22 | Configures all of the needed upload_parameters and sets up all information pertinent 23 | to the video to be uploaded. 24 | 25 | :param video_file_path: the absolute path to the video file 26 | """ 27 | 28 | upload_parameters = { 29 | 'file_path': video_file_path, 30 | 'file_size': os.stat(video_file_path).st_size, 31 | 'file_name': os.path.basename(video_file_path) 32 | } 33 | 34 | try: 35 | # Setup API client 36 | jwplatform_client = Client(JW_API_KEY, JW_API_SECRET) 37 | 38 | # Make /videos/create API call with multipart parameter specified 39 | jwplatform_video_create_response = jwplatform_client.videos.create( 40 | upload_method='multipart', 41 | title=upload_parameters['file_name'] 42 | ) 43 | 44 | except JWPlatformError: 45 | logging.exception('An error occurred during the uploader setup. Check that your API keys are properly ' 46 | 'set up in your environment, and ensure that the video file path exists.') 47 | return 48 | 49 | # Construct base url for upload 50 | upload_parameters['upload_url'] = '{protocol}://{address}{path}'.format(**jwplatform_video_create_response['link']) 51 | 52 | logging.info('Upload URL to be used: {}'.format(upload_parameters['upload_url'])) 53 | 54 | upload_parameters['query_parameters'] = jwplatform_video_create_response['link']['query'] 55 | upload_parameters['query_parameters']['api_format'] = 'json' 56 | upload_parameters['headers'] = {'X-Session-ID': jwplatform_video_create_response['session_id']} 57 | # The chunk offset will be updated several times during the course of the upload 58 | upload_parameters['chunk_offset'] = 0 59 | 60 | # Perform the multipart upload 61 | with open(upload_parameters['file_path'], 'rb') as file_to_upload: 62 | while True: 63 | chunk = file_to_upload.read(BYTES_TO_BUFFER) 64 | if len(chunk) <= 0: 65 | break 66 | 67 | try: 68 | upload_chunk(chunk, upload_parameters) 69 | 70 | # Log any exceptions that bubbled up 71 | except requests.exceptions.RequestException: 72 | logging.exception('Error posting data, stopping upload...') 73 | break 74 | 75 | 76 | def upload_chunk(chunk, upload_parameters): 77 | """ 78 | Handles the POST request needed to upload a single portion of the video file. 79 | Serves as a helper method for upload_by_multipart(). 80 | The offset used to determine where a chunk begins and ends is updated in the course of 81 | this method's execution. 82 | 83 | :param chunk: the raw bytes of data from the video file 84 | :param upload_parameters: a collection of all pieces of info needed to upload the video 85 | """ 86 | begin_chunk = upload_parameters['chunk_offset'] 87 | 88 | # The next chunk will begin at (begin_chunk + len(chunk)), so the -1 ensures that the ranges do not overlap 89 | end_chunk = begin_chunk + len(chunk) - 1 90 | file_size = upload_parameters['file_size'] 91 | filename = upload_parameters['file_size'] 92 | logging.info("begin_chunk / end_chunk = {} / {}".format(begin_chunk, end_chunk)) 93 | 94 | upload_parameters['headers'].update( 95 | { 96 | 'X-Content-Range': 'bytes {}-{}/{}'.format(begin_chunk, end_chunk, file_size), 97 | 'Content-Disposition': 'attachment; filename="{}"'.format(filename), 98 | 'Content-Type': 'application/octet-stream', 99 | 'Content-Length': str((end_chunk - begin_chunk) + 1) 100 | } 101 | ) 102 | 103 | response = requests.post( 104 | upload_parameters['upload_url'], 105 | params=upload_parameters['query_parameters'], 106 | headers=upload_parameters['headers'], 107 | data=chunk 108 | ) 109 | response.raise_for_status() 110 | 111 | # As noted before, the next chunk begins at (begin_chunk + len(chunk)) 112 | upload_parameters['chunk_offset'] = begin_chunk + len(chunk) 113 | -------------------------------------------------------------------------------- /tests/v1/test_resource.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import re 4 | import pytest 5 | import jwplatform.v1 6 | import responses 7 | 8 | from requests.exceptions import ConnectionError 9 | 10 | 11 | @responses.activate 12 | def test_existing_resource(): 13 | url_expr = re.compile(r'https?://api\.test\.tst/v1/videos/show\?.*' 14 | 'video_key=VideoKey.*') 15 | responses.add( 16 | responses.GET, url_expr, 17 | status=200, 18 | content_type='application/json', 19 | body='{"status": "ok", ' 20 | '"rate_limit": {"reset": 1478929300, "limit": 50, "remaining": 47},' 21 | '"video": {"status": "ready", "expires_date": null, "description": null, ' 22 | '"title": "Title", "views": 179, "tags": "", "sourceformat": null, ' 23 | '"mediatype": "video", "upload_session_id": null, "custom": {}, ' 24 | '"duration": "817.56", "sourceurl": null, "link": null, "author": null, ' 25 | '"key": "VideoKey", "error": null, "date": 1464754765, ' 26 | '"md5": "653bc15b6cba7319c2df9b5cf869b5b8", "sourcetype": "file", ' 27 | '"size": "904237686"}}') 28 | 29 | jwp_client = jwplatform.v1.Client('api_key', 'api_secret', host='api.test.tst') 30 | resp = jwp_client.videos.show(video_key='VideoKey') 31 | 32 | assert resp['status'] == 'ok' 33 | assert 'status' in resp['video'] 34 | assert resp['video']['key'] == 'VideoKey' 35 | 36 | 37 | @responses.activate 38 | def test_long_resource(): 39 | url_expr = re.compile(r'https?://api\.test\.tst/v1/a/b/c/d/f/e\?.*' 40 | 'abcde=.*') 41 | responses.add( 42 | responses.GET, url_expr, 43 | status=200, 44 | content_type='application/json', 45 | body='{"status": "ok"}') 46 | 47 | jwp_client = jwplatform.v1.Client('api_key', 'api_secret', host='api.test.tst') 48 | resp = jwp_client.a.b.c.d.f.e(abcde='') 49 | 50 | assert resp['status'] == 'ok' 51 | 52 | 53 | @responses.activate 54 | def test_nonexisting_resource(): 55 | url_expr = re.compile(r'https?://api\.test\.tst/v1/videos/abcd/show\?.*' 56 | 'abcd_key=AbcdKey.*') 57 | responses.add( 58 | responses.GET, url_expr, 59 | status=404, 60 | content_type='application/json', 61 | body='{"status": "error", ' 62 | '"message": "API method `/videos/abcd/show` not found", ' 63 | '"code": "NotFound", "title": "Not Found"}') 64 | 65 | jwp_client = jwplatform.v1.Client('api_key', 'api_secret', host='api.test.tst') 66 | 67 | with pytest.raises(jwplatform.v1.errors.JWPlatformNotFoundError) as err: 68 | jwp_client.videos.abcd.show(abcd_key='AbcdKey') 69 | 70 | assert err.value.message == 'API method `/videos/abcd/show` not found' 71 | 72 | 73 | @responses.activate 74 | def test_long_resource(): 75 | url_expr = re.compile(r'https?://api\.test\.tst/v1/json/error\?.*') 76 | responses.add( 77 | responses.GET, url_expr, 78 | status=200, 79 | content_type='application/json', 80 | body='({"json": "error"})') 81 | 82 | jwp_client = jwplatform.v1.Client('api_key', 'api_secret', host='api.test.tst') 83 | 84 | with pytest.raises(jwplatform.v1.errors.JWPlatformUnknownError) as err: 85 | jwp_client.json.error() 86 | 87 | assert err.value.message == 'Not a valid JSON string: ({"json": "error"})' 88 | 89 | 90 | @responses.activate 91 | def test_post_existing_resource(): 92 | url_expr = re.compile(r'https?://api\.test\.tst/v1/a/b/c/d') 93 | responses.add( 94 | responses.POST, url_expr, 95 | status=200, 96 | content_type='application/json', 97 | body='{"status": "ok"}') 98 | 99 | jwp_client = jwplatform.v1.Client('api_key', 'api_secret', host='api.test.tst') 100 | resp = jwp_client.a.b.c.d(http_method='POST', abcde=123) 101 | 102 | assert resp['status'] == 'ok' 103 | 104 | 105 | @responses.activate 106 | def test_post_parameters_in_url(): 107 | url_expr = re.compile(r'https?://api\.test\.tst/v1/a/b/c/d\?.*') 108 | responses.add( 109 | responses.POST, url_expr, 110 | status=200, 111 | content_type='application/json', 112 | body='{"status": "ok"}') 113 | 114 | jwp_client = jwplatform.v1.Client('api_key', 'api_secret', host='api.test.tst') 115 | resp = jwp_client.a.b.c.d(http_method='POST', use_body=False, _post='true', _body='false') 116 | 117 | assert resp['status'] == 'ok' 118 | 119 | 120 | @responses.activate 121 | def test_post_parameters_in_body(): 122 | url_expr = re.compile(r'https?://api\.test\.tst/v1/a/b/c/d\?.*') 123 | responses.add( 124 | responses.POST, url_expr, 125 | status=200, 126 | content_type='application/json', 127 | body='{"status": "ok"}') 128 | 129 | jwp_client = jwplatform.v1.Client('api_key', 'api_secret', host='api.test.tst') 130 | 131 | # ConnectionError is expected as request parameters are included in the 132 | # request body for POST request by default. 133 | with pytest.raises(ConnectionError): 134 | resp = jwp_client.a.b.c.d(http_method='POST', post='true', _body='none') 135 | -------------------------------------------------------------------------------- /examples/video_list_to_csv.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import logging 5 | import os 6 | import csv 7 | import time 8 | import jwplatform 9 | from jwplatform.client import JWPlatformClient 10 | 11 | def make_csv(secret, site_id, path_to_csv=None, result_limit=1000, query_params=None): 12 | """ 13 | Function which fetches a video library and writes each video_objects Metadata to CSV. Useful for CMS systems. 14 | 15 | :param secret: Secret value for your JWPlatform API key 16 | :param site_id: ID of a JWPlatform site 17 | :param path_to_csv: Local system path to desired CSV. Default will be within current working directory. 18 | :param result_limit: Number of video results returned in response. (Suggested to leave at default of 1000) 19 | :param query_params: Arguments conforming to standards found @ https://developer.jwplayer.com/jwplayer/reference#get_v2-sites-site-id-media 20 | :return: Dict which represents the JSON response. 21 | """ 22 | 23 | path_to_csv = path_to_csv or os.path.join(os.getcwd(), 'video_list.csv') 24 | timeout_in_seconds = 2 25 | max_retries = 3 26 | retries = 0 27 | page = 1 28 | videos = list() 29 | if query_params is None: 30 | query_params = {} 31 | query_params["page_length"] = result_limit 32 | 33 | jwplatform_client = JWPlatformClient(secret) 34 | logging.info("Querying for video list.") 35 | 36 | while True: 37 | try: 38 | query_params["page"] = page 39 | response = jwplatform_client.Media.list(site_id=site_id, query_params=query_params) 40 | except jwplatform.errors.TooManyRequestsError: 41 | logging.error("Encountered rate limiting error. Backing off on request time.") 42 | if retries == max_retries: 43 | raise 44 | timeout_in_seconds *= timeout_in_seconds # Exponential back off for timeout in seconds. 2->4->8->etc.etc. 45 | retries += 1 46 | time.sleep(timeout_in_seconds) 47 | continue 48 | except jwplatform.errors.APIError as e: 49 | logging.error("Encountered an error querying for videos list.\n{}".format(e)) 50 | raise e 51 | 52 | # Reset retry flow-control variables upon a non successful query (AKA not rate limited) 53 | retries = 0 54 | timeout_in_seconds = 2 55 | 56 | # Add all fetched video objects to our videos list. 57 | next_videos = response.json_body["media"] 58 | for video in next_videos: 59 | csv_video = video["metadata"] 60 | csv_video["id"] = video["id"] 61 | csv_video['duration'] = video['duration'] 62 | csv_video['custom_params'] = video['custom_params'] 63 | captions = get_captions(api_client=jwplatform_client, site_id=site_id, media_id=video["id"]) 64 | csv_video['has_captions'] = bool(len(captions)) 65 | csv_video['captions'] = captions 66 | videos.append(csv_video) 67 | page += 1 68 | logging.info("Accumulated {} videos.".format(len(videos))) 69 | if len(next_videos) == 0: # Condition which defines you've reached the end of the library 70 | break 71 | 72 | # Section for writing video library to csv 73 | desired_fields = ['id', 'title', 'description', 'tags', 'publish_start_date', 'permalink', 'custom_params', 'duration', 'has_captions', 'captions'] 74 | should_write_header = not os.path.isfile(path_to_csv) 75 | with open(path_to_csv, 'a+') as path_to_csv: 76 | # Only write columns to the csv which are specified above. Columns not specified are ignored. 77 | writer = csv.DictWriter(path_to_csv, fieldnames=desired_fields, extrasaction='ignore') 78 | if should_write_header: 79 | writer.writeheader() 80 | writer.writerows(videos) 81 | 82 | def get_captions(api_client, site_id, media_id): 83 | captions = [] 84 | captions_response = {} 85 | try: 86 | captions_response = api_client.request( 87 | method='GET', 88 | path=f'https://api.jwplayer.com/v2/sites/{site_id}/media/{media_id}/text_tracks/' 89 | ) 90 | except jwplatform.errors.TooManyRequestsError: 91 | logging.error("Encountered rate limiting error. Taking a 60 seconds break.") 92 | time.sleep(60) 93 | captions_response = api_client.request( 94 | method='GET', 95 | path=f'https://api.jwplayer.com/v2/sites/{site_id}/media/{media_id}/text_tracks/' 96 | ) 97 | except jwplatform.errors.APIError as e: 98 | logging.error("Encountered an error querying for text tracks list.\n{}".format(e)) 99 | raise e 100 | for text_track in captions_response.json_body['text_tracks']: 101 | captions.append( 102 | { 103 | 'created': text_track['created'], 104 | 'id': text_track['id'], 105 | 'metadata.label': text_track['metadata']['label'], 106 | 'metadata.srclang': text_track['metadata']['srclang'], 107 | 'status': text_track['status'], 108 | 'track_kind':text_track['track_kind'] 109 | } 110 | ) 111 | return captions 112 | -------------------------------------------------------------------------------- /jwplatform/upload.py: -------------------------------------------------------------------------------- 1 | import http.client 2 | import logging 3 | import math 4 | import os 5 | from dataclasses import dataclass 6 | from enum import Enum 7 | from hashlib import md5 8 | from urllib.parse import urlparse 9 | 10 | MAX_PAGE_SIZE = 1000 11 | MIN_PART_SIZE = 5 * 1024 * 1024 12 | MAX_FILE_SIZE = 25 * 1000 * 1024 * 1024 13 | 14 | 15 | class UploadType(Enum): 16 | """ 17 | This class stores the enum values for the different type of uploads. 18 | """ 19 | direct = "direct" 20 | multipart = "multipart" 21 | 22 | 23 | @dataclass 24 | class UploadContext: 25 | """ 26 | This class stores the structure for an upload context so that it can be resumed later. 27 | """ 28 | 29 | def __init__(self, upload_method, upload_id, upload_token, direct_link): 30 | self.upload_method = upload_method 31 | self.upload_id = upload_id 32 | self.upload_token = upload_token 33 | self.direct_link = direct_link 34 | 35 | """ 36 | This method evaluates whether an upload can be resumed based on the upload context state 37 | """ 38 | def can_resume(self) -> bool: 39 | return self.upload_token is not None \ 40 | and self.upload_method == UploadType.multipart.value \ 41 | and self.upload_id is not None 42 | 43 | 44 | def _upload_to_s3(bytes_chunk, upload_link): 45 | url_metadata = urlparse(upload_link) 46 | if url_metadata.scheme in 'https': 47 | connection = http.client.HTTPSConnection(host=url_metadata.hostname) 48 | else: 49 | connection = http.client.HTTPConnection(host=url_metadata.hostname) 50 | 51 | connection.request('PUT', upload_link, body=bytes_chunk) 52 | response = connection.getresponse() 53 | if 200 <= response.status <= 299: 54 | return response 55 | 56 | raise S3UploadError(response) 57 | 58 | 59 | def _get_bytes_hash(bytes_chunk): 60 | return md5(bytes_chunk).hexdigest() 61 | 62 | 63 | def _get_returned_hash(response): 64 | return response.headers['ETag'] 65 | 66 | 67 | class MultipartUpload: 68 | """ 69 | This class manages the multi-part upload. 70 | """ 71 | 72 | def __init__(self, client, file, target_part_size, retry_count, upload_context: UploadContext): 73 | self._upload_id = upload_context.upload_id 74 | self._target_part_size = target_part_size 75 | self._upload_retry_count = retry_count 76 | self._file = file 77 | self._client = client 78 | self._logger = logging.getLogger(self.__class__.__name__) 79 | self._upload_context = upload_context 80 | 81 | @property 82 | def upload_context(self): 83 | return self._upload_context 84 | 85 | @upload_context.setter 86 | def upload_context(self, value): 87 | self._upload_context = value 88 | 89 | def upload(self): 90 | """ 91 | This methods uploads the parts for the multi-part upload. 92 | Returns: 93 | 94 | """ 95 | if self._target_part_size < MIN_PART_SIZE: 96 | raise ValueError(f"The part size has to be at least greater than {MIN_PART_SIZE} bytes.") 97 | 98 | filename = self._file.name 99 | file_size = os.stat(filename).st_size 100 | part_count = math.ceil(file_size / self._target_part_size) 101 | 102 | if part_count > 10000: 103 | raise ValueError("The given file cannot be divided into more than 10000 parts. Please try increasing the " 104 | "target part size.") 105 | 106 | # Upload the parts 107 | self._upload_parts(part_count) 108 | 109 | # Mark upload as complete 110 | self._mark_upload_completion() 111 | 112 | def _upload_parts(self, part_count): 113 | try: 114 | filename = self._file.name 115 | remaining_parts_count = part_count 116 | total_page_count = math.ceil(part_count / MAX_PAGE_SIZE) 117 | for page_number in range(1, total_page_count + 1): 118 | batch_size = min(remaining_parts_count, MAX_PAGE_SIZE) 119 | page_length = MAX_PAGE_SIZE 120 | remaining_parts_count = remaining_parts_count - batch_size 121 | query_params = {'page_length': page_length, 'page': page_number} 122 | self._logger.debug( 123 | f'calling list method with page_number:{page_number} and page_length:{page_length}.') 124 | body = self._retrieve_part_links(query_params) 125 | upload_links = body['parts'] 126 | for returned_part in upload_links[:batch_size]: 127 | part_number = returned_part['id'] 128 | bytes_chunk = self._file.read(self._target_part_size) 129 | if part_number < batch_size and len(bytes_chunk) != self._target_part_size: 130 | raise IOError("Failed to read enough bytes") 131 | retry_count = 0 132 | for _ in range(self._upload_retry_count): 133 | try: 134 | self._upload_part(bytes_chunk, part_number, returned_part) 135 | self._logger.debug( 136 | f"Successfully uploaded part {(page_number - 1) * MAX_PAGE_SIZE + part_number} " 137 | f"of {part_count} for upload id {self._upload_id}") 138 | break 139 | except (DataIntegrityError, PartUploadError, OSError) as err: 140 | self._logger.warning(err) 141 | retry_count = retry_count + 1 142 | self._logger.warning( 143 | f"Encountered error upload part {(page_number - 1) * MAX_PAGE_SIZE + part_number} " 144 | f"of {part_count} for file {filename}.") 145 | if retry_count >= self._upload_retry_count: 146 | self._file.seek(0, 0) 147 | raise MaxRetriesExceededError( 148 | f"Max retries ({self._upload_retry_count}) exceeded while uploading part" 149 | f" {part_number} of {part_count} for file {filename}.") from err 150 | except Exception as ex: 151 | self._file.seek(0, 0) 152 | self._logger.exception(ex) 153 | raise 154 | 155 | def _retrieve_part_links(self, query_params): 156 | resp = self._client.list(upload_id=self._upload_id, query_params=query_params) 157 | return resp.json_body 158 | 159 | def _upload_part(self, bytes_chunk, part_number, returned_part): 160 | computed_hash = _get_bytes_hash(bytes_chunk) 161 | 162 | # Check if the file has already been uploaded and the hash matches. Return immediately without doing anything 163 | # if the hash matches. 164 | upload_hash = self._get_uploaded_part_hash(returned_part) 165 | if upload_hash and (repr(upload_hash) == repr(f"{computed_hash}")): # returned hash is not surrounded by '"' 166 | self._logger.debug(f"Part number {part_number} already uploaded. Skipping") 167 | return 168 | if upload_hash: 169 | raise UnrecoverableError(f'The file part {part_number} has been uploaded but the hash of the uploaded part ' 170 | f'does not match the hash of the current part read. Aborting.') 171 | 172 | if "upload_link" not in returned_part: 173 | raise KeyError(f"Invalid upload link for part {part_number}.") 174 | 175 | returned_part = returned_part["upload_link"] 176 | response = _upload_to_s3(bytes_chunk, returned_part) 177 | 178 | returned_hash = _get_returned_hash(response) 179 | if repr(returned_hash) != repr(f"\"{computed_hash}\""): # The returned hash is surrounded by '"' character 180 | raise DataIntegrityError("The hash of the uploaded file does not match with the hash on the server.") 181 | 182 | def _get_uploaded_part_hash(self, upload_link): 183 | upload_hash = upload_link.get("etag") 184 | return upload_hash 185 | 186 | def _mark_upload_completion(self): 187 | self._client.complete(self._upload_id) 188 | self._logger.info("Upload successful!") 189 | 190 | 191 | class SingleUpload: 192 | """ 193 | This class manages the operations related to the upload of a media file via a direct link. 194 | """ 195 | 196 | def __init__(self, upload_link, file, retry_count, upload_context: UploadContext): 197 | self._upload_link = upload_link 198 | self._upload_retry_count = retry_count 199 | self._file = file 200 | self._logger = logging.getLogger(self.__class__.__name__) 201 | self._upload_context = upload_context 202 | 203 | @property 204 | def upload_context(self): 205 | return self._upload_context 206 | 207 | @upload_context.setter 208 | def upload_context(self, value): 209 | self._upload_context = value 210 | 211 | def upload(self): 212 | """ 213 | Uploads the media file to the actual location as specified in the direct link. 214 | Returns: 215 | 216 | """ 217 | self._logger.debug(f"Starting to upload file:{self._file.name}") 218 | bytes_chunk = self._file.read() 219 | computed_hash = _get_bytes_hash(bytes_chunk) 220 | retry_count = 0 221 | for _ in range(self._upload_retry_count): 222 | try: 223 | response = _upload_to_s3(bytes_chunk, self._upload_link) 224 | returned_hash = _get_returned_hash(response) 225 | # The returned hash is surrounded by '"' character 226 | if repr(returned_hash) != repr(f"\"{computed_hash}\""): 227 | raise DataIntegrityError( 228 | "The hash of the uploaded file does not match with the hash on the server.") 229 | self._logger.debug(f"Successfully uploaded file {self._file.name}.") 230 | return 231 | except (IOError, PartUploadError, DataIntegrityError, OSError) as err: 232 | self._logger.warning(err) 233 | self._logger.exception(err, stack_info=True) 234 | self._logger.warning(f"Encountered error uploading file {self._file.name}.") 235 | retry_count = retry_count + 1 236 | if retry_count >= self._upload_retry_count: 237 | self._file.seek(0, 0) 238 | raise MaxRetriesExceededError(f"Max retries exceeded while uploading file {self._file.name}") \ 239 | from err 240 | 241 | except Exception as ex: 242 | self._file.seek(0, 0) 243 | self._logger.exception(ex) 244 | raise 245 | 246 | 247 | class DataIntegrityError(Exception): 248 | """ 249 | This class is used to wrap exceptions when the uploaded data failed a data integrity check with the current file 250 | part hash. 251 | """ 252 | pass 253 | 254 | 255 | class MaxRetriesExceededError(Exception): 256 | """ 257 | This class is used to wrap exceptions when the number of retries are exceeded while uploading a part. 258 | """ 259 | pass 260 | 261 | 262 | class PartUploadError(Exception): 263 | """ 264 | This class is used to wrap exceptions that occur because of part upload errors. 265 | """ 266 | pass 267 | 268 | 269 | class S3UploadError(PartUploadError): 270 | """ 271 | This class extends the PartUploadError exception class when the upload is done via S3. 272 | """ 273 | pass 274 | 275 | 276 | class UnrecoverableError(Exception): 277 | """ 278 | This class wraps exceptions that should not be recoverable or resumed from. 279 | """ 280 | pass 281 | -------------------------------------------------------------------------------- /tests/v1/test_errors.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import re 4 | import json 5 | 6 | import responses 7 | import pytest 8 | 9 | import jwplatform.v1 10 | 11 | 12 | SUPPORTED_ERROR_CASES = [ 13 | { 14 | 'http_status': 400, 15 | 'response': { 16 | 'status': 'error', 17 | 'code': 'UnknownError', 18 | 'title': 'An Unknown Error occurred', 19 | 'message': '' 20 | }, 21 | 'expected_exception': jwplatform.v1.errors.JWPlatformUnknownError 22 | }, 23 | { 24 | 'http_status': 404, 25 | 'response': { 26 | 'status': 'error', 27 | 'code': 'NotFound', 28 | 'title': 'Not Found', 29 | 'message': 'Item not found' 30 | }, 31 | 'expected_exception': jwplatform.v1.errors.JWPlatformNotFoundError 32 | }, 33 | { 34 | 'http_status': 400, 35 | 'response': { 36 | 'status': 'error', 37 | 'code': 'NoMethod', 38 | 'title': 'No Method Specified', 39 | 'message': '' 40 | }, 41 | 'expected_exception': jwplatform.v1.errors.JWPlatformNoMethodError 42 | }, 43 | { 44 | 'http_status': 501, 45 | 'response': { 46 | 'status': 'error', 47 | 'code': 'NotImplemented', 48 | 'title': 'Method Not Implemented', 49 | 'message': '' 50 | }, 51 | 'expected_exception': jwplatform.v1.errors.JWPlatformNotImplementedError 52 | }, 53 | { 54 | 'http_status': 405, 55 | 'response': { 56 | 'status': 'error', 57 | 'code': 'NotSupported', 58 | 'title': 'Method or parameter not supported', 59 | 'message': '' 60 | }, 61 | 'expected_exception': jwplatform.v1.errors.JWPlatformNotSupportedError 62 | }, 63 | { 64 | 'http_status': 500, 65 | 'response': { 66 | 'status': 'error', 67 | 'code': 'CallFailed', 68 | 'title': 'Call Failed', 69 | 'message': '' 70 | }, 71 | 'expected_exception': jwplatform.v1.errors.JWPlatformCallFailedError 72 | }, 73 | { 74 | 'http_status': 503, 75 | 'response': { 76 | 'status': 'error', 77 | 'code': 'CallUnavailable', 78 | 'title': 'Call Unavailable', 79 | 'message': '' 80 | }, 81 | 'expected_exception': jwplatform.v1.errors.JWPlatformCallUnavailableError 82 | }, 83 | { 84 | 'http_status': 400, 85 | 'response': { 86 | 'status': 'error', 87 | 'code': 'CallInvalid', 88 | 'title': 'Call Invalid', 89 | 'message': '' 90 | }, 91 | 'expected_exception': jwplatform.v1.errors.JWPlatformCallInvalidError 92 | }, 93 | { 94 | 'http_status': 400, 95 | 'response': { 96 | 'status': 'error', 97 | 'code': 'ParameterMissing', 98 | 'title': 'Missing Parameter', 99 | 'message': 'Parameter is missing' 100 | }, 101 | 'expected_exception': jwplatform.v1.errors.JWPlatformParameterMissingError 102 | }, 103 | { 104 | 'http_status': 400, 105 | 'response': { 106 | 'status': 'error', 107 | 'code': 'ParameterEmpty', 108 | 'title': 'Empty Parameter', 109 | 'message': '' 110 | }, 111 | 'expected_exception': jwplatform.v1.errors.JWPlatformParameterEmptyError 112 | }, 113 | { 114 | 'http_status': 400, 115 | 'response': { 116 | 'status': 'error', 117 | 'code': 'ParameterEncodingError', 118 | 'title': 'Parameter Encoding Error', 119 | 'message': '' 120 | }, 121 | 'expected_exception': jwplatform.v1.errors.JWPlatformParameterEncodingError 122 | }, 123 | { 124 | 'http_status': 400, 125 | 'response': { 126 | 'status': 'error', 127 | 'code': 'ParameterInvalid', 128 | 'title': 'Invalid Parameter', 129 | 'message': '' 130 | }, 131 | 'expected_exception': jwplatform.v1.errors.JWPlatformParameterInvalidError 132 | }, 133 | { 134 | 'http_status': 412, 135 | 'response': { 136 | 'status': 'error', 137 | 'code': 'PreconditionFailed', 138 | 'title': 'Precondition Failed', 139 | 'message': '' 140 | }, 141 | 'expected_exception': jwplatform.v1.errors.JWPlatformPreconditionFailedError 142 | }, 143 | { 144 | 'http_status': 409, 145 | 'response': { 146 | 'status': 'error', 147 | 'code': 'ItemAlreadyExists', 148 | 'title': 'Item Already Exists', 149 | 'message': '' 150 | }, 151 | 'expected_exception': jwplatform.v1.errors.JWPlatformItemAlreadyExistsError 152 | }, 153 | { 154 | 'http_status': 403, 155 | 'response': { 156 | 'status': 'error', 157 | 'code': 'PermissionDenied', 158 | 'title': 'Permission Denied', 159 | 'message': '' 160 | }, 161 | 'expected_exception': jwplatform.v1.errors.JWPlatformPermissionDeniedError 162 | }, 163 | { 164 | 'http_status': 500, 165 | 'response': { 166 | 'status': 'error', 167 | 'code': 'DatabaseError', 168 | 'title': 'Database Error', 169 | 'message': '' 170 | }, 171 | 'expected_exception': jwplatform.v1.errors.JWPlatformDatabaseError 172 | }, 173 | { 174 | 'http_status': 500, 175 | 'response': { 176 | 'status': 'error', 177 | 'code': 'IntegrityError', 178 | 'title': 'Integrity Error', 179 | 'message': '' 180 | }, 181 | 'expected_exception': jwplatform.v1.errors.JWPlatformIntegrityError 182 | }, 183 | { 184 | 'http_status': 400, 185 | 'response': { 186 | 'status': 'error', 187 | 'code': 'DigestMissing', 188 | 'title': 'Digest Missing', 189 | 'message': '' 190 | }, 191 | 'expected_exception': jwplatform.v1.errors.JWPlatformDigestMissingError 192 | }, 193 | { 194 | 'http_status': 400, 195 | 'response': { 196 | 'status': 'error', 197 | 'code': 'DigestInvalid', 198 | 'title': 'Digest Invalid', 199 | 'message': '' 200 | }, 201 | 'expected_exception': jwplatform.v1.errors.JWPlatformDigestInvalidError 202 | }, 203 | { 204 | 'http_status': 400, 205 | 'response': { 206 | 'status': 'error', 207 | 'code': 'FileUploadFailed', 208 | 'title': 'File Upload Failed', 209 | 'message': '' 210 | }, 211 | 'expected_exception': jwplatform.v1.errors.JWPlatformFileUploadFailedError 212 | }, 213 | { 214 | 'http_status': 400, 215 | 'response': { 216 | 'status': 'error', 217 | 'code': 'FileSizeMissing', 218 | 'title': 'File Size Missing', 219 | 'message': '' 220 | }, 221 | 'expected_exception': jwplatform.v1.errors.JWPlatformFileSizeMissingError 222 | }, 223 | { 224 | 'http_status': 400, 225 | 'response': { 226 | 'status': 'error', 227 | 'code': 'FileSizeInvalid', 228 | 'title': 'File Size Invalid', 229 | 'message': '' 230 | }, 231 | 'expected_exception': jwplatform.v1.errors.JWPlatformFileSizeInvalidError 232 | }, 233 | { 234 | 'http_status': 500, 235 | 'response': { 236 | 'status': 'error', 237 | 'code': 'InternalError', 238 | 'title': 'Internal Error', 239 | 'message': '' 240 | }, 241 | 'expected_exception': jwplatform.v1.errors.JWPlatformInternalError 242 | }, 243 | { 244 | 'http_status': 400, 245 | 'response': { 246 | 'status': 'error', 247 | 'code': 'ApiKeyMissing', 248 | 'title': 'User Key Missing', 249 | 'message': '' 250 | }, 251 | 'expected_exception': jwplatform.v1.errors.JWPlatformApiKeyMissingError 252 | }, 253 | { 254 | 'http_status': 400, 255 | 'response': { 256 | 'status': 'error', 257 | 'code': 'ApiKeyInvalid', 258 | 'title': 'User Key Invalid', 259 | 'message': '' 260 | }, 261 | 'expected_exception': jwplatform.v1.errors.JWPlatformApiKeyInvalidError 262 | }, 263 | { 264 | 'http_status': 400, 265 | 'response': { 266 | 'status': 'error', 267 | 'code': 'TimestampMissing', 268 | 'title': 'Timestamp Missing', 269 | 'message': '' 270 | }, 271 | 'expected_exception': jwplatform.v1.errors.JWPlatformTimestampMissingError 272 | }, 273 | { 274 | 'http_status': 400, 275 | 'response': { 276 | 'status': 'error', 277 | 'code': 'TimestampInvalid', 278 | 'title': 'Timestamp Invalid', 279 | 'message': '' 280 | }, 281 | 'expected_exception': jwplatform.v1.errors.JWPlatformTimestampInvalidError 282 | }, 283 | { 284 | 'http_status': 403, 285 | 'response': { 286 | 'status': 'error', 287 | 'code': 'TimestampExpired', 288 | 'title': 'Timestamp Expired', 289 | 'message': '' 290 | }, 291 | 'expected_exception': jwplatform.v1.errors.JWPlatformTimestampExpiredError 292 | }, 293 | { 294 | 'http_status': 400, 295 | 'response': { 296 | 'status': 'error', 297 | 'code': 'NonceMissing', 298 | 'title': 'Nonce Missing', 299 | 'message': '' 300 | }, 301 | 'expected_exception': jwplatform.v1.errors.JWPlatformNonceMissingError 302 | }, 303 | { 304 | 'http_status': 400, 305 | 'response': { 306 | 'status': 'error', 307 | 'code': 'NonceInvalid', 308 | 'title': 'Nonce Invalid', 309 | 'message': '' 310 | }, 311 | 'expected_exception': jwplatform.v1.errors.JWPlatformNonceInvalidError 312 | }, 313 | { 314 | 'http_status': 400, 315 | 'response': { 316 | 'status': 'error', 317 | 'code': 'SignatureMissing', 318 | 'title': 'Signature Missing', 319 | 'message': '' 320 | }, 321 | 'expected_exception': jwplatform.v1.errors.JWPlatformSignatureMissingError 322 | }, 323 | { 324 | 'http_status': 400, 325 | 'response': { 326 | 'status': 'error', 327 | 'code': 'SignatureInvalid', 328 | 'title': 'Signature Invalid', 329 | 'message': '400' 330 | }, 331 | 'expected_exception': jwplatform.v1.errors.JWPlatformSignatureInvalidError 332 | }, 333 | { 334 | 'http_status': 429, 335 | 'response': { 336 | 'status': 'error', 337 | 'code': 'RateLimitExceeded', 338 | 'title': 'Rate Limit Exceeded', 339 | 'message': '' 340 | }, 341 | 'expected_exception': jwplatform.v1.errors.JWPlatformRateLimitExceededError 342 | }, 343 | { 344 | 'http_status': 500, 345 | 'response': { 346 | 'status': 'error', 347 | 'message': 'New unhandled error', 348 | 'code': 'NewError', 349 | 'title': 'New Error' 350 | }, 351 | 'expected_exception': jwplatform.v1.errors.JWPlatformUnknownError 352 | } 353 | ] 354 | 355 | 356 | @responses.activate 357 | @pytest.mark.parametrize('test_case', SUPPORTED_ERROR_CASES) 358 | def test_supported_errors_parsing(test_case): 359 | url_expr = re.compile(r'https?://api\.test\.tst/v1/error\?.*') 360 | 361 | responses.add( 362 | responses.GET, url_expr, 363 | status=test_case['http_status'], 364 | content_type='application/json', 365 | body=json.dumps(test_case['response'])) 366 | 367 | jwp_client = jwplatform.v1.Client('api_key', 'api_secret', host='api.test.tst') 368 | 369 | with pytest.raises(test_case['expected_exception']) as err: 370 | jwp_client.error() 371 | 372 | assert err.value.message == test_case['response']['message'] 373 | 374 | 375 | @responses.activate 376 | def test_empty_response_parsing(): 377 | url_expr = re.compile(r'https?://api\.test\.tst/v1/error\?.*') 378 | 379 | responses.add( 380 | responses.GET, url_expr, 381 | status=500, 382 | content_type='application/json', 383 | body='') 384 | 385 | jwp_client = jwplatform.v1.Client('api_key', 'api_secret', host='api.test.tst') 386 | 387 | with pytest.raises(jwplatform.v1.errors.JWPlatformUnknownError) as err: 388 | jwp_client.error() 389 | 390 | assert err.value.message == 'Not a valid JSON string: ' 391 | 392 | 393 | @responses.activate 394 | def test_non_json_response_parsing(): 395 | url_expr = re.compile(r'https?://api\.test\.tst/v1/error\?.*') 396 | 397 | responses.add( 398 | responses.GET, url_expr, 399 | status=502, 400 | content_type='text/html', 401 | body='502 Bad Gateway') 402 | 403 | jwp_client = jwplatform.v1.Client('api_key', 'api_secret', host='api.test.tst') 404 | 405 | with pytest.raises(jwplatform.v1.errors.JWPlatformUnknownError) as err: 406 | jwp_client.error() 407 | 408 | assert err.value.message == 'Not a valid JSON string: 502 Bad Gateway' 409 | -------------------------------------------------------------------------------- /tests/test_uploads.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import math 3 | import sys 4 | # import mock 5 | import http.client 6 | from hashlib import md5 7 | from unittest import skip, TestCase 8 | from unittest.mock import patch, mock_open, Mock 9 | from http.client import RemoteDisconnected 10 | 11 | from jwplatform.version import __version__ 12 | from jwplatform.client import JWPlatformClient, JWPLATFORM_API_HOST 13 | import logging 14 | 15 | # from .mock import JWPlatformMock 16 | from jwplatform.upload import UploadType, MaxRetriesExceededError, S3UploadError, UploadContext 17 | from tests.mock import JWPlatformMock, S3Mock 18 | 19 | 20 | def _get_parts_responses(part_count): 21 | parts = [{"upload_link": "http://s3server/upload-link", 22 | 'id': part_id + 1} for part_id in range(part_count)] 23 | result = {'page': 1, 'page_length': 10, 'total': 10, 'parts': parts} 24 | return result 25 | 26 | 27 | class TestUploads(TestCase): 28 | file_content_mock_data_simple = b'some bytes' 29 | file_content_mock_data_large = b'some bytes' * 5 * 1024 * 1024 30 | logging.basicConfig(stream=sys.stdout, level=logging.DEBUG) 31 | 32 | @patch("os.stat") 33 | @patch("builtins.open") 34 | def test_upload_method_is_direct_when_file_size_is_small(self, mock_file, os_stat): 35 | os_stat.return_value.st_size = 4 * 1024 * 1024 36 | client = JWPlatformClient() 37 | media_client_instance = client.Media 38 | file_absolute_path = "mock_file_path" 39 | with open(file_absolute_path, "rb") as file: 40 | kwargs = {'target_part_size': 10 * 1024 * 1024, 'retry_count': 10, 'site_id': 'siteDEid'} 41 | with JWPlatformMock(): 42 | upload_context = media_client_instance.create_media_and_get_upload_context(file, **kwargs) 43 | self.assertTrue(upload_context.upload_method == UploadType.direct.value) 44 | mock_file.assert_called_with(file_absolute_path, "rb") 45 | os_stat.assert_called_once() 46 | 47 | @patch("os.stat") 48 | @patch("builtins.open") 49 | def test_upload_method_is_multipart_when_file_size_is_large(self, mock_file, os_stat): 50 | os_stat.return_value.st_size = 100 * 1024 * 1024 51 | client = JWPlatformClient() 52 | media_client_instance = client.Media 53 | file_absolute_path = "mock_file_path" 54 | with open(file_absolute_path, "rb") as file: 55 | kwargs = {'target_part_size': 10 * 1024 * 1024, 'retry_count': 10, 'site_id': 'siteDEid'} 56 | with JWPlatformMock() as mock_api: 57 | upload_context = media_client_instance.create_media_and_get_upload_context(file, **kwargs) 58 | self.assertTrue(upload_context.upload_method == UploadType.multipart.value) 59 | mock_file.assert_called_with(file_absolute_path, "rb") 60 | os_stat.assert_called_once() 61 | mock_api.createMedia.request_mock.assert_called_once() 62 | 63 | @patch("builtins.open", new_callable=mock_open, read_data=file_content_mock_data_simple) 64 | @patch("os.stat") 65 | @patch("jwplatform.upload._get_bytes_hash") 66 | @patch("jwplatform.upload._get_returned_hash") 67 | def test_upload_method_with_direct_upload(self, get_returned_hash, get_bytes_hash, os_stat, mock_file): 68 | 69 | file_hash = md5(self.file_content_mock_data_simple).hexdigest() 70 | get_bytes_hash.return_value = file_hash 71 | get_returned_hash.return_value = f'\"{file_hash}\"' 72 | os_stat.return_value.st_size = 5 * 1024 * 1024 73 | client = JWPlatformClient() 74 | media_client_instance = client.Media 75 | file_absolute_path = "mock_file_path" 76 | with open(file_absolute_path, "rb") as file: 77 | kwargs = {'target_part_size': 10 * 1024 * 1024, 'retry_count': 1, 'site_id': 'siteDEid'} 78 | with JWPlatformMock() as mock_api, S3Mock() as s3_api: 79 | upload_context = media_client_instance.create_media_and_get_upload_context(file, **kwargs) 80 | media_client_instance.upload(file, upload_context, **kwargs) 81 | self.assertTrue(upload_context.upload_method == UploadType.direct.value) 82 | mock_file.assert_called_with(file_absolute_path, "rb") 83 | os_stat.assert_called_once() 84 | mock_api.createMedia.request_mock.assert_called_once() 85 | s3_api.uploadToS3.request_mock.assert_called_once() 86 | 87 | @patch("builtins.open", new_callable=mock_open, read_data=file_content_mock_data_simple) 88 | @patch("os.stat") 89 | @patch("jwplatform.upload._get_bytes_hash") 90 | @patch("jwplatform.upload._get_returned_hash", return_value='wrong_hash') 91 | def test_upload_method_with_direct_upload_fails_hash_check(self, get_returned_hash, get_bytes_hash, os_stat, 92 | mock_file): 93 | file_hash = md5(self.file_content_mock_data_simple).hexdigest() 94 | get_bytes_hash.return_value = file_hash 95 | os_stat.return_value.st_size = 5 * 1024 * 1024 96 | client = JWPlatformClient() 97 | media_client_instance = client.Media 98 | file_absolute_path = "mock_file_path" 99 | with self.assertRaises(MaxRetriesExceededError): 100 | with open(file_absolute_path, "rb") as file: 101 | kwargs = {'target_part_size': 10 * 1024 * 1024, 'retry_count': 1, 'site_id': 'siteDEid'} 102 | with JWPlatformMock() as mock_api, S3Mock() as s3_api: 103 | upload_context = media_client_instance.create_media_and_get_upload_context(file, **kwargs) 104 | media_client_instance.upload(file, upload_context, **kwargs) 105 | s3_api.uploadToS3.request_mock.assert_not_called() 106 | mock_api.completeUpload.request_mock.assert_not_called() 107 | mock_file.assert_called_with(file_absolute_path, "rb") 108 | 109 | @patch("builtins.open", new_callable=mock_open, read_data=file_content_mock_data_simple) 110 | @patch("os.stat") 111 | @patch("jwplatform.upload._get_bytes_hash") 112 | @patch("jwplatform.upload._get_returned_hash") 113 | def test_upload_method_with_direct_upload_retries_on_failed_hash_check(self, get_returned_hash, get_bytes_hash, 114 | os_stat, mock_file): 115 | file_hash = md5(self.file_content_mock_data_simple).hexdigest() 116 | get_bytes_hash.return_value = file_hash 117 | get_returned_hash.side_effect = ['wrong-hash', 'wrong-hash', f'\"{file_hash}\"'] 118 | os_stat.return_value.st_size = 5 * 1024 * 1024 119 | client = JWPlatformClient() 120 | media_client_instance = client.Media 121 | file_absolute_path = "mock_file_path" 122 | with open(file_absolute_path, "rb") as file: 123 | kwargs = {'target_part_size': 10 * 1024 * 1024, 'retry_count': 3, 'site_id': 'siteDEid'} 124 | with JWPlatformMock() as mock_api, S3Mock() as s3_api: 125 | upload_context = media_client_instance.create_media_and_get_upload_context(file, **kwargs) 126 | media_client_instance.upload(file, upload_context, **kwargs) 127 | self.assertTrue(upload_context.upload_method == UploadType.direct.value) 128 | mock_file.assert_called_with(file_absolute_path, "rb") 129 | self.assertEqual(get_returned_hash.call_count, 3) 130 | mock_api.createMedia.request_mock.assert_called_once() 131 | self.assertEqual(s3_api.uploadToS3.request_mock.call_count, 3) 132 | mock_file.assert_called_with(file_absolute_path, "rb") 133 | 134 | @patch("builtins.open", new_callable=mock_open, read_data=file_content_mock_data_simple) 135 | @patch("os.stat") 136 | @patch("jwplatform.upload._get_bytes_hash") 137 | @patch("jwplatform.upload._get_returned_hash") 138 | @patch("jwplatform.upload._upload_to_s3") 139 | def test_upload_method_with_direct_upload_retries_on_failed_s3_upload(self, s3_upload_response, get_returned_hash, 140 | get_bytes_hash, os_stat, mock_file): 141 | file_hash = md5(self.file_content_mock_data_simple).hexdigest() 142 | get_bytes_hash.return_value = file_hash 143 | get_returned_hash.return_value = f'\"{file_hash}\"' 144 | s3_upload_response.side_effect = [S3UploadError, S3UploadError, 'some_response'] 145 | os_stat.return_value.st_size = 5 * 1024 * 1024 146 | client = JWPlatformClient() 147 | media_client_instance = client.Media 148 | file_absolute_path = "mock_file_path" 149 | with open(file_absolute_path, "rb") as file: 150 | kwargs = {'target_part_size': 10 * 1024 * 1024, 'retry_count': 5, 'site_id': 'siteDEid'} 151 | with JWPlatformMock() as mock_api: 152 | upload_context = media_client_instance.create_media_and_get_upload_context(file, **kwargs) 153 | media_client_instance.upload(file, upload_context, **kwargs) 154 | self.assertTrue(upload_context.upload_method == UploadType.direct.value) 155 | mock_file.assert_called_with(file_absolute_path, "rb") 156 | self.assertEqual(get_returned_hash.call_count, 1) 157 | mock_api.createMedia.request_mock.assert_called_once() 158 | self.assertEqual(s3_upload_response.call_count, 3) 159 | mock_file.assert_called_with(file_absolute_path, "rb") 160 | 161 | @patch("builtins.open", new_callable=mock_open, read_data=file_content_mock_data_simple) 162 | @patch("os.stat") 163 | @patch("jwplatform.upload._get_bytes_hash") 164 | @patch("jwplatform.upload._get_returned_hash") 165 | @patch("jwplatform.upload._upload_to_s3") 166 | def test_upload_method_with_direct_upload_throws_when_retries_exceeded_on_failed_s3_upload(self, s3_upload_response, 167 | get_returned_hash, 168 | get_bytes_hash, os_stat, 169 | mock_file): 170 | file_hash = md5(self.file_content_mock_data_simple).hexdigest() 171 | get_bytes_hash.return_value = file_hash 172 | get_returned_hash.return_value = f'\"{file_hash}\"' 173 | s3_upload_response.side_effect = [S3UploadError, S3UploadError, S3UploadError] 174 | os_stat.return_value.st_size = 5 * 1024 * 1024 175 | client = JWPlatformClient() 176 | media_client_instance = client.Media 177 | file_absolute_path = "mock_file_path" 178 | with open(file_absolute_path, "rb") as file: 179 | kwargs = {'target_part_size': 10 * 1024 * 1024, 'retry_count': 3, 'site_id': 'siteDEid'} 180 | with JWPlatformMock() as mock_api: 181 | upload_context = media_client_instance.create_media_and_get_upload_context(file, **kwargs) 182 | with self.assertRaises(MaxRetriesExceededError): 183 | media_client_instance.upload(file, upload_context, **kwargs) 184 | mock_api.completeUpload.request_mock.assert_not_called() 185 | mock_file.assert_called_with(file_absolute_path, "rb") 186 | 187 | @patch("builtins.open", new_callable=mock_open, read_data=file_content_mock_data_simple) 188 | @patch("os.stat") 189 | @patch("jwplatform.upload._get_bytes_hash") 190 | @patch("jwplatform.upload._get_returned_hash") 191 | @patch("jwplatform.upload._upload_to_s3") 192 | def test_upload_method_with_direct_upload_throws_on_unexpected_error(self, s3_upload_response, 193 | get_returned_hash, 194 | get_bytes_hash, 195 | os_stat, 196 | mock_file): 197 | file_hash = md5(self.file_content_mock_data_simple).hexdigest() 198 | get_bytes_hash.return_value = file_hash 199 | get_returned_hash.return_value = f'\"{file_hash}\"' 200 | s3_upload_response.side_effect = Exception 201 | os_stat.return_value.st_size = 5 * 1024 * 1024 202 | client = JWPlatformClient() 203 | media_client_instance = client.Media 204 | file_absolute_path = "mock_file_path" 205 | with open(file_absolute_path, "rb") as file: 206 | kwargs = {'target_part_size': 10 * 1024 * 1024, 'retry_count': 3, 'site_id': 'siteDEid'} 207 | with JWPlatformMock() as mock_api: 208 | upload_context = media_client_instance.create_media_and_get_upload_context(file, **kwargs) 209 | with self.assertRaises(Exception): 210 | media_client_instance.upload(file, upload_context, **kwargs) 211 | mock_file.assert_called_with(file_absolute_path, "rb") 212 | 213 | @patch("builtins.open", new_callable=mock_open, read_data=file_content_mock_data_large) 214 | @patch("os.stat") 215 | @patch("jwplatform.upload._get_bytes_hash") 216 | @patch("jwplatform.upload._get_returned_hash") 217 | @patch("jwplatform.upload.MultipartUpload._get_uploaded_part_hash") 218 | @patch("jwplatform.upload.MultipartUpload._retrieve_part_links") 219 | def test_upload_method_with_multipart_upload_success(self, retrieve_part_links, get_uploaded_part_hash, 220 | get_returned_hash, get_bytes_hash,os_stat, mock_file): 221 | file_hash = md5(self.file_content_mock_data_large).hexdigest() 222 | get_bytes_hash.return_value = file_hash 223 | os_stat.return_value.st_size = len(self.file_content_mock_data_large) 224 | target_part_size = 5 * 1024 * 1024 225 | part_count = math.ceil(len(self.file_content_mock_data_large) / target_part_size) 226 | get_uploaded_part_hash.return_value = None 227 | get_returned_hash.return_value = f'\"{file_hash}\"' 228 | retrieve_part_links.return_value = _get_parts_responses(part_count) 229 | client = JWPlatformClient() 230 | media_client_instance = client.Media 231 | file_absolute_path = "mock_file_path" 232 | with open(file_absolute_path, "rb") as file: 233 | kwargs = {'target_part_size': 5 * 1024 * 1024, 'retry_count': 3, 'base_url': JWPLATFORM_API_HOST, 234 | 'site_id': 'siteDEid'} 235 | with JWPlatformMock() as mock_api, S3Mock() as s3_api: 236 | upload_context = media_client_instance.create_media_and_get_upload_context(file, **kwargs) 237 | self.assertTrue(upload_context.upload_method == UploadType.multipart.value) 238 | media_client_instance.upload(file, upload_context, **kwargs) 239 | mock_file.assert_called_with(file_absolute_path, "rb") 240 | mock_api.createMedia.request_mock.assert_called_once() 241 | mock_api.completeUpload.request_mock.assert_called_once() 242 | self.assertEqual(get_uploaded_part_hash.call_count, part_count) 243 | self.assertEqual(get_returned_hash.call_count, part_count) 244 | retrieve_part_links.assert_called_once() 245 | self.assertEqual(s3_api.uploadToS3.request_mock.call_count, part_count) 246 | 247 | @patch("builtins.open", new_callable=mock_open, read_data=file_content_mock_data_large) 248 | @patch("os.stat") 249 | @patch("jwplatform.upload._get_bytes_hash") 250 | @patch("jwplatform.upload._get_returned_hash") 251 | @patch("jwplatform.upload.MultipartUpload._get_uploaded_part_hash") 252 | @patch("jwplatform.upload.MultipartUpload._retrieve_part_links") 253 | def test_upload_method_with_multipart_upload_failure_on_mismatched_upload_hash(self, retrieve_part_links, 254 | get_uploaded_part_hash, 255 | get_returned_hash, 256 | get_bytes_hash, os_stat, mock_file): 257 | file_hash = md5(self.file_content_mock_data_large).hexdigest() 258 | get_bytes_hash.return_value = file_hash 259 | os_stat.return_value.st_size = len(self.file_content_mock_data_large) 260 | target_part_size = 5 * 1024 * 1024 261 | part_count = math.ceil(len(self.file_content_mock_data_large) / target_part_size) 262 | get_uploaded_part_hash.return_value = None 263 | get_returned_hash.return_value = f'\"wrong_hash\"' 264 | retrieve_part_links.return_value = _get_parts_responses(part_count) 265 | site_id = 'siteDEid' 266 | client = JWPlatformClient() 267 | media_client_instance = client.Media 268 | file_absolute_path = "mock_file_path" 269 | retry_count = 3 270 | with open(file_absolute_path, "rb") as file: 271 | kwargs = {'target_part_size': 5 * 1024 * 1024, 'retry_count': retry_count, 'base_url': JWPLATFORM_API_HOST, 272 | 'site_id': 'siteDEid'} 273 | with JWPlatformMock() as mock_api, S3Mock() as s3_api: 274 | upload_context = media_client_instance.create_media_and_get_upload_context(file, **kwargs) 275 | self.assertTrue(upload_context.upload_method == UploadType.multipart.value) 276 | with self.assertRaises(MaxRetriesExceededError): 277 | media_client_instance.upload(file, upload_context, **kwargs) 278 | mock_file.assert_called_with(file_absolute_path, "rb") 279 | mock_api.createMedia.request_mock.assert_called_once() 280 | mock_api.completeUpload.request_mock.assert_not_called() 281 | self.assertEqual(get_uploaded_part_hash.call_count, retry_count) 282 | self.assertEqual(get_returned_hash.call_count, retry_count) 283 | retrieve_part_links.assert_called_once() 284 | self.assertEqual(s3_api.uploadToS3.request_mock.call_count, retry_count) 285 | 286 | @patch("builtins.open", new_callable=mock_open, read_data=file_content_mock_data_large) 287 | @patch("os.stat") 288 | @patch("jwplatform.upload._get_bytes_hash") 289 | @patch("jwplatform.upload._get_returned_hash") 290 | @patch("jwplatform.upload.MultipartUpload._retrieve_part_links") 291 | @patch("jwplatform.upload.MultipartUpload._get_uploaded_part_hash") 292 | def test_upload_method_with_multipart_upload_resume_success(self, get_uploaded_part_hash, retrieve_part_links, 293 | get_returned_hash, get_bytes_hash, os_stat, mock_file): 294 | file_hash = md5(self.file_content_mock_data_large).hexdigest() 295 | get_bytes_hash.return_value = file_hash 296 | get_returned_hash.return_value = f'\"{file_hash}\"' 297 | get_uploaded_part_hash.return_value = file_hash 298 | os_stat.return_value.st_size = len(self.file_content_mock_data_large) 299 | target_part_size = 5 * 1024 * 1024 300 | part_count = len(self.file_content_mock_data_large) // target_part_size + 1 301 | retrieve_part_links.return_value = _get_parts_responses(part_count) 302 | site_id = 'siteDEid' 303 | client = JWPlatformClient() 304 | media_client_instance = client.Media 305 | file_absolute_path = "mock_file_path" 306 | with open(file_absolute_path, "rb") as file: 307 | kwargs = {'target_part_size': 5 * 1024 * 1024, 'retry_count': 3, 'base_url': JWPLATFORM_API_HOST, 308 | 'site_id': 'siteDEid'} 309 | with JWPlatformMock() as mock_api, S3Mock() as s3_api: 310 | upload_context = UploadContext(UploadType.multipart.value, 'NL3OL1JB', 'upload_token', None) 311 | media_client_instance.resume(file, upload_context, **kwargs) 312 | mock_file.assert_called_with(file_absolute_path, "rb") 313 | mock_api.createMedia.request_mock.assert_not_called() 314 | mock_api.completeUpload.request_mock.assert_called_once() 315 | retrieve_part_links.assert_called_once() 316 | s3_api.uploadToS3.request_mock.assert_not_called() 317 | 318 | @patch("builtins.open", new_callable=mock_open, read_data=file_content_mock_data_large) 319 | @patch("os.stat") 320 | @patch("jwplatform.upload._get_bytes_hash") 321 | @patch("jwplatform.upload._get_returned_hash") 322 | @patch("jwplatform.upload.MultipartUpload._retrieve_part_links") 323 | @patch("jwplatform.upload._upload_to_s3") 324 | def test_upload_method_with_multipart_upload_throws_when_retries_exceeded_on_failed_s3_upload(self, 325 | s3_upload_response, 326 | retrieve_part_links, 327 | get_returned_hash, 328 | get_bytes_hash, 329 | os_stat, 330 | mock_file): 331 | file_hash = md5(self.file_content_mock_data_large).hexdigest() 332 | get_bytes_hash.return_value = file_hash 333 | get_returned_hash.return_value = f'\"{file_hash}\"' 334 | os_stat.return_value.st_size = len(self.file_content_mock_data_large) 335 | target_part_size = 5 * 1024 * 1024 336 | part_count = len(self.file_content_mock_data_large) // target_part_size + 1 337 | retrieve_part_links.return_value = _get_parts_responses(part_count) 338 | s3_upload_response.side_effect = S3UploadError 339 | site_id = 'siteDEid' 340 | client = JWPlatformClient() 341 | media_client_instance = client.Media 342 | file_absolute_path = "mock_file_path" 343 | with open(file_absolute_path, "rb") as file: 344 | kwargs = {'target_part_size': 5 * 1024 * 1024, 'retry_count': 3, 'base_url': JWPLATFORM_API_HOST, 345 | 'site_id': 'siteDEid'} 346 | with JWPlatformMock() as mock_api, S3Mock() as s3_api: 347 | upload_context = media_client_instance.create_media_and_get_upload_context(file, **kwargs) 348 | self.assertTrue(upload_context.upload_method == UploadType.multipart.value) 349 | with self.assertRaises(MaxRetriesExceededError): 350 | media_client_instance.upload(file, upload_context, **kwargs) 351 | mock_api.completeUpload.request_mock.assert_not_called() 352 | mock_file.assert_called_with(file_absolute_path, "rb") 353 | 354 | @patch("builtins.open", new_callable=mock_open, read_data=file_content_mock_data_large) 355 | @patch("os.stat") 356 | @patch("jwplatform.upload._get_bytes_hash") 357 | @patch("jwplatform.upload._get_returned_hash") 358 | @patch("jwplatform.upload.MultipartUpload._retrieve_part_links") 359 | @patch("jwplatform.upload._upload_to_s3") 360 | def test_upload_method_with_multipart_upload_throws_on_unexpected_error(self, 361 | s3_upload_response, 362 | retrieve_part_links, 363 | get_returned_hash, 364 | get_bytes_hash, 365 | os_stat, 366 | mock_file): 367 | file_hash = md5(self.file_content_mock_data_large).hexdigest() 368 | get_bytes_hash.return_value = file_hash 369 | get_returned_hash.return_value = f'\"{file_hash}\"' 370 | os_stat.return_value.st_size = len(self.file_content_mock_data_large) 371 | target_part_size = 5 * 1024 * 1024 372 | part_count = len(self.file_content_mock_data_large) // target_part_size + 1 373 | retrieve_part_links.return_value = _get_parts_responses(part_count) 374 | s3_upload_response.side_effect = Exception 375 | site_id = 'siteDEid' 376 | client = JWPlatformClient() 377 | media_client_instance = client.Media 378 | file_absolute_path = "mock_file_path" 379 | with open(file_absolute_path, "rb") as file: 380 | kwargs = {'target_part_size': 5 * 1024 * 1024, 'retry_count': 3, 'base_url': JWPLATFORM_API_HOST, 381 | 'site_id': 'siteDEid'} 382 | with JWPlatformMock() as mock_api, S3Mock() as s3_api: 383 | upload_context = media_client_instance.create_media_and_get_upload_context(file, **kwargs) 384 | self.assertTrue(upload_context.upload_method == UploadType.multipart.value) 385 | with self.assertRaises(Exception): 386 | media_client_instance.upload(file, upload_context, **kwargs) 387 | mock_api.completeUpload.request_mock.assert_not_called() 388 | mock_file.assert_called_with(file_absolute_path, "rb") 389 | s3_upload_response.assert_called_once() 390 | -------------------------------------------------------------------------------- /jwplatform/client.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import http.client 3 | import logging 4 | import json 5 | import os 6 | import urllib.parse 7 | from neterr import StrictHTTPErrors 8 | 9 | from jwplatform.version import __version__ 10 | from jwplatform.errors import APIError 11 | from jwplatform.response import APIResponse, ResourceResponse, ResourcesResponse 12 | from jwplatform.upload import MultipartUpload, SingleUpload, UploadType, MIN_PART_SIZE, MaxRetriesExceededError, \ 13 | UploadContext, MAX_FILE_SIZE 14 | 15 | JWPLATFORM_API_HOST = 'api.jwplayer.com' 16 | JWPLATFORM_API_PORT = 443 17 | USER_AGENT = f"jwplatform_client-python/{__version__}" 18 | UPLOAD_RETRY_ATTEMPTS = 3 19 | 20 | __all__ = ( 21 | "JWPLATFORM_API_HOST", "JWPLATFORM_API_PORT", "USER_AGENT", "JWPlatformClient" 22 | ) 23 | 24 | 25 | class JWPlatformClient: 26 | """JW Platform API client. 27 | 28 | An API client for the JW Platform. For the API documentation see: 29 | https://developer.jwplayer.com/jwplayer/reference#introduction-to-api-v2 30 | 31 | Args: 32 | secret (str): Secret value for your API key 33 | host (str, optional): API server host name. 34 | Default is 'api.jwplayer.com'. 35 | 36 | Examples: 37 | jwplatform_client = jwplatform.client.Client('API_KEY') 38 | """ 39 | 40 | def __init__(self, secret=None, host=None): 41 | if host is None: 42 | host = JWPLATFORM_API_HOST 43 | 44 | self._api_secret = secret 45 | self._connection = http.client.HTTPSConnection( 46 | host=host, 47 | port=JWPLATFORM_API_PORT 48 | ) 49 | 50 | self._logger = logging.getLogger(self.__class__.__name__) 51 | 52 | self.analytics = _AnalyticsClient(self) 53 | self.advertising = _AdvertisingClient(self) 54 | 55 | self.Import = _ImportClient(self) 56 | self.Channel = _ChannelClient(self) 57 | self.Media = _MediaClient(self) 58 | self.WebhookClient = _WebhookClient(self) 59 | self.MediaProtectionRule = _MediaProtectionRuleClient(self) 60 | self.Player = _PlayerClient(self) 61 | self.Playlist = _PlaylistClient(self) 62 | self.Site = _SiteClient(self) 63 | self.Thumbnail = _ThumbnailClient(self) 64 | 65 | def raw_request(self, method, url, body=None, headers=None): 66 | """ 67 | Exposes http.client.HTTPSConnection.request without modifying the request. 68 | 69 | Either returns an APIResponse or raises an APIError. 70 | """ 71 | if headers is None: 72 | headers = {} 73 | 74 | self._connection.request(method, url, body, headers) 75 | response = self._connection.getresponse() 76 | 77 | if 200 <= response.status <= 299: 78 | return APIResponse(response) 79 | 80 | raise APIError.from_response(response) 81 | 82 | def request(self, method, path, body=None, headers=None, query_params=None): 83 | """ 84 | Sends a request using the client's configuration. 85 | 86 | Args: 87 | method (str): HTTP request method 88 | path (str): Resource or endpoint to request 89 | body (dict): Contents of the request body that will be converted to JSON 90 | headers (dict): Any additional HTTP headers 91 | query_params (dict): Any additional query parameters to add to the URI 92 | """ 93 | if headers is None: 94 | headers = {} 95 | 96 | if "User-Agent" not in headers: 97 | headers["User-Agent"] = USER_AGENT 98 | if "Authorization" not in headers and self._api_secret is not None: 99 | headers["Authorization"] = f"Bearer {self._api_secret}" 100 | if "Content-Type" not in headers: 101 | headers["Content-Type"] = "application/json" 102 | 103 | if body is not None: 104 | body = json.dumps(body) 105 | if query_params is not None: 106 | path += "?" + urllib.parse.urlencode(query_params) 107 | 108 | return self.raw_request(method=method, url=path, body=body, headers=headers) 109 | 110 | def request_with_retry(self, method, path, body=None, headers=None, query_params=None, 111 | retry_attempts=3): 112 | """ 113 | Sends a request using the client's configuration. 114 | 115 | Args: 116 | method (str): HTTP request method 117 | path (str): Resource or endpoint to request 118 | body (dict): Contents of the request body that will be converted to JSON 119 | headers (dict): Any additional HTTP headers 120 | query_params (dict): Any additional query parameters to add to the URI 121 | retry_attempts: The number of retry attempts that should be made for the request. 122 | """ 123 | retry_count = 0 124 | for _ in range(retry_attempts): 125 | try: 126 | response = self.request(method, path, body=body, headers=headers, query_params=query_params) 127 | return response 128 | except StrictHTTPErrors as http_error: 129 | self._logger.warning(http_error, exc_info=True) 130 | retry_count = retry_count + 1 131 | if retry_count >= retry_attempts: 132 | self._logger.error(f"Exceeded maximum number of retries {retry_attempts}" 133 | f"while connecting to the host.") 134 | raise 135 | 136 | def query_usage(self, body=None, query_params=None): 137 | return self._client.request( 138 | method="PUT", 139 | path=f"/v2/query_usage/", 140 | body=body, 141 | query_params=query_params 142 | ) 143 | 144 | 145 | class _ScopedClient: 146 | 147 | def __init__(self, client: JWPlatformClient): 148 | self._client = client 149 | 150 | 151 | class _ResourceClient(_ScopedClient): 152 | _resource_name = None 153 | _id_name = None 154 | _collection_path = "/v2/{resource_name}/" 155 | _singular_path = "/v2/{resource_name}/{resource_id}/" 156 | 157 | def list(self, site_id, query_params=None): 158 | response = self._client.request( 159 | method="GET", 160 | path=self._collection_path.format(site_id=site_id, resource_name=self._resource_name), 161 | query_params=query_params 162 | ) 163 | return ResourcesResponse.from_client(response, self._resource_name, self.__class__) 164 | 165 | def create(self, site_id, body=None, query_params=None): 166 | response = self._client.request( 167 | method="POST", 168 | path=self._collection_path.format(site_id=site_id, resource_name=self._resource_name), 169 | body=body, 170 | query_params=query_params 171 | ) 172 | return ResourceResponse.from_client(response, self.__class__) 173 | 174 | def get(self, site_id, query_params=None, **kwargs): 175 | resource_id = kwargs[self._id_name] 176 | response = self._client.request( 177 | method="GET", 178 | path=self._singular_path.format(site_id=site_id, resource_name=self._resource_name, 179 | resource_id=resource_id), 180 | query_params=query_params 181 | ) 182 | return ResourceResponse.from_client(response, self.__class__) 183 | 184 | def update(self, site_id, body, query_params=None, **kwargs): 185 | resource_id = kwargs[self._id_name] 186 | response = self._client.request( 187 | method="PATCH", 188 | path=self._singular_path.format(site_id=site_id, resource_name=self._resource_name, 189 | resource_id=resource_id), 190 | body=body, 191 | query_params=query_params 192 | ) 193 | return ResourceResponse.from_client(response, self.__class__) 194 | 195 | def delete(self, site_id, query_params=None, **kwargs): 196 | resource_id = kwargs[self._id_name] 197 | return self._client.request( 198 | method="DELETE", 199 | path=self._singular_path.format(site_id=site_id, resource_name=self._resource_name, 200 | resource_id=resource_id), 201 | query_params=query_params 202 | ) 203 | 204 | 205 | class _SiteResourceClient(_ResourceClient): 206 | _collection_path = "/v2/sites/{site_id}/{resource_name}/" 207 | _singular_path = "/v2/sites/{site_id}/{resource_name}/{resource_id}/" 208 | 209 | 210 | class _AnalyticsClient(_ScopedClient): 211 | 212 | def query(self, site_id, body, query_params=None): 213 | return self._client.request( 214 | method="POST", 215 | path=f"/v2/sites/{site_id}/analytics/queries/", 216 | body=body, 217 | query_params=query_params 218 | ) 219 | 220 | 221 | class _ImportClient(_SiteResourceClient): 222 | _resource_name = "imports" 223 | _id_name = "import_id" 224 | 225 | 226 | class _ChannelClient(_SiteResourceClient): 227 | _resource_name = "channels" 228 | _id_name = "channel_id" 229 | 230 | def __init__(self, client): 231 | super().__init__(client) 232 | self.Event = _ChannelEventClient(client) 233 | 234 | 235 | class _ChannelEventClient(_ScopedClient): 236 | 237 | def list(self, site_id, channel_id, query_params=None): 238 | response = self._client.request( 239 | method="GET", 240 | path=f"/v2/sites/{site_id}/channels/{channel_id}/events/", 241 | query_params=query_params 242 | ) 243 | return ResourcesResponse.from_client(response, "events", self.__class__) 244 | 245 | def get(self, site_id, channel_id, event_id, query_params=None): 246 | response = self._client.request( 247 | method="GET", 248 | path=f"/v2/sites/{site_id}/channels/{channel_id}/events/{event_id}/", 249 | query_params=query_params 250 | ) 251 | return ResourceResponse.from_client(response, self.__class__) 252 | 253 | def request_master(self, site_id, channel_id, event_id, query_params=None): 254 | return self._client.request( 255 | method="PUT", 256 | path=f"/v2/sites/{site_id}/channels/{channel_id}/events/{event_id}/request_master/", 257 | query_params=query_params 258 | ) 259 | 260 | def clip(self, site_id, channel_id, event_id, body=None, query_params=None): 261 | return self._client.request( 262 | method="PUT", 263 | path=f"/v2/sites/{site_id}/channels/{channel_id}/events/{event_id}/clip/", 264 | body=None, 265 | query_params=query_params 266 | ) 267 | 268 | 269 | class _MediaRenditionClient(_ScopedClient): 270 | 271 | def list(self, site_id, media_id, query_params=None): 272 | response = self._client.request( 273 | method="GET", 274 | path=f"/v2/sites/{site_id}/media/{media_id}/media_renditions/", 275 | query_params=query_params 276 | ) 277 | return ResourcesResponse.from_client(response, "media_renditions", self.__class__) 278 | 279 | def create(self, site_id, media_id, body=None, query_params=None): 280 | response = self._client.request( 281 | method="POST", 282 | path=f"/v2/sites/{site_id}/media/{media_id}/media_renditions/", 283 | body=body, 284 | query_params=query_params 285 | ) 286 | return ResourceResponse.from_client(response, self.__class__) 287 | 288 | def get(self, site_id, media_id, rendition_id, query_params=None): 289 | response = self._client.request( 290 | method="GET", 291 | path=f"/v2/sites/{site_id}/media/{media_id}/media_renditions/{rendition_id}/", 292 | query_params=query_params 293 | ) 294 | return ResourceResponse.from_client(response, self.__class__) 295 | 296 | def delete(self, site_id, media_id, rendition_id, query_params=None): 297 | return self._client.request( 298 | method="DELETE", 299 | path=f"/v2/sites/{site_id}/media/{media_id}/media_renditions/{rendition_id}/", 300 | query_params=query_params 301 | ) 302 | 303 | 304 | class _OriginalClient(_ScopedClient): 305 | 306 | def list(self, site_id, media_id, query_params=None): 307 | response = self._client.request( 308 | method="GET", 309 | path=f"/v2/sites/{site_id}/media/{media_id}/originals/", 310 | query_params=query_params 311 | ) 312 | return ResourcesResponse.from_client(response, "originals", self.__class__) 313 | 314 | def create(self, site_id, media_id, body=None, query_params=None): 315 | response = self._client.request( 316 | method="POST", 317 | path=f"/v2/sites/{site_id}/media/{media_id}/originals/", 318 | body=body, 319 | query_params=query_params 320 | ) 321 | return ResourceResponse.from_client(response, self.__class__) 322 | 323 | def get(self, site_id, media_id, original_id, query_params=None): 324 | response = self._client.request( 325 | method="GET", 326 | path=f"/v2/sites/{site_id}/media/{media_id}/originals/{original_id}/", 327 | query_params=query_params 328 | ) 329 | return ResourceResponse.from_client(response, self.__class__) 330 | 331 | def update(self, site_id, media_id, original_id, body, query_params=None): 332 | response = self._client.request( 333 | method="PATCH", 334 | path=f"/v2/sites/{site_id}/media/{media_id}/originals/{original_id}/", 335 | body=body, 336 | query_params=query_params 337 | ) 338 | return ResourceResponse.from_client(response, self.__class__) 339 | 340 | def delete(self, site_id, media_id, original_id, query_params=None): 341 | return self._client.request( 342 | method="DELETE", 343 | path=f"/v2/sites/{site_id}/media/{media_id}/originals/{original_id}/", 344 | query_params=query_params 345 | ) 346 | 347 | 348 | class _TextTrackClient(_ScopedClient): 349 | 350 | def list(self, site_id, media_id, query_params=None): 351 | response = self._client.request( 352 | method="GET", 353 | path=f"/v2/sites/{site_id}/media/{media_id}/text_tracks/", 354 | query_params=query_params 355 | ) 356 | return ResourcesResponse.from_client(response, "text_tracks", self.__class__) 357 | 358 | def create(self, site_id, media_id, body=None, query_params=None): 359 | response = self._client.request( 360 | method="POST", 361 | path=f"/v2/sites/{site_id}/media/{media_id}/text_tracks/", 362 | body=body, 363 | query_params=query_params 364 | ) 365 | return ResourceResponse.from_client(response, self.__class__) 366 | 367 | def get(self, site_id, media_id, track_id, query_params=None): 368 | response = self._client.request( 369 | method="GET", 370 | path=f"/v2/sites/{site_id}/media/{media_id}/text_tracks/{track_id}/", 371 | query_params=query_params 372 | ) 373 | return ResourceResponse.from_client(response, self.__class__) 374 | 375 | def update(self, site_id, media_id, track_id, body, query_params=None): 376 | response = self._client.request( 377 | method="PATCH", 378 | path=f"/v2/sites/{site_id}/media/{media_id}/text_tracks/{track_id}/", 379 | body=body, 380 | query_params=query_params 381 | ) 382 | return ResourceResponse.from_client(response, self.__class__) 383 | 384 | def delete(self, site_id, media_id, track_id, query_params=None): 385 | return self._client.request( 386 | method="DELETE", 387 | path=f"/v2/sites/{site_id}/media/{media_id}/text_tracks/{track_id}/", 388 | query_params=query_params 389 | ) 390 | 391 | def publish(self, site_id, media_id, track_id, body=None, query_params=None): 392 | response = self._client.request( 393 | method="PUT", 394 | path=f"/v2/sites/{site_id}/media/{media_id}/text_tracks/{track_id}/publish/", 395 | body=body, 396 | query_params=query_params 397 | ) 398 | return ResourceResponse.from_client(response, self.__class__) 399 | 400 | def unpublish(self, site_id, media_id, track_id, body=None, query_params=None): 401 | response = self._client.request( 402 | method="PUT", 403 | path=f"/v2/sites/{site_id}/media/{media_id}/text_tracks/{track_id}/unpublish/", 404 | body=body, 405 | query_params=query_params 406 | ) 407 | return ResourceResponse.from_client(response, self.__class__) 408 | 409 | 410 | CREATE_MEDIA_PAYLOAD = { 411 | "upload": { 412 | }, 413 | "metadata": { 414 | 415 | } 416 | } 417 | 418 | 419 | class _MediaClient(_SiteResourceClient): 420 | _resource_name = "media" 421 | _id_name = "media_id" 422 | _logger = logging.getLogger(__name__) 423 | 424 | def __init__(self, client): 425 | super().__init__(client) 426 | self.MediaRendition = _MediaRenditionClient(client) 427 | self.Original = _OriginalClient(client) 428 | self.TextTrack = _TextTrackClient(client) 429 | 430 | def reupload(self, site_id, body, query_params=None, **kwargs): 431 | resource_id = kwargs[self._id_name] 432 | return self._client.request( 433 | method="PUT", 434 | path=self._singular_path.format(site_id=site_id, resource_name=self._resource_name, 435 | resource_id=resource_id) + "reupload/", 436 | body=body, 437 | query_params=query_params 438 | ) 439 | 440 | def _determine_upload_method(self, file, target_part_size) -> str: 441 | file_size = os.stat(file.name).st_size 442 | if file_size > MAX_FILE_SIZE: 443 | raise NotImplementedError('File size greater than 25 GB is not supported.') 444 | if file_size <= target_part_size: 445 | return UploadType.direct.value 446 | return UploadType.multipart.value 447 | 448 | def create_media_and_get_upload_context(self, file, body=None, query_params=None, **kwargs) -> UploadContext: 449 | """ 450 | Creates the media and retrieve the upload context 451 | Args: 452 | file: The file-like object to the actual media file to be uploaded 453 | body: The body of the payload. 454 | query_params: The query parameters. 455 | **kwargs: The upload arguments. 456 | 457 | Returns: The UploadContext that can be reused to resuming an upload. 458 | 459 | """ 460 | if not kwargs: 461 | kwargs = {} 462 | site_id = kwargs['site_id'] 463 | # Determine the upload type - Single or multi-part 464 | target_part_size = int(kwargs.get('target_part_size', MIN_PART_SIZE)) 465 | upload_method = self._determine_upload_method(file, target_part_size) 466 | if not body: 467 | body = CREATE_MEDIA_PAYLOAD.copy() 468 | 469 | if 'upload' not in body: 470 | body['upload'] = {} 471 | 472 | if not isinstance(body['upload'], dict): 473 | raise ValueError("Invalid payload structure. The upload element needs to be dictionary.") 474 | 475 | body["upload"]["method"] = upload_method 476 | 477 | # Create the media 478 | resp = self.create(site_id, body, query_params) 479 | 480 | result = resp.json_body 481 | upload_id = result.get("upload_id") 482 | upload_token = result.get("upload_token") 483 | direct_link = result.get("upload_link") 484 | 485 | upload_context = UploadContext(upload_method, upload_id, upload_token, direct_link) 486 | return upload_context 487 | 488 | def upload(self, file, upload_context: UploadContext, **kwargs) -> None: 489 | """ 490 | Uploads the media file. 491 | Args: 492 | file: The file-like object to the actual media file to be uploaded 493 | upload_context: The query parameters. 494 | **kwargs: The upload parameters. 495 | 496 | Returns: None 497 | 498 | """ 499 | upload_handler = self._get_upload_handler_for_upload_type(upload_context, file, **kwargs) 500 | try: 501 | upload_handler.upload() 502 | except Exception: 503 | file.seek(0, 0) 504 | raise 505 | 506 | def resume(self, file, upload_context: UploadContext, **kwargs) -> None: 507 | """ 508 | Resumes the upload of the media file. 509 | Args: 510 | file: The file-like object to the actual media file to be resumed 511 | upload_context: The query parameters. 512 | **kwargs: The upload parameters. 513 | 514 | Returns: None 515 | """ 516 | if not upload_context: 517 | raise ValueError("The provided context is None. Cannot resume the upload.") 518 | if not upload_context.can_resume(): 519 | upload_context = self.create_media_and_get_upload_context(file, **kwargs) 520 | upload_handler = self._get_upload_handler_for_upload_type(upload_context, file, **kwargs) 521 | try: 522 | upload_handler.upload() 523 | except Exception: 524 | file.seek(0, 0) 525 | raise 526 | 527 | def _get_upload_handler_for_upload_type(self, context: UploadContext, file, **kwargs): 528 | upload_method = context.upload_method 529 | base_url = kwargs.get('base_url', JWPLATFORM_API_HOST) 530 | target_part_size = int(kwargs.get('target_part_size', MIN_PART_SIZE)) 531 | retry_count = int(kwargs.get('retry_count', UPLOAD_RETRY_ATTEMPTS)) 532 | 533 | if upload_method == UploadType.direct.value: 534 | direct_link = context.direct_link 535 | upload_handler = SingleUpload(direct_link, file, retry_count, context) 536 | else: 537 | upload_token = context.upload_token 538 | upload_client = _UploadClient(api_secret=upload_token, base_url=base_url) 539 | upload_handler = MultipartUpload(upload_client, file, target_part_size, 540 | retry_count, context) 541 | return upload_handler 542 | 543 | 544 | class _UploadClient(_ScopedClient): 545 | _collection_path = "/v2/uploads/{resource_id}" 546 | 547 | def __init__(self, api_secret, base_url): 548 | if base_url is None: 549 | base_url = JWPLATFORM_API_HOST 550 | client = JWPlatformClient(secret=api_secret, host=base_url) 551 | super().__init__(client) 552 | 553 | def list(self, upload_id, query_params=None): 554 | """ 555 | Lists the parts for a given multi-part upload. 556 | Args: 557 | upload_id: The upload ID for the upload 558 | query_params: The query parameters. 559 | 560 | Returns: None 561 | 562 | """ 563 | resource_path = self._collection_path.format(resource_id=upload_id) 564 | resource_path = f"{resource_path}/parts" 565 | response = self._client.request_with_retry(method="GET", path=resource_path, query_params=query_params) 566 | return ResourcesResponse.from_client(response, 'parts', self.__class__) 567 | 568 | def complete(self, upload_id, body=None) -> None: 569 | """ 570 | Marks the upload as complete. 571 | Args: 572 | upload_id: The upload ID for the upload 573 | body: [Optional] - The body of the payload. 574 | 575 | Returns: List of parts with their upload metadata in a JSON format. 576 | 577 | """ 578 | resource_path = self._collection_path.format(resource_id=upload_id) 579 | resource_path = f"{resource_path}/complete" 580 | self._client.request_with_retry(method="PUT", path=resource_path, body=body) 581 | 582 | 583 | class _WebhookClient(_ResourceClient): 584 | _resource_name = "webhooks" 585 | _id_name = "webhook_id" 586 | 587 | 588 | class _VpbConfigClient(_ResourceClient): 589 | _resource_name = "vpb_configs" 590 | _id_name = "config_id" 591 | _collection_path = "/v2/sites/{site_id}/advertising/{resource_name}/" 592 | _singular_path = "/v2/sites/{site_id}/advertising/{resource_name}/{resource_id}/" 593 | 594 | 595 | class _PlayerBiddingConfigClient(_ResourceClient): 596 | _resource_name = "player_bidding_configs" 597 | _id_name = "config_id" 598 | _collection_path = "/v2/sites/{site_id}/advertising/{resource_name}/" 599 | _singular_path = "/v2/sites/{site_id}/advertising/{resource_name}/{resource_id}/" 600 | 601 | 602 | class _ScheduleClient(_ResourceClient): 603 | _resource_name = "schedules" 604 | _id_name = "ad_schedule_id" 605 | _collection_path = "/v2/sites/{site_id}/advertising/{resource_name}/" 606 | _singular_path = "/v2/sites/{site_id}/advertising/{resource_name}/{resource_id}/" 607 | 608 | 609 | class _AdvertisingClient(_ScopedClient): 610 | 611 | def __init__(self, client): 612 | super().__init__(client) 613 | self.VpbConfig = _VpbConfigClient(client) 614 | self.PlayerBiddingConfig = _PlayerBiddingConfigClient(client) 615 | self.Schedule = _ScheduleClient(client) 616 | 617 | def update_schedules_vpb_config(self, site_id, body, query_params=None): 618 | return self._client.request( 619 | method="PUT", 620 | path=f"/v2/sites/{site_id}/advertising/update_schedules_vpb_config/", 621 | body=body, 622 | query_params=query_params 623 | ) 624 | 625 | def update_schedules_player_bidding_configs(self, site_id, body, query_params=None): 626 | return self._client.request( 627 | method="PUT", 628 | path=f"/v2/sites/{site_id}/advertising/update_schedules_player_bidding_configs/", 629 | body=body, 630 | query_params=query_params 631 | ) 632 | 633 | 634 | class _MediaProtectionRuleClient(_ResourceClient): 635 | _resource_name = "media_protection_rules" 636 | _id_name = "protection_rule_id" 637 | 638 | 639 | class _PlayerClient(_ResourceClient): 640 | _resource_name = "players" 641 | _id_name = "player_id" 642 | 643 | 644 | class _ManualPlaylistClient(_ResourceClient): 645 | _resource_name = "manual_playlist" 646 | _id_name = "playlist_id" 647 | _collection_path = "/v2/sites/{site_id}/playlists/{resource_name}/" 648 | _singular_path = "/v2/sites/{site_id}/playlists/{resource_id}/{resource_name}/" 649 | 650 | 651 | class _DynamicPlaylistClient(_ResourceClient): 652 | _resource_name = "dynamic_playlist" 653 | _id_name = "playlist_id" 654 | _collection_path = "/v2/sites/{site_id}/playlists/{resource_name}/" 655 | _singular_path = "/v2/sites/{site_id}/playlists/{resource_id}/{resource_name}/" 656 | 657 | 658 | class _TrendingPlaylistClient(_ResourceClient): 659 | _resource_name = "trending_playlist" 660 | _id_name = "playlist_id" 661 | _collection_path = "/v2/sites/{site_id}/playlists/{resource_name}/" 662 | _singular_path = "/v2/sites/{site_id}/playlists/{resource_id}/{resource_name}/" 663 | 664 | 665 | class _ArticleMatchingPlaylistClient(_ResourceClient): 666 | _resource_name = "article_matching_playlist" 667 | _id_name = "playlist_id" 668 | _collection_path = "/v2/sites/{site_id}/playlists/{resource_name}/" 669 | _singular_path = "/v2/sites/{site_id}/playlists/{resource_id}/{resource_name}/" 670 | 671 | 672 | class _SearchPlaylistClient(_ResourceClient): 673 | _resource_name = "search_playlist" 674 | _id_name = "playlist_id" 675 | _collection_path = "/v2/sites/{site_id}/playlists/{resource_name}/" 676 | _singular_path = "/v2/sites/{site_id}/playlists/{resource_id}/{resource_name}/" 677 | 678 | 679 | class _RecommendationsPlaylistClient(_ResourceClient): 680 | _resource_name = "recommendations_playlist" 681 | _id_name = "playlist_id" 682 | _collection_path = "/v2/sites/{site_id}/playlists/{resource_name}/" 683 | _singular_path = "/v2/sites/{site_id}/playlists/{resource_id}/{resource_name}/" 684 | 685 | 686 | class _WatchlistPlaylistClient(_ResourceClient): 687 | _resource_name = "watchlist_playlist" 688 | _id_name = "playlist_id" 689 | _collection_path = "/v2/sites/{site_id}/playlists/{resource_name}/" 690 | _singular_path = "/v2/sites/{site_id}/playlists/{resource_id}/{resource_name}/" 691 | 692 | 693 | class _PlaylistClient(_ResourceClient): 694 | _resource_name = "playlists" 695 | _id_name = "playlist_id" 696 | 697 | def __init__(self, client): 698 | super().__init__(client) 699 | self.ManualPlaylist = _ManualPlaylistClient(client) 700 | self.DynamicPlaylist = _DynamicPlaylistClient(client) 701 | self.TrendingPlaylist = _TrendingPlaylistClient(client) 702 | self.ArticleMatchingPlaylist = _ArticleMatchingPlaylistClient(client) 703 | self.SearchPlaylist = _SearchPlaylistClient(client) 704 | self.RecommendationsPlaylist = _RecommendationsPlaylistClient(client) 705 | self.WatchlistPlaylist = _WatchlistPlaylistClient(client) 706 | 707 | 708 | class _SiteProtectionRuleClient(_ScopedClient): 709 | 710 | def get(self, site_id, query_params=None): 711 | response = self._client.request( 712 | method="GET", 713 | path=f"/v2/sites/{site_id}/site_protection_rule/", 714 | query_params=query_params 715 | ) 716 | return ResourceResponse.from_client(response, self.__class__) 717 | 718 | def update(self, site_id, body, query_params=None, **kwargs): 719 | response = self._client.request( 720 | method="PATCH", 721 | path=f"/v2/sites/{site_id}/site_protection_rule/", 722 | body=body, 723 | query_params=query_params 724 | ) 725 | return ResourceResponse.from_client(response, self.__class__) 726 | 727 | 728 | class _SiteClient(_ScopedClient): 729 | 730 | def __init__(self, client): 731 | super().__init__(client) 732 | self.SiteProtectionRule = _SiteProtectionRuleClient(client) 733 | 734 | def remove_tag(self, site_id, body=None, query_params=None): 735 | return self._client.request( 736 | method="PUT", 737 | path=f"/v2/sites/{site_id}/remove_tag/", 738 | body=body, 739 | query_params=query_params 740 | ) 741 | 742 | def rename_tag(self, site_id, body=None, query_params=None): 743 | return self._client.request( 744 | method="PUT", 745 | path=f"/v2/sites/{site_id}/rename_tag/", 746 | body=body, 747 | query_params=query_params 748 | ) 749 | 750 | def query_usage(self, site_id, body=None, query_params=None): 751 | return self._client.request( 752 | method="PUT", 753 | path=f"/v2/sites/{site_id}/query_usage/", 754 | body=body, 755 | query_params=query_params 756 | ) 757 | 758 | 759 | class _ThumbnailClient(_SiteResourceClient): 760 | _resource_name = "thumbnails" 761 | _id_name = "thumbnail_id" 762 | --------------------------------------------------------------------------------