astreum 0.1.8__py3-none-any.whl → 0.1.9__py3-none-any.whl

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.

Potentially problematic release.


This version of astreum might be problematic. Click here for more details.

@@ -2,7 +2,8 @@
2
2
  K-bucket implementation for Kademlia-style routing in Astreum node.
3
3
  """
4
4
 
5
- from typing import List, Tuple
5
+ from typing import List, Set
6
+ from .peer import Peer
6
7
 
7
8
  class KBucket:
8
9
  """
@@ -14,67 +15,76 @@ class KBucket:
14
15
  This creates a least-recently seen eviction policy.
15
16
  """
16
17
 
17
- def __init__(self, size: int):
18
+ def __init__(self, k: int = 20):
18
19
  """
19
20
  Initialize a k-bucket with a fixed size.
20
21
 
21
22
  Args:
22
- size (int): Maximum number of peers in the bucket
23
+ k (int): Maximum number of peers in the bucket
23
24
  """
24
- self.size = size
25
- self.peers: List[Tuple[str, int]] = []
25
+ self.k = k
26
+ self.peers: List[Peer] = []
27
+ self._peer_ids: Set[bytes] = set() # Track peer IDs for quick lookup
26
28
 
27
- def add(self, peer: Tuple[str, int]) -> bool:
29
+ def add(self, peer: Peer) -> bool:
28
30
  """
29
31
  Add peer to bucket if not full or if peer exists.
30
32
 
31
33
  Args:
32
- peer (Tuple[str, int]): Peer address (host, port)
34
+ peer (Peer): Peer to add to the bucket
33
35
 
34
36
  Returns:
35
37
  bool: True if added/exists, False if bucket full and peer not in bucket
36
38
  """
37
- if peer in self.peers:
38
- # Move to end (most recently seen)
39
- self.peers.remove(peer)
39
+ # If peer already in bucket, move to end (most recently seen)
40
+ if peer.public_key in self._peer_ids:
41
+ # Find and remove the peer
42
+ for i, existing_peer in enumerate(self.peers):
43
+ if existing_peer.public_key == peer.public_key:
44
+ del self.peers[i]
45
+ break
46
+
47
+ # Add back at the end (most recently seen)
40
48
  self.peers.append(peer)
49
+ peer.update_last_seen()
41
50
  return True
42
-
43
- if len(self.peers) < self.size:
51
+
52
+ # If bucket not full, add peer
53
+ if len(self.peers) < self.k:
44
54
  self.peers.append(peer)
55
+ self._peer_ids.add(peer.public_key)
56
+ peer.update_last_seen()
45
57
  return True
46
58
 
59
+ # Bucket full and peer not in bucket
47
60
  return False
48
61
 
49
- def remove(self, peer: Tuple[str, int]) -> bool:
62
+ def remove(self, peer: Peer) -> bool:
50
63
  """
51
64
  Remove peer from bucket.
52
65
 
53
66
  Args:
54
- peer (Tuple[str, int]): Peer address to remove
67
+ peer (Peer): Peer to remove
55
68
 
56
69
  Returns:
57
- bool: True if peer was removed, False if peer not in bucket
70
+ bool: True if removed, False if not in bucket
58
71
  """
59
- if peer in self.peers:
60
- self.peers.remove(peer)
61
- return True
72
+ if peer.public_key in self._peer_ids:
73
+ for i, existing_peer in enumerate(self.peers):
74
+ if existing_peer.public_key == peer.public_key:
75
+ del self.peers[i]
76
+ self._peer_ids.remove(peer.public_key)
77
+ return True
62
78
  return False
63
79
 
64
- def get_peers(self) -> List[Tuple[str, int]]:
65
- """
66
- Get all peers in the bucket.
67
-
68
- Returns:
69
- List[Tuple[str, int]]: List of peer addresses
70
- """
80
+ def get_peers(self) -> List[Peer]:
81
+ """Get all peers in the bucket."""
71
82
  return self.peers.copy()
72
83
 
73
- def __len__(self) -> int:
74
- """
75
- Get the number of peers in the bucket.
84
+ def contains(self, peer_id: bytes) -> bool:
85
+ """Check if a peer ID is in the bucket."""
86
+ return peer_id in self._peer_ids
76
87
 
