dpm-tool 0.1.0__tar.gz

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.
Files changed (53) hide show
  1. dpm_tool-0.1.0/LICENSE +21 -0
  2. dpm_tool-0.1.0/PKG-INFO +121 -0
  3. dpm_tool-0.1.0/README.md +93 -0
  4. dpm_tool-0.1.0/app.py +64 -0
  5. dpm_tool-0.1.0/dpm/__init__.py +0 -0
  6. dpm_tool-0.1.0/dpm/_constants.py +50 -0
  7. dpm_tool-0.1.0/dpm/_text.py +82 -0
  8. dpm_tool-0.1.0/dpm/_types.py +170 -0
  9. dpm_tool-0.1.0/dpm/config.py +90 -0
  10. dpm_tool-0.1.0/dpm/db.py +396 -0
  11. dpm_tool-0.1.0/dpm/delta.py +504 -0
  12. dpm_tool-0.1.0/dpm/delta_db.py +262 -0
  13. dpm_tool-0.1.0/dpm/delta_schema.py +87 -0
  14. dpm_tool-0.1.0/dpm/eiopa.py +152 -0
  15. dpm_tool-0.1.0/dpm/excel.py +192 -0
  16. dpm_tool-0.1.0/dpm/parser.py +532 -0
  17. dpm_tool-0.1.0/dpm/toc.py +223 -0
  18. dpm_tool-0.1.0/dpm/ui/__init__.py +0 -0
  19. dpm_tool-0.1.0/dpm/ui/_confirm_cancel_modal.tcss +24 -0
  20. dpm_tool-0.1.0/dpm/ui/_override_db_modal.tcss +24 -0
  21. dpm_tool-0.1.0/dpm/ui/_utils.py +152 -0
  22. dpm_tool-0.1.0/dpm/ui/apply_screen.py +184 -0
  23. dpm_tool-0.1.0/dpm/ui/apply_screen.tcss +54 -0
  24. dpm_tool-0.1.0/dpm/ui/delta_screen.py +220 -0
  25. dpm_tool-0.1.0/dpm/ui/delta_screen.tcss +80 -0
  26. dpm_tool-0.1.0/dpm/ui/explore_delta_screen.py +494 -0
  27. dpm_tool-0.1.0/dpm/ui/explore_delta_screen.tcss +105 -0
  28. dpm_tool-0.1.0/dpm/ui/explore_screen.py +385 -0
  29. dpm_tool-0.1.0/dpm/ui/explore_screen.tcss +131 -0
  30. dpm_tool-0.1.0/dpm/ui/ingest_screen.py +273 -0
  31. dpm_tool-0.1.0/dpm/ui/ingest_screen.tcss +73 -0
  32. dpm_tool-0.1.0/dpm/ui/log_handler.py +47 -0
  33. dpm_tool-0.1.0/dpm/ui/main_screen.py +72 -0
  34. dpm_tool-0.1.0/dpm/ui/main_screen.tcss +55 -0
  35. dpm_tool-0.1.0/dpm/ui/settings_screen.py +84 -0
  36. dpm_tool-0.1.0/dpm/ui/settings_screen.tcss +55 -0
  37. dpm_tool-0.1.0/dpm/workflows.py +361 -0
  38. dpm_tool-0.1.0/dpm/xbrl.py +286 -0
  39. dpm_tool-0.1.0/dpm_tool.egg-info/PKG-INFO +121 -0
  40. dpm_tool-0.1.0/dpm_tool.egg-info/SOURCES.txt +51 -0
  41. dpm_tool-0.1.0/dpm_tool.egg-info/dependency_links.txt +1 -0
  42. dpm_tool-0.1.0/dpm_tool.egg-info/entry_points.txt +2 -0
  43. dpm_tool-0.1.0/dpm_tool.egg-info/requires.txt +8 -0
  44. dpm_tool-0.1.0/dpm_tool.egg-info/top_level.txt +2 -0
  45. dpm_tool-0.1.0/pyproject.toml +56 -0
  46. dpm_tool-0.1.0/setup.cfg +4 -0
  47. dpm_tool-0.1.0/tests/test_db_explore.py +131 -0
  48. dpm_tool-0.1.0/tests/test_delta_db.py +113 -0
  49. dpm_tool-0.1.0/tests/test_delta_sheets.py +129 -0
  50. dpm_tool-0.1.0/tests/test_dimensions.py +153 -0
  51. dpm_tool-0.1.0/tests/test_eiopa_config.py +61 -0
  52. dpm_tool-0.1.0/tests/test_golden.py +158 -0
  53. dpm_tool-0.1.0/tests/test_parsing.py +236 -0
