diff --git a/README.md b/README.md index 7cc97de2..ea46370a 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ Some of the cool and cutting-edge [transport protocols](https://connectivity.lib | [`js-peer`](./js-peer/) | Browser Chat Peer in TypeScript | ✅ | ✅ | ✅ | ❌ | ❌ | | [`go-peer`](./go-peer/) | Chat peer implemented in Go | ✅ | ❌ | ✅ | ✅ | ✅ | | [`rust-peer`](./rust-peer/) | Chat peer implemented in Rust | ❌ | ❌ | ✅ | ✅ | ❌ | +| [`py-peer`](./py-peer/) | Chat peer implemented in Python | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ - Protocol supported ❌ - Protocol not supported @@ -82,3 +83,13 @@ cargo run -- --help cd go-peer go run . ``` + +## Getting started: Python + +Make sure you have the [uv package manager](https://github.com/astral-sh/uv) +installed first. Follow the instructions on their Github to install it. + +``` +cd py-peer +uv run hello.py +``` diff --git a/py-peer/.python-version b/py-peer/.python-version new file mode 100644 index 00000000..e4fba218 --- /dev/null +++ b/py-peer/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/py-peer/README.md b/py-peer/README.md new file mode 100644 index 00000000..010f8532 --- /dev/null +++ b/py-peer/README.md @@ -0,0 +1,8 @@ +# Python Peer (py-peer) of Universal Connectivity + +This is the Python implementation of the [Universal Connectivity][UNIV_CONN] app showcasing the [Gossipsub][GOSSIPSUB], and eventually [QUIC][QUIC], features of the core libp2p protocol as found in the [py-libp2p][PYLIBP2P] Python libp2p implementation. + +[GOSSIPSUB]: https://github.com/libp2p/specs/blob/master/pubsub/gossipsub/README.md +[PYLIBP2P]: https://github.com/libp2p/py-libp2p +[QUIC]: https://github.com/libp2p/specs/blob/master/quic/README.md +[UNIV_CONN]: https://github.com/libp2p/universal-connectivity diff --git a/py-peer/hello.py b/py-peer/hello.py new file mode 100644 index 00000000..279c6898 --- /dev/null +++ b/py-peer/hello.py @@ -0,0 +1,6 @@ +def main(): + print("Hello from py-peer!") + + +if __name__ == "__main__": + main() diff --git a/py-peer/pyproject.toml b/py-peer/pyproject.toml new file mode 100644 index 00000000..622657ca --- /dev/null +++ b/py-peer/pyproject.toml @@ -0,0 +1,25 @@ +[project] +name = "py-peer" +version = "0.1.0" +description = "Python implementation of the Universal Connectivity peer and p2p chat experience." +readme = "README.md" +requires-python = ">=3.12" +dependencies = [] + +[tool.uv] +dev-dependencies = [ + "pytest>=8.0", + "ruff>=0.5" +] + +[tool.ruff] +line-length = 100 +select = ["E", "F", "I", "W", "Q"] +# E = Errors +# F = Pyflakes +# I = Imports +# W = Warnings +# Q = Quality +ignore = [ + "E501", # Line too long +] diff --git a/py-peer/setup.py b/py-peer/setup.py new file mode 100644 index 00000000..042eb21a --- /dev/null +++ b/py-peer/setup.py @@ -0,0 +1,22 @@ +from setuptools import setup, find_packages + +setup( + name="py-peer", + version="0.1.0", + packages=find_packages(), + install_requires=[ + "trio", + "multiaddr", + "base58", + "py-libp2p", + ], + entry_points={ + "console_scripts": [ + "py-peer=py_peer.main:main", + ], + }, + description="A modular libp2p peer implementation in Python", + author="Your Name", + author_email="your.email@example.com", + url="https://github.com/yourusername/py-peer", +) \ No newline at end of file diff --git a/py-peer/src/py_peer/__init__.py b/py-peer/src/py_peer/__init__.py new file mode 100644 index 00000000..87f3383f --- /dev/null +++ b/py-peer/src/py_peer/__init__.py @@ -0,0 +1,5 @@ +""" +py-peer: A modular libp2p peer implementation in Python. +""" + +__version__ = "0.1.0" \ No newline at end of file diff --git a/py-peer/src/py_peer/config.py b/py-peer/src/py_peer/config.py new file mode 100644 index 00000000..e7a83e2d --- /dev/null +++ b/py-peer/src/py_peer/config.py @@ -0,0 +1,91 @@ +""" +Configuration module for py-peer. +""" +import argparse +import socket +from dataclasses import dataclass +from typing import Optional + +from libp2p.crypto.rsa import create_new_key_pair +from libp2p.crypto.keys import KeyPair +from libp2p.custom_types import TProtocol + +# Default values +DEFAULT_TOPIC = "pubsub-chat" +DEFAULT_PORT = 8080 +GOSSIPSUB_PROTOCOL_ID = TProtocol("/meshsub/1.0.0") +NOISE_PROTOCOL_ID = TProtocol("/noise") +MPLEX_PROTOCOL_ID = TProtocol("/mplex/6.7.0") + + +@dataclass +class PeerConfig: + """Configuration for a libp2p peer.""" + topic: str + destination: Optional[str] + port: int + verbose: bool + key_pair: KeyPair + + @classmethod + def from_args(cls, args: argparse.Namespace) -> 'PeerConfig': + """Create a PeerConfig from command line arguments.""" + # Generate a key pair for the node + key_pair = create_new_key_pair() + + return cls( + topic=args.topic, + destination=args.destination, + port=args.port if args.port != 0 else find_free_port(), + verbose=args.verbose, + key_pair=key_pair, + ) + + +def find_free_port() -> int: + """Find a free port on localhost.""" + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("", 0)) # Bind to a free port provided by the OS + return s.getsockname()[1] + + +def parse_args() -> argparse.Namespace: + """Parse command line arguments.""" + description = """ + This program demonstrates a modular pubsub p2p application using libp2p with + the gossipsub protocol as the pubsub router. + """ + + parser = argparse.ArgumentParser(description=description) + parser.add_argument( + "-t", + "--topic", + type=str, + help="topic name to subscribe", + default=DEFAULT_TOPIC, + ) + + parser.add_argument( + "-d", + "--destination", + type=str, + help="Address of peer to connect to", + default=None, + ) + + parser.add_argument( + "-p", + "--port", + type=int, + help="Port to listen on", + default=DEFAULT_PORT, + ) + + parser.add_argument( + "-v", + "--verbose", + action="store_true", + help="Enable debug logging", + ) + + return parser.parse_args() \ No newline at end of file diff --git a/py-peer/src/py_peer/main.py b/py-peer/src/py_peer/main.py new file mode 100644 index 00000000..71efe71b --- /dev/null +++ b/py-peer/src/py_peer/main.py @@ -0,0 +1,37 @@ +""" +Main entry point for py-peer. +""" +import trio +import logging + +from py_peer.config import parse_args, PeerConfig +from py_peer.peer import Peer +from py_peer.utils.logging import configure_logging + + +def main() -> None: + """Main entry point for the application.""" + # Parse command line arguments + args = parse_args() + + # Configure logging + logger = configure_logging(args.verbose) + + # Create peer configuration + config = PeerConfig.from_args(args) + + logger.info("Running py-peer...") + logger.info(f"Your selected topic is: {config.topic}") + logger.info(f"Your peer ID is: {config.key_pair.public_key}") + + # Create and start the peer + peer = Peer(config) + + try: + trio.run(peer.start) + except KeyboardInterrupt: + logger.info("Application terminated by user") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/py-peer/src/py_peer/network/__init__.py b/py-peer/src/py_peer/network/__init__.py new file mode 100644 index 00000000..2d911f97 --- /dev/null +++ b/py-peer/src/py_peer/network/__init__.py @@ -0,0 +1,3 @@ +""" +Network modules for py-peer. +""" \ No newline at end of file diff --git a/py-peer/src/py_peer/network/discovery.py b/py-peer/src/py_peer/network/discovery.py new file mode 100644 index 00000000..7735d03d --- /dev/null +++ b/py-peer/src/py_peer/network/discovery.py @@ -0,0 +1,60 @@ +""" +Peer discovery mechanisms for py-peer. +""" +import multiaddr +import trio + +from libp2p.peer.peerinfo import info_from_p2p_addr +from libp2p.host.host_interface import ( + IHost, +) +from libp2p.pubsub.pubsub import Pubsub + +from py_peer.utils.logging import get_logger + +logger = get_logger(__name__) + + +async def connect_to_peer(host: IHost, peer_addr: str) -> bool: + """ + Connect to a peer using its multiaddress. + + Args: + host: The libp2p host + peer_addr: The multiaddress of the peer to connect to + + Returns: + True if connection was successful, False otherwise + """ + try: + maddr = multiaddr.Multiaddr(peer_addr) + protocols_in_maddr = maddr.protocols() + info = info_from_p2p_addr(maddr) + + logger.debug(f"Multiaddr protocols: {protocols_in_maddr}") + logger.info( + f"Connecting to peer: {info.peer_id} " + f"using protocols: {protocols_in_maddr}" + ) + + await host.connect(info) + logger.info(f"Connected to peer: {info.peer_id}") + return True + except Exception: + logger.exception(f"Failed to connect to peer: {peer_addr}") + return False + + +async def monitor_peers(pubsub: Pubsub, interval: float = 30.0) -> None: + """ + Monitor connected peers and log information periodically. + + Args: + host: The libp2p host + interval: The interval in seconds between checks + """ + while True: + # peers = host.get_network().get_peer_id() + peers = pubsub.peers + logger.debug(f"Connected to {len(peers)} peers: {peers}") + await trio.sleep(interval) \ No newline at end of file diff --git a/py-peer/src/py_peer/network/host.py b/py-peer/src/py_peer/network/host.py new file mode 100644 index 00000000..b352e645 --- /dev/null +++ b/py-peer/src/py_peer/network/host.py @@ -0,0 +1,71 @@ +""" +Host creation and management for py-peer. +""" +import multiaddr +from typing import List, Dict, Any + +from libp2p import new_host +from libp2p.crypto.keys import KeyPair +from libp2p.custom_types import TProtocol +from libp2p.stream_muxer.mplex.mplex import Mplex +from libp2p.tools.factories import security_options_factory_factory +from libp2p.host.host_interface import IHost + +from py_peer.config import NOISE_PROTOCOL_ID, MPLEX_PROTOCOL_ID +from py_peer.utils.logging import get_logger + +logger = get_logger(__name__) + + +def create_host( + key_pair: KeyPair, + listen_addrs: List[multiaddr.Multiaddr] = None, + security_protocol: TProtocol = NOISE_PROTOCOL_ID, + muxer_protocol: TProtocol = MPLEX_PROTOCOL_ID, +) -> IHost: + """ + Create a new libp2p host. + + Args: + key_pair: The key pair for the host + listen_addrs: List of multiaddresses to listen on + security_protocol: The security protocol to use + muxer_protocol: The stream multiplexer protocol to use + + Returns: + A new libp2p host + """ + if listen_addrs is None: + listen_addrs = [] + + # Security options + security_options_factory = security_options_factory_factory(security_protocol) + security_options = security_options_factory(key_pair) + + # Create a new libp2p host + host = new_host( + key_pair=key_pair, + muxer_opt={muxer_protocol: Mplex}, + sec_opt=security_options, + ) + + logger.debug(f"Host ID: {host.get_id()}") + logger.debug( + f"Host multiselect protocols: " + f"{host.get_mux().get_protocols() if hasattr(host, 'get_mux') else 'N/A'}" + ) + + return host + + +def get_listen_multiaddr(port: int) -> multiaddr.Multiaddr: + """ + Get a multiaddress for listening on all interfaces with the given port. + + Args: + port: The port to listen on + + Returns: + A multiaddress + """ + return multiaddr.Multiaddr(f"/ip4/0.0.0.0/tcp/{port}") \ No newline at end of file diff --git a/py-peer/src/py_peer/peer.py b/py-peer/src/py_peer/peer.py new file mode 100644 index 00000000..ee333752 --- /dev/null +++ b/py-peer/src/py_peer/peer.py @@ -0,0 +1,141 @@ +""" +Core peer implementation for py-peer. +""" +import multiaddr +import trio +from typing import Optional, List, Dict, Any, Set +import logging + +from libp2p.tools.async_service.trio_service import background_trio_service +from libp2p.pubsub.pubsub import ISubscriptionAPI + +from py_peer.config import PeerConfig +from py_peer.network.host import create_host, get_listen_multiaddr +from py_peer.network.discovery import connect_to_peer, monitor_peers +from py_peer.pubsub.gossipsub import create_gossipsub, create_pubsub +from py_peer.pubsub.handlers import receive_loop, publish_loop, monitor_peer_topics +from py_peer.utils.logging import get_logger + +logger = get_logger(__name__) + + +class Peer: + """A libp2p peer with pubsub capabilities.""" + + def __init__(self, config: PeerConfig): + """ + Initialize a new peer. + + Args: + config: The peer configuration + """ + self.config = config + self.host = None + self.pubsub = None + self.gossipsub = None + self.subscriptions: Dict[str, Any] = {} + + async def start(self) -> None: + """Start the peer and all its services.""" + # Create the host + listen_addr = get_listen_multiaddr(self.config.port) + self.host = create_host(self.config.key_pair) + + # Create gossipsub and pubsub + self.gossipsub = create_gossipsub() + self.pubsub = create_pubsub(self.host, self.gossipsub) + + # Start the host and services + async with self.host.run(listen_addrs=[listen_addr]), trio.open_nursery() as nursery: + logger.info(f"Node started with peer ID: {self.host.get_id()}") + logger.info(f"Listening on: {listen_addr}") + logger.info("Initializing PubSub and GossipSub...") + + # Start pubsub and gossipsub services + async with background_trio_service(self.pubsub): + async with background_trio_service(self.gossipsub): + logger.info("Pubsub and GossipSub services started.") + await self.pubsub.wait_until_ready() + logger.info("Pubsub ready.") + + # Subscribe to the configured topic + await self.subscribe_to_topic(self.config.topic, nursery) + + # Connect to destination if specified + if self.config.destination: + await self.connect_to_destination(nursery) + else: + # Server mode + logger.info( + "Run this script in another console with:\n" + f"python main.py " + f"-d /ip4/127.0.0.1/tcp/{self.config.port}/p2p/{self.host.get_id()}\n" + ) + logger.info("Waiting for peers...") + + # Start topic monitoring to auto-subscribe to client topics + nursery.start_soon( + monitor_peer_topics, + self.pubsub, + self.handle_new_topic + ) + + # Start the publish loop for the main topic + nursery.start_soon(publish_loop, self.pubsub, self.config.topic) + + # Monitor peers + nursery.start_soon(monitor_peers, self.pubsub) + + # Keep the peer running + await trio.sleep_forever() + + async def subscribe_to_topic(self, topic: str, nursery: trio.Nursery) -> None: + """ + Subscribe to a topic and start a receive loop. + + Args: + topic: The topic to subscribe to + nursery: The trio nursery + """ + logger.info(f"Subscribing to topic: {topic}") + subscription = await self.pubsub.subscribe(topic) + self.subscriptions[topic] = subscription + + # Start a receive loop for this topic + nursery.start_soon(receive_loop, subscription) + + async def handle_new_topic(self, topic: str, subscription: ISubscriptionAPI) -> None: + """ + Handle a new topic discovered from peers. + + Args: + topic: The new topic + subscription: The subscription to the topic + """ + self.subscriptions[topic] = subscription + logger.info(f"Added new topic to subscriptions: {topic}") + + async def connect_to_destination(self, nursery: trio.Nursery) -> None: + """ + Connect to the destination peer. + + Args: + nursery: The trio nursery + """ + if not self.config.destination: + return + + success = await connect_to_peer(self.host, self.config.destination) + if not success: + logger.error("Failed to connect to destination peer. Exiting.") + return + + # Debug peer connections + if logger.isEnabledFor(logging.DEBUG): + await trio.sleep(1) + logger.debug(f"After connection, pubsub.peers: {self.pubsub.peers}") + peer_protocols = [ + self.gossipsub.peer_protocol.get(p) + for p in self.pubsub.peers.keys() + ] + logger.debug(f"Peer protocols: {peer_protocols}") \ No newline at end of file diff --git a/py-peer/src/py_peer/pubsub/__init__.py b/py-peer/src/py_peer/pubsub/__init__.py new file mode 100644 index 00000000..9e8b99c6 --- /dev/null +++ b/py-peer/src/py_peer/pubsub/__init__.py @@ -0,0 +1,3 @@ +""" +PubSub modules for py-peer. +""" \ No newline at end of file diff --git a/py-peer/src/py_peer/pubsub/gossipsub.py b/py-peer/src/py_peer/pubsub/gossipsub.py new file mode 100644 index 00000000..0b34ceee --- /dev/null +++ b/py-peer/src/py_peer/pubsub/gossipsub.py @@ -0,0 +1,72 @@ +""" +GossipSub implementation for py-peer. +""" +from typing import List, Any, Dict + +from libp2p.custom_types import TProtocol +from libp2p.pubsub.gossipsub import GossipSub +from libp2p.pubsub.pubsub import Pubsub +from libp2p.host.host_interface import IHost + +from py_peer.config import GOSSIPSUB_PROTOCOL_ID +from py_peer.utils.logging import get_logger + +logger = get_logger(__name__) + + +def create_gossipsub( + protocols: List[TProtocol] = None, + degree: int = 3, + degree_low: int = 2, + degree_high: int = 4, + time_to_live: int = 60, + gossip_window: int = 2, + gossip_history: int = 5, + heartbeat_initial_delay: float = 2.0, + heartbeat_interval: float = 5.0, +) -> GossipSub: + """ + Create a GossipSub instance with the given parameters. + + Args: + protocols: List of protocols to use + degree: Number of peers to maintain in mesh + degree_low: Lower bound for mesh peers + degree_high: Upper bound for mesh peers + time_to_live: TTL for message cache in seconds + gossip_window: Window for gossip + gossip_history: History length to keep + heartbeat_initial_delay: Initial delay for heartbeats + heartbeat_interval: Interval between heartbeats + + Returns: + A GossipSub instance + """ + if protocols is None: + protocols = [GOSSIPSUB_PROTOCOL_ID] + + return GossipSub( + protocols=protocols, + degree=degree, + degree_low=degree_low, + degree_high=degree_high, + time_to_live=time_to_live, + gossip_window=gossip_window, + gossip_history=gossip_history, + heartbeat_initial_delay=heartbeat_initial_delay, + heartbeat_interval=heartbeat_interval, + ) + + +def create_pubsub(host: IHost, gossipsub: GossipSub) -> Pubsub: + """ + Create a Pubsub instance with the given host and GossipSub router. + + Args: + host: The libp2p host + gossipsub: The GossipSub router + + Returns: + A Pubsub instance + """ + return Pubsub(host, gossipsub) \ No newline at end of file diff --git a/py-peer/src/py_peer/pubsub/handlers.py b/py-peer/src/py_peer/pubsub/handlers.py new file mode 100644 index 00000000..5fb334d1 --- /dev/null +++ b/py-peer/src/py_peer/pubsub/handlers.py @@ -0,0 +1,83 @@ +""" +Message handlers for pubsub operations. +""" +import base58 +import trio +from typing import Any, Set, Callable, Awaitable +from libp2p.pubsub.pubsub import ISubscriptionAPI + +from libp2p.pubsub.pubsub import Pubsub +from py_peer.utils.logging import get_logger + +logger = get_logger(__name__) + + +async def receive_loop(subscription: ISubscriptionAPI) -> None: + """ + Loop to receive messages from a subscription. + + Args: + subscription: The subscription to receive messages from + """ + logger.debug("Starting receive loop") + while True: + try: + message = await subscription.get() + logger.info(f"From peer: {base58.b58encode(message.from_id).decode()}") + print(f"Received message: {message.data.decode('utf-8')}") + except Exception: + logger.exception("Error in receive loop") + await trio.sleep(1) + + +async def publish_loop(pubsub: Pubsub, topic: str) -> None: + """ + Loop to publish messages to a topic. + + Args: + pubsub: The pubsub instance + topic: The topic to publish to + """ + logger.debug("Starting publish loop...") + print("Type messages to send (press Enter to send, 'quit' to exit):") + while True: + try: + # Use trio's run_sync_in_worker_thread to avoid blocking the event loop + message = await trio.to_thread.run_sync(input) + if message.lower() == "quit": + print("Exiting publish loop.") + break + if message: + logger.debug(f"Publishing message: {message}") + await pubsub.publish(topic, message.encode()) + print(f"Published: {message}") + except Exception: + logger.exception("Error in publish loop") + await trio.sleep(1) # Avoid tight loop on error + +async def monitor_peer_topics( + pubsub: Pubsub, + on_new_topic: Callable[[str, Any], Awaitable[None]] +) -> None: + """ + Monitor for new topics that peers are subscribed to. + + Args: + pubsub: The pubsub instance + on_new_topic: Callback function for new topics + """ + # Keep track of topics we've already subscribed to + subscribed_topics: Set[str] = set() + + while True: + # Check for new topics in peer_topics + for topic in pubsub.peer_topics.keys(): + if topic not in subscribed_topics: + logger.info(f"Auto-subscribing to new topic: {topic}") + subscription = await pubsub.subscribe(topic) + subscribed_topics.add(topic) + # Call the callback for the new topic + await on_new_topic(topic, subscription) + + # Check every 2 seconds for new topics + await trio.sleep(2) \ No newline at end of file diff --git a/py-peer/src/py_peer/utils/__init__.py b/py-peer/src/py_peer/utils/__init__.py new file mode 100644 index 00000000..8eb7c0b2 --- /dev/null +++ b/py-peer/src/py_peer/utils/__init__.py @@ -0,0 +1,3 @@ +""" +Utility modules for py-peer. +""" \ No newline at end of file diff --git a/py-peer/src/py_peer/utils/logging.py b/py-peer/src/py_peer/utils/logging.py new file mode 100644 index 00000000..85ecf64c --- /dev/null +++ b/py-peer/src/py_peer/utils/logging.py @@ -0,0 +1,44 @@ +""" +Logging configuration for py-peer. +""" +import logging +from typing import Optional + + +def configure_logging(verbose: bool = False) -> logging.Logger: + """ + Configure logging for the application. + + Args: + verbose: Whether to enable debug logging + + Returns: + A configured logger instance + """ + # Configure logging + logging.basicConfig( + level=logging.DEBUG if verbose else logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + ) + logger = logging.getLogger("py-peer") + + if verbose: + logger.setLevel(logging.DEBUG) + logger.debug("Debug logging enabled") + + return logger + + +def get_logger(name: Optional[str] = None) -> logging.Logger: + """ + Get a logger with the given name. + + Args: + name: The name for the logger, defaults to 'py-peer' + + Returns: + A logger instance + """ + if name is None: + name = "py-peer" + return logging.getLogger(name) \ No newline at end of file diff --git a/py-peer/uv.lock b/py-peer/uv.lock new file mode 100644 index 00000000..099cfb91 --- /dev/null +++ b/py-peer/uv.lock @@ -0,0 +1,97 @@ +version = 1 +requires-python = ">=3.12" + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "iniconfig" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, +] + +[[package]] +name = "packaging" +version = "24.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 }, +] + +[[package]] +name = "pluggy" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, +] + +[[package]] +name = "py-peer" +version = "0.1.0" +source = { virtual = "." } + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.0" }, + { name = "ruff", specifier = ">=0.5" }, +] + +[[package]] +name = "pytest" +version = "8.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/35/30e0d83068951d90a01852cb1cef56e5d8a09d20c7f511634cc2f7e0372a/pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761", size = 1445919 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/92/76a1c94d3afee238333bc0a42b82935dd8f9cf8ce9e336ff87ee14d9e1cf/pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6", size = 343083 }, +] + +[[package]] +name = "ruff" +version = "0.9.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/39/8b/a86c300359861b186f18359adf4437ac8e4c52e42daa9eedc731ef9d5b53/ruff-0.9.7.tar.gz", hash = "sha256:643757633417907510157b206e490c3aa11cab0c087c912f60e07fbafa87a4c6", size = 3669813 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/f3/3a1d22973291226df4b4e2ff70196b926b6f910c488479adb0eeb42a0d7f/ruff-0.9.7-py3-none-linux_armv6l.whl", hash = "sha256:99d50def47305fe6f233eb8dabfd60047578ca87c9dcb235c9723ab1175180f4", size = 11774588 }, + { url = "https://files.pythonhosted.org/packages/8e/c9/b881f4157b9b884f2994fd08ee92ae3663fb24e34b0372ac3af999aa7fc6/ruff-0.9.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d59105ae9c44152c3d40a9c40d6331a7acd1cdf5ef404fbe31178a77b174ea66", size = 11746848 }, + { url = "https://files.pythonhosted.org/packages/14/89/2f546c133f73886ed50a3d449e6bf4af27d92d2f960a43a93d89353f0945/ruff-0.9.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f313b5800483770bd540cddac7c90fc46f895f427b7820f18fe1822697f1fec9", size = 11177525 }, + { url = "https://files.pythonhosted.org/packages/d7/93/6b98f2c12bf28ab9def59c50c9c49508519c5b5cfecca6de871cf01237f6/ruff-0.9.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:042ae32b41343888f59c0a4148f103208bf6b21c90118d51dc93a68366f4e903", size = 11996580 }, + { url = "https://files.pythonhosted.org/packages/8e/3f/b3fcaf4f6d875e679ac2b71a72f6691a8128ea3cb7be07cbb249f477c061/ruff-0.9.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87862589373b33cc484b10831004e5e5ec47dc10d2b41ba770e837d4f429d721", size = 11525674 }, + { url = "https://files.pythonhosted.org/packages/f0/48/33fbf18defb74d624535d5d22adcb09a64c9bbabfa755bc666189a6b2210/ruff-0.9.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a17e1e01bee0926d351a1ee9bc15c445beae888f90069a6192a07a84af544b6b", size = 12739151 }, + { url = "https://files.pythonhosted.org/packages/63/b5/7e161080c5e19fa69495cbab7c00975ef8a90f3679caa6164921d7f52f4a/ruff-0.9.7-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:7c1f880ac5b2cbebd58b8ebde57069a374865c73f3bf41f05fe7a179c1c8ef22", size = 13416128 }, + { url = "https://files.pythonhosted.org/packages/4e/c8/b5e7d61fb1c1b26f271ac301ff6d9de5e4d9a9a63f67d732fa8f200f0c88/ruff-0.9.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e63fc20143c291cab2841dbb8260e96bafbe1ba13fd3d60d28be2c71e312da49", size = 12870858 }, + { url = "https://files.pythonhosted.org/packages/da/cb/2a1a8e4e291a54d28259f8fc6a674cd5b8833e93852c7ef5de436d6ed729/ruff-0.9.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:91ff963baed3e9a6a4eba2a02f4ca8eaa6eba1cc0521aec0987da8d62f53cbef", size = 14786046 }, + { url = "https://files.pythonhosted.org/packages/ca/6c/c8f8a313be1943f333f376d79724260da5701426c0905762e3ddb389e3f4/ruff-0.9.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88362e3227c82f63eaebf0b2eff5b88990280fb1ecf7105523883ba8c3aaf6fb", size = 12550834 }, + { url = "https://files.pythonhosted.org/packages/9d/ad/f70cf5e8e7c52a25e166bdc84c082163c9c6f82a073f654c321b4dff9660/ruff-0.9.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0372c5a90349f00212270421fe91874b866fd3626eb3b397ede06cd385f6f7e0", size = 11961307 }, + { url = "https://files.pythonhosted.org/packages/52/d5/4f303ea94a5f4f454daf4d02671b1fbfe2a318b5fcd009f957466f936c50/ruff-0.9.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d76b8ab60e99e6424cd9d3d923274a1324aefce04f8ea537136b8398bbae0a62", size = 11612039 }, + { url = "https://files.pythonhosted.org/packages/eb/c8/bd12a23a75603c704ce86723be0648ba3d4ecc2af07eecd2e9fa112f7e19/ruff-0.9.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:0c439bdfc8983e1336577f00e09a4e7a78944fe01e4ea7fe616d00c3ec69a3d0", size = 12168177 }, + { url = "https://files.pythonhosted.org/packages/cc/57/d648d4f73400fef047d62d464d1a14591f2e6b3d4a15e93e23a53c20705d/ruff-0.9.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:115d1f15e8fdd445a7b4dc9a30abae22de3f6bcabeb503964904471691ef7606", size = 12610122 }, + { url = "https://files.pythonhosted.org/packages/49/79/acbc1edd03ac0e2a04ae2593555dbc9990b34090a9729a0c4c0cf20fb595/ruff-0.9.7-py3-none-win32.whl", hash = "sha256:e9ece95b7de5923cbf38893f066ed2872be2f2f477ba94f826c8defdd6ec6b7d", size = 9988751 }, + { url = "https://files.pythonhosted.org/packages/6d/95/67153a838c6b6ba7a2401241fd8a00cd8c627a8e4a0491b8d853dedeffe0/ruff-0.9.7-py3-none-win_amd64.whl", hash = "sha256:3770fe52b9d691a15f0b87ada29c45324b2ace8f01200fb0c14845e499eb0c2c", size = 11002987 }, + { url = "https://files.pythonhosted.org/packages/63/6a/aca01554949f3a401991dc32fe22837baeaccb8a0d868256cbb26a029778/ruff-0.9.7-py3-none-win_arm64.whl", hash = "sha256:b075a700b2533feb7a01130ff656a4ec0d5f340bb540ad98759b8401c32c2037", size = 10177763 }, +]