paskia 0.7.2__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.
Files changed (39) hide show
  1. paskia/_version.py +2 -2
  2. paskia/authsession.py +12 -49
  3. paskia/bootstrap.py +30 -25
  4. paskia/db/__init__.py +163 -401
  5. paskia/db/background.py +128 -0
  6. paskia/db/jsonl.py +132 -0
  7. paskia/db/operations.py +1241 -0
  8. paskia/db/structs.py +148 -0
  9. paskia/fastapi/admin.py +456 -215
  10. paskia/fastapi/api.py +16 -15
  11. paskia/fastapi/authz.py +7 -2
  12. paskia/fastapi/mainapp.py +2 -1
  13. paskia/fastapi/remote.py +20 -20
  14. paskia/fastapi/reset.py +9 -10
  15. paskia/fastapi/user.py +10 -18
  16. paskia/fastapi/ws.py +22 -19
  17. paskia/frontend-build/auth/admin/index.html +3 -3
  18. paskia/frontend-build/auth/assets/AccessDenied-aTdCvz9k.js +8 -0
  19. paskia/frontend-build/auth/assets/admin-BeNu48FR.css +1 -0
  20. paskia/frontend-build/auth/assets/admin-tVs8oyLv.js +1 -0
  21. paskia/frontend-build/auth/assets/{auth-BU_O38k2.css → auth-BKX7shEe.css} +1 -1
  22. paskia/frontend-build/auth/assets/auth-Dk3q4pNS.js +1 -0
  23. paskia/frontend-build/auth/index.html +3 -3
  24. paskia/globals.py +7 -10
  25. paskia/migrate/__init__.py +274 -0
  26. paskia/migrate/sql.py +381 -0
  27. paskia/util/permutil.py +16 -5
  28. paskia/util/sessionutil.py +3 -2
  29. paskia/util/userinfo.py +12 -26
  30. {paskia-0.7.2.dist-info → paskia-0.8.0.dist-info}/METADATA +21 -25
  31. {paskia-0.7.2.dist-info → paskia-0.8.0.dist-info}/RECORD +33 -29
  32. {paskia-0.7.2.dist-info → paskia-0.8.0.dist-info}/entry_points.txt +1 -0
  33. paskia/db/sql.py +0 -1424
  34. paskia/frontend-build/auth/assets/AccessDenied-C-lL9vbN.js +0 -8
  35. paskia/frontend-build/auth/assets/admin-Cs6Mg773.css +0 -1
  36. paskia/frontend-build/auth/assets/admin-Df5_Damp.js +0 -1
  37. paskia/frontend-build/auth/assets/auth-Df3pjeSS.js +0 -1
  38. paskia/util/tokens.py +0 -44
  39. {paskia-0.7.2.dist-info → paskia-0.8.0.dist-info}/WHEEL +0 -0
paskia/db/jsonl.py ADDED
@@ -0,0 +1,132 @@
1
+ """
2
+ JSONL persistence layer for the database.
3
+
4
+ Handles file I/O, JSON diffs, and persistence. Works with plain JSON/dict data.
5
+ Uses aiofiles for async I/O operations.
6
+ """
7
+
8
+ import logging
9
+ from collections import deque
10
+ from datetime import datetime, timezone
11
+ from pathlib import Path
12
+
13
+ import aiofiles
14
+ import jsondiff
15
+ import msgspec
16
+
17
+ _logger = logging.getLogger(__name__)
18
+
19
+ # Default database path
20
+ DB_PATH_DEFAULT = "paskia.jsonl"
21
+
22
+
23
+ class _ChangeRecord(msgspec.Struct, omit_defaults=True):
24
+ """A single change record in the JSONL file."""
25
+
26
+ ts: datetime
27
+ a: str # action - describes the operation (e.g., "migrate", "login", "create_user")
28
+ u: str | None = None # user UUID who performed the action (None for system)
29
+ diff: dict = {}
30
+
31
+
32
+ # msgspec encoder for change records
33
+ _change_encoder = msgspec.json.Encoder()
34
+
35
+
36
+ async def load_jsonl(db_path: Path) -> dict:
37
+ """Load data from disk by applying change log.
38
+
39
+ Replays all changes from JSONL file using plain dicts (to handle
40
+ schema evolution).
41
+
42
+ Args:
43
+ db_path: Path to the JSONL database file
44
+
45
+ Returns:
46
+ The final state after applying all changes
47
+
48
+ Raises:
49
+ ValueError: If file doesn't exist or cannot be loaded
50
+ """
51
+ if not db_path.exists():
52
+ raise ValueError(f"Database file not found: {db_path}")
53
+ data_dict: dict = {}
54
+ try:
55
+ # Read entire file at once and split into lines
56
+ async with aiofiles.open(db_path, "rb") as f:
57
+ content = await f.read()
58
+ for line_num, line in enumerate(content.split(b"\n"), 1):
59
+ line = line.strip()
60
+ if not line:
61
+ continue
62
+ try:
63
+ change = msgspec.json.decode(line)
64
+ # Apply the diff to current state (marshal=True for $-prefixed keys)
65
+ data_dict = jsondiff.patch(data_dict, change["diff"], marshal=True)
66
+ except Exception as e:
67
+ raise ValueError(f"Error parsing line {line_num}: {e}")
68
+ except (OSError, ValueError, msgspec.DecodeError) as e:
69
+ raise ValueError(f"Failed to load database: {e}")
70
+ return data_dict
71
+
72
+
73
+ def compute_diff(previous: dict, current: dict) -> dict | None:
74
+ """Compute JSON diff between two states.
75
+
76
+ Args:
77
+ previous: Previous state (JSON-compatible dict)
78
+ current: Current state (JSON-compatible dict)
79
+
80
+ Returns:
81
+ The diff, or None if no changes
82
+ """
83
+ diff = jsondiff.diff(previous, current, marshal=True)
84
+ return diff if diff else None
85
+
86
+
87
+ def create_change_record(
88
+ action: str, diff: dict, user: str | None = None
89
+ ) -> _ChangeRecord:
90
+ """Create a change record for persistence."""
91
+ return _ChangeRecord(
92
+ ts=datetime.now(timezone.utc),
93
+ a=action,
94
+ u=user,
95
+ diff=diff,
96
+ )
97
+
98
+
99
+ async def flush_changes(
100
+ db_path: Path,
101
+ pending_changes: deque[_ChangeRecord],
102
+ ) -> bool:
103
+ """Write all pending changes to disk.
104
+
105
+ Args:
106
+ db_path: Path to the JSONL database file
107
+ pending_changes: Queue of pending change records (will be cleared on success)
108
+
109
+ Returns:
110
+ True if flush succeeded, False otherwise
111
+ """
112
+ if not pending_changes:
113
+ return True
114
+
115
+ # Collect all pending changes
116
+ changes_to_write = list(pending_changes)
117
+ pending_changes.clear()
118
+
119
+ try:
120
+ # Build lines to append (keep as bytes, join with \n)
121
+ lines = [_change_encoder.encode(change) for change in changes_to_write]
122
+
123
+ # Append all lines in a single write (binary mode for Windows compatibility)
124
+ async with aiofiles.open(db_path, "ab") as f:
125
+ await f.write(b"\n".join(lines) + b"\n")
126
+ return True
127
+ except OSError:
128
+ _logger.exception("Failed to flush database changes")
129
+ # Re-queue the changes on failure
130
+ for change in reversed(changes_to_write):
131
+ pending_changes.appendleft(change)
132
+ return False