From 69598ef47d1c55e0b089c95e6b28b6eb4b45db63 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 21 Feb 2024 21:01:05 -0800 Subject: [PATCH 01/62] Bump workflows --- .github/workflows/codeql.yml | 105 ++++++++++----------- .github/workflows/publish-develop-docs.yml | 4 +- .github/workflows/publish-py.yaml | 4 +- .github/workflows/publish-release-docs.yml | 4 +- .github/workflows/test-docs.yml | 4 +- .github/workflows/test-src.yaml | 8 +- 6 files changed, 64 insertions(+), 65 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 0f26793..63a412e 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -12,67 +12,66 @@ name: "CodeQL" on: - push: - branches: [ "main" ] - pull_request: - # The branches below must be a subset of the branches above - branches: [ "main" ] - schedule: - # Runs at 22:21 on Monday. - - cron: '21 22 * * 1' + push: + branches: ["main"] + pull_request: + # The branches below must be a subset of the branches above + branches: ["main"] + schedule: + # Runs at 22:21 on Monday. + - cron: "21 22 * * 1" jobs: - analyze: - name: Analyze - runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} - timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }} - permissions: - actions: read - contents: read - security-events: write + analyze: + name: Analyze + runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} + timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }} + permissions: + actions: read + contents: read + security-events: write - strategy: - fail-fast: false - matrix: - language: [ 'javascript', 'python' ] - # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby', 'swift' ] - # Use only 'java' to analyze code written in Java, Kotlin or both - # Use only 'javascript' to analyze code written in JavaScript, TypeScript or both - # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support + strategy: + fail-fast: false + matrix: + language: ["javascript", "python"] + # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby', 'swift' ] + # Use only 'java' to analyze code written in Java, Kotlin or both + # Use only 'javascript' to analyze code written in JavaScript, TypeScript or both + # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support - steps: - - name: Checkout repository - uses: actions/checkout@v3 + steps: + - name: Checkout repository + uses: actions/checkout@v4 - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v2 - with: - languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. - # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs - # queries: security-extended,security-and-quality + # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v3 - # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@v2 + # â„šī¸ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun - # â„šī¸ Command-line programs to run using the OS shell. - # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + # If the Autobuild fails above, remove it and uncomment the following three lines. + # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. - # If the Autobuild fails above, remove it and uncomment the following three lines. - # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. + # - run: | + # echo "Run, Build Application using script" + # ./location_of_script_within_repo/buildscript.sh - # - run: | - # echo "Run, Build Application using script" - # ./location_of_script_within_repo/buildscript.sh - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 - with: - category: "/language:${{matrix.language}}" + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{matrix.language}}" diff --git a/.github/workflows/publish-develop-docs.yml b/.github/workflows/publish-develop-docs.yml index 6b1d4de..474b82c 100644 --- a/.github/workflows/publish-develop-docs.yml +++ b/.github/workflows/publish-develop-docs.yml @@ -7,10 +7,10 @@ jobs: deploy: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 0 - - uses: actions/setup-python@v4 + - uses: actions/checkout@v5 with: python-version: 3.x - run: pip install -r requirements/build-docs.txt diff --git a/.github/workflows/publish-py.yaml b/.github/workflows/publish-py.yaml index 34ae5fa..5f77870 100644 --- a/.github/workflows/publish-py.yaml +++ b/.github/workflows/publish-py.yaml @@ -11,9 +11,9 @@ jobs: publish-package: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/checkout@v5 with: python-version: "3.x" - name: Install dependencies diff --git a/.github/workflows/publish-release-docs.yml b/.github/workflows/publish-release-docs.yml index 6fc3233..d183fc7 100644 --- a/.github/workflows/publish-release-docs.yml +++ b/.github/workflows/publish-release-docs.yml @@ -8,10 +8,10 @@ jobs: deploy: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 0 - - uses: actions/setup-python@v4 + - uses: actions/checkout@v5 with: python-version: 3.x - run: pip install -r requirements/build-docs.txt diff --git a/.github/workflows/test-docs.yml b/.github/workflows/test-docs.yml index d5f5052..dbbdec6 100644 --- a/.github/workflows/test-docs.yml +++ b/.github/workflows/test-docs.yml @@ -14,10 +14,10 @@ jobs: docs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 0 - - uses: actions/setup-python@v4 + - uses: actions/setup-python@v5 with: python-version: 3.x - name: Check docs build diff --git a/.github/workflows/test-src.yaml b/.github/workflows/test-src.yaml index b5ae7d0..ba033be 100644 --- a/.github/workflows/test-src.yaml +++ b/.github/workflows/test-src.yaml @@ -17,9 +17,9 @@ jobs: matrix: python-version: ["3.9", "3.10", "3.11"] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Use Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/checkout@v5 with: python-version: ${{ matrix.python-version }} - name: Install Python Dependencies @@ -29,9 +29,9 @@ jobs: coverage: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Use Latest Python - uses: actions/setup-python@v2 + uses: actions/checkout@v5 with: python-version: "3.10" - name: Install Python Dependencies From c86f37bf3ae18fe6f1cb4b30f436320194503d9a Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 24 Jun 2024 18:49:15 -0700 Subject: [PATCH 02/62] use_query -> use_search_params --- docs/examples/python/use-query.py | 5 ++--- docs/src/learn/hooks.md | 2 +- docs/src/learn/routers-routes-and-links.md | 2 +- src/reactpy_router/__init__.py | 4 ++-- src/reactpy_router/core.py | 12 ++++++++++-- tests/test_core.py | 7 ++++--- 6 files changed, 20 insertions(+), 12 deletions(-) diff --git a/docs/examples/python/use-query.py b/docs/examples/python/use-query.py index a8678cc..0d04e55 100644 --- a/docs/examples/python/use-query.py +++ b/docs/examples/python/use-query.py @@ -1,11 +1,10 @@ from reactpy import component, html - -from reactpy_router import link, route, simple, use_query +from reactpy_router import link, route, simple, use_search_params @component def search(): - query = use_query() + query = use_search_params() return html.h1(f"Search Results for {query['q'][0]} 🔍") diff --git a/docs/src/learn/hooks.md b/docs/src/learn/hooks.md index 3479ffc..208b7e1 100644 --- a/docs/src/learn/hooks.md +++ b/docs/src/learn/hooks.md @@ -8,7 +8,7 @@ Several pre-fabricated hooks are provided to help integrate with routing feature ## Use Query -The [`use_query`][src.reactpy_router.use_query] hook can be used to access query parameters from the current location. It returns a dictionary of query parameters, where each value is a list of strings. +The [`use_search_params`][src.reactpy_router.use_search_params] hook can be used to access query parameters from the current location. It returns a dictionary of query parameters, where each value is a list of strings. === "components.py" diff --git a/docs/src/learn/routers-routes-and-links.md b/docs/src/learn/routers-routes-and-links.md index af62578..942bb06 100644 --- a/docs/src/learn/routers-routes-and-links.md +++ b/docs/src/learn/routers-routes-and-links.md @@ -54,7 +54,7 @@ Any route parameters collected from the current location then be accessed using !!! warning "Pitfall" - While it is possible to use route parameters to capture values from query strings (such as `#!python /my/route/?foo={bar}`), this is not recommended. Instead, you should use the [`use_query`][src.reactpy_router.use_query] hook to access query string values. + While it is possible to use route parameters to capture values from query strings (such as `#!python /my/route/?foo={bar}`), this is not recommended. Instead, you should use the [`use_search_params`][src.reactpy_router.use_search_params] hook to access query string values. ## Route Links diff --git a/src/reactpy_router/__init__.py b/src/reactpy_router/__init__.py index cb2fcbc..f81babf 100644 --- a/src/reactpy_router/__init__.py +++ b/src/reactpy_router/__init__.py @@ -2,7 +2,7 @@ __version__ = "0.1.1" from . import simple -from .core import create_router, link, route, router_component, use_params, use_query +from .core import create_router, link, route, router_component, use_params, use_search_params from .types import Route, RouteCompiler, RouteResolver __all__ = ( @@ -16,5 +16,5 @@ "RouteResolver", "simple", "use_params", - "use_query", + "use_search_params", ) diff --git a/src/reactpy_router/core.py b/src/reactpy_router/core.py index 490a78c..498ffe4 100644 --- a/src/reactpy_router/core.py +++ b/src/reactpy_router/core.py @@ -88,14 +88,22 @@ def use_params() -> dict[str, Any]: return _use_route_state().params -def use_query( +def use_search_params( keep_blank_values: bool = False, strict_parsing: bool = False, errors: str = "replace", max_num_fields: int | None = None, separator: str = "&", ) -> dict[str, list[str]]: - """See :func:`urllib.parse.parse_qs` for parameter info.""" + """ + The `use_search_params` hook is used to read and modify the query string in the URL \ + for the current location. Like React's own `use_state` hook, `use_search_params returns \ + an array of two values: the current location's search params and a function that may \ + be used to update them. + + See `urllib.parse.parse_qs` for info on this hook's parameters.""" + + # FIXME: This needs to return a tuple of the search params and a function to update them return parse_qs( use_location().search[1:], keep_blank_values=keep_blank_values, diff --git a/tests/test_core.py b/tests/test_core.py index 77577b3..8172cee 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -2,7 +2,7 @@ from reactpy import Ref, component, html, use_location from reactpy.testing import DisplayFixture -from reactpy_router import link, route, simple, use_params, use_query +from reactpy_router import link, route, simple, use_params, use_search_params async def test_simple_router(display: DisplayFixture): @@ -40,7 +40,8 @@ def sample(): root_element = await display.root_element() except AttributeError: root_element = await display.page.wait_for_selector( - f"#display-{display._next_view_id}", state="attached" # type: ignore + f"#display-{display._next_view_id}", + state="attached", # type: ignore ) assert not await root_element.inner_html() @@ -140,7 +141,7 @@ async def test_use_query(display: DisplayFixture): @component def check_query(): - assert use_query() == expected_query + assert use_search_params() == expected_query return html.h1({"id": "success"}, "success") @component From ec1b6336c6741556f7e09983120cfc2e2da4fa68 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 24 Jun 2024 18:49:43 -0700 Subject: [PATCH 03/62] use_params docstring --- src/reactpy_router/core.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/reactpy_router/core.py b/src/reactpy_router/core.py index 498ffe4..cef0e70 100644 --- a/src/reactpy_router/core.py +++ b/src/reactpy_router/core.py @@ -84,7 +84,14 @@ def link(*children: VdomChild, to: str, **attributes: Any) -> VdomDict: def use_params() -> dict[str, Any]: - """Get parameters from the currently matching route pattern""" + """The `use_params` hook returns an object of key/value pairs of the dynamic params \ + from the current URL that were matched by the `Route`. Child routes inherit all params \ + from their parent routes. + + For example, if you have a `URL_PARAM` defined in the route `/example//`, + this hook will return the URL_PARAM value that was matched.""" + + # TODO: Check if this returns all parent params return _use_route_state().params @@ -121,9 +128,7 @@ def _iter_routes(routes: Sequence[R]) -> Iterator[R]: yield parent -def _match_route( - compiled_routes: Sequence[RouteResolver], location: Location -) -> tuple[Any, dict[str, Any]] | None: +def _match_route(compiled_routes: Sequence[RouteResolver], location: Location) -> tuple[Any, dict[str, Any]] | None: for resolver in compiled_routes: match = resolver.resolve(location.pathname) if match is not None: From 332ca75fad9d297edafe5468fdc4dc70fa754061 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 24 Jun 2024 18:50:07 -0700 Subject: [PATCH 04/62] better error for route state --- src/reactpy_router/core.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/reactpy_router/core.py b/src/reactpy_router/core.py index cef0e70..8142a4b 100644 --- a/src/reactpy_router/core.py +++ b/src/reactpy_router/core.py @@ -150,7 +150,12 @@ class _RouteState: def _use_route_state() -> _RouteState: route_state = use_context(_route_state_context) - assert route_state is not None + if route_state is None: + raise RuntimeError( + "ReactPy-Router was unable to find a route context. Are you " + "sure this hook/component is being called within a router?" + ) + return route_state From 189b484d24d532c5bb8f4f69c3036e036122b288 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 25 Jun 2024 00:54:36 -0700 Subject: [PATCH 05/62] simple.router -> browser_router --- .../examples/python/basic-routing-more-routes.py | 5 ++--- docs/examples/python/basic-routing.py | 5 ++--- docs/examples/python/nested-routes.py | 9 +++------ docs/examples/python/route-links.py | 5 ++--- docs/examples/python/route-parameters.py | 9 +++------ docs/examples/python/use-params.py | 5 ++--- docs/examples/python/use-query.py | 4 ++-- docs/src/learn/routers-routes-and-links.md | 8 ++++---- docs/src/learn/simple-application.md | 2 +- docs/src/reference/router.md | 2 +- src/reactpy_router/__init__.py | 6 ++++-- src/reactpy_router/{simple.py => routers.py} | 8 +++++--- tests/test_core.py | 16 ++++++++-------- tests/test_simple.py | 3 +-- 14 files changed, 40 insertions(+), 47 deletions(-) rename src/reactpy_router/{simple.py => routers.py} (90%) diff --git a/docs/examples/python/basic-routing-more-routes.py b/docs/examples/python/basic-routing-more-routes.py index 8ddbebb..c363ebc 100644 --- a/docs/examples/python/basic-routing-more-routes.py +++ b/docs/examples/python/basic-routing-more-routes.py @@ -1,11 +1,10 @@ from reactpy import component, html, run - -from reactpy_router import route, simple +from reactpy_router import browser_router, route @component def root(): - return simple.router( + return browser_router( route("/", html.h1("Home Page 🏠")), route("/messages", html.h1("Messages đŸ’Ŧ")), route("*", html.h1("Missing Link 🔗‍đŸ’Ĩ")), diff --git a/docs/examples/python/basic-routing.py b/docs/examples/python/basic-routing.py index 57b7a37..a638fe7 100644 --- a/docs/examples/python/basic-routing.py +++ b/docs/examples/python/basic-routing.py @@ -1,11 +1,10 @@ from reactpy import component, html, run - -from reactpy_router import route, simple +from reactpy_router import browser_router, route @component def root(): - return simple.router( + return browser_router( route("/", html.h1("Home Page 🏠")), route("*", html.h1("Missing Link 🔗‍đŸ’Ĩ")), ) diff --git a/docs/examples/python/nested-routes.py b/docs/examples/python/nested-routes.py index f03a692..c6bfcb7 100644 --- a/docs/examples/python/nested-routes.py +++ b/docs/examples/python/nested-routes.py @@ -1,7 +1,7 @@ from typing import TypedDict from reactpy import component, html, run -from reactpy_router import link, route, simple +from reactpy_router import browser_router, link, route message_data: list["MessageDataType"] = [ {"id": 1, "with": ["Alice"], "from": None, "message": "Hello!"}, @@ -17,7 +17,7 @@ @component def root(): - return simple.router( + return browser_router( route("/", home()), route( "/messages", @@ -40,10 +40,7 @@ def home(): @component def all_messages(): - last_messages = { - ", ".join(msg["with"]): msg - for msg in sorted(message_data, key=lambda m: m["id"]) - } + last_messages = {", ".join(msg["with"]): msg for msg in sorted(message_data, key=lambda m: m["id"])} return html.div( html.h1("All Messages đŸ’Ŧ"), html.ul( diff --git a/docs/examples/python/route-links.py b/docs/examples/python/route-links.py index f2be305..db5a850 100644 --- a/docs/examples/python/route-links.py +++ b/docs/examples/python/route-links.py @@ -1,11 +1,10 @@ from reactpy import component, html, run - -from reactpy_router import link, route, simple +from reactpy_router import browser_router, link, route @component def root(): - return simple.router( + return browser_router( route("/", home()), route("/messages", html.h1("Messages đŸ’Ŧ")), route("*", html.h1("Missing Link 🔗‍đŸ’Ĩ")), diff --git a/docs/examples/python/route-parameters.py b/docs/examples/python/route-parameters.py index 4fd30e2..3fff752 100644 --- a/docs/examples/python/route-parameters.py +++ b/docs/examples/python/route-parameters.py @@ -1,7 +1,7 @@ from typing import TypedDict from reactpy import component, html, run -from reactpy_router import link, route, simple +from reactpy_router import browser_router, link, route from reactpy_router.core import use_params message_data: list["MessageDataType"] = [ @@ -18,7 +18,7 @@ @component def root(): - return simple.router( + return browser_router( route("/", home()), route( "/messages", @@ -39,10 +39,7 @@ def home(): @component def all_messages(): - last_messages = { - ", ".join(msg["with"]): msg - for msg in sorted(message_data, key=lambda m: m["id"]) - } + last_messages = {", ".join(msg["with"]): msg for msg in sorted(message_data, key=lambda m: m["id"])} return html.div( html.h1("All Messages đŸ’Ŧ"), html.ul( diff --git a/docs/examples/python/use-params.py b/docs/examples/python/use-params.py index 7b1193a..c9c35a0 100644 --- a/docs/examples/python/use-params.py +++ b/docs/examples/python/use-params.py @@ -1,6 +1,5 @@ from reactpy import component, html - -from reactpy_router import link, route, simple, use_params +from reactpy_router import browser_router, link, route, use_params @component @@ -11,7 +10,7 @@ def user(): @component def root(): - return simple.router( + return browser_router( route( "/", html.div( diff --git a/docs/examples/python/use-query.py b/docs/examples/python/use-query.py index 0d04e55..8ebbc14 100644 --- a/docs/examples/python/use-query.py +++ b/docs/examples/python/use-query.py @@ -1,5 +1,5 @@ from reactpy import component, html -from reactpy_router import link, route, simple, use_search_params +from reactpy_router import browser_router, link, route, use_search_params @component @@ -10,7 +10,7 @@ def search(): @component def root(): - return simple.router( + return browser_router( route( "/", html.div( diff --git a/docs/src/learn/routers-routes-and-links.md b/docs/src/learn/routers-routes-and-links.md index 942bb06..9e6197d 100644 --- a/docs/src/learn/routers-routes-and-links.md +++ b/docs/src/learn/routers-routes-and-links.md @@ -4,14 +4,14 @@ We include built-in components that automatically handle routing, which enable S ## Routers and Routes -The [`simple.router`][src.reactpy_router.simple.router] component is one possible implementation of a [Router][src.reactpy_router.types.Router]. Routers takes a series of [route][src.reactpy_router.route] objects as positional arguments and render whatever element matches the current location. +The [`browser_router`][src.reactpy_router.browser_router] component is one possible implementation of a [Router][src.reactpy_router.types.Router]. Routers takes a series of [route][src.reactpy_router.route] objects as positional arguments and render whatever element matches the current location. !!! abstract "Note" The current location is determined based on the browser's current URL and can be found by checking the [`use_location`][reactpy.backend.hooks.use_location] hook. -Here's a basic example showing how to use `#!python simple.router` with two routes. +Here's a basic example showing how to use `#!python browser_router` with two routes. === "components.py" @@ -21,9 +21,9 @@ Here's a basic example showing how to use `#!python simple.router` with two rout Here we'll note some special syntax in the route path for the second route. The `#!python "*"` is a wildcard that will match any path. This is useful for creating a "404" page that will be shown when no other route matches. -### Simple Router +### Browser Router -The syntax for declaring routes with the [simple.router][src.reactpy_router.simple.router] is very similar to the syntax used by [`starlette`](https://www.starlette.io/routing/) (a popular Python web framework). As such route parameters are declared using the following syntax: +The syntax for declaring routes with the [`browser_router`][src.reactpy_router.browser_router] is very similar to the syntax used by [`starlette`](https://www.starlette.io/routing/) (a popular Python web framework). As such route parameters are declared using the following syntax: ```python linenums="0" /my/route/{param} diff --git a/docs/src/learn/simple-application.md b/docs/src/learn/simple-application.md index 8f2a5b5..0dde0f2 100644 --- a/docs/src/learn/simple-application.md +++ b/docs/src/learn/simple-application.md @@ -1,6 +1,6 @@

-Here you'll learn the various features of `reactpy-router` and how to use them. These examples will utilize the [`reactpy_router.simple.router`][src.reactpy_router.simple.router]. +Here you'll learn the various features of `reactpy-router` and how to use them. These examples will utilize the [`reactpy_router.browser_router`][src.reactpy_router.browser_router].