77
- Returns:
78
- int: Number of peers
79
- """
88
+ def __len__(self) -> int:
89
+ """Get the number of peers in the bucket."""
80
90
  return len(self.peers)
@@ -3,25 +3,30 @@ Message related classes and utilities for Astreum node network.
3
3
  """
4
4
 
5
5
  import struct
6
- from enum import Enum, auto
6
+ from enum import IntEnum, auto
7
7
  from dataclasses import dataclass
8
8
  from typing import Optional
9
9
  from astreum.utils.bytes_format import encode, decode
10
10
 
11
- class Topic(Enum):
11
+ class Topic(IntEnum):
12
12
  """
13
13
  Enum for different message topics in the Astreum network.
14
14
  """
15
- OBJECT_REQUEST = auto()
16
- OBJECT = auto()
17
- PING = auto()
18
- PONG = auto()
19
- ROUTE = auto()
20
- ROUTE_REQUEST = auto()
21
- LATEST_BLOCK = auto()
22
- BLOCK = auto()
23
- LATEST_BLOCK_REQUEST = auto()
24
- TRANSACTION = auto()
15
+ PEER_ROUTE = 1
16
+ LATEST_BLOCK_REQUEST = 2
17
+ LATEST_BLOCK_RESPONSE = 3
18
+ GET_BLOCKS = 4
19
+ BLOCKS = 5
20
+ TRANSACTION = 6
21
+ BLOCK_COMMIT = 7
22
+ OBJECT_REQUEST = 8
23
+ OBJECT_RESPONSE = 9
24
+ PING = 10
25
+ PONG = 11
26
+ ROUTE = 12
27
+ ROUTE_REQUEST = 13
28
+ LATEST_BLOCK = 14
29
+ BLOCK = 15
25
30
 
26
31
  def to_bytes(self) -> bytes:
27
32
  """
@@ -151,6 +151,9 @@ class PeerManager:
151
151
  """
152
152
  Calculate the XOR distance between our node ID and the given public key.
153
153
 
154
+ In Kademlia, peers are organized into buckets based on the XOR distance.
155
+ The bucket index (0-255) represents the position of the first bit that differs.
156
+
154
157
  Args:
155
158
  public_key (bytes): The remote node's public key
156
159
 
@@ -11,25 +11,32 @@ class RouteTable:
11
11
  Kademlia-style routing table using k-buckets.
12
12
 
13
13
  The routing table consists of k-buckets, each covering a specific range of distances.
14
- Each k-bucket is a list of nodes with specific IDs in a certain distance range from ourselves.
14
+ In Kademlia, bucket index (i) contains nodes that share exactly i bits with the local node:
15
+ - Bucket 0: Contains peers that don't share the first bit with our node ID
16
+ - Bucket 1: Contains peers that share the first bit, but differ on the second bit
17
+ - Bucket 2: Contains peers that share the first two bits, but differ on the third bit
18
+ - And so on...
19
+
20
+ This structuring ensures efficient routing to any node in the network.
15
21
  """
16
22
 
17
- def __init__(self, config: dict, our_node_id: bytes):
23
+ def __init__(self, relay):
18
24
  """
19
25
  Initialize the routing table.
20
26
 
21
27
  Args:
22
- config (dict): Configuration dictionary
23
- our_node_id (bytes): Our node's unique identifier
28
+ relay: The relay instance this route table belongs to
24
29
  """
25
- self.our_node_id = our_node_id
26
- self.bucket_size = config.get('max_peers_per_bucket', 20)
27
- self.buckets: Dict[int, KBucket] = {}
28
- self.peer_manager = PeerManager(our_node_id)
30
+ self.relay = relay
31
+ self.our_node_id = relay.node_id
32
+ self.bucket_size = relay.config.get('max_peers_per_bucket', 20)
33
+ # Initialize buckets - for a 256-bit key, we need up to 256 buckets
34
+ self.buckets = {i: KBucket(k=self.bucket_size) for i in range(256)}
35
+ self.peer_manager = PeerManager(self.our_node_id)
29
36
 
30
37
  def add_peer(self, peer: Peer) -> bool:
31
38
  """