dpm_tool-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ZvNico
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,121 @@
1
+ Metadata-Version: 2.4
2
+ Name: dpm-tool
3
+ Version: 0.1.0
4
+ Summary: Textual TUI for EIOPA Solvency II DPM: ingest annotated-templates workbooks, diff versions, and roll deltas onto XBRL instances.
5
+ Author-email: ZvNico <nicolas.baconnier@efrei.net>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/ZvNico/michael
8
+ Project-URL: Repository, https://github.com/ZvNico/michael
9
+ Project-URL: Issues, https://github.com/ZvNico/michael/issues
10
+ Keywords: eiopa,solvency-ii,dpm,xbrl,regulatory-reporting,tui
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Financial and Insurance Industry
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Office/Business :: Financial
16
+ Requires-Python: >=3.12
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Requires-Dist: duckdb>=1.1.0
20
+ Requires-Dist: lxml>=6.1.1
21
+ Requires-Dist: openpyxl>=3.1.5
22
+ Requires-Dist: polars>=1.42.1
23
+ Requires-Dist: pyarrow>=25.0.0
24
+ Requires-Dist: textual>=0.80.0
25
+ Requires-Dist: textual-fspicker>=1.0.1
26
+ Requires-Dist: xlsxwriter>=3.2.0
27
+ Dynamic: license-file
28
+
29
+ # dpm-tool
30
+
31
+ A terminal UI toolkit for working with the EIOPA Solvency II **Data Point Model
32
+ (DPM)**. It ingests the official annotated-templates workbooks into local
33
+ databases, compares two DPM versions to produce a reviewable delta, and rolls
34
+ that delta forward onto your XBRL instance documents.
35
+
36
+ Everything runs in a [Textual](https://textual.textualize.io/) TUI — no
37
+ subcommands to memorise.
38
+
39
+ ---
40
+
41
+ ## Install
42
+
43
+ ```bash
44
+ uv tool install dpm-tool # as a standalone tool
45
+ # or, inside a project
46
+ uv add dpm-tool
47
+ ```
48
+
49
+ Requires Python ≥ 3.12.
50
+
51
+ ## Run
52
+
53
+ ```bash
54
+ dpm-tool
55
+ ```
56
+
57
+ This launches the **EIOPA DPM Toolkit** home screen. Navigate with the arrow
58
+ keys, `Enter` to select, `Esc` to go back, `s` for settings, `q` to quit.
59
+
60
+ ---
61
+
62
+ ## What it does
63
+
64
+ The home screen exposes five workflows:
65
+
66
+ | Screen | What it does |
67
+ | --- | --- |
68
+ | **⇩ DPM Ingest** | Parse an EIOPA annotated-templates workbook into a versioned local database (`db/versions/<version>.duckdb`). |
69
+ | **Δ DPM Delta** | Compare two ingested versions and export a reviewable delta workbook (`Delta_DPM.xlsx`). |
70
+ | **⇄ XBRL Apply Delta** | Roll the delta between two versions forward onto an XBRL instance, writing a new file (the input is never modified). |
71
+ | **⌕ Explore Database** | Browse an ingested DPM database — templates, metrics, dimensions and their members. |
72
+ | **≠ Explore Delta** | Browse the computed changes between two versions interactively. |
73
+
74
+ ### Typical flow
75
+
76
+ 1. **Add the versions you track** in **Settings** (`s`). The tool can download
77
+ the official workbooks straight from EIOPA, or you can supply an explicit URL
78
+ for the odd hotfix build.
79
+ 2. **Ingest** the old and new workbooks into versioned databases.
80
+ 3. **Delta** the two versions to review what changed, or **Apply Delta** to
81
+ update your XBRL instances.
82
+
83
+ ---
84
+
85
+ ## How the XBRL update works
86
+
87
+ When applying a delta to an XBRL instance:
88
+
89
+ - **Deleted** metric — matching facts are removed.
90
+ - **Modified** metric with a changed QName — the fact tag is renamed.
91
+ - **Added** metric — ignored (no value or context can be inferred).
92
+ - **Kept** metric — left unchanged.
93
+
94
+ The perimeter is auto-detected from the instance's `schemaRef`
95
+ (e.g. `.../mod/qrs.xsd` → `qrs`) and can be overridden. A dry-run mode reports
96
+ what *would* change without writing output.
97
+
98
+ ---
99
+
100
+ ## Data & config layout
101
+
102
+ | Path | Contents |
103
+ | --- | --- |
104
+ | `db/versions/` | Ingested DPM version databases (DuckDB). |
105
+ | `db/delta/` | Cached delta databases computed between two versions. |
106
+ | `data/downloads/` | Source workbooks downloaded from EIOPA. |
107
+ | `dpm-tool.config.json` | Tracked versions and the selected UI theme. |
108
+
109
+ ---
110
+
111
+ ## Development
112
+
113
+ ```bash
114
+ uv sync # install with dev dependencies
115
+ uv run dpm-tool # run from source
116
+ uv run pytest # run the test suite
117
+ ```
118
+
119
+ ## License
120
+
121
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,93 @@
1
+ # dpm-tool
2
+
3
+ A terminal UI toolkit for working with the EIOPA Solvency II **Data Point Model
4
+ (DPM)**. It ingests the official annotated-templates workbooks into local
5
+ databases, compares two DPM versions to produce a reviewable delta, and rolls
6
+ that delta forward onto your XBRL instance documents.
7
+
8
+ Everything runs in a [Textual](https://textual.textualize.io/) TUI — no
9
+ subcommands to memorise.
10
+
11
+ ---
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ uv tool install dpm-tool # as a standalone tool
17
+ # or, inside a project
18
+ uv add dpm-tool
19
+ ```
20
+
21
+ Requires Python ≥ 3.12.
22
+
23
+ ## Run
24
+
25
+ ```bash
26
+ dpm-tool
27
+ ```
28
+
29
+ This launches the **EIOPA DPM Toolkit** home screen. Navigate with the arrow
30
+ keys, `Enter` to select, `Esc` to go back, `s` for settings, `q` to quit.
31
+
32
+ ---
33
+
34
+ ## What it does
35
+
36
+ The home screen exposes five workflows:
37
+
38
+ | Screen | What it does |
39
+ | --- | --- |
40
+ | **⇩ DPM Ingest** | Parse an EIOPA annotated-templates workbook into a versioned local database (`db/versions/<version>.duckdb`). |
41
+ | **Δ DPM Delta** | Compare two ingested versions and export a reviewable delta workbook (`Delta_DPM.xlsx`). |
42
+ | **⇄ XBRL Apply Delta** | Roll the delta between two versions forward onto an XBRL instance, writing a new file (the input is never modified). |
43
+ | **⌕ Explore Database** | Browse an ingested DPM database — templates, metrics, dimensions and their members. |
44
+ | **≠ Explore Delta** | Browse the computed changes between two versions interactively. |
45
+
46
+ ### Typical flow
47
+
48
+ 1. **Add the versions you track** in **Settings** (`s`). The tool can download
49
+ the official workbooks straight from EIOPA, or you can supply an explicit URL
50
+ for the odd hotfix build.
51
+ 2. **Ingest** the old and new workbooks into versioned databases.
52
+ 3. **Delta** the two versions to review what changed, or **Apply Delta** to
53
+ update your XBRL instances.
54
+
55
+ ---
56
+
57
+ ## How the XBRL update works
58
+
59
+ When applying a delta to an XBRL instance:
60
+
61
+ - **Deleted** metric — matching facts are removed.
62
+ - **Modified** metric with a changed QName — the fact tag is renamed.
63
+ - **Added** metric — ignored (no value or context can be inferred).
64
+ - **Kept** metric — left unchanged.
65
+
66
+ The perimeter is auto-detected from the instance's `schemaRef`
67
+ (e.g. `.../mod/qrs.xsd` → `qrs`) and can be overridden. A dry-run mode reports
68
+ what *would* change without writing output.
69
+
70
+ ---
71
+
72
+ ## Data & config layout
73
+
74
+ | Path | Contents |
75
+ | --- | --- |
76
+ | `db/versions/` | Ingested DPM version databases (DuckDB). |
77
+ | `db/delta/` | Cached delta databases computed between two versions. |
78
+ | `data/downloads/` | Source workbooks downloaded from EIOPA. |
79
+ | `dpm-tool.config.json` | Tracked versions and the selected UI theme. |
80
+
81
+ ---
82
+
83
+ ## Development
84
+
85
+ ```bash
86
+ uv sync # install with dev dependencies
87
+ uv run dpm-tool # run from source
88
+ uv run pytest # run the test suite
89
+ ```
90
+
91
+ ## License
92
+
93
+ MIT — see [LICENSE](LICENSE).
dpm_tool-0.1.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()
File without changes
@@ -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"]
@@ -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 ""
@@ -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
@@ -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)