makeleaps-client 0.1.0__tar.gz

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,9 @@
1
+ .nf/tmp/
2
+ .nf/logs/
3
+ .nf/research/
4
+ dist/
5
+ __pycache__/
6
+ .venv/
7
+ .pytest_cache/
8
+ .ruff_cache/
9
+ src/makeleaps_client/_version.py
@@ -0,0 +1 @@
1
+ 3.12
@@ -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.
@@ -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,118 @@
1
+ # makeleaps-client
2
+
3
+ Unofficial Python client library and CLI for the MakeLeaps REST API. This project is not affiliated with or endorsed by MakeLeaps, Inc.
4
+
5
+ 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.
6
+
7
+ Requires Python 3.12 or newer. Distributed under the MIT license.
8
+
9
+ ```sh
10
+ pip install makeleaps-client
11
+ ```
12
+
13
+ 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.
14
+
15
+ ## Async Python library
16
+
17
+ 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.
18
+
19
+ ```python
20
+ import asyncio
21
+ from pathlib import Path
22
+
23
+ from makeleaps_client import MakeLeapsClient, MakeLeapsError
24
+
25
+
26
+ async def read_documents(client_id: str, client_secret: str):
27
+ async with MakeLeapsClient(client_id, client_secret) as api:
28
+ company = await api.get_partner()
29
+ print(company.name)
30
+
31
+ async for document in api.list_documents(
32
+ document_type="invoice",
33
+ date_from="2025-01-01",
34
+ date_to="2025-12-31",
35
+ filters={"sent": "true"},
36
+ ):
37
+ print(document.mid, document.total, document.client.display_name)
38
+
39
+ document = await api.get_document("example-document-mid")
40
+ print(document.model_dump())
41
+ Path("current.pdf").write_bytes(
42
+ await api.get_document_pdf("example-document-mid")
43
+ )
44
+
45
+ async for archive in api.list_archives():
46
+ print(archive.mid, archive.date_sent)
47
+ Path("sent.pdf").write_bytes(
48
+ await api.get_archive_pdf("example-archive-mid")
49
+ )
50
+
51
+
52
+ # Supply credentials from your application's own credential handling.
53
+ # asyncio.run(read_documents(client_id, client_secret))
54
+ ```
55
+
56
+ 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`.
57
+
58
+ | Operation | Result |
59
+ | --- | --- |
60
+ | `await api.get_partner()` | `Partner` |
61
+ | `api.list_documents(document_type=None, date_from=None, date_to=None, filters=None)` | Async iterator of `Document` |
62
+ | `await api.get_document(mid)` | `Document` |
63
+ | `await api.get_document_pdf(mid)` | PDF `bytes` |
64
+ | `api.list_archives()` | Async iterator of `DocumentArchive` |
65
+ | `await api.get_archive_pdf(mid)` | PDF `bytes` |
66
+
67
+ 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.
68
+
69
+ 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.
70
+
71
+ 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.
72
+
73
+ 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.
74
+
75
+ The default response language is Japanese. Set `MakeLeapsClient(client_id, client_secret, language="en")` to change `Accept-Language` on all requests.
76
+
77
+ ## Command line
78
+
79
+ 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.
80
+
81
+ ```sh
82
+ makeleaps --help
83
+ makeleaps partner
84
+ makeleaps documents list --type invoice --date-from 2025-01-01 --date-to 2025-12-31
85
+ makeleaps documents list --filter sent=true --filter document_number=EXAMPLE-A --limit 10
86
+ makeleaps documents get example-document-mid
87
+ makeleaps documents pdf example-document-mid --output current.pdf
88
+ makeleaps archives list --limit 10
89
+ makeleaps archives pdf example-archive-mid --output sent.pdf
90
+ makeleaps documents list --language en
91
+ ```
92
+
93
+ 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.
94
+
95
+ Bulk PDF downloads create the destination directory if needed:
96
+
97
+ ```sh
98
+ makeleaps documents pdf --dir ./current-pdfs --type invoice --date-from 2025-01-01 --filter sent=true
99
+ makeleaps archives pdf --dir ./sent-pdfs --limit 100
100
+ ```
101
+
102
+ 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`.
103
+
104
+ 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.
105
+
106
+ ## Authentication and requests
107
+
108
+ 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.
109
+
110
+ 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.
111
+
112
+ No third-party MakeLeaps client is used. Tests replace the HTTP boundary with synthetic responses and never contact MakeLeaps:
113
+
114
+ ```sh
115
+ uv run --python 3.12 pytest
116
+ uv run --python 3.13 pytest
117
+ uv build
118
+ ```
@@ -0,0 +1,52 @@
1
+ [project]
2
+ name = "makeleaps-client"
3
+ dynamic = ["version"]
4
+ description = "Unofficial Python client library and CLI for the MakeLeaps REST API (not affiliated with MakeLeaps, Inc.)"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ authors = [{ name = "minamik", email = "10973755+minamikik@users.noreply.github.com" }]
9
+ requires-python = ">=3.12"
10
+ dependencies = [
11
+ "httpx>=0.28.1",
12
+ "pydantic>=2.14.1",
13
+ "pyyaml>=6.0.3",
14
+ ]
15
+
16
+ [project.urls]
17
+ Repository = "https://github.com/minamikik/makeleaps-client"
18
+
19
+ [project.scripts]
20
+ makeleaps = "makeleaps_client.cli:main"
21
+
22
+ [build-system]
23
+ requires = ["hatchling", "hatch-vcs"]
24
+ build-backend = "hatchling.build"
25
+
26
+ [tool.hatch.version]
27
+ source = "vcs"
28
+
29
+ [tool.hatch.version.raw-options]
30
+ tag_regex = "^v(?P<version>[^/]+)$"
31
+ git_describe_command = ["git", "describe", "--dirty", "--tags", "--long", "--match", "v*"]
32
+
33
+ [tool.hatch.build.hooks.vcs]
34
+ version-file = "src/makeleaps_client/_version.py"
35
+
36
+ [tool.hatch.build.targets.sdist]
37
+ exclude = ["/.nf/", "/.github/", "/noren.yaml", "/noren-words.txt"]
38
+
39
+ [tool.hatch.build.targets.wheel]
40
+ packages = ["src/makeleaps_client"]
41
+
42
+ [tool.pytest.ini_options]
43
+ addopts = ["--strict-markers"]
44
+ markers = [
45
+ "req(id): the adopted spec item ID that the test verifies",
46
+ ]
47
+ testpaths = ["tests"]
48
+
49
+ [dependency-groups]
50
+ dev = [
51
+ "pytest>=9.1.1",
52
+ ]
@@ -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