lfss 0.8.3__py3-none-any.whl → 0.9.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.
lfss/src/server.py CHANGED
@@ -16,10 +16,10 @@ from .stat import RequestDB
16
16
  from .config import MAX_BUNDLE_BYTES, CHUNK_SIZE
17
17
  from .utils import ensure_uri_compnents, format_last_modified, now_stamp, wait_for_debounce_tasks
18
18
  from .connection_pool import global_connection_init, global_connection_close, unique_cursor
19
- from .database import Database, DECOY_USER, check_user_permission, UserConn, FileConn
19
+ from .database import Database, DECOY_USER, check_file_read_permission, check_path_permission, UserConn, FileConn
20
20
  from .database import delayed_log_activity, delayed_log_access
21
21
  from .datatype import (
22
- FileReadPermission, FileRecord, UserRecord, PathContents,
22
+ FileReadPermission, FileRecord, UserRecord, PathContents, AccessLevel,
23
23
  FileSortKey, DirSortKey
24
24
  )
25
25
  from .thumb import get_thumb
@@ -54,6 +54,7 @@ def handle_exception(fn):
54
54
  if isinstance(e, FileNotFoundError): raise HTTPException(status_code=404, detail=str(e))
55
55
  if isinstance(e, FileExistsError): raise HTTPException(status_code=409, detail=str(e))
56
56
  if isinstance(e, TooManyItemsError): raise HTTPException(status_code=400, detail=str(e))
57
+ if isinstance(e, DatabaseLockedError): raise HTTPException(status_code=503, detail=str(e))
57
58
  logger.error(f"Uncaptured error in {fn.__name__}: {e}")
58
59
  raise
59
60
  return wrapper
@@ -228,39 +229,37 @@ async def get_file_impl(
228
229
  if path == "": path = "/"
229
230
  if path.endswith("/"):
230
231
  # return file under the path as json
231
- async with unique_cursor() as conn:
232
- fconn = FileConn(conn)
232
+ async with unique_cursor() as cur:
233
+ fconn = FileConn(cur)
233
234
  if user.id == 0:
234
235
  raise HTTPException(status_code=401, detail="Permission denied, credential required")
235
236
  if thumb:
236
237
  return await emit_thumbnail(path, download, create_time=None)
237
238
 
238
239
  if path == "/":
240
+ peer_users = await UserConn(cur).list_peer_users(user.id, AccessLevel.READ)
239
241
  return PathContents(
240
- dirs = await fconn.list_root_dirs(user.username, skim=True) \
242
+ dirs = await fconn.list_root_dirs(user.username, *[x.username for x in peer_users], skim=True) \
241
243
  if not user.is_admin else await fconn.list_root_dirs(skim=True),
242
244
  files = []
243
245
  )
244
246
 
245
- if not path.startswith(f"{user.username}/") and not user.is_admin:
246
- raise HTTPException(status_code=403, detail="Permission denied, path must start with username")
247
+ if not await check_path_permission(path, user, cursor=cur) >= AccessLevel.READ:
248
+ raise HTTPException(status_code=403, detail="Permission denied")
247
249
 
248
250
  return await fconn.list_path(path)
249
251
 
250
252
  # handle file query
251
- async with unique_cursor() as conn:
252
- fconn = FileConn(conn)
253
- file_record = await fconn.get_file_record(path)
254
- if not file_record:
255
- raise HTTPException(status_code=404, detail="File not found")
256
-
257
- uconn = UserConn(conn)
258
- owner = await uconn.get_user_by_id(file_record.owner_id)
259
-
260
- assert owner is not None, "Owner not found"
261
- allow_access, reason = check_user_permission(user, owner, file_record)
262
- if not allow_access:
263
- raise HTTPException(status_code=403, detail=reason)
253
+ async with unique_cursor() as cur:
254
+ fconn = FileConn(cur)
255
+ file_record = await fconn.get_file_record(path, throw=True)
256
+ uconn = UserConn(cur)
257
+ owner = await uconn.get_user_by_id(file_record.owner_id, throw=True)
258
+
259
+ if not await check_path_permission(path, user, cursor=cur) >= AccessLevel.READ:
260
+ allow_access, reason = check_file_read_permission(user, owner, file_record)
261
+ if not allow_access:
262
+ raise HTTPException(status_code=403 if user.id != 0 else 401, detail=reason)
264
263
 
265
264
  req_range = request.headers.get("Range", None)
266
265
  if not req_range is None:
@@ -326,17 +325,11 @@ async def put_file(
326
325
  ):
327
326
  path = ensure_uri_compnents(path)
328
327
  assert not path.endswith("/"), "Path must not end with /"
329
- if not path.startswith(f"{user.username}/"):
330
- if not user.is_admin:
331
- logger.debug(f"Reject put request from {user.username} to {path}")
332
- raise HTTPException(status_code=403, detail="Permission denied")
333
- else:
334
- first_comp = path.split("/")[0]
335
- async with unique_cursor() as c:
336
- uconn = UserConn(c)
337
- owner = await uconn.get_user(first_comp)
338
- if not owner:
339
- raise HTTPException(status_code=404, detail="Owner not found")
328
+
329
+ access_level = await check_path_permission(path, user)
330
+ if access_level < AccessLevel.WRITE:
331
+ logger.debug(f"Reject put request from {user.username} to {path}")
332
+ raise HTTPException(status_code=403, detail="Permission denied")
340
333
 
341
334
  logger.info(f"PUT {path}, user: {user.username}")
342
335
  exists_flag = False
@@ -352,7 +345,7 @@ async def put_file(
352
345
  "Content-Type": "application/json",
353
346
  }, content=json.dumps({"url": path}))
