picopyn 0.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.
- picopyn/__init__.py +9 -0
- picopyn/client.py +92 -0
- picopyn/connection.py +84 -0
- picopyn/pool.py +272 -0
- picopyn-0.1.dist-info/METADATA +3 -0
- picopyn-0.1.dist-info/RECORD +8 -0
- picopyn-0.1.dist-info/WHEEL +5 -0
- picopyn-0.1.dist-info/top_level.txt +1 -0
picopyn/__init__.py
ADDED
picopyn/client.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
import asyncpg
|
|
5
|
+
|
|
6
|
+
from .connection import Connection
|
|
7
|
+
from .pool import Pool
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Client:
|
|
11
|
+
"""
|
|
12
|
+
Async client for managing connections to a picodata cluster using a connection pool.
|
|
13
|
+
|
|
14
|
+
This client handles connection pooling, automatic node discovery (if enabled),
|
|
15
|
+
and supports load balancing strategies for query distribution.
|
|
16
|
+
|
|
17
|
+
:param dsn (str): The data source name (e.g., "postgresql://user:pass@host:port") for the cluster.
|
|
18
|
+
:param balance_strategy (callable, optional): A custom strategy function to select a connection
|
|
19
|
+
from the pool. If None, round-robin strategy is used.
|
|
20
|
+
:param connect_kwargs: Additional keyword arguments passed to each connection.
|
|
21
|
+
|
|
22
|
+
Example:
|
|
23
|
+
>>> def random_strategy(connections):
|
|
24
|
+
... import random
|
|
25
|
+
... return random.choice(connections)
|
|
26
|
+
|
|
27
|
+
>>> client = Client(
|
|
28
|
+
... dsn="postgresql://admin:pass@localhost:5432",
|
|
29
|
+
... balance_strategy=random_strategy
|
|
30
|
+
... )
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
dsn: str,
|
|
36
|
+
pool_size: int | None = None,
|
|
37
|
+
balance_strategy: Callable[[list[Connection]], Connection] | None = None,
|
|
38
|
+
**connect_kwargs: Any,
|
|
39
|
+
) -> None:
|
|
40
|
+
self._pool = Pool(
|
|
41
|
+
dsn=dsn,
|
|
42
|
+
max_size=pool_size or 10,
|
|
43
|
+
enable_discovery=True,
|
|
44
|
+
balance_strategy=balance_strategy,
|
|
45
|
+
**connect_kwargs,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
async def connect(self) -> None:
|
|
49
|
+
"""
|
|
50
|
+
Prepares the client by connection connection pool.
|
|
51
|
+
|
|
52
|
+
This should be called before using the client to ensure connections are available.
|
|
53
|
+
"""
|
|
54
|
+
await self._pool.connect()
|
|
55
|
+
|
|
56
|
+
async def execute(self, query: str, *args: Any) -> str:
|
|
57
|
+
"""
|
|
58
|
+
Executes a query that does not return rows (e.g. INSERT, UPDATE, DELETE).
|
|
59
|
+
|
|
60
|
+
:param query: The SQL query string.
|
|
61
|
+
:param args: Optional parameters for the SQL query.
|
|
62
|
+
:return: The result of the query execution.
|
|
63
|
+
"""
|
|
64
|
+
return await self._pool.execute(query, *args)
|
|
65
|
+
|
|
66
|
+
async def fetch(self, query: str, *args: Any) -> list[asyncpg.Record]:
|
|
67
|
+
"""
|
|
68
|
+
Executes a query and fetches all resulting rows.
|
|
69
|
+
|
|
70
|
+
:param query: The SQL query string.
|
|
71
|
+
:param args: Optional parameters for the SQL query.
|
|
72
|
+
:return: A list of rows returned by the query.
|
|
73
|
+
"""
|
|
74
|
+
return await self._pool.fetch(query, *args)
|
|
75
|
+
|
|
76
|
+
async def fetchrow(self, query: str, *args: Any) -> asyncpg.Record | None:
|
|
77
|
+
"""
|
|
78
|
+
Executes a query and fetches a single row (first row).
|
|
79
|
+
|
|
80
|
+
:param query: The SQL query string.
|
|
81
|
+
:param args: Optional parameters for the SQL query.
|
|
82
|
+
:return: A single row returned by the query.
|
|
83
|
+
"""
|
|
84
|
+
return await self._pool.fetchrow(query, *args)
|
|
85
|
+
|
|
86
|
+
async def close(self) -> None:
|
|
87
|
+
"""
|
|
88
|
+
Closes all connections in the pool.
|
|
89
|
+
|
|
90
|
+
This should be called during application shutdown to clean up resources.
|
|
91
|
+
"""
|
|
92
|
+
await self._pool.close()
|
picopyn/connection.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
import asyncpg
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Connection:
|
|
7
|
+
"""
|
|
8
|
+
A representation of a database session.
|
|
9
|
+
|
|
10
|
+
:param dsn (str): The data source name (e.g., "postgresql://user:pass@host:port") for the picodata node.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def __init__(self, dsn: str) -> None:
|
|
14
|
+
if dsn is None:
|
|
15
|
+
raise ValueError("dsn can not be None")
|
|
16
|
+
self.dsn = dsn
|
|
17
|
+
self.conn = None
|
|
18
|
+
|
|
19
|
+
async def connect(self) -> None:
|
|
20
|
+
"""
|
|
21
|
+
Create new connection to Picodata
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
self.conn = await asyncpg.connect(self.dsn)
|
|
26
|
+
except Exception as e:
|
|
27
|
+
raise RuntimeError(
|
|
28
|
+
f"Failed to connect to picodata instance using DSN {self.dsn}: {e}"
|
|
29
|
+
) from e
|
|
30
|
+
|
|
31
|
+
async def execute(self, *args: Any, **kwargs: Any) -> str:
|
|
32
|
+
"""
|
|
33
|
+
Execute an SQL command
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
if not self.conn:
|
|
37
|
+
raise OSError("No active connection. Try to call .connect() before.")
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
return await self.conn.execute(*args, **kwargs)
|
|
41
|
+
except Exception as e:
|
|
42
|
+
raise RuntimeError(f"Failed to execute SQL query: {e}. Query: {args}") from e
|
|
43
|
+
|
|
44
|
+
async def fetchrow(self, *args: Any, **kwargs: Any) -> asyncpg.Record | None:
|
|
45
|
+
"""
|
|
46
|
+
Run a query and return the first row.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
if not self.conn:
|
|
50
|
+
raise OSError("No active connection. Try to call .connect() before")
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
return await self.conn.fetchrow(*args, **kwargs)
|
|
54
|
+
except Exception as e:
|
|
55
|
+
raise RuntimeError(
|
|
56
|
+
f"Failed to execute SQL query and fetch row: {e}. Query: {args}"
|
|
57
|
+
) from e
|
|
58
|
+
|
|
59
|
+
async def fetch(self, *args: Any, **kwargs: Any) -> list[asyncpg.Record]:
|
|
60
|
+
"""
|
|
61
|
+
Run a query and return the results as a list.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
if not self.conn:
|
|
65
|
+
raise OSError("No active connection. Try to call .connect() before")
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
return await self.conn.fetch(*args, **kwargs)
|
|
69
|
+
except Exception as e:
|
|
70
|
+
raise RuntimeError(
|
|
71
|
+
f"Failed to execute SQL query and fetch result: {e}. Query: {args}"
|
|
72
|
+
) from e
|
|
73
|
+
|
|
74
|
+
async def close(self, *args: Any, **kwargs: Any) -> None:
|
|
75
|
+
"""
|
|
76
|
+
Close the connection gracefully.
|
|
77
|
+
"""
|
|
78
|
+
if self.conn:
|
|
79
|
+
try:
|
|
80
|
+
return await self.conn.close(*args, **kwargs)
|
|
81
|
+
except Exception as e:
|
|
82
|
+
raise RuntimeError(
|
|
83
|
+
f"Failed to disconnect from picodata instance {self.dsn}: {e}"
|
|
84
|
+
) from e
|
picopyn/pool.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import random
|
|
4
|
+
import time
|
|
5
|
+
from collections import deque
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from typing import Any
|
|
8
|
+
from urllib.parse import urlparse
|
|
9
|
+
|
|
10
|
+
import asyncpg
|
|
11
|
+
|
|
12
|
+
from .connection import Connection
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Pool:
|
|
16
|
+
"""A connection pool.
|
|
17
|
+
|
|
18
|
+
Connection pool can be used to manage a set of connections to the database.
|
|
19
|
+
Connections are first acquired from the pool, then used, and then released
|
|
20
|
+
back to the pool
|
|
21
|
+
|
|
22
|
+
:param dsn (str): The data source name (e.g., "postgresql://user:pass@host:port") for the cluster.
|
|
23
|
+
:param balance_strategy (callable, optional): A custom strategy function to select a connection
|
|
24
|
+
from the pool. If None, round-robin strategy is used.
|
|
25
|
+
:param max_size (int): Maximum number of connections in the pool. Must be at least 1.
|
|
26
|
+
:param enable_discovery (bool): If True, the pool will automatically discover available
|
|
27
|
+
picodata instances. If False, only the given `dsn` will be used.
|
|
28
|
+
:param balance_strategy (callable, optional): A function that selects a connection from the pool.
|
|
29
|
+
If None, a default round-robin strategy will be used.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
dsn: str,
|
|
35
|
+
max_size: int = 10,
|
|
36
|
+
enable_discovery: bool = False,
|
|
37
|
+
balance_strategy: Callable[[list[Connection]], Connection] | None = None,
|
|
38
|
+
**connect_kwargs: Any,
|
|
39
|
+
) -> None:
|
|
40
|
+
if max_size < 1:
|
|
41
|
+
raise ValueError("max_size must be at least 1")
|
|
42
|
+
|
|
43
|
+
self._dsn = dsn
|
|
44
|
+
self._connect_kwargs = connect_kwargs
|
|
45
|
+
self._max_size = max_size
|
|
46
|
+
self._pool: deque[Connection] = deque()
|
|
47
|
+
self._used: set[Connection] = set()
|
|
48
|
+
self._lock: asyncio.Lock = asyncio.Lock()
|
|
49
|
+
self._default_acquire_timeout_sec = 5
|
|
50
|
+
# node discovery mode
|
|
51
|
+
# if disabled, pool will be filled with given address connections
|
|
52
|
+
# if enabled, pool will be filled with available picodata instances
|
|
53
|
+
self.enable_discovery = enable_discovery
|
|
54
|
+
# load balancing strategy:
|
|
55
|
+
# if None, a simple round-robin strategy will be used.
|
|
56
|
+
# otherwise, the provided callable will be used to select connections.
|
|
57
|
+
if balance_strategy is not None and not callable(balance_strategy):
|
|
58
|
+
raise ValueError("balance_strategy must be callable or None")
|
|
59
|
+
self._balance_strategy = balance_strategy
|
|
60
|
+
|
|
61
|
+
async def connect(self) -> None:
|
|
62
|
+
"""
|
|
63
|
+
Prepares the pool by opening up to `max_size` connections.
|
|
64
|
+
|
|
65
|
+
This should be called before using the pool to ensure connections are available.
|
|
66
|
+
"""
|
|
67
|
+
async with self._lock:
|
|
68
|
+
if len(self._pool) == self._max_size:
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
# if node discovery is enabled, then connect to all alive picodata instances
|
|
72
|
+
# (if they fit within the max_size limit)
|
|
73
|
+
if self.enable_discovery:
|
|
74
|
+
try:
|
|
75
|
+
instance_addrs = await self._discover_instances()
|
|
76
|
+
except Exception as e:
|
|
77
|
+
raise RuntimeError(
|
|
78
|
+
f"Failed to discover instances using DSN {self._dsn}: {e}"
|
|
79
|
+
) from e
|
|
80
|
+
|
|
81
|
+
parsed_url = urlparse(self._dsn)
|
|
82
|
+
|
|
83
|
+
addr_index = 0
|
|
84
|
+
# fill the connection pool with connections to all available nodes, up to the max_size.
|
|
85
|
+
# this ensures the pool is evenly populated across all nodes.
|
|
86
|
+
# if a node fails to connect, it will be skipped and removed from the list.
|
|
87
|
+
# the loop will exit early if no nodes remain to avoid an infinite loop.
|
|
88
|
+
while len(self._pool) < self._max_size and instance_addrs:
|
|
89
|
+
address = instance_addrs[addr_index % len(instance_addrs)]
|
|
90
|
+
dsn = f"{parsed_url.scheme}://{parsed_url.username}:{parsed_url.password}@{address}"
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
conn = Connection(dsn, **self._connect_kwargs)
|
|
94
|
+
await conn.connect()
|
|
95
|
+
self._pool.append(conn)
|
|
96
|
+
except Exception as e:
|
|
97
|
+
print(f"Could not connect to node {address} for pool: {e}")
|
|
98
|
+
instance_addrs.remove(address)
|
|
99
|
+
if not instance_addrs:
|
|
100
|
+
break
|
|
101
|
+
continue
|
|
102
|
+
|
|
103
|
+
addr_index += 1
|
|
104
|
+
|
|
105
|
+
# then fill the connection pool up to max_size with main mode connections
|
|
106
|
+
while len(self._pool) < self._max_size:
|
|
107
|
+
main_node_conn = Connection(self._dsn, **self._connect_kwargs)
|
|
108
|
+
try:
|
|
109
|
+
await main_node_conn.connect()
|
|
110
|
+
self._pool.append(main_node_conn)
|
|
111
|
+
except Exception as e:
|
|
112
|
+
raise RuntimeError(
|
|
113
|
+
f"Could not connect to main node {self._dsn} for pool: {e}"
|
|
114
|
+
) from e
|
|
115
|
+
|
|
116
|
+
# rotate the pool to randomize the order of connections.
|
|
117
|
+
# this helps to distribute the initial load more evenly across nodes
|
|
118
|
+
# when using round-robin or when multiple clients start simultaneously.
|
|
119
|
+
shift = random.randint(0, len(self._pool) - 1)
|
|
120
|
+
self._pool.rotate(shift)
|
|
121
|
+
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
async def _discover_instances(self) -> list[str]:
|
|
125
|
+
# make temporary connection
|
|
126
|
+
temp_conn = Connection(self._dsn, **self._connect_kwargs)
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
await temp_conn.connect()
|
|
130
|
+
|
|
131
|
+
# all instance addresses excluding connected node
|
|
132
|
+
alive_instances_info = await temp_conn.fetch(
|
|
133
|
+
"""
|
|
134
|
+
WITH my_uuid AS (SELECT instance_uuid() AS uuid)
|
|
135
|
+
SELECT i.name, i.raft_id, i.current_state, p.address
|
|
136
|
+
FROM _pico_instance i
|
|
137
|
+
JOIN _pico_peer_address p ON i.raft_id = p.raft_id
|
|
138
|
+
JOIN my_uuid u ON 1 = 1
|
|
139
|
+
WHERE p.connection_type = 'pgproto' AND i.uuid != u.uuid;
|
|
140
|
+
"""
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
online_addresses = []
|
|
144
|
+
# place connected node as first node to be sure that
|
|
145
|
+
# it will be in the pool independ on pool size
|
|
146
|
+
parsed_url = urlparse(self._dsn)
|
|
147
|
+
online_addresses.append(f"{parsed_url.hostname}:{parsed_url.port}")
|
|
148
|
+
for r in alive_instances_info:
|
|
149
|
+
if not r.get("current_state", None):
|
|
150
|
+
continue
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
current_state = json.loads(r.get("current_state", None))
|
|
154
|
+
except json.JSONDecodeError:
|
|
155
|
+
print(
|
|
156
|
+
f"Failed to decode current state of picodata instance {r.get('current_state', None)}"
|
|
157
|
+
)
|
|
158
|
+
continue
|
|
159
|
+
|
|
160
|
+
if "Online" in current_state:
|
|
161
|
+
online_addresses.append(r["address"])
|
|
162
|
+
|
|
163
|
+
return online_addresses
|
|
164
|
+
finally:
|
|
165
|
+
await temp_conn.close()
|
|
166
|
+
|
|
167
|
+
async def acquire(self, timeout: float | None = None) -> Connection:
|
|
168
|
+
"""
|
|
169
|
+
Acquire a connection from the pool.
|
|
170
|
+
|
|
171
|
+
If no connections are available, this method will wait until one is released.
|
|
172
|
+
|
|
173
|
+
:return: A database connection.
|
|
174
|
+
"""
|
|
175
|
+
start_time = time.monotonic()
|
|
176
|
+
effective_timeout = timeout if timeout is not None else self._default_acquire_timeout_sec
|
|
177
|
+
|
|
178
|
+
while True:
|
|
179
|
+
async with self._lock:
|
|
180
|
+
# сheck if there are any available connections in the pool
|
|
181
|
+
if self._pool:
|
|
182
|
+
# round-robin strategy
|
|
183
|
+
if self._balance_strategy is None:
|
|
184
|
+
conn = self._pool.popleft()
|
|
185
|
+
# custom strategy
|
|
186
|
+
else:
|
|
187
|
+
try:
|
|
188
|
+
conn = self._balance_strategy(list(self._pool))
|
|
189
|
+
except Exception as e:
|
|
190
|
+
raise RuntimeError(f"balance_strategy raised an exception: {e}") from e
|
|
191
|
+
|
|
192
|
+
if conn not in self._pool:
|
|
193
|
+
raise RuntimeError("balance_strategy returned a connection not in pool")
|
|
194
|
+
self._pool.remove(conn)
|
|
195
|
+
|
|
196
|
+
# mark it as currently in use
|
|
197
|
+
self._used.add(conn)
|
|
198
|
+
return conn
|
|
199
|
+
|
|
200
|
+
if (time.monotonic() - start_time) >= effective_timeout:
|
|
201
|
+
raise TimeoutError("Timed out waiting for a free connection in the pool")
|
|
202
|
+
|
|
203
|
+
# if no connections are available, wait briefly before retrying
|
|
204
|
+
# this gives other coroutines (like `release`) a chance to return a connection to the pool
|
|
205
|
+
await asyncio.sleep(0.1)
|
|
206
|
+
|
|
207
|
+
async def release(self, conn: Connection) -> None:
|
|
208
|
+
"""
|
|
209
|
+
Release a previously acquired connection back to the pool.
|
|
210
|
+
|
|
211
|
+
:param conn: The connection to release.
|
|
212
|
+
"""
|
|
213
|
+
async with self._lock:
|
|
214
|
+
if conn in self._used:
|
|
215
|
+
self._used.remove(conn)
|
|
216
|
+
self._pool.append(conn)
|
|
217
|
+
|
|
218
|
+
async def close(self) -> None:
|
|
219
|
+
"""
|
|
220
|
+
Closes all connections in the pool.
|
|
221
|
+
|
|
222
|
+
This should be called during application shutdown to clean up resources.
|
|
223
|
+
"""
|
|
224
|
+
async with self._lock:
|
|
225
|
+
while self._pool:
|
|
226
|
+
conn = self._pool.popleft()
|
|
227
|
+
await conn.close()
|
|
228
|
+
for conn in self._used:
|
|
229
|
+
await conn.close()
|
|
230
|
+
self._used.clear()
|
|
231
|
+
|
|
232
|
+
async def execute(self, query: str, *args: Any) -> str:
|
|
233
|
+
"""
|
|
234
|
+
Executes a query that does not return rows (e.g. INSERT, UPDATE, DELETE).
|
|
235
|
+
|
|
236
|
+
:param query: The SQL query string.
|
|
237
|
+
:param args: Optional parameters for the SQL query.
|
|
238
|
+
:return: The result of the query execution.
|
|
239
|
+
"""
|
|
240
|
+
conn = await self.acquire()
|
|
241
|
+
try:
|
|
242
|
+
return await conn.execute(query, *args)
|
|
243
|
+
finally:
|
|
244
|
+
await self.release(conn)
|
|
245
|
+
|
|
246
|
+
async def fetch(self, query: str, *args: Any) -> list[asyncpg.Record]:
|
|
247
|
+
"""
|
|
248
|
+
Executes a query and fetches all resulting rows.
|
|
249
|
+
|
|
250
|
+
:param query: The SQL query string.
|
|
251
|
+
:param args: Optional parameters for the SQL query.
|
|
252
|
+
:return: A list of rows returned by the query.
|
|
253
|
+
"""
|
|
254
|
+
conn = await self.acquire()
|
|
255
|
+
try:
|
|
256
|
+
return await conn.fetch(query, *args)
|
|
257
|
+
finally:
|
|
258
|
+
await self.release(conn)
|
|
259
|
+
|
|
260
|
+
async def fetchrow(self, query: str, *args: Any) -> asyncpg.Record | None:
|
|
261
|
+
"""
|
|
262
|
+
Executes a query and fetches a single row (first row).
|
|
263
|
+
|
|
264
|
+
:param query: The SQL query string.
|
|
265
|
+
:param args: Optional parameters for the SQL query.
|
|
266
|
+
:return: A single row returned by the query.
|
|
267
|
+
"""
|
|
268
|
+
conn = await self.acquire()
|
|
269
|
+
try:
|
|
270
|
+
return await conn.fetchrow(query, *args)
|
|
271
|
+
finally:
|
|
272
|
+
await self.release(conn)
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
picopyn/__init__.py,sha256=iS9NJS9kKcd6_s6s3lg7rOosRIxbMAc4EppPg5d7eK0,144
|
|
2
|
+
picopyn/client.py,sha256=ca7qhr2EHf63fiBEWoiTdqoQao4s0rwmXXjTAmq2BXQ,3070
|
|
3
|
+
picopyn/connection.py,sha256=Z-yqrCir2AKe4IOU7L4pkXeveJHtLnyRiIKFbTI43XA,2576
|
|
4
|
+
picopyn/pool.py,sha256=A2tdmtqYkAcM4HSkmPw7a3YQ4iCG4XfOH_ws47MMwmY,10882
|
|
5
|
+
picopyn-0.1.dist-info/METADATA,sha256=XvAyt9khfnh7B29JGAg59rG9tTrjxMiEuENjkUChBj0,49
|
|
6
|
+
picopyn-0.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
7
|
+
picopyn-0.1.dist-info/top_level.txt,sha256=MAN9e0p63X638jEg5MflC2jOKv2hVMhZ0UuKvE8xjF8,8
|
|
8
|
+
picopyn-0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
picopyn
|