git-worklog 1.6.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 (44) hide show
  1. git_worklog/__init__.py +58 -0
  2. git_worklog/__main__.py +10 -0
  3. git_worklog/analysis/__init__.py +48 -0
  4. git_worklog/analysis/commits.py +118 -0
  5. git_worklog/analysis/coverage.py +149 -0
  6. git_worklog/analysis/history.py +339 -0
  7. git_worklog/analysis/manifest.py +432 -0
  8. git_worklog/analysis/reconcile.py +211 -0
  9. git_worklog/analysis/refs.py +178 -0
  10. git_worklog/analysis/results.py +871 -0
  11. git_worklog/analysis/worktree.py +114 -0
  12. git_worklog/cli/__init__.py +414 -0
  13. git_worklog/cli/analyze.py +340 -0
  14. git_worklog/cli/apply.py +61 -0
  15. git_worklog/cli/coverage.py +62 -0
  16. git_worklog/cli/doctor.py +348 -0
  17. git_worklog/cli/migrate.py +43 -0
  18. git_worklog/cli/models.py +112 -0
  19. git_worklog/cli/preview.py +124 -0
  20. git_worklog/cli/refs.py +55 -0
  21. git_worklog/cli/reindex.py +72 -0
  22. git_worklog/cli/report.py +227 -0
  23. git_worklog/cli/validate.py +252 -0
  24. git_worklog/cli/version.py +47 -0
  25. git_worklog/cli/view.py +218 -0
  26. git_worklog/config.py +79 -0
  27. git_worklog/data/provider_models.json +34 -0
  28. git_worklog/dates.py +237 -0
  29. git_worklog/language.py +295 -0
  30. git_worklog/markers.py +765 -0
  31. git_worklog/mdhtml.py +266 -0
  32. git_worklog/migrate.py +305 -0
  33. git_worklog/paths.py +85 -0
  34. git_worklog/preview.py +776 -0
  35. git_worklog/providers.py +337 -0
  36. git_worklog/viewer.py +1198 -0
  37. git_worklog/viewserver.py +120 -0
  38. git_worklog/writer.py +420 -0
  39. git_worklog-1.6.0.dist-info/METADATA +352 -0
  40. git_worklog-1.6.0.dist-info/RECORD +44 -0
  41. git_worklog-1.6.0.dist-info/WHEEL +5 -0
  42. git_worklog-1.6.0.dist-info/entry_points.txt +2 -0
  43. git_worklog-1.6.0.dist-info/licenses/LICENSE +21 -0
  44. git_worklog-1.6.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,218 @@
1
+ """``git-worklog view`` — the worklog as one page in a browser.
2
+
3
+ Read-only, like ``validate``: nothing in the repository or its worklog is
4
+ written. The page is derived from the day files every time
5
+ (:mod:`git_worklog.viewer`) and lands outside the repository, in
6
+ ``~/.git-worklog/view/``, under a name fixed per worklog -- so re-running the
7
+ command and reloading the tab is how the page refreshes. The file is
8
+ owner-only because it embeds the whole worklog, quotes from the source
9
+ included.
10
+
11
+ Git is asked, read-only, about the commits the worklog cites, so a hash on the
12
+ page opens its commit. The repository asked is the directory the worklog sits
13
+ in -- the same one the page is named for -- not the cwd, which with ``--dir``
14
+ may be another project altogether.
15
+
16
+ ``--serve`` keeps the command running as a local server
17
+ (:mod:`git_worklog.viewserver`) instead of writing a file, which is what lets
18
+ the page show diffs. The JSON still comes first and whole: ``run`` binds the
19
+ port and opens the browser, the answer is printed, and only then does
20
+ ``after_emit`` start answering requests, until it is interrupted.
21
+
22
+ Opening a browser is best effort and can never cost the JSON contract. Python's
23
+ ``webbrowser`` starts some browsers as child processes that inherit stdout, so
24
+ for the length of the call stdout points at stderr. And on Linux with no
25
+ display, ``webbrowser`` falls back to terminal browsers (lynx, w3m) whenever
26
+ ``TERM`` is set -- which an agent's shell sets -- and runs them in the
27
+ foreground, where they would sit waiting for keys nobody will press. There the
28
+ page is written and the browser is not started; the answer says so and exits 0,
29
+ because the page is what the command promised.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import hashlib
35
+ import os
36
+ import re
37
+ import sys
38
+ import webbrowser
39
+ from pathlib import Path
40
+
41
+ from git_worklog import markers as wm
42
+ from git_worklog import paths, viewer, viewserver, writer
43
+
44
+ NOTE = ("The page embeds the whole worklog, quotes from the source included, and "
45
+ "is written owner-only. Share it only where the source may be read.")
46
+ SERVE_NOTE = ("Serving on 127.0.0.1 until interrupted (Ctrl+C). The page shows "
47
+ "diffs, which are source code. The URL holds this run's access "
48
+ "token: it stays in the browser's history and in this output, "
49
+ "and stops working when the server does.")
50
+
51
+
52
+ def _default_output(worklog_dir: str) -> str:
53
+ """One stable file per worklog: named for its project, keyed by its path.
54
+
55
+ The project is the directory holding the worklog, not the cwd: the skill runs
56
+ every command from its own directory, which would name every page after it.
57
+ """
58
+ real = os.path.realpath(worklog_dir)
59
+ project = re.sub(r"[^A-Za-z0-9._-]+", "-", os.path.basename(os.path.dirname(real)))
60
+ project = project.strip("-.") or "worklog"
61
+ digest = hashlib.sha256(real.encode("utf-8")).hexdigest()[:8]
62
+ return os.path.join(paths.view_dir(), f"{project}-{digest}.html")
63
+
64
+
65
+ def _inside(path: str, directory: str) -> bool:
66
+ path, directory = os.path.realpath(path), os.path.realpath(directory)
67
+ try:
68
+ return os.path.commonpath([path, directory]) == directory
69
+ except ValueError: # different drives on Windows
70
+ return False
71
+
72
+
73
+ def _open_quietly(url: str) -> "str | None":
74
+ """Open ``url`` in a browser. None when it opened, else why it did not."""
75
+ if sys.platform.startswith("linux") and not any(
76
+ os.environ.get(v) for v in ("DISPLAY", "WAYLAND_DISPLAY", "BROWSER")):
77
+ return "there is no display to open a browser on"
78
+ sys.stdout.flush()
79
+ saved = os.dup(1)
80
+ try:
81
+ os.dup2(2, 1)
82
+ opened = webbrowser.open(url)
83
+ finally:
84
+ os.dup2(saved, 1)
85
+ os.close(saved)
86
+ return None if opened else "no browser could be started"
87
+
88
+
89
+ def run(args) -> "tuple[dict, int]":
90
+ repo = args.repo or "."
91
+ worklog_dir = args.dir or os.path.join(repo, wm.WORKLOG_DIRNAME)
92
+ target = os.path.abspath(worklog_dir)
93
+
94
+ if not os.path.isdir(worklog_dir):
95
+ return {"ok": False, "worklog_dir": target, "errors": [{
96
+ "code": "NOT_FOUND", "message": f"{target} does not exist.",
97
+ "target": target}]}, 2
98
+ # The page may go anywhere but into the worklog it shows:
99
+ # `--output .git-worklog/index.md` would replace the index with HTML.
100
+ if args.output and _inside(args.output, worklog_dir):
101
+ out = os.path.abspath(args.output)
102
+ return {"ok": False, "worklog_dir": target, "errors": [{
103
+ "code": "OUTPUT_INSIDE_WORKLOG",
104
+ "message": f"{out} is inside the worklog directory {target}; "
105
+ "view never writes there.",
106
+ "target": out}]}, 2
107
+
108
+ # Where the worklog lives, which is where its commits are.
109
+ repo_dir = os.path.dirname(os.path.realpath(worklog_dir))
110
+ if getattr(args, "serve", False):
111
+ return _serve(args, worklog_dir, target, repo_dir)
112
+
113
+ try:
114
+ page, info = viewer.build(worklog_dir, repo=repo_dir)
115
+ if args.output:
116
+ out = os.path.abspath(args.output)
117
+ else:
118
+ # Created first, owner-only: atomic_write would create it too, but
119
+ # with default permissions, and this directory holds private text.
120
+ paths.ensure_dir(paths.view_dir())
121
+ out = _default_output(worklog_dir)
122
+ writer.atomic_write(out, page, lambda _text: None, prefix=".rw-view-")
123
+ except OSError as exc:
124
+ return {"ok": False, "worklog_dir": target, "errors": [{
125
+ "code": "IO_ERROR", "message": str(exc)}]}, 2
126
+
127
+ url = Path(out).as_uri()
128
+ warnings = list(info["warnings"])
129
+ opened = _open(args, url, f"The page was written, but {{}}. Open {out}.", out, warnings)
130
+ return {
131
+ "ok": True,
132
+ "worklog_dir": target,
133
+ "layout": info["layout"],
134
+ "output": out,
135
+ "url": url,
136
+ "opened": opened,
137
+ "day_count": info["day_count"],
138
+ "commit_count": info["commit_count"],
139
+ "range": info["range"],
140
+ "warnings": warnings,
141
+ "note": NOTE,
142
+ }, 0
143
+
144
+
145
+ def _open(args, url: str, message: str, target: str, warnings: list) -> bool:
146
+ """Open ``url`` unless told not to; a browser that will not start is a warning."""
147
+ if args.no_open:
148
+ return False
149
+ reason = _open_quietly(url)
150
+ if reason:
151
+ warnings.append({"code": "BROWSER_NOT_OPENED",
152
+ "message": message.format(reason), "target": target})
153
+ return reason is None
154
+
155
+
156
+ def _serve(args, worklog_dir: str, target: str, repo_dir: str) -> "tuple[dict, int]":
157
+ """Bind, build once and answer; :func:`after_emit` does the serving."""
158
+ server = None
159
+ try:
160
+ server = viewserver.Server(worklog_dir, repo_dir)
161
+ _page, info = server.page()
162
+ except OSError as exc:
163
+ if server is not None:
164
+ server.server_close()
165
+ return {"ok": False, "worklog_dir": target, "errors": [{
166
+ "code": "IO_ERROR", "message": str(exc)}]}, 2
167
+ args.server = server
168
+ url = server.url
169
+ warnings = list(info["warnings"])
170
+ # The socket is already listening, so a browser that is quicker than
171
+ # after_emit just waits in the backlog.
172
+ opened = _open(args, url, f"The page is being served, but {{}}. Open {url}.", url,
173
+ warnings)
174
+ return {
175
+ "ok": True,
176
+ "worklog_dir": target,
177
+ "layout": info["layout"],
178
+ "output": None,
179
+ "url": url,
180
+ "serving": True,
181
+ "opened": opened,
182
+ "day_count": info["day_count"],
183
+ "commit_count": info["commit_count"],
184
+ "range": info["range"],
185
+ "warnings": warnings,
186
+ "note": SERVE_NOTE,
187
+ }, 0
188
+
189
+
190
+ def after_emit(args, payload: dict, code: int) -> int:
191
+ """Serve until interrupted, once the answer is out."""
192
+ server = getattr(args, "server", None)
193
+ if server is None:
194
+ return code
195
+ # A pipe is block-buffered: unflushed, the reader would wait for a URL that
196
+ # is only printed when the server stops.
197
+ sys.stdout.flush()
198
+ try:
199
+ server.serve_forever()
200
+ except KeyboardInterrupt:
201
+ pass
202
+ finally:
203
+ server.server_close()
204
+ return 0
205
+
206
+
207
+ def render_text(p: dict) -> str:
208
+ if not p.get("ok"):
209
+ return "".join(f"error: {e['message']}\n" for e in p.get("errors", []))
210
+ head = f"git-worklog view — {p['day_count']} day file(s)"
211
+ if p["range"]:
212
+ head += f", {p['range']['from']} → {p['range']['to']}"
213
+ lines = [head, f" {p['output'] or 'serving ' + p['url']}",
214
+ " opened in your browser" if p["opened"] else f" open: {p['url']}"]
215
+ if p.get("serving"):
216
+ lines.append(" press Ctrl+C to stop")
217
+ lines.extend(f" warning: {w['message']}" for w in p["warnings"])
218
+ return "\n".join(lines) + "\n"
git_worklog/config.py ADDED
@@ -0,0 +1,79 @@
1
+ """Reading ``.git-worklog/config.json`` (roadmap §4.4, §6.2.7).
2
+
3
+ Until the language contract there was no reader at all: ``markers.render_config``
4
+ wrote the file on first apply and only ``schema_version`` was ever read back, by
5
+ ``doctor`` and ``validate``, to check the layout was not from the future. This
6
+ module is the first code to treat config as *settings* rather than as a version
7
+ stamp.
8
+
9
+ Two facts about the file shape everything here:
10
+
11
+ ``ensure_data_dir`` never rewrites an existing config. Every worklog created
12
+ since the ``.git-worklog/`` layout landed already carries ``"language": "auto"``
13
+ and ``"index_language": "auto"``, written by builds that ignored both. So
14
+ ``"auto"`` on disk cannot be read as a choice — it is indistinguishable from a
15
+ file the user has never opened, and means exactly "nobody has decided".
16
+
17
+ The file is the user's. A malformed config is reported by ``doctor`` and
18
+ ``validate``, which exist for that; it does not entitle this module to rewrite
19
+ it, and an unreadable one degrades to "no preference" rather than stopping a
20
+ run — losing a config override is a smaller failure than refusing to write a
21
+ worklog at all.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ import os
28
+
29
+ from git_worklog import markers as wm
30
+ from git_worklog.language import AUTO
31
+
32
+
33
+ def load(worklog_dir: str) -> dict:
34
+ """The config object, or ``{}`` when absent, unreadable or malformed."""
35
+ path = wm.config_path(worklog_dir)
36
+ try:
37
+ with open(path, encoding="utf-8") as fh:
38
+ data = json.load(fh)
39
+ except (OSError, ValueError, UnicodeDecodeError):
40
+ return {}
41
+ return data if isinstance(data, dict) else {}
42
+
43
+
44
+ def _setting(config: dict, key: str) -> "str | None":
45
+ """A configured string, or None for absent/``auto``/wrong-typed."""
46
+ value = config.get(key)
47
+ if not isinstance(value, str):
48
+ return None
49
+ value = value.strip()
50
+ if not value or value.lower() == AUTO:
51
+ return None
52
+ return value
53
+
54
+
55
+ def language(config: dict) -> "str | None":
56
+ """The project's content-language override, if it set one (§6.2.7)."""
57
+ return _setting(config, "language")
58
+
59
+
60
+ def index_language(config: dict) -> "str | None":
61
+ """The project's fixed index language, if it set one (§6.2.12).
62
+
63
+ ``auto`` here does not mean "no index language" — it means the index keeps
64
+ whichever language it was first built in, which is recorded in the index
65
+ file itself rather than written back here.
66
+ """
67
+ return _setting(config, "index_language")
68
+
69
+
70
+ def timezone(config: dict) -> "str | None":
71
+ """The project's timezone override, if it set one."""
72
+ return _setting(config, "timezone")
73
+
74
+
75
+ def find(repo: str = ".", worklog_dir: "str | None" = None) -> dict:
76
+ """Load the config for a repository, honouring an explicit ``--dir``."""
77
+ if worklog_dir is None:
78
+ worklog_dir = os.path.join(repo, wm.WORKLOG_DIRNAME)
79
+ return load(worklog_dir)
@@ -0,0 +1,34 @@
1
+ {
2
+ "_comment": "Subagent model selection per host — SINGLE SOURCE OF TRUTH. The skill selects the provider it is currently running under and passes that provider's model_id to every spawned day / code-analysis subagent. Defaults prioritise cost efficiency for bounded, per-day repository analysis. Do NOT silently replace these models with more expensive ones. Resolved by git_worklog/providers.py (`git-worklog analyze prepare --host <key>`); mirrored (human-facing) in references/provider-models.md. It lives inside the package, not in a sibling config/ directory, because only git_worklog* is packaged — from outside, an installed CLI could not read it.",
3
+ "providers": {
4
+ "anthropic": {
5
+ "display_name": "Claude Haiku 4.5",
6
+ "model_id": "claude-haiku-4-5",
7
+ "escalation_model_id": "claude-sonnet-5-5"
8
+ },
9
+ "openai": {
10
+ "display_name": "GPT-6 Luna",
11
+ "model_id": "gpt-6-luna",
12
+ "reasoning_effort": "low",
13
+ "escalation_model_id": "gpt-6.1-sol",
14
+ "escalation_reasoning_effort": "medium"
15
+ },
16
+ "google": {
17
+ "display_name": "Gemini 3.8 Flash",
18
+ "model_id": "gemini-3.8-flash",
19
+ "escalation_model_id": "gemini-3.1-pro-preview"
20
+ }
21
+ },
22
+ "model_unavailable_policy": "halt-and-ask",
23
+ "escalation_policy": {
24
+ "automatic": false,
25
+ "suggest_when": [
26
+ "analysis_incomplete",
27
+ "confidence_unknown",
28
+ "conflicting_evidence",
29
+ "merge_result_unresolved",
30
+ "revert_final_state_unclear",
31
+ "required_code_context_unreadable"
32
+ ]
33
+ }
34
+ }
git_worklog/dates.py ADDED
@@ -0,0 +1,237 @@
1
+ """Resolve Git Worklog date parameters into a canonical per-day range.
2
+
3
+ This is the deterministic date contract (roadmap §6.1): natural language is
4
+ normalised into standard parameters by the model *before* anything here runs, so
5
+ this module never interprets free text. It only accepts the canonical parameters
6
+ (``date`` / ``days`` / ``from_`` / ``to``) plus the ``NNd`` / bare-date
7
+ shortcuts.
8
+
9
+ The logic lives in the package rather than in ``scripts/`` because only
10
+ ``git_worklog*`` is packaged: an installed CLI has no ``scripts/`` directory to
11
+ shell out to, so anything the CLI needs has to be importable, and the two front
12
+ ends must not drift apart.
13
+
14
+ The per-day boundary uses a half-open interval ``[local 00:00, next 00:00)`` so
15
+ that millisecond precision and DST transitions never create gaps or overlaps.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import os
21
+ import re
22
+ from datetime import date as date_cls, datetime, timedelta
23
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
24
+
25
+ # Default span cap. It exists to bound per-day subagent cost, so it governs
26
+ # worklog generation and backfill. Report mode only reads day files already on
27
+ # disk and spawns no subagents, so it overrides this with its own max_days.
28
+ MAX_DAYS = 30
29
+
30
+ _DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
31
+ _SHORTCUT_DAYS_RE = re.compile(r"^(\d+)d$")
32
+
33
+
34
+ class DateError(ValueError):
35
+ """A date parameter that cannot be honoured, carrying the wire code.
36
+
37
+ Mirrors :class:`git_worklog.language.LanguageError` and
38
+ :class:`git_worklog.analysis.AnalysisError`: the callers are thin shells (a
39
+ script and, later, a CLI subcommand) that both owe the user one JSON object
40
+ with a stable ``code``, so the code belongs on the exception rather than
41
+ being re-derived from the message at each call site.
42
+ """
43
+
44
+ def __init__(self, code: str, message: str, **extra):
45
+ self.code = code
46
+ self.message = message
47
+ self.extra = extra
48
+ super().__init__(message)
49
+
50
+ def as_error(self) -> dict:
51
+ """The error dict as it goes on the wire."""
52
+ return {"code": self.code, "message": self.message, **self.extra}
53
+
54
+
55
+ def detect_timezone(override: "str | None") -> "tuple[ZoneInfo, str, str]":
56
+ """Resolve the timezone following the documented priority order.
57
+
58
+ Returns ``(tzinfo, iana_name, source)``. ``source`` records where the value
59
+ came from so the dry-run summary can be transparent about it.
60
+ """
61
+ if override:
62
+ try:
63
+ return ZoneInfo(override), override, "explicit"
64
+ except ZoneInfoNotFoundError:
65
+ raise DateError("INVALID_TIMEZONE",
66
+ f"Unknown IANA timezone: {override!r}.")
67
+
68
+ tz_env = os.environ.get("TZ")
69
+ if tz_env:
70
+ try:
71
+ return ZoneInfo(tz_env), tz_env, "env:TZ"
72
+ except ZoneInfoNotFoundError:
73
+ pass
74
+
75
+ localtime = "/etc/localtime"
76
+ if os.path.islink(localtime):
77
+ target = os.path.realpath(localtime)
78
+ marker = "/zoneinfo/"
79
+ if marker in target:
80
+ name = target.split(marker, 1)[1]
81
+ try:
82
+ return ZoneInfo(name), name, "system:/etc/localtime"
83
+ except ZoneInfoNotFoundError:
84
+ pass
85
+
86
+ # Last resort: a fixed offset with no IANA name. The orchestrator should
87
+ # surface this and, per the contract, may ask the user to specify one.
88
+ local = datetime.now().astimezone().tzinfo
89
+ name = getattr(local, "key", None) or (local.tzname(None) if local else "UTC") or "UTC"
90
+ return local, name, "system-offset"
91
+
92
+
93
+ def _parse_iso_date(value: str, field: str) -> date_cls:
94
+ if not _DATE_RE.match(value):
95
+ raise DateError("INVALID_DATE",
96
+ f"{field} must be formatted as YYYY-MM-DD (got {value!r}).",
97
+ field=field, value=value)
98
+ try:
99
+ return date_cls.fromisoformat(value)
100
+ except ValueError:
101
+ raise DateError("INVALID_DATE",
102
+ f"{field} is not a real calendar date: {value!r}.",
103
+ field=field, value=value)
104
+
105
+
106
+ def day_window(d: "date_cls | str", tz: ZoneInfo) -> "tuple[datetime, datetime]":
107
+ """The half-open ``[local 00:00, next 00:00)`` window for one calendar day.
108
+
109
+ This is the day boundary the whole tool agrees on: what a day file covers,
110
+ what a Day Subagent is asked about, and what report mode counts commits in
111
+ all have to be the same window, or a commit falls through the crack between
112
+ two of them.
113
+ """
114
+ if isinstance(d, str):
115
+ d = datetime.fromisoformat(d).date()
116
+ start = datetime(d.year, d.month, d.day, tzinfo=tz)
117
+ # timedelta arithmetic operates on wall-clock time and keeps the tzinfo, so
118
+ # the offset is recomputed for the next local midnight even across DST.
119
+ return start, start + timedelta(days=1)
120
+
121
+
122
+ def _day_bounds(d: date_cls, tz: ZoneInfo) -> dict:
123
+ start, end = day_window(d, tz)
124
+ return {
125
+ "date": d.isoformat(),
126
+ "start": start.isoformat(),
127
+ "end": end.isoformat(),
128
+ }
129
+
130
+
131
+ def absorb_shortcut(token: "str | None", date: "str | None",
132
+ days: "int | None") -> "tuple[str | None, int | None]":
133
+ """Fold a positional shortcut token into the canonical ``(date, days)``.
134
+
135
+ Returns the pair unchanged when there is no token. A shortcut that collides
136
+ with the flag it would set is an error rather than an override: the user
137
+ said the same thing twice and the two may disagree.
138
+ """
139
+ if token is None:
140
+ return date, days
141
+ m = _SHORTCUT_DAYS_RE.match(token)
142
+ if m:
143
+ if days is not None:
144
+ raise DateError("ARG_CONFLICT",
145
+ f"Shortcut {token!r} conflicts with --days.")
146
+ return date, int(m.group(1))
147
+ if _DATE_RE.match(token):
148
+ if date is not None:
149
+ raise DateError("ARG_CONFLICT",
150
+ f"Shortcut {token!r} conflicts with --date.")
151
+ return token, days
152
+ raise DateError("UNKNOWN_SHORTCUT",
153
+ f"Unrecognised shortcut {token!r}; expected NNd or YYYY-MM-DD.",
154
+ value=token)
155
+
156
+
157
+ def resolve(date: "str | None" = None, days: "int | None" = None,
158
+ from_: "str | None" = None, to: "str | None" = None,
159
+ include_uncommitted: bool = False, timezone: "str | None" = None,
160
+ today: "str | None" = None, max_days: "int | None" = None) -> dict:
161
+ """Resolve the canonical parameters into per-day boundaries.
162
+
163
+ Raises :class:`DateError` on any validation failure.
164
+ """
165
+ tz, tz_name, tz_source = detect_timezone(timezone)
166
+ # Explicit None check: `or MAX_DAYS` would treat max_days=0 as unset and
167
+ # silently restore the default instead of rejecting it.
168
+ if max_days is None:
169
+ max_days = MAX_DAYS
170
+ if max_days < 1:
171
+ raise DateError("BAD_MAX_DAYS",
172
+ f"max-days must be at least 1 (got {max_days}).",
173
+ max_days=max_days)
174
+
175
+ today_date = _parse_iso_date(today, "today") if today else datetime.now(tz).date()
176
+
177
+ has_date = date is not None
178
+ has_days = days is not None
179
+ has_from = from_ is not None
180
+ has_to = to is not None
181
+ has_range = has_from or has_to
182
+
183
+ # Mutual exclusivity: date / days / (from+to) are three separate modes.
184
+ active = sum([has_date, has_days, has_range])
185
+ if active == 0:
186
+ raise DateError("NO_DATE_SPEC",
187
+ "No date parameter given. Provide date, days, or from+to.")
188
+ if active > 1:
189
+ raise DateError("ARG_CONFLICT",
190
+ "date, days and from/to are mutually exclusive; supply exactly one mode.",
191
+ provided={"date": date, "days": days,
192
+ "from": from_, "to": to})
193
+
194
+ common = {
195
+ "ok": True,
196
+ "timezone": tz_name,
197
+ "timezone_source": tz_source,
198
+ "include_uncommitted": bool(include_uncommitted),
199
+ "today": today_date.isoformat(),
200
+ "max_days": max_days,
201
+ "errors": [],
202
+ }
203
+
204
+ if has_date:
205
+ d = _parse_iso_date(date, "date")
206
+ return {**common, "mode": "date", "days_count": 1,
207
+ "dates": [_day_bounds(d, tz)]}
208
+
209
+ if has_days:
210
+ if not isinstance(days, int) or days < 1 or days > max_days:
211
+ raise DateError("DAYS_OUT_OF_RANGE",
212
+ f"days must be an integer between 1 and {max_days} (got {days}).",
213
+ requested_days=days, max_days=max_days)
214
+ start_day = today_date - timedelta(days=days - 1)
215
+ dates = [_day_bounds(start_day + timedelta(days=i), tz) for i in range(days)]
216
+ return {**common, "mode": "days", "days_count": days, "dates": dates}
217
+
218
+ # range mode
219
+ if has_from and not has_to:
220
+ raise DateError("FROM_WITHOUT_TO", "from must be accompanied by to.")
221
+ if has_to and not has_from:
222
+ raise DateError("TO_WITHOUT_FROM",
223
+ "to must not be used on its own; provide from as well.")
224
+
225
+ d_from = _parse_iso_date(from_, "from")
226
+ d_to = _parse_iso_date(to, "to")
227
+ if d_from > d_to:
228
+ raise DateError("FROM_AFTER_TO",
229
+ f"from ({d_from}) must not be later than to ({d_to}).")
230
+ total = (d_to - d_from).days + 1
231
+ if total > max_days:
232
+ raise DateError("TOO_MANY_DAYS",
233
+ f"The requested range spans {total} days, exceeding the "
234
+ f"{max_days}-day limit. Narrow the range to {max_days} days or fewer.",
235
+ requested_days=total, max_days=max_days)
236
+ dates = [_day_bounds(d_from + timedelta(days=i), tz) for i in range(total)]
237
+ return {**common, "mode": "range", "days_count": total, "dates": dates}