From 78dc2916856bde55bf2898cf32c7f81ba78eb1f0 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Thu, 11 May 2023 00:18:41 -0600 Subject: [PATCH 1/3] initial docs + * match --- README.md | 284 +++++++++++++++++++++++++++++++++++-- reactpy_router/__init__.py | 2 +- reactpy_router/simple.py | 3 + tests/test_simple.py | 4 + 4 files changed, 279 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index b182609..979bdc3 100644 --- a/README.md +++ b/README.md @@ -18,30 +18,288 @@ cd reactpy-router pip install -e . -r requirements.txt ``` -# Running the Tests +# Usage -To run the tests you'll need to install [Chrome](https://www.google.com/chrome/). Then you -can download the [ChromeDriver](https://chromedriver.chromium.org/downloads) and add it to -your `PATH`. Once that's done, simply `pip` install the requirements: +Assuming you are familiar with the basics of [ReactPy](https://reactpy.dev), you can +begin by using the simple built-in router implementation supplied by `reactpy-router`. -```bash -pip install -r requirements.txt +```python +from reactpy import component, html, run +from reactpy_router import route, simple + +@component +def root(): + return simple.router( + route("/", html.h1("Home Page 🏠")), + route("*", html.h1("Missing Link πŸ”—β€πŸ’₯")), + ) + +run(root) ``` -And run the tests with `pytest`: +When navigating to http://127.0.0.1:8000 you should see "Home Page 🏠". However, if you +go to any other route (e.g. http://127.0.0.1:8000/missing) you will instead see the +"Missing Link πŸ”—β€πŸ’₯" page. -```bash -pytest tests +With this foundation you can start adding more routes: + +```python +from reactpy import component, html, run +from reactpy_router import route, simple + +@component +def root(): + return simple.router( + route("/", html.h1("Home Page 🏠")), + route("/messages", html.h1("Messages πŸ’¬")), + route("*", html.h1("Missing Link πŸ”—β€πŸ’₯")), + ) + +run(root) +``` + +With this change you can now also go to `/messages` to see "Messages πŸ’¬" displayed. + +# Route Links + +Instead of using the standard `` element to create links to different parts of your +application, use `reactpy_router.link` instead. When users click links constructed using +`reactpy_router.link`, instead of letting the browser navigate to the associated route, +ReactPy will more quickly handle the transition by avoiding the cost of a full page +load. + +```python +from reactpy import component, html, run +from reactpy_router import link, route, simple + +@component +def root(): + return simple.router( + route("/", home()), + route("/messages", html.h1("Messages πŸ’¬")), + route("*", html.h1("Missing Link πŸ”—β€πŸ’₯")), + ) + +@component +def home(): + return html.div( + html.h1("Home Page 🏠"), + link("Messages", to="/messages"), + ) + +run(root) +``` + +Now, when you go to the home page, you can click the link to go to `/messages`. + +## Nested Routes + +Routes can be nested in order to construct more complicated application structures: + +```python +from reactpy import component, html, run +from reactpy_router import route, simple, link + +message_data = [ + {"id": 1, "with": ["Alice"], "from": None, "message": "Hello!"}, + {"id": 2, "with": ["Alice"], "from": "Alice", "message": "How's it going?"}, + {"id": 3, "with": ["Alice"], "from": None, "message": "Good, you?"}, + {"id": 4, "with": ["Alice"], "from": "Alice", "message": "Good, thanks!"}, + {"id": 5, "with": ["Alice", "Bob"], "from": None, "message": "We meeting now?"}, + {"id": 6, "with": ["Alice", "Bob"], "from": "Alice", "message": "Not sure."}, + {"id": 7, "with": ["Alice", "Bob"], "from": "Bob", "message": "I'm here!"}, + {"id": 8, "with": ["Alice", "Bob"], "from": None, "message": "Great!"}, +] + +@component +def root(): + return simple.router( + route("/", home()), + route( + "/messages", + all_messages(), + # we'll improve upon these manually created routes in the next section... + route("/with/Alice", messages_with("Alice")), + route("/with/Alice-Bob", messages_with("Alice", "Bob")), + ), + route("*", html.h1("Missing Link πŸ”—β€πŸ’₯")), + ) + +@component +def home(): + return html.div( + html.h1("Home Page 🏠"), + link("Messages", to="/messages"), + ) + +@component +def all_messages(): + last_messages = { + ", ".join(msg["with"]): msg + for msg in sorted(message_data, key=lambda m: m["id"]) + } + return html.div( + html.h1("All Messages πŸ’¬"), + html.ul( + [ + 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() + ] + ), + ) + +@component +def messages_with(*names): + names = set(names) + messages = [msg for msg in message_data if set(msg["with"]) == names] + return html.div( + html.h1(f"Messages with {', '.join(names)} πŸ’¬"), + html.ul( + [ + html.li( + {"key": msg["id"]}, + f"{msg['from'] or 'You'}: {msg['message']}", + ) + for msg in messages + ] + ), + ) + +run(root) +``` + +## Route Parameters + +In the example above we had to manually create a `messages_with(...)` component for each +conversation. This would be better accomplished by defining a single route that declares +a "route parameters" instead. With the `simple.router` route parameters are declared +using the following syntax: + +``` +/my/route/{param} +/my/route/{param:type} +``` + +In this case, `param` is the name of the route parameter and the optionally declared +`type` specifies what kind of parameter it is. The available parameter types and what +patterns they match are are: + +- str (default) - `[^/]+` +- int - `\d+` +- float - `\d+(\.\d+)?` +- uuid - `[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}` +- path - `.+` + +Any parameters that have matched in the currently displayed route can then be consumed +with the `use_params` hook which returns a dictionary mapping the parameter names to +their values. Note that parameters with a declared type will be converted to is in the +parameters dictionary. So for example `/my/route/{my_param:float}` would match +`/my/route/3.14` and have a parameter dictionary of `{"my_param": 3.14}`. + +If we take this information and apply it to our growing example application we'd +substitute the manually constructed `/messages/with` routes with a single +`/messages/with/{names}` route: + +```python +from reactpy import component, html, run +from reactpy_router import route, simple, link +from reactpy_router.core import use_params + +message_data = [ + {"id": 1, "with": ["Alice"], "from": None, "message": "Hello!"}, + {"id": 2, "with": ["Alice"], "from": "Alice", "message": "How's it going?"}, + {"id": 3, "with": ["Alice"], "from": None, "message": "Good, you?"}, + {"id": 4, "with": ["Alice"], "from": "Alice", "message": "Good, thanks!"}, + {"id": 5, "with": ["Alice", "Bob"], "from": None, "message": "We meeting now?"}, + {"id": 6, "with": ["Alice", "Bob"], "from": "Alice", "message": "Not sure."}, + {"id": 7, "with": ["Alice", "Bob"], "from": "Bob", "message": "I'm here!"}, + {"id": 8, "with": ["Alice", "Bob"], "from": None, "message": "Great!"}, +] + +@component +def root(): + return simple.router( + route("/", home()), + route( + "/messages", + all_messages(), + route("/with/{names}", messages_with()), # note the path param + ), + route("*", html.h1("Missing Link πŸ”—β€πŸ’₯")), + ) + +@component +def home(): + return html.div( + html.h1("Home Page 🏠"), + link("Messages", to="/messages"), + ) + +@component +def all_messages(): + last_messages = { + ", ".join(msg["with"]): msg + for msg in sorted(message_data, key=lambda m: m["id"]) + } + return html.div( + html.h1("All Messages πŸ’¬"), + html.ul( + [ + 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() + ] + ), + ) + +@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] + return html.div( + html.h1(f"Messages with {', '.join(names)} πŸ’¬"), + html.ul( + [ + html.li( + {"key": msg["id"]}, + f"{msg['from'] or 'You'}: {msg['message']}", + ) + for msg in messages + ] + ), + ) + +run(root) ``` -You can run the tests in headless mode (i.e. without opening the browser): +# Running the Tests ```bash -pytest tests +nox -s test ``` -You'll need to run in headless mode to execute the suite in continuous integration systems -like GitHub Actions. +You can run the tests with a headed browser. + +```bash +nox -s test -- --headed +``` # Releasing This Package diff --git a/reactpy_router/__init__.py b/reactpy_router/__init__.py index 8d0c697..0fa3ea1 100644 --- a/reactpy_router/__init__.py +++ b/reactpy_router/__init__.py @@ -1,5 +1,5 @@ # the version is statically loaded by setup.py -__version__ = "0.0.1" +__version__ = "0.1.0" from . import simple from .core import create_router, link, route, router_component, use_params, use_query diff --git a/reactpy_router/simple.py b/reactpy_router/simple.py index 3c6ea9b..e9e2e11 100644 --- a/reactpy_router/simple.py +++ b/reactpy_router/simple.py @@ -34,6 +34,9 @@ def resolve(self, path: str) -> tuple[Any, dict[str, Any]] | None: def parse_path(path: str) -> tuple[re.Pattern[str], ConverterMapping]: + if path == "*": + return re.compile(".*"), {} + pattern = "^" last_match_end = 0 converters: ConverterMapping = {} diff --git a/tests/test_simple.py b/tests/test_simple.py index e7de017..a46cffe 100644 --- a/tests/test_simple.py +++ b/tests/test_simple.py @@ -48,3 +48,7 @@ def test_parse_path_re_escape(): re.compile(r"^/a/(?P\d+)/c\.d$"), {"b": int}, ) + + +def test_match_any_path(): + assert parse_path("*") == (re.compile(".*"), {}) From 16d45299e892ca6bb655d650bca428b64e8f3e7c Mon Sep 17 00:00:00 2001 From: rmorshea Date: Thu, 25 May 2023 00:09:22 -0600 Subject: [PATCH 2/3] use mkdocs --- .gitignore | 4 + README.md | 316 +--------------------------- docs/mkdocs.yml | 55 +++++ docs/src/assets/logo.svg | 160 ++++++++++++++ docs/src/contributing.md | 70 ++++++ docs/src/index.md | 18 ++ docs/src/reference.md | 5 + docs/src/tutorials/custom-router.md | 3 + docs/src/tutorials/simple-app.md | 277 ++++++++++++++++++++++++ docs/src/usage.md | 164 +++++++++++++++ noxfile.py | 18 ++ reactpy_router/core.py | 6 + reactpy_router/py.typed | 1 + reactpy_router/simple.py | 12 ++ reactpy_router/types.py | 17 +- requirements.txt | 1 + requirements/build-docs.txt | 5 + 17 files changed, 815 insertions(+), 317 deletions(-) create mode 100644 docs/mkdocs.yml create mode 100644 docs/src/assets/logo.svg create mode 100644 docs/src/contributing.md create mode 100644 docs/src/index.md create mode 100644 docs/src/reference.md create mode 100644 docs/src/tutorials/custom-router.md create mode 100644 docs/src/tutorials/simple-app.md create mode 100644 docs/src/usage.md create mode 100644 reactpy_router/py.typed create mode 100644 requirements/build-docs.txt diff --git a/.gitignore b/.gitignore index b66252c..c9676bc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ +# --- DOCS --- + +docs/site + # --- JAVASCRIPT BUNDLES --- reactpy_router/bundle.js diff --git a/README.md b/README.md index 979bdc3..63aeaab 100644 --- a/README.md +++ b/README.md @@ -2,318 +2,4 @@ A URL router for ReactPy -# Installation - -Use `pip` to install this package: - -```bash -pip install reactpy-router -``` - -For a developer installation from source be sure to install [NPM](https://www.npmjs.com/) before running: - -```bash -git clone https://github.com/reactive-python/reactpy-router -cd reactpy-router -pip install -e . -r requirements.txt -``` - -# Usage - -Assuming you are familiar with the basics of [ReactPy](https://reactpy.dev), you can -begin by using the simple built-in router implementation supplied by `reactpy-router`. - -```python -from reactpy import component, html, run -from reactpy_router import route, simple - -@component -def root(): - return simple.router( - route("/", html.h1("Home Page 🏠")), - route("*", html.h1("Missing Link πŸ”—β€πŸ’₯")), - ) - -run(root) -``` - -When navigating to http://127.0.0.1:8000 you should see "Home Page 🏠". However, if you -go to any other route (e.g. http://127.0.0.1:8000/missing) you will instead see the -"Missing Link πŸ”—β€πŸ’₯" page. - -With this foundation you can start adding more routes: - -```python -from reactpy import component, html, run -from reactpy_router import route, simple - -@component -def root(): - return simple.router( - route("/", html.h1("Home Page 🏠")), - route("/messages", html.h1("Messages πŸ’¬")), - route("*", html.h1("Missing Link πŸ”—β€πŸ’₯")), - ) - -run(root) -``` - -With this change you can now also go to `/messages` to see "Messages πŸ’¬" displayed. - -# Route Links - -Instead of using the standard `` element to create links to different parts of your -application, use `reactpy_router.link` instead. When users click links constructed using -`reactpy_router.link`, instead of letting the browser navigate to the associated route, -ReactPy will more quickly handle the transition by avoiding the cost of a full page -load. - -```python -from reactpy import component, html, run -from reactpy_router import link, route, simple - -@component -def root(): - return simple.router( - route("/", home()), - route("/messages", html.h1("Messages πŸ’¬")), - route("*", html.h1("Missing Link πŸ”—β€πŸ’₯")), - ) - -@component -def home(): - return html.div( - html.h1("Home Page 🏠"), - link("Messages", to="/messages"), - ) - -run(root) -``` - -Now, when you go to the home page, you can click the link to go to `/messages`. - -## Nested Routes - -Routes can be nested in order to construct more complicated application structures: - -```python -from reactpy import component, html, run -from reactpy_router import route, simple, link - -message_data = [ - {"id": 1, "with": ["Alice"], "from": None, "message": "Hello!"}, - {"id": 2, "with": ["Alice"], "from": "Alice", "message": "How's it going?"}, - {"id": 3, "with": ["Alice"], "from": None, "message": "Good, you?"}, - {"id": 4, "with": ["Alice"], "from": "Alice", "message": "Good, thanks!"}, - {"id": 5, "with": ["Alice", "Bob"], "from": None, "message": "We meeting now?"}, - {"id": 6, "with": ["Alice", "Bob"], "from": "Alice", "message": "Not sure."}, - {"id": 7, "with": ["Alice", "Bob"], "from": "Bob", "message": "I'm here!"}, - {"id": 8, "with": ["Alice", "Bob"], "from": None, "message": "Great!"}, -] - -@component -def root(): - return simple.router( - route("/", home()), - route( - "/messages", - all_messages(), - # we'll improve upon these manually created routes in the next section... - route("/with/Alice", messages_with("Alice")), - route("/with/Alice-Bob", messages_with("Alice", "Bob")), - ), - route("*", html.h1("Missing Link πŸ”—β€πŸ’₯")), - ) - -@component -def home(): - return html.div( - html.h1("Home Page 🏠"), - link("Messages", to="/messages"), - ) - -@component -def all_messages(): - last_messages = { - ", ".join(msg["with"]): msg - for msg in sorted(message_data, key=lambda m: m["id"]) - } - return html.div( - html.h1("All Messages πŸ’¬"), - html.ul( - [ - 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() - ] - ), - ) - -@component -def messages_with(*names): - names = set(names) - messages = [msg for msg in message_data if set(msg["with"]) == names] - return html.div( - html.h1(f"Messages with {', '.join(names)} πŸ’¬"), - html.ul( - [ - html.li( - {"key": msg["id"]}, - f"{msg['from'] or 'You'}: {msg['message']}", - ) - for msg in messages - ] - ), - ) - -run(root) -``` - -## Route Parameters - -In the example above we had to manually create a `messages_with(...)` component for each -conversation. This would be better accomplished by defining a single route that declares -a "route parameters" instead. With the `simple.router` route parameters are declared -using the following syntax: - -``` -/my/route/{param} -/my/route/{param:type} -``` - -In this case, `param` is the name of the route parameter and the optionally declared -`type` specifies what kind of parameter it is. The available parameter types and what -patterns they match are are: - -- str (default) - `[^/]+` -- int - `\d+` -- float - `\d+(\.\d+)?` -- uuid - `[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}` -- path - `.+` - -Any parameters that have matched in the currently displayed route can then be consumed -with the `use_params` hook which returns a dictionary mapping the parameter names to -their values. Note that parameters with a declared type will be converted to is in the -parameters dictionary. So for example `/my/route/{my_param:float}` would match -`/my/route/3.14` and have a parameter dictionary of `{"my_param": 3.14}`. - -If we take this information and apply it to our growing example application we'd -substitute the manually constructed `/messages/with` routes with a single -`/messages/with/{names}` route: - -```python -from reactpy import component, html, run -from reactpy_router import route, simple, link -from reactpy_router.core import use_params - -message_data = [ - {"id": 1, "with": ["Alice"], "from": None, "message": "Hello!"}, - {"id": 2, "with": ["Alice"], "from": "Alice", "message": "How's it going?"}, - {"id": 3, "with": ["Alice"], "from": None, "message": "Good, you?"}, - {"id": 4, "with": ["Alice"], "from": "Alice", "message": "Good, thanks!"}, - {"id": 5, "with": ["Alice", "Bob"], "from": None, "message": "We meeting now?"}, - {"id": 6, "with": ["Alice", "Bob"], "from": "Alice", "message": "Not sure."}, - {"id": 7, "with": ["Alice", "Bob"], "from": "Bob", "message": "I'm here!"}, - {"id": 8, "with": ["Alice", "Bob"], "from": None, "message": "Great!"}, -] - -@component -def root(): - return simple.router( - route("/", home()), - route( - "/messages", - all_messages(), - route("/with/{names}", messages_with()), # note the path param - ), - route("*", html.h1("Missing Link πŸ”—β€πŸ’₯")), - ) - -@component -def home(): - return html.div( - html.h1("Home Page 🏠"), - link("Messages", to="/messages"), - ) - -@component -def all_messages(): - last_messages = { - ", ".join(msg["with"]): msg - for msg in sorted(message_data, key=lambda m: m["id"]) - } - return html.div( - html.h1("All Messages πŸ’¬"), - html.ul( - [ - 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() - ] - ), - ) - -@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] - return html.div( - html.h1(f"Messages with {', '.join(names)} πŸ’¬"), - html.ul( - [ - html.li( - {"key": msg["id"]}, - f"{msg['from'] or 'You'}: {msg['message']}", - ) - for msg in messages - ] - ), - ) - -run(root) -``` - -# Running the Tests - -```bash -nox -s test -``` - -You can run the tests with a headed browser. - -```bash -nox -s test -- --headed -``` - -# Releasing This Package - -To release a new version of reactpy-router on PyPI: - -1. Install [`twine`](https://twine.readthedocs.io/en/latest/) with `pip install twine` -2. Update the `version = "x.y.z"` variable in `reactpy-router/__init__.py` -3. `git` add the changes to `__init__.py` and create a `git tag -a x.y.z -m 'comment'` -4. Build the Python package with `python setup.py sdist bdist_wheel` -5. Check the build artifacts `twine check --strict dist/*` -6. Upload the build artifacts to [PyPI](https://pypi.org/) `twine upload dist/*` - -To release a new version of `reactpy-router` on [NPM](https://www.npmjs.com/): - -1. Update `js/package.json` with new npm package version -2. Clean out prior builds `git clean -fdx` -3. Install and publish `npm install && npm publish` +Read the docs: https://reactive-python.github.io/reactpy-router diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml new file mode 100644 index 0000000..54a4f8c --- /dev/null +++ b/docs/mkdocs.yml @@ -0,0 +1,55 @@ +site_name: ReactPy Router +docs_dir: src +repo_url: https://github.com/reactive-python/reactpy-router + +nav: + - Home: index.md + - Usage: usage.md + - Tutorials: + - Simple Application: tutorials/simple-app.md + - Custom Router: tutorials/custom-router.md + - Reference: reference.md + - Contributing: contributing.md + - Source Code: https://github.com/reactive-python/reactpy-router + +theme: + name: material + logo: assets/logo.svg + favicon: assets/logo.svg + palette: + # Palette toggle for light mode + - scheme: default + toggle: + icon: material/brightness-7 + name: Switch to dark mode + primary: black + accent: light-blue + + # Palette toggle for dark mode + - scheme: slate + toggle: + icon: material/brightness-4 + name: Switch to light mode + primary: black + accent: light-blue + + +plugins: +- search +- mkdocstrings: + default_handler: python + handlers: + python: + paths: ["../"] + import: + - https://reactpy.dev/docs/objects.inv + - https://installer.readthedocs.io/en/stable/objects.inv + +markdown_extensions: + - admonition + - pymdownx.details + - pymdownx.superfences + +watch: + - "../reactpy_router" + diff --git a/docs/src/assets/logo.svg b/docs/src/assets/logo.svg new file mode 100644 index 0000000..312fb87 --- /dev/null +++ b/docs/src/assets/logo.svg @@ -0,0 +1,160 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/src/contributing.md b/docs/src/contributing.md new file mode 100644 index 0000000..959ba6e --- /dev/null +++ b/docs/src/contributing.md @@ -0,0 +1,70 @@ +# Contributing + +!!! note + + The [Code of Conduct](https://github.com/reactive-python/reactpy/blob/main/CODE_OF_CONDUCT.md) + applies in all community spaces. If you are not familiar with our Code of Conduct policy, + take a minute to read it before making your first contribution. + +The ReactPy team welcomes contributions and contributors of all kinds - whether they +come as code changes, participation in the discussions, opening issues and pointing out +bugs, or simply sharing your work with your colleagues and friends. We’re excited to see +how you can help move this project and community forward! + +## Everyone Can Contribute! + +Trust us, there’s so many ways to support the project. We’re always looking for people who can: + +- Improve our documentation +- Teach and tell others about ReactPy +- Share ideas for new features +- Report bugs +- Participate in general discussions + +Still aren’t sure what you have to offer? Just [ask us](https://github.com/reactive-python/reactpy-router/discussions) and we’ll help you make your first contribution. + +## Development Environment + +For a developer installation from source be sure to install +[NPM](https://www.npmjs.com/) before running: + +```bash +git clone https://github.com/reactive-python/reactpy-router +cd reactpy-router +pip install -e . -r requirements.txt +``` + +This will install an ediable version of `reactpy-router` as well as tools you'll need +to work with this project. + +Of particular note is [`nox`](https://nox.thea.codes/en/stable/), which is used to +automate testing and other development tasks. + +## Running the Tests + +```bash +nox -s test +``` + +You can run the tests with a headed browser. + +```bash +nox -s test -- --headed +``` + +## Releasing This Package + +To release a new version of reactpy-router on PyPI: + +1. Install [`twine`](https://twine.readthedocs.io/en/latest/) with `pip install twine` +2. Update the `version = "x.y.z"` variable in `reactpy-router/__init__.py` +3. `git` add the changes to `__init__.py` and create a `git tag -a x.y.z -m 'comment'` +4. Build the Python package with `python setup.py sdist bdist_wheel` +5. Check the build artifacts `twine check --strict dist/*` +6. Upload the build artifacts to [PyPI](https://pypi.org/) `twine upload dist/*` + +To release a new version of `reactpy-router` on [NPM](https://www.npmjs.com/): + +1. Update `js/package.json` with new npm package version +2. Clean out prior builds `git clean -fdx` +3. Install and publish `npm install && npm publish` diff --git a/docs/src/index.md b/docs/src/index.md new file mode 100644 index 0000000..351fd71 --- /dev/null +++ b/docs/src/index.md @@ -0,0 +1,18 @@ +# ReactPy Router + +A URL router for [ReactPy](https://reactpy.dev). + +!!! note + + If you don't already know the basics of working with ReactPy, you should + [start there](https://reactpy.dev/docs/guides/getting-started/index.html). + +## Installation + +Use `pip` to install this package: + +```bash +pip install reactpy-router +``` + +[installer.records][] diff --git a/docs/src/reference.md b/docs/src/reference.md new file mode 100644 index 0000000..aabc9b3 --- /dev/null +++ b/docs/src/reference.md @@ -0,0 +1,5 @@ +# Reference + +::: reactpy_router.core +::: reactpy_router.simple +::: reactpy_router.types diff --git a/docs/src/tutorials/custom-router.md b/docs/src/tutorials/custom-router.md new file mode 100644 index 0000000..fa03675 --- /dev/null +++ b/docs/src/tutorials/custom-router.md @@ -0,0 +1,3 @@ +# Custom Router + +Under construction 🚧 diff --git a/docs/src/tutorials/simple-app.md b/docs/src/tutorials/simple-app.md new file mode 100644 index 0000000..5f7fcbd --- /dev/null +++ b/docs/src/tutorials/simple-app.md @@ -0,0 +1,277 @@ +# Simple Application + +Let's build a simple web application for viewing messages between several people. + +For the purposes of this tutorial we'll be working with the following data: + +```python +message_data = [ + {"id": 1, "with": ["Alice"], "from": None, "message": "Hello!"}, + {"id": 2, "with": ["Alice"], "from": "Alice", "message": "How's it going?"}, + {"id": 3, "with": ["Alice"], "from": None, "message": "Good, you?"}, + {"id": 4, "with": ["Alice"], "from": "Alice", "message": "Good, thanks!"}, + {"id": 5, "with": ["Alice", "Bob"], "from": None, "message": "We meeting now?"}, + {"id": 6, "with": ["Alice", "Bob"], "from": "Alice", "message": "Not sure."}, + {"id": 7, "with": ["Alice", "Bob"], "from": "Bob", "message": "I'm here!"}, + {"id": 8, "with": ["Alice", "Bob"], "from": None, "message": "Great!"}, +] +``` + +In a more realistic application this data would be stored in a database, but for this +tutorial we'll just keep it in memory. + +## Basic Routing + +The first step is to create a basic router that will display the home page when the +user navigates to the root of the application, and a "missing link" page for any other +route: + +```python +from reactpy import component, html, run +from reactpy_router import route, simple + +@component +def root(): + return simple.router( + route("/", html.h1("Home Page 🏠")), + route("*", html.h1("Missing Link πŸ”—β€πŸ’₯")), + ) + +run(root) +``` + +When navigating to http://127.0.0.1:8000 you should see "Home Page 🏠". However, if you +go to any other route (e.g. http://127.0.0.1:8000/missing) you will instead see the +"Missing Link πŸ”—β€πŸ’₯" page. + +With this foundation you can start adding more routes: + +```python +from reactpy import component, html, run +from reactpy_router import route, simple + +@component +def root(): + return simple.router( + route("/", html.h1("Home Page 🏠")), + route("/messages", html.h1("Messages πŸ’¬")), + route("*", html.h1("Missing Link πŸ”—β€πŸ’₯")), + ) + +run(root) +``` + +With this change you can now also go to `/messages` to see "Messages πŸ’¬" displayed. + +## Route Links + +Instead of using the standard `` element to create links to different parts of your +application, use `reactpy_router.link` instead. When users click links constructed using +`reactpy_router.link`, instead of letting the browser navigate to the associated route, +ReactPy will more quickly handle the transition by avoiding the cost of a full page +load. + +```python +from reactpy import component, html, run +from reactpy_router import link, route, simple + +@component +def root(): + return simple.router( + route("/", home()), + route("/messages", html.h1("Messages πŸ’¬")), + route("*", html.h1("Missing Link πŸ”—β€πŸ’₯")), + ) + +@component +def home(): + return html.div( + html.h1("Home Page 🏠"), + link("Messages", to="/messages"), + ) + +run(root) +``` + +Now, when you go to the home page, you can click the link to go to `/messages`. + +## Nested Routes + +Routes can be nested in order to construct more complicated application structures: + +```python +from reactpy import component, html, run +from reactpy_router import route, simple, link + +message_data = [ + {"id": 1, "with": ["Alice"], "from": None, "message": "Hello!"}, + {"id": 2, "with": ["Alice"], "from": "Alice", "message": "How's it going?"}, + {"id": 3, "with": ["Alice"], "from": None, "message": "Good, you?"}, + {"id": 4, "with": ["Alice"], "from": "Alice", "message": "Good, thanks!"}, + {"id": 5, "with": ["Alice", "Bob"], "from": None, "message": "We meeting now?"}, + {"id": 6, "with": ["Alice", "Bob"], "from": "Alice", "message": "Not sure."}, + {"id": 7, "with": ["Alice", "Bob"], "from": "Bob", "message": "I'm here!"}, + {"id": 8, "with": ["Alice", "Bob"], "from": None, "message": "Great!"}, +] + +@component +def root(): + return simple.router( + route("/", home()), + route( + "/messages", + all_messages(), + # we'll improve upon these manually created routes in the next section... + route("/with/Alice", messages_with("Alice")), + route("/with/Alice-Bob", messages_with("Alice", "Bob")), + ), + route("*", html.h1("Missing Link πŸ”—β€πŸ’₯")), + ) + +@component +def home(): + return html.div( + html.h1("Home Page 🏠"), + link("Messages", to="/messages"), + ) + +@component +def all_messages(): + last_messages = { + ", ".join(msg["with"]): msg + for msg in sorted(message_data, key=lambda m: m["id"]) + } + return html.div( + html.h1("All Messages πŸ’¬"), + html.ul( + [ + 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() + ] + ), + ) + +@component +def messages_with(*names): + names = set(names) + messages = [msg for msg in message_data if set(msg["with"]) == names] + return html.div( + html.h1(f"Messages with {', '.join(names)} πŸ’¬"), + html.ul( + [ + html.li( + {"key": msg["id"]}, + f"{msg['from'] or 'You'}: {msg['message']}", + ) + for msg in messages + ] + ), + ) + +run(root) +``` + +## Route Parameters + +In the example above we had to manually create a `messages_with(...)` component for each +conversation. This would be better accomplished by defining a single route that declares +["route parameters"](../usage.md#simple-router) instead. + +Any parameters that have matched in the currently displayed route can then be consumed +with the `use_params` hook which returns a dictionary mapping the parameter names to +their values. Note that parameters with a declared type will be converted to is in the +parameters dictionary. So for example `/my/route/{my_param:float}` would match +`/my/route/3.14` and have a parameter dictionary of `{"my_param": 3.14}`. + +If we take this information and apply it to our growing example application we'd +substitute the manually constructed `/messages/with` routes with a single +`/messages/with/{names}` route: + +```python +from reactpy import component, html, run +from reactpy_router import route, simple, link +from reactpy_router.core import use_params + +message_data = [ + {"id": 1, "with": ["Alice"], "from": None, "message": "Hello!"}, + {"id": 2, "with": ["Alice"], "from": "Alice", "message": "How's it going?"}, + {"id": 3, "with": ["Alice"], "from": None, "message": "Good, you?"}, + {"id": 4, "with": ["Alice"], "from": "Alice", "message": "Good, thanks!"}, + {"id": 5, "with": ["Alice", "Bob"], "from": None, "message": "We meeting now?"}, + {"id": 6, "with": ["Alice", "Bob"], "from": "Alice", "message": "Not sure."}, + {"id": 7, "with": ["Alice", "Bob"], "from": "Bob", "message": "I'm here!"}, + {"id": 8, "with": ["Alice", "Bob"], "from": None, "message": "Great!"}, +] + +@component +def root(): + return simple.router( + route("/", home()), + route( + "/messages", + all_messages(), + route("/with/{names}", messages_with()), # note the path param + ), + route("*", html.h1("Missing Link πŸ”—β€πŸ’₯")), + ) + +@component +def home(): + return html.div( + html.h1("Home Page 🏠"), + link("Messages", to="/messages"), + ) + +@component +def all_messages(): + last_messages = { + ", ".join(msg["with"]): msg + for msg in sorted(message_data, key=lambda m: m["id"]) + } + return html.div( + html.h1("All Messages πŸ’¬"), + html.ul( + [ + 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() + ] + ), + ) + +@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] + return html.div( + html.h1(f"Messages with {', '.join(names)} πŸ’¬"), + html.ul( + [ + html.li( + {"key": msg["id"]}, + f"{msg['from'] or 'You'}: {msg['message']}", + ) + for msg in messages + ] + ), + ) + +run(root) +``` diff --git a/docs/src/usage.md b/docs/src/usage.md new file mode 100644 index 0000000..0bf0387 --- /dev/null +++ b/docs/src/usage.md @@ -0,0 +1,164 @@ +# Usage + +!!! note + + The sections below assume you already know the basics of [ReacPy](https://reactpy.dev). + +Here you'll learn the various features of `reactpy-router` and how to use them. All examples +will utilize the [simple.router][reactpy_router.simple.router] (though you can [use your own](#custom-routers)). + +## Routers and Routes + +The [simple.router][reactpy_router.simple.router] component is one possible +implementation of a [Router][reactpy_router.types.Router]. Routers takes a series of +[Route][reactpy_router.types.Route] objects as positional arguments and render whatever +element matches the current location. For convenience, these `Route` objects are created +using the [route][reactpy_router.route] function. + +!!! 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 `simple.router` with two routes: + +```python +from reactpy import component, html, run +from reactpy_router import route, simple, use_location + +@component +def root(): + location = use_location() + return simple.router( + route("/", html.h1("Home Page 🏠")), + route("*", html.h1("Missing Link πŸ”—β€πŸ’₯")), + ) +``` + +Here we'll note some special syntax in the route path for the second route. The `*` is a +wildcard that will match any path. This is useful for creating a "404" page that will be +shown when no other route matches. + +### Simple Router + +The syntax for declaring routes with the [simple.router][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: + +``` +/my/route/{param} +/my/route/{param:type} +``` + +In this case, `param` is the name of the route parameter and the optionally declared +`type` specifies what kind of parameter it is. The available parameter types and what +patterns they match are are: + +- str (default) - `[^/]+` +- int - `\d+` +- float - `\d+(\.\d+)?` +- uuid - `[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}` +- path - `.+` + +!!! note + + The `path` type is special in that it will match any path, including `/` characters. + This is useful for creating routes that match a path prefix. + +So in practice these each might look like: + +``` +/my/route/{param} +/my/route/{param:int} +/my/route/{param:float} +/my/route/{param:uuid} +/my/route/{param:path} +``` + +Any route parameters collected from the current location then be accessed using the +[`use_params`](#using-parameters) hook. + +!!! note + + It's worth pointing out that, while you can use route parameters to capture values + from queryies (i.e. `?foo=bar`), this is not recommended. Instead, you should use + the [use_query][reactpy_router.use_query] hook to access query parameters. + +### Route Links + +Links between routes should be created using the [link][reactpy_router.link] component. +This will allow ReactPy to handle the transition between routes more quickly by avoiding +the cost of a full page load. + +```python +from reactpy import component, html, run +from reactpy_router import link, route, simple, use_location + +@component +def root(): + location = use_location() + return simple.router( + route("/", html.h1("Home Page 🏠")), + route("/about", html.h1("About Page πŸ“–")), + link("/about", html.button("About")), + ) +``` + +## Hooks + +`reactpy-router` provides a number of hooks for working with the routes: + +- [`use_query`](#using-queries) - for accessing query parameters +- [`use_params`](#using-parameters) - for accessing route parameters + +If you're not familiar with hooks, you should +[read the docs](https://reactpy.dev/docs/guides/adding-interactivity/components-with-state/index.html#your-first-hook). + +### Using Queries + +The [use_query][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. + +```python +from reactpy import component, html, run +from reactpy_router import link, route, simple, use_query + +@component +def root(): + return simple.router( + route("/", html.h1("Home Page 🏠")), + route("/search", search()), + link("Search", to="/search?q=reactpy"), + ) + +@component +def search(): + query = use_query() + return html.h1(f"Search Results for {query['q'][0]} πŸ”") +``` + +### Using Parameters + +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. + +```python +from reactpy import component, html, run +from reactpy_router import link, route, simple, use_params + +@component +def root(): + return simple.router( + route("/", html.h1("Home Page 🏠")), + route("/user/{id:int}", user()), + link("User 123", to="/user/123"), + ) + +@component +def user(): + params = use_params() + return html.h1(f"User {params['id']} πŸ‘€") +``` diff --git a/noxfile.py b/noxfile.py index ee5f848..2103db6 100644 --- a/noxfile.py +++ b/noxfile.py @@ -13,6 +13,18 @@ def format(session: Session) -> None: session.run("isort", ".") +@session +def docs(session: Session) -> None: + setup_docs(session) + session.run("mkdocs", "serve") + + +@session +def docs_build(session: Session) -> None: + setup_docs(session) + session.run("mkdocs", "build") + + @session def test(session: Session) -> None: session.notify("test_style") @@ -52,5 +64,11 @@ def test_suite(session: Session) -> None: session.run("pytest", "tests", *posargs) +def setup_docs(session: Session) -> None: + install_requirements(session, "build-docs") + session.install("-e", ".") + session.chdir("docs") + + def install_requirements(session: Session, name: str) -> None: session.install("-r", str(REQUIREMENTS_DIR / f"{name}.txt")) diff --git a/reactpy_router/core.py b/reactpy_router/core.py index df0105e..15d5b72 100644 --- a/reactpy_router/core.py +++ b/reactpy_router/core.py @@ -1,3 +1,5 @@ +"""Core functionality for the reactpy-router package.""" + from __future__ import annotations from dataclasses import dataclass, replace @@ -25,6 +27,7 @@ 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) @@ -42,6 +45,8 @@ def router_component( *routes: R, compiler: RouteCompiler[R], ) -> ComponentType | 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) @@ -64,6 +69,7 @@ def router_component( @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, diff --git a/reactpy_router/py.typed b/reactpy_router/py.typed new file mode 100644 index 0000000..7632ecf --- /dev/null +++ b/reactpy_router/py.typed @@ -0,0 +1 @@ +# Marker file for PEP 561 diff --git a/reactpy_router/simple.py b/reactpy_router/simple.py index e9e2e11..566332e 100644 --- a/reactpy_router/simple.py +++ b/reactpy_router/simple.py @@ -1,3 +1,5 @@ +"""A simple router implementation for ReactPy""" + from __future__ import annotations import re @@ -15,9 +17,12 @@ ConverterMapping: TypeAlias = "dict[str, ConversionFunc]" PARAM_REGEX = re.compile(r"{(?P\w+)(?P:\w+)?}") +"""Regex for matching path params""" 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) @@ -34,6 +39,7 @@ def resolve(self, path: str) -> tuple[Any, dict[str, Any]] | None: def parse_path(path: str) -> tuple[re.Pattern[str], ConverterMapping]: + """Parse a path into a regex pattern and a mapping of converters""" if path == "*": return re.compile(".*"), {} @@ -56,8 +62,12 @@ def parse_path(path: str) -> tuple[re.Pattern[str], ConverterMapping]: 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] = { @@ -82,6 +92,8 @@ class ConversionInfo(TypedDict): "func": str, }, } +"""The supported conversion types""" router = create_router(SimpleResolver) +"""The simple router""" diff --git a/reactpy_router/types.py b/reactpy_router/types.py index 2a600ba..a91787e 100644 --- a/reactpy_router/types.py +++ b/reactpy_router/types.py @@ -1,3 +1,5 @@ +"""Types for reactpy_router""" + from __future__ import annotations from dataclasses import dataclass, field @@ -10,9 +12,16 @@ @dataclass(frozen=True) class Route: + """A route that can be matched against a path""" + path: str + """The path to match against""" + element: Any = field(hash=False) + """The element to render if the path matches""" + routes: Sequence[Self] + """Child routes""" def __hash__(self) -> int: el = self.element @@ -24,13 +33,17 @@ def __hash__(self) -> int: class Router(Protocol[R]): + """Return a component that renders the first matching route""" + def __call__(self, *routes: R) -> ComponentType: - """Return a component that renders the first matching route""" + ... class RouteCompiler(Protocol[R]): + """Compile a route into a resolver that can be matched against a path""" + def __call__(self, route: R) -> RouteResolver: - """Compile a route into a resolver that can be matched against a path""" + ... class RouteResolver(Protocol): diff --git a/requirements.txt b/requirements.txt index 55f870e..5893bf2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ +-r requirements/build-docs.txt -r requirements/check-style.txt -r requirements/check-types.txt -r requirements/nox-deps.txt diff --git a/requirements/build-docs.txt b/requirements/build-docs.txt new file mode 100644 index 0000000..3f19165 --- /dev/null +++ b/requirements/build-docs.txt @@ -0,0 +1,5 @@ +mkdocs +mkdocs-material +mkdocs-gen-files +mkdocs-literate-nav +mkdocstrings[python] From f1ac7a1fa79aee74cc8e091aaede58afcb43beeb Mon Sep 17 00:00:00 2001 From: rmorshea Date: Thu, 25 May 2023 00:14:32 -0600 Subject: [PATCH 3/3] fix test gh action --- .github/workflows/test.yaml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 0c53b1a..b9660df 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -1,6 +1,12 @@ name: Test -on: [push] +on: + push: + branches: + - main + pull_request: + branches: + - main jobs: coverage: