diff --git a/.gitignore b/.gitignore
index 512a53f..64d047b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -54,6 +54,9 @@ output/*/index.html
docs/_build
/docs/doctrees/
/docs/html/
+# API reference pages, regenerated from docs/conf.py on every build
+/docs/modules.rst
+/docs/mt940*.rst
.aider*
.env
diff --git a/README.md b/README.md
index 3e6ffa2..2b3e34d 100644
--- a/README.md
+++ b/README.md
@@ -8,71 +8,70 @@
[](https://coveralls.io/github/WoLpH/mt940?branch=develop)
[](https://github.com/WoLpH/mt940/blob/develop/LICENSE)
-`mt940` is a library to parse MT940 files and return smart Python collections
-for statistics and manipulation. It has no runtime dependencies, is fully type
-annotated (`py.typed`), and ships smart models that make working with bank
-statements straightforward.
-
-## Quick Start
-
-```bash
-pip install mt-940
-```
+`mt940` parses **MT940 bank statement files** into smart, fully typed Python
+collections you can iterate, aggregate and serialize. It has no runtime
+dependencies, ships type information (`py.typed`), and copes with the quirks of
+many real-world banks.
```python
import mt940
-import pprint
-
-transactions = mt940.parse('mt940_tests/jejik/abnamro.sta')
-
-print('Transactions:')
-print(transactions)
-pprint.pprint(transactions.data)
+transactions = mt940.parse('statement.sta')
for transaction in transactions:
- print('Transaction: ', transaction)
- pprint.pprint(transaction.data)
+ print(transaction.data['date'], transaction.data['amount'])
```
-## Usage
+## Why mt940
-### Set opening / closing balance information on each transaction
+- **Zero runtime dependencies** — pure standard library.
+- **Fully typed** — ships `py.typed`; checked under pyright, mypy and pyrefly.
+- **Battle-tested** — 100% test coverage against fixtures from many banks.
+- **Smart models** — amounts, balances and dates come back as rich Python
+ objects, not raw strings.
+- **JSON-ready** — a single encoder serializes a whole statement.
+- **Extensible** — opt-in tags and pre/post processors for bank-specific
+ formats.
+- **Modern Python** — supports 3.10 through 3.13.
-```python
-import mt940
-import pprint
+## Installation
-mt940.tags.BalanceBase.scope = mt940.models.Transaction
+```bash
+pip install mt-940
+```
-# The currency has to be set manually when setting the BalanceBase scope to
-# Transaction.
-transactions = mt940.models.Transactions(processors=dict(
- pre_statement=[
- mt940.processors.add_currency_pre_processor('EUR'),
- ],
-))
+Using [uv](https://docs.astral.sh/uv/):
-with open('mt940_tests/jejik/abnamro.sta') as f:
- data = f.read()
+```bash
+uv add mt-940
+```
-transactions.parse(data)
+Requires Python 3.10 or newer.
-for transaction in transactions:
- print('Transaction: ', transaction)
- pprint.pprint(transaction.data)
-```
+## Quick start
-### Simple JSON encoding
+`parse()` accepts a filename, an open file handle, or the raw `str`/`bytes`
+contents, and returns a `Transactions` collection:
```python
-import json
import mt940
+import pprint
transactions = mt940.parse('mt940_tests/jejik/abnamro.sta')
-print(json.dumps(transactions, indent=4, cls=mt940.JSONEncoder))
+# Statement-level data (balances, account, ...) lives on the collection:
+pprint.pprint(transactions.data)
+
+# Iterate the individual transactions:
+for transaction in transactions:
+ print(transaction.data['date'], transaction.data['amount'])
+ pprint.pprint(transaction.data)
```
+Each `Transaction` exposes a `data` dictionary with the parsed fields. Which
+fields are present depends on the source bank and the tags in the file.
+
+## Usage
+
### Reading balances
Statement-level balances live on the `Transactions` object's `data`, not on the
@@ -87,13 +86,34 @@ print(transactions.data['final_closing_balance'])
print(transactions.data['available_balance'])
```
+### Set opening / closing balance on each transaction
+
+```python
+import mt940
+import pprint
+
+mt940.tags.BalanceBase.scope = mt940.models.Transaction
+
+# The currency has to be set manually when moving the BalanceBase scope to
+# Transaction.
+transactions = mt940.models.Transactions(processors=dict(
+ pre_statement=[mt940.processors.add_currency_pre_processor('EUR')],
+))
+
+with open('mt940_tests/jejik/abnamro.sta') as fh:
+ transactions.parse(fh.read())
+
+for transaction in transactions:
+ pprint.pprint(transaction.data)
+```
+
### Multiple statements in one file
A single `parse()` merges everything into one `Transactions` and keeps only the
-**last** block's statement-level data (e.g. balances). For files that concatenate
-several statements (including balance-only blocks), use `parse_statements()`,
-which splits on `:20:` boundaries and returns one `Transactions` per statement,
-each with its own balances:
+**last** block's statement-level data (e.g. balances). For files that
+concatenate several statements (including balance-only blocks), use
+`parse_statements()`, which splits on `:20:` boundaries and returns one
+`Transactions` per statement, each with its own balances:
```python
import mt940
@@ -104,47 +124,35 @@ for statement in mt940.parse_statements('statements.sta'):
print(statement.data['final_closing_balance'])
```
-### Parsing statements from the Dutch bank ASN
-
-Tag 61 in ASN statements does not follow the SWIFT specification, so a custom
-tag is used:
+### Serializing to JSON
```python
-import pprint
+import json
import mt940
-
-def ASNB_mt940_data():
- with open('mt940_tests/ASNB/0708271685_09022020_164516.940.txt') as fh:
- return fh.read()
-
-
-tag_parser = mt940.tags.StatementASNB()
-trs = mt940.models.Transactions(tags={tag_parser.id: tag_parser})
-trs.parse(ASNB_mt940_data())
-
-print(pprint.pformat(trs.data, sort_dicts=False))
+transactions = mt940.parse('statement.sta')
+print(json.dumps(transactions, indent=4, cls=mt940.JSONEncoder))
```
### Transaction grouping (opt-in)
-By default a new transaction is started only on the `:61:` statement tag.
-Some banks delimit transactions differently — for example by repeating the
-`:20:` transaction reference per block. Because changing the default grouping
-would break existing users, this behaviour is **opt-in**: pass
-`transaction_boundary` (an iterable of tag *slugs*) to start a new transaction
-on those tags too. Omitting it preserves the historical behaviour.
+By default a new transaction is started only on the `:61:` statement tag. Some
+banks delimit transactions differently — for example by repeating the `:20:`
+transaction reference per block. Because changing the default grouping would
+break existing users, this behaviour is **opt-in**: pass `transaction_boundary`
+(an iterable of tag *slugs*) to start a new transaction on those tags too.
```python
import mt940
# Each `:20:` (transaction_reference_number) starts its own transaction:
transactions = mt940.parse(
- data, transaction_boundary={'transaction_reference_number'}
+ 'statement.sta', transaction_boundary={'transaction_reference_number'}
)
```
-The same option is accepted by `mt940.models.Transactions(transaction_boundary=...)`.
+The same option is accepted by
+`mt940.models.Transactions(transaction_boundary=...)`.
### Banks with longer reference fields (opt-in)
@@ -157,12 +165,49 @@ same-line data, so this is handled by an **opt-in** `StatementGLS` tag:
import mt940
gls = mt940.tags.StatementGLS()
-transactions = mt940.parse(data, tags={gls.id: gls})
+transactions = mt940.parse('statement.sta', tags={gls.id: gls})
```
(Longer *supplementary details* — issue #117, e.g. Wise — are handled by the
default parser and need no opt-in.)
+### Statements from the Dutch bank ASN (opt-in)
+
+Tag 61 in ASN statements does not follow the SWIFT specification, so the opt-in
+`StatementASNB` tag is used:
+
+```python
+import mt940
+import pprint
+
+tag = mt940.tags.StatementASNB()
+transactions = mt940.models.Transactions(tags={tag.id: tag})
+
+with open('mt940_tests/ASNB/mt940.txt') as fh:
+ transactions.parse(fh.read())
+
+pprint.pprint(transactions.data, sort_dicts=False)
+```
+
+## Supported tags
+
+| Tag | Meaning |
+| --- | --- |
+| `:13:` | Date/time the report was created |
+| `:20:` | Transaction reference number |
+| `:21:` | Related reference |
+| `:25:` | Account identification |
+| `:28C:` | Statement number / sequence number |
+| `:34F:` | Floor limit for debit and credit |
+| `:60F:` / `:60M:` | (Final / intermediate) opening balance |
+| `:61:` | Statement line (a transaction) |
+| `:62F:` / `:62M:` | (Final / intermediate) closing balance |
+| `:64:` | Available balance |
+| `:65:` | Forward available balance |
+| `:86:` | Transaction details |
+| `:90C:` / `:90D:` | Number and sum of credit / debit entries |
+| `:NS:` | Bank-specific non-SWIFT extensions |
+
## Contributing
Help is greatly appreciated. Please clone the **develop** branch and run `tox`
diff --git a/docs/conf.py b/docs/conf.py
index dd967ae..96891a0 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -1,315 +1,83 @@
-# !/usr/bin/env python
-#
-# complexity documentation build configuration file, created by
-# sphinx-quickstart on Tue Jul 9 22:26:36 2013.
-#
-# This file is execfile()d with the current directory set to its containing
-# dir.
-#
-# Note that not all possible configuration values are present in this
-# autogenerated file.
-#
-# All configuration values have a default; values that are commented out
-# serve to show the default.
+"""Sphinx configuration for the mt940 documentation.
-import os
-import sys
-
-# If extensions (or modules to document with autodoc) are in another directory,
-# add these directories to sys.path here. If the directory is relative to the
-# documentation root, use os.path.abspath to make it absolute, like shown here.
-# sys.path.insert(0, os.path.abspath('.'))
+The API reference pages are generated from the source on every build (locally,
+on Read the Docs and in CI) by running ``sphinx-apidoc`` from the
+``builder-inited`` hook below, so the generated ``mt940*.rst`` files do not need
+to be committed.
+"""
-# Get the project root dir, which is the parent dir of this
-docs_root = os.path.abspath(os.path.dirname(__file__))
-project_root = os.path.join(docs_root, os.path.pardir)
+from __future__ import annotations
-# Insert the project root dir as the first element in the PYTHONPATH.
-# This lets us ensure that the source package is imported, and that its
-# version is used.
-sys.path.insert(0, project_root)
+import os
+import sys
+from typing import TYPE_CHECKING
-# package data
-about = {}
-with open(os.path.join(project_root, 'mt940/__about__.py')) as fp:
- exec(fp.read(), about)
+if TYPE_CHECKING:
+ from sphinx.application import Sphinx
+DOCS_ROOT = os.path.abspath(os.path.dirname(__file__))
+PROJECT_ROOT = os.path.dirname(DOCS_ROOT)
+PACKAGE_ROOT = os.path.join(PROJECT_ROOT, 'mt940')
-import mt940
+# Make the package importable and single-source its metadata.
+sys.path.insert(0, PROJECT_ROOT)
+_about: dict[str, str] = {}
+with open(os.path.join(PACKAGE_ROOT, '__about__.py')) as _fh:
+ exec(_fh.read(), _about)
-assert mt940 is not None
+# -- Project information ------------------------------------------------------
+project = _about['__title__']
+author = _about['__author__']
+copyright = _about['__copyright__'] # noqa: A001
+release = _about['__version__']
+version = '.'.join(release.split('.')[:2])
# -- General configuration ----------------------------------------------------
-
-# If your documentation needs a minimal Sphinx version, state it here.
-# needs_sphinx = '1.0'
-
-# Add any Sphinx extension module names here, as strings. They can be
-# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
extensions = [
'sphinx.ext.autodoc',
- 'sphinx.ext.doctest',
- 'sphinx.ext.intersphinx',
- 'sphinx.ext.todo',
- 'sphinx.ext.coverage',
- 'sphinx.ext.mathjax',
- 'sphinx.ext.ifconfig',
- 'sphinx.ext.viewcode',
'sphinx.ext.napoleon',
+ 'sphinx.ext.viewcode',
+ 'sphinx.ext.intersphinx',
+ 'sphinx.ext.doctest',
]
-
-# Add any paths that contain templates here, relative to this directory.
-templates_path = ['_templates']
-
-# The suffix of source filenames.
-source_suffix = '.rst'
-
-# The encoding of source files.
-# source_encoding = 'utf-8-sig'
-
-# The master toctree document.
-master_doc = 'index'
-
-# General information about the project.
-project = about['__title__']
-copyright = f'{about["__copyright__"]}'
-
-# The version info for the project you're documenting, acts as replacement for
-# |version| and |release|, also used in various other places throughout the
-# built documents.
-#
-# The short X.Y version.
-version = '%s' % ('.'.join(about['__version__'].split('.'))[:2])
-# The full version, including alpha/beta/rc tags.
-release = '%s' % (about['__version__'])
-
-# The language for content autogenerated by Sphinx. Refer to documentation
-# for a list of supported languages.
-# language = None
-
-# There are two options for replacing |today|: either, you set today to some
-# non-false value, then it is used:
-# today = ''
-# Else, today_fmt is used as the format for a strftime call.
-# today_fmt = '%B %d, %Y'
-
-# List of patterns, relative to source directory, that match files and
-# directories to ignore when looking for source files.
-exclude_patterns = ['_build']
-
-# The reST default role (used for this markup: `text`) to use for all
-# documents.
-# default_role = None
-
-# If true, '()' will be appended to :func: etc. cross-reference text.
-# add_function_parentheses = True
-
-# If true, the current module name will be prepended to all description
-# unit titles (such as .. function::).
-# add_module_names = True
-
-# If true, sectionauthor and moduleauthor directives will be shown in the
-# output. They are ignored by default.
-# show_authors = False
-
-# The name of the Pygments (syntax highlighting) style to use.
-pygments_style = 'sphinx'
-
-# A list of ignored prefixes for module index sorting.
-# modindex_common_prefix = []
-
-# If true, keep warnings as 'system message' paragraphs in the built documents.
-# keep_warnings = False
-
-
-# -- Options for HTML output --------------------------------------------------
-
-# The theme to use for HTML and HTML Help pages. See the documentation for
-# a list of builtin themes.
-html_theme = 'furo'
-
-# Theme options are theme-specific and customize the look and feel of a theme
-# further. For a list of options available for each theme, see the
-# documentation.
-# html_theme_options = {}
-
-# Add any paths that contain custom themes here, relative to this directory.
-# html_theme_path = []
-
-# The name for this set of Sphinx documents. If None, it defaults to
-# ' v documentation'.
-# html_title = None
-
-# A shorter title for the navigation bar. Default is the same as html_title.
-# html_short_title = None
-
-# The name of an image file (relative to this directory) to place at the top
-# of the sidebar.
-# html_logo = None
-
-# The name of an image file (within the static path) to use as favicon of the
-# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32
-# pixels large.
-# html_favicon = None
-
-# Add any paths that contain custom static files (such as style sheets) here,
-# relative to this directory. They are copied after the builtin static files,
-# so a file named 'default.css' will overwrite the builtin 'default.css'.
-# html_static_path = ['_static']
-
-# If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
-# using the given strftime format.
-# html_last_updated_fmt = '%b %d, %Y'
-
-# If true, SmartyPants will be used to convert quotes and dashes to
-# typographically correct entities.
-# html_use_smartypants = True
-
-# Custom sidebar templates, maps document names to template names.
-# html_sidebars = {}
-
-# Additional templates that should be rendered to pages, maps page names to
-# template names.
-# html_additional_pages = {}
-
-# If false, no module index is generated.
-# html_domain_indices = True
-
-# If false, no index is generated.
-# html_use_index = True
-
-# If true, the index is split into individual pages for each letter.
-# html_split_index = False
-
-# If true, links to the reST sources are added to the pages.
-# html_show_sourcelink = True
-
-# If true, 'Created using Sphinx' is shown in the HTML footer. Default is True.
-# html_show_sphinx = True
-
-# If true, '(C) Copyright ...' is shown in the HTML footer. Default is True.
-# html_show_copyright = True
-
-# If true, an OpenSearch description file will be output, and all pages will
-# contain a tag referring to it. The value of this option must be the
-# base URL from which the finished HTML is served.
-# html_use_opensearch = ''
-
-# This is the file name suffix for HTML files (e.g. '.xhtml').
-# html_file_suffix = None
-
-# Output file base name for HTML help builder.
-htmlhelp_basename = '%sdoc' % about['__title__']
-
-
-# -- Options for LaTeX output -------------------------------------------------
-
-latex_elements = {
- # The paper size ('letterpaper' or 'a4paper').
- # 'papersize': 'letterpaper',
- # The font size ('10pt', '11pt' or '12pt').
- # 'pointsize': '10pt',
- # Additional stuff for the LaTeX preamble.
- # 'preamble': '',
+exclude_patterns = ['_build', 'html', 'doctrees']
+
+# -- Autodoc / Napoleon -------------------------------------------------------
+autodoc_typehints = 'description'
+autodoc_member_order = 'bysource'
+autodoc_default_options = {
+ 'members': True,
+ 'show-inheritance': True,
}
+napoleon_google_docstring = True
+napoleon_numpy_docstring = False
-# Grouping the document tree into LaTeX files. List of tuples (source start
-# file, target name, title, author, documentclass [howto/manual]).
-latex_documents = [
- (
- 'index',
- '{0}.tex'.format(about['__package_name__']),
- '{0} Documentation'.format(about['__title__']),
- about['__author__'],
- 'manual',
- ),
-]
-
-# The name of an image file (relative to this directory) to place at the top of
-# the title page.
-# latex_logo = None
-
-# For 'manual' documents, if this is true, then toplevel headings are parts,
-# not chapters.
-# latex_use_parts = False
-
-# If true, show page references after internal links.
-# latex_show_pagerefs = False
-
-# If true, show URL addresses after external links.
-# latex_show_urls = False
-
-# Documents to append as an appendix to all manuals.
-# latex_appendices = []
-
-# If false, no module index is generated.
-# latex_domain_indices = True
-
-
-# -- Options for manual page output -------------------------------------------
-
-# One entry per manual page. List of tuples
-# (source start file, name, description, authors, manual section).
-man_pages = [
- (
- 'index',
- about['__package_name__'],
- '{0} Documentation'.format(about['__title__']),
- about['__author__'],
- 1,
- ),
-]
-
-# If true, show URL addresses after external links.
-# man_show_urls = False
-
-
-# -- Options for Texinfo output -----------------------------------------------
-
-# Grouping the document tree into Texinfo files. List of tuples
-# (source start file, target name, title, author,
-# dir menu entry, description, category)
-texinfo_documents = [
- (
- 'index',
- '{0}'.format(about['__package_name__']),
- '{0} Documentation'.format(about['__title__']),
- about['__author__'],
- about['__package_name__'],
- about['__description__'],
- 'Miscellaneous',
- ),
-]
-
-# Documents to append as an appendix to all manuals.
-# texinfo_appendices = []
-
-# If false, no module index is generated.
-# texinfo_domain_indices = True
-
-# How to display URL addresses: 'footnote', 'no', or 'inline'.
-# texinfo_show_urls = 'footnote'
-
-# If true, do not generate a @detailmenu in the 'Top' node's menu.
-# texinfo_no_detailmenu = False
-
-# Example configuration for intersphinx: refer to the Python standard library.
intersphinx_mapping = {
'python': ('https://docs.python.org/3/', None),
}
-# section names - optional
-changelog_sections = ['general', 'rendering', 'tests', 'docs']
+# -- HTML output --------------------------------------------------------------
+html_theme = 'furo'
+html_title = f'{project} {release}'
+
+
+# -- Generate the API reference on every build --------------------------------
+def _run_apidoc(app: Sphinx) -> None:
+ """Regenerate the ``mt940*.rst`` API pages from the package source."""
+ from sphinx.ext import apidoc
+
+ apidoc.main(
+ [
+ '--force',
+ '--separate',
+ '--module-first',
+ '--output-dir',
+ DOCS_ROOT,
+ PACKAGE_ROOT,
+ ]
+ )
-# tags to sort on inside of sections - also optional
-changelog_inner_tag_sort = ['feature', 'bug']
-# how to render changelog links - these are plain
-# python string templates, ticket/pullreq/changeset number goes
-# in '%s'
-github_url = 'https://github.com/'
-github_profile_url = github_url + 'WoLpH/'
-github_project_url = github_profile_url + 'mt940/'
-changelog_render_ticket = github_url + 'issues/%s'
-changelog_render_pullreq = github_url + 'pulls/%s'
-changelog_render_changeset = github_url + 'commit/%s'
+def setup(app: Sphinx) -> None:
+ """Connect the apidoc generation step to the build."""
+ app.connect('builder-inited', _run_apidoc)
diff --git a/docs/index.rst b/docs/index.rst
index b070c80..84bb9c6 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -1,10 +1,14 @@
Welcome to MT940's documentation!
=================================
-``mt940`` is a library to parse MT940 files and return smart Python collections
-for statistics and manipulation. See the project `README
-`_ for a feature overview and quick-start
-examples.
+``mt940`` parses MT940 bank statement files and returns smart, fully typed
+Python collections for statistics and manipulation. It has no runtime
+dependencies, ships type information (``py.typed``), and handles the quirks of
+many real-world banks.
+
+Start with :doc:`installation` and :doc:`usage`; the :doc:`modules` section
+contains the full API reference. See the project `README
+`_ for a feature overview.
Contents:
diff --git a/docs/installation.rst b/docs/installation.rst
index 3cbcf90..e7f5ec7 100644
--- a/docs/installation.rst
+++ b/docs/installation.rst
@@ -2,6 +2,8 @@
Installation
============
+``mt940`` requires Python 3.10 or newer and has no runtime dependencies.
+
Install the latest release from PyPI::
$ pip install mt-940
diff --git a/docs/modules.rst b/docs/modules.rst
deleted file mode 100644
index ff34c93..0000000
--- a/docs/modules.rst
+++ /dev/null
@@ -1,7 +0,0 @@
-mt940
-=====
-
-.. toctree::
- :maxdepth: 4
-
- mt940
diff --git a/docs/mt940.json.rst b/docs/mt940.json.rst
deleted file mode 100644
index 4451fa3..0000000
--- a/docs/mt940.json.rst
+++ /dev/null
@@ -1,7 +0,0 @@
-mt940.json module
-=================
-
-.. automodule:: mt940.json
- :members:
- :show-inheritance:
- :undoc-members:
diff --git a/docs/mt940.models.rst b/docs/mt940.models.rst
deleted file mode 100644
index 2db27a4..0000000
--- a/docs/mt940.models.rst
+++ /dev/null
@@ -1,7 +0,0 @@
-mt940.models module
-===================
-
-.. automodule:: mt940.models
- :members:
- :show-inheritance:
- :undoc-members:
diff --git a/docs/mt940.parser.rst b/docs/mt940.parser.rst
deleted file mode 100644
index 79275d1..0000000
--- a/docs/mt940.parser.rst
+++ /dev/null
@@ -1,7 +0,0 @@
-mt940.parser module
-===================
-
-.. automodule:: mt940.parser
- :members:
- :show-inheritance:
- :undoc-members:
diff --git a/docs/mt940.processors.rst b/docs/mt940.processors.rst
deleted file mode 100644
index 7198d22..0000000
--- a/docs/mt940.processors.rst
+++ /dev/null
@@ -1,7 +0,0 @@
-mt940.processors module
-=======================
-
-.. automodule:: mt940.processors
- :members:
- :show-inheritance:
- :undoc-members:
diff --git a/docs/mt940.rst b/docs/mt940.rst
deleted file mode 100644
index 308689c..0000000
--- a/docs/mt940.rst
+++ /dev/null
@@ -1,20 +0,0 @@
-mt940 package
-=============
-
-.. automodule:: mt940
- :members:
- :show-inheritance:
- :undoc-members:
-
-Submodules
-----------
-
-.. toctree::
- :maxdepth: 4
-
- mt940.json
- mt940.models
- mt940.parser
- mt940.processors
- mt940.tags
- mt940.utils
diff --git a/docs/mt940.tags.rst b/docs/mt940.tags.rst
deleted file mode 100644
index 269effd..0000000
--- a/docs/mt940.tags.rst
+++ /dev/null
@@ -1,7 +0,0 @@
-mt940.tags module
-=================
-
-.. automodule:: mt940.tags
- :members:
- :show-inheritance:
- :undoc-members:
diff --git a/docs/mt940.utils.rst b/docs/mt940.utils.rst
deleted file mode 100644
index cd87139..0000000
--- a/docs/mt940.utils.rst
+++ /dev/null
@@ -1,7 +0,0 @@
-mt940.utils module
-==================
-
-.. automodule:: mt940.utils
- :members:
- :show-inheritance:
- :undoc-members:
diff --git a/docs/usage.rst b/docs/usage.rst
index 3fbe466..e149f69 100644
--- a/docs/usage.rst
+++ b/docs/usage.rst
@@ -1,7 +1,118 @@
-========
+=====
Usage
-========
+=====
-To use MT940 in a project::
+``mt940`` turns raw MT940 statement files into rich Python objects. The
+high-level entry point is :func:`mt940.parse`, which accepts a filename, a file
+handle, or raw ``str``/``bytes`` and returns a
+:class:`mt940.models.Transactions` collection.
- import mt940
+Parsing a statement
+===================
+
+.. code-block:: python
+
+ import mt940
+
+ transactions = mt940.parse('statement.sta')
+
+ for transaction in transactions:
+ print(transaction.data['date'], transaction.data['amount'])
+
+Each :class:`~mt940.models.Transaction` exposes a ``data`` dictionary with the
+parsed fields. Which fields are present depends on the source bank and the tags
+in the file.
+
+Reading balances
+================
+
+Statement-level balances live on the collection's ``data``, not on the
+individual transactions. This works even for files that contain no
+transactions at all:
+
+.. code-block:: python
+
+ import mt940
+
+ transactions = mt940.parse('statement.sta')
+ print(transactions.data['final_opening_balance'])
+ print(transactions.data['final_closing_balance'])
+ print(transactions.data['available_balance'])
+
+Multiple statements in one file
+===============================
+
+A single :func:`mt940.parse` merges everything into one
+:class:`~mt940.models.Transactions` and keeps only the **last** block's
+statement-level data. For files that concatenate several statements, use
+:func:`mt940.parse_statements`, which splits on ``:20:`` boundaries and returns
+one collection per statement:
+
+.. code-block:: python
+
+ import mt940
+
+ for statement in mt940.parse_statements('statements.sta'):
+ print(statement.data['final_opening_balance'])
+ print(statement.data['final_closing_balance'])
+
+Serializing to JSON
+===================
+
+:class:`mt940.JSONEncoder` knows how to serialize the model objects:
+
+.. code-block:: python
+
+ import json
+ import mt940
+
+ transactions = mt940.parse('statement.sta')
+ print(json.dumps(transactions, indent=4, cls=mt940.JSONEncoder))
+
+Opt-in behaviours
+=================
+
+Some banks deviate from the SWIFT specification. These behaviours are opt-in so
+the defaults stay backwards compatible.
+
+Transaction grouping
+--------------------
+
+By default a new transaction is started only on the ``:61:`` statement tag.
+Pass ``transaction_boundary`` (an iterable of tag *slugs*) to also start a new
+transaction on those tags — for example to treat every ``:20:`` as a boundary:
+
+.. code-block:: python
+
+ import mt940
+
+ transactions = mt940.parse(
+ 'statement.sta', transaction_boundary={'transaction_reference_number'}
+ )
+
+Longer reference fields
+-----------------------
+
+Some banks (e.g. GLS / Atruvia) put a customer reference longer than the SWIFT
+16-character cap on the ``:61:`` line. Enable the opt-in
+:class:`mt940.tags.StatementGLS` tag for those:
+
+.. code-block:: python
+
+ import mt940
+
+ gls = mt940.tags.StatementGLS()
+ transactions = mt940.parse('statement.sta', tags={gls.id: gls})
+
+Statements from the Dutch bank ASN
+----------------------------------
+
+Tag 61 in ASN statements does not follow the SWIFT specification, so the opt-in
+:class:`mt940.tags.StatementASNB` tag is used:
+
+.. code-block:: python
+
+ import mt940
+
+ tag = mt940.tags.StatementASNB()
+ transactions = mt940.parse('statement.sta', tags={tag.id: tag})
diff --git a/mt940/__init__.py b/mt940/__init__.py
index 2e2ff6c..b5ce5f7 100644
--- a/mt940/__init__.py
+++ b/mt940/__init__.py
@@ -1,3 +1,29 @@
+"""mt940 — parse MT940 bank statement files into rich Python objects.
+
+The high-level entry point is :func:`parse`, which accepts a filename, a file
+handle, or raw ``str``/``bytes`` and returns a
+:class:`~mt940.models.Transactions` collection you can iterate over. Use
+:func:`parse_statements` for files that concatenate several statements, and
+:class:`JSONEncoder` to serialize the result to JSON.
+
+Example:
+ >>> import mt940
+ >>> data = (
+ ... ':20:REF\\n'
+ ... ':25:NL00BANK0123456789\\n'
+ ... ':28C:1/1\\n'
+ ... ':60F:C091019EUR1000,00\\n'
+ ... ':61:0910201020C500,00NTRFNONREF//B\\n'
+ ... ':86:Example transaction\\n'
+ ... ':62F:C091020EUR1500,00\\n'
+ ... )
+ >>> transactions = mt940.parse(data)
+ >>> len(transactions)
+ 1
+ >>> transactions[0].data['amount']
+ <500.00 EUR>
+"""
+
from . import json, models, parser, processors, tags, utils
from .__about__ import __version__
from .json import JSONEncoder
diff --git a/mt940/_types.py b/mt940/_types.py
new file mode 100644
index 0000000..9762d2a
--- /dev/null
+++ b/mt940/_types.py
@@ -0,0 +1,57 @@
+"""Shared type aliases and processor protocols.
+
+These are the precise types used across the public API. The module is a *leaf*:
+it imports nothing from the rest of the package, so it never participates in an
+import cycle. The processor protocols therefore type their ``transactions`` and
+``tag`` arguments as :data:`~typing.Any`; the concrete processor functions in
+:mod:`mt940.processors` still annotate them precisely.
+"""
+
+from __future__ import annotations
+
+import os
+from collections.abc import Callable
+from typing import IO, Any, Protocol
+
+#: Accepted input for :func:`mt940.parse` and :func:`mt940.parse_statements`:
+#: a path, raw ``str``/``bytes`` data, or an open binary/text file handle.
+Source = str | bytes | os.PathLike[str] | IO[str] | IO[bytes]
+
+#: A parsed tag dictionary. Intentionally dynamic: the available keys are
+#: tag- and bank-specific, so a precise ``TypedDict`` would misrepresent it.
+TagDict = dict[str, Any]
+
+
+class PreProcessor(Protocol):
+ """A callable run *before* a tag value is turned into a model object."""
+
+ def __call__(
+ self,
+ transactions: Any,
+ tag: Any,
+ tag_dict: TagDict,
+ /,
+ *args: Any,
+ ) -> TagDict: ...
+
+
+class PostProcessor(Protocol):
+ """A callable run *after* a tag value is turned into a model object."""
+
+ def __call__(
+ self,
+ transactions: Any,
+ tag: Any,
+ tag_dict: TagDict,
+ result: TagDict,
+ /,
+ ) -> TagDict: ...
+
+
+#: A pre- or post-processor as stored in :attr:`Transactions.processors`.
+#: The container mixes both kinds keyed by ``pre_*``/``post_*``, so the element
+#: type stays callable-flexible while pinning the return type.
+Processor = Callable[..., TagDict]
+
+#: Mapping of processor-slot name to the processors registered for it.
+Processors = dict[str, list[Processor]]
diff --git a/mt940/json.py b/mt940/json.py
index ed79aae..b9e9231 100644
--- a/mt940/json.py
+++ b/mt940/json.py
@@ -1,3 +1,17 @@
+"""JSON serialization for MT940 models.
+
+This module exposes :class:`JSONEncoder`, a :class:`json.JSONEncoder` subclass
+that knows how to serialize the model types returned by the parser (balances,
+amounts, dates and the transaction collections).
+
+Example:
+ >>> import json
+ >>> import mt940
+ >>> transactions = mt940.models.Transactions()
+ >>> json.dumps(transactions, cls=mt940.JSONEncoder)
+ '{"transactions": []}'
+"""
+
from __future__ import annotations
import datetime
@@ -9,12 +23,21 @@
class JSONEncoder(json.JSONEncoder):
+ """Serialize MT940 model objects to JSON-compatible primitives.
+
+ Dates, datetimes, timedeltas, timezones and decimals are rendered as
+ strings; :class:`~mt940.models.Transactions`,
+ :class:`~mt940.models.Transaction`, :class:`~mt940.models.Balance` and
+ :class:`~mt940.models.Amount` are rendered as their ``data``/``__dict__``
+ mappings. Pass it as the ``cls`` argument to :func:`json.dumps`.
+ """
+
def default(self, o: Any) -> Any:
- """
- Custom JSON encoder for MT940 models.
+ """Return a JSON-serializable representation of ``o``.
Args:
- o: The object to serialize.
+ o: The object to serialize. ``o`` keeps the permissive ``Any`` type
+ of the overridden :meth:`json.JSONEncoder.default`.
Returns:
The serialized form of the object.
diff --git a/mt940/models.py b/mt940/models.py
index 156c4ff..97ec562 100644
--- a/mt940/models.py
+++ b/mt940/models.py
@@ -1,12 +1,19 @@
+"""Data models returned by the MT940 parser.
+
+The parser produces a :class:`Transactions` collection (statement-level data
+plus a sequence of :class:`Transaction` objects). The remaining classes are the
+value types stored on them: :class:`Amount`, :class:`Balance`, :class:`Date`,
+:class:`DateTime` and :class:`FixedOffset`. They accept the string fields
+found in raw MT940 data and coerce them to native Python types.
+"""
+
from __future__ import annotations
import datetime
import decimal
import re
-import typing
import warnings
from collections.abc import (
- Callable,
Iterable,
Mapping,
MutableMapping,
@@ -17,12 +24,12 @@
import mt940
from . import processors, utils
-
-if typing.TYPE_CHECKING:
- pass
+from ._types import Processors
class Model:
+ """Base class for MT940 models, providing a uniform ``repr``."""
+
def __repr__(self) -> str:
return f'<{self.__class__.__name__}>'
@@ -48,12 +55,15 @@ def __init__(self, offset: int | str = 0, name: str | None = None) -> None:
self._offset = datetime.timedelta(minutes=offset)
def utcoffset(self, dt: datetime.datetime | None) -> datetime.timedelta:
+ """Return the fixed offset east of UTC."""
return self._offset
def dst(self, dt: datetime.datetime | None) -> datetime.timedelta:
+ """Return a zero DST adjustment (fixed offsets have no DST)."""
return datetime.timedelta(0)
def tzname(self, dt: datetime.datetime | None) -> str:
+ """Return the offset's name."""
return self._name
@@ -111,6 +121,13 @@ class DateTime(datetime.datetime, Model):
"""
def __new__(cls, *args: Any, **kwargs: Any) -> DateTime:
+ """Build a ``DateTime`` from string or positional date components.
+
+ When keyword arguments are given the individual fields are coerced from
+ strings, two-digit years are shifted into the 2000s, and ``offset`` (in
+ minutes) is converted to a :class:`FixedOffset`. Positional arguments
+ fall through to :class:`datetime.datetime`.
+ """
if kwargs:
tzinfo = None
if 'tzinfo' in kwargs:
@@ -160,6 +177,12 @@ class Date(datetime.date, Model):
"""
def __new__(cls, *args: Any, **kwargs: Any) -> Date:
+ """Build a ``Date`` from string or positional date components.
+
+ Keyword arguments are coerced through :class:`DateTime` (so two-digit
+ years are normalised); positional arguments fall through to
+ :class:`datetime.date`.
+ """
if kwargs:
dt = DateTime(*args, **kwargs).date()
return datetime.date.__new__(cls, dt.year, dt.month, dt.day)
@@ -188,6 +211,12 @@ def __init__(
currency: str | None = None,
**kwargs: Any,
) -> None:
+ """Coerce ``amount`` to a signed :class:`decimal.Decimal`.
+
+ ``status`` is ``'C'`` for credit (positive) or ``'D'`` for debit, in
+ which case the amount is negated. Extra keyword arguments are ignored
+ so a parsed tag dictionary can be splatted in directly.
+ """
self.amount = decimal.Decimal(amount.replace(',', '.'))
self.currency = currency
@@ -196,7 +225,7 @@ def __init__(
if status == 'D':
self.amount = -self.amount
- def __eq__(self, other: Any) -> bool:
+ def __eq__(self, other: object) -> bool:
return (
isinstance(other, Amount)
and self.amount == other.amount
@@ -211,12 +240,19 @@ def __repr__(self) -> str:
class SumAmount(Amount):
+ """An :class:`Amount` that also tracks how many entries it sums.
+
+ Used for the ``:90D:``/``:90C:`` tags, which report the total amount *and*
+ the ``number`` of debit/credit entries that make it up.
+ """
+
def __init__(
self,
*args: Any,
number: int,
**kwargs: Any,
) -> None:
+ """Store the entry ``number`` alongside the summed amount."""
super().__init__(*args, **kwargs)
self.number = number
@@ -262,7 +298,7 @@ def __init__(
self.amount = amount
self.date = date
- def __eq__(self, other: Any) -> bool:
+ def __eq__(self, other: object) -> bool:
return (
isinstance(other, Balance)
and self.amount == other.amount
@@ -277,11 +313,24 @@ def __str__(self) -> str:
class Transaction(Model):
+ """A single statement transaction and its parsed fields.
+
+ Holds a back-reference to its owning :class:`Transactions` collection and a
+ ``data`` dictionary with the parsed tag fields (amount, dates, references,
+ purpose, ...). Field availability depends on the source bank and tags.
+ """
+
def __init__(
self,
transactions: Transactions,
data: dict[str, Any] | None = None,
) -> None:
+ """Create a transaction owned by ``transactions``.
+
+ Args:
+ transactions: The collection this transaction belongs to.
+ data: Optional initial field data to populate.
+ """
self.transactions = transactions
self.data: dict[str, Any] = {}
self.update(data)
@@ -312,7 +361,7 @@ class Transactions(Sequence[Transaction]):
as begin and end balance
"""
- DEFAULT_PROCESSORS: ClassVar[dict[str, list[Callable[..., Any]]]] = dict(
+ DEFAULT_PROCESSORS: ClassVar[Processors] = dict(
pre_account_identification=[],
post_account_identification=[],
pre_available_balance=[],
@@ -360,20 +409,39 @@ class Transactions(Sequence[Transaction]):
)
def __getstate__(self) -> dict[str, Any]:
+ """Return picklable state, dropping the (unpicklable) processors."""
# Processors are not always safe to dump so ignore them entirely
state = self.__dict__.copy()
del state['processors']
return state
+ def __setstate__(self, state: dict[str, Any]) -> None:
+ """Restore unpickled state, re-creating the dropped processors.
+
+ ``__getstate__`` omits :attr:`processors`, so it is rebuilt from
+ :attr:`DEFAULT_PROCESSORS` here to keep the unpickled object usable.
+ """
+ self.__dict__.update(state)
+ self.processors: Processors = self.DEFAULT_PROCESSORS.copy()
+
def __init__(
self,
- processors: dict[str, list[Callable[..., Any]]] | None = None,
+ processors: Processors | None = None,
tags: dict[int | str, mt940.tags.Tag] | None = None,
transaction_boundary: Iterable[str] | None = None,
) -> None:
- self.processors: dict[str, list[Callable[..., Any]]] = (
- self.DEFAULT_PROCESSORS.copy()
- )
+ """Create an empty collection, optionally customizing parsing.
+
+ Args:
+ processors: Extra pre/post processors merged over
+ :attr:`DEFAULT_PROCESSORS`.
+ tags: Extra or overriding tag parsers merged over the defaults.
+ transaction_boundary: Tag *slugs* that each open a new transaction
+ (issue #110). By default only ``:61:`` starts one; a bare
+ string is treated as a single slug. Omit to keep the legacy
+ behaviour.
+ """
+ self.processors = self.DEFAULT_PROCESSORS.copy()
self.tags: MutableMapping[int | str, mt940.tags.Tag] = dict(
self.default_tags()
)
@@ -401,6 +469,11 @@ def __init__(
@property
def currency(self) -> str | None:
+ """The statement currency, derived from the first available balance.
+
+ Returns ``None`` when no balance or floor-limit carrying a currency has
+ been parsed yet.
+ """
balance = utils.coalesce(
self.data.get('final_opening_balance'),
self.data.get('opening_balance'),
@@ -423,6 +496,7 @@ def currency(self) -> str | None:
@classmethod
def defaultTags(cls) -> Mapping[int | str, mt940.tags.Tag]: # noqa: N802 # pragma: no cover
+ """Deprecated alias for :meth:`default_tags`."""
warnings.warn(
'defaultTags is deprecated, use default_tags instead',
DeprecationWarning,
@@ -432,6 +506,7 @@ def defaultTags(cls) -> Mapping[int | str, mt940.tags.Tag]: # noqa: N802 # prag
@staticmethod
def default_tags() -> Mapping[int | str, mt940.tags.Tag]:
+ """Return the built-in tag parsers keyed by tag id."""
return mt940.tags.TAG_BY_ID
def parse(self, data: str) -> list[Transaction]:
@@ -470,6 +545,14 @@ def _process_match(
valid_matches: list[re.Match[str]],
data: str,
) -> None:
+ """Parse one matched tag and route its result to the right place.
+
+ Runs the tag's pre-processors, builds the model object, runs the
+ post-processors, then files the result as either statement-level data,
+ a new transaction (``:61:`` or a configured boundary tag), or an update
+ to the current transaction, based on the tag's
+ :attr:`~mt940.tags.Tag.scope`.
+ """
tag_id = self.normalize_tag_id(match.group('tag'))
# get tag instance corresponding to tag id
@@ -517,6 +600,11 @@ def _process_match(
self.data.update(result)
def _process_statement_tag(self, result: dict[str, Any]) -> None:
+ """File a ``:61:`` statement result into the current/new transaction.
+
+ Reuses the trailing placeholder transaction if it has no ``id`` yet,
+ otherwise starts a new :class:`Transaction`.
+ """
if not self.transactions:
transaction = Transaction(self)
self.transactions.append(transaction)
@@ -529,6 +617,11 @@ def _process_statement_tag(self, result: dict[str, Any]) -> None:
transaction.data.update(result)
def _update_transaction(self, result: dict[str, Any]) -> None:
+ """Merge a transaction-scoped result into the current transaction.
+
+ New keys are set directly; string values for keys that already exist
+ are appended on a new line (e.g. multi-line ``:86:`` details).
+ """
transaction = self.transactions[-1]
for k, v in result.items():
if k in transaction.data and hasattr(v, 'strip'):
diff --git a/mt940/parser.py b/mt940/parser.py
index 0d8e4e8..9882cdd 100644
--- a/mt940/parser.py
+++ b/mt940/parser.py
@@ -28,11 +28,13 @@
import os
import re
+from collections.abc import Iterable
from typing import TYPE_CHECKING, Any
import mt940
if TYPE_CHECKING:
+ from ._types import Processors, Source
from .models import Transactions
@@ -77,25 +79,27 @@ def safe_is_file(filename: Any) -> bool:
def parse(
- src: Any,
+ src: Source,
encoding: str | None = None,
- processors: dict[str, list[Any]] | None = None,
- tags: dict[Any, Any] | None = None,
- transaction_boundary: Any = None,
+ processors: Processors | None = None,
+ tags: dict[int | str, mt940.tags.Tag] | None = None,
+ transaction_boundary: Iterable[str] | None = None,
) -> Transactions:
- """
- Parses mt940 data and returns transactions object
-
- :param src: file handler to read, filename to read or raw data as string
- :param encoding: optional encoding override for byte input
- :param processors: optional extra pre/post processors
- :param tags: optional extra/override tag parsers
- :param transaction_boundary: optional iterable of tag *slugs* that each
- start a new transaction (issue #110). By default only ``:61:`` starts a
- transaction; pass e.g. ``{'transaction_reference_number'}`` to also
- start one on every ``:20:``. Omitting it keeps the legacy behaviour.
- :return: Collection of transactions
- :rtype: Transactions
+ """Parse MT940 data into a single :class:`~mt940.models.Transactions`.
+
+ Args:
+ src: A file handle, a filename to read, or the raw data as
+ ``str``/``bytes``.
+ encoding: Optional encoding override for byte input.
+ processors: Optional extra pre/post processors.
+ tags: Optional extra or overriding tag parsers.
+ transaction_boundary: Optional iterable of tag *slugs* that each start
+ a new transaction (issue #110). By default only ``:61:`` starts a
+ transaction; pass e.g. ``{'transaction_reference_number'}`` to also
+ start one on every ``:20:``. Omit it to keep the legacy behaviour.
+
+ Returns:
+ The parsed collection of transactions.
"""
data = _read(src, encoding)
transactions = mt940.models.Transactions(
@@ -107,11 +111,11 @@ def parse(
def parse_statements(
- src: Any,
+ src: Source,
encoding: str | None = None,
- processors: dict[str, list[Any]] | None = None,
- tags: dict[Any, Any] | None = None,
- transaction_boundary: Any = None,
+ processors: Processors | None = None,
+ tags: dict[int | str, mt940.tags.Tag] | None = None,
+ transaction_boundary: Iterable[str] | None = None,
) -> list[Transactions]:
"""
Parse an mt940 file that contains multiple statement blocks.
@@ -131,13 +135,16 @@ def parse_statements(
which instead treats ``:20:`` as an *intra*-statement transaction boundary;
the two target different, non-standard bank formats -- don't combine them.
- :param src: file handler to read, filename to read or raw data as string
- :param encoding: optional encoding override for byte input
- :param processors: optional extra pre/post processors (applied per block)
- :param tags: optional extra/override tag parsers (applied per block)
- :param transaction_boundary: see :func:`parse` (see the note above)
- :return: one Transactions per statement block
- :rtype: list[Transactions]
+ Args:
+ src: A file handle, a filename to read, or the raw data as
+ ``str``/``bytes``.
+ encoding: Optional encoding override for byte input.
+ processors: Optional extra pre/post processors (applied per block).
+ tags: Optional extra or overriding tag parsers (applied per block).
+ transaction_boundary: See :func:`parse` (and the note above).
+
+ Returns:
+ One :class:`~mt940.models.Transactions` per statement block.
"""
data = _read(src, encoding)
statements: list[Transactions] = []
diff --git a/mt940/processors.py b/mt940/processors.py
index f151f8c..a05a9e9 100644
--- a/mt940/processors.py
+++ b/mt940/processors.py
@@ -13,10 +13,10 @@
import collections
import functools
import re
-import typing
-from collections.abc import Callable
from typing import TYPE_CHECKING, Any
+from ._types import PostProcessor, PreProcessor
+
if TYPE_CHECKING:
from . import models, tags
@@ -24,7 +24,7 @@
def add_currency_pre_processor(
currency: str,
overwrite: bool = True,
-) -> Callable[..., Any]:
+) -> PreProcessor:
"""
Return a pre-processor that adds currency information
to tag dictionaries.
@@ -437,15 +437,7 @@ def transaction_details_post_processor(
def transactions_to_transaction(
*keys: str,
-) -> typing.Callable[
- [
- models.Transactions,
- tags.Tag,
- dict[str, Any],
- dict[str, Any],
- ],
- dict[str, Any],
-]:
+) -> PostProcessor:
"""
Copy the global transactions details to the transaction.
diff --git a/mt940/tags.py b/mt940/tags.py
index 9a0addb..fec4536 100644
--- a/mt940/tags.py
+++ b/mt940/tags.py
@@ -106,10 +106,15 @@ def parse(
"""
Parses the given value using the Tag's pattern.
- :param transactions: The transactions model instance.
- :param value: The string value to parse.
- :return: A dictionary of matched group values.
- :raises RuntimeError: If parsing fails.
+ Args:
+ transactions: The transactions model instance.
+ value: The string value to parse.
+
+ Returns:
+ A dictionary of matched group values.
+
+ Raises:
+ RuntimeError: If the value does not match the tag's pattern.
"""
match = self.re.match(value)
if match: # pragma: no branch
@@ -160,15 +165,23 @@ def __call__(
"""
Processes the tag value and returns parsed content.
- :param transactions: The transactions model instance.
- :param value: The string value to process.
- :return: The processed value, which can be a string or dict.
+ The base implementation returns ``value`` unchanged; subclasses
+ override it to build model objects (amounts, balances, dates, ...).
+
+ Args:
+ transactions: The transactions model instance.
+ value: The parsed group dictionary to process.
+
+ Returns:
+ The processed mapping.
"""
return value
def __new__(cls, *args: typing.Any, **kwargs: typing.Any) -> Tag:
- """
- Creates a new Tag instance and sets up logging details.
+ """Create a Tag instance, deriving its ``name``, ``slug`` and logger.
+
+ The ``slug`` is the snake_case form of the class name and is used to
+ look up matching pre/post processors.
"""
cls.name = cls.__name__
words = re.findall('([A-Z][a-z]+)', cls.__name__)
@@ -177,10 +190,10 @@ def __new__(cls, *args: typing.Any, **kwargs: typing.Any) -> Tag:
return object.__new__(cls)
def __hash__(self) -> int:
- """
- Returns a hash based on the tag's ID.
+ """Return a hash based on the tag's ``id``.
- :return: The integer hash of the tag.
+ Returns:
+ The integer hash of the tag.
"""
return hash(self.id) if isinstance(self.id, str) else self.id
@@ -361,14 +374,20 @@ def __call__(
class OpeningBalance(BalanceBase):
+ """Opening balance (``:60:``)."""
+
id = 60
class FinalOpeningBalance(BalanceBase):
+ """Final opening balance (``:60F:``)."""
+
id = '60F'
class IntermediateOpeningBalance(BalanceBase):
+ """Intermediate opening balance (``:60M:``)."""
+
id = '60M'
@@ -540,22 +559,32 @@ class StatementGLS(Statement):
class ClosingBalance(BalanceBase):
+ """Closing balance (``:62:``)."""
+
id: str | int = 62
class IntermediateClosingBalance(ClosingBalance):
+ """Intermediate closing balance (``:62M:``)."""
+
id = '62M'
class FinalClosingBalance(ClosingBalance):
+ """Final closing balance (``:62F:``)."""
+
id = '62F'
class AvailableBalance(BalanceBase):
+ """Available balance (``:64:``)."""
+
id = 64
class ForwardAvailableBalance(BalanceBase):
+ """Forward available balance (``:65:``)."""
+
id = 65
@@ -592,17 +621,23 @@ def __call__(
class SumDebitEntries(SumEntries):
+ """Number and sum of debit entries (``:90D:``)."""
+
status = 'D'
id = '90D'
class SumCreditEntries(SumEntries):
+ """Number and sum of credit entries (``:90C:``)."""
+
status = 'C'
id = '90C'
@enum.unique
class Tags(enum.Enum):
+ """Registry of the built-in tag parsers, one instance per member."""
+
DATE_TIME_INDICATION = DateTimeIndication()
TRANSACTION_REFERENCE_NUMBER = TransactionReferenceNumber()
RELATED_REFERENCE = RelatedReference()
@@ -625,4 +660,5 @@ class Tags(enum.Enum):
SUM_CREDIT_ENTRIES = SumCreditEntries()
+#: Mapping of tag id (``int`` or ``str``) to the tag instance that parses it.
TAG_BY_ID = {t.value.id: t.value for t in Tags}
diff --git a/mt940/utils.py b/mt940/utils.py
index 5acc696..3512b95 100644
--- a/mt940/utils.py
+++ b/mt940/utils.py
@@ -1,3 +1,10 @@
+"""Small, dependency-free helpers shared across the package.
+
+Contains :func:`coalesce` (first non-``None`` value) and :func:`join_lines`
+(whitespace-aware line joining) together with the :class:`Strip` flag enum that
+configures the latter.
+"""
+
from __future__ import annotations
import enum
diff --git a/mt940_tests/test_parse.py b/mt940_tests/test_parse.py
index 143b605..8a2fff8 100644
--- a/mt940_tests/test_parse.py
+++ b/mt940_tests/test_parse.py
@@ -26,3 +26,19 @@ def test_non_ascii_parse(path, encoding):
with path.open('r') as fh:
data = fh.read()
pickle.dumps(mt940.parse(data))
+
+
+def test_pickle_roundtrip_restores_processors():
+ # __getstate__ drops the (unpicklable) processors; __setstate__ must
+ # restore them so the unpickled object is still usable.
+ with (_tests_path / 'jejik' / 'ing.sta').open() as fh:
+ transactions = mt940.parse(fh.read())
+
+ # Trusted, self-produced pickle (a round-trip of our own object), so
+ # pickle.loads is safe here.
+ restored = pickle.loads(pickle.dumps(transactions))
+
+ assert restored.processors == transactions.processors
+ assert len(restored) == len(transactions)
+ # Re-parsing exercises the restored processors without raising.
+ restored.parse('')
diff --git a/tox.ini b/tox.ini
index ccc5374..c6b87ff 100644
--- a/tox.ini
+++ b/tox.ini
@@ -67,10 +67,9 @@ commands = pyrefly check
[testenv:docs]
description = Build the Sphinx documentation
extras = docs
-allowlist_externals = rm
+# The API pages are regenerated from conf.py on every build (see _run_apidoc),
+# so no separate sphinx-apidoc step is needed here.
commands =
- rm -vf docs/mt940.rst docs/mt940.json.rst docs/mt940.models.rst docs/mt940.parser.rst docs/mt940.processors.rst docs/mt940.tags.rst docs/mt940.utils.rst
- sphinx-apidoc --separate --module-first --output-dir docs/ mt940
sphinx-build -W -b html -d {envtmpdir}/doctrees docs docs/html
# ── Coverage ─────────────────────────────────────────────────