cista 1.4.2__py3-none-any.whl → 1.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.
cista/serve.py CHANGED
@@ -2,6 +2,7 @@ import os
2
2
  import re
3
3
  from pathlib import Path
4
4
 
5
+ from fastapi_vue.hostutil import parse_endpoint
5
6
  from sanic import Sanic
6
7
 
7
8
  from cista import config, server80
@@ -44,18 +45,28 @@ def check_cert(certdir, domain):
44
45
 
45
46
 
46
47
  def parse_listen(listen):
47
- if listen.startswith("/"):
48
- unix = Path(listen).resolve()
48
+ # Domain name (e.g. example.com) -> HTTPS with LetsEncrypt
49
+ if re.fullmatch(r"(\w+(-\w+)*\.)+\w{2,}", listen, re.UNICODE):
50
+ return f"https://{listen}", {"host": listen, "port": 443, "ssl": True}
51
+
52
+ # Use fastapi_vue's parse_endpoint for everything else
53
+ endpoints = parse_endpoint(listen, default_port=8989)
54
+ ep = endpoints[0]
55
+
56
+ if "uds" in ep:
57
+ unix = Path(ep["uds"]).resolve()
49
58
  if not unix.parent.exists():
50
59
  raise ValueError(
51
60
  f"Directory for unix socket does not exist: {unix.parent}/",
52
61
  )
53
62
  return "http://localhost", {"unix": unix.as_posix()}
54
- if re.fullmatch(r"(\w+(-\w+)*\.)+\w{2,}", listen, re.UNICODE):
55
- return f"https://{listen}", {"host": listen, "port": 443, "ssl": True}
56
- try:
57
- addr, _port = listen.split(":", 1)
58
- port = int(_port)
59
- except Exception:
60
- raise ValueError(f"Invalid listen address: {listen}") from None
61
- return f"http://localhost:{port}", {"host": addr, "port": port}
63
+
64
+ host, port = ep["host"], ep["port"]
65
+ # When binding all interfaces, use single_listener=False for Sanic
66
+ if len(endpoints) > 1:
67
+ return f"http://localhost:{port}", {
68
+ "host": host,
69
+ "port": port,
70
+ "single_listener": False,
71
+ }
72
+ return f"http://{host}:{port}", {"host": host, "port": port}
cista/watching.py CHANGED
@@ -1,4 +1,5 @@
1
1
  import asyncio
2
+ import queue
2
3
  import shutil
3
4
  import sys
4
5
  import threading
@@ -116,6 +117,28 @@ state = State()
116
117
  rootpath: Path = None # type: ignore
117
118
  quit = threading.Event()
118
119
 
120
+ # Thread-safe queue for signaling path updates from websockets
121
+ _update_queue: queue.Queue[PurePosixPath] = queue.Queue()
122
+
123
+
124
+ def notify_change(*paths: PurePosixPath | str):
125
+ """Signal that paths have changed. Called from control/upload websockets."""
126
+ for path in paths:
127
+ if isinstance(path, str):
128
+ path = PurePosixPath(path)
129
+ # Convert absolute paths to relative (strip leading /)
130
+ if path.is_absolute():
131
+ path = (
132
+ PurePosixPath(*path.parts[1:])
133
+ if len(path.parts) > 1
134
+ else PurePosixPath()
135
+ )
136
+ # Skip root paths (empty, '.') to avoid full tree walks
137
+ if not path.parts or path.parts == (".",):
138
+ continue
139
+ _update_queue.put(path)
140
+
141
+
119
142
  ## Filesystem scanning
120
143
 
121
144
 
@@ -326,127 +349,373 @@ def format_root(root):
326
349
 
327
350
 
328
351
  def broadcast(msg, loop):
329
- return asyncio.run_coroutine_threadsafe(abroadcast(msg), loop).result()
352
+ fut = asyncio.run_coroutine_threadsafe(abroadcast(msg), loop)
353
+ return fut.result()
330
354
 
331
355
 
332
356
  async def abroadcast(msg):
357
+ client_count = 0
333
358
  try:
334
359
  for queue in pubsub.values():
335
360
  queue.put_nowait(msg)
361
+ client_count += 1
336
362
  except Exception:
337
363
  # Log because asyncio would silently eat the error
338
364
  logger.exception("Broadcast error")
365
+ return client_count
339
366
 
340
367
 
341
368
  ## Watcher thread
342
369
 
343
370
 
