lfss 0.9.2__py3-none-any.whl → 0.11.4__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.
lfss/eng/error.py CHANGED
@@ -6,16 +6,20 @@ class FileLockedError(LFSSExceptionBase):...
6
6
 
7
7
  class InvalidOptionsError(LFSSExceptionBase, ValueError):...
8
8
 
9
+ class InvalidDataError(LFSSExceptionBase, ValueError):...
10
+
11
+ class InvalidPathError(LFSSExceptionBase, ValueError):...
12
+
9
13
  class DatabaseLockedError(LFSSExceptionBase, sqlite3.DatabaseError):...
10
14
 
15
+ class DatabaseTransactionError(LFSSExceptionBase, sqlite3.DatabaseError):...
16
+
11
17
  class PathNotFoundError(LFSSExceptionBase, FileNotFoundError):...
12
18
 
13
19
  class FileDuplicateError(LFSSExceptionBase, FileExistsError):...
14
20
 
15
21
  class PermissionDeniedError(LFSSExceptionBase, PermissionError):...
16
22
 
17
- class InvalidPathError(LFSSExceptionBase, ValueError):...
18
-
19
23
  class StorageExceededError(LFSSExceptionBase):...
20
24
 
21
25
  class TooManyItemsError(LFSSExceptionBase):...
lfss/eng/log.py CHANGED
@@ -1,8 +1,9 @@
1
- from .config import DATA_HOME
1
+ from .config import LOG_DIR, DISABLE_LOGGING
2
+ import time, sqlite3, dataclasses
2
3
  from typing import TypeVar, Callable, Literal, Optional
3
4
  from concurrent.futures import ThreadPoolExecutor
4
5
  from functools import wraps
5
- import logging, pathlib, asyncio
6
+ import logging, asyncio
6
7
  from logging import handlers
7
8
 
8
9
  class BCOLORS:
@@ -57,15 +58,81 @@ class BaseLogger(logging.Logger):
57
58
  @thread_wrap
58
59
  def error(self, *args, **kwargs): super().error(*args, **kwargs)
59
60
 
60
- _fh_T = Literal['rotate', 'simple', 'daily']
61
+ class SQLiteFileHandler(logging.FileHandler):
62
+ def __init__(self, filename, *args, **kwargs):
63
+ super().__init__(filename, *args, **kwargs)
64
+ self._db_file = filename
65
+ self._buffer: list[logging.LogRecord] = []
66
+ self._buffer_size = 100
67
+ self._flush_interval = 10
68
+ self._last_flush = time.time()
69
+ conn = sqlite3.connect(self._db_file, check_same_thread=False)
70
+ conn.execute('PRAGMA journal_mode=WAL')
71
+ conn.execute('''
72
+ CREATE TABLE IF NOT EXISTS log (
73
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
74
+ created TIMESTAMP,
75
+ created_epoch FLOAT,
76
+ name TEXT,
77
+ levelname VARCHAR(16),
78
+ level INTEGER,
79
+ message TEXT
80
+ )
81
+ ''')
82
+ conn.commit()
83
+ conn.close()
84
+
85
+ def flush(self):
86
+ def format_time(self, record: logging.LogRecord):
87
+ """ Create a time stamp """
88
+ return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(record.created))
89
+ self.acquire()
90
+ try:
91
+ conn = sqlite3.connect(self._db_file, check_same_thread=False)
92
+ conn.executemany('''
93
+ INSERT INTO log (created, created_epoch, name, levelname, level, message)
94
+ VALUES (?, ?, ?, ?, ?, ?)
95
+ ''', [
96
+ (format_time(self, record), record.created, record.name, record.levelname, record.levelno, record.getMessage())
97
+ for record in self._buffer
98
+ ])
99
+ conn.commit()
100
+ conn.close()
101
+ self._buffer.clear()
102
+ self._last_flush = time.time()
103
+ finally:
104
+ self.release()
105
+
106
+ def emit(self, record: logging.LogRecord):
107
+ self._buffer.append(record)
108
+ if len(self._buffer) > self._buffer_size or time.time() - self._last_flush > self._flush_interval:
109
+ self.flush()
110
+
111
+ def close(self):
112
+ self.flush()
113
+ return super().close()
114
+
115
+ def eval_logline(row: sqlite3.Row):
116
+ @dataclasses.dataclass
117
+ class DBLogRecord:
118
+ id: int
119
+ created: str
120
+ created_epoch: float
121
+ name: str
122
+ levelname: str
123
+ level: int
124
+ message: str
125
+ return DBLogRecord(*row)
126
+
127
+ _fh_T = Literal['rotate', 'simple', 'daily', 'sqlite']
61
128
 
