pyholded 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.
Files changed (36) hide show
  1. pyholded-0.1.0/.gitattributes +6 -0
  2. pyholded-0.1.0/.github/workflows/ci.yml +93 -0
  3. pyholded-0.1.0/.gitignore +45 -0
  4. pyholded-0.1.0/LICENSE +21 -0
  5. pyholded-0.1.0/Makefile +49 -0
  6. pyholded-0.1.0/PKG-INFO +376 -0
  7. pyholded-0.1.0/README.md +339 -0
  8. pyholded-0.1.0/pyproject.toml +104 -0
  9. pyholded-0.1.0/requirements.lock +69 -0
  10. pyholded-0.1.0/sbom.cdx.json +1010 -0
  11. pyholded-0.1.0/scripts/_sbom_signing.py +29 -0
  12. pyholded-0.1.0/scripts/generate_sbom.py +347 -0
  13. pyholded-0.1.0/scripts/verify_sbom.py +57 -0
  14. pyholded-0.1.0/src/pyholded/__init__.py +55 -0
  15. pyholded-0.1.0/src/pyholded/_proxies.py +73 -0
  16. pyholded-0.1.0/src/pyholded/_registry.py +84 -0
  17. pyholded-0.1.0/src/pyholded/cli.py +329 -0
  18. pyholded-0.1.0/src/pyholded/client.py +159 -0
  19. pyholded-0.1.0/src/pyholded/config.py +196 -0
  20. pyholded-0.1.0/src/pyholded/endpoints.py +210 -0
  21. pyholded-0.1.0/src/pyholded/exceptions.py +43 -0
  22. pyholded-0.1.0/src/pyholded/multi.py +98 -0
  23. pyholded-0.1.0/src/pyholded/output.py +97 -0
  24. pyholded-0.1.0/src/pyholded/py.typed +0 -0
  25. pyholded-0.1.0/src/pyholded/transport.py +139 -0
  26. pyholded-0.1.0/tests/__init__.py +0 -0
  27. pyholded-0.1.0/tests/conftest.py +10 -0
  28. pyholded-0.1.0/tests/test_cli.py +231 -0
  29. pyholded-0.1.0/tests/test_client.py +192 -0
  30. pyholded-0.1.0/tests/test_config.py +126 -0
  31. pyholded-0.1.0/tests/test_multi_account.py +154 -0
  32. pyholded-0.1.0/tests/test_output.py +66 -0
  33. pyholded-0.1.0/tests/test_package.py +23 -0
  34. pyholded-0.1.0/tests/test_registry.py +59 -0
  35. pyholded-0.1.0/tests/test_sbom_script.py +39 -0
  36. pyholded-0.1.0/tests/test_transport.py +64 -0
