lfss 0.7.15__py3-none-any.whl → 0.8.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.
@@ -1,9 +1,13 @@
1
- from typing import Optional, Literal
1
+ from __future__ import annotations
2
+ from typing import Optional, Literal, Iterator
2
3
  import os
3
4
  import requests
5
+ import requests.adapters
4
6
  import urllib.parse
7
+ from lfss.src.error import PathNotFoundError
5
8
  from lfss.src.datatype import (
6
- FileReadPermission, FileRecord, DirectoryRecord, UserRecord, PathContents
9
+ FileReadPermission, FileRecord, DirectoryRecord, UserRecord, PathContents,
10
+ FileSortKey, DirSortKey
7
11
  )
8
12
  from lfss.src.utils import ensure_uri_compnents
9
13
 
@@ -11,12 +15,41 @@ _default_endpoint = os.environ.get('LFSS_ENDPOINT', 'http://localhost:8000')
11
15
  _default_token = os.environ.get('LFSS_TOKEN', '')
12
16
 
13
17
  class Connector:
18
+ class Session:
19
+ def __init__(self, connector: Connector, pool_size: int = 10):
20
+ self.connector = connector
21
+ self.pool_size = pool_size
22
+ def open(self):
23
+ self.close()
24
+ if self.connector._session is None:
25
+ s = requests.Session()
26
+ adapter = requests.adapters.HTTPAdapter(pool_connections=self.pool_size, pool_maxsize=self.pool_size)
27
+ s.mount('http://', adapter)
28
+ s.mount('https://', adapter)
29
+ self.connector._session = s
30
+ def close(self):
31
+ if self.connector._session is not None:
32
+ self.connector._session.close()
33
+ self.connector._session = None
34
+ def __call__(self):
35
+ return self.connector
36
+ def __enter__(self):
37
+ self.open()
38
+ return self.connector
39
+ def __exit__(self, exc_type, exc_value, traceback):
40
+ self.close()
41
+
14
42
  def __init__(self, endpoint=_default_endpoint, token=_default_token):
15
43
  assert token, "No token provided. Please set LFSS_TOKEN environment variable."
16
44
  self.config = {
17
45
  "endpoint": endpoint,
18
46
  "token": token
19
47
  }
48
+ self._session: Optional[requests.Session] = None
49
+
50
+ def session(self, pool_size: int = 10):
51
+ """ avoid creating a new session for each request. """
52
+ return self.Session(self, pool_size)
20
53
 
21
54
  def _fetch_factory(
22
55
  self, method: Literal['GET', 'POST', 'PUT', 'DELETE'],
@@ -31,9 +64,13 @@ class Connector:
31
64
  headers.update({
32
65
  'Authorization': f"Bearer {self.config['token']}",
33
66
  })
34
- with requests.Session() as s:
35
- response = s.request(method, url, headers=headers, **kwargs)
67
+ if self._session is not None:
68
+ response = self._session.request(method, url, headers=headers, **kwargs)
36
69
  response.raise_for_status()
70
+ else:
71
+ with requests.Session() as s:
72
+ response = s.request(method, url, headers=headers, **kwargs)
73
+ response.raise_for_status()
37
74
  return response
38
75
  return f
39
76
 
@@ -80,9 +117,9 @@ class Connector:
80
117
  )
81
118
  return response.json()
82
119
 
83
- def _get(self, path: str) -> Optional[requests.Response]:
120
+ def _get(self, path: str, stream: bool = False) -> Optional[requests.Response]:
84
121
  try:
85
- response = self._fetch_factory('GET', path)()
122
+ response = self._fetch_factory('GET', path)(stream=stream)
86
123
  except requests.exceptions.HTTPError as e:
87
124
  if e.response.status_code == 404:
88
125
  return None
@@ -94,6 +131,12 @@ class Connector:
94
131
  response = self._get(path)
95
132
  if response is None: return None
96
133
  return response.content
134
+
135
+ def get_stream(self, path: str) -> Iterator[bytes]:
136
+ """Downloads a file from the specified path, will raise PathNotFoundError if path not found."""
137
+ response = self._get(path, stream=True)
138
+ if response is None: raise PathNotFoundError("Path not found: " + path)
139
+ return response.iter_content(chunk_size=1024)
97
140
 
98
141
  def get_json(self, path: str) -> Optional[dict]:
99
142
  response = self._get(path)
@@ -118,12 +161,50 @@ class Connector:
118
161
  return None
119
162
  raise e
120
163
 
121
- def list_path(self, path: str, flat: bool = False) -> PathContents:
164
+ def list_path(self, path: str) -> PathContents:
165
+ """
166
+ shorthand list with limited options,
167
+ for large directories / more options, use list_files and list_dirs instead.
168
+ """
122
169
  assert path.endswith('/')
123
- response = self._fetch_factory('GET', path, {'flat': flat})()
170
+ response = self._fetch_factory('GET', path)()
124
171
  dirs = [DirectoryRecord(**d) for d in response.json()['dirs']]
125
172
  files = [FileRecord(**f) for f in response.json()['files']]
126
173
  return PathContents(dirs=dirs, files=files)
174
+
175
+ def count_files(self, path: str, flat: bool = False) -> int:
176
+ assert path.endswith('/')
177
+ response = self._fetch_factory('GET', '_api/count-files', {'path': path, 'flat': flat})()
178
+ return response.json()['count']
179
+
180
+ def list_files(
181
+ self, path: str, offset: int = 0, limit: int = 1000,
182
+ order_by: FileSortKey = '', order_desc: bool = False,
183
+ flat: bool = False
184
+ ) -> list[FileRecord]:
185
+ assert path.endswith('/')
186
+ response = self._fetch_factory('GET', "_api/list-files", {
187
+ 'path': path,
188
+ 'offset': offset, 'limit': limit, 'order_by': order_by, 'order_desc': order_desc, 'flat': flat
189
+ })()
190
+ return [FileRecord(**f) for f in response.json()]
191
+
192
+ def count_dirs(self, path: str) -> int:
193
+ assert path.endswith('/')
194
+ response = self._fetch_factory('GET', '_api/count-dirs', {'path': path})()
195
+ return response.json()['count']
196
+
197
+ def list_dirs(
198
+ self, path: str, offset: int = 0, limit: int = 1000,
199
+ order_by: DirSortKey = '', order_desc: bool = False,
200
+ skim: bool = True
201
+ ) -> list[DirectoryRecord]:
202
+ assert path.endswith('/')
203
+ response = self._fetch_factory('GET', "_api/list-dirs", {
204
+ 'path': path,
205
+ 'offset': offset, 'limit': limit, 'order_by': order_by, 'order_desc': order_desc, 'skim': skim
206
+ })()
207
+ return [DirectoryRecord(**d) for d in response.json()]
127
208
 
128
209
  def set_file_permission(self, path: str, permission: int | FileReadPermission):
129
210
  """Sets the file permission for the specified path."""
lfss/cli/cli.py CHANGED
@@ -1,4 +1,4 @@
1
- from lfss.client import Connector, upload_directory, upload_file, download_file, download_directory
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=int, default=None)
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')
@@ -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 = Semaphore(1)
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)
lfss/src/database.py CHANGED
@@ -10,10 +10,13 @@ import aiosqlite, aiofiles
10
10
  import aiofiles.os
11
11
 
12
12
  from .connection_pool import execute_sql, unique_cursor, transaction
13
- from .datatype import UserRecord, FileReadPermission, FileRecord, DirectoryRecord, PathContents
13
+ from .datatype import (
14
+ UserRecord, FileReadPermission, FileRecord, DirectoryRecord, PathContents,
15
+ FileSortKey, DirSortKey, isValidFileSortKey, isValidDirSortKey
16
+ )
14
17
  from .config import LARGE_BLOB_DIR, CHUNK_SIZE
15
18
  from .log import get_logger
16
- from .utils import decode_uri_compnents, hash_credential, concurrent_wrap
19
+ from .utils import decode_uri_compnents, hash_credential, concurrent_wrap, debounce_async
17
20
  from .error import *
18
21
 
19
22
  class DBObjectBase(ABC):
@@ -156,55 +159,108 @@ class FileConn(DBObjectBase):
156
159
  dirs = [await self.get_path_record(u) for u in dirnames] if not skim else [DirectoryRecord(u) for u in dirnames]
157
160
  return dirs
158
161
 
159
- async def list_path(self, url: str, flat: bool = False) -> PathContents:
160
- """
161
- List all files and directories under the given path
162
- if flat is True, list all files under the path, with out delimiting directories
163
- """
164
- self.logger.debug(f"Listing path {url}, flat={flat}")
165
- if not url.endswith('/'):
166
- url += '/'
167
- if url == '/':
168
- # users cannot be queried using '/', because we store them without '/' prefix,
169
- # so we need to handle this case separately,
170
- if flat:
171
- cursor = await self.cur.execute("SELECT * FROM fmeta")
172
- res = await cursor.fetchall()
173
- files = [self.parse_record(r) for r in res]
174
- return PathContents([], files)
162
+ async def count_path_dirs(self, url: str):
163
+ if not url.endswith('/'): url += '/'
164
+ if url == '/': url = ''
165
+ cursor = await self.cur.execute("""
166
+ SELECT COUNT(*) FROM (
167
+ SELECT DISTINCT SUBSTR(
168
+ url, LENGTH(?) + 1,
169
+ INSTR(SUBSTR(url, LENGTH(?) + 1), '/')
170
+ ) AS dirname
171
+ FROM fmeta WHERE url LIKE ? AND dirname != ''
172
+ )
173
+ """, (url, url, url + '%'))
174
+ res = await cursor.fetchone()
175
+ assert res is not None, "Error: count_path_dirs"
176
+ return res[0]
175
177
 
178
+ async def list_path_dirs(
179
+ self, url: str,
180
+ offset: int = 0, limit: int = int(1e5),
181
+ order_by: DirSortKey = '', order_desc: bool = False,
182
+ skim: bool = True
183
+ ) -> list[DirectoryRecord]:
184
+ if not isValidDirSortKey(order_by):
185
+ raise ValueError(f"Invalid order_by ({order_by})")
186
+
187
+ if not url.endswith('/'): url += '/'
188
+ if url == '/': url = ''
189
+
190
+ sql_qury = """
191
+ SELECT DISTINCT SUBSTR(
192
+ url,
193
+ 1 + LENGTH(?),
194
+ INSTR(SUBSTR(url, 1 + LENGTH(?)), '/')
195
+ ) AS dirname
196
+ FROM fmeta WHERE url LIKE ? AND dirname != ''
197
+ """ \
198
+ + (f"ORDER BY {order_by} {'DESC' if order_desc else 'ASC'}" if order_by else '') \
199
+ + " LIMIT ? OFFSET ?"
200
+ cursor = await self.cur.execute(sql_qury, (url, url, url + '%', limit, offset))
201
+ res = await cursor.fetchall()
202
+ dirs_str = [r[0] for r in res]
203
+ async def get_dir(dir_url):
204
+ if skim:
205
+ return DirectoryRecord(dir_url)
176
206
  else:
177
- return PathContents(await self.list_root_dirs(), [])
178
-
207
+ return await self.get_path_record(dir_url)
208
+ dirs = await asyncio.gather(*[get_dir(url + d) for d in dirs_str])
209
+ return dirs
210
+
211
+ async def count_path_files(self, url: str, flat: bool = False):
212
+ if not url.endswith('/'): url += '/'
213
+ if url == '/': url = ''
179
214
  if flat:
180
- cursor = await self.cur.execute("SELECT * FROM fmeta WHERE url LIKE ?", (url + '%', ))
181
- res = await cursor.fetchall()
182
- files = [self.parse_record(r) for r in res]
183
- return PathContents([], files)
215
+ cursor = await self.cur.execute("SELECT COUNT(*) FROM fmeta WHERE url LIKE ?", (url + '%', ))
216
+ else:
217
+ cursor = await self.cur.execute("SELECT COUNT(*) FROM fmeta WHERE url LIKE ? AND url NOT LIKE ?", (url + '%', url + '%/%'))
218
+ res = await cursor.fetchone()
219
+ assert res is not None, "Error: count_path_files"
220
+ return res[0]
184
221
 
185
- cursor = await self.cur.execute("SELECT * FROM fmeta WHERE url LIKE ? AND url NOT LIKE ?", (url + '%', url + '%/%'))
222
+ async def list_path_files(
223
+ self, url: str,
224
+ offset: int = 0, limit: int = int(1e5),
225
+ order_by: FileSortKey = '', order_desc: bool = False,
226
+ flat: bool = False,
227
+ ) -> list[FileRecord]:
228
+ if not isValidFileSortKey(order_by):
229
+ raise ValueError(f"Invalid order_by {order_by}")
230
+
231
+ if not url.endswith('/'): url += '/'
232
+ if url == '/': url = ''
233
+
234
+ sql_query = "SELECT * FROM fmeta WHERE url LIKE ?"
235
+ if not flat: sql_query += " AND url NOT LIKE ?"
236
+ if order_by: sql_query += f" ORDER BY {order_by} {'DESC' if order_desc else 'ASC'}"
237
+ sql_query += " LIMIT ? OFFSET ?"
238
+ if flat:
239
+ cursor = await self.cur.execute(sql_query, (url + '%', limit, offset))
240
+ else:
241
+ cursor = await self.cur.execute(sql_query, (url + '%', url + '%/%', limit, offset))
186
242
  res = await cursor.fetchall()
187
243
  files = [self.parse_record(r) for r in res]