62
129
  __g_logger_dict: dict[str, BaseLogger] = {}
63
130
  def get_logger(
64
131
  name = 'default',
65
- log_home = pathlib.Path(DATA_HOME) / 'logs',
132
+ log_home = LOG_DIR,
66
133
  level = 'DEBUG',
67
134
  term_level = 'INFO',
68
- file_handler_type: _fh_T = 'rotate',
135
+ file_handler_type: _fh_T = 'sqlite',
69
136
  global_instance = True
70
137
  )->BaseLogger:
71
138
  if global_instance and name in __g_logger_dict:
@@ -87,22 +154,25 @@ def get_logger(
87
154
  if isinstance(color, str) and color.startswith('\033'):
88
155
  format_str_plain = format_str_plain.replace(color, '')
89
156
 
90
- formatter_plain = logging.Formatter(format_str_plain)
91
- log_home.mkdir(exist_ok=True)
92
- log_file = log_home / f'{name}.log'
93
- if file_handler_type == 'simple':
94
- file_handler = logging.FileHandler(log_file)
95
- elif file_handler_type == 'daily':
96
- file_handler = handlers.TimedRotatingFileHandler(
97
- log_file, when='midnight', interval=1, backupCount=30
98
- )
99
- elif file_handler_type == 'rotate':
100
- file_handler = handlers.RotatingFileHandler(
101
- log_file, maxBytes=1024*1024, backupCount=5
102
- )
103
-
104
- file_handler.setFormatter(formatter_plain)
105
- logger.addHandler(file_handler)
157
+ if not DISABLE_LOGGING:
158
+ formatter_plain = logging.Formatter(format_str_plain)
159
+ log_home.mkdir(exist_ok=True)
160
+ log_file = log_home / f'{name}.log'
161
+ if file_handler_type == 'simple':
162
+ file_handler = logging.FileHandler(log_file)
163
+ elif file_handler_type == 'daily':
164
+ file_handler = handlers.TimedRotatingFileHandler(
165
+ log_file, when='midnight', interval=1, backupCount=30
166
+ )
167
+ elif file_handler_type == 'rotate':
168
+ file_handler = handlers.RotatingFileHandler(
169
+ log_file, maxBytes=1024*1024, backupCount=5
170
+ )
171
+ elif file_handler_type == 'sqlite':
172
+ file_handler = SQLiteFileHandler(log_file if log_file.suffix == '.db' else log_file.with_suffix('.log.db'))
173
+
174
+ file_handler.setFormatter(formatter_plain)
175
+ logger.addHandler(file_handler)
106
176
 
107
177
  logger = BaseLogger(name)
108
178
  setupLogger(logger)
lfss/eng/thumb.py CHANGED
@@ -1,5 +1,6 @@
1
1
  from lfss.eng.config import THUMB_DB, THUMB_SIZE
2
2
  from lfss.eng.database import FileConn
3
+ from lfss.eng.error import *
3
4
  from lfss.eng.connection_pool import unique_cursor
4
5
  from typing import Optional
5
6
  from PIL import Image
@@ -10,44 +11,42 @@ from contextlib import asynccontextmanager
10
11
  async def _maybe_init_thumb(c: aiosqlite.Cursor):
11
12
  await c.execute('''
12
13
  CREATE TABLE IF NOT EXISTS thumbs (
13
- path TEXT PRIMARY KEY,
14
- ctime TEXT,
14
+ file_id CHAR(32) PRIMARY KEY,
15
15
  thumb BLOB
16
16
  )
17
17
  ''')
18
- await c.execute('CREATE INDEX IF NOT EXISTS thumbs_path_idx ON thumbs (path)')
18
+ await c.execute('CREATE INDEX IF NOT EXISTS thumbs_path_idx ON thumbs (file_id)')
19
19
 
20
- async def _get_cache_thumb(c: aiosqlite.Cursor, path: str, ctime: str) -> Optional[bytes]:
20
+ async def _get_cache_thumb(c: aiosqlite.Cursor, file_id: str) -> Optional[bytes]:
21
21
  res = await c.execute('''
22
- SELECT ctime, thumb FROM thumbs WHERE path = ?
23
- ''', (path, ))
22
+ SELECT thumb FROM thumbs WHERE file_id = ?
23
+ ''', (file_id, ))
24
24
  row = await res.fetchone()
25
25
  if row is None:
26
26
  return None
27
- # check if ctime matches, if not delete and return None
28
- if row[0] != ctime:
29
- await _delete_cache_thumb(c, path)
30
- return None
31
- blob: bytes = row[1]
27
+ blob: bytes = row[0]
32
28
  return blob
33
29
 
34
- async def _save_cache_thumb(c: aiosqlite.Cursor, path: str, ctime: str, raw_bytes: bytes) -> bytes:
35
- raw_img = Image.open(BytesIO(raw_bytes))
30
+ async def _save_cache_thumb(c: aiosqlite.Cursor, file_id: str, raw_bytes: bytes) -> bytes:
31
+ try:
32
+ raw_img = Image.open(BytesIO(raw_bytes))
33
+ except Exception:
34
+ raise InvalidDataError('Invalid image data for thumbnail: ' + file_id)
36
35
  raw_img.thumbnail(THUMB_SIZE)
37
36
  img = raw_img.convert('RGB')
38
37
  bio = BytesIO()
39
38
  img.save(bio, 'JPEG')
40
39
  blob = bio.getvalue()
41
40
  await c.execute('''
42
- INSERT OR REPLACE INTO thumbs (path, ctime, thumb) VALUES (?, ?, ?)
43
- ''', (path, ctime, blob))
41
+ INSERT OR REPLACE INTO thumbs (file_id, thumb) VALUES (?, ?)
42
+ ''', (file_id, blob))
44
43
  await c.execute('COMMIT') # commit immediately
