pytap3 3.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,18 @@
1
+ Copyright (c) 2026 Federico Santandrea
2
+ Copyright (c) 2019-2023 John Hagen
3
+
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software
6
+ and associated documentation files (the "Software"), to deal in the Software without restriction,
7
+ including without limitation the rights to use, copy, modify, merge, publish, distribute,
8
+ sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
9
+ furnished to do so, subject to the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be included in all copies or
12
+ substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
15
+ BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
16
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
17
+ DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,3 @@
1
+ include README.rst
2
+ include LICENSE.txt
3
+ include pytap3_build.py
pytap3-3.0.0/PKG-INFO ADDED
@@ -0,0 +1,129 @@
1
+ Metadata-Version: 2.4
2
+ Name: pytap3
3
+ Version: 3.0.0
4
+ Summary: Cross-platform tunnel interface for Python
5
+ Home-page: https://github.com/sntfrc/pytap3
6
+ Author: Federico Santandrea
7
+ Author-email: federico@santandrea.net
8
+ License: MIT
9
+ Keywords: pytap,tun,tap,networking
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: POSIX :: Linux
14
+ Classifier: Programming Language :: Python
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Programming Language :: Python :: 3.8
18
+ Classifier: Programming Language :: Python :: 3.9
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
+ Requires-Python: >=3.8
24
+ License-File: LICENSE.txt
25
+ Dynamic: author
26
+ Dynamic: author-email
27
+ Dynamic: classifier
28
+ Dynamic: description
29
+ Dynamic: home-page
30
+ Dynamic: keywords
31
+ Dynamic: license
32
+ Dynamic: license-file
33
+ Dynamic: requires-python
34
+ Dynamic: summary
35
+
36
+ ``pytap3`` - Cross-platform tunnel interface for Python
37
+ =======================================================
38
+
39
+ This is a cross-platform fork of the original
40
+ `johnthagen/pytap2 <https://github.com/johnthagen/pytap2>`_. It provides a
41
+ packet-oriented interface to TUN devices on Linux, macOS, and Windows.
42
+
43
+ Requirements
44
+ ------------
45
+
46
+ Requirements depend on your operating system:
47
+
48
+ * **Linux:** Install ``net-tools`` to provide the ``ifconfig`` command.
49
+
50
+ On Debian/Ubuntu:
51
+
52
+ .. code:: shell-session
53
+
54
+ $ sudo apt install net-tools
55
+
56
+ On RHEL/Rocky Linux/Fedora:
57
+
58
+ .. code:: shell-session
59
+
60
+ $ sudo yum install net-tools
61
+
62
+ * **macOS:** No additional packages are required; ``ifconfig`` is included
63
+ with macOS.
64
+
65
+ * **Windows:** ``wintun.dll`` is required. When building/installing from source
66
+ on Windows, the package downloads the signed Wintun 0.14.1 archive from
67
+ `Wintun <https://www.wintun.net/>`_, verifies its SHA-256 checksum, and bundles
68
+ the DLL matching the Python platform. This requires network access during
69
+ installation. Wheels are platform-tagged; build Windows wheels on Windows to
70
+ include the DLL. Source distributions build locally on any supported OS.
71
+
72
+ Creating and configuring a TUN device may require administrator or root
73
+ privileges.
74
+
75
+ Installation
76
+ ------------
77
+
78
+ You can install, upgrade, and uninstall ``pytap3`` with these commands:
79
+
80
+ .. code:: shell-session
81
+
82
+ $ pip install pytap3
83
+ $ pip install --upgrade pytap3
84
+ $ pip uninstall pytap3
85
+
86
+ Usage
87
+ -----
88
+
89
+ Using as a context manager automatically brings up the device and closes it at the
90
+ end of the ``with`` block.
91
+
92
+ .. code:: python
93
+
94
+ from pytap3 import TapDevice
95
+
96
+ with TapDevice() as device:
97
+ device.ifconfig(mtu=1300)
98
+ device.write(b'0000')
99
+
100
+ Or manually call ``up()`` and ``close()``.
101
+
102
+ .. code:: python
103
+
104
+ from pytap3 import TapDevice
105
+
106
+ device = TapDevice()
107
+ device.up()
108
+ device.ifconfig(mtu=1300)
109
+ device.write(b'0000')
110
+ device.close()
111
+
112
+ The ``fileno()`` method is defined, so that the device object can be passed directly
113
+ to `select() <https://docs.python.org/library/select.html#select.select>`_. This might
114
+ not work on Windows, as the underlying Wintun driver does not support this.
115
+
116
+ On macOS, configuring an IP address with ``ifconfig(ip=..., netmask=...)`` also
117
+ adds a route for that subnet through the ``utun`` interface. Routes that conflict
118
+ with an existing route may require administrator privileges or manual adjustment.
119
+
120
+ You can make pytap3 a drop-in replacement for pytap2 by just importing it like this:
121
+ ``import pytap3 as pytap2``
122
+
123
+ Releases
124
+ --------
125
+
126
+ 3.0.0 - 2024-06-05
127
+ ^^^^^^^^^^^^^^^^^^
128
+
129
+ - Windows, Linux and macOS support!
@@ -0,0 +1,94 @@
1
+ ``pytap3`` - Cross-platform tunnel interface for Python
2
+ =======================================================
3
+
4
+ This is a cross-platform fork of the original
5
+ `johnthagen/pytap2 <https://github.com/johnthagen/pytap2>`_. It provides a
6
+ packet-oriented interface to TUN devices on Linux, macOS, and Windows.
7
+
8
+ Requirements
9
+ ------------
10
+
11
+ Requirements depend on your operating system:
12
+
13
+ * **Linux:** Install ``net-tools`` to provide the ``ifconfig`` command.
14
+
15
+ On Debian/Ubuntu:
16
+
17
+ .. code:: shell-session
18
+
19
+ $ sudo apt install net-tools
20
+
21
+ On RHEL/Rocky Linux/Fedora:
22
+
23
+ .. code:: shell-session
24
+
25
+ $ sudo yum install net-tools
26
+
27
+ * **macOS:** No additional packages are required; ``ifconfig`` is included
28
+ with macOS.
29
+
30
+ * **Windows:** ``wintun.dll`` is required. When building/installing from source
31
+ on Windows, the package downloads the signed Wintun 0.14.1 archive from
32
+ `Wintun <https://www.wintun.net/>`_, verifies its SHA-256 checksum, and bundles
33
+ the DLL matching the Python platform. This requires network access during
34
+ installation. Wheels are platform-tagged; build Windows wheels on Windows to
35
+ include the DLL. Source distributions build locally on any supported OS.
36
+
37
+ Creating and configuring a TUN device may require administrator or root
38
+ privileges.
39
+
40
+ Installation
41
+ ------------
42
+
43
+ You can install, upgrade, and uninstall ``pytap3`` with these commands:
44
+
45
+ .. code:: shell-session
46
+
47
+ $ pip install pytap3
48
+ $ pip install --upgrade pytap3
49
+ $ pip uninstall pytap3
50
+
51
+ Usage
52
+ -----
53
+
54
+ Using as a context manager automatically brings up the device and closes it at the
55
+ end of the ``with`` block.
56
+
57
+ .. code:: python
58
+
59
+ from pytap3 import TapDevice
60
+
61
+ with TapDevice() as device:
62
+ device.ifconfig(mtu=1300)
63
+ device.write(b'0000')
64
+
65
+ Or manually call ``up()`` and ``close()``.
66
+
67
+ .. code:: python
68
+
69
+ from pytap3 import TapDevice
70
+
71
+ device = TapDevice()
72
+ device.up()
73
+ device.ifconfig(mtu=1300)
74
+ device.write(b'0000')
75
+ device.close()
76
+
77
+ The ``fileno()`` method is defined, so that the device object can be passed directly
78
+ to `select() <https://docs.python.org/library/select.html#select.select>`_. This might
79
+ not work on Windows, as the underlying Wintun driver does not support this.
80
+
81
+ On macOS, configuring an IP address with ``ifconfig(ip=..., netmask=...)`` also
82
+ adds a route for that subnet through the ``utun`` interface. Routes that conflict
83
+ with an existing route may require administrator privileges or manual adjustment.
84
+
85
+ You can make pytap3 a drop-in replacement for pytap2 by just importing it like this:
86
+ ``import pytap3 as pytap2``
87
+
88
+ Releases
89
+ --------
90
+
91
+ 3.0.0 - 2024-06-05
92
+ ^^^^^^^^^^^^^^^^^^
93
+
94
+ - Windows, Linux and macOS support!
@@ -0,0 +1,28 @@
1
+ [build-system]
2
+ requires = ["setuptools>=60.2", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [tool.mypy]
6
+ ignore_missing_imports = true
7
+ strict = true
8
+
9
+ [tool.black]
10
+ line-length = 99
11
+ target-version = ["py38", "py39", "py310"]
12
+
13
+ [tool.isort]
14
+ profile = "black"
15
+ line_length = 99
16
+ force_sort_within_sections = true
17
+ src_paths = ["src/pytap3"]
18
+ skip_gitignore = true
19
+
20
+ [tool.coverage.run]
21
+ branch = true
22
+ source = ["pytap3", "tests/"]
23
+
24
+ [tool.coverage.paths]
25
+ source = [
26
+ "src/pytap3",
27
+ ".tox/*/lib/python*/site-packages/pytap3",
28
+ ]
@@ -0,0 +1,76 @@
1
+ import hashlib
2
+ import io
3
+ from pathlib import Path
4
+ import sys
5
+ import sysconfig
6
+ from typing import Dict, List, Tuple, cast
7
+ import urllib.request
8
+ import zipfile
9
+
10
+ from setuptools.command.build_py import build_py
11
+ from wheel.bdist_wheel import bdist_wheel
12
+
13
+ WINTUN_URL = "https://www.wintun.net/builds/wintun-0.14.1.zip"
14
+ WINTUN_SHA256 = "07c256185d6ee3652e09fa55c0b673e2624b565e02c4b9091c79ca7d2f24ef51"
15
+
16
+ _PLATFORM_ARCHITECTURES: Dict[str, str] = {
17
+ "win32": "x86",
18
+ "win-amd64": "amd64",
19
+ "win-arm32": "arm",
20
+ "win-arm64": "arm64",
21
+ }
22
+
23
+
24
+ def get_wintun_architecture() -> str:
25
+ platform_name = sysconfig.get_platform().lower()
26
+ try:
27
+ return _PLATFORM_ARCHITECTURES[platform_name]
28
+ except KeyError as exc:
29
+ raise RuntimeError(
30
+ "Unsupported Windows platform for Wintun: {}".format(platform_name)
31
+ ) from exc
32
+
33
+
34
+ def download_wintun(destination: Path, architecture: str) -> None:
35
+ with urllib.request.urlopen(WINTUN_URL, timeout=30) as response:
36
+ archive_data = response.read()
37
+ digest = hashlib.sha256(archive_data).hexdigest()
38
+ if digest != WINTUN_SHA256:
39
+ raise ValueError(
40
+ "Wintun archive SHA-256 mismatch: expected {}, got {}".format(WINTUN_SHA256, digest)
41
+ )
42
+
43
+ member_name = "wintun/bin/{}/wintun.dll".format(architecture)
44
+ with zipfile.ZipFile(io.BytesIO(archive_data)) as archive:
45
+ destination.parent.mkdir(parents=True, exist_ok=True)
46
+ destination.write_bytes(archive.read(member_name))
47
+ (destination.parent / "wintun-LICENSE.txt").write_bytes(archive.read("wintun/LICENSE.txt"))
48
+
49
+
50
+ class WintunBuildPy(build_py): # type: ignore[misc]
51
+ def get_outputs(self, include_bytecode: int = 1) -> List[str]:
52
+ outputs = cast(List[str], super().get_outputs(include_bytecode))
53
+ if sys.platform == "win32":
54
+ package_directory = Path(self.build_lib) / "pytap3"
55
+ outputs.extend(
56
+ str(package_directory / filename)
57
+ for filename in ("wintun.dll", "wintun-LICENSE.txt")
58
+ )
59
+ return outputs
60
+
61
+ def run(self) -> None:
62
+ super().run()
63
+ if sys.platform == "win32":
64
+ architecture = get_wintun_architecture()
65
+ destination = Path(self.build_lib) / "pytap3" / "wintun.dll"
66
+ download_wintun(destination, architecture)
67
+
68
+
69
+ class PlatformWheel(bdist_wheel): # type: ignore[misc]
70
+ def finalize_options(self) -> None:
71
+ super().finalize_options()
72
+ self.root_is_pure = False
73
+
74
+ def get_tag(self) -> Tuple[str, str, str]:
75
+ _, _, platform_tag = cast(Tuple[str, str, str], super().get_tag())
76
+ return "py3", "none", platform_tag
pytap3-3.0.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
pytap3-3.0.0/setup.py ADDED
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env python3
2
+
3
+ import os
4
+ import sys
5
+
6
+ import setuptools
7
+
8
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
9
+
10
+ from pytap3_build import PlatformWheel, WintunBuildPy # noqa: E402
11
+
12
+ setuptools.setup(
13
+ name="pytap3",
14
+ version="3.0.0",
15
+ description="Cross-platform tunnel interface for Python",
16
+ long_description=open("README.rst").read(),
17
+ keywords=["pytap", "tun", "tap", "networking"],
18
+ author="Federico Santandrea",
19
+ author_email="federico@santandrea.net",
20
+ url="https://github.com/sntfrc/pytap3",
21
+ packages=setuptools.find_packages("src"),
22
+ package_dir={"": "src"},
23
+ package_data={"pytap3": ["py.typed"]},
24
+ cmdclass={"bdist_wheel": PlatformWheel, "build_py": WintunBuildPy},
25
+ include_package_data=True,
26
+ python_requires=">=3.8",
27
+ zip_safe=False,
28
+ license="MIT",
29
+ classifiers=[
30
+ "Development Status :: 4 - Beta",
31
+ "Intended Audience :: Developers",
32
+ "License :: OSI Approved :: MIT License",
33
+ "Operating System :: POSIX :: Linux",
34
+ "Programming Language :: Python",
35
+ "Programming Language :: Python :: 3",
36
+ "Programming Language :: Python :: 3 :: Only",
37
+ "Programming Language :: Python :: 3.8",
38
+ "Programming Language :: Python :: 3.9",
39
+ "Programming Language :: Python :: 3.10",
40
+ "Programming Language :: Python :: 3.11",
41
+ "Programming Language :: Python :: 3.12",
42
+ "Topic :: Software Development :: Libraries :: Python Modules",
43
+ ],
44
+ )
@@ -0,0 +1,499 @@
1
+ """A small, packet-oriented wrapper for OS TUN interfaces.
2
+
3
+ Linux uses ``/dev/net/tun``, macOS uses ``utun`` kernel control sockets, and
4
+ Windows uses the Wintun userspace API. Creating/configuring an interface
5
+ normally requires administrator/root privileges.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import ctypes
11
+ import ctypes.util
12
+ import errno
13
+ import ipaddress
14
+ import logging
15
+ import os
16
+ import platform
17
+ import socket
18
+ import struct
19
+ import subprocess
20
+ import uuid
21
+ from typing import Optional
22
+
23
+ if os.name != "nt":
24
+ import fcntl
25
+
26
+
27
+ IFF_TUN = 0x0001
28
+ IFF_TAP = 0x0002
29
+ IFF_NO_PI = 0x1000
30
+ _IFNAMSIZ = 16
31
+ _DEFAULT_MTU = 1500
32
+ _LOGGER = logging.getLogger(__name__)
33
+
34
+
35
+ class TapDeviceError(OSError):
36
+ """Raised when a TUN interface cannot be created or operated."""
37
+
38
+
39
+ def _run(*args: str) -> None:
40
+ """Run an interface configuration command and preserve its error output."""
41
+ try:
42
+ subprocess.run(args, check=True, capture_output=True, text=True)
43
+ except FileNotFoundError as exc:
44
+ raise TapDeviceError(f"Required command not found: {args[0]}") from exc
45
+ except subprocess.CalledProcessError as exc:
46
+ detail = (exc.stderr or exc.stdout or "").strip()
47
+ raise TapDeviceError(detail or f"Command failed: {' '.join(args)}") from exc
48
+
49
+
50
+ class _LinuxBackend:
51
+ TUNSETIFF = 0x400454CA
52
+
53
+ def __init__(self, name: Optional[str], mode: int, no_pi: bool) -> None:
54
+ if mode not in (IFF_TUN, IFF_TAP):
55
+ raise ValueError("mode must be IFF_TUN or IFF_TAP")
56
+ self.fd = os.open("/dev/net/tun", os.O_RDWR)
57
+ flags = mode | (IFF_NO_PI if no_pi else 0)
58
+ request = struct.pack("16sH", (name or "").encode(), flags)
59
+ try:
60
+ result = fcntl.ioctl(self.fd, self.TUNSETIFF, request)
61
+ except Exception:
62
+ os.close(self.fd)
63
+ raise
64
+ self.name = result[:_IFNAMSIZ].split(b"\0", 1)[0].decode()
65
+ self.no_pi = no_pi
66
+
67
+ def read(self, size: int) -> bytes:
68
+ return os.read(self.fd, size)
69
+
70
+ def write(self, packet: bytes) -> int:
71
+ return os.write(self.fd, packet)
72
+
73
+ def fileno(self) -> int:
74
+ return self.fd
75
+
76
+ def close(self) -> None:
77
+ if self.fd is not None:
78
+ os.close(self.fd)
79
+ self.fd = None
80
+
81
+
82
+ class _MacOSBackend:
83
+ # Darwin kernel-control socket structures (native byte order).
84
+ _CTL_INFO_SIZE = 96 # uint32 ctl_id + char ctl_name[96]
85
+ _SOCKADDR_CTL = struct.Struct("=BBHII5I")
86
+ _CTLIOCGINFO = 0xC0644E03
87
+ _UTUN_CONTROL_NAME = b"com.apple.net.utun_control"
88
+ _AF_SYSTEM = 32
89
+ _AF_SYS_CONTROL = 2
90
+ _SYSPROTO_CONTROL = 2
91
+ _UTUN_OPT_IFNAME = 2
92
+
93
+ def __init__(self, name: Optional[str], mode: int, no_pi: bool) -> None:
94
+ if mode != IFF_TUN:
95
+ raise ValueError("macOS utun supports layer-3 TUN devices only")
96
+ if name and not name.startswith("utun"):
97
+ name = "utun0"
98
+ requested_unit = None if not name else int(name[4:]) + 1
99
+ unit = 1 if requested_unit is None else requested_unit
100
+ retried = False
101
+ while True:
102
+ interface_name = f"utun{unit - 1}"
103
+ _LOGGER.debug("Creating macOS utun control socket for %s", interface_name)
104
+ try:
105
+ sock = socket.socket(
106
+ getattr(socket, "PF_SYSTEM", 32), socket.SOCK_DGRAM,
107
+ self._SYSPROTO_CONTROL
108
+ )
109
+ except OSError as exc:
110
+ raise TapDeviceError(
111
+ exc.errno,
112
+ f"Creating control socket for {interface_name} failed: "
113
+ f"{exc.strerror or exc}",
114
+ ) from exc
115
+ try:
116
+ info = bytearray(self._CTL_INFO_SIZE + 4)
117
+ info[4:4 + len(self._UTUN_CONTROL_NAME)] = self._UTUN_CONTROL_NAME
118
+ fcntl.ioctl(sock.fileno(), self._CTLIOCGINFO, info, True)
119
+ ctl_id = struct.unpack_from("=I", info)[0]
120
+ except OSError as exc:
121
+ sock.close()
122
+ raise TapDeviceError(
123
+ exc.errno,
124
+ f"Looking up the utun kernel control for {interface_name} "
125
+ f"failed: {exc.strerror or exc}",
126
+ ) from exc
127
+ except Exception:
128
+ sock.close()
129
+ raise
130
+ try:
131
+ _LOGGER.debug(
132
+ "Connecting macOS utun control socket to unit %d (%s)",
133
+ unit, interface_name,
134
+ )
135
+ sock.connect((ctl_id, unit))
136
+ except OSError as exc:
137
+ sock.close()
138
+ if exc.errno == errno.EBUSY:
139
+ _LOGGER.debug(
140
+ "%s is busy; trying the next macOS utun unit",
141
+ interface_name,
142
+ )
143
+ unit += 1
144
+ retried = True
145
+ continue
146
+ raise TapDeviceError(
147
+ exc.errno,
148
+ f"Connecting to macOS utun unit {unit} ({interface_name}) "
149
+ f"failed: {exc.strerror or exc}",
150
+ ) from exc
151
+ except Exception:
152
+ sock.close()
153
+ raise
154
+ self.sock = sock
155
+ break
156
+
157
+ _LOGGER.debug(
158
+ "Reading assigned interface name for macOS utun unit %d", unit
159
+ )
160
+ try:
161
+ self.name = self.sock.getsockopt(
162
+ self._SYSPROTO_CONTROL, self._UTUN_OPT_IFNAME, 64
163
+ ).split(b"\0", 1)[0].decode()
164
+ except OSError as exc:
165
+ self.sock.close()
166
+ raise TapDeviceError(
167
+ exc.errno,
168
+ f"Reading the assigned name for macOS utun unit {unit} failed: "
169
+ f"{exc.strerror or exc}",
170
+ ) from exc
171
+ except Exception:
172
+ self.sock.close()
173
+ raise
174
+ if name and not retried and name != self.name:
175
+ self.sock.close()
176
+ raise TapDeviceError(
177
+ f"Requested {name}, but macOS assigned {self.name}; "
178
+ "utun names are assigned by the kernel"
179
+ )
180
+
181
+ def read(self, size: int) -> bytes:
182
+ packet = self.sock.recv(size + 4)
183
+ if len(packet) < 4:
184
+ return b""
185
+ # utun prepends a 32-bit address-family value to every packet.
186
+ return packet[4:]
187
+
188
+ def write(self, packet: bytes) -> int:
189
+ if not packet:
190
+ return self.sock.send(packet)
191
+ version = packet[0] >> 4
192
+ if version == 4:
193
+ family = socket.AF_INET
194
+ elif version == 6:
195
+ family = socket.AF_INET6
196
+ else:
197
+ raise ValueError("utun accepts only IPv4 or IPv6 packets")
198
+ return max(0, self.sock.send(struct.pack("!I", family) + packet) - 4)
199
+
200
+ def fileno(self) -> int:
201
+ return self.sock.fileno()
202
+
203
+ def close(self) -> None:
204
+ self.sock.close()
205
+
206
+
207
+ class _GUID(ctypes.Structure):
208
+ _fields_ = [
209
+ ("Data1", ctypes.c_uint32),
210
+ ("Data2", ctypes.c_uint16),
211
+ ("Data3", ctypes.c_uint16),
212
+ ("Data4", ctypes.c_ubyte * 8),
213
+ ]
214
+
215
+ @classmethod
216
+ def from_uuid(cls, value: uuid.UUID) -> "_GUID":
217
+ raw = value.bytes_le
218
+ result = cls()
219
+ ctypes.memmove(ctypes.byref(result), raw, 16)
220
+ return result
221
+
222
+
223
+ class _WindowsBackend:
224
+ ERROR_NO_MORE_ITEMS = 259
225
+ ERROR_BUFFER_OVERFLOW = 111
226
+ WAIT_OBJECT_0 = 0
227
+ INFINITE = 0xFFFFFFFF
228
+
229
+ def __init__(self, name: Optional[str], mode: int, no_pi: bool) -> None:
230
+ if mode != IFF_TUN:
231
+ raise ValueError("Wintun supports layer-3 TUN devices only")
232
+ if not hasattr(ctypes, "WinDLL"):
233
+ raise TapDeviceError("Wintun is available only on Windows")
234
+ self.name = name or "pytap3"
235
+ self._dll = self._load_dll()
236
+ self._bind_api()
237
+ adapter_type = "Wintun"
238
+ adapter_guid = _GUID.from_uuid(
239
+ uuid.uuid5(uuid.NAMESPACE_DNS, "pytap3:" + self.name)
240
+ )
241
+ self.adapter = self._create_adapter(
242
+ self.name, adapter_type, ctypes.byref(adapter_guid)
243
+ )
244
+ if not self.adapter:
245
+ raise ctypes.WinError(ctypes.get_last_error())
246
+ self.session = self._start_session(self.adapter, 0x400000)
247
+ if not self.session:
248
+ error = ctypes.get_last_error()
249
+ self._close_adapter(self.adapter)
250
+ self.adapter = None
251
+ raise ctypes.WinError(error)
252
+ self._wait_event = self._get_read_wait_event(self.session)
253
+ if not self._wait_event:
254
+ self.close()
255
+ raise ctypes.WinError(ctypes.get_last_error())
256
+ self._kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
257
+ self._kernel32.WaitForSingleObject.argtypes = [ctypes.c_void_p, ctypes.c_uint32]
258
+ self._kernel32.WaitForSingleObject.restype = ctypes.c_uint32
259
+
260
+ @staticmethod
261
+ def _load_dll():
262
+ candidates = []
263
+ bundled = os.path.join(os.path.dirname(__file__), "wintun.dll")
264
+ if os.path.isfile(bundled):
265
+ candidates.append(bundled)
266
+ found = ctypes.util.find_library("wintun")
267
+ if found:
268
+ candidates.append(found)
269
+ candidates.append("wintun.dll")
270
+ errors = []
271
+ for candidate in candidates:
272
+ try:
273
+ return ctypes.WinDLL(candidate, use_last_error=True)
274
+ except OSError as exc:
275
+ errors.append(str(exc))
276
+ raise TapDeviceError(
277
+ "Could not load wintun.dll. Place the architecture-matched, "
278
+ "signed Wintun DLL beside this module or on PATH. " + "; ".join(errors)
279
+ )
280
+
281
+ def _bind_api(self) -> None:
282
+ pointer = ctypes.c_void_p
283
+ self._create_adapter = self._dll.WintunCreateAdapter
284
+ self._create_adapter.argtypes = [ctypes.c_wchar_p, ctypes.c_wchar_p,
285
+ ctypes.POINTER(_GUID)]
286
+ self._create_adapter.restype = pointer
287
+ self._start_session = self._dll.WintunStartSession
288
+ self._start_session.argtypes = [pointer, ctypes.c_uint32]
289
+ self._start_session.restype = pointer
290
+ self._end_session = self._dll.WintunEndSession
291
+ self._end_session.argtypes = [pointer]
292
+ self._end_session.restype = None
293
+ self._close_adapter = self._dll.WintunCloseAdapter
294
+ self._close_adapter.argtypes = [pointer]
295
+ self._close_adapter.restype = None
296
+ self._receive_packet = self._dll.WintunReceivePacket
297
+ self._receive_packet.argtypes = [pointer, ctypes.POINTER(ctypes.c_uint32)]
298
+ self._receive_packet.restype = pointer
299
+ self._release_packet = self._dll.WintunReleaseReceivePacket
300
+ self._release_packet.argtypes = [pointer, pointer]
301
+ self._release_packet.restype = None
302
+ self._allocate_packet = self._dll.WintunAllocateSendPacket
303
+ self._allocate_packet.argtypes = [pointer, ctypes.c_uint32]
304
+ self._allocate_packet.restype = pointer
305
+ self._send_packet = self._dll.WintunSendPacket
306
+ self._send_packet.argtypes = [pointer, pointer]
307
+ self._send_packet.restype = None
308
+ self._get_read_wait_event = self._dll.WintunGetReadWaitEvent
309
+ self._get_read_wait_event.argtypes = [pointer]
310
+ self._get_read_wait_event.restype = pointer
311
+
312
+ def read(self, size: int) -> bytes:
313
+ while True:
314
+ packet_size = ctypes.c_uint32()
315
+ packet = self._receive_packet(self.session, ctypes.byref(packet_size))
316
+ if packet:
317
+ try:
318
+ return ctypes.string_at(packet, min(size, packet_size.value))
319
+ finally:
320
+ self._release_packet(self.session, packet)
321
+ error = ctypes.get_last_error()
322
+ if error != self.ERROR_NO_MORE_ITEMS:
323
+ raise ctypes.WinError(error)
324
+ result = self._kernel32.WaitForSingleObject(self._wait_event, self.INFINITE)
325
+ if result != self.WAIT_OBJECT_0:
326
+ raise ctypes.WinError(ctypes.get_last_error())
327
+
328
+ def write(self, packet: bytes) -> int:
329
+ ptr = self._allocate_packet(self.session, len(packet))
330
+ if not ptr:
331
+ error = ctypes.get_last_error()
332
+ if error == self.ERROR_BUFFER_OVERFLOW:
333
+ return 0
334
+ raise ctypes.WinError(error)
335
+ if packet:
336
+ ctypes.memmove(ptr, packet, len(packet))
337
+ self._send_packet(self.session, ptr)
338
+ return len(packet)
339
+
340
+ def fileno(self) -> int:
341
+ # This is a Windows event HANDLE. It is waitable with Win32 wait APIs,
342
+ # but is not a POSIX descriptor and cannot be passed to select.select().
343
+ return int(self._wait_event)
344
+
345
+ def close(self) -> None:
346
+ if getattr(self, "session", None):
347
+ self._end_session(self.session)
348
+ self.session = None
349
+ if getattr(self, "adapter", None):
350
+ self._close_adapter(self.adapter)
351
+ self.adapter = None
352
+
353
+
354
+ class TapDevice:
355
+ """Create and use a packet-oriented TUN device.
356
+
357
+ Args:
358
+ name: Preferred interface name. macOS assigns ``utunN`` names itself
359
+ and tries a later unit if the requested unit is busy.
360
+ mode: ``IFF_TUN`` (default) or Linux-only ``IFF_TAP``.
361
+ no_pi: Linux-only; omit the two-byte packet-information header.
362
+ mtu: MTU to set when the interface is brought up.
363
+
364
+ ``read`` and ``write`` exchange IP packets without platform headers.
365
+ """
366
+
367
+ def __init__(self, name: Optional[str] = None, mode: int = IFF_TUN,
368
+ no_pi: bool = True, mtu: int = _DEFAULT_MTU) -> None:
369
+ self.mtu = mtu
370
+ self.mode = mode
371
+ self.no_pi = no_pi
372
+ self._platform = platform.system()
373
+ if self._platform == "Linux":
374
+ self._backend = _LinuxBackend(name, mode, no_pi)
375
+ elif self._platform == "Darwin":
376
+ self._backend = _MacOSBackend(name, mode, no_pi)
377
+ elif self._platform == "Windows":
378
+ self._backend = _WindowsBackend(name, mode, no_pi)
379
+ else:
380
+ raise TapDeviceError(f"Unsupported operating system: {self._platform}")
381
+ self.name = self._backend.name
382
+ self._closed = False
383
+
384
+ def fileno(self) -> int:
385
+ """Return the POSIX file descriptor or Windows Wintun wait-event handle."""
386
+ return self._backend.fileno()
387
+
388
+ @property
389
+ def fd(self) -> int:
390
+ """Compatibility alias for ``fileno()``."""
391
+ return self.fileno()
392
+
393
+ def read(self, size: Optional[int] = None) -> bytes:
394
+ """Read one packet; defaults to one MTU of payload."""
395
+ if self._closed:
396
+ raise ValueError("I/O operation on closed TUN device")
397
+ if size is None:
398
+ size = self.mtu
399
+ if self._platform == "Linux" and not self.no_pi:
400
+ size += 4
401
+ return self._backend.read(size)
402
+
403
+ def write(self, packet: bytes) -> int:
404
+ """Write one complete IP packet to the interface."""
405
+ if self._closed:
406
+ raise ValueError("I/O operation on closed TUN device")
407
+ if not isinstance(packet, (bytes, bytearray, memoryview)):
408
+ raise TypeError("packet must be bytes-like")
409
+ return self._backend.write(bytes(packet))
410
+
411
+ def up(self) -> None:
412
+ """Bring the interface up and apply the configured MTU."""
413
+ if self._platform == "Windows":
414
+ _run("netsh", "interface", "set", "interface", f"name={self.name}",
415
+ "admin=enabled")
416
+ _run("netsh", "interface", "ipv4", "set", "subinterface",
417
+ self.name, f"mtu={self.mtu}", "store=active")
418
+ else:
419
+ _run("ifconfig", self.name, "mtu", str(self.mtu), "up")
420
+
421
+ @staticmethod
422
+ def _mac_peer(address: str, netmask: str) -> str:
423
+ ip = ipaddress.ip_address(address)
424
+ if ip.version == 4:
425
+ network = ipaddress.ip_network(f"{address}/{netmask}", strict=False)
426
+ else:
427
+ network = ipaddress.ip_network(f"{address}/{netmask}", strict=False)
428
+ for candidate in network.hosts():
429
+ if candidate != ip:
430
+ return str(candidate)
431
+ raise ValueError(f"No other usable address in {network} for utun peer")
432
+
433
+ def ifconfig(self, ip: Optional[str] = None, netmask: Optional[str] = None,
434
+ mtu: Optional[int] = None, peer: Optional[str] = None,
435
+ address: Optional[str] = None) -> None:
436
+ """Configure interface addressing and/or MTU.
437
+
438
+ For macOS, ``peer`` defaults to the first usable address in the given
439
+ subnet other than ``ip``. This supplies utun's required point-to-point
440
+ destination address. It is a deterministic placeholder, not a probe of
441
+ address use on the network. The subnet route implied by ``netmask`` is
442
+ also installed through the utun interface.
443
+ """
444
+ if address is not None:
445
+ if ip is not None and ip != address:
446
+ raise ValueError("pass either ip or address, not conflicting values")
447
+ ip = address
448
+ target_mtu = mtu if mtu is not None else self.mtu
449
+ if self._platform == "Linux":
450
+ args = ["ifconfig", self.name]
451
+ if ip:
452
+ args.append(ip)
453
+ if netmask:
454
+ args.extend(["netmask", netmask])
455
+ if target_mtu:
456
+ args.extend(["mtu", str(target_mtu)])
457
+ _run(*args)
458
+ elif self._platform == "Darwin":
459
+ if ip:
460
+ if not netmask:
461
+ raise ValueError("netmask is required when configuring an IP")
462
+ chosen_peer = peer or self._mac_peer(ip, netmask)
463
+ network = ipaddress.ip_network(f"{ip}/{netmask}", strict=False)
464
+ args = ["ifconfig", self.name, "inet", ip, chosen_peer,
465
+ "netmask", netmask]
466
+ else:
467
+ args = ["ifconfig", self.name]
468
+ if target_mtu:
469
+ args.extend(["mtu", str(target_mtu)])
470
+ _run(*args)
471
+ if ip:
472
+ _run("route", "-n", "add", "-net", str(network), ip)
473
+ else:
474
+ if ip:
475
+ if not netmask:
476
+ raise ValueError("netmask is required when configuring an IP")
477
+ _run("netsh", "interface", "ipv4", "set", "address",
478
+ f"name={self.name}", "source=static", f"address={ip}",
479
+ f"mask={netmask}")
480
+ if target_mtu:
481
+ _run("netsh", "interface", "ipv4", "set", "subinterface",
482
+ self.name, f"mtu={target_mtu}", "store=active")
483
+ if mtu is not None:
484
+ self.mtu = mtu
485
+
486
+ def close(self) -> None:
487
+ if not self._closed:
488
+ self._backend.close()
489
+ self._closed = True
490
+
491
+ def __enter__(self) -> "TapDevice":
492
+ self.up()
493
+ return self
494
+
495
+ def __exit__(self, exc_type, exc_value, traceback) -> None:
496
+ self.close()
497
+
498
+
499
+ __all__ = ["TapDevice", "TapDeviceError", "IFF_TUN", "IFF_TAP", "IFF_NO_PI"]
File without changes
@@ -0,0 +1,129 @@
1
+ Metadata-Version: 2.4
2
+ Name: pytap3
3
+ Version: 3.0.0
4
+ Summary: Cross-platform tunnel interface for Python
5
+ Home-page: https://github.com/sntfrc/pytap3
6
+ Author: Federico Santandrea
7
+ Author-email: federico@santandrea.net
8
+ License: MIT
9
+ Keywords: pytap,tun,tap,networking
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: POSIX :: Linux
14
+ Classifier: Programming Language :: Python
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Programming Language :: Python :: 3.8
18
+ Classifier: Programming Language :: Python :: 3.9
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
+ Requires-Python: >=3.8
24
+ License-File: LICENSE.txt
25
+ Dynamic: author
26
+ Dynamic: author-email
27
+ Dynamic: classifier
28
+ Dynamic: description
29
+ Dynamic: home-page
30
+ Dynamic: keywords
31
+ Dynamic: license
32
+ Dynamic: license-file
33
+ Dynamic: requires-python
34
+ Dynamic: summary
35
+
36
+ ``pytap3`` - Cross-platform tunnel interface for Python
37
+ =======================================================
38
+
39
+ This is a cross-platform fork of the original
40
+ `johnthagen/pytap2 <https://github.com/johnthagen/pytap2>`_. It provides a
41
+ packet-oriented interface to TUN devices on Linux, macOS, and Windows.
42
+
43
+ Requirements
44
+ ------------
45
+
46
+ Requirements depend on your operating system:
47
+
48
+ * **Linux:** Install ``net-tools`` to provide the ``ifconfig`` command.
49
+
50
+ On Debian/Ubuntu:
51
+
52
+ .. code:: shell-session
53
+
54
+ $ sudo apt install net-tools
55
+
56
+ On RHEL/Rocky Linux/Fedora:
57
+
58
+ .. code:: shell-session
59
+
60
+ $ sudo yum install net-tools
61
+
62
+ * **macOS:** No additional packages are required; ``ifconfig`` is included
63
+ with macOS.
64
+
65
+ * **Windows:** ``wintun.dll`` is required. When building/installing from source
66
+ on Windows, the package downloads the signed Wintun 0.14.1 archive from
67
+ `Wintun <https://www.wintun.net/>`_, verifies its SHA-256 checksum, and bundles
68
+ the DLL matching the Python platform. This requires network access during
69
+ installation. Wheels are platform-tagged; build Windows wheels on Windows to
70
+ include the DLL. Source distributions build locally on any supported OS.
71
+
72
+ Creating and configuring a TUN device may require administrator or root
73
+ privileges.
74
+
75
+ Installation
76
+ ------------
77
+
78
+ You can install, upgrade, and uninstall ``pytap3`` with these commands:
79
+
80
+ .. code:: shell-session
81
+
82
+ $ pip install pytap3
83
+ $ pip install --upgrade pytap3
84
+ $ pip uninstall pytap3
85
+
86
+ Usage
87
+ -----
88
+
89
+ Using as a context manager automatically brings up the device and closes it at the
90
+ end of the ``with`` block.
91
+
92
+ .. code:: python
93
+
94
+ from pytap3 import TapDevice
95
+
96
+ with TapDevice() as device:
97
+ device.ifconfig(mtu=1300)
98
+ device.write(b'0000')
99
+
100
+ Or manually call ``up()`` and ``close()``.
101
+
102
+ .. code:: python
103
+
104
+ from pytap3 import TapDevice
105
+
106
+ device = TapDevice()
107
+ device.up()
108
+ device.ifconfig(mtu=1300)
109
+ device.write(b'0000')
110
+ device.close()
111
+
112
+ The ``fileno()`` method is defined, so that the device object can be passed directly
113
+ to `select() <https://docs.python.org/library/select.html#select.select>`_. This might
114
+ not work on Windows, as the underlying Wintun driver does not support this.
115
+
116
+ On macOS, configuring an IP address with ``ifconfig(ip=..., netmask=...)`` also
117
+ adds a route for that subnet through the ``utun`` interface. Routes that conflict
118
+ with an existing route may require administrator privileges or manual adjustment.
119
+
120
+ You can make pytap3 a drop-in replacement for pytap2 by just importing it like this:
121
+ ``import pytap3 as pytap2``
122
+
123
+ Releases
124
+ --------
125
+
126
+ 3.0.0 - 2024-06-05
127
+ ^^^^^^^^^^^^^^^^^^
128
+
129
+ - Windows, Linux and macOS support!
@@ -0,0 +1,15 @@
1
+ LICENSE.txt
2
+ MANIFEST.in
3
+ README.rst
4
+ pyproject.toml
5
+ pytap3_build.py
6
+ setup.py
7
+ src/pytap3/__init__.py
8
+ src/pytap3/py.typed
9
+ src/pytap3.egg-info/PKG-INFO
10
+ src/pytap3.egg-info/SOURCES.txt
11
+ src/pytap3.egg-info/dependency_links.txt
12
+ src/pytap3.egg-info/not-zip-safe
13
+ src/pytap3.egg-info/top_level.txt
14
+ tests/test_pytap3.py
15
+ tests/test_pytap3_build.py
@@ -0,0 +1 @@
1
+ pytap3
@@ -0,0 +1,115 @@
1
+ import errno
2
+ import os
3
+ import socket
4
+ import struct
5
+ import unittest
6
+ from unittest.mock import Mock, call, patch
7
+
8
+ from pytap3 import IFF_TUN, TapDevice, _MacOSBackend
9
+
10
+
11
+ class TestCase(unittest.TestCase):
12
+ def test(self) -> None:
13
+ pass
14
+
15
+
16
+ @unittest.skipIf(os.name == "nt", "the macOS backend requires fcntl")
17
+ class MacOSBackendTestCase(unittest.TestCase):
18
+ def test_write_encodes_address_family_in_network_byte_order(self) -> None:
19
+ backend = _MacOSBackend.__new__(_MacOSBackend)
20
+ backend.sock = Mock()
21
+ packet = b"\x45\x00"
22
+ frame = struct.pack("!I", socket.AF_INET) + packet
23
+ backend.sock.send.return_value = len(frame)
24
+
25
+ self.assertEqual(backend.write(packet), len(packet))
26
+ backend.sock.send.assert_called_once_with(frame)
27
+
28
+ @patch("pytap3.fcntl.ioctl")
29
+ @patch("pytap3.socket.socket")
30
+ def test_automatic_name_uses_next_available_unit(
31
+ self, socket_factory: Mock, _ioctl: Mock
32
+ ) -> None:
33
+ busy_socket = Mock()
34
+ busy_socket.connect.side_effect = OSError(errno.EBUSY, "resource busy")
35
+ available_socket = Mock()
36
+ available_socket.getsockopt.return_value = b"utun1\0"
37
+ socket_factory.side_effect = [busy_socket, available_socket]
38
+
39
+ backend = _MacOSBackend(None, IFF_TUN, True)
40
+
41
+ self.assertEqual(backend.name, "utun1")
42
+ busy_socket.connect.assert_called_once_with((0, 1))
43
+ available_socket.connect.assert_called_once_with((0, 2))
44
+ busy_socket.close.assert_called_once_with()
45
+ self.assertIs(backend.sock, available_socket)
46
+
47
+ @patch("pytap3.fcntl.ioctl")
48
+ @patch("pytap3.socket.socket")
49
+ def test_requested_name_uses_exact_unit(
50
+ self, socket_factory: Mock, _ioctl: Mock
51
+ ) -> None:
52
+ requested_socket = Mock()
53
+ requested_socket.getsockopt.return_value = b"utun4\0"
54
+ socket_factory.return_value = requested_socket
55
+
56
+ backend = _MacOSBackend("utun4", IFF_TUN, True)
57
+
58
+ self.assertEqual(backend.name, "utun4")
59
+ requested_socket.connect.assert_called_once_with((0, 5))
60
+ self.assertIs(backend.sock, requested_socket)
61
+
62
+ @patch("pytap3.fcntl.ioctl")
63
+ @patch("pytap3.socket.socket")
64
+ def test_requested_busy_unit_retries_and_uses_assigned_name(
65
+ self, socket_factory: Mock, _ioctl: Mock
66
+ ) -> None:
67
+ busy_socket = Mock()
68
+ busy_socket.connect.side_effect = OSError(errno.EBUSY, "Resource busy")
69
+ available_socket = Mock()
70
+ available_socket.getsockopt.return_value = b"utun5\0"
71
+ socket_factory.side_effect = [busy_socket, available_socket]
72
+
73
+ backend = _MacOSBackend("utun4", IFF_TUN, True)
74
+
75
+ self.assertEqual(backend.name, "utun5")
76
+ busy_socket.connect.assert_called_once_with((0, 5))
77
+ available_socket.connect.assert_called_once_with((0, 6))
78
+ busy_socket.close.assert_called_once_with()
79
+ self.assertIs(backend.sock, available_socket)
80
+
81
+
82
+ class MacOSRouteTestCase(unittest.TestCase):
83
+ @patch("pytap3._run")
84
+ def test_ifconfig_adds_route_for_configured_subnet(self, run: Mock) -> None:
85
+ device = TapDevice.__new__(TapDevice)
86
+ device._platform = "Darwin"
87
+ device.name = "utun3"
88
+ device.mtu = 1500
89
+
90
+ device.ifconfig(ip="10.0.13.7", netmask="255.255.240.0")
91
+
92
+ self.assertEqual(
93
+ run.call_args_list,
94
+ [
95
+ call(
96
+ "ifconfig", "utun3", "inet", "10.0.13.7", "10.0.0.1",
97
+ "netmask", "255.255.240.0", "mtu", "1500",
98
+ ),
99
+ call(
100
+ "route", "-n", "add", "-net", "10.0.0.0/20",
101
+ "-interface", "utun3",
102
+ ),
103
+ ],
104
+ )
105
+
106
+ @patch("pytap3._run")
107
+ def test_ifconfig_without_address_does_not_add_route(self, run: Mock) -> None:
108
+ device = TapDevice.__new__(TapDevice)
109
+ device._platform = "Darwin"
110
+ device.name = "utun3"
111
+ device.mtu = 1500
112
+
113
+ device.ifconfig(mtu=1400)
114
+
115
+ run.assert_called_once_with("ifconfig", "utun3", "mtu", "1400")
@@ -0,0 +1,121 @@
1
+ import hashlib
2
+ import io
3
+ from pathlib import Path
4
+ import tempfile
5
+ import unittest
6
+ from unittest.mock import patch
7
+ import zipfile
8
+
9
+ import pytap3_build
10
+ from setuptools import Distribution
11
+
12
+
13
+ def _wintun_archive() -> bytes:
14
+ buffer = io.BytesIO()
15
+ with zipfile.ZipFile(buffer, "w") as archive:
16
+ archive.writestr("wintun/LICENSE.txt", "Wintun license")
17
+ for architecture in ("x86", "amd64", "arm", "arm64"):
18
+ archive.writestr("wintun/bin/{}/wintun.dll".format(architecture), architecture)
19
+ return buffer.getvalue()
20
+
21
+
22
+ class WintunBuildTestCase(unittest.TestCase):
23
+ def test_architecture_matches_windows_python_platform(self) -> None:
24
+ for platform_name, expected in (
25
+ ("win32", "x86"),
26
+ ("win-amd64", "amd64"),
27
+ ("win-arm32", "arm"),
28
+ ("win-arm64", "arm64"),
29
+ ):
30
+ with self.subTest(platform=platform_name):
31
+ with patch(
32
+ "pytap3_build.sysconfig.get_platform",
33
+ return_value=platform_name,
34
+ ):
35
+ self.assertEqual(pytap3_build.get_wintun_architecture(), expected)
36
+
37
+ def test_unsupported_platform_is_reported(self) -> None:
38
+ with patch("pytap3_build.sysconfig.get_platform", return_value="win-unknown"):
39
+ with self.assertRaisesRegex(RuntimeError, "win-unknown"):
40
+ pytap3_build.get_wintun_architecture()
41
+
42
+ def test_download_extracts_matching_dll_and_license(self) -> None:
43
+ archive_data = _wintun_archive()
44
+ with tempfile.TemporaryDirectory() as directory:
45
+ destination = Path(directory) / "pytap3" / "wintun.dll"
46
+ with patch(
47
+ "pytap3_build.urllib.request.urlopen",
48
+ return_value=io.BytesIO(archive_data),
49
+ ) as urlopen:
50
+ with patch.object(
51
+ pytap3_build,
52
+ "WINTUN_SHA256",
53
+ hashlib.sha256(archive_data).hexdigest(),
54
+ ):
55
+ pytap3_build.download_wintun(destination, "arm64")
56
+
57
+ urlopen.assert_called_once_with(pytap3_build.WINTUN_URL, timeout=30)
58
+ self.assertEqual(destination.read_bytes(), b"arm64")
59
+ self.assertEqual(
60
+ (destination.parent / "wintun-LICENSE.txt").read_bytes(),
61
+ b"Wintun license",
62
+ )
63
+
64
+ def test_download_rejects_unexpected_archive(self) -> None:
65
+ with tempfile.TemporaryDirectory() as directory:
66
+ destination = Path(directory) / "pytap3" / "wintun.dll"
67
+ with patch(
68
+ "pytap3_build.urllib.request.urlopen",
69
+ return_value=io.BytesIO(b"not the official archive"),
70
+ ):
71
+ with self.assertRaisesRegex(ValueError, "SHA-256 mismatch"):
72
+ pytap3_build.download_wintun(destination, "amd64")
73
+ self.assertFalse(destination.exists())
74
+
75
+ def test_windows_build_includes_downloaded_files_as_outputs(self) -> None:
76
+ command = pytap3_build.WintunBuildPy(Distribution({"packages": []}))
77
+ command.ensure_finalized()
78
+ with tempfile.TemporaryDirectory() as directory:
79
+ command.build_lib = directory
80
+ with patch("pytap3_build.sys.platform", "win32"):
81
+ with patch("pytap3_build.build_py.run"):
82
+ with patch("pytap3_build.get_wintun_architecture", return_value="amd64"):
83
+ with patch("pytap3_build.download_wintun") as download:
84
+ command.run()
85
+ download.assert_called_once_with(
86
+ Path(directory) / "pytap3" / "wintun.dll", "amd64"
87
+ )
88
+ outputs = command.get_outputs()
89
+ self.assertIn(
90
+ str(Path(directory) / "pytap3" / "wintun.dll"),
91
+ outputs,
92
+ )
93
+ self.assertIn(
94
+ str(Path(directory) / "pytap3" / "wintun-LICENSE.txt"),
95
+ outputs,
96
+ )
97
+
98
+ def test_non_windows_build_does_not_download_wintun(self) -> None:
99
+ command = pytap3_build.WintunBuildPy(Distribution({"packages": []}))
100
+ command.ensure_finalized()
101
+ with tempfile.TemporaryDirectory() as directory:
102
+ command.build_lib = directory
103
+ for platform_name in ("linux", "darwin"):
104
+ with self.subTest(platform=platform_name):
105
+ with patch("pytap3_build.sys.platform", platform_name):
106
+ with patch("pytap3_build.build_py.run"):
107
+ with patch("pytap3_build.download_wintun") as download:
108
+ command.run()
109
+ download.assert_not_called()
110
+
111
+ def test_wheel_uses_platform_tag_without_python_abi_tag(self) -> None:
112
+ command = pytap3_build.PlatformWheel(Distribution())
113
+ with patch("pytap3_build.bdist_wheel.finalize_options"):
114
+ command.finalize_options()
115
+ self.assertFalse(command.root_is_pure)
116
+
117
+ with patch(
118
+ "pytap3_build.bdist_wheel.get_tag",
119
+ return_value=("cp315", "cp315", "win_amd64"),
120
+ ):
121
+ self.assertEqual(command.get_tag(), ("py3", "none", "win_amd64"))