lfss 0.5.0__tar.gz → 0.5.1__tar.gz
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.
- {lfss-0.5.0 → lfss-0.5.1}/PKG-INFO +1 -1
- {lfss-0.5.0 → lfss-0.5.1}/frontend/api.js +2 -2
- {lfss-0.5.0 → lfss-0.5.1}/frontend/scripts.js +1 -1
- lfss-0.5.1/lfss/cli/balance.py +111 -0
- {lfss-0.5.0 → lfss-0.5.1}/lfss/cli/cli.py +4 -4
- {lfss-0.5.0 → lfss-0.5.1}/lfss/client/api.py +2 -2
- lfss-0.5.1/lfss/sql/init.sql +38 -0
- lfss-0.5.1/lfss/sql/pragma.sql +5 -0
- {lfss-0.5.0 → lfss-0.5.1}/lfss/src/config.py +2 -1
- {lfss-0.5.0 → lfss-0.5.1}/lfss/src/database.py +12 -52
- {lfss-0.5.0 → lfss-0.5.1}/lfss/src/server.py +10 -5
- {lfss-0.5.0 → lfss-0.5.1}/pyproject.toml +3 -2
- {lfss-0.5.0 → lfss-0.5.1}/Readme.md +0 -0
- {lfss-0.5.0 → lfss-0.5.1}/docs/Known_issues.md +0 -0
- {lfss-0.5.0 → lfss-0.5.1}/docs/Permission.md +0 -0
- {lfss-0.5.0 → lfss-0.5.1}/frontend/index.html +0 -0
- {lfss-0.5.0 → lfss-0.5.1}/frontend/popup.css +0 -0
- {lfss-0.5.0 → lfss-0.5.1}/frontend/popup.js +0 -0
- {lfss-0.5.0 → lfss-0.5.1}/frontend/styles.css +0 -0
- {lfss-0.5.0 → lfss-0.5.1}/frontend/utils.js +0 -0
- {lfss-0.5.0 → lfss-0.5.1}/lfss/cli/panel.py +0 -0
- {lfss-0.5.0 → lfss-0.5.1}/lfss/cli/serve.py +0 -0
- {lfss-0.5.0 → lfss-0.5.1}/lfss/cli/user.py +0 -0
- {lfss-0.5.0 → lfss-0.5.1}/lfss/client/__init__.py +0 -0
- {lfss-0.5.0 → lfss-0.5.1}/lfss/src/__init__.py +0 -0
- {lfss-0.5.0 → lfss-0.5.1}/lfss/src/error.py +0 -0
- {lfss-0.5.0 → lfss-0.5.1}/lfss/src/log.py +0 -0
- {lfss-0.5.0 → lfss-0.5.1}/lfss/src/stat.py +0 -0
- {lfss-0.5.0 → lfss-0.5.1}/lfss/src/utils.py +0 -0
@@ -57,13 +57,13 @@ export default class Connector {
|
|
57
57
|
* @returns {Promise<string>} - the promise of the request, the url of the file
|
58
58
|
*/
|
59
59
|
async put(path, file, {
|
60
|
-
|
60
|
+
conflict = 'abort',
|
61
61
|
permission = 0
|
62
62
|
} = {}){
|
63
63
|
if (path.startsWith('/')){ path = path.slice(1); }
|
64
64
|
const fileBytes = await file.arrayBuffer();
|
65
65
|
const dst = new URL(this.config.endpoint + '/' + path);
|
66
|
-
dst.searchParams.append('
|
66
|
+
dst.searchParams.append('conflict', conflict);
|
67
67
|
dst.searchParams.append('permission', permission);
|
68
68
|
const res = await fetch(dst.toString(), {
|
69
69
|
method: 'PUT',
|
@@ -176,7 +176,7 @@ Are you sure you want to proceed?
|
|
176
176
|
async function uploadFile(...args){
|
177
177
|
const [file, path] = args;
|
178
178
|
try{
|
179
|
-
await conn.put(path, file, {
|
179
|
+
await conn.put(path, file, {conflict: 'overwrite'});
|
180
180
|
}
|
181
181
|
catch (err){
|
182
182
|
showPopup('Failed to upload file [' + file.name + ']: ' + err, {level: 'error', timeout: 5000});
|
@@ -0,0 +1,111 @@
|
|
1
|
+
"""
|
2
|
+
Balance the storage by ensuring that large file thresholds are met.
|
3
|
+
"""
|
4
|
+
|
5
|
+
from lfss.src.config import DATA_HOME, LARGE_BLOB_DIR, LARGE_FILE_BYTES
|
6
|
+
import argparse, time
|
7
|
+
from functools import wraps
|
8
|
+
from asyncio import Semaphore
|
9
|
+
import aiosqlite, aiofiles, asyncio
|
10
|
+
|
11
|
+
sem = Semaphore(1)
|
12
|
+
db_file = DATA_HOME / 'lfss.db'
|
13
|
+
|
14
|
+
def _get_sem():
|
15
|
+
return sem
|
16
|
+
|
17
|
+
def barriered(func):
|
18
|
+
@wraps(func)
|
19
|
+
async def wrapper(*args, **kwargs):
|
20
|
+
async with _get_sem():
|
21
|
+
return await func(*args, **kwargs)
|
22
|
+
return wrapper
|
23
|
+
|
24
|
+
@barriered
|
25
|
+
async def move_to_external(f_id: str, flag: str = ''):
|
26
|
+
async with aiosqlite.connect(db_file, timeout = 60) as c:
|
27
|
+
async with c.execute( "SELECT data FROM fdata WHERE file_id = ?", (f_id,)) as cursor:
|
28
|
+
blob_row = await cursor.fetchone()
|
29
|
+
if blob_row is None:
|
30
|
+
print(f"{flag}File {f_id} not found in fdata")
|
31
|
+
return
|
32
|
+
await c.execute("BEGIN")
|
33
|
+
blob: bytes = blob_row[0]
|
34
|
+
try:
|
35
|
+
async with aiofiles.open(LARGE_BLOB_DIR / f_id, 'wb') as f:
|
36
|
+
await f.write(blob)
|
37
|
+
await c.execute( "UPDATE fmeta SET external = 1 WHERE file_id = ?", (f_id,))
|
38
|
+
await c.execute( "DELETE FROM fdata WHERE file_id = ?", (f_id,))
|
39
|
+
await c.commit()
|
40
|
+
print(f"{flag}Moved {f_id} to external storage")
|
41
|
+
except Exception as e:
|
42
|
+
await c.rollback()
|
43
|
+
print(f"{flag}Error moving {f_id}: {e}")
|
44
|
+
|
45
|
+
if isinstance(e, KeyboardInterrupt):
|
46
|
+
raise e
|
47
|
+
|
48
|
+
@barriered
|
49
|
+
async def move_to_internal(f_id: str, flag: str = ''):
|
50
|
+
async with aiosqlite.connect(db_file, timeout = 60) as c:
|
51
|
+
if not (LARGE_BLOB_DIR / f_id).exists():
|
52
|
+
print(f"{flag}File {f_id} not found in external storage")
|
53
|
+
return
|
54
|
+
async with aiofiles.open(LARGE_BLOB_DIR / f_id, 'rb') as f:
|
55
|
+
blob = await f.read()
|
56
|
+
|
57
|
+
await c.execute("BEGIN")
|
58
|
+
try:
|
59
|
+
await c.execute("INSERT INTO fdata (file_id, data) VALUES (?, ?)", (f_id, blob))
|
60
|
+
await c.execute("UPDATE fmeta SET external = 0 WHERE file_id = ?", (f_id,))
|
61
|
+
await c.commit()
|
62
|
+
(LARGE_BLOB_DIR / f_id).unlink(missing_ok=True)
|
63
|
+
print(f"{flag}Moved {f_id} to internal storage")
|
64
|
+
except Exception as e:
|
65
|
+
await c.rollback()
|
66
|
+
print(f"{flag}Error moving {f_id}: {e}")
|
67
|
+
if isinstance(e, KeyboardInterrupt):
|
68
|
+
raise e
|
69
|
+
|
70
|
+
|
71
|
+
async def _main():
|
72
|
+
|
73
|
+
tasks = []
|
74
|
+
start_time = time.time()
|
75
|
+
async with aiosqlite.connect(db_file) as conn:
|
76
|
+
exceeded_rows = await (await conn.execute(
|
77
|
+
"SELECT file_id FROM fmeta WHERE file_size > ? AND external = 0",
|
78
|
+
(LARGE_FILE_BYTES,)
|
79
|
+
)).fetchall()
|
80
|
+
|
81
|
+
for i in range(0, len(exceeded_rows)):
|
82
|
+
row = exceeded_rows[i]
|
83
|
+
f_id = row[0]
|
84
|
+
tasks.append(move_to_external(f_id, flag=f"[e-{i+1}/{len(exceeded_rows)}] "))
|
85
|
+
|
86
|
+
async with aiosqlite.connect(db_file) as conn:
|
87
|
+
under_rows = await (await conn.execute(
|
88
|
+
"SELECT file_id, file_size, external FROM fmeta WHERE file_size <= ? AND external = 1",
|
89
|
+
(LARGE_FILE_BYTES,)
|
90
|
+
)).fetchall()
|
91
|
+
|
92
|
+
for i in range(0, len(under_rows)):
|
93
|
+
row = under_rows[i]
|
94
|
+
f_id = row[0]
|
95
|
+
tasks.append(move_to_internal(f_id, flag=f"[i-{i+1}/{len(under_rows)}] "))
|
96
|
+
|
97
|
+
await asyncio.gather(*tasks)
|
98
|
+
end_time = time.time()
|
99
|
+
print(f"Balancing complete, took {end_time - start_time:.2f} seconds. "
|
100
|
+
f"{len(exceeded_rows)} files moved to external storage, {len(under_rows)} files moved to internal storage.")
|
101
|
+
|
102
|
+
def main():
|
103
|
+
global sem
|
104
|
+
parser = argparse.ArgumentParser(description="Balance the storage by ensuring that large file thresholds are met.")
|
105
|
+
parser.add_argument("-j", "--jobs", type=int, default=2, help="Number of concurrent jobs")
|
106
|
+
args = parser.parse_args()
|
107
|
+
sem = Semaphore(args.jobs)
|
108
|
+
asyncio.run(_main())
|
109
|
+
|
110
|
+
if __name__ == '__main__':
|
111
|
+
main()
|
@@ -13,8 +13,8 @@ def parse_arguments():
|
|
13
13
|
sp_upload.add_argument("src", help="Source file or directory", type=str)
|
14
14
|
sp_upload.add_argument("dst", help="Destination path", type=str)
|
15
15
|
sp_upload.add_argument("-j", "--jobs", type=int, default=1, help="Number of concurrent uploads")
|
16
|
-
sp_upload.add_argument("--interval", type=float, default=0, help="Interval between
|
17
|
-
sp_upload.add_argument("--overwrite",
|
16
|
+
sp_upload.add_argument("--interval", type=float, default=0, help="Interval between files, only works with directory upload")
|
17
|
+
sp_upload.add_argument("--conflict", choices=["overwrite", "abort", "skip"], default="abort", help="Conflict resolution")
|
18
18
|
sp_upload.add_argument("--permission", type=FileReadPermission, default=FileReadPermission.UNSET, help="File permission")
|
19
19
|
sp_upload.add_argument("--retries", type=int, default=0, help="Number of retries, only works with directory upload")
|
20
20
|
|
@@ -32,7 +32,7 @@ def main():
|
|
32
32
|
n_concurrent=args.jobs,
|
33
33
|
n_reties=args.retries,
|
34
34
|
interval=args.interval,
|
35
|
-
|
35
|
+
conflict=args.conflict,
|
36
36
|
permission=args.permission
|
37
37
|
)
|
38
38
|
if failed_upload:
|
@@ -44,7 +44,7 @@ def main():
|
|
44
44
|
connector.put(
|
45
45
|
args.dst,
|
46
46
|
f.read(),
|
47
|
-
|
47
|
+
conflict=args.conflict,
|
48
48
|
permission=args.permission
|
49
49
|
)
|
50
50
|
else:
|
@@ -34,13 +34,13 @@ class Connector:
|
|
34
34
|
return response
|
35
35
|
return f
|
36
36
|
|
37
|
-
def put(self, path: str, file_data: bytes, permission: int | FileReadPermission = 0,
|
37
|
+
def put(self, path: str, file_data: bytes, permission: int | FileReadPermission = 0, conflict: Literal['overwrite', 'abort', 'skip'] = 'abort'):
|
38
38
|
"""Uploads a file to the specified path."""
|
39
39
|
if path.startswith('/'):
|
40
40
|
path = path[1:]
|
41
41
|
response = self._fetch('PUT', path, search_params={
|
42
42
|
'permission': int(permission),
|
43
|
-
'
|
43
|
+
'conflict': conflict
|
44
44
|
})(
|
45
45
|
data=file_data,
|
46
46
|
headers={'Content-Type': 'application/octet-stream'}
|
@@ -0,0 +1,38 @@
|
|
1
|
+
CREATE TABLE IF NOT EXISTS user (
|
2
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
3
|
+
username VARCHAR(255) UNIQUE NOT NULL,
|
4
|
+
credential VARCHAR(255) NOT NULL,
|
5
|
+
is_admin BOOLEAN DEFAULT FALSE,
|
6
|
+
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
7
|
+
last_active TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
8
|
+
max_storage INTEGER DEFAULT 1073741824,
|
9
|
+
permission INTEGER DEFAULT 0
|
10
|
+
);
|
11
|
+
|
12
|
+
CREATE TABLE IF NOT EXISTS fmeta (
|
13
|
+
url VARCHAR(512) PRIMARY KEY,
|
14
|
+
owner_id INTEGER NOT NULL,
|
15
|
+
file_id VARCHAR(256) NOT NULL,
|
16
|
+
file_size INTEGER,
|
17
|
+
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
18
|
+
access_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
19
|
+
permission INTEGER DEFAULT 0,
|
20
|
+
external BOOLEAN DEFAULT FALSE,
|
21
|
+
FOREIGN KEY(owner_id) REFERENCES user(id)
|
22
|
+
);
|
23
|
+
|
24
|
+
CREATE TABLE IF NOT EXISTS fdata (
|
25
|
+
file_id VARCHAR(256) PRIMARY KEY,
|
26
|
+
data BLOB
|
27
|
+
);
|
28
|
+
|
29
|
+
CREATE TABLE IF NOT EXISTS usize (
|
30
|
+
user_id INTEGER PRIMARY KEY,
|
31
|
+
size INTEGER DEFAULT 0
|
32
|
+
);
|
33
|
+
|
34
|
+
CREATE INDEX IF NOT EXISTS idx_fmeta_url ON fmeta(url);
|
35
|
+
|
36
|
+
CREATE INDEX IF NOT EXISTS idx_user_username ON user(username);
|
37
|
+
|
38
|
+
CREATE INDEX IF NOT EXISTS idx_user_credential ON user(credential);
|
@@ -10,6 +10,7 @@ if not DATA_HOME.exists():
|
|
10
10
|
LARGE_BLOB_DIR = DATA_HOME / 'large_blobs'
|
11
11
|
LARGE_BLOB_DIR.mkdir(exist_ok=True)
|
12
12
|
|
13
|
-
|
13
|
+
# https://sqlite.org/fasterthanfs.html
|
14
|
+
LARGE_FILE_BYTES = 8 * 1024 * 1024 # 8MB
|
14
15
|
MAX_FILE_BYTES = 1024 * 1024 * 1024 # 1GB
|
15
16
|
MAX_BUNDLE_BYTES = 1024 * 1024 * 1024 # 1GB
|
@@ -3,6 +3,7 @@ from typing import Optional, overload, Literal, AsyncIterable
|
|
3
3
|
from abc import ABC, abstractmethod
|
4
4
|
|
5
5
|
import urllib.parse
|
6
|
+
from pathlib import Path
|
6
7
|
import dataclasses, hashlib, uuid
|
7
8
|
from contextlib import asynccontextmanager
|
8
9
|
from functools import wraps
|
@@ -23,6 +24,15 @@ _g_conn: Optional[aiosqlite.Connection] = None
|
|
23
24
|
def hash_credential(username, password):
|
24
25
|
return hashlib.sha256((username + password).encode()).hexdigest()
|
25
26
|
|
27
|
+
async def execute_sql(conn: aiosqlite.Connection, name: str):
|
28
|
+
this_dir = Path(__file__).parent
|
29
|
+
sql_dir = this_dir.parent / 'sql'
|
30
|
+
async with aiofiles.open(sql_dir / name, 'r') as f:
|
31
|
+
sql = await f.read()
|
32
|
+
sql = sql.split(';')
|
33
|
+
for s in sql:
|
34
|
+
await conn.execute(s)
|
35
|
+
|
26
36
|
_atomic_lock = Lock()
|
27
37
|
def atomic(func):
|
28
38
|
""" Ensure non-reentrancy """
|
@@ -48,7 +58,8 @@ class DBConnBase(ABC):
|
|
48
58
|
global _g_conn
|
49
59
|
if _g_conn is None:
|
50
60
|
_g_conn = await aiosqlite.connect(DATA_HOME / 'lfss.db')
|
51
|
-
await _g_conn.
|
61
|
+
await execute_sql(_g_conn, 'pragma.sql')
|
62
|
+
await execute_sql(_g_conn, 'init.sql')
|
52
63
|
|
53
64
|
async def commit(self):
|
54
65
|
await self.conn.commit()
|
@@ -82,26 +93,6 @@ class UserConn(DBConnBase):
|
|
82
93
|
|
83
94
|
async def init(self):
|
84
95
|
await super().init()
|
85
|
-
# default to 1GB (1024x1024x1024 bytes)
|
86
|
-
await self.conn.execute('''
|
87
|
-
CREATE TABLE IF NOT EXISTS user (
|
88
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
89
|
-
username VARCHAR(255) UNIQUE NOT NULL,
|
90
|
-
credential VARCHAR(255) NOT NULL,
|
91
|
-
is_admin BOOLEAN DEFAULT FALSE,
|
92
|
-
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
93
|
-
last_active TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
94
|
-
max_storage INTEGER DEFAULT 1073741824,
|
95
|
-
permission INTEGER DEFAULT 0
|
96
|
-
)
|
97
|
-
''')
|
98
|
-
await self.conn.execute('''
|
99
|
-
CREATE INDEX IF NOT EXISTS idx_user_username ON user(username)
|
100
|
-
''')
|
101
|
-
await self.conn.execute('''
|
102
|
-
CREATE INDEX IF NOT EXISTS idx_user_credential ON user(credential)
|
103
|
-
''')
|
104
|
-
|
105
96
|
return self
|
106
97
|
|
107
98
|
async def get_user(self, username: str) -> Optional[UserRecord]:
|
@@ -222,37 +213,6 @@ class FileConn(DBConnBase):
|
|
222
213
|
|
223
214
|
async def init(self):
|
224
215
|
await super().init()
|
225
|
-
await self.conn.execute('''
|
226
|
-
CREATE TABLE IF NOT EXISTS fmeta (
|
227
|
-
url VARCHAR(512) PRIMARY KEY,
|
228
|
-
owner_id INTEGER NOT NULL,
|
229
|
-
file_id VARCHAR(256) NOT NULL,
|
230
|
-
file_size INTEGER,
|
231
|
-
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
232
|
-
access_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
233
|
-
permission INTEGER DEFAULT 0,
|
234
|
-
external BOOLEAN DEFAULT FALSE,
|
235
|
-
FOREIGN KEY(owner_id) REFERENCES user(id)
|
236
|
-
)
|
237
|
-
''')
|
238
|
-
await self.conn.execute('''
|
239
|
-
CREATE INDEX IF NOT EXISTS idx_fmeta_url ON fmeta(url)
|
240
|
-
''')
|
241
|
-
|
242
|
-
await self.conn.execute('''
|
243
|
-
CREATE TABLE IF NOT EXISTS fdata (
|
244
|
-
file_id VARCHAR(256) PRIMARY KEY,
|
245
|
-
data BLOB
|
246
|
-
)
|
247
|
-
''')
|
248
|
-
|
249
|
-
# user file size table
|
250
|
-
await self.conn.execute('''
|
251
|
-
CREATE TABLE IF NOT EXISTS usize (
|
252
|
-
user_id INTEGER PRIMARY KEY,
|
253
|
-
size INTEGER DEFAULT 0
|
254
|
-
)
|
255
|
-
''')
|
256
216
|
# backward compatibility, since 0.2.1
|
257
217
|
async with self.conn.execute("SELECT * FROM user") as cursor:
|
258
218
|
res = await cursor.fetchall()
|
@@ -1,4 +1,4 @@
|
|
1
|
-
from typing import Optional
|
1
|
+
from typing import Optional, Literal
|
2
2
|
from functools import wraps
|
3
3
|
|
4
4
|
from fastapi import FastAPI, APIRouter, Depends, Request, Response
|
@@ -179,7 +179,7 @@ async def get_file(path: str, download = False, user: UserRecord = Depends(get_c
|
|
179
179
|
async def put_file(
|
180
180
|
request: Request,
|
181
181
|
path: str,
|
182
|
-
|
182
|
+
conflict: Literal["overwrite", "skip", "abort"] = "abort",
|
183
183
|
permission: int = 0,
|
184
184
|
user: UserRecord = Depends(get_current_user)):
|
185
185
|
path = ensure_uri_compnents(path)
|
@@ -201,8 +201,12 @@ async def put_file(
|
|
201
201
|
exists_flag = False
|
202
202
|
file_record = await conn.file.get_file_record(path)
|
203
203
|
if file_record:
|
204
|
-
if
|
204
|
+
if conflict == "abort":
|
205
205
|
raise HTTPException(status_code=409, detail="File exists")
|
206
|
+
if conflict == "skip":
|
207
|
+
return Response(status_code=200, headers={
|
208
|
+
"Content-Type": "application/json",
|
209
|
+
}, content=json.dumps({"url": path}))
|
206
210
|
# remove the old file
|
207
211
|
exists_flag = True
|
208
212
|
await conn.delete_file(path)
|
@@ -226,8 +230,9 @@ async def put_file(
|
|
226
230
|
blobs = await request.body()
|
227
231
|
if len(blobs) > LARGE_FILE_BYTES:
|
228
232
|
async def blob_reader():
|
229
|
-
|
230
|
-
|
233
|
+
chunk_size = 16 * 1024 * 1024 # 16MB
|
234
|
+
for b in range(0, len(blobs), chunk_size):
|
235
|
+
yield blobs[b:b+chunk_size]
|
231
236
|
await conn.save_file(user.id, path, blob_reader(), permission = FileReadPermission(permission))
|
232
237
|
else:
|
233
238
|
await conn.save_file(user.id, path, blobs, permission = FileReadPermission(permission))
|
@@ -1,12 +1,12 @@
|
|
1
1
|
[tool.poetry]
|
2
2
|
name = "lfss"
|
3
|
-
version = "0.5.
|
3
|
+
version = "0.5.1"
|
4
4
|
description = "Lightweight file storage service"
|
5
5
|
authors = ["li, mengxun <limengxun45@outlook.com>"]
|
6
6
|
readme = "Readme.md"
|
7
7
|
homepage = "https://github.com/MenxLi/lfss"
|
8
8
|
repository = "https://github.com/MenxLi/lfss"
|
9
|
-
include = ["Readme.md", "docs/*", "frontend/*"]
|
9
|
+
include = ["Readme.md", "docs/*", "frontend/*", "lfss/sql/*"]
|
10
10
|
|
11
11
|
[tool.poetry.dependencies]
|
12
12
|
python = ">=3.9"
|
@@ -20,6 +20,7 @@ lfss-serve = "lfss.cli.serve:main"
|
|
20
20
|
lfss-user = "lfss.cli.user:main"
|
21
21
|
lfss-panel = "lfss.cli.panel:main"
|
22
22
|
lfss-cli = "lfss.cli.cli:main"
|
23
|
+
lfss-balance = "lfss.cli.balance:main"
|
23
24
|
|
24
25
|
[build-system]
|
25
26
|
requires = ["poetry-core>=1.0.0"]
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|