188
-
189
- # substr indexing starts from 1
190
- cursor = await self.cur.execute(
191
- """
192
- SELECT DISTINCT
193
- SUBSTR(
194
- url,
195
- 1 + LENGTH(?),
196
- INSTR(SUBSTR(url, 1 + LENGTH(?)), '/') - 1
197
- ) AS subdir
198
- FROM fmeta WHERE url LIKE ?
199
- """,
200
- (url, url, url + '%')
244
+ return files
245
+
246
+ async def list_path(self, url: str) -> PathContents:
247
+ """
248
+ List all files and directories under the given path.
249
+ This method is a handy way file browsing, but has limitaions:
250
+ - It does not support pagination
251
+ - It does not support sorting
252
+ - It cannot flatten directories
253
+ - It cannot list directories with details
254
+ """
255
+ MAX_ITEMS = int(1e4)
256
+ dir_count = await self.count_path_dirs(url)
257
+ file_count = await self.count_path_files(url, flat=False)
258
+ if dir_count + file_count > MAX_ITEMS:
259
+ raise TooManyItemsError("Too many items, please paginate")
260
+ return PathContents(
261
+ dirs = await self.list_path_dirs(url, skim=True, limit=MAX_ITEMS),
262
+ files = await self.list_path_files(url, flat=False, limit=MAX_ITEMS)
201
263
  )
202
- res = await cursor.fetchall()
203
- dirs_str = [r[0] + '/' for r in res if r[0] != '/']
204
- async def get_dir(dir_url):
205
- return DirectoryRecord(dir_url, -1)
206
- dirs = await asyncio.gather(*[get_dir(url + d) for d in dirs_str])
207
- return PathContents(dirs, files)
208
264
 
209
265
  async def get_path_record(self, url: str) -> DirectoryRecord:
210
266
  """
@@ -361,7 +417,8 @@ class FileConn(DBObjectBase):
361
417
  async def set_file_blob(self, file_id: str, blob: bytes):
362
418
  await self.cur.execute("INSERT OR REPLACE INTO blobs.fdata (file_id, data) VALUES (?, ?)", (file_id, blob))
363
419
 
364
- async def set_file_blob_external(self, file_id: str, stream: AsyncIterable[bytes])->int:
420
+ @staticmethod
421
+ async def set_file_blob_external(file_id: str, stream: AsyncIterable[bytes])->int:
365
422
  size_sum = 0
366
423
  try:
367
424
  async with aiofiles.open(LARGE_BLOB_DIR / file_id, 'wb') as f:
@@ -389,7 +446,8 @@ class FileConn(DBObjectBase):
389
446
  if not chunk: break
390
447
  yield chunk
391
448
 
392
- async def delete_file_blob_external(self, file_id: str):
449
+ @staticmethod
450
+ async def delete_file_blob_external(file_id: str):
393
451
  if (LARGE_BLOB_DIR / file_id).exists():
394
452
  await aiofiles.os.remove(LARGE_BLOB_DIR / file_id)
395
453
 
@@ -399,6 +457,36 @@ class FileConn(DBObjectBase):
399
457
  async def delete_file_blobs(self, file_ids: list[str]):
400
458
  await self.cur.execute("DELETE FROM blobs.fdata WHERE file_id IN ({})".format(','.join(['?'] * len(file_ids))), file_ids)
401
459
 
460
+ _log_active_queue = []
461
+ _log_active_lock = asyncio.Lock()
462
+ @debounce_async()
463
+ async def _set_all_active():
464
+ async with transaction() as conn:
465
+ uconn = UserConn(conn)
466
+ async with _log_active_lock:
467
+ for u in _log_active_queue:
468
+ await uconn.set_active(u)
469
+ _log_active_queue.clear()
470
+ async def delayed_log_activity(username: str):
471
+ async with _log_active_lock:
472
+ _log_active_queue.append(username)
473
+ await _set_all_active()
474
+
475
+ _log_access_queue = []
476
+ _log_access_lock = asyncio.Lock()
477
+ @debounce_async()
478
+ async def _log_all_access():
479
+ async with transaction() as conn:
480
+ fconn = FileConn(conn)
481
+ async with _log_access_lock:
482
+ for r in _log_access_queue:
483
+ await fconn.log_access(r)
484
+ _log_access_queue.clear()
485
+ async def delayed_log_access(url: str):
486
+ async with _log_access_lock:
487
+ _log_access_queue.append(url)
488
+ await _log_all_access()
489
+
402
490
  def validate_url(url: str, is_file = True):
403
491
  prohibited_chars = ['..', ';', "'", '"', '\\', '\0', '\n', '\r', '\t', '\x0b', '\x0c']
404
492
  ret = not url.startswith('/') and not url.startswith('_') and not url.startswith('.')
@@ -433,11 +521,6 @@ class Database:
433
521
  await execute_sql(conn, 'init.sql')
434
522
  return self
435
523
 
436
- async def record_user_activity(self, u: str):
437
- async with transaction() as conn:
438
- uconn = UserConn(conn)
439
- await uconn.set_active(u)
440
-
441
524
  async def update_file_record(self, user: UserRecord, url: str, permission: FileReadPermission):
442
525
  validate_url(url)
443
526
  async with transaction() as conn:
@@ -459,9 +542,7 @@ class Database:
459
542
  if file_size is not provided, the blob must be bytes
460
543
  """
461
544
  validate_url(url)
462
- async with transaction() as cur:
463
- uconn = UserConn(cur)
464
- fconn = FileConn(cur)
545
+ async with unique_cursor() as cur:
465
546
  user = await get_user(cur, u)
466
547
  if user is None:
467
548
  return
@@ -477,27 +558,35 @@ class Database:
477
558
  if await get_user(cur, first_component) is None:
478
559
  raise PermissionDeniedError(f"Invalid path: {first_component} is not a valid username")
479
560
 
480
- user_size_used = await fconn.user_size(user.id)
561
+ fconn_r = FileConn(cur)
562
+ user_size_used = await fconn_r.user_size(user.id)
563
+
481
564
  if isinstance(blob, bytes):
482
565
  file_size = len(blob)
483
566
  if user_size_used + file_size > user.max_storage:
484
567
  raise StorageExceededError(f"Unable to save file, user {user.username} has storage limit of {user.max_storage}, used {user_size_used}, requested {file_size}")
485
568
  f_id = uuid.uuid4().hex
486
- await fconn.set_file_blob(f_id, blob)
487
- await fconn.set_file_record(
488
- url, owner_id=user.id, file_id=f_id, file_size=file_size,
489
- permission=permission, external=False, mime_type=mime_type)
569
+
570
+ async with transaction() as w_cur:
571
+ fconn_w = FileConn(w_cur)
572
+ await fconn_w.set_file_blob(f_id, blob)
573
+ await fconn_w.set_file_record(
574
+ url, owner_id=user.id, file_id=f_id, file_size=file_size,
575
+ permission=permission, external=False, mime_type=mime_type)
490
576
  else:
491
577
  assert isinstance(blob, AsyncIterable)
492
578
  f_id = uuid.uuid4().hex
493
- file_size = await fconn.set_file_blob_external(f_id, blob)
579
+ file_size = await FileConn.set_file_blob_external(f_id, blob)
494
580
  if user_size_used + file_size > user.max_storage:
495
- await fconn.delete_file_blob_external(f_id)
581
+ await FileConn.delete_file_blob_external(f_id)
496
582
  raise StorageExceededError(f"Unable to save file, user {user.username} has storage limit of {user.max_storage}, used {user_size_used}, requested {file_size}")
497
- await fconn.set_file_record(
498
- url, owner_id=user.id, file_id=f_id, file_size=file_size,
499
- permission=permission, external=True, mime_type=mime_type)
500
- await uconn.set_active(user.username)
583
+
584
+ async with transaction() as w_cur:
585
+ await FileConn(w_cur).set_file_record(
586
+ url, owner_id=user.id, file_id=f_id, file_size=file_size,
587
+ permission=permission, external=True, mime_type=mime_type)
588
+
589
+ await delayed_log_activity(user.username)
501
590
 
502
591
  async def read_file_stream(self, url: str) -> AsyncIterable[bytes]:
503
592
  validate_url(url)
@@ -510,9 +599,7 @@ class Database:
510
599
  raise ValueError(f"File {url} is not stored externally, should use read_file instead")
511
600
  ret = fconn.get_file_blob_external(r.file_id)
512
601
 
513
- async with transaction() as w_cur:
514
- await FileConn(w_cur).log_access(url)
515
-
602
+ await delayed_log_access(url)
516
603
  return ret
517
604
 
518
605
 
@@ -532,9 +619,7 @@ class Database:
532
619
  if blob is None:
533
620
  raise FileNotFoundError(f"File {url} data not found")
534
621
 
535
- async with transaction() as w_cur:
536
- await FileConn(w_cur).log_access(url)
537
-
622
+ await delayed_log_access(url)
538
623
  return blob
539
624
 
540
625
  async def delete_file(self, url: str, assure_user: Optional[UserRecord] = None) -> Optional[FileRecord]:
@@ -653,7 +738,8 @@ class Database:
653
738
  async with unique_cursor() as cur:
654
739
  fconn = FileConn(cur)
655
740
  if urls is None:
656
- urls = [r.url for r in (await fconn.list_path(top_url, flat=True)).files]
741
+ fcount = await fconn.count_path_files(top_url, flat=True)
742
+ urls = [r.url for r in (await fconn.list_path_files(top_url, flat=True, limit=fcount))]
657
743
 
658
744
  for url in urls:
659
745
  if not url.startswith(top_url):
lfss/src/datatype.py CHANGED
@@ -1,4 +1,5 @@
1
1
  from enum import IntEnum
2
+ from typing import Literal
2
3
  import dataclasses
3
4
 
4
5
  class FileReadPermission(IntEnum):
@@ -51,6 +52,10 @@ class DirectoryRecord:
51
52
 
52
53
  @dataclasses.dataclass
53
54
  class PathContents:
54
- dirs: list[DirectoryRecord]
55
- files: list[FileRecord]
56
-
55
+ dirs: list[DirectoryRecord] = dataclasses.field(default_factory=list)
56
+ files: list[FileRecord] = dataclasses.field(default_factory=list)
57
+
58
+ FileSortKey = Literal['', 'url', 'file_size', 'create_time', 'access_time', 'mime_type']
59
+ isValidFileSortKey = lambda x: x in ['', 'url', 'file_size', 'create_time', 'access_time', 'mime_type']
60
+ DirSortKey = Literal['', 'dirname']
61
+ isValidDirSortKey = lambda x: x in ['', 'dirname']
lfss/src/error.py CHANGED
@@ -7,4 +7,6 @@ class PermissionDeniedError(LFSSExceptionBase, PermissionError):...
7
7
 
8
8
  class InvalidPathError(LFSSExceptionBase, ValueError):...
9
9
 
10
- class StorageExceededError(LFSSExceptionBase):...
10
+ class StorageExceededError(LFSSExceptionBase):...
11
+
12
+ class TooManyItemsError(LFSSExceptionBase):...