agent2learn 0.1.2__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.
- agent2learn/__init__.py +3 -0
- agent2learn/_release.py +19 -0
- agent2learn/aipolicy.py +182 -0
- agent2learn/api.py +590 -0
- agent2learn/audit.py +358 -0
- agent2learn/auth/__init__.py +282 -0
- agent2learn/auth/cdp.py +1067 -0
- agent2learn/auth/paste.py +378 -0
- agent2learn/calendar.py +525 -0
- agent2learn/calibrate.py +347 -0
- agent2learn/check.py +1091 -0
- agent2learn/cli.py +2039 -0
- agent2learn/clock.py +39 -0
- agent2learn/config.py +205 -0
- agent2learn/console.py +229 -0
- agent2learn/convert.py +1223 -0
- agent2learn/doctor.py +1167 -0
- agent2learn/errors.py +32 -0
- agent2learn/ground.py +735 -0
- agent2learn/index.py +614 -0
- agent2learn/ingest.py +3229 -0
- agent2learn/locations.py +247 -0
- agent2learn/outlines.py +754 -0
- agent2learn/paths.py +683 -0
- agent2learn/pipeline.py +392 -0
- agent2learn/privacy.py +1123 -0
- agent2learn/schools/__init__.py +29 -0
- agent2learn/schools/_base.py +194 -0
- agent2learn/schools/generic.py +78 -0
- agent2learn/schools/uwaterloo.py +66 -0
- agent2learn/session.py +373 -0
- agent2learn/skills.py +1081 -0
- agent2learn/snapshot.py +399 -0
- agent2learn/submit.py +1047 -0
- agent2learn/transactions.py +157 -0
- agent2learn/upgrade.py +288 -0
- agent2learn/vault.py +1134 -0
- agent2learn-0.1.2.data/data/a2l-coursework/SKILL.md +52 -0
- agent2learn-0.1.2.data/data/a2l-setup/SKILL.md +27 -0
- agent2learn-0.1.2.data/data/a2l-study/SKILL.md +27 -0
- agent2learn-0.1.2.data/data/a2l-sync/SKILL.md +30 -0
- agent2learn-0.1.2.dist-info/METADATA +186 -0
- agent2learn-0.1.2.dist-info/RECORD +46 -0
- agent2learn-0.1.2.dist-info/WHEEL +4 -0
- agent2learn-0.1.2.dist-info/entry_points.txt +3 -0
- agent2learn-0.1.2.dist-info/licenses/LICENSE +202 -0
agent2learn/clock.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""The single wall-clock seam for everything that reaches the vault.
|
|
2
|
+
|
|
3
|
+
Vault bytes must be reproducible: the golden-vault test hashes every generated file and
|
|
4
|
+
compares the map across Windows, macOS, and Linux. A module that reads the clock directly
|
|
5
|
+
is therefore untestable for byte parity, because two runs can never agree. Every writer
|
|
6
|
+
that stamps a timestamp into a manifest, twin, index, snapshot, or audit calls through here
|
|
7
|
+
so a test can freeze time in exactly one place.
|
|
8
|
+
|
|
9
|
+
``tests/test_no_forbidden_calls.py`` enforces this: ``datetime.now`` may only appear in this
|
|
10
|
+
module and in the authentication and transport paths, which never write vault content.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from datetime import UTC, datetime
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def now() -> datetime:
|
|
19
|
+
"""Return the current instant as an aware UTC ``datetime``."""
|
|
20
|
+
return datetime.now(UTC)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def stamp() -> str:
|
|
24
|
+
"""Return the current instant as the vault's canonical ``Z``-suffixed ISO-8601 string.
|
|
25
|
+
|
|
26
|
+
The vault stores ``2026-08-25T12:00:00Z`` rather than ``+00:00`` so timestamps compare
|
|
27
|
+
as plain strings and sort lexicographically in the same order as chronologically.
|
|
28
|
+
"""
|
|
29
|
+
return to_stamp(now())
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def to_stamp(value: datetime) -> str:
|
|
33
|
+
"""Render an aware UTC ``datetime`` in the vault's canonical timestamp form."""
|
|
34
|
+
if value.tzinfo is None:
|
|
35
|
+
raise ValueError("timestamp must be timezone-aware")
|
|
36
|
+
return value.astimezone(UTC).isoformat().replace("+00:00", "Z")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
__all__ = ["now", "stamp", "to_stamp"]
|
agent2learn/config.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Platform-correct machine configuration and state locations.
|
|
2
|
+
|
|
3
|
+
The config file is intentionally small and local. Known fields are validated strictly, while
|
|
4
|
+
unknown top-level JSON fields are retained as opaque future-version data and never interpreted by
|
|
5
|
+
this version. This permits a downgrade without silently deleting newer settings.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, TypeAlias, cast
|
|
15
|
+
|
|
16
|
+
from platformdirs import PlatformDirs
|
|
17
|
+
|
|
18
|
+
from agent2learn import paths
|
|
19
|
+
|
|
20
|
+
JSONValue: TypeAlias = None | bool | int | float | str | list["JSONValue"] | dict[str, "JSONValue"]
|
|
21
|
+
|
|
22
|
+
DIRS = PlatformDirs("agent2learn", appauthor=False, ensure_exists=True)
|
|
23
|
+
DEFAULT_VAULT = Path.home() / "agent2learn"
|
|
24
|
+
_KNOWN_KEYS = frozenset(
|
|
25
|
+
{
|
|
26
|
+
"vault",
|
|
27
|
+
"school",
|
|
28
|
+
"submit_enabled",
|
|
29
|
+
"include_discussions",
|
|
30
|
+
"include_grades",
|
|
31
|
+
"ocr_words_per_page",
|
|
32
|
+
}
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class Config:
|
|
38
|
+
"""The user-controlled Agent2Learn configuration.
|
|
39
|
+
|
|
40
|
+
``submit_enabled`` is only the first acknowledgement gate; it is never sufficient to perform
|
|
41
|
+
a submission. A fresh interactive confirmation remains mandatory for every file.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
vault: Path = field(default_factory=lambda: DEFAULT_VAULT)
|
|
45
|
+
school: str = "uwaterloo"
|
|
46
|
+
submit_enabled: bool = False
|
|
47
|
+
include_discussions: bool = False
|
|
48
|
+
include_grades: bool = False
|
|
49
|
+
ocr_words_per_page: int = 80
|
|
50
|
+
extras: dict[str, JSONValue] = field(default_factory=dict)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def config_path() -> Path:
|
|
54
|
+
"""Return the per-user JSON config path and ensure its directory exists."""
|
|
55
|
+
|
|
56
|
+
return _directory(DIRS.user_config_path) / "config.json"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def state_dir() -> Path:
|
|
60
|
+
"""Return the machine-state directory for sessions and calibration."""
|
|
61
|
+
|
|
62
|
+
return _directory(DIRS.user_state_path)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def data_dir() -> Path:
|
|
66
|
+
"""Return the per-user data directory used for the dedicated browser profile."""
|
|
67
|
+
|
|
68
|
+
return _directory(DIRS.user_data_path)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def log_path() -> Path:
|
|
72
|
+
"""Return the primary bounded local log path."""
|
|
73
|
+
|
|
74
|
+
return _directory(DIRS.user_log_path) / "a2l.log"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def load() -> Config:
|
|
78
|
+
"""Load and validate config, returning privacy-safe defaults when it is absent."""
|
|
79
|
+
|
|
80
|
+
destination = config_path()
|
|
81
|
+
try:
|
|
82
|
+
linked = paths.is_link(destination)
|
|
83
|
+
present = paths.long_path(destination).is_file()
|
|
84
|
+
except OSError as exc:
|
|
85
|
+
raise ValueError("config file is unreadable") from exc
|
|
86
|
+
if linked:
|
|
87
|
+
raise ValueError("config file must not be a symlink")
|
|
88
|
+
if not present:
|
|
89
|
+
return Config()
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
with open(
|
|
93
|
+
os.fspath(paths.long_path(destination)),
|
|
94
|
+
encoding="utf-8",
|
|
95
|
+
newline="",
|
|
96
|
+
) as handle:
|
|
97
|
+
raw: Any = json.load(handle)
|
|
98
|
+
except FileNotFoundError:
|
|
99
|
+
# The file can disappear between is_file() and open(); absence is still the default case.
|
|
100
|
+
return Config()
|
|
101
|
+
except (UnicodeError, json.JSONDecodeError) as exc:
|
|
102
|
+
raise ValueError("config file is not valid JSON") from exc
|
|
103
|
+
except OSError as exc:
|
|
104
|
+
raise ValueError("config file is unreadable") from exc
|
|
105
|
+
|
|
106
|
+
if not isinstance(raw, dict):
|
|
107
|
+
raise ValueError("config root must be a JSON object")
|
|
108
|
+
|
|
109
|
+
data: dict[str, JSONValue] = {
|
|
110
|
+
cast(str, key): cast(JSONValue, value) for key, value in raw.items()
|
|
111
|
+
}
|
|
112
|
+
vault = _read_vault(data)
|
|
113
|
+
school = _read_string(data, "school", "uwaterloo")
|
|
114
|
+
submit_enabled = _read_bool(data, "submit_enabled", False)
|
|
115
|
+
include_discussions = _read_bool(data, "include_discussions", False)
|
|
116
|
+
include_grades = _read_bool(data, "include_grades", False)
|
|
117
|
+
ocr_words_per_page = _read_positive_int(data, "ocr_words_per_page", 80)
|
|
118
|
+
extras = {key: value for key, value in data.items() if key not in _KNOWN_KEYS}
|
|
119
|
+
return Config(
|
|
120
|
+
vault=vault,
|
|
121
|
+
school=school,
|
|
122
|
+
submit_enabled=submit_enabled,
|
|
123
|
+
include_discussions=include_discussions,
|
|
124
|
+
include_grades=include_grades,
|
|
125
|
+
ocr_words_per_page=ocr_words_per_page,
|
|
126
|
+
extras=extras,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def save(cfg: Config) -> None:
|
|
131
|
+
"""Canonicalize and atomically save ``cfg`` through the shared path primitive."""
|
|
132
|
+
|
|
133
|
+
if cfg.extras.keys() & _KNOWN_KEYS:
|
|
134
|
+
raise ValueError("extras cannot replace a known config key")
|
|
135
|
+
if not cfg.school:
|
|
136
|
+
raise ValueError("school must not be empty")
|
|
137
|
+
|
|
138
|
+
payload: dict[str, JSONValue] = {
|
|
139
|
+
"include_discussions": cfg.include_discussions,
|
|
140
|
+
"include_grades": cfg.include_grades,
|
|
141
|
+
"ocr_words_per_page": cfg.ocr_words_per_page,
|
|
142
|
+
"school": cfg.school,
|
|
143
|
+
"submit_enabled": cfg.submit_enabled,
|
|
144
|
+
"vault": os.fspath(cfg.vault),
|
|
145
|
+
**cfg.extras,
|
|
146
|
+
}
|
|
147
|
+
text = (
|
|
148
|
+
json.dumps(
|
|
149
|
+
payload,
|
|
150
|
+
ensure_ascii=False,
|
|
151
|
+
sort_keys=True,
|
|
152
|
+
indent=2,
|
|
153
|
+
separators=(",", ": "),
|
|
154
|
+
)
|
|
155
|
+
+ "\n"
|
|
156
|
+
)
|
|
157
|
+
destination = config_path()
|
|
158
|
+
paths.atomic_write_text(destination, text)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _directory(value: str | os.PathLike[str]) -> Path:
|
|
162
|
+
directory = Path(value)
|
|
163
|
+
paths.long_path(directory).mkdir(parents=True, exist_ok=True)
|
|
164
|
+
return directory
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _read_vault(data: dict[str, JSONValue]) -> Path:
|
|
168
|
+
value = data.get("vault", os.fspath(DEFAULT_VAULT))
|
|
169
|
+
if not isinstance(value, str) or not value:
|
|
170
|
+
raise ValueError("vault must be a non-empty string")
|
|
171
|
+
return Path(value).expanduser()
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _read_string(data: dict[str, JSONValue], key: str, default: str) -> str:
|
|
175
|
+
value = data.get(key, default)
|
|
176
|
+
if not isinstance(value, str) or not value:
|
|
177
|
+
raise ValueError(f"{key} must be a non-empty string")
|
|
178
|
+
return value
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _read_bool(data: dict[str, JSONValue], key: str, default: bool) -> bool:
|
|
182
|
+
value = data.get(key, default)
|
|
183
|
+
if not isinstance(value, bool):
|
|
184
|
+
raise ValueError(f"{key} must be a boolean")
|
|
185
|
+
return value
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _read_positive_int(data: dict[str, JSONValue], key: str, default: int) -> int:
|
|
189
|
+
value = data.get(key, default)
|
|
190
|
+
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
|
|
191
|
+
raise ValueError(f"{key} must be a positive integer")
|
|
192
|
+
return value
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
__all__ = [
|
|
196
|
+
"DIRS",
|
|
197
|
+
"DEFAULT_VAULT",
|
|
198
|
+
"Config",
|
|
199
|
+
"config_path",
|
|
200
|
+
"data_dir",
|
|
201
|
+
"load",
|
|
202
|
+
"log_path",
|
|
203
|
+
"save",
|
|
204
|
+
"state_dir",
|
|
205
|
+
]
|
agent2learn/console.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Terminal presentation and privacy-preserving local diagnostics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import math
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
from logging.handlers import RotatingFileHandler
|
|
11
|
+
from typing import Any, TextIO
|
|
12
|
+
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
|
|
15
|
+
from agent2learn import __version__, config, paths
|
|
16
|
+
|
|
17
|
+
_UNICODE_GLYPH = {"ok": "✓", "warn": "⚠", "fail": "✗", "info": "ℹ"}
|
|
18
|
+
_ASCII_GLYPH = {"ok": "[ok]", "warn": "[!]", "fail": "[x]", "info": "[-]"}
|
|
19
|
+
_ALLOWED_EVENTS = frozenset(
|
|
20
|
+
{
|
|
21
|
+
"auth",
|
|
22
|
+
"auth_checked",
|
|
23
|
+
"audit",
|
|
24
|
+
"calendar",
|
|
25
|
+
"convert",
|
|
26
|
+
"courses",
|
|
27
|
+
"diff",
|
|
28
|
+
"doctor",
|
|
29
|
+
"fetch",
|
|
30
|
+
"submit",
|
|
31
|
+
"sync",
|
|
32
|
+
"sync_completed",
|
|
33
|
+
"upgrade",
|
|
34
|
+
}
|
|
35
|
+
)
|
|
36
|
+
_ALLOWED_DIAGNOSTIC_CODES = frozenset(
|
|
37
|
+
{
|
|
38
|
+
"API_UNAVAILABLE",
|
|
39
|
+
"AUTH_EXPIRED",
|
|
40
|
+
"CONFIG_INVALID",
|
|
41
|
+
"CONVERSION_GAP",
|
|
42
|
+
"DOCTOR_REPORT",
|
|
43
|
+
"INTEGRITY_GAP",
|
|
44
|
+
"SUBMISSION_BLOCKED",
|
|
45
|
+
"SYNC_OK",
|
|
46
|
+
}
|
|
47
|
+
)
|
|
48
|
+
_ALLOWED_STATUSES = frozenset({"completed", "failure", "skipped", "started", "success", "warning"})
|
|
49
|
+
_SAFE_EXCEPTION_CLASSES = frozenset(
|
|
50
|
+
{
|
|
51
|
+
"A2LError",
|
|
52
|
+
"AuthenticationError",
|
|
53
|
+
"ConversionError",
|
|
54
|
+
"DownloadError",
|
|
55
|
+
"FileNotFoundError",
|
|
56
|
+
"NotConfigured",
|
|
57
|
+
"OSError",
|
|
58
|
+
"PermissionError",
|
|
59
|
+
"RuntimeError",
|
|
60
|
+
"SessionExpired",
|
|
61
|
+
"TimeoutError",
|
|
62
|
+
"ValueError",
|
|
63
|
+
}
|
|
64
|
+
)
|
|
65
|
+
_LOGGER_NAME = "agent2learn"
|
|
66
|
+
_HANDLER_MARKER = "_agent2learn_allowlisted_handler"
|
|
67
|
+
_MAX_BYTES = 1_048_576
|
|
68
|
+
_BACKUP_COUNT = 4
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _glyphs_for(stream: TextIO) -> dict[str, str]:
|
|
72
|
+
encoding = getattr(stream, "encoding", None)
|
|
73
|
+
if not encoding:
|
|
74
|
+
return dict(_ASCII_GLYPH)
|
|
75
|
+
try:
|
|
76
|
+
"✓".encode(encoding)
|
|
77
|
+
except (LookupError, UnicodeEncodeError):
|
|
78
|
+
return dict(_ASCII_GLYPH)
|
|
79
|
+
return dict(_UNICODE_GLYPH)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
GLYPH = _glyphs_for(sys.stdout)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def out() -> Console:
|
|
86
|
+
"""Return a Rich console with color disabled for non-TTY and ``NO_COLOR`` output."""
|
|
87
|
+
|
|
88
|
+
stream = sys.stdout
|
|
89
|
+
terminal = _is_tty(stream)
|
|
90
|
+
no_color = "NO_COLOR" in os.environ or not terminal
|
|
91
|
+
supports_unicode = _glyphs_for(stream) == _UNICODE_GLYPH
|
|
92
|
+
return Console(
|
|
93
|
+
file=stream,
|
|
94
|
+
force_terminal=terminal,
|
|
95
|
+
no_color=no_color,
|
|
96
|
+
emoji=supports_unicode,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def get_logger() -> logging.Logger:
|
|
101
|
+
"""Return the package logger; handlers are installed by ``configure_logging``."""
|
|
102
|
+
|
|
103
|
+
return logging.getLogger(_LOGGER_NAME)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def configure_logging(*, verbose: bool = False) -> logging.Logger:
|
|
107
|
+
"""Install a bounded handler that writes only structured allowlisted events.
|
|
108
|
+
|
|
109
|
+
``verbose`` changes diagnostic level, never the data schema. Ordinary logger calls without
|
|
110
|
+
the private structured payload are rejected by the handler filter, so a future traceback or
|
|
111
|
+
request debug string cannot accidentally become a local log record.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
logger = get_logger()
|
|
115
|
+
logger.setLevel(logging.DEBUG if verbose else logging.INFO)
|
|
116
|
+
logger.propagate = False
|
|
117
|
+
for handler in list(logger.handlers):
|
|
118
|
+
if getattr(handler, _HANDLER_MARKER, False):
|
|
119
|
+
logger.removeHandler(handler)
|
|
120
|
+
handler.close()
|
|
121
|
+
|
|
122
|
+
handler = RotatingFileHandler(
|
|
123
|
+
os.fspath(paths.long_path(config.log_path())),
|
|
124
|
+
maxBytes=_MAX_BYTES,
|
|
125
|
+
backupCount=_BACKUP_COUNT,
|
|
126
|
+
encoding="utf-8",
|
|
127
|
+
delay=True,
|
|
128
|
+
)
|
|
129
|
+
setattr(handler, _HANDLER_MARKER, True)
|
|
130
|
+
handler.setLevel(logging.DEBUG if verbose else logging.INFO)
|
|
131
|
+
handler.setFormatter(logging.Formatter("%(message)s"))
|
|
132
|
+
handler.addFilter(_AllowlistedFilter())
|
|
133
|
+
logger.addHandler(handler)
|
|
134
|
+
return logger
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def close_owned_handlers() -> None:
|
|
138
|
+
"""Close only Agent2Learn's allowlisted handlers without creating a replacement.
|
|
139
|
+
|
|
140
|
+
Privacy log purge calls this before unlinking the five known rotating files. Keeping the
|
|
141
|
+
helper here means the privacy layer does not need to depend on the logger's private marker or
|
|
142
|
+
accidentally close handlers belonging to an embedding application.
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
logger = get_logger()
|
|
146
|
+
for handler in list(logger.handlers):
|
|
147
|
+
if getattr(handler, _HANDLER_MARKER, False):
|
|
148
|
+
logger.removeHandler(handler)
|
|
149
|
+
handler.close()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def log_event(
|
|
153
|
+
event: str,
|
|
154
|
+
*,
|
|
155
|
+
diagnostic_code: str | None = None,
|
|
156
|
+
stage_ms: int | float | None = None,
|
|
157
|
+
status: str | None = None,
|
|
158
|
+
exception: BaseException | type[BaseException] | None = None,
|
|
159
|
+
**context: object,
|
|
160
|
+
) -> None:
|
|
161
|
+
"""Write a safe event and deliberately discard all arbitrary context.
|
|
162
|
+
|
|
163
|
+
``context`` exists so callers can pass rich operation context without making it part of the
|
|
164
|
+
persistence contract. URLs, headers, bodies, cookies, identities, course data, filenames,
|
|
165
|
+
grades, discussions, drafts, and confirmation phrases are never serialized.
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
del context
|
|
169
|
+
if event not in _ALLOWED_EVENTS:
|
|
170
|
+
raise ValueError("event is not an allowlisted diagnostic event")
|
|
171
|
+
payload: dict[str, object] = {"event": event, "package_version": __version__}
|
|
172
|
+
if diagnostic_code is not None:
|
|
173
|
+
if diagnostic_code not in _ALLOWED_DIAGNOSTIC_CODES:
|
|
174
|
+
raise ValueError("diagnostic_code is not an allowlisted diagnostic code")
|
|
175
|
+
payload["diagnostic_code"] = diagnostic_code
|
|
176
|
+
if stage_ms is not None:
|
|
177
|
+
if isinstance(stage_ms, bool) or not isinstance(stage_ms, (int, float)):
|
|
178
|
+
raise ValueError("stage_ms must be a finite non-negative number")
|
|
179
|
+
if not math.isfinite(stage_ms) or stage_ms < 0:
|
|
180
|
+
raise ValueError("stage_ms must be a finite non-negative number")
|
|
181
|
+
payload["stage_ms"] = stage_ms
|
|
182
|
+
if status is not None:
|
|
183
|
+
if status not in _ALLOWED_STATUSES:
|
|
184
|
+
raise ValueError("status is not an allowlisted diagnostic status")
|
|
185
|
+
payload["status"] = status
|
|
186
|
+
if exception is not None:
|
|
187
|
+
exception_name = (
|
|
188
|
+
exception.__name__ if isinstance(exception, type) else type(exception).__name__
|
|
189
|
+
)
|
|
190
|
+
payload["exception_class"] = (
|
|
191
|
+
exception_name if exception_name in _SAFE_EXCEPTION_CLASSES else "Exception"
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
logger = get_logger()
|
|
195
|
+
if not any(getattr(handler, _HANDLER_MARKER, False) for handler in logger.handlers):
|
|
196
|
+
logger = configure_logging()
|
|
197
|
+
logger.info("structured event", extra={"_a2l_payload": payload})
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class _AllowlistedFilter(logging.Filter):
|
|
201
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
202
|
+
payload: Any = getattr(record, "_a2l_payload", None)
|
|
203
|
+
if not isinstance(payload, dict):
|
|
204
|
+
return False
|
|
205
|
+
record.msg = json.dumps(
|
|
206
|
+
payload,
|
|
207
|
+
ensure_ascii=False,
|
|
208
|
+
sort_keys=True,
|
|
209
|
+
separators=(",", ":"),
|
|
210
|
+
)
|
|
211
|
+
record.args = ()
|
|
212
|
+
return True
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _is_tty(stream: TextIO) -> bool:
|
|
216
|
+
try:
|
|
217
|
+
return bool(stream.isatty())
|
|
218
|
+
except (AttributeError, OSError):
|
|
219
|
+
return False
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
__all__ = [
|
|
223
|
+
"GLYPH",
|
|
224
|
+
"close_owned_handlers",
|
|
225
|
+
"configure_logging",
|
|
226
|
+
"get_logger",
|
|
227
|
+
"log_event",
|
|
228
|
+
"out",
|
|
229
|
+
]
|