From 5bf28faae1f9040a66bc7690e374844005ed34e1 Mon Sep 17 00:00:00 2001 From: Qeggs Date: Mon, 8 Jun 2026 21:50:47 +0800 Subject: [PATCH 1/8] feat: Add context manager support to Socket class - Add close() method for explicit socket cleanup - Implement __enter__ and __exit__ for with-statement support - Update __del__ to call close() instead of direct socket.close() - Enables proper resource management using 'with Socket(...) as s' --- pythonping/network.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/pythonping/network.py b/pythonping/network.py index 080b9b0..55d2707 100644 --- a/pythonping/network.py +++ b/pythonping/network.py @@ -74,11 +74,21 @@ def receive(self, timeout=2): return b'', '', time_left packet, source = self.socket.recvfrom(self.buffer_size) return packet, source, time_left + + def close(self): + """Close the socket""" + self.socket.close() + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.close() def __del__(self): try: if hasattr(self, "socket") and self.socket: - self.socket.close() + self.close() except AttributeError: raise AttributeError("Attribute error because of failed socket init. Make sure you have the root privilege." " This error may also be caused from DNS resolution problems.") From d0a49d3071a5a0aff6c87147e91e4522584f009b Mon Sep 17 00:00:00 2001 From: Qeggs Date: Mon, 8 Jun 2026 21:51:39 +0800 Subject: [PATCH 2/8] feat: Add factory method and context manager to Communicator class - Add _create_socket static method for socket instantiation - Allow subclasses to override socket creation (e.g., async socket) - Add close() method to properly close the underlying socket - Implement __enter__ and __exit__ for with-statement support --- pythonping/executor.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/pythonping/executor.py b/pythonping/executor.py index 085c6b1..270e658 100644 --- a/pythonping/executor.py +++ b/pythonping/executor.py @@ -290,7 +290,7 @@ def __init__(self, target, payload_provider, timeout, interval, socket_options=( :type output: file :param repr_format: How to __repr__ the response. Allowed: legacy, None :type repr_format: str""" - self.socket = network.Socket(target, 'icmp', options=socket_options, source=source) + self.socket = self._create_socket(target, 'icmp', options=socket_options, source=source) self.provider = payload_provider self.timeout = timeout self.interval = interval @@ -300,6 +300,16 @@ def __init__(self, target, payload_provider, timeout, interval, socket_options=( # note that to make Communicator instances thread safe, the seed ID must be unique per thread if self.seed_id is None: self.seed_id = os.getpid() & 0xFFFF + + @staticmethod + def _create_socket( + target, + protocol='icmp', + options=(), + buffer_size=2048, + source=None, + ): + return network.Socket(target, protocol, options, buffer_size, source) def __del__(self): pass @@ -386,3 +396,13 @@ def run(self, match_payloads=False): if self.interval: time.sleep(self.interval) + + def close(self): + """Closes the socket""" + self.socket.close() + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.close() \ No newline at end of file From 676cd1247146abe5f6f745fb51047337fb505ec1 Mon Sep 17 00:00:00 2001 From: Qeggs Date: Mon, 8 Jun 2026 21:54:35 +0800 Subject: [PATCH 3/8] feat: Add AsyncSocket class for asynchronous network communication - Create new aionetwork.py module for async socket support - Add AsyncSocket inheriting from Socket with non-blocking socket - Implement async send() using loop.sock_sendto - Implement async receive() using asyncio.wait_for and loop.sock_recvfrom - Set socket to non-blocking mode for asyncio integration --- pythonping/aionetwork.py | 57 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 pythonping/aionetwork.py diff --git a/pythonping/aionetwork.py b/pythonping/aionetwork.py new file mode 100644 index 0000000..f9b37d9 --- /dev/null +++ b/pythonping/aionetwork.py @@ -0,0 +1,57 @@ +import time +import select +import asyncio +from . import network + +class AsyncSocket(network.Socket): + def __init__(self, destination, protocol, options=(), buffer_size=2048, source=None): + """Creates a async network socket to exchange messages + + :param destination: Destination IP address + :type destination: str + :param protocol: Name of the protocol to use + :type protocol: str + :param options: Options to set on the socket + :type options: tuple + :param source: Source IP to use - implemented in future releases + :type source: Union[None, str] + :param buffer_size: Size in bytes of the listening buffer for incoming packets (replies) + :type buffer_size: int""" + if options is None: + options = () + super().__init__(destination, protocol, options, buffer_size, source) + # Nonblocking is required here to support asynchronous operations. + self.socket.setblocking(False) + + async def send(self, packet): + """Sends a raw packet on the stream + + :param packet: The raw packet to send + :type packet: bytes""" + loop = asyncio.get_running_loop() + if self.source: + self.socket.bind((self.source, 0)) + await loop.sock_sendto(self.socket, packet, (self.destination, 0)) + + async def receive(self, timeout=2): + """Listen for incoming packets until timeout + + :param timeout: Time after which stop listening + :type timeout: Union[int, float] + :return: The packet, the remote socket, and the time left before timeout + :rtype: (bytes, tuple, float)""" + loop = asyncio.get_running_loop() + start_time = time.perf_counter() + try: + response = await asyncio.wait_for( + loop.sock_recvfrom( + self.socket, + self.buffer_size + ), + timeout = timeout + ) + packet, source = response + except asyncio.TimeoutError: + packet, source = b"", b"" + end_time = time.perf_counter() + return packet, source, timeout - (end_time - start_time) \ No newline at end of file From 66d35bbbfbffa4b592b0a302c657a1dc29012271 Mon Sep 17 00:00:00 2001 From: Qeggs Date: Mon, 8 Jun 2026 21:55:19 +0800 Subject: [PATCH 4/8] feat: Add AsyncCommunicator class for async ping execution - Create new aioexecutor.py module for async executor support - Add AsyncCommunicator inheriting from Communicator - Override _create_socket to return AsyncSocket instead of Socket - Implement async send_ping() for sending ICMP requests - Implement async listen_for() for receiving and matching responses - Implement async run() to perform all pings asynchronously - Use asyncio.sleep() instead of time.sleep() for non-blocking intervals --- pythonping/aioexecutor.py | 88 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 pythonping/aioexecutor.py diff --git a/pythonping/aioexecutor.py b/pythonping/aioexecutor.py new file mode 100644 index 0000000..893aa0b --- /dev/null +++ b/pythonping/aioexecutor.py @@ -0,0 +1,88 @@ +import asyncio + +from . import executor +from . import aionetwork +from . import icmp + +class AsyncCommunicator(executor.Communicator): + socket: aionetwork.AsyncSocket + + @staticmethod + def _create_socket( + target, + protocol='icmp', + options=(), + buffer_size=2048, + source=None, + ): + return aionetwork.AsyncSocket(target, protocol, options, buffer_size, source) + + async def send_ping(self, packet_id, sequence_number, payload): + """Sends one ICMP Echo Request on the socket + + :param packet_id: The ID to use for the packet + :type packet_id: int + :param sequence_number: The sequence number to use for the packet + :type sequence_number: int + :param payload: The payload of the ICMP message + :type payload: Union[str, bytes] + :rtype: ICMP""" + i = icmp.ICMP( + icmp.Types.EchoRequest, + payload=payload, + identifier=packet_id, sequence_number=sequence_number) + await self.socket.send(i.packet) + return i + + async def listen_for(self, packet_id, timeout, payload_pattern=None, source_request=None): + """Listens for a packet of a given id for a given timeout + + :param packet_id: The ID of the packet to listen for, the same for request and response + :type packet_id: int + :param timeout: How long to listen for the specified packet, in seconds + :type timeout: float + :param payload_pattern: Payload reply pattern to match to request, if set to None, match by ID only + :type payload_pattern: Union[None, bytes] + :return: The response to the request with the specified packet_id + :rtype: Response""" + time_left = timeout + response = icmp.ICMP() + while time_left > 0: + # Keep listening until a packet arrives + raw_packet, source_socket, time_left = await self.socket.receive(time_left) + # If we actually received something + if raw_packet != b'': + response.unpack(raw_packet) + + # Ensure we have not unpacked the packet we sent (RHEL will also listen to outgoing packets) + if response.id == packet_id and response.message_type != icmp.Types.EchoRequest.type_id: + if payload_pattern is None: + # To allow Windows-like behaviour (no payload inspection, but only match packet identifiers), + # simply allow for it to be an always true in the legacy usage case + payload_matched = True + else: + payload_matched = (payload_pattern == response.payload) + + if payload_matched: + return executor.Response(executor.Message('', response, source_socket[0]), timeout - time_left, source_request, repr_format=self.repr_format) + return executor.Response(None, timeout, source_request, repr_format=self.repr_format) + + async def run(self, match_payloads=False): + """Performs all the pings and stores the responses + + :param match_payloads: optional to set to True to make sure requests and replies have equivalent payloads + :type match_payloads: bool""" + self.responses.clear() + identifier = self.seed_id + seq = 1 + for payload in self.provider: + icmp_out = await self.send_ping(identifier, seq, payload) + if not match_payloads: + self.responses.append(await self.listen_for(identifier, self.timeout, None, icmp_out)) + else: + self.responses.append(await self.listen_for(identifier, self.timeout, icmp_out.payload, icmp_out)) + + seq = self.increase_seq(seq) + + if self.interval: + await asyncio.sleep(self.interval) From 6edfa14419139536402ffe09da68d4fce8eccfc3 Mon Sep 17 00:00:00 2001 From: Qeggs Date: Mon, 8 Jun 2026 21:55:59 +0800 Subject: [PATCH 5/8] feat: Add aping() async function as main entry point for asynchronous ping - Create new aioping.py module for async ping API - Add aping() function with same parameters as sync ping() - Use AsyncCommunicator with context manager for proper resource cleanup - Maintain same payload provider logic (Repeat/Sweep) as sync version - Support DF flag via AsyncSocket.DONT_FRAGMENT - Reuse SEED_IDs mechanism for thread-safe unique identifiers - Update docstring: 'Async ping to remote host handling responses' --- pythonping/aioping.py | 88 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 pythonping/aioping.py diff --git a/pythonping/aioping.py b/pythonping/aioping.py new file mode 100644 index 0000000..c7b54e0 --- /dev/null +++ b/pythonping/aioping.py @@ -0,0 +1,88 @@ +import sys +from random import randint +from . import aionetwork, aioexecutor, payload_provider +from .utils import random_text + + +# this needs to be available across all thread usages and will hold ints +SEED_IDs = [] + + +async def aping(target, + timeout=2, + count=4, + size=1, + interval=0, + payload=None, + sweep_start=None, + sweep_end=None, + df=False, + verbose=False, + out=sys.stdout, + match=False, + source=None, + out_format='legacy'): + """Async ping to remote host handling responses + + :param target: The remote hostname or IP address to ping + :type target: str + :param timeout: Time in seconds before considering each non-arrived reply permanently lost. + :type timeout: Union[int, float] + :param count: How many times to attempt the ping + :type count: int + :param size: Size of the entire packet to send + :type size: int + :param interval: Interval to wait between pings + :type interval: int + :param payload: Payload content, leave None if size is set to use random text + :type payload: Union[str, bytes] + :param sweep_start: If size is not set, initial size in a sweep of sizes + :type sweep_start: int + :param sweep_end: If size is not set, final size in a sweep of sizes + :type sweep_end: int + :param df: Don't Fragment flag value for IP Header + :type df: bool + :param verbose: Print output while performing operations + :type verbose: bool + :param out: Stream to which redirect the verbose output + :type out: stream + :param match: Do payload matching between request and reply (default behaviour follows that of Windows which is + by packet identifier only, Linux behaviour counts a non equivalent payload in reply as fail, such as when pinging + 8.8.8.8 with 1000 bytes and reply is truncated to only the first 74 of request payload with packet identifiers + the same in request and reply) + :type match: bool + :param repr_format: How to __repr__ the response. Allowed: legacy, None + :type repr_format: str + :return: List with the result of each ping + :rtype: executor.ResponseList""" + provider = payload_provider.Repeat(b'', 0) + if sweep_start and sweep_end and sweep_end >= sweep_start: + if not payload: + payload = random_text(sweep_start) + provider = payload_provider.Sweep(payload, sweep_start, sweep_end) + elif size and size > 0: + if not payload: + payload = random_text(size) + provider = payload_provider.Repeat(payload, count) + options = () + if df: + options = aionetwork.AsyncSocket.DONT_FRAGMENT + + # Fix to allow for pythonping multithreaded usage; + # no need to protect this loop as no one will ever surpass 0xFFFF amount of threads + while True: + # seed_id needs to be less than or equal to 65535 (as original code was seed_id = getpid() & 0xFFFF) + seed_id = randint(0x1, 0xFFFF) + if seed_id not in SEED_IDs: + SEED_IDs.append(seed_id) + break + + + with aioexecutor.AsyncCommunicator(target, provider, timeout, interval, socket_options=options, verbose=verbose, output=out, + seed_id=seed_id, source=source, repr_format=out_format) as comm: + + await comm.run(match_payloads=match) + + SEED_IDs.remove(seed_id) + + return comm.responses From 0ec91b2946d92d98a8210923f431dfcadd362e30 Mon Sep 17 00:00:00 2001 From: Qeggs Date: Mon, 8 Jun 2026 21:59:43 +0800 Subject: [PATCH 6/8] feat: Expose aping() as public API in top-level module - Import aping from .aioping module - Make async ping available via pythonping.aping() --- pythonping/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pythonping/__init__.py b/pythonping/__init__.py index 2b3c29e..bd1cc57 100644 --- a/pythonping/__init__.py +++ b/pythonping/__init__.py @@ -2,7 +2,7 @@ from random import randint from . import network, executor, payload_provider from .utils import random_text - +from .aioping import aping # this needs to be available across all thread usages and will hold ints SEED_IDs = [] From 909ebc2b8069f2c795ed71d99bd17e102eff22a7 Mon Sep 17 00:00:00 2001 From: Qeggs Date: Mon, 8 Jun 2026 22:01:50 +0800 Subject: [PATCH 7/8] fix: move SEED_IDs cleanup and return inside context manager in aping() - Move SEED_IDs.remove(seed_id) inside with block - Move return comm.responses inside with block - Ensures proper ordering of resource cleanup and response return - Prevents potential issues when socket cleanup is needed before return --- pythonping/aioping.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pythonping/aioping.py b/pythonping/aioping.py index c7b54e0..f2b0b44 100644 --- a/pythonping/aioping.py +++ b/pythonping/aioping.py @@ -83,6 +83,6 @@ async def aping(target, await comm.run(match_payloads=match) - SEED_IDs.remove(seed_id) + SEED_IDs.remove(seed_id) - return comm.responses + return comm.responses From dc1b5471e0203fc266375d48650340e37f89dfe7 Mon Sep 17 00:00:00 2001 From: Qeggs Date: Mon, 8 Jun 2026 22:03:15 +0800 Subject: [PATCH 8/8] refactor: use context manager in ping() for proper socket cleanup - Replace explicit Communicator instantiation with with-statement - Move SEED_IDs.remove(seed_id) inside with block - Move return comm.responses inside with block - Ensures socket is properly closed even if run() throws exception - Maintains identical behavior and return value --- pythonping/__init__.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pythonping/__init__.py b/pythonping/__init__.py index bd1cc57..c5ea7f4 100644 --- a/pythonping/__init__.py +++ b/pythonping/__init__.py @@ -78,11 +78,11 @@ def ping(target, break - comm = executor.Communicator(target, provider, timeout, interval, socket_options=options, verbose=verbose, output=out, - seed_id=seed_id, source=source, repr_format=out_format) + with executor.Communicator(target, provider, timeout, interval, socket_options=options, verbose=verbose, output=out, + seed_id=seed_id, source=source, repr_format=out_format) as comm: - comm.run(match_payloads=match) + comm.run(match_payloads=match) - SEED_IDs.remove(seed_id) + SEED_IDs.remove(seed_id) - return comm.responses + return comm.responses