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.
Files changed (58) hide show
  1. paskia/_version.py +2 -2
  2. paskia/aaguid/__init__.py +5 -4
  3. paskia/authsession.py +4 -19
  4. paskia/db/__init__.py +2 -4
  5. paskia/db/background.py +3 -3
  6. paskia/db/jsonl.py +99 -111
  7. paskia/db/logging.py +318 -0
  8. paskia/db/migrations.py +19 -20
  9. paskia/db/operations.py +107 -196
  10. paskia/db/structs.py +236 -46
  11. paskia/fastapi/__main__.py +13 -6
  12. paskia/fastapi/admin.py +72 -195
  13. paskia/fastapi/api.py +56 -58
  14. paskia/fastapi/authz.py +3 -8
  15. paskia/fastapi/logging.py +261 -0
  16. paskia/fastapi/mainapp.py +14 -3
  17. paskia/fastapi/remote.py +11 -37
  18. paskia/fastapi/reset.py +0 -2
  19. paskia/fastapi/response.py +22 -0
  20. paskia/fastapi/user.py +7 -7
  21. paskia/fastapi/ws.py +14 -37
  22. paskia/fastapi/wschat.py +55 -2
  23. paskia/fastapi/wsutil.py +10 -2
  24. paskia/frontend-build/auth/admin/index.html +6 -6
  25. paskia/frontend-build/auth/assets/AccessDenied-C29NZI95.css +1 -0
  26. paskia/frontend-build/auth/assets/AccessDenied-DAdzg_MJ.js +12 -0
  27. paskia/frontend-build/auth/assets/{RestrictedAuth-CvR33_Z0.css → RestrictedAuth-BOdNrlQB.css} +1 -1
  28. paskia/frontend-build/auth/assets/{RestrictedAuth-DsJXicIw.js → RestrictedAuth-BSusdAfp.js} +1 -1
  29. paskia/frontend-build/auth/assets/_plugin-vue_export-helper-D2l53SUz.js +49 -0
  30. paskia/frontend-build/auth/assets/_plugin-vue_export-helper-DYJ24FZK.css +1 -0
  31. paskia/frontend-build/auth/assets/admin-BeFvGyD6.js +1 -0
  32. paskia/frontend-build/auth/assets/{admin-DzzjSg72.css → admin-CmNtuH3s.css} +1 -1
  33. paskia/frontend-build/auth/assets/{auth-C7k64Wad.css → auth-BKq4T2K2.css} +1 -1
  34. paskia/frontend-build/auth/assets/auth-DvHf8hgy.js +1 -0
  35. paskia/frontend-build/auth/assets/{forward-DmqVHZ7e.js → forward-C86Jm_Uq.js} +1 -1
  36. paskia/frontend-build/auth/assets/reset-B8PlNXuP.css +1 -0
  37. paskia/frontend-build/auth/assets/reset-D71FG0VL.js +1 -0
  38. paskia/frontend-build/auth/assets/{restricted-D3AJx3_6.js → restricted-CW0drE_k.js} +1 -1
  39. paskia/frontend-build/auth/index.html +6 -6
  40. paskia/frontend-build/auth/restricted/index.html +5 -5
  41. paskia/frontend-build/int/forward/index.html +5 -5
  42. paskia/frontend-build/int/reset/index.html +4 -4
  43. paskia/migrate/__init__.py +9 -9
  44. paskia/migrate/sql.py +26 -19
  45. paskia/remoteauth.py +6 -6
  46. {paskia-0.9.0.dist-info → paskia-0.10.0.dist-info}/METADATA +1 -1
  47. paskia-0.10.0.dist-info/RECORD +60 -0
  48. paskia/frontend-build/auth/assets/AccessDenied-DPkUS8LZ.css +0 -1
  49. paskia/frontend-build/auth/assets/AccessDenied-Fmeb6EtF.js +0 -8
  50. paskia/frontend-build/auth/assets/_plugin-vue_export-helper-BTzJAQlS.css +0 -1
  51. paskia/frontend-build/auth/assets/_plugin-vue_export-helper-nhjnO_bd.js +0 -2
  52. paskia/frontend-build/auth/assets/admin-CPE1pLMm.js +0 -1
  53. paskia/frontend-build/auth/assets/auth-YIZvPlW_.js +0 -1
  54. paskia/frontend-build/auth/assets/reset-Chtv69AT.css +0 -1
  55. paskia/frontend-build/auth/assets/reset-s20PATTN.js +0 -1
  56. paskia-0.9.0.dist-info/RECORD +0 -57
  57. {paskia-0.9.0.dist-info → paskia-0.10.0.dist-info}/WHEEL +0 -0
  58. {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 logging
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
- Args:
17
- data_dict: The raw database dictionary loaded from JSONL
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
- Returns:
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
- return migrated
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)