diff --git a/docs/src/reference/router.md b/docs/src/reference/router.md index 2fcea59..148b0c5 100644 --- a/docs/src/reference/router.md +++ b/docs/src/reference/router.md @@ -1 +1 @@ -::: src.reactpy_router.simple +::: src.reactpy_router.browser_router diff --git a/src/reactpy_router/__init__.py b/src/reactpy_router/__init__.py index f81babf..6eb873d 100644 --- a/src/reactpy_router/__init__.py +++ b/src/reactpy_router/__init__.py @@ -1,8 +1,9 @@ # the version is statically loaded by setup.py __version__ = "0.1.1" -from . import simple +from . import routers from .core import create_router, link, route, router_component, use_params, use_search_params +from .routers import browser_router from .types import Route, RouteCompiler, RouteResolver __all__ = ( @@ -11,10 +12,11 @@ "route", "route", "Route", + "routers", "RouteCompiler", "router_component", "RouteResolver", - "simple", + "browser_router", "use_params", "use_search_params", ) diff --git a/src/reactpy_router/simple.py b/src/reactpy_router/routers.py similarity index 90% rename from src/reactpy_router/simple.py rename to src/reactpy_router/routers.py index 256f78d..fedcd1a 100644 --- a/src/reactpy_router/simple.py +++ b/src/reactpy_router/routers.py @@ -11,7 +11,7 @@ from reactpy_router.core import create_router from reactpy_router.types import Route -__all__ = ["router"] +__all__ = ["browser_router"] ConversionFunc: TypeAlias = "Callable[[str], Any]" ConverterMapping: TypeAlias = "dict[str, ConversionFunc]" @@ -94,5 +94,7 @@ class ConversionInfo(TypedDict): """The supported conversion types""" -router = create_router(SimpleResolver) -"""The simple router""" +browser_router = create_router(SimpleResolver) +"""This is the recommended router for all ReactPy Router web projects. +It uses the DOM History API to update the URL and manage the history stack.""" +# TODO: Check if this is true. If not, make it true. diff --git a/tests/test_core.py b/tests/test_core.py index 8172cee..8e50212 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -2,7 +2,7 @@ from reactpy import Ref, component, html, use_location from reactpy.testing import DisplayFixture -from reactpy_router import link, route, simple, use_params, use_search_params +from reactpy_router import link, route, routers, use_params, use_search_params async def test_simple_router(display: DisplayFixture): @@ -18,7 +18,7 @@ def check_location(): @component def sample(): - return simple.router( + return routers.browser_router( make_location_check("/a"), make_location_check("/b"), make_location_check("/c"), @@ -50,7 +50,7 @@ def sample(): async def test_nested_routes(display: DisplayFixture): @component def sample(): - return simple.router( + return routers.browser_router( route( "/a", html.h1({"id": "a"}, "A"), @@ -79,7 +79,7 @@ async def test_navigate_with_link(display: DisplayFixture): @component def sample(): render_count.current += 1 - return simple.router( + return routers.browser_router( route("/", link("Root", to="/a", id="root")), route("/a", link("A", to="/b", id="a")), route("/b", link("B", to="/c", id="b")), @@ -110,7 +110,7 @@ def check_params(): @component def sample(): - return simple.router( + return routers.browser_router( route( "/first/{first:str}", check_params(), @@ -146,7 +146,7 @@ def check_query(): @component def sample(): - return simple.router(route("/", check_query())) + return routers.browser_router(route("/", check_query())) await display.show(sample) @@ -158,7 +158,7 @@ def sample(): async def test_browser_popstate(display: DisplayFixture): @component def sample(): - return simple.router( + return routers.browser_router( route("/", link("Root", to="/a", id="root")), route("/a", link("A", to="/b", id="a")), route("/b", link("B", to="/c", id="b")), @@ -190,7 +190,7 @@ def sample(): async def test_relative_links(display: DisplayFixture): @component def sample(): - return simple.router( + return routers.browser_router( route("/", link("Root", to="/a", id="root")), route("/a", link("A", to="/a/b", id="a")), route("/a/b", link("B", to="../a/b/c", id="b")), diff --git a/tests/test_simple.py b/tests/test_simple.py index 9ec8a2b..751e657 100644 --- a/tests/test_simple.py +++ b/tests/test_simple.py @@ -2,8 +2,7 @@ import uuid import pytest - -from reactpy_router.simple import parse_path +from reactpy_router.routers import parse_path def test_parse_path(): From 65570006a093ef4509f94562fd22a1087e9eea62 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 25 Jun 2024 01:22:19 -0700 Subject: [PATCH 06/62] Allow multi-component routes --- src/reactpy_router/core.py | 29 +++++++++++++++++++++-------- src/reactpy_router/types.py | 4 ++-- 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/src/reactpy_router/core.py b/src/reactpy_router/core.py index 8142a4b..ee53760 100644 --- a/src/reactpy_router/core.py +++ b/src/reactpy_router/core.py @@ -3,8 +3,9 @@ from __future__ import annotations from dataclasses import dataclass, replace +from logging import getLogger from pathlib import Path -from typing import Any, Callable, Iterator, Sequence, TypeVar +from typing import Any, Callable, Iterator, Literal, Sequence, TypeVar from urllib.parse import parse_qs from reactpy import ( @@ -24,6 +25,7 @@ from reactpy_router.types import Route, RouteCompiler, Router, RouteResolver +_logger = getLogger(__name__) R = TypeVar("R", bound=Route) @@ -35,8 +37,8 @@ def route(path: str, element: Any | None, *routes: Route) -> Route: def create_router(compiler: RouteCompiler[R]) -> Router[R]: """A decorator that turns a route compiler into a router""" - def wrapper(*routes: R) -> ComponentType: - return router_component(*routes, compiler=compiler) + def wrapper(*routes: R, select: Literal["first", "all"] = "first") -> ComponentType: + return router_component(*routes, select=select, compiler=compiler) return wrapper @@ -44,9 +46,10 @@ def wrapper(*routes: R) -> ComponentType: @component def router_component( *routes: R, + select: Literal["first", "all"], compiler: RouteCompiler[R], ) -> VdomDict | None: - """A component that renders the first matching route using the given compiler""" + """A component that renders matching route(s) using the given compiler function.""" old_conn = use_connection() location, set_location = use_state(old_conn.location) @@ -56,7 +59,7 @@ def router_component( dependencies=(compiler, hash(routes)), ) - match = use_memo(lambda: _match_route(resolvers, location)) + match = use_memo(lambda: _match_route(resolvers, location, select)) if match is not None: element, params = match @@ -128,12 +131,22 @@ def _iter_routes(routes: Sequence[R]) -> Iterator[R]: yield parent -def _match_route(compiled_routes: Sequence[RouteResolver], location: Location) -> tuple[Any, dict[str, Any]] | None: +def _match_route( + compiled_routes: Sequence[RouteResolver], location: Location, select: Literal["first", "all"] +) -> tuple[Any, dict[str, Any]] | None: + matches = [] + for resolver in compiled_routes: match = resolver.resolve(location.pathname) if match is not None: - return match - return None + if select == "first": + return match + matches.append(match) + + if not matches: + _logger.debug("No matching route found for %s", location.pathname) + + return matches or None _link, _history = export( diff --git a/src/reactpy_router/types.py b/src/reactpy_router/types.py index a91787e..7f85eeb 100644 --- a/src/reactpy_router/types.py +++ b/src/reactpy_router/types.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Any, Sequence, TypeVar +from typing import Any, Literal, Sequence, TypeVar from reactpy.core.vdom import is_vdom from reactpy.types import ComponentType, Key @@ -35,7 +35,7 @@ def __hash__(self) -> int: class Router(Protocol[R]): """Return a component that renders the first matching route""" - def __call__(self, *routes: R) -> ComponentType: + def __call__(self, *routes: R, select: Literal["first", "all"]) -> ComponentType: ... From 9e72ca079530e3eb53f8d9ac89596f1fa20e9a88 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 25 Jun 2024 01:23:45 -0700 Subject: [PATCH 07/62] Fix potential key identity bug of router component children --- src/reactpy_router/core.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/reactpy_router/core.py b/src/reactpy_router/core.py index ee53760..494e8ba 100644 --- a/src/reactpy_router/core.py +++ b/src/reactpy_router/core.py @@ -63,12 +63,13 @@ def router_component( if match is not None: element, params = match - return html._( + return html.div( ConnectionContext( _route_state_context(element, value=_RouteState(set_location, params)), value=Connection(old_conn.scope, location, old_conn.carrier), ), _history({"on_change": lambda event: set_location(Location(**event))}), + key=location.pathname + select, ) return None From 7a242e10e0d3a5884e1ddd4913d45598aefcee20 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 25 Jun 2024 01:37:06 -0700 Subject: [PATCH 08/62] Add slug conversion type --- src/reactpy_router/routers.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/reactpy_router/routers.py b/src/reactpy_router/routers.py index fedcd1a..7498816 100644 --- a/src/reactpy_router/routers.py +++ b/src/reactpy_router/routers.py @@ -70,28 +70,32 @@ class ConversionInfo(TypedDict): CONVERSION_TYPES: dict[str, ConversionInfo] = { - "str": { - "regex": r"[^/]+", - "func": str, - }, "int": { "regex": r"\d+", "func": int, }, - "float": { - "regex": r"\d+(\.\d+)?", - "func": float, + "str": { + "regex": r"[^/]+", + "func": str, }, "uuid": { "regex": r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", "func": uuid.UUID, }, + "slug": { + "regex": r"[-a-zA-Z0-9_]+", + "func": str, + }, "path": { "regex": r".+", "func": str, }, + "float": { + "regex": r"\d+(\.\d+)?", + "func": float, + }, } -"""The supported conversion types""" +"""The conversion types supported by the default Resolver. You can add more types if needed.""" browser_router = create_router(SimpleResolver) From e92842f6467345eb32c668be1f3d365534a4809f Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 25 Jun 2024 02:10:23 -0700 Subject: [PATCH 09/62] SimpleResolver -> Resolver, CONVERSION_TYPES -> CONVERTERS, file refactoring, and better resolver base class --- src/reactpy_router/__init__.py | 9 ++- src/reactpy_router/converters.py | 33 +++++++++++ src/reactpy_router/resolvers.py | 55 +++++++++++++++++++ src/reactpy_router/routers.py | 94 +------------------------------- src/reactpy_router/types.py | 24 ++++++-- 5 files changed, 114 insertions(+), 101 deletions(-) create mode 100644 src/reactpy_router/converters.py create mode 100644 src/reactpy_router/resolvers.py diff --git a/src/reactpy_router/__init__.py b/src/reactpy_router/__init__.py index 6eb873d..0415bb6 100644 --- a/src/reactpy_router/__init__.py +++ b/src/reactpy_router/__init__.py @@ -1,8 +1,10 @@ # the version is statically loaded by setup.py __version__ = "0.1.1" -from . import routers + +from .converters import CONVERTERS from .core import create_router, link, route, router_component, use_params, use_search_params +from .resolvers import Resolver from .routers import browser_router from .types import Route, RouteCompiler, RouteResolver @@ -10,13 +12,14 @@ "create_router", "link", "route", - "route", - "Route", "routers", + "Route", "RouteCompiler", "router_component", "RouteResolver", "browser_router", "use_params", "use_search_params", + "Resolver", + "CONVERTERS", ) diff --git a/src/reactpy_router/converters.py b/src/reactpy_router/converters.py new file mode 100644 index 0000000..e60d97f --- /dev/null +++ b/src/reactpy_router/converters.py @@ -0,0 +1,33 @@ +import uuid + +from reactpy_router.types import ConversionInfo + +__all__ = ["CONVERTERS"] + +CONVERTERS: dict[str, ConversionInfo] = { + "int": { + "regex": r"\d+", + "func": int, + }, + "str": { + "regex": r"[^/]+", + "func": str, + }, + "uuid": { + "regex": r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", + "func": uuid.UUID, + }, + "slug": { + "regex": r"[-a-zA-Z0-9_]+", + "func": str, + }, + "path": { + "regex": r".+", + "func": str, + }, + "float": { + "regex": r"\d+(\.\d+)?", + "func": float, + }, +} +"""The conversion types supported by the default Resolver. You can add more types if needed.""" diff --git a/src/reactpy_router/resolvers.py b/src/reactpy_router/resolvers.py new file mode 100644 index 0000000..981282b --- /dev/null +++ b/src/reactpy_router/resolvers.py @@ -0,0 +1,55 @@ +import re +from typing import Any + +from reactpy_router.converters import CONVERTERS +from reactpy_router.types import ConversionInfo, ConverterMapping, Route + +__all__ = ["Resolver"] + + +class Resolver: + """A simple route resolver that uses regex to match paths""" + + def __init__( + self, + route: Route, + param_pattern=r"{(?P\w+)(?P:\w+)?}", + converters: dict[str, ConversionInfo] | None = None, + ) -> None: + self.element = route.element + self.pattern, self.converter_mapping = self.parse_path(route.path) + self.converters = converters or CONVERTERS + self.key = self.pattern.pattern + self.param_regex = re.compile(param_pattern) + + def parse_path(self, path: str) -> tuple[re.Pattern[str], ConverterMapping]: + pattern = "^" + last_match_end = 0 + converter_mapping: ConverterMapping = {} + for match in self.param_regex.finditer(path): + param_name = match.group("name") + param_type = (match.group("type") or "str").strip(":") + try: + param_conv = self.converter_mapping[param_type] + except KeyError as e: + raise ValueError(f"Unknown conversion type {param_type!r} in {path!r}") from e + pattern += re.escape(path[last_match_end : match.start()]) + pattern += f"(?P<{param_name}>{param_conv['regex']})" + converter_mapping[param_name] = param_conv["func"] + last_match_end = match.end() + pattern += f"{re.escape(path[last_match_end:])}$" + + # Replace literal `*` with "match anything" regex pattern, if it's at the end of the path + if pattern.endswith(r"\*$"): + pattern = f"{pattern[:-3]}.*$" + + return re.compile(pattern), converter_mapping + + def resolve(self, path: str) -> tuple[Any, dict[str, Any]] | None: + match = self.pattern.match(path) + if match: + return ( + self.element, + {k: self.converter_mapping[k](v) for k, v in match.groupdict().items()}, + ) + return None diff --git a/src/reactpy_router/routers.py b/src/reactpy_router/routers.py index 7498816..42583a9 100644 --- a/src/reactpy_router/routers.py +++ b/src/reactpy_router/routers.py @@ -2,103 +2,13 @@ from __future__ import annotations -import re -import uuid -from typing import Any, Callable - -from typing_extensions import TypeAlias, TypedDict - from reactpy_router.core import create_router -from reactpy_router.types import Route +from reactpy_router.resolvers import Resolver __all__ = ["browser_router"] -ConversionFunc: TypeAlias = "Callable[[str], Any]" -ConverterMapping: TypeAlias = "dict[str, ConversionFunc]" - -STAR_PATTERN = re.compile("^.*$") -PARAM_PATTERN = re.compile(r"{(?P\w+)(?P:\w+)?}") - - -class SimpleResolver: - """A simple route resolver that uses regex to match paths""" - - def __init__(self, route: Route) -> None: - self.element = route.element - self.pattern, self.converters = parse_path(route.path) - self.key = self.pattern.pattern - - def resolve(self, path: str) -> tuple[Any, dict[str, Any]] | None: - match = self.pattern.match(path) - if match: - return ( - self.element, - {k: self.converters[k](v) for k, v in match.groupdict().items()}, - ) - return None - - -def parse_path(path: str) -> tuple[re.Pattern[str], ConverterMapping]: - if path == "*": - return STAR_PATTERN, {} - - pattern = "^" - last_match_end = 0 - converters: ConverterMapping = {} - for match in PARAM_PATTERN.finditer(path): - param_name = match.group("name") - param_type = (match.group("type") or "str").lstrip(":") - try: - param_conv = CONVERSION_TYPES[param_type] - except KeyError: - raise ValueError(f"Unknown conversion type {param_type!r} in {path!r}") - pattern += re.escape(path[last_match_end : match.start()]) - pattern += f"(?P<{param_name}>{param_conv['regex']})" - converters[param_name] = param_conv["func"] - last_match_end = match.end() - pattern += re.escape(path[last_match_end:]) + "$" - return re.compile(pattern), converters - - -class ConversionInfo(TypedDict): - """Information about a conversion type""" - - regex: str - """The regex to match the conversion type""" - func: ConversionFunc - """The function to convert the matched string to the expected type""" - - -CONVERSION_TYPES: dict[str, ConversionInfo] = { - "int": { - "regex": r"\d+", - "func": int, - }, - "str": { - "regex": r"[^/]+", - "func": str, - }, - "uuid": { - "regex": r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", - "func": uuid.UUID, - }, - "slug": { - "regex": r"[-a-zA-Z0-9_]+", - "func": str, - }, - "path": { - "regex": r".+", - "func": str, - }, - "float": { - "regex": r"\d+(\.\d+)?", - "func": float, - }, -} -"""The conversion types supported by the default Resolver. You can add more types if needed.""" - -browser_router = create_router(SimpleResolver) +browser_router = create_router(Resolver) """This is the recommended router for all ReactPy Router web projects. It uses the DOM History API to update the URL and manage the history stack.""" # TODO: Check if this is true. If not, make it true. diff --git a/src/reactpy_router/types.py b/src/reactpy_router/types.py index 7f85eeb..b1ba2e6 100644 --- a/src/reactpy_router/types.py +++ b/src/reactpy_router/types.py @@ -3,12 +3,15 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Any, Literal, Sequence, TypeVar +from typing import Any, Callable, Literal, Sequence, TypeAlias, TypedDict, TypeVar from reactpy.core.vdom import is_vdom from reactpy.types import ComponentType, Key from typing_extensions import Protocol, Self +ConversionFunc: TypeAlias = Callable[[str], Any] +ConverterMapping: TypeAlias = dict[str, ConversionFunc] + @dataclass(frozen=True) class Route: @@ -29,20 +32,20 @@ def __hash__(self) -> int: return hash((self.path, key, self.routes)) -R = TypeVar("R", bound=Route, contravariant=True) +R_contra = TypeVar("R_contra", bound=Route, contravariant=True) -class Router(Protocol[R]): +class Router(Protocol[R_contra]): """Return a component that renders the first matching route""" - def __call__(self, *routes: R, select: Literal["first", "all"]) -> ComponentType: + def __call__(self, *routes: R_contra, select: Literal["first", "all"]) -> ComponentType: ... -class RouteCompiler(Protocol[R]): +class RouteCompiler(Protocol[R_contra]): """Compile a route into a resolver that can be matched against a path""" - def __call__(self, route: R) -> RouteResolver: + def __call__(self, route: R_contra) -> RouteResolver: ... @@ -55,3 +58,12 @@ def key(self) -> Key: def resolve(self, path: str) -> tuple[Any, dict[str, Any]] | None: """Return the path's associated element and path params or None""" + + +class ConversionInfo(TypedDict): + """Information about a conversion type""" + + regex: str + """The regex to match the conversion type""" + func: ConversionFunc + """The function to convert the matched string to the expected type""" From f46d9d1699dd8c9376c039385009a7566d2a4c85 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 25 Jun 2024 02:18:52 -0700 Subject: [PATCH 10/62] allow customizable match_any_identifier --- src/reactpy_router/resolvers.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/reactpy_router/resolvers.py b/src/reactpy_router/resolvers.py index 981282b..e4d0577 100644 --- a/src/reactpy_router/resolvers.py +++ b/src/reactpy_router/resolvers.py @@ -8,12 +8,13 @@ class Resolver: - """A simple route resolver that uses regex to match paths""" + """A simple route resolver that uses regex to match paths.""" def __init__( self, route: Route, param_pattern=r"{(?P\w+)(?P:\w+)?}", + match_any_identifier=r"\*$", converters: dict[str, ConversionInfo] | None = None, ) -> None: self.element = route.element @@ -21,6 +22,7 @@ def __init__( self.converters = converters or CONVERTERS self.key = self.pattern.pattern self.param_regex = re.compile(param_pattern) + self.match_any = match_any_identifier def parse_path(self, path: str) -> tuple[re.Pattern[str], ConverterMapping]: pattern = "^" @@ -39,8 +41,8 @@ def parse_path(self, path: str) -> tuple[re.Pattern[str], ConverterMapping]: last_match_end = match.end() pattern += f"{re.escape(path[last_match_end:])}$" - # Replace literal `*` with "match anything" regex pattern, if it's at the end of the path - if pattern.endswith(r"\*$"): + # Replace "match anything" pattern with regex, if it's at the end of the path + if pattern.endswith(self.match_any): pattern = f"{pattern[:-3]}.*$" return re.compile(pattern), converter_mapping From b8efe549b9bf59711d58c25d5796db953d776257 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 25 Jun 2024 14:22:57 -0700 Subject: [PATCH 11/62] attempt to fix workflows --- .github/workflows/codeql.yml | 15 --------------- .github/workflows/publish-develop-docs.yml | 2 +- .github/workflows/publish-py.yaml | 2 +- .github/workflows/publish-release-docs.yml | 2 +- .github/workflows/test-src.yaml | 4 ++-- 5 files changed, 5 insertions(+), 20 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 63a412e..213f18a 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -56,21 +56,6 @@ jobs: # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs # queries: security-extended,security-and-quality - # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@v3 - - # â„šī¸ Command-line programs to run using the OS shell. - # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun - - # If the Autobuild fails above, remove it and uncomment the following three lines. - # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. - - # - run: | - # echo "Run, Build Application using script" - # ./location_of_script_within_repo/buildscript.sh - - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v3 with: diff --git a/.github/workflows/publish-develop-docs.yml b/.github/workflows/publish-develop-docs.yml index 474b82c..95d98da 100644 --- a/.github/workflows/publish-develop-docs.yml +++ b/.github/workflows/publish-develop-docs.yml @@ -10,7 +10,7 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 - - uses: actions/checkout@v5 + - uses: actions/setup-python@v5 with: python-version: 3.x - run: pip install -r requirements/build-docs.txt diff --git a/.github/workflows/publish-py.yaml b/.github/workflows/publish-py.yaml index 5f77870..d7437d7 100644 --- a/.github/workflows/publish-py.yaml +++ b/.github/workflows/publish-py.yaml @@ -13,7 +13,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Set up Python - uses: actions/checkout@v5 + uses: actions/setup-python@v5 with: python-version: "3.x" - name: Install dependencies diff --git a/.github/workflows/publish-release-docs.yml b/.github/workflows/publish-release-docs.yml index d183fc7..a98e986 100644 --- a/.github/workflows/publish-release-docs.yml +++ b/.github/workflows/publish-release-docs.yml @@ -11,7 +11,7 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 - - uses: actions/checkout@v5 + - uses: actions/setup-python@v5 with: python-version: 3.x - run: pip install -r requirements/build-docs.txt diff --git a/.github/workflows/test-src.yaml b/.github/workflows/test-src.yaml index ba033be..df93152 100644 --- a/.github/workflows/test-src.yaml +++ b/.github/workflows/test-src.yaml @@ -19,7 +19,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Use Python ${{ matrix.python-version }} - uses: actions/checkout@v5 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install Python Dependencies @@ -31,7 +31,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Use Latest Python - uses: actions/checkout@v5 + uses: actions/setup-python@v5 with: python-version: "3.10" - name: Install Python Dependencies From bee18a597a2c84d7854f55ae6c4eda5a6fd440a4 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 25 Jun 2024 14:38:20 -0700 Subject: [PATCH 12/62] server-side link component --- src/js/package-lock.json | 11 ------- src/js/package.json | 3 +- src/js/src/index.js | 54 ++++++++++++----------------------- src/reactpy_router/core.py | 12 +++++--- src/reactpy_router/js/Link.js | 8 ++++++ src/reactpy_router/routers.py | 1 - 6 files changed, 36 insertions(+), 53 deletions(-) create mode 100644 src/reactpy_router/js/Link.js diff --git a/src/js/package-lock.json b/src/js/package-lock.json index db77c4d..c98d3c6 100644 --- a/src/js/package-lock.json +++ b/src/js/package-lock.json @@ -6,7 +6,6 @@ "": { "name": "reactpy-router", "dependencies": { - "htm": "^3.0.4", "react": "^17.0.1", "react-dom": "^17.0.1" }, @@ -1098,11 +1097,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/htm": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/htm/-/htm-3.1.0.tgz", - "integrity": "sha512-L0s3Sid5r6YwrEvkig14SK3Emmc+kIjlfLhEGn2Vy3bk21JyDEes4MoDsbJk6luaPp8bugErnxPz86ZuAw6e5Q==" - }, "node_modules/ignore": { "version": "5.2.4", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz", @@ -3126,11 +3120,6 @@ "has-symbols": "^1.0.2" } }, - "htm": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/htm/-/htm-3.1.0.tgz", - "integrity": "sha512-L0s3Sid5r6YwrEvkig14SK3Emmc+kIjlfLhEGn2Vy3bk21JyDEes4MoDsbJk6luaPp8bugErnxPz86ZuAw6e5Q==" - }, "ignore": { "version": "5.2.4", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz", diff --git a/src/js/package.json b/src/js/package.json index 9baef8c..d4ac92c 100644 --- a/src/js/package.json +++ b/src/js/package.json @@ -18,16 +18,15 @@ "test": "echo \"Error: no test specified\" && exit 1" }, "devDependencies": { - "prettier": "^2.2.1", "eslint": "^8.38.0", "eslint-plugin-react": "^7.32.2", + "prettier": "^2.2.1", "rollup": "^2.35.1", "rollup-plugin-commonjs": "^10.1.0", "rollup-plugin-node-resolve": "^5.2.0", "rollup-plugin-replace": "^2.2.0" }, "dependencies": { - "htm": "^3.0.4", "react": "^17.0.1", "react-dom": "^17.0.1" } diff --git a/src/js/src/index.js b/src/js/src/index.js index 1f43092..1e79baa 100644 --- a/src/js/src/index.js +++ b/src/js/src/index.js @@ -1,44 +1,28 @@ import React from "react"; import ReactDOM from "react-dom"; -import htm from "htm"; - -const html = htm.bind(React.createElement); export function bind(node) { - return { - create: (type, props, children) => - React.createElement(type, props, ...children), - render: (element) => { - ReactDOM.render(element, node); - }, - unmount: () => ReactDOM.unmountComponentAtNode(node), - }; -} - -export function History({ onChange }) { - // capture changes to the browser's history - React.useEffect(() => { - const listener = () => { - onChange({ - pathname: window.location.pathname, - search: window.location.search, - }); + return { + create: (type, props, children) => + React.createElement(type, props, ...children), + render: (element) => { + ReactDOM.render(element, node); + }, + unmount: () => ReactDOM.unmountComponentAtNode(node), }; - window.addEventListener("popstate", listener); - return () => window.removeEventListener("popstate", listener); - }); - return null; } -export function Link({ to, onClick, children, ...props }) { - const handleClick = (event) => { - event.preventDefault(); - window.history.pushState({}, to, new URL(to, window.location)); - onClick({ - pathname: window.location.pathname, - search: window.location.search, +export function History({ onChange }) { + // capture changes to the browser's history + React.useEffect(() => { + const listener = () => { + onChange({ + pathname: window.location.pathname, + search: window.location.search, + }); + }; + window.addEventListener("popstate", listener); + return () => window.removeEventListener("popstate", listener); }); - }; - - return html`${children}`; + return null; } diff --git a/src/reactpy_router/core.py b/src/reactpy_router/core.py index 494e8ba..a9147f1 100644 --- a/src/reactpy_router/core.py +++ b/src/reactpy_router/core.py @@ -7,6 +7,7 @@ from pathlib import Path from typing import Any, Callable, Iterator, Literal, Sequence, TypeVar from urllib.parse import parse_qs +from uuid import uuid4 from reactpy import ( component, @@ -68,7 +69,7 @@ def router_component( _route_state_context(element, value=_RouteState(set_location, params)), value=Connection(old_conn.scope, location, old_conn.carrier), ), - _history({"on_change": lambda event: set_location(Location(**event))}), + History({"on_change": lambda event: set_location(Location(**event))}), key=location.pathname + select, ) @@ -79,12 +80,14 @@ def router_component( def link(*children: VdomChild, to: str, **attributes: Any) -> VdomDict: """A component that renders a link to the given path""" set_location = _use_route_state().set_location + uuid = uuid4().hex attrs = { **attributes, "to": to, "onClick": lambda event: set_location(Location(**event)), + "id": uuid, } - return _link(attrs, *children) + return html._(html.a(attrs, *children), html.script(link_js_content.replace("UUID", uuid))) def use_params() -> dict[str, Any]: @@ -150,10 +153,11 @@ def _match_route( return matches or None -_link, _history = export( +History = export( module_from_file("reactpy-router", file=Path(__file__).parent / "bundle.js"), - ("Link", "History"), + ("History"), ) +link_js_content = (Path(__file__).parent / "js" / "Link.js").read_text(encoding="utf-8") @dataclass diff --git a/src/reactpy_router/js/Link.js b/src/reactpy_router/js/Link.js new file mode 100644 index 0000000..b74e736 --- /dev/null +++ b/src/reactpy_router/js/Link.js @@ -0,0 +1,8 @@ +document.getElementById("UUID").addEventListener("click", (event) => { + event.preventDefault(); + window.history.pushState({}, to, new URL(to, window.location)); + onClick({ + pathname: window.location.pathname, + search: window.location.search, + }); +}); diff --git a/src/reactpy_router/routers.py b/src/reactpy_router/routers.py index 42583a9..9ba8f39 100644 --- a/src/reactpy_router/routers.py +++ b/src/reactpy_router/routers.py @@ -11,4 +11,3 @@ browser_router = create_router(Resolver) """This is the recommended router for all ReactPy Router web projects. It uses the DOM History API to update the URL and manage the history stack.""" -# TODO: Check if this is true. If not, make it true. From de53beb2f26cb409f23539e472c91db067846c9f Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 26 Jun 2024 00:58:20 -0700 Subject: [PATCH 13/62] fix a handful of test errors --- MANIFEST.in | 2 +- docs/examples/python/nested-routes.py | 5 +- docs/examples/python/route-parameters.py | 5 +- src/js/src/index.js | 2 +- src/reactpy_router/__init__.py | 9 +++- src/reactpy_router/core.py | 46 +++++++++++++------ src/reactpy_router/resolvers.py | 9 ++-- .../{js/Link.js => static/link.js} | 0 src/reactpy_router/types.py | 4 +- tests/test_core.py | 20 ++++---- tests/{test_simple.py => test_resolver.py} | 24 ++++++---- 11 files changed, 82 insertions(+), 44 deletions(-) rename src/reactpy_router/{js/Link.js => static/link.js} (100%) rename tests/{test_simple.py => test_resolver.py} (59%) diff --git a/MANIFEST.in b/MANIFEST.in index bdca1f4..71f4855 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,2 +1,2 @@ -include src/reactpy_router/bundle.js +recursive-include src/reactpy_router/static * include src/reactpy_router/py.typed diff --git a/docs/examples/python/nested-routes.py b/docs/examples/python/nested-routes.py index c6bfcb7..b78f694 100644 --- a/docs/examples/python/nested-routes.py +++ b/docs/examples/python/nested-routes.py @@ -40,7 +40,10 @@ def home(): @component def all_messages(): - last_messages = {", ".join(msg["with"]): msg for msg in sorted(message_data, key=lambda m: m["id"])} + last_messages = { + ", ".join(msg["with"]): msg + for msg in sorted(message_data, key=lambda m: m["id"]) + } return html.div( html.h1("All Messages đŸ’Ŧ"), html.ul( diff --git a/docs/examples/python/route-parameters.py b/docs/examples/python/route-parameters.py index 3fff752..b46bae1 100644 --- a/docs/examples/python/route-parameters.py +++ b/docs/examples/python/route-parameters.py @@ -39,7 +39,10 @@ def home(): @component def all_messages(): - last_messages = {", ".join(msg["with"]): msg for msg in sorted(message_data, key=lambda m: m["id"])} + last_messages = { + ", ".join(msg["with"]): msg + for msg in sorted(message_data, key=lambda m: m["id"]) + } return html.div( html.h1("All Messages đŸ’Ŧ"), html.ul( diff --git a/src/js/src/index.js b/src/js/src/index.js index 1e79baa..500f98a 100644 --- a/src/js/src/index.js +++ b/src/js/src/index.js @@ -13,7 +13,7 @@ export function bind(node) { } export function History({ onChange }) { - // capture changes to the browser's history + // Capture changes to the browser's history React.useEffect(() => { const listener = () => { onChange({ diff --git a/src/reactpy_router/__init__.py b/src/reactpy_router/__init__.py index 0415bb6..0dc24d6 100644 --- a/src/reactpy_router/__init__.py +++ b/src/reactpy_router/__init__.py @@ -3,7 +3,14 @@ from .converters import CONVERTERS -from .core import create_router, link, route, router_component, use_params, use_search_params +from .core import ( + create_router, + link, + route, + router_component, + use_params, + use_search_params, +) from .resolvers import Resolver from .routers import browser_router from .types import Route, RouteCompiler, RouteResolver diff --git a/src/reactpy_router/core.py b/src/reactpy_router/core.py index a9147f1..3670dfa 100644 --- a/src/reactpy_router/core.py +++ b/src/reactpy_router/core.py @@ -62,15 +62,23 @@ def router_component( match = use_memo(lambda: _match_route(resolvers, location, select)) - if match is not None: - element, params = match - return html.div( - ConnectionContext( - _route_state_context(element, value=_RouteState(set_location, params)), - value=Connection(old_conn.scope, location, old_conn.carrier), + if match: + route_elements = [ + _route_state_context( + html.div( + element, # type: ignore + key=f"{location.pathname}{select}{params}{element}{id(element)}", + ), + value=_RouteState(set_location, params), + ) + for element, params in match + ] + return ConnectionContext( + History( # type: ignore + {"on_change": lambda event: set_location(Location(**event))} ), - History({"on_change": lambda event: set_location(Location(**event))}), - key=location.pathname + select, + route_elements, + value=Connection(old_conn.scope, location, old_conn.carrier), ) return None @@ -87,7 +95,9 @@ def link(*children: VdomChild, to: str, **attributes: Any) -> VdomDict: "onClick": lambda event: set_location(Location(**event)), "id": uuid, } - return html._(html.a(attrs, *children), html.script(link_js_content.replace("UUID", uuid))) + return html._( + html.a(attrs, *children), html.script(link_js_content.replace("UUID", uuid)) + ) def use_params() -> dict[str, Any]: @@ -136,28 +146,34 @@ def _iter_routes(routes: Sequence[R]) -> Iterator[R]: def _match_route( - compiled_routes: Sequence[RouteResolver], location: Location, select: Literal["first", "all"] -) -> tuple[Any, dict[str, Any]] | None: + compiled_routes: Sequence[RouteResolver], + location: Location, + select: Literal["first", "all"], +) -> list[tuple[Any, dict[str, Any]]]: matches = [] for resolver in compiled_routes: match = resolver.resolve(location.pathname) if match is not None: if select == "first": - return match + return [match] matches.append(match) if not matches: _logger.debug("No matching route found for %s", location.pathname) - return matches or None + return matches History = export( - module_from_file("reactpy-router", file=Path(__file__).parent / "bundle.js"), + module_from_file( + "reactpy-router", file=Path(__file__).parent / "static" / "bundle.js" + ), ("History"), ) -link_js_content = (Path(__file__).parent / "js" / "Link.js").read_text(encoding="utf-8") +link_js_content = (Path(__file__).parent / "static" / "link.js").read_text( + encoding="utf-8" +) @dataclass diff --git a/src/reactpy_router/resolvers.py b/src/reactpy_router/resolvers.py index e4d0577..c53e108 100644 --- a/src/reactpy_router/resolvers.py +++ b/src/reactpy_router/resolvers.py @@ -19,12 +19,13 @@ def __init__( ) -> None: self.element = route.element self.pattern, self.converter_mapping = self.parse_path(route.path) - self.converters = converters or CONVERTERS + self.registered_converters = converters or CONVERTERS self.key = self.pattern.pattern self.param_regex = re.compile(param_pattern) self.match_any = match_any_identifier def parse_path(self, path: str) -> tuple[re.Pattern[str], ConverterMapping]: + # Convert path to regex pattern, then interpret using registered converters pattern = "^" last_match_end = 0 converter_mapping: ConverterMapping = {} @@ -32,9 +33,11 @@ def parse_path(self, path: str) -> tuple[re.Pattern[str], ConverterMapping]: param_name = match.group("name") param_type = (match.group("type") or "str").strip(":") try: - param_conv = self.converter_mapping[param_type] + param_conv = self.registered_converters[param_type] except KeyError as e: - raise ValueError(f"Unknown conversion type {param_type!r} in {path!r}") from e + raise ValueError( + f"Unknown conversion type {param_type!r} in {path!r}" + ) from e pattern += re.escape(path[last_match_end : match.start()]) pattern += f"(?P<{param_name}>{param_conv['regex']})" converter_mapping[param_name] = param_conv["func"] diff --git a/src/reactpy_router/js/Link.js b/src/reactpy_router/static/link.js similarity index 100% rename from src/reactpy_router/js/Link.js rename to src/reactpy_router/static/link.js diff --git a/src/reactpy_router/types.py b/src/reactpy_router/types.py index b1ba2e6..716e85e 100644 --- a/src/reactpy_router/types.py +++ b/src/reactpy_router/types.py @@ -38,7 +38,9 @@ def __hash__(self) -> int: class Router(Protocol[R_contra]): """Return a component that renders the first matching route""" - def __call__(self, *routes: R_contra, select: Literal["first", "all"]) -> ComponentType: + def __call__( + self, *routes: R_contra, select: Literal["first", "all"] = "first" + ) -> ComponentType: ... diff --git a/tests/test_core.py b/tests/test_core.py index 8e50212..61237eb 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -2,7 +2,7 @@ from reactpy import Ref, component, html, use_location from reactpy.testing import DisplayFixture -from reactpy_router import link, route, routers, use_params, use_search_params +from reactpy_router import browser_router, link, route, use_params, use_search_params async def test_simple_router(display: DisplayFixture): @@ -18,7 +18,7 @@ def check_location(): @component def sample(): - return routers.browser_router( + return browser_router( make_location_check("/a"), make_location_check("/b"), make_location_check("/c"), @@ -40,8 +40,8 @@ def sample(): root_element = await display.root_element() except AttributeError: root_element = await display.page.wait_for_selector( - f"#display-{display._next_view_id}", - state="attached", # type: ignore + f"#display-{display._next_view_id}", # type: ignore + state="attached", ) assert not await root_element.inner_html() @@ -50,7 +50,7 @@ def sample(): async def test_nested_routes(display: DisplayFixture): @component def sample(): - return routers.browser_router( + return browser_router( route( "/a", html.h1({"id": "a"}, "A"), @@ -79,7 +79,7 @@ async def test_navigate_with_link(display: DisplayFixture): @component def sample(): render_count.current += 1 - return routers.browser_router( + return browser_router( route("/", link("Root", to="/a", id="root")), route("/a", link("A", to="/b", id="a")), route("/b", link("B", to="/c", id="b")), @@ -110,7 +110,7 @@ def check_params(): @component def sample(): - return routers.browser_router( + return browser_router( route( "/first/{first:str}", check_params(), @@ -146,7 +146,7 @@ def check_query(): @component def sample(): - return routers.browser_router(route("/", check_query())) + return browser_router(route("/", check_query())) await display.show(sample) @@ -158,7 +158,7 @@ def sample(): async def test_browser_popstate(display: DisplayFixture): @component def sample(): - return routers.browser_router( + return browser_router( route("/", link("Root", to="/a", id="root")), route("/a", link("A", to="/b", id="a")), route("/b", link("B", to="/c", id="b")), @@ -190,7 +190,7 @@ def sample(): async def test_relative_links(display: DisplayFixture): @component def sample(): - return routers.browser_router( + return browser_router( route("/", link("Root", to="/a", id="root")), route("/a", link("A", to="/a/b", id="a")), route("/a/b", link("B", to="../a/b/c", id="b")), diff --git a/tests/test_simple.py b/tests/test_resolver.py similarity index 59% rename from tests/test_simple.py rename to tests/test_resolver.py index 751e657..bef0854 100644 --- a/tests/test_simple.py +++ b/tests/test_resolver.py @@ -2,31 +2,32 @@ import uuid import pytest -from reactpy_router.routers import parse_path +from reactpy_router import Resolver, route def test_parse_path(): - assert parse_path("/a/b/c") == (re.compile("^/a/b/c$"), {}) - assert parse_path("/a/{b}/c") == ( + resolver = Resolver(route("/", None)) + assert resolver.parse_path("/a/b/c") == (re.compile("^/a/b/c$"), {}) + assert resolver.parse_path("/a/{b}/c") == ( re.compile(r"^/a/(?P[^/]+)/c$"), {"b": str}, ) - assert parse_path("/a/{b:int}/c") == ( + assert resolver.parse_path("/a/{b:int}/c") == ( re.compile(r"^/a/(?P\d+)/c$"), {"b": int}, ) - assert parse_path("/a/{b:int}/{c:float}/c") == ( + assert resolver.parse_path("/a/{b:int}/{c:float}/c") == ( re.compile(r"^/a/(?P\d+)/(?P\d+(\.\d+)?)/c$"), {"b": int, "c": float}, ) - assert parse_path("/a/{b:int}/{c:float}/{d:uuid}/c") == ( + assert resolver.parse_path("/a/{b:int}/{c:float}/{d:uuid}/c") == ( re.compile( r"^/a/(?P\d+)/(?P\d+(\.\d+)?)/(?P[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[" r"0-9a-f]{4}-[0-9a-f]{12})/c$" ), {"b": int, "c": float, "d": uuid.UUID}, ) - assert parse_path("/a/{b:int}/{c:float}/{d:uuid}/{e:path}/c") == ( + assert resolver.parse_path("/a/{b:int}/{c:float}/{d:uuid}/{e:path}/c") == ( re.compile( r"^/a/(?P\d+)/(?P\d+(\.\d+)?)/(?P[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[" r"0-9a-f]{4}-[0-9a-f]{12})/(?P.+)/c$" @@ -36,13 +37,15 @@ def test_parse_path(): def test_parse_path_unkown_conversion(): + resolver = Resolver(route("/", None)) with pytest.raises(ValueError): - parse_path("/a/{b:unknown}/c") + resolver.parse_path("/a/{b:unknown}/c") def test_parse_path_re_escape(): """Check that we escape regex characters in the path""" - assert parse_path("/a/{b:int}/c.d") == ( + resolver = Resolver(route("/", None)) + assert resolver.parse_path("/a/{b:int}/c.d") == ( # ^ regex character re.compile(r"^/a/(?P\d+)/c\.d$"), {"b": int}, @@ -50,4 +53,5 @@ def test_parse_path_re_escape(): def test_match_star_path(): - assert parse_path("*") == (re.compile("^.*$"), {}) + resolver = Resolver(route("/", None)) + assert resolver.parse_path("*") == (re.compile("^.*$"), {}) From f77f052dda00fa81b6288fcc584cc992a7945709 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 26 Jun 2024 02:03:22 -0700 Subject: [PATCH 14/62] change js output directory --- src/js/rollup.config.js | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/src/js/rollup.config.js b/src/js/rollup.config.js index ab1d0b1..5d19a7c 100644 --- a/src/js/rollup.config.js +++ b/src/js/rollup.config.js @@ -3,23 +3,23 @@ import commonjs from "rollup-plugin-commonjs"; import replace from "rollup-plugin-replace"; export default { - input: "src/index.js", - output: { - file: "../reactpy_router/bundle.js", - format: "esm", - }, - plugins: [ - resolve(), - commonjs(), - replace({ - "process.env.NODE_ENV": JSON.stringify("production"), - }), - ], - onwarn: function (warning) { - if (warning.code === "THIS_IS_UNDEFINED") { - // skip warning where `this` is undefined at the top level of a module - return; - } - console.warn(warning.message); - }, + input: "src/index.js", + output: { + file: "../reactpy_router/static/bundle.js", + format: "esm", + }, + plugins: [ + resolve(), + commonjs(), + replace({ + "process.env.NODE_ENV": JSON.stringify("production"), + }), + ], + onwarn: function (warning) { + if (warning.code === "THIS_IS_UNDEFINED") { + // skip warning where `this` is undefined at the top level of a module + return; + } + console.warn(warning.message); + }, }; From 518d226df69ac99a1ccf531e5d9ce5912a141f5b Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 26 Jun 2024 14:03:29 -0700 Subject: [PATCH 15/62] fix py3.9 type hints --- src/reactpy_router/resolvers.py | 2 ++ src/reactpy_router/types.py | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/reactpy_router/resolvers.py b/src/reactpy_router/resolvers.py index c53e108..feb489c 100644 --- a/src/reactpy_router/resolvers.py +++ b/src/reactpy_router/resolvers.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import re from typing import Any diff --git a/src/reactpy_router/types.py b/src/reactpy_router/types.py index 716e85e..c1300f2 100644 --- a/src/reactpy_router/types.py +++ b/src/reactpy_router/types.py @@ -3,11 +3,11 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Any, Callable, Literal, Sequence, TypeAlias, TypedDict, TypeVar +from typing import Any, Callable, Literal, Sequence, TypedDict, TypeVar from reactpy.core.vdom import is_vdom from reactpy.types import ComponentType, Key -from typing_extensions import Protocol, Self +from typing_extensions import Protocol, Self, TypeAlias ConversionFunc: TypeAlias = Callable[[str], Any] ConverterMapping: TypeAlias = dict[str, ConversionFunc] From e96a512fc57990be5203566a276b4d0a523a160d Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 28 Jun 2024 16:38:18 -0700 Subject: [PATCH 16/62] Change star pattern to `{NAME:any}` --- .gitignore | 2 +- .../python/basic-routing-more-routes.py | 2 +- docs/examples/python/basic-routing.py | 2 +- docs/examples/python/nested-routes.py | 2 +- docs/examples/python/route-links.py | 2 +- docs/examples/python/route-parameters.py | 2 +- docs/src/learn/routers-routes-and-links.md | 8 ++- src/reactpy_router/converters.py | 4 ++ src/reactpy_router/core.py | 5 +- src/reactpy_router/resolvers.py | 54 +++++++++----- tests/test_core.py | 6 +- tests/test_resolver.py | 70 ++++++++++--------- 12 files changed, 90 insertions(+), 69 deletions(-) diff --git a/.gitignore b/.gitignore index 9155bda..220d2a8 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,7 @@ docs/site # --- JAVASCRIPT BUNDLES --- -src/reactpy_router/bundle.js +src/reactpy_router/static/bundle.js # --- PYTHON IGNORE FILES ---- diff --git a/docs/examples/python/basic-routing-more-routes.py b/docs/examples/python/basic-routing-more-routes.py index c363ebc..32bb31e 100644 --- a/docs/examples/python/basic-routing-more-routes.py +++ b/docs/examples/python/basic-routing-more-routes.py @@ -7,7 +7,7 @@ def root(): return browser_router( route("/", html.h1("Home Page 🏠")), route("/messages", html.h1("Messages đŸ’Ŧ")), - route("*", html.h1("Missing Link 🔗‍đŸ’Ĩ")), + route("{404:any}", html.h1("Missing Link 🔗‍đŸ’Ĩ")), ) diff --git a/docs/examples/python/basic-routing.py b/docs/examples/python/basic-routing.py index a638fe7..43c4e65 100644 --- a/docs/examples/python/basic-routing.py +++ b/docs/examples/python/basic-routing.py @@ -6,7 +6,7 @@ def root(): return browser_router( route("/", html.h1("Home Page 🏠")), - route("*", html.h1("Missing Link 🔗‍đŸ’Ĩ")), + route("{404:any}", html.h1("Missing Link 🔗‍đŸ’Ĩ")), ) diff --git a/docs/examples/python/nested-routes.py b/docs/examples/python/nested-routes.py index b78f694..6c949df 100644 --- a/docs/examples/python/nested-routes.py +++ b/docs/examples/python/nested-routes.py @@ -26,7 +26,7 @@ def root(): route("/with/Alice", messages_with("Alice")), route("/with/Alice-Bob", messages_with("Alice", "Bob")), ), - route("*", html.h1("Missing Link 🔗‍đŸ’Ĩ")), + route("{404:any}", html.h1("Missing Link 🔗‍đŸ’Ĩ")), ) diff --git a/docs/examples/python/route-links.py b/docs/examples/python/route-links.py index db5a850..46c98d3 100644 --- a/docs/examples/python/route-links.py +++ b/docs/examples/python/route-links.py @@ -7,7 +7,7 @@ def root(): return browser_router( route("/", home()), route("/messages", html.h1("Messages đŸ’Ŧ")), - route("*", html.h1("Missing Link 🔗‍đŸ’Ĩ")), + route("{404:any}", html.h1("Missing Link 🔗‍đŸ’Ĩ")), ) diff --git a/docs/examples/python/route-parameters.py b/docs/examples/python/route-parameters.py index b46bae1..79d87b2 100644 --- a/docs/examples/python/route-parameters.py +++ b/docs/examples/python/route-parameters.py @@ -25,7 +25,7 @@ def root(): all_messages(), route("/with/{names}", messages_with()), # note the path param ), - route("*", html.h1("Missing Link 🔗‍đŸ’Ĩ")), + route("{404:any}", html.h1("Missing Link 🔗‍đŸ’Ĩ")), ) diff --git a/docs/src/learn/routers-routes-and-links.md b/docs/src/learn/routers-routes-and-links.md index 9e6197d..0144762 100644 --- a/docs/src/learn/routers-routes-and-links.md +++ b/docs/src/learn/routers-routes-and-links.md @@ -19,7 +19,7 @@ Here's a basic example showing how to use `#!python browser_router` with two rou {% include "../../examples/python/basic-routing.py" %} ``` -Here we'll note some special syntax in the route path for the second route. The `#!python "*"` is a wildcard that will match any path. This is useful for creating a "404" page that will be shown when no other route matches. +Here we'll note some special syntax in the route path for the second route. The `#!python "any"` type is a wildcard that will match any path. This is useful for creating a default page or error page such as "404 NOT FOUND". ### Browser Router @@ -34,11 +34,13 @@ In this case, `#!python param` is the name of the route parameter and the option | Type | Pattern | | --- | --- | -| `#!python str` (default) | `#!python [^/]+` | | `#!python int` | `#!python \d+` | -| `#!python float` | `#!python \d+(\.\d+)?` | +| `#!python str` (default) | `#!python [^/]+` | | `#!python uuid` | `#!python [0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}` | +| `#!python slug` | `#!python [-a-zA-Z0-9_]+` | | `#!python path` | `#!python .+` | +| `#!python float` | `#!python \d+(\.\d+)?` | +| `#!python any` | `#!python .*` | So in practice these each might look like: diff --git a/src/reactpy_router/converters.py b/src/reactpy_router/converters.py index e60d97f..e44e0b1 100644 --- a/src/reactpy_router/converters.py +++ b/src/reactpy_router/converters.py @@ -29,5 +29,9 @@ "regex": r"\d+(\.\d+)?", "func": float, }, + "any": { + "regex": r".*", + "func": str, + }, } """The conversion types supported by the default Resolver. You can add more types if needed.""" diff --git a/src/reactpy_router/core.py b/src/reactpy_router/core.py index 3670dfa..4d8c0bd 100644 --- a/src/reactpy_router/core.py +++ b/src/reactpy_router/core.py @@ -65,10 +65,7 @@ def router_component( if match: route_elements = [ _route_state_context( - html.div( - element, # type: ignore - key=f"{location.pathname}{select}{params}{element}{id(element)}", - ), + element, value=_RouteState(set_location, params), ) for element, params in match diff --git a/src/reactpy_router/resolvers.py b/src/reactpy_router/resolvers.py index feb489c..cbec0bb 100644 --- a/src/reactpy_router/resolvers.py +++ b/src/reactpy_router/resolvers.py @@ -16,47 +16,63 @@ def __init__( self, route: Route, param_pattern=r"{(?P\w+)(?P:\w+)?}", - match_any_identifier=r"\*$", converters: dict[str, ConversionInfo] | None = None, ) -> None: self.element = route.element - self.pattern, self.converter_mapping = self.parse_path(route.path) self.registered_converters = converters or CONVERTERS - self.key = self.pattern.pattern + self.converter_mapping: ConverterMapping = {} + # self.match_any_indentifier = match_any_identifier self.param_regex = re.compile(param_pattern) - self.match_any = match_any_identifier + self.pattern = self.parse_path(route.path) + self.key = self.pattern.pattern # Unique identifier for ReactPy rendering - def parse_path(self, path: str) -> tuple[re.Pattern[str], ConverterMapping]: + def parse_path(self, path: str) -> re.Pattern[str]: # Convert path to regex pattern, then interpret using registered converters pattern = "^" last_match_end = 0 - converter_mapping: ConverterMapping = {} + + # Iterate through matches of the parameter pattern for match in self.param_regex.finditer(path): - param_name = match.group("name") + # Extract parameter name and type + name = match.group("name") + if name[0].isnumeric(): + name = f"_numeric_{name}" param_type = (match.group("type") or "str").strip(":") + + # Check if a converter exists for the type try: - param_conv = self.registered_converters[param_type] + conversion_info = self.registered_converters[param_type] except KeyError as e: raise ValueError( f"Unknown conversion type {param_type!r} in {path!r}" ) from e + + # Add the string before the match to the pattern pattern += re.escape(path[last_match_end : match.start()]) - pattern += f"(?P<{param_name}>{param_conv['regex']})" - converter_mapping[param_name] = param_conv["func"] + + # Add the match to the pattern + pattern += f"(?P<{name}>{conversion_info['regex']})" + + # Keep a local mapping of parameter names to conversion functions. + self.converter_mapping[name] = conversion_info["func"] + + # Update the last match end last_match_end = match.end() - pattern += f"{re.escape(path[last_match_end:])}$" - # Replace "match anything" pattern with regex, if it's at the end of the path - if pattern.endswith(self.match_any): - pattern = f"{pattern[:-3]}.*$" + # Add the string after the last match + pattern += f"{re.escape(path[last_match_end:])}$" - return re.compile(pattern), converter_mapping + return re.compile(pattern) def resolve(self, path: str) -> tuple[Any, dict[str, Any]] | None: match = self.pattern.match(path) if match: - return ( - self.element, - {k: self.converter_mapping[k](v) for k, v in match.groupdict().items()}, - ) + # Convert the matched groups to the correct types + params = { + parameter_name.strip("_numeric_"): self.converter_mapping[ + parameter_name + ](value) + for parameter_name, value in match.groupdict().items() + } + return (self.element, params) return None diff --git a/tests/test_core.py b/tests/test_core.py index 61237eb..29f4f4c 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -84,7 +84,7 @@ def sample(): route("/a", link("A", to="/b", id="a")), route("/b", link("B", to="/c", id="b")), route("/c", link("C", to="/default", id="c")), - route("*", html.h1({"id": "default"}, "Default")), + route("{default:any}", html.h1({"id": "default"}, "Default")), ) await display.show(sample) @@ -163,7 +163,7 @@ def sample(): route("/a", link("A", to="/b", id="a")), route("/b", link("B", to="/c", id="b")), route("/c", link("C", to="/default", id="c")), - route("*", html.h1({"id": "default"}, "Default")), + route("{default:any}", html.h1({"id": "default"}, "Default")), ) await display.show(sample) @@ -197,7 +197,7 @@ def sample(): route("/a/b/c", link("C", to="../d", id="c")), route("/a/d", link("D", to="e", id="d")), route("/a/e", link("E", to="../default", id="e")), - route("*", html.h1({"id": "default"}, "Default")), + route("{default:any}", html.h1({"id": "default"}, "Default")), ) await display.show(sample) diff --git a/tests/test_resolver.py b/tests/test_resolver.py index bef0854..d0ef85b 100644 --- a/tests/test_resolver.py +++ b/tests/test_resolver.py @@ -5,35 +5,45 @@ from reactpy_router import Resolver, route +def test_resolve_any(): + resolver = Resolver(route("{404:any}", "Hello World")) + assert resolver.parse_path("{404:any}") == re.compile("^(?P<_numeric_404>.*)$") + assert resolver.converter_mapping == {"_numeric_404": str} + assert resolver.resolve("/hello/world") == ("Hello World", {"404": "/hello/world"}) + + def test_parse_path(): resolver = Resolver(route("/", None)) - assert resolver.parse_path("/a/b/c") == (re.compile("^/a/b/c$"), {}) - assert resolver.parse_path("/a/{b}/c") == ( - re.compile(r"^/a/(?P[^/]+)/c$"), - {"b": str}, - ) - assert resolver.parse_path("/a/{b:int}/c") == ( - re.compile(r"^/a/(?P\d+)/c$"), - {"b": int}, - ) - assert resolver.parse_path("/a/{b:int}/{c:float}/c") == ( - re.compile(r"^/a/(?P\d+)/(?P\d+(\.\d+)?)/c$"), - {"b": int, "c": float}, + assert resolver.parse_path("/a/b/c") == re.compile("^/a/b/c$") + assert resolver.converter_mapping == {} + + assert resolver.parse_path("/a/{b}/c") == re.compile(r"^/a/(?P[^/]+)/c$") + assert resolver.converter_mapping == {"b": str} + + assert resolver.parse_path("/a/{b:int}/c") == re.compile(r"^/a/(?P\d+)/c$") + assert resolver.converter_mapping == {"b": int} + + assert resolver.parse_path("/a/{b:int}/{c:float}/c") == re.compile( + r"^/a/(?P\d+)/(?P\d+(\.\d+)?)/c$" ) - assert resolver.parse_path("/a/{b:int}/{c:float}/{d:uuid}/c") == ( - re.compile( - r"^/a/(?P\d+)/(?P\d+(\.\d+)?)/(?P[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[" - r"0-9a-f]{4}-[0-9a-f]{12})/c$" - ), - {"b": int, "c": float, "d": uuid.UUID}, + assert resolver.converter_mapping == {"b": int, "c": float} + + assert resolver.parse_path("/a/{b:int}/{c:float}/{d:uuid}/c") == re.compile( + r"^/a/(?P\d+)/(?P\d+(\.\d+)?)/(?P[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/c$" ) - assert resolver.parse_path("/a/{b:int}/{c:float}/{d:uuid}/{e:path}/c") == ( - re.compile( - r"^/a/(?P\d+)/(?P\d+(\.\d+)?)/(?P[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[" - r"0-9a-f]{4}-[0-9a-f]{12})/(?P.+)/c$" - ), - {"b": int, "c": float, "d": uuid.UUID, "e": str}, + assert resolver.converter_mapping == {"b": int, "c": float, "d": uuid.UUID} + + assert resolver.parse_path( + "/a/{b:int}/{c:float}/{d:uuid}/{e:path}/c" + ) == re.compile( + r"^/a/(?P\d+)/(?P\d+(\.\d+)?)/(?P[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/(?P.+)/c$" ) + assert resolver.converter_mapping == { + "b": int, + "c": float, + "d": uuid.UUID, + "e": str, + } def test_parse_path_unkown_conversion(): @@ -45,13 +55,5 @@ def test_parse_path_unkown_conversion(): def test_parse_path_re_escape(): """Check that we escape regex characters in the path""" resolver = Resolver(route("/", None)) - assert resolver.parse_path("/a/{b:int}/c.d") == ( - # ^ regex character - re.compile(r"^/a/(?P\d+)/c\.d$"), - {"b": int}, - ) - - -def test_match_star_path(): - resolver = Resolver(route("/", None)) - assert resolver.parse_path("*") == (re.compile("^.*$"), {}) + assert resolver.parse_path("/a/{b:int}/c.d") == re.compile(r"^/a/(?P\d+)/c\.d$") + assert resolver.converter_mapping == {"b": int} From 03b8f2c2c0b7b63830b182dc295330a8afe0e1b8 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 4 Oct 2024 20:02:07 -0700 Subject: [PATCH 17/62] Fix router in local environment --- .editorconfig | 32 ++++++++++++++++++++++++++++++++ src/reactpy_router/core.py | 16 +++++----------- tests/conftest.py | 25 +++++++++++++++---------- 3 files changed, 52 insertions(+), 21 deletions(-) create mode 100644 .editorconfig diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..356385d --- /dev/null +++ b/.editorconfig @@ -0,0 +1,32 @@ +# http://editorconfig.org + +root = true + +[*] +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true +charset = utf-8 +end_of_line = lf + +[*.py] +indent_size = 4 +max_line_length = 120 + +[*.md] +indent_size = 4 + +[*.html] +max_line_length = off + +[*.js] +max_line_length = off + +[*.css] +indent_size = 4 +max_line_length = off + +# Tests can violate line width restrictions in the interest of clarity. +[**/test_*.py] +max_line_length = off diff --git a/src/reactpy_router/core.py b/src/reactpy_router/core.py index 4d8c0bd..2d7b002 100644 --- a/src/reactpy_router/core.py +++ b/src/reactpy_router/core.py @@ -74,7 +74,7 @@ def router_component( History( # type: ignore {"on_change": lambda event: set_location(Location(**event))} ), - route_elements, + html._(route_elements), value=Connection(old_conn.scope, location, old_conn.carrier), ) @@ -92,16 +92,14 @@ def link(*children: VdomChild, to: str, **attributes: Any) -> VdomDict: "onClick": lambda event: set_location(Location(**event)), "id": uuid, } - return html._( - html.a(attrs, *children), html.script(link_js_content.replace("UUID", uuid)) - ) + return html._(html.a(attrs, *children), html.script(link_js_content.replace("UUID", uuid))) def use_params() -> dict[str, Any]: """The `use_params` hook returns an object of key/value pairs of the dynamic params \ from the current URL that were matched by the `Route`. Child routes inherit all params \ from their parent routes. - + For example, if you have a `URL_PARAM` defined in the route `/example//`, this hook will return the URL_PARAM value that was matched.""" @@ -163,14 +161,10 @@ def _match_route( History = export( - module_from_file( - "reactpy-router", file=Path(__file__).parent / "static" / "bundle.js" - ), + module_from_file("reactpy-router", file=Path(__file__).parent / "static" / "bundle.js"), ("History"), ) -link_js_content = (Path(__file__).parent / "static" / "link.js").read_text( - encoding="utf-8" -) +link_js_content = (Path(__file__).parent / "static" / "link.js").read_text(encoding="utf-8") @dataclass diff --git a/tests/conftest.py b/tests/conftest.py index 573eba5..2240376 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,6 @@ +import asyncio +import sys + import pytest from playwright.async_api import async_playwright from reactpy.testing import BackendFixture, DisplayFixture @@ -5,27 +8,29 @@ def pytest_addoption(parser) -> None: parser.addoption( - "--headed", - dest="headed", - action="store_true", - help="Open a browser window when runnging web-based tests", + "--headless", + dest="headless", + action="store_false", + help="Hide the browser window when running web-based tests", ) @pytest.fixture async def display(backend, browser): - async with DisplayFixture(backend, browser) as display: - display.page.set_default_timeout(10000) - yield display + if sys.platform == "win32": + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + async with DisplayFixture(backend, browser) as display_fixture: + display_fixture.page.set_default_timeout(10000) + yield display_fixture @pytest.fixture async def backend(): - async with BackendFixture() as backend: - yield backend + async with BackendFixture() as backend_fixture: + yield backend_fixture @pytest.fixture async def browser(pytestconfig): async with async_playwright() as pw: - yield await pw.chromium.launch(headless=not bool(pytestconfig.option.headed)) + yield await pw.chromium.launch(headless=False) From 37760bb4ea6b0b8a4b404a9cd428ada9c3850d1e Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 5 Oct 2024 02:20:38 -0700 Subject: [PATCH 18/62] Fix link component --- docs/examples/python/nested-routes.py | 8 +++----- docs/examples/python/route-parameters.py | 8 +++----- src/reactpy_router/core.py | 15 ++++++++++++--- src/reactpy_router/static/link.js | 9 +++------ 4 files changed, 21 insertions(+), 19 deletions(-) diff --git a/docs/examples/python/nested-routes.py b/docs/examples/python/nested-routes.py index 6c949df..332adbb 100644 --- a/docs/examples/python/nested-routes.py +++ b/docs/examples/python/nested-routes.py @@ -1,6 +1,7 @@ from typing import TypedDict from reactpy import component, html, run + from reactpy_router import browser_router, link, route message_data: list["MessageDataType"] = [ @@ -40,10 +41,7 @@ def home(): @component def all_messages(): - last_messages = { - ", ".join(msg["with"]): msg - for msg in sorted(message_data, key=lambda m: m["id"]) - } + last_messages = {", ".join(msg["with"]): msg for msg in sorted(message_data, key=lambda m: m["id"])} return html.div( html.h1("All Messages đŸ’Ŧ"), html.ul( @@ -66,7 +64,7 @@ def all_messages(): @component def messages_with(*names): - messages = [msg for msg in message_data if set(msg["with"]) == names] + messages = [msg for msg in message_data if tuple(msg["with"]) == names] return html.div( html.h1(f"Messages with {', '.join(names)} đŸ’Ŧ"), html.ul( diff --git a/docs/examples/python/route-parameters.py b/docs/examples/python/route-parameters.py index 79d87b2..fdb120b 100644 --- a/docs/examples/python/route-parameters.py +++ b/docs/examples/python/route-parameters.py @@ -1,6 +1,7 @@ from typing import TypedDict from reactpy import component, html, run + from reactpy_router import browser_router, link, route from reactpy_router.core import use_params @@ -39,10 +40,7 @@ def home(): @component def all_messages(): - last_messages = { - ", ".join(msg["with"]): msg - for msg in sorted(message_data, key=lambda m: m["id"]) - } + last_messages = {", ".join(msg["with"]): msg for msg in sorted(message_data, key=lambda m: m["id"])} return html.div( html.h1("All Messages đŸ’Ŧ"), html.ul( @@ -66,7 +64,7 @@ def all_messages(): @component def messages_with(): names = set(use_params()["names"].split("-")) # and here we use the path param - messages = [msg for msg in message_data if set(msg["with"]) == names] + messages = [msg for msg in message_data if tuple(msg["with"]) == names] return html.div( html.h1(f"Messages with {', '.join(names)} đŸ’Ŧ"), html.ul( diff --git a/src/reactpy_router/core.py b/src/reactpy_router/core.py index 2d7b002..f412a95 100644 --- a/src/reactpy_router/core.py +++ b/src/reactpy_router/core.py @@ -83,13 +83,22 @@ def router_component( @component def link(*children: VdomChild, to: str, **attributes: Any) -> VdomDict: - """A component that renders a link to the given path""" + """A component that renders a link to the given path. + + FIXME: This currently works in a "dumb" way by trusting that ReactPy's script tag + properly sets the location. When a client-server communication layer is added to + ReactPy, this component will need to be rewritten to use that instead.""" set_location = _use_route_state().set_location uuid = uuid4().hex + + def on_click(_event: dict[str, Any]) -> None: + pathname, search = to.split("?", 1) if "?" in to else (to, "") + set_location(Location(pathname, search)) + attrs = { **attributes, - "to": to, - "onClick": lambda event: set_location(Location(**event)), + "href": to, + "onClick": on_click, "id": uuid, } return html._(html.a(attrs, *children), html.script(link_js_content.replace("UUID", uuid))) diff --git a/src/reactpy_router/static/link.js b/src/reactpy_router/static/link.js index b74e736..7fbfa17 100644 --- a/src/reactpy_router/static/link.js +++ b/src/reactpy_router/static/link.js @@ -1,8 +1,5 @@ document.getElementById("UUID").addEventListener("click", (event) => { - event.preventDefault(); - window.history.pushState({}, to, new URL(to, window.location)); - onClick({ - pathname: window.location.pathname, - search: window.location.search, - }); + event.preventDefault(); + let to = event.target.getAttribute("href"); + window.history.pushState({}, to, new URL(to, window.location)); }); From ee616d07b9ece838b56915aba0864466094d4b89 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 5 Oct 2024 02:39:43 -0700 Subject: [PATCH 19/62] Fix route parameters --- docs/examples/python/route-parameters.py | 2 +- src/reactpy_router/resolvers.py | 10 ++++------ 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/docs/examples/python/route-parameters.py b/docs/examples/python/route-parameters.py index fdb120b..629f033 100644 --- a/docs/examples/python/route-parameters.py +++ b/docs/examples/python/route-parameters.py @@ -63,7 +63,7 @@ def all_messages(): @component def messages_with(): - names = set(use_params()["names"].split("-")) # and here we use the path param + names = tuple(use_params()["names"].split("-")) # and here we use the path param messages = [msg for msg in message_data if tuple(msg["with"]) == names] return html.div( html.h1(f"Messages with {', '.join(names)} đŸ’Ŧ"), diff --git a/src/reactpy_router/resolvers.py b/src/reactpy_router/resolvers.py index cbec0bb..d4abaaf 100644 --- a/src/reactpy_router/resolvers.py +++ b/src/reactpy_router/resolvers.py @@ -43,9 +43,7 @@ def parse_path(self, path: str) -> re.Pattern[str]: try: conversion_info = self.registered_converters[param_type] except KeyError as e: - raise ValueError( - f"Unknown conversion type {param_type!r} in {path!r}" - ) from e + raise ValueError(f"Unknown conversion type {param_type!r} in {path!r}") from e # Add the string before the match to the pattern pattern += re.escape(path[last_match_end : match.start()]) @@ -69,9 +67,9 @@ def resolve(self, path: str) -> tuple[Any, dict[str, Any]] | None: if match: # Convert the matched groups to the correct types params = { - parameter_name.strip("_numeric_"): self.converter_mapping[ - parameter_name - ](value) + parameter_name[len("_numeric_") :] + if parameter_name.startswith("_numeric_") + else parameter_name: self.converter_mapping[parameter_name](value) for parameter_name, value in match.groupdict().items() } return (self.element, params) From 169e2dc3e4faaa3b30a85199b5770a1344fefacf Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 5 Oct 2024 02:48:17 -0700 Subject: [PATCH 20/62] remove black from workflow --- .github/workflows/test-docs.yml | 63 +++++++++++----------- docs/src/learn/routers-routes-and-links.md | 18 +++---- noxfile.py | 1 - pyproject.toml | 6 +-- requirements/check-style.txt | 1 - 5 files changed, 42 insertions(+), 47 deletions(-) diff --git a/.github/workflows/test-docs.yml b/.github/workflows/test-docs.yml index dbbdec6..2179d42 100644 --- a/.github/workflows/test-docs.yml +++ b/.github/workflows/test-docs.yml @@ -1,37 +1,36 @@ name: Test on: - push: - branches: - - main - pull_request: - branches: - - main - schedule: - - cron: "0 0 * * *" + push: + branches: + - main + pull_request: + branches: + - main + schedule: + - cron: "0 0 * * *" jobs: - docs: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - uses: actions/setup-python@v5 - with: - python-version: 3.x - - name: Check docs build - run: | - pip install -r requirements/build-docs.txt - linkcheckMarkdown docs/ -v -r - linkcheckMarkdown README.md -v -r - linkcheckMarkdown CHANGELOG.md -v -r - cd docs - mkdocs build --strict - - name: Check docs examples - run: | - pip install -r requirements/check-types.txt - pip install -r requirements/check-style.txt - mypy --show-error-codes docs/examples/python/ - black docs/examples/python/ --check - ruff check docs/examples/python/ + docs: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-python@v5 + with: + python-version: 3.x + - name: Check docs build + run: | + pip install -r requirements/build-docs.txt + linkcheckMarkdown docs/ -v -r + linkcheckMarkdown README.md -v -r + linkcheckMarkdown CHANGELOG.md -v -r + cd docs + mkdocs build --strict + - name: Check docs examples + run: | + pip install -r requirements/check-types.txt + pip install -r requirements/check-style.txt + mypy --show-error-codes docs/examples/python/ + ruff check docs/examples/python/ diff --git a/docs/src/learn/routers-routes-and-links.md b/docs/src/learn/routers-routes-and-links.md index 0144762..fd9ba9a 100644 --- a/docs/src/learn/routers-routes-and-links.md +++ b/docs/src/learn/routers-routes-and-links.md @@ -32,15 +32,15 @@ The syntax for declaring routes with the [`browser_router`][src.reactpy_router.b In this case, `#!python param` is the name of the route parameter and the optionally declared `#!python type` specifies what kind of parameter it is. The available parameter types and what patterns they match are are: -| Type | Pattern | -| --- | --- | -| `#!python int` | `#!python \d+` | -| `#!python str` (default) | `#!python [^/]+` | -| `#!python uuid` | `#!python [0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}` | -| `#!python slug` | `#!python [-a-zA-Z0-9_]+` | -| `#!python path` | `#!python .+` | -| `#!python float` | `#!python \d+(\.\d+)?` | -| `#!python any` | `#!python .*` | +| Type | Pattern | +| ------------------------ | ----------------------------------------------------------------------- | +| `#!python str` (default) | `#!python [^/]+` | +| `#!python int` | `#!python \d+` | +| `#!python float` | `#!python \d+(\.\d+)?` | +| `#!python uuid` | `#!python [0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}` | +| `#!python slug` | `#!python [-a-zA-Z0-9_]+` | +| `#!python path` | `#!python .+` | +| `#!python any` | `#!python .*` | So in practice these each might look like: diff --git a/noxfile.py b/noxfile.py index 1eebea2..6376072 100644 --- a/noxfile.py +++ b/noxfile.py @@ -33,7 +33,6 @@ def test_types(session: Session) -> None: @session(tags=["test"]) def test_style(session: Session) -> None: install_requirements_file(session, "check-style") - session.run("black", ".", "--check") session.run("ruff", "check", ".") diff --git a/pyproject.toml b/pyproject.toml index 763f3a0..d6a0110 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,11 +9,9 @@ warn_redundant_casts = true warn_unused_ignores = true check_untyped_defs = true -[tool.ruff.isort] -known-first-party = ["src", "tests"] - [tool.ruff] -ignore = ["E501"] +lint.ignore = ["E501"] +lint.isort.known-first-party = ["src", "tests"] extend-exclude = [".venv/*", ".eggs/*", ".nox/*", "build/*"] line-length = 120 diff --git a/requirements/check-style.txt b/requirements/check-style.txt index e4f6562..af3ee57 100644 --- a/requirements/check-style.txt +++ b/requirements/check-style.txt @@ -1,2 +1 @@ -black >=23,<24 ruff From 5ffc34f78c6f6c600a75a7a6aa6ff30a887a9480 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 5 Oct 2024 02:53:00 -0700 Subject: [PATCH 21/62] Format with ruff + prettier --- src/js/rollup.config.js | 38 +++++++++++++++++------------------ src/js/src/index.js | 40 ++++++++++++++++++------------------- src/reactpy_router/types.py | 8 ++------ 3 files changed, 41 insertions(+), 45 deletions(-) diff --git a/src/js/rollup.config.js b/src/js/rollup.config.js index 5d19a7c..396fd87 100644 --- a/src/js/rollup.config.js +++ b/src/js/rollup.config.js @@ -3,23 +3,23 @@ import commonjs from "rollup-plugin-commonjs"; import replace from "rollup-plugin-replace"; export default { - input: "src/index.js", - output: { - file: "../reactpy_router/static/bundle.js", - format: "esm", - }, - plugins: [ - resolve(), - commonjs(), - replace({ - "process.env.NODE_ENV": JSON.stringify("production"), - }), - ], - onwarn: function (warning) { - if (warning.code === "THIS_IS_UNDEFINED") { - // skip warning where `this` is undefined at the top level of a module - return; - } - console.warn(warning.message); - }, + input: "src/index.js", + output: { + file: "../reactpy_router/static/bundle.js", + format: "esm", + }, + plugins: [ + resolve(), + commonjs(), + replace({ + "process.env.NODE_ENV": JSON.stringify("production"), + }), + ], + onwarn: function (warning) { + if (warning.code === "THIS_IS_UNDEFINED") { + // skip warning where `this` is undefined at the top level of a module + return; + } + console.warn(warning.message); + }, }; diff --git a/src/js/src/index.js b/src/js/src/index.js index 500f98a..56b9f9f 100644 --- a/src/js/src/index.js +++ b/src/js/src/index.js @@ -2,27 +2,27 @@ import React from "react"; import ReactDOM from "react-dom"; export function bind(node) { - return { - create: (type, props, children) => - React.createElement(type, props, ...children), - render: (element) => { - ReactDOM.render(element, node); - }, - unmount: () => ReactDOM.unmountComponentAtNode(node), - }; + return { + create: (type, props, children) => + React.createElement(type, props, ...children), + render: (element) => { + ReactDOM.render(element, node); + }, + unmount: () => ReactDOM.unmountComponentAtNode(node), + }; } export function History({ onChange }) { - // Capture changes to the browser's history - React.useEffect(() => { - const listener = () => { - onChange({ - pathname: window.location.pathname, - search: window.location.search, - }); - }; - window.addEventListener("popstate", listener); - return () => window.removeEventListener("popstate", listener); - }); - return null; + // Capture changes to the browser's history + React.useEffect(() => { + const listener = () => { + onChange({ + pathname: window.location.pathname, + search: window.location.search, + }); + }; + window.addEventListener("popstate", listener); + return () => window.removeEventListener("popstate", listener); + }); + return null; } diff --git a/src/reactpy_router/types.py b/src/reactpy_router/types.py index c1300f2..4e5ee17 100644 --- a/src/reactpy_router/types.py +++ b/src/reactpy_router/types.py @@ -38,17 +38,13 @@ def __hash__(self) -> int: class Router(Protocol[R_contra]): """Return a component that renders the first matching route""" - def __call__( - self, *routes: R_contra, select: Literal["first", "all"] = "first" - ) -> ComponentType: - ... + def __call__(self, *routes: R_contra, select: Literal["first", "all"] = "first") -> ComponentType: ... class RouteCompiler(Protocol[R_contra]): """Compile a route into a resolver that can be matched against a path""" - def __call__(self, route: R_contra) -> RouteResolver: - ... + def __call__(self, route: R_contra) -> RouteResolver: ... class RouteResolver(Protocol): From 8b057b27b31b61b51f3e7a280a4a181f4f98f9b5 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 5 Oct 2024 03:02:43 -0700 Subject: [PATCH 22/62] Fix github actions --- docs/src/about/code.md | 6 ++---- tests/conftest.py | 7 +++++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/src/about/code.md b/docs/src/about/code.md index 5d73042..0eda9ee 100644 --- a/docs/src/about/code.md +++ b/docs/src/about/code.md @@ -40,12 +40,10 @@ By running the command below you can run the full test suite: nox -t test ``` -Or, if you want to run the tests in the foreground with a visible browser window, run: - - +Or, if you want to run the tests in the background run: ```bash linenums="0" -nox -t test -- --headed +nox -t test -- --headless ``` ## Creating a pull request diff --git a/tests/conftest.py b/tests/conftest.py index 2240376..54224ab 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,16 +1,19 @@ import asyncio +import os import sys import pytest from playwright.async_api import async_playwright from reactpy.testing import BackendFixture, DisplayFixture +GITHUB_ACTIONS = os.getenv("GITHUB_ACTIONS", "").lower() == "true" + def pytest_addoption(parser) -> None: parser.addoption( "--headless", dest="headless", - action="store_false", + action="store_true", help="Hide the browser window when running web-based tests", ) @@ -33,4 +36,4 @@ async def backend(): @pytest.fixture async def browser(pytestconfig): async with async_playwright() as pw: - yield await pw.chromium.launch(headless=False) + yield await pw.chromium.launch(headless=True if GITHUB_ACTIONS else pytestconfig.getoption("headless")) From db9e074f50902015c1b96017554aaa54ef8222c5 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 5 Oct 2024 12:47:46 -0700 Subject: [PATCH 23/62] Fix use_search_params --- .../python/{use-query.py => use-search-params.py} | 10 +++++++--- docs/src/learn/hooks.md | 4 ++-- src/reactpy_router/core.py | 2 ++ tests/test_core.py | 3 ++- 4 files changed, 13 insertions(+), 6 deletions(-) rename docs/examples/python/{use-query.py => use-search-params.py} (66%) diff --git a/docs/examples/python/use-query.py b/docs/examples/python/use-search-params.py similarity index 66% rename from docs/examples/python/use-query.py rename to docs/examples/python/use-search-params.py index 8ebbc14..b3fd0ec 100644 --- a/docs/examples/python/use-query.py +++ b/docs/examples/python/use-search-params.py @@ -1,11 +1,12 @@ -from reactpy import component, html +from reactpy import component, html, run + from reactpy_router import browser_router, link, route, use_search_params @component def search(): query = use_search_params() - return html.h1(f"Search Results for {query['q'][0]} 🔍") + return html._(html.h1(f"Search Results for {query['q'][0]} 🔍"), html.p("Nothing (yet).")) @component @@ -18,5 +19,8 @@ def root(): link("Search", to="/search?q=reactpy"), ), ), - route("/about", html.h1("About Page 📖")), + route("/search", search()), ) + + +run(root) diff --git a/docs/src/learn/hooks.md b/docs/src/learn/hooks.md index 208b7e1..d9d805a 100644 --- a/docs/src/learn/hooks.md +++ b/docs/src/learn/hooks.md @@ -6,14 +6,14 @@ Several pre-fabricated hooks are provided to help integrate with routing feature --- -## Use Query +## Use Search Params The [`use_search_params`][src.reactpy_router.use_search_params] hook can be used to access query parameters from the current location. It returns a dictionary of query parameters, where each value is a list of strings. === "components.py" ```python - {% include "../../examples/python/use-query.py" %} + {% include "../../examples/python/use-search-params.py" %} ``` ## Use Parameters diff --git a/src/reactpy_router/core.py b/src/reactpy_router/core.py index f412a95..b036f68 100644 --- a/src/reactpy_router/core.py +++ b/src/reactpy_router/core.py @@ -93,6 +93,8 @@ def link(*children: VdomChild, to: str, **attributes: Any) -> VdomDict: def on_click(_event: dict[str, Any]) -> None: pathname, search = to.split("?", 1) if "?" in to else (to, "") + if search: + search = f"?{search}" set_location(Location(pathname, search)) attrs = { diff --git a/tests/test_core.py b/tests/test_core.py index 29f4f4c..faa6256 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -2,6 +2,7 @@ from reactpy import Ref, component, html, use_location from reactpy.testing import DisplayFixture + from reactpy_router import browser_router, link, route, use_params, use_search_params @@ -136,7 +137,7 @@ def sample(): await display.page.wait_for_selector("#success") -async def test_use_query(display: DisplayFixture): +async def test_search_params(display: DisplayFixture): expected_query: dict[str, Any] = {} @component From 7947e919c0deed4bace76b0f70c2c644775b31cc Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 5 Oct 2024 12:49:23 -0700 Subject: [PATCH 24/62] Clean up use-params example --- docs/examples/python/use-params.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/examples/python/use-params.py b/docs/examples/python/use-params.py index c9c35a0..89de933 100644 --- a/docs/examples/python/use-params.py +++ b/docs/examples/python/use-params.py @@ -1,4 +1,5 @@ -from reactpy import component, html +from reactpy import component, html, run + from reactpy_router import browser_router, link, route, use_params @@ -20,3 +21,6 @@ def root(): ), route("/user/{id:int}", user()), ) + + +run(root) From d9a943a6f3e59ee3bc6214cfbaf46f8c99d3a622 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 5 Oct 2024 14:12:20 -0700 Subject: [PATCH 25/62] Remove select from top level router --- src/reactpy_router/__init__.py | 5 ++--- src/reactpy_router/core.py | 13 ++++++------- src/reactpy_router/types.py | 2 +- 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/src/reactpy_router/__init__.py b/src/reactpy_router/__init__.py index 0dc24d6..8f07f42 100644 --- a/src/reactpy_router/__init__.py +++ b/src/reactpy_router/__init__.py @@ -7,7 +7,7 @@ create_router, link, route, - router_component, + router, use_params, use_search_params, ) @@ -19,10 +19,9 @@ "create_router", "link", "route", - "routers", "Route", "RouteCompiler", - "router_component", + "router", "RouteResolver", "browser_router", "use_params", diff --git a/src/reactpy_router/core.py b/src/reactpy_router/core.py index b036f68..f99a833 100644 --- a/src/reactpy_router/core.py +++ b/src/reactpy_router/core.py @@ -38,16 +38,15 @@ def route(path: str, element: Any | None, *routes: Route) -> Route: def create_router(compiler: RouteCompiler[R]) -> Router[R]: """A decorator that turns a route compiler into a router""" - def wrapper(*routes: R, select: Literal["first", "all"] = "first") -> ComponentType: - return router_component(*routes, select=select, compiler=compiler) + def wrapper(*routes: R) -> ComponentType: + return router(*routes, compiler=compiler) return wrapper @component -def router_component( +def router( *routes: R, - select: Literal["first", "all"], compiler: RouteCompiler[R], ) -> VdomDict | None: """A component that renders matching route(s) using the given compiler function.""" @@ -60,7 +59,7 @@ def router_component( dependencies=(compiler, hash(routes)), ) - match = use_memo(lambda: _match_route(resolvers, location, select)) + match = use_memo(lambda: _match_route(resolvers, location, select="first")) if match: route_elements = [ @@ -86,8 +85,8 @@ def link(*children: VdomChild, to: str, **attributes: Any) -> VdomDict: """A component that renders a link to the given path. FIXME: This currently works in a "dumb" way by trusting that ReactPy's script tag - properly sets the location. When a client-server communication layer is added to - ReactPy, this component will need to be rewritten to use that instead.""" + properly sets the location. When a client-server communication layer is added to a + future ReactPy release, this component will need to be rewritten to use that instead.""" set_location = _use_route_state().set_location uuid = uuid4().hex diff --git a/src/reactpy_router/types.py b/src/reactpy_router/types.py index 4e5ee17..51f3188 100644 --- a/src/reactpy_router/types.py +++ b/src/reactpy_router/types.py @@ -38,7 +38,7 @@ def __hash__(self) -> int: class Router(Protocol[R_contra]): """Return a component that renders the first matching route""" - def __call__(self, *routes: R_contra, select: Literal["first", "all"] = "first") -> ComponentType: ... + def __call__(self, *routes: R_contra) -> ComponentType: ... class RouteCompiler(Protocol[R_contra]): From 94e9de5c2e40543a0be2e98353f11acf034fd876 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 5 Oct 2024 22:47:01 -0700 Subject: [PATCH 26/62] docs tweaks --- docs/mkdocs.yml | 4 ++-- docs/src/learn/routers-routes-and-links.md | 20 +++++++++---------- ...imple-application.md => your-first-app.md} | 2 +- 3 files changed, 13 insertions(+), 13 deletions(-) rename docs/src/learn/{simple-application.md => your-first-app.md} (95%) diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index d93b302..84d475d 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -1,8 +1,8 @@ --- nav: - - Get Started: + - Get Started: - Add ReactPy-Router to Your Project: index.md - - Your First Routed Application: learn/simple-application.md + - Your First Routed Application: learn/your-first-app.md - Advanced Topics: - Routers, Routes, and Links: learn/routers-routes-and-links.md - Hooks: learn/hooks.md diff --git a/docs/src/learn/routers-routes-and-links.md b/docs/src/learn/routers-routes-and-links.md index fd9ba9a..f8f7ed2 100644 --- a/docs/src/learn/routers-routes-and-links.md +++ b/docs/src/learn/routers-routes-and-links.md @@ -32,15 +32,15 @@ The syntax for declaring routes with the [`browser_router`][src.reactpy_router.b In this case, `#!python param` is the name of the route parameter and the optionally declared `#!python type` specifies what kind of parameter it is. The available parameter types and what patterns they match are are: -| Type | Pattern | -| ------------------------ | ----------------------------------------------------------------------- | -| `#!python str` (default) | `#!python [^/]+` | -| `#!python int` | `#!python \d+` | -| `#!python float` | `#!python \d+(\.\d+)?` | -| `#!python uuid` | `#!python [0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}` | -| `#!python slug` | `#!python [-a-zA-Z0-9_]+` | -| `#!python path` | `#!python .+` | -| `#!python any` | `#!python .*` | +| Type | Pattern | +| --- | --- | +| `#!python str` (default) | `#!python [^/]+` | +| `#!python int` | `#!python \d+` | +| `#!python float` | `#!python \d+(\.\d+)?` | +| `#!python uuid` | `#!python [0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}` | +| `#!python slug` | `#!python [-a-zA-Z0-9_]+` | +| `#!python path` | `#!python .+` | +| `#!python any` | `#!python .*` | So in practice these each might look like: @@ -52,7 +52,7 @@ So in practice these each might look like: /my/route/{param:path} ``` -Any route parameters collected from the current location then be accessed using the [`use_params`](#using-parameters) hook. +Any route parameters collected from the current location then be accessed using the [`use_params`](hooks.md#use-parameters) hook. !!! warning "Pitfall" diff --git a/docs/src/learn/simple-application.md b/docs/src/learn/your-first-app.md similarity index 95% rename from docs/src/learn/simple-application.md rename to docs/src/learn/your-first-app.md index 0dde0f2..8df24d6 100644 --- a/docs/src/learn/simple-application.md +++ b/docs/src/learn/your-first-app.md @@ -39,7 +39,7 @@ The first step is to create a basic router that will display the home page when {% include "../../examples/python/basic-routing.py" %} ``` -When navigating to [`http://127.0.0.1:8000``](http://127.0.0.1:8000) you should see `Home Page 🏠`. However, if you go to any other route you will instead see `Missing Link 🔗‍đŸ’Ĩ`. +When navigating to [`http://127.0.0.1:8000`](http://127.0.0.1:8000) you should see `Home Page 🏠`. However, if you go to any other route you will instead see `Missing Link 🔗‍đŸ’Ĩ`. With this foundation you can start adding more routes. From afb97c8f4838195e65c7a4ab2a6ccc37e765755b Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 5 Oct 2024 22:47:11 -0700 Subject: [PATCH 27/62] js tweaks --- .prettierrc | 4 ++++ src/js/src/index.js | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 .prettierrc diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..32ad81f --- /dev/null +++ b/.prettierrc @@ -0,0 +1,4 @@ +{ + "proseWrap": "never", + "trailingComma": "all" +} diff --git a/src/js/src/index.js b/src/js/src/index.js index 56b9f9f..348f5d0 100644 --- a/src/js/src/index.js +++ b/src/js/src/index.js @@ -13,7 +13,8 @@ export function bind(node) { } export function History({ onChange }) { - // Capture changes to the browser's history + // Capture browser "history go back" action and tell the server about it + // Note: Browsers do not allow you to detect "history go forward" actions. React.useEffect(() => { const listener = () => { onChange({ From 78c7c4966d4b1819b1594496898a4d37989af337 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 5 Oct 2024 23:43:23 -0700 Subject: [PATCH 28/62] Refactor some more docs --- docs/mkdocs.yml | 240 +++++++++++++++---------------- docs/src/learn/hooks.md | 2 +- docs/src/reference/components.md | 4 + docs/src/reference/core.md | 1 - docs/src/reference/hooks.md | 4 + docs/src/reference/router.md | 5 +- src/reactpy_router/__init__.py | 2 - src/reactpy_router/core.py | 24 ++-- src/reactpy_router/routers.py | 2 +- src/reactpy_router/types.py | 28 ++-- 10 files changed, 156 insertions(+), 156 deletions(-) create mode 100644 docs/src/reference/components.md delete mode 100644 docs/src/reference/core.md create mode 100644 docs/src/reference/hooks.md diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 84d475d..9fc7441 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -1,147 +1,137 @@ --- nav: - - Get Started: - - Add ReactPy-Router to Your Project: index.md - - Your First Routed Application: learn/your-first-app.md - - Advanced Topics: - - Routers, Routes, and Links: learn/routers-routes-and-links.md - - Hooks: learn/hooks.md - - Creating a Custom Router 🚧: learn/custom-router.md - - Reference: - - Core: reference/core.md - - Router: reference/router.md - - Types: reference/types.md - - About: - - Changelog: about/changelog.md - - Contributor Guide: - - Code: about/code.md - - Docs: about/docs.md - - Community: - - GitHub Discussions: https://github.com/reactive-python/reactpy-router/discussions - - Discord: https://discord.gg/uNb5P4hA9X - - Reddit: https://www.reddit.com/r/ReactPy/ - - License: about/license.md + - Get Started: + - Add ReactPy-Router to Your Project: index.md + - Your First Routed Application: learn/your-first-app.md + - Advanced Topics: + - Routers, Routes, and Links: learn/routers-routes-and-links.md + - Hooks: learn/hooks.md + - Creating a Custom Router 🚧: learn/custom-router.md + - Reference: + - Router Components: reference/router.md + - Components: reference/components.md + - Hooks: reference/hooks.md + - Types: reference/types.md + - About: + - Changelog: about/changelog.md + - Contributor Guide: + - Code: about/code.md + - Docs: about/docs.md + - Community: + - GitHub Discussions: https://github.com/reactive-python/reactpy-router/discussions + - Discord: https://discord.gg/uNb5P4hA9X + - Reddit: https://www.reddit.com/r/ReactPy/ + - License: about/license.md theme: - name: material - custom_dir: overrides - palette: - - media: "(prefers-color-scheme: dark)" - scheme: slate - toggle: - icon: material/white-balance-sunny - name: Switch to light mode - primary: red # We use red to indicate that something is unthemed - accent: red - - media: "(prefers-color-scheme: light)" - scheme: default - toggle: - icon: material/weather-night - name: Switch to dark mode - primary: white - accent: red - features: - - navigation.instant - - navigation.tabs - - navigation.tabs.sticky - - navigation.top - - content.code.copy - - search.highlight - icon: - repo: fontawesome/brands/github - admonition: - note: fontawesome/solid/note-sticky - logo: https://raw.githubusercontent.com/reactive-python/reactpy/main/branding/svg/reactpy-logo-square.svg - favicon: https://raw.githubusercontent.com/reactive-python/reactpy/main/branding/svg/reactpy-logo-square.svg + name: material + custom_dir: overrides + palette: + - media: "(prefers-color-scheme: dark)" + scheme: slate + toggle: + icon: material/white-balance-sunny + name: Switch to light mode + primary: red # We use red to indicate that something is unthemed + accent: red + - media: "(prefers-color-scheme: light)" + scheme: default + toggle: + icon: material/weather-night + name: Switch to dark mode + primary: white + accent: red + features: + - navigation.instant + - navigation.tabs + - navigation.tabs.sticky + - navigation.top + - content.code.copy + - search.highlight + icon: + repo: fontawesome/brands/github + admonition: + note: fontawesome/solid/note-sticky + logo: https://raw.githubusercontent.com/reactive-python/reactpy/main/branding/svg/reactpy-logo-square.svg + favicon: https://raw.githubusercontent.com/reactive-python/reactpy/main/branding/svg/reactpy-logo-square.svg markdown_extensions: - - toc: - permalink: true - - pymdownx.emoji: - emoji_index: !!python/name:material.extensions.emoji.twemoji - emoji_generator: !!python/name:material.extensions.emoji.to_svg - - pymdownx.tabbed: - alternate_style: true - - pymdownx.highlight: - linenums: true - - pymdownx.superfences - - pymdownx.details - - pymdownx.inlinehilite - - admonition - - attr_list - - md_in_html - - pymdownx.keys + - toc: + permalink: true + - pymdownx.emoji: + emoji_index: !!python/name:material.extensions.emoji.twemoji + emoji_generator: !!python/name:material.extensions.emoji.to_svg + - pymdownx.tabbed: + alternate_style: true + - pymdownx.highlight: + linenums: true + - pymdownx.superfences + - pymdownx.details + - pymdownx.inlinehilite + - admonition + - attr_list + - md_in_html + - pymdownx.keys plugins: - - search - - include-markdown - - git-authors - - minify: - minify_html: true - minify_js: true - minify_css: true - cache_safe: true - - git-revision-date-localized: - fallback_to_build_date: true - - spellcheck: - known_words: dictionary.txt - allow_unicode: no - ignore_code: yes - skip_files: - - "index.md" - - "reference\\core.md" - - "reference/core.md" - - "reference\\types.md" - - "reference/types.md" - - mkdocstrings: - default_handler: python - handlers: - python: - paths: ["../"] - import: - - https://reactpy.dev/docs/objects.inv - - https://installer.readthedocs.io/en/stable/objects.inv - + - search + - include-markdown + - git-authors + - minify: + minify_html: true + minify_js: true + minify_css: true + cache_safe: true + - git-revision-date-localized: + fallback_to_build_date: true + - spellcheck: + known_words: dictionary.txt + allow_unicode: no + - mkdocstrings: + default_handler: python + handlers: + python: + paths: ["../"] + import: + - https://reactpy.dev/docs/objects.inv + - https://installer.readthedocs.io/en/stable/objects.inv + options: + show_root_heading: true + show_bases: false extra: - generator: false - version: - provider: mike - analytics: - provider: google - property: G-XRLQYZBG00 + generator: false + version: + provider: mike + analytics: + provider: google + property: G-XRLQYZBG00 extra_javascript: - - assets/js/main.js + - assets/js/main.js extra_css: - - assets/css/main.css - - assets/css/button.css - - assets/css/admonition.css - - assets/css/banner.css - - assets/css/sidebar.css - - assets/css/navbar.css - - assets/css/table-of-contents.css - - assets/css/code.css - - assets/css/footer.css - - assets/css/home.css + - assets/css/main.css + - assets/css/button.css + - assets/css/admonition.css + - assets/css/banner.css + - assets/css/sidebar.css + - assets/css/navbar.css + - assets/css/table-of-contents.css + - assets/css/code.css + - assets/css/footer.css + - assets/css/home.css watch: - - "../docs" - - ../README.md - - ../CHANGELOG.md - - ../LICENSE.md - - "../src" + - "../docs" + - ../README.md + - ../CHANGELOG.md + - ../LICENSE.md + - "../src" site_name: ReactPy Router site_author: Archmonger site_description: It's React-Router, but in Python. -copyright: '© -
- -Reactive Python and affiliates. -' +copyright: '©
Reactive Python and affiliates.' repo_url: https://github.com/reactive-python/reactpy-router site_url: https://reactive-python.github.io/reactpy-router repo_name: ReactPy Router (GitHub) diff --git a/docs/src/learn/hooks.md b/docs/src/learn/hooks.md index d9d805a..619d26c 100644 --- a/docs/src/learn/hooks.md +++ b/docs/src/learn/hooks.md @@ -6,7 +6,7 @@ Several pre-fabricated hooks are provided to help integrate with routing feature --- -## Use Search Params +## Use Search Parameters The [`use_search_params`][src.reactpy_router.use_search_params] hook can be used to access query parameters from the current location. It returns a dictionary of query parameters, where each value is a list of strings. diff --git a/docs/src/reference/components.md b/docs/src/reference/components.md new file mode 100644 index 0000000..704947c --- /dev/null +++ b/docs/src/reference/components.md @@ -0,0 +1,4 @@ +::: src.reactpy_router + + options: + members: ["route", "link"] diff --git a/docs/src/reference/core.md b/docs/src/reference/core.md deleted file mode 100644 index 26cf9e5..0000000 --- a/docs/src/reference/core.md +++ /dev/null @@ -1 +0,0 @@ -::: src.reactpy_router.core diff --git a/docs/src/reference/hooks.md b/docs/src/reference/hooks.md new file mode 100644 index 0000000..ab6a036 --- /dev/null +++ b/docs/src/reference/hooks.md @@ -0,0 +1,4 @@ +::: src.reactpy_router + + options: + members: ["use_params", "use_search_params"] diff --git a/docs/src/reference/router.md b/docs/src/reference/router.md index 148b0c5..7463e1a 100644 --- a/docs/src/reference/router.md +++ b/docs/src/reference/router.md @@ -1 +1,4 @@ -::: src.reactpy_router.browser_router +::: src.reactpy_router + + options: + members: ["browser_router"] diff --git a/src/reactpy_router/__init__.py b/src/reactpy_router/__init__.py index 8f07f42..065c301 100644 --- a/src/reactpy_router/__init__.py +++ b/src/reactpy_router/__init__.py @@ -7,7 +7,6 @@ create_router, link, route, - router, use_params, use_search_params, ) @@ -21,7 +20,6 @@ "route", "Route", "RouteCompiler", - "router", "RouteResolver", "browser_router", "use_params", diff --git a/src/reactpy_router/core.py b/src/reactpy_router/core.py index f99a833..58d05b0 100644 --- a/src/reactpy_router/core.py +++ b/src/reactpy_router/core.py @@ -31,7 +31,7 @@ def route(path: str, element: Any | None, *routes: Route) -> Route: - """Create a route with the given path, element, and child routes""" + """Create a route with the given path, element, and child routes.""" return Route(path, element, routes) @@ -49,7 +49,10 @@ def router( *routes: R, compiler: RouteCompiler[R], ) -> VdomDict | None: - """A component that renders matching route(s) using the given compiler function.""" + """A component that renders matching route(s) using the given compiler function. + + This typically should never be used by a user. Instead, use `create_router` if creating + a custom routing engine.""" old_conn = use_connection() location, set_location = use_state(old_conn.location) @@ -82,11 +85,10 @@ def router( @component def link(*children: VdomChild, to: str, **attributes: Any) -> VdomDict: - """A component that renders a link to the given path. - - FIXME: This currently works in a "dumb" way by trusting that ReactPy's script tag - properly sets the location. When a client-server communication layer is added to a - future ReactPy release, this component will need to be rewritten to use that instead.""" + """A component that renders a link to the given path.""" + # FIXME: This currently works in a "dumb" way by trusting that ReactPy's script tag \ + # properly sets the location. When a client-server communication layer is added to a \ + # future ReactPy release, this component will need to be rewritten to use that instead. \ set_location = _use_route_state().set_location uuid = uuid4().hex @@ -106,8 +108,8 @@ def on_click(_event: dict[str, Any]) -> None: def use_params() -> dict[str, Any]: - """The `use_params` hook returns an object of key/value pairs of the dynamic params \ - from the current URL that were matched by the `Route`. Child routes inherit all params \ + """The `use_params` hook returns an object of key/value pairs of the dynamic parameters \ + from the current URL that were matched by the `Route`. Child routes inherit all parameters \ from their parent routes. For example, if you have a `URL_PARAM` defined in the route `/example//`, @@ -126,8 +128,8 @@ def use_search_params( ) -> dict[str, list[str]]: """ The `use_search_params` hook is used to read and modify the query string in the URL \ - for the current location. Like React's own `use_state` hook, `use_search_params returns \ - an array of two values: the current location's search params and a function that may \ + for the current location. Like React's own `use_state` hook, `use_search_params` returns \ + an array of two values: the current location's search parameters and a function that may \ be used to update them. See `urllib.parse.parse_qs` for info on this hook's parameters.""" diff --git a/src/reactpy_router/routers.py b/src/reactpy_router/routers.py index 9ba8f39..d5ed0e1 100644 --- a/src/reactpy_router/routers.py +++ b/src/reactpy_router/routers.py @@ -1,4 +1,4 @@ -"""A simple router implementation for ReactPy""" +"""URL router implementation for ReactPy""" from __future__ import annotations diff --git a/src/reactpy_router/types.py b/src/reactpy_router/types.py index 51f3188..0801466 100644 --- a/src/reactpy_router/types.py +++ b/src/reactpy_router/types.py @@ -1,9 +1,9 @@ -"""Types for reactpy_router""" +"""Type definitions for the `reactpy-router` package.""" from __future__ import annotations from dataclasses import dataclass, field -from typing import Any, Callable, Literal, Sequence, TypedDict, TypeVar +from typing import Any, Callable, Sequence, TypedDict, TypeVar from reactpy.core.vdom import is_vdom from reactpy.types import ComponentType, Key @@ -15,16 +15,16 @@ @dataclass(frozen=True) class Route: - """A route that can be matched against a path""" + """A route that can be matched against a path.""" path: str - """The path to match against""" + """The path to match against.""" element: Any = field(hash=False) - """The element to render if the path matches""" + """The element to render if the path matches.""" routes: Sequence[Self] - """Child routes""" + """Child routes.""" def __hash__(self) -> int: el = self.element @@ -36,32 +36,32 @@ def __hash__(self) -> int: class Router(Protocol[R_contra]): - """Return a component that renders the first matching route""" + """Return a component that renders the first matching route.""" def __call__(self, *routes: R_contra) -> ComponentType: ... class RouteCompiler(Protocol[R_contra]): - """Compile a route into a resolver that can be matched against a path""" + """Compile a route into a resolver that can be matched against a path.""" def __call__(self, route: R_contra) -> RouteResolver: ... class RouteResolver(Protocol): - """A compiled route that can be matched against a path""" + """A compiled route that can be matched against a path.""" @property def key(self) -> Key: - """Uniquely identified this resolver""" + """Uniquely identified this resolver.""" def resolve(self, path: str) -> tuple[Any, dict[str, Any]] | None: - """Return the path's associated element and path params or None""" + """Return the path's associated element and path parameters or None.""" class ConversionInfo(TypedDict): - """Information about a conversion type""" + """Information about a conversion type.""" regex: str - """The regex to match the conversion type""" + """The regex to match the conversion type.""" func: ConversionFunc - """The function to convert the matched string to the expected type""" + """The function to convert the matched string to the expected type.""" From 1b8031bd6aef6c058dc5c4d8be582f6f3fbeb473 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 6 Oct 2024 00:09:35 -0700 Subject: [PATCH 29/62] Better mkdocstrings --- .github/workflows/test-docs.yml | 9 ++++++--- docs/examples/python/route-parameters.py | 3 +-- docs/mkdocs.yml | 2 +- docs/src/learn/hooks.md | 4 ++-- docs/src/learn/routers-routes-and-links.md | 8 ++++---- docs/src/learn/your-first-app.md | 2 +- docs/src/reference/components.md | 2 +- docs/src/reference/hooks.md | 2 +- docs/src/reference/router.md | 2 +- docs/src/reference/types.md | 2 +- 10 files changed, 19 insertions(+), 17 deletions(-) diff --git a/.github/workflows/test-docs.yml b/.github/workflows/test-docs.yml index 2179d42..7110bc4 100644 --- a/.github/workflows/test-docs.yml +++ b/.github/workflows/test-docs.yml @@ -20,9 +20,14 @@ jobs: - uses: actions/setup-python@v5 with: python-version: 3.x - - name: Check docs build + - name: Install Python Dependencies run: | pip install -r requirements/build-docs.txt + pip install -r requirements/check-types.txt + pip install -r requirements/check-style.txt + pip install -e . + - name: Check docs build + run: | linkcheckMarkdown docs/ -v -r linkcheckMarkdown README.md -v -r linkcheckMarkdown CHANGELOG.md -v -r @@ -30,7 +35,5 @@ jobs: mkdocs build --strict - name: Check docs examples run: | - pip install -r requirements/check-types.txt - pip install -r requirements/check-style.txt mypy --show-error-codes docs/examples/python/ ruff check docs/examples/python/ diff --git a/docs/examples/python/route-parameters.py b/docs/examples/python/route-parameters.py index 629f033..a2e6707 100644 --- a/docs/examples/python/route-parameters.py +++ b/docs/examples/python/route-parameters.py @@ -2,8 +2,7 @@ from reactpy import component, html, run -from reactpy_router import browser_router, link, route -from reactpy_router.core import use_params +from reactpy_router import browser_router, link, route, use_params message_data: list["MessageDataType"] = [ {"id": 1, "with": ["Alice"], "from": None, "message": "Hello!"}, diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 9fc7441..093f296 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -96,8 +96,8 @@ plugins: - https://reactpy.dev/docs/objects.inv - https://installer.readthedocs.io/en/stable/objects.inv options: - show_root_heading: true show_bases: false + show_root_members_full_path: true extra: generator: false version: diff --git a/docs/src/learn/hooks.md b/docs/src/learn/hooks.md index 619d26c..7ed3821 100644 --- a/docs/src/learn/hooks.md +++ b/docs/src/learn/hooks.md @@ -8,7 +8,7 @@ Several pre-fabricated hooks are provided to help integrate with routing feature ## Use Search Parameters -The [`use_search_params`][src.reactpy_router.use_search_params] hook can be used to access query parameters from the current location. It returns a dictionary of query parameters, where each value is a list of strings. +The [`use_search_params`][reactpy_router.use_search_params] hook can be used to access query parameters from the current location. It returns a dictionary of query parameters, where each value is a list of strings. === "components.py" @@ -18,7 +18,7 @@ The [`use_search_params`][src.reactpy_router.use_search_params] hook can be used ## Use Parameters -The [`use_params`][src.reactpy_router.use_params] hook can be used to access route parameters from the current location. It returns a dictionary of route parameters, where each value is mapped to a value that matches the type specified in the route path. +The [`use_params`][reactpy_router.use_params] hook can be used to access route parameters from the current location. It returns a dictionary of route parameters, where each value is mapped to a value that matches the type specified in the route path. === "components.py" diff --git a/docs/src/learn/routers-routes-and-links.md b/docs/src/learn/routers-routes-and-links.md index f8f7ed2..f0abfc1 100644 --- a/docs/src/learn/routers-routes-and-links.md +++ b/docs/src/learn/routers-routes-and-links.md @@ -4,7 +4,7 @@ We include built-in components that automatically handle routing, which enable S ## Routers and Routes -The [`browser_router`][src.reactpy_router.browser_router] component is one possible implementation of a [Router][src.reactpy_router.types.Router]. Routers takes a series of [route][src.reactpy_router.route] objects as positional arguments and render whatever element matches the current location. +The [`browser_router`][reactpy_router.browser_router] component is one possible implementation of a [Router][reactpy_router.types.Router]. Routers takes a series of [route][reactpy_router.route] objects as positional arguments and render whatever element matches the current location. !!! abstract "Note" @@ -23,7 +23,7 @@ Here we'll note some special syntax in the route path for the second route. The ### Browser Router -The syntax for declaring routes with the [`browser_router`][src.reactpy_router.browser_router] is very similar to the syntax used by [`starlette`](https://www.starlette.io/routing/) (a popular Python web framework). As such route parameters are declared using the following syntax: +The syntax for declaring routes with the [`browser_router`][reactpy_router.browser_router] is very similar to the syntax used by [`starlette`](https://www.starlette.io/routing/) (a popular Python web framework). As such route parameters are declared using the following syntax: ```python linenums="0" /my/route/{param} @@ -56,11 +56,11 @@ Any route parameters collected from the current location then be accessed using !!! warning "Pitfall" - While it is possible to use route parameters to capture values from query strings (such as `#!python /my/route/?foo={bar}`), this is not recommended. Instead, you should use the [`use_search_params`][src.reactpy_router.use_search_params] hook to access query string values. + While it is possible to use route parameters to capture values from query strings (such as `#!python /my/route/?foo={bar}`), this is not recommended. Instead, you should use the [`use_search_params`][reactpy_router.use_search_params] hook to access query string values. ## Route Links -Links between routes should be created using the [link][src.reactpy_router.link] component. This will allow ReactPy to handle the transition between routes and avoid a page reload. +Links between routes should be created using the [link][reactpy_router.link] component. This will allow ReactPy to handle the transition between routes and avoid a page reload. === "components.py" diff --git a/docs/src/learn/your-first-app.md b/docs/src/learn/your-first-app.md index 8df24d6..4b67677 100644 --- a/docs/src/learn/your-first-app.md +++ b/docs/src/learn/your-first-app.md @@ -1,6 +1,6 @@

