Permalink
Join GitHub today
GitHub is home to over 28 million developers working together to host and review code, manage projects, and build software together.
Sign up| import re | |
| import sys | |
| if sys.version < '2.4': | |
| from sets import ImmutableSet as frozenset | |
| import six | |
| from six.moves.urllib import parse as urlparse | |
| from routes.util import _url_quote as url_quote, _str_encode, as_unicode | |
| class Route(object): | |
| """The Route object holds a route recognition and generation | |
| routine. | |
| See Route.__init__ docs for usage. | |
| """ | |
| # reserved keys that don't count | |
| reserved_keys = ['requirements'] | |
| # special chars to indicate a natural split in the URL | |
| done_chars = ('/', ',', ';', '.', '#') | |
| def __init__(self, name, routepath, **kargs): | |
| """Initialize a route, with a given routepath for | |
| matching/generation | |
| The set of keyword args will be used as defaults. | |
| Usage:: | |
| >>> from routes.base import Route | |
| >>> newroute = Route(None, ':controller/:action/:id') | |
| >>> sorted(newroute.defaults.items()) | |
| [('action', 'index'), ('id', None)] | |
| >>> newroute = Route(None, 'date/:year/:month/:day', | |
| ... controller="blog", action="view") | |
| >>> newroute = Route(None, 'archives/:page', controller="blog", | |
| ... action="by_page", requirements = { 'page':'\d{1,2}' }) | |
| >>> newroute.reqs | |
| {'page': '\\\d{1,2}'} | |
| .. Note:: | |
| Route is generally not called directly, a Mapper instance | |
| connect method should be used to add routes. | |
| """ | |
| self.routepath = routepath | |
| self.sub_domains = False | |
| self.prior = None | |
| self.redirect = False | |
| self.name = name | |
| self._kargs = kargs | |
| self.minimization = kargs.pop('_minimize', False) | |
| self.encoding = kargs.pop('_encoding', 'utf-8') | |
| self.reqs = kargs.get('requirements', {}) | |
| self.decode_errors = 'replace' | |
| # Don't bother forming stuff we don't need if its a static route | |
| self.static = kargs.pop('_static', False) | |
| self.filter = kargs.pop('_filter', None) | |
| self.absolute = kargs.pop('_absolute', False) | |
| # Pull out the member/collection name if present, this applies only to | |
| # map.resource | |
| self.member_name = kargs.pop('_member_name', None) | |
| self.collection_name = kargs.pop('_collection_name', None) | |
| self.parent_resource = kargs.pop('_parent_resource', None) | |
| # Pull out route conditions | |
| self.conditions = kargs.pop('conditions', None) | |
| # Determine if explicit behavior should be used | |
| self.explicit = kargs.pop('_explicit', False) | |
| # Since static need to be generated exactly, treat them as | |
| # non-minimized | |
| if self.static: | |
| self.external = '://' in self.routepath | |
| self.minimization = False | |
| # Strip preceding '/' if present, and not minimizing | |
| if routepath.startswith('/') and self.minimization: | |
| self.routepath = routepath[1:] | |
| self._setup_route() | |
| def _setup_route(self): | |
| # Build our routelist, and the keys used in the route | |
| self.routelist = routelist = self._pathkeys(self.routepath) | |
| routekeys = frozenset(key['name'] for key in routelist | |
| if isinstance(key, dict)) | |
| self.dotkeys = frozenset(key['name'] for key in routelist | |
| if isinstance(key, dict) and | |
| key['type'] == '.') | |
| if not self.minimization: | |
| self.make_full_route() | |
| # Build a req list with all the regexp requirements for our args | |
| self.req_regs = {} | |
| for key, val in six.iteritems(self.reqs): | |
| self.req_regs[key] = re.compile('^' + val + '$') | |
| # Update our defaults and set new default keys if needed. defaults | |
| # needs to be saved | |
| (self.defaults, defaultkeys) = self._defaults(routekeys, | |
| self.reserved_keys, | |
| self._kargs.copy()) | |
| # Save the maximum keys we could utilize | |
| self.maxkeys = defaultkeys | routekeys | |
| # Populate our minimum keys, and save a copy of our backward keys for | |
| # quicker generation later | |
| (self.minkeys, self.routebackwards) = self._minkeys(routelist[:]) | |
| # Populate our hardcoded keys, these are ones that are set and don't | |
| # exist in the route | |
| self.hardcoded = frozenset(key for key in self.maxkeys | |
| if key not in routekeys | |
| and self.defaults[key] is not None) | |
| # Cache our default keys | |
| self._default_keys = frozenset(self.defaults.keys()) | |
| def make_full_route(self): | |
| """Make a full routelist string for use with non-minimized | |
| generation""" | |
| regpath = '' | |
| for part in self.routelist: | |
| if isinstance(part, dict): | |
| regpath += '%(' + part['name'] + ')s' | |
| else: | |
| regpath += part | |
| self.regpath = regpath | |
| def make_unicode(self, s): | |
| """Transform the given argument into a unicode string.""" | |
| if isinstance(s, six.text_type): | |
| return s | |
| elif isinstance(s, bytes): | |
| return s.decode(self.encoding) | |
| elif callable(s): | |
| return s | |
| else: | |
| return six.text_type(s) | |
| def _pathkeys(self, routepath): | |
| """Utility function to walk the route, and pull out the valid | |
| dynamic/wildcard keys.""" | |
| collecting = False | |
| current = '' | |
| done_on = '' | |
| var_type = '' | |
| just_started = False | |
| routelist = [] | |
| for char in routepath: | |
| if char in [':', '*', '{'] and not collecting and not self.static \ | |
| or char in ['{'] and not collecting: | |
| just_started = True | |
| collecting = True | |
| var_type = char | |
| if char == '{': | |
| done_on = '}' | |
| just_started = False | |
| if len(current) > 0: | |
| routelist.append(current) | |
| current = '' | |
| elif collecting and just_started: | |
| just_started = False | |
| if char == '(': | |
| done_on = ')' | |
| else: | |
| current = char | |
| done_on = self.done_chars + ('-',) | |
| elif collecting and char not in done_on: | |
| current += char | |
| elif collecting: | |
| collecting = False | |
| if var_type == '{': | |
| if current[0] == '.': | |
| var_type = '.' | |
| current = current[1:] | |
| else: | |
| var_type = ':' | |
| opts = current.split(':') | |
| if len(opts) > 1: | |
| current = opts[0] | |
| self.reqs[current] = opts[1] | |
| routelist.append(dict(type=var_type, name=current)) | |
| if char in self.done_chars: | |
| routelist.append(char) | |
| done_on = var_type = current = '' | |
| else: | |
| current += char | |
| if collecting: | |
| routelist.append(dict(type=var_type, name=current)) | |
| elif current: | |
| routelist.append(current) | |
| return routelist | |
| def _minkeys(self, routelist): | |
| """Utility function to walk the route backwards | |
| Will also determine the minimum keys we can handle to generate | |
| a working route. | |
| routelist is a list of the '/' split route path | |
| defaults is a dict of all the defaults provided for the route | |
| """ | |
| minkeys = [] | |
| backcheck = routelist[:] | |
| # If we don't honor minimization, we need all the keys in the | |
| # route path | |
| if not self.minimization: | |
| for part in backcheck: | |
| if isinstance(part, dict): | |
| minkeys.append(part['name']) | |
| return (frozenset(minkeys), backcheck) | |
| gaps = False | |
| backcheck.reverse() | |
| for part in backcheck: | |
| if not isinstance(part, dict) and part not in self.done_chars: | |
| gaps = True | |
| continue | |
| elif not isinstance(part, dict): | |
| continue | |
| key = part['name'] | |
| if key in self.defaults and not gaps: | |
| continue | |
| minkeys.append(key) | |
| gaps = True | |
| return (frozenset(minkeys), backcheck) | |
| def _defaults(self, routekeys, reserved_keys, kargs): | |
| """Creates default set with values stringified | |
| Put together our list of defaults, stringify non-None values | |
| and add in our action/id default if they use it and didn't | |
| specify it. | |
| defaultkeys is a list of the currently assumed default keys | |
| routekeys is a list of the keys found in the route path | |
| reserved_keys is a list of keys that are not | |
| """ | |
| defaults = {} | |
| # Add in a controller/action default if they don't exist | |
| if 'controller' not in routekeys and 'controller' not in kargs \ | |
| and not self.explicit: | |
| kargs['controller'] = 'content' | |
| if 'action' not in routekeys and 'action' not in kargs \ | |
| and not self.explicit: | |
| kargs['action'] = 'index' | |
| defaultkeys = frozenset(key for key in kargs.keys() | |
| if key not in reserved_keys) | |
| for key in defaultkeys: | |
| if kargs[key] is not None: | |
| defaults[key] = self.make_unicode(kargs[key]) | |
| else: | |
| defaults[key] = None | |
| if 'action' in routekeys and 'action' not in defaults \ | |
| and not self.explicit: | |
| defaults['action'] = 'index' | |
| if 'id' in routekeys and 'id' not in defaults \ | |
| and not self.explicit: | |
| defaults['id'] = None | |
| newdefaultkeys = frozenset(key for key in defaults.keys() | |
| if key not in reserved_keys) | |
| return (defaults, newdefaultkeys) | |
| def makeregexp(self, clist, include_names=True): | |
| """Create a regular expression for matching purposes | |
| Note: This MUST be called before match can function properly. | |
| clist should be a list of valid controller strings that can be | |
| matched, for this reason makeregexp should be called by the web | |
| framework after it knows all available controllers that can be | |
| utilized. | |
| include_names indicates whether this should be a match regexp | |
| assigned to itself using regexp grouping names, or if names | |
| should be excluded for use in a single larger regexp to | |
| determine if any routes match | |
| """ | |
| if self.minimization: | |
| reg = self.buildnextreg(self.routelist, clist, include_names)[0] | |
| if not reg: | |
| reg = '/' | |
| reg = reg + '/?' + '$' | |
| if not reg.startswith('/'): | |
| reg = '/' + reg | |
| else: | |
| reg = self.buildfullreg(clist, include_names) | |
| reg = '^' + reg | |
| if not include_names: | |
| return reg | |
| self.regexp = reg | |
| self.regmatch = re.compile(reg) | |
| def buildfullreg(self, clist, include_names=True): | |
| """Build the regexp by iterating through the routelist and | |
| replacing dicts with the appropriate regexp match""" | |
| regparts = [] | |
| for part in self.routelist: | |
| if isinstance(part, dict): | |
| var = part['name'] | |
| if var == 'controller': | |
| partmatch = '|'.join(map(re.escape, clist)) | |
| elif part['type'] == ':': | |
| partmatch = self.reqs.get(var) or '[^/]+?' | |
| elif part['type'] == '.': | |
| partmatch = self.reqs.get(var) or '[^/.]+?' | |
| else: | |
| partmatch = self.reqs.get(var) or '.+?' | |
| if include_names: | |
| regpart = '(?P<%s>%s)' % (var, partmatch) | |
| else: | |
| regpart = '(?:%s)' % partmatch | |
| if part['type'] == '.': | |
| regparts.append('(?:\.%s)??' % regpart) | |
| else: | |
| regparts.append(regpart) | |
| else: | |
| regparts.append(re.escape(part)) | |
| regexp = ''.join(regparts) + '$' | |
| return regexp | |
| def buildnextreg(self, path, clist, include_names=True): | |
| """Recursively build our regexp given a path, and a controller | |
| list. | |
| Returns the regular expression string, and two booleans that | |
| can be ignored as they're only used internally by buildnextreg. | |
| """ | |
| if path: | |
| part = path[0] | |
| else: | |
| part = '' | |
| reg = '' | |
| # noreqs will remember whether the remainder has either a string | |
| # match, or a non-defaulted regexp match on a key, allblank remembers | |
| # if the rest could possible be completely empty | |
| (rest, noreqs, allblank) = ('', True, True) | |
| if len(path[1:]) > 0: | |
| self.prior = part | |
| (rest, noreqs, allblank) = self.buildnextreg(path[1:], clist, | |
| include_names) | |
| if isinstance(part, dict) and part['type'] in (':', '.'): | |
| var = part['name'] | |
| typ = part['type'] | |
| partreg = '' | |
| # First we plug in the proper part matcher | |
| if var in self.reqs: | |
| if include_names: | |
| partreg = '(?P<%s>%s)' % (var, self.reqs[var]) | |
| else: | |
| partreg = '(?:%s)' % self.reqs[var] | |
| if typ == '.': | |
| partreg = '(?:\.%s)??' % partreg | |
| elif var == 'controller': | |
| if include_names: | |
| partreg = '(?P<%s>%s)' % (var, '|'.join(map(re.escape, | |
| clist))) | |
| else: | |
| partreg = '(?:%s)' % '|'.join(map(re.escape, clist)) | |
| elif self.prior in ['/', '#']: | |
| if include_names: | |
| partreg = '(?P<' + var + '>[^' + self.prior + ']+?)' | |
| else: | |
| partreg = '(?:[^' + self.prior + ']+?)' | |
| else: | |
| if not rest: | |
| if typ == '.': | |
| exclude_chars = '/.' | |
| else: | |
| exclude_chars = '/' | |
| if include_names: | |
| partreg = '(?P<%s>[^%s]+?)' % (var, exclude_chars) | |
| else: | |
| partreg = '(?:[^%s]+?)' % exclude_chars | |
| if typ == '.': | |
| partreg = '(?:\.%s)??' % partreg | |
| else: | |
| end = ''.join(self.done_chars) | |
| rem = rest | |
| if rem[0] == '\\' and len(rem) > 1: | |
| rem = rem[1] | |
| elif rem.startswith('(\\') and len(rem) > 2: | |
| rem = rem[2] | |
| else: | |
| rem = end | |
| rem = frozenset(rem) | frozenset(['/']) | |
| if include_names: | |
| partreg = '(?P<%s>[^%s]+?)' % (var, ''.join(rem)) | |
| else: | |
| partreg = '(?:[^%s]+?)' % ''.join(rem) | |
| if var in self.reqs: | |
| noreqs = False | |
| if var not in self.defaults: | |
| allblank = False | |
| noreqs = False | |
| # Now we determine if its optional, or required. This changes | |
| # depending on what is in the rest of the match. If noreqs is | |
| # true, then its possible the entire thing is optional as there's | |
| # no reqs or string matches. | |
| if noreqs: | |
| # The rest is optional, but now we have an optional with a | |
| # regexp. Wrap to ensure that if we match anything, we match | |
| # our regexp first. It's still possible we could be completely | |
| # blank as we have a default | |
| if var in self.reqs and var in self.defaults: | |
| reg = '(?:' + partreg + rest + ')?' | |
| # Or we have a regexp match with no default, so now being | |
| # completely blank form here on out isn't possible | |
| elif var in self.reqs: | |
| allblank = False | |
| reg = partreg + rest | |
| # If the character before this is a special char, it has to be | |
| # followed by this | |
| elif var in self.defaults and self.prior in (',', ';', '.'): | |
| reg = partreg + rest | |
| # Or we have a default with no regexp, don't touch the allblank | |
| elif var in self.defaults: | |
| reg = partreg + '?' + rest | |
| # Or we have a key with no default, and no reqs. Not possible | |
| # to be all blank from here | |
| else: | |
| allblank = False | |
| reg = partreg + rest | |
| # In this case, we have something dangling that might need to be | |
| # matched | |
| else: | |
| # If they can all be blank, and we have a default here, we know | |
| # its safe to make everything from here optional. Since | |
| # something else in the chain does have req's though, we have | |
| # to make the partreg here required to continue matching | |
| if allblank and var in self.defaults: | |
| reg = '(?:' + partreg + rest + ')?' | |
| # Same as before, but they can't all be blank, so we have to | |
| # require it all to ensure our matches line up right | |
| else: | |
| reg = partreg + rest | |
| elif isinstance(part, dict) and part['type'] == '*': | |
| var = part['name'] | |
| if noreqs: | |
| if include_names: | |
| reg = '(?P<%s>.*)' % var + rest | |
| else: | |
| reg = '(?:.*)' + rest | |
| if var not in self.defaults: | |
| allblank = False | |
| noreqs = False | |
| else: | |
| if allblank and var in self.defaults: | |
| if include_names: | |
| reg = '(?P<%s>.*)' % var + rest | |
| else: | |
| reg = '(?:.*)' + rest | |
| elif var in self.defaults: | |
| if include_names: | |
| reg = '(?P<%s>.*)' % var + rest | |
| else: | |
| reg = '(?:.*)' + rest | |
| else: | |
| if include_names: | |
| reg = '(?P<%s>.*)' % var + rest | |
| else: | |
| reg = '(?:.*)' + rest | |
| allblank = False | |
| noreqs = False | |
| elif part and part[-1] in self.done_chars: | |
| if allblank: | |
| reg = re.escape(part[:-1]) + '(?:' + re.escape(part[-1]) + rest | |
| reg += ')?' | |
| else: | |
| allblank = False | |
| reg = re.escape(part) + rest | |
| # We have a normal string here, this is a req, and it prevents us from | |
| # being all blank | |
| else: | |
| noreqs = False | |
| allblank = False | |
| reg = re.escape(part) + rest | |
| return (reg, noreqs, allblank) | |
| def match(self, url, environ=None, sub_domains=False, | |
| sub_domains_ignore=None, domain_match=''): | |
| """Match a url to our regexp. | |
| While the regexp might match, this operation isn't | |
| guaranteed as there's other factors that can cause a match to | |
| fail even though the regexp succeeds (Default that was relied | |
| on wasn't given, requirement regexp doesn't pass, etc.). | |
| Therefore the calling function shouldn't assume this will | |
| return a valid dict, the other possible return is False if a | |
| match doesn't work out. | |
| """ | |
| # Static routes don't match, they generate only | |
| if self.static: | |
| return False | |
| match = self.regmatch.match(url) | |
| if not match: | |
| return False | |
| sub_domain = None | |
| if sub_domains and environ and 'HTTP_HOST' in environ: | |
| host = environ['HTTP_HOST'].split(':')[0] | |
| sub_match = re.compile('^(.+?)\.%s$' % domain_match) | |
| subdomain = re.sub(sub_match, r'\1', host) | |
| if subdomain not in sub_domains_ignore and host != subdomain: | |
| sub_domain = subdomain | |
| if self.conditions: | |
| if 'method' in self.conditions and environ and \ | |
| environ['REQUEST_METHOD'] not in self.conditions['method']: | |
| return False | |
| # Check sub-domains? | |
| use_sd = self.conditions.get('sub_domain') | |
| if use_sd and not sub_domain: | |
| return False | |
| elif not use_sd and 'sub_domain' in self.conditions and sub_domain: | |
| return False | |
| if isinstance(use_sd, list) and sub_domain not in use_sd: | |
| return False | |
| matchdict = match.groupdict() | |
| result = {} | |
| extras = self._default_keys - frozenset(matchdict.keys()) | |
| for key, val in six.iteritems(matchdict): | |
| if key != 'path_info' and self.encoding: | |
| # change back into python unicode objects from the URL | |
| # representation | |
| try: | |
| val = as_unicode(val, self.encoding, self.decode_errors) | |
| except UnicodeDecodeError: | |
| return False | |
| if not val and key in self.defaults and self.defaults[key]: | |
| result[key] = self.defaults[key] | |
| else: | |
| result[key] = val | |
| for key in extras: | |
| result[key] = self.defaults[key] | |
| # Add the sub-domain if there is one | |
| if sub_domains: | |
| result['sub_domain'] = sub_domain | |
| # If there's a function, call it with environ and expire if it | |
| # returns False | |
| if self.conditions and 'function' in self.conditions and \ | |
| not self.conditions['function'](environ, result): | |
| return False | |
| return result | |
| def generate_non_minimized(self, kargs): | |
| """Generate a non-minimal version of the URL""" | |
| # Iterate through the keys that are defaults, and NOT in the route | |
| # path. If its not in kargs, or doesn't match, or is None, this | |
| # route won't work | |
| for k in self.maxkeys - self.minkeys: | |
| if k not in kargs: | |
| return False | |
| elif self.make_unicode(kargs[k]) != \ | |
| self.make_unicode(self.defaults[k]): | |
| return False | |
| # Ensure that all the args in the route path are present and not None | |
| for arg in self.minkeys: | |
| if arg not in kargs or kargs[arg] is None: | |
| if arg in self.dotkeys: | |
| kargs[arg] = '' | |
| else: | |
| return False | |
| # Encode all the argument that the regpath can use | |
| for k in kargs: | |
| if k in self.maxkeys: | |
| if k in self.dotkeys: | |
| if kargs[k]: | |
| kargs[k] = url_quote('.' + as_unicode(kargs[k], | |
| self.encoding), self.encoding) | |
| else: | |
| kargs[k] = url_quote(as_unicode(kargs[k], self.encoding), | |
| self.encoding) | |
| return self.regpath % kargs | |
| def generate_minimized(self, kargs): | |
| """Generate a minimized version of the URL""" | |
| routelist = self.routebackwards | |
| urllist = [] | |
| gaps = False | |
| for part in routelist: | |
| if isinstance(part, dict) and part['type'] in (':', '.'): | |
| arg = part['name'] | |
| # For efficiency, check these just once | |
| has_arg = arg in kargs | |
| has_default = arg in self.defaults | |
| # Determine if we can leave this part off | |
| # First check if the default exists and wasn't provided in the | |
| # call (also no gaps) | |
| if has_default and not has_arg and not gaps: | |
| continue | |
| # Now check to see if there's a default and it matches the | |
| # incoming call arg | |
| if (has_default and has_arg) and \ | |
| self.make_unicode(kargs[arg]) == \ | |
| self.make_unicode(self.defaults[arg]) and not gaps: | |
| continue | |
| # We need to pull the value to append, if the arg is None and | |
| # we have a default, use that | |
| if has_arg and kargs[arg] is None and has_default and not gaps: | |
| continue | |
| # Otherwise if we do have an arg, use that | |
| elif has_arg: | |
| val = kargs[arg] | |
| elif has_default and self.defaults[arg] is not None: | |
| val = self.defaults[arg] | |
| # Optional format parameter? | |
| elif part['type'] == '.': | |
| continue | |
| # No arg at all? This won't work | |
| else: | |
| return False | |
| val = as_unicode(val, self.encoding) | |
| urllist.append(url_quote(val, self.encoding)) | |
| if part['type'] == '.': | |
| urllist.append('.') | |
| if has_arg: | |
| del kargs[arg] | |
| gaps = True | |
| elif isinstance(part, dict) and part['type'] == '*': | |
| arg = part['name'] | |
| kar = kargs.get(arg) | |
| if kar is not None: | |
| urllist.append(url_quote(kar, self.encoding)) | |
| gaps = True | |
| elif part and part[-1] in self.done_chars: | |
| if not gaps and part in self.done_chars: | |
| continue | |
| elif not gaps: | |
| urllist.append(part[:-1]) | |
| gaps = True | |
| else: | |
| gaps = True | |
| urllist.append(part) | |
| else: | |
| gaps = True | |
| urllist.append(part) | |
| urllist.reverse() | |
| url = ''.join(urllist) | |
| return url | |
| def generate(self, _ignore_req_list=False, _append_slash=False, **kargs): | |
| """Generate a URL from ourself given a set of keyword arguments | |
| Toss an exception if this | |
| set of keywords would cause a gap in the url. | |
| """ | |
| # Verify that our args pass any regexp requirements | |
| if not _ignore_req_list: | |
| for key in self.reqs.keys(): | |
| val = kargs.get(key) | |
| if val and not self.req_regs[key].match(self.make_unicode(val)): | |
| return False | |
| # Verify that if we have a method arg, its in the method accept list. | |
| # Also, method will be changed to _method for route generation | |
| meth = as_unicode(kargs.get('method'), self.encoding) | |
| if meth: | |
| if self.conditions and 'method' in self.conditions \ | |
| and meth.upper() not in self.conditions['method']: | |
| return False | |
| kargs.pop('method') | |
| if self.minimization: | |
| url = self.generate_minimized(kargs) | |
| else: | |
| url = self.generate_non_minimized(kargs) | |
| if url is False: | |
| return url | |
| if not url.startswith('/') and not self.static: | |
| url = '/' + url | |
| extras = frozenset(kargs.keys()) - self.maxkeys | |
| if extras: | |
| if _append_slash and not url.endswith('/'): | |
| url += '/' | |
| fragments = [] | |
| # don't assume the 'extras' set preserves order: iterate | |
| # through the ordered kargs instead | |
| for key in kargs: | |
| if key not in extras: | |
| continue | |
| if key == 'action' or key == 'controller': | |
| continue | |
| val = kargs[key] | |
| if isinstance(val, (tuple, list)): | |
| for value in val: | |
| value = as_unicode(value, self.encoding) | |
| fragments.append((key, _str_encode(value, | |
| self.encoding))) | |
| else: | |
| val = as_unicode(val, self.encoding) | |
| fragments.append((key, _str_encode(val, self.encoding))) | |
| if fragments: | |
| url += '?' | |
| url += urlparse.urlencode(fragments) | |
| elif _append_slash and not url.endswith('/'): | |
| url += '/' | |
| return url |