Repository: mkdocs/mkdocs

Issue:
Support IPv6
IPv4 is old, IPv6 is current. `mkdocs  serve -a '[::0]:8081'` fails with the following error:

```
INFO    -  Building documentation... 
INFO    -  Cleaning site directory 
Traceback (most recent call last):
  File "/home/sybren/.virtualenvs/flamenco/bin/mkdocs", line 11, in <module>
    sys.exit(cli())
  File "/home/sybren/.virtualenvs/flamenco/lib/python3.6/site-packages/click/core.py", line 722, in __call__
    return self.main(*args, **kwargs)
  File "/home/sybren/.virtualenvs/flamenco/lib/python3.6/site-packages/click/core.py", line 697, in main
    rv = self.invoke(ctx)
  File "/home/sybren/.virtualenvs/flamenco/lib/python3.6/site-packages/click/core.py", line 1066, in invoke
    return _process_result(sub_ctx.command.invoke(sub_ctx))
  File "/home/sybren/.virtualenvs/flamenco/lib/python3.6/site-packages/click/core.py", line 895, in invoke
    return ctx.invoke(self.callback, **ctx.params)
  File "/home/sybren/.virtualenvs/flamenco/lib/python3.6/site-packages/click/core.py", line 535, in invoke
    return callback(*args, **kwargs)
  File "/home/sybren/.virtualenvs/flamenco/lib/python3.6/site-packages/mkdocs/__main__.py", line 127, in serve_command
    livereload=livereload
  File "/home/sybren/.virtualenvs/flamenco/lib/python3.6/site-packages/mkdocs/commands/serve.py", line 116, in serve
    _livereload(host, port, config, builder, tempdir)
  File "/home/sybren/.virtualenvs/flamenco/lib/python3.6/site-packages/mkdocs/commands/serve.py", line 55, in _livereload
    server.serve(root=site_dir, host=host, port=int(port), restart_delay=0)
ValueError: invalid literal for int() with base 10: ':0]:8081'
```

Tested with mkdocs-0.16.3 on Python 3.6.

Instead of doing `config['dev_addr'].split(':', 1)` (serve.py:112), please consider using the [ipaddress](https://docs.python.org/3/library/ipaddress.html) module from the standard Python library.



Discussion:
The server is intended for development purposes only and it not intended to be used to serve your site to the world. It's just a "localhost" server. The only reason the user can specify an IP/port is to avoid conflicts with other servers which might be running on the same machine.

That being the case, is there any reason why we need to support IPv6?
> Instead of doing `config['dev_addr'].split(':', 1)` (serve.py:112), please consider using the ipaddress module from the standard Python library.

Actually, the ipaddress lib won't help here as it does not handle ports (AFAICT). We would still need to separate the port before passing the IP address to the lib. However, it should be an easy fix to use `rsplit` rather than `split`, which would split the string at the rightmost colon rather than the leftmost colon. I haven't tried it yet, but that may be enough to support IPv6. We don't appear to actually do any validation of the IP address (we apparently leave that to tornado). The error is related to the port not being an integer. So if we break the port off of the address properly and just pass an IPv6 address to tornado, it may `just work.`

In either case, a better error message seems like a good idea.


I'm labeling this as a "bug" due to the failure to properly split off the port. If that gives us IPv6 support for free, then great. But if not, we'll address that matter separately.
> The only reason the user can specify an IP/port is to avoid conflicts with other servers which might be running on the same machine.
> That being the case, is there any reason why we need to support IPv6?

Localhost may not even be IPv4, or you may want to bind to a public IP like `[::0]` to let someone else on the network look at the docs you're writing.




One more note: the ipaddress lib was added to the standard library in 3.3, but we support 2.6 and 2.7 in the current release with no plans to drop support for 2.7.
For future reference, the ipadress lib has been [backported](https://pypi.python.org/pypi/ipaddress) for 2.6, 2.7 and 3.2. 

Resolving patch:
diff --git a/mkdocs/commands/serve.py b/mkdocs/commands/serve.py
--- a/mkdocs/commands/serve.py
+++ b/mkdocs/commands/serve.py
@@ -52,7 +52,7 @@ def get_web_handlers(self, script):
     for d in config['theme_dir']:
         server.watch(d, builder)
 
-    server.serve(root=site_dir, host=host, port=int(port), restart_delay=0)
+    server.serve(root=site_dir, host=host, port=port, restart_delay=0)
 
 
 def _static_server(host, port, site_dir):
@@ -68,7 +68,7 @@ def _static_server(host, port, site_dir):
             "default_filename": "index.html"
         }),
     ])
-    application.listen(port=int(port), address=host)
+    application.listen(port=port, address=host)
 
     log.info('Running at: http://%s:%s/', host, port)
     log.info('Hold ctrl+c to quit.')
@@ -106,12 +106,12 @@ def builder():
         build(config, live_server=live_server, dirty=dirty)
         return config
 
