├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .run ├── Python tests (headed) .run.xml └── Python tests (headless).run.xml ├── LICENSE ├── README.md ├── dev-notes └── rename-rpc.patch ├── docs ├── cli_usage.md ├── component.md ├── introduction.md └── rpc.md ├── incubator ├── accordion-custom-elements │ └── accordion-custom-elements.html ├── draggable-window-system │ ├── draggable-window.js │ └── index.html ├── drop-indicator │ └── index.html ├── getClientRects-vs │ └── getClientRects-vs.html ├── html-editor-vanilla-js │ ├── html-editor-claude.html │ └── html-editor-vanilla-js.html ├── html-tree │ ├── html-tree-marker-summary-hotzone-claude.html │ ├── html-tree-marker-summary-hotzone.html │ └── html-tree.html ├── names-slots │ └── named-slots-index.html ├── popup-notifications │ ├── popup-notification-custom-element.html │ └── popup-notification-library.html ├── pycharm-plugin │ └── plugin.groovy └── pytest-xvirt-fix-stacktrace │ ├── failed-remote-test-pytest-output.txt │ └── pytest-xvirt-EvtRuntestLogreport.json ├── licenses ├── README.md └── platformdirs-LICENSE ├── mypy.ini ├── pip-install-all.py ├── preprocessing ├── __init__.py └── shoelace │ ├── __init__.py │ ├── convert.py │ ├── vscode.html-custom-data.json │ └── web-types.json ├── pypi_helper.py ├── pypi_helper_test.py ├── pypi_inc_minor.py ├── pypi_upload.py ├── pyproject.toml ├── pytest.ini ├── src ├── wwwpy │ ├── __init__.py │ ├── _build_meta.py │ ├── asgi │ │ ├── __init__.py │ │ ├── asgi-compliant.md │ │ ├── echo_handler.py │ │ ├── favicon.png │ │ ├── favicon.txt │ │ ├── index.html │ │ ├── main_daphne.py │ │ ├── main_granian.py │ │ ├── main_hypercorn.py │ │ ├── main_tornado.py │ │ ├── main_uvicorn.py │ │ └── tornado_asgi_handler.py │ ├── base_conf.py │ ├── bootstrap.py │ ├── common │ │ ├── __init__.py │ │ ├── _raise_on_any.py │ │ ├── asynclib.py │ │ ├── collectionlib.py │ │ ├── databind │ │ │ ├── __init__.py │ │ │ └── databind.py │ │ ├── designer │ │ │ ├── __init__.py │ │ │ ├── canvas_selection.py │ │ │ ├── class_path.py │ │ │ ├── code_edit.py │ │ │ ├── code_info.py │ │ │ ├── code_strings.py │ │ │ ├── comp_info.py │ │ │ ├── el_common.py │ │ │ ├── el_shoelace.json │ │ │ ├── el_shoelace.py │ │ │ ├── el_standard.py │ │ │ ├── element_editor.py │ │ │ ├── element_library.py │ │ │ ├── html_edit.py │ │ │ ├── html_locator.py │ │ │ ├── html_parser.py │ │ │ ├── html_parser_mod.py │ │ │ ├── locator_lib.py │ │ │ ├── log_emit.py │ │ │ ├── new_component.py │ │ │ ├── packaging │ │ │ │ ├── __init__.py │ │ │ │ ├── package_manager.py │ │ │ │ └── packages.py │ │ │ ├── sl_icons.txt │ │ │ └── ui │ │ │ │ ├── __init__.py │ │ │ │ ├── _drop_indicator_svg.py │ │ │ │ ├── rect_readonly.py │ │ │ │ ├── rect_readonly_py.py │ │ │ │ └── svg.py │ │ ├── detect.py │ │ ├── escapelib.py │ │ ├── event_observer.py │ │ ├── eventbus.py │ │ ├── exitlib.py │ │ ├── extension_point.py │ │ ├── fetch.py │ │ ├── fetch_debug.py │ │ ├── files.py │ │ ├── filesystem │ │ │ ├── __init__.py │ │ │ └── sync │ │ │ │ ├── __init__.py │ │ │ │ ├── event.py │ │ │ │ ├── event_invert_apply.py │ │ │ │ ├── event_rebase.py │ │ │ │ └── sync_delta2.py │ │ ├── http_transport.py │ │ ├── indent.py │ │ ├── injectorlib.py │ │ ├── iterlib.py │ │ ├── loglib.py │ │ ├── modlib.py │ │ ├── property_monitor.py │ │ ├── quickstart │ │ │ ├── .gitignore │ │ │ ├── __init__.py │ │ │ ├── basic │ │ │ │ ├── readme.txt │ │ │ │ └── remote │ │ │ │ │ ├── __init__.py │ │ │ │ │ └── component1.py │ │ │ ├── chat │ │ │ │ ├── common │ │ │ │ │ ├── __init__.py │ │ │ │ │ └── name.py │ │ │ │ ├── readme.txt │ │ │ │ ├── remote │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── component1.py │ │ │ │ │ └── rpc.py │ │ │ │ └── server │ │ │ │ │ ├── __init__.py │ │ │ │ │ └── rpc.py │ │ │ ├── markdown │ │ │ │ ├── readme.txt │ │ │ │ └── remote │ │ │ │ │ ├── __init__.py │ │ │ │ │ └── component1.py │ │ │ └── upload │ │ │ │ ├── .gitignore │ │ │ │ ├── readme.txt │ │ │ │ ├── remote │ │ │ │ ├── __init__.py │ │ │ │ ├── component1.py │ │ │ │ └── upload_component.py │ │ │ │ └── server │ │ │ │ ├── __init__.py │ │ │ │ └── rpc.py │ │ ├── reloader.py │ │ ├── result.py │ │ ├── rpc │ │ │ ├── __init__.py │ │ │ ├── custom_loader.py │ │ │ ├── func_registry.py │ │ │ ├── hibrid_dispatcher.py │ │ │ ├── invoker.py │ │ │ ├── serialization.py │ │ │ ├── serializer.py │ │ │ └── v2 │ │ │ │ ├── __init__.py │ │ │ │ ├── caller_proxy.py │ │ │ │ └── dispatcher.py │ │ ├── rpc2 │ │ │ ├── __init__.py │ │ │ ├── default_skeleton.py │ │ │ ├── default_stub.py │ │ │ ├── encoder_decoder.py │ │ │ ├── skeleton.py │ │ │ ├── stub.py │ │ │ ├── transport.py │ │ │ └── typed_function.py │ │ ├── settingslib.py │ │ ├── state.py │ │ ├── strings.py │ │ ├── time_logger.py │ │ ├── tree.py │ │ └── type_listener.py │ ├── exceptions.py │ ├── http.py │ ├── platformdirs │ │ ├── __init__.py │ │ ├── __main__.py │ │ ├── android.py │ │ ├── api.py │ │ ├── macos.py │ │ ├── readme.txt │ │ ├── unix.py │ │ ├── version.py │ │ └── windows.py │ ├── remote │ │ ├── __init__.py │ │ ├── _elementlib.py │ │ ├── browser_main.py │ │ ├── component.py │ │ ├── databind │ │ │ ├── __init__.py │ │ │ └── bind_wrapper.py │ │ ├── designer │ │ │ ├── __init__.py │ │ │ ├── dev_mode.py │ │ │ ├── dev_mode_events.py │ │ │ ├── di_remote.py │ │ │ ├── drop_zone.py │ │ │ ├── global_interceptor.py │ │ │ ├── helpers.py │ │ │ ├── locator_js.py │ │ │ ├── log_redirect.py │ │ │ ├── rpc.py │ │ │ └── ui │ │ │ │ ├── __init__.py │ │ │ │ ├── accordion_components.py │ │ │ │ ├── button_tab.py │ │ │ │ ├── comp_structure.py │ │ │ │ ├── design_aware.py │ │ │ │ ├── dev_mode_component.py │ │ │ │ ├── drag_manager.py │ │ │ │ ├── element_selector.py │ │ │ │ ├── filesystem_tree.py │ │ │ │ ├── floater.py │ │ │ │ ├── floater_action_band.py │ │ │ │ ├── floater_drop_indicator.py │ │ │ │ ├── floater_selection_indicator.py │ │ │ │ ├── floater_selection_indicator_weird.py │ │ │ │ ├── help_icon.py │ │ │ │ ├── intent.py │ │ │ │ ├── intent_add_element.py │ │ │ │ ├── intent_manager.py │ │ │ │ ├── intent_select_element.py │ │ │ │ ├── locator_event.py │ │ │ │ ├── mailto_component.py │ │ │ │ ├── mailto_edit_component.py │ │ │ │ ├── new_toolbox.py │ │ │ │ ├── palette.py │ │ │ │ ├── pointer_api.py │ │ │ │ ├── property_editor.py │ │ │ │ ├── pushable_sidebar.py │ │ │ │ ├── python_console.py │ │ │ │ ├── quickstart_ui.py │ │ │ │ ├── searchable_combobox.py │ │ │ │ ├── searchable_combobox2.py │ │ │ │ ├── searchable_list_1.py │ │ │ │ ├── svg_icon.py │ │ │ │ ├── system_tools │ │ │ │ ├── __init__.py │ │ │ │ ├── logger_levels.py │ │ │ │ ├── system_tools_component.py │ │ │ │ └── system_versions.py │ │ │ │ ├── toolbox.py │ │ │ │ └── window_component.py │ │ ├── eventlib.py │ │ ├── fetch.py │ │ ├── files.py │ │ ├── hotkey.py │ │ ├── hotkeylib.py │ │ ├── idbfs.py │ │ ├── jslib.py │ │ ├── remote_fixtures.py │ │ ├── root_path.py │ │ ├── shoelace.py │ │ ├── simple_dark_theme.py │ │ ├── websocket.py │ │ └── widget.py │ ├── resources.py │ ├── rpc.py │ ├── server │ │ ├── __init__.py │ │ ├── __main__.py │ │ ├── asgi.py │ │ ├── configure.py │ │ ├── convention.py │ │ ├── custom_str.py │ │ ├── designer │ │ │ ├── __init__.py │ │ │ ├── dev_mode.py │ │ │ └── rpc.py │ │ ├── fetch.py │ │ ├── filesystem_sync │ │ │ ├── README.md │ │ │ ├── __init__.py │ │ │ ├── any_observer.py │ │ │ ├── debouncer.py │ │ │ ├── debouncer_thread.py │ │ │ ├── sync_delta.py │ │ │ ├── sync_zip.py │ │ │ └── watchdog_debouncer.py │ │ ├── proxy.py │ │ ├── pytestlib │ │ │ ├── __init__.py │ │ │ ├── playwrightlib.py │ │ │ ├── pytest_plugin.py │ │ │ ├── remote_conftest.py │ │ │ ├── remote_test_main.py │ │ │ └── xvirt_impl.py │ │ ├── rpc4tests.py │ │ ├── settingslib.py │ │ ├── tcp_port.py │ │ └── wait_url.py │ ├── unasync.py │ ├── webserver.py │ ├── webservers │ │ ├── __init__.py │ │ ├── asgi_webserver.py │ │ ├── available_webservers.py │ │ └── tornado.py │ └── websocket.py └── wwwpy_plugins │ ├── README.md │ └── wwwpy │ ├── README.md │ └── __init__.py ├── tests ├── __init__.py ├── available_webservers_test.py ├── common │ ├── __init__.py │ ├── _raise_on_any_test.py │ ├── collectionlib_test.py │ ├── databind │ │ ├── __init__.py │ │ └── test_databind.py │ ├── designer │ │ ├── __init__.py │ │ ├── canvas_selection_test.py │ │ ├── code_edit_test.py │ │ ├── code_info_test.py │ │ ├── code_strings_test.py │ │ ├── comp_info_test.py │ │ ├── component_fixture.py │ │ ├── element_editor_test.py │ │ ├── element_library_test.py │ │ ├── html_edit_test.py │ │ ├── html_locator_test.py │ │ ├── html_parser_test.py │ │ ├── locator_lib_test.py │ │ └── ui │ │ │ ├── __init__.py │ │ │ ├── _drop_indicator_svg_test.py │ │ │ ├── rect_readonly_py_test.py │ │ │ └── svg_test.py │ ├── event_observer_test.py │ ├── eventbus_test.py │ ├── exitlib_test.py │ ├── extension_point_test.py │ ├── files_test.py │ ├── injectorlib_test.py │ ├── iterlib_test.py │ ├── loglib_test.py │ ├── modlib_test.py │ ├── package_manager_test.py │ ├── property_monitor_test.py │ ├── quickstart_test.py │ ├── reload │ │ ├── __init__.py │ │ ├── reload_1 │ │ │ ├── component.py │ │ │ └── reload_1_test_disable.py │ │ ├── reload_2 │ │ │ ├── package2 │ │ │ │ ├── __init__.py │ │ │ │ ├── class_a.py │ │ │ │ └── class_b.py │ │ │ └── reload_2_test_disable.py │ │ └── reload_test.py │ ├── reloader_test.py │ ├── rpc │ │ ├── __init__.py │ │ ├── custom_loader_test.py │ │ ├── support1.py │ │ ├── support2.py │ │ ├── support3.py │ │ ├── test_ast_parser.py │ │ ├── test_invoker.py │ │ ├── test_rpc.py │ │ ├── test_serialization.py │ │ ├── test_serializer.py │ │ └── v2 │ │ │ └── caller_proxy_test.py │ ├── rpc2 │ │ ├── __init__.py │ │ ├── encoder_decoder_test.py │ │ ├── rpc_integration_test.py │ │ ├── stub_generator_test.py │ │ ├── transport_fake.py │ │ └── typed_function_test.py │ ├── settingslib_test.py │ ├── state_test.py │ └── version_test.py ├── conftest.py ├── layer │ ├── LAYERS.md │ ├── __init__.py │ ├── layer_2_support │ │ ├── build_archive │ │ │ └── simple │ │ │ │ ├── dir1 │ │ │ │ └── bar.txt │ │ │ │ └── foo.txt │ │ └── from_filesystem │ │ │ ├── one_file │ │ │ └── foo.py │ │ │ ├── relative_to │ │ │ └── yes │ │ │ │ └── yes.txt │ │ │ └── resource_filter │ │ │ └── yes │ │ │ ├── reject │ │ │ └── foo.txt │ │ │ └── yes.txt │ ├── layer_4_support │ │ ├── convention_b │ │ │ └── remote │ │ │ │ └── __init__.py │ │ ├── convention_c_async │ │ │ └── remote │ │ │ │ └── __init__.py │ │ └── convention_c_sync │ │ │ └── remote │ │ │ └── __init__.py │ ├── layer_5_support │ │ ├── rpc_remote │ │ │ └── remote │ │ │ │ ├── __init__.py │ │ │ │ └── rpc.py │ │ └── rpc_server │ │ │ ├── remote │ │ │ └── __init__.py │ │ │ └── server │ │ │ ├── __init__.py │ │ │ └── rpc.py │ ├── test_dev_mode.py │ ├── test_hot_reload.py │ ├── test_layer5_test_pool_change_failure.txt │ ├── test_layer_1.py │ ├── test_layer_2.py │ ├── test_layer_3.py │ ├── test_layer_4.py │ └── test_layer_5.py ├── remote │ ├── README.md │ ├── __init__.py │ ├── component_future_test.py │ ├── component_test.py │ ├── databind │ │ ├── __init__.py │ │ └── test_databind.py │ ├── designer │ │ ├── __init__.py │ │ ├── drop_zone_test.py │ │ ├── locator_js_test.py │ │ └── ui │ │ │ ├── __init__.py │ │ │ ├── accordion_test.py │ │ │ ├── button_tab_test.py │ │ │ ├── drag_manager_test.py │ │ │ ├── element_selector_test.py │ │ │ ├── intent_add_element_test.py │ │ │ ├── intent_manager_test.py │ │ │ ├── intent_select_element_test.py │ │ │ ├── locator_event_test.py │ │ │ ├── palette_test.py │ │ │ ├── pushable_sidebar_test.py │ │ │ ├── searchable_combobox_test.py │ │ │ ├── svg_test.py │ │ │ └── tool_selection_indicator_test.py │ ├── element_lib_test.py │ ├── eventlib_test.py │ ├── jslib_test.py │ ├── rpc4tests_helper.py │ ├── rpc4tests_test.py │ ├── test_in_pyodide.py │ ├── test_rpc.py │ └── test_widget.py ├── server │ ├── __init__.py │ ├── cmd_line_args_test.py │ ├── convention_fixture.py │ ├── dev_mode_test.py │ ├── filesystem_sync │ │ ├── __init__.py │ │ ├── activity_monitor.py │ │ ├── activity_monitor_test.py │ │ ├── any_observer_test.py │ │ ├── debouncer_test.py │ │ ├── event_invert_apply_test.py │ │ ├── event_logger.py │ │ ├── event_rebase_test.py │ │ ├── event_test.py │ │ ├── filesystem_fixture.py │ │ ├── fs_compare.py │ │ ├── mutator.py │ │ ├── sync_fixture.py │ │ ├── sync_test.py │ │ ├── time_mock.py │ │ ├── wakeup_recorder.py │ │ ├── watchdog-events.txt │ │ └── watchdog_debouncer_test.py │ ├── page_fixture.py │ ├── pytestlib │ │ ├── __init__.py │ │ └── xvirt_impl_test.py │ ├── remote_ui │ │ ├── __init__.py │ │ ├── drop_zone_test.py │ │ └── searchable_combobox_test.py │ ├── rpc │ │ ├── __init__.py │ │ ├── test_invoker.py │ │ ├── test_rpc.py │ │ └── test_rpc_sync.py │ ├── startup │ │ ├── __init__.py │ │ └── test_dev_mode.py │ └── tcp_port_test.py ├── test_unsync.py └── timeouts.py └── tox.ini /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | 4 | on: 5 | push: 6 | branches: [ '*' ] 7 | pull_request: 8 | branches: [ '*' ] 9 | workflow_dispatch: 10 | 11 | env: 12 | FORCE_COLOR: "1" # Make tools pretty. 13 | PLAYWRIGHT_PATCH_TIMEOUT_MILLIS: "45000" 14 | 15 | jobs: 16 | test: 17 | runs-on: ${{ matrix.runs-on }} 18 | strategy: 19 | matrix: 20 | python-version: [ "3.10", "3.11", "3.12", "3.13" ] 21 | runs-on: [ ubuntu-latest, macos-13, windows-latest ] 22 | fail-fast: false 23 | env: 24 | TOX_TESTENV_PASSENV: "XAUTHORITY DISPLAY" 25 | 26 | steps: 27 | - uses: actions/checkout@v4 28 | 29 | - name: Install uv 30 | uses: astral-sh/setup-uv@v5 31 | with: 32 | enable-cache: true 33 | version: "0.6.1" 34 | 35 | - name: Set up Python ${{ matrix.python-version }} 36 | run: uv python install ${{ matrix.python-version }} 37 | 38 | - name: Install tox with tox-uv 39 | run: uv tool install tox --with tox-uv 40 | 41 | - name: Run tests 42 | run: tox -e py 43 | -------------------------------------------------------------------------------- /.run/Python tests (headed) .run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 18 | -------------------------------------------------------------------------------- /.run/Python tests (headless).run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 18 | -------------------------------------------------------------------------------- /incubator/html-tree/html-tree-marker-summary-hotzone.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Minimal Tree Example 7 | 17 | 18 | 19 |

Tree Widget via details/summary

20 | 21 |
22 | Fruits 23 |
24 | Citrus 25 |
26 | Orange 27 |
28 |
29 | Lemon 30 |
31 |
32 |
33 | Berries 34 |
35 | Strawberry 36 |
37 |
38 | Blueberry 39 |
40 |
41 |
42 | 43 |
44 | Vegetables 45 |
46 | Root 47 |
48 | Carrot 49 |
50 |
51 | Beetroot 52 |
53 |
54 |
55 | Leafy Greens 56 |
57 | Spinach 58 |
59 |
60 | Lettuce 61 |
62 |
63 |
64 | 65 | 74 | 75 | 76 | 77 | -------------------------------------------------------------------------------- /incubator/html-tree/html-tree.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Minimal Tree Example 6 | 16 | 17 | 18 |

Tree Widget via details/summary

