makeleaps-client 0.1.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.
- makeleaps_client/__init__.py +8 -0
- makeleaps_client/_version.py +24 -0
- makeleaps_client/cli.py +171 -0
- makeleaps_client/client.py +350 -0
- makeleaps_client/models.py +231 -0
- makeleaps_client-0.1.0.dist-info/METADATA +132 -0
- makeleaps_client-0.1.0.dist-info/RECORD +10 -0
- makeleaps_client-0.1.0.dist-info/WHEEL +4 -0
- makeleaps_client-0.1.0.dist-info/entry_points.txt +2 -0
- makeleaps_client-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Unofficial, async Python client for the MakeLeaps REST API."""
|
|
2
|
+
from .client import MakeLeapsClient, MakeLeapsError
|
|
3
|
+
from .models import Client, Contact, Document, DocumentArchive, LineItem, Partner
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
'MakeLeapsClient', 'MakeLeapsError', 'Partner', 'Document',
|
|
7
|
+
'DocumentArchive', 'Client', 'Contact', 'LineItem',
|
|
8
|
+
]
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.1.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
makeleaps_client/cli.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Command-line interface built on the async library."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import asyncio
|
|
6
|
+
from datetime import date
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import re
|
|
10
|
+
import sys
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import yaml
|
|
14
|
+
|
|
15
|
+
from .client import MakeLeapsClient, MakeLeapsError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _nonnegative(value: str) -> int:
|
|
19
|
+
try:
|
|
20
|
+
number = int(value)
|
|
21
|
+
if number < 0:
|
|
22
|
+
raise ValueError
|
|
23
|
+
return number
|
|
24
|
+
except ValueError:
|
|
25
|
+
raise argparse.ArgumentTypeError('limit must be a nonnegative integer') from None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _date(value: str) -> str:
|
|
29
|
+
try:
|
|
30
|
+
return date.fromisoformat(value).isoformat()
|
|
31
|
+
except ValueError:
|
|
32
|
+
raise argparse.ArgumentTypeError('dates must use YYYY-MM-DD') from None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _filter(value: str) -> tuple[str, str]:
|
|
36
|
+
key, separator, val = value.partition('=')
|
|
37
|
+
if not separator or not key:
|
|
38
|
+
raise argparse.ArgumentTypeError('filters must use key=value')
|
|
39
|
+
return key, val
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _list_options(parser, *, documents=False):
|
|
43
|
+
parser.add_argument('--limit', type=_nonnegative, help='Maximum number of records (default: all)')
|
|
44
|
+
if documents:
|
|
45
|
+
parser.add_argument('--type', dest='document_type', help='API document_type value')
|
|
46
|
+
parser.add_argument('--date-from', type=_date, help='Inclusive issue date, YYYY-MM-DD')
|
|
47
|
+
parser.add_argument('--date-to', type=_date, help='Inclusive issue date, YYYY-MM-DD')
|
|
48
|
+
parser.add_argument('--filter', action='append', type=_filter, default=[], metavar='KEY=VALUE', help='Pass an API query parameter; may be repeated')
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _parser() -> argparse.ArgumentParser:
|
|
52
|
+
parser = argparse.ArgumentParser(prog='makeleaps', description='Read MakeLeaps company data, documents, archives, and PDFs.')
|
|
53
|
+
parser.add_argument('--language', default='ja', help='Response language (default: ja)')
|
|
54
|
+
commands = parser.add_subparsers(dest='resource', required=True)
|
|
55
|
+
partner = commands.add_parser('partner', help='Get the company associated with the API key')
|
|
56
|
+
partner.add_argument('--language', default=argparse.SUPPRESS)
|
|
57
|
+
for name in ('documents', 'archives'):
|
|
58
|
+
group = commands.add_parser(name, help='Read documents' if name == 'documents' else 'Read records saved at the time of sending')
|
|
59
|
+
group.add_argument('--language', default=argparse.SUPPRESS)
|
|
60
|
+
actions = group.add_subparsers(dest='operation', required=True)
|
|
61
|
+
listing = actions.add_parser('list', help='List all matching records')
|
|
62
|
+
listing.add_argument('--language', default=argparse.SUPPRESS)
|
|
63
|
+
_list_options(listing, documents=name == 'documents')
|
|
64
|
+
if name == 'documents':
|
|
65
|
+
get = actions.add_parser('get', help='Get a document with its client and contact')
|
|
66
|
+
get.add_argument('mid')
|
|
67
|
+
get.add_argument('--language', default=argparse.SUPPRESS)
|
|
68
|
+
pdf = actions.add_parser('pdf', help='Save one PDF, or all matching PDFs with --dir')
|
|
69
|
+
pdf.add_argument('mid', nargs='?')
|
|
70
|
+
pdf.add_argument('--language', default=argparse.SUPPRESS)
|
|
71
|
+
destinations = pdf.add_mutually_exclusive_group(required=True)
|
|
72
|
+
destinations.add_argument('--output', type=Path, metavar='FILE')
|
|
73
|
+
destinations.add_argument('--dir', type=Path, metavar='DIRECTORY')
|
|
74
|
+
_list_options(pdf, documents=name == 'documents')
|
|
75
|
+
return parser
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _safe_component(value: Any) -> str:
|
|
79
|
+
# Include Windows filename restrictions so exported names remain portable.
|
|
80
|
+
return re.sub(r'[<>:"/\\|?*\x00-\x1f\x7f]', '_', str(value)).rstrip(' .')
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _filename(record) -> str:
|
|
84
|
+
if not record.mid:
|
|
85
|
+
raise MakeLeapsError('A PDF record is missing its mid.')
|
|
86
|
+
values = (record.date or 'unknown', record.document_type or 'unknown',
|
|
87
|
+
record.document_number or '', record.mid)
|
|
88
|
+
return '_'.join(_safe_component(value) for value in values) + '.pdf'
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
async def _records(api, args):
|
|
92
|
+
if args.resource == 'documents':
|
|
93
|
+
iterator = api.list_documents(document_type=args.document_type,
|
|
94
|
+
date_from=args.date_from, date_to=args.date_to,
|
|
95
|
+
filters=args.filter)
|
|
96
|
+
else:
|
|
97
|
+
iterator = api.list_archives()
|
|
98
|
+
count = 0
|
|
99
|
+
try:
|
|
100
|
+
if args.limit == 0:
|
|
101
|
+
return
|
|
102
|
+
async for record in iterator:
|
|
103
|
+
yield record
|
|
104
|
+
count += 1
|
|
105
|
+
if args.limit is not None and count >= args.limit:
|
|
106
|
+
return
|
|
107
|
+
finally:
|
|
108
|
+
await iterator.aclose()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
async def _execute(args, client_id, client_secret):
|
|
112
|
+
async with MakeLeapsClient(client_id, client_secret, language=args.language) as api:
|
|
113
|
+
if args.resource == 'partner':
|
|
114
|
+
return (await api.get_partner()).model_dump()
|
|
115
|
+
if args.operation == 'get':
|
|
116
|
+
return (await api.get_document(args.mid)).model_dump()
|
|
117
|
+
if args.operation == 'list':
|
|
118
|
+
return [record.model_dump() async for record in _records(api, args)]
|
|
119
|
+
fetch = api.get_document_pdf if args.resource == 'documents' else api.get_archive_pdf
|
|
120
|
+
if args.mid:
|
|
121
|
+
args.output.write_bytes(await fetch(args.mid))
|
|
122
|
+
return None
|
|
123
|
+
directory = args.dir
|
|
124
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
125
|
+
existing = {path.name for path in directory.iterdir() if path.is_file()}
|
|
126
|
+
records = _records(api, args)
|
|
127
|
+
try:
|
|
128
|
+
async for record in records:
|
|
129
|
+
filename = _filename(record)
|
|
130
|
+
suffix = '_' + _safe_component(record.mid) + '.pdf'
|
|
131
|
+
if any(name.endswith(suffix) for name in existing):
|
|
132
|
+
continue
|
|
133
|
+
(directory / filename).write_bytes(await fetch(record.mid))
|
|
134
|
+
existing.add(filename)
|
|
135
|
+
finally:
|
|
136
|
+
await records.aclose()
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def main(argv: list[str] | None = None) -> int:
|
|
141
|
+
parser = _parser()
|
|
142
|
+
args = parser.parse_args(argv)
|
|
143
|
+
if getattr(args, 'operation', None) == 'pdf':
|
|
144
|
+
if args.mid and args.dir:
|
|
145
|
+
parser.error('use either MID with --output, or --dir without MID')
|
|
146
|
+
if not args.mid and args.output:
|
|
147
|
+
parser.error('--output requires MID')
|
|
148
|
+
if args.mid and (args.limit is not None or (args.resource == 'documents' and any((args.document_type, args.date_from, args.date_to, args.filter)))):
|
|
149
|
+
parser.error('filters and --limit apply only to --dir')
|
|
150
|
+
if args.resource == 'documents' and getattr(args, 'date_from', None) and getattr(args, 'date_to', None):
|
|
151
|
+
if args.date_from > args.date_to:
|
|
152
|
+
parser.error('--date-from must not be later than --date-to')
|
|
153
|
+
client_id = os.environ.get('MAKELEAPS_CLIENT_ID')
|
|
154
|
+
client_secret = os.environ.get('MAKELEAPS_CLIENT_SECRET')
|
|
155
|
+
if not client_id or not client_secret:
|
|
156
|
+
print('Set both MAKELEAPS_CLIENT_ID and MAKELEAPS_CLIENT_SECRET.', file=sys.stderr)
|
|
157
|
+
return 1
|
|
158
|
+
try:
|
|
159
|
+
result = asyncio.run(_execute(args, client_id, client_secret))
|
|
160
|
+
if result is not None:
|
|
161
|
+
print(yaml.safe_dump(result, allow_unicode=True, sort_keys=False), end='')
|
|
162
|
+
return 0
|
|
163
|
+
except MakeLeapsError as exc:
|
|
164
|
+
print(str(exc), file=sys.stderr)
|
|
165
|
+
return 1
|
|
166
|
+
except OSError:
|
|
167
|
+
print('Unable to read or write the requested output file.', file=sys.stderr)
|
|
168
|
+
return 1
|
|
169
|
+
except KeyboardInterrupt:
|
|
170
|
+
print('Interrupted.', file=sys.stderr)
|
|
171
|
+
return 130
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
"""Async, read-only MakeLeaps client with bounded retries and pagination."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
from collections import deque
|
|
6
|
+
from collections.abc import AsyncIterator, Mapping, Sequence
|
|
7
|
+
from datetime import date
|
|
8
|
+
from email.utils import parsedate_to_datetime
|
|
9
|
+
import math
|
|
10
|
+
import time
|
|
11
|
+
from typing import Any, TypeVar
|
|
12
|
+
from urllib.parse import quote, urljoin
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
from pydantic import ValidationError
|
|
16
|
+
|
|
17
|
+
from .models import Contact, Client, Document, DocumentArchive, Partner, Resource
|
|
18
|
+
|
|
19
|
+
_ORIGIN = 'https://api.makeleaps.com'
|
|
20
|
+
_TOKEN_URL = _ORIGIN + '/user/oauth2/token/'
|
|
21
|
+
_PAGE_SIZE = 100
|
|
22
|
+
_PAGE_CONCURRENCY = 8
|
|
23
|
+
_ATTEMPTS = 4
|
|
24
|
+
T = TypeVar('T', bound=Resource)
|
|
25
|
+
Filters = Mapping[str, Any] | Sequence[tuple[str, Any]]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class MakeLeapsError(Exception):
|
|
29
|
+
"""A request or response failure, without credentials or raw server content."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _safe_url(value: str) -> httpx.URL:
|
|
33
|
+
try:
|
|
34
|
+
# Only API-relative links can be resolved against the fixed TLS origin.
|
|
35
|
+
url = httpx.URL(urljoin(_ORIGIN + '/', value))
|
|
36
|
+
if (url.scheme != 'https' or url.host != 'api.makeleaps.com'
|
|
37
|
+
or url.port not in (None, 443) or url.userinfo or url.fragment):
|
|
38
|
+
raise ValueError
|
|
39
|
+
return url
|
|
40
|
+
except (ValueError, TypeError, httpx.InvalidURL):
|
|
41
|
+
raise MakeLeapsError('The API returned an unsupported resource URL.') from None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
async def _together(*coroutines):
|
|
45
|
+
tasks = [asyncio.create_task(coro) for coro in coroutines]
|
|
46
|
+
try:
|
|
47
|
+
return await asyncio.gather(*tasks)
|
|
48
|
+
finally:
|
|
49
|
+
for task in tasks:
|
|
50
|
+
if not task.done():
|
|
51
|
+
task.cancel()
|
|
52
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class _Limiter:
|
|
56
|
+
def __init__(self):
|
|
57
|
+
self._recent = deque()
|
|
58
|
+
self._daily = deque()
|
|
59
|
+
self._lock = asyncio.Lock()
|
|
60
|
+
self._blocked_until = 0.0
|
|
61
|
+
|
|
62
|
+
def defer(self, seconds: float) -> None:
|
|
63
|
+
self._blocked_until = max(self._blocked_until, time.monotonic() + seconds)
|
|
64
|
+
|
|
65
|
+
async def acquire(self) -> None:
|
|
66
|
+
async with self._lock:
|
|
67
|
+
while True:
|
|
68
|
+
now = time.monotonic()
|
|
69
|
+
while self._recent and self._recent[0] <= now - 60:
|
|
70
|
+
self._recent.popleft()
|
|
71
|
+
while self._daily and self._daily[0] <= now - 86400:
|
|
72
|
+
self._daily.popleft()
|
|
73
|
+
delay = max(0.0, self._blocked_until - now)
|
|
74
|
+
if len(self._recent) >= 250:
|
|
75
|
+
delay = max(delay, self._recent[0] + 60 - now)
|
|
76
|
+
if len(self._daily) >= 20000:
|
|
77
|
+
delay = max(delay, self._daily[0] + 86400 - now)
|
|
78
|
+
if delay > 0:
|
|
79
|
+
await asyncio.sleep(delay)
|
|
80
|
+
continue
|
|
81
|
+
self._recent.append(now)
|
|
82
|
+
self._daily.append(now)
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _retry_after(response: httpx.Response, attempt: int) -> float:
|
|
87
|
+
value = response.headers.get('Retry-After', '')
|
|
88
|
+
try:
|
|
89
|
+
delay = float(value)
|
|
90
|
+
if math.isfinite(delay) and delay >= 0:
|
|
91
|
+
return delay
|
|
92
|
+
except ValueError:
|
|
93
|
+
try:
|
|
94
|
+
return max(0.0, parsedate_to_datetime(value).timestamp() - time.time())
|
|
95
|
+
except (ValueError, TypeError, OverflowError):
|
|
96
|
+
pass
|
|
97
|
+
return float(2 ** attempt)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class MakeLeapsClient:
|
|
101
|
+
"""Use credentials from arguments only; close with ``async with`` or ``aclose``.
|
|
102
|
+
|
|
103
|
+
``transport`` supports HTTP boundary substitution, e.g. httpx.MockTransport.
|
|
104
|
+
A client belongs to one event loop. Its token and request limiter are shared
|
|
105
|
+
by all operations on that client.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
def __init__(self, client_id: str, client_secret: str, *, language: str = 'ja',
|
|
109
|
+
transport: httpx.AsyncBaseTransport | None = None):
|
|
110
|
+
if not client_id or not client_secret:
|
|
111
|
+
raise MakeLeapsError('Both client_id and client_secret are required.')
|
|
112
|
+
self._credentials = httpx.BasicAuth(client_id, client_secret)
|
|
113
|
+
self._http = httpx.AsyncClient(
|
|
114
|
+
headers={'Accept-Language': language, 'Accept': 'application/json'},
|
|
115
|
+
transport=transport, follow_redirects=False, timeout=60.0,
|
|
116
|
+
trust_env=False,
|
|
117
|
+
)
|
|
118
|
+
self._token = None
|
|
119
|
+
self._expires_at = 0.0
|
|
120
|
+
self._token_lock = asyncio.Lock()
|
|
121
|
+
self._partner_url = None
|
|
122
|
+
self._partner_lock = asyncio.Lock()
|
|
123
|
+
self._limiter = _Limiter()
|
|
124
|
+
|
|
125
|
+
async def __aenter__(self) -> MakeLeapsClient:
|
|
126
|
+
return self
|
|
127
|
+
|
|
128
|
+
async def __aexit__(self, *exc_info) -> None:
|
|
129
|
+
await self.aclose()
|
|
130
|
+
|
|
131
|
+
async def aclose(self) -> None:
|
|
132
|
+
await self._http.aclose()
|
|
133
|
+
self._token = None
|
|
134
|
+
self._expires_at = 0.0
|
|
135
|
+
|
|
136
|
+
async def _send(self, method: str, url: str | httpx.URL, **kwargs) -> httpx.Response:
|
|
137
|
+
url = _safe_url(str(url))
|
|
138
|
+
for attempt in range(_ATTEMPTS):
|
|
139
|
+
await self._limiter.acquire()
|
|
140
|
+
try:
|
|
141
|
+
response = await self._http.request(method, url, **kwargs)
|
|
142
|
+
except httpx.HTTPError:
|
|
143
|
+
if attempt + 1 == _ATTEMPTS:
|
|
144
|
+
raise MakeLeapsError('Unable to communicate with the MakeLeaps API.') from None
|
|
145
|
+
await asyncio.sleep(2 ** attempt)
|
|
146
|
+
continue
|
|
147
|
+
if response.status_code == 429:
|
|
148
|
+
if attempt + 1 == _ATTEMPTS:
|
|
149
|
+
raise MakeLeapsError('The MakeLeaps API rate limit persisted after retries.')
|
|
150
|
+
self._limiter.defer(_retry_after(response, attempt))
|
|
151
|
+
continue
|
|
152
|
+
if response.status_code >= 500 and attempt + 1 < _ATTEMPTS:
|
|
153
|
+
await asyncio.sleep(2 ** attempt)
|
|
154
|
+
continue
|
|
155
|
+
return response
|
|
156
|
+
raise MakeLeapsError('Unable to complete the MakeLeaps API request.')
|
|
157
|
+
|
|
158
|
+
@staticmethod
|
|
159
|
+
def _check(response: httpx.Response) -> None:
|
|
160
|
+
if not 200 <= response.status_code < 300:
|
|
161
|
+
raise MakeLeapsError(f'MakeLeaps API request failed (HTTP {response.status_code}).')
|
|
162
|
+
|
|
163
|
+
@staticmethod
|
|
164
|
+
def _json(response: httpx.Response) -> dict[str, Any]:
|
|
165
|
+
try:
|
|
166
|
+
data = response.json()
|
|
167
|
+
if not isinstance(data, dict):
|
|
168
|
+
raise ValueError
|
|
169
|
+
return data
|
|
170
|
+
except (ValueError, UnicodeError):
|
|
171
|
+
raise MakeLeapsError('The MakeLeaps API returned invalid JSON.') from None
|
|
172
|
+
|
|
173
|
+
async def _access_token(self, rejected: str | None = None) -> str:
|
|
174
|
+
async with self._token_lock:
|
|
175
|
+
if self._token and time.monotonic() < self._expires_at and self._token != rejected:
|
|
176
|
+
return self._token
|
|
177
|
+
response = await self._send('POST', _TOKEN_URL, auth=self._credentials,
|
|
178
|
+
data={'grant_type': 'client_credentials'})
|
|
179
|
+
self._check(response)
|
|
180
|
+
data = self._json(response)
|
|
181
|
+
try:
|
|
182
|
+
token = data['access_token']
|
|
183
|
+
expires = float(data['expires_in'])
|
|
184
|
+
if not isinstance(token, str) or not token or not math.isfinite(expires) or expires <= 0:
|
|
185
|
+
raise ValueError
|
|
186
|
+
except (KeyError, TypeError, ValueError):
|
|
187
|
+
raise MakeLeapsError('The MakeLeaps API returned an invalid access token response.') from None
|
|
188
|
+
self._token = token
|
|
189
|
+
self._expires_at = time.monotonic() + expires
|
|
190
|
+
return token
|
|
191
|
+
|
|
192
|
+
async def _request(self, url: str | httpx.URL, *, params=None) -> httpx.Response:
|
|
193
|
+
url = _safe_url(str(url))
|
|
194
|
+
token = await self._access_token()
|
|
195
|
+
response = await self._send('GET', url, params=params,
|
|
196
|
+
headers={'Authorization': f'Bearer {token}'})
|
|
197
|
+
if response.status_code == 401:
|
|
198
|
+
token = await self._access_token(rejected=token)
|
|
199
|
+
response = await self._send('GET', url, params=params,
|
|
200
|
+
headers={'Authorization': f'Bearer {token}'})
|
|
201
|
+
self._check(response)
|
|
202
|
+
return response
|
|
203
|
+
|
|
204
|
+
async def _data(self, url: str | httpx.URL, *, params=None) -> dict[str, Any]:
|
|
205
|
+
data = self._json(await self._request(url, params=params))
|
|
206
|
+
if 'response' not in data:
|
|
207
|
+
raise MakeLeapsError('The MakeLeaps API response is missing its resource data.')
|
|
208
|
+
return data
|
|
209
|
+
|
|
210
|
+
@staticmethod
|
|
211
|
+
def _model(model: type[T], data: Any) -> T:
|
|
212
|
+
try:
|
|
213
|
+
return model.model_validate(data)
|
|
214
|
+
except ValidationError:
|
|
215
|
+
raise MakeLeapsError('The MakeLeaps API returned an invalid resource.') from None
|
|
216
|
+
|
|
217
|
+
async def _company(self) -> str:
|
|
218
|
+
async with self._partner_lock:
|
|
219
|
+
if self._partner_url is None:
|
|
220
|
+
data = (await self._data(_ORIGIN + '/api/'))['response']
|
|
221
|
+
try:
|
|
222
|
+
partners = data['partners']
|
|
223
|
+
if len(partners) != 1:
|
|
224
|
+
raise ValueError
|
|
225
|
+
self._partner_url = str(_safe_url(partners[0]['url']))
|
|
226
|
+
except (KeyError, TypeError, ValueError):
|
|
227
|
+
raise MakeLeapsError('The API key must identify exactly one company.') from None
|
|
228
|
+
return self._partner_url
|
|
229
|
+
|
|
230
|
+
async def get_partner(self) -> Partner:
|
|
231
|
+
"""Return the company associated with the API key."""
|
|
232
|
+
return self._model(Partner, (await self._data(await self._company()))['response'])
|
|
233
|
+
|
|
234
|
+
async def _resource(self, kind: str, mid: str) -> dict[str, Any]:
|
|
235
|
+
if not mid:
|
|
236
|
+
raise MakeLeapsError('A resource mid is required.')
|
|
237
|
+
url = (await self._company()) + kind + '/' + quote(str(mid), safe='') + '/'
|
|
238
|
+
value = (await self._data(url))['response']
|
|
239
|
+
if not isinstance(value, dict):
|
|
240
|
+
raise MakeLeapsError('The MakeLeaps API returned an invalid resource.')
|
|
241
|
+
return value
|
|
242
|
+
|
|
243
|
+
async def _pages(self, url: str, params=None) -> AsyncIterator[list[dict[str, Any]]]:
|
|
244
|
+
query = httpx.QueryParams({'page_size': _PAGE_SIZE, 'page': 1})
|
|
245
|
+
if params:
|
|
246
|
+
query = query.merge(params)
|
|
247
|
+
first = await self._data(url, params=query)
|
|
248
|
+
values, meta = self._page(first)
|
|
249
|
+
yield values
|
|
250
|
+
if not meta.get('next'):
|
|
251
|
+
return
|
|
252
|
+
_safe_url(meta['next'])
|
|
253
|
+
count, page = meta.get('count'), meta.get('page', 1)
|
|
254
|
+
if isinstance(count, int) and isinstance(page, int) and values:
|
|
255
|
+
last_page = math.ceil(count / len(values))
|
|
256
|
+
for start in range(page + 1, last_page + 1, _PAGE_CONCURRENCY):
|
|
257
|
+
pages = await _together(*(self._data(url, params=query.set('page', number))
|
|
258
|
+
for number in range(start, min(start + _PAGE_CONCURRENCY, last_page + 1))))
|
|
259
|
+
for data in pages:
|
|
260
|
+
batch, _ = self._page(data)
|
|
261
|
+
yield batch
|
|
262
|
+
else:
|
|
263
|
+
# Older responses without count still have a usable next link.
|
|
264
|
+
seen = {str(_safe_url(url).copy_merge_params(query))}
|
|
265
|
+
while meta.get('next'):
|
|
266
|
+
next_url = str(_safe_url(meta['next']))
|
|
267
|
+
if next_url in seen:
|
|
268
|
+
raise MakeLeapsError('The MakeLeaps API returned a repeated page.')
|
|
269
|
+
seen.add(next_url)
|
|
270
|
+
values, meta = self._page(await self._data(next_url))
|
|
271
|
+
yield values
|
|
272
|
+
|
|
273
|
+
@staticmethod
|
|
274
|
+
def _page(data):
|
|
275
|
+
values, meta = data['response'], data.get('meta', {})
|
|
276
|
+
if not isinstance(values, list) or not all(isinstance(v, dict) for v in values) or not isinstance(meta, dict):
|
|
277
|
+
raise MakeLeapsError('The MakeLeaps API returned an invalid list page.')
|
|
278
|
+
return values, meta
|
|
279
|
+
|
|
280
|
+
async def _related(self) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
281
|
+
company = await self._company()
|
|
282
|
+
async def collect(kind):
|
|
283
|
+
records = {}
|
|
284
|
+
async for page in self._pages(company + kind + '/'):
|
|
285
|
+
for item in page:
|
|
286
|
+
if isinstance(item.get('url'), str):
|
|
287
|
+
records[item['url']] = item
|
|
288
|
+
return records
|
|
289
|
+
clients, contacts = await _together(collect('client'), collect('contact'))
|
|
290
|
+
return clients, contacts
|
|
291
|
+
|
|
292
|
+
def _expand(self, value, related) -> Document:
|
|
293
|
+
value = dict(value)
|
|
294
|
+
for field, records in zip(('client', 'client_contact'), related):
|
|
295
|
+
reference = value.get(field)
|
|
296
|
+
if isinstance(reference, str):
|
|
297
|
+
if reference not in records:
|
|
298
|
+
raise MakeLeapsError('A document references an unavailable client or contact.')
|
|
299
|
+
value[field] = records[reference]
|
|
300
|
+
return self._model(Document, value)
|
|
301
|
+
|
|
302
|
+
async def get_document(self, mid: str) -> Document:
|
|
303
|
+
"""Return a document with its complete client and contact resources."""
|
|
304
|
+
value, related = await _together(self._resource('document', mid), self._related())
|
|
305
|
+
return self._expand(value, related)
|
|
306
|
+
|
|
307
|
+
async def list_documents(self, *, document_type: str | None = None,
|
|
308
|
+
date_from: date | str | None = None,
|
|
309
|
+
date_to: date | str | None = None,
|
|
310
|
+
filters: Filters | None = None) -> AsyncIterator[Document]:
|
|
311
|
+
"""Yield all matching documents in API order, fetching pages on demand.
|
|
312
|
+
|
|
313
|
+
Date bounds are inclusive. ``filters`` accepts a mapping or pairs, so
|
|
314
|
+
repeated API parameters such as document_number retain all values.
|
|
315
|
+
"""
|
|
316
|
+
params = httpx.QueryParams(filters or {})
|
|
317
|
+
for key, value in (('document_type', document_type), ('date__gte', date_from), ('date__lte', date_to)):
|
|
318
|
+
if value is not None:
|
|
319
|
+
params = params.set(key, value.isoformat() if isinstance(value, date) else value)
|
|
320
|
+
pages = self._pages((await self._company()) + 'document/', params)
|
|
321
|
+
try:
|
|
322
|
+
first, related = await _together(anext(pages), self._related())
|
|
323
|
+
for value in first:
|
|
324
|
+
yield self._expand(value, related)
|
|
325
|
+
async for page in pages:
|
|
326
|
+
for value in page:
|
|
327
|
+
yield self._expand(value, related)
|
|
328
|
+
finally:
|
|
329
|
+
await pages.aclose()
|
|
330
|
+
|
|
331
|
+
async def list_archives(self) -> AsyncIterator[DocumentArchive]:
|
|
332
|
+
"""Yield every Document Archive in API order."""
|
|
333
|
+
async for page in self._pages((await self._company()) + 'archive/'):
|
|
334
|
+
for value in page:
|
|
335
|
+
yield self._model(DocumentArchive, value)
|
|
336
|
+
|
|
337
|
+
async def _pdf(self, kind: str, mid: str) -> bytes:
|
|
338
|
+
value = await self._resource(kind, mid)
|
|
339
|
+
url = value.get('pdf_content_url')
|
|
340
|
+
if not isinstance(url, str) or not url:
|
|
341
|
+
raise MakeLeapsError('No PDF is available for this resource.')
|
|
342
|
+
return (await self._request(url)).content
|
|
343
|
+
|
|
344
|
+
async def get_document_pdf(self, mid: str) -> bytes:
|
|
345
|
+
"""Return current document PDF bytes, without modifying the document."""
|
|
346
|
+
return await self._pdf('document', mid)
|
|
347
|
+
|
|
348
|
+
async def get_archive_pdf(self, mid: str) -> bytes:
|
|
349
|
+
"""Return the PDF bytes saved at the time of sending."""
|
|
350
|
+
return await self._pdf('archive', mid)
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Permissive models for MakeLeaps API v2023.06.26 read responses."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import datetime as dt
|
|
5
|
+
from decimal import Decimal
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, ConfigDict
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Resource(BaseModel):
|
|
12
|
+
"""Retain unknown fields and serialize only fields present in the response."""
|
|
13
|
+
|
|
14
|
+
model_config = ConfigDict(extra="allow")
|
|
15
|
+
|
|
16
|
+
def model_dump(self, **kwargs: Any) -> dict[str, Any]:
|
|
17
|
+
kwargs.setdefault("mode", "json")
|
|
18
|
+
kwargs.setdefault("exclude_unset", True)
|
|
19
|
+
return super().model_dump(**kwargs)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Money(Resource):
|
|
23
|
+
amount: Decimal | None = None
|
|
24
|
+
currency: str | None = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TaxTotal(Resource):
|
|
28
|
+
tax_type: str | None = None
|
|
29
|
+
tax_rate: Decimal | None = None
|
|
30
|
+
currency: str | None = None
|
|
31
|
+
subtotal: Decimal | Money | None = None
|
|
32
|
+
tax: Decimal | Money | None = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Partner(Resource):
|
|
36
|
+
url: str | None = None
|
|
37
|
+
name: str | None = None
|
|
38
|
+
clients: str | None = None
|
|
39
|
+
contacts: str | None = None
|
|
40
|
+
documents: str | None = None
|
|
41
|
+
line_item_catalog: str | None = None
|
|
42
|
+
tags: str | None = None
|
|
43
|
+
sending_orders: str | None = None
|
|
44
|
+
history: str | None = None
|
|
45
|
+
integrations: dict[str, Any] | None = None
|
|
46
|
+
account_name: str | None = None
|
|
47
|
+
tax_registration_number: str | None = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Client(Resource):
|
|
51
|
+
url: str | None = None
|
|
52
|
+
mid: str | None = None
|
|
53
|
+
display_name: str | None = None
|
|
54
|
+
organization_type: str | None = None
|
|
55
|
+
is_organization: bool | None = None
|
|
56
|
+
date_archived: dt.datetime | None = None
|
|
57
|
+
client_external_id: str | None = None
|
|
58
|
+
contacts: list[Any] | None = None
|
|
59
|
+
default_contact: str | dict[str, Any] | None = None
|
|
60
|
+
managing_teams: list[Any] | None = None
|
|
61
|
+
default_presets: list[Any] | None = None
|
|
62
|
+
integrations: dict[str, Any] | None = None
|
|
63
|
+
lang_code: str | None = None
|
|
64
|
+
settings: str | dict[str, Any] | None = None
|
|
65
|
+
date_cutoff: int | None = None
|
|
66
|
+
tax_registration_number: str | None = None
|
|
67
|
+
virtual_bank_accounts: list[Any] | None = None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class Contact(Resource):
|
|
71
|
+
url: str | None = None
|
|
72
|
+
mid: str | None = None
|
|
73
|
+
owner: str | dict[str, Any] | None = None
|
|
74
|
+
owner_type: str | None = None
|
|
75
|
+
contact_type: str | None = None
|
|
76
|
+
name: str | None = None
|
|
77
|
+
family_name: str | None = None
|
|
78
|
+
given_name: str | None = None
|
|
79
|
+
display_name: str | None = None
|
|
80
|
+
title: str | None = None
|
|
81
|
+
department: str | None = None
|
|
82
|
+
format: str | None = None
|
|
83
|
+
phone_number: str | None = None
|
|
84
|
+
fax_number: str | None = None
|
|
85
|
+
is_default: bool | str | None = None
|
|
86
|
+
email: str | dict[str, Any] | None = None
|
|
87
|
+
addresses: list[Any] | None = None
|
|
88
|
+
default_address: str | dict[str, Any] | None = None
|
|
89
|
+
integrations: dict[str, Any] | None = None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class LineItem(Resource):
|
|
93
|
+
kind: str | None = None
|
|
94
|
+
description: str | None = None
|
|
95
|
+
unit_cost: Decimal | None = None
|
|
96
|
+
quantity: Decimal | None = None
|
|
97
|
+
quantity_unit: str | None = None
|
|
98
|
+
price: Decimal | None = None
|
|
99
|
+
is_taxable: bool | None = None
|
|
100
|
+
is_tax_inclusive: bool | None = None
|
|
101
|
+
has_jp_withholding_tax: bool | None = None
|
|
102
|
+
currency: str | None = None
|
|
103
|
+
tax_rate: Decimal | None = None
|
|
104
|
+
tax_type: str | None = None
|
|
105
|
+
extended_data: dict[str, Any] | None = None
|
|
106
|
+
vendor_cost: Decimal | None = None
|
|
107
|
+
source_catalog_item: str | None = None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class Document(Resource):
|
|
111
|
+
document_number: str | None = None
|
|
112
|
+
document_type: str | None = None
|
|
113
|
+
title: str | None = None
|
|
114
|
+
project_name: str | None = None
|
|
115
|
+
bank_details_header: str | None = None
|
|
116
|
+
bank_details: str | None = None
|
|
117
|
+
message: str | None = None
|
|
118
|
+
proviso: str | None = None
|
|
119
|
+
group_lineitem_by_tax_rate: bool | None = None
|
|
120
|
+
has_hanko: bool | None = None
|
|
121
|
+
has_prices: bool | None = None
|
|
122
|
+
has_totals: bool | None = None
|
|
123
|
+
has_currency_code: bool | None = None
|
|
124
|
+
has_lineitem_tax_state: bool | None = None
|
|
125
|
+
show_lineitem_tax_rate: bool | None = None
|
|
126
|
+
show_lineitem_tax_state: bool | None = None
|
|
127
|
+
show_lineitem_tax_type: bool | None = None
|
|
128
|
+
signature: bool | None = None
|
|
129
|
+
display_tax_rate: bool | None = None
|
|
130
|
+
hide_tax_description: bool | None = None
|
|
131
|
+
has_tax_stamp: bool | None = None
|
|
132
|
+
currency: str | None = None
|
|
133
|
+
subtotal: Decimal | None = None
|
|
134
|
+
tax: Decimal | None = None
|
|
135
|
+
jp_withholding_tax: Decimal | None = None
|
|
136
|
+
tax_rate: Decimal | None = None
|
|
137
|
+
tax_type: str | None = None
|
|
138
|
+
subtotal_sales_tax: Decimal | None = None
|
|
139
|
+
subtotal_withholding_tax: Decimal | None = None
|
|
140
|
+
total: Decimal | None = None
|
|
141
|
+
mixed_tax_rate_totals: dict[str, TaxTotal] | None = None
|
|
142
|
+
tax_group_totals: list[TaxTotal] | None = None
|
|
143
|
+
extended_data: dict[str, Any] | None = None
|
|
144
|
+
mid: str | None = None
|
|
145
|
+
document_template: str | dict[str, Any] | None = None
|
|
146
|
+
display_name: str | None = None
|
|
147
|
+
note: str | None = None
|
|
148
|
+
external_id: str | None = None
|
|
149
|
+
round_tax_down: bool | None = None
|
|
150
|
+
tax_rounding: str | None = None
|
|
151
|
+
lineitem_price_rounding: str | None = None
|
|
152
|
+
tags: list[Any] | None = None
|
|
153
|
+
url: str | None = None
|
|
154
|
+
pickup_links: str | dict[str, Any] | None = None
|
|
155
|
+
pdf_content_url: str | None = None
|
|
156
|
+
pdf_upload_url: str | None = None
|
|
157
|
+
client: Client | str | None = None
|
|
158
|
+
client_contact: Contact | str | None = None
|
|
159
|
+
modified: dt.datetime | None = None
|
|
160
|
+
date_paid: dt.datetime | None = None
|
|
161
|
+
date_sent: dt.datetime | None = None
|
|
162
|
+
date_cancelled: dt.datetime | None = None
|
|
163
|
+
date_removed: dt.datetime | None = None
|
|
164
|
+
date_accepted: dt.datetime | None = None
|
|
165
|
+
date_confirmed: dt.datetime | None = None
|
|
166
|
+
date_converted: dt.datetime | None = None
|
|
167
|
+
payment_date: dt.date | None = None
|
|
168
|
+
invoicing_period_start_date: dt.date | None = None
|
|
169
|
+
invoicing_period_end_date: dt.date | None = None
|
|
170
|
+
is_accepted: bool | None = None
|
|
171
|
+
is_confirmed: bool | None = None
|
|
172
|
+
is_converted: bool | None = None
|
|
173
|
+
approval_status: str | None = None
|
|
174
|
+
integrations: dict[str, Any] | None = None
|
|
175
|
+
date: dt.date | None = None
|
|
176
|
+
date_due: dt.date | None = None
|
|
177
|
+
date_valid: dt.date | None = None
|
|
178
|
+
date_shipped: dt.date | None = None
|
|
179
|
+
sender_name: str | None = None
|
|
180
|
+
sender_contact_department: str | None = None
|
|
181
|
+
sender_contact_title: str | None = None
|
|
182
|
+
sender_contact_name: str | None = None
|
|
183
|
+
sender_address_format: str | None = None
|
|
184
|
+
sender_country: str | None = None
|
|
185
|
+
sender_postal_code: str | None = None
|
|
186
|
+
sender_region: str | None = None
|
|
187
|
+
sender_locality: str | None = None
|
|
188
|
+
sender_street_address: str | None = None
|
|
189
|
+
sender_extended_address: str | None = None
|
|
190
|
+
sender_phone_number: str | None = None
|
|
191
|
+
sender_fax_number: str | None = None
|
|
192
|
+
partner_tax_registration_number: str | None = None
|
|
193
|
+
display_partner_tax_registration_number: bool | None = None
|
|
194
|
+
recipient_name: str | None = None
|
|
195
|
+
recipient_contact_department: str | None = None
|
|
196
|
+
recipient_contact_title: str | None = None
|
|
197
|
+
recipient_contact_name: str | None = None
|
|
198
|
+
recipient_honorific: str | None = None
|
|
199
|
+
recipient_address_format: str | None = None
|
|
200
|
+
recipient_country: str | None = None
|
|
201
|
+
recipient_postal_code: str | None = None
|
|
202
|
+
recipient_region: str | None = None
|
|
203
|
+
recipient_locality: str | None = None
|
|
204
|
+
recipient_street_address: str | None = None
|
|
205
|
+
recipient_extended_address: str | None = None
|
|
206
|
+
recipient_phone_number: str | None = None
|
|
207
|
+
recipient_fax_number: str | None = None
|
|
208
|
+
client_tax_registration_number: str | None = None
|
|
209
|
+
display_client_tax_registration_number: bool | None = None
|
|
210
|
+
lineitems: list[LineItem] | None = None
|
|
211
|
+
purchase_order_ids: list[Any] | None = None
|
|
212
|
+
peppol_details: Any | None = None
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class DocumentArchive(Resource):
|
|
216
|
+
mid: str | None = None
|
|
217
|
+
url: str | None = None
|
|
218
|
+
pdf_content_url: str | None = None
|
|
219
|
+
uses_external_pdf: bool | str | None = None
|
|
220
|
+
document_type: str | None = None
|
|
221
|
+
document_number: str | None = None
|
|
222
|
+
sending_methods: list[str] | None = None
|
|
223
|
+
legal_categories: list[Any] | None = None
|
|
224
|
+
date_sent: dt.datetime | None = None
|
|
225
|
+
date: dt.date | None = None
|
|
226
|
+
sender: str | None = None
|
|
227
|
+
client: str | None = None
|
|
228
|
+
currency: str | None = None
|
|
229
|
+
total: Decimal | None = None
|
|
230
|
+
external_id: str | None = None
|
|
231
|
+
delivery_methods: list[str] | None = None
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: makeleaps-client
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Unofficial Python client library and CLI for the MakeLeaps REST API (not affiliated with MakeLeaps, Inc.)
|
|
5
|
+
Project-URL: Repository, https://github.com/minamikik/makeleaps-client
|
|
6
|
+
Author-email: minamik <10973755+minamikik@users.noreply.github.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Python: >=3.12
|
|
10
|
+
Requires-Dist: httpx>=0.28.1
|
|
11
|
+
Requires-Dist: pydantic>=2.14.1
|
|
12
|
+
Requires-Dist: pyyaml>=6.0.3
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# makeleaps-client
|
|
16
|
+
|
|
17
|
+
Unofficial Python client library and CLI for the MakeLeaps REST API. This project is not affiliated with or endorsed by MakeLeaps, Inc.
|
|
18
|
+
|
|
19
|
+
The client targets API keys using **MakeLeaps API version `v2023.06.26`**. Response shapes and behavior were checked against that version. MakeLeaps assigns the API version to your API key; the client does not select a version in requests. Other API key versions have not been verified.
|
|
20
|
+
|
|
21
|
+
Requires Python 3.12 or newer. Distributed under the MIT license.
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
pip install makeleaps-client
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The package provides the `makeleaps_client` library and the `makeleaps` command. Both support read operations only: your company, documents of every type, current document PDFs, Document Archives, and PDFs preserved at the time of sending. Choose the PDF source appropriate for your records; an archive may not exist for every sent document.
|
|
28
|
+
|
|
29
|
+
## Async Python library
|
|
30
|
+
|
|
31
|
+
Pass credentials explicitly. The library reads neither environment variables nor configuration files. It discovers the one company associated with the API key, so no company argument is needed.
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
import asyncio
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
|
|
37
|
+
from makeleaps_client import MakeLeapsClient, MakeLeapsError
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def read_documents(client_id: str, client_secret: str):
|
|
41
|
+
async with MakeLeapsClient(client_id, client_secret) as api:
|
|
42
|
+
company = await api.get_partner()
|
|
43
|
+
print(company.name)
|
|
44
|
+
|
|
45
|
+
async for document in api.list_documents(
|
|
46
|
+
document_type="invoice",
|
|
47
|
+
date_from="2025-01-01",
|
|
48
|
+
date_to="2025-12-31",
|
|
49
|
+
filters={"sent": "true"},
|
|
50
|
+
):
|
|
51
|
+
print(document.mid, document.total, document.client.display_name)
|
|
52
|
+
|
|
53
|
+
document = await api.get_document("example-document-mid")
|
|
54
|
+
print(document.model_dump())
|
|
55
|
+
Path("current.pdf").write_bytes(
|
|
56
|
+
await api.get_document_pdf("example-document-mid")
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
async for archive in api.list_archives():
|
|
60
|
+
print(archive.mid, archive.date_sent)
|
|
61
|
+
Path("sent.pdf").write_bytes(
|
|
62
|
+
await api.get_archive_pdf("example-archive-mid")
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# Supply credentials from your application's own credential handling.
|
|
67
|
+
# asyncio.run(read_documents(client_id, client_secret))
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Use one client within one event loop. The async context manager closes its HTTP connections; alternatively call `await api.aclose()`. Handle request failures with `MakeLeapsError`.
|
|
71
|
+
|
|
72
|
+
| Operation | Result |
|
|
73
|
+
| --- | --- |
|
|
74
|
+
| `await api.get_partner()` | `Partner` |
|
|
75
|
+
| `api.list_documents(document_type=None, date_from=None, date_to=None, filters=None)` | Async iterator of `Document` |
|
|
76
|
+
| `await api.get_document(mid)` | `Document` |
|
|
77
|
+
| `await api.get_document_pdf(mid)` | PDF `bytes` |
|
|
78
|
+
| `api.list_archives()` | Async iterator of `DocumentArchive` |
|
|
79
|
+
| `await api.get_archive_pdf(mid)` | PDF `bytes` |
|
|
80
|
+
|
|
81
|
+
Date bounds are inclusive (`date__gte` and `date__lte`). They accept `datetime.date` or ISO date strings. Other filters pass through using the API's parameter names and values. To repeat a parameter, supply pairs, for example `filters=[("document_number", "EXAMPLE-A"), ("document_number", "EXAMPLE-B")]`. MakeLeaps may silently ignore unsupported filters; consult its API documentation.
|
|
82
|
+
|
|
83
|
+
List operations yield every matching record in API order. Pages contain up to 100 records, and up to eight subsequent pages are fetched together when iteration advances beyond the current batch. Stopping iteration avoids subsequent batches; pages already requested in the current batch may have completed. Use `await iterator.aclose()` when explicitly holding an iterator and ending it early.
|
|
84
|
+
|
|
85
|
+
Documents include full client and client contact resources. Each document operation loads the client and contact lists once and joins them by URL. Those lists are not cached across operations. An unavailable referenced resource causes `MakeLeapsError` rather than returning a partially expanded document.
|
|
86
|
+
|
|
87
|
+
Models are permissive pydantic models: all fields are optional, unknown fields are retained, and enum-like values remain strings. Known monetary and numeric fields become `Decimal`, dates become `datetime.date`, and timestamps become `datetime.datetime`. Nested line items and tax totals are also typed. `model_dump()` defaults to JSON-compatible API values, retaining unknown fields and omitting fields absent from the response. Explicit `mode="python"` produces Python values instead.
|
|
88
|
+
|
|
89
|
+
The default response language is Japanese. Set `MakeLeapsClient(client_id, client_secret, language="en")` to change `Accept-Language` on all requests.
|
|
90
|
+
|
|
91
|
+
## Command line
|
|
92
|
+
|
|
93
|
+
The CLI reads `MAKELEAPS_CLIENT_ID` and `MAKELEAPS_CLIENT_SECRET` from its environment and passes them to the library. Set them using your shell's or process runner's credential handling. It does not read configuration files.
|
|
94
|
+
|
|
95
|
+
```sh
|
|
96
|
+
makeleaps --help
|
|
97
|
+
makeleaps partner
|
|
98
|
+
makeleaps documents list --type invoice --date-from 2025-01-01 --date-to 2025-12-31
|
|
99
|
+
makeleaps documents list --filter sent=true --filter document_number=EXAMPLE-A --limit 10
|
|
100
|
+
makeleaps documents get example-document-mid
|
|
101
|
+
makeleaps documents pdf example-document-mid --output current.pdf
|
|
102
|
+
makeleaps archives list --limit 10
|
|
103
|
+
makeleaps archives pdf example-archive-mid --output sent.pdf
|
|
104
|
+
makeleaps documents list --language en
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Company, document, and archive results are written as complete YAML values to stdout. Each list is a single YAML sequence, including when empty. Monetary values remain strings in YAML. PDF commands write the original bytes to files. Failures produce an explanatory message on stderr and a nonzero exit status; completed files from an interrupted or failed bulk export remain on disk.
|
|
108
|
+
|
|
109
|
+
Bulk PDF downloads create the destination directory if needed:
|
|
110
|
+
|
|
111
|
+
```sh
|
|
112
|
+
makeleaps documents pdf --dir ./current-pdfs --type invoice --date-from 2025-01-01 --filter sent=true
|
|
113
|
+
makeleaps archives pdf --dir ./sent-pdfs --limit 100
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Documents accept the same filters for `list` and bulk `pdf`. Archives support `--limit` and `--language`. Without `--limit`, list and bulk PDF commands process every matching record; `--limit 0` processes none. `--language` is accepted on every command, and defaults to `ja`.
|
|
117
|
+
|
|
118
|
+
Bulk filenames use `<date>_<document_type>_<document_number>_<mid>.pdf`. Characters that cannot be used in portable filenames are replaced with `_`. A file with the same `mid` suffix already in the destination directory is skipped without fetching that PDF, even if its document number changed. The current document PDF and its archive PDF can differ; use separate directories for each source.
|
|
119
|
+
|
|
120
|
+
## Authentication and requests
|
|
121
|
+
|
|
122
|
+
Requests use HTTPS exclusively on `api.makeleaps.com`, with redirects disabled. Access tokens remain in process memory. Expired tokens and a 401 response cause reauthentication; a second 401 fails. Error messages do not include credentials, tokens, or raw response bodies.
|
|
123
|
+
|
|
124
|
+
All requests on one client share a rolling limiter of 250 requests per 60 seconds and 20,000 per day, based on observed API response headers. Concurrent clients or other applications using the same credentials can consume the same server quota. A 429 response delays subsequent requests according to `Retry-After` (seconds or an HTTP date). Transport errors and server errors use exponential backoff. A request has at most four attempts, and reauthentication after a 401 is attempted once. The HTTP timeout is 60 seconds.
|
|
125
|
+
|
|
126
|
+
No third-party MakeLeaps client is used. Tests replace the HTTP boundary with synthetic responses and never contact MakeLeaps:
|
|
127
|
+
|
|
128
|
+
```sh
|
|
129
|
+
uv run --python 3.12 pytest
|
|
130
|
+
uv run --python 3.13 pytest
|
|
131
|
+
uv build
|
|
132
|
+
```
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
makeleaps_client/__init__.py,sha256=tEXCFRVSk9q0pLXyHCrIDjcznAwY0yN9ewZ5aMQs5rI,335
|
|
2
|
+
makeleaps_client/_version.py,sha256=n_5vdJsPNu7wZ57LGuRL585uvll-hiuvZUBWzdG0RQU,520
|
|
3
|
+
makeleaps_client/cli.py,sha256=ZflqR6Qh3405smU-Jrs5fDxepmVpQSZd2Urxh8xkOaE,7402
|
|
4
|
+
makeleaps_client/client.py,sha256=jYARod8QSqqSoqz-I-Xd-ARl4Tzz0a4FIgNWZsmUabE,15153
|
|
5
|
+
makeleaps_client/models.py,sha256=BAKHhNUl32vo2EZ6OON1cGNcvLq4IlwXZ0P7eEQShoI,8309
|
|
6
|
+
makeleaps_client-0.1.0.dist-info/METADATA,sha256=4URX4X1xN6Syo19W7imE14TXhTujn5cYkRUY60QdL8g,8141
|
|
7
|
+
makeleaps_client-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
8
|
+
makeleaps_client-0.1.0.dist-info/entry_points.txt,sha256=-wBUhyc9PZ2hWTBhdpxEM-geTg772dK_rrLCcdgcZJk,56
|
|
9
|
+
makeleaps_client-0.1.0.dist-info/licenses/LICENSE,sha256=C3kcuSn77D1YmBPwywbEXaYrjkyWWcMvubpMt-2bi3Y,1064
|
|
10
|
+
makeleaps_client-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 minamik
|
|
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.
|