32
- Add a peer to the appropriate k-bucket based on distance.
39
+ Add a peer to the appropriate k-bucket based on bit prefix matching.
33
40
 
34
41
  Args:
35
42
  peer (Peer): The peer to add
@@ -37,14 +44,32 @@ class RouteTable:
37
44
  Returns:
38
45
  bool: True if the peer was added, False otherwise
39
46
  """
40
- distance = self.peer_manager.calculate_distance(peer.public_key)
47
+ # Calculate the number of matching prefix bits
48
+ matching_bits = self.peer_manager.calculate_distance(peer.public_key)
49
+
50
+ # Add to the appropriate bucket based on the number of matching bits
51
+ return self.buckets[matching_bits].add(peer)
52
+
53
+ def update_peer(self, addr: tuple, public_key: bytes, difficulty: int = 1) -> Peer:
54
+ """
55
+ Update or add a peer to the routing table.
41
56
 
42
- # Create bucket if it doesn't exist
43
- if distance not in self.buckets:
44
- self.buckets[distance] = KBucket(self.bucket_size)
57
+ Args:
58
+ addr: Tuple of (ip, port)
59
+ public_key: Peer's public key
60
+ difficulty: Peer's proof-of-work difficulty
45
61
 
46
- # Add to bucket
47
- return self.buckets[distance].add(peer.address)
62
+ Returns:
63
+ Peer: The updated or added peer
64
+ """
65
+ # Create or update the peer
66
+ peer = self.peer_manager.add_or_update_peer(addr, public_key)
67
+ peer.difficulty = difficulty
68
+
69
+ # Add to the appropriate bucket
70
+ self.add_peer(peer)
71
+
72
+ return peer
48
73
 
49
74
  def remove_peer(self, peer: Peer) -> bool:
50
75
  """
@@ -56,70 +81,81 @@ class RouteTable:
56
81
  Returns:
57
82
  bool: True if the peer was removed, False otherwise
58
83
  """
59
- distance = self.peer_manager.calculate_distance(peer.public_key)
60
-
61
- if distance in self.buckets:
62
- return self.buckets[distance].remove(peer.address)
84
+ matching_bits = self.peer_manager.calculate_distance(peer.public_key)
85
+ if matching_bits in self.buckets:
86
+ return self.buckets[matching_bits].remove(peer)
63
87
  return False
64
-
65
- def get_closest_peers(self, target_id: bytes, limit: int = 20) -> List[Peer]:
88
+
89
+ def get_closest_peers(self, target_id: bytes, count: int = 3) -> List[Peer]:
66
90
  """
67
- Get the closest peers to a target ID.
91
+ Get the closest peers to the target ID.
68
92
 
69
93
  Args:
70
- target_id (bytes): The target ID to find closest peers to
71
- limit (int): Maximum number of peers to return
94
+ target_id: Target ID to find peers close to
95
+ count: Maximum number of peers to return
72
96
 
73
97
  Returns:
74
- List[Peer]: The closest peers
98
+ List of peers closest to the target ID
75
99
  """
76
- # Calculate distances from all known peers to the target
77
- peers_with_distance = []
78
-
79
- for bucket in self.buckets.values():
80
- for address in bucket.addresses:
81
- peer = self.peer_manager.get_peer_by_address(address)
82
- if peer:
83
- # Calculate XOR distance between target and this peer
84
- xor_distance = 0
85
- for i in range(min(len(target_id), len(peer.public_key))):
86
- xor_bit = target_id[i] ^ peer.public_key[i]
87
- xor_distance = (xor_distance << 8) | xor_bit
88
-
89
- peers_with_distance.append((peer, xor_distance))
90
-
91
- # Sort by distance (closest first)
92
- peers_with_distance.sort(key=lambda x: x[1])
93
-
94
- # Return only the peers (without distances), up to the limit
95
- return [p[0] for p in peers_with_distance[:limit]]
100
+ # Calculate the number of matching prefix bits with the target
101
+ matching_bits = self.peer_manager.calculate_distance(target_id)
102
+
103
+ closest_peers = []
104
+
105
+ # First check the exact matching bucket
106
+ if matching_bits in self.buckets:
107
+ bucket_peers = self.buckets[matching_bits].get_peers()
108
+ closest_peers.extend(bucket_peers)
109
+
110
+ # If we need more peers, also check adjacent buckets (farther first)
111
+ if len(closest_peers) < count:
112
+ # Check buckets with fewer matching bits (higher XOR distance)
113
+ for i in range(matching_bits - 1, -1, -1):
114
+ if i in self.buckets:
115
+ bucket_peers = self.buckets[i].get_peers()
116
+ closest_peers.extend(bucket_peers)
117
+ if len(closest_peers) >= count:
118
+ break
119
+
120
+ # If still not enough, check buckets with more matching bits
121
+ if len(closest_peers) < count:
122
+ for i in range(matching_bits + 1, 256):
123
+ if i in self.buckets:
124
+ bucket_peers = self.buckets[i].get_peers()
125
+ closest_peers.extend(bucket_peers)
126
+ if len(closest_peers) >= count:
127
+ break
128
+
129
+ # Return the closest peers, limited by count
130
+ return closest_peers[:count]
96
131
 
97
- def get_bucket_stats(self) -> Dict[int, int]:
132
+ def get_bucket_peers(self, bucket_index: int) -> List[Peer]:
98
133
  """
99
- Get statistics about the buckets in the routing table.
134
+ Get all peers from a specific bucket.
100
135
 
136
+ Args:
137
+ bucket_index: Index of the bucket to get peers from
138
+
101
139
  Returns:
102
- Dict[int, int]: Mapping of distance to number of peers in that bucket
140
+ List of peers in the bucket
103
141
  """
104
- return {distance: len(bucket) for distance, bucket in self.buckets.items()}
105
-
106
- def get_peers_in_bucket(self, distance: int) -> List[Peer]:
142
+ if bucket_index in self.buckets:
143
+ return self.buckets[bucket_index].get_peers()
144
+ return []
145
+
146
+ def has_peer(self, addr: tuple) -> bool:
107
147
  """
108
- Get all peers in a specific bucket.
148
+ Check if a peer with the given address exists in the routing table.
109
149
 
110
150
  Args:
111
- distance (int): Bucket distance
151
+ addr: Tuple of (ip, port)
112
152
 
113
153
  Returns:
