paskia 0.9.0__py3-none-any.whl → 0.10.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.
- paskia/_version.py +2 -2
- paskia/aaguid/__init__.py +5 -4
- paskia/authsession.py +4 -19
- paskia/db/__init__.py +2 -4
- paskia/db/background.py +3 -3
- paskia/db/jsonl.py +99 -111
- paskia/db/logging.py +318 -0
- paskia/db/migrations.py +19 -20
- paskia/db/operations.py +107 -196
- paskia/db/structs.py +236 -46
- paskia/fastapi/__main__.py +13 -6
- paskia/fastapi/admin.py +72 -195
- paskia/fastapi/api.py +56 -58
- paskia/fastapi/authz.py +3 -8
- paskia/fastapi/logging.py +261 -0
- paskia/fastapi/mainapp.py +14 -3
- paskia/fastapi/remote.py +11 -37
- paskia/fastapi/reset.py +0 -2
- paskia/fastapi/response.py +22 -0
- paskia/fastapi/user.py +7 -7
- paskia/fastapi/ws.py +14 -37
- paskia/fastapi/wschat.py +55 -2
- paskia/fastapi/wsutil.py +10 -2
- paskia/frontend-build/auth/admin/index.html +6 -6
- paskia/frontend-build/auth/assets/AccessDenied-C29NZI95.css +1 -0
- paskia/frontend-build/auth/assets/AccessDenied-DAdzg_MJ.js +12 -0
- paskia/frontend-build/auth/assets/{RestrictedAuth-CvR33_Z0.css → RestrictedAuth-BOdNrlQB.css} +1 -1
- paskia/frontend-build/auth/assets/{RestrictedAuth-DsJXicIw.js → RestrictedAuth-BSusdAfp.js} +1 -1
- paskia/frontend-build/auth/assets/_plugin-vue_export-helper-D2l53SUz.js +49 -0
- paskia/frontend-build/auth/assets/_plugin-vue_export-helper-DYJ24FZK.css +1 -0
- paskia/frontend-build/auth/assets/admin-BeFvGyD6.js +1 -0
- paskia/frontend-build/auth/assets/{admin-DzzjSg72.css → admin-CmNtuH3s.css} +1 -1
- paskia/frontend-build/auth/assets/{auth-C7k64Wad.css → auth-BKq4T2K2.css} +1 -1
- paskia/frontend-build/auth/assets/auth-DvHf8hgy.js +1 -0
- paskia/frontend-build/auth/assets/{forward-DmqVHZ7e.js → forward-C86Jm_Uq.js} +1 -1
- paskia/frontend-build/auth/assets/reset-B8PlNXuP.css +1 -0
- paskia/frontend-build/auth/assets/reset-D71FG0VL.js +1 -0
- paskia/frontend-build/auth/assets/{restricted-D3AJx3_6.js → restricted-CW0drE_k.js} +1 -1
- paskia/frontend-build/auth/index.html +6 -6
- paskia/frontend-build/auth/restricted/index.html +5 -5
- paskia/frontend-build/int/forward/index.html +5 -5
- paskia/frontend-build/int/reset/index.html +4 -4
- paskia/migrate/__init__.py +9 -9
- paskia/migrate/sql.py +26 -19
- paskia/remoteauth.py +6 -6
- {paskia-0.9.0.dist-info → paskia-0.10.0.dist-info}/METADATA +1 -1
- paskia-0.10.0.dist-info/RECORD +60 -0
- paskia/frontend-build/auth/assets/AccessDenied-DPkUS8LZ.css +0 -1
- paskia/frontend-build/auth/assets/AccessDenied-Fmeb6EtF.js +0 -8
- paskia/frontend-build/auth/assets/_plugin-vue_export-helper-BTzJAQlS.css +0 -1
- paskia/frontend-build/auth/assets/_plugin-vue_export-helper-nhjnO_bd.js +0 -2
- paskia/frontend-build/auth/assets/admin-CPE1pLMm.js +0 -1
- paskia/frontend-build/auth/assets/auth-YIZvPlW_.js +0 -1
- paskia/frontend-build/auth/assets/reset-Chtv69AT.css +0 -1
- paskia/frontend-build/auth/assets/reset-s20PATTN.js +0 -1
- paskia-0.9.0.dist-info/RECORD +0 -57
- {paskia-0.9.0.dist-info → paskia-0.10.0.dist-info}/WHEEL +0 -0
- {paskia-0.9.0.dist-info → paskia-0.10.0.dist-info}/entry_points.txt +0 -0
paskia/db/logging.py
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Database change logging with pretty-printed diffs.
|
|
3
|
+
|
|
4
|
+
Provides a logger for JSONL database changes that formats diffs
|
|
5
|
+
in a human-readable path.notation style with color coding.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import re
|
|
10
|
+
import sys
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger("paskia.db")
|
|
14
|
+
|
|
15
|
+
# Pattern to match control characters and bidirectional overrides
|
|
16
|
+
_UNSAFE_CHARS = re.compile(
|
|
17
|
+
r"[\x00-\x1f\x7f-\x9f" # C0 and C1 control characters
|
|
18
|
+
r"\u200e\u200f" # LRM, RLM
|
|
19
|
+
r"\u202a-\u202e" # LRE, RLE, PDF, LRO, RLO
|
|
20
|
+
r"\u2066-\u2069" # LRI, RLI, FSI, PDI
|
|
21
|
+
r"]"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
# ANSI color codes (matching FastAPI logging style)
|
|
25
|
+
_RESET = "\033[0m"
|
|
26
|
+
_DIM = "\033[2m"
|
|
27
|
+
_PATH_PREFIX = "\033[1;30m" # Dark grey for path prefix (like host in access log)
|
|
28
|
+
_PATH_FINAL = "\033[0m" # Default for final element (like path in access log)
|
|
29
|
+
_DELETE = "\033[1;31m" # Red for deletions
|
|
30
|
+
_ADD = "\033[0;32m" # Green for additions
|
|
31
|
+
_ACTION = "\033[1;34m" # Bold blue for action name
|
|
32
|
+
_USER = "\033[0;34m" # Blue for user display
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _use_color() -> bool:
|
|
36
|
+
"""Check if we should use color output."""
|
|
37
|
+
return sys.stderr.isatty()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _format_value(value: Any, use_color: bool, max_len: int = 60) -> str:
|
|
41
|
+
"""Format a value for display, truncating if needed."""
|
|
42
|
+
if value is None:
|
|
43
|
+
return "null"
|
|
44
|
+
|
|
45
|
+
if isinstance(value, bool):
|
|
46
|
+
return "true" if value else "false"
|
|
47
|
+
|
|
48
|
+
if isinstance(value, (int, float)):
|
|
49
|
+
return str(value)
|
|
50
|
+
|
|
51
|
+
if isinstance(value, str):
|
|
52
|
+
# Filter out control characters and bidirectional overrides
|
|
53
|
+
value = _UNSAFE_CHARS.sub("", value)
|
|
54
|
+
# Truncate long strings
|
|
55
|
+
if len(value) > max_len:
|
|
56
|
+
return value[: max_len - 3] + "..."
|
|
57
|
+
return value
|
|
58
|
+
|
|
59
|
+
if isinstance(value, dict):
|
|
60
|
+
if not value:
|
|
61
|
+
return "{}"
|
|
62
|
+
# For small dicts, show inline
|
|
63
|
+
if len(value) == 1:
|
|
64
|
+
k, v = next(iter(value.items()))
|
|
65
|
+
return "{" + f"{k}: {_format_value(v, use_color, max_len=30)}" + "}"
|
|
66
|
+
return f"{{...{len(value)} keys}}"
|
|
67
|
+
|
|
68
|
+
if isinstance(value, list):
|
|
69
|
+
if not value:
|
|
70
|
+
return "[]"
|
|
71
|
+
if len(value) == 1:
|
|
72
|
+
return "[" + _format_value(value[0], use_color, max_len=30) + "]"
|
|
73
|
+
return f"[...{len(value)} items]"
|
|
74
|
+
|
|
75
|
+
# Fallback for other types
|
|
76
|
+
text = str(value)
|
|
77
|
+
if len(text) > max_len:
|
|
78
|
+
text = text[: max_len - 3] + "..."
|
|
79
|
+
return text
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _format_path(path: list[str], use_color: bool) -> str:
|
|
83
|
+
"""Format a path as dot notation with prefix in dark grey, final in default."""
|
|
84
|
+
if not path:
|
|
85
|
+
return ""
|
|
86
|
+
if not use_color:
|
|
87
|
+
return ".".join(path)
|
|
88
|
+
if len(path) == 1:
|
|
89
|
+
return f"{_PATH_FINAL}{path[0]}{_RESET}"
|
|
90
|
+
prefix = ".".join(path[:-1])
|
|
91
|
+
final = path[-1]
|
|
92
|
+
return f"{_PATH_PREFIX}{prefix}.{_RESET}{_PATH_FINAL}{final}{_RESET}"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _get_nested(data: dict | None, path: list[str]) -> Any:
|
|
96
|
+
"""Get a nested value from a dict by path, or None if not found."""
|
|
97
|
+
if data is None:
|
|
98
|
+
return None
|
|
99
|
+
current = data
|
|
100
|
+
for key in path:
|
|
101
|
+
if not isinstance(current, dict) or key not in current:
|
|
102
|
+
return None
|
|
103
|
+
current = current[key]
|
|
104
|
+
return current
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _collect_changes(
|
|
108
|
+
diff: dict,
|
|
109
|
+
path: list[str],
|
|
110
|
+
changes: list[tuple[str, list[str], Any]],
|
|
111
|
+
previous: dict | None,
|
|
112
|
+
) -> None:
|
|
113
|
+
"""
|
|
114
|
+
Recursively collect changes from a diff into a flat list.
|
|
115
|
+
|
|
116
|
+
Each change is a tuple of (change_type, path, new_value).
|
|
117
|
+
change_type is one of: 'add', 'update', 'delete'
|
|
118
|
+
"""
|
|
119
|
+
if not isinstance(diff, dict):
|
|
120
|
+
# Leaf value - check if it existed before
|
|
121
|
+
existed = _get_nested(previous, path) is not None
|
|
122
|
+
changes.append(("update" if existed else "add", path, diff))
|
|
123
|
+
return
|
|
124
|
+
|
|
125
|
+
for key, value in diff.items():
|
|
126
|
+
if key == "$delete":
|
|
127
|
+
# $delete contains a list of keys to delete
|
|
128
|
+
if isinstance(value, list):
|
|
129
|
+
for deleted_key in value:
|
|
130
|
+
changes.append(("delete", path + [str(deleted_key)], None))
|
|
131
|
+
else:
|
|
132
|
+
changes.append(("delete", path + [str(value)], None))
|
|
133
|
+
|
|
134
|
+
elif key == "$replace":
|
|
135
|
+
# $replace replaces the entire collection at this path
|
|
136
|
+
# We need to track what was added and what was deleted
|
|
137
|
+
old_collection = _get_nested(previous, path)
|
|
138
|
+
old_keys = (
|
|
139
|
+
set(old_collection.keys())
|
|
140
|
+
if isinstance(old_collection, dict)
|
|
141
|
+
else set()
|
|
142
|
+
)
|
|
143
|
+
new_keys = set(value.keys()) if isinstance(value, dict) else set()
|
|
144
|
+
|
|
145
|
+
# Items that existed before but not in new = deleted
|
|
146
|
+
for deleted_key in old_keys - new_keys:
|
|
147
|
+
changes.append(("delete", path + [str(deleted_key)], None))
|
|
148
|
+
|
|
149
|
+
# Items in new collection
|
|
150
|
+
if isinstance(value, dict):
|
|
151
|
+
for rkey, rval in value.items():
|
|
152
|
+
existed = rkey in old_keys
|
|
153
|
+
changes.append(
|
|
154
|
+
("update" if existed else "add", path + [str(rkey)], rval)
|
|
155
|
+
)
|
|
156
|
+
elif value or not old_keys:
|
|
157
|
+
# Non-dict replacement or empty replacement with nothing before
|
|
158
|
+
changes.append(
|
|
159
|
+
("update" if old_collection is not None else "add", path, value)
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
elif key.startswith("$"):
|
|
163
|
+
# Other special operations (future-proofing)
|
|
164
|
+
changes.append(("add", path, {key: value}))
|
|
165
|
+
|
|
166
|
+
else:
|
|
167
|
+
# Regular nested key - check if this item existed before
|
|
168
|
+
new_path = path + [str(key)]
|
|
169
|
+
existed = _get_nested(previous, new_path) is not None
|
|
170
|
+
if existed:
|
|
171
|
+
# Item exists - recurse to show specific field changes
|
|
172
|
+
_collect_changes(value, new_path, changes, previous)
|
|
173
|
+
else:
|
|
174
|
+
# New item - record as add with full value, don't recurse
|
|
175
|
+
changes.append(("add", new_path, value))
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _format_change_lines(
|
|
179
|
+
change_type: str, path: list[str], value: Any, use_color: bool
|
|
180
|
+
) -> list[str]:
|
|
181
|
+
"""Format a single change as one or more lines."""
|
|
182
|
+
if change_type == "delete":
|
|
183
|
+
if not use_color:
|
|
184
|
+
return [f" {'.'.join(path)} ✗"]
|
|
185
|
+
if len(path) == 1:
|
|
186
|
+
return [f" {_DELETE}{path[0]} ✗{_RESET}"]
|
|
187
|
+
prefix = ".".join(path[:-1])
|
|
188
|
+
final = path[-1]
|
|
189
|
+
return [f" {_PATH_PREFIX}{prefix}.{_RESET}{_DELETE}{final} ✗{_RESET}"]
|
|
190
|
+
|
|
191
|
+
if change_type == "add":
|
|
192
|
+
# New item being created - only final element in green
|
|
193
|
+
# For dict values, show children on separate indented lines
|
|
194
|
+
if isinstance(value, dict) and value:
|
|
195
|
+
lines = []
|
|
196
|
+
# First line: path with green final element and grey =
|
|
197
|
+
if not use_color:
|
|
198
|
+
lines.append(f" {'.'.join(path)} =")
|
|
199
|
+
elif len(path) == 1:
|
|
200
|
+
lines.append(f" {_ADD}{path[0]}{_RESET} {_DIM}={_RESET}")
|
|
201
|
+
else:
|
|
202
|
+
prefix = ".".join(path[:-1])
|
|
203
|
+
final = path[-1]
|
|
204
|
+
lines.append(
|
|
205
|
+
f" {_PATH_PREFIX}{prefix}.{_RESET}{_ADD}{final}{_RESET} {_DIM}={_RESET}"
|
|
206
|
+
)
|
|
207
|
+
# Child lines: indented key: value, with aligned values
|
|
208
|
+
max_key_len = max(len(k) for k in value.keys())
|
|
209
|
+
field_width = max(max_key_len, 12) # minimum 12 chars
|
|
210
|
+
for k, v in value.items():
|
|
211
|
+
v_str = _format_value(v, use_color)
|
|
212
|
+
padding = " " * (field_width - len(k))
|
|
213
|
+
if use_color:
|
|
214
|
+
lines.append(f" {k}{_DIM}:{_RESET}{padding} {v_str}")
|
|
215
|
+
else:
|
|
216
|
+
lines.append(f" {k}:{padding} {v_str}")
|
|
217
|
+
return lines
|
|
218
|
+
else:
|
|
219
|
+
value_str = _format_value(value, use_color)
|
|
220
|
+
if not use_color:
|
|
221
|
+
return [f" {'.'.join(path)} = {value_str}"]
|
|
222
|
+
if len(path) == 1:
|
|
223
|
+
return [f" {_ADD}{path[0]}{_RESET} {_DIM}={_RESET} {value_str}"]
|
|
224
|
+
prefix = ".".join(path[:-1])
|
|
225
|
+
final = path[-1]
|
|
226
|
+
return [
|
|
227
|
+
f" {_PATH_PREFIX}{prefix}.{_RESET}{_ADD}{final}{_RESET} {_DIM}={_RESET} {value_str}"
|
|
228
|
+
]
|
|
229
|
+
|
|
230
|
+
# update: Existing item being updated - normal path colors
|
|
231
|
+
value_str = _format_value(value, use_color)
|
|
232
|
+
path_str = _format_path(path, use_color)
|
|
233
|
+
if use_color:
|
|
234
|
+
return [f" {path_str} {_DIM}={_RESET} {value_str}"]
|
|
235
|
+
return [f" {path_str} = {value_str}"]
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def format_diff(diff: dict, previous: dict | None = None) -> list[str]:
|
|
239
|
+
"""
|
|
240
|
+
Format a JSON diff as human-readable lines.
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
diff: The JSON diff dict
|
|
244
|
+
previous: The previous state dict (for determining add vs update)
|
|
245
|
+
|
|
246
|
+
Returns a list of formatted lines (without newlines).
|
|
247
|
+
Single changes return one line, multiple changes return multiple lines.
|
|
248
|
+
"""
|
|
249
|
+
use_color = _use_color()
|
|
250
|
+
changes: list[tuple[str, list[str], Any]] = []
|
|
251
|
+
_collect_changes(diff, [], changes, previous)
|
|
252
|
+
|
|
253
|
+
if not changes:
|
|
254
|
+
return []
|
|
255
|
+
|
|
256
|
+
# Format each change
|
|
257
|
+
lines = []
|
|
258
|
+
for change_type, path, value in changes:
|
|
259
|
+
lines.extend(_format_change_lines(change_type, path, value, use_color))
|
|
260
|
+
|
|
261
|
+
return lines
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def format_action_header(action: str, user_display: str | None = None) -> str:
|
|
265
|
+
"""Format the action header line."""
|
|
266
|
+
use_color = _use_color()
|
|
267
|
+
|
|
268
|
+
if use_color:
|
|
269
|
+
action_str = f"{_ACTION}{action}{_RESET}"
|
|
270
|
+
if user_display:
|
|
271
|
+
user_str = f"{_USER}{user_display}{_RESET}"
|
|
272
|
+
return f"{action_str} by {user_str}"
|
|
273
|
+
return action_str
|
|
274
|
+
else:
|
|
275
|
+
if user_display:
|
|
276
|
+
return f"{action} by {user_display}"
|
|
277
|
+
return action
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def log_change(
|
|
281
|
+
action: str,
|
|
282
|
+
diff: dict,
|
|
283
|
+
user_display: str | None = None,
|
|
284
|
+
previous: dict | None = None,
|
|
285
|
+
) -> None:
|
|
286
|
+
"""
|
|
287
|
+
Log a database change with pretty-printed diff.
|
|
288
|
+
|
|
289
|
+
Args:
|
|
290
|
+
action: The action name (e.g., "login", "admin:delete_user")
|
|
291
|
+
diff: The JSON diff dict
|
|
292
|
+
user_display: Optional display name of the user who performed the action
|
|
293
|
+
previous: The previous state dict (for determining add vs update)
|
|
294
|
+
"""
|
|
295
|
+
header = format_action_header(action, user_display)
|
|
296
|
+
diff_lines = format_diff(diff, previous)
|
|
297
|
+
|
|
298
|
+
if not diff_lines:
|
|
299
|
+
logger.info(header)
|
|
300
|
+
return
|
|
301
|
+
|
|
302
|
+
if len(diff_lines) == 1:
|
|
303
|
+
# Single change - combine on one line
|
|
304
|
+
logger.info(f"{header}{diff_lines[0]}")
|
|
305
|
+
else:
|
|
306
|
+
# Multiple changes - header on its own line, then changes
|
|
307
|
+
logger.info(header)
|
|
308
|
+
for line in diff_lines:
|
|
309
|
+
logger.info(line)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def configure_db_logging() -> None:
|
|
313
|
+
"""Configure the database logger to output to stderr without prefix."""
|
|
314
|
+
handler = logging.StreamHandler(sys.stderr)
|
|
315
|
+
handler.setFormatter(logging.Formatter("%(message)s"))
|
|
316
|
+
logger.addHandler(handler)
|
|
317
|
+
logger.setLevel(logging.INFO)
|
|
318
|
+
logger.propagate = False
|
paskia/db/migrations.py
CHANGED
|
@@ -5,30 +5,29 @@ Migrations are applied during database load based on the version field.
|
|
|
5
5
|
Each migration should be idempotent and only run when needed.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
-
import
|
|
8
|
+
from collections.abc import Awaitable, Callable
|
|
9
9
|
|
|
10
|
-
_logger = logging.getLogger(__name__)
|
|
11
10
|
|
|
11
|
+
def migrate_v1(d: dict) -> None:
|
|
12
|
+
"""Remove Org.created_at fields."""
|
|
13
|
+
for org_data in d["orgs"].values():
|
|
14
|
+
org_data.pop("created_at", None)
|
|
12
15
|
|
|
13
|
-
def apply_migrations(data_dict: dict) -> bool:
|
|
14
|
-
"""Apply any pending schema migrations to the database dictionary.
|
|
15
16
|
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
migrations = sorted(
|
|
18
|
+
[f for n, f in globals().items() if n.startswith("migrate_v")],
|
|
19
|
+
key=lambda f: int(f.__name__.removeprefix("migrate_v")),
|
|
20
|
+
)
|
|
18
21
|
|
|
19
|
-
|
|
20
|
-
True if any migrations were applied, False otherwise
|
|
21
|
-
"""
|
|
22
|
-
db_version = data_dict.get("v", 0)
|
|
23
|
-
migrated = False
|
|
22
|
+
DBVER = len(migrations) # Used by bootstrap and migrate:sql to set initial version
|
|
24
23
|
|
|
25
|
-
if db_version == 0:
|
|
26
|
-
# Migration v0 -> v1: Remove created_at from orgs (field removed from schema)
|
|
27
|
-
if "orgs" in data_dict:
|
|
28
|
-
for org_data in data_dict["orgs"].values():
|
|
29
|
-
org_data.pop("created_at", None)
|
|
30
|
-
data_dict["v"] = 1
|
|
31
|
-
migrated = True
|
|
32
|
-
_logger.info("Applied schema migration: v0 -> v1 (removed org.created_at)")
|
|
33
24
|
|
|
34
|
-
|
|
25
|
+
async def apply_all_migrations(
|
|
26
|
+
data_dict: dict,
|
|
27
|
+
current_version: int,
|
|
28
|
+
persist: Callable[[str, int, dict], Awaitable[None]],
|
|
29
|
+
) -> None:
|
|
30
|
+
while current_version < DBVER:
|
|
31
|
+
migrations[current_version](data_dict)
|
|
32
|
+
current_version += 1
|
|
33
|
+
await persist(f"migrate:v{current_version}", current_version, data_dict)
|