344
- def watcher_inotify(loop):
345
- """Inotify watcher thread (Linux only)"""
346
- import inotify.adapters
371
+ class PathIndex:
372
+ """O(1) path lookup index for the flat FileEntry tree."""
373
+
374
+ def __init__(self, root: list[FileEntry]):
375
+ self.root = root
376
+ self._index: dict[PurePosixPath, tuple[int, int]] = {}
377
+ self._rebuild()
378
+
379
+ def _rebuild(self):
380
+ """Build path -> (start_idx, count) mapping in single O(n) pass."""
381
+ index: dict[PurePosixPath, tuple[int, int]] = {}
382
+ path_stack: list[tuple[PurePosixPath, int]] = [] # (path, start_idx)
383
+
384
+ for i, entry in enumerate(self.root):
385
+ # Pop completed paths from stack
386
+ while path_stack and entry.level <= len(path_stack[-1][0].parts):
387
+ completed_path, start_idx = path_stack.pop()
388
+ index[completed_path] = (start_idx, i - start_idx)
389
+
390
+ # Build current path
391
+ if entry.level == 0:
392
+ current_path = PurePosixPath()
393
+ else:
394
+ parent = path_stack[-1][0] if path_stack else PurePosixPath()
395
+ current_path = parent / entry.name
396
+
397
+ path_stack.append((current_path, i))
398
+
399
+ # Close remaining paths
400
+ for path, start_idx in path_stack:
401
+ index[path] = (start_idx, len(self.root) - start_idx)
402
+
403
+ self._index = index
404
+
405
+ def get(self, path: PurePosixPath) -> tuple[int | None, list[FileEntry]]:
406
+ """O(1) lookup: returns (start_idx, entries) or (None, [])."""
407
+ if path not in self._index:
408
+ return None, []
409
+ start, count = self._index[path]
410
+ return start, self.root[start : start + count]
411
+
412
+ def find_insert_pos(self, path: PurePosixPath, isfile: int) -> int:
413
+ """Find insertion position using index + binary search."""
414
+ if not path.parts:
415
+ return 0
416
+
417
+ parent = path.parent
418
+ name = path.name
419
+
420
+ # Find parent's range
421
+ if parent == PurePosixPath():
422
+ # Insert at root level - scan root's direct children
423
+ start, count = 0, len(self.root)
424
+ target_level = 1
425
+ elif parent in self._index:
426
+ start, count = self._index[parent]
427
+ start += 1 # Skip parent entry itself
428
+ count -= 1
429
+ target_level = len(parent.parts) + 1
430
+ else:
431
+ # Parent doesn't exist, shouldn't happen
432
+ return len(self.root)
433
+
434
+ # Binary search among direct children at target_level
435
+ # Collect children indices first
436
+ children = []
437
+ i = start
438
+ end = start + count
439
+ while i < end:
440
+ entry = self.root[i]
441
+ if entry.level == target_level:
442
+ children.append(i)
443
+ i += 1
444
+
445
+ if not children:
446
+ return start
447
+
448
+ # Binary search for insertion point
449
+ nsort = sortkey(name)
450
+ lo, hi = 0, len(children)
451
+ while lo < hi:
452
+ mid = (lo + hi) // 2
453
+ idx = children[mid]
454
+ entry = self.root[idx]
455
+ ename = entry.name
456
+ esort = sortkey(ename)
457
+ # Compare: isfile, then sort key, then case-sensitive
458
+ cmp = (
459
+ entry.isfile - isfile
460
+ or (esort > nsort) - (esort < nsort)
461
+ or (ename > name) - (ename < name)
462
+ )
463
+ if cmp < 0:
464
+ lo = mid + 1
465
+ else:
466
+ hi = mid
467
+
468
+ if lo < len(children):
469
+ return children[lo]
470
+ elif children:
471
+ # Insert after last child's subtree
472
+ last_idx = children[-1]
473
+ last_entry = self.root[last_idx]
474
+ if last_entry.isfile:
475
+ return last_idx + 1
476
+ # Find end of last child's subtree
477
+ last_path = parent / last_entry.name
478
+ if last_path in self._index:
479
+ s, c = self._index[last_path]
480
+ return s + c
481
+ return last_idx + 1
482
+ return start
483
+
484
+ def apply_update(
485
+ self, path: PurePosixPath, new_entries: list[FileEntry]
486
+ ) -> list[FileEntry]:
487
+ """Apply an update and return the new root. Rebuilds index."""
488
+ start, old_entries = self.get(path)
489
+
490
+ if old_entries == new_entries:
491
+ return self.root
492
+
493
+ new_root = self.root[:]
494
+
495
+ if start is not None:
496
+ del new_root[start : start + len(old_entries)]
497
+
498
+ if new_entries:
499
+ # Rebuild index on modified list to find insert pos
500
+ self.root = new_root
501
+ self._rebuild()
502
+ insert_pos = self.find_insert_pos(path, new_entries[0].isfile)
503
+ new_root[insert_pos:insert_pos] = new_entries
504
+
505
+ self.root = new_root
506
+ self._rebuild()
507
+ return new_root
508
+
509
+
510
+ def collapse_paths(paths: set[PurePosixPath]) -> set[PurePosixPath]:
511
+ """Remove child paths if parent is in set."""
512
+ if not paths:
513
+ return paths
514
+ # Filter out root paths (empty or '.') which would cause full tree walks
515
+ paths = {p for p in paths if p.parts and p.parts != (".",)}
516
+ if not paths:
517
+ return set()
518
+ # Sort by depth (fewest parts first)
519
+ sorted_paths = sorted(paths, key=lambda p: len(p.parts))
520
+ result = set()
521
+ for path in sorted_paths:
522
+ # Check if any ancestor is already in result
523
+ is_child = False
524
+ for i in range(len(path.parts)):
525
+ ancestor = PurePosixPath(*path.parts[:i]) if i > 0 else PurePosixPath()
526
+ if ancestor in result:
527
+ is_child = True
528
+ break
529
+ if not is_child:
530
+ result.add(path)
531
+ return result
532
+
533
+
534
+ # Debounce settings
535
+ DEBOUNCE_DELAY = 0.01 # Wait 10ms after last event
536
+ DEBOUNCE_MAX = 0.1 # But no more than 100ms total
537
+
538
+
539
+ def watcher(loop):
540
+ """Unified watcher thread handling inotify, websocket signals, and periodic scans."""
541
+ use_inotify = sys.platform == "linux"
542
+ inotify_tree = None
543
+ modified_flags = frozenset()
544
+
545
+ if use_inotify:
546
+ import inotify.adapters
547
+
548
+ modified_flags = frozenset(
549
+ (
550
+ "IN_CREATE",
551
+ "IN_DELETE",
552
+ "IN_DELETE_SELF",
553
+ "IN_MODIFY",
554
+ "IN_MOVE_SELF",
555
+ "IN_MOVED_FROM",
556
+ "IN_MOVED_TO",
557
+ )
558
+ )
347
559
 
