Repository: clarketm/wait-for-it

Issue:
"wait-for-it -s ::1:1234" crashes with ValueError: invalid literal for int() with base 10: ':1:1234'
```
$ wait-for-it -s ::1:1234
Traceback (most recent call last):
  File "/tmp/tmp.EpOU9tyqoz/py3/bin/wait-for-it", line 33, in <module>
    sys.exit(load_entry_point('wait-for-it', 'console_scripts', 'wait-for-it')())
  File "/tmp/tmp.EpOU9tyqoz/py3/lib/python3.7/site-packages/click/core.py", line 829, in __call__
    return self.main(*args, **kwargs)
  File "/tmp/tmp.EpOU9tyqoz/py3/lib/python3.7/site-packages/click/core.py", line 782, in main
    rv = self.invoke(ctx)
  File "/tmp/tmp.EpOU9tyqoz/py3/lib/python3.7/site-packages/click/core.py", line 1066, in invoke
    return ctx.invoke(self.callback, **ctx.params)
  File "/tmp/tmp.EpOU9tyqoz/py3/lib/python3.7/site-packages/click/core.py", line 610, in invoke
    return callback(*args, **kwargs)
  File "/tmp/tmp.EpOU9tyqoz/wait-for-it/wait_for_it/wait_for_it.py", line 98, in cli
    _connect_all_serial(service, timeout)
  File "/tmp/tmp.EpOU9tyqoz/wait-for-it/wait_for_it/wait_for_it.py", line 183, in _connect_all_serial
    connect(service, timeout)
  File "/tmp/tmp.EpOU9tyqoz/wait-for-it/wait_for_it/wait_for_it.py", line 187, in connect
    host, port = _determine_host_and_port_for(service)
  File "/tmp/tmp.EpOU9tyqoz/wait-for-it/wait_for_it/wait_for_it.py", line 32, in _determine_host_and_port_for
    port = url.port or (443 if url.scheme == "https" else 80)
  File "/usr/lib/python3.7/urllib/parse.py", line 169, in port
    port = int(port, 10)
ValueError: invalid literal for int() with base 10: ':1:1234'
```

This is caused by the URL service format support introduced at https://github.com/clarketm/wait-for-it/commit/9d8f631939bb686d7d1587766e0381211e83bfe0#diff-ae5818251477f1a9e950145b0ae260dcR36-R41 .

The core is the difference between these two cases:
```
In [18]: urlparse('https://[::1]:1234', scheme="http").port
Out[18]: 1234

In [19]: urlparse('https://::1:1234', scheme="http").port
[..]
ValueError: invalid literal for int() with base 10: ':1:1234'
```

