diff --git a/.gitignore b/.gitignore index d4b323f..369601a 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,7 @@ /build/ /.idea/ /**/__pycache__/ +# +.project +.pydevproject +.directory diff --git a/README.rst b/README.rst index 2660773..d50b4c6 100644 --- a/README.rst +++ b/README.rst @@ -1,8 +1,32 @@ -kmy KMyMoney_ data file reader -================================ +kmy: KMyMoney_ Data File Reader +=============================== + +`kmy` is a free and open-source Pyton library for reading the XML file +format of the KMyMoney personal finance software +(`kmymoney.org `__). + +It is not directly affiliated with / sponsored or coordinated by the +developers of the KMyMoney project. + +Compatibility +------------- + +This package is compatible with V. 5.2.x of KMyMoney. Files generated +with V. 5.1.x are not supported any more. + +Major Changes +------------- + +V. 0.0.3 → 0.1 +~~~~~~~~~~~~~~ + +- Adaptations to new file format (KMM V. 5.2.x) +- New entities: currencies, securities +- Improved entities / attribute coverage for tags and payees Usage ------------- +----- + `kmy` is straightforward to use (Python 3 tested only) - e.g. .. code-block:: python @@ -28,4 +52,4 @@ There are still some outliers like `tags` that are not yet implemented. Feel free to submit Issues or MRs. It works for what I want for now, but we shall see how or if it evolves :-) -.. _KMyMoney: https://kmymoney.org/ \ No newline at end of file +.. _KMyMoney: https://kmymoney.org/ diff --git a/kmy/__init__.py b/kmy/__init__.py index 65be0e7..9ca5037 100644 --- a/kmy/__init__.py +++ b/kmy/__init__.py @@ -11,5 +11,7 @@ from .subaccount import SubAccount from .tag import Tag from .transaction import Transaction +from .currency import Currency +from .security import Security from .user import User from .useraddress import UserAddress diff --git a/kmy/currency.py b/kmy/currency.py new file mode 100644 index 0000000..4ca68de --- /dev/null +++ b/kmy/currency.py @@ -0,0 +1,33 @@ +from typing import Dict +from typing import List + + +class Currency: + def __init__(self): + self.id: str = "" + self.type: int = 0 + self.name: str = "" + self.symbol: str = "" + self.roundingMethod: int = 0 + self.saf: int = 0 + self.pp: int = 0 + self.scf: int = 0 + + def __repr__(self): + return f"{self.__class__.__name__}(name='{self.name}')" + + @classmethod + def from_xml(cls, node): + currency = cls() + currency.init_from_xml(node) + return currency + + def init_from_xml(self, node): + self.id = node.attrib['id'] + self.type = node.attrib['type'] + self.name = node.attrib['name'] + self.symbol = node.attrib['symbol'] + self.roundingMethod = node.attrib['rounding-method'] + self.saf = node.attrib['saf'] + self.pp = node.attrib['pp'] + self.scf = node.attrib['scf'] diff --git a/kmy/fileinfo.py b/kmy/fileinfo.py index 10cae0e..613212c 100644 --- a/kmy/fileinfo.py +++ b/kmy/fileinfo.py @@ -4,6 +4,7 @@ def __init__(self): self.lastModifiedDate: str = "" self.version: str = "" self.fixVersion: str = "" + self.appVersion: str = "" def __repr__(self): return f"{self.__class__.__name__}(creationDate='{self.creationDate}', lastModifiedDate={self.lastModifiedDate})" @@ -15,4 +16,5 @@ def from_xml(cls, node): file_info.lastModifiedDate = node.find('LAST_MODIFIED_DATE').attrib['date'] file_info.version = node.find('VERSION').attrib['id'] file_info.fixVersion = node.find('FIXVERSION').attrib['id'] + file_info.appVersion = node.find('APPVERSION').attrib['id'] return file_info diff --git a/kmy/kmy.py b/kmy/kmy.py index d936eda..8353005 100644 --- a/kmy/kmy.py +++ b/kmy/kmy.py @@ -5,10 +5,12 @@ from .account import Account from .costcenter import CostCenter +from .currency import Currency from .fileinfo import FileInfo from .institution import Institution from .pairs import pairs_dict_from_xml from .payee import Payee +from .security import Security from .tag import Tag from .transaction import Transaction from .user import User @@ -24,6 +26,8 @@ def __init__(self): self.tags: List[Tag] = [] self.accounts: List[Account] = [] self.transactions: List[Transaction] = [] + self.securities: List[Security] = [] + self.currencies: List[Currency] = [] self.keyValuePairs: Dict[str, str] = {} def __repr__(self): @@ -37,31 +41,52 @@ def from_xml(cls, node): def init_from_xml(self, node): self.fileInfo = FileInfo.from_xml(node.find('FILEINFO')) + self.user = User.from_xml(node.find('USER')) + institution_nodes = node.find('INSTITUTIONS') for institution_node in institution_nodes: institution = Institution.from_xml(institution_node) self.institutions.append(institution) + payee_nodes = node.find('PAYEES') for payee_node in payee_nodes: payee = Payee.from_xml(payee_node) self.payees.append(payee) + + # Cost-centers, according to KMyMoney's official + # documentation, are not used yet/reserved for + # future versions. costcenter_nodes = node.find('COSTCENTERS') for costcenter_node in costcenter_nodes: costcenter = CostCenter.from_xml(costcenter_node) self.costCenters.append(costcenter) + tag_nodes = node.find('TAGS') for tag_node in tag_nodes: tag = Tag.from_xml(tag_node) self.tags.append(tag) + account_nodes = node.find('ACCOUNTS') for account_node in account_nodes: account = Account.from_xml(account_node) self.accounts.append(account) + transaction_nodes = node.find('TRANSACTIONS') for transaction_node in transaction_nodes: transaction = Transaction.from_xml(transaction_node) self.transactions.append(transaction) + + security_nodes = node.find('SECURITIES') + for security_node in security_nodes: + security = Security.from_xml(security_node) + self.securities.append(security) + + currency_nodes = node.find('CURRENCIES') + for currency_node in currency_nodes: + currency = Currency.from_xml(currency_node) + self.currencies.append(currency) + self.keyValuePairs = pairs_dict_from_xml(node.find('KEYVALUEPAIRS')) @classmethod diff --git a/kmy/payee.py b/kmy/payee.py index 9b22659..ddc057e 100644 --- a/kmy/payee.py +++ b/kmy/payee.py @@ -9,12 +9,16 @@ def __init__(self): self.reference: str = "" self.name: str = "" self.email: str = "" + self.notes: str = "" self.id: str = "" - self.matchingEnabled: bool = False self.address: PayeeAddress = PayeeAddress() self.payeeIdentifiers: List[Dict[str, str]] = list() - self.usingMatchkey: bool = False - self.matchkeys: List[str] + self.matchingEnabled: bool = False + self.matchIgnoreCase: bool = False + self.usingMatchKey: bool = False + self.matchKeys: List[str] # yes, one or several of them in one field + self.idPattern: str = "" + self.urlTemplate : str = "" def __repr__(self): return f"{self.__class__.__name__}(name='{self.name}')" @@ -29,18 +33,26 @@ def init_from_xml(self, node): self.reference = node.attrib['reference'] self.name = node.attrib['name'] self.email = node.attrib['email'] + self.notes = node.attrib['notes'] if "notes" in node.attrib else "" self.id = node.attrib['id'] - self.matchingEnabled = (node.attrib['matchingenabled'] != '0') - self.usingMatchkey = (node.attrib["usingmatchkey"] != "0") if "usingmatchkey" in node.attrib else False - self.matchkeys = node.attrib["matchkey"].split(" ") if "matchkeys" in node.attrib else [] + address_node = node.find('ADDRESS') if address_node is not None: self.address = PayeeAddress.from_xml(address_node) + payee_identifier_nodes = node.findall("payeeIdentifier") if payee_identifier_nodes is not None: for payee_identifier_node in payee_identifier_nodes: self.payeeIdentifiers.append(payee_identifier_node.attrib.copy()) + self.matchingEnabled = (node.attrib['matchingenabled'] != "0") + self.matchIgnoreCase = (node.attrib['matchignorecase'] != "0") if "matchignorecase" in node.attrib else False + self.usingMatchKey = (node.attrib["usingmatchkey"] != "0") if "usingmatchkey" in node.attrib else False + self.matchKeys = node.attrib["matchkey"].split(" ") if "matchkey" in node.attrib else [] + + self.idPattern = node.attrib['idpattern'] if "idpattern" in node.attrib else "" + self.urlTemplate = node.attrib['urltemplate'] if "urltemplate" in node.attrib else "" + def matched_by(self, **kwargs: Dict[str, str]) -> bool: def is_match(candidate: Dict[str, str]) -> bool: for key, val in kwargs.items(): diff --git a/kmy/security.py b/kmy/security.py new file mode 100644 index 0000000..aa00fff --- /dev/null +++ b/kmy/security.py @@ -0,0 +1,39 @@ +from typing import Dict +from typing import List + +from .pairs import pairs_dict_from_xml + + +class Security: + def __init__(self): + self.id: str = "" + self.type: int = 0 + self.name: str = "" + self.symbol: str = "" + self.roundingMethod: int = 0 + self.saf: int = 0 + self.pp: int = 0 + self.tradingCurrency: str = "" + self.tradingMarket: str = "" + self.keyValuePairs: Dict[str, str] = {} + + def __repr__(self): + return f"{self.__class__.__name__}(name='{self.name}')" + + @classmethod + def from_xml(cls, node): + security = cls() + security.init_from_xml(node) + return security + + def init_from_xml(self, node): + self.id = node.attrib['id'] + self.type = node.attrib['type'] + self.name = node.attrib['name'] + self.symbol = node.attrib['symbol'] + self.roundingMethod = node.attrib['rounding-method'] + self.saf = node.attrib['saf'] + self.pp = node.attrib['pp'] + self.tradingCurrency = node.attrib['trading-currency'] + self.tradingMarket = node.attrib['trading-market'] + self.keyValuePairs = pairs_dict_from_xml(node.find('KEYVALUEPAIRS')) diff --git a/kmy/tag.py b/kmy/tag.py index d616fcc..bbf4c88 100644 --- a/kmy/tag.py +++ b/kmy/tag.py @@ -1,8 +1,9 @@ class Tag: def __init__(self): self.closed: bool = False - self.tagColor: str = "" + self.color: str = "" self.name: str = "" + self.notes: str = "" self.id: str = "" def __repr__(self): @@ -16,6 +17,7 @@ def from_xml(cls, node): def init_from_xml(self, node): self.closed = (node.attrib['closed'] != '0') - self.tagColor = node.attrib['tagcolor'] + self.color = node.attrib['tagcolor'] self.name = node.attrib['name'] + self.notes = node.attrib['notes'] if "notes" in node.attrib else "" self.id = node.attrib['id'] diff --git a/setup.py b/setup.py index 45987c2..d4e3725 100644 --- a/setup.py +++ b/setup.py @@ -1,10 +1,11 @@ from setuptools import setup, find_packages -VERSION = '0.0.3' +VERSION = '0.1' DESCRIPTION = 'KMyMoney (.kmy) file parser' URL = 'https://github.com/timerickson/kmy' LONG_DESCRIPTION = 'A simply library to read and provide typed access to KMyMoney data in .kmy files.' \ - 'It currently only supports readonly access.' + 'It currently only supports read-only access.' \ + 'Only XML-based files, not SQLite-based ones.' # Setting up setup( @@ -26,4 +27,4 @@ "Operating System :: MacOS :: MacOS X", "Operating System :: Microsoft :: Windows", ] -) \ No newline at end of file +) diff --git a/tests/Test.kmy b/tests/Test.kmy index 03b6bc0..90bea83 100644 Binary files a/tests/Test.kmy and b/tests/Test.kmy differ diff --git a/tests/Test.kmy.1~ b/tests/Test.kmy.1~ deleted file mode 100644 index 0b37a30..0000000 Binary files a/tests/Test.kmy.1~ and /dev/null differ diff --git a/tests/Test.kmy.2~ b/tests/Test.kmy.2~ deleted file mode 100644 index 9d1ca6d..0000000 Binary files a/tests/Test.kmy.2~ and /dev/null differ diff --git a/tests/Test.kmy.xml b/tests/Test.kmy.xml deleted file mode 100644 index 2eba809..0000000 --- a/tests/Test.kmy.xml +++ /dev/null @@ -1,136 +0,0 @@ - - - - - - - - - - -
- - - -
- - - - - - - - -
- - -
- - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/tests/test_account.py b/tests/test_account.py index 4e0a421..f17d4ff 100644 --- a/tests/test_account.py +++ b/tests/test_account.py @@ -11,7 +11,7 @@ def setUp(self): self.account0 = self.accounts[0] def test_read_accounts_count(self): - self.assertEqual(11, len(self.accounts)) + self.assertEqual(13, len(self.accounts)) def test_read_number(self): self.assertEqual('', self.account0.number) @@ -47,7 +47,7 @@ def test_read_id(self): self.assertEqual('AStd::Asset', self.account0.id) def test_read_subaccounts(self): - self.assertEqual(1, len(self.account0.subAccounts)) + self.assertEqual(2, len(self.account0.subAccounts)) def test_read_subaccount_id(self): self.assertEqual('A000001', self.account0.subAccounts[0].id) diff --git a/tests/test_currency.py b/tests/test_currency.py new file mode 100644 index 0000000..d16b001 --- /dev/null +++ b/tests/test_currency.py @@ -0,0 +1,42 @@ +import unittest +from kmy.kmy import Kmy + +file_name = 'Test.kmy' + + +class TestCurrency(unittest.TestCase): + def setUp(self): + mm = Kmy.from_kmy_file(file_name) + self.currencies = mm.currencies + self.curr0 = self.currencies[0] + + def test_read_currencies_count(self): + self.assertEqual(1, len(self.currencies)) + + def test_read_id(self): + self.assertEqual("USD", self.curr0.id) + + def test_read_name(self): + self.assertEqual("US-Dollar", self.curr0.name) + + def test_read_symbol(self): + self.assertEqual("$", self.curr0.symbol) + + def test_read_type(self): + self.assertEqual("3", self.curr0.type) + + def test_read_rounding_method(self): + self.assertEqual("7", self.curr0.roundingMethod) + + def test_read_saf(self): + self.assertEqual("100", self.curr0.saf) + + def test_read_pp(self): + self.assertEqual("4", self.curr0.pp) + + def test_read_scf(self): + self.assertEqual("100", self.curr0.scf) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_fileinfo.py b/tests/test_fileinfo.py index 4daa082..bf2aca8 100644 --- a/tests/test_fileinfo.py +++ b/tests/test_fileinfo.py @@ -13,13 +13,16 @@ def test_read_creationDate(self): self.assertEqual('2020-12-13', self.fileInfo.creationDate) def test_read_lastModifiedDate(self): - self.assertEqual('2020-12-13', self.fileInfo.lastModifiedDate) + self.assertEqual('2025-10-03T12:53:29+02:00', self.fileInfo.lastModifiedDate) def test_read_version(self): self.assertEqual('1', self.fileInfo.version) def test_read_fixVersion(self): - self.assertEqual('5', self.fileInfo.fixVersion) + self.assertEqual('9', self.fileInfo.fixVersion) + + def test_read_appVersion(self): + self.assertEqual('5.2.1-c9bd024', self.fileInfo.appVersion) if __name__ == '__main__': diff --git a/tests/test_institution.py b/tests/test_institution.py index dca1bb2..773c282 100644 --- a/tests/test_institution.py +++ b/tests/test_institution.py @@ -14,7 +14,7 @@ def test_read_institutions_count(self): self.assertEqual(1, len(self.institutions)) def test_read_institution_sortcode(self): - self.assertEqual('Routing number', self.institution0.sortcode) + self.assertEqual('Routing number', self.institution0.sortCode) def test_read_institution_manager(self): self.assertEqual('', self.institution0.manager) diff --git a/tests/test_institutionaddress.py b/tests/test_institution_address.py similarity index 100% rename from tests/test_institutionaddress.py rename to tests/test_institution_address.py diff --git a/tests/test_payeeaddress.py b/tests/test_payee_address.py similarity index 100% rename from tests/test_payeeaddress.py rename to tests/test_payee_address.py diff --git a/tests/test_security.py b/tests/test_security.py new file mode 100644 index 0000000..4387898 --- /dev/null +++ b/tests/test_security.py @@ -0,0 +1,45 @@ +import unittest +from kmy.kmy import Kmy + +file_name = 'Test.kmy' + + +class TestSecurity(unittest.TestCase): + def setUp(self): + mm = Kmy.from_kmy_file(file_name) + self.securities = mm.securities + self.sec0 = self.securities[0] + + def test_read_securities_count(self): + self.assertEqual(1, len(self.securities)) + + def test_read_id(self): + self.assertEqual("E000001", self.sec0.id) + + def test_read_name(self): + self.assertEqual("3M", self.sec0.name) + + def test_read_symbol(self): + self.assertEqual("MMM", self.sec0.symbol) + + def test_read_type(self): + self.assertEqual("0", self.sec0.type) + + def test_read_rounding_method(self): + self.assertEqual("7", self.sec0.roundingMethod) + + def test_read_saf(self): + self.assertEqual("100", self.sec0.saf) + + def test_read_pp(self): + self.assertEqual("4", self.sec0.pp) + + def test_read_trading_currency(self): + self.assertEqual("USD", self.sec0.tradingCurrency) + + def test_read_trading_market(self): + self.assertEqual("NYSE", self.sec0.tradingMarket) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_tag.py b/tests/test_tag.py index aa56814..9914f3f 100644 --- a/tests/test_tag.py +++ b/tests/test_tag.py @@ -17,7 +17,7 @@ def test_read_closed(self): self.assertEqual(False, self.tag0.closed) def test_read_tagcolor(self): - self.assertEqual('#000000', self.tag0.tagColor) + self.assertEqual('#000000', self.tag0.color) def test_read_name(self): self.assertEqual('Bar Tag', self.tag0.name)