signingflow 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 (31) hide show
  1. signingflow-0.1.0/LICENSE +21 -0
  2. signingflow-0.1.0/PKG-INFO +117 -0
  3. signingflow-0.1.0/README.md +104 -0
  4. signingflow-0.1.0/pyproject.toml +29 -0
  5. signingflow-0.1.0/setup.cfg +4 -0
  6. signingflow-0.1.0/src/signingflow/__init__.py +35 -0
  7. signingflow-0.1.0/src/signingflow/allowlist.py +29 -0
  8. signingflow-0.1.0/src/signingflow/client.py +19 -0
  9. signingflow-0.1.0/src/signingflow/clock.py +13 -0
  10. signingflow-0.1.0/src/signingflow/config.py +105 -0
  11. signingflow-0.1.0/src/signingflow/envelopes.py +279 -0
  12. signingflow-0.1.0/src/signingflow/errors.py +79 -0
  13. signingflow-0.1.0/src/signingflow/files.py +81 -0
  14. signingflow-0.1.0/src/signingflow/jail.py +94 -0
  15. signingflow-0.1.0/src/signingflow/models.py +188 -0
  16. signingflow-0.1.0/src/signingflow/pagination.py +63 -0
  17. signingflow-0.1.0/src/signingflow/path.py +139 -0
  18. signingflow-0.1.0/src/signingflow/redact.py +46 -0
  19. signingflow-0.1.0/src/signingflow/request.py +247 -0
  20. signingflow-0.1.0/src/signingflow/resources/allowlist.json +110 -0
  21. signingflow-0.1.0/src/signingflow/status.py +111 -0
  22. signingflow-0.1.0/src/signingflow/templates.py +18 -0
  23. signingflow-0.1.0/src/signingflow/version.py +4 -0
  24. signingflow-0.1.0/src/signingflow/webhook_verify.py +49 -0
  25. signingflow-0.1.0/src/signingflow/webhooks.py +147 -0
  26. signingflow-0.1.0/src/signingflow.egg-info/PKG-INFO +117 -0
  27. signingflow-0.1.0/src/signingflow.egg-info/SOURCES.txt +29 -0
  28. signingflow-0.1.0/src/signingflow.egg-info/dependency_links.txt +1 -0
  29. signingflow-0.1.0/src/signingflow.egg-info/requires.txt +3 -0
  30. signingflow-0.1.0/src/signingflow.egg-info/top_level.txt +1 -0
  31. signingflow-0.1.0/tests/test_sdk.py +796 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Signingflow
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,117 @@
1
+ Metadata-Version: 2.4
2
+ Name: signingflow
3
+ Version: 0.1.0
4
+ Summary: Official Python SDK for sending a document, reading envelope status, and verifying webhooks with the Signingflow API.
5
+ Author: Signingflow
6
+ License: MIT
7
+ Requires-Python: >=3.11
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Provides-Extra: dev
11
+ Requires-Dist: coverage>=7.6; extra == "dev"
12
+ Dynamic: license-file
13
+
14
+ # signingflow
15
+
16
+ [![CI](https://github.com/Signingflow/signingflow-python/actions/workflows/ci.yml/badge.svg)](https://github.com/Signingflow/signingflow-python/actions/workflows/ci.yml)
17
+ [![Coverage](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/Signingflow/signingflow-python/badges/coverage.json)](https://github.com/Signingflow/signingflow-python/actions/workflows/ci.yml)
18
+ [![License](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
19
+
20
+ Python SDK for the Signingflow calls used to send a document, read envelope status, and receive webhooks.
21
+
22
+ The package talks to `https://api.signingflow.com`. Keep the API key on your server. Python 3.11 or newer is required.
23
+
24
+ ```bash
25
+ pip install "signingflow @ git+https://github.com/Signingflow/signingflow-python.git"
26
+ ```
27
+
28
+ The package name is `signingflow`. Install it from [GitHub](https://github.com/Signingflow/signingflow-python) until it is published on PyPI.
29
+
30
+ ```python
31
+ import os
32
+ import signingflow
33
+
34
+ client = signingflow.Client(
35
+ os.environ["SIGNINGFLOW_API_KEY"],
36
+ workspace_id=os.environ["SIGNINGFLOW_WORKSPACE_ID"],
37
+ env="sandbox",
38
+ )
39
+
40
+ envelope = client.envelopes.send_document(
41
+ title="NDA",
42
+ file=pdf_bytes,
43
+ filename="contract.pdf",
44
+ recipients=[{"name": "Ada Lovelace", "email": "ada@example.com"}],
45
+ message="Please sign",
46
+ )
47
+ ```
48
+
49
+ `file` is the raw bytes. `b""` is an empty file. A disk path is `path`, and it is read only from `upload_root`. That directory must be one this process writes. The open refuses a symlink, a hard link, an absolute path, and a path that leaves the directory. If this runtime cannot open a path without following a symlink, the read is refused. Do not pass a path from a request field.
50
+
51
+ `env` is `sandbox` or `live`. The client always sends `X-Request-Env`. A missing environment header is treated as live by the API, so the client rejects a missing environment.
52
+
53
+ `base_url` must be `https`. The host is `api.signingflow.com` unless `allow_custom_base_url` is set. A username or password on the URL is rejected. The host comparison is case-sensitive. There is no module-level API key.
54
+
55
+ The client sends `Accept-Encoding: identity`, does not follow redirects, and stops reading once an API response passes 8 MB.
56
+
57
+ ## Send and read
58
+
59
+ `envelopes.prepare_draft` stores one PDF and creates a draft. It does not email anyone.
60
+
61
+ `envelopes.send_document` stores the PDF, creates the draft, adds the recipients to the first document, and sends. `envelopes.send_from_template` starts from `templates/{id}`.
62
+
63
+ If update or send fails, the error includes `envelope_name` for the draft that was saved. After a timeout, the client returns the envelope only when `sent_time` is within two minutes after the call started and not more than a minute ahead of this clock. An older or far-future timestamp is an error.
64
+
65
+ `envelopes.list(directory="needAction")` reads pages of 10. `page_size` can be from 1 to 1000. Omitting it selects 10. `0` is rejected. `all()` walks later pages and stops after 100 follow-up pages or a repeated token.
66
+
67
+ `envelopes.download_completed(name)` reads the envelope first. It requests the download URL only when the state is `COMPLETED`. The download call returns `uri`, `download_time`, and `etag`. This client does not GET `uri`. Treat that URL as untrusted until your application checks the host. Do not send the API key to it.
68
+
69
+ `envelopes.remind(name)` notifies people who can still sign. The call is not idempotent.
70
+
71
+ ## Idempotency
72
+
73
+ Creates and sends send a UUID `idempotencyKey` in the JSON body. The same key is stored for the whole environment. A repeat returns HTTP 409 and does not return the original resource. The SDK raises `signingflow.IdempotencyError` with `resource_name` when the error includes one. It does not treat that response as success.
74
+
75
+ Pass the same `idempotency_key` only to retry one request after a timeout. `update` and `remind` are not retried automatically. A query value that contains the API key is rejected, including when that key is percent-encoded.
76
+
77
+ ## Webhooks
78
+
79
+ ```python
80
+ destination = client.webhook_urls.create(
81
+ display_name="Envelope events",
82
+ http_request_uri="https://example.com/webhooks/signingflow",
83
+ events=["DOC_ENVELOPE_COMPLETED"],
84
+ )
85
+ secret = signingflow.signing_secret_of(destination)
86
+ ```
87
+
88
+ Store that secret from the create response. A later `retrieve` does not include it. Do not set `http_config`. `auth` `NONE` is rejected.
89
+
90
+ ```python
91
+ event = signingflow.construct_event(
92
+ raw_body,
93
+ signature_header,
94
+ secret,
95
+ previous_secret=previous_secret,
96
+ previous_signature=previous_signature,
97
+ )
98
+ event.payload
99
+ event.body_sha256
100
+ ```
101
+
102
+ `raw_body` must be the original `bytes`. The signature is lowercase hex HMAC-SHA256 of those bytes. There is no timestamp. Compare `X-Signingflow-Signature` or `X-Webhook-Signature`. During a grace window, also pass the previous header and secret. Parse JSON only after the MAC matches. `body_sha256` is the SHA-256 of those raw bytes. Store it and reject a delivery you have already seen.
103
+
104
+ ## Errors
105
+
106
+ Every SDK error is a `signingflow.Error`.
107
+
108
+ | Type | When |
109
+ | --- | --- |
110
+ | `AuthenticationError` | HTTP 401 |
111
+ | `PermissionError` | HTTP 403 |
112
+ | `IdempotencyError` | HTTP 409 whose body says the key was already used |
113
+ | `RateLimitError` | HTTP 429 |
114
+ | `InvalidRequestError` | Any other HTTP 4xx, or a rejected argument |
115
+ | `APIError` | HTTP 5xx, or a body the client will not parse |
116
+ | `ConnectionError` | The API was not reached, including a timeout |
117
+ | `SignatureVerificationError` | A webhook MAC did not match |
@@ -0,0 +1,104 @@
1
+ # signingflow
2
+
3
+ [![CI](https://github.com/Signingflow/signingflow-python/actions/workflows/ci.yml/badge.svg)](https://github.com/Signingflow/signingflow-python/actions/workflows/ci.yml)
4
+ [![Coverage](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/Signingflow/signingflow-python/badges/coverage.json)](https://github.com/Signingflow/signingflow-python/actions/workflows/ci.yml)
5
+ [![License](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
6
+
7
+ Python SDK for the Signingflow calls used to send a document, read envelope status, and receive webhooks.
8
+
9
+ The package talks to `https://api.signingflow.com`. Keep the API key on your server. Python 3.11 or newer is required.
10
+
11
+ ```bash
12
+ pip install "signingflow @ git+https://github.com/Signingflow/signingflow-python.git"
13
+ ```
14
+
15
+ The package name is `signingflow`. Install it from [GitHub](https://github.com/Signingflow/signingflow-python) until it is published on PyPI.
16
+
17
+ ```python
18
+ import os
19
+ import signingflow
20
+
21
+ client = signingflow.Client(
22
+ os.environ["SIGNINGFLOW_API_KEY"],
23
+ workspace_id=os.environ["SIGNINGFLOW_WORKSPACE_ID"],
24
+ env="sandbox",
25
+ )
26
+
27
+ envelope = client.envelopes.send_document(
28
+ title="NDA",
29
+ file=pdf_bytes,
30
+ filename="contract.pdf",
31
+ recipients=[{"name": "Ada Lovelace", "email": "ada@example.com"}],
32
+ message="Please sign",
33
+ )
34
+ ```
35
+
36
+ `file` is the raw bytes. `b""` is an empty file. A disk path is `path`, and it is read only from `upload_root`. That directory must be one this process writes. The open refuses a symlink, a hard link, an absolute path, and a path that leaves the directory. If this runtime cannot open a path without following a symlink, the read is refused. Do not pass a path from a request field.
37
+
38
+ `env` is `sandbox` or `live`. The client always sends `X-Request-Env`. A missing environment header is treated as live by the API, so the client rejects a missing environment.
39
+
40
+ `base_url` must be `https`. The host is `api.signingflow.com` unless `allow_custom_base_url` is set. A username or password on the URL is rejected. The host comparison is case-sensitive. There is no module-level API key.
41
+
42
+ The client sends `Accept-Encoding: identity`, does not follow redirects, and stops reading once an API response passes 8 MB.
43
+
44
+ ## Send and read
45
+
46
+ `envelopes.prepare_draft` stores one PDF and creates a draft. It does not email anyone.
47
+
48
+ `envelopes.send_document` stores the PDF, creates the draft, adds the recipients to the first document, and sends. `envelopes.send_from_template` starts from `templates/{id}`.
49
+
50
+ If update or send fails, the error includes `envelope_name` for the draft that was saved. After a timeout, the client returns the envelope only when `sent_time` is within two minutes after the call started and not more than a minute ahead of this clock. An older or far-future timestamp is an error.
51
+
52
+ `envelopes.list(directory="needAction")` reads pages of 10. `page_size` can be from 1 to 1000. Omitting it selects 10. `0` is rejected. `all()` walks later pages and stops after 100 follow-up pages or a repeated token.
53
+
54
+ `envelopes.download_completed(name)` reads the envelope first. It requests the download URL only when the state is `COMPLETED`. The download call returns `uri`, `download_time`, and `etag`. This client does not GET `uri`. Treat that URL as untrusted until your application checks the host. Do not send the API key to it.
55
+
56
+ `envelopes.remind(name)` notifies people who can still sign. The call is not idempotent.
57
+
58
+ ## Idempotency
59
+
60
+ Creates and sends send a UUID `idempotencyKey` in the JSON body. The same key is stored for the whole environment. A repeat returns HTTP 409 and does not return the original resource. The SDK raises `signingflow.IdempotencyError` with `resource_name` when the error includes one. It does not treat that response as success.
61
+
62
+ Pass the same `idempotency_key` only to retry one request after a timeout. `update` and `remind` are not retried automatically. A query value that contains the API key is rejected, including when that key is percent-encoded.
63
+
64
+ ## Webhooks
65
+
66
+ ```python
67
+ destination = client.webhook_urls.create(
68
+ display_name="Envelope events",
69
+ http_request_uri="https://example.com/webhooks/signingflow",
70
+ events=["DOC_ENVELOPE_COMPLETED"],
71
+ )
72
+ secret = signingflow.signing_secret_of(destination)
73
+ ```
74
+
75
+ Store that secret from the create response. A later `retrieve` does not include it. Do not set `http_config`. `auth` `NONE` is rejected.
76
+
77
+ ```python
78
+ event = signingflow.construct_event(
79
+ raw_body,
80
+ signature_header,
81
+ secret,
82
+ previous_secret=previous_secret,
83
+ previous_signature=previous_signature,
84
+ )
85
+ event.payload
86
+ event.body_sha256
87
+ ```
88
+
89
+ `raw_body` must be the original `bytes`. The signature is lowercase hex HMAC-SHA256 of those bytes. There is no timestamp. Compare `X-Signingflow-Signature` or `X-Webhook-Signature`. During a grace window, also pass the previous header and secret. Parse JSON only after the MAC matches. `body_sha256` is the SHA-256 of those raw bytes. Store it and reject a delivery you have already seen.
90
+
91
+ ## Errors
92
+
93
+ Every SDK error is a `signingflow.Error`.
94
+
95
+ | Type | When |
96
+ | --- | --- |
97
+ | `AuthenticationError` | HTTP 401 |
98
+ | `PermissionError` | HTTP 403 |
99
+ | `IdempotencyError` | HTTP 409 whose body says the key was already used |
100
+ | `RateLimitError` | HTTP 429 |
101
+ | `InvalidRequestError` | Any other HTTP 4xx, or a rejected argument |
102
+ | `APIError` | HTTP 5xx, or a body the client will not parse |
103
+ | `ConnectionError` | The API was not reached, including a timeout |
104
+ | `SignatureVerificationError` | A webhook MAC did not match |
@@ -0,0 +1,29 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "signingflow"
7
+ version = "0.1.0"
8
+ description = "Official Python SDK for sending a document, reading envelope status, and verifying webhooks with the Signingflow API."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Signingflow" }]
13
+ dependencies = []
14
+
15
+ [project.optional-dependencies]
16
+ dev = ["coverage>=7.6"]
17
+
18
+ [tool.setuptools.packages.find]
19
+ where = ["src"]
20
+
21
+ [tool.setuptools.package-data]
22
+ signingflow = ["resources/allowlist.json"]
23
+
24
+ [tool.coverage.run]
25
+ branch = true
26
+ source = ["signingflow"]
27
+
28
+ [tool.coverage.report]
29
+ show_missing = true
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,35 @@
1
+ """Official Python SDK for the Signingflow calls used by skills and MCP tools."""
2
+
3
+ from signingflow.client import Client
4
+ from signingflow.errors import (
5
+ APIError,
6
+ AuthenticationError,
7
+ ConnectionError,
8
+ Error,
9
+ IdempotencyError,
10
+ InvalidRequestError,
11
+ PermissionError,
12
+ RateLimitError,
13
+ SignatureVerificationError,
14
+ )
15
+ from signingflow.version import VERSION
16
+ from signingflow.webhook_verify import Event, construct_event
17
+ from signingflow.webhooks import HTTPConfig, signing_secret_of
18
+
19
+ __all__ = [
20
+ "VERSION",
21
+ "APIError",
22
+ "AuthenticationError",
23
+ "Client",
24
+ "ConnectionError",
25
+ "Error",
26
+ "Event",
27
+ "HTTPConfig",
28
+ "IdempotencyError",
29
+ "InvalidRequestError",
30
+ "PermissionError",
31
+ "RateLimitError",
32
+ "SignatureVerificationError",
33
+ "construct_event",
34
+ "signing_secret_of",
35
+ ]
@@ -0,0 +1,29 @@
1
+ """The 15 operations used by skills and MCP tools."""
2
+
3
+ import json
4
+
5
+ from importlib.resources import files
6
+
7
+ from signingflow.errors import Error
8
+
9
+
10
+ def _load():
11
+ try:
12
+ raw = files("signingflow").joinpath("resources/allowlist.json").read_text(encoding="utf-8")
13
+ parsed = json.loads(raw)
14
+ operations = parsed["operations"]
15
+ if len(operations) != 15:
16
+ raise ValueError("allowlist length")
17
+ return {item["operationId"]: item for item in operations}
18
+ except (OSError, json.JSONDecodeError, KeyError, TypeError, ValueError) as exc:
19
+ raise RuntimeError("allowlist.json is invalid") from exc
20
+
21
+
22
+ OPERATIONS = _load()
23
+
24
+
25
+ def operation(operation_id):
26
+ try:
27
+ return OPERATIONS[operation_id]
28
+ except KeyError as exc:
29
+ raise Error(f"Unknown operation {operation_id}.") from exc
@@ -0,0 +1,19 @@
1
+ """The instance client. There is no module-level API key."""
2
+
3
+ from signingflow.config import Config
4
+ from signingflow.envelopes import Envelopes
5
+ from signingflow.files import Files
6
+ from signingflow.request import Sender
7
+ from signingflow.templates import Templates
8
+ from signingflow.webhooks import WebhookDeliveries, WebhookURLs
9
+
10
+
11
+ class Client:
12
+ def __init__(self, api_key, **options):
13
+ self.config = Config(api_key, **options)
14
+ sender = Sender(self.config)
15
+ self.files = Files(sender)
16
+ self.templates = Templates(sender)
17
+ self.envelopes = Envelopes(sender, self.files, self.templates)
18
+ self.webhook_urls = WebhookURLs(sender)
19
+ self.webhook_deliveries = WebhookDeliveries(sender)
@@ -0,0 +1,13 @@
1
+ """Wall clock and sleep. Tests replace these."""
2
+
3
+ import time
4
+ from datetime import datetime, timezone
5
+
6
+
7
+ def now():
8
+ return datetime.now(timezone.utc)
9
+
10
+
11
+ def sleep(seconds):
12
+ if seconds > 0:
13
+ time.sleep(seconds)
@@ -0,0 +1,105 @@
1
+ """Client settings. The API key stays on the instance."""
2
+
3
+ from urllib.parse import urlsplit
4
+
5
+ OFFICIAL_HOST = "api.signingflow.com"
6
+ DEFAULT_BASE_URL = "https://api.signingflow.com"
7
+ DEFAULT_TIMEOUT = 30
8
+ DEFAULT_RETRIES = 2
9
+ DEFAULT_UPLOAD_HOSTS = ("storage.googleapis.com",)
10
+
11
+
12
+ class Config:
13
+ def __init__(
14
+ self,
15
+ api_key,
16
+ *,
17
+ workspace_id,
18
+ env,
19
+ base_url=None,
20
+ allow_custom_base_url=False,
21
+ timeout=None,
22
+ max_network_retries=None,
23
+ channel=None,
24
+ funnel=None,
25
+ upload_hosts=None,
26
+ upload_root=None,
27
+ ssl_context=None,
28
+ ):
29
+ from signingflow.errors import Error, invalid
30
+
31
+ self.api_key = (api_key or "").strip()
32
+ if not self.api_key:
33
+ raise Error("API key is required.")
34
+ self.workspace_id = (workspace_id or "").strip()
35
+ if not self.workspace_id:
36
+ raise Error("workspace_id is required.")
37
+ self.wire_env = _normalize_env(env)
38
+ base = (base_url or "").strip() or DEFAULT_BASE_URL
39
+ base = base.rstrip("/")
40
+ parsed = urlsplit(base)
41
+ if not parsed.scheme or not parsed.netloc:
42
+ raise Error("base_url must be an absolute URL.")
43
+ if parsed.scheme != "https":
44
+ raise Error("base_url must be https.")
45
+ if parsed.username is not None or parsed.password is not None:
46
+ raise Error("base_url must not include a username or password.")
47
+ if "\x00" in base:
48
+ raise Error("base_url must be an absolute URL.")
49
+ raw_host = _raw_host(parsed.netloc)
50
+ if raw_host != OFFICIAL_HOST and not allow_custom_base_url:
51
+ raise Error("base_url host must be api.signingflow.com, or set allow_custom_base_url.")
52
+ self.base_url = base
53
+ self.allow_custom_base_url = bool(allow_custom_base_url)
54
+ self.timeout = DEFAULT_TIMEOUT if timeout is None else timeout
55
+ if not isinstance(self.timeout, (int, float)) or isinstance(self.timeout, bool) or self.timeout <= 0:
56
+ raise Error("timeout must be positive.")
57
+ self.retries = DEFAULT_RETRIES if max_network_retries is None else max_network_retries
58
+ if isinstance(self.retries, bool) or not isinstance(self.retries, int) or self.retries < 0:
59
+ raise Error("max_network_retries must be a whole number of zero or more.")
60
+ self.upload_hosts = _upload_hosts(upload_hosts)
61
+ self.channel = (channel or "").strip()
62
+ self.funnel = (funnel or "").strip()
63
+ self.upload_root = (upload_root or "").strip()
64
+ self.ssl_context = ssl_context
65
+ if any(bad_header(value) for value in (self.api_key, self.workspace_id, self.channel, self.funnel)):
66
+ raise invalid("invalid header value")
67
+
68
+
69
+ def bad_header(value):
70
+ return "\r" in value or "\n" in value
71
+
72
+
73
+ def _normalize_env(env):
74
+ from signingflow.errors import Error
75
+
76
+ if env in ("sandbox", "SANDBOX"):
77
+ return "SANDBOX"
78
+ if env in ("live", "LIVE"):
79
+ return "LIVE"
80
+ raise Error("env must be sandbox or live.")
81
+
82
+
83
+ def _raw_host(netloc):
84
+ host = netloc.rsplit("@", 1)[-1]
85
+ if host.startswith("["):
86
+ end = host.find("]")
87
+ return host[: end + 1] if end != -1 else host
88
+ return host.split(":", 1)[0]
89
+
90
+
91
+ def _upload_hosts(hosts):
92
+ from signingflow.errors import Error
93
+
94
+ if hosts is None:
95
+ hosts = DEFAULT_UPLOAD_HOSTS
96
+ cleaned = []
97
+ for host in hosts:
98
+ host = str(host).strip().lower()
99
+ if not host:
100
+ continue
101
+ dots = host.count(".")
102
+ if dots < 2 or host.startswith(".") or host.endswith(".") or ".." in host:
103
+ raise Error("Each upload_hosts entry must be a specific host, not a public suffix.")
104
+ cleaned.append(host)
105
+ return tuple(cleaned)