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.
@@ -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
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ makeleaps = makeleaps_client.cli:main
@@ -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.