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.
- git_worklog/__init__.py +58 -0
- git_worklog/__main__.py +10 -0
- git_worklog/analysis/__init__.py +48 -0
- git_worklog/analysis/commits.py +118 -0
- git_worklog/analysis/coverage.py +149 -0
- git_worklog/analysis/history.py +339 -0
- git_worklog/analysis/manifest.py +432 -0
- git_worklog/analysis/reconcile.py +211 -0
- git_worklog/analysis/refs.py +178 -0
- git_worklog/analysis/results.py +871 -0
- git_worklog/analysis/worktree.py +114 -0
- git_worklog/cli/__init__.py +414 -0
- git_worklog/cli/analyze.py +340 -0
- git_worklog/cli/apply.py +61 -0
- git_worklog/cli/coverage.py +62 -0
- git_worklog/cli/doctor.py +348 -0
- git_worklog/cli/migrate.py +43 -0
- git_worklog/cli/models.py +112 -0
- git_worklog/cli/preview.py +124 -0
- git_worklog/cli/refs.py +55 -0
- git_worklog/cli/reindex.py +72 -0
- git_worklog/cli/report.py +227 -0
- git_worklog/cli/validate.py +252 -0
- git_worklog/cli/version.py +47 -0
- git_worklog/cli/view.py +218 -0
- git_worklog/config.py +79 -0
- git_worklog/data/provider_models.json +34 -0
- git_worklog/dates.py +237 -0
- git_worklog/language.py +295 -0
- git_worklog/markers.py +765 -0
- git_worklog/mdhtml.py +266 -0
- git_worklog/migrate.py +305 -0
- git_worklog/paths.py +85 -0
- git_worklog/preview.py +776 -0
- git_worklog/providers.py +337 -0
- git_worklog/viewer.py +1198 -0
- git_worklog/viewserver.py +120 -0
- git_worklog/writer.py +420 -0
- git_worklog-1.6.0.dist-info/METADATA +352 -0
- git_worklog-1.6.0.dist-info/RECORD +44 -0
- git_worklog-1.6.0.dist-info/WHEEL +5 -0
- git_worklog-1.6.0.dist-info/entry_points.txt +2 -0
- git_worklog-1.6.0.dist-info/licenses/LICENSE +21 -0
- git_worklog-1.6.0.dist-info/top_level.txt +1 -0
git_worklog/cli/view.py
ADDED
|
@@ -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}
|