Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add functionality to optionally disable path encoding #98

Merged
merged 9 commits into from
Oct 10, 2022
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
- [Installation](#installation)
- [Usage](#usage)
- [Signed URLs](#signed-urls)
- [Disabled Path Encoding](#disabled-path-encoding)
- [Srcset Generation](#srcset-generation)
* [Fixed-Width Images](#fixed-width-images)
+ [Variable Quality](#variable-quality)
Expand Down Expand Up @@ -69,6 +70,27 @@ To produce a signed URL, you must enable secure URLs on your source and then pro

```

## Disabled Path Encoding

Path encoding is enabled by default. It can be toggled off by setting `disable_path_encoding` to `True` in the optional `options` paramater in `create_url()` and `create_srcset()` functions:

```python
>>> from imgix import UrlBuilder
>>> ub = UrlBuilder("sdk-test.imgix.net")
>>> ub.create_url(" <>[]{}|^%.jpg", params={'w': 100, 'h': 100}, options={'disable_path_encoding': True})
'https://sdk-test.imgix.net/ <>[]{}|^%.jpg?h=100&w=100'
```

Normally this would output a source URL like `https://demo.imgix.net/%20%3C%3E%5B%5D%7B%7D%7C%5E%25.jpg?h=100&2=100`, but since path encoding is disabled, it will output a source URL like `https://sdk-test.imgix.net/ <>[]{}|^%.jpg?h=100&w=100`.

```python
>>> from imgix import UrlBuilder
>>> ub = UrlBuilder("sdk-test.imgix.net")
>>> ub.create_srcset("image<>[]{} 123.png", widths=[100], options={'disable_path_encoding': True})
'https://sdk-test.imgix.net/image<>[]{} 123.png?w=100 100w'
```
Normally this would output a source URL like `https://sdk-test.imgix.net/image%3C%3E%5B%5D%7B%7D%20123.png?&w=100 100w`, but since path encoding is disabled, it will output a source URL like `https://sdk-test.imgix.net//image<>[]{} 123.png?w=100 100w`.

## Srcset Generation

The imgix-python package allows for generation of custom srcset attributes, which can be invoked through the `create_srcset` method. By default, the generated srcset will allow for responsive size switching by building a list of image-width mappings.
Expand Down
120 changes: 74 additions & 46 deletions imgix/urlbuilder.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,8 @@ class UrlBuilder(object):
"""

def __init__(
self,
domain,
use_https=True,
sign_key=None,
include_library_param=True):
self, domain, use_https=True, sign_key=None, include_library_param=True
luqven marked this conversation as resolved.
Show resolved Hide resolved
):

self.validate_domain(domain)

Expand All @@ -82,16 +79,18 @@ def validate_domain(self, domain):
"""

err_str = str(
'Domain must be passed in as fully-qualified domain names and ' +
'should not include a protocol or any path element, i.e. ' +
'"example.imgix.net".')
"Domain must be passed in as fully-qualified domain names and "
+ "should not include a protocol or any path element, i.e. "
+ '"example.imgix.net".'
)

if re.match(DOMAIN_PATTERN, domain) is None:
raise ValueError(err_str)

def create_url(self, path="", params={}):
def create_url(self, path="", params={}, options={}):
"""
Create URL with supplied path and `params` parameters dict.
Create URL with supplied path, `params` parameters dict
and optional `options` dict.

Parameters
----------
Expand All @@ -101,13 +100,19 @@ def create_url(self, path="", params={}):
added to the URL unprocessed. For a complete list of imgix
supported parameters, visit https://docs.imgix.com/apis/url .
(default None)
options : dict
Dictionary specifying URL options such as disabled_path_encoding.
(default None)
luqven marked this conversation as resolved.
Show resolved Hide resolved

Returns
-------
str
imgix URL
"""
sanitized_path = self._sanitize_path(path)
disable_path_encoding = options.get("disable_path_encoding", False)
sanitized_path = self._sanitize_path(
path, options={"disable_path_encoding": disable_path_encoding}
)

query_string = self._build_params(params)

Expand All @@ -116,12 +121,9 @@ def create_url(self, path="", params={}):

scheme = "https" if self._use_https else "http"

return scheme + "://" \
+ self._domain \
+ sanitized_path \
+ query_string
return scheme + "://" + self._domain + sanitized_path + query_string
luqven marked this conversation as resolved.
Show resolved Hide resolved

def _sanitize_path(self, path):
def _sanitize_path(self, path, options={}):
if not path:
return ""

Expand All @@ -133,7 +135,9 @@ def _sanitize_path(self, path):

# Encode the path without a leading forward slash,
# then add it back before returning.
if _path.startswith("http"):
if options["disable_path_encoding"]:
return "/" + _path
elif _path.startswith("http"):
return "/" + self._encode_proxy_path(_path)
else:
return "/" + self._encode_file_path(_path)
Expand All @@ -159,25 +163,28 @@ def _build_params(self, params):
# First we call encode on v to get a bytes-like object,
# then after replacing any padding characters that may
# be present, we call decode to get back a string object.
_params[k] = urlsafe_b64encode(
v.encode('utf-8')).replace(b"=", b'').decode("utf-8")
_params[k] = (
urlsafe_b64encode(v.encode("utf-8"))
.replace(b"=", b"")
.decode("utf-8")
)
else:
# quote_plus will encode SPACE (' ') as PLUS (+). If a
# PLUS (+) is present, e.g. "Futura+Condensed Medium",
# it will be encoded as "Futura%2BCondensed+Medium".
_params[k] = quote_plus(v).replace("+", "%20")

query_string = [f'{k}={_params[k]}' for k in sorted(_params.keys())]
query_string = [f"{k}={_params[k]}" for k in sorted(_params.keys())]
delimeter = "?" if query_string else ""
return delimeter + "&".join(query_string)

def _sign_url(self, prefixed_path, query_string):
signature_base = self._sign_key + prefixed_path + query_string
signature = hashlib.md5(signature_base.encode('utf-8')).hexdigest()
signature = hashlib.md5(signature_base.encode("utf-8")).hexdigest()
delimeter = "&s=" if query_string else "?s="
return query_string + delimeter + signature

def create_srcset(self, path, params={}, **kwargs):
def create_srcset(self, path, params={}, options={}, **kwargs):
"""
Create a srcset attribute.

Expand Down Expand Up @@ -215,6 +222,9 @@ def create_srcset(self, path, params={}, **kwargs):
Parameters that will be transformed into query parameters,
including 'w' or 'ar' and 'h' if generating a pixel density
described srcset, {} by default.
options: dict, optional
Options that will be used to generate the srcset,
including 'disable_path_encoding', {} by default.
start : int, optional
Starting minimum width value, MIN_WIDTH by default.
stop : int, optional
Expand All @@ -228,16 +238,18 @@ def create_srcset(self, path, params={}, **kwargs):
str
Srcset attribute string.
"""
widths_list = kwargs.get('widths', None)
widths_list = kwargs.get("widths", None)
if widths_list:
validate_widths(widths_list)
return self._build_srcset_pairs(path, params, targets=widths_list)
return self._build_srcset_pairs(
path, params, options, targets=widths_list
)

# Attempt to assign `start`, `stop`, and `tol` from `kwargs`.
# If the key does not exist, assign `None`.
start = kwargs.get('start', None)
stop = kwargs.get('stop', None)
tol = kwargs.get('tol', None)
start = kwargs.get("start", None)
stop = kwargs.get("stop", None)
tol = kwargs.get("tol", None)

# Attempt to generate the specified target widths.
# Assign defaults where appropriate, then validate
Expand All @@ -252,35 +264,47 @@ def create_srcset(self, path, params={}, **kwargs):

validate_min_max_tol(start, stop, tol)

targets = \
target_widths(start=start, stop=stop, tol=tol)
targets = target_widths(start=start, stop=stop, tol=tol)

if 'w' in params or 'h' in params:
disable_variable_quality = \
kwargs.get('disable_variable_quality', False)
if "w" in params or "h" in params:
disable_variable_quality = kwargs.get(
"disable_variable_quality", False
)
return self._build_srcset_DPR(
path,
params,
disable_variable_quality=disable_variable_quality)
options,
disable_variable_quality=disable_variable_quality,
)
else:
return self._build_srcset_pairs(path, params, targets)
return self._build_srcset_pairs(path, params, options, targets)

def _build_srcset_pairs(
self, path, params, targets=TARGET_WIDTHS):
self, path, params, options, targets=TARGET_WIDTHS
):
# prevents mutating the params dict
srcset_params = dict(params)
srcset_entries = []

for w in targets:
srcset_params['w'] = w
srcset_entries.append(self.create_url(path, srcset_params) +
" " + str(w) + "w")
srcset_params["w"] = w
srcset_entries.append(
self.create_url(path, srcset_params, options)
+ " "
+ str(w)
+ "w"
)

return ",\n".join(srcset_entries)

def _build_srcset_DPR(
self, path, params, targets=TARGET_RATIOS,
disable_variable_quality=False):
self,
path,
params,
options,
targets=TARGET_RATIOS,
disable_variable_quality=False,
):
# If variable quality output is _not disabled_, then output
# quality values, 'q', will vary in accordance with the
# default `DPR_QUALITIES` [1x => q=75, ... 5x => 20].
Expand All @@ -299,14 +323,18 @@ def _build_srcset_DPR(
srcset_entries = []

for dpr in targets:
srcset_params['dpr'] = dpr
srcset_params["dpr"] = dpr

if not disable_variable_quality:
quality = params.get('q', DPR_QUALITIES[dpr])
srcset_params['q'] = quality

srcset_entries.append(self.create_url(path, srcset_params) +
" " + str(dpr) + "x")
quality = params.get("q", DPR_QUALITIES[dpr])
srcset_params["q"] = quality

srcset_entries.append(
self.create_url(path, srcset_params, options)
+ " "
+ str(dpr)
+ "x"
)

return ",\n".join(srcset_entries)

Expand Down
26 changes: 26 additions & 0 deletions tests/test_srcset.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
DOMAIN = "testing.imgix.net"
TOKEN = "MYT0KEN"
JPG_PATH = "image.jpg"
JPG_PATH_WITH_SPACE = "image 123.jpg"


def extract_descriptors(srcset=""):
Expand Down Expand Up @@ -132,6 +133,31 @@ def test_create_srcset_100_to_108():
assert expected == actual


def test_create_srcset_with_widths_and_disable_path_encoding_true():
ub = imgix.UrlBuilder(DOMAIN, include_library_param=False)
actual = ub.create_srcset(
JPG_PATH_WITH_SPACE,
widths=[100],
options={"disable_path_encoding": True},
)
expected = "https://testing.imgix.net/image 123.jpg?w=100 100w"

assert expected == actual


def test_create_srcset_start_equals_stop_with_disable_path_encoding_true():
ub = imgix.UrlBuilder(DOMAIN, include_library_param=False)
actual = ub.create_srcset(
JPG_PATH_WITH_SPACE,
start=713,
stop=713,
options={"disable_path_encoding": True},
)
expected = "https://testing.imgix.net/image 123.jpg?w=713 713w"

assert expected == actual


def test_given_width_srcset_is_DPR():
srcset = _default_srcset({"w": 100})
device_pixel_ratio = 1
Expand Down
24 changes: 24 additions & 0 deletions tests/test_url_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,30 @@ def test_create_url_with_questionable_chars_in_path():
)


def test_create_url_with_unicode_path_and_disabled_path_encoding():
builder = _default_builder()
url = builder.create_url(
"ساندویچ.jpg", options={"disable_path_encoding": True}
)
assert url == "https://my-social-network.imgix.net/" "ساندویچ.jpg"


def test_create_url_with_spaces_brackets_in_path_and_disabled_path_encoding():
builder = _default_builder()
url = builder.create_url(
r" <>[]{}|\^%.jpg", options={"disable_path_encoding": True}
)
assert url == "https://my-social-network.imgix.net/" r" <>[]{}|\^%.jpg"


def test_create_url_with_special_chars_in_path_and_disabled_path_encoding():
builder = _default_builder()
url = builder.create_url(
"&$+,:;=?@#.jpg", options={"disable_path_encoding": True}
)
assert url == "https://my-social-network.imgix.net/" "&$+,:;=?@#.jpg"


def test_use_https():
# Defaults to https
builder = imgix.UrlBuilder("my-social-network.imgix.net")
Expand Down