|
@@ -6,16 +6,19 @@ import shlex
|
|
|
import socket
|
|
|
import subprocess
|
|
|
import time
|
|
|
-from typing import List
|
|
|
+from typing import Iterator, List, Optional
|
|
|
|
|
|
import backoff
|
|
|
-import docker
|
|
|
+import docker.errors
|
|
|
import pytest
|
|
|
import requests
|
|
|
-from _pytest._code.code import ReprExceptionInfo
|
|
|
-from packaging.version import Version
|
|
|
+from _pytest.fixtures import FixtureRequest
|
|
|
+from docker import DockerClient
|
|
|
from docker.models.containers import Container
|
|
|
-from requests.packages.urllib3.util.connection import HAS_IPV6
|
|
|
+from docker.models.networks import Network
|
|
|
+from packaging.version import Version
|
|
|
+from requests import Response
|
|
|
+from urllib3.util.connection import HAS_IPV6
|
|
|
|
|
|
logging.basicConfig(level=logging.INFO)
|
|
|
logging.getLogger('backoff').setLevel(logging.INFO)
|
|
@@ -40,8 +43,9 @@ test_container = 'nginx-proxy-pytest'
|
|
|
#
|
|
|
###############################################################################
|
|
|
|
|
|
+
|
|
|
@contextlib.contextmanager
|
|
|
-def ipv6(force_ipv6=True):
|
|
|
+def ipv6(force_ipv6: bool = True):
|
|
|
"""
|
|
|
Meant to be used as a context manager to force IPv6 sockets:
|
|
|
|
|
@@ -59,10 +63,10 @@ def ipv6(force_ipv6=True):
|
|
|
FORCE_CONTAINER_IPV6 = False
|
|
|
|
|
|
|
|
|
-class requests_for_docker(object):
|
|
|
+class RequestsForDocker:
|
|
|
"""
|
|
|
Proxy for calling methods of the requests module.
|
|
|
- When a HTTP response failed due to HTTP Error 404 or 502, retry a few times.
|
|
|
+ When an HTTP response failed due to HTTP Error 404 or 502, retry a few times.
|
|
|
Provides method `get_conf` to extract the nginx-proxy configuration content.
|
|
|
"""
|
|
|
def __init__(self):
|
|
@@ -71,7 +75,7 @@ class requests_for_docker(object):
|
|
|
self.session.verify = CA_ROOT_CERTIFICATE
|
|
|
|
|
|
@staticmethod
|
|
|
- def get_nginx_proxy_containers() -> List[Container]:
|
|
|
+ def get_nginx_proxy_container() -> Container:
|
|
|
"""
|
|
|
Return list of containers
|
|
|
"""
|
|
@@ -80,69 +84,69 @@ class requests_for_docker(object):
|
|
|
pytest.fail("Too many running nginxproxy/nginx-proxy:test containers", pytrace=False)
|
|
|
elif len(nginx_proxy_containers) == 0:
|
|
|
pytest.fail("No running nginxproxy/nginx-proxy:test container", pytrace=False)
|
|
|
- return nginx_proxy_containers
|
|
|
+ return nginx_proxy_containers.pop()
|
|
|
|
|
|
- def get_conf(self):
|
|
|
+ def get_conf(self) -> bytes:
|
|
|
"""
|
|
|
Return the nginx config file
|
|
|
"""
|
|
|
- nginx_proxy_containers = self.get_nginx_proxy_containers()
|
|
|
- return get_nginx_conf_from_container(nginx_proxy_containers[0])
|
|
|
+ nginx_proxy_container = self.get_nginx_proxy_container()
|
|
|
+ return get_nginx_conf_from_container(nginx_proxy_container)
|
|
|
|
|
|
def get_ip(self) -> str:
|
|
|
"""
|
|
|
Return the nginx container ip address
|
|
|
"""
|
|
|
- nginx_proxy_containers = self.get_nginx_proxy_containers()
|
|
|
- return container_ip(nginx_proxy_containers[0])
|
|
|
+ nginx_proxy_container = self.get_nginx_proxy_container()
|
|
|
+ return container_ip(nginx_proxy_container)
|
|
|
|
|
|
- def get(self, *args, **kwargs):
|
|
|
+ def get(self, *args, **kwargs) -> Response:
|
|
|
with ipv6(kwargs.pop('ipv6', False)):
|
|
|
@backoff.on_predicate(backoff.constant, lambda r: r.status_code in (404, 502), interval=.3, max_tries=30, jitter=None)
|
|
|
- def _get(*args, **kwargs):
|
|
|
- return self.session.get(*args, **kwargs)
|
|
|
+ def _get(*_args, **_kwargs):
|
|
|
+ return self.session.get(*_args, **_kwargs)
|
|
|
return _get(*args, **kwargs)
|
|
|
|
|
|
- def post(self, *args, **kwargs):
|
|
|
+ def post(self, *args, **kwargs) -> Response:
|
|
|
with ipv6(kwargs.pop('ipv6', False)):
|
|
|
@backoff.on_predicate(backoff.constant, lambda r: r.status_code in (404, 502), interval=.3, max_tries=30, jitter=None)
|
|
|
- def _post(*args, **kwargs):
|
|
|
- return self.session.post(*args, **kwargs)
|
|
|
+ def _post(*_args, **_kwargs):
|
|
|
+ return self.session.post(*_args, **_kwargs)
|
|
|
return _post(*args, **kwargs)
|
|
|
|
|
|
- def put(self, *args, **kwargs):
|
|
|
+ def put(self, *args, **kwargs) -> Response:
|
|
|
with ipv6(kwargs.pop('ipv6', False)):
|
|
|
@backoff.on_predicate(backoff.constant, lambda r: r.status_code in (404, 502), interval=.3, max_tries=30, jitter=None)
|
|
|
- def _put(*args, **kwargs):
|
|
|
- return self.session.put(*args, **kwargs)
|
|
|
+ def _put(*_args, **_kwargs):
|
|
|
+ return self.session.put(*_args, **_kwargs)
|
|
|
return _put(*args, **kwargs)
|
|
|
|
|
|
- def head(self, *args, **kwargs):
|
|
|
+ def head(self, *args, **kwargs) -> Response:
|
|
|
with ipv6(kwargs.pop('ipv6', False)):
|
|
|
@backoff.on_predicate(backoff.constant, lambda r: r.status_code in (404, 502), interval=.3, max_tries=30, jitter=None)
|
|
|
- def _head(*args, **kwargs):
|
|
|
- return self.session.head(*args, **kwargs)
|
|
|
+ def _head(*_args, **_kwargs):
|
|
|
+ return self.session.head(*_args, **_kwargs)
|
|
|
return _head(*args, **kwargs)
|
|
|
|
|
|
- def delete(self, *args, **kwargs):
|
|
|
+ def delete(self, *args, **kwargs) -> Response:
|
|
|
with ipv6(kwargs.pop('ipv6', False)):
|
|
|
@backoff.on_predicate(backoff.constant, lambda r: r.status_code in (404, 502), interval=.3, max_tries=30, jitter=None)
|
|
|
- def _delete(*args, **kwargs):
|
|
|
- return self.session.delete(*args, **kwargs)
|
|
|
+ def _delete(*_args, **_kwargs):
|
|
|
+ return self.session.delete(*_args, **_kwargs)
|
|
|
return _delete(*args, **kwargs)
|
|
|
|
|
|
- def options(self, *args, **kwargs):
|
|
|
+ def options(self, *args, **kwargs) -> Response:
|
|
|
with ipv6(kwargs.pop('ipv6', False)):
|
|
|
@backoff.on_predicate(backoff.constant, lambda r: r.status_code in (404, 502), interval=.3, max_tries=30, jitter=None)
|
|
|
- def _options(*args, **kwargs):
|
|
|
- return self.session.options(*args, **kwargs)
|
|
|
+ def _options(*_args, **_kwargs):
|
|
|
+ return self.session.options(*_args, **_kwargs)
|
|
|
return _options(*args, **kwargs)
|
|
|
|
|
|
def __getattr__(self, name):
|
|
|
return getattr(requests, name)
|
|
|
|
|
|
|
|
|
-def container_ip(container: Container):
|
|
|
+def container_ip(container: Container) -> str:
|
|
|
"""
|
|
|
return the IP address of a container.
|
|
|
|
|
@@ -171,7 +175,7 @@ def container_ip(container: Container):
|
|
|
return net_info[network_name]["IPAddress"]
|
|
|
|
|
|
|
|
|
-def container_ipv6(container):
|
|
|
+def container_ipv6(container: Container) -> str:
|
|
|
"""
|
|
|
return the IPv6 address of a container.
|
|
|
"""
|
|
@@ -188,7 +192,7 @@ def container_ipv6(container):
|
|
|
return net_info[network_name]["GlobalIPv6Address"]
|
|
|
|
|
|
|
|
|
-def nginx_proxy_dns_resolver(domain_name):
|
|
|
+def nginx_proxy_dns_resolver(domain_name: str) -> Optional[str]:
|
|
|
"""
|
|
|
if "nginx-proxy" if found in host, return the ip address of the docker container
|
|
|
issued from the docker image nginxproxy/nginx-proxy:test.
|
|
@@ -200,18 +204,18 @@ def nginx_proxy_dns_resolver(domain_name):
|
|
|
if 'nginx-proxy' in domain_name:
|
|
|
nginxproxy_containers = docker_client.containers.list(filters={"status": "running", "ancestor": "nginxproxy/nginx-proxy:test"})
|
|
|
if len(nginxproxy_containers) == 0:
|
|
|
- log.warn(f"no container found from image nginxproxy/nginx-proxy:test while resolving {domain_name!r}")
|
|
|
+ log.warning(f"no container found from image nginxproxy/nginx-proxy:test while resolving {domain_name!r}")
|
|
|
exited_nginxproxy_containers = docker_client.containers.list(filters={"status": "exited", "ancestor": "nginxproxy/nginx-proxy:test"})
|
|
|
if len(exited_nginxproxy_containers) > 0:
|
|
|
exited_nginxproxy_container_logs = exited_nginxproxy_containers[0].logs()
|
|
|
- log.warn(f"nginxproxy/nginx-proxy:test container might have exited unexpectedly. Container logs: " + "\n" + exited_nginxproxy_container_logs.decode())
|
|
|
- return
|
|
|
+ log.warning(f"nginxproxy/nginx-proxy:test container might have exited unexpectedly. Container logs: " + "\n" + exited_nginxproxy_container_logs.decode())
|
|
|
+ return None
|
|
|
nginxproxy_container = nginxproxy_containers[0]
|
|
|
ip = container_ip(nginxproxy_container)
|
|
|
log.info(f"resolving domain name {domain_name!r} as IP address {ip} of nginx-proxy container {nginxproxy_container.name}")
|
|
|
return ip
|
|
|
|
|
|
-def docker_container_dns_resolver(domain_name):
|
|
|
+def docker_container_dns_resolver(domain_name: str) -> Optional[str]:
|
|
|
"""
|
|
|
if domain name is of the form "XXX.container.docker" or "anything.XXX.container.docker", return the ip address of the docker container
|
|
|
named XXX.
|
|
@@ -224,15 +228,15 @@ def docker_container_dns_resolver(domain_name):
|
|
|
match = re.search(r'(^|.+\.)(?P<container>[^.]+)\.container\.docker$', domain_name)
|
|
|
if not match:
|
|
|
log.debug(f"{domain_name!r} does not match")
|
|
|
- return
|
|
|
+ return None
|
|
|
|
|
|
container_name = match.group('container')
|
|
|
log.debug(f"looking for container {container_name!r}")
|
|
|
try:
|
|
|
container = docker_client.containers.get(container_name)
|
|
|
except docker.errors.NotFound:
|
|
|
- log.warn(f"container named {container_name!r} not found while resolving {domain_name!r}")
|
|
|
- return
|
|
|
+ log.warning(f"container named {container_name!r} not found while resolving {domain_name!r}")
|
|
|
+ return None
|
|
|
log.debug(f"container {container.name!r} found ({container.short_id})")
|
|
|
|
|
|
ip = container_ip(container)
|
|
@@ -252,7 +256,7 @@ def monkey_patch_urllib_dns_resolver():
|
|
|
logging.getLogger('DNS').debug(f"resolving domain name {repr(args)}")
|
|
|
_args = list(args)
|
|
|
|
|
|
- # Fail early when querying IP directly and it is forced ipv6 when not supported,
|
|
|
+ # Fail early when querying IP directly, and it is forced ipv6 when not supported,
|
|
|
# Otherwise a pytest container not using the host network fails to pass `test_raw-ip-vhost`.
|
|
|
if FORCE_CONTAINER_IPV6 and not HAS_IPV6:
|
|
|
pytest.skip("This system does not support IPv6")
|
|
@@ -274,19 +278,12 @@ def monkey_patch_urllib_dns_resolver():
|
|
|
socket.getaddrinfo = new_getaddrinfo
|
|
|
return prv_getaddrinfo
|
|
|
|
|
|
+
|
|
|
def restore_urllib_dns_resolver(getaddrinfo_func):
|
|
|
socket.getaddrinfo = getaddrinfo_func
|
|
|
|
|
|
|
|
|
-def remove_all_containers():
|
|
|
- for container in docker_client.containers.list(all=True):
|
|
|
- if PYTEST_RUNNING_IN_CONTAINER and container.name == test_container:
|
|
|
- continue # pytest is running within a Docker container, so we do not want to remove that particular container
|
|
|
- logging.info(f"removing container {container.name}")
|
|
|
- container.remove(v=True, force=True)
|
|
|
-
|
|
|
-
|
|
|
-def get_nginx_conf_from_container(container):
|
|
|
+def get_nginx_conf_from_container(container: Container) -> bytes:
|
|
|
"""
|
|
|
return the nginx /etc/nginx/conf.d/default.conf file content from a container
|
|
|
"""
|
|
@@ -301,20 +298,20 @@ def get_nginx_conf_from_container(container):
|
|
|
return conffile.read()
|
|
|
|
|
|
|
|
|
-def docker_compose_up(compose_file='docker-compose.yml'):
|
|
|
+def docker_compose_up(compose_file: str):
|
|
|
logging.info(f'{DOCKER_COMPOSE} -f {compose_file} up -d')
|
|
|
try:
|
|
|
subprocess.check_output(shlex.split(f'{DOCKER_COMPOSE} -f {compose_file} up -d'), stderr=subprocess.STDOUT)
|
|
|
except subprocess.CalledProcessError as e:
|
|
|
- pytest.fail(f"Error while runninng '{DOCKER_COMPOSE} -f {compose_file} up -d':\n{e.output}", pytrace=False)
|
|
|
+ pytest.fail(f"Error while running '{DOCKER_COMPOSE} -f {compose_file} up -d':\n{e.output}", pytrace=False)
|
|
|
|
|
|
|
|
|
-def docker_compose_down(compose_file='docker-compose.yml'):
|
|
|
+def docker_compose_down(compose_file: str):
|
|
|
logging.info(f'{DOCKER_COMPOSE} -f {compose_file} down -v')
|
|
|
try:
|
|
|
subprocess.check_output(shlex.split(f'{DOCKER_COMPOSE} -f {compose_file} down -v'), stderr=subprocess.STDOUT)
|
|
|
except subprocess.CalledProcessError as e:
|
|
|
- pytest.fail(f"Error while runninng '{DOCKER_COMPOSE} -f {compose_file} down -v':\n{e.output}", pytrace=False)
|
|
|
+ pytest.fail(f"Error while running '{DOCKER_COMPOSE} -f {compose_file} down -v':\n{e.output}", pytrace=False)
|
|
|
|
|
|
|
|
|
def wait_for_nginxproxy_to_be_ready():
|
|
@@ -333,7 +330,7 @@ def wait_for_nginxproxy_to_be_ready():
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
-def docker_compose_file(request):
|
|
|
+def docker_compose_file(request: FixtureRequest) -> Iterator[Optional[str]]:
|
|
|
"""Fixture naming the docker compose file to consider.
|
|
|
|
|
|
If a YAML file exists with the same name as the test module (with the `.py` extension replaced
|
|
@@ -343,25 +340,28 @@ def docker_compose_file(request):
|
|
|
Tests can override this fixture to specify a custom location.
|
|
|
"""
|
|
|
test_module_dir = os.path.dirname(request.module.__file__)
|
|
|
- yml_file = os.path.join(test_module_dir, request.module.__name__ + '.yml')
|
|
|
- yaml_file = os.path.join(test_module_dir, request.module.__name__ + '.yaml')
|
|
|
+ yml_file = os.path.join(test_module_dir, f"{request.module.__name__}.yml")
|
|
|
+ yaml_file = os.path.join(test_module_dir, f"{request.module.__name__}.yaml")
|
|
|
default_file = os.path.join(test_module_dir, 'docker-compose.yml')
|
|
|
|
|
|
+ docker_compose_file = None
|
|
|
+
|
|
|
if os.path.isfile(yml_file):
|
|
|
docker_compose_file = yml_file
|
|
|
elif os.path.isfile(yaml_file):
|
|
|
docker_compose_file = yaml_file
|
|
|
- else:
|
|
|
+ elif os.path.isfile(default_file):
|
|
|
docker_compose_file = default_file
|
|
|
|
|
|
- if not os.path.isfile(docker_compose_file):
|
|
|
+ if docker_compose_file is None:
|
|
|
logging.error("Could not find any docker compose file named either '{0}.yml', '{0}.yaml' or 'docker-compose.yml'".format(request.module.__name__))
|
|
|
+ else:
|
|
|
+ logging.debug(f"using docker compose file {docker_compose_file}")
|
|
|
|
|
|
- logging.debug(f"using docker compose file {docker_compose_file}")
|
|
|
- return docker_compose_file
|
|
|
+ yield docker_compose_file
|
|
|
|
|
|
|
|
|
-def connect_to_network(network):
|
|
|
+def connect_to_network(network: Network) -> Optional[Network]:
|
|
|
"""
|
|
|
If we are running from a container, connect our container to the given network
|
|
|
|
|
@@ -371,8 +371,8 @@ def connect_to_network(network):
|
|
|
try:
|
|
|
my_container = docker_client.containers.get(test_container)
|
|
|
except docker.errors.NotFound:
|
|
|
- logging.warn(f"container {test_container} not found")
|
|
|
- return
|
|
|
+ logging.warning(f"container {test_container} not found")
|
|
|
+ return None
|
|
|
|
|
|
# figure out our container networks
|
|
|
my_networks = list(my_container.attrs["NetworkSettings"]["Networks"].keys())
|
|
@@ -389,7 +389,7 @@ def connect_to_network(network):
|
|
|
return network
|
|
|
|
|
|
|
|
|
-def disconnect_from_network(network=None):
|
|
|
+def disconnect_from_network(network: Network = None):
|
|
|
"""
|
|
|
If we are running from a container, disconnect our container from the given network.
|
|
|
|
|
@@ -399,7 +399,7 @@ def disconnect_from_network(network=None):
|
|
|
try:
|
|
|
my_container = docker_client.containers.get(test_container)
|
|
|
except docker.errors.NotFound:
|
|
|
- logging.warn(f"container {test_container} not found")
|
|
|
+ logging.warning(f"container {test_container} not found")
|
|
|
return
|
|
|
|
|
|
# figure out our container networks
|
|
@@ -411,7 +411,7 @@ def disconnect_from_network(network=None):
|
|
|
network.disconnect(my_container)
|
|
|
|
|
|
|
|
|
-def connect_to_all_networks():
|
|
|
+def connect_to_all_networks() -> List[Network]:
|
|
|
"""
|
|
|
If we are running from a container, connect our container to all current docker networks.
|
|
|
|
|
@@ -427,6 +427,7 @@ def connect_to_all_networks():
|
|
|
|
|
|
class DockerComposer(contextlib.AbstractContextManager):
|
|
|
def __init__(self):
|
|
|
+ self._networks = None
|
|
|
self._docker_compose_file = None
|
|
|
|
|
|
def __exit__(self, *exc_info):
|
|
@@ -440,13 +441,12 @@ class DockerComposer(contextlib.AbstractContextManager):
|
|
|
docker_compose_down(self._docker_compose_file)
|
|
|
self._docker_compose_file = None
|
|
|
|
|
|
- def compose(self, docker_compose_file):
|
|
|
+ def compose(self, docker_compose_file: Optional[str]):
|
|
|
if docker_compose_file == self._docker_compose_file:
|
|
|
return
|
|
|
self._down()
|
|
|
if docker_compose_file is None:
|
|
|
return
|
|
|
- remove_all_containers()
|
|
|
docker_compose_up(docker_compose_file)
|
|
|
self._networks = connect_to_all_networks()
|
|
|
wait_for_nginxproxy_to_be_ready()
|
|
@@ -462,14 +462,14 @@ class DockerComposer(contextlib.AbstractContextManager):
|
|
|
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
|
-def docker_composer():
|
|
|
+def docker_composer() -> Iterator[DockerComposer]:
|
|
|
with DockerComposer() as d:
|
|
|
yield d
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
-def ca_root_certificate():
|
|
|
- return CA_ROOT_CERTIFICATE
|
|
|
+def ca_root_certificate() -> Iterator[str]:
|
|
|
+ yield CA_ROOT_CERTIFICATE
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
@@ -480,7 +480,7 @@ def monkey_patched_dns():
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
-def docker_compose(monkey_patched_dns, docker_composer, docker_compose_file):
|
|
|
+def docker_compose(monkey_patched_dns, docker_composer, docker_compose_file) -> Iterator[DockerClient]:
|
|
|
"""Ensures containers described in a docker compose file are started.
|
|
|
|
|
|
A custom docker compose file name can be specified by overriding the `docker_compose_file`
|
|
@@ -493,12 +493,12 @@ def docker_compose(monkey_patched_dns, docker_composer, docker_compose_file):
|
|
|
yield docker_client
|
|
|
|
|
|
|
|
|
-@pytest.fixture()
|
|
|
-def nginxproxy():
|
|
|
+@pytest.fixture
|
|
|
+def nginxproxy() -> Iterator[RequestsForDocker]:
|
|
|
"""
|
|
|
Provides the `nginxproxy` object that can be used in the same way the requests module is:
|
|
|
|
|
|
- r = nginxproxy.get("http://foo.com")
|
|
|
+ r = nginxproxy.get("https://foo.com")
|
|
|
|
|
|
The difference is that in case an HTTP requests has status code 404 or 502 (which mostly
|
|
|
indicates that nginx has just reloaded), we retry up to 30 times the query.
|
|
@@ -507,15 +507,15 @@ def nginxproxy():
|
|
|
made against containers to use the containers IPv6 address when set to `True`. If IPv6 is not
|
|
|
supported by the system or docker, that particular test will be skipped.
|
|
|
"""
|
|
|
- yield requests_for_docker()
|
|
|
+ yield RequestsForDocker()
|
|
|
|
|
|
|
|
|
-@pytest.fixture()
|
|
|
-def acme_challenge_path():
|
|
|
+@pytest.fixture
|
|
|
+def acme_challenge_path() -> Iterator[str]:
|
|
|
"""
|
|
|
Provides fake Let's Encrypt ACME challenge path used in certain tests
|
|
|
"""
|
|
|
- return ".well-known/acme-challenge/test-filename"
|
|
|
+ yield ".well-known/acme-challenge/test-filename"
|
|
|
|
|
|
###############################################################################
|
|
|
#
|
|
@@ -523,14 +523,13 @@ def acme_challenge_path():
|
|
|
#
|
|
|
###############################################################################
|
|
|
|
|
|
-# pytest hook to display additionnal stuff in test report
|
|
|
+# pytest hook to display additional stuff in test report
|
|
|
def pytest_runtest_logreport(report):
|
|
|
if report.failed:
|
|
|
- if isinstance(report.longrepr, ReprExceptionInfo):
|
|
|
- test_containers = docker_client.containers.list(all=True, filters={"ancestor": "nginxproxy/nginx-proxy:test"})
|
|
|
- for container in test_containers:
|
|
|
- report.longrepr.addsection('nginx-proxy logs', container.logs())
|
|
|
- report.longrepr.addsection('nginx-proxy conf', get_nginx_conf_from_container(container))
|
|
|
+ test_containers = docker_client.containers.list(all=True, filters={"ancestor": "nginxproxy/nginx-proxy:test"})
|
|
|
+ for container in test_containers:
|
|
|
+ report.longrepr.addsection('nginx-proxy logs', container.logs().decode())
|
|
|
+ report.longrepr.addsection('nginx-proxy conf', get_nginx_conf_from_container(container).decode())
|
|
|
|
|
|
|
|
|
# Py.test `incremental` marker, see http://stackoverflow.com/a/12579625/107049
|