Skip to content

Commit e96a512

Browse files
committed
Change star pattern to {NAME:any}
1 parent 518d226 commit e96a512

12 files changed

+90
-69
lines changed

.gitignore

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ docs/site
44

55
# --- JAVASCRIPT BUNDLES ---
66

7-
src/reactpy_router/bundle.js
7+
src/reactpy_router/static/bundle.js
88

99
# --- PYTHON IGNORE FILES ----
1010

docs/examples/python/basic-routing-more-routes.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ def root():
77
return browser_router(
88
route("/", html.h1("Home Page 🏠")),
99
route("/messages", html.h1("Messages 💬")),
10-
route("*", html.h1("Missing Link 🔗‍💥")),
10+
route("{404:any}", html.h1("Missing Link 🔗‍💥")),
1111
)
1212

1313

docs/examples/python/basic-routing.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
def root():
77
return browser_router(
88
route("/", html.h1("Home Page 🏠")),
9-
route("*", html.h1("Missing Link 🔗‍💥")),
9+
route("{404:any}", html.h1("Missing Link 🔗‍💥")),
1010
)
1111

1212

docs/examples/python/nested-routes.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ def root():
2626
route("/with/Alice", messages_with("Alice")),
2727
route("/with/Alice-Bob", messages_with("Alice", "Bob")),
2828
),
29-
route("*", html.h1("Missing Link 🔗‍💥")),
29+
route("{404:any}", html.h1("Missing Link 🔗‍💥")),
3030
)
3131

3232

docs/examples/python/route-links.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ def root():
77
return browser_router(
88
route("/", home()),
99
route("/messages", html.h1("Messages 💬")),
10-
route("*", html.h1("Missing Link 🔗‍💥")),
10+
route("{404:any}", html.h1("Missing Link 🔗‍💥")),
1111
)
1212

1313

docs/examples/python/route-parameters.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ def root():
2525
all_messages(),
2626
route("/with/{names}", messages_with()), # note the path param
2727
),
28-
route("*", html.h1("Missing Link 🔗‍💥")),
28+
route("{404:any}", html.h1("Missing Link 🔗‍💥")),
2929
)
3030

3131

docs/src/learn/routers-routes-and-links.md

+5-3
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ Here's a basic example showing how to use `#!python browser_router` with two rou
1919
{% include "../../examples/python/basic-routing.py" %}
2020
```
2121

22-
Here we'll note some special syntax in the route path for the second route. The `#!python "*"` is a wildcard that will match any path. This is useful for creating a "404" page that will be shown when no other route matches.
22+
Here we'll note some special syntax in the route path for the second route. The `#!python "any"` type is a wildcard that will match any path. This is useful for creating a default page or error page such as "404 NOT FOUND".
2323

2424
### Browser Router
2525

@@ -34,11 +34,13 @@ In this case, `#!python param` is the name of the route parameter and the option
3434

3535
| Type | Pattern |
3636
| --- | --- |
37-
| `#!python str` (default) | `#!python [^/]+` |
3837
| `#!python int` | `#!python \d+` |
39-
| `#!python float` | `#!python \d+(\.\d+)?` |
38+
| `#!python str` (default) | `#!python [^/]+` |
4039
| `#!python uuid` | `#!python [0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}` |
40+
| `#!python slug` | `#!python [-a-zA-Z0-9_]+` |
4141
| `#!python path` | `#!python .+` |
42+
| `#!python float` | `#!python \d+(\.\d+)?` |
43+
| `#!python any` | `#!python .*` |
4244

4345
So in practice these each might look like:
4446

src/reactpy_router/converters.py

+4
Original file line numberDiff line numberDiff line change
@@ -29,5 +29,9 @@
2929
"regex": r"\d+(\.\d+)?",
3030
"func": float,
3131
},
32+
"any": {
33+
"regex": r".*",
34+
"func": str,
35+
},
3236
}
3337
"""The conversion types supported by the default Resolver. You can add more types if needed."""

src/reactpy_router/core.py

