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 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)