") + if self.swagger: + self.write(f"
Visit here to login and use my swagger doc
") + self.finish() + + + + + + + +__all__ = [ + SystemHostHandler.__name__, + UsersHandler.__name__, + AppSettingsHandler.__name__, + AppSettingHandler.__name__, + LoginHandler.__name__, + LogoutHandler.__name__, + WhoAmIHandler.__name__, + PagesHandler.__name__, + PageHandler.__name__, + SubscribersHandler.__name__, + SwaggerUIHandler.__name__, + MainHandler.__name__ +] \ No newline at end of file diff --git a/hololinked/system_host/models.py b/hololinked/system_host/models.py new file mode 100644 index 0000000..14d7948 --- /dev/null +++ b/hololinked/system_host/models.py @@ -0,0 +1,86 @@ +import typing +from dataclasses import asdict, field + +from sqlalchemy import Integer, String, JSON, ARRAY, Boolean, BLOB +from sqlalchemy.orm import Mapped, mapped_column, DeclarativeBase, MappedAsDataclass + +from ..server.constants import JSONSerializable + + +class HololinkedHostTableBase(DeclarativeBase): + pass + +class Pages(HololinkedHostTableBase, MappedAsDataclass): + __tablename__ = "pages" + + name : Mapped[str] = mapped_column(String(1024), primary_key=True, nullable=False) + URL : Mapped[str] = mapped_column(String(1024), unique=True, nullable=False) + description : Mapped[str] = mapped_column(String(16384)) + json_specfication : Mapped[typing.Dict[str, typing.Any]] = mapped_column(JSON, nullable=True) + + def json(self): + return { + "name" : self.name, + "URL" : self.URL, + "description" : self.description, + "json_specification" : self.json_specfication + } + +class AppSettings(HololinkedHostTableBase, MappedAsDataclass): + __tablename__ = "appsettings" + + field : Mapped[str] = mapped_column(String(8192), primary_key=True) + value : Mapped[typing.Dict[str, typing.Any]] = mapped_column(JSON) + + def json(self): + return asdict(self) + +class LoginCredentials(HololinkedHostTableBase, MappedAsDataclass): + __tablename__ = "login_credentials" + + email : Mapped[str] = mapped_column(String(1024), primary_key=True) + password : Mapped[str] = mapped_column(String(1024), unique=True) + +class Server(HololinkedHostTableBase, MappedAsDataclass): + __tablename__ = "http_servers" + + hostname : Mapped[str] = mapped_column(String, primary_key=True) + type : Mapped[str] = mapped_column(String) + port : Mapped[int] = mapped_column(Integer) + IPAddress : Mapped[str] = mapped_column(String) + https : Mapped[bool] = mapped_column(Boolean) + + def json(self): + return { + "hostname" : self.hostname, + "type" : self.type, + "port" : self.port, + "IPAddress" : self.IPAddress, + "https" : self.https + } + + + +class HololinkedHostInMemoryTableBase(DeclarativeBase): + pass + +class UserSession(HololinkedHostInMemoryTableBase, MappedAsDataclass): + __tablename__ = "user_sessions" + + email : Mapped[str] = mapped_column(String) + session_key : Mapped[BLOB] = mapped_column(BLOB, primary_key=True) + origin : Mapped[str] = mapped_column(String) + user_agent : Mapped[str] = mapped_column(String) + remote_IP : Mapped[str] = mapped_column(String) + + + +__all__ = [ + HololinkedHostTableBase.__name__, + HololinkedHostInMemoryTableBase.__name__, + Pages.__name__, + AppSettings.__name__, + LoginCredentials.__name__, + Server.__name__, + UserSession.__name__ +] \ No newline at end of file diff --git a/hololinked/system_host/server.py b/hololinked/system_host/server.py new file mode 100644 index 0000000..f229abb --- /dev/null +++ b/hololinked/system_host/server.py @@ -0,0 +1,146 @@ +import secrets +import os +import base64 +import socket +import json +import asyncio +import ssl +import typing +import getpass +from argon2 import PasswordHasher + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.ext import asyncio as asyncio_ext +from sqlalchemy_utils import database_exists, create_database, drop_database +from tornado.web import Application, StaticFileHandler, RequestHandler +from tornado.httpserver import HTTPServer as TornadoHTTP1Server +from tornado import ioloop + +from ..server.serializers import JSONSerializer +from ..server.database import BaseDB +from ..server.config import global_config +from .models import * +from .handlers import * + + +def create_system_host(db_config_file : typing.Optional[str] = None, ssl_context : typing.Optional[ssl.SSLContext] = None, + handlers : typing.List[typing.Tuple[str, RequestHandler, dict]] = [], **server_settings) -> TornadoHTTP1Server: + """ + global function for creating system hosting server using a database configuration file, SSL context & certain + server settings. Currently supports only one server per process due to usage of some global variables. + """ + disk_DB_URL = BaseDB.create_postgres_URL(db_config_file, database='hololinked-host', use_dialect=False) + if not database_exists(disk_DB_URL): + try: + create_database(disk_DB_URL) + sync_disk_db_engine = create_engine(disk_DB_URL) + HololinkedHostTableBase.metadata.create_all(sync_disk_db_engine) + create_tables(sync_disk_db_engine) + create_credentials(sync_disk_db_engine) + except Exception as ex: + if disk_DB_URL.startswith("sqlite"): + os.remove(disk_DB_URL.split('/')[-1]) + else: + drop_database(disk_DB_URL) + raise ex from None + finally: + sync_disk_db_engine.dispose() + + disk_DB_URL = BaseDB.create_postgres_URL(db_config_file, database='hololinked-host', use_dialect=True) + disk_engine = asyncio_ext.create_async_engine(disk_DB_URL, echo=True) + disk_session = sessionmaker(disk_engine, expire_on_commit=True, + class_=asyncio_ext.AsyncSession) # type: asyncio_ext.AsyncSession + + mem_DB_URL = BaseDB.create_sqlite_URL(in_memory=True) + mem_engine = create_engine(mem_DB_URL, echo=True) + mem_session = sessionmaker(mem_engine, expire_on_commit=True, + class_=Session) # type: Session + HololinkedHostInMemoryTableBase.metadata.create_all(mem_engine) + + CORS = server_settings.pop("CORS", []) + if not isinstance(CORS, (str, list)): + raise TypeError("CORS should be a list of strings or a string") + if isinstance(CORS, str): + CORS = [CORS] + kwargs = dict( + CORS=CORS, + disk_session=disk_session, + mem_session=mem_session + ) + + system_host_compatible_handlers = [] + for handler in handlers: + system_host_compatible_handlers.append((handler[0], handler[1], kwargs)) + + app = Application([ + (r"/", MainHandler, dict(IP="https://localhost:8080", swagger=True, **kwargs)), + (r"/users", UsersHandler, kwargs), + (r"/pages", PagesHandler, kwargs), + (r"/pages/(.*)", PageHandler, kwargs), + (r"/app-settings", AppSettingsHandler, kwargs), + (r"/app-settings/(.*)", AppSettingHandler, kwargs), + (r"/subscribers", SubscribersHandler, kwargs), + # (r"/remote-objects", RemoteObjectsHandler), + (r"/login", LoginHandler, kwargs), + (r"/logout", LogoutHandler, kwargs), + (r"/swagger-ui", SwaggerUIHandler, kwargs), + *system_host_compatible_handlers, + (r"/(.*)", StaticFileHandler, dict(path=os.path.join(os.path.dirname(__file__), + f"assets{os.sep}hololinked-server-swagger-api{os.sep}system-host-api")) + ), + ], + cookie_secret=base64.b64encode(os.urandom(32)).decode('utf-8'), + **server_settings) + + return TornadoHTTP1Server(app, ssl_options=ssl_context) + + +def start_tornado_server(server : TornadoHTTP1Server, port : int = 8080): + server.listen(port) + event_loop = ioloop.IOLoop.current() + print("starting server") + event_loop.start() + + +def create_tables(engine): + with Session(engine) as session, session.begin(): + file = open(f"{os.path.dirname(os.path.abspath(__file__))}{os.sep}assets{os.sep}default_host_settings.json", 'r') + default_settings = JSONSerializer.generic_load(file) + for name, settings in default_settings.items(): + session.add(AppSettings( + field = name, + value = settings + )) + session.commit() + + +def create_credentials(sync_engine): + """ + create name and password for a new user in a database + """ + + print("Requested primary host seems to use a new database. Give username and password (not for database server, but for client logins from hololinked-portal) : ") + email = input("email-id (not collected anywhere else excepted your own database) : ") + while True: + password = getpass.getpass("password : ") + password_confirm = getpass.getpass("repeat-password : ") + if password != password_confirm: + print("password & repeat password not the same. Try again.") + continue + with Session(sync_engine) as session, session.begin(): + ph = PasswordHasher(time_cost=global_config.PWD_HASHER_TIME_COST) + session.add(LoginCredentials(email=email, password=ph.hash(password))) + session.commit() + return + raise RuntimeError("password not created, aborting database creation.") + + +def delete_database(db_config_file): + # config_file = str(Path(os.path.dirname(__file__)).parent) + "\\assets\\db_config.json" + URL = BaseDB.create_URL(db_config_file, database="hololinked-host", use_dialect=False) + drop_database(URL) + + + +__all__ = ['create_system_host'] \ No newline at end of file diff --git a/hololinked/webdashboard/visualization_parameters.py b/hololinked/webdashboard/visualization_parameters.py new file mode 100644 index 0000000..53f76d4 --- /dev/null +++ b/hololinked/webdashboard/visualization_parameters.py @@ -0,0 +1,158 @@ +import os +import typing +from enum import Enum +from ..param.parameterized import Parameterized +from ..server.constants import USE_OBJECT_NAME, HTTP_METHODS +from ..server.remote_parameter import RemoteParameter +from ..server.events import Event + +try: + import plotly.graph_objects as go +except: + go = None + +class VisualizationParameter(RemoteParameter): + # type shield from RemoteParameter + pass + + + +class PlotlyFigure(VisualizationParameter): + + __slots__ = ['data_sources', 'update_event_name', 'refresh_interval', 'polled', + '_action_stub'] + + def __init__(self, default_figure, *, + data_sources : typing.Dict[str, typing.Union[RemoteParameter, typing.Any]], + polled : bool = False, refresh_interval : typing.Optional[int] = None, + update_event_name : typing.Optional[str] = None, doc: typing.Union[str, None] = None, + URL_path : str = USE_OBJECT_NAME) -> None: + super().__init__(default=default_figure, doc=doc, constant=True, readonly=True, URL_path=URL_path) + self.data_sources = data_sources + self.refresh_interval = refresh_interval + self.update_event_name = update_event_name + self.polled = polled + + def _post_slot_set(self, slot : str, old : typing.Any, value : typing.Any) -> None: + if slot == 'owner' and self.owner is not None: + from ..webdashboard import RepeatedRequests, AxiosRequestConfig, EventSource + if self.polled: + if self.refresh_interval is None: + raise ValueError(f'for PlotlyFigure {self.name}, set refresh interval (ms) since its polled') + request = AxiosRequestConfig( + url=f'/parameters?{"&".join(f"{key}={value}" for key, value in self.data_sources.items())}', + # Here is where graphQL is very useful + method='get' + ) + self._action_stub = RepeatedRequests( + requests=request, + interval=self.refresh_interval, + ) + elif self.update_event_name: + if not isinstance(self.update_event_name, str): + raise ValueError(f'update_event_name for PlotlyFigure {self.name} must be a string') + request = EventSource(f'/event/{self.update_event_name}') + self._action_stub = request + else: + pass + + for field, source in self.data_sources.items(): + if isinstance(source, RemoteParameter): + if isinstance(source, EventSource): + raise RuntimeError("Parameter field not supported for event source, give str") + self.data_sources[field] = request.response[source.name] + elif isinstance(source, str): + if isinstance(source, RepeatedRequests) and source not in self.owner.parameters: # should be in remote parameters, not just parameter + raise ValueError(f'data_sources must be a string or RemoteParameter, type {type(source)} has been found') + self.data_sources[field] = request.response[source] + else: + raise ValueError(f'given source {source} invalid. Specify str for events or Parameter') + + return super()._post_slot_set(slot, old, value) + + def validate_and_adapt(self, value : typing.Any) -> typing.Any: + if self.allow_None and value is None: + return + if not go: + raise ImportError("plotly was not found/imported, install plotly to suport PlotlyFigure paramater") + if not isinstance(value, go.Figure): + raise TypeError(f"figure arguments accepts only plotly.graph_objects.Figure, not type {type(value)}", + self) + return value + + @classmethod + def serialize(cls, value): + return value.to_json() + + + +class Image(VisualizationParameter): + + __slots__ = ['event', 'streamable', '_action_stub', 'data_sources'] + + def __init__(self, default : typing.Any = None, *, streamable : bool = True, doc : typing.Optional[str] = None, + constant : bool = False, readonly : bool = False, allow_None : bool = False, + URL_path : str = USE_OBJECT_NAME, + http_method : typing.Tuple[typing.Optional[str], typing.Optional[str]] = (HTTP_METHODS.GET, HTTP_METHODS.PUT), + state : typing.Optional[typing.Union[typing.List, typing.Tuple, str, Enum]] = None, + db_persist : bool = False, db_init : bool = False, db_commit : bool = False, + class_member : bool = False, fget : typing.Optional[typing.Callable] = None, + fset : typing.Optional[typing.Callable] = None, fdel : typing.Optional[typing.Callable] = None, + deepcopy_default : bool = False, per_instance_descriptor : bool = False, + precedence : typing.Optional[float] = None) -> None: + super().__init__(default, doc=doc, constant=constant, readonly=readonly, allow_None=allow_None, + URL_path=URL_path, http_method=http_method, state=state, + db_persist=db_persist, db_init=db_init, db_commit=db_commit, class_member=class_member, + fget=fget, fset=fset, fdel=fdel, deepcopy_default=deepcopy_default, + per_instance_descriptor=per_instance_descriptor, precedence=precedence) + self.streamable = streamable + + def __set_name__(self, owner : typing.Any, attrib_name : str) -> None: + super().__set_name__(owner, attrib_name) + self.event = Event(attrib_name) + + def _post_value_set(self, obj : Parameterized, value : typing.Any) -> None: + super()._post_value_set(obj, value) + if value is not None: + print(f"pushing event {value[0:100]}") + self.event.push(value, serialize=False) + + def _post_slot_set(self, slot : str, old : typing.Any, value : typing.Any) -> None: + if slot == 'owner' and self.owner is not None: + from ..webdashboard import SSEVideoSource + request = SSEVideoSource(f'/event/image') + self._action_stub = request + self.data_sources = request.response + return super()._post_slot_set(slot, old, value) + + + + +class FileServer(RemoteParameter): + + __slots__ = ['directory'] + + def __init__(self, directory : str, *, doc : typing.Optional[str] = None, URL_path : str = USE_OBJECT_NAME, + class_member: bool = False, per_instance_descriptor: bool = False) -> None: + self.directory = self.validate_and_adapt_directory(directory) + super().__init__(default=self.load_files(self.directory), doc=doc, URL_path=URL_path, constant=True, + class_member=class_member, per_instance_descriptor=per_instance_descriptor) + + def validate_and_adapt_directory(self, value : str): + if not isinstance(value, str): + raise TypeError(f"FileServer parameter not a string, but type {type(value)}", self) + if not os.path.isdir(value): + raise ValueError(f"FileServer parameter directory '{value}' not a valid directory", self) + if not value.endswith('\\'): + value += '\\' + return value + + def load_files(self, directory : str): + return [f for f in os.listdir(directory) if os.path.isfile(os.path.join(directory, f))] + +class DocumentationFolder(FileServer): + + def __init__(self, directory : str, *, doc : typing.Optional[str] = None, URL_path : str = '/documentation', + class_member: bool = False, per_instance_descriptor: bool = False) -> None: + super().__init__(directory=directory, doc=doc, URL_path=URL_path, + class_member=class_member, per_instance_descriptor=per_instance_descriptor) \ No newline at end of file diff --git a/licenses/starlette-LICENSE.md b/licenses/starlette-LICENSE.md deleted file mode 100644 index d16a60e..0000000 --- a/licenses/starlette-LICENSE.md +++ /dev/null @@ -1,27 +0,0 @@ -Copyright © 2018, [Encode OSS Ltd](https://www.encode.io/). -All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - -* Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. - -* Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - -* Neither the name of the copyright holder nor the names of its - contributors may be used to endorse or promote products derived from - this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE -FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2c03b5e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +argon2==0.1.10 +ifaddr==0.2.0 +msgspec==0.18.6 +pyzmq==25.1.0 +SQLAlchemy==2.0.21 +SQLAlchemy_Utils==0.41.1 +tornado==6.3.3 + diff --git a/setup.py b/setup.py index 4dbdbad..fff0b2d 100644 --- a/setup.py +++ b/setup.py @@ -1,24 +1,56 @@ import setuptools -long_description=""" -A zmq-based RPC tool-kit with built-in HTTP support for instrument control/data acquisition -or controlling generic python objects. -""" +# read the contents of your README file +from pathlib import Path +this_directory = Path(__file__).parent +long_description = (this_directory / "README.md").read_text() + setuptools.setup( name="hololinked", - version="0.1.0", + version="0.1.2", author="Vignesh Vaidyanathan", - author_email="vignesh.vaidyanathan@physik.uni-muenchen.de", - description="A zmq-based RPC tool-kit with built-in HTTP support for instrument control/data acquisition or controlling generic python objects.", + author_email="vignesh.vaidyanathan@hololinked.dev", + description="A ZMQ-based Object Oriented RPC tool-kit with HTTP support for instrument control/data acquisition or controlling generic python objects.", long_description=long_description, long_description_content_type="text/markdown", - url="", - packages=['hololinked'], + url="https://hololinked.readthedocs.io/en/latest/index.html", + packages=[ + 'hololinked', + 'hololinked.server', + 'hololinked.rpc', + 'hololinked.client', + 'hololinked.param' + ], classifiers=[ "Programming Language :: Python :: 3", + "License :: OSI Approved :: BSD License", "Operating System :: OS Independent", + "Intended Audience :: Information Technology", + "Intended Audience :: Science/Research", + "Intended Audience :: Healthcare Industry", + "Intended Audience :: Manufacturing", + "Intended Audience :: Developers", + "Intended Audience :: End Users/Desktop", + "Intended Audience :: Education", + "Topic :: Internet :: WWW/HTTP", + "Topic :: Internet :: WWW/HTTP :: Browsers", + "Topic :: Scientific/Engineering :: Human Machine Interfaces", + "Topic :: System :: Hardware", + "Development Status :: 4 - Beta" ], python_requires='>=3.7', + install_requires=[ + "argon2-cffi>=0.1.10", + "ifaddr>=0.2.0", + "msgspec>=0.18.6", + "pyzmq>=25.1.0", + "SQLAlchemy>=2.0.21", + "SQLAlchemy_Utils>=0.41.1", + "tornado>=6.3.3" + ], + license="BSD-3-Clause", + license_files=('license.txt', 'licenses/param-LICENSE.txt', 'licenses/pyro-LICENSE.txt'), + keywords=["data-acquisition", "zmq-rpc", "SCADA/IoT", "Web of Things"] ) \ No newline at end of file