19 | 20 |
21 | Fruits 22 |
23 | Citrus 24 |
25 | Orange 26 |
27 |
28 | Lemon 29 |
30 |
31 |
32 | Berries 33 |
34 | Strawberry 35 |
36 |
37 | Blueberry 38 |
39 |
40 |
41 | 42 |
43 | Vegetables 44 |
45 | Root 46 |
47 | Carrot 48 |
49 |
50 | Beetroot 51 |
52 |
53 |
54 | Leafy Greens 55 |
56 | Spinach 57 |
58 |
59 | Lettuce 60 |
61 |
62 |
63 | 64 | 65 | -------------------------------------------------------------------------------- /incubator/pycharm-plugin/plugin.groovy: -------------------------------------------------------------------------------- 1 | import com.intellij.openapi.actionSystem.AnAction 2 | import com.intellij.openapi.actionSystem.AnActionEvent 3 | import com.intellij.openapi.project.DumbAware 4 | 5 | import static liveplugin.PluginUtil.registerAction 6 | import static liveplugin.PluginUtil.show 7 | 8 | import com.intellij.openapi.actionSystem.CommonDataKeys 9 | import static liveplugin.PluginUtil.* 10 | import com.intellij.openapi.wm.IdeFocusManager 11 | 12 | registerAction("Hello World", "alt shift H") { AnActionEvent event -> 13 | def project = event?.project 14 | def editor = CommonDataKeys.EDITOR.getData(event.dataContext) 15 | if (project == null || editor == null) { 16 | show("No project or editor") 17 | return 18 | } 19 | 20 | def caretModel = editor.caretModel 21 | def document = editor.document 22 | 23 | def line_no = 10 24 | def col_no = 5 25 | 26 | if (document.lineCount >= line_no) { 27 | def lineStartOffset = document.getLineStartOffset(line_no-1) 28 | def lineEndOffset = document.getLineEndOffset(line_no-1) 29 | def targetOffset = Math.min(lineStartOffset + col_no, lineEndOffset) 30 | 31 | caretModel.moveToOffset(targetOffset) 32 | IdeFocusManager.getInstance(project).requestFocus(editor.contentComponent, true) 33 | def scrollingModel = editor.scrollingModel 34 | scrollingModel.scrollToCaret(com.intellij.openapi.editor.ScrollType.RELATIVE) 35 | } 36 | show("scrolled to ${line_no}:${col_no}") 37 | } 38 | if (!isIdeStartup) 39 | show("Loaded 'Set caret and scroll to it'
Use alt+shift+H to run it") 40 | -------------------------------------------------------------------------------- /licenses/README.md: -------------------------------------------------------------------------------- 1 | ## Third-Party Library Licenses 2 | 3 | This folder contains the licenses for all third-party libraries used in this project. Each license file provides 4 | information about the specific terms under which the library is distributed, including any usage restrictions, 5 | permissions, and limitations. -------------------------------------------------------------------------------- /licenses/platformdirs-LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2010-202x The platformdirs developers 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | python_version = 3.7 3 | check_untyped_defs = true 4 | disallow_untyped_calls = true 5 | disallow_untyped_defs = true 6 | follow_imports = silent 7 | ignore_missing_imports = true 8 | show_column_numbers = true 9 | warn_incomplete_stub = false 10 | warn_redundant_casts = true 11 | warn_unused_ignores = true 12 | -------------------------------------------------------------------------------- /pip-install-all.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | 3 | 4 | def install_requirements(): 5 | subprocess.run(['uv', 'pip', 'install', '-e', '.[all]'], check=True) 6 | subprocess.run(['playwright', 'install-deps', 'chromium'], check=True) 7 | 8 | 9 | if __name__ == "__main__": 10 | install_requirements() 11 | -------------------------------------------------------------------------------- /preprocessing/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wwwpy-labs/wwwpy/2179159a90bd712a8507f67d667dbd28db9f7f74/preprocessing/__init__.py -------------------------------------------------------------------------------- /preprocessing/shoelace/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wwwpy-labs/wwwpy/2179159a90bd712a8507f67d667dbd28db9f7f74/preprocessing/shoelace/__init__.py -------------------------------------------------------------------------------- /pypi_helper_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import subprocess 4 | from pathlib import Path 5 | 6 | from pypi_helper import Pyproject 7 | from pypi_helper import update_minor_version 8 | 9 | pyproject_content = ''' 10 | [project] 11 | name = "wwwpy" 12 | version = "0.1.38" 13 | ''' 14 | 15 | 16 | def test_Pyproject_properties(): 17 | target = Pyproject(pyproject_content) 18 | 19 | assert target.version == '0.1.38' 20 | assert target.content == pyproject_content 21 | 22 | 23 | def test_Pyproject_version_inc_minor(): 24 | target = Pyproject(pyproject_content) 25 | target.version_inc_minor() 26 | 27 | assert target.version == '0.1.39' 28 | assert target.content == pyproject_content.replace('0.1.38', '0.1.39') 29 | 30 | 31 | def test_Pyproject_without_args__should_load_pyproject_toml(tmp_path): 32 | target = Pyproject() 33 | assert target.version 34 | assert target.content 35 | 36 | 37 | def test_update_minor_version(tmp_path): 38 | file = tmp_path / "pyproject.toml" 39 | file.write_text(pyproject_content) 40 | 41 | result = update_minor_version(file) 42 | assert result == 'Updated version to 0.1.39' 43 | updated_content = file.read_text() 44 | assert 'version = "0.1.39"' in updated_content 45 | 46 | def test_do_not_add_cr(tmp_path): 47 | file = tmp_path / "pyproject.toml" 48 | file.write_text('[project]\nversion = "0.1.38"\n#comment') 49 | 50 | update_minor_version(file) 51 | 52 | assert file.read_text() == '[project]\nversion = "0.1.39"\n#comment' 53 | -------------------------------------------------------------------------------- /pypi_inc_minor.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import subprocess 4 | 5 | from pypi_helper import uncommitted_changes, update_minor_version, write_build_meta 6 | 7 | 8 | def main(): 9 | if uncommitted_changes(): 10 | return 11 | 12 | msg = update_minor_version() 13 | if msg: 14 | print('=== committing changes') 15 | write_build_meta() 16 | subprocess.run(['git', 'commit', '-am', msg]) 17 | 18 | 19 | if __name__ == '__main__': 20 | main() 21 | -------------------------------------------------------------------------------- /pypi_upload.py: -------------------------------------------------------------------------------- 1 | import glob 2 | import os 3 | import subprocess 4 | import sys 5 | 6 | from pypi_helper import uncommitted_changes 7 | 8 | 9 | def main(): 10 | if uncommitted_changes(): 11 | return 12 | # Load environment variables from .env 13 | with open('.env') as f: 14 | for line in f: 15 | line = line.strip() 16 | if not line or line.startswith('#'): 17 | continue 18 | if '=' in line: 19 | key, value = line.split('=', 1) 20 | os.environ[key] = value 21 | 22 | # Remove all files in dist/ 23 | dist_files = glob.glob('dist/*') 24 | for file in dist_files: 25 | os.remove(file) 26 | 27 | # Build the package 28 | try: 29 | subprocess.run([sys.executable, '-m', 'build'], check=True) 30 | except subprocess.CalledProcessError as e: 31 | sys.exit(e.returncode) 32 | 33 | # Upload the package to PyPI 34 | dist_files = glob.glob('dist/*') 35 | if dist_files: 36 | subprocess.run([sys.executable, '-m', 'twine', 'upload'] + dist_files, check=True) 37 | subprocess.run(['git', 'push']) 38 | else: 39 | print("No files found in dist/ to upload.") 40 | 41 | 42 | if __name__ == '__main__': 43 | main() 44 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts=--showlocals 3 | asyncio_mode = auto 4 | asyncio_default_fixture_loop_scope = function 5 | log_cli_level=DEBUG 6 | 7 | python_files=test_*.py *_test.py *_fixture.py 8 | log_cli=1 9 | # 10 | ; addopts = --headed 11 | #--slowmo 2000 12 | ; addopts = --assert=plain 13 | -------------------------------------------------------------------------------- /src/wwwpy/__init__.py: -------------------------------------------------------------------------------- 1 | try: 2 | from . import _build_meta 3 | 4 | __version__ = _build_meta.__version__ 5 | __banner__ = f'wwwpy v{__version__}' 6 | except: 7 | __version__ = 'unknown' 8 | __banner__ = 'wwwpy version unknown' 9 | 10 | __all__ = ['__version__'] 11 | -------------------------------------------------------------------------------- /src/wwwpy/_build_meta.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.1.85" 2 | git_hash_short = "de8c216" 3 | git_hash = "de8c2161a0c53284014bbbc2bd5e92f7087227b7" 4 | -------------------------------------------------------------------------------- /src/wwwpy/asgi/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wwwpy-labs/wwwpy/2179159a90bd712a8507f67d667dbd28db9f7f74/src/wwwpy/asgi/__init__.py -------------------------------------------------------------------------------- /src/wwwpy/asgi/asgi-compliant.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 1. **Uvicorn** - Fast ASGI server built on `uvloop` and `httptools`. 6 | 2. **Daphne** - ASGI server maintained by the Django project, supports HTTP, HTTP2, and WebSockets. 7 | 3. **Hypercorn** - ASGI server built on `asyncio` and `trio`, supports HTTP/2 and WebSockets. 8 | 4. **Gunicorn with Uvicorn workers** - Flexible WSGI/ASGI server using Uvicorn as workers for ASGI support. 9 | 5. **Mangum** - ASGI adapter for AWS Lambda to deploy ASGI apps on serverless infrastructure. 10 | 6. **Starlette** - While primarily a framework, it can run as an ASGI app and be used with ASGI servers like Uvicorn. 11 | 7. **Quart** - An ASGI-compatible framework based on Flask, with a built-in server for ASGI. 12 | 8. **ASGIRef** - Lightweight, spec-compliant ASGI server. 13 | 9. **Litestar (formerly Starlite)** - ASGI server and web framework optimized for performance. 14 | 10. **FastAPI** - Although primarily a framework, FastAPI apps run on ASGI, typically with Uvicorn or Hypercorn. 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/wwwpy/asgi/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wwwpy-labs/wwwpy/2179159a90bd712a8507f67d667dbd28db9f7f74/src/wwwpy/asgi/favicon.png -------------------------------------------------------------------------------- /src/wwwpy/asgi/favicon.txt: -------------------------------------------------------------------------------- 1 | w cute at 2 | https://icons8.com/icon/ZUpyBfSaJr8a/w-cute 3 | -------------------------------------------------------------------------------- /src/wwwpy/asgi/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | WebSocket Echo Client 7 | 13 | 14 | 15 |

WebSocket Echo Test

16 | 17 | 18 | 19 | 20 |

Log:

21 | 22 | 23 | 66 | 67 | -------------------------------------------------------------------------------- /src/wwwpy/asgi/main_daphne.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | 3 | # uv pip install daphne 4 | subprocess.run(['daphne', 'wwwpy.asgi.echo_handler:app']) 5 | -------------------------------------------------------------------------------- /src/wwwpy/asgi/main_granian.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | from pathlib import Path 3 | 4 | wd = Path(__file__).parent.parent.parent 5 | 6 | # uv pip install granian 7 | subprocess.run(['granian', '--interface', 'asgi', 'wwwpy.asgi.echo_handler:app'], cwd=wd) 8 | -------------------------------------------------------------------------------- /src/wwwpy/asgi/main_hypercorn.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from hypercorn.config import Config 3 | from hypercorn.asyncio import serve 4 | 5 | from echo_handler import app 6 | 7 | # uv pip install hypercorn 8 | config = Config() 9 | config.bind = '0.0.0.0:8000' 10 | asyncio.run(serve(app, config)) 11 | -------------------------------------------------------------------------------- /src/wwwpy/asgi/main_tornado.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | 3 | # uv pip install tornado 4 | 5 | import tornado.ioloop 6 | import tornado.web 7 | import tornado_asgi_handler 8 | import echo_handler 9 | 10 | application = tornado.web.Application([ 11 | (r".*", tornado_asgi_handler.ASGIHandler, dict(asgi_app=echo_handler.app)) 12 | ]) 13 | application.listen(8000) 14 | 15 | try: 16 | tornado.ioloop.IOLoop.current().start() 17 | except KeyboardInterrupt as err: 18 | print("Server stopped") 19 | -------------------------------------------------------------------------------- /src/wwwpy/asgi/main_uvicorn.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | 3 | # uv pip install 'uvicorn[standard]' 4 | subprocess.run(['uvicorn', 'wwwpy.asgi.echo_handler:app', '--reload']) 5 | -------------------------------------------------------------------------------- /src/wwwpy/base_conf.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | _INITIAL = set(globals()) 4 | 5 | PLAYWRIGHT_PATCH_TIMEOUT_MILLIS = 45000 6 | PLAYWRIGHT_HEADFUL = False 7 | 8 | _NEW = set(globals()) - _INITIAL 9 | _NEW.remove('_INITIAL') 10 | try: 11 | import wwwpy_user_conf 12 | 13 | # list all the local custom variables and copy them (if they exists from wwwpy_user_conf) 14 | for key in _NEW: 15 | if hasattr(wwwpy_user_conf, key): 16 | value = getattr(wwwpy_user_conf, key) 17 | globals()[key] = value 18 | else: 19 | env_value = os.environ.get(key) 20 | if env_value is not None: 21 | globals()[key] = env_value 22 | 23 | except ModuleNotFoundError: 24 | import logging 25 | 26 | logger = logging.getLogger(__name__) 27 | logger.info("No local wwwpy_user_conf.py file found. Using default values.") 28 | -------------------------------------------------------------------------------- /src/wwwpy/common/__init__.py: -------------------------------------------------------------------------------- 1 | _no_remote_infrastructure_found_text = """ 2 | Import of package "remote" failed. Unable to bootstrap the application.""" 3 | 4 | # language=html 5 | _remote_module_not_found_html = """ 6 | Warning, no module 'remote' was found. 7 |
8 |
9 | This may be because the running directory is not a valid wwwpy project directory. 10 |
11 |
12 | If you want to create a new project from the quickstarter, be sure to run from an empty directory. 13 |
14 | Read for more information about the 15 | 16 | quickstarter and a 17 | 18 | general project structure. 19 | """ 20 | _warn = '\033[33m[WARN]\033[0m' 21 | _remote_module_not_found_console = f"""{_warn} The current project folder is not empty: $[directory] 22 | {_warn} It does not contain a `remote` package, which is required for a wwwpy project. 23 | If you want to create a new project from the quickstarter, be sure to run from an empty directory. 24 | Read for more information here: https://github.com/wwwpy-labs/wwwpy/blob/main/docs/introduction.md""" 25 | -------------------------------------------------------------------------------- /src/wwwpy/common/asynclib.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from typing import Union, Coroutine, Any 3 | 4 | # todo evaluate if best to use Awaitable instead of Coroutine 5 | OptionalCoroutine = Union[None, Coroutine[Any, Any, None]] 6 | _task_set = set() 7 | 8 | 9 | def create_task_safe(coro): 10 | """Create an asyncio task and keep a reference to prevent garbage collection. 11 | 12 | See https://stackoverflow.com/questions/71938799/python-asyncio-create-task-really-need-to-keep-a-reference 13 | """ 14 | task = asyncio.create_task(coro) 15 | _task_set.add(task) 16 | task.add_done_callback(_task_set.discard) 17 | return task 18 | -------------------------------------------------------------------------------- /src/wwwpy/common/collectionlib.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Callable, TypeVar, Any, Generic, Collection 4 | 5 | T = TypeVar('T') 6 | K = TypeVar('K') 7 | 8 | 9 | class ListMap(list[T]): 10 | def __init__(self, args: Collection[T] = (), key_func: Callable[[T], K] = None): 11 | super().__init__(args) 12 | if key_func is not None: 13 | self._key = key_func 14 | self._map = {self._key(item): item for item in self} 15 | 16 | # __add__ = _modify_method(list.__add__, takes_list=True) 17 | # __iadd__ = _modify_method(list.__iadd__, takes_list=True) 18 | # __setitem__ = _modify_method(list.__setitem__, 1) 19 | def _key(self, item: T) -> K: 20 | return item 21 | 22 | def append(self, value: T): 23 | self._map[self._key(value)] = value 24 | super().append(value) 25 | 26 | def insert(self, index: int, value: T): 27 | self._map[self._key(value)] = value 28 | super().insert(index, value) 29 | 30 | def extend(self, values: Collection[T]): 31 | for value in values: 32 | self._map[self._key(value)] = value 33 | super().extend(values) 34 | 35 | def get(self, key: K) -> T | None: 36 | """Return the item with the given key or None if it does not exist.""" 37 | return self._map.get(key) 38 | -------------------------------------------------------------------------------- /src/wwwpy/common/databind/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wwwpy-labs/wwwpy/2179159a90bd712a8507f67d667dbd28db9f7f74/src/wwwpy/common/databind/__init__.py -------------------------------------------------------------------------------- /src/wwwpy/common/databind/databind.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from typing import Callable, List 5 | 6 | from wwwpy.common.property_monitor import Monitor, PropertyChanged, set_origin, monitor_changes, Monitorable, \ 7 | get_monitor_or_create 8 | 9 | 10 | @dataclass 11 | class TargetOriginEvent: 12 | origin_target: TargetAdapter 13 | value: any 14 | 15 | 16 | # todo rename to BindingTarget or Bindable or BindableTarget? 17 | class TargetAdapter(Monitorable): 18 | 19 | def __init__(self): 20 | super().__init__() 21 | 22 | def set_target_value(self, value): 23 | pass 24 | 25 | def get_target_value(self): 26 | pass 27 | 28 | 29 | class Binding: 30 | 31 | def __init__(self, source, attr_name, target_adapter: TargetAdapter): 32 | super().__init__() 33 | self.source = source 34 | self.attr_name = attr_name 35 | self.target_adapter = target_adapter 36 | target_adapter.monitor_object.listeners.append(self._on_target_changes) 37 | get_monitor_or_create(source).add_attribute_listener(attr_name, self._on_source_changes) 38 | 39 | def apply_binding(self): 40 | self.target_adapter.set_target_value(getattr(self.source, self.attr_name)) 41 | 42 | def _on_target_changes(self, events: List[PropertyChanged]): 43 | event = events[-1] 44 | with set_origin(self.source, self): 45 | setattr(self.source, self.attr_name, event.new_value) 46 | 47 | def _on_source_changes(self, events: List[PropertyChanged]): 48 | event = events[-1] # todo we should filter only for the attr_name! 49 | if event.origin == self: 50 | return 51 | with set_origin(self.target_adapter, self): 52 | self.target_adapter.set_target_value(event.new_value) 53 | -------------------------------------------------------------------------------- /src/wwwpy/common/designer/__init__.py: -------------------------------------------------------------------------------- 1 | pypi_packages = ['libcst==1.4.0', 'rope==1.13.0'] -------------------------------------------------------------------------------- /src/wwwpy/common/designer/canvas_selection.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from dataclasses import dataclass 5 | 6 | from wwwpy.common.designer.locator_lib import Locator 7 | from wwwpy.common.type_listener import TypeListeners 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | @dataclass 13 | class CanvasSelectionChangeEvent: 14 | old: Locator | None 15 | new: Locator | None 16 | 17 | 18 | class CanvasSelection: 19 | """At the time of writing, this class is listened by the old infrastructure, but 20 | it is not updated by it. Who writes it is the new infrastructure. 21 | """ 22 | current_selection: Locator | None 23 | """It looks like the ElementPath should have Origin.live""" 24 | 25 | def __init__(self): 26 | self._current_selection = None 27 | self.on_change: TypeListeners[CanvasSelectionChangeEvent] = TypeListeners(CanvasSelectionChangeEvent) 28 | 29 | @property 30 | def current_selection(self) -> Locator | None: 31 | return self._current_selection 32 | 33 | @current_selection.setter 34 | def current_selection(self, value: Locator | None): 35 | old = self._current_selection 36 | self._current_selection = value 37 | event = CanvasSelectionChangeEvent(old, value) 38 | logger.debug(f'CanvasSelection: {old} -> {value}') 39 | self.on_change.notify(event) 40 | -------------------------------------------------------------------------------- /src/wwwpy/common/designer/class_path.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | 5 | 6 | @dataclass() 7 | class ClassPath: 8 | class_module: str 9 | """The module name of the Component.""" 10 | class_name: str 11 | """The class name of the Component.""" 12 | -------------------------------------------------------------------------------- /src/wwwpy/common/designer/log_emit.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import warnings 3 | from typing import Callable 4 | 5 | 6 | class _CustomHandler(logging.Handler): 7 | def __init__(self, callback: Callable[[str], None]): 8 | super().__init__() 9 | self._emit = callback 10 | 11 | def emit(self, record: logging.LogRecord): 12 | log_entry = self.format(record) 13 | self._emit(log_entry) 14 | 15 | 16 | def add_once(emit: Callable[[str], None]): 17 | logging.getLogger('tornado.access').setLevel(logging.ERROR) 18 | 19 | for log_name in ['common', 'remote', 'server']: 20 | logging.getLogger(log_name).setLevel(logging.DEBUG) 21 | 22 | root = logging.getLogger() 23 | 24 | for handler in root.handlers: 25 | if isinstance(handler, _CustomHandler): 26 | return 27 | from wwwpy.common.detect import is_pyodide 28 | # side = 'R' if is_pyodide() else 'S' 29 | side = '🌎' if is_pyodide() else '🏛' 30 | formatter = logging.Formatter(f'%(asctime)s {side} %(levelname).1s %(name)s:%(lineno)d - %(message)s') 31 | custom_handler = _CustomHandler(emit) 32 | custom_handler.setFormatter(formatter) 33 | root.addHandler(custom_handler) 34 | 35 | 36 | _original_showwarning = None 37 | 38 | 39 | def _custom_showwarning(message, category, filename, lineno, file=None, line=None): 40 | if _original_showwarning: 41 | _original_showwarning(message, category, filename, lineno, file, line) 42 | log_entry = f'{category.__name__}: {message} ({filename}:{lineno})' 43 | logging.getLogger('common').warning(log_entry) 44 | 45 | 46 | def warning_to_log(): 47 | global _original_showwarning 48 | if _original_showwarning: 49 | return 50 | _original_showwarning = warnings.showwarning 51 | warnings.showwarning = _custom_showwarning 52 | -------------------------------------------------------------------------------- /src/wwwpy/common/designer/new_component.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | from pathlib import Path 5 | from typing import List 6 | 7 | from wwwpy.common import modlib 8 | 9 | import logging 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | def is_component(path: Path): 15 | return (path.is_file() and path.suffix == '.py' and 16 | path.name.startswith('component')) 17 | 18 | 19 | def next_name_for(current_files: List[str]) -> tuple[int, str]: 20 | files = set(current_files) 21 | for i in range(1, sys.maxsize): 22 | name = f'component{i}.py' 23 | if name not in files: 24 | return i, name 25 | raise ValueError('Really?') 26 | 27 | 28 | def next_component_path(folder: Path | None = None) -> tuple[int, Path] | None: 29 | if folder is None: 30 | folder = modlib._find_package_directory('remote') 31 | if folder is None: 32 | logger.error('remote package not found') 33 | return None 34 | index, name = next_name_for([d.name for d in folder.iterdir() if is_component(d)]) 35 | file = folder / name 36 | assert not file.exists() 37 | return index, file 38 | 39 | 40 | def main(): 41 | add() 42 | 43 | 44 | def add(folder: Path | None = None) -> Path | None: 45 | if folder is None: 46 | folder = modlib._find_package_directory('remote') 47 | if folder is None: 48 | logger.error('remote package not found') 49 | return None 50 | 51 | index, name = next_name_for([d.name for d in folder.iterdir() if is_component(d)]) 52 | file = folder / name 53 | assert not file.exists() 54 | content = f'''import wwwpy.remote.component as wpc 55 | import js 56 | 57 | import logging 58 | 59 | logger = logging.getLogger(__name__) 60 | 61 | class Component{index}(wpc.Component, tag_name='component-{index}'): 62 | def init_component(self): 63 | # language=html 64 | self.element.innerHTML = """ 65 |
component-{index}
""" 66 | ''' 67 | file.write_text(content) 68 | return file 69 | 70 | 71 | if __name__ == '__main__': 72 | main() 73 | -------------------------------------------------------------------------------- /src/wwwpy/common/designer/packaging/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wwwpy-labs/wwwpy/2179159a90bd712a8507f67d667dbd28db9f7f74/src/wwwpy/common/designer/packaging/__init__.py -------------------------------------------------------------------------------- /src/wwwpy/common/designer/packaging/packages.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from dataclasses import dataclass 3 | 4 | 5 | @dataclass(frozen=True) 6 | class Package: 7 | name: str 8 | version: str 9 | 10 | 11 | @dataclass(frozen=True) 12 | class PackageSpecification: 13 | name: str 14 | version_specs: str | None = None 15 | """Version specifications for the package or None for any version. E.g. '==1.0.0' or '>=1.0.0' 16 | 17 | See https://packaging.python.org/en/latest/specifications/version-specifiers/ 18 | """ 19 | 20 | def build_installation_string(self) -> list[str]: 21 | if self.version_specs is None: 22 | return [self.name] 23 | return [f"{self.name}{self.version_specs}"] 24 | 25 | 26 | @dataclass(frozen=True) 27 | class PackageRequest: 28 | install: list[PackageSpecification] 29 | -------------------------------------------------------------------------------- /src/wwwpy/common/designer/ui/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wwwpy-labs/wwwpy/2179159a90bd712a8507f67d667dbd28db9f7f74/src/wwwpy/common/designer/ui/__init__.py -------------------------------------------------------------------------------- /src/wwwpy/common/designer/ui/rect_readonly.py: -------------------------------------------------------------------------------- 1 | from typing import Protocol 2 | 3 | 4 | class RectReadOnly(Protocol): 5 | """This is intended to be used in common to handle js.DOMRectReadOnly""" 6 | x: float 7 | y: float 8 | width: float 9 | height: float 10 | top: float 11 | right: float 12 | bottom: float 13 | left: float 14 | 15 | def toJSON(self) -> object: ... 16 | 17 | 18 | def rect_xy_center(rect: RectReadOnly) -> tuple[float, float]: 19 | """Get the center x and y coordinates of the given rect.""" 20 | x = rect.x + rect.width / 2 21 | y = rect.y + rect.height / 2 22 | return x, y 23 | 24 | 25 | def rect_to_py(rect: RectReadOnly) -> RectReadOnly: 26 | from . import rect_readonly_py 27 | return rect_readonly_py.RectReadOnlyPy(rect) 28 | -------------------------------------------------------------------------------- /src/wwwpy/common/detect.py: -------------------------------------------------------------------------------- 1 | def is_pyodide() -> bool: 2 | try: 3 | import pyodide 4 | return True 5 | except ImportError: 6 | return False 7 | -------------------------------------------------------------------------------- /src/wwwpy/common/escapelib.py: -------------------------------------------------------------------------------- 1 | # Create a translation table for the specified characters 2 | escape_table = str.maketrans({ 3 | '\r': '\\r', # Carriage return -> \r 4 | '\n': '\\n', # Newline (line feed) -> \n 5 | '\t': '\\t', # Tab -> \t 6 | '\\': '\\\\' # Backslash -> \\ 7 | }) 8 | 9 | 10 | def escape_string(s: str) -> str: 11 | return s.translate(escape_table) 12 | 13 | 14 | def unescape_string(s: str) -> str: 15 | escaped_bytes = s.encode('ascii', 'backslashreplace') 16 | return escaped_bytes.decode('unicode_escape') 17 | -------------------------------------------------------------------------------- /src/wwwpy/common/event_observer.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | 3 | 4 | class EventObserver: 5 | _last_event: datetime 6 | _now: callable 7 | 8 | def __init__(self, min_delay_millis, time_provider=None): 9 | self._min_delay_millis = min_delay_millis 10 | if time_provider is None: 11 | time_provider = datetime.utcnow 12 | self._now = time_provider 13 | self._update() 14 | 15 | def _update(self): 16 | self._last_event = self._now() 17 | 18 | def event_happened(self): 19 | self._update() 20 | 21 | def is_stable(self): 22 | if self._min_delay_millis == 0: 23 | return True 24 | now = self._now() 25 | diff: timedelta = now - self._last_event 26 | return diff.total_seconds() * 1000 > self._min_delay_millis 27 | -------------------------------------------------------------------------------- /src/wwwpy/common/exitlib.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import weakref 3 | from typing import Callable 4 | 5 | 6 | class _Obj: 7 | pass 8 | 9 | 10 | def on_exit(callback: Callable[[], None]) -> None: 11 | frame = sys._getframe(1) 12 | obj = _Obj() 13 | frame.f_locals[obj] = obj 14 | weakref.finalize(obj, callback) 15 | -------------------------------------------------------------------------------- /src/wwwpy/common/fetch.py: -------------------------------------------------------------------------------- 1 | async def async_fetch_str(url: str, method: str = 'GET', data: str = '') -> str: 2 | pass 3 | 4 | 5 | try: 6 | from wwwpy.remote.fetch import async_fetch_str 7 | except ImportError or ModuleNotFoundError: 8 | from wwwpy.server.fetch import async_fetch_str 9 | -------------------------------------------------------------------------------- /src/wwwpy/common/fetch_debug.py: -------------------------------------------------------------------------------- 1 | def fetch_debug(url, method, data) -> str: 2 | return f'method={method} len(data)={len(data)} url=`{url}` data=`{data[:100]}`' 3 | -------------------------------------------------------------------------------- /src/wwwpy/common/filesystem/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wwwpy-labs/wwwpy/2179159a90bd712a8507f67d667dbd28db9f7f74/src/wwwpy/common/filesystem/__init__.py -------------------------------------------------------------------------------- /src/wwwpy/common/filesystem/sync/__init__.py: -------------------------------------------------------------------------------- 1 | import tempfile 2 | from pathlib import Path 3 | from typing import List, Protocol, Any 4 | 5 | from wwwpy.common.filesystem.sync.event import Event 6 | 7 | 8 | def new_tmp_path() -> Path: 9 | return Path(tempfile.mkdtemp(prefix='debounce-tmp-path-')) 10 | 11 | class Sync(Protocol): 12 | 13 | @staticmethod 14 | def sync_source(source: Path, events: List[Event]) -> List[Any]: 15 | """Could be called 'sync_produce'. This part has the following input: 16 | - a reference to the root of the monitored source filesystem 17 | - a list of filesystem change events 18 | 19 | 20 | The output is: 21 | - a list of filesystem changes that can be serialized. 22 | - such list can be an aggregated list of changes (e.g., a file was created and then modified, the two events can be aggregated into a single event) 23 | 24 | """ 25 | 26 | @staticmethod 27 | def sync_target(target: Path, changes: List[Any]) -> None: 28 | """Could be called 'sync_apply'. This part has the following input: 29 | - a reference to the root of the target filesystem 30 | - a list of filesystem aggregated change events 31 | 32 | The output is: 33 | - the target filesystem is updated to reflect the changes 34 | """ 35 | 36 | @staticmethod 37 | def sync_init(source: Path) -> List[Any]: 38 | """This part has the following input: 39 | - a reference to the root of the monitored source filesystem 40 | 41 | The output is: 42 | - a list of filesystem aggregated change events that reflect the current state of the source filesystem 43 | """ 44 | -------------------------------------------------------------------------------- /src/wwwpy/common/filesystem/sync/event_rebase.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from pathlib import Path 4 | from typing import List, Set 5 | 6 | from wwwpy.common.filesystem import sync 7 | 8 | 9 | def filter_by_directory(events: List[sync.Event], directory_set: Set[str | Path]) -> List[sync.Event]: 10 | """Rebase events that falls in directory_set. 11 | It will retain the original path because the events will be used to send sync content to the remote; 12 | so the files still need to be accessible.""" 13 | new_events = [] 14 | path_set = {Path(d) for d in directory_set} 15 | 16 | def accept(event: sync.Event) -> bool: 17 | src_path = Path(event.src_path) 18 | for path in path_set: 19 | if src_path.is_relative_to(path): 20 | return True 21 | return False 22 | 23 | for event in events: 24 | if accept(event): 25 | new_events.append(event) 26 | return new_events 27 | -------------------------------------------------------------------------------- /src/wwwpy/common/filesystem/sync/sync_delta2.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import List, Any 3 | 4 | from wwwpy.common.rpc import serialization 5 | from wwwpy.common.filesystem.sync import Event 6 | from wwwpy.common.filesystem.sync import event_invert_apply 7 | 8 | 9 | def sync_source(source: Path, events: List[Event]) -> List[Any]: 10 | events_inverted = event_invert_apply.events_invert(source, events) 11 | ser = serialization.serialize(events_inverted, List[Event]) 12 | return ser 13 | 14 | 15 | def sync_target(target_root: Path, changes: List[Any]) -> None: 16 | events = serialization.deserialize(changes, List[Event]) 17 | event_invert_apply.events_apply(target_root, events) 18 | 19 | 20 | def sync_init(source: Path) -> List[Any]: 21 | events = event_invert_apply.events_init(source) 22 | ser = serialization.serialize(events, List[Event]) 23 | return ser 24 | 25 | 26 | -------------------------------------------------------------------------------- /src/wwwpy/common/indent.py: -------------------------------------------------------------------------------- 1 | import textwrap 2 | 3 | 4 | def indent_code(code: str, times=1) -> str: 5 | return textwrap.indent(code, ' ' * (4 * times)) 6 | -------------------------------------------------------------------------------- /src/wwwpy/common/iterlib.py: -------------------------------------------------------------------------------- 1 | from typing import Iterable, TypeVar, Generic, Callable, Iterator 2 | 3 | _T = TypeVar('_T') 4 | 5 | 6 | class repeatable_chain(Generic[_T]): 7 | def __init__(self, *iterables: Iterable[_T]): 8 | self._iterables = iterables 9 | 10 | def __iter__(self) -> Iterator[_T]: 11 | def it() -> Iterator[_T]: 12 | for iterable in self._iterables: 13 | yield from iterable 14 | 15 | return it() 16 | 17 | 18 | class CallableToIterable(Generic[_T]): 19 | def __init__(self, iterator_factory: Callable[[], Iterator[_T]]): 20 | self.iterator_factory = iterator_factory 21 | 22 | def __iter__(self) -> Iterator[_T]: 23 | try: 24 | return iter_catching(self.iterator_factory()) 25 | except: 26 | import traceback 27 | traceback.print_exc() 28 | return iter([]) 29 | 30 | 31 | def iter_catching(gen): 32 | while True: 33 | try: 34 | yield next(gen) 35 | except StopIteration: 36 | break 37 | except Exception as e: 38 | import traceback 39 | traceback.print_exc() 40 | -------------------------------------------------------------------------------- /src/wwwpy/common/loglib.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | 4 | def set_log_level(log_level: dict[str, str]): 5 | tr = translate_names(log_level) 6 | for module, level in tr.items(): 7 | logging.getLogger(module).setLevel(level) 8 | 9 | 10 | def translate_names(log_level): 11 | translated = {} 12 | for module, level in log_level.items(): 13 | name = logging.getLevelName(level) 14 | if isinstance(name, int): 15 | translated[module] = name 16 | return translated 17 | -------------------------------------------------------------------------------- /src/wwwpy/common/modlib.py: -------------------------------------------------------------------------------- 1 | """Module library for finding module paths and roots.""" 2 | from __future__ import annotations 3 | 4 | import os 5 | import sys 6 | from pathlib import Path 7 | 8 | import logging 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | def _find_module_path(module_name: str) -> Path | None: 13 | """Finds the path of a module without loading it.""" 14 | parts = module_name.split('.') 15 | for sys_path in map(Path, sys.path): 16 | module_path = sys_path.joinpath(*parts) 17 | py_file = module_path.with_suffix('.py') 18 | init_file = module_path / '__init__.py' 19 | 20 | if py_file.is_file(): 21 | return py_file 22 | if module_path.is_dir() and init_file.is_file(): 23 | return init_file 24 | 25 | return None 26 | 27 | def _find_package_directory(package_name) -> Path | None: 28 | """Finds the directory of a package without loading it.""" 29 | path = _find_module_path(package_name) 30 | if not path: 31 | return None 32 | if path.name != '__init__.py': 33 | raise ValueError(f'package {package_name} is not a package') 34 | return path.parent 35 | 36 | 37 | def _find_module_root(fqn, full_path): 38 | parts = fqn.split('.') 39 | relative_path = os.path.join(*parts[:-1]) + '.py' 40 | full_path = os.path.abspath(full_path) 41 | index = full_path.rfind(relative_path) 42 | if index == -1: 43 | return None 44 | return full_path[index:] 45 | -------------------------------------------------------------------------------- /src/wwwpy/common/quickstart/.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ -------------------------------------------------------------------------------- /src/wwwpy/common/quickstart/basic/readme.txt: -------------------------------------------------------------------------------- 1 | Basic setup for a new project 2 | 3 | It contains just a simple Component with almost no content -------------------------------------------------------------------------------- /src/wwwpy/common/quickstart/basic/remote/__init__.py: -------------------------------------------------------------------------------- 1 | from js import document 2 | 3 | 4 | async def main(): 5 | from wwwpy.remote import shoelace 6 | shoelace.setup_shoelace() 7 | 8 | from . import component1 # for component registration 9 | document.body.innerHTML = '' 10 | -------------------------------------------------------------------------------- /src/wwwpy/common/quickstart/basic/remote/component1.py: -------------------------------------------------------------------------------- 1 | import wwwpy.remote.component as wpc 2 | import js 3 | 4 | import logging 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | 9 | class Component1(wpc.Component, tag_name='component-1'): 10 | 11 | def init_component(self): 12 | # language=html 13 | self.element.innerHTML = """ 14 |
component-1
15 | """ 16 | -------------------------------------------------------------------------------- /src/wwwpy/common/quickstart/chat/common/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wwwpy-labs/wwwpy/2179159a90bd712a8507f67d667dbd28db9f7f74/src/wwwpy/common/quickstart/chat/common/__init__.py -------------------------------------------------------------------------------- /src/wwwpy/common/quickstart/chat/common/name.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | adjectives = [ 4 | 'adaptive', 'agile', 'ambitious', 'analytical', 'bold', 'brilliant', 'clever', 'crazy', 'creative', 'curious', 5 | 'daring', 'determined', 'dynamic', 'efficient', 'energetic', 'fearless', 'focused', 'innovative', 'insightful', 6 | 'inspired', 'intuitive', 'masterful', 'mighty', 'nimble', 'passionate', 'proactive', 'quick', 'reliable', 7 | 'resourceful', 'savvy', 'sharp', 'skillful', 'sleek', 'smart', 'strategic', 'talented', 'tenacious', 8 | 'versatile', 'visionary', 'witty' 9 | ] 10 | 11 | roles = [ 12 | 'ace', 'analyst', 'architect', 'artisan', 'builder', 'champion', 'coder', 'connoisseur', 'creator', 'debugger', 13 | 'designer', 'developer', 'dynamo', 'engineer', 'expert', 'genius', 'guru', 'innovator', 'luminary', 'maestro', 14 | 'maintainer', 'manager', 'master', 'maverick', 'ninja', 'operator', 'pioneer', 'prodigy', 'programmer', 'sage', 15 | 'specialist', 'strategist', 'technician', 'trailblazer', 'tuner', 'virtuoso', 'visionary', 'whiz', 'wizard', 16 | 'wrangler' 17 | ] 18 | 19 | 20 | def generate_name(): 21 | return f"{random.choice(adjectives)}-{random.choice(roles)}" 22 | -------------------------------------------------------------------------------- /src/wwwpy/common/quickstart/chat/readme.txt: -------------------------------------------------------------------------------- 1 | A simple chat application 2 | 3 | It shows how to send messages from the browser (remote) to the server (local). 4 | It also shows how to send messages from the server to all the browsers currently connected. 5 | 6 | -------------------------------------------------------------------------------- /src/wwwpy/common/quickstart/chat/remote/__init__.py: -------------------------------------------------------------------------------- 1 | from js import document 2 | 3 | 4 | async def main(): 5 | from wwwpy.remote import shoelace 6 | shoelace.setup_shoelace() 7 | 8 | from . import component1 # for component registration 9 | document.body.innerHTML = '' 10 | -------------------------------------------------------------------------------- /src/wwwpy/common/quickstart/chat/remote/rpc.py: -------------------------------------------------------------------------------- 1 | messages_listeners = [] 2 | 3 | 4 | class Rpc: 5 | def new_message(self, msg: str): 6 | for listener in messages_listeners: 7 | listener(msg) 8 | -------------------------------------------------------------------------------- /src/wwwpy/common/quickstart/chat/server/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wwwpy-labs/wwwpy/2179159a90bd712a8507f67d667dbd28db9f7f74/src/wwwpy/common/quickstart/chat/server/__init__.py -------------------------------------------------------------------------------- /src/wwwpy/common/quickstart/chat/server/rpc.py: -------------------------------------------------------------------------------- 1 | from remote import rpc 2 | 3 | from wwwpy.server.convention import default_project 4 | 5 | 6 | async def send_message_to_all(msg: str) -> str: 7 | for client in default_project().websocket_pool.clients: 8 | client.rpc(rpc.Rpc).new_message(msg) 9 | return 'done' 10 | -------------------------------------------------------------------------------- /src/wwwpy/common/quickstart/markdown/readme.txt: -------------------------------------------------------------------------------- 1 | Render markdown as you type 2 | 3 | This component demonstrates how to build an interactive markdown renderer using the markdown PyPI package. -------------------------------------------------------------------------------- /src/wwwpy/common/quickstart/markdown/remote/__init__.py: -------------------------------------------------------------------------------- 1 | from js import document 2 | 3 | 4 | async def main(): 5 | from wwwpy.remote import shoelace 6 | shoelace.setup_shoelace() 7 | 8 | from . import component1 # for component registration 9 | document.body.innerHTML = '' 10 | -------------------------------------------------------------------------------- /src/wwwpy/common/quickstart/markdown/remote/component1.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import wwwpy.remote.component as wpc 4 | import js 5 | 6 | import logging 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | class Component1(wpc.Component, tag_name='component-1'): 12 | textarea1: js.HTMLTextAreaElement = wpc.element() 13 | div1: js.HTMLDivElement = wpc.element() 14 | title: js.HTMLElement = wpc.element() 15 | 16 | def init_component(self): 17 | # language=html 18 | self.element.innerHTML = """ 19 |
Loading...
20 | 21 | 33 | 34 | 35 |
36 |
37 | 38 | """ 39 | asyncio.ensure_future(self.after_init()) 40 | 41 | async def after_init(self): 42 | logger.info('Component1 after_init') 43 | import micropip 44 | await micropip.install('markdown') 45 | import markdown 46 | # language=markdown 47 | self.title.innerHTML = markdown.markdown(f""" 48 | # Component1 49 | - This is a simple component that uses a textarea and a div to render markdown. 50 | - This text itself is rendered using markdown. 51 | """) 52 | self.textarea1.value = js.localStorage.getItem('textarea1') or 'Write your _markdown_ here ...' 53 | self._render_markdown() 54 | 55 | async def textarea1__input(self, event): 56 | js.localStorage.setItem('textarea1', self.textarea1.value) 57 | self._render_markdown() 58 | 59 | def _render_markdown(self): 60 | import markdown 61 | self.div1.innerHTML = markdown.markdown(self.textarea1.value) 62 | -------------------------------------------------------------------------------- /src/wwwpy/common/quickstart/upload/.gitignore: -------------------------------------------------------------------------------- 1 | uploads 2 | .idea -------------------------------------------------------------------------------- /src/wwwpy/common/quickstart/upload/readme.txt: -------------------------------------------------------------------------------- 1 | A simple component to upload files to the server 2 | 3 | It shows how to upload files from the browser (remote) to the server python process (server). 4 | It supports multiple file uploads with progress bars. -------------------------------------------------------------------------------- /src/wwwpy/common/quickstart/upload/remote/__init__.py: -------------------------------------------------------------------------------- 1 | from js import document 2 | 3 | 4 | async def main(): 5 | from wwwpy.remote import shoelace 6 | shoelace.setup_shoelace() 7 | 8 | from . import component1 # for component registration 9 | from . import upload_component # for component registration 10 | document.body.innerHTML = '' 11 | -------------------------------------------------------------------------------- /src/wwwpy/common/quickstart/upload/remote/component1.py: -------------------------------------------------------------------------------- 1 | import js 2 | import logging 3 | 4 | import wwwpy.remote.component as wpc 5 | 6 | from .upload_component import UploadComponent 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | class Component1(wpc.Component, tag_name='component-1'): 12 | multiple_checkbox: js.HTMLInputElement = wpc.element() 13 | upload1: UploadComponent = wpc.element() 14 | 15 | def init_component(self): 16 | # language=html 17 | self.element.innerHTML = """ 18 | 19 |
20 |
Component1 in component1.py
21 |
The files are uploaded in the project root 'uploads' folder. To change this behaviour see file server/rpc.py
22 | 25 |

The component below the line is defined in upload_component.py

26 |
27 | 28 |
29 | """ 30 | self.multiple_checkbox.checked = self.upload1.multiple 31 | 32 | async def multiple_checkbox__input(self, event): 33 | js.console.log('handler multiple_checkbox__input event =', event) 34 | self.upload1.multiple = self.multiple_checkbox.checked 35 | 36 | 37 | -------------------------------------------------------------------------------- /src/wwwpy/common/quickstart/upload/server/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wwwpy-labs/wwwpy/2179159a90bd712a8507f67d667dbd28db9f7f74/src/wwwpy/common/quickstart/upload/server/__init__.py -------------------------------------------------------------------------------- /src/wwwpy/common/quickstart/upload/server/rpc.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import logging 3 | from pathlib import Path 4 | 5 | logger = logging.getLogger(__name__) 6 | 7 | 8 | async def upload_init(name: str, size: int): 9 | file = _resolve_file(name) 10 | file.unlink(missing_ok=True) 11 | file.touch() 12 | logger.info(f'upload_init name={name} size={size}') 13 | return None 14 | 15 | 16 | async def upload_append(name: str, b64str: str): 17 | logger.info(f'upload_append name={name} len(b64str)={len(b64str)}') 18 | file = _resolve_file(name) 19 | bytes_append = base64.b64decode(b64str) 20 | with file.open('ab') as f: 21 | f.write(bytes_append) 22 | return None 23 | 24 | 25 | async def upload_abort(name: str): 26 | logger.info(f'upload_abort name={name}') 27 | file = _resolve_file(name) 28 | file.unlink(missing_ok=True) 29 | return None 30 | 31 | 32 | def _resolve_file(name: str) -> Path: 33 | folder = Path(__file__).parent.parent / 'uploads' 34 | candidate = folder / name 35 | # security check: candidate is inside the folder? 36 | if not candidate.resolve().is_relative_to(folder.resolve()): 37 | raise ValueError(f'Invalid path: {candidate}') 38 | candidate.parent.mkdir(parents=True, exist_ok=True) 39 | return candidate 40 | -------------------------------------------------------------------------------- /src/wwwpy/common/reloader.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | import sys 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | 9 | def reload(module): 10 | import importlib 11 | # importlib.invalidate_caches() 12 | return importlib.reload(module) 13 | 14 | 15 | def unload_path(path: str, skip_wwwpy: bool = False): 16 | def accept(module): 17 | try: 18 | module_file = getattr(module, '__file__', None) 19 | if module_file: 20 | return module_file.startswith(path) and module_file != __file__ 21 | module_path = getattr(module, '__path__', None) 22 | if module_path: 23 | acc = any(p.startswith(path) for p in module_path) 24 | if acc and not all(p.startswith(path) for p in module_path): 25 | logger.warning(f'hot-reload: module `{module}` has mixed paths: {module_path}') 26 | return acc 27 | except: 28 | return False 29 | 30 | names = [name for name, module in sys.modules.items() if accept(module)] 31 | 32 | for name in names: 33 | if skip_wwwpy and name.startswith('wwwpy.') or name == 'wwwpy': 34 | logger.debug(f'hot-reload: skip module `{name}`') 35 | else: 36 | logger.debug(f'hot-reload: unload module `{name}`') 37 | del (sys.modules[name]) 38 | -------------------------------------------------------------------------------- /src/wwwpy/common/rpc/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wwwpy-labs/wwwpy/2179159a90bd712a8507f67d667dbd28db9f7f74/src/wwwpy/common/rpc/__init__.py -------------------------------------------------------------------------------- /src/wwwpy/common/rpc/custom_loader.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import importlib.abc 3 | import importlib.util 4 | import sys 5 | import ast 6 | from typing import Set 7 | 8 | from wwwpy.common.rpc import func_registry 9 | from ast import Module, FunctionDef, AsyncFunctionDef, ClassDef 10 | 11 | 12 | class CustomLoader(importlib.abc.Loader): 13 | def __init__(self, loader): 14 | self.loader = loader 15 | 16 | def create_module(self, spec): 17 | return None 18 | 19 | def exec_module(self, module): 20 | module_name = module.__name__ 21 | source = self.loader.get_source(module_name) 22 | proxy_source = func_registry.source_to_proxy(module_name, source) 23 | code = compile(proxy_source, module.__file__, 'exec') 24 | exec(code, module.__dict__) 25 | 26 | 27 | class CustomFinder(importlib.abc.MetaPathFinder): 28 | """It intercepts the packages specified and rewrites them as a proxy-to-remote. 29 | The goal is to seamlessly invoke remote functions from the server""" 30 | 31 | def __init__(self, packages_name: Set[str], custom_loader=CustomLoader): 32 | self.custom_loader = custom_loader 33 | self.packages_name = packages_name 34 | super().__init__() 35 | 36 | def find_spec(self, fullname, path, target=None): 37 | if fullname in self.packages_name: 38 | # Temporarily remove this finder from sys.meta_path to avoid recursion. Remove also pytest rewriter hook 39 | orig = sys.meta_path.copy() 40 | sys.meta_path = [f for f in sys.meta_path if not isinstance(f, CustomFinder)] 41 | sys.meta_path = [f for f in sys.meta_path if f.__class__.__name__ != 'AssertionRewritingHook'] 42 | try: 43 | spec = importlib.util.find_spec(fullname) 44 | finally: 45 | # Reinsert this finder back into sys.meta_path 46 | sys.meta_path = orig 47 | 48 | if spec: 49 | spec.loader = self.custom_loader(spec.loader) 50 | return spec 51 | return None 52 | -------------------------------------------------------------------------------- /src/wwwpy/common/rpc/hibrid_dispatcher.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any 4 | 5 | from wwwpy.common.rpc.serializer import RpcRequest, RpcResponse 6 | from wwwpy.common.rpc.v2.dispatcher import Dispatcher 7 | from wwwpy.exceptions import RemoteException 8 | 9 | 10 | # todo cleanup, this is 'mostly' used by the remote for the remote-to-server rpc(s) 11 | # but it's also used by the server tests. This should take care mostly about serialization/deserialization 12 | # and error handling, so we may completely abstract the transport (urllib, browser fetch etc etc) 13 | # and remove old tests and do new one for the new version 14 | # There are two counterparts to the dispatch, it's RpcRoute.dispatch() and setup_websocket().message() 15 | class HybridDispatcher(Dispatcher): 16 | def __init__(self, module_name: str, rpc_url: str): 17 | self.rpc_url = rpc_url 18 | from wwwpy.common.fetch import async_fetch_str 19 | self.fetch = async_fetch_str 20 | self.module_name = module_name 21 | 22 | async def dispatch_async(self, func_name: str, *args) -> Any: 23 | rpc_request = RpcRequest.to_json(self.module_name, func_name, *args) 24 | json_response = await self.fetch(self.rpc_url, method='POST', data=rpc_request) 25 | response = RpcResponse.from_json(json_response) 26 | ex = response.exception 27 | if ex is not None and ex != '': 28 | raise RemoteException(ex) 29 | return response.result 30 | 31 | def dispatch_sync(self, func_name: str, *args) -> Any: 32 | rpc_request = RpcRequest.to_json(self.module_name, func_name, *args) 33 | import js 34 | xhr = js.XMLHttpRequest.new() 35 | xhr.open('POST', self.rpc_url, False) 36 | xhr.setRequestHeader('Content-Type', 'application/json') 37 | xhr.send(rpc_request) 38 | json_response = xhr.responseText 39 | response = RpcResponse.from_json(json_response) 40 | ex = response.exception 41 | if ex is not None and ex != '': 42 | raise RemoteException(ex) 43 | return response.result 44 | -------------------------------------------------------------------------------- /src/wwwpy/common/rpc/invoker.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | from wwwpy.common.rpc.func_registry import FuncMeta 3 | 4 | 5 | class Invoker: 6 | def __init__(self, module: FuncMeta): 7 | self.module = module 8 | self.module_type = _load_package(module.name) 9 | 10 | def __getitem__(self, item): 11 | return _Invocable(getattr(self.module_type, item)) 12 | 13 | 14 | class _Invocable: 15 | def __init__(self, func): 16 | self.func = func 17 | 18 | 19 | def _load_package(package_name: str): 20 | try: 21 | return importlib.import_module(package_name) 22 | except ImportError: 23 | print(f"Package '{package_name}' could not be loaded.") 24 | return None 25 | -------------------------------------------------------------------------------- /src/wwwpy/common/rpc/serializer.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import NamedTuple, List, Any, Optional 3 | 4 | 5 | class RpcResponse(NamedTuple): 6 | result: Any 7 | exception: str 8 | 9 | @classmethod 10 | def from_json(cls, string: str) -> 'RpcResponse': 11 | obj = json.loads(string) 12 | response = RpcResponse(*obj) 13 | return response 14 | 15 | def to_json(self) -> str: 16 | return json.dumps(self, default=str) 17 | 18 | 19 | class RpcRequest(NamedTuple): 20 | module: str 21 | func: str 22 | args: List[Optional[Any]] 23 | 24 | @classmethod 25 | def to_json(cls, module_name: str, func_name: str, *args) -> str: 26 | return json.dumps(RpcRequest(module_name, func_name, args)) 27 | 28 | @classmethod 29 | def from_json(cls, string: str) -> 'RpcRequest': 30 | obj = json.loads(string) 31 | request = RpcRequest(*obj) 32 | return request 33 | -------------------------------------------------------------------------------- /src/wwwpy/common/rpc/v2/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wwwpy-labs/wwwpy/2179159a90bd712a8507f67d667dbd28db9f7f74/src/wwwpy/common/rpc/v2/__init__.py -------------------------------------------------------------------------------- /src/wwwpy/common/rpc/v2/dispatcher.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Protocol 3 | 4 | 5 | @dataclass(frozen=True) 6 | class FunctionDef: 7 | name: str 8 | annotations: list[type] 9 | return_annotation: type 10 | 11 | 12 | @dataclass(frozen=True) 13 | class Definition: 14 | target: str 15 | functions: dict[str, FunctionDef] 16 | 17 | 18 | class Dispatcher(Protocol): 19 | """ 20 | This Caller/Callee convention this is the Caller. 21 | https://en.wikipedia.org/wiki/Calling_convention 22 | 23 | This is used by the proxy_generator to be used inside the generated source code. 24 | The proxy_generator will instantiate a Dispatcher for the module and one for each class. 25 | 26 | It requires the __module__ and __qualname__ attributes. because they will be they will be imported 27 | in the generated source code. 28 | 29 | For example, classes and functions have these attributes. 30 | """ 31 | __module__: str 32 | __qualname__: str 33 | 34 | def definition_complete(self, definition: Definition) -> None: 35 | """The proxy_generator will call this method when the top level module 36 | is parsed and also at the end of each class definition. This allows the implementation to 37 | inspect the functions and their type hints through the locals() dictionary.""" 38 | ... 39 | 40 | def dispatch_sync(self, function_name: str, *args) -> any: 41 | """The proxy_generator will call this method to dispatch a function call to the implementation.""" 42 | ... 43 | 44 | async def dispatch_async(self, function_name: str, *args) -> any: 45 | """The proxy_generator will call this method to dispatch a function call to the implementation.""" 46 | ... 47 | -------------------------------------------------------------------------------- /src/wwwpy/common/rpc2/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wwwpy-labs/wwwpy/2179159a90bd712a8507f67d667dbd28db9f7f74/src/wwwpy/common/rpc2/__init__.py -------------------------------------------------------------------------------- /src/wwwpy/common/rpc2/encoder_decoder.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TypeVar, Union, Type 4 | 5 | from wwwpy.common.rpc import serialization 6 | 7 | T = TypeVar('T') 8 | 9 | 10 | class EncoderDecoderType: 11 | buffer_type: Union[bytes, str] 12 | 13 | 14 | class Encoder(EncoderDecoderType): 15 | buffer: bytes | str 16 | 17 | def encode(self, obj: any, cls: Type[T]): 18 | """Add the object to the encoder""" 19 | 20 | 21 | class Decoder(EncoderDecoderType): 22 | def decode(self, cls: Type[T]) -> T: 23 | """Get the next object of the given type""" 24 | 25 | 26 | class EncoderDecoder: 27 | def decoder(self, buffer: str | bytes) -> Decoder: raise NotImplementedError 28 | 29 | def encoder(self) -> Encoder: raise NotImplementedError 30 | 31 | 32 | class JsonEncoder(Encoder): 33 | def __init__(self, sep: str): 34 | self._sep = sep 35 | self._buffer = [] 36 | 37 | def encode(self, obj: any, cls: Type[T]): 38 | self._buffer.append(serialization.to_json(obj, cls)) 39 | 40 | @property 41 | def buffer(self) -> str: 42 | return self._sep.join(self._buffer) 43 | 44 | 45 | class JsonDecoder(Decoder): 46 | def __init__(self, buffer: str, sep: str): 47 | self._buffer = iter(buffer.split(sep)) 48 | 49 | def decode(self, cls: Type[T]) -> T: 50 | item = next(self._buffer) 51 | return serialization.from_json(item, cls) 52 | 53 | 54 | class JsonEncoderDecoder(EncoderDecoder): 55 | 56 | def __init__(self): 57 | self._sep = '\n' 58 | 59 | def decoder(self, buffer: str | bytes) -> Decoder: 60 | return JsonDecoder(buffer, sep=self._sep) 61 | 62 | def encoder(self) -> Encoder: 63 | return JsonEncoder(sep=self._sep) 64 | -------------------------------------------------------------------------------- /src/wwwpy/common/rpc2/skeleton.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | 4 | class Skeleton: 5 | def invoke_sync(self): raise NotImplementedError 6 | 7 | async def invoke_async(self): raise NotImplementedError 8 | -------------------------------------------------------------------------------- /src/wwwpy/common/rpc2/transport.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | 4 | class Transport: 5 | 6 | def send_sync(self, payload: str | bytes): raise NotImplementedError 7 | 8 | async def send_async(self, payload: str | bytes): raise NotImplementedError 9 | 10 | def recv_sync(self) -> str | bytes: raise NotImplementedError 11 | 12 | async def recv_async(self) -> str | bytes: raise NotImplementedError 13 | -------------------------------------------------------------------------------- /src/wwwpy/common/rpc2/typed_function.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import inspect 4 | import types 5 | import typing 6 | from dataclasses import dataclass 7 | 8 | 9 | @dataclass 10 | class TypedFunction: 11 | module_name: str 12 | func_name: str 13 | args_types: list[type] 14 | return_type: type # this MUST be present also for void functions 15 | is_coroutine: bool 16 | 17 | def __post_init__(self): 18 | if self.return_type is None: 19 | raise Exception(f'Return type missing for function {self.func_name}') 20 | 21 | 22 | def get_typed_function(function: types.FunctionType) -> TypedFunction: 23 | type_hints = typing.get_type_hints(function) # to be used if the below code do not resolve types 24 | 25 | signature = inspect.signature(function) 26 | args_types = [] 27 | for name, param in signature.parameters.items(): 28 | annotation = type_hints.get(name, None) 29 | if annotation is None: 30 | raise Exception( 31 | f'There is no support for not annotated arguments. Annotation missing for parameter {name} in function {function.__name__}') 32 | args_types.append(annotation) 33 | 34 | return_annotation = type_hints.get('return', type(None)) 35 | return TypedFunction( 36 | function.__module__, 37 | function.__name__, 38 | args_types, 39 | return_annotation, 40 | inspect.iscoroutinefunction(function) 41 | ) 42 | -------------------------------------------------------------------------------- /src/wwwpy/common/settingslib.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | import logging 3 | import configparser 4 | 5 | logger = logging.getLogger(__name__) 6 | 7 | 8 | class Settings: 9 | def __init__(self): 10 | self._config = configparser.ConfigParser() 11 | 12 | def load(self, ini_file: Path): 13 | logger.debug(f'Loading settings from {ini_file}') 14 | self._config.read(ini_file) 15 | 16 | @property 17 | def hotreload_self(self) -> bool: 18 | return self._config.getboolean('general', 'hotreload_self', fallback=False) 19 | 20 | @property 21 | def open_url_code(self) -> str: 22 | return self._config.get('general', 'open_url_code', fallback='') 23 | 24 | @property 25 | def log_level(self) -> dict[str, str]: 26 | if not self._config.has_section('log_level'): 27 | return {} 28 | return dict(self._config.items('log_level')) 29 | -------------------------------------------------------------------------------- /src/wwwpy/common/strings.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | 4 | def id_to_hex(obj) -> str: 5 | """Convert an object's ID to a shorter hexadecimal representation.""" 6 | return hex(id(obj)).upper()[2:] 7 | -------------------------------------------------------------------------------- /src/wwwpy/common/time_logger.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | import time 5 | from datetime import timedelta 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | class TimeLogger: 11 | def __init__(self, name): 12 | self.name = name 13 | self.start = time.perf_counter() 14 | 15 | def debug(self, message=''): 16 | logger.debug(self.message(message), stacklevel=2) 17 | 18 | def message(self, msg='') -> str: 19 | delta = self.time_spent() 20 | if msg: 21 | msg = f' - {msg}' 22 | return f'{self.name}{msg} time={delta}' 23 | 24 | def time_spent(self) -> timedelta: 25 | end = time.perf_counter() 26 | return timedelta(seconds=end - self.start) 27 | -------------------------------------------------------------------------------- /src/wwwpy/common/tree.py: -------------------------------------------------------------------------------- 1 | # tree.py 2 | # credits to: https://stackoverflow.com/a/59109706/316766 3 | 4 | from pathlib import Path 5 | from typing import Protocol, Iterable 6 | 7 | # prefix components: 8 | space = ' ' 9 | branch = '│ ' 10 | # pointers: 11 | tee = '├── ' 12 | last = '└── ' 13 | 14 | 15 | class NodeProtocol(Protocol): 16 | def iterdir(self) -> Iterable['NodeProtocol']: ... 17 | 18 | def is_dir(self) -> bool: ... 19 | 20 | @property 21 | def name(self) -> str: ... 22 | 23 | 24 | def tree(dir_path: NodeProtocol, prefix: str = '', file_size=False): 25 | """A recursive generator, given a directory Path object 26 | will yield a visual tree structure line by line 27 | with each line prefixed by the same characters 28 | """ 29 | contents = list(dir_path.iterdir()) 30 | # contents each get pointers that are ├── with a final └── : 31 | pointers = [tee] * (len(contents) - 1) + [last] 32 | for pointer, path in zip(pointers, contents): 33 | fs = '' if not file_size or path.is_dir() else f' ({path.stat().st_size})' 34 | yield prefix + pointer + path.name + fs 35 | try: 36 | if path.is_dir(): # extend the prefix and recurse: 37 | extension = branch if pointer == tee else space 38 | # i.e. space because last, └── , above so no more | 39 | yield from tree(path, prefix=prefix + extension, file_size=file_size) 40 | except Exception as e: 41 | yield prefix + f'Error: {e}' 42 | 43 | 44 | def print_tree(path, printer=print, file_size=True): 45 | for line in tree(Path(path), file_size=file_size): 46 | printer(line) 47 | 48 | 49 | def filesystem_tree_str(path: Path | str) -> str: 50 | path = Path(path) 51 | if path.exists(): 52 | tree_str = '\n'.join(tree(path, prefix=' ', file_size=True)) 53 | return f'Filesystem tree for {path}:\n{tree_str}' 54 | else: 55 | return f'Filesystem path do not exists `{path}`' 56 | 57 | 58 | if __name__ == '__main__': 59 | print_tree(Path(__file__).parent.parent, file_size=True) 60 | -------------------------------------------------------------------------------- /src/wwwpy/common/type_listener.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TypeVar, Generic, Callable 4 | 5 | T = TypeVar('T') 6 | 7 | 8 | class TypeListeners(Generic[T], list[Callable[[T], None]]): 9 | def __init__(self, event_type: type[T] | None) -> None: 10 | super().__init__() 11 | self.event_type = event_type 12 | 13 | def add(self, handler: Callable[[T], None]) -> None: 14 | self.append(handler) 15 | 16 | def remove(self, handler: Callable[[T], None]) -> None: 17 | super().remove(handler) 18 | 19 | def notify(self, event: T) -> None: 20 | if self.event_type and not isinstance(event, self.event_type): 21 | raise TypeError(f'Handler expects {self.event_type}') 22 | for h in list(self): 23 | h(event) 24 | 25 | 26 | class DictListeners: 27 | def __init__(self): 28 | self._listeners: dict[type, TypeListeners] = {} 29 | self.catch_all = TypeListeners(None) 30 | 31 | def on(self, event_type: type[T]) -> TypeListeners[T]: 32 | lst = self._listeners.get(event_type) 33 | if lst is None: 34 | lst = TypeListeners(event_type) 35 | self._listeners[event_type] = lst 36 | return lst 37 | 38 | def notify(self, ev: T) -> None: 39 | listeners = self._listeners.get(type(ev), None) 40 | if listeners: 41 | listeners.notify(ev) 42 | self.catch_all.notify(ev) 43 | -------------------------------------------------------------------------------- /src/wwwpy/exceptions.py: -------------------------------------------------------------------------------- 1 | class WwwpyException(Exception): 2 | pass 3 | 4 | 5 | class RemoteException(WwwpyException): 6 | """When the server code fails""" 7 | pass 8 | 9 | 10 | class RemoteError(WwwpyException): 11 | """When network/wwwpy infrastructure fails""" 12 | pass 13 | -------------------------------------------------------------------------------- /src/wwwpy/http.py: -------------------------------------------------------------------------------- 1 | from typing import NamedTuple, Callable, Union 2 | # todo rename this in httplib (otherwise it crash jetbrains debug mode) 3 | from wwwpy.common.asynclib import OptionalCoroutine 4 | 5 | 6 | class HttpRequest(NamedTuple): 7 | method: str 8 | content: Union[str, bytes] 9 | content_type: str 10 | 11 | 12 | class HttpResponse(NamedTuple): 13 | content: Union[str, bytes] 14 | content_type: str 15 | 16 | @staticmethod 17 | def application_zip(content: bytes) -> 'HttpResponse': 18 | content_type = 'application/zip, application/octet-stream, application/x-zip-compressed, multipart/x-zip' 19 | return HttpResponse(content, content_type) 20 | 21 | @staticmethod 22 | def text_html(content: str) -> 'HttpResponse': 23 | return HttpResponse(content, 'text/html') 24 | 25 | 26 | class HttpRoute(NamedTuple): 27 | path: str 28 | callback: Callable[[HttpRequest, Callable[[HttpResponse], OptionalCoroutine]], OptionalCoroutine] 29 | -------------------------------------------------------------------------------- /src/wwwpy/platformdirs/__main__.py: -------------------------------------------------------------------------------- 1 | """Main entry point.""" 2 | 3 | from __future__ import annotations 4 | 5 | from . import PlatformDirs, __version__ 6 | 7 | PROPS = ( 8 | "user_data_dir", 9 | "user_config_dir", 10 | "user_cache_dir", 11 | "user_state_dir", 12 | "user_log_dir", 13 | "user_documents_dir", 14 | "user_downloads_dir", 15 | "user_pictures_dir", 16 | "user_videos_dir", 17 | "user_music_dir", 18 | "user_runtime_dir", 19 | "site_data_dir", 20 | "site_config_dir", 21 | "site_cache_dir", 22 | "site_runtime_dir", 23 | ) 24 | 25 | 26 | def main() -> None: 27 | """Run the main entry point.""" 28 | app_name = "MyApp" 29 | app_author = "MyCompany" 30 | 31 | print(f"-- platformdirs {__version__} --") # noqa: T201 32 | 33 | print("-- app dirs (with optional 'version')") # noqa: T201 34 | dirs = PlatformDirs(app_name, app_author, version="1.0") 35 | for prop in PROPS: 36 | print(f"{prop}: {getattr(dirs, prop)}") # noqa: T201 37 | 38 | print("\n-- app dirs (without optional 'version')") # noqa: T201 39 | dirs = PlatformDirs(app_name, app_author) 40 | for prop in PROPS: 41 | print(f"{prop}: {getattr(dirs, prop)}") # noqa: T201 42 | 43 | print("\n-- app dirs (without optional 'appauthor')") # noqa: T201 44 | dirs = PlatformDirs(app_name) 45 | for prop in PROPS: 46 | print(f"{prop}: {getattr(dirs, prop)}") # noqa: T201 47 | 48 | print("\n-- app dirs (with disabled 'appauthor')") # noqa: T201 49 | dirs = PlatformDirs(app_name, appauthor=False) 50 | for prop in PROPS: 51 | print(f"{prop}: {getattr(dirs, prop)}") # noqa: T201 52 | 53 | 54 | if __name__ == "__main__": 55 | main() 56 | -------------------------------------------------------------------------------- /src/wwwpy/platformdirs/readme.txt: -------------------------------------------------------------------------------- 1 | licence in https://github.com/wwwpy-labs/wwwpy/tree/main/licenses 2 | 3 | originally from (see below) 4 | 5 | git clone https://github.com/tox-dev/platformdirs 6 | git checkout ee86084986d64209f228c9059eabdf791865f758 7 | -------------------------------------------------------------------------------- /src/wwwpy/platformdirs/version.py: -------------------------------------------------------------------------------- 1 | # file generated by setuptools_scm 2 | # don't change, don't track in version control 3 | TYPE_CHECKING = False 4 | if TYPE_CHECKING: 5 | from typing import Tuple, Union 6 | VERSION_TUPLE = Tuple[Union[int, str], ...] 7 | else: 8 | VERSION_TUPLE = object 9 | 10 | version: str 11 | __version__: str 12 | __version_tuple__: VERSION_TUPLE 13 | version_tuple: VERSION_TUPLE 14 | 15 | __version__ = version = '4.3.7.dev13+g7ed2ea6' 16 | __version_tuple__ = version_tuple = (4, 3, 7, 'dev13', 'g7ed2ea6') 17 | -------------------------------------------------------------------------------- /src/wwwpy/remote/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | 5 | logger = logging.getLogger(__name__) 6 | 7 | 8 | # PUBLIC-API 9 | def dict_to_js(o): 10 | import js 11 | import pyodide 12 | return pyodide.ffi.to_js(o, dict_converter=js.Object.fromEntries) 13 | 14 | 15 | async def micropip_install(package): 16 | from js import pyodide 17 | await pyodide.loadPackage('micropip') 18 | import micropip 19 | await micropip.install([package]) 20 | 21 | 22 | # def set_timeout(callback: Callable[[], Union[None, Awaitable[None]]], timeout_millis: int | None = 0): 23 | # from pyodide.ffi import create_once_callable 24 | # from js import window 25 | # window.setTimeout(create_once_callable(callback), timeout_millis) 26 | 27 | 28 | def dict_to_py(js_obj): 29 | import js 30 | py_dict = {} 31 | # Iterate through properties, including those in the prototype chain 32 | current = js_obj 33 | while current: 34 | for prop in js.Object.getOwnPropertyNames(current): 35 | try: 36 | value = getattr(js_obj, prop) 37 | if not callable(value): # Skip methods 38 | _ = str(value) # some properties throw errors when accessed 39 | py_dict[prop] = value 40 | except Exception as ex: 41 | try: 42 | logger.warning(f'Error accessing property `{prop}` of {current}: {ex}') 43 | except: 44 | pass 45 | current = js.Object.getPrototypeOf(current) 46 | 47 | return py_dict 48 | -------------------------------------------------------------------------------- /src/wwwpy/remote/databind/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wwwpy-labs/wwwpy/2179159a90bd712a8507f67d667dbd28db9f7f74/src/wwwpy/remote/databind/__init__.py -------------------------------------------------------------------------------- /src/wwwpy/remote/databind/bind_wrapper.py: -------------------------------------------------------------------------------- 1 | from wwwpy.common.databind.databind import TargetAdapter 2 | import js 3 | 4 | from pyodide.ffi import create_proxy 5 | 6 | from wwwpy.common.property_monitor import PropertyChanged 7 | 8 | # rename to HTMLInputTargetAdapter or HTMLInputTarget 9 | class InputTargetAdapter(TargetAdapter): 10 | def __init__(self, inp: js.HTMLInputElement): 11 | super().__init__() 12 | self.input = inp 13 | self.input.addEventListener('input', create_proxy(self._new_input_event)) 14 | 15 | def set_target_value(self, value): 16 | self.input.value = value 17 | 18 | def get_target_value(self): 19 | return self.input.value 20 | 21 | def _new_input_event(self, event): 22 | self.monitor_object.notify([PropertyChanged(self, '', None, self.input.value)]) 23 | -------------------------------------------------------------------------------- /src/wwwpy/remote/designer/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wwwpy-labs/wwwpy/2179159a90bd712a8507f67d667dbd28db9f7f74/src/wwwpy/remote/designer/__init__.py -------------------------------------------------------------------------------- /src/wwwpy/remote/designer/dev_mode_events.py: -------------------------------------------------------------------------------- 1 | class AfterDevModeShow: 2 | """This event is published after the dev mode component is added to the DOM""" 3 | -------------------------------------------------------------------------------- /src/wwwpy/remote/designer/di_remote.py: -------------------------------------------------------------------------------- 1 | from wwwpy.remote.designer.ui import palette, pushable_sidebar, floater_action_band 2 | 3 | 4 | def register_bindings(): 5 | palette.extension_point_register() 6 | pushable_sidebar.register_extension_point() 7 | floater_action_band.extension_point_register() 8 | -------------------------------------------------------------------------------- /src/wwwpy/remote/designer/global_interceptor.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from typing import Callable 5 | 6 | import js 7 | from js import Event, document, HTMLElement 8 | from pyodide.ffi import create_proxy 9 | 10 | 11 | @dataclass 12 | class InterceptorEvent: 13 | target: HTMLElement | None 14 | event: Event 15 | interceptor: GlobalInterceptor 16 | 17 | def uninstall(self): 18 | self.interceptor.uninstall() 19 | 20 | def preventAndStop(self): 21 | if self.event: 22 | self.event.preventDefault() 23 | self.event.stopImmediatePropagation() 24 | self.event.stopPropagation() 25 | 26 | 27 | class GlobalInterceptor: 28 | 29 | def __init__(self, callback: Callable[[InterceptorEvent], None], event_name: str = 'click'): 30 | self._callback = callback 31 | self._event_name = event_name 32 | self._installed = False 33 | self._proxy = create_proxy(self._handler) 34 | 35 | def install(self): 36 | if self._installed: 37 | return 38 | self._installed = True 39 | js.document.addEventListener(self._event_name, self._proxy, True) 40 | 41 | def uninstall(self): 42 | if not self._installed: 43 | return 44 | self._installed = False 45 | js.document.removeEventListener(self._event_name, self._proxy, True) 46 | 47 | def _handler(self, event: Event): 48 | ev = InterceptorEvent(event.target, event, self) 49 | self._callback(ev) 50 | 51 | 52 | def global_interceptor_start(callback: Callable[[InterceptorEvent], None]): 53 | proxy = [] 54 | 55 | def _uninstall(): 56 | document.removeEventListener('click', proxy[0], True) 57 | proxy.clear() 58 | 59 | def global_click(event: Event): 60 | ev = InterceptorEvent(event.target, event, _uninstall) 61 | callback(ev) 62 | 63 | proxy.append(create_proxy(global_click)) 64 | document.addEventListener('click', proxy[0], True) 65 | -------------------------------------------------------------------------------- /src/wwwpy/remote/designer/log_redirect.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from js import console 4 | from wwwpy.common.designer import log_emit 5 | from wwwpy.server.designer import rpc 6 | 7 | _log_buffer = asyncio.Queue() 8 | 9 | 10 | async def _process_buffer(): 11 | """This technique avoids the issue of wrong log order""" 12 | while True: 13 | msg = await _log_buffer.get() 14 | await rpc.server_console_log(msg) 15 | 16 | 17 | def redirect_logging(): 18 | asyncio.create_task(_process_buffer()) 19 | 20 | def emit(msg: str): 21 | console.log(msg) 22 | # this is not correct because we are not in a coroutine 23 | # but it probably works because we are in the browser with just one event loop 24 | _log_buffer.put_nowait(msg) 25 | 26 | log_emit.add_once(emit) 27 | log_emit.warning_to_log() 28 | -------------------------------------------------------------------------------- /src/wwwpy/remote/designer/rpc.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import List, Any 3 | 4 | from wwwpy.common import files 5 | 6 | 7 | class DesignerRpc: 8 | 9 | def hotreload_notify_changes(self, do_reload: bool, events: List[Any]): 10 | directory = Path(files._bundle_path) 11 | 12 | from wwwpy.common.filesystem.sync import Sync, sync_delta2 13 | sync_impl: Sync = sync_delta2 14 | sync_impl.sync_target(directory, events) 15 | if do_reload: 16 | self.hotreload_do() 17 | 18 | def hotreload_do(self): 19 | from wwwpy.remote.browser_main import _reload 20 | _reload() 21 | -------------------------------------------------------------------------------- /src/wwwpy/remote/designer/ui/__init__.py: -------------------------------------------------------------------------------- 1 | # comment -------------------------------------------------------------------------------- /src/wwwpy/remote/designer/ui/floater.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from wwwpy.common.designer.ui.rect_readonly import RectReadOnly 4 | from wwwpy.remote import component as wpc 5 | 6 | 7 | class Floater(wpc.Component, auto_define=False): 8 | """This is a floating component that will adapt itself to the given situation""" 9 | 10 | def set_reference_geometry(self, rect: RectReadOnly): 11 | """Set the geometry of the element that we are attached to.""" 12 | -------------------------------------------------------------------------------- /src/wwwpy/remote/designer/ui/floater_selection_indicator.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import js 4 | 5 | from wwwpy.common.designer.ui.rect_readonly import RectReadOnly 6 | from wwwpy.remote import dict_to_js 7 | from wwwpy.remote.designer.ui.floater import Floater 8 | 9 | 10 | class SelectionIndicatorFloater(Floater, tag_name='selection-indicator-floater'): 11 | 12 | def init_component(self): 13 | self.element.attachShadow(dict_to_js({'mode': 'open'})) 14 | # language=html 15 | self.element.shadowRoot.innerHTML = """ 16 | 26 | """ 27 | 28 | @property 29 | def transition(self) -> bool: 30 | return self.element.style.transition != 'none' 31 | 32 | @transition.setter 33 | def transition(self, value: bool): 34 | if value: 35 | self.element.style.transition = 'all 0.2s ease' 36 | else: 37 | self.element.style.transition = 'none' 38 | 39 | @property 40 | def visible(self) -> bool: 41 | return self.element.style.display == 'block' 42 | 43 | def hide(self): 44 | self.element.style.display = 'none' 45 | 46 | def set_reference_geometry(self, rect: RectReadOnly): 47 | bs = 2 # Adjust this value to match the border size in CSS 48 | 49 | rect = js.DOMRect.new(rect.x - bs, rect.y - bs, rect.width, rect.height, ) 50 | 51 | self.element.style.display = 'block' 52 | self.element.style.top = f"{rect.top}px" 53 | self.element.style.left = f"{rect.left}px" 54 | self.element.style.width = f"{rect.width}px" 55 | self.element.style.height = f"{rect.height}px" 56 | -------------------------------------------------------------------------------- /src/wwwpy/remote/designer/ui/help_icon.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import js 4 | 5 | import wwwpy.remote.component as wpc 6 | from wwwpy.remote import dict_to_js 7 | 8 | 9 | class HelpIcon(wpc.Component, tag_name='wwwpy-help-icon'): 10 | _link: js.HTMLElement = wpc.element() 11 | href: str = wpc.attribute() 12 | 13 | def init_component(self): 14 | self.element.attachShadow(dict_to_js({'mode': 'open'})) 15 | # language=html 16 | self.element.shadowRoot.innerHTML = """ 17 | 25 | 26 | 27 | 28 | ? 29 | 30 | 31 | 32 | 33 | """ 34 | self._update_href() 35 | 36 | def _link__click(self, event: js.MouseEvent): 37 | event.stopPropagation() 38 | if not self.href: 39 | event.preventDefault() 40 | 41 | def attributeChangedCallback(self, name: str, oldValue: str, newValue: str): 42 | if name == 'href': 43 | self._update_href() 44 | 45 | def _update_href(self): 46 | self._link.href = self.href 47 | 48 | @property 49 | def visible(self): 50 | return self.element.style.visibility != 'hidden' 51 | 52 | @visible.setter 53 | def visible(self, value): 54 | self.element.style.visibility = 'visible' if value else 'hidden' 55 | -------------------------------------------------------------------------------- /src/wwwpy/remote/designer/ui/intent_select_element.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from dataclasses import dataclass 3 | from functools import cached_property 4 | 5 | import js 6 | 7 | from wwwpy.common.designer.canvas_selection import CanvasSelection 8 | from wwwpy.common.injectorlib import injector 9 | from wwwpy.remote.designer.ui.element_selector import ElementSelector 10 | from wwwpy.remote.designer.ui.intent import Intent 11 | from wwwpy.remote.designer.ui.locator_event import LocatorEvent 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | @dataclass 17 | class SelectElementIntent(Intent): 18 | """Action to select an element in the designer.""" 19 | label: str = 'Select' 20 | icon: str = 'select_element_icon' 21 | 22 | def on_hover(self, event: LocatorEvent): 23 | logger.debug(f'on_hover {event}') 24 | self._set_selection_from_js_event(event) 25 | 26 | def on_submit(self, event: LocatorEvent) -> bool: 27 | logger.debug(f'on_submit {event}') 28 | self._set_selection_from_js_event(event) 29 | injector.get(CanvasSelection).current_selection = event.locator 30 | return True 31 | 32 | def _set_selection_from_js_event(self, le: LocatorEvent): 33 | element_selector: ElementSelector = self._element_selector 34 | if not element_selector.element.isConnected: 35 | js.document.body.appendChild(element_selector.element) 36 | 37 | target = None 38 | if le is not None: 39 | target = le.main_element 40 | 41 | if element_selector.get_selected_element() != target: 42 | element_selector.set_selected_element(target) 43 | 44 | return target 45 | 46 | @cached_property 47 | def _element_selector(self) -> ElementSelector: 48 | return ElementSelector() 49 | 50 | 51 | def _pretty(node: js.HTMLElement): 52 | if hasattr(node, 'tagName'): 53 | identifier = node.dataset.name if node.hasAttribute('data-name') else node.id 54 | return f'{node.tagName.lower()}#{identifier}.{node.className}[{node.innerHTML.strip()[:20]}…]' 55 | return str(node) 56 | -------------------------------------------------------------------------------- /src/wwwpy/remote/designer/ui/mailto_component.py: -------------------------------------------------------------------------------- 1 | import wwwpy.remote.component as wpc 2 | import js 3 | 4 | import logging 5 | import urllib.parse 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | class MailtoComponent(wpc.Component): 11 | recipient: str = wpc.attribute() 12 | subject: str = wpc.attribute() 13 | body: str = wpc.attribute() 14 | text_content: str = wpc.attribute() 15 | target: js.HTMLElement = wpc.attribute() 16 | 17 | _link: js.HTMLElement = wpc.element() 18 | 19 | def init_component(self): 20 | # language=html 21 | self.element.innerHTML = """""" 22 | 23 | self._update_attributes() 24 | 25 | def attributeChangedCallback(self, name: str, oldValue: str, newValue: str): 26 | self._update_attributes() 27 | 28 | def connectedCallback(self): 29 | self._update_attributes() 30 | 31 | def _update_attributes(self): 32 | target = self.target if self.target else '_blank' 33 | recipient = self.recipient if self.recipient else 'foo@example.com' 34 | subject = self.subject if self.subject else 'subject not specified' 35 | body = self.body if self.body else '' 36 | text_content = self.text_content if self.text_content else f"Send Email to {recipient}" 37 | 38 | # make sure to encode the subject and body 39 | subject = urllib.parse.quote_plus(subject) 40 | body = urllib.parse.quote_plus(body) 41 | 42 | self._link.href = f"mailto:{recipient}?subject={subject}&body={body}" 43 | self._link.textContent = text_content 44 | self._link.target = target 45 | -------------------------------------------------------------------------------- /src/wwwpy/remote/designer/ui/python_console.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from dataclasses import dataclass 3 | 4 | import wwwpy.remote.component as wpc 5 | import js # used in globals 6 | from js import pyodide, document, console, window 7 | 8 | import logging 9 | 10 | from wwwpy.common import state 11 | 12 | logger = logging.getLogger(__name__) 13 | logger.setLevel(logging.DEBUG) 14 | 15 | 16 | @dataclass 17 | class State: 18 | python_code: str = 'logger.info("Hello")' 19 | 20 | 21 | class PythonConsoleComponent(wpc.Component, tag_name='wwwpy-python-console'): 22 | _ta_python_code: js.HTMLTextAreaElement = wpc.element() 23 | 24 | def init_component(self): 25 | # language=html 26 | self.element.innerHTML = """ 27 |
Press CTRL-Enter to run Python code
28 | 30 | """ 31 | self._state = state._restore(State) 32 | self._ta_python_code.value = self._state.python_code 33 | 34 | async def _ta_python_code__keydown(self, event): 35 | if event.ctrlKey and event.key == 'Enter': 36 | await js.pyodide.runPythonAsync(self._ta_python_code.value, globals=globals()) 37 | 38 | async def _ta_python_code__input(self, event): 39 | self._state.python_code = self._ta_python_code.value 40 | -------------------------------------------------------------------------------- /src/wwwpy/remote/designer/ui/quickstart_ui.py: -------------------------------------------------------------------------------- 1 | import js 2 | from pyodide.ffi import create_proxy 3 | import asyncio 4 | from wwwpy.common import quickstart 5 | from wwwpy.remote.designer.ui.window_component import new_window, WindowComponent 6 | from wwwpy.remote.designer.ui.searchable_list_1 import SearchableList1, Item 7 | from wwwpy.server.designer import rpc 8 | 9 | 10 | class QuickstartUI: 11 | 12 | def __init__(self): 13 | self.window: WindowComponent = new_window("Select a quickstart", closable=False) 14 | # self.window.set_size('300px', '300px') 15 | self.window.set_position('5px', '5px') 16 | cmp1 = SearchableList1() 17 | cmp1.root_element().style.color = 'white' 18 | quickstart_list = quickstart.quickstart_list() 19 | self.quickstart_list = quickstart_list 20 | cmp1.items = [Item(qs.title, qs.description, {'quickstart': qs}) for qs in quickstart_list] 21 | cmp1.placeholder = 'Search or select below...' 22 | self.window.element.append(cmp1.element) 23 | self.on_done = lambda *args: None 24 | 25 | cmp1.element.addEventListener('item-click', create_proxy(self._item_click_handler)) 26 | 27 | def accept_quickstart(self, quickstart_name: str): 28 | self.window.element.remove() 29 | 30 | async def _notify_server(): 31 | await rpc.quickstart_apply(quickstart_name) 32 | self.on_done(quickstart_name) 33 | 34 | asyncio.create_task(_notify_server()) 35 | 36 | def _item_click_handler(self, event): 37 | item = event.detail 38 | assert isinstance(item, Item) 39 | self.accept_quickstart(item.values['quickstart'].name) 40 | 41 | 42 | def create() -> QuickstartUI: 43 | return QuickstartUI() 44 | -------------------------------------------------------------------------------- /src/wwwpy/remote/designer/ui/system_tools/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wwwpy-labs/wwwpy/2179159a90bd712a8507f67d667dbd28db9f7f74/src/wwwpy/remote/designer/ui/system_tools/__init__.py -------------------------------------------------------------------------------- /src/wwwpy/remote/designer/ui/system_tools/system_versions.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import js 4 | 5 | import wwwpy.remote.component as wpc 6 | from wwwpy.remote.jslib import waitAnimationFrame 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | class SystemVersions(wpc.Component, tag_name='wwwpy-system-versions'): 12 | _container: js.HTMLElement = wpc.element() 13 | _wwwpy: js.HTMLElement = wpc.element() 14 | _python_remote: js.HTMLElement = wpc.element() 15 | _python_server: js.HTMLElement = wpc.element() 16 | _pyodide: js.HTMLElement = wpc.element() 17 | 18 | def init_component(self): 19 | # language=html 20 | self.element.innerHTML = """ 21 |
22 |     wwwpy==...
23 |     pyodide==...
24 |     
25 |     Python versions:
26 |     pyton remote==...
27 |     pyton server==...
28 | 
""" 29 | 30 | async def after_init_component(self): 31 | await waitAnimationFrame() 32 | 33 | import platform 34 | pyver = platform.python_version() 35 | 36 | try: 37 | import wwwpy 38 | wp_ver = wwwpy.__version__ 39 | except Exception as e: 40 | wp_ver = e.message 41 | 42 | try: 43 | import js 44 | pyodide_ver = js.window.pyodide.version 45 | except Exception as e: 46 | pyodide_ver = e.message 47 | 48 | self._wwwpy.innerText = f'wwwpy==' + wp_ver 49 | self._pyodide.innerText = 'pyodide==' + pyodide_ver 50 | self._python_remote.innerText = 'remote==' + pyver + ' (this is tied to Pyodide version)' 51 | 52 | from wwwpy.server.designer import rpc 53 | server_version = await rpc.server_python_version_string() 54 | self._python_server.innerText = 'server==' + server_version 55 | -------------------------------------------------------------------------------- /src/wwwpy/remote/fetch.py: -------------------------------------------------------------------------------- 1 | from js import fetch, console 2 | 3 | from wwwpy.common.fetch_debug import fetch_debug 4 | 5 | # logger = logging.getLogger(__name__) 6 | logger = console # if log redirect is active, it will create an infinite loop because 7 | 8 | 9 | async def async_fetch_str(url: str, method: str = 'GET', data: str = '') -> str: 10 | logger.debug(__name__ + ' ' + fetch_debug(url, method, data)) 11 | response = await fetch(url, method=method, body=data) 12 | text = await response.text() 13 | return text 14 | -------------------------------------------------------------------------------- /src/wwwpy/remote/files.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import mimetypes 3 | 4 | from js import console, document 5 | from pathlib import Path 6 | 7 | 8 | def download_path(filename: str, path: Path, mime_type: str = None): 9 | download_bytes(filename, path.read_bytes(), mime_type) 10 | 11 | 12 | def download_bytes(filename: str, content: bytes, mime_type: str = None): 13 | if mime_type is None: 14 | gt = mimetypes.guess_type(filename) 15 | mime_type = gt[0] 16 | if mime_type is None: 17 | mime_type = 'application/octet-stream' 18 | console.log(f'guess mime type for `{filename}` is `{mime_type}`') 19 | a = document.createElement('a') 20 | a.download = filename 21 | a.href = f'data:{mime_type};base64,{base64.b64encode(content).decode("ascii")}' 22 | document.body.append(a) 23 | a.click() 24 | 25 | -------------------------------------------------------------------------------- /src/wwwpy/remote/idbfs.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | from pathlib import Path 4 | 5 | import js 6 | from pyodide.ffi import create_proxy, to_js 7 | from wwwpy.common.asynclib import create_task_safe 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | async def _fs_idbfs_sync(n): 13 | queue = asyncio.Queue(1) 14 | js.pyodide.FS.syncfs(n, create_proxy(lambda err: queue.put_nowait(err))) 15 | res = await asyncio.wait_for(queue.get(), 5) 16 | logger.debug(f'fs_idbfs_sync({n}) res: {res}') 17 | return res 18 | 19 | 20 | def fs_idbfs_load(): return create_task_safe(_fs_idbfs_sync(1)) 21 | 22 | 23 | def fs_idbfs_save(): return create_task_safe(_fs_idbfs_sync(0)) 24 | 25 | 26 | async def fs_idbfs_mount(mount_point: str): 27 | if Path(mount_point).exists(): 28 | return logger.warning('already mounted') 29 | js.pyodide.FS.mkdirTree(mount_point) 30 | js.pyodide.FS.mount(js.pyodide.FS.filesystems.IDBFS, to_js({}), mount_point) 31 | await fs_idbfs_load() 32 | -------------------------------------------------------------------------------- /src/wwwpy/remote/remote_fixtures.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import js 4 | import pytest 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | 9 | @pytest.fixture 10 | def clean_document(): 11 | clean_document_now('begin') 12 | yield None 13 | clean_document_now('end') 14 | 15 | 16 | def clean_document_now(mode='mode/NA'): 17 | # logger.debug(f'_clean_document {mode}') 18 | js.document.documentElement.innerHTML = '' 19 | js.document.head.innerHTML = '' 20 | for attr in js.document.documentElement.attributes: 21 | js.document.documentElement.removeAttributeNode(attr) 22 | 23 | # logger.debug(f'body=```{js.document.body.innerHTML}```') 24 | -------------------------------------------------------------------------------- /src/wwwpy/remote/root_path.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from pathlib import Path 5 | 6 | from js import console 7 | 8 | from wwwpy.common import modlib 9 | 10 | 11 | @dataclass 12 | class _Dir: 13 | root: Path 14 | remote: Path 15 | 16 | 17 | def _get_dir(): 18 | # this works because both wwwpy and user code are flattened in the same folder 19 | root = modlib._find_module_path('wwwpy').parent.parent 20 | console.log(f'root={root}') 21 | remote = root / 'remote' 22 | return _Dir(root, remote) 23 | -------------------------------------------------------------------------------- /src/wwwpy/remote/shoelace.py: -------------------------------------------------------------------------------- 1 | from js import document 2 | import js 3 | 4 | from wwwpy.common.asynclib import create_task_safe 5 | from wwwpy.remote.jslib import script_load_once 6 | 7 | _version = '2.20.0' 8 | _css_url = f'https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@{_version}/cdn/themes/dark.css' 9 | _js_url = f'https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@{_version}/cdn/shoelace.js' 10 | 11 | _task = [] 12 | 13 | 14 | def setup_shoelace(): 15 | async def _setup_shoelace(): 16 | needed = await script_load_once(_js_url, type='module') 17 | if needed: 18 | document.documentElement.className = 'sl-theme-dark' 19 | document.head.append(document.createRange().createContextualFragment(_head_style)) 20 | 21 | return create_task_safe(_setup_shoelace()) 22 | 23 | 24 | # 25 | 26 | # language=html 27 | _head_style = f""" 28 | 29 | 30 | 38 | """ 39 | _head_script = f"""""" 40 | 41 | 42 | # alternative 43 | # language=html 44 | _head_style_dark_palin =""" 45 | 46 | 58 | """ -------------------------------------------------------------------------------- /src/wwwpy/remote/websocket.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Callable 4 | 5 | from wwwpy.common.rpc.serializer import RpcRequest 6 | import asyncio 7 | 8 | import logging 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | async def setup_websocket(): 14 | from js import window, console 15 | import importlib 16 | def log(msg): 17 | console.log(msg) 18 | 19 | def message(msg): 20 | log(f'message:{msg}') 21 | r = RpcRequest.from_json(msg) 22 | # _debug_requested_module(r.module) 23 | m = importlib.import_module(r.module) 24 | class_name, func_name = r.func.split('.') 25 | attr = getattr(m, class_name) 26 | inst = attr() 27 | func = getattr(inst, func_name) 28 | func(*r.args) 29 | 30 | l = window.location 31 | proto = 'ws' if l.protocol == 'http:' else 'wss' 32 | url = f'{proto}://{l.host}/wwwpy/ws' 33 | _WebSocketReconnect(url, message) 34 | 35 | 36 | class _WebSocketReconnect: 37 | def __init__(self, url: str, on_message: Callable): 38 | self._url = url 39 | self._on_message = on_message 40 | self._counter = 0 41 | self._connect() 42 | 43 | def _connect(self): 44 | from js import WebSocket, console 45 | self._counter += 1 46 | console.log(f'connecting to {self._url} counter={self._counter}') 47 | es = WebSocket.new(self._url) 48 | es.onopen = lambda e: console.log('open') 49 | es.onmessage = lambda e: self._on_message(e.data) 50 | es.onerror = lambda e: es.close() 51 | 52 | async def reconnect(): 53 | await asyncio.sleep(1) 54 | self._connect() 55 | 56 | es.onclose = lambda e: asyncio.create_task(reconnect()) # for now, we just retry forever and ever 57 | 58 | 59 | def _debug_requested_module(module_name: str): 60 | from wwwpy.common import modlib 61 | mp = modlib._find_module_path(module_name) 62 | logger.debug(f'requested module: {module_name} path: {mp}') 63 | if mp: 64 | logger.debug(f'content: ```{mp.read_text()}```') 65 | -------------------------------------------------------------------------------- /src/wwwpy/server/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wwwpy-labs/wwwpy/2179159a90bd712a8507f67d667dbd28db9f7f74/src/wwwpy/server/__init__.py -------------------------------------------------------------------------------- /src/wwwpy/server/__main__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import argparse 4 | import logging 5 | import os 6 | from pathlib import Path 7 | from typing import Optional, Sequence, NamedTuple 8 | 9 | from wwwpy.server.convention import start_default 10 | from wwwpy.server.tcp_port import find_port 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | class Arguments(NamedTuple): 16 | directory: Path 17 | port: int 18 | dev: bool 19 | 20 | 21 | def parse_arguments(args: Optional[Sequence[str]] = None) -> Arguments: 22 | parser = argparse.ArgumentParser(prog='wwwpy') 23 | parser.add_argument('dev', nargs='?', const=True, default=False, 24 | help="Run in development mode") 25 | parser.add_argument('--directory', '-d', default=os.getcwd(), 26 | help='set the root path for the project (default: current directory)') 27 | parser.add_argument('--port', type=int, default=8000, 28 | help='bind to this port (default: 8000)') 29 | 30 | parsed_args = parser.parse_args(args) 31 | return Arguments( 32 | directory=Path(parsed_args.directory).absolute(), 33 | port=parsed_args.port, 34 | dev=bool(parsed_args.dev) 35 | ) 36 | 37 | 38 | def _open_browser(args, settings): 39 | import webbrowser 40 | if settings.open_url_code: 41 | loc = {'url': f'http://localhost:{args.port}', args: args} 42 | exec(settings.open_url_code, loc, loc) 43 | else: 44 | webbrowser.open(f'http://localhost:{args.port}') 45 | 46 | 47 | def main(): 48 | import wwwpy 49 | print(f'Starting wwwpy v{wwwpy.__version__}') 50 | args = parse_arguments() 51 | if args.port == 0: 52 | args = args._replace(port=find_port()) 53 | project = start_default(args.directory, args.port, dev_mode=args.dev) 54 | _open_browser(args, project.settings) 55 | try: 56 | from wwwpy.webserver import wait_forever 57 | wait_forever() 58 | except KeyboardInterrupt: 59 | pass 60 | 61 | 62 | if __name__ == '__main__': 63 | main() 64 | -------------------------------------------------------------------------------- /src/wwwpy/server/custom_str.py: -------------------------------------------------------------------------------- 1 | class CustomStr(str): 2 | pass 3 | 4 | 5 | def get_root_folder_or_fail() -> CustomStr: 6 | import sys 7 | only_custom = [p for p in sys.path if isinstance(p, CustomStr)] 8 | 9 | if len(only_custom) != 1: 10 | raise ValueError('Cannot find root folder') 11 | 12 | return only_custom[0] 13 | -------------------------------------------------------------------------------- /src/wwwpy/server/designer/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wwwpy-labs/wwwpy/2179159a90bd712a8507f67d667dbd28db9f7f74/src/wwwpy/server/designer/__init__.py -------------------------------------------------------------------------------- /src/wwwpy/server/fetch.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import urllib 5 | import urllib.request 6 | from typing import Any 7 | 8 | from wwwpy.http import HttpResponse 9 | 10 | 11 | async def async_fetch_str(url: str, method: str = 'GET', data: str = '') -> str: 12 | response = sync_fetch_response(url, method=method, data=data) 13 | assert isinstance(response.content, str) 14 | return response.content 15 | 16 | 17 | def sync_fetch_response(url: str, method: str = 'GET', data: str | bytes = '') -> HttpResponse: 18 | def make_response(r: Any) -> HttpResponse: 19 | return HttpResponse( 20 | r.read().decode("utf-8"), 21 | r.headers.get_content_type() 22 | ) 23 | 24 | if method != 'GET': 25 | if isinstance(data, str): 26 | data_bytes = bytes(data, 'utf8') 27 | else: 28 | data_bytes = data 29 | 30 | rq = urllib.request.Request(url, method=method, data=data_bytes) 31 | with urllib.request.urlopen(rq) as r: 32 | return make_response(r) 33 | else: 34 | with urllib.request.urlopen(url) as r: 35 | return make_response(r) 36 | 37 | 38 | async def async_fetch_response(url: str, method: str = 'GET', data: str | bytes = '') -> HttpResponse: 39 | def make_response(r: Any) -> HttpResponse: 40 | return HttpResponse( 41 | r.read().decode("utf-8"), 42 | r.headers.get_content_type() 43 | ) 44 | 45 | def fetch() -> HttpResponse: 46 | if method != 'GET': 47 | if isinstance(data, str): 48 | data_bytes = bytes(data, 'utf8') 49 | else: 50 | data_bytes = data 51 | 52 | rq = urllib.request.Request(url, method=method, data=data_bytes) 53 | with urllib.request.urlopen(rq) as r: 54 | return make_response(r) 55 | else: 56 | with urllib.request.urlopen(url) as r: 57 | return make_response(r) 58 | 59 | loop = asyncio.get_running_loop() 60 | return await loop.run_in_executor(None, fetch) 61 | -------------------------------------------------------------------------------- /src/wwwpy/server/filesystem_sync/README.md: -------------------------------------------------------------------------------- 1 | # Filesystem-sync 2 | 3 | This package serves to monitor a filesystem and propagate changes to another filesystem to keep them in sync. 4 | 5 | The source and target filesystems are assumed to be on different machines and there is a small part that serializes the filesystem changes to be sent over the transport (e.g., network) 6 | 7 | The source filesystem is monitored using [watchdog](https://pypi.org/project/watchdog/). 8 | 9 | There are in essence two parts: 10 | - one part to debounce the filesystem events to avoid frenzied activity 11 | - one part that takes in the debounced events and propagates them to the target filesystem 12 | 13 | Both parts have tests and are tested in isolation. 14 | 15 | ## Debounce 16 | This is a general implementation that keeps a buffer and notifies the observer when a timeout is reached. 17 | 18 | ## Sync 19 | Take a look to the [Sync](src/filesystem_sync/sync.py) protocol -------------------------------------------------------------------------------- /src/wwwpy/server/filesystem_sync/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/wwwpy/server/filesystem_sync/sync_zip.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import base64 4 | import os 5 | import zipfile 6 | from io import BytesIO 7 | from pathlib import Path 8 | 9 | import shutil 10 | from pathlib import Path 11 | from typing import List, Any 12 | 13 | from wwwpy.common.filesystem.sync import Event 14 | 15 | 16 | def sync_source(source: Path, events: List[Event]) -> List[Any]: 17 | if not events: 18 | return [] 19 | return sync_init(source) 20 | 21 | 22 | def _delete_recursive(path: Path, ignore_errors): 23 | for e in path.iterdir(): 24 | if e.is_file(): 25 | e.unlink(missing_ok=ignore_errors) 26 | elif e.is_dir(): 27 | shutil.rmtree(e, ignore_errors=ignore_errors) 28 | 29 | 30 | def sync_target(target_root: Path, changes: List[Any]) -> None: 31 | if not changes: 32 | return 33 | _delete_recursive(target_root, ignore_errors=True) 34 | _delete_recursive(target_root, ignore_errors=False) 35 | 36 | b = base64.b64decode(changes[0]) 37 | with BytesIO(b) as stream: 38 | with zipfile.ZipFile(stream, "r") as zip_file: 39 | zip_file.extractall(target_root) 40 | 41 | 42 | def sync_init(source: Path) -> List[Any]: 43 | b = _zip_in_memory(source) 44 | s = base64.b64encode(b).decode('utf-8') 45 | return [s] 46 | 47 | 48 | def _zip_path(zip_file, path): 49 | if os.path.isfile(path): 50 | zip_file.write(path, os.path.basename(path)) 51 | elif os.path.isdir(path): 52 | for root, dirs, files in os.walk(path): 53 | for file in files: 54 | file_path = os.path.join(root, file) 55 | arcname = os.path.relpath(file_path, path) 56 | zip_file.write(file_path, arcname) 57 | 58 | 59 | def _zip_in_memory(path) -> bytes: 60 | stream = BytesIO() 61 | with zipfile.ZipFile(stream, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=1) as zip_file: 62 | _zip_path(zip_file, path) 63 | 64 | stream.seek(0) 65 | return stream.getbuffer().tobytes() 66 | -------------------------------------------------------------------------------- /src/wwwpy/server/proxy.py: -------------------------------------------------------------------------------- 1 | from wwwpy.common.rpc.serializer import RpcRequest 2 | from wwwpy.websocket import SendEndpoint 3 | 4 | 5 | class Proxy: 6 | def __init__(self, module_name: str, send_endpoint: SendEndpoint): 7 | self.send_endpoint = send_endpoint 8 | self.module_name = module_name 9 | 10 | def dispatch(self, func_name: str, *args) -> None: 11 | rpc_request = RpcRequest.to_json(self.module_name, func_name, *args) 12 | self.send_endpoint.send(rpc_request) 13 | -------------------------------------------------------------------------------- /src/wwwpy/server/pytestlib/__init__.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | 3 | required = { 4 | ('pytest',), 5 | ('xvirt', 'pytest-xvirt'), 6 | ('playwright',), 7 | ('pytest_playwright', 'pytest-playwright') 8 | } 9 | missing_pip = set() 10 | installed = set() 11 | for tup in required: 12 | 13 | pip_name = tup[0] if len(tup) == 1 else tup[1] 14 | try: 15 | importlib.import_module(tup[0]) 16 | installed.add(pip_name) 17 | except ImportError: 18 | missing_pip.add(pip_name) 19 | 20 | if len(missing_pip) > 0: 21 | msg = ('You need to install the following packages to use this plugin:\n ' 22 | + ', '.join(missing_pip) 23 | + '\n\nYou can install them by running:\n `pip install ' + ' '.join(missing_pip) + 24 | '`\n\n' 25 | + 'Packages already installed: ' + ', '.join(installed) 26 | ) 27 | raise ImportError(msg) 28 | -------------------------------------------------------------------------------- /src/wwwpy/server/pytestlib/pytest_plugin.py: -------------------------------------------------------------------------------- 1 | # wwwpy/pytest_plugin.py 2 | 3 | import pytest 4 | 5 | from .playwrightlib import playwright_setup_page_logger 6 | from .xvirt_impl import XVirtImpl 7 | 8 | 9 | def pytest_addoption(parser): 10 | parser.addoption("--headful", action="store_true", default=False, help="run tests in headfull mode") 11 | 12 | 13 | def pytest_configure(config): 14 | # This function is called before any tests or fixtures are collected. 15 | # You can add any setup code here. 16 | 17 | # For example, you can add your conftest.py setup code here. 18 | # Note: You might need to adjust this code to work in this context. 19 | from .playwrightlib import playwright_patch_timeout 20 | playwright_patch_timeout() 21 | 22 | 23 | @pytest.hookimpl 24 | def pytest_xvirt_setup(config): 25 | import wwwpy.base_conf 26 | headful = config.getoption("--headful") or wwwpy.base_conf.PLAYWRIGHT_HEADFUL 27 | return XVirtImpl(headless=not headful) 28 | 29 | 30 | def pytest_unconfigure(config): 31 | # This function is called after all tests and fixtures have been collected. 32 | # You can add any teardown code here. 33 | pass 34 | 35 | 36 | @pytest.fixture(autouse=True) 37 | def before_each_after_each(request): 38 | if 'page' not in request.node.fixturenames: 39 | yield 40 | return 41 | page = request.getfixturevalue('page') 42 | playwright_setup_page_logger(page) 43 | yield 44 | -------------------------------------------------------------------------------- /src/wwwpy/server/pytestlib/remote_conftest.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import pytest 4 | from js import window 5 | 6 | from wwwpy.common.fetch_debug import fetch_debug 7 | 8 | 9 | @pytest.fixture(scope="session") 10 | def event_loop(): 11 | # this prevents pytest-asyncio to closing the pyodide event loop (webloop) 12 | yield asyncio.get_running_loop() 13 | 14 | 15 | def pytest_sessionstart(session): 16 | print(f'invocation_dir={session.config.invocation_dir}') 17 | print(f'rootpath={session.config.rootpath}') 18 | 19 | 20 | def pytest_xvirt_send_event(event_json): 21 | async def callback(): 22 | path = '#xvirt_notify_path_marker#' 23 | await async_fetch_str(path, method='POST', data=event_json) 24 | 25 | asyncio.create_task(callback()) 26 | 27 | 28 | async def async_fetch_str(url: str, method: str = 'GET', data: str = '') -> str: 29 | print(__name__ + ' ' + fetch_debug(url, method, data)) 30 | response = await window.fetch(url, method=method, body=data) 31 | text = await response.text() 32 | return text 33 | 34 | 35 | def pytest_runtest_setup(item): 36 | _clean_doc_now() 37 | 38 | 39 | # intent_manager_tests.py fails without the following, don't know why 40 | def pytest_runtest_teardown(item, nextitem): 41 | _clean_doc_now() 42 | 43 | 44 | def _clean_doc_now(): 45 | from wwwpy.remote import remote_fixtures 46 | remote_fixtures.clean_document_now() 47 | -------------------------------------------------------------------------------- /src/wwwpy/server/pytestlib/remote_test_main.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | from pathlib import Path 4 | 5 | 6 | async def main(rootpath, invocation_dir, args): 7 | from wwwpy.common.designer import log_emit 8 | def emit(msg: str): 9 | print(msg) 10 | from js import console 11 | console.log(msg) 12 | 13 | log_emit.add_once(emit) 14 | log_emit.warning_to_log() 15 | logging.getLogger().setLevel(logging.DEBUG) 16 | # from wwwpy.remote.designer import log_redirect 17 | # log_redirect.redirect_logging() 18 | 19 | from js import console 20 | console.log(f'main({rootpath}, {invocation_dir}, {args})') 21 | from wwwpy.common.tree import print_tree 22 | print_tree('/wwwpy_bundle') 23 | 24 | Path('/wwwpy_bundle/pytest.ini').write_text("[pytest]\n" 25 | "asyncio_mode = auto") 26 | from wwwpy.remote import micropip_install 27 | await micropip_install('pytest==7.2.2') # didn't work with update to 8.1.1 28 | await micropip_install('pytest-asyncio') 29 | await micropip_install('pytest-xvirt') 30 | import wwwpy.common.designer as des 31 | for package in des.pypi_packages: 32 | await micropip_install(package) 33 | import pytest 34 | print('-=-' * 20 + 'pytest imported') 35 | 36 | os.chdir(invocation_dir) 37 | pytest.main(args) 38 | -------------------------------------------------------------------------------- /src/wwwpy/server/rpc4tests.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | 4 | from tests.timeouts import timeout_multiplier 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | 9 | async def rpctst_echo(msg: str) -> str: 10 | return f'echo {msg}' 11 | 12 | 13 | async def rpctst_exec(source: str, timeout_secs: int = 1) -> None: 14 | """Example: 15 | page.mouse.click(100, 100) 16 | page.locator('input').fill('foo1') 17 | 18 | This method queue the command to be executed in the playwright thread, 19 | it waits the completion but do not return the result. 20 | """ 21 | from wwwpy.server.pytestlib.xvirt_impl import xvirt_instances 22 | from wwwpy.server.pytestlib.playwrightlib import PlaywrightBunch 23 | i = len(xvirt_instances) 24 | assert i == 1, f'len(xvirt_instances)={i}' 25 | xvirt = xvirt_instances[0] 26 | args = xvirt.playwright_args 27 | assert args is not None 28 | pwb: PlaywrightBunch = args.instance 29 | assert pwb is not None 30 | gl = {'page': pwb.page, 'pwb': pwb} 31 | loop = asyncio.get_running_loop() 32 | done = asyncio.Event() 33 | ex = [] 34 | 35 | def _execute(): 36 | try: 37 | exec(source, gl) 38 | except Exception as e: 39 | logger.error(f'Error executing source: {e}') 40 | ex.append(e) 41 | loop.call_soon_threadsafe(done.set) 42 | 43 | args.queue.put(_execute) 44 | timeout = timeout_secs * timeout_multiplier() 45 | try: 46 | await asyncio.wait_for(done.wait(), timeout=timeout) 47 | except asyncio.TimeoutError: 48 | raise TimeoutError(f"Execution timed out after {timeout} seconds for code `{source}`") 49 | 50 | if ex: raise ex[0] 51 | -------------------------------------------------------------------------------- /src/wwwpy/server/settingslib.py: -------------------------------------------------------------------------------- 1 | from .. import platformdirs 2 | from ..common.settingslib import Settings 3 | 4 | 5 | def user_settings() -> Settings: 6 | _user_config_path = platformdirs.user_config_path('wwwpy', roaming=True, ensure_exists=True) 7 | settings = Settings() 8 | settings.load(_user_config_path / 'settings.ini') 9 | return settings 10 | -------------------------------------------------------------------------------- /src/wwwpy/server/tcp_port.py: -------------------------------------------------------------------------------- 1 | import errno 2 | import logging 3 | import socket 4 | 5 | logger = logging.getLogger(__name__) 6 | 7 | _start = (10035 - 1) # windows doesn't like the previous port range 8 | _end = 30000 9 | 10 | 11 | def find_port() -> int: 12 | global _start 13 | while _start < _end: 14 | _start += 1 15 | with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: 16 | try: 17 | s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 18 | s.bind(('0.0.0.0', _start)) 19 | print(f'findport() -> {_start}') 20 | return _start 21 | except socket.error as e: 22 | if e.errno != errno.EADDRINUSE: 23 | raise 24 | 25 | raise Exception(f'find_port(start={_start},end={_end}) no bindable tcp port found in interval') 26 | 27 | if __name__ == '__main__': 28 | print(find_port()) 29 | print(find_port()) 30 | 31 | 32 | def is_port_busy(port: int) -> bool: 33 | with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: 34 | try: 35 | if hasattr(socket, "SO_REUSEPORT"): 36 | s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 37 | s.bind(('0.0.0.0', port)) 38 | return False 39 | except socket.error as e: 40 | # logger.error(f'is_port_busy({port}) -> {e.errno}', exc_info=e) 41 | if e.errno == errno.EADDRINUSE: 42 | return True 43 | raise 44 | -------------------------------------------------------------------------------- /src/wwwpy/server/wait_url.py: -------------------------------------------------------------------------------- 1 | import urllib.error 2 | import urllib.request 3 | from time import sleep 4 | 5 | 6 | def wait_url(url: str) -> None: 7 | for _ in range(300): 8 | try: 9 | urllib.request.urlopen(url) 10 | return 11 | except urllib.error.HTTPError: 12 | return 13 | except Exception: 14 | sleep(0.01) 15 | raise Exception(f'timeout wait_url(`{url}`)') 16 | -------------------------------------------------------------------------------- /src/wwwpy/unasync.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import sys 3 | import traceback 4 | from inspect import iscoroutinefunction 5 | from threading import Thread 6 | 7 | 8 | def unasync(f): 9 | if not iscoroutinefunction(f): 10 | return f 11 | result = None 12 | exc_info = None 13 | exception = None 14 | 15 | async def main_safe(*args, **kwargs): 16 | nonlocal result, exc_info, exception 17 | result = None 18 | exc_info = None 19 | exception = None 20 | try: 21 | result = await f(*args, **kwargs) 22 | except Exception as ex: 23 | exception = ex 24 | exc_info = sys.exc_info() 25 | 26 | def start(*args, **kwargs): 27 | asyncio.run(main_safe(*args, **kwargs)) 28 | 29 | def wrapper(*args, **kwargs): 30 | nonlocal result, exc_info, exception 31 | thread = Thread(target=start, args=args, kwargs=kwargs) 32 | thread.start() 33 | thread.join() 34 | if exc_info is not None: 35 | exc_type, exc_value, exc_traceback = exc_info 36 | print('', file=sys.stderr) 37 | traceback.print_exception(exc_type, exc_value, exc_traceback, file=sys.stderr) 38 | raise exception 39 | return result 40 | 41 | return wrapper 42 | -------------------------------------------------------------------------------- /src/wwwpy/webservers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wwwpy-labs/wwwpy/2179159a90bd712a8507f67d667dbd28db9f7f74/src/wwwpy/webservers/__init__.py -------------------------------------------------------------------------------- /src/wwwpy/webservers/asgi_webserver.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, Awaitable 2 | 3 | from wwwpy.common.asynclib import OptionalCoroutine 4 | from wwwpy.http import HttpRoute 5 | from wwwpy.server.asgi import routes_to_asgi_application, AsgiApplication 6 | from wwwpy.webserver import Webserver, Route 7 | from wwwpy.websocket import WebsocketRoute 8 | 9 | 10 | class AsgiWebserver(Webserver): 11 | def __init__(self, start): 12 | super().__init__() 13 | self._start = start 14 | self.app = AsgiApplication() 15 | 16 | def _setup_route(self, route: Route): 17 | if isinstance(route, HttpRoute): 18 | self.app.http_route[route.path] = route 19 | elif isinstance(route, WebsocketRoute): 20 | self.app.websocket_route[route.path] = route 21 | else: 22 | raise Exception(f'Unknown route type: {type(route)}') 23 | 24 | async def _start_listen(self) -> None: 25 | await self._start(self) 26 | -------------------------------------------------------------------------------- /src/wwwpy/webservers/available_webservers.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import Iterator, List 3 | 4 | from ..webserver import Webserver 5 | 6 | 7 | class AvailableWebservers: 8 | def __init__(self) -> None: 9 | self._classes = _webservers_classes() 10 | 11 | @property 12 | def ids(self) -> Iterator[str]: 13 | return map(lambda w: w.__name__, self._classes) 14 | 15 | def new_instance(self) -> Webserver: 16 | return self._classes[0]() 17 | 18 | def instances(self) -> Iterator[Webserver]: 19 | for webserver_class in self._classes: 20 | yield webserver_class() 21 | 22 | 23 | def _webservers_classes() -> List[type[Webserver]]: 24 | result: List[type[Webserver]] = [] 25 | try: 26 | from .tornado import WsTornado 27 | result.append(WsTornado) 28 | except: 29 | pass 30 | 31 | return result 32 | 33 | 34 | _available_webservers: AvailableWebservers | None = None 35 | 36 | 37 | def available_webservers() -> AvailableWebservers: 38 | global _available_webservers 39 | if _available_webservers is None: 40 | _available_webservers = AvailableWebservers() 41 | return _available_webservers 42 | -------------------------------------------------------------------------------- /src/wwwpy_plugins/README.md: -------------------------------------------------------------------------------- 1 | Namespace package for wwwpy plugins. -------------------------------------------------------------------------------- /src/wwwpy_plugins/wwwpy/README.md: -------------------------------------------------------------------------------- 1 | The namespace `wwwpy_plugins.wwwpy` and all packages starting with `wwwpy_plugins.wwwpy-` are reserved for internal use. -------------------------------------------------------------------------------- /src/wwwpy_plugins/wwwpy/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wwwpy-labs/wwwpy/2179159a90bd712a8507f67d667dbd28db9f7f74/src/wwwpy_plugins/wwwpy/__init__.py -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from functools import partial 3 | from typing import Iterable 4 | 5 | import pytest 6 | 7 | from wwwpy.server.tcp_port import find_port 8 | from wwwpy.webserver import Webserver 9 | from wwwpy.webservers.available_webservers import available_webservers 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | def _webservers_instances() -> Iterable[Webserver]: 15 | for i in available_webservers().instances(): 16 | i.set_port(find_port()) 17 | yield i 18 | 19 | 20 | def for_all_webservers(): 21 | return partial(pytest.mark.parametrize, 'webserver', _webservers_instances(), 22 | ids=available_webservers().ids)() 23 | 24 | -------------------------------------------------------------------------------- /tests/available_webservers_test.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Iterator 2 | 3 | from wwwpy.webservers.available_webservers import available_webservers 4 | 5 | 6 | def test_at_least_one(): 7 | next(available_webservers().ids) 8 | 9 | 10 | def test_ids(): 11 | for ws_id in available_webservers().ids: 12 | assert isinstance(ws_id, str) 13 | assert ws_id.isidentifier() 14 | 15 | 16 | def test_new_instance(): 17 | instance = available_webservers().new_instance() 18 | assert instance is not None 19 | 20 | 21 | def test_instances(): 22 | instances = available_webservers().instances() 23 | assert isinstance(instances, Iterator) 24 | -------------------------------------------------------------------------------- /tests/common/collectionlib_test.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from wwwpy.common import collectionlib as cl 4 | 5 | 6 | @dataclass 7 | class Item: 8 | name: str 9 | color: str 10 | 11 | 12 | class MyListMap(cl.ListMap[Item]): 13 | def _key(self, item: Item) -> str: 14 | return item.name 15 | 16 | 17 | def test_add_item(): 18 | # GIVEN 19 | target = MyListMap() 20 | 21 | # WHEN 22 | target.append(Item('apple', 'red')) 23 | 24 | # THEN 25 | assert len(target) == 1 26 | assert target.get('apple').color == 'red' 27 | 28 | def test_insert_item(): 29 | # GIVEN 30 | target = MyListMap() 31 | 32 | # WHEN 33 | target.insert(0, Item('apple', 'red')) 34 | 35 | # THEN 36 | assert len(target) == 1 37 | assert target.get('apple').color == 'red' 38 | 39 | def test_extend_items(): 40 | # GIVEN 41 | target = MyListMap() 42 | 43 | # WHEN 44 | target.extend([Item('apple', 'red'), Item('banana', 'yellow')]) 45 | 46 | # THEN 47 | assert len(target) == 2 48 | assert target.get('apple').color == 'red' 49 | assert target.get('banana').color == 'yellow' 50 | 51 | def test_items_in_constructor(): 52 | # GIVEN 53 | target = MyListMap([Item('apple', 'red'), Item('banana', 'yellow')]) 54 | 55 | # THEN 56 | assert len(target) == 2 57 | assert target.get('apple').color == 'red' 58 | assert target.get('banana').color == 'yellow' 59 | assert target.get('missing') is None 60 | 61 | 62 | def test_keyfunc_in_constructor(): 63 | # GIVEN 64 | target = cl.ListMap([Item('apple', 'red'), Item('banana', 'yellow')], key_func=lambda x: x.color) 65 | 66 | # THEN 67 | assert len(target) == 2 68 | assert target.get('red').name == 'apple' 69 | assert target.get('yellow').name == 'banana' 70 | 71 | 72 | def test_equality_with_list(): 73 | # GIVEN 74 | target = cl.ListMap(['a', 'b', 'c']) 75 | 76 | # THEN 77 | assert target == ['a', 'b', 'c'] 78 | -------------------------------------------------------------------------------- /tests/common/databind/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wwwpy-labs/wwwpy/2179159a90bd712a8507f67d667dbd28db9f7f74/tests/common/databind/__init__.py -------------------------------------------------------------------------------- /tests/common/designer/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wwwpy-labs/wwwpy/2179159a90bd712a8507f67d667dbd28db9f7f74/tests/common/designer/__init__.py -------------------------------------------------------------------------------- /tests/common/designer/canvas_selection_test.py: -------------------------------------------------------------------------------- 1 | from wwwpy.common.designer.canvas_selection import CanvasSelection, CanvasSelectionChangeEvent 2 | from wwwpy.common.designer.html_locator import html_to_node_path 3 | from wwwpy.common.designer.locator_lib import Locator, Origin 4 | 5 | 6 | def _new_element_path_old(): 7 | node_path = html_to_node_path("""""", [0]) 8 | ep = Locator('p1.comp1', 'Comp2', node_path, Origin.source) 9 | return ep 10 | 11 | 12 | def _new_element_path(class_module, class_name, html, path, origin): 13 | return Locator(class_module, class_name, html_to_node_path(html, path), origin) 14 | 15 | 16 | _some_element_path = _new_element_path('p1.comp1', 'Comp2', "
", [0], Origin.source) 17 | 18 | 19 | def test_initial_state(): 20 | # GIVEN 21 | target = CanvasSelection() 22 | 23 | # WHEN 24 | 25 | # THEN 26 | assert target.current_selection is None 27 | 28 | 29 | def test_assignment_should_stick(): 30 | # GIVEN 31 | target = CanvasSelection() 32 | ep = _new_element_path_old() 33 | 34 | # WHEN 35 | target.current_selection = ep 36 | 37 | # THEN 38 | assert target.current_selection is ep 39 | 40 | 41 | def test_events(): 42 | # GIVEN 43 | target = CanvasSelection() 44 | ep1 = _new_element_path_old() 45 | ep2 = _new_element_path('p1.comp1', 'Comp2', "
", [0], Origin.source) 46 | events = [] 47 | target.on_change.add(lambda e: events.append(e)) 48 | 49 | # WHEN 50 | target.current_selection = ep1 51 | 52 | # THEN 53 | assert events == [CanvasSelectionChangeEvent(None, ep1)] 54 | events.clear() 55 | 56 | # WHEN 57 | target.current_selection = ep2 58 | 59 | # THEN 60 | assert events == [CanvasSelectionChangeEvent(ep1, ep2)] 61 | events.clear() 62 | 63 | # WHEN 64 | target.current_selection = None 65 | 66 | # THEN 67 | assert events == [CanvasSelectionChangeEvent(ep2, None)] 68 | -------------------------------------------------------------------------------- /tests/common/designer/component_fixture.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | 5 | from tests.common import DynSysPath, dyn_sys_path 6 | 7 | 8 | # todo should be merged/refactored with TargetFixture 9 | class ComponentFixture: 10 | def __init__(self, dyn_sys_path: DynSysPath): 11 | self._source = None 12 | self.dyn_sys_path: DynSysPath = dyn_sys_path 13 | 14 | def write_component(self, path: str, class_name: str, html: str = '') -> Path: 15 | return self.dyn_sys_path.write_module2(path, f''' 16 | class {class_name}: 17 | def init_component(self): 18 | self.element.innerHTML = """{html}""" 19 | ''') 20 | 21 | 22 | @pytest.fixture 23 | def component_fixture(dyn_sys_path: DynSysPath): 24 | return ComponentFixture(dyn_sys_path) 25 | -------------------------------------------------------------------------------- /tests/common/designer/element_library_test.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | import logging 3 | 4 | import pytest 5 | 6 | from wwwpy.common.designer import element_library 7 | from wwwpy.common.designer.element_library import ElementDef 8 | from wwwpy.common.rpc import serialization 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | @pytest.fixture 14 | def el(): 15 | return element_library.element_library() 16 | 17 | 18 | def test_element_library(el): 19 | assert len(el.elements) > 0 20 | 21 | 22 | def test_basic_attributes(el): 23 | span = el.by_tag_name('span') 24 | assert span 25 | assert span.attributes.get('class') is not None 26 | 27 | 28 | def test_shown_element(el): 29 | assert el.by_tag_name('sl-button') is not None 30 | 31 | 32 | def test_serialization(el): 33 | for e in el.elements: 34 | e = dataclasses.replace(e) 35 | e.gen_html = None 36 | logger.debug(f'serializing {e.tag_name}') 37 | ser = serialization.to_json(e, ElementDef) 38 | deser = serialization.from_json(ser, ElementDef) 39 | assert e == deser 40 | 41 | 42 | def test_unknown_element(el): 43 | ed = el.by_tag_name('unknown-element') 44 | assert ed is not None 45 | assert ed is el.by_tag_name('unknown-element') 46 | -------------------------------------------------------------------------------- /tests/common/designer/ui/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wwwpy-labs/wwwpy/2179159a90bd712a8507f67d667dbd28db9f7f74/tests/common/designer/ui/__init__.py -------------------------------------------------------------------------------- /tests/common/designer/ui/_drop_indicator_svg_test.py: -------------------------------------------------------------------------------- 1 | import xml.etree.ElementTree as ET 2 | 3 | from wwwpy.common.designer.html_edit import Position 4 | from wwwpy.common.designer.ui._drop_indicator_svg import svg_indicator_for, position_for 5 | 6 | 7 | def test_valid_xml(): 8 | for p in Position: 9 | # xml_string = 'text' 10 | xml_string = svg_indicator_for(100, 50, p) 11 | root = ET.fromstring(xml_string) 12 | assert root.tag == '{http://www.w3.org/2000/svg}svg' 13 | 14 | 15 | def test_svg_should_be_different_from_each_other(): 16 | a, b, c, d = map(lambda p: svg_indicator_for(100, 50, p), Position) 17 | assert a != b 18 | assert a != c 19 | assert b != c 20 | assert a != d 21 | assert b != d 22 | assert c != d 23 | 24 | 25 | class TestPositionFor: 26 | def test_beforebegin(self): 27 | assert position_for(100, 50, 1, 1) == Position.beforebegin 28 | 29 | def test_afterend(self): 30 | assert position_for(100, 50, 100 - 1, 50 - 1) == Position.afterend 31 | 32 | def test_afterbegin(self): 33 | assert position_for(50, 50, 25 - 1, 25 - 1) == Position.afterbegin 34 | 35 | def test_beforeend(self): 36 | assert position_for(50, 50, 25 + 1, 25 + 1) == Position.beforeend 37 | -------------------------------------------------------------------------------- /tests/common/designer/ui/rect_readonly_py_test.py: -------------------------------------------------------------------------------- 1 | from wwwpy.common.designer.ui.rect_readonly_py import RectReadOnlyPy 2 | 3 | 4 | def test_rect_read_only_dc_instantiation_and_properties(): 5 | rect = RectReadOnlyPy(x=1.0, y=2.0, width=3.0, height=4.0) 6 | # base properties 7 | assert rect.x == 1.0 8 | assert rect.y == 2.0 9 | assert rect.width == 3.0 10 | assert rect.height == 4.0 11 | # calculated edges 12 | assert rect.left == rect.x 13 | assert rect.top == rect.y 14 | assert rect.right == rect.x + rect.width 15 | assert rect.bottom == rect.y + rect.height 16 | 17 | 18 | def test_rect_read_only_dc_to_json(): 19 | rect = RectReadOnlyPy(x=1.0, y=2.0, width=3.0, height=4.0) 20 | json_data = rect.toJSON() 21 | 22 | # Should match DOMRectReadOnly.toJSON() structure 23 | assert json_data == { 24 | "x": 1.0, 25 | "y": 2.0, 26 | "width": 3.0, 27 | "height": 4.0 28 | } 29 | 30 | 31 | def test_rect_read_only_init_from_instance(): 32 | original = RectReadOnlyPy(x=1.0, y=2.0, width=3.0, height=4.0) 33 | dup = RectReadOnlyPy(original) 34 | assert dup.x == 1.0 35 | assert dup.y == 2.0 36 | assert dup.width == 3.0 37 | assert dup.height == 4.0 38 | assert dup.left == original.left 39 | assert dup.top == original.top 40 | assert dup.right == original.right 41 | assert dup.bottom == original.bottom 42 | 43 | 44 | def test_rect_read_only_init_from_duck_typed_obj(): 45 | class Dummy: 46 | def __init__(self, x, y, width, height): 47 | self.x = x 48 | self.y = y 49 | self.width = width 50 | self.height = height 51 | 52 | dummy = Dummy(5.0, 6.0, 7.0, 8.0) 53 | rect = RectReadOnlyPy(dummy) 54 | assert rect.x == 5.0 55 | assert rect.y == 6.0 56 | assert rect.width == 7.0 57 | assert rect.height == 8.0 58 | assert rect.left == 5.0 59 | assert rect.top == 6.0 60 | assert rect.right == 12.0 61 | assert rect.bottom == 14.0 62 | assert rect.toJSON() == {"x": 5.0, "y": 6.0, "width": 7.0, "height": 8.0} 63 | -------------------------------------------------------------------------------- /tests/common/designer/ui/svg_test.py: -------------------------------------------------------------------------------- 1 | import xml.etree.ElementTree as ET 2 | 3 | from wwwpy.common.designer.ui.svg import build_document, add_rounded_background2 4 | 5 | # language=html 6 | _svg1 = """ 7 | 8 | 9 | 10 | 11 | 12 | """ 13 | 14 | 15 | def test_namespace_ok(): 16 | svg_str = add_rounded_background2(_svg1, '#00FF00') 17 | assert 'ns0:' not in svg_str 18 | assert 'xmlns="http://www.w3.org/2000/svg"' in svg_str 19 | 20 | 21 | def test_change_stroke_color(): 22 | def mutator(attrs): 23 | if 'stroke' in attrs: 24 | attrs['stroke'] = '#000000' 25 | 26 | result = build_document(_svg1, mutator) 27 | root = ET.fromstring(result) 28 | for elem in root.iter(): 29 | if elem.tag.endswith('path'): 30 | assert elem.attrib['stroke'] == '#000000' 31 | 32 | assert 'ns0:' not in result 33 | assert 'xmlns="http://www.w3.org/2000/svg"' in result 34 | 35 | 36 | def test_add_data_test_attribute(): 37 | def mutator(attrs): 38 | attrs['data-test'] = '1' 39 | 40 | result = build_document(_svg1, mutator) 41 | root = ET.fromstring(result) 42 | for elem in root.iter(): 43 | assert elem.attrib.get('data-test') == '1' 44 | 45 | assert 'ns0:' not in result 46 | assert 'xmlns="http://www.w3.org/2000/svg"' in result 47 | -------------------------------------------------------------------------------- /tests/common/event_observer_test.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | 3 | from wwwpy.common.event_observer import EventObserver 4 | 5 | 6 | def test_not_enough_time_passed_for_stability(): 7 | now = datetime(2021, 1, 1, 0, 0, 0) 8 | target = EventObserver(100, lambda: now) 9 | assert not target.is_stable() 10 | 11 | 12 | def test_enough_time_passed_for_stability(): 13 | now = datetime(2021, 1, 1, 0, 0, 0) 14 | 15 | def time_provider(): 16 | nonlocal now 17 | return now 18 | 19 | target = EventObserver(100, time_provider) 20 | now = now + timedelta(milliseconds=101) 21 | assert target.is_stable() 22 | 23 | def test_event_delay_stability(): 24 | now = datetime(2021, 1, 1, 0, 0, 0) 25 | 26 | def time_provider(): 27 | nonlocal now 28 | return now 29 | 30 | target = EventObserver(100, time_provider) 31 | now = now + timedelta(milliseconds=50) 32 | target.event_happened() 33 | now = now + timedelta(milliseconds=70) 34 | assert not target.is_stable() 35 | now = now + timedelta(milliseconds=70) 36 | assert target.is_stable() 37 | -------------------------------------------------------------------------------- /tests/common/exitlib_test.py: -------------------------------------------------------------------------------- 1 | from wwwpy.common.exitlib import on_exit 2 | 3 | 4 | def test_on_exit_callback_triggered(): 5 | calls = [] 6 | 7 | def fun(): 8 | on_exit(lambda: calls.append(1)) 9 | 10 | fun() 11 | 12 | assert calls == [1] 13 | 14 | 15 | def test_multiple_on_exit_callbacks(): 16 | calls = [] 17 | 18 | def fun(): 19 | on_exit(lambda: calls.append(1)) 20 | on_exit(lambda: calls.append(2)) 21 | 22 | fun() 23 | 24 | assert calls == [1, 2] 25 | 26 | 27 | def test_exception_on_exit(): 28 | calls = [] 29 | 30 | def fun(): 31 | on_exit(lambda: calls.append(1)) 32 | raise Exception("Test exception") 33 | 34 | try: 35 | fun() 36 | except Exception: 37 | pass 38 | 39 | assert calls == [1] 40 | -------------------------------------------------------------------------------- /tests/common/iterlib_test.py: -------------------------------------------------------------------------------- 1 | from typing import Iterable, TypeVar, Generic, Callable, Iterator 2 | 3 | from wwwpy.common import iterlib 4 | 5 | 6 | def test_callable_to_iterable(): 7 | def call(): 8 | yield 1 9 | yield 2 10 | 11 | target = iterlib.CallableToIterable(call) 12 | 13 | assert list(target) == [1, 2] 14 | assert list(target) == [1, 2] 15 | 16 | 17 | def test_should_swallow_exception__during_creation(): 18 | def call(): 19 | raise Exception('some') 20 | 21 | target = iterlib.CallableToIterable(call) 22 | 23 | assert list(target) == [] 24 | assert list(target) == [] 25 | 26 | 27 | def test_should_swallow_exception__during_iteration(): 28 | def call(): 29 | yield 1 30 | raise Exception('some') 31 | 32 | target = iterlib.CallableToIterable(call) 33 | 34 | assert list(target) == [1] 35 | assert list(target) == [1] 36 | -------------------------------------------------------------------------------- /tests/common/loglib_test.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from wwwpy.common.loglib import translate_names 4 | 5 | 6 | def test_translate_names(): 7 | result = translate_names({'mod1.mod2': 'DEBUG', 'mod3': 'INFO'}) 8 | assert result == {'mod1.mod2': logging.DEBUG, 'mod3': logging.INFO} 9 | 10 | 11 | def test_translate_unrecognized(): 12 | result = translate_names({'mod1.mod2': 'DEBUG', 'mod3': 'INFO', 'mod4': 'FOO'}) 13 | assert result == {'mod1.mod2': logging.DEBUG, 'mod3': logging.INFO} 14 | -------------------------------------------------------------------------------- /tests/common/modlib_test.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from tests.common import restore_sys_path, dyn_sys_path, DynSysPath 4 | from wwwpy.common.modlib import _find_module_path 5 | 6 | 7 | def test_package_path(tmp_path, restore_sys_path): 8 | # GIVEN 9 | sys.path.insert(0, str(tmp_path)) 10 | mod1py = tmp_path / 'module1.py' 11 | mod1py.write_text(' invalid python ') 12 | 13 | # WHEN 14 | target = _find_module_path('module1') 15 | # THEN 16 | assert target is not None 17 | assert str(mod1py) == str(target) 18 | 19 | 20 | def test_package_path2(dyn_sys_path: DynSysPath): 21 | # GIVEN 22 | mod1py = dyn_sys_path.write_module('package1', 'module1', ' I am module1') 23 | dyn_sys_path.write_module('package1', '__init__', 'I am package1') 24 | 25 | # WHEN 26 | target = _find_module_path('package1.module1') 27 | # THEN 28 | assert target is not None 29 | assert str(mod1py) == str(target) 30 | 31 | 32 | def test_package_path3(dyn_sys_path: DynSysPath): 33 | # GIVEN 34 | dyn_sys_path.write_module('p1', 'p2', 'import xyz') 35 | mod1py= dyn_sys_path.write_module('p1.p2', 'm1', 'import xyz') 36 | 37 | # WHEN 38 | target = _find_module_path('p1.p2.m1') 39 | # THEN 40 | assert target is not None 41 | assert str(mod1py) == str(target) 42 | 43 | def test_package_path4(dyn_sys_path: DynSysPath): 44 | # GIVEN 45 | 46 | # WHEN 47 | target = _find_module_path('wwwpy.remote.component') 48 | # THEN 49 | assert target is not None 50 | assert target.name == 'component.py' 51 | 52 | def test_package_not_found(): 53 | # GIVEN 54 | 55 | # WHEN 56 | target = _find_module_path('package_not_exists') 57 | # THEN 58 | assert target is None -------------------------------------------------------------------------------- /tests/common/quickstart_test.py: -------------------------------------------------------------------------------- 1 | from wwwpy.common.quickstart import quickstart_list, is_empty_project 2 | 3 | 4 | def test_quickstart_list(): 5 | target = quickstart_list() 6 | assert target is not None 7 | assert len(target) > 0 8 | 9 | 10 | def test_basic(): 11 | target = quickstart_list() 12 | basic = target.get('basic') 13 | assert basic is not None 14 | assert basic.name == 'basic' 15 | assert basic.title == 'Basic setup for a new project' 16 | assert basic.description == 'It contains just a simple Component with almost no content' 17 | 18 | 19 | def test_basic_should_be_the_first(): 20 | target = quickstart_list() 21 | first = target[0] 22 | assert first.name == 'basic' 23 | 24 | 25 | def test_empty_project(tmp_path): 26 | assert is_empty_project(tmp_path) 27 | 28 | 29 | def test_empty_project2(tmp_path): 30 | (tmp_path / 'remote').mkdir() 31 | (tmp_path / '__pycache__').mkdir() 32 | assert is_empty_project(tmp_path) 33 | 34 | 35 | def test_empty_project3(tmp_path): 36 | (tmp_path / 'remote').mkdir() 37 | (tmp_path / 'remote/__init__.py').touch() 38 | assert is_empty_project(tmp_path) 39 | 40 | 41 | def test_not_empty_project(tmp_path): 42 | remote = tmp_path / 'remote' 43 | remote.mkdir() 44 | (remote / '__init__.py').write_text('# some content') 45 | assert not is_empty_project(tmp_path) 46 | 47 | 48 | def test_not_empty_project2(tmp_path): 49 | (tmp_path / 'some-file.txt').touch() 50 | assert not is_empty_project(tmp_path) 51 | -------------------------------------------------------------------------------- /tests/common/reload/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wwwpy-labs/wwwpy/2179159a90bd712a8507f67d667dbd28db9f7f74/tests/common/reload/__init__.py -------------------------------------------------------------------------------- /tests/common/reload/reload_1/component.py: -------------------------------------------------------------------------------- 1 | print('loading 123...') 2 | 3 | class Class1: 4 | def __init__(self): 5 | self.a = 123 6 | -------------------------------------------------------------------------------- /tests/common/reload/reload_1/reload_1_test_disable.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | 4 | def test_1(): 5 | import component 6 | 7 | assert component.Class1().a == 123 8 | 9 | # beware of issue https://stackoverflow.com/questions/71544388/understanding-pythons-importlib-reload 10 | component_file = (Path(__file__).parent / 'component.py') 11 | component_file.write_text(component_file.read_text().replace('.a = 123', '.b = 45')) 12 | 13 | from wwwpy.common import reloader 14 | reloader.reload(component) 15 | 16 | assert component.Class1().b == 45 17 | -------------------------------------------------------------------------------- /tests/common/reload/reload_2/package2/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wwwpy-labs/wwwpy/2179159a90bd712a8507f67d667dbd28db9f7f74/tests/common/reload/reload_2/package2/__init__.py -------------------------------------------------------------------------------- /tests/common/reload/reload_2/package2/class_a.py: -------------------------------------------------------------------------------- 1 | print('loading ClassA... 123') 2 | import package2.class_b as class_b 3 | 4 | 5 | class ClassA: 6 | def __init__(self): 7 | self.b = 45 8 | self.class_b = class_b.ClassB() 9 | -------------------------------------------------------------------------------- /tests/common/reload/reload_2/package2/class_b.py: -------------------------------------------------------------------------------- 1 | print('loading ClassB 123...') 2 | 3 | 4 | class ClassB: 5 | def __init__(self): 6 | self.b = 45 7 | -------------------------------------------------------------------------------- /tests/common/reload/reload_2/reload_2_test_disable.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from wwwpy.common import modlib 4 | from wwwpy.common.modlib import _find_module_path 5 | from wwwpy.common.reloader import unload_path 6 | 7 | parent = Path(__file__).parent / 'package2' 8 | 9 | 10 | def test_2(): 11 | replace((parent / 'class_a.py'), '.b = 45', '.a = 123') 12 | replace((parent / 'class_b.py'), '.b = 45', '.a = 123') 13 | 14 | import package2.class_a 15 | 16 | assert package2.class_a.ClassA().a == 123 17 | assert package2.class_a.ClassA().class_b.a == 123 18 | 19 | # beware of issue https://stackoverflow.com/questions/71544388/understanding-pythons-importlib-reload 20 | replace((parent / 'class_a.py'), '.a = 123', '.b = 45') 21 | replace((parent / 'class_b.py'), '.a = 123', '.b = 45') 22 | 23 | p2 = modlib._find_package_directory('package2') 24 | 25 | unload_path(str(p2)) 26 | 27 | del package2.class_a 28 | import package2.class_a 29 | assert package2.class_a.ClassA().b == 45 30 | assert package2.class_a.ClassA().class_b.b == 45 31 | 32 | 33 | def replace(path, old, new): 34 | path.write_text(path.read_text().replace(old, new)) 35 | -------------------------------------------------------------------------------- /tests/common/reload/reload_test.py: -------------------------------------------------------------------------------- 1 | """ 2 | Reload tests are defined like the following: 3 | - setup initial module(s) 4 | - verify something about the module(s) 5 | - change the module(s) 6 | - invoke the reload function 7 | - verify the change(s) took effect 8 | """ 9 | from pathlib import Path 10 | 11 | 12 | def test_reload_1(pytester): 13 | # GIVEN 14 | # copy all the content of test_files to the pytester path 15 | test_files = Path(__file__).parent / 'reload_1' 16 | _copy(test_files, pytester) 17 | 18 | # WHEN 19 | result = pytester.runpytest('-p', 'no:wwwpy') 20 | result.assert_outcomes(passed=1) 21 | 22 | 23 | def test_reload_2(pytester): 24 | """Class A uses class B. When class A is reloaded, class B should be reloaded as well.""" 25 | # GIVEN 26 | # copy all the content of test_files to the pytester path 27 | test_files = Path(__file__).parent / 'reload_2' 28 | _copy(test_files, pytester) 29 | 30 | # WHEN 31 | result = pytester.runpytest('-p', 'no:wwwpy') 32 | result.assert_outcomes(passed=1) 33 | 34 | 35 | _ignore = {'__pycache__'} 36 | 37 | 38 | def _copy(test_files, pytester, subpath: str = ''): 39 | for file in test_files.iterdir(): 40 | if file.is_dir(): 41 | if file.name not in _ignore: 42 | _copy(file, pytester, file.name + '/') 43 | continue 44 | filename = file.name.replace('_test_disable.py', '_test.py') 45 | path = pytester.path / (subpath + filename) 46 | path.parent.mkdir(parents=True, exist_ok=True) 47 | path.write_text(file.read_text()) 48 | -------------------------------------------------------------------------------- /tests/common/rpc/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wwwpy-labs/wwwpy/2179159a90bd712a8507f67d667dbd28db9f7f74/tests/common/rpc/__init__.py -------------------------------------------------------------------------------- /tests/common/rpc/custom_loader_test.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import sys 3 | 4 | from tests.common import dyn_sys_path, DynSysPath 5 | from wwwpy.common.rpc.custom_loader import CustomFinder 6 | 7 | 8 | def test_remote_rpc_interceptor(dyn_sys_path: DynSysPath): 9 | """Importing remote.rpc should not raise an exception even from the server side 10 | even though it imports 'js' that does not exist on the server side. 11 | It is because the import process of such package is handled and modified""" 12 | factory = MockLoaderFactory() 13 | target = CustomFinder({'remote.rpc'}, factory.MockLoader) 14 | sys.meta_path.insert(0, target) 15 | dyn_sys_path.write_module2('remote/rpc.py', ' not even python code') 16 | import remote # noqa 17 | assert remote 18 | 19 | import remote.rpc 20 | assert remote.rpc 21 | 22 | assert len(factory.mock_loaders) == 1 23 | assert factory.mock_loaders[0].sources == [' not even python code'] 24 | 25 | 26 | class MockLoaderFactory: 27 | def __init__(self): 28 | self.mock_loaders = [] 29 | parent = self 30 | 31 | class MockLoader(importlib.abc.Loader): 32 | def __init__(self, loader): 33 | self.loader = loader 34 | self.sources = [] 35 | parent.mock_loaders.append(self) 36 | 37 | def create_module(self, spec): 38 | return None 39 | 40 | def exec_module(self, module): 41 | module_name = module.__name__ 42 | source = self.loader.get_source(module_name) 43 | self.sources.append(source) 44 | 45 | self.MockLoader = MockLoader 46 | -------------------------------------------------------------------------------- /tests/common/rpc/support1.py: -------------------------------------------------------------------------------- 1 | def support1_function0(a: int, b: int) -> int: 2 | pass 3 | 4 | 5 | async def support1_function1(a: int, # this is done on purpose 6 | b: float) -> str: 7 | pass 8 | -------------------------------------------------------------------------------- /tests/common/rpc/support2.py: -------------------------------------------------------------------------------- 1 | def support2_mul(a: int, b: int) -> int: 2 | return a * b 3 | 4 | 5 | async def support2_concat(a: str, b: str) -> str: 6 | return a + b 7 | -------------------------------------------------------------------------------- /tests/common/rpc/support3.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | 3 | 4 | async def support3_mul(a: int, b: int) -> int: 5 | return a * b 6 | 7 | 8 | async def support3_concat(a: str, b: str) -> str: 9 | return a + b 10 | 11 | 12 | async def support3_with_typing_import() -> Dict[str, str]: 13 | return {'foo': 'bar'} 14 | 15 | 16 | async def support3_default_values_primitive_types(a: int, b: int = 2, c: int = 3) -> int: 17 | return a * b * c 18 | 19 | 20 | async def support3_throws_error(exception_message: str, return_message: str) -> str: 21 | if exception_message != '': 22 | raise Exception('support3_throws_error: ' + exception_message) 23 | if return_message != '': 24 | return return_message 25 | -------------------------------------------------------------------------------- /tests/common/rpc/test_invoker.py: -------------------------------------------------------------------------------- 1 | from tests.common.rpc.test_rpc import support2_module_name 2 | from wwwpy.common.rpc.func_registry import from_package_name 3 | from wwwpy.common.rpc.invoker import Invoker 4 | 5 | 6 | def test_simple_invoke(): 7 | module = from_package_name(support2_module_name) 8 | target = Invoker(module) 9 | # THEN 10 | actual = target['support2_mul'].func(6, 7) 11 | assert actual == 42 12 | 13 | -------------------------------------------------------------------------------- /tests/common/rpc/test_serializer.py: -------------------------------------------------------------------------------- 1 | from tests.common.rpc.test_rpc import support2_module_name 2 | from wwwpy.common.rpc.serializer import RpcRequest 3 | 4 | 5 | def test_rpc(): 6 | request = RpcRequest.to_json(support2_module_name, 'support2_mul', 6, 7) 7 | restored = RpcRequest.from_json(request) 8 | 9 | assert restored.module == support2_module_name 10 | assert restored.func == 'support2_mul' 11 | assert restored.args == [6, 7] 12 | -------------------------------------------------------------------------------- /tests/common/rpc2/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wwwpy-labs/wwwpy/2179159a90bd712a8507f67d667dbd28db9f7f74/tests/common/rpc2/__init__.py -------------------------------------------------------------------------------- /tests/common/rpc2/encoder_decoder_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from wwwpy.common.rpc2.encoder_decoder import JsonEncoderDecoder 4 | 5 | 6 | def test_encoder_buffer(): 7 | target = JsonEncoderDecoder() 8 | 9 | enc = target.encoder() 10 | 11 | enc.encode('ciao', str) 12 | enc.encode(123, int) 13 | 14 | assert enc.buffer 15 | 16 | dec = target.decoder(enc.buffer) 17 | 18 | assert 'ciao' == dec.decode(str) 19 | assert 123 == dec.decode(int) 20 | 21 | with pytest.raises(Exception): 22 | dec.decode(str) 23 | -------------------------------------------------------------------------------- /tests/common/rpc2/transport_fake.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass, field 4 | 5 | import pytest 6 | 7 | from wwwpy.common.rpc2.transport import Transport 8 | 9 | 10 | class TransportFake(Transport): 11 | 12 | def __init__(self): 13 | self.recv_buffer = [] 14 | self.send_buffer = [] 15 | self.send_sync_callback = lambda: None 16 | 17 | async def empty(): pass 18 | 19 | self.send_async_callback = empty 20 | 21 | def send_sync(self, payload: str | bytes): 22 | self.send_buffer.append(payload) 23 | self.send_sync_callback() 24 | 25 | async def send_async(self, payload: str | bytes): 26 | self.send_buffer.append(payload) 27 | await self.send_async_callback() 28 | 29 | def recv_sync(self) -> str | bytes: 30 | return self._consume() 31 | 32 | def _consume(self): 33 | if len(self.recv_buffer) == 0: 34 | raise Exception('Buffer is empty') 35 | return self.recv_buffer.pop(0) 36 | 37 | async def recv_async(self) -> str | bytes: 38 | return self._consume() 39 | 40 | 41 | @dataclass 42 | class PairedTransport: 43 | client: TransportFake = field(default_factory=lambda: TransportFake()) 44 | server: TransportFake = field(default_factory=lambda: TransportFake()) 45 | 46 | def __post_init__(self): 47 | self.client.recv_buffer = self.server.send_buffer 48 | self.client.send_buffer = self.server.recv_buffer 49 | 50 | 51 | def test_client_send_sync(): 52 | target = PairedTransport() 53 | 54 | target.client.send_sync('payload1') 55 | 56 | assert target.server.recv_sync() == 'payload1' 57 | 58 | 59 | def test_loop_transport_empty(): 60 | target = PairedTransport() 61 | with pytest.raises(Exception): 62 | target.server.recv_sync() 63 | 64 | with pytest.raises(Exception): 65 | target.client.recv_sync() 66 | -------------------------------------------------------------------------------- /tests/common/rpc2/typed_function_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import dataclasses 4 | 5 | from wwwpy.common.rpc2.typed_function import get_typed_function, TypedFunction 6 | 7 | 8 | class Car: ... 9 | 10 | 11 | def test_sync(): 12 | def mock_sync(a: int, b: Car) -> float: ... 13 | 14 | target = get_typed_function(mock_sync) 15 | 16 | assert target == TypedFunction(__name__, 'mock_sync', [int, Car], float, False) 17 | 18 | 19 | def test_async(): 20 | async def mock_async(a: int, b: Car) -> float: ... 21 | 22 | target = get_typed_function(mock_async) 23 | 24 | assert target == TypedFunction(__name__, 'mock_async', [int, Car], float, True) 25 | 26 | 27 | def test_none_type(): 28 | def none_explicit(a: int) -> None: ... 29 | 30 | def none_implicit(a: int): ... 31 | 32 | expected = TypedFunction(__name__, 'none_explicit', [int], type(None), False) 33 | assert get_typed_function(none_explicit) == dataclasses.replace(expected, func_name='none_explicit') 34 | assert get_typed_function(none_implicit) == dataclasses.replace(expected, func_name='none_implicit') 35 | -------------------------------------------------------------------------------- /tests/common/settingslib_test.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | 5 | from wwwpy.common.settingslib import Settings 6 | 7 | 8 | class Fix: 9 | def __init__(self, tmp_path: Path): 10 | self.target = Settings() 11 | self.ini = tmp_path / 'foo.ini' 12 | self.tmp_path = tmp_path 13 | 14 | def write_load(self, content): 15 | self.ini.write_text(content) 16 | self.target.load(self.ini) 17 | 18 | 19 | @pytest.fixture 20 | def fix(tmp_path) -> Fix: 21 | return Fix(tmp_path) 22 | 23 | 24 | def test_hotreload_self_default(fix: Fix): 25 | assert not fix.target.hotreload_self 26 | 27 | 28 | def test_hotreload_self_set(fix: Fix): 29 | fix.write_load('[general]\nhotreload_self=true') 30 | assert fix.target.hotreload_self 31 | 32 | 33 | def test_open_url(fix: Fix): 34 | assert not fix.target.open_url_code 35 | 36 | 37 | def test_open_url_set(fix: Fix): 38 | fix.write_load("""[general]\nopen_url_code=import os; os.system("open '{url}')""") 39 | assert fix.target.open_url_code == """import os; os.system("open '{url}')""" 40 | 41 | 42 | def test_log_level_empty(fix: Fix): 43 | assert {} == fix.target.log_level 44 | 45 | 46 | def test_log_level(fix: Fix): 47 | fix.write_load("""[log_level]\nmod1.mod2=DEBUG\nmod3=INFO""") 48 | assert {'mod1.mod2': 'DEBUG', 'mod3': 'INFO'} == fix.target.log_level 49 | 50 | 51 | def _new_target(tmp_path, content: str = None): 52 | target = Settings() 53 | ini = tmp_path / 'foo.ini' 54 | if content: 55 | ini.write_text(content) 56 | target.load(ini) 57 | return target, ini 58 | -------------------------------------------------------------------------------- /tests/common/version_test.py: -------------------------------------------------------------------------------- 1 | def test_version(): 2 | import wwwpy 3 | assert wwwpy.__version__ 4 | print(wwwpy.__version__) 5 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | pytest_plugins = ['pytester'] 2 | 3 | import logging 4 | 5 | 6 | def pytest_configure(): 7 | packages = ['tests', 'wwwpy', 'common', 'remote', 'server'] 8 | for log_name in packages: 9 | logging.getLogger(log_name).setLevel(logging.DEBUG) 10 | 11 | 12 | def pytest_report_from_serializable(config, data): 13 | replace_pair = [ 14 | ( 15 | 'File "/wwwpy_bundle/tests/', 16 | 'File "tests/' 17 | ), 18 | ( 19 | 'File "/wwwpy_bundle/wwwpy/', 20 | 'File "src/wwwpy/' 21 | ), 22 | ] 23 | 24 | def _replace_in_string(s: str) -> str: 25 | for old, new in replace_pair: 26 | s = s.replace(old, new) 27 | return s 28 | 29 | sections = data.get('sections', []) 30 | for section in sections: 31 | name: str = section[0] 32 | content: str = section[1] 33 | new_content = _replace_in_string(content) 34 | if new_content != content: 35 | section[1] = f'This stderr was modified by {__file__}\n{new_content}' 36 | -------------------------------------------------------------------------------- /tests/layer/LAYERS.md: -------------------------------------------------------------------------------- 1 | # Test layers 2 | 3 | Tests are organized in layers. The next layer uses the api from the previous one. 4 | 5 | - layer 1 - http server 6 | - half-duplex communication aka http-requests/http-response 7 | 8 | - layer 2 - resources 9 | - Resource represents something (a file) to be transferred 10 | - from_filesystem(...) returns an Iterable[PathResource] 11 | - library_resources() returns the library itself 12 | - define the user resources to be sent 13 | - locate/guesstimate the user files/folders 14 | - ... todo investigate better ways to do it 15 | - ; see python packages: modulefinder 16 | - 17 | - 18 | 19 | - build_archive(Iterator[Resource]) returns the bytes of a zip archive 20 | 21 | - layer 3 - remote code execution; playwright needed from here on 22 | - plain python code remote execution - pyodide 23 | - bootstrap_routes(...) deliver zipped python files and execute them 24 | - zip_route to serve an application/zip 25 | - bootstrap_route to serve an html file that 26 | - bootstrap pyodide with the following python 27 | - download the zip, uncompress it, add to sys path, execute some main() 28 | - ... docs; limited lib support: wwwpy is not available, only pyodide 29 | - layer 4 - convention(s) 30 | - layer 5 - test client/server communication 31 | - half-duplex rpc using http-request/http-response coms 32 | - full-duplex rpc using websockets/long-polling coms 33 | - full-duplex communication aka websocket/long-polling 34 | - layer 6 - higher level functionality 35 | - ask browser to refresh 36 | 37 | Tests should be executed on all supported web servers. -------------------------------------------------------------------------------- /tests/layer/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wwwpy-labs/wwwpy/2179159a90bd712a8507f67d667dbd28db9f7f74/tests/layer/__init__.py -------------------------------------------------------------------------------- /tests/layer/layer_2_support/build_archive/simple/dir1/bar.txt: -------------------------------------------------------------------------------- 1 | #bar -------------------------------------------------------------------------------- /tests/layer/layer_2_support/build_archive/simple/foo.txt: -------------------------------------------------------------------------------- 1 | #foo -------------------------------------------------------------------------------- /tests/layer/layer_2_support/from_filesystem/one_file/foo.py: -------------------------------------------------------------------------------- 1 | # foo.py 2 | -------------------------------------------------------------------------------- /tests/layer/layer_2_support/from_filesystem/relative_to/yes/yes.txt: -------------------------------------------------------------------------------- 1 | yes -------------------------------------------------------------------------------- /tests/layer/layer_2_support/from_filesystem/resource_filter/yes/reject/foo.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wwwpy-labs/wwwpy/2179159a90bd712a8507f67d667dbd28db9f7f74/tests/layer/layer_2_support/from_filesystem/resource_filter/yes/reject/foo.txt -------------------------------------------------------------------------------- /tests/layer/layer_2_support/from_filesystem/resource_filter/yes/yes.txt: -------------------------------------------------------------------------------- 1 | yes -------------------------------------------------------------------------------- /tests/layer/layer_4_support/convention_b/remote/__init__.py: -------------------------------------------------------------------------------- 1 | from js import document 2 | 3 | document.body.innerHTML = '' 4 | -------------------------------------------------------------------------------- /tests/layer/layer_4_support/convention_c_async/remote/__init__.py: -------------------------------------------------------------------------------- 1 | async def main(): 2 | from js import document 3 | document.body.innerHTML = '' 4 | -------------------------------------------------------------------------------- /tests/layer/layer_4_support/convention_c_sync/remote/__init__.py: -------------------------------------------------------------------------------- 1 | def main(): 2 | from js import document 3 | document.body.innerHTML = '' 4 | -------------------------------------------------------------------------------- /tests/layer/layer_5_support/rpc_remote/remote/__init__.py: -------------------------------------------------------------------------------- 1 | import js 2 | 3 | js.document.body.innerHTML = 'ready' 4 | -------------------------------------------------------------------------------- /tests/layer/layer_5_support/rpc_remote/remote/rpc.py: -------------------------------------------------------------------------------- 1 | from js import document 2 | 3 | 4 | class Layer5Rpc1: 5 | 6 | def set_body_inner_html(self, msg): 7 | document.body.innerHTML = msg 8 | -------------------------------------------------------------------------------- /tests/layer/layer_5_support/rpc_server/remote/__init__.py: -------------------------------------------------------------------------------- 1 | from js import document 2 | 3 | 4 | async def main(): 5 | from server.rpc import multiply 6 | res = await multiply(7, 6) 7 | document.body.innerHTML = str(res) 8 | -------------------------------------------------------------------------------- /tests/layer/layer_5_support/rpc_server/server/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wwwpy-labs/wwwpy/2179159a90bd712a8507f67d667dbd28db9f7f74/tests/layer/layer_5_support/rpc_server/server/__init__.py -------------------------------------------------------------------------------- /tests/layer/layer_5_support/rpc_server/server/rpc.py: -------------------------------------------------------------------------------- 1 | async def multiply(a: int, b: int) -> int: 2 | return a * b 3 | -------------------------------------------------------------------------------- /tests/layer/test_dev_mode.py: -------------------------------------------------------------------------------- 1 | import tests.server.convention_fixture 2 | import wwwpy.server.convention 3 | from tests.common import dyn_sys_path, DynSysPath 4 | from wwwpy.common.files import get_all_paths_with_hashes 5 | 6 | 7 | def test_dev_mode_disabled__should_NOT_create_canonical_components(dyn_sys_path: DynSysPath): 8 | tests.server.convention_fixture.start_test_convention(dyn_sys_path.path) 9 | assert get_all_paths_with_hashes(dyn_sys_path.path) == set() 10 | 11 | 12 | def test_dev_mode_non_empty_folder_but_no_remote__should_not_fail(dyn_sys_path: DynSysPath): 13 | # dyn_sys_path.path.mkdir('some-folder') 14 | (dyn_sys_path.path / 'some-folder').mkdir() 15 | tests.server.convention_fixture.start_test_convention(dyn_sys_path.path, dev_mode=True) 16 | 17 | 18 | -------------------------------------------------------------------------------- /tests/layer/test_layer_1.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import http.client 4 | import threading 5 | import urllib.parse 6 | from http import HTTPStatus 7 | from time import sleep 8 | from typing import Callable 9 | 10 | from tests import for_all_webservers 11 | from wwwpy.http import HttpRoute, HttpResponse, HttpRequest 12 | from wwwpy.server.tcp_port import find_port 13 | from wwwpy.server.fetch import sync_fetch_response 14 | from wwwpy.webserver import Webserver 15 | 16 | 17 | class TestHttpRoute: 18 | 19 | @for_all_webservers() 20 | def test_webservers_get(self, webserver: Webserver): 21 | response_a = HttpResponse('a', 'text/plain') 22 | response_b = HttpResponse('b', 'text/html') 23 | 24 | webserver.set_routes(HttpRoute('/b', lambda req, res: res(response_b))) 25 | webserver.set_routes(HttpRoute('/', lambda req, res: res(response_a))) 26 | 27 | webserver.set_port(find_port()).start_listen() 28 | 29 | url = webserver.localhost_url() 30 | 31 | assert sync_fetch_response(url) == response_a 32 | assert sync_fetch_response(url + '/b') == response_b 33 | 34 | @for_all_webservers() 35 | def test_webservers_post(self, webserver: Webserver): 36 | # GIVEN 37 | response_a = HttpResponse('a', 'text/plain') 38 | actual_request: HttpRequest | None = None 39 | 40 | def handler(req: HttpRequest, res) -> None: 41 | nonlocal actual_request 42 | actual_request = req 43 | # return response_a 44 | res(response_a) 45 | 46 | http_route = HttpRoute('/route1', handler) 47 | 48 | webserver.set_routes(http_route).start_listen() 49 | 50 | url = webserver.localhost_url() 51 | 52 | # WHEN 53 | actual_response = sync_fetch_response(url + '/route1', method='POST', data='post-body') 54 | 55 | # THEN 56 | assert actual_response == response_a 57 | assert actual_request.method == 'POST' 58 | assert actual_request.content.decode('utf8') == 'post-body' 59 | -------------------------------------------------------------------------------- /tests/layer/test_layer_4.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from playwright.sync_api import Page, expect 4 | 5 | import tests.server.convention_fixture 6 | import wwwpy.server.convention 7 | from tests import for_all_webservers 8 | from tests.common import restore_sys_path 9 | from wwwpy.resources import from_directory 10 | from wwwpy.server import configure 11 | from wwwpy.webserver import Webserver 12 | 13 | file_parent = Path(__file__).parent 14 | 15 | 16 | @for_all_webservers() 17 | def test_server_convention_b(page: Page, webserver: Webserver, restore_sys_path): 18 | _test_convention('convention_b', page, webserver) 19 | 20 | 21 | @for_all_webservers() 22 | def test_server_convention_c_async(page: Page, webserver: Webserver, restore_sys_path): 23 | _test_convention('convention_c_async', page, webserver) 24 | 25 | 26 | @for_all_webservers() 27 | def test_server_convention_c_sync(page: Page, webserver: Webserver, restore_sys_path): 28 | _test_convention('convention_c_sync', page, webserver) 29 | 30 | sub_text = "This may be because the running directory is not a valid wwwpy project directory." 31 | 32 | @for_all_webservers() 33 | def test_extraneous_file(page: Page, webserver: Webserver, restore_sys_path, tmp_path: Path): 34 | tmp_path.touch('some_file.txt') 35 | tests.server.convention_fixture.start_test_convention(tmp_path, webserver) 36 | webserver.start_listen() 37 | page.goto(webserver.localhost_url()) 38 | expect(page.locator("body")).to_contain_text(sub_text) 39 | 40 | 41 | @for_all_webservers() 42 | def test_empty__folder__error_message(page: Page, webserver: Webserver, restore_sys_path, tmp_path: Path): 43 | tests.server.convention_fixture.start_test_convention(tmp_path, webserver) 44 | webserver.start_listen() 45 | page.goto(webserver.localhost_url()) 46 | expect(page.locator("body")).to_contain_text(sub_text) 47 | 48 | 49 | def _test_convention(directory, page, webserver): 50 | tests.server.convention_fixture.start_test_convention(file_parent / 'layer_4_support' / directory, webserver) 51 | webserver.start_listen() 52 | page.goto(webserver.localhost_url()) 53 | expect(page.locator('id=tag1')).to_have_value(directory) 54 | -------------------------------------------------------------------------------- /tests/remote/README.md: -------------------------------------------------------------------------------- 1 | This tests will be executed in the browser -------------------------------------------------------------------------------- /tests/remote/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wwwpy-labs/wwwpy/2179159a90bd712a8507f67d667dbd28db9f7f74/tests/remote/__init__.py -------------------------------------------------------------------------------- /tests/remote/component_future_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from js import HTMLDivElement 4 | 5 | from wwwpy.remote.component import Component, element 6 | 7 | 8 | def test_correct_annotation(): 9 | class Comp1(Component): 10 | div1: HTMLDivElement = element() 11 | 12 | def init_component(self): 13 | self.element.innerHTML = """
div1
""" 14 | 15 | c = Comp1() 16 | nop = c.div1 17 | -------------------------------------------------------------------------------- /tests/remote/databind/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wwwpy-labs/wwwpy/2179159a90bd712a8507f67d667dbd28db9f7f74/tests/remote/databind/__init__.py -------------------------------------------------------------------------------- /tests/remote/databind/test_databind.py: -------------------------------------------------------------------------------- 1 | """this should test only InputTargetAdapter directly, not the whole databind""" 2 | from __future__ import annotations 3 | 4 | import logging 5 | from dataclasses import dataclass 6 | 7 | import js 8 | import pytest 9 | from js import document 10 | 11 | from wwwpy.common.databind.databind import Binding 12 | from wwwpy.remote.databind.bind_wrapper import InputTargetAdapter 13 | from wwwpy.server.rpc4tests import rpctst_exec 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | @dataclass 19 | class User: 20 | name: str 21 | 22 | 23 | @dataclass 24 | class Car: 25 | color: str 26 | 27 | 28 | async def test_databind_input_string1(fixture: Fixture): 29 | # GIVEN 30 | tag1 = _new_target_adapter() 31 | user = User('foo1') 32 | 33 | # WHEN 34 | _bind(user, 'name', tag1) 35 | 36 | # THEN 37 | assert tag1.input.value == 'foo1' 38 | 39 | 40 | async def test_databind_input_string2(fixture: Fixture): 41 | # GIVEN 42 | tag1 = _new_target_adapter() 43 | car1 = Car('yellow') 44 | 45 | # WHEN 46 | _bind(car1, 'color', tag1) 47 | 48 | # THEN 49 | assert tag1.input.value == 'yellow' 50 | 51 | 52 | async def test_databind_input_string__target_to_source(fixture: Fixture): 53 | # GIVEN 54 | tag1 = _new_target_adapter() 55 | car1 = Car('') 56 | 57 | # WHEN 58 | _bind(car1, 'color', tag1) 59 | 60 | await rpctst_exec("page.locator('#tag1').press_sequentially('yellow1')") 61 | 62 | # THEN 63 | assert car1.color == 'yellow1' 64 | 65 | 66 | def _new_target_adapter(tag_id: str = 'tag1'): 67 | tag1: js.HTMLInputElement = document.createElement('input') # noqa 68 | tag1.id = tag_id 69 | document.body.append(tag1) 70 | return InputTargetAdapter(tag1) 71 | 72 | 73 | def _bind(instance, attr_name, target_adapter): 74 | target = Binding(instance, attr_name, target_adapter) 75 | target.apply_binding() 76 | 77 | 78 | class Fixture: 79 | pass 80 | 81 | 82 | @pytest.fixture 83 | def fixture(): 84 | document.body.innerHTML = '' 85 | return Fixture() 86 | -------------------------------------------------------------------------------- /tests/remote/designer/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wwwpy-labs/wwwpy/2179159a90bd712a8507f67d667dbd28db9f7f74/tests/remote/designer/__init__.py -------------------------------------------------------------------------------- /tests/remote/designer/drop_zone_test.py: -------------------------------------------------------------------------------- 1 | from js import document, MouseEvent 2 | import js 3 | from wwwpy.remote import dict_to_js 4 | from wwwpy.remote.designer import drop_zone 5 | 6 | 7 | def test__extract_first_with_data_name(): 8 | # GIVEN 9 | document.body.innerHTML = '
' 10 | foo: js.HTMLDivElement = document.getElementById('foo') 11 | event = _materialize_mousemove(foo) 12 | 13 | # WHEN 14 | result = drop_zone._extract_first_with_data_name(event) 15 | 16 | # THEN 17 | assert result is not None 18 | assert result == foo.parentElement 19 | 20 | 21 | def test__the_element_itself_if_has_data_name(): 22 | # GIVEN 23 | document.body.innerHTML = '
' 24 | foo: js.HTMLDivElement = document.getElementById('foo') 25 | event = _materialize_mousemove(foo) 26 | 27 | # WHEN 28 | result = drop_zone._extract_first_with_data_name(event) 29 | 30 | # THEN 31 | assert result is not None 32 | assert result == foo 33 | 34 | 35 | def test__if_no_data_name_is_found__return_event_target(): 36 | # GIVEN 37 | document.body.innerHTML = '
' 38 | foo: js.HTMLDivElement = document.getElementById('foo') 39 | event = _materialize_mousemove(foo) 40 | 41 | # WHEN 42 | result = drop_zone._extract_first_with_data_name(event) 43 | 44 | # THEN 45 | assert result is not None 46 | assert result == foo 47 | 48 | 49 | def _materialize_mousemove(element): 50 | mouse_move_event = MouseEvent.new("mousemove", dict_to_js({ 51 | "bubbles": True, 52 | "cancelable": True, 53 | "clientX": 150, # X coordinate of the mouse pointer 54 | "clientY": 250, # Y coordinate of the mouse pointer 55 | })) 56 | events = [] 57 | 58 | def handle_real_event(event: MouseEvent): 59 | events.append(event) 60 | 61 | element.onmousemove = lambda ev: handle_real_event(ev) 62 | element.dispatchEvent(mouse_move_event) 63 | assert len(events) == 1 64 | event = events[0] 65 | return event 66 | -------------------------------------------------------------------------------- /tests/remote/designer/ui/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wwwpy-labs/wwwpy/2179159a90bd712a8507f67d667dbd28db9f7f74/tests/remote/designer/ui/__init__.py -------------------------------------------------------------------------------- /tests/remote/designer/ui/button_tab_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from js import document 3 | import js 4 | import logging 5 | from wwwpy.remote.designer.ui.button_tab import ButtonTab, Tab 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | @pytest.fixture 10 | def target(): 11 | target = ButtonTab() 12 | document.body.innerHTML = '' 13 | document.body.appendChild(target.element) 14 | try: 15 | yield target 16 | finally: 17 | document.body.innerHTML = '' 18 | 19 | 20 | def test_set_mixed_tabs(target): 21 | target.tabs = [Tab('Tab1'), 'Tab2'] 22 | 23 | html = target.root_element().innerHTML 24 | assert 'Tab1' in html 25 | assert 'Tab2' in html 26 | 27 | 28 | def test_click_event(target): 29 | # GIVEN 30 | target.tabs = ['Tab1', 'Tab2'] 31 | tab_click = [] 32 | 33 | def on_selected(tab): 34 | tab_click.append(tab.text) 35 | 36 | for t in target._tabs: 37 | t.on_selected = on_selected 38 | 39 | # WHEN 40 | target.tabs[0].root_element().click() 41 | assert tab_click == ['Tab1'] 42 | 43 | target.tabs[1].root_element().click() 44 | assert tab_click == ['Tab1', 'Tab2'] 45 | 46 | 47 | def test_click_event_on_tab(target): 48 | # GIVEN 49 | tab_click = [] 50 | target.tabs = [Tab('Tab1', on_selected=lambda tab: tab_click.append(tab.text))] 51 | 52 | # WHEN 53 | target.tabs[0].root_element().click() 54 | assert tab_click == ['Tab1'] 55 | 56 | 57 | def test_selection(target): 58 | # GIVEN 59 | target.tabs = ['Tab1', 'Tab2'] 60 | 61 | # WHEN 62 | tab1 = target.tabs[0] 63 | tab2 = target.tabs[1] 64 | 65 | logger.warning('follow with tab1.click()') 66 | tab1.root_element().click() 67 | 68 | assert tab1.selected 69 | assert not tab2.selected 70 | 71 | logger.warning('follow with tab2.click()') 72 | tab2.root_element().click() 73 | 74 | assert not tab1.selected, target.root_element().innerHTML 75 | assert tab2.selected, target.root_element().innerHTML 76 | -------------------------------------------------------------------------------- /tests/remote/designer/ui/palette_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | 5 | from pyodide.ffi import create_proxy 6 | 7 | from wwwpy.remote.designer.ui.palette import PaletteItemComponent 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | class TestPaletteItem: 12 | 13 | def test_selected_should_have_class_selected(self): 14 | item = PaletteItemComponent() 15 | item.selected = True 16 | assert item.element.classList.contains('selected') 17 | 18 | def test_selected_deselected_should_not_have_class_selected(self): 19 | item = PaletteItemComponent() 20 | item.selected = True 21 | item.selected = False 22 | assert not item.element.classList.contains('selected') 23 | -------------------------------------------------------------------------------- /tests/remote/designer/ui/tool_selection_indicator_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import js 4 | 5 | from wwwpy.common.designer.ui.rect_readonly_py import RectReadOnlyPy 6 | from wwwpy.remote._elementlib import element_xy_center 7 | from wwwpy.remote.designer.ui.floater_selection_indicator import SelectionIndicatorFloater 8 | 9 | 10 | def test_simple(): 11 | # GIVEN 12 | target = SelectionIndicatorFloater() 13 | rect = RectReadOnlyPy(22, 11, 40, 20) 14 | rect_center = rect.xy_center 15 | target.set_reference_geometry(rect) 16 | 17 | # WHEN 18 | js.document.body.append(target.element) 19 | element_center = element_xy_center(target.element) 20 | 21 | # THEN 22 | assert rect_center == element_center 23 | 24 | rect_area = rect.width * rect.height 25 | element_area = target.element.clientWidth * target.element.clientHeight 26 | assert rect_area * 0.9 < element_area < rect_area * 1.1 27 | -------------------------------------------------------------------------------- /tests/remote/element_lib_test.py: -------------------------------------------------------------------------------- 1 | import js 2 | 3 | from wwwpy.remote._elementlib import ensure_tag_instance 4 | 5 | 6 | class Test_ensure_tag_instance: 7 | def test_in_body(self): 8 | element1 = ensure_tag_instance('div', 'test_id', js.document.body) 9 | element2 = ensure_tag_instance('div', 'test_id', js.document.body) 10 | 11 | assert element1 == element2 12 | 13 | def test_in_head(self): 14 | element1 = ensure_tag_instance('style', 'test_id', js.document.head) 15 | element2 = ensure_tag_instance('style', 'test_id', js.document.head) 16 | 17 | assert element1 == element2 18 | -------------------------------------------------------------------------------- /tests/remote/rpc4tests_helper.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from typing import TYPE_CHECKING 5 | 6 | from wwwpy.server import rpc4tests 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | if TYPE_CHECKING: 11 | from playwright.sync_api import Page # noqa 12 | 13 | 14 | async def rpctst_exec(source: str | list[str], timeout_secs: int = 1) -> None: 15 | if isinstance(source, str): 16 | await _rpctst_exec(source, timeout_secs) 17 | elif isinstance(source, list): 18 | for cmd in source: 19 | await _rpctst_exec(cmd, timeout_secs) 20 | else: 21 | raise TypeError(f"source must be str or list[str], got {type(source)}") 22 | 23 | 24 | async def rpctst_exec_touch_event(events: dict | list[dict]) -> None: 25 | if isinstance(events, dict): 26 | events = [events] 27 | for event in events: 28 | cmd = f'pwb.cdp.send("Input.dispatchTouchEvent", {event})' 29 | logger.debug(f'rpctst_exec_touch_event: `{cmd}`') 30 | await _rpctst_exec(cmd) 31 | 32 | 33 | async def _rpctst_exec(source: str, timeout_secs: int = 1) -> None: 34 | logger.debug(f'rpctst_exec: `{source}` (timeout_secs={timeout_secs})') 35 | await rpc4tests.rpctst_exec(source, timeout_secs) 36 | # class PlaywrightPageCallable(Protocol): 37 | # def __call__(self, page: Page) -> None: ... 38 | 39 | # PlaywrightPageCallable = Callable[[Page], None] 40 | 41 | # def page_exec(block: Callable[[Page], None]): 42 | # pass 43 | 44 | # def main(): 45 | # page_exec(lambda page: page.mouse.click(0, 0)) 46 | # page_exec(lambda page: page.mouse.click(0, 0)) 47 | # 48 | # def test_func(page: Page): 49 | # page.mouse.click(0, 0) 50 | 51 | # page_exec(test_func) 52 | # 53 | # if __name__ == '__main__': 54 | # main() 55 | -------------------------------------------------------------------------------- /tests/remote/test_in_pyodide.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from js import document 3 | 4 | 5 | def test_first(): 6 | document.body.innerHTML = '' 7 | assert document.getElementById('tag1').value == 'foo1' 8 | 9 | 10 | @pytest.mark.asyncio 11 | async def test_second(): 12 | return 13 | assert False, 'This sadly succeeds, because async tests are running correctly in Pyodide' + \ 14 | 'See issue: https://github.com/pyodide/pyodide/issues/2221#issuecomment-2379624269' 15 | -------------------------------------------------------------------------------- /tests/remote/test_rpc.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import js 4 | from js import document 5 | 6 | import wwwpy.remote.component as wpc 7 | from wwwpy.server.rpc4tests import rpctst_exec 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | async def test_some(): 13 | class Comp1(wpc.Component): 14 | button1: js.HTMLButtonElement = wpc.element() 15 | 16 | def init_component(self): 17 | self.element.innerHTML = """""" 18 | self.events = [] 19 | 20 | def button1__click(self, event): 21 | self.events.append(event) 22 | self.button1.innerHTML += '.' 23 | 24 | comp1 = Comp1() 25 | document.body.innerHTML = '' 26 | document.body.append(comp1.element) 27 | for idx in range(1, 15): 28 | await rpctst_exec('page.mouse.click(100, 100)') 29 | assert len(comp1.events) == idx 30 | assert len(comp1.button1.innerHTML) == idx 31 | # await _assert_retry(lambda: len(comp1.events) == idx) 32 | # await _assert_retry(lambda: len(comp1.button1.innerHTML) == idx) 33 | 34 | 35 | # async def _assert_retry(condition): 36 | # source = inspect.getsource(condition).strip() 37 | # __tracebackhide__ = True 38 | # [await sleep(0.01) for _ in range(100) if not condition()] 39 | # assert condition(), f'wait_condition timeout `{source}`' 40 | -------------------------------------------------------------------------------- /tests/server/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wwwpy-labs/wwwpy/2179159a90bd712a8507f67d667dbd28db9f7f74/tests/server/__init__.py -------------------------------------------------------------------------------- /tests/server/cmd_line_args_test.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | import os 5 | 6 | from wwwpy.server.__main__ import parse_arguments, Arguments 7 | 8 | cwd_path = Path(os.getcwd()) 9 | 10 | 11 | def test_default_arguments(): 12 | args = parse_arguments([]) 13 | assert args == Arguments(directory=cwd_path, port=8000, dev=False) 14 | 15 | 16 | def test_dev_mode(): 17 | args = parse_arguments(['dev']) 18 | assert args == Arguments(directory=cwd_path, port=8000, dev=True) 19 | 20 | 21 | def test_dev_mode_with_custom_port(): 22 | args = parse_arguments(['dev', '--port', '9000']) 23 | assert args == Arguments(directory=cwd_path, port=9000, dev=True) 24 | 25 | 26 | def test_custom_port(): 27 | args = parse_arguments(['--port', '9000']) 28 | assert args == Arguments(directory=cwd_path, port=9000, dev=False) 29 | 30 | 31 | def test_custom_directory_and_port_with_dev(): 32 | args = parse_arguments(['--directory', '/tmp', '--port', '1234', 'dev']) 33 | assert args == Arguments(directory=Path('/tmp').absolute(), port=1234, dev=True) 34 | 35 | 36 | def test_invalid_port(): 37 | with pytest.raises(SystemExit): 38 | parse_arguments(['--port', 'invalid']) 39 | 40 | 41 | def test_help_option(): 42 | with pytest.raises(SystemExit): 43 | parse_arguments(['--help']) 44 | 45 | 46 | def test_unknown_option(): 47 | with pytest.raises(SystemExit): 48 | parse_arguments(['--unknown']) 49 | -------------------------------------------------------------------------------- /tests/server/convention_fixture.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from pathlib import Path 4 | 5 | from wwwpy.common.settingslib import Settings 6 | from wwwpy.server.configure import Project, setup 7 | from wwwpy.server.convention import default_config, add_project 8 | from wwwpy.webserver import Webserver 9 | 10 | 11 | def start_test_convention(directory: Path, webserver: Webserver = None, dev_mode=False) -> Project: 12 | config = default_config(directory, dev_mode) 13 | project = setup(config, Settings()) 14 | 15 | if webserver is not None: 16 | webserver.set_routes(*project.routes) 17 | 18 | return project 19 | -------------------------------------------------------------------------------- /tests/server/filesystem_sync/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wwwpy-labs/wwwpy/2179159a90bd712a8507f67d667dbd28db9f7f74/tests/server/filesystem_sync/__init__.py -------------------------------------------------------------------------------- /tests/server/filesystem_sync/activity_monitor.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from datetime import timedelta, datetime 4 | from typing import Callable 5 | 6 | 7 | class ActivityMonitor: 8 | 9 | def __init__(self, window: timedelta, time_func: Callable[[], datetime] = datetime.utcnow): 10 | super().__init__() 11 | self._last_touch: datetime | None = None 12 | self.window = window 13 | self._time_func = time_func 14 | 15 | def at_rest(self) -> bool: 16 | delta = self.rest_delta() 17 | if delta is None: 18 | # print('at_rest: True') 19 | return True 20 | ar = delta >= self.window 21 | # print(f'at_rest: {ar}') 22 | return ar 23 | 24 | def rest_delta(self) -> timedelta | None: 25 | lt = self._last_touch 26 | if lt is None: 27 | return None 28 | delta = self._time_func() - lt 29 | return delta 30 | 31 | def touch(self): 32 | self._last_touch = self._time_func() 33 | -------------------------------------------------------------------------------- /tests/server/filesystem_sync/activity_monitor_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from datetime import timedelta 4 | 5 | import pytest 6 | 7 | from tests.server.filesystem_sync.activity_monitor import ActivityMonitor 8 | from tests.server.filesystem_sync.time_mock import TimeMock 9 | 10 | _time_mock = TimeMock() 11 | 12 | 13 | @pytest.fixture 14 | def target(): 15 | _time_mock.reset() 16 | return ActivityMonitor(timedelta(milliseconds=20), time_func=_time_mock) 17 | 18 | 19 | def test_activity_monitor(target): 20 | assert target.at_rest() 21 | 22 | 23 | def test_touch__change_at_rest(target): 24 | target.touch() 25 | assert not target.at_rest() 26 | _time_mock.advance(timedelta(milliseconds=10)) 27 | assert not target.at_rest() 28 | 29 | 30 | def test_rest_delta(target): 31 | assert target.rest_delta() is None 32 | target.touch() 33 | _time_mock.advance(timedelta(milliseconds=10)) 34 | assert target.rest_delta() == timedelta(milliseconds=10) 35 | 36 | 37 | def test_touch_after_time_window(target): 38 | target.touch() 39 | _time_mock.advance(timedelta(milliseconds=21)) 40 | assert target.at_rest() 41 | -------------------------------------------------------------------------------- /tests/server/filesystem_sync/event_logger.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from pathlib import Path 3 | from typing import List 4 | 5 | from watchdog.events import FileSystemEvent 6 | 7 | 8 | @dataclass(frozen=True, eq=True, order=True) 9 | class Event: 10 | event_type: str = field(compare=True) 11 | is_directory: bool = field(compare=True) 12 | src_path: str = field(compare=True) 13 | dest_path: str = field(compare=True) 14 | 15 | 16 | _events = set() 17 | 18 | 19 | def _to_event(e: FileSystemEvent) -> Event: 20 | event = Event(e.event_type, e.is_directory, e.src_path, e.dest_path) 21 | return event 22 | 23 | 24 | def _simplify(e: Event) -> Event: 25 | src_path = '' if e.src_path is '' else 'src' 26 | dest_path = '' if e.dest_path is '' else 'dest' 27 | return Event(e.event_type, e.is_directory, src_path, dest_path) 28 | 29 | 30 | def log_filesystem_events(events: List[FileSystemEvent]): 31 | try: 32 | for e in events: 33 | event = _simplify(_to_event(e)) 34 | _events.add(event) 35 | sorted_events = sorted(_events) 36 | Path('/tmp/events.txt').write_text('\n'.join([str(e) for e in sorted_events])) 37 | except Exception as ex: 38 | pass 39 | -------------------------------------------------------------------------------- /tests/server/filesystem_sync/event_rebase_test.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from tests.server.filesystem_sync.filesystem_fixture import FilesystemFixture, fixture 4 | from wwwpy.common.filesystem import sync 5 | from wwwpy.common.filesystem.sync import event_rebase 6 | 7 | 8 | def test_root_file_should_not_fire_remote_notification(fixture: FilesystemFixture): 9 | with fixture.source_mutator as m: 10 | m.touch('foo.txt') 11 | 12 | actual = event_rebase.filter_by_directory(fixture.source_mutator.events, {'some'}) 13 | 14 | assert actual == [] 15 | 16 | 17 | def test_pertinent_change_should_be_included(fixture: FilesystemFixture): 18 | with fixture.source_mutator as m: 19 | m.write('foo.txt', 'content') 20 | 21 | actual = event_rebase.filter_by_directory(fixture.source_mutator.events, {''}) 22 | 23 | assert sync.Event('modified', False, 'foo.txt') in actual 24 | 25 | 26 | def test_pertinent_multiple_change_should_be_included(fixture: FilesystemFixture): 27 | with fixture.source_mutator as m: 28 | m.write('foo.txt', 'content') 29 | m.mkdir('p1') 30 | m.mkdir('p2') 31 | m.write('p1/p1.txt', 'content') 32 | m.write('p2/p2.txt', 'content') 33 | 34 | actual = event_rebase.filter_by_directory(fixture.source_mutator.events, {'p1', 'p2'}) 35 | 36 | assert sync.Event('modified', False, 'p1/p1.txt') in actual 37 | assert sync.Event('modified', False, 'p2/p2.txt') in actual 38 | assert sync.Event('modified', False, 'foo.txt') not in actual 39 | -------------------------------------------------------------------------------- /tests/server/filesystem_sync/time_mock.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | 3 | 4 | class TimeMock: 5 | def __init__(self): 6 | self.now = datetime(2023, 1, 1, 0, 0, 0) 7 | 8 | def reset(self): 9 | self.now = datetime(2023, 1, 1, 0, 0, 0) 10 | 11 | def __call__(self): 12 | return self.now 13 | 14 | def advance(self, delta: timedelta): 15 | self.now += delta 16 | -------------------------------------------------------------------------------- /tests/server/filesystem_sync/wakeup_recorder.py: -------------------------------------------------------------------------------- 1 | class WakeupRecorder: 2 | def __init__(self): 3 | self.events = 0 4 | 5 | def call(self, debouncer): 6 | self.events += 1 7 | -------------------------------------------------------------------------------- /tests/server/filesystem_sync/watchdog-events.txt: -------------------------------------------------------------------------------- 1 | Event(event_type='closed', is_directory=False, src_path='src', dest_path='') 2 | Event(event_type='created', is_directory=False, src_path='src', dest_path='') 3 | Event(event_type='created', is_directory=True, src_path='src', dest_path='') 4 | Event(event_type='deleted', is_directory=False, src_path='src', dest_path='') 5 | Event(event_type='deleted', is_directory=True, src_path='src', dest_path='') 6 | Event(event_type='modified', is_directory=False, src_path='src', dest_path='') 7 | Event(event_type='modified', is_directory=True, src_path='src', dest_path='') 8 | Event(event_type='moved', is_directory=False, src_path='src', dest_path='dest') 9 | Event(event_type='moved', is_directory=True, src_path='src', dest_path='dest') 10 | 11 | -------------------------------------------------------------------------------- /tests/server/filesystem_sync/watchdog_debouncer_test.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | from pathlib import Path 3 | from time import sleep 4 | from typing import List 5 | 6 | from tests.timeouts import timeout_multiplier 7 | from wwwpy.common.filesystem.sync import Event 8 | from wwwpy.server.filesystem_sync.watchdog_debouncer import WatchdogDebouncer 9 | 10 | 11 | def test_basic_event_expectation(tmp_path: Path): 12 | """Testing on multiplatform watchdog events and behavior is very difficult. Let's keep it simple""" 13 | # GIVEN 14 | events = [] 15 | 16 | def callback(evs: List[Event]): 17 | events.extend(evs) 18 | 19 | target = WatchdogDebouncer(tmp_path, timedelta(milliseconds=200 * timeout_multiplier()), callback) 20 | target.start() 21 | file_path = tmp_path / 'test_file.txt' 22 | file_path.touch() 23 | 24 | # WHEN 25 | 26 | # wait debouncing 27 | _wait_condition(lambda: target.debouncer.is_debouncing) 28 | _wait_condition(lambda: not target.debouncer.is_debouncing) 29 | 30 | # THEN 31 | 32 | assert events != [], f'events={events}' 33 | 34 | 35 | def _wait_condition(condition): 36 | __tracebackhide__ = True 37 | [sleep(0.1) for _ in range(5 * timeout_multiplier()) if not condition()] 38 | assert condition(), 'wait_condition timeout' 39 | -------------------------------------------------------------------------------- /tests/server/pytestlib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wwwpy-labs/wwwpy/2179159a90bd712a8507f67d667dbd28db9f7f74/tests/server/pytestlib/__init__.py -------------------------------------------------------------------------------- /tests/server/pytestlib/xvirt_impl_test.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import sys 4 | 5 | import pytest 6 | 7 | # todo, incomplete, slow and difficult to setup 8 | def disabled_test_user_folders(pytester: pytest.Pytester): 9 | """Create remote package and verify that it is loaded by the browser""" 10 | src = os.environ['OLDPWD'] + '/.cache/ms-playwright' 11 | dst = os.path.expanduser('~/.cache/ms-playwright') 12 | shutil.copytree(src, dst) 13 | tr = pytester.mkpydir('tests') 14 | sys.path.insert(0, str(pytester.path)) 15 | print(f'tr: {tr}') 16 | tr = pytester.mkpydir('tests/remote') 17 | (tr / 'some_test.py').write_text('def test_1(): pass') 18 | 19 | result = pytester.runpytest('--headful') 20 | 21 | # THEN 22 | result.assert_outcomes(passed=1, failed=0) 23 | -------------------------------------------------------------------------------- /tests/server/remote_ui/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wwwpy-labs/wwwpy/2179159a90bd712a8507f67d667dbd28db9f7f74/tests/server/remote_ui/__init__.py -------------------------------------------------------------------------------- /tests/server/remote_ui/searchable_combobox_test.py: -------------------------------------------------------------------------------- 1 | from playwright.sync_api import expect 2 | 3 | from tests import for_all_webservers 4 | from tests.server.page_fixture import PageFixture, fixture 5 | 6 | 7 | @for_all_webservers() 8 | def test_combo(fixture: PageFixture): 9 | # language=python 10 | fixture.start_remote(""" 11 | from wwwpy.remote.designer.ui.searchable_combobox2 import SearchableComboBox 12 | import js 13 | from js import document 14 | target = SearchableComboBox() 15 | document.body.innerHTML = '' 16 | document.body.append(target.element) 17 | target.placeholder = 'target-placeholder1' 18 | """) 19 | page = fixture.page 20 | 21 | # expect(page.get_by_text("foo123")).to_be_attached() 22 | # await expect(page.locator('input#my-input')).toHaveValue('1'); 23 | # expect(page.locator('input')).to_have_value('foo123') 24 | locator = page.get_by_placeholder("target-placeholder1") 25 | expect(locator).to_have_value("") 26 | fixture.evaluate("import remote") 27 | fixture.evaluate("remote.target.text_value = 'foo123'") 28 | expect(locator).to_have_value("foo123") 29 | return 30 | locator = page.locator('input[value=""]') 31 | expect(locator).to_have_count(1) 32 | return 33 | page.locator("#root").locator("") 34 | assert False, page.locator("#root").inner_html() 35 | # expect(page.locator('body')).to_have_js_property() 36 | # fixture.evaluate("import remote; remote.target.text_value = 'foo123'") 37 | # expect(page.locator('body')).to('ready') 38 | # assert False, page.locator('head').inner_html() 39 | # fixture.assert_evaluate("False, 'see if it shows'") 40 | -------------------------------------------------------------------------------- /tests/server/rpc/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wwwpy-labs/wwwpy/2179159a90bd712a8507f67d667dbd28db9f7f74/tests/server/rpc/__init__.py -------------------------------------------------------------------------------- /tests/server/rpc/test_invoker.py: -------------------------------------------------------------------------------- 1 | from tests.common.rpc.test_rpc import support2_module_name 2 | from wwwpy.common.rpc.func_registry import from_package_name 3 | from wwwpy.common.rpc.invoker import Invoker 4 | from wwwpy.unasync import unasync 5 | 6 | 7 | @unasync 8 | async def test_module_invoke_async(): 9 | module = from_package_name(support2_module_name) 10 | target = Invoker(module) 11 | 12 | # THEN 13 | actual = await target['support2_concat'].func('hello', ' world') 14 | assert actual == 'hello world' 15 | -------------------------------------------------------------------------------- /tests/server/rpc/test_rpc_sync.py: -------------------------------------------------------------------------------- 1 | from playwright.sync_api import expect 2 | 3 | from tests import for_all_webservers 4 | from tests.server.page_fixture import fixture, PageFixture 5 | 6 | 7 | @for_all_webservers() 8 | def test_sync_rpc_func(fixture: PageFixture): 9 | # GIVEN 10 | fixture.dev_mode = False 11 | fixture.write_module('server/rpc.py', "def func1() -> str: return 'ready'") 12 | 13 | # WHEN 14 | fixture.start_remote( # language=python 15 | """ 16 | async def main(): 17 | import js 18 | from server import rpc 19 | js.document.body.innerText = 'first=' + rpc.func1() 20 | """) 21 | # THEN 22 | expect(fixture.page.locator('body')).to_have_text('first=ready', use_inner_text=True) 23 | -------------------------------------------------------------------------------- /tests/server/startup/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wwwpy-labs/wwwpy/2179159a90bd712a8507f67d667dbd28db9f7f74/tests/server/startup/__init__.py -------------------------------------------------------------------------------- /tests/server/startup/test_dev_mode.py: -------------------------------------------------------------------------------- 1 | from wwwpy.server.tcp_port import find_port 2 | 3 | 4 | def test_start_dev_mode__empty_folder(tmp_path): 5 | from wwwpy.server.convention import start_default 6 | start_default(tmp_path, find_port(), dev_mode=True) 7 | assert True 8 | -------------------------------------------------------------------------------- /tests/server/tcp_port_test.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import socket 3 | 4 | from wwwpy.server import tcp_port 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | 9 | def test_is_port_busy(): 10 | port = tcp_port.find_port() 11 | assert not tcp_port.is_port_busy(port) 12 | 13 | 14 | def test_port_is_busy(): 15 | port = tcp_port.find_port() 16 | 17 | with socket.create_server(('0.0.0.0', port)) as s: 18 | assert tcp_port.is_port_busy(port) 19 | 20 | assert not tcp_port.is_port_busy(port) 21 | 22 | -------------------------------------------------------------------------------- /tests/test_unsync.py: -------------------------------------------------------------------------------- 1 | from wwwpy.unasync import unasync 2 | 3 | 4 | def test_unsync(): 5 | @unasync 6 | async def fun(): 7 | return 'ok' 8 | 9 | assert fun() == 'ok' 10 | 11 | 12 | def test_unsync_with_args(): 13 | @unasync 14 | async def fun(arg1): 15 | return arg1 16 | 17 | assert fun('foo') == 'foo' 18 | -------------------------------------------------------------------------------- /tests/timeouts.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | 4 | logger = logging.getLogger(__name__) 5 | 6 | 7 | def is_github(): 8 | getenv = os.getenv('GITHUB_ACTIONS') 9 | return getenv == 'true' 10 | 11 | 12 | def timeout_multiplier(): 13 | multiplier = 15 if is_github() else 1 14 | return multiplier 15 | 16 | 17 | logger.debug(f'timeout_multiplier={timeout_multiplier()}') 18 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | env_list = py310, py311, py312, py313 3 | isolated_build = True 4 | 5 | [testenv] 6 | extras = test 7 | passenv = 8 | PLAYWRIGHT_PATCH_TIMEOUT_MILLIS 9 | FORCE_COLOR 10 | GITHUB_ACTIONS 11 | commands = 12 | python -m playwright install chromium 13 | python -m playwright install-deps chromium 14 | python -m pytest -rP {posargs:--verbose --showlocals --log-level=DEBUG} tests/ 15 | 16 | [testenv:py310] 17 | basepython = python3.10 18 | 19 | [testenv:py311] 20 | basepython = python3.11 21 | 22 | [testenv:py312] 23 | basepython = python3.12 24 | 25 | [testenv:py313] 26 | basepython = python3.13 27 | --------------------------------------------------------------------------------