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/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 0f26793..213f18a 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -12,67 +12,51 @@ 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 - - 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 - - # 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. - - # 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@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 - - # 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@v2 - with: - category: "/language:${{matrix.language}}" + 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 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # 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 + + - 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..95d98da 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/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 34ae5fa..d7437d7 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/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 6fc3233..a98e986 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/setup-python@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..7110bc4 100644 --- a/.github/workflows/test-docs.yml +++ b/.github/workflows/test-docs.yml @@ -1,37 +1,39 @@ 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@v3 - with: - fetch-depth: 0 - - uses: actions/setup-python@v4 - 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: 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 + cd docs + mkdocs build --strict + - name: Check docs examples + run: | + mypy --show-error-codes docs/examples/python/ + ruff check docs/examples/python/ diff --git a/.github/workflows/test-src.yaml b/.github/workflows/test-src.yaml index b5ae7d0..df93152 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/setup-python@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/setup-python@v5 with: python-version: "3.10" - name: Install Python Dependencies diff --git a/.gitignore b/.gitignore index 9155bda..12271fc 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 ---- @@ -108,7 +108,7 @@ celerybeat.pid # Environments .env -.venv +.venv* env/ venv/ ENV/ 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/CHANGELOG.md b/CHANGELOG.md index c673a2f..7380865 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,7 +34,31 @@ 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. +- Fix bug where the ReactPy would not detect the current URL after a reconnection. +- Fixed flakey tests on GitHub CI by adding click delays. ## [0.1.1] - 2023-12-13 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/basic-routing-more-routes.py b/docs/examples/python/basic-routing-more-routes.py index 8ddbebb..32bb31e 100644 --- a/docs/examples/python/basic-routing-more-routes.py +++ b/docs/examples/python/basic-routing-more-routes.py @@ -1,14 +1,13 @@ 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 🔗‍đŸ’Ĩ")), + route("{404:any}", html.h1("Missing Link 🔗‍đŸ’Ĩ")), ) diff --git a/docs/examples/python/basic-routing.py b/docs/examples/python/basic-routing.py index 57b7a37..43c4e65 100644 --- a/docs/examples/python/basic-routing.py +++ b/docs/examples/python/basic-routing.py @@ -1,13 +1,12 @@ 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 🔗‍đŸ’Ĩ")), + route("{404:any}", html.h1("Missing Link 🔗‍đŸ’Ĩ")), ) diff --git a/docs/examples/python/nested-routes.py b/docs/examples/python/nested-routes.py index f03a692..01ffb18 100644 --- a/docs/examples/python/nested-routes.py +++ b/docs/examples/python/nested-routes.py @@ -1,7 +1,8 @@ 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 +18,7 @@ @component def root(): - return simple.router( + return browser_router( route("/", home()), route( "/messages", @@ -26,7 +27,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 🔗‍đŸ’Ĩ")), ) @@ -34,39 +35,32 @@ 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"]) - } + 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), ) @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-links.py b/docs/examples/python/route-links.py index f2be305..baf428c 100644 --- a/docs/examples/python/route-links.py +++ b/docs/examples/python/route-links.py @@ -1,14 +1,14 @@ 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 🔗‍đŸ’Ĩ")), + route("{404:any}", html.h1("Missing Link 🔗‍đŸ’Ĩ")), ) @@ -16,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 4fd30e2..a794742 100644 --- a/docs/examples/python/route-parameters.py +++ b/docs/examples/python/route-parameters.py @@ -1,8 +1,8 @@ from typing import TypedDict from reactpy import component, html, run -from reactpy_router import link, route, simple -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!"}, @@ -18,14 +18,14 @@ @component def root(): - return simple.router( + return browser_router( route("/", home()), route( "/messages", all_messages(), route("/with/{names}", messages_with()), # note the path param ), - route("*", html.h1("Missing Link 🔗‍đŸ’Ĩ")), + route("{404:any}", html.h1("Missing Link 🔗‍đŸ’Ĩ")), ) @@ -33,40 +33,32 @@ 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"]) - } + 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), ) @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] + 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)} đŸ’Ŧ"), html.ul( diff --git a/docs/examples/python/use-params.py b/docs/examples/python/use-params.py index 7b1193a..93a4f07 100644 --- a/docs/examples/python/use-params.py +++ b/docs/examples/python/use-params.py @@ -1,23 +1,26 @@ -from reactpy import component, html +from reactpy import component, html, run -from reactpy_router import link, route, simple, use_params +from reactpy_router import browser_router, link, route, use_params @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 def root(): - return simple.router( + return browser_router( route( "/", html.div( html.h1("Home Page 🏠"), - link("User 123", to="/user/123"), + link({"to": "/user/123"}, "User 123"), ), ), route("/user/{id:int}", user()), ) + + +run(root) diff --git a/docs/examples/python/use-query.py b/docs/examples/python/use-query.py deleted file mode 100644 index a8678cc..0000000 --- a/docs/examples/python/use-query.py +++ /dev/null @@ -1,23 +0,0 @@ -from reactpy import component, html - -from reactpy_router import link, route, simple, use_query - - -@component -def search(): - query = use_query() - return html.h1(f"Search Results for {query['q'][0]} 🔍") - - -@component -def root(): - return simple.router( - route( - "/", - html.div( - html.h1("Home Page 🏠"), - link("Search", to="/search?q=reactpy"), - ), - ), - route("/about", html.h1("About Page 📖")), - ) diff --git a/docs/examples/python/use-search-params.py b/docs/examples/python/use-search-params.py new file mode 100644 index 0000000..faeba5e --- /dev/null +++ b/docs/examples/python/use-search-params.py @@ -0,0 +1,26 @@ +from reactpy import component, html, run + +from reactpy_router import browser_router, link, route, use_search_params + + +@component +def search(): + search_params = use_search_params() + return html._(html.h1(f"Search Results for {search_params['query'][0]} 🔍"), html.p("Nothing (yet).")) + + +@component +def root(): + return browser_router( + route( + "/", + html.div( + html.h1("Home Page 🏠"), + link({"to": "/search?query=reactpy"}, "Search"), + ), + ), + route("/search", search()), + ) + + +run(root) diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index d93b302..5173834 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/simple-application.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_bases: false + show_root_members_full_path: true 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/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], - ) 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/docs/src/learn/hooks.md b/docs/src/learn/hooks.md index 3479ffc..7ed3821 100644 --- a/docs/src/learn/hooks.md +++ b/docs/src/learn/hooks.md @@ -6,19 +6,19 @@ Several pre-fabricated hooks are provided to help integrate with routing feature --- -## Use Query +## Use Search Parameters -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`][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 -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 af62578..f0abfc1 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`][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" 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" @@ -19,11 +19,11 @@ Here's a basic example showing how to use `#!python simple.router` with two rout {% 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". -### 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`][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} @@ -38,7 +38,9 @@ In this case, `#!python param` is the name of the route parameter and the option | `#!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: @@ -50,15 +52,15 @@ 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" - 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`][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/simple-application.md b/docs/src/learn/your-first-app.md similarity index 93% rename from docs/src/learn/simple-application.md rename to docs/src/learn/your-first-app.md index 8f2a5b5..4b67677 100644 --- a/docs/src/learn/simple-application.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.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`][reactpy_router.browser_router].

