Skip to content

Commit f049d67

Browse files
authored
add robust lint/testing + upgrade idom + more robust routing with Starlette (#3)
* add robust lint/testing + upgrade idom * improve coverage
1 parent 0c57513 commit f049d67

19 files changed

+531
-83
lines changed

Diff for: .github/ISSUE_TEMPLATE/bug_report.md

+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
name: Bug report
3+
about: Create a report to help us improve
4+
title: Bug Report
5+
labels: bug
6+
assignees: rmorshea
7+
8+
---
9+
10+
**Describe the bug**
11+
A clear and concise description of what the bug is.
12+
13+
**To Reproduce**
14+
Steps to reproduce the behavior:
15+
1. Go to '...'
16+
2. Click on '....'
17+
3. Scroll down to '....'
18+
4. See error
19+
20+
**Expected behavior**
21+
A clear and concise description of what you expected to happen.
22+
23+
**Additional context**
24+
Add any other context about the problem here.

Diff for: .github/ISSUE_TEMPLATE/doc_enhancement.md

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
name: Doc enhancement
3+
about: Documentation needs to be fixed or added
4+
title: Doc Enhancement
5+
labels: docs
6+
assignees: rmorshea
7+
8+
---
9+
10+
**Describe what documentation needs to be fixed or added**
11+
Is something missing, worded poorly, or flat out wrong? Tells us about it here.
12+
13+
**Additional context**
14+
Add any other context about the problem here.

Diff for: .github/ISSUE_TEMPLATE/feature_request.md

+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
name: Feature request
3+
about: Suggest an idea for this project
4+
title: ''
5+
labels: enhancement
6+
assignees: rmorshea
7+
8+
---
9+
10+
**Is your feature request related to a problem? Please describe.**
11+
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
12+
13+
**Describe the solution you'd like**
14+
A clear and concise description of what you want to happen.
15+
16+
**Describe alternatives you've considered**
17+
A clear and concise description of any alternative solutions or features you've considered.
18+
19+
**Additional context**
20+
Add any other context or screenshots about the feature request here.

Diff for: .github/workflows/release.yaml

+30
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# This workflows will upload a Python Package using Twine when a release is created
2+
# For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries
3+
4+
name: Release
5+
6+
on:
7+
release:
8+
types:
9+
- created
10+
11+
jobs:
12+
publish-package:
13+
runs-on: ubuntu-latest
14+
steps:
15+
- uses: actions/checkout@v2
16+
- name: Set up Python
17+
uses: actions/setup-python@v1
18+
with:
19+
python-version: "3.x"
20+
- name: Install dependencies
21+
run: |
22+
python -m pip install --upgrade pip
23+
pip install setuptools wheel twine
24+
- name: Build and publish
25+
env:
26+
TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }}
27+
TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
28+
run: |
29+
python setup.py bdist_wheel
30+
twine upload dist/*

Diff for: .github/workflows/test.yaml

+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
name: Test
2+
3+
on: [push]
4+
5+
jobs:
6+
coverage:
7+
runs-on: ubuntu-latest
8+
steps:
9+
- uses: actions/checkout@v2
10+
- name: Use Latest Python
11+
uses: actions/setup-python@v2
12+
with:
13+
python-version: "3.10"
14+
- name: Install Python Dependencies
15+
run: pip install -r requirements/nox-deps.txt
16+
- name: Run Tests
17+
run: nox -s test
18+
19+
environments:
20+
runs-on: ubuntu-latest
21+
strategy:
22+
matrix:
23+
python-version: ["3.7", "3.8", "3.9", "3.10"]
24+
steps:
25+
- uses: actions/checkout@v2
26+
- name: Use Python ${{ matrix.python-version }}
27+
uses: actions/setup-python@v2
28+
with:
29+
python-version: ${{ matrix.python-version }}
30+
- name: Install Python Dependencies
31+
run: pip install -r requirements/nox-deps.txt
32+
- name: Run Tests
33+
run: nox -s test -- --no-cov

Diff for: idom_router/__init__.py

+20
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,22 @@
11
# the version is statically loaded by setup.py
22
__version__ = "0.0.1"
3+
4+
from .router import (
5+
Route,
6+
RoutesConstructor,
7+
configure,
8+
link,
9+
use_location,
10+
use_params,
11+
use_query,
12+
)
13+
14+
__all__ = [
15+
"configure",
16+
"link",
17+
"Route",
18+
"RoutesConstructor",
19+
"use_location",
20+
"use_params",
21+
"use_query",
22+
]

Diff for: idom_router/router.py

+112-41
Original file line numberDiff line numberDiff line change
@@ -1,72 +1,138 @@
11
from __future__ import annotations
2-
from dataclasses import dataclass
32

4-
from fnmatch import translate as fnmatch_translate
5-
from pathlib import Path
63
import re
7-
from typing import Any, Iterator, Protocol, Callable, Sequence
4+
from dataclasses import dataclass
5+
from pathlib import Path
6+
from typing import Any, Callable, Iterator, Sequence
7+
from urllib.parse import parse_qs
88

9-
from idom import create_context, component, use_context, use_state
10-
from idom.web.module import export, module_from_file
11-
from idom.core.vdom import coalesce_attributes_and_children, VdomAttributesAndChildren
9+
from idom import component, create_context, use_context, use_memo, use_state
10+
from idom.core.types import VdomAttributesAndChildren, VdomDict
11+
from idom.core.vdom import coalesce_attributes_and_children
1212
from idom.types import BackendImplementation, ComponentType, Context, Location
13+
from idom.web.module import export, module_from_file
14+
from starlette.routing import compile_path
15+
16+
try:
17+
from typing import Protocol
18+
except ImportError: # pragma: no cover
19+
from typing_extensions import Protocol # type: ignore
1320

1421

15-
class Router(Protocol):
22+
class RoutesConstructor(Protocol):
1623
def __call__(self, *routes: Route) -> ComponentType:
1724
...
1825

1926

20-
def bind(backend: BackendImplementation) -> Router:
27+
def configure(
28+
implementation: BackendImplementation[Any] | Callable[[], Location]
29+
) -> RoutesConstructor:
30+
if isinstance(implementation, BackendImplementation):
31+
use_location = implementation.use_location
32+
elif callable(implementation):
33+
use_location = implementation
34+
else:
35+
raise TypeError(
36+
"Expected a 'BackendImplementation' or "
37+
f"'use_location' hook, not {implementation}"
38+
)
39+
2140
@component
22-
def Router(*routes: Route):
23-
initial_location = backend.use_location()
41+
def routes(*routes: Route) -> ComponentType | None:
42+
initial_location = use_location()
2443
location, set_location = use_state(initial_location)
25-
for p, r in _compile_routes(routes):
26-
if p.match(location.pathname):
44+
compiled_routes = use_memo(
45+
lambda: _iter_compile_routes(routes), dependencies=routes
46+
)
47+
for r in compiled_routes:
48+
match = r.pattern.match(location.pathname)
49+
if match:
2750
return _LocationStateContext(
2851
r.element,
29-
value=(location, set_location),
30-
key=r.path,
52+
value=_LocationState(
53+
location,
54+
set_location,
55+
{k: r.converters[k](v) for k, v in match.groupdict().items()},
56+
),
57+
key=r.pattern.pattern,
3158
)
3259
return None
3360

34-
return Router
35-
36-
37-
def use_location() -> str:
38-
return _use_location_state()[0]
61+
return routes
3962

4063

4164
@dataclass
4265
class Route:
43-
path: str | re.Pattern
66+
path: str
4467
element: Any
68+
routes: Sequence[Route]
69+
70+
def __init__(self, path: str, element: Any | None, *routes: Route) -> None:
71+
self.path = path
72+
self.element = element
73+
self.routes = routes
4574

4675

4776
@component
48-
def Link(*attributes_or_children: VdomAttributesAndChildren, to: str) -> None:
77+
def link(*attributes_or_children: VdomAttributesAndChildren, to: str) -> VdomDict:
4978
attributes, children = coalesce_attributes_and_children(attributes_or_children)
50-
set_location = _use_location_state()[1]
51-
return _Link(
52-
{
53-
**attributes,
54-
"to": to,
55-
"onClick": lambda event: set_location(Location(**event)),
56-
},
57-
*children,
79+
set_location = _use_location_state().set_location
80+
attrs = {
81+
**attributes,
82+
"to": to,
83+
"onClick": lambda event: set_location(Location(**event)),
84+
}
85+
return _Link(attrs, *children)
86+
87+
88+
def use_location() -> Location:
89+
"""Get the current route location"""
90+
return _use_location_state().location
91+
92+
93+
def use_params() -> dict[str, Any]:
94+
"""Get parameters from the currently matching route pattern"""
95+
return _use_location_state().params
96+
97+
98+
def use_query(
99+
keep_blank_values: bool = False,
100+
strict_parsing: bool = False,
101+
errors: str = "replace",
102+
max_num_fields: int | None = None,
103+
separator: str = "&",
104+
) -> dict[str, list[str]]:
105+
"""See :func:`urllib.parse.parse_qs` for parameter info."""
106+
return parse_qs(
107+
use_location().search[1:],
108+
keep_blank_values=keep_blank_values,
109+
strict_parsing=strict_parsing,
110+
errors=errors,
111+
max_num_fields=max_num_fields,
112+
separator=separator,
58113
)
59114

60115

61-
def _compile_routes(routes: Sequence[Route]) -> Iterator[tuple[re.Pattern, Route]]:
116+
def _iter_compile_routes(routes: Sequence[Route]) -> Iterator[_CompiledRoute]:
117+
for path, element in _iter_routes(routes):
118+
pattern, _, converters = compile_path(path)
119+
yield _CompiledRoute(
120+
pattern, {k: v.convert for k, v in converters.items()}, element
121+
)
122+
123+
124+
def _iter_routes(routes: Sequence[Route]) -> Iterator[tuple[str, Any]]:
62125
for r in routes:
63-
if isinstance(r.path, re.Pattern):
64-
yield r.path, r
65-
continue
66-
if not r.path.startswith("/"):
67-
raise ValueError("Path pattern must begin with '/'")
68-
pattern = re.compile(fnmatch_translate(r.path))
69-
yield pattern, r
126+
for path, element in _iter_routes(r.routes):
127+
yield r.path + path, element
128+
yield r.path, r.element
129+
130+
131+
@dataclass
132+
class _CompiledRoute:
133+
pattern: re.Pattern[str]
134+
converters: dict[str, Callable[[Any], Any]]
135+
element: Any
70136

71137

72138
def _use_location_state() -> _LocationState:
@@ -75,9 +141,14 @@ def _use_location_state() -> _LocationState:
75141
return location_state
76142

77143

78-
_LocationSetter = Callable[[str], None]
79-
_LocationState = tuple[Location, _LocationSetter]
80-
_LocationStateContext: type[Context[_LocationState | None]] = create_context(None)
144+
@dataclass
145+
class _LocationState:
146+
location: Location
147+
set_location: Callable[[Location], None]
148+
params: dict[str, Any]
149+
150+
151+
_LocationStateContext: Context[_LocationState | None] = create_context(None)
81152

82153
_Link = export(
83154
module_from_file(

0 commit comments

Comments
 (0)