├── redashAPI ├── __init__.py └── client.py ├── requirements.txt ├── .gitignore ├── setup.py ├── LICENSE └── README.md /redashAPI/__init__.py: -------------------------------------------------------------------------------- 1 | from .client import RedashAPIClient 2 | 3 | name = "redash-api-client" 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | certifi==2019.9.11 2 | chardet==3.0.4 3 | idna==2.8 4 | requests==2.22.0 5 | urllib3==1.25.6 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | 3 | .venv 4 | venv/ 5 | .vscode/ 6 | 7 | build/ 8 | dist/ 9 | 10 | *.egg-info/ 11 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | with open("README.md", "r") as fh: 4 | long_description = fh.read() 5 | 6 | setuptools.setup( 7 | name="redash-api-client", 8 | version="0.3.0", 9 | author="Damien Zeng", 10 | author_email="damnee562@gmail.com", 11 | description="Redash API Client", 12 | long_description=long_description, 13 | long_description_content_type="text/markdown", 14 | url="https://github.com/damnee562/redash-api-client", 15 | packages=setuptools.find_packages(), 16 | install_requires=['requests'], 17 | classifiers=[ 18 | "Programming Language :: Python :: 3.6", 19 | "License :: OSI Approved :: MIT License", 20 | "Operating System :: OS Independent", 21 | ], 22 | ) 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Damien Zeng 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 | # Redash-API-Client 2 | 3 | [![PyPI version fury.io](https://badge.fury.io/py/redash-api-client.svg)](https://pypi.org/project/redash-api-client/) 4 | [![PyPI pyversions](https://img.shields.io/pypi/pyversions/redash-api-client.svg)](https://pypi.python.org/pypi/redash-api-client/) 5 | [![PyPI license](https://img.shields.io/pypi/l/redash-api-client.svg)](https://pypi.python.org/pypi/redash-api-client/) 6 | [![Downloads](https://pepy.tech/badge/redash-api-client)](https://pepy.tech/project/redash-api-client) 7 | 8 | Redash API Client written in Python. 9 | 10 | ## Dependencies 11 | 12 | * Python3.6+ 13 | 14 | ## Installation 15 | 16 | pip install redash-api-client 17 | 18 | ## Getting Started 19 | 20 | ```python 21 | from redashAPI import RedashAPIClient 22 | 23 | # Create API client instance 24 | """ 25 | :args: 26 | API_KEY 27 | REDASH_HOST (optional): `http://localhost:5000` by default 28 | """ 29 | Redash = RedashAPIClient(API_KEY, REDASH_HOST) 30 | ``` 31 | 32 | ### Redash's RESTful API 33 | 34 | | URI | Supported Methods | 35 | | ------------------ | ----------------------------- | 36 | | *users* | **GET**, **POST** | 37 | | *users/1* | **GET**, **POST** | 38 | | *data_sources* | **GET**, **POST** | 39 | | *data_sources/1* | **GET**, **POST**, **DELETE** | 40 | | *queries* | **GET**, **POST** | 41 | | *queries/1* | **GET**, **POST**, **DELETE** | 42 | | *query_results* | **POST** | 43 | | *query_results/1* | **GET** | 44 | | *visualizations* | **POST** | 45 | | *visualizations/1* | **POST**, **DELETE** | 46 | | *dashboards* | **GET**, **POST** | 47 | | *dashboards/slug* | **GET**, **POST**, **DELETE** | 48 | | *widgets* | **POST** | 49 | | *widgets/1* | **POST**, **DELETE** | 50 | 51 | ```python 52 | ### EXAMPLE ### 53 | 54 | # List all Data Sources 55 | res = Redash.get('data_sources') 56 | res.json() 57 | """ 58 | [ 59 | { 60 | 'name': 'data_source1', 61 | 'pause_reason': None, 62 | 'syntax': 'sql', 63 | 'paused': 0, 64 | 'view_only': False, 65 | 'type': 'pg', 66 | 'id': 1 67 | }, 68 | ... 69 | ] 70 | """ 71 | 72 | # Retrieve specific Data Source 73 | res = Redash.get('data_sources/1') 74 | res.json() 75 | """ 76 | { 77 | "scheduled_queue_name": "scheduled_queries", 78 | "name": "test1", 79 | "pause_reason": "None", 80 | "queue_name": "queries", 81 | "syntax": "sql", 82 | "paused": 0, 83 | "options": { 84 | "password": "--------", 85 | "dbname": "bi", 86 | "user": "" 87 | }, 88 | "groups": { 89 | "1":False 90 | }, 91 | "type": "pg", 92 | "id": 1 93 | } 94 | """ 95 | 96 | # Create New Data Source 97 | Redash.post('data_sources', { 98 | "name": "New Data Source", 99 | "type": "pg", 100 | "options": { 101 | "dbname": DB_NAME, 102 | "host": DB_HOST, 103 | "user": DB_USER, 104 | "passwd": DB_PASSWORD, 105 | "port": DB_PORT 106 | } 107 | }) 108 | """ 109 | { 110 | "scheduled_queue_name": "scheduled_queries", 111 | "name": "New Data Source", 112 | "pause_reason": "None", 113 | "queue_name": "queries", 114 | "syntax": "sql", 115 | "paused": 0, 116 | "options": { 117 | "dbname": DB_NAME, 118 | "host": DB_HOST, 119 | "user": DB_USER, 120 | "passwd": DB_PASSWORD, 121 | "port": DB_PORT 122 | }, 123 | "groups": { 124 | "2": False 125 | }, 126 | "type": "pg", 127 | "id": 2 128 | } 129 | """ 130 | 131 | # Delete specific Data Source 132 | Redash.delete('data_sources/2') 133 | ``` 134 | 135 | ### Create Data Source 136 | 137 | - **_type** 138 | 139 | - Type of Data Source. ([Supported types](https://github.com/getredash/redash/blob/ddb0ef15c1340e7de627e928f80486dfd3d6e1d5/redash/settings/__init__.py#L309-L358)) 140 | 141 | - **name** 142 | 143 | - Name for Data Source. 144 | 145 | - **options** 146 | 147 | - Configuration. 148 | 149 | ```python 150 | ### EXAMPLE ### 151 | 152 | Redash.create_data_source("pg", "First Data Source", { 153 | "dbname": DB_NAME, 154 | "host": DB_HOST, 155 | "user": DB_USER, 156 | "passwd": DB_PASSWORD, 157 | "port": DB_PORT 158 | }) 159 | ``` 160 | 161 | ### Create Query 162 | 163 | - **ds_id** 164 | 165 | - Data Source ID. 166 | 167 | - **name** 168 | 169 | - Name for query. 170 | 171 | - **qry** 172 | 173 | - Query string. 174 | 175 | - **desc (optional)** 176 | 177 | - Description. 178 | 179 | - **with_results (optional)** 180 | 181 | - Generate query results automatically, `True` by default. 182 | 183 | - **options (optional)** 184 | 185 | - Custom options. 186 | 187 | ```python 188 | ### EXAMPLE ### 189 | 190 | Redash.create_query(1, "First Query", "SELECT * FROM table_name;") 191 | ``` 192 | 193 | ### Refresh Query 194 | 195 | - **qry_id** 196 | 197 | - Query ID. 198 | 199 | ```python 200 | ### EXAMPLE ### 201 | 202 | Redash.refresh_query(1) 203 | ``` 204 | 205 | ### Generate Query Result 206 | 207 | - **ds_id** 208 | 209 | - Data Source ID. 210 | 211 | - **qry** 212 | 213 | - Query String. 214 | 215 | - **qry_id (optional)** 216 | 217 | - Query ID. 218 | 219 | - **max_age (optional)** 220 | 221 | - If query results less than *max_age* seconds old are available, 222 | return them, otherwise execute the query; if omitted or -1, returns 223 | any cached result, or executes if not available. Set to zero to 224 | always execute. 225 | 226 | - **parameters (optional)** 227 | 228 | - A set of parameter values to apply to the query. 229 | 230 | - **return_results (optional)** 231 | 232 | - Return results if query is executed successfully, `True` by default. 233 | 234 | ```python 235 | ### EXAMPLE ### 236 | 237 | Redash.generate_query_results(1) 238 | ``` 239 | 240 | ### Query and Wait Result 241 | 242 | - **ds_id** 243 | 244 | - Data Source ID. 245 | 246 | - **qry** 247 | 248 | - Query String. 249 | 250 | - **timeout (optional)** 251 | - Defines the time in seconds to wait before cutting the request. 252 | 253 | ```python 254 | ### EXAMPLE ### 255 | 256 | Redash.query_and_wait_result(1, 'select * from my_table;', 60) 257 | ``` 258 | 259 | ### Create Visualization 260 | 261 | - **qry_id** 262 | 263 | - Query ID. 264 | 265 | - **_type** 266 | 267 | - Type of Visualization. (`table`, `line`, `column`, `area`, `pie`, `scatter`, `bubble`, `box`, `pivot`) 268 | 269 | - **name** 270 | 271 | - Name for Visualization. 272 | 273 | - **columns (optional)** 274 | 275 | - Columns for Table. (Required if *_type* is `table`) 276 | 277 | - **x_axis (optional)** 278 | 279 | - Column for X Axis. (Required if *_type* is not `table` nor `pivot`) 280 | 281 | - **y_axis (optional)** 282 | 283 | - Columns for Y Axis (Required if *_type* is not `table` nor `pivot`) 284 | 285 | - **size_column (optional)** 286 | 287 | - Column for size. (Bubble) 288 | 289 | - **group_by (optional)** 290 | 291 | - Group by specific column. 292 | 293 | - **custom_options (optional)** 294 | 295 | - Custom options for Visualization. 296 | 297 | - **desc (optional)** 298 | 299 | - Description. 300 | 301 | ```python 302 | ### EXAMPLE 1 ### 303 | 304 | Redash.create_visualization(1, "table", "First Visualization", columns=[ 305 | {"name": "column1", "type": "string"}, 306 | {"name": "column2", "type": "datetime"} 307 | ]) 308 | 309 | ### EXAMPLE 2 ### 310 | Redash.create_visualization(1, "line", "Second Visualization", x_axis="column1", y_axis=[ 311 | {"type": "line", "name": "column2", "label": "c2"} 312 | ]) 313 | ``` 314 | 315 | ### Create Dashboard 316 | 317 | - **name** 318 | 319 | - Name for Dashboard. 320 | 321 | ```python 322 | ### EXAMPLE ### 323 | 324 | Redash.create_dashboard("First Dashboard") 325 | ``` 326 | 327 | ### Add Widget into Dashboard 328 | 329 | - **db_id** 330 | 331 | - Dashboard ID. 332 | 333 | - **text (optional)** 334 | 335 | - Text Widget. 336 | 337 | - **vs_id (optional)** 338 | 339 | - Visualization ID. 340 | 341 | - **full_width (optional)** 342 | 343 | - Full width or not, `False` by default. 344 | 345 | - **position (optional)** 346 | 347 | - Custom position for Widget. 348 | 349 | ```python 350 | ### EXAMPLE 1 ### 351 | 352 | Redash.add_widget(1, text="Test") 353 | 354 | ### EXAMPLE 2 ### 355 | Redash.add_widget(1, visualization_id=1, full_width=True) 356 | ``` 357 | 358 | ### Publish Dashboard 359 | 360 | - **db_id** 361 | 362 | - Dashboard ID. 363 | 364 | ```python 365 | ### EXAMPLE ### 366 | 367 | url = Redash.publish_dashboard(1) 368 | ``` 369 | 370 | ## License 371 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 372 | -------------------------------------------------------------------------------- /redashAPI/client.py: -------------------------------------------------------------------------------- 1 | import json 2 | import requests 3 | import time 4 | from datetime import datetime 5 | 6 | class RedashAPIClient: 7 | def __init__(self, api_key: str, host: str="http://localhost:5000"): 8 | self.api_key = api_key 9 | self.host = host 10 | 11 | self.s = requests.Session() 12 | self.s.headers.update({"Authorization": f"Key {api_key}"}) 13 | 14 | def get(self, uri: str): 15 | res = self.s.get(f"{self.host}/api/{uri}") 16 | 17 | if res.status_code != 200: 18 | raise Exception(f"[GET] /api/{uri} ({res.status_code})") 19 | 20 | return res 21 | 22 | def post(self, uri: str, payload: dict=None): 23 | if payload is None or not isinstance(payload, dict): 24 | payload = {} 25 | 26 | data = json.dumps(payload) 27 | 28 | self.s.headers.update({"Content-Type": "application/json"}) 29 | res = self.s.post(f"{self.host}/api/{uri}", data=data) 30 | 31 | if res.status_code != 200: 32 | raise Exception(f"[POST] /api/{uri} ({res.status_code})") 33 | 34 | return res 35 | 36 | def delete(self, uri: str): 37 | res = self.s.delete(f"{self.host}/api/{uri}") 38 | 39 | if res.status_code != 200 and res.status_code != 204: 40 | raise Exception(f"[DELETE] /api/{uri} ({res.status_code})") 41 | 42 | return res 43 | 44 | def create_data_source(self, _type: str, name: str, options: dict=None): 45 | if options is None or not isinstance(options, dict): 46 | options = {} 47 | 48 | payload = { 49 | "type": _type, 50 | "name": name, 51 | "options": options 52 | } 53 | 54 | return self.post('data_sources', payload) 55 | 56 | def create_query(self, ds_id: int, name: str, qry: str, desc: str="", with_results: bool=True, options: dict=None): 57 | if options is None or not isinstance(options, dict): 58 | options = {} 59 | 60 | payload = { 61 | "data_source_id": ds_id, 62 | "name": name, 63 | "query": qry, 64 | "description": desc, 65 | "options": options 66 | } 67 | 68 | res = self.post('queries', payload) 69 | 70 | if with_results: 71 | self.generate_query_results(ds_id, qry) 72 | 73 | return res 74 | 75 | def refresh_query(self, qry_id: int): 76 | return self.post(f"queries/{qry_id}/refresh") 77 | 78 | def generate_query_results(self, ds_id: int, qry: str, qry_id: int=None, max_age: int=0, parameters: dict=None, return_results: bool=False): 79 | if parameters is None or not isinstance(parameters, dict): 80 | parameters = {} 81 | 82 | payload = { 83 | "data_source_id": ds_id, 84 | "query": qry, 85 | "max_age": max_age, 86 | "parameters": parameters 87 | } 88 | 89 | if qry_id is not None: 90 | payload["query_id"] = qry_id 91 | 92 | res = self.post('query_results', payload) 93 | 94 | if return_results: 95 | job_id = res.json().get('job', {}).get('id', None) 96 | result_id = self.get(f"jobs/{job_id}").json().get('query_result_id', None) 97 | 98 | return self.get(f"query_results/{result_id}") 99 | 100 | return res 101 | 102 | def query_and_wait_result(self, ds_id: int, query: str, timeout: int=60): 103 | payload = { 104 | 'data_source_id': ds_id, 105 | 'query': query, 106 | 'max_age': 0 107 | } 108 | 109 | res = self.post('query_results', payload) 110 | job_id = res.json().get('job', {}).get('id') 111 | 112 | start = datetime.now() 113 | while True: 114 | job = self.get(f'jobs/{job_id}') 115 | job = job.json().get('job', {}) 116 | if job.get('status') == 3: 117 | query_result_id = job.get('query_result_id') 118 | break 119 | 120 | if (datetime.now() - start).total_seconds() > timeout: 121 | raise Exception('Polling timeout.') 122 | 123 | time.sleep(0.2) 124 | 125 | return self.get(f'query_results/{query_result_id}') 126 | 127 | def create_visualization(self, qry_id: int, _type: str, name: str, columns: list=None, x_axis: str=None, y_axis: list=None, size_column: str=None, group_by: str=None, custom_options: dict=None, desc: str=None): 128 | if custom_options is None or not isinstance(custom_options, dict): 129 | custom_options = {} 130 | 131 | if _type == 'table': 132 | if columns is None or not isinstance(columns, list) or len(columns) == 0: 133 | try: 134 | columns = custom_options['columns'] 135 | except KeyError: 136 | raise Exception("columns is reqruied for table.") 137 | 138 | order = 100000 139 | table_columns = [] 140 | for idx, col in enumerate(columns): 141 | if 'name' not in col or 'type' not in col: 142 | raise Exception("name and type are required in columns.") 143 | 144 | table_columns.append({ 145 | "alignContent": "left", 146 | "allowHTML": True, 147 | "allowSearch": False, 148 | "booleanValues": [False, True], 149 | "dateTimeFormat": "DD/MM/YY HH:mm", 150 | "displayAs": "string", 151 | "highlightLinks": False, 152 | "imageHeight": "", 153 | "imageTitleTemplate": "{{ @ }}", 154 | "imageUrlTemplate": "{{ @ }}", 155 | "imageWidth": "", 156 | "linkOpenInNewTab": True, 157 | "linkTextTemplate": "{{ @ }}", 158 | "linkTitleTemplate": "{{ @ }}", 159 | "linkUrlTemplate": "{{ @ }}", 160 | "numberFormat": "0,0", 161 | "order": order + idx, 162 | "title": col.get('title', col['name']), 163 | "visible": True, 164 | **col 165 | }) 166 | 167 | chart_type = 'TABLE' 168 | options = { 169 | "autoHeight": True, 170 | "defaultColumns": 3, 171 | "defaultRows": 15, 172 | "itemsPerPage": 10, 173 | "minColumns": 1, 174 | "columns": table_columns, 175 | **custom_options 176 | } 177 | 178 | elif _type == 'pivot': 179 | if not custom_options: 180 | raise Exception("custom_options is required for pivot.") 181 | 182 | chart_type = 'PIVOT' 183 | options = custom_options 184 | 185 | elif _type in ['line', 'column', 'area', 'pie', 'scatter', 'bubble', 'box']: 186 | if x_axis is None or y_axis is None or not isinstance(y_axis, list) or len(y_axis) == 0: 187 | raise Exception(f"x_axis and y_axis are required for {_type}.") 188 | 189 | columnMapping = {} 190 | seriesOptions = {} 191 | for idx, y in enumerate(y_axis): 192 | if 'name' not in y: 193 | raise Exception("name is required in y_axis.") 194 | 195 | y_name = y['name'] 196 | y_label = y.get('label', y_name) 197 | y_type = y.get('type', _type) 198 | 199 | columnMapping[y_name] = "y" 200 | seriesOptions[y_name] = { 201 | "index": 0, 202 | "type": y_type, 203 | "name": y_label, 204 | "yAxis": 0, 205 | "zIndex": idx 206 | } 207 | 208 | if size_column is not None: 209 | columnMapping[size_column] = "size" 210 | 211 | if group_by is not None: 212 | columnMapping[group_by] = "series" 213 | 214 | chart_type = 'CHART' 215 | options = { 216 | "globalSeriesType": _type, 217 | "sortX": True, 218 | "legend": {"enabled": True}, 219 | "yAxis": [{"type": "linear"}, {"type": "linear", "opposite": True}], 220 | "xAxis": {"type": "category", "labels": {"enabled": True}}, 221 | "error_y": {"type": "data", "visible": True}, 222 | "series": {"stacking": None, "error_y": {"type": "data", "visible": True}}, 223 | "columnMapping": {x_axis: "x", **columnMapping}, 224 | "seriesOptions": seriesOptions, 225 | "showDataLabels": True if _type == 'pie' else False, 226 | **custom_options 227 | } 228 | else: 229 | raise Exception(f"Type {_type} is not supported yet.") 230 | 231 | payload = { 232 | "name": name, 233 | "type": chart_type, 234 | "query_id": qry_id, 235 | "description": desc, 236 | "options": options 237 | } 238 | 239 | return self.post('visualizations', payload) 240 | 241 | def create_dashboard(self, name: str): 242 | payload = { 243 | "name": name 244 | } 245 | 246 | return self.post('dashboards', payload) 247 | 248 | def add_widget(self, db_id: int, text: str="", vs_id: int=None, full_width: bool=False, position: dict=None): 249 | if position is None or not isinstance(position, dict): 250 | position = {} 251 | 252 | payload = { 253 | "dashboard_id": db_id, 254 | "text": text, 255 | "visualization_id": vs_id, 256 | "width": 1, 257 | "options": { 258 | "position": position or self.calculate_widget_position(db_id, full_width) 259 | } 260 | } 261 | 262 | return self.post('widgets', payload) 263 | 264 | def calculate_widget_position(self, db_id: int, full_width: bool): 265 | res = self.get('dashboards') 266 | dashboards = res.json().get('results', []) 267 | slug = next((d['slug'] for d in dashboards if d['id'] == db_id), None) 268 | 269 | res = self.get(f'dashboards/{slug}') 270 | widgets = res.json().get('widgets', []) 271 | 272 | exceed_half_width_widgets_count = 0 273 | for w in widgets: 274 | if w['options']['position']['col'] + w['options']['position']['sizeX'] > 3: 275 | exceed_half_width_widgets_count += 1 276 | 277 | position = { 278 | "col": 0, 279 | "row": 0, 280 | "sizeX": 3, 281 | "sizeY": 8 282 | } 283 | 284 | if len(widgets) > 0: 285 | row, col = divmod(len(widgets) - exceed_half_width_widgets_count, 2) 286 | 287 | position['col'] = col * 3 288 | position['row'] = (row + exceed_half_width_widgets_count) * 8 289 | 290 | if full_width: 291 | position['col'] = 0 292 | position['sizeX'] = 6 293 | 294 | return position 295 | 296 | def publish_dashboard(self, db_id: int): 297 | self.post(f"dashboards/{db_id}", {"is_draft": False}) 298 | 299 | res = self.post(f"dashboards/{db_id}/share") 300 | public_url = res.json().get('public_url', None) 301 | 302 | return public_url 303 | --------------------------------------------------------------------------------