-Here you'll learn the various features of `reactpy-router` and how to use them. These examples will utilize the [`reactpy_router.browser_router`][src.reactpy_router.browser_router]. +Here you'll learn the various features of `reactpy-router` and how to use them. These examples will utilize the [`reactpy_router.browser_router`][reactpy_router.browser_router].

diff --git a/docs/src/reference/components.md b/docs/src/reference/components.md index 704947c..f1cc570 100644 --- a/docs/src/reference/components.md +++ b/docs/src/reference/components.md @@ -1,4 +1,4 @@ -::: src.reactpy_router +::: reactpy_router options: members: ["route", "link"] diff --git a/docs/src/reference/hooks.md b/docs/src/reference/hooks.md index ab6a036..d3cfa18 100644 --- a/docs/src/reference/hooks.md +++ b/docs/src/reference/hooks.md @@ -1,4 +1,4 @@ -::: src.reactpy_router +::: reactpy_router options: members: ["use_params", "use_search_params"] diff --git a/docs/src/reference/router.md b/docs/src/reference/router.md index 7463e1a..5700cf5 100644 --- a/docs/src/reference/router.md +++ b/docs/src/reference/router.md @@ -1,4 +1,4 @@ -::: src.reactpy_router +::: reactpy_router options: members: ["browser_router"] diff --git a/docs/src/reference/types.md b/docs/src/reference/types.md index 0482432..204bee7 100644 --- a/docs/src/reference/types.md +++ b/docs/src/reference/types.md @@ -1 +1 @@ -::: src.reactpy_router.types +::: reactpy_router.types From f52aaa3fe1f07570aedbdeee952ed8707377a7db Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 6 Oct 2024 00:11:07 -0700 Subject: [PATCH 30/62] Remove core.py --- src/reactpy_router/__init__.py | 19 +-- src/reactpy_router/components.py | 48 ++++++++ src/reactpy_router/core.py | 199 ------------------------------- src/reactpy_router/hooks.py | 71 +++++++++++ src/reactpy_router/resolvers.py | 9 +- src/reactpy_router/routers.py | 102 +++++++++++++++- tests/test_resolver.py | 19 ++- 7 files changed, 233 insertions(+), 234 deletions(-) create mode 100644 src/reactpy_router/components.py delete mode 100644 src/reactpy_router/core.py create mode 100644 src/reactpy_router/hooks.py diff --git a/src/reactpy_router/__init__.py b/src/reactpy_router/__init__.py index 065c301..fa2781f 100644 --- a/src/reactpy_router/__init__.py +++ b/src/reactpy_router/__init__.py @@ -2,28 +2,15 @@ __version__ = "0.1.1" -from .converters import CONVERTERS -from .core import ( - create_router, - link, - route, - use_params, - use_search_params, -) -from .resolvers import Resolver -from .routers import browser_router -from .types import Route, RouteCompiler, RouteResolver +from .components import link, route +from .hooks import use_params, use_search_params +from .routers import browser_router, create_router __all__ = ( "create_router", "link", "route", - "Route", - "RouteCompiler", - "RouteResolver", "browser_router", "use_params", "use_search_params", - "Resolver", - "CONVERTERS", ) diff --git a/src/reactpy_router/components.py b/src/reactpy_router/components.py new file mode 100644 index 0000000..10d0dce --- /dev/null +++ b/src/reactpy_router/components.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any +from uuid import uuid4 + +from reactpy import component, html +from reactpy.backend.types import Location +from reactpy.core.types import VdomChild, VdomDict +from reactpy.web.module import export, module_from_file + +from reactpy_router.hooks import _use_route_state +from reactpy_router.types import Route + +History = export( + module_from_file("reactpy-router", file=Path(__file__).parent / "static" / "bundle.js"), + ("History"), +) +link_js_content = (Path(__file__).parent / "static" / "link.js").read_text(encoding="utf-8") + + +@component +def link(*children: VdomChild, to: str, **attributes: Any) -> VdomDict: + """A component that renders a link to the given path.""" + # FIXME: This currently works in a "dumb" way by trusting that ReactPy's script tag \ + # properly sets the location. When a client-server communication layer is added to a \ + # future ReactPy release, this component will need to be rewritten to use that instead. \ + set_location = _use_route_state().set_location + uuid = uuid4().hex + + def on_click(_event: dict[str, Any]) -> None: + pathname, search = to.split("?", 1) if "?" in to else (to, "") + if search: + search = f"?{search}" + set_location(Location(pathname, search)) + + attrs = { + **attributes, + "href": to, + "onClick": on_click, + "id": uuid, + } + return html._(html.a(attrs, *children), html.script(link_js_content.replace("UUID", uuid))) + + +def route(path: str, element: Any | None, *routes: Route) -> Route: + """Create a route with the given path, element, and child routes.""" + return Route(path, element, routes) diff --git a/src/reactpy_router/core.py b/src/reactpy_router/core.py deleted file mode 100644 index 58d05b0..0000000 --- a/src/reactpy_router/core.py +++ /dev/null @@ -1,199 +0,0 @@ -"""Core functionality for the reactpy-router package.""" - -from __future__ import annotations - -from dataclasses import dataclass, replace -from logging import getLogger -from pathlib import Path -from typing import Any, Callable, Iterator, Literal, Sequence, TypeVar -from urllib.parse import parse_qs -from uuid import uuid4 - -from reactpy import ( - component, - create_context, - html, - use_context, - use_location, - use_memo, - use_state, -) -from reactpy.backend.hooks import ConnectionContext, use_connection -from reactpy.backend.types import Connection, Location -from reactpy.core.types import VdomChild, VdomDict -from reactpy.types import ComponentType, Context -from reactpy.web.module import export, module_from_file - -from reactpy_router.types import Route, RouteCompiler, Router, RouteResolver - -_logger = getLogger(__name__) -R = TypeVar("R", bound=Route) - - -def route(path: str, element: Any | None, *routes: Route) -> Route: - """Create a route with the given path, element, and child routes.""" - return Route(path, element, routes) - - -def create_router(compiler: RouteCompiler[R]) -> Router[R]: - """A decorator that turns a route compiler into a router""" - - def wrapper(*routes: R) -> ComponentType: - return router(*routes, compiler=compiler) - - return wrapper - - -@component -def router( - *routes: R, - compiler: RouteCompiler[R], -) -> VdomDict | None: - """A component that renders matching route(s) using the given compiler function. - - This typically should never be used by a user. Instead, use `create_router` if creating - a custom routing engine.""" - - old_conn = use_connection() - location, set_location = use_state(old_conn.location) - - resolvers = use_memo( - lambda: tuple(map(compiler, _iter_routes(routes))), - dependencies=(compiler, hash(routes)), - ) - - match = use_memo(lambda: _match_route(resolvers, location, select="first")) - - if match: - route_elements = [ - _route_state_context( - element, - value=_RouteState(set_location, params), - ) - for element, params in match - ] - return ConnectionContext( - History( # type: ignore - {"on_change": lambda event: set_location(Location(**event))} - ), - html._(route_elements), - value=Connection(old_conn.scope, location, old_conn.carrier), - ) - - return None - - -@component -def link(*children: VdomChild, to: str, **attributes: Any) -> VdomDict: - """A component that renders a link to the given path.""" - # FIXME: This currently works in a "dumb" way by trusting that ReactPy's script tag \ - # properly sets the location. When a client-server communication layer is added to a \ - # future ReactPy release, this component will need to be rewritten to use that instead. \ - set_location = _use_route_state().set_location - uuid = uuid4().hex - - def on_click(_event: dict[str, Any]) -> None: - pathname, search = to.split("?", 1) if "?" in to else (to, "") - if search: - search = f"?{search}" - set_location(Location(pathname, search)) - - attrs = { - **attributes, - "href": to, - "onClick": on_click, - "id": uuid, - } - return html._(html.a(attrs, *children), html.script(link_js_content.replace("UUID", uuid))) - - -def use_params() -> dict[str, Any]: - """The `use_params` hook returns an object of key/value pairs of the dynamic parameters \ - from the current URL that were matched by the `Route`. Child routes inherit all parameters \ - from their parent routes. - - For example, if you have a `URL_PARAM` defined in the route `/example//`, - this hook will return the URL_PARAM value that was matched.""" - - # TODO: Check if this returns all parent params - return _use_route_state().params - - -def use_search_params( - keep_blank_values: bool = False, - strict_parsing: bool = False, - errors: str = "replace", - max_num_fields: int | None = None, - separator: str = "&", -) -> dict[str, list[str]]: - """ - The `use_search_params` hook is used to read and modify the query string in the URL \ - for the current location. Like React's own `use_state` hook, `use_search_params` returns \ - an array of two values: the current location's search parameters and a function that may \ - be used to update them. - - See `urllib.parse.parse_qs` for info on this hook's parameters.""" - - # FIXME: This needs to return a tuple of the search params and a function to update them - return parse_qs( - use_location().search[1:], - keep_blank_values=keep_blank_values, - strict_parsing=strict_parsing, - errors=errors, - max_num_fields=max_num_fields, - separator=separator, - ) - - -def _iter_routes(routes: Sequence[R]) -> Iterator[R]: - for parent in routes: - for child in _iter_routes(parent.routes): - yield replace(child, path=parent.path + child.path) # type: ignore[misc] - yield parent - - -def _match_route( - compiled_routes: Sequence[RouteResolver], - location: Location, - select: Literal["first", "all"], -) -> list[tuple[Any, dict[str, Any]]]: - matches = [] - - for resolver in compiled_routes: - match = resolver.resolve(location.pathname) - if match is not None: - if select == "first": - return [match] - matches.append(match) - - if not matches: - _logger.debug("No matching route found for %s", location.pathname) - - return matches - - -History = export( - module_from_file("reactpy-router", file=Path(__file__).parent / "static" / "bundle.js"), - ("History"), -) -link_js_content = (Path(__file__).parent / "static" / "link.js").read_text(encoding="utf-8") - - -@dataclass -class _RouteState: - set_location: Callable[[Location], None] - params: dict[str, Any] - - -def _use_route_state() -> _RouteState: - route_state = use_context(_route_state_context) - if route_state is None: - raise RuntimeError( - "ReactPy-Router was unable to find a route context. Are you " - "sure this hook/component is being called within a router?" - ) - - return route_state - - -_route_state_context: Context[_RouteState | None] = create_context(None) diff --git a/src/reactpy_router/hooks.py b/src/reactpy_router/hooks.py new file mode 100644 index 0000000..1ef6d8e --- /dev/null +++ b/src/reactpy_router/hooks.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Callable +from urllib.parse import parse_qs + +from reactpy import ( + create_context, + use_context, + use_location, +) +from reactpy.backend.types import Location +from reactpy.types import Context + + +@dataclass +class _RouteState: + set_location: Callable[[Location], None] + params: dict[str, Any] + + +def _use_route_state() -> _RouteState: + route_state = use_context(_route_state_context) + if route_state is None: + raise RuntimeError( + "ReactPy-Router was unable to find a route context. Are you " + "sure this hook/component is being called within a router?" + ) + + return route_state + + +_route_state_context: Context[_RouteState | None] = create_context(None) + + +def use_params() -> dict[str, Any]: + """The `use_params` hook returns an object of key/value pairs of the dynamic parameters \ + from the current URL that were matched by the `Route`. Child routes inherit all parameters \ + from their parent routes. + + For example, if you have a `URL_PARAM` defined in the route `/example//`, + this hook will return the URL_PARAM value that was matched.""" + + # TODO: Check if this returns all parent params + return _use_route_state().params + + +def use_search_params( + keep_blank_values: bool = False, + strict_parsing: bool = False, + errors: str = "replace", + max_num_fields: int | None = None, + separator: str = "&", +) -> dict[str, list[str]]: + """ + The `use_search_params` hook is used to read and modify the query string in the URL \ + for the current location. Like React's own `use_state` hook, `use_search_params` returns \ + an array of two values: the current location's search parameters and a function that may \ + be used to update them. + + See `urllib.parse.parse_qs` for info on this hook's parameters.""" + + # FIXME: This needs to return a tuple of the search params and a function to update them + return parse_qs( + use_location().search[1:], + keep_blank_values=keep_blank_values, + strict_parsing=strict_parsing, + errors=errors, + max_num_fields=max_num_fields, + separator=separator, + ) diff --git a/src/reactpy_router/resolvers.py b/src/reactpy_router/resolvers.py index d4abaaf..36b3a1b 100644 --- a/src/reactpy_router/resolvers.py +++ b/src/reactpy_router/resolvers.py @@ -6,11 +6,13 @@ from reactpy_router.converters import CONVERTERS from reactpy_router.types import ConversionInfo, ConverterMapping, Route -__all__ = ["Resolver"] +__all__ = ["StarletteResolver"] -class Resolver: - """A simple route resolver that uses regex to match paths.""" +class StarletteResolver: + """URL resolver that matches routes that match the starlette URL routing syntax. + + However, we add a few additional parameter types on top of Starlette's syntax.""" def __init__( self, @@ -21,7 +23,6 @@ def __init__( self.element = route.element self.registered_converters = converters or CONVERTERS self.converter_mapping: ConverterMapping = {} - # self.match_any_indentifier = match_any_identifier self.param_regex = re.compile(param_pattern) self.pattern = self.parse_path(route.path) self.key = self.pattern.pattern # Unique identifier for ReactPy rendering diff --git a/src/reactpy_router/routers.py b/src/reactpy_router/routers.py index d5ed0e1..3312fc9 100644 --- a/src/reactpy_router/routers.py +++ b/src/reactpy_router/routers.py @@ -2,12 +2,106 @@ from __future__ import annotations -from reactpy_router.core import create_router -from reactpy_router.resolvers import Resolver +from dataclasses import replace +from logging import getLogger +from typing import Any, Iterator, Literal, Sequence, TypeVar -__all__ = ["browser_router"] +from reactpy import ( + component, + html, + use_memo, + use_state, +) +from reactpy.backend.hooks import ConnectionContext, use_connection +from reactpy.backend.types import Connection, Location +from reactpy.core.types import VdomDict +from reactpy.types import ComponentType +from reactpy_router.components import History +from reactpy_router.hooks import _route_state_context, _RouteState +from reactpy_router.resolvers import StarletteResolver +from reactpy_router.types import Route, RouteCompiler, Router, RouteResolver -browser_router = create_router(Resolver) +__all__ = ["browser_router", "create_router"] +_logger = getLogger(__name__) +R = TypeVar("R", bound=Route) + + +def create_router(compiler: RouteCompiler[R]) -> Router[R]: + """A decorator that turns a route compiler into a router""" + + def wrapper(*routes: R) -> ComponentType: + return router(*routes, compiler=compiler) + + return wrapper + + +browser_router = create_router(StarletteResolver) """This is the recommended router for all ReactPy Router web projects. It uses the DOM History API to update the URL and manage the history stack.""" + + +@component +def router( + *routes: R, + compiler: RouteCompiler[R], +) -> VdomDict | None: + """A component that renders matching route(s) using the given compiler function. + + This typically should never be used by a user. Instead, use `create_router` if creating + a custom routing engine.""" + + old_conn = use_connection() + location, set_location = use_state(old_conn.location) + + resolvers = use_memo( + lambda: tuple(map(compiler, _iter_routes(routes))), + dependencies=(compiler, hash(routes)), + ) + + match = use_memo(lambda: _match_route(resolvers, location, select="first")) + + if match: + route_elements = [ + _route_state_context( + element, + value=_RouteState(set_location, params), + ) + for element, params in match + ] + return ConnectionContext( + History( # type: ignore + {"on_change": lambda event: set_location(Location(**event))} + ), + html._(route_elements), + value=Connection(old_conn.scope, location, old_conn.carrier), + ) + + return None + + +def _iter_routes(routes: Sequence[R]) -> Iterator[R]: + for parent in routes: + for child in _iter_routes(parent.routes): + yield replace(child, path=parent.path + child.path) # type: ignore[misc] + yield parent + + +def _match_route( + compiled_routes: Sequence[RouteResolver], + location: Location, + select: Literal["first", "all"], +) -> list[tuple[Any, dict[str, Any]]]: + matches = [] + + for resolver in compiled_routes: + match = resolver.resolve(location.pathname) + if match is not None: + if select == "first": + return [match] + matches.append(match) + + if not matches: + _logger.debug("No matching route found for %s", location.pathname) + + return matches diff --git a/tests/test_resolver.py b/tests/test_resolver.py index d0ef85b..bd578ad 100644 --- a/tests/test_resolver.py +++ b/tests/test_resolver.py @@ -2,18 +2,19 @@ import uuid import pytest -from reactpy_router import Resolver, route + +from reactpy_router import StarletteResolver, route def test_resolve_any(): - resolver = Resolver(route("{404:any}", "Hello World")) + resolver = StarletteResolver(route("{404:any}", "Hello World")) assert resolver.parse_path("{404:any}") == re.compile("^(?P<_numeric_404>.*)$") assert resolver.converter_mapping == {"_numeric_404": str} assert resolver.resolve("/hello/world") == ("Hello World", {"404": "/hello/world"}) def test_parse_path(): - resolver = Resolver(route("/", None)) + resolver = StarletteResolver(route("/", None)) assert resolver.parse_path("/a/b/c") == re.compile("^/a/b/c$") assert resolver.converter_mapping == {} @@ -23,9 +24,7 @@ def test_parse_path(): assert resolver.parse_path("/a/{b:int}/c") == re.compile(r"^/a/(?P\d+)/c$") assert resolver.converter_mapping == {"b": int} - assert resolver.parse_path("/a/{b:int}/{c:float}/c") == re.compile( - r"^/a/(?P\d+)/(?P\d+(\.\d+)?)/c$" - ) + assert resolver.parse_path("/a/{b:int}/{c:float}/c") == re.compile(r"^/a/(?P\d+)/(?P\d+(\.\d+)?)/c$") assert resolver.converter_mapping == {"b": int, "c": float} assert resolver.parse_path("/a/{b:int}/{c:float}/{d:uuid}/c") == re.compile( @@ -33,9 +32,7 @@ def test_parse_path(): ) assert resolver.converter_mapping == {"b": int, "c": float, "d": uuid.UUID} - assert resolver.parse_path( - "/a/{b:int}/{c:float}/{d:uuid}/{e:path}/c" - ) == re.compile( + assert resolver.parse_path("/a/{b:int}/{c:float}/{d:uuid}/{e:path}/c") == re.compile( r"^/a/(?P\d+)/(?P\d+(\.\d+)?)/(?P[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/(?P.+)/c$" ) assert resolver.converter_mapping == { @@ -47,13 +44,13 @@ def test_parse_path(): def test_parse_path_unkown_conversion(): - resolver = Resolver(route("/", None)) + resolver = StarletteResolver(route("/", None)) with pytest.raises(ValueError): resolver.parse_path("/a/{b:unknown}/c") def test_parse_path_re_escape(): """Check that we escape regex characters in the path""" - resolver = Resolver(route("/", None)) + resolver = StarletteResolver(route("/", None)) assert resolver.parse_path("/a/{b:int}/c.d") == re.compile(r"^/a/(?P\d+)/c\.d$") assert resolver.converter_mapping == {"b": int} From bad417fa81cea7e8835c725ec504e75c82f88733 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 6 Oct 2024 00:13:56 -0700 Subject: [PATCH 31/62] cleaner example for use params --- docs/examples/python/use-params.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/examples/python/use-params.py b/docs/examples/python/use-params.py index 89de933..76a94df 100644 --- a/docs/examples/python/use-params.py +++ b/docs/examples/python/use-params.py @@ -6,7 +6,7 @@ @component def user(): params = use_params() - return html.h1(f"User {params['id']} 👤") + return html._(html.h1(f"User {params['id']} 👤"), html.p("Nothing (yet).")) @component From 155079b93216b1cc782f5f03c5f79be99256f11d Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 6 Oct 2024 01:29:22 -0700 Subject: [PATCH 32/62] ignore link click type --- tests/test_core.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_core.py b/tests/test_core.py index faa6256..9089259 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -92,7 +92,7 @@ def sample(): for link_selector in ["#root", "#a", "#b", "#c"]: lnk = await display.page.wait_for_selector(link_selector) - await lnk.click() + await lnk.click() # type: ignore await display.page.wait_for_selector("#default") @@ -171,7 +171,7 @@ def sample(): for link_selector in ["#root", "#a", "#b", "#c"]: lnk = await display.page.wait_for_selector(link_selector) - await lnk.click() + await lnk.click() # type: ignore await display.page.wait_for_selector("#default") @@ -205,7 +205,7 @@ def sample(): for link_selector in ["#root", "#a", "#b", "#c", "#d", "#e"]: lnk = await display.page.wait_for_selector(link_selector) - await lnk.click() + await lnk.click() # type: ignore await display.page.wait_for_selector("#default") From c7d19f6e718df8fd73e80bfb8bee5046a7e31d82 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 6 Oct 2024 01:29:35 -0700 Subject: [PATCH 33/62] Move event loop policy to dedicated fixture --- tests/conftest.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 54224ab..18e3646 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -20,8 +20,6 @@ def pytest_addoption(parser) -> None: @pytest.fixture async def display(backend, browser): - if sys.platform == "win32": - asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) async with DisplayFixture(backend, browser) as display_fixture: display_fixture.page.set_default_timeout(10000) yield display_fixture @@ -37,3 +35,10 @@ async def backend(): async def browser(pytestconfig): async with async_playwright() as pw: yield await pw.chromium.launch(headless=True if GITHUB_ACTIONS else pytestconfig.getoption("headless")) + + +@pytest.fixture +def event_loop_policy(request): + if sys.platform == "win32": + return asyncio.WindowsProactorEventLoopPolicy() + return asyncio.get_event_loop_policy() From d7184b11c6c7ceb65e8ff9e1ba6b8f251870d843 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 6 Oct 2024 01:29:41 -0700 Subject: [PATCH 34/62] Fix resolver tests --- tests/test_resolver.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_resolver.py b/tests/test_resolver.py index bd578ad..4e8a669 100644 --- a/tests/test_resolver.py +++ b/tests/test_resolver.py @@ -3,7 +3,8 @@ import pytest -from reactpy_router import StarletteResolver, route +from reactpy_router import route +from reactpy_router.resolvers import StarletteResolver def test_resolve_any(): From 6857f7ab6bb768152b276dcf55a3850ccb92dc40 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 6 Oct 2024 02:45:39 -0700 Subject: [PATCH 35/62] fix type errors --- tests/test_core.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_core.py b/tests/test_core.py index 9089259..faa6256 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -92,7 +92,7 @@ def sample(): for link_selector in ["#root", "#a", "#b", "#c"]: lnk = await display.page.wait_for_selector(link_selector) - await lnk.click() # type: ignore + await lnk.click() await display.page.wait_for_selector("#default") @@ -171,7 +171,7 @@ def sample(): for link_selector in ["#root", "#a", "#b", "#c"]: lnk = await display.page.wait_for_selector(link_selector) - await lnk.click() # type: ignore + await lnk.click() await display.page.wait_for_selector("#default") @@ -205,7 +205,7 @@ def sample(): for link_selector in ["#root", "#a", "#b", "#c", "#d", "#e"]: lnk = await display.page.wait_for_selector(link_selector) - await lnk.click() # type: ignore + await lnk.click() await display.page.wait_for_selector("#default") From fa0f307100221db9d5bd5d72c5ec78750f6f5123 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 6 Oct 2024 02:47:52 -0700 Subject: [PATCH 36/62] use link class instead of ID --- .gitignore | 2 +- src/reactpy_router/components.py | 9 ++++++++- src/reactpy_router/static/link.js | 2 +- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 220d2a8..12271fc 100644 --- a/.gitignore +++ b/.gitignore @@ -108,7 +108,7 @@ celerybeat.pid # Environments .env -.venv +.venv* env/ venv/ ENV/ diff --git a/src/reactpy_router/components.py b/src/reactpy_router/components.py index 10d0dce..f424beb 100644 --- a/src/reactpy_router/components.py +++ b/src/reactpy_router/components.py @@ -34,11 +34,18 @@ def on_click(_event: dict[str, Any]) -> None: search = f"?{search}" set_location(Location(pathname, search)) + class_name = uuid + if "className" in attributes: + class_name = " ".join([attributes.pop("className"), class_name]) + # TODO: This can be removed when ReactPy stops supporting underscores in attribute names + if "class_name" in attributes: + class_name = " ".join([attributes.pop("class_name"), class_name]) + attrs = { **attributes, "href": to, "onClick": on_click, - "id": uuid, + "className": uuid, } return html._(html.a(attrs, *children), html.script(link_js_content.replace("UUID", uuid))) diff --git a/src/reactpy_router/static/link.js b/src/reactpy_router/static/link.js index 7fbfa17..e2e51dd 100644 --- a/src/reactpy_router/static/link.js +++ b/src/reactpy_router/static/link.js @@ -1,4 +1,4 @@ -document.getElementById("UUID").addEventListener("click", (event) => { +document.getElementsByClassName("UUID").addEventListener("click", (event) => { event.preventDefault(); let to = event.target.getAttribute("href"); window.history.pushState({}, to, new URL(to, window.location)); From 1723f8deace841505fc5772babd6cca44165a2fd Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 6 Oct 2024 03:08:26 -0700 Subject: [PATCH 37/62] prefix uuid with a string --- src/reactpy_router/components.py | 8 ++++---- src/reactpy_router/static/link.js | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/reactpy_router/components.py b/src/reactpy_router/components.py index f424beb..04d4027 100644 --- a/src/reactpy_router/components.py +++ b/src/reactpy_router/components.py @@ -26,7 +26,7 @@ def link(*children: VdomChild, to: str, **attributes: Any) -> VdomDict: # properly sets the location. When a client-server communication layer is added to a \ # future ReactPy release, this component will need to be rewritten to use that instead. \ set_location = _use_route_state().set_location - uuid = uuid4().hex + class_uuid = f"link-{uuid4().hex}" def on_click(_event: dict[str, Any]) -> None: pathname, search = to.split("?", 1) if "?" in to else (to, "") @@ -34,7 +34,7 @@ def on_click(_event: dict[str, Any]) -> None: search = f"?{search}" set_location(Location(pathname, search)) - class_name = uuid + class_name = class_uuid if "className" in attributes: class_name = " ".join([attributes.pop("className"), class_name]) # TODO: This can be removed when ReactPy stops supporting underscores in attribute names @@ -45,9 +45,9 @@ def on_click(_event: dict[str, Any]) -> None: **attributes, "href": to, "onClick": on_click, - "className": uuid, + "className": class_uuid, } - return html._(html.a(attrs, *children), html.script(link_js_content.replace("UUID", uuid))) + return html._(html.a(attrs, *children), html.script(link_js_content.replace("UUID", class_uuid))) def route(path: str, element: Any | None, *routes: Route) -> Route: diff --git a/src/reactpy_router/static/link.js b/src/reactpy_router/static/link.js index e2e51dd..7363403 100644 --- a/src/reactpy_router/static/link.js +++ b/src/reactpy_router/static/link.js @@ -1,4 +1,4 @@ -document.getElementsByClassName("UUID").addEventListener("click", (event) => { +document.querySelector(".UUID").addEventListener("click", (event) => { event.preventDefault(); let to = event.target.getAttribute("href"); window.history.pushState({}, to, new URL(to, window.location)); From b67101afca850e532d815d13b7336738440fbf7c Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 6 Oct 2024 03:33:55 -0700 Subject: [PATCH 38/62] add test for link search params --- tests/test_core.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/test_core.py b/tests/test_core.py index faa6256..b4b402c 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -226,3 +226,24 @@ def sample(): await display.page.go_back() await display.page.wait_for_selector("#root") + + +async def test_link_with_query_string(display: DisplayFixture): + @component + def check_search_params(): + query = use_search_params() + assert query == {"a": ["1"], "b": ["2"]} + return html.h1({"id": "success"}, "success") + + @component + def sample(): + return browser_router( + route("/", link("Root", to="/a?a=1&b=2", id="root")), + route("/a", check_search_params()), + ) + + await display.show(sample) + await display.page.wait_for_selector("#root") + lnk = await display.page.wait_for_selector("#root") + await lnk.click() + await display.page.wait_for_selector("#success") From c38ac3bee0c618972562b0dfb695335c51c17e5a Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 6 Oct 2024 03:34:11 -0700 Subject: [PATCH 39/62] Remove coverage from some unneeded places --- src/reactpy_router/components.py | 2 +- src/reactpy_router/hooks.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/reactpy_router/components.py b/src/reactpy_router/components.py index 04d4027..7c3baa5 100644 --- a/src/reactpy_router/components.py +++ b/src/reactpy_router/components.py @@ -38,7 +38,7 @@ def on_click(_event: dict[str, Any]) -> None: if "className" in attributes: class_name = " ".join([attributes.pop("className"), class_name]) # TODO: This can be removed when ReactPy stops supporting underscores in attribute names - if "class_name" in attributes: + if "class_name" in attributes: # pragma: no cover class_name = " ".join([attributes.pop("class_name"), class_name]) attrs = { diff --git a/src/reactpy_router/hooks.py b/src/reactpy_router/hooks.py index 1ef6d8e..36fdce8 100644 --- a/src/reactpy_router/hooks.py +++ b/src/reactpy_router/hooks.py @@ -21,7 +21,7 @@ class _RouteState: def _use_route_state() -> _RouteState: route_state = use_context(_route_state_context) - if route_state is None: + if route_state is None: # pragma: no cover raise RuntimeError( "ReactPy-Router was unable to find a route context. Are you " "sure this hook/component is being called within a router?" From f57b63be76817d295041b01f679979bb5bbdb910 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 6 Oct 2024 14:29:53 -0700 Subject: [PATCH 40/62] Fix browser history --- src/reactpy_router/static/link.js | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/reactpy_router/static/link.js b/src/reactpy_router/static/link.js index 7363403..1bd2c81 100644 --- a/src/reactpy_router/static/link.js +++ b/src/reactpy_router/static/link.js @@ -1,5 +1,9 @@ -document.querySelector(".UUID").addEventListener("click", (event) => { - event.preventDefault(); - let to = event.target.getAttribute("href"); - window.history.pushState({}, to, new URL(to, window.location)); -}); +document.querySelector(".UUID").addEventListener( + "click", + (event) => { + event.preventDefault(); + let to = event.target.getAttribute("href"); + window.history.pushState({}, to, new URL(to, window.location)); + }, + { once: true }, +); From 88d1f46a89a0b2d95cb87a4c8d6e1afed0891d40 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 6 Oct 2024 14:52:47 -0700 Subject: [PATCH 41/62] use server side prevent default --- src/reactpy_router/components.py | 11 ++++++----- src/reactpy_router/static/link.js | 1 - 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/reactpy_router/components.py b/src/reactpy_router/components.py index 7c3baa5..f06eb99 100644 --- a/src/reactpy_router/components.py +++ b/src/reactpy_router/components.py @@ -4,7 +4,7 @@ from typing import Any from uuid import uuid4 -from reactpy import component, html +from reactpy import component, event, html from reactpy.backend.types import Location from reactpy.core.types import VdomChild, VdomDict from reactpy.web.module import export, module_from_file @@ -26,15 +26,16 @@ def link(*children: VdomChild, to: str, **attributes: Any) -> VdomDict: # properly sets the location. When a client-server communication layer is added to a \ # future ReactPy release, this component will need to be rewritten to use that instead. \ set_location = _use_route_state().set_location - class_uuid = f"link-{uuid4().hex}" + @event(prevent_default=True) def on_click(_event: dict[str, Any]) -> None: pathname, search = to.split("?", 1) if "?" in to else (to, "") if search: search = f"?{search}" set_location(Location(pathname, search)) - class_name = class_uuid + uuid_string = f"link-{uuid4().hex}" + class_name = f"{uuid_string}" if "className" in attributes: class_name = " ".join([attributes.pop("className"), class_name]) # TODO: This can be removed when ReactPy stops supporting underscores in attribute names @@ -45,9 +46,9 @@ def on_click(_event: dict[str, Any]) -> None: **attributes, "href": to, "onClick": on_click, - "className": class_uuid, + "className": class_name, } - return html._(html.a(attrs, *children), html.script(link_js_content.replace("UUID", class_uuid))) + return html._(html.a(attrs, *children), html.script(link_js_content.replace("UUID", uuid_string))) def route(path: str, element: Any | None, *routes: Route) -> Route: diff --git a/src/reactpy_router/static/link.js b/src/reactpy_router/static/link.js index 1bd2c81..0ce08b9 100644 --- a/src/reactpy_router/static/link.js +++ b/src/reactpy_router/static/link.js @@ -1,7 +1,6 @@ document.querySelector(".UUID").addEventListener( "click", (event) => { - event.preventDefault(); let to = event.target.getAttribute("href"); window.history.pushState({}, to, new URL(to, window.location)); }, From a78a5b18fa6446aa325b569e5ab79dd441a9469b Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 6 Oct 2024 16:48:52 -0700 Subject: [PATCH 42/62] Server side handling of relative URLs --- src/reactpy_router/components.py | 21 ++++++++++++++++++++- tests/test_core.py | 10 +++++++--- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/src/reactpy_router/components.py b/src/reactpy_router/components.py index f06eb99..8b3aafc 100644 --- a/src/reactpy_router/components.py +++ b/src/reactpy_router/components.py @@ -2,9 +2,10 @@ from pathlib import Path from typing import Any +from urllib.parse import urljoin from uuid import uuid4 -from reactpy import component, event, html +from reactpy import component, event, html, use_connection from reactpy.backend.types import Location from reactpy.core.types import VdomChild, VdomDict from reactpy.web.module import export, module_from_file @@ -26,12 +27,30 @@ def link(*children: VdomChild, to: str, **attributes: Any) -> VdomDict: # properly sets the location. When a client-server communication layer is added to a \ # future ReactPy release, this component will need to be rewritten to use that instead. \ set_location = _use_route_state().set_location + current_path = use_connection().location.pathname @event(prevent_default=True) def on_click(_event: dict[str, Any]) -> None: pathname, search = to.split("?", 1) if "?" in to else (to, "") if search: search = f"?{search}" + + # Resolve relative paths that match `../foo` + if pathname.startswith("../"): + pathname = urljoin(current_path, pathname) + + # Resolve relative paths that match `foo` + if not pathname.startswith("/"): + pathname = urljoin(current_path, pathname) + + # Resolve relative paths that match `/foo/../bar` + while "/../" in pathname: + part_1, part_2 = pathname.split("/../", 1) + pathname = urljoin(f"{part_1}/", f"../{part_2}") + + # Resolve relative paths that match `foo/./bar` + pathname = pathname.replace("/./", "/") + set_location(Location(pathname, search)) uuid_string = f"link-{uuid4().hex}" diff --git a/tests/test_core.py b/tests/test_core.py index b4b402c..a177338 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -193,22 +193,26 @@ async def test_relative_links(display: DisplayFixture): def sample(): return browser_router( route("/", link("Root", to="/a", id="root")), - route("/a", link("A", to="/a/b", id="a")), + route("/a", link("A", to="/a/a/../b", id="a")), route("/a/b", link("B", to="../a/b/c", id="b")), route("/a/b/c", link("C", to="../d", id="c")), route("/a/d", link("D", to="e", id="d")), - route("/a/e", link("E", to="../default", id="e")), + route("/a/e", link("E", to="/a/./f", id="e")), + route("/a/f", link("F", to="../default", id="f")), route("{default:any}", html.h1({"id": "default"}, "Default")), ) await display.show(sample) - for link_selector in ["#root", "#a", "#b", "#c", "#d", "#e"]: + for link_selector in ["#root", "#a", "#b", "#c", "#d", "#e", "#f"]: lnk = await display.page.wait_for_selector(link_selector) await lnk.click() await display.page.wait_for_selector("#default") + await display.page.go_back() + await display.page.wait_for_selector("#f") + await display.page.go_back() await display.page.wait_for_selector("#e") From 97a715c20465a39843f98924880ea9d5c317a961 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 6 Oct 2024 17:09:04 -0700 Subject: [PATCH 43/62] Add test for class name --- tests/test_core.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/test_core.py b/tests/test_core.py index a177338..eb07624 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -251,3 +251,14 @@ def sample(): lnk = await display.page.wait_for_selector("#root") await lnk.click() await display.page.wait_for_selector("#success") + + +async def test_link_class_name(display: DisplayFixture): + @component + def sample(): + return browser_router(route("/", link("Root", to="/a", id="root", className="class1"))) + + await display.show(sample) + + lnk = await display.page.wait_for_selector("#root") + assert "class1" in await lnk.get_attribute("class") From 4633ab288df68478640e6aafe6dadd27bd4b266d Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 6 Oct 2024 17:12:05 -0700 Subject: [PATCH 44/62] fix last coverage hit --- src/reactpy_router/routers.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/reactpy_router/routers.py b/src/reactpy_router/routers.py index 3312fc9..90928d0 100644 --- a/src/reactpy_router/routers.py +++ b/src/reactpy_router/routers.py @@ -99,7 +99,10 @@ def _match_route( if match is not None: if select == "first": return [match] - matches.append(match) + + # This is no longer used by `reactpy-router`, since `react-router>=6.0.0` no longer supports + # multiple matches. However, it's kept here to support future changes. + matches.append(match) # pragma: no cover if not matches: _logger.debug("No matching route found for %s", location.pathname) From 70a7ea07b1503793de070de6ea1cb5f77e0d8932 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 11 Oct 2024 15:54:21 -0700 Subject: [PATCH 45/62] self review --- docs/examples/python/use-search-params.py | 6 ++-- src/reactpy_router/converters.py | 2 +- src/reactpy_router/hooks.py | 10 +++---- src/reactpy_router/resolvers.py | 10 +++++-- src/reactpy_router/routers.py | 34 +++++++++++------------ src/reactpy_router/types.py | 15 +++++----- 6 files changed, 41 insertions(+), 36 deletions(-) diff --git a/docs/examples/python/use-search-params.py b/docs/examples/python/use-search-params.py index b3fd0ec..6d3cac0 100644 --- a/docs/examples/python/use-search-params.py +++ b/docs/examples/python/use-search-params.py @@ -5,8 +5,8 @@ @component def search(): - query = use_search_params() - return html._(html.h1(f"Search Results for {query['q'][0]} 🔍"), html.p("Nothing (yet).")) + search_params = use_search_params() + return html._(html.h1(f"Search Results for {search_params['query'][0]} 🔍"), html.p("Nothing (yet).")) @component @@ -16,7 +16,7 @@ def root(): "/", html.div( html.h1("Home Page 🏠"), - link("Search", to="/search?q=reactpy"), + link("Search", to="/search?query=reactpy"), ), ), route("/search", search()), diff --git a/src/reactpy_router/converters.py b/src/reactpy_router/converters.py index e44e0b1..5fe1b5e 100644 --- a/src/reactpy_router/converters.py +++ b/src/reactpy_router/converters.py @@ -34,4 +34,4 @@ "func": str, }, } -"""The conversion types supported by the default Resolver. You can add more types if needed.""" +"""The conversion types supported by the default Resolver.""" diff --git a/src/reactpy_router/hooks.py b/src/reactpy_router/hooks.py index 36fdce8..3f83811 100644 --- a/src/reactpy_router/hooks.py +++ b/src/reactpy_router/hooks.py @@ -53,14 +53,14 @@ def use_search_params( separator: str = "&", ) -> dict[str, list[str]]: """ - The `use_search_params` hook is used to read and modify the query string in the URL \ - for the current location. Like React's own `use_state` hook, `use_search_params` returns \ - an array of two values: the current location's search parameters and a function that may \ - be used to update them. + The `use_search_params` hook is used to read the query string in the URL \ + for the current location. See `urllib.parse.parse_qs` for info on this hook's parameters.""" - # FIXME: This needs to return a tuple of the search params and a function to update them + # TODO: In order to match `react-router`, this will need to return a tuple of the search params \ + # and a function to update them. This is currently not possible without reactpy core having a \ + # communication layer. return parse_qs( use_location().search[1:], keep_blank_values=keep_blank_values, diff --git a/src/reactpy_router/resolvers.py b/src/reactpy_router/resolvers.py index 36b3a1b..e349c0f 100644 --- a/src/reactpy_router/resolvers.py +++ b/src/reactpy_router/resolvers.py @@ -10,9 +10,9 @@ class StarletteResolver: - """URL resolver that matches routes that match the starlette URL routing syntax. + """URL resolver that matches routes using starlette's URL routing syntax. - However, we add a few additional parameter types on top of Starlette's syntax.""" + However, this resolver adds a few additional parameter types on top of Starlette's syntax.""" def __init__( self, @@ -34,10 +34,14 @@ def parse_path(self, path: str) -> re.Pattern[str]: # Iterate through matches of the parameter pattern for match in self.param_regex.finditer(path): - # Extract parameter name and type + # Extract parameter name name = match.group("name") if name[0].isnumeric(): + # Regex group names can't begin with a number, so we must prefix them with + # "_numeric_". This prefix is removed later within this function. name = f"_numeric_{name}" + + # Extract the parameter type param_type = (match.group("type") or "str").strip(":") # Check if a converter exists for the type diff --git a/src/reactpy_router/routers.py b/src/reactpy_router/routers.py index 90928d0..b1fda71 100644 --- a/src/reactpy_router/routers.py +++ b/src/reactpy_router/routers.py @@ -4,7 +4,7 @@ from dataclasses import replace from logging import getLogger -from typing import Any, Iterator, Literal, Sequence, TypeVar +from typing import Any, Iterator, Literal, Sequence from reactpy import ( component, @@ -20,33 +20,32 @@ from reactpy_router.components import History from reactpy_router.hooks import _route_state_context, _RouteState from reactpy_router.resolvers import StarletteResolver -from reactpy_router.types import Route, RouteCompiler, Router, RouteResolver +from reactpy_router.types import CompiledRoute, Resolver, Router, RouteType __all__ = ["browser_router", "create_router"] _logger = getLogger(__name__) -R = TypeVar("R", bound=Route) -def create_router(compiler: RouteCompiler[R]) -> Router[R]: - """A decorator that turns a route compiler into a router""" +def create_router(resolver: Resolver[RouteType]) -> Router[RouteType]: + """A decorator that turns a resolver into a router""" - def wrapper(*routes: R) -> ComponentType: - return router(*routes, compiler=compiler) + def wrapper(*routes: RouteType) -> ComponentType: + return router(*routes, resolver=resolver) return wrapper browser_router = create_router(StarletteResolver) """This is the recommended router for all ReactPy Router web projects. -It uses the DOM History API to update the URL and manage the history stack.""" +It uses the JavaScript DOM History API to manage the history stack.""" @component def router( - *routes: R, - compiler: RouteCompiler[R], + *routes: RouteType, + resolver: Resolver[RouteType], ) -> VdomDict | None: - """A component that renders matching route(s) using the given compiler function. + """A component that renders matching route(s) using the given resolver. This typically should never be used by a user. Instead, use `create_router` if creating a custom routing engine.""" @@ -55,8 +54,8 @@ def router( location, set_location = use_state(old_conn.location) resolvers = use_memo( - lambda: tuple(map(compiler, _iter_routes(routes))), - dependencies=(compiler, hash(routes)), + lambda: tuple(map(resolver, _iter_routes(routes))), + dependencies=(resolver, hash(routes)), ) match = use_memo(lambda: _match_route(resolvers, location, select="first")) @@ -80,7 +79,7 @@ def router( return None -def _iter_routes(routes: Sequence[R]) -> Iterator[R]: +def _iter_routes(routes: Sequence[RouteType]) -> Iterator[RouteType]: for parent in routes: for child in _iter_routes(parent.routes): yield replace(child, path=parent.path + child.path) # type: ignore[misc] @@ -88,7 +87,7 @@ def _iter_routes(routes: Sequence[R]) -> Iterator[R]: def _match_route( - compiled_routes: Sequence[RouteResolver], + compiled_routes: Sequence[CompiledRoute], location: Location, select: Literal["first", "all"], ) -> list[tuple[Any, dict[str, Any]]]: @@ -100,8 +99,9 @@ def _match_route( if select == "first": return [match] - # This is no longer used by `reactpy-router`, since `react-router>=6.0.0` no longer supports - # multiple matches. However, it's kept here to support future changes. + # Matching multiple routes is disabled since `react-router` no longer supports multiple + # matches via the `Route` component. However, it's kept here to support future changes + # or third-party routers. matches.append(match) # pragma: no cover if not matches: diff --git a/src/reactpy_router/types.py b/src/reactpy_router/types.py index 0801466..15a77c4 100644 --- a/src/reactpy_router/types.py +++ b/src/reactpy_router/types.py @@ -32,22 +32,23 @@ def __hash__(self) -> int: return hash((self.path, key, self.routes)) -R_contra = TypeVar("R_contra", bound=Route, contravariant=True) +RouteType = TypeVar("RouteType", bound=Route) +RouteType_contra = TypeVar("RouteType_contra", bound=Route, contravariant=True) -class Router(Protocol[R_contra]): +class Router(Protocol[RouteType_contra]): """Return a component that renders the first matching route.""" - def __call__(self, *routes: R_contra) -> ComponentType: ... + def __call__(self, *routes: RouteType_contra) -> ComponentType: ... -class RouteCompiler(Protocol[R_contra]): - """Compile a route into a resolver that can be matched against a path.""" +class Resolver(Protocol[RouteType_contra]): + """Compile a route into a resolver that can be matched against a given path.""" - def __call__(self, route: R_contra) -> RouteResolver: ... + def __call__(self, route: RouteType_contra) -> CompiledRoute: ... -class RouteResolver(Protocol): +class CompiledRoute(Protocol): """A compiled route that can be matched against a path.""" @property From 98ac776f505baec24e2a58a2a7f5f2250d382cb4 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 11 Oct 2024 15:58:46 -0700 Subject: [PATCH 46/62] Add click delay (attempt to fix flakey tests) --- tests/test_core.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/test_core.py b/tests/test_core.py index eb07624..f820270 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1,3 +1,4 @@ +import os from typing import Any from reactpy import Ref, component, html, use_location @@ -5,6 +6,9 @@ from reactpy_router import browser_router, link, route, use_params, use_search_params +GITHUB_ACTIONS = os.getenv("GITHUB_ACTIONS", "").lower() == "true" +CLICK_DELAY = 250 if GITHUB_ACTIONS else 25 # Delay in miliseconds. + async def test_simple_router(display: DisplayFixture): def make_location_check(path, *routes): @@ -92,7 +96,7 @@ def sample(): for link_selector in ["#root", "#a", "#b", "#c"]: lnk = await display.page.wait_for_selector(link_selector) - await lnk.click() + await lnk.click(delay=CLICK_DELAY) await display.page.wait_for_selector("#default") @@ -171,7 +175,7 @@ def sample(): for link_selector in ["#root", "#a", "#b", "#c"]: lnk = await display.page.wait_for_selector(link_selector) - await lnk.click() + await lnk.click(delay=CLICK_DELAY) await display.page.wait_for_selector("#default") @@ -206,7 +210,7 @@ def sample(): for link_selector in ["#root", "#a", "#b", "#c", "#d", "#e", "#f"]: lnk = await display.page.wait_for_selector(link_selector) - await lnk.click() + await lnk.click(delay=CLICK_DELAY) await display.page.wait_for_selector("#default") @@ -249,7 +253,7 @@ def sample(): await display.show(sample) await display.page.wait_for_selector("#root") lnk = await display.page.wait_for_selector("#root") - await lnk.click() + await lnk.click(delay=CLICK_DELAY) await display.page.wait_for_selector("#success") From 9fc92cef471d2e742b28cbb3e1cd21178ee83aab Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 11 Oct 2024 16:13:45 -0700 Subject: [PATCH 47/62] Add changelog --- CHANGELOG.md | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c673a2f..67fd37f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,7 +34,30 @@ Using the following categories, list your changes in this order: ## [Unreleased] -- Nothing (yet)! +### Changed + +- Bump GitHub workflows +- Rename `use_query` to `use_search_params`. +- Rename `simple.router` to `browser_router`. +- Rename `SimpleResolver` to `StarletteResolver`. +- Rename `CONVERSION_TYPES` to `CONVERTERS`. +- Change "Match Any" syntax from a star `*` to `{name:any}`. +- Rewrite `reactpy_router.link` to be a server-side component. +- Simplified top-level exports within `reactpy_router`. + +### Added + +- New error for ReactPy router elements being used outside router context. +- Configurable/inheritable `Resolver` base class. +- Add debug log message for when there are no router matches. +- Add slug as a supported type. + +### Fixed + +- Fix bug where changing routes could cause render failure due to key identity. +- Fix bug where "Match Any" pattern wouldn't work when used in complex or nested paths. +- Fix bug where `link` elements could not have `@component` type children. +- Fixed flakey tests being flakey on GitHub CI by adding click delays. ## [0.1.1] - 2023-12-13 From 0e87d27b7f333555f62e9959703e0db527829e3b Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 11 Oct 2024 16:16:42 -0700 Subject: [PATCH 48/62] Increase delay --- tests/test_core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_core.py b/tests/test_core.py index f820270..9bdfde8 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -7,7 +7,7 @@ from reactpy_router import browser_router, link, route, use_params, use_search_params GITHUB_ACTIONS = os.getenv("GITHUB_ACTIONS", "").lower() == "true" -CLICK_DELAY = 250 if GITHUB_ACTIONS else 25 # Delay in miliseconds. +CLICK_DELAY = 350 if GITHUB_ACTIONS else 25 # Delay in miliseconds. async def test_simple_router(display: DisplayFixture): From 04696156d76c4690ab105f0fac11a29f3598ee78 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 11 Oct 2024 20:04:52 -0700 Subject: [PATCH 49/62] Remove unneeded homepage stuff --- docs/mkdocs.yml | 2 +- .../add-interactivity-demo.html | 172 ------------------ .../home-code-examples/add-interactivity.py | 30 --- .../home-code-examples/code-block.html | 7 - .../create-user-interfaces-demo.html | 24 --- .../create-user-interfaces.py | 22 --- .../write-components-with-python-demo.html | 65 ------- .../write-components-with-python.py | 15 -- 8 files changed, 1 insertion(+), 336 deletions(-) delete mode 100644 docs/overrides/home-code-examples/add-interactivity-demo.html delete mode 100644 docs/overrides/home-code-examples/add-interactivity.py delete mode 100644 docs/overrides/home-code-examples/code-block.html delete mode 100644 docs/overrides/home-code-examples/create-user-interfaces-demo.html delete mode 100644 docs/overrides/home-code-examples/create-user-interfaces.py delete mode 100644 docs/overrides/home-code-examples/write-components-with-python-demo.html delete mode 100644 docs/overrides/home-code-examples/write-components-with-python.py diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 093f296..5173834 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -131,7 +131,7 @@ watch: site_name: ReactPy Router site_author: Archmonger site_description: It's React-Router, but in Python. -copyright: '©
Reactive Python and affiliates.' +copyright: '©
Reactive Python and affiliates.' repo_url: https://github.com/reactive-python/reactpy-router site_url: https://reactive-python.github.io/reactpy-router repo_name: ReactPy Router (GitHub) diff --git a/docs/overrides/home-code-examples/add-interactivity-demo.html b/docs/overrides/home-code-examples/add-interactivity-demo.html deleted file mode 100644 index 48ac19a..0000000 --- a/docs/overrides/home-code-examples/add-interactivity-demo.html +++ /dev/null @@ -1,172 +0,0 @@ -
-
- -
-
- - - - example.com/videos.html -
-
- -
-
-

