dpm-tool 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.
- app.py +64 -0
- dpm/__init__.py +0 -0
- dpm/_constants.py +50 -0
- dpm/_text.py +82 -0
- dpm/_types.py +170 -0
- dpm/config.py +90 -0
- dpm/db.py +396 -0
- dpm/delta.py +504 -0
- dpm/delta_db.py +262 -0
- dpm/delta_schema.py +87 -0
- dpm/eiopa.py +152 -0
- dpm/excel.py +192 -0
- dpm/parser.py +532 -0
- dpm/toc.py +223 -0
- dpm/ui/__init__.py +0 -0
- dpm/ui/_confirm_cancel_modal.tcss +24 -0
- dpm/ui/_override_db_modal.tcss +24 -0
- dpm/ui/_utils.py +152 -0
- dpm/ui/apply_screen.py +184 -0
- dpm/ui/apply_screen.tcss +54 -0
- dpm/ui/delta_screen.py +220 -0
- dpm/ui/delta_screen.tcss +80 -0
- dpm/ui/explore_delta_screen.py +494 -0
- dpm/ui/explore_delta_screen.tcss +105 -0
- dpm/ui/explore_screen.py +385 -0
- dpm/ui/explore_screen.tcss +131 -0
- dpm/ui/ingest_screen.py +273 -0
- dpm/ui/ingest_screen.tcss +73 -0
- dpm/ui/log_handler.py +47 -0
- dpm/ui/main_screen.py +72 -0
- dpm/ui/main_screen.tcss +55 -0
- dpm/ui/settings_screen.py +84 -0
- dpm/ui/settings_screen.tcss +55 -0
- dpm/workflows.py +361 -0
- dpm/xbrl.py +286 -0
- dpm_tool-0.1.0.dist-info/METADATA +121 -0
- dpm_tool-0.1.0.dist-info/RECORD +41 -0
- dpm_tool-0.1.0.dist-info/WHEEL +5 -0
- dpm_tool-0.1.0.dist-info/entry_points.txt +2 -0
- dpm_tool-0.1.0.dist-info/licenses/LICENSE +21 -0
- dpm_tool-0.1.0.dist-info/top_level.txt +2 -0
app.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from textual import work
|
|
6
|
+
from textual.app import App
|
|
7
|
+
|
|
8
|
+
from dpm.config import load_theme, save_theme
|
|
9
|
+
from dpm.ui._utils import ConfirmCancelModal
|
|
10
|
+
from dpm.ui.main_screen import MainScreen
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DpmToolApp(App):
|
|
14
|
+
TITLE = "EIOPA DPM Toolkit"
|
|
15
|
+
BINDINGS = [
|
|
16
|
+
("q", "quit", "Quit"),
|
|
17
|
+
("escape", "go_back", "Back"),
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
def on_mount(self) -> None:
|
|
21
|
+
# Restore the persisted theme (ignore an unknown/removed theme name), then
|
|
22
|
+
# persist any later change (e.g. via the command palette) as it happens.
|
|
23
|
+
saved = load_theme()
|
|
24
|
+
if saved and saved in self.available_themes:
|
|
25
|
+
self.theme = saved
|
|
26
|
+
self.watch(self, "theme", self._persist_theme, init=False)
|
|
27
|
+
|
|
28
|
+
# Push rather than override get_default_screen: Textual only loads a
|
|
29
|
+
# screen's CSS_PATH when the screen is pushed, not for the default screen.
|
|
30
|
+
self.push_screen(MainScreen())
|
|
31
|
+
|
|
32
|
+
def _persist_theme(self, theme: str) -> None:
|
|
33
|
+
save_theme(theme)
|
|
34
|
+
|
|
35
|
+
def action_go_back(self) -> None:
|
|
36
|
+
self._go_back()
|
|
37
|
+
|
|
38
|
+
@work(exclusive=True, name="go-back")
|
|
39
|
+
async def _go_back(self) -> None:
|
|
40
|
+
"""Go back a screen, confirming first if the current screen has a running task.
|
|
41
|
+
|
|
42
|
+
Shared by the Escape binding and every screen's '← Back' button. ``workers`` is
|
|
43
|
+
app-global, so we match on ``w.node`` to count only the current screen's tasks
|
|
44
|
+
(this back worker itself runs on the app).
|
|
45
|
+
"""
|
|
46
|
+
if isinstance(self.screen, MainScreen):
|
|
47
|
+
return # main menu — nothing to go back to
|
|
48
|
+
screen = self.screen
|
|
49
|
+
running = [w for w in self.workers if w.is_running and w.node is screen]
|
|
50
|
+
if running:
|
|
51
|
+
if not await self.push_screen_wait(ConfirmCancelModal()):
|
|
52
|
+
return
|
|
53
|
+
for w in running:
|
|
54
|
+
w.cancel()
|
|
55
|
+
self.pop_screen()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def main() -> None:
|
|
59
|
+
logging.basicConfig(level=logging.WARNING)
|
|
60
|
+
DpmToolApp().run()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
if __name__ == "__main__":
|
|
64
|
+
main()
|
dpm/__init__.py
ADDED
|
File without changes
|
dpm/_constants.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
# ── On-disk layout ──────────────────────────────────────────────────────────
|
|
7
|
+
# All DuckDB artifacts live under a single root: ingested version databases in
|
|
8
|
+
# ``db/versions`` and computed delta databases in ``db/delta``.
|
|
9
|
+
DB_ROOT = Path("db")
|
|
10
|
+
VERSIONS_DIR = DB_ROOT / "versions"
|
|
11
|
+
DELTA_DIR = DB_ROOT / "delta"
|
|
12
|
+
|
|
13
|
+
# Downloaded source workbooks are cached here; app config lives at CONFIG_PATH.
|
|
14
|
+
DOWNLOADS_DIR = Path("data/downloads")
|
|
15
|
+
CONFIG_PATH = Path("dpm-tool.config.json")
|
|
16
|
+
|
|
17
|
+
ROW_RE = re.compile(r"\b[A-Z]{0,3}R\d{3,6}\b", re.I)
|
|
18
|
+
COL_RE = re.compile(r"\b[A-Z]{0,3}C\d{3,6}\b", re.I)
|
|
19
|
+
CODE_RE = re.compile(r"\b[A-Z]{1,3}\.(?:[0-9]{2}\.){1,6}[0-9]{2}\b", re.I)
|
|
20
|
+
QNAME_RE = re.compile(
|
|
21
|
+
r"(^\{[^}]+\}.+)|(^[A-Za-z_][\w.-]*:[A-Za-z_][\w.-]*$)"
|
|
22
|
+
r"|(^[A-Za-z_][\w.-]*\.[A-Za-z_][\w.-]*(?:\.[A-Za-z_][\w.-]*)+$)"
|
|
23
|
+
)
|
|
24
|
+
QNAME_TOKEN_RE = re.compile(r"\b(?:s2md_met|[A-Za-z_][\w.-]*):[A-Za-z_][\w.-]*\b")
|
|
25
|
+
METRIC_LABEL_RE = re.compile(r"Metric:\s*(.*?)(?:\)\s*(?:\[|$)|$)")
|
|
26
|
+
# Dimension declaration, e.g. "s2c_dim:BL (Line of business [general])"
|
|
27
|
+
DIM_DECL_RE = re.compile(r"\bs2c_dim:([A-Za-z0-9]+)\b", re.I)
|
|
28
|
+
# Dimension member, e.g. "s2c_LB:x91 (Neither unit-linked ...)". Excludes the
|
|
29
|
+
# `dim` domain so declarations are not mistaken for members.
|
|
30
|
+
MEMBER_RE = re.compile(r"\bs2c_(?!dim:)([A-Za-z0-9]+):(x\d+)\b", re.I)
|
|
31
|
+
# Trailing parenthetical label, e.g. "... (Line of business [general])"
|
|
32
|
+
PAREN_LABEL_RE = re.compile(r"\((.*)\)\s*$")
|
|
33
|
+
GENERIC_TOC_CODES = frozenset({"T99", "T.99", "T.99.99", "TOC", "TABLE OF CONTENTS"})
|
|
34
|
+
|
|
35
|
+
METRIC_COLS = [
|
|
36
|
+
"perimeter",
|
|
37
|
+
"template_code",
|
|
38
|
+
"subtemplate_code",
|
|
39
|
+
"row_code",
|
|
40
|
+
"column_code",
|
|
41
|
+
"qname",
|
|
42
|
+
"metric_label",
|
|
43
|
+
"row_label",
|
|
44
|
+
"column_label",
|
|
45
|
+
]
|
|
46
|
+
# Default single value column: a subtemplate whose only column is this is a plain
|
|
47
|
+
# row list; any other column code makes it a row×column matrix (see change_type).
|
|
48
|
+
DEFAULT_COLUMN_CODE = "C0010"
|
|
49
|
+
KEY_COLS = ["perimeter", "template_code", "subtemplate_code", "row_code", "column_code"]
|
|
50
|
+
IDENTITY_COLS = ["template_code", "subtemplate_code", "row_code", "column_code"]
|
dpm/_text.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections.abc import Iterable
|
|
5
|
+
|
|
6
|
+
from dpm._constants import (
|
|
7
|
+
CODE_RE,
|
|
8
|
+
COL_RE,
|
|
9
|
+
DIM_DECL_RE,
|
|
10
|
+
MEMBER_RE,
|
|
11
|
+
METRIC_LABEL_RE,
|
|
12
|
+
PAREN_LABEL_RE,
|
|
13
|
+
QNAME_RE,
|
|
14
|
+
QNAME_TOKEN_RE,
|
|
15
|
+
ROW_RE,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def norm(value: object) -> str:
|
|
20
|
+
if value is None:
|
|
21
|
+
return ""
|
|
22
|
+
return re.sub(r"\s+", " ", str(value).replace("\n", " ").replace("\r", " ").strip())
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def first_match(pattern: re.Pattern[str], text: str) -> str:
|
|
26
|
+
match = pattern.search(text or "")
|
|
27
|
+
return match.group(0).upper() if match else ""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def extract_qname(text: str) -> str:
|
|
31
|
+
match = QNAME_TOKEN_RE.search(norm(text))
|
|
32
|
+
return match.group(0) if match else ""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def extract_metric_label(text: str) -> str:
|
|
36
|
+
match = METRIC_LABEL_RE.search(norm(text))
|
|
37
|
+
return match.group(1).strip() if match else ""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def is_qname(text: str) -> bool:
|
|
41
|
+
text = norm(text)
|
|
42
|
+
return bool(extract_qname(text) or QNAME_RE.search(text))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def extract_paren_label(text: str) -> str:
|
|
46
|
+
match = PAREN_LABEL_RE.search(norm(text))
|
|
47
|
+
return match.group(1).strip() if match else ""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def extract_dimension_token(text: str) -> str:
|
|
51
|
+
"""Return the full 's2c_dim:XX' declaration token, or ''."""
|
|
52
|
+
match = DIM_DECL_RE.search(norm(text))
|
|
53
|
+
return match.group(0) if match else ""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def is_dimension_decl(text: str) -> bool:
|
|
57
|
+
return bool(DIM_DECL_RE.search(norm(text)))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def extract_member_token(text: str) -> str:
|
|
61
|
+
"""Return the full 's2c_XX:xN' member token, or ''."""
|
|
62
|
+
match = MEMBER_RE.search(norm(text))
|
|
63
|
+
return match.group(0) if match else ""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def is_member(text: str) -> bool:
|
|
67
|
+
return bool(MEMBER_RE.search(norm(text)))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def nearest_text(values: Iterable[str]) -> str:
|
|
71
|
+
for value in values:
|
|
72
|
+
text = norm(value)
|
|
73
|
+
if (
|
|
74
|
+
text
|
|
75
|
+
and not ROW_RE.fullmatch(text)
|
|
76
|
+
and not COL_RE.fullmatch(text)
|
|
77
|
+
and not CODE_RE.fullmatch(text)
|
|
78
|
+
and text.lower() != "metrics"
|
|
79
|
+
and not is_qname(text)
|
|
80
|
+
):
|
|
81
|
+
return text[:500]
|
|
82
|
+
return ""
|
dpm/_types.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import TypedDict
|
|
6
|
+
|
|
7
|
+
import polars as pl
|
|
8
|
+
from openpyxl import load_workbook
|
|
9
|
+
from openpyxl.workbook.workbook import Workbook
|
|
10
|
+
|
|
11
|
+
from dpm._text import norm
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class TocEntry:
|
|
16
|
+
perimeter: str
|
|
17
|
+
template_code: str
|
|
18
|
+
subtemplate_code: str
|
|
19
|
+
sheet_name: str | None = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class DpmDataset:
|
|
24
|
+
metrics: pl.DataFrame
|
|
25
|
+
entries: list[TocEntry]
|
|
26
|
+
# Full metric catalogue (metric_code, metric_label) for the metrics delta.
|
|
27
|
+
metric_catalog: pl.DataFrame = field(default_factory=pl.DataFrame)
|
|
28
|
+
# Flat dimensions × members frame for the dimensions delta.
|
|
29
|
+
dimension_members: pl.DataFrame = field(default_factory=pl.DataFrame)
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def templates(self) -> set[tuple[str, str]]:
|
|
33
|
+
return {(e.perimeter, e.template_code) for e in self.entries}
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def subtemplates(self) -> set[tuple[str, str, str]]:
|
|
37
|
+
return {
|
|
38
|
+
(e.perimeter, e.template_code, e.subtemplate_code) for e in self.entries
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class DeltaResult:
|
|
44
|
+
"""The three-section delta between two DPM versions.
|
|
45
|
+
|
|
46
|
+
Each frame follows the canonical snake_case schema in ``dpm.delta_schema``:
|
|
47
|
+
``structure`` the per-perimeter fact/structural delta (``DELTA_STRUCTURE_COLS``),
|
|
48
|
+
``metrics`` the metric-catalogue delta (``DELTA_METRIC_COLS``), and
|
|
49
|
+
``dimensions`` the dimensions/members delta (``DELTA_DIMENSION_COLS``).
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
structure: pl.DataFrame
|
|
53
|
+
metrics: pl.DataFrame
|
|
54
|
+
dimensions: pl.DataFrame
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class TemplateRow(TypedDict):
|
|
58
|
+
template_code: str
|
|
59
|
+
template_label: str
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class SubtemplateRow(TypedDict):
|
|
63
|
+
subtemplate_code: str
|
|
64
|
+
template_code: str
|
|
65
|
+
subtemplate_label: str
|
|
66
|
+
subtemplate_type: str
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class PerimeterRow(TypedDict):
|
|
70
|
+
perimeter_code: str
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class PerimeterTemplateRow(TypedDict):
|
|
74
|
+
perimeter_code: str
|
|
75
|
+
template_code: str
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class MetricRow(TypedDict):
|
|
79
|
+
metric_code: str
|
|
80
|
+
metric_label: str
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class FactRow(TypedDict):
|
|
84
|
+
subtemplate_code: str
|
|
85
|
+
row_code: str
|
|
86
|
+
column_code: str
|
|
87
|
+
row_label: str
|
|
88
|
+
column_label: str
|
|
89
|
+
metric_code: str
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class DimensionRow(TypedDict):
|
|
93
|
+
dimension_code: str
|
|
94
|
+
dimension_label: str
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class DimensionMemberRow(TypedDict):
|
|
98
|
+
member_code: str
|
|
99
|
+
dimension_code: str
|
|
100
|
+
member_label: str
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class FactDimensionRow(TypedDict):
|
|
104
|
+
subtemplate_code: str
|
|
105
|
+
row_code: str
|
|
106
|
+
column_code: str
|
|
107
|
+
dimension_code: str
|
|
108
|
+
member_code: str
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass
|
|
112
|
+
class WorkbookCache:
|
|
113
|
+
path: Path
|
|
114
|
+
wb: Workbook
|
|
115
|
+
sheet_names: list[str]
|
|
116
|
+
_matrix_cache: dict[str, list[list[str]]] = field(
|
|
117
|
+
default_factory=dict, repr=False, init=False
|
|
118
|
+
)
|
|
119
|
+
_crossed_cache: dict[str, frozenset[tuple[int, int]]] = field(
|
|
120
|
+
default_factory=dict, repr=False, init=False
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
@classmethod
|
|
124
|
+
def open(cls, path: Path) -> WorkbookCache:
|
|
125
|
+
wb = load_workbook(path, read_only=True, data_only=True)
|
|
126
|
+
return cls(path=path, wb=wb, sheet_names=list(wb.sheetnames))
|
|
127
|
+
|
|
128
|
+
def close(self) -> None:
|
|
129
|
+
self.wb.close()
|
|
130
|
+
|
|
131
|
+
def _ensure_parsed(self, sheet_name: str) -> None:
|
|
132
|
+
if sheet_name in self._matrix_cache:
|
|
133
|
+
return
|
|
134
|
+
ws = self.wb[sheet_name]
|
|
135
|
+
rows: list[list[str]] = []
|
|
136
|
+
crossed: set[tuple[int, int]] = set()
|
|
137
|
+
for r_idx, row in enumerate(ws.iter_rows()):
|
|
138
|
+
row_vals: list[str] = []
|
|
139
|
+
for cell in row:
|
|
140
|
+
row_vals.append(norm(cell.value))
|
|
141
|
+
try:
|
|
142
|
+
if cell.fill and cell.fill.fill_type == "solid":
|
|
143
|
+
col_idx = getattr(cell, "column", None)
|
|
144
|
+
if col_idx is not None:
|
|
145
|
+
crossed.add((r_idx, col_idx - 1))
|
|
146
|
+
except (AttributeError, TypeError):
|
|
147
|
+
pass
|
|
148
|
+
rows.append(row_vals)
|
|
149
|
+
self._matrix_cache[sheet_name] = rows
|
|
150
|
+
self._crossed_cache[sheet_name] = frozenset(crossed)
|
|
151
|
+
|
|
152
|
+
def matrix(self, sheet_name: str) -> list[list[str]]:
|
|
153
|
+
self._ensure_parsed(sheet_name)
|
|
154
|
+
return self._matrix_cache[sheet_name]
|
|
155
|
+
|
|
156
|
+
def crossed_cells(self, sheet_name: str) -> frozenset[tuple[int, int]]:
|
|
157
|
+
"""Return 0-indexed (row, col) positions where cells have a solid fill (invalid data cells)."""
|
|
158
|
+
self._ensure_parsed(sheet_name)
|
|
159
|
+
return self._crossed_cache.get(sheet_name, frozenset())
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@dataclass(frozen=True)
|
|
163
|
+
class ApplyStats:
|
|
164
|
+
perimeter: str
|
|
165
|
+
facts_before: int
|
|
166
|
+
facts_after: int
|
|
167
|
+
deleted_facts: int
|
|
168
|
+
renamed_facts: int
|
|
169
|
+
deleted_qnames: int
|
|
170
|
+
modified_qnames: int
|
dpm/config.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Small JSON-backed app config.
|
|
2
|
+
|
|
3
|
+
Holds the list of DPM versions the user tracks — each entry is
|
|
4
|
+
``{"version": "2.10.0", "url": <str|None>}`` (an optional explicit download URL
|
|
5
|
+
that overrides auto-resolution, needed for the odd hotfix builds) — plus the
|
|
6
|
+
selected UI ``theme``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from dpm._constants import CONFIG_PATH
|
|
17
|
+
from dpm.workflows import version_key
|
|
18
|
+
|
|
19
|
+
LOG = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class VersionEntry:
|
|
24
|
+
version: str
|
|
25
|
+
url: str | None = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _load_raw(path: Path) -> dict:
|
|
29
|
+
if not path.exists():
|
|
30
|
+
return {}
|
|
31
|
+
try:
|
|
32
|
+
return json.loads(path.read_text())
|
|
33
|
+
except (json.JSONDecodeError, OSError) as exc:
|
|
34
|
+
LOG.warning("Could not read config %s: %s", path, exc)
|
|
35
|
+
return {}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _save_raw(raw: dict, path: Path) -> None:
|
|
39
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
path.write_text(json.dumps(raw, indent=2))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def load_versions(path: Path = CONFIG_PATH) -> list[VersionEntry]:
|
|
44
|
+
raw = _load_raw(path)
|
|
45
|
+
entries = [
|
|
46
|
+
VersionEntry(version=str(e["version"]), url=(e.get("url") or None))
|
|
47
|
+
for e in raw.get("versions", [])
|
|
48
|
+
if e.get("version")
|
|
49
|
+
]
|
|
50
|
+
return sorted(entries, key=lambda e: version_key(e.version))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def save_versions(entries: list[VersionEntry], path: Path = CONFIG_PATH) -> None:
|
|
54
|
+
# Merge into the existing config so unrelated keys (e.g. ``theme``) survive.
|
|
55
|
+
raw = _load_raw(path)
|
|
56
|
+
raw["versions"] = [
|
|
57
|
+
{"version": e.version, **({"url": e.url} if e.url else {})}
|
|
58
|
+
for e in sorted(entries, key=lambda e: version_key(e.version))
|
|
59
|
+
]
|
|
60
|
+
_save_raw(raw, path)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def load_theme(path: Path = CONFIG_PATH) -> str | None:
|
|
64
|
+
"""Return the persisted UI theme name, or ``None`` if unset."""
|
|
65
|
+
return _load_raw(path).get("theme") or None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def save_theme(theme: str, path: Path = CONFIG_PATH) -> None:
|
|
69
|
+
"""Persist the selected UI theme, preserving the rest of the config."""
|
|
70
|
+
raw = _load_raw(path)
|
|
71
|
+
raw["theme"] = theme
|
|
72
|
+
_save_raw(raw, path)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def add_version(
|
|
76
|
+
version: str, url: str | None = None, path: Path = CONFIG_PATH
|
|
77
|
+
) -> list[VersionEntry]:
|
|
78
|
+
"""Add (or update the URL of) a version, returning the new list."""
|
|
79
|
+
version = version.strip()
|
|
80
|
+
url = (url or "").strip() or None
|
|
81
|
+
entries = [e for e in load_versions(path) if e.version != version]
|
|
82
|
+
entries.append(VersionEntry(version=version, url=url))
|
|
83
|
+
save_versions(entries, path)
|
|
84
|
+
return load_versions(path)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def remove_version(version: str, path: Path = CONFIG_PATH) -> list[VersionEntry]:
|
|
88
|
+
entries = [e for e in load_versions(path) if e.version != version]
|
|
89
|
+
save_versions(entries, path)
|
|
90
|
+
return load_versions(path)
|