114
- List[Peer]: Peers in the bucket
154
+ bool: True if the peer exists, False otherwise
115
155
  """
116
- if distance not in self.buckets:
117
- return []
118
-
119
- peers = []
120
- for address in self.buckets[distance].addresses:
121
- peer = self.peer_manager.get_peer_by_address(address)
122
- if peer:
123
- peers.append(peer)
124
-
125
- return peers
156
+ return self.peer_manager.get_peer_by_address(addr) is not None
157
+
158
+ @property
159
+ def num_buckets(self) -> int:
160
+ """Get the number of active buckets."""
161
+ return len(self.buckets)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: astreum
3
- Version: 0.1.8
3
+ Version: 0.1.9
4
4
  Summary: Python library to interact with the Astreum blockchain and its Lispeum virtual machine.
5
5
  Author-email: "Roy R. O. Okello" <roy@stelar.xyz>
6
6
  Project-URL: Homepage, https://github.com/astreum/lib
@@ -11,6 +11,8 @@ Classifier: Operating System :: OS Independent
11
11
  Requires-Python: >=3.8
12
12
  Description-Content-Type: text/markdown
13
13
  License-File: LICENSE
14
+ Requires-Dist: pycryptodomex<4.0.0,>=3.14.1
15
+ Requires-Dist: cryptography<40.0.0,>=39.0.0
14
16
 
15
17
  # lib
16
18
 
@@ -26,9 +28,10 @@ When initializing an Astreum Node, you need to provide a configuration dictionar
26
28
 
27
29
  | Parameter | Type | Default | Description |
28
30
  |-----------|------|---------|-------------|
29
- | `node_id` | bytes | Random 32 bytes | Unique identifier for the node |
30
- | `followed_chain_id` | bytes | None | ID of the blockchain that this node follows |
31
- | `storage_path` | str | "./storage" | Directory path where node data will be stored |
31
+ | `private_key` | string | Auto-generated | Hex string of Ed25519 private key. If not provided, a new keypair will be generated automatically |
32
+ | `storage_path` | string | "storage" | Path to store data |
33
+ | `max_storage_space` | int | 1073741824 (1GB) | Maximum storage space in bytes |
34
+ | `max_object_recursion` | int | 50 | Maximum recursion depth for resolving nested objects |
32
35
 
33
36
  ### Network Configuration
34
37
 
@@ -38,15 +41,17 @@ When initializing an Astreum Node, you need to provide a configuration dictionar
38
41
  | `incoming_port` | int | 7373 | Port to listen for incoming messages |
39
42
  | `max_message_size` | int | 65536 | Maximum size of UDP datagrams in bytes |
40
43
  | `num_workers` | int | 4 | Number of worker threads for message processing |
44
+ | `network_request_timeout` | float | 5.0 | Maximum time (in seconds) to wait for network object requests |
41
45
 
42
46
  ### Route Configuration
43
47
 
44
48
  | Parameter | Type | Default | Description |
45
49
  |-----------|------|---------|-------------|
46
- | `peer_route` | bool | False | Whether to participate in the peer discovery route |
47
50
  | `validation_route` | bool | False | Whether to participate in the block validation route |
48
51
  | `bootstrap_peers` | list | [] | List of bootstrap peers in the format `[("hostname", port), ...]` |
49
52
 
53
+ > **Note:** The peer route is always enabled as it's necessary for object discovery and retrieval.
54
+
50
55
  ### Example Usage
51
56
 
52
57
  ```python
@@ -54,8 +59,7 @@ from astreum.node import Node
54
59
 
55
60
  # Configuration dictionary
56
61
  config = {
57
- "node_id": b"my-unique-node-id-goes-here-exactly-32", # 32 bytes
58
- "followed_chain_id": b"main-chain-id-goes-here",
62
+ "private_key": "my-private-key-goes-here",
59
63
  "storage_path": "./data/node1",
60
64
  "incoming_port": 7373,
61
65
  "use_ipv6": False,
@@ -2,7 +2,7 @@ astreum/__init__.py,sha256=di8SwGUW1lNKUwvNWCjH9eLmz__sk1SbtuNZVjjpRe4,59
2
2
  astreum/lispeum/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
3
  astreum/lispeum/expression.py,sha256=3zOEoXFHpzEFUIi1clONY55WYAh5K0YkYhaLmtvQj0I,2939
4
4
  astreum/lispeum/parser.py,sha256=SU8mjPj1ub4xQbU4CeX15HmKZAj4vI6TeefX2B72VCo,1191
5
- astreum/lispeum/storage.py,sha256=SmVDfropIP4fTOXq2_xWsWzYesLNsQBst8AIbyXdao4,16174
5
+ astreum/lispeum/storage.py,sha256=34BXwn6RVPqdGc6q-SxHT8Qn-lH8YDtnvwJh_7E-gNQ,13948
6
6
  astreum/lispeum/tokenizer.py,sha256=4obr1Jt-k1TqhsImHWUn7adQl9Ks5_VmcEFOTlQwocQ,1437
7
7
  astreum/lispeum/special/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
8
  astreum/lispeum/special/definition.py,sha256=ukQJ-Mz57bpqbTPQiwmevdE4uxec3Zt--apT3VhUYqU,840
@@ -20,18 +20,18 @@ astreum/lispeum/special/number/addition.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5
20
20
  astreum/machine/__init__.py,sha256=5Sx-_MEYFG-089q2G8JtLp9_ELiPYnYIXhagjy6C4w0,11048
21
21
  astreum/machine/environment.py,sha256=K0084U6B7wwjrDZ9b2_7cEcbBzsB7UOy_Zpbrr7B3GY,834
22
22
  astreum/machine/error.py,sha256=MvqBaZZt33rNELNhUJ2lER3TE3aS8WVqsWF2hz2AwoA,38
23
- astreum/node/__init__.py,sha256=LVNFvIpTMojaBCZ6OjunoumhVWRcQAteEobY8kxuFac,19523
24
- astreum/node/models.py,sha256=CBYuMJVD0uI5i6fWSFE2egkZXtFQHvR79kKTU4KP1WE,3086
25
- astreum/node/relay/__init__.py,sha256=i_uD8ZMuA8Xa4vdzVAdiyd5uhIv-7-n9SBUG7WiL8mk,10024
26
- astreum/node/relay/bucket.py,sha256=w0jzOrSUVT5WmRW_ggmtBcdf8sgZW-_MTeT4YQuhAvg,2211
23
+ astreum/node/__init__.py,sha256=wvmMMACicwhJ-ipSLpR1rsEUXWuHaZ8fnsBCkUA_RA8,22387
24
+ astreum/node/models.py,sha256=MScDCNEpR-SfaFsRSU567mVuZRhH1Lqc7gmlqb3j-nI,10980
25
+ astreum/node/relay/__init__.py,sha256=gQNYDxllkjZqE6bvgPOYkKu3QHkjPFn11RvtP-BxtKI,13994
26
+ astreum/node/relay/bucket.py,sha256=pcmollbbM-xeHlmDxLZnzvf0Ut-9v9RoN6SijYiQuu8,2893
27
27
  astreum/node/relay/envelope.py,sha256=TfkynttoPX7smvMV7xEAdtIlfz-Z-EZjuhZ826csZxA,10078
28
- astreum/node/relay/message.py,sha256=9j3Q5T5VVj47g0xqxUCWxtf2EFXutIPbj0mt1KbVw3M,2812
29
- astreum/node/relay/peer.py,sha256=eOml9G4xMoQeR_BuONVgJvpvCwhQo-QXCpRhShAxz4Q,5721
30
- astreum/node/relay/route.py,sha256=ZyWoTC2EXX-SU_80fFS0TP-lITH6e9mDeS-irsfcMp4,4303
28
+ astreum/node/relay/message.py,sha256=uezmGjNaQK4fZmYQLCHd2YpiosaaFb8DOa3H58HS1jA,2887
29
+ astreum/node/relay/peer.py,sha256=DlvTR9j0BZQ1dW-p_9UGgfLvQqwNdpNLMSCYEW4FhyI,5899
30
+ astreum/node/relay/route.py,sha256=fyOSsAe1mfsCVeN6LtQ_OEUEb1FiC5dobZBEJKNGU9U,5814
31
31
  astreum/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
32
32
  astreum/utils/bytes_format.py,sha256=X4tG5GGPweNCE54bHYkLFiuLTbmpy5upO_s1Cef-MGA,2711
33
- astreum-0.1.8.dist-info/LICENSE,sha256=gYBvRDP-cPLmTyJhvZ346QkrYW_eleke4Z2Yyyu43eQ,1089
34
- astreum-0.1.8.dist-info/METADATA,sha256=Uu8SnsLZ2vdk5fhicfnsqB3jCQN08u4hMB3_KZYMxOQ,2686
35
- astreum-0.1.8.dist-info/WHEEL,sha256=EaM1zKIUYa7rQnxGiOCGhzJABRwy4WO57rWMR3_tj4I,91
36
- astreum-0.1.8.dist-info/top_level.txt,sha256=1EG1GmkOk3NPmUA98FZNdKouhRyget-KiFiMk0i2Uz0,8
37
- astreum-0.1.8.dist-info/RECORD,,
33
+ astreum-0.1.9.dist-info/LICENSE,sha256=gYBvRDP-cPLmTyJhvZ346QkrYW_eleke4Z2Yyyu43eQ,1089
34
+ astreum-0.1.9.dist-info/METADATA,sha256=giKzIaeLdFUp_6AKqh1YxeS3epMjYpzlT8Dya6Gg3uY,2956
35
+ astreum-0.1.9.dist-info/WHEEL,sha256=52BFRY2Up02UkjOa29eZOS2VxUrpPORXg1pkohGGUS8,91
36
+ astreum-0.1.9.dist-info/top_level.txt,sha256=1EG1GmkOk3NPmUA98FZNdKouhRyget-KiFiMk0i2Uz0,8
37
+ astreum-0.1.9.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (75.9.1)
2
+ Generator: setuptools (76.0.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5