From ce067c3e2d34506ea84c5adfe3d27b67513bff08 Mon Sep 17 00:00:00 2001 From: Jay Lee Date: Thu, 13 Oct 2016 15:33:10 -0400 Subject: [PATCH] URITemplate 3.0 --- src/uritemplate/__init__.py | 273 ++----------------------- src/uritemplate/api.py | 71 +++++++ src/uritemplate/template.py | 150 ++++++++++++++ src/uritemplate/variable.py | 384 ++++++++++++++++++++++++++++++++++++ 4 files changed, 622 insertions(+), 256 deletions(-) create mode 100644 src/uritemplate/api.py create mode 100644 src/uritemplate/template.py create mode 100644 src/uritemplate/variable.py diff --git a/src/uritemplate/__init__.py b/src/uritemplate/__init__.py index 712405d4..40c03204 100644 --- a/src/uritemplate/__init__.py +++ b/src/uritemplate/__init__.py @@ -1,265 +1,26 @@ -#!/usr/bin/env python - -""" -URI Template (RFC6570) Processor """ -__copyright__ = """\ -Copyright 2011-2013 Joe Gregorio +uritemplate +=========== -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at +The URI templating library for humans. - http://www.apache.org/licenses/LICENSE-2.0 +See http://uritemplate.rtfd.org/ for documentation + +:copyright: (c) 2013-2015 Ian Cordasco +:license: Modified BSD, see LICENSE for more details -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. """ -import re -try: - from urllib.parse import quote -except ImportError: - from urllib import quote +__title__ = 'uritemplate' +__author__ = 'Ian Cordasco' +__license__ = 'Modified BSD or Apache License, Version 2.0' +__copyright__ = 'Copyright 2013 Ian Cordasco' +__version__ = '3.0.0' +__version_info__ = tuple(int(i) for i in __version__.split('.') if i.isdigit()) +from uritemplate.api import ( + URITemplate, expand, partial, variables # noqa: E402 +) - -__version__ = "0.6" - -RESERVED = ":/?#[]@!$&'()*+,;=" -OPERATOR = "+#./;?&|!@" -MODIFIER = ":^" -TEMPLATE = re.compile("{([^\}]+)}") - - -def variables(template): - '''Returns the set of keywords in a uri template''' - vars = set() - for varlist in TEMPLATE.findall(template): - if varlist[0] in OPERATOR: - varlist = varlist[1:] - varspecs = varlist.split(',') - for var in varspecs: - # handle prefix values - var = var.split(':')[0] - # handle composite values - if var.endswith('*'): - var = var[:-1] - vars.add(var) - return vars - - -def _quote(value, safe, prefix=None): - if prefix is not None: - return quote(str(value)[:prefix], safe) - return quote(str(value), safe) - - -def _tostring(varname, value, explode, prefix, operator, safe=""): - if isinstance(value, list): - return ",".join([_quote(x, safe) for x in value]) - if isinstance(value, dict): - keys = sorted(value.keys()) - if explode: - return ",".join([_quote(key, safe) + "=" + \ - _quote(value[key], safe) for key in keys]) - else: - return ",".join([_quote(key, safe) + "," + \ - _quote(value[key], safe) for key in keys]) - elif value is None: - return - else: - return _quote(value, safe, prefix) - - -def _tostring_path(varname, value, explode, prefix, operator, safe=""): - joiner = operator - if isinstance(value, list): - if explode: - out = [_quote(x, safe) for x in value if value is not None] - else: - joiner = "," - out = [_quote(x, safe) for x in value if value is not None] - if out: - return joiner.join(out) - else: - return - elif isinstance(value, dict): - keys = sorted(value.keys()) - if explode: - out = [_quote(key, safe) + "=" + \ - _quote(value[key], safe) for key in keys \ - if value[key] is not None] - else: - joiner = "," - out = [_quote(key, safe) + "," + \ - _quote(value[key], safe) \ - for key in keys if value[key] is not None] - if out: - return joiner.join(out) - else: - return - elif value is None: - return - else: - return _quote(value, safe, prefix) - - -def _tostring_semi(varname, value, explode, prefix, operator, safe=""): - joiner = operator - if operator == "?": - joiner = "&" - if isinstance(value, list): - if explode: - out = [varname + "=" + _quote(x, safe) \ - for x in value if x is not None] - if out: - return joiner.join(out) - else: - return - else: - return varname + "=" + ",".join([_quote(x, safe) \ - for x in value]) - elif isinstance(value, dict): - keys = sorted(value.keys()) - if explode: - return joiner.join([_quote(key, safe) + "=" + \ - _quote(value[key], safe) \ - for key in keys if key is not None]) - else: - return varname + "=" + ",".join([_quote(key, safe) + "," + \ - _quote(value[key], safe) for key in keys \ - if key is not None]) - else: - if value is None: - return - elif value: - return (varname + "=" + _quote(value, safe, prefix)) - else: - return varname - - -def _tostring_query(varname, value, explode, prefix, operator, safe=""): - joiner = operator - if operator in ["?", "&"]: - joiner = "&" - if isinstance(value, list): - if 0 == len(value): - return None - if explode: - return joiner.join([varname + "=" + _quote(x, safe) \ - for x in value]) - else: - return (varname + "=" + ",".join([_quote(x, safe) \ - for x in value])) - elif isinstance(value, dict): - if 0 == len(value): - return None - keys = sorted(value.keys()) - if explode: - return joiner.join([_quote(key, safe) + "=" + \ - _quote(value[key], safe) \ - for key in keys]) - else: - return varname + "=" + \ - ",".join([_quote(key, safe) + "," + \ - _quote(value[key], safe) for key in keys]) - else: - if value is None: - return - elif value: - return (varname + "=" + _quote(value, safe, prefix)) - else: - return (varname + "=") - - -TOSTRING = { - "" : _tostring, - "+": _tostring, - "#": _tostring, - ";": _tostring_semi, - "?": _tostring_query, - "&": _tostring_query, - "/": _tostring_path, - ".": _tostring_path, - } - - -def expand(template, variables): - """ - Expand template as a URI Template using variables. - """ - def _sub(match): - expression = match.group(1) - operator = "" - if expression[0] in OPERATOR: - operator = expression[0] - varlist = expression[1:] - else: - varlist = expression - - safe = "" - if operator in ["+", "#"]: - safe = RESERVED - varspecs = varlist.split(",") - varnames = [] - defaults = {} - for varspec in varspecs: - default = None - explode = False - prefix = None - if "=" in varspec: - varname, default = tuple(varspec.split("=", 1)) - else: - varname = varspec - if varname[-1] == "*": - explode = True - varname = varname[:-1] - elif ":" in varname: - try: - prefix = int(varname[varname.index(":")+1:]) - except ValueError: - raise ValueError("non-integer prefix '{0}'".format( - varname[varname.index(":")+1:])) - varname = varname[:varname.index(":")] - if default: - defaults[varname] = default - varnames.append((varname, explode, prefix)) - - retval = [] - joiner = operator - start = operator - if operator == "+": - start = "" - joiner = "," - if operator == "#": - joiner = "," - if operator == "?": - joiner = "&" - if operator == "&": - start = "&" - if operator == "": - joiner = "," - for varname, explode, prefix in varnames: - if varname in variables: - value = variables[varname] - if not value and value != "" and varname in defaults: - value = defaults[varname] - elif varname in defaults: - value = defaults[varname] - else: - continue - expanded = TOSTRING[operator]( - varname, value, explode, prefix, operator, safe=safe) - if expanded is not None: - retval.append(expanded) - if len(retval) > 0: - return start + joiner.join(retval) - else: - return "" - - return TEMPLATE.sub(_sub, template) +__all__ = ('URITemplate', 'expand', 'partial', 'variables') diff --git a/src/uritemplate/api.py b/src/uritemplate/api.py new file mode 100644 index 00000000..37c7c45e --- /dev/null +++ b/src/uritemplate/api.py @@ -0,0 +1,71 @@ +""" + +uritemplate.api +=============== + +This module contains the very simple API provided by uritemplate. + +""" +from uritemplate.template import URITemplate + + +def expand(uri, var_dict=None, **kwargs): + """Expand the template with the given parameters. + + :param str uri: The templated URI to expand + :param dict var_dict: Optional dictionary with variables and values + :param kwargs: Alternative way to pass arguments + :returns: str + + Example:: + + expand('https://api.github.com{/end}', {'end': 'users'}) + expand('https://api.github.com{/end}', end='gists') + + .. note:: Passing values by both parts, may override values in + ``var_dict``. For example:: + + expand('https://{var}', {'var': 'val1'}, var='val2') + + ``val2`` will be used instead of ``val1``. + + """ + return URITemplate(uri).expand(var_dict, **kwargs) + + +def partial(uri, var_dict=None, **kwargs): + """Partially expand the template with the given parameters. + + If all of the parameters for the template are not given, return a + partially expanded template. + + :param dict var_dict: Optional dictionary with variables and values + :param kwargs: Alternative way to pass arguments + :returns: :class:`URITemplate` + + Example:: + + t = URITemplate('https://api.github.com{/end}') + t.partial() # => URITemplate('https://api.github.com{/end}') + + """ + return URITemplate(uri).partial(var_dict, **kwargs) + + +def variables(uri): + """Parse the variables of the template. + + This returns all of the variable names in the URI Template. + + :returns: Set of variable names + :rtype: set + + Example:: + + variables('https://api.github.com{/end}) + # => {'end'} + variables('https://api.github.com/repos{/username}{/repository}') + # => {'username', 'repository'} + + """ + return set(URITemplate(uri).variable_names) diff --git a/src/uritemplate/template.py b/src/uritemplate/template.py new file mode 100644 index 00000000..c9d7c7ed --- /dev/null +++ b/src/uritemplate/template.py @@ -0,0 +1,150 @@ +""" + +uritemplate.template +==================== + +This module contains the essential inner workings of uritemplate. + +What treasures await you: + +- URITemplate class + +You see a treasure chest of knowledge in front of you. +What do you do? +> + +""" + +import re +from uritemplate.variable import URIVariable + +template_re = re.compile('{([^\}]+)}') + + +def _merge(var_dict, overrides): + if var_dict: + opts = var_dict.copy() + opts.update(overrides) + return opts + return overrides + + +class URITemplate(object): + + """This parses the template and will be used to expand it. + + This is the most important object as the center of the API. + + Example:: + + from uritemplate import URITemplate + import requests + + + t = URITemplate( + 'https://api.github.com/users/sigmavirus24/gists{/gist_id}' + ) + uri = t.expand(gist_id=123456) + resp = requests.get(uri) + for gist in resp.json(): + print(gist['html_url']) + + Please note:: + + str(t) + # 'https://api.github.com/users/sigmavirus24/gists{/gistid}' + repr(t) # is equivalent to + # URITemplate(str(t)) + # Where str(t) is interpreted as the URI string. + + Also, ``URITemplates`` are hashable so they can be used as keys in + dictionaries. + + """ + + def __init__(self, uri): + #: The original URI to be parsed. + self.uri = uri + #: A list of the variables in the URI. They are stored as + #: :class:`URIVariable`\ s + self.variables = [ + URIVariable(m.groups()[0]) for m in template_re.finditer(self.uri) + ] + #: A set of variable names in the URI. + self.variable_names = set() + for variable in self.variables: + self.variable_names.update(variable.variable_names) + + def __repr__(self): + return 'URITemplate("%s")' % self + + def __str__(self): + return self.uri + + def __eq__(self, other): + return self.uri == other.uri + + def __hash__(self): + return hash(self.uri) + + def _expand(self, var_dict, replace): + if not self.variables: + return self.uri + + expansion = var_dict + expanded = {} + for v in self.variables: + expanded.update(v.expand(expansion)) + + def replace_all(match): + return expanded.get(match.groups()[0], '') + + def replace_partial(match): + match = match.groups()[0] + var = '{%s}' % match + return expanded.get(match) or var + + replace = replace_partial if replace else replace_all + + return template_re.sub(replace, self.uri) + + def expand(self, var_dict=None, **kwargs): + """Expand the template with the given parameters. + + :param dict var_dict: Optional dictionary with variables and values + :param kwargs: Alternative way to pass arguments + :returns: str + + Example:: + + t = URITemplate('https://api.github.com{/end}') + t.expand({'end': 'users'}) + t.expand(end='gists') + + .. note:: Passing values by both parts, may override values in + ``var_dict``. For example:: + + expand('https://{var}', {'var': 'val1'}, var='val2') + + ``val2`` will be used instead of ``val1``. + + """ + return self._expand(_merge(var_dict, kwargs), False) + + def partial(self, var_dict=None, **kwargs): + """Partially expand the template with the given parameters. + + If all of the parameters for the template are not given, return a + partially expanded template. + + :param dict var_dict: Optional dictionary with variables and values + :param kwargs: Alternative way to pass arguments + :returns: :class:`URITemplate` + + Example:: + + t = URITemplate('https://api.github.com{/end}') + t.partial() # => URITemplate('https://api.github.com{/end}') + + """ + return URITemplate(self._expand(_merge(var_dict, kwargs), True)) diff --git a/src/uritemplate/variable.py b/src/uritemplate/variable.py new file mode 100644 index 00000000..1842830f --- /dev/null +++ b/src/uritemplate/variable.py @@ -0,0 +1,384 @@ +""" + +uritemplate.variable +==================== + +This module contains the URIVariable class which powers the URITemplate class. + +What treasures await you: + +- URIVariable class + +You see a hammer in front of you. +What do you do? +> + +""" + +import collections +import sys + +if (2, 6) <= sys.version_info < (2, 8): + import urllib +elif (3, 3) <= sys.version_info < (4, 0): + import urllib.parse as urllib + + +class URIVariable(object): + + """This object validates everything inside the URITemplate object. + + It validates template expansions and will truncate length as decided by + the template. + + Please note that just like the :class:`URITemplate `, this + object's ``__str__`` and ``__repr__`` methods do not return the same + information. Calling ``str(var)`` will return the original variable. + + This object does the majority of the heavy lifting. The ``URITemplate`` + object finds the variables in the URI and then creates ``URIVariable`` + objects. Expansions of the URI are handled by each ``URIVariable`` + object. ``URIVariable.expand()`` returns a dictionary of the original + variable and the expanded value. Check that method's documentation for + more information. + + """ + + operators = ('+', '#', '.', '/', ';', '?', '&', '|', '!', '@') + reserved = ":/?#[]@!$&'()*+,;=" + + def __init__(self, var): + #: The original string that comes through with the variable + self.original = var + #: The operator for the variable + self.operator = '' + #: List of safe characters when quoting the string + self.safe = '' + #: List of variables in this variable + self.variables = [] + #: List of variable names + self.variable_names = [] + #: List of defaults passed in + self.defaults = {} + # Parse the variable itself. + self.parse() + self.post_parse() + + def __repr__(self): + return 'URIVariable(%s)' % self + + def __str__(self): + return self.original + + def parse(self): + """Parse the variable. + + This finds the: + - operator, + - set of safe characters, + - variables, and + - defaults. + + """ + var_list = self.original + if self.original[0] in URIVariable.operators: + self.operator = self.original[0] + var_list = self.original[1:] + + if self.operator in URIVariable.operators[:2]: + self.safe = URIVariable.reserved + + var_list = var_list.split(',') + + for var in var_list: + default_val = None + name = var + if '=' in var: + name, default_val = tuple(var.split('=', 1)) + + explode = False + if name.endswith('*'): + explode = True + name = name[:-1] + + prefix = None + if ':' in name: + name, prefix = tuple(name.split(':', 1)) + prefix = int(prefix) + + if default_val: + self.defaults[name] = default_val + + self.variables.append( + (name, {'explode': explode, 'prefix': prefix}) + ) + + self.variable_names = [varname for (varname, _) in self.variables] + + def post_parse(self): + """Set ``start``, ``join_str`` and ``safe`` attributes. + + After parsing the variable, we need to set up these attributes and it + only makes sense to do it in a more easily testable way. + """ + self.safe = '' + self.start = self.join_str = self.operator + if self.operator == '+': + self.start = '' + if self.operator in ('+', '#', ''): + self.join_str = ',' + if self.operator == '#': + self.start = '#' + if self.operator == '?': + self.start = '?' + self.join_str = '&' + + if self.operator in ('+', '#'): + self.safe = URIVariable.reserved + + def _query_expansion(self, name, value, explode, prefix): + """Expansion method for the '?' and '&' operators.""" + if value is None: + return None + + tuples, items = is_list_of_tuples(value) + + safe = self.safe + if list_test(value) and not tuples: + if not value: + return None + if explode: + return self.join_str.join( + '%s=%s' % (name, quote(v, safe)) for v in value + ) + else: + value = ','.join(quote(v, safe) for v in value) + return '%s=%s' % (name, value) + + if dict_test(value) or tuples: + if not value: + return None + items = items or sorted(value.items()) + if explode: + return self.join_str.join( + '%s=%s' % ( + quote(k, safe), quote(v, safe) + ) for k, v in items + ) + else: + value = ','.join( + '%s,%s' % ( + quote(k, safe), quote(v, safe) + ) for k, v in items + ) + return '%s=%s' % (name, value) + + if value: + value = value[:prefix] if prefix else value + return '%s=%s' % (name, quote(value, safe)) + return name + '=' + + def _label_path_expansion(self, name, value, explode, prefix): + """Label and path expansion method. + + Expands for operators: '/', '.' + + """ + join_str = self.join_str + safe = self.safe + + if value is None or (len(value) == 0 and value != ''): + return None + + tuples, items = is_list_of_tuples(value) + + if list_test(value) and not tuples: + if not explode: + join_str = ',' + + expanded = join_str.join( + quote(v, safe) for v in value if value is not None + ) + return expanded if expanded else None + + if dict_test(value) or tuples: + items = items or sorted(value.items()) + format_str = '%s=%s' + if not explode: + format_str = '%s,%s' + join_str = ',' + + expanded = join_str.join( + format_str % ( + quote(k, safe), quote(v, safe) + ) for k, v in items if v is not None + ) + return expanded if expanded else None + + value = value[:prefix] if prefix else value + return quote(value, safe) + + def _semi_path_expansion(self, name, value, explode, prefix): + """Expansion method for ';' operator.""" + join_str = self.join_str + safe = self.safe + + if value is None: + return None + + if self.operator == '?': + join_str = '&' + + tuples, items = is_list_of_tuples(value) + + if list_test(value) and not tuples: + if explode: + expanded = join_str.join( + '%s=%s' % ( + name, quote(v, safe) + ) for v in value if v is not None + ) + return expanded if expanded else None + else: + value = ','.join(quote(v, safe) for v in value) + return '%s=%s' % (name, value) + + if dict_test(value) or tuples: + items = items or sorted(value.items()) + + if explode: + return join_str.join( + '%s=%s' % ( + quote(k, safe), quote(v, safe) + ) for k, v in items if v is not None + ) + else: + expanded = ','.join( + '%s,%s' % ( + quote(k, safe), quote(v, safe) + ) for k, v in items if v is not None + ) + return '%s=%s' % (name, expanded) + + value = value[:prefix] if prefix else value + if value: + return '%s=%s' % (name, quote(value, safe)) + + return name + + def _string_expansion(self, name, value, explode, prefix): + if value is None: + return None + + tuples, items = is_list_of_tuples(value) + + if list_test(value) and not tuples: + return ','.join(quote(v, self.safe) for v in value) + + if dict_test(value) or tuples: + items = items or sorted(value.items()) + format_str = '%s=%s' if explode else '%s,%s' + + return ','.join( + format_str % ( + quote(k, self.safe), quote(v, self.safe) + ) for k, v in items + ) + + value = value[:prefix] if prefix else value + return quote(value, self.safe) + + def expand(self, var_dict=None): + """Expand the variable in question. + + Using ``var_dict`` and the previously parsed defaults, expand this + variable and subvariables. + + :param dict var_dict: dictionary of key-value pairs to be used during + expansion + :returns: dict(variable=value) + + Examples:: + + # (1) + v = URIVariable('/var') + expansion = v.expand({'var': 'value'}) + print(expansion) + # => {'/var': '/value'} + + # (2) + v = URIVariable('?var,hello,x,y') + expansion = v.expand({'var': 'value', 'hello': 'Hello World!', + 'x': '1024', 'y': '768'}) + print(expansion) + # => {'?var,hello,x,y': + # '?var=value&hello=Hello%20World%21&x=1024&y=768'} + + """ + return_values = [] + + for name, opts in self.variables: + value = var_dict.get(name, None) + if not value and value != '' and name in self.defaults: + value = self.defaults[name] + + if value is None: + continue + + expanded = None + if self.operator in ('/', '.'): + expansion = self._label_path_expansion + elif self.operator in ('?', '&'): + expansion = self._query_expansion + elif self.operator == ';': + expansion = self._semi_path_expansion + else: + expansion = self._string_expansion + + expanded = expansion(name, value, opts['explode'], opts['prefix']) + + if expanded is not None: + return_values.append(expanded) + + value = '' + if return_values: + value = self.start + self.join_str.join(return_values) + return {self.original: value} + + +def is_list_of_tuples(value): + if (not value or + not isinstance(value, (list, tuple)) or + not all(isinstance(t, tuple) and len(t) == 2 for t in value)): + return False, None + + return True, value + + +def list_test(value): + return isinstance(value, (list, tuple)) + + +def dict_test(value): + return isinstance(value, (dict, collections.MutableMapping)) + + +try: + texttype = unicode +except NameError: # Python 3 + texttype = str + +stringlikes = (texttype, bytes) + + +def _encode(value, encoding='utf-8'): + if (isinstance(value, texttype) and + getattr(value, 'encode', None) is not None): + return value.encode(encoding) + return value + + +def quote(value, safe): + if not isinstance(value, stringlikes): + value = str(value) + return urllib.quote(_encode(value), safe)