348
- modified_flags = (
349
- "IN_CREATE",
350
- "IN_DELETE",
351
- "IN_DELETE_SELF",
352
- "IN_MODIFY",
353
- "IN_MOVE_SELF",
354
- "IN_MOVED_FROM",
355
- "IN_MOVED_TO",
356
- )
357
560
  while not quit.is_set():
358
- i = inotify.adapters.InotifyTree(rootpath.as_posix())
561
+ if use_inotify:
562
+ import inotify.adapters
563
+
564
+ inotify_tree = inotify.adapters.InotifyTree(rootpath.as_posix())
565
+
359
566
  # Initialize the tree from filesystem
360
567
  update_root(loop)
568
+ path_index = PathIndex(state.root[:])
569
+
361
570
  trefresh = time.monotonic() + 300.0
362
571
  tspace = time.monotonic() + 5.0
363
- # Watch for changes (frequent wakeups needed for quiting)
572
+
573
+ # Pending changes: path -> {"ws": count, "inotify": count}
574
+ dirty_paths: dict[PurePosixPath, dict[str, int]] = {}
575
+ first_event_time: float | None = None
576
+ last_event_time: float | None = None
577
+
578
+ def add_dirty(path: PurePosixPath, source: str) -> bool:
579
+ """Add path to dirty set. Returns True if added, False if redundant."""
580
+ nonlocal first_event_time, last_event_time
581
+ # Check if already covered by an existing dirty path
582
+ for existing in dirty_paths:
583
+ if path == existing or (
584
+ len(path.parts) > len(existing.parts)
585
+ and path.parts[: len(existing.parts)] == existing.parts
586
+ ):
587
+ # Count the event even if skipped
588
+ dirty_paths[existing][source] = (
589
+ dirty_paths[existing].get(source, 0) + 1
590
+ )
591
+ return False
592
+ # Remove any paths that would be covered by this new one
593
+ covered = {
594
+ p
595
+ for p in dirty_paths
596
+ if len(p.parts) > len(path.parts)
597
+ and p.parts[: len(path.parts)] == path.parts
598
+ }
599
+ # Aggregate counts from covered paths
600
+ counts: dict[str, int] = {source: 1}
601
+ for p in covered:
602
+ for s, c in dirty_paths[p].items():
603
+ counts[s] = counts.get(s, 0) + c
604
+ del dirty_paths[p]
605
+ dirty_paths[path] = counts
606
+ now = time.monotonic()
607
+ if first_event_time is None:
608
+ first_event_time = now
609
+ last_event_time = now
610
+ return True
611
+
364
612
  while not quit.is_set():
365
- t = time.monotonic()
366
- # The watching is not entirely reliable, so do a full refresh every 30 seconds
367
- if t >= trefresh:
613
+ now = time.monotonic()
614
+
615
+ # Full refresh every 300s
616
+ if now >= trefresh:
368
617
  break
369
- # Disk usage update
370
- if t >= tspace:
371
- tspace = time.monotonic() + 5.0
618
+
619
+ # Disk usage update every 5s
620
+ if now >= tspace:
621
+ tspace = now + 5.0
372
622
  update_space(loop)
373
- # Inotify events, update the tree
374
- dirty = False
375
- rootmod = state.root[:]
376
- for event in i.event_gen(yield_nones=False, timeout_s=0.1):
377
- assert event
378
- if quit.is_set():
379
- return
380
- interesting = any(f in modified_flags for f in event[1])
381
- if interesting:
382
- # Update modified path
383
- path = PurePosixPath(event[2]) / event[3]
384
- try:
385
- rel_path = path.relative_to(rootpath)
386
- update_path(rootmod, rel_path, loop)
387
- except Exception as e:
388
- logger.error(
389
- f"Error processing inotify event for path {path}: {e}"
390
- )
391
- raise
392
- if not dirty:
393
- t = time.monotonic()
394
- dirty = True
395
- # Wait a maximum of 0.2s to push the updates
396
- if dirty and time.monotonic() >= t + 0.2:
397
- break
398
- if dirty and state.root != rootmod:
399
- try:
400
- update = format_update(state.root, rootmod)
401
- with state.lock:
402
- broadcast(update, loop)
403
- state.root = rootmod
404
- except Exception:
405
- logger.exception(
406
- "format_update failed; falling back to full rescan"
407
- )
408
- # Fallback: full rescan and try diff again; last resort send full root
623
+
624
+ # Check if we should flush pending changes
625
+ should_flush = False
626
+ if dirty_paths:
627
+ time_since_last = now - last_event_time if last_event_time else 0
628
+ time_since_first = now - first_event_time if first_event_time else 0
629
+ if (
630
+ time_since_last >= DEBOUNCE_DELAY
631
+ or time_since_first >= DEBOUNCE_MAX
632
+ ):
633
+ should_flush = True
634
+
635
+ if should_flush:
636
+ paths_to_process = dirty_paths.copy()
637
+ dirty_paths.clear()
638
+ first_event_time = None
639
+ last_event_time = None
640
+
641
+ # Collapse paths (remove children if parent present)
642
+ collapsed = collapse_paths(set(paths_to_process.keys()))
643
+
644
+ # Process each collapsed path
645
+ new_root = path_index.root
646
+ for path in collapsed:
647
+ new_entries = walk(path)
648
+ new_root = path_index.apply_update(path, new_entries)
649
+
650
+ # Broadcast if changed
651
+ if new_root != state.root:
409
652
  try:
410
- fresh = walk(PurePosixPath())
653
+ update_msg = format_update(state.root, new_root)
654
+ with state.lock:
655
+ broadcast(update_msg, loop)
656
+ state.root = new_root
657
+ except Exception:
658
+ logger.exception("format_update failed; full rescan")
411
659
  try:
412
- update = format_update(state.root, fresh)
660
+ fresh = walk(PurePosixPath())
661
+ path_index = PathIndex(fresh)
662
+ update_msg = format_update(state.root, fresh)
413
663
  with state.lock:
414
- broadcast(update, loop)
664
+ broadcast(update_msg, loop)
415
665
  state.root = fresh
416
666
  except Exception:
417
- logger.exception(
418
- "Fallback diff failed; sending full root snapshot"
419
- )
667
+ logger.exception("Fallback failed; sending full root")
420
668
  with state.lock:
421
669
  broadcast(format_root(fresh), loop)
422
670
  state.root = fresh
423
- except Exception:
424
- logger.exception(
425
- "Full rescan failed; dropping this batch of updates"
426
- )
427
671
 
428
- del i # Free the inotify object
672
+ # Collect events from websocket signals (non-blocking)
673
+ try:
674
+ while True:
675
+ path = _update_queue.get_nowait()
676
+ add_dirty(path, "ws")
677
+ except queue.Empty:
678
+ pass
679
+
680
+ # Collect inotify events if available (short timeout for responsiveness)
681
+ if inotify_tree:
682
+ for event in inotify_tree.event_gen(yield_nones=False, timeout_s=0.05):
683
+ if quit.is_set():
684
+ return
685
+ if not (modified_flags & set(event[1])):
686
+ continue
687
+
688
+ # Extract relative path
689
+ path = PurePosixPath(event[2]) / event[3]
690
+ try:
691
+ rel_path = path.relative_to(rootpath)
692
+ except ValueError:
693
+ continue
429
694
 
695
+ # Skip dotfiles
696
+ if any(part.startswith(".") for part in rel_path.parts):
697
+ continue
430
698
 
431
- def watcher_poll(loop):
432
- """Polling version of the watcher thread."""
433
- while not quit.is_set():
434
- t0 = time.perf_counter()
435
- update_root(loop)
436
- update_space(loop)
437
- dur = time.perf_counter() - t0
438
- if dur > 1.0:
439
- logger.debug(f"Reading the full file list took {dur:.1f}s")
440
- quit.wait(0.1 + 8 * dur)
699
+ add_dirty(rel_path, "inotify")
700
+
701
+ # Don't block too long collecting events
702
+ now = time.monotonic()
703
+ if first_event_time and now - first_event_time >= DEBOUNCE_MAX:
704
+ break
705
+ else:
706
+ # No inotify, just sleep briefly for responsiveness
707
+ time.sleep(0.05)
708
+
709
+ if inotify_tree:
710
+ del inotify_tree
441
711
 
442
712
 
443
713
  def start(app):
444
714
  global rootpath
445
715
  config.load_config()
446
716
  rootpath = config.config.path
447
- use_inotify = sys.platform == "linux"
448
717
  app.ctx.watcher = threading.Thread(
449
- target=watcher_inotify if use_inotify else watcher_poll,
718
+ target=watcher,
450
719
  args=[app.loop],
451
720
  # Descriptive name for system monitoring
452
721
  name=f"cista-watcher {rootpath}",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cista
3
- Version: 1.4.2
3
+ Version: 1.5.0
4
4
  Summary: Dropbox-like file server with modern web interface
5
5
  Project-URL: Homepage, https://git.zi.fi/Vasanko/cista-storage
6
6
  Author: Vasanko
@@ -16,8 +16,8 @@ Requires-Python: >=3.11
16
16
  Requires-Dist: argon2-cffi>=25.1.0
17
17
  Requires-Dist: av>=15.0.0
18
18
  Requires-Dist: blake3>=1.0.5
19
- Requires-Dist: docopt>=0.6.2
20
- Requires-Dist: fastapi-vue>=0.5.1
19
+ Requires-Dist: docopt-ng>=0.9.0
20
+ Requires-Dist: fastapi-vue>=0.5.2
21
21
  Requires-Dist: fastapi[standard]>=0.128.0
22
22
  Requires-Dist: html5tagger>=1.3.0
23
23
  Requires-Dist: httpx>=0.28.0
@@ -0,0 +1,33 @@
1
+ cista/__init__.py,sha256=4IT57gQUTOV6q9zFGj9i6E9LmiRz3dIfHK3q4qCeOIc,66
2
+ cista/__main__.py,sha256=_OVF8Js2cOLYiEiGFzU-2tLKxNB7je2sJPtPBzxRAro,6926
3
+ cista/_version.py,sha256=7QEFslgnfsEfWLaZBQ4XRIkrvnxd18xbV0t8PvA28po,77
4
+ cista/api.py,sha256=FAZG1yj3dux5WZ9FyT2DRtDb5oYRVxXSnGuZl18HSWI,5827
5
+ cista/app.py,sha256=5iownX81gZIDjrqurGybjOGAiDE1IHr4R3SGrRkViqY,10205
6
+ cista/auth.py,sha256=-xYYAYhNIivgkchfW0P7-fuJT1fodPpg-c0niVjgf4g,14339
7
+ cista/config.py,sha256=noKbZjpcEq5WG55WEkuKKudBzb5InoGEDtSWeUdVrJM,6183
8
+ cista/droppy.py,sha256=04a7TyZjmgmaLifGyH3WMAbwCAGBGPEl9oFYc423dQA,1304
9
+ cista/fileio.py,sha256=A8OBsv_jN4IgHaZCr8s9t6uqASu3r6qxPCILPKVxXQ0,2939
10
+ cista/preview.py,sha256=VA0zejs997wp6-UZcKt8gzJRu0ACog0UNhHrdcx6od8,9541
11
+ cista/protocol.py,sha256=9nWM-TgvExtR8fpTyUlF0x7-bVZADFEJNlFaPdkPk7U,4321
12
+ cista/serve.py,sha256=XssVa6IEsmrkR83d79V_ZIhnbvqIBkVMv7B833Co1y8,2284
13
+ cista/server80.py,sha256=opjtARyheVXQfJ5wBOMyzGDAtpMHnOpKNoZ1EsIytnI,780
14
+ cista/session.py,sha256=zc-A0iPaepAOLIoO5PTAHy9qn2NZb2zQir3Rm4ZmaK0,1090
15
+ cista/sso.py,sha256=z3ELX7Griv6W_OQuZA7y-u0rjk9ljHCUjrCEV-zEYE0,10410
16
+ cista/watching.py,sha256=HjvV3d4QUVPjsH_86fyewqRPgheczBQXUxmyq4cmqW0,23478
17
+ cista/frontend-build/index.html,sha256=BWYc8PA_YSfLUB3xe1DaIWiI-5aKxL-24fP-mddtroE,725
18
+ cista/frontend-build/robots.txt,sha256=Mx6pCQ2wyfb1l72YQP1bFxgw9uCzuhyyTfqR8Mla7cE,26
19
+ cista/frontend-build/assets/icons-6j2UZ_HA.js,sha256=J-0NXwc1tsV85JMGEE2GCGPFBt8dMX-g1nlYQKIBAEQ,102556
20
+ cista/frontend-build/assets/index-BHQJhxzY.js,sha256=mhPbB7QIYZx-PIHD49yv22ASnodrLp-Xp1LlLbLuaXE,128408
21
+ cista/frontend-build/assets/index-De5UYAfC.css,sha256=NNvkrLY57y7n0WPVFBD_vvqZUenlM9gNVxChjjhbgGw,33698
22
+ cista/frontend-build/assets/logo-ctv8tVwU.svg,sha256=l9HX62cauXDeo8zXU5-xNWhLmm-y0-xsXp_1xzHQX7s,258
23
+ cista/frontend-build/assets/searchWorker-DbTcSX-c.js,sha256=aKcLgV0IFvq6yarxebeUVash4rQlNuYiKcR4MyB-lp0,1903
24
+ cista/util/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
25
+ cista/util/apphelpers.py,sha256=sQ3oQGa3XlgPIIKeYDgk6wzmtml-O3GZH7g3lsePUXY,2603
26
+ cista/util/asynclink.py,sha256=3jl57o6f2AQGYDKV4hJFHSJUuJaKy3-dBctxNut8_9E,3122
27
+ cista/util/filename.py,sha256=_0qO7mPudritrSnlHQTAh03KiunYrt3I9vat5ZVXXEg,581
28
+ cista/util/lrucache.py,sha256=Gpzx7FNtN1Xkdlgipa1oGebnWolMJVZfruW3KKlX7Ms,2070
29
+ cista/util/pwgen.py,sha256=yapMYCAzOGqIm51LsQoBbkXAQQufAhbX0hOI1fPQ8nQ,6276
30
+ cista-1.5.0.dist-info/METADATA,sha256=DWoS7PlMPVxGbd0QV_X0IYKMj0dtv6Vsw0JnqtwzN-Y,8268
31
+ cista-1.5.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
32
+ cista-1.5.0.dist-info/entry_points.txt,sha256=_XK4tjdkpyX8yUrVMHboZ5EJ9Q1bNqhuIkKoA06WiKw,46
33
+ cista-1.5.0.dist-info/RECORD,,