pax_global_header00006660000000000000000000000064150633124300014507gustar00rootroot0000000000000052 comment=960f4f88adb29f6bbdd2167686e6f1759fe9e2a5 trame-server-3.6.1/000077500000000000000000000000001506331243000141325ustar00rootroot00000000000000trame-server-3.6.1/.codecov.yml000066400000000000000000000001031506331243000163470ustar00rootroot00000000000000comment: false coverage: status: project: off patch: off trame-server-3.6.1/.coveragerc000066400000000000000000000000471506331243000162540ustar00rootroot00000000000000[run] omit = *docs*, *tests*, setup.py trame-server-3.6.1/.flake8000066400000000000000000000003321506331243000153030ustar00rootroot00000000000000[flake8] # Just assume black did a good job with the line lengths ignore = E501 # Black sometimes conflicts with flake8 here # Ignore white space before colon and after binary operator extend-ignore = E203, W503 trame-server-3.6.1/.github/000077500000000000000000000000001506331243000154725ustar00rootroot00000000000000trame-server-3.6.1/.github/dependabot.yml000066400000000000000000000003401506331243000203170ustar00rootroot00000000000000version: 2 updates: # Maintain dependencies for GitHub Actions - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" groups: actions: patterns: - "*" trame-server-3.6.1/.github/workflows/000077500000000000000000000000001506331243000175275ustar00rootroot00000000000000trame-server-3.6.1/.github/workflows/test_and_release.yml000066400000000000000000000046061506331243000235610ustar00rootroot00000000000000name: Test and Release on: push: branches: [master] pull_request: branches: [master] concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: pre-commit: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - uses: actions/setup-python@v6 with: python-version: "3.9" # Install and run pre-commit - run: | pip install pre-commit pre-commit install pre-commit run --all-files pytest: name: Pytest ${{ matrix.config.name }} runs-on: ${{ matrix.config.os }} strategy: fail-fast: false matrix: python-version: ["3.10"] config: - { name: "Linux", os: ubuntu-latest } - { name: "MacOSX", os: macos-latest } - { name: "Windows", os: windows-latest } defaults: run: shell: bash steps: - name: Checkout uses: actions/checkout@v5 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - name: Install and Run Tests run: | pip install ".[dev]" pip install -r tests/requirements.txt # Run the tests with coverage so we get a coverage report too pip install coverage coverage run --omit "*/tests/*" --source . -m pytest . # Print the coverage report coverage report -m - name: Upload Coverage to Codecov uses: codecov/codecov-action@v5.5.1 with: token: ${{ secrets.CODECOV_TOKEN }} release: needs: [pre-commit, pytest] runs-on: ubuntu-latest if: github.event_name == 'push' environment: name: pypi url: https://pypi.org/p/trame-server permissions: id-token: write # IMPORTANT: mandatory for trusted publishing contents: write # IMPORTANT: mandatory for making GitHub Releases steps: - name: Checkout uses: actions/checkout@v5 with: fetch-depth: 0 - name: Python Semantic Release id: release uses: relekang/python-semantic-release@v10.4.1 with: github_token: ${{ secrets.GITHUB_TOKEN }} - name: Publish package distributions to PyPI if: steps.release.outputs.released == 'true' uses: pypa/gh-action-pypi-publish@release/v1 trame-server-3.6.1/.gitignore000066400000000000000000000032551506331243000161270ustar00rootroot00000000000000# local env files .env.local .env.*.local # OS files .DS_Store # test file trame_net.log # 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/ trame-server-3.6.1/.pre-commit-config.yaml000066400000000000000000000024361506331243000204200ustar00rootroot00000000000000repos: - repo: https://github.com/codespell-project/codespell rev: v2.1.0 hooks: - id: codespell - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.8.4 hooks: - id: ruff - id: ruff-format - repo: https://github.com/pre-commit/pre-commit-hooks rev: "v5.0.0" hooks: - id: check-added-large-files - id: check-case-conflict - id: check-merge-conflict - id: check-symlinks - id: check-yaml - id: debug-statements - id: end-of-file-fixer exclude: ^trame_server/LICENSE$ - id: mixed-line-ending - id: name-tests-test args: ["--pytest-test-first"] - id: requirements-txt-fixer - id: trailing-whitespace - repo: https://github.com/rbubley/mirrors-prettier rev: "v3.3.3" hooks: - id: prettier types_or: [yaml, markdown, json] - repo: https://github.com/python-jsonschema/check-jsonschema rev: "0.29.4" hooks: - id: check-dependabot - id: check-github-workflows - id: check-readthedocs - repo: https://github.com/pre-commit/pygrep-hooks rev: "v1.10.0" hooks: - id: rst-backticks - id: rst-directive-colons - id: rst-inline-touching-normal ci: autoupdate_commit_msg: "chore: update pre-commit hooks" trame-server-3.6.1/.prettierignore000066400000000000000000000000151506331243000171710ustar00rootroot00000000000000CHANGELOG.md trame-server-3.6.1/.readthedocs.yaml000066400000000000000000000003151506331243000173600ustar00rootroot00000000000000version: 2 build: os: ubuntu-20.04 tools: python: "3.9" sphinx: configuration: docs/source/conf.py python: install: - requirements: docs/requirements.txt - method: pip path: . trame-server-3.6.1/CHANGELOG.md000066400000000000000000000746321506331243000157570ustar00rootroot00000000000000# CHANGELOG ## v3.4.0 (2025-03-10) ### Build System - **deps**: Bump codecov/codecov-action in the actions group ([`0866ed1`](https://github.com/Kitware/trame-server/commit/0866ed1308dd75748ce183cf5522d1e612391a8d)) Bumps the actions group with 1 update: [codecov/codecov-action](https://github.com/codecov/codecov-action). Updates `codecov/codecov-action` from 5.3.1 to 5.4.0 - [Release notes](https://github.com/codecov/codecov-action/releases) - [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/codecov/codecov-action/compare/v5.3.1...v5.4.0) --- updated-dependencies: - dependency-name: codecov/codecov-action dependency-type: direct:production update-type: version-update:semver-minor dependency-group: actions ... Signed-off-by: dependabot[bot] - **deps**: Bump relekang/python-semantic-release in the actions group ([`413f728`](https://github.com/Kitware/trame-server/commit/413f728030c2e9b3e6057a51038d4f7847c190e7)) Bumps the actions group with 1 update: [relekang/python-semantic-release](https://github.com/relekang/python-semantic-release). Updates `relekang/python-semantic-release` from 9.19.1 to 9.21.0 - [Release notes](https://github.com/relekang/python-semantic-release/releases) - [Changelog](https://github.com/python-semantic-release/python-semantic-release/blob/master/CHANGELOG.rst) - [Commits](https://github.com/relekang/python-semantic-release/compare/v9.19.1...v9.21.0) --- updated-dependencies: - dependency-name: relekang/python-semantic-release dependency-type: direct:production update-type: version-update:semver-minor dependency-group: actions ... Signed-off-by: dependabot[bot] - **deps**: Bump relekang/python-semantic-release in the actions group ([`8f90229`](https://github.com/Kitware/trame-server/commit/8f90229a0b1d67209ab69a2ca4a40a344e0e26b7)) Bumps the actions group with 1 update: [relekang/python-semantic-release](https://github.com/relekang/python-semantic-release). Updates `relekang/python-semantic-release` from 9.19.0 to 9.19.1 - [Release notes](https://github.com/relekang/python-semantic-release/releases) - [Changelog](https://github.com/python-semantic-release/python-semantic-release/blob/master/CHANGELOG.rst) - [Commits](https://github.com/relekang/python-semantic-release/compare/v9.19.0...v9.19.1) --- updated-dependencies: - dependency-name: relekang/python-semantic-release dependency-type: direct:production update-type: version-update:semver-patch dependency-group: actions ... Signed-off-by: dependabot[bot] - **deps**: Bump relekang/python-semantic-release in the actions group ([`8169c38`](https://github.com/Kitware/trame-server/commit/8169c38b64927919401154831b86d216f8c6282f)) Bumps the actions group with 1 update: [relekang/python-semantic-release](https://github.com/relekang/python-semantic-release). Updates `relekang/python-semantic-release` from 9.17.0 to 9.19.0 - [Release notes](https://github.com/relekang/python-semantic-release/releases) - [Changelog](https://github.com/python-semantic-release/python-semantic-release/blob/master/CHANGELOG.rst) - [Commits](https://github.com/relekang/python-semantic-release/compare/v9.17.0...v9.19.0) --- updated-dependencies: - dependency-name: relekang/python-semantic-release dependency-type: direct:production update-type: version-update:semver-minor dependency-group: actions ... Signed-off-by: dependabot[bot] - **deps**: Bump relekang/python-semantic-release in the actions group ([`e630f73`](https://github.com/Kitware/trame-server/commit/e630f7387b71777b172544aba87c2f17fac080a0)) Bumps the actions group with 1 update: [relekang/python-semantic-release](https://github.com/relekang/python-semantic-release). Updates `relekang/python-semantic-release` from 9.15.2 to 9.16.1 - [Release notes](https://github.com/relekang/python-semantic-release/releases) - [Changelog](https://github.com/python-semantic-release/python-semantic-release/blob/master/CHANGELOG.md) - [Commits](https://github.com/relekang/python-semantic-release/compare/v9.15.2...v9.16.1) --- updated-dependencies: - dependency-name: relekang/python-semantic-release dependency-type: direct:production update-type: version-update:semver-minor dependency-group: actions ... Signed-off-by: dependabot[bot] - **deps**: Bump the actions group with 2 updates ([`faa7b32`](https://github.com/Kitware/trame-server/commit/faa7b3263baa730f541fd8e8b93641d6d2c294a3)) Bumps the actions group with 2 updates: [codecov/codecov-action](https://github.com/codecov/codecov-action) and [relekang/python-semantic-release](https://github.com/relekang/python-semantic-release). Updates `codecov/codecov-action` from 5.1.2 to 5.3.1 - [Release notes](https://github.com/codecov/codecov-action/releases) - [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/codecov/codecov-action/compare/v5.1.2...v5.3.1) Updates `relekang/python-semantic-release` from 9.16.1 to 9.17.0 - [Release notes](https://github.com/relekang/python-semantic-release/releases) - [Changelog](https://github.com/python-semantic-release/python-semantic-release/blob/master/CHANGELOG.md) - [Commits](https://github.com/relekang/python-semantic-release/compare/v9.16.1...v9.17.0) --- updated-dependencies: - dependency-name: codecov/codecov-action dependency-type: direct:production update-type: version-update:semver-minor dependency-group: actions - dependency-name: relekang/python-semantic-release dependency-type: direct:production dependency-group: actions ... Signed-off-by: dependabot[bot] ### Continuous Integration - Add nox setup ([`b30ecac`](https://github.com/Kitware/trame-server/commit/b30ecac926b126123a1d9c42abaa3055a3e0d6f5)) ### Documentation - **enable_module**: Serve instead of server ([`8221d95`](https://github.com/Kitware/trame-server/commit/8221d95a04b3b28b4b349f514de3a170eb84d6f1)) ### Features - **weakref**: Support weakref.WeakMethod in state.change and ctrl ([`db9c2a4`](https://github.com/Kitware/trame-server/commit/db9c2a4b549f295412ead2bf95ffeb00b68a35c3)) ## v3.3.0 (2025-01-12) ### Chores - Fix pyproject syntax ([`296c4cd`](https://github.com/Kitware/trame-server/commit/296c4cdc6a86aa17ad591d5b80eff5c14714553e)) ### Continuous Integration - Pre-commit exclude changelog ([`3231702`](https://github.com/Kitware/trame-server/commit/3231702c57c8ea55ede3384a815c883a7a7dffea)) - **pyproject**: Add target-version in tool.ruff ([`468c467`](https://github.com/Kitware/trame-server/commit/468c4675bf9ff48b61580bee82b405d6a38f4bd6)) ### Documentation - **state**: Example of state.modified_keys usgae ([`ca5b51e`](https://github.com/Kitware/trame-server/commit/ca5b51e97eab99563547191dc297161f96ef4727)) ### Features - **state**: Add modified_keys accessor ([`12733a1`](https://github.com/Kitware/trame-server/commit/12733a182019aa23dcfe441ba29cbfacb194fdd7)) ### Testing - **state**: Fix possible exec order swap ([`1f8dd8a`](https://github.com/Kitware/trame-server/commit/1f8dd8acfb50b1279944ea74edd8c3d96f4bb02d)) ## v3.2.7 (2025-01-07) ### Bug Fixes - **wslink**: Remove AppKey warning ([`b0bf240`](https://github.com/Kitware/trame-server/commit/b0bf240fe5ffafd9f473d52fd49ccf579605948f)) ### Build System - **deps**: Bump codecov/codecov-action in the actions group ([`cc6023d`](https://github.com/Kitware/trame-server/commit/cc6023d2e31936d04bc0b96b5e4a0f30c32085b1)) Bumps the actions group with 1 update: [codecov/codecov-action](https://github.com/codecov/codecov-action). Updates `codecov/codecov-action` from 4.0.1 to 5.1.2 - [Release notes](https://github.com/codecov/codecov-action/releases) - [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/codecov/codecov-action/compare/v4.0.1...v5.1.2) --- updated-dependencies: - dependency-name: codecov/codecov-action dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions ... Signed-off-by: dependabot[bot] ### Chores - **pyproject**: Fix syntax ([`b6e171a`](https://github.com/Kitware/trame-server/commit/b6e171a0b9d892505abc75080b74a11eed7b645c)) ### Continuous Integration - Pre-commit hooks ([`b6e6b39`](https://github.com/Kitware/trame-server/commit/b6e6b398c227212adf0890e926d483ec6f56643a)) ## v3.2.6 (2025-01-07) ### Bug Fixes - **ruff**: Handle lint suggestion ([`ab94d97`](https://github.com/Kitware/trame-server/commit/ab94d97e3550113908339ffc10f6264a3c76bbe6)) ### Continuous Integration - Improve project automation ([`37d33cc`](https://github.com/Kitware/trame-server/commit/37d33ccdb28391f3a5443a0fb4fcda5e3226a0e7)) - Pre-commit prettier ([`e331a05`](https://github.com/Kitware/trame-server/commit/e331a054ed2913b864daa61f562da103139d79f3)) - **codecov**: Try to fix upload ([`ca4814b`](https://github.com/Kitware/trame-server/commit/ca4814b51e26b625f2e36be4925b64baaf5ec9f1)) ### Documentation - **readme**: Update README.rst ([`73bad67`](https://github.com/Kitware/trame-server/commit/73bad678f16430e5f2457b529eee02e5dd7e3bec)) ### Testing - Improve coverage ([`eba8290`](https://github.com/Kitware/trame-server/commit/eba82908427e9e2e41d1a2a66c12674e4cb9ad67)) - **windows**: Try to fix raise condition on windows ([`24804d6`](https://github.com/Kitware/trame-server/commit/24804d6e21cd737316dd2f29068f3e99e2a198fd)) ## v3.2.5 (2025-01-04) ### Bug Fixes - **enable_module**: Return True if the module was loaded ([`84aa953`](https://github.com/Kitware/trame-server/commit/84aa953f7dbd1c93cc53b5387a140463c6f5776d)) ### Continuous Integration - Fix url ([`85789e1`](https://github.com/Kitware/trame-server/commit/85789e18e564e4a9b0082137ace575f39dff8eb8)) - Improve test ([`bf20291`](https://github.com/Kitware/trame-server/commit/bf20291072a5f1750e1cd885f494d48e55d8c10e)) ### Documentation - Update README.rst ([`203cdf2`](https://github.com/Kitware/trame-server/commit/203cdf2d814f872fd9b420ef23f2a74000cdd4ef)) ### Testing - **state**: Get 100% coverage ([`d9db56f`](https://github.com/Kitware/trame-server/commit/d9db56f63e43dca6c83867f6b854afe84f6878f8)) ## v3.2.4 (2024-12-30) ### Bug Fixes - **ci**: Update to pyproject and ruff ([`406ae4a`](https://github.com/Kitware/trame-server/commit/406ae4ab48fc4a418ae85c17d2420a7067f3c4f2)) ### Documentation - **example**: Add child-server with size observer ([`c3bf08c`](https://github.com/Kitware/trame-server/commit/c3bf08c605bb099fbe9c242923f1a5e1d71002e4)) - **example**: Child server translation ([`c5b917d`](https://github.com/Kitware/trame-server/commit/c5b917d6a01c90c66c4cd520db69743a592f21d4)) - **state**: Add missing doc strings ([`09af332`](https://github.com/Kitware/trame-server/commit/09af332cb198738416d4a0ff1f198f120d0ae554)) ## v3.2.3 (2024-09-19) ### Bug Fixes - **prefix**: Add exclamation to JS delimiters ([`74dfcc6`](https://github.com/Kitware/trame-server/commit/74dfcc635552e6a1a4ab85f2717336d76504de85)) ## v3.2.2 (2024-09-19) ### Bug Fixes - **prefix**: Add comma to JS delimiters ([`3d754ab`](https://github.com/Kitware/trame-server/commit/3d754ab1e8c88e8a28cfb039ac77742011a9ade2)) ### Documentation - **child_server**: Update example ([`9feae26`](https://github.com/Kitware/trame-server/commit/9feae2641e71c3e6ec4aea10811539099dfd1f27)) ## v3.2.1 (2024-09-18) ### Bug Fixes - **child_server**: Fix method binding on event ([`211a196`](https://github.com/Kitware/trame-server/commit/211a196401e6b1200f1f30d54c598462ce0d51b0)) ## v3.2.0 (2024-09-16) ### Continuous Integration - **py310**: Move to 3.10 by default ([`75bc43d`](https://github.com/Kitware/trame-server/commit/75bc43db300ff45b1c6a7b4731a971c6eed30f73)) - **py310**: Move to 3.10 by default ([`1fd48ad`](https://github.com/Kitware/trame-server/commit/1fd48ad88280897bf8674f5024cc8b783087e9d6)) ### Features - **network_completion**: Allow to await network_completion ([`da22615`](https://github.com/Kitware/trame-server/commit/da226154a98335884a6ff52b66af039890d47207)) ## v3.1.2 (2024-09-03) ### Bug Fixes - **perf**: State comparison ([`f23240b`](https://github.com/Kitware/trame-server/commit/f23240b4d9784f560d09709cc0e8d05a8bfe8277)) ## v3.1.1 (2024-09-03) ### Bug Fixes - **client**: Add support for msgpack layer ([`2d89a0e`](https://github.com/Kitware/trame-server/commit/2d89a0ee4d8377bf14827d3a4d5d33d269af296f)) ## v3.1.0 (2024-08-16) ### Features - **http**: Enable header override for built-in server ([`f4467d1`](https://github.com/Kitware/trame-server/commit/f4467d1679d92c998bc4d72473fe0c0a392d2dc8)) ## v3.0.3 (2024-07-02) ### Bug Fixes - **banner**: Provide option to force flush stdout ([`1eabdaf`](https://github.com/Kitware/trame-server/commit/1eabdaf6acda8ce6c6b719ac0c6877eaf4f26051)) ## v3.0.2 (2024-06-19) ### Bug Fixes - **type**: Add some type hints in trame_server.core ([`209d212`](https://github.com/Kitware/trame-server/commit/209d21200b840ed92b1302d10718a2369e366fc2)) - **type**: Use Literal for 'backend' and use type alias ClientType ([`bc7375e`](https://github.com/Kitware/trame-server/commit/bc7375e8fb51b1845d297adb3c77498a96b0cec5)) - **type**: Use Literal for 'exec_mode' and remove TypeAlias not available in Python<3.10 ([`725d1a7`](https://github.com/Kitware/trame-server/commit/725d1a7c33b94970d5469b642caa152a374bd091)) ## v3.0.1 (2024-05-30) ### Bug Fixes - **state**: Allow to clear client cache ([`532080b`](https://github.com/Kitware/trame-server/commit/532080b0cea5a1aea21002ef6314bd19851abbf5)) ## v3.0.0 (2024-04-10) ### Features - **wslink**: Use msgpack and chunking for ws data exchange ([`6cad8a7`](https://github.com/Kitware/trame-server/commit/6cad8a75c9c0b9a6c49f44513729c5a36c710a55)) BREAKING CHANGE: use wslink>=2 that deeply change network handling ### Breaking Changes - **wslink**: Use wslink>=2 that deeply change network handling ## v2.17.3 (2024-04-02) ### Bug Fixes - **wslink**: Prevent fetching v2 ([`e5c0969`](https://github.com/Kitware/trame-server/commit/e5c096976fa1926f62bdec2857ef1310956043be)) ## v2.17.2 (2024-02-16) ### Bug Fixes - **hot_reload**: Make life_cycle work with hot_reload ([`3288b7a`](https://github.com/Kitware/trame-server/commit/3288b7aaa2b57949b64202c48dba98f03e9c5f35)) This also makes _get_decorator_name() more robust and less likely to produce a confusing error. Signed-off-by: Patrick Avery ## v2.17.1 (2024-02-16) ### Bug Fixes - **pywebview**: Add menu support ([`580b012`](https://github.com/Kitware/trame-server/commit/580b01295861e557d523bc2093cb79ee1553084d)) ## v2.17.0 (2024-02-16) ### Continuous Integration - Pre-commit ([`1891427`](https://github.com/Kitware/trame-server/commit/1891427dd59a457c8ef34a87e4996e05db043f54)) ### Features - **pywebview**: Allow method call on window object ([`80edbb7`](https://github.com/Kitware/trame-server/commit/80edbb742e949e2572f3b7db4e211c3a762e0d11)) ## v2.16.1 (2024-02-09) ### Bug Fixes - **hot-reload**: On controller ([`a910340`](https://github.com/Kitware/trame-server/commit/a9103409c0f8969f8fa4cc2b73cedb8d02eefdeb)) ## v2.16.0 (2024-01-29) ### Features - **force_state_push**: Add new server method ([`1e0b043`](https://github.com/Kitware/trame-server/commit/1e0b04357f9d81937ec99493ad176a07f8fe8456)) ## v2.15.0 (2024-01-09) ### Features - **context**: Add server.context, a server side only State object ([`f142a17`](https://github.com/Kitware/trame-server/commit/f142a17e72cb4106e6864b12f6fcf6d70810a7eb)) ## v2.14.0 (2024-01-01) ### Features - **vue3**: Vue3 client is the new default ([`662309e`](https://github.com/Kitware/trame-server/commit/662309e2ef7435b69003c3ea97b96d180fd35064)) ## v2.13.1 (2023-12-08) ### Bug Fixes - **trigger**: Update protocol to use controller function ([`62ac1be`](https://github.com/Kitware/trame-server/commit/62ac1be076643d5c17e0df449996c6055b119272)) ## v2.13.0 (2023-12-08) ### Documentation - **README**: Add TRAME_SERVER env variable description ([`22450e2`](https://github.com/Kitware/trame-server/commit/22450e2fe4f9f9db6bdf789e30372a8074ab4b2c)) ### Features - **translator**: Enable namespace child_server ([`61de095`](https://github.com/Kitware/trame-server/commit/61de095dac00ba02fd5b1cbd94d694c82ee022f8)) ### Testing - **translator**: Improve translator tests and avoid client dependency ([`385ce3d`](https://github.com/Kitware/trame-server/commit/385ce3da305318ed81b98be23039b90f871e223a)) ## v2.12.1 (2023-10-31) ### Bug Fixes - **flush**: Make sure server info is flushed ([`49525de`](https://github.com/Kitware/trame-server/commit/49525dedc0ff94020b5f9e9d1dbea3872ba52188)) ## v2.12.0 (2023-09-28) ### Features - **jupyter**: Add support for Jupyter backend ([`2f2aa2b`](https://github.com/Kitware/trame-server/commit/2f2aa2bfed906a8dd6a8d02a58e09cf6c4375bf8)) ## v2.11.7 (2023-07-20) ### Bug Fixes - **client_type**: Allow default to be changed ([`8d99002`](https://github.com/Kitware/trame-server/commit/8d99002a666aa93afee156643bfe1f57b43f8e01)) ### Continuous Integration - Fix version ([`bd31c8b`](https://github.com/Kitware/trame-server/commit/bd31c8bc4f6d70f3b80919afcae4323cd1a889e5)) ## v2.11.6 (2023-07-20) ### Bug Fixes - **client_type**: Expose default client_type ([`7113e12`](https://github.com/Kitware/trame-server/commit/7113e12b142391e667e9db0276f85a4dc87e04a9)) ## v2.11.5 (2023-07-14) ### Bug Fixes - **argparse**: Skip -- when processing trame-args ([`c4333fb`](https://github.com/Kitware/trame-server/commit/c4333fb73f577e954e118f6d32c4888ddfbc82f2)) ## v2.11.4 (2023-06-09) ### Bug Fixes - **backend**: Allow backend selection from TRAME_BACKEND env ([`a160297`](https://github.com/Kitware/trame-server/commit/a16029745404ab0ceab46e50673ba9d10b4610c8)) ## v2.11.3 (2023-06-09) ### Bug Fixes - **info**: Ensure dynamic port to be printed ([`0c7f53a`](https://github.com/Kitware/trame-server/commit/0c7f53a93acef70d3fc3a0a1eaf792a568926b8a)) ## v2.11.2 (2023-05-24) ### Bug Fixes - **reload**: Don't reload state change corountine ([`ba05514`](https://github.com/Kitware/trame-server/commit/ba05514a688b5ac0449d68fb0d056ccbc40a9033)) ## v2.11.1 (2023-05-24) ### Bug Fixes - **hot-reload**: Remove async task from reload ([`cba6a7f`](https://github.com/Kitware/trame-server/commit/cba6a7f0d8a778057c4febf8f6ae52939829b613)) ## v2.11.0 (2023-04-25) ### Features - **py-client**: Add Python client to drive remote state ([`6904605`](https://github.com/Kitware/trame-server/commit/69046058a8d5334192bd07e1de81afb9f35007a5)) ## v2.10.0 (2023-03-27) ### Features - **args**: Allow trame args to be specified separately ([`d2600c3`](https://github.com/Kitware/trame-server/commit/d2600c32a2acb18c7f700239e37581df85c9f57d)) This allows trame arguments to be specified either by a `--trame-args` argument or via a `TRAME_ARGS` environment variable. It still ignores the regular arguments when we are using `pytest`. But having the `TRAME_ARGS` environment variable allows us to specify arguments for trame when using `pytest`. Signed-off-by: Patrick Avery - **ArgumentParser**: Subclass and allow parsing disable ([`22746dd`](https://github.com/Kitware/trame-server/commit/22746dd171f2aca4ea2c6675eefd6ffc242c5f95)) This disables argument parsing if either the environment variable TRAME_ARGS_DISABLED is set or pytest has been loaded into the modules (which, right now, is apparently the best way to determine if pytest is running). This fixes an issue where trame would parse arguments from pytest and fail. Since pytest doesn't allow us to add any non-pytest arguments, disable parsing the arguments if we are using pytest. Fixes: pyvista/pyvista#3973 Signed-off-by: Patrick Avery ## v2.9.1 (2023-02-15) ### Bug Fixes - **on_server_exited**: Run exit tasks till completion for exec_mode=main ([`876d536`](https://github.com/Kitware/trame-server/commit/876d536e9f830feea96c4f60a18710fa5c8839a0)) - **task_funcs**: Allow controller with only task_funcs ([`c6fd518`](https://github.com/Kitware/trame-server/commit/c6fd5189ada5edcdeaa99173badb72c7b7f3e8de)) ## v2.9.0 (2023-02-08) ### Features - **client_type**: Improve module handling to support vue2/3 ([`000899e`](https://github.com/Kitware/trame-server/commit/000899eac77d009281961ad68a98298e03752e42)) ## v2.8.1 (2023-01-27) ### Bug Fixes - **version**: Add trame_server.__version__ ([`f6957f4`](https://github.com/Kitware/trame-server/commit/f6957f4dabee608c288ac841a38360fa286d1d67)) Partially addresses Kitware/trame#183 Signed-off-by: Patrick Avery ## v2.8.0 (2023-01-21) ### Bug Fixes - **controller**: Add @once helper ([`f25db26`](https://github.com/Kitware/trame-server/commit/f25db2636a6fbf7ddf5d0c3e9d8dbda97e471fa3)) ### Features - **on_server_start**: Add new life cycle ([`0db1961`](https://github.com/Kitware/trame-server/commit/0db1961f36eee42dd1a16b0eb496104e8b2332b4)) ## v2.7.2 (2023-01-20) ### Bug Fixes - **dev**: Add hot reloading ([`5884ede`](https://github.com/Kitware/trame-server/commit/5884ede6a2662b830bba6d279d59abab0425e5e7)) This adds a `--hot-reload` option where, if set, controller/state callback functions will be automatically reloaded for every function call. This excludes functions that are located in site-packages directories (which are usually libraries that the user is not currently developing). There is also a `@hot_reload` decorator that may be added to functions as well, which will cause the function to be reloaded every time. This work is largely based off of https://github.com/julvo/reloading, with some major modifications, including adding support for methods. His license is included within the file. Signed-off-by: Patrick Avery ## v2.7.1 (2023-01-20) ### Bug Fixes - Use host argument with wslink ([#8](https://github.com/Kitware/trame-server/pull/8), [`8935e6a`](https://github.com/Kitware/trame-server/commit/8935e6a1c434f5054b1dda89482c34c4e1595276)) Add environment variable fallback for host definition ## v2.7.0 (2023-01-09) ### Features - **ready**: Add ready future on server to await ([`6b72322`](https://github.com/Kitware/trame-server/commit/6b72322c06a5ade065355e2ffbd5d74206a89464)) ## v2.6.1 (2022-12-10) ### Bug Fixes - **corountine**: Remove deprecated API for Py 3.11 ([`82d945d`](https://github.com/Kitware/trame-server/commit/82d945d319c3f8c87c8797f4318e44b964e35e25)) Using inspect.isawaitable rather than asyncio.coroutine fix #7 ## v2.6.0 (2022-12-06) ### Chores - **security**: Fixed Formatting ([`005192d`](https://github.com/Kitware/trame-server/commit/005192d508d9abe1a3d8557637c3e7163d1048f1)) - **security**: Removed trailing whitespace ([`e242fed`](https://github.com/Kitware/trame-server/commit/e242fedaddc068372466e3e472523d45c3330c1d)) ### Features - **security**: Moved authKeyFile argument from wslink to trame-server ([`9c3b6fc`](https://github.com/Kitware/trame-server/commit/9c3b6fc5757fc6ca3ef1954f01216d64919d72e0)) - **security**: Use authKeyFile argument if present ([`5c4fb3b`](https://github.com/Kitware/trame-server/commit/5c4fb3b227e8085073ce15dc3a9b5603db93b426)) ## v2.5.1 (2022-11-09) ### Bug Fixes - **isascii**: Add python3.6 compatible isascii() method ([`a957cdf`](https://github.com/Kitware/trame-server/commit/a957cdf9497aa72b3137cd9e390652311922b285)) If python >= 3.7 is being used, just use the built-in `isascii()` method. But if python < 3.7, we have to use our own. Signed-off-by: Patrick Avery ## v2.5.0 (2022-10-27) ### Features - **state**: Report when state key is not serializable ([`fac8866`](https://github.com/Kitware/trame-server/commit/fac886650d53d052b79d70ac8b99a4847e98ca76)) ## v2.4.1 (2022-10-26) ### Bug Fixes - **file-upload**: Properly filter fields for client sync ([`1656aab`](https://github.com/Kitware/trame-server/commit/1656aab27dddeea1a4128aef6f437a18dde395c6)) fix #3 ## v2.4.0 (2022-10-24) ### Features - **no-http**: Add cmd option to disable HTTP serving ([`d34c471`](https://github.com/Kitware/trame-server/commit/d34c4719faf6ff1dc5d223ee3adbc42fb6d17d7c)) ## v2.3.0 (2022-10-20) ### Bug Fixes - **state**: Better network handling for collaboration ([`b3b0e2f`](https://github.com/Kitware/trame-server/commit/b3b0e2fd3ef324f9512473993aff19f867c8cd61)) ### Features - **state**: Allow state change to be async + add clean method ([`fa41cdd`](https://github.com/Kitware/trame-server/commit/fa41cdd8947319a0c685db9e5b835fca68175295)) ## v2.2.1 (2022-09-27) ### Bug Fixes - **aiohttp.router**: Simplify route management ([`245baaf`](https://github.com/Kitware/trame-server/commit/245baaf682ff27ed46df0ace473c6b8adcefe916)) - **controller**: Add support for async tasks ([`45bf037`](https://github.com/Kitware/trame-server/commit/45bf037889e684044eb1431922b8f40f9621bd67)) ## v2.2.0 (2022-09-22) ### Features - **wslink**: Add lifecycle to allow web server routes to be added ([`9432cc8`](https://github.com/Kitware/trame-server/commit/9432cc855adc7430dc337c4efcd756c356304840)) ## v2.1.6 (2022-08-12) ### Bug Fixes - **wslink**: Handle new --reverse-url cli arg ([`eb4eceb`](https://github.com/Kitware/trame-server/commit/eb4ecebab1c6e87fad501d74afad93a63852e005)) Signed-off-by: Patrick Avery ## v2.1.5 (2022-08-10) ### Bug Fixes - **trigger**: Allow triggers to return something ([`aebfb01`](https://github.com/Kitware/trame-server/commit/aebfb017889d7fc40690925cf457959896fa097a)) ### Chores - **semantic-release**: Bump version to latest ([`efb63ce`](https://github.com/Kitware/trame-server/commit/efb63ce9c624e0a1a9d0f3d85ac0a05b17de90d8)) Signed-off-by: Patrick Avery ### Documentation - **coverage**: Add setup.py to .coveragerc ([`ee4f01b`](https://github.com/Kitware/trame-server/commit/ee4f01b87e7e09c5641f0a0acf027d304b3cef8b)) This should not be included in the coverage since we will not run it with pytest. Signed-off-by: Patrick Avery - **coverage**: Remove codecov PR comment ([`1ae8142`](https://github.com/Kitware/trame-server/commit/1ae81424e2af6fd7b5044da65d66777dddc07a42)) Signed-off-by: Patrick Avery ## v2.1.4 (2022-06-14) ### Bug Fixes - **desktop**: Add support for gui option ([`2d59706`](https://github.com/Kitware/trame-server/commit/2d59706b5bb209ccc181bffaf3c22065060a27fa)) ### Documentation - **codecov**: Create and print coverage report ([`809a6de`](https://github.com/Kitware/trame-server/commit/809a6def95df2ead4d5172fa863b684851384898)) Signed-off-by: Patrick Avery - **codecov**: Show coverage for all source files ([`1c2b98e`](https://github.com/Kitware/trame-server/commit/1c2b98eae2a62860ed37adf3065fe42f5d48d218)) This includes files that were not imported by the tests. Signed-off-by: Patrick Avery - **codecov**: Upload coverage to codecov ([`3a00af1`](https://github.com/Kitware/trame-server/commit/3a00af1cae74858d57e4bb5a7d961ab838ba5aba)) Signed-off-by: Patrick Avery ## v2.1.3 (2022-06-10) ### Bug Fixes - **state.update**: Prevent equal value to trigger change ([`5d2d5e1`](https://github.com/Kitware/trame-server/commit/5d2d5e1563238d995a34b521f85a3fcba716e950)) ## v2.1.2 (2022-06-10) ### Bug Fixes - **state**: Prevent equal value to trigger change ([`4c3165f`](https://github.com/Kitware/trame-server/commit/4c3165f9232d481f085845ed49c0b1c5109c1b81)) ### Documentation - **contributing**: Add CONTRIBUTING.rst ([`27212aa`](https://github.com/Kitware/trame-server/commit/27212aaea6a16843711a37337e4fdc439db5c4bb)) Signed-off-by: Patrick Avery ## v2.1.1 (2022-06-06) ### Bug Fixes - **flush**: Force flush for information print ([`fda6300`](https://github.com/Kitware/trame-server/commit/fda6300d42fa83d1350e8fb75412ec1314b03ba5)) ## v2.1.0 (2022-06-04) ### Features - **ui**: Introduce virtual node manager on server ([`f50551e`](https://github.com/Kitware/trame-server/commit/f50551ee1729204f208bc46f9200f4ad78d1197e)) ## v2.0.2 (2022-05-30) ### Bug Fixes - **state**: No @state.change exec before server ready ([`50acbb5`](https://github.com/Kitware/trame-server/commit/50acbb5cdd867c981ea2b3d62948737c4ee4317c)) ### Chores - Downgrade python semantic release for fix ([`fc07473`](https://github.com/Kitware/trame-server/commit/fc07473e85a56853faa5d55ab4d68b4b39917873)) The newest version of semantic release has a bug that causes it to exit with errors. Downgrade to the latest version without the bug. Signed-off-by: Patrick Avery - Rename publish => release in github action ([`d25c852`](https://github.com/Kitware/trame-server/commit/d25c8523c3810e8239f2883295f2236a4055c83a)) Signed-off-by: Patrick Avery ## v2.0.1 (2022-05-27) ### Bug Fixes - Add github action for semantic release ([`661b74a`](https://github.com/Kitware/trame-server/commit/661b74a658dabf8ed1c837c6a5e4ab53f368210a)) Signed-off-by: Patrick Avery - **async**: Export task decorator ([`0fb23e9`](https://github.com/Kitware/trame-server/commit/0fb23e990985ccaa2761b5b8f4342ea44a476e26)) - **async**: Fix method call ([`ddf6938`](https://github.com/Kitware/trame-server/commit/ddf693883f986ae52b57ef0351479df99f2ed4a2)) - **controller**: Add decorator for 'set' and 'add' ([`28894d3`](https://github.com/Kitware/trame-server/commit/28894d3a6a52e85b63d744181d19d66ab8020819)) - **kwarg**: Improve **kwarg handling in server.start ([`218400d`](https://github.com/Kitware/trame-server/commit/218400d8737e8d92cd601ec07ca3a9d14b451702)) - **vue_use**: Fix typo in 'reduce_vue_use' ([`37823d4`](https://github.com/Kitware/trame-server/commit/37823d4a361f01ca286a5090dbde984a5481c842)) Could not pass options before like this: vue_use = [('trame_component', {...})] - **vue_use**: Reduce duplicate and merge options ([`a8ce28b`](https://github.com/Kitware/trame-server/commit/a8ce28b56090f00c5eddd4c9f8390b63dff6f09a)) ### Chores - **version**: Bump version to publish ([`eb07d9a`](https://github.com/Kitware/trame-server/commit/eb07d9ab5a5599053d0374ea7450459ce57a9daf)) ### Documentation - **api**: Add missing API docstring ([`00e8ad4`](https://github.com/Kitware/trame-server/commit/00e8ad41c1b2c003add6a3229cafbbc5ad2930dc)) - **api**: Update controller api ([`b02b741`](https://github.com/Kitware/trame-server/commit/b02b741d5dd042a384124094625a5695ee0c3a42)) trame-server-3.6.1/CONTRIBUTING.rst000066400000000000000000000021611506331243000165730ustar00rootroot00000000000000============================ Contributing to trame-server ============================ #. Clone the repository using ``git clone`` #. Install pre-commit via ``pip install pre-commit`` #. Run ``pre-commit install`` to set up pre-commit hooks #. Make changes to the code, and commit your changes to a separate branch #. Create a fork of the repository on GitHub #. Push your branch to your fork, and open a pull request Tips #### #. When first creating a new project, it is helpful to run ``pre-commit run --all-files`` to ensure all files pass the pre-commit checks. #. A quick way to fix ``ruff`` issues is by installing ruff (``pip install ruff``) and running the ``ruff check --fix .`` or ``ruff format`` command at the root of your repository. #. A quick way to fix ``codespell`` issues is by installing codespell (``pip install codespell``) and running the ``codespell -w`` command at the root of your directory. #. The `.codespellrc file `_ can be used fix any other codespell issues, such as ignoring certain files, directories, words, or regular expressions. trame-server-3.6.1/LICENSE000066400000000000000000000010511506331243000151340ustar00rootroot00000000000000Copyright 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. trame-server-3.6.1/MANIFEST.in000066400000000000000000000001511506331243000156650ustar00rootroot00000000000000include trame_server/LICENSE include trame_server/utils/banner.txt prune tests prune examples prune docs trame-server-3.6.1/README.rst000066400000000000000000000074541506331243000156330ustar00rootroot00000000000000.. |pypi_download| image:: https://img.shields.io/pypi/dm/trame-server trame-server: server implementation of trame |pypi_download| =========================================================================== .. image:: https://github.com/Kitware/trame-server/actions/workflows/test_and_release.yml/badge.svg :target: https://github.com/Kitware/trame-server/actions/workflows/test_and_release.yml :alt: Test and Release .. image:: https://codecov.io/github/Kitware/trame-server/graph/badge.svg?token=VeOing7nYT :target: https://codecov.io/github/Kitware/trame-server :alt: Code Coverage trame-server is the server implementation of `trame `_. This Python library provide the server implementation of the shared state and controller along with the definition of the web server. The web server aims to be flexible so it can be use within a Jupyter environment or as a standalone desktop application. This package is not supposed to be used by itself but rather should come as a dependency of **trame**. For any specificity, please refer to `the trame documentation `_. Installing ----------------------------------------------------------- trame-server can be installed with `pip `_: .. code-block:: bash pip install --upgrade trame-server 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. **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) * **TRAME_DESKTOP_DEBUG** : If defined it will allow user to inspect the web content in desktop mode * **TRAME_SERVER** : If set to true, this will prevent browser from opening by default **Life cycle callbacks** Life cycle events are directly managed on the application controller and are prefixed with ``on_*``. * **on_server_start** : Executed at server.start() call while passing the server as argument. * **on_server_bind** : WSLinkServer is getting bound to trame so you can attach your own routes. Its instance will be passed as argument to callback. * **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 can be use to hot_reload methods like the UI. License ----------------------------------------------------------- trame-server is made available under the Apache License, Version 2.0. For more details, see `LICENSE `_ Community ----------------------------------------------------------- `Trame `_ | `Discussions `_ | `Issues `_ | `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 `_. trame-server-3.6.1/docs/000077500000000000000000000000001506331243000150625ustar00rootroot00000000000000trame-server-3.6.1/docs/README.md000066400000000000000000000004271506331243000163440ustar00rootroot00000000000000To build the docs, first install the requirements in the `docs/` directory via: ``` pip install -r requirements.txt ``` Then, run the bash script in the `docs/` directory: ``` ./build.sh ``` The sphinx html documentation will be built and placed inside the `dist` directory. trame-server-3.6.1/docs/build.sh000077500000000000000000000002341506331243000165170ustar00rootroot00000000000000#!/usr/bin/env bash # Clean up the current documentation rm -rf dist mkdir dist # Build and open sphinx-build source dist google-chrome ./dist/index.html trame-server-3.6.1/docs/requirements.txt000066400000000000000000000000411506331243000203410ustar00rootroot00000000000000pygments sphinx sphinx-rtd-theme trame-server-3.6.1/docs/source/000077500000000000000000000000001506331243000163625ustar00rootroot00000000000000trame-server-3.6.1/docs/source/api.rst000066400000000000000000000001621506331243000176640ustar00rootroot00000000000000API === .. toctree:: :hidden: :maxdepth: 1 .. automodule:: trame_server :members: :exclude-members: trame-server-3.6.1/docs/source/conf.py000066400000000000000000000020441506331243000176610ustar00rootroot00000000000000# Configuration file for the Sphinx documentation builder. # -- Project information ----------------------------------------------------- project = "Trame Server" copyright = "2022, Kitware" author = "Kitware" # -- General configuration --------------------------------------------------- extensions = [ "sphinx.ext.duration", "sphinx.ext.doctest", "sphinx.ext.autodoc", "sphinx.ext.autosummary", "sphinx.ext.intersphinx", ] intersphinx_mapping = { "python": ("https://docs.python.org/3/", None), "sphinx": ("https://www.sphinx-doc.org/en/master/", None), } intersphinx_disabled_domains = ["std"] templates_path = ["_templates"] # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This pattern also affects html_static_path and html_extra_path. exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] autodoc_member_order = "bysource" # -- Options for HTML output ------------------------------------------------- html_theme = "sphinx_rtd_theme" trame-server-3.6.1/docs/source/index.rst000066400000000000000000000004521506331243000202240ustar00rootroot00000000000000Welcome to Trame Server's documentation! ======================================== **Trame Server** is Trame's server library. .. note:: This project is under active development. .. toctree:: :hidden: :maxdepth: 1 api Trame .. include:: usage.rst trame-server-3.6.1/docs/source/usage.rst000066400000000000000000000006571506331243000202300ustar00rootroot00000000000000Usage ===== .. _installation: Installation ------------ To use Trame Server, first install it using pip: .. code-block:: console (.venv) $ pip install trame-server Running ------- To create a server, you can instantiate a ``Server`` object: .. autoclass:: trame_server.Server The ``name`` parameter can be used to specify the server name. For example: >>> server = Server(name="my_server") >>> server.name 'my_server' trame-server-3.6.1/examples/000077500000000000000000000000001506331243000157505ustar00rootroot00000000000000trame-server-3.6.1/examples/child-server/000077500000000000000000000000001506331243000203375ustar00rootroot00000000000000trame-server-3.6.1/examples/child-server/multi_server_size_observer.py000066400000000000000000000041461506331243000263770ustar00rootroot00000000000000from trame.app import get_server from trame.decorators import TrameApp, change from trame.ui.vuetify3 import SinglePageLayout from trame.widgets import client from trame.widgets import vuetify3 as v3 import trame_server @TrameApp() class MainApp: def __init__(self, server=None): self.server = get_server(server) self._build_ui() SecondApp(self.server) def _build_ui(self): with SinglePageLayout(self.server, full_height=True) as layout: with layout.content: with client.SizeObserver("main_size_observer"): with v3.VContainer(): v3.VBtn( "Open Second App", click="window.open('/?ui=second', target='_blank')", ) @change("main_size_observer") def main_size_observer(self, main_size_observer, **_): print("main_size_observer", main_size_observer) @TrameApp() class SecondApp: def __init__( self, server: trame_server.Server | None = None, template_name="second" ): self.prefix = "" if server: self.prefix = "second_" self.server = server.create_child_server(prefix=self.prefix) else: self.server = get_server(server) self.state = self.server.state self.ctrl = self.server.controller self._build_ui(template_name) # server.state.change("second_size_observer")(lambda **_: print("second size")) @change("second_size_observer") def second_size_observer(self, **_): print("second_size_observer", self.state.second_size_observer) def _build_ui(self, template_name): # self.state.dialog_show = False with SinglePageLayout( self.server, template_name=template_name, full_height=True ) as layout: with layout.content: with client.SizeObserver("second_size_observer"): with v3.VContainer(): v3.VBtn("Hello") def main(): app = MainApp() app.server.start() if __name__ == "__main__": main() trame-server-3.6.1/examples/child-server/multi_server_triggers.py000066400000000000000000000101321506331243000253340ustar00rootroot00000000000000import random from trame.app import get_server from trame.decorators import TrameApp, change, trigger from trame.ui.vuetify3 import SinglePageLayout from trame.widgets import vuetify3 as v3 def build_dialog(): with v3.VDialog(v_model=("dialog_show", False), width=300) as d: v3.VCard(title="Test", text="This is a dialog.") # force state default at exec so translation can work d.html # noqa: B018 @TrameApp() class MainApp: def __init__(self, server=None): self.server = get_server(server) self._build_ui() SecondApp(self.server) def _build_ui(self): with SinglePageLayout(self.server, full_height=True) as layout: with layout.content: build_dialog() v3.VBtn( "Open Second App", click="window.open('/?ui=second', target='_blank')", ) v3.VBtn("Test Trigger", click="trame.trigger('test')") v3.VBtn("Unique Main Trigger", click="trame.trigger('main')") v3.VBtn("Show dialog", click="dialog_show = true") @trigger("test") def test_trigger(self): print("test main") @trigger("main") def main_trigger(self): print("unique main") @TrameApp() class SecondApp: def __init__(self, server=None, template_name="second"): self.prefix = "" if server: self.prefix = "second_" self.server = server.create_child_server(prefix=self.prefix) else: self.server = get_server(server) self.state = self.server.state self.ctrl = self.server.controller self.translator = self.server.translator self.state.random_value = random.random() # timing thing to get name resoved self.server.trigger("second")(self.second_trigger) self._build_ui(template_name) @trigger("test") def test_trigger(self): print("test second") @trigger("second") def second_trigger(self): print("unique second") @change("random_value") def random_value_changed(self, **_): print(f"Random value changed to: {self.state.random_value}") def random_print(self, random_value): print(f"{random_value=}") def _build_ui(self, template_name): # self.state.dialog_show = False with SinglePageLayout( self.server, template_name=template_name, full_height=True ) as layout: with layout.toolbar.clear() as toolbar: toolbar.density = "compact" toolbar.title = "Data Table Example" with layout.content: build_dialog() v3.VBtn("Test Trigger", click=f"trame.trigger('{self.prefix}test')") v3.VBtn("Test Trigger ctrl", click=self.test_trigger) v3.VBtn( "Unique Second Trigger", click=f"trame.trigger('{self.prefix}second')", ) v3.VBtn("Unique Second Trigger ctrl", click=self.second_trigger) v3.VBtn( "Unique Second Trigger fix", click=f"trame.trigger('{self.ctrl.trigger_name(self.second_trigger)}')", ) v3.VBtn( "Show dialog", click=self.translator.translate_js_expression( self.state, "dialog_show = true" ), ) v3.VBtn( "Change random_value", click=self.translator.translate_js_expression( self.state, "random_value = Math.random()" ), ) v3.VBtn( "Change random_value (auto translate)", click="random_value = Math.random()", ) v3.VBtn( "Change random_value (arg auto translate)", click=(self.random_print, "[random_value]"), ) def main(): app = MainApp() app.server.start() if __name__ == "__main__": main() trame-server-3.6.1/examples/client-server/000077500000000000000000000000001506331243000205325ustar00rootroot00000000000000trame-server-3.6.1/examples/client-server/README.md000066400000000000000000000002401506331243000220050ustar00rootroot00000000000000Example on how to test this example ```bash python ./server.py --port 1234 --server & python ./client.py --port 1235 --url ws://localhost:1234/ws --server ``` trame-server-3.6.1/examples/client-server/client.py000066400000000000000000000052061506331243000223650ustar00rootroot00000000000000from trame.app import asynchronous, get_server from trame.ui.vuetify import SinglePageLayout from trame.widgets import vuetify from trame_server.client import Client server = get_server(client_type="vue2") state, ctrl = server.state, server.controller server.cli.add_argument("--url") client = Client() state.trame_title = "Client" state.counter = 0 @state.change("counter") def on_change(counter, **_): if counter == client.state.counter: return msg = f"client::counter = {counter}" if state.log is None: state.log = "" state.log += msg + "\n" print(msg) # Push local state to remote server with client.state: client.state.counter = counter async def trigger(*args, **kwargs): msg = f"client::trigger = {args} {kwargs}" if state.log is None: state.log = "" state.log += msg + "\n" print(msg) resp = await client.call_trigger("my_method", args, kwargs) print("Server replied with:", resp) @client.state.change("counter") def on_remote_change(counter, **_): if counter == state.counter: return with state: msg = f"remote::counter = {counter}" if state.log is None: state.log = "" state.log += msg + "\n" print(msg) # Sync local state state.counter = counter @client.state.change("test_file") def on_file(test_file, **_): with state: msg = f"remote::test_file = {test_file.get('name')}" print(list(test_file.keys())) state.log += msg + "\n" print(msg) @client.state.change("attch_data") def on_attachment(attch_data, **_): with state: msg = f"remote::attch_data {attch_data.get('time')}" print(msg) state.log += msg + "\n" @ctrl.add("on_server_ready") def connect_to_server(**_): url = server.cli.parse_args().url print(f"Client connect to {url}") asynchronous.create_task(client.connect(url, secret="wslink-secret")) with SinglePageLayout(server) as layout: layout.title.set_text(state.trame_title) with layout.toolbar as toolbar: vuetify.VSpacer() toolbar.add_child("{{ counter }}") vuetify.VSpacer() vuetify.VBtn("-", click="counter--") vuetify.VBtn("+", click="counter++") vuetify.VBtn( "fn", click=(trigger, "[counter, 2]", "{a: 2 * counter, b: 3 * counter}") ) with layout.content: with vuetify.VContainer(fluid=True): vuetify.VTextarea( v_model=("log", ""), outlined=True, clearable=True, auto_grow=True, ) if __name__ == "__main__": server.start() trame-server-3.6.1/examples/client-server/server.py000066400000000000000000000040321506331243000224110ustar00rootroot00000000000000import time from pathlib import Path from trame.app import get_server from trame.ui.vuetify import SinglePageLayout from trame.widgets import vuetify server = get_server(client_type="vue2") state, ctrl = server.state, server.controller state.trame_title = "Server" state.counter = 0 state.attch_data = None @state.change("test_file") def on_file(test_file, **_): if test_file is not None: print("Got file...", test_file.get("name")) else: print("No file...") @state.change("counter") def on_change(counter, **_): msg = f"server::counter = {counter}" if state.log is None: state.log = "" state.log += msg + "\n" print(msg) @ctrl.trigger("my_method") def on_method(*args, **kwargs): msg = f"server::method {args} {kwargs}" if state.log is None: state.log = "" state.log += msg + "\n" print(msg) return f"It is {time.time()}s" def test_attachment(): content = Path(__file__) state.attch_data = { "id": state.counter, "time": time.time(), "big": server.protocol.addAttachment(content.read_bytes()), } with SinglePageLayout(server) as layout: layout.title.set_text(state.trame_title) with layout.toolbar as toolbar: vuetify.VSpacer() toolbar.add_child("{{ counter }}") vuetify.VSpacer() vuetify.VBtn("Test attachment", click=test_attachment) vuetify.VFileInput( v_model=("test_file", None), dense=True, hide_details=True, style="max-width: 300px;", classes="mx-2", ) vuetify.VBtn("-", click="counter--") vuetify.VBtn("+", click="counter++") vuetify.VBtn("fn", click="trigger('my_method', [counter, 2], {a: 1, b: 2})") with layout.content: with vuetify.VContainer(fluid=True): vuetify.VTextarea( v_model=("log", ""), outlined=True, clearable=True, auto_grow=True, ) if __name__ == "__main__": server.start() trame-server-3.6.1/examples/client-state/000077500000000000000000000000001506331243000203445ustar00rootroot00000000000000trame-server-3.6.1/examples/client-state/clear_client_state.py000066400000000000000000000013421506331243000245420ustar00rootroot00000000000000from pathlib import Path from trame.app import get_server from trame.ui.html import DivLayout from trame.widgets import html server = get_server() state = server.state server.enable_module( { "serve": { "__test": str(Path(__file__).parent.resolve()), }, "scripts": ["__test/stateUpdateListener.js"], } ) state.msg = "Something" state.change_count = 0 def update_state(): server.clear_state_client_cache("msg") state.dirty("msg") with DivLayout(server): html.Button("A", click="msg = 'something A'") html.Button("B", click="msg = 'something B'") html.Button("Force", click=update_state) html.Div("MSG={{ msg }} - Change count={{ change_count }}") server.start() trame-server-3.6.1/examples/client-state/stateUpdateListener.js000066400000000000000000000003001506331243000246640ustar00rootroot00000000000000trame.state.addListener(({ type, keys }) => { if (type === "dirty-state" && keys.includes("msg")) { trame.state.set("change_count", trame.state.get("change_count") + 1); } }); trame-server-3.6.1/examples/dataclass_typed_state.py000066400000000000000000000153771506331243000227030ustar00rootroot00000000000000""" Usage example of the trame_server.utils.typed_state.TypedState class. This class provides a convenient way to create two-way bindings between a trame state and a dataclass. """ from dataclasses import dataclass, field from datetime import datetime, time from enum import Enum, auto from uuid import UUID, uuid4 from trame.app import get_server from trame.ui.vuetify3 import SinglePageLayout from trame.widgets import vuetify3 as vuetify from trame_server.utils.typed_state import ( DefaultEncoderDecoder, TypedState, ) server = get_server(client_type="vue3") state, ctrl = server.state, server.controller class Priority(Enum): LOW = auto() MEDIUM = auto() HIGH = auto() def current_time() -> time: return datetime.now().time() @dataclass class Inner: """ Dataclasses can define default values. The default values will be available in the state as default values. """ text_value: str = "" priority: Priority = Priority.MEDIUM tags: list[str] = field(default_factory=list) selected_time: time = field(default_factory=current_time) @dataclass class TrameState: """ Dataclasses can be nested and support various types, including enums and UUIDs. """ inner: Inner = field(default_factory=Inner) last_inner_update: datetime = field(default_factory=datetime.now) task_id: UUID = field(default_factory=uuid4) class CustomEncoder(DefaultEncoderDecoder): def encode(self, obj): if isinstance(obj, time): return obj.isoformat(timespec="minutes") return super().encode(obj) # Create TypedState instance with custom encoder typed_state = TypedState(state, TrameState, encoders=[CustomEncoder()]) # Nested names will contain the nested field names automatically. print(f"State field path: {typed_state.name.inner.text_value}") # Nested instances can be split into separate typed states if needed inner_state = typed_state.get_sub_state(typed_state.name.inner) assert isinstance(inner_state, TypedState) assert inner_state.name.text_value == typed_state.name.inner.text_value # State change callbacks def on_text_change(text_value): print(f"Current text: {text_value}") def on_priority_change(priority): print(f"Priority changed to: {priority}") def on_task_update(text_value: str, priority: Priority, tags: list[str]): print(f"Task updated - Text: {text_value}, Priority: {priority}, Tags: {tags}") def on_inner_data_change(inner_data: Inner): # When binding nested dataclass, the callback uses typed_state.data.inner assert inner_data == typed_state.data.inner # Nested dataclasses can be converted back to a dataclass using the as_dataclass method print(f"Inner data changed: {TypedState.as_dataclass(inner_data)}") typed_state.data.last_inner_update = datetime.now() def on_time_change(last_modified_time: time): print(f"Selected time changed: {last_modified_time}") # TypedState provides a bind_changes method to bind state changes to strongly typed callbacks # Notice that the key binding is not limited to dataclass leaf but includes nested classes providing flexibility to # the binding approach. typed_state.bind_changes( { typed_state.name.inner.text_value: on_text_change, typed_state.name.inner.priority: on_priority_change, typed_state.name.inner.selected_time: on_time_change, ( typed_state.name.inner.text_value, typed_state.name.inner.priority, typed_state.name.inner.tags, ): on_task_update, (typed_state.name.inner,): on_inner_data_change, } ) @ctrl.add("reset_task") def reset_task(): # Dataclass values can be set from a dataclass instance using the TypedState.from_dataclass method TypedState.from_dataclass(typed_state.data.inner, Inner()) typed_state.data.task_id = uuid4() with SinglePageLayout(server) as layout: with layout.content: with vuetify.VContainer( fluid=True, classes="d-flex justify-center align-center", style="height: 100%;", ): with vuetify.VCard(style="max-width: 500px;"): vuetify.VCardTitle("TypedState trame example") with vuetify.VCardText(): # Task description input vuetify.VTextField( v_model=(typed_state.name.inner.text_value,), label="Task Description", outlined=True, ) # Priority selector # Typed state encoder can be used to encode data in a consistent way for reading in the callbacks. # Here the VSelect PRIORITY Enum value will be encoded by the typed_state encoder. vuetify.VSelect( v_model=(typed_state.name.inner.priority,), items=( "options", typed_state.encode( [{"text": p.name.title(), "value": p} for p in Priority] ), ), item_title="text", item_value="value", label="Priority", outlined=True, ) # Tags input vuetify.VCombobox( v_model=(typed_state.name.inner.tags,), label="Tags", multiple=True, chips=True, clearable=True, outlined=True, style="width: 100%; max-width: 100%;", ) # Time picker vuetify.VTextField( v_model=(typed_state.name.inner.selected_time,), label="Selected Time", type="time", outlined=True, ) # Display current task ID vuetify.VTextField( v_model=(typed_state.name.task_id,), label="Task ID", readonly=True, outlined=True, ) # Display last modification date vuetify.VTextField( v_model=(typed_state.name.last_inner_update,), label="Last Updated", readonly=True, outlined=True, ) with vuetify.VCardActions(): vuetify.VBtn( "Reset Task", color="primary", click=ctrl.reset_task, ) server.start() trame-server-3.6.1/examples/modified_keys.py000066400000000000000000000026521506331243000211420ustar00rootroot00000000000000from trame.app import get_server from trame.ui.vuetify import SinglePageLayout from trame.widgets import vuetify2 as vuetify server = get_server(client_type="vue2") state, ctrl = server.state, server.controller state.field1 = 1 state.field2 = 2 state.field3 = 3 VAR_NAMES = ["field1", "field2", "field3"] @state.change(*VAR_NAMES) def change_detected(**_): print(f"Triggered because {list(state.modified_keys)} changed") for n in state.modified_keys: print(f"{n} = {state[n]}") with SinglePageLayout(server) as layout: with layout.content: with vuetify.VContainer( fluid=True, classes="d-flex justify-center align-center", style="height: 100%;", ): with vuetify.VCard(style="max-width: 400px;"): vuetify.VCardTitle("VCard") with vuetify.VCardText(): vuetify.VTextField( v_model=("field1",), label="Field 1", outlined=True, ) vuetify.VTextField( v_model=("field2",), label="Field 2", outlined=True, ) vuetify.VTextField( v_model=("field3",), label="Field 3", outlined=True, ) server.start() trame-server-3.6.1/examples/start-stop-server/000077500000000000000000000000001506331243000213745ustar00rootroot00000000000000trame-server-3.6.1/examples/start-stop-server/app.py000066400000000000000000000024261506331243000225320ustar00rootroot00000000000000# /// script # requires-python = ">=3.10" # dependencies = [ # "trame", # "trame-vtk", # "trame-vuetify", # ] # /// from trame.app import TrameApp, demo from trame.ui.html import DivLayout from trame.widgets import html class App(TrameApp): def __init__(self, server=None): super().__init__(server) self.sub_app = demo.Cone("internal") self.task = None self._build_ui() async def start(self): if self.state.running: return self.sub_app.server.start(exec_mode="task", port=0) await self.sub_app.server.ready self.state.running = True port = self.sub_app.server.port self.state.url = f"http://localhost:{port}/" async def stop(self): if not self.state.running: return await self.sub_app.server.stop() self.state.running = False self.state.url = "" def _build_ui(self): with DivLayout(self.server) as self.ui: html.Button("Stop", click=self.stop, v_if=("running", False)) html.Button("Start", click=self.start, v_else=True) html.A("{{ url }}", href=("url", ""), v_show="running", target="_blank") def main(): app = App() app.server.start() if __name__ == "__main__": main() trame-server-3.6.1/noxfile.py000066400000000000000000000005001506331243000161430ustar00rootroot00000000000000import nox @nox.session(python=["3.8", "3.9", "3.10", "3.11", "3.12"]) def tests(session): session.install(".[dev]") session.install("-r", "./tests/requirements.txt") session.run("pytest") @nox.session def lint(session): session.install(".[dev]") session.run("pre-commit", "run", "--all-files") trame-server-3.6.1/pyproject.toml000066400000000000000000000054051506331243000170520ustar00rootroot00000000000000[project] name = "trame-server" version = "3.6.1" description = "Internal server side implementation of trame" authors = [ {name = "Kitware Inc."}, ] dependencies = [ "wslink>=2.2.2,<3", "more-itertools", ] requires-python = ">=3.7" readme = "README.rst" license = {text = "Apache License 2.0"} keywords = ["Python", "Interactive", "Web", "Application", "Framework"] 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", ] [project.optional-dependencies] dev = [ "pre-commit", "ruff", "pytest", "pytest-asyncio", "nox", ] [build-system] requires = ['setuptools'] build-backend = 'setuptools.build_meta' [tool.setuptools.packages.find] where = ["."] [tool.semantic_release] version_toml = [ "pyproject.toml:project.version", ] build_command = """ python -m venv .venv source .venv/bin/activate pip install -U pip build python -m build . """ [tool.pytest.ini_options] log_cli_level = "INFO" xfail_strict = true minversion = "6.0" addopts = ["-ra", "--strict-config", "--strict-markers"] filterwarnings = ["error"] testpaths = [ "tests", ] [tool.ruff] line-length = 88 indent-width = 4 target-version = "py37" [tool.ruff.lint] extend-select = [ "ARG", # flake8-unused-arguments "B", # flake8-bugbear "C4", # flake8-comprehensions "EM", # flake8-errmsg "EXE", # flake8-executable "FURB", # refurb "G", # flake8-logging-format "I", # isort "ICN", # flake8-import-conventions "NPY", # NumPy specific rules "PD", # pandas-vet "PGH", # pygrep-hooks "PIE", # flake8-pie "PL", # pylint "PT", # flake8-pytest-style "PTH", # flake8-use-pathlib "PYI", # flake8-pyi "RET", # flake8-return "RUF", # Ruff-specific # "T20", # flake8-print "UP", # pyupgrade "YTT", # flake8-2020 ] ignore = [ "ISC001", # Conflicts with formatter "PLR09", # Too many <...> "PLR2004", # Magic value used in comparison ] select = [ "E", "W", "F", "B", "I", "UP", ] fixable = ["ALL"] unfixable = [] [tool.ruff.format] quote-style = "double" indent-style = "space" skip-magic-trailing-comma = false line-ending = "auto" docstring-code-format = true # This only has an effect when the `docstring-code-format` setting is # enabled. docstring-code-line-length = "dynamic" [tool.ruff.lint.pycodestyle] max-line-length = 120 trame-server-3.6.1/tests/000077500000000000000000000000001506331243000152745ustar00rootroot00000000000000trame-server-3.6.1/tests/conftest.py000066400000000000000000000002731506331243000174750ustar00rootroot00000000000000import pytest from trame_server import Server @pytest.fixture(scope="session") def server(): return Server() @pytest.fixture def controller(server): return server.controller trame-server-3.6.1/tests/requirements.txt000066400000000000000000000000511506331243000205540ustar00rootroot00000000000000pytest pytest-asyncio trame trame-client trame-server-3.6.1/tests/test_async.py000066400000000000000000000043131506331243000200230ustar00rootroot00000000000000import asyncio import multiprocessing import time from concurrent.futures import ProcessPoolExecutor import pytest from trame.app import asynchronous, get_server @pytest.mark.asyncio async def test_thread_state_sync(): running_states = [] value_changes = [] MULTI_PROCESS_MANAGER = multiprocessing.Manager() SPAWN = multiprocessing.get_context("spawn") PROCESS_EXECUTOR = ProcessPoolExecutor(1, mp_context=SPAWN) loop = asyncio.get_event_loop() queue = MULTI_PROCESS_MANAGER.Queue() server = get_server("test_thread_state_sync") server.state.running = False server.state.a = 0 @server.state.change("running") def on_running_change(running, **_): running_states.append(running) @server.state.change("a") def on_a_change(a, **_): value_changes.append(a) server.start(exec_mode="task", port=0) assert await server.ready def exec_in_thread(queue): with asynchronous.StateQueue(queue) as state: assert state.queue is queue state.running = True state.update( { "b": 10, "c": 20, } ) for i in range(10): time.sleep(0.1) state.a = i assert state.a == i assert state["a"] == i state.running = False asynchronous.decorate_task( loop.run_in_executor( PROCESS_EXECUTOR, exec_in_thread(queue), ) ) asynchronous.create_state_queue_monitor_task(server, queue) previous_size = len(value_changes) while len(value_changes) < 10: await asyncio.sleep(0.15) assert len(value_changes) > previous_size previous_size = len(value_changes) assert running_states == [False, True, False] assert server.state.b == 10 assert server.state.c == 20 await server.stop() @pytest.mark.asyncio async def test_task_decorator(): bg_update = "idle" @asynchronous.task async def run_something(): nonlocal bg_update bg_update = "ok" run_something() assert bg_update == "idle" await asyncio.sleep(0.1) assert bg_update == "ok" trame-server-3.6.1/tests/test_client.py000066400000000000000000000026741506331243000201740ustar00rootroot00000000000000import asyncio import pytest from trame.app import asynchronous, get_client, get_server @pytest.mark.asyncio async def test_client_connection(): server = get_server("test_client_connection") server.start(exec_mode="task", port=0) assert await server.ready assert server.running url = f"ws://localhost:{server.port}/ws" client = get_client(url) asynchronous.create_task(client.connect(secret="wslink-secret")) for _ in range(10): if client.connected == 2: break await asyncio.sleep(0.1) # should be a noop await client.connect() assert client.connected == 2 @client.change("a") def on_change(a, **_): assert a == 2 @server.trigger("add") def server_method(*args): result = 0 for v in args: result += v return result with server.state as state: state.a = 2 await server.network_completion await asyncio.sleep(0.1) # wait for client network assert server.state.a == client.state.a with client.state as state: state.b = {"a": 1, "b": 2, "_filter": ["b"]} await asyncio.sleep(0.1) # wait for client network assert server.state.b == {"a": 1, "_filter": ["b"]} assert await client.call_trigger("add", [1, 2, 3]) == 6 assert await client.call_trigger("add") == 0 await asyncio.sleep(0.1) await client.diconnect() await asyncio.sleep(0.5) await server.stop() trame-server-3.6.1/tests/test_controller.py000066400000000000000000000064261506331243000211000ustar00rootroot00000000000000import asyncio import logging import weakref import pytest from trame_server.controller import FunctionNotImplementedError logger = logging.getLogger(__name__) def test_define_later(controller): f = controller.func with pytest.raises(FunctionNotImplementedError): f() controller.func = lambda: 3 assert f() == 3 def test_trigger_name(controller): def fn_1(x): return x * 2 def fn_2(x): return x * 3 a_name = controller.trigger_name(fn_1) b_name = controller.trigger_name(fn_2) a_name_next = controller.trigger_name(fn_1) fn_1_r = controller.trigger_fn(a_name) fn_2_r = controller.trigger_fn(b_name) assert a_name != b_name assert a_name == a_name_next assert a_name == "trigger__1" assert fn_1 is fn_1_r assert fn_2 is fn_2_r def test_composition(controller): def fn(): return 1 @controller.add("func_attr") def fn_1(): return 1.5 assert controller.func_attr() == [1.5] @controller.add("func_attr", clear=True) def fn_2(): return 2 @controller.once("func_attr") def fn_3(): return 3 # get f_attr = controller.func f_item = controller["func"] assert f_attr is f_item # set controller.func_attr = fn controller["func_item"] = fn assert controller.func_attr() == [1, 2, 3] assert controller.func_attr() == [1, 2] assert controller.func_attr() == [1, 2] # invalid set with pytest.raises(NameError): controller.trigger = fn def test_weakrefs(controller): class Obj: method_call_count = 0 destructor_call_count = 0 def __del__(self): Obj.destructor_call_count += 1 def fn(self): Obj.method_call_count += 1 print("Obj.fn called") return 1 o = Obj() controller.func.add(weakref.WeakMethod(o.fn)) @controller.add("func") def fn_1(): return 1.5 controller.func() assert Obj.method_call_count == 1 del o assert Obj.destructor_call_count == 1 controller.func() assert Obj.method_call_count == 1 @pytest.mark.asyncio async def test_tasks(controller): @controller.add("async_fn") def sync_fn_add(): return 1 @controller.add_task("async_fn", clear=True) async def async_fn(): await asyncio.sleep(0.01) return 2 @controller.add("async_fn") def sync_fn_add_2(): return 4 @controller.set("async_fn") def set_fn(): return 5 result = controller.async_fn() assert len(result) == 3 assert result[0] == 5 assert result[1] == 4 assert await result[2] == 2 result = controller.async_fn() assert len(result) == 3 with pytest.raises(KeyError): controller.async_fn.remove(async_fn) controller.async_fn.remove_task(async_fn) result = controller.async_fn() assert len(result) == 2 controller.async_fn.discard(async_fn) # no error if missing controller.async_fn.discard(sync_fn_add_2) assert controller.async_fn() == 5 @controller.set("async_fn", clear=True) def set_fn_2(): return 10 assert controller.async_fn() == 10 assert controller.async_fn.exists() controller.async_fn.clear() assert not controller.async_fn.exists() trame-server-3.6.1/tests/test_namespace.py000066400000000000000000000007501506331243000206430ustar00rootroot00000000000000from trame.app import get_server from trame.ui.html import DivLayout from trame.widgets import html def test_namespace_template(): server = get_server("test_namespace_template") child_server = server.create_child_server(prefix="child_") child_server.state.a = 10 layout = DivLayout(child_server) with layout: html.Div("{{ a }}") assert layout.html == "
\n
\n{{ child_a }}\n
\n
" assert child_server.translator("a") == "child_a" trame-server-3.6.1/tests/test_server.py000066400000000000000000000127641506331243000202250ustar00rootroot00000000000000import asyncio import os from pathlib import Path import pytest from trame.app import get_server from trame.modules import www from wslink import register as export_rpc from wslink.websocket import LinkProtocol @pytest.mark.asyncio async def test_child_server(): server = get_server("test_child_server") server.start(exec_mode="task", port=0) child_server = server.create_child_server(prefix="child_") assert await server.ready assert await child_server.ready assert server.running assert child_server.running server.state.a = 1 child_server.state.a = 2 assert server.state.has("a") assert server.state.has("child_a") assert child_server.state.has("a") assert server.state.child_a == child_server.state.a server.state.flush() await server.network_completion assert server.get_server_state() == { "name": "test_child_server", "state": { "a": 1, "child_a": 2, "trame__busy": 1, "trame__client_only": [ "trame__busy", ], "trame__favicon": None, "trame__module_scripts": [], "trame__mousetrap": [], "trame__scripts": [], "trame__styles": [], "trame__title": "Trame", "trame__vue_use": [], }, } await asyncio.sleep(0.1) await server.stop() def test_http_headers(): server = get_server("test_http_headers") server.http_headers.shared_array_buffer = True server.http_headers.set_header("hello", "world") server.http_headers.set_header("hello2", "world2") server.http_headers.remove_header("hello2") assert server.http_headers.headers == { "hello": "world", "Cross-Origin-Opener-Policy": "same-origin", "Cross-Origin-Embedder-Policy": "require-corp", "Access-Control-Allow-Origin": "*", } server.http_headers.shared_array_buffer = False assert server.http_headers.headers == { "hello": "world", } assert server.http_headers.get_header("hello") == "world" def test_enable_module(): server = get_server("test_enable_module") child_server = server.create_child_server(prefix="child_") module = { "scripts": ["fake_url/script.js"], "state": { "a": 1, "b": 2, }, "serve": {"data": "/tmp"}, } assert child_server.enable_module(module) assert child_server.enable_module(www) # should skip since already loaded assert not server.enable_module(module) assert not server.enable_module(www) assert server.state.a == 1 assert server.state.b == 2 assert server.serve == {"data": "/tmp"} @server.change("a") def on_change(**_): pass @server.trigger("my_name") def another_method(): pass assert server.state._change_callbacks["a"][0] == on_change assert server.trigger_name(another_method) == "my_name" assert server.name == "test_enable_module" # default is vue3 assert server.client_type == "vue3" # can still be overridden server.client_type = "vue2" assert server.client_type == "vue2" # Can only be set once with pytest.raises(TypeError): server.client_type = "vue3" def test_cli(): server = get_server("test_cli") child_server = server.create_child_server(prefix="child_") server.cli.add_argument("--data") child_server.cli.add_argument("--data2") args = server.cli.parse_known_args()[0] assert args.data is None assert args.data2 is None @pytest.mark.asyncio async def test_server_start_async(): server = get_server("test_server_start_async") count = 0 def on_start(s): nonlocal count count += 2 assert server is s def on_ready(**_): nonlocal count count += 3 child_server = server.create_child_server(prefix="child_") server.controller.on_server_start.add(on_start) server.controller.on_server_ready.add(on_ready) class TestProto(LinkProtocol): @export_rpc("pytest.protocol.test") def run( self, ): return 11 def register_protocol(protocol): protocol.registerLinkProtocol(TestProto()) child_server.add_protocol_to_configure(register_protocol) server.state.a = 10 child_server.start(exec_mode="task", thread=True, port=0) assert await server.ready assert await child_server.ready # Should be a noop as already started server.start(exec_mode="task", port=0) assert server.protocol_call("pytest.protocol.test") == 11 await asyncio.sleep(0.1) assert count == 5 server.force_state_push("a") server.js_call("js_ref", "method", "arg1", "arg2") server.clear_state_client_cache("a") assert child_server.protocol == server.protocol assert child_server.port == server.port assert child_server.port != 0 await child_server.stop() def test_server_start_sync(): os.environ["TRAME_ARGS"] = "--banner --no-http" os.environ["TRAME_LOG_NETWORK"] = "trame_net.log" server = get_server("test_server_start_sync") server.serve.update( { "data": str(Path(__file__).parent.resolve()), "data2": ( str(Path(__file__).parent.resolve()), "sync", ), # don't remember usage... } ) server.state.a = b"sdkfjhvlskdjhf" server.start(timeout=1, open_browser=False) def test_ui(): server = get_server("test_ui") server.ui.vnode # noqa: B018 trame-server-3.6.1/tests/test_state.py000066400000000000000000000226071506331243000200340ustar00rootroot00000000000000import asyncio import weakref import pytest from trame_server.core import Translator from trame_server.state import State class FakeServer: def __init__(self): self._change_callbacks = {} self._events = [] self.translator = Translator() def _push_state(self, delta_state): self._events.append({"type": "push", "content": {**delta_state}}) def add_event(self, content, type="msg"): self._events.append({"type": type, "content": content}) def __repr__(self) -> str: lines = [""] for line_nb, entry in enumerate(self._events): lines.append(f"{line_nb:6} {entry.get('type'):5}: {entry.get('content')}") lines.append("") return "\n".join(lines) def test_minimum_change_detection(): """ 0 msg : test_minimum_change_detection 1 msg : Before server ready 2 push : {'a': 2} 3 exec : 2 4 msg : After server ready 5 msg : (prev=2) After 2, 3, 3, 4, 4 6 msg : (prev=4) Before Flush 7 push : {'a': 4} 8 exec : 4 9 msg : (prev=4) After Flush 10 msg : Enter with a=4 11 msg : About to exit a=4 12 msg : (prev=4) After with state + same value 13 msg : Enter with a=4 14 msg : About to exit a=5 15 push : {'a': 5} 16 exec : 5 17 msg : (prev=5) After with state + 3,4,5 18 msg : Enter with a=5 19 msg : About to exit a=5 20 msg : (prev=5) After with state + 3,5,4,5 21 msg : Enter with a=5 22 msg : About to exit a=5 23 push : {'b': 3, 'c': 2} 24 msg : (prev=5) After with state + a:1,5 b:2,3 c:3,2 25 msg : Enter with a=5 26 msg : About to exit a=1 27 push : {'a': 1, 'b': 2, 'c': 3} 28 exec : 1 """ server = FakeServer() server.add_event("test_minimum_change_detection") state = State(commit_fn=server._push_state) @state.change("a") def on_change_exec(a, **_): server.add_event(type="exec", content=a) state.a = 1 state.a = 1 state.a = 2 state.a = 2 server.add_event("Before server ready") state.ready() server.add_event("After server ready") state.a = 2 state.a = 3 state.a = 3 state.a = 4 state.a = 4 server.add_event("(prev=2) After 2, 3, 3, 4, 4") # Flush server.add_event("(prev=4) Before Flush") state.flush() server.add_event("(prev=4) After Flush") # This should be a NoOp with state: server.add_event(f"Enter with a={state.a}") state.a = 4 server.add_event(f"About to exit a={state.a}") server.add_event("(prev=4) After with state + same value") with state: server.add_event(f"Enter with a={state.a}") state.a = 3 state.a = 4 state.a = 5 server.add_event(f"About to exit a={state.a}") server.add_event("(prev=5) After with state + 3,4,5") # Even though it changed, finally it is the same value with state: server.add_event(f"Enter with a={state.a}") state.a = 3 state.a = 5 state.a = 4 state.a = 5 server.add_event(f"About to exit a={state.a}") server.add_event("(prev=5) After with state + 3,5,4,5") # Use update to set {a: 1, b: 2, c: 3} with state: server.add_event(f"Enter with a={state.a}") state.update({"a": 1, "b": 2, "c": 3}) state.update({"a": 5, "b": 3, "c": 2}) server.add_event(f"About to exit a={state.a}") server.add_event("(prev=5) After with state + a:1,5 b:2,3 c:3,2") # Use update to set {a: 1, b: 2, c: 3} with state: server.add_event(f"Enter with a={state.a}") state.update({"a": 1, "b": 2, "c": 3}) server.add_event(f"About to exit a={state.a}") # Validate event result = [line.strip() for line in str(server).split("\n")] expected = [ line.strip() for line in str(test_minimum_change_detection.__doc__).split("\n") ] # Grab new scenario output # print(expected) # print("-"*60) # print(result) assert expected == result def test_client_only(): server = FakeServer() server.add_event("test_client_only") state = State(commit_fn=server._push_state) state.ready() state.aa = 1 state.client_only("aa") def test_dict_api(): server = FakeServer() server.add_event("test_dict_api") state = State(commit_fn=server._push_state) state.flush() # should return right away since not ready state.ready() state.a = 1 state.c = [] assert state.has("a") assert not state.has("b") state.setdefault("a", 10) state.setdefault("b", 20) assert state.has("b") assert state.a == 1 assert state.b == 20 assert state.is_dirty_all("a", "b") assert state.is_dirty("a", "b") state.flush() assert not state.is_dirty("a", "b") assert state.setdefault("a", 30) == 1 state.c.append("item") assert not state.is_dirty("c") state.dirty("c") assert state.is_dirty("c") assert state.initial == {"a": 1, "b": 20, "c": ["item"]} @pytest.mark.asyncio async def test_change_detection(): """ 0 msg : test_change_detection 1 push : {'a': 2} 2 msg : a changed (sync) 3 msg : a changed (async) """ server = FakeServer() server.add_event("test_change_detection") state = State(commit_fn=server._push_state, hot_reload=True) state.ready() state.a = 1 @state.change("a") def regular_callback(**__kwargs): server.add_event("a changed (sync)") @state.change("a") async def coroutine_callback(**__kwargs): server.add_event("a changed (async)") assert "a" in state._pending_update state.clean("a") assert "a" not in state._pending_update with state: state.a = 2 await asyncio.sleep(0.1) result = [line.strip() for line in str(server).split("\n")] expected = [line.strip() for line in str(test_change_detection.__doc__).split("\n")] # Grab new scenario output # print(expected) # print("-"*60) # print(result) assert expected == result def test_dunder(): server = FakeServer() server.add_event("test_dunder") state = State(commit_fn=server._push_state, hot_reload=True) state.ready() # get dunder assert state.__dict__ != state.__getattr__("__dict__") # get private (not in state) assert state._something is None # set private (not in state) state._something = 1 assert state._something == 1 state.flush() assert state.to_dict() == {} @pytest.mark.asyncio async def test_modified_keys(): """ 0 msg : test_modified_keys 1 push : {'a': 1, 'b': 2, 'c': 3} 2 msg : get initial a,b,c 3 msg : changed should be => a 4 push : {'a': 2} 5 msg : changed ['a'] 6 msg : End of flush 1 7 msg : changed should be => a, b 8 push : {'a': 3, 'b': 4} 9 msg : changed ['a', 'b'] 10 msg : End of flush 2 11 msg : changed should be => a, b, c 12 push : {'a': 4, 'b': 6, 'c': 6} 13 msg : changed ['a', 'b', 'c'] 14 msg : side effect c => a + b 15 push : {'a': 4.5, 'b': 6.5} 16 msg : changed ['a', 'b'] 17 msg : End of flush 3 """ server = FakeServer() server.add_event("test_modified_keys") state = State(commit_fn=server._push_state) NAMES = ["a", "b", "c"] state.update( { "a": 1, "b": 2, "c": 3, } ) state.ready() server.add_event("get initial a,b,c") await asyncio.sleep(0.01) @state.change(*NAMES) def on_change(**_): m_keys = list(state.modified_keys) m_keys.sort() server.add_event(f"changed {m_keys}") @state.change("c") def trigger_side_effect(**_): server.add_event("side effect c => a + b") state.a += 0.5 state.b += 0.5 with state: state.a += 1 server.add_event("changed should be => a") # yield await asyncio.sleep(0.01) server.add_event("End of flush 1") with state: state.a += 1 state.b += 2 server.add_event("changed should be => a, b") # yield await asyncio.sleep(0.01) server.add_event("End of flush 2") with state: state.a += 1 state.b += 2 state.c += 3 server.add_event("changed should be => a, b, c") # yield await asyncio.sleep(0.1) server.add_event("End of flush 3") result = [line.strip() for line in str(server).split("\n")] expected = [line.strip() for line in str(test_modified_keys.__doc__).split("\n")] # sometime 13 and 14 could have a reverse execution order # as trame does not guaranty the execution order of the callbacks. result.pop(14) result.pop(14) expected.pop(14) expected.pop(14) print(result) assert expected == result def test_weakref(): server = FakeServer() state = State(commit_fn=server._push_state, hot_reload=True) state.ready() class Obj: method_call_count = 0 destructor_call_count = 0 def __del__(self): Obj.destructor_call_count += 1 def fn(self, *_args, **_kwargs): Obj.method_call_count += 1 print("Obj.fn called") return 1 o = Obj() state.a = 1 state.change("a")(weakref.WeakMethod(o.fn)) state.a = 2 state.flush() assert Obj.method_call_count == 1 del o assert Obj.destructor_call_count == 1 state.a = 3 state.flush() assert Obj.method_call_count == 1 trame-server-3.6.1/tests/test_translator.py000066400000000000000000000071441506331243000211040ustar00rootroot00000000000000import logging from trame_server.core import State logger = logging.getLogger(__name__) def test_translation(): root_state = State() a_state = State(internal=root_state) b_state = State(internal=root_state) root_state.ready() a_state.ready() b_state.ready() # Since the translator doesn't have a prefix or any translation, # changing a piece of state on any of the 3 states applies to all root_state.value = 123 assert root_state.value == 123 assert a_state.value == 123 assert b_state.value == 123 a_state.value = 456 assert root_state.value == 456 assert a_state.value == 456 assert b_state.value == 456 b_state.value = 789 assert root_state.value == 789 assert a_state.value == 789 assert b_state.value == 789 # Add translations for a_state and b_state which will cause # "value" to point to a different key a_state.translator.add_translation("value", "a_value") b_state.translator.add_translation("value", "b_value") root_state.value = 123 a_state.value = 456 b_state.value = 789 assert root_state.value == 123 assert a_state.value == 456 assert b_state.value == 789 assert root_state.a_value == 456 assert root_state.b_value == 789 expected_state = { "value": 123, "a_value": 456, "b_value": 789, } assert expected_state == root_state.to_dict() def test_prefix(): root_state = State() a_state = State(internal=root_state) b_state = State(internal=root_state) root_state.ready() a_state.ready() b_state.ready() a_state.translator.set_prefix("a_") b_state.translator.set_prefix("b_") root_state.value = 123 a_state.value = 456 b_state.value = 789 assert root_state.value == 123 assert a_state.value == 456 assert b_state.value == 789 assert root_state.a_value == 456 assert root_state.b_value == 789 expected_state = { "value": 123, "a_value": 456, "b_value": 789, } assert expected_state == root_state.to_dict() def test_prefix_and_translation(): root_state = State() a_state = State(internal=root_state) b_state = State(internal=root_state) root_state.ready() a_state.ready() b_state.ready() # The states will be isolated by default a_state.translator.set_prefix("a_") b_state.translator.set_prefix("b_") # But by adding translations for a_state and b_state # that point to a common key they are still able to interact root_state.translator.add_translation("shared_value", "common_shared_value") a_state.translator.add_translation("shared_value", "common_shared_value") b_state.translator.add_translation("shared_value", "common_shared_value") root_state.value = 123 a_state.value = 456 b_state.value = 789 assert root_state.value == 123 assert a_state.value == 456 assert b_state.value == 789 assert root_state.a_value == 456 assert root_state.b_value == 789 root_state.shared_value = 123 assert root_state.shared_value == 123 assert a_state.shared_value == 123 assert b_state.shared_value == 123 a_state.shared_value = 456 assert root_state.shared_value == 456 assert a_state.shared_value == 456 assert b_state.shared_value == 456 b_state.shared_value = 789 assert root_state.shared_value == 789 assert a_state.shared_value == 789 assert b_state.shared_value == 789 expected_state = { "value": 123, "a_value": 456, "b_value": 789, "common_shared_value": 789, } assert expected_state == root_state.to_dict() trame-server-3.6.1/tests/test_typed_state.py000066400000000000000000000372551506331243000212460ustar00rootroot00000000000000import asyncio from dataclasses import dataclass, field from datetime import date, datetime, time, timezone from enum import Enum, auto from pathlib import Path from unittest.mock import MagicMock from uuid import UUID, uuid4 import pytest from trame_server import Server from trame_server.utils.typed_state import ( DefaultEncoderDecoder, IStateEncoderDecoder, TypedState, ) @pytest.fixture def state(): server = Server() server.state.ready() return server.state @dataclass class MyData: a: int = 1 b: int = 2 @dataclass class MyBiggerData: my_other_data: MyData = field(default_factory=MyData) c: float = 42.0 def test_can_be_constructed_from_simple_dataclass(state): typed_state = TypedState(state, MyData) assert typed_state.data.a == 1 typed_state.data.a = 42 assert state[typed_state.name.a] == 42 def test_can_be_constructed_from_nested_dataclass(state): typed_state = TypedState(state, MyBiggerData) assert typed_state.data.my_other_data.a == 1 typed_state.data.my_other_data.a = 42 assert state[typed_state.name.my_other_data.a] == 42 def test_can_handle_namespace(state): typed_state_ns1 = TypedState(state, MyData, namespace="ns1") typed_state_ns2 = TypedState(state, MyData, namespace="ns2") typed_state_ns1.data.a = 40 typed_state_ns2.data.a = 3 assert typed_state_ns2.data.a != typed_state_ns1.data.a def test_can_be_converted_to_dataclass(state): typed_state = TypedState(state, MyBiggerData) typed_state.data.my_other_data.a = 42 typed_state.data.my_other_data.b = 43 typed_state.data.c = 44 assert typed_state.get_dataclass() == MyBiggerData( my_other_data=MyData(a=42, b=43), c=44 ) assert TypedState.as_dataclass(typed_state.data.my_other_data) == MyData(a=42, b=43) def test_can_be_set_from_data_class(state): typed_state = TypedState(state, MyBiggerData) typed_state.set_dataclass(MyBiggerData(my_other_data=MyData(a=42, b=43), c=44)) assert typed_state.data.my_other_data.a == 42 assert typed_state.data.my_other_data.b == 43 assert typed_state.data.c == 44 TypedState.from_dataclass(typed_state.data.my_other_data, MyData(a=45, b=46)) assert typed_state.data.my_other_data.a == 45 assert typed_state.data.my_other_data.b == 46 def test_can_be_used_to_connect_to_state_changes(state): typed_state = TypedState(state, MyBiggerData, namespace="ns1") mock = MagicMock() @state.change(typed_state.name.my_other_data.a) def on_a_change(**_): mock(typed_state.data.my_other_data.a) typed_state.data.my_other_data.a = 53 state.flush() mock.assert_called_once_with(53) class MyEnum(Enum): A = auto() B = auto() C = auto() @dataclass class DataWithTypes: my_enum: MyEnum my_uuid: UUID my_enum_tuple: tuple[MyEnum] my_enum_list: list[MyEnum] my_enum_dict: dict[MyEnum, MyEnum] my_datetime: datetime my_date: date my_time: time my_path: Path def test_has_default_encoders_and_decoders_for_basic_types(state): typed_state = TypedState(state, DataWithTypes) typed_state.data.my_enum = MyEnum.B uuid = uuid4() typed_state.data.my_uuid = uuid typed_state.data.my_enum_tuple = (MyEnum.A, MyEnum.C) typed_state.data.my_enum_list = [MyEnum.A, MyEnum.C] typed_state.data.my_enum_dict = {MyEnum.A: MyEnum.B} dt = datetime.now(tz=timezone.utc) typed_state.data.my_datetime = dt typed_state.data.my_date = dt.date() typed_state.data.my_time = dt.time() typed_state.data.my_path = Path(__file__) assert typed_state.data.my_enum == MyEnum.B assert typed_state.data.my_uuid == uuid assert typed_state.data.my_enum_tuple == (MyEnum.A, MyEnum.C) assert typed_state.data.my_enum_list == [MyEnum.A, MyEnum.C] assert typed_state.data.my_enum_dict == {MyEnum.A: MyEnum.B} assert typed_state.data.my_datetime == dt assert typed_state.data.my_date == dt.date() assert typed_state.data.my_time == dt.time() assert typed_state.data.my_path == Path(__file__) assert state[typed_state.name.my_enum] == MyEnum.B.value assert state[typed_state.name.my_uuid] == str(uuid) assert state[typed_state.name.my_enum_tuple] == (MyEnum.A.value, MyEnum.C.value) assert state[typed_state.name.my_enum_list] == [MyEnum.A.value, MyEnum.C.value] assert state[typed_state.name.my_enum_dict] == {MyEnum.A.value: MyEnum.B.value} assert state[typed_state.name.my_datetime] == dt.isoformat() assert state[typed_state.name.my_date] == dt.date().isoformat() assert state[typed_state.name.my_time] == dt.time().isoformat() assert state[typed_state.name.my_path] == Path(__file__).as_posix() def test_can_customize_encoders_and_decoders(state): class MyEncoder(IStateEncoderDecoder): def decode(self, _obj, _obj_type: type): return 42 def encode(self, obj): return str(obj) typed_state = TypedState(state, DataWithTypes, encoders=[MyEncoder()]) typed_state.data.my_enum = MyEnum.B assert state[typed_state.name.my_enum] == str(MyEnum.B) assert typed_state.data.my_enum == 42 @dataclass class MyDataWithFactory: a: list[MyEnum] = field(default_factory=lambda: [MyEnum.A, MyEnum.B]) def test_is_compatible_with_default_factory(state): typed_state = TypedState(state, MyDataWithFactory) assert state[typed_state.name.a] == [MyEnum.A.value, MyEnum.B.value] def test_different_data_classes_with_same_name_are_not_mangled_by_default(state): @dataclass class A: a: int = 42 @dataclass class B: a: float = 32.3 a = TypedState(state, A) b = TypedState(state, B) a.data.a = 3 assert b.data.a == 32.3 def test_state_field_is_consistent_for_nested_proxies(state): typed_state = TypedState(state, MyBiggerData) data_fields = TypedState.get_field_proxy_dict(typed_state.data) # root data fields contains : data, data.c, data.my_other_data, data.my_other_data.a, my_other_data.b assert len(data_fields) == 5 # inner field contains: my_other_data, my_other_data.a, my_other_data.b assert len(TypedState.get_field_proxy_dict(typed_state.data.my_other_data)) == 3 def test_can_bind_state_names_to_strongly_typed_state_callback(state): typed_state = TypedState(state, DataWithTypes) mock = MagicMock() typed_state.bind_changes( {(typed_state.name.my_date, typed_state.name.my_time): mock} ) dt = datetime.now(tz=timezone.utc) typed_state.data.my_date = dt.date() typed_state.data.my_time = dt.time() state.flush() mock.assert_called_once_with(dt.date(), dt.time()) def test_can_bind_state_names_to_inner_dataclass_types(state): typed_state = TypedState(state, MyBiggerData) mock = MagicMock() typed_state.bind_changes( {(typed_state.name.my_other_data, typed_state.name.c): mock} ) typed_state.data.my_other_data.a = 54 state.flush() mock.assert_called_once_with(typed_state.data.my_other_data, typed_state.data.c) def test_can_bind_to_full_typed_state(state): typed_state = TypedState(state, MyBiggerData) mock = MagicMock() typed_state.bind_changes({typed_state.name: mock}) typed_state.data.my_other_data.a = 54 state.flush() mock.assert_called_once_with(typed_state.data) def test_data_proxies_key_binding_is_unpacked_for_inner_states(state): typed_state = TypedState(state, MyBiggerData) state_keys = TypedState.get_reactive_state_id_keys( [typed_state.name.my_other_data, typed_state.name.c] ) assert state_keys == [ typed_state.name.my_other_data.a, typed_state.name.my_other_data.b, TypedState.get_state_id(typed_state.name.my_other_data), typed_state.name.c, ] def test_data_proxies_value_key_is_inner_state_id_for_inner_states(state): typed_state = TypedState(state, MyBiggerData) value_keys = TypedState.get_value_state_keys( [typed_state.data, typed_state.data.my_other_data] ) assert value_keys == [ TypedState.get_state_id(typed_state.data), TypedState.get_state_id(typed_state.data.my_other_data), ] field_dict = TypedState.get_field_proxy_dict(typed_state.data) assert all(k in field_dict for k in value_keys) def test_can_bind_to_arbitrary_callback_args(state): typed_state = TypedState(state, MyBiggerData) mock_b = MagicMock() def my_callback(arbitrary_a, other_b): mock_b(arbitrary_a, other_b) typed_state.bind_changes( {(typed_state.name.my_other_data.b, typed_state.name.c): my_callback} ) typed_state.data.my_other_data.b = 2 typed_state.data.c = 3.4 state.flush() mock_b.assert_called_once_with(2, 3.4) class CustomEnumEncode(DefaultEncoderDecoder): def encode(self, obj): if isinstance(obj, MyEnum): return obj.name + "_CUSTOM" return super().encode(obj) def decode(self, obj, obj_type: type): if issubclass(obj_type, MyEnum) and isinstance(obj, str): return MyEnum[obj.replace("_CUSTOM", "")] return super().decode(obj, obj_type) def test_can_encode_values_consistently_with_proxy_encoding(state): typed_state = TypedState( state, MyBiggerData, encoders=[CustomEnumEncode()], ) assert typed_state.encode([{"text": e.name, "value": e} for e in MyEnum]) == [ {"text": e.name, "value": e.name + "_CUSTOM"} for e in MyEnum ] def test_can_check_if_is_specific_proxy_type(state): typed_state = TypedState(state, MyBiggerData) assert TypedState.is_name_proxy_class(typed_state.name.my_other_data) assert TypedState.is_data_proxy_class(typed_state.data.my_other_data) @dataclass class DualState: d1: MyData = field(default_factory=MyData) d2: MyData = field(default_factory=MyData) def test_supports_creating_sub_states(state): typed_state = TypedState(state, DualState) sub_state_1 = typed_state.get_sub_state(typed_state.name.d1) assert isinstance(sub_state_1, TypedState) assert sub_state_1.name.a == typed_state.name.d1.a typed_state.data.d1.a = 808 assert sub_state_1.data.a == 808 sub_state_2 = typed_state.get_sub_state(typed_state.name.d2) assert sub_state_2.name.a == typed_state.name.d2.a assert sub_state_1._encoder == sub_state_2._encoder == typed_state._encoder @pytest.mark.asyncio async def test_is_compatible_with_async_bind_changes(state): typed_state = TypedState(state, MyData) mock = MagicMock() async def async_callback(value): await asyncio.sleep(0.1) mock(value) typed_state.bind_changes({typed_state.name.a: async_callback}) with state: typed_state.data.a = 43 await asyncio.sleep(0.05) mock.assert_not_called() await asyncio.sleep(0.1) mock.assert_called_once_with(43) @dataclass class DataWithUnionTypes: my_optional_enum: MyEnum | None my_str_or_enum: str | MyEnum my_enum_or_str: MyEnum | str my_optional_enum_list: list[MyEnum | str | None] | None def test_handles_union_types_by_order_of_definition(state): encode = CustomEnumEncode() typed_state = TypedState(state, DataWithUnionTypes, encoders=[encode]) b_enum = MyEnum.B b_str = encode.encode(b_enum) typed_state.data.my_optional_enum = None typed_state.data.my_str_or_enum = b_str typed_state.data.my_enum_or_str = b_str assert typed_state.data.my_optional_enum is None assert typed_state.data.my_str_or_enum == b_str assert typed_state.data.my_enum_or_str == b_enum assert state[typed_state.name.my_optional_enum] is None assert state[typed_state.name.my_str_or_enum] == b_str assert state[typed_state.name.my_enum_or_str] == b_str typed_state.data.my_optional_enum = b_enum typed_state.data.my_str_or_enum = b_enum typed_state.data.my_enum_or_str = b_enum assert typed_state.data.my_optional_enum == b_enum assert typed_state.data.my_str_or_enum == b_str assert typed_state.data.my_enum_or_str == b_enum assert state[typed_state.name.my_optional_enum] == b_str assert state[typed_state.name.my_str_or_enum] == b_str assert state[typed_state.name.my_enum_or_str] == b_str def test_handles_list_of_union(state): encode = CustomEnumEncode() typed_state = TypedState(state, DataWithUnionTypes, encoders=[encode]) b_enum = MyEnum.B b_str = encode.encode(b_enum) typed_state.data.my_optional_enum_list = None assert typed_state.data.my_optional_enum_list is None typed_state.data.my_optional_enum_list = [b_str, b_enum, None] assert typed_state.data.my_optional_enum_list == [b_enum, b_enum, None] assert state[typed_state.name.my_optional_enum_list] == [b_str, b_str, None] def test_failure_to_encode_raises_type_error(state): class RaiseEncode(DefaultEncoderDecoder): def encode(self, _obj): raise AssertionError() def decode(self, _obj, _obj_type: type): raise AssertionError() typed_state = TypedState(state, DataWithTypes, encoders=[RaiseEncode()]) state.setdefault(typed_state.name.my_enum, MyEnum.A.value) with pytest.raises(TypeError): typed_state.data.my_enum = MyEnum.A with pytest.raises(TypeError): print(typed_state.data.my_enum) @dataclass class SimpleTypes: my_int: int my_enum: MyEnum my_path: Path @dataclass class TypedComposite: simple_types: SimpleTypes = field(default_factory=SimpleTypes) @dataclass class DataclassCollections: nested_list: list[TypedComposite] = field(default_factory=list) nested_dict: dict[str, TypedComposite] = field(default_factory=dict) def test_encode_decode_supports_collections_of_nested_dataclass(state): typed_state = TypedState(state, DataclassCollections) typed_state.data.nested_list = [ TypedComposite( SimpleTypes(my_int=1, my_enum=MyEnum.A, my_path=Path("/path/to/1")) ), TypedComposite( SimpleTypes(my_int=2, my_enum=MyEnum.B, my_path=Path("/path/to/2")) ), ] typed_state.data.nested_dict = { "3": TypedComposite( SimpleTypes(my_int=3, my_enum=MyEnum.C, my_path=Path("/path/to/3")) ), "4": TypedComposite( SimpleTypes(my_int=4, my_enum=MyEnum.A, my_path=Path("/path/to/4")) ), } assert typed_state.data.nested_list[0] == TypedComposite( SimpleTypes(my_int=1, my_enum=MyEnum.A, my_path=Path("/path/to/1")) ) assert typed_state.data.nested_list[1] == TypedComposite( SimpleTypes(my_int=2, my_enum=MyEnum.B, my_path=Path("/path/to/2")) ) assert typed_state.data.nested_dict["3"] == TypedComposite( SimpleTypes(my_int=3, my_enum=MyEnum.C, my_path=Path("/path/to/3")) ) assert typed_state.data.nested_dict["4"] == TypedComposite( SimpleTypes(my_int=4, my_enum=MyEnum.A, my_path=Path("/path/to/4")) ) assert state[typed_state.name.nested_list] == [ { "simple_types": { "my_int": 1, "my_enum": typed_state.encode(MyEnum.A), "my_path": typed_state.encode("/path/to/1"), } }, { "simple_types": { "my_int": 2, "my_enum": typed_state.encode(MyEnum.B), "my_path": typed_state.encode("/path/to/2"), } }, ] assert state[typed_state.name.nested_dict] == { "3": { "simple_types": { "my_int": 3, "my_enum": typed_state.encode(MyEnum.C), "my_path": typed_state.encode("/path/to/3"), } }, "4": { "simple_types": { "my_int": 4, "my_enum": typed_state.encode(MyEnum.A), "my_path": typed_state.encode("/path/to/4"), } }, } trame-server-3.6.1/tests/test_typed_state_future_annotations.py000066400000000000000000000013361506331243000252440ustar00rootroot00000000000000from __future__ import annotations # triggers lazy evaluation of field.type from dataclasses import dataclass from enum import Enum, auto import pytest from trame_server import Server from trame_server.utils.typed_state import TypedState class AnEnum(Enum): A = auto() B = auto() @dataclass class DataWithTypesAnnotations: my_enum: AnEnum @pytest.fixture def state(): server = Server() server.state.ready() return server.state def test_is_compatible_with_from_future_annotations(state): typed_state = TypedState(state, DataWithTypesAnnotations) typed_state.data.my_enum = AnEnum.B assert typed_state.data.my_enum == AnEnum.B assert state[typed_state.name.my_enum] == AnEnum.B.value trame-server-3.6.1/tests/test_ui.py000066400000000000000000000020141506331243000173170ustar00rootroot00000000000000from trame.app import get_server from trame.ui.html import DivLayout from trame.widgets import html def test_ui_vnode(): server = get_server("test_ui_vnode") state = server.state state.ready() with DivLayout(server) as layout: server.ui.content(layout) assert state.trame__template_main == "
\n\n
" with server.ui.content: html.Div("Hello") assert state.trame__template_main == "
\n
\nHello\n
\n
" with server.ui.content: html.Div("World") assert ( state.trame__template_main == "
\n
\nHello\n
\n
\nWorld\n
\n
" ) with server.ui["content"].clear(): html.Div("World") assert state.trame__template_main == "
\n
\nWorld\n
\n
" server.ui.clear() assert state.trame__template_main == "
\n
\nWorld\n
\n
" server.ui.flush_content() assert state.trame__template_main == "
\n\n
" server.ui.clear_layouts() trame-server-3.6.1/tests/test_utils.py000066400000000000000000000060271506331243000200520ustar00rootroot00000000000000import asyncio import io from contextlib import redirect_stdout import pytest from trame.app import get_server from trame_server import utils from trame_server.utils import banner, hot_reload, server, version def test_banner(): with io.StringIO() as buf, redirect_stdout(buf): banner.print_banner() output = buf.getvalue() assert len(output) > 4096 @pytest.mark.asyncio async def test_print_informations(): server_app = get_server("test_print_informations") with io.StringIO() as buf, redirect_stdout(buf): server_app.start(exec_mode="task", port=0) assert await server_app.ready # not automatic when server start as task server.print_informations(server_app) output = buf.getvalue() assert "http://localhost:" in output await asyncio.sleep(0.1) await server_app.stop() def test_version(): from trame_client import __version__ as v_client from trame_server import __version__ as v_server assert version.get_version("trame_server") == v_server assert version.get_version("trame_client") == v_client assert version.get_version("trame").split(".")[0] == "3" assert version.get_version("something_that_does_not_exist") is None def test_utils_fn(): assert utils.isascii(b"sdv") assert utils.isascii("sdv") assert not utils.isascii("Hällö, Wörld!") # remove filter keys input_dict = {"a": "hello", "b": "world", "_filter": ["b"]} output_dict = utils.clean_value(input_dict) assert input_dict != output_dict assert output_dict == {"a": "hello", "_filter": ["b"]} # deep clone input_dict = { "a": { "a.a": {"x": 1}, "a.b": {"y": 2}, }, "b": { "b.a": {"z": 3}, "b.b": {"xyz": 4}, }, } output_dict = {} utils.update_dict(output_dict, input_dict) assert output_dict == input_dict assert input_dict["a"] is input_dict["a"] assert output_dict["a"] == input_dict["a"] assert output_dict["a"] is not input_dict["a"] assert output_dict["a"]["a.a"] == input_dict["a"]["a.a"] assert output_dict["a"]["a.a"] is not input_dict["a"]["a.a"] # reduce vue_use class FakeState: def __init__(self): self.trame__vue_use = [ "trame_vtk", ("hello", {"a": 1, "c": {"x": 1}}), "trame_vtk", ("hello", {"b": 2, "c": {"y": 2}}), "trame_vtk", "trame_xyz", ] fake_state = FakeState() utils.reduce_vue_use(fake_state) assert fake_state.trame__vue_use == [ "trame_vtk", ( "hello", { "a": 1, "b": 2, "c": { "x": 1, "y": 2, }, }, ), "trame_xyz", ] def test_hot_reload(): @hot_reload.hot_reload def re_eval(): pass # skip decorate twice hot_reload.hot_reload(re_eval) re_eval() trame-server-3.6.1/trame_server/000077500000000000000000000000001506331243000166305ustar00rootroot00000000000000trame-server-3.6.1/trame_server/LICENSE000077700000000000000000000000001506331243000210502../LICENSEustar00rootroot00000000000000trame-server-3.6.1/trame_server/__init__.py000066400000000000000000000003231506331243000207370ustar00rootroot00000000000000from .client import Client from .core import Server from .utils.version import get_version __version__ = get_version("trame-server") __license__ = "Apache License 2.0" __all__ = [ "Client", "Server", ] trame-server-3.6.1/trame_server/client.py000066400000000000000000000214431506331243000204640ustar00rootroot00000000000000import asyncio import logging import os import traceback import aiohttp import msgpack from wslink.chunking import UnChunker, generate_chunks from trame_server.utils import asynchronous from .state import State MAX_MSG_SIZE = int(os.environ.get("WSLINK_MAX_MSG_SIZE", 4194304)) logger = logging.getLogger(__name__) class WsLinkSession: CLIENT_ERROR = -32099 AUTH_ID = "system:c0:0" def __init__(self, ws): self.loop = asyncio.get_running_loop() self.attachment_atomic = asyncio.Lock() self.ws = ws self.msg_count = 0 self.bin_id = 1 self.subscriptions = {} self.client_id = None self.unchunker = UnChunker() self.in_flight_rpc = {} async def on_msg_complete(self, payload): # Notification-only message from the server - should be binary attachment header if "id" not in payload: return msg_id = payload.get("id") msg_type, msg_topic, msg_idx = msg_id.split(":") future = self.in_flight_rpc.get(msg_id) # Error if "error" in payload: if future: future.set_exception( payload.get("error", "Server error") ) # May need to wrap in Exception? else: print("Server error:", payload.get("error")) self.in_flight_rpc.pop(msg_id) return # Normal processing msg_result = payload.get("result") # RPC if msg_type == "rpc": if future: future.set_result(msg_result) # Publish if msg_type == "publish" and msg_topic in self.subscriptions: event = msg_result for fn in self.subscriptions[msg_topic]: try: fn(event) except Exception: print("Subscription callback error") traceback.print_exc() # System if msg_type == "system": if msg_id == WsLinkSession.AUTH_ID: self.client_id = msg_result.get("clientID") self.unchunker.set_max_message_size(msg_result.get("maxMsgSize")) future.set_result(self.client_id) else: future.set_result(msg_result) # Clean pending future if future: self.in_flight_rpc.pop(msg_id) async def listen(self): async for msg in self.ws: if msg.type == aiohttp.WSMsgType.CLOSE: print("CLOSE") elif msg.type == aiohttp.WSMsgType.CLOSING: print("CLOSING") elif msg.type == aiohttp.WSMsgType.CLOSED: print("CLOSED") elif msg.type == aiohttp.WSMsgType.ERROR: print("ERROR") elif msg.type == aiohttp.WSMsgType.TEXT: logger.critical("wslink is not expecting text message:\n> %s", msg.data) if msg.type == aiohttp.WSMsgType.BINARY: full_message = self.unchunker.process_chunk(msg.data) if full_message is not None: await self.on_msg_complete(full_message) async def auth(self, **kwargs): key = WsLinkSession.AUTH_ID resp = self.loop.create_future() wrapper = { "wslink": "1.0", "id": key, "method": "wslink.hello", "args": [kwargs], "kwargs": {}, } self.in_flight_rpc[key] = resp try: packed_wrapper = msgpack.packb(wrapper) except Exception: del wrapper["error"]["data"] packed_wrapper = msgpack.packb(wrapper) async with self.attachment_atomic: for chunk in generate_chunks(packed_wrapper, MAX_MSG_SIZE): if self.ws is not None: await self.ws.send_bytes(chunk) return resp async def call(self, method, args=None, kwargs=None): self.msg_count += 1 key = f"rpc:{self.client_id}:{self.msg_count}" resp = self.loop.create_future() self.in_flight_rpc[key] = resp if args is None: args = [] if kwargs is None: kwargs = {} wrapper = { "wslink": "1.0", "id": key, "method": method, "args": args, "kwargs": kwargs, } try: packed_wrapper = msgpack.packb(wrapper) except Exception: del wrapper["error"]["data"] packed_wrapper = msgpack.packb(wrapper) async with self.attachment_atomic: for chunk in generate_chunks(packed_wrapper, MAX_MSG_SIZE): if self.ws is not None: await self.ws.send_bytes(chunk) return resp def register_subscription(self, topic, callback): if topic not in self.subscriptions: self.subscriptions[topic] = [callback] else: self.subscriptions[topic].append(callback) def unregister_subscription(self, topic, callback): callbacks = self.subscriptions.get(topic, []) if callback in callbacks: callbacks.remove(callback) if len(callbacks) == 0 and topic in self.subscriptions: self.subscriptions.pop(topic) def clear_subscriptions(self): topics = list(self.subscriptions.keys()) for topic in topics: self.subscriptions.pop(topic) async def close(self): if self.ws: await self.ws.close() class Client: """ Client implementation for driving a remote trame server with its shared state and trigger method calls in plain python. """ def __init__(self, url=None, config=None, translator=None, hot_reload=False): # Network self._connected = 0 self._session = None self._url = url self._config = {} if config is None else config # fake server self.hot_reload = hot_reload self._change_callbacks = {} # trame state self._state = State( translator, commit_fn=self._push_state, hot_reload=hot_reload ) async def connect(self, url=None, **kwargs): if self._connected: return self._connected = 1 config = {**self._config, **kwargs} if url is None: url = self._url async with aiohttp.ClientSession() as session: async with session.ws_connect(url) as ws: self._session = WsLinkSession(ws) self._state.ready() self._session.register_subscription( "trame.state.topic", self._on_state_update ) self._connected = 2 task = asynchronous.create_task(self._session.listen()) await self._session.auth(**config) await task self._session.clear_subscriptions() self._session = None self._connected = 0 async def diconnect(self): if self._session: await self._session.close() # ----------------------------------------------------- # Fake server for state # ----------------------------------------------------- @property def change(self): """ Use as decorator `@server.change(key1, key2, ...)` so the decorated function will be called like so `_fn(**state)` when any of the listed key name is getting modified from either client or server. :param *_args: A list of variable name to monitor :type *_args: str """ return self._state.change def _push_state(self, state): if self._session and self._session.client_id is not None: delta = [] for key, value in state.items(): if isinstance(value, dict) and "_filter" in value: skip_keys = set(value.get("_filter")) new_value = {} for k, v in value.items(): if k not in skip_keys: new_value[k] = v delta.append({"key": key, "value": new_value}) else: delta.append({"key": key, "value": value}) asynchronous.create_task(self._session.call("trame.state.update", [delta])) def _on_state_update(self, modified_state): with self.state: self.state.update(modified_state) # ----------------------------------------------------- @property def connected(self): return self._connected @property def state(self): return self._state async def call_trigger(self, name, args=None, kwargs=None): if args is None: args = [] if kwargs is None: kwargs = {} response = await self._session.call("trame.trigger", [name, args, kwargs]) return await response trame-server-3.6.1/trame_server/controller.py000066400000000000000000000310401506331243000213630ustar00rootroot00000000000000import logging import weakref from .utils import asynchronous, is_dunder, share from .utils.hot_reload import reload from .utils.namespace import Translator logger = logging.getLogger(__name__) def _safe_call(f, *args, **kwargs): return ( f() and f()(*args, **kwargs) if isinstance(f, weakref.WeakMethod) else f(*args, **kwargs) ) class TriggerCounter: def __init__(self, init=0): self._count = init def next(self): self._count += 1 return self._count class Controller: """Controller acts as a container for function proxies It allows functions to be passed around that are not yet defined, and can be defined or re-defined later. For example: >>> ctrl.trigger_name(fn) trigger__12 >>> f = ctrl.hello_func # function is currently undefined >>> ctrl.hello_func = lambda: print("Hello, world!") >>> f() Hello, world! >>> ctrl.hello_func = lambda: print("Hello again!") >>> f() Hello again! >>> ctrl.on_data_change.add(lambda: print("Update pipeline!")) >>> ctrl.on_data_change.add(lambda: print("Update view!")) >>> ctrl.on_data_change.add(lambda: print("Wow that is pretty cool!")) >>> ctrl.on_data_change() "Update pipeline!" "Wow that is pretty cool!" "Update view!" >>> ctrl.on_data_change.clear(set_only=True) # add, remove, discard, clear """ def __init__(self, translator=None, internal=None, hot_reload=False): super().__setattr__("__trame_hot_reload__", hot_reload) super().__setattr__("_translator", translator if translator else Translator()) super().__setattr__("_triggers", share(internal, "_triggers", {})) super().__setattr__( "_triggers_fn2name", share(internal, "_triggers_fn2name", {}) ) super().__setattr__( "_triggers_name_id", share(internal, "_triggers_name_id", TriggerCounter()) ) super().__setattr__("_func_dict", share(internal, "_func_dict", {})) def trigger(self, name): """ Use as decorator `@server.trigger(name)` so the decorated function will be able to be called from the client by doing `click="trigger(name)"`. :param name: A name to use for that trigger :type name: str """ if not name.startswith("trigger__"): name = self._translator.translate_key(name) def register_trigger(func): logger.info("trigger(%s)", name) self._triggers[name] = func self._triggers_fn2name[func] = name return func return register_trigger def trigger_name(self, fn): """ Given a function this method will register a trigger and returned its name. If manually registered, the given name at the time will be returned. :return: The trigger name for that function :rtype: str """ if fn in self._triggers_fn2name: return self._triggers_fn2name[fn] name = f"trigger__{self._triggers_name_id.next()}" self.trigger(name)(fn) return name def trigger_fn(self, name): """ Given a trigger name get its attached function/method. :return: The trigger function for that name :rtype: function """ return self._triggers.get(name) def __getitem__(self, name): return self.__getattr__(name) def __setitem__(self, name, value): self.__setattr__(name, value) def __getattr__(self, name): if is_dunder(name): return super().__getattr__(name) name = self._translator.translate_key(name) if name not in self._func_dict: self._func_dict[name] = ControllerFunction(self, name) return self._func_dict[name] def __setattr__(self, name, func): # Do not allow pre-existing attributes, such as `trigger`, to be # re-defined. if name in self.__dict__ or name in Controller.__dict__: msg = ( f"'{name}' is a special attribute on Controller that cannot " "be re-assigned" ) raise NameError(msg) name = self._translator.translate_key(name) if name in self._func_dict: self._func_dict[name].func = func else: self._func_dict[name] = ControllerFunction(self, name, func) def add(self, name, clear=False): """ Use as decorator `@ctrl.add(name)` so the decorated function will be added to a given controller name :param name: Controller method name to be added to :type name: str .. code-block:: ctrl = server.controller @ctr.add("on_server_ready") def on_ready(**state): pass # or ctrl.on_server_ready.add(on_ready) You can also make sure when the method get registered we clear any previous content. .. code-block:: ctrl = server.controller @ctr.add("on_server_ready", clear=True) def on_ready(**state): pass # or ctrl.on_server_ready.clear() ctrl.on_server_ready.add(on_ready) """ name = self._translator.translate_key(name) def register_ctrl_method(func): if clear: self[name].clear() self[name].add(func) return func return register_ctrl_method def once(self, name): """ Use as decorator `@ctrl.once(name)` so the decorated function will be added to a given controller name and will only execute once. :param name: Controller method name to be added to :type name: str .. code-block:: ctrl = server.controller @ctr.once("on_server_ready") def on_ready(**state): pass # or ctrl.on_server_ready.once(on_ready) """ name = self._translator.translate_key(name) def register_ctrl_method(func): self[name].once(func) return func return register_ctrl_method def add_task(self, name, clear=False): """ Use as decorator `@ctrl.add_task(name)` so the decorated function will be added to a given controller name :param name: Controller method name to be added to :type name: str .. code-block:: ctrl = server.controller @ctr.add_task("on_server_ready") async def on_ready(**state): pass # or ctrl.on_server_ready.add_task(on_ready) You can also make sure when the method get registered we clear any previous content. .. code-block:: ctrl = server.controller @ctr.add_task("on_server_ready", clear=True) async def on_ready(**state): pass # or ctrl.on_server_ready.clear() ctrl.on_server_ready.add_task(on_ready) """ name = self._translator.translate_key(name) def register_ctrl_method(func): if clear: self[name].clear() self[name].add_task(func) return func return register_ctrl_method def set(self, name, clear=False): """ Use as decorator `@ctrl.set(name)` so the decorated function will be added to a given controller name :param name: Controller method name to be set to :type name: str .. code-block:: ctrl = server.controller @ctr.set("on_server_ready") def on_ready(**state): pass # or ctrl.on_server_ready = on_ready You can also make sure when the method get registered we clear any previous content. .. code-block:: ctrl = server.controller @ctr.set("on_server_ready", clear=True) def on_ready(**state): pass # or ctrl.on_server_ready.clear() ctrl.on_server_ready = on_ready """ name = self._translator.translate_key(name) def register_ctrl_method(func): if clear: self[name].clear() self[name] = func return func return register_ctrl_method class ControllerFunction: """Controller functions are callable function proxy objects Any calls are forwarded to the internal function, which may be undefined or dynamically changed. If a call is made when the internal function is undefined, a FunctionNotImplementedError is raised. """ def __init__(self, controller, name, func=None): # The controller is needed to check for settings like hot reload self.controller = controller # The name is needed to provide more helpful information upon # a FunctionNotImplementedError exception. self.name = name self.func = func self.funcs = set() self.task_funcs = set() self.funcs_once = set() def __call__(self, *args, **kwargs): if self.func is None and len(self.funcs) + len(self.task_funcs) == 0: raise FunctionNotImplementedError(self.name) copy_list = list(self.funcs) + list(self.funcs_once) self.funcs_once.clear() # Exec main function first result = None if self.func is not None: if self.hot_reload: f = reload(self.func) else: f = self.func result = _safe_call(f, *args, **kwargs) if self.hot_reload: copy_list = list(map(reload, copy_list)) # Exec added fn after results = [_safe_call(f, *args, **kwargs) for f in copy_list] # Schedule any task for task_fn in list(self.task_funcs): results.append( asynchronous.create_task(_safe_call(task_fn, *args, **kwargs)) ) # Figure out return if self.func is None: return results if len(copy_list): return [result, *results] if len(self.task_funcs): return results return result def once(self, func): """ Add function to the set of functions to be called when the current ControllerFunction is called. After first execution, the function will automatically be removed. :param func: Function to add """ self.funcs_once.add(func) def add(self, func): """ Add function to the set of functions to be called when the current ControllerFunction is called. :param func: Function to add """ self.funcs.add(func) def add_task(self, func): """ Add task to the set of coroutine to be called when the current ControllerFunction is called. :param func: Function to add """ self.task_funcs.add(func) def discard(self, func): """ Discard function to the set of functions to be called when the current ControllerFunction is called. :param func: Function to discard """ self.funcs.discard(func) self.funcs_once.discard(func) self.task_funcs.discard(func) def remove(self, func): """ Remove function to the set of functions to be called when the current ControllerFunction is called. :param func: Function to remove """ self.funcs.remove(func) def remove_task(self, func): """ Remove task function to the set of functions to be called when the current ControllerFunction is called. :param func: Function to remove """ self.task_funcs.remove(func) def clear(self, set_only=False): """ Clear all the functions registered to the current ControllerFunction. :param set_only: (default: False) If true only the "added" one will be removed. """ if not set_only: self.func = None self.funcs.clear() self.funcs_once.clear() self.task_funcs.clear() def exists(self): """ Check if at least a function was registered to the current ControllerFunction. :return: True if either a function was set or added """ if self.func is not None: return True return len(self.funcs) + len(self.task_funcs) > 0 @property def hot_reload(self): return self.controller.__trame_hot_reload__ class FunctionNotImplementedError(Exception): pass trame-server-3.6.1/trame_server/core.py000066400000000000000000000645341506331243000201460ustar00rootroot00000000000000from __future__ import annotations import asyncio import inspect import logging import os import sys from typing import Literal from . import utils from .controller import Controller from .http import HttpHeader from .protocol import CoreServer from .state import State from .ui import VirtualNodeManager from .utils import share from .utils.argument_parser import ArgumentParser from .utils.namespace import Translator logger = logging.getLogger(__name__) ClientType = Literal["vue2", "vue3"] BackendType = Literal["aiohttp", "generic", "tornado", "jupyter"] ExecModeType = Literal["main", "desktop", "task", "coroutine"] DEFAULT_CLIENT_TYPE: ClientType = "vue3" def set_default_client_type(value: ClientType) -> None: global DEFAULT_CLIENT_TYPE # noqa: PLW0603 DEFAULT_CLIENT_TYPE = value class Server: """ Server implementation for trame. This is the core object that manage client/server communication but also holds a state and controller instance. With trame a server instance should be retrieved by using **trame.app.get_server()** Known options: - log_network: False (path to log file) - ws_max_msg_size: 10000000 (bytes) - ws_heart_beat: 30 - desktop_debug: False :param name: A name identifier for a given server :type name: str, optional (default: trame) :param **options: Gather any keyword arguments into options :type options: Dict """ def __init__( self, name="trame", vn_constructor=None, translator=None, parent_server=None, **options, ) -> None: # Core internal variables self._parent_server = parent_server self._translator = translator if translator else Translator() self._name = share(parent_server, "_name", name) self._options = share(parent_server, "_options", options) self._client_type = share(parent_server, "_client_type", None) self._http_header = share(parent_server, "_http_header", HttpHeader()) # use parent_server instead of local version self._server = None self._running_stage = 0 # 0: off / 1: pending / 2: running self._running_port = 0 self._running_future = None self._www = None self.serve = {} # HTTP static endpoints self._loaded_modules = set() self._loaded_module_dicts = [] self._cli_parser = None self._root_protocol = None self._protocols_to_configure = [] # ENV variable mapping settings self.hot_reload = "--hot-reload" in sys.argv or bool( os.getenv("TRAME_HOT_RELOAD", None) ) if parent_server is None: self._options["log_network"] = self._options.get( "log_network", os.environ.get("TRAME_LOG_NETWORK", False) ) self._options["ws_max_msg_size"] = self._options.get( "ws_max_msg_size", os.environ.get("TRAME_WS_MAX_MSG_SIZE", 10000000) ) self._options["ws_heart_beat"] = self._options.get( "ws_heart_beat", os.environ.get("TRAME_WS_HEART_BEAT", 30) ) self._options["desktop_debug"] = self._options.get( "desktop_debug", os.environ.get("TRAME_DESKTOP_DEBUG", False) ) # reset default wslink startup message os.environ["WSLINK_READY_MSG"] = "" # Shared state + reserve internal keys if parent_server is None: self._state = State( self.translator, commit_fn=self._push_state, hot_reload=self.hot_reload ) for key in ["scripts", "module_scripts", "styles", "vue_use", "mousetrap"]: self._state[f"trame__{key}"] = [] self._state.trame__client_only = ["trame__busy"] self._state.trame__busy = 1 self._state.trame__favicon = None self._state.trame__title = "Trame" else: self._state = State( self.translator, internal=parent_server._state, commit_fn=self._push_state, hot_reload=self.hot_reload, ) # Controller if parent_server is None: self._controller = Controller(self.translator, hot_reload=self.hot_reload) else: self._controller = Controller( self.translator, internal=parent_server._controller, hot_reload=self.hot_reload, ) # Server only context if parent_server is None: self._context = State(self.translator, hot_reload=self.hot_reload) else: self._context = State( self.translator, internal=parent_server._context, hot_reload=self.hot_reload, ) # UI (FIXME): use for translator self._ui = share(parent_server, "_ui", VirtualNodeManager(self, vn_constructor)) def create_child_server(self, translator=None, prefix=None) -> Server: translator = translator if translator else Translator(prefix=prefix) return Server(translator=translator, parent_server=self) # ------------------------------------------------------------------------- # State management helpers # ------------------------------------------------------------------------- def _push_state(self, state): if self.protocol: self.protocol.push_state_change(state) # ------------------------------------------------------------------------- # Initialization helper # ------------------------------------------------------------------------- @property def http_headers(self): """Return http header helper so they can be applied before the server start.""" return self._http_header def enable_module(self, module, **kwargs): """ Expend server using a module definition which can be used to serve custom client code or assets, load/initialize resources (js, css, vue), register custom protocols and even execute custom code. Any previously seem module will be automatically skipped. The attributes that are getting processed in a module are the following: - setup(server, **kwargs): Function called first - scripts = [] : List all JavaScript URL that should be loaded - module_scripts = [] : List all JavaScript URL as type=module to load - styles = [] : List all CSS URL that should be loaded - vue_use = ['libName', ('libName2', { **options })]: List Vue plugin to load - state = {} : Set of variable to add to state - serve = { data: '/path/on/fs' }: Set of endpoints to serve static content - www = '/path/on/fs' : Path served as main web content :param module: A module to enable or a dict() :param kwargs: Any optional parameters needed for your module setup() function. """ if self.root_server != self: return self.root_server.enable_module(module, **kwargs) # Make sure definitions is a dict while skipping already loaded module definitions = module if isinstance(definitions, dict): if definitions in self._loaded_module_dicts: return False self._loaded_module_dicts.append(definitions) elif definitions in self._loaded_modules: return False else: self._loaded_modules.add(definitions) definitions = definitions.__dict__ if "setup" in definitions: definitions["setup"](self, **kwargs) for key in ["scripts", "module_scripts", "styles", "vue_use"]: if key in definitions: self.state[f"trame__{key}"] += definitions[key] if "state" in definitions: self.state.update(definitions["state"]) if "serve" in definitions: self.serve.update(definitions["serve"]) if "www" in definitions: self._www = definitions["www"] # Reduce vue_use to merge options utils.reduce_vue_use(self.state) return True # ------------------------------------------------------------------------- # Call methods # ------------------------------------------------------------------------- def js_call(self, ref: str | None = None, method: str | None = None, *args): """ Python call method on JS element. :param ref: ref name of the widget element :type ref: str :param method: name of the method that should be called :type method: str :param *args: set of parameters needed for the function """ if self.protocol: self.protocol.push_actions( [ { "type": "method", "ref": ref, "method": method, "args": list(args), }, ], ) # ------------------------------------------------------------------------- # Annotations # ------------------------------------------------------------------------- @property def change(self): """ Use as decorator `@server.change(key1, key2, ...)` so the decorated function will be called like so `_fn(**state)` when any of the listed key name is getting modified from either client or server. :param *_args: A list of variable name to monitor :type *_args: str """ return self._state.change # ------------------------------------------------------------------------- @property def trigger(self): """ Use as decorator `@server.trigger(name)` so the decorated function will be able to be called from the client by doing `click="trigger(name)"`. :param name: A name to use for that trigger :type name: str """ return self._controller.trigger # ------------------------------------------------------------------------- # From a function get its trigger name and register it if need be # ------------------------------------------------------------------------- @property def trigger_name(self): """ Given a function this method will register a trigger and returned its name. If manually registered, the given name at the time will be returned. :return: The trigger name for that function :rtype: str """ return self._controller.trigger_name # ------------------------------------------------------------------------- # App properties # ------------------------------------------------------------------------- @property def name(self) -> str: """Name of server""" return self._name @property def root_server(self) -> Server: """Root server to start""" if self._parent_server: return self._parent_server.root_server return self @property def translator(self): """Translator of the server""" return self._translator @property def options(self): """Server options provided at instantiation time""" return self._options @property def client_type(self) -> ClientType: """Specify the client type. Either 'vue2' or 'vue3' for now.""" if self._client_type is None: return DEFAULT_CLIENT_TYPE # default return self._client_type @client_type.setter def client_type(self, value: ClientType) -> None: """Should only be called once before any widget initialization""" if self._client_type is None: self._client_type = value if self.client_type != value: msg = ( f"Trying to switch client_type from {self._client_type} to {value}." "The client_type can only be set once." ) raise TypeError(msg) @property def cli(self): """argparse parser""" if self.root_server != self: return self.root_server.cli if self._cli_parser: return self._cli_parser self._cli_parser = ArgumentParser(description="Kitware trame") # Trame specific args self._cli_parser.add_argument( "--server", help="Prevent your browser from opening at startup", action="store_true", ) self._cli_parser.add_argument( "--banner", help="Print trame banner", action="store_true", ) self._cli_parser.add_argument( "--app", help="Use OS built-in browser", action="store_true", ) self._cli_parser.add_argument( "--no-http", help="Do not serve anything over http", dest="no_http", action="store_true", ) self._cli_parser.add_argument( "--authKeyFile", help="""Path to a File that contains the Authentication key for clients to connect to the WebSocket. This takes precedence over '-a, --authKey' from wslink.""", ) self._cli_parser.add_argument( "--hot-reload", help="""Automatically reload state/controller callback functions for every function call. This allows live editing of the functions. Functions located in the site-packages directories are skipped.""", action="store_true", ) self._cli_parser.add_argument( "--trame-args", help="""If specified, trame will ignore all other arguments, and only the contents of the `--trame-args` will be used. For example: `--trame-args="-p 8081 --server"`. Alternatively, the environment variable `TRAME_ARGS` may be set instead.""", ) CoreServer.add_arguments(self._cli_parser) return self._cli_parser @property def state(self) -> State: """ :return: The server shared state :rtype: trame_server.state.State """ return self._state @property def context(self) -> State: """ The server-only context (not shared with the client). :return: The server context state :rtype: trame_server.state.State """ return self._context @property def controller(self) -> Controller: """ :return: The server controller :rtype: trame_server.controller.Controller """ return self._controller @property def ui(self) -> VirtualNodeManager: """ :return: The server VirtualNode manager :rtype: trame_server.ui.VirtualNodeManager """ return self._ui @property def running(self) -> bool: """Return True if the server is currently starting or running.""" if self.root_server != self: return self.root_server.running return self._running_stage > 1 @property def network_completion(self): """Return a future to await if you want to ensure that any pending network call have been issued before locking the server""" return asyncio.ensure_future(self.context.network_monitor.completion()) @property def ready(self): """Return a future that will resolve once the server is ready""" if self.root_server != self: return self.root_server.ready if self._running_future is None: self._running_future = asyncio.get_running_loop().create_future() return self._running_future # ------------------------------------------------------------------------- # API for network handling # ------------------------------------------------------------------------- def get_server_state(self): """Return the current server state""" return { "name": self._name, "state": self.state.initial, } def clear_state_client_cache(self, *state_names): protocol = self.protocol if protocol: protocol.clear_state_client_cache(*state_names) # ------------------------------------------------------------------------- def add_protocol_to_configure(self, configure_protocol_fn): """ Register function that will be called with a wslink.ServerProtocol when the server start and is ready for registering new wslink.Protocol. :param configure_protocol_fn: A function to be called later with a wslink.ServerProtocol as argument. """ if self.root_server != self: self.root_server.add_protocol_to_configure(configure_protocol_fn) return self._protocols_to_configure.append(configure_protocol_fn) @property def protocol(self): """Return the server root protocol""" if self.root_server != self: return self.root_server.protocol return self._root_protocol # ------------------------------------------------------------------------- def protocol_call(self, method, *args, **kwargs): """ Call a registered protocol method :param method: Method registration name :type method: str :param *args: Set of args to use for that method call :param **kwargs: Set of keyword arguments to use for that method call :return: transparently return what the called function returns """ if self.protocol: pair = self.protocol.getRPCMethod(method) if pair: obj, func = pair return func(obj, *args, **kwargs) return None error = "Protocol does not exist yet" raise ValueError(error) def force_state_push(self, *key_names): """ Should only be needed when client corrupted its data and need the server need to send it again. :param *args: Set of key names to be send again to the client. """ self.protocol_call( "trame.force.push", *[self._translator.translate_key(k) for k in key_names] ) # ------------------------------------------------------------------------- # Server handling (start/stop/port) # ------------------------------------------------------------------------- def start( self, port: int | None = None, thread: bool = False, open_browser: bool | None = None, show_connection_info: bool = True, disable_logging: bool = False, backend: BackendType | None = None, exec_mode: ExecModeType = "main", timeout: int | None = None, host: str | None = None, **kwargs, ): """ Start the server by listening to the provided port or using the `--port, -p` command line argument. If the server is already starting or started, any further call will be skipped. When the exec_mode="main" or "desktop", the method will be blocking. If exec_mode="task", the method will return a scheduled task. If exec_mode="coroutine", the method will return a coroutine which will need to be scheduled by the user. :param port: A port number to listen to. When 0 is provided the system will use a random open port. :param thread: If the server run in a thread which means we should disable interuption listeners :param open_browser: Should we open the system browser with app url. Using the `--server` command line argument is similar to setting it to False. :param show_connection_info: Should we print connection URL at startup? :param disable_logging: Ask wslink to disable logging :param backend: aiohttp by default but could be generic or tornado. This can also be set with the environment variable ``TRAME_BACKEND``. Defaults to ``'aiohttp'``. :param exec_mode: main/desktop/task/coroutine specify how the start function should work :param timeout: How much second should we wait before automatically stopping the server when no client is connected. Setting it to 0 will disable such auto-shutdown. :param host: The hostname used to bind the server. This can also be set with the environment variable ``TRAME_DEFAULT_HOST``. Defaults to ``'localhost'``. :param **kwargs: Keyword arguments for capturing optional parameters for wslink server and/or desktop browser """ if self.root_server != self: self.root_server.start( port=port, thread=thread, open_browser=open_browser, show_connection_info=show_connection_info, disable_logging=disable_logging, backend=backend, exec_mode=exec_mode, timeout=timeout, host=host, **kwargs, ) return None if self._running_stage: return None # Try to bind client if none were added if self._www is None: from trame_client import module self.enable_module(module) # Apply any header change needed self._http_header.apply() # Trigger on_server_start life cycle callback if self.controller.on_server_start.exists(): self.controller.on_server_start(self) CoreServer.bind_server(self) options = self.cli.parse_known_args()[0] if backend is None: backend = os.environ.get("TRAME_BACKEND", "aiohttp") if open_browser is None: open_browser = not os.environ.get("TRAME_SERVER", False) if options.host == "localhost": if host is None: host = os.environ.get("TRAME_DEFAULT_HOST", "localhost") options.host = host if timeout is not None: options.timeout = timeout if port is not None: options.port = port if not options.content: options.content = self._www if thread: options.nosignalhandlers = True if options.banner: from .utils.banner import print_banner self.controller.on_server_ready.add(print_banner) if options.app: exec_mode = "desktop" if exec_mode == "desktop": from .utils.desktop import start_browser options.port = 0 exec_mode, show_connection_info, open_browser = "main", False, False self.controller.on_server_ready.add( lambda **_: start_browser(self, **kwargs) ) # Allow for older wslink versions where this was not an attribute reverse_url = getattr(options, "reverse_url", None) if not reverse_url and show_connection_info and exec_mode != "task": from .utils.server import print_informations self.controller.on_server_ready.add(lambda **_: print_informations(self)) if ( not reverse_url and open_browser and exec_mode != "task" and not options.server ): from .utils.browser import open_browser self.controller.on_server_ready.add(lambda **_: open_browser(self)) if len(self.serve): endpoints = [] for key in self.serve: value = self.serve[key] if isinstance(value, (list, tuple)): # tuple are use to describe sync loading (affect client) endpoints.append(f"{key}={value[0]}") else: endpoints.append(f"{key}={value}") options.fsEndpoints = "|".join(endpoints) # Reset http delivery if options.no_http: options.content = "" options.fsEndpoints = "" self._server_options = options CoreServer.configure(options) self._running_stage = 1 task = CoreServer.server_start( options, **{ # Do a proper merging/override **kwargs, "disableLogging": disable_logging, "backend": backend, "exec_mode": exec_mode, }, ) # Manage exit life cycle unless coroutine if exec_mode == "main": self._running_stage = 0 if self.controller.on_server_exited.exists(): loop = asyncio.get_event_loop() for exit_task in self.controller.on_server_exited( **self.state.to_dict() ): if inspect.isawaitable(exit_task): loop.run_until_complete(exit_task) elif callable(exit_task): result = exit_task() if inspect.isawaitable(result): loop.run_until_complete(result) elif hasattr(task, "add_done_callback"): def on_done(task: asyncio.Task) -> None: try: task.result() self._running_stage = 0 if self.controller.on_server_exited.exists(): self.controller.on_server_exited(**self.state.to_dict()) except asyncio.CancelledError: pass # Task cancellation should not be logged as an error. except Exception: # pylint: disable=broad-except logging.exception("Exception raised by task = %r", task) task.add_done_callback(on_done) return task async def stop(self) -> None: """Coroutine for stopping the server""" if self.root_server != self: await self.root_server.stop() elif self._running_stage: await self._server.stop() self._running_future = None self._running_stage = 0 @property def port(self) -> int: """Once started, you can retrieve the port used""" if self.root_server != self: return self.root_server.port return self._running_port @property def server_options(self): """Once started, you can retrieve the server options used""" return self._server_options trame-server-3.6.1/trame_server/http.py000066400000000000000000000035561506331243000201720ustar00rootroot00000000000000PRESET_KEY_SHARED_ARRAY_BUFFER = "SharedArrayBuffer" class HttpHeader: """Helper class to construct and define http headers for the web server. This class is only driving the built-in aiohttp web server. But if can be manually leveraged to configure your own server implementation. """ def __init__(self): self._headers = {} self._presets = {} @property def shared_array_buffer(self): """Return True if the shared array buffer was set""" return self._presets.get(PRESET_KEY_SHARED_ARRAY_BUFFER, False) @shared_array_buffer.setter def shared_array_buffer(self, v): """Enable/Disable http header to support shared array buffer""" self._presets[PRESET_KEY_SHARED_ARRAY_BUFFER] = bool(v) if self.shared_array_buffer: self.set_header("Cross-Origin-Opener-Policy", "same-origin") self.set_header("Cross-Origin-Embedder-Policy", "require-corp") self.set_header("Access-Control-Allow-Origin", "*") else: self.remove_header("Cross-Origin-Opener-Policy") self.remove_header("Cross-Origin-Embedder-Policy") self.remove_header("Access-Control-Allow-Origin") def set_header(self, key, value): """Set given header key/value pair""" self._headers[key] = value def remove_header(self, key): """Discard given header key if present""" if key in self._headers: self._headers.pop(key) def get_header(self, key): """Return the current header value for the given key""" return self._headers.get(key) @property def headers(self): """Return currently configured headers""" return self._headers def apply(self): """Only apply on aiohttp backend""" from wslink.backends import aiohttp aiohttp.HTTP_HEADERS = self.headers trame-server-3.6.1/trame_server/protocol.py000066400000000000000000000175631506331243000210570ustar00rootroot00000000000000import inspect import os from pathlib import Path from wslink import register as exportRpc from wslink import server from wslink.websocket import ServerProtocol from trame_server.state import TRAME_NON_INIT_VALUE from trame_server.utils import clean_state, logger class CoreServer(ServerProtocol): authentication_token = "wslink-secret" server = None # --------------------------------------------------------------- # Static methods # --------------------------------------------------------------- @staticmethod def bind_server(server): logger.initialize_logger(server.options) CoreServer.server = server # Forward options to wslink os.environ["WSLINK_MAX_MSG_SIZE"] = str(server.options["ws_max_msg_size"]) os.environ["WSLINK_HEART_BEAT"] = str(server.options["ws_heart_beat"]) @staticmethod def add_arguments(parser): server.add_arguments(parser) @staticmethod def configure(args): if args.authKeyFile: with Path(args.authKeyFile).open("r") as key_file: CoreServer.authentication_token = key_file.read().strip() else: CoreServer.authentication_token = args.authKey @staticmethod def server_start( options, disableLogging=False, backend="aiohttp", exec_mode="main", **kwargs, ): # NOTE: **kwargs to wslink's start_webserver are currently unused return server.start_webserver( options=options, protocol=CoreServer, disableLogging=disableLogging, backend=backend, exec_mode=exec_mode, **kwargs, ) @staticmethod def server_stop(): server.stop_webserver() # --------------------------------------------------------------- # Server # --------------------------------------------------------------- def initialize(self): # Called by wslink self.rpcMethods = {} self.server = CoreServer.server self.server._root_protocol = self self.server.context.network_monitor = self.network_monitor self._clients_state = {} for configure in self.server._protocols_to_configure: configure(self) self.updateSecret(CoreServer.authentication_token) def set_server(self, _server): self.server._server = _server if self.server.controller.on_server_bind.exists(): # Hack - use internal of aiohttp to rework routes order # FIXME: WON'T WORK with a different backend server_routes = _server.app.router._resources wslink_routes = list(server_routes) server_routes.clear() self.server.controller.on_server_bind(_server) server_routes.extend(wslink_routes) def port_callback(self, port_used): self.server._running_port = port_used if self.server._running_stage < 2: self.server._running_stage = 2 self.server.state.ready() self.server.context.ready() if self.server.controller.on_server_ready.exists(): self.server.controller.on_server_ready(**self.server.state.to_dict()) # Mark the server as ready if not self.server.ready.done(): self.server.ready.set_result(True) # --------------------------------------------------------------- def getRPCMethod(self, name): if len(self.rpcMethods) == 0: def is_method(x): return inspect.ismethod(x) or inspect.isfunction(x) for protocolObject in self.getLinkProtocols(): for k in inspect.getmembers(protocolObject.__class__, is_method): proc = k[1] if "_wslinkuris" in proc.__dict__: uri_info = proc.__dict__["_wslinkuris"][0] if "uri" in uri_info: uri = uri_info["uri"] self.rpcMethods[uri] = (protocolObject, proc) if name in self.rpcMethods: return self.rpcMethods[name] return None # --------------------------------------------------------------- # Publish # --------------------------------------------------------------- def push_state_change(self, modified_state, skip_last_active_client=False): ok, str_values = clean_state(modified_state) # Only send changes state_to_send = {} for key, value in ok.items(): prev_str = self._clients_state.get(key, TRAME_NON_INIT_VALUE) new_str = str_values.get(key, TRAME_NON_INIT_VALUE) if prev_str != new_str: state_to_send[key] = value # Log and send state if state_to_send: logger.state_s2c(state_to_send) self.publish( "trame.state.topic", state_to_send, skip_last_active_client=skip_last_active_client, ) # Keep track of last push self._clients_state.update(str_values) # --------------------------------------------------------------- def push_actions(self, actions): logger.action_s2c(actions) self.publish("trame.actions.topic", actions) # --------------------------------------------------------------- # Internal RPCs # --------------------------------------------------------------- def clear_state_client_cache(self, *keys): for k in keys: del self._clients_state[k] # --------------------------------------------------------------- # RPCs # --------------------------------------------------------------- @exportRpc("trame.force.push") def force_push_state(self, *keys): state_to_send = {key: self.server.state[key] for key in keys} if state_to_send: self.publish( "trame.state.topic", state_to_send, skip_last_active_client=False, ) # --------------------------------------------------------------- @exportRpc("trame.lifecycle.update") def life_cycle_update(self, name): _fn = self.server.controller[f"on_{name}"] if _fn.exists(): _fn() # --------------------------------------------------------------- @exportRpc("trame.error.client") def js_error(self, message): print(f" JS Error => {message}") # --------------------------------------------------------------- @exportRpc("trame.state.get") def get_server_state(self): server_state = self.server.get_server_state() ok, _ = clean_state(server_state.get("state", {})) state_to_send = {**server_state, "state": ok} logger.initial_state(state_to_send) return state_to_send # --------------------------------------------------------------- @exportRpc("trame.trigger") async def trigger(self, name, args, kwargs): logger.action_c2s({"name": name, "args": args, "kwargs": kwargs}) with self.server.state: fn = self.server.controller.trigger_fn(name) if fn: result = fn(*args, **kwargs) if inspect.isawaitable(result): result = await result return result print(f"Trigger {name} seems to be missing") return None # --------------------------------------------------------------- @exportRpc("trame.state.update") def update_state(self, changes): logger.state_c2s(changes) with self.server.state: client_state = {} for change in changes: client_state[change["key"]] = change.get("value") # Push to other clients (collaboration) before flush self.push_state_change(client_state, skip_last_active_client=True) # Update server state self.server.state.update(client_state) trame-server-3.6.1/trame_server/state.py000066400000000000000000000265511506331243000203330ustar00rootroot00000000000000import inspect import logging import weakref from .utils import asynchronous, is_dunder, is_private, share from .utils.hot_reload import reload from .utils.namespace import Translator logger = logging.getLogger(__name__) __all__ = [ "State", ] TRAME_NON_INIT_VALUE = "__trame__: non_init_value_that_is_not_None" class StateChangeHandler: def __init__(self, listeners): self._all_listeners = listeners self._currents = set() def add(self, key): if key in self._all_listeners: for callback in self._all_listeners[key]: self._currents.add(callback) def add_all(self, keys): for key in keys: self.add(key) def clear(self): self._currents.clear() def __iter__(self): return iter(list(self._currents)) class State: """ Flexible dictionary managing a server shared state. Variables can be accessed with either the `[]` or `.` notation. :examples: >>> with state: ... state.a = 1 ... state.b = 2 ... # state is flushed() """ def __init__( self, translator=None, internal=None, commit_fn=None, hot_reload=False, ready=False, ): self._push_state_fn = commit_fn self._hot_reload = hot_reload self._translator = translator if translator else Translator() self._modified_keys = share(internal, "_modified_keys", set()) self._change_callbacks = share(internal, "_change_callbacks", {}) self._pending_update = share(internal, "_pending_update", {}) self._pushed_state = share(internal, "_pushed_state", {}) self._state_listeners = share( internal, "_state_listeners", StateChangeHandler(self._change_callbacks) ) self._parent_state = internal self._children_state = [] self._ready_flag = ready if internal: internal._children_state.append(self) def ready(self) -> None: """Mark the state as ready for synchronization.""" if self._ready_flag: return self._ready_flag = True self.flush() if self._parent_state: self._parent_state.ready() for child in self._children_state: child.ready() @property def is_ready(self) -> bool: """Return True is the instance is ready for synchronization, False otherwise.""" if self._parent_state: return self._parent_state.is_ready return self._ready_flag @property def translator(self) -> Translator: """Return the translator instance used to namespace the variable names.""" return self._translator def __getitem__(self, key): key = self._translator.translate_key(key) return self._pending_update.get(key, self._pushed_state.get(key)) def __setitem__(self, key, value): key = self._translator.translate_key(key) if key in self._pushed_state: if value == self._pushed_state[key]: self._pending_update.pop(key, None) return self._pending_update[key] = value def __getattr__(self, key): if is_dunder(key): # Forward dunder calls to object return getattr(object, key) if is_private(key): return self.__dict__.get(key) return self.__getitem__(key) def __setattr__(self, key, value): if is_private(key): self.__dict__[key] = value else: self.__setitem__(key, value) def client_only(self, *_args): """ Tag a given set of variable name(s) to be client only. This means that when they get changed on the client side, the server will not be aware of their change and no network bandwidth will be used to maintain the server in sync with the client state. :param *_args: A list a variable name :type *_args: str """ _args = self._translator.translate_list(_args) change_detected = 0 full_list = [ *self._pending_update.get("trame__client_only", []), *self._pushed_state.get("trame__client_only", []), ] for name in _args: if name not in full_list: full_list.append(name) change_detected += 1 if change_detected: self._pending_update["trame__client_only"] = full_list self.flush() def to_dict(self): """ Flush current state modification and return the resulting state state as a python dict. """ self.flush() return self._pushed_state def has(self, key): """Check is a key is currently available in the state""" _key = self._translator.translate_key(key) result = _key in self._pushed_state or _key in self._pending_update logger.info("has(%s => %s) = %s", key, _key, result) return result def setdefault(self, key, value): """ Set an initial value if the key is not present yet :returns the value in the state for the given key """ key = self._translator.translate_key(key) if key in self._pushed_state: return self._pushed_state[key] return self._pending_update.setdefault(key, value) def is_dirty(self, *_args): """ Check if any provided key name(s) still has a pending changed that will need to be flushed. """ _args = self._translator.translate_list(_args) for name in _args: if name in self._pending_update: return True return False def is_dirty_all(self, *_args): """ Check if all provided key name(s) has a pending changed that will need to be flushed. """ count = 0 _args = self._translator.translate_list(_args) for name in _args: if name in self._pending_update: count += 1 return count == len(_args) def dirty(self, *_args): """ Mark existing variable name(s) to be modified in a way that they will be pushed again at flush time. Note that the variable(s) will be unmarked automatically when reset to its previous value. """ _args = self._translator.translate_list(_args) for key in _args: self._pending_update.setdefault(key, self._pushed_state.get(key)) def clean(self, *_args): """ Save pending variable(s) and unmark them as dirty. This will prevent change listener(s) to react or the client to be aware of any change. """ _args = self._translator.translate_list(_args) for key in _args: if key in self._pending_update: self._pushed_state[key] = self._pending_update.pop(key) def update(self, _dict): """Update the current state dict with the provided one""" _dict = self._translator.translate_dict(_dict) self._pending_update.update(_dict) for key in _dict: if _dict[key] == self._pushed_state.get(key, TRAME_NON_INIT_VALUE): self._pending_update.pop(key, None) @property def modified_keys(self): """ Return the set of state's keys that are modified for the current state.change update. Usage example: -------------- >>> NAMES = ["a", "b", "c"] >>> state.update({"a": 1, "b": 2, "c": 3}) >>> @state.change(*NAMES) ... def on_change(*_): ... for name in state.modified_keys: ... print(f"{name} value updated to {state[name]}") >>> with state: ... state.a += 1 >>> with state: ... state.a += 1 ... state.b += 2 >>> with state: ... state.a += 1 ... state.b += 2 ... state.c += 3 """ # for child server we may need to run the translator on them return self._modified_keys def flush(self): """ Force pushing modified state and execute any @state.change listener if the variable value is different (by value AND reference) from its previous value or if `dirty` has been flagged on the variable and it has not been unflagged since. """ if not self.is_ready: return None keys = set() if len(self._pending_update): _keys = set(self._pending_update.keys()) while len(_keys): keys |= _keys # update modified keys for current update batch self._modified_keys.clear() self._modified_keys |= _keys # Do the flush if self._push_state_fn: self._push_state_fn(self._pending_update) self._pushed_state.update(self._pending_update) self._pending_update.clear() # Execute state listeners self._state_listeners.add_all(_keys) for fn in self._state_listeners: if isinstance(fn, weakref.WeakMethod): callback = fn() if callback is None: continue else: callback = fn if self._hot_reload: if not inspect.iscoroutinefunction(callback): callback = reload(callback) coroutine = callback(**self._pushed_state) if inspect.isawaitable(coroutine): asynchronous.create_task(coroutine) self._state_listeners.clear() # Check if state change from state listeners _keys = set(self._pending_update.keys()) return keys @property def initial(self): """Return the initial state without triggering a flush""" self._pushed_state.update(self._pending_update) self._pending_update.clear() return self._pushed_state def __enter__(self): return self def __exit__(self, exc_type, exc_value, exc_traceback): self.flush() # ------------------------------------------------------------------------- # Annotations # ------------------------------------------------------------------------- def change(self, *_args, **_kwargs): """ Use as decorator `@server.change(key1, key2, ...)` so the decorated function will be called like so `_fn(**state)` when any of the listed key name is getting modified from either client or server. Can also be used as a function to decorate method functions (see 2nd example below) :param *_args: A list of variable name to monitor :type *_args: str :examples: >>> @state.change("a", "b") # for functions ... def on_change(a, b, **kwargs): ... pass >>> state.change("a")(self.on_a_change) # for methods :see-also TrameApp """ def register_change_callback(func): for n in _args: name = self._translator.translate_key(n) if name not in self._change_callbacks: self._change_callbacks[name] = [] self._change_callbacks[name].append(func) return func return register_change_callback trame-server-3.6.1/trame_server/ui.py000066400000000000000000000031051506331243000176160ustar00rootroot00000000000000from .utils import is_dunder class VirtualNodeManager: """VirtualNodeManager acts as a container for VirtualNode It allows widgets to be passed around that are not yet defined, and can be defined or re-defined later. For example: >>> with layout: ... ui.hello_widget(layout) # widget is currently undefined >>> with ui.hello_widget: ... html.Div("Hello") """ def __init__(self, server, vn_constructor=None): super().__setattr__("_server", server) super().__setattr__("_vn_dict", {}) super().__setattr__("_vn_constructor", vn_constructor) def __getitem__(self, name): return self.__getattr__(name) def __getattr__(self, name): if is_dunder(name): return super().__getattr__(name) if name not in self._vn_dict: self._vn_dict[name] = self._vn_constructor(trame_server=self._server) return self._vn_dict[name] def set_vn_constructor(self, constructor): """Should not be called by user""" self._vn_constructor = constructor def clear_layouts(self): """ Remove any reference to previously registered layouts across all VirutalNodes. """ for vn in self._vn_dict.values(): vn.clear_layouts() def flush_content(self): """Push all VirtualNode contents to registered layouts""" for vn in self._vn_dict.values(): vn.flush_content() def clear(self): """Clear all VirtualNode contents""" for vn in self._vn_dict.values(): vn.clear() trame-server-3.6.1/trame_server/utils/000077500000000000000000000000001506331243000177705ustar00rootroot00000000000000trame-server-3.6.1/trame_server/utils/__init__.py000066400000000000000000000044021506331243000221010ustar00rootroot00000000000000import msgpack from . import logger def share(obj, attr_name, default_value): if obj and hasattr(obj, attr_name): return getattr(obj, attr_name) return default_value def isascii(s): # For Python >= 3.7, use the built-in function return s.isascii() def is_dunder(s): # Check if this is a double underscore (dunder) name return len(s) > 4 and isascii(s) and s[:2] == s[-2:] == "__" def is_private(s): return isascii(s) and s[0] == "_" def clean_state(state): cleaned = {} str_values = {} for key in state: value = clean_value(state[key]) try: str_value = msgpack.packb(value) cleaned[key] = value str_values[key] = str_value except Exception: logger.error( f"Skip state value for '{key}' since its content is not serializable" ) return cleaned, str_values def clean_value(value): if isinstance(value, dict) and "_filter" in value.keys(): subset = {} subset.update(value) keys_to_filter = value.get("_filter") for key in keys_to_filter: subset.pop(key, None) return subset if isinstance(value, list): return list(map(clean_value, value)) return value def update_dict(destination, source): for key, value in source.items(): if isinstance(value, dict): # get node or create one container = destination.setdefault(key, {}) update_dict(container, value) else: destination[key] = value return destination def reduce_vue_use(state): _order = [] _options = {} _reduced = [] # Merge options for item in state.trame__vue_use: options = {} if isinstance(item, str): name = item else: name, options = item _options.setdefault(name, {}) if name not in _order: _order.append(name) update_dict(_options[name], options) # Generate new content for name in _order: if len(_options[name]): _reduced.append((name, _options[name])) else: _reduced.append(name) # Update state.trame__vue_use with cleaned up version state.trame__vue_use = _reduced trame-server-3.6.1/trame_server/utils/argument_parser.py000066400000000000000000000060031506331243000235370ustar00rootroot00000000000000import argparse import os import sys class ArgumentParser(argparse.ArgumentParser): """Custom argument parser for Trame In this class, `parse_known_args()` has a custom implementation. Normally, this would parse sys.argv[1:] as normal, but it will be different under the following conditions: 1. A `--trame-args` argument is provided. If this is provided, then only the arguments supplied to this argument will be used for trame. For instance: `--trame-args="-p 8081 --server"` will mean the args parsed for trame will be `-p 8081 --server`. 2. A `TRAME_ARGS` environment variable is set. If this is set, then only the arguments supplied to this environment variable will be used for trame. For instance: `TRAME_ARGS="-p 8081 --server"` will mean the args parsed for trame will be `-p 8081 --server`. 3. The `pytest` module has been loaded. If this is the case, then we will automatically ignore `sys.argv` because `pytest` will not allow for arguments it does not recognize. If `pytest` has been loaded, the only way to specify trame arguments is through the `TRAME_ARGS` environment variable mentioned in #2. """ def parse_known_args(self, args=None, namespace=None): # The trame arguments are usually specified as normal, but they # can be alternatively specified via an environment variable or # via `--trame-args="..."`. args = self._extract_trame_args(args) return super().parse_known_args(args, namespace) def _extract_trame_args(self, args): """Extract the arguments we will parse in trame""" if args is not None: # If the args is not None, then we are analyzing specific arguments. # Just return it the way it is. return args args = [] if self._skip_default_parsing else sys.argv[1:] if any(x.startswith("--trame-args") for x in sys.argv[1:]): # Allow argparse to handle all of the different ways this argument may be specified tmp_parser = argparse.ArgumentParser() tmp_parser.add_argument("--trame-args") out, _ = tmp_parser.parse_known_args([v for v in sys.argv[1:] if v != "--"]) # Add these in args += out.trame_args.split() elif "TRAME_ARGS" in os.environ: # If "--trame-args" wasn't specified, check the environment variable. args += os.environ["TRAME_ARGS"].split() return args @property def _skip_default_parsing(self): # Skip the default parsing if the trame arguments were set another # way, such as the environment variable or the `--trame-args="..."` arg. # Also, skip the default parsing if we are in pytest, because pytest # will definitely not allow us to have extra arguments anyways. return ( "TRAME_ARGS" in os.environ or any(x.startswith("--trame-args") for x in sys.argv[1:]) or "pytest" in sys.modules ) trame-server-3.6.1/trame_server/utils/asynchronous.py000066400000000000000000000124251506331243000231010ustar00rootroot00000000000000import asyncio import logging from . import is_dunder, is_private __all__ = [ "StateQueue", "create_state_queue_monitor_task", "create_task", "decorate_task", "handle_task_result", "task", ] QUEUE_EXIT = "STOP" def handle_task_result(task: asyncio.Task) -> None: try: task.result() except asyncio.CancelledError: pass # Task cancellation should not be logged as an error. except Exception: # pylint: disable=broad-except logging.exception("Exception raised by task = %r", task) def create_task(coroutine, loop=None): """ Create a task from a coroutine while also attaching a done callback so any exception or error could be caught and reported. :param coroutine: A coroutine to execute as an independent task :param loop: Optionally provide the loop on which the task should be scheduled on. By default we will use the current running loop. :return: The decorated task :rtype: asyncio.Task """ if loop is None: loop = asyncio.get_event_loop() return decorate_task(loop.create_task(coroutine)) def decorate_task(task): """ Decorate a task by attaching a done callback so any exception or error could be caught and reported. :param task: A coroutine to execute as an independent task :type task: asyncio.Task :return: The same task object :rtype: asyncio.Task """ task.add_done_callback(handle_task_result) return task async def _queue_update_state(server, queue, delay=1): _monitor_queue = True while _monitor_queue: if queue.empty(): await asyncio.sleep(delay) else: msg = queue.get_nowait() if isinstance(msg, str): if msg == QUEUE_EXIT: _monitor_queue = False else: with server.state: server.state.update(msg) def create_state_queue_monitor_task(server, queue, delay=1): """ Create and schedule a task to watch over the provided queue to update a server state. This is especially useful when using a multiprocess executor and you want to report progress into your current server. :param server: A coroutine to execute as an independent task :type server: trame_server.core.Server :param queue: A queue instance meant to exchange state from the parallel process to the given server :type queue: multiprocessing.Queue :param delay: Time to sleep in seconds before processing the queue once emptied :type delay: float :return: The monitoring task :rtype: asyncio.Task """ return create_task(_queue_update_state(server, queue, delay=delay)) class StateQueue: """ Class use to decorate a multiprocessing.Queue inside your external process to simulate your server state object. :param queue: A queue instance meant to exchange state from the parallel process to the given server :type queue: multiprocessing.Queue :param auto_flush: Should you manage the state update phase or just propagate as soon as you update a property :type auto_flush: Boolean """ def __init__(self, queue, auto_flush=True): self._queue = queue self._pending_update = {} self._pushed_state = {} self._auto_flush = auto_flush self._ctx_count = 0 @property def queue(self): """Provide access to the decorated queue""" return self._queue def __getitem__(self, key): return self._pending_update.get(key, self._pushed_state.get(key)) def __setitem__(self, key, value): self._pending_update[key] = value if self._auto_flush: self.flush() def __getattr__(self, key): if is_dunder(key): # Forward dunder calls to object return getattr(object, key) if is_private(key): return self.__dict__.get(key) return self.__getitem__(key) def __setattr__(self, key, value): if is_private(key): self.__dict__[key] = value else: self.__setitem__(key, value) def update(self, _dict): """ Update the distributed state from a set of key/value pair :param _dict: A dict containing one or many key/value pair :type _dict: dict """ self._pending_update.update(_dict) if self._auto_flush: self.flush() def flush(self): """Explicitly push any local change to the queue.""" if len(self._pending_update): self._queue.put_nowait(self._pending_update) self._pushed_state.update(self._pending_update) self._pending_update = {} def exit(self): """Release the monitoring task as we are done with our work""" self._queue.put_nowait(QUEUE_EXIT) def __enter__(self): self._ctx_count += 1 return self def __exit__(self, exc_type, exc_value, exc_traceback): self._ctx_count -= 1 if self._ctx_count == 0: self.flush() self.exit() def task(func): """Function decorator to make its async execution within a task""" def wrapper(*args, **kwargs): create_task(func(*args, **kwargs)) return wrapper trame-server-3.6.1/trame_server/utils/banner.py000066400000000000000000000002171506331243000216070ustar00rootroot00000000000000from pathlib import Path def print_banner(*_, **__): txt = Path(__file__).with_name("banner.txt").read_text() print(txt, flush=True) trame-server-3.6.1/trame_server/utils/banner.txt000066400000000000000000000102411506331243000217740ustar00rootroot00000000000000 ###### ######### ***** ########## ************** ###########**************** ########## *************** ########## ********** *** ########## ******** ******* ########## ******* ********** ########### ****** ######### ********** ########## ******* ############## ********** ######### ******* ############### ********** ##### ********* ############### ********** ##********** ########## ********** ********** ####### ***********#### ********** ### ###### **********######## ********** ####### ***** ####### ********** ########## ********** ########## ******** ###### ********** ########### ********** ########## ********** ####### ******** ########## ********* ##########********* ######### ***** ########## ***** ########## ***** ########## * ########## * ########## * * ########## * ########## * ########## *****########### ***** ########## ***** ######### ******** ########## *********########## ******** ####### ********** ########## *********** ########## ********** ###### ******** ########## ********** ########## ********** ####### ***** ####### ********** ####### ********** ###### ### ********** ### ********** ####### ********** *********** ########## **********## ********** ############### *********###### ********** ############### ******* ######### ********** ############## ****** ########## ********** ########## ****** ########### ********* ******* ########## ******* ******** ########## *** ********** ########## *************** ########## *************** ########### ************** ########## ******** ######## ###### ### ### ### ########### ########### ############ ######################### ########### ########### ########## ############## ########################## ############## ### #### #### #### #### #### #### #### ### #### #### #### #### #### #### #### ### #### ############# #### ### ### ################# ### #### ############### #### ### ### ################# ### #### ##### #### #### ### ### #### #### #### #### #### #### ### ### ### ##### #### #### ##### ##### #### ### ### ###### #### ########### #### ############### #### ### ### ############### ######## #### ######## ### ### ### ####### ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Powered By Kitware - https://www.kitware.com/ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ trame-server-3.6.1/trame_server/utils/browser.py000066400000000000000000000007071506331243000220310ustar00rootroot00000000000000def open_browser(server): args = server.cli.parse_known_args()[0] local_url = f"http://{args.host}:{server.port}/" try: import asyncio import webbrowser loop = asyncio.get_event_loop() loop.call_later(0.1, lambda: webbrowser.open(local_url)) print( "And to prevent your browser from opening, " "add '--server' to your command line." ) except Exception: pass trame-server-3.6.1/trame_server/utils/desktop.py000066400000000000000000000115211506331243000220130ustar00rootroot00000000000000import asyncio import threading from multiprocessing import Process, Queue from .asynchronous import handle_task_result try: import webview except ImportError as err: msg = "server.start(exec_mode='desktop', ...) requires pywebview>=3.4" raise ImportError(msg) from err WINDOW_ARGS = [ "html", "js_api", "width", "height", "x", "y", "screen", "resizable", "fullscreen", "min_size", "hidden", "frameless", "easy_drag", "focus", "minimized", "maximized", "on_top", "confirm_close", "background_color", "transparent", "text_select", "zoomable", "draggable", "vibrancy", "server", "server_args", "localization", ] def filter_dict(dict_to_filter, key_list): return {k: dict_to_filter[k] for k in key_list if k in dict_to_filter} def to_menu(menu_struct, fn_menu_click): if isinstance(menu_struct, list): return [to_menu(item, fn_menu_click) for item in menu_struct] if isinstance(menu_struct, str): return webview.menu.MenuSeparator() if isinstance(menu_struct, tuple): label, item = menu_struct if isinstance(item, list): return webview.menu.Menu(label, to_menu(item, fn_menu_click)) if isinstance(item, str): return webview.menu.MenuAction(label, lambda: fn_menu_click(item)) return None class BrowserProcess(Process): def __init__( self, title=None, port=None, msg_queue=None, action_queue=None, debug=False, gui=None, menu=None, **kwargs, ): Process.__init__(self) self._title = title self._port = port self._msg_queue = msg_queue self._action_queue = action_queue self._menu = menu or [] self._window_args = filter_dict(kwargs, WINDOW_ARGS) self._main_window = None # start args self._debug = debug self._gui = gui self._monitoring = True def exit(self): # It does not appear that we need to destroy the window self._monitoring = False self._msg_queue.put("closing") def menu_click(self, name): self._msg_queue.put(f"menu:{name}") async def _monitor_action_requests(self): while self._monitoring: await asyncio.sleep(0.5) if not self._action_queue.empty(): msg = self._action_queue.get_nowait() action = msg.get("action") args = msg.get("args", []) kwargs = msg.get("kwargs", {}) fn = getattr(self._main_window, action) if action == "destroy": self.exit() fn(*args, **kwargs) def run_in_thread(self, loop): asyncio.set_event_loop(loop) task = loop.create_task(self._monitor_action_requests()) task.add_done_callback(handle_task_result) loop.run_until_complete(task) def run(self): self._main_window = webview.create_window( title=self._title, url=f"http://localhost:{self._port}/index.html", **self._window_args, ) if hasattr(self._main_window, "events"): # Newer versions of pywebview (>=3.6) use window.events.closing self._main_window.events.closing += self.exit else: # Older versions (around pywebview<=3.5) use window.closing self._main_window.closing += self.exit event_loop = asyncio.new_event_loop() thread = threading.Thread(target=lambda: self.run_in_thread(event_loop)) webview.start( menu=to_menu(self._menu, self.menu_click), func=lambda: thread.start(), debug=self._debug, gui=self._gui, ) def start_browser(server, **kwargs): _msg_queue = Queue() _window_action_queue = Queue() _on_msg = kwargs.get("on_message") async def process_msg(): keep_processing = True while keep_processing: await asyncio.sleep(0.5) if not _msg_queue.empty(): msg = _msg_queue.get_nowait() if _on_msg: _on_msg(msg) if msg == "closing": keep_processing = False await server.stop() task = asyncio.create_task(process_msg()) task.add_done_callback(handle_task_result) client_process = BrowserProcess( title=server.state.trame__title, port=server.port, msg_queue=_msg_queue, action_queue=_window_action_queue, debug=server.options.get("desktop_debug"), **kwargs, ) client_process.start() def window_call(action, *args, **kwargs): _window_action_queue.put({"action": action, "args": args, "kwargs": kwargs}) server.controller.pywebview_window_call = window_call trame-server-3.6.1/trame_server/utils/hot_reload.py000066400000000000000000000236251506331243000224720ustar00rootroot00000000000000"""Automatically reload function code each time it is called This module provides a `hot_reload` decorator where the decorated function is reloaded from source every time the function is called. This works primarily for global functions and class methods, but it also has limited support for nested functions as well*. This module also provides a `reload` function that can be used to reload a function on demand, i. e. `new_func = reload(old_func)`. This module is based upon Julian Vossen's reloading library: https://github.com/julvo/reloading But it is heavily modified, and includes additional support for things like class methods. The license for the original library is pasted at the bottom of this file. * Nested functions have the following issues: 1. You cannot use the `nonlocal` statement in nested functions. When the code is reloaded, Python treats it as a global function, and therefore the `nonlocal` keyword is not allowed. 2. You cannot add capture variables during reloading. Whatever was captured when the function was first defined is what will continue to be captured. This is because the outer function is never reloaded, and the outer function's local scope may potentially be gone at the time of decorating. 3. If some of the capture variables share the same name as global variables, but are different, then references to the global variable within the function or functions it calls may end up referring to the capture variable instead. """ import ast import functools import inspect import site import sys import traceback import types from pathlib import Path try: from trame_client.ui.core import AbstractLayout from trame_client.widgets.core import AbstractElement # Skip any methods whose classes inherit from these SKIP_CLASSES = [AbstractElement, AbstractLayout] except ImportError: SKIP_CLASSES = [] # Strip any decorators with these names STRIP_DECORATORS = ["ctrl", "state", "life_cycle"] # We don't actually have logic right now to reload lambdas anyways SKIP_LAMBDA_FUNCS = True # If this is True, then skip any functions that are located under any # site packages directories. # This essentially means to skip any functions that are not located # in editable environments. SKIP_SITE_PACKAGES = True def hot_reload(func): """Decorator to reload the function on every call If there are multiple decorators on this function, only the decorators after the `@hot_reload` decorator will be reloaded """ if getattr(func, "__is_hot_reload_func", False): # It is already a hot_reload function. Just return it. # This prevents multiple hot_reload wrappers. return func @functools.wraps(func) def wrapped(*args, **kwargs): # If the user decorated the function manually, make sure we # don't perform checks and reload every time. new_func = reload(func, perform_checks=False) return new_func(*args, **kwargs) wrapped.__is_hot_reload_func = True return wrapped def reload(func, perform_checks=True): """Attempt to reload the provided function This works by locating the file the function was defined in, reloading its body of code, and returning the reloaded function. If perform_checks is True, then several checks will be performed beforehand to determine whether or not the function should be skipped. """ if perform_checks: if not isinstance(func, (types.FunctionType, types.MethodType)): # We only allow reloading of function and method types return func if isinstance(func, types.MethodType): if isinstance(func.__self__, tuple(SKIP_CLASSES)): # We need to skip reloading methods on this class return func if SKIP_LAMBDA_FUNCS and "" in func.__qualname__: # Skip lambda functions. It is harder to reload them. return func if SKIP_SITE_PACKAGES and _func_in_site_packages(func): # Skip any packages that were not installed in editable mode return func while True: try: return _reload_func(func) except Exception: _handle_exception(func) def _reload_func(func): func_locals = _find_function_locals(func) code = _recompile_function(func) # Unfortunately, exec is a little challenging here for non-global # functions. # Since the source function is compiled by itself, it is treated as # a global function, and therefore cannot have closure variables. # Because of this, the function will not have read access to the locals # we pass in (even though it can still write the function to the locals). # To work around this, we copy the locals to the globals (and some globals # may be over-written as a result). # An alternative work-around would be to add AST code to put a small # wrapper function around the function before compiling, such as: # def _wrapper(): # # _wrapper() # This may get the function to capture the local variables. globals_copy = func.__globals__.copy() globals_copy.update(func_locals) exec(code, globals_copy) new_func = globals_copy[func.__name__] if isinstance(func, types.MethodType): # This is a bound method. Make the new method bound as well. new_func = types.MethodType(new_func, func.__self__) return new_func def _find_function_locals(func): # First, look at the qualified name. # If is not in the qualified name, then the locals should be # the same as the globals. This is much easier for reloading. if "" not in func.__qualname__: return func.__globals__ # If is in the qualified name, then we may need captured closure # variables to run the function correctly. We will use these as the locals. # Note that this means that new closure variables cannot be added when # reloading the function. return inspect.getclosurevars(func).nonlocals def _recompile_function(func): tree = _parse_func_file_until_successful(func) if not _isolate_function_def(func.__name__, tree): path = inspect.getfile(func) msg = f"Failed to find '{func.__qualname__}' in file '{path}'" raise Exception(msg) return compile(tree, filename="", mode="exec") def _parse_func_file_until_successful(func): path = inspect.getfile(func) while True: source = _load_file(path) try: return ast.parse(source) except SyntaxError: _handle_exception(func) def _load_file(path): src = "" # while loop here since while saving, the file may sometimes be empty. while src == "": src = Path(path).read_text() return src + "\n" def _isolate_function_def(funcname, tree): """Strip everything but the function definition from the ast in-place. Also strips the hot_reload decorator (including all decorators before it) from the function definition""" for node in ast.walk(tree): if isinstance(node, ast.FunctionDef) and node.name == funcname: decorator_names = [_get_decorator_name(dec) for dec in node.decorator_list] if "hot_reload" in decorator_names: _strip_hot_reload_decorator(node) # Remove any decorators that we have indicated to strip _strip_decorators(node, STRIP_DECORATORS) tree.body = [node] return True return False def _handle_exception(func): fpath = inspect.getfile(func) exc = traceback.format_exc() exc = exc.replace('File ""', f'File "{fpath}"') sys.stderr.write(exc + "\n") print(f"Edit '{func.__qualname__}' in '{fpath}' and press return to continue") sys.stdin.readline() def _get_decorator_name(dec_node): if hasattr(dec_node, "id"): return dec_node.id if hasattr(dec_node, "func"): if hasattr(dec_node.func, "id"): return dec_node.func.id if hasattr(dec_node.func, "value"): return dec_node.func.value.id if hasattr(dec_node, "value"): return dec_node.value.id msg = f"Failed to find decorator name: {dec_node}" raise Exception(msg) def _strip_hot_reload_decorator(func): """Remove the 'hot_reload' decorator and all decorators before it""" decorator_names = [_get_decorator_name(dec) for dec in func.decorator_list] hot_reload_idx = decorator_names.index("hot_reload") func.decorator_list = func.decorator_list[hot_reload_idx + 1 :] def _strip_decorators(func, blacklist): """Strip only specific decorators""" func.decorator_list = [ dec for dec in func.decorator_list if _get_decorator_name(dec) not in blacklist ] def _func_in_site_packages(func): """Return whether a function is in any site-packages directories""" path = inspect.getfile(func) return any(path.startswith(x) for x in site.getsitepackages()) JULVO_RELOADING_LICENSE = """MIT License Copyright (c) 2019 Julian Vossen Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ trame-server-3.6.1/trame_server/utils/logger.py000066400000000000000000000037151506331243000216270ustar00rootroot00000000000000import json from pathlib import Path OUTPUT_LOG = False class EscapeEncoder(json.JSONEncoder): """Custom encoder for numpy data types""" def default(self, obj): if isinstance( obj, (bytes,), ): return str(type(obj)) return json.JSONEncoder.default(self, obj) def initialize_logger(config): global OUTPUT_LOG # noqa: PLW0603 OUTPUT_LOG = config.get("log_network", False) if OUTPUT_LOG and Path(OUTPUT_LOG).exists(): Path(OUTPUT_LOG).unlink() class StateExchangeType: STATE_INITIAL = "----------- INITIAL STATE -----------\n" STATE_CLIENT_TO_SERVER = "----------- STATE: Client => Server -----------\n" STATE_SERVER_TO_CLIENT = "----------- STATE: Server => Client -----------\n" ACTION_CLIENT_TO_SERVER = "----------- EVENT: Client => Server -----------\n" ACTION_SERVER_TO_CLIENT = "----------- EVENT: Server => Client -----------\n" def state_exchange(exchange, data): if OUTPUT_LOG: with Path(OUTPUT_LOG).open(mode="a") as f: f.write(exchange) f.write(json.dumps(data, indent=2, cls=EscapeEncoder)) f.write("\n") f.write("-" * 60) f.write("\n") def initial_state(data): state_exchange(StateExchangeType.STATE_INITIAL, data) def state_c2s(data): state_exchange(StateExchangeType.STATE_CLIENT_TO_SERVER, data) def state_s2c(data): state_exchange(StateExchangeType.STATE_SERVER_TO_CLIENT, data) def action_s2c(data): state_exchange(StateExchangeType.ACTION_SERVER_TO_CLIENT, data) def action_c2s(data): state_exchange(StateExchangeType.ACTION_CLIENT_TO_SERVER, data) def error(message): print(f"Error: {message}", flush=True) if OUTPUT_LOG: with Path(OUTPUT_LOG).open(mode="a") as f: f.write("-" * 60) f.write("\nERROR: ") f.write(message) f.write("\n") f.write("-" * 60) f.write("\n") trame-server-3.6.1/trame_server/utils/namespace.py000066400000000000000000000054121506331243000223000ustar00rootroot00000000000000import logging from more_itertools import split_when logger = logging.getLogger(__name__) RESERVED_CONTROLLER_NAMES = { "on_server_start", "on_server_bind", "on_server_ready", "on_client_connected", "on_client_exited", "on_server_exited", "on_server_reload", } JS_DELIMITOR = { "'", '"', ":", ";", "(", ")", "{", "}", " ", ".", ",", "!", "[", "]", "`", "\n", "+", "*", "/", "-", "|", "&", } def js_tokenizer(a, b): return a in JS_DELIMITOR or b in JS_DELIMITOR def vue_template_tokenizer(a, b): return a == b and a in {"{", "}"} def is_name_reserved(name): if name.startswith("trame__"): return True if name in RESERVED_CONTROLLER_NAMES: return True return False class Translator: """Helper for mapping or namespacing names for state or controller""" def __init__(self, prefix=None): logger.info("Translator(prefix=%s)", prefix) self._prefix = prefix self._transl = {} def set_prefix(self, prefix): self._prefix = prefix def add_translation(self, key, translated_key): self._transl[key] = translated_key def translate_key(self, key): # Reserved keys if is_name_reserved(key): return key if key in self._transl: return self._transl[key] if self._prefix: return f"{self._prefix}{key}" return key def translate_list(self, key_list): return [self.translate_key(v) for v in key_list] def translate_dict(self, key_dict): return {self.translate_key(k): v for k, v in key_dict.items()} def translate_js_expression(self, state, expression): tokens = [] for token in split_when(expression, js_tokenizer): token_str = "".join(token) logger.info("(prefix=%s) token %s", self._prefix, token_str) if state.has(token_str): _token = self.translate_key(token_str) logger.info("(prefix=%s) translated %s", self._prefix, _token) tokens.append(_token) else: tokens.append(token_str) logger.info(" => %s", "".join(tokens)) return "".join(tokens) def translate_vue_templating(self, state, expression): tokens = [] for token in split_when(expression, vue_template_tokenizer): token_str = "".join(token) logger.info(" token %s", token_str) if token_str.startswith("{"): tokens.append(self.translate_js_expression(state, token_str)) else: tokens.append(token_str) return "".join(tokens) def __call__(self, key): return self.translate_key(key) trame-server-3.6.1/trame_server/utils/server.py000066400000000000000000000013501506331243000216470ustar00rootroot00000000000000import socket import sys def print_informations(server): options = server.server_options local_url = f"http://{options.host}:{server.port}/" print() print("App running at:") print(f" - Local: {local_url}") try: try: host_ip = socket.gethostbyname(options.host) except Exception: host_name = socket.gethostname() host_ip = socket.gethostbyname(host_name) print(f" - Network: http://{host_ip}:{server.port}/") except socket.gaierror: print(f" - Network: http://{options.host}:{server.port}/") print() print( "Note that for multi-users you need to use and configure a launcher.", flush=True, ) sys.stdout.flush() trame-server-3.6.1/trame_server/utils/typed_state.py000066400000000000000000000551651506331243000227030ustar00rootroot00000000000000import inspect from abc import ABC, abstractmethod from dataclasses import MISSING, Field, fields, is_dataclass from datetime import date, datetime, time, timezone from decimal import Decimal from enum import Enum from pathlib import Path from types import UnionType from typing import ( Any, Callable, Generic, Iterable, Type, TypeVar, Union, cast, get_args, get_origin, get_type_hints, ) from uuid import UUID from trame_server.state import State T = TypeVar("T") V = TypeVar("V") class _SerializationFailure: """ Simple class to handle encoding / decoding failures """ def __init__(self, reason: str = ""): self.reason = reason def __eq__(self, other): return isinstance(other, _SerializationFailure) class IStateEncoderDecoder(ABC): """ State to/from primitive type encoding/decoding interface. """ _failure = _SerializationFailure() @abstractmethod def encode(self, obj): pass @abstractmethod def decode(self, obj, obj_type: type): pass @staticmethod def failed_serialization(reason: str = "") -> _SerializationFailure: return _SerializationFailure(reason) @classmethod def is_serialization_success(cls, value): return not isinstance(value, _SerializationFailure) class DefaultEncoderDecoder(IStateEncoderDecoder): """ Default primitive type encoding/decoding. """ def encode(self, obj): if isinstance(obj, UUID): return str(obj) if isinstance(obj, Enum): return obj.value if isinstance(obj, Decimal): return str(obj) if isinstance(obj, datetime): return obj.astimezone(timezone.utc).isoformat() if isinstance(obj, date): return obj.isoformat() if isinstance(obj, time): return obj.isoformat() if isinstance(obj, Path): return obj.as_posix() return obj def decode(self, obj, obj_type: type): if obj is None: return None if isinstance(obj, obj_type): return obj if issubclass(obj_type, datetime): return obj_type.fromisoformat(obj) if issubclass(obj_type, date): return obj_type.fromisoformat(obj) if issubclass(obj_type, time): return obj_type.fromisoformat(obj) # UUID, Decimal, Enum conversion use obj_type(obj) decoding return obj_type(obj) class CollectionEncoderDecoder(IStateEncoderDecoder): """ Encoding/decoding for lists, tuples, dicts and type unions. Delegates to an encoder list for contained types. Expects the encoder in its encoder list to return self.failed_serialization when encoding / decoding a specific type is not possible. If the delegate encoder raises an error, the error will be caught and considered as failed_serialization. Encoder will continue to the following encoder if the previous one wasn't able to encode / decode it. :param encoders: List of encoders to use when encoding/decoding lists and dicts. """ def __init__(self, encoders: list[IStateEncoderDecoder] | None = None): self._encoders = encoders or [DefaultEncoderDecoder()] def encode(self, obj): if is_dataclass(obj): return { field.name: self.encode(getattr(obj, field.name)) for field in fields(obj) } if isinstance(obj, dict): return {self.encode(key): self.encode(value) for key, value in obj.items()} if self._is_iterable(obj): return type(obj)(self.encode(value) for value in obj) for encoder in self._encoders: val = self._try_serialize(encoder.encode, obj) if self.is_serialization_success(val): return val _error_msg = f"Failed to encode object {obj}. No appropriate encoder in {self._encoders}." raise TypeError(_error_msg) @classmethod def _is_iterable(cls, obj): return isinstance(obj, list) or isinstance(obj, tuple) def _try_serialize(self, f, *args): try: return f(*args) except Exception as e: return self.failed_serialization(str(e)) def decode(self, obj, obj_type: type): val = self._try_decode(obj, obj_type) if self.is_serialization_success(val): return val _error_msg = f"Failed to decode object {obj} of type {obj_type}. No appropriate decoder in {self._encoders}." raise TypeError(_error_msg) def _try_decode(self, obj, obj_type: type): for decode in self._decode_strategies(): val = decode(obj, obj_type) if self.is_serialization_success(val): return val return self.failed_serialization() def _decode_strategies(self) -> list[Callable[[Any, type], Any]]: return [ self._decode_dataclass, self._decode_union, self._decode_dict, self._decode_iterable, self._delegate_decode, ] def _delegate_decode(self, obj, obj_type: type): for encoder in self._encoders: val = self._try_serialize(encoder.decode, obj, obj_type) if self.is_serialization_success(val): return val return self.failed_serialization() def _decode_dict(self, obj, obj_type: type): if not isinstance(obj, dict): return self.failed_serialization() key_type, value_type = get_args(obj_type) return { self.decode(key, key_type): self.decode(value, value_type) for key, value in obj.items() } def _decode_iterable(self, obj, obj_type: type): if not self._is_iterable(obj): return self.failed_serialization() value_type = get_args(obj_type)[0] return obj_type(self.decode(value, value_type) for value in obj) def _decode_union(self, obj, obj_type: type): if not self._is_union_type(obj_type): return self.failed_serialization() for sub_union_type in get_args(obj_type): val = self._try_decode(obj, sub_union_type) if self.is_serialization_success(val): return val return self.failed_serialization() def _decode_dataclass(self, obj, obj_type: type): if not is_dataclass(obj_type): return self.failed_serialization() field_types = get_type_hints(obj_type) decoded_dict = { field.name: self._try_decode(obj.get(field.name), field_types[field.name]) for field in fields(obj_type) } return obj_type(**decoded_dict) @classmethod def _is_union_type(cls, obj_type: type): return get_origin(obj_type) is Union or isinstance(obj_type, UnionType) class _ProxyField: """ Descriptor for proxy state fields to an equivalent dataclass field. If the dataclass provides default, or a default factory, the associated state will be initialized to the given encoded state value. :param state: Trame State which will be mutated / read from. :param state_id: Associated trame string id where the data will be pushed / read from. :param name: Name of the source field. :param field_type: Type of the source field. :param default: Default value of the source field. :param default_factory: Default factory of the source field. :param state_encoder: Encoder/decoder class for the proxy. """ def __init__( self, *, state: State, state_id: str, name: str, field_type: type, default, default_factory, state_encoder: IStateEncoderDecoder, ): self._state = state self._state_id = state_id self._name = name self._default = default self._encoder = state_encoder self._type = field_type # Set the default value to trame state if needed default_value = default if default_value == MISSING and default_factory != MISSING: default_value = default_factory() if default_value != MISSING: self._state.setdefault(self._state_id, self._encoder.encode(default_value)) def __get__(self, instance, owner): return self.get_value() def __set__(self, instance, value): self.set_value(value) def get_value(self): value = self._state[self._state_id] return self._encoder.decode(value, self._type) def set_value(self, value): self._state[self._state_id] = self._encoder.encode(value) class _NameField: """ Descriptor for fields to state id string equivalent. :param state_id: Associated trame string id where the data will be pushed / read from. """ def __init__(self, state_id: str): self._state_id = state_id def __get__(self, instance, owner): return self._state_id class TypedState(Generic[T]): """ Helper to have access to, mutate, and be notified of state changes using a strongly typed dataclass interface. TypedState provides a type-safe wrapper around the trame State object, allowing to: - Access and modify state using dataclass field names with full type hints - Bind change callbacks to specific fields or combinations of fields - Automatically handle encoding/decoding of complex types (enums, UUIDs, dates, etc.) - Use namespaces to avoid conflicts between different state objects """ _STATE_PROXY_DATACLASS_TYPE = "__state_proxy_dataclass_type" _STATE_PROXY_FIELD_DICT = "__state_proxy_field_dict" _STATE_PROXY_STATE_ID = "__state_proxy_state_id" def __init__( self, state: State, dataclass_type: Type[T], *, namespace="", encoders: list[IStateEncoderDecoder] | None = None, encoder: IStateEncoderDecoder | None = None, data: T | None = None, name: T | None = None, ): self._encoder = encoder or CollectionEncoderDecoder(encoders) self.state = state self.data = data or self._create_state_proxy( dataclass_type=dataclass_type, state=state, namespace=namespace, encoder=self._encoder, ) self.name = name or self._create_state_names_proxy( dataclass_type=dataclass_type, namespace=namespace, ) def bind_changes( self, change_dict: dict[Any | list[Any] | tuple[Any], Callable] ) -> None: """ Binds a typed state key change to the given input callback. Calls are strongly typed and will call the passed callback only with the input keys and not the full trame state. Binding is compatible with nested dataclass types. :param change_dict: Dict containing the key to callback mapping to bind. """ for key, callback in change_dict.items(): self.bind_typed_state_change(key, callback, self.state, self.data) def encode(self, value: Any) -> Any: """ Encodes the input value with the typed_state state encoder. """ return self._encoder.encode(value) def get_dataclass(self) -> T: """ :return: Current content of the typed state as dataclass. """ return self.as_dataclass(self.data) def set_dataclass(self, data: T) -> None: """ Set the content of the typed state from the input dataclass. Dataclass instance needs to match the dataclass type the typed state was constructed from. :param data: Instance of dataclass matching the typed state type. """ self.from_dataclass(self.data, data) @classmethod def _create_state_proxy( cls, dataclass_type: Type[T], state: State, *, namespace="", encoder: IStateEncoderDecoder | None = None, ) -> T: """ Returns a State proxy with the same field structure as the input dataclass and for each field returning a proxy to the associated state value. :param dataclass_type: Type of dataclass for which the proxy will be created. :param state: Trame state which will be mutated/read from by the proxy. :param namespace: Optional namespace for the trame state. All proxy field access will be using this namespace prefix. :param encoder: Optional encoder/decoder from dataclass field to trame state field. If not encoder is provided, will use a default encoder/decoder. """ encoder = encoder or CollectionEncoderDecoder(None) def handler(state_id: str, field: Field, field_type: type): return _ProxyField( state=state, state_id=state_id, name=field.name, default=field.default, default_factory=field.default_factory, field_type=field_type, state_encoder=encoder, ) return cls._build_proxy_cls(dataclass_type, namespace, handler, "__Proxy") @classmethod def _create_state_names_proxy(cls, dataclass_type: Type[T], *, namespace="") -> T: """ Returns a State proxy with the same field structure as the input dataclass and for each field returning the fully qualified state id name associated with a dataclass leaf. :param dataclass_type: Type of dataclass for which the proxy will be created. :param namespace: Optional namespace for the trame state. All proxy field access will be using this namespace prefix. """ def handler(state_id: str, _field: Field, _field_type: type): return _NameField(state_id=state_id) return cls._build_proxy_cls(dataclass_type, namespace, handler, "__ProxyName") @classmethod def _build_proxy_cls( cls, dataclass_type: Type[T], prefix: str, handler: Callable[[str, Field, type], Any], cls_suffix: str, proxy_field_dict: dict | None = None, ) -> T: """ Parses the input dataclass_type fields and construct a dataclass proxy based on its Field hierarchy. Visits each Field and nested dataclass recursively and forwards proxy field creation to the handler callable. :param dataclass_type: Type of the dataclass for which the proxy will be created :param prefix: State id prefix for each field created from the dataclass. :param handler: Callable which is responsible for creating the proxy attached in place of each Field. :param cls_suffix: Suffix attached to the created Proxy class type. :param proxy_field_dict: Dict of proxy fields at the parent level. Leave as None when starting recursion. :return: Created Proxy instance. """ namespace = {} class_name = dataclass_type.__name__ inner_field_dict = {} prefix = f"{prefix}__{class_name}" if prefix else class_name # Use type hints instead of field.type to avoid lazy evaluation of field.type when used in files containing # from __future__ import annotations header. field_types = get_type_hints(dataclass_type) for f in fields(dataclass_type): state_id = f"{prefix}__{f.name}" f_type = field_types[f.name] if is_dataclass(f_type): field = cls._build_proxy_cls( f_type, state_id, handler, cls_suffix, inner_field_dict ) else: field = handler(state_id, f, f_type) inner_field_dict[cls.get_state_id(field, state_id)] = field namespace[f.name] = field if proxy_field_dict is not None: proxy_field_dict.update(**inner_field_dict) # Add dataclass type, fields and state id to the proxy class namespace[cls._STATE_PROXY_DATACLASS_TYPE] = dataclass_type namespace[cls._STATE_PROXY_FIELD_DICT] = inner_field_dict namespace[cls._STATE_PROXY_STATE_ID] = prefix proxy_cls = type(f"{class_name}{cls_suffix}", (), namespace) # Create the proxy instance and add instance to the accessible proxy fields proxy_instance = proxy_cls() inner_field_dict[prefix] = proxy_instance return cast(T, proxy_instance) @classmethod def _get_proxy_dataclass_type(cls, instance: T) -> Type[T] | None: """ :return: dataclass type attached to the input proxy instance. """ return getattr(instance, cls._STATE_PROXY_DATACLASS_TYPE, None) @classmethod def _get_proxy_dataclass_type_or_raise(cls, instance: T) -> Type[T]: """ :return: dataclass type attached to the proxy instance :raises: RuntimeError if the input instance is not a proxy. """ kls = cls._get_proxy_dataclass_type(instance) if kls is None: _error_msg = ( f"Expected an instance of type __Proxy got {type(instance).__name__}." ) raise RuntimeError(_error_msg) return kls @classmethod def is_proxy_class(cls, instance: T) -> bool: """ :return: True if the input instance is a state proxy type. False otherwise. """ return cls._get_proxy_dataclass_type(instance) is not None @classmethod def is_name_proxy_class(cls, instance: T) -> bool: return cls.is_proxy_class(instance) and type(instance).__name__.endswith( "__ProxyName" ) @classmethod def is_data_proxy_class(cls, instance: T) -> bool: return cls.is_proxy_class(instance) and type(instance).__name__.endswith( "__Proxy" ) @classmethod def get_state_id(cls, instance: T, default: str = "") -> str: """ :return: State id string attached to the input state proxy or default if the input is not a state proxy instance. """ return getattr(instance, cls._STATE_PROXY_STATE_ID, default) @classmethod def as_dataclass(cls, instance: T) -> T: """ Converts the input state proxy instance to dataclass. """ dataclass_type = cls._get_proxy_dataclass_type_or_raise(instance) kwargs = {} for f in fields(dataclass_type): attr = getattr(instance, f.name) if cls.is_proxy_class(attr): kwargs[f.name] = cls.as_dataclass(attr) else: kwargs[f.name] = attr return dataclass_type(**kwargs) @classmethod def from_dataclass(cls, instance: T, dataclass_obj: T) -> None: """ Populate the state proxy instance from the values of the given dataclass object. """ dataclass_type = cls._get_proxy_dataclass_type_or_raise(instance) if not isinstance(dataclass_obj, dataclass_type): _error_msg = f"Expected instance of {dataclass_type.__name__}, got {type(dataclass_obj).__name__}" raise TypeError(_error_msg) for f in fields(dataclass_type): attr = getattr(instance, f.name) value = getattr(dataclass_obj, f.name) if cls.is_proxy_class(attr): cls.from_dataclass(attr, value) else: setattr(instance, f.name, value) @classmethod def get_field_proxy_dict(cls, instance: T) -> dict[str, _ProxyField]: """ :returns: State ID to ProxyField as saved in the input instance. """ cls._get_proxy_dataclass_type_or_raise(instance) return getattr(instance, cls._STATE_PROXY_FIELD_DICT, {}) @classmethod def get_reactive_state_id_keys(cls, keys: Iterable[Any]) -> list[str]: """ returns the list of state ids to react to on change from the input. :param keys: tuple of either str or dataclass proxy. :return: list of keys """ react_keys = [] for key in keys: if cls.is_proxy_class(key): react_keys.extend(list(cls.get_field_proxy_dict(key).keys())) else: react_keys.append(key) return react_keys @classmethod def get_value_state_keys(cls, keys: Iterable[Any]) -> list[str]: """ returns the list of keys in the proxy to return when the state modified a given key. :param keys: tuple of either str or dataclass proxy. :return: list of keys present in the proxy instance field dict """ return [cls.get_state_id(key, key) for key in keys] @classmethod def bind_typed_state_change( cls, keys: Any | list[Any] | tuple[Any], callback: Callable, state: State, data: T, ) -> None: """ Bind state id changes to callbacks. Callbacks are called with converted state values found with input keys. """ state_id_to_field_dict = cls.get_field_proxy_dict(data) if isinstance(keys, list): keys = tuple(keys) if not isinstance(keys, tuple): keys = (keys,) value_keys = cls.get_value_state_keys(keys) # On state change, get strongly typed values from the typed state and call the callback with the given values def _on_state_change(**_): values = [state_id_to_field_dict[k] for k in value_keys] values = [v if cls.is_proxy_class(v) else v.get_value() for v in values] return callback(*values) # Define an async variant for state change in case the bound method is async async def _on_state_change_async(**_): await _on_state_change() # Use state change closure to bind to direct or async version depending on the bound callback state.change(*cls.get_reactive_state_id_keys(keys))( _on_state_change_async if inspect.iscoroutinefunction(callback) else _on_state_change ) def get_sub_state(self, sub_name: V) -> "TypedState[V]": """ Create a TypedState based on a sub nested dataclass name proxy. The created TypedState will have the same state ids as the ones present in the full TypedState. This method can be used to simplify the connection of a "full" TypedState to sub UI components such as buttons or sliders. :returns: New typed state based on the given input sub name. """ if not self.is_name_proxy_class(sub_name): _error_msg = f"Sub state creation should be called with a Name Proxy instance. Got: {type(sub_name)}" raise RuntimeError(_error_msg) state_id = self.get_state_id(sub_name) sub_data = self.get_field_proxy_dict(self.data)[state_id] return TypedState( self.state, self._get_proxy_dataclass_type(sub_name), encoder=self._encoder, data=sub_data, name=sub_name, ) trame-server-3.6.1/trame_server/utils/version.py000066400000000000000000000011161506331243000220260ustar00rootroot00000000000000try: # Use importlib metadata if available (python >=3.8) from importlib.metadata import PackageNotFoundError, version except ImportError: # No importlib metadata. Try to use pkg_resources instead. from pkg_resources import ( DistributionNotFound as PackageNotFoundError, ) from pkg_resources import ( get_distribution, ) def version(x): return get_distribution(x).version def get_version(package_name): try: return version(package_name) except PackageNotFoundError: # package is not installed pass