├── .gitignore ├── LICENSE ├── README.md ├── requirements.txt ├── setup.cfg ├── setup.py └── sidefx_web └── __init__.py /.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 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Thanh Ha 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # sidefx-web 2 | Simple CLI tool for using the SideFX Web API. 3 | 4 | ## Requirements 5 | 6 | * Python 3 7 | 8 | ## Install 9 | 10 | ```bash 11 | pip install sidefx-web 12 | sidefx-web --setup 13 | ``` 14 | 15 | ## Example Usages 16 | 17 | ### List houdini versions 18 | 19 | ```bash 20 | sidefx-web list-builds -h # Print help message 21 | sidefx-web list-builds houdini 22 | sidefx-web list-builds houdini-qt4 --only-production # Filter only production builds 23 | sidefx-web list-builds houdini --version 16.5 # Filter version e.g. 16.5, 17.0 24 | sidefx-web list-builds houdini --platform linux # Filter platform: linux, macos, win64 25 | ``` 26 | 27 | ### Download houdini 28 | ```bash 29 | sidefx-web download -h # Print help message 30 | sidefx-web download houdini 16.5 496 linux # Download houdini 16.5 build 496 for linux 31 | ``` 32 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests~=2.21.0 2 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = sidefx-web 3 | author = Thanh Ha 4 | author-email = zxiiro@linux.com 5 | summary = Simple tool for using the SideFX Web API 6 | description-file = README.md 7 | description-content-type = text/markdown; charset=UTF-8 8 | home-page = https://github.com/zxiiro/sidefx-web-cli 9 | project_urls = 10 | Bug Tracker = https://github.com/zxiiro/sidefx-web-cli/issues 11 | Source Code = https://github.com/zxiiro/sidefx-web-cli 12 | license = MIT 13 | classifier = 14 | Intended Audience :: Developers 15 | Intended Audience :: Information Technology 16 | License :: OSI Approved :: MIT License 17 | Operating System :: POSIX :: Linux 18 | Programming Language :: Python 19 | Programming Language :: Python :: 3.5 20 | Programming Language :: Python :: 3.6 21 | Programming Language :: Python :: 3.7 22 | 23 | [wheel] 24 | universal = 1 25 | 26 | [files] 27 | package = sidefx_web 28 | 29 | [entry_points] 30 | console_scripts = 31 | sidefx-web = sidefx_web:cli 32 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # SPDX-License-Identifier: MIT 3 | ############################################################################## 4 | # MIT License 5 | # 6 | # Copyright (c) 2019 Thanh Ha 7 | # 8 | # Permission is hereby granted, free of charge, to any person obtaining a copy 9 | # of this software and associated documentation files (the "Software"), to deal 10 | # in the Software without restriction, including without limitation the rights 11 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 12 | # copies of the Software, and to permit persons to whom the Software is 13 | # furnished to do so, subject to the following conditions: 14 | # 15 | # The above copyright notice and this permission notice shall be included in all 16 | # copies or substantial portions of the Software. 17 | # 18 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 21 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 24 | # SOFTWARE. 25 | ############################################################################## 26 | """Setup.py.""" 27 | 28 | from setuptools import find_packages 29 | from setuptools import setup 30 | 31 | 32 | with open('requirements.txt') as f: 33 | install_reqs = f.read().splitlines() 34 | 35 | setup( 36 | setup_requires=['pbr'], 37 | pbr=True, 38 | install_requires=install_reqs, 39 | packages=find_packages(exclude=[ 40 | '*.tests', 41 | '*.tests.*', 42 | 'tests.*', 43 | 'tests' 44 | ]), 45 | ) 46 | -------------------------------------------------------------------------------- /sidefx_web/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # SPDX-License-Identifier: MIT 3 | ############################################################################## 4 | # MIT License 5 | # 6 | # Copyright (c) 2019 Thanh Ha 7 | # 8 | # Permission is hereby granted, free of charge, to any person obtaining a copy 9 | # of this software and associated documentation files (the "Software"), to deal 10 | # in the Software without restriction, including without limitation the rights 11 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 12 | # copies of the Software, and to permit persons to whom the Software is 13 | # furnished to do so, subject to the following conditions: 14 | # 15 | # The above copyright notice and this permission notice shall be included in all 16 | # copies or substantial portions of the Software. 17 | # 18 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 21 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 24 | # SOFTWARE. 25 | ############################################################################## 26 | """CLI for SideFX Web API.""" 27 | import argparse 28 | import base64 29 | import configparser 30 | import json 31 | import logging 32 | import os 33 | import pathlib 34 | import sys 35 | import time 36 | import urllib 37 | 38 | import requests 39 | 40 | CONFIG_DIR = '/'.join([str(pathlib.Path.home()), '.config', 'sidefx-web']) 41 | CONFIG_FILE = '/'.join([CONFIG_DIR, 'config.ini']) 42 | 43 | 44 | def cli(): 45 | parser = argparse.ArgumentParser() 46 | parser.add_argument( 47 | '--access-token-url', type=str, 48 | default='https://www.sidefx.com/oauth2/application_token', 49 | help='URL for the SideFX OAuth application token.') 50 | parser.add_argument( 51 | '--endpoint-url', type=str, default='https://www.sidefx.com/api/', 52 | help='URL for the SideFX Web API endpoint.') 53 | parser.add_argument('--debug', action='store_true', 54 | help='Enable DEBUG output.') 55 | parser.add_argument('--setup', '-s', action='store_true', 56 | help='Setup configuration for SideFX Web API.') 57 | subparsers = parser.add_subparsers(dest='cmd') 58 | 59 | download_parser = subparsers.add_parser( 60 | 'download', help='Download a SideFX product.') 61 | download_parser.add_argument( 62 | 'product', type=str, choices=['houdini', 'houdini-qt4'], 63 | help='Product to list: houdini, houdini-qt4') 64 | download_parser.add_argument( 65 | 'version', type=str, 66 | help='The major version of Houdini. e.g. 16.5, 17.0.') 67 | download_parser.add_argument( 68 | 'build', type=str, 69 | help=('Either a specific build number, e.g. 382, or the string ' 70 | '"production" to get the latest production build')) 71 | download_parser.add_argument( 72 | 'platform', type=str, choices=['win64', 'macos', 'linux'], 73 | help='The operating system to install Houdini on: win64, macos, linux') 74 | download_parser.set_defaults(func='download') 75 | 76 | list_builds_parser = subparsers.add_parser( 77 | 'list-builds', help='List SideFX products available for download.') 78 | list_builds_parser.add_argument( 79 | 'product', type=str, choices=['houdini', 'houdini-qt4'], 80 | help='Product to list: houdini, houdini-qt4') 81 | list_builds_parser.add_argument( 82 | '--version', type=str, 83 | help='The major version of Houdini. e.g. 16.5, 17.0.') 84 | list_builds_parser.add_argument( 85 | '--platform', type=str, choices=['win64', 'macos', 'linux'], 86 | help='The operating system to install Houdini on: win64, macos, linux') 87 | list_builds_parser.add_argument( 88 | '--only-production', action='store_true', 89 | help='Only return the production builds.') 90 | list_builds_parser.set_defaults(func='list_builds') 91 | 92 | args = parser.parse_args() 93 | 94 | if args.debug: 95 | logging.getLogger("").setLevel(logging.DEBUG) 96 | 97 | if args.setup: 98 | setup() 99 | 100 | cfg = get_config() 101 | client_id = cfg.get('Auth', 'client_id') 102 | client_secret_key = cfg.get('Auth', 'client_secret_key') 103 | token = cfg.get('Cache', 'access_token', fallback=None) 104 | token_expiry = cfg.get('Cache', 'access_token_expiry', fallback=None) 105 | if token_expiry: 106 | token_expiry = float(token_expiry) 107 | 108 | log.debug('Access Token URL: {}'.format(args.access_token_url)) 109 | log.debug('Client ID: {}'.format(client_id)) 110 | log.debug('Client Secret Key: ******{}'.format(client_secret_key[-6:])) 111 | log.debug('Cached Access Token: {}'.format(token)) 112 | log.debug('Cached Access Token Expiry: {}'.format(token_expiry)) 113 | 114 | if (token is None or token_expiry is None or token_expiry < time.time()): 115 | log.info('Fetching a new token.') 116 | token, token_expiry = get_access_token( 117 | args.access_token_url, client_id, client_secret_key) 118 | 119 | if not cfg.has_section('Cache'): 120 | cfg.add_section('Cache') 121 | cfg.set('Cache', 'access_token', token) 122 | cfg.set('Cache', 'access_token_expiry', str(token_expiry)) 123 | save_config(cfg) 124 | 125 | log.debug('Access Token: {}'.format(token)) 126 | log.debug('Access Token Expiry Time: {}'.format(token_expiry)) 127 | 128 | if hasattr(args, 'func'): 129 | if args.func == 'list_builds': 130 | list_builds(args.endpoint_url, token, args.product, 131 | version=args.version, 132 | platform=args.platform, 133 | only_production=args.only_production) 134 | elif args.func == 'download': 135 | download(args.endpoint_url, token, 136 | args.product, args.version, args.build, args.platform) 137 | 138 | 139 | def download(endpoint_url, token, 140 | product, 141 | version, 142 | build, 143 | platform): 144 | resp = call_api(endpoint_url, token, 'download.get_daily_build_download', 145 | product, version, build, platform) 146 | log.debug(resp) 147 | download_url = resp.get('download_url') 148 | filename = resp.get('filename') 149 | log.info('Downloading {}'.format(filename)) 150 | urllib.request.urlretrieve(download_url, filename) 151 | 152 | 153 | def list_builds(endpoint_url, token, product, 154 | version=None, 155 | platform=None, 156 | only_production=None): 157 | resp = call_api(endpoint_url, token, 'download.get_daily_builds_list', 158 | product, version, platform, only_production) 159 | for i in resp: 160 | log.info(i) 161 | 162 | 163 | ############ 164 | # Requests # 165 | ############ 166 | 167 | def call_api(endpoint_url, access_token, function_name, *args, **kwargs): 168 | """Call into the Web API.""" 169 | response = requests.post( 170 | endpoint_url, 171 | headers={ 172 | "Authorization": "Bearer " + access_token, 173 | }, 174 | data=dict( 175 | json=json.dumps([function_name, args, kwargs]), 176 | )) 177 | if response.status_code == 200: 178 | return response.json() 179 | log.debug(response.status_code, response.reason, response.text) 180 | 181 | 182 | def get_access_token(url, client_id, client_secret_key): 183 | auth = base64.b64encode("{}:{}".format( 184 | client_id, client_secret_key).encode() 185 | ).decode('utf-8') 186 | headers = { 187 | 'Authorization': 'Basic {0}'.format(auth), 188 | } 189 | req = requests.post(url, headers=headers) 190 | 191 | if req.status_code != 200: 192 | print('ERROR: {} {}'.format(req.status_code, req.reason)) 193 | sys.exit(1) 194 | 195 | data = req.json() 196 | expiry_time = time.time() - 2 + data['expires_in'] 197 | return data['access_token'], expiry_time 198 | 199 | 200 | ################# 201 | # Configuration # 202 | ################# 203 | 204 | def get_config(): 205 | cfg = configparser.ConfigParser() 206 | try: 207 | with open(CONFIG_FILE, 'r') as f: 208 | cfg.read(CONFIG_FILE) 209 | except FileNotFoundError: 210 | setup() 211 | cfg.read(CONFIG_FILE) 212 | return cfg 213 | 214 | 215 | def save_config(cfg): 216 | cfgfile = pathlib.Path(CONFIG_FILE) 217 | with open(CONFIG_FILE, 'w') as f: 218 | cfg.write(f) 219 | os.chmod(CONFIG_FILE, 0o600) 220 | log.debug('Saved config file.') 221 | 222 | 223 | def setup(): 224 | log.info('Credentials are needed in order to use the SideFX Web API. ' 225 | 'Detailed instructions available at ' 226 | 'https://www.sidefx.com/docs/api/credentials/index.html') 227 | client_id = input('Enter your Client ID: ') 228 | client_secret_key = input('Enter your Client Secret Key: ') 229 | log.debug('Set Client ID to {}'.format(client_id)) 230 | log.debug('Set Client Secret Key to {}'.format(client_secret_key)) 231 | 232 | cfg = configparser.ConfigParser() 233 | if not cfg.has_section('Auth'): 234 | cfg.add_section('Auth') 235 | cfg.set('Auth', 'client_id', client_id) 236 | cfg.set('Auth', 'client_secret_key', client_secret_key) 237 | 238 | cfgdir = pathlib.Path(CONFIG_DIR) 239 | cfgdir.mkdir(parents=True, exist_ok=True) 240 | save_config(cfg) 241 | 242 | 243 | class LogFormatter(logging.Formatter): 244 | """Custom log formatter.""" 245 | 246 | default_fmt = logging.Formatter('%(levelname)s: %(message)s') 247 | debug_fmt = logging.Formatter( 248 | '%(levelname)s: %(name)s:%(lineno)d: %(message)s') 249 | info_fmt = logging.Formatter('%(message)s') 250 | 251 | def format(self, record): 252 | """Format log messages depending on log level.""" 253 | if record.levelno == logging.INFO: 254 | return self.info_fmt.format(record) 255 | if record.levelno == logging.DEBUG: 256 | return self.debug_fmt.format(record) 257 | else: 258 | return self.default_fmt.format(record) 259 | 260 | 261 | console_handler = logging.StreamHandler(sys.stdout) 262 | console_handler.setFormatter(LogFormatter()) 263 | logging.getLogger("").setLevel(logging.INFO) 264 | logging.getLogger("").addHandler(console_handler) 265 | log = logging.getLogger(__name__) 266 | --------------------------------------------------------------------------------