+1-4
Original file line numberDiff line numberDiff line change
@@ -65,10 +65,7 @@ def router_component(
6565
if match:
6666
route_elements = [
6767
_route_state_context(
68-
html.div(
69-
element, # type: ignore
70-
key=f"{location.pathname}{select}{params}{element}{id(element)}",
71-
),
68+
element,
7269
value=_RouteState(set_location, params),
7370
)
7471
for element, params in match

src/reactpy_router/resolvers.py

+35-19
Original file line numberDiff line numberDiff line change
@@ -16,47 +16,63 @@ def __init__(
1616
self,
1717
route: Route,
1818
param_pattern=r"{(?P<name>\w+)(?P<type>:\w+)?}",
19-
match_any_identifier=r"\*$",
2019
converters: dict[str, ConversionInfo] | None = None,
2120
) -> None:
2221
self.element = route.element
23-
self.pattern, self.converter_mapping = self.parse_path(route.path)
2422
self.registered_converters = converters or CONVERTERS
25-
self.key = self.pattern.pattern
23+
self.converter_mapping: ConverterMapping = {}
24+
# self.match_any_indentifier = match_any_identifier
2625
self.param_regex = re.compile(param_pattern)
27-
self.match_any = match_any_identifier
26+
self.pattern = self.parse_path(route.path)
27+
self.key = self.pattern.pattern # Unique identifier for ReactPy rendering
2828

29-
def parse_path(self, path: str) -> tuple[re.Pattern[str], ConverterMapping]:
29+
def parse_path(self, path: str) -> re.Pattern[str]:
3030
# Convert path to regex pattern, then interpret using registered converters
3131
pattern = "^"
3232
last_match_end = 0
33-
converter_mapping: ConverterMapping = {}
33+
34+
# Iterate through matches of the parameter pattern
3435
for match in self.param_regex.finditer(path):
35-
param_name = match.group("name")
36+
# Extract parameter name and type
37+
name = match.group("name")
38+
if name[0].isnumeric():
39+
name = f"_numeric_{name}"
3640
param_type = (match.group("type") or "str").strip(":")
41+
42+
# Check if a converter exists for the type
3743
try:
38-
param_conv = self.registered_converters[param_type]
44+
conversion_info = self.registered_converters[param_type]
3945
except KeyError as e:
4046
raise ValueError(
4147
f"Unknown conversion type {param_type!r} in {path!r}"
4248
) from e
49+
50+
# Add the string before the match to the pattern
4351
pattern += re.escape(path[last_match_end : match.start()])
44-
pattern += f"(?P<{param_name}>{param_conv['regex']})"
45-
converter_mapping[param_name] = param_conv["func"]
52+
53+
# Add the match to the pattern
54+
pattern += f"(?P<{name}>{conversion_info['regex']})"
55+
56+
# Keep a local mapping of parameter names to conversion functions.
57+
self.converter_mapping[name] = conversion_info["func"]
58+
59+
# Update the last match end
4660
last_match_end = match.end()
47-
pattern += f"{re.escape(path[last_match_end:])}$"
4861

49-
# Replace "match anything" pattern with regex, if it's at the end of the path
50-
if pattern.endswith(self.match_any):
51-
pattern = f"{pattern[:-3]}.*$"
62+
# Add the string after the last match
63+
pattern += f"{re.escape(path[last_match_end:])}$"
5264

53-
return re.compile(pattern), converter_mapping
65+
return re.compile(pattern)
5466

5567
def resolve(self, path: str) -> tuple[Any, dict[str, Any]] | None:
5668
match = self.pattern.match(path)
5769
if match:
58-
return (
59-
self.element,
60-
{k: self.converter_mapping[k](v) for k, v in match.groupdict().items()},
61-
)
70+
# Convert the matched groups to the correct types
71+
params = {
72+
parameter_name.strip("_numeric_"): self.converter_mapping[
73+
parameter_name
74+
](value)
75+
for parameter_name, value in match.groupdict().items()
76+
}
77+
return (self.element, params)
6278
return None

tests/test_core.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ def sample():
8484
route("/a", link("A", to="/b", id="a")),
8585
route("/b", link("B", to="/c", id="b")),
8686
route("/c", link("C", to="/default", id="c")),
87-
route("*", html.h1({"id": "default"}, "Default")),
87+
route("{default:any}", html.h1({"id": "default"}, "Default")),
8888
)
8989

9090
await display.show(sample)
@@ -163,7 +163,7 @@ def sample():
163163
route("/a", link("A", to="/b", id="a")),
164164
route("/b", link("B", to="/c", id="b")),
165165
route("/c", link("C", to="/default", id="c")),
166-
route("*", html.h1({"id": "default"}, "Default")),
166+
route("{default:any}", html.h1({"id": "default"}, "Default")),
167167
)
168168

169169
await display.show(sample)
@@ -197,7 +197,7 @@ def sample():
197197
route("/a/b/c", link("C", to="../d", id="c")),
198198
route("/a/d", link("D", to="e", id="d")),
199199
route("/a/e", link("E", to="../default", id="e")),
200-
route("*", html.h1({"id": "default"}, "Default")),
200+
route("{default:any}", html.h1({"id": "default"}, "Default")),
201201
)
202202

203203
await display.show(sample)

tests/test_resolver.py

+36-34
Original file line numberDiff line numberDiff line change
@@ -5,35 +5,45 @@
55
from reactpy_router import Resolver, route
66