Searchable Videos

-

Type a search query below.

- -
- -

5 Videos

- -
-
- - - -
-
-

ReactPy: The Documentary

-

From web library to taco delivery service

-
- -
- -
-
- - - -
-
-

Code using Worst Practices

-

Harriet Potter (2013)

-
- -
- -
-
- - - -
-
-

Introducing ReactPy Foriegn

-

Tim Cooker (2015)

-
- -
- -
-
- - - -
-
-

Introducing ReactPy Cooks

-

Soap Boat and Dinosaur Dan (2018)

-
- -
- -
-
- - - -
-
-

Introducing Quantum Components

-

Isaac Asimov and Lauren-kun (2020)

-
- -
-

-
- - -
-
diff --git a/docs/overrides/home-code-examples/add-interactivity.py b/docs/overrides/home-code-examples/add-interactivity.py deleted file mode 100644 index 9097644..0000000 --- a/docs/overrides/home-code-examples/add-interactivity.py +++ /dev/null @@ -1,30 +0,0 @@ -from reactpy import component, html, use_state - - -def filter_videos(videos, search_text): - return None - - -def search_input(dictionary, value): - return None - - -def video_list(videos, empty_heading): - return None - - -@component -def searchable_video_list(videos): - search_text, set_search_text = use_state("") - found_videos = filter_videos(videos, search_text) - - return html._( - search_input( - {"on_change": lambda new_text: set_search_text(new_text)}, - value=search_text, - ), - video_list( - videos=found_videos, - empty_heading=f"No matches for “{search_text}”", - ), - ) diff --git a/docs/overrides/home-code-examples/code-block.html b/docs/overrides/home-code-examples/code-block.html deleted file mode 100644 index c1f14e5..0000000 --- a/docs/overrides/home-code-examples/code-block.html +++ /dev/null @@ -1,7 +0,0 @@ -
- -
-
- -
-
diff --git a/docs/overrides/home-code-examples/create-user-interfaces-demo.html b/docs/overrides/home-code-examples/create-user-interfaces-demo.html deleted file mode 100644 index 9a684d3..0000000 --- a/docs/overrides/home-code-examples/create-user-interfaces-demo.html +++ /dev/null @@ -1,24 +0,0 @@ -
-
-
-
- - - -
-
-

