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
@@ -0,0 +1,29 @@
1
+ """School adapters and shared institution-specific policy helpers."""
2
+
3
+ from ._base import (
4
+ CONSERVATIVE_TOPIC_EXCLUSION_POLICY,
5
+ School,
6
+ TopicExclusionPolicy,
7
+ hostname_matches_suffix,
8
+ normalize_topic_kind,
9
+ parse_api_timestamp,
10
+ render_timestamp,
11
+ topic_is_excluded,
12
+ utc_timestamp,
13
+ )
14
+ from .generic import GenericSchool
15
+ from .uwaterloo import UWaterloo
16
+
17
+ __all__ = [
18
+ "CONSERVATIVE_TOPIC_EXCLUSION_POLICY",
19
+ "GenericSchool",
20
+ "School",
21
+ "TopicExclusionPolicy",
22
+ "UWaterloo",
23
+ "hostname_matches_suffix",
24
+ "normalize_topic_kind",
25
+ "parse_api_timestamp",
26
+ "render_timestamp",
27
+ "topic_is_excluded",
28
+ "utc_timestamp",
29
+ ]
@@ -0,0 +1,194 @@
1
+ """Shared school-adapter contracts, policy matching, and time handling."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Iterable
7
+ from dataclasses import dataclass
8
+ from datetime import UTC, datetime
9
+ from typing import Protocol, runtime_checkable
10
+ from urllib.parse import urlsplit
11
+ from zoneinfo import ZoneInfo
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class TopicExclusionPolicy:
16
+ """Structured rules for topics that must remain link stubs."""
17
+
18
+ kinds: frozenset[str]
19
+ host_suffixes: frozenset[str]
20
+ url_markers: frozenset[str]
21
+
22
+
23
+ @runtime_checkable
24
+ class School(Protocol):
25
+ """Institution-specific rules used by the portable sync engine."""
26
+
27
+ id: str
28
+ name: str
29
+ base_url: str
30
+ timezone: str
31
+ auth_hint: str
32
+
33
+ def term_from_offering(self, code: str) -> str | None:
34
+ """Return the institution's term code when an offering contains one."""
35
+
36
+ def term_label(self, term: str) -> str:
37
+ """Return the stable display label for a term code."""
38
+
39
+ def auth_hosts(self) -> list[str]:
40
+ """Return reviewed identity hosts allowed only during interactive auth."""
41
+
42
+ def outline_hosts(self) -> list[str]:
43
+ """Return reviewed first-party hosts allowed for outline rendering."""
44
+
45
+ def topic_exclusion_policy(self) -> TopicExclusionPolicy:
46
+ """Return structured licensed/external-topic exclusion rules."""
47
+
48
+
49
+ CONSERVATIVE_TOPIC_EXCLUSION_POLICY = TopicExclusionPolicy(
50
+ kinds=frozenset({"lti"}),
51
+ host_suffixes=frozenset({"vitalsource.com"}),
52
+ url_markers=frozenset({"quicklink.d2l", "type=lti"}),
53
+ )
54
+
55
+
56
+ def normalize_topic_kind(kind: str) -> str:
57
+ """Normalize the small topic-kind vocabulary used by policy matching."""
58
+
59
+ return re.sub(r"[\s_-]+", "", kind.casefold().strip())
60
+
61
+
62
+ def hostname_matches_suffix(host_or_url: str, suffixes: Iterable[str]) -> bool:
63
+ """Match a hostname or URL only at a DNS-label boundary.
64
+
65
+ ``example.vitalsource.com`` matches ``vitalsource.com``; neither
66
+ ``notvitalsource.com`` nor ``vitalsource.com.example`` does. Parsing the URL before
67
+ matching also prevents a lookalike value in a path or query from being treated as a host.
68
+ """
69
+
70
+ hostname = _hostname(host_or_url)
71
+ if hostname is None:
72
+ return False
73
+
74
+ for suffix in suffixes:
75
+ normalized_suffix = _hostname(suffix)
76
+ if normalized_suffix is None:
77
+ continue
78
+ normalized_suffix = normalized_suffix.lstrip(".")
79
+ if hostname == normalized_suffix or hostname.endswith(f".{normalized_suffix}"):
80
+ return True
81
+ return False
82
+
83
+
84
+ def topic_is_excluded(
85
+ kind: str,
86
+ url: str | None,
87
+ policy: TopicExclusionPolicy,
88
+ ) -> bool:
89
+ """Return whether a topic is excluded by normalized kind, host, or URL marker."""
90
+
91
+ normalized_kinds = {normalize_topic_kind(value) for value in policy.kinds}
92
+ if normalize_topic_kind(kind) in normalized_kinds:
93
+ return True
94
+
95
+ if not url:
96
+ return False
97
+ if hostname_matches_suffix(url, policy.host_suffixes):
98
+ return True
99
+
100
+ return any(_url_marker_matches(url, marker) for marker in policy.url_markers)
101
+
102
+
103
+ def parse_api_timestamp(value: str) -> datetime:
104
+ """Parse an API ISO-8601 timestamp and return an aware UTC instant."""
105
+
106
+ if not isinstance(value, str):
107
+ raise ValueError("timestamp must be an ISO 8601 string")
108
+
109
+ candidate = f"{value[:-1]}+00:00" if value.endswith(("Z", "z")) else value
110
+ try:
111
+ parsed = datetime.fromisoformat(candidate)
112
+ except ValueError as exc:
113
+ raise ValueError("timestamp must be a valid ISO 8601 value") from exc
114
+ if parsed.tzinfo is None or parsed.utcoffset() is None:
115
+ raise ValueError("timestamp must be timezone-aware")
116
+ return parsed.astimezone(UTC)
117
+
118
+
119
+ def utc_timestamp(value: str | datetime) -> str:
120
+ """Serialize an API timestamp as a canonical UTC ISO-8601 value."""
121
+
122
+ instant = _as_utc(value)
123
+ return instant.isoformat().replace("+00:00", "Z")
124
+
125
+
126
+ def render_timestamp(value: str | datetime, school_or_timezone: School | str) -> str:
127
+ """Render a UTC instant in an explicit school IANA timezone.
128
+
129
+ No ambient process timezone or locale is consulted. Passing a school object keeps the
130
+ rendering rule attached to the adapter; a timezone string is useful for lower-level tests.
131
+ """
132
+
133
+ timezone_name = (
134
+ school_or_timezone if isinstance(school_or_timezone, str) else school_or_timezone.timezone
135
+ )
136
+ return _as_utc(value).astimezone(ZoneInfo(timezone_name)).isoformat()
137
+
138
+
139
+ def _as_utc(value: str | datetime) -> datetime:
140
+ if isinstance(value, str):
141
+ return parse_api_timestamp(value)
142
+ if not isinstance(value, datetime) or value.tzinfo is None or value.utcoffset() is None:
143
+ raise ValueError("timestamp must be timezone-aware")
144
+ return value.astimezone(UTC)
145
+
146
+
147
+ def _hostname(value: str) -> str | None:
148
+ candidate = value if "://" in value else f"//{value}"
149
+ try:
150
+ hostname = urlsplit(candidate).hostname
151
+ except ValueError:
152
+ return None
153
+ if hostname is None:
154
+ return None
155
+ return hostname.rstrip(".").casefold()
156
+
157
+
158
+ def _url_marker_matches(url: str, marker: str) -> bool:
159
+ """Match a URL marker without treating arbitrary hostname substrings as evidence."""
160
+
161
+ folded_marker = marker.casefold().strip()
162
+ if not folded_marker:
163
+ return False
164
+
165
+ candidate = url if "://" in url else f"//{url}"
166
+ try:
167
+ parsed = urlsplit(candidate)
168
+ except ValueError:
169
+ return False
170
+
171
+ hostname = _hostname(url)
172
+ if hostname is not None and (
173
+ hostname == folded_marker
174
+ or hostname.startswith(f"{folded_marker}.")
175
+ or hostname.endswith(f".{folded_marker}")
176
+ ):
177
+ return True
178
+
179
+ non_host_url = f"{parsed.path}?{parsed.query}#{parsed.fragment}".casefold()
180
+ pattern = rf"(?<![a-z0-9]){re.escape(folded_marker)}(?![a-z0-9])"
181
+ return re.search(pattern, non_host_url) is not None
182
+
183
+
184
+ __all__ = [
185
+ "CONSERVATIVE_TOPIC_EXCLUSION_POLICY",
186
+ "School",
187
+ "TopicExclusionPolicy",
188
+ "hostname_matches_suffix",
189
+ "normalize_topic_kind",
190
+ "parse_api_timestamp",
191
+ "render_timestamp",
192
+ "topic_is_excluded",
193
+ "utc_timestamp",
194
+ ]
@@ -0,0 +1,78 @@
1
+ """Conservative, explicitly opt-in adapter for an untested school."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import warnings
6
+ from urllib.parse import urlsplit
7
+
8
+ from ._base import CONSERVATIVE_TOPIC_EXCLUSION_POLICY, TopicExclusionPolicy
9
+
10
+
11
+ class GenericSchool:
12
+ """Represent a user-supplied host without pretending it is validated.
13
+
14
+ The command layer must supply ``--host`` explicitly. This adapter intentionally has no
15
+ authentication or outline egress allowlist and emits a warning whenever it is constructed or
16
+ used, so an untested institution cannot look like a supported integration.
17
+ """
18
+
19
+ id = "generic"
20
+ name = "Untested school"
21
+ timezone = "UTC"
22
+ auth_hint = "Explicit host only; authentication is untested"
23
+
24
+ def __init__(self, host: str) -> None:
25
+ self.host = _validate_host(host)
26
+ self.base_url = f"https://{self.host}"
27
+ self._warn()
28
+
29
+ def term_from_offering(self, code: str) -> str | None:
30
+ self._warn()
31
+ return None
32
+
33
+ def term_label(self, term: str) -> str:
34
+ self._warn()
35
+ return f"Term {term}"
36
+
37
+ def auth_hosts(self) -> list[str]:
38
+ self._warn()
39
+ return []
40
+
41
+ def outline_hosts(self) -> list[str]:
42
+ self._warn()
43
+ return []
44
+
45
+ def topic_exclusion_policy(self) -> TopicExclusionPolicy:
46
+ self._warn()
47
+ return CONSERVATIVE_TOPIC_EXCLUSION_POLICY
48
+
49
+ def _warn(self) -> None:
50
+ warnings.warn(
51
+ "WARNING: untested school adapter; no authentication or outline hosts are approved",
52
+ UserWarning,
53
+ stacklevel=3,
54
+ )
55
+
56
+
57
+ def _validate_host(host: str) -> str:
58
+ if not isinstance(host, str) or not host.strip():
59
+ raise ValueError("generic school requires a non-empty --host")
60
+ raw = host.strip()
61
+ if "://" in raw or any(character in raw for character in "/?#@"):
62
+ raise ValueError("generic school --host must be a hostname, not a URL or path")
63
+
64
+ parsed = urlsplit(f"//{raw}")
65
+ try:
66
+ port = parsed.port
67
+ except ValueError as exc:
68
+ raise ValueError("generic school --host has an invalid port") from exc
69
+ if parsed.hostname is None or parsed.username is not None or parsed.password is not None:
70
+ raise ValueError("generic school --host must contain a hostname")
71
+
72
+ hostname = parsed.hostname.rstrip(".").casefold()
73
+ if ":" in hostname:
74
+ hostname = f"[{hostname}]"
75
+ return f"{hostname}:{port}" if port is not None else hostname
76
+
77
+
78
+ __all__ = ["GenericSchool"]
@@ -0,0 +1,66 @@
1
+ """University of Waterloo school adapter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ from ._base import CONSERVATIVE_TOPIC_EXCLUSION_POLICY, TopicExclusionPolicy
8
+
9
+ _TERM_RUN = re.compile(r"(?<!\d)(\d{4})(?!\d)")
10
+ _SEASONS = {1: "Winter", 5: "Spring", 9: "Fall"}
11
+
12
+
13
+ class UWaterloo:
14
+ """Rules for Waterloo's LEARN instance and academic term codes."""
15
+
16
+ id: str = "uwaterloo"
17
+ name: str = "University of Waterloo"
18
+ base_url: str = "https://learn.uwaterloo.ca"
19
+ timezone: str = "America/Toronto"
20
+ auth_hint: str = "WatIAM + Duo"
21
+
22
+ def term_from_offering(self, code: str) -> str | None:
23
+ """Return the last four-digit Waterloo term code in the offering string."""
24
+
25
+ valid = [
26
+ match.group(1)
27
+ for match in _TERM_RUN.finditer(code)
28
+ if 1000 <= int(match.group(1)) <= 1999
29
+ ]
30
+ return valid[-1] if valid else None
31
+
32
+ def term_label(self, term: str) -> str:
33
+ """Convert a Waterloo term code such as ``1265`` to ``Spring 2026``."""
34
+
35
+ if not re.fullmatch(r"\d{4}", term):
36
+ raise ValueError(f"invalid Waterloo term code: {term!r}")
37
+ term_code = int(term)
38
+ if not 1000 <= term_code <= 1999:
39
+ raise ValueError(f"invalid Waterloo term code: {term!r}")
40
+ season = _SEASONS.get(term_code % 10)
41
+ if season is None:
42
+ raise ValueError(f"unknown Waterloo term season: {term!r}")
43
+ year = 1900 + term_code // 10
44
+ return f"{season} {year}"
45
+
46
+ def auth_hosts(self) -> list[str]:
47
+ """Return reviewed identity-provider host suffixes for interactive sign-in only."""
48
+
49
+ # Duo uses changing tenant and asset subdomains (for example, ``sso-*`` and ``api-*``).
50
+ # The provider boundary absorbs that documented host churn without becoming an
51
+ # arbitrary-website wildcard. The Waterloo ADFS hand-off remains a separate reviewed
52
+ # host. The CDP gate applies HTTPS/default-port and DNS-label-boundary checks.
53
+ return ["duosecurity.com", "adfs.uwaterloo.ca"]
54
+
55
+ def outline_hosts(self) -> list[str]:
56
+ """Return reviewed first-party outline hosts beyond the configured LEARN origin."""
57
+
58
+ return []
59
+
60
+ def topic_exclusion_policy(self) -> TopicExclusionPolicy:
61
+ """Return Waterloo's licensed-topic exclusion policy."""
62
+
63
+ return CONSERVATIVE_TOPIC_EXCLUSION_POLICY
64
+
65
+
66
+ __all__ = ["UWaterloo"]