354
347
  exists_flag = True
355
- if not user.is_admin and not file_record.owner_id == user.id:
348
+ if await check_path_permission(path, user) < AccessLevel.WRITE:
356
349
  raise HTTPException(status_code=403, detail="Permission denied, cannot overwrite other's file")
357
350
  await db.delete_file(path)
358
351
 
@@ -386,17 +379,11 @@ async def post_file(
386
379
  ):
387
380
  path = ensure_uri_compnents(path)
388
381
  assert not path.endswith("/"), "Path must not end with /"
389
- if not path.startswith(f"{user.username}/"):
390
- if not user.is_admin:
391
- logger.debug(f"Reject put request from {user.username} to {path}")
392
- raise HTTPException(status_code=403, detail="Permission denied")
393
- else:
394
- first_comp = path.split("/")[0]
395
- async with unique_cursor() as conn:
396
- uconn = UserConn(conn)
397
- owner = await uconn.get_user(first_comp)
398
- if not owner:
399
- raise HTTPException(status_code=404, detail="Owner not found")
382
+
383
+ access_level = await check_path_permission(path, user)
384
+ if access_level < AccessLevel.WRITE:
385
+ logger.debug(f"Reject post request from {user.username} to {path}")
386
+ raise HTTPException(status_code=403, detail="Permission denied")
400
387
 
401
388
  logger.info(f"POST {path}, user: {user.username}")
402
389
  exists_flag = False
@@ -412,7 +399,7 @@ async def post_file(
412
399
  "Content-Type": "application/json",
413
400
  }, content=json.dumps({"url": path}))
414
401
  exists_flag = True
415
- if not user.is_admin and not file_record.owner_id == user.id:
402
+ if await check_path_permission(path, user) < AccessLevel.WRITE:
416
403
  raise HTTPException(status_code=403, detail="Permission denied, cannot overwrite other's file")
417
404
  await db.delete_file(path)
418
405
 