45
44
  return blob
46
45
 
47
- async def _delete_cache_thumb(c: aiosqlite.Cursor, path: str):
46
+ async def _delete_cache_thumb(c: aiosqlite.Cursor, file_id: str):
48
47
  await c.execute('''
49
- DELETE FROM thumbs WHERE path = ?
50
- ''', (path, ))
48
+ DELETE FROM thumbs WHERE file_id = ?
49
+ ''', (file_id, ))
51
50
  await c.execute('COMMIT')
52
51
 
53
52
  @asynccontextmanager
@@ -71,15 +70,13 @@ async def get_thumb(path: str) -> Optional[tuple[bytes, str]]:
71
70
  r = await fconn.get_file_record(path)
72
71
 
73
72
  if r is None:
74
- async with cache_cursor() as cur:
75
- await _delete_cache_thumb(cur, path)
76
73
  raise FileNotFoundError(f'File not found: {path}')
77
74
  if not r.mime_type.startswith('image/'):
78
75
  return None
79
76
 
77
+ file_id = r.file_id
80
78
  async with cache_cursor() as cur:
81
- c_time = r.create_time
82
- thumb_blob = await _get_cache_thumb(cur, path, c_time)
79
+ thumb_blob = await _get_cache_thumb(cur, file_id)
83
80
  if thumb_blob is not None:
84
81
  return thumb_blob, "image/jpeg"
85
82
 
@@ -94,5 +91,5 @@ async def get_thumb(path: str) -> Optional[tuple[bytes, str]]:
94
91
  data = await fconn.get_file_blob(r.file_id)
95
92
  assert data is not None
96
93
 
97
- thumb_blob = await _save_cache_thumb(cur, path, c_time, data)
94
+ thumb_blob = await _save_cache_thumb(cur, file_id, data)
98
95
  return thumb_blob, "image/jpeg"
lfss/eng/utils.py CHANGED
@@ -11,7 +11,6 @@ from concurrent.futures import ThreadPoolExecutor
11
11
  from typing import TypeVar, Callable, Awaitable
12
12
  from functools import wraps, partial
13
13
  from uuid import uuid4
14
- import os
15
14
 
16
15
  async def copy_file(source: str|pathlib.Path, destination: str|pathlib.Path):
17
16
  async with aiofiles.open(source, mode='rb') as src:
@@ -20,7 +19,7 @@ async def copy_file(source: str|pathlib.Path, destination: str|pathlib.Path):
20
19
  await dest.write(chunk)
21
20
 
22
21
  def hash_credential(username: str, password: str):
23
- return hashlib.sha256((username + password).encode()).hexdigest()
22
+ return hashlib.sha256(f"{username}:{password}".encode()).hexdigest()
24
23
 
25
24
  def encode_uri_compnents(path: str):
26
25
  path_sp = path.split("/")
@@ -36,17 +35,41 @@ def ensure_uri_compnents(path: str):
36
35
  """ Ensure the path components are safe to use """
37
36
  return encode_uri_compnents(decode_uri_compnents(path))
38
37
 
39
- g_debounce_tasks: OrderedDict[str, asyncio.Task] = OrderedDict()
40
- lock_debounce_task_queue = Lock()
38
+ class TaskManager:
39
+ def __init__(self):
40
+ self._tasks: OrderedDict[str, asyncio.Task] = OrderedDict()
41
+
42
+ def push(self, task: asyncio.Task) -> str:
43
+ tid = uuid4().hex
44
+ if tid in self._tasks:
45
+ raise ValueError("Task ID collision")
46
+ self._tasks[tid] = task
47
+ return tid
48
+
49
+ def cancel(self, task_id: str):
50
+ task = self._tasks.pop(task_id, None)
51
+ if task is not None:
52
+ task.cancel()
53
+
54
+ def truncate(self):
55
+ new_tasks = OrderedDict()
56
+ for tid, task in self._tasks.items():
57
+ if not task.done():
58
+ new_tasks[tid] = task
59
+ self._tasks = new_tasks
60
+
61
+ async def wait_all(self):
62
+ async def stop_task(task: asyncio.Task):
63
+ if not task.done():
64
+ await task
65
+ await asyncio.gather(*map(stop_task, self._tasks.values()))
66
+ self._tasks.clear()
67
+
68
+ def __len__(self): return len(self._tasks)
69
+
70
+ g_debounce_tasks: TaskManager = TaskManager()
41
71
  async def wait_for_debounce_tasks():
42
- async def stop_task(task: asyncio.Task):
43
- task.cancel()
44
- try:
45
- await task
46
- except asyncio.CancelledError:
47
- pass
48
- await asyncio.gather(*map(stop_task, g_debounce_tasks.values()))
49
- g_debounce_tasks.clear()
72
+ await g_debounce_tasks.wait_all()
50
73
 
51
74
  def debounce_async(delay: float = 0.1, max_wait: float = 1.):
52
75
  """
@@ -54,7 +77,8 @@ def debounce_async(delay: float = 0.1, max_wait: float = 1.):
54
77
  ensuring execution at least once every `max_wait` seconds.
55
78
  """
56
79
  def debounce_wrap(func):
57
- task_record: tuple[str, asyncio.Task] | None = None
80
+ # task_record: tuple[str, asyncio.Task] | None = None
81
+ prev_task_id = None
58
82
  fn_execution_lock = Lock()
59
83
  last_execution_time = 0
60
84
 
@@ -67,12 +91,11 @@ def debounce_async(delay: float = 0.1, max_wait: float = 1.):
67
91
 
68
92
  @functools.wraps(func)
69
93
  async def wrapper(*args, **kwargs):
70
- nonlocal task_record, last_execution_time
94
+ nonlocal prev_task_id, last_execution_time
71
95
 
72
- async with lock_debounce_task_queue:
73
- if task_record is not None:
74
- task_record[1].cancel()
75
- g_debounce_tasks.pop(task_record[0], None)
96
+ if prev_task_id is not None:
97
+ g_debounce_tasks.cancel(prev_task_id)
98
+ prev_task_id = None
76
99
 
77
100
  async with fn_execution_lock:
78
101
  if time.monotonic() - last_execution_time > max_wait:
@@ -81,14 +104,12 @@ def debounce_async(delay: float = 0.1, max_wait: float = 1.):
81
104
  return
82
105
 
83
106
  task = asyncio.create_task(delayed_func(*args, **kwargs))
84
- task_uid = uuid4().hex
85
- task_record = (task_uid, task)
86
- async with lock_debounce_task_queue:
87
- g_debounce_tasks[task_uid] = task
88
- if len(g_debounce_tasks) > 2048:
89
- # finished tasks are not removed from the dict
90
- # so we need to clear it periodically
91
- await wait_for_debounce_tasks()
107
+ prev_task_id = g_debounce_tasks.push(task)
108
+ if len(g_debounce_tasks) > 1024:
109
+ # finished tasks are not removed from the dict
110
+ # so we need to clear it periodically
111
+ g_debounce_tasks.truncate()
112
+
92
113
  return wrapper
93
114
  return debounce_wrap
94
115
 
@@ -133,12 +154,12 @@ def fmt_storage_size(size: int) -> str:
133
154
  return f"{size/1024**4:.2f}T"
134
155
 
135
156
  _FnReturnT = TypeVar('_FnReturnT')
136
- _AsyncReturnT = Awaitable[_FnReturnT]
157
+ _AsyncReturnT = TypeVar('_AsyncReturnT', bound=Awaitable)
137
158
  _g_executor = None
138
159
  def get_global_executor():
139
160
  global _g_executor
140
161
  if _g_executor is None:
141
- _g_executor = ThreadPoolExecutor(max_workers=4 if (cpu_count:=os.cpu_count()) and cpu_count > 4 else cpu_count)
162
+ _g_executor = ThreadPoolExecutor()
142
163
  return _g_executor
143
164
  def async_wrap(executor=None):
144
165
  if executor is None:
@@ -157,7 +178,7 @@ def concurrent_wrap(executor=None):
157
178
  def sync_fn(*args, **kwargs):
158
179
  loop = asyncio.new_event_loop()
159
180
  return loop.run_until_complete(func(*args, **kwargs))
160
- return sync_fn
181
+ return sync_fn # type: ignore
161
182
  return _concurrent_wrap
162
183
 
163
184
  # https://stackoverflow.com/a/279586/6775765
lfss/sql/init.sql CHANGED
@@ -1,4 +1,4 @@
1
- CREATE TABLE IF NOT EXISTS user (
1
+ CREATE TABLE IF NOT EXISTS main.user (
2
2
  id INTEGER PRIMARY KEY AUTOINCREMENT,
3
3
  username VARCHAR(256) UNIQUE NOT NULL,
4
4
  credential VARCHAR(256) NOT NULL,
@@ -9,7 +9,7 @@ CREATE TABLE IF NOT EXISTS user (
9
9
  permission INTEGER DEFAULT 0
10
10
  );
11
11
 
12
- CREATE TABLE IF NOT EXISTS fmeta (
12
+ CREATE TABLE IF NOT EXISTS main.fmeta (
13
13
  url VARCHAR(1024) PRIMARY KEY,
14
14
  owner_id INTEGER NOT NULL,
15
15
  file_id CHAR(32) NOT NULL,
@@ -22,12 +22,17 @@ CREATE TABLE IF NOT EXISTS fmeta (
22
22
  FOREIGN KEY(owner_id) REFERENCES user(id)
23
23
  );
24
24
 
25
- CREATE TABLE IF NOT EXISTS usize (
25
+ CREATE TABLE IF NOT EXISTS main.dupcount (
26
+ file_id CHAR(32) PRIMARY KEY,
27
+ count INTEGER DEFAULT 0
28
+ );
29
+
30
+ CREATE TABLE IF NOT EXISTS main.usize (
26
31
  user_id INTEGER PRIMARY KEY,
27
32
  size INTEGER DEFAULT 0
28
33
  );
29
34
 
30
- CREATE TABLE IF NOT EXISTS upeer (
35
+ CREATE TABLE IF NOT EXISTS main.upeer (
31
36
  src_user_id INTEGER NOT NULL,
32
37
  dst_user_id INTEGER NOT NULL,
33
38
  access_level INTEGER DEFAULT 0,
lfss/svc/app.py CHANGED
@@ -6,4 +6,4 @@ app.include_router(router_api)
6
6
  if ENABLE_WEBDAV:
7
7
  from .app_dav import *
8
8
  app.include_router(router_dav)
9
- app.include_router(router_fs)
9
+ app.include_router(router_fs)
lfss/svc/app_base.py CHANGED
@@ -27,7 +27,7 @@ req_conn = RequestDB()
27
27
  async def lifespan(app: FastAPI):
28
28
  global db
29
29
  try:
30
- await global_connection_init(n_read = 2)
30
+ await global_connection_init(n_read = 8 if not DEBUG_MODE else 1)
31
31
  await asyncio.gather(db.init(), req_conn.init())
32
32
  yield
33
33
  await req_conn.commit()
@@ -47,21 +47,26 @@ def handle_exception(fn):
47
47
  if isinstance(e, StorageExceededError): raise HTTPException(status_code=413, detail=str(e))
48
48
  if isinstance(e, PermissionError): raise HTTPException(status_code=403, detail=str(e))
49
49
  if isinstance(e, InvalidPathError): raise HTTPException(status_code=400, detail=str(e))
50
+ if isinstance(e, InvalidOptionsError): raise HTTPException(status_code=400, detail=str(e))
51
+ if isinstance(e, InvalidDataError): raise HTTPException(status_code=400, detail=str(e))
50
52
  if isinstance(e, FileNotFoundError): raise HTTPException(status_code=404, detail=str(e))
51
53
  if isinstance(e, FileDuplicateError): raise HTTPException(status_code=409, detail=str(e))
52
54
  if isinstance(e, FileExistsError): raise HTTPException(status_code=409, detail=str(e))
53
55
  if isinstance(e, TooManyItemsError): raise HTTPException(status_code=400, detail=str(e))
54
56
  if isinstance(e, DatabaseLockedError): raise HTTPException(status_code=503, detail=str(e))
57
+ if isinstance(e, DatabaseTransactionError): raise HTTPException(status_code=503, detail=str(e))
55
58
  if isinstance(e, FileLockedError): raise HTTPException(status_code=423, detail=str(e))
56
- if isinstance(e, InvalidOptionsError): raise HTTPException(status_code=400, detail=str(e))
57
59
  logger.error(f"Uncaptured error in {fn.__name__}: {e}")
58
60
  raise
59
61
  return wrapper
60
62
 
63
+ env_origins = os.environ.get("LFSS_ORIGINS", "*")
64
+ logger.debug(f"LFSS_ORIGINS: {env_origins}")
65
+ origins = [x.strip() for x in env_origins.split(",") if x.strip()]
61
66
  app = FastAPI(docs_url=None, redoc_url=None, lifespan=lifespan)
62
67
  app.add_middleware(
63
68
  CORSMiddleware,
64
- allow_origins=["*"],
69
+ allow_origins=origins,
65
70
  allow_credentials=True,
66
71
  allow_methods=["*"],
67
72
  allow_headers=["*"],