regclock 0.0.1__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.
regclock/__init__.py ADDED
@@ -0,0 +1,63 @@
1
+ """regclock: a calendar-correct runtime for regulatory obligation deadlines.
2
+
3
+ This package represents a regulatory obligation as plain data (a small
4
+ Pydantic schema) and computes its deadline lifecycle over real calendar
5
+ time as a deterministic, auditable state machine. See the README for
6
+ the *is / is not* scope and prior-art comparison.
7
+
8
+ Sub-namespaces of interest:
9
+
10
+ * :mod:`regclock.io` — importers (LegalRuleML) and reasoner adapters
11
+ (:class:`regclock.io.deontic.DeonticReasoner`).
12
+ * :mod:`regclock.i18n` — language registry for display strings; English
13
+ is built-in, other languages register via
14
+ :func:`regclock.i18n.register_language`.
15
+ """
16
+
17
+ from regclock import i18n
18
+ from regclock.lifecycle.events import Event, EventLog
19
+ from regclock.lifecycle.schedule import ScheduledInstance, build_schedule
20
+ from regclock.lifecycle.state_machine import LifecycleStatus, resolve_state
21
+ from regclock.model.amendment import Amendment, AmendmentLog
22
+ from regclock.model.calendar import BusinessCalendar
23
+ from regclock.model.obligation import (
24
+ Bearer,
25
+ DeadlineSpec,
26
+ EvidenceRequirement,
27
+ Obligation,
28
+ Penalty,
29
+ SourceCitation,
30
+ )
31
+ from regclock.schemas.types import (
32
+ DayBasis,
33
+ DeadlineKind,
34
+ EventKind,
35
+ RecurrenceFreq,
36
+ State,
37
+ )
38
+
39
+ __all__ = [
40
+ "Amendment",
41
+ "AmendmentLog",
42
+ "Bearer",
43
+ "BusinessCalendar",
44
+ "DayBasis",
45
+ "DeadlineKind",
46
+ "DeadlineSpec",
47
+ "Event",
48
+ "EventKind",
49
+ "EventLog",
50
+ "EvidenceRequirement",
51
+ "LifecycleStatus",
52
+ "Obligation",
53
+ "Penalty",
54
+ "RecurrenceFreq",
55
+ "ScheduledInstance",
56
+ "SourceCitation",
57
+ "State",
58
+ "build_schedule",
59
+ "i18n",
60
+ "resolve_state",
61
+ ]
62
+
63
+ __version__ = "0.0.1"
regclock/cli.py ADDED
@@ -0,0 +1,232 @@
1
+ """Command-line interface for regclock.
2
+
3
+ Subcommands:
4
+
5
+ * ``regclock schedule`` — expand a YAML obligation file into a
6
+ concrete due-date schedule for a horizon.
7
+ * ``regclock status`` — replay an event log and print the lifecycle
8
+ state of every obligation as of a reference date.
9
+ * ``regclock pack`` — assemble a regulator-ready evidence pack for a
10
+ reporting period.
11
+
12
+ The CLI keeps all heavy logic inside the library; this file is a thin
13
+ shell around it so that the package is usable from both Python and a
14
+ terminal.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import os
21
+ import sys
22
+ from datetime import date
23
+ from pathlib import Path
24
+ from typing import Any
25
+
26
+ import click
27
+ import yaml
28
+
29
+ from regclock import i18n
30
+ from regclock.emit.ics import to_ics
31
+ from regclock.emit.report import render_report
32
+ from regclock.evidence.pack import build_pack
33
+ from regclock.lifecycle.events import Event, EventLog
34
+ from regclock.lifecycle.schedule import build_schedule
35
+ from regclock.lifecycle.state_machine import resolve_state
36
+ from regclock.model.calendar import BusinessCalendar
37
+ from regclock.model.obligation import Obligation
38
+ from regclock.schemas.types import EventKind
39
+
40
+
41
+ def _default_lang_from_env() -> str:
42
+ """Resolve the default CLI language from environment variables.
43
+
44
+ Honours ``REGCLOCK_LANG`` first (explicit override), then ``LANG``
45
+ (POSIX), falling back to English. Only the language part of a POSIX
46
+ locale (e.g. ``"es_ES.UTF-8"`` → ``"es"``) is used.
47
+ """
48
+ raw = os.environ.get("REGCLOCK_LANG") or os.environ.get("LANG") or "en"
49
+ return raw.split(".", 1)[0].split("_", 1)[0].lower() or "en"
50
+
51
+
52
+ @click.group()
53
+ @click.option(
54
+ "--lang",
55
+ default=None,
56
+ help="Display language for human-readable output. "
57
+ "Defaults to REGCLOCK_LANG, then LANG, then 'en'.",
58
+ )
59
+ @click.version_option(package_name="regclock")
60
+ def cli(lang: str | None) -> None:
61
+ """A calendar-correct runtime for regulatory obligation deadlines."""
62
+ i18n.set_default_language(lang or _default_lang_from_env())
63
+
64
+
65
+ @cli.command("schedule")
66
+ @click.argument("obligations_path", type=click.Path(exists=True, path_type=Path))
67
+ @click.option("--from", "horizon_start", required=True, type=click.DateTime(["%Y-%m-%d"]))
68
+ @click.option("--to", "horizon_end", required=True, type=click.DateTime(["%Y-%m-%d"]))
69
+ @click.option(
70
+ "--format",
71
+ "fmt",
72
+ type=click.Choice(["json", "markdown", "ics"]),
73
+ default="markdown",
74
+ )
75
+ def schedule_cmd(
76
+ obligations_path: Path,
77
+ horizon_start: Any,
78
+ horizon_end: Any,
79
+ fmt: str,
80
+ ) -> None:
81
+ """Expand obligations into a concrete due-date schedule."""
82
+ obligations = _load_obligations(obligations_path)
83
+ calendars = _calendars_for(obligations)
84
+ schedule = build_schedule(
85
+ obligations,
86
+ horizon_start.date(),
87
+ horizon_end.date(),
88
+ calendars,
89
+ )
90
+ if fmt == "ics":
91
+ click.echo(to_ics(schedule), nl=False)
92
+ return
93
+ if fmt == "json":
94
+ rows = [
95
+ {
96
+ "obligation_id": s.obligation_id,
97
+ "title": s.title,
98
+ "jurisdiction": s.jurisdiction,
99
+ "due_on": s.due_on.isoformat(),
100
+ "kind": s.kind.value,
101
+ "trigger_on": s.trigger_on.isoformat() if s.trigger_on else None,
102
+ }
103
+ for s in schedule
104
+ ]
105
+ click.echo(json.dumps(rows, indent=2))
106
+ return
107
+ lines = ["| Obligation | Title | Due | Kind |", "|---|---|---|---|"]
108
+ for s in schedule:
109
+ lines.append(f"| {s.obligation_id} | {s.title} | {s.due_on} | {s.kind.value} |")
110
+ click.echo("\n".join(lines))
111
+
112
+
113
+ @cli.command("status")
114
+ @click.argument("obligations_path", type=click.Path(exists=True, path_type=Path))
115
+ @click.option(
116
+ "--events",
117
+ "events_path",
118
+ type=click.Path(exists=True, path_type=Path),
119
+ required=True,
120
+ )
121
+ @click.option("--asof", "as_of", required=True, type=click.DateTime(["%Y-%m-%d"]))
122
+ @click.option(
123
+ "--format",
124
+ "fmt",
125
+ type=click.Choice(["markdown", "html", "json"]),
126
+ default="markdown",
127
+ )
128
+ def status_cmd(obligations_path: Path, events_path: Path, as_of: Any, fmt: str) -> None:
129
+ """Replay the event log and print lifecycle states as of a date."""
130
+ obligations = _load_obligations(obligations_path)
131
+ events = _load_events(events_path)
132
+ calendars = _calendars_for(obligations)
133
+ schedule = build_schedule(
134
+ obligations,
135
+ as_of.date().replace(month=1, day=1),
136
+ as_of.date().replace(month=12, day=31),
137
+ calendars,
138
+ events=events,
139
+ )
140
+ obligations_by_id = {o.id: o for o in obligations}
141
+ statuses = []
142
+ for inst in schedule:
143
+ obligation = obligations_by_id[inst.obligation_id]
144
+ calendar = calendars[obligation.jurisdiction]
145
+ statuses.append(resolve_state(obligation, inst, events, calendar, as_of.date()))
146
+ click.echo(render_report(statuses, fmt=fmt)) # type: ignore[arg-type]
147
+
148
+
149
+ @cli.command("pack")
150
+ @click.argument("obligations_path", type=click.Path(exists=True, path_type=Path))
151
+ @click.option(
152
+ "--events",
153
+ "events_path",
154
+ type=click.Path(exists=True, path_type=Path),
155
+ required=True,
156
+ )
157
+ @click.option("--period", required=True, help="e.g. 2026Q1 or 2026-01-01:2026-03-31")
158
+ @click.option("--out", "out_path", type=click.Path(path_type=Path), default=None)
159
+ def pack_cmd(obligations_path: Path, events_path: Path, period: str, out_path: Path | None) -> None:
160
+ """Assemble an evidence pack for a reporting period."""
161
+ start, end = _parse_period(period)
162
+ obligations = _load_obligations(obligations_path)
163
+ events = _load_events(events_path)
164
+ calendars = _calendars_for(obligations)
165
+ schedule = build_schedule(obligations, start, end, calendars, events=events)
166
+ pack = build_pack(
167
+ period_start=start,
168
+ period_end=end,
169
+ obligations=obligations,
170
+ schedule=schedule,
171
+ events=events,
172
+ calendars=calendars,
173
+ )
174
+ payload = pack.model_dump(mode="json")
175
+ text = json.dumps(payload, indent=2, default=str)
176
+ if out_path is None:
177
+ click.echo(text)
178
+ else:
179
+ out_path.write_text(text, encoding="utf-8")
180
+ click.echo(f"wrote {out_path} (digest={pack.digest[:12]}…)")
181
+
182
+
183
+ def _load_obligations(path: Path) -> list[Obligation]:
184
+ """Load a list of obligations from YAML or JSON."""
185
+ raw = yaml.safe_load(path.read_text(encoding="utf-8"))
186
+ if isinstance(raw, dict) and "obligations" in raw:
187
+ raw = raw["obligations"]
188
+ if not isinstance(raw, list):
189
+ click.echo("Expected a list of obligations or {obligations: [...]}", err=True)
190
+ sys.exit(2)
191
+ return [Obligation.model_validate(item) for item in raw]
192
+
193
+
194
+ def _load_events(path: Path) -> EventLog:
195
+ """Load an event log from JSONL."""
196
+ events: list[Event] = []
197
+ for line in path.read_text(encoding="utf-8").splitlines():
198
+ line = line.strip()
199
+ if not line:
200
+ continue
201
+ payload = json.loads(line)
202
+ if "kind" in payload and isinstance(payload["kind"], str):
203
+ payload["kind"] = EventKind(payload["kind"])
204
+ events.append(Event.model_validate(payload))
205
+ return EventLog(events=events)
206
+
207
+
208
+ def _calendars_for(obligations: list[Obligation]) -> dict[str, BusinessCalendar]:
209
+ """Build a default :class:`BusinessCalendar` per jurisdiction seen."""
210
+ jurisdictions = {o.jurisdiction for o in obligations}
211
+ return {j: BusinessCalendar(jurisdiction=j) for j in jurisdictions}
212
+
213
+
214
+ def _parse_period(period: str) -> tuple[date, date]:
215
+ """Parse a ``YYYYQn`` or ``start:end`` period string."""
216
+ if ":" in period:
217
+ a, b = period.split(":", 1)
218
+ return date.fromisoformat(a), date.fromisoformat(b)
219
+ if "Q" in period.upper():
220
+ year_str, q_str = period.upper().split("Q", 1)
221
+ year = int(year_str)
222
+ q = int(q_str)
223
+ month_start = {1: 1, 2: 4, 3: 7, 4: 10}[q]
224
+ from calendar import monthrange
225
+
226
+ month_end = month_start + 2
227
+ return date(year, month_start, 1), date(year, month_end, monthrange(year, month_end)[1])
228
+ raise click.BadParameter(f"Unrecognised period: {period}")
229
+
230
+
231
+ if __name__ == "__main__": # pragma: no cover
232
+ cli()
@@ -0,0 +1,12 @@
1
+ """Emit layer: documents, iCalendar exports, and status reports."""
2
+
3
+ from regclock.emit.documents import render_reminder, render_stub
4
+ from regclock.emit.ics import to_ics
5
+ from regclock.emit.report import render_report
6
+
7
+ __all__ = [
8
+ "render_reminder",
9
+ "render_report",
10
+ "render_stub",
11
+ "to_ics",
12
+ ]
@@ -0,0 +1,122 @@
1
+ """Render filing/document stubs and reminder payloads via Jinja2.
2
+
3
+ Templates live under ``regclock/templates/{lang}/``. The English pack
4
+ ships in-tree; additional language packs are registered at runtime via
5
+ :func:`regclock.i18n.register_language`. When a language registers no
6
+ template directory, the English templates are used with the registered
7
+ labels substituted in via the ``ui`` namespace and the
8
+ ``state_label`` / ``event_kind_label`` Jinja globals.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from importlib import resources
14
+ from pathlib import Path
15
+
16
+ from jinja2 import (
17
+ ChoiceLoader,
18
+ Environment,
19
+ FileSystemLoader,
20
+ StrictUndefined,
21
+ select_autoescape,
22
+ )
23
+
24
+ from regclock import i18n
25
+ from regclock.lifecycle.state_machine import LifecycleStatus
26
+ from regclock.model.obligation import Obligation
27
+
28
+
29
+ def _env(lang: str | None = None) -> Environment:
30
+ """Build a Jinja2 environment configured for ``lang``.
31
+
32
+ The loader tries the language-specific template directory first
33
+ (if registered via :func:`regclock.i18n.register_language`) and
34
+ falls back to the in-tree English pack for any file the language
35
+ pack does not override. ``ui``, ``state_label``, and
36
+ ``event_kind_label`` are exposed as globals so templates do not
37
+ need to import anything.
38
+ """
39
+ resolved_lang = (lang or i18n.get_default_language()).lower()
40
+ en_dir = str(resources.files("regclock").joinpath("templates", "en"))
41
+ loaders = []
42
+ custom_dir = i18n.templates_dir_for(resolved_lang)
43
+ if custom_dir is not None:
44
+ loaders.append(FileSystemLoader(str(Path(custom_dir))))
45
+ loaders.append(FileSystemLoader(en_dir))
46
+ env = Environment(
47
+ loader=ChoiceLoader(loaders),
48
+ autoescape=select_autoescape(enabled_extensions=("html", "md")),
49
+ undefined=StrictUndefined,
50
+ trim_blocks=True,
51
+ lstrip_blocks=True,
52
+ )
53
+ env.globals["ui"] = i18n.labels_for_jinja(resolved_lang)
54
+ env.globals["state_label"] = lambda state: i18n.label_for_state(state, resolved_lang)
55
+ env.globals["event_kind_label"] = lambda kind: i18n.label_for_event_kind(kind, resolved_lang)
56
+ return env
57
+
58
+
59
+ def render_stub(
60
+ obligation: Obligation,
61
+ status: LifecycleStatus,
62
+ lang: str | None = None,
63
+ ) -> str:
64
+ """Render a Markdown filing/document stub for one obligation instance.
65
+
66
+ Args:
67
+ obligation: The obligation definition.
68
+ status: The computed lifecycle status (provides due date,
69
+ state, evidence references).
70
+ lang: Language code; defaults to
71
+ :func:`regclock.i18n.get_default_language`.
72
+
73
+ Returns:
74
+ A Markdown string ready to be saved to disk or attached to a
75
+ ticket. The content is descriptive only: the engine does not
76
+ sign or submit anything.
77
+
78
+ Example:
79
+ >>> render_stub(obl, status, lang="es")
80
+ '# Borrador de presentación: ...'
81
+
82
+ """
83
+ template = _env(lang).get_template("filing_stub.md.j2")
84
+ return template.render(obligation=obligation, status=status)
85
+
86
+
87
+ def render_reminder(
88
+ obligation: Obligation,
89
+ status: LifecycleStatus,
90
+ lang: str | None = None,
91
+ ) -> dict[str, str]:
92
+ """Render a reminder payload for an upcoming or overdue instance.
93
+
94
+ Args:
95
+ obligation: The obligation definition.
96
+ status: The computed lifecycle status.
97
+ lang: Language code; defaults to
98
+ :func:`regclock.i18n.get_default_language`.
99
+
100
+ Returns:
101
+ A dict with ``subject``, ``summary``, and ``body`` keys. The
102
+ dict is intentionally minimal so it can be fed into email,
103
+ Slack, or ticket-system adapters without further work.
104
+
105
+ Example:
106
+ >>> render_reminder(obl, status)
107
+ {'subject': '...', 'summary': '...', 'body': '...'}
108
+
109
+ """
110
+ body_template = _env(lang).get_template("reminder_body.md.j2")
111
+ body = body_template.render(obligation=obligation, status=status)
112
+ subject = (
113
+ f"[{obligation.jurisdiction}] {obligation.title} — "
114
+ f"{i18n.t('ui.due_on', lang).lower()} {status.instance.due_on}"
115
+ )
116
+ summary = (
117
+ f"{i18n.t('report.col_state', lang)}: "
118
+ f"{i18n.label_for_state(status.state, lang)}; "
119
+ f"{i18n.t('ui.reminder_due_date', lang).lower()} "
120
+ f"({status.instance.due_on})."
121
+ )
122
+ return {"subject": subject, "summary": summary, "body": body}
regclock/emit/ics.py ADDED
@@ -0,0 +1,95 @@
1
+ """Export a schedule to iCalendar (RFC 5545).
2
+
3
+ We hand-roll the ICS output instead of pulling in a third-party
4
+ dependency, because the format we emit (VEVENT only, all-day) is small,
5
+ stable, and easy to validate.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ from collections.abc import Iterable
12
+ from datetime import date, datetime, timezone
13
+
14
+ from regclock.lifecycle.schedule import ScheduledInstance
15
+
16
+ _PRODID = "-//regclock//deadline-runtime//EN"
17
+
18
+
19
+ def to_ics(instances: Iterable[ScheduledInstance], calendar_name: str = "Obligations") -> str:
20
+ """Render a list of scheduled instances as an iCalendar string.
21
+
22
+ Args:
23
+ instances: The scheduled instances to export.
24
+ calendar_name: Human-readable name shown by calendar clients.
25
+
26
+ Returns:
27
+ An iCalendar VCALENDAR/VEVENT string using all-day events
28
+ (``VALUE=DATE``).
29
+
30
+ Example:
31
+ >>> print(to_ics(schedule)[:64])
32
+ BEGIN:VCALENDAR
33
+ VERSION:2.0
34
+ PRODID:-//regclock//deadline-runtime//EN
35
+
36
+ """
37
+ now = _utc_now_stamp()
38
+ lines: list[str] = [
39
+ "BEGIN:VCALENDAR",
40
+ "VERSION:2.0",
41
+ f"PRODID:{_PRODID}",
42
+ "CALSCALE:GREGORIAN",
43
+ f"X-WR-CALNAME:{_escape(calendar_name)}",
44
+ ]
45
+ for inst in instances:
46
+ uid = _uid_for(inst)
47
+ lines.extend(
48
+ [
49
+ "BEGIN:VEVENT",
50
+ f"UID:{uid}",
51
+ f"DTSTAMP:{now}",
52
+ f"DTSTART;VALUE=DATE:{_ical_date(inst.due_on)}",
53
+ f"DTEND;VALUE=DATE:{_ical_date(_next_day(inst.due_on))}",
54
+ f"SUMMARY:{_escape(inst.title)}",
55
+ f"DESCRIPTION:{_escape(f'Obligation {inst.obligation_id} ({inst.jurisdiction})')}",
56
+ "TRANSP:TRANSPARENT",
57
+ "END:VEVENT",
58
+ ]
59
+ )
60
+ lines.append("END:VCALENDAR")
61
+ return "\r\n".join(lines) + "\r\n"
62
+
63
+
64
+ def _ical_date(d: date) -> str:
65
+ """Render a date as ``YYYYMMDD`` per RFC 5545 ``DATE`` value type."""
66
+ return d.strftime("%Y%m%d")
67
+
68
+
69
+ def _next_day(d: date) -> date:
70
+ """Return the day after ``d``; used for the exclusive DTEND of all-day events."""
71
+ from datetime import timedelta
72
+
73
+ return d + timedelta(days=1)
74
+
75
+
76
+ def _utc_now_stamp() -> str:
77
+ """Return the current UTC time as an RFC 5545 ``DTSTAMP`` string."""
78
+ return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
79
+
80
+
81
+ def _escape(text: str) -> str:
82
+ """Escape backslashes, commas, semicolons and newlines per RFC 5545."""
83
+ return (
84
+ text.replace("\\", "\\\\")
85
+ .replace(",", "\\,")
86
+ .replace(";", "\\;")
87
+ .replace("\n", "\\n")
88
+ )
89
+
90
+
91
+ def _uid_for(inst: ScheduledInstance) -> str:
92
+ """Build a stable, deterministic UID for one scheduled instance."""
93
+ material = f"{inst.obligation_id}|{inst.due_on.isoformat()}|{inst.kind.value}"
94
+ digest = hashlib.sha1(material.encode("utf-8")).hexdigest()
95
+ return f"{digest}@regclock"
@@ -0,0 +1,126 @@
1
+ """Status reports in Markdown, HTML, or JSON.
2
+
3
+ JSON output keeps machine-readable English ``state.value`` strings so
4
+ that serialised reports remain locale-agnostic and diffable. Markdown
5
+ and HTML use the translated display strings registered via
6
+ :mod:`regclock.i18n`.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from collections.abc import Iterable
13
+ from typing import Literal
14
+
15
+ from regclock import i18n
16
+ from regclock.lifecycle.state_machine import LifecycleStatus
17
+
18
+
19
+ def render_report(
20
+ statuses: Iterable[LifecycleStatus],
21
+ fmt: Literal["markdown", "html", "json"] = "markdown",
22
+ lang: str | None = None,
23
+ ) -> str:
24
+ """Render a flat status report for a collection of lifecycle statuses.
25
+
26
+ Args:
27
+ statuses: The lifecycle statuses to include.
28
+ fmt: Output format: ``"markdown"`` (default), ``"html"``, or
29
+ ``"json"``.
30
+ lang: Language code for human-readable columns. JSON output is
31
+ always machine-readable (English ``state.value``);
32
+ ``lang`` only affects Markdown and HTML. Defaults to the
33
+ current default language.
34
+
35
+ Returns:
36
+ The rendered report as a single string.
37
+
38
+ Raises:
39
+ ValueError: If ``fmt`` is not a recognised format.
40
+
41
+ Example:
42
+ >>> print(render_report(statuses))
43
+ | Obligation | State | Due | ...
44
+
45
+ """
46
+ statuses_list = list(statuses)
47
+ rows = [_row(s) for s in statuses_list]
48
+ if fmt == "json":
49
+ return json.dumps(rows, indent=2, default=str)
50
+ resolved_lang = lang or i18n.get_default_language()
51
+ display_rows = [
52
+ {**row, "state_display": i18n.label_for_state(s.state, resolved_lang)}
53
+ for row, s in zip(rows, statuses_list)
54
+ ]
55
+ if fmt == "markdown":
56
+ return _markdown(display_rows, resolved_lang)
57
+ if fmt == "html":
58
+ return _html(display_rows, resolved_lang)
59
+ raise ValueError(f"Unknown format: {fmt}")
60
+
61
+
62
+ def _row(status: LifecycleStatus) -> dict[str, object]:
63
+ """Flatten a :class:`LifecycleStatus` into a serialisable row dict.
64
+
65
+ ``state`` is always the machine-readable enum value (English) so
66
+ that JSON output is locale-independent.
67
+ """
68
+ return {
69
+ "obligation_id": status.instance.obligation_id,
70
+ "title": status.instance.title,
71
+ "jurisdiction": status.instance.jurisdiction,
72
+ "due_on": status.instance.due_on.isoformat(),
73
+ "state": status.state.value,
74
+ "days_to_due": status.days_to_due,
75
+ "evidence_on": status.evidence.occurred_on.isoformat() if status.evidence else None,
76
+ }
77
+
78
+
79
+ def _headers(lang: str) -> dict[str, str]:
80
+ return {
81
+ "obligation": i18n.t("report.col_obligation", lang),
82
+ "title": i18n.t("report.col_title", lang),
83
+ "jurisdiction": i18n.t("report.col_jurisdiction", lang),
84
+ "due": i18n.t("report.col_due", lang),
85
+ "state": i18n.t("report.col_state", lang),
86
+ "days": i18n.t("report.col_days", lang),
87
+ "evidence": i18n.t("report.col_evidence", lang),
88
+ }
89
+
90
+
91
+ def _markdown(rows: list[dict[str, object]], lang: str) -> str:
92
+ """Render rows as a GitHub-flavoured Markdown table."""
93
+ h = _headers(lang)
94
+ header = (
95
+ f"| {h['obligation']} | {h['title']} | {h['jurisdiction']} | "
96
+ f"{h['due']} | {h['state']} | {h['days']} | {h['evidence']} |\n"
97
+ "|---|---|---|---|---|---|---|\n"
98
+ )
99
+ body = "".join(
100
+ f"| {r['obligation_id']} | {r['title']} | {r['jurisdiction']} | "
101
+ f"{r['due_on']} | {r['state_display']} | {r['days_to_due']} | "
102
+ f"{r['evidence_on'] or '-'} |\n"
103
+ for r in rows
104
+ )
105
+ return header + body
106
+
107
+
108
+ def _html(rows: list[dict[str, object]], lang: str) -> str:
109
+ """Render rows as a minimal HTML table."""
110
+ h = _headers(lang)
111
+ head = (
112
+ "<table><thead><tr>"
113
+ f"<th>{h['obligation']}</th><th>{h['title']}</th>"
114
+ f"<th>{h['jurisdiction']}</th><th>{h['due']}</th>"
115
+ f"<th>{h['state']}</th><th>{h['days']}</th>"
116
+ f"<th>{h['evidence']}</th>"
117
+ "</tr></thead><tbody>"
118
+ )
119
+ body = "".join(
120
+ f"<tr><td>{r['obligation_id']}</td><td>{r['title']}</td>"
121
+ f"<td>{r['jurisdiction']}</td><td>{r['due_on']}</td>"
122
+ f"<td>{r['state_display']}</td><td>{r['days_to_due']}</td>"
123
+ f"<td>{r['evidence_on'] or '-'}</td></tr>"
124
+ for r in rows
125
+ )
126
+ return head + body + "</tbody></table>"
@@ -0,0 +1,11 @@
1
+ """Evidence layer: reproducible satisfaction records and regulator-ready packs."""
2
+
3
+ from regclock.evidence.pack import EvidencePack, build_pack
4
+ from regclock.evidence.record import EvidenceRecord, make_record
5
+
6
+ __all__ = [
7
+ "EvidencePack",
8
+ "EvidenceRecord",
9
+ "build_pack",
10
+ "make_record",
11
+ ]