moneymanager-parser 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.
@@ -0,0 +1,15 @@
1
+ """Typed SDK for Realbyte Money Manager backup files."""
2
+
3
+ from .core import MoneyManagerBackup
4
+ from .models import Account, Currency, QueryResult, Transaction
5
+
6
+ __version__ = "0.1.0"
7
+
8
+ __all__ = [
9
+ "Account",
10
+ "Currency",
11
+ "MoneyManagerBackup",
12
+ "QueryResult",
13
+ "Transaction",
14
+ "__version__",
15
+ ]
@@ -0,0 +1,86 @@
1
+ """Command line interface for moneymanager-parser."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ from collections.abc import Sequence
8
+ from typing import Any
9
+
10
+ from .core import MoneyManagerBackup
11
+ from .models import Currency
12
+
13
+
14
+ def _print(data: Any) -> None:
15
+ print(json.dumps(data, indent=2, default=str))
16
+
17
+
18
+ def _currency_dict(currency: Currency | None) -> dict[str, Any] | None:
19
+ if currency is None:
20
+ return None
21
+ return {
22
+ "iso": currency.iso,
23
+ "symbol": currency.symbol,
24
+ "name": currency.name,
25
+ "is_main": currency.is_main,
26
+ }
27
+
28
+
29
+ def build_parser() -> argparse.ArgumentParser:
30
+ parser = argparse.ArgumentParser(prog="mmbak", description="Offline Realbyte .mmbak parser")
31
+ sub = parser.add_subparsers(dest="command", required=True)
32
+ for name in ("schema", "currency"):
33
+ cmd = sub.add_parser(name)
34
+ cmd.add_argument("path")
35
+ query = sub.add_parser("query")
36
+ query.add_argument("path")
37
+ query.add_argument("--from", dest="date_from")
38
+ query.add_argument("--to", dest="date_to")
39
+ query.add_argument("--month")
40
+ query.add_argument("--category")
41
+ query.add_argument("--search")
42
+ query.add_argument("--kind", choices=["expense", "income", "all"], default="expense")
43
+ query.add_argument("--group-by", choices=["month", "category", "day", "week"])
44
+ query.add_argument("--top", type=int)
45
+ query.add_argument("--list", dest="list_n", type=int)
46
+ query.add_argument("--limit", type=int, default=24)
47
+ return parser
48
+
49
+
50
+ def main(argv: Sequence[str] | None = None) -> int:
51
+ args = build_parser().parse_args(argv)
52
+ try:
53
+ with MoneyManagerBackup.from_file(args.path) as backup:
54
+ if args.command == "schema":
55
+ _print(backup.schema())
56
+ elif args.command == "currency":
57
+ main_currency = backup.currency()
58
+ _print(
59
+ {
60
+ "main": _currency_dict(main_currency),
61
+ "all": [_currency_dict(item) for item in backup.currencies()],
62
+ }
63
+ )
64
+ else:
65
+ _print(
66
+ backup.query(
67
+ date_from=args.date_from,
68
+ date_to=args.date_to,
69
+ month=args.month,
70
+ category=args.category,
71
+ search=args.search,
72
+ kind=args.kind,
73
+ group_by=args.group_by,
74
+ top=args.top,
75
+ list_n=args.list_n,
76
+ limit=args.limit,
77
+ ).as_dict()
78
+ )
79
+ except Exception as exc: # noqa: BLE001
80
+ _print({"status": "error", "reason": str(exc)})
81
+ return 1
82
+ return 0
83
+
84
+
85
+ if __name__ == "__main__":
86
+ raise SystemExit(main())
@@ -0,0 +1,559 @@
1
+ """Core parser for Realbyte Money Manager backup files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import calendar
6
+ import io
7
+ import os
8
+ import sqlite3
9
+ import tempfile
10
+ import zipfile
11
+ from collections.abc import Iterable, Iterator, Mapping
12
+ from dataclasses import dataclass
13
+ from datetime import date, datetime, timedelta
14
+ from pathlib import Path
15
+ from typing import Any, cast
16
+
17
+ from .models import (
18
+ Account,
19
+ Currency,
20
+ GroupBy,
21
+ JsonDict,
22
+ Kind,
23
+ QueryKind,
24
+ QueryResult,
25
+ Transaction,
26
+ )
27
+ from .schema import (
28
+ AMOUNT_COLS,
29
+ ASSET_TABLE_CANDIDATES,
30
+ ASSETFK_COLS,
31
+ BALANCE_COLS,
32
+ CATEGORY_TABLE_CANDIDATES,
33
+ CATFK_COLS,
34
+ CATNAME_COLS,
35
+ CURRENCY_ISO_COLS,
36
+ CURRENCY_MAIN_COLS,
37
+ CURRENCY_SYMBOL_COLS,
38
+ CURRENCY_TABLE_CANDIDATES,
39
+ DATE_COLS,
40
+ DEFAULT_TYPE_MAP,
41
+ MEMO_COLS,
42
+ NAME_COLS,
43
+ STRING_TYPE_MAP,
44
+ TXN_TABLE_CANDIDATES,
45
+ TYPE_COLS,
46
+ UID_COLS,
47
+ )
48
+
49
+ TypeMap = Mapping[int, Kind]
50
+
51
+
52
+ def _first_present(options: Iterable[str], available: Iterable[str]) -> str | None:
53
+ aset = set(available)
54
+ for option in options:
55
+ if option in aset:
56
+ return option
57
+ lower = {item.lower(): item for item in available}
58
+ for option in options:
59
+ found = lower.get(option.lower())
60
+ if found is not None:
61
+ return found
62
+ return None
63
+
64
+
65
+ def _table_columns(con: sqlite3.Connection, table: str) -> list[str]:
66
+ return [str(row[1]) for row in con.execute(f'PRAGMA table_info("{table}")')]
67
+
68
+
69
+ def _list_tables(con: sqlite3.Connection) -> list[str]:
70
+ return [str(row[0]) for row in con.execute("SELECT name FROM sqlite_master WHERE type='table'")]
71
+
72
+
73
+ def _pick_table(
74
+ _con: sqlite3.Connection, candidates: Iterable[str], tables: Iterable[str]
75
+ ) -> str | None:
76
+ return _first_present(candidates, tables)
77
+
78
+
79
+ def _parse_date(value: object) -> date | None:
80
+ if value is None:
81
+ return None
82
+ if isinstance(value, (int, float)):
83
+ number = float(value)
84
+ if number > 1e12:
85
+ return datetime.fromtimestamp(number / 1000).date()
86
+ if number > 1e9:
87
+ return datetime.fromtimestamp(number).date()
88
+ return None
89
+ text = str(value).strip()
90
+ if not text:
91
+ return None
92
+ if text.lstrip("-").isdigit():
93
+ number = int(text)
94
+ if len(text) >= 12:
95
+ return datetime.fromtimestamp(number / 1000).date()
96
+ if len(text) in {10, 11}:
97
+ return datetime.fromtimestamp(number).date()
98
+ for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d", "%Y/%m/%d %H:%M:%S", "%Y/%m/%d"):
99
+ try:
100
+ return datetime.strptime(text, fmt).date()
101
+ except ValueError:
102
+ continue
103
+ digits = "".join(char for char in text if char.isdigit())
104
+ if len(digits) >= 8:
105
+ try:
106
+ return date(int(digits[0:4]), int(digits[4:6]), int(digits[6:8]))
107
+ except ValueError:
108
+ return None
109
+ return None
110
+
111
+
112
+ def _to_float(value: object) -> float:
113
+ try:
114
+ return float(cast(Any, value))
115
+ except (TypeError, ValueError):
116
+ return 0.0
117
+
118
+
119
+ def _normal_type_map(type_map: Mapping[int, str] | None) -> dict[int, Kind]:
120
+ source = type_map or DEFAULT_TYPE_MAP
121
+ out: dict[int, Kind] = {}
122
+ for key, value in source.items():
123
+ if value not in {"income", "expense", "transfer"}:
124
+ raise ValueError(f"unsupported transaction kind: {value}")
125
+ out[int(key)] = cast(Kind, value)
126
+ return out
127
+
128
+
129
+ def _kind(do_type: object, type_map: Mapping[int, Kind]) -> Kind:
130
+ if do_type is None:
131
+ return "expense"
132
+ if isinstance(do_type, str):
133
+ text = do_type.strip()
134
+ if text.lstrip("-").isdigit():
135
+ return type_map.get(int(text), "expense")
136
+ return cast(Kind, STRING_TYPE_MAP.get(text.lower(), "expense"))
137
+ try:
138
+ return type_map.get(int(cast(Any, do_type)), "expense")
139
+ except (TypeError, ValueError):
140
+ return "expense"
141
+
142
+
143
+ def _month_label(day: date) -> str:
144
+ return day.strftime("%Y-%m")
145
+
146
+
147
+ def _iso_week_start(day: date) -> date:
148
+ return day - timedelta(days=day.weekday())
149
+
150
+
151
+ class _ManagedConnection(sqlite3.Connection):
152
+ _cleanup_path: Path | None = None
153
+
154
+ def close(self) -> None:
155
+ cleanup = self._cleanup_path
156
+ try:
157
+ super().close()
158
+ finally:
159
+ if cleanup is not None:
160
+ cleanup.unlink(missing_ok=True)
161
+
162
+
163
+ def _connect_file(path: Path, *, readonly: bool, cleanup: Path | None = None) -> sqlite3.Connection:
164
+ uri = f"file:{path.resolve()}?mode=ro" if readonly else str(path)
165
+ con = sqlite3.connect(uri, uri=readonly, factory=_ManagedConnection)
166
+ con.row_factory = sqlite3.Row
167
+ con._cleanup_path = cleanup
168
+ return con
169
+
170
+
171
+ def _temp_connection_from_bytes(data: bytes, directory: Path | None = None) -> sqlite3.Connection:
172
+ with tempfile.NamedTemporaryFile(suffix=".sqlite", dir=directory, delete=False) as handle:
173
+ handle.write(data)
174
+ name = Path(handle.name)
175
+ return _connect_file(name, readonly=True, cleanup=name)
176
+
177
+
178
+ def _connection_from_bytes(data: bytes, directory: Path | None = None) -> sqlite3.Connection:
179
+ con = sqlite3.connect(":memory:")
180
+ con.row_factory = sqlite3.Row
181
+ if hasattr(con, "deserialize"):
182
+ con.deserialize(data)
183
+ return con
184
+ con.close()
185
+ return _temp_connection_from_bytes(data, directory)
186
+
187
+
188
+ def _read_db_bytes_from_zip(data: bytes, label: str = "backup") -> bytes:
189
+ with zipfile.ZipFile(io.BytesIO(data)) as archive:
190
+ members = [name for name in archive.namelist() if not name.endswith("/")]
191
+ if not members:
192
+ raise ValueError(f"empty .mmbak archive: {label}")
193
+ member = next(
194
+ (name for name in members if name.lower().endswith((".sqlite", ".db", ".mmbak"))),
195
+ None,
196
+ )
197
+ if member is None:
198
+ member = max(members, key=lambda name: archive.getinfo(name).file_size)
199
+ return archive.read(member)
200
+
201
+
202
+ def _open_mmbak(path: os.PathLike[str] | str) -> sqlite3.Connection:
203
+ file_path = Path(path)
204
+ data = file_path.read_bytes()
205
+ if zipfile.is_zipfile(file_path):
206
+ return _connection_from_bytes(
207
+ _read_db_bytes_from_zip(data, str(file_path)), file_path.parent
208
+ )
209
+ return _connect_file(file_path, readonly=True)
210
+
211
+
212
+ def _name_map(con: sqlite3.Connection, table: str | None) -> dict[str, str]:
213
+ if not table:
214
+ return {}
215
+ cols = _table_columns(con, table)
216
+ name_col = _first_present(NAME_COLS, cols)
217
+ if not name_col:
218
+ return {}
219
+ id_cols = [col for col in UID_COLS if col in cols]
220
+ if not id_cols:
221
+ return {}
222
+ selected = ", ".join(f'"{col}" AS k{index}' for index, col in enumerate(id_cols))
223
+ out: dict[str, str] = {}
224
+ for row in con.execute(f'SELECT {selected}, "{name_col}" AS n FROM "{table}"'):
225
+ for index in range(len(id_cols)):
226
+ value = row[f"k{index}"]
227
+ if value is not None and value != "":
228
+ out.setdefault(str(value), str(row["n"]))
229
+ return out
230
+
231
+
232
+ def _period_bound(value: str, *, is_end: bool) -> date:
233
+ text = value.strip()
234
+ parts = text.split("-")
235
+ if len(parts) == 2:
236
+ year, month = int(parts[0]), int(parts[1])
237
+ if is_end:
238
+ return date(year, month, calendar.monthrange(year, month)[1])
239
+ return date(year, month, 1)
240
+ return datetime.strptime(text, "%Y-%m-%d").date()
241
+
242
+
243
+ @dataclass(frozen=True, slots=True)
244
+ class _ResolvedSchema:
245
+ txn_table: str
246
+ amount: str
247
+ date_col: str
248
+ type_col: str | None
249
+ category_fk: str | None
250
+ category_name: str | None
251
+ asset_fk: str | None
252
+ memo: str | None
253
+ category_table: str | None
254
+ asset_table: str | None
255
+ currency_table: str | None
256
+
257
+
258
+ class MoneyManagerBackup:
259
+ """A parsed, offline Realbyte Money Manager backup.
260
+
261
+ Income rows are preserved and exposed. The backup's currencies are read from
262
+ the ``CURRENCY`` table when present; ``currency()`` returns the main currency.
263
+ """
264
+
265
+ def __init__(
266
+ self, con: sqlite3.Connection, *, type_map: Mapping[int, str] | None = None
267
+ ) -> None:
268
+ self._con = con
269
+ self._type_map = _normal_type_map(type_map)
270
+ self._schema = self._resolve_schema()
271
+ self._categories = _name_map(con, self._schema.category_table)
272
+ self._account_names = _name_map(con, self._schema.asset_table)
273
+
274
+ @classmethod
275
+ def from_file(
276
+ cls, path: os.PathLike[str] | str, *, type_map: Mapping[int, str] | None = None
277
+ ) -> MoneyManagerBackup:
278
+ return cls(_open_mmbak(path), type_map=type_map)
279
+
280
+ @classmethod
281
+ def from_bytes(
282
+ cls, data: bytes, *, type_map: Mapping[int, str] | None = None
283
+ ) -> MoneyManagerBackup:
284
+ payload = (
285
+ _read_db_bytes_from_zip(data, "bytes") if zipfile.is_zipfile(io.BytesIO(data)) else data
286
+ )
287
+ return cls(_connection_from_bytes(payload), type_map=type_map)
288
+
289
+ def close(self) -> None:
290
+ self._con.close()
291
+
292
+ def __enter__(self) -> MoneyManagerBackup:
293
+ return self
294
+
295
+ def __exit__(self, *_exc: object) -> None:
296
+ self.close()
297
+
298
+ def _resolve_schema(self) -> _ResolvedSchema:
299
+ tables = _list_tables(self._con)
300
+ txn_table = _pick_table(self._con, TXN_TABLE_CANDIDATES, tables)
301
+ if not txn_table:
302
+ raise ValueError(f"no transaction table found; tables={tables}")
303
+ cols = _table_columns(self._con, txn_table)
304
+ amount = _first_present(AMOUNT_COLS, cols)
305
+ date_col = _first_present(DATE_COLS, cols)
306
+ if not amount or not date_col:
307
+ raise ValueError(f"missing amount/date columns in {txn_table}: {cols}")
308
+ return _ResolvedSchema(
309
+ txn_table=txn_table,
310
+ amount=amount,
311
+ date_col=date_col,
312
+ type_col=_first_present(TYPE_COLS, cols),
313
+ category_fk=_first_present(CATFK_COLS, cols),
314
+ category_name=_first_present(CATNAME_COLS, cols),
315
+ asset_fk=_first_present(ASSETFK_COLS, cols),
316
+ memo=_first_present(MEMO_COLS, cols),
317
+ category_table=_pick_table(self._con, CATEGORY_TABLE_CANDIDATES, tables),
318
+ asset_table=_pick_table(self._con, ASSET_TABLE_CANDIDATES, tables),
319
+ currency_table=_pick_table(self._con, CURRENCY_TABLE_CANDIDATES, tables),
320
+ )
321
+
322
+ def _iter_rows(self) -> Iterator[sqlite3.Row]:
323
+ s = self._schema
324
+ cols = [f'"{s.amount}" AS amt', f'"{s.date_col}" AS dt']
325
+ cols.append(f'"{s.type_col}" AS ty' if s.type_col else "NULL AS ty")
326
+ cols.append(f'"{s.category_fk}" AS cat' if s.category_fk else "NULL AS cat")
327
+ cols.append(f'"{s.category_name}" AS catname' if s.category_name else "NULL AS catname")
328
+ cols.append(f'"{s.asset_fk}" AS asset' if s.asset_fk else "NULL AS asset")
329
+ cols.append(f'"{s.memo}" AS memo' if s.memo else "NULL AS memo")
330
+ yield from self._con.execute(f'SELECT {", ".join(cols)} FROM "{s.txn_table}"')
331
+
332
+ def transactions(self) -> list[Transaction]:
333
+ out: list[Transaction] = []
334
+ for row in self._iter_rows():
335
+ parsed = _parse_date(row["dt"])
336
+ if parsed is None:
337
+ continue
338
+ cat = row["catname"] or self._categories.get(str(row["cat"])) or "Uncategorized"
339
+ account = (
340
+ self._account_names.get(str(row["asset"])) if row["asset"] is not None else None
341
+ )
342
+ out.append(
343
+ Transaction(
344
+ amount=abs(_to_float(row["amt"])),
345
+ date=parsed,
346
+ kind=_kind(row["ty"], self._type_map),
347
+ category=str(cat),
348
+ memo=str(row["memo"] or ""),
349
+ account=account,
350
+ )
351
+ )
352
+ return out
353
+
354
+ def categories(self) -> list[str]:
355
+ names = set(self._categories.values())
356
+ names.update(txn.category for txn in self.transactions())
357
+ return sorted(names)
358
+
359
+ def accounts(self) -> list[Account]:
360
+ asset_table = self._schema.asset_table
361
+ if not asset_table:
362
+ return []
363
+ cols = _table_columns(self._con, asset_table)
364
+ name_col = _first_present(NAME_COLS, cols)
365
+ balance_col = _first_present(BALANCE_COLS, cols)
366
+ if not name_col or not balance_col:
367
+ return []
368
+ return [
369
+ Account(name=str(row["n"]), balance=_to_float(row["b"]))
370
+ for row in self._con.execute(
371
+ f'SELECT "{name_col}" AS n, "{balance_col}" AS b FROM "{asset_table}"'
372
+ )
373
+ ]
374
+
375
+ def currencies(self) -> list[Currency]:
376
+ table = self._schema.currency_table
377
+ if not table:
378
+ return []
379
+ cols = _table_columns(self._con, table)
380
+ iso_col = _first_present(CURRENCY_ISO_COLS, cols)
381
+ symbol_col = _first_present(CURRENCY_SYMBOL_COLS, cols)
382
+ if not iso_col and not symbol_col:
383
+ return []
384
+ name_col = _first_present(NAME_COLS, cols)
385
+ main_col = _first_present(CURRENCY_MAIN_COLS, cols)
386
+ select = [
387
+ f'"{iso_col}" AS iso' if iso_col else "'' AS iso",
388
+ f'"{symbol_col}" AS sym' if symbol_col else "'' AS sym",
389
+ f'"{name_col}" AS nm' if name_col else "'' AS nm",
390
+ f'"{main_col}" AS mn' if main_col else "0 AS mn",
391
+ ]
392
+ return [
393
+ Currency(
394
+ iso=str(row["iso"] or ""),
395
+ symbol=str(row["sym"] or ""),
396
+ name=str(row["nm"] or ""),
397
+ is_main=bool(_to_float(row["mn"])),
398
+ )
399
+ for row in self._con.execute(f'SELECT {", ".join(select)} FROM "{table}"')
400
+ ]
401
+
402
+ def currency(self) -> Currency | None:
403
+ items = self.currencies()
404
+ if not items:
405
+ return None
406
+ for item in items:
407
+ if item.is_main:
408
+ return item
409
+ return items[0]
410
+
411
+ def schema(self) -> JsonDict:
412
+ tables = _list_tables(self._con)
413
+ out: JsonDict = {"tables": {table: _table_columns(self._con, table) for table in tables}}
414
+ s = self._schema
415
+ out["transaction_table"] = s.txn_table
416
+ out["resolved_columns"] = {
417
+ "amount": s.amount,
418
+ "date": s.date_col,
419
+ "type": s.type_col,
420
+ "category_fk": s.category_fk,
421
+ "category_name": s.category_name,
422
+ "asset_fk": s.asset_fk,
423
+ "memo": s.memo,
424
+ }
425
+ if s.type_col:
426
+ rows = self._con.execute(
427
+ f'SELECT "{s.type_col}" AS ty, COUNT(*) n, SUM(ABS("{s.amount}")) total '
428
+ f'FROM "{s.txn_table}" GROUP BY "{s.type_col}"'
429
+ )
430
+ out["do_type_breakdown"] = [
431
+ {
432
+ "do_type": str(row["ty"]),
433
+ "count": int(row["n"]),
434
+ "abs_total": _to_float(row["total"]),
435
+ }
436
+ for row in rows
437
+ ]
438
+ return out
439
+
440
+ def query(
441
+ self,
442
+ *,
443
+ date_from: str | date | None = None,
444
+ date_to: str | date | None = None,
445
+ month: str | None = None,
446
+ category: str | None = None,
447
+ search: str | None = None,
448
+ kind: QueryKind = "expense",
449
+ group_by: GroupBy | None = None,
450
+ top: int | None = None,
451
+ list_n: int | None = None,
452
+ limit: int = 24,
453
+ ) -> QueryResult:
454
+ if kind not in {"expense", "income", "all"}:
455
+ raise ValueError("kind must be expense, income, or all")
456
+ if month:
457
+ date_from = month
458
+ date_to = month
459
+ start = _period_bound(date_from, is_end=False) if isinstance(date_from, str) else date_from
460
+ end = _period_bound(date_to, is_end=True) if isinstance(date_to, str) else date_to
461
+ cat_want = category.strip().lower() if category else None
462
+ search_want = search.strip().lower() if search else None
463
+ matches: list[Transaction] = []
464
+ for txn in self.transactions():
465
+ if txn.kind == "transfer":
466
+ continue
467
+ if kind != "all" and txn.kind != kind:
468
+ continue
469
+ if start and txn.date < start:
470
+ continue
471
+ if end and txn.date > end:
472
+ continue
473
+ if cat_want and txn.category.lower() != cat_want:
474
+ continue
475
+ if (
476
+ search_want
477
+ and search_want not in txn.memo.lower()
478
+ and search_want not in txn.category.lower()
479
+ ):
480
+ continue
481
+ matches.append(txn)
482
+ total = sum(txn.amount for txn in matches)
483
+ dates = [txn.date for txn in matches]
484
+ filters: JsonDict = {
485
+ "from": start.isoformat() if start else None,
486
+ "to": end.isoformat() if end else None,
487
+ "category": category,
488
+ "search": search,
489
+ "kind": kind,
490
+ }
491
+ summary: JsonDict = {
492
+ "total": round(total),
493
+ "count": len(matches),
494
+ "avg_per_txn": round(total / len(matches)) if matches else 0,
495
+ "date_min": min(dates).isoformat() if dates else None,
496
+ "date_max": max(dates).isoformat() if dates else None,
497
+ }
498
+ result = QueryResult(status="ok", filters=filters, summary=summary)
499
+ if group_by:
500
+ groups: dict[str, list[float]] = {}
501
+ for txn in matches:
502
+ if group_by == "month":
503
+ key = _month_label(txn.date)
504
+ elif group_by == "day":
505
+ key = txn.date.isoformat()
506
+ elif group_by == "week":
507
+ key = _iso_week_start(txn.date).isoformat()
508
+ else:
509
+ key = txn.category
510
+ bucket = groups.setdefault(key, [0.0, 0.0])
511
+ bucket[0] += txn.amount
512
+ bucket[1] += 1
513
+ items = list(groups.items())
514
+ items.sort(
515
+ key=(lambda item: item[1][0]), reverse=True
516
+ ) if group_by == "category" else items.sort(key=lambda item: item[0])
517
+ result = QueryResult(
518
+ status="ok",
519
+ filters=filters,
520
+ summary=summary,
521
+ group_by=group_by,
522
+ groups=[
523
+ {"key": key, "total": round(value[0]), "count": int(value[1])}
524
+ for key, value in items[:limit]
525
+ ],
526
+ )
527
+ if top:
528
+ tops = sorted(matches, key=lambda txn: txn.amount, reverse=True)[:top]
529
+ result.top.extend(
530
+ {
531
+ "amount": round(txn.amount),
532
+ "category": txn.category,
533
+ "memo": txn.memo[:60],
534
+ "date": txn.date.isoformat(),
535
+ }
536
+ for txn in tops
537
+ )
538
+ if list_n:
539
+ recent = sorted(matches, key=lambda txn: txn.date, reverse=True)[:list_n]
540
+ result.transactions.extend(
541
+ {
542
+ "date": txn.date.isoformat(),
543
+ "amount": round(txn.amount),
544
+ "category": txn.category,
545
+ "memo": txn.memo[:60],
546
+ }
547
+ for txn in recent
548
+ )
549
+ if not (group_by or top or list_n):
550
+ cats: dict[str, float] = {}
551
+ for txn in matches:
552
+ cats[txn.category] = cats.get(txn.category, 0.0) + txn.amount
553
+ result.categories.extend(
554
+ {"name": name, "total": round(value)}
555
+ for name, value in sorted(cats.items(), key=lambda item: item[1], reverse=True)[
556
+ :limit
557
+ ]
558
+ )
559
+ return result
@@ -0,0 +1,63 @@
1
+ """Public dataclasses returned by the SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from datetime import date
7
+ from typing import Any, Literal
8
+
9
+ Kind = Literal["income", "expense", "transfer"]
10
+ GroupBy = Literal["month", "category", "day", "week"]
11
+ QueryKind = Literal["expense", "income", "all"]
12
+
13
+ JsonValue = Any
14
+ JsonDict = dict[str, Any]
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class Transaction:
19
+ amount: float
20
+ date: date
21
+ kind: Kind
22
+ category: str
23
+ memo: str
24
+ account: str | None = None
25
+
26
+
27
+ @dataclass(frozen=True, slots=True)
28
+ class Account:
29
+ name: str
30
+ balance: float
31
+
32
+
33
+ @dataclass(frozen=True, slots=True)
34
+ class Currency:
35
+ iso: str
36
+ symbol: str
37
+ name: str = ""
38
+ is_main: bool = False
39
+
40
+
41
+ @dataclass(frozen=True, slots=True)
42
+ class QueryResult:
43
+ status: str
44
+ filters: JsonDict
45
+ summary: JsonDict
46
+ group_by: str | None = None
47
+ groups: list[JsonDict] = field(default_factory=list)
48
+ top: list[JsonDict] = field(default_factory=list)
49
+ transactions: list[JsonDict] = field(default_factory=list)
50
+ categories: list[JsonDict] = field(default_factory=list)
51
+
52
+ def as_dict(self) -> JsonDict:
53
+ out: JsonDict = {"status": self.status, "filters": self.filters, "summary": self.summary}
54
+ if self.group_by:
55
+ out["group_by"] = self.group_by
56
+ out["groups"] = self.groups
57
+ if self.top:
58
+ out["top"] = self.top
59
+ if self.transactions:
60
+ out["transactions"] = self.transactions
61
+ if self.categories:
62
+ out["categories"] = self.categories
63
+ return out
File without changes
@@ -0,0 +1,36 @@
1
+ """Schema aliases used by Realbyte Money Manager releases."""
2
+
3
+ TXN_TABLE_CANDIDATES = ("INOUTCOME", "ZINOUTCOME", "inoutcome")
4
+ CATEGORY_TABLE_CANDIDATES = ("ZCATEGORY", "CATEGORY", "category")
5
+ ASSET_TABLE_CANDIDATES = ("ASSETS", "ZASSETS", "ASSET", "assets")
6
+ CURRENCY_TABLE_CANDIDATES = ("CURRENCY", "ZCURRENCY", "currency")
7
+
8
+ AMOUNT_COLS = ("ZMONEY", "AMOUNT", "amount", "money", "ZAMOUNT")
9
+ DATE_COLS = ("WDATE", "wdate", "ZDATE", "DATE", "date", "ZTIME")
10
+ TYPE_COLS = ("DO_TYPE", "type", "TYPE", "ZTYPE", "inoutType")
11
+ CATFK_COLS = ("ctgUid", "CTGUID", "ZCATEGORY", "category_id", "categoryUid", "ctg_uid")
12
+ CATNAME_COLS = ("CATEGORY_NAME", "category_name", "ZCTGNAME", "ctgName")
13
+ ASSETFK_COLS = ("assetUid", "ASSETUID", "ASSETS", "account_id", "asset_uid")
14
+ MEMO_COLS = ("ZCONTENT", "MEMO", "memo", "content", "ZCOMMENT", "comment")
15
+
16
+ NAME_COLS = ("ZNAME", "NAME", "name", "title", "ZTITLE")
17
+ UID_COLS = ("uid", "UID", "C_UID", "_id", "id", "ID", "ZUID")
18
+ BALANCE_COLS = ("ZMONEY", "AMOUNT", "amount", "balance", "ZAMOUNT")
19
+
20
+ CURRENCY_ISO_COLS = ("ISO", "iso", "CODE", "code", "ZISO")
21
+ CURRENCY_SYMBOL_COLS = ("SYMBOL", "symbol", "ZSYMBOL")
22
+ CURRENCY_MAIN_COLS = ("IS_MAIN_CURRENCY", "is_main_currency", "IS_MAIN", "ZISMAIN")
23
+
24
+ DEFAULT_TYPE_MAP = {0: "income", 1: "expense", 2: "transfer", 3: "transfer", 4: "transfer"}
25
+ STRING_TYPE_MAP = {
26
+ "income": "income",
27
+ "in": "income",
28
+ "0": "income",
29
+ "expense": "expense",
30
+ "out": "expense",
31
+ "1": "expense",
32
+ "transfer": "transfer",
33
+ "trans": "transfer",
34
+ "2": "transfer",
35
+ "3": "transfer",
36
+ }
@@ -0,0 +1,104 @@
1
+ Metadata-Version: 2.4
2
+ Name: moneymanager-parser
3
+ Version: 0.1.0
4
+ Summary: Typed parser and query SDK for Realbyte Money Manager .mmbak backups.
5
+ Project-URL: Homepage, https://github.com/shubham1172/moneymanager-parser
6
+ Project-URL: Repository, https://github.com/shubham1172/moneymanager-parser
7
+ Project-URL: Issues, https://github.com/shubham1172/moneymanager-parser/issues
8
+ Author-email: Shubham Sharma <shubhamsharma1172@gmail.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: finance,mmbak,money-manager,realbyte,sqlite
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.10
22
+ Provides-Extra: dev
23
+ Requires-Dist: build>=1.2; extra == 'dev'
24
+ Requires-Dist: mypy>=1.10; extra == 'dev'
25
+ Requires-Dist: pytest-cov>=5.0; extra == 'dev'
26
+ Requires-Dist: pytest>=8.2; extra == 'dev'
27
+ Requires-Dist: ruff>=0.5; extra == 'dev'
28
+ Description-Content-Type: text/markdown
29
+
30
+ # moneymanager-parser
31
+
32
+ [![CI](https://github.com/shubham1172/moneymanager-parser/actions/workflows/ci.yml/badge.svg)](https://github.com/shubham1172/moneymanager-parser/actions/workflows/ci.yml)
33
+ ![PyPI](https://img.shields.io/badge/PyPI-unpublished-lightgrey)
34
+
35
+ Typed Python SDK and offline CLI for Realbyte Money Manager `.mmbak` exports.
36
+
37
+ A `.mmbak` export is a ZIP-wrapped SQLite database. This package reads the export locally,
38
+ resolves common schema aliases across app versions, and exposes transactions, summaries, flexible
39
+ queries, schema inspection, categories, accounts, and currencies. Core parsing uses only the Python
40
+ standard library. Income is exposed by the API, but expense-oriented analysis should treat it as
41
+ informational because Money Manager exports can vary by installation.
42
+
43
+ The backup's currencies are read from the `CURRENCY` table when present. `currency()` returns the main
44
+ currency (`ISO`, `symbol`, `name`); amounts are stored in the account/transaction currency and are not
45
+ converted.
46
+
47
+ ## Install
48
+
49
+ After the first release:
50
+
51
+ ```bash
52
+ pip install moneymanager-parser
53
+ ```
54
+
55
+ ## Quickstart
56
+
57
+ ```python
58
+ from moneymanager_parser import MoneyManagerBackup
59
+
60
+ with MoneyManagerBackup.from_file("backup.mmbak") as backup:
61
+ main = backup.currency()
62
+ if main:
63
+ print(main.iso, main.symbol)
64
+ for txn in backup.transactions():
65
+ print(txn.date, txn.kind, txn.category, txn.amount)
66
+ print(backup.query(month="2026-01", group_by="category").as_dict())
67
+ ```
68
+
69
+ Custom transaction type maps are supported:
70
+
71
+ ```python
72
+ MoneyManagerBackup.from_file("backup.mmbak", type_map={0: "income", 1: "expense", 7: "transfer"})
73
+ ```
74
+
75
+ ## CLI
76
+
77
+ ```bash
78
+ mmbak query backup.mmbak --month 2026-01 --group-by category --top 10
79
+ mmbak schema backup.mmbak
80
+ mmbak currency backup.mmbak
81
+ ```
82
+
83
+ All commands print JSON and never contact external services.
84
+
85
+ ## Development
86
+
87
+ ```bash
88
+ python3 -m venv .venv
89
+ . .venv/bin/activate
90
+ pip install -e '.[dev]'
91
+ ruff check .
92
+ ruff format --check .
93
+ mypy src
94
+ pytest --cov
95
+ ```
96
+
97
+ ## Contributing
98
+
99
+ Issues and pull requests are welcome. Please include tests for schema variants and avoid committing
100
+ personal exports; test backups should be generated synthetically.
101
+
102
+ ## License
103
+
104
+ MIT © 2026 Shubham Sharma
@@ -0,0 +1,11 @@
1
+ moneymanager_parser/__init__.py,sha256=r-dBbZhURX0KCQsn87hM_U0wHcEGYKolr7KD69Vz0cU,311
2
+ moneymanager_parser/cli.py,sha256=XaL_oe-_0JbTxzmxqvdgE4A26g8BN9JrlZtpw-MIgKc,2874
3
+ moneymanager_parser/core.py,sha256=R42JOpDWrG6VZVK1PhLqF377r-O8EVbRWH3b_tNS-7s,19734
4
+ moneymanager_parser/models.py,sha256=cSupPAAJWGH8Pd7HINMjTIZmSZwWdv5tc0iFZBcJh0w,1618
5
+ moneymanager_parser/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ moneymanager_parser/schema.py,sha256=37bQbbMMzwqrBBTgxrI184nLOOLOVhl6dABJ_3SiPow,1559
7
+ moneymanager_parser-0.1.0.dist-info/METADATA,sha256=nn7mGiLZJQc_VD-aVeZ-qLsHsOqOjcy84HyvHAbdD40,3480
8
+ moneymanager_parser-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
9
+ moneymanager_parser-0.1.0.dist-info/entry_points.txt,sha256=M1bUPmBeQRw1Qaphkywo8RmpbzJ0m67t2HdIkZeUM9g,55
10
+ moneymanager_parser-0.1.0.dist-info/licenses/LICENSE,sha256=U9afe1B-vmXT-7exsQJLiYToBjmWuUgKuEtue-Vqopk,1071
11
+ moneymanager_parser-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ mmbak = moneymanager_parser.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Shubham Sharma
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.