pyurt-pro 3.1.1__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.
- pyurt_pro/__init__.py +23 -0
- pyurt_pro/auth/jwt.py +24 -0
- pyurt_pro/cli.py +20 -0
- pyurt_pro/cluster/node.py +80 -0
- pyurt_pro/config.py +56 -0
- pyurt_pro/core/protocol.py +51 -0
- pyurt_pro/mq/broker.py +38 -0
- pyurt_pro/rpc/server.py +43 -0
- pyurt_pro-3.1.1.dist-info/METADATA +51 -0
- pyurt_pro-3.1.1.dist-info/RECORD +13 -0
- pyurt_pro-3.1.1.dist-info/WHEEL +5 -0
- pyurt_pro-3.1.1.dist-info/entry_points.txt +2 -0
- pyurt_pro-3.1.1.dist-info/top_level.txt +1 -0
pyurt_pro/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PyURT Pro – расширенная версия с кластеризацией, очередями, RPC и JWT.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from pyurt_pro.core.protocol import ExtendedProtocol, MessageTypes
|
|
6
|
+
from pyurt_pro.cluster.node import ClusterNode, ClusterServer
|
|
7
|
+
from pyurt_pro.mq.broker import RedisBroker
|
|
8
|
+
from pyurt_pro.rpc.server import RPCServer, rpc_method
|
|
9
|
+
from pyurt_pro.auth.jwt import JWTManager
|
|
10
|
+
from pyurt_pro.config import load_config, get_config
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"ExtendedProtocol",
|
|
14
|
+
"MessageTypes",
|
|
15
|
+
"ClusterNode",
|
|
16
|
+
"ClusterServer",
|
|
17
|
+
"RedisBroker",
|
|
18
|
+
"RPCServer",
|
|
19
|
+
"rpc_method",
|
|
20
|
+
"JWTManager",
|
|
21
|
+
"load_config",
|
|
22
|
+
"get_config",
|
|
23
|
+
]
|
pyurt_pro/auth/jwt.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import jwt
|
|
2
|
+
import time
|
|
3
|
+
from typing import Optional
|
|
4
|
+
from pyurt_pro.config import get_config
|
|
5
|
+
|
|
6
|
+
class JWTManager:
|
|
7
|
+
@staticmethod
|
|
8
|
+
def get_secret_key() -> str:
|
|
9
|
+
return get_config()['auth']['secret_key']
|
|
10
|
+
|
|
11
|
+
@classmethod
|
|
12
|
+
def generate_token(cls, user_id: str, expires_in: int = None) -> str:
|
|
13
|
+
if expires_in is None:
|
|
14
|
+
expires_in = get_config()['auth'].get('expires_in', 3600)
|
|
15
|
+
payload = {"user_id": user_id, "exp": time.time() + expires_in}
|
|
16
|
+
return jwt.encode(payload, cls.get_secret_key(), algorithm="HS256")
|
|
17
|
+
|
|
18
|
+
@classmethod
|
|
19
|
+
def verify_token(cls, token: str) -> Optional[str]:
|
|
20
|
+
try:
|
|
21
|
+
payload = jwt.decode(token, cls.get_secret_key(), algorithms=["HS256"])
|
|
22
|
+
return payload.get("user_id")
|
|
23
|
+
except jwt.InvalidTokenError:
|
|
24
|
+
return None
|
pyurt_pro/cli.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import argparse
|
|
3
|
+
from pyurt_pro.config import load_config
|
|
4
|
+
from pyurt_pro.cluster.node import ClusterNode
|
|
5
|
+
|
|
6
|
+
def main():
|
|
7
|
+
parser = argparse.ArgumentParser(description="PyURT Pro Cluster Node")
|
|
8
|
+
parser.add_argument("-c", "--config", default="config.yaml", help="Path to YAML config")
|
|
9
|
+
args = parser.parse_args()
|
|
10
|
+
|
|
11
|
+
load_config(args.config)
|
|
12
|
+
node = ClusterNode()
|
|
13
|
+
try:
|
|
14
|
+
node.start()
|
|
15
|
+
except KeyboardInterrupt:
|
|
16
|
+
node.stop()
|
|
17
|
+
sys.exit(0)
|
|
18
|
+
|
|
19
|
+
if __name__ == "__main__":
|
|
20
|
+
main()
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
import redis
|
|
6
|
+
from typing import Any
|
|
7
|
+
from pyurt import PyURTServer
|
|
8
|
+
from pyurt_pro.core.protocol import ExtendedProtocol, MessageTypes
|
|
9
|
+
from pyurt_pro.config import get_config
|
|
10
|
+
|
|
11
|
+
class ClusterServer(PyURTServer):
|
|
12
|
+
def __init__(self, node: 'ClusterNode'):
|
|
13
|
+
super().__init__()
|
|
14
|
+
self.node = node
|
|
15
|
+
|
|
16
|
+
async def handle_client(self, reader, writer):
|
|
17
|
+
while True:
|
|
18
|
+
try:
|
|
19
|
+
msg_type, payload = await ExtendedProtocol.read(reader)
|
|
20
|
+
if msg_type == 0:
|
|
21
|
+
break
|
|
22
|
+
if msg_type == MessageTypes.CLUSTER_JOIN:
|
|
23
|
+
data = json.loads(payload)
|
|
24
|
+
self.node.peers.add((data['host'], data['port']))
|
|
25
|
+
writer.write(ExtendedProtocol.build(b'OK', MessageTypes.ACK))
|
|
26
|
+
await writer.drain()
|
|
27
|
+
elif msg_type == MessageTypes.CLUSTER_PING:
|
|
28
|
+
writer.write(ExtendedProtocol.build(b'PONG', MessageTypes.ACK))
|
|
29
|
+
await writer.drain()
|
|
30
|
+
else:
|
|
31
|
+
writer.write(ExtendedProtocol.build(b'NACK', MessageTypes.NACK))
|
|
32
|
+
await writer.drain()
|
|
33
|
+
except Exception:
|
|
34
|
+
break
|
|
35
|
+
writer.close()
|
|
36
|
+
await writer.wait_closed()
|
|
37
|
+
|
|
38
|
+
class ClusterNode:
|
|
39
|
+
def __init__(self):
|
|
40
|
+
config = get_config()['cluster']
|
|
41
|
+
self.host = config['host']
|
|
42
|
+
self.port = config['port']
|
|
43
|
+
self.seed_nodes = config['seed_nodes']
|
|
44
|
+
self.redis_url = config['redis_url']
|
|
45
|
+
self.max_size = config['max_size']
|
|
46
|
+
self.redis = redis.from_url(self.redis_url)
|
|
47
|
+
self.peers = set()
|
|
48
|
+
self.running = False
|
|
49
|
+
self.server = None
|
|
50
|
+
self._loop = None
|
|
51
|
+
|
|
52
|
+
def start(self):
|
|
53
|
+
self.running = True
|
|
54
|
+
self._register_node()
|
|
55
|
+
self.server = ClusterServer(self)
|
|
56
|
+
self._loop = asyncio.new_event_loop()
|
|
57
|
+
asyncio.set_event_loop(self._loop)
|
|
58
|
+
self._loop.run_until_complete(self.server.run())
|
|
59
|
+
|
|
60
|
+
def _register_node(self):
|
|
61
|
+
self.redis.sadd("pyurt:cluster:nodes", f"{self.host}:{self.port}")
|
|
62
|
+
self.redis.expire("pyurt:cluster:nodes", 60)
|
|
63
|
+
threading.Thread(target=self._heartbeat_loop, daemon=True).start()
|
|
64
|
+
|
|
65
|
+
def _heartbeat_loop(self):
|
|
66
|
+
while self.running:
|
|
67
|
+
self.redis.sadd("pyurt:cluster:nodes", f"{self.host}:{self.port}")
|
|
68
|
+
self.redis.expire("pyurt:cluster:nodes", 30)
|
|
69
|
+
all_nodes = self.redis.smembers("pyurt:cluster:nodes")
|
|
70
|
+
self.peers = {node.decode() for node in all_nodes if node.decode() != f"{self.host}:{self.port}"}
|
|
71
|
+
time.sleep(10)
|
|
72
|
+
|
|
73
|
+
def broadcast(self, data: Any):
|
|
74
|
+
self.redis.publish("pyurt:cluster:broadcast", json.dumps(data))
|
|
75
|
+
|
|
76
|
+
def stop(self):
|
|
77
|
+
self.running = False
|
|
78
|
+
self.redis.srem("pyurt:cluster:nodes", f"{self.host}:{self.port}")
|
|
79
|
+
if self.server and self._loop:
|
|
80
|
+
asyncio.run_coroutine_threadsafe(self.server.close(), self._loop)
|
pyurt_pro/config.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import yaml
|
|
3
|
+
from typing import Dict, Any
|
|
4
|
+
|
|
5
|
+
DEFAULT_CONFIG = {
|
|
6
|
+
"cluster": {
|
|
7
|
+
"host": "0.0.0.0",
|
|
8
|
+
"port": 8888,
|
|
9
|
+
"seed_nodes": [],
|
|
10
|
+
"redis_url": "redis://localhost:6379/0",
|
|
11
|
+
"max_size": 1048576,
|
|
12
|
+
},
|
|
13
|
+
"mq": {
|
|
14
|
+
"persistent": True,
|
|
15
|
+
"max_messages": 1000,
|
|
16
|
+
},
|
|
17
|
+
"rpc": {
|
|
18
|
+
"enable_ssl": False,
|
|
19
|
+
},
|
|
20
|
+
"auth": {
|
|
21
|
+
"secret_key": "change-me-in-production",
|
|
22
|
+
"expires_in": 3600,
|
|
23
|
+
},
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
_config = None
|
|
27
|
+
|
|
28
|
+
def load_config(config_path: str = None) -> Dict[str, Any]:
|
|
29
|
+
global _config
|
|
30
|
+
if _config is not None:
|
|
31
|
+
return _config
|
|
32
|
+
if config_path is None:
|
|
33
|
+
config_path = os.environ.get("PYURT_PRO_CONFIG", "config.yaml")
|
|
34
|
+
try:
|
|
35
|
+
with open(config_path, 'r') as f:
|
|
36
|
+
user_config = yaml.safe_load(f) or {}
|
|
37
|
+
except FileNotFoundError:
|
|
38
|
+
user_config = {}
|
|
39
|
+
merged = _deep_merge(DEFAULT_CONFIG, user_config)
|
|
40
|
+
_config = merged
|
|
41
|
+
return merged
|
|
42
|
+
|
|
43
|
+
def _deep_merge(base: dict, override: dict) -> dict:
|
|
44
|
+
result = base.copy()
|
|
45
|
+
for key, value in override.items():
|
|
46
|
+
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
|
|
47
|
+
result[key] = _deep_merge(result[key], value)
|
|
48
|
+
else:
|
|
49
|
+
result[key] = value
|
|
50
|
+
return result
|
|
51
|
+
|
|
52
|
+
def get_config() -> Dict[str, Any]:
|
|
53
|
+
global _config
|
|
54
|
+
if _config is None:
|
|
55
|
+
load_config()
|
|
56
|
+
return _config
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import enum
|
|
2
|
+
import struct
|
|
3
|
+
import zlib
|
|
4
|
+
import asyncio
|
|
5
|
+
from typing import Tuple, Optional
|
|
6
|
+
from pyurt import Protocol
|
|
7
|
+
from pyurt_pro.config import get_config
|
|
8
|
+
|
|
9
|
+
class MessageTypes(enum.IntEnum):
|
|
10
|
+
DATA = 1
|
|
11
|
+
ACK = 2
|
|
12
|
+
NACK = 3
|
|
13
|
+
RPC_REQUEST = 100
|
|
14
|
+
RPC_RESPONSE = 101
|
|
15
|
+
MQ_PUBLISH = 200
|
|
16
|
+
MQ_SUBSCRIBE = 201
|
|
17
|
+
MQ_UNSUBSCRIBE = 202
|
|
18
|
+
MQ_DELIVER = 203
|
|
19
|
+
CLUSTER_JOIN = 300
|
|
20
|
+
CLUSTER_LEAVE = 301
|
|
21
|
+
CLUSTER_SYNC = 302
|
|
22
|
+
CLUSTER_PING = 303
|
|
23
|
+
AUTH_TOKEN = 400
|
|
24
|
+
AUTH_CHALLENGE = 401
|
|
25
|
+
AUTH_RESPONSE = 402
|
|
26
|
+
|
|
27
|
+
class ExtendedProtocol(Protocol):
|
|
28
|
+
HEADER_SIZE = 9
|
|
29
|
+
|
|
30
|
+
@staticmethod
|
|
31
|
+
def build(payload: bytes, msg_type: int = MessageTypes.DATA) -> bytes:
|
|
32
|
+
crc = zlib.crc32(payload) & 0xFFFFFFFF
|
|
33
|
+
header = struct.pack('!BII', msg_type, len(payload), crc)
|
|
34
|
+
return header + payload
|
|
35
|
+
|
|
36
|
+
@staticmethod
|
|
37
|
+
async def read(reader, timeout: float = 30.0) -> Tuple[int, Optional[bytes]]:
|
|
38
|
+
try:
|
|
39
|
+
raw_header = await asyncio.wait_for(reader.readexactly(ExtendedProtocol.HEADER_SIZE), timeout)
|
|
40
|
+
except asyncio.IncompleteReadError:
|
|
41
|
+
return 0, None
|
|
42
|
+
except asyncio.TimeoutError:
|
|
43
|
+
raise ValueError("Timeout reading header")
|
|
44
|
+
msg_type, length, crc = struct.unpack('!BII', raw_header)
|
|
45
|
+
max_size = get_config()['cluster']['max_size']
|
|
46
|
+
if length > max_size:
|
|
47
|
+
raise ValueError(f"Message too large: {length}")
|
|
48
|
+
payload = await asyncio.wait_for(reader.readexactly(length), timeout)
|
|
49
|
+
if zlib.crc32(payload) & 0xFFFFFFFF != crc:
|
|
50
|
+
raise ValueError("CRC mismatch")
|
|
51
|
+
return msg_type, payload
|
pyurt_pro/mq/broker.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import redis
|
|
3
|
+
from typing import Any, Callable
|
|
4
|
+
from pyurt_pro.config import get_config
|
|
5
|
+
|
|
6
|
+
class RedisBroker:
|
|
7
|
+
def __init__(self):
|
|
8
|
+
config = get_config()['mq']
|
|
9
|
+
self.redis_url = get_config()['cluster']['redis_url']
|
|
10
|
+
self.redis = redis.from_url(self.redis_url)
|
|
11
|
+
self.pubsub = self.redis.pubsub()
|
|
12
|
+
self._subscriptions = {}
|
|
13
|
+
self.persistent = config.get('persistent', True)
|
|
14
|
+
self.max_messages = config.get('max_messages', 1000)
|
|
15
|
+
|
|
16
|
+
def publish(self, topic: str, message: Any, persistent: bool = None):
|
|
17
|
+
if persistent is None:
|
|
18
|
+
persistent = self.persistent
|
|
19
|
+
data = {"payload": message, "persistent": persistent}
|
|
20
|
+
self.redis.publish(f"pyurt:mq:{topic}", json.dumps(data))
|
|
21
|
+
if persistent:
|
|
22
|
+
self.redis.rpush(f"pyurt:mq:{topic}:history", json.dumps(message))
|
|
23
|
+
self.redis.ltrim(f"pyurt:mq:{topic}:history", -self.max_messages, -1)
|
|
24
|
+
|
|
25
|
+
def subscribe(self, topic: str, callback: Callable):
|
|
26
|
+
if topic not in self._subscriptions:
|
|
27
|
+
self.pubsub.subscribe(f"pyurt:mq:{topic}")
|
|
28
|
+
self._subscriptions[topic] = callback
|
|
29
|
+
import threading
|
|
30
|
+
def listener():
|
|
31
|
+
for msg in self.pubsub.listen():
|
|
32
|
+
if msg['type'] == 'message':
|
|
33
|
+
data = json.loads(msg['data'])
|
|
34
|
+
callback(data['payload'])
|
|
35
|
+
threading.Thread(target=listener, daemon=True).start()
|
|
36
|
+
|
|
37
|
+
def get_history(self, topic: str, limit: int = 10) -> list:
|
|
38
|
+
return [json.loads(item) for item in self.redis.lrange(f"pyurt:mq:{topic}:history", -limit, -1)]
|
pyurt_pro/rpc/server.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from typing import Dict, Callable
|
|
3
|
+
from pyurt import PyURTServer
|
|
4
|
+
from pyurt_pro.core.protocol import ExtendedProtocol, MessageTypes
|
|
5
|
+
|
|
6
|
+
class RPCServer(PyURTServer):
|
|
7
|
+
def __init__(self):
|
|
8
|
+
super().__init__()
|
|
9
|
+
self.methods: Dict[str, Callable] = {}
|
|
10
|
+
|
|
11
|
+
def register(self, name: str, func: Callable):
|
|
12
|
+
self.methods[name] = func
|
|
13
|
+
|
|
14
|
+
async def handle_client(self, reader, writer):
|
|
15
|
+
while True:
|
|
16
|
+
try:
|
|
17
|
+
msg_type, payload = await ExtendedProtocol.read(reader, timeout=30.0)
|
|
18
|
+
if msg_type == 0:
|
|
19
|
+
break
|
|
20
|
+
if msg_type == MessageTypes.RPC_REQUEST:
|
|
21
|
+
data = json.loads(payload)
|
|
22
|
+
method_name = data.get("method")
|
|
23
|
+
params = data.get("params", [])
|
|
24
|
+
if method_name in self.methods:
|
|
25
|
+
result = self.methods[method_name](*params)
|
|
26
|
+
response = {"id": data.get("id"), "result": result, "error": None}
|
|
27
|
+
else:
|
|
28
|
+
response = {"id": data.get("id"), "result": None, "error": "Method not found"}
|
|
29
|
+
writer.write(ExtendedProtocol.build(json.dumps(response).encode(), MessageTypes.RPC_RESPONSE))
|
|
30
|
+
await writer.drain()
|
|
31
|
+
else:
|
|
32
|
+
writer.write(ExtendedProtocol.build(b'NACK', MessageTypes.NACK))
|
|
33
|
+
await writer.drain()
|
|
34
|
+
except Exception:
|
|
35
|
+
break
|
|
36
|
+
writer.close()
|
|
37
|
+
await writer.wait_closed()
|
|
38
|
+
|
|
39
|
+
def rpc_method(name: str = None):
|
|
40
|
+
def decorator(func):
|
|
41
|
+
func._rpc_name = name or func.__name__
|
|
42
|
+
return func
|
|
43
|
+
return decorator
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyurt-pro
|
|
3
|
+
Version: 3.1.1
|
|
4
|
+
Summary: PyURT Pro – расширенный протокол с кластеризацией, очередями, RPC и JWT
|
|
5
|
+
Author: HioDev
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: pyurt,cluster,mq,rpc,jwt
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
Requires-Dist: pyurt>=3.1.0
|
|
18
|
+
Requires-Dist: redis>=4.0.0
|
|
19
|
+
Requires-Dist: pyyaml>=6.0
|
|
20
|
+
Requires-Dist: pyjwt>=2.0.0
|
|
21
|
+
|
|
22
|
+
# PyURT Pro – расширенный протокол
|
|
23
|
+
|
|
24
|
+
Добавляет кластеризацию, очереди сообщений, RPC и JWT-аутентификацию.
|
|
25
|
+
|
|
26
|
+
## Установка
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install pyurt-pro
|
|
30
|
+
## Запуск
|
|
31
|
+
# УСТАНОВИТЕ И ЗАПУСТИТЕ redis
|
|
32
|
+
pyurt-pro -c config.yaml # Запуск
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
## В коде
|
|
36
|
+
from pyurt_pro import ClusterNode, RedisBroker, RPCServer, rpc_method
|
|
37
|
+
|
|
38
|
+
# Кластер
|
|
39
|
+
node = ClusterNode()
|
|
40
|
+
node.start()
|
|
41
|
+
|
|
42
|
+
# Очереди
|
|
43
|
+
broker = RedisBroker()
|
|
44
|
+
broker.publish("chat", "Hello, world!")
|
|
45
|
+
|
|
46
|
+
# RPC
|
|
47
|
+
@rpc_method("add")
|
|
48
|
+
def add(a, b):
|
|
49
|
+
return a + b
|
|
50
|
+
|
|
51
|
+
## Для запуска
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
pyurt_pro/__init__.py,sha256=ey8i70sjvdy0UUz91AO2DmtWIHqg-v5r4zQuAv0W-d0,654
|
|
2
|
+
pyurt_pro/cli.py,sha256=gPpzGOk0Q1HrlxpE-0JVZ2faQV-eChe9x7dFqDKUwx4,520
|
|
3
|
+
pyurt_pro/config.py,sha256=p9SxaQiOXfANFknh7hpPHg9VWs9XyLUKS8rKTKbXkxg,1419
|
|
4
|
+
pyurt_pro/auth/jwt.py,sha256=tJVpaVn268oEGejTvAi11sC8eAYNy1oNQiJKhGg28h0,826
|
|
5
|
+
pyurt_pro/cluster/node.py,sha256=jTdX3tleHKzsgjmr7WWnf407Cv-OI9ggGs3EcWkQ8T0,3026
|
|
6
|
+
pyurt_pro/core/protocol.py,sha256=T0cps4tnwQiadpBFslSg2wnC-gFD1ys5HPOLqmMrEtM,1623
|
|
7
|
+
pyurt_pro/mq/broker.py,sha256=M1-wqkmYHNpXnaL_bJnDW3NXHPiUAB6BIu8q2S06b_w,1648
|
|
8
|
+
pyurt_pro/rpc/server.py,sha256=0Gzvyvo2xRDM_7zr_hzd2qAU8wbcrhyLVK01kwfnJs4,1703
|
|
9
|
+
pyurt_pro-3.1.1.dist-info/METADATA,sha256=0rbP3FDVvlFZuj_PBO6kfKPpq8yqaqD3O6bEsjVtoMc,1442
|
|
10
|
+
pyurt_pro-3.1.1.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
11
|
+
pyurt_pro-3.1.1.dist-info/entry_points.txt,sha256=K1ndqExjHITXrgsDRxUQc8DZKc-esCIMqr3FT5-YYAo,49
|
|
12
|
+
pyurt_pro-3.1.1.dist-info/top_level.txt,sha256=Zbt566bE_5KUXhLoAxf_8LsPT584MayWb_rd0LObh68,10
|
|
13
|
+
pyurt_pro-3.1.1.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pyurt_pro
|