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.
- dpm_tool-0.1.0/LICENSE +21 -0
- dpm_tool-0.1.0/PKG-INFO +121 -0
- dpm_tool-0.1.0/README.md +93 -0
- dpm_tool-0.1.0/app.py +64 -0
- dpm_tool-0.1.0/dpm/__init__.py +0 -0
- dpm_tool-0.1.0/dpm/_constants.py +50 -0
- dpm_tool-0.1.0/dpm/_text.py +82 -0
- dpm_tool-0.1.0/dpm/_types.py +170 -0
- dpm_tool-0.1.0/dpm/config.py +90 -0
- dpm_tool-0.1.0/dpm/db.py +396 -0
- dpm_tool-0.1.0/dpm/delta.py +504 -0
- dpm_tool-0.1.0/dpm/delta_db.py +262 -0
- dpm_tool-0.1.0/dpm/delta_schema.py +87 -0
- dpm_tool-0.1.0/dpm/eiopa.py +152 -0
- dpm_tool-0.1.0/dpm/excel.py +192 -0
- dpm_tool-0.1.0/dpm/parser.py +532 -0
- dpm_tool-0.1.0/dpm/toc.py +223 -0
- dpm_tool-0.1.0/dpm/ui/__init__.py +0 -0
- dpm_tool-0.1.0/dpm/ui/_confirm_cancel_modal.tcss +24 -0
- dpm_tool-0.1.0/dpm/ui/_override_db_modal.tcss +24 -0
- dpm_tool-0.1.0/dpm/ui/_utils.py +152 -0
- dpm_tool-0.1.0/dpm/ui/apply_screen.py +184 -0
- dpm_tool-0.1.0/dpm/ui/apply_screen.tcss +54 -0
- dpm_tool-0.1.0/dpm/ui/delta_screen.py +220 -0
- dpm_tool-0.1.0/dpm/ui/delta_screen.tcss +80 -0
- dpm_tool-0.1.0/dpm/ui/explore_delta_screen.py +494 -0
- dpm_tool-0.1.0/dpm/ui/explore_delta_screen.tcss +105 -0
- dpm_tool-0.1.0/dpm/ui/explore_screen.py +385 -0
- dpm_tool-0.1.0/dpm/ui/explore_screen.tcss +131 -0
- dpm_tool-0.1.0/dpm/ui/ingest_screen.py +273 -0
- dpm_tool-0.1.0/dpm/ui/ingest_screen.tcss +73 -0
- dpm_tool-0.1.0/dpm/ui/log_handler.py +47 -0
- dpm_tool-0.1.0/dpm/ui/main_screen.py +72 -0
- dpm_tool-0.1.0/dpm/ui/main_screen.tcss +55 -0
- dpm_tool-0.1.0/dpm/ui/settings_screen.py +84 -0
- dpm_tool-0.1.0/dpm/ui/settings_screen.tcss +55 -0
- dpm_tool-0.1.0/dpm/workflows.py +361 -0
- dpm_tool-0.1.0/dpm/xbrl.py +286 -0
- dpm_tool-0.1.0/dpm_tool.egg-info/PKG-INFO +121 -0
- dpm_tool-0.1.0/dpm_tool.egg-info/SOURCES.txt +51 -0
- dpm_tool-0.1.0/dpm_tool.egg-info/dependency_links.txt +1 -0
- dpm_tool-0.1.0/dpm_tool.egg-info/entry_points.txt +2 -0
- dpm_tool-0.1.0/dpm_tool.egg-info/requires.txt +8 -0
- dpm_tool-0.1.0/dpm_tool.egg-info/top_level.txt +2 -0
- dpm_tool-0.1.0/pyproject.toml +56 -0
- dpm_tool-0.1.0/setup.cfg +4 -0
- dpm_tool-0.1.0/tests/test_db_explore.py +131 -0
- dpm_tool-0.1.0/tests/test_delta_db.py +113 -0
- dpm_tool-0.1.0/tests/test_delta_sheets.py +129 -0
- dpm_tool-0.1.0/tests/test_dimensions.py +153 -0
- dpm_tool-0.1.0/tests/test_eiopa_config.py +61 -0
- dpm_tool-0.1.0/tests/test_golden.py +158 -0
- 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.
|
dpm_tool-0.1.0/PKG-INFO
ADDED
|
@@ -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).
|
dpm_tool-0.1.0/README.md
ADDED
|
@@ -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)
|