-    # Perform the initial build
-    config = builder()
+    try:
+        # Perform the initial build
+        config = builder()
 
-    host, port = config['dev_addr'].split(':', 1)
+        host, port = config['dev_addr']
 
-    try:
         if livereload in ['livereload', 'dirty']:
             _livereload(host, port, config, builder, tempdir)
         else:
diff --git a/mkdocs/config/config_options.py b/mkdocs/config/config_options.py
--- a/mkdocs/config/config_options.py
+++ b/mkdocs/config/config_options.py
@@ -1,5 +1,6 @@
 from __future__ import unicode_literals
 import os
+from collections import namedtuple
 
 from mkdocs import utils, legacy
 from mkdocs.config.base import Config, ValidationError
@@ -156,6 +157,31 @@ def pre_validation(self, config, key_name):
         target[target_key] = config.pop(key_name)
 
 
+class IpAddress(OptionallyRequired):
+    """
+    IpAddress Config Option
+
+    Validate that an IP address is in an apprioriate format
+    """
+
+    def run_validation(self, value):
+        try:
+            host, port = value.rsplit(':', 1)
+        except:
+            raise ValidationError("Must be a string of format 'IP:PORT'")
+
+        try:
+            port = int(port)
+        except:
+            raise ValidationError("'{0}' is not a valid port".format(port))
+
+        class Address(namedtuple('Address', 'host port')):
+            def __str__(self):
+                return '{0}:{1}'.format(self.host, self.port)
+
+        return Address(host, port)
+
+
 class URL(OptionallyRequired):
     """
     URL Config Option
diff --git a/mkdocs/config/defaults.py b/mkdocs/config/defaults.py
--- a/mkdocs/config/defaults.py
+++ b/mkdocs/config/defaults.py
@@ -57,8 +57,7 @@
     ('google_analytics', config_options.Type(list, length=2)),
 
     # The address on which to serve the live reloading docs server.
-    ('dev_addr', config_options.Type(
-        utils.string_types, default='127.0.0.1:8000')),
+    ('dev_addr', config_options.IpAddress(default='127.0.0.1:8000')),
 
     # If `True`, use `<page_name>/index.hmtl` style files with hyperlinks to
     # the directory.If `False`, use `<page_name>.html style file with


Test patch:
diff --git a/mkdocs/tests/config/config_options_tests.py b/mkdocs/tests/config/config_options_tests.py
--- a/mkdocs/tests/config/config_options_tests.py
+++ b/mkdocs/tests/config/config_options_tests.py
@@ -76,6 +76,73 @@ def test_length(self):
                           option.validate, "Testing Long")
 
 
+class IpAddressTest(unittest.TestCase):
+
+    def test_valid_address(self):
+        addr = '127.0.0.1:8000'
+
+        option = config_options.IpAddress()
+        value = option.validate(addr)
+        self.assertEqual(utils.text_type(value), addr)
+        self.assertEqual(value.host, '127.0.0.1')
+        self.assertEqual(value.port, 8000)
+
+    def test_valid_IPv6_address(self):
+        addr = '[::1]:8000'
+
+        option = config_options.IpAddress()
+        value = option.validate(addr)
+        self.assertEqual(utils.text_type(value), addr)
+        self.assertEqual(value.host, '[::1]')
+        self.assertEqual(value.port, 8000)
+
+    def test_named_address(self):
+        addr = 'localhost:8000'
+
+        option = config_options.IpAddress()
+        value = option.validate(addr)
+        self.assertEqual(utils.text_type(value), addr)
+        self.assertEqual(value.host, 'localhost')
+        self.assertEqual(value.port, 8000)
+
+    def test_default_address(self):
+        addr = '127.0.0.1:8000'
+
+        option = config_options.IpAddress(default=addr)
+        value = option.validate(None)
+        self.assertEqual(utils.text_type(value), addr)
+        self.assertEqual(value.host, '127.0.0.1')
+        self.assertEqual(value.port, 8000)
+
+    def test_invalid_address_format(self):
+        option = config_options.IpAddress()
+        self.assertRaises(
+            config_options.ValidationError,
+            option.validate, '127.0.0.18000'
+        )
+
+    def test_invalid_address_type(self):
+        option = config_options.IpAddress()
+        self.assertRaises(
+            config_options.ValidationError,
+            option.validate, 123
+        )
+
+    def test_invalid_address_port(self):
+        option = config_options.IpAddress()
+        self.assertRaises(
+            config_options.ValidationError,
+            option.validate, '127.0.0.1:foo'
+        )
+
+    def test_invalid_address_missing_port(self):
+        option = config_options.IpAddress()
+        self.assertRaises(
+            config_options.ValidationError,
+            option.validate, '127.0.0.1'
+        )
+
+
 class URLTest(unittest.TestCase):
 
     def test_valid_url(self):