lfss 0.7.15__py3-none-any.whl → 0.8.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.
- Readme.md +2 -2
- docs/Permission.md +4 -2
- frontend/api.js +271 -8
- frontend/index.html +40 -28
- frontend/login.css +21 -0
- frontend/login.js +83 -0
- frontend/scripts.js +77 -88
- frontend/state.js +19 -4
- frontend/styles.css +26 -8
- frontend/thumb.css +6 -0
- frontend/thumb.js +6 -2
- lfss/{client → api}/__init__.py +72 -41
- lfss/api/connector.py +261 -0
- lfss/cli/cli.py +1 -1
- lfss/cli/user.py +1 -1
- lfss/src/config.py +1 -1
- lfss/src/connection_pool.py +3 -2
- lfss/src/database.py +193 -100
- lfss/src/datatype.py +8 -3
- lfss/src/error.py +3 -1
- lfss/src/server.py +147 -61
- lfss/src/stat.py +1 -1
- lfss/src/utils.py +47 -13
- {lfss-0.7.15.dist-info → lfss-0.8.1.dist-info}/METADATA +5 -3
- lfss-0.8.1.dist-info/RECORD +43 -0
- lfss/client/api.py +0 -143
- lfss-0.7.15.dist-info/RECORD +0 -41
- {lfss-0.7.15.dist-info → lfss-0.8.1.dist-info}/WHEEL +0 -0
- {lfss-0.7.15.dist-info → lfss-0.8.1.dist-info}/entry_points.txt +0 -0
lfss/api/connector.py
ADDED
@@ -0,0 +1,261 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
from typing import Optional, Literal, Iterator
|
3
|
+
import os
|
4
|
+
import requests
|
5
|
+
import requests.adapters
|
6
|
+
import urllib.parse
|
7
|
+
from tempfile import SpooledTemporaryFile
|
8
|
+
from lfss.src.error import PathNotFoundError
|
9
|
+
from lfss.src.datatype import (
|
10
|
+
FileReadPermission, FileRecord, DirectoryRecord, UserRecord, PathContents,
|
11
|
+
FileSortKey, DirSortKey
|
12
|
+
)
|
13
|
+
from lfss.src.utils import ensure_uri_compnents
|
14
|
+
|
15
|
+
_default_endpoint = os.environ.get('LFSS_ENDPOINT', 'http://localhost:8000')
|
16
|
+
_default_token = os.environ.get('LFSS_TOKEN', '')
|
17
|
+
|
18
|
+
class Connector:
|
19
|
+
class Session:
|
20
|
+
def __init__(self, connector: Connector, pool_size: int = 10):
|
21
|
+
self.connector = connector
|
22
|
+
self.pool_size = pool_size
|
23
|
+
def open(self):
|
24
|
+
self.close()
|
25
|
+
if self.connector._session is None:
|
26
|
+
s = requests.Session()
|
27
|
+
adapter = requests.adapters.HTTPAdapter(pool_connections=self.pool_size, pool_maxsize=self.pool_size)
|
28
|
+
s.mount('http://', adapter)
|
29
|
+
s.mount('https://', adapter)
|
30
|
+
self.connector._session = s
|
31
|
+
def close(self):
|
32
|
+
if self.connector._session is not None:
|
33
|
+
self.connector._session.close()
|
34
|
+
self.connector._session = None
|
35
|
+
def __call__(self):
|
36
|
+
return self.connector
|
37
|
+
def __enter__(self):
|
38
|
+
self.open()
|
39
|
+
return self.connector
|
40
|
+
def __exit__(self, exc_type, exc_value, traceback):
|
41
|
+
self.close()
|
42
|
+
|
43
|
+
def __init__(self, endpoint=_default_endpoint, token=_default_token):
|
44
|
+
assert token, "No token provided. Please set LFSS_TOKEN environment variable."
|
45
|
+
self.config = {
|
46
|
+
"endpoint": endpoint,
|
47
|
+
"token": token
|
48
|
+
}
|
49
|
+
self._session: Optional[requests.Session] = None
|
50
|
+
|
51
|
+
def session(self, pool_size: int = 10):
|
52
|
+
""" avoid creating a new session for each request. """
|
53
|
+
return self.Session(self, pool_size)
|
54
|
+
|
55
|
+
def _fetch_factory(
|
56
|
+
self, method: Literal['GET', 'POST', 'PUT', 'DELETE'],
|
57
|
+
path: str, search_params: dict = {}
|
58
|
+
):
|
59
|
+
if path.startswith('/'):
|
60
|
+
path = path[1:]
|
61
|
+
path = ensure_uri_compnents(path)
|
62
|
+
def f(**kwargs):
|
63
|
+
url = f"{self.config['endpoint']}/{path}" + "?" + urllib.parse.urlencode(search_params)
|
64
|
+
headers: dict = kwargs.pop('headers', {})
|
65
|
+
headers.update({
|
66
|
+
'Authorization': f"Bearer {self.config['token']}",
|
67
|
+
})
|
68
|
+
if self._session is not None:
|
69
|
+
response = self._session.request(method, url, headers=headers, **kwargs)
|
70
|
+
response.raise_for_status()
|
71
|
+
else:
|
72
|
+
with requests.Session() as s:
|
73
|
+
response = s.request(method, url, headers=headers, **kwargs)
|
74
|
+
response.raise_for_status()
|
75
|
+
return response
|
76
|
+
return f
|
77
|
+
|
78
|
+
def put(self, path: str, file_data: bytes, permission: int | FileReadPermission = 0, conflict: Literal['overwrite', 'abort', 'skip', 'skip-ahead'] = 'abort'):
|
79
|
+
"""Uploads a file to the specified path."""
|
80
|
+
assert isinstance(file_data, bytes), "file_data must be bytes"
|
81
|
+
|
82
|
+
# Skip ahead by checking if the file already exists
|
83
|
+
if conflict == 'skip-ahead':
|
84
|
+
exists = self.get_metadata(path)
|
85
|
+
if exists is None:
|
86
|
+
conflict = 'skip'
|
87
|
+
else:
|
88
|
+
return {'status': 'skipped', 'path': path}
|
89
|
+
|
90
|
+
response = self._fetch_factory('PUT', path, search_params={
|
91
|
+
'permission': int(permission),
|
92
|
+
'conflict': conflict
|
93
|
+
})(
|
94
|
+
data=file_data,
|
95
|
+
headers={'Content-Type': 'application/octet-stream'}
|
96
|
+
)
|
97
|
+
return response.json()
|
98
|
+
|
99
|
+
def post(self, path, file: str | bytes, permission: int | FileReadPermission = 0, conflict: Literal['overwrite', 'abort', 'skip', 'skip-ahead'] = 'abort'):
|
100
|
+
"""
|
101
|
+
Uploads a file to the specified path,
|
102
|
+
using the POST method, with form-data/multipart.
|
103
|
+
file can be a path to a file on disk, or bytes.
|
104
|
+
"""
|
105
|
+
|
106
|
+
# Skip ahead by checking if the file already exists
|
107
|
+
if conflict == 'skip-ahead':
|
108
|
+
exists = self.get_metadata(path)
|
109
|
+
if exists is None:
|
110
|
+
conflict = 'skip'
|
111
|
+
else:
|
112
|
+
return {'status': 'skipped', 'path': path}
|
113
|
+
|
114
|
+
if isinstance(file, str):
|
115
|
+
assert os.path.exists(file), "File does not exist on disk"
|
116
|
+
fsize = os.path.getsize(file)
|
117
|
+
|
118
|
+
with open(file, 'rb') if isinstance(file, str) else SpooledTemporaryFile(max_size=1024*1024*32) as fp:
|
119
|
+
|
120
|
+
if isinstance(file, bytes):
|
121
|
+
fsize = len(file)
|
122
|
+
fp.write(file)
|
123
|
+
fp.seek(0)
|
124
|
+
|
125
|
+
# https://stackoverflow.com/questions/12385179/
|
126
|
+
print(f"Uploading {fsize} bytes")
|
127
|
+
response = self._fetch_factory('POST', path, search_params={
|
128
|
+
'permission': int(permission),
|
129
|
+
'conflict': conflict
|
130
|
+
})(
|
131
|
+
files={'file': fp},
|
132
|
+
)
|
133
|
+
return response.json()
|
134
|
+
|
135
|
+
def put_json(self, path: str, data: dict, permission: int | FileReadPermission = 0, conflict: Literal['overwrite', 'abort', 'skip', 'skip-ahead'] = 'abort'):
|
136
|
+
"""Uploads a JSON file to the specified path."""
|
137
|
+
assert path.endswith('.json'), "Path must end with .json"
|
138
|
+
assert isinstance(data, dict), "data must be a dict"
|
139
|
+
|
140
|
+
# Skip ahead by checking if the file already exists
|
141
|
+
if conflict == 'skip-ahead':
|
142
|
+
exists = self.get_metadata(path)
|
143
|
+
if exists is None:
|
144
|
+
conflict = 'skip'
|
145
|
+
else:
|
146
|
+
return {'status': 'skipped', 'path': path}
|
147
|
+
|
148
|
+
response = self._fetch_factory('PUT', path, search_params={
|
149
|
+
'permission': int(permission),
|
150
|
+
'conflict': conflict
|
151
|
+
})(
|
152
|
+
json=data,
|
153
|
+
headers={'Content-Type': 'application/json'}
|
154
|
+
)
|
155
|
+
return response.json()
|
156
|
+
|
157
|
+
def _get(self, path: str, stream: bool = False) -> Optional[requests.Response]:
|
158
|
+
try:
|
159
|
+
response = self._fetch_factory('GET', path)(stream=stream)
|
160
|
+
except requests.exceptions.HTTPError as e:
|
161
|
+
if e.response.status_code == 404:
|
162
|
+
return None
|
163
|
+
raise e
|
164
|
+
return response
|
165
|
+
|
166
|
+
def get(self, path: str) -> Optional[bytes]:
|
167
|
+
"""Downloads a file from the specified path."""
|
168
|
+
response = self._get(path)
|
169
|
+
if response is None: return None
|
170
|
+
return response.content
|
171
|
+
|
172
|
+
def get_stream(self, path: str) -> Iterator[bytes]:
|
173
|
+
"""Downloads a file from the specified path, will raise PathNotFoundError if path not found."""
|
174
|
+
response = self._get(path, stream=True)
|
175
|
+
if response is None: raise PathNotFoundError("Path not found: " + path)
|
176
|
+
return response.iter_content(chunk_size=1024)
|
177
|
+
|
178
|
+
def get_json(self, path: str) -> Optional[dict]:
|
179
|
+
response = self._get(path)
|
180
|
+
if response is None: return None
|
181
|
+
assert response.headers['Content-Type'] == 'application/json'
|
182
|
+
return response.json()
|
183
|
+
|
184
|
+
def delete(self, path: str):
|
185
|
+
"""Deletes the file at the specified path."""
|
186
|
+
self._fetch_factory('DELETE', path)()
|
187
|
+
|
188
|
+
def get_metadata(self, path: str) -> Optional[FileRecord | DirectoryRecord]:
|
189
|
+
"""Gets the metadata for the file at the specified path."""
|
190
|
+
try:
|
191
|
+
response = self._fetch_factory('GET', '_api/meta', {'path': path})()
|
192
|
+
if path.endswith('/'):
|
193
|
+
return DirectoryRecord(**response.json())
|
194
|
+
else:
|
195
|
+
return FileRecord(**response.json())
|
196
|
+
except requests.exceptions.HTTPError as e:
|
197
|
+
if e.response.status_code == 404:
|
198
|
+
return None
|
199
|
+
raise e
|
200
|
+
|
201
|
+
def list_path(self, path: str) -> PathContents:
|
202
|
+
"""
|
203
|
+
shorthand list with limited options,
|
204
|
+
for large directories / more options, use list_files and list_dirs instead.
|
205
|
+
"""
|
206
|
+
assert path.endswith('/')
|
207
|
+
response = self._fetch_factory('GET', path)()
|
208
|
+
dirs = [DirectoryRecord(**d) for d in response.json()['dirs']]
|
209
|
+
files = [FileRecord(**f) for f in response.json()['files']]
|
210
|
+
return PathContents(dirs=dirs, files=files)
|
211
|
+
|
212
|
+
def count_files(self, path: str, flat: bool = False) -> int:
|
213
|
+
assert path.endswith('/')
|
214
|
+
response = self._fetch_factory('GET', '_api/count-files', {'path': path, 'flat': flat})()
|
215
|
+
return response.json()['count']
|
216
|
+
|
217
|
+
def list_files(
|
218
|
+
self, path: str, offset: int = 0, limit: int = 1000,
|
219
|
+
order_by: FileSortKey = '', order_desc: bool = False,
|
220
|
+
flat: bool = False
|
221
|
+
) -> list[FileRecord]:
|
222
|
+
assert path.endswith('/')
|
223
|
+
response = self._fetch_factory('GET', "_api/list-files", {
|
224
|
+
'path': path,
|
225
|
+
'offset': offset, 'limit': limit, 'order_by': order_by, 'order_desc': order_desc, 'flat': flat
|
226
|
+
})()
|
227
|
+
return [FileRecord(**f) for f in response.json()]
|
228
|
+
|
229
|
+
def count_dirs(self, path: str) -> int:
|
230
|
+
assert path.endswith('/')
|
231
|
+
response = self._fetch_factory('GET', '_api/count-dirs', {'path': path})()
|
232
|
+
return response.json()['count']
|
233
|
+
|
234
|
+
def list_dirs(
|
235
|
+
self, path: str, offset: int = 0, limit: int = 1000,
|
236
|
+
order_by: DirSortKey = '', order_desc: bool = False,
|
237
|
+
skim: bool = True
|
238
|
+
) -> list[DirectoryRecord]:
|
239
|
+
assert path.endswith('/')
|
240
|
+
response = self._fetch_factory('GET', "_api/list-dirs", {
|
241
|
+
'path': path,
|
242
|
+
'offset': offset, 'limit': limit, 'order_by': order_by, 'order_desc': order_desc, 'skim': skim
|
243
|
+
})()
|
244
|
+
return [DirectoryRecord(**d) for d in response.json()]
|
245
|
+
|
246
|
+
def set_file_permission(self, path: str, permission: int | FileReadPermission):
|
247
|
+
"""Sets the file permission for the specified path."""
|
248
|
+
self._fetch_factory('POST', '_api/meta', {'path': path, 'perm': int(permission)})(
|
249
|
+
headers={'Content-Type': 'application/www-form-urlencoded'}
|
250
|
+
)
|
251
|
+
|
252
|
+
def move(self, path: str, new_path: str):
|
253
|
+
"""Move file or directory to a new path."""
|
254
|
+
self._fetch_factory('POST', '_api/meta', {'path': path, 'new_path': new_path})(
|
255
|
+
headers = {'Content-Type': 'application/www-form-urlencoded'}
|
256
|
+
)
|
257
|
+
|
258
|
+
def whoami(self) -> UserRecord:
|
259
|
+
"""Gets information about the current user."""
|
260
|
+
response = self._fetch_factory('GET', '_api/whoami')()
|
261
|
+
return UserRecord(**response.json())
|
lfss/cli/cli.py
CHANGED
@@ -1,4 +1,4 @@
|
|
1
|
-
from lfss.
|
1
|
+
from lfss.api import Connector, upload_directory, upload_file, download_file, download_directory
|
2
2
|
from pathlib import Path
|
3
3
|
import argparse
|
4
4
|
from lfss.src.datatype import FileReadPermission
|
lfss/cli/user.py
CHANGED
@@ -29,7 +29,7 @@ async def _main():
|
|
29
29
|
sp_set.add_argument('username', type=str)
|
30
30
|
sp_set.add_argument('-p', '--password', type=str, default=None)
|
31
31
|
sp_set.add_argument('-a', '--admin', type=parse_bool, default=None)
|
32
|
-
sp_set.add_argument('--permission', type=
|
32
|
+
sp_set.add_argument('--permission', type=parse_permission, default=None)
|
33
33
|
sp_set.add_argument('--max-storage', type=parse_storage_size, default=None)
|
34
34
|
|
35
35
|
sp_list = sp.add_parser('list')
|
lfss/src/config.py
CHANGED
@@ -18,7 +18,7 @@ if __env_large_file is not None:
|
|
18
18
|
LARGE_FILE_BYTES = parse_storage_size(__env_large_file)
|
19
19
|
else:
|
20
20
|
LARGE_FILE_BYTES = 8 * 1024 * 1024 # 8MB
|
21
|
-
|
21
|
+
MAX_MEM_FILE_BYTES = 128 * 1024 * 1024 # 128MB
|
22
22
|
MAX_BUNDLE_BYTES = 512 * 1024 * 1024 # 512MB
|
23
23
|
CHUNK_SIZE = 1024 * 1024 # 1MB chunks for streaming (on large files)
|
24
24
|
|
lfss/src/connection_pool.py
CHANGED
@@ -46,7 +46,7 @@ class SqlConnection:
|
|
46
46
|
|
47
47
|
class SqlConnectionPool:
|
48
48
|
_r_sem: Semaphore
|
49
|
-
_w_sem: Semaphore
|
49
|
+
_w_sem: Lock | Semaphore
|
50
50
|
def __init__(self):
|
51
51
|
self._readers: list[SqlConnection] = []
|
52
52
|
self._writer: None | SqlConnection = None
|
@@ -57,7 +57,8 @@ class SqlConnectionPool:
|
|
57
57
|
self._readers = []
|
58
58
|
|
59
59
|
self._writer = SqlConnection(await get_connection(read_only=False))
|
60
|
-
self._w_sem =
|
60
|
+
self._w_sem = Lock()
|
61
|
+
# self._w_sem = Semaphore(1)
|
61
62
|
|
62
63
|
for _ in range(n_read):
|
63
64
|
conn = await get_connection(read_only=True)
|