Package: yrouter
YrouterYrouter is a framework-agnostic URL routing package focused on simplicity and performance.RouterclassGiven a sequence ofrouteobjects, theRouterclass builds a tree out of it.The mappingroutes -> treeis important to bear in mind when building routes withyrouter(More on this later).The empty route is the root of the tree and the remaining routes are its children.
Let's see an example:>>>handler=lambda:None>>>routes=(route("",handler,name="index"),route("users",handler,name="users",subroutes=(route("<int:id>",handler,name="user-details"),)),route("articles",subroutes=(route("<str:category>",handler,name="category"),route("<slug:title>",handler,name="article"),)),)>>>router=Router(routes)>>>router.display()/users/<int:id>/articles/<str:category>/<slug:title>/Matching a handler given a pathWe can now match URL paths against our router.>>>router.match("/")<FullMatch:handler=index,kwargs={},should_redirect=False>>>>router.match("users/66/")<FullMatch:handler=user-details,kwargs={'id':66},should_redirect=False>>>>router.match("articles/tech/")<FullMatch:handler=category,kwargs={'category':'tech'},should_redirect=False>>>>router.match("articles/hello-world/")<FullMatch:handler=article,kwargs={'title':'hello-world'},should_redirect=False>We can match the/users/path but not the/articles/path since the latter doesn't have a handler attached to it:>>>router.match("users/")<FullMatch:handler=users,kwargs={},should_redirect=False>>>>router.match("articles/")<NoMatch>Finding a path given a handler nameWe can also go in the opposite way: find a path given a handler name and eventual keyword arguments.>>>router.find("index")'/'>>>router.find("user-details",id=66)'/users/66/'>>>router.find("category",category="tech")'/articles/tech/'If given an invalid handler name or missing/extra keywords arguments for the route being searched, the find method returns None.>>>router.find("something")>>>router.find("user-details")>>>router.find("category",category="tech",foo="bar")There is an exception for routes with regex converters that will return the initial path if no keyword arguments is provided. They behave similarly to other routes however when keyword arguments are provided.RouteNodeandrouteWhen therouterbuilds up a tree out ofrouteobjects, it creates aRouteNodefor each component in theroutebeing described.>>>node=route("authors/<int:id>/<str:title>/")>>>node.display(0)authors/<int:id>/<str:title>/>>>node<RouteNode:converter=<ExactConverter:description=authors;identifier=None>;handler=None;children=1>>>>node=node.children[0]>>>node<RouteNode:converter=<IntConverter:description=<int:id>;identifier=id>;handler=None;children=1>>>>node=node.children[0]>>>node<RouteNode:converter=<StringConverter:description=<str:title>;identifier=title>;handler=None;children=0>>>>node.children[]ARouteNodeis primarily described by itsconverter.
The job of aconverteris to find if a given value can be accepted by theRouteNodeit is attached to:Here is what happens when we try to matchauthors/23/some-titleagainst the above route:First, the path is split like this:["authors","23","some-title"]Then, we try to matchauthorswith the firstRouteNodeconverter -->ExactConverter--> accepts.Match23with the next node's converter in the tree -->IntConverter--> accepts.Matchsome-titlewith the next node's converter -->StringConverter--> refuses.The last converter refusessome-titlesince it only matches alphabetic characters ==> returnNoMatchConvertersThe converters provided by default are:IntConverterA converter that matches positive integers.>>>converter=IntConverter(description="<int:id>",identifier="id")>>>converter.accepts("100")(True,{'id':100})>>>converter.accepts("0")(True,{'id':0})>>>converter.accepts("1.0")(False,{})>>>converter.accepts("-1")(False,{})>>>converter.accepts("hello-world")(False,{})StringConverterA converter that only matches alphabetic characters.>>>converter=StringConverter(description="<str:string>",identifier="string")>>>converter.accepts("hello")(True,{'string':'hello'})>>>converter.accepts("ABC")(True,{'string':'ABC'})>>>converter.accepts("1")(False,{})>>>converter.accepts("hello-world")(False,{})SlugConverterA converter that matches slugs.>>>converter=SlugConverter("<slug:slug>","slug")>>>converter.accepts("a-slug")(True,{'slug':'a-slug'})>>>converter.accepts("1-random_slug")(True,{'slug':'1-random_slug'})>>>converter.accepts("hello")(True,{'slug':'hello'})>>>converter.accepts("hi@hi")(True,{'slug':'hi@hi'})UUIDConverterA converter that matches UUIDs.>>>converter=UUIDConverter(description="<uuid:uuid>",identifier="uuid_identifier")>>>converter.accepts("20bfa7b2-50a5-11ec-83dc-479fd603abba")(True,{'uuid_identifier':'20bfa7b2-50a5-11ec-83dc-479fd603abba'})>>>converter.accepts("1-2-3-4")(False,{})PathConverterA converter that matches arbitrary paths.>>>converter=PathConverter(description="<path:path>",identifier="path")>>>converter.accepts("images/original/hero.jpg")(True,{'path':'images/original/hero.jpg'})>>>converter.accepts("1-2/three/_4")(True,{'path':'1-2/three/_4'})RegexConverterA converter that matches regular expressions.>>>converter=RegexConverter("<re:(?P<match>^[a-z]*$)>","(?P<match>^[a-z]*$)")>>>converter.accepts("whatever")(True,{'match':'whatever'})>>>converter.accepts("a-b")(False,{})Sinceyrouterrepresent routes by delimiting them with the slash (/) character, a slash isn't allowed in regex identifiers!Adding a converterYou can easily add a new converter toyrouter.A converter has a description and optionally an identifier. The latter represent the keyword argument when matching against a route. If<int:id>is the description of a converter, thenidis the identifier.To register a new converter, we need to subclassAbstractConverterand implement theacceptsmethod.
This method should return(True, matched_kwargs)if it accepts a given value orREFUSEDelse.fromyrouterimportAbstractConverter,REFUSEDclassMyCustomConverter(AbstractConverter,converter_name="custom"):defaccepts(self,value):return(True,{self.identifier:value})ifvalue.isidentifier()elseREFUSEDThis converter will only accept strings that are considered as valid identifiers in Python.>>>converter=MyCustomConverter(description="<custom:identifier>",identifier="identifier")>>>converter.accepts("valid_identifier")(True,{"identifier":"valid_identifier})>>>converter.accepts("invalid-identifier")(False,{})To use your converter in a route:>>>route("<custom:my_identifier>/, handler, name='custom-route'")You need to make sure that the code of the converter is read before attempting to use it in a route.Ideally, you'd write the code of your converter right above the routes that use it.Trailing slash behaviorWithyrouter, you either choose if all your URLs have a trailing slash or if they all don't.This is important; you can't have, let's say/users/66andusers/66/.For more details about this, see:To slash or no to slash.By default, a slash is appended to all URLs which means that if a user requests a resource like/users/66, our above router will match but will indicate that the user should be redirected; in this case tousers/66/:>>>router.match("users/66")<FullMatch:handler=user-details,kwargs={'id':66},should_redirect=True>As such, if you don't want to append a slash to your URLs, you'd define your router withappend_slash=False:>>>no_slash_router=Router(routes,append_slash=False)>>>no_slash_router.match("users/66")<FullMatch:handler=user-details,kwargs={'id':66},should_redirect=False>>>>no_slash_router.match("users/66/")<FullMatch:handler=user-details,kwargs={'id':66},should_redirect=True>It also works when we try to find the path for a given handler name:>>>no_slash_router.find("category",category="business")'/articles/business'The empty route equivalently matches the empty string''and/:>>>router.match("")<FullMatch:handler=index,kwargs={},should_redirect=False>>>>router.match("/")<FullMatch:handler=index,kwargs={},should_redirect=False>>>>no_slash_router.match("")<FullMatch:handler=index,kwargs={},should_redirect=False>>>>no_slash_router.match("/")<FullMatch:handler=index,kwargs={},should_redirect=False>Extra considerationsRoutes starting with the same prefix at the same levelThe tree structure of the routes assumes that each node's children is described uniquely. Therefore, you can't write something like this:>>>routes=(route("",handler,name="home"),route("users/",handler,name="users"),route("users/<int:id>",handler,name="users-details"),)>>>router=Router(routes)Traceback(mostrecentcalllast):...yrouter.exceptions.RouterConfigurationError:Anodematching'users'alreadyexistsatthislevelofthetree.Do this instead:>>>routes=(route("",handler,name="home"),route("users/",handler,name="users",subroutes=(route("<int:id>",handler,name="users-details"),)),)Theusersroute will still be matched, as long as it has a handler attached to it.Routes with similar namesIt's not recommended to have routes with similar names.You can distinguish routes from different modules by using the following technique:>>>routes=(route("",handler,name="home:index"),route("users",handler,name="users:index",subroutes=(route("<int:id>",handler,name="users:details"),)),)>>>router=Router(routes)>>>router.find("home:index")'/'>>>router.find("users:index")'/users/'>>>router.find("users:details",id=66)'/users/66/'Firstrouteof a router different than the empty pathIf the first route isn't the empty route described by the empty string''or'/',yrouterwill automatically create a route for it. It's needed as the root of the router's tree. The empty path won't be matched however:>>>router=Router([route("home/",handler,name="home")])>>>router.match("/home/")<FullMatch:handler=home,kwargs={},should_redirect=False>>>>assertnot(router.match("")orrouter.match("/"))Integration with other librariesThe idea of buildingyroutercame fromthis feature request in the websockets library. As such,yrouter-websocketsis a routing package for thewebsocketslibrary based onyrouter.InstallationYrouter requiresPython>=3.9. You can install it from Pypi:pipinstallyrouterRunning the testsyrouterhas a mix of unit tests and doctests. You can run the test suite withpytest.cdpath/to/yrouter
pytestBenchmarkYou can find a comparison ofyrouterand some other routing modules inyrouter-bench.