1
0
mirror of https://github.com/craigerl/aprsd.git synced 2026-04-06 23:25:34 -04:00

Merge pull request #204 from craigerl/serial-kiss

Added SerialKISSDriver
This commit is contained in:
Walter A. Boring IV 2025-12-08 15:35:50 -05:00 committed by GitHub
commit 9c06957943
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 499 additions and 165 deletions

View File

@ -113,6 +113,7 @@ You can see the [available plugins/extensions on pypi here:](https://pypi.org/se
- [aprsd-admin-extension](https://github.com/hemna/aprsd-admin-extension) - Web Administration page for APRSD
- [aprsd-webchat-extension](https://github.com/hemna/aprsd-webchat-extension) - Web page for APRS Messaging
- [aprsd-rich-cli-extension](https://github.com/hemna/aprsd-rich-cli-extension) - Textual rich CLI versions of aprsd commands
- [aprsd-irc-extension](https://github.com/hemna/aprsd-irc-extension) - an IRC like server command for APRS
### APRSD Overview Diagram
@ -299,6 +300,8 @@ file
│ 📂 aprsd-admin-extension │ Administration extension for the Ham radio APRSD Server │ 1.0.1 │ 2025-01-06T21:57:24 │ Yes │
│ 📂 aprsd-irc-extension │ An Extension to Ham radio APRSD Daemon to act like an irc server │ 0.0.5 │ 2024-04-09T11:28:47 │ No │
│ │ for APRS │ │ │ │
│ 📂 aprsd-rich-cli-extens │ APRSD Extension to create textual rich CLI versions of aprsd │ 0.1.1 │ 2024-12-01T00:00:00 │ No │
│ ion │ commands │ │ │ │
│ 📂 aprsd-webchat-extens │ Web page for APRS Messaging │ 1.2.3 │ 2024-10-01T00:00:00 │ No │
│ ion │ │ │ │ │
└──────────────────────────┴─────────────────────────────────────────────────────────────────────┴─────────┴─────────────────────┴────────────┘

View File

@ -30,6 +30,13 @@ class APRSDClient(metaclass=trace.TraceWrapperMetaclass):
driver = None
lock = threading.Lock()
filter = None
connected = False
running = False
auto_connect = True
login_status = {
'success': False,
'message': None,
}
def __new__(cls, *args, **kwargs):
"""This magic turns this into a singleton."""
@ -40,12 +47,6 @@ class APRSDClient(metaclass=trace.TraceWrapperMetaclass):
def __init__(self, auto_connect: bool = True):
self.auto_connect = auto_connect
self.connected = False
self.running = False
self.login_status = {
'success': False,
'message': None,
}
if not self.driver:
self.driver = DriverRegistry().get_driver()
if self.auto_connect:
@ -72,12 +73,6 @@ class APRSDClient(metaclass=trace.TraceWrapperMetaclass):
return True
return False
# @property
# def is_connected(self):
# if not self.driver:
# return False
# return self.driver.is_connected()
@property
def login_success(self):
if not self.driver:
@ -106,6 +101,7 @@ class APRSDClient(metaclass=trace.TraceWrapperMetaclass):
@wrapt.synchronized(lock)
def connect(self):
# log a stack trace here to find out who is calling us.
if not self.driver:
self.driver = DriverRegistry().get_driver()
if not self.connected:

View File

@ -2,9 +2,11 @@
from aprsd.client.drivers.aprsis import APRSISDriver
from aprsd.client.drivers.fake import APRSDFakeDriver
from aprsd.client.drivers.registry import DriverRegistry
from aprsd.client.drivers.serialkiss import SerialKISSDriver
from aprsd.client.drivers.tcpkiss import TCPKISSDriver
driver_registry = DriverRegistry()
driver_registry.register(APRSDFakeDriver)
driver_registry.register(APRSISDriver)
driver_registry.register(TCPKISSDriver)
driver_registry.register(SerialKISSDriver)

View File

@ -0,0 +1,184 @@
"""
APRSD KISS Client Driver Base classusing native KISS implementation.
This module provides a KISS client driver for APRSD using the new
non-asyncio KISSInterface implementation.
"""
import datetime
import logging
from typing import Any, Callable, Dict
import aprslib
from kiss import util as kissutil
from aprsd.packets import core
from aprsd.utils import trace
LOG = logging.getLogger('APRSD')
class KISSDriver(metaclass=trace.TraceWrapperMetaclass):
"""APRSD KISS Client Driver Base class."""
packets_received = 0
packets_sent = 0
last_packet_sent = None
last_packet_received = None
keepalive = None
# timeout in seconds
select_timeout = 1
def __init__(self):
"""Initialize the KISS client.
Args:
client_name: Name of the client instance
"""
super().__init__()
self._connected = False
self.keepalive = datetime.datetime.now()
def login_success(self) -> bool:
"""There is no login for KISS."""
if not self._connected:
return False
return True
def login_failure(self) -> str:
"""There is no login for KISS."""
return 'Login successful'
def set_filter(self, filter_text: str):
"""Set packet filter (not implemented for KISS).
Args:
filter_text: Filter specification (ignored for KISS)
"""
# KISS doesn't support filtering at the TNC level
pass
@property
def filter(self) -> str:
"""Get packet filter (not implemented for KISS).
Returns:
str: Empty string (not implemented for KISS)
"""
return ''
@property
def is_alive(self) -> bool:
"""Check if the client is connected.
Returns:
bool: True if connected to KISS TNC, False otherwise
"""
return self._connected
def _handle_fend(self, buffer: bytes, strip_df_start: bool = True) -> bytes:
"""
Handle FEND (end of frame) encountered in a KISS data stream.
:param buffer: the buffer containing the frame
:param strip_df_start: remove leading null byte (DATA_FRAME opcode)
:return: the bytes of the frame without escape characters or frame
end markers (FEND)
"""
frame = kissutil.recover_special_codes(kissutil.strip_nmea(bytes(buffer)))
if strip_df_start:
frame = kissutil.strip_df_start(frame)
return bytes(frame)
def fix_raw_frame(self, raw_frame: bytes) -> bytes:
"""Fix the raw frame by recalculating the FCS."""
ax25_data = raw_frame[2:-1] # Remove KISS markers
return self._handle_fend(ax25_data)
def decode_packet(self, *args, **kwargs) -> core.Packet:
"""Decode a packet from an AX.25 frame.
Args:
frame: Received AX.25 frame
"""
frame = kwargs.get('frame')
if not frame:
LOG.warning('No frame received to decode?!?!')
return None
try:
aprslib_frame = aprslib.parse(str(frame))
packet = core.factory(aprslib_frame)
if isinstance(packet, core.ThirdPartyPacket):
return packet.subpacket
else:
return packet
except Exception as e:
LOG.error(f'Error decoding packet: {e}')
return None
def consumer(self, callback: Callable, raw: bool = False):
"""Start consuming frames with the given callback.
Args:
callback: Function to call with received packets
Raises:
Exception: If not connected to KISS TNC
"""
# Ensure connection
if not self._connected:
return
# Read frame
frame = self.read_frame()
if frame:
LOG.info(f'GOT FRAME: {frame} calling {callback}')
kwargs = {
'frame': frame,
}
callback(**kwargs)
def read_frame(self):
"""Read a frame from the KISS interface.
This is implemented in the subclass.
"""
raise NotImplementedError('read_frame is not implemented for KISS')
@trace.no_trace
def stats(self, serializable: bool = False) -> Dict[str, Any]:
"""Get client statistics.
Returns:
Dict containing client statistics
"""
if serializable:
keepalive = self.keepalive.isoformat()
if self.last_packet_sent:
last_packet_sent = self.last_packet_sent.isoformat()
else:
last_packet_sent = 'None'
if self.last_packet_received:
last_packet_received = self.last_packet_received.isoformat()
else:
last_packet_received = 'None'
else:
keepalive = self.keepalive
last_packet_sent = self.last_packet_sent
last_packet_received = self.last_packet_received
stats = {
'client': self.__class__.__name__,
'transport': self.transport,
'connected': self._connected,
'path': self.path,
'packets_sent': self.packets_sent,
'packets_received': self.packets_received,
'last_packet_sent': last_packet_sent,
'last_packet_received': last_packet_received,
'connection_keepalive': keepalive,
}
return stats

View File

@ -0,0 +1,249 @@
"""
APRSD KISS Client Driver using native KISS implementation.
This module provides a KISS client driver for APRSD using the new
non-asyncio KISSInterface implementation.
"""
import datetime
import logging
# import select
from typing import Any, Dict
import serial
from ax253 import frame as ax25frame
from kiss import constants as kiss_constants
from kiss import util as kissutil
from kiss.kiss import Command
from oslo_config import cfg
from aprsd import ( # noqa
client,
conf, # noqa
exception,
)
from aprsd.client.drivers.kiss_common import KISSDriver
from aprsd.packets import core
from aprsd.utils import trace
CONF = cfg.CONF
LOG = logging.getLogger('APRSD')
class SerialKISSDriver(KISSDriver):
"""APRSD client driver for Serial KISS connections."""
_instance = None
def __new__(cls, *args, **kwargs):
"""This magic turns this into a singleton."""
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self):
"""Initialize the KISS client.
Args:
client_name: Name of the client instance
"""
super().__init__()
self._connected = False
self.keepalive = datetime.datetime.now()
# This is initialized in setup_connection()
self.socket = None
@property
def transport(self) -> str:
return client.TRANSPORT_SERIALKISS
@staticmethod
def is_enabled() -> bool:
"""Check if KISS is enabled in configuration.
Returns:
bool: True if either Serial is enabled
"""
return CONF.kiss_serial.enabled
@staticmethod
def is_configured():
# Ensure that the config vars are correctly set
if SerialKISSDriver.is_enabled():
if not CONF.kiss_serial.device:
LOG.error('KISS Serial enabled, but no device is set.')
raise exception.MissingConfigOptionException(
'kiss_serial.device is not set.',
)
return True
return False
def close(self):
"""Close the connection."""
self._connected = False
if self.socket and self.socket.is_open:
try:
self.socket.close()
except Exception:
pass
def setup_connection(self):
"""Set up the KISS interface."""
if not self.is_enabled():
LOG.error('KISS is not enabled in configuration')
return
if self._connected:
LOG.warning('KISS interface already connected')
return
try:
# Configure for TCP KISS
if self.is_enabled():
LOG.info(
f'Serial KISS Connection to {CONF.kiss_serial.device}:{CONF.kiss_serial.baudrate}'
)
self.path = CONF.kiss_serial.path
self.connect()
if self._connected:
LOG.info('KISS interface initialized')
else:
LOG.error('Failed to connect to KISS interface')
except Exception as ex:
LOG.error('Failed to initialize KISS interface')
LOG.exception(ex)
self._connected = False
def connect(self):
"""Connect to the KISS interface."""
if not self.is_enabled():
LOG.error('KISS is not enabled in configuration')
return
if self._connected:
LOG.warning('KISS interface already connected')
return
# Close existing socket if it exists
if self.socket and self.socket.is_open:
try:
self.socket.close()
except Exception:
pass
try:
# serial.Serial() automatically opens the port, so we don't need to call open()
self.socket = serial.Serial(
CONF.kiss_serial.device,
timeout=1,
baudrate=CONF.kiss_serial.baudrate,
# bytesize=8,
# parity='N',
# stopbits=1,
# xonxoff=False,
# rtscts=False,
# dsrdtr=False,
)
self._connected = True
except serial.SerialException as e:
LOG.error(f'Failed to connect to KISS interface: {e}')
self._connected = False
return False
except Exception as ex:
LOG.error('Failed to connect to KISS interface')
LOG.exception(ex)
self._connected = False
def read_frame(self):
"""Read a frame from the KISS interface."""
if not self.socket:
return None
if not self._connected:
return None
while self._connected:
# try:
# readable, _, _ = select.select(
# [self.socket],
# [],
# [],
# self.select_timeout,
# )
# if not readable:
# continue
# except Exception as e:
# # No need to log if we are not running.
# # this happens when the client is stopped/closed.
# LOG.error(f'Error in read loop: {e}')
# self._connected = False
# break
try:
short_buf = self.socket.read(1024)
if not short_buf:
continue
raw_frame = self.fix_raw_frame(short_buf)
return ax25frame.Frame.from_bytes(raw_frame)
except Exception as e:
LOG.error(f'Error in read loop: {e}')
self._connected = False
break
def send(self, packet: core.Packet):
"""Send an APRS packet.
Args:
packet: APRS packet to send (Packet or Message object)
Raises:
Exception: If not connected or send fails
"""
if not self.socket:
raise Exception('KISS interface not initialized')
payload = None
path = self.path
packet.prepare()
payload = packet.payload.encode('US-ASCII')
if packet.path:
path = packet.path
LOG.debug(
f"KISS Send '{payload}' TO '{packet.to_call}' From "
f"'{packet.from_call}' with PATH '{path}'",
)
frame = ax25frame.Frame.ui(
destination='APZ100',
# destination=packet.to_call,
source=packet.from_call,
path=path,
info=payload,
)
# now escape the frame special characters
frame_escaped = kissutil.escape_special_codes(bytes(frame))
# and finally wrap the frame in KISS protocol
command = Command.DATA_FRAME
frame_kiss = b''.join(
[kiss_constants.FEND, command.value, frame_escaped, kiss_constants.FEND]
)
self.socket.write(frame_kiss)
# Update last packet sent time
self.last_packet_sent = datetime.datetime.now()
# Increment packets sent counter
self.packets_sent += 1
@trace.no_trace
def stats(self, serializable: bool = False) -> Dict[str, Any]:
"""Get client statistics.
Returns:
Dict containing client statistics
"""
stats = super().stats(serializable=serializable)
stats['device'] = CONF.kiss_serial.device
stats['baudrate'] = CONF.kiss_serial.baudrate
return stats

View File

@ -9,7 +9,7 @@ import datetime
import logging
import select
import socket
from typing import Any, Callable, Dict
from typing import Any, Dict
import aprslib
from ax253 import frame as ax25frame
@ -23,45 +23,22 @@ from aprsd import ( # noqa
conf, # noqa
exception,
)
from aprsd.client.drivers.kiss_common import KISSDriver
from aprsd.packets import core
from aprsd.utils import trace
CONF = cfg.CONF
LOG = logging.getLogger('APRSD')
def handle_fend(buffer: bytes, strip_df_start: bool = True) -> bytes:
"""
Handle FEND (end of frame) encountered in a KISS data stream.
:param buffer: the buffer containing the frame
:param strip_df_start: remove leading null byte (DATA_FRAME opcode)
:return: the bytes of the frame without escape characters or frame
end markers (FEND)
"""
frame = kissutil.recover_special_codes(kissutil.strip_nmea(bytes(buffer)))
if strip_df_start:
frame = kissutil.strip_df_start(frame)
LOG.warning(f'handle_fend {" ".join(f"{b:02X}" for b in bytes(frame))}')
return bytes(frame)
class TCPKISSDriver(metaclass=trace.TraceWrapperMetaclass):
class TCPKISSDriver(KISSDriver):
# class TCPKISSDriver:
"""APRSD client driver for TCP KISS connections."""
_instance = None
# Class level attributes required by Client protocol
packets_received = 0
packets_sent = 0
last_packet_sent = None
last_packet_received = None
keepalive = None
client_name = None
socket = None
# timeout in seconds
select_timeout = 1
path = None
def __new__(cls, *args, **kwargs):
@ -107,15 +84,6 @@ class TCPKISSDriver(metaclass=trace.TraceWrapperMetaclass):
return True
return False
@property
def is_alive(self) -> bool:
"""Check if the client is connected.
Returns:
bool: True if connected to KISS TNC, False otherwise
"""
return self._connected
def close(self):
"""Close the connection."""
self._connected = False
@ -200,112 +168,15 @@ class TCPKISSDriver(metaclass=trace.TraceWrapperMetaclass):
LOG.exception(ex)
self._connected = False
def set_filter(self, filter_text: str):
"""Set packet filter (not implemented for KISS).
Args:
filter_text: Filter specification (ignored for KISS)
"""
# KISS doesn't support filtering at the TNC level
pass
@property
def filter(self) -> str:
"""Get packet filter (not implemented for KISS).
Returns:
str: Empty string (not implemented for KISS)
"""
return ''
def login_success(self) -> bool:
"""There is no login for KISS."""
if not self._connected:
return False
return True
def login_failure(self) -> str:
"""There is no login for KISS."""
return 'Login successful'
def consumer(self, callback: Callable, raw: bool = False):
"""Start consuming frames with the given callback.
Args:
callback: Function to call with received packets
Raises:
Exception: If not connected to KISS TNC
"""
# Ensure connection
if not self._connected:
return
# Read frame
frame = self.read_frame()
if frame:
LOG.info(f'GOT FRAME: {frame} calling {callback}')
kwargs = {
'frame': frame,
}
callback(**kwargs)
def decode_packet(self, *args, **kwargs) -> core.Packet:
"""Decode a packet from an AX.25 frame.
Args:
frame: Received AX.25 frame
"""
frame = kwargs.get('frame')
if not frame:
LOG.warning('No frame received to decode?!?!')
return None
try:
aprslib_frame = aprslib.parse(str(frame))
packet = core.factory(aprslib_frame)
if isinstance(packet, core.ThirdPartyPacket):
return packet.subpacket
else:
return packet
except Exception as e:
LOG.error(f'Error decoding packet: {e}')
return None
def stats(self, serializable: bool = False) -> Dict[str, Any]:
"""Get client statistics.
Returns:
Dict containing client statistics
"""
if serializable:
keepalive = self.keepalive.isoformat()
if self.last_packet_sent:
last_packet_sent = self.last_packet_sent.isoformat()
else:
last_packet_sent = 'None'
if self.last_packet_received:
last_packet_received = self.last_packet_received.isoformat()
else:
last_packet_received = 'None'
else:
keepalive = self.keepalive
last_packet_sent = self.last_packet_sent
last_packet_received = self.last_packet_received
stats = {
'client': self.__class__.__name__,
'transport': self.transport,
'connected': self._connected,
'path': self.path,
'packets_sent': self.packets_sent,
'packets_received': self.packets_received,
'last_packet_sent': last_packet_sent,
'last_packet_received': last_packet_received,
'connection_keepalive': keepalive,
'host': CONF.kiss_tcp.host,
'port': CONF.kiss_tcp.port,
}
stats = super().stats(serializable=serializable)
stats['host'] = CONF.kiss_tcp.host
stats['port'] = CONF.kiss_tcp.port
return stats
def connect(self) -> bool:
@ -348,11 +219,6 @@ class TCPKISSDriver(metaclass=trace.TraceWrapperMetaclass):
self._connected = False
return False
def fix_raw_frame(self, raw_frame: bytes) -> bytes:
"""Fix the raw frame by recalculating the FCS."""
ax25_data = raw_frame[2:-1] # Remove KISS markers
return handle_fend(ax25_data)
def read_frame(self, blocking=False):
"""
Generator for complete lines, received from the server

View File

@ -130,6 +130,25 @@ def trace_method(f):
return trace_method_logging_wrapper
def no_trace(f):
"""Decorator to exclude a method from TraceWrapperMetaclass wrapping.
Use this decorator on methods that should not be wrapped with trace_method
by the TraceWrapperMetaclass.
Example:
class MyClass(metaclass=TraceWrapperMetaclass):
def traced_method(self):
pass # This will be wrapped
@no_trace
def not_traced_method(self):
pass # This will NOT be wrapped
"""
f.__no_trace__ = True
return f
class TraceWrapperMetaclass(type):
"""Metaclass that wraps all methods of a class with trace_method.
@ -143,12 +162,22 @@ class TraceWrapperMetaclass(type):
def __new__(cls, classname, bases, class_dict):
new_class_dict = {}
for attribute_name, attribute in class_dict.items():
if isinstance(attribute, types.FunctionType):
# replace it with a wrapped version
attribute = functools.update_wrapper(
trace_method(attribute),
attribute,
)
if attribute_name == '__new__' or attribute_name == '__init__':
# Don't trace the __new__ or __init__ methods
pass
elif attribute_name == 'consumer':
pass
elif isinstance(attribute, types.FunctionType):
# Check if method is marked with @no_trace decorator
if getattr(attribute, '__no_trace__', False):
# Don't wrap methods marked with @no_trace
pass
else:
# replace it with a wrapped version
attribute = functools.update_wrapper(
trace_method(attribute),
attribute,
)
new_class_dict[attribute_name] = attribute
return type.__new__(cls, classname, bases, new_class_dict)

View File

@ -316,7 +316,8 @@ class TestTCPKISSDriver(unittest.TestCase):
"""Test fix_raw_frame removes KISS markers and handles FEND."""
# Create a test frame with KISS markers
with mock.patch(
'aprsd.client.drivers.tcpkiss.handle_fend', return_value=b'fixed_frame'
'aprsd.client.drivers.kiss_common.KISSDriver._handle_fend',
return_value=b'fixed_frame',
) as mock_handle_fend:
raw_frame = b'\xc0\x00some_frame_data\xc0' # \xc0 is FEND
@ -344,7 +345,7 @@ class TestTCPKISSDriver(unittest.TestCase):
mock_factory.assert_called_once_with(mock_aprs_data)
self.assertEqual(result, mock_packet)
@mock.patch('aprsd.client.drivers.tcpkiss.LOG')
@mock.patch('aprsd.client.drivers.kiss_common.LOG')
def test_decode_packet_no_frame(self, mock_log):
"""Test decode_packet with no frame returns None."""
result = self.driver.decode_packet()
@ -352,13 +353,13 @@ class TestTCPKISSDriver(unittest.TestCase):
self.assertIsNone(result)
mock_log.warning.assert_called_once()
@mock.patch('aprsd.client.drivers.tcpkiss.LOG')
@mock.patch('aprsd.client.drivers.kiss_common.LOG')
def test_decode_packet_exception(self, mock_log):
"""Test decode_packet handles exceptions."""
mock_frame = 'invalid frame'
with mock.patch(
'aprsd.client.drivers.tcpkiss.aprslib.parse',
'aprsd.client.drivers.kiss_common.aprslib.parse',
side_effect=Exception('Test error'),
) as mock_parse:
result = self.driver.decode_packet(frame=mock_frame)

10
uv.lock generated
View File

@ -84,6 +84,7 @@ dev = [
{ name = "mistune" },
{ name = "nodeenv" },
{ name = "packaging" },
{ name = "pip" },
{ name = "pip-tools" },
{ name = "platformdirs" },
{ name = "pluggy" },
@ -93,6 +94,7 @@ dev = [
{ name = "pyproject-hooks" },
{ name = "pyyaml" },
{ name = "requests" },
{ name = "setuptools" },
{ name = "snowballstemmer" },
{ name = "sphinx" },
{ name = "sphinxcontrib-applehelp" },
@ -155,6 +157,7 @@ requires-dist = [
{ name = "packaging", specifier = "==25.0" },
{ name = "packaging", marker = "extra == 'dev'", specifier = "==25.0" },
{ name = "pbr", specifier = "==6.1.1" },
{ name = "pip", marker = "extra == 'dev'", specifier = "==25.2" },
{ name = "pip-tools", marker = "extra == 'dev'", specifier = "==7.5.0" },
{ name = "platformdirs", marker = "extra == 'dev'", specifier = "==4.3.8" },
{ name = "pluggy", specifier = "==1.6.0" },
@ -175,6 +178,7 @@ requires-dist = [
{ name = "rich", specifier = "==14.1.0" },
{ name = "rush", specifier = "==2021.4.0" },
{ name = "setuptools", specifier = "==80.9.0" },
{ name = "setuptools", marker = "extra == 'dev'", specifier = "==80.9.0" },
{ name = "snowballstemmer", marker = "extra == 'dev'", specifier = "==3.0.1" },
{ name = "sphinx", marker = "extra == 'dev'", specifier = "==8.1.3" },
{ name = "sphinxcontrib-applehelp", marker = "extra == 'dev'", specifier = "==2.0.0" },
@ -768,11 +772,11 @@ wheels = [
[[package]]
name = "pip"
version = "24.3.1"
version = "25.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f4/b1/b422acd212ad7eedddaf7981eee6e5de085154ff726459cf2da7c5a184c1/pip-24.3.1.tar.gz", hash = "sha256:ebcb60557f2aefabc2e0f918751cd24ea0d56d8ec5445fe1807f1d2109660b99", size = 1931073, upload-time = "2024-10-27T18:35:56.354Z" }
sdist = { url = "https://files.pythonhosted.org/packages/20/16/650289cd3f43d5a2fadfd98c68bd1e1e7f2550a1a5326768cddfbcedb2c5/pip-25.2.tar.gz", hash = "sha256:578283f006390f85bb6282dffb876454593d637f5d1be494b5202ce4877e71f2", size = 1840021, upload-time = "2025-07-30T21:50:15.401Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/7d/500c9ad20238fcfcb4cb9243eede163594d7020ce87bd9610c9e02771876/pip-24.3.1-py3-none-any.whl", hash = "sha256:3790624780082365f47549d032f3770eeb2b1e8bd1f7b2e02dace1afa361b4ed", size = 1822182, upload-time = "2024-10-27T18:35:53.067Z" },
{ url = "https://files.pythonhosted.org/packages/b7/3f/945ef7ab14dc4f9d7f40288d2df998d1837ee0888ec3659c813487572faa/pip-25.2-py3-none-any.whl", hash = "sha256:6d67a2b4e7f14d8b31b8b52648866fa717f45a1eb70e83002f4331d07e953717", size = 1752557, upload-time = "2025-07-30T21:50:13.323Z" },
]
[[package]]