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
|
@@ -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"]
|