lfss 0.4.0__py3-none-any.whl → 0.5.0__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.
frontend/api.js CHANGED
@@ -25,6 +25,8 @@
25
25
  * @typedef {Object} DirectoryRecord
26
26
  * @property {string} url - the url of the directory
27
27
  * @property {string} size - the size of the directory, in bytes
28
+ * @property {string} create_time - the time the directory was created
29
+ * @property {string} access_time - the time the directory was last accessed
28
30
  *
29
31
  * @typedef {Object} PathListResponse
30
32
  * @property {DirectoryRecord[]} dirs - the list of directories in the directory
@@ -54,10 +56,16 @@ export default class Connector {
54
56
  * @param {File} file - the file to upload
55
57
  * @returns {Promise<string>} - the promise of the request, the url of the file
56
58
  */
57
- async put(path, file){
59
+ async put(path, file, {
60
+ overwrite = false,
61
+ permission = 0
62
+ } = {}){
58
63
  if (path.startsWith('/')){ path = path.slice(1); }
59
64
  const fileBytes = await file.arrayBuffer();
60
- const res = await fetch(this.config.endpoint + '/' + path, {
65
+ const dst = new URL(this.config.endpoint + '/' + path);
66
+ dst.searchParams.append('overwrite', overwrite);
67
+ dst.searchParams.append('permission', permission);
68
+ const res = await fetch(dst.toString(), {
61
69
  method: 'PUT',
62
70
  headers: {
63
71
  'Authorization': 'Bearer ' + this.config.token,
@@ -106,12 +114,12 @@ export default class Connector {
106
114
  }
107
115
 
108
116
  /**
109
- * @param {string} path - the path to the file
110
- * @returns {Promise<FileRecord | null>} - the promise of the request
117
+ * @param {string} path - the path to the file or directory
118
+ * @returns {Promise<FileRecord | DirectoryRecord | null>} - the promise of the request
111
119
  */
112
120
  async getMetadata(path){
113
121
  if (path.startsWith('/')){ path = path.slice(1); }
114
- const res = await fetch(this.config.endpoint + '/_api/fmeta?path=' + path, {
122
+ const res = await fetch(this.config.endpoint + '/_api/meta?path=' + path, {
115
123
  method: 'GET',
116
124
  headers: {
117
125
  'Authorization': 'Bearer ' + this.config.token
@@ -165,7 +173,7 @@ export default class Connector {
165
173
  */
166
174
  async setFilePermission(path, permission){
167
175
  if (path.startsWith('/')){ path = path.slice(1); }
168
- const dst = new URL(this.config.endpoint + '/_api/fmeta');
176
+ const dst = new URL(this.config.endpoint + '/_api/meta');
169
177
  dst.searchParams.append('path', path);
170
178
  dst.searchParams.append('perm', permission);
171
179
  const res = await fetch(dst.toString(), {
@@ -186,7 +194,7 @@ export default class Connector {
186
194
  async moveFile(path, newPath){
187
195
  if (path.startsWith('/')){ path = path.slice(1); }
188
196
  if (newPath.startsWith('/')){ newPath = newPath.slice(1); }
189
- const dst = new URL(this.config.endpoint + '/_api/fmeta');
197
+ const dst = new URL(this.config.endpoint + '/_api/meta');
190
198
  dst.searchParams.append('path', path);
191
199
  dst.searchParams.append('new_path', newPath);
192
200
  const res = await fetch(dst.toString(), {
frontend/scripts.js CHANGED
@@ -79,6 +79,10 @@ pathBackButton.addEventListener('click', () => {
79
79
 
80
80
  function onFileNameInpuChange(){
81
81
  const fileName = uploadFileNameInput.value;
82
+ if (fileName.endsWith('/')){
83
+ uploadFileNameInput.classList.add('duplicate');
84
+ return;
85
+ }
82
86
  if (fileName.length === 0){
83
87
  uploadFileNameInput.classList.remove('duplicate');
84
88
  }
@@ -172,7 +176,7 @@ Are you sure you want to proceed?
172
176
  async function uploadFile(...args){
173
177
  const [file, path] = args;
174
178
  try{
175
- await conn.put(path, file);
179
+ await conn.put(path, file, {overwrite: true});
176
180
  }
177
181
  catch (err){
178
182
  showPopup('Failed to upload file [' + file.name + ']: ' + err, {level: 'error', timeout: 5000});
@@ -219,6 +223,9 @@ function refreshFileList(){
219
223
 
220
224
  data.dirs.forEach(dir => {
221
225
  const tr = document.createElement('tr');
226
+ const sizeTd = document.createElement('td');
227
+ const accessTimeTd = document.createElement('td');
228
+ const createTimeTd = document.createElement('td');
222
229
  {
223
230
  const nameTd = document.createElement('td');
224
231
  if (dir.url.endsWith('/')){
@@ -239,19 +246,14 @@ function refreshFileList(){
239
246
  tr.appendChild(nameTd);
240
247
  tbody.appendChild(tr);
241
248
  }
242
-
243
249
  {
244
- const sizeTd = document.createElement('td');
250
+ // these are initialized meta
245
251
  sizeTd.textContent = formatSize(dir.size);
246
252
  tr.appendChild(sizeTd);
247
- }
248
- {
249
- const dateTd = document.createElement('td');
250
- tr.appendChild(dateTd);
251
- }
252
- {
253
- const dateTd = document.createElement('td');
254
- tr.appendChild(dateTd);
253
+ accessTimeTd.textContent = cvtGMT2Local(dir.access_time);
254
+ tr.appendChild(accessTimeTd);
255
+ createTimeTd.textContent = cvtGMT2Local(dir.create_time);
256
+ tr.appendChild(createTimeTd);
255
257
  }
256
258
  {
257
259
  const accessTd = document.createElement('td');
@@ -262,6 +264,22 @@ function refreshFileList(){
262
264
  const actContainer = document.createElement('div');
263
265
  actContainer.classList.add('action-container');
264
266
 
267
+ const showMetaButton = document.createElement('a');
268
+ showMetaButton.textContent = 'Details';
269
+ showMetaButton.style.cursor = 'pointer';
270
+ showMetaButton.addEventListener('click', () => {
271
+ const dirUrlEncap = dir.url + (dir.url.endsWith('/') ? '' : '/');
272
+ conn.getMetadata(dirUrlEncap).then(
273
+ (meta) => {
274
+ sizeTd.textContent = formatSize(meta.size);
275
+ accessTimeTd.textContent = cvtGMT2Local(meta.access_time);
276
+ createTimeTd.textContent = cvtGMT2Local(meta.create_time);
277
+ }
278
+ );
279
+ showPopup('Fetching metadata...', {level: 'info', timeout: 3000});
280
+ });
281
+ actContainer.appendChild(showMetaButton);
282
+
265
283
  const downloadButton = document.createElement('a');
266
284
  downloadButton.textContent = 'Download';
267
285
  downloadButton.href = conn.config.endpoint + '/_api/bundle?' +
frontend/styles.css CHANGED
@@ -192,6 +192,7 @@ input#file-name.duplicate{
192
192
  display: flex;
193
193
  flex-direction: row;
194
194
  gap: 10px;
195
+ /* justify-content: flex-end; */
195
196
  }
196
197
  a{
197
198
  color: #195f8b;
frontend/utils.js CHANGED
@@ -1,5 +1,8 @@
1
1
 
2
2
  export function formatSize(size){
3
+ if (size < 0){
4
+ return '';
5
+ }
3
6
  const sizeInKb = size / 1024;
4
7
  const sizeInMb = sizeInKb / 1024;
5
8
  const sizeInGb = sizeInMb / 1024;
@@ -68,6 +71,9 @@ export function getRandomString(n, additionalCharset='0123456789_-(=)[]{}'){
68
71
  * @returns {string}
69
72
  */
70
73
  export function cvtGMT2Local(dateStr){
74
+ if (!dateStr || dateStr === 'N/A'){
75
+ return '';
76
+ }
71
77
  const gmtdate = new Date(dateStr);
72
78
  const localdate = new Date(gmtdate.getTime() + gmtdate.getTimezoneOffset() * 60000);
73
79
  return localdate.toISOString().slice(0, 19).replace('T', ' ');
lfss/cli/cli.py ADDED
@@ -0,0 +1,52 @@
1
+ from lfss.client import Connector, upload_directory
2
+ from lfss.src.database import FileReadPermission
3
+ from pathlib import Path
4
+ import argparse
5
+
6
+ def parse_arguments():
7
+ parser = argparse.ArgumentParser(description="Command line interface, please set LFSS_ENDPOINT and LFSS_TOKEN environment variables.")
8
+
9
+ sp = parser.add_subparsers(dest="command", required=True)
10
+
11
+ # upload
12
+ sp_upload = sp.add_parser("upload", help="Upload files")
13
+ sp_upload.add_argument("src", help="Source file or directory", type=str)
14
+ sp_upload.add_argument("dst", help="Destination path", type=str)
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 retries, only works with directory upload")
17
+ sp_upload.add_argument("--overwrite", action="store_true", help="Overwrite existing files")
18
+ sp_upload.add_argument("--permission", type=FileReadPermission, default=FileReadPermission.UNSET, help="File permission")
19
+ sp_upload.add_argument("--retries", type=int, default=0, help="Number of retries, only works with directory upload")
20
+
21
+ return parser.parse_args()
22
+
23
+ def main():
24
+ args = parse_arguments()
25
+ connector = Connector()
26
+ if args.command == "upload":
27
+ src_path = Path(args.src)
28
+ if src_path.is_dir():
29
+ failed_upload = upload_directory(
30
+ connector, args.src, args.dst,
31
+ verbose=True,
32
+ n_concurrent=args.jobs,
33
+ n_reties=args.retries,
34
+ interval=args.interval,
35
+ overwrite=args.overwrite,
36
+ permission=args.permission
37
+ )
38
+ if failed_upload:
39
+ print("Failed to upload:")
40
+ for path in failed_upload:
41
+ print(f" {path}")
42
+ else:
43
+ with open(args.src, 'rb') as f:
44
+ connector.put(
45
+ args.dst,
46
+ f.read(),
47
+ overwrite=args.overwrite,
48
+ permission=args.permission
49
+ )
50
+ else:
51
+ raise NotImplementedError(f"Command {args.command} not implemented.")
52
+
lfss/cli/user.py CHANGED
@@ -8,6 +8,8 @@ def parse_storage_size(s: str) -> int:
8
8
  return int(s[:-1]) * 1024 * 1024
9
9
  if s[-1] in 'Gg':
10
10
  return int(s[:-1]) * 1024 * 1024 * 1024
11
+ if s[-1] in 'Tt':
12
+ return int(s[:-1]) * 1024 * 1024 * 1024 * 1024
11
13
  return int(s)
12
14
 
13
15
  async def _main():
lfss/client/__init__.py CHANGED
@@ -0,0 +1,60 @@
1
+ import os, time
2
+ from threading import Lock
3
+ from concurrent.futures import ThreadPoolExecutor
4
+ from .api import Connector
5
+
6
+ def upload_directory(
7
+ connector: Connector,
8
+ directory: str,
9
+ path: str,
10
+ n_concurrent: int = 1,
11
+ n_reties: int = 0,
12
+ interval: float = 0,
13
+ verbose: bool = False,
14
+ **put_kwargs
15
+ ) -> list[str]:
16
+ assert path.endswith('/'), "Path must end with a slash."
17
+ if path.startswith('/'):
18
+ path = path[1:]
19
+
20
+ _counter = 0
21
+ _counter_lock = Lock()
22
+
23
+ faild_files = []
24
+ def put_file(file_path):
25
+ with _counter_lock:
26
+ nonlocal _counter
27
+ _counter += 1
28
+ this_count = _counter
29
+ dst_path = f"{path}{os.path.relpath(file_path, directory)}"
30
+ if verbose:
31
+ print(f"[{this_count}] Uploading {file_path} to {dst_path}")
32
+
33
+ this_try = 0
34
+ with open(file_path, 'rb') as f:
35
+ blob = f.read()
36
+
37
+ while this_try <= n_reties:
38
+ try:
39
+ connector.put(dst_path, blob, **put_kwargs)
40
+ break
41
+ except Exception as e:
42
+ if isinstance(e, KeyboardInterrupt):
43
+ raise e
44
+ if verbose:
45
+ print(f"[{this_count}] Error uploading {file_path}: {e}, retrying...")
46
+ this_try += 1
47
+ finally:
48
+ time.sleep(interval)
49
+
50
+ if this_try > n_reties:
51
+ faild_files.append(file_path)
52
+ if verbose:
53
+ print(f"[{this_count}] Failed to upload {file_path} after {n_reties} retries.")
54
+
55
+ with ThreadPoolExecutor(n_concurrent) as executor:
56
+ for root, dirs, files in os.walk(directory):
57
+ for file in files:
58
+ executor.submit(put_file, os.path.join(root, file))
59
+
60
+ return faild_files
lfss/client/api.py CHANGED
@@ -3,7 +3,7 @@ import os
3
3
  import requests
4
4
  import urllib.parse
5
5
  from lfss.src.database import (
6
- FileReadPermission, FileRecord, UserRecord, PathContents
6
+ FileReadPermission, FileRecord, DirectoryRecord, UserRecord, PathContents
7
7
  )
8
8
 
9
9
  _default_endpoint = os.environ.get('LFSS_ENDPOINT', 'http://localhost:8000')
@@ -34,9 +34,14 @@ class Connector:
34
34
  return response
35
35
  return f
36
36
 
37
- def put(self, path: str, file_data: bytes):
37
+ def put(self, path: str, file_data: bytes, permission: int | FileReadPermission = 0, overwrite: bool = False):
38
38
  """Uploads a file to the specified path."""
39
- response = self._fetch('PUT', path)(
39
+ if path.startswith('/'):
40
+ path = path[1:]
41
+ response = self._fetch('PUT', path, search_params={
42
+ 'permission': int(permission),
43
+ 'overwrite': overwrite
44
+ })(
40
45
  data=file_data,
41
46
  headers={'Content-Type': 'application/octet-stream'}
42
47
  )
@@ -58,11 +63,14 @@ class Connector:
58
63
  path = path[1:]
59
64
  self._fetch('DELETE', path)()
60
65
 
61
- def get_metadata(self, path: str) -> Optional[FileRecord]:
66
+ def get_metadata(self, path: str) -> Optional[FileRecord | DirectoryRecord]:
62
67
  """Gets the metadata for the file at the specified path."""
63
68
  try:
64
- response = self._fetch('GET', '_api/fmeta', {'path': path})()
65
- return FileRecord(**response.json())
69
+ response = self._fetch('GET', '_api/meta', {'path': path})()
70
+ if path.endswith('/'):
71
+ return DirectoryRecord(**response.json())
72
+ else:
73
+ return FileRecord(**response.json())
66
74
  except requests.exceptions.HTTPError as e:
67
75
  if e.response.status_code == 404:
68
76
  return None
@@ -75,13 +83,13 @@ class Connector:
75
83
 
76
84
  def set_file_permission(self, path: str, permission: int | FileReadPermission):
77
85
  """Sets the file permission for the specified path."""
78
- self._fetch('POST', '_api/fmeta', {'path': path, 'perm': int(permission)})(
86
+ self._fetch('POST', '_api/meta', {'path': path, 'perm': int(permission)})(
79
87
  headers={'Content-Type': 'application/www-form-urlencoded'}
80
88
  )
81
89
 
82
90
  def move_file(self, path: str, new_path: str):
83
91
  """Moves a file to a new location."""
84
- self._fetch('POST', '_api/fmeta', {'path': path, 'new_path': new_path})(
92
+ self._fetch('POST', '_api/meta', {'path': path, 'new_path': new_path})(
85
93
  headers = {'Content-Type': 'application/www-form-urlencoded'}
86
94
  )
87
95
 
lfss/src/config.py CHANGED
@@ -7,6 +7,9 @@ DATA_HOME = Path(os.environ.get('LFSS_DATA', __default_dir))
7
7
  if not DATA_HOME.exists():
8
8
  DATA_HOME.mkdir()
9
9
  print(f"[init] Created data home at {DATA_HOME}")
10
+ LARGE_BLOB_DIR = DATA_HOME / 'large_blobs'
11
+ LARGE_BLOB_DIR.mkdir(exist_ok=True)
10
12
 
11
- MAX_FILE_BYTES = 256 * 1024 * 1024 # 256MB
12
- MAX_BUNDLE_BYTES = 256 * 1024 * 1024 # 256MB
13
+ LARGE_FILE_BYTES = 64 * 1024 * 1024 # 64MB
14
+ MAX_FILE_BYTES = 1024 * 1024 * 1024 # 1GB
15
+ MAX_BUNDLE_BYTES = 1024 * 1024 * 1024 # 1GB
lfss/src/database.py CHANGED
@@ -7,12 +7,13 @@ import dataclasses, hashlib, uuid
7
7
  from contextlib import asynccontextmanager
8
8
  from functools import wraps
9
9
  from enum import IntEnum
10
- import zipfile, io
10
+ import zipfile, io, asyncio
11
11
 
12
- import aiosqlite
12
+ import aiosqlite, aiofiles
13
+ import aiofiles.os
13
14
  from asyncio import Lock
14
15
 
15
- from .config import DATA_HOME
16
+ from .config import DATA_HOME, LARGE_BLOB_DIR
16
17
  from .log import get_logger
17
18
  from .utils import decode_uri_compnents
18
19
  from .error import *
@@ -47,6 +48,7 @@ class DBConnBase(ABC):
47
48
  global _g_conn
48
49
  if _g_conn is None:
49
50
  _g_conn = await aiosqlite.connect(DATA_HOME / 'lfss.db')
51
+ await _g_conn.execute('PRAGMA journal_mode=memory')
50
52
 
51
53
  async def commit(self):
52
54
  await self.conn.commit()
@@ -190,15 +192,19 @@ class FileRecord:
190
192
  create_time: str
191
193
  access_time: str
192
194
  permission: FileReadPermission
195
+ external: bool
193
196
 
194
197
  def __str__(self):
195
198
  return f"File {self.url} (owner={self.owner_id}, created at {self.create_time}, accessed at {self.access_time}, " + \
196
- f"file_id={self.file_id}, permission={self.permission}, size={self.file_size})"
199
+ f"file_id={self.file_id}, permission={self.permission}, size={self.file_size}, external={self.external})"
197
200
 
198
201
  @dataclasses.dataclass
199
202
  class DirectoryRecord:
200
203
  url: str
201
204
  size: int
205
+ create_time: str = ""
206
+ update_time: str = ""
207
+ access_time: str = ""
202
208
 
203
209
  def __str__(self):
204
210
  return f"Directory {self.url} (size={self.size})"
@@ -224,7 +230,9 @@ class FileConn(DBConnBase):
224
230
  file_size INTEGER,
225
231
  create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
226
232
  access_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
227
- permission INTEGER DEFAULT 0
233
+ permission INTEGER DEFAULT 0,
234
+ external BOOLEAN DEFAULT FALSE,
235
+ FOREIGN KEY(owner_id) REFERENCES user(id)
228
236
  )
229
237
  ''')
230
238
  await self.conn.execute('''
@@ -256,6 +264,16 @@ class FileConn(DBConnBase):
256
264
  size = await cursor.fetchone()
257
265
  if size is not None and size[0] is not None:
258
266
  await self._user_size_inc(r[0], size[0])
267
+
268
+ # backward compatibility, since 0.5.0
269
+ # 'external' means the file is not stored in the database, but in the external storage
270
+ async with self.conn.execute("SELECT * FROM fmeta") as cursor:
271
+ res = await cursor.fetchone()
272
+ if res and len(res) < 8:
273
+ self.logger.info("Updating fmeta table")
274
+ await self.conn.execute('''
275
+ ALTER TABLE fmeta ADD COLUMN external BOOLEAN DEFAULT FALSE
276
+ ''')
259
277
 
260
278
  return self
261
279
 
@@ -278,7 +296,7 @@ class FileConn(DBConnBase):
278
296
  res = await cursor.fetchall()
279
297
  return [self.parse_record(r) for r in res]
280
298
 
281
- async def get_path_records(self, url: str) -> list[FileRecord]:
299
+ async def get_path_file_records(self, url: str) -> list[FileRecord]:
282
300
  async with self.conn.execute("SELECT * FROM fmeta WHERE url LIKE ?", (url + '%', )) as cursor:
283
301
  res = await cursor.fetchall()
284
302
  return [self.parse_record(r) for r in res]
@@ -348,10 +366,27 @@ class FileConn(DBConnBase):
348
366
  ) as cursor:
349
367
  res = await cursor.fetchall()
350
368
  dirs_str = [r[0] + '/' for r in res if r[0] != '/']
351
- dirs = [DirectoryRecord(url + d, await self.path_size(url + d, include_subpath=True)) for d in dirs_str]
352
-
369
+ async def get_dir(dir_url):
370
+ return DirectoryRecord(dir_url, -1)
371
+ dirs = await asyncio.gather(*[get_dir(url + d) for d in dirs_str])
353
372
  return PathContents(dirs, files)
354
373
 
374
+ async def get_path_record(self, url: str) -> Optional[DirectoryRecord]:
375
+ assert url.endswith('/'), "Path must end with /"
376
+ async with self.conn.execute("""
377
+ SELECT MIN(create_time) as create_time,
378
+ MAX(create_time) as update_time,
379
+ MAX(access_time) as access_time
380
+ FROM fmeta
381
+ WHERE url LIKE ?
382
+ """, (url + '%', )) as cursor:
383
+ result = await cursor.fetchone()
384
+ if result is None or any(val is None for val in result):
385
+ raise PathNotFoundError(f"Path {url} not found")
386
+ create_time, update_time, access_time = result
387
+ p_size = await self.path_size(url, include_subpath=True)
388
+ return DirectoryRecord(url, p_size, create_time=create_time, update_time=update_time, access_time=access_time)
389
+
355
390
  async def user_size(self, user_id: int) -> int:
356
391
  async with self.conn.execute("SELECT size FROM usize WHERE user_id = ?", (user_id, )) as cursor:
357
392
  res = await cursor.fetchone()
@@ -383,7 +418,8 @@ class FileConn(DBConnBase):
383
418
  owner_id: Optional[int] = None,
384
419
  file_id: Optional[str] = None,
385
420
  file_size: Optional[int] = None,
386
- permission: Optional[ FileReadPermission ] = None
421
+ permission: Optional[ FileReadPermission ] = None,
422
+ external: Optional[bool] = None
387
423
  ):
388
424
 
389
425
  old = await self.get_file_record(url)
@@ -392,6 +428,7 @@ class FileConn(DBConnBase):
392
428
  # should delete the old blob if file_id is changed
393
429
  assert file_id is None, "Cannot update file id"
394
430
  assert file_size is None, "Cannot update file size"
431
+ assert external is None, "Cannot update external"
395
432
 
396
433
  if owner_id is None: owner_id = old.owner_id
397
434
  if permission is None: permission = old.permission
@@ -402,13 +439,13 @@ class FileConn(DBConnBase):
402
439
  """, (owner_id, int(permission), url))
403
440
  self.logger.info(f"File {url} updated")
404
441
  else:
405
- self.logger.debug(f"Creating fmeta {url}: permission={permission}, owner_id={owner_id}, file_id={file_id}, file_size={file_size}")
442
+ self.logger.debug(f"Creating fmeta {url}: permission={permission}, owner_id={owner_id}, file_id={file_id}, file_size={file_size}, external={external}")
406
443
  if permission is None:
407
444
  permission = FileReadPermission.UNSET
408
- assert owner_id is not None and file_id is not None and file_size is not None, "Missing required fields"
445
+ assert owner_id is not None and file_id is not None and file_size is not None and external is not None
409
446
  await self.conn.execute(
410
- "INSERT INTO fmeta (url, owner_id, file_id, file_size, permission) VALUES (?, ?, ?, ?, ?)",
411
- (url, owner_id, file_id, file_size, int(permission))
447
+ "INSERT INTO fmeta (url, owner_id, file_id, file_size, permission, external) VALUES (?, ?, ?, ?, ?, ?)",
448
+ (url, owner_id, file_id, file_size, int(permission), external)
412
449
  )
413
450
  await self._user_size_inc(owner_id, file_size)
414
451
  self.logger.info(f"File {url} created")
@@ -465,6 +502,20 @@ class FileConn(DBConnBase):
465
502
  async def set_file_blob(self, file_id: str, blob: bytes):
466
503
  await self.conn.execute("INSERT OR REPLACE INTO fdata (file_id, data) VALUES (?, ?)", (file_id, blob))
467
504
 
505
+ @atomic
506
+ async def set_file_blob_external(self, file_id: str, stream: AsyncIterable[bytes])->int:
507
+ size_sum = 0
508
+ try:
509
+ async with aiofiles.open(LARGE_BLOB_DIR / file_id, 'wb') as f:
510
+ async for chunk in stream:
511
+ size_sum += len(chunk)
512
+ await f.write(chunk)
513
+ except Exception as e:
514
+ if (LARGE_BLOB_DIR / file_id).exists():
515
+ await aiofiles.os.remove(LARGE_BLOB_DIR / file_id)
516
+ raise
517
+ return size_sum
518
+
468
519
  async def get_file_blob(self, file_id: str) -> Optional[bytes]:
469
520
  async with self.conn.execute("SELECT data FROM fdata WHERE file_id = ?", (file_id, )) as cursor:
470
521
  res = await cursor.fetchone()
@@ -472,6 +523,17 @@ class FileConn(DBConnBase):
472
523
  return None
473
524
  return res[0]
474
525
 
526
+ async def get_file_blob_external(self, file_id: str) -> AsyncIterable[bytes]:
527
+ assert (LARGE_BLOB_DIR / file_id).exists(), f"File {file_id} not found"
528
+ async with aiofiles.open(LARGE_BLOB_DIR / file_id, 'rb') as f:
529
+ async for chunk in f:
530
+ yield chunk
531
+
532
+ @atomic
533
+ async def delete_file_blob_external(self, file_id: str):
534
+ if (LARGE_BLOB_DIR / file_id).exists():
535
+ await aiofiles.os.remove(LARGE_BLOB_DIR / file_id)
536
+
475
537
  @atomic
476
538
  async def delete_file_blob(self, file_id: str):
477
539
  await self.conn.execute("DELETE FROM fdata WHERE file_id = ?", (file_id, ))
@@ -543,9 +605,15 @@ class Database:
543
605
  if _g_conn is not None:
544
606
  await _g_conn.rollback()
545
607
 
546
- async def save_file(self, u: int | str, url: str, blob: bytes):
608
+ async def save_file(
609
+ self, u: int | str, url: str,
610
+ blob: bytes | AsyncIterable[bytes],
611
+ permission: FileReadPermission = FileReadPermission.UNSET
612
+ ):
613
+ """
614
+ if file_size is not provided, the blob must be bytes
615
+ """
547
616
  validate_url(url)
548
- assert isinstance(blob, bytes), "blob must be bytes"
549
617
 
550
618
  user = await get_user(self, u)
551
619
  if user is None:
@@ -562,25 +630,48 @@ class Database:
562
630
  if await get_user(self, first_component) is None:
563
631
  raise PermissionDeniedError(f"Invalid path: {first_component} is not a valid username")
564
632
 
565
- # check if fize_size is within limit
566
- file_size = len(blob)
567
633
  user_size_used = await self.file.user_size(user.id)
568
- if user_size_used + file_size > user.max_storage:
569
- raise StorageExceededError(f"Unable to save file, user {user.username} has storage limit of {user.max_storage}, used {user_size_used}, requested {file_size}")
570
-
571
- f_id = uuid.uuid4().hex
572
- async with transaction(self):
573
- await self.file.set_file_blob(f_id, blob)
574
- await self.file.set_file_record(url, owner_id=user.id, file_id=f_id, file_size=file_size)
575
- await self.user.set_active(user.username)
634
+ if isinstance(blob, bytes):
635
+ file_size = len(blob)
636
+ if user_size_used + file_size > user.max_storage:
637
+ raise StorageExceededError(f"Unable to save file, user {user.username} has storage limit of {user.max_storage}, used {user_size_used}, requested {file_size}")
638
+ f_id = uuid.uuid4().hex
639
+ async with transaction(self):
640
+ await self.file.set_file_blob(f_id, blob)
641
+ await self.file.set_file_record(
642
+ url, owner_id=user.id, file_id=f_id, file_size=file_size,
643
+ permission=permission, external=False)
644
+ await self.user.set_active(user.username)
645
+ else:
646
+ assert isinstance(blob, AsyncIterable)
647
+ async with transaction(self):
648
+ f_id = uuid.uuid4().hex
649
+ file_size = await self.file.set_file_blob_external(f_id, blob)
650
+ if user_size_used + file_size > user.max_storage:
651
+ await self.file.delete_file_blob_external(f_id)
652
+ raise StorageExceededError(f"Unable to save file, user {user.username} has storage limit of {user.max_storage}, used {user_size_used}, requested {file_size}")
653
+ await self.file.set_file_record(
654
+ url, owner_id=user.id, file_id=f_id, file_size=file_size,
655
+ permission=permission, external=True)
656
+ await self.user.set_active(user.username)
657
+
658
+ async def read_file_stream(self, url: str) -> AsyncIterable[bytes]:
659
+ validate_url(url)
660
+ r = await self.file.get_file_record(url)
661
+ if r is None:
662
+ raise FileNotFoundError(f"File {url} not found")
663
+ if not r.external:
664
+ raise ValueError(f"File {url} is not stored externally, should use read_file instead")
665
+ return self.file.get_file_blob_external(r.file_id)
576
666
 
577
- # async def read_file_stream(self, url: str): ...
578
667
  async def read_file(self, url: str) -> bytes:
579
668
  validate_url(url)
580
669
 
581
670
  r = await self.file.get_file_record(url)
582
671
  if r is None:
583
672
  raise FileNotFoundError(f"File {url} not found")
673
+ if r.external:
674
+ raise ValueError(f"File {url} is stored externally, should use read_file_stream instead")
584
675
 
585
676
  f_id = r.file_id
586
677
  blob = await self.file.get_file_blob(f_id)
@@ -600,8 +691,11 @@ class Database:
600
691
  if r is None:
601
692
  return None
602
693
  f_id = r.file_id
603
- await self.file.delete_file_blob(f_id)
604
694
  await self.file.delete_file_record(url)
695
+ if r.external:
696
+ await self.file.delete_file_blob_external(f_id)
697
+ else:
698
+ await self.file.delete_file_blob(f_id)
605
699
  return r
606
700
 
607
701
  async def move_file(self, old_url: str, new_url: str):
@@ -611,17 +705,33 @@ class Database:
611
705
  async with transaction(self):
612
706
  await self.file.move_file(old_url, new_url)
613
707
 
708
+ async def __batch_delete_file_blobs(self, file_records: list[FileRecord], batch_size: int = 512):
709
+ # https://github.com/langchain-ai/langchain/issues/10321
710
+ internal_ids = []
711
+ external_ids = []
712
+ for r in file_records:
713
+ if r.external:
714
+ external_ids.append(r.file_id)
715
+ else:
716
+ internal_ids.append(r.file_id)
717
+
718
+ for i in range(0, len(internal_ids), batch_size):
719
+ await self.file.delete_file_blobs([r for r in internal_ids[i:i+batch_size]])
720
+ for i in range(0, len(external_ids)):
721
+ await self.file.delete_file_blob_external(external_ids[i])
722
+
723
+
614
724
  async def delete_path(self, url: str):
615
725
  validate_url(url, is_file=False)
616
726
 
617
727
  async with transaction(self):
618
- records = await self.file.get_path_records(url)
728
+ records = await self.file.get_path_file_records(url)
619
729
  if not records:
620
730
  return None
621
- await self.file.delete_file_blobs([r.file_id for r in records])
731
+ await self.__batch_delete_file_blobs(records)
622
732
  await self.file.delete_path_records(url)
623
733
  return records
624
-
734
+
625
735
  async def delete_user(self, u: str | int):
626
736
  user = await get_user(self, u)
627
737
  if user is None:
@@ -629,11 +739,11 @@ class Database:
629
739
 
630
740
  async with transaction(self):
631
741
  records = await self.file.get_user_file_records(user.id)
632
- await self.file.delete_file_blobs([r.file_id for r in records])
742
+ await self.__batch_delete_file_blobs(records)
633
743
  await self.file.delete_user_file_records(user.id)
634
744
  await self.user.delete_user(user.username)
635
745
 
636
- async def iter_path(self, top_url: str, urls: Optional[list[str]]) -> AsyncIterable[tuple[FileRecord, bytes]]:
746
+ async def iter_path(self, top_url: str, urls: Optional[list[str]]) -> AsyncIterable[tuple[FileRecord, bytes | AsyncIterable[bytes]]]:
637
747
  if urls is None:
638
748
  urls = [r.url for r in await self.file.list_path(top_url, flat=True)]
639
749
 
@@ -644,10 +754,13 @@ class Database:
644
754
  if r is None:
645
755
  continue
646
756
  f_id = r.file_id
647
- blob = await self.file.get_file_blob(f_id)
648
- if blob is None:
649
- self.logger.warning(f"Blob not found for {url}")
650
- continue
757
+ if r.external:
758
+ blob = self.file.get_file_blob_external(f_id)
759
+ else:
760
+ blob = await self.file.get_file_blob(f_id)
761
+ if blob is None:
762
+ self.logger.warning(f"Blob not found for {url}")
763
+ continue
651
764
  yield r, blob
652
765
 
653
766
  async def zip_path(self, top_url: str, urls: Optional[list[str]]) -> io.BytesIO:
@@ -658,7 +771,12 @@ class Database:
658
771
  async for (r, blob) in self.iter_path(top_url, urls):
659
772
  rel_path = r.url[len(top_url):]
660
773
  rel_path = decode_uri_compnents(rel_path)
661
- zf.writestr(rel_path, blob)
774
+ if r.external:
775
+ assert isinstance(blob, AsyncIterable)
776
+ zf.writestr(rel_path, b''.join([chunk async for chunk in blob]))
777
+ else:
778
+ assert isinstance(blob, bytes)
779
+ zf.writestr(rel_path, blob)
662
780
  buffer.seek(0)
663
781
  return buffer
664
782
 
lfss/src/error.py CHANGED
@@ -1,6 +1,8 @@
1
1
 
2
2
  class LFSSExceptionBase(Exception):...
3
3
 
4
+ class PathNotFoundError(LFSSExceptionBase, FileNotFoundError):...
5
+
4
6
  class PermissionDeniedError(LFSSExceptionBase, PermissionError):...
5
7
 
6
8
  class InvalidPathError(LFSSExceptionBase, ValueError):...
lfss/src/server.py CHANGED
@@ -2,6 +2,7 @@ from typing import Optional
2
2
  from functools import wraps
3
3
 
4
4
  from fastapi import FastAPI, APIRouter, Depends, Request, Response
5
+ from fastapi.responses import StreamingResponse
5
6
  from fastapi.exceptions import HTTPException
6
7
  from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
7
8
  from fastapi.middleware.cors import CORSMiddleware
@@ -14,7 +15,7 @@ from contextlib import asynccontextmanager
14
15
  from .error import *
15
16
  from .log import get_logger
16
17
  from .stat import RequestDB
17
- from .config import MAX_BUNDLE_BYTES, MAX_FILE_BYTES
18
+ from .config import MAX_BUNDLE_BYTES, MAX_FILE_BYTES, LARGE_BLOB_DIR, LARGE_FILE_BYTES
18
19
  from .utils import ensure_uri_compnents, format_last_modified, now_stamp
19
20
  from .database import Database, UserRecord, DECOY_USER, FileRecord, check_user_permission, FileReadPermission
20
21
 
@@ -141,19 +142,32 @@ async def get_file(path: str, download = False, user: UserRecord = Depends(get_c
141
142
 
142
143
  fname = path.split("/")[-1]
143
144
  async def send(media_type: Optional[str] = None, disposition = "attachment"):
144
- fblob = await conn.read_file(path)
145
- if media_type is None:
146
- media_type, _ = mimetypes.guess_type(fname)
147
- if media_type is None:
148
- media_type = mimesniff.what(fblob)
149
-
150
- return Response(
151
- content=fblob, media_type=media_type, headers={
152
- "Content-Disposition": f"{disposition}; filename={fname}",
153
- "Content-Length": str(len(fblob)),
154
- "Last-Modified": format_last_modified(file_record.create_time)
155
- }
156
- )
145
+ if not file_record.external:
146
+ fblob = await conn.read_file(path)
147
+ if media_type is None:
148
+ media_type, _ = mimetypes.guess_type(fname)
149
+ if media_type is None:
150
+ media_type = mimesniff.what(fblob)
151
+ return Response(
152
+ content=fblob, media_type=media_type, headers={
153
+ "Content-Disposition": f"{disposition}; filename={fname}",
154
+ "Content-Length": str(len(fblob)),
155
+ "Last-Modified": format_last_modified(file_record.create_time)
156
+ }
157
+ )
158
+
159
+ else:
160
+ if media_type is None:
161
+ media_type, _ = mimetypes.guess_type(fname)
162
+ if media_type is None:
163
+ media_type = mimesniff.what(str((LARGE_BLOB_DIR / file_record.file_id).absolute()))
164
+ return StreamingResponse(
165
+ await conn.read_file_stream(path), media_type=media_type, headers={
166
+ "Content-Disposition": f"{disposition}; filename={fname}",
167
+ "Content-Length": str(file_record.file_size),
168
+ "Last-Modified": format_last_modified(file_record.create_time)
169
+ }
170
+ )
157
171
 
158
172
  if download:
159
173
  return await send('application/octet-stream', "attachment")
@@ -162,7 +176,12 @@ async def get_file(path: str, download = False, user: UserRecord = Depends(get_c
162
176
 
163
177
  @router_fs.put("/{path:path}")
164
178
  @handle_exception
165
- async def put_file(request: Request, path: str, user: UserRecord = Depends(get_current_user)):
179
+ async def put_file(
180
+ request: Request,
181
+ path: str,
182
+ overwrite: Optional[bool] = False,
183
+ permission: int = 0,
184
+ user: UserRecord = Depends(get_current_user)):
166
185
  path = ensure_uri_compnents(path)
167
186
  if user.id == 0:
168
187
  logger.debug("Reject put request from DECOY_USER")
@@ -182,8 +201,10 @@ async def put_file(request: Request, path: str, user: UserRecord = Depends(get_c
182
201
  exists_flag = False
183
202
  file_record = await conn.file.get_file_record(path)
184
203
  if file_record:
185
- exists_flag = True
204
+ if not overwrite:
205
+ raise HTTPException(status_code=409, detail="File exists")
186
206
  # remove the old file
207
+ exists_flag = True
187
208
  await conn.delete_file(path)
188
209
 
189
210
  # check content-type
@@ -191,20 +212,25 @@ async def put_file(request: Request, path: str, user: UserRecord = Depends(get_c
191
212
  logger.debug(f"Content-Type: {content_type}")
192
213
  if content_type == "application/json":
193
214
  body = await request.json()
194
- await conn.save_file(user.id, path, json.dumps(body).encode('utf-8'))
215
+ blobs = json.dumps(body).encode('utf-8')
195
216
  elif content_type == "application/x-www-form-urlencoded":
196
217
  # may not work...
197
218
  body = await request.form()
198
219
  file = body.get("file")
199
220
  if isinstance(file, str) or file is None:
200
221
  raise HTTPException(status_code=400, detail="Invalid form data, file required")
201
- await conn.save_file(user.id, path, await file.read())
222
+ blobs = await file.read()
202
223
  elif content_type == "application/octet-stream":
203
- body = await request.body()
204
- await conn.save_file(user.id, path, body)
224
+ blobs = await request.body()
225
+ else:
226
+ blobs = await request.body()
227
+ if len(blobs) > LARGE_FILE_BYTES:
228
+ async def blob_reader():
229
+ for b in range(0, len(blobs), 4096):
230
+ yield blobs[b:b+4096]
231
+ await conn.save_file(user.id, path, blob_reader(), permission = FileReadPermission(permission))
205
232
  else:
206
- body = await request.body()
207
- await conn.save_file(user.id, path, body)
233
+ await conn.save_file(user.id, path, blobs, permission = FileReadPermission(permission))
208
234
 
209
235
  # https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Methods/PUT
210
236
  if exists_flag:
@@ -282,19 +308,18 @@ async def bundle_files(path: str, user: UserRecord = Depends(get_current_user)):
282
308
  }
283
309
  )
284
310
 
285
- @router_api.get("/fmeta")
311
+ @router_api.get("/meta")
286
312
  @handle_exception
287
313
  async def get_file_meta(path: str, user: UserRecord = Depends(get_current_user)):
288
314
  logger.info(f"GET meta({path}), user: {user.username}")
289
- if path.endswith("/"):
290
- raise HTTPException(status_code=400, detail="Invalid path")
291
315
  path = ensure_uri_compnents(path)
292
- file_record = await conn.file.get_file_record(path)
293
- if not file_record:
294
- raise HTTPException(status_code=404, detail="File not found")
295
- return file_record
316
+ get_fn = conn.file.get_file_record if not path.endswith("/") else conn.file.get_path_record
317
+ record = await get_fn(path)
318
+ if not record:
319
+ raise HTTPException(status_code=404, detail="Path not found")
320
+ return record
296
321
 
297
- @router_api.post("/fmeta")
322
+ @router_api.post("/meta")
298
323
  @handle_exception
299
324
  async def update_file_meta(
300
325
  path: str,
lfss/src/stat.py CHANGED
@@ -32,7 +32,7 @@ class RequestDB:
32
32
  async def commit(self):
33
33
  await self.conn.commit()
34
34
 
35
- @debounce_async(0.1)
35
+ @debounce_async(0.05)
36
36
  async def ensure_commit_once(self):
37
37
  await self.commit()
38
38
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: lfss
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: Lightweight file storage service
5
5
  Home-page: https://github.com/MenxLi/lfss
6
6
  Author: li, mengxun
@@ -11,6 +11,7 @@ Classifier: Programming Language :: Python :: 3.9
11
11
  Classifier: Programming Language :: Python :: 3.10
12
12
  Classifier: Programming Language :: Python :: 3.11
13
13
  Classifier: Programming Language :: Python :: 3.12
14
+ Requires-Dist: aiofiles (==23.*)
14
15
  Requires-Dist: aiosqlite (==0.*)
15
16
  Requires-Dist: fastapi (==0.*)
16
17
  Requires-Dist: mimesniff (==1.*)
@@ -0,0 +1,28 @@
1
+ Readme.md,sha256=8ZAADV1K20gEDhwiL-Qq_rBAePlwWQn8c5uJ_X7OCIg,1128
2
+ docs/Known_issues.md,sha256=rfdG3j1OJF-59S9E06VPyn0nZKbW-ybPxkoZ7MEZWp8,81
3
+ docs/Permission.md,sha256=EY1Y4tT5PDdHDb2pXsVgMAJXxkUihTZfPT0_z7Q53FA,1485
4
+ frontend/api.js,sha256=aIeeaHlkxKUAHLI-BxYxqYqyfJIRxmeFPdXLYyMSdbs,7682
5
+ frontend/index.html,sha256=VPJDs2LG8ep9kjlsKzjWzpN9vc1VGgdvOUlNTZWyQoQ,2088
6
+ frontend/popup.css,sha256=VzkjG1ZTLxhHMtTyobnlvqYmVsTmdbJJed2Pu1cc06c,1007
7
+ frontend/popup.js,sha256=dH5n7C2Vo9gCsMfQ4ajL4D1ETl0Wk9rIldxUb7x0f_c,4634
8
+ frontend/scripts.js,sha256=znnV43tRupV7-GuZUlYIkNpi49J9E0DzYdlodSlTO9s,19486
9
+ frontend/styles.css,sha256=kN2XmEeRXLMyZQowSXYPhD4_1XVXgKwTKdTdtAKuX0E,4102
10
+ frontend/utils.js,sha256=Ts4nlef8pkrEgpwX-uQwAhWvwxlIzex8ijDLNCa22ps,2372
11
+ lfss/cli/cli.py,sha256=2sL0RcJWjnMUjYwSHxO5m_oJP-61WUFJZC9eH3m-Jq8,2223
12
+ lfss/cli/panel.py,sha256=iGdVmdWYjA_7a78ZzWEB_3ggIOBeUKTzg6F5zLaB25c,1401
13
+ lfss/cli/serve.py,sha256=bO3GT0kuylMGN-7bZWP4e71MlugGZ_lEMkYaYld_Ntg,985
14
+ lfss/cli/user.py,sha256=uSNgF8wGYpdOowN8Mah3V_ii6NlxRMNecrrVj3HaemU,3328
15
+ lfss/client/__init__.py,sha256=YttaGTBup7mxnW0CtxdZPF0HWga2cGM4twz9MXJIrts,1827
16
+ lfss/client/api.py,sha256=L2LrnUO7Ef4mUlvZ40_GLu0pqf9wlFgieHgaQXP0Dk0,3827
17
+ lfss/src/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
18
+ lfss/src/config.py,sha256=i96PgONQnsmQ34hcqRXUcmo_Ullq2aRQvHbD54pIguM,447
19
+ lfss/src/database.py,sha256=XproulWille_i_aRr3IFE0isRfL98WgY6y6MRcOlS1o,33473
20
+ lfss/src/error.py,sha256=imbhwnbhnI3HLhkbfICROe3F0gleKrOk4XnqHJDOtuI,285
21
+ lfss/src/log.py,sha256=qNE04sHoZ2rMbQ5dR2zT7Xaz1KVAfYp5hKpWJX42S9g,5244
22
+ lfss/src/server.py,sha256=Zl5O3KY16ASB674xaY3YmqnyQ_8d4MLjqZwtcz4Phgs,14345
23
+ lfss/src/stat.py,sha256=hTMtQyM_Ukmhc33Bb9FGCfBMIX02KrGHQg8nL7sC8sU,2082
24
+ lfss/src/utils.py,sha256=8VkrtpSmurbMiX7GyK-n7Grvzy3uwSJXHdONEsuLCDI,2272
25
+ lfss-0.5.0.dist-info/METADATA,sha256=0z_QH6HMzuhFHQwYUapOTACU1tqqrEL_D86ekyYJABc,1820
26
+ lfss-0.5.0.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
27
+ lfss-0.5.0.dist-info/entry_points.txt,sha256=_FXOyxodFtVBaxtBhdHcupqW_IolIIx-S6y6p7CkDFk,137
28
+ lfss-0.5.0.dist-info/RECORD,,
@@ -1,4 +1,5 @@
1
1
  [console_scripts]
2
+ lfss-cli=lfss.cli.cli:main
2
3
  lfss-panel=lfss.cli.panel:main
3
4
  lfss-serve=lfss.cli.serve:main
4
5
  lfss-user=lfss.cli.user:main
@@ -1,27 +0,0 @@
1
- Readme.md,sha256=8ZAADV1K20gEDhwiL-Qq_rBAePlwWQn8c5uJ_X7OCIg,1128
2
- docs/Known_issues.md,sha256=rfdG3j1OJF-59S9E06VPyn0nZKbW-ybPxkoZ7MEZWp8,81
3
- docs/Permission.md,sha256=EY1Y4tT5PDdHDb2pXsVgMAJXxkUihTZfPT0_z7Q53FA,1485
4
- frontend/api.js,sha256=D_MaQlmBGzzSR30a23Kh3DxPeF8MpKYe5uXSb9srRTU,7281
5
- frontend/index.html,sha256=VPJDs2LG8ep9kjlsKzjWzpN9vc1VGgdvOUlNTZWyQoQ,2088
6
- frontend/popup.css,sha256=VzkjG1ZTLxhHMtTyobnlvqYmVsTmdbJJed2Pu1cc06c,1007
7
- frontend/popup.js,sha256=dH5n7C2Vo9gCsMfQ4ajL4D1ETl0Wk9rIldxUb7x0f_c,4634
8
- frontend/scripts.js,sha256=1Cd5cb84SWolozA0Q1MpWaXiAqeQ5R6qZyk7gdoRPgU,18266
9
- frontend/styles.css,sha256=Ql_-W5dbh6LlJL2Kez8qsqPSF2fckFgmOYP4SMfKJVs,4065
10
- frontend/utils.js,sha256=biE2te5ezswZyuwlDTYbHEt7VWKfCcUrusNt1lHjkLw,2263
11
- lfss/cli/panel.py,sha256=iGdVmdWYjA_7a78ZzWEB_3ggIOBeUKTzg6F5zLaB25c,1401
12
- lfss/cli/serve.py,sha256=bO3GT0kuylMGN-7bZWP4e71MlugGZ_lEMkYaYld_Ntg,985
13
- lfss/cli/user.py,sha256=906MIQO1mr4XXzPpT8Kfri1G61bWlgjDzIglxypQ7z0,3251
14
- lfss/client/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
- lfss/client/api.py,sha256=VBiU-JxGaN6S-fXYD_KvzG7FsGBIUzZ5IisJJSMooDk,3443
16
- lfss/src/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
17
- lfss/src/config.py,sha256=mc8QoiWoPgiZ5JnSvKBH8jWbp5uLSyG3l5boDiQPXS0,325
18
- lfss/src/database.py,sha256=wNDsvjvr7Jq1gDeUd_26tETcfeiplAmywIwXLK1TD80,27923
19
- lfss/src/error.py,sha256=S5ui3tJ0uKX4EZnt2Db-KbDmJXlECI_OY95cNMkuegc,218
20
- lfss/src/log.py,sha256=qNE04sHoZ2rMbQ5dR2zT7Xaz1KVAfYp5hKpWJX42S9g,5244
21
- lfss/src/server.py,sha256=P4GpsIJ869fi6lzHk2IVMyJooGWIvqh0cykkDerJ74k,13190
22
- lfss/src/stat.py,sha256=_4OaSvBm7D6mPgifwxnhGIEk1_q3SxfJr3lizaEoV_w,2081
23
- lfss/src/utils.py,sha256=8VkrtpSmurbMiX7GyK-n7Grvzy3uwSJXHdONEsuLCDI,2272
24
- lfss-0.4.0.dist-info/METADATA,sha256=X0qXQ4gkl_sJ5VNEGLJ0XG0JSqmNnOPixHVn6ZQ7pj0,1787
25
- lfss-0.4.0.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
26
- lfss-0.4.0.dist-info/entry_points.txt,sha256=nUIJhenyZbcymvPVhrzV7SAtRhd7O52DflbRrpQUC04,110
27
- lfss-0.4.0.dist-info/RECORD,,
File without changes