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 @@ [![Coverage](https://coveralls.io/repos/github/WoLpH/mt940/badge.svg?branch=develop)](https://coveralls.io/github/WoLpH/mt940?branch=develop) [![License](https://img.shields.io/pypi/l/mt-940)](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 ─────────────────────────────────────────────────