From 53f5488df27cf39d1e760c339ce6c9d95709ecd8 Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Mon, 2 Dec 2024 04:08:38 -0800 Subject: [PATCH 1/4] Synchronize CI with other reactive-python repos (#48) --- .github/workflows/publish-develop-docs.yml | 8 ++- .github/workflows/publish-latest-docs.yml | 7 ++- .github/workflows/publish-py.yaml | 33 ------------ .github/workflows/publish-python.yaml | 27 ++++++++++ .github/workflows/test-docs.yml | 12 ++--- .../{test-style.yml => test-javascript.yml} | 12 ++--- .../{test-src.yml => test-python.yml} | 30 ++++++++--- CHANGELOG.md | 4 +- docs/mkdocs.yml | 2 +- docs/src/about/contributing.md | 4 ++ docs/src/dictionary.txt | 1 + pyproject.toml | 52 +++++++++++++------ 12 files changed, 109 insertions(+), 83 deletions(-) delete mode 100644 .github/workflows/publish-py.yaml create mode 100644 .github/workflows/publish-python.yaml rename .github/workflows/{test-style.yml => test-javascript.yml} (70%) rename .github/workflows/{test-src.yml => test-python.yml} (73%) diff --git a/.github/workflows/publish-develop-docs.yml b/.github/workflows/publish-develop-docs.yml index 584be64..a1434ba 100644 --- a/.github/workflows/publish-develop-docs.yml +++ b/.github/workflows/publish-develop-docs.yml @@ -4,7 +4,7 @@ on: branches: - main jobs: - deploy: + publish-develop-docs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -17,14 +17,12 @@ jobs: with: python-version: 3.x - name: Install dependencies - run: | - pip install --upgrade hatch uv + run: pip install --upgrade pip hatch uv - name: Configure Git run: | git config user.name github-actions git config user.email github-actions@github.com - name: Publish Develop Docs - run: | - hatch run docs:deploy_develop + run: hatch run docs:deploy_develop concurrency: group: publish-docs diff --git a/.github/workflows/publish-latest-docs.yml b/.github/workflows/publish-latest-docs.yml index 3ff83a7..0a1e996 100644 --- a/.github/workflows/publish-latest-docs.yml +++ b/.github/workflows/publish-latest-docs.yml @@ -18,13 +18,12 @@ jobs: python-version: 3.x - name: Install dependencies run: | - pip install --upgrade hatch uv + pip install --upgrade pip hatch uv - name: Configure Git run: | git config user.name github-actions git config user.email github-actions@github.com - - name: Publish Develop Docs - run: | - hatch run docs:deploy_latest ${{ github.ref_name }} + - name: Publish ${{ github.event.release.name }} Docs + run: hatch run docs:deploy_latest ${{ github.ref_name }} concurrency: group: publish-docs diff --git a/.github/workflows/publish-py.yaml b/.github/workflows/publish-py.yaml deleted file mode 100644 index d69b475..0000000 --- a/.github/workflows/publish-py.yaml +++ /dev/null @@ -1,33 +0,0 @@ -# This workflows will upload a Python Package using Twine when a release is created -# For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries - -name: Publish Python - -on: - release: - types: [published] - -jobs: - publish-package: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.x" - - name: Install dependencies - run: | - pip3 --quiet install --upgrade hatch uv twine - - name: Build Package - run: | - hatch build --clean - - name: Publish to PyPI - env: - TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} - TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} - run: | - twine upload dist/* diff --git a/.github/workflows/publish-python.yaml b/.github/workflows/publish-python.yaml new file mode 100644 index 0000000..a2228a7 --- /dev/null +++ b/.github/workflows/publish-python.yaml @@ -0,0 +1,27 @@ +name: Publish Python + +on: + release: + types: [published] + +jobs: + publish-python: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.x" + - name: Install dependencies + run: pip install --upgrade pip hatch uv + - name: Build Package + run: hatch build --clean + - name: Publish to PyPI + env: + HATCH_INDEX_USER: ${{ secrets.PYPI_USERNAME }} + HATCH_INDEX_AUTH: ${{ secrets.PYPI_PASSWORD }} + run: hatch publish --yes diff --git a/.github/workflows/test-docs.yml b/.github/workflows/test-docs.yml index 6a4de7b..0a81d18 100644 --- a/.github/workflows/test-docs.yml +++ b/.github/workflows/test-docs.yml @@ -24,12 +24,10 @@ jobs: with: python-version: 3.x - name: Install Python Dependencies - run: | - pip3 --quiet install --upgrade hatch uv ruff + run: pip install --upgrade pip hatch uv + - name: Check documentation links + run: hatch run docs:linkcheck - name: Check docs build - run: | - hatch run docs:build - hatch run docs:linkcheck + run: hatch run docs:build - name: Check docs examples - run: | - ruff check docs/examples/python/ + run: hatch fmt docs --check diff --git a/.github/workflows/test-style.yml b/.github/workflows/test-javascript.yml similarity index 70% rename from .github/workflows/test-style.yml rename to .github/workflows/test-javascript.yml index c1e45ab..5f62c0e 100644 --- a/.github/workflows/test-style.yml +++ b/.github/workflows/test-javascript.yml @@ -1,4 +1,4 @@ -name: Test Style +name: Test on: push: @@ -9,7 +9,7 @@ on: - main jobs: - test-style: + javascript: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -18,10 +18,8 @@ jobs: bun-version: latest - uses: actions/setup-python@v5 with: - python-version: "3.x" + python-version: 3.x - name: Install Python Dependencies - run: | - pip3 install hatch uv + run: pip install --upgrade pip hatch uv - name: Run Tests - run: | - hatch fmt --check + run: hatch run javascript:check diff --git a/.github/workflows/test-src.yml b/.github/workflows/test-python.yml similarity index 73% rename from .github/workflows/test-src.yml rename to .github/workflows/test-python.yml index 6f1aebf..9390316 100644 --- a/.github/workflows/test-src.yml +++ b/.github/workflows/test-python.yml @@ -11,7 +11,7 @@ on: - cron: "0 0 * * *" jobs: - source: + python-source: runs-on: ubuntu-latest strategy: matrix: @@ -26,8 +26,7 @@ jobs: with: python-version: ${{ matrix.python-version }} - name: Install Python Dependencies - run: | - pip3 install hatch uv + run: pip install --upgrade pip hatch uv - name: Run Tests run: | hatch test --cover --python ${{ matrix.python-version }} @@ -40,18 +39,18 @@ jobs: if-no-files-found: error include-hidden-files: true retention-days: 7 - coverage: + + python-coverage: needs: - - source + - python-source runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Use Latest Python - uses: actions/setup-python@v5 + - uses: actions/setup-python@v5 with: python-version: "3.x" - name: Install Python Dependencies - run: python -m pip install --upgrade coverage[toml] + run: pip install --upgrade coverage[toml] - name: Download data uses: actions/download-artifact@v4 with: @@ -66,3 +65,18 @@ jobs: with: name: coverage-report path: htmlcov + + python-formatting: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - uses: actions/setup-python@v5 + with: + python-version: "3.x" + - name: Install Python Dependencies + run: pip install --upgrade pip hatch uv + - name: Check Python formatting + run: hatch fmt src tests --check diff --git a/CHANGELOG.md b/CHANGELOG.md index 66617e3..e3fa727 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,7 +34,9 @@ Using the following categories, list your changes in this order: ## [Unreleased] -- Nothing (yet)! +### Changed + +- Set upper limit on ReactPy version to `<2.0.0`. ## [1.0.3] - 2024-11-21 diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 8f8a1a1..28df470 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -145,6 +145,6 @@ site_description: It's React-Router, but in Python. 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) +repo_name: ReactPy Router edit_uri: edit/main/docs/src/ docs_dir: src diff --git a/docs/src/about/contributing.md b/docs/src/about/contributing.md index d23b77f..c7cf012 100644 --- a/docs/src/about/contributing.md +++ b/docs/src/about/contributing.md @@ -5,6 +5,7 @@ If you plan to make code changes to this repository, you will need to install th - [Git](https://git-scm.com/downloads) - [Python 3.9+](https://www.python.org/downloads/) - [Hatch](https://hatch.pypa.io/latest/) +- [Bun](https://bun.sh/) Once you finish installing these dependencies, you can clone this repository: @@ -40,6 +41,8 @@ By utilizing `hatch`, the following commands are available to manage the develop | `hatch fmt --check` | Run all linters and formatters, but do not save fixes to the disk | | `hatch fmt --linter` | Run only linters | | `hatch fmt --formatter` | Run only formatters | +| `hatch run javascript:check` | Run the JavaScript linter/formatter | +| `hatch run javascript:fix` | Run the JavaScript linter/formatter and write fixes to disk | ??? tip "Configure your IDE for linting" @@ -54,6 +57,7 @@ By utilizing `hatch`, the following commands are available to manage the develop | `hatch run docs:serve` | Start the [`mkdocs`](https://www.mkdocs.org/) server to view documentation locally | | `hatch run docs:build` | Build the documentation | | `hatch run docs:linkcheck` | Check for broken links in the documentation | +| `hatch fmt docs --check` | Run linter on code examples in the documentation | ### Environment Management diff --git a/docs/src/dictionary.txt b/docs/src/dictionary.txt index 435700c..b487e39 100644 --- a/docs/src/dictionary.txt +++ b/docs/src/dictionary.txt @@ -39,6 +39,7 @@ backhaul sublicense contravariant formatters +linter linters linting pytest diff --git a/pyproject.toml b/pyproject.toml index 956b2a2..6472bdf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,6 +2,10 @@ build-backend = "hatchling.build" requires = ["hatchling", "hatch-build-scripts"] +############################## +# >>> Hatch Build Config <<< # +############################## + [project] name = "reactpy_router" description = "A URL router for ReactPy." @@ -24,7 +28,7 @@ classifiers = [ "Environment :: Web Environment", "Typing :: Typed", ] -dependencies = ["reactpy>=1.0.0", "typing_extensions"] +dependencies = ["reactpy>=1.0.0, <2.0.0", "typing_extensions"] dynamic = ["version"] urls.Changelog = "https://reactive-python.github.io/reactpy-router/latest/about/changelog/" urls.Documentation = "https://reactive-python.github.io/reactpy-router/latest/" @@ -35,10 +39,10 @@ path = "src/reactpy_router/__init__.py" [tool.hatch.build.targets.sdist] include = ["/src"] -artifacts = ["/src/reactpy_router/static/bundle.js"] +artifacts = ["/src/reactpy_router/static/"] [tool.hatch.build.targets.wheel] -artifacts = ["/src/reactpy_router/static/bundle.js"] +artifacts = ["/src/reactpy_router/static/"] [tool.hatch.metadata] license-files = { paths = ["LICENSE.md"] } @@ -53,7 +57,9 @@ commands = [ ] artifacts = [] -# >>> Hatch Tests <<< +############################# +# >>> Hatch Test Runner <<< # +############################# [tool.hatch.envs.hatch-test] extra-dependencies = ["pytest-sugar", "anyio", "reactpy[testing,starlette]"] @@ -63,24 +69,30 @@ matrix-name-format = "{variable}-{value}" [[tool.hatch.envs.hatch-test.matrix]] python = ["3.9", "3.10", "3.11", "3.12"] -# >>> Hatch Documentation Scripts <<< +[tool.pytest.ini_options] +addopts = """\ + --strict-config + --strict-markers + """ + +####################################### +# >>> Hatch Documentation Scripts <<< # +####################################### [tool.hatch.envs.docs] template = "docs" -detached = true dependencies = [ "mkdocs", "mkdocs-git-revision-date-localized-plugin", "mkdocs-material==9.4.0", "mkdocs-include-markdown-plugin", - "linkcheckmd", "mkdocs-spellcheck[all]", "mkdocs-git-authors-plugin", "mkdocs-minify-plugin", "mike", "mkdocstrings[python]", - "black", - "reactpy_router @ {root:uri}", + "black", # Used by mkdocstrings for auto formatting + "linkcheckmd", ] [tool.hatch.envs.docs.scripts] @@ -94,10 +106,23 @@ linkcheck = [ deploy_latest = ["cd docs && mike deploy --push --update-aliases {args} latest"] deploy_develop = ["cd docs && mike deploy --push develop"] +############################ +# >>> Hatch JS Scripts <<< # +############################ -# >>> Generic Tools <<< +[tool.hatch.envs.javascript] +detached = true + +[tool.hatch.envs.javascript.scripts] +check = ["cd src/js && bun install", "cd src/js && bun run check"] +fix = ["cd src/js && bun install", "cd src/js && bun run format"] + +######################### +# >>> Generic Tools <<< # +######################### [tool.ruff] +extend-exclude = [".venv/*", ".eggs/*", "build/*"] line-length = 120 format.preview = true lint.extend-ignore = [ @@ -111,13 +136,6 @@ lint.extend-ignore = [ "SLF001", # Private member accessed ] lint.preview = true -extend-exclude = [".venv/*", ".eggs/*", "build/*"] - -[tool.pytest.ini_options] -addopts = """\ - --strict-config - --strict-markers - """ [tool.coverage.run] branch = true From 44f76d231da0403705365e1741342f3dae757835 Mon Sep 17 00:00:00 2001 From: Steve Jones Date: Tue, 7 Jan 2025 01:52:33 +0000 Subject: [PATCH 2/4] Fix `link` reference to `currentTarget` (#49) Co-authored-by: Archmonger <16909269+Archmonger@users.noreply.github.com> --- CHANGELOG.md | 4 ++++ src/reactpy_router/static/link.js | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3fa727..6658cea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,10 @@ Using the following categories, list your changes in this order: - Set upper limit on ReactPy version to `<2.0.0`. +### Fixed + +- Fixed bug where `link` element sometimes would sometimes not retrieve the correct `href` attribute. + ## [1.0.3] - 2024-11-21 ### Fixed diff --git a/src/reactpy_router/static/link.js b/src/reactpy_router/static/link.js index 9f78cc5..7ab069b 100644 --- a/src/reactpy_router/static/link.js +++ b/src/reactpy_router/static/link.js @@ -4,7 +4,7 @@ document.querySelector(".UUID").addEventListener( // Prevent default if ctrl isn't pressed if (!event.ctrlKey) { event.preventDefault(); - let to = event.target.getAttribute("href"); + let to = event.currentTarget.getAttribute("href"); let new_url = new URL(to, window.location); // Deduplication needed due to ReactPy rendering bug From 2d79831a5b0ad333b6a66b05bfb62cb098c3f237 Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Sat, 11 Jan 2025 22:40:16 -0800 Subject: [PATCH 3/4] Refactoring related to ReactPy v1.1.0 (#50) --- .github/workflows/test-python.yml | 15 ++++ CHANGELOG.md | 26 ++---- README.md | 4 +- docs/src/about/contributing.md | 1 + pyproject.toml | 14 +++- src/js/src/components.ts | 101 +++++++++++++++++++++++ src/js/src/index.ts | 130 +----------------------------- src/js/src/types.ts | 4 - src/js/src/utils.ts | 12 ++- src/reactpy_router/components.py | 53 ++---------- src/reactpy_router/routers.py | 31 +++---- src/reactpy_router/static/link.js | 17 ---- src/reactpy_router/types.py | 6 +- 13 files changed, 169 insertions(+), 245 deletions(-) create mode 100644 src/js/src/components.ts delete mode 100644 src/reactpy_router/static/link.js diff --git a/.github/workflows/test-python.yml b/.github/workflows/test-python.yml index 9390316..bbac572 100644 --- a/.github/workflows/test-python.yml +++ b/.github/workflows/test-python.yml @@ -80,3 +80,18 @@ jobs: run: pip install --upgrade pip hatch uv - name: Check Python formatting run: hatch fmt src tests --check + + python-types: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - uses: actions/setup-python@v5 + with: + python-version: 3.x + - name: Install Python Dependencies + run: pip install --upgrade pip hatch uv + - name: Run Python type checker + run: hatch run python:type_check diff --git a/CHANGELOG.md b/CHANGELOG.md index 6658cea..1fced87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,25 +10,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +Don't forget to remove deprecated code on each major release! +--> @@ -36,7 +21,10 @@ Using the following categories, list your changes in this order: ### Changed -- Set upper limit on ReactPy version to `<2.0.0`. +- Set maximum ReactPy version to `<2.0.0`. +- Set minimum ReactPy version to `1.1.0`. +- `link` element now calculates URL changes using the client. +- Refactoring related to `reactpy>=1.1.0` changes. ### Fixed diff --git a/README.md b/README.md index 4fcafc9..24ad465 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # ReactPy Router

- - + + diff --git a/docs/src/about/contributing.md b/docs/src/about/contributing.md index c7cf012..82b82f1 100644 --- a/docs/src/about/contributing.md +++ b/docs/src/about/contributing.md @@ -43,6 +43,7 @@ By utilizing `hatch`, the following commands are available to manage the develop | `hatch fmt --formatter` | Run only formatters | | `hatch run javascript:check` | Run the JavaScript linter/formatter | | `hatch run javascript:fix` | Run the JavaScript linter/formatter and write fixes to disk | +| `hatch run python:type_check` | Run the Python type checker | ??? tip "Configure your IDE for linting" diff --git a/pyproject.toml b/pyproject.toml index 6472bdf..0f3d7f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ classifiers = [ "Environment :: Web Environment", "Typing :: Typed", ] -dependencies = ["reactpy>=1.0.0, <2.0.0", "typing_extensions"] +dependencies = ["reactpy>=1.1.0, <2.0.0", "typing_extensions"] dynamic = ["version"] urls.Changelog = "https://reactive-python.github.io/reactpy-router/latest/about/changelog/" urls.Documentation = "https://reactive-python.github.io/reactpy-router/latest/" @@ -53,7 +53,7 @@ installer = "uv" [[tool.hatch.build.hooks.build-scripts.scripts]] commands = [ "bun install --cwd src/js", - "bun build src/js/src/index.js --outfile src/reactpy_router/static/bundle.js --minify", + "bun build src/js/src/index.ts --outfile src/reactpy_router/static/bundle.js --minify", ] artifacts = [] @@ -106,6 +106,16 @@ linkcheck = [ deploy_latest = ["cd docs && mike deploy --push --update-aliases {args} latest"] deploy_develop = ["cd docs && mike deploy --push develop"] +################################ +# >>> Hatch Python Scripts <<< # +################################ + +[tool.hatch.envs.python] +extra-dependencies = ["pyright"] + +[tool.hatch.envs.python.scripts] +type_check = ["pyright src"] + ############################ # >>> Hatch JS Scripts <<< # ############################ diff --git a/src/js/src/components.ts b/src/js/src/components.ts new file mode 100644 index 0000000..4712637 --- /dev/null +++ b/src/js/src/components.ts @@ -0,0 +1,101 @@ +import React from "preact/compat"; +import ReactDOM from "preact/compat"; +import { createLocationObject, pushState, replaceState } from "./utils"; +import { HistoryProps, LinkProps, NavigateProps } from "./types"; + +/** + * Interface used to bind a ReactPy node to React. + */ +export function bind(node) { + return { + create: (type, props, children) => + React.createElement(type, props, ...children), + render: (element) => { + ReactDOM.render(element, node); + }, + unmount: () => ReactDOM.unmountComponentAtNode(node), + }; +} + +/** + * History component that captures browser "history go back" actions and notifies the server. + */ +export function History({ onHistoryChangeCallback }: HistoryProps): null { + // Tell the server about history "popstate" events + React.useEffect(() => { + const listener = () => { + onHistoryChangeCallback(createLocationObject()); + }; + + // Register the event listener + window.addEventListener("popstate", listener); + + // 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 + React.useEffect(() => { + onHistoryChangeCallback(createLocationObject()); + return () => {}; + }, []); + return null; +} + +/** + * Link component that captures clicks on anchor links and notifies the server. + * + * This component is not the actual `` link element. It is just an event + * listener for ReactPy-Router's server-side link component. + */ +export function Link({ onClickCallback, linkClass }: LinkProps): null { + React.useEffect(() => { + // Event function that will tell the server about clicks + const handleClick = (event: Event) => { + let click_event = event as MouseEvent; + if (!click_event.ctrlKey) { + event.preventDefault(); + let to = (event.currentTarget as HTMLElement).getAttribute("href"); + pushState(to); + onClickCallback(createLocationObject()); + } + }; + + // Register the event listener + let link = document.querySelector(`.${linkClass}`); + if (link) { + link.addEventListener("click", handleClick); + } else { + console.warn(`Link component with class name ${linkClass} not found.`); + } + + // Delete the event listener when the component is unmounted + return () => { + if (link) { + link.removeEventListener("click", handleClick); + } + }; + }); + return null; +} + +/** + * Client-side portion of the navigate component, that allows the server to command the client to change URLs. + */ +export function Navigate({ + onNavigateCallback, + to, + replace = false, +}: NavigateProps): null { + React.useEffect(() => { + if (replace) { + replaceState(to); + } else { + pushState(to); + } + onNavigateCallback(createLocationObject()); + return () => {}; + }, []); + + return null; +} diff --git a/src/js/src/index.ts b/src/js/src/index.ts index d7c6b3e..8de0626 100644 --- a/src/js/src/index.ts +++ b/src/js/src/index.ts @@ -1,129 +1 @@ -import React from "preact/compat"; -import ReactDOM from "preact/compat"; -import { createLocationObject, pushState, replaceState } from "./utils"; -import { - HistoryProps, - LinkProps, - NavigateProps, - FirstLoadProps, -} from "./types"; - -/** - * Interface used to bind a ReactPy node to React. - */ -export function bind(node) { - return { - create: (type, props, children) => - React.createElement(type, props, ...children), - render: (element) => { - ReactDOM.render(element, node); - }, - unmount: () => ReactDOM.unmountComponentAtNode(node), - }; -} - -/** - * History component that captures browser "history go back" actions and notifies the server. - */ -export function History({ onHistoryChangeCallback }: HistoryProps): null { - React.useEffect(() => { - // Register a listener for the "popstate" event and send data back to the server using the `onHistoryChange` callback. - const listener = () => { - onHistoryChangeCallback(createLocationObject()); - }; - - // Register the event listener - window.addEventListener("popstate", listener); - - // 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 code is commented out since it currently runs every time any component - // is mounted due to a ReactPy core rendering bug. `FirstLoad` component is used instead. - // https://github.com/reactive-python/reactpy/pull/1224 - // React.useEffect(() => { - // onHistoryChange({ - // pathname: window.location.pathname, - // search: window.location.search, - // }); - // return () => {}; - // }, []); - return null; -} - -/** - * Link component that captures clicks on anchor links and notifies the server. - * - * This component is not the actual `` link element. It is just an event - * listener for ReactPy-Router's server-side link component. - * - * @disabled This component is currently unused due to a ReactPy core rendering bug - * which causes duplicate rendering (and thus duplicate event listeners). - */ -export function Link({ onClickCallback, linkClass }: LinkProps): null { - React.useEffect(() => { - // Event function that will tell the server about clicks - const handleClick = (event: MouseEvent) => { - event.preventDefault(); - let to = (event.target as HTMLElement).getAttribute("href"); - pushState(to); - onClickCallback(createLocationObject()); - }; - - // Register the event listener - let link = document.querySelector(`.${linkClass}`); - if (link) { - link.addEventListener("click", handleClick); - } else { - console.warn(`Link component with class name ${linkClass} not found.`); - } - - // Delete the event listener when the component is unmounted - return () => { - let link = document.querySelector(`.${linkClass}`); - if (link) { - link.removeEventListener("click", handleClick); - } - }; - }); - return null; -} - -/** - * Client-side portion of the navigate component, that allows the server to command the client to change URLs. - */ -export function Navigate({ - onNavigateCallback, - to, - replace = false, -}: NavigateProps): null { - React.useEffect(() => { - if (replace) { - replaceState(to); - } else { - pushState(to); - } - onNavigateCallback(createLocationObject()); - return () => {}; - }, []); - - return null; -} - -/** - * FirstLoad component that captures the URL during the initial page load and notifies the server. - * - * FIXME: This component only exists because of a ReactPy core rendering bug, and should be removed when the bug - * is fixed. In the future, all this logic should be handled by the `History` component. - * https://github.com/reactive-python/reactpy/pull/1224 - */ -export function FirstLoad({ onFirstLoadCallback }: FirstLoadProps): null { - React.useEffect(() => { - onFirstLoadCallback(createLocationObject()); - return () => {}; - }, []); - - return null; -} +export { bind, History, Link, Navigate } from "./components"; diff --git a/src/js/src/types.ts b/src/js/src/types.ts index f4cf6cd..7144668 100644 --- a/src/js/src/types.ts +++ b/src/js/src/types.ts @@ -17,7 +17,3 @@ export interface NavigateProps { to: string; replace?: boolean; } - -export interface FirstLoadProps { - onFirstLoadCallback: (location: ReactPyLocation) => void; -} diff --git a/src/js/src/utils.ts b/src/js/src/utils.ts index a0d1af7..e3f1dd5 100644 --- a/src/js/src/utils.ts +++ b/src/js/src/utils.ts @@ -7,10 +7,18 @@ export function createLocationObject(): ReactPyLocation { }; } -export function pushState(to: string): void { +export function pushState(to: any): void { + if (typeof to !== "string") { + console.error("pushState() requires a string argument."); + return; + } window.history.pushState(null, "", new URL(to, window.location.href)); } -export function replaceState(to: string): void { +export function replaceState(to: any): void { + if (typeof to !== "string") { + console.error("replaceState() requires a string argument."); + return; + } window.history.replaceState(null, "", new URL(to, window.location.href)); } diff --git a/src/reactpy_router/components.py b/src/reactpy_router/components.py index 6a84799..6a751e7 100644 --- a/src/reactpy_router/components.py +++ b/src/reactpy_router/components.py @@ -2,10 +2,9 @@ from pathlib import Path from typing import TYPE_CHECKING, Any -from urllib.parse import urljoin from uuid import uuid4 -from reactpy import component, html, use_connection +from reactpy import component, html, use_connection, use_ref from reactpy.backend.types import Location from reactpy.web.module import export, module_from_file @@ -34,13 +33,6 @@ ) """Client-side portion of the navigate component""" -FirstLoad = export( - module_from_file("reactpy-router", file=Path(__file__).parent / "static" / "bundle.js"), - ("FirstLoad"), -) - -link_js_content = (Path(__file__).parent / "static" / "link.js").read_text(encoding="utf-8") - def link(attributes: dict[str, Any], *children: Any, key: Key | None = None) -> Component: """ @@ -59,8 +51,7 @@ def link(attributes: dict[str, Any], *children: Any, key: Key | None = None) -> @component def _link(attributes: dict[str, Any], *children: Any) -> VdomDict: attributes = attributes.copy() - uuid_string = f"link-{uuid4().hex}" - class_name = f"{uuid_string}" + class_name = use_ref(f"link-{uuid4().hex}").current set_location = _use_route_state().set_location if "className" in attributes: class_name = " ".join([attributes.pop("className"), class_name]) @@ -80,44 +71,10 @@ 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 - - def on_click(_event: dict[str, Any]) -> None: - if _event.get("ctrlKey", False): - return - - 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_callback(_event: dict[str, Any]) -> None: + set_location(Location(**_event)) - # def on_click_callback(_event: dict[str, Any]) -> None: - # set_location(Location(**_event)) - # return html._(html.a(attrs, *children), Link({"onClickCallback": on_click_callback, "linkClass": uuid_string})) + return html._(Link({"onClickCallback": on_click_callback, "linkClass": class_name}), html.a(attrs, *children)) 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 d8e75f2..25c37b4 100644 --- a/src/reactpy_router/routers.py +++ b/src/reactpy_router/routers.py @@ -7,11 +7,11 @@ from typing import TYPE_CHECKING, Any, Literal, cast 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.hooks import ConnectionContext, use_connection from reactpy.types import ComponentType, VdomDict -from reactpy_router.components import FirstLoad, History +from reactpy_router.components import History from reactpy_router.hooks import RouteState, _route_state_context from reactpy_router.resolvers import StarletteResolver @@ -20,16 +20,16 @@ from reactpy.core.component import Component - from reactpy_router.types import CompiledRoute, Resolver, Router, RouteType + from reactpy_router.types import CompiledRoute, Resolver, Route, Router __all__ = ["browser_router", "create_router"] _logger = getLogger(__name__) -def create_router(resolver: Resolver[RouteType]) -> Router[RouteType]: +def create_router(resolver: Resolver[Route]) -> Router[Route]: """A decorator that turns a resolver into a router""" - def wrapper(*routes: RouteType) -> Component: + def wrapper(*routes: Route) -> Component: return router(*routes, resolver=resolver) return wrapper @@ -38,13 +38,13 @@ def wrapper(*routes: RouteType) -> Component: _starlette_router = create_router(StarletteResolver) -def browser_router(*routes: RouteType) -> Component: +def browser_router(*routes: Route) -> Component: """This is the recommended router for all ReactPy-Router web projects. It uses the JavaScript [History API](https://developer.mozilla.org/en-US/docs/Web/API/History_API) to manage the history stack. Args: - *routes (RouteType): A list of routes to be rendered by the router. + *routes (Route): A list of routes to be rendered by the router. Returns: A router component that renders the given routes. @@ -54,8 +54,8 @@ def browser_router(*routes: RouteType) -> Component: @component def router( - *routes: RouteType, - resolver: Resolver[RouteType], + *routes: Route, + resolver: Resolver[Route], ) -> VdomDict | None: """A component that renders matching route(s) using the given resolver. @@ -76,9 +76,9 @@ def router( if match: if first_load: # We need skip rendering the application on 'first_load' to avoid - # rendering it twice. The second render occurs following - # the impending on_history_change event + # rendering it twice. The second render follows the on_history_change event route_elements = [] + set_first_load(False) else: route_elements = [ _route_state_context( @@ -94,15 +94,8 @@ def on_history_change(event: dict[str, Any]) -> None: if location != new_location: set_location(new_location) - def on_first_load(event: dict[str, Any]) -> None: - """Callback function used within the JavaScript `FirstLoad` component.""" - if first_load: - set_first_load(False) - on_history_change(event) - return ConnectionContext( History({"onHistoryChangeCallback": on_history_change}), # type: ignore[return-value] - FirstLoad({"onFirstLoadCallback": on_first_load}) if first_load else "", *route_elements, value=Connection(old_conn.scope, location, old_conn.carrier), ) @@ -110,7 +103,7 @@ def on_first_load(event: dict[str, Any]) -> None: return None -def _iter_routes(routes: Sequence[RouteType]) -> Iterator[RouteType]: +def _iter_routes(routes: Sequence[Route]) -> Iterator[Route]: for parent in routes: for child in _iter_routes(parent.routes): yield replace(child, path=parent.path + child.path) # type: ignore[misc] diff --git a/src/reactpy_router/static/link.js b/src/reactpy_router/static/link.js deleted file mode 100644 index 7ab069b..0000000 --- a/src/reactpy_router/static/link.js +++ /dev/null @@ -1,17 +0,0 @@ -document.querySelector(".UUID").addEventListener( - "click", - (event) => { - // Prevent default if ctrl isn't pressed - if (!event.ctrlKey) { - event.preventDefault(); - let to = event.currentTarget.getAttribute("href"); - let new_url = new URL(to, window.location); - - // Deduplication needed due to ReactPy rendering bug - if (new_url.href !== window.location.href) { - window.history.pushState(null, "", new URL(to, window.location)); - } - } - }, - { once: true }, -); diff --git a/src/reactpy_router/types.py b/src/reactpy_router/types.py index 81404b7..ca2c913 100644 --- a/src/reactpy_router/types.py +++ b/src/reactpy_router/types.py @@ -46,9 +46,6 @@ def __hash__(self) -> int: return hash((self.path, key, self.routes)) -RouteType = TypeVar("RouteType", bound=Route) -"""A type variable for `Route`.""" - RouteType_contra = TypeVar("RouteType_contra", bound=Route, contravariant=True) """A contravariant type variable for `Route`.""" @@ -66,6 +63,7 @@ def __call__(self, *routes: RouteType_contra) -> Component: Returns: The resulting component after processing the routes. """ + ... class Resolver(Protocol[RouteType_contra]): @@ -81,6 +79,7 @@ def __call__(self, route: RouteType_contra) -> CompiledRoute: Returns: The compiled route. """ + ... class CompiledRoute(Protocol): @@ -104,6 +103,7 @@ def resolve(self, path: str) -> tuple[Any, dict[str, Any]] | None: Returns: A tuple containing the associated element and a dictionary of path parameters, or None if the path cannot be resolved. """ + ... class ConversionInfo(TypedDict): From 88ec72faa8572c308baa4fd25464763fdd74c20c Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Sun, 12 Jan 2025 03:07:08 -0800 Subject: [PATCH 4/4] Custom router API (#51) --- CHANGELOG.md | 10 +++ .../python/custom_router_easy_resolver.py | 16 +++++ .../python/custom_router_easy_router.py | 6 ++ docs/examples/python/example/__init__.py | 0 docs/examples/python/example/resolvers.py | 4 ++ docs/mkdocs.yml | 2 +- docs/src/learn/custom-router.md | 29 +++++++- src/reactpy_router/resolvers.py | 30 ++++---- src/reactpy_router/routers.py | 71 +++++++------------ src/reactpy_router/types.py | 36 +++++++--- tests/test_resolver.py | 27 +++++-- 11 files changed, 150 insertions(+), 81 deletions(-) create mode 100644 docs/examples/python/custom_router_easy_resolver.py create mode 100644 docs/examples/python/custom_router_easy_router.py create mode 100644 docs/examples/python/example/__init__.py create mode 100644 docs/examples/python/example/resolvers.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fced87..7fc4f03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,12 +19,22 @@ Don't forget to remove deprecated code on each major release! ## [Unreleased] +### Added + +- Support for custom routers. + ### Changed - Set maximum ReactPy version to `<2.0.0`. - Set minimum ReactPy version to `1.1.0`. - `link` element now calculates URL changes using the client. - Refactoring related to `reactpy>=1.1.0` changes. +- Changed ReactPy-Router's method of waiting for the initial URL to be deterministic. +- Rename `StarletteResolver` to `ReactPyResolver`. + +### Removed + +- `StarletteResolver` is removed in favor of `ReactPyResolver`. ### Fixed diff --git a/docs/examples/python/custom_router_easy_resolver.py b/docs/examples/python/custom_router_easy_resolver.py new file mode 100644 index 0000000..322cce3 --- /dev/null +++ b/docs/examples/python/custom_router_easy_resolver.py @@ -0,0 +1,16 @@ +from typing import ClassVar + +from reactpy_router.resolvers import ConversionInfo, ReactPyResolver + + +# Create a custom resolver that uses the following pattern: "{name:type}" +class CustomResolver(ReactPyResolver): + # Match parameters that use the "" format + param_pattern: str = r"<(?P\w+)(?P:\w+)?>" + + # Enable matching for the following types: int, str, any + converters: ClassVar[dict[str, ConversionInfo]] = { + "int": ConversionInfo(regex=r"\d+", func=int), + "str": ConversionInfo(regex=r"[^/]+", func=str), + "any": ConversionInfo(regex=r".*", func=str), + } diff --git a/docs/examples/python/custom_router_easy_router.py b/docs/examples/python/custom_router_easy_router.py new file mode 100644 index 0000000..7457138 --- /dev/null +++ b/docs/examples/python/custom_router_easy_router.py @@ -0,0 +1,6 @@ +from example.resolvers import CustomResolver + +from reactpy_router.routers import create_router + +# This can be used in any location where `browser_router` was previously used +custom_router = create_router(CustomResolver) diff --git a/docs/examples/python/example/__init__.py b/docs/examples/python/example/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docs/examples/python/example/resolvers.py b/docs/examples/python/example/resolvers.py new file mode 100644 index 0000000..ad328cb --- /dev/null +++ b/docs/examples/python/example/resolvers.py @@ -0,0 +1,4 @@ +from reactpy_router.resolvers import ReactPyResolver + + +class CustomResolver(ReactPyResolver): ... diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 28df470..ad4ab0f 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -6,7 +6,7 @@ nav: - Advanced Topics: - Routers, Routes, and Links: learn/routers-routes-and-links.md - Hooks: learn/hooks.md - - Creating a Custom Router 🚧: learn/custom-router.md + - Creating a Custom Router: learn/custom-router.md - Reference: - Routers: reference/routers.md - Components: reference/components.md diff --git a/docs/src/learn/custom-router.md b/docs/src/learn/custom-router.md index fa03675..c0b1bac 100644 --- a/docs/src/learn/custom-router.md +++ b/docs/src/learn/custom-router.md @@ -1,3 +1,28 @@ -# Custom Router +Custom routers can be used to define custom routing logic for your application. This is useful when you need to implement a custom routing algorithm or when you need to integrate with an existing URL routing system. -Under construction 🚧 +--- + +## Step 1: Creating a custom resolver + +You may want to create a custom resolver to allow ReactPy to utilize an existing routing syntax. + +To start off, you will need to create a subclass of `#!python ReactPyResolver`. Within this subclass, you have two attributes which you can modify to support your custom routing syntax: + +- `#!python param_pattern`: A regular expression pattern that matches the parameters in your URL. This pattern must contain the regex named groups `name` and `type`. +- `#!python converters`: A dictionary that maps a `type` to it's respective `regex` pattern and a converter `func`. + +=== "resolver.py" + + ```python + {% include "../../examples/python/custom_router_easy_resolver.py" %} + ``` + +## Step 2: Creating a custom router + +Then, you can use this resolver to create your custom router... + +=== "resolver.py" + + ```python + {% include "../../examples/python/custom_router_easy_router.py" %} + ``` diff --git a/src/reactpy_router/resolvers.py b/src/reactpy_router/resolvers.py index 48de28f..58e7b7f 100644 --- a/src/reactpy_router/resolvers.py +++ b/src/reactpy_router/resolvers.py @@ -1,31 +1,29 @@ from __future__ import annotations import re -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, ClassVar from reactpy_router.converters import CONVERTERS +from reactpy_router.types import MatchedRoute if TYPE_CHECKING: from reactpy_router.types import ConversionInfo, ConverterMapping, Route -__all__ = ["StarletteResolver"] +__all__ = ["ReactPyResolver"] -class StarletteResolver: - """URL resolver that matches routes using starlette's URL routing syntax. +class ReactPyResolver: + """URL resolver that can match a path against any given routes. - However, this resolver adds a few additional parameter types on top of Starlette's syntax.""" + URL routing syntax for this resolver is based on Starlette, and supports a mixture of Starlette and Django parameter types.""" - def __init__( - self, - route: Route, - param_pattern=r"{(?P\w+)(?P:\w+)?}", - converters: dict[str, ConversionInfo] | None = None, - ) -> None: + param_pattern: str = r"{(?P\w+)(?P:\w+)?}" + converters: ClassVar[dict[str, ConversionInfo]] = CONVERTERS + + def __init__(self, route: Route) -> None: self.element = route.element - self.registered_converters = converters or CONVERTERS self.converter_mapping: ConverterMapping = {} - self.param_regex = re.compile(param_pattern) + self.param_regex = re.compile(self.param_pattern) self.pattern = self.parse_path(route.path) self.key = self.pattern.pattern # Unique identifier for ReactPy rendering @@ -48,7 +46,7 @@ def parse_path(self, path: str) -> re.Pattern[str]: # Check if a converter exists for the type try: - conversion_info = self.registered_converters[param_type] + conversion_info = self.converters[param_type] except KeyError as e: msg = f"Unknown conversion type {param_type!r} in {path!r}" raise ValueError(msg) from e @@ -70,7 +68,7 @@ def parse_path(self, path: str) -> re.Pattern[str]: return re.compile(pattern) - def resolve(self, path: str) -> tuple[Any, dict[str, Any]] | None: + def resolve(self, path: str) -> MatchedRoute | None: match = self.pattern.match(path) if match: # Convert the matched groups to the correct types @@ -80,5 +78,5 @@ def resolve(self, path: str) -> tuple[Any, dict[str, Any]] | None: else parameter_name: self.converter_mapping[parameter_name](value) for parameter_name, value in match.groupdict().items() } - return (self.element, params) + return MatchedRoute(self.element, params, path) return None diff --git a/src/reactpy_router/routers.py b/src/reactpy_router/routers.py index 25c37b4..fad94eb 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 TYPE_CHECKING, Any, Literal, cast +from typing import TYPE_CHECKING, Any, Union, cast from reactpy import component, use_memo, use_state from reactpy.backend.types import Connection, Location @@ -13,14 +13,14 @@ from reactpy_router.components import History from reactpy_router.hooks import RouteState, _route_state_context -from reactpy_router.resolvers import StarletteResolver +from reactpy_router.resolvers import ReactPyResolver if TYPE_CHECKING: from collections.abc import Iterator, Sequence from reactpy.core.component import Component - from reactpy_router.types import CompiledRoute, Resolver, Route, Router + from reactpy_router.types import CompiledRoute, MatchedRoute, Resolver, Route, Router __all__ = ["browser_router", "create_router"] _logger = getLogger(__name__) @@ -35,7 +35,7 @@ def wrapper(*routes: Route) -> Component: return wrapper -_starlette_router = create_router(StarletteResolver) +_router = create_router(ReactPyResolver) def browser_router(*routes: Route) -> Component: @@ -49,7 +49,7 @@ def browser_router(*routes: Route) -> Component: Returns: A router component that renders the given routes. """ - return _starlette_router(*routes) + return _router(*routes) @component @@ -57,36 +57,27 @@ def router( *routes: Route, resolver: Resolver[Route], ) -> VdomDict | None: - """A component that renders matching route(s) using the given resolver. + """A component that renders matching route using the given resolver. - This typically should never be used by a user. Instead, use `create_router` if creating + User notice: This component typically should never be used. Instead, use `create_router` if creating a custom routing engine.""" - old_conn = use_connection() - location, set_location = use_state(old_conn.location) - first_load, set_first_load = use_state(True) - + old_connection = use_connection() + location, set_location = use_state(cast(Union[Location, None], None)) resolvers = use_memo( lambda: tuple(map(resolver, _iter_routes(routes))), dependencies=(resolver, hash(routes)), ) - - match = use_memo(lambda: _match_route(resolvers, location, select="first")) + route_element = None + match = use_memo(lambda: _match_route(resolvers, location or old_connection.location)) if match: - if first_load: - # We need skip rendering the application on 'first_load' to avoid - # rendering it twice. The second render follows the on_history_change event - route_elements = [] - set_first_load(False) - else: - route_elements = [ - _route_state_context( - element, - value=RouteState(set_location, params), - ) - for element, params in match - ] + # Skip rendering until ReactPy-Router knows what URL the page is on. + if location: + route_element = _route_state_context( + match.element, + value=RouteState(set_location, match.params), + ) def on_history_change(event: dict[str, Any]) -> None: """Callback function used within the JavaScript `History` component.""" @@ -96,8 +87,8 @@ def on_history_change(event: dict[str, Any]) -> None: return ConnectionContext( History({"onHistoryChangeCallback": on_history_change}), # type: ignore[return-value] - *route_elements, - value=Connection(old_conn.scope, location, old_conn.carrier), + route_element, + value=Connection(old_connection.scope, location or old_connection.location, old_connection.carrier), ) return None @@ -110,9 +101,9 @@ def _iter_routes(routes: Sequence[Route]) -> Iterator[Route]: yield parent -def _add_route_key(match: tuple[Any, dict[str, Any]], key: str | int) -> Any: +def _add_route_key(match: MatchedRoute, key: str | int) -> Any: """Add a key to the VDOM or component on the current route, if it doesn't already have one.""" - element, _params = match + element = match.element if hasattr(element, "render") and not element.key: element = cast(ComponentType, element) element.key = key @@ -125,24 +116,12 @@ def _add_route_key(match: tuple[Any, dict[str, Any]], key: str | int) -> Any: def _match_route( compiled_routes: Sequence[CompiledRoute], location: Location, - select: Literal["first", "all"], -) -> list[tuple[Any, dict[str, Any]]]: - matches = [] - +) -> MatchedRoute | None: for resolver in compiled_routes: match = resolver.resolve(location.pathname) if match is not None: - if select == "first": - return [_add_route_key(match, resolver.key)] + return _add_route_key(match, resolver.key) - # 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. - # TODO: The `resolver.key` value has edge cases where it is not unique enough to use as - # a key here. We can potentially fix this by throwing errors for duplicate identical routes. - matches.append(_add_route_key(match, resolver.key)) # pragma: no cover + _logger.debug("No matching route found for %s", location.pathname) - if not matches: - _logger.debug("No matching route found for %s", location.pathname) - - return matches + return None diff --git a/src/reactpy_router/types.py b/src/reactpy_router/types.py index ca2c913..755e244 100644 --- a/src/reactpy_router/types.py +++ b/src/reactpy_router/types.py @@ -28,9 +28,9 @@ class Route: A class representing a route that can be matched against a path. Attributes: - path (str): The path to match against. - element (Any): The element to render if the path matches. - routes (Sequence[Self]): Child routes. + path: The path to match against. + element: The element to render if the path matches. + routes: Child routes. Methods: __hash__() -> int: Returns a hash value for the route based on its path, element, and child routes. @@ -67,11 +67,11 @@ def __call__(self, *routes: RouteType_contra) -> Component: class Resolver(Protocol[RouteType_contra]): - """Compile a route into a resolver that can be matched against a given path.""" + """A class, that when instantiated, can match routes against a given path.""" def __call__(self, route: RouteType_contra) -> CompiledRoute: """ - Compile a route into a resolver that can be matched against a given path. + Compile a route into a resolver that can be match routes against a given path. Args: route: The route to compile. @@ -87,18 +87,18 @@ class CompiledRoute(Protocol): A protocol for a compiled route that can be matched against a path. Attributes: - key (Key): A property that uniquely identifies this resolver. + key: A property that uniquely identifies this resolver. """ @property def key(self) -> Key: ... - def resolve(self, path: str) -> tuple[Any, dict[str, Any]] | None: + def resolve(self, path: str) -> MatchedRoute | None: """ Return the path's associated element and path parameters or None. Args: - path (str): The path to resolve. + path: The path to resolve. Returns: A tuple containing the associated element and a dictionary of path parameters, or None if the path cannot be resolved. @@ -106,13 +106,29 @@ def resolve(self, path: str) -> tuple[Any, dict[str, Any]] | None: ... +@dataclass(frozen=True) +class MatchedRoute: + """ + Represents a matched route. + + Attributes: + element: The element to render. + params: The parameters extracted from the path. + path: The path that was matched. + """ + + element: Any + params: dict[str, Any] + path: str + + class ConversionInfo(TypedDict): """ A TypedDict that holds information about a conversion type. Attributes: - regex (str): The regex to match the conversion type. - func (ConversionFunc): The function to convert the matched string to the expected type. + regex: The regex to match the conversion type. + func: The function to convert the matched string to the expected type. """ regex: str diff --git a/tests/test_resolver.py b/tests/test_resolver.py index a5dad38..cf1a17e 100644 --- a/tests/test_resolver.py +++ b/tests/test_resolver.py @@ -4,18 +4,33 @@ import pytest from reactpy_router import route -from reactpy_router.resolvers import StarletteResolver +from reactpy_router.resolvers import ReactPyResolver +from reactpy_router.types import MatchedRoute def test_resolve_any(): - resolver = StarletteResolver(route("{404:any}", "Hello World")) + resolver = ReactPyResolver(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"}) + assert resolver.resolve("/hello/world") == MatchedRoute( + element="Hello World", params={"404": "/hello/world"}, path="/hello/world" + ) + + +def test_custom_resolver(): + class CustomResolver(ReactPyResolver): + param_pattern = r"<(?P\w+)(?P:\w+)?>" + + resolver = CustomResolver(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") == MatchedRoute( + element="Hello World", params={"404": "/hello/world"}, path="/hello/world" + ) def test_parse_path(): - resolver = StarletteResolver(route("/", None)) + resolver = ReactPyResolver(route("/", None)) assert resolver.parse_path("/a/b/c") == re.compile("^/a/b/c$") assert resolver.converter_mapping == {} @@ -45,13 +60,13 @@ def test_parse_path(): def test_parse_path_unkown_conversion(): - resolver = StarletteResolver(route("/", None)) + resolver = ReactPyResolver(route("/", None)) with pytest.raises(ValueError, match="Unknown conversion type 'unknown' in '/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""" - resolver = StarletteResolver(route("/", None)) + resolver = ReactPyResolver(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}