billos 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.
- billos-0.1.0/LICENSE +21 -0
- billos-0.1.0/PKG-INFO +61 -0
- billos-0.1.0/README.md +42 -0
- billos-0.1.0/billos/__init__.py +4 -0
- billos-0.1.0/billos/_operations.py +62 -0
- billos-0.1.0/billos/client.py +320 -0
- billos-0.1.0/billos.egg-info/PKG-INFO +61 -0
- billos-0.1.0/billos.egg-info/SOURCES.txt +11 -0
- billos-0.1.0/billos.egg-info/dependency_links.txt +1 -0
- billos-0.1.0/billos.egg-info/top_level.txt +1 -0
- billos-0.1.0/pyproject.toml +28 -0
- billos-0.1.0/setup.cfg +4 -0
- billos-0.1.0/tests/test_client.py +133 -0
billos-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 BillOS (Pingo Creative)
|
|
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.
|
billos-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: billos
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: BillOS for Python: Israeli tax documents, expenses, webhooks and the regulatory outputs, over one small client.
|
|
5
|
+
Author: BillOS
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Documentation, https://docs.billos.co.il
|
|
8
|
+
Project-URL: Homepage, https://billos.co.il
|
|
9
|
+
Project-URL: Source, https://github.com/baraviz/pest-os/tree/main/packages/billos-python
|
|
10
|
+
Keywords: billos,invoice,bookkeeping,israel,tax,api
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Topic :: Office/Business :: Financial :: Accounting
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# billos (Python)
|
|
21
|
+
|
|
22
|
+
BillOS from Python: legally valid Israeli tax documents (חשבונית מס, קבלה, זיכוי), the expense ledger, webhooks and the regulatory outputs, over one small client with no dependencies beyond the standard library (Python 3.9+).
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install billos
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from billos import BillOS, BillOSError
|
|
30
|
+
|
|
31
|
+
billos = BillOS(api_key=os.environ["BILLOS_API_KEY"])
|
|
32
|
+
bid = billos.businesses.list()["businesses"][0]["id"]
|
|
33
|
+
|
|
34
|
+
created = billos.documents.create(bid, {
|
|
35
|
+
"docType": 320,
|
|
36
|
+
"priceMode": "gross",
|
|
37
|
+
"party": {"name": "דנה לוי", "phone": "0521111111"},
|
|
38
|
+
"lines": [{"description": "איפור כלה", "quantity": 1, "unitPriceExVat": 120000}],
|
|
39
|
+
"payments": [{"method": "card", "amount": 120000}],
|
|
40
|
+
}, idempotency_key="order-8812")
|
|
41
|
+
|
|
42
|
+
issued = billos.documents.issue(bid, created["document"]["id"])
|
|
43
|
+
print(issued["document"]["docNumber"])
|
|
44
|
+
|
|
45
|
+
pdf = billos.documents.print(bid, created["document"]["id"]) # Binary: .bytes, .content_type, .variant
|
|
46
|
+
link = billos.documents.share(bid, created["document"]["id"]) # a public URL for the customer
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Money is **integer agorot** everywhere, exactly like the API (₪354.00 is `35400`); a foreign-currency document (`"currency": "USD"`) is in that currency's cents. Nothing in the client converts.
|
|
50
|
+
|
|
51
|
+
## What you get
|
|
52
|
+
|
|
53
|
+
- Every operation in the spec as `namespace.method` (snake_case): `businesses`, `parties`, `documents`, `recurring`, `expenses`, `exports`, `files`, `webhooks`, `events`, `keys`, `usage`, `sandbox`. `OPERATIONS` (method and path per operation) is generated from the OpenAPI spec, and a test checks every operation has a method, so the client cannot lag behind the API.
|
|
54
|
+
- The conventions handled: `X-Api-Key`, the `{ok, ...}` envelope returned as a dict (plus `request_id`), `Idempotency-Key` from `idempotency_key=`, `Retry-After` honoured.
|
|
55
|
+
- Retries on 429 / 502 / 503 / 504 and network errors, up to `max_retries` (default 2), for GET/DELETE and for POSTs carrying an idempotency key; never for a POST without one.
|
|
56
|
+
- `BillOSError` with `status`, `reason`, `request_id`, `body`, `retryable`. Branch on `reason`.
|
|
57
|
+
- Binary answers (`documents.preview`, `documents.print`, `files.get`, a PDF report) come back as `Binary(bytes, content_type, variant, request_id)`.
|
|
58
|
+
- `verify_webhook_signature(secret, header, raw_body)` for the `X-BillOS-Signature` header.
|
|
59
|
+
- A `bk_test_` key answers from the sandbox with no change on your side (`billos.sandbox.seed()` fills it with demo data).
|
|
60
|
+
|
|
61
|
+
Documentation and the OpenAPI spec: https://docs.billos.co.il. MIT.
|
billos-0.1.0/README.md
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# billos (Python)
|
|
2
|
+
|
|
3
|
+
BillOS from Python: legally valid Israeli tax documents (חשבונית מס, קבלה, זיכוי), the expense ledger, webhooks and the regulatory outputs, over one small client with no dependencies beyond the standard library (Python 3.9+).
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install billos
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
from billos import BillOS, BillOSError
|
|
11
|
+
|
|
12
|
+
billos = BillOS(api_key=os.environ["BILLOS_API_KEY"])
|
|
13
|
+
bid = billos.businesses.list()["businesses"][0]["id"]
|
|
14
|
+
|
|
15
|
+
created = billos.documents.create(bid, {
|
|
16
|
+
"docType": 320,
|
|
17
|
+
"priceMode": "gross",
|
|
18
|
+
"party": {"name": "דנה לוי", "phone": "0521111111"},
|
|
19
|
+
"lines": [{"description": "איפור כלה", "quantity": 1, "unitPriceExVat": 120000}],
|
|
20
|
+
"payments": [{"method": "card", "amount": 120000}],
|
|
21
|
+
}, idempotency_key="order-8812")
|
|
22
|
+
|
|
23
|
+
issued = billos.documents.issue(bid, created["document"]["id"])
|
|
24
|
+
print(issued["document"]["docNumber"])
|
|
25
|
+
|
|
26
|
+
pdf = billos.documents.print(bid, created["document"]["id"]) # Binary: .bytes, .content_type, .variant
|
|
27
|
+
link = billos.documents.share(bid, created["document"]["id"]) # a public URL for the customer
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Money is **integer agorot** everywhere, exactly like the API (₪354.00 is `35400`); a foreign-currency document (`"currency": "USD"`) is in that currency's cents. Nothing in the client converts.
|
|
31
|
+
|
|
32
|
+
## What you get
|
|
33
|
+
|
|
34
|
+
- Every operation in the spec as `namespace.method` (snake_case): `businesses`, `parties`, `documents`, `recurring`, `expenses`, `exports`, `files`, `webhooks`, `events`, `keys`, `usage`, `sandbox`. `OPERATIONS` (method and path per operation) is generated from the OpenAPI spec, and a test checks every operation has a method, so the client cannot lag behind the API.
|
|
35
|
+
- The conventions handled: `X-Api-Key`, the `{ok, ...}` envelope returned as a dict (plus `request_id`), `Idempotency-Key` from `idempotency_key=`, `Retry-After` honoured.
|
|
36
|
+
- Retries on 429 / 502 / 503 / 504 and network errors, up to `max_retries` (default 2), for GET/DELETE and for POSTs carrying an idempotency key; never for a POST without one.
|
|
37
|
+
- `BillOSError` with `status`, `reason`, `request_id`, `body`, `retryable`. Branch on `reason`.
|
|
38
|
+
- Binary answers (`documents.preview`, `documents.print`, `files.get`, a PDF report) come back as `Binary(bytes, content_type, variant, request_id)`.
|
|
39
|
+
- `verify_webhook_signature(secret, header, raw_body)` for the `X-BillOS-Signature` header.
|
|
40
|
+
- A `bk_test_` key answers from the sandbox with no change on your side (`billos.sandbox.seed()` fills it with demo data).
|
|
41
|
+
|
|
42
|
+
Documentation and the OpenAPI spec: https://docs.billos.co.il. MIT.
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
"""BillOS for Python: a thin, dependency-free client over the ledger API (https://docs.billos.co.il)."""
|
|
2
|
+
from .client import BillOS, BillOSError, OPERATIONS, SURFACE, DEFAULT_BASE_URL, VERSION, verify_webhook_signature
|
|
3
|
+
|
|
4
|
+
__all__ = ["BillOS", "BillOSError", "OPERATIONS", "SURFACE", "DEFAULT_BASE_URL", "VERSION", "verify_webhook_signature"]
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# GENERATED from the BillOS OpenAPI spec by packages/billos-sdk/scripts/generate.mjs. Do not edit.
|
|
2
|
+
# operationId -> (method, path template). The client's namespaces name these; a test in the Node
|
|
3
|
+
# SDK regenerates this file and fails when the commit is stale.
|
|
4
|
+
OPERATIONS = {
|
|
5
|
+
"createBusiness": ("POST", "/businesses"),
|
|
6
|
+
"listBusinesses": ("GET", "/businesses"),
|
|
7
|
+
"getBusiness": ("GET", "/businesses/{bid}"),
|
|
8
|
+
"updateBusiness": ("PATCH", "/businesses/{bid}"),
|
|
9
|
+
"deactivateBusiness": ("POST", "/businesses/{bid}/deactivate"),
|
|
10
|
+
"reactivateBusiness": ("POST", "/businesses/{bid}/reactivate"),
|
|
11
|
+
"createParty": ("POST", "/businesses/{bid}/parties"),
|
|
12
|
+
"listParties": ("GET", "/businesses/{bid}/parties"),
|
|
13
|
+
"updateParty": ("PATCH", "/businesses/{bid}/parties/{id}"),
|
|
14
|
+
"recordPartyConsent": ("POST", "/businesses/{bid}/parties/{id}/consent"),
|
|
15
|
+
"getFxRate": ("GET", "/businesses/{bid}/fx-rate"),
|
|
16
|
+
"createDocument": ("POST", "/businesses/{bid}/documents"),
|
|
17
|
+
"listDocuments": ("GET", "/businesses/{bid}/documents"),
|
|
18
|
+
"getDocument": ("GET", "/businesses/{bid}/documents/{id}"),
|
|
19
|
+
"updateDocument": ("PATCH", "/businesses/{bid}/documents/{id}"),
|
|
20
|
+
"deleteDocument": ("DELETE", "/businesses/{bid}/documents/{id}"),
|
|
21
|
+
"issueDocument": ("POST", "/businesses/{bid}/documents/{id}/issue"),
|
|
22
|
+
"issueCreditNote": ("POST", "/businesses/{bid}/documents/{id}/credit-note"),
|
|
23
|
+
"previewDocument": ("POST", "/businesses/{bid}/documents/{id}/preview"),
|
|
24
|
+
"printDocument": ("POST", "/businesses/{bid}/documents/{id}/prints"),
|
|
25
|
+
"createRecurring": ("POST", "/businesses/{bid}/recurring"),
|
|
26
|
+
"listRecurring": ("GET", "/businesses/{bid}/recurring"),
|
|
27
|
+
"getRecurring": ("GET", "/businesses/{bid}/recurring/{id}"),
|
|
28
|
+
"updateRecurring": ("PATCH", "/businesses/{bid}/recurring/{id}"),
|
|
29
|
+
"deleteRecurring": ("DELETE", "/businesses/{bid}/recurring/{id}"),
|
|
30
|
+
"runRecurring": ("POST", "/businesses/{bid}/recurring/{id}/run"),
|
|
31
|
+
"createExpense": ("POST", "/businesses/{bid}/expenses"),
|
|
32
|
+
"listExpenses": ("GET", "/businesses/{bid}/expenses"),
|
|
33
|
+
"shareDocument": ("POST", "/businesses/{bid}/documents/{id}/share"),
|
|
34
|
+
"emailDocument": ("POST", "/businesses/{bid}/documents/{id}/email"),
|
|
35
|
+
"findOrCreateParty": ("POST", "/businesses/{bid}/parties/find-or-create"),
|
|
36
|
+
"recordExpenseDirect": ("POST", "/businesses/{bid}/expenses/record"),
|
|
37
|
+
"deleteExpenseDraft": ("DELETE", "/businesses/{bid}/expenses/{id}"),
|
|
38
|
+
"getExpense": ("GET", "/businesses/{bid}/expenses/{id}"),
|
|
39
|
+
"updateExpense": ("PATCH", "/businesses/{bid}/expenses/{id}"),
|
|
40
|
+
"recordExpense": ("POST", "/businesses/{bid}/expenses/{id}/record"),
|
|
41
|
+
"correctExpense": ("POST", "/businesses/{bid}/expenses/{id}/correction"),
|
|
42
|
+
"ocrExpense": ("POST", "/businesses/{bid}/expenses/ocr"),
|
|
43
|
+
"exportOpenfrmt": ("POST", "/businesses/{bid}/exports/openfrmt"),
|
|
44
|
+
"continuityReport": ("GET", "/businesses/{bid}/reports/continuity"),
|
|
45
|
+
"managementReport": ("GET", "/businesses/{bid}/reports/{kind}"),
|
|
46
|
+
"createBackup": ("POST", "/businesses/{bid}/backup"),
|
|
47
|
+
"fileThumbnails": ("POST", "/businesses/{bid}/files/thumbs"),
|
|
48
|
+
"getFile": ("GET", "/businesses/{bid}/files/{id}"),
|
|
49
|
+
"listKeys": ("GET", "/keys"),
|
|
50
|
+
"updateKey": ("PATCH", "/keys/{id}"),
|
|
51
|
+
"getUsage": ("GET", "/usage"),
|
|
52
|
+
"createWebhook": ("POST", "/webhooks"),
|
|
53
|
+
"listWebhooks": ("GET", "/webhooks"),
|
|
54
|
+
"updateWebhook": ("PATCH", "/webhooks/{id}"),
|
|
55
|
+
"deleteWebhook": ("DELETE", "/webhooks/{id}"),
|
|
56
|
+
"listWebhookDeliveries": ("GET", "/webhooks/{id}/deliveries"),
|
|
57
|
+
"redeliverWebhook": ("POST", "/webhooks/{id}/deliveries/{did}/redeliver"),
|
|
58
|
+
"seedSandbox": ("POST", "/sandbox/seed"),
|
|
59
|
+
"listEvents": ("GET", "/events"),
|
|
60
|
+
"getEvent": ("GET", "/events/{id}"),
|
|
61
|
+
"testWebhook": ("POST", "/webhooks/{id}/test"),
|
|
62
|
+
}
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
"""billos: BillOS from Python.
|
|
2
|
+
|
|
3
|
+
A thin wrapper over the ledger API with no dependencies beyond the standard library: one
|
|
4
|
+
``request()`` that speaks the API's conventions (``X-Api-Key``, the ``{ok, ...}`` envelope,
|
|
5
|
+
the ``{error, reason}`` failure shape, ``Idempotency-Key``, ``X-Request-Id``,
|
|
6
|
+
``Retry-After``) and resource namespaces that name every operation in the OpenAPI spec.
|
|
7
|
+
|
|
8
|
+
Money is integer agorot everywhere, exactly like the API. Amounts on a foreign-currency
|
|
9
|
+
document are that currency's minor units. Nothing here converts.
|
|
10
|
+
|
|
11
|
+
``_operations.py`` (method + path per operationId) is GENERATED from the OpenAPI spec by the
|
|
12
|
+
Node SDK's generator and kept fresh by its test; ``SURFACE`` names where each operation lives
|
|
13
|
+
on this client, and a test walks it, so a new endpoint without a Python method fails the suite.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import hashlib
|
|
18
|
+
import hmac
|
|
19
|
+
import json
|
|
20
|
+
import re
|
|
21
|
+
import time
|
|
22
|
+
import urllib.error
|
|
23
|
+
import urllib.parse
|
|
24
|
+
import urllib.request
|
|
25
|
+
from typing import Any, Callable, Dict, Optional, Tuple
|
|
26
|
+
|
|
27
|
+
from ._operations import OPERATIONS # generated from the OpenAPI spec (see packages/billos-sdk/scripts/generate.mjs)
|
|
28
|
+
|
|
29
|
+
DEFAULT_BASE_URL = "https://api.billos.co.il/v1"
|
|
30
|
+
VERSION = "0.1.0"
|
|
31
|
+
|
|
32
|
+
# operationId -> "namespace.method" on the client (what the drift test walks).
|
|
33
|
+
SURFACE: Dict[str, str] = {
|
|
34
|
+
"createBusiness": "businesses.create", "listBusinesses": "businesses.list", "getBusiness": "businesses.get",
|
|
35
|
+
"updateBusiness": "businesses.update", "deactivateBusiness": "businesses.deactivate", "reactivateBusiness": "businesses.reactivate",
|
|
36
|
+
"listKeys": "keys.list", "updateKey": "keys.update", "getUsage": "usage.get",
|
|
37
|
+
"createParty": "parties.create", "listParties": "parties.list", "findOrCreateParty": "parties.find_or_create",
|
|
38
|
+
"updateParty": "parties.update", "recordPartyConsent": "parties.record_consent",
|
|
39
|
+
"getFxRate": "documents.fx_rate", "createDocument": "documents.create", "listDocuments": "documents.list",
|
|
40
|
+
"getDocument": "documents.get", "updateDocument": "documents.update", "deleteDocument": "documents.delete",
|
|
41
|
+
"issueDocument": "documents.issue", "issueCreditNote": "documents.credit_note", "previewDocument": "documents.preview",
|
|
42
|
+
"printDocument": "documents.print", "shareDocument": "documents.share", "emailDocument": "documents.email",
|
|
43
|
+
"createRecurring": "recurring.create", "listRecurring": "recurring.list", "getRecurring": "recurring.get",
|
|
44
|
+
"updateRecurring": "recurring.update", "deleteRecurring": "recurring.delete", "runRecurring": "recurring.run",
|
|
45
|
+
"createExpense": "expenses.create", "recordExpenseDirect": "expenses.record", "listExpenses": "expenses.list",
|
|
46
|
+
"getExpense": "expenses.get", "updateExpense": "expenses.update", "deleteExpenseDraft": "expenses.delete",
|
|
47
|
+
"recordExpense": "expenses.record_draft", "correctExpense": "expenses.correct", "ocrExpense": "expenses.ocr",
|
|
48
|
+
"exportOpenfrmt": "exports.openfrmt", "continuityReport": "exports.continuity", "managementReport": "exports.report",
|
|
49
|
+
"createBackup": "exports.backup", "fileThumbnails": "files.thumbnails", "getFile": "files.get",
|
|
50
|
+
"createWebhook": "webhooks.create", "listWebhooks": "webhooks.list", "updateWebhook": "webhooks.update",
|
|
51
|
+
"deleteWebhook": "webhooks.delete", "listWebhookDeliveries": "webhooks.deliveries", "redeliverWebhook": "webhooks.redeliver",
|
|
52
|
+
"testWebhook": "webhooks.test", "listEvents": "events.list", "getEvent": "events.get", "seedSandbox": "sandbox.seed",
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
TRANSIENT = {429, 502, 503, 504}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class BillOSError(Exception):
|
|
59
|
+
"""Every failure. ``reason`` is the API's stable code; ``request_id`` is what to quote to support."""
|
|
60
|
+
|
|
61
|
+
def __init__(self, status: int, reason: Optional[str], message: str, request_id: Optional[str] = None, body: Any = None):
|
|
62
|
+
super().__init__(message)
|
|
63
|
+
self.status = status
|
|
64
|
+
self.reason = reason or ("internal" if status >= 500 else "error")
|
|
65
|
+
self.request_id = request_id
|
|
66
|
+
self.body = body
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def retryable(self) -> bool:
|
|
70
|
+
"""Transient: a 429/502/503/504 or a network failure. Re-send GET/DELETE freely; re-send a
|
|
71
|
+
POST only with the same idempotency key, or you may create the thing twice."""
|
|
72
|
+
return self.status in TRANSIENT or self.status == 0
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class Binary:
|
|
76
|
+
"""A binary answer: PDF bytes, a stored file."""
|
|
77
|
+
|
|
78
|
+
def __init__(self, data: bytes, content_type: str, variant: Optional[str], request_id: Optional[str]):
|
|
79
|
+
self.bytes = data
|
|
80
|
+
self.content_type = content_type
|
|
81
|
+
self.variant = variant
|
|
82
|
+
self.request_id = request_id
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# The transport seam: (method, url, headers, body) -> (status, headers dict, body bytes).
|
|
86
|
+
Transport = Callable[[str, str, Dict[str, str], Optional[bytes], float], Tuple[int, Dict[str, str], bytes]]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _urllib_transport(method: str, url: str, headers: Dict[str, str], body: Optional[bytes], timeout: float):
|
|
90
|
+
req = urllib.request.Request(url, data=body, method=method, headers=headers)
|
|
91
|
+
try:
|
|
92
|
+
with urllib.request.urlopen(req, timeout=timeout) as res: # noqa: S310 (https to our own API)
|
|
93
|
+
return res.status, {k.lower(): v for k, v in res.headers.items()}, res.read()
|
|
94
|
+
except urllib.error.HTTPError as e:
|
|
95
|
+
return e.code, {k.lower(): v for k, v in e.headers.items()}, e.read()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _fill(template: str, params: Dict[str, Any]) -> str:
|
|
99
|
+
def one(m: "re.Match[str]") -> str:
|
|
100
|
+
v = params.get(m.group(1))
|
|
101
|
+
if v is None or v == "":
|
|
102
|
+
raise TypeError(f'billos: missing path parameter "{m.group(1)}" for {template}')
|
|
103
|
+
return urllib.parse.quote(str(v), safe="")
|
|
104
|
+
return re.sub(r"\{(\w+)\}", one, template)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _query(query: Optional[Dict[str, Any]]) -> str:
|
|
108
|
+
if not query:
|
|
109
|
+
return ""
|
|
110
|
+
pairs = [(k, str(v).lower() if isinstance(v, bool) else str(v)) for k, v in query.items() if v is not None and v != ""]
|
|
111
|
+
return ("?" + urllib.parse.urlencode(pairs)) if pairs else ""
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class _NS:
|
|
115
|
+
def __init__(self, client: "BillOS"):
|
|
116
|
+
self._c = client
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class BillOS:
|
|
120
|
+
"""``BillOS(api_key=...)``. Every method returns the API's JSON envelope as a dict (with
|
|
121
|
+
``request_id`` added, non-standard but harmless) or a ``Binary`` for PDFs and files."""
|
|
122
|
+
|
|
123
|
+
def __init__(self, api_key: str, base_url: str = DEFAULT_BASE_URL, timeout: float = 30.0, max_retries: int = 2,
|
|
124
|
+
headers: Optional[Dict[str, str]] = None, transport: Optional[Transport] = None, sleep: Callable[[float], None] = time.sleep):
|
|
125
|
+
if not api_key or not isinstance(api_key, str):
|
|
126
|
+
raise TypeError("billos: api_key is required")
|
|
127
|
+
self.api_key = api_key
|
|
128
|
+
self.base_url = base_url.rstrip("/")
|
|
129
|
+
self.timeout = timeout
|
|
130
|
+
self.max_retries = max_retries
|
|
131
|
+
self.headers = dict(headers or {})
|
|
132
|
+
self._transport = transport or _urllib_transport
|
|
133
|
+
self._sleep = sleep
|
|
134
|
+
self.businesses = _Businesses(self)
|
|
135
|
+
self.keys = _Keys(self)
|
|
136
|
+
self.usage = _Usage(self)
|
|
137
|
+
self.parties = _Parties(self)
|
|
138
|
+
self.documents = _Documents(self)
|
|
139
|
+
self.recurring = _Recurring(self)
|
|
140
|
+
self.expenses = _Expenses(self)
|
|
141
|
+
self.exports = _Exports(self)
|
|
142
|
+
self.files = _Files(self)
|
|
143
|
+
self.webhooks = _Webhooks(self)
|
|
144
|
+
self.events = _Events(self)
|
|
145
|
+
self.sandbox = _Sandbox(self)
|
|
146
|
+
|
|
147
|
+
# ── the one call behind every method ────────────────────────────────────────────────
|
|
148
|
+
def request(self, op: str, path: Optional[Dict[str, Any]] = None, query: Optional[Dict[str, Any]] = None, body: Any = None,
|
|
149
|
+
idempotency_key: Optional[str] = None, headers: Optional[Dict[str, str]] = None, binary: bool = False,
|
|
150
|
+
raw: Optional[Tuple[bytes, str]] = None):
|
|
151
|
+
method, template = OPERATIONS[op]
|
|
152
|
+
url = self.base_url + _fill(template, path or {}) + _query(query)
|
|
153
|
+
can_retry = method in ("GET", "DELETE") or bool(idempotency_key)
|
|
154
|
+
h = {"X-Api-Key": self.api_key, "Accept": "*/*" if binary else "application/json", "User-Agent": f"billos-python/{VERSION}"}
|
|
155
|
+
h.update(self.headers)
|
|
156
|
+
h.update(headers or {})
|
|
157
|
+
if idempotency_key:
|
|
158
|
+
h["Idempotency-Key"] = str(idempotency_key)
|
|
159
|
+
payload: Optional[bytes] = None
|
|
160
|
+
if raw is not None:
|
|
161
|
+
payload, h["Content-Type"] = raw[0], raw[1]
|
|
162
|
+
elif body is not None:
|
|
163
|
+
payload = json.dumps(body, ensure_ascii=False).encode("utf-8")
|
|
164
|
+
h["Content-Type"] = "application/json"
|
|
165
|
+
|
|
166
|
+
attempt = 0
|
|
167
|
+
while True:
|
|
168
|
+
attempt += 1
|
|
169
|
+
try:
|
|
170
|
+
status, rh, data = self._transport(method, url, h, payload, self.timeout)
|
|
171
|
+
except Exception as e: # network
|
|
172
|
+
if can_retry and attempt <= self.max_retries:
|
|
173
|
+
self._sleep(0.25 * (2 ** (attempt - 1)))
|
|
174
|
+
continue
|
|
175
|
+
raise BillOSError(0, "network", f"billos: {e}") from e
|
|
176
|
+
request_id = rh.get("x-request-id")
|
|
177
|
+
if 200 <= status < 300:
|
|
178
|
+
ctype = rh.get("content-type", "")
|
|
179
|
+
if binary or "json" not in ctype:
|
|
180
|
+
return Binary(data, ctype or "application/octet-stream", rh.get("x-print-variant"), request_id)
|
|
181
|
+
out = json.loads(data.decode("utf-8")) if data else {}
|
|
182
|
+
if isinstance(out, dict):
|
|
183
|
+
out.setdefault("request_id", request_id)
|
|
184
|
+
return out
|
|
185
|
+
try:
|
|
186
|
+
err_body = json.loads(data.decode("utf-8")) if data else None
|
|
187
|
+
except Exception:
|
|
188
|
+
err_body = None
|
|
189
|
+
if status in TRANSIENT and can_retry and attempt <= self.max_retries:
|
|
190
|
+
try:
|
|
191
|
+
ra = float(rh.get("retry-after", ""))
|
|
192
|
+
except ValueError:
|
|
193
|
+
ra = 0.0
|
|
194
|
+
self._sleep(min(ra, 30.0) if ra > 0 else 0.25 * (2 ** (attempt - 1)))
|
|
195
|
+
continue
|
|
196
|
+
ed = err_body if isinstance(err_body, dict) else {}
|
|
197
|
+
raise BillOSError(status, ed.get("reason"), ed.get("error") or f"HTTP {status}", request_id, err_body)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class _Businesses(_NS):
|
|
201
|
+
def list(self, query=None, **o): return self._c.request("listBusinesses", query=query, **o)
|
|
202
|
+
def create(self, body, **o): return self._c.request("createBusiness", body=body, **o)
|
|
203
|
+
def get(self, bid, **o): return self._c.request("getBusiness", path={"bid": bid}, **o)
|
|
204
|
+
def update(self, bid, body, **o): return self._c.request("updateBusiness", path={"bid": bid}, body=body, **o)
|
|
205
|
+
def deactivate(self, bid, **o): return self._c.request("deactivateBusiness", path={"bid": bid}, **o)
|
|
206
|
+
def reactivate(self, bid, **o): return self._c.request("reactivateBusiness", path={"bid": bid}, **o)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class _Keys(_NS):
|
|
210
|
+
def list(self, **o): return self._c.request("listKeys", **o)
|
|
211
|
+
def update(self, key_id, body, **o): return self._c.request("updateKey", path={"id": key_id}, body=body, **o)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class _Usage(_NS):
|
|
215
|
+
def get(self, query=None, **o): return self._c.request("getUsage", query=query, **o)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class _Parties(_NS):
|
|
219
|
+
def list(self, bid, query=None, **o): return self._c.request("listParties", path={"bid": bid}, query=query, **o)
|
|
220
|
+
def create(self, bid, body, **o): return self._c.request("createParty", path={"bid": bid}, body=body, **o)
|
|
221
|
+
def find_or_create(self, bid, body, **o): return self._c.request("findOrCreateParty", path={"bid": bid}, body=body, **o)
|
|
222
|
+
def update(self, bid, party_id, body, **o): return self._c.request("updateParty", path={"bid": bid, "id": party_id}, body=body, **o)
|
|
223
|
+
def record_consent(self, bid, party_id, body=None, **o): return self._c.request("recordPartyConsent", path={"bid": bid, "id": party_id}, body=body or {}, **o)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class _Documents(_NS):
|
|
227
|
+
def fx_rate(self, bid, query, **o): return self._c.request("getFxRate", path={"bid": bid}, query=query, **o)
|
|
228
|
+
def create(self, bid, body, **o): return self._c.request("createDocument", path={"bid": bid}, body=body, **o)
|
|
229
|
+
def list(self, bid, query=None, **o): return self._c.request("listDocuments", path={"bid": bid}, query=query, **o)
|
|
230
|
+
def get(self, bid, doc_id, **o): return self._c.request("getDocument", path={"bid": bid, "id": doc_id}, **o)
|
|
231
|
+
def update(self, bid, doc_id, body, **o): return self._c.request("updateDocument", path={"bid": bid, "id": doc_id}, body=body, **o)
|
|
232
|
+
def delete(self, bid, doc_id, **o): return self._c.request("deleteDocument", path={"bid": bid, "id": doc_id}, **o)
|
|
233
|
+
def issue(self, bid, doc_id, body=None, **o): return self._c.request("issueDocument", path={"bid": bid, "id": doc_id}, body=body or {}, **o)
|
|
234
|
+
def credit_note(self, bid, doc_id, body=None, **o): return self._c.request("issueCreditNote", path={"bid": bid, "id": doc_id}, body=body or {}, **o)
|
|
235
|
+
def preview(self, bid, doc_id, body=None, **o): return self._c.request("previewDocument", path={"bid": bid, "id": doc_id}, body=body or {}, binary=True, **o)
|
|
236
|
+
def print(self, bid, doc_id, body=None, **o): return self._c.request("printDocument", path={"bid": bid, "id": doc_id}, body=body or {"kind": "auto"}, binary=True, **o)
|
|
237
|
+
def share(self, bid, doc_id, body=None, **o): return self._c.request("shareDocument", path={"bid": bid, "id": doc_id}, body=body or {"kind": "auto"}, **o)
|
|
238
|
+
def email(self, bid, doc_id, body, **o): return self._c.request("emailDocument", path={"bid": bid, "id": doc_id}, body=body, **o)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
class _Recurring(_NS):
|
|
242
|
+
def create(self, bid, body, **o): return self._c.request("createRecurring", path={"bid": bid}, body=body, **o)
|
|
243
|
+
def list(self, bid, query=None, **o): return self._c.request("listRecurring", path={"bid": bid}, query=query, **o)
|
|
244
|
+
def get(self, bid, plan_id, **o): return self._c.request("getRecurring", path={"bid": bid, "id": plan_id}, **o)
|
|
245
|
+
def update(self, bid, plan_id, body, **o): return self._c.request("updateRecurring", path={"bid": bid, "id": plan_id}, body=body, **o)
|
|
246
|
+
def delete(self, bid, plan_id, **o): return self._c.request("deleteRecurring", path={"bid": bid, "id": plan_id}, **o)
|
|
247
|
+
def run(self, bid, plan_id, **o): return self._c.request("runRecurring", path={"bid": bid, "id": plan_id}, **o)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
class _Expenses(_NS):
|
|
251
|
+
def create(self, bid, body, **o): return self._c.request("createExpense", path={"bid": bid}, body=body, **o)
|
|
252
|
+
def record(self, bid, body, **o): return self._c.request("recordExpenseDirect", path={"bid": bid}, body=body, **o)
|
|
253
|
+
def list(self, bid, query=None, **o): return self._c.request("listExpenses", path={"bid": bid}, query=query, **o)
|
|
254
|
+
def get(self, bid, expense_id, **o): return self._c.request("getExpense", path={"bid": bid, "id": expense_id}, **o)
|
|
255
|
+
def update(self, bid, expense_id, body, **o): return self._c.request("updateExpense", path={"bid": bid, "id": expense_id}, body=body, **o)
|
|
256
|
+
def delete(self, bid, expense_id, **o): return self._c.request("deleteExpenseDraft", path={"bid": bid, "id": expense_id}, **o)
|
|
257
|
+
def record_draft(self, bid, expense_id, **o): return self._c.request("recordExpense", path={"bid": bid, "id": expense_id}, **o)
|
|
258
|
+
def correct(self, bid, expense_id, body=None, **o): return self._c.request("correctExpense", path={"bid": bid, "id": expense_id}, body=body or {}, **o)
|
|
259
|
+
|
|
260
|
+
def ocr(self, bid, data: bytes, mime: str = "image/jpeg", **o):
|
|
261
|
+
"""A receipt image or PDF -> a pre-filled draft. The bytes are the body with their own Content-Type."""
|
|
262
|
+
return self._c.request("ocrExpense", path={"bid": bid}, raw=(data, mime), **o)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
class _Exports(_NS):
|
|
266
|
+
def openfrmt(self, bid, body, **o): return self._c.request("exportOpenfrmt", path={"bid": bid}, body=body, **o)
|
|
267
|
+
def continuity(self, bid, query=None, **o): return self._c.request("continuityReport", path={"bid": bid}, query=query, **o)
|
|
268
|
+
|
|
269
|
+
def report(self, bid, kind, query=None, **o):
|
|
270
|
+
"""``format='json'`` answers the report data; otherwise PDF bytes."""
|
|
271
|
+
return self._c.request("managementReport", path={"bid": bid, "kind": kind}, query=query, binary=(query or {}).get("format", "pdf") != "json", **o)
|
|
272
|
+
|
|
273
|
+
def backup(self, bid, **o): return self._c.request("createBackup", path={"bid": bid}, **o)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
class _Files(_NS):
|
|
277
|
+
def thumbnails(self, bid, body, **o): return self._c.request("fileThumbnails", path={"bid": bid}, body=body, **o)
|
|
278
|
+
def get(self, bid, file_id, **o): return self._c.request("getFile", path={"bid": bid, "id": file_id}, binary=True, **o)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
class _Webhooks(_NS):
|
|
282
|
+
def create(self, body, **o): return self._c.request("createWebhook", body=body, **o)
|
|
283
|
+
def list(self, **o): return self._c.request("listWebhooks", **o)
|
|
284
|
+
def update(self, webhook_id, body, **o): return self._c.request("updateWebhook", path={"id": webhook_id}, body=body, **o)
|
|
285
|
+
def delete(self, webhook_id, **o): return self._c.request("deleteWebhook", path={"id": webhook_id}, **o)
|
|
286
|
+
def deliveries(self, webhook_id, query=None, **o): return self._c.request("listWebhookDeliveries", path={"id": webhook_id}, query=query, **o)
|
|
287
|
+
def redeliver(self, webhook_id, delivery_id, **o): return self._c.request("redeliverWebhook", path={"id": webhook_id, "did": delivery_id}, **o)
|
|
288
|
+
def test(self, webhook_id, **o): return self._c.request("testWebhook", path={"id": webhook_id}, **o)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
class _Events(_NS):
|
|
292
|
+
def list(self, query=None, **o): return self._c.request("listEvents", query=query, **o)
|
|
293
|
+
def get(self, event_id, **o): return self._c.request("getEvent", path={"id": event_id}, **o)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
class _Sandbox(_NS):
|
|
297
|
+
def seed(self, **o):
|
|
298
|
+
"""Test keys only: a demo business with customers, documents and expenses. Idempotent."""
|
|
299
|
+
return self._c.request("seedSandbox", **o)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def verify_webhook_signature(secret: str, header: str, raw_body: bytes, tolerance_seconds: int = 300, now: Optional[float] = None) -> bool:
|
|
303
|
+
"""Verify ``X-BillOS-Signature`` ("t=<unix>,v1=<hex>"): HMAC-SHA256 over the bytes ``t + "." + raw_body``.
|
|
304
|
+
|
|
305
|
+
``raw_body`` must be the request's raw bytes, not a parsed or re-serialized body."""
|
|
306
|
+
if not isinstance(raw_body, (bytes, bytearray)):
|
|
307
|
+
raise TypeError("billos: raw_body must be the raw request bytes")
|
|
308
|
+
parts = dict(p.split("=", 1) for p in (header or "").split(",") if "=" in p)
|
|
309
|
+
try:
|
|
310
|
+
t = int(parts.get("t", ""))
|
|
311
|
+
except ValueError:
|
|
312
|
+
return False
|
|
313
|
+
v1 = parts.get("v1")
|
|
314
|
+
if not v1:
|
|
315
|
+
return False
|
|
316
|
+
if abs((now if now is not None else time.time()) - t) > tolerance_seconds:
|
|
317
|
+
return False
|
|
318
|
+
expected = hmac.new(secret.encode("utf-8"), f"{t}.".encode("utf-8") + bytes(raw_body), hashlib.sha256).hexdigest()
|
|
319
|
+
# Bytes, so a forged header with non-ASCII characters is a False, not a TypeError.
|
|
320
|
+
return hmac.compare_digest(expected.encode("ascii"), v1.encode("utf-8"))
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: billos
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: BillOS for Python: Israeli tax documents, expenses, webhooks and the regulatory outputs, over one small client.
|
|
5
|
+
Author: BillOS
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Documentation, https://docs.billos.co.il
|
|
8
|
+
Project-URL: Homepage, https://billos.co.il
|
|
9
|
+
Project-URL: Source, https://github.com/baraviz/pest-os/tree/main/packages/billos-python
|
|
10
|
+
Keywords: billos,invoice,bookkeeping,israel,tax,api
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Topic :: Office/Business :: Financial :: Accounting
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# billos (Python)
|
|
21
|
+
|
|
22
|
+
BillOS from Python: legally valid Israeli tax documents (חשבונית מס, קבלה, זיכוי), the expense ledger, webhooks and the regulatory outputs, over one small client with no dependencies beyond the standard library (Python 3.9+).
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install billos
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from billos import BillOS, BillOSError
|
|
30
|
+
|
|
31
|
+
billos = BillOS(api_key=os.environ["BILLOS_API_KEY"])
|
|
32
|
+
bid = billos.businesses.list()["businesses"][0]["id"]
|
|
33
|
+
|
|
34
|
+
created = billos.documents.create(bid, {
|
|
35
|
+
"docType": 320,
|
|
36
|
+
"priceMode": "gross",
|
|
37
|
+
"party": {"name": "דנה לוי", "phone": "0521111111"},
|
|
38
|
+
"lines": [{"description": "איפור כלה", "quantity": 1, "unitPriceExVat": 120000}],
|
|
39
|
+
"payments": [{"method": "card", "amount": 120000}],
|
|
40
|
+
}, idempotency_key="order-8812")
|
|
41
|
+
|
|
42
|
+
issued = billos.documents.issue(bid, created["document"]["id"])
|
|
43
|
+
print(issued["document"]["docNumber"])
|
|
44
|
+
|
|
45
|
+
pdf = billos.documents.print(bid, created["document"]["id"]) # Binary: .bytes, .content_type, .variant
|
|
46
|
+
link = billos.documents.share(bid, created["document"]["id"]) # a public URL for the customer
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Money is **integer agorot** everywhere, exactly like the API (₪354.00 is `35400`); a foreign-currency document (`"currency": "USD"`) is in that currency's cents. Nothing in the client converts.
|
|
50
|
+
|
|
51
|
+
## What you get
|
|
52
|
+
|
|
53
|
+
- Every operation in the spec as `namespace.method` (snake_case): `businesses`, `parties`, `documents`, `recurring`, `expenses`, `exports`, `files`, `webhooks`, `events`, `keys`, `usage`, `sandbox`. `OPERATIONS` (method and path per operation) is generated from the OpenAPI spec, and a test checks every operation has a method, so the client cannot lag behind the API.
|
|
54
|
+
- The conventions handled: `X-Api-Key`, the `{ok, ...}` envelope returned as a dict (plus `request_id`), `Idempotency-Key` from `idempotency_key=`, `Retry-After` honoured.
|
|
55
|
+
- Retries on 429 / 502 / 503 / 504 and network errors, up to `max_retries` (default 2), for GET/DELETE and for POSTs carrying an idempotency key; never for a POST without one.
|
|
56
|
+
- `BillOSError` with `status`, `reason`, `request_id`, `body`, `retryable`. Branch on `reason`.
|
|
57
|
+
- Binary answers (`documents.preview`, `documents.print`, `files.get`, a PDF report) come back as `Binary(bytes, content_type, variant, request_id)`.
|
|
58
|
+
- `verify_webhook_signature(secret, header, raw_body)` for the `X-BillOS-Signature` header.
|
|
59
|
+
- A `bk_test_` key answers from the sandbox with no change on your side (`billos.sandbox.seed()` fills it with demo data).
|
|
60
|
+
|
|
61
|
+
Documentation and the OpenAPI spec: https://docs.billos.co.il. MIT.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
billos
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "billos"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "BillOS for Python: Israeli tax documents, expenses, webhooks and the regulatory outputs, over one small client."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [{ name = "BillOS" }]
|
|
13
|
+
keywords = ["billos", "invoice", "bookkeeping", "israel", "tax", "api"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Topic :: Office/Business :: Financial :: Accounting",
|
|
19
|
+
]
|
|
20
|
+
dependencies = []
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Documentation = "https://docs.billos.co.il"
|
|
24
|
+
Homepage = "https://billos.co.il"
|
|
25
|
+
Source = "https://github.com/baraviz/pest-os/tree/main/packages/billos-python"
|
|
26
|
+
|
|
27
|
+
[tool.setuptools.packages.find]
|
|
28
|
+
include = ["billos*"]
|
billos-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""The client against a fake transport: headers, path filling, query encoding, envelope,
|
|
2
|
+
errors with request ids, retries, binary answers, OCR raw upload, webhook signatures, and
|
|
3
|
+
that SURFACE names a live method for every operation in the generated table.
|
|
4
|
+
Run: python3 -m unittest discover -s packages/billos-python/tests"""
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
import unittest
|
|
12
|
+
|
|
13
|
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
|
14
|
+
from billos import BillOS, BillOSError, OPERATIONS, SURFACE, verify_webhook_signature # noqa: E402
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Fake:
|
|
18
|
+
def __init__(self, handler):
|
|
19
|
+
self.calls = []
|
|
20
|
+
self.handler = handler
|
|
21
|
+
|
|
22
|
+
def __call__(self, method, url, headers, body, timeout):
|
|
23
|
+
self.calls.append((method, url, headers, body))
|
|
24
|
+
status, hdrs, data = self.handler(method, url, headers, body, len(self.calls))
|
|
25
|
+
h = {"content-type": "application/json", "x-request-id": "req-1"}
|
|
26
|
+
h.update(hdrs)
|
|
27
|
+
return status, h, data if isinstance(data, bytes) else json.dumps(data).encode()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ClientTests(unittest.TestCase):
|
|
31
|
+
def test_call_shape(self):
|
|
32
|
+
fake = Fake(lambda *a: (200, {}, {"ok": True, "documents": []}))
|
|
33
|
+
c = BillOS("bk_test_abc", base_url="https://x.test/v1/", transport=fake)
|
|
34
|
+
out = c.documents.list("biz 1", {"status": "issued", "take": 5, "from": None})
|
|
35
|
+
method, url, headers, body = fake.calls[0]
|
|
36
|
+
self.assertEqual(url, "https://x.test/v1/businesses/biz%201/documents?status=issued&take=5")
|
|
37
|
+
self.assertEqual(method, "GET")
|
|
38
|
+
self.assertEqual(headers["X-Api-Key"], "bk_test_abc")
|
|
39
|
+
self.assertEqual(out["documents"], [])
|
|
40
|
+
self.assertEqual(out["request_id"], "req-1")
|
|
41
|
+
|
|
42
|
+
def test_create_sends_json_and_idempotency_key(self):
|
|
43
|
+
fake = Fake(lambda *a: (201, {}, {"ok": True, "document": {"id": "d1"}}))
|
|
44
|
+
c = BillOS("k", transport=fake)
|
|
45
|
+
c.documents.create("b1", {"docType": 400, "payments": [{"method": 1, "amount": 100}]}, idempotency_key="order-1")
|
|
46
|
+
_, _, headers, body = fake.calls[0]
|
|
47
|
+
self.assertEqual(headers["Idempotency-Key"], "order-1")
|
|
48
|
+
self.assertEqual(headers["Content-Type"], "application/json")
|
|
49
|
+
self.assertEqual(json.loads(body)["docType"], 400)
|
|
50
|
+
with self.assertRaisesRegex(TypeError, 'missing path parameter "bid"'):
|
|
51
|
+
c.documents.get("", "x")
|
|
52
|
+
self.assertEqual(len(fake.calls), 1)
|
|
53
|
+
|
|
54
|
+
def test_error_shape(self):
|
|
55
|
+
fake = Fake(lambda *a: (404, {"x-request-id": "req-404"}, {"error": "not found", "reason": "not_found"}))
|
|
56
|
+
c = BillOS("k", transport=fake)
|
|
57
|
+
with self.assertRaises(BillOSError) as cm:
|
|
58
|
+
c.businesses.get("nope")
|
|
59
|
+
e = cm.exception
|
|
60
|
+
self.assertEqual((e.status, e.reason, e.request_id, e.retryable), (404, "not_found", "req-404", False))
|
|
61
|
+
# a network failure is transient too
|
|
62
|
+
def boom(*a):
|
|
63
|
+
raise OSError("dns")
|
|
64
|
+
with self.assertRaises(BillOSError) as cm2:
|
|
65
|
+
BillOS("k", transport=boom, max_retries=0).documents.issue("b", "d")
|
|
66
|
+
self.assertTrue(cm2.exception.retryable and cm2.exception.status == 0)
|
|
67
|
+
|
|
68
|
+
def test_retries(self):
|
|
69
|
+
slept = []
|
|
70
|
+
state = {"n": 0}
|
|
71
|
+
|
|
72
|
+
def h(*a):
|
|
73
|
+
state["n"] += 1
|
|
74
|
+
return (429, {"retry-after": "0"}, {"error": "slow", "reason": "rate_limited"}) if state["n"] == 1 else (200, {}, {"ok": True, "businesses": []})
|
|
75
|
+
fake = Fake(h)
|
|
76
|
+
c = BillOS("k", transport=fake, sleep=slept.append)
|
|
77
|
+
self.assertTrue(c.businesses.list()["ok"])
|
|
78
|
+
self.assertEqual(len(fake.calls), 2)
|
|
79
|
+
# a POST without an idempotency key is never retried
|
|
80
|
+
fake2 = Fake(lambda *a: (502, {}, {"error": "x", "reason": "provider"}))
|
|
81
|
+
c2 = BillOS("k", transport=fake2, sleep=slept.append)
|
|
82
|
+
with self.assertRaises(BillOSError) as cm:
|
|
83
|
+
c2.documents.issue("b", "d")
|
|
84
|
+
self.assertTrue(cm.exception.retryable)
|
|
85
|
+
self.assertEqual(len(fake2.calls), 1)
|
|
86
|
+
# with one, it is
|
|
87
|
+
state["n"] = 0
|
|
88
|
+
fake3 = Fake(lambda m, u, hh, b, n: (502, {}, {"error": "x", "reason": "provider"}) if n == 1 else (201, {}, {"ok": True, "document": {}}))
|
|
89
|
+
c3 = BillOS("k", transport=fake3, sleep=slept.append)
|
|
90
|
+
c3.documents.create("b", {"docType": 400}, idempotency_key="k1")
|
|
91
|
+
self.assertEqual(len(fake3.calls), 2)
|
|
92
|
+
|
|
93
|
+
def test_binary_and_ocr(self):
|
|
94
|
+
pdf = b"%PDF-1.7"
|
|
95
|
+
|
|
96
|
+
def h(method, url, headers, body, n):
|
|
97
|
+
if url.endswith("/prints"):
|
|
98
|
+
return 200, {"content-type": "application/pdf", "x-print-variant": "origin"}, pdf
|
|
99
|
+
return 201, {}, {"ok": True, "expense": {"id": "e1"}}
|
|
100
|
+
fake = Fake(h)
|
|
101
|
+
c = BillOS("k", transport=fake)
|
|
102
|
+
out = c.documents.print("b", "d")
|
|
103
|
+
self.assertEqual((out.bytes, out.content_type, out.variant), (pdf, "application/pdf", "origin"))
|
|
104
|
+
self.assertEqual(json.loads(fake.calls[0][3])["kind"], "auto")
|
|
105
|
+
c.expenses.ocr("b", b"\x01\x02", mime="image/png")
|
|
106
|
+
_, url, headers, body = fake.calls[1]
|
|
107
|
+
self.assertEqual(headers["Content-Type"], "image/png")
|
|
108
|
+
self.assertEqual(body, b"\x01\x02")
|
|
109
|
+
self.assertTrue(url.endswith("/businesses/b/expenses/ocr"))
|
|
110
|
+
|
|
111
|
+
def test_surface_covers_every_generated_operation(self):
|
|
112
|
+
c = BillOS("k", transport=Fake(lambda *a: (200, {}, {"ok": True})))
|
|
113
|
+
for op, where in SURFACE.items():
|
|
114
|
+
ns, fn = where.split(".")
|
|
115
|
+
self.assertTrue(callable(getattr(getattr(c, ns), fn)), f"{op} -> {where}")
|
|
116
|
+
self.assertEqual(set(SURFACE), set(OPERATIONS))
|
|
117
|
+
self.assertEqual(len(set(SURFACE.values())), len(SURFACE))
|
|
118
|
+
|
|
119
|
+
def test_webhook_signature(self):
|
|
120
|
+
secret, body = "whsec_test", b'{"id":"del_1"}'
|
|
121
|
+
t = int(time.time())
|
|
122
|
+
v1 = hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest()
|
|
123
|
+
self.assertTrue(verify_webhook_signature(secret, f"t={t},v1={v1}", body))
|
|
124
|
+
self.assertFalse(verify_webhook_signature(secret, f"t={t},v1={v1}", body + b" "))
|
|
125
|
+
self.assertFalse(verify_webhook_signature(secret, f"t={t - 1000},v1={v1}", body))
|
|
126
|
+
self.assertFalse(verify_webhook_signature(secret, "garbage", body))
|
|
127
|
+
self.assertFalse(verify_webhook_signature(secret, f"t={t},v1=abc\u05d0", body), "non-ASCII in the header is a False, not a crash")
|
|
128
|
+
with self.assertRaises(TypeError):
|
|
129
|
+
verify_webhook_signature(secret, f"t={t},v1={v1}", {"id": "del_1"})
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
if __name__ == "__main__":
|
|
133
|
+
unittest.main()
|