autosignly 0.1.0.dev0__tar.gz → 0.1.3__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.
- {autosignly-0.1.0.dev0 → autosignly-0.1.3}/PKG-INFO +95 -4
- autosignly-0.1.3/README.md +218 -0
- {autosignly-0.1.0.dev0 → autosignly-0.1.3}/src/autosignly/__init__.py +18 -0
- autosignly-0.1.3/src/autosignly/_version.py +1 -0
- {autosignly-0.1.0.dev0 → autosignly-0.1.3}/src/autosignly/client.py +161 -10
- {autosignly-0.1.0.dev0 → autosignly-0.1.3}/src/autosignly/models.py +176 -0
- autosignly-0.1.3/src/autosignly/webhooks.py +79 -0
- autosignly-0.1.3/tests/test_client.py +667 -0
- autosignly-0.1.3/tests/test_contract.py +178 -0
- autosignly-0.1.3/tests/test_webhooks.py +74 -0
- autosignly-0.1.0.dev0/README.md +0 -127
- autosignly-0.1.0.dev0/src/autosignly/_version.py +0 -1
- autosignly-0.1.0.dev0/src/autosignly/webhooks.py +0 -39
- autosignly-0.1.0.dev0/tests/test_client.py +0 -312
- autosignly-0.1.0.dev0/tests/test_webhooks.py +0 -40
- {autosignly-0.1.0.dev0 → autosignly-0.1.3}/.gitignore +0 -0
- {autosignly-0.1.0.dev0 → autosignly-0.1.3}/pyproject.toml +0 -0
- {autosignly-0.1.0.dev0 → autosignly-0.1.3}/src/autosignly/errors.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: autosignly
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
4
4
|
Summary: Python client for the Autosignly API - eIDAS electronic signatures and document workflows
|
|
5
5
|
Project-URL: Homepage, https://autosignly.eu
|
|
6
6
|
Project-URL: Documentation, https://docs.16it.eu/docs/intro/
|
|
@@ -69,6 +69,13 @@ for summary in client.iter_documents(status="SIGNED"):
|
|
|
69
69
|
print(summary.id, summary.name)
|
|
70
70
|
```
|
|
71
71
|
|
|
72
|
+
Both listing calls take `tag_id` as well. Several tags narrow the result — a document has to
|
|
73
|
+
carry all of them — and a tag that does not exist gives an empty page rather than an error:
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
page = client.list_documents(tag_id=["contracts", "2026"], status="SIGNED")
|
|
77
|
+
```
|
|
78
|
+
|
|
72
79
|
## Downloading the file
|
|
73
80
|
|
|
74
81
|
A document carries a short-lived link to its file. The link expires, so fetch the document again
|
|
@@ -85,6 +92,44 @@ open("signed.pdf", "wb").write(pdf)
|
|
|
85
92
|
A document that is still being signed can be downloaded as well - it then carries only the
|
|
86
93
|
signatures collected so far.
|
|
87
94
|
|
|
95
|
+
## Attachments
|
|
96
|
+
|
|
97
|
+
Files attached to a document are converted to PDF and merged into it when it is sent for
|
|
98
|
+
signing, behind an index page listing each one with its checksum — so a single signature
|
|
99
|
+
covers the document and everything attached to it.
|
|
100
|
+
|
|
101
|
+
Attachments can only be added before the document is sent, so upload it first and send it
|
|
102
|
+
afterwards instead of using `upload_and_sign`:
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
document_id = client.upload_pdf(
|
|
106
|
+
pdf=open("protocol.pdf", "rb").read(),
|
|
107
|
+
document_name="Handover protocol",
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
attachment = client.add_attachment(
|
|
111
|
+
document_id,
|
|
112
|
+
content=open("site-photo.jpg", "rb").read(),
|
|
113
|
+
file_name="site-photo.jpg",
|
|
114
|
+
)
|
|
115
|
+
print(attachment.order_index, attachment.sha256)
|
|
116
|
+
|
|
117
|
+
for existing in client.list_attachments(document_id):
|
|
118
|
+
print(existing.file_name, existing.page_count)
|
|
119
|
+
|
|
120
|
+
client.send_for_signing(document_id, signers=[signer])
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
An attachment can be dropped again while the document is still unsent:
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
client.delete_attachment(document_id, attachment.id)
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
PDF, JPEG and PNG are accepted, recognised from the content rather than the file name.
|
|
130
|
+
Attachments merge in the order they were added, and can only be changed before the document
|
|
131
|
+
is sent for signing.
|
|
132
|
+
|
|
88
133
|
## Tags
|
|
89
134
|
|
|
90
135
|
```python
|
|
@@ -95,17 +140,63 @@ client.set_document_tags(document_id, tag_ids=[tag.id], names=["2026"])
|
|
|
95
140
|
Setting tags replaces the whole set: tags left out are removed, and names that do not exist yet are
|
|
96
141
|
added to the company tag pool.
|
|
97
142
|
|
|
143
|
+
## Parties
|
|
144
|
+
|
|
145
|
+
A party is the other side of a document — a business or a natural person the company signs with.
|
|
146
|
+
|
|
147
|
+
```python
|
|
148
|
+
from autosignly import Party, PartyAddress, PartyType
|
|
149
|
+
|
|
150
|
+
acme = client.create_party(Party(
|
|
151
|
+
type=PartyType.COMPANY,
|
|
152
|
+
name="Acme Sp. z o.o.",
|
|
153
|
+
tax_id="5842831253",
|
|
154
|
+
email="kontakt@acme.pl",
|
|
155
|
+
address=PartyAddress(street="Marszalkowska", number="12/34",
|
|
156
|
+
postal_code="00-001", city="Warszawa", country_code="PL"),
|
|
157
|
+
))
|
|
158
|
+
|
|
159
|
+
for party in client.list_parties(name="acme", type=PartyType.COMPANY):
|
|
160
|
+
print(party.id, party.name, party.tax_id)
|
|
161
|
+
|
|
162
|
+
client.update_party(acme.id, Party(type=PartyType.COMPANY, name="Acme Renamed",
|
|
163
|
+
tax_id="5842831253"))
|
|
164
|
+
client.delete_party(acme.id)
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
A `COMPANY` needs a `tax_id` and an `address`; a `PERSON` needs a `firstname` and an `email`. A
|
|
168
|
+
Polish address makes the tax id subject to the NIP checksum.
|
|
169
|
+
|
|
170
|
+
`update_party` replaces the whole party, so send every field you want to keep. Creating a party
|
|
171
|
+
that already exists — same tax id for a `COMPANY`, same e-mail for a `PERSON` — is rejected rather
|
|
172
|
+
than deduplicated, so look the party up before retrying a failed create.
|
|
173
|
+
|
|
174
|
+
Parties belong to the environment of the key that created them: a sandbox key never sees a
|
|
175
|
+
production party. Listing has no `sort` — the searchable fields are stored encrypted, so the
|
|
176
|
+
server cannot order by them.
|
|
177
|
+
|
|
98
178
|
## Verifying webhooks
|
|
99
179
|
|
|
100
|
-
Autosignly signs every
|
|
101
|
-
|
|
180
|
+
Autosignly signs every delivery. Check the signature against the raw request body, before parsing
|
|
181
|
+
it - re-serialising the JSON changes the bytes and the signature will not match.
|
|
102
182
|
|
|
103
183
|
```python
|
|
104
184
|
from autosignly import webhooks
|
|
105
185
|
|
|
106
|
-
webhooks.verify(
|
|
186
|
+
webhooks.verify(
|
|
187
|
+
request.body,
|
|
188
|
+
request.headers["X-Webhook-Signature"],
|
|
189
|
+
webhook_key,
|
|
190
|
+
request.headers["X-Webhook-Timestamp"],
|
|
191
|
+
)
|
|
107
192
|
```
|
|
108
193
|
|
|
194
|
+
The signature covers the timestamp as well as the body, and a delivery older than five minutes is
|
|
195
|
+
rejected even when its signature matches, so a captured request cannot be replayed later.
|
|
196
|
+
|
|
197
|
+
While a webhook key is being rotated a delivery carries several signatures; it is accepted when any
|
|
198
|
+
of them matches, so rotation needs no change on your side.
|
|
199
|
+
|
|
109
200
|
`verify` raises `InvalidSignatureError` on a mismatch; `webhooks.is_valid(...)` returns a boolean
|
|
110
201
|
instead.
|
|
111
202
|
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# autosignly
|
|
2
|
+
|
|
3
|
+
Python client for the [Autosignly](https://autosignly.eu) API - eIDAS electronic signatures and
|
|
4
|
+
document workflows.
|
|
5
|
+
|
|
6
|
+
> **Not published yet.** This package is being built. Install from source for now.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pip install autosignly
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Requires Python 3.10 or newer.
|
|
15
|
+
|
|
16
|
+
## Quickstart
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
from autosignly import AutosignlyClient, Signer
|
|
20
|
+
|
|
21
|
+
with AutosignlyClient(api_key="api_key_...", api_secret="api_sct_...") as client:
|
|
22
|
+
document_id = client.upload_and_sign(
|
|
23
|
+
pdf=open("contract.pdf", "rb").read(),
|
|
24
|
+
document_name="Consulting agreement",
|
|
25
|
+
signers=[
|
|
26
|
+
Signer(
|
|
27
|
+
first_name="Anna",
|
|
28
|
+
last_name="Nowak",
|
|
29
|
+
email="anna@example.com",
|
|
30
|
+
country="PL",
|
|
31
|
+
)
|
|
32
|
+
],
|
|
33
|
+
)
|
|
34
|
+
print(document_id)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The key and secret decide which environment you are working in. Every environment, production or
|
|
38
|
+
sandbox, has its own pair, so pointing a script at the sandbox is a matter of swapping credentials.
|
|
39
|
+
|
|
40
|
+
The secret must stay on your server. It must never be shipped to a browser or a mobile app.
|
|
41
|
+
|
|
42
|
+
## Reading documents
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
document = client.get_document(document_id)
|
|
46
|
+
print(document.status, [s.email for s in document.signers])
|
|
47
|
+
|
|
48
|
+
for summary in client.iter_documents(status="SIGNED"):
|
|
49
|
+
print(summary.id, summary.name)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Both listing calls take `tag_id` as well. Several tags narrow the result — a document has to
|
|
53
|
+
carry all of them — and a tag that does not exist gives an empty page rather than an error:
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
page = client.list_documents(tag_id=["contracts", "2026"], status="SIGNED")
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Downloading the file
|
|
60
|
+
|
|
61
|
+
A document carries a short-lived link to its file. The link expires, so fetch the document again
|
|
62
|
+
for a fresh one rather than storing it.
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
document = client.get_document(document_id)
|
|
66
|
+
print(document.file_url)
|
|
67
|
+
|
|
68
|
+
pdf = client.download_document(document_id)
|
|
69
|
+
open("signed.pdf", "wb").write(pdf)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
A document that is still being signed can be downloaded as well - it then carries only the
|
|
73
|
+
signatures collected so far.
|
|
74
|
+
|
|
75
|
+
## Attachments
|
|
76
|
+
|
|
77
|
+
Files attached to a document are converted to PDF and merged into it when it is sent for
|
|
78
|
+
signing, behind an index page listing each one with its checksum — so a single signature
|
|
79
|
+
covers the document and everything attached to it.
|
|
80
|
+
|
|
81
|
+
Attachments can only be added before the document is sent, so upload it first and send it
|
|
82
|
+
afterwards instead of using `upload_and_sign`:
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
document_id = client.upload_pdf(
|
|
86
|
+
pdf=open("protocol.pdf", "rb").read(),
|
|
87
|
+
document_name="Handover protocol",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
attachment = client.add_attachment(
|
|
91
|
+
document_id,
|
|
92
|
+
content=open("site-photo.jpg", "rb").read(),
|
|
93
|
+
file_name="site-photo.jpg",
|
|
94
|
+
)
|
|
95
|
+
print(attachment.order_index, attachment.sha256)
|
|
96
|
+
|
|
97
|
+
for existing in client.list_attachments(document_id):
|
|
98
|
+
print(existing.file_name, existing.page_count)
|
|
99
|
+
|
|
100
|
+
client.send_for_signing(document_id, signers=[signer])
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
An attachment can be dropped again while the document is still unsent:
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
client.delete_attachment(document_id, attachment.id)
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
PDF, JPEG and PNG are accepted, recognised from the content rather than the file name.
|
|
110
|
+
Attachments merge in the order they were added, and can only be changed before the document
|
|
111
|
+
is sent for signing.
|
|
112
|
+
|
|
113
|
+
## Tags
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
tag = client.create_tag("contracts")
|
|
117
|
+
client.set_document_tags(document_id, tag_ids=[tag.id], names=["2026"])
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Setting tags replaces the whole set: tags left out are removed, and names that do not exist yet are
|
|
121
|
+
added to the company tag pool.
|
|
122
|
+
|
|
123
|
+
## Parties
|
|
124
|
+
|
|
125
|
+
A party is the other side of a document — a business or a natural person the company signs with.
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
from autosignly import Party, PartyAddress, PartyType
|
|
129
|
+
|
|
130
|
+
acme = client.create_party(Party(
|
|
131
|
+
type=PartyType.COMPANY,
|
|
132
|
+
name="Acme Sp. z o.o.",
|
|
133
|
+
tax_id="5842831253",
|
|
134
|
+
email="kontakt@acme.pl",
|
|
135
|
+
address=PartyAddress(street="Marszalkowska", number="12/34",
|
|
136
|
+
postal_code="00-001", city="Warszawa", country_code="PL"),
|
|
137
|
+
))
|
|
138
|
+
|
|
139
|
+
for party in client.list_parties(name="acme", type=PartyType.COMPANY):
|
|
140
|
+
print(party.id, party.name, party.tax_id)
|
|
141
|
+
|
|
142
|
+
client.update_party(acme.id, Party(type=PartyType.COMPANY, name="Acme Renamed",
|
|
143
|
+
tax_id="5842831253"))
|
|
144
|
+
client.delete_party(acme.id)
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
A `COMPANY` needs a `tax_id` and an `address`; a `PERSON` needs a `firstname` and an `email`. A
|
|
148
|
+
Polish address makes the tax id subject to the NIP checksum.
|
|
149
|
+
|
|
150
|
+
`update_party` replaces the whole party, so send every field you want to keep. Creating a party
|
|
151
|
+
that already exists — same tax id for a `COMPANY`, same e-mail for a `PERSON` — is rejected rather
|
|
152
|
+
than deduplicated, so look the party up before retrying a failed create.
|
|
153
|
+
|
|
154
|
+
Parties belong to the environment of the key that created them: a sandbox key never sees a
|
|
155
|
+
production party. Listing has no `sort` — the searchable fields are stored encrypted, so the
|
|
156
|
+
server cannot order by them.
|
|
157
|
+
|
|
158
|
+
## Verifying webhooks
|
|
159
|
+
|
|
160
|
+
Autosignly signs every delivery. Check the signature against the raw request body, before parsing
|
|
161
|
+
it - re-serialising the JSON changes the bytes and the signature will not match.
|
|
162
|
+
|
|
163
|
+
```python
|
|
164
|
+
from autosignly import webhooks
|
|
165
|
+
|
|
166
|
+
webhooks.verify(
|
|
167
|
+
request.body,
|
|
168
|
+
request.headers["X-Webhook-Signature"],
|
|
169
|
+
webhook_key,
|
|
170
|
+
request.headers["X-Webhook-Timestamp"],
|
|
171
|
+
)
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
The signature covers the timestamp as well as the body, and a delivery older than five minutes is
|
|
175
|
+
rejected even when its signature matches, so a captured request cannot be replayed later.
|
|
176
|
+
|
|
177
|
+
While a webhook key is being rotated a delivery carries several signatures; it is accepted when any
|
|
178
|
+
of them matches, so rotation needs no change on your side.
|
|
179
|
+
|
|
180
|
+
`verify` raises `InvalidSignatureError` on a mismatch; `webhooks.is_valid(...)` returns a boolean
|
|
181
|
+
instead.
|
|
182
|
+
|
|
183
|
+
## Errors
|
|
184
|
+
|
|
185
|
+
Every failure raises a subclass of `AutosignlyError` carrying the HTTP status and the error type
|
|
186
|
+
returned by the API.
|
|
187
|
+
|
|
188
|
+
```python
|
|
189
|
+
from autosignly import AutosignlyError, NotFoundError
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
client.get_document("does-not-exist")
|
|
193
|
+
except NotFoundError:
|
|
194
|
+
...
|
|
195
|
+
except AutosignlyError as error:
|
|
196
|
+
print(error.status_code, error.error_type, error.error_id)
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Connection problems and server errors are retried automatically, with an exponential backoff and
|
|
200
|
+
jitter. Client errors are not retried, since repeating a rejected request cannot change its outcome.
|
|
201
|
+
|
|
202
|
+
Rate limits are retried too, honouring the delay the API asks for. When that delay is longer than a
|
|
203
|
+
minute the call fails instead of blocking your thread, and `RateLimitError.retry_after` tells you
|
|
204
|
+
how long to wait.
|
|
205
|
+
|
|
206
|
+
The client does not implement a circuit breaker. It runs inside your process, on calls you asked
|
|
207
|
+
for, so refusing to even attempt one would be surprising - and your own infrastructure is the right
|
|
208
|
+
place for that policy. Pass your own `http_client` if you want to add one.
|
|
209
|
+
|
|
210
|
+
## Links
|
|
211
|
+
|
|
212
|
+
- Website: <https://autosignly.eu>
|
|
213
|
+
- API documentation: <https://docs.16it.eu/docs/intro/>
|
|
214
|
+
- Source and issues: <https://github.com/16it-pl/autosignly-sdk>
|
|
215
|
+
|
|
216
|
+
## License
|
|
217
|
+
|
|
218
|
+
Apache-2.0
|
|
@@ -26,10 +26,19 @@ from .errors import (
|
|
|
26
26
|
ValidationError,
|
|
27
27
|
)
|
|
28
28
|
from .models import (
|
|
29
|
+
Attachment,
|
|
30
|
+
AttachmentFormat,
|
|
31
|
+
AttachmentStatus,
|
|
32
|
+
Credentials,
|
|
29
33
|
Document,
|
|
30
34
|
DocumentStatus,
|
|
35
|
+
EnvironmentType,
|
|
31
36
|
DocumentSummary,
|
|
32
37
|
Page,
|
|
38
|
+
Party,
|
|
39
|
+
PartyAddress,
|
|
40
|
+
PartyType,
|
|
41
|
+
SignatureMode,
|
|
33
42
|
SignatureType,
|
|
34
43
|
Signer,
|
|
35
44
|
SignerDetails,
|
|
@@ -53,10 +62,19 @@ __all__ = [
|
|
|
53
62
|
"RateLimitError",
|
|
54
63
|
"ServerError",
|
|
55
64
|
"ValidationError",
|
|
65
|
+
"Attachment",
|
|
66
|
+
"AttachmentFormat",
|
|
67
|
+
"AttachmentStatus",
|
|
68
|
+
"Credentials",
|
|
56
69
|
"Document",
|
|
57
70
|
"DocumentStatus",
|
|
71
|
+
"EnvironmentType",
|
|
58
72
|
"DocumentSummary",
|
|
59
73
|
"Page",
|
|
74
|
+
"Party",
|
|
75
|
+
"PartyAddress",
|
|
76
|
+
"PartyType",
|
|
77
|
+
"SignatureMode",
|
|
60
78
|
"SignatureType",
|
|
61
79
|
"Signer",
|
|
62
80
|
"SignerDetails",
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.3"
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
|
+
import mimetypes
|
|
6
7
|
import random
|
|
7
8
|
import time
|
|
8
9
|
import uuid
|
|
@@ -13,9 +14,12 @@ import httpx
|
|
|
13
14
|
from . import errors
|
|
14
15
|
from ._version import __version__
|
|
15
16
|
from .models import (
|
|
17
|
+
Attachment,
|
|
18
|
+
Credentials,
|
|
16
19
|
Document,
|
|
17
20
|
DocumentSummary,
|
|
18
21
|
Page,
|
|
22
|
+
Party,
|
|
19
23
|
Signer,
|
|
20
24
|
SigningRequestResult,
|
|
21
25
|
Tag,
|
|
@@ -87,23 +91,40 @@ class AutosignlyClient:
|
|
|
87
91
|
payload = self._request("GET", "/api-key")
|
|
88
92
|
return bool(payload.get("valid", False))
|
|
89
93
|
|
|
94
|
+
def describe_credentials(self) -> Credentials:
|
|
95
|
+
"""Report which company and environment this key and secret resolve to.
|
|
96
|
+
|
|
97
|
+
Useful before a first call: it says whether the pair points at
|
|
98
|
+
production or at a sandbox, without touching any document.
|
|
99
|
+
"""
|
|
100
|
+
payload = self._request("GET", "/credentials")
|
|
101
|
+
return Credentials.from_payload(payload)
|
|
102
|
+
|
|
90
103
|
# -- documents -----------------------------------------------------------
|
|
91
104
|
|
|
92
105
|
def list_documents(
|
|
93
106
|
self,
|
|
94
107
|
*,
|
|
95
108
|
status: str | Sequence[str] | None = None,
|
|
109
|
+
tag_id: str | Sequence[str] | None = None,
|
|
96
110
|
page: int = 0,
|
|
97
111
|
size: int = 20,
|
|
98
112
|
sort: str | None = None,
|
|
99
113
|
) -> Page[DocumentSummary]:
|
|
100
|
-
"""Return one page of documents belonging to this environment.
|
|
114
|
+
"""Return one page of documents belonging to this environment.
|
|
115
|
+
|
|
116
|
+
Several tags narrow the result: a document has to carry all of them. A
|
|
117
|
+
tag that does not exist yields an empty page rather than an error.
|
|
118
|
+
"""
|
|
101
119
|
params: list[tuple[str, Any]] = [("page", page), ("size", size)]
|
|
102
120
|
if sort:
|
|
103
121
|
params.append(("sort", sort))
|
|
104
122
|
if status:
|
|
105
123
|
values = [status] if isinstance(status, str) else list(status)
|
|
106
124
|
params.extend(("status", value) for value in values)
|
|
125
|
+
if tag_id:
|
|
126
|
+
tags = [tag_id] if isinstance(tag_id, str) else list(tag_id)
|
|
127
|
+
params.extend(("tagId", tag) for tag in tags)
|
|
107
128
|
|
|
108
129
|
payload = self._request("GET", "/documents", params=params)
|
|
109
130
|
return _to_page(payload, DocumentSummary.from_payload)
|
|
@@ -112,13 +133,15 @@ class AutosignlyClient:
|
|
|
112
133
|
self,
|
|
113
134
|
*,
|
|
114
135
|
status: str | Sequence[str] | None = None,
|
|
136
|
+
tag_id: str | Sequence[str] | None = None,
|
|
115
137
|
size: int = 50,
|
|
116
138
|
sort: str | None = None,
|
|
117
139
|
) -> Iterator[DocumentSummary]:
|
|
118
140
|
"""Walk every document, fetching further pages as needed."""
|
|
119
141
|
page_number = 0
|
|
120
142
|
while True:
|
|
121
|
-
page = self.list_documents(
|
|
143
|
+
page = self.list_documents(
|
|
144
|
+
status=status, tag_id=tag_id, page=page_number, size=size, sort=sort)
|
|
122
145
|
yield from page.content
|
|
123
146
|
if not page.has_next:
|
|
124
147
|
return
|
|
@@ -143,14 +166,68 @@ class AutosignlyClient:
|
|
|
143
166
|
status_code=404,
|
|
144
167
|
)
|
|
145
168
|
|
|
146
|
-
|
|
147
|
-
response = self._http.get(document.file_url)
|
|
148
|
-
except httpx.TransportError as exc:
|
|
149
|
-
raise errors.ConnectionError(f"Could not download {document_id}: {exc}") from exc
|
|
169
|
+
return self._download(document.file_url, document_id)
|
|
150
170
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
171
|
+
def upload_pdf(self, *, pdf: bytes, document_name: str, file_name: str = "document.pdf") -> str:
|
|
172
|
+
"""Store a PDF as a document without sending it to anyone.
|
|
173
|
+
|
|
174
|
+
Returns the identifier of the created document. Use this when the
|
|
175
|
+
document needs attachments before it goes out: upload it, attach the
|
|
176
|
+
files with :meth:`add_attachment`, then call :meth:`send_for_signing`.
|
|
177
|
+
A document that has already been sent can no longer take attachments.
|
|
178
|
+
"""
|
|
179
|
+
files = {
|
|
180
|
+
"file": (file_name, pdf, "application/pdf"),
|
|
181
|
+
"request": (None, json.dumps({"documentName": document_name}), "application/json"),
|
|
182
|
+
}
|
|
183
|
+
payload = self._request("POST", "/documents", files=files)
|
|
184
|
+
return payload.get("documentId", "")
|
|
185
|
+
|
|
186
|
+
# -- attachments ---------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
def list_attachments(self, document_id: str) -> list[Attachment]:
|
|
189
|
+
"""Return the attachments of a document, in the order they will merge."""
|
|
190
|
+
payload = self._request("GET", f"/documents/{document_id}/attachments")
|
|
191
|
+
return [Attachment.from_payload(item) for item in payload or []]
|
|
192
|
+
|
|
193
|
+
def add_attachment(
|
|
194
|
+
self,
|
|
195
|
+
document_id: str,
|
|
196
|
+
*,
|
|
197
|
+
content: bytes,
|
|
198
|
+
file_name: str,
|
|
199
|
+
) -> Attachment:
|
|
200
|
+
"""Attach a file to a document that has not been sent for signing yet.
|
|
201
|
+
|
|
202
|
+
The file is converted to PDF and merged into the document when it is
|
|
203
|
+
sent, behind an index page carrying its checksum, so one signature
|
|
204
|
+
covers the document and everything attached to it. PDF, JPEG and PNG
|
|
205
|
+
are accepted, recognised from the content rather than the file name.
|
|
206
|
+
Attachments merge in the order they were added.
|
|
207
|
+
"""
|
|
208
|
+
files = {"file": (file_name, content, _content_type(file_name))}
|
|
209
|
+
payload = self._request("POST", f"/documents/{document_id}/attachments", files=files)
|
|
210
|
+
return Attachment.from_payload(payload)
|
|
211
|
+
|
|
212
|
+
def delete_attachment(self, document_id: str, attachment_id: str) -> None:
|
|
213
|
+
"""Remove an attachment from a document not yet sent for signing."""
|
|
214
|
+
self._request("DELETE", f"/documents/{document_id}/attachments/{attachment_id}")
|
|
215
|
+
|
|
216
|
+
def download_attachment(self, document_id: str, attachment_id: str) -> bytes:
|
|
217
|
+
"""Fetch one attachment converted to PDF — the rendition that gets merged."""
|
|
218
|
+
for attachment in self.list_attachments(document_id):
|
|
219
|
+
if attachment.id != attachment_id:
|
|
220
|
+
continue
|
|
221
|
+
if not attachment.file_url:
|
|
222
|
+
raise errors.NotFoundError(
|
|
223
|
+
f"Attachment {attachment_id} is not converted yet",
|
|
224
|
+
status_code=404,
|
|
225
|
+
)
|
|
226
|
+
return self._download(attachment.file_url, attachment_id)
|
|
227
|
+
raise errors.NotFoundError(
|
|
228
|
+
f"Document {document_id} has no attachment {attachment_id}",
|
|
229
|
+
status_code=404,
|
|
230
|
+
)
|
|
154
231
|
|
|
155
232
|
def send_for_signing(
|
|
156
233
|
self,
|
|
@@ -158,6 +235,7 @@ class AutosignlyClient:
|
|
|
158
235
|
*,
|
|
159
236
|
signers: Sequence[Signer] | None = None,
|
|
160
237
|
signature_type: str | None = None,
|
|
238
|
+
signature_mode: str | None = None,
|
|
161
239
|
verification_method: str | None = None,
|
|
162
240
|
initiator_email: str | None = None,
|
|
163
241
|
initiator_locale: str | None = None,
|
|
@@ -173,6 +251,8 @@ class AutosignlyClient:
|
|
|
173
251
|
body["signers"] = [signer.to_payload() for signer in signers]
|
|
174
252
|
if signature_type:
|
|
175
253
|
body["signatureType"] = signature_type
|
|
254
|
+
if signature_mode:
|
|
255
|
+
body["signatureMode"] = signature_mode
|
|
176
256
|
if verification_method:
|
|
177
257
|
body["verificationMethod"] = verification_method
|
|
178
258
|
if initiator_email:
|
|
@@ -181,7 +261,7 @@ class AutosignlyClient:
|
|
|
181
261
|
"locale": initiator_locale,
|
|
182
262
|
}
|
|
183
263
|
|
|
184
|
-
payload = self._request("POST", f"/documents/{document_id}/
|
|
264
|
+
payload = self._request("POST", f"/documents/{document_id}/signings", json_body=body)
|
|
185
265
|
return SigningRequestResult.from_payload(payload)
|
|
186
266
|
|
|
187
267
|
def upload_and_sign(
|
|
@@ -191,6 +271,7 @@ class AutosignlyClient:
|
|
|
191
271
|
document_name: str,
|
|
192
272
|
signers: Sequence[Signer],
|
|
193
273
|
signature_type: str | None = None,
|
|
274
|
+
signature_mode: str | None = None,
|
|
194
275
|
verification_method: str | None = None,
|
|
195
276
|
initiator_email: str | None = None,
|
|
196
277
|
initiator_locale: str | None = None,
|
|
@@ -207,6 +288,8 @@ class AutosignlyClient:
|
|
|
207
288
|
}
|
|
208
289
|
if signature_type:
|
|
209
290
|
request["signatureType"] = signature_type
|
|
291
|
+
if signature_mode:
|
|
292
|
+
request["signatureMode"] = signature_mode
|
|
210
293
|
if verification_method:
|
|
211
294
|
request["verificationMethod"] = verification_method
|
|
212
295
|
if initiator_email:
|
|
@@ -222,6 +305,58 @@ class AutosignlyClient:
|
|
|
222
305
|
payload = self._request("POST", "/documents/signings", files=files)
|
|
223
306
|
return payload.get("documentId", "")
|
|
224
307
|
|
|
308
|
+
# -- parties -------------------------------------------------------------
|
|
309
|
+
|
|
310
|
+
def list_parties(
|
|
311
|
+
self,
|
|
312
|
+
*,
|
|
313
|
+
name: str | None = None,
|
|
314
|
+
type: str | None = None,
|
|
315
|
+
page: int = 0,
|
|
316
|
+
size: int = 20,
|
|
317
|
+
) -> Page[Party]:
|
|
318
|
+
"""Return one page of the company parties for this environment.
|
|
319
|
+
|
|
320
|
+
``name`` matches a fragment of the name, given name, tax id, e-mail or
|
|
321
|
+
phone. ``type`` narrows the page to ``COMPANY`` or ``PERSON``. There is no sort: the searchable fields are stored
|
|
322
|
+
encrypted, so the server cannot order by them.
|
|
323
|
+
"""
|
|
324
|
+
params: list[tuple[str, Any]] = [("page", page), ("size", size)]
|
|
325
|
+
if name:
|
|
326
|
+
params.append(("name", name))
|
|
327
|
+
if type:
|
|
328
|
+
params.append(("type", type))
|
|
329
|
+
payload = self._request("GET", "/parties", params=params)
|
|
330
|
+
return _to_page(payload, Party.from_payload)
|
|
331
|
+
|
|
332
|
+
def get_party(self, party_id: str) -> Party:
|
|
333
|
+
"""Return one party. Unknown in this environment raises ``NotFoundError``."""
|
|
334
|
+
payload = self._request("GET", f"/parties/{party_id}")
|
|
335
|
+
return Party.from_payload(payload)
|
|
336
|
+
|
|
337
|
+
def create_party(self, party: Party) -> Party:
|
|
338
|
+
"""Add a party to the company in this environment.
|
|
339
|
+
|
|
340
|
+
A party with the same tax id (``COMPANY``) or e-mail (``PERSON``) is
|
|
341
|
+
rejected rather than duplicated, so this call is not safe to repeat
|
|
342
|
+
blindly — look the party up first when retrying.
|
|
343
|
+
"""
|
|
344
|
+
payload = self._request("POST", "/parties", json_body=party.to_payload())
|
|
345
|
+
return Party.from_payload(payload)
|
|
346
|
+
|
|
347
|
+
def update_party(self, party_id: str, party: Party) -> Party:
|
|
348
|
+
"""Replace the party data.
|
|
349
|
+
|
|
350
|
+
Every field is taken from ``party``, so send the whole party, not only
|
|
351
|
+
what changed.
|
|
352
|
+
"""
|
|
353
|
+
payload = self._request("PUT", f"/parties/{party_id}", json_body=party.to_payload())
|
|
354
|
+
return Party.from_payload(payload)
|
|
355
|
+
|
|
356
|
+
def delete_party(self, party_id: str) -> None:
|
|
357
|
+
"""Remove the party. Documents already signed keep their copy of the data."""
|
|
358
|
+
self._request("DELETE", f"/parties/{party_id}")
|
|
359
|
+
|
|
225
360
|
# -- tags ----------------------------------------------------------------
|
|
226
361
|
|
|
227
362
|
def list_tags(self, *, name: str | None = None, page: int = 0, size: int = 20) -> Page[Tag]:
|
|
@@ -266,6 +401,16 @@ class AutosignlyClient:
|
|
|
266
401
|
|
|
267
402
|
# -- transport -----------------------------------------------------------
|
|
268
403
|
|
|
404
|
+
def _download(self, url: str, subject: str) -> bytes:
|
|
405
|
+
try:
|
|
406
|
+
response = self._http.get(url)
|
|
407
|
+
except httpx.TransportError as exc:
|
|
408
|
+
raise errors.ConnectionError(f"Could not download {subject}: {exc}") from exc
|
|
409
|
+
|
|
410
|
+
if response.status_code >= 400:
|
|
411
|
+
raise _to_error(response)
|
|
412
|
+
return response.content
|
|
413
|
+
|
|
269
414
|
def _request(
|
|
270
415
|
self,
|
|
271
416
|
method: str,
|
|
@@ -312,6 +457,12 @@ class AutosignlyClient:
|
|
|
312
457
|
raise errors.ConnectionError(f"Could not reach {url}: {last_error}")
|
|
313
458
|
|
|
314
459
|
|
|
460
|
+
def _content_type(file_name: str) -> str:
|
|
461
|
+
"""The server detects the real format from the bytes; this is only a hint."""
|
|
462
|
+
guessed, _ = mimetypes.guess_type(file_name)
|
|
463
|
+
return guessed or "application/octet-stream"
|
|
464
|
+
|
|
465
|
+
|
|
315
466
|
def _backoff(attempt: int) -> float:
|
|
316
467
|
"""Exponential backoff with jitter.
|
|
317
468
|
|