zenmoney-python 0.7.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.
- zenmoney_api/__init__.py +11 -0
- zenmoney_api/client.py +167 -0
- zenmoney_api/entities.py +63 -0
- zenmoney_api/errors.py +21 -0
- zenmoney_api/models.py +180 -0
- zenmoney_api/oauth.py +169 -0
- zenmoney_api/protocol.py +219 -0
- zenmoney_api/py.typed +0 -0
- zenmoney_api/transactions.py +148 -0
- zenmoney_python-0.7.0.dist-info/METADATA +224 -0
- zenmoney_python-0.7.0.dist-info/RECORD +14 -0
- zenmoney_python-0.7.0.dist-info/WHEEL +5 -0
- zenmoney_python-0.7.0.dist-info/licenses/LICENSE +21 -0
- zenmoney_python-0.7.0.dist-info/top_level.txt +1 -0
zenmoney_api/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Public ZenMoney HTTP API, independent of applications and banks."""
|
|
2
|
+
from .client import Client, AsyncClient
|
|
3
|
+
from .errors import ZenMoneyError, ClientClosedError, HTTPError, TransportError, ResponseError
|
|
4
|
+
from .oauth import OAuthClient, AsyncOAuthClient, Token, authorization_url, callback_code
|
|
5
|
+
from .protocol import Diff, KINDS, FORCE_FETCH, WRITABLE, DELETABLE, entity_key
|
|
6
|
+
from . import models, transactions, entities
|
|
7
|
+
|
|
8
|
+
__all__ = ['Client', 'AsyncClient', 'OAuthClient', 'AsyncOAuthClient', 'Token',
|
|
9
|
+
'authorization_url', 'callback_code', 'ZenMoneyError', 'HTTPError',
|
|
10
|
+
'TransportError', 'ResponseError', 'ClientClosedError', 'Diff', 'KINDS', 'FORCE_FETCH',
|
|
11
|
+
'WRITABLE', 'DELETABLE', 'entity_key', 'models', 'transactions', 'entities']
|
zenmoney_api/client.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Synchronous/asynchronous public API clients with explicit resource ownership."""
|
|
2
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
3
|
+
from math import isfinite
|
|
4
|
+
import time
|
|
5
|
+
from typing import Any, Generic, Self, TypeVar, overload
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from .errors import ClientClosedError, HTTPError, ResponseError, TransportError
|
|
10
|
+
from .protocol import Changes, Diff, EntityData, diff_payload, decode_diff, encode_json, suggest_payload, decode_suggestion
|
|
11
|
+
|
|
12
|
+
BASE_URL = 'https://api.zenmoney.ru'
|
|
13
|
+
ENDPOINT = BASE_URL + '/v8/diff/'
|
|
14
|
+
Timeout = float | httpx.Timeout | None
|
|
15
|
+
Transport = TypeVar('Transport', httpx.BaseTransport, httpx.AsyncBaseTransport)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def validate_timeout(timeout: Timeout) -> None:
|
|
19
|
+
values = (timeout.connect, timeout.read, timeout.write, timeout.pool) if isinstance(timeout, httpx.Timeout) else (timeout,)
|
|
20
|
+
if any(value is not None and (type(value) not in (int, float) or not isfinite(value) or value <= 0) for value in values):
|
|
21
|
+
raise ValueError('Timeouts must be positive finite numbers or None')
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class _Config(Generic[Transport]):
|
|
25
|
+
def __init__(self, token: str, *, transport: Transport | None = None,
|
|
26
|
+
timeout: Timeout = 60, max_response_bytes: int = 64*1024*1024,
|
|
27
|
+
clock: Callable[[], float] = time.time):
|
|
28
|
+
if not isinstance(token, str) or not token or any(c.isspace() for c in token):
|
|
29
|
+
raise ValueError('A nonempty bearer token without whitespace is required')
|
|
30
|
+
if type(max_response_bytes) is not int or max_response_bytes <= 0:
|
|
31
|
+
raise ValueError('Response limit must be a positive integer')
|
|
32
|
+
validate_timeout(timeout)
|
|
33
|
+
self._token = token
|
|
34
|
+
self._transport: Transport | None = transport
|
|
35
|
+
self._timeout, self._limit, self._clock = timeout, max_response_bytes, clock
|
|
36
|
+
self._closed = False
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def is_closed(self) -> bool:
|
|
40
|
+
return self._closed
|
|
41
|
+
|
|
42
|
+
def _check_open(self) -> None:
|
|
43
|
+
if self._closed:
|
|
44
|
+
raise ClientClosedError('ZenMoney client is closed')
|
|
45
|
+
|
|
46
|
+
def _options(self) -> dict[str, Any]:
|
|
47
|
+
return dict(timeout=self._timeout, transport=self._transport, follow_redirects=False, trust_env=False)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Client(_Config[httpx.BaseTransport]):
|
|
51
|
+
_http: httpx.Client | None = None
|
|
52
|
+
|
|
53
|
+
def __enter__(self) -> Self:
|
|
54
|
+
self._check_open()
|
|
55
|
+
return self
|
|
56
|
+
|
|
57
|
+
def __exit__(self, *args: Any) -> None:
|
|
58
|
+
self.close()
|
|
59
|
+
|
|
60
|
+
def close(self) -> None:
|
|
61
|
+
if not self._closed:
|
|
62
|
+
self._closed = True
|
|
63
|
+
if self._http is not None:
|
|
64
|
+
self._http.close()
|
|
65
|
+
self._http = None
|
|
66
|
+
|
|
67
|
+
def _request(self, path: str, payload: Any) -> bytes:
|
|
68
|
+
self._check_open()
|
|
69
|
+
content = encode_json(payload)
|
|
70
|
+
try:
|
|
71
|
+
if self._http is None:
|
|
72
|
+
self._http = httpx.Client(**self._options())
|
|
73
|
+
with self._http.stream('POST', BASE_URL+path, headers={'Authorization': f'Bearer {self._token}', 'Content-Type': 'application/json'}, content=content) as response:
|
|
74
|
+
if response.status_code != 200:
|
|
75
|
+
raise HTTPError(response.status_code)
|
|
76
|
+
body = bytearray()
|
|
77
|
+
for chunk in response.iter_bytes():
|
|
78
|
+
if len(body)+len(chunk) > self._limit:
|
|
79
|
+
raise ResponseError('ZenMoney response exceeds configured limit')
|
|
80
|
+
body.extend(chunk)
|
|
81
|
+
return bytes(body)
|
|
82
|
+
except httpx.HTTPError:
|
|
83
|
+
raise TransportError('ZenMoney connection failed') from None
|
|
84
|
+
|
|
85
|
+
def diff(self, server_timestamp: int = 0, *, changes: Changes | None = None,
|
|
86
|
+
deletions: Sequence[Mapping[str, Any]] | None = None,
|
|
87
|
+
force_fetch: Sequence[str] | None = None) -> Diff:
|
|
88
|
+
"""Exchange complete objects; persist the cursor only after storing returned data."""
|
|
89
|
+
payload = diff_payload(server_timestamp, int(self._clock()), changes, deletions, force_fetch)
|
|
90
|
+
return decode_diff(self._request('/v8/diff/', payload), server_timestamp)
|
|
91
|
+
|
|
92
|
+
def read_diff(self, server_timestamp: int = 0, *, force_fetch: Sequence[str] | None = None) -> Diff:
|
|
93
|
+
return self.diff(server_timestamp, force_fetch=force_fetch)
|
|
94
|
+
|
|
95
|
+
def upsert(self, kind: str, entities: Sequence[Mapping[str, Any]], server_timestamp: int = 0) -> Diff:
|
|
96
|
+
"""Send complete objects; timestamps resolve conflicts on the server."""
|
|
97
|
+
return self.diff(server_timestamp, changes={kind: entities})
|
|
98
|
+
|
|
99
|
+
def delete(self, deletions: Sequence[Mapping[str, Any]], server_timestamp: int = 0) -> Diff:
|
|
100
|
+
return self.diff(server_timestamp, deletions=deletions)
|
|
101
|
+
|
|
102
|
+
@overload
|
|
103
|
+
def suggest(self, transactions: Mapping[str, Any]) -> EntityData: ...
|
|
104
|
+
@overload
|
|
105
|
+
def suggest(self, transactions: Sequence[Mapping[str, Any]]) -> list[EntityData]: ...
|
|
106
|
+
def suggest(self, transactions: Mapping[str, Any] | Sequence[Mapping[str, Any]]) -> EntityData | list[EntityData]:
|
|
107
|
+
payload = suggest_payload(transactions)
|
|
108
|
+
return decode_suggestion(self._request('/v8/suggest/', payload), payload)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class AsyncClient(_Config[httpx.AsyncBaseTransport]):
|
|
112
|
+
_http: httpx.AsyncClient | None = None
|
|
113
|
+
|
|
114
|
+
async def __aenter__(self) -> Self:
|
|
115
|
+
self._check_open()
|
|
116
|
+
return self
|
|
117
|
+
|
|
118
|
+
async def __aexit__(self, *args: Any) -> None:
|
|
119
|
+
await self.aclose()
|
|
120
|
+
|
|
121
|
+
async def aclose(self) -> None:
|
|
122
|
+
if not self._closed:
|
|
123
|
+
self._closed = True
|
|
124
|
+
if self._http is not None:
|
|
125
|
+
await self._http.aclose()
|
|
126
|
+
self._http = None
|
|
127
|
+
|
|
128
|
+
async def _request(self, path: str, payload: Any) -> bytes:
|
|
129
|
+
self._check_open()
|
|
130
|
+
content = encode_json(payload)
|
|
131
|
+
try:
|
|
132
|
+
if self._http is None:
|
|
133
|
+
self._http = httpx.AsyncClient(**self._options())
|
|
134
|
+
async with self._http.stream('POST', BASE_URL+path, headers={'Authorization': f'Bearer {self._token}', 'Content-Type': 'application/json'}, content=content) as response:
|
|
135
|
+
if response.status_code != 200:
|
|
136
|
+
raise HTTPError(response.status_code)
|
|
137
|
+
body = bytearray()
|
|
138
|
+
async for chunk in response.aiter_bytes():
|
|
139
|
+
if len(body)+len(chunk) > self._limit:
|
|
140
|
+
raise ResponseError('ZenMoney response exceeds configured limit')
|
|
141
|
+
body.extend(chunk)
|
|
142
|
+
return bytes(body)
|
|
143
|
+
except httpx.HTTPError:
|
|
144
|
+
raise TransportError('ZenMoney connection failed') from None
|
|
145
|
+
|
|
146
|
+
async def diff(self, server_timestamp: int = 0, *, changes: Changes | None = None,
|
|
147
|
+
deletions: Sequence[Mapping[str, Any]] | None = None,
|
|
148
|
+
force_fetch: Sequence[str] | None = None) -> Diff:
|
|
149
|
+
payload = diff_payload(server_timestamp, int(self._clock()), changes, deletions, force_fetch)
|
|
150
|
+
return decode_diff(await self._request('/v8/diff/', payload), server_timestamp)
|
|
151
|
+
|
|
152
|
+
async def read_diff(self, server_timestamp: int = 0, *, force_fetch: Sequence[str] | None = None) -> Diff:
|
|
153
|
+
return await self.diff(server_timestamp, force_fetch=force_fetch)
|
|
154
|
+
|
|
155
|
+
async def upsert(self, kind: str, entities: Sequence[Mapping[str, Any]], server_timestamp: int = 0) -> Diff:
|
|
156
|
+
return await self.diff(server_timestamp, changes={kind: entities})
|
|
157
|
+
|
|
158
|
+
async def delete(self, deletions: Sequence[Mapping[str, Any]], server_timestamp: int = 0) -> Diff:
|
|
159
|
+
return await self.diff(server_timestamp, deletions=deletions)
|
|
160
|
+
|
|
161
|
+
@overload
|
|
162
|
+
async def suggest(self, transactions: Mapping[str, Any]) -> EntityData: ...
|
|
163
|
+
@overload
|
|
164
|
+
async def suggest(self, transactions: Sequence[Mapping[str, Any]]) -> list[EntityData]: ...
|
|
165
|
+
async def suggest(self, transactions: Mapping[str, Any] | Sequence[Mapping[str, Any]]) -> EntityData | list[EntityData]:
|
|
166
|
+
payload = suggest_payload(transactions)
|
|
167
|
+
return decode_suggestion(await self._request('/v8/suggest/', payload), payload)
|
zenmoney_api/entities.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Complete catalog objects, including fields required by the current live API."""
|
|
2
|
+
import time
|
|
3
|
+
from copy import deepcopy
|
|
4
|
+
from typing import Any, cast
|
|
5
|
+
from uuid import uuid4
|
|
6
|
+
|
|
7
|
+
from .models import Account, Tag, Merchant
|
|
8
|
+
from .protocol import EntityData, encode_json, timestamp
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _base(user: int, title: str, id: str | None, now: int | None) -> EntityData:
|
|
12
|
+
if type(user) is not int or user <= 0:
|
|
13
|
+
raise ValueError('Expected a positive user ID')
|
|
14
|
+
if not isinstance(title, str) or not title.strip():
|
|
15
|
+
raise ValueError('Expected a nonempty title')
|
|
16
|
+
if id is not None and (not isinstance(id, str) or not id):
|
|
17
|
+
raise ValueError('Expected a nonempty ID')
|
|
18
|
+
return dict(id=str(uuid4()) if id is None else id, user=user, title=title,
|
|
19
|
+
changed=timestamp(int(time.time()) if now is None else now))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _extend(result: EntityData, fields: EntityData) -> EntityData:
|
|
23
|
+
if any(k in fields for k in ('id', 'user', 'title', 'changed')):
|
|
24
|
+
raise ValueError('Identity fields must use the explicit constructor parameters')
|
|
25
|
+
result.update(deepcopy(fields))
|
|
26
|
+
encode_json(result)
|
|
27
|
+
return result
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def account(*, user: int, title: str, instrument: int, account_type: str = 'cash',
|
|
31
|
+
id: str | None = None, now: int | None = None, **fields: Any) -> Account:
|
|
32
|
+
if type(instrument) is not int or instrument <= 0:
|
|
33
|
+
raise ValueError('Expected a positive instrument ID')
|
|
34
|
+
if account_type not in ('cash', 'ccard', 'checking', 'loan', 'deposit', 'emoney'):
|
|
35
|
+
raise ValueError('Unsupported new account type; use the existing system debt account')
|
|
36
|
+
result = _base(user, title, id, now)
|
|
37
|
+
result.update(role=None, instrument=instrument, company=None, type=account_type,
|
|
38
|
+
syncID=None, balance=0, startBalance=0, creditLimit=None, inBalance=True,
|
|
39
|
+
savings=False, enableCorrection=False, enableSMS=False, archive=False,
|
|
40
|
+
private=False, balanceCorrectionType='request', capitalization=None,
|
|
41
|
+
percent=None, startDate=None, endDateOffset=None, endDateOffsetInterval=None,
|
|
42
|
+
payoffStep=None, payoffInterval=None)
|
|
43
|
+
result = _extend(result, fields)
|
|
44
|
+
if account_type in ('loan', 'deposit') and any(result.get(k) is None for k in
|
|
45
|
+
('capitalization', 'percent', 'startDate', 'endDateOffset', 'endDateOffsetInterval')):
|
|
46
|
+
raise ValueError('Loan/deposit parameters are required')
|
|
47
|
+
return cast(Account, result)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def tag(*, user: int, title: str, id: str | None = None,
|
|
51
|
+
now: int | None = None, **fields: Any) -> Tag:
|
|
52
|
+
result = _base(user, title, id, now)
|
|
53
|
+
result.update(parent=None, icon=None, picture=None, color=None, showIncome=True,
|
|
54
|
+
showOutcome=True, budgetIncome=True, budgetOutcome=True, required=False,
|
|
55
|
+
archive=False, staticId=None)
|
|
56
|
+
return cast(Tag, _extend(result, fields))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def merchant(*, user: int, title: str, id: str | None = None,
|
|
60
|
+
now: int | None = None, **fields: Any) -> Merchant:
|
|
61
|
+
result = _base(user, title, id, now)
|
|
62
|
+
result['mcc'] = None
|
|
63
|
+
return cast(Merchant, _extend(result, fields))
|
zenmoney_api/errors.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Errors contain no response bodies, credentials or financial data."""
|
|
2
|
+
class ZenMoneyError(Exception):
|
|
3
|
+
pass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TransportError(ZenMoneyError):
|
|
7
|
+
"""The outcome of a write may be unknown. Do not automatically retry."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ResponseError(ZenMoneyError):
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class HTTPError(ZenMoneyError):
|
|
15
|
+
def __init__(self, status_code: int):
|
|
16
|
+
self.status_code = status_code
|
|
17
|
+
super().__init__(f'ZenMoney HTTP {status_code}')
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ClientClosedError(ZenMoneyError):
|
|
21
|
+
"""A closed client cannot be reopened; construct a new instance."""
|
zenmoney_api/models.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Wire field names match the public API. Reads retain all unknown server fields.
|
|
2
|
+
|
|
3
|
+
TypedDicts help editors; low-level diff performs structural, not full schema validation.
|
|
4
|
+
Use full fetched entities for updates; do not send partial TypedDicts as patches.
|
|
5
|
+
"""
|
|
6
|
+
from typing import TypedDict, Literal, Required
|
|
7
|
+
from decimal import Decimal
|
|
8
|
+
|
|
9
|
+
Number = int | float | Decimal
|
|
10
|
+
|
|
11
|
+
class Instrument(TypedDict, total=False):
|
|
12
|
+
id: int
|
|
13
|
+
changed: int
|
|
14
|
+
title: str
|
|
15
|
+
shortTitle: str
|
|
16
|
+
symbol: str
|
|
17
|
+
rate: Number
|
|
18
|
+
|
|
19
|
+
class Company(TypedDict, total=False):
|
|
20
|
+
id: int
|
|
21
|
+
changed: int
|
|
22
|
+
title: str
|
|
23
|
+
fullTitle: str | None
|
|
24
|
+
www: str | None
|
|
25
|
+
country: str | None
|
|
26
|
+
|
|
27
|
+
class User(TypedDict, total=False):
|
|
28
|
+
id: int
|
|
29
|
+
changed: int
|
|
30
|
+
login: str | None
|
|
31
|
+
currency: int
|
|
32
|
+
parent: int | None
|
|
33
|
+
|
|
34
|
+
class Entity(TypedDict, total=False):
|
|
35
|
+
id: str
|
|
36
|
+
changed: int
|
|
37
|
+
user: int
|
|
38
|
+
|
|
39
|
+
class Account(Entity, total=False):
|
|
40
|
+
private: bool
|
|
41
|
+
balanceCorrectionType: str
|
|
42
|
+
role: int | None
|
|
43
|
+
instrument: int | None
|
|
44
|
+
company: int | None
|
|
45
|
+
type: Literal['cash', 'ccard', 'checking', 'loan', 'deposit', 'emoney', 'debt']
|
|
46
|
+
title: str
|
|
47
|
+
syncID: list[str] | None
|
|
48
|
+
balance: Number | None
|
|
49
|
+
startBalance: Number | None
|
|
50
|
+
creditLimit: Number | None
|
|
51
|
+
inBalance: bool
|
|
52
|
+
savings: bool | None
|
|
53
|
+
enableCorrection: bool
|
|
54
|
+
enableSMS: bool
|
|
55
|
+
archive: bool
|
|
56
|
+
capitalization: bool | None
|
|
57
|
+
percent: Number | None
|
|
58
|
+
startDate: str | None
|
|
59
|
+
endDateOffset: int | None
|
|
60
|
+
endDateOffsetInterval: Literal['day', 'week', 'month', 'year'] | None
|
|
61
|
+
payoffStep: int | None
|
|
62
|
+
payoffInterval: Literal['month', 'year'] | None
|
|
63
|
+
|
|
64
|
+
class Tag(Entity, total=False):
|
|
65
|
+
archive: bool
|
|
66
|
+
staticId: str | None
|
|
67
|
+
title: str
|
|
68
|
+
parent: str | None
|
|
69
|
+
icon: str | None
|
|
70
|
+
picture: str | None
|
|
71
|
+
color: int | None
|
|
72
|
+
showIncome: bool
|
|
73
|
+
showOutcome: bool
|
|
74
|
+
budgetIncome: bool
|
|
75
|
+
budgetOutcome: bool
|
|
76
|
+
required: bool | None
|
|
77
|
+
|
|
78
|
+
class Merchant(Entity, total=False):
|
|
79
|
+
mcc: int | None
|
|
80
|
+
title: str
|
|
81
|
+
|
|
82
|
+
class Movement(Entity, total=False):
|
|
83
|
+
incomeInstrument: int
|
|
84
|
+
incomeAccount: str
|
|
85
|
+
income: Number
|
|
86
|
+
outcomeInstrument: int
|
|
87
|
+
outcomeAccount: str
|
|
88
|
+
outcome: Number
|
|
89
|
+
tag: list[str] | None
|
|
90
|
+
merchant: str | None
|
|
91
|
+
payee: str | None
|
|
92
|
+
comment: str | None
|
|
93
|
+
|
|
94
|
+
class Transaction(Movement, total=False):
|
|
95
|
+
created: int
|
|
96
|
+
deleted: bool
|
|
97
|
+
hold: bool | None
|
|
98
|
+
originalPayee: str | None
|
|
99
|
+
date: str
|
|
100
|
+
mcc: int | None
|
|
101
|
+
reminderMarker: str | None
|
|
102
|
+
opIncome: Number | None
|
|
103
|
+
opIncomeInstrument: int | None
|
|
104
|
+
opOutcome: Number | None
|
|
105
|
+
opOutcomeInstrument: int | None
|
|
106
|
+
latitude: Number | None
|
|
107
|
+
longitude: Number | None
|
|
108
|
+
viewed: bool
|
|
109
|
+
qrCode: str | None
|
|
110
|
+
source: str | None
|
|
111
|
+
incomeBankID: str | None
|
|
112
|
+
outcomeBankID: str | None
|
|
113
|
+
|
|
114
|
+
class Reminder(Movement, total=False):
|
|
115
|
+
interval: Literal['day', 'week', 'month', 'year'] | None
|
|
116
|
+
step: int | None
|
|
117
|
+
points: list[int] | None
|
|
118
|
+
startDate: str
|
|
119
|
+
endDate: str | None
|
|
120
|
+
notify: bool
|
|
121
|
+
|
|
122
|
+
class ReminderMarker(Movement, total=False):
|
|
123
|
+
isForecast: bool
|
|
124
|
+
date: str
|
|
125
|
+
reminder: str
|
|
126
|
+
state: Literal['planned', 'processed', 'deleted']
|
|
127
|
+
notify: bool
|
|
128
|
+
|
|
129
|
+
class Budget(TypedDict, total=False):
|
|
130
|
+
isIncomeForecast: bool
|
|
131
|
+
isOutcomeForecast: bool
|
|
132
|
+
changed: int
|
|
133
|
+
user: int
|
|
134
|
+
tag: str | None
|
|
135
|
+
date: str
|
|
136
|
+
income: Number
|
|
137
|
+
incomeLock: bool
|
|
138
|
+
outcome: Number
|
|
139
|
+
outcomeLock: bool
|
|
140
|
+
|
|
141
|
+
class Deletion(TypedDict):
|
|
142
|
+
id: str
|
|
143
|
+
object: str
|
|
144
|
+
user: int
|
|
145
|
+
stamp: int
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class TransactionFields(TypedDict, total=False):
|
|
149
|
+
"""Optional wire fields accepted by the transaction builder."""
|
|
150
|
+
originalPayee: str | None
|
|
151
|
+
reminderMarker: str | None
|
|
152
|
+
opIncome: Number | None
|
|
153
|
+
opIncomeInstrument: int | None
|
|
154
|
+
opOutcome: Number | None
|
|
155
|
+
opOutcomeInstrument: int | None
|
|
156
|
+
latitude: Number | None
|
|
157
|
+
longitude: Number | None
|
|
158
|
+
mcc: int | None
|
|
159
|
+
hold: bool | None
|
|
160
|
+
deleted: bool
|
|
161
|
+
viewed: bool
|
|
162
|
+
qrCode: str | None
|
|
163
|
+
source: str | None
|
|
164
|
+
incomeBankID: str | None
|
|
165
|
+
outcomeBankID: str | None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class BaseTransactionOptions(TransactionFields, total=False):
|
|
169
|
+
"""Shared keywords of all operation builders, excluding the counterparty."""
|
|
170
|
+
user: Required[int]
|
|
171
|
+
date: Required[str]
|
|
172
|
+
categories: list[str] | None
|
|
173
|
+
comment: str | None
|
|
174
|
+
merchant: str | None
|
|
175
|
+
id: str | None
|
|
176
|
+
now: int | None
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class TransactionOptions(BaseTransactionOptions, total=False):
|
|
180
|
+
payee: str | None
|
zenmoney_api/oauth.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Explicit OAuth code exchange/refresh; secrets and tokens have redacted reprs."""
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from typing import Any, Generic, Self, TypeVar
|
|
4
|
+
from urllib.parse import urlencode, urlsplit, parse_qs
|
|
5
|
+
from hmac import compare_digest
|
|
6
|
+
import httpx
|
|
7
|
+
from .client import BASE_URL, Timeout, validate_timeout
|
|
8
|
+
from .errors import ClientClosedError, HTTPError, TransportError, ResponseError
|
|
9
|
+
from .protocol import decode_json
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def authorization_url(client_id: str, redirect_uri: str, *, state: str) -> str:
|
|
13
|
+
if not all(isinstance(v, str) and v for v in (client_id, redirect_uri, state)):
|
|
14
|
+
raise ValueError('client_id, redirect_uri and unpredictable state are required')
|
|
15
|
+
return BASE_URL + '/oauth2/authorize/?' + urlencode(dict(response_type='code', client_id=client_id, redirect_uri=redirect_uri, state=state))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def callback_code(callback_url: str, *, expected_state: str) -> str:
|
|
19
|
+
if not isinstance(callback_url, str) or not isinstance(expected_state, str) or not expected_state:
|
|
20
|
+
raise ValueError('Callback URL and expected state are required')
|
|
21
|
+
query = parse_qs(urlsplit(callback_url).query, keep_blank_values=True)
|
|
22
|
+
states, codes = query.get('state', []), query.get('code', [])
|
|
23
|
+
if not expected_state or len(states) != 1 or not compare_digest(states[0].encode(), expected_state.encode()):
|
|
24
|
+
raise ValueError('OAuth state mismatch')
|
|
25
|
+
if 'error' in query or len(codes) != 1 or not codes[0]:
|
|
26
|
+
raise ResponseError('OAuth authorization was not completed')
|
|
27
|
+
return codes[0]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class Token:
|
|
32
|
+
access_token: str = field(repr=False)
|
|
33
|
+
token_type: str = 'bearer'
|
|
34
|
+
expires_in: int | None = None
|
|
35
|
+
refresh_token: str | None = field(default=None, repr=False)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _token(raw: bytes) -> Token:
|
|
39
|
+
data = decode_json(raw)
|
|
40
|
+
if not isinstance(data, dict) or not isinstance(data.get('access_token'), str) or not data['access_token'] or any(c.isspace() for c in data['access_token']) or str(data.get('token_type', '')).lower() != 'bearer':
|
|
41
|
+
raise ResponseError('Invalid OAuth token response')
|
|
42
|
+
if data.get('expires_in') is not None and (type(data['expires_in']) is not int or data['expires_in'] < 0):
|
|
43
|
+
raise ResponseError('Invalid OAuth token lifetime')
|
|
44
|
+
if data.get('refresh_token') is not None and (not isinstance(data['refresh_token'], str) or not data['refresh_token']):
|
|
45
|
+
raise ResponseError('Invalid refresh token')
|
|
46
|
+
return Token(data['access_token'], 'bearer', data.get('expires_in'), data.get('refresh_token'))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
Transport = TypeVar('Transport', httpx.BaseTransport, httpx.AsyncBaseTransport)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class _OAuthConfig(Generic[Transport]):
|
|
53
|
+
def __init__(self, client_id: str, client_secret: str, redirect_uri: str,
|
|
54
|
+
*, transport: Transport | None = None, timeout: Timeout = 60):
|
|
55
|
+
if not all(isinstance(v, str) and v for v in (client_id, client_secret, redirect_uri)):
|
|
56
|
+
raise ValueError('OAuth application credentials are required')
|
|
57
|
+
validate_timeout(timeout)
|
|
58
|
+
self._id, self._secret, self._redirect = client_id, client_secret, redirect_uri
|
|
59
|
+
self._transport: Transport | None = transport
|
|
60
|
+
self._timeout = timeout
|
|
61
|
+
self._closed = False
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def is_closed(self) -> bool:
|
|
65
|
+
return self._closed
|
|
66
|
+
|
|
67
|
+
def _check_open(self) -> None:
|
|
68
|
+
if self._closed:
|
|
69
|
+
raise ClientClosedError('ZenMoney OAuth client is closed')
|
|
70
|
+
|
|
71
|
+
def authorization_url(self, *, state: str) -> str:
|
|
72
|
+
return authorization_url(self._id, self._redirect, state=state)
|
|
73
|
+
|
|
74
|
+
def _form(self, grant_type: str, value: str) -> dict[str, str]:
|
|
75
|
+
self._check_open()
|
|
76
|
+
if not isinstance(value, str) or not value:
|
|
77
|
+
raise ValueError('Nonempty code or refresh token required')
|
|
78
|
+
form = dict(grant_type=grant_type, client_id=self._id, client_secret=self._secret)
|
|
79
|
+
if grant_type == 'authorization_code':
|
|
80
|
+
form.update(code=value, redirect_uri=self._redirect)
|
|
81
|
+
else:
|
|
82
|
+
form['refresh_token'] = value
|
|
83
|
+
return form
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class OAuthClient(_OAuthConfig[httpx.BaseTransport]):
|
|
87
|
+
_http: httpx.Client | None = None
|
|
88
|
+
|
|
89
|
+
def __enter__(self) -> Self:
|
|
90
|
+
self._check_open()
|
|
91
|
+
return self
|
|
92
|
+
|
|
93
|
+
def __exit__(self, *args: Any) -> None:
|
|
94
|
+
self.close()
|
|
95
|
+
|
|
96
|
+
def close(self) -> None:
|
|
97
|
+
if not self._closed:
|
|
98
|
+
self._closed = True
|
|
99
|
+
if self._http is not None:
|
|
100
|
+
self._http.close()
|
|
101
|
+
self._http = None
|
|
102
|
+
elif self._transport is not None:
|
|
103
|
+
self._transport.close()
|
|
104
|
+
|
|
105
|
+
def _request(self, form: dict[str, str]) -> Token:
|
|
106
|
+
self._check_open()
|
|
107
|
+
try:
|
|
108
|
+
if self._http is None:
|
|
109
|
+
self._http = httpx.Client(transport=self._transport, timeout=self._timeout, trust_env=False, follow_redirects=False)
|
|
110
|
+
with self._http.stream('POST', BASE_URL+'/oauth2/token/', data=form) as response:
|
|
111
|
+
if response.status_code != 200:
|
|
112
|
+
raise HTTPError(response.status_code)
|
|
113
|
+
body = bytearray()
|
|
114
|
+
for chunk in response.iter_bytes():
|
|
115
|
+
if len(body)+len(chunk) > 1024*1024:
|
|
116
|
+
raise ResponseError('OAuth response exceeds limit')
|
|
117
|
+
body.extend(chunk)
|
|
118
|
+
return _token(bytes(body))
|
|
119
|
+
except httpx.HTTPError:
|
|
120
|
+
raise TransportError('OAuth connection failed') from None
|
|
121
|
+
|
|
122
|
+
def exchange_code(self, code: str) -> Token:
|
|
123
|
+
return self._request(self._form('authorization_code', code))
|
|
124
|
+
|
|
125
|
+
def refresh(self, refresh_token: str) -> Token:
|
|
126
|
+
return self._request(self._form('refresh_token', refresh_token))
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class AsyncOAuthClient(_OAuthConfig[httpx.AsyncBaseTransport]):
|
|
130
|
+
_http: httpx.AsyncClient | None = None
|
|
131
|
+
|
|
132
|
+
async def __aenter__(self) -> Self:
|
|
133
|
+
self._check_open()
|
|
134
|
+
return self
|
|
135
|
+
|
|
136
|
+
async def __aexit__(self, *args: Any) -> None:
|
|
137
|
+
await self.aclose()
|
|
138
|
+
|
|
139
|
+
async def aclose(self) -> None:
|
|
140
|
+
if not self._closed:
|
|
141
|
+
self._closed = True
|
|
142
|
+
if self._http is not None:
|
|
143
|
+
await self._http.aclose()
|
|
144
|
+
self._http = None
|
|
145
|
+
elif self._transport is not None:
|
|
146
|
+
await self._transport.aclose()
|
|
147
|
+
|
|
148
|
+
async def _request(self, form: dict[str, str]) -> Token:
|
|
149
|
+
self._check_open()
|
|
150
|
+
try:
|
|
151
|
+
if self._http is None:
|
|
152
|
+
self._http = httpx.AsyncClient(transport=self._transport, timeout=self._timeout, trust_env=False, follow_redirects=False)
|
|
153
|
+
async with self._http.stream('POST', BASE_URL+'/oauth2/token/', data=form) as response:
|
|
154
|
+
if response.status_code != 200:
|
|
155
|
+
raise HTTPError(response.status_code)
|
|
156
|
+
body = bytearray()
|
|
157
|
+
async for chunk in response.aiter_bytes():
|
|
158
|
+
if len(body)+len(chunk) > 1024*1024:
|
|
159
|
+
raise ResponseError('OAuth response exceeds limit')
|
|
160
|
+
body.extend(chunk)
|
|
161
|
+
return _token(bytes(body))
|
|
162
|
+
except httpx.HTTPError:
|
|
163
|
+
raise TransportError('OAuth connection failed') from None
|
|
164
|
+
|
|
165
|
+
async def exchange_code(self, code: str) -> Token:
|
|
166
|
+
return await self._request(self._form('authorization_code', code))
|
|
167
|
+
|
|
168
|
+
async def refresh(self, refresh_token: str) -> Token:
|
|
169
|
+
return await self._request(self._form('refresh_token', refresh_token))
|
zenmoney_api/protocol.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Lossless wire protocol; accepts complete objects and preserves extensions."""
|
|
2
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
3
|
+
from copy import deepcopy
|
|
4
|
+
from decimal import Decimal
|
|
5
|
+
from math import isfinite
|
|
6
|
+
from typing import Any, TypeAlias, cast
|
|
7
|
+
|
|
8
|
+
import simplejson as json
|
|
9
|
+
|
|
10
|
+
from .errors import ResponseError
|
|
11
|
+
|
|
12
|
+
EntityData: TypeAlias = dict[str, Any]
|
|
13
|
+
Changes: TypeAlias = Mapping[str, Sequence[Mapping[str, Any]]]
|
|
14
|
+
KINDS = ('instrument', 'company', 'user', 'account', 'tag', 'merchant',
|
|
15
|
+
'budget', 'reminder', 'reminderMarker', 'transaction', 'country')
|
|
16
|
+
FORCE_FETCH = tuple(kind for kind in KINDS if kind != 'country')
|
|
17
|
+
WRITABLE = ('account', 'tag', 'merchant', 'budget', 'reminder', 'reminderMarker', 'transaction')
|
|
18
|
+
DELETABLE = tuple(kind for kind in WRITABLE if kind != 'budget')
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Diff(dict[str, Any]):
|
|
22
|
+
"""Decoded entities, including unknown fields, and the unchanged UTF-8 response."""
|
|
23
|
+
def __init__(self, data: EntityData, raw_json: str):
|
|
24
|
+
super().__init__(data)
|
|
25
|
+
self.raw_json = raw_json
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def server_timestamp(self) -> int:
|
|
29
|
+
return int(self['serverTimestamp'])
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def entity_key(kind: str, item: Mapping[str, Any]) -> str:
|
|
33
|
+
if kind == 'budget':
|
|
34
|
+
if not all(k in item for k in ('user', 'date', 'tag')):
|
|
35
|
+
raise ResponseError('Invalid budget key')
|
|
36
|
+
if type(item['user']) is not int or item['user'] <= 0 or not isinstance(item['date'], str):
|
|
37
|
+
raise ResponseError('Invalid budget key')
|
|
38
|
+
if item['tag'] is not None and not isinstance(item['tag'], str):
|
|
39
|
+
raise ResponseError('Invalid budget key')
|
|
40
|
+
return str(json.dumps([item['user'], item['date'], item['tag']], separators=(',', ':')))
|
|
41
|
+
identifier = item.get('id')
|
|
42
|
+
if type(identifier) not in (str, int) or identifier == '':
|
|
43
|
+
raise ResponseError('Invalid entity identifier')
|
|
44
|
+
return str(identifier)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def validate_diff(data: Any) -> None:
|
|
48
|
+
if not isinstance(data, dict) or type(data.get('serverTimestamp')) is not int or data['serverTimestamp'] < 0:
|
|
49
|
+
raise ResponseError('Invalid server timestamp')
|
|
50
|
+
if data.get('error'):
|
|
51
|
+
raise ResponseError('ZenMoney rejected diff')
|
|
52
|
+
for kind in KINDS:
|
|
53
|
+
rows = data.get(kind, [])
|
|
54
|
+
if not isinstance(rows, list):
|
|
55
|
+
raise ResponseError('Invalid entity collection')
|
|
56
|
+
for row in rows:
|
|
57
|
+
if not isinstance(row, dict):
|
|
58
|
+
raise ResponseError('Invalid entity')
|
|
59
|
+
entity_key(kind, row)
|
|
60
|
+
if not isinstance(data.get('deletion', []), list):
|
|
61
|
+
raise ResponseError('Invalid deletions')
|
|
62
|
+
for row in data.get('deletion', []):
|
|
63
|
+
if not isinstance(row, dict) or not isinstance(row.get('object'), str) or type(row.get('id')) not in (str, int):
|
|
64
|
+
raise ResponseError('Invalid deletion')
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _unique_object(pairs: list[tuple[str, Any]]) -> EntityData:
|
|
68
|
+
result: EntityData = {}
|
|
69
|
+
for key, value in pairs:
|
|
70
|
+
if key in result:
|
|
71
|
+
raise ValueError('Duplicate JSON key')
|
|
72
|
+
result[key] = value
|
|
73
|
+
return result
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _validate_json(value: Any, seen: set[int] | None = None) -> None:
|
|
77
|
+
"""Reject coercions, circular values and nonfinite Decimals before encoding."""
|
|
78
|
+
if value is None or type(value) in (str, int, bool):
|
|
79
|
+
return
|
|
80
|
+
if isinstance(value, Decimal):
|
|
81
|
+
if not value.is_finite():
|
|
82
|
+
raise ValueError('Expected a finite decimal')
|
|
83
|
+
return
|
|
84
|
+
if type(value) is float:
|
|
85
|
+
if not isfinite(value):
|
|
86
|
+
raise ValueError('Expected a finite number')
|
|
87
|
+
return
|
|
88
|
+
if isinstance(value, (dict, list, tuple)):
|
|
89
|
+
seen = set() if seen is None else seen
|
|
90
|
+
if id(value) in seen:
|
|
91
|
+
raise ValueError('Circular JSON value')
|
|
92
|
+
seen.add(id(value))
|
|
93
|
+
try:
|
|
94
|
+
values: Iterable[Any]
|
|
95
|
+
if isinstance(value, dict):
|
|
96
|
+
if not all(isinstance(key, str) for key in value):
|
|
97
|
+
raise ValueError('JSON keys must be strings')
|
|
98
|
+
values = value.values()
|
|
99
|
+
else:
|
|
100
|
+
values = value
|
|
101
|
+
for item in values:
|
|
102
|
+
_validate_json(item, seen)
|
|
103
|
+
finally:
|
|
104
|
+
seen.remove(id(value))
|
|
105
|
+
return
|
|
106
|
+
raise ValueError('Unsupported JSON value')
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def decode_json(raw: bytes) -> Any:
|
|
110
|
+
try:
|
|
111
|
+
return json.loads(raw.decode('utf-8'), object_pairs_hook=_unique_object,
|
|
112
|
+
use_decimal=True, allow_nan=False)
|
|
113
|
+
except (ValueError, UnicodeError, RecursionError):
|
|
114
|
+
raise ResponseError('Invalid ZenMoney JSON') from None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def decode_diff(raw: bytes, cursor: int) -> Diff:
|
|
118
|
+
data = decode_json(raw)
|
|
119
|
+
validate_diff(data)
|
|
120
|
+
if data['serverTimestamp'] < cursor:
|
|
121
|
+
raise ResponseError('ZenMoney cursor moved backwards')
|
|
122
|
+
return Diff(data, raw.decode('utf-8'))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def timestamp(value: Any) -> int:
|
|
126
|
+
if type(value) is not int or value < 0:
|
|
127
|
+
raise ValueError('Expected a nonnegative integer timestamp')
|
|
128
|
+
return int(value)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def encode_json(value: Any) -> bytes:
|
|
132
|
+
try:
|
|
133
|
+
_validate_json(value)
|
|
134
|
+
return json.dumps(value, ensure_ascii=False, allow_nan=False,
|
|
135
|
+
use_decimal=True).encode('utf-8')
|
|
136
|
+
except (ValueError, TypeError, UnicodeError, RecursionError):
|
|
137
|
+
raise ValueError('Expected finite JSON values with string keys') from None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _sequence(value: Any) -> bool:
|
|
141
|
+
return isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray))
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def diff_payload(cursor: int, now: int, changes: Changes | None = None,
|
|
145
|
+
deletions: Sequence[Mapping[str, Any]] | None = None,
|
|
146
|
+
force_fetch: Sequence[str] | None = None) -> EntityData:
|
|
147
|
+
payload: EntityData = {'serverTimestamp': timestamp(cursor), 'currentClientTimestamp': timestamp(now)}
|
|
148
|
+
if force_fetch is not None:
|
|
149
|
+
if not _sequence(force_fetch) or any(not isinstance(k, str) or k not in FORCE_FETCH for k in force_fetch):
|
|
150
|
+
raise ValueError('Unsupported forceFetch collection')
|
|
151
|
+
payload['forceFetch'] = list(dict.fromkeys(force_fetch))
|
|
152
|
+
if changes is not None:
|
|
153
|
+
if not isinstance(changes, Mapping) or any(k not in WRITABLE for k in changes):
|
|
154
|
+
raise ValueError('Only user entity collections can be written')
|
|
155
|
+
for kind, rows in changes.items():
|
|
156
|
+
if not _sequence(rows):
|
|
157
|
+
raise ValueError('Expected an entity sequence')
|
|
158
|
+
keys = set()
|
|
159
|
+
copied = []
|
|
160
|
+
for row in rows:
|
|
161
|
+
if not isinstance(row, Mapping):
|
|
162
|
+
raise ValueError('Expected an entity object')
|
|
163
|
+
try:
|
|
164
|
+
key = entity_key(kind, row)
|
|
165
|
+
except ResponseError:
|
|
166
|
+
raise ValueError('Invalid entity identity') from None
|
|
167
|
+
# User entity IDs are strings; budgets are keyed by user/date/tag.
|
|
168
|
+
if kind != 'budget' and not isinstance(row['id'], str):
|
|
169
|
+
raise ValueError('Expected a string entity ID')
|
|
170
|
+
if key in keys:
|
|
171
|
+
raise ValueError('Duplicate entity identity')
|
|
172
|
+
keys.add(key)
|
|
173
|
+
timestamp(row.get('changed'))
|
|
174
|
+
if type(row.get('user')) is not int or row['user'] <= 0:
|
|
175
|
+
raise ValueError('Expected positive user ID')
|
|
176
|
+
copied.append(deepcopy(dict(row)))
|
|
177
|
+
payload[kind] = copied
|
|
178
|
+
if deletions is not None:
|
|
179
|
+
if not _sequence(deletions):
|
|
180
|
+
raise ValueError('Expected deletion sequence')
|
|
181
|
+
seen = set()
|
|
182
|
+
copied_deletions = []
|
|
183
|
+
for row in deletions:
|
|
184
|
+
if not isinstance(row, Mapping) or row.get('object') not in DELETABLE or not isinstance(row.get('id'), str) or not row['id']:
|
|
185
|
+
raise ValueError('Invalid deletion identity')
|
|
186
|
+
timestamp(row.get('stamp'))
|
|
187
|
+
if type(row.get('user')) is not int or row['user'] <= 0:
|
|
188
|
+
raise ValueError('Expected positive user ID')
|
|
189
|
+
deletion_key = (row['object'], row['id'])
|
|
190
|
+
if deletion_key in seen or any(t.get('id') == row['id'] for t in payload.get(row['object'], [])):
|
|
191
|
+
raise ValueError('Duplicate or conflicting deletion')
|
|
192
|
+
seen.add(deletion_key)
|
|
193
|
+
copied_deletions.append(deepcopy(dict(row)))
|
|
194
|
+
payload['deletion'] = copied_deletions
|
|
195
|
+
encode_json(payload)
|
|
196
|
+
return payload
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def suggest_payload(transactions: Mapping[str, Any] | Sequence[Mapping[str, Any]]) -> EntityData | list[EntityData]:
|
|
200
|
+
payload: EntityData | list[EntityData]
|
|
201
|
+
if isinstance(transactions, Mapping):
|
|
202
|
+
payload = deepcopy(dict(transactions))
|
|
203
|
+
elif _sequence(transactions) and transactions and all(isinstance(t, Mapping) for t in transactions):
|
|
204
|
+
payload = [deepcopy(dict(t)) for t in transactions]
|
|
205
|
+
else:
|
|
206
|
+
raise ValueError('Expected transaction object or nonempty sequence')
|
|
207
|
+
encode_json(payload)
|
|
208
|
+
return payload
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def decode_suggestion(raw: bytes, request: EntityData | list[EntityData]) -> EntityData | list[EntityData]:
|
|
212
|
+
result = decode_json(raw)
|
|
213
|
+
if isinstance(request, list):
|
|
214
|
+
valid = isinstance(result, list) and len(result) == len(request) and all(isinstance(t, dict) and not t.get('error') for t in result)
|
|
215
|
+
else:
|
|
216
|
+
valid = isinstance(result, dict) and not result.get('error')
|
|
217
|
+
if not valid:
|
|
218
|
+
raise ResponseError('Invalid suggestion response')
|
|
219
|
+
return cast(EntityData | list[EntityData], result)
|
zenmoney_api/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Builders produce full wire objects; they never send or persist anything."""
|
|
2
|
+
import math
|
|
3
|
+
import time
|
|
4
|
+
from copy import deepcopy
|
|
5
|
+
from datetime import date as Date
|
|
6
|
+
from decimal import Decimal
|
|
7
|
+
from typing import Any, Unpack, cast
|
|
8
|
+
from collections.abc import Mapping
|
|
9
|
+
from .models import Number, Transaction, TransactionOptions, TransactionFields, BaseTransactionOptions
|
|
10
|
+
from .protocol import EntityData
|
|
11
|
+
from uuid import uuid4
|
|
12
|
+
from .protocol import DELETABLE, timestamp, encode_json
|
|
13
|
+
|
|
14
|
+
CREATION_DEFAULTS: EntityData = {key: None for key in (
|
|
15
|
+
'merchant', 'originalPayee', 'reminderMarker', 'opIncome', 'opIncomeInstrument',
|
|
16
|
+
'opOutcome', 'opOutcomeInstrument', 'latitude', 'longitude', 'qrCode', 'source',
|
|
17
|
+
'incomeBankID', 'outcomeBankID')}
|
|
18
|
+
CREATION_DEFAULTS['viewed'] = True
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _finite_number(value: Any) -> bool:
|
|
22
|
+
if isinstance(value, Decimal):
|
|
23
|
+
return value.is_finite()
|
|
24
|
+
return type(value) is int or (type(value) is float and math.isfinite(value))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _amount(value: Number, *, positive: bool = False) -> Number:
|
|
28
|
+
if not _finite_number(value) or value < 0 or (positive and value == 0):
|
|
29
|
+
raise ValueError('Expected a finite nonnegative amount')
|
|
30
|
+
return value
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _id(value: str) -> str:
|
|
34
|
+
if not isinstance(value, str) or not value:
|
|
35
|
+
raise ValueError('Expected a nonempty account ID')
|
|
36
|
+
return value
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _positive_int(value: int) -> int:
|
|
40
|
+
if type(value) is not int or value <= 0:
|
|
41
|
+
raise ValueError('Expected a positive integer ID')
|
|
42
|
+
return value
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def transaction(*, user: int, date: str, income_account: str, outcome_account: str,
|
|
46
|
+
income_instrument: int, outcome_instrument: int, income: Number,
|
|
47
|
+
outcome: Number, categories: list[str] | None = None,
|
|
48
|
+
payee: str | None = None, comment: str | None = None,
|
|
49
|
+
merchant: str | None = None, id: str | None = None,
|
|
50
|
+
now: int | None = None, **fields: Unpack[TransactionFields]) -> Transaction:
|
|
51
|
+
"""Build a posted operation. IDs/timestamps can be supplied for deterministic drafts."""
|
|
52
|
+
if not isinstance(date, str) or Date.fromisoformat(date).isoformat() != date:
|
|
53
|
+
raise ValueError('Expected ISO calendar date')
|
|
54
|
+
stamp = timestamp(int(time.time()) if now is None else now)
|
|
55
|
+
if categories is not None and (not isinstance(categories, list) or not all(isinstance(t, str) and t for t in categories)):
|
|
56
|
+
raise ValueError('Expected category IDs or None')
|
|
57
|
+
for value in (payee, comment, merchant):
|
|
58
|
+
if value is not None and not isinstance(value, str):
|
|
59
|
+
raise ValueError('Expected optional text')
|
|
60
|
+
result: EntityData = {**CREATION_DEFAULTS, 'mcc': None, 'id': _id(id) if id is not None else str(uuid4()),
|
|
61
|
+
'created': stamp, 'changed': stamp, 'user': _positive_int(user), 'deleted': False, 'hold': False,
|
|
62
|
+
'date': date, 'incomeAccount': _id(income_account), 'outcomeAccount': _id(outcome_account),
|
|
63
|
+
'incomeInstrument': _positive_int(income_instrument), 'outcomeInstrument': _positive_int(outcome_instrument),
|
|
64
|
+
'income': _amount(income), 'outcome': _amount(outcome), 'tag': deepcopy(categories),
|
|
65
|
+
'payee': payee, 'comment': comment, 'merchant': merchant}
|
|
66
|
+
allowed = set(CREATION_DEFAULTS) | {'mcc', 'hold', 'deleted'}
|
|
67
|
+
if any(k not in allowed for k in fields):
|
|
68
|
+
raise ValueError('Unsupported optional transaction field')
|
|
69
|
+
result.update(fields)
|
|
70
|
+
for name in ('opIncome', 'opOutcome'):
|
|
71
|
+
if result[name] is not None:
|
|
72
|
+
_amount(result[name])
|
|
73
|
+
if (result[name] is None) != (result[name+'Instrument'] is None):
|
|
74
|
+
raise ValueError('Original amount and instrument must be supplied together')
|
|
75
|
+
if result[name+'Instrument'] is not None:
|
|
76
|
+
_positive_int(result[name+'Instrument'])
|
|
77
|
+
for name, limit in (('latitude', 90), ('longitude', 180)):
|
|
78
|
+
if result[name] is not None and (not _finite_number(result[name]) or not -limit <= result[name] <= limit):
|
|
79
|
+
raise ValueError('Invalid coordinates')
|
|
80
|
+
if type(result['deleted']) is not bool or (result['hold'] is not None and type(result['hold']) is not bool):
|
|
81
|
+
raise ValueError('Invalid transaction flags')
|
|
82
|
+
if type(result['viewed']) is not bool:
|
|
83
|
+
raise ValueError('Invalid viewed flag')
|
|
84
|
+
if result['mcc'] is not None and (type(result['mcc']) is not int or result['mcc'] < 0):
|
|
85
|
+
raise ValueError('Invalid MCC')
|
|
86
|
+
for name in ('originalPayee', 'reminderMarker', 'qrCode', 'source', 'incomeBankID', 'outcomeBankID'):
|
|
87
|
+
if result[name] is not None and not isinstance(result[name], str):
|
|
88
|
+
raise ValueError('Invalid optional transaction text')
|
|
89
|
+
encode_json(result)
|
|
90
|
+
return cast(Transaction, result)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def expense(*, account: str, instrument: int, amount: Number, **kwargs: Unpack[TransactionOptions]) -> Transaction:
|
|
94
|
+
return transaction(income_account=account, outcome_account=account,
|
|
95
|
+
income_instrument=instrument, outcome_instrument=instrument,
|
|
96
|
+
income=0, outcome=_amount(amount, positive=True), **kwargs)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def income(*, account: str, instrument: int, amount: Number, **kwargs: Unpack[TransactionOptions]) -> Transaction:
|
|
100
|
+
return transaction(income_account=account, outcome_account=account,
|
|
101
|
+
income_instrument=instrument, outcome_instrument=instrument,
|
|
102
|
+
income=_amount(amount, positive=True), outcome=0, **kwargs)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def transfer(*, from_account: str, to_account: str, from_instrument: int,
|
|
106
|
+
to_instrument: int, amount_out: Number, amount_in: Number, **kwargs: Unpack[TransactionOptions]) -> Transaction:
|
|
107
|
+
if from_account == to_account:
|
|
108
|
+
raise ValueError('Transfer requires different accounts')
|
|
109
|
+
return transaction(income_account=to_account, outcome_account=from_account,
|
|
110
|
+
income_instrument=to_instrument, outcome_instrument=from_instrument,
|
|
111
|
+
income=_amount(amount_in, positive=True), outcome=_amount(amount_out, positive=True), **kwargs)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def debt(*, action: str, account: str, debt_account: str, instrument: int,
|
|
115
|
+
amount: Number, payee: str, **kwargs: Unpack[BaseTransactionOptions]) -> Transaction:
|
|
116
|
+
"""lend/repay: cash → debt; borrow/collect: debt → cash. Both use cash currency."""
|
|
117
|
+
if action not in ('lend', 'borrow', 'repay', 'collect'):
|
|
118
|
+
raise ValueError('Unknown debt action')
|
|
119
|
+
if not isinstance(payee, str) or not payee.strip():
|
|
120
|
+
raise ValueError('Debt requires a counterparty')
|
|
121
|
+
outgoing = action in ('lend', 'repay')
|
|
122
|
+
return transfer(from_account=account if outgoing else debt_account,
|
|
123
|
+
to_account=debt_account if outgoing else account,
|
|
124
|
+
from_instrument=instrument, to_instrument=instrument,
|
|
125
|
+
amount_out=amount, amount_in=amount, payee=payee, **kwargs)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def update(entity: Mapping[str, Any], *, now: int | None = None, **fields: Any) -> EntityData:
|
|
129
|
+
"""Copy a complete fetched entity, retaining unknown fields. Identity is immutable."""
|
|
130
|
+
if any(k in fields for k in ('id', 'user', 'created', 'changed')):
|
|
131
|
+
raise ValueError('Cannot replace identity or timestamps in an update')
|
|
132
|
+
result = deepcopy(dict(entity))
|
|
133
|
+
result.update(deepcopy(fields))
|
|
134
|
+
result['changed'] = timestamp(int(time.time()) if now is None else now)
|
|
135
|
+
encode_json(result)
|
|
136
|
+
return result
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def deletion(kind: str, id: str, *, user: int, now: int | None = None) -> EntityData:
|
|
140
|
+
if kind not in DELETABLE:
|
|
141
|
+
raise ValueError('This entity cannot be deleted by ID')
|
|
142
|
+
return {'object': kind, 'id': _id(id), 'user': _positive_int(user),
|
|
143
|
+
'stamp': timestamp(int(time.time()) if now is None else now)}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def clear_budget(budget: Mapping[str, Any], *, now: int | None = None) -> EntityData:
|
|
147
|
+
"""Budget has a composite identity; clear amounts/locks instead of deleting by ID."""
|
|
148
|
+
return update(budget, now=now, income=0, outcome=0, incomeLock=False, outcomeLock=False)
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: zenmoney-python
|
|
3
|
+
Version: 0.7.0
|
|
4
|
+
Summary: Independent sync and async Python client for the public ZenMoney API
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Repository, https://github.com/vaslv/zenmoney-python
|
|
7
|
+
Project-URL: Issues, https://github.com/vaslv/zenmoney-python/issues
|
|
8
|
+
Project-URL: Documentation, https://github.com/vaslv/zenmoney-python/blob/main/README.md
|
|
9
|
+
Keywords: zenmoney,finance,api,asyncio
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Typing :: Typed
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Requires-Python: >=3.12
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: httpx<0.29,>=0.28
|
|
19
|
+
Requires-Dist: simplejson<4,>=3.19
|
|
20
|
+
Provides-Extra: test
|
|
21
|
+
Requires-Dist: pytest<10,>=8; extra == "test"
|
|
22
|
+
Requires-Dist: pytest-cov<8,>=6; extra == "test"
|
|
23
|
+
Requires-Dist: mypy<2,>=1.15; extra == "test"
|
|
24
|
+
Requires-Dist: types-simplejson<4,>=3.19; extra == "test"
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# zenmoney-python
|
|
28
|
+
|
|
29
|
+
Самостоятельная Python-библиотека публичного HTTP API ZenMoney. Проект находится
|
|
30
|
+
рядом с ZenImport и развивается независимо. Distribution: `zenmoney-python`,
|
|
31
|
+
импорт: `zenmoney_api`, версия 0.7.0. Python 3.12+, HTTPX 0.28.x, simplejson для точных JSON-чисел.
|
|
32
|
+
|
|
33
|
+
## Возможности
|
|
34
|
+
|
|
35
|
+
- Синхронный и асинхронный клиент: полная и инкрементальная синхронизация diff,
|
|
36
|
+
forceFetch, создание/обновление/удаление пользовательских сущностей.
|
|
37
|
+
- Счета, категории, получатели, операции, бюджеты, напоминания и планируемые
|
|
38
|
+
операции; чтение системных справочников. Типы полей для редактора в `models`.
|
|
39
|
+
- Функции создания дохода, расхода, перевода, долговой операции; сохранение
|
|
40
|
+
оригинальных сумм/валют, нескольких категорий и неизвестных полей при обновлении.
|
|
41
|
+
- Suggest: один объект или список; OAuth code exchange и явное обновление токена.
|
|
42
|
+
- Без автоматических повторов записи, скрытого хранения данных или зависимости
|
|
43
|
+
от UI/банков. Единственный публичный namespace — `zenmoney_api`.
|
|
44
|
+
|
|
45
|
+
Область покрытия и честные ограничения: [docs/coverage.md](docs/coverage.md).
|
|
46
|
+
Сценарии расходов, возвратов, долгов и переводов: [docs/basic-operations.md](docs/basic-operations.md).
|
|
47
|
+
|
|
48
|
+
## Установка
|
|
49
|
+
|
|
50
|
+
Готовые wheel, sdist и SHA256SUMS доступны в [GitHub Releases](https://github.com/vaslv/zenmoney-python/releases).
|
|
51
|
+
Установка версии 0.7.0 из GitHub (Python 3.12+):
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
python -m pip install "https://github.com/vaslv/zenmoney-python/releases/download/v0.7.0/zenmoney_python-0.7.0-py3-none-any.whl"
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Пакет ещё не загружен в PyPI. Установка из исходников: `python -m pip install .`.
|
|
58
|
+
Для собственной сборки выполните `sh scripts/build.sh`: в `dist/` появятся wheel,
|
|
59
|
+
sdist и SHA256SUMS. Twine проверяет метаданные; wheel собирается из sdist.
|
|
60
|
+
Имя репозитория и дистрибутива — `zenmoney-python`, Python-import — `zenmoney_api`.
|
|
61
|
+
Прежнее локальное имя `zenmoney-api-client` больше не используется; перед
|
|
62
|
+
переходом в существующем окружении удалите старый дистрибутив или создайте чистое
|
|
63
|
+
окружение, чтобы два пакета не владели одним namespace.
|
|
64
|
+
|
|
65
|
+
Порядок первого GitHub-релиза и интеграции приложений: [docs/publication.md](docs/publication.md).
|
|
66
|
+
|
|
67
|
+
## Чтение
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
import os
|
|
71
|
+
from zenmoney_api import Client
|
|
72
|
+
|
|
73
|
+
with Client(os.environ['ZENMONEY_TOKEN']) as api:
|
|
74
|
+
initial = api.read_diff() # cursor=0 — первоначальная синхронизация
|
|
75
|
+
# Сначала атомарно сохраните сущности и удаления, затем курсор в своей БД.
|
|
76
|
+
cursor = initial['serverTimestamp']
|
|
77
|
+
changes = api.read_diff(cursor)
|
|
78
|
+
accounts = api.read_diff(cursor, force_fetch=['account'])
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Клиент сохраняет неизвестные поля в словарях; `Diff.raw_json` содержит исходный
|
|
82
|
+
ответ. Целые числа читаются как `int`, десятичные — как `Decimal`. Для денег
|
|
83
|
+
используйте `Decimal('1.23')` или целые числа VND: при отправке они остаются
|
|
84
|
+
JSON-числами без округления через float. `float` принимается, но уже возникшую
|
|
85
|
+
в нём погрешность SDK исправить не может. NaN/Infinity и не-JSON объекты отклоняются
|
|
86
|
+
до запроса. SDK не пересчитывает валюты. Обычный `json.dumps` не сериализует Decimal;
|
|
87
|
+
для исходного ответа используйте `Diff.raw_json`, для подготовленных объектов —
|
|
88
|
+
`zenmoney_api.protocol.encode_json`.
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
from zenmoney_api import AsyncClient
|
|
92
|
+
|
|
93
|
+
async def load(token):
|
|
94
|
+
async with AsyncClient(token) as api:
|
|
95
|
+
return await api.read_diff()
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Используйте контекстный менеджер либо `close()` / `aclose()` для освобождения
|
|
99
|
+
соединений. Закрытый клиент не используется повторно: он вызывает `ClientClosedError`;
|
|
100
|
+
создайте новый экземпляр. `is_closed` показывает состояние. Один экземпляр
|
|
101
|
+
асинхронного клиента используйте в одном event loop. Параметр `timeout` принимает
|
|
102
|
+
секунды, `httpx.Timeout` или явно отключённый таймаут `None`.
|
|
103
|
+
|
|
104
|
+
## Подготовка и отправка
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
from zenmoney_api import transactions as tx
|
|
108
|
+
|
|
109
|
+
operation = tx.expense(user=123, account='account-id', instrument=1,
|
|
110
|
+
amount=100000, date='2026-10-13', categories=['category-id'], payee='Shop')
|
|
111
|
+
# Сохраните operation и его UUID до отправки. Повтор не должен создавать новый UUID.
|
|
112
|
+
# Выполнить запись, когда вызывающее приложение готово:
|
|
113
|
+
# result = api.upsert('transaction', [operation], cursor)
|
|
114
|
+
# Затем повторно получите данные и проверьте наличие/содержимое операции.
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
`transaction(...)` формирует общий объект; `income`, `expense`, `transfer`, `debt`
|
|
118
|
+
упрощают типовые случаи. Примеры: [examples/operations.py](examples/operations.py).
|
|
119
|
+
`update(fetched_object, tag=[...])` копирует полный объект с сохранением полей.
|
|
120
|
+
|
|
121
|
+
`api.diff(cursor, changes={'account': [...], 'tag': [...], 'transaction': [...]},
|
|
122
|
+
deletions=[...])` позволяет передать несколько типов сущностей одним запросом.
|
|
123
|
+
`upsert(kind, entities, cursor)` — удобная обёртка.
|
|
124
|
+
Для новых объектов каталога есть полные формы с актуальными полями:
|
|
125
|
+
`entities.account(user=..., title=..., instrument=...)`, `entities.tag(...)`,
|
|
126
|
+
`entities.merchant(...)`. Полученный объект можно передать в upsert.
|
|
127
|
+
Для loan/deposit задайте параметры срока и процентов; системный debt-счёт
|
|
128
|
+
следует получать из diff, а не создавать повторно. **Передавайте полные сущности,
|
|
129
|
+
не patch**. Низкоуровневый интерфейс проверяет структуру, идентификаторы, timestamps
|
|
130
|
+
и JSON, но не подменяет серверную проверку всех полей. Ограничения/пакетирование
|
|
131
|
+
определяет вызывающее приложение, скрытого лимита в 20 записей в общем SDK нет.
|
|
132
|
+
|
|
133
|
+
`deletion(kind, id, user=..., now=...)` создаёт удаление по ID. Для бюджета с
|
|
134
|
+
составным ключом используйте `clear_budget` и `upsert('budget', ...)`.
|
|
135
|
+
Мягкое удаление операции — `update(operation, deleted=True)`.
|
|
136
|
+
|
|
137
|
+
`api.suggest({'payee': 'Shop'})` или список объектов возвращает подсказки API,
|
|
138
|
+
не изменяя записи.
|
|
139
|
+
|
|
140
|
+
## OAuth (необязательно)
|
|
141
|
+
|
|
142
|
+
Для diff/suggest достаточно готового Bearer-токена в Client/AsyncClient.
|
|
143
|
+
Регистрация приложения и собственный OAuth не требуются для использования SDK.
|
|
144
|
+
Методы ниже предназначены для владельцев уже выданных реквизитов приложения;
|
|
145
|
+
их живая проверка не является условием приёмки основного API.
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
import secrets
|
|
150
|
+
from zenmoney_api import OAuthClient, callback_code
|
|
151
|
+
|
|
152
|
+
with OAuthClient('registered-client-id', 'client-secret', 'https://your-app/callback') as oauth:
|
|
153
|
+
state = secrets.token_urlsafe(32) # сохраните в сессии до открытия URL
|
|
154
|
+
url = oauth.authorization_url(state=state)
|
|
155
|
+
# code = callback_code(received_callback_url, expected_state=state)
|
|
156
|
+
# token = oauth.exchange_code(code)
|
|
157
|
+
# if token.refresh_token is not None:
|
|
158
|
+
# refreshed = oauth.refresh(token.refresh_token)
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Для async есть независимый `AsyncOAuthClient` с теми же аргументами:
|
|
162
|
+
используйте `async with`, а `exchange_code` и `refresh` вызывайте с `await`.
|
|
163
|
+
OAuth-клиенты переиспользуют HTTPX-клиент и переданный транспорт между запросами.
|
|
164
|
+
При работе без контекстного менеджера вызовите `close()` / `await aclose()`.
|
|
165
|
+
Оба способа освобождают открытый HTTPX-клиент и его транспорт; повторное закрытие
|
|
166
|
+
безопасно. После закрытия `is_closed` равен `True`, а обмен кода, обновление токена
|
|
167
|
+
и повторный вход в контекст вызывают `ClientClosedError`. Создайте новый экземпляр
|
|
168
|
+
для следующего сеанса. `AsyncOAuthClient` используйте в рамках одного event loop.
|
|
169
|
+
SDK не поднимает callback-сервер, не регистрирует приложение, не хранит токены
|
|
170
|
+
и не обновляет их автоматически. Если refresh-ответ не содержит нового
|
|
171
|
+
refresh_token, приложение сохраняет прежний. `Token.__repr__` скрывает токены.
|
|
172
|
+
State проверяется до обмена code; привязка callback к сессии — задача приложения.
|
|
173
|
+
|
|
174
|
+
## Ошибки и подтверждение записи
|
|
175
|
+
|
|
176
|
+
`HTTPError.status_code`, `TransportError`, `ResponseError` не включают тело ответа
|
|
177
|
+
и токен. HTTP 200 не гарантирует принятия всех изменений: более новый `changed`
|
|
178
|
+
на сервере может победить. После записи требуется отдельное чтение и сверка;
|
|
179
|
+
при неопределённом результате нельзя создавать другой UUID и слепо повторять.
|
|
180
|
+
Синхронизация БД, пользовательское подтверждение, журнал отправок и reconciliation
|
|
181
|
+
принадлежат приложению, а не SDK.
|
|
182
|
+
|
|
183
|
+
## Разработка и Docker
|
|
184
|
+
|
|
185
|
+
```sh
|
|
186
|
+
sh scripts/test.sh
|
|
187
|
+
sh scripts/build.sh
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Тесты используют установленный wheel без дерева исходников, без сети и без
|
|
191
|
+
настоящих токенов. Есть проверка реального HTTP на локальном тестовом сервере.
|
|
192
|
+
Тестовый образ проверяет строгую типизацию mypy, типовые примеры использования
|
|
193
|
+
публичного API и выводит покрытие. Сборка требует доступа к реестру образов и PyPI.
|
|
194
|
+
|
|
195
|
+
## Переход с 0.4
|
|
196
|
+
|
|
197
|
+
Версия 0.5 намеренно меняет контракт: `zenimport_zenmoney` удалён, десятичные
|
|
198
|
+
числа ответа теперь Decimal, закрытый клиент не открывается повторно. Для записи
|
|
199
|
+
используйте `diff`/`upsert`, для типовых объектов — `transactions`.
|
|
200
|
+
Старые приложения следует адаптировать после приёмки SDK; их ограничения
|
|
201
|
+
не определяют дизайн библиотеки.
|
|
202
|
+
[Архитектура](docs/architecture.md) · [Покрытие API](docs/coverage.md) ·
|
|
203
|
+
[Проверка на настоящем API](docs/live-validation.md) · [История](CHANGELOG.md).
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
## Живая приёмка
|
|
207
|
+
|
|
208
|
+
[Результаты проверки на тестовом аккаунте](docs/live-results.md).
|
|
209
|
+
Для собственного отдельного тестового аккаунта скопируйте `.env.example` в `.env`
|
|
210
|
+
и заполните ZENMONEY_TEST_TOKEN. Шаблон содержит также все необязательные переменные
|
|
211
|
+
OAuth. Файл не попадает в Git и образ Docker.
|
|
212
|
+
|
|
213
|
+
`sh scripts/live-check.sh` выполняет только чтение. `--write` явно включает запись
|
|
214
|
+
искусственных данных и требует первоначально пустого отдельного аккаунта;
|
|
215
|
+
`--soft-delete` и `--cleanup` работают только с UUID сохранённого тестового журнала.
|
|
216
|
+
После ошибки автоматического повтора нет. `--resume` разрешает только повторную
|
|
217
|
+
сверку и продолжение подтверждённых шагов, без повторения неизвестной записи.
|
|
218
|
+
Завершённый очищенный журнал не используется для нового запуска.
|
|
219
|
+
Для нового запуска задайте другой путь `--journal /state/new-run.json`.
|
|
220
|
+
|
|
221
|
+
`--write --basic --journal /state/basic-run.json` проверяет согласованный базовый
|
|
222
|
+
набор операций, баланс после каждого шага, долговые движения и отсутствие дубля
|
|
223
|
+
при явной записи того же UUID. `--cleanup --journal /state/basic-run.json`
|
|
224
|
+
убирает тестовые объекты; `--inspect` выводит только названия отличающихся полей.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
zenmoney_api/__init__.py,sha256=B02R0EPk-zCeaV7ZupXfxTKLt-d0uiY9TK2XjSCW_LA,747
|
|
2
|
+
zenmoney_api/client.py,sha256=MQ1r021HduolE4sRPj4apYoZnYaoQzzAq6MWkB0W__Y,7943
|
|
3
|
+
zenmoney_api/entities.py,sha256=bcoqVNQ-CP3513UJFFWcD-atew54TC0tExR0bmhEvfE,3074
|
|
4
|
+
zenmoney_api/errors.py,sha256=ZWUTQXHXj4fN7yLTQQrRooBy8tdMyga80fMK_NrWDF0,562
|
|
5
|
+
zenmoney_api/models.py,sha256=fccKs3BAK6g8K_zuBsAvGcATw6pzGEPnRwMelNupw1E,4426
|
|
6
|
+
zenmoney_api/oauth.py,sha256=gX-0iajnur40FjZy-nWFccroumLwTEPL28HDSBWydhk,7448
|
|
7
|
+
zenmoney_api/protocol.py,sha256=Kew5Mf12a03pLrBADhvtH3dcYFH4qkpErCW1BVYwsrw,9482
|
|
8
|
+
zenmoney_api/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
zenmoney_api/transactions.py,sha256=6NRTENjTReWwRZsxDe9QBAO9j084YOZ5Yl1SuPFRLiU,7857
|
|
10
|
+
zenmoney_python-0.7.0.dist-info/licenses/LICENSE,sha256=GlIFmM7HXSAzMamaTXRQwkYmr1lyD9pCWT8lpVOhTMY,1071
|
|
11
|
+
zenmoney_python-0.7.0.dist-info/METADATA,sha256=H6LfjBvC0rJx_6GY-9u3w024wEdoKTfSWOkKDbZKimM,15910
|
|
12
|
+
zenmoney_python-0.7.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
13
|
+
zenmoney_python-0.7.0.dist-info/top_level.txt,sha256=HYVXmPnkDkPu47HG-rUgLoQFMMV4I89l-ePHwmJkFfY,13
|
|
14
|
+
zenmoney_python-0.7.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Andrei Vasilev
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
zenmoney_api
|