"""User configuration.
Mirrors the shape used by the other tools in this tree: JSON under
`~/.config/<name>/config.json`, colours as ANSI slot numbers written as strings,
and a handful of UI toggles. Colour slots rather than hex, because the terminal
decides what slot 8 looks like.
This is user-editable configuration, deliberately separate from the AUR build
state in `aur.py`: one is yours to edit by hand, the other is the tool's memory.
"""
from __future__ import annotations
import json
import os
from dataclasses import asdict, dataclass, field
from pathlib import Path
APP = "updater"
def config_dir() -> Path:
base = os.environ.get("XDG_CONFIG_HOME") or (Path.home() / ".config")
return Path(base) / APP
def config_path() -> Path:
return config_dir() / "config.json"
@dataclass
class Config:
#: packages never updated by this tool, keyed by name
ignored: list[str] = field(default_factory=list)
#: advisory ids you have read and decided to live with. Acknowledge is not
#: "fixed": most advisories on a rolling distribution have no fix available,
#: so the useful action is to stop being told about the ones you have seen.
acked: list[str] = field(default_factory=list)
#: epoch of the newest news item you have read. Arch uses the news feed to
#: announce upgrades that will not complete on their own, so this is the
#: difference between "nothing new" and "you have not looked".
news_seen: float = 0.0
#: ANSI slot numbers, as strings, matching theme.py's roles
color_accent: str = "10" # bright green
color_muted: str = "8" # bright black
color_ok: str = "2" # green
color_warn: str = "3" # yellow
color_alert: str = "1" # red
color_old: str = "1"
color_new: str = "10"
color_ver: str = "6" # cyan
#: UI
cursor_icon: str = "▸"
hide_hints: bool = False
show_vcs: bool = True
#: how many rows a section shows before collapsing by default
expand_threshold: int = 8
#: set when the file has been created or edited, so we can tell a first run
@classmethod
def load(cls, path: Path | None = None) -> Config:
path = path or config_path()
try:
raw = json.loads(path.read_text())
except (OSError, ValueError):
return cls()
# A valid JSON document is not necessarily an object. `[]` in a
# user-editable config crashed the program at startup, before any
# command ran.
if not isinstance(raw, dict):
return cls()
known = cls.__dataclass_fields__
return cls(**{k: v for k, v in raw.items() if k in known})
def save(self, path: Path | None = None) -> None:
"""Write atomically: a half-written config would lose your ignore list."""
path = path or config_path()
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".tmp")
tmp.write_text(json.dumps(asdict(self), indent=2, sort_keys=True) + "\n")
tmp.replace(path)
# -- ignores -----------------------------------------------------------
def is_ignored(self, package: str) -> bool:
return package in self.ignored
def ignore(self, package: str) -> None:
if package not in self.ignored:
self.ignored.append(package)
self.ignored.sort()
def unignore(self, package: str) -> None:
if package in self.ignored:
self.ignored.remove(package)
# -- advisory acknowledgements ----------------------------------------
def is_acked(self, advisory: str) -> bool:
return advisory in self.acked
def ack(self, advisory: str) -> None:
if advisory not in self.acked:
self.acked.append(advisory)
self.acked.sort()
def unack(self, advisory: str) -> None:
if advisory in self.acked:
self.acked.remove(advisory)
def toggle_acked(self, advisory: str) -> bool:
"""Flip one advisory. Returns True if it is now acknowledged."""
if self.is_acked(advisory):
self.unack(advisory)
return False
self.ack(advisory)
return True
def clear_acks(self) -> int:
"""Forget every acknowledgement. Returns how many there were."""
count = len(self.acked)
self.acked = []
return count
def toggle_ignored(self, package: str) -> bool:
"""Flip one package. Returns True if it is now ignored."""
if self.is_ignored(package):
self.unignore(package)
return False
self.ignore(package)
return True