@@ -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. diff --git a/docs/src/reference/components.md b/docs/src/reference/components.md new file mode 100644 index 0000000..f1cc570 --- /dev/null +++ b/docs/src/reference/components.md @@ -0,0 +1,4 @@ +::: 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..d3cfa18 --- /dev/null +++ b/docs/src/reference/hooks.md @@ -0,0 +1,4 @@ +::: reactpy_router + + options: + members: ["use_params", "use_search_params"] diff --git a/docs/src/reference/router.md b/docs/src/reference/router.md index 2fcea59..5700cf5 100644 --- a/docs/src/reference/router.md +++ b/docs/src/reference/router.md @@ -1 +1,4 @@ -::: src.reactpy_router.simple +::: 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 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 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/rollup.config.js b/src/js/rollup.config.js index ab1d0b1..396fd87 100644 --- a/src/js/rollup.config.js +++ b/src/js/rollup.config.js @@ -5,7 +5,7 @@ import replace from "rollup-plugin-replace"; export default { input: "src/index.js", output: { - file: "../reactpy_router/bundle.js", + file: "../reactpy_router/static/bundle.js", format: "esm", }, plugins: [ diff --git a/src/js/src/index.js b/src/js/src/index.js index 1f43092..8ead7eb 100644 --- a/src/js/src/index.js +++ b/src/js/src/index.js @@ -1,8 +1,5 @@ import React from "react"; import ReactDOM from "react-dom"; -import htm from "htm"; - -const html = htm.bind(React.createElement); export function bind(node) { return { @@ -15,30 +12,69 @@ export function bind(node) { }; } -export function History({ onChange }) { - // capture changes to the browser's history +export function History({ onHistoryChange }) { + // Capture browser "history go back" action and tell the server about it + // 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 `onHistoryChange` callback. const listener = () => { - onChange({ + onHistoryChange({ 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({ to, onClick, children, ...props }) { - const handleClick = (event) => { - event.preventDefault(); - window.history.pushState({}, to, new URL(to, window.location)); - onClick({ + // 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; +} - return html`${children}`; +// 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. + 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 + let link = document.querySelector(`.${linkClass}`); + if (link) { + link.addEventListener("click", handleClick); + } + + // Delete the event listener when the component is unmounted + return () => { + let link = document.querySelector(`.${linkClass}`); + if (link) { + link.removeEventListener("click", handleClick); + } + }; + }); + return null; } diff --git a/src/reactpy_router/__init__.py b/src/reactpy_router/__init__.py index cb2fcbc..fa2781f 100644 --- a/src/reactpy_router/__init__.py +++ b/src/reactpy_router/__init__.py @@ -1,20 +1,16 @@ # the version is statically loaded by setup.py __version__ = "0.1.1" -from . import simple -from .core import create_router, link, route, router_component, use_params, use_query -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", - "Route", - "RouteCompiler", - "router_component", - "RouteResolver", - "simple", + "browser_router", "use_params", - "use_query", + "use_search_params", ) diff --git a/src/reactpy_router/components.py b/src/reactpy_router/components.py new file mode 100644 index 0000000..3008065 --- /dev/null +++ b/src/reactpy_router/components.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +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.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 + +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"), +) +"""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""" + +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.""" + return _link(attributes, *children) + + +@component +def _link(attributes: dict[str, Any], *children: Any) -> VdomDict: + """A component that renders a link to the given path.""" + attributes = attributes.copy() + uuid_string = f"link-{uuid4().hex}" + class_name = f"{uuid_string}" + set_location = _use_route_state().set_location + 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: # pragma: no cover + raise ValueError("The `to` attribute is required for the `Link` component.") + to = attributes.pop("to") + + attrs = { + **attributes, + "href": to, + "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: + 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))) + + # 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: + """Create a route with the given path, element, and child routes.""" + return Route(path, element, routes) diff --git a/src/reactpy_router/converters.py b/src/reactpy_router/converters.py new file mode 100644 index 0000000..5fe1b5e --- /dev/null +++ b/src/reactpy_router/converters.py @@ -0,0 +1,37 @@ +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, + }, + "any": { + "regex": r".*", + "func": str, + }, +} +"""The conversion types supported by the default Resolver.""" diff --git a/src/reactpy_router/core.py b/src/reactpy_router/core.py deleted file mode 100644 index 490a78c..0000000 --- a/src/reactpy_router/core.py +++ /dev/null @@ -1,144 +0,0 @@ -"""Core functionality for the reactpy-router package.""" - -from __future__ import annotations - -from dataclasses import dataclass, replace -from pathlib import Path -from typing import Any, Callable, Iterator, Sequence, TypeVar -from urllib.parse import parse_qs - -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 - -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_component(*routes, compiler=compiler) - - return wrapper - - -@component -def router_component( - *routes: R, - compiler: RouteCompiler[R], -) -> VdomDict | None: - """A component that renders the first matching route using the given compiler""" - - 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)) - - if match is not None: - element, params = match - return html._( - 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))}), - ) - - return None - - -@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 - attrs = { - **attributes, - "to": to, - "onClick": lambda event: set_location(Location(**event)), - } - return _link(attrs, *children) - - -def use_params() -> dict[str, Any]: - """Get parameters from the currently matching route pattern""" - return _use_route_state().params - - -def use_query( - 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.""" - 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 -) -> tuple[Any, dict[str, Any]] | None: - for resolver in compiled_routes: - match = resolver.resolve(location.pathname) - if match is not None: - return match - return None - - -_link, _history = export( - module_from_file("reactpy-router", file=Path(__file__).parent / "bundle.js"), - ("Link", "History"), -) - - -@dataclass -class _RouteState: - set_location: Callable[[Location], None] - params: dict[str, Any] - - -def _use_route_state() -> _RouteState: - route_state = use_context(_route_state_context) - assert route_state is not None - 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..3831acf --- /dev/null +++ b/src/reactpy_router/hooks.py @@ -0,0 +1,69 @@ +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: # 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?" + ) + + 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 the query string in the URL \ + 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( + query_string, + 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 new file mode 100644 index 0000000..55c6a01 --- /dev/null +++ b/src/reactpy_router/resolvers.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import re +from typing import Any + +from reactpy_router.converters import CONVERTERS +from reactpy_router.types import ConversionInfo, ConverterMapping, Route + +__all__ = ["StarletteResolver"] + + +class StarletteResolver: + """URL resolver that matches routes using starlette's URL routing syntax. + + However, this resolver adds a few additional parameter types on top of Starlette's syntax.""" + + def __init__( + self, + route: Route, + param_pattern=r"{(?P\w+)(?P:\w+)?}", + converters: dict[str, ConversionInfo] | None = None, + ) -> None: + self.element = route.element + self.registered_converters = converters or CONVERTERS + self.converter_mapping: ConverterMapping = {} + self.param_regex = re.compile(param_pattern) + self.pattern = self.parse_path(route.path) + self.key = self.pattern.pattern # Unique identifier for ReactPy rendering + + def parse_path(self, path: str) -> re.Pattern[str]: + # Convert path to regex pattern, then interpret using registered converters + pattern = "^" + last_match_end = 0 + + # Iterate through matches of the parameter pattern + for match in self.param_regex.finditer(path): + # 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 + 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 + + # Add the string before the match to the pattern + pattern += re.escape(path[last_match_end : match.start()]) + + # Add the match to the pattern + pattern += f"(?P<{name}>{conversion_info['regex']})" + + # 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 + last_match_end = match.end() + + # Add the string after the last match + pattern += f"{re.escape(path[last_match_end:])}$" + + return re.compile(pattern) + + def resolve(self, path: str) -> tuple[Any, dict[str, Any]] | None: + match = self.pattern.match(path) + if match: + # Convert the matched groups to the correct types + params = { + 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) + return None diff --git a/src/reactpy_router/routers.py b/src/reactpy_router/routers.py new file mode 100644 index 0000000..25b72c1 --- /dev/null +++ b/src/reactpy_router/routers.py @@ -0,0 +1,110 @@ +"""URL router implementation for ReactPy""" + +from __future__ import annotations + +from dataclasses import replace +from logging import getLogger +from typing import Any, Iterator, Literal, Sequence + +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 +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 CompiledRoute, Resolver, Router, RouteType + +__all__ = ["browser_router", "create_router"] +_logger = getLogger(__name__) + + +def create_router(resolver: Resolver[RouteType]) -> Router[RouteType]: + """A decorator that turns a resolver into a router""" + + 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 JavaScript DOM History API to manage the history stack.""" + + +@component +def router( + *routes: RouteType, + resolver: Resolver[RouteType], +) -> VdomDict | None: + """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.""" + + old_conn = use_connection() + location, set_location = use_state(old_conn.location) + + resolvers = use_memo( + lambda: tuple(map(resolver, _iter_routes(routes))), + dependencies=(resolver, 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 + ] + + def on_history_change(event: dict[str, Any]) -> None: + """Callback function used within the JavaScript `History` component.""" + new_location = Location(**event) + if location != new_location: + set_location(new_location) + + return ConnectionContext( + History({"onHistoryChange": on_history_change}), # type: ignore[return-value] + *route_elements, + value=Connection(old_conn.scope, location, old_conn.carrier), + ) + + return None + + +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] + yield parent + + +def _match_route( + compiled_routes: Sequence[CompiledRoute], + 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] + + # 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: + _logger.debug("No matching route found for %s", location.pathname) + + return matches diff --git a/src/reactpy_router/simple.py b/src/reactpy_router/simple.py deleted file mode 100644 index 256f78d..0000000 --- a/src/reactpy_router/simple.py +++ /dev/null @@ -1,98 +0,0 @@ -"""A simple router implementation for ReactPy""" - -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 - -__all__ = ["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] = { - "str": { - "regex": r"[^/]+", - "func": str, - }, - "int": { - "regex": r"\d+", - "func": int, - }, - "float": { - "regex": r"\d+(\.\d+)?", - "func": float, - }, - "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, - }, - "path": { - "regex": r".+", - "func": str, - }, -} -"""The supported conversion types""" - - -router = create_router(SimpleResolver) -"""The simple router""" 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 }, +); diff --git a/src/reactpy_router/types.py b/src/reactpy_router/types.py index a91787e..15a77c4 100644 --- a/src/reactpy_router/types.py +++ b/src/reactpy_router/types.py @@ -1,27 +1,30 @@ -"""Types for reactpy_router""" +"""Type definitions for the `reactpy-router` package.""" from __future__ import annotations from dataclasses import dataclass, field -from typing import Any, Sequence, TypeVar +from typing import Any, Callable, 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] @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 @@ -29,29 +32,37 @@ def __hash__(self) -> int: return hash((self.path, key, self.routes)) -R = TypeVar("R", bound=Route, contravariant=True) +RouteType = TypeVar("RouteType", bound=Route) +RouteType_contra = TypeVar("RouteType_contra", bound=Route, contravariant=True) -class Router(Protocol[R]): - """Return a component that renders the first matching route""" +class Router(Protocol[RouteType_contra]): + """Return a component that renders the first matching route.""" - def __call__(self, *routes: R) -> ComponentType: - ... + def __call__(self, *routes: RouteType_contra) -> ComponentType: ... -class RouteCompiler(Protocol[R]): - """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) -> RouteResolver: - ... + def __call__(self, route: RouteType_contra) -> CompiledRoute: ... -class RouteResolver(Protocol): - """A compiled route that can be matched against a path""" +class CompiledRoute(Protocol): + """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.""" + + regex: str + """The regex to match the conversion type.""" + func: ConversionFunc + """The function to convert the matched string to the expected type.""" diff --git a/tests/conftest.py b/tests/conftest.py index 573eba5..18e3646 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,31 +1,44 @@ +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( - "--headed", - dest="headed", + "--headless", + dest="headless", action="store_true", - help="Open a browser window when runnging web-based tests", + 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 + 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=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() diff --git a/tests/test_core.py b/tests/test_core.py index 77577b3..390236d 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1,8 +1,13 @@ +import os from typing import Any 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 browser_router, link, route, use_params, use_search_params + +GITHUB_ACTIONS = os.getenv("GITHUB_ACTIONS", "").lower() == "true" +CLICK_DELAY = 350 if GITHUB_ACTIONS else 25 # Delay in miliseconds. async def test_simple_router(display: DisplayFixture): @@ -18,7 +23,7 @@ def check_location(): @component def sample(): - return simple.router( + return browser_router( make_location_check("/a"), make_location_check("/b"), make_location_check("/c"), @@ -40,7 +45,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() @@ -49,7 +55,7 @@ def sample(): async def test_nested_routes(display: DisplayFixture): @component def sample(): - return simple.router( + return browser_router( route( "/a", html.h1({"id": "a"}, "A"), @@ -78,19 +84,19 @@ async def test_navigate_with_link(display: DisplayFixture): @component def sample(): render_count.current += 1 - return simple.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("*", html.h1({"id": "default"}, "Default")), + return browser_router( + 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() + _link = await display.page.wait_for_selector(link_selector) + await _link.click(delay=CLICK_DELAY) await display.page.wait_for_selector("#default") @@ -109,7 +115,7 @@ def check_params(): @component def sample(): - return simple.router( + return browser_router( route( "/first/{first:str}", check_params(), @@ -135,17 +141,17 @@ 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 def check_query(): - assert use_query() == expected_query + assert use_search_params() == expected_query return html.h1({"id": "success"}, "success") @component def sample(): - return simple.router(route("/", check_query())) + return browser_router(route("/", check_query())) await display.show(sample) @@ -157,19 +163,19 @@ def sample(): async def test_browser_popstate(display: DisplayFixture): @component def sample(): - return simple.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("*", html.h1({"id": "default"}, "Default")), + return browser_router( + 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() + _link = await display.page.wait_for_selector(link_selector) + await _link.click(delay=CLICK_DELAY) await display.page.wait_for_selector("#default") @@ -189,24 +195,28 @@ def sample(): async def test_relative_links(display: DisplayFixture): @component def sample(): - return simple.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")), - 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")), + return browser_router( + 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"]: - lnk = await display.page.wait_for_selector(link_selector) - await lnk.click() + for link_selector in ["#root", "#a", "#b", "#c", "#d", "#e", "#f"]: + _link = await display.page.wait_for_selector(link_selector) + await _link.click(delay=CLICK_DELAY) 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") @@ -224,3 +234,46 @@ 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({"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") + _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({"to": "/a", "id": "root", "className": "class1"}, "Root"))) + + await display.show(sample) + + _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") diff --git a/tests/test_resolver.py b/tests/test_resolver.py new file mode 100644 index 0000000..4e8a669 --- /dev/null +++ b/tests/test_resolver.py @@ -0,0 +1,57 @@ +import re +import uuid + +import pytest + +from reactpy_router import route +from reactpy_router.resolvers import StarletteResolver + + +def test_resolve_any(): + 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 = StarletteResolver(route("/", None)) + 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.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.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(): + 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 = 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} diff --git a/tests/test_simple.py b/tests/test_simple.py deleted file mode 100644 index 9ec8a2b..0000000 --- a/tests/test_simple.py +++ /dev/null @@ -1,54 +0,0 @@ -import re -import uuid - -import pytest - -from reactpy_router.simple import parse_path - - -def test_parse_path(): - assert parse_path("/a/b/c") == (re.compile("^/a/b/c$"), {}) - assert parse_path("/a/{b}/c") == ( - re.compile(r"^/a/(?P[^/]+)/c$"), - {"b": str}, - ) - assert parse_path("/a/{b:int}/c") == ( - re.compile(r"^/a/(?P\d+)/c$"), - {"b": int}, - ) - assert 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") == ( - 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") == ( - 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}, - ) - - -def test_parse_path_unkown_conversion(): - with pytest.raises(ValueError): - 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") == ( - # ^ regex character - re.compile(r"^/a/(?P\d+)/c\.d$"), - {"b": int}, - ) - - -def test_match_star_path(): - assert parse_path("*") == (re.compile("^.*$"), {})