My video

-

Video description

-
- -
-
-
diff --git a/docs/overrides/home-code-examples/create-user-interfaces.py b/docs/overrides/home-code-examples/create-user-interfaces.py deleted file mode 100644 index 37776ab..0000000 --- a/docs/overrides/home-code-examples/create-user-interfaces.py +++ /dev/null @@ -1,22 +0,0 @@ -from reactpy import component, html - - -def thumbnail(video): - return None - - -def like_button(video): - return None - - -@component -def video(video): - return html.div( - thumbnail(video), - html.a( - {"href": video.url}, - html.h3(video.title), - html.p(video.description), - ), - like_button(video), - ) diff --git a/docs/overrides/home-code-examples/write-components-with-python-demo.html b/docs/overrides/home-code-examples/write-components-with-python-demo.html deleted file mode 100644 index 203287c..0000000 --- a/docs/overrides/home-code-examples/write-components-with-python-demo.html +++ /dev/null @@ -1,65 +0,0 @@ -
-
-

3 Videos

-
-
- - - -
-
-

First video

-

Video description

-
- -
-
-
- - - -
-
-

Second video

-

Video description

-
- -
-
-
- - - -
-
-

Third video

-

Video description

-
- -
-
-
diff --git a/docs/overrides/home-code-examples/write-components-with-python.py b/docs/overrides/home-code-examples/write-components-with-python.py deleted file mode 100644 index 6af43ba..0000000 --- a/docs/overrides/home-code-examples/write-components-with-python.py +++ /dev/null @@ -1,15 +0,0 @@ -from reactpy import component, html - - -@component -def video_list(videos, empty_heading): - count = len(videos) - heading = empty_heading - if count > 0: - noun = "Videos" if count > 1 else "Video" - heading = f"{count} {noun}" - - return html.section( - html.h2(heading), - [video(video) for video in videos], - ) From 1d33c65ec3034f409ac7e407e0c0a85214f2a09f Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 11 Oct 2024 20:05:13 -0700 Subject: [PATCH 50/62] Use ReactJS event naming conventions --- src/reactpy_router/routers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/reactpy_router/routers.py b/src/reactpy_router/routers.py index b1fda71..74738a4 100644 --- a/src/reactpy_router/routers.py +++ b/src/reactpy_router/routers.py @@ -70,7 +70,7 @@ def router( ] return ConnectionContext( History( # type: ignore - {"on_change": lambda event: set_location(Location(**event))} + {"onChange": lambda event: set_location(Location(**event))} ), html._(route_elements), value=Connection(old_conn.scope, location, old_conn.carrier), From 25e440ffbd7b22c07ce57c49c6840d6fa56343c5 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 11 Oct 2024 23:22:33 -0700 Subject: [PATCH 51/62] Self review: Use JS component as background listener for link clicks --- src/js/src/index.js | 39 ++++++++++++++++++-- src/reactpy_router/components.py | 59 ++++++++++++------------------- src/reactpy_router/routers.py | 9 +++-- src/reactpy_router/static/link.js | 8 ----- 4 files changed, 65 insertions(+), 50 deletions(-) delete mode 100644 src/reactpy_router/static/link.js diff --git a/src/js/src/index.js b/src/js/src/index.js index 348f5d0..890f39e 100644 --- a/src/js/src/index.js +++ b/src/js/src/index.js @@ -12,18 +12,53 @@ export function bind(node) { }; } -export function History({ onChange }) { +export function History({ onBrowserBack }) { // Capture browser "history go back" action and tell the server about it // Note: Browsers do not allow you to detect "history go forward" actions. React.useEffect(() => { + // Register a listener for the "popstate" event and send data back to the server using the `onBrowserBack` callback. const listener = () => { - onChange({ + onBrowserBack({ pathname: window.location.pathname, search: window.location.search, }); }; + + // Register the event listener window.addEventListener("popstate", listener); + + // Delete the event listener when the component is unmounted return () => window.removeEventListener("popstate", listener); }); return null; } + +export function Link({ onClick, linkClass }) { + // This component is not the actual anchor link. + // It is an event listener for the link component created by ReactPy. + React.useEffect(() => { + // Event function that will tell the server about clicks + const handleClick = (event) => { + event.preventDefault(); + let to = event.target.getAttribute("href"); + window.history.pushState({}, to, new URL(to, window.location)); + onClick({ + pathname: window.location.pathname, + search: window.location.search, + }); + }; + + // Register the event listener + document + .querySelector(`.${linkClass}`) + .addEventListener("click", handleClick); + + // Delete the event listener when the component is unmounted + return () => { + document + .querySelector(`.${linkClass}`) + .removeEventListener("click", handleClick); + }; + }); + return null; +} diff --git a/src/reactpy_router/components.py b/src/reactpy_router/components.py index 8b3aafc..37214d8 100644 --- a/src/reactpy_router/components.py +++ b/src/reactpy_router/components.py @@ -2,12 +2,11 @@ from pathlib import Path from typing import Any -from urllib.parse import urljoin from uuid import uuid4 -from reactpy import component, event, html, use_connection +from reactpy import component, html from reactpy.backend.types import Location -from reactpy.core.types import VdomChild, VdomDict +from reactpy.core.types import VdomDict from reactpy.web.module import export, module_from_file from reactpy_router.hooks import _use_route_state @@ -17,57 +16,43 @@ module_from_file("reactpy-router", file=Path(__file__).parent / "static" / "bundle.js"), ("History"), ) -link_js_content = (Path(__file__).parent / "static" / "link.js").read_text(encoding="utf-8") +Link = export( + module_from_file("reactpy-router", file=Path(__file__).parent / "static" / "bundle.js"), + ("Link"), +) @component -def link(*children: VdomChild, to: str, **attributes: Any) -> VdomDict: +def link(*attributes_and_children: Any, to: str | None = None, **kwargs: Any) -> VdomDict: """A component that renders a link to the given path.""" - # FIXME: This currently works in a "dumb" way by trusting that ReactPy's script tag \ - # properly sets the location. When a client-server communication layer is added to a \ - # future ReactPy release, this component will need to be rewritten to use that instead. \ - set_location = _use_route_state().set_location - current_path = use_connection().location.pathname - - @event(prevent_default=True) - def on_click(_event: dict[str, Any]) -> None: - pathname, search = to.split("?", 1) if "?" in to else (to, "") - if search: - search = f"?{search}" - - # Resolve relative paths that match `../foo` - if pathname.startswith("../"): - pathname = urljoin(current_path, pathname) - - # Resolve relative paths that match `foo` - if not pathname.startswith("/"): - pathname = urljoin(current_path, pathname) - - # Resolve relative paths that match `/foo/../bar` - while "/../" in pathname: - part_1, part_2 = pathname.split("/../", 1) - pathname = urljoin(f"{part_1}/", f"../{part_2}") - - # Resolve relative paths that match `foo/./bar` - pathname = pathname.replace("/./", "/") - - set_location(Location(pathname, search)) + if to is None: + raise ValueError("The `to` attribute is required for the `Link` component.") uuid_string = f"link-{uuid4().hex}" class_name = f"{uuid_string}" + set_location = _use_route_state().set_location + attributes = {} + children: tuple[Any] = attributes_and_children + + if attributes_and_children and isinstance(attributes_and_children[0], dict): + attributes = attributes_and_children[0] + children = attributes_and_children[1:] if "className" in attributes: class_name = " ".join([attributes.pop("className"), class_name]) - # TODO: This can be removed when ReactPy stops supporting underscores in attribute names if "class_name" in attributes: # pragma: no cover + # TODO: This can be removed when ReactPy stops supporting underscores in attribute names class_name = " ".join([attributes.pop("class_name"), class_name]) attrs = { **attributes, "href": to, - "onClick": on_click, "className": class_name, } - return html._(html.a(attrs, *children), html.script(link_js_content.replace("UUID", uuid_string))) + + def on_click(_event: dict[str, Any]) -> None: + set_location(Location(**_event)) + + return html._(html.a(attrs, *children, **kwargs), Link({"onClick": on_click, "linkClass": uuid_string})) def route(path: str, element: Any | None, *routes: Route) -> Route: diff --git a/src/reactpy_router/routers.py b/src/reactpy_router/routers.py index 74738a4..74c0c17 100644 --- a/src/reactpy_router/routers.py +++ b/src/reactpy_router/routers.py @@ -68,10 +68,13 @@ def router( ) for element, params in match ] + + def on_browser_back(event: dict[str, Any]) -> None: + """Callback function used within the JavaScript `History` component.""" + set_location(Location(**event)) + return ConnectionContext( - History( # type: ignore - {"onChange": lambda event: set_location(Location(**event))} - ), + History({"onBrowserBack": on_browser_back}), # type: ignore[return-value] html._(route_elements), value=Connection(old_conn.scope, location, old_conn.carrier), ) diff --git a/src/reactpy_router/static/link.js b/src/reactpy_router/static/link.js deleted file mode 100644 index 0ce08b9..0000000 --- a/src/reactpy_router/static/link.js +++ /dev/null @@ -1,8 +0,0 @@ -document.querySelector(".UUID").addEventListener( - "click", - (event) => { - let to = event.target.getAttribute("href"); - window.history.pushState({}, to, new URL(to, window.location)); - }, - { once: true }, -); From 8e9b6a8074d574bd652205d3a516bbde012ae43d Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 12 Oct 2024 03:02:26 -0700 Subject: [PATCH 52/62] use attributes dict for all link parameters --- docs/examples/python/nested-routes.py | 28 +++++----- docs/examples/python/route-links.py | 3 +- docs/examples/python/route-parameters.py | 27 ++++------ docs/examples/python/use-params.py | 2 +- docs/examples/python/use-search-params.py | 2 +- src/reactpy_router/components.py | 28 ++++++---- tests/test_core.py | 65 +++++++++++++---------- 7 files changed, 82 insertions(+), 73 deletions(-) diff --git a/docs/examples/python/nested-routes.py b/docs/examples/python/nested-routes.py index 332adbb..01ffb18 100644 --- a/docs/examples/python/nested-routes.py +++ b/docs/examples/python/nested-routes.py @@ -35,30 +35,26 @@ def root(): def home(): return html.div( html.h1("Home Page 🏠"), - link("Messages", to="/messages"), + link({"to": "/messages"}, "Messages"), ) @component def all_messages(): last_messages = {", ".join(msg["with"]): msg for msg in sorted(message_data, key=lambda m: m["id"])} + + messages = [] + for msg in last_messages.values(): + _link = link( + {"to": f"/messages/with/{'-'.join(msg['with'])}"}, + f"Conversation with: {', '.join(msg['with'])}", + ) + msg_from = f"{'' if msg['from'] is None else '🔴'} {msg['message']}" + messages.append(html.li({"key": msg["id"]}, html.p(_link), msg_from)) + return html.div( html.h1("All Messages đŸ’Ŧ"), - html.ul( - [ - html.li( - {"key": msg["id"]}, - html.p( - link( - f"Conversation with: {', '.join(msg['with'])}", - to=f"/messages/with/{'-'.join(msg['with'])}", - ), - ), - f"{'' if msg['from'] is None else '🔴'} {msg['message']}", - ) - for msg in last_messages.values() - ] - ), + html.ul(messages), ) diff --git a/docs/examples/python/route-links.py b/docs/examples/python/route-links.py index 46c98d3..baf428c 100644 --- a/docs/examples/python/route-links.py +++ b/docs/examples/python/route-links.py @@ -1,4 +1,5 @@ from reactpy import component, html, run + from reactpy_router import browser_router, link, route @@ -15,7 +16,7 @@ def root(): def home(): return html.div( html.h1("Home Page 🏠"), - link("Messages", to="/messages"), + link({"to": "/messages"}, "Messages"), ) diff --git a/docs/examples/python/route-parameters.py b/docs/examples/python/route-parameters.py index a2e6707..a794742 100644 --- a/docs/examples/python/route-parameters.py +++ b/docs/examples/python/route-parameters.py @@ -33,30 +33,25 @@ def root(): def home(): return html.div( html.h1("Home Page 🏠"), - link("Messages", to="/messages"), + link({"to": "/messages"}, "Messages"), ) @component def all_messages(): last_messages = {", ".join(msg["with"]): msg for msg in sorted(message_data, key=lambda m: m["id"])} + messages = [] + for msg in last_messages.values(): + msg_hyperlink = link( + {"to": f"/messages/with/{'-'.join(msg['with'])}"}, + f"Conversation with: {', '.join(msg['with'])}", + ) + msg_from = f"{'' if msg['from'] is None else '🔴'} {msg['message']}" + messages.append(html.li({"key": msg["id"]}, html.p(msg_hyperlink), msg_from)) + return html.div( html.h1("All Messages đŸ’Ŧ"), - html.ul( - [ - html.li( - {"key": msg["id"]}, - html.p( - link( - f"Conversation with: {', '.join(msg['with'])}", - to=f"/messages/with/{'-'.join(msg['with'])}", - ), - ), - f"{'' if msg['from'] is None else '🔴'} {msg['message']}", - ) - for msg in last_messages.values() - ] - ), + html.ul(messages), ) diff --git a/docs/examples/python/use-params.py b/docs/examples/python/use-params.py index 76a94df..93a4f07 100644 --- a/docs/examples/python/use-params.py +++ b/docs/examples/python/use-params.py @@ -16,7 +16,7 @@ def root(): "/", html.div( html.h1("Home Page 🏠"), - link("User 123", to="/user/123"), + link({"to": "/user/123"}, "User 123"), ), ), route("/user/{id:int}", user()), diff --git a/docs/examples/python/use-search-params.py b/docs/examples/python/use-search-params.py index 6d3cac0..faeba5e 100644 --- a/docs/examples/python/use-search-params.py +++ b/docs/examples/python/use-search-params.py @@ -16,7 +16,7 @@ def root(): "/", html.div( html.h1("Home Page 🏠"), - link("Search", to="/search?query=reactpy"), + link({"to": "/search?query=reactpy"}, "Search"), ), ), route("/search", search()), diff --git a/src/reactpy_router/components.py b/src/reactpy_router/components.py index 37214d8..9e4701c 100644 --- a/src/reactpy_router/components.py +++ b/src/reactpy_router/components.py @@ -6,6 +6,7 @@ from reactpy import component, html from reactpy.backend.types import Location +from reactpy.core.component import Component from reactpy.core.types import VdomDict from reactpy.web.module import export, module_from_file @@ -16,32 +17,37 @@ module_from_file("reactpy-router", file=Path(__file__).parent / "static" / "bundle.js"), ("History"), ) +"""Client-side portion of history handling""" + Link = export( module_from_file("reactpy-router", file=Path(__file__).parent / "static" / "bundle.js"), ("Link"), ) +"""Client-side portion of link handling""" + + +def link(attributes: dict[str, Any], *children: Any) -> Component: + """Create a link with the given attributes and children.""" + return _link(attributes, *children) @component -def link(*attributes_and_children: Any, to: str | None = None, **kwargs: Any) -> VdomDict: +def _link(attributes: dict[str, Any], *children: Any) -> VdomDict: """A component that renders a link to the given path.""" - if to is None: - raise ValueError("The `to` attribute is required for the `Link` component.") - + attributes = attributes.copy() uuid_string = f"link-{uuid4().hex}" class_name = f"{uuid_string}" set_location = _use_route_state().set_location - attributes = {} - children: tuple[Any] = attributes_and_children - - if attributes_and_children and isinstance(attributes_and_children[0], dict): - attributes = attributes_and_children[0] - children = attributes_and_children[1:] if "className" in attributes: class_name = " ".join([attributes.pop("className"), class_name]) if "class_name" in attributes: # pragma: no cover # TODO: This can be removed when ReactPy stops supporting underscores in attribute names class_name = " ".join([attributes.pop("class_name"), class_name]) + if "href" in attributes and "to" not in attributes: + attributes["to"] = attributes.pop("href") + if "to" not in attributes: + raise ValueError("The `to` attribute is required for the `Link` component.") + to = attributes.pop("to") attrs = { **attributes, @@ -52,7 +58,7 @@ def link(*attributes_and_children: Any, to: str | None = None, **kwargs: Any) -> def on_click(_event: dict[str, Any]) -> None: set_location(Location(**_event)) - return html._(html.a(attrs, *children, **kwargs), Link({"onClick": on_click, "linkClass": uuid_string})) + return html._(html.a(attrs, *children), Link({"onClick": on_click, "linkClass": uuid_string})) def route(path: str, element: Any | None, *routes: Route) -> Route: diff --git a/tests/test_core.py b/tests/test_core.py index 9bdfde8..959643d 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -85,18 +85,18 @@ async def test_navigate_with_link(display: DisplayFixture): def sample(): render_count.current += 1 return browser_router( - route("/", link("Root", to="/a", id="root")), - route("/a", link("A", to="/b", id="a")), - route("/b", link("B", to="/c", id="b")), - route("/c", link("C", to="/default", id="c")), + route("/", link({"to": "/a", "id": "root"}, "Root")), + route("/a", link({"to": "/b", "id": "a"}, "A")), + route("/b", link({"to": "/c", "id": "b"}, "B")), + route("/c", link({"to": "/default", "id": "c"}, "C")), route("{default:any}", html.h1({"id": "default"}, "Default")), ) await display.show(sample) for link_selector in ["#root", "#a", "#b", "#c"]: - lnk = await display.page.wait_for_selector(link_selector) - await lnk.click(delay=CLICK_DELAY) + _link = await display.page.wait_for_selector(link_selector) + await _link.click(delay=CLICK_DELAY) await display.page.wait_for_selector("#default") @@ -164,18 +164,18 @@ async def test_browser_popstate(display: DisplayFixture): @component def sample(): return browser_router( - route("/", link("Root", to="/a", id="root")), - route("/a", link("A", to="/b", id="a")), - route("/b", link("B", to="/c", id="b")), - route("/c", link("C", to="/default", id="c")), + route("/", link({"to": "/a", "id": "root"}, "Root")), + route("/a", link({"to": "/b", "id": "a"}, "A")), + route("/b", link({"to": "/c", "id": "b"}, "B")), + route("/c", link({"to": "/default", "id": "c"}, "C")), route("{default:any}", html.h1({"id": "default"}, "Default")), ) await display.show(sample) for link_selector in ["#root", "#a", "#b", "#c"]: - lnk = await display.page.wait_for_selector(link_selector) - await lnk.click(delay=CLICK_DELAY) + _link = await display.page.wait_for_selector(link_selector) + await _link.click(delay=CLICK_DELAY) await display.page.wait_for_selector("#default") @@ -196,21 +196,21 @@ async def test_relative_links(display: DisplayFixture): @component def sample(): return browser_router( - route("/", link("Root", to="/a", id="root")), - route("/a", link("A", to="/a/a/../b", id="a")), - route("/a/b", link("B", to="../a/b/c", id="b")), - route("/a/b/c", link("C", to="../d", id="c")), - route("/a/d", link("D", to="e", id="d")), - route("/a/e", link("E", to="/a/./f", id="e")), - route("/a/f", link("F", to="../default", id="f")), + route("/", link({"to": "a", "id": "root"}, "Root")), + route("/a", link({"to": "a/a/../b", "id": "a"}, "A")), + route("/a/b", link({"to": "../a/b/c", "id": "b"}, "B")), + route("/a/b/c", link({"to": "../d", "id": "c"}, "C")), + route("/a/d", link({"to": "e", "id": "d"}, "D")), + route("/a/e", link({"to": "/a/./f", "id": "e"}, "E")), + route("/a/f", link({"to": "../default", "id": "f"}, "F")), route("{default:any}", html.h1({"id": "default"}, "Default")), ) await display.show(sample) for link_selector in ["#root", "#a", "#b", "#c", "#d", "#e", "#f"]: - lnk = await display.page.wait_for_selector(link_selector) - await lnk.click(delay=CLICK_DELAY) + _link = await display.page.wait_for_selector(link_selector) + await _link.click(delay=CLICK_DELAY) await display.page.wait_for_selector("#default") @@ -246,23 +246,34 @@ def check_search_params(): @component def sample(): return browser_router( - route("/", link("Root", to="/a?a=1&b=2", id="root")), + route("/", link({"to": "/a?a=1&b=2", "id": "root"}, "Root")), route("/a", check_search_params()), ) await display.show(sample) await display.page.wait_for_selector("#root") - lnk = await display.page.wait_for_selector("#root") - await lnk.click(delay=CLICK_DELAY) + _link = await display.page.wait_for_selector("#root") + await _link.click(delay=CLICK_DELAY) await display.page.wait_for_selector("#success") async def test_link_class_name(display: DisplayFixture): @component def sample(): - return browser_router(route("/", link("Root", to="/a", id="root", className="class1"))) + return browser_router(route("/", link({"to": "/a", "id": "root", "className": "class1"}, "Root"))) await display.show(sample) - lnk = await display.page.wait_for_selector("#root") - assert "class1" in await lnk.get_attribute("class") + _link = await display.page.wait_for_selector("#root") + assert "class1" in await _link.get_attribute("class") + + +async def test_link_href(display: DisplayFixture): + @component + def sample(): + return browser_router(route("/", link({"href": "/a", "id": "root"}, "Root"))) + + await display.show(sample) + + _link = await display.page.wait_for_selector("#root") + assert "/a" in await _link.get_attribute("href") From 05e7012062151369f7c9baf52ddee4e169fd3f2f Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 12 Oct 2024 03:36:44 -0700 Subject: [PATCH 53/62] Allow reconnections to re-obtain the current URL --- src/js/src/index.js | 33 +++++++++++++++++++++++---------- src/reactpy_router/routers.py | 8 +++++--- 2 files changed, 28 insertions(+), 13 deletions(-) diff --git a/src/js/src/index.js b/src/js/src/index.js index 890f39e..4a3a43e 100644 --- a/src/js/src/index.js +++ b/src/js/src/index.js @@ -12,13 +12,13 @@ export function bind(node) { }; } -export function History({ onBrowserBack }) { +export function History({ onHistoryChange }) { // Capture browser "history go back" action and tell the server about it - // Note: Browsers do not allow you to detect "history go forward" actions. + // Note: Browsers do not allow us to detect "history go forward" actions. React.useEffect(() => { - // Register a listener for the "popstate" event and send data back to the server using the `onBrowserBack` callback. + // Register a listener for the "popstate" event and send data back to the server using the `onHistoryChange` callback. const listener = () => { - onBrowserBack({ + onHistoryChange({ pathname: window.location.pathname, search: window.location.search, }); @@ -30,6 +30,17 @@ export function History({ onBrowserBack }) { // Delete the event listener when the component is unmounted return () => window.removeEventListener("popstate", listener); }); + + // Tell the server about the URL during the initial page load + // FIXME: This currently runs every time any component is mounted due to a ReactPy core rendering bug. + // https://github.com/reactive-python/reactpy/pull/1224 + React.useEffect(() => { + onHistoryChange({ + pathname: window.location.pathname, + search: window.location.search, + }); + return () => {}; + }, []); return null; } @@ -49,15 +60,17 @@ export function Link({ onClick, linkClass }) { }; // Register the event listener - document - .querySelector(`.${linkClass}`) - .addEventListener("click", handleClick); + let link = document.querySelector(`.${linkClass}`); + if (link) { + link.addEventListener("click", handleClick); + } // Delete the event listener when the component is unmounted return () => { - document - .querySelector(`.${linkClass}`) - .removeEventListener("click", handleClick); + let link = document.querySelector(`.${linkClass}`); + if (link) { + link.removeEventListener("click", handleClick); + } }; }); return null; diff --git a/src/reactpy_router/routers.py b/src/reactpy_router/routers.py index 74c0c17..9a966d1 100644 --- a/src/reactpy_router/routers.py +++ b/src/reactpy_router/routers.py @@ -69,12 +69,14 @@ def router( for element, params in match ] - def on_browser_back(event: dict[str, Any]) -> None: + def on_history_change(event: dict[str, Any]) -> None: """Callback function used within the JavaScript `History` component.""" - set_location(Location(**event)) + new_location = Location(**event) + if location != new_location: + set_location(new_location) return ConnectionContext( - History({"onBrowserBack": on_browser_back}), # type: ignore[return-value] + History({"onHistoryChange": on_history_change}), # type: ignore[return-value] html._(route_elements), value=Connection(old_conn.scope, location, old_conn.carrier), ) From afa8c8359a7b7a53488ec3dffdf1428b2dfcb8d5 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 12 Oct 2024 03:59:57 -0700 Subject: [PATCH 54/62] Add new changelog item --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 67fd37f..e1ab4e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,7 @@ Using the following categories, list your changes in this order: - Fix bug where changing routes could cause render failure due to key identity. - Fix bug where "Match Any" pattern wouldn't work when used in complex or nested paths. - Fix bug where `link` elements could not have `@component` type children. +- Fix bug where the ReactPy would not detect the current URL after a reconnection. - Fixed flakey tests being flakey on GitHub CI by adding click delays. ## [0.1.1] - 2023-12-13 From 972c275afd93eecdb0bac4f6587ae7f6c026c3df Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 12 Oct 2024 04:01:02 -0700 Subject: [PATCH 55/62] Revert to dumb script for links --- src/js/src/index.js | 3 +++ src/reactpy_router/components.py | 41 ++++++++++++++++++++++++++++--- src/reactpy_router/static/link.js | 8 ++++++ 3 files changed, 49 insertions(+), 3 deletions(-) create mode 100644 src/reactpy_router/static/link.js diff --git a/src/js/src/index.js b/src/js/src/index.js index 4a3a43e..8ead7eb 100644 --- a/src/js/src/index.js +++ b/src/js/src/index.js @@ -44,6 +44,9 @@ export function History({ onHistoryChange }) { return null; } +// FIXME: The Link component is unused due to a ReactPy core rendering bug +// which causes duplicate rendering (and thus duplicate event listeners). +// https://github.com/reactive-python/reactpy/pull/1224 export function Link({ onClick, linkClass }) { // This component is not the actual anchor link. // It is an event listener for the link component created by ReactPy. diff --git a/src/reactpy_router/components.py b/src/reactpy_router/components.py index 9e4701c..d77ee4f 100644 --- a/src/reactpy_router/components.py +++ b/src/reactpy_router/components.py @@ -2,9 +2,10 @@ from pathlib import Path from typing import Any +from urllib.parse import urljoin from uuid import uuid4 -from reactpy import component, html +from reactpy import component, event, html, use_connection from reactpy.backend.types import Location from reactpy.core.component import Component from reactpy.core.types import VdomDict @@ -25,6 +26,8 @@ ) """Client-side portion of link handling""" +link_js_content = (Path(__file__).parent / "static" / "link.js").read_text(encoding="utf-8") + def link(attributes: dict[str, Any], *children: Any) -> Component: """Create a link with the given attributes and children.""" @@ -55,10 +58,42 @@ def _link(attributes: dict[str, Any], *children: Any) -> VdomDict: "className": class_name, } + # FIXME: This component currently works in a "dumb" way by trusting that ReactPy's script tag \ + # properly sets the location due to bugs in ReactPy rendering. + # https://github.com/reactive-python/reactpy/pull/1224 + current_path = use_connection().location.pathname + + @event(prevent_default=True) def on_click(_event: dict[str, Any]) -> None: - set_location(Location(**_event)) + pathname, search = to.split("?", 1) if "?" in to else (to, "") + if search: + search = f"?{search}" + + # Resolve relative paths that match `../foo` + if pathname.startswith("../"): + pathname = urljoin(current_path, pathname) + + # Resolve relative paths that match `foo` + if not pathname.startswith("/"): + pathname = urljoin(current_path, pathname) + + # Resolve relative paths that match `/foo/../bar` + while "/../" in pathname: + part_1, part_2 = pathname.split("/../", 1) + pathname = urljoin(f"{part_1}/", f"../{part_2}") + + # Resolve relative paths that match `foo/./bar` + pathname = pathname.replace("/./", "/") + + set_location(Location(pathname, search)) + + attrs["onClick"] = on_click + + return html._(html.a(attrs, *children), html.script(link_js_content.replace("UUID", uuid_string))) - return html._(html.a(attrs, *children), Link({"onClick": on_click, "linkClass": uuid_string})) + # def on_click(_event: dict[str, Any]) -> None: + # set_location(Location(**_event)) + # return html._(html.a(attrs, *children), Link({"onClick": on_click, "linkClass": uuid_string})) def route(path: str, element: Any | None, *routes: Route) -> Route: diff --git a/src/reactpy_router/static/link.js b/src/reactpy_router/static/link.js new file mode 100644 index 0000000..0ce08b9 --- /dev/null +++ b/src/reactpy_router/static/link.js @@ -0,0 +1,8 @@ +document.querySelector(".UUID").addEventListener( + "click", + (event) => { + let to = event.target.getAttribute("href"); + window.history.pushState({}, to, new URL(to, window.location)); + }, + { once: true }, +); From ff65483c5c33422634730a4cf52c7b78a983e586 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 12 Oct 2024 04:07:43 -0700 Subject: [PATCH 56/62] no cov on exception --- src/reactpy_router/components.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/reactpy_router/components.py b/src/reactpy_router/components.py index d77ee4f..3008065 100644 --- a/src/reactpy_router/components.py +++ b/src/reactpy_router/components.py @@ -48,7 +48,7 @@ def _link(attributes: dict[str, Any], *children: Any) -> VdomDict: class_name = " ".join([attributes.pop("class_name"), class_name]) if "href" in attributes and "to" not in attributes: attributes["to"] = attributes.pop("href") - if "to" not in attributes: + if "to" not in attributes: # pragma: no cover raise ValueError("The `to` attribute is required for the `Link` component.") to = attributes.pop("to") From 7e10dd4a0fc634d53899ab1ca6500efe81bb9b4b Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 12 Oct 2024 04:21:13 -0700 Subject: [PATCH 57/62] fix test --- tests/test_core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_core.py b/tests/test_core.py index 959643d..390236d 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -197,7 +197,7 @@ async def test_relative_links(display: DisplayFixture): def sample(): return browser_router( route("/", link({"to": "a", "id": "root"}, "Root")), - route("/a", link({"to": "a/a/../b", "id": "a"}, "A")), + route("/a", link({"to": "/a/a/../b", "id": "a"}, "A")), route("/a/b", link({"to": "../a/b/c", "id": "b"}, "B")), route("/a/b/c", link({"to": "../d", "id": "c"}, "C")), route("/a/d", link({"to": "e", "id": "d"}, "D")), From 78b5a95aba119c8b5c9e9eb083706df548698926 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 12 Oct 2024 05:49:51 -0700 Subject: [PATCH 58/62] Fix spelling --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e1ab4e0..7380865 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,7 +58,7 @@ Using the following categories, list your changes in this order: - Fix bug where "Match Any" pattern wouldn't work when used in complex or nested paths. - Fix bug where `link` elements could not have `@component` type children. - Fix bug where the ReactPy would not detect the current URL after a reconnection. -- Fixed flakey tests being flakey on GitHub CI by adding click delays. +- Fixed flakey tests on GitHub CI by adding click delays. ## [0.1.1] - 2023-12-13 From 2d8813397894e7d4bd3692fa98cec902d06903ea Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Sat, 12 Oct 2024 20:00:08 -0700 Subject: [PATCH 59/62] Update src/reactpy_router/resolvers.py --- src/reactpy_router/resolvers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/reactpy_router/resolvers.py b/src/reactpy_router/resolvers.py index e349c0f..55c6a01 100644 --- a/src/reactpy_router/resolvers.py +++ b/src/reactpy_router/resolvers.py @@ -56,7 +56,7 @@ def parse_path(self, path: str) -> re.Pattern[str]: # Add the match to the pattern pattern += f"(?P<{name}>{conversion_info['regex']})" - # Keep a local mapping of parameter names to conversion functions. + # Keep a local mapping of the URL's parameter names to conversion functions. self.converter_mapping[name] = conversion_info["func"] # Update the last match end From b1192c5c94d5400ffcca7fc7bf794725812dba5a Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 14 Oct 2024 01:54:23 -0700 Subject: [PATCH 60/62] use unpacking instead of list --- src/reactpy_router/routers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/reactpy_router/routers.py b/src/reactpy_router/routers.py index 9a966d1..a4370fa 100644 --- a/src/reactpy_router/routers.py +++ b/src/reactpy_router/routers.py @@ -77,7 +77,7 @@ def on_history_change(event: dict[str, Any]) -> None: return ConnectionContext( History({"onHistoryChange": on_history_change}), # type: ignore[return-value] - html._(route_elements), + *route_elements, value=Connection(old_conn.scope, location, old_conn.carrier), ) From 9d18fc9eba3f210fd70441d64ffc48c258f1bff0 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 14 Oct 2024 01:54:50 -0700 Subject: [PATCH 61/62] safer query string parsing --- src/reactpy_router/hooks.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/reactpy_router/hooks.py b/src/reactpy_router/hooks.py index 3f83811..c7f6a5f 100644 --- a/src/reactpy_router/hooks.py +++ b/src/reactpy_router/hooks.py @@ -57,12 +57,14 @@ def use_search_params( for the current location. See `urllib.parse.parse_qs` for info on this hook's parameters.""" + location = use_location() + query_string = location.search[1:] if len(location.search) > 1 else "" # TODO: In order to match `react-router`, this will need to return a tuple of the search params \ # and a function to update them. This is currently not possible without reactpy core having a \ # communication layer. return parse_qs( - use_location().search[1:], + query_string, keep_blank_values=keep_blank_values, strict_parsing=strict_parsing, errors=errors, From df4c088715255cb183205bfd0aaed3256f3ae457 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 14 Oct 2024 01:57:30 -0700 Subject: [PATCH 62/62] Remove unused import --- src/reactpy_router/hooks.py | 6 +----- src/reactpy_router/routers.py | 7 +------ 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/src/reactpy_router/hooks.py b/src/reactpy_router/hooks.py index c7f6a5f..3831acf 100644 --- a/src/reactpy_router/hooks.py +++ b/src/reactpy_router/hooks.py @@ -4,11 +4,7 @@ from typing import Any, Callable from urllib.parse import parse_qs -from reactpy import ( - create_context, - use_context, - use_location, -) +from reactpy import create_context, use_context, use_location from reactpy.backend.types import Location from reactpy.types import Context diff --git a/src/reactpy_router/routers.py b/src/reactpy_router/routers.py index a4370fa..25b72c1 100644 --- a/src/reactpy_router/routers.py +++ b/src/reactpy_router/routers.py @@ -6,12 +6,7 @@ from logging import getLogger from typing import Any, Iterator, Literal, Sequence -from reactpy import ( - component, - html, - use_memo, - use_state, -) +from reactpy import component, use_memo, use_state from reactpy.backend.hooks import ConnectionContext, use_connection from reactpy.backend.types import Connection, Location from reactpy.core.types import VdomDict