ofxtools 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- docs/conf.py +76 -0
- ofxtools/Client.py +1003 -0
- ofxtools/Parser.py +262 -0
- ofxtools/Types.py +821 -0
- ofxtools/__init__.py +6 -0
- ofxtools/config/__init__.py +133 -0
- ofxtools/config/fi.cfg +4496 -0
- ofxtools/config/ofxget_example.cfg +64 -0
- ofxtools/header.py +350 -0
- ofxtools/lib.py +82 -0
- ofxtools/models/__init__.py +35 -0
- ofxtools/models/bank/__init__.py +10 -0
- ofxtools/models/bank/interxfer.py +90 -0
- ofxtools/models/bank/mail.py +77 -0
- ofxtools/models/bank/msgsets.py +324 -0
- ofxtools/models/bank/recur.py +185 -0
- ofxtools/models/bank/stmt.py +341 -0
- ofxtools/models/bank/stmtend.py +157 -0
- ofxtools/models/bank/stpchk.py +84 -0
- ofxtools/models/bank/sync.py +167 -0
- ofxtools/models/bank/wire.py +112 -0
- ofxtools/models/bank/xfer.py +118 -0
- ofxtools/models/base.py +586 -0
- ofxtools/models/billpay/__init__.py +7 -0
- ofxtools/models/billpay/common.py +166 -0
- ofxtools/models/billpay/list.py +89 -0
- ofxtools/models/billpay/mail.py +61 -0
- ofxtools/models/billpay/msgsets.py +97 -0
- ofxtools/models/billpay/pmt.py +109 -0
- ofxtools/models/billpay/recur.py +100 -0
- ofxtools/models/billpay/sync.py +61 -0
- ofxtools/models/common.py +81 -0
- ofxtools/models/email.py +170 -0
- ofxtools/models/i18n.py +1227 -0
- ofxtools/models/invest/__init__.py +7 -0
- ofxtools/models/invest/acct.py +61 -0
- ofxtools/models/invest/mail.py +48 -0
- ofxtools/models/invest/msgsets.py +126 -0
- ofxtools/models/invest/openorders.py +162 -0
- ofxtools/models/invest/positions.py +86 -0
- ofxtools/models/invest/securities.py +277 -0
- ofxtools/models/invest/stmt.py +316 -0
- ofxtools/models/invest/transactions.py +370 -0
- ofxtools/models/ofx.py +129 -0
- ofxtools/models/profile.py +169 -0
- ofxtools/models/signon.py +292 -0
- ofxtools/models/signup.py +397 -0
- ofxtools/models/tax1099.py +538 -0
- ofxtools/models/wrapperbases.py +72 -0
- ofxtools/py.typed +0 -0
- ofxtools/scripts/__init__.py +0 -0
- ofxtools/scripts/ofxget.py +1560 -0
- ofxtools/utils.py +406 -0
- ofxtools-1.0.0.dist-info/METADATA +136 -0
- ofxtools-1.0.0.dist-info/RECORD +99 -0
- ofxtools-1.0.0.dist-info/WHEEL +5 -0
- ofxtools-1.0.0.dist-info/entry_points.txt +2 -0
- ofxtools-1.0.0.dist-info/licenses/LICENSE +14 -0
- ofxtools-1.0.0.dist-info/top_level.txt +3 -0
- tests/base.py +543 -0
- tests/test_client.py +875 -0
- tests/test_header.py +634 -0
- tests/test_models_bank_interxfer.py +342 -0
- tests/test_models_bank_mail.py +233 -0
- tests/test_models_bank_recur.py +630 -0
- tests/test_models_bank_stmt.py +856 -0
- tests/test_models_bank_stmtend.py +392 -0
- tests/test_models_bank_stpchk.py +254 -0
- tests/test_models_bank_sync.py +1133 -0
- tests/test_models_bank_wire.py +322 -0
- tests/test_models_bank_xfer.py +461 -0
- tests/test_models_base.py +796 -0
- tests/test_models_billpay_common.py +488 -0
- tests/test_models_billpay_list.py +413 -0
- tests/test_models_billpay_mail.py +186 -0
- tests/test_models_billpay_pmt.py +401 -0
- tests/test_models_billpay_recur.py +229 -0
- tests/test_models_billpay_sync.py +270 -0
- tests/test_models_common.py +169 -0
- tests/test_models_email.py +393 -0
- tests/test_models_i18n.py +52 -0
- tests/test_models_invest.py +1301 -0
- tests/test_models_invest_oo.py +425 -0
- tests/test_models_invest_transactions.py +1227 -0
- tests/test_models_msgsets.py +1604 -0
- tests/test_models_ofx.py +240 -0
- tests/test_models_profile.py +297 -0
- tests/test_models_securities.py +642 -0
- tests/test_models_signon.py +507 -0
- tests/test_models_signup.py +1208 -0
- tests/test_ofxget.py +1376 -0
- tests/test_parser.py +591 -0
- tests/test_spec_bank.py +1154 -0
- tests/test_spec_billpay.py +1671 -0
- tests/test_spec_invest.py +887 -0
- tests/test_spec_signon.py +631 -0
- tests/test_spec_tax1099.py +638 -0
- tests/test_types.py +677 -0
- tests/test_utils.py +96 -0
ofxtools/Client.py
ADDED
|
@@ -0,0 +1,1003 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Network client that composes/transmits Open Financial Exchange (OFX) requests,
|
|
3
|
+
and receives OFX responses in reply. A basic CLI utility is included.
|
|
4
|
+
|
|
5
|
+
To use, create an OFXClient instance configured with OFX connection parameters:
|
|
6
|
+
server URL, OFX protocol version, financial institution identifiers, client
|
|
7
|
+
identifiers, etc.
|
|
8
|
+
|
|
9
|
+
``config/fi.cfg`` contains a database of these parameters, most conveniently
|
|
10
|
+
accessed via ``scripts/ofxget.py``.
|
|
11
|
+
|
|
12
|
+
Using the configured ``OFXClient`` instance, make a request by calling the
|
|
13
|
+
relevant method, e.g. ``OFXClient.request_statements()``. Provide the password
|
|
14
|
+
as the first positional argument; any remaining positional arguments are parsed
|
|
15
|
+
as requests. Simple data containers for each statement (``StmtRq``,
|
|
16
|
+
``CcStmtRq``, etc.) are provided for this purpose. Options follow as keyword
|
|
17
|
+
arguments.
|
|
18
|
+
|
|
19
|
+
For example:
|
|
20
|
+
|
|
21
|
+
>>> import datetime; import ofxtools
|
|
22
|
+
>>> from ofxtools.Client import OFXClient, StmtRq, CcStmtEndRq
|
|
23
|
+
>>> client = OFXClient("https://ofx.chase.com", userid="MoMoney",
|
|
24
|
+
... org="B1", fid="10898",
|
|
25
|
+
... version=220, prettyprint=True,
|
|
26
|
+
... bankid="111000614")
|
|
27
|
+
>>> dtstart = datetime.datetime(2015, 1, 1, tzinfo=ofxtools.utils.UTC)
|
|
28
|
+
>>> dtend = datetime.datetime(2015, 1, 31, tzinfo=ofxtools.utils.UTC)
|
|
29
|
+
>>> s0 = StmtRq(acctid="1", accttype="CHECKING", dtstart=dtstart, dtend=dtend)
|
|
30
|
+
>>> s1 = StmtRq(acctid="2", accttype="SAVINGS", dtstart=dtstart, dtend=dtend)
|
|
31
|
+
>>> c0 = CcStmtEndRq(acctid="3", dtstart=dtstart, dtend=dtend)
|
|
32
|
+
>>> response = client.request_statements("t0ps3kr1t", s0, s1, c0)
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"AUTH_PLACEHOLDER",
|
|
37
|
+
"StmtRq",
|
|
38
|
+
"CcStmtRq",
|
|
39
|
+
"InvStmtRq",
|
|
40
|
+
"StmtEndRq",
|
|
41
|
+
"CcStmtEndRq",
|
|
42
|
+
"OFXClient",
|
|
43
|
+
"wrap_stmtrq",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# stdlib imports
|
|
48
|
+
import datetime
|
|
49
|
+
import http.cookiejar
|
|
50
|
+
import itertools
|
|
51
|
+
import logging
|
|
52
|
+
import urllib.request as urllib_request
|
|
53
|
+
import uuid
|
|
54
|
+
import xml.etree.ElementTree as ET
|
|
55
|
+
from collections.abc import Iterator
|
|
56
|
+
from functools import singledispatch
|
|
57
|
+
from io import BytesIO
|
|
58
|
+
from operator import attrgetter, itemgetter
|
|
59
|
+
from typing import (
|
|
60
|
+
BinaryIO,
|
|
61
|
+
NamedTuple,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# 3rd party libs
|
|
65
|
+
try:
|
|
66
|
+
import requests
|
|
67
|
+
|
|
68
|
+
USE_REQUESTS = True
|
|
69
|
+
except ImportError:
|
|
70
|
+
USE_REQUESTS = False
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# local imports
|
|
74
|
+
from ofxtools import config, utils
|
|
75
|
+
from ofxtools.header import make_header
|
|
76
|
+
from ofxtools.models import ACCTINFORQ, ACCTINFOTRNRQ
|
|
77
|
+
from ofxtools.models.bank import (
|
|
78
|
+
BANKACCTFROM,
|
|
79
|
+
BANKMSGSET,
|
|
80
|
+
BANKMSGSRQV1,
|
|
81
|
+
CCACCTFROM,
|
|
82
|
+
CCSTMTENDRQ,
|
|
83
|
+
CCSTMTENDTRNRQ,
|
|
84
|
+
CCSTMTRQ,
|
|
85
|
+
CCSTMTTRNRQ,
|
|
86
|
+
CREDITCARDMSGSET,
|
|
87
|
+
CREDITCARDMSGSRQV1,
|
|
88
|
+
INCTRAN,
|
|
89
|
+
INTERXFERMSGSET,
|
|
90
|
+
STMTENDRQ,
|
|
91
|
+
STMTENDTRNRQ,
|
|
92
|
+
STMTRQ,
|
|
93
|
+
STMTTRNRQ,
|
|
94
|
+
WIREXFERMSGSET,
|
|
95
|
+
)
|
|
96
|
+
from ofxtools.models.billpay.msgsets import BILLPAYMSGSET
|
|
97
|
+
from ofxtools.models.email import EMAILMSGSET
|
|
98
|
+
from ofxtools.models.invest import (
|
|
99
|
+
INCPOS,
|
|
100
|
+
INVACCTFROM,
|
|
101
|
+
INVSTMTMSGSET,
|
|
102
|
+
INVSTMTMSGSRQV1,
|
|
103
|
+
INVSTMTRQ,
|
|
104
|
+
INVSTMTTRNRQ,
|
|
105
|
+
SECLISTMSGSET,
|
|
106
|
+
)
|
|
107
|
+
from ofxtools.models.ofx import OFX
|
|
108
|
+
from ofxtools.models.profile import PROFMSGSET, PROFMSGSRQV1, PROFRQ, PROFTRNRQ
|
|
109
|
+
from ofxtools.models.signon import FI, SIGNONMSGSET, SIGNONMSGSRQV1, SONRQ
|
|
110
|
+
from ofxtools.models.signup import SIGNUPMSGSET, SIGNUPMSGSRQV1
|
|
111
|
+
from ofxtools.models.tax1099 import (
|
|
112
|
+
TAX1099MSGSET,
|
|
113
|
+
TAX1099MSGSRQV1,
|
|
114
|
+
TAX1099RQ,
|
|
115
|
+
TAX1099TRNRQ,
|
|
116
|
+
)
|
|
117
|
+
from ofxtools.Parser import OFXTree
|
|
118
|
+
from ofxtools.utils import UTC, classproperty
|
|
119
|
+
|
|
120
|
+
AUTH_PLACEHOLDER = "{:0<32}".format("anonymous")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
logger = logging.getLogger(__name__)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# Statement request data containers
|
|
127
|
+
# Pass instances of these containers as args to OFXClient.request_statement()
|
|
128
|
+
class StmtRq(NamedTuple):
|
|
129
|
+
"""
|
|
130
|
+
Parameters of a bank statement request
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
acctid: str | None = None
|
|
134
|
+
accttype: str | None = None
|
|
135
|
+
dtstart: datetime.datetime | None = None
|
|
136
|
+
dtend: datetime.datetime | None = None
|
|
137
|
+
inctran: bool | None = True
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class CcStmtRq(NamedTuple):
|
|
141
|
+
"""
|
|
142
|
+
Parameters of a credit card statement request
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
acctid: str | None = None
|
|
146
|
+
dtstart: datetime.datetime | None = None
|
|
147
|
+
dtend: datetime.datetime | None = None
|
|
148
|
+
inctran: bool | None = True
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class InvStmtRq(NamedTuple):
|
|
152
|
+
"""
|
|
153
|
+
Parameters of an investment account statement request
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
acctid: str | None = None
|
|
157
|
+
dtstart: datetime.datetime | None = None
|
|
158
|
+
dtend: datetime.datetime | None = None
|
|
159
|
+
dtasof: datetime.datetime | None = None
|
|
160
|
+
inctran: bool | None = True
|
|
161
|
+
incoo: bool | None = False
|
|
162
|
+
incpos: bool | None = True
|
|
163
|
+
incbal: bool | None = True
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class StmtEndRq(NamedTuple):
|
|
167
|
+
"""
|
|
168
|
+
Parameters of a bank statement ending balance request
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
acctid: str | None = None
|
|
172
|
+
accttype: str | None = None
|
|
173
|
+
dtstart: datetime.datetime | None = None
|
|
174
|
+
dtend: datetime.datetime | None = None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class CcStmtEndRq(NamedTuple):
|
|
178
|
+
"""
|
|
179
|
+
Parameters of a credit card statement ending balance request
|
|
180
|
+
"""
|
|
181
|
+
|
|
182
|
+
acctid: str | None = None
|
|
183
|
+
dtstart: datetime.datetime | None = None
|
|
184
|
+
dtend: datetime.datetime | None = None
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# TYPE ALIASES
|
|
188
|
+
RequestParam = StmtRq | CcStmtRq | InvStmtRq | StmtEndRq | CcStmtEndRq
|
|
189
|
+
Request = STMTRQ | CCSTMTRQ | INVSTMTRQ | STMTENDRQ | CCSTMTENDRQ
|
|
190
|
+
Message = BANKMSGSRQV1 | CREDITCARDMSGSRQV1 | INVSTMTMSGSRQV1
|
|
191
|
+
MsgsetClass = (
|
|
192
|
+
type[SIGNONMSGSET]
|
|
193
|
+
| type[SIGNUPMSGSET]
|
|
194
|
+
| type[BANKMSGSET]
|
|
195
|
+
| type[CREDITCARDMSGSET]
|
|
196
|
+
| type[INVSTMTMSGSET]
|
|
197
|
+
| type[INTERXFERMSGSET]
|
|
198
|
+
| type[WIREXFERMSGSET]
|
|
199
|
+
| type[BILLPAYMSGSET]
|
|
200
|
+
| type[EMAILMSGSET]
|
|
201
|
+
| type[SECLISTMSGSET]
|
|
202
|
+
| type[PROFMSGSET]
|
|
203
|
+
| type[TAX1099MSGSET]
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class OFXClient:
|
|
208
|
+
"""
|
|
209
|
+
Basic OFX client to download statement and profile requests.
|
|
210
|
+
"""
|
|
211
|
+
|
|
212
|
+
# OFX header/signon defaults
|
|
213
|
+
userid: str = AUTH_PLACEHOLDER
|
|
214
|
+
clientuid: str | None = None
|
|
215
|
+
org: str | None = None
|
|
216
|
+
fid: str | None = None
|
|
217
|
+
version: int = 203
|
|
218
|
+
appid: str = "QWIN"
|
|
219
|
+
appver: str = "2700"
|
|
220
|
+
language: str = "ENG"
|
|
221
|
+
useragent: str = "InetClntApp/3.0"
|
|
222
|
+
|
|
223
|
+
# Formatting defaults
|
|
224
|
+
prettyprint: bool = False
|
|
225
|
+
close_elements: bool = True
|
|
226
|
+
|
|
227
|
+
# Stmt request
|
|
228
|
+
bankid: str | None = None
|
|
229
|
+
brokerid: str | None = None
|
|
230
|
+
persist_cookies: bool = True
|
|
231
|
+
|
|
232
|
+
def __repr__(self) -> str:
|
|
233
|
+
r = (
|
|
234
|
+
"{cls}(url={url!r}, userid={userid!r}, clientuid={clientuid!r}, "
|
|
235
|
+
"org={org!r}, fid={fid!r}, version={version}, appid={appid!r}, "
|
|
236
|
+
"appver={appver!r}, language={language!r}, prettyprint={prettyprint}, "
|
|
237
|
+
"close_elements={close_elements}, bankid={bankid!r}, brokerid={brokerid!r})"
|
|
238
|
+
)
|
|
239
|
+
attrs = dict(vars(self.__class__))
|
|
240
|
+
attrs.update(vars(self))
|
|
241
|
+
attrs["cls"] = self.__class__.__name__
|
|
242
|
+
return r.format(**attrs)
|
|
243
|
+
|
|
244
|
+
def __init__(
|
|
245
|
+
self,
|
|
246
|
+
url: str,
|
|
247
|
+
userid: str | None = None,
|
|
248
|
+
clientuid: str | None = None,
|
|
249
|
+
org: str | None = None,
|
|
250
|
+
fid: str | None = None,
|
|
251
|
+
version: int | None = None,
|
|
252
|
+
appid: str | None = None,
|
|
253
|
+
appver: str | None = None,
|
|
254
|
+
language: str | None = None,
|
|
255
|
+
prettyprint: bool | None = None,
|
|
256
|
+
close_elements: bool | None = None,
|
|
257
|
+
bankid: str | None = None,
|
|
258
|
+
brokerid: str | None = None,
|
|
259
|
+
useragent: str | None = None,
|
|
260
|
+
persist_cookies: bool | None = None,
|
|
261
|
+
):
|
|
262
|
+
self.url = url
|
|
263
|
+
|
|
264
|
+
for attr in [
|
|
265
|
+
"userid",
|
|
266
|
+
"clientuid",
|
|
267
|
+
"org",
|
|
268
|
+
"fid",
|
|
269
|
+
"version",
|
|
270
|
+
"appid",
|
|
271
|
+
"appver",
|
|
272
|
+
"language",
|
|
273
|
+
"prettyprint",
|
|
274
|
+
"close_elements",
|
|
275
|
+
"bankid",
|
|
276
|
+
"brokerid",
|
|
277
|
+
"useragent",
|
|
278
|
+
"persist_cookies",
|
|
279
|
+
]:
|
|
280
|
+
value = locals()[attr]
|
|
281
|
+
if value is not None:
|
|
282
|
+
setattr(self, attr, value)
|
|
283
|
+
|
|
284
|
+
if (not self.close_elements) and self.version >= 200:
|
|
285
|
+
raise ValueError(f"OFX version {self.version} must close all tags")
|
|
286
|
+
|
|
287
|
+
# Allow persistent cookies in case PROFRS sets cookies that are needed by
|
|
288
|
+
# subsequent STMTRQ or what have you.
|
|
289
|
+
self.cookiejar = http.cookiejar.CookieJar()
|
|
290
|
+
|
|
291
|
+
@classproperty
|
|
292
|
+
@classmethod
|
|
293
|
+
def uuid(cls) -> str:
|
|
294
|
+
"""
|
|
295
|
+
Return a new UUID each time called.
|
|
296
|
+
|
|
297
|
+
Wrapper we can mock for testing.
|
|
298
|
+
"""
|
|
299
|
+
return str(uuid.uuid4()).upper()
|
|
300
|
+
|
|
301
|
+
@property
|
|
302
|
+
def http_headers(self) -> dict[str, str]:
|
|
303
|
+
"""Pass to urllib.request.urlopen()"""
|
|
304
|
+
mimetype = "application/x-ofx"
|
|
305
|
+
# Python libraries such as ``urllib.request`` and ``requests``
|
|
306
|
+
# identify themselves in the ``User-Agent`` header,
|
|
307
|
+
# which apparently displeases some FIs
|
|
308
|
+
return {
|
|
309
|
+
"User-Agent": self.useragent,
|
|
310
|
+
"Content-Type": mimetype,
|
|
311
|
+
# Apparently Amex is unhappy unless it sees a MIME type of application/xml
|
|
312
|
+
# with some quality rating - ANY quality rating, it seems.
|
|
313
|
+
"Accept": f"*/*, {mimetype}, application/xml;q=0.9",
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
def dtclient(self) -> datetime.datetime:
|
|
317
|
+
"""
|
|
318
|
+
Wrapper we can mock for testing.
|
|
319
|
+
(as opposed to datetime.datetime, which is a C extension)
|
|
320
|
+
"""
|
|
321
|
+
return datetime.datetime.now(UTC)
|
|
322
|
+
|
|
323
|
+
def request_statements(
|
|
324
|
+
self,
|
|
325
|
+
password: str,
|
|
326
|
+
*requests: RequestParam,
|
|
327
|
+
gen_newfileuid: bool = True,
|
|
328
|
+
dryrun: bool = False,
|
|
329
|
+
timeout: float | None = None,
|
|
330
|
+
skip_profile: bool = False,
|
|
331
|
+
) -> BinaryIO:
|
|
332
|
+
"""
|
|
333
|
+
Package and send OFX statement requests
|
|
334
|
+
(STMTRQ/CCSTMTRQ/INVSTMTRQ/STMTENDRQ/CCSTMTENDRQ).
|
|
335
|
+
"""
|
|
336
|
+
if dryrun:
|
|
337
|
+
url = ""
|
|
338
|
+
logger.info("Dry run for statement request")
|
|
339
|
+
elif skip_profile:
|
|
340
|
+
url = self.url
|
|
341
|
+
logger.info(f"Skipping profile request; using url='{url}'")
|
|
342
|
+
else:
|
|
343
|
+
logger.info("Requesting OFX profile to extract service URLs")
|
|
344
|
+
RqCls2url = self._get_service_urls(
|
|
345
|
+
timeout=timeout,
|
|
346
|
+
gen_newfileuid=gen_newfileuid,
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
# HACK FIXME
|
|
350
|
+
# As a simplification, we assume that FIs handle all classes
|
|
351
|
+
# of statement request from a single URL.
|
|
352
|
+
urls = set(RqCls2url.values())
|
|
353
|
+
assert len(urls) == 1
|
|
354
|
+
url = urls.pop()
|
|
355
|
+
logger.info(f"Received service url={url} from OFX profile response")
|
|
356
|
+
|
|
357
|
+
logger.info(f"Creating statement requests for {requests}")
|
|
358
|
+
# Group requests by type and pass to the appropriate *TRNRQ handler
|
|
359
|
+
# function (see singledispatch setup below).
|
|
360
|
+
#
|
|
361
|
+
# Classes don't have rich comparison methods, so we can't sort by class.
|
|
362
|
+
# As a proxy, we sort by class name, even though we actually group by class
|
|
363
|
+
# so we can use it when iterating over groupby().
|
|
364
|
+
sortKey = attrgetter("__class__.__name__")
|
|
365
|
+
groupKey = attrgetter("__class__")
|
|
366
|
+
trnrqs = [
|
|
367
|
+
wrap_stmtrq(cls(), rqs, self)
|
|
368
|
+
for cls, rqs in itertools.groupby(
|
|
369
|
+
sorted(requests, key=sortKey), key=groupKey
|
|
370
|
+
)
|
|
371
|
+
]
|
|
372
|
+
|
|
373
|
+
# trnrqs is a pair of (models.*MSGSRQV1, [*TRNRQ])
|
|
374
|
+
# Can't sort *MSGSRQV1 by class, either, so we use the same trick
|
|
375
|
+
# of sorting by class name and grouping by class.
|
|
376
|
+
def trnSortKey(pair):
|
|
377
|
+
return pair[0].__name__
|
|
378
|
+
|
|
379
|
+
trnGroupKey = itemgetter(0)
|
|
380
|
+
trnrqs.sort(key=trnSortKey)
|
|
381
|
+
|
|
382
|
+
# N.B. we need to annotate first arg as typing.Type here to indicate that
|
|
383
|
+
# we're passing in a class not an instance.
|
|
384
|
+
def msg_args(
|
|
385
|
+
msgcls: type[BANKMSGSRQV1]
|
|
386
|
+
| type[CREDITCARDMSGSRQV1]
|
|
387
|
+
| type[INVSTMTMSGSRQV1],
|
|
388
|
+
trnrqs: Iterator[Request],
|
|
389
|
+
) -> tuple[str, Message]:
|
|
390
|
+
trnrqs_ = list(itertools.chain.from_iterable(t[1] for t in trnrqs))
|
|
391
|
+
attr_name = msgcls.__name__.lower()
|
|
392
|
+
return (attr_name, msgcls(*trnrqs_))
|
|
393
|
+
|
|
394
|
+
msgs = dict(
|
|
395
|
+
msg_args(msgcls, _trnrqs)
|
|
396
|
+
for msgcls, _trnrqs in itertools.groupby(trnrqs, key=trnGroupKey)
|
|
397
|
+
)
|
|
398
|
+
logger.debug(f"Wrapped statement request messages: {msgs}")
|
|
399
|
+
|
|
400
|
+
signon = self.signon(password)
|
|
401
|
+
ofx = OFX(signonmsgsrqv1=signon, **msgs)
|
|
402
|
+
|
|
403
|
+
if gen_newfileuid:
|
|
404
|
+
newfileuid = self.uuid
|
|
405
|
+
else:
|
|
406
|
+
newfileuid = None
|
|
407
|
+
|
|
408
|
+
return self.download(
|
|
409
|
+
ofx,
|
|
410
|
+
newfileuid=newfileuid,
|
|
411
|
+
dryrun=dryrun,
|
|
412
|
+
timeout=timeout,
|
|
413
|
+
url=url,
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
def _get_service_urls(
|
|
417
|
+
self,
|
|
418
|
+
timeout: float | None = None,
|
|
419
|
+
gen_newfileuid: bool = True,
|
|
420
|
+
) -> dict:
|
|
421
|
+
"""Query OFX profile endpoint to construct mapping of statement request
|
|
422
|
+
data container to URL providing that service.
|
|
423
|
+
"""
|
|
424
|
+
profile = self.request_profile(
|
|
425
|
+
gen_newfileuid=gen_newfileuid,
|
|
426
|
+
timeout=timeout,
|
|
427
|
+
)
|
|
428
|
+
parser = OFXTree()
|
|
429
|
+
parser.parse(profile)
|
|
430
|
+
ofx = parser.convert()
|
|
431
|
+
proftrnrs = ofx.profmsgsrsv1[0]
|
|
432
|
+
msgsetlist = proftrnrs.msgsetlist # proxy access to SubAggregate attributes
|
|
433
|
+
classmap = {
|
|
434
|
+
BANKMSGSET: StmtRq,
|
|
435
|
+
CREDITCARDMSGSET: CcStmtRq,
|
|
436
|
+
INVSTMTMSGSET: InvStmtRq,
|
|
437
|
+
}
|
|
438
|
+
urls = {
|
|
439
|
+
RqCls: msgset.url # proxy access to SubAggregate attributes
|
|
440
|
+
for msgset in msgsetlist
|
|
441
|
+
if (RqCls := classmap.get(type(msgset), None)) is not None
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
# Also map *STMTENDRQ
|
|
445
|
+
def map_stmtendrq_urls(
|
|
446
|
+
msgsetCls: MsgsetClass,
|
|
447
|
+
stmtendrqCls: type[StmtEndRq] | type[CcStmtEndRq],
|
|
448
|
+
):
|
|
449
|
+
try:
|
|
450
|
+
index = [type(msgset) for msgset in msgsetlist].index(msgsetCls)
|
|
451
|
+
except ValueError:
|
|
452
|
+
pass
|
|
453
|
+
else:
|
|
454
|
+
msgset = msgsetlist[index]
|
|
455
|
+
if msgset.closingavail: # proxy access to SubAggregate attributes
|
|
456
|
+
urls[stmtendrqCls] = msgset.url # proxy access to SubAgg attributes
|
|
457
|
+
|
|
458
|
+
map_stmtendrq_urls(BANKMSGSET, StmtEndRq)
|
|
459
|
+
map_stmtendrq_urls(CREDITCARDMSGSET, CcStmtEndRq)
|
|
460
|
+
|
|
461
|
+
return urls
|
|
462
|
+
|
|
463
|
+
def request_profile(
|
|
464
|
+
self,
|
|
465
|
+
version: int | None = None,
|
|
466
|
+
gen_newfileuid: bool = True,
|
|
467
|
+
prettyprint: bool | None = None,
|
|
468
|
+
close_elements: bool | None = None,
|
|
469
|
+
dryrun: bool = False,
|
|
470
|
+
timeout: float | None = None,
|
|
471
|
+
url: str | None = None,
|
|
472
|
+
persist: bool = True,
|
|
473
|
+
) -> BinaryIO:
|
|
474
|
+
"""Request/cache OFX profiles (PROFRS).
|
|
475
|
+
|
|
476
|
+
ofxget.scan_profile() overrides version/prettyprint/close_elements.
|
|
477
|
+
"""
|
|
478
|
+
filename = f"{self.org}-{self.fid}.profrs"
|
|
479
|
+
persistdir = config.DATADIR / "fiprofiles"
|
|
480
|
+
persistpath = persistdir / filename
|
|
481
|
+
|
|
482
|
+
if persistpath.exists():
|
|
483
|
+
with open(persistpath, "rb") as f:
|
|
484
|
+
profrs: BytesIO | None = BytesIO(f.read())
|
|
485
|
+
|
|
486
|
+
parser = OFXTree()
|
|
487
|
+
parser.parse(profrs)
|
|
488
|
+
ofx = parser.convert()
|
|
489
|
+
proftrnrs = ofx.profmsgsrsv1[0]
|
|
490
|
+
dtprofup = proftrnrs.profrs.dtprofup
|
|
491
|
+
else:
|
|
492
|
+
persistdir.mkdir(parents=True, exist_ok=True)
|
|
493
|
+
profrs = None
|
|
494
|
+
dtprofup = None
|
|
495
|
+
|
|
496
|
+
response = self._request_profile(
|
|
497
|
+
dtprofup=dtprofup,
|
|
498
|
+
version=version,
|
|
499
|
+
gen_newfileuid=gen_newfileuid,
|
|
500
|
+
prettyprint=prettyprint,
|
|
501
|
+
close_elements=close_elements,
|
|
502
|
+
dryrun=dryrun,
|
|
503
|
+
timeout=timeout,
|
|
504
|
+
url=url,
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
if dryrun:
|
|
508
|
+
return response
|
|
509
|
+
|
|
510
|
+
parser = OFXTree()
|
|
511
|
+
parser.parse(response)
|
|
512
|
+
ofx = parser.convert()
|
|
513
|
+
|
|
514
|
+
# If the client has the latest version of the FIs profile, the server returns
|
|
515
|
+
# status code 1 in the <STATUS> aggregate of the profile-transaction aggregate
|
|
516
|
+
# <PROFTRNRS>. The server does not return a profile- response aggregate <PROFRS>.
|
|
517
|
+
|
|
518
|
+
# If the client does not have the latest version of the FI profile, the server
|
|
519
|
+
# responds with the profile-response aggregate <PROFRS> in the profile-transaction
|
|
520
|
+
# aggregate <PROFTRNRS>.
|
|
521
|
+
proftrnrs = ofx.profmsgsrsv1[0]
|
|
522
|
+
if proftrnrs.status.code == 1:
|
|
523
|
+
assert profrs is not None
|
|
524
|
+
response = profrs
|
|
525
|
+
else:
|
|
526
|
+
assert proftrnrs.status.code == 0
|
|
527
|
+
dtprofup_server = proftrnrs.profrs.dtprofup
|
|
528
|
+
assert dtprofup is None or dtprofup <= dtprofup_server
|
|
529
|
+
|
|
530
|
+
# Cache the updated PROFRS sent by the server
|
|
531
|
+
response.seek(0)
|
|
532
|
+
with open(persistpath, "wb") as f:
|
|
533
|
+
f.write(response.read())
|
|
534
|
+
|
|
535
|
+
# Rewind PROFRS so it can be returned cleanly after having been parsed.
|
|
536
|
+
response.seek(0)
|
|
537
|
+
|
|
538
|
+
return response
|
|
539
|
+
|
|
540
|
+
def _request_profile(
|
|
541
|
+
self,
|
|
542
|
+
dtprofup: datetime.datetime | None = None,
|
|
543
|
+
version: int | None = None,
|
|
544
|
+
gen_newfileuid: bool = True,
|
|
545
|
+
prettyprint: bool | None = None,
|
|
546
|
+
close_elements: bool | None = None,
|
|
547
|
+
dryrun: bool = False,
|
|
548
|
+
timeout: float | None = None,
|
|
549
|
+
url: str | None = None,
|
|
550
|
+
) -> BytesIO:
|
|
551
|
+
"""Package and send OFX profile requests (PROFRQ)."""
|
|
552
|
+
logger.info("Creating profile request")
|
|
553
|
+
|
|
554
|
+
if dtprofup is None:
|
|
555
|
+
dtprofup = datetime.datetime(1990, 1, 1, tzinfo=UTC)
|
|
556
|
+
profrq = PROFRQ(clientrouting="NONE", dtprofup=dtprofup)
|
|
557
|
+
proftrnrq = PROFTRNRQ(trnuid=self.uuid, profrq=profrq)
|
|
558
|
+
|
|
559
|
+
logger.debug(f"Wrapped profile request: {proftrnrq}")
|
|
560
|
+
|
|
561
|
+
user = password = AUTH_PLACEHOLDER
|
|
562
|
+
signon = self.signon(password, userid=user)
|
|
563
|
+
|
|
564
|
+
ofx = OFX(signonmsgsrqv1=signon, profmsgsrqv1=PROFMSGSRQV1(proftrnrq))
|
|
565
|
+
|
|
566
|
+
if gen_newfileuid:
|
|
567
|
+
newfileuid = self.uuid
|
|
568
|
+
else:
|
|
569
|
+
newfileuid = None
|
|
570
|
+
|
|
571
|
+
return self.download(
|
|
572
|
+
ofx,
|
|
573
|
+
version=version,
|
|
574
|
+
newfileuid=newfileuid,
|
|
575
|
+
prettyprint=prettyprint,
|
|
576
|
+
close_elements=close_elements,
|
|
577
|
+
dryrun=dryrun,
|
|
578
|
+
timeout=timeout,
|
|
579
|
+
url=url,
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
def request_accounts(
|
|
583
|
+
self,
|
|
584
|
+
password: str,
|
|
585
|
+
dtacctup: datetime.datetime,
|
|
586
|
+
dryrun: bool = False,
|
|
587
|
+
version: int | None = None,
|
|
588
|
+
gen_newfileuid: bool = True,
|
|
589
|
+
timeout: float | None = None,
|
|
590
|
+
skip_profile: bool = False,
|
|
591
|
+
) -> BinaryIO:
|
|
592
|
+
"""
|
|
593
|
+
Package and send OFX account info requests (ACCTINFORQ)
|
|
594
|
+
"""
|
|
595
|
+
if dryrun:
|
|
596
|
+
url = ""
|
|
597
|
+
elif skip_profile:
|
|
598
|
+
url = self.url
|
|
599
|
+
else:
|
|
600
|
+
RqCls2url = self._get_service_urls(
|
|
601
|
+
timeout=timeout,
|
|
602
|
+
gen_newfileuid=gen_newfileuid,
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
# HACK FIXME
|
|
606
|
+
# As a simplification, we assume that FIs handle all classes
|
|
607
|
+
# of statement request from a single URL.
|
|
608
|
+
urls = set(RqCls2url.values())
|
|
609
|
+
assert len(urls) == 1
|
|
610
|
+
url = urls.pop()
|
|
611
|
+
|
|
612
|
+
logger.info("Creating account info request")
|
|
613
|
+
signon = self.signon(password)
|
|
614
|
+
|
|
615
|
+
acctinforq = ACCTINFORQ(dtacctup=dtacctup)
|
|
616
|
+
acctinfotrnrq = ACCTINFOTRNRQ(trnuid=self.uuid, acctinforq=acctinforq)
|
|
617
|
+
msgs = SIGNUPMSGSRQV1(acctinfotrnrq)
|
|
618
|
+
|
|
619
|
+
logger.debug(f"Wrapped account info request messages: {msgs}")
|
|
620
|
+
|
|
621
|
+
ofx = OFX(signonmsgsrqv1=signon, signupmsgsrqv1=msgs)
|
|
622
|
+
|
|
623
|
+
if gen_newfileuid:
|
|
624
|
+
newfileuid = self.uuid
|
|
625
|
+
else:
|
|
626
|
+
newfileuid = None
|
|
627
|
+
|
|
628
|
+
return self.download(
|
|
629
|
+
ofx,
|
|
630
|
+
newfileuid=newfileuid,
|
|
631
|
+
dryrun=dryrun,
|
|
632
|
+
timeout=timeout,
|
|
633
|
+
url=url,
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
def request_tax1099(
|
|
637
|
+
self,
|
|
638
|
+
password: str,
|
|
639
|
+
*taxyears: str,
|
|
640
|
+
acctnum: str | None = None,
|
|
641
|
+
recid: str | None = None,
|
|
642
|
+
gen_newfileuid: bool = True,
|
|
643
|
+
dryrun: bool = False,
|
|
644
|
+
timeout: float | None = None,
|
|
645
|
+
skip_profile: bool = False,
|
|
646
|
+
) -> BinaryIO:
|
|
647
|
+
"""
|
|
648
|
+
Request US federal income tax form 1099 (TAX1099RQ)
|
|
649
|
+
"""
|
|
650
|
+
if dryrun:
|
|
651
|
+
url = ""
|
|
652
|
+
elif skip_profile:
|
|
653
|
+
url = self.url
|
|
654
|
+
else:
|
|
655
|
+
RqCls2url = self._get_service_urls(
|
|
656
|
+
timeout=timeout,
|
|
657
|
+
gen_newfileuid=gen_newfileuid,
|
|
658
|
+
)
|
|
659
|
+
|
|
660
|
+
# HACK FIXME
|
|
661
|
+
# As a simplification, we assume that FIs handle all classes
|
|
662
|
+
# of statement request from a single URL.
|
|
663
|
+
urls = set(RqCls2url.values())
|
|
664
|
+
assert len(urls) == 1
|
|
665
|
+
url = urls.pop()
|
|
666
|
+
|
|
667
|
+
logger.info("Creating tax 1099 request")
|
|
668
|
+
signon = self.signon(password)
|
|
669
|
+
|
|
670
|
+
rq = TAX1099RQ(*taxyears, recid=recid or None)
|
|
671
|
+
msgs = TAX1099MSGSRQV1(TAX1099TRNRQ(trnuid=self.uuid, tax1099rq=rq))
|
|
672
|
+
|
|
673
|
+
logger.debug(f"Wrapped tax 1099 request messages: {msgs}")
|
|
674
|
+
|
|
675
|
+
ofx = OFX(signonmsgsrqv1=signon, tax1099msgsrqv1=msgs)
|
|
676
|
+
|
|
677
|
+
if gen_newfileuid:
|
|
678
|
+
newfileuid = self.uuid
|
|
679
|
+
else:
|
|
680
|
+
newfileuid = None
|
|
681
|
+
|
|
682
|
+
return self.download(
|
|
683
|
+
ofx,
|
|
684
|
+
newfileuid=newfileuid,
|
|
685
|
+
dryrun=dryrun,
|
|
686
|
+
timeout=timeout,
|
|
687
|
+
url=url,
|
|
688
|
+
)
|
|
689
|
+
|
|
690
|
+
def signon(
|
|
691
|
+
self,
|
|
692
|
+
userpass: str,
|
|
693
|
+
userid: str | None = None,
|
|
694
|
+
sesscookie: str | None = None,
|
|
695
|
+
) -> SIGNONMSGSRQV1:
|
|
696
|
+
"""Construct SONRQ; package in SIGNONMSGSRQV1"""
|
|
697
|
+
if self.org:
|
|
698
|
+
fi: FI | None = FI(org=self.org, fid=self.fid)
|
|
699
|
+
else:
|
|
700
|
+
fi = None
|
|
701
|
+
|
|
702
|
+
if userid is None:
|
|
703
|
+
userid = self.userid
|
|
704
|
+
|
|
705
|
+
# CLIENTUID was introduced to the spec in OFXv1.0.3
|
|
706
|
+
if self.version < 103:
|
|
707
|
+
clientuid = None
|
|
708
|
+
else:
|
|
709
|
+
clientuid = self.clientuid
|
|
710
|
+
|
|
711
|
+
sonrq = SONRQ(
|
|
712
|
+
dtclient=self.dtclient(),
|
|
713
|
+
userid=userid,
|
|
714
|
+
userpass=userpass,
|
|
715
|
+
language=self.language,
|
|
716
|
+
fi=fi,
|
|
717
|
+
sesscookie=sesscookie,
|
|
718
|
+
appid=self.appid,
|
|
719
|
+
appver=self.appver,
|
|
720
|
+
clientuid=clientuid,
|
|
721
|
+
)
|
|
722
|
+
return SIGNONMSGSRQV1(sonrq=sonrq)
|
|
723
|
+
|
|
724
|
+
def stmttrnrq(
|
|
725
|
+
self,
|
|
726
|
+
bankid: str,
|
|
727
|
+
acctid: str,
|
|
728
|
+
accttype: str,
|
|
729
|
+
dtstart: datetime.datetime | None = None,
|
|
730
|
+
dtend: datetime.datetime | None = None,
|
|
731
|
+
inctran: bool = True,
|
|
732
|
+
) -> STMTTRNRQ:
|
|
733
|
+
"""Construct STMTRQ; package in STMTTRNRQ"""
|
|
734
|
+
acct = BANKACCTFROM(bankid=bankid, acctid=acctid, accttype=accttype)
|
|
735
|
+
inctran_ = INCTRAN(dtstart=dtstart, dtend=dtend, include=inctran)
|
|
736
|
+
stmtrq = STMTRQ(bankacctfrom=acct, inctran=inctran_)
|
|
737
|
+
trnuid = self.uuid
|
|
738
|
+
return STMTTRNRQ(trnuid=trnuid, stmtrq=stmtrq)
|
|
739
|
+
|
|
740
|
+
def stmtendtrnrq(
|
|
741
|
+
self,
|
|
742
|
+
bankid: str,
|
|
743
|
+
acctid: str,
|
|
744
|
+
accttype: str,
|
|
745
|
+
dtstart: datetime.datetime | None = None,
|
|
746
|
+
dtend: datetime.datetime | None = None,
|
|
747
|
+
) -> STMTENDTRNRQ:
|
|
748
|
+
"""Construct STMTENDRQ; package in STMTENDTRNRQ"""
|
|
749
|
+
acct = BANKACCTFROM(bankid=bankid, acctid=acctid, accttype=accttype)
|
|
750
|
+
stmtrq = STMTENDRQ(bankacctfrom=acct, dtstart=dtstart, dtend=dtend)
|
|
751
|
+
trnuid = self.uuid
|
|
752
|
+
return STMTENDTRNRQ(trnuid=trnuid, stmtendrq=stmtrq)
|
|
753
|
+
|
|
754
|
+
def ccstmttrnrq(
|
|
755
|
+
self,
|
|
756
|
+
acctid: str,
|
|
757
|
+
dtstart: datetime.datetime | None = None,
|
|
758
|
+
dtend: datetime.datetime | None = None,
|
|
759
|
+
inctran: bool = True,
|
|
760
|
+
) -> CCSTMTTRNRQ:
|
|
761
|
+
"""Construct CCSTMTRQ; package in CCSTMTTRNRQ"""
|
|
762
|
+
acct = CCACCTFROM(acctid=acctid)
|
|
763
|
+
inctran_ = INCTRAN(dtstart=dtstart, dtend=dtend, include=inctran)
|
|
764
|
+
stmtrq = CCSTMTRQ(ccacctfrom=acct, inctran=inctran_)
|
|
765
|
+
trnuid = self.uuid
|
|
766
|
+
return CCSTMTTRNRQ(trnuid=trnuid, ccstmtrq=stmtrq)
|
|
767
|
+
|
|
768
|
+
def ccstmtendtrnrq(
|
|
769
|
+
self,
|
|
770
|
+
acctid: str,
|
|
771
|
+
dtstart: datetime.datetime | None = None,
|
|
772
|
+
dtend: datetime.datetime | None = None,
|
|
773
|
+
) -> CCSTMTENDTRNRQ:
|
|
774
|
+
"""Construct CCSTMTENDRQ; package in CCSTMTENDTRNRQ"""
|
|
775
|
+
acct = CCACCTFROM(acctid=acctid)
|
|
776
|
+
stmtrq = CCSTMTENDRQ(ccacctfrom=acct, dtstart=dtstart, dtend=dtend)
|
|
777
|
+
trnuid = self.uuid
|
|
778
|
+
return CCSTMTENDTRNRQ(trnuid=trnuid, ccstmtendrq=stmtrq)
|
|
779
|
+
|
|
780
|
+
def invstmttrnrq(
|
|
781
|
+
self,
|
|
782
|
+
acctid: str,
|
|
783
|
+
brokerid: str,
|
|
784
|
+
dtstart: datetime.datetime | None = None,
|
|
785
|
+
dtend: datetime.datetime | None = None,
|
|
786
|
+
inctran: bool = True,
|
|
787
|
+
incoo: bool = False,
|
|
788
|
+
dtasof: datetime.datetime | None = None,
|
|
789
|
+
incpos: bool = True,
|
|
790
|
+
incbal: bool = True,
|
|
791
|
+
) -> INVSTMTTRNRQ:
|
|
792
|
+
"""Construct INVSTMTRQ; package in INVSTMTTRNRQ"""
|
|
793
|
+
acct = INVACCTFROM(acctid=acctid, brokerid=brokerid)
|
|
794
|
+
if inctran:
|
|
795
|
+
inctran_: INCTRAN | None = INCTRAN(
|
|
796
|
+
dtstart=dtstart, dtend=dtend, include=inctran
|
|
797
|
+
)
|
|
798
|
+
else:
|
|
799
|
+
inctran_ = None
|
|
800
|
+
incpos_ = INCPOS(dtasof=dtasof, include=incpos)
|
|
801
|
+
stmtrq = INVSTMTRQ(
|
|
802
|
+
invacctfrom=acct,
|
|
803
|
+
inctran=inctran_,
|
|
804
|
+
incoo=incoo,
|
|
805
|
+
incpos=incpos_,
|
|
806
|
+
incbal=incbal,
|
|
807
|
+
)
|
|
808
|
+
trnuid = self.uuid
|
|
809
|
+
return INVSTMTTRNRQ(trnuid=trnuid, invstmtrq=stmtrq)
|
|
810
|
+
|
|
811
|
+
def download(
|
|
812
|
+
self,
|
|
813
|
+
ofx: OFX,
|
|
814
|
+
version: int | None = None,
|
|
815
|
+
oldfileuid: str | None = None,
|
|
816
|
+
newfileuid: str | None = None,
|
|
817
|
+
prettyprint: bool | None = None,
|
|
818
|
+
close_elements: bool | None = None,
|
|
819
|
+
dryrun: bool = False,
|
|
820
|
+
timeout: float | None = None,
|
|
821
|
+
url: str | None = None,
|
|
822
|
+
) -> BytesIO:
|
|
823
|
+
"""
|
|
824
|
+
Package complete OFX tree and POST to server.
|
|
825
|
+
|
|
826
|
+
N.B. ``version`` / ``prettyprint`` / ``close_elements`` kwargs are
|
|
827
|
+
basically hacks for ``scripts.ofxget.scan_profile()``; ordinarily you
|
|
828
|
+
should initialize the ``OFXClient`` with the proper version# and
|
|
829
|
+
formatting parameters, rather than overriding the client config here.
|
|
830
|
+
|
|
831
|
+
Optional kwargs:
|
|
832
|
+
``version`` - OFX version to report in header
|
|
833
|
+
``oldfileuid`` - OLDFILEUID to report in header
|
|
834
|
+
``newfileuid`` - NEWFILEUID to report in header
|
|
835
|
+
``prettyprint`` - add newlines between tags and indentation
|
|
836
|
+
``close_elements`` - add markup closing tags to leaf elements
|
|
837
|
+
``dryrun`` - dump serialized request to stdout instead of POSTing
|
|
838
|
+
``timeout`` - HTTP connection timeout (in seconds)
|
|
839
|
+
"""
|
|
840
|
+
request = self.serialize(
|
|
841
|
+
ofx,
|
|
842
|
+
version=version,
|
|
843
|
+
oldfileuid=oldfileuid,
|
|
844
|
+
newfileuid=newfileuid,
|
|
845
|
+
prettyprint=prettyprint,
|
|
846
|
+
close_elements=close_elements,
|
|
847
|
+
)
|
|
848
|
+
logger.debug(f"Finished request: {request.decode()}")
|
|
849
|
+
|
|
850
|
+
if dryrun:
|
|
851
|
+
return BytesIO(request)
|
|
852
|
+
|
|
853
|
+
if url is None:
|
|
854
|
+
url = self.url
|
|
855
|
+
|
|
856
|
+
# NB: we resolve the url opener here instead of in __init__ because the tests
|
|
857
|
+
# mock urlopen after instantiating the OFXClient object
|
|
858
|
+
response = self.post_request(url, request, timeout)
|
|
859
|
+
return BytesIO(response)
|
|
860
|
+
|
|
861
|
+
def post_request(
|
|
862
|
+
self, url: str, serialized_request: bytes, timeout: float | None
|
|
863
|
+
) -> bytes:
|
|
864
|
+
"""Separated out to facilitate mocking in unit tests."""
|
|
865
|
+
if timeout in (None, False):
|
|
866
|
+
# timeout = socket._GLOBAL_DEFAULT_TIMEOUT # type: ignore
|
|
867
|
+
timeout = 10.0
|
|
868
|
+
|
|
869
|
+
if USE_REQUESTS:
|
|
870
|
+
logger.info("Using requests lib to post request")
|
|
871
|
+
with requests.Session() as sess:
|
|
872
|
+
if self.persist_cookies:
|
|
873
|
+
sess.cookies = self.cookiejar # type: ignore
|
|
874
|
+
|
|
875
|
+
# Replace session default headers entirely rather than merging
|
|
876
|
+
# via the headers= kwarg. Some FIs (e.g. Amex) validate HTTP
|
|
877
|
+
# header ordering and reject requests where User-Agent is not
|
|
878
|
+
# the first header — which happens when requests prepends its
|
|
879
|
+
# own defaults before ours.
|
|
880
|
+
sess.headers = self.http_headers # type: ignore
|
|
881
|
+
|
|
882
|
+
response = sess.request(
|
|
883
|
+
method="POST",
|
|
884
|
+
url=url,
|
|
885
|
+
data=serialized_request,
|
|
886
|
+
timeout=timeout,
|
|
887
|
+
)
|
|
888
|
+
return response.content
|
|
889
|
+
|
|
890
|
+
else:
|
|
891
|
+
logger.info("Using urllib to post request")
|
|
892
|
+
handlers = []
|
|
893
|
+
if self.persist_cookies:
|
|
894
|
+
handlers.append(urllib_request.HTTPCookieProcessor(self.cookiejar))
|
|
895
|
+
opener = urllib_request.build_opener(*handlers)
|
|
896
|
+
|
|
897
|
+
req = urllib_request.Request(
|
|
898
|
+
url, method="POST", data=serialized_request, headers=self.http_headers
|
|
899
|
+
)
|
|
900
|
+
|
|
901
|
+
response = opener.open(req, timeout=timeout)
|
|
902
|
+
return response.read() # type: ignore
|
|
903
|
+
|
|
904
|
+
def serialize(
|
|
905
|
+
self,
|
|
906
|
+
ofx: OFX,
|
|
907
|
+
version: int | None = None,
|
|
908
|
+
oldfileuid: str | None = None,
|
|
909
|
+
newfileuid: str | None = None,
|
|
910
|
+
prettyprint: bool | None = None,
|
|
911
|
+
close_elements: bool | None = None,
|
|
912
|
+
) -> bytes:
|
|
913
|
+
"""
|
|
914
|
+
Transform a ``models.OFX`` instance into bytestring representation
|
|
915
|
+
with OFX header prepended.
|
|
916
|
+
|
|
917
|
+
N.B. ``version`` / ``prettyprint`` / ``close_elements`` kwargs are
|
|
918
|
+
basically hacks for ``scripts.ofxget.scan_profile()``; ordinarily you
|
|
919
|
+
should initialize the ``OFXClient`` with the proper version# and
|
|
920
|
+
formatting parameters, rather than overriding the client config here.
|
|
921
|
+
|
|
922
|
+
Optional kwargs:
|
|
923
|
+
``version`` - OFX version to report in header
|
|
924
|
+
``oldfileuid`` - OLDFILEUID to report in header
|
|
925
|
+
``newfileuid`` - NEWFILEUID to report in header
|
|
926
|
+
``prettyprint`` - add newlines between tags and indentation
|
|
927
|
+
``close_elements`` - add markup closing tags to leaf elements
|
|
928
|
+
"""
|
|
929
|
+
if version is None:
|
|
930
|
+
version = self.version
|
|
931
|
+
if prettyprint is None:
|
|
932
|
+
prettyprint = self.prettyprint
|
|
933
|
+
if close_elements is None:
|
|
934
|
+
close_elements = self.close_elements
|
|
935
|
+
|
|
936
|
+
header = bytes(
|
|
937
|
+
str(
|
|
938
|
+
make_header(
|
|
939
|
+
version=version, oldfileuid=oldfileuid, newfileuid=newfileuid
|
|
940
|
+
)
|
|
941
|
+
),
|
|
942
|
+
"utf_8",
|
|
943
|
+
)
|
|
944
|
+
|
|
945
|
+
tree = ofx.to_etree()
|
|
946
|
+
if prettyprint:
|
|
947
|
+
utils.indent(tree)
|
|
948
|
+
|
|
949
|
+
# Some servers choke on OFXv1 requests including ending tags for
|
|
950
|
+
# elements (which are optional per the spec).
|
|
951
|
+
if close_elements is False:
|
|
952
|
+
if version >= 200:
|
|
953
|
+
raise ValueError(
|
|
954
|
+
f"OFX version {version} requires ending tags for elements"
|
|
955
|
+
)
|
|
956
|
+
body = utils.tostring_unclosed_elements(tree)
|
|
957
|
+
else:
|
|
958
|
+
# ``method="html"`` skips the initial XML declaration
|
|
959
|
+
body = ET.tostring(tree, encoding="utf_8", method="html")
|
|
960
|
+
|
|
961
|
+
return header + body
|
|
962
|
+
|
|
963
|
+
|
|
964
|
+
@singledispatch
|
|
965
|
+
def wrap_stmtrq(nt, rqs, client):
|
|
966
|
+
raise ValueError(f"Not a *StmtRq/*StmtEndRq: {nt.__class__.__name__}")
|
|
967
|
+
|
|
968
|
+
|
|
969
|
+
@wrap_stmtrq.register(StmtRq)
|
|
970
|
+
def wrap_stmtrq_stmtrq(nt, rqs, client):
|
|
971
|
+
return (
|
|
972
|
+
BANKMSGSRQV1,
|
|
973
|
+
[client.stmttrnrq(**dict(rq._asdict(), bankid=client.bankid)) for rq in rqs],
|
|
974
|
+
)
|
|
975
|
+
|
|
976
|
+
|
|
977
|
+
@wrap_stmtrq.register(CcStmtRq)
|
|
978
|
+
def wrap_stmtrq_ccstmtrq(nt, rqs, client):
|
|
979
|
+
return (CREDITCARDMSGSRQV1, [client.ccstmttrnrq(**rq._asdict()) for rq in rqs])
|
|
980
|
+
|
|
981
|
+
|
|
982
|
+
@wrap_stmtrq.register(InvStmtRq)
|
|
983
|
+
def wrap_stmtrq_invstmtrq(nt, rqs, client):
|
|
984
|
+
return (
|
|
985
|
+
INVSTMTMSGSRQV1,
|
|
986
|
+
[
|
|
987
|
+
client.invstmttrnrq(**dict(r._asdict(), brokerid=client.brokerid))
|
|
988
|
+
for r in rqs
|
|
989
|
+
],
|
|
990
|
+
)
|
|
991
|
+
|
|
992
|
+
|
|
993
|
+
@wrap_stmtrq.register(StmtEndRq)
|
|
994
|
+
def wrap_stmtrq_stmtendrq(nt, rqs, client):
|
|
995
|
+
return (
|
|
996
|
+
BANKMSGSRQV1,
|
|
997
|
+
[client.stmtendtrnrq(**dict(rq._asdict(), bankid=client.bankid)) for rq in rqs],
|
|
998
|
+
)
|
|
999
|
+
|
|
1000
|
+
|
|
1001
|
+
@wrap_stmtrq.register(CcStmtEndRq)
|
|
1002
|
+
def wrap_stmtrq_ccstmtendrq(nt, rqs, client):
|
|
1003
|
+
return (CREDITCARDMSGSRQV1, [client.ccstmtendtrnrq(**rq._asdict()) for rq in rqs])
|