nightshift-cli 0.1.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.
nightshift/config.py ADDED
@@ -0,0 +1,447 @@
1
+ """Load and validate ``~/.nightshift/config.yaml``.
2
+
3
+ Every failure raised from here is meant to be read by a human at a terminal, so
4
+ messages name the offending field and say what a valid value looks like.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import re
11
+ from dataclasses import dataclass, field
12
+ from datetime import datetime, time, timedelta
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ import yaml
17
+
18
+ KNOWN_PROVIDERS = ("claude_code", "codex", "copilot")
19
+
20
+ DEFAULT_STATE_DIR = Path("~/.nightshift")
21
+ DEFAULT_DIGEST_DIR = Path("~/nightshift-reports")
22
+ DEFAULT_WINDOWS = ("00:00-06:00",)
23
+ DEFAULT_IDLE_MINUTES = 60
24
+ DEFAULT_TIMEOUT_S = 600
25
+ DEFAULT_MAX_RUNS_PER_DAY = 6
26
+ DEFAULT_MAX_RUNS_PER_WEEK = 30
27
+
28
+ _TASK_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]*$")
29
+ _WINDOW_RE = re.compile(r"^\s*(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})\s*$")
30
+
31
+
32
+ class ConfigError(Exception):
33
+ """A human-readable configuration problem."""
34
+
35
+
36
+ def expand(p: str | Path) -> Path:
37
+ """Expand ``~`` and ``$VARS``, returning an absolute path."""
38
+ return Path(os.path.expandvars(os.path.expanduser(str(p)))).absolute()
39
+
40
+
41
+ def state_dir() -> Path:
42
+ """Where ledger/queue/lock/config live. Override with ``NIGHTSHIFT_HOME``."""
43
+ return expand(os.environ.get("NIGHTSHIFT_HOME", DEFAULT_STATE_DIR))
44
+
45
+
46
+ def config_path() -> Path:
47
+ return state_dir() / "config.yaml"
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class Budget:
52
+ max_runs_per_day: int = DEFAULT_MAX_RUNS_PER_DAY
53
+ max_runs_per_week: int = DEFAULT_MAX_RUNS_PER_WEEK
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class Provider:
58
+ name: str
59
+ enabled: bool = False
60
+ budget: Budget = field(default_factory=Budget)
61
+ #: Where the CLI lives, when it isn't simply on PATH under its usual name.
62
+ #: ``None`` means "look up the adapter's default name on PATH". Set this for
63
+ #: an install that PATH can't see — Codex bundled inside ChatGPT.app, say.
64
+ binary: str | None = None
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class Project:
69
+ name: str
70
+ path: Path
71
+ tasks: tuple[str, ...]
72
+ #: Pin this project to one provider. ``None`` means "whichever enabled
73
+ #: provider can run right now". A pin is hard: if the named provider is out
74
+ #: of budget, still in use, or not installed, the project waits rather than
75
+ #: being reviewed by someone else.
76
+ provider: str | None = None
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class Window:
81
+ """A local-time window. ``start > end`` means it crosses midnight."""
82
+
83
+ start: time
84
+ end: time
85
+ raw: str
86
+
87
+ @property
88
+ def crosses_midnight(self) -> bool:
89
+ return self.start > self.end
90
+
91
+ def contains(self, t: time) -> bool:
92
+ if self.crosses_midnight:
93
+ # e.g. 22:00-06:00 — inside if at/after start OR before end.
94
+ return t >= self.start or t < self.end
95
+ return self.start <= t < self.end
96
+
97
+
98
+ @dataclass(frozen=True)
99
+ class Schedule:
100
+ windows: tuple[Window, ...]
101
+ idle_minutes: int = DEFAULT_IDLE_MINUTES
102
+
103
+ def is_open(self, now: datetime) -> bool:
104
+ return any(w.contains(now.time()) for w in self.windows)
105
+
106
+ def next_open(self, now: datetime) -> datetime | None:
107
+ """The next minute at which a window opens, searching 48h ahead."""
108
+ if self.is_open(now):
109
+ return now
110
+ probe = now.replace(second=0, microsecond=0)
111
+ for _ in range(48 * 60):
112
+ probe += timedelta(minutes=1)
113
+ if self.is_open(probe):
114
+ return probe
115
+ return None
116
+
117
+
118
+ @dataclass(frozen=True)
119
+ class Config:
120
+ providers: dict[str, Provider]
121
+ projects: tuple[Project, ...]
122
+ schedule: Schedule
123
+ digest_dir: Path
124
+ timeout_s: int = DEFAULT_TIMEOUT_S
125
+ source: Path | None = None
126
+
127
+ def enabled_providers(self) -> list[Provider]:
128
+ return [p for p in self.providers.values() if p.enabled]
129
+
130
+ def provider(self, name: str) -> Provider:
131
+ try:
132
+ return self.providers[name]
133
+ except KeyError:
134
+ raise ConfigError(f"unknown provider {name!r}") from None
135
+
136
+ def pairs(self) -> list[tuple[str, str]]:
137
+ """Every ``(project, task)`` combination, in config order."""
138
+ return [(p.name, t) for p in self.projects for t in p.tasks]
139
+
140
+
141
+ def _require_mapping(value: Any, where: str) -> dict:
142
+ if value is None:
143
+ return {}
144
+ if not isinstance(value, dict):
145
+ raise ConfigError(f"{where}: expected a mapping, got {_typename(value)}")
146
+ return value
147
+
148
+
149
+ def _typename(value: Any) -> str:
150
+ return type(value).__name__
151
+
152
+
153
+ def _parse_positive_int(value: Any, where: str) -> int:
154
+ if isinstance(value, bool) or not isinstance(value, int):
155
+ raise ConfigError(f"{where}: expected a whole number, got {value!r}")
156
+ if value <= 0:
157
+ raise ConfigError(f"{where}: must be greater than 0, got {value}")
158
+ return value
159
+
160
+
161
+ def parse_window(raw: Any, where: str) -> Window:
162
+ if not isinstance(raw, str):
163
+ raise ConfigError(f'{where}: expected a string like "09:00-18:00", got {raw!r}')
164
+ m = _WINDOW_RE.match(raw)
165
+ if not m:
166
+ raise ConfigError(f'{where}: expected "HH:MM-HH:MM", got {raw!r}')
167
+ sh, sm, eh, em = (int(g) for g in m.groups())
168
+ for hh, mm, side in ((sh, sm, "start"), (eh, em, "end")):
169
+ if hh > 23 or mm > 59:
170
+ raise ConfigError(
171
+ f"{where}: {side} time {hh:02d}:{mm:02d} is not a real clock time "
172
+ f'(in {raw!r})'
173
+ )
174
+ start, end = time(sh, sm), time(eh, em)
175
+ if start == end:
176
+ raise ConfigError(
177
+ f"{where}: {raw!r} starts and ends at the same time — a window must "
178
+ f'have a duration (use "00:00-23:59" for all day)'
179
+ )
180
+ return Window(start=start, end=end, raw=raw.strip())
181
+
182
+
183
+ def _parse_budget(raw: Any, where: str) -> Budget:
184
+ data = _require_mapping(raw, where)
185
+ unknown = set(data) - {"max_runs_per_day", "max_runs_per_week"}
186
+ if unknown:
187
+ raise ConfigError(
188
+ f"{where}: unknown field(s) {sorted(unknown)} — "
189
+ f"expected max_runs_per_day, max_runs_per_week"
190
+ )
191
+ day = data.get("max_runs_per_day", DEFAULT_MAX_RUNS_PER_DAY)
192
+ week = data.get("max_runs_per_week", DEFAULT_MAX_RUNS_PER_WEEK)
193
+ budget = Budget(
194
+ max_runs_per_day=_parse_positive_int(day, f"{where}.max_runs_per_day"),
195
+ max_runs_per_week=_parse_positive_int(week, f"{where}.max_runs_per_week"),
196
+ )
197
+ if budget.max_runs_per_week < budget.max_runs_per_day:
198
+ raise ConfigError(
199
+ f"{where}: max_runs_per_week ({budget.max_runs_per_week}) is below "
200
+ f"max_runs_per_day ({budget.max_runs_per_day}) — the weekly cap would "
201
+ f"make the daily cap unreachable"
202
+ )
203
+ return budget
204
+
205
+
206
+ def _parse_providers(raw: Any) -> dict[str, Provider]:
207
+ data = _require_mapping(raw, "providers")
208
+ unknown = set(data) - set(KNOWN_PROVIDERS)
209
+ if unknown:
210
+ raise ConfigError(
211
+ f"providers: unknown provider(s) {sorted(unknown)} — "
212
+ f"known providers are {', '.join(KNOWN_PROVIDERS)}"
213
+ )
214
+ providers: dict[str, Provider] = {}
215
+ for name in KNOWN_PROVIDERS:
216
+ entry = _require_mapping(data.get(name), f"providers.{name}")
217
+ unknown_fields = set(entry) - {"enabled", "budget", "binary"}
218
+ if unknown_fields:
219
+ raise ConfigError(
220
+ f"providers.{name}: unknown field(s) {sorted(unknown_fields)} — "
221
+ f"expected enabled, budget, binary"
222
+ )
223
+ enabled = entry.get("enabled", False)
224
+ if not isinstance(enabled, bool):
225
+ raise ConfigError(
226
+ f"providers.{name}.enabled: expected true or false, got {enabled!r}"
227
+ )
228
+ providers[name] = Provider(
229
+ name=name,
230
+ enabled=enabled,
231
+ budget=_parse_budget(entry.get("budget"), f"providers.{name}.budget"),
232
+ binary=_parse_binary(entry.get("binary"), f"providers.{name}.binary"),
233
+ )
234
+ return providers
235
+
236
+
237
+ def _parse_binary(value: Any, where: str) -> str | None:
238
+ """A command name or a path to one. Existence is the adapter's problem.
239
+
240
+ Deliberately not checked for existence here: config parsing runs on a
241
+ developer's laptop and in CI, and a path that is missing on one is routinely
242
+ present on the other. An adapter that can't find its binary already reports
243
+ that as unavailable, with a reason — which is a skip, not a crash. Rejecting
244
+ it here would turn one absent CLI into a config file that won't load at all.
245
+ """
246
+ if value is None:
247
+ return None
248
+ if not isinstance(value, str) or not value.strip():
249
+ raise ConfigError(
250
+ f"{where}: expected a command name or a path to one, got {value!r}"
251
+ )
252
+ return value.strip()
253
+
254
+
255
+ def _parse_projects(raw: Any) -> tuple[Project, ...]:
256
+ if raw is None:
257
+ raise ConfigError(
258
+ "projects: no projects configured — add at least one, or run "
259
+ "`nightshift init`"
260
+ )
261
+ if not isinstance(raw, list):
262
+ raise ConfigError(f"projects: expected a list, got {_typename(raw)}")
263
+ if not raw:
264
+ raise ConfigError(
265
+ "projects: the list is empty — nightshift has nothing to review"
266
+ )
267
+
268
+ projects: list[Project] = []
269
+ seen: set[str] = set()
270
+ for i, entry in enumerate(raw):
271
+ where = f"projects[{i}]"
272
+ data = _require_mapping(entry, where)
273
+ unknown = set(data) - {"name", "path", "tasks", "provider"}
274
+ if unknown:
275
+ raise ConfigError(
276
+ f"{where}: unknown field(s) {sorted(unknown)} — "
277
+ f"expected name, path, tasks, provider"
278
+ )
279
+
280
+ name = data.get("name")
281
+ if not isinstance(name, str) or not name.strip():
282
+ raise ConfigError(f"{where}.name: expected a non-empty string, got {name!r}")
283
+ name = name.strip()
284
+ if name in seen:
285
+ raise ConfigError(
286
+ f"{where}.name: duplicate project name {name!r} — names must be unique"
287
+ )
288
+ seen.add(name)
289
+
290
+ raw_path = data.get("path")
291
+ if not isinstance(raw_path, str) or not raw_path.strip():
292
+ raise ConfigError(
293
+ f"{where}.path: expected a filesystem path, got {raw_path!r}"
294
+ )
295
+
296
+ tasks_raw = data.get("tasks")
297
+ if tasks_raw is None:
298
+ raise ConfigError(
299
+ f"{where}.tasks: no tasks listed — e.g. [code_review, deps_audit]"
300
+ )
301
+ if not isinstance(tasks_raw, list) or not tasks_raw:
302
+ raise ConfigError(
303
+ f"{where}.tasks: expected a non-empty list, got {tasks_raw!r}"
304
+ )
305
+ tasks: list[str] = []
306
+ for task in tasks_raw:
307
+ if not isinstance(task, str) or not _TASK_RE.match(task):
308
+ raise ConfigError(
309
+ f"{where}.tasks: {task!r} is not a valid task name — use the "
310
+ f"stem of a prompt file, e.g. code_review"
311
+ )
312
+ if task not in tasks:
313
+ tasks.append(task)
314
+
315
+ pinned = data.get("provider")
316
+ if pinned is not None:
317
+ if not isinstance(pinned, str) or not pinned.strip():
318
+ raise ConfigError(
319
+ f"{where}.provider: expected a provider name, got {pinned!r}"
320
+ )
321
+ pinned = pinned.strip()
322
+ if pinned not in KNOWN_PROVIDERS:
323
+ raise ConfigError(
324
+ f"{where}.provider: unknown provider {pinned!r} — known "
325
+ f"providers are {', '.join(KNOWN_PROVIDERS)}"
326
+ )
327
+
328
+ projects.append(
329
+ Project(
330
+ name=name,
331
+ path=expand(raw_path),
332
+ tasks=tuple(tasks),
333
+ provider=pinned,
334
+ )
335
+ )
336
+ return tuple(projects)
337
+
338
+
339
+ def _parse_schedule(raw: Any) -> Schedule:
340
+ data = _require_mapping(raw, "schedule")
341
+ unknown = set(data) - {"windows", "idle_minutes"}
342
+ if unknown:
343
+ raise ConfigError(
344
+ f"schedule: unknown field(s) {sorted(unknown)} — "
345
+ f"expected windows, idle_minutes"
346
+ )
347
+ windows_raw = data.get("windows", list(DEFAULT_WINDOWS))
348
+ if not isinstance(windows_raw, list) or not windows_raw:
349
+ raise ConfigError(
350
+ f'schedule.windows: expected a non-empty list like ["09:00-18:00"], '
351
+ f"got {windows_raw!r}"
352
+ )
353
+ windows = tuple(
354
+ parse_window(w, f"schedule.windows[{i}]") for i, w in enumerate(windows_raw)
355
+ )
356
+ idle = data.get("idle_minutes", DEFAULT_IDLE_MINUTES)
357
+ if isinstance(idle, bool) or not isinstance(idle, int) or idle < 0:
358
+ raise ConfigError(
359
+ f"schedule.idle_minutes: expected 0 or a positive whole number, got {idle!r}"
360
+ )
361
+ return Schedule(windows=windows, idle_minutes=idle)
362
+
363
+
364
+ def parse(data: Any, source: Path | None = None) -> Config:
365
+ """Validate a already-deserialised config mapping."""
366
+ root = _require_mapping(data, "config")
367
+ unknown = set(root) - {"providers", "projects", "schedule", "digest", "run"}
368
+ if unknown:
369
+ raise ConfigError(
370
+ f"config: unknown top-level key(s) {sorted(unknown)} — "
371
+ f"expected providers, projects, schedule, digest, run"
372
+ )
373
+
374
+ providers = _parse_providers(root.get("providers"))
375
+ if not any(p.enabled for p in providers.values()):
376
+ raise ConfigError(
377
+ "providers: every provider is disabled — enable at least one, e.g.\n"
378
+ " providers:\n"
379
+ " claude_code:\n"
380
+ " enabled: true"
381
+ )
382
+
383
+ projects = _parse_projects(root.get("projects"))
384
+ # Cross-field, so it can't live in _parse_projects: a pin at a disabled
385
+ # provider is a contradiction inside one file — the project could never run,
386
+ # on any machine, at any hour. Refused here for the same reason the check
387
+ # above refuses a config with every provider disabled. Contrast
388
+ # `providers.*.binary`, which is left to the adapter precisely because
389
+ # whether a path exists depends on the machine rather than the file.
390
+ for project in projects:
391
+ if project.provider and not providers[project.provider].enabled:
392
+ raise ConfigError(
393
+ f"project {project.name!r} is pinned to provider "
394
+ f"{project.provider!r}, which is disabled — so it could never be "
395
+ f"reviewed. Either set providers.{project.provider}.enabled: true, "
396
+ f"or drop the `provider:` line to let any enabled provider take it."
397
+ )
398
+
399
+ digest = _require_mapping(root.get("digest"), "digest")
400
+ unknown_digest = set(digest) - {"dir"}
401
+ if unknown_digest:
402
+ raise ConfigError(
403
+ f"digest: unknown field(s) {sorted(unknown_digest)} — expected dir"
404
+ )
405
+ digest_dir = digest.get("dir", str(DEFAULT_DIGEST_DIR))
406
+ if not isinstance(digest_dir, str) or not digest_dir.strip():
407
+ raise ConfigError(f"digest.dir: expected a filesystem path, got {digest_dir!r}")
408
+
409
+ run = _require_mapping(root.get("run"), "run")
410
+ unknown_run = set(run) - {"timeout_s"}
411
+ if unknown_run:
412
+ raise ConfigError(
413
+ f"run: unknown field(s) {sorted(unknown_run)} — expected timeout_s"
414
+ )
415
+ timeout_s = _parse_positive_int(
416
+ run.get("timeout_s", DEFAULT_TIMEOUT_S), "run.timeout_s"
417
+ )
418
+
419
+ return Config(
420
+ providers=providers,
421
+ projects=projects,
422
+ schedule=_parse_schedule(root.get("schedule")),
423
+ digest_dir=expand(digest_dir),
424
+ timeout_s=timeout_s,
425
+ source=source,
426
+ )
427
+
428
+
429
+ def load(path: Path | None = None) -> Config:
430
+ """Read and validate the config file."""
431
+ path = path or config_path()
432
+ if not path.exists():
433
+ raise ConfigError(
434
+ f"no config at {path} — run `nightshift init` to create one"
435
+ )
436
+ try:
437
+ raw = yaml.safe_load(path.read_text(encoding="utf-8"))
438
+ except yaml.YAMLError as exc:
439
+ raise ConfigError(f"{path} is not valid YAML:\n{exc}") from exc
440
+ except OSError as exc:
441
+ raise ConfigError(f"cannot read {path}: {exc}") from exc
442
+ if raw is None:
443
+ raise ConfigError(f"{path} is empty — run `nightshift init` to populate it")
444
+ try:
445
+ return parse(raw, source=path)
446
+ except ConfigError as exc:
447
+ raise ConfigError(f"{path}: {exc}") from None
nightshift/cron.py ADDED
@@ -0,0 +1,96 @@
1
+ """Crontab entries for the two scheduled commands.
2
+
3
+ nightshift has no daemon: cron calls ``run`` hourly and the command decides for
4
+ itself whether to act.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import shutil
10
+ import subprocess
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ MARKER = "# nightshift (managed — edit via `nightshift init`)"
15
+ END_MARKER = "# end nightshift"
16
+
17
+ HOURLY_RUN = "0 * * * *"
18
+ DAILY_DIGEST = "30 7 * * *"
19
+
20
+
21
+ def executable() -> str:
22
+ """Absolute path to the installed ``nightshift`` entry point."""
23
+ found = shutil.which("nightshift")
24
+ if found:
25
+ return found
26
+ # Running from a source checkout or a venv that isn't on cron's PATH.
27
+ return f"{Path(sys.executable)} -m nightshift"
28
+
29
+
30
+ def entries(binary: str | None = None) -> list[str]:
31
+ exe = binary or executable()
32
+ return [
33
+ f"{HOURLY_RUN} {exe} run >> /tmp/nightshift-cron.log 2>&1",
34
+ f"{DAILY_DIGEST} {exe} digest >> /tmp/nightshift-cron.log 2>&1",
35
+ ]
36
+
37
+
38
+ def block(binary: str | None = None) -> str:
39
+ lines = [MARKER, *entries(binary), END_MARKER]
40
+ return "\n".join(lines) + "\n"
41
+
42
+
43
+ def read_crontab() -> str:
44
+ """Current crontab, or empty string if there isn't one."""
45
+ try:
46
+ proc = subprocess.run(
47
+ ["crontab", "-l"], capture_output=True, text=True, check=False
48
+ )
49
+ except (OSError, subprocess.SubprocessError):
50
+ return ""
51
+ if proc.returncode != 0:
52
+ return ""
53
+ return proc.stdout
54
+
55
+
56
+ def strip_block(existing: str) -> str:
57
+ """Remove a previously installed nightshift block."""
58
+ out: list[str] = []
59
+ skipping = False
60
+ for line in existing.splitlines():
61
+ if line.strip() == MARKER:
62
+ skipping = True
63
+ continue
64
+ if skipping:
65
+ if line.strip() == END_MARKER:
66
+ skipping = False
67
+ continue
68
+ out.append(line)
69
+ return "\n".join(out).strip("\n")
70
+
71
+
72
+ def merged(existing: str, binary: str | None = None) -> str:
73
+ """The crontab that would result from installing our block."""
74
+ base = strip_block(existing)
75
+ parts = [p for p in (base, block(binary).rstrip("\n")) if p]
76
+ return "\n".join(parts) + "\n"
77
+
78
+
79
+ def install(binary: str | None = None) -> None:
80
+ """Replace the user's crontab with one containing our block.
81
+
82
+ Raises ``RuntimeError`` if ``crontab`` isn't usable — the caller prints the
83
+ lines so the user can paste them in by hand.
84
+ """
85
+ if shutil.which("crontab") is None:
86
+ raise RuntimeError("`crontab` is not on PATH")
87
+ new = merged(read_crontab(), binary)
88
+ try:
89
+ proc = subprocess.run(
90
+ ["crontab", "-"], input=new, text=True, capture_output=True, check=False
91
+ )
92
+ except (OSError, subprocess.SubprocessError) as exc:
93
+ raise RuntimeError(f"could not run `crontab -`: {exc}") from exc
94
+ if proc.returncode != 0:
95
+ detail = (proc.stderr or proc.stdout or "").strip() or f"exit {proc.returncode}"
96
+ raise RuntimeError(f"`crontab -` failed: {detail}")