sanic-org / sanic-routing Goto Github PK
View Code? Open in Web Editor NEWInternal handler routing for Sanic beginning with v21.3.
Home Page: https://sanicframework.org/
License: MIT License
Internal handler routing for Sanic beginning with v21.3.
Home Page: https://sanicframework.org/
License: MIT License
Currently, a route is defined as defined path to reach the endpoint. Multiple methods whether in a single definition, or multiple will result in a single route.
We should add a RouteGroup
that has a near identical API to the Route
.
In practical example, it means that this would yield a single route:
router.add("/path", methods=("foo", "bar"))
But this would yield two:
router.add("/path", methods=("foo",))
router.add("/path", methods=("bar"))
However, they should be joined into a single RouteGroup
that the router does not distinguish between when generating the source or resolving a path.
After updating to Sanic 21.3 and bringing in sanic-routing, I am running into issues starting the server.
I can't find where the try/else
part is being built to propose a solution, but here is a quick example that produces the error.
EXAMPLE APP
from multiprocessing import Process
import time
import requests
from sanic import Blueprint, HTTPResponse, Request, Sanic
async def get(request: Request, *args, **kwargs) -> HTTPResponse:
print(request.route.path)
print(args)
print(kwargs)
return HTTPResponse()
bp0 = Blueprint(name='base')
bp1 = Blueprint(name='first', url_prefix='/<first>')
bp2 = Blueprint(name='second', url_prefix='/<first>/<second>')
bp3 = Blueprint(name='third', url_prefix='/<first>/<second>/<third>')
bp0.add_route(get, '/')
bp1.add_route(get, '/')
bp2.add_route(get, '/')
bp3.add_route(get, '/')
app = Sanic(name='test')
app.blueprint(bp0)
app.blueprint(bp1)
app.blueprint(bp2)
app.blueprint(bp3)
p = Process(target=app.run, kwargs={'port': 9000})
try:
p.start()
time.sleep(2)
print(requests.get('http://localhost:9000/'))
print(requests.get('http://localhost:9000/1'))
print(requests.get('http://localhost:9000/1/2'))
print(requests.get('http://localhost:9000/1/2/3'))
finally:
p.terminate()
EXCEPTION
[2021-03-30 23:04:01 +0000] [6332] [ERROR] Experienced exception while trying to serve
Traceback (most recent call last):
File "/home/my-project/env/lib/python3.7/site-packages/sanic/app.py", line 918, in run
serve_single(server_settings)
File "/home/my-project/env/lib/python3.7/site-packages/sanic/server.py", line 725, in serve_single
serve(**server_settings)
File "/home/my-project/env/lib/python3.7/site-packages/sanic/server.py", line 554, in serve
trigger_events(before_start, loop)
File "/home/my-project/env/lib/python3.7/site-packages/sanic/server.py", line 354, in trigger_events
loop.run_until_complete(result)
File "uvloop/loop.pyx", line 1494, in uvloop.loop.Loop.run_until_complete
File "/home/my-project/env/lib/python3.7/site-packages/sanic/app.py", line 1280, in finalize
app.router.finalize()
File "/home/my-project/env/lib/python3.7/site-packages/sanic/router.py", line 179, in finalize
super().finalize(*args, **kwargs)
File "/home/my-project/env/lib/python3.7/site-packages/sanic_routing/router.py", line 185, in finalize
self._render(do_compile)
File "/home/my-project/env/lib/python3.7/site-packages/sanic_routing/router.py", line 263, in _render
"exec",
File "<string>", line 34
basket['__params__']['first'] = str(basket[0])
^
IndentationError: expected an indented block
ERROR
When I make a local modification to router.py to print(self.find_route_src)
, it reveals this:
def find_route(path, router, basket, extra):
parts = tuple(path[1:].split(router.delimiter))
try:
route = router.static_routes[parts]
basket['__raw_path__'] = path
return route, basket
except KeyError:
pass
num = len(parts)
if num > 0:
basket[0] = parts[0]
if num > 1:
basket[1] = parts[1]
if num == 3:
basket[2] = parts[2]
try:
basket['__params__']['first'] = str(basket[0])
basket['__params__']['second'] = str(basket[1])
basket['__params__']['third'] = str(basket[2])
except ValueError:
...
else:
basket['__raw_path__'] = '<first>/<second>/<third>'
return router.dynamic_routes[('<first>', '<second>', '<third>')], basket
try:
basket['__params__']['first'] = str(basket[0])
basket['__params__']['second'] = str(basket[1])
except ValueError:
...
else:
basket['__raw_path__'] = '<first>/<second>'
return router.dynamic_routes[('<first>', '<second>')], basket
try:
basket['__params__']['first'] = str(basket[0])
except ValueError:
...
else:
basket['__raw_path__'] = '<first>'
return router.dynamic_routes[('<first>',)], basket
The Route.methods
property is generally nested as follows:
{
"/some/path": set("GET", "POST")
}
However, this level of nesting is not only a breaking change, it is not necessary. It is the result of an earlier iteration of the router that was since abandoned. It seems this artifact has remained, and is no longer necessary.
Can be solved:
def _finalize_methods(self):
self.methods = set()
for handlers in self.handlers.values():
self.methods.update(set(key.upper() for key in handlers.keys()))
When adding routes where there both is and is not a set of requirements ...
app.route("/<foo>")(lambda _, **__: text("..."))
app.route("/<foo>", host="foo.com")(lambda _: text("..."))
The requirements are evaluated first, and therefore a request without fails:
$ curl localhost:9999/ssss/
⚠️ 404 — Not Found
==================
Requested URL /ssss not found
The generated router:
def find_route(path, router, basket, extra):
parts = tuple(path[1:].split(router.delimiter))
num = len(parts)
if num > 0:
basket[0] = parts[0]
if extra == {'host': 'foo.com'}:
basket['__handler_idx__'] = 1
else:
raise NotFound
try:
basket['__params__']['foo'] = str(basket[0])
except ValueError:
...
else:
basket['__raw_path__'] = '<foo>'
return router.dynamic_routes[('<foo>',)], basket
raise NotFound
The fix for this is to add a check when injecting the requirements section to see if there is a fallback. If so, then the solution is to not raise NotFound
in the else
block after the extra
evaluation. That exception should only be raised if extra
is required, and all have been exhausted.
def find_route(path, router, basket, extra):
parts = tuple(path[1:].split(router.delimiter))
num = len(parts)
if num > 0:
basket[0] = parts[0]
if extra == {'host': 'foo.com'}:
basket['__handler_idx__'] = 1
try:
basket['__params__']['foo'] = str(basket[0])
except ValueError:
...
else:
basket['__raw_path__'] = '<foo>'
return router.dynamic_routes[('<foo>',)], basket
raise NotFound
Raise warning with a move towards enforcement of unique route names.
Originally posted by @xmcp in sanic-org/sanic-ext#60
There seems to be an unexpected behavior when mixing paths with trailing slashes and a route with a path/regex parameter. When creating an app with strict_slashes=False
, one would expect that static routes will match paths with and without the trailing slash as a top priority. But paths with a trailing slash are actually a "last resource" when looking for a matching route.
from sanic import Sanic, response
app = Sanic("MyApp", strict_slashes=False)
async def hello_world(request, *args, **kwargs):
return response.text('Hello World!')
async def catch_all(request, path):
return response.text(f'Caught one: {path}')
app.add_route(hello_world, '/hello/') # same behavior using '/hello'
app.add_route(catch_all, '/<path:path>')
if __name__ == '__main__':
app.run(port=8080)
Because strict_slashes=False
, the hello_world
route is supposed to match both /hello
and /hello/
. But the latter is being matched with the catch_all
route.
Looks like this is happening due to how the resolve
method works rigt now: https://github.com/sanic-org/sanic-routing/blob/main/sanic_routing/router.py#L92; it's looking for the route with the trailing slash only when no other route would match.
In this example, having a subnode with regex needs to make sure the node level return does not include the num>x
.
"/<foo>"
r"/<foo>/<invoice:(?P<invoice>[0-9]+)\.pdf>"
def find_route(path, method, router, basket, extra):
parts = tuple(path[1:].split(router.delimiter))
num = len(parts)
if parts[0]:
if parts[0] == "v2":
if num > 1:
basket[1] = parts[1]
if num > 3:
...
elif num == 3:
basket[2] = parts[2]
...
if num > 2:
raise NotFound
try:
basket['__params__']['foo'] = str(basket[1])
except (ValueError, KeyError):
pass
else:
if method in frozenset({'OPTIONS'}):
route_idx = 0
elif method in frozenset({'GET'}):
route_idx = 1
else:
raise NoMethod
return router.dynamic_routes[('v2', '<foo>')][route_idx], basket
match = router.matchers[0].match(path)
if match:
basket['__params__'] = match.groupdict()
if method in frozenset({'OPTIONS'}):
route_idx = 0
elif method in frozenset({'GET'}):
route_idx = 1
else:
raise NoMethod
return router.regex_routes[('v2', '<foo>', '<invoice:(?P<invoice>[0-9]+)\\.pdf>')][route_idx], basket
raise NotFound
matchers = [
re.compile(r'^/v2/<foo>/(?P<invoice>[0-9]+)\.pdf$'),
]
A route definition might legitimately have two :
in it:
/path/to/<file_uuid:[A-Fa-f0-9]{8}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{12}(?:\.[A-z]{1,4})?>
Traceback (most recent call last):
File "/app/run_sanic.py", line 63, in <module>
run_sanic_forever()
File "/app/run_sanic.py", line 28, in run_sanic_forever
server = loop.run_until_complete(serv_task)
File "uvloop/loop.pyx", line 1494, in uvloop.loop.Loop.run_until_complete
File "/usr/local/lib/python3.9/site-packages/sanic/app.py", line 1017, in create_server
await self.trigger_events(
File "/usr/local/lib/python3.9/site-packages/sanic/app.py", line 1041, in trigger_events
await result
File "/usr/local/lib/python3.9/site-packages/sanic/app.py", line 1280, in finalize
app.router.finalize()
File "/usr/local/lib/python3.9/site-packages/sanic/router.py", line 179, in finalize
super().finalize(*args, **kwargs)
File "/usr/local/lib/python3.9/site-packages/sanic_routing/router.py", line 216, in finalize
self._generate_tree()
File "/usr/local/lib/python3.9/site-packages/sanic_routing/router.py", line 238, in _generate_tree
self.tree.finalize()
File "/usr/local/lib/python3.9/site-packages/sanic_routing/tree.py", line 365, in finalize
self.root.finalize_children()
File "/usr/local/lib/python3.9/site-packages/sanic_routing/tree.py", line 47, in finalize_children
child.finalize_children()
File "/usr/local/lib/python3.9/site-packages/sanic_routing/tree.py", line 47, in finalize_children
child.finalize_children()
File "/usr/local/lib/python3.9/site-packages/sanic_routing/tree.py", line 47, in finalize_children
child.finalize_children()
File "/usr/local/lib/python3.9/site-packages/sanic_routing/tree.py", line 39, in finalize_children
k: v for k, v in sorted(self._children.items(), key=self._sorting)
File "/usr/local/lib/python3.9/site-packages/sanic_routing/tree.py", line 320, in _sorting
key, param_type = key.split(":")
ValueError: too many values to unpack (expected 2)
A very common endpoint is to do some sort of a match on an extension:
/foo/bar.jpg
Usually, you want to get bar
as a variable value. I propose a new route segment type: ext
that would allow make it super simple to setup these types of routes.
There are three general scenarios:
I think something like the following should work:
/foo/<file:ext>
/foo/<file:ext:jpg>
/foo/<file:ext:jpg|png|gif|svg>
This would result in two passed variables: whatever is defined, and the extension as ext.
For example:
@app.get("/invoice/<invoice_id:ext:pdf|txt>")
async def invoice_handler(request, invoice_id, ext):
...
Hi,
I'm adding sanic-routing
to conda-forge in order to get sanic
to build again:
conda-forge/sanic-feedstock#34
However neither the repository nor the package contain a LICENSE
file, which is kind of a soft-requirement to build packages on conda-forge.
It would be great to add it to the repository and to the package in order to make the package on conda-forge more compliant. 👍
Hello,
Currently, requirements are based entirely on matching the hash of the extra.
Is there any way to actually have this run an equality check, dict to dict, to do more advanced checks ?
It would most certainly impact performances, i'm just wondering if it's doable with the current code, somehow ?
thanks
It would be helpful to add a catch for SyntaxError
in the compiler to output the source. Something like this, except abstracted so that line numbers and source can be displayed in other uses as well:
try:
compiled_src = compile(
self.find_route_src,
"",
"exec",
)
except SyntaxError as e:
lines = self.find_route_src.split("\n")
pad = len(str(len(lines)))
logger.error(
"\n".join(
f"{str(idx+1).rjust(pad)}: {line}"
for idx, line in enumerate(lines)
)
)
raise e
Sanic can't find the route if there's a colon in the uri.
Code snippet
from sanic import Sanic, Request, HTTPResponse
app = Sanic('zzz')
@app.get(f'/abc/x:y')
async def main(_: Request):
return HTTPResponse()
if __name__ == '__main__':
app.run()
Expected behavior
GET http://127.0.0.1:8000/abc/x:y 200 0
Actual behavior
GET http://127.0.0.1:8000/abc/x:y 404 733
Environment
[2022-09-26 20:50:40 +0600] [13060] [INFO] Sanic v22.6.2
[2022-09-26 20:50:40 +0600] [13060] [INFO] Goin' Fast @ http://127.0.0.1:8000
[2022-09-26 20:50:40 +0600] [13060] [INFO] mode: production, single worker
[2022-09-26 20:50:40 +0600] [13060] [INFO] server: sanic, HTTP/1.1
[2022-09-26 20:50:40 +0600] [13060] [INFO] python: 3.10.5
[2022-09-26 20:50:40 +0600] [13060] [INFO] platform: Windows-10-10.0.19044-SP0
[2022-09-26 20:50:40 +0600] [13060] [INFO] packages: sanic-routing==22.3.0
[2022-09-26 20:50:40 +0600] [13060] [INFO] Starting worker [13060]
Additional context
fastapi/fastapi#4892
encode/starlette#1657
I find it a bit surprising that a websocket request to Sanic ends up in an app.get
handler (at least with the http1 server - didn't try asgi), as to my knowledge Sanic does not provide means to handle such requests in any meaningful way (you could respond 101 perhaps but then what?), and if you simply ignore the header, the connection will fail but your handler runs for nothing.
Also, it is not possible to serve GET and websocket (GET+Upgrade) at the same path like so:
@app.get("/")
def index(req):
...
@app.websocket("/")
def websocket(req, ws):
...
Would it be possible to change the routing such that upgrade: websocket
requests are considered a different method than GET, or alternatively make the scheme also a part of routing (ws, wss, http and https each being different routing-wise)?
On the front side the same path as the document is convenient:
// Same path (easy peasy)
const ws = new WebSocket(location.href.replace(/^http/, 'ws'))
// Different path
const ws_path = '/ws' // sometimes hard to know in front code
const ws = new WebSocket(new URL(ws_path, location.href.replace(/^http/, 'ws')))
Routes that both set unquote=True
and have int
path parameters will cause a bug in the Sanic router, resulting in 500 errors when visited.
Run this code:
from sanic import Sanic
from sanic.response import text
app = Sanic('app')
@app.route("/test/<x:int>", unquote=True)
async def handler(request, x: int):
return text(f'hi {x}')
if __name__=='__main__':
app.run(port=4321, debug=True)
Then visit /test/1
.
Sanic raises a 500 error:
[2023-07-17 03:41:24 +0800] [52892] [ERROR] Exception occurred while handling uri: 'http://127.0.0.1:4321/test/1'
Traceback (most recent call last):
File "C:\Users\xmcp\AppData\Roaming\Python\Python310\site-packages\sanic\app.py", line 904, in handle_request
route, handler, kwargs = self.router.get(
File "C:\Users\xmcp\AppData\Roaming\Python\Python310\site-packages\sanic\router.py", line 64, in get
return self._get(path, method, host)
File "C:\Users\xmcp\AppData\Roaming\Python\Python310\site-packages\sanic\router.py", line 36, in _get
return self.resolve(
File "C:\Users\xmcp\AppData\Roaming\Python\Python310\site-packages\sanic_routing\router.py", line 82, in resolve
route, param_basket = self.find_route(
File "", line 22, in find_route
File "C:\Program Files\Python310\lib\urllib\parse.py", line 656, in unquote
if '%' not in string:
TypeError: argument of type 'int' is not iterable
[2023-07-17 03:41:24 +0800] - (sanic.access)[INFO][127.0.0.1:50831]: GET http://127.0.0.1:4321/test/1 500 2402
It should return hi 1
without a 500 error.
As a script (app.run
or Sanic.serve
)
Windows
Sanic 22.12.0; Routing 22.8.0
self.find_route_src
in the sanic router says something like that:
if num == 4: # CHECK 1
try:
basket['__matches__'][3] = int(parts[3])
except ValueError:
pass
else:
basket['__matches__'][3] = unquote(basket['__matches__'][3])
You can see that Sanic converts the request arg into an integer and then tries to unquote it.
The string
parameter type is somewhat confusing since we also have int
. Often people new to Sanic will think it should be str
.
I propose that it becomes deprecated, we add it as an alias to str
for some period of time, and then remove it prior to the 21.12 Sanic LTS.
@sanic-org/framework thoughts?
When a route is parametrized (e.g. /<identifier>
or /<identifier:int>
) and an HTTP method is used that is not defined, the response comes back as a 404 Not Found
instead of 405 Method Not Allowed
.
Simple routes w/o parameterization properly return a 405.
from sanic import Sanic, Blueprint
from sanic.response import HTTPResponse
from sanic.views import HTTPMethodView
app = Sanic(name='test')
@app.get(uri='/with-func', name='get-func')
def handler_str(request):
print(None)
return HTTPResponse()
@app.get(uri='/with-func/str/<identifier>', name='str-id-get-func')
def handler_str(request, identifier: str):
print(identifier, type(identifier))
return HTTPResponse()
@app.get(uri='/with-func/int/<identifier:int>', name='int-id-get-func')
def handler_int(request, identifier: int):
print(identifier, type(identifier))
return HTTPResponse()
class StrView(HTTPMethodView, attach=app, uri='/with-view', name='get-view'):
@staticmethod
def get(request):
print(None)
return HTTPResponse()
class StrView(HTTPMethodView, attach=app, uri='/with-view/str/<identifier>', name='str-id-get-view'):
@staticmethod
def get(request, identifier):
print(identifier, type(identifier))
return HTTPResponse()
class IntView(HTTPMethodView, attach=app, uri='/with-view/int/<identifier:int>', name='int-id-get-view'):
@staticmethod
def get(request, identifier: int):
print(identifier, type(identifier))
return HTTPResponse()
app.run(host='localhost', port=8080, single_process=True)
from httpx import get, post
paths = [
'/with-func',
'/with-func/str/test',
'/with-func/int/1',
'/with-view',
'/with-view/str/test',
'/with-view/int/1'
]
for path in paths:
print(path)
for method in [get, post]:
response = method(f'http://localhost:8080{path}')
print(f'\t{method.__name__:8}{response}')
[2023-11-02 21:35:08 +0000] [32026] [INFO] Sanic v23.6.0
[2023-11-02 21:35:08 +0000] [32026] [INFO] Goin' Fast @ http://localhost:8080
[2023-11-02 21:35:08 +0000] [32026] [INFO] mode: production, single worker
[2023-11-02 21:35:08 +0000] [32026] [INFO] server: sanic, HTTP/1.1
[2023-11-02 21:35:08 +0000] [32026] [INFO] python: 3.11.6
[2023-11-02 21:35:08 +0000] [32026] [INFO] platform: Linux-3.10.0-1160.99.1.el7.x86_64-x86_64-with-glibc2.17
[2023-11-02 21:35:08 +0000] [32026] [INFO] packages: sanic-routing==23.6.0, sanic-testing==23.6.0, sanic-ext==23.6.0
[2023-11-02 21:35:08 +0000] [32026] [INFO] Sanic Extensions:
[2023-11-02 21:35:08 +0000] [32026] [INFO] > injection [0 dependencies; 0 constants]
[2023-11-02 21:35:08 +0000] [32026] [INFO] > openapi [http://localhost:8080/docs]
[2023-11-02 21:35:08 +0000] [32026] [INFO] > http
[2023-11-02 21:35:08 +0000] [32026] [INFO] Starting worker [32026]
None
test <class 'str'>
1 <class 'int'>
None
test <class 'str'>
1 <class 'int'>
/with-func
get <Response [200 OK]>
post <Response [405 Method Not Allowed]>
/with-func/str/test
get <Response [200 OK]>
post <Response [404 Not Found]>
/with-func/int/1
get <Response [200 OK]>
post <Response [404 Not Found]>
/with-view
get <Response [200 OK]>
post <Response [405 Method Not Allowed]>
/with-view/str/test
get <Response [200 OK]>
post <Response [404 Not Found]>
/with-view/int/1
get <Response [200 OK]>
post <Response [404 Not Found]>
Currently, this produces a syntax error:
app.route("/<foo>/", strict_slashes=True)(lambda _: text("..."))
app.route("/<foo>/", strict_slashes=True, host="foo.com")(
lambda _: text("...")
)
compiled_src = compile(
File "", line 8
elif extra == {'host': 'foo.com'}:
^
SyntaxError: invalid syntax
This is because when deciding the type of conditional, it is looking at the wrong idx
.
# tree.py
def _inject_requirements(self, location, indent):
for idx, reqs in self.route.requirements.items():
conditional = "if" if idx == 0 else "elif"
To solve:
def _inject_requirements(self, location, indent):
for k, (idx, reqs) in enumerate(self.route.requirements.items()):
conditional = "if" if k == 0 else "elif"
Currently it is this:
return (
child.dynamic,
len(child._children),
key,
bool(child.group and child.group.regex),
type_ * -1,
)
It should be:
return (
child.dynamic,
len(child._children),
bool(child.group and child.group.regex),
type_ * -1,
key,
)
... so that the variable name is the last tie breaker
from sanic import Sanic
from sanic.response import text
app = Sanic("MyHelloWorldApp")
@app.get("/<id:int>/<subpath:path>")
async def hello_world(request, id, subpath):
return text(str(type(id)))
Expected response is <class 'int'>
, but actual response is <class 'str'>
.
Same with <id:float>
or <id:uuid>
.
When using <id:ymd>
, it throws an error:
[2023-06-20 20:15:55 +0300] [3355801] [ERROR] Invalid matching pattern ([12]\d{3}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01]))
Traceback (most recent call last):
File "lib/python3.8/site-packages/sanic/worker/serve.py", line 117, in worker_serve
return _serve_http_1(
File "lib/python3.8/site-packages/sanic/server/runners.py", line 224, in _serve_http_1
loop.run_until_complete(app._startup())
File "uvloop/loop.pyx", line 1517, in uvloop.loop.Loop.run_until_complete
File "lib/python3.8/site-packages/sanic/app.py", line 1580, in _startup
self.finalize()
File "lib/python3.8/site-packages/sanic/app.py", line 1551, in finalize
self.router.finalize()
File "lib/python3.8/site-packages/sanic/router.py", line 202, in finalize
super().finalize(*args, **kwargs)
File "lib/python3.8/site-packages/sanic_routing/router.py", line 329, in finalize
route.finalize()
File "lib/python3.8/site-packages/sanic_routing/route.py", line 280, in finalize
self._compile_regex()
File "lib/python3.8/site-packages/sanic_routing/route.py", line 267, in _compile_regex
raise InvalidUsage(f"Invalid matching pattern {pattern}")
sanic_routing.exceptions.InvalidUsage: Invalid matching pattern ([12]\d{3}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01]))
This should be:
@property
def uri(self):
return f"/{self.path}"
app.router.register_pattern(
"ipv4",
ipaddress.ip_address,
IP_ADDRESS_PATTERN,
)
If IP_ADDRESS_PATTERN
is a str
there is a mypy
error that it is looking for a compiled expression. Should add support for accepting both re.compile(...)
and strings, and auto compile them at registration.
If you have two or more @app.route with a similar but different methods will return MethodNotSupported.
Example:
@app.route("/<path:path>", methods=["GET", "OPTIONS"])
async def get_stuff(request, path):
return.text("This was a GET or OPTIONS request")
@app.route("/<path:path>", methods=["POST"])
async def post_stuff(request, path):
return.text("This was a POST request")
Error when issuing a GET request: sanic_routing.exceptions.NoMethod: Method 'GET' not found on <Route: name=__main__.cors_204 path=<path:path>>
We need to type case param_basket["__params__"]
since it is not done inline in the finder like param_basket["__matches__"]
.
Hi y'all,
we have just upgraded from sanic==20.3.0
to sanic==21.9.1
with sanic-routing==0.7.1
.
Our test suite now shows 404s and 405s where we didn't expect them.
We run into this with Python 3.7, 3.8 and 3.9 on Windows and Ubuntu.
Minimal Example
from sanic import Sanic
from sanic.response import text
app = Sanic("MyHelloWorldApp")
@app.get("/conversation/<conversation_id:path>/story")
async def story(request, conversation_id):
return text("story")
@app.put("/conversation/<conversation_id:path>/tracker/events")
async def put_events(request, conversation_id):
return text("put events")
@app.post("/conversation/<conversation_id:path>/tracker/events")
async def post_events(request, conversation_id):
return text("post events")
How to reproduce
If you do a curl http://127.0.0.1:8000/conversation/dasda-dasd/story
, this will result in a 404 (instead of the expected "story") If you comment out put_events
or post_events
it works fine.
I believe this is somewhat related to sanic-org/sanic#2130
Appreciate any help as this is currently blocking us to upgrade 🙌🏻
It seems that browsers and the stdlib unquote
both use uppercase, but curl
and probably other implementations get a 404 when sending lowercase.
I'm working on an app that has a pretty unique routing requirement.
I need something that looks like this:
app = Sanic()
@app.get("/login")
async def login(request):
...
@app.get("/logout")
async def logout(request):
...
@app.get("/metrics")
async def metrics(request):
...
@app.route("/<mypath:path>")
async def fallback(request, mypath: str):
# all other routes go to this handler
...
Implementing it as-is does not work because the "/<mypath:path>" matches all routes and overrides the other route handlers.
One thought would be to have the ability to assign a priority weight on the route, like:
@app.get("/login", priority=1)
async def login(request):
...
@app.route("/<mypath:path>", priority=9)
async def fallback(request, mypath: str):
# this handler is evaluated after those with higher priority (lower number)
...
That might break some backwards compatibility (not sure), another solution would be to have a new match type, that is always evaluated last in the router (and this kind would never return a NotFound from the router).
@app.route("/<mypath:fallback>")
async def fallback(request, mypath: str):
# this handler is evaluated after those with higher priority (lower number)
...
Does that seem useful? Or have I missed an obvious way to achieve this?
Describe the bug
In my project, I have an endpoint
@api.route(
r'/image/iiif/'
r'<image_id>/'
r'<region:full|square|\d+,\d+,\d+,\d+>/'
r'<size:max|\d+,|,\d+|\d+,\d+>/'
r'<rotation:int>/'
r'default.jpg')
It works for Sanic==20.12.1, but after migration to Sanic==21.3.4 it's not a valid router anymore.
parts = ('api', 'image', 'iiif', '<image_id>/<region:full|square|\\d+,\\d+,\\d+,\\d+>', '<size:max|\\d+,|,\\d+|\\d+,\\d+>', '<rotation:int>', ...)
delimiter = '/'
def parts_to_path(parts, delimiter="/"):
path = []
for part in parts:
if part.startswith("<"):
try:
match = REGEX_PARAM_NAME.match(part)
param_type = ""
> if match.group(2):
E AttributeError: 'NoneType' object has no attribute 'group'
../venv/lib/python3.8/site-packages/sanic_routing/utils.py:76: AttributeError
How to reproduce
>>> from sanic_routing.utils import path_to_parts
>>> s = (
... r'/image/iiif/'
... r'<image_id>/'
... r'<region:full|square|\d+,\d+,\d+,\d+>/'
... r'<size:max|\d+,|,\d+|\d+,\d+>/'
... r'<rotation:int>/'
... r'default.jpg')
>> path_to_parts(s)
('image', 'iiif', '<image_id>/<region:full|square|\\d+,\\d+,\\d+,\\d+>', '<size:max|\\d+,|,\\d+|\\d+,\\d+>', '<rotation:int>', 'default.jpg')
Expected behavior
>> path_to_parts(s)
('image', 'iiif', '<image_id>', '<region:full|square|\\d+,\\d+,\\d+,\\d+>', '<size:max|\\d+,|,\\d+|\\d+,\\d+>', '<rotation:int>', 'default.jpg')
Environment
Hi
We have route in code defined as:
@app.get('/relations/<relation_id>/keys')
Code above worked with sanic 21.3.2 and sanic-router 0.4.2, but with 0.4.3 ends up with error:
Experienced exception while trying to serve
Traceback (most recent call last):
File "/service/venv/lib/python3.7/site-packages/sanic/app.py", line 918, in run
serve_single(server_settings)
File "/service/venv/lib/python3.7/site-packages/sanic/server.py", line 725, in serve_single
serve(**server_settings)
File "/service/venv/lib/python3.7/site-packages/sanic/server.py", line 554, in serve
trigger_events(before_start, loop)
File "/service/venv/lib/python3.7/site-packages/sanic/server.py", line 354, in trigger_events
loop.run_until_complete(result)
File "uvloop/loop.pyx", line 1494, in uvloop.loop.Loop.run_until_complete
File "/service/venv/lib/python3.7/site-packages/sanic/app.py", line 1280, in finalize
app.router.finalize()
File "/service/venv/lib/python3.7/site-packages/sanic/router.py", line 179, in finalize
super().finalize(*args, **kwargs)
File "/service/venv/lib/python3.7/site-packages/sanic_routing/router.py", line 185, in finalize
self._render(do_compile)
File "/service/venv/lib/python3.7/site-packages/sanic_routing/router.py", line 262, in _render
"exec",
File "<string>", line 108
elif parts[0] == "relations":
Not sure how to resolve this, does somebody have any ideas?
Some of our routes are not resolved. Return 404 Not Found.
Last tested on sanic-routing v0.6.2 , sanic v21.3.4
Unittest to reproduce:
Line to add to /tests/test_routing.py
def test_failing():
def handler1():
return "handler1"
def handler2():
return "handler2"
router = Router()
router.add("/v1/c", handler1, methods=["GET"])
router.add("/v1/c/<c_id:int>", handler2, methods=["GET"])
router.add("/v1/c/<c_id:int>/e", handler1, methods=["GET"])
router.add("/v1/c/<c_id:int>/e/<e_id:int>", handler2, methods=["GET"])
router.add("/v1/c/<c_id:int>/f", handler1, methods=["GET"])
router.add("/v1/c/<c_id:int>/f/<f_id:int>", handler2, methods=["GET"])
# Comment next line to pass the test
router.add("/v1/c/<c_id:int>/d", handler1, methods=["GET"])
router.add("/v1/c/<c_id:int>/d/<d_id:int>", handler2, methods=["GET"])
router.finalize()
_, handler, params = router.get("/v1/c", "GET")
assert handler() == "handler1"
assert params == {}
_, handler, params = router.get("/v1/c/123", "GET")
assert handler() == "handler2"
assert params == {"c_id": 123}
_, handler, params = router.get("/v1/c/123/e", "GET")
assert handler() == "handler1"
assert params == {"c_id": 123}
_, handler, params = router.get("/v1/c/123/e/456", "GET")
assert handler() == "handler2"
assert params == {"c_id": 123, "e_id": 456}
_, handler, params = router.get("/v1/c/123/f", "GET")
assert handler() == "handler1"
assert params == {"c_id": 123}
_, handler, params = router.get("/v1/c/123/f/890", "GET")
assert handler() == "handler2"
assert params == {"c_id": 123, "f_id": 890}
Seems to have something to do with the auto generated src for compile.
Example:
/path/to/<slug:slug>
Perhaps something like this:
"slug": (str, re.compile(r"^[\w-]+")),
import sanic_routing.utils as sru
sru.path_to_parts('/asd/<int1>,<int2>')
('asd', '<int1>,<int2>')
sru.parts_to_path(sru.path_to_parts('/asd/<int1>,<int2>'))
Traceback (most recent call last):
File "/srv/git/sensors-server/venv/lib/python3.9/site-packages/sanic_routing/utils.py", line 75, in parts_to_path
if match.group(2):
AttributeError: 'NoneType' object has no attribute 'group'
During handling of the above exception, another exception occurred:
Traceback (most recent call last):
File "/srv/git/sensors-server/venv/lib/python3.9/site-packages/sanic_routing/utils.py", line 83, in parts_to_path
if match.group(2):
AttributeError: 'NoneType' object has no attribute 'group'
During handling of the above exception, another exception occurred:
Traceback (most recent call last):
File "/usr/lib/python3.9/code.py", line 90, in runcode
exec(code, self.locals)
File "<input>", line 1, in <module>
File "/srv/git/sensors-server/venv/lib/python3.9/site-packages/sanic_routing/utils.py", line 93, in parts_to_path
raise InvalidUsage(f"Invalid declaration: {part}")
sanic_routing.exceptions.InvalidUsage: Invalid declaration: <int1>,<int2>
Describe the bug
Routes which are overlapping and have different depth are not found.
For example, if you describe your paths as :
/foo/<foo_id>/bars_ids
/foo/<foo_id>/bars_ids/<bar_id>/settings
you will get not found error while requesting /foo/123/bars_ids.
This cause is hidden in optimize()
function, when Line("...", 0, offset=-1, render=False))
are rendered,
offset is not applied to structures which have same indentation but are situated below.
Code snippet
def find_route(path, router, basket, extra):
*omitted_part*
if parts[2] == "bars_ids":
if num > 3:
basket[3] = parts[3]
if num == 5:
if parts[4] == "settings":
.....
**this block should have same identation as if num>3**
try:
basket['__params__']['foo_id'] = str(basket[1])
Expected behavior
Both routes should be accessed as expected
Environment :
PR: #13
The type annotation on register_pattern
gives Pattern
as the acceptable type.
However, an exception is thrown if you do provide a pattern with re.compile
, complaining that it must be a str
.
So:
app.router.register_pattern(
"nestr",
nonempty_str,
re.compile(r"^[^/]+$")
)
passes the type checker, but fails with an exception, while
app.router.register_pattern(
"nestr",
nonempty_str,
r"^[^/]+$"
)
does not raise an exception, but highlights the pattern argument as a mismatching type.
The relevant lines are:
https://github.com/sanic-org/sanic-routing/blob/main/sanic_routing/router.py#L241-L243
https://github.com/sanic-org/sanic-routing/blob/main/sanic_routing/router.py#L272-L276
I have this path /<entity_id>:meta
in my route, and it works in the old version.
But now it doesn't work anymore due to this REGEX
https://github.com/sanic-org/sanic-routing/blob/main/sanic_routing/patterns.py#L22
It said that I'm using invalid pattern /<entity_id>:meta
, but it was intended and not easily to change because it was used to retrieve metadata for frontend team.
I hope you fix the regex pattern to suit more cases.
Thank you!
Because alpha
types are no longer using regex, they are acting the same as string
. We need to add a new constructor to be used inside the router in place of str
.
Currently .patterns
supports a fixed set of patterns using REGEX_TYPES
.
This should be expanded so that custom patterns can be added at run time.
router.register_pattern("ipv4", ipaddress.ip_address, r"^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$")
Then, it should be usable:
@app.route("/foo/<ip:ipv4>")
async def handler(request, ip: IPv4Address):
....
Re code generation: maybe take that a step forward and use actual inline Python code instead of string literals. Code that is syntactically valid can be loaded even if the variables don't exist, provided that the particular path never gets executed. Annotations or some other hacks could be used to inject parts into it. Well, you get the idea but this is becoming some true black magic by now.
# This is router code
if need_foo:
with codegen:
# This is generated code
if route == "foo":
return handler_foo
Originally posted by @Tronic in #37 (comment)
Test case fails:
def test_use_route_test_bug2():
router = Router()
def h1(foo):
return "first"
router.add("/<foo:int>", h1)
def h2(foo):
return "second"
router.add("/<foo:int>/bar", h2)
router.finalize()
with pytest.raises(NotFound):
router.get("/foo/aaaa", "BASE") #<-- This raises NotFound as expected
with pytest.raises(NotFound):
router.get("/0/aaaa", "BASE") #<-- This returns route h1, should raise NotFound.
Generated source:
def find_route(path, router, basket, extra):
parts = tuple(path[1:].split(router.delimiter))
num = len(parts)
if num > 0:
basket[0] = parts[0]
if num == 2:
if parts[1] == "bar":
try:
basket['__params__']['foo'] = int(basket[0])
except ValueError:
...
else:
basket['__raw_path__'] = '<foo:int>/bar'
return router.dynamic_routes[('<foo:int>', 'bar')], basket
try:
basket['__params__']['foo'] = int(basket[0])
except ValueError:
...
else:
basket['__raw_path__'] = '<foo:int>'
return router.dynamic_routes[('<foo:int>',)], basket
raise NotFound
When there are two parts to the route, and parts[1] != "bar"
, it falls through to the try/except clause for int(basket[0])
, succeeds in that, so returns the route match for h1
, should raise NotFound
.
I don't think this is an indentation issue like #13, perhaps a logic problem?
A declarative, efficient, and flexible JavaScript library for building user interfaces.
🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.
TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
An Open Source Machine Learning Framework for Everyone
The Web framework for perfectionists with deadlines.
A PHP framework for web artisans
Bring data to life with SVG, Canvas and HTML. 📊📈🎉
JavaScript (JS) is a lightweight interpreted programming language with first-class functions.
Some thing interesting about web. New door for the world.
A server is a program made to process requests and deliver data to clients.
Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.
Some thing interesting about visualization, use data art
Some thing interesting about game, make everyone happy.
We are working to build community through open source technology. NB: members must have two-factor auth.
Open source projects and samples from Microsoft.
Google ❤️ Open Source for everyone.
Alibaba Open Source for everyone
Data-Driven Documents codes.
China tencent open source team.