77

8+
def test_resolve_any():
9+
resolver = Resolver(route("{404:any}", "Hello World"))
10+
assert resolver.parse_path("{404:any}") == re.compile("^(?P<_numeric_404>.*)$")
11+
assert resolver.converter_mapping == {"_numeric_404": str}
12+
assert resolver.resolve("/hello/world") == ("Hello World", {"404": "/hello/world"})
13+
14+
815
def test_parse_path():
916
resolver = Resolver(route("/", None))
10-
assert resolver.parse_path("/a/b/c") == (re.compile("^/a/b/c$"), {})
11-
assert resolver.parse_path("/a/{b}/c") == (
12-
re.compile(r"^/a/(?P<b>[^/]+)/c$"),
13-
{"b": str},
14-
)
15-
assert resolver.parse_path("/a/{b:int}/c") == (
16-
re.compile(r"^/a/(?P<b>\d+)/c$"),
17-
{"b": int},
18-
)
19-
assert resolver.parse_path("/a/{b:int}/{c:float}/c") == (
20-
re.compile(r"^/a/(?P<b>\d+)/(?P<c>\d+(\.\d+)?)/c$"),
21-
{"b": int, "c": float},
17+
assert resolver.parse_path("/a/b/c") == re.compile("^/a/b/c$")
18+
assert resolver.converter_mapping == {}
19+
20+
assert resolver.parse_path("/a/{b}/c") == re.compile(r"^/a/(?P<b>[^/]+)/c$")
21+
assert resolver.converter_mapping == {"b": str}
22+
23+
assert resolver.parse_path("/a/{b:int}/c") == re.compile(r"^/a/(?P<b>\d+)/c$")
24+
assert resolver.converter_mapping == {"b": int}
25+
26+
assert resolver.parse_path("/a/{b:int}/{c:float}/c") == re.compile(
27+
r"^/a/(?P<b>\d+)/(?P<c>\d+(\.\d+)?)/c$"
2228
)
23-
assert resolver.parse_path("/a/{b:int}/{c:float}/{d:uuid}/c") == (
24-
re.compile(
25-
r"^/a/(?P<b>\d+)/(?P<c>\d+(\.\d+)?)/(?P<d>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-["
26-
r"0-9a-f]{4}-[0-9a-f]{12})/c$"
27-
),
28-
{"b": int, "c": float, "d": uuid.UUID},
29+
assert resolver.converter_mapping == {"b": int, "c": float}
30+
31+
assert resolver.parse_path("/a/{b:int}/{c:float}/{d:uuid}/c") == re.compile(
32+
r"^/a/(?P<b>\d+)/(?P<c>\d+(\.\d+)?)/(?P<d>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/c$"
2933
)
30-
assert resolver.parse_path("/a/{b:int}/{c:float}/{d:uuid}/{e:path}/c") == (
31-
re.compile(
32-
r"^/a/(?P<b>\d+)/(?P<c>\d+(\.\d+)?)/(?P<d>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-["
33-
r"0-9a-f]{4}-[0-9a-f]{12})/(?P<e>.+)/c$"
34-
),
35-
{"b": int, "c": float, "d": uuid.UUID, "e": str},
34+
assert resolver.converter_mapping == {"b": int, "c": float, "d": uuid.UUID}
35+
36+
assert resolver.parse_path(
37+
"/a/{b:int}/{c:float}/{d:uuid}/{e:path}/c"
38+
) == re.compile(
39+
r"^/a/(?P<b>\d+)/(?P<c>\d+(\.\d+)?)/(?P<d>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/(?P<e>.+)/c$"
3640
)
41+
assert resolver.converter_mapping == {
42+
"b": int,
43+
"c": float,
44+
"d": uuid.UUID,
45+
"e": str,
46+
}
3747

3848

3949
def test_parse_path_unkown_conversion():
@@ -45,13 +55,5 @@ def test_parse_path_unkown_conversion():
4555
def test_parse_path_re_escape():
4656
"""Check that we escape regex characters in the path"""
4757
resolver = Resolver(route("/", None))
48-
assert resolver.parse_path("/a/{b:int}/c.d") == (
49-
# ^ regex character
50-
re.compile(r"^/a/(?P<b>\d+)/c\.d$"),
51-
{"b": int},
52-
)
53-
54-
55-
def test_match_star_path():
56-
resolver = Resolver(route("/", None))
57-
assert resolver.parse_path("*") == (re.compile("^.*$"), {})
58+
assert resolver.parse_path("/a/{b:int}/c.d") == re.compile(r"^/a/(?P<b>\d+)/c\.d$")
59+
assert resolver.converter_mapping == {"b": int}

0 commit comments

Comments
 (0)