I wonder if a user can expect `::1:1234` or rather `[::1]:1234` to work with `wait-for-it`. The square bracket syntax is from [RFC 3986 section 3.2.2](https://tools.ietf.org/html/rfc3986#section-3.2.2) but we don't always have a full URI here. What do you think? What's the best other tool to check how they handle this issue, especially disambiguation of the colon?

PS: This is not a recently introduced issue so maybe it doesn't need to block the upcoming release but just my 2 cents.

Comments:

Patch:
diff --git a/.github/workflows/python-package.yaml b/.github/workflows/python-package.yaml
index b70a10b..18fe05a 100644
--- a/.github/workflows/python-package.yaml
+++ b/.github/workflows/python-package.yaml
@@ -5,7 +5,8 @@ jobs:
     strategy:
       matrix:
         python-version: [3.6, 3.7, 3.8]
-    runs-on: ubuntu-latest
+        runs-on: [macos-latest, ubuntu-latest]
+    runs-on: ${{ matrix.runs-on }}
     steps:
     - name: Checkout
       uses: actions/checkout@v2
diff --git a/wait_for_it/wait_for_it.py b/wait_for_it/wait_for_it.py
index 467f259..b9f7549 100644
--- a/wait_for_it/wait_for_it.py
+++ b/wait_for_it/wait_for_it.py
@@ -7,6 +7,7 @@ import subprocess
 import sys
 import time
 from contextlib import contextmanager
+from enum import Enum
 from urllib.parse import urlparse
 
 import click
@@ -14,6 +15,15 @@ import click
 from wait_for_it import __version__
 
 
+class _WaitForItException(Exception):
+    """Base class for all exceptions custom to wait-for-it"""
+
+
+class _MalformedServiceSyntaxException(_WaitForItException):
+    def __init__(self, service):
+        super().__init__(f"{service!r} is not a supported syntax for a service")
+
+
 def _asyncio_run(*args, **kvargs):
     """
     Cheap backport of asyncio.run of Python 3.7+ to Python 3.6.
@@ -27,9 +37,12 @@ def _asyncio_run(*args, **kvargs):
 
 def _determine_host_and_port_for(service):
     scheme, _, host = service.rpartition(r"//")
-    url = urlparse(f"{scheme}//{host}", scheme="http")
-    host = url.hostname
-    port = url.port or (443 if url.scheme == "https" else 80)
+    try:
+        url = urlparse(f"{scheme}//{host}", scheme="http")
+        host = url.hostname
+        port = url.port or (443 if url.scheme == "https" else 80)
+    except ValueError:
+        raise _MalformedServiceSyntaxException(service)
     return host, port
 
 
@@ -86,9 +99,16 @@ async def _wait_until_available_and_report(reporter, host, port):
     help="Timeout in seconds, 0 for no timeout",
 )
 @click.argument("commands", nargs=-1)
-def cli(service, quiet, parallel, timeout, commands):
+def cli(**kwargs):
     """Wait for service(s) to be available before executing a command."""
+    try:
+        _cli_internal(**kwargs)
+    except _WaitForItException as e:
+        _Messenger.tell_failure(str(e))
+        sys.exit(1)
+
 
+def _cli_internal(service, quiet, parallel, timeout, commands):
     if quiet:
         sys.stdout = open(os.devnull, "w")
 
@@ -102,11 +122,31 @@ def cli(service, quiet, parallel, timeout, commands):
         sys.exit(result.returncode)
 
 
-class _ConnectionJobReporter:
-    _SUCCESS = "[+] "
-    _FAILURE = "[-] "
-    _NEUTRAL = "[*] "
+class _Messenger:
+    class _MessageType(Enum):
+        SUCCESS = "[+] "
+        FAILURE = "[-] "
+        NEUTRAL = "[*] "
 
+    @classmethod
+    def _tell(cls, message_type, message):
+        prefix = message_type.value
+        print(f"{prefix}{message}")
+
+    @classmethod
+    def tell_success(cls, message):
+        cls._tell(cls._MessageType.SUCCESS, message)
+
+    @classmethod
+    def tell_failure(cls, message):
+        cls._tell(cls._MessageType.FAILURE, message)
+
+    @classmethod
+    def tell_neutral(cls, message):
+        cls._tell(cls._MessageType.NEUTRAL, message)
+
+
+class _ConnectionJobReporter:
     def __init__(self, host, port, timeout):
         self._friendly_name = f"{host}:{port}"
         self._timeout = timeout
@@ -115,24 +155,23 @@ class _ConnectionJobReporter:
 
     def on_before_start(self):
         if self._timeout:
-            print(
-                f"{self._NEUTRAL}Waiting {self._timeout} seconds for {self._friendly_name}"
-            )
+            message = f"Waiting {self._timeout} seconds for {self._friendly_name}"
         else:
-            print(f"{self._NEUTRAL}Waiting for {self._friendly_name} without a timeout")
+            message = f"Waiting for {self._friendly_name} without a timeout"
+
+        _Messenger.tell_neutral(message)
         self._started_at = time.time()
 
     def on_success(self):
         seconds = round(time.time() - self._started_at)
-        print(
-            f"{self._SUCCESS}{self._friendly_name} is available after {seconds} seconds"
+        _Messenger.tell_success(
+            f"{self._friendly_name} is available after {seconds} seconds"
         )
         self.job_successful = True
 
     def on_timeout(self):
-        print(
-            f"{self._FAILURE}Timeout occurred after waiting {self._timeout} seconds"
-            f" for {self._friendly_name}"
+        _Messenger.tell_failure(
+            f"Timeout occurred after waiting {self._timeout} seconds"
         )
 
 


Test patch:
diff --git a/wait_for_it/test_wait_for_it.py b/wait_for_it/test_wait_for_it.py
index aa04023..42e0e72 100644
--- a/wait_for_it/test_wait_for_it.py
+++ b/wait_for_it/test_wait_for_it.py
@@ -10,7 +10,11 @@ from unittest import TestCase
 
 from click.testing import CliRunner
 from parameterized import parameterized
-from .wait_for_it import cli, _determine_host_and_port_for
+from .wait_for_it import (
+    cli,
+    _determine_host_and_port_for,
+    _MalformedServiceSyntaxException,
+)
 
 _ANY_FREE_PORT = 0
 
@@ -113,15 +117,30 @@ class CliTest(TestCase):
 class DetermineHostAndPortForTest(TestCase):
     @parameterized.expand(
         [
-            ("domain.ext", 80),
-            ("domain.ext:123", 123),
-            ("http://domain.ext", 80),
-            ("http://domain.ext/path/", 80),
-            ("https://domain.ext", 443),
-            ("https://domain.ext/path/", 443),
+            ("[::1]:1234", "::1", 1234),
+            ("domain.ext", "domain.ext", 80),
+            ("domain.ext:0", "domain.ext", 80),
+            ("domain.ext:1", "domain.ext", 1),
+            ("domain.ext:65535", "domain.ext", 65535),
+            ("http://domain.ext", "domain.ext", 80),
+            ("http://domain.ext/path/", "domain.ext", 80),
+            ("https://domain.ext", "domain.ext", 443),
+            ("https://domain.ext/path/", "domain.ext", 443),
         ]
     )
-    def test_supportec(self, service, expected_port):
+    def test_supported(self, service, expected_host, expected_port):
         actual_host, actual_port = _determine_host_and_port_for(service)
-        assert actual_host == "domain.ext"
+        assert actual_host == expected_host
         assert actual_port == expected_port
+
+    @parameterized.expand(
+        [
+            ("::1:1234",),  # needs "[::1]:1234", instead
+            ("domain.ext:-1",),
+            ("domain.ext:65536",),
+            ("domain.ext:1.2",),
+        ]
+    )
+    def test_rejected(self, service):
+        with self.assertRaises(_MalformedServiceSyntaxException):
+            _determine_host_and_port_for(service)