Repository: pydicom/pynetdicom
Pull request #1034 (2025-07-03 14:53:58)

Fix issue with using a hostname without IPv6
#### Reference issue
When using a hostname instead of an IP-address, the AddressInformation.address_family function would return AF_INET6. This doesn't work in an environment without IPv6 such as in (some) Docker containers.
Furthermore, a (rather hypothetical) scenario where IPv4 is not available was not supported.

#### Fixes
To fix these issues, AddressInformation.address now does DNS lookup. This means that the address_family/is_ipv4/is_ipv6 functions now work as intended. IPv4 address are preferred over IPv6 for compatibility.

#### Tasks
- [x] Unit tests added that reproduce issue or prove feature is working
- [x] Fix or feature added
- [x] Documentation and examples updated (if relevant)
- [x] Unit tests passing and coverage at 100% after adding fix/feature
- [x] Type annotations updated and passing with mypy
- [ ] ~Apps updated and tested (if relevant)~


scaramallion: Can you add a line to the release notes as well, please?
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/pydicom/pynetdicom/pull/1034?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pydicom) Report
Attention: Patch coverage is `75.00000%` with `3 lines` in your changes missing coverage. Please review.
> Project coverage is 50.39%. Comparing base [(`77a9783`)](https://app.codecov.io/gh/pydicom/pynetdicom/commit/77a9783c3e726c142c10cadea234efb625fcd4b0?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pydicom) to head [(`ff0500f`)](https://app.codecov.io/gh/pydicom/pynetdicom/commit/ff0500ff138b3578a61d926b664b0d9dce7cccf6?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pydicom).

| [Files with missing lines](https://app.codecov.io/gh/pydicom/pynetdicom/pull/1034?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pydicom) | Patch % | Lines |
|---|---|---|
| [pynetdicom/transport.py](https://app.codecov.io/gh/pydicom/pynetdicom/pull/1034?src=pr&el=tree&filepath=pynetdicom%2Ftransport.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pydicom#diff-cHluZXRkaWNvbS90cmFuc3BvcnQucHk=) | 75.00% | [3 Missing :warning: ](https://app.codecov.io/gh/pydicom/pynetdicom/pull/1034?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pydicom) |

> :exclamation:  There is a different number of reports uploaded between BASE (77a9783) and HEAD (ff0500f). Click for more details.
> 
> <details><summary>HEAD has 11 uploads less than BASE</summary>
>
>| Flag | BASE (77a9783) | HEAD (ff0500f) |
>|------|------|------|
>||12|1|
></details>

<details><summary>Additional details and impacted files</summary>


```diff
@@             Coverage Diff              @@
##              main    #1034       +/-   ##
============================================
- Coverage   100.00%   50.39%   -49.61%     
============================================
  Files           28       28               
  Lines         9006     9014        +8     
============================================
- Hits          9006     4543     -4463     
- Misses           0     4471     +4471     
```

| [Files with missing lines](https://app.codecov.io/gh/pydicom/pynetdicom/pull/1034?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pydicom) | Coverage Δ | |
|---|---|---|
| [pynetdicom/transport.py](https://app.codecov.io/gh/pydicom/pynetdicom/pull/1034?src=pr&el=tree&filepath=pynetdicom%2Ftransport.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pydicom#diff-cHluZXRkaWNvbS90cmFuc3BvcnQucHk=) | `81.86% <75.00%> (-18.14%)` | :arrow_down: |

... and [24 files with indirect coverage changes](https://app.codecov.io/gh/pydicom/pynetdicom/pull/1034/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pydicom)

</details>
<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>

Patch:
diff --git a/docs/changelog/v3.1.0.rst b/docs/changelog/v3.1.0.rst
index 1febba4d0..5c12260ce 100644
--- a/docs/changelog/v3.1.0.rst
+++ b/docs/changelog/v3.1.0.rst
@@ -7,3 +7,4 @@ Fixes
 -----
 
 * Fixed the default logging handlers for N-ACTION treating *Action Type ID* as mandatory (:issue:`1027`)
+* Fixed being unable to resolve IPv4 address when using the hostname (:issue:`1033`, :pr:`1034`)
diff --git a/pynetdicom/transport.py b/pynetdicom/transport.py
index 7b32ffbb6..f7da56703 100644
--- a/pynetdicom/transport.py
+++ b/pynetdicom/transport.py
@@ -87,11 +87,11 @@ class AddressInformation:
         Parameters
         ----------
         value : str
-            The IPv4 or IPv6 address. The following conversion will be made:
+            The hostname, or IPv4 or IPv6 address. The following conversion will be made:
 
-            * ``""`` (INADDR_ANY) -> ``"0.0.0.0"``
+            * ``""`` (INADDR_ANY) -> ``"0.0.0.0"`` or ``"::"``
             * ``"<broadcast>"`` (INADDR_BROADCAST) -> ``"255.255.255.255"``
-            * ``"localhost"`` -> ``"127.0.0.1"``
+            * ``"localhost"`` -> ``"127.0.0.1"`` or ``"::1"``
 
         Returns
         -------
@@ -102,11 +102,28 @@ class AddressInformation:
 
     @address.setter
     def address(self, value: str) -> None:
-        value = "0.0.0.0" if value == "" else value
-        value = "127.0.0.1" if value == "localhost" else value
-        value = "255.255.255.255" if value == "<broadcast>" else value
-
-        self._addr = value
+        if value:
+            # getaddrinfo does not handle <broadcast>. IPv6 does not use broadcast.
+            value = "255.255.255.255" if value == "<broadcast>" else value
+            flags = 0
+        else:
+            # getaddrinfo interprets "" as "0.0.0.0" or "::". Set the AI_PASSIVE flag
+            # to return an address that can be used with BIND.
+            flags = socket.AI_PASSIVE
+
+        # getaddrinfo translates a hostname into IPv4 and/or IPv6-addresses.
+        entries = socket.getaddrinfo(value if value else None, 0, flags=flags)
+
+        # Use the first IPv4 address, or the first IPv6 address if there are no
+        # IPv4 addresses available.
+        ipv4_entries = [addr for addr in entries if addr[0] == socket.AF_INET]
+        ipv6_entries = [addr for addr in entries if addr[0] == socket.AF_INET6]
+        if ipv4_entries:
+            self._addr = cast(str, ipv4_entries[0][4][0])
+        elif ipv6_entries:
+            self._addr = cast(str, ipv6_entries[0][4][0])
+        else:
+            raise socket.gaierror("Address resolution failed")
 
     @property
     def address_family(self) -> socket.AddressFamily:


Test patch:
diff --git a/pynetdicom/tests/test_transport.py b/pynetdicom/tests/test_transport.py
index 9a23f0427..2b99b8794 100644
--- a/pynetdicom/tests/test_transport.py
+++ b/pynetdicom/tests/test_transport.py
@@ -11,6 +11,7 @@ from struct import pack
 import sys
 import threading
 import time
+from unittest import mock
 
 import pytest
 
@@ -56,6 +57,14 @@ DATASET = dcmread(os.path.join(DCM_DIR, "RTImageStorage.dcm"))
 
 # debug_logger()
 
+_original_getaddrinfo = socket.getaddrinfo
+
+
+def getaddrinfo_no_ipv4(*args, **kwargs):
+    """Calls socket.getaddrinfo and leaves out the IPv4 results."""
+    entries = _original_getaddrinfo(*args, **kwargs)
+    return [addr for addr in entries if addr[0] != socket.AF_INET]
+
 
 class TestAddressInformation:
     """Tests for AssociationInformation."""
@@ -93,6 +102,24 @@ class TestAddressInformation:
         assert addr.scope_id == 11
         assert addr.as_tuple == ("::1", 0, 10, 11)
 
+    def test_no_ipv4(self):
+        # Check to ensure we have IPv6 entries
+        localhost_entries = getaddrinfo_no_ipv4("localhost", 0)
+
+        with mock.patch("socket.getaddrinfo", new=getaddrinfo_no_ipv4):
+            if localhost_entries:
+                addr = AddressInformation("localhost", 0)
+                assert addr.address == "::1"
+                assert addr.port == 0
+
+            addr = AddressInformation("", 0)
+            assert addr.address in ("::0", "::")
+            assert addr.port == 0
+
+            # IPv6 does not use broadcast.
+            with pytest.raises(socket.gaierror, match="Address resolution failed"):
+                AddressInformation("<broadcast>", 0)
+
     def test_from_tuple(self):
         addr = AddressInformation.from_tuple(("localhost", get_port()))
         assert isinstance(addr, AddressInformation)
@@ -106,7 +133,7 @@ class TestAddressInformation:
 
         addr = AddressInformation.from_tuple(("::0", get_port("remote")))
         assert isinstance(addr, AddressInformation)
-        assert addr.address == "::0"
+        assert addr.address in ("::0", "::")
         assert addr.port == get_port("remote")
 
     def test_from_add_port(self):
@@ -117,14 +144,14 @@ class TestAddressInformation:
 
         addr = AddressInformation.from_addr_port("::0", get_port("remote"))
         assert isinstance(addr, AddressInformation)
-        assert addr.address == "::0"
+        assert addr.address in ("::0", "::")
         assert addr.port == get_port("remote")
         assert addr.flowinfo == 0
         assert addr.scope_id == 0
 
         addr = AddressInformation.from_addr_port(("::0", 12, 13), get_port("remote"))
         assert isinstance(addr, AddressInformation)
-        assert addr.address == "::0"
+        assert addr.address in ("::0", "::")
         assert addr.port == get_port("remote")
         assert addr.flowinfo == 12
         assert addr.scope_id == 13
@@ -143,12 +170,16 @@ class TestAddressInformation:
         assert addr.address == "192.168.0.1"
         assert addr.address_family == socket.AF_INET
         addr.address = "::0"
-        assert addr.address == "::0"
+        assert addr.address in ("::0", "::")
         assert addr.address_family == socket.AF_INET6
         addr.address = "192.168.0.1"
         assert addr.address == "192.168.0.1"
         assert addr.address_family == socket.AF_INET
 
+    def test_resolve_hostname(self):
+        with pytest.raises(socket.gaierror):
+            AddressInformation("remotehost", 0)
+
 
 class TestTConnect:
     """Tests for T_CONNECT."""
@@ -164,9 +195,9 @@ class TestTConnect:
     def test_address_request(self):
         """Test init with an A-ASSOCIATE primitive"""
         request = A_ASSOCIATE()
-        request.called_presentation_address = AddressInformation("123.4", 12)
+        request.called_presentation_address = AddressInformation("1.2.3.4", 12)
         conn = T_CONNECT(request)
-        assert conn.address == ("123.4", 12)
+        assert conn.address == ("1.2.3.4", 12)
         assert conn.request is request
 
         msg = r"A connection attempt has not yet been made"
@@ -176,7 +207,7 @@ class TestTConnect:
     def test_result_setter(self):
         """Test setting the result value."""
         request = A_ASSOCIATE()
-        request.called_presentation_address = AddressInformation("123.4", 12)
+        request.called_presentation_address = AddressInformation("1.2.3.4", 12)
         conn = T_CONNECT(request)
 
         msg = r"Invalid connection result 'foo'"
@@ -191,7 +222,7 @@ class TestTConnect:
 
     def test_address_inf(self):
         request = A_ASSOCIATE()
-        request.called_presentation_address = AddressInformation("123.4", 12)
+        request.called_presentation_address = AddressInformation("1.2.3.4", 12)
         conn = T_CONNECT(request)
         assert conn.address_info is request.called_presentation_address