@@ -0,0 +1,6 @@
1
+ * text=auto eol=lf
2
+ *.py text eol=lf
3
+ *.toml text eol=lf
4
+ *.md text eol=lf
5
+ *.bundle binary
6
+ sbom.cdx.json text eol=lf
@@ -0,0 +1,93 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ permissions:
10
+ contents: read
11
+
12
+ jobs:
13
+ gate:
14
+ name: Static gate (lint/format/types/security)
15
+ runs-on: ubuntu-latest
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+ - name: Set up Python 3.14
19
+ uses: actions/setup-python@v5
20
+ with:
21
+ python-version: "3.14"
22
+ - name: Install
23
+ run: |
24
+ python -m pip install --upgrade pip
25
+ pip install -e ".[dev]"
26
+ - name: Lint (ruff)
27
+ run: python -m ruff check .
28
+ - name: Format (black)
29
+ run: python -m black --check --diff .
30
+ - name: Types (mypy --strict)
31
+ run: python -m mypy
32
+ - name: Security (bandit)
33
+ run: python -m bandit -rq src -c pyproject.toml
34
+ - name: Dead code (vulture)
35
+ run: python -m vulture src/
36
+ - name: Dependency audit (pip-audit)
37
+ run: python -m pip_audit -r requirements.lock --strict
38
+
39
+ test:
40
+ name: Tests (${{ matrix.os }})
41
+ strategy:
42
+ fail-fast: false
43
+ matrix:
44
+ os: [ubuntu-latest, windows-latest, macos-latest]
45
+ runs-on: ${{ matrix.os }}
46
+ steps:
47
+ - uses: actions/checkout@v4
48
+ - name: Set up Python 3.14
49
+ uses: actions/setup-python@v5
50
+ with:
51
+ python-version: "3.14"
52
+ - name: Install
53
+ run: |
54
+ python -m pip install --upgrade pip
55
+ pip install -e ".[dev]"
56
+ - name: Tests (pytest)
57
+ run: python -m pytest
58
+
59
+ sbom:
60
+ name: SBOM + quality score
61
+ runs-on: ubuntu-latest
62
+ needs: [gate, test]
63
+ env:
64
+ SBOM_MIN_SCORE: "8.0"
65
+ steps:
66
+ - uses: actions/checkout@v4
67
+ - name: Set up Python 3.14
68
+ uses: actions/setup-python@v5
69
+ with:
70
+ python-version: "3.14"
71
+ - name: Install
72
+ run: |
73
+ python -m pip install --upgrade pip
74
+ pip install -e ".[dev]"
75
+ - name: Install sbomqs
76
+ run: |
77
+ go install github.com/interlynk-io/sbomqs@latest
78
+ echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH"
79
+ - name: Generate signed SBOM
80
+ run: python scripts/generate_sbom.py --sign --output sbom.cdx.json
81
+ - name: Verify SBOM signature
82
+ run: python scripts/verify_sbom.py sbom.cdx.json
83
+ - name: Score SBOM (fail below threshold)
84
+ run: |
85
+ sbomqs score sbom.cdx.json
86
+ score=$(sbomqs score sbom.cdx.json --basic | cut -f1)
87
+ awk -v s="$score" -v m="$SBOM_MIN_SCORE" 'BEGIN { exit (s+0 < m+0) }' \
88
+ || { echo "SBOM score $score below $SBOM_MIN_SCORE"; exit 1; }
89
+ - name: Upload SBOM artifact
90
+ uses: actions/upload-artifact@v4
91
+ with:
92
+ name: sbom-cyclonedx
93
+ path: sbom.cdx.json
@@ -0,0 +1,45 @@
1
+ # Claude Code
2
+ CLAUDE.md
3
+ .claude/
4
+
5
+ # Signing — never commit private keys (the public key is embedded in the SBOM)
6
+ signing/signing-key.pem
7
+ signing/cosign.key
8
+ *.key
9
+
10
+ # Virtual environments
11
+ venv/
12
+ .venv/
13
+ env/
14
+ ENV/
15
+
16
+ # Python bytecode / caches
17
+ __pycache__/
18
+ *.py[cod]
19
+ *$py.class
20
+ *.so
21
+
22
+ # Packaging / build
23
+ build/
24
+ dist/
25
+ *.egg-info/
26
+ *.egg
27
+ .eggs/
28
+ pip-wheel-metadata/
29
+
30
+ # Test / coverage / tooling caches
31
+ .pytest_cache/
32
+ .mypy_cache/
33
+ .ruff_cache/
34
+ .tox/
35
+ .nox/
36
+ .coverage
37
+ .coverage.*
38
+ htmlcov/
39
+ coverage.xml
40
+
41
+ # Editors / OS
42
+ .idea/
43
+ .vscode/
44
+ *.swp
45
+ .DS_Store
pyholded-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Marc Rivero López
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,49 @@
1
+ .PHONY: install gate lint format types security deadcode audit test \
2
+ sbom sbom-score sbom-verify lock all
3
+
4
+ VENV ?= venv
5
+ PY := $(VENV)/bin/python
6
+ SBOM := sbom.cdx.json
7
+ SBOM_MIN_SCORE ?= 9.0
8
+
9
+ install:
10
+ $(PY) -m pip install -e ".[dev]"
11
+
12
+ lint:
13
+ $(PY) -m ruff check .
14
+
15
+ format:
16
+ $(PY) -m black --check --diff .
17
+
18
+ types:
19
+ $(PY) -m mypy
20
+
21
+ security:
22
+ $(PY) -m bandit -rq src -c pyproject.toml
23
+
24
+ deadcode:
25
+ $(PY) -m vulture src/
26
+
27
+ audit:
28
+ $(PY) -m pip_audit -r requirements.lock --strict
29
+
30
+ test:
31
+ $(PY) -m pytest
32
+
33
+ gate: lint format types security deadcode test
34
+
35
+ lock:
36
+ uv pip compile pyproject.toml --generate-hashes -o requirements.lock
37
+
38
+ sbom:
39
+ $(PY) scripts/generate_sbom.py --sign --output $(SBOM)
40
+
41
+ sbom-verify:
42
+ $(PY) scripts/verify_sbom.py $(SBOM)
43
+
44
+ sbom-score: sbom
45
+ sbomqs score $(SBOM)
46
+ @score=$$(sbomqs score $(SBOM) --basic | cut -f1); \
47
+ awk -v s=$$score -v m=$(SBOM_MIN_SCORE) 'BEGIN { if (s+0 < m+0) { printf "SBOM score %.1f below minimum %.1f\n", s, m; exit 1 } printf "SBOM score %.1f >= %.1f OK\n", s, m }'
48
+
49
+ all: gate sbom-score sbom-verify
@@ -0,0 +1,376 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyholded
3
+ Version: 0.1.0
4
+ Summary: Modular Python client and CLI for the complete Holded API, with rich, JSON and TOON output.
5
+ Project-URL: Homepage, https://github.com/seifreed/pyholded
6
+ Project-URL: Documentation, https://developers.holded.com
7
+ Project-URL: Issues, https://github.com/seifreed/pyholded/issues
8
+ Author-email: Marc Rivero López <mriverolopez@gmail.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: accounting,api,cli,client,crm,holded,invoicing
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Classifier: Topic :: Office/Business :: Financial :: Accounting
17
+ Classifier: Typing :: Typed
18
+ Requires-Python: <3.15,>=3.14
19
+ Requires-Dist: click>=8.1
20
+ Requires-Dist: httpx>=0.28
21
+ Requires-Dist: rich>=13.9
22
+ Requires-Dist: toons>=0.7
23
+ Provides-Extra: dev
24
+ Requires-Dist: bandit; extra == 'dev'
25
+ Requires-Dist: black; extra == 'dev'
26
+ Requires-Dist: coverage; extra == 'dev'
27
+ Requires-Dist: cryptography; extra == 'dev'
28
+ Requires-Dist: cyclonedx-bom; extra == 'dev'
29
+ Requires-Dist: mypy; extra == 'dev'
30
+ Requires-Dist: pip-audit; extra == 'dev'
31
+ Requires-Dist: pytest; extra == 'dev'
32
+ Requires-Dist: pytest-cov; extra == 'dev'
33
+ Requires-Dist: pytest-httpx; extra == 'dev'
34
+ Requires-Dist: ruff; extra == 'dev'
35
+ Requires-Dist: vulture; extra == 'dev'
36
+ Description-Content-Type: text/markdown
37
+
38
+ <p align="center">
39
+ <img src="https://img.shields.io/badge/pyholded-Holded%20API%20v2%20client-blue?style=for-the-badge" alt="pyholded">
40
+ </p>
41
+
42
+ <h1 align="center">pyholded</h1>
43
+
44
+ <p align="center">
45
+ <strong>Modular Python client and CLI for the complete Holded API v2</strong>
46
+ </p>
47
+
48
+ <p align="center">
49
+ <a href="https://pypi.org/project/pyholded/"><img src="https://img.shields.io/pypi/v/pyholded?style=flat-square&logo=pypi&logoColor=white" alt="PyPI Version"></a>
50
+ <a href="https://pypi.org/project/pyholded/"><img src="https://img.shields.io/pypi/pyversions/pyholded?style=flat-square&logo=python&logoColor=white" alt="Python Versions"></a>
51
+ <a href="https://github.com/seifreed/pyholded/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="License"></a>
52
+ <a href="https://github.com/seifreed/pyholded/actions"><img src="https://img.shields.io/github/actions/workflow/status/seifreed/pyholded/ci.yml?style=flat-square&logo=github&label=CI" alt="CI Status"></a>
53
+ <a href="https://github.com/seifreed/pyholded"><img src="https://img.shields.io/badge/types-py.typed-brightgreen?style=flat-square" alt="Typed"></a>
54
+ <a href="https://github.com/seifreed/pyholded/blob/main/sbom.cdx.json"><img src="https://img.shields.io/badge/SBOM-CycloneDX%209.3%2FA%20signed-brightgreen?style=flat-square" alt="SBOM"></a>
55
+ </p>
56
+
57
+ <p align="center">
58
+ <a href="https://github.com/seifreed/pyholded/stargazers"><img src="https://img.shields.io/github/stars/seifreed/pyholded?style=flat-square" alt="GitHub Stars"></a>
59
+ <a href="https://github.com/seifreed/pyholded/issues"><img src="https://img.shields.io/github/issues/seifreed/pyholded?style=flat-square" alt="GitHub Issues"></a>
60
+ <a href="https://buymeacoffee.com/seifreed"><img src="https://img.shields.io/badge/Buy%20Me%20a%20Coffee-support-yellow?style=flat-square&logo=buy-me-a-coffee&logoColor=white" alt="Buy Me a Coffee"></a>
61
+ </p>
62
+
63
+ ---
64
+
65
+ ## Overview
66
+
67
+ **pyholded** is a Python toolkit to talk to the [Holded](https://developers.holded.com)
68
+ business-management API (v2). Every endpoint across all modules — sales/purchase
69
+ documents, contacts, products, CRM, projects and team — is described in a single
70
+ declarative registry from which both the typed client and the CLI are generated.
71
+ Results print as rich tables, JSON, or TOON.
72
+
73
+ ### Key Features
74
+
75
+ | Feature | Description |
76
+ |---------|-------------|
77
+ | **Registry-driven** | Every endpoint is data; client and CLI share one source of truth |
78
+ | **Full v2 surface** | Documents, contacts, products, CRM, projects, team — plus a `raw` escape hatch |
79
+ | **Bearer auth** | Token from `--token`, environment variable, or TOML config file |
80
+ | **Cursor pagination** | `--all` (CLI) / `paginate=True` (library) merges every page |
81
+ | **Three outputs** | Rich tables, JSON, and TOON (token-efficient for LLMs) |
82
+ | **CLI + Library** | Use as a command-line tool or a typed Python package (`py.typed`) |
83
+ | **Strict quality gate** | ruff, black, mypy (strict), bandit, vulture, pip-audit — zero suppressions |
84
+
85
+ ### Supported Outputs
86
+
87
+ ```text
88
+ Records rich tables, JSON, TOON
89
+ Pagination cursor-based ({items, cursor, has_more}); --all merges pages
90
+ Auth Authorization: Bearer <PAT> via env var or config file
91
+ Binary PDF download for any document type (invoices, credit-notes, ...)
92
+ ```
93
+
94
+ ---
95
+
96
+ ## Installation
97
+
98
+ ### From PyPI (Recommended)
99
+
100
+ ```bash
101
+ pip install pyholded
102
+ ```
103
+
104
+ ### From Source
105
+
106
+ ```bash
107
+ git clone https://github.com/seifreed/pyholded.git
108
+ cd pyholded
109
+ python3 -m venv venv
110
+ source venv/bin/activate # Windows: venv\Scripts\activate
111
+ pip install -e .
112
+ ```
113
+
114
+ ### Optional Extras
115
+
116
+ ```bash
117
+ pip install "pyholded[dev]" # ruff, black, mypy, bandit, vulture, pip-audit, pytest
118
+ ```
119
+
120
+ ---
121
+
122
+ ## Authentication
123
+
124
+ Holded API v2 uses a Personal Access Token (`pat_…`) sent as `Authorization: Bearer`.
125
+ Generate one in Holded: **Settings → Developers → Credentials → Add API Token**.
126
+
127
+ The token is resolved in order of precedence:
128
+
129
+ 1. An explicit value (`--token`, or `HoldedClient(token=...)`).
130
+ 2. The `HOLDED_TOKEN` environment variable (`HOLDED_API_KEY` also accepted).
131
+ 3. A TOML config file (`--config`, `HOLDED_CONFIG`, or `~/.config/pyholded/config.toml`).
132
+
133
+ ```toml
134
+ # ~/.config/pyholded/config.toml
135
+ [holded]
136
+ token = "pat_xxx_yyy"
137
+ # base_url = "https://api.holded.com/api/v2/" # optional override
138
+ ```
139
+
140
+ ### Multiple accounts
141
+
142
+ Configure several Holded accounts and query one or all of them.
143
+
144
+ Environment variables — `HOLDED_TOKEN` is the `default` account; `HOLDED_TOKEN_<NAME>`
145
+ adds a named account:
146
+
147
+ ```bash
148
+ export HOLDED_TOKEN="pat_default"
149
+ export HOLDED_TOKEN_ACME="pat_acme"
150
+ export HOLDED_TOKEN_PERSONAL="pat_personal"
151
+ ```
152
+
153
+ Config file — per-account tables (env overrides the file for the same name):
154
+
155
+ ```toml
156
+ # ~/.config/pyholded/config.toml
157
+ default_account = "acme" # optional; picks the account when none is given
158
+
159
+ [accounts.acme]
160
+ token = "pat_acme"
161
+ # base_url = "..." # optional, per account
162
+
163
+ [accounts.personal]
164
+ token = "pat_personal"
165
+ ```
166
+
167
+ Select with `--account <name>` (CLI) / `HoldedClient(account="acme")` (library), or fan
168
+ out to every account with `--all-accounts` / `MultiClient`. When several accounts are
169
+ configured and none is selected, set `default_account` or pass one explicitly.
170
+
171
+ ---
172
+
173
+ ## Quick Start
174
+
175
+ ```bash
176
+ # List every resource and its operations
177
+ holded resources
178
+
179
+ # List records (pretty table by default)
180
+ holded contacts list --limit 5
181
+
182
+ # TOON output, ideal for LLM contexts
183
+ holded taxes list -o toon
184
+ ```
185
+
186
+ ---
187
+
188
+ ## Usage
189
+
190
+ ### Command Line Interface
191
+
192
+ ```bash
193
+ # List a page, or follow the cursor and fetch all pages
194
+ holded invoices list --limit 50
195
+ holded expenses_accounts list --all -o json
196
+
197
+ # Get one record, in JSON
198
+ holded contacts get --id 0123456789abcdef01234567 -o json
199
+
200
+ # Download a document PDF (binary)
201
+ holded invoices get-pdf --id 89abcdef0123456789abcdef > invoice.pdf
202
+
203
+ # Create from inline JSON, a file, or key=value fields
204
+ holded contacts create --data '{"name": "ACME SL"}'
205
+ holded contacts create --data @contact.json
206
+ holded contacts create --field name=ACME --field code=B12345678
207
+
208
+ # Multiple accounts
209
+ holded accounts # list configured accounts
210
+ holded --account acme contacts list # one named account
211
+ holded --all-accounts contacts list -o json # fan out -> {account: result}
212
+
213
+ # Call any endpoint directly
214
+ holded raw GET taxes -o toon
215
+ ```
216
+
217
+ ### Main Commands
218
+
219
+ | Command | Description |
220
+ |--------|-------------|
221
+ | `holded resources` | List all resources and their operations |
222
+ | `holded accounts` | List configured accounts (names + base URLs; tokens never shown) |
223
+ | `holded <resource> list` | List records (cursor-paginated; `--all` fetches every page) |
224
+ | `holded <resource> get --id <id>` | Get a single record |
225
+ | `holded <resource> create --data <json>` | Create a record |
226
+ | `holded <resource> update --id <id> --data <json>` | Update a record |
227
+ | `holded <resource> delete --id <id>` | Delete a record |
228
+ | `holded invoices get-pdf --id <id>` | Download a document PDF (also `send`) |
229
+ | `holded raw <METHOD> <PATH>` | Call an arbitrary endpoint |
230
+
231
+ ### Options
232
+
233
+ | Option | Description |
234
+ |--------|-------------|
235
+ | `-o, --output {rich,json,toon}` | Output format (global default or per-command override) |
236
+ | `-a, --account <name>` | Use a named account (env/config) |
237
+ | `--all-accounts` | Run the command on every configured account |
238
+ | `--all` | Follow the cursor and fetch every page (GET) |
239
+ | `--limit`, `--cursor` | Manual pagination controls |
240
+ | `--data <json\|@file>`, `--field k=v` | Request body for create/update |
241
+ | `--token`, `--config`, `--base-url`, `--timeout` | Connection options |
242
+
243
+ ### Resources
244
+
245
+ | Group | Resources |
246
+ |-------|-----------|
247
+ | **Documents** (CRUD + `get-pdf`, `send`) | `invoices`, `credit_notes`, `sales_orders`, `estimates`, `proformas`, `waybills`, `sales_receipts`, `purchases`, `purchase_orders` |
248
+ | **Masters** (CRUD) | `contacts`, `contact_groups`, `products`, `services`, `warehouses`, `payments`, `sales_channels`, `expenses_accounts`, `taxes`, `payment_methods` |
249
+ | **CRM** | `funnels`, `leads` (+ `create-note`, `create-task`), `events`, `bookings`, `booking_locations` |
250
+ | **Projects / Team** | `projects`, `tasks`, `employees` |
251
+
252
+ ---
253
+
254
+ ## Python Library
255
+
256
+ ### Basic Usage
257
+
258
+ ```python
259
+ from pyholded import HoldedClient
260
+
261
+ with HoldedClient() as client: # token from env or config file
262
+ page = client.invoices.list(params={"limit": 50})
263
+ everyone = client.contacts.list(paginate=True) # all pages, merged items list
264
+ contact = client.contacts.get(id="0123456789abcdef01234567")
265
+ pdf = client.invoices.getPdf(id="89abcdef0123456789abcdef") # raw bytes
266
+
267
+ new = client.contacts.create(data={"name": "ACME SL", "code": "B12345678"})
268
+
269
+ # Any endpoint, even one not modelled, is reachable directly:
270
+ raw = client.request("GET", "taxes", params={"limit": 5})
271
+ ```
272
+
273
+ Resources are attributes; operations are methods. Path parameters (`id`) are keyword
274
+ arguments, query parameters go in `params=`, and the request body in `data=`.
275
+
276
+ ### Multiple accounts
277
+
278
+ ```python
279
+ from pyholded import HoldedClient, MultiClient
280
+
281
+ # one named account
282
+ with HoldedClient(account="acme") as client:
283
+ invoices = client.invoices.list()
284
+
285
+ # every configured account at once -> {account: result}
286
+ with MultiClient.from_accounts() as multi: # or from_accounts(["acme", "personal"])
287
+ per_account = multi.contacts.list(params={"limit": 5})
288
+ # {"acme": {"items": [...]}, "personal": {"items": [...]}}
289
+ ```
290
+
291
+ A failure on one account is captured as `{"error": "..."}` for that account, so the
292
+ others still return their data.
293
+
294
+ ### Output Helpers
295
+
296
+ ```python
297
+ from pyholded import OutputFormat, render, to_json, to_toon
298
+
299
+ render(page, OutputFormat.TOON) # print in TOON
300
+ print(to_json(page)) # canonical JSON string
301
+ print(to_toon(page)) # TOON string
302
+ ```
303
+
304
+ ---
305
+
306
+ ## Supply Chain / SBOM
307
+
308
+ A CycloneDX 1.6 SBOM ([`sbom.cdx.json`](sbom.cdx.json)) is generated from real data —
309
+ package SHA-256 hashes come from a `uv`-compiled hashed lockfile
310
+ ([`requirements.lock`](requirements.lock)), licenses and suppliers from installed
311
+ package metadata, a full dependency graph, and an **ECDSA P-256 (ES256) signature**
312
+ embedded as a CycloneDX JSF block (pure-Python, offline, no external tools). CI
313
+ regenerates, scores and verifies it on every push.
314
+
315
+ ```bash
316
+ make sbom # generate + sign sbom.cdx.json
317
+ make sbom-score # generate + score with sbomqs (fails below 9.0)
318
+ make sbom-verify # verify the embedded signature
319
+ make lock # refresh the hashed lockfile (uv)
320
+ ```
321
+
322
+ Current [sbomqs](https://github.com/interlynk-io/sbomqs) score: **9.3 / 10 (Grade A)** —
323
+ Identification, Provenance, Integrity, Licensing, Vulnerability and Structural all at A.
324
+ Completeness (D) is capped by sbomqs's CycloneDX dependency-graph detection, not by
325
+ missing data (the `dependencies` and `compositions` are present and valid). A perfect
326
+ 10/A is not attainable for a PyPI project (it also requires per-component CPEs, which
327
+ Python packages do not have).
328
+
329
+ The signing private key is never committed; the public key travels inside the SBOM
330
+ (`signature.publicKey` JWK), so `scripts/verify_sbom.py` verifies it with no extra files.
331
+
332
+ ## Requirements
333
+
334
+ - Python 3.14+
335
+ - See [pyproject.toml](pyproject.toml) for dependencies and extras
336
+
337
+ ---
338
+
339
+ ## Contributing
340
+
341
+ Contributions are welcome.
342
+
343
+ 1. Fork the repository
344
+ 2. Create your feature branch (`git checkout -b feature/amazing-feature`)
345
+ 3. Commit your changes (`git commit -m 'Add amazing feature'`)
346
+ 4. Push to the branch (`git push origin feature/amazing-feature`)
347
+ 5. Open a Pull Request
348
+
349
+ All changes must keep the full quality gate green (`ruff`, `black`, `mypy --strict`,
350
+ `bandit`, `vulture`, `pip-audit`, `pytest`) with zero in-line suppressions.
351
+
352
+ ---
353
+
354
+ ## Support the Project
355
+
356
+ If this project is useful in your workflows, you can support development:
357
+
358
+ <a href="https://buymeacoffee.com/seifreed" target="_blank">
359
+ <img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" height="50">
360
+ </a>
361
+
362
+ ---
363
+
364
+ ## License
365
+
366
+ This project is licensed under the MIT license. See [LICENSE](LICENSE).
367
+
368
+ **Attribution**
369
+ - Author: **Marc Rivero López** | [@seifreed](https://github.com/seifreed)
370
+ - Repository: [github.com/seifreed/pyholded](https://github.com/seifreed/pyholded)
371
+
372
+ ---
373
+
374
+ <p align="center">
375
+ <sub>Built for practical Holded automation and business-data workflows</sub>
376
+ </p>