diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..c6bdd8f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,24 @@ +--- +name: Bug report +about: Create a report to help us improve +title: Bug Report +labels: bug +assignees: rmorshea + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/doc_enhancement.md b/.github/ISSUE_TEMPLATE/doc_enhancement.md new file mode 100644 index 0000000..9a960b0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/doc_enhancement.md @@ -0,0 +1,14 @@ +--- +name: Doc enhancement +about: Documentation needs to be fixed or added +title: Doc Enhancement +labels: docs +assignees: rmorshea + +--- + +**Describe what documentation needs to be fixed or added** +Is something missing, worded poorly, or flat out wrong? Tells us about it here. + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..1c5de5f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: enhancement +assignees: rmorshea + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..73009c4 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,30 @@ +# 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: Release + +on: + release: + types: + - created + +jobs: + publish-package: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v1 + with: + python-version: "3.x" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install setuptools wheel twine + - name: Build and publish + env: + TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} + TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} + run: | + python setup.py bdist_wheel + twine upload dist/* diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..0c53b1a --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,33 @@ +name: Test + +on: [push] + +jobs: + coverage: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Use Latest Python + uses: actions/setup-python@v2 + with: + python-version: "3.10" + - name: Install Python Dependencies + run: pip install -r requirements/nox-deps.txt + - name: Run Tests + run: nox -s test + + environments: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.7", "3.8", "3.9", "3.10"] + steps: + - uses: actions/checkout@v2 + - name: Use Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Install Python Dependencies + run: pip install -r requirements/nox-deps.txt + - name: Run Tests + run: nox -s test -- --no-cov diff --git a/idom_router/__init__.py b/idom_router/__init__.py index 2463234..0598a4a 100644 --- a/idom_router/__init__.py +++ b/idom_router/__init__.py @@ -1,2 +1,22 @@ # the version is statically loaded by setup.py __version__ = "0.0.1" + +from .router import ( + Route, + RoutesConstructor, + configure, + link, + use_location, + use_params, + use_query, +) + +__all__ = [ + "configure", + "link", + "Route", + "RoutesConstructor", + "use_location", + "use_params", + "use_query", +] diff --git a/idom_router/router.py b/idom_router/router.py index 957bc86..6a68c2c 100644 --- a/idom_router/router.py +++ b/idom_router/router.py @@ -1,72 +1,138 @@ from __future__ import annotations -from dataclasses import dataclass -from fnmatch import translate as fnmatch_translate -from pathlib import Path import re -from typing import Any, Iterator, Protocol, Callable, Sequence +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable, Iterator, Sequence +from urllib.parse import parse_qs -from idom import create_context, component, use_context, use_state -from idom.web.module import export, module_from_file -from idom.core.vdom import coalesce_attributes_and_children, VdomAttributesAndChildren +from idom import component, create_context, use_context, use_memo, use_state +from idom.core.types import VdomAttributesAndChildren, VdomDict +from idom.core.vdom import coalesce_attributes_and_children from idom.types import BackendImplementation, ComponentType, Context, Location +from idom.web.module import export, module_from_file +from starlette.routing import compile_path + +try: + from typing import Protocol +except ImportError: # pragma: no cover + from typing_extensions import Protocol # type: ignore -class Router(Protocol): +class RoutesConstructor(Protocol): def __call__(self, *routes: Route) -> ComponentType: ... -def bind(backend: BackendImplementation) -> Router: +def configure( + implementation: BackendImplementation[Any] | Callable[[], Location] +) -> RoutesConstructor: + if isinstance(implementation, BackendImplementation): + use_location = implementation.use_location + elif callable(implementation): + use_location = implementation + else: + raise TypeError( + "Expected a 'BackendImplementation' or " + f"'use_location' hook, not {implementation}" + ) + @component - def Router(*routes: Route): - initial_location = backend.use_location() + def routes(*routes: Route) -> ComponentType | None: + initial_location = use_location() location, set_location = use_state(initial_location) - for p, r in _compile_routes(routes): - if p.match(location.pathname): + compiled_routes = use_memo( + lambda: _iter_compile_routes(routes), dependencies=routes + ) + for r in compiled_routes: + match = r.pattern.match(location.pathname) + if match: return _LocationStateContext( r.element, - value=(location, set_location), - key=r.path, + value=_LocationState( + location, + set_location, + {k: r.converters[k](v) for k, v in match.groupdict().items()}, + ), + key=r.pattern.pattern, ) return None - return Router - - -def use_location() -> str: - return _use_location_state()[0] + return routes @dataclass class Route: - path: str | re.Pattern + path: str element: Any + routes: Sequence[Route] + + def __init__(self, path: str, element: Any | None, *routes: Route) -> None: + self.path = path + self.element = element + self.routes = routes @component -def Link(*attributes_or_children: VdomAttributesAndChildren, to: str) -> None: +def link(*attributes_or_children: VdomAttributesAndChildren, to: str) -> VdomDict: attributes, children = coalesce_attributes_and_children(attributes_or_children) - set_location = _use_location_state()[1] - return _Link( - { - **attributes, - "to": to, - "onClick": lambda event: set_location(Location(**event)), - }, - *children, + set_location = _use_location_state().set_location + attrs = { + **attributes, + "to": to, + "onClick": lambda event: set_location(Location(**event)), + } + return _Link(attrs, *children) + + +def use_location() -> Location: + """Get the current route location""" + return _use_location_state().location + + +def use_params() -> dict[str, Any]: + """Get parameters from the currently matching route pattern""" + return _use_location_state().params + + +def use_query( + keep_blank_values: bool = False, + strict_parsing: bool = False, + errors: str = "replace", + max_num_fields: int | None = None, + separator: str = "&", +) -> dict[str, list[str]]: + """See :func:`urllib.parse.parse_qs` for parameter info.""" + return parse_qs( + use_location().search[1:], + keep_blank_values=keep_blank_values, + strict_parsing=strict_parsing, + errors=errors, + max_num_fields=max_num_fields, + separator=separator, ) -def _compile_routes(routes: Sequence[Route]) -> Iterator[tuple[re.Pattern, Route]]: +def _iter_compile_routes(routes: Sequence[Route]) -> Iterator[_CompiledRoute]: + for path, element in _iter_routes(routes): + pattern, _, converters = compile_path(path) + yield _CompiledRoute( + pattern, {k: v.convert for k, v in converters.items()}, element + ) + + +def _iter_routes(routes: Sequence[Route]) -> Iterator[tuple[str, Any]]: for r in routes: - if isinstance(r.path, re.Pattern): - yield r.path, r - continue - if not r.path.startswith("/"): - raise ValueError("Path pattern must begin with '/'") - pattern = re.compile(fnmatch_translate(r.path)) - yield pattern, r + for path, element in _iter_routes(r.routes): + yield r.path + path, element + yield r.path, r.element + + +@dataclass +class _CompiledRoute: + pattern: re.Pattern[str] + converters: dict[str, Callable[[Any], Any]] + element: Any def _use_location_state() -> _LocationState: @@ -75,9 +141,14 @@ def _use_location_state() -> _LocationState: return location_state -_LocationSetter = Callable[[str], None] -_LocationState = tuple[Location, _LocationSetter] -_LocationStateContext: type[Context[_LocationState | None]] = create_context(None) +@dataclass +class _LocationState: + location: Location + set_location: Callable[[Location], None] + params: dict[str, Any] + + +_LocationStateContext: Context[_LocationState | None] = create_context(None) _Link = export( module_from_file( diff --git a/noxfile.py b/noxfile.py new file mode 100644 index 0000000..4cf7137 --- /dev/null +++ b/noxfile.py @@ -0,0 +1,56 @@ +from pathlib import Path + +from nox import Session, session + +ROOT = Path(".") +REQUIREMENTS_DIR = ROOT / "requirements" + + +@session +def format(session: Session) -> None: + install_requirements(session, "style") + session.run("black", ".") + session.run("isort", ".") + + +@session +def test(session: Session) -> None: + session.notify("test_style") + session.notify("test_types") + session.notify("test_suite") + + +@session +def test_style(session: Session) -> None: + install_requirements(session, "check-style") + session.run("black", "--check", ".") + session.run("isort", "--check", ".") + session.run("flake8", ".") + + +@session +def test_types(session: Session) -> None: + install_requirements(session, "check-types") + session.run("mypy", "--strict", "idom_router") + + +@session +def test_suite(session: Session) -> None: + install_requirements(session, "test-env") + session.run("playwright", "install", "chromium") + + posargs = session.posargs[:] + + if "--no-cov" in session.posargs: + posargs.remove("--no-cov") + session.log("Coverage won't be checked") + session.install(".") + else: + posargs += ["--cov=idom_router", "--cov-report=term"] + session.install("-e", ".") + + session.run("pytest", "tests", *posargs) + + +def install_requirements(session: Session, name: str) -> None: + session.install("-r", str(REQUIREMENTS_DIR / f"{name}.txt")) diff --git a/pyproject.toml b/pyproject.toml index 899743e..d645c38 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,3 +6,14 @@ build-backend = "setuptools.build_meta" [tool.pytest.ini_options] testpaths = "tests" asyncio_mode = "auto" + + +[tool.isort] +profile = "black" + + +[tool.mypy] +ignore_missing_imports = true +warn_unused_configs = true +warn_redundant_casts = true +warn_unused_ignores = true diff --git a/requirements.txt b/requirements.txt index 7c94c3c..55f870e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ -twine -pytest -pytest-asyncio -idom[testing,starlette] +-r requirements/check-style.txt +-r requirements/check-types.txt +-r requirements/nox-deps.txt +-r requirements/pkg-deps.txt +-r requirements/test-env.txt diff --git a/requirements/check-style.txt b/requirements/check-style.txt new file mode 100644 index 0000000..ea105f4 --- /dev/null +++ b/requirements/check-style.txt @@ -0,0 +1,4 @@ +black +flake8 +flake8_idom_hooks +isort diff --git a/requirements/check-types.txt b/requirements/check-types.txt new file mode 100644 index 0000000..d8455d6 --- /dev/null +++ b/requirements/check-types.txt @@ -0,0 +1,2 @@ +mypy +idom diff --git a/requirements/nox-deps.txt b/requirements/nox-deps.txt new file mode 100644 index 0000000..816817c --- /dev/null +++ b/requirements/nox-deps.txt @@ -0,0 +1 @@ +nox diff --git a/requirements/pkg-deps.txt b/requirements/pkg-deps.txt new file mode 100644 index 0000000..a61b19f --- /dev/null +++ b/requirements/pkg-deps.txt @@ -0,0 +1,3 @@ +idom >=0.40.2,<0.41 +typing_extensions +starlette diff --git a/requirements/test-env.txt b/requirements/test-env.txt new file mode 100644 index 0000000..c440c3b --- /dev/null +++ b/requirements/test-env.txt @@ -0,0 +1,5 @@ +twine +pytest +pytest-asyncio +pytest-cov +idom[testing,starlette] diff --git a/setup.cfg b/setup.cfg index 3c6e79c..8d1ef40 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,2 +1,21 @@ [bdist_wheel] universal=1 + +[flake8] +ignore = E203, E266, E501, W503, F811, N802 +max-line-length = 88 +extend-exclude = + .nox + venv + .venv + tests/cases/* + +[coverage:report] +fail_under = 100 +show_missing = True +skip_covered = True +sort = Miss +exclude_lines = + pragma: no cover + \.\.\. + raise NotImplementedError diff --git a/setup.py b/setup.py index 8486ed4..dc0fefa 100644 --- a/setup.py +++ b/setup.py @@ -1,14 +1,13 @@ from __future__ import print_function import os +import shutil import subprocess import sys -import shutil -from setuptools import setup, find_packages -from distutils.command.build import build # type: ignore -from distutils.command.sdist import sdist # type: ignore +from setuptools import find_packages, setup from setuptools.command.develop import develop +from setuptools.command.sdist import sdist # the name of the project name = "idom_router" @@ -26,7 +25,6 @@ package = { "name": name, "python_requires": ">=3.7", - "install_requires": ["idom>=0.39.0"], "packages": find_packages(exclude=["tests*"]), "description": "A URL router for IDOM", "author": "Ryan Morshead", @@ -50,6 +48,19 @@ } +# ----------------------------------------------------------------------------- +# Requirements +# ----------------------------------------------------------------------------- + + +requirements = [] +with open(os.path.join(here, "requirements", "pkg-deps.txt"), "r") as f: + for line in map(str.strip, f): + if not line.startswith("#"): + requirements.append(line) +package["install_requires"] = requirements + + # ----------------------------------------------------------------------------- # Library Version # ----------------------------------------------------------------------------- @@ -96,10 +107,18 @@ def run(self): package["cmdclass"] = { "sdist": build_javascript_first(sdist), - "build": build_javascript_first(build), "develop": build_javascript_first(develop), } +if sys.version_info < (3, 10, 6): + from distutils.command.build import build + + package["cmdclass"]["build"] = build_javascript_first(build) +else: + from setuptools.command.build_py import build_py + + package["cmdclass"]["build_py"] = build_javascript_first(build_py) + # ----------------------------------------------------------------------------- # Install It diff --git a/tests/conftest.py b/tests/conftest.py index 0bdd089..d632b8f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,6 @@ import pytest +from idom.testing import BackendFixture, DisplayFixture from playwright.async_api import async_playwright -from idom.testing import DisplayFixture, BackendFixture def pytest_addoption(parser) -> None: @@ -13,16 +13,16 @@ def pytest_addoption(parser) -> None: @pytest.fixture -async def display(server, browser): - async with DisplayFixture(server, browser) as display: +async def display(backend, browser): + async with DisplayFixture(backend, browser) as display: display.page.set_default_timeout(10000) yield display @pytest.fixture -async def server(): - async with BackendFixture() as server: - yield server +async def backend(): + async with BackendFixture() as backend: + yield backend @pytest.fixture diff --git a/tests/test_router.py b/tests/test_router.py index aad7990..ee83f81 100644 --- a/tests/test_router.py +++ b/tests/test_router.py @@ -1,61 +1,176 @@ import pytest - from idom import Ref, component, html -from idom.testing import poll, BackendFixture, DisplayFixture +from idom.testing import BackendFixture, DisplayFixture -from idom_router.router import bind, Route, Router, Link +from idom_router import ( + Route, + RoutesConstructor, + configure, + link, + use_location, + use_params, + use_query, +) @pytest.fixture -def Router(server): - return bind(server.implementation) +def routes(backend: BackendFixture): + return configure(backend.implementation) + + +def test_configure(backend): + configure(backend.implementation) + configure(backend.implementation.use_location) + with pytest.raises( + TypeError, match="Expected a 'BackendImplementation' or 'use_location' hook" + ): + configure(None) + + +async def test_simple_router(display: DisplayFixture, routes: RoutesConstructor): + def make_location_check(path, *routes): + name = path.lstrip("/").replace("/", "-") + @component + def check_location(): + assert use_location().pathname == path + return html.h1({"id": name}, path) + + return Route(path, check_location(), *routes) -async def test_simple_router(display: DisplayFixture, Router: Router): @component - def Sample(): - return Router( - Route("/a", html.h1({"id": "a"}, "A")), - Route("/b", html.h1({"id": "b"}, "B")), - Route("/c", html.h1({"id": "c"}, "C")), - Route("/*", html.h1({"id": "default"}, "Default")), + def sample(): + return routes( + make_location_check("/a"), + make_location_check("/b"), + make_location_check("/c"), ) - await display.show(Sample) + await display.show(sample) for path, selector in [ ("/a", "#a"), ("/b", "#b"), ("/c", "#c"), - ("/default", "#default"), - ("/anything", "#default"), + ]: + await display.goto(path) + await display.page.wait_for_selector(selector) + + await display.goto("/missing") + + try: + root_element = display.root_element() + except AttributeError: + root_element = await display.page.wait_for_selector( + f"#display-{display._next_view_id}", state="attached" + ) + + assert not await root_element.inner_html() + + +async def test_nested_routes(display: DisplayFixture, routes: RoutesConstructor): + @component + def sample(): + return routes( + Route( + "/a", + html.h1({"id": "a"}, "A"), + Route( + "/b", + html.h1({"id": "b"}, "B"), + Route("/c", html.h1({"id": "c"}, "C")), + ), + ), + ) + + await display.show(sample) + + for path, selector in [ + ("/a", "#a"), + ("/a/b", "#b"), + ("/a/b/c", "#c"), ]: await display.goto(path) await display.page.wait_for_selector(selector) -async def test_navigate_with_link(display: DisplayFixture, Router: Router): +async def test_navigate_with_link(display: DisplayFixture, routes: RoutesConstructor): render_count = Ref(0) @component - def Sample(): + def sample(): render_count.current += 1 - return Router( - Route("/", Link({"id": "root"}, "Root", to="/a")), - Route("/a", Link({"id": "a"}, "A", to="/b")), - Route("/b", Link({"id": "b"}, "B", to="/c")), - Route("/c", Link({"id": "c"}, "C", to="/default")), - Route("/*", html.h1({"id": "default"}, "Default")), + return routes( + Route("/", link({"id": "root"}, "Root", to="/a")), + Route("/a", link({"id": "a"}, "A", to="/b")), + Route("/b", link({"id": "b"}, "B", to="/c")), + Route("/c", link({"id": "c"}, "C", to="/default")), + Route("/{path:path}", html.h1({"id": "default"}, "Default")), ) - await display.show(Sample) + await display.show(sample) for link_selector in ["#root", "#a", "#b", "#c"]: - link = await display.page.wait_for_selector(link_selector) - await link.click() + lnk = await display.page.wait_for_selector(link_selector) + await lnk.click() await display.page.wait_for_selector("#default") # check that we haven't re-rendered the root component by clicking the link # (i.e. we are preventing default link behavior) assert render_count.current == 1 + + +async def test_use_params(display: DisplayFixture, routes: RoutesConstructor): + expected_params = {} + + @component + def check_params(): + assert use_params() == expected_params + return html.h1({"id": "success"}, "success") + + @component + def sample(): + return routes( + Route( + "/first/{first:str}", + check_params(), + Route( + "/second/{second:str}", + check_params(), + Route( + "/third/{third:str}", + check_params(), + ), + ), + ) + ) + + await display.show(sample) + + for path, expected_params in [ + ("/first/1", {"first": "1"}), + ("/first/1/second/2", {"first": "1", "second": "2"}), + ("/first/1/second/2/third/3", {"first": "1", "second": "2", "third": "3"}), + ]: + await display.goto(path) + await display.page.wait_for_selector("#success") + + +async def test_use_query(display: DisplayFixture, routes: RoutesConstructor): + expected_query = {} + + @component + def check_query(): + assert use_query() == expected_query + return html.h1({"id": "success"}, "success") + + @component + def sample(): + return routes(Route("/", check_query())) + + await display.show(sample) + + expected_query = {"hello": ["world"], "thing": ["1", "2"]} + await display.goto("?hello=world&thing=1&thing=2") + await display.page.wait_for_selector("#success")