1616from collections .abc import Mapping , Sequence
1717from dataclasses import dataclass , field
1818from typing import Literal , cast
19- from urllib .parse import quote , unquote
19+ from urllib .parse import parse_qs , quote , unquote
2020
2121__all__ = ["InvalidUriTemplate" , "Operator" , "UriTemplate" , "Variable" ]
2222
@@ -201,6 +201,8 @@ class UriTemplate:
201201 _parts : tuple [_Part , ...] = field (repr = False , compare = False )
202202 _variables : tuple [Variable , ...] = field (repr = False , compare = False )
203203 _pattern : re .Pattern [str ] = field (repr = False , compare = False )
204+ _path_variables : tuple [Variable , ...] = field (repr = False , compare = False )
205+ _query_variables : tuple [Variable , ...] = field (repr = False , compare = False )
204206
205207 @staticmethod
206208 def is_template (value : str ) -> bool :
@@ -253,8 +255,22 @@ def parse(
253255 )
254256
255257 parts , variables = _parse (template , max_expressions = max_expressions )
256- pattern = _build_pattern (parts )
257- return cls (template = template , _parts = parts , _variables = variables , _pattern = pattern )
258+
259+ # Trailing {?...}/{&...} expressions are matched leniently via
260+ # parse_qs instead of regex: order-agnostic, partial, ignores
261+ # extras. The path portion keeps regex matching.
262+ path_parts , query_vars = _split_query_tail (parts )
263+ path_vars = variables [: len (variables ) - len (query_vars )]
264+ pattern = _build_pattern (path_parts )
265+
266+ return cls (
267+ template = template ,
268+ _parts = parts ,
269+ _variables = variables ,
270+ _pattern = pattern ,
271+ _path_variables = path_vars ,
272+ _query_variables = query_vars ,
273+ )
258274
259275 @property
260276 def variables (self ) -> tuple [Variable , ...]:
@@ -355,6 +371,19 @@ def match(self, uri: str, *, max_uri_length: int = DEFAULT_MAX_URI_LENGTH) -> di
355371 >>> t.match("/files/a/b/c")
356372 {'path': ['a', 'b', 'c']}
357373
374+ **Query parameters** (``{?q,lang}`` at the end of a template)
375+ are matched leniently: order-agnostic, partial, and unrecognized
376+ params are ignored. Absent params are omitted from the result so
377+ downstream function defaults can apply::
378+
379+ >>> t = UriTemplate.parse("logs://{service}{?since,level}")
380+ >>> t.match("logs://api")
381+ {'service': 'api'}
382+ >>> t.match("logs://api?level=error")
383+ {'service': 'api', 'level': 'error'}
384+ >>> t.match("logs://api?level=error&since=5m&utm=x")
385+ {'service': 'api', 'since': '5m', 'level': 'error'}
386+
358387 Args:
359388 uri: A concrete URI string.
360389 max_uri_length: Maximum permitted length of the input URI.
@@ -369,54 +398,125 @@ def match(self, uri: str, *, max_uri_length: int = DEFAULT_MAX_URI_LENGTH) -> di
369398 """
370399 if len (uri ) > max_uri_length :
371400 return None
401+
402+ if self ._query_variables :
403+ # Two-phase: regex matches the path, parse_qs handles the
404+ # query. Query params may be partial, reordered, or include
405+ # extras; absent params stay absent so downstream defaults
406+ # can apply.
407+ path , _ , query = uri .partition ("?" )
408+ m = self ._pattern .fullmatch (path )
409+ if m is None :
410+ return None
411+ result = _extract_path (m , self ._path_variables )
412+ if result is None :
413+ return None
414+ if query :
415+ parsed = parse_qs (query , keep_blank_values = True )
416+ for var in self ._query_variables :
417+ if var .name in parsed :
418+ result [var .name ] = parsed [var .name ][0 ]
419+ return result
420+
372421 m = self ._pattern .fullmatch (uri )
373422 if m is None :
374423 return None
424+ return _extract_path (m , self ._variables )
375425
376- result : dict [str , str | list [str ]] = {}
377- # One capture group per variable, emitted in template order.
378- for var , raw in zip (self ._variables , m .groups ()):
379- spec = _OPERATOR_SPECS [var .operator ]
426+ def __str__ (self ) -> str :
427+ return self .template
380428
381- if var .explode :
382- # Explode capture holds the whole run including separators,
383- # e.g. "/a/b/c" or ";keys=a;keys=b". Split and decode each.
384- if not raw :
385- result [var .name ] = []
429+
430+ def _extract_path (m : re .Match [str ], variables : tuple [Variable , ...]) -> dict [str , str | list [str ]] | None :
431+ """Decode regex capture groups into a variable-name mapping.
432+
433+ Handles scalar and explode variables. Named explode (``;``) strips
434+ and validates the ``name=`` prefix per item, returning ``None`` on
435+ mismatch.
436+ """
437+ result : dict [str , str | list [str ]] = {}
438+ # One capture group per variable, emitted in template order.
439+ for var , raw in zip (variables , m .groups ()):
440+ spec = _OPERATOR_SPECS [var .operator ]
441+
442+ if var .explode :
443+ # Explode capture holds the whole run including separators,
444+ # e.g. "/a/b/c" or ";keys=a;keys=b". Split and decode each.
445+ if not raw :
446+ result [var .name ] = []
447+ continue
448+ segments : list [str ] = []
449+ prefix = f"{ var .name } ="
450+ for seg in raw .split (spec .separator ):
451+ if not seg : # leading separator produces an empty first item
386452 continue
387- segments : list [str ] = []
388- prefix = f"{ var .name } ="
389- for seg in raw .split (spec .separator ):
390- if not seg : # leading separator produces an empty first item
391- continue
392- if spec .named :
393- # Named explode emits name=value per item (or bare
394- # name for ; with empty value). Validate the name
395- # and strip the prefix before decoding.
396- if seg .startswith (prefix ):
397- seg = seg [len (prefix ) :]
398- elif seg == var .name :
399- seg = ""
400- else :
401- return None
402- segments .append (unquote (seg ))
403- result [var .name ] = segments
404- else :
405- result [var .name ] = unquote (raw )
453+ if spec .named :
454+ # Named explode emits name=value per item (or bare
455+ # name for ; with empty value). Validate the name
456+ # and strip the prefix before decoding.
457+ if seg .startswith (prefix ):
458+ seg = seg [len (prefix ) :]
459+ elif seg == var .name :
460+ seg = ""
461+ else :
462+ return None
463+ segments .append (unquote (seg ))
464+ result [var .name ] = segments
465+ else :
466+ result [var .name ] = unquote (raw )
406467
407- return result
468+ return result
408469
409- def __str__ (self ) -> str :
410- return self .template
470+
471+ def _split_query_tail (
472+ parts : tuple [_Part , ...],
473+ ) -> tuple [tuple [_Part , ...], tuple [Variable , ...]]:
474+ """Separate trailing ``?``/``&`` expressions from the path portion.
475+
476+ Lenient query matching (order-agnostic, partial, ignores extras)
477+ applies when a template ends with one or more consecutive ``?``/``&``
478+ expressions and the preceding path portion contains no literal
479+ ``?``. If the path has a literal ``?`` (e.g., ``?fixed=1{&page}``),
480+ the URI's ``?`` split won't align with the template's expression
481+ boundary, so strict regex matching is used instead.
482+
483+ Returns:
484+ A pair ``(path_parts, query_vars)``. If lenient matching does
485+ not apply, ``query_vars`` is empty and ``path_parts`` is the
486+ full input.
487+ """
488+ split = len (parts )
489+ for i in range (len (parts ) - 1 , - 1 , - 1 ):
490+ part = parts [i ]
491+ if isinstance (part , _Expression ) and part .operator in ("?" , "&" ):
492+ split = i
493+ else :
494+ break
495+
496+ if split == len (parts ):
497+ return parts , ()
498+
499+ # If the path portion contains a literal ?, the URI's ? won't align
500+ # with our template split. Fall back to strict regex.
501+ for part in parts [:split ]:
502+ if isinstance (part , str ) and "?" in part :
503+ return parts , ()
504+
505+ query_vars : list [Variable ] = []
506+ for part in parts [split :]:
507+ assert isinstance (part , _Expression )
508+ query_vars .extend (part .variables )
509+
510+ return parts [:split ], tuple (query_vars )
411511
412512
413513def _build_pattern (parts : tuple [_Part , ...]) -> re .Pattern [str ]:
414514 """Compile a regex that matches URIs produced by this template.
415515
416516 Walks parts in order: literals are ``re.escape``'d, expressions
417517 become capture groups. One group is emitted per variable, in the
418- same order as ``UriTemplate._variables``, so ``match.groups()`` can
419- be zipped directly.
518+ same order as the variables appearing in ``parts``, so
519+ ``match.groups()`` can be zipped directly.
420520
421521 Raises:
422522 re.error: Only if pattern assembly is buggy — should not happen
0 commit comments