diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..10c9e08d --- /dev/null +++ b/.gitignore @@ -0,0 +1,124 @@ +# local env files +.env.local +.env.*.local + +# OS files +.DS_Store + +# Editor directories and files +.idea +.vscode +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +dist/ +downloads/ +eggs/ +.eggs/ +sdist/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..93f59b8a --- /dev/null +++ b/LICENSE @@ -0,0 +1,13 @@ +Copyright 2022 Kitware Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. \ No newline at end of file diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 00000000..b36543b9 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1 @@ +include trame/LICENSE diff --git a/README.rst b/README.rst new file mode 100644 index 00000000..5a6dfb1f --- /dev/null +++ b/README.rst @@ -0,0 +1,140 @@ +trame: simple, powerful, innovative +=========================================================== + +**trame** - a web framework that weaves together open source components into customized visual analytics easily. + +**trame** is French for + +* the core that ties things together +* a guide providing the essence of a task + +.. image:: https://kitware.github.io/trame/examples/MultiFilter.jpg + :alt: Welcome to trame and 3D visualization + +With **trame**, create stunning, interactive web applications compactly and intuitively. + +|image_1| |image_2| |image_3| + +.. |image_1| image:: https://kitware.github.io/trame/examples/CarotidFlow.jpg + :width: 30% +.. |image_2| image:: https://kitware.github.io/trame/examples/UberPickupsNYC.jpg + :width: 30% +.. |image_3| image:: https://kitware.github.io/trame/examples/FiniteElementAnalysis.jpg + :width: 30% + +3D Visualization +----------------------------------------------------------- + +With best-in-class VTK and ParaView platforms at its core, **trame** provides complete control of 3D visualizations and data movements. +Developers benefit from a write-once environment while **trame** simply exposes both local and remote rendering through a single method. + +Rich Features +----------------------------------------------------------- + +**trame** leverages existing libraries and tools such as Vuetify, Altair, Vega, deck.gl, VTK, ParaView, and more, to create vivid content for visual analytics applications. + +Problem Focused +----------------------------------------------------------- + +By relying simply on Python, **trame** focuses on one's data and associated analysis and visualizations while hiding the complications of web app development. + +Desktop to cloud +----------------------------------------------------------- + +The resulting **trame** applications can act as local desktop applications or remote cloud applications both accessed through a browser. + + +Installing +----------------------------------------------------------- + +trame can be installed with `pip `_: + +.. code-block:: bash + + pip install --upgrade trame --pre + +Usage +----------------------------------------------------------- + +The `Trame Tutorial `_ is the place to go to learn how to use the library and start building your own application. + +The `API Reference `_ documentation provides API-level documentation. + + +License +----------------------------------------------------------- + +trame is made available under the Apache License, Version 2.0. For more details, see `LICENSE `_ + + +Community +----------------------------------------------------------- + +`Trame `_ | `Discussions `_ | `Issues `_ | `RoadMap `_ | `Contact Us `_ + +.. image:: https://zenodo.org/badge/410108340.svg + :target: https://zenodo.org/badge/latestdoi/410108340 + + +Enjoying trame? +----------------------------------------------------------- + +Share your experience `with a testimonial `_ or `with a brand approval `_. + + +Optional dependencies +----------------------------------------------------------- + +When installing trame using pip (`pip install trame`) you will get the core infrastructure for any trame application to work but more advanced usage may require additional dependencies. +The list below capture which may need to add depending on your usage: + +* **pywebview** : Needed for desktop usage (--app) +* **jupyterlab** : Needed to run inside jupyter-lab +* **notebook** : Needed to run inside jupyter-notebook +* **requests** : Needed when using remote assets such as GDrive files + + +Environments variables +----------------------------------------------------------- + +* **TRAME_LOG_NETWORK** : Path to log file for capturing network exchange. (default: None) +* **TRAME_WS_MAX_MSG_SIZE** : Maximum size in bytes of any ws message. (default: 10MB) +* **TRAME_WS_HEART_BEAT** : Time in second before assuming the server is non-responsive. (default: 30s) + + +Life cycle callbacks +-------------------------------------------------------------------------- + +Life cycle events are directly managed on the application controller +and are prefixed with `on_*`. + +* **on_server_ready** : All protocols initialized and available for client to connect +* **on_client_connected** : Connection established to server +* **on_client_exited** : Linked to browser "beforeunload" event +* **on_server_exited** : Trame is exiting its event loop + +* **on_server_reload** : If callback registered it is use for reloading server side modules + + +Reserved state entries +-------------------------------------------------------------------------- + +The shared state allow us to synchronize the server with the client. +Rather than creating another mechanism to handle similar needs throughout +the application we purposely reuse that state for internal purpose. +To prevent any conflict with any user we are prefixing our internal +variable with `trame__*`. In general those state values should not be use +or changed by the user except for the one listed below: + +Read/Write: + - **trame__favicon**: Update it to replace the displayed favicon in your + browser. The content needs to be a image encoded url. + - **trame__title**: Update it to replace your page title + (tab name / window name). + +Read-only: + - **trame__busy**: Provide information if we have pending request waiting + for the server to respond. + - **tts**: Template Time Stamp to regenerate sub elements when a template + get's updated. Usually used as `:key="tts"` to force some component + rebuild. \ No newline at end of file diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 00000000..e857f6e2 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,39 @@ +[metadata] +name = trame +version = 2.0.0rc3 +description = Trame, a framework to build applications in plain Python +long_description = file: README.rst +long_description_content_type = text/x-rst +author = Kitware Inc. +license = Apache License 2.0 +classifiers = + Development Status :: 5 - Production/Stable + Environment :: Web Environment + License :: OSI Approved :: Apache Software License + Natural Language :: English + Operating System :: OS Independent + Programming Language :: Python :: 3 :: Only + Topic :: Software Development :: Libraries :: Application Frameworks + Topic :: Software Development :: Libraries :: Python Modules +keywords = + Python + Interactive + Web + Application + Framework + +[options] +packages = find: +include_package_data = True +install_requires = + trame-client + trame-components + trame-deckgl + trame-markdown + trame-matplotlib + trame-plotly + trame-router + trame-server + trame-vega + trame-vtk + trame-vuetify diff --git a/setup.py b/setup.py new file mode 100644 index 00000000..60684932 --- /dev/null +++ b/setup.py @@ -0,0 +1,3 @@ +from setuptools import setup + +setup() diff --git a/trame/LICENSE b/trame/LICENSE new file mode 120000 index 00000000..ea5b6064 --- /dev/null +++ b/trame/LICENSE @@ -0,0 +1 @@ +../LICENSE \ No newline at end of file diff --git a/trame/__init__.py b/trame/__init__.py new file mode 100644 index 00000000..4dc06ec0 --- /dev/null +++ b/trame/__init__.py @@ -0,0 +1,3 @@ +__path__ = __import__("pkgutil").extend_path(__path__, __name__) + +__license__ = "Apache License 2.0" diff --git a/trame/app/__init__.py b/trame/app/__init__.py new file mode 100644 index 00000000..9ac5eaa9 --- /dev/null +++ b/trame/app/__init__.py @@ -0,0 +1,27 @@ +from trame_server import Server +from trame_client import module + +DEFAULT_NAME = "trame" +AVAILABLE_SERVERS = {} + + +def get_server(name=None, create_if_missing=True, **kwargs): + """ + Return a server for serving trame applications. + If a name is given and such server is not available yet, + it will be created otherwise the previsouly created instance will be returned. + """ + if name is None: + name = DEFAULT_NAME + + if name in AVAILABLE_SERVERS: + return AVAILABLE_SERVERS[name] + + if create_if_missing: + server = Server(name, **kwargs) + server.enable_module(module) # Always load html module first + AVAILABLE_SERVERS[name] = server + return server + + # No server available for given name + return None diff --git a/trame/app/asynchronous.py b/trame/app/asynchronous.py new file mode 100644 index 00000000..4fdb3afa --- /dev/null +++ b/trame/app/asynchronous.py @@ -0,0 +1,15 @@ +from trame_server.utils.asynchronous import ( + create_task, + decorate_task, + create_state_queue_monitor_task, + StateQueue, + task, +) + +__all__ = [ + "create_task", + "decorate_task", + "create_state_queue_monitor_task", + "StateQueue", + "task", +] diff --git a/trame/app/dev.py b/trame/app/dev.py new file mode 100644 index 00000000..8ea6eaf7 --- /dev/null +++ b/trame/app/dev.py @@ -0,0 +1,29 @@ +def clear_triggers(server): + names = list(server._triggers.keys()) + for name in names: + fn = server._triggers.pop(name) + server._triggers_fn2name.pop(fn) + print(f"unregister trigger {name}") + + +def clear_change_listeners(server): + server._change_callbacks.clear() + + +def remove_change_listeners(server, *names): + for name in names: + if name in server._change_callbacks: + server._change_callbacks.pop(name) + + +def reload(*reload_list): + """ + Helper function use to reload python modules that were passed as + arguments. + + :param reload_list: positional arguments of the modules to reload when the + reload button is pressed. + :type reload_list: python modules + """ + for m in reload_list: + m.__loader__.exec_module(m) diff --git a/trame/app/jupyter.py b/trame/app/jupyter.py new file mode 100644 index 00000000..16cb8dee --- /dev/null +++ b/trame/app/jupyter.py @@ -0,0 +1,56 @@ +import asyncio +from trame.app import get_server +from IPython import display +from trame_server.utils.asynchronous import handle_task_result + + +def show(_server, ui=None, **kwargs): + if isinstance(_server, str): + _server = get_server(_server) + + def on_ready(**_): + params = f"?ui={ui}" if ui else "" + src = f"{kwargs.get('protocol', 'http')}://{kwargs.get('host', 'localhost')}:{_server.port}/index.html{params}" + loop = asyncio.get_event_loop() + loop.call_later(0.1, lambda: display_iframe(src, **kwargs)) + _server.controller.on_server_ready.discard(on_ready) + + if _server._running_stage == 0: + _server.controller.on_server_ready.add(on_ready) + _server.start( + exec_mode="task", + port=0, + open_browser=False, + show_connection_info=False, + disableLogging=True, + timeout=0, + ) + elif _server._running_stage == 1: + _server.controller.on_server_ready.add(on_ready) + elif _server._running_stage == 2: + on_ready() + + +def display_iframe(src, **kwargs): + """Convenience method to display an iframe for the given url source""" + + # Set some defaults. The kwargs can override these. + # width and height are both required. + iframe_kwargs = { + "width": "100%", + "height": 600, + **kwargs, + } + iframe = display.IFrame(src=src, **iframe_kwargs) + return display.display(iframe) + + +def run(name, **kwargs): + """Run and display a Jupyter server proxy process with the given name + + Note that the proxy process must be registered with Jupyter by setting + the `jupyter_serverproxy_servers` entrypoint in its setup.py or setup.cfg + file. + """ + src = f"/{name}" + return display_iframe(src, **kwargs) diff --git a/trame/app/singleton.py b/trame/app/singleton.py new file mode 100644 index 00000000..87aada53 --- /dev/null +++ b/trame/app/singleton.py @@ -0,0 +1,13 @@ +from typing import Type, TypeVar, Generic + +T = TypeVar("T") + + +class Singleton(Generic[T]): + """Singleton decorator""" + + def __init__(self, cls: Type[T]): + self._instance: T = cls() + + def __call__(self) -> T: + return self._instance diff --git a/trame/assets/__init__.py b/trame/assets/__init__.py new file mode 100644 index 00000000..8db66d3d --- /dev/null +++ b/trame/assets/__init__.py @@ -0,0 +1 @@ +__path__ = __import__("pkgutil").extend_path(__path__, __name__) diff --git a/trame/assets/local.py b/trame/assets/local.py new file mode 100644 index 00000000..26be885c --- /dev/null +++ b/trame/assets/local.py @@ -0,0 +1,97 @@ +import base64 +import mimetypes +from pathlib import Path + +mimetypes.init() + + +def to_mime(file_path): + return mimetypes.guess_type(file_path)[0] + + +def to_txt(full_path): + with open(full_path) as txt_file: + return str(txt_file.read()) + + +def to_base64(full_path): + with open(full_path, "rb") as bin_file: + return base64.b64encode(bin_file.read()).decode("ascii") + + +def to_url(full_path): + encoded = to_base64(full_path) + mime = to_mime(full_path) + return f"data:{mime};base64,{encoded}" + + +class LocalFileManager: + def __init__(self, base_path): + _base = Path(base_path) + + # Ensure directory + if _base.is_file(): + _base = _base.parent + + self._root = Path(str(_base.resolve().absolute())) + self._assests = {} + + def __getitem__(self, name): + return self._assests.get(name) + + def __getattr__(self, name): + return self._assests.get(name) + + def _to_path(self, file_path): + _input_file = Path(file_path) + if _input_file.is_absolute(): + return str(_input_file.resolve().absolute()) + + return str(self._root.joinpath(file_path).resolve().absolute()) + + def base64(self, key, file_path=None): + if file_path is None: + file_path, key = key, file_path + + data = to_base64(self._to_path(file_path)) + + if key is not None: + self._assests[key] = data + + return data + + def url(self, key, file_path): + if file_path is None: + file_path, key = key, file_path + + data = to_url(self._to_path(file_path)) + + if key is not None: + self._assests[key] = data + + return data + + def txt(self, key, file_path): + if file_path is None: + file_path, key = key, file_path + + data = to_txt(self._to_path(file_path)) + + if key is not None: + self._assests[key] = data + + return data + + @property + def assets(self): + return self._assests + + def get_assets(self, *keys): + if len(keys) == 0: + return self.assets + + _assets = {} + for key in keys: + _assets[key] = self._assests.get(key) + + return _assets diff --git a/trame/assets/remote.py b/trame/assets/remote.py new file mode 100644 index 00000000..e563efe7 --- /dev/null +++ b/trame/assets/remote.py @@ -0,0 +1,102 @@ +import os +from urllib.error import HTTPError +from urllib.request import urlretrieve + + +def download_file_from_google_drive(id, destination): + import requests + + URL = "https://docs.google.com/uc" + + session = requests.Session() + response = session.get( + URL, params={"id": id, "confirm": "t", "export": "download"}, stream=True + ) + token = get_confirm_token(response) + + if token: + params = {"id": id, "confirm": token} + response = session.get(URL, params=params, stream=True) + + save_response_content(response, destination) + + +def get_confirm_token(response): + for key, value in response.cookies.items(): + if key.startswith("download_warning"): + return value + + return None + + +def save_response_content(response, destination): + CHUNK_SIZE = 32768 + + with open(destination, "wb") as f: + for chunk in response.iter_content(CHUNK_SIZE): + if chunk: # filter out keep-alive new chunks + f.write(chunk) + + +class AbstractRemoteFile: + def __init__(self, local_path=None, local_base=None): + # setup base path + self._base = os.getcwd() + if local_base is not None: + if os.path.exists(local_base): + if os.path.isfile(local_base): + self._base = os.path.abspath(os.path.dirname(local_base)) + else: + self._base = os.path.abspath(local_base) + else: + self._base = os.path.abspath(local_base) + + # setup local path + self._file_path = local_path + if not os.path.isabs(local_path): + self._file_path = os.path.abspath(os.path.join(self._base, local_path)) + + # Make sure target directory exists + parent_dir = os.path.dirname(self._file_path) + if not os.path.exists(parent_dir): + os.makedirs(parent_dir) + + @property + def local(self): + return os.path.exists(self._file_path) + + def fetch(self): + pass + + @property + def path(self): + if not self.local: + self.fetch() + + return self._file_path + + +class GoogleDriveFile(AbstractRemoteFile): + def __init__(self, local_path=None, google_id=None, local_base=None): + super().__init__(local_path, local_base) + self._gid = google_id + + def fetch(self): + try: + print(f"Downloading:\n - {self._gid}\n - to {self._file_path}") + download_file_from_google_drive(self._gid, self._file_path) + except HTTPError as e: + print(RuntimeError(f"Failed to download {self._gid}. {e.reason}")) + + +class HttpFile(AbstractRemoteFile): + def __init__(self, local_path=None, remote_url=None, local_base=None): + super().__init__(local_path, local_base) + self._url = remote_url + + def fetch(self): + try: + print(f"Downloading:\n - {self._url}\n - to {self._file_path}") + urlretrieve(self._url, self._file_path) + except HTTPError as e: + print(RuntimeError(f"Failed to download {self._url}. {e.reason}")) diff --git a/trame/modules/__init__.py b/trame/modules/__init__.py new file mode 100644 index 00000000..8db66d3d --- /dev/null +++ b/trame/modules/__init__.py @@ -0,0 +1 @@ +__path__ = __import__("pkgutil").extend_path(__path__, __name__) diff --git a/trame/tools/__init__.py b/trame/tools/__init__.py new file mode 100644 index 00000000..8db66d3d --- /dev/null +++ b/trame/tools/__init__.py @@ -0,0 +1 @@ +__path__ = __import__("pkgutil").extend_path(__path__, __name__) diff --git a/trame/tools/www.py b/trame/tools/www.py new file mode 100644 index 00000000..94c308c2 --- /dev/null +++ b/trame/tools/www.py @@ -0,0 +1,30 @@ +import argparse +import importlib +from trame.app import get_server + + +def enable_modules(_server, *names): + for module_name in names: + m = importlib.import_module(f"trame.modules.{module_name}") + _server.enable_module(m) + + +def main(): + parser = argparse.ArgumentParser(description="Client generator for trame") + + parser.add_argument( + "--output", + help="Directory to fill with trame client code", + required=True, + ) + + args, module_names = parser.parse_known_args() + + server = get_server("www-generator") + enable_modules(server, "www") + enable_modules(server, *module_names) + server.write_www(args.output) + + +if __name__ == "__main__": + main() diff --git a/trame/ui/__init__.py b/trame/ui/__init__.py new file mode 100644 index 00000000..5284146e --- /dev/null +++ b/trame/ui/__init__.py @@ -0,0 +1 @@ +__import__("pkg_resources").declare_namespace(__name__) diff --git a/trame/widgets/__init__.py b/trame/widgets/__init__.py new file mode 100644 index 00000000..8db66d3d --- /dev/null +++ b/trame/widgets/__init__.py @@ -0,0 +1 @@ +__path__ = __import__("pkgutil").extend_path(__path__, __name__)