leadger 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.
@@ -0,0 +1,385 @@
1
+ """Storage: the YAML-backed orchestrator for tasks, days and metrics.
2
+
3
+ Implements the product's core rules:
4
+ - lazy snapshot + rollover at config.day_start (regras 1-2)
5
+ - the "% do dia entregue" hero metric, including over-delivery and
6
+ the goal=0 bonus-day case (regra 3)
7
+ - external-edit detection via mtime, last-write-wins (regra 7)
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+ from datetime import date, datetime
14
+ from pathlib import Path
15
+ from typing import Optional
16
+
17
+ from ruamel.yaml.comments import CommentedMap, CommentedSeq
18
+
19
+ from .ids import generate_task_id
20
+ from .recur import next_occurrence
21
+ from .tasks import Task, TaskStatus
22
+ from .time_utils import leadger_today, now_in_zone, parse_day_start
23
+ from .yaml_io import read_yaml, write_yaml_atomic
24
+
25
+ DEFAULT_TIMEZONE = "America/Sao_Paulo"
26
+ DEFAULT_DAY_START = "05:00"
27
+
28
+ # sentinel distinguishing "leave unchanged" from "remove" in update_task(recur=...)
29
+ _UNSET: object = object()
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class DayMetrics:
34
+ """The hero metric for a single day."""
35
+
36
+ date: date
37
+ goal: int
38
+ snapshot_done: int
39
+ extras_done: int
40
+ extras_total: int
41
+ done: int
42
+ pct: Optional[float]
43
+ bonus_day: bool
44
+ over_delivery: bool
45
+
46
+
47
+ class Storage:
48
+ """Loads/saves a single leadger.yaml and applies the business rules."""
49
+
50
+ def __init__(self, path: Path | str) -> None:
51
+ self.path = Path(path).expanduser()
52
+ self._mtime: Optional[float] = None
53
+ self.data: CommentedMap = CommentedMap()
54
+ self.reload()
55
+
56
+ # ------------------------------------------------------------------
57
+ # IO
58
+ # ------------------------------------------------------------------
59
+ def reload(self) -> None:
60
+ """Force a re-read from disk. Raises CorruptedYAMLError if invalid."""
61
+ self.data = read_yaml(self.path)
62
+ self._mtime = self.path.stat().st_mtime if self.path.exists() else None
63
+
64
+ def _sync(self) -> None:
65
+ """Reload if the file changed on disk since our last read (regra 7)."""
66
+ if not self.path.exists():
67
+ return
68
+ mtime = self.path.stat().st_mtime
69
+ if self._mtime is None or mtime != self._mtime:
70
+ self.reload()
71
+
72
+ def save(self) -> None:
73
+ write_yaml_atomic(self.path, self.data)
74
+ self._mtime = self.path.stat().st_mtime
75
+
76
+ # ------------------------------------------------------------------
77
+ # Config / time
78
+ # ------------------------------------------------------------------
79
+ @property
80
+ def config(self) -> CommentedMap:
81
+ return self.data.setdefault("config", CommentedMap())
82
+
83
+ @property
84
+ def timezone_name(self) -> str:
85
+ return str(self.config.get("timezone", DEFAULT_TIMEZONE))
86
+
87
+ @property
88
+ def day_start(self): # -> datetime.time
89
+ return parse_day_start(str(self.config.get("day_start", DEFAULT_DAY_START)))
90
+
91
+ def now(self) -> datetime:
92
+ return now_in_zone(self.timezone_name)
93
+
94
+ def today(self, now: Optional[datetime] = None) -> date:
95
+ now = now or self.now()
96
+ return leadger_today(now, self.day_start)
97
+
98
+ # ------------------------------------------------------------------
99
+ # Tasks
100
+ # ------------------------------------------------------------------
101
+ @property
102
+ def _tasks_seq(self) -> CommentedSeq:
103
+ return self.data.setdefault("tasks", CommentedSeq())
104
+
105
+ @property
106
+ def _days_seq(self) -> CommentedSeq:
107
+ return self.data.setdefault("days", CommentedSeq())
108
+
109
+ def all_tasks(self) -> list[Task]:
110
+ self._sync()
111
+ return [Task(raw) for raw in self._tasks_seq]
112
+
113
+ def get_task(self, task_id: str) -> Task:
114
+ self._sync()
115
+ for raw in self._tasks_seq:
116
+ if raw["id"] == task_id:
117
+ return Task(raw)
118
+ raise KeyError(task_id)
119
+
120
+ def _existing_ids(self) -> set[str]:
121
+ return {raw["id"] for raw in self._tasks_seq}
122
+
123
+ def add_task(
124
+ self,
125
+ title: str,
126
+ *,
127
+ target: Optional[date] = None,
128
+ tags: Optional[list[str]] = None,
129
+ recur: Optional[str] = None,
130
+ now: Optional[datetime] = None,
131
+ ) -> Task:
132
+ self._sync()
133
+ now = now or self.now()
134
+ today = self.today(now)
135
+ target = target or today
136
+
137
+ self.ensure_day(now=now)
138
+
139
+ task_id = generate_task_id(self._existing_ids())
140
+ task = Task.new(
141
+ task_id=task_id, title=title, target=target, created=now, tags=tags, recur=recur
142
+ )
143
+ self._tasks_seq.append(task.raw)
144
+
145
+ if target == today:
146
+ self._get_day_record(today)["extras"].append(task_id)
147
+
148
+ self.save()
149
+ return task
150
+
151
+ def set_task_status(
152
+ self, task_id: str, status: TaskStatus, now: Optional[datetime] = None
153
+ ) -> Task:
154
+ self._sync()
155
+ now = now or self.now()
156
+ self.ensure_day(now=now)
157
+ task = self.get_task(task_id)
158
+ changed = task.set_status(status, now)
159
+ if changed and status is TaskStatus.DONE:
160
+ self._spawn_next_if_recurring(task, now)
161
+ self.save()
162
+ return task
163
+
164
+ def update_task(
165
+ self,
166
+ task_id: str,
167
+ *,
168
+ title: Optional[str] = None,
169
+ target: Optional[date] = None,
170
+ tags: Optional[list[str]] = None,
171
+ status: Optional[TaskStatus] = None,
172
+ recur: object = _UNSET,
173
+ now: Optional[datetime] = None,
174
+ ) -> Task:
175
+ """Edit title/target/tags/status/recur of an existing task.
176
+
177
+ Raises KeyError (unknown id) or InvalidTransitionError (bad status
178
+ edge). Retargeting onto today adds the task to today's `extras` —
179
+ it counts toward delivery if done, but never inflates the goal.
180
+ `recur` uses a sentinel: omitted = unchanged; None = end the series.
181
+ """
182
+ self._sync()
183
+ now = now or self.now()
184
+ today = self.today(now)
185
+ self.ensure_day(now=now)
186
+ task = self.get_task(task_id)
187
+
188
+ if title is not None:
189
+ task.title = title
190
+ if tags is not None:
191
+ task.tags = tags
192
+ if recur is not _UNSET:
193
+ task.recur = recur # type: ignore[assignment]
194
+ if target is not None and target != task.target:
195
+ task.target = target
196
+ if target == today:
197
+ day_record = self._get_day_record(today)
198
+ snapshot_ids = list(day_record.get("snapshot", []))
199
+ extras_ids = list(day_record.get("extras", []))
200
+ if task_id not in snapshot_ids and task_id not in extras_ids:
201
+ day_record["extras"].append(task_id)
202
+ if status is not None:
203
+ changed = task.set_status(status, now)
204
+ if changed and status is TaskStatus.DONE:
205
+ self._spawn_next_if_recurring(task, now)
206
+
207
+ self.save()
208
+ return task
209
+
210
+ def _spawn_next_if_recurring(
211
+ self, task: Task, now: datetime
212
+ ) -> Optional[Task]:
213
+ """Completing a recurring occurrence creates the next one (series rule).
214
+
215
+ Cancelling or deleting the open occurrence ends the series. If an
216
+ open future occurrence of the same series already exists (e.g. the
217
+ task was reopened and completed again), nothing is created.
218
+ """
219
+ recur = task.recur
220
+ if not recur:
221
+ return None
222
+ today = self.today(now)
223
+ for raw in self._tasks_seq:
224
+ other = Task(raw)
225
+ if (
226
+ other.id != task.id
227
+ and other.recur == recur
228
+ and other.title == task.title
229
+ and other.status in (TaskStatus.TODO, TaskStatus.PAUSED)
230
+ and other.target > today
231
+ ):
232
+ return None
233
+ target = next_occurrence(recur, max(task.target, today))
234
+ next_task = Task.new(
235
+ task_id=generate_task_id(self._existing_ids()),
236
+ title=task.title,
237
+ target=target,
238
+ created=now,
239
+ tags=task.tags,
240
+ recur=recur,
241
+ )
242
+ self._tasks_seq.append(next_task.raw)
243
+ return next_task
244
+
245
+ # ------------------------------------------------------------------
246
+ # Days / snapshot lazy / rollover
247
+ # ------------------------------------------------------------------
248
+ def _get_day_record(self, day: date) -> Optional[CommentedMap]:
249
+ for raw in self._days_seq:
250
+ if _as_date(raw["date"]) == day:
251
+ return raw
252
+ return None
253
+
254
+ def ensure_day(
255
+ self, day: Optional[date] = None, now: Optional[datetime] = None
256
+ ) -> CommentedMap:
257
+ """Lazily run the day-turnover (rollover + snapshot) for `day`.
258
+
259
+ No-op if `day` already has an entry in `days`. Otherwise:
260
+ 1. rollover stale todo/paused tasks (target < day) onto `day`,
261
+ bumping their migration count;
262
+ 2. snapshot the (now up to date) todo/paused tasks targeting
263
+ `day` as `goal`/`snapshot`, with empty `extras`.
264
+ """
265
+ self._sync()
266
+ now = now or self.now()
267
+ target_day = day or self.today(now)
268
+
269
+ existing = self._get_day_record(target_day)
270
+ if existing is not None:
271
+ return existing
272
+
273
+ for raw in self._tasks_seq:
274
+ task = Task(raw)
275
+ if task.status in (TaskStatus.TODO, TaskStatus.PAUSED) and task.target < target_day:
276
+ task.migrate(target_day)
277
+
278
+ snapshot_ids = [
279
+ task.id
280
+ for task in (Task(raw) for raw in self._tasks_seq)
281
+ if task.target == target_day and task.status in (TaskStatus.TODO, TaskStatus.PAUSED)
282
+ ]
283
+
284
+ day_record = CommentedMap()
285
+ day_record["date"] = target_day
286
+ day_record["goal"] = len(snapshot_ids)
287
+ day_record["snapshot"] = CommentedSeq(snapshot_ids)
288
+ day_record["extras"] = CommentedSeq()
289
+ self._days_seq.append(day_record)
290
+
291
+ self.save()
292
+ return day_record
293
+
294
+ def delete_task(self, task_id: str, now: Optional[datetime] = None) -> None:
295
+ """Remove a task permanently. Raises KeyError if unknown.
296
+
297
+ The id is also removed from every day's `extras`, but stays in
298
+ `snapshot` (the frozen photo): deleting a planned task does NOT
299
+ reduce that day's goal, same anti-gaming rule as cancelling.
300
+ """
301
+ self._sync()
302
+ now = now or self.now()
303
+ self.ensure_day(now=now)
304
+ seq = self._tasks_seq
305
+ for i, raw in enumerate(seq):
306
+ if raw["id"] == task_id:
307
+ del seq[i]
308
+ break
309
+ else:
310
+ raise KeyError(task_id)
311
+ for day_record in self._days_seq:
312
+ extras = day_record.get("extras")
313
+ if extras is not None and task_id in list(extras):
314
+ extras.remove(task_id)
315
+ self.save()
316
+
317
+ def recorded_days(self) -> list[date]:
318
+ """Sorted dates of every entry in `days`."""
319
+ self._sync()
320
+ return sorted(_as_date(raw["date"]) for raw in self._days_seq)
321
+
322
+ # ------------------------------------------------------------------
323
+ # Metrics
324
+ # ------------------------------------------------------------------
325
+ def get_day_metrics(
326
+ self, day: Optional[date] = None, now: Optional[datetime] = None
327
+ ) -> DayMetrics:
328
+ self._sync()
329
+ now = now or self.now()
330
+ today = self.today(now)
331
+ target_day = day or today
332
+
333
+ if target_day == today:
334
+ day_record = self.ensure_day(target_day, now=now)
335
+ else:
336
+ day_record = self._get_day_record(target_day)
337
+
338
+ if day_record is None:
339
+ day_record = CommentedMap(
340
+ date=target_day, goal=0, snapshot=CommentedSeq(), extras=CommentedSeq()
341
+ )
342
+
343
+ goal = int(day_record.get("goal", 0))
344
+ tasks_by_id = {task.id: task for task in self.all_tasks()}
345
+
346
+ snapshot_done = sum(
347
+ 1
348
+ for task_id in day_record.get("snapshot", [])
349
+ if tasks_by_id.get(task_id) and tasks_by_id[task_id].status is TaskStatus.DONE
350
+ )
351
+ # ids that don't resolve (task deleted / file edited by hand) don't count
352
+ extras_ids = [
353
+ task_id for task_id in day_record.get("extras", []) if task_id in tasks_by_id
354
+ ]
355
+ extras_done = sum(
356
+ 1 for task_id in extras_ids if tasks_by_id[task_id].status is TaskStatus.DONE
357
+ )
358
+ done = snapshot_done + extras_done
359
+
360
+ if goal == 0:
361
+ pct = None if done > 0 else 0.0
362
+ bonus_day = done > 0
363
+ else:
364
+ pct = done / goal * 100
365
+ bonus_day = False
366
+
367
+ return DayMetrics(
368
+ date=target_day,
369
+ goal=goal,
370
+ snapshot_done=snapshot_done,
371
+ extras_done=extras_done,
372
+ extras_total=len(extras_ids),
373
+ done=done,
374
+ pct=pct,
375
+ bonus_day=bonus_day,
376
+ over_delivery=pct is not None and pct > 100,
377
+ )
378
+
379
+
380
+ def _as_date(value: object) -> date:
381
+ if isinstance(value, datetime):
382
+ return value.date()
383
+ if isinstance(value, date):
384
+ return value
385
+ return date.fromisoformat(str(value))
leadger/core/tasks.py ADDED
@@ -0,0 +1,161 @@
1
+ """Task model: a typed view over a YAML task entry, plus its state machine.
2
+
3
+ `Task` wraps the ruamel CommentedMap loaded from leadger.yaml and mutates
4
+ it in place, so any comments the user attached to that task survive.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from datetime import date, datetime
10
+ from enum import Enum
11
+ from typing import Optional
12
+
13
+ from ruamel.yaml.comments import CommentedMap, CommentedSeq
14
+
15
+ from .yaml_io import to_yaml_timestamp
16
+
17
+
18
+ class TaskStatus(str, Enum):
19
+ TODO = "todo"
20
+ DONE = "done"
21
+ PAUSED = "paused"
22
+ CANCELLED = "cancelled"
23
+
24
+
25
+ class InvalidTransitionError(ValueError):
26
+ """Raised when a task status transition is not allowed."""
27
+
28
+
29
+ # todo <-> paused, todo|paused -> done|cancelled, done|cancelled -> todo
30
+ _VALID_TRANSITIONS: dict[TaskStatus, frozenset[TaskStatus]] = {
31
+ TaskStatus.TODO: frozenset({TaskStatus.PAUSED, TaskStatus.DONE, TaskStatus.CANCELLED}),
32
+ TaskStatus.PAUSED: frozenset({TaskStatus.TODO, TaskStatus.DONE, TaskStatus.CANCELLED}),
33
+ TaskStatus.DONE: frozenset({TaskStatus.TODO}),
34
+ TaskStatus.CANCELLED: frozenset({TaskStatus.TODO}),
35
+ }
36
+
37
+
38
+ def _as_date(value: object) -> date:
39
+ if isinstance(value, datetime):
40
+ return value.date()
41
+ if isinstance(value, date):
42
+ return value
43
+ return date.fromisoformat(str(value))
44
+
45
+
46
+ class Task:
47
+ """Typed view over a task's CommentedMap."""
48
+
49
+ def __init__(self, raw: CommentedMap) -> None:
50
+ self._raw = raw
51
+
52
+ @property
53
+ def raw(self) -> CommentedMap:
54
+ return self._raw
55
+
56
+ @property
57
+ def id(self) -> str:
58
+ return self._raw["id"]
59
+
60
+ @property
61
+ def title(self) -> str:
62
+ return self._raw["title"]
63
+
64
+ @title.setter
65
+ def title(self, value: str) -> None:
66
+ self._raw["title"] = value
67
+
68
+ @property
69
+ def status(self) -> TaskStatus:
70
+ return TaskStatus(self._raw["status"])
71
+
72
+ @property
73
+ def target(self) -> date:
74
+ return _as_date(self._raw["target"])
75
+
76
+ @target.setter
77
+ def target(self, value: date) -> None:
78
+ self._raw["target"] = value
79
+
80
+ @property
81
+ def created(self) -> datetime:
82
+ return self._raw["created"]
83
+
84
+ @property
85
+ def completed(self) -> Optional[datetime]:
86
+ return self._raw.get("completed")
87
+
88
+ @property
89
+ def tags(self) -> list[str]:
90
+ return list(self._raw.get("tags") or [])
91
+
92
+ @tags.setter
93
+ def tags(self, value: list[str]) -> None:
94
+ self._raw["tags"] = CommentedSeq(value)
95
+
96
+ @property
97
+ def migrations(self) -> int:
98
+ return int(self._raw.get("migrations", 0))
99
+
100
+ @property
101
+ def recur(self) -> Optional[str]:
102
+ """Recurrence spec ("dia" | "seg".."dom") or None. See core/recur.py."""
103
+ value = self._raw.get("recur")
104
+ return str(value) if value else None
105
+
106
+ @recur.setter
107
+ def recur(self, value: Optional[str]) -> None:
108
+ if value:
109
+ self._raw["recur"] = value
110
+ else:
111
+ self._raw.pop("recur", None)
112
+
113
+ def migrate(self, new_target: date) -> None:
114
+ """Roll this task over to `new_target`, bumping its migration count."""
115
+ self._raw["target"] = new_target
116
+ self._raw["migrations"] = self.migrations + 1
117
+
118
+ def set_status(self, new_status: TaskStatus, now: datetime) -> bool:
119
+ """Apply a status transition. Returns False if already in that state.
120
+
121
+ Raises InvalidTransitionError for disallowed transitions (e.g.
122
+ done -> paused). Sets/clears `completed` for the done <-> todo edge.
123
+ """
124
+ current = self.status
125
+ if new_status == current:
126
+ return False
127
+ allowed = _VALID_TRANSITIONS.get(current, frozenset())
128
+ if new_status not in allowed:
129
+ raise InvalidTransitionError(
130
+ f"Transicao invalida: {current.value} -> {new_status.value}"
131
+ )
132
+ self._raw["status"] = new_status.value
133
+ if new_status is TaskStatus.DONE:
134
+ self._raw["completed"] = to_yaml_timestamp(now)
135
+ elif current is TaskStatus.DONE and new_status is TaskStatus.TODO:
136
+ self._raw["completed"] = None
137
+ return True
138
+
139
+ @classmethod
140
+ def new(
141
+ cls,
142
+ *,
143
+ task_id: str,
144
+ title: str,
145
+ target: date,
146
+ created: datetime,
147
+ tags: Optional[list[str]] = None,
148
+ recur: Optional[str] = None,
149
+ ) -> "Task":
150
+ raw = CommentedMap()
151
+ raw["id"] = task_id
152
+ raw["title"] = title
153
+ raw["status"] = TaskStatus.TODO.value
154
+ raw["target"] = target
155
+ raw["created"] = to_yaml_timestamp(created)
156
+ raw["completed"] = None
157
+ raw["tags"] = CommentedSeq(tags or [])
158
+ raw["migrations"] = 0
159
+ if recur: # only write the key when there is recurrence (lean YAML)
160
+ raw["recur"] = recur
161
+ return cls(raw)
@@ -0,0 +1,43 @@
1
+ """Timezone-aware 'leadger day' calculations.
2
+
3
+ The 'leadger day' is the date used for `target`/`days[].date`. It only
4
+ rolls over at `config.day_start`: at 03:00 with day_start=05:00, it's
5
+ still 'yesterday'.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from datetime import date, datetime, time, timedelta, timezone
11
+ from zoneinfo import ZoneInfo
12
+
13
+
14
+ def now_in_zone(tz_name: str) -> datetime:
15
+ """Current time in `tz_name`, with a fixed UTC-offset tzinfo.
16
+
17
+ A fixed offset (vs. a ZoneInfo) is what ruamel.yaml's round-trip
18
+ representer needs to dump a clean `+HH:MM`/`-HH:MM` suffix.
19
+ """
20
+ return _to_fixed_offset(datetime.now(ZoneInfo(tz_name)))
21
+
22
+
23
+ def _to_fixed_offset(dt: datetime) -> datetime:
24
+ offset = dt.utcoffset()
25
+ if offset is None:
26
+ return dt.replace(microsecond=0)
27
+ total_minutes = int(offset.total_seconds() // 60)
28
+ sign = "+" if total_minutes >= 0 else "-"
29
+ hours, minutes = divmod(abs(total_minutes), 60)
30
+ fixed = timezone(offset, f"{sign}{hours:02d}:{minutes:02d}")
31
+ return dt.replace(microsecond=0, tzinfo=fixed)
32
+
33
+
34
+ def leadger_today(now: datetime, day_start: time) -> date:
35
+ """The 'leadger day' for `now`, given the configured day_start cutoff."""
36
+ if now.time() < day_start:
37
+ return now.date() - timedelta(days=1)
38
+ return now.date()
39
+
40
+
41
+ def parse_day_start(value: str) -> time:
42
+ hour_str, minute_str = value.split(":")
43
+ return time(hour=int(hour_str), minute=int(minute_str))
@@ -0,0 +1,97 @@
1
+ """Low-level YAML round-trip IO: atomic writes, backups, corruption handling.
2
+
3
+ Uses ruamel.yaml in round-trip mode so comments and key order in the
4
+ user's leadger.yaml survive every read/write cycle.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from datetime import datetime, timezone
11
+ from pathlib import Path
12
+
13
+ from ruamel.yaml import YAML
14
+ from ruamel.yaml.comments import CommentedMap, CommentedSeq
15
+ from ruamel.yaml.error import YAMLError
16
+ from ruamel.yaml.timestamp import TimeStamp
17
+
18
+ SCHEMA_VERSION = 1
19
+
20
+ yaml = YAML(typ="rt")
21
+ yaml.indent(mapping=2, sequence=4, offset=2)
22
+ yaml.preserve_quotes = True
23
+
24
+
25
+ class CorruptedYAMLError(Exception):
26
+ """Raised when the data file exists but cannot be parsed safely."""
27
+
28
+
29
+ def to_yaml_timestamp(dt: datetime) -> TimeStamp:
30
+ """Wrap a tz-aware datetime so it dumps as `YYYY-MM-DDTHH:MM:SS+HH:MM`.
31
+
32
+ ruamel uses `tzinfo.tzname()` as the dumped suffix, so the tzinfo is
33
+ normalized to a fixed offset named `+HH:MM`/`-HH:MM` — an unnamed
34
+ `timezone(timedelta(...))` would dump as `UTC-03:00`, which ruamel
35
+ cannot parse back.
36
+ """
37
+ offset = dt.utcoffset()
38
+ if offset is not None:
39
+ total_minutes = int(offset.total_seconds() // 60)
40
+ sign = "+" if total_minutes >= 0 else "-"
41
+ hours, minutes = divmod(abs(total_minutes), 60)
42
+ dt = dt.replace(tzinfo=timezone(offset, f"{sign}{hours:02d}:{minutes:02d}"))
43
+ ts = TimeStamp(
44
+ dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second, dt.microsecond, dt.tzinfo
45
+ )
46
+ ts._yaml["t"] = True
47
+ return ts
48
+
49
+
50
+ def default_document() -> CommentedMap:
51
+ """A fresh leadger.yaml document with a minimal commented config."""
52
+ doc = CommentedMap()
53
+ doc["version"] = SCHEMA_VERSION
54
+ config = CommentedMap()
55
+ config["day_start"] = "05:00"
56
+ config.yaml_add_eol_comment("hora em que o dia vira", "day_start")
57
+ config["timezone"] = "America/Sao_Paulo"
58
+ doc["config"] = config
59
+ doc["tasks"] = CommentedSeq()
60
+ doc["days"] = CommentedSeq()
61
+ return doc
62
+
63
+
64
+ def read_yaml(path: Path) -> CommentedMap:
65
+ """Load `path`, or return a fresh default document if it doesn't exist yet.
66
+
67
+ Raises CorruptedYAMLError if the file exists but isn't valid YAML, or its
68
+ root isn't a mapping. Callers must not write back in that case.
69
+ """
70
+ if not path.exists():
71
+ return default_document()
72
+ try:
73
+ with path.open("r", encoding="utf-8") as fh:
74
+ data = yaml.load(fh)
75
+ except YAMLError as exc:
76
+ raise CorruptedYAMLError(f"{path} nao e um YAML valido: {exc}") from exc
77
+ if not isinstance(data, CommentedMap):
78
+ raise CorruptedYAMLError(f"{path} nao tem o formato esperado (mapa no nivel raiz)")
79
+ data.setdefault("version", SCHEMA_VERSION)
80
+ data.setdefault("config", CommentedMap())
81
+ data.setdefault("tasks", CommentedSeq())
82
+ data.setdefault("days", CommentedSeq())
83
+ return data
84
+
85
+
86
+ def write_yaml_atomic(path: Path, data: CommentedMap) -> None:
87
+ """Write `data` to `path` atomically, keeping `<path>.bak` of the prior version."""
88
+ path.parent.mkdir(parents=True, exist_ok=True)
89
+ tmp_path = path.with_name(path.name + ".tmp")
90
+ bak_path = path.with_name(path.name + ".bak")
91
+
92
+ with tmp_path.open("w", encoding="utf-8") as fh:
93
+ yaml.dump(data, fh)
94
+
95
+ if path.exists():
96
+ os.replace(path, bak_path)
97
+ os.replace(tmp_path, path)