ml4t-coursework 0.2.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 (40) hide show
  1. ml4t_coursework/__init__.py +29 -0
  2. ml4t_coursework/checks.py +221 -0
  3. ml4t_coursework/components.py +204 -0
  4. ml4t_coursework/contracts.py +101 -0
  5. ml4t_coursework/course.py +86 -0
  6. ml4t_coursework/data/__init__.py +129 -0
  7. ml4t_coursework/data/__main__.py +30 -0
  8. ml4t_coursework/data/etf_close_fingerprint.csv +101 -0
  9. ml4t_coursework/fixtures.py +67 -0
  10. ml4t_coursework/project.py +87 -0
  11. ml4t_coursework/py.typed +0 -0
  12. ml4t_coursework/reference/__init__.py +6 -0
  13. ml4t_coursework/reference/_config.py +51 -0
  14. ml4t_coursework/reference/_shared.py +36 -0
  15. ml4t_coursework/reference/allocator.py +143 -0
  16. ml4t_coursework/reference/availability_lag.py +88 -0
  17. ml4t_coursework/reference/backtest_config.py +57 -0
  18. ml4t_coursework/reference/baseline_strategy.py +117 -0
  19. ml4t_coursework/reference/cost_model.py +119 -0
  20. ml4t_coursework/reference/data_panel.py +96 -0
  21. ml4t_coursework/reference/exit_rule.py +151 -0
  22. ml4t_coursework/reference/features.py +115 -0
  23. ml4t_coursework/reference/fold_splitter.py +116 -0
  24. ml4t_coursework/reference/holdout_split.py +85 -0
  25. ml4t_coursework/reference/labeler.py +127 -0
  26. ml4t_coursework/reference/model_gbm.py +118 -0
  27. ml4t_coursework/reference/model_linear.py +128 -0
  28. ml4t_coursework/reference/objective.py +41 -0
  29. ml4t_coursework/reference/preprocessor.py +150 -0
  30. ml4t_coursework/reference/quality_gates.py +125 -0
  31. ml4t_coursework/reference/signal.py +105 -0
  32. ml4t_coursework/reference/strategy_spec.py +42 -0
  33. ml4t_coursework/reference/task_form.py +102 -0
  34. ml4t_coursework/reference/universe.py +100 -0
  35. ml4t_coursework/report.py +71 -0
  36. ml4t_coursework/results.py +82 -0
  37. ml4t_coursework-0.2.0.dist-info/METADATA +130 -0
  38. ml4t_coursework-0.2.0.dist-info/RECORD +40 -0
  39. ml4t_coursework-0.2.0.dist-info/WHEEL +4 -0
  40. ml4t_coursework-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,29 @@
1
+ """The plumbing every ML4T course notebook runs on.
2
+
3
+ !pip install -q ml4t-coursework
4
+ import ml4t_coursework as mlc
5
+ mlc.use("foundations")
6
+
7
+ from ml4t_coursework import save_component, load_component, append_result, report
8
+
9
+ The package holds the student's project folder, the components they write and the contracts those
10
+ are checked against, their results log and their submission report. It is named for what it does
11
+ rather than for a course, because more than one course installs it.
12
+ """
13
+
14
+ __version__ = "0.2.0"
15
+
16
+ from .components import catalog, load_component, save_component, source_of, status
17
+ from .contracts import add_source
18
+ from .contracts import get as contract
19
+ from .course import Course, active_course, register_course, use
20
+ from .project import describe, home, setup
21
+ from .report import report
22
+ from .results import append_result, latest, results
23
+
24
+ __all__ = [
25
+ "save_component", "load_component", "append_result", "report",
26
+ "setup", "home", "describe", "status", "catalog", "contract", "results", "latest",
27
+ "source_of", "use", "Course", "register_course", "active_course", "add_source",
28
+ "__version__",
29
+ ]
@@ -0,0 +1,221 @@
1
+ """The five checks every component contract carries, and the failure they raise.
2
+
3
+ `certification.md` § 2 is the specification. The checks assert properties, never equality with a
4
+ reference output: two correct fold splitters legitimately differ and an equality check would fail
5
+ correct work and teach copying. The reference delta is the one exception and it is reported, not
6
+ enforced.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+ from typing import Any, Callable
13
+
14
+ import numpy as np
15
+ import pandas as pd
16
+
17
+
18
+ class Failure(Exception):
19
+ """A contract check that did not pass.
20
+
21
+ Every failure names the expectation and the observed value, because "expected 12 folds, got 9"
22
+ is feedback and a bare AssertionError is not.
23
+ """
24
+
25
+ def __init__(self, check: str, expected: str, observed: str, hint: str = "") -> None:
26
+ self.check = check
27
+ self.expected = expected
28
+ self.observed = observed
29
+ self.hint = hint
30
+ message = f"{check}: expected {expected}, got {observed}"
31
+ if hint:
32
+ message = f"{message}\n {hint}"
33
+ super().__init__(message)
34
+
35
+
36
+ def require(condition: bool, check: str, expected: str, observed: str, hint: str = "") -> None:
37
+ if not condition:
38
+ raise Failure(check, expected, observed, hint)
39
+
40
+
41
+ @dataclass
42
+ class CheckResult:
43
+ name: str
44
+ passed: bool
45
+ detail: str = ""
46
+
47
+ def line(self) -> str:
48
+ mark = "pass" if self.passed else "FAIL"
49
+ return f" [{mark}] {self.name}{': ' + self.detail if self.detail else ''}"
50
+
51
+
52
+ @dataclass
53
+ class Conformance:
54
+ component: str
55
+ conformant: bool
56
+ checks: list[CheckResult] = field(default_factory=list)
57
+ delta: str = ""
58
+ stamped_at: str = ""
59
+ helper_version: str = ""
60
+ unit: str = ""
61
+
62
+ def __str__(self) -> str:
63
+ head = f"{self.component}: {'conformant' if self.conformant else 'NOT conformant'}"
64
+ body = "\n".join(c.line() for c in self.checks)
65
+ tail = f"\n [note] reference delta: {self.delta}" if self.delta else ""
66
+ return f"{head}\n{body}{tail}"
67
+
68
+ def to_dict(self) -> dict:
69
+ return {
70
+ "component": self.component,
71
+ "unit": self.unit,
72
+ "conformant": self.conformant,
73
+ "checks": [{"name": c.name, "passed": c.passed, "detail": c.detail} for c in self.checks],
74
+ "reference_delta": self.delta,
75
+ "stamped_at": self.stamped_at,
76
+ "helper_version": self.helper_version,
77
+ }
78
+
79
+
80
+ # --- comparison helpers, shared by determinism, the leakage probe and the delta ---------------
81
+
82
+
83
+ def same(left: Any, right: Any, tol: float = 1e-12) -> bool:
84
+ """Value equality that understands the shapes components actually return."""
85
+ if isinstance(left, pd.DataFrame) and isinstance(right, pd.DataFrame):
86
+ if left.shape != right.shape or not left.index.equals(right.index):
87
+ return False
88
+ if list(left.columns) != list(right.columns):
89
+ return False
90
+ return bool(np.allclose(left.to_numpy(dtype=float), right.to_numpy(dtype=float),
91
+ atol=tol, rtol=0, equal_nan=True))
92
+ if isinstance(left, pd.Series) and isinstance(right, pd.Series):
93
+ if not left.index.equals(right.index):
94
+ return False
95
+ return bool(np.allclose(left.to_numpy(dtype=float), right.to_numpy(dtype=float),
96
+ atol=tol, rtol=0, equal_nan=True))
97
+ if isinstance(left, (list, tuple)) and isinstance(right, (list, tuple)):
98
+ return len(left) == len(right) and all(same(a, b, tol) for a, b in zip(left, right))
99
+ if isinstance(left, pd.Index) and isinstance(right, pd.Index):
100
+ return left.equals(right)
101
+ if isinstance(left, np.ndarray) and isinstance(right, np.ndarray):
102
+ return left.shape == right.shape and bool(np.allclose(left, right, atol=tol, equal_nan=True))
103
+ return bool(left == right)
104
+
105
+
106
+ def overlap(left: Any, right: Any) -> tuple[Any, Any]:
107
+ """Restrict two indexed objects to the rows they share, in the same order."""
108
+ if isinstance(left, (pd.Series, pd.DataFrame)) and isinstance(right, (pd.Series, pd.DataFrame)):
109
+ shared = left.index.intersection(right.index)
110
+ return left.loc[shared], right.loc[shared]
111
+ return left, right
112
+
113
+
114
+ def describe_delta(student: Any, reference: Any) -> str:
115
+ """One line on how far the student's output sits from the shipped reference's.
116
+
117
+ Reported and never enforced. Where a design choice legitimately differs the delta is
118
+ information; only the properties gate.
119
+ """
120
+ try:
121
+ a, b = overlap(student, reference)
122
+ if isinstance(a, (pd.Series, pd.DataFrame)) and isinstance(b, (pd.Series, pd.DataFrame)):
123
+ if len(a) == 0:
124
+ return "no overlapping rows, so nothing to compare"
125
+ if isinstance(a, pd.DataFrame) and isinstance(b, pd.DataFrame):
126
+ cols = a.columns.intersection(b.columns)
127
+ if len(cols) == 0:
128
+ return "no overlapping columns, so nothing to compare"
129
+ a, b = a[cols], b[cols]
130
+ diff = np.abs(np.asarray(a, dtype=float) - np.asarray(b, dtype=float))
131
+ scale = np.nanmean(np.abs(np.asarray(b, dtype=float)))
132
+ mean = np.nanmean(diff)
133
+ rel = f", about {mean / scale:.1%} of the reference's own scale" if scale else ""
134
+ return f"mean absolute difference {mean:.6g} over {len(a)} rows{rel}"
135
+ if isinstance(a, (list, tuple)) and isinstance(b, (list, tuple)):
136
+ return f"{len(a)} against the reference's {len(b)}"
137
+ if isinstance(a, dict) and isinstance(b, dict):
138
+ differing = sorted(k for k in set(a) | set(b) if a.get(k) != b.get(k))
139
+ return "identical to the reference" if not differing else f"differs on {', '.join(differing)}"
140
+ return "identical to the reference" if same(a, b) else f"{a!r} against the reference's {b!r}"
141
+ except Exception as exc: # a delta is information; it must never break a save
142
+ return f"not computable ({type(exc).__name__})"
143
+
144
+
145
+ # --- the four gating checks --------------------------------------------------------------------
146
+
147
+
148
+ def run_interface(contract, obj) -> CheckResult:
149
+ contract.interface(obj)
150
+ return CheckResult("interface", True, contract.interface_detail)
151
+
152
+
153
+ def run_determinism(contract, obj) -> CheckResult:
154
+ first = contract.probe(obj)
155
+ second = contract.probe(obj)
156
+ require(
157
+ same(first, second),
158
+ "determinism",
159
+ "the same output from the same input twice",
160
+ "two different outputs",
161
+ "Something in the component is drawing on state that changes between calls - an unseeded "
162
+ "random draw, a mutable default, or a value read from the clock.",
163
+ )
164
+ return CheckResult("determinism", True, "same input twice, same output")
165
+
166
+
167
+ def run_leakage(contract, obj) -> CheckResult:
168
+ if contract.leakage is None:
169
+ return CheckResult("leakage probe", True, contract.leakage_note)
170
+ detail = contract.leakage(obj)
171
+ return CheckResult("leakage probe", True, detail or contract.leakage_note)
172
+
173
+
174
+ def run_invariants(contract, obj) -> list[CheckResult]:
175
+ results = []
176
+ for label, fn in contract.invariants:
177
+ detail = fn(obj)
178
+ results.append(CheckResult(label, True, detail or ""))
179
+ return results
180
+
181
+
182
+ def evaluate(contract, obj) -> Conformance:
183
+ """Run all five checks, stopping the gating ones at the first failure."""
184
+ result = Conformance(component=contract.name, unit=contract.units[0], conformant=True)
185
+ stages: list[Callable[[], Any]] = [
186
+ lambda: [run_interface(contract, obj)],
187
+ lambda: [run_leakage(contract, obj)],
188
+ lambda: [run_determinism(contract, obj)],
189
+ lambda: run_invariants(contract, obj),
190
+ ]
191
+ names = ["interface", "leakage probe", "determinism", "domain invariants"]
192
+ for name, stage in zip(names, stages):
193
+ try:
194
+ result.checks.extend(stage())
195
+ except Failure as failure:
196
+ result.checks.append(CheckResult(failure.check, False, str(failure).split(": ", 1)[1]))
197
+ result.conformant = False
198
+ break
199
+ except NameError as exc:
200
+ missing = str(exc).split("'")[1] if "'" in str(exc) else "something"
201
+ result.checks.append(CheckResult(name, False, (
202
+ f"the component calls {missing!r}, which is not part of it. "
203
+ f"Colab does not keep your session, so a later notebook loads this file on its own "
204
+ f"and {missing!r} will not be there. Either move it inside the component, or pass "
205
+ f"it along: save_component(..., also=[{missing}]) for a function, "
206
+ f"include={{{missing!r}: ...}} for a value."
207
+ )))
208
+ result.conformant = False
209
+ break
210
+ except Exception as exc:
211
+ result.checks.append(
212
+ CheckResult(name, False, f"the check could not run: {type(exc).__name__}: {exc}")
213
+ )
214
+ result.conformant = False
215
+ break
216
+ if result.conformant:
217
+ try:
218
+ result.delta = describe_delta(contract.probe(obj), contract.probe(contract.reference()))
219
+ except Exception as exc:
220
+ result.delta = f"not computable ({type(exc).__name__})"
221
+ return result
@@ -0,0 +1,204 @@
1
+ """`save_component` and `load_component`: how a component is checked, stamped and recovered.
2
+
3
+ What persists between units is the student's *code*, not intermediate data. A notebook defines its
4
+ component from scratch and writes it here; a later pipeline notebook loads it, or falls back to the
5
+ shipped reference and says so. Restart-and-run-all works on any unit, in any order, on a cold
6
+ session, which is the property this module exists to protect.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import datetime as dt
12
+ import inspect
13
+ import json
14
+ import textwrap
15
+ from typing import Any, Callable, Sequence
16
+
17
+ from . import contracts, project
18
+ from .checks import Conformance, evaluate
19
+
20
+ __version_marker__ = "components"
21
+
22
+ _PREAMBLE = (
23
+ "# Saved by ml4t-coursework. This is your own code, exactly as you wrote it.\n"
24
+ "import numpy as np\n"
25
+ "import pandas as pd\n\n"
26
+ )
27
+
28
+
29
+ def _version() -> str:
30
+ from . import __version__
31
+
32
+ return __version__
33
+
34
+
35
+ def _now() -> str:
36
+ return dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds")
37
+
38
+
39
+ def _source_of(obj: Any, name: str) -> str:
40
+ try:
41
+ return textwrap.dedent(inspect.getsource(obj))
42
+ except (OSError, TypeError) as exc:
43
+ raise ValueError(
44
+ f"Could not read the source of the object you passed for {name!r}.\n"
45
+ f" Define it as a function or a class in a notebook cell and pass the object itself, "
46
+ f"not a lambda, a partial or an instance.\n"
47
+ f" ({type(exc).__name__}: {exc})"
48
+ ) from exc
49
+
50
+
51
+ def _rebuild(source: str, symbol: str, name: str) -> Any:
52
+ """Execute the saved source in a fresh namespace and return the object.
53
+
54
+ This is deliberately done before the checks run. What gets validated has to be what a later
55
+ notebook will actually load, and a component that only works because of something else in the
56
+ student's session would pass in place and fail on a cold kernel.
57
+ """
58
+ namespace: dict[str, Any] = {}
59
+ import numpy as np
60
+ import pandas as pd
61
+
62
+ namespace.update({"np": np, "pd": pd, "numpy": np, "pandas": pd})
63
+ try:
64
+ exec(compile(_PREAMBLE + source, f"<{name}>", "exec"), namespace)
65
+ except NameError as exc:
66
+ missing = str(exc).split("'")[1] if "'" in str(exc) else "something"
67
+ raise ValueError(
68
+ f"Your {name} refers to {missing!r}, which is not part of it.\n"
69
+ f" Colab does not keep your session, so a later notebook loads this file on its own "
70
+ f"and {missing!r} will not be there.\n"
71
+ f" Either move it inside the component, or pass it along: "
72
+ f"save_component({name!r}, {symbol}, also=[{missing}])"
73
+ ) from exc
74
+ if symbol not in namespace:
75
+ raise ValueError(
76
+ f"The saved source does not define {symbol!r}.\n"
77
+ f" Name the function or class you pass, and pass it by that name."
78
+ )
79
+ return namespace[symbol]
80
+
81
+
82
+ def save_component(
83
+ name: str,
84
+ obj: Any,
85
+ also: Sequence[Callable] = (),
86
+ include: dict[str, Any] | None = None,
87
+ quiet: bool = False,
88
+ ) -> Conformance:
89
+ """Check a component against its contract, stamp the verdict, and write it to the project.
90
+
91
+ `also` carries any function you defined in another cell that the component calls; `include`
92
+ carries any plain value it reads, as `{name: value}`. A component has to stand on its own,
93
+ because a later notebook loads this file on a cold session with nothing else in scope.
94
+ """
95
+ contract = contracts.get(name)
96
+ folder = project.components_dir()
97
+
98
+ if contract.kind == "config":
99
+ if not isinstance(obj, dict):
100
+ raise ValueError(
101
+ f"{name} is a recorded choice, not a function.\n"
102
+ f" Pass a dictionary. Expected keys: {contract.interface_detail}\n"
103
+ f" You passed a {type(obj).__name__}."
104
+ )
105
+ payload = dict(obj)
106
+ checked: Any = payload
107
+ body = json.dumps(payload, indent=2, default=str)
108
+ target = folder / f"{name}.json"
109
+ symbol = name
110
+ else:
111
+ symbol = getattr(obj, "__name__", None)
112
+ if not symbol:
113
+ raise ValueError(
114
+ f"{name}: pass the function or class itself, by name, not an instance or a lambda."
115
+ )
116
+ parts = [f"{key} = {value!r}" for key, value in (include or {}).items()]
117
+ parts += [_source_of(helper, name) for helper in also]
118
+ parts.append(_source_of(obj, name))
119
+ body = "\n\n".join(parts)
120
+ checked = _rebuild(body, symbol, name)
121
+ target = folder / f"{name}.py"
122
+
123
+ result = evaluate(contract, checked)
124
+ result.stamped_at = _now()
125
+ result.helper_version = _version()
126
+
127
+ target.write_text(_PREAMBLE + body if contract.kind == "callable" else body)
128
+ meta = result.to_dict() | {"symbol": symbol, "file": target.name, "kind": contract.kind}
129
+ (folder / f"{name}.meta.json").write_text(json.dumps(meta, indent=2))
130
+
131
+ if not quiet:
132
+ print(result)
133
+ if result.conformant:
134
+ print(f" saved to {target}")
135
+ else:
136
+ print(f" saved to {target}, and it is recorded as not conformant.")
137
+ print(" Nothing is blocked: fix it whenever you like, and any pipeline notebook you "
138
+ "run meanwhile uses the shipped reference for this step.")
139
+ return result
140
+
141
+
142
+ def _meta(name: str) -> dict | None:
143
+ path = project.components_dir(create=False) / f"{name}.meta.json"
144
+ if not path.is_file():
145
+ return None
146
+ try:
147
+ return json.loads(path.read_text())
148
+ except json.JSONDecodeError:
149
+ return None
150
+
151
+
152
+ def source_of(name: str) -> str:
153
+ """`'yours'` or `'reference'` - what `load_component` would return right now."""
154
+ meta = _meta(name)
155
+ return "yours" if meta and meta.get("conformant") else "reference"
156
+
157
+
158
+ def load_component(name: str, quiet: bool = False) -> Any:
159
+ """Return the student's component when it is conformant, the shipped reference otherwise.
160
+
161
+ It always says which. This is what stops a wrong Part 4 from blocking Part 7.
162
+ """
163
+ contract = contracts.get(name)
164
+ meta = _meta(name)
165
+ if meta and meta.get("conformant"):
166
+ folder = project.components_dir(create=False)
167
+ path = folder / meta["file"]
168
+ try:
169
+ if contract.kind == "config":
170
+ obj = json.loads(path.read_text())
171
+ else:
172
+ obj = _rebuild(path.read_text(), meta["symbol"], name)
173
+ if not quiet:
174
+ stamped = meta.get("stamped_at", "")[:10]
175
+ print(f"{name}: using your implementation (checked {stamped})")
176
+ return obj
177
+ except Exception as exc:
178
+ if not quiet:
179
+ print(f"{name}: your saved version would not load ({type(exc).__name__}), so this "
180
+ f"run uses the shipped reference.")
181
+ elif not quiet:
182
+ reason = "you have not written it yet" if meta is None else "yours is recorded as not conformant"
183
+ print(f"{name}: using the shipped reference, because {reason}.")
184
+ return contract.reference()
185
+
186
+
187
+ def status() -> str:
188
+ """Every component the course asks for, and where each one stands."""
189
+ lines = []
190
+ for name, contract in sorted(contracts.load_all().items()):
191
+ meta = _meta(name)
192
+ if meta is None:
193
+ state = "not written yet"
194
+ elif meta.get("conformant"):
195
+ state = f"conformant, {meta.get('stamped_at', '')[:10]}"
196
+ else:
197
+ failed = [c["name"] for c in meta.get("checks", []) if not c["passed"]]
198
+ state = f"NOT conformant ({', '.join(failed) or 'unknown'})"
199
+ lines.append(f" {name:<20} unit {contract.units[0]:<5} {state}")
200
+ return "components in your project folder:\n" + "\n".join(lines)
201
+
202
+
203
+ def catalog() -> str:
204
+ return contracts.catalog()
@@ -0,0 +1,101 @@
1
+ """What a component is, and the registry of the twenty the course asks for.
2
+
3
+ One `Contract` per component, declared beside its reference implementation in `reference/`. The
4
+ contract is authored *from* the reference, which is what guarantees it is passable, and every
5
+ reference is tested against its own contract plus a deliberately broken variant that must fail it.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import importlib
11
+ import pkgutil
12
+ from dataclasses import dataclass
13
+ from types import ModuleType
14
+ from typing import Any, Callable
15
+
16
+ CONTRACTS: dict[str, "Contract"] = {}
17
+
18
+ _SOURCES: list[str] = [f"{__package__}.reference"]
19
+ _LOADED: set[str] = set()
20
+
21
+
22
+ @dataclass
23
+ class Contract:
24
+ name: str
25
+ kind: str
26
+ units: tuple[str, ...]
27
+ summary: str
28
+ probe: Callable[[Any], Any]
29
+ interface: Callable[[Any], None]
30
+ reference: Callable[[], Any]
31
+ interface_detail: str = ""
32
+ invariants: tuple[tuple[str, Callable[[Any], str | None]], ...] = ()
33
+ leakage: Callable[[Any], str | None] | None = None
34
+ leakage_note: str = "not applicable to this component, and here is why"
35
+ dependencies: tuple[str, ...] = ()
36
+
37
+ def __post_init__(self) -> None:
38
+ if self.kind not in {"callable", "config"}:
39
+ raise ValueError(f"{self.name}: kind must be 'callable' or 'config', got {self.kind!r}")
40
+
41
+ def describe(self) -> str:
42
+ lines = [f"{self.name} ({self.kind}, written in unit {' and '.join(self.units)})",
43
+ f" {self.summary}",
44
+ f" interface: {self.interface_detail}",
45
+ f" leakage probe: {self.leakage_note}"]
46
+ for label, _ in self.invariants:
47
+ lines.append(f" invariant: {label}")
48
+ return "\n".join(lines)
49
+
50
+
51
+ def register(contract: Contract) -> Contract:
52
+ existing = CONTRACTS.get(contract.name)
53
+ if existing is not None and existing is not contract:
54
+ raise ValueError(
55
+ f"Two different contracts are both called {contract.name!r}.\n"
56
+ f" Already registered from: {existing.reference.__module__}\n"
57
+ f" Now registering from: {contract.reference.__module__}\n"
58
+ f" Component names are shared across every registered source, so pick another."
59
+ )
60
+ CONTRACTS[contract.name] = contract
61
+ return contract
62
+
63
+
64
+ def add_source(package: str | ModuleType) -> None:
65
+ """Add a package of contract modules to discovery. A course outside this one calls this."""
66
+ name = package if isinstance(package, str) else package.__name__
67
+ if name not in _SOURCES:
68
+ _SOURCES.append(name)
69
+
70
+
71
+ def load_all() -> dict[str, Contract]:
72
+ """Import every module in every registered source package, each of which registers a contract.
73
+
74
+ Tracked per source rather than by whether the registry is empty: a caller that pre-registers
75
+ one contract of its own must not suppress the import of every other source.
76
+ """
77
+ for source in list(_SOURCES):
78
+ if source in _LOADED:
79
+ continue
80
+ _LOADED.add(source)
81
+ package = importlib.import_module(source)
82
+ for module in pkgutil.iter_modules(package.__path__):
83
+ if not module.name.startswith("_"):
84
+ importlib.import_module(f"{package.__name__}.{module.name}")
85
+ return CONTRACTS
86
+
87
+
88
+ def get(name: str) -> Contract:
89
+ contracts = load_all()
90
+ if name not in contracts:
91
+ known = ", ".join(sorted(contracts))
92
+ raise KeyError(
93
+ f"There is no component called {name!r} in this course.\n"
94
+ f" The ones there are: {known}\n"
95
+ f" Check the spelling against the name the unit's notebook asked you to save."
96
+ )
97
+ return contracts[name]
98
+
99
+
100
+ def catalog() -> str:
101
+ return "\n\n".join(c.describe() for _, c in sorted(load_all().items()))
@@ -0,0 +1,86 @@
1
+ """Which course a notebook belongs to, and the few facts that differ between them.
2
+
3
+ Almost everything here is shared: the checks, the contract machinery, the results columns, the
4
+ report. Three things are not, and they are the three a package named after one course would have
5
+ gotten wrong - the project folder on the student's Drive, the environment override that points at
6
+ it, and the list of stage names a results row may carry. They live on a `Course` and a notebook
7
+ selects one in its opening cell.
8
+
9
+ There is deliberately no default. A notebook that never says which course it belongs to would
10
+ otherwise write its components into another course's folder, and the failure would surface weeks
11
+ later as a component that loads but does the wrong thing.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass
17
+
18
+ COURSES: dict[str, "Course"] = {}
19
+ _active: str | None = None
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class Course:
24
+ key: str
25
+ title: str
26
+ folder: str
27
+ env_var: str
28
+ stages: tuple[str, ...]
29
+ terminal_stages: tuple[str, ...] = ()
30
+
31
+ def __post_init__(self) -> None:
32
+ unknown = [s for s in self.terminal_stages if s not in self.stages]
33
+ if unknown:
34
+ raise ValueError(
35
+ f"{self.key}: terminal stages must also be stages, and "
36
+ f"{', '.join(unknown)} is not."
37
+ )
38
+
39
+
40
+ def register_course(course: Course, replace: bool = False) -> Course:
41
+ existing = COURSES.get(course.key)
42
+ if existing is not None and existing != course and not replace:
43
+ raise ValueError(
44
+ f"A different course is already registered as {course.key!r}.\n"
45
+ f" Registered: {existing}\n"
46
+ f" Pass replace=True if you meant to change it."
47
+ )
48
+ COURSES[course.key] = course
49
+ return course
50
+
51
+
52
+ def use(key: str) -> Course:
53
+ """Select the course this session belongs to. Every notebook calls this in its opening cell."""
54
+ global _active
55
+ if key not in COURSES:
56
+ known = ", ".join(sorted(COURSES)) or "none yet"
57
+ raise KeyError(
58
+ f"There is no course registered as {key!r}.\n"
59
+ f" Registered: {known}\n"
60
+ f" A course registers itself with ml4t_coursework.register_course()."
61
+ )
62
+ _active = key
63
+ return COURSES[key]
64
+
65
+
66
+ def active_course() -> Course:
67
+ if _active is None:
68
+ known = ", ".join(repr(k) for k in sorted(COURSES)) or "none yet"
69
+ raise RuntimeError(
70
+ "This session has not said which course it belongs to, so there is no project "
71
+ "folder to read or write.\n"
72
+ f" Add ml4t_coursework.use(...) to the notebook's opening cell, one of: {known}.\n"
73
+ " The setup notebook does this and so does every unit notebook."
74
+ )
75
+ return COURSES[_active]
76
+
77
+
78
+ register_course(Course(
79
+ key="foundations",
80
+ title="ML4T: Foundations",
81
+ folder="ml4t-foundations",
82
+ env_var="ML4T_FOUNDATIONS_HOME",
83
+ stages=("baseline", "v0", "part_02", "part_04", "part_05", "part_06", "part_07", "part_08",
84
+ "final"),
85
+ terminal_stages=("baseline", "v0", "final"),
86
+ ))