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.
Files changed (46) hide show
  1. agent2learn/__init__.py +3 -0
  2. agent2learn/_release.py +19 -0
  3. agent2learn/aipolicy.py +182 -0
  4. agent2learn/api.py +590 -0
  5. agent2learn/audit.py +358 -0
  6. agent2learn/auth/__init__.py +282 -0
  7. agent2learn/auth/cdp.py +1067 -0
  8. agent2learn/auth/paste.py +378 -0
  9. agent2learn/calendar.py +525 -0
  10. agent2learn/calibrate.py +347 -0
  11. agent2learn/check.py +1091 -0
  12. agent2learn/cli.py +2039 -0
  13. agent2learn/clock.py +39 -0
  14. agent2learn/config.py +205 -0
  15. agent2learn/console.py +229 -0
  16. agent2learn/convert.py +1223 -0
  17. agent2learn/doctor.py +1167 -0
  18. agent2learn/errors.py +32 -0
  19. agent2learn/ground.py +735 -0
  20. agent2learn/index.py +614 -0
  21. agent2learn/ingest.py +3229 -0
  22. agent2learn/locations.py +247 -0
  23. agent2learn/outlines.py +754 -0
  24. agent2learn/paths.py +683 -0
  25. agent2learn/pipeline.py +392 -0
  26. agent2learn/privacy.py +1123 -0
  27. agent2learn/schools/__init__.py +29 -0
  28. agent2learn/schools/_base.py +194 -0
  29. agent2learn/schools/generic.py +78 -0
  30. agent2learn/schools/uwaterloo.py +66 -0
  31. agent2learn/session.py +373 -0
  32. agent2learn/skills.py +1081 -0
  33. agent2learn/snapshot.py +399 -0
  34. agent2learn/submit.py +1047 -0
  35. agent2learn/transactions.py +157 -0
  36. agent2learn/upgrade.py +288 -0
  37. agent2learn/vault.py +1134 -0
  38. agent2learn-0.1.2.data/data/a2l-coursework/SKILL.md +52 -0
  39. agent2learn-0.1.2.data/data/a2l-setup/SKILL.md +27 -0
  40. agent2learn-0.1.2.data/data/a2l-study/SKILL.md +27 -0
  41. agent2learn-0.1.2.data/data/a2l-sync/SKILL.md +30 -0
  42. agent2learn-0.1.2.dist-info/METADATA +186 -0
  43. agent2learn-0.1.2.dist-info/RECORD +46 -0
  44. agent2learn-0.1.2.dist-info/WHEEL +4 -0
  45. agent2learn-0.1.2.dist-info/entry_points.txt +3 -0
  46. 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
+ ]