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.
@@ -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)
@@ -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))
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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