@@ -431,7 +418,7 @@ async def post_file(
431
418
  @handle_exception
432
419
  async def delete_file(path: str, user: UserRecord = Depends(registered_user)):
433
420
  path = ensure_uri_compnents(path)
434
- if not path.startswith(f"{user.username}/") and not user.is_admin:
421
+ if await check_path_permission(path, user) < AccessLevel.WRITE:
435
422
  raise HTTPException(status_code=403, detail="Permission denied")
436
423
 
437
424
  logger.info(f"DELETE {path}, user: {user.username}")
@@ -458,6 +445,7 @@ async def bundle_files(path: str, user: UserRecord = Depends(registered_user)):
458
445
  if not path == "" and path[0] == "/": # adapt to both /path and path
459
446
  path = path[1:]
460
447
 
448
+ # TODO: may check peer users here
461
449
  owner_records_cache: dict[int, UserRecord] = {} # cache owner records, ID -> UserRecord
462
450
  async def is_access_granted(file_record: FileRecord):
463
451
  owner_id = file_record.owner_id
@@ -465,11 +453,10 @@ async def bundle_files(path: str, user: UserRecord = Depends(registered_user)):
465
453
  if owner is None:
466
454
  async with unique_cursor() as conn:
467
455
  uconn = UserConn(conn)
468
- owner = await uconn.get_user_by_id(owner_id)
469
- assert owner is not None, "Owner not found"
456
+ owner = await uconn.get_user_by_id(owner_id, throw=True)
470
457
  owner_records_cache[owner_id] = owner
471
458
 
472
- allow_access, _ = check_user_permission(user, owner, file_record)
459
+ allow_access, _ = check_file_read_permission(user, owner, file_record)
473
460
  return allow_access
474
461
 
475
462
  async with unique_cursor() as conn:
@@ -502,23 +489,20 @@ async def get_file_meta(path: str, user: UserRecord = Depends(registered_user)):
502
489
  logger.info(f"GET meta({path}), user: {user.username}")
503
490
  path = ensure_uri_compnents(path)
504
491
  is_file = not path.endswith("/")
505
- async with unique_cursor() as conn:
506
- fconn = FileConn(conn)
492
+ async with unique_cursor() as cur:
493
+ fconn = FileConn(cur)
507
494
  if is_file:
508
- record = await fconn.get_file_record(path)
509
- if not record:
510
- raise HTTPException(status_code=404, detail="File not found")
511
- if not path.startswith(f"{user.username}/") and not user.is_admin:
512
- uconn = UserConn(conn)
513
- owner = await uconn.get_user_by_id(record.owner_id)
514
- assert owner is not None, "Owner not found"
515
- is_allowed, reason = check_user_permission(user, owner, record)
495
+ record = await fconn.get_file_record(path, throw=True)
496
+ if await check_path_permission(path, user, cursor=cur) < AccessLevel.READ:
497
+ uconn = UserConn(cur)
498
+ owner = await uconn.get_user_by_id(record.owner_id, throw=True)
499
+ is_allowed, reason = check_file_read_permission(user, owner, record)
516
500
  if not is_allowed:
517
501
  raise HTTPException(status_code=403, detail=reason)
518
502
  else:
519
- record = await fconn.get_path_record(path)
520
- if not path.startswith(f"{user.username}/") and not user.is_admin:
503
+ if await check_path_permission(path, user, cursor=cur) < AccessLevel.READ:
521
504
  raise HTTPException(status_code=403, detail="Permission denied")
505
+ record = await fconn.get_path_record(path)
522
506
  return record
523
507
 
524
508
  @router_api.post("/meta")
@@ -559,15 +543,14 @@ async def update_file_meta(
559
543
 
560
544
  return Response(status_code=200, content="OK")
561
545
 
562
- async def validate_path_permission(path: str, user: UserRecord):
546
+ async def validate_path_read_permission(path: str, user: UserRecord):
563
547
  if not path.endswith("/"):
564
548
  raise HTTPException(status_code=400, detail="Path must end with /")
565
- if not path.startswith(f"{user.username}/") and not user.is_admin:
549
+ if not await check_path_permission(path, user) >= AccessLevel.READ:
566
550
  raise HTTPException(status_code=403, detail="Permission denied")
567
-
568
551
  @router_api.get("/count-files")
569
552
  async def count_files(path: str, flat: bool = False, user: UserRecord = Depends(registered_user)):
570
- await validate_path_permission(path, user)
553
+ await validate_path_read_permission(path, user)
571
554
  path = ensure_uri_compnents(path)
572
555
  async with unique_cursor() as conn:
573
556
  fconn = FileConn(conn)
@@ -578,7 +561,7 @@ async def list_files(
578
561
  order_by: FileSortKey = "", order_desc: bool = False,
579
562
  flat: bool = False, user: UserRecord = Depends(registered_user)
580
563
  ):
581
- await validate_path_permission(path, user)
564
+ await validate_path_read_permission(path, user)
582
565
  path = ensure_uri_compnents(path)
583
566
  async with unique_cursor() as conn:
584
567
  fconn = FileConn(conn)
@@ -590,7 +573,7 @@ async def list_files(
590
573
 
591
574
  @router_api.get("/count-dirs")
592
575
  async def count_dirs(path: str, user: UserRecord = Depends(registered_user)):
593
- await validate_path_permission(path, user)
576
+ await validate_path_read_permission(path, user)
594
577
  path = ensure_uri_compnents(path)
595
578
  async with unique_cursor() as conn:
596
579
  fconn = FileConn(conn)
@@ -601,7 +584,7 @@ async def list_dirs(
601
584
  order_by: DirSortKey = "", order_desc: bool = False,
602
585
  skim: bool = True, user: UserRecord = Depends(registered_user)
603
586
  ):
604
- await validate_path_permission(path, user)
587
+ await validate_path_read_permission(path, user)
605
588
  path = ensure_uri_compnents(path)
606
589
  async with unique_cursor() as conn:
607
590
  fconn = FileConn(conn)
lfss/src/utils.py CHANGED
@@ -47,13 +47,15 @@ def debounce_async(delay: float = 0.1, max_wait: float = 1.):
47
47
  """
48
48
  def debounce_wrap(func):
49
49
  task_record: tuple[str, asyncio.Task] | None = None
50
+ fn_execution_lock = Lock()
50
51
  last_execution_time = 0
51
52
 
52
53
  async def delayed_func(*args, **kwargs):
53
54
  nonlocal last_execution_time
54
55
  await asyncio.sleep(delay)
55
- await func(*args, **kwargs)
56
- last_execution_time = time.monotonic()
56
+ async with fn_execution_lock:
57
+ await func(*args, **kwargs)
58
+ last_execution_time = time.monotonic()
57
59
 
58
60
  @functools.wraps(func)
59
61
  async def wrapper(*args, **kwargs):
@@ -64,10 +66,11 @@ def debounce_async(delay: float = 0.1, max_wait: float = 1.):
64
66
  task_record[1].cancel()
65
67
  g_debounce_tasks.pop(task_record[0], None)
66
68
 
67
- if time.monotonic() - last_execution_time > max_wait:
68
- await func(*args, **kwargs)
69
- last_execution_time = time.monotonic()
70
- return
69
+ async with fn_execution_lock:
70
+ if time.monotonic() - last_execution_time > max_wait:
71
+ await func(*args, **kwargs)
72
+ last_execution_time = time.monotonic()
73
+ return
71
74
 
72
75
  task = asyncio.create_task(delayed_func(*args, **kwargs))
73
76
  task_uid = uuid4().hex
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: lfss
3
- Version: 0.8.3
3
+ Version: 0.9.0
4
4
  Summary: Lightweight file storage service
5
5
  Home-page: https://github.com/MenxLi/lfss
6
6
  Author: li, mengxun
@@ -24,9 +24,17 @@ Description-Content-Type: text/markdown
24
24
  # Lightweight File Storage Service (LFSS)
25
25
  [![PyPI](https://img.shields.io/pypi/v/lfss)](https://pypi.org/project/lfss/)
26
26
 
27
- My experiment on a lightweight and high-performance file/object storage service.
27
+ My experiment on a lightweight and high-performance file/object storage service...
28
+
29
+ **Highlights:**
30
+
31
+ - User storage limit and access control.
32
+ - Pagination and sorted file listing for vast number of files.
33
+ - High performance: high concurrency, near-native speed on stress tests.
34
+ - Support range requests, so you can stream large files / resume download.
35
+
28
36
  It stores small files and metadata in sqlite, large files in the filesystem.
29
- Tested on 2 million files, and it works fine...
37
+ Tested on 2 million files, and it is still fast.
30
38
 
31
39
  Usage:
32
40
  ```sh
@@ -45,8 +53,8 @@ lfss-panel --open
45
53
  Or, you can start a web server at `/frontend` and open `index.html` in your browser.
46
54
 
47
55
  The API usage is simple, just `GET`, `PUT`, `DELETE` to the `/<username>/file/url` path.
48
- Authentication is done via `Authorization` header with the value `Bearer <token>`, or through the `token` query parameter.
49
- You can refer to `frontend` as an application example, and `frontend/api.js` or `lfss/api/connector.py` for the API usage.
56
+ Authentication via `Authorization` header with the value `Bearer <token>`, or through the `token` query parameter.
57
+ You can refer to `frontend` as an application example, `lfss/api/connector.py` for more APIs.
50
58
 
51
59
  By default, the service exposes all files to the public for `GET` requests,
52
60
  but file-listing is restricted to the user's own files.
@@ -1,7 +1,7 @@
1
- Readme.md,sha256=LpbTvUWjCOv4keMNDrZvEnNAmCQnvaxvlq2srWixXn0,1299
1
+ Readme.md,sha256=J1tGk7B9EyIXT-RN7VGz_229UeKvZHVLpn1FvzNDxL4,1538
2
2
  docs/Known_issues.md,sha256=rfdG3j1OJF-59S9E06VPyn0nZKbW-ybPxkoZ7MEZWp8,81
3
- docs/Permission.md,sha256=9r9nEmhqfz18RTS8FI0fZ9F0a31r86OoAyx3EQxxpk0,2317
4
- frontend/api.js,sha256=wUJNAkL8QigAiwR_jaMPUhCQEsL-lp0wZ6XeueYgunE,18049
3
+ docs/Permission.md,sha256=mvK8gVBBgoIFJqikcaReU_bUo-mTq_ECqJaDDJoQF7Q,3126
4
+ frontend/api.js,sha256=hMV6Fc1JxkFQgv7BV1Y_Su7pqsWeF_92hPMmDBcXC04,18485
5
5
  frontend/index.html,sha256=-k0bJ5FRqdl_H-O441D_H9E-iejgRCaL_z5UeYaS2qc,3384
6
6
  frontend/info.css,sha256=Ny0N3GywQ3a9q1_Qph_QFEKB4fEnTe_2DJ1Y5OsLLmQ,595
7
7
  frontend/info.js,sha256=xGUJPCSrtDhuSu0ELLQZ77PmVWldg-prU1mwQGbdEoA,5797
@@ -13,32 +13,32 @@ frontend/scripts.js,sha256=nWH6NgavZTVmjK44i2DeRi6mJzGSe4qeQPUbDaEVt58,21735
13
13
  frontend/state.js,sha256=vbNL5DProRKmSEY7xu9mZH6IY0PBenF8WGxPtGgDnLI,1680
14
14
  frontend/styles.css,sha256=xcNLqI3KBsY5TLnku8UIP0Jfr7QLajr1_KNlZj9eheM,4935
15
15
  frontend/thumb.css,sha256=rNsx766amYS2DajSQNabhpQ92gdTpNoQKmV69OKvtpI,295
16
- frontend/thumb.js,sha256=tX9LtxT6O2O6IUlpdvID6S973SUpWxgPVVqI9pwlVw8,6113
16
+ frontend/thumb.js,sha256=46ViD2TlTTWy0fx6wjoAs_5CQ4ajYB90vVzM7UO2IHw,6182
17
17
  frontend/utils.js,sha256=IYUZl77ugiXKcLxSNOWC4NSS0CdD5yRgUsDb665j0xM,2556
18
18
  lfss/api/__init__.py,sha256=c_-GokKJ_3REgve16AwgXRfFyl9mwy7__FxCIIVlk1Q,6660
19
- lfss/api/connector.py,sha256=tCUwTlbzTwHvPnFb8nlnc6LEnrXwdCnCCThyBISt2Tg,11319
19
+ lfss/api/connector.py,sha256=sSYy8gDewOosQiOzn8rvl7NsfFkIuhumHDefAVCgess,11573
20
20
  lfss/cli/__init__.py,sha256=lPwPmqpa7EXQ4zlU7E7LOe6X2kw_xATGdwoHphUEirA,827
21
21
  lfss/cli/balance.py,sha256=R2rbO2tg9TVnnQIVeU0GJVeMS-5LDhEdk4mbOE9qGq0,4121
22
- lfss/cli/cli.py,sha256=LxUrviHtsqi-vs_GWZw2qRs9dBNvx9PSQHLW6SwUmhA,8167
22
+ lfss/cli/cli.py,sha256=iQkZm5Ltlhw7EWM4gOv_N0vjxiteGDH_aGhh06YMPYk,8066
23
23
  lfss/cli/panel.py,sha256=iGdVmdWYjA_7a78ZzWEB_3ggIOBeUKTzg6F5zLaB25c,1401
24
24
  lfss/cli/serve.py,sha256=T-jz_PJcaY9FJRRfyWV9MbjDI73YdOOCn4Nr2PO-s0c,993
25
- lfss/cli/user.py,sha256=uqHQ7onddTjJAYg3B1DIc8hDl0aCkIMZolLKhQrBd0k,4046
25
+ lfss/cli/user.py,sha256=4ynarVvSybxEQaoAzCn2dN2h6-9A61XDNQ-83-lwK4s,5364
26
26
  lfss/cli/vacuum.py,sha256=4TMMYC_5yEt7jeaFTVC3iX0X6aUzYXBiCsrcIYgw_uA,3695
27
- lfss/sql/init.sql,sha256=C-JtQAlaOjESI8uoF1Y_9dKukEVSw5Ll-7yA3gG-XHU,1210
27
+ lfss/sql/init.sql,sha256=8LjHx0TBCkBD62xFfssSeHDqKYVQQJkZAg4rSm046f4,1496
28
28
  lfss/sql/pragma.sql,sha256=uENx7xXjARmro-A3XAK8OM8v5AxDMdCCRj47f86UuXg,206
29
29
  lfss/src/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
30
30
  lfss/src/bounded_pool.py,sha256=BI1dU-MBf82TMwJBYbjhEty7w1jIUKc5Bn9SnZ_-hoY,1288
31
31
  lfss/src/config.py,sha256=7k_6L_aG7vD-lVcmgpwMIg-ggpMMDNU8IbaB4y1effE,861
32
- lfss/src/connection_pool.py,sha256=4YULPcmndy33FyBSo9w1fZjAlBX2q-xPP27xJOTA06I,5228
33
- lfss/src/database.py,sha256=zoiBm7CVHHV4TqwmK6lPnZvK9mzDNtrNvAJCRaIYMU8,36302
34
- lfss/src/datatype.py,sha256=yyOcxhGwz-EJi003f8hGl82EJuY4F92y6fSX6cK60Bc,2126
35
- lfss/src/error.py,sha256=Bh_GUtuNsMSMIKbFreU4CfZzL96TZdNAIcgmDxvGbQ0,333
32
+ lfss/src/connection_pool.py,sha256=-tePasJxiZZ73ymgWf_kFnaKouc4Rrr4K6EXwjb7Mm4,6141
33
+ lfss/src/database.py,sha256=jahoLa3kU6wGovwyfuvaMuGI8rLxcJYSfMkTT8_LI3c,41883
34
+ lfss/src/datatype.py,sha256=27UB7-l9SICy5lAvKjdzpTL_GohZjzstQcr9PtAq7nM,2709
35
+ lfss/src/error.py,sha256=oUQPkXzrlJ6Mtvm26T_3SYW_4j9unfudG3HMa1Q-JF0,421
36
36
  lfss/src/log.py,sha256=u6WRZZsE7iOx6_CV2NHh1ugea26p408FI4WstZh896A,5139
37
- lfss/src/server.py,sha256=YLsp6bab7q0I2hI4uUYIiWc2S0k6d6bbMaweg6VbVV4,23743
37
+ lfss/src/server.py,sha256=TMsZBt-hF4dh_-e_v5odki09S36kJ33Gi_GbtUnGQ-M,23310
38
38
  lfss/src/stat.py,sha256=WlRiiAl0rHX9oPi3thi6K4GKn70WHpClSDCt7iZUow4,3197
39
39
  lfss/src/thumb.py,sha256=qjCNMpnCozMuzkhm-2uAYy1eAuYTeWG6xqs-13HX-7k,3266
40
- lfss/src/utils.py,sha256=DxjHabdiISMkrm1WQlpsZFKL3by6YrzBNQaDt_uZlRk,5744
41
- lfss-0.8.3.dist-info/METADATA,sha256=2Q3LdTB3vX_i8kpXIJ9SmMv-GLC1kslzN0RUBW9ksSA,2059
42
- lfss-0.8.3.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
43
- lfss-0.8.3.dist-info/entry_points.txt,sha256=VJ8svMz7RLtMCgNk99CElx7zo7M-N-z7BWDVw2HA92E,205
44
- lfss-0.8.3.dist-info/RECORD,,
40
+ lfss/src/utils.py,sha256=XHBDBODU6RPpYywArmMBVnghPk3crPLrSW4cKxqiP5Q,5887
41
+ lfss-0.9.0.dist-info/METADATA,sha256=QNSYV-4VzdS4VCmKtSvjSMurXTb1qvUhvW-Wvpz3dcA,2298
42
+ lfss-0.9.0.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
43
+ lfss-0.9.0.dist-info/entry_points.txt,sha256=VJ8svMz7RLtMCgNk99CElx7zo7M-N-z7BWDVw2HA92E,205
44
+ lfss-0.9.0.dist-info/RECORD,,
File without changes