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.
- moneymanager_parser/__init__.py +15 -0
- moneymanager_parser/cli.py +86 -0
- moneymanager_parser/core.py +559 -0
- moneymanager_parser/models.py +63 -0
- moneymanager_parser/py.typed +0 -0
- moneymanager_parser/schema.py +36 -0
- moneymanager_parser-0.1.0.dist-info/METADATA +104 -0
- moneymanager_parser-0.1.0.dist-info/RECORD +11 -0
- moneymanager_parser-0.1.0.dist-info/WHEEL +4 -0
- moneymanager_parser-0.1.0.dist-info/entry_points.txt +2 -0
- moneymanager_parser-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
+
[](https://github.com/shubham1172/moneymanager-parser/actions/workflows/ci.yml)
|
|
33
|
+

|
|
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,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.
|