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 +63 -0
- regclock/cli.py +232 -0
- regclock/emit/__init__.py +12 -0
- regclock/emit/documents.py +122 -0
- regclock/emit/ics.py +95 -0
- regclock/emit/report.py +126 -0
- regclock/evidence/__init__.py +11 -0
- regclock/evidence/pack.py +111 -0
- regclock/evidence/record.py +102 -0
- regclock/i18n.py +221 -0
- regclock/io/__init__.py +16 -0
- regclock/io/deontic.py +104 -0
- regclock/io/legalruleml.py +172 -0
- regclock/lifecycle/__init__.py +14 -0
- regclock/lifecycle/events.py +135 -0
- regclock/lifecycle/schedule.py +137 -0
- regclock/lifecycle/state_machine.py +199 -0
- regclock/model/__init__.py +27 -0
- regclock/model/amendment.py +107 -0
- regclock/model/calendar.py +211 -0
- regclock/model/obligation.py +221 -0
- regclock/model/recurrence.py +240 -0
- regclock/schemas/__init__.py +17 -0
- regclock/schemas/types.py +99 -0
- regclock/templates/en/filing_stub.md.j2 +31 -0
- regclock/templates/en/reminder_body.md.j2 +10 -0
- regclock/utils/__init__.py +13 -0
- regclock/utils/fiscal.py +266 -0
- regclock/utils/llm_client.py +165 -0
- regclock-0.0.1.dist-info/METADATA +538 -0
- regclock-0.0.1.dist-info/RECORD +34 -0
- regclock-0.0.1.dist-info/WHEEL +4 -0
- regclock-0.0.1.dist-info/entry_points.txt +2 -0
- regclock-0.0.1.dist-info/licenses/LICENSE +201 -0
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"
|
regclock/emit/report.py
ADDED
|
@@ -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
|
+
]
|