├── easycharts ├── __init__.py ├── exceptions.py ├── frontend.py └── charts.py ├── requirements.txt ├── images ├── logo.png ├── resource-mon.png ├── get-started-apis.png ├── get-started-test.png ├── get-started-test-1.png ├── get-started-update.png └── get-started-test-1-bar.png ├── nextbuild.py ├── setup.py ├── LICENSE ├── .github └── workflows │ └── package.yaml ├── .gitignore └── README.md /easycharts/__init__.py: -------------------------------------------------------------------------------- 1 | from .charts import ChartServer -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | easyrpc>=0.241 2 | aiopyql>=0.359 -------------------------------------------------------------------------------- /images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codemation/easycharts/HEAD/images/logo.png -------------------------------------------------------------------------------- /images/resource-mon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codemation/easycharts/HEAD/images/resource-mon.png -------------------------------------------------------------------------------- /images/get-started-apis.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codemation/easycharts/HEAD/images/get-started-apis.png -------------------------------------------------------------------------------- /images/get-started-test.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codemation/easycharts/HEAD/images/get-started-test.png -------------------------------------------------------------------------------- /images/get-started-test-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codemation/easycharts/HEAD/images/get-started-test-1.png -------------------------------------------------------------------------------- /images/get-started-update.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codemation/easycharts/HEAD/images/get-started-update.png -------------------------------------------------------------------------------- /images/get-started-test-1-bar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codemation/easycharts/HEAD/images/get-started-test-1-bar.png -------------------------------------------------------------------------------- /easycharts/exceptions.py: -------------------------------------------------------------------------------- 1 | from fastapi import HTTPException 2 | 3 | class DuplicateDatasetError(HTTPException): 4 | def __init__(self, dataset: str): 5 | super().__init__( 6 | status_code = 422, 7 | detail = f"A dataset with name {dataset} already exists" 8 | ) 9 | class MissingDatasetError(HTTPException): 10 | def __init__(self, dataset: str): 11 | super().__init__( 12 | status_code = 404, 13 | detail = f"No dataset with name {dataset} exists" 14 | ) -------------------------------------------------------------------------------- /nextbuild.py: -------------------------------------------------------------------------------- 1 | """ 2 | Purpose: 3 | Increments current Pypi version by .001 4 | 5 | Usage: 6 | pip3 download easycharts && ls easycharts*.whl | sed 's/-/" "/g' | awk '{print "(" $2 ")"}' | python3 python/easyauth/easyauth/nextbuild.py 7 | """ 8 | if __name__=='__main__': 9 | import sys 10 | version = sys.stdin.readline().rstrip() 11 | if '(' in version and ')' in version: 12 | right_i = version.index('(') 13 | left_i = version.index(')') 14 | version = version[right_i+2:left_i-1] 15 | print(f"{float(version)+0.001:.3f}") -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | BASE_REQUIREMENTS = [ 4 | 'easyrpc>=0.241', 5 | 'aiopyql>=0.359' 6 | ] 7 | 8 | with open("README.md", "r") as fh: 9 | long_description = fh.read() 10 | setuptools.setup( 11 | name='easycharts', 12 | version='NEXTVERSION', 13 | packages=setuptools.find_packages(include=['easycharts'], exclude=['build']), 14 | author="Joshua Jamison", 15 | author_email="joshjamison1@gmail.com", 16 | description="Easily create data visualization of static or streaming data", 17 | long_description=long_description, 18 | long_description_content_type="text/markdown", 19 | url="https://github.com/codemation/easycharts", 20 | classifiers=[ 21 | "Programming Language :: Python :: 3", 22 | "License :: OSI Approved :: MIT License", 23 | "Operating System :: OS Independent", 24 | ], 25 | python_requires='>=3.7, <4', 26 | install_requires=BASE_REQUIREMENTS, 27 | extras_require={} 28 | ) -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Joshua Jamison 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 | -------------------------------------------------------------------------------- /.github/workflows/package.yaml: -------------------------------------------------------------------------------- 1 | name: Package and Push to PyPI 2 | on: 3 | push: 4 | tags: 5 | - "*" 6 | jobs: 7 | package: 8 | name: Package easycharts for PyPI 9 | runs-on: ubuntu-latest 10 | steps: 11 | # Downloads a copy of the code in your repository before running CI tests 12 | - name: Check out repository code 13 | uses: actions/checkout@v2 14 | - name: Setup Python 15 | uses: actions/setup-python@v2 16 | with: 17 | python-version: 3.8 18 | - name: Install Packaging dependencies 19 | run: | 20 | pip install wheel twine 21 | 22 | - name: Package & Test PyPI Installation 23 | run: | 24 | export NEXTVERSION=$(pip -qqq download easycharts && ls easycharts*.whl | sed 's/-/" "/g' | awk '{print "(" $2 ")"}' | python nextbuild.py) 25 | sed -i 's/NEXTVERSION/'$NEXTVERSION'/g' setup.py 26 | python setup.py bdist_wheel 27 | export PYQL_PACKAGE=$(pwd)/dist/easycharts-$NEXTVERSION-py3-none-any.whl 28 | pip install $(echo -n $PYQL_PACKAGE) 29 | 30 | - name: Upload to PyPi 31 | env: # Or as an environment variable 32 | PYPI: ${{ secrets.PYPI }} 33 | run: | 34 | export NEXTVERSION=$(pip -qqq download easycharts && ls easycharts*.whl | sed 's/-/" "/g' | awk '{print "(" $2 ")"}' | python nextbuild.py) 35 | export PYQL_PACKAGE=$(pwd)/dist/easycharts-$NEXTVERSION-py3-none-any.whl 36 | python -m twine upload $(pwd)/dist/easycharts-$NEXTVERSION-py3-none-any.whl -u codemation -p $PYPI -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![](images/logo.png) 2 | 3 | ## 4 | 5 | Easily create data visualization of static or streaming data 6 | 7 | 8 | ## Get Started 9 | 10 | ```python 11 | pip install easycharts 12 | ``` 13 | 14 | ## Create EasyCharts Server 15 | 16 | ```python 17 | # charts.py 18 | from fastapi import FastAPI 19 | from easycharts import ChartServer 20 | 21 | server = FastAPI() 22 | 23 | @server.on_event('startup') 24 | async def setup(): 25 | server.charts = await ChartServer.create( 26 | server, 27 | charts_db="test" 28 | ) 29 | 30 | await server.charts.create_dataset( 31 | "test", 32 | labels=['a', 'b', 'c', 'd'], 33 | dataset=[1,2,3,4] 34 | ) 35 | ``` 36 | ## Start Server 37 | 38 | ```bash 39 | uvicorn --host 0.0.0.0 --port 0.0.0.0 charts:server 40 | 41 | ``` 42 | 43 | ![](images/get-started-test.png) 44 | 45 | ## Update Data via API 46 | 47 | In a separate window, access the OpenAPI docs to demonstrate dynanimc updates to the graph 48 | 49 | ``` 50 | http://0.0.0.0:8220/docs 51 | ``` 52 | 53 | ![](images/get-started-update.png) 54 | 55 | ## Line 56 | ![](images/get-started-test-1.png) 57 | 58 | 59 | ## Bar 60 | ![](images/get-started-test-1-bar.png) 61 | 62 | ## APIS 63 | 64 | ![](images/get-started-apis.png) 65 | 66 | ## Real World Usage - Resource Monitoring 67 | 68 | 69 | ```python 70 | import datetime, psutil 71 | import asyncio 72 | from fastapi import FastAPI 73 | from easycharts import ChartServer 74 | from easyschedule import EasyScheduler 75 | 76 | scheduler = EasyScheduler() 77 | server = FastAPI() 78 | 79 | every_minute = '* * * * *' 80 | 81 | @server.on_event('startup') 82 | async def setup(): 83 | asyncio.create_task(scheduler.start()) 84 | server.charts = await ChartServer.create( 85 | server, 86 | charts_db="charts_database", 87 | chart_prefix = '/mycharts' 88 | ) 89 | 90 | await server.charts.create_dataset( 91 | "test", 92 | labels=['a', 'b', 'c', 'd'], 93 | dataset=[1,2,3,4] 94 | ) 95 | 96 | # set initial sync time 97 | label=datetime.datetime.now().isoformat()[11:19] 98 | await server.charts.create_dataset( 99 | 'cpu', 100 | labels=[label], 101 | dataset=[psutil.cpu_percent()] 102 | ) 103 | await server.charts.create_dataset( 104 | 'mem', 105 | labels=[label], 106 | dataset=[psutil.virtual_memory().percent] 107 | ) 108 | 109 | @scheduler(schedule=every_minute) 110 | async def resource_monitor(): 111 | time_now=datetime.datetime.now().isoformat()[11:19] 112 | 113 | # updates CPU & MEM datasets with current time 114 | await server.charts.update_dataset( 115 | 'cpu', 116 | label=time_now, 117 | data=psutil.cpu_percent() 118 | ) 119 | await server.charts.update_dataset( 120 | 'mem', 121 | label=time_now, 122 | data=psutil.virtual_memory().percent 123 | ) 124 | ``` 125 | 126 | ![](images/resource-mon.png) -------------------------------------------------------------------------------- /easycharts/frontend.py: -------------------------------------------------------------------------------- 1 | def get_chart_body(names: list, creds: str, chart_type: str): 2 | names_joined = '__and__'.join(names) 3 | names = [f'"{name}"' for name in names ] 4 | names_csv = ', '.join(names) 5 | return f""" 6 | 7 | 8 | 145 | 146 | """ 147 | 148 | def get_chart_page(names: list, creds: str, chart_type: str): 149 | chart_body = get_chart_body(names, creds, chart_type=chart_type) 150 | return f""" 151 | 152 | 153 | 154 | 155 | 156 | {chart_body} 157 | 158 | 159 | """ -------------------------------------------------------------------------------- /easycharts/charts.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import time, uuid, os 3 | from enum import Enum 4 | from typing import Optional 5 | 6 | from pydantic import BaseModel 7 | from fastapi import Response 8 | from fastapi.responses import HTMLResponse 9 | from aiopyql.data import Database 10 | from easyrpc.server import EasyRpcServer 11 | from easyrpc.auth import encode 12 | 13 | from easycharts.exceptions import ( 14 | DuplicateDatasetError, 15 | MissingDatasetError 16 | ) 17 | from easycharts.frontend import get_chart_body, get_chart_page 18 | 19 | class ChartType(str, Enum): 20 | bar = 'bar' 21 | line = 'line' 22 | 23 | class ChartServer: 24 | def __init__(self, 25 | rpc_server: EasyRpcServer, 26 | charts_db: Database = None 27 | ): 28 | self.rpc_server = rpc_server 29 | self.log = rpc_server.log 30 | self.db = charts_db 31 | 32 | @classmethod 33 | async def create(cls, 34 | server, 35 | charts_db: str, 36 | chart_prefix = '/chart' 37 | ): 38 | rpc_server = EasyRpcServer( 39 | server, 40 | '/ws/charts', 41 | server_secret='easycharts' if not 'RPC_SECRET' in os.environ else os.environ['RPC_SECRET'], 42 | ) 43 | 44 | charts_db = await Database.create( 45 | database=f"{charts_db}.db", 46 | cache_enabled=True 47 | ) 48 | 49 | chart_server = cls( 50 | rpc_server, 51 | charts_db=charts_db 52 | ) 53 | 54 | @server.get(chart_prefix + '/{chart}', response_class=HTMLResponse, tags=['charts']) 55 | async def view_chart_html( 56 | chart: str, 57 | extra: str = None, 58 | chart_type: ChartType = ChartType.line, 59 | body_only: bool = False 60 | ): 61 | charts = [chart] 62 | if extra: 63 | charts.append(extra) 64 | if not body_only: 65 | return await chart_server.get_chart_page(charts, chart_type=chart_type) 66 | return await chart_server.get_chart_body(charts, chart_type=chart_type) 67 | 68 | @server.post(chart_prefix + '/{chart}', tags=['charts']) 69 | async def update_chart(chart: str , label: str, data: str): 70 | return await chart_server.update_dataset( 71 | chart, 72 | label, 73 | data 74 | ) 75 | @server.delete(chart_prefix + '/{chart}', tags=['charts']) 76 | async def delete_chart(chart: str): 77 | return await chart_server.remove_dataset( 78 | chart 79 | ) 80 | 81 | class Dataset(BaseModel): 82 | name: str 83 | labels: list 84 | dataset: list 85 | 86 | @server.put(chart_prefix, tags=['charts']) 87 | async def create_chart(dataset: Dataset, response: Response): 88 | return await chart_server.create_dataset( 89 | dataset.name, 90 | dataset.labels, 91 | dataset.dataset, 92 | response=response 93 | ) 94 | 95 | 96 | @server.on_event('shutdown') 97 | async def db_close(): 98 | await charts_db.close() 99 | await asyncio.sleep(1) 100 | 101 | 102 | # create default rpc_server methods 103 | @rpc_server.origin(namespace='easycharts') 104 | async def create_chart(names: list, chart_type='line'): 105 | for name in names: 106 | if not name in chart_server.db.tables: 107 | raise MissingDatasetError(name) 108 | datasets = [] 109 | for name in names: 110 | dataset = await chart_server.db.tables[name].select('*') 111 | labels = [d['label'] for d in dataset] 112 | data = [d['data'] for d in dataset] 113 | datasets.append( 114 | { 115 | 'name': name, 116 | 'labels': labels, 117 | 'data': data, 118 | "latest_timestamp": dataset[-1]['timestamp'] 119 | } 120 | ) 121 | chart_id = '__and__'.join(names) 122 | return { 123 | "chart_id": f"{chart_id}_id", 124 | "name": chart_id, 125 | "names": names, 126 | "action": "create_chart", 127 | "type": chart_type, 128 | "datasets": datasets 129 | } 130 | 131 | @rpc_server.origin(namespace='easycharts') 132 | async def update_chart(name: str, latest_timestamp: float): 133 | if not name in chart_server.db.tables: 134 | raise MissingDatasetError(name) 135 | 136 | changes = await chart_server.db.tables[name].select( 137 | '*', 138 | where=[ 139 | ['timestamp', '>', latest_timestamp], 140 | ] 141 | ) 142 | 143 | labels = [d['label'] for d in changes] 144 | data = [d['data'] for d in changes] 145 | if labels and data: 146 | return { 147 | "name": name, 148 | "action": "update_chart", 149 | "latest_timestamp": changes[-1]['timestamp'], 150 | "labels": labels, 151 | "data": data 152 | } 153 | return { 154 | "info": f"{name} does not have any changes to update" 155 | } 156 | return chart_server 157 | 158 | 159 | async def create_dataset(self, 160 | name: str, 161 | labels: list, 162 | dataset: list, 163 | response: Response = None 164 | ): 165 | if name in self.db.tables: 166 | self.log.warning(f"Dataset with name {name} already exists") 167 | if response: 168 | raise DuplicateDatasetError(name) 169 | return 170 | 171 | # create dataset table 172 | await self.db.create_table( 173 | name, 174 | [ 175 | ('timestamp', float, 'UNIQUE NOT NULL'), 176 | ('label', str), 177 | ('data', str), 178 | ], 179 | 'timestamp', 180 | cache_enabled=True 181 | ) 182 | 183 | # load table with data 184 | for label, data in zip(labels, dataset): 185 | await self.db.tables[name].insert( 186 | timestamp=time.time(), 187 | label=label, 188 | data=data 189 | ) 190 | return {'message': f"dataset {name} created"} 191 | 192 | async def update_dataset(self, 193 | name: str, 194 | label: str, 195 | data 196 | ): 197 | if not name in self.db.tables: 198 | raise MissingDatasetError(name) 199 | 200 | await self.db.tables[name].insert( 201 | timestamp=time.time(), 202 | label=label, 203 | data=data 204 | ) 205 | return f"datapoint created" 206 | 207 | async def remove_dataset(self, 208 | name 209 | ): 210 | if not name in self.db.tables: 211 | raise MissingDatasetError(name) 212 | 213 | await self.db.remove_table(name) 214 | return f"dataset {name} removed" 215 | def get_credentials(self): 216 | creds = encode( 217 | 'easycharts' if not 'RPC_SECRET' in os.environ else os.environ['RPC_SECRET'], 218 | **{ 219 | "type":"PROXY", 220 | "id": str(uuid.uuid1()), 221 | "namespace":"easycharts", 222 | "serialization": "json" 223 | } 224 | ) 225 | return creds 226 | async def get_chart_page(self, name, chart_type='line'): 227 | return get_chart_page(name, self.get_credentials(), chart_type=chart_type) 228 | async def get_chart_body(self, name, chart_type='line'): 229 | return get_chart_body(name, self.get_credentials(), chart_type=chart